forked from boos/BoosAPI
add site announcements
This commit is contained in:
@@ -213,6 +213,15 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|
|||||||
|
|
||||||
> `TOKEN_ROUTER_CACHE_TTL_MS`、`PROXY_LOG_RETENTION_DAYS`、`PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES` 当前仍属于部署级环境变量,不在后台运行时设置里单独维护。
|
> `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 与反向代理
|
- [部署指南](./deployment.md) — Docker Compose 与反向代理
|
||||||
|
|||||||
@@ -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 本地数据库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🛠️ 故障排查
|
## 🛠️ 故障排查
|
||||||
|
|
||||||
### 问题:添加站点后无法获取模型列表
|
### 问题:添加站点后无法获取模型列表
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -99,6 +99,13 @@
|
|||||||
"when": 1773855000000,
|
"when": 1773855000000,
|
||||||
"tag": "0014_explicit_group_routes",
|
"tag": "0014_explicit_group_routes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773900000000,
|
||||||
|
"tag": "0015_site_announcements",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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 `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 `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 `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 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));
|
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_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 `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 `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 `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 `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));
|
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_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_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 `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 `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`);
|
||||||
CREATE INDEX `sites_status_idx` ON `sites` (`status`(191));
|
CREATE INDEX `sites_status_idx` ON `sites` (`status`(191));
|
||||||
CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`);
|
CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`);
|
||||||
|
|||||||
@@ -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);
|
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);
|
||||||
ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(191) DEFAULT 'pattern';
|
CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191));
|
||||||
CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`);
|
CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`);
|
||||||
CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`);
|
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`);
|
||||||
|
|||||||
@@ -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_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 "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 "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 "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 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");
|
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_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 "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 "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 "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 "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");
|
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_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_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 "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 "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id");
|
||||||
CREATE INDEX "sites_status_idx" ON "sites" ("status");
|
CREATE INDEX "sites_status_idx" ON "sites" ("status");
|
||||||
CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available");
|
CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available");
|
||||||
|
|||||||
@@ -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);
|
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);
|
||||||
ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT 'pattern';
|
CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key");
|
||||||
CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id");
|
CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at");
|
||||||
CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id");
|
CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at");
|
||||||
|
|||||||
@@ -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": {
|
"site_disabled_models": {
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1468,6 +1574,32 @@
|
|||||||
],
|
],
|
||||||
"unique": false
|
"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",
|
"name": "site_disabled_models_site_id_idx",
|
||||||
"table": "site_disabled_models",
|
"table": "site_disabled_models",
|
||||||
@@ -1591,6 +1723,14 @@
|
|||||||
"source_route_id"
|
"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",
|
"name": "site_disabled_models_site_model_unique",
|
||||||
"table": "site_disabled_models",
|
"table": "site_disabled_models",
|
||||||
@@ -1716,6 +1856,17 @@
|
|||||||
],
|
],
|
||||||
"onDelete": "CASCADE"
|
"onDelete": "CASCADE"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"table": "site_announcements",
|
||||||
|
"columns": [
|
||||||
|
"site_id"
|
||||||
|
],
|
||||||
|
"referencedTable": "sites",
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "CASCADE"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"table": "site_disabled_models",
|
"table": "site_disabled_models",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
|
|||||||
'proxy_video_tasks',
|
'proxy_video_tasks',
|
||||||
'proxy_files',
|
'proxy_files',
|
||||||
'downstream_api_keys',
|
'downstream_api_keys',
|
||||||
|
'site_announcements',
|
||||||
'events',
|
'events',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -276,6 +276,30 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
|
|||||||
expiresAtIdx: index('downstream_api_keys_expires_at_idx').on(table.expiresAt),
|
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', {
|
export const events = sqliteTable('events', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
|
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import { testRoutes } from './routes/api/test.js';
|
|||||||
import { monitorRoutes } from './routes/api/monitor.js';
|
import { monitorRoutes } from './routes/api/monitor.js';
|
||||||
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
||||||
import { oauthRoutes } from './routes/api/oauth.js';
|
import { oauthRoutes } from './routes/api/oauth.js';
|
||||||
|
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||||
import { proxyRoutes } from './routes/proxy/router.js';
|
import { proxyRoutes } from './routes/proxy/router.js';
|
||||||
import { startScheduler } from './services/checkinScheduler.js';
|
import { startScheduler } from './services/checkinScheduler.js';
|
||||||
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||||
@@ -26,6 +27,7 @@ import { repairStoredCreatedAtValues } from './services/storedTimestampRepairSer
|
|||||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||||
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
||||||
|
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
|
||||||
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
||||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
@@ -320,6 +322,7 @@ await app.register(settingsRoutes);
|
|||||||
await app.register(accountTokensRoutes);
|
await app.register(accountTokensRoutes);
|
||||||
await app.register(searchRoutes);
|
await app.register(searchRoutes);
|
||||||
await app.register(eventsRoutes);
|
await app.register(eventsRoutes);
|
||||||
|
await app.register(siteAnnouncementsRoutes);
|
||||||
await app.register(taskRoutes);
|
await app.register(taskRoutes);
|
||||||
await app.register(testRoutes);
|
await app.register(testRoutes);
|
||||||
await app.register(monitorRoutes);
|
await app.register(monitorRoutes);
|
||||||
@@ -358,6 +361,7 @@ if (existsSync(webDir)) {
|
|||||||
|
|
||||||
// Start scheduler
|
// Start scheduler
|
||||||
await startScheduler();
|
await startScheduler();
|
||||||
|
startSiteAnnouncementPolling();
|
||||||
try {
|
try {
|
||||||
await startOAuthLoopbackCallbackServers();
|
await startOAuthLoopbackCallbackServers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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' })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||||
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||||
|
|
||||||
@@ -44,6 +45,19 @@ export interface ApiTokenInfo {
|
|||||||
tokenGroup?: string | null;
|
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 {
|
export interface CreateApiTokenOptions {
|
||||||
name?: string;
|
name?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
@@ -66,6 +80,7 @@ export interface PlatformAdapter {
|
|||||||
getModels(baseUrl: string, token: string, platformUserId?: number): Promise<string[]>;
|
getModels(baseUrl: string, token: string, platformUserId?: number): Promise<string[]>;
|
||||||
getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string | null>;
|
getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string | null>;
|
||||||
getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise<ApiTokenInfo[]>;
|
getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise<ApiTokenInfo[]>;
|
||||||
|
getSiteAnnouncements(baseUrl: string, accessToken: string, platformUserId?: number): Promise<SiteAnnouncement[]>;
|
||||||
getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string[]>;
|
getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string[]>;
|
||||||
createApiToken(baseUrl: string, accessToken: string, platformUserId?: number, options?: CreateApiTokenOptions): Promise<boolean>;
|
createApiToken(baseUrl: string, accessToken: string, platformUserId?: number, options?: CreateApiTokenOptions): Promise<boolean>;
|
||||||
deleteApiToken(baseUrl: string, accessToken: string, tokenKey: string, platformUserId?: number): Promise<boolean>;
|
deleteApiToken(baseUrl: string, accessToken: string, tokenKey: string, platformUserId?: number): Promise<boolean>;
|
||||||
@@ -149,6 +164,14 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
|||||||
return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }];
|
return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSiteAnnouncements(
|
||||||
|
_baseUrl: string,
|
||||||
|
_accessToken: string,
|
||||||
|
_platformUserId?: number,
|
||||||
|
): Promise<SiteAnnouncement[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
async createApiToken(
|
async createApiToken(
|
||||||
_baseUrl: string,
|
_baseUrl: string,
|
||||||
_accessToken: string,
|
_accessToken: string,
|
||||||
@@ -192,4 +215,9 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
|||||||
}
|
}
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected buildNoticeSourceKey(content: string): string {
|
||||||
|
const normalized = (content || '').trim();
|
||||||
|
return `notice:${createHash('sha1').update(normalized).digest('hex')}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { AddressInfo } from 'node:net';
|
import { AddressInfo } from 'node:net';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { DoneHubAdapter } from './doneHub.js';
|
import { DoneHubAdapter } from './doneHub.js';
|
||||||
|
|
||||||
describe('DoneHubAdapter', () => {
|
describe('DoneHubAdapter', () => {
|
||||||
@@ -48,6 +49,15 @@ describe('DoneHubAdapter', () => {
|
|||||||
return;
|
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.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'not found' }));
|
res.end(JSON.stringify({ error: 'not found' }));
|
||||||
});
|
});
|
||||||
@@ -93,4 +103,20 @@ describe('DoneHubAdapter', () => {
|
|||||||
expect(balance.used).toBe(60);
|
expect(balance.used).toBe(60);
|
||||||
expect(balance.quota).toBe(100);
|
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' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OneHubAdapter } from './oneHub.js';
|
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 {
|
export class DoneHubAdapter extends OneHubAdapter {
|
||||||
readonly platformName: string = 'done-hub';
|
readonly platformName: string = 'done-hub';
|
||||||
@@ -32,6 +32,26 @@ export class DoneHubAdapter extends OneHubAdapter {
|
|||||||
return { balance: quotaRemaining, used, quota: total, todayIncome, todayQuotaConsumption };
|
return { balance: quotaRemaining, used, quota: total, todayIncome, todayQuotaConsumption };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<SiteAnnouncement[]> {
|
||||||
|
try {
|
||||||
|
const payload = await this.fetchJson<any>(`${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.
|
// getModels is inherited from OneHubAdapter which already has /api/available_model fallback.
|
||||||
// No need to override here — OneHub's implementation handles this correctly.
|
// No need to override here — OneHub's implementation handles this correctly.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { AddressInfo } from 'node:net';
|
import { AddressInfo } from 'node:net';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { NewApiAdapter } from './newApi.js';
|
import { NewApiAdapter } from './newApi.js';
|
||||||
|
|
||||||
@@ -142,6 +143,15 @@ describe('NewApiAdapter', () => {
|
|||||||
return;
|
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 (req.url?.startsWith('/api/token/')) {
|
||||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) {
|
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) {
|
||||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
@@ -616,4 +626,20 @@ describe('NewApiAdapter', () => {
|
|||||||
expect(receivedHeaders['rix-api-user']).toBe('42');
|
expect(receivedHeaders['rix-api-user']).toBe('42');
|
||||||
expect(receivedHeaders['neo-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' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 type { RequestInit as UndiciRequestInit } from 'undici';
|
||||||
import { createContext, runInContext } from 'node:vm';
|
import { createContext, runInContext } from 'node:vm';
|
||||||
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||||
@@ -15,6 +15,26 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<SiteAnnouncement[]> {
|
||||||
|
try {
|
||||||
|
const payload = await this.fetchJson<any>(`${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 {
|
private tryDecodeUserId(token: string): number | null {
|
||||||
try {
|
try {
|
||||||
const parts = token.split('.');
|
const parts = token.split('.');
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
override async checkin(): Promise<CheckinResult> {
|
||||||
|
return { success: false, message: 'unsupported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getBalance(): Promise<BalanceInfo> {
|
||||||
|
return { balance: 0, used: 0, quota: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getModels(): Promise<string[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SingleNoticeAdapter extends UnsupportedAnnouncementAdapter {
|
||||||
|
override readonly platformName = 'single-notice';
|
||||||
|
|
||||||
|
override async getSiteAnnouncements(): Promise<SiteAnnouncement[]> {
|
||||||
|
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<SiteAnnouncement[]> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -438,4 +438,79 @@ describe('Sub2ApiAdapter', () => {
|
|||||||
const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me');
|
const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me');
|
||||||
expect(deleted).toBe(true);
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CheckinResult,
|
CheckinResult,
|
||||||
BalanceInfo,
|
BalanceInfo,
|
||||||
CreateApiTokenOptions,
|
CreateApiTokenOptions,
|
||||||
|
type SiteAnnouncement,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
} from './base.js';
|
} from './base.js';
|
||||||
|
|
||||||
@@ -486,6 +487,41 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
return this.fetchModelsByToken(normalizedBase, discoveredApiToken);
|
return this.fetchModelsByToken(normalizedBase, discoveredApiToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override async getSiteAnnouncements(baseUrl: string, accessToken: string): Promise<SiteAnnouncement[]> {
|
||||||
|
try {
|
||||||
|
const endpoint = '/api/v1/announcements?page=1&page_size=100';
|
||||||
|
const res = await this.fetchJson<any>(`${normalizeBaseUrl(baseUrl)}${endpoint}`, {
|
||||||
|
headers: this.buildAuthHeader(accessToken),
|
||||||
|
});
|
||||||
|
const data = this.parseSub2ApiEnvelope<any>(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<ApiTokenInfo[]> {
|
override async getApiTokens(baseUrl: string, accessToken: string): Promise<ApiTokenInfo[]> {
|
||||||
try {
|
try {
|
||||||
const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken);
|
const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { syncSiteAnnouncements } from './siteAnnouncementService.js';
|
||||||
|
|
||||||
|
const DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
let pollingTimer: ReturnType<typeof setInterval> | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
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<SiteAnnouncementSyncResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
+5
-2
@@ -35,6 +35,7 @@ const About = lazy(() => import('./pages/About.js'));
|
|||||||
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
||||||
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
||||||
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
||||||
|
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
|
||||||
|
|
||||||
type ThemeMode = 'system' | 'light' | 'dark';
|
type ThemeMode = 'system' | 'light' | 'dark';
|
||||||
|
|
||||||
@@ -322,12 +323,13 @@ function UserProfileModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarGroups = [
|
export const sidebarGroups = [
|
||||||
{
|
{
|
||||||
label: '控制台',
|
label: '控制台',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
|
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
|
||||||
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
|
{ to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
|
||||||
|
{ to: '/site-announcements', label: '站点公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||||
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
|
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
|
||||||
{ to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> },
|
{ to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> },
|
||||||
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
|
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
|
||||||
@@ -764,6 +766,7 @@ function AppShell() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard adminName={displayName} />} />
|
<Route path="/" element={<Dashboard adminName={displayName} />} />
|
||||||
<Route path="/sites" element={<Sites />} />
|
<Route path="/sites" element={<Sites />} />
|
||||||
|
<Route path="/site-announcements" element={<SiteAnnouncements />} />
|
||||||
<Route path="/accounts" element={<Accounts />} />
|
<Route path="/accounts" element={<Accounts />} />
|
||||||
<Route path="/oauth" element={<OAuthManagement />} />
|
<Route path="/oauth" element={<OAuthManagement />} />
|
||||||
<Route path="/tokens" element={<Tokens />} />
|
<Route path="/tokens" element={<Tokens />} />
|
||||||
|
|||||||
@@ -478,6 +478,14 @@ export const api = {
|
|||||||
markEventRead: (id: number) => request(`/api/events/${id}/read`, { method: 'POST' }),
|
markEventRead: (id: number) => request(`/api/events/${id}/read`, { method: 'POST' }),
|
||||||
markAllEventsRead: () => request('/api/events/read-all', { method: 'POST' }),
|
markAllEventsRead: () => request('/api/events/read-all', { method: 'POST' }),
|
||||||
clearEvents: () => request('/api/events', { method: 'DELETE' }),
|
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)))}`),
|
getTasks: (limit = 50) => request(`/api/tasks?limit=${Math.max(1, Math.min(200, Math.trunc(limit)))}`),
|
||||||
getTask: (id: string) => request(`/api/tasks/${encodeURIComponent(id)}`),
|
getTask: (id: string) => request(`/api/tasks/${encodeURIComponent(id)}`),
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const typeLabels: Record<string, string> = {
|
|||||||
token: '令牌',
|
token: '令牌',
|
||||||
proxy: '代理',
|
proxy: '代理',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
|
site_notice: '站点公告',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NotificationPanel({
|
export default function NotificationPanel({
|
||||||
@@ -97,7 +98,7 @@ export default function NotificationPanel({
|
|||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
|
||||||
{['', 'checkin', 'balance', 'token', 'proxy', 'status'].map((filterType) => (
|
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => (
|
||||||
<button key={filterType} onClick={() => setFilter(filterType)}
|
<button key={filterType} onClick={() => setFilter(filterType)}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11, padding: '3px 8px', borderRadius: 12,
|
fontSize: 11, padding: '3px 8px', borderRadius: 12,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const TYPE_OPTIONS = [
|
|||||||
{ value: 'token', label: '令牌' },
|
{ value: 'token', label: '令牌' },
|
||||||
{ value: 'proxy', label: '代理' },
|
{ value: 'proxy', label: '代理' },
|
||||||
{ value: 'status', label: '状态' },
|
{ value: 'status', label: '状态' },
|
||||||
|
{ value: 'site_notice', label: '站点公告' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function levelLabel(level: string) {
|
function levelLabel(level: string) {
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { useToast } from '../components/Toast.js';
|
||||||
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
|
import { clearFocusParams, readFocusAnnouncementId } from './helpers/navigationFocus.js';
|
||||||
|
import { tr } from '../i18n.js';
|
||||||
|
|
||||||
|
type SiteAnnouncementRow = {
|
||||||
|
id: number;
|
||||||
|
siteId: number;
|
||||||
|
platform: string;
|
||||||
|
sourceKey: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
level: 'info' | 'warning' | 'error';
|
||||||
|
firstSeenAt?: string | null;
|
||||||
|
lastSeenAt?: string | null;
|
||||||
|
readAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SiteRow = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
platform?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SiteAnnouncements() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
|
const [rows, setRows] = useState<SiteAnnouncementRow[]>([]);
|
||||||
|
const [sites, setSites] = useState<SiteRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [markingAll, setMarkingAll] = useState(false);
|
||||||
|
const [highlightAnnouncementId, setHighlightAnnouncementId] = useState<number | null>(null);
|
||||||
|
const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
|
const highlightTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const siteNameById = useMemo(() => {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
for (const site of sites) {
|
||||||
|
map.set(site.id, site.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [sites]);
|
||||||
|
|
||||||
|
const load = async (silent = false) => {
|
||||||
|
if (silent) setRefreshing(true);
|
||||||
|
else setLoading(true);
|
||||||
|
try {
|
||||||
|
const [announcementRows, siteRows] = await Promise.all([
|
||||||
|
api.getSiteAnnouncements(),
|
||||||
|
api.getSites(),
|
||||||
|
]);
|
||||||
|
setRows(Array.isArray(announcementRows) ? announcementRows : []);
|
||||||
|
setSites(Array.isArray(siteRows) ? siteRows : []);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.message || '加载站点公告失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
const win = globalThis as typeof globalThis & {
|
||||||
|
clearTimeout?: typeof clearTimeout;
|
||||||
|
};
|
||||||
|
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') {
|
||||||
|
win.clearTimeout(highlightTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const focusAnnouncementId = readFocusAnnouncementId(location.search);
|
||||||
|
if (!focusAnnouncementId || loading) return;
|
||||||
|
const row = rowRefs.current.get(focusAnnouncementId);
|
||||||
|
const nextSearch = clearFocusParams(location.search);
|
||||||
|
const win = globalThis as typeof globalThis & {
|
||||||
|
setTimeout?: typeof setTimeout;
|
||||||
|
clearTimeout?: typeof clearTimeout;
|
||||||
|
};
|
||||||
|
if (!row) {
|
||||||
|
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
|
||||||
|
setHighlightAnnouncementId(focusAnnouncementId);
|
||||||
|
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') {
|
||||||
|
win.clearTimeout(highlightTimerRef.current);
|
||||||
|
}
|
||||||
|
if (typeof win.setTimeout === 'function') {
|
||||||
|
highlightTimerRef.current = win.setTimeout(() => {
|
||||||
|
setHighlightAnnouncementId((current) => (current === focusAnnouncementId ? null : current));
|
||||||
|
}, 2200);
|
||||||
|
}
|
||||||
|
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
|
||||||
|
}, [loading, location.pathname, location.search, navigate, rows]);
|
||||||
|
|
||||||
|
const clearAll = async () => {
|
||||||
|
setClearing(true);
|
||||||
|
try {
|
||||||
|
await api.clearSiteAnnouncements();
|
||||||
|
setRows([]);
|
||||||
|
toast.success('公告已清空');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.message || '清空公告失败');
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAllRead = async () => {
|
||||||
|
setMarkingAll(true);
|
||||||
|
try {
|
||||||
|
await api.markAllSiteAnnouncementsRead();
|
||||||
|
setRows((current) => current.map((row) => ({
|
||||||
|
...row,
|
||||||
|
readAt: row.readAt || new Date().toISOString(),
|
||||||
|
})));
|
||||||
|
toast.success('已标记全部为已读');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.message || '标记失败');
|
||||||
|
} finally {
|
||||||
|
setMarkingAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSync = async () => {
|
||||||
|
try {
|
||||||
|
await api.syncSiteAnnouncements();
|
||||||
|
toast.success('公告同步任务已启动');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.message || '启动同步失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="page-header">
|
||||||
|
<h2 className="page-title">{tr('站点公告')}</h2>
|
||||||
|
<div className="page-actions">
|
||||||
|
<button
|
||||||
|
onClick={() => load(true)}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||||
|
>
|
||||||
|
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={markAllRead}
|
||||||
|
disabled={markingAll}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||||
|
>
|
||||||
|
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={triggerSync}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||||
|
>
|
||||||
|
手动同步
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
disabled={clearing}
|
||||||
|
className="btn btn-link btn-link-danger"
|
||||||
|
>
|
||||||
|
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 0 }}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<span className="spinner spinner-sm" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div style={{ padding: 32, textAlign: 'center' }}>
|
||||||
|
<div className="empty-state-title">暂无公告</div>
|
||||||
|
<div className="empty-state-desc">当前没有可显示的站点公告。</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
rows.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
ref={(node) => {
|
||||||
|
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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<span className="badge badge-muted">{siteNameById.get(row.siteId) || `站点 #${row.siteId}`}</span>
|
||||||
|
<span className="badge badge-info">{row.platform}</span>
|
||||||
|
<span className={`badge ${row.readAt ? 'badge-muted' : 'badge-warning'}`}>{row.readAt ? '已读' : '未读'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{row.content}</div>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
首次发现:{formatDateTimeLocal(row.firstSeenAt || row.lastSeenAt || '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
buildSiteFocusPath,
|
buildSiteFocusPath,
|
||||||
clearFocusParams,
|
clearFocusParams,
|
||||||
readFocusAccountIntent,
|
readFocusAccountIntent,
|
||||||
|
readFocusAnnouncementId,
|
||||||
readFocusSiteId,
|
readFocusSiteId,
|
||||||
} from './navigationFocus.js';
|
} from './navigationFocus.js';
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ describe('navigationFocus helpers', () => {
|
|||||||
it('parses focus params from query string', () => {
|
it('parses focus params from query string', () => {
|
||||||
expect(readFocusSiteId('?focusSiteId=15')).toBe(15);
|
expect(readFocusSiteId('?focusSiteId=15')).toBe(15);
|
||||||
expect(readFocusSiteId('?focusSiteId=abc')).toBeNull();
|
expect(readFocusSiteId('?focusSiteId=abc')).toBeNull();
|
||||||
|
expect(readFocusAnnouncementId('?focusAnnouncementId=21')).toBe(21);
|
||||||
|
expect(readFocusAnnouncementId('?focusAnnouncementId=abc')).toBeNull();
|
||||||
|
|
||||||
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=1')).toEqual({
|
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=1')).toEqual({
|
||||||
accountId: 22,
|
accountId: 22,
|
||||||
@@ -37,6 +40,7 @@ describe('navigationFocus helpers', () => {
|
|||||||
|
|
||||||
it('clears focus params but keeps other params', () => {
|
it('clears focus params but keeps other params', () => {
|
||||||
expect(clearFocusParams('?focusSiteId=12&q=abc')).toBe('?q=abc');
|
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&openRebind=1&type=token')).toBe('?type=token');
|
||||||
expect(clearFocusParams('?focusAccountId=2')).toBe('');
|
expect(clearFocusParams('?focusAccountId=2')).toBe('');
|
||||||
});
|
});
|
||||||
@@ -60,6 +64,12 @@ describe('navigationFocus helpers', () => {
|
|||||||
type: 'status',
|
type: 'status',
|
||||||
})).toBe('/sites?focusSiteId=9');
|
})).toBe('/sites?focusSiteId=9');
|
||||||
|
|
||||||
|
expect(buildEventNavigationPath({
|
||||||
|
relatedType: 'site_announcement',
|
||||||
|
relatedId: 12,
|
||||||
|
type: 'site_notice',
|
||||||
|
})).toBe('/site-announcements?focusAnnouncementId=12');
|
||||||
|
|
||||||
expect(buildEventNavigationPath({
|
expect(buildEventNavigationPath({
|
||||||
relatedType: 'route',
|
relatedType: 'route',
|
||||||
relatedId: null,
|
relatedId: null,
|
||||||
@@ -68,6 +78,7 @@ describe('navigationFocus helpers', () => {
|
|||||||
|
|
||||||
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
||||||
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
||||||
|
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
||||||
expect(buildEventNavigationPath({})).toBe('/events');
|
expect(buildEventNavigationPath({})).toBe('/events');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const FOCUS_SITE_ID_KEY = 'focusSiteId';
|
const FOCUS_SITE_ID_KEY = 'focusSiteId';
|
||||||
|
const FOCUS_ANNOUNCEMENT_ID_KEY = 'focusAnnouncementId';
|
||||||
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
|
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
|
||||||
const FOCUS_TOKEN_ID_KEY = 'focusTokenId';
|
const FOCUS_TOKEN_ID_KEY = 'focusTokenId';
|
||||||
const OPEN_REBIND_KEY = 'openRebind';
|
const OPEN_REBIND_KEY = 'openRebind';
|
||||||
@@ -21,6 +22,12 @@ export function buildSiteFocusPath(siteId: number): string {
|
|||||||
return `/sites?${FOCUS_SITE_ID_KEY}=${normalizedId}`;
|
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(
|
export function buildAccountFocusPath(
|
||||||
accountId: number,
|
accountId: number,
|
||||||
options?: { openRebind?: boolean; segment?: 'session' | 'apikey' | 'tokens' },
|
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));
|
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 } {
|
export function readFocusAccountIntent(search: string): { accountId: number | null; openRebind: boolean } {
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
return {
|
return {
|
||||||
@@ -64,6 +76,7 @@ export function readFocusTokenId(search: string): number | null {
|
|||||||
export function clearFocusParams(search: string): string {
|
export function clearFocusParams(search: string): string {
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
params.delete(FOCUS_SITE_ID_KEY);
|
params.delete(FOCUS_SITE_ID_KEY);
|
||||||
|
params.delete(FOCUS_ANNOUNCEMENT_ID_KEY);
|
||||||
params.delete(FOCUS_ACCOUNT_ID_KEY);
|
params.delete(FOCUS_ACCOUNT_ID_KEY);
|
||||||
params.delete(FOCUS_TOKEN_ID_KEY);
|
params.delete(FOCUS_TOKEN_ID_KEY);
|
||||||
params.delete(OPEN_REBIND_KEY);
|
params.delete(OPEN_REBIND_KEY);
|
||||||
@@ -86,6 +99,9 @@ export function buildEventNavigationPath(event: {
|
|||||||
if (relatedType === 'site' && relatedId) {
|
if (relatedType === 'site' && relatedId) {
|
||||||
return buildSiteFocusPath(relatedId);
|
return buildSiteFocusPath(relatedId);
|
||||||
}
|
}
|
||||||
|
if (relatedType === 'site_announcement' && relatedId) {
|
||||||
|
return buildAnnouncementFocusPath(relatedId);
|
||||||
|
}
|
||||||
if (relatedType === 'route') {
|
if (relatedType === 'route') {
|
||||||
return '/routes';
|
return '/routes';
|
||||||
}
|
}
|
||||||
@@ -95,6 +111,9 @@ export function buildEventNavigationPath(event: {
|
|||||||
if (eventType === 'checkin') {
|
if (eventType === 'checkin') {
|
||||||
return '/checkin';
|
return '/checkin';
|
||||||
}
|
}
|
||||||
|
if (eventType === 'site_notice') {
|
||||||
|
return '/site-announcements';
|
||||||
|
}
|
||||||
|
|
||||||
return '/events';
|
return '/events';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={['/site-announcements?focusAnnouncementId=12']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/site-announcements" element={<SiteAnnouncements />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user