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]); } }