AnnouncementService.php 16 KB

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