AnnouncementService.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. <?php
  2. namespace App\Services;
  3. use App\Constants\Util;
  4. use App\Support\AnnouncementCatalog as Catalog;
  5. use App\Support\LocalizedContent;
  6. use Carbon\Carbon;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\Validator;
  9. use Illuminate\Validation\Rule;
  10. use Illuminate\Validation\ValidationException;
  11. class AnnouncementService
  12. {
  13. private const FIELDS = ['category', 'publish_platforms', 'display_frequency',
  14. 'publish_at', 'end_at', 'status', 'click_type', 'link', 'activity_id'];
  15. public function options(): array
  16. {
  17. return [
  18. 'categories' => Catalog::options(Catalog::CATEGORIES),
  19. 'languages' => Catalog::options(LocalizedContent::LANGUAGES),
  20. 'platforms' => Catalog::options(Catalog::PLATFORMS),
  21. 'display_frequencies' => Catalog::options(Catalog::FREQUENCIES),
  22. 'click_types' => Catalog::options(Catalog::CLICKS),
  23. 'statuses' => [['value' => 0, 'label' => '关闭'], ['value' => 1, 'label' => '开启']],
  24. 'timezone' => Catalog::TIMEZONE,
  25. 'defaults' => ['category' => 'content', 'publish_platforms' => array_keys(Catalog::PLATFORMS),
  26. 'display_frequency' => 'daily_first_login', 'publish_immediately' => true, 'no_end' => true,
  27. 'status' => 1, 'click_type' => 'none'],
  28. ];
  29. }
  30. public function listing(array $input): array
  31. {
  32. $params = Validator::make($input, $this->pageRules() + $this->dateRangeRules() + [
  33. 'category' => ['nullable', Rule::in(array_keys(Catalog::CATEGORIES))],
  34. 'platform' => ['nullable', Rule::in(array_keys(Catalog::PLATFORMS))],
  35. 'status' => ['nullable', 'integer', Rule::in([0, 1])],
  36. 'title' => ['nullable', 'string', 'max:200'],
  37. 'language' => ['nullable', Rule::in(array_keys(LocalizedContent::LANGUAGES))],
  38. ])->validate();
  39. $query = DB::table('announcements');
  40. foreach (['category', 'status'] as $key) {
  41. if (isset($params[$key]) && $params[$key] !== '') $query->where($key, $params[$key]);
  42. }
  43. if (!empty($params['language']) || (isset($params['title']) && $params['title'] !== '')) {
  44. $query->whereExists(function ($q) use ($params) {
  45. $q->selectRaw('1')->from('announcement_translations as t')->whereColumn('t.announcement_id', 'announcements.id');
  46. if (!empty($params['language'])) $q->where('t.language', $params['language']);
  47. if (isset($params['title']) && $params['title'] !== '') $q->where('t.title', 'like', '%' . $params['title'] . '%');
  48. });
  49. }
  50. if (!empty($params['platform'])) {
  51. if (DB::connection()->getDriverName() === 'sqlite') {
  52. $query->whereRaw('EXISTS (SELECT 1 FROM json_each(publish_platforms) WHERE json_each.value = ?)', [$params['platform']]);
  53. } else $query->whereJsonContains('publish_platforms', $params['platform']);
  54. }
  55. $this->dateRange($query, 'publish_at', $params);
  56. $total = (clone $query)->count();
  57. $rows = $query->orderByDesc('publish_at')->orderByDesc('id')
  58. ->forPage((int) ($params['page'] ?? 1), (int) ($params['limit'] ?? 20))->get();
  59. $translations = [];
  60. if ($rows->isNotEmpty()) foreach (DB::table('announcement_translations')->whereIn('announcement_id', $rows->pluck('id')->all())->get(['announcement_id', 'language', 'title']) as $text) {
  61. $translations[$text->announcement_id][$text->language] = ['title' => $text->title];
  62. }
  63. return ['total' => $total, 'data' => $rows->map(fn ($row) => $this->dto((array) $row + ['translations' => $translations[$row->id] ?? []], false))->all()];
  64. }
  65. public function detail(int $id): array
  66. {
  67. $row = DB::table('announcements')->where('id', $id)->first();
  68. if (!$row) $this->invalid('id', '公告不存在');
  69. return $this->dto((array) $row);
  70. }
  71. public function save(array $input): array
  72. {
  73. foreach (['sender_id', 'sender_name', 'sender_display', 'title', 'content'] as $field) {
  74. if (array_key_exists($field, $input)) $this->invalid($field, '发送者不可指定;标题与内容请使用translations提交');
  75. }
  76. $params = Validator::make($input, [
  77. 'id' => ['nullable', 'integer', 'min:1'],
  78. 'category' => ['sometimes', 'required', Rule::in(array_keys(Catalog::CATEGORIES))],
  79. 'translations' => ['sometimes', 'required', 'array'],
  80. 'publish_platforms' => ['sometimes', 'required', 'array', 'min:1', 'max:3'],
  81. 'publish_platforms.*' => ['required', 'string', 'distinct', Rule::in(array_keys(Catalog::PLATFORMS))],
  82. 'display_frequency' => ['sometimes', 'required', Rule::in(array_keys(Catalog::FREQUENCIES))],
  83. 'publish_immediately' => ['sometimes', 'boolean'],
  84. 'publish_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
  85. 'no_end' => ['sometimes', 'boolean'],
  86. 'end_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
  87. 'status' => ['sometimes', 'integer', Rule::in([0, 1])],
  88. 'click_type' => ['sometimes', 'required', Rule::in(array_keys(Catalog::CLICKS))],
  89. 'link' => ['nullable', 'string', 'max:2048'],
  90. 'activity_id' => ['nullable', 'integer', 'min:1'],
  91. ])->validate();
  92. return DB::transaction(function () use ($params) {
  93. $now = $this->now();
  94. $before = [];
  95. $id = $params['id'] ?? null;
  96. if ($id !== null) {
  97. $found = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
  98. if (!$found) $this->invalid('id', '公告不存在');
  99. $before = $this->snapshot((int) $id);
  100. }
  101. $row = $before ?: ['category' => 'content', 'publish_platforms' => array_keys(Catalog::PLATFORMS),
  102. 'display_frequency' => 'daily_first_login', 'publish_at' => $now, 'end_at' => null,
  103. 'status' => 1, 'click_type' => 'none', 'link' => null, 'activity_id' => null];
  104. foreach (self::FIELDS as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
  105. if (array_key_exists('translations', $params)) $row['translations'] = LocalizedContent::parse($params['translations'], 200, 20000);
  106. if (empty($row['translations'])) $this->invalid('translations', '至少填写一个语言的标题和正文');
  107. if ($before && $row['category'] !== $before['category'] && !array_key_exists('translations', $params)) {
  108. $this->invalid('translations', '切换公告分类时请重新填写多语言内容');
  109. }
  110. $row['publish_platforms'] = array_values(array_intersect(array_keys(Catalog::PLATFORMS), $row['publish_platforms']));
  111. if (array_key_exists('publish_immediately', $params) && $params['publish_immediately']) {
  112. $row['publish_at'] = $before && $before['publish_at'] <= $now ? $before['publish_at'] : $now;
  113. } elseif (($params['publish_immediately'] ?? null) === false || ($params['publish_immediately'] ?? null) === 0 || ($params['publish_immediately'] ?? null) === '0') {
  114. if (!$row['publish_at'] || (!$before && empty($params['publish_at']))) $this->invalid('publish_at', '定时发布必须填写发布时间');
  115. }
  116. if (!$row['publish_at']) $this->invalid('publish_at', '请填写发布时间');
  117. if (array_key_exists('no_end', $params)) {
  118. if ($params['no_end']) $row['end_at'] = null;
  119. elseif (!$row['end_at']) $this->invalid('end_at', '请填写结束时间');
  120. }
  121. if ($row['end_at'] !== null && $row['end_at'] <= $row['publish_at']) $this->invalid('end_at', '结束时间必须晚于发布时间');
  122. if ($row['category'] === 'image') {
  123. foreach ($row['translations'] as $language => &$block) {
  124. if (mb_strlen($block['content']) > 2048 || !$this->imageAddress($block['content'])) $this->invalid('translations.' . $language . '.content', '图片公告请使用上传成功的jpg/jpeg/png/webp图片地址');
  125. $block['content'] = Util::replacePartInUrl($block['content']);
  126. }
  127. unset($block);
  128. }
  129. if (in_array($row['click_type'], ['external', 'internal'], true)) {
  130. if (!$this->validLink((string) $row['link'], $row['click_type'])) $this->invalid('link', '请填写有效的外站链接或站内相对路径');
  131. $row['activity_id'] = null;
  132. } elseif ($row['click_type'] === 'activity') {
  133. if (empty($row['activity_id']) || !DB::table('activity_rewards')->where('id', $row['activity_id'])->exists()) $this->invalid('activity_id', '请选择存在的优惠活动');
  134. $row['link'] = null;
  135. } else { $row['link'] = null; $row['activity_id'] = null; }
  136. $row['status'] = (int) $row['status'];
  137. $row['activity_id'] = $row['activity_id'] === null ? null : (int) $row['activity_id'];
  138. $actor = OperationAuditService::actor();
  139. if (!$actor['operator_id'] || $actor['operator_name'] === '') $this->invalid('sender_id', '请使用后台管理员身份');
  140. $values = array_intersect_key($row, array_flip(self::FIELDS));
  141. $values['publish_platforms'] = json_encode($values['publish_platforms'], JSON_THROW_ON_ERROR);
  142. if (!$before) {
  143. $id = DB::table('announcements')->insertGetId($values + ['sender_id' => $actor['operator_id'],
  144. 'sender_name' => $actor['operator_name'], 'created_at' => $now, 'updated_at' => $now]);
  145. } else {
  146. $changed = false;
  147. foreach (array_merge(self::FIELDS, ['translations']) as $field) if ($row[$field] !== $before[$field]) { $changed = true; break; }
  148. if (!$changed) return $this->detail((int) $id);
  149. DB::table('announcements')->where('id', $id)->update($values + ['updated_at' => $now]);
  150. }
  151. DB::table('announcement_translations')->where('announcement_id', $id)->delete();
  152. foreach ($row['translations'] as $language => $block) DB::table('announcement_translations')->insert($block + ['announcement_id' => $id, 'language' => $language]);
  153. $after = $this->snapshot((int) $id);
  154. OperationAuditService::record('announcement', (int) $id, $before ? 'update' : 'create', $before, $after, $now);
  155. return $this->dto($after);
  156. });
  157. }
  158. public function status(int $id, int $status): array
  159. {
  160. Validator::make(compact('id', 'status'), ['id' => ['required', 'integer', 'min:1'], 'status' => ['required', Rule::in([0, 1])]])->validate();
  161. return DB::transaction(function () use ($id, $status) {
  162. $found = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
  163. if (!$found) $this->invalid('id', '公告不存在');
  164. $before = $this->snapshot((int) $id);
  165. if ($before['status'] === $status) return $this->dto($before);
  166. $now = $this->now();
  167. DB::table('announcements')->where('id', $id)->update(['status' => $status, 'updated_at' => $now]);
  168. $after = $this->snapshot($id);
  169. OperationAuditService::record('announcement', $id, 'status', $before, $after, $now);
  170. return $this->dto($after);
  171. });
  172. }
  173. public function logs(array $input): array
  174. {
  175. if (isset($input['announcement_id'])) $input['resource_id'] = $input['announcement_id'];
  176. return LocalizedAuditService::listing('announcement', $input);
  177. }
  178. private function translations(int $id): array
  179. {
  180. $rows = DB::table('announcement_translations')->where('announcement_id', $id)->get()->keyBy('language');
  181. $result = [];
  182. foreach (LocalizedContent::LANGUAGES as $language => $_) if (isset($rows[$language])) $result[$language] = ['title' => $rows[$language]->title, 'content' => $rows[$language]->content];
  183. return $result;
  184. }
  185. private function snapshot(int $id): array
  186. {
  187. $row = $this->normalize((array) DB::table('announcements')->where('id', $id)->first());
  188. $row['translations'] = $this->translations($id);
  189. return $row;
  190. }
  191. private function dto(array $row, bool $full = true): array
  192. {
  193. $row = $this->normalize($row);
  194. $translations = $row['translations'] ?? $this->translations((int) $row['id']);
  195. unset($row['translations']);
  196. $row['languages'] = array_keys($translations);
  197. $row['titles'] = (object) array_map(fn ($b) => $b['title'], $translations);
  198. $now = $this->now();
  199. $row['category_name'] = Catalog::CATEGORIES[$row['category']];
  200. $row['platform_label'] = count($row['publish_platforms']) === 3 ? '全部' : implode('、', array_map(fn ($p) => Catalog::PLATFORMS[$p], $row['publish_platforms']));
  201. $row['display_frequency_name'] = Catalog::FREQUENCIES[$row['display_frequency']];
  202. $row['sender_display'] = '批核者:' . $row['sender_name'];
  203. $row['publication_state'] = Catalog::state($row, $now);
  204. $row['publication_state_name'] = Catalog::STATES[$row['publication_state']];
  205. $row['effective_status'] = $row['publication_state'] === 'active' ? 1 : 0;
  206. $row['publish_immediately'] = $row['publish_at'] <= $now;
  207. $row['no_end'] = $row['end_at'] === null;
  208. if ($full) {
  209. if ($row['category'] === 'image') foreach ($translations as &$block) $block['content'] = Util::ensureUrl($block['content']);
  210. unset($block); $row['translations'] = $translations;
  211. }
  212. return $row;
  213. }
  214. private function normalize(array $row): array
  215. {
  216. if (isset($row['publish_platforms']) && is_string($row['publish_platforms'])) $row['publish_platforms'] = json_decode($row['publish_platforms'], true, 512, JSON_THROW_ON_ERROR);
  217. foreach (['id', 'sender_id', 'status'] as $field) if (isset($row[$field])) $row[$field] = (int) $row[$field];
  218. if (isset($row['activity_id'])) $row['activity_id'] = (int) $row['activity_id'];
  219. return $row;
  220. }
  221. private function imageAddress(string $value): bool
  222. {
  223. if (!$this->validLink($value, str_starts_with($value, '/') ? 'internal' : 'external')) return false;
  224. return (bool) preg_match('/\.(?:jpe?g|png|webp)$/i', (string) parse_url($value, PHP_URL_PATH));
  225. }
  226. private function validLink(string $value, string $type): bool
  227. {
  228. if ($value === '' || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) return false;
  229. if ($type === 'internal') return str_starts_with($value, '/') && !str_starts_with($value, '//');
  230. $parts = parse_url($value);
  231. return filter_var($value, FILTER_VALIDATE_URL) && $parts && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
  232. && !isset($parts['user']) && !isset($parts['pass']);
  233. }
  234. private function pageRules(): array { return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']]; }
  235. private function dateRangeRules(): array { return [
  236. 'start_date' => ['nullable', 'required_with:end_date', 'date_format:Y-m-d'],
  237. 'end_date' => ['nullable', 'required_with:start_date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
  238. ]; }
  239. private function dateRange($query, string $field, array $params): void
  240. {
  241. if (!empty($params['start_date'])) $query->where($field, '>=', $params['start_date'] . ' 00:00:00')->where($field, '<=', $params['end_date'] . ' 23:59:59');
  242. }
  243. private function now(): string { return Carbon::now(Catalog::TIMEZONE)->format('Y-m-d H:i:s'); }
  244. private function invalid(string $key, string $message): void { throw ValidationException::withMessages([$key => $message]); }
  245. }