AnnouncementService.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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'];
  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. ], ['click_type.in' => '点击方式仅支持无链接、外站链接、站内路径'])->validate();
  91. return DB::transaction(function () use ($params) {
  92. $now = $this->now();
  93. $before = [];
  94. $id = $params['id'] ?? null;
  95. if ($id !== null) {
  96. $found = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
  97. if (!$found) $this->invalid('id', '公告不存在');
  98. $before = $this->snapshot((int) $id);
  99. }
  100. $row = $before ?: ['category' => 'content', 'publish_platforms' => array_keys(Catalog::PLATFORMS),
  101. 'display_frequency' => 'daily_first_login', 'publish_at' => $now, 'end_at' => null,
  102. 'status' => 1, 'click_type' => 'none', 'link' => null];
  103. foreach (self::FIELDS as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
  104. if (array_key_exists('translations', $params)) $row['translations'] = LocalizedContent::parse($params['translations'], 200, 20000);
  105. if (empty($row['translations'])) $this->invalid('translations', '至少填写一个语言的标题和正文');
  106. if ($before && $row['category'] !== $before['category'] && !array_key_exists('translations', $params)) {
  107. $this->invalid('translations', '切换公告分类时请重新填写多语言内容');
  108. }
  109. $row['publish_platforms'] = array_values(array_intersect(array_keys(Catalog::PLATFORMS), $row['publish_platforms']));
  110. if (array_key_exists('publish_immediately', $params) && $params['publish_immediately']) {
  111. $row['publish_at'] = $before && $before['publish_at'] <= $now ? $before['publish_at'] : $now;
  112. } elseif (($params['publish_immediately'] ?? null) === false || ($params['publish_immediately'] ?? null) === 0 || ($params['publish_immediately'] ?? null) === '0') {
  113. if (!$row['publish_at'] || (!$before && empty($params['publish_at']))) $this->invalid('publish_at', '定时发布必须填写发布时间');
  114. }
  115. if (!$row['publish_at']) $this->invalid('publish_at', '请填写发布时间');
  116. if (array_key_exists('no_end', $params)) {
  117. if ($params['no_end']) $row['end_at'] = null;
  118. elseif (!$row['end_at']) $this->invalid('end_at', '请填写结束时间');
  119. }
  120. if ($row['end_at'] !== null && $row['end_at'] <= $row['publish_at']) $this->invalid('end_at', '结束时间必须晚于发布时间');
  121. if ($row['category'] === 'image') {
  122. foreach ($row['translations'] as $language => &$block) {
  123. if (mb_strlen($block['content']) > 2048 || !$this->imageAddress($block['content'])) $this->invalid('translations.' . $language . '.content', '图片公告请使用上传成功的jpg/jpeg/png/webp图片地址');
  124. $block['content'] = Util::replacePartInUrl($block['content']);
  125. }
  126. unset($block);
  127. }
  128. if (in_array($row['click_type'], ['external', 'internal'], true)) {
  129. if (!$this->validLink((string) $row['link'], $row['click_type'])) $this->invalid('link', '请填写有效的外站链接或站内相对路径');
  130. } else { $row['link'] = null; }
  131. $row['status'] = (int) $row['status'];
  132. $actor = OperationAuditService::actor();
  133. if (!$actor['operator_id'] || $actor['operator_name'] === '') $this->invalid('sender_id', '请使用后台管理员身份');
  134. $values = array_intersect_key($row, array_flip(self::FIELDS));
  135. $values['publish_platforms'] = json_encode($values['publish_platforms'], JSON_THROW_ON_ERROR);
  136. if (!$before) {
  137. $id = DB::table('announcements')->insertGetId($values + ['sender_id' => $actor['operator_id'],
  138. 'sender_name' => $actor['operator_name'], 'created_at' => $now, 'updated_at' => $now]);
  139. } else {
  140. $changed = false;
  141. foreach (array_merge(self::FIELDS, ['translations']) as $field) if ($row[$field] !== $before[$field]) { $changed = true; break; }
  142. if (!$changed) return $this->detail((int) $id);
  143. DB::table('announcements')->where('id', $id)->update($values + ['updated_at' => $now]);
  144. }
  145. DB::table('announcement_translations')->where('announcement_id', $id)->delete();
  146. foreach ($row['translations'] as $language => $block) DB::table('announcement_translations')->insert($block + ['announcement_id' => $id, 'language' => $language]);
  147. $after = $this->snapshot((int) $id);
  148. OperationAuditService::record('announcement', (int) $id, $before ? 'update' : 'create', $before, $after, $now);
  149. SiteRealtimeService::changed('announcement', (int) $id, $before ? 'update' : 'create',
  150. $row['status'] ? ['start' => $row['publish_at'], 'end' => $row['end_at']] : [],
  151. ($before && Catalog::state($before, $now) === 'active') || Catalog::state($after, $now) === 'active');
  152. return $this->dto($after);
  153. });
  154. }
  155. public function status(int $id, int $status): array
  156. {
  157. Validator::make(compact('id', 'status'), ['id' => ['required', 'integer', 'min:1'], 'status' => ['required', Rule::in([0, 1])]])->validate();
  158. return DB::transaction(function () use ($id, $status) {
  159. $found = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
  160. if (!$found) $this->invalid('id', '公告不存在');
  161. $before = $this->snapshot((int) $id);
  162. if ($before['status'] === $status) return $this->dto($before);
  163. $now = $this->now();
  164. DB::table('announcements')->where('id', $id)->update(['status' => $status, 'updated_at' => $now]);
  165. $after = $this->snapshot($id);
  166. OperationAuditService::record('announcement', $id, 'status', $before, $after, $now);
  167. SiteRealtimeService::changed('announcement', $id, 'status',
  168. $status ? ['start' => $after['publish_at'], 'end' => $after['end_at']] : [],
  169. Catalog::state($before, $now) === 'active' || Catalog::state($after, $now) === 'active');
  170. return $this->dto($after);
  171. });
  172. }
  173. public function delete(int $id): array
  174. {
  175. Validator::make(compact('id'), ['id' => ['required', 'integer', 'min:1']])->validate();
  176. return DB::transaction(function () use ($id) {
  177. $row = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
  178. if (!$row) $this->invalid('id', '公告不存在');
  179. $before = $this->snapshot($id); $now = $this->now();
  180. DB::table('announcement_translations')->where('announcement_id', $id)->delete();
  181. DB::table('announcements')->where('id', $id)->delete();
  182. OperationAuditService::record('announcement', $id, 'delete', $before, [], $now);
  183. SiteRealtimeService::changed('announcement', $id, 'delete', [], false);
  184. return ['id' => $id, 'deleted' => true];
  185. });
  186. }
  187. public function logs(array $input): array
  188. {
  189. if (isset($input['announcement_id'])) $input['resource_id'] = $input['announcement_id'];
  190. return LocalizedAuditService::listing('announcement', $input);
  191. }
  192. private function translations(int $id): array
  193. {
  194. $rows = DB::table('announcement_translations')->where('announcement_id', $id)->get()->keyBy('language');
  195. $result = [];
  196. foreach (LocalizedContent::LANGUAGES as $language => $_) if (isset($rows[$language])) $result[$language] = ['title' => $rows[$language]->title, 'content' => $rows[$language]->content];
  197. return $result;
  198. }
  199. private function snapshot(int $id): array
  200. {
  201. $row = $this->normalize((array) DB::table('announcements')->where('id', $id)->first());
  202. $row['translations'] = $this->translations($id);
  203. return $row;
  204. }
  205. private function dto(array $row, bool $full = true): array
  206. {
  207. $row = $this->normalize($row);
  208. $translations = $row['translations'] ?? $this->translations((int) $row['id']);
  209. unset($row['translations']);
  210. $row['languages'] = array_keys($translations);
  211. $row['titles'] = (object) array_map(fn ($b) => $b['title'], $translations);
  212. $now = $this->now();
  213. $row['category_name'] = Catalog::CATEGORIES[$row['category']];
  214. $row['platform_label'] = count($row['publish_platforms']) === 3 ? '全部' : implode('、', array_map(fn ($p) => Catalog::PLATFORMS[$p], $row['publish_platforms']));
  215. $row['display_frequency_name'] = Catalog::FREQUENCIES[$row['display_frequency']];
  216. $row['sender_display'] = '批核者:' . $row['sender_name'];
  217. $row['publication_state'] = Catalog::state($row, $now);
  218. $row['publication_state_name'] = Catalog::STATES[$row['publication_state']];
  219. $row['effective_status'] = $row['publication_state'] === 'active' ? 1 : 0;
  220. $row['publish_immediately'] = $row['publish_at'] <= $now;
  221. $row['no_end'] = $row['end_at'] === null;
  222. if ($full) {
  223. if ($row['category'] === 'image') foreach ($translations as &$block) $block['content'] = Util::ensureUrl($block['content']);
  224. unset($block); $row['translations'] = $translations;
  225. }
  226. return $row;
  227. }
  228. private function normalize(array $row): array
  229. {
  230. if (isset($row['publish_platforms']) && is_string($row['publish_platforms'])) $row['publish_platforms'] = json_decode($row['publish_platforms'], true, 512, JSON_THROW_ON_ERROR);
  231. foreach (['id', 'sender_id', 'status'] as $field) if (isset($row[$field])) $row[$field] = (int) $row[$field];
  232. unset($row['activity_id']);
  233. if (($row['click_type'] ?? 'none') === 'activity') { $row['click_type'] = 'none'; $row['link'] = null; }
  234. return $row;
  235. }
  236. private function imageAddress(string $value): bool
  237. {
  238. if (!$this->validLink($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 validLink(string $value, string $type): bool
  242. {
  243. if ($value === '' || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) return false;
  244. if ($type === 'internal') return str_starts_with($value, '/') && !str_starts_with($value, '//');
  245. $parts = parse_url($value);
  246. return filter_var($value, FILTER_VALIDATE_URL) && $parts && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
  247. && !isset($parts['user']) && !isset($parts['pass']);
  248. }
  249. private function pageRules(): array { return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']]; }
  250. private function dateRangeRules(): array { return [
  251. 'start_date' => ['nullable', 'required_with:end_date', 'date_format:Y-m-d'],
  252. 'end_date' => ['nullable', 'required_with:start_date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
  253. ]; }
  254. private function dateRange($query, string $field, array $params): void
  255. {
  256. if (!empty($params['start_date'])) $query->where($field, '>=', $params['start_date'] . ' 00:00:00')->where($field, '<=', $params['end_date'] . ' 23:59:59');
  257. }
  258. private function now(): string { return Carbon::now(Catalog::TIMEZONE)->format('Y-m-d H:i:s'); }
  259. private function invalid(string $key, string $message): void { throw ValidationException::withMessages([$key => $message]); }
  260. }