1
0
forked from boos/BoosAPI

Merge pull request #185 from cita-777/codex/site-announcements

[codex] add site announcements
This commit is contained in:
Cita
2026-03-20 11:28:33 +08:00
committed by GitHub
35 changed files with 1831 additions and 15 deletions
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIM
CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` DATETIME, `last_selected_at` DATETIME, `last_fail_at` DATETIME, `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` DATETIME, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL);
CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT);
CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` DATETIME, `ends_at` DATETIME, `upstream_created_at` DATETIME, `upstream_updated_at` DATETIME, `first_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `read_at` DATETIME, `dismissed_at` DATETIME, `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE);
CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`key`(191));
@@ -19,6 +20,7 @@ CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availabi
CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191));
CREATE UNIQUE INDEX `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191));
CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`);
CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191));
CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191));
CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191));
CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191));
@@ -55,6 +57,8 @@ CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`);
CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`);
CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`);
CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`);
CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`);
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`);
CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`);
CREATE INDEX `sites_status_idx` ON `sites` (`status`(191));
CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`);
+4 -4
View File
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE);
ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(191) DEFAULT 'pattern';
CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`);
CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`);
CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` DATETIME, `ends_at` DATETIME, `upstream_created_at` DATETIME, `upstream_updated_at` DATETIME, `first_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `last_seen_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `read_at` DATETIME, `dismissed_at` DATETIME, `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191));
CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`);
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`);
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS
CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TIMESTAMP, "last_selected_at" TIMESTAMP, "last_fail_at" TIMESTAMP, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL);
CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT);
CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TIMESTAMP, "ends_at" TIMESTAMP, "upstream_created_at" TIMESTAMP, "upstream_updated_at" TIMESTAMP, "first_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "last_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "read_at" TIMESTAMP, "dismissed_at" TIMESTAMP, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE);
CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("key");
@@ -19,6 +20,7 @@ CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availabi
CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id");
CREATE UNIQUE INDEX "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id");
CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id");
CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key");
CREATE UNIQUE INDEX "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name");
CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url");
CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name");
@@ -55,6 +57,8 @@ CREATE INDEX "route_channels_route_id_idx" ON "route_channels" ("route_id");
CREATE INDEX "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id");
CREATE INDEX "route_channels_token_id_idx" ON "route_channels" ("token_id");
CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id");
CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at");
CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at");
CREATE INDEX "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id");
CREATE INDEX "sites_status_idx" ON "sites" ("status");
CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available");
+4 -4
View File
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE);
ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT 'pattern';
CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id");
CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id");
CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TIMESTAMP, "ends_at" TIMESTAMP, "upstream_created_at" TIMESTAMP, "upstream_updated_at" TIMESTAMP, "first_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "last_seen_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "read_at" TIMESTAMP, "dismissed_at" TIMESTAMP, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
CREATE UNIQUE INDEX "site_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key");
CREATE INDEX "site_announcements_read_at_idx" ON "site_announcements" ("read_at");
CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at");
+151
View File
@@ -906,6 +906,112 @@
}
}
},
"site_announcements": {
"columns": {
"id": {
"logicalType": "integer",
"notNull": true,
"defaultValue": null,
"primaryKey": true
},
"site_id": {
"logicalType": "integer",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"platform": {
"logicalType": "text",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"source_key": {
"logicalType": "text",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"title": {
"logicalType": "text",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"content": {
"logicalType": "text",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"level": {
"logicalType": "text",
"notNull": true,
"defaultValue": "'info'",
"primaryKey": false
},
"source_url": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"starts_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"ends_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"upstream_created_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"upstream_updated_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"first_seen_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": "datetime('now')",
"primaryKey": false
},
"last_seen_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": "datetime('now')",
"primaryKey": false
},
"read_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"dismissed_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"raw_payload": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
}
}
},
"site_disabled_models": {
"columns": {
"id": {
@@ -1468,6 +1574,32 @@
],
"unique": false
},
{
"name": "site_announcements_read_at_idx",
"table": "site_announcements",
"columns": [
"read_at"
],
"unique": false
},
{
"name": "site_announcements_site_id_first_seen_at_idx",
"table": "site_announcements",
"columns": [
"site_id",
"first_seen_at"
],
"unique": false
},
{
"name": "site_announcements_site_source_key_unique",
"table": "site_announcements",
"columns": [
"site_id",
"source_key"
],
"unique": true
},
{
"name": "site_disabled_models_site_id_idx",
"table": "site_disabled_models",
@@ -1591,6 +1723,14 @@
"source_route_id"
]
},
{
"name": "site_announcements_site_source_key_unique",
"table": "site_announcements",
"columns": [
"site_id",
"source_key"
]
},
{
"name": "site_disabled_models_site_model_unique",
"table": "site_disabled_models",
@@ -1716,6 +1856,17 @@
],
"onDelete": "CASCADE"
},
{
"table": "site_announcements",
"columns": [
"site_id"
],
"referencedTable": "sites",
"referencedColumns": [
"id"
],
"onDelete": "CASCADE"
},
{
"table": "site_disabled_models",
"columns": [
+1
View File
@@ -33,6 +33,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
'proxy_video_tasks',
'proxy_files',
'downstream_api_keys',
'site_announcements',
'events',
]);
+24
View File
@@ -276,6 +276,30 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
expiresAtIdx: index('downstream_api_keys_expires_at_idx').on(table.expiresAt),
}));
export const siteAnnouncements = sqliteTable('site_announcements', {
id: integer('id').primaryKey({ autoIncrement: true }),
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
platform: text('platform').notNull(),
sourceKey: text('source_key').notNull(),
title: text('title').notNull(),
content: text('content').notNull(),
level: text('level').notNull().default('info'),
sourceUrl: text('source_url'),
startsAt: text('starts_at'),
endsAt: text('ends_at'),
upstreamCreatedAt: text('upstream_created_at'),
upstreamUpdatedAt: text('upstream_updated_at'),
firstSeenAt: text('first_seen_at').default(sql`(datetime('now'))`),
lastSeenAt: text('last_seen_at').default(sql`(datetime('now'))`),
readAt: text('read_at'),
dismissedAt: text('dismissed_at'),
rawPayload: text('raw_payload'),
}, (table) => ({
siteSourceKeyUnique: uniqueIndex('site_announcements_site_source_key_unique').on(table.siteId, table.sourceKey),
siteIdFirstSeenAtIdx: index('site_announcements_site_id_first_seen_at_idx').on(table.siteId, table.firstSeenAt),
readAtIdx: index('site_announcements_read_at_idx').on(table.readAt),
}));
export const events = sqliteTable('events', {
id: integer('id').primaryKey({ autoIncrement: true }),
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
@@ -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"');
});
});
+6 -2
View File
@@ -19,6 +19,7 @@ import { testRoutes } from './routes/api/test.js';
import { monitorRoutes } from './routes/api/monitor.js';
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
import { oauthRoutes } from './routes/api/oauth.js';
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
import { proxyRoutes } from './routes/proxy/router.js';
import { startScheduler } from './services/checkinScheduler.js';
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
@@ -27,6 +28,7 @@ import { repairStoredCreatedAtValues } from './services/storedTimestampRepairSer
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
import { existsSync } from 'fs';
@@ -337,8 +339,9 @@ await app.register(authRoutes);
await app.register(settingsRoutes);
await app.register(accountTokensRoutes);
await app.register(searchRoutes);
await app.register(eventsRoutes);
await app.register(taskRoutes);
await app.register(eventsRoutes);
await app.register(siteAnnouncementsRoutes);
await app.register(taskRoutes);
await app.register(testRoutes);
await app.register(monitorRoutes);
await app.register(downstreamApiKeysRoutes);
@@ -376,6 +379,7 @@ if (existsSync(webDir)) {
// Start scheduler
await startScheduler();
startSiteAnnouncementPolling();
try {
await startOAuthLoopbackCallbackServers();
} 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' })]);
});
});
+127
View File
@@ -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,
};
});
}
+28
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import type { RequestInit as UndiciRequestInit } from 'undici';
import { withSiteProxyRequestInit } from '../siteProxy.js';
@@ -44,6 +45,19 @@ export interface ApiTokenInfo {
tokenGroup?: string | null;
}
export interface SiteAnnouncement {
sourceKey: string;
title: string;
content: string;
level: 'info' | 'warning' | 'error';
sourceUrl?: string | null;
startsAt?: string | null;
endsAt?: string | null;
upstreamCreatedAt?: string | null;
upstreamUpdatedAt?: string | null;
rawPayload?: unknown;
}
export interface CreateApiTokenOptions {
name?: string;
group?: string;
@@ -66,6 +80,7 @@ export interface PlatformAdapter {
getModels(baseUrl: string, token: string, platformUserId?: number): Promise<string[]>;
getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string | null>;
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[]>;
createApiToken(baseUrl: string, accessToken: string, platformUserId?: number, options?: CreateApiTokenOptions): 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' }];
}
async getSiteAnnouncements(
_baseUrl: string,
_accessToken: string,
_platformUserId?: number,
): Promise<SiteAnnouncement[]> {
return [];
}
async createApiToken(
_baseUrl: string,
_accessToken: string,
@@ -192,4 +215,9 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
}
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 { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { AddressInfo } from 'node:net';
import { createHash } from 'node:crypto';
import { DoneHubAdapter } from './doneHub.js';
describe('DoneHubAdapter', () => {
@@ -48,6 +49,15 @@ describe('DoneHubAdapter', () => {
return;
}
if (req.url === '/api/notice') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: 'Scheduled maintenance tonight',
}));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
@@ -93,4 +103,20 @@ describe('DoneHubAdapter', () => {
expect(balance.used).toBe(60);
expect(balance.quota).toBe(100);
});
it('normalizes the global site notice from /api/notice', async () => {
const adapter = new DoneHubAdapter();
const rows = await adapter.getSiteAnnouncements(baseUrl, 'token');
expect(rows).toEqual([
{
sourceKey: `notice:${createHash('sha1').update('Scheduled maintenance tonight').digest('hex')}`,
title: 'Site notice',
content: 'Scheduled maintenance tonight',
level: 'info',
sourceUrl: '/api/notice',
rawPayload: { success: true, data: 'Scheduled maintenance tonight' },
},
]);
});
});
+21 -1
View File
@@ -1,5 +1,5 @@
import { OneHubAdapter } from './oneHub.js';
import type { BalanceInfo, CheckinResult } from './base.js';
import type { BalanceInfo, CheckinResult, SiteAnnouncement } from './base.js';
export class DoneHubAdapter extends OneHubAdapter {
readonly platformName: string = 'done-hub';
@@ -32,6 +32,26 @@ export class DoneHubAdapter extends OneHubAdapter {
return { balance: quotaRemaining, used, quota: total, todayIncome, todayQuotaConsumption };
}
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<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.
// No need to override here — OneHub's implementation handles this correctly.
}
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { AddressInfo } from 'node:net';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { NewApiAdapter } from './newApi.js';
@@ -142,6 +143,15 @@ describe('NewApiAdapter', () => {
return;
}
if (req.url === '/api/notice') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
data: 'Welcome to the site',
}));
return;
}
if (req.url?.startsWith('/api/token/')) {
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) {
res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -616,4 +626,20 @@ describe('NewApiAdapter', () => {
expect(receivedHeaders['rix-api-user']).toBe('42');
expect(receivedHeaders['neo-api-user']).toBe('42');
});
it('normalizes the global site notice from /api/notice', async () => {
const adapter = new NewApiAdapter();
const rows = await adapter.getSiteAnnouncements(baseUrl, 'session-token');
expect(rows).toEqual([
{
sourceKey: `notice:${createHash('sha1').update('Welcome to the site').digest('hex')}`,
title: 'Site notice',
content: 'Welcome to the site',
level: 'info',
sourceUrl: '/api/notice',
rawPayload: { success: true, data: 'Welcome to the site' },
},
]);
});
});
+21 -1
View File
@@ -1,4 +1,4 @@
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js';
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions, type SiteAnnouncement } from './base.js';
import type { RequestInit as UndiciRequestInit } from 'undici';
import { createContext, runInContext } from 'node:vm';
import { withSiteProxyRequestInit } from '../siteProxy.js';
@@ -15,6 +15,26 @@ export class NewApiAdapter extends BasePlatformAdapter {
}
}
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<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 {
try {
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');
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',
},
},
]);
});
});
+36
View File
@@ -4,6 +4,7 @@ import {
CheckinResult,
BalanceInfo,
CreateApiTokenOptions,
type SiteAnnouncement,
UserInfo,
} from './base.js';
@@ -486,6 +487,41 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
return this.fetchModelsByToken(normalizedBase, discoveredApiToken);
}
override async getSiteAnnouncements(baseUrl: string, accessToken: string): Promise<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[]> {
try {
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;
}