From 940bc1d71cf1afd39b49d06ea500fbb47857b564 Mon Sep 17 00:00:00 2001 From: cita Date: Fri, 20 Mar 2026 11:24:29 +0800 Subject: [PATCH] add site announcements --- docs/configuration.md | 9 + docs/upstream-integration.md | 19 ++ drizzle/0015_site_announcements.sql | 26 ++ drizzle/meta/_journal.json | 7 + src/server/db/generated/mysql.bootstrap.sql | 4 + src/server/db/generated/mysql.upgrade.sql | 8 +- .../db/generated/postgres.bootstrap.sql | 4 + src/server/db/generated/postgres.upgrade.sql | 8 +- src/server/db/generated/schemaContract.json | 151 +++++++++++ src/server/db/index.ts | 1 + src/server/db/schema.ts | 24 ++ src/server/db/siteAnnouncementsSchema.test.ts | 45 ++++ src/server/index.ts | 8 +- .../routes/api/siteAnnouncements.test.ts | 244 ++++++++++++++++++ src/server/routes/api/siteAnnouncements.ts | 127 +++++++++ src/server/services/platforms/base.ts | 28 ++ src/server/services/platforms/doneHub.test.ts | 26 ++ src/server/services/platforms/doneHub.ts | 22 +- src/server/services/platforms/newApi.test.ts | 26 ++ src/server/services/platforms/newApi.ts | 22 +- .../platforms/siteAnnouncements.test.ts | 103 ++++++++ src/server/services/platforms/sub2api.test.ts | 75 ++++++ src/server/services/platforms/sub2api.ts | 36 +++ .../siteAnnouncementPollingService.test.ts | 32 +++ .../siteAnnouncementPollingService.ts | 35 +++ .../services/siteAnnouncementService.test.ts | 192 ++++++++++++++ .../services/siteAnnouncementService.ts | 163 ++++++++++++ src/web/App.tsx | 7 +- src/web/api.ts | 8 + src/web/components/NotificationPanel.tsx | 3 +- src/web/pages/ProgramLogs.tsx | 1 + src/web/pages/SiteAnnouncements.tsx | 224 ++++++++++++++++ src/web/pages/helpers/navigationFocus.test.ts | 11 + src/web/pages/helpers/navigationFocus.ts | 19 ++ src/web/pages/siteAnnouncements.test.tsx | 128 +++++++++ 35 files changed, 1831 insertions(+), 15 deletions(-) create mode 100644 drizzle/0015_site_announcements.sql create mode 100644 src/server/db/siteAnnouncementsSchema.test.ts create mode 100644 src/server/routes/api/siteAnnouncements.test.ts create mode 100644 src/server/routes/api/siteAnnouncements.ts create mode 100644 src/server/services/platforms/siteAnnouncements.test.ts create mode 100644 src/server/services/siteAnnouncementPollingService.test.ts create mode 100644 src/server/services/siteAnnouncementPollingService.ts create mode 100644 src/server/services/siteAnnouncementService.test.ts create mode 100644 src/server/services/siteAnnouncementService.ts create mode 100644 src/web/pages/SiteAnnouncements.tsx create mode 100644 src/web/pages/siteAnnouncements.test.tsx diff --git a/docs/configuration.md b/docs/configuration.md index 3d841f4..2019fa8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -213,6 +213,15 @@ Metapi 的路由引擎按多因子加权选择最优通道。 > `TOKEN_ROUTER_CACHE_TTL_MS`、`PROXY_LOG_RETENTION_DAYS`、`PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES` 当前仍属于部署级环境变量,不在后台运行时设置里单独维护。 +## 站点公告 + +管理后台新增了「站点公告」页面,用于保存和浏览 Metapi 已同步到本地的上游公告记录。 + +- 首次发现的上游公告会写入站内通知,并按现有通知渠道外发一次 +- 后续重复同步只更新本地公告记录,不会重复外发同一条公告 +- 当前支持的上游公告来源包括 `new-api`、`done-hub` 与 `sub2api` +- 「清空公告」只删除 Metapi 本地保存的公告记录,不会修改上游站点数据 + ## 下一步 - [部署指南](./deployment.md) — Docker Compose 与反向代理 diff --git a/docs/upstream-integration.md b/docs/upstream-integration.md index b2925cf..6afdc9a 100644 --- a/docs/upstream-integration.md +++ b/docs/upstream-integration.md @@ -379,6 +379,25 @@ Metapi 自动追踪每个账号的健康状态: --- +## 📰 站点公告 + +Metapi 会定期抓取已接入站点的公告,并在首次发现时写入站内通知与「站点公告」页面。 + +当前支持的公告来源: + +- `new-api`:读取 `/api/notice` 的全站公告 +- `done-hub`:读取 `/api/notice` 的全站公告 +- `sub2api`:读取 `/api/v1/announcements` 的公告列表 + +行为约定: + +1. 首次发现的公告会触发一次 Metapi 通知 +2. 已经保存过的公告再次同步时只更新本地记录 +3. `sub2api` 这类需要登录态的公告接口,会优先使用站点下已启用账号的会话令牌 +4. 「站点公告」页面的清空操作只影响 Metapi 本地数据库 + +--- + ## 🛠️ 故障排查 ### 问题:添加站点后无法获取模型列表 diff --git a/drizzle/0015_site_announcements.sql b/drizzle/0015_site_announcements.sql new file mode 100644 index 0000000..ea42d23 --- /dev/null +++ b/drizzle/0015_site_announcements.sql @@ -0,0 +1,26 @@ +CREATE TABLE `site_announcements` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `site_id` integer NOT NULL, + `platform` text NOT NULL, + `source_key` text NOT NULL, + `title` text NOT NULL, + `content` text NOT NULL, + `level` text DEFAULT 'info' NOT NULL, + `source_url` text, + `starts_at` text, + `ends_at` text, + `upstream_created_at` text, + `upstream_updated_at` text, + `first_seen_at` text DEFAULT (datetime('now')), + `last_seen_at` text DEFAULT (datetime('now')), + `read_at` text, + `dismissed_at` text, + `raw_payload` text, + FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`,`source_key`); +--> statement-breakpoint +CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`,`first_seen_at`); +--> statement-breakpoint +CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6e870bf..dd72f9f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1773855000000, "tag": "0014_explicit_group_routes", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1773900000000, + "tag": "0015_site_announcements", + "breakpoints": true } ] } diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql index b29fe93..cb7b867 100644 --- a/src/server/db/generated/mysql.bootstrap.sql +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIM CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` DATETIME, `last_selected_at` DATETIME, `last_fail_at` DATETIME, `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` DATETIME, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL); CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT); +CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` DATETIME, `ends_at` DATETIME, `upstream_created_at` DATETIME, `upstream_updated_at` DATETIME, `first_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `read_at` DATETIME, `dismissed_at` DATETIME, `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE); CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`key`(191)); @@ -19,6 +20,7 @@ CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availabi CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191)); CREATE UNIQUE INDEX `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191)); CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`); +CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191)); CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191)); CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191)); CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191)); @@ -55,6 +57,8 @@ CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`); CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`); CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`); CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); +CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`); +CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`); CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`); CREATE INDEX `sites_status_idx` ON `sites` (`status`(191)); CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`); diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql index 2e455b5..f279314 100644 --- a/src/server/db/generated/mysql.upgrade.sql +++ b/src/server/db/generated/mysql.upgrade.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE); -ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(191) DEFAULT 'pattern'; -CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`); -CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); +CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` DATETIME, `ends_at` DATETIME, `upstream_created_at` DATETIME, `upstream_updated_at` DATETIME, `first_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `read_at` DATETIME, `dismissed_at` DATETIME, `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); +CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191)); +CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`); +CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`); diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql index 29b8f81..f6631e3 100644 --- a/src/server/db/generated/postgres.bootstrap.sql +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TIMESTAMP, "last_selected_at" TIMESTAMP, "last_fail_at" TIMESTAMP, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL); CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT); +CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TIMESTAMP, "ends_at" TIMESTAMP, "upstream_created_at" TIMESTAMP, "upstream_updated_at" TIMESTAMP, "first_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "last_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "read_at" TIMESTAMP, "dismissed_at" TIMESTAMP, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE); CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("key"); @@ -19,6 +20,7 @@ CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availabi CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id"); CREATE UNIQUE INDEX "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id"); CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id"); +CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key"); CREATE UNIQUE INDEX "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name"); CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url"); CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name"); @@ -55,6 +57,8 @@ CREATE INDEX "route_channels_route_id_idx" ON "route_channels" ("route_id"); CREATE INDEX "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id"); CREATE INDEX "route_channels_token_id_idx" ON "route_channels" ("token_id"); CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id"); +CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at"); +CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at"); CREATE INDEX "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id"); CREATE INDEX "sites_status_idx" ON "sites" ("status"); CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available"); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql index f8ca612..2e3e66b 100644 --- a/src/server/db/generated/postgres.upgrade.sql +++ b/src/server/db/generated/postgres.upgrade.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE); -ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT 'pattern'; -CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id"); -CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id"); +CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TIMESTAMP, "ends_at" TIMESTAMP, "upstream_created_at" TIMESTAMP, "upstream_updated_at" TIMESTAMP, "first_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "last_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "read_at" TIMESTAMP, "dismissed_at" TIMESTAMP, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); +CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key"); +CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at"); +CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at"); diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json index 75e577d..f0bb5c3 100644 --- a/src/server/db/generated/schemaContract.json +++ b/src/server/db/generated/schemaContract.json @@ -906,6 +906,112 @@ } } }, + "site_announcements": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "platform": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "source_key": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "title": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "content": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "level": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'info'", + "primaryKey": false + }, + "source_url": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "starts_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "ends_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "upstream_created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "upstream_updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "first_seen_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "last_seen_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "read_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "dismissed_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "raw_payload": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, "site_disabled_models": { "columns": { "id": { @@ -1468,6 +1574,32 @@ ], "unique": false }, + { + "name": "site_announcements_read_at_idx", + "table": "site_announcements", + "columns": [ + "read_at" + ], + "unique": false + }, + { + "name": "site_announcements_site_id_first_seen_at_idx", + "table": "site_announcements", + "columns": [ + "site_id", + "first_seen_at" + ], + "unique": false + }, + { + "name": "site_announcements_site_source_key_unique", + "table": "site_announcements", + "columns": [ + "site_id", + "source_key" + ], + "unique": true + }, { "name": "site_disabled_models_site_id_idx", "table": "site_disabled_models", @@ -1591,6 +1723,14 @@ "source_route_id" ] }, + { + "name": "site_announcements_site_source_key_unique", + "table": "site_announcements", + "columns": [ + "site_id", + "source_key" + ] + }, { "name": "site_disabled_models_site_model_unique", "table": "site_disabled_models", @@ -1716,6 +1856,17 @@ ], "onDelete": "CASCADE" }, + { + "table": "site_announcements", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, { "table": "site_disabled_models", "columns": [ diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 72627be..61b2e5c 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -33,6 +33,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([ 'proxy_video_tasks', 'proxy_files', 'downstream_api_keys', + 'site_announcements', 'events', ]); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index b6956e3..a50a268 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -276,6 +276,30 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', { expiresAtIdx: index('downstream_api_keys_expires_at_idx').on(table.expiresAt), })); +export const siteAnnouncements = sqliteTable('site_announcements', { + id: integer('id').primaryKey({ autoIncrement: true }), + siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }), + platform: text('platform').notNull(), + sourceKey: text('source_key').notNull(), + title: text('title').notNull(), + content: text('content').notNull(), + level: text('level').notNull().default('info'), + sourceUrl: text('source_url'), + startsAt: text('starts_at'), + endsAt: text('ends_at'), + upstreamCreatedAt: text('upstream_created_at'), + upstreamUpdatedAt: text('upstream_updated_at'), + firstSeenAt: text('first_seen_at').default(sql`(datetime('now'))`), + lastSeenAt: text('last_seen_at').default(sql`(datetime('now'))`), + readAt: text('read_at'), + dismissedAt: text('dismissed_at'), + rawPayload: text('raw_payload'), +}, (table) => ({ + siteSourceKeyUnique: uniqueIndex('site_announcements_site_source_key_unique').on(table.siteId, table.sourceKey), + siteIdFirstSeenAtIdx: index('site_announcements_site_id_first_seen_at_idx').on(table.siteId, table.firstSeenAt), + readAtIdx: index('site_announcements_read_at_idx').on(table.readAt), +})); + export const events = sqliteTable('events', { id: integer('id').primaryKey({ autoIncrement: true }), type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status' diff --git a/src/server/db/siteAnnouncementsSchema.test.ts b/src/server/db/siteAnnouncementsSchema.test.ts new file mode 100644 index 0000000..dc67205 --- /dev/null +++ b/src/server/db/siteAnnouncementsSchema.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { generateDialectArtifacts } from './schemaArtifactGenerator.js'; +import type { SchemaContract } from './schemaContract.js'; +import * as schema from './schema.js'; + +const dbDir = dirname(fileURLToPath(import.meta.url)); +const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json'); + +function readSchemaContract(): SchemaContract { + return JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; +} + +describe('site announcements schema', () => { + it('declares the site_announcements table in the Drizzle schema', () => { + expect(schema.siteAnnouncements).toBeDefined(); + }); + + it('keeps site_announcements in the generated schema contract', () => { + const contract = readSchemaContract(); + const table = contract.tables.site_announcements; + + expect(table).toBeDefined(); + expect(table?.columns.site_id?.logicalType).toBe('integer'); + expect(table?.columns.source_key?.logicalType).toBe('text'); + expect(table?.columns.first_seen_at?.logicalType).toBe('datetime'); + expect(table?.columns.last_seen_at?.logicalType).toBe('datetime'); + expect(table?.columns.read_at?.logicalType).toBe('datetime'); + expect(table?.columns.raw_payload?.logicalType).toBe('text'); + expect(contract.indexes.some((index) => index.name === 'site_announcements_site_id_first_seen_at_idx')).toBe(true); + expect(contract.indexes.some((index) => index.name === 'site_announcements_read_at_idx')).toBe(true); + expect(contract.uniques.some((unique) => unique.name === 'site_announcements_site_source_key_unique')).toBe(true); + }); + + it('emits bootstrap sql for the site_announcements table', () => { + const artifacts = generateDialectArtifacts(readSchemaContract()); + + expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `site_announcements`'); + expect(artifacts.mysqlBootstrap).toContain('`site_announcements_site_source_key_unique`'); + expect(artifacts.postgresBootstrap).toContain('CREATE TABLE IF NOT EXISTS "site_announcements"'); + expect(artifacts.postgresBootstrap).toContain('"site_announcements_site_source_key_unique"'); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts index 27815ff..d7a1212 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -18,6 +18,7 @@ import { testRoutes } from './routes/api/test.js'; import { monitorRoutes } from './routes/api/monitor.js'; import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js'; import { oauthRoutes } from './routes/api/oauth.js'; +import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; @@ -26,6 +27,7 @@ import { repairStoredCreatedAtValues } from './services/storedTimestampRepairSer import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js'; +import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js'; import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { existsSync } from 'fs'; @@ -319,8 +321,9 @@ await app.register(authRoutes); await app.register(settingsRoutes); await app.register(accountTokensRoutes); await app.register(searchRoutes); -await app.register(eventsRoutes); -await app.register(taskRoutes); + await app.register(eventsRoutes); + await app.register(siteAnnouncementsRoutes); + await app.register(taskRoutes); await app.register(testRoutes); await app.register(monitorRoutes); await app.register(downstreamApiKeysRoutes); @@ -358,6 +361,7 @@ if (existsSync(webDir)) { // Start scheduler await startScheduler(); +startSiteAnnouncementPolling(); try { await startOAuthLoopbackCallbackServers(); } catch (error) { diff --git a/src/server/routes/api/siteAnnouncements.test.ts b/src/server/routes/api/siteAnnouncements.test.ts new file mode 100644 index 0000000..68374c8 --- /dev/null +++ b/src/server/routes/api/siteAnnouncements.test.ts @@ -0,0 +1,244 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +const syncSiteAnnouncementsMock = vi.fn(); + +vi.mock('../../services/siteAnnouncementService.js', () => ({ + syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('site announcements routes', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let closeDbConnections: DbModule['closeDbConnections'] | undefined; + let getBackgroundTask: ((taskId: string) => { id: string; status: string } | null) | null = null; + let resetBackgroundTasks: (() => void) | null = null; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-routes-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./siteAnnouncements.js'); + const eventsModule = await import('./events.js'); + const backgroundTaskModule = await import('../../services/backgroundTaskService.js'); + db = dbModule.db; + schema = dbModule.schema; + closeDbConnections = dbModule.closeDbConnections; + getBackgroundTask = backgroundTaskModule.getBackgroundTask; + resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests; + + app = Fastify(); + await app.register(routesModule.siteAnnouncementsRoutes); + await app.register(eventsModule.eventsRoutes); + }); + + beforeEach(async () => { + syncSiteAnnouncementsMock.mockReset(); + resetBackgroundTasks?.(); + await db.delete(schema.siteAnnouncements).run(); + await db.delete(schema.events).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + if (typeof closeDbConnections === 'function') { + await closeDbConnections(); + } + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + delete process.env.DATA_DIR; + }); + + it('lists announcements with site and unread filters', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + }).returning().get(); + const otherSite = await db.insert(schema.sites).values({ + name: 'Other Site', + url: 'https://other.example.com', + platform: 'new-api', + }).returning().get(); + + await db.insert(schema.siteAnnouncements).values([ + { + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + firstSeenAt: '2026-03-20 10:00:00', + lastSeenAt: '2026-03-20 10:00:00', + }, + { + siteId: otherSite.id, + platform: 'new-api', + sourceKey: 'announcement:12', + title: 'Other', + content: 'Other notice', + level: 'info', + firstSeenAt: '2026-03-20 09:00:00', + lastSeenAt: '2026-03-20 09:00:00', + readAt: '2026-03-20 09:05:00', + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: `/api/site-announcements?siteId=${site.id}&read=false`, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as Array<{ siteId: number; sourceKey: string }>; + expect(body).toEqual([ + expect.objectContaining({ + siteId: site.id, + sourceKey: 'announcement:11', + }), + ]); + }); + + it('marks one announcement as read and then marks all as read', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + }).returning().get(); + const first = await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + firstSeenAt: '2026-03-20 10:00:00', + lastSeenAt: '2026-03-20 10:00:00', + }).returning().get(); + await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:12', + title: 'Model online', + content: 'gpt-4.1 is available', + level: 'info', + firstSeenAt: '2026-03-20 11:00:00', + lastSeenAt: '2026-03-20 11:00:00', + }).run(); + + const singleRead = await app.inject({ + method: 'POST', + url: `/api/site-announcements/${first.id}/read`, + }); + expect(singleRead.statusCode).toBe(200); + + const afterSingle = await db.select().from(schema.siteAnnouncements).where(eq(schema.siteAnnouncements.id, first.id)).get(); + expect(afterSingle?.readAt).toBeTruthy(); + + const bulkRead = await app.inject({ + method: 'POST', + url: '/api/site-announcements/read-all', + }); + expect(bulkRead.statusCode).toBe(200); + + const rows = await db.select().from(schema.siteAnnouncements).all(); + expect(rows.every((row) => typeof row.readAt === 'string' && row.readAt.length > 0)).toBe(true); + }); + + it('clears local announcement rows without touching program events', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + }).returning().get(); + await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + firstSeenAt: '2026-03-20 10:00:00', + lastSeenAt: '2026-03-20 10:00:00', + }).run(); + await db.insert(schema.events).values({ + type: 'site_notice', + title: '站点公告:Sub Site', + message: 'Window starts at 10:00', + level: 'info', + relatedId: 123, + relatedType: 'site_announcement', + createdAt: '2026-03-20 10:00:00', + }).run(); + + const response = await app.inject({ + method: 'DELETE', + url: '/api/site-announcements', + }); + + expect(response.statusCode).toBe(200); + expect(await db.select().from(schema.siteAnnouncements).all()).toEqual([]); + expect(await db.select().from(schema.events).all()).toHaveLength(1); + }); + + it('queues a background sync task and returns its task id', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + }).returning().get(); + + syncSiteAnnouncementsMock.mockImplementation(() => new Promise(() => {})); + + const response = await app.inject({ + method: 'POST', + url: '/api/site-announcements/sync', + payload: { siteId: site.id }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { taskId: string; queued: boolean }; + expect(body.queued).toBe(true); + expect(typeof body.taskId).toBe('string'); + expect(getBackgroundTask?.(body.taskId)).toBeTruthy(); + }); + + it('returns site_notice rows through the generic events api filter', async () => { + await db.insert(schema.events).values({ + type: 'site_notice', + title: '站点公告:Sub Site', + message: 'Window starts at 10:00', + level: 'info', + relatedId: 1, + relatedType: 'site', + createdAt: '2026-03-20 10:00:00', + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/events?type=site_notice', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as Array<{ type: string }>; + expect(body).toEqual([expect.objectContaining({ type: 'site_notice' })]); + }); +}); diff --git a/src/server/routes/api/siteAnnouncements.ts b/src/server/routes/api/siteAnnouncements.ts new file mode 100644 index 0000000..239c847 --- /dev/null +++ b/src/server/routes/api/siteAnnouncements.ts @@ -0,0 +1,127 @@ +import { FastifyInstance } from 'fastify'; +import { and, desc, eq } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { syncSiteAnnouncements } from '../../services/siteAnnouncementService.js'; + +type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect; + +function parseTimeValue(input?: string | null): number | null { + const raw = String(input || '').trim(); + if (!raw) return null; + const parsed = Date.parse(raw.includes('T') ? raw : raw.replace(' ', 'T') + 'Z'); + return Number.isFinite(parsed) ? parsed : null; +} + +function applyReadFilter(rows: SiteAnnouncementRow[], read?: string): SiteAnnouncementRow[] { + if (read === 'true') { + return rows.filter((row) => typeof row.readAt === 'string' && row.readAt.length > 0); + } + if (read === 'false') { + return rows.filter((row) => !row.readAt); + } + return rows; +} + +function applyStatusFilter(rows: SiteAnnouncementRow[], status?: string): SiteAnnouncementRow[] { + const normalized = String(status || '').trim().toLowerCase(); + if (!normalized) return rows; + const now = Date.now(); + if (normalized === 'dismissed') { + return rows.filter((row) => typeof row.dismissedAt === 'string' && row.dismissedAt.length > 0); + } + if (normalized === 'expired') { + return rows.filter((row) => !row.dismissedAt && (() => { + const endsAt = parseTimeValue(row.endsAt); + return endsAt !== null && endsAt < now; + })()); + } + if (normalized === 'active') { + return rows.filter((row) => !row.dismissedAt && (() => { + const endsAt = parseTimeValue(row.endsAt); + return endsAt === null || endsAt >= now; + })()); + } + return rows; +} + +export async function siteAnnouncementsRoutes(app: FastifyInstance) { + app.get<{ + Querystring: { + limit?: string; + offset?: string; + siteId?: string; + platform?: string; + read?: string; + status?: string; + }; + }>('/api/site-announcements', async (request) => { + const limit = Math.max(1, Math.min(500, Number.parseInt(request.query.limit || '50', 10))); + const offset = Math.max(0, Number.parseInt(request.query.offset || '0', 10)); + const filters: any[] = []; + + const siteId = Number.parseInt(String(request.query.siteId || ''), 10); + if (Number.isFinite(siteId) && siteId > 0) { + filters.push(eq(schema.siteAnnouncements.siteId, siteId)); + } + + const platform = String(request.query.platform || '').trim(); + if (platform) { + filters.push(eq(schema.siteAnnouncements.platform, platform)); + } + + const base = db.select().from(schema.siteAnnouncements); + const rows = filters.length > 0 + ? await base.where(and(...filters)).orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all() + : await base.orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all(); + + const filtered = applyStatusFilter(applyReadFilter(rows, request.query.read), request.query.status); + return filtered.slice(offset, offset + limit); + }); + + app.post<{ Params: { id: string } }>('/api/site-announcements/:id/read', async (request) => { + const id = Number.parseInt(request.params.id, 10); + const readAt = formatUtcSqlDateTime(new Date()); + await db.update(schema.siteAnnouncements) + .set({ readAt }) + .where(eq(schema.siteAnnouncements.id, id)) + .run(); + return { success: true }; + }); + + app.post('/api/site-announcements/read-all', async () => { + const readAt = formatUtcSqlDateTime(new Date()); + await db.update(schema.siteAnnouncements) + .set({ readAt }) + .run(); + return { success: true }; + }); + + app.delete('/api/site-announcements', async () => { + await db.delete(schema.siteAnnouncements).run(); + return { success: true }; + }); + + app.post<{ Body: { siteId?: number | string | null } }>('/api/site-announcements/sync', async (request) => { + const parsedSiteId = Number.parseInt(String(request.body?.siteId ?? ''), 10); + const siteId = Number.isFinite(parsedSiteId) && parsedSiteId > 0 ? parsedSiteId : null; + const { task, reused } = startBackgroundTask( + { + type: 'site-announcements-sync', + title: siteId ? `同步站点公告 #${siteId}` : '同步站点公告', + dedupeKey: siteId ? `site-announcements:${siteId}` : 'site-announcements:all', + notifyOnSuccess: false, + notifyOnFailure: false, + }, + () => syncSiteAnnouncements(siteId ? { siteId } : undefined), + ); + + return { + success: true, + queued: true, + reused, + taskId: task.id, + }; + }); +} diff --git a/src/server/services/platforms/base.ts b/src/server/services/platforms/base.ts index b0acbfb..1b22dd7 100644 --- a/src/server/services/platforms/base.ts +++ b/src/server/services/platforms/base.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import type { RequestInit as UndiciRequestInit } from 'undici'; import { withSiteProxyRequestInit } from '../siteProxy.js'; @@ -44,6 +45,19 @@ export interface ApiTokenInfo { tokenGroup?: string | null; } +export interface SiteAnnouncement { + sourceKey: string; + title: string; + content: string; + level: 'info' | 'warning' | 'error'; + sourceUrl?: string | null; + startsAt?: string | null; + endsAt?: string | null; + upstreamCreatedAt?: string | null; + upstreamUpdatedAt?: string | null; + rawPayload?: unknown; +} + export interface CreateApiTokenOptions { name?: string; group?: string; @@ -66,6 +80,7 @@ export interface PlatformAdapter { getModels(baseUrl: string, token: string, platformUserId?: number): Promise; getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise; getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise; + getSiteAnnouncements(baseUrl: string, accessToken: string, platformUserId?: number): Promise; getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise; createApiToken(baseUrl: string, accessToken: string, platformUserId?: number, options?: CreateApiTokenOptions): Promise; deleteApiToken(baseUrl: string, accessToken: string, tokenKey: string, platformUserId?: number): Promise; @@ -149,6 +164,14 @@ export abstract class BasePlatformAdapter implements PlatformAdapter { return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }]; } + async getSiteAnnouncements( + _baseUrl: string, + _accessToken: string, + _platformUserId?: number, + ): Promise { + return []; + } + async createApiToken( _baseUrl: string, _accessToken: string, @@ -192,4 +215,9 @@ export abstract class BasePlatformAdapter implements PlatformAdapter { } return res.json() as Promise; } + + protected buildNoticeSourceKey(content: string): string { + const normalized = (content || '').trim(); + return `notice:${createHash('sha1').update(normalized).digest('hex')}`; + } } diff --git a/src/server/services/platforms/doneHub.test.ts b/src/server/services/platforms/doneHub.test.ts index 4ca539b..3222aab 100644 --- a/src/server/services/platforms/doneHub.test.ts +++ b/src/server/services/platforms/doneHub.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { AddressInfo } from 'node:net'; +import { createHash } from 'node:crypto'; import { DoneHubAdapter } from './doneHub.js'; describe('DoneHubAdapter', () => { @@ -48,6 +49,15 @@ describe('DoneHubAdapter', () => { return; } + if (req.url === '/api/notice') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + data: 'Scheduled maintenance tonight', + })); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'not found' })); }); @@ -93,4 +103,20 @@ describe('DoneHubAdapter', () => { expect(balance.used).toBe(60); expect(balance.quota).toBe(100); }); + + it('normalizes the global site notice from /api/notice', async () => { + const adapter = new DoneHubAdapter(); + const rows = await adapter.getSiteAnnouncements(baseUrl, 'token'); + + expect(rows).toEqual([ + { + sourceKey: `notice:${createHash('sha1').update('Scheduled maintenance tonight').digest('hex')}`, + title: 'Site notice', + content: 'Scheduled maintenance tonight', + level: 'info', + sourceUrl: '/api/notice', + rawPayload: { success: true, data: 'Scheduled maintenance tonight' }, + }, + ]); + }); }); diff --git a/src/server/services/platforms/doneHub.ts b/src/server/services/platforms/doneHub.ts index 922b7c7..a7cb3dd 100644 --- a/src/server/services/platforms/doneHub.ts +++ b/src/server/services/platforms/doneHub.ts @@ -1,5 +1,5 @@ import { OneHubAdapter } from './oneHub.js'; -import type { BalanceInfo, CheckinResult } from './base.js'; +import type { BalanceInfo, CheckinResult, SiteAnnouncement } from './base.js'; export class DoneHubAdapter extends OneHubAdapter { readonly platformName: string = 'done-hub'; @@ -32,6 +32,26 @@ export class DoneHubAdapter extends OneHubAdapter { return { balance: quotaRemaining, used, quota: total, todayIncome, todayQuotaConsumption }; } + override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise { + try { + const payload = await this.fetchJson(`${baseUrl}/api/notice`); + const content = typeof payload?.data === 'string' + ? payload.data.trim() + : (typeof payload === 'string' ? payload.trim() : ''); + if (!content) return []; + return [{ + sourceKey: this.buildNoticeSourceKey(content), + title: 'Site notice', + content, + level: 'info', + sourceUrl: '/api/notice', + rawPayload: payload, + }]; + } catch { + return []; + } + } + // getModels is inherited from OneHubAdapter which already has /api/available_model fallback. // No need to override here — OneHub's implementation handles this correctly. } diff --git a/src/server/services/platforms/newApi.test.ts b/src/server/services/platforms/newApi.test.ts index b11d090..9f7baeb 100644 --- a/src/server/services/platforms/newApi.test.ts +++ b/src/server/services/platforms/newApi.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { AddressInfo } from 'node:net'; +import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { NewApiAdapter } from './newApi.js'; @@ -142,6 +143,15 @@ describe('NewApiAdapter', () => { return; } + if (req.url === '/api/notice') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + data: 'Welcome to the site', + })); + return; + } + if (req.url?.startsWith('/api/token/')) { if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) { res.writeHead(401, { 'Content-Type': 'application/json' }); @@ -616,4 +626,20 @@ describe('NewApiAdapter', () => { expect(receivedHeaders['rix-api-user']).toBe('42'); expect(receivedHeaders['neo-api-user']).toBe('42'); }); + + it('normalizes the global site notice from /api/notice', async () => { + const adapter = new NewApiAdapter(); + const rows = await adapter.getSiteAnnouncements(baseUrl, 'session-token'); + + expect(rows).toEqual([ + { + sourceKey: `notice:${createHash('sha1').update('Welcome to the site').digest('hex')}`, + title: 'Site notice', + content: 'Welcome to the site', + level: 'info', + sourceUrl: '/api/notice', + rawPayload: { success: true, data: 'Welcome to the site' }, + }, + ]); + }); }); diff --git a/src/server/services/platforms/newApi.ts b/src/server/services/platforms/newApi.ts index 7d2c3fb..f74ec60 100644 --- a/src/server/services/platforms/newApi.ts +++ b/src/server/services/platforms/newApi.ts @@ -1,4 +1,4 @@ -import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js'; +import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions, type SiteAnnouncement } from './base.js'; import type { RequestInit as UndiciRequestInit } from 'undici'; import { createContext, runInContext } from 'node:vm'; import { withSiteProxyRequestInit } from '../siteProxy.js'; @@ -15,6 +15,26 @@ export class NewApiAdapter extends BasePlatformAdapter { } } + override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise { + try { + const payload = await this.fetchJson(`${baseUrl}/api/notice`); + const content = typeof payload?.data === 'string' + ? payload.data.trim() + : (typeof payload === 'string' ? payload.trim() : ''); + if (!content) return []; + return [{ + sourceKey: this.buildNoticeSourceKey(content), + title: 'Site notice', + content, + level: 'info', + sourceUrl: '/api/notice', + rawPayload: payload, + }]; + } catch { + return []; + } + } + private tryDecodeUserId(token: string): number | null { try { const parts = token.split('.'); diff --git a/src/server/services/platforms/siteAnnouncements.test.ts b/src/server/services/platforms/siteAnnouncements.test.ts new file mode 100644 index 0000000..dfa223e --- /dev/null +++ b/src/server/services/platforms/siteAnnouncements.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { + BasePlatformAdapter, + type BalanceInfo, + type CheckinResult, + type PlatformAdapter, + type SiteAnnouncement, +} from './base.js'; + +class UnsupportedAnnouncementAdapter extends BasePlatformAdapter { + override readonly platformName = 'unsupported'; + + override async detect(): Promise { + return false; + } + + override async checkin(): Promise { + return { success: false, message: 'unsupported' }; + } + + override async getBalance(): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + override async getModels(): Promise { + return []; + } +} + +class SingleNoticeAdapter extends UnsupportedAnnouncementAdapter { + override readonly platformName = 'single-notice'; + + override async getSiteAnnouncements(): Promise { + return [{ + sourceKey: 'notice:welcome', + title: 'Site notice', + content: 'Welcome to the site', + level: 'info', + sourceUrl: '/api/notice', + rawPayload: { notice: 'Welcome to the site' }, + }]; + } +} + +class ListAnnouncementAdapter extends UnsupportedAnnouncementAdapter { + override readonly platformName = 'list-notice'; + + override async getSiteAnnouncements(): Promise { + return [ + { + sourceKey: 'announcement:1', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'warning', + startsAt: '2026-03-20T10:00:00Z', + upstreamCreatedAt: '2026-03-20T09:00:00Z', + rawPayload: { id: 1 }, + }, + { + sourceKey: 'announcement:2', + title: 'New model online', + content: 'gpt-4.1 is available', + level: 'info', + endsAt: '2026-03-21T00:00:00Z', + upstreamUpdatedAt: '2026-03-20T12:00:00Z', + rawPayload: { id: 2 }, + }, + ]; + } +} + +function expectAnnouncementShape(row: SiteAnnouncement) { + expect(row.sourceKey).toBeTruthy(); + expect(row.title).toBeTruthy(); + expect(row.content).toBeTruthy(); + expect(['info', 'warning', 'error']).toContain(row.level); +} + +describe('site announcement platform contract', () => { + it('allows unsupported adapters to return an empty list', async () => { + const adapter: PlatformAdapter = new UnsupportedAnnouncementAdapter(); + + await expect(adapter.getSiteAnnouncements('https://example.com', 'token')).resolves.toEqual([]); + }); + + it('normalizes single-notice platforms into the shared announcement shape', async () => { + const adapter: PlatformAdapter = new SingleNoticeAdapter(); + + const rows = await adapter.getSiteAnnouncements('https://example.com', 'token'); + + expect(rows).toHaveLength(1); + expectAnnouncementShape(rows[0]); + }); + + it('normalizes list-style platforms into the shared announcement shape', async () => { + const adapter: PlatformAdapter = new ListAnnouncementAdapter(); + + const rows = await adapter.getSiteAnnouncements('https://example.com', 'token'); + + expect(rows).toHaveLength(2); + rows.forEach(expectAnnouncementShape); + }); +}); diff --git a/src/server/services/platforms/sub2api.test.ts b/src/server/services/platforms/sub2api.test.ts index 762115d..38b16f6 100644 --- a/src/server/services/platforms/sub2api.test.ts +++ b/src/server/services/platforms/sub2api.test.ts @@ -438,4 +438,79 @@ describe('Sub2ApiAdapter', () => { const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me'); expect(deleted).toBe(true); }); + + it('normalizes announcements from /api/v1/announcements', async () => { + await startServer((req, res) => { + if (req.url === '/api/v1/announcements?page=1&page_size=100') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + code: 0, + message: 'success', + data: { + items: [ + { + id: 11, + title: 'Maintenance', + content: 'Window starts at 10:00', + starts_at: '2026-03-20T10:00:00Z', + ends_at: '2026-03-20T12:00:00Z', + created_at: '2026-03-20T09:00:00Z', + updated_at: '2026-03-20T09:30:00Z', + }, + { + id: 12, + title: 'New model online', + content: 'gpt-4.1 is available', + read_at: '2026-03-20T12:05:00Z', + created_at: '2026-03-20T12:00:00Z', + updated_at: '2026-03-20T12:01:00Z', + }, + ], + }, + })); + return; + } + res.writeHead(404).end(); + }); + + const rows = await adapter.getSiteAnnouncements(baseUrl, 'jwt-token'); + + expect(rows).toEqual([ + { + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + startsAt: '2026-03-20T10:00:00Z', + endsAt: '2026-03-20T12:00:00Z', + upstreamCreatedAt: '2026-03-20T09:00:00Z', + upstreamUpdatedAt: '2026-03-20T09:30:00Z', + rawPayload: { + id: 11, + title: 'Maintenance', + content: 'Window starts at 10:00', + starts_at: '2026-03-20T10:00:00Z', + ends_at: '2026-03-20T12:00:00Z', + created_at: '2026-03-20T09:00:00Z', + updated_at: '2026-03-20T09:30:00Z', + }, + }, + { + sourceKey: 'announcement:12', + title: 'New model online', + content: 'gpt-4.1 is available', + level: 'info', + upstreamCreatedAt: '2026-03-20T12:00:00Z', + upstreamUpdatedAt: '2026-03-20T12:01:00Z', + rawPayload: { + id: 12, + title: 'New model online', + content: 'gpt-4.1 is available', + read_at: '2026-03-20T12:05:00Z', + created_at: '2026-03-20T12:00:00Z', + updated_at: '2026-03-20T12:01:00Z', + }, + }, + ]); + }); }); diff --git a/src/server/services/platforms/sub2api.ts b/src/server/services/platforms/sub2api.ts index 34a5881..23106e7 100644 --- a/src/server/services/platforms/sub2api.ts +++ b/src/server/services/platforms/sub2api.ts @@ -4,6 +4,7 @@ import { CheckinResult, BalanceInfo, CreateApiTokenOptions, + type SiteAnnouncement, UserInfo, } from './base.js'; @@ -486,6 +487,41 @@ export class Sub2ApiAdapter extends BasePlatformAdapter { return this.fetchModelsByToken(normalizedBase, discoveredApiToken); } + override async getSiteAnnouncements(baseUrl: string, accessToken: string): Promise { + try { + const endpoint = '/api/v1/announcements?page=1&page_size=100'; + const res = await this.fetchJson(`${normalizeBaseUrl(baseUrl)}${endpoint}`, { + headers: this.buildAuthHeader(accessToken), + }); + const data = this.parseSub2ApiEnvelope(res, endpoint); + const rawItems = Array.isArray(data) + ? data + : (Array.isArray(data?.items) ? data.items : []); + const rows: SiteAnnouncement[] = []; + for (const item of rawItems) { + const id = Number.parseInt(String(item?.id), 10); + if (!Number.isFinite(id) || id <= 0) continue; + const title = typeof item?.title === 'string' ? item.title.trim() : ''; + const content = typeof item?.content === 'string' ? item.content.trim() : ''; + if (!title && !content) continue; + rows.push({ + sourceKey: `announcement:${id}`, + title: title || `Announcement ${id}`, + content: content || title, + level: 'info', + startsAt: typeof item?.starts_at === 'string' ? item.starts_at : undefined, + endsAt: typeof item?.ends_at === 'string' ? item.ends_at : undefined, + upstreamCreatedAt: typeof item?.created_at === 'string' ? item.created_at : undefined, + upstreamUpdatedAt: typeof item?.updated_at === 'string' ? item.updated_at : undefined, + rawPayload: item, + }); + } + return rows; + } catch { + return []; + } + } + override async getApiTokens(baseUrl: string, accessToken: string): Promise { try { const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken); diff --git a/src/server/services/siteAnnouncementPollingService.test.ts b/src/server/services/siteAnnouncementPollingService.test.ts new file mode 100644 index 0000000..1977a20 --- /dev/null +++ b/src/server/services/siteAnnouncementPollingService.test.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const syncSiteAnnouncementsMock = vi.fn(); + +vi.mock('./siteAnnouncementService.js', () => ({ + syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args), +})); + +describe('siteAnnouncementPollingService', () => { + beforeEach(() => { + vi.useFakeTimers(); + syncSiteAnnouncementsMock.mockReset(); + }); + + afterEach(async () => { + const module = await import('./siteAnnouncementPollingService.js'); + module.stopSiteAnnouncementPolling(); + vi.useRealTimers(); + }); + + it('runs one immediate sync and then continues on the configured interval', async () => { + const module = await import('./siteAnnouncementPollingService.js'); + syncSiteAnnouncementsMock.mockResolvedValue(undefined); + + module.startSiteAnnouncementPolling(60_000); + await vi.advanceTimersByTimeAsync(0); + expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(60_000); + expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/server/services/siteAnnouncementPollingService.ts b/src/server/services/siteAnnouncementPollingService.ts new file mode 100644 index 0000000..fa157af --- /dev/null +++ b/src/server/services/siteAnnouncementPollingService.ts @@ -0,0 +1,35 @@ +import { syncSiteAnnouncements } from './siteAnnouncementService.js'; + +const DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS = 15 * 60 * 1000; + +let pollingTimer: ReturnType | null = null; +let syncRunning = false; + +async function runSyncOnce() { + if (syncRunning) return; + syncRunning = true; + try { + await syncSiteAnnouncements(); + } catch (error) { + console.error('[SiteAnnouncementPolling] Sync failed:', error); + } finally { + syncRunning = false; + } +} + +export function startSiteAnnouncementPolling(intervalMs = DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS) { + stopSiteAnnouncementPolling(); + pollingTimer = setInterval(() => { + void runSyncOnce(); + }, Math.max(10_000, intervalMs)); + pollingTimer.unref?.(); + void runSyncOnce(); + return { intervalMs: Math.max(10_000, intervalMs) }; +} + +export function stopSiteAnnouncementPolling() { + if (pollingTimer) { + clearInterval(pollingTimer); + pollingTimer = null; + } +} diff --git a/src/server/services/siteAnnouncementService.test.ts b/src/server/services/siteAnnouncementService.test.ts new file mode 100644 index 0000000..7230d04 --- /dev/null +++ b/src/server/services/siteAnnouncementService.test.ts @@ -0,0 +1,192 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const getAdapterMock = vi.fn(); +const sendNotificationMock = vi.fn(); + +vi.mock('./platforms/index.js', () => ({ + getAdapter: (...args: unknown[]) => getAdapterMock(...args), +})); + +vi.mock('./notifyService.js', () => ({ + sendNotification: (...args: unknown[]) => sendNotificationMock(...args), +})); + +type DbModule = typeof import('../db/index.js'); +type ServiceModule = typeof import('./siteAnnouncementService.js'); + +describe('siteAnnouncementService', () => { + let dataDir = ''; + let db: DbModule['db']; + let schema: DbModule['schema']; + let closeDbConnections: DbModule['closeDbConnections']; + let syncSiteAnnouncements: ServiceModule['syncSiteAnnouncements']; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-service-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const serviceModule = await import('./siteAnnouncementService.js'); + db = dbModule.db; + schema = dbModule.schema; + closeDbConnections = dbModule.closeDbConnections; + syncSiteAnnouncements = serviceModule.syncSiteAnnouncements; + }); + + beforeEach(async () => { + vi.useFakeTimers(); + getAdapterMock.mockReset(); + sendNotificationMock.mockReset(); + + await db.delete(schema.siteAnnouncements).run(); + await db.delete(schema.events).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + vi.useRealTimers(); + if (typeof closeDbConnections === 'function') { + await closeDbConnections(); + } + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + delete process.env.DATA_DIR; + }); + + it('stores first-seen announcements, creates one event, and sends one notification', async () => { + vi.setSystemTime(new Date('2026-03-20T10:00:00Z')); + + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + status: 'active', + }).returning().get(); + await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'demo-user', + accessToken: 'jwt-token', + status: 'active', + }).run(); + await db.insert(schema.sites).values({ + name: 'Unsupported Site', + url: 'https://unsupported.example.com', + platform: 'openai', + status: 'active', + }).run(); + + getAdapterMock.mockImplementation((platform: string) => { + if (platform === 'sub2api') { + return { + getSiteAnnouncements: vi.fn(async (_baseUrl: string, accessToken: string) => { + expect(accessToken).toBe('jwt-token'); + return [{ + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + rawPayload: { id: 11, title: 'Maintenance' }, + }]; + }), + }; + } + return { + getSiteAnnouncements: undefined, + }; + }); + + const result = await syncSiteAnnouncements(); + + expect(result).toMatchObject({ + scannedSites: 2, + inserted: 1, + updated: 0, + unsupported: 1, + notifications: 1, + events: 1, + failed: 0, + }); + + const announcementRows = await db.select().from(schema.siteAnnouncements).all(); + expect(announcementRows).toHaveLength(1); + expect(announcementRows[0]).toMatchObject({ + siteId: site.id, + platform: 'sub2api', + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + }); + + const eventRows = await db.select().from(schema.events).all(); + expect(eventRows).toHaveLength(1); + expect(eventRows[0]).toMatchObject({ + type: 'site_notice', + relatedType: 'site_announcement', + }); + expect(Number(eventRows[0]?.relatedId)).toBe(Number(announcementRows[0]?.id)); + + expect(sendNotificationMock).toHaveBeenCalledTimes(1); + expect(sendNotificationMock.mock.calls[0]?.[0]).toContain('Sub Site'); + expect(sendNotificationMock.mock.calls[0]?.[1]).toContain('Window starts at 10:00'); + expect(sendNotificationMock.mock.calls[0]?.[2]).toBe('info'); + }); + + it('updates existing announcements without duplicating events or notifications', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Sub Site', + url: 'https://sub.example.com', + platform: 'sub2api', + status: 'active', + }).returning().get(); + await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'demo-user', + accessToken: 'jwt-token', + status: 'active', + }).run(); + + getAdapterMock.mockReturnValue({ + getSiteAnnouncements: vi.fn(async () => [{ + sourceKey: 'announcement:11', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + rawPayload: { id: 11, title: 'Maintenance' }, + }]), + }); + + vi.setSystemTime(new Date('2026-03-20T10:00:00Z')); + await syncSiteAnnouncements({ siteId: site.id }); + const firstRow = await db.select().from(schema.siteAnnouncements).get(); + + vi.setSystemTime(new Date('2026-03-20T11:00:00Z')); + const result = await syncSiteAnnouncements({ siteId: site.id }); + + expect(result).toMatchObject({ + scannedSites: 1, + inserted: 0, + updated: 1, + notifications: 0, + events: 0, + failed: 0, + }); + + const announcementRows = await db.select().from(schema.siteAnnouncements).all(); + expect(announcementRows).toHaveLength(1); + expect(announcementRows[0]?.firstSeenAt).toBe(firstRow?.firstSeenAt); + expect(announcementRows[0]?.lastSeenAt).not.toBe(firstRow?.lastSeenAt); + + const eventRows = await db.select().from(schema.events).all(); + expect(eventRows).toHaveLength(1); + expect(sendNotificationMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/server/services/siteAnnouncementService.ts b/src/server/services/siteAnnouncementService.ts new file mode 100644 index 0000000..73c53a4 --- /dev/null +++ b/src/server/services/siteAnnouncementService.ts @@ -0,0 +1,163 @@ +import { and, asc, eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { getAdapter } from './platforms/index.js'; +import { sendNotification } from './notifyService.js'; +import { formatUtcSqlDateTime } from './localTimeService.js'; +import type { SiteAnnouncement } from './platforms/base.js'; + +export type SiteAnnouncementSyncResult = { + scannedSites: number; + inserted: number; + updated: number; + unsupported: number; + notifications: number; + events: number; + failed: number; + failedSites: Array<{ siteId: number; siteName: string; message: string }>; +}; + +function toStoredPayload(rawPayload: unknown): string | null { + if (rawPayload === undefined) return null; + try { + return JSON.stringify(rawPayload); + } catch { + return null; + } +} + +function buildAnnouncementMessage(row: SiteAnnouncement): string { + const title = String(row.title || '').trim(); + const content = String(row.content || '').trim(); + if (title && content && title !== content && title.toLowerCase() !== 'site notice') { + return `${title}\n${content}`; + } + return content || title; +} + +async function resolveSiteAccessToken(siteId: number, siteApiKey?: string | null): Promise { + const direct = String(siteApiKey || '').trim(); + if (direct) return direct; + + const account = await db.select() + .from(schema.accounts) + .where(and( + eq(schema.accounts.siteId, siteId), + eq(schema.accounts.status, 'active'), + )) + .orderBy(asc(schema.accounts.id)) + .limit(1) + .get(); + + return String(account?.accessToken || '').trim(); +} + +async function listTargetSites(siteId?: number | null) { + if (siteId && Number.isFinite(siteId) && siteId > 0) { + return await db.select() + .from(schema.sites) + .where(eq(schema.sites.id, siteId)) + .all(); + } + + return await db.select() + .from(schema.sites) + .where(eq(schema.sites.status, 'active')) + .all(); +} + +export async function syncSiteAnnouncements(options?: { siteId?: number | null }): Promise { + const result: SiteAnnouncementSyncResult = { + scannedSites: 0, + inserted: 0, + updated: 0, + unsupported: 0, + notifications: 0, + events: 0, + failed: 0, + failedSites: [], + }; + + const sites = await listTargetSites(options?.siteId ?? null); + + for (const site of sites) { + result.scannedSites += 1; + const adapter = getAdapter(String(site.platform || '')); + if (!adapter || typeof adapter.getSiteAnnouncements !== 'function') { + result.unsupported += 1; + continue; + } + + try { + const accessToken = await resolveSiteAccessToken(site.id, site.apiKey); + const announcements = await adapter.getSiteAnnouncements(site.url, accessToken); + const seenAt = formatUtcSqlDateTime(new Date()); + + for (const announcement of announcements) { + const existing = await db.select() + .from(schema.siteAnnouncements) + .where(and( + eq(schema.siteAnnouncements.siteId, site.id), + eq(schema.siteAnnouncements.sourceKey, announcement.sourceKey), + )) + .limit(1) + .get(); + + const patch = { + platform: String(site.platform || '').trim(), + title: announcement.title, + content: announcement.content, + level: announcement.level, + sourceUrl: announcement.sourceUrl ?? null, + startsAt: announcement.startsAt ?? null, + endsAt: announcement.endsAt ?? null, + upstreamCreatedAt: announcement.upstreamCreatedAt ?? null, + upstreamUpdatedAt: announcement.upstreamUpdatedAt ?? null, + lastSeenAt: seenAt, + rawPayload: toStoredPayload(announcement.rawPayload), + }; + + if (existing) { + await db.update(schema.siteAnnouncements) + .set(patch) + .where(eq(schema.siteAnnouncements.id, existing.id)) + .run(); + result.updated += 1; + continue; + } + + const inserted = await db.insert(schema.siteAnnouncements).values({ + siteId: site.id, + sourceKey: announcement.sourceKey, + firstSeenAt: seenAt, + ...patch, + }).returning().get(); + result.inserted += 1; + + const title = `站点公告:${site.name}`; + const message = buildAnnouncementMessage(announcement); + await db.insert(schema.events).values({ + type: 'site_notice', + title, + message, + level: announcement.level, + relatedId: inserted.id, + relatedType: 'site_announcement', + createdAt: seenAt, + }).run(); + result.events += 1; + + await sendNotification(title, message, announcement.level); + result.notifications += 1; + } + } catch (error) { + result.failed += 1; + result.failedSites.push({ + siteId: site.id, + siteName: site.name, + message: error instanceof Error && error.message ? error.message : 'unknown error', + }); + } + } + + return result; +} diff --git a/src/web/App.tsx b/src/web/App.tsx index cc68962..82cfa44 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -35,6 +35,7 @@ const About = lazy(() => import('./pages/About.js')); const ModelTester = lazy(() => import('./pages/ModelTester.js')); const Monitors = lazy(() => import('./pages/Monitors.js')); const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js')); +const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js')); type ThemeMode = 'system' | 'light' | 'dark'; @@ -322,12 +323,13 @@ function UserProfileModal({ ); } -const sidebarGroups = [ +export const sidebarGroups = [ { label: '控制台', items: [ { to: '/', label: '仪表盘', icon: }, - { to: '/sites', label: '站点', icon: }, + { to: '/sites', label: '站点管理', icon: }, + { to: '/site-announcements', label: '站点公告', icon: }, { to: '/accounts', label: '连接管理', icon: }, { to: '/oauth', label: 'OAuth 管理', icon: }, { to: '/downstream-keys', label: '下游密钥', icon: }, @@ -764,6 +766,7 @@ function AppShell() { } /> } /> + } /> } /> } /> } /> diff --git a/src/web/api.ts b/src/web/api.ts index fd39f2e..c39ffc4 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -478,6 +478,14 @@ export const api = { markEventRead: (id: number) => request(`/api/events/${id}/read`, { method: 'POST' }), markAllEventsRead: () => request('/api/events/read-all', { method: 'POST' }), clearEvents: () => request('/api/events', { method: 'DELETE' }), + getSiteAnnouncements: (params?: string) => request(`/api/site-announcements${params ? '?' + params : ''}`), + markSiteAnnouncementRead: (id: number) => request(`/api/site-announcements/${id}/read`, { method: 'POST' }), + markAllSiteAnnouncementsRead: () => request('/api/site-announcements/read-all', { method: 'POST' }), + clearSiteAnnouncements: () => request('/api/site-announcements', { method: 'DELETE' }), + syncSiteAnnouncements: (payload?: { siteId?: number }) => request('/api/site-announcements/sync', { + method: 'POST', + body: JSON.stringify(payload || {}), + }), getTasks: (limit = 50) => request(`/api/tasks?limit=${Math.max(1, Math.min(200, Math.trunc(limit)))}`), getTask: (id: string) => request(`/api/tasks/${encodeURIComponent(id)}`), diff --git a/src/web/components/NotificationPanel.tsx b/src/web/components/NotificationPanel.tsx index 8dd8dc7..b259c2f 100644 --- a/src/web/components/NotificationPanel.tsx +++ b/src/web/components/NotificationPanel.tsx @@ -18,6 +18,7 @@ const typeLabels: Record = { token: '令牌', proxy: '代理', status: '状态', + site_notice: '站点公告', }; export default function NotificationPanel({ @@ -97,7 +98,7 @@ export default function NotificationPanel({ {/* Filters */}
- {['', 'checkin', 'balance', 'token', 'proxy', 'status'].map((filterType) => ( + {['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => ( + + + +
+ + +
+ {loading ? ( +
+ +
+ ) : rows.length === 0 ? ( +
+
暂无公告
+
当前没有可显示的站点公告。
+
+ ) : ( + rows.map((row, index) => ( +
{ + if (node) rowRefs.current.set(row.id, node); + else rowRefs.current.delete(row.id); + }} + className={`animate-slide-up stagger-${Math.min(index + 1, 5)} ${highlightAnnouncementId === row.id ? 'row-focus-highlight' : ''}`.trim()} + style={{ + padding: '16px 18px', + borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)', + }} + > +
+
{row.title}
+
+ {siteNameById.get(row.siteId) || `站点 #${row.siteId}`} + {row.platform} + {row.readAt ? '已读' : '未读'} +
+
+
{row.content}
+
+ 首次发现:{formatDateTimeLocal(row.firstSeenAt || row.lastSeenAt || '')} +
+
+ )) + )} +
+ + ); +} diff --git a/src/web/pages/helpers/navigationFocus.test.ts b/src/web/pages/helpers/navigationFocus.test.ts index 93a0941..03dd7cb 100644 --- a/src/web/pages/helpers/navigationFocus.test.ts +++ b/src/web/pages/helpers/navigationFocus.test.ts @@ -5,6 +5,7 @@ import { buildSiteFocusPath, clearFocusParams, readFocusAccountIntent, + readFocusAnnouncementId, readFocusSiteId, } from './navigationFocus.js'; @@ -20,6 +21,8 @@ describe('navigationFocus helpers', () => { it('parses focus params from query string', () => { expect(readFocusSiteId('?focusSiteId=15')).toBe(15); expect(readFocusSiteId('?focusSiteId=abc')).toBeNull(); + expect(readFocusAnnouncementId('?focusAnnouncementId=21')).toBe(21); + expect(readFocusAnnouncementId('?focusAnnouncementId=abc')).toBeNull(); expect(readFocusAccountIntent('?focusAccountId=22&openRebind=1')).toEqual({ accountId: 22, @@ -37,6 +40,7 @@ describe('navigationFocus helpers', () => { it('clears focus params but keeps other params', () => { expect(clearFocusParams('?focusSiteId=12&q=abc')).toBe('?q=abc'); + expect(clearFocusParams('?focusAnnouncementId=44&type=site_notice')).toBe('?type=site_notice'); expect(clearFocusParams('?focusAccountId=2&openRebind=1&type=token')).toBe('?type=token'); expect(clearFocusParams('?focusAccountId=2')).toBe(''); }); @@ -60,6 +64,12 @@ describe('navigationFocus helpers', () => { type: 'status', })).toBe('/sites?focusSiteId=9'); + expect(buildEventNavigationPath({ + relatedType: 'site_announcement', + relatedId: 12, + type: 'site_notice', + })).toBe('/site-announcements?focusAnnouncementId=12'); + expect(buildEventNavigationPath({ relatedType: 'route', relatedId: null, @@ -68,6 +78,7 @@ describe('navigationFocus helpers', () => { expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs'); expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin'); + expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements'); expect(buildEventNavigationPath({})).toBe('/events'); }); }); diff --git a/src/web/pages/helpers/navigationFocus.ts b/src/web/pages/helpers/navigationFocus.ts index 7e91a76..117e4ee 100644 --- a/src/web/pages/helpers/navigationFocus.ts +++ b/src/web/pages/helpers/navigationFocus.ts @@ -1,4 +1,5 @@ const FOCUS_SITE_ID_KEY = 'focusSiteId'; +const FOCUS_ANNOUNCEMENT_ID_KEY = 'focusAnnouncementId'; const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId'; const FOCUS_TOKEN_ID_KEY = 'focusTokenId'; const OPEN_REBIND_KEY = 'openRebind'; @@ -21,6 +22,12 @@ export function buildSiteFocusPath(siteId: number): string { return `/sites?${FOCUS_SITE_ID_KEY}=${normalizedId}`; } +export function buildAnnouncementFocusPath(announcementId: number): string { + const normalizedId = normalizePositiveId(announcementId); + if (!normalizedId) return '/site-announcements'; + return `/site-announcements?${FOCUS_ANNOUNCEMENT_ID_KEY}=${normalizedId}`; +} + export function buildAccountFocusPath( accountId: number, options?: { openRebind?: boolean; segment?: 'session' | 'apikey' | 'tokens' }, @@ -48,6 +55,11 @@ export function readFocusSiteId(search: string): number | null { return normalizePositiveId(params.get(FOCUS_SITE_ID_KEY)); } +export function readFocusAnnouncementId(search: string): number | null { + const params = new URLSearchParams(search); + return normalizePositiveId(params.get(FOCUS_ANNOUNCEMENT_ID_KEY)); +} + export function readFocusAccountIntent(search: string): { accountId: number | null; openRebind: boolean } { const params = new URLSearchParams(search); return { @@ -64,6 +76,7 @@ export function readFocusTokenId(search: string): number | null { export function clearFocusParams(search: string): string { const params = new URLSearchParams(search); params.delete(FOCUS_SITE_ID_KEY); + params.delete(FOCUS_ANNOUNCEMENT_ID_KEY); params.delete(FOCUS_ACCOUNT_ID_KEY); params.delete(FOCUS_TOKEN_ID_KEY); params.delete(OPEN_REBIND_KEY); @@ -86,6 +99,9 @@ export function buildEventNavigationPath(event: { if (relatedType === 'site' && relatedId) { return buildSiteFocusPath(relatedId); } + if (relatedType === 'site_announcement' && relatedId) { + return buildAnnouncementFocusPath(relatedId); + } if (relatedType === 'route') { return '/routes'; } @@ -95,6 +111,9 @@ export function buildEventNavigationPath(event: { if (eventType === 'checkin') { return '/checkin'; } + if (eventType === 'site_notice') { + return '/site-announcements'; + } return '/events'; } diff --git a/src/web/pages/siteAnnouncements.test.tsx b/src/web/pages/siteAnnouncements.test.tsx new file mode 100644 index 0000000..3fb956d --- /dev/null +++ b/src/web/pages/siteAnnouncements.test.tsx @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, create } from 'react-test-renderer'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { ToastProvider } from '../components/Toast.js'; +import SiteAnnouncements from './SiteAnnouncements.js'; +import { sidebarGroups } from '../App.js'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: { + getSiteAnnouncements: vi.fn(), + getSites: vi.fn(), + markSiteAnnouncementRead: vi.fn(), + markAllSiteAnnouncementsRead: vi.fn(), + clearSiteAnnouncements: vi.fn(), + syncSiteAnnouncements: vi.fn(), + }, +})); + +vi.mock('../api.js', () => ({ + api: apiMock, +})); + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function collectText(node: any): string { + const children = node?.children || []; + return children.map((child: any) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +describe('SiteAnnouncements page', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMock.getSites.mockResolvedValue([ + { id: 9, name: 'Sub Site', platform: 'sub2api' }, + ]); + apiMock.getSiteAnnouncements.mockResolvedValue([ + { + id: 12, + siteId: 9, + platform: 'sub2api', + sourceKey: 'announcement:12', + title: 'Maintenance', + content: 'Window starts at 10:00', + level: 'info', + firstSeenAt: '2026-03-20 10:00:00', + lastSeenAt: '2026-03-20 10:00:00', + readAt: null, + }, + ]); + apiMock.clearSiteAnnouncements.mockResolvedValue({ success: true }); + apiMock.markAllSiteAnnouncementsRead.mockResolvedValue({ success: true }); + apiMock.syncSiteAnnouncements.mockResolvedValue({ success: true, queued: true, taskId: 'task-1' }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('exposes the renamed sidebar item and the site announcements entry', () => { + const consoleGroup = sidebarGroups.find((group) => group.label === '控制台'); + const labels = (consoleGroup?.items || []).map((item) => item.label); + + expect(labels).toContain('站点管理'); + expect(labels).toContain('站点公告'); + }); + + it('loads announcements, highlights the focused row, and clears local rows', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + } /> + + + , + { + createNodeMock: (element) => { + if (element.type === 'div') { + return { + scrollIntoView: () => undefined, + }; + } + return {}; + }, + }, + ); + }); + await flushMicrotasks(); + + expect(apiMock.getSiteAnnouncements).toHaveBeenCalled(); + expect(apiMock.getSites).toHaveBeenCalled(); + expect(JSON.stringify(root!.toJSON())).toContain('站点公告'); + expect(JSON.stringify(root!.toJSON())).toContain('Maintenance'); + + const highlightedRows = root!.root.findAll((node) => { + const className = typeof node.props.className === 'string' ? node.props.className : ''; + return className.includes('row-focus-highlight') && collectText(node).includes('Maintenance'); + }); + expect(highlightedRows).toHaveLength(1); + + const clearButton = root!.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('清空公告') + )); + + await act(async () => { + await clearButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.clearSiteAnnouncements).toHaveBeenCalledTimes(1); + } finally { + root?.unmount(); + } + }); +});