创建由壹点统一通知中心

This commit is contained in:
WuYongJie 2026-07-15 17:44:35 +08:00
commit cfeaafb8f3
24 changed files with 2805 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
.env
data
*.db
*.log
.git

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
PORT=3088
# Windows 本机路径包含空格时,Prisma SQLite 请使用完整绝对路径;Docker 会覆盖为 /app/data。
DATABASE_URL=file:D:/AI Code/YouYiDianNotificationCenter/data/notifications.db
# “由壹点”机器人所属企业内部应用的凭证,仅允许配置在通知中心。
DINGTALK_APP_KEY=
DINGTALK_APP_SECRET=
DINGTALK_ROBOT_CODE=
# 每个项目必须使用不同密钥;allowedHosts 用于限制卡片跳转域名。
# 生产环境请使用足够长的随机密钥,不要把真实值提交到 Git。
YYD_NOTIFICATION_PROJECTS_JSON=[{"code":"material_library","name":"素材库","secret":"replace-with-random-secret","allowedHosts":["sc.example.com"]},{"code":"fixed_asset","name":"固定资产","secret":"replace-with-another-random-secret","allowedHosts":["asset.example.com"]}]
WORKER_ENABLED=true
WORKER_INTERVAL_MS=1000
READ_STATUS_INTERVAL_MS=5000

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.sh text eol=lf

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.env
data/
*.log
*.db
*.db-journal
*.db-wal

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN apk add --no-cache openssl
COPY package*.json ./
RUN npm ci
COPY prisma ./prisma
RUN npx prisma generate
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache openssl
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=build /app/dist ./dist
COPY prisma ./prisma
RUN mkdir -p /app/data
EXPOSE 3088
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]

53
README.md Normal file
View File

@ -0,0 +1,53 @@
# 由壹点通知中心
由壹点内部项目统一钉钉机器人单聊出口。只有本服务保存钉钉应用密钥和 `RobotCode`;素材库、固定资产、产品开发平台等业务系统使用各自的项目密钥调用本服务。
## 安全边界
- 每个项目使用独立 HMAC 密钥。
- 请求签名包含时间、nonce、HTTP 方法、路径和正文摘要。
- nonce 五分钟内只能使用一次。
- 每个项目只能跳转到配置的 HTTPS 域名。
- `projectCode + idempotencyKey` 唯一,防止业务重试造成重复通知。
- 项目无法读取其它项目的投递记录。
## 本地启动
1. 复制 `.env.example` 为 `.env` 并填写真实配置;不要提交 `.env`。
Windows 下项目目录包含空格,`DATABASE_URL` 请保留为完整绝对路径。
2. 执行 `npm install`。
3. 执行 `npm run prisma:generate`。
4. 执行 `npm run prisma:migrate`。
5. 执行 `npm run dev`。
健康检查:`GET /health`。
生产环境建议只把容器端口绑定到 `127.0.0.1`,再由 Nginx 提供独立 HTTPS 域名。素材库和固定资产可能位于不同主机,因此不能把业务项目配置成通知中心的容器内网地址。
当前生产部署脚本位于:
- `scripts/deploy-material-host.sh`:只管理通知中心独立容器、配置和独立 FRP 服务;
- `scripts/deploy-public-nginx.sh`:备份并扩展素材库专用 FRP 端口白名单,然后为素材库公网域名增加 `/notification-center/` 反向代理;脚本会验证主站、S3 和通知中心,失败时回滚。
脚本不包含任何密钥。统一机器人凭证从素材库生产环境安全复制到通知中心独立 `.env`,项目 HMAC 密钥在服务器生成并以 `0600` 权限保存。
素材库生产 NAS 无法稳定访问 Docker Hub。正式发布应先在构建机或维护工作站构建 `youyidian-notification-center:latest`,将镜像安全传入生产机,再执行部署脚本;脚本检测到预载镜像后会使用 `--no-build` 启动。
## 签名算法
请求头:
- `x-yyd-project-code`
- `x-yyd-timestamp`:Unix 秒
- `x-yyd-nonce`:每次请求生成新的随机字符串
- `x-yyd-signature`:HMAC-SHA256 十六进制字符串
待签名文本:
```text
timestamp\nnonce\nHTTP_METHOD\nPATH\nSHA256(rawBody)
```
发送接口:`POST /v1/notifications`。查询接口:`GET /v1/notifications/:id`。
通知中心只接受任务并异步发送,业务系统通过查询接口获取 `pending / sending / sent / unread / read / retrying / failed` 状态。

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
services:
notification-center:
image: youyidian-notification-center:${YYD_NOTIFICATION_IMAGE_TAG:-latest}
build: .
container_name: youyidian-notification-center
restart: unless-stopped
ports:
- "127.0.0.1:${PORT:-3088}:3088"
environment:
PORT: 3088
DATABASE_URL: file:/app/data/notifications.db
DINGTALK_APP_KEY: ${DINGTALK_APP_KEY}
DINGTALK_APP_SECRET: ${DINGTALK_APP_SECRET}
DINGTALK_ROBOT_CODE: ${DINGTALK_ROBOT_CODE}
YYD_NOTIFICATION_PROJECTS_JSON: ${YYD_NOTIFICATION_PROJECTS_JSON}
WORKER_ENABLED: ${WORKER_ENABLED:-true}
volumes:
- notification_data:/app/data
volumes:
notification_data:

1587
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "youyidian-notification-center",
"version": "1.0.0",
"private": true,
"description": "由壹点内部项目统一钉钉单聊通知中心",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "prisma generate && tsc",
"start": "node dist/index.js",
"test": "tsx --test src/__tests__/*.test.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"prisma": "^5.22.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@ -0,0 +1,41 @@
-- CreateTable
CREATE TABLE "NotificationDelivery" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectCode" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"requestHash" TEXT NOT NULL,
"recipientUserId" TEXT NOT NULL,
"businessTitle" TEXT NOT NULL,
"renderedTitle" TEXT NOT NULL,
"content" TEXT NOT NULL,
"actionTitle" TEXT NOT NULL,
"actionUrl" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"attemptCount" INTEGER NOT NULL DEFAULT 0,
"maxAttempts" INTEGER NOT NULL DEFAULT 4,
"nextAttemptAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"leaseOwner" TEXT,
"leaseExpiresAt" DATETIME,
"processQueryKey" TEXT,
"lastErrorCode" TEXT,
"lastErrorMessage" TEXT,
"sentAt" DATETIME,
"readAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE TABLE "RequestNonce" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectCode" TEXT NOT NULL,
"nonce" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX "NotificationDelivery_projectCode_idempotencyKey_key" ON "NotificationDelivery"("projectCode", "idempotencyKey");
CREATE INDEX "NotificationDelivery_status_nextAttemptAt_idx" ON "NotificationDelivery"("status", "nextAttemptAt");
CREATE INDEX "NotificationDelivery_leaseExpiresAt_idx" ON "NotificationDelivery"("leaseExpiresAt");
CREATE INDEX "NotificationDelivery_processQueryKey_idx" ON "NotificationDelivery"("processQueryKey");
CREATE UNIQUE INDEX "RequestNonce_projectCode_nonce_key" ON "RequestNonce"("projectCode", "nonce");
CREATE INDEX "RequestNonce_expiresAt_idx" ON "RequestNonce"("expiresAt");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

50
prisma/schema.prisma Normal file
View File

@ -0,0 +1,50 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model NotificationDelivery {
id String @id @default(uuid())
projectCode String
idempotencyKey String
requestHash String
recipientUserId String
businessTitle String
renderedTitle String
content String
actionTitle String
actionUrl String
status String @default("pending")
attemptCount Int @default(0)
maxAttempts Int @default(4)
nextAttemptAt DateTime @default(now())
leaseOwner String?
leaseExpiresAt DateTime?
processQueryKey String?
lastErrorCode String?
lastErrorMessage String?
sentAt DateTime?
readAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([projectCode, idempotencyKey])
@@index([status, nextAttemptAt])
@@index([leaseExpiresAt])
@@index([processQueryKey])
}
model RequestNonce {
id String @id @default(uuid())
projectCode String
nonce String
expiresAt DateTime
createdAt DateTime @default(now())
@@unique([projectCode, nonce])
@@index([expiresAt])
}

View File

@ -0,0 +1,235 @@
#!/usr/bin/env bash
set -euo pipefail
target=/mnt/PoolOne/apps/youyidian-notification-center
archive=/tmp/youyidian-notification-center-release.tar.gz
source_env=/mnt/PoolOne/apps/youyidian-3d/.env
backup_root=/mnt/PoolOne/apps/youyidian-notification-center-backups
test -f "$archive"
test -f "$source_env"
if tar -tzf "$archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then
echo '安装包包含不安全路径' >&2
exit 1
fi
mkdir -p "$target" "$backup_root"
if test -f "$target/package.json"; then
stamp="$(date +%Y%m%d%H%M%S)"
tar -czf "$backup_root/source-$stamp.tar.gz" --exclude=.env --exclude=data -C "$target" .
fi
tar -xzf "$archive" -C "$target"
python3 - <<'PY'
from pathlib import Path
import json
import os
import secrets
import shutil
import time
def parse_env(path: Path):
result = {}
if not path.exists():
return result
for raw in path.read_text(encoding='utf-8').splitlines():
line = raw.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
if value.startswith('"') and value.endswith('"'):
try:
value = json.loads(value)
except Exception:
value = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
value = value[1:-1]
result[key] = value
return result
def quoted(value):
return json.dumps(str(value), ensure_ascii=False)
source_path = Path('/mnt/PoolOne/apps/youyidian-3d/.env')
target_path = Path('/mnt/PoolOne/apps/youyidian-notification-center/.env')
source = parse_env(source_path)
existing = parse_env(target_path)
app_key = (
source.get('DINGTALK_WORK_NOTICE_CLIENT_ID')
or source.get('DINGTALK_WORK_NOTICE_APP_KEY')
or source.get('DINGTALK_CLIENT_ID')
or source.get('DINGTALK_APP_KEY')
)
app_secret = (
source.get('DINGTALK_WORK_NOTICE_CLIENT_SECRET')
or source.get('DINGTALK_WORK_NOTICE_APP_SECRET')
or source.get('DINGTALK_CLIENT_SECRET')
or source.get('DINGTALK_APP_SECRET')
)
robot_code = source.get('DINGTALK_ROBOT_CODE')
if not app_key or not app_secret or not robot_code:
raise SystemExit('素材库生产环境缺少统一机器人配置')
try:
old_projects = json.loads(existing.get('YYD_NOTIFICATION_PROJECTS_JSON', '[]'))
except Exception:
old_projects = []
by_code = {
str(item.get('code')): item
for item in old_projects
if isinstance(item, dict)
}
def secret_for(code):
old = str(by_code.get(code, {}).get('secret') or '')
return old if len(old) >= 24 else secrets.token_hex(32)
projects = [
{
'code': 'material_library',
'name': '素材库',
'secret': secret_for('material_library'),
'allowedHosts': ['sc.jiansun.vip'],
},
{
'code': 'fixed_asset',
'name': '固定资产',
'secret': secret_for('fixed_asset'),
'allowedHosts': ['www.jsgdzc.cn', 'jsgdzc.cn'],
},
]
center_lines = [
'PORT=3088',
'DINGTALK_APP_KEY=' + quoted(app_key),
'DINGTALK_APP_SECRET=' + quoted(app_secret),
'DINGTALK_ROBOT_CODE=' + quoted(robot_code),
'YYD_NOTIFICATION_PROJECTS_JSON=' + quoted(
json.dumps(projects, ensure_ascii=False, separators=(',', ':'))
),
'WORKER_ENABLED=true',
'WORKER_INTERVAL_MS=1000',
'READ_STATUS_INTERVAL_MS=5000',
]
target_path.write_text('\n'.join(center_lines) + '\n', encoding='utf-8')
os.chmod(target_path, 0o600)
material_secret = next(
item['secret'] for item in projects if item['code'] == 'material_library'
)
updates = {
'YYD_NOTIFICATION_CENTER_URL': 'https://sc.jiansun.vip/notification-center',
'YYD_NOTIFICATION_CENTER_PROJECT_CODE': 'material_library',
'YYD_NOTIFICATION_CENTER_PROJECT_SECRET': material_secret,
'YYD_NOTIFICATION_CENTER_TEST_USER_IDS': '',
}
backup = source_path.with_name(
'.env.before-yyd-notification-' + time.strftime('%Y%m%d%H%M%S')
)
shutil.copy2(source_path, backup)
os.chmod(backup, 0o600)
lines = source_path.read_text(encoding='utf-8').splitlines()
seen = set()
output = []
for line in lines:
if '=' in line and not line.lstrip().startswith('#'):
key = line.split('=', 1)[0].strip()
if key in updates:
output.append(key + '=' + quoted(updates[key]))
seen.add(key)
continue
output.append(line)
for key, value in updates.items():
if key not in seen:
output.append(key + '=' + quoted(value))
source_path.write_text('\n'.join(output) + '\n', encoding='utf-8')
os.chmod(source_path, 0o600)
print('通知中心与素材库适配配置已写入')
PY
cd "$target"
image="youyidian-notification-center:${YYD_NOTIFICATION_IMAGE_TAG:-latest}"
if docker image inspect "$image" >/dev/null 2>&1; then
docker compose up -d --no-build
else
docker compose up -d --build
fi
healthy=0
for _ in $(seq 1 30); do
if curl -fsS --max-time 3 http://127.0.0.1:3088/health >/tmp/yyd-notification-health.json; then
healthy=1
break
fi
sleep 2
done
if test "$healthy" != 1; then
docker compose logs --tail 120
exit 1
fi
python3 - <<'PY'
from pathlib import Path
source = Path('/etc/frp/frpc-sc-entry.toml').read_text(encoding='utf-8')
header = source.split('[[proxies]]', 1)[0]
header = header.replace(
'/root/frp/frpc-sc-entry.log',
'/root/frp/frpc-notify-entry.log',
)
proxy = '''[[proxies]]
name = "youyidian-notification-center"
type = "tcp"
localIP = "127.0.0.1"
localPort = 3088
remotePort = 2326
healthCheck.type = "http"
healthCheck.path = "/health"
healthCheck.intervalSeconds = 15
healthCheck.maxFailed = 3
healthCheck.timeoutSeconds = 5
'''
Path('/etc/frp/frpc-notify-entry.toml').write_text(
header.rstrip() + '\n\n' + proxy,
encoding='utf-8',
)
PY
chmod 600 /etc/frp/frpc-notify-entry.toml
cat > /etc/systemd/system/frpc-notify-entry.service <<'EOF'
[Unit]
Description=FRP Client for Youyidian notification center
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
Restart=always
RestartSec=10s
LimitNOFILE=65535
ExecStart=/root/frp-upgrade/frp_0.67.0_linux_amd64/frpc -c /etc/frp/frpc-notify-entry.toml
StandardOutput=append:/root/frp/frpc-notify-entry.service.log
StandardError=append:/root/frp/frpc-notify-entry.service.log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now frpc-notify-entry.service
sleep 3
systemctl is-active --quiet frpc-notify-entry.service
rm -f "$archive"
curl -fsS http://127.0.0.1:3088/health
echo
printf 'CENTER_CONTAINER='
docker inspect -f '{{.State.Status}}' youyidian-notification-center
printf 'FRP_SERVICE='
systemctl is-active frpc-notify-entry.service

View File

@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
config=/etc/nginx/conf.d/sc.jiansun.vip.conf
frps_config=/etc/frp/frps-sucai.toml
frps_service=frps-sucai.service
test -f "$config"
test -f "$frps_config"
backup="${config}.before-notification-center-$(date +%Y%m%d%H%M%S)"
frps_backup="${frps_config}.before-notification-center-$(date +%Y%m%d%H%M%S)"
cp -a "$config" "$backup"
cp -a "$frps_config" "$frps_backup"
FRPS_CONFIG="$frps_config" python3 - <<'PY'
from pathlib import Path
import os
import re
path = Path(os.environ['FRPS_CONFIG'])
text = path.read_text(encoding='utf-8')
match = re.search(
r'allowPorts\s*=\s*\[\s*\{\s*start\s*=\s*2321\s*,\s*end\s*=\s*(\d+)\s*\}\s*,?\s*\]',
text,
re.S,
)
if not match:
raise SystemExit('未识别素材库 FRP 端口白名单,拒绝修改')
if int(match.group(1)) < 2326:
text = text[:match.start(1)] + '2326' + text[match.end(1):]
path.write_text(text, encoding='utf-8')
PY
if ! /opt/frp-furniture-server/frps verify -c "$frps_config"; then
cp -a "$frps_backup" "$frps_config"
echo 'FRP 服务端配置验证失败,已回滚' >&2
exit 1
fi
if ! systemctl restart "$frps_service"; then
cp -a "$frps_backup" "$frps_config"
systemctl restart "$frps_service"
echo 'FRP 服务端重启失败,已回滚' >&2
exit 1
fi
frp_ready=0
for _ in $(seq 1 20); do
if ss -lnt | grep -q ':2321 ' \
&& ss -lnt | grep -q ':2322 ' \
&& ss -lnt | grep -q ':2323 ' \
&& ss -lnt | grep -q ':2326 '; then
frp_ready=1
break
fi
sleep 2
done
if test "$frp_ready" != 1; then
cp -a "$frps_backup" "$frps_config"
systemctl restart "$frps_service"
echo 'FRP 隧道未全部恢复,已回滚' >&2
exit 1
fi
CONFIG="$config" python3 - <<'PY'
from pathlib import Path
import os
path = Path(os.environ['CONFIG'])
text = path.read_text(encoding='utf-8')
if 'location ^~ /notification-center/' in text:
raise SystemExit(0)
needle = ' location / {\n'
if needle not in text:
raise SystemExit('未找到主站 location,拒绝修改')
block = ''' location ^~ /notification-center/ {
proxy_pass http://127.0.0.1:2326/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
client_max_body_size 128k;
}
'''
path.write_text(text.replace(needle, block + needle, 1), encoding='utf-8')
PY
if ! nginx -t; then
cp -a "$backup" "$config"
nginx -t
echo 'Nginx 配置验证失败,已回滚' >&2
exit 1
fi
systemctl reload nginx
curl -fsS --max-time 10 --resolve sc.jiansun.vip:443:127.0.0.1 \
https://sc.jiansun.vip/api/health >/tmp/yyd-material-public-health.json
curl -fsS --max-time 10 --resolve s3.jiansun.vip:443:127.0.0.1 \
https://s3.jiansun.vip/minio/health/live >/tmp/yyd-s3-public-health.txt
healthy=0
for _ in $(seq 1 20); do
if curl -fsS --max-time 5 --resolve sc.jiansun.vip:443:127.0.0.1 \
https://sc.jiansun.vip/notification-center/health \
>/tmp/yyd-notification-public-health.json; then
healthy=1
break
fi
sleep 2
done
if test "$healthy" != 1; then
cp -a "$backup" "$config"
nginx -t
systemctl reload nginx
echo '通知中心公网健康检查失败,已回滚 Nginx 配置' >&2
exit 1
fi
cat /tmp/yyd-notification-public-health.json
echo
echo 'PUBLIC_NOTIFICATION_CENTER_OK'

View File

@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import test from 'node:test'
import { AddressInfo } from 'node:net'
import { loadProjectConfigs } from '../config'
import prisma from '../prisma'
import { createServer } from '../server'
import { createCanonicalSignature } from '../signature'
const projectSecret = '123456789012345678901234'
const projects = loadProjectConfigs(JSON.stringify([
{ code: 'fixed_asset', name: '固定资产', secret: projectSecret, allowedHosts: ['asset.example.com'] },
]))
const headers = (method: string, path: string, body: string, nonce = crypto.randomBytes(18).toString('base64url')) => {
const timestamp = String(Math.floor(Date.now() / 1000))
return {
'Content-Type': 'application/json',
'x-yyd-project-code': 'fixed_asset',
'x-yyd-timestamp': timestamp,
'x-yyd-nonce': nonce,
'x-yyd-signature': createCanonicalSignature({ secret: projectSecret, timestamp, nonce, method, path, body }),
}
}
test('通知中心验证签名、项目域名和幂等键', async () => {
await prisma.requestNonce.deleteMany()
await prisma.notificationDelivery.deleteMany()
const server = createServer(projects).listen(0)
await new Promise<void>((resolve) => server.once('listening', resolve))
const port = (server.address() as AddressInfo).port
const path = '/v1/notifications'
const body = JSON.stringify({
idempotencyKey: 'disposal_applied:53:1',
recipientUserId: 'ding-user-1',
title: '待审批报废',
content: '有一项报废申请等待审批',
actionTitle: '查看详情',
actionUrl: 'https://asset.example.com/workflows/disposals/53',
})
try {
const first = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body })
assert.equal(first.status, 202)
const firstPayload = await first.json() as any
assert.equal(firstPayload.title, '【由壹点·固定资产】待审批报废')
const second = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body })
assert.equal(second.status, 200)
const secondPayload = await second.json() as any
assert.equal(secondPayload.id, firstPayload.id)
const badBody = JSON.stringify({ ...JSON.parse(body), actionUrl: 'https://evil.example.com/path' })
const rejected = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, badBody), body: badBody })
assert.equal(rejected.status, 422)
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
await prisma.$disconnect()
}
})

View File

@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { loadProjectConfigs } from '../config'
import { createCanonicalSignature, safeEqual } from '../signature'
test('每个项目必须使用独立且足够长的密钥', () => {
const projects = loadProjectConfigs(JSON.stringify([
{ code: 'fixed_asset', name: '固定资产', secret: '123456789012345678901234', allowedHosts: ['asset.example.com'] },
]))
assert.equal(projects.get('fixed_asset')?.name, '固定资产')
assert.throws(() => loadProjectConfigs(JSON.stringify([
{ code: 'bad', name: '错误项目', secret: 'short', allowedHosts: ['example.com'] },
])))
})
test('签名包含项目密钥、时间、nonce、方法、路径和正文', () => {
const input = {
secret: '123456789012345678901234',
timestamp: '1784102400',
nonce: 'nonce_1234567890abcdef',
method: 'POST',
path: '/v1/notifications',
body: JSON.stringify({ title: '测试' }),
}
const signature = createCanonicalSignature(input)
assert.equal(signature.length, 64)
assert.equal(safeEqual(signature, createCanonicalSignature(input)), true)
assert.equal(safeEqual(signature, createCanonicalSignature({ ...input, path: '/v1/other' })), false)
})

52
src/config.ts Normal file
View File

@ -0,0 +1,52 @@
export type ProjectConfig = {
code: string
name: string
secret: string
allowedHosts: string[]
}
const clean = (value: unknown) => String(value || '').trim()
export const loadProjectConfigs = (raw = process.env.YYD_NOTIFICATION_PROJECTS_JSON || '') => {
if (!raw.trim()) return new Map<string, ProjectConfig>()
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('YYD_NOTIFICATION_PROJECTS_JSON 不是有效 JSON')
}
if (!Array.isArray(parsed)) {
throw new Error('YYD_NOTIFICATION_PROJECTS_JSON 必须是数组')
}
const projects = new Map<string, ProjectConfig>()
for (const row of parsed) {
const item = row as Record<string, unknown>
const code = clean(item.code)
const name = clean(item.name)
const secret = clean(item.secret)
const allowedHosts = Array.isArray(item.allowedHosts)
? item.allowedHosts.map(clean).filter(Boolean).map((host) => host.toLowerCase())
: []
if (!/^[a-z0-9_-]{2,40}$/.test(code)) throw new Error(`项目 code 不合法: ${code || '(空)'}`)
if (!name) throw new Error(`项目 ${code} 缺少 name`)
if (secret.length < 24) throw new Error(`项目 ${code} 的 secret 至少需要 24 个字符`)
if (allowedHosts.length === 0) throw new Error(`项目 ${code} 至少需要一个 allowedHosts`)
if (projects.has(code)) throw new Error(`项目 code 重复: ${code}`)
projects.set(code, { code, name, secret, allowedHosts })
}
return projects
}
export const getDingTalkConfig = () => ({
appKey: clean(process.env.DINGTALK_APP_KEY),
appSecret: clean(process.env.DINGTALK_APP_SECRET),
robotCode: clean(process.env.DINGTALK_ROBOT_CODE),
})
export const assertRuntimeConfig = (projects: Map<string, ProjectConfig>) => {
if (projects.size === 0) throw new Error('未配置任何通知项目')
const dingTalk = getDingTalkConfig()
const missing = Object.entries(dingTalk).filter(([, value]) => !value).map(([key]) => key)
if (missing.length > 0) throw new Error(`钉钉配置缺失: ${missing.join(', ')}`)
}

126
src/dingtalk.ts Normal file
View File

@ -0,0 +1,126 @@
import { getDingTalkConfig } from './config'
type DingTalkPayload = Record<string, any>
export class DingTalkError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly deterministic = false,
) {
super(message)
this.name = 'DingTalkError'
}
}
let tokenCache: { token: string; expiresAt: number } | null = null
let tokenPromise: Promise<string> | null = null
const requestJson = async (url: string, init?: RequestInit): Promise<DingTalkPayload> => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
})
const data = await response.json().catch(() => ({})) as DingTalkPayload
if (!response.ok) {
const code = clean(data.code) || `HTTP_${response.status}`
const message = clean(data.message) || `钉钉接口返回 HTTP ${response.status}`
throw new DingTalkError(message, code, response.status >= 400 && response.status < 500 && response.status !== 429)
}
if (data.errcode !== undefined && Number(data.errcode) !== 0) {
throw new DingTalkError(clean(data.errmsg) || '钉钉接口调用失败', String(data.errcode), isDeterministic(data.errcode, data.errmsg))
}
return data
} catch (error: any) {
if (error instanceof DingTalkError) throw error
if (error?.name === 'AbortError') throw new DingTalkError('钉钉请求超时', 'TIMEOUT')
throw new DingTalkError(clean(error?.message) || '钉钉网络请求失败', 'NETWORK')
} finally {
clearTimeout(timeout)
}
}
const clean = (value: unknown) => String(value || '').trim()
const stringList = (value: unknown) => Array.isArray(value) ? value.map(clean).filter(Boolean) : []
const isDeterministic = (code: unknown, message: unknown) =>
['40014', '40056', '40057', '40078', '41001', '41009', '60111', '60121', '70020'].includes(String(code)) ||
/不存在|不在可见范围|无权限|invalid user|forbidden user|userid/i.test(clean(message))
const getToken = async () => {
if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) return tokenCache.token
if (tokenPromise) return tokenPromise
tokenPromise = (async () => {
const { appKey, appSecret } = getDingTalkConfig()
const data = await requestJson(
`https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}`,
)
const token = clean(data.access_token)
if (!token) throw new DingTalkError('钉钉未返回 access_token', 'TOKEN_MISSING')
tokenCache = { token, expiresAt: Date.now() + Math.max(300, Number(data.expires_in) || 7200) * 1000 }
return token
})()
try {
return await tokenPromise
} finally {
tokenPromise = null
}
}
export const sendRobotMessage = async (input: {
recipientUserId: string
title: string
content: string
actionTitle: string
actionUrl: string
}) => {
const { robotCode } = getDingTalkConfig()
const token = await getToken()
const data = await requestJson('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', {
method: 'POST',
headers: { 'x-acs-dingtalk-access-token': token },
body: JSON.stringify({
robotCode,
userIds: [input.recipientUserId],
msgKey: input.actionUrl ? 'sampleActionCard' : 'sampleMarkdown',
msgParam: JSON.stringify(input.actionUrl ? {
title: input.title.slice(0, 120),
text: input.content.slice(0, 4800),
singleTitle: input.actionTitle.slice(0, 20),
singleURL: input.actionUrl,
} : {
title: input.title.slice(0, 120),
text: input.content.slice(0, 4800),
}),
}),
})
const invalid = stringList(data.invalidStaffIdList)
const filtered = stringList(data.filteredStaffIdList)
const flowControlled = stringList(data.flowControlledStaffIdList)
if (invalid.includes(input.recipientUserId)) throw new DingTalkError('收件人钉钉 userId 无效', 'INVALID_USER', true)
if (filtered.includes(input.recipientUserId)) throw new DingTalkError('收件人不在机器人可见范围内', 'FILTERED_USER', true)
if (flowControlled.includes(input.recipientUserId)) throw new DingTalkError('钉钉机器人发送被限流', 'FLOW_CONTROLLED')
const processQueryKey = clean(data.processQueryKey)
if (!processQueryKey) throw new DingTalkError('钉钉未返回 processQueryKey', 'PROCESS_QUERY_KEY_MISSING')
return processQueryKey
}
export const queryRobotReadStatus = async (processQueryKey: string) => {
const { robotCode } = getDingTalkConfig()
const token = await getToken()
const url = new URL('https://api.dingtalk.com/v1.0/robot/oToMessages/readStatus')
url.searchParams.set('robotCode', robotCode)
url.searchParams.set('processQueryKey', processQueryKey)
const data = await requestJson(url.toString(), {
headers: { 'x-acs-dingtalk-access-token': token },
})
const rows = Array.isArray(data.messageReadInfoList) ? data.messageReadInfoList : []
const read = rows.some((row) => clean(row?.readStatus).toLowerCase() === 'read')
const unread = rows.some((row) => clean(row?.readStatus).toLowerCase() === 'unread')
const failed = clean(data.sendStatus).toLowerCase().includes('fail')
return { read, unread, failed }
}

13
src/index.ts Normal file
View File

@ -0,0 +1,13 @@
import 'dotenv/config'
import { assertRuntimeConfig, loadProjectConfigs } from './config'
import { createServer } from './server'
import { startWorker } from './worker'
const projects = loadProjectConfigs()
assertRuntimeConfig(projects)
const port = Math.max(1, Number(process.env.PORT) || 3088)
createServer(projects).listen(port, () => {
console.log(`[NotificationCenter] listening on ${port}, projects=${projects.size}`)
})
startWorker()

5
src/prisma.ts Normal file
View File

@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma

157
src/server.ts Normal file
View File

@ -0,0 +1,157 @@
import crypto from 'crypto'
import express, { NextFunction, Request, Response } from 'express'
import type { ProjectConfig } from './config'
import prisma from './prisma'
import { createCanonicalSignature, safeEqual, sha256 } from './signature'
type SignedRequest = Request & { rawBody?: Buffer; project?: ProjectConfig }
const clean = (value: unknown) => String(value || '').trim()
const deliveryView = (row: any) => ({
id: row.id,
projectCode: row.projectCode,
status: row.status,
recipientUserId: row.recipientUserId,
title: row.renderedTitle,
processQueryKey: row.processQueryKey,
lastErrorCode: row.lastErrorCode,
lastErrorMessage: row.lastErrorMessage,
sentAt: row.sentAt,
readAt: row.readAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
})
const validateActionUrl = (value: string, project: ProjectConfig) => {
let url: URL
try {
url = new URL(value)
} catch {
throw new Error('actionUrl 必须是完整 HTTPS 地址')
}
if (url.protocol !== 'https:') throw new Error('actionUrl 只允许 HTTPS')
if (!project.allowedHosts.includes(url.hostname.toLowerCase())) {
throw new Error(`actionUrl 域名不在项目白名单内: ${url.hostname}`)
}
return url.toString()
}
export const createServer = (projects: Map<string, ProjectConfig>) => {
const app = express()
app.disable('x-powered-by')
app.use(express.json({
limit: '64kb',
verify: (req, _res, buffer) => { (req as SignedRequest).rawBody = Buffer.from(buffer) },
}))
app.get('/health', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
res.json({ ok: true, service: 'youyidian-notification-center', database: 'ready' })
} catch {
res.status(503).json({ ok: false, service: 'youyidian-notification-center', database: 'unavailable' })
}
})
const authenticate = async (req: SignedRequest, res: Response, next: NextFunction) => {
try {
const projectCode = clean(req.header('x-yyd-project-code'))
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)
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) {
return res.status(401).json({ error: 'TIMESTAMP_EXPIRED' })
}
if (!/^[a-zA-Z0-9_-]{16,100}$/.test(nonce)) return res.status(401).json({ error: 'NONCE_INVALID' })
const expected = createCanonicalSignature({
secret: project.secret,
timestamp,
nonce,
method: req.method,
path: req.path,
body: req.rawBody || Buffer.alloc(0),
})
if (!safeEqual(expected, signature)) return res.status(401).json({ error: 'SIGNATURE_INVALID' })
await prisma.requestNonce.deleteMany({ where: { expiresAt: { lt: new Date() } } })
try {
await prisma.requestNonce.create({ data: { projectCode, nonce, expiresAt: new Date(timestampMs + 5 * 60_000) } })
} catch {
return res.status(409).json({ error: 'NONCE_REPLAYED' })
}
req.project = project
next()
} catch (error) {
next(error)
}
}
app.post('/v1/notifications', authenticate, async (req: SignedRequest, res, next) => {
try {
const project = req.project!
const idempotencyKey = clean(req.body?.idempotencyKey)
const recipientUserId = clean(req.body?.recipientUserId)
const title = clean(req.body?.title)
const content = clean(req.body?.content)
const actionTitle = clean(req.body?.actionTitle) || '查看详情'
let actionUrl: string
try {
actionUrl = validateActionUrl(clean(req.body?.actionUrl), project)
} 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 renderedTitle = `【由壹点·${project.name}】${title}`
const normalized = { recipientUserId, title, content, actionTitle, actionUrl }
const requestHash = sha256(JSON.stringify(normalized))
const existing = await prisma.notificationDelivery.findUnique({
where: { projectCode_idempotencyKey: { projectCode: project.code, idempotencyKey } },
})
if (existing) {
if (existing.requestHash !== requestHash) return res.status(409).json({ error: 'IDEMPOTENCY_CONFLICT' })
return res.status(200).json(deliveryView(existing))
}
const created = await prisma.notificationDelivery.create({
data: {
id: crypto.randomUUID(),
projectCode: project.code,
idempotencyKey,
requestHash,
recipientUserId,
businessTitle: title,
renderedTitle,
content,
actionTitle,
actionUrl,
},
})
res.status(202).json(deliveryView(created))
} catch (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 } })
if (!delivery) return res.status(404).json({ error: 'NOT_FOUND' })
res.json(deliveryView(delivery))
} catch (error) {
next(error)
}
})
app.use((error: any, _req: Request, res: Response, _next: NextFunction) => {
console.error('[NotificationCenter]', error)
res.status(500).json({ error: 'INTERNAL_ERROR', message: clean(error?.message) || '服务异常' })
})
return app
}

27
src/signature.ts Normal file
View File

@ -0,0 +1,27 @@
import crypto from 'crypto'
export const sha256 = (value: string | Buffer) => crypto.createHash('sha256').update(value).digest('hex')
export const createCanonicalSignature = (input: {
secret: string
timestamp: string
nonce: string
method: string
path: string
body: string | Buffer
}) => {
const canonical = [
input.timestamp,
input.nonce,
input.method.toUpperCase(),
input.path,
sha256(input.body),
].join('\n')
return crypto.createHmac('sha256', input.secret).update(canonical).digest('hex')
}
export const safeEqual = (left: string, right: string) => {
const a = Buffer.from(left)
const b = Buffer.from(right)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}

123
src/worker.ts Normal file
View File

@ -0,0 +1,123 @@
import os from 'os'
import prisma from './prisma'
import { DingTalkError, queryRobotReadStatus, sendRobotMessage } from './dingtalk'
const workerId = `${os.hostname()}:${process.pid}:${Math.random().toString(36).slice(2, 10)}`
const backoffMinutes = [1, 5, 15, 30]
let busy = false
let readBusy = false
const claimNext = async () => {
const now = new Date()
const claimable = {
OR: [
{ status: { in: ['pending', 'retrying'] }, nextAttemptAt: { lte: now } },
{ status: 'sending', leaseExpiresAt: { lte: now } },
],
}
const candidate = await prisma.notificationDelivery.findFirst({ where: claimable, orderBy: [{ nextAttemptAt: 'asc' }, { createdAt: 'asc' }] })
if (!candidate) return null
const claimed = await prisma.notificationDelivery.updateMany({
where: { id: candidate.id, ...claimable },
data: {
status: 'sending',
leaseOwner: workerId,
leaseExpiresAt: new Date(Date.now() + 120_000),
attemptCount: { increment: 1 },
},
})
if (claimed.count !== 1) return null
return prisma.notificationDelivery.findUnique({ where: { id: candidate.id } })
}
const processOne = async () => {
const delivery = await claimNext()
if (!delivery) return
try {
const processQueryKey = await sendRobotMessage({
recipientUserId: delivery.recipientUserId,
title: delivery.renderedTitle,
content: delivery.content,
actionTitle: delivery.actionTitle,
actionUrl: delivery.actionUrl,
})
await prisma.notificationDelivery.updateMany({
where: { id: delivery.id, status: 'sending', leaseOwner: workerId },
data: {
status: 'sent',
processQueryKey,
sentAt: new Date(),
nextAttemptAt: new Date(Date.now() + 60_000),
leaseOwner: null,
leaseExpiresAt: null,
lastErrorCode: null,
lastErrorMessage: null,
},
})
} catch (error: any) {
const deterministic = error instanceof DingTalkError && error.deterministic
const exhausted = delivery.attemptCount >= delivery.maxAttempts
const failed = deterministic || exhausted
const delay = backoffMinutes[Math.min(Math.max(0, delivery.attemptCount - 1), backoffMinutes.length - 1)]
await prisma.notificationDelivery.updateMany({
where: { id: delivery.id, status: 'sending', leaseOwner: workerId },
data: {
status: failed ? 'failed' : 'retrying',
nextAttemptAt: failed ? delivery.nextAttemptAt : new Date(Date.now() + delay * 60_000),
leaseOwner: null,
leaseExpiresAt: null,
lastErrorCode: error instanceof DingTalkError ? error.code : 'UNKNOWN',
lastErrorMessage: String(error?.message || '通知发送失败').slice(0, 2000),
},
})
}
}
const refreshOne = async () => {
const delivery = await prisma.notificationDelivery.findFirst({
where: {
status: { in: ['sent', 'unread'] },
processQueryKey: { not: null },
nextAttemptAt: { lte: new Date() },
},
orderBy: { updatedAt: 'asc' },
})
if (!delivery?.processQueryKey) return
try {
const result = await queryRobotReadStatus(delivery.processQueryKey)
if (result.read) {
await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: 'read', readAt: new Date() } })
} else if (result.failed) {
await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: 'failed', lastErrorCode: 'SEND_RESULT_FAILED', lastErrorMessage: '钉钉返回发送失败' } })
} else {
await prisma.notificationDelivery.update({ where: { id: delivery.id }, data: { status: result.unread ? 'unread' : delivery.status, nextAttemptAt: new Date(Date.now() + 5 * 60_000) } })
}
} catch (error: any) {
await prisma.notificationDelivery.update({
where: { id: delivery.id },
data: { lastErrorCode: error instanceof DingTalkError ? error.code : 'READ_STATUS_FAILED', lastErrorMessage: String(error?.message || '查询已读状态失败').slice(0, 2000), nextAttemptAt: new Date(Date.now() + 5 * 60_000) },
})
}
}
export const startWorker = () => {
if (process.env.WORKER_ENABLED === 'false') return
const interval = Math.max(500, Number(process.env.WORKER_INTERVAL_MS) || 1000)
const readInterval = Math.max(2000, Number(process.env.READ_STATUS_INTERVAL_MS) || 5000)
const tick = async () => {
if (busy) return
busy = true
try {
await processOne()
} finally {
busy = false
}
}
setInterval(() => void tick(), interval).unref()
setInterval(() => {
if (readBusy) return
readBusy = true
void refreshOne().finally(() => { readBusy = false })
}, readInterval).unref()
setTimeout(() => void tick(), 500).unref()
}

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}