diff --git a/.env.example b/.env.example index 6a441e9..30dac6e 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,14 @@ PORT=3088 # Windows 本机路径包含空格时,Prisma SQLite 请使用完整绝对路径;Docker 会覆盖为 /app/data。 DATABASE_URL=file:D:/AI Code/YouYiDianNotificationCenter/data/notifications.db +# 管理 API Bearer Secret,只允许通知中心维护人持有;Bearer 方式放在 Authorization 头。 +# 若这两项未配置,服务会安全回退到 legacy `YYD_NOTIFICATION_PROJECTS_JSON` 内存模式,旧 `/v1/*` 保持可用,但不挂载数据库 admin API。 +YYD_NOTIFICATION_ADMIN_API_SECRET= +# 旧变量兼容别名;新部署请只配置 YYD_NOTIFICATION_ADMIN_API_SECRET。 +YYD_NOTIFICATION_ADMIN_TOKEN= +# 数据库存储的项目密钥使用该 32 字节密钥加密;推荐使用 64 位 hex(例如 openssl rand -hex 32)。 +YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY= + # “由壹点”机器人所属企业内部应用的凭证,仅允许配置在通知中心。 DINGTALK_APP_KEY= DINGTALK_APP_SECRET= @@ -15,6 +23,7 @@ DINGTALK_LOGIN_TICKET_TTL_SECONDS=60 # 每个项目必须使用不同密钥;allowedHosts 用于限制卡片跳转域名。 # login 省略或 enabled=false 时保持原直接跳转,不启用免登中转。 +# 该环境变量只用于首次或补录导入:启动期和 /admin/runtime/import-env 都只导入缺失项目,不覆盖数据库里已存在项目、已轮换密钥或手工维护的 recipients。 # 生产环境请使用足够长的随机密钥,不要把真实值提交到 Git。 YYD_NOTIFICATION_PROJECTS_JSON=[{"code":"material_library","name":"素材库","secret":"replace-with-random-secret","allowedHosts":["sc.example.com"],"recipients":[{"alias":"示例花名","userId":"replace-with-dingtalk-user-id"}]},{"code":"fixed_asset","name":"固定资产","secret":"replace-with-another-random-secret","allowedHosts":["asset.example.com"],"login":{"enabled":true,"callbackUrl":"https://asset.example.com/api/dingtalk/broker-login","allowedRedirectPathPrefixes":["/workflows/disposals","/dashboard"]}},{"code":"furniture_platform","name":"家具业务系统","secret":"replace-with-furniture-random-secret","allowedHosts":["cp.example.com"]},{"code":"script_monitor","name":"脚本监控","secret":"replace-with-script-monitor-random-secret","allowedHosts":["cp.example.com"]}] diff --git a/README.md b/README.md index ce15070..f379c0f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ 1. 复制 `.env.example` 为 `.env` 并填写真实配置;不要提交 `.env`。 Windows 下项目目录包含空格,`DATABASE_URL` 请保留为完整绝对路径。 + `YYD_NOTIFICATION_ADMIN_API_SECRET`(兼容旧变量 `YYD_NOTIFICATION_ADMIN_TOKEN`)和 `YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY` 只保存在通知中心服务端。 2. 执行 `npm install`。 3. 执行 `npm run prisma:generate`。 4. 执行 `npm run prisma:migrate`。 @@ -43,6 +44,54 @@ `npm test` 不读取本地 `.env`:测试入口会创建唯一临时 SQLite,先执行正式 Prisma 迁移,再串行运行测试,并在成功或失败后清理数据库、journal、WAL 和 SHM 文件。 +## 项目配置来源与管理 API + +当 `YYD_NOTIFICATION_ADMIN_API_SECRET`(或兼容旧变量 `YYD_NOTIFICATION_ADMIN_TOKEN`)与 `YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY` 都已配置时,通知项目配置以数据库为准;`YYD_NOTIFICATION_PROJECTS_JSON` 只承担“首次/补录导入缺失项目”的 bootstrap 角色: + +- 服务启动时会尝试把 `YYD_NOTIFICATION_PROJECTS_JSON` 中尚未登记的项目导入数据库; +- 已存在项目不会被覆盖,已轮换密钥、已手工维护的 `recipients` 和登录配置保持不变; +- 运维可用 `POST /admin/runtime/import-env` 再次补录当前环境变量中的缺失项目; +- 运维可用 `POST /admin/runtime/refresh` 让运行时缓存重新读取数据库,不必重启进程。 +- 如果管理员密钥或加密密钥缺失,服务会安全回退到 legacy 内存模式:继续直接使用 `YYD_NOTIFICATION_PROJECTS_JSON` 提供旧 `/v1/*` 能力,且不挂载 `/admin/*`。 + +管理 API 使用独立管理员 Bearer Secret,与业务项目 HMAC 密钥隔离: + +- 主变量:`YYD_NOTIFICATION_ADMIN_API_SECRET` +- 兼容旧变量:`YYD_NOTIFICATION_ADMIN_TOKEN` +- 请求头:`Authorization: Bearer ` +- 可选 `x-yyd-admin-actor`:写入审计日志的操作人标识 + +当前管理接口: + +- `GET /admin/projects` / `GET /admin/projects/:projectCode` +- `POST /admin/projects` +- `PATCH /admin/projects/:projectCode` +- `DELETE /admin/projects/:projectCode` +- `GET|PUT /admin/projects/:projectCode/recipients` +- `POST /admin/projects/:projectCode/rotate-secret` +- `POST /admin/projects/:projectCode/test` +- `GET /admin/deliveries` +- `GET /admin/audits` +- `POST /admin/runtime/import-env` +- `POST /admin/runtime/refresh` + +稳定响应 contract: + +- 项目列表:`{ projects: [...] }`,默认包含 `projectCode / name / recipientCount / recipients / deliverySummary` +- 创建/轮换密钥:`{ project, secret, env }` +- 投递列表:`{ deliveries, total }` +- 审计列表:`{ audits, total }` +- 管理错误:`{ error: { code, message, details? } }` + +其中 `secret` 与 `env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET` 只会在 `POST /admin/projects` 和 `POST /admin/projects/:projectCode/rotate-secret` 当次响应里出现一次,后续列表、详情和审计都不会回显明文密钥。 + +请求体大小限制: + +- 业务 `/v1/*`:64 KB +- 管理 `/admin/*`:256 KB + +普通业务接入接口 `POST /v1/notifications`、`GET /v1/notifications/:id`、`GET /v1/notifications` 与原签名协议保持兼容。 + 生产环境建议只把容器端口绑定到 `127.0.0.1`,再由 Nginx 提供独立 HTTPS 域名。素材库和固定资产可能位于不同主机,因此不能把业务项目配置成通知中心的容器内网地址。 当前生产部署脚本位于: @@ -54,6 +103,8 @@ 发布脚本会先一致性备份通知中心 SQLite、环境配置和源码,再从本次解压的源码强制构建镜像并 `force-recreate`,不会因为服务器上存在旧 `latest` 镜像而跳过新代码。素材库生产 NAS 无法稳定访问 Docker Hub,正式发布前应预载 `node:22-alpine` 基础镜像并确认 npm 依赖源可用;构建失败会在重建容器前终止,旧服务继续运行。 +Git 发布入口会在生产机首次生成、后续稳定保留 `YYD_NOTIFICATION_ADMIN_API_SECRET` 与 `YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY`,并只把管理地址和管理员密钥同步到素材库后端 `.env`;脚本只打印配置完成状态,不输出密钥原文。 + ## 签名算法 请求头: diff --git a/docker-compose.yml b/docker-compose.yml index 19daccb..d8a3d22 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,8 @@ services: YYD_NOTIFICATION_PUBLIC_BASE_URL: ${YYD_NOTIFICATION_PUBLIC_BASE_URL:-} DINGTALK_LOGIN_TICKET_TTL_SECONDS: ${DINGTALK_LOGIN_TICKET_TTL_SECONDS:-60} YYD_NOTIFICATION_PROJECTS_JSON: ${YYD_NOTIFICATION_PROJECTS_JSON} + YYD_NOTIFICATION_ADMIN_API_SECRET: ${YYD_NOTIFICATION_ADMIN_API_SECRET} + YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY: ${YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY} WORKER_ENABLED: ${WORKER_ENABLED:-true} volumes: - notification_data:/app/data diff --git a/docs/通知中心新项目开通操作手册.md b/docs/通知中心新项目开通操作手册.md index 729d428..a73f8b8 100644 --- a/docs/通知中心新项目开通操作手册.md +++ b/docs/通知中心新项目开通操作手册.md @@ -103,17 +103,21 @@ 这是生产写入操作,必须获得上线授权后执行。 +当管理员密钥与加密密钥都已配置时,通知项目配置的真实来源改为**通知中心数据库**;`YYD_NOTIFICATION_PROJECTS_JSON` 只用于把“数据库里还没有的项目”首次或补录导入,不能再把它当成每次发布都全量覆盖的真源。 + 执行顺序: 1. 确认目标服务器和目录是通知中心,而不是其他业务项目。 2. 备份通知中心 `.env`,备份文件包含密钥,权限必须限制为仅维护人可读。 3. 备份通知中心 SQLite 数据目录,确保现有通知记录可恢复。 -4. 在 `YYD_NOTIFICATION_PROJECTS_JSON` 数组末尾新增项目,完整保留所有已有项目和已有 `recipients`。 +4. 如需用环境变量补录,在 `YYD_NOTIFICATION_PROJECTS_JSON` 数组末尾新增项目;该数组中的既有项目只作为参考,不会覆盖数据库里已存在项目。 5. 再次解析 JSON,确认项目代码唯一、密钥长度合格、域名格式正确、花名映射无重复。 -6. 使用当前正式发布方式重新加载通知中心配置。 +6. 使用当前正式发布方式重新加载通知中心配置,或在管理员 API 调用 `POST /admin/runtime/import-env` 导入缺失项目后执行 `POST /admin/runtime/refresh`。 7. 验证本机 `/health` 与公网 `/notification-center/health`。 8. 验证现有项目仍可鉴权,新项目能够通过签名请求进入通知队列。 +如果当前环境尚未配置管理员密钥或加密密钥,通知中心会继续使用 legacy `YYD_NOTIFICATION_PROJECTS_JSON` 内存模式提供旧 `/v1/*`;此时 `/admin/*` 不挂载,也不会做数据库项目导入和运行时刷新。 + 重要限制: - 日常发布使用仓库当前的 `scripts/git-build-deploy.ps1` 流程。 @@ -123,6 +127,31 @@ 健康检查只证明服务和数据库可用,不证明新项目已经能够发送通知。仍需完成下一节的真实业务验收。 +### 5.1 管理 API 开通与轮换 + +维护人现在也可以直接通过管理员 API 管理项目,而不是手工编辑环境变量。管理员鉴权与业务项目 HMAC 密钥分离: + +- 主变量:`YYD_NOTIFICATION_ADMIN_API_SECRET` +- 兼容旧变量:`YYD_NOTIFICATION_ADMIN_TOKEN` +- 请求头:`Authorization: Bearer ` +- 可选:`x-yyd-admin-actor: <操作人标识>`,仅用于审计 + +常用接口: + +- `POST /admin/projects`:创建项目,并**只在本次响应**返回新密钥和交付业务方的 `env` +- `PATCH /admin/projects/:projectCode`:修改名称、域名白名单、启停状态、免登配置 +- `PUT /admin/projects/:projectCode/recipients`:替换当前项目花名清单 +- `POST /admin/projects/:projectCode/rotate-secret`:轮换项目密钥,并**只在本次响应**返回新密钥和 `env` +- `POST /admin/projects/:projectCode/test`:发一条测试通知 +- `GET /admin/deliveries` / `GET /admin/audits`:查看投递与审计 + +关键规则: + +- `secret` 和 `env.YYD_NOTIFICATION_CENTER_PROJECT_SECRET` 只在创建/轮换响应中展示一次,后续列表、详情、审计都不回显; +- 审计日志自动脱敏,不记录明文密钥、签名或 Bearer Token; +- `/admin/*` 请求体限制为 256 KB,超限返回结构化错误; +- `/v1/notifications` 等业务接口保持原有签名协议与返回语义。 + ## 六、安全交付三项配置 向业务项目技术负责人交付: diff --git a/prisma/migrations/20260828_admin_project_store/migration.sql b/prisma/migrations/20260828_admin_project_store/migration.sql new file mode 100644 index 0000000..79292bb --- /dev/null +++ b/prisma/migrations/20260828_admin_project_store/migration.sql @@ -0,0 +1,52 @@ +ALTER TABLE "NotificationDelivery" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE "NotificationProject" ( + "id" TEXT NOT NULL PRIMARY KEY, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "secretCiphertext" TEXT NOT NULL, + "secretNonce" TEXT NOT NULL, + "secretAuthTag" TEXT NOT NULL, + "allowedHostsJson" TEXT NOT NULL, + "loginEnabled" BOOLEAN NOT NULL DEFAULT false, + "loginCallbackUrl" TEXT, + "loginAllowedRedirectPrefixesJson" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "importedFromEnvAt" DATETIME, + "lastSecretRotatedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +CREATE TABLE "NotificationProjectRecipient" ( + "id" TEXT NOT NULL PRIMARY KEY, + "projectId" TEXT NOT NULL, + "aliasDisplay" TEXT NOT NULL, + "aliasNormalized" TEXT NOT NULL, + "userIdCiphertext" TEXT NOT NULL, + "userIdNonce" TEXT NOT NULL, + "userIdAuthTag" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "NotificationProjectRecipient_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "NotificationProject" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE "NotificationAudit" ( + "id" TEXT NOT NULL PRIMARY KEY, + "actorType" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "targetProjectCode" TEXT, + "requestId" TEXT, + "ipAddress" TEXT, + "summary" TEXT NOT NULL, + "detailsJson" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX "NotificationProject_code_key" ON "NotificationProject"("code"); +CREATE INDEX "NotificationProject_isActive_updatedAt_idx" ON "NotificationProject"("isActive", "updatedAt"); +CREATE UNIQUE INDEX "NotificationProjectRecipient_projectId_aliasNormalized_key" ON "NotificationProjectRecipient"("projectId", "aliasNormalized"); +CREATE INDEX "NotificationProjectRecipient_projectId_updatedAt_idx" ON "NotificationProjectRecipient"("projectId", "updatedAt"); +CREATE INDEX "NotificationAudit_targetProjectCode_createdAt_idx" ON "NotificationAudit"("targetProjectCode", "createdAt"); +CREATE INDEX "NotificationAudit_action_createdAt_idx" ON "NotificationAudit"("action", "createdAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 13e8e8b..6d92aba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,7 @@ model NotificationDelivery { processQueryKey String? lastErrorCode String? lastErrorMessage String? + isTest Boolean @default(false) sentAt DateTime? readAt DateTime? createdAt DateTime @default(now()) @@ -65,3 +66,56 @@ model DingtalkLoginTicket { @@index([projectCode, audience, expiresAt]) @@index([expiresAt, consumedAt]) } + +model NotificationProject { + id String @id @default(uuid()) + code String @unique + name String + secretCiphertext String + secretNonce String + secretAuthTag String + allowedHostsJson String + loginEnabled Boolean @default(false) + loginCallbackUrl String? + loginAllowedRedirectPrefixesJson String? + isActive Boolean @default(true) + importedFromEnvAt DateTime? + lastSecretRotatedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + recipients NotificationProjectRecipient[] + + @@index([isActive, updatedAt]) +} + +model NotificationProjectRecipient { + id String @id @default(uuid()) + projectId String + aliasDisplay String + aliasNormalized String + userIdCiphertext String + userIdNonce String + userIdAuthTag String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project NotificationProject @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, aliasNormalized]) + @@index([projectId, updatedAt]) +} + +model NotificationAudit { + id String @id @default(uuid()) + actorType String + actorId String + action String + targetProjectCode String? + requestId String? + ipAddress String? + summary String + detailsJson String? + createdAt DateTime @default(now()) + + @@index([targetProjectCode, createdAt]) + @@index([action, createdAt]) +} diff --git a/scripts/deploy-material-host.sh b/scripts/deploy-material-host.sh index 42bd8aa..63e9cfd 100644 --- a/scripts/deploy-material-host.sh +++ b/scripts/deploy-material-host.sh @@ -224,6 +224,16 @@ except Exception: if ticket_ttl < 30 or ticket_ttl > 60: ticket_ttl = 60 +admin_api_secret = ( + existing.get('YYD_NOTIFICATION_ADMIN_API_SECRET') + or existing.get('YYD_NOTIFICATION_ADMIN_TOKEN') + or secrets.token_hex(32) +) +config_encryption_key = ( + existing.get('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY') + or secrets.token_hex(32) +) + center_lines = [ 'PORT=3088', 'DINGTALK_APP_KEY=' + quoted(app_key), @@ -238,6 +248,8 @@ center_lines = [ 'YYD_NOTIFICATION_PROJECTS_JSON=' + quoted( json.dumps(projects, ensure_ascii=False, separators=(',', ':')) ), + 'YYD_NOTIFICATION_ADMIN_API_SECRET=' + quoted(admin_api_secret), + 'YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY=' + quoted(config_encryption_key), 'WORKER_ENABLED=true', 'WORKER_INTERVAL_MS=1000', 'READ_STATUS_INTERVAL_MS=5000', @@ -253,6 +265,9 @@ updates = { 'YYD_NOTIFICATION_CENTER_PROJECT_CODE': 'material_library', 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET': material_secret, 'YYD_NOTIFICATION_CENTER_TEST_USER_IDS': '', + 'YYD_NOTIFICATION_CENTER_ADMIN_URL': 'https://sc.jiansun.vip/notification-center/admin', + 'YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET': admin_api_secret, + 'YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS': '8000', } backup = source_path.with_name( '.env.before-yyd-notification-' + time.strftime('%Y%m%d%H%M%S') diff --git a/scripts/git-build-deploy.ps1 b/scripts/git-build-deploy.ps1 index 9382b90..3e5c88e 100644 --- a/scripts/git-build-deploy.ps1 +++ b/scripts/git-build-deploy.ps1 @@ -107,11 +107,16 @@ mkdir -p "$backup_root" stamp="$(date +%Y%m%d%H%M%S)" cp -a .env "$backup_root/env-before-git-release-$stamp" chmod 600 "$backup_root/env-before-git-release-$stamp" +material_env=/mnt/PoolOne/apps/youyidian-3d/.env +test -f "$material_env" +cp -a "$material_env" "$backup_root/material-env-before-git-release-$stamp" +chmod 600 "$backup_root/material-env-before-git-release-$stamp" # 盘点任务通过统一 H5 免登入口打开,必须显式保留 /inventory 白名单;不放宽为全站路径。 python3 - <<'PY' from pathlib import Path import json +import secrets def parse_env_value(raw: str) -> str: @@ -125,6 +130,45 @@ def parse_env_value(raw: str) -> str: env_path = Path('.env') lines = env_path.read_text(encoding='utf-8').splitlines() + + +def env_values(source_lines): + result = {} + for line in source_lines: + if '=' not in line or line.lstrip().startswith('#'): + continue + key, raw = line.split('=', 1) + result[key.strip()] = parse_env_value(raw) + return result + + +def upsert_env(source_lines, updates): + output = [] + seen = set() + for line in source_lines: + if '=' in line and not line.lstrip().startswith('#'): + key = line.split('=', 1)[0].strip() + if key in updates: + output.append(key + '=' + str(updates[key])) + seen.add(key) + continue + output.append(line) + for key, value in updates.items(): + if key not in seen: + output.append(key + '=' + str(value)) + return output + + +existing_env = env_values(lines) +admin_api_secret = ( + existing_env.get('YYD_NOTIFICATION_ADMIN_API_SECRET') + or existing_env.get('YYD_NOTIFICATION_ADMIN_TOKEN') + or secrets.token_hex(32) +) +config_encryption_key = ( + existing_env.get('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY') + or secrets.token_hex(32) +) projects_index = next( ( index @@ -163,6 +207,23 @@ if isinstance(login, dict) and login.get('enabled') is True: ) env_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') env_path.chmod(0o600) + +lines = upsert_env(lines, { + 'YYD_NOTIFICATION_ADMIN_API_SECRET': admin_api_secret, + 'YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY': config_encryption_key, +}) +env_path.write_text('\n'.join(lines) + '\n', encoding='utf-8') +env_path.chmod(0o600) + +material_env_path = Path('/mnt/PoolOne/apps/youyidian-3d/.env') +material_lines = material_env_path.read_text(encoding='utf-8').splitlines() +material_lines = upsert_env(material_lines, { + 'YYD_NOTIFICATION_CENTER_ADMIN_URL': 'https://sc.jiansun.vip/notification-center/admin', + 'YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET': admin_api_secret, + 'YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS': '8000', +}) +material_env_path.write_text('\n'.join(material_lines) + '\n', encoding='utf-8') +material_env_path.chmod(0o600) print('fixed_asset 免登回跳白名单已核对 /inventory') PY diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts new file mode 100644 index 0000000..a1cfef8 --- /dev/null +++ b/src/__tests__/admin.test.ts @@ -0,0 +1,555 @@ +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import test from 'node:test' +import { AddressInfo } from 'node:net' +import { AdminProjectRegistry, maskUserId } from '../admin-project-registry' +import { initializeRuntime } from '../index' +import prisma from '../prisma' +import { createServer } from '../server' +import { createCanonicalSignature } from '../signature' + +const adminApiSecret = 'admin-token-for-tests' +const encryptionKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + +const clean = (value: unknown) => String(value || '').trim() + +const assertEncryptedRecipientRow = (row: { + aliasDisplay: string + userIdCiphertext: string + userIdNonce: string + userIdAuthTag: string +}, rawUserId: string) => { + assert.ok(row.userIdCiphertext) + assert.ok(row.userIdNonce) + assert.ok(row.userIdAuthTag) + assert.notEqual(row.userIdCiphertext, rawUserId) + assert.notEqual(row.userIdNonce, rawUserId) + assert.notEqual(row.userIdAuthTag, rawUserId) + assert.doesNotMatch(JSON.stringify(row), new RegExp(rawUserId)) +} + +const resetDatabase = async () => { + await prisma.notificationAudit.deleteMany() + await prisma.notificationProjectRecipient.deleteMany() + await prisma.notificationProject.deleteMany() + await prisma.dingtalkLoginTicket.deleteMany() + await prisma.requestNonce.deleteMany() + await prisma.notificationDelivery.deleteMany() +} + +const withAdminAuth = (extra: Record = {}) => ({ + Authorization: `Bearer ${adminApiSecret}`, + 'Content-Type': 'application/json', + 'x-yyd-admin-actor': 'ops.haitang', + ...extra, +}) + +const signedHeaders = (input: { + projectCode: string + secret: string + method: string + path: string + body: string + nonce?: string + timestamp?: string +}) => { + const timestamp = input.timestamp || String(Math.floor(Date.now() / 1000)) + const nonce = input.nonce || crypto.randomBytes(18).toString('base64url') + return { + 'Content-Type': 'application/json', + 'x-yyd-project-code': input.projectCode, + 'x-yyd-timestamp': timestamp, + 'x-yyd-nonce': nonce, + 'x-yyd-signature': createCanonicalSignature({ + secret: input.secret, + timestamp, + nonce, + method: input.method, + path: input.path, + body: input.body, + }), + } +} + +test('环境变量项目只导入一次且不覆盖数据库配置,refresh 后 /v1 使用新缓存', async () => { + const oldEncryptionKey = process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY + const oldAdminApiSecret = process.env.YYD_NOTIFICATION_ADMIN_API_SECRET + const oldAdminToken = process.env.YYD_NOTIFICATION_ADMIN_TOKEN + const oldProjectsJson = process.env.YYD_NOTIFICATION_PROJECTS_JSON + const oldPublicBaseUrl = process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = encryptionKey + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = adminApiSecret + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = '' + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = 'https://sc.example.com/notification-center' + process.env.YYD_NOTIFICATION_PROJECTS_JSON = JSON.stringify([ + { + code: 'fixed_asset', + name: '固定资产环境版', + secret: 'env-fixed-asset-secret-1234567890', + allowedHosts: ['env.example.com'], + recipients: [{ alias: '环境海棠', userId: 'ding-env-user' }], + }, + { + code: 'material_library', + name: '素材库', + secret: 'material-library-secret-1234567890', + allowedHosts: ['material.example.com'], + recipients: [{ alias: '素材同学', userId: 'ding-material-user' }], + }, + ]) + + await resetDatabase() + const registry = new AdminProjectRegistry() + const created = await registry.createProject({ + projectCode: 'fixed_asset', + name: '固定资产数据库版', + allowedHosts: ['asset.example.com'], + recipients: [{ alias: '海棠原样', userId: 'ding-db-user' }], + }, { actorType: 'system', actorId: 'seed' }) + const projectSecret = created.secret + const imported = await registry.importFromEnvironment({ actorType: 'system', actorId: 'import-test' }) + assert.deepEqual(imported.importedProjectCodes, ['material_library']) + assert.deepEqual(imported.skippedProjectCodes, ['fixed_asset']) + + const storedFixedAsset = await prisma.notificationProject.findUniqueOrThrow({ + where: { code: 'fixed_asset' }, + include: { recipients: true }, + }) + assert.equal(storedFixedAsset.recipients.length, 1) + assertEncryptedRecipientRow(storedFixedAsset.recipients[0], 'ding-db-user') + + const storedMaterialLibrary = await prisma.notificationProject.findUniqueOrThrow({ + where: { code: 'material_library' }, + include: { recipients: true }, + }) + assert.equal(storedMaterialLibrary.recipients.length, 1) + assertEncryptedRecipientRow(storedMaterialLibrary.recipients[0], 'ding-material-user') + + const fixedAsset = await registry.getProjectView('fixed_asset', true) + assert.equal(fixedAsset.project.name, '固定资产数据库版') + assert.deepEqual(fixedAsset.project.allowedHosts, ['asset.example.com']) + assert.equal(fixedAsset.project.recipientCount, 1) + assert.equal(fixedAsset.project.recipients?.[0].alias, '海棠原样') + assert.equal(fixedAsset.project.recipients?.[0].userId, 'ding-db-user') + + const server = createServer(registry).listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + try { + const path = '/v1/notifications' + const body = JSON.stringify({ + idempotencyKey: 'fixed-asset:db-secret:1', + recipientAlias: '海棠原样', + title: '数据库项目密钥生效', + content: '正文', + actionTitle: '查看详情', + actionUrl: 'https://asset.example.com/tasks/1', + }) + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: signedHeaders({ projectCode: 'fixed_asset', secret: projectSecret, method: 'POST', path, body }), + body, + }) + assert.equal(response.status, 202) + assert.equal((await response.json() as any).title, '【固定资产数据库版】数据库项目密钥生效') + + const envSecretBody = JSON.stringify({ + idempotencyKey: 'fixed-asset:env-secret:1', + recipientUserId: 'ding-db-user', + title: '环境变量旧密钥不应覆盖', + content: '正文', + actionTitle: '查看详情', + actionUrl: 'https://asset.example.com/tasks/2', + }) + const envSecretRejected = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: signedHeaders({ + projectCode: 'fixed_asset', + secret: 'env-fixed-asset-secret-1234567890', + method: 'POST', + path, + body: envSecretBody, + }), + body: envSecretBody, + }) + assert.equal(envSecretRejected.status, 401) + assert.deepEqual(await envSecretRejected.json(), { error: 'SIGNATURE_INVALID' }) + + await prisma.notificationProject.update({ + where: { code: 'fixed_asset' }, + data: { name: '固定资产刷新后' }, + }) + const refresh = await fetch(`http://127.0.0.1:${port}/admin/runtime/refresh`, { + method: 'POST', + headers: withAdminAuth(), + }) + assert.equal(refresh.status, 200) + const refreshPayload = await refresh.json() as any + assert.equal(refreshPayload.projectCount, 2) + + const refreshedBody = JSON.stringify({ + idempotencyKey: 'fixed-asset:db-secret:2', + recipientUserId: 'ding-db-user', + title: '刷新后标题', + content: '正文', + actionTitle: '查看详情', + actionUrl: 'https://asset.example.com/tasks/3', + }) + const refreshed = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: signedHeaders({ projectCode: 'fixed_asset', secret: projectSecret, method: 'POST', path, body: refreshedBody }), + body: refreshedBody, + }) + assert.equal(refreshed.status, 202) + assert.equal((await refreshed.json() as any).title, '【固定资产刷新后】刷新后标题') + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = oldEncryptionKey + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = oldAdminApiSecret + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = oldAdminToken + process.env.YYD_NOTIFICATION_PROJECTS_JSON = oldProjectsJson + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = oldPublicBaseUrl + await prisma.$disconnect() + } +}) + +test('缺少 admin 配置时启动安全回退到 legacy env source,旧 /v1 正常而 admin 不挂载', async () => { + const oldEncryptionKey = process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY + const oldAdminApiSecret = process.env.YYD_NOTIFICATION_ADMIN_API_SECRET + const oldAdminToken = process.env.YYD_NOTIFICATION_ADMIN_TOKEN + const oldProjectsJson = process.env.YYD_NOTIFICATION_PROJECTS_JSON + const oldAppKey = process.env.DINGTALK_APP_KEY + const oldAppSecret = process.env.DINGTALK_APP_SECRET + const oldRobotCode = process.env.DINGTALK_ROBOT_CODE + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = '' + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = '' + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = '' + process.env.YYD_NOTIFICATION_PROJECTS_JSON = JSON.stringify([ + { + code: 'legacy_asset', + name: '旧版固定资产', + secret: 'legacy-fixed-asset-secret-1234567890', + allowedHosts: ['legacy.example.com'], + recipients: [{ alias: '旧版海棠', userId: 'ding-legacy-user' }], + }, + ]) + process.env.DINGTALK_APP_KEY = 'legacy-test-app-key' + process.env.DINGTALK_APP_SECRET = 'legacy-test-app-secret' + process.env.DINGTALK_ROBOT_CODE = 'legacy-test-robot-code' + + await resetDatabase() + const runtime = await initializeRuntime() + assert.equal(runtime.mode, 'legacy') + assert.equal(runtime.adminAvailable, false) + assert.ok(runtime.adminConfigError) + assert.equal(runtime.activeProjectCount, 1) + assert.ok(runtime.projectSource instanceof Map) + + const server = createServer(runtime.projectSource).listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + try { + const path = '/v1/notifications' + const body = JSON.stringify({ + idempotencyKey: 'legacy:1', + recipientAlias: '旧版海棠', + title: 'legacy 启动兼容', + content: '正文', + actionTitle: '查看详情', + actionUrl: 'https://legacy.example.com/tasks/1', + }) + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: signedHeaders({ + projectCode: 'legacy_asset', + secret: 'legacy-fixed-asset-secret-1234567890', + method: 'POST', + path, + body, + }), + body, + }) + assert.equal(response.status, 202) + assert.equal((await response.json() as any).title, '【旧版固定资产】legacy 启动兼容') + + const adminResponse = await fetch(`http://127.0.0.1:${port}/admin/projects`, { + headers: withAdminAuth({ Accept: 'application/json' }), + }) + assert.equal(adminResponse.status, 404) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = oldEncryptionKey + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = oldAdminApiSecret + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = oldAdminToken + process.env.YYD_NOTIFICATION_PROJECTS_JSON = oldProjectsJson + process.env.DINGTALK_APP_KEY = oldAppKey + process.env.DINGTALK_APP_SECRET = oldAppSecret + process.env.DINGTALK_ROBOT_CODE = oldRobotCode + await prisma.$disconnect() + } +}) + +test('admin API 提供稳定 contract,rotate-secret/test/deliveries/audits 可用且测试通知走 mock', async () => { + const oldEncryptionKey = process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY + const oldAdminApiSecret = process.env.YYD_NOTIFICATION_ADMIN_API_SECRET + const oldAdminToken = process.env.YYD_NOTIFICATION_ADMIN_TOKEN + const oldProjectsJson = process.env.YYD_NOTIFICATION_PROJECTS_JSON + const oldPublicBaseUrl = process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = encryptionKey + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = adminApiSecret + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = '' + process.env.YYD_NOTIFICATION_PROJECTS_JSON = '' + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = 'https://sc.example.com/notification-center' + + await resetDatabase() + const registry = new AdminProjectRegistry() + await registry.refresh() + const sentMessages: Array> = [] + const server = createServer(registry, { + sendTestMessage: async (input) => { + sentMessages.push({ + recipientUserId: input.recipientUserId, + title: input.title, + content: input.content, + actionTitle: input.actionTitle, + actionUrl: input.actionUrl, + }) + return 'admin-test-pqk' + }, + }).listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + const port = (server.address() as AddressInfo).port + + try { + const createResponse = await fetch(`http://127.0.0.1:${port}/admin/projects`, { + method: 'POST', + headers: withAdminAuth(), + body: JSON.stringify({ + projectCode: 'ops_center', + name: '运营中心', + allowedHosts: ['ops.example.com'], + recipients: [{ alias: '海棠 Ops', userId: 'ding-ops-user-1' }], + }), + }) + assert.equal(createResponse.status, 201) + const created = await createResponse.json() as any + assert.equal(created.project.projectCode, 'ops_center') + assert.equal(created.project.name, '运营中心') + assert.equal(created.project.isActive, true) + assert.deepEqual(created.project.allowedHosts, ['ops.example.com']) + assert.equal(created.project.recipientMode, 'userId_or_alias') + assert.equal(created.project.recipientCount, 1) + assert.equal(created.project.recipients[0].alias, '海棠 Ops') + assert.equal(created.project.deliverySummary.lastStatus, null) + assert.equal(typeof created.secret, 'string') + assert.ok(created.secret.length >= 24) + assert.deepEqual(created.env, { + YYD_NOTIFICATION_CENTER_URL: 'https://sc.example.com/notification-center', + YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'ops_center', + YYD_NOTIFICATION_CENTER_PROJECT_SECRET: created.secret, + }) + + const listResponse = await fetch(`http://127.0.0.1:${port}/admin/projects`, { + headers: withAdminAuth({ Accept: 'application/json' }), + }) + assert.equal(listResponse.status, 200) + const listPayload = await listResponse.json() as any + assert.equal(listPayload.projects.length, 1) + assert.deepEqual(Object.keys(listPayload.projects[0]).sort(), [ + 'allowedHosts', + 'deliverySummary', + 'isActive', + 'name', + 'projectCode', + 'recipientCount', + 'recipientMode', + 'recipients', + 'updatedAt', + ].sort()) + + const updateResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/ops_center`, { + method: 'PATCH', + headers: withAdminAuth(), + body: JSON.stringify({ + name: '运营中心升级版', + login: { + enabled: true, + callbackUrl: 'https://ops.example.com/api/dingtalk/broker-login', + allowedRedirectPathPrefixes: ['/tasks', '/dashboard'], + }, + }), + }) + assert.equal(updateResponse.status, 200) + const updated = await updateResponse.json() as any + assert.equal(updated.project.name, '运营中心升级版') + assert.equal(updated.project.login.enabled, true) + assert.deepEqual(updated.project.login.allowedRedirectPathPrefixes, ['/tasks', '/dashboard']) + + const replaceRecipients = await fetch(`http://127.0.0.1:${port}/admin/projects/ops_center/recipients`, { + method: 'PUT', + headers: withAdminAuth(), + body: JSON.stringify({ + recipients: [{ alias: '海棠 新', userId: 'ding-ops-user-2' }], + }), + }) + assert.equal(replaceRecipients.status, 200) + const replaced = await replaceRecipients.json() as any + assert.equal(replaced.project.recipientCount, 1) + assert.equal(replaced.project.recipients[0].alias, '海棠 新') + assert.equal(replaced.project.recipients[0].userId, 'ding-ops-user-2') + + const testResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/ops_center/test`, { + method: 'POST', + headers: withAdminAuth(), + body: JSON.stringify({ + recipientAlias: '海棠 新', + title: '管理台联调', + content: '这是测试正文', + actionTitle: '查看任务', + actionUrl: 'https://ops.example.com/tasks/1', + }), + }) + assert.equal(testResponse.status, 201) + const testPayload = await testResponse.json() as any + assert.equal(testPayload.delivery.status, 'sent') + assert.equal(testPayload.delivery.isTest, true) + assert.equal(testPayload.delivery.recipientAlias, null) + assert.equal(testPayload.delivery.recipientUserIdMasked, maskUserId('ding-ops-user-2')) + assert.equal('recipientUserId' in testPayload.delivery, false) + assert.equal('actionUrl' in testPayload.delivery, false) + assert.equal(sentMessages.length, 1) + assert.equal(sentMessages[0].title, '【运营中心升级版】管理台联调') + assert.match(sentMessages[0].content, /### 【运营中心升级版】管理台联调/) + assert.match(sentMessages[0].actionUrl, /\/notification-center\/v1\/auth\/h5-entry/) + + const deliveriesResponse = await fetch(`http://127.0.0.1:${port}/admin/deliveries?projectCode=ops_center&isTest=true`, { + headers: withAdminAuth({ Accept: 'application/json' }), + }) + assert.equal(deliveriesResponse.status, 200) + const deliveriesPayload = await deliveriesResponse.json() as any + assert.equal(deliveriesPayload.total, 1) + assert.equal(deliveriesPayload.deliveries[0].projectCode, 'ops_center') + assert.equal(deliveriesPayload.deliveries[0].isTest, true) + assert.equal(deliveriesPayload.deliveries[0].recipientAlias, null) + assert.equal(deliveriesPayload.deliveries[0].recipientUserIdMasked, maskUserId('ding-ops-user-2')) + assert.equal('recipientUserId' in deliveriesPayload.deliveries[0], false) + assert.equal('actionUrl' in deliveriesPayload.deliveries[0], false) + + const auditsResponse = await fetch(`http://127.0.0.1:${port}/admin/audits?projectCode=ops_center`, { + headers: withAdminAuth({ Accept: 'application/json' }), + }) + assert.equal(auditsResponse.status, 200) + const auditsPayload = await auditsResponse.json() as any + assert.ok(auditsPayload.total >= 4) + const auditsJson = JSON.stringify(auditsPayload) + const deliveriesJson = JSON.stringify(deliveriesPayload) + const testJson = JSON.stringify(testPayload) + assert.doesNotMatch(auditsJson, new RegExp(created.secret)) + assert.doesNotMatch(auditsJson, new RegExp(adminApiSecret)) + assert.doesNotMatch(auditsJson, /ding-ops-user-2/) + assert.doesNotMatch(deliveriesJson, /ding-ops-user-2/) + assert.doesNotMatch(testJson, /ding-ops-user-2/) + assert.doesNotMatch(deliveriesJson, /https:\/\/ops\.example\.com\/tasks\/1/) + assert.doesNotMatch(testJson, /https:\/\/ops\.example\.com\/tasks\/1/) + assert.match(auditsJson, /\[redacted\]|\*\*\*/) + + const rotateResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/ops_center/rotate-secret`, { + method: 'POST', + headers: withAdminAuth(), + }) + assert.equal(rotateResponse.status, 200) + const rotated = await rotateResponse.json() as any + assert.equal(rotated.project.projectCode, 'ops_center') + assert.notEqual(rotated.secret, created.secret) + assert.deepEqual(rotated.env, { + YYD_NOTIFICATION_CENTER_URL: 'https://sc.example.com/notification-center', + YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'ops_center', + YYD_NOTIFICATION_CENTER_PROJECT_SECRET: rotated.secret, + }) + + const notificationPath = '/v1/notifications' + const oldSecretBody = JSON.stringify({ + idempotencyKey: 'ops-old-secret', + recipientUserId: 'ding-ops-user-2', + title: '旧密钥应失败', + content: '正文', + actionTitle: '查看', + actionUrl: 'https://ops.example.com/tasks/2', + }) + const oldSecretRejected = await fetch(`http://127.0.0.1:${port}${notificationPath}`, { + method: 'POST', + headers: signedHeaders({ + projectCode: 'ops_center', + secret: created.secret, + method: 'POST', + path: notificationPath, + body: oldSecretBody, + }), + body: oldSecretBody, + }) + assert.equal(oldSecretRejected.status, 401) + assert.deepEqual(await oldSecretRejected.json(), { error: 'SIGNATURE_INVALID' }) + + const newSecretBody = JSON.stringify({ + idempotencyKey: 'ops-new-secret', + recipientAlias: '海棠 新', + title: '新密钥已生效', + content: '正文', + actionTitle: '查看', + actionUrl: 'https://ops.example.com/tasks/3', + }) + const newSecretAccepted = await fetch(`http://127.0.0.1:${port}${notificationPath}`, { + method: 'POST', + headers: signedHeaders({ + projectCode: 'ops_center', + secret: rotated.secret, + method: 'POST', + path: notificationPath, + body: newSecretBody, + }), + body: newSecretBody, + }) + assert.equal(newSecretAccepted.status, 202) + assert.equal((await newSecretAccepted.json() as any).title, '【运营中心升级版】新密钥已生效') + + const deleteCreate = await fetch(`http://127.0.0.1:${port}/admin/projects`, { + method: 'POST', + headers: withAdminAuth(), + body: JSON.stringify({ + projectCode: 'temp_unused', + name: '临时项目', + allowedHosts: ['temp.example.com'], + }), + }) + assert.equal(deleteCreate.status, 201) + const deleteResponse = await fetch(`http://127.0.0.1:${port}/admin/projects/temp_unused`, { + method: 'DELETE', + headers: withAdminAuth(), + }) + assert.equal(deleteResponse.status, 200) + assert.deepEqual(await deleteResponse.json(), { projectCode: 'temp_unused', deleted: true }) + + const oversizedResponse = await fetch(`http://127.0.0.1:${port}/admin/projects`, { + method: 'POST', + headers: withAdminAuth(), + body: JSON.stringify({ + projectCode: 'oversized_project', + name: '超大请求', + allowedHosts: ['oversized.example.com'], + padding: 'x'.repeat(300_000), + }), + }) + assert.equal(oversizedResponse.status, 413) + assert.deepEqual(await oversizedResponse.json(), { + error: { code: 'REQUEST_TOO_LARGE', message: '请求体超过大小限制' }, + }) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY = oldEncryptionKey + process.env.YYD_NOTIFICATION_ADMIN_API_SECRET = oldAdminApiSecret + process.env.YYD_NOTIFICATION_ADMIN_TOKEN = oldAdminToken + process.env.YYD_NOTIFICATION_PROJECTS_JSON = oldProjectsJson + process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL = oldPublicBaseUrl + await prisma.$disconnect() + } +}) diff --git a/src/admin-auth.ts b/src/admin-auth.ts new file mode 100644 index 0000000..f77eb19 --- /dev/null +++ b/src/admin-auth.ts @@ -0,0 +1,43 @@ +import type { NextFunction, Request, Response } from 'express' +import { safeEqual, sha256 } from './signature' +import { AdminApiError, getAdminToken, type AdminAuditActor } from './admin-project-registry' + +const clean = (value: unknown) => String(value || '').trim() + +const parseBearerToken = (value: unknown) => { + const raw = clean(value) + if (!raw) return '' + const matched = raw.match(/^Bearer\s+(.+)$/i) + return clean(matched?.[1] || '') +} + +export type AdminRequest = Request & { + adminActor?: AdminAuditActor +} + +export const extractAdminActor = (req: Request): AdminAuditActor => ({ + actorType: 'admin', + actorId: clean(req.header('x-yyd-admin-actor')) || 'admin', + requestId: clean(req.header('x-request-id')) || undefined, + ipAddress: clean(req.ip || req.socket.remoteAddress) || undefined, +}) + +export const authenticateAdmin = (req: AdminRequest, _res: Response, next: NextFunction) => { + try { + const expectedToken = getAdminToken() + if (!expectedToken) { + throw new AdminApiError(503, 'ADMIN_AUTH_NOT_CONFIGURED', '管理员鉴权未配置') + } + const actualToken = parseBearerToken(req.header('authorization')) + if (!actualToken) { + throw new AdminApiError(401, 'ADMIN_AUTH_REQUIRED', '缺少管理员 Bearer Token') + } + if (!safeEqual(sha256(actualToken), sha256(expectedToken))) { + throw new AdminApiError(401, 'ADMIN_TOKEN_INVALID', '管理员 Token 无效') + } + req.adminActor = extractAdminActor(req) + next() + } catch (error) { + next(error) + } +} diff --git a/src/admin-project-registry.ts b/src/admin-project-registry.ts new file mode 100644 index 0000000..27d86fd --- /dev/null +++ b/src/admin-project-registry.ts @@ -0,0 +1,991 @@ +import crypto from 'crypto' +import prisma from './prisma' +import { getPublicBaseUrl, loadProjectConfigs, normalizeRecipientAlias, type ProjectConfig, type ProjectLoginConfig } from './config' + +const clean = (value: unknown) => String(value || '').trim() +const RECENT_FAILURE_WINDOW_DAYS = 7 + +export class AdminApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string, + public readonly details?: unknown, + ) { + super(message) + this.name = 'AdminApiError' + } +} + +export type AdminAuditActor = { + actorType: 'admin' | 'system' + actorId: string + requestId?: string + ipAddress?: string +} + +export type ProjectRecipientInput = { + alias: string + userId: string +} + +export type ProjectUpsertInput = { + projectCode: string + name: string + allowedHosts: string[] + isActive?: boolean + recipients?: ProjectRecipientInput[] + login?: ProjectLoginConfig | { enabled: false } | null +} + +export type ProjectUpdateInput = { + name?: string + allowedHosts?: string[] + isActive?: boolean + login?: ProjectLoginConfig | { enabled: false } | null +} + +export type ProjectRecipientView = { + alias: string + userId: string +} + +export type ProjectListItem = { + name: string + projectCode: string + isActive: boolean + allowedHosts: string[] + recipientMode: 'userId_only' | 'userId_or_alias' + recipientCount: number + recipients?: ProjectRecipientView[] + updatedAt: Date + deliverySummary: { + lastStatus: string | null + lastSentAt: Date | null + recentFailedCount: number + } + login?: { + enabled: true + callbackUrl: string + allowedRedirectPathPrefixes: string[] + } +} + +type ProjectCacheEntry = { + config: ProjectConfig + isActive: boolean +} + +type ProjectDbRow = Awaited> +type EncryptedStringPayload = { + ciphertext: string + nonce: string + authTag: string +} + +const looksLikeHexKey = (value: string) => /^[0-9a-f]{64}$/i.test(value) + +const decodeEncryptionKey = (raw: string) => { + const value = clean(raw) + if (!value) { + throw new Error('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY 未配置') + } + if (looksLikeHexKey(value)) { + return Buffer.from(value, 'hex') + } + try { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const decoded = Buffer.from(normalized, 'base64') + if (decoded.length === 32) return decoded + } catch { + // ignore + } + throw new Error('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY 必须是 32 字节的 hex 或 base64/base64url 密钥') +} + +const parseJsonArray = (raw: string, code: string, field: string) => { + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string')) { + throw new Error('INVALID') + } + return parsed.map((item) => String(item)) + } catch { + throw new Error(`项目 ${code} 的 ${field} 配置损坏`) + } +} + +const normalizeProjectInput = (candidate: { + code: string + name: string + secret: string + allowedHosts: string[] + recipients?: ProjectRecipientInput[] + login?: ProjectLoginConfig | { enabled: false } | null +}) => { + const parsed = loadProjectConfigs(JSON.stringify([{ + code: candidate.code, + name: candidate.name, + secret: candidate.secret, + allowedHosts: candidate.allowedHosts, + recipients: candidate.recipients?.map((item) => ({ + alias: item.alias, + userId: item.userId, + })), + login: candidate.login === null ? undefined : candidate.login, + }])) + const normalized = parsed.get(candidate.code) + if (!normalized) throw new Error(`项目 ${candidate.code} 解析失败`) + return normalized +} + +const projectConfigToLoginPayload = (login: ProjectLoginConfig | undefined) => + login ? { + enabled: true as const, + callbackUrl: login.callbackUrl, + allowedRedirectPathPrefixes: [...login.allowedRedirectPathPrefixes], + } : undefined + +export const maskUserId = (value: string) => { + if (value.length <= 6) return '***' + return `${value.slice(0, 3)}***${value.slice(-3)}` +} + +const redactAuditDetails = (value: unknown, keyHint = ''): unknown => { + if (Array.isArray(value)) return value.map((item) => redactAuditDetails(item)) + if (!value || typeof value !== 'object') { + if (typeof value === 'string' && /(secret|token|signature|authorization)/i.test(keyHint)) return '[redacted]' + if (typeof value === 'string' && /userId/i.test(keyHint)) return maskUserId(value) + return value + } + const output: Record = {} + for (const [key, item] of Object.entries(value as Record)) { + if (/(secret|ciphertext|authTag|token|signature|authorization)/i.test(key)) { + output[key] = '[redacted]' + continue + } + if (/userId/i.test(key) && typeof item === 'string') { + output[key] = maskUserId(item) + continue + } + output[key] = redactAuditDetails(item, key) + } + return output +} + +const createAuditDetailsJson = (details: unknown) => { + if (details === undefined) return null + return JSON.stringify(redactAuditDetails(details)) +} + +const buildProjectEnv = (projectCode: string, secret: string) => ({ + YYD_NOTIFICATION_CENTER_URL: getPublicBaseUrl(), + YYD_NOTIFICATION_CENTER_PROJECT_CODE: projectCode, + YYD_NOTIFICATION_CENTER_PROJECT_SECRET: secret, +}) + +const buildProjectView = (input: { + code: string + name: string + isActive: boolean + allowedHosts: string[] + recipients: ProjectRecipientView[] + updatedAt: Date + deliverySummary: { + lastStatus: string | null + lastSentAt: Date | null + recentFailedCount: number + } + login?: ProjectLoginConfig + includeRecipients?: boolean +}): ProjectListItem => ({ + name: input.name, + projectCode: input.code, + isActive: input.isActive, + allowedHosts: input.allowedHosts, + recipientMode: input.recipients.length > 0 ? 'userId_or_alias' : 'userId_only', + recipientCount: input.recipients.length, + ...(input.includeRecipients ? { recipients: input.recipients } : {}), + updatedAt: input.updatedAt, + deliverySummary: input.deliverySummary, + ...(input.login ? { login: projectConfigToLoginPayload(input.login) } : {}), +}) + +export const createProjectSecret = () => crypto.randomBytes(32).toString('base64url') + +export const getAdminToken = () => + clean(process.env.YYD_NOTIFICATION_ADMIN_API_SECRET || process.env.YYD_NOTIFICATION_ADMIN_TOKEN) + +export const assertAdminRuntimeConfig = () => { + if (!getAdminToken()) throw new Error('YYD_NOTIFICATION_ADMIN_API_SECRET 未配置(可兼容旧变量 YYD_NOTIFICATION_ADMIN_TOKEN)') + decodeEncryptionKey(process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY || '') +} + +export class AdminProjectRegistry { + private readonly encryptionKey: Buffer + + private runtimeProjects = new Map() + + private refreshedAt: Date | null = null + + constructor(encryptionKeyRaw = process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY || '') { + this.encryptionKey = decodeEncryptionKey(encryptionKeyRaw) + } + + private encryptValue(value: string): EncryptedStringPayload { + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv) + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) + return { + ciphertext: encrypted.toString('base64'), + nonce: iv.toString('base64'), + authTag: cipher.getAuthTag().toString('base64'), + } + } + + private decryptValue(payload: EncryptedStringPayload) { + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + this.encryptionKey, + Buffer.from(payload.nonce, 'base64'), + ) + decipher.setAuthTag(Buffer.from(payload.authTag, 'base64')) + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(payload.ciphertext, 'base64')), + decipher.final(), + ]) + return decrypted.toString('utf8') + } + + private encryptSecret(secret: string) { + const encrypted = this.encryptValue(secret) + return { + secretCiphertext: encrypted.ciphertext, + secretNonce: encrypted.nonce, + secretAuthTag: encrypted.authTag, + } + } + + private decryptSecret(row: { + secretCiphertext: string + secretNonce: string + secretAuthTag: string + }) { + return this.decryptValue({ + ciphertext: row.secretCiphertext, + nonce: row.secretNonce, + authTag: row.secretAuthTag, + }) + } + + private encryptRecipientUserId(userId: string) { + const encrypted = this.encryptValue(userId) + return { + userIdCiphertext: encrypted.ciphertext, + userIdNonce: encrypted.nonce, + userIdAuthTag: encrypted.authTag, + } + } + + private decryptRecipientUserId(row: { + userIdCiphertext: string + userIdNonce: string + userIdAuthTag: string + }) { + return this.decryptValue({ + ciphertext: row.userIdCiphertext, + nonce: row.userIdNonce, + authTag: row.userIdAuthTag, + }) + } + + private buildRecipientCreateRows(recipientAliases: Map, inputRecipients: ProjectRecipientInput[]) { + const displayByNormalized = new Map() + for (const item of inputRecipients) { + const aliasDisplay = clean(item.alias) + if (!aliasDisplay) continue + displayByNormalized.set(normalizeRecipientAlias(aliasDisplay), aliasDisplay) + } + return [...recipientAliases.entries()].map(([aliasNormalized, userId]) => ({ + id: crypto.randomUUID(), + aliasDisplay: displayByNormalized.get(aliasNormalized) || aliasNormalized, + aliasNormalized, + ...this.encryptRecipientUserId(userId), + })) + } + + private decodeRecipientView(row: { + aliasDisplay: string + userIdCiphertext: string + userIdNonce: string + userIdAuthTag: string + }) { + return { + alias: row.aliasDisplay, + userId: this.decryptRecipientUserId(row), + } + } + + private mapProjectRowToRuntimeEntry(row: { + code: string + name: string + secretCiphertext: string + secretNonce: string + secretAuthTag: string + allowedHostsJson: string + loginEnabled: boolean + loginCallbackUrl: string | null + loginAllowedRedirectPrefixesJson: string | null + isActive: boolean + recipients: Array<{ aliasNormalized: string; userIdCiphertext: string; userIdNonce: string; userIdAuthTag: string }> + }): ProjectCacheEntry { + const allowedHosts = parseJsonArray(row.allowedHostsJson, row.code, 'allowedHostsJson') + const loginPrefixes = row.loginAllowedRedirectPrefixesJson + ? parseJsonArray(row.loginAllowedRedirectPrefixesJson, row.code, 'loginAllowedRedirectPrefixesJson') + : [] + const recipientAliases = new Map() + for (const recipient of row.recipients) recipientAliases.set( + recipient.aliasNormalized, + this.decryptRecipientUserId(recipient), + ) + const config: ProjectConfig = { + code: row.code, + name: row.name, + secret: this.decryptSecret(row), + allowedHosts, + recipientAliases, + login: row.loginEnabled && row.loginCallbackUrl ? { + enabled: true, + callbackUrl: row.loginCallbackUrl, + allowedRedirectPathPrefixes: loginPrefixes, + } : undefined, + } + return { config, isActive: row.isActive } + } + + async refresh() { + const rows = await prisma.notificationProject.findMany({ + include: { + recipients: { + orderBy: { aliasDisplay: 'asc' }, + }, + }, + orderBy: { code: 'asc' }, + }) + const next = new Map() + for (const row of rows) { + const entry = this.mapProjectRowToRuntimeEntry(row) + next.set(row.code, entry) + } + this.runtimeProjects = next + this.refreshedAt = new Date() + return { + refreshedAt: this.refreshedAt, + projectCount: [...next.values()].filter((item) => item.isActive).length, + } + } + + getRuntimeProjectsSnapshot() { + const snapshot = new Map() + for (const [code, entry] of this.runtimeProjects.entries()) { + if (entry.isActive) snapshot.set(code, entry.config) + } + return snapshot + } + + getRefreshedAt() { + return this.refreshedAt + } + + async getActiveProject(code: string) { + const entry = this.runtimeProjects.get(code) + return entry?.isActive ? entry.config : undefined + } + + async getProjectByCode(code: string, includeRecipients = false) { + const row = await prisma.notificationProject.findUnique({ + where: { code }, + include: includeRecipients ? { + recipients: { + orderBy: { aliasDisplay: 'asc' }, + }, + } : undefined, + }) + if (!row) throw new AdminApiError(404, 'PROJECT_NOT_FOUND', `项目 ${code} 不存在`) + return row + } + + private async buildDeliverySummaryMap(projectCodes: string[]) { + const summaries = new Map() + if (projectCodes.length === 0) return summaries + const recentSince = new Date(Date.now() - RECENT_FAILURE_WINDOW_DAYS * 24 * 60 * 60 * 1000) + const [recentFailed, latestDeliveries] = await prisma.$transaction([ + prisma.notificationDelivery.groupBy({ + by: ['projectCode'], + where: { + projectCode: { in: projectCodes }, + status: 'failed', + createdAt: { gte: recentSince }, + }, + orderBy: { projectCode: 'asc' }, + _count: { projectCode: true }, + }), + prisma.notificationDelivery.findMany({ + where: { projectCode: { in: projectCodes } }, + orderBy: [{ createdAt: 'desc' }, { updatedAt: 'desc' }], + select: { + projectCode: true, + status: true, + sentAt: true, + }, + }), + ]) + for (const code of projectCodes) { + summaries.set(code, { lastStatus: null, lastSentAt: null, recentFailedCount: 0 }) + } + for (const row of recentFailed) { + const countPayload = row._count as { projectCode?: number } | undefined + summaries.set(row.projectCode, { + ...(summaries.get(row.projectCode) || { lastStatus: null, lastSentAt: null, recentFailedCount: 0 }), + recentFailedCount: countPayload?.projectCode || 0, + }) + } + for (const row of latestDeliveries) { + if (!summaries.has(row.projectCode)) continue + const current = summaries.get(row.projectCode)! + if (current.lastStatus !== null) continue + summaries.set(row.projectCode, { + ...current, + lastStatus: row.status, + lastSentAt: row.sentAt, + }) + } + return summaries + } + + private async buildProjectViewFromRow( + row: Awaited>, + options: { includeRecipients?: boolean; deliverySummaryMap?: Map } = {}, + ) { + const runtimeEntry = this.mapProjectRowToRuntimeEntry({ + ...row, + recipients: 'recipients' in row && Array.isArray((row as { recipients?: unknown }).recipients) + ? (row as { recipients: Array<{ aliasNormalized: string; userIdCiphertext: string; userIdNonce: string; userIdAuthTag: string }> }).recipients + : await prisma.notificationProjectRecipient.findMany({ + where: { projectId: row.id }, + orderBy: { aliasDisplay: 'asc' }, + select: { aliasNormalized: true, userIdCiphertext: true, userIdNonce: true, userIdAuthTag: true }, + }), + }) + const recipientRows = 'recipients' in row && Array.isArray((row as { recipients?: unknown }).recipients) + ? (row as { recipients: Array<{ aliasDisplay: string; userIdCiphertext: string; userIdNonce: string; userIdAuthTag: string }> }).recipients.map((item) => this.decodeRecipientView(item)) + : await prisma.notificationProjectRecipient.findMany({ + where: { projectId: row.id }, + orderBy: { aliasDisplay: 'asc' }, + select: { aliasDisplay: true, userIdCiphertext: true, userIdNonce: true, userIdAuthTag: true }, + }).then((items) => items.map((item) => this.decodeRecipientView(item))) + const summary = options.deliverySummaryMap?.get(row.code) || { lastStatus: null, lastSentAt: null, recentFailedCount: 0 } + return buildProjectView({ + code: row.code, + name: row.name, + isActive: row.isActive, + allowedHosts: [...runtimeEntry.config.allowedHosts], + recipients: recipientRows, + updatedAt: row.updatedAt, + deliverySummary: summary, + login: runtimeEntry.config.login, + includeRecipients: options.includeRecipients, + }) + } + + private async recordAudit(input: { + actor: AdminAuditActor + action: string + summary: string + targetProjectCode?: string + details?: unknown + }) { + await prisma.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: input.actor.actorType, + actorId: input.actor.actorId, + action: input.action, + targetProjectCode: input.targetProjectCode || null, + requestId: input.actor.requestId || null, + ipAddress: input.actor.ipAddress || null, + summary: input.summary, + detailsJson: createAuditDetailsJson(input.details), + }, + }) + } + + private async loadExistingProjectForMutation(code: string) { + const row = await prisma.notificationProject.findUnique({ + where: { code }, + include: { + recipients: { + orderBy: { aliasDisplay: 'asc' }, + }, + }, + }) + if (!row) throw new AdminApiError(404, 'PROJECT_NOT_FOUND', `项目 ${code} 不存在`) + return row + } + + async listProjects(includeRecipients = false) { + const rows = await prisma.notificationProject.findMany({ + include: { + recipients: { + orderBy: { aliasDisplay: 'asc' }, + }, + }, + orderBy: { code: 'asc' }, + }) + const summaryMap = await this.buildDeliverySummaryMap(rows.map((row) => row.code)) + const projects = await Promise.all(rows.map((row) => this.buildProjectViewFromRow(row, { + includeRecipients, + deliverySummaryMap: summaryMap, + }))) + return { projects } + } + + async getProjectView(projectCode: string, includeRecipients = false) { + const row = await this.loadExistingProjectForMutation(projectCode) + const deliverySummaryMap = await this.buildDeliverySummaryMap([projectCode]) + return { + project: await this.buildProjectViewFromRow(row, { includeRecipients, deliverySummaryMap }), + } + } + + async createProject(input: ProjectUpsertInput, actor: AdminAuditActor) { + const secret = createProjectSecret() + const normalized = normalizeProjectInput({ + code: input.projectCode, + name: input.name, + secret, + allowedHosts: input.allowedHosts, + recipients: input.recipients || [], + login: input.login, + }) + const encrypted = this.encryptSecret(secret) + try { + await prisma.$transaction(async (tx) => { + const projectId = crypto.randomUUID() + await tx.notificationProject.create({ + data: { + id: projectId, + code: normalized.code, + name: normalized.name, + ...encrypted, + allowedHostsJson: JSON.stringify(normalized.allowedHosts), + loginEnabled: Boolean(normalized.login), + loginCallbackUrl: normalized.login?.callbackUrl || null, + loginAllowedRedirectPrefixesJson: normalized.login + ? JSON.stringify(normalized.login.allowedRedirectPathPrefixes) + : null, + isActive: input.isActive !== false, + lastSecretRotatedAt: new Date(), + recipients: normalized.recipientAliases.size > 0 ? { + create: this.buildRecipientCreateRows(normalized.recipientAliases, input.recipients || []), + } : undefined, + }, + }) + await tx.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: actor.actorType, + actorId: actor.actorId, + action: 'project.create', + targetProjectCode: normalized.code, + requestId: actor.requestId || null, + ipAddress: actor.ipAddress || null, + summary: `创建项目 ${normalized.code}`, + detailsJson: createAuditDetailsJson({ + allowedHosts: normalized.allowedHosts, + login: projectConfigToLoginPayload(normalized.login), + recipientCount: normalized.recipientAliases.size, + isActive: input.isActive !== false, + }), + }, + }) + }) + } catch (error: any) { + if (clean(error?.code) === 'P2002') { + throw new AdminApiError(409, 'PROJECT_CODE_CONFLICT', `项目 ${normalized.code} 已存在`) + } + throw error + } + await this.refresh() + return { + project: (await this.getProjectView(normalized.code, true)).project, + secret, + env: buildProjectEnv(normalized.code, secret), + } + } + + async updateProject(projectCode: string, input: ProjectUpdateInput, actor: AdminAuditActor) { + const current = await this.loadExistingProjectForMutation(projectCode) + const currentRuntime = this.mapProjectRowToRuntimeEntry(current) + const normalized = normalizeProjectInput({ + code: current.code, + name: input.name ?? current.name, + secret: currentRuntime.config.secret, + allowedHosts: input.allowedHosts ?? currentRuntime.config.allowedHosts, + recipients: current.recipients.map((item) => ({ + alias: item.aliasDisplay, + userId: this.decryptRecipientUserId(item), + })), + login: Object.prototype.hasOwnProperty.call(input, 'login') + ? input.login + : currentRuntime.config.login, + }) + await prisma.$transaction(async (tx) => { + await tx.notificationProject.update({ + where: { code: projectCode }, + data: { + name: normalized.name, + allowedHostsJson: JSON.stringify(normalized.allowedHosts), + loginEnabled: Boolean(normalized.login), + loginCallbackUrl: normalized.login?.callbackUrl || null, + loginAllowedRedirectPrefixesJson: normalized.login + ? JSON.stringify(normalized.login.allowedRedirectPathPrefixes) + : null, + ...(input.isActive === undefined ? {} : { isActive: input.isActive }), + }, + }) + await tx.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: actor.actorType, + actorId: actor.actorId, + action: 'project.update', + targetProjectCode: projectCode, + requestId: actor.requestId || null, + ipAddress: actor.ipAddress || null, + summary: `更新项目 ${projectCode}`, + detailsJson: createAuditDetailsJson({ + name: normalized.name, + allowedHosts: normalized.allowedHosts, + login: projectConfigToLoginPayload(normalized.login), + isActive: input.isActive ?? current.isActive, + }), + }, + }) + }) + await this.refresh() + return this.getProjectView(projectCode, true) + } + + async replaceRecipients(projectCode: string, recipients: ProjectRecipientInput[], actor: AdminAuditActor) { + const current = await this.loadExistingProjectForMutation(projectCode) + const currentRuntime = this.mapProjectRowToRuntimeEntry(current) + const normalized = normalizeProjectInput({ + code: current.code, + name: current.name, + secret: currentRuntime.config.secret, + allowedHosts: currentRuntime.config.allowedHosts, + recipients, + login: currentRuntime.config.login, + }) + await prisma.$transaction(async (tx) => { + await tx.notificationProjectRecipient.deleteMany({ where: { projectId: current.id } }) + if (normalized.recipientAliases.size > 0) { + await tx.notificationProjectRecipient.createMany({ + data: this.buildRecipientCreateRows(normalized.recipientAliases, recipients).map((item) => ({ + ...item, + projectId: current.id, + })), + }) + } + await tx.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: actor.actorType, + actorId: actor.actorId, + action: 'project.recipients.replace', + targetProjectCode: projectCode, + requestId: actor.requestId || null, + ipAddress: actor.ipAddress || null, + summary: `替换项目 ${projectCode} 收件人`, + detailsJson: createAuditDetailsJson({ + recipientCount: normalized.recipientAliases.size, + recipients: [...normalized.recipientAliases.entries()].map(([alias, userId]) => ({ alias, userId })), + }), + }, + }) + }) + await this.refresh() + return this.getProjectView(projectCode, true) + } + + async rotateSecret(projectCode: string, actor: AdminAuditActor) { + await this.getProjectByCode(projectCode) + const secret = createProjectSecret() + const encrypted = this.encryptSecret(secret) + await prisma.$transaction(async (tx) => { + await tx.notificationProject.update({ + where: { code: projectCode }, + data: { + ...encrypted, + lastSecretRotatedAt: new Date(), + }, + }) + await tx.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: actor.actorType, + actorId: actor.actorId, + action: 'project.rotate_secret', + targetProjectCode: projectCode, + requestId: actor.requestId || null, + ipAddress: actor.ipAddress || null, + summary: `轮换项目 ${projectCode} 密钥`, + detailsJson: createAuditDetailsJson({ rotatedAt: new Date().toISOString() }), + }, + }) + }) + await this.refresh() + return { + project: (await this.getProjectView(projectCode, true)).project, + secret, + env: buildProjectEnv(projectCode, secret), + } + } + + async deleteProject(projectCode: string, actor: AdminAuditActor) { + const current = await this.loadExistingProjectForMutation(projectCode) + const deliveryCount = await prisma.notificationDelivery.count({ where: { projectCode } }) + if (deliveryCount > 0) { + throw new AdminApiError(409, 'PROJECT_HAS_DELIVERIES', `项目 ${projectCode} 已存在投递记录,请先停用而不是删除`, { + deliveryCount, + }) + } + await prisma.$transaction(async (tx) => { + await tx.notificationProject.delete({ where: { code: projectCode } }) + await tx.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: actor.actorType, + actorId: actor.actorId, + action: 'project.delete', + targetProjectCode: projectCode, + requestId: actor.requestId || null, + ipAddress: actor.ipAddress || null, + summary: `删除项目 ${projectCode}`, + detailsJson: createAuditDetailsJson({ recipientCount: current.recipients.length }), + }, + }) + }) + await this.refresh() + return { projectCode, deleted: true } + } + + async importFromEnvironment(actor: AdminAuditActor) { + const parsed = loadProjectConfigs() + if (parsed.size === 0) { + return { + importedProjectCodes: [] as string[], + skippedProjectCodes: [] as string[], + projectCount: this.getRuntimeProjectsSnapshot().size, + refreshedAt: this.getRefreshedAt(), + } + } + const existing = await prisma.notificationProject.findMany({ + select: { code: true }, + }) + const existingCodes = new Set(existing.map((item) => item.code)) + const importedProjectCodes: string[] = [] + const skippedProjectCodes: string[] = [] + await prisma.$transaction(async (tx) => { + for (const project of parsed.values()) { + if (existingCodes.has(project.code)) { + skippedProjectCodes.push(project.code) + continue + } + const secretPayload = this.encryptSecret(project.secret) + await tx.notificationProject.create({ + data: { + id: crypto.randomUUID(), + code: project.code, + name: project.name, + ...secretPayload, + allowedHostsJson: JSON.stringify(project.allowedHosts), + loginEnabled: Boolean(project.login), + loginCallbackUrl: project.login?.callbackUrl || null, + loginAllowedRedirectPrefixesJson: project.login + ? JSON.stringify(project.login.allowedRedirectPathPrefixes) + : null, + isActive: true, + importedFromEnvAt: new Date(), + lastSecretRotatedAt: new Date(), + recipients: project.recipientAliases.size > 0 ? { + create: this.buildRecipientCreateRows(project.recipientAliases, [...project.recipientAliases.entries()].map(([alias, userId]) => ({ + alias, + userId, + }))), + } : undefined, + }, + }) + importedProjectCodes.push(project.code) + } + await tx.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: actor.actorType, + actorId: actor.actorId, + action: 'runtime.import_env', + targetProjectCode: null, + requestId: actor.requestId || null, + ipAddress: actor.ipAddress || null, + summary: importedProjectCodes.length > 0 + ? `导入环境变量项目 ${importedProjectCodes.join(', ')}` + : '环境变量项目已存在,未覆盖', + detailsJson: createAuditDetailsJson({ + importedProjectCodes, + skippedProjectCodes, + }), + }, + }) + }) + const refreshed = await this.refresh() + return { + importedProjectCodes, + skippedProjectCodes, + projectCount: refreshed.projectCount, + refreshedAt: refreshed.refreshedAt, + } + } + + async listRecipients(projectCode: string) { + const row = await this.loadExistingProjectForMutation(projectCode) + const recipients = row.recipients.map((item) => this.decodeRecipientView(item)) + return { + recipients, + total: recipients.length, + } + } + + async listDeliveries(input: { + projectCode?: string + status?: string + isTest?: boolean + take?: number + skip?: number + }) { + const where = { + ...(input.projectCode ? { projectCode: input.projectCode } : {}), + ...(input.status ? { status: input.status } : {}), + ...(input.isTest === undefined ? {} : { isTest: input.isTest }), + } + const take = Math.min(100, Math.max(1, input.take || 20)) + const skip = Math.max(0, input.skip || 0) + const [total, rows] = await prisma.$transaction([ + prisma.notificationDelivery.count({ where }), + prisma.notificationDelivery.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { updatedAt: 'desc' }], + skip, + take, + }), + ]) + return { + deliveries: rows.map((row) => ({ + id: row.id, + projectCode: row.projectCode, + idempotencyKey: row.idempotencyKey, + recipientAlias: null, + recipientUserIdMasked: maskUserId(row.recipientUserId), + title: row.renderedTitle, + status: row.status, + attemptCount: row.attemptCount, + maxAttempts: row.maxAttempts, + lastErrorCode: row.lastErrorCode, + lastErrorMessage: row.lastErrorMessage, + processQueryKey: row.processQueryKey, + actionTitle: row.actionTitle, + isTest: row.isTest, + sentAt: row.sentAt, + readAt: row.readAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })), + total, + } + } + + async listAudits(input: { + projectCode?: string + action?: string + take?: number + skip?: number + }) { + const where = { + ...(input.projectCode ? { targetProjectCode: input.projectCode } : {}), + ...(input.action ? { action: input.action } : {}), + } + const take = Math.min(100, Math.max(1, input.take || 20)) + const skip = Math.max(0, input.skip || 0) + const [total, rows] = await prisma.$transaction([ + prisma.notificationAudit.count({ where }), + prisma.notificationAudit.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take, + }), + ]) + return { + audits: rows.map((row) => ({ + id: row.id, + actorType: row.actorType, + actorId: row.actorId, + action: row.action, + targetProjectCode: row.targetProjectCode, + requestId: row.requestId, + ipAddress: row.ipAddress, + summary: row.summary, + details: row.detailsJson ? JSON.parse(row.detailsJson) : null, + createdAt: row.createdAt, + })), + total, + } + } + + async recordRuntimeRefresh(actor: AdminAuditActor) { + const refreshed = await this.refresh() + await this.recordAudit({ + actor, + action: 'runtime.refresh', + summary: '刷新运行时项目缓存', + details: { + projectCount: refreshed.projectCount, + refreshedAt: refreshed.refreshedAt.toISOString(), + }, + }) + return refreshed + } + + async createSystemAudit(action: string, summary: string, details?: unknown) { + await this.recordAudit({ + actor: { actorType: 'system', actorId: 'bootstrap' }, + action, + summary, + details, + }) + } + + async resolveProjectConfigForCreate(projectCode: string) { + const entry = await this.getActiveProject(projectCode) + if (!entry) throw new AdminApiError(404, 'PROJECT_NOT_FOUND', `项目 ${projectCode} 不存在或已停用`) + return entry + } + + async maskProjectRecipientForAudit(projectCode: string, recipientAlias: string | undefined, recipientUserId: string | undefined) { + const recipient = clean(recipientAlias) || clean(recipientUserId) + return recipient ? { + projectCode, + recipient: clean(recipientAlias) ? normalizeRecipientAlias(recipientAlias) : maskUserId(clean(recipientUserId)), + } : { projectCode } + } +} diff --git a/src/index.ts b/src/index.ts index 0f3040a..3904cf2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,42 @@ import 'dotenv/config' -import { assertRuntimeConfig, loadProjectConfigs } from './config' +import type { ProjectSource } from './server' import prisma, { ensureDatabaseReady } from './prisma' -import { createServer } from './server' +import { createServer, resolveRuntimeProjectSource } from './server' import { startWorker } from './worker' -const projects = loadProjectConfigs() -assertRuntimeConfig(projects) - const port = Math.max(1, Number(process.env.PORT) || 3088) +export const initializeRuntime = async (): Promise<{ + projectSource: ProjectSource + mode: 'legacy' | 'registry' + adminAvailable: boolean + activeProjectCount: number + importedProjectCount: number + refreshedAt: Date | null + adminConfigError: string | null +}> => { + return resolveRuntimeProjectSource() +} + const main = async () => { await ensureDatabaseReady() - createServer(projects).listen(port, () => { - console.log(`[NotificationCenter] listening on ${port}, projects=${projects.size}`) + const runtime = await initializeRuntime() + createServer(runtime.projectSource).listen(port, () => { + console.log( + runtime.mode === 'registry' + ? `[NotificationCenter] listening on ${port}, mode=registry, activeProjects=${runtime.activeProjectCount}, imported=${runtime.importedProjectCount}, refreshedAt=${runtime.refreshedAt?.toISOString() || 'n/a'}` + : `[NotificationCenter] listening on ${port}, mode=legacy, activeProjects=${runtime.activeProjectCount}, adminAvailable=false, reason=${runtime.adminConfigError || 'n/a'}`, + ) }) startWorker() } -main().catch((error) => { - console.error('[NotificationCenter] startup failed:', error) - process.exitCode = 1 -}) +if (require.main === module) { + main().catch((error) => { + console.error('[NotificationCenter] startup failed:', error) + process.exitCode = 1 + }) +} process.on('SIGTERM', async () => { await prisma.$disconnect().catch(() => undefined) diff --git a/src/server.ts b/src/server.ts index a04bc37..9630cc2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,41 @@ import crypto from 'crypto' import express, { NextFunction, Request, Response } from 'express' import { buildH5EntryUrl, createH5EntryPage, getRedirectPathFromActionUrl, LoginInputError, validateRedirectPath } from './auth' -import { getDingTalkConfig, getPublicBaseUrl, normalizeRecipientAlias, type ProjectConfig } from './config' -import { DingTalkError, exchangeH5AuthCodeForUserId } from './dingtalk' +import { authenticateAdmin, type AdminRequest } from './admin-auth' +import { AdminApiError, AdminProjectRegistry, assertAdminRuntimeConfig, maskUserId, type ProjectRecipientInput, type ProjectUpdateInput, type ProjectUpsertInput } from './admin-project-registry' +import { assertRuntimeConfig, getDingTalkConfig, getPublicBaseUrl, loadProjectConfigs, normalizeRecipientAlias, type ProjectConfig, type ProjectLoginConfig } from './config' +import { DingTalkError, exchangeH5AuthCodeForUserId, sendRobotMessage } from './dingtalk' import prisma from './prisma' import { createCanonicalSignature, safeEqual, sha256 } from './signature' +import { classifyDeliveryFailure } from './worker' type SignedRequest = Request & { rawBody?: Buffer; project?: ProjectConfig } +export type ProjectSource = Map | AdminProjectRegistry const clean = (value: unknown) => String(value || '').trim() +const getTodayLabel = () => { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: process.env.TZ || 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date()) + const year = parts.find((item) => item.type === 'year')?.value || '0000' + const month = parts.find((item) => item.type === 'month')?.value || '00' + const day = parts.find((item) => item.type === 'day')?.value || '00' + return `${year}-${month}-${day}` +} + +class PublicApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message?: string, + ) { + super(message || code) + this.name = 'PublicApiError' + } +} export const renderNotificationTitle = (projectName: string, businessTitle: string) => `【${clean(projectName)}】${clean(businessTitle)}` @@ -24,11 +51,42 @@ export const renderNotificationContent = ( const heading = `### ${renderNotificationTitle(projectName, businessTitle)}` return contentWithoutLegacyHeading ? `${heading}\n\n${contentWithoutLegacyHeading}` : heading } + const ticketTtlSeconds = () => { const configured = Number(process.env.DINGTALK_LOGIN_TICKET_TTL_SECONDS || 60) return Number.isInteger(configured) && configured >= 30 && configured <= 60 ? configured : 60 } -const deliveryView = (row: any) => ({ + +const isRegistryProjectSource = (source: ProjectSource): source is AdminProjectRegistry => + source instanceof AdminProjectRegistry + +const publicErrorPayload = (error: PublicApiError) => + error.message && error.message !== error.code + ? { error: error.code, message: error.message } + : { error: error.code } + +const adminErrorPayload = (error: AdminApiError) => ({ + error: { + code: error.code, + message: error.message, + ...(error.details === undefined ? {} : { details: error.details }), + }, +}) + +const deliveryView = (row: { + id: string + projectCode: string + status: string + recipientUserId: string + renderedTitle: string + processQueryKey: string | null + lastErrorCode: string | null + lastErrorMessage: string | null + sentAt: Date | null + readAt: Date | null + createdAt: Date + updatedAt: Date +}) => ({ id: row.id, projectCode: row.projectCode, status: row.status, @@ -43,6 +101,45 @@ const deliveryView = (row: any) => ({ updatedAt: row.updatedAt, }) +const adminDeliveryView = (row: { + id: string + projectCode: string + idempotencyKey: string + recipientUserId: string + renderedTitle: string + status: string + attemptCount: number + maxAttempts: number + lastErrorCode: string | null + lastErrorMessage: string | null + processQueryKey: string | null + actionTitle: string + isTest: boolean + sentAt: Date | null + readAt: Date | null + createdAt: Date + updatedAt: Date +}) => ({ + id: row.id, + projectCode: row.projectCode, + idempotencyKey: row.idempotencyKey, + recipientAlias: null, + recipientUserIdMasked: maskUserId(row.recipientUserId), + title: row.renderedTitle, + status: row.status, + attemptCount: row.attemptCount, + maxAttempts: row.maxAttempts, + lastErrorCode: row.lastErrorCode, + lastErrorMessage: row.lastErrorMessage, + processQueryKey: row.processQueryKey, + actionTitle: row.actionTitle, + isTest: row.isTest, + sentAt: row.sentAt, + readAt: row.readAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}) + const validateActionUrl = (value: string, project: ProjectConfig) => { let url: URL try { @@ -61,21 +158,246 @@ const validateActionUrl = (value: string, project: ProjectConfig) => { type CreateServerDependencies = { exchangeH5Code?: (code: string) => Promise + sendTestMessage?: typeof sendRobotMessage } -export const createServer = (projects: Map, dependencies: CreateServerDependencies = {}) => { +type NormalizedNotificationInput = { + project: ProjectConfig + idempotencyKey: string + recipientUserId: string + title: string + content: string + actionTitle: string + destinationActionUrl: string + renderedActionUrl: string + hasSelfBrandedTitle: boolean +} + +const getProjectFromSource = async (source: ProjectSource, projectCode: string) => { + if (isRegistryProjectSource(source)) return source.getActiveProject(projectCode) + return source.get(projectCode) +} + +const parseRecipientsInput = (value: unknown): ProjectRecipientInput[] => { + if (value === undefined) return [] + if (!Array.isArray(value)) throw new AdminApiError(422, 'RECIPIENTS_INVALID', 'recipients 必须是数组') + return value.map((item, index) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw new AdminApiError(422, 'RECIPIENTS_INVALID', `recipients[${index}] 必须是对象`) + } + const row = item as Record + return { + alias: clean(row.alias), + userId: clean(row.userId), + } + }) +} + +const parseAllowedHosts = (value: unknown) => { + if (!Array.isArray(value)) throw new AdminApiError(422, 'ALLOWED_HOSTS_INVALID', 'allowedHosts 必须是数组') + return value.map((item) => clean(item)).filter(Boolean) +} + +const parseOptionalBoolean = (value: unknown, field: string) => { + if (value === undefined) return undefined + if (typeof value !== 'boolean') { + throw new AdminApiError(422, 'BOOLEAN_FIELD_INVALID', `${field} 必须是布尔值`) + } + return value +} + +const parseLoginPayload = (value: unknown): ProjectLoginConfig | { enabled: false } | null | undefined => { + if (value === undefined) return undefined + if (value === null) return null + if (typeof value !== 'object' || Array.isArray(value)) { + throw new AdminApiError(422, 'LOGIN_INVALID', 'login 必须是对象、null 或省略') + } + const row = value as Record + if (row.enabled === false) return { enabled: false } + return { + enabled: true, + callbackUrl: clean(row.callbackUrl), + allowedRedirectPathPrefixes: Array.isArray(row.allowedRedirectPathPrefixes) + ? row.allowedRedirectPathPrefixes.map((item) => clean(item)).filter(Boolean) + : [], + } +} + +const buildPublicNotificationInput = (input: { + project: ProjectConfig + body: Record | undefined + requireIdempotency: boolean +}) => { + const body = input.body || {} + const idempotencyKey = clean(body.idempotencyKey) + const requestedRecipientUserId = clean(body.recipientUserId) + const recipientAlias = clean(body.recipientAlias) + const title = clean(body.title) + const content = clean(body.content) + const actionTitle = clean(body.actionTitle) || '查看详情' + + if (requestedRecipientUserId && recipientAlias) { + throw new PublicApiError(422, 'RECIPIENT_SELECTOR_CONFLICT') + } + if (recipientAlias.length > 50 || /[\u0000-\u001f\u007f]/.test(recipientAlias)) { + throw new PublicApiError(422, 'RECIPIENT_ALIAS_INVALID') + } + const recipientUserId = requestedRecipientUserId || input.project.recipientAliases.get(normalizeRecipientAlias(recipientAlias)) || '' + if (recipientAlias && !recipientUserId) { + throw new PublicApiError(422, 'RECIPIENT_ALIAS_NOT_FOUND') + } + + let destinationActionUrl: string + let renderedActionUrl: string + try { + destinationActionUrl = validateActionUrl(clean(body.actionUrl), input.project) + renderedActionUrl = input.project.login + ? buildH5EntryUrl({ + publicBaseUrl: getPublicBaseUrl(), + project: input.project, + redirectPath: getRedirectPathFromActionUrl(destinationActionUrl, input.project), + }) + : destinationActionUrl + } catch (error: any) { + throw new PublicApiError(422, 'ACTION_URL_INVALID', clean(error?.message)) + } + + if (input.requireIdempotency && (!idempotencyKey || idempotencyKey.length > 160)) { + throw new PublicApiError(422, 'IDEMPOTENCY_KEY_INVALID') + } + if (!recipientUserId || !title || !content) throw new PublicApiError(422, 'MESSAGE_FIELDS_REQUIRED') + if (title.length > 100 || content.length > 4800 || actionTitle.length > 20) { + throw new PublicApiError(422, 'MESSAGE_TOO_LONG') + } + if (title.startsWith('【由壹点')) { + throw new PublicApiError(422, 'TITLE_BRAND_RESERVED') + } + + return { + project: input.project, + idempotencyKey, + recipientUserId, + title, + content, + actionTitle, + destinationActionUrl, + renderedActionUrl, + hasSelfBrandedTitle: title.startsWith(`【${input.project.name}】`), + } +} + +const createOrReuseDelivery = async (input: { + payload: NormalizedNotificationInput + isTest?: boolean + enforceIdempotency?: boolean + maxAttempts?: number +}) => { + const enforceIdempotency = input.enforceIdempotency !== false + const normalized = { + recipientUserId: input.payload.recipientUserId, + title: input.payload.title, + content: input.payload.content, + actionTitle: input.payload.actionTitle, + actionUrl: input.payload.destinationActionUrl, + } + const requestHash = sha256(JSON.stringify(normalized)) + if (enforceIdempotency) { + const existing = await prisma.notificationDelivery.findUnique({ + where: { + projectCode_idempotencyKey: { + projectCode: input.payload.project.code, + idempotencyKey: input.payload.idempotencyKey, + }, + }, + }) + if (existing) { + if (existing.requestHash !== requestHash) throw new PublicApiError(409, 'IDEMPOTENCY_CONFLICT') + return { row: existing, reused: true } + } + } + if (input.payload.hasSelfBrandedTitle) throw new PublicApiError(422, 'TITLE_BRAND_RESERVED') + const renderedTitle = renderNotificationTitle(input.payload.project.name, input.payload.title) + const renderedContent = renderNotificationContent(input.payload.project.name, input.payload.title, input.payload.content) + if (renderedTitle.length > 120 || renderedContent.length > 4800) { + throw new PublicApiError(422, 'MESSAGE_TOO_LONG') + } + const created = await prisma.notificationDelivery.create({ + data: { + id: crypto.randomUUID(), + projectCode: input.payload.project.code, + idempotencyKey: input.payload.idempotencyKey, + requestHash, + recipientUserId: input.payload.recipientUserId, + businessTitle: input.payload.title, + renderedTitle, + content: renderedContent, + actionTitle: input.payload.actionTitle, + actionUrl: input.payload.renderedActionUrl, + maxAttempts: input.maxAttempts ?? 5, + isTest: Boolean(input.isTest), + }, + }) + return { row: created, reused: false } +} + +const buildAdminNotificationInput = (project: ProjectConfig, body: Record | undefined) => { + const parsed = buildPublicNotificationInput({ + project, + body: { + ...body, + idempotencyKey: body?.idempotencyKey || `admin-test:${project.code}:${Date.now()}:${crypto.randomUUID()}`, + }, + requireIdempotency: true, + }) + return { + ...parsed, + idempotencyKey: `admin-test:${project.code}:${Date.now()}:${crypto.randomUUID()}`, + } +} + +const parseAdminProjectCreateBody = (body: Record | undefined): ProjectUpsertInput => { + const input = body || {} + return { + projectCode: clean(input.projectCode), + name: clean(input.name), + allowedHosts: parseAllowedHosts(input.allowedHosts), + isActive: parseOptionalBoolean(input.isActive, 'isActive'), + recipients: parseRecipientsInput(input.recipients), + login: parseLoginPayload(input.login), + } +} + +const parseAdminProjectUpdateBody = (body: Record | undefined): ProjectUpdateInput => { + const input = body || {} + const update: ProjectUpdateInput = {} + if (Object.prototype.hasOwnProperty.call(input, 'name')) update.name = clean(input.name) + if (Object.prototype.hasOwnProperty.call(input, 'allowedHosts')) update.allowedHosts = parseAllowedHosts(input.allowedHosts) + if (Object.prototype.hasOwnProperty.call(input, 'isActive')) update.isActive = parseOptionalBoolean(input.isActive, 'isActive') + if (Object.prototype.hasOwnProperty.call(input, 'login')) update.login = parseLoginPayload(input.login) + return update +} + +const writePublicError = (res: Response, error: PublicApiError) => + res.status(error.status).json(publicErrorPayload(error)) + +const writeAdminError = (res: Response, error: AdminApiError) => + res.status(error.status).json(adminErrorPayload(error)) + +export const createServer = (projectSource: ProjectSource, dependencies: CreateServerDependencies = {}) => { const exchangeH5Code = dependencies.exchangeH5Code || exchangeH5AuthCodeForUserId + const sendTestMessage = dependencies.sendTestMessage || sendRobotMessage const app = express() app.disable('x-powered-by') - app.use(express.json({ + + app.use('/v1', express.json({ limit: '64kb', verify: (req, _res, buffer) => { (req as SignedRequest).rawBody = Buffer.from(buffer) }, })) + app.use('/admin', express.json({ limit: '256kb' })) app.get('/health', async (_req, res) => { try { await prisma.$queryRaw`SELECT 1` - // 接入方只能看到「已投递给通知中心」,这里的失败计数是对账的唯一切口。 const groups = await prisma.notificationDelivery.groupBy({ by: ['status'], _count: { _all: true } }) const counts: Record = {} for (const group of groups) counts[group.status] = group._count._all @@ -93,8 +415,8 @@ export const createServer = (projects: Map, dependencies: } }) - app.get('/v1/auth/h5-entry', (req, res) => { - const project = projects.get(clean(req.query.projectCode)) + app.get('/v1/auth/h5-entry', async (req, res) => { + const project = await getProjectFromSource(projectSource, clean(req.query.projectCode)) if (!project?.login) return res.status(404).send('Not Found') try { const redirectPath = validateRedirectPath(req.query.redirectPath, project) @@ -125,59 +447,63 @@ export const createServer = (projects: Map, dependencies: }) app.post('/v1/auth/h5-code', async (req, res, next) => { - const project = projects.get(clean(req.body?.projectCode)) - if (!project?.login) return res.status(404).json({ error: 'PROJECT_LOGIN_NOT_FOUND' }) - const code = clean(req.body?.code) - if (code.length < 8 || code.length > 2048 || /[\u0000-\u001f\u007f]/.test(code)) { - return res.status(422).json({ error: 'H5_CODE_INVALID' }) - } - let redirectPath: string try { - redirectPath = validateRedirectPath(req.body?.redirectPath, project) - } catch { - return res.status(422).json({ error: 'REDIRECT_PATH_INVALID' }) - } + const project = await getProjectFromSource(projectSource, clean(req.body?.projectCode)) + if (!project?.login) return res.status(404).json({ error: 'PROJECT_LOGIN_NOT_FOUND' }) + const code = clean(req.body?.code) + if (code.length < 8 || code.length > 2048 || /[\u0000-\u001f\u007f]/.test(code)) { + return res.status(422).json({ error: 'H5_CODE_INVALID' }) + } + let redirectPath: string + try { + redirectPath = validateRedirectPath(req.body?.redirectPath, project) + } catch { + return res.status(422).json({ error: 'REDIRECT_PATH_INVALID' }) + } - let dingtalkUserId: string - try { - dingtalkUserId = clean(await exchangeH5Code(code)) - if (!dingtalkUserId) throw new Error('EMPTY_USER_ID') + let dingtalkUserId: string + try { + dingtalkUserId = clean(await exchangeH5Code(code)) + if (!dingtalkUserId) throw new Error('EMPTY_USER_ID') + } catch (error) { + const status = error instanceof DingTalkError && error.deterministic ? 401 : 502 + return res.status(status).json({ error: 'H5_AUTH_FAILED' }) + } + + const ticket = crypto.randomBytes(32).toString('base64url') + const ticketNonce = crypto.randomBytes(24).toString('base64url') + const now = new Date() + try { + await prisma.dingtalkLoginTicket.deleteMany({ + where: { expiresAt: { lt: new Date(now.getTime() - 24 * 60 * 60_000) } }, + }) + await prisma.dingtalkLoginTicket.create({ + data: { + id: crypto.randomUUID(), + projectCode: project.code, + audience: new URL(project.login.callbackUrl).origin, + ticketHash: sha256(ticket), + ticketNonce, + authCodeHash: sha256(code), + dingtalkUserId, + redirectPath, + expiresAt: new Date(now.getTime() + ticketTtlSeconds() * 1000), + }, + }) + } catch (error: any) { + if (clean(error?.code) === 'P2002') return res.status(401).json({ error: 'H5_AUTH_FAILED' }) + return next(error) + } + + res.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + res.status(201).json({ + ticket, + formPostUrl: project.login.callbackUrl, + expiresIn: ticketTtlSeconds(), + }) } catch (error) { - const status = error instanceof DingTalkError && error.deterministic ? 401 : 502 - return res.status(status).json({ error: 'H5_AUTH_FAILED' }) + next(error) } - - const ticket = crypto.randomBytes(32).toString('base64url') - const ticketNonce = crypto.randomBytes(24).toString('base64url') - const now = new Date() - try { - await prisma.dingtalkLoginTicket.deleteMany({ - where: { expiresAt: { lt: new Date(now.getTime() - 24 * 60 * 60_000) } }, - }) - await prisma.dingtalkLoginTicket.create({ - data: { - id: crypto.randomUUID(), - projectCode: project.code, - audience: new URL(project.login.callbackUrl).origin, - ticketHash: sha256(ticket), - ticketNonce, - authCodeHash: sha256(code), - dingtalkUserId, - redirectPath, - expiresAt: new Date(now.getTime() + ticketTtlSeconds() * 1000), - }, - }) - } catch (error: any) { - if (clean(error?.code) === 'P2002') return res.status(401).json({ error: 'H5_AUTH_FAILED' }) - return next(error) - } - - res.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') - res.status(201).json({ - ticket, - formPostUrl: project.login.callbackUrl, - expiresIn: ticketTtlSeconds(), - }) }) const authenticate = async (req: SignedRequest, res: Response, next: NextFunction) => { @@ -186,7 +512,7 @@ export const createServer = (projects: Map, dependencies: const timestamp = clean(req.header('x-yyd-timestamp')) const nonce = clean(req.header('x-yyd-nonce')) const signature = clean(req.header('x-yyd-signature')) - const project = projects.get(projectCode) + const project = await getProjectFromSource(projectSource, projectCode) if (!project || !timestamp || !nonce || !signature) return res.status(401).json({ error: 'AUTH_REQUIRED' }) const timestampMs = Number(timestamp) * 1000 if (!Number.isFinite(timestampMs) || Math.abs(Date.now() - timestampMs) > 5 * 60_000) { @@ -218,83 +544,24 @@ export const createServer = (projects: Map, dependencies: app.post('/v1/notifications', authenticate, async (req: SignedRequest, res, next) => { try { - const project = req.project! - const idempotencyKey = clean(req.body?.idempotencyKey) - const requestedRecipientUserId = clean(req.body?.recipientUserId) - const recipientAlias = clean(req.body?.recipientAlias) - const title = clean(req.body?.title) - const content = clean(req.body?.content) - const actionTitle = clean(req.body?.actionTitle) || '查看详情' - if (requestedRecipientUserId && recipientAlias) { - return res.status(422).json({ error: 'RECIPIENT_SELECTOR_CONFLICT' }) - } - if (recipientAlias.length > 50 || /[\u0000-\u001f\u007f]/.test(recipientAlias)) { - return res.status(422).json({ error: 'RECIPIENT_ALIAS_INVALID' }) - } - const recipientUserId = requestedRecipientUserId || project.recipientAliases.get(normalizeRecipientAlias(recipientAlias)) || '' - if (recipientAlias && !recipientUserId) { - return res.status(422).json({ error: 'RECIPIENT_ALIAS_NOT_FOUND' }) - } - let destinationActionUrl: string - let actionUrl: string - try { - destinationActionUrl = validateActionUrl(clean(req.body?.actionUrl), project) - actionUrl = project.login - ? buildH5EntryUrl({ - publicBaseUrl: getPublicBaseUrl(), - project, - redirectPath: getRedirectPathFromActionUrl(destinationActionUrl, project), - }) - : destinationActionUrl - } catch (error: any) { - return res.status(422).json({ error: 'ACTION_URL_INVALID', message: clean(error?.message) }) - } - if (!idempotencyKey || idempotencyKey.length > 160) return res.status(422).json({ error: 'IDEMPOTENCY_KEY_INVALID' }) - if (!recipientUserId || !title || !content) return res.status(422).json({ error: 'MESSAGE_FIELDS_REQUIRED' }) - if (title.length > 100 || content.length > 4800 || actionTitle.length > 20) return res.status(422).json({ error: 'MESSAGE_TOO_LONG' }) - if (title.startsWith('【由壹点')) return res.status(422).json({ error: 'TITLE_BRAND_RESERVED' }) - - const normalized = { recipientUserId, title, content, actionTitle, actionUrl: destinationActionUrl } - const requestHash = sha256(JSON.stringify(normalized)) - const existing = await prisma.notificationDelivery.findUnique({ - where: { projectCode_idempotencyKey: { projectCode: project.code, idempotencyKey } }, + const payload = buildPublicNotificationInput({ + project: req.project!, + body: req.body as Record | undefined, + requireIdempotency: true, }) - if (existing) { - if (existing.requestHash !== requestHash) return res.status(409).json({ error: 'IDEMPOTENCY_CONFLICT' }) - return res.status(200).json(deliveryView(existing)) - } - - if (title.startsWith(`【${project.name}】`)) return res.status(422).json({ error: 'TITLE_BRAND_RESERVED' }) - const renderedTitle = renderNotificationTitle(project.name, title) - const renderedContent = renderNotificationContent(project.name, title, content) - if (renderedTitle.length > 120 || renderedContent.length > 4800) { - return res.status(422).json({ error: 'MESSAGE_TOO_LONG' }) - } - - const created = await prisma.notificationDelivery.create({ - data: { - id: crypto.randomUUID(), - projectCode: project.code, - idempotencyKey, - requestHash, - recipientUserId, - businessTitle: title, - renderedTitle, - content: renderedContent, - actionTitle, - actionUrl, - maxAttempts: 5, - }, - }) - res.status(202).json(deliveryView(created)) + const created = await createOrReuseDelivery({ payload }) + res.status(created.reused ? 200 : 202).json(deliveryView(created.row)) } catch (error) { + if (error instanceof PublicApiError) return writePublicError(res, error) next(error) } }) app.get('/v1/notifications/:id', authenticate, async (req: SignedRequest, res, next) => { try { - const delivery = await prisma.notificationDelivery.findFirst({ where: { id: String(req.params.id), projectCode: req.project!.code } }) + const delivery = await prisma.notificationDelivery.findFirst({ + where: { id: String(req.params.id), projectCode: req.project!.code }, + }) if (!delivery) return res.status(404).json({ error: 'NOT_FOUND' }) res.json(deliveryView(delivery)) } catch (error) { @@ -302,7 +569,6 @@ export const createServer = (projects: Map, dependencies: } }) - // 按状态分页列出本项目投递记录,供接入方对账失败/未读等异常。 app.get('/v1/notifications', authenticate, async (req: SignedRequest, res, next) => { try { const status = clean(req.query.status) @@ -359,13 +625,283 @@ export const createServer = (projects: Map, dependencies: } }) - app.use((error: any, _req: Request, res: Response, _next: NextFunction) => { + if (isRegistryProjectSource(projectSource)) { + app.post('/admin/runtime/import-env', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + const result = await projectSource.importFromEnvironment(req.adminActor!) + res.status(200).json(result) + } catch (error: any) { + if (!(error instanceof AdminApiError) && /YYD_NOTIFICATION_PROJECTS_JSON|项目/.test(clean(error?.message))) { + return writeAdminError(res, new AdminApiError(422, 'ENV_IMPORT_INVALID', clean(error?.message))) + } + next(error) + } + }) + + app.post('/admin/runtime/refresh', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + const result = await projectSource.recordRuntimeRefresh(req.adminActor!) + res.json(result) + } catch (error) { + next(error) + } + }) + + app.get('/admin/projects', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + const includeRecipientsQuery = clean(req.query.includeRecipients) + const includeRecipients = includeRecipientsQuery ? includeRecipientsQuery !== '0' : true + res.json(await projectSource.listProjects(includeRecipients)) + } catch (error) { + next(error) + } + }) + + app.get('/admin/projects/:projectCode', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + const includeRecipientsQuery = clean(req.query.includeRecipients) + const includeRecipients = includeRecipientsQuery ? includeRecipientsQuery !== '0' : true + res.json(await projectSource.getProjectView(clean(req.params.projectCode), includeRecipients)) + } catch (error) { + next(error) + } + }) + + app.post('/admin/projects', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + const body = parseAdminProjectCreateBody(req.body as Record | undefined) + res.status(201).json(await projectSource.createProject(body, req.adminActor!)) + } catch (error: any) { + if (error instanceof AdminApiError) return writeAdminError(res, error) + if (error instanceof Error) return writeAdminError(res, new AdminApiError(422, 'PROJECT_INPUT_INVALID', error.message)) + next(error) + } + }) + + app.patch('/admin/projects/:projectCode', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + const projectCode = clean(req.params.projectCode) + const body = parseAdminProjectUpdateBody(req.body as Record | undefined) + res.json(await projectSource.updateProject(projectCode, body, req.adminActor!)) + } catch (error: any) { + if (error instanceof AdminApiError) return writeAdminError(res, error) + if (error instanceof Error) return writeAdminError(res, new AdminApiError(422, 'PROJECT_INPUT_INVALID', error.message)) + next(error) + } + }) + + app.delete('/admin/projects/:projectCode', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + res.json(await projectSource.deleteProject(clean(req.params.projectCode), req.adminActor!)) + } catch (error) { + next(error) + } + }) + + app.get('/admin/projects/:projectCode/recipients', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + res.json(await projectSource.listRecipients(clean(req.params.projectCode))) + } catch (error) { + next(error) + } + }) + + app.put('/admin/projects/:projectCode/recipients', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + const recipients = parseRecipientsInput((req.body as Record | undefined)?.recipients) + res.json(await projectSource.replaceRecipients(clean(req.params.projectCode), recipients, req.adminActor!)) + } catch (error: any) { + if (error instanceof AdminApiError) return writeAdminError(res, error) + if (error instanceof Error) return writeAdminError(res, new AdminApiError(422, 'RECIPIENTS_INVALID', error.message)) + next(error) + } + }) + + app.post('/admin/projects/:projectCode/rotate-secret', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + res.json(await projectSource.rotateSecret(clean(req.params.projectCode), req.adminActor!)) + } catch (error) { + next(error) + } + }) + + app.post('/admin/projects/:projectCode/test', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + const project = await projectSource.resolveProjectConfigForCreate(clean(req.params.projectCode)) + const payload = buildAdminNotificationInput(project, req.body as Record | undefined) + const created = await createOrReuseDelivery({ + payload, + isTest: true, + maxAttempts: 1, + enforceIdempotency: false, + }) + try { + const processQueryKey = await sendTestMessage({ + recipientUserId: created.row.recipientUserId, + title: created.row.renderedTitle, + content: created.row.content, + actionTitle: created.row.actionTitle, + actionUrl: created.row.actionUrl, + }) + const sent = await prisma.notificationDelivery.update({ + where: { id: created.row.id }, + data: { + status: 'sent', + attemptCount: 1, + processQueryKey, + sentAt: new Date(), + nextAttemptAt: new Date(Date.now() + 60_000), + lastErrorCode: null, + lastErrorMessage: null, + }, + }) + await prisma.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: req.adminActor!.actorType, + actorId: req.adminActor!.actorId, + action: 'project.test', + targetProjectCode: project.code, + requestId: req.adminActor!.requestId || null, + ipAddress: req.adminActor!.ipAddress || null, + summary: `发送项目 ${project.code} 测试通知`, + detailsJson: JSON.stringify({ + recipientUserIdMasked: maskUserId(created.row.recipientUserId), + title: created.row.businessTitle, + actionUrlHost: new URL(payload.destinationActionUrl).host, + sentDate: getTodayLabel(), + }), + }, + }) + return res.status(201).json({ delivery: adminDeliveryView(sent) }) + } catch (error) { + const decision = classifyDeliveryFailure({ + error, + attemptCount: 1, + maxAttempts: 1, + }) + const failed = await prisma.notificationDelivery.update({ + where: { id: created.row.id }, + data: { + status: decision.failed ? 'failed' : 'retrying', + attemptCount: 1, + lastErrorCode: decision.errorCode, + lastErrorMessage: decision.errorMessage, + }, + }) + await prisma.notificationAudit.create({ + data: { + id: crypto.randomUUID(), + actorType: req.adminActor!.actorType, + actorId: req.adminActor!.actorId, + action: 'project.test', + targetProjectCode: project.code, + requestId: req.adminActor!.requestId || null, + ipAddress: req.adminActor!.ipAddress || null, + summary: `发送项目 ${project.code} 测试通知失败`, + detailsJson: JSON.stringify({ + recipientUserIdMasked: maskUserId(created.row.recipientUserId), + title: created.row.businessTitle, + errorCode: decision.errorCode, + errorMessage: decision.errorMessage, + sentDate: getTodayLabel(), + }), + }, + }) + return res.status(201).json({ delivery: adminDeliveryView(failed) }) + } + } catch (error) { + if (error instanceof PublicApiError) { + return writeAdminError(res, new AdminApiError(error.status, error.code, error.message)) + } + next(error) + } + }) + + app.get('/admin/deliveries', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + const isTestRaw = clean(req.query.isTest).toLowerCase() + const isTest = isTestRaw ? isTestRaw === 'true' || isTestRaw === '1' : undefined + res.json(await projectSource.listDeliveries({ + projectCode: clean(req.query.projectCode) || undefined, + status: clean(req.query.status) || undefined, + isTest, + take: Number(req.query.limit), + skip: Number(req.query.offset), + })) + } catch (error) { + next(error) + } + }) + + app.get('/admin/audits', authenticateAdmin, async (req: AdminRequest, res, next) => { + try { + res.json(await projectSource.listAudits({ + projectCode: clean(req.query.projectCode) || undefined, + action: clean(req.query.action) || undefined, + take: Number(req.query.limit), + skip: Number(req.query.offset), + })) + } catch (error) { + next(error) + } + }) + } + + app.use((error: any, req: Request, res: Response, _next: NextFunction) => { + const path = clean(req.path) + if (clean(error?.type) === 'entity.too.large') { + if (path.startsWith('/admin')) { + return res.status(413).json({ error: { code: 'REQUEST_TOO_LARGE', message: '请求体超过大小限制' } }) + } + return res.status(413).json({ error: 'REQUEST_TOO_LARGE' }) + } + if (error instanceof AdminApiError) return writeAdminError(res, error) + if (error instanceof PublicApiError) return writePublicError(res, error) console.error('[NotificationCenter]', { name: clean(error?.name) || 'Error', code: clean(error?.code) || undefined, message: clean(error?.message).slice(0, 500) || '服务异常', }) + if (path.startsWith('/admin')) { + return res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: '服务异常' } }) + } res.status(500).json({ error: 'INTERNAL_ERROR', message: '服务异常' }) }) return app } + +export const resolveRuntimeProjectSource = async () => { + try { + assertAdminRuntimeConfig() + } catch (error: any) { + const projects = loadProjectConfigs() + assertRuntimeConfig(projects) + return { + projectSource: projects as ProjectSource, + mode: 'legacy' as const, + adminAvailable: false, + activeProjectCount: projects.size, + importedProjectCount: 0, + refreshedAt: null, + adminConfigError: clean(error?.message) || '管理员配置缺失', + } + } + const projectRegistry = new AdminProjectRegistry() + const imported = await projectRegistry.importFromEnvironment({ + actorType: 'system', + actorId: 'startup-import', + }) + const refreshed = await projectRegistry.refresh() + const projects = projectRegistry.getRuntimeProjectsSnapshot() + assertRuntimeConfig(projects) + return { + projectSource: projectRegistry as ProjectSource, + mode: 'registry' as const, + adminAvailable: true, + activeProjectCount: projects.size, + importedProjectCount: imported.importedProjectCodes.length, + refreshedAt: refreshed.refreshedAt, + adminConfigError: null, + } +}