feat: add shared notification admin management

This commit is contained in:
WuYongJie 2026-08-29 10:21:24 +08:00
parent 36e420b0c2
commit 0d7eec13ce
13 changed files with 2556 additions and 142 deletions

View File

@ -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"]}]

View File

@ -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 <YYD_NOTIFICATION_ADMIN_API_SECRET>`
- 可选 `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`;脚本只打印配置完成状态,不输出密钥原文。
## 签名算法
请求头:

View File

@ -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

View File

@ -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 <YYD_NOTIFICATION_ADMIN_API_SECRET>`
- 可选:`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` 等业务接口保持原有签名协议与返回语义。
## 六、安全交付三项配置
向业务项目技术负责人交付:

View File

@ -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");

View File

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

View File

@ -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')

View File

@ -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

555
src/__tests__/admin.test.ts Normal file
View File

@ -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<string, string> = {}) => ({
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<void>((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<void>((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<void>((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<void>((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<Record<string, string>> = []
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<void>((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<void>((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()
}
})

43
src/admin-auth.ts Normal file
View File

@ -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)
}
}

View File

@ -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<ReturnType<typeof prisma.notificationProject.findFirstOrThrow>>
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<string, unknown> = {}
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
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<string, ProjectCacheEntry>()
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<string, string>, inputRecipients: ProjectRecipientInput[]) {
const displayByNormalized = new Map<string, string>()
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<string, string>()
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<string, ProjectCacheEntry>()
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<string, ProjectConfig>()
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<string, { lastStatus: string | null; lastSentAt: Date | null; recentFailedCount: number }>()
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<ReturnType<typeof prisma.notificationProject.findUniqueOrThrow>>,
options: { includeRecipients?: boolean; deliverySummaryMap?: Map<string, { lastStatus: string | null; lastSentAt: Date | null; recentFailedCount: number }> } = {},
) {
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 }
}
}

View File

@ -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)

View File

@ -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<string, ProjectConfig> | 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<string>
sendTestMessage?: typeof sendRobotMessage
}
export const createServer = (projects: Map<string, ProjectConfig>, 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<string, unknown>
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<string, unknown>
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, number> = {}
for (const group of groups) counts[group.status] = group._count._all
@ -93,8 +415,8 @@ export const createServer = (projects: Map<string, ProjectConfig>, 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<string, ProjectConfig>, 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<string, ProjectConfig>, 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<string, ProjectConfig>, 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<string, unknown> | 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<string, ProjectConfig>, 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<string, ProjectConfig>, 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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,
}
}