| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278 |
- <?php
- namespace App\Services;
- use App\Constants\Util;
- use App\Support\AnnouncementCatalog as Catalog;
- use App\Support\LocalizedContent;
- use Carbon\Carbon;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Validator;
- use Illuminate\Validation\Rule;
- use Illuminate\Validation\ValidationException;
- class AnnouncementService
- {
- private const FIELDS = ['category', 'publish_platforms', 'display_frequency',
- 'publish_at', 'end_at', 'status', 'click_type', 'link'];
- public function options(): array
- {
- return [
- 'categories' => Catalog::options(Catalog::CATEGORIES),
- 'languages' => Catalog::options(LocalizedContent::LANGUAGES),
- 'platforms' => Catalog::options(Catalog::PLATFORMS),
- 'display_frequencies' => Catalog::options(Catalog::FREQUENCIES),
- 'click_types' => Catalog::options(Catalog::CLICKS),
- 'statuses' => [['value' => 0, 'label' => '关闭'], ['value' => 1, 'label' => '开启']],
- 'timezone' => Catalog::TIMEZONE,
- 'defaults' => ['category' => 'content', 'publish_platforms' => array_keys(Catalog::PLATFORMS),
- 'display_frequency' => 'daily_first_login', 'publish_immediately' => true, 'no_end' => true,
- 'status' => 1, 'click_type' => 'none'],
- ];
- }
- public function listing(array $input): array
- {
- $params = Validator::make($input, $this->pageRules() + $this->dateRangeRules() + [
- 'category' => ['nullable', Rule::in(array_keys(Catalog::CATEGORIES))],
- 'platform' => ['nullable', Rule::in(array_keys(Catalog::PLATFORMS))],
- 'status' => ['nullable', 'integer', Rule::in([0, 1])],
- 'title' => ['nullable', 'string', 'max:200'],
- 'language' => ['nullable', Rule::in(array_keys(LocalizedContent::LANGUAGES))],
- ])->validate();
- $query = DB::table('announcements');
- foreach (['category', 'status'] as $key) {
- if (isset($params[$key]) && $params[$key] !== '') $query->where($key, $params[$key]);
- }
- if (!empty($params['language']) || (isset($params['title']) && $params['title'] !== '')) {
- $query->whereExists(function ($q) use ($params) {
- $q->selectRaw('1')->from('announcement_translations as t')->whereColumn('t.announcement_id', 'announcements.id');
- if (!empty($params['language'])) $q->where('t.language', $params['language']);
- if (isset($params['title']) && $params['title'] !== '') $q->where('t.title', 'like', '%' . $params['title'] . '%');
- });
- }
- if (!empty($params['platform'])) {
- if (DB::connection()->getDriverName() === 'sqlite') {
- $query->whereRaw('EXISTS (SELECT 1 FROM json_each(publish_platforms) WHERE json_each.value = ?)', [$params['platform']]);
- } else $query->whereJsonContains('publish_platforms', $params['platform']);
- }
- $this->dateRange($query, 'publish_at', $params);
- $total = (clone $query)->count();
- $rows = $query->orderByDesc('publish_at')->orderByDesc('id')
- ->forPage((int) ($params['page'] ?? 1), (int) ($params['limit'] ?? 20))->get();
- $translations = [];
- if ($rows->isNotEmpty()) foreach (DB::table('announcement_translations')->whereIn('announcement_id', $rows->pluck('id')->all())->get(['announcement_id', 'language', 'title']) as $text) {
- $translations[$text->announcement_id][$text->language] = ['title' => $text->title];
- }
- return ['total' => $total, 'data' => $rows->map(fn ($row) => $this->dto((array) $row + ['translations' => $translations[$row->id] ?? []], false))->all()];
- }
- public function detail(int $id): array
- {
- $row = DB::table('announcements')->where('id', $id)->first();
- if (!$row) $this->invalid('id', '公告不存在');
- return $this->dto((array) $row);
- }
- public function save(array $input): array
- {
- foreach (['sender_id', 'sender_name', 'sender_display', 'title', 'content'] as $field) {
- if (array_key_exists($field, $input)) $this->invalid($field, '发送者不可指定;标题与内容请使用translations提交');
- }
- $params = Validator::make($input, [
- 'id' => ['nullable', 'integer', 'min:1'],
- 'category' => ['sometimes', 'required', Rule::in(array_keys(Catalog::CATEGORIES))],
- 'translations' => ['sometimes', 'required', 'array'],
- 'publish_platforms' => ['sometimes', 'required', 'array', 'min:1', 'max:3'],
- 'publish_platforms.*' => ['required', 'string', 'distinct', Rule::in(array_keys(Catalog::PLATFORMS))],
- 'display_frequency' => ['sometimes', 'required', Rule::in(array_keys(Catalog::FREQUENCIES))],
- 'publish_immediately' => ['sometimes', 'boolean'],
- 'publish_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
- 'no_end' => ['sometimes', 'boolean'],
- 'end_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
- 'status' => ['sometimes', 'integer', Rule::in([0, 1])],
- 'click_type' => ['sometimes', 'required', Rule::in(array_keys(Catalog::CLICKS))],
- 'link' => ['nullable', 'string', 'max:2048'],
- ], ['click_type.in' => '点击方式仅支持无链接、外站链接、站内路径'])->validate();
- return DB::transaction(function () use ($params) {
- $now = $this->now();
- $before = [];
- $id = $params['id'] ?? null;
- if ($id !== null) {
- $found = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
- if (!$found) $this->invalid('id', '公告不存在');
- $before = $this->snapshot((int) $id);
- }
- $row = $before ?: ['category' => 'content', 'publish_platforms' => array_keys(Catalog::PLATFORMS),
- 'display_frequency' => 'daily_first_login', 'publish_at' => $now, 'end_at' => null,
- 'status' => 1, 'click_type' => 'none', 'link' => null];
- foreach (self::FIELDS as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
- if (array_key_exists('translations', $params)) $row['translations'] = LocalizedContent::parse($params['translations'], 200, 20000);
- if (empty($row['translations'])) $this->invalid('translations', '至少填写一个语言的标题和正文');
- if ($before && $row['category'] !== $before['category'] && !array_key_exists('translations', $params)) {
- $this->invalid('translations', '切换公告分类时请重新填写多语言内容');
- }
- $row['publish_platforms'] = array_values(array_intersect(array_keys(Catalog::PLATFORMS), $row['publish_platforms']));
- if (array_key_exists('publish_immediately', $params) && $params['publish_immediately']) {
- $row['publish_at'] = $before && $before['publish_at'] <= $now ? $before['publish_at'] : $now;
- } elseif (($params['publish_immediately'] ?? null) === false || ($params['publish_immediately'] ?? null) === 0 || ($params['publish_immediately'] ?? null) === '0') {
- if (!$row['publish_at'] || (!$before && empty($params['publish_at']))) $this->invalid('publish_at', '定时发布必须填写发布时间');
- }
- if (!$row['publish_at']) $this->invalid('publish_at', '请填写发布时间');
- if (array_key_exists('no_end', $params)) {
- if ($params['no_end']) $row['end_at'] = null;
- elseif (!$row['end_at']) $this->invalid('end_at', '请填写结束时间');
- }
- if ($row['end_at'] !== null && $row['end_at'] <= $row['publish_at']) $this->invalid('end_at', '结束时间必须晚于发布时间');
- if ($row['category'] === 'image') {
- foreach ($row['translations'] as $language => &$block) {
- if (mb_strlen($block['content']) > 2048 || !$this->imageAddress($block['content'])) $this->invalid('translations.' . $language . '.content', '图片公告请使用上传成功的jpg/jpeg/png/webp图片地址');
- $block['content'] = Util::replacePartInUrl($block['content']);
- }
- unset($block);
- }
- if (in_array($row['click_type'], ['external', 'internal'], true)) {
- if (!$this->validLink((string) $row['link'], $row['click_type'])) $this->invalid('link', '请填写有效的外站链接或站内相对路径');
- } else { $row['link'] = null; }
- $row['status'] = (int) $row['status'];
- $actor = OperationAuditService::actor();
- if (!$actor['operator_id'] || $actor['operator_name'] === '') $this->invalid('sender_id', '请使用后台管理员身份');
- $values = array_intersect_key($row, array_flip(self::FIELDS));
- $values['publish_platforms'] = json_encode($values['publish_platforms'], JSON_THROW_ON_ERROR);
- if (!$before) {
- $id = DB::table('announcements')->insertGetId($values + ['sender_id' => $actor['operator_id'],
- 'sender_name' => $actor['operator_name'], 'created_at' => $now, 'updated_at' => $now]);
- } else {
- $changed = false;
- foreach (array_merge(self::FIELDS, ['translations']) as $field) if ($row[$field] !== $before[$field]) { $changed = true; break; }
- if (!$changed) return $this->detail((int) $id);
- DB::table('announcements')->where('id', $id)->update($values + ['updated_at' => $now]);
- }
- DB::table('announcement_translations')->where('announcement_id', $id)->delete();
- foreach ($row['translations'] as $language => $block) DB::table('announcement_translations')->insert($block + ['announcement_id' => $id, 'language' => $language]);
- $after = $this->snapshot((int) $id);
- OperationAuditService::record('announcement', (int) $id, $before ? 'update' : 'create', $before, $after, $now);
- SiteRealtimeService::changed('announcement', (int) $id, $before ? 'update' : 'create',
- $row['status'] ? ['start' => $row['publish_at'], 'end' => $row['end_at']] : [],
- ($before && Catalog::state($before, $now) === 'active') || Catalog::state($after, $now) === 'active');
- return $this->dto($after);
- });
- }
- public function status(int $id, int $status): array
- {
- Validator::make(compact('id', 'status'), ['id' => ['required', 'integer', 'min:1'], 'status' => ['required', Rule::in([0, 1])]])->validate();
- return DB::transaction(function () use ($id, $status) {
- $found = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
- if (!$found) $this->invalid('id', '公告不存在');
- $before = $this->snapshot((int) $id);
- if ($before['status'] === $status) return $this->dto($before);
- $now = $this->now();
- DB::table('announcements')->where('id', $id)->update(['status' => $status, 'updated_at' => $now]);
- $after = $this->snapshot($id);
- OperationAuditService::record('announcement', $id, 'status', $before, $after, $now);
- SiteRealtimeService::changed('announcement', $id, 'status',
- $status ? ['start' => $after['publish_at'], 'end' => $after['end_at']] : [],
- Catalog::state($before, $now) === 'active' || Catalog::state($after, $now) === 'active');
- return $this->dto($after);
- });
- }
- public function delete(int $id): array
- {
- Validator::make(compact('id'), ['id' => ['required', 'integer', 'min:1']])->validate();
- return DB::transaction(function () use ($id) {
- $row = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
- if (!$row) $this->invalid('id', '公告不存在');
- $before = $this->snapshot($id); $now = $this->now();
- DB::table('announcement_translations')->where('announcement_id', $id)->delete();
- DB::table('announcements')->where('id', $id)->delete();
- OperationAuditService::record('announcement', $id, 'delete', $before, [], $now);
- SiteRealtimeService::changed('announcement', $id, 'delete', [], false);
- return ['id' => $id, 'deleted' => true];
- });
- }
- public function logs(array $input): array
- {
- if (isset($input['announcement_id'])) $input['resource_id'] = $input['announcement_id'];
- return LocalizedAuditService::listing('announcement', $input);
- }
- private function translations(int $id): array
- {
- $rows = DB::table('announcement_translations')->where('announcement_id', $id)->get()->keyBy('language');
- $result = [];
- foreach (LocalizedContent::LANGUAGES as $language => $_) if (isset($rows[$language])) $result[$language] = ['title' => $rows[$language]->title, 'content' => $rows[$language]->content];
- return $result;
- }
- private function snapshot(int $id): array
- {
- $row = $this->normalize((array) DB::table('announcements')->where('id', $id)->first());
- $row['translations'] = $this->translations($id);
- return $row;
- }
- private function dto(array $row, bool $full = true): array
- {
- $row = $this->normalize($row);
- $translations = $row['translations'] ?? $this->translations((int) $row['id']);
- unset($row['translations']);
- $row['languages'] = array_keys($translations);
- $row['titles'] = (object) array_map(fn ($b) => $b['title'], $translations);
- $now = $this->now();
- $row['category_name'] = Catalog::CATEGORIES[$row['category']];
- $row['platform_label'] = count($row['publish_platforms']) === 3 ? '全部' : implode('、', array_map(fn ($p) => Catalog::PLATFORMS[$p], $row['publish_platforms']));
- $row['display_frequency_name'] = Catalog::FREQUENCIES[$row['display_frequency']];
- $row['sender_display'] = '批核者:' . $row['sender_name'];
- $row['publication_state'] = Catalog::state($row, $now);
- $row['publication_state_name'] = Catalog::STATES[$row['publication_state']];
- $row['effective_status'] = $row['publication_state'] === 'active' ? 1 : 0;
- $row['publish_immediately'] = $row['publish_at'] <= $now;
- $row['no_end'] = $row['end_at'] === null;
- if ($full) {
- if ($row['category'] === 'image') foreach ($translations as &$block) $block['content'] = Util::ensureUrl($block['content']);
- unset($block); $row['translations'] = $translations;
- }
- return $row;
- }
- private function normalize(array $row): array
- {
- if (isset($row['publish_platforms']) && is_string($row['publish_platforms'])) $row['publish_platforms'] = json_decode($row['publish_platforms'], true, 512, JSON_THROW_ON_ERROR);
- foreach (['id', 'sender_id', 'status'] as $field) if (isset($row[$field])) $row[$field] = (int) $row[$field];
- unset($row['activity_id']);
- if (($row['click_type'] ?? 'none') === 'activity') { $row['click_type'] = 'none'; $row['link'] = null; }
- return $row;
- }
- private function imageAddress(string $value): bool
- {
- if (!$this->validLink($value, str_starts_with($value, '/') ? 'internal' : 'external')) return false;
- return (bool) preg_match('/\.(?:jpe?g|png|webp)$/i', (string) parse_url($value, PHP_URL_PATH));
- }
- private function validLink(string $value, string $type): bool
- {
- if ($value === '' || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) return false;
- if ($type === 'internal') return str_starts_with($value, '/') && !str_starts_with($value, '//');
- $parts = parse_url($value);
- return filter_var($value, FILTER_VALIDATE_URL) && $parts && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
- && !isset($parts['user']) && !isset($parts['pass']);
- }
- private function pageRules(): array { return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']]; }
- private function dateRangeRules(): array { return [
- 'start_date' => ['nullable', 'required_with:end_date', 'date_format:Y-m-d'],
- 'end_date' => ['nullable', 'required_with:start_date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
- ]; }
- private function dateRange($query, string $field, array $params): void
- {
- if (!empty($params['start_date'])) $query->where($field, '>=', $params['start_date'] . ' 00:00:00')->where($field, '<=', $params['end_date'] . ' 23:59:59');
- }
- private function now(): string { return Carbon::now(Catalog::TIMEZONE)->format('Y-m-d H:i:s'); }
- private function invalid(string $key, string $message): void { throw ValidationException::withMessages([$key => $message]); }
- }
|