feat: add shared notification admin management
This commit is contained in:
parent
36e420b0c2
commit
0d7eec13ce
@ -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"]}]
|
||||
|
||||
|
||||
51
README.md
51
README.md
@ -34,6 +34,7 @@
|
||||
|
||||
1. 复制 `.env.example` 为 `.env` 并填写真实配置;不要提交 `.env`。
|
||||
Windows 下项目目录包含空格,`DATABASE_URL` 请保留为完整绝对路径。
|
||||
`YYD_NOTIFICATION_ADMIN_API_SECRET`(兼容旧变量 `YYD_NOTIFICATION_ADMIN_TOKEN`)和 `YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY` 只保存在通知中心服务端。
|
||||
2. 执行 `npm install`。
|
||||
3. 执行 `npm run prisma:generate`。
|
||||
4. 执行 `npm run prisma:migrate`。
|
||||
@ -43,6 +44,54 @@
|
||||
|
||||
`npm test` 不读取本地 `.env`:测试入口会创建唯一临时 SQLite,先执行正式 Prisma 迁移,再串行运行测试,并在成功或失败后清理数据库、journal、WAL 和 SHM 文件。
|
||||
|
||||
## 项目配置来源与管理 API
|
||||
|
||||
当 `YYD_NOTIFICATION_ADMIN_API_SECRET`(或兼容旧变量 `YYD_NOTIFICATION_ADMIN_TOKEN`)与 `YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY` 都已配置时,通知项目配置以数据库为准;`YYD_NOTIFICATION_PROJECTS_JSON` 只承担“首次/补录导入缺失项目”的 bootstrap 角色:
|
||||
|
||||
- 服务启动时会尝试把 `YYD_NOTIFICATION_PROJECTS_JSON` 中尚未登记的项目导入数据库;
|
||||
- 已存在项目不会被覆盖,已轮换密钥、已手工维护的 `recipients` 和登录配置保持不变;
|
||||
- 运维可用 `POST /admin/runtime/import-env` 再次补录当前环境变量中的缺失项目;
|
||||
- 运维可用 `POST /admin/runtime/refresh` 让运行时缓存重新读取数据库,不必重启进程。
|
||||
- 如果管理员密钥或加密密钥缺失,服务会安全回退到 legacy 内存模式:继续直接使用 `YYD_NOTIFICATION_PROJECTS_JSON` 提供旧 `/v1/*` 能力,且不挂载 `/admin/*`。
|
||||
|
||||
管理 API 使用独立管理员 Bearer Secret,与业务项目 HMAC 密钥隔离:
|
||||
|
||||
- 主变量:`YYD_NOTIFICATION_ADMIN_API_SECRET`
|
||||
- 兼容旧变量:`YYD_NOTIFICATION_ADMIN_TOKEN`
|
||||
- 请求头:`Authorization: Bearer <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`;脚本只打印配置完成状态,不输出密钥原文。
|
||||
|
||||
## 签名算法
|
||||
|
||||
请求头:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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` 等业务接口保持原有签名协议与返回语义。
|
||||
|
||||
## 六、安全交付三项配置
|
||||
|
||||
向业务项目技术负责人交付:
|
||||
|
||||
52
prisma/migrations/20260828_admin_project_store/migration.sql
Normal file
52
prisma/migrations/20260828_admin_project_store/migration.sql
Normal 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");
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
555
src/__tests__/admin.test.ts
Normal 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
43
src/admin-auth.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
991
src/admin-project-registry.ts
Normal file
991
src/admin-project-registry.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
38
src/index.ts
38
src/index.ts
@ -1,26 +1,42 @@
|
||||
import 'dotenv/config'
|
||||
import { assertRuntimeConfig, loadProjectConfigs } from './config'
|
||||
import type { ProjectSource } from './server'
|
||||
import prisma, { ensureDatabaseReady } from './prisma'
|
||||
import { createServer } from './server'
|
||||
import { createServer, resolveRuntimeProjectSource } from './server'
|
||||
import { startWorker } from './worker'
|
||||
|
||||
const projects = loadProjectConfigs()
|
||||
assertRuntimeConfig(projects)
|
||||
|
||||
const port = Math.max(1, Number(process.env.PORT) || 3088)
|
||||
|
||||
export const initializeRuntime = async (): Promise<{
|
||||
projectSource: ProjectSource
|
||||
mode: 'legacy' | 'registry'
|
||||
adminAvailable: boolean
|
||||
activeProjectCount: number
|
||||
importedProjectCount: number
|
||||
refreshedAt: Date | null
|
||||
adminConfigError: string | null
|
||||
}> => {
|
||||
return resolveRuntimeProjectSource()
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
await ensureDatabaseReady()
|
||||
createServer(projects).listen(port, () => {
|
||||
console.log(`[NotificationCenter] listening on ${port}, projects=${projects.size}`)
|
||||
const runtime = await initializeRuntime()
|
||||
createServer(runtime.projectSource).listen(port, () => {
|
||||
console.log(
|
||||
runtime.mode === 'registry'
|
||||
? `[NotificationCenter] listening on ${port}, mode=registry, activeProjects=${runtime.activeProjectCount}, imported=${runtime.importedProjectCount}, refreshedAt=${runtime.refreshedAt?.toISOString() || 'n/a'}`
|
||||
: `[NotificationCenter] listening on ${port}, mode=legacy, activeProjects=${runtime.activeProjectCount}, adminAvailable=false, reason=${runtime.adminConfigError || 'n/a'}`,
|
||||
)
|
||||
})
|
||||
startWorker()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[NotificationCenter] startup failed:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('[NotificationCenter] startup failed:', error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await prisma.$disconnect().catch(() => undefined)
|
||||
|
||||
794
src/server.ts
794
src/server.ts
@ -1,14 +1,41 @@
|
||||
import crypto from 'crypto'
|
||||
import express, { NextFunction, Request, Response } from 'express'
|
||||
import { buildH5EntryUrl, createH5EntryPage, getRedirectPathFromActionUrl, LoginInputError, validateRedirectPath } from './auth'
|
||||
import { getDingTalkConfig, getPublicBaseUrl, normalizeRecipientAlias, type ProjectConfig } from './config'
|
||||
import { DingTalkError, exchangeH5AuthCodeForUserId } from './dingtalk'
|
||||
import { authenticateAdmin, type AdminRequest } from './admin-auth'
|
||||
import { AdminApiError, AdminProjectRegistry, assertAdminRuntimeConfig, maskUserId, type ProjectRecipientInput, type ProjectUpdateInput, type ProjectUpsertInput } from './admin-project-registry'
|
||||
import { assertRuntimeConfig, getDingTalkConfig, getPublicBaseUrl, loadProjectConfigs, normalizeRecipientAlias, type ProjectConfig, type ProjectLoginConfig } from './config'
|
||||
import { DingTalkError, exchangeH5AuthCodeForUserId, sendRobotMessage } from './dingtalk'
|
||||
import prisma from './prisma'
|
||||
import { createCanonicalSignature, safeEqual, sha256 } from './signature'
|
||||
import { classifyDeliveryFailure } from './worker'
|
||||
|
||||
type SignedRequest = Request & { rawBody?: Buffer; project?: ProjectConfig }
|
||||
export type ProjectSource = Map<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,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user