SiteMessageService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. <?php
  2. namespace App\Services;
  3. use App\Support\LocalizedContent;
  4. use Carbon\Carbon;
  5. use Illuminate\Auth\Access\AuthorizationException;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Log;
  8. use Illuminate\Support\Facades\Validator;
  9. use Illuminate\Validation\Rule;
  10. class SiteMessageService
  11. {
  12. public const CATEGORIES = ['discount' => '优惠活动', 'site_notice' => '网站公告', 'internal' => '站内信', 'promotion' => '活动宣传'];
  13. public const TARGETS = ['all_users' => '所有用户', 'specified_users' => '指定用户', 'all_agents' => '所有代理', 'specified_agents' => '指定代理'];
  14. public const STATES = ['scheduled' => '待发送', 'sent' => '已发送', 'retracted' => '已撤回'];
  15. public static function canManageAgents(): bool
  16. {
  17. return in_array((int) (request()->user->id ?? 0), config('agent.admin_ids', [1]), true);
  18. }
  19. public function options(): array
  20. {
  21. return ['categories' => $this->optionsFor(self::CATEGORIES), 'recipient_types' => $this->optionsFor(self::TARGETS),
  22. 'languages' => $this->optionsFor(LocalizedContent::LANGUAGES), 'can_manage_agents' => self::canManageAgents(), 'timezone' => 'Asia/Shanghai'];
  23. }
  24. public function recipients(string $kind, array $input): array
  25. {
  26. if ($kind === 'agent') $this->requireAgentAccess();
  27. $params = Validator::make($input, $this->pageRules() + ['q' => ['nullable', 'string', 'max:100']])->validate();
  28. $query = $this->eligible($kind);
  29. if (isset($params['q']) && $params['q'] !== '') {
  30. $q = $params['q'];
  31. $query->where(function ($query) use ($kind, $q) {
  32. foreach ($kind === 'agent' ? ['username', 'real_name'] : ['account', 'username', 'first_name'] as $field) $query->orWhere('u.' . $field, 'like', '%' . $q . '%');
  33. if (ctype_digit($q)) {
  34. $query->orWhere('u.id', $q);
  35. if ($kind === 'user') $query->orWhere('u.user_id', $q)->orWhere('u.member_id', $q);
  36. }
  37. });
  38. }
  39. $total = (clone $query)->count();
  40. $fields = $kind === 'agent' ? ['u.id', 'u.username', 'u.real_name', 'u.language', 'u.status']
  41. : ['u.id', 'u.user_id', 'u.member_id', 'u.account', 'u.username', 'u.first_name', 'u.language'];
  42. $rows = $query->select($fields)->orderByDesc('u.id')->forPage((int) ($params['page'] ?? 1), (int) ($params['limit'] ?? 20))->get();
  43. return ['total' => $total, 'data' => $rows->map(function ($row) { $row->id = (int) $row->id; return $row; })->all()];
  44. }
  45. public function send(array $input): array
  46. {
  47. foreach (['sender_id', 'sender_name', 'sender_display'] as $key) if (array_key_exists($key, $input)) LocalizedContent::invalid($key, '发送者由登录管理员生成');
  48. $params = Validator::make($input, [
  49. 'request_id' => ['required', 'string', 'regex:/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/D'],
  50. 'category' => ['sometimes', Rule::in(array_keys(self::CATEGORIES))],
  51. 'recipient_type' => ['required', Rule::in(array_keys(self::TARGETS))],
  52. 'recipient_ids' => ['nullable', 'array', 'max:500'], 'recipient_ids.*' => ['required', 'integer', 'min:1'],
  53. 'send_at' => ['nullable', 'date_format:Y-m-d H:i:s'], 'translations' => ['required', 'array'],
  54. ])->validate();
  55. if (empty($params['send_at'])) $params['send_at'] = null;
  56. $type = $params['recipient_type']; $kind = str_ends_with($type, 'agents') ? 'agent' : 'user';
  57. if ($kind === 'agent') $this->requireAgentAccess();
  58. $ids = array_values(array_unique(array_map('intval', $params['recipient_ids'] ?? []))); sort($ids);
  59. if (str_starts_with($type, 'specified_') && !$ids) LocalizedContent::invalid('recipient_ids', '请选择收件人');
  60. if (str_starts_with($type, 'all_') && $ids) LocalizedContent::invalid('recipient_ids', '群发全部账号时请清空指定收件人');
  61. $translations = LocalizedContent::parse($params['translations'], 500, 5000, true);
  62. $actor = OperationAuditService::actor();
  63. if (!$actor['operator_id'] || $actor['operator_name'] === '') LocalizedContent::invalid('sender_id', '请使用后台管理员身份');
  64. $hash = hash('sha256', json_encode([$params['category'] ?? 'internal', $type, $ids, $params['send_at'] ?? null, $translations], JSON_THROW_ON_ERROR));
  65. return DB::transaction(function () use ($params, $type, $kind, $ids, $translations, $actor, $hash) {
  66. $now = $this->now();
  67. // The unique key serializes concurrent retries before any receipts or audit records are created.
  68. $created = DB::table('site_messages')->insertOrIgnore([
  69. 'category' => $params['category'] ?? 'internal', 'recipient_type' => $type,
  70. 'recipient_ids' => json_encode($ids, JSON_THROW_ON_ERROR), 'send_at' => $params['send_at'] ?? $now,
  71. 'status' => 'scheduled', 'sender_id' => $actor['operator_id'], 'sender_name' => $actor['operator_name'],
  72. 'request_id' => $params['request_id'], 'request_hash' => $hash, 'created_at' => $now, 'updated_at' => $now,
  73. ]);
  74. $message = DB::table('site_messages')->where('sender_id', $actor['operator_id'])->where('request_id', $params['request_id'])->lockForUpdate()->first();
  75. if (!$message) throw new \RuntimeException('信息创建失败');
  76. if ($message->request_hash !== $hash) LocalizedContent::invalid('request_id', '请求标识已用于其他内容,请重新生成');
  77. if (!$created) return $this->detail((int) $message->id);
  78. if ($ids && $this->eligible($kind)->whereIn('u.id', $ids)->count() !== count($ids)) LocalizedContent::invalid('recipient_ids', '部分账号不存在或不在可发送范围,请重新选择');
  79. foreach ($translations as $language => $block) DB::table('site_message_translations')->insert($block + ['message_id' => $message->id, 'language' => $language]);
  80. if ($message->send_at <= $now) $this->deliver((int) $message->id, $actor);
  81. else OperationAuditService::record('site_message', (int) $message->id, 'schedule', [], $this->snapshot((int) $message->id), $now);
  82. return $this->detail((int) $message->id);
  83. }, 3);
  84. }
  85. public function deliver(int $id, ?array $actor = null): bool
  86. {
  87. return DB::transaction(function () use ($id, $actor) {
  88. $message = DB::table('site_messages')->where('id', $id)->lockForUpdate()->first();
  89. $now = $this->now();
  90. if (!$message || $message->status !== 'scheduled' || $message->send_at > $now) return false;
  91. $before = $this->snapshot($id);
  92. $kind = str_ends_with($message->recipient_type, 'agents') ? 'agent' : 'user';
  93. $ids = json_decode($message->recipient_ids, true, 512, JSON_THROW_ON_ERROR);
  94. $base = $this->eligible($kind);
  95. if ($ids) $base->whereIn('u.id', $ids);
  96. $maximum = (int) (clone $base)->max('u.id');
  97. $base->where('u.id', '<=', $maximum);
  98. $targetCount = $ids ? count($ids) : (int) (clone $base)->count();
  99. // One INSERT SELECT chooses each recipient's language once; no per-language re-scans or partial publication.
  100. $source = (clone $base)->join('site_message_translations as t', function ($join) use ($id) {
  101. $join->on('t.language', '=', 'u.language')->where('t.message_id', '=', $id);
  102. })->selectRaw('? AS message_id, ? AS recipient_kind', [$id, $kind])
  103. ->addSelect('u.id as recipient_id', 't.language')->selectRaw('? AS created_at', [$now]);
  104. DB::table('site_message_recipients')->insertUsing(['message_id', 'recipient_kind', 'recipient_id', 'language', 'created_at'], $source);
  105. $count = DB::table('site_message_recipients')->where('message_id', $id)->count();
  106. DB::table('site_messages')->where('id', $id)->update(['status' => 'sent', 'sent_at' => $now,
  107. 'recipient_count' => $count, 'skipped_count' => max(0, $targetCount - $count), 'updated_at' => $now]);
  108. OperationAuditService::record('site_message', $id, 'send', $before, $this->snapshot($id), $now,
  109. $actor ?? ['operator_id' => null, 'operator_name' => '系统']);
  110. return true;
  111. }, 3);
  112. }
  113. public function dispatchDue(): array
  114. {
  115. $result = ['sent' => 0, 'failed' => 0];
  116. DB::table('site_messages')->where('status', 'scheduled')->where('send_at', '<=', $this->now())->orderBy('id')->chunkById(100, function ($rows) use (&$result) {
  117. foreach ($rows as $row) {
  118. try { if ($this->deliver((int) $row->id)) ++$result['sent']; }
  119. catch (\Throwable $e) { ++$result['failed']; Log::error('site_message_delivery_failed', ['id' => $row->id, 'exception' => $e]); }
  120. }
  121. });
  122. return $result;
  123. }
  124. public function retract(int $id): array
  125. {
  126. return DB::transaction(function () use ($id) {
  127. $found = DB::table('site_messages')->where('id', $id)->lockForUpdate()->first();
  128. if (!$found) LocalizedContent::invalid('id', '信息不存在');
  129. $this->checkAccess($found->recipient_type);
  130. if ($found->status === 'retracted') return $this->detail($id);
  131. $before = $this->snapshot($id); $now = $this->now();
  132. DB::table('site_messages')->where('id', $id)->update(['status' => 'retracted', 'retracted_at' => $now, 'updated_at' => $now]);
  133. OperationAuditService::record('site_message', $id, 'retract', $before, $this->snapshot($id), $now);
  134. return $this->detail($id);
  135. }, 3);
  136. }
  137. public function listing(array $input): array
  138. {
  139. $params = Validator::make($input, $this->pageRules() + [
  140. 'category' => ['nullable', Rule::in(array_keys(self::CATEGORIES))], 'language' => ['nullable', Rule::in(array_keys(LocalizedContent::LANGUAGES))],
  141. 'title' => ['nullable', 'string', 'max:500'],
  142. 'start_date' => ['nullable', 'required_with:end_date', 'date_format:Y-m-d'],
  143. 'end_date' => ['nullable', 'required_with:start_date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
  144. ])->validate();
  145. $query = DB::table('site_messages as m');
  146. if (!self::canManageAgents()) $query->whereIn('m.recipient_type', ['all_users', 'specified_users']);
  147. if (!empty($params['category'])) $query->where('m.category', $params['category']);
  148. if (!empty($params['start_date'])) $query->where('m.send_at', '>=', $params['start_date'] . ' 00:00:00')->where('m.send_at', '<=', $params['end_date'] . ' 23:59:59');
  149. if (!empty($params['language']) || (isset($params['title']) && $params['title'] !== '')) {
  150. $query->whereExists(function ($q) use ($params) {
  151. $q->selectRaw('1')->from('site_message_translations as t')->whereColumn('t.message_id', 'm.id');
  152. if (!empty($params['language'])) $q->where('t.language', $params['language']);
  153. if (isset($params['title']) && $params['title'] !== '') $q->where('t.title', 'like', '%' . $params['title'] . '%');
  154. });
  155. }
  156. $total = (clone $query)->count();
  157. $rows = $query->select('m.*')->orderByDesc('m.send_at')->orderByDesc('m.id')->forPage((int) ($params['page'] ?? 1), (int) ($params['limit'] ?? 20))->get();
  158. $translations = [];
  159. if ($rows->isNotEmpty()) foreach (DB::table('site_message_translations')->whereIn('message_id', $rows->pluck('id')->all())->get(['message_id', 'language', 'title']) as $text) {
  160. $translations[$text->message_id][$text->language] = ['title' => $text->title];
  161. }
  162. return ['total' => $total, 'data' => $rows->map(fn ($row) => $this->dto((array) $row, false, $translations[$row->id] ?? []))->all()];
  163. }
  164. public function detail(int $id): array
  165. {
  166. $row = DB::table('site_messages')->where('id', $id)->first();
  167. if (!$row) LocalizedContent::invalid('id', '信息不存在');
  168. $this->checkAccess($row->recipient_type);
  169. $item = $this->dto((array) $row, true);
  170. $item['delivered_by_language'] = DB::table('site_message_recipients')->where('message_id', $id)->select('language')->selectRaw('COUNT(*) AS total')->groupBy('language')->pluck('total', 'language')->map(fn ($n) => (int) $n)->all();
  171. return $item;
  172. }
  173. public function logs(array $input): array
  174. {
  175. return LocalizedAuditService::listing('site_message', $input, function ($q) {
  176. $q->join('site_messages as m', 'm.id', '=', 'a.resource_id');
  177. if (!self::canManageAgents()) $q->whereIn('m.recipient_type', ['all_users', 'specified_users']);
  178. });
  179. }
  180. private function snapshot(int $id): array
  181. {
  182. $row = (array) DB::table('site_messages')->where('id', $id)->first();
  183. unset($row['request_hash']);
  184. $row['recipient_ids'] = json_decode($row['recipient_ids'], true, 512, JSON_THROW_ON_ERROR);
  185. $row['translations'] = $this->translations($id);
  186. return $row;
  187. }
  188. private function dto(array $row, bool $full, ?array $translations = null): array
  189. {
  190. $translations = $translations ?? $this->translations((int) $row['id']);
  191. unset($row['request_hash']);
  192. foreach (['id', 'sender_id', 'recipient_count', 'skipped_count'] as $key) $row[$key] = (int) $row[$key];
  193. $row['recipient_ids'] = json_decode($row['recipient_ids'], true, 512, JSON_THROW_ON_ERROR);
  194. $row['titles'] = (object) array_map(fn ($b) => $b['title'], $translations); $row['languages'] = array_keys($translations);
  195. $row['category_name'] = self::CATEGORIES[$row['category']]; $row['recipient_label'] = self::TARGETS[$row['recipient_type']];
  196. $row['status_name'] = self::STATES[$row['status']]; $row['sender_display'] = '批核者:' . $row['sender_name'];
  197. if ($full) $row['translations'] = $translations;
  198. return $row;
  199. }
  200. private function translations(int $id): array
  201. {
  202. $result = [];
  203. foreach (DB::table('site_message_translations')->where('message_id', $id)->orderBy('language')->get() as $row) $result[$row->language] = ['title' => $row->title, 'subtitle' => $row->subtitle, 'content' => $row->content];
  204. return $result;
  205. }
  206. private function eligible(string $kind)
  207. {
  208. $query = DB::table(($kind === 'agent' ? 'agents' : 'users') . ' as u');
  209. if ($kind === 'user') $query->where('u.from', 1)->whereNull('u.delete_time');
  210. return $query;
  211. }
  212. private function checkAccess(string $type): void { if (str_ends_with($type, 'agents')) $this->requireAgentAccess(); }
  213. private function requireAgentAccess(): void { if (!self::canManageAgents()) throw new AuthorizationException('无代理管理权限'); }
  214. private function optionsFor(array $values): array { $result = []; foreach ($values as $value => $label) $result[] = compact('value', 'label'); return $result; }
  215. private function pageRules(): array { return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']]; }
  216. private function now(): string { return Carbon::now('Asia/Shanghai')->format('Y-m-d H:i:s'); }
  217. }