一同看(YiTongKan)项目文档 版本:2026-04-24 · Cloudflare 迁移完成后的当前生产形态
React + Vite Tailwind + MUI/Radix Cloudflare Workers D1 × 21 KV R2 Queues Durable Objects Analytics Engine
配套文档:环境.md(密钥/账号/踩坑) ·migration-tasks.md(阶段进度) ·docs/d1-shards.md(分片细节) ·docs/analytics-grafana.md
1. 系统概览
一同看是一个视频/图文内容聚合站,原架构为单机 PHP + MySQL(149.28.233.43),为承载 ~100 万 DAU 的目标,整体迁移到 Cloudflare 无服务器栈。
关键设计决策
| 决策 | 动机 |
|---|---|
| D1 替代 MySQL 作为内容库 | 读走 CF 边缘零延迟,避开单机瓶颈 |
评论按 content_id % 4、历史按 uid % 16 分片 | 绕过 D1 单库 10 GB 硬限 |
视频/收藏写入走 UPSERT on md5 / 复合主键 | 解决上游 MySQL 重复 md5 导致的静默丢失 |
缓存层采用 cache:list_epoch 版本号前缀 | 写入后 O(1) 全局失效,替代逐 key 删 |
| 热写入(浏览/点赞/历史)走 Queue → DO → KV | 把 D1 的 "一行一次 write" 成本摊薄为每 30s 一次 |
| 归档 >90d 的 watch_history 到 R2,SHA-256 入 KV | D1 空间不被长尾拖死,可验证归档完整性 |
| db-proxy 作为 Workers↔MySQL 唯一通道(11 端点白名单) | 避免 Hyperdrive 费用 + IP 白名单管理 |
| 人机校验保留前端滑块 | 迁移阶段 C 决策,减少跨系统耦合 |
2. 运行时拓扑
2.1 域名 / 服务映射
| 域名 | 归属 | 作用 |
|---|---|---|
ytk.yitongcs.com | Cloudflare Pages / Worker | 前端 SPA |
ytk-api.yitongcs.com | Workers (ytk-api) | 主 API + 管理接口 |
ytk-db.yitongcs.com | 149.28.233.43:8080 Python db-proxy | Workers 访问 MySQL 的唯一代理层 |
php.yitongcs.com | 旧 PHP (149) | 白名单兜底(proxyToPhp 已收口) |
user.gayboys.vip | 站外用户中心 | USERSITE_DOMAIN,少量老接口 |
2.2 生产机
| IP | 端口 | 角色 | 数据库 |
|---|---|---|---|
149.28.233.43 | 22 | 主 MySQL + db-proxy + cron 同步宿主 | ytk, videocenter, tp_legacy_video_meta |
108.61.23.146 | 22324 | 视频数据库(yzm) | yzm.yzm_video |
两台机每 10 分钟跑 ytk_sync.sh(149 用 videocenter,108 用 yzm),把增量行 POST 到 /admin/sync,再做第二遍 backfill(填时长/大小/下载地址)。
3. 前端(React + Vite)
3.1 技术栈
- React 18 + Vite + TypeScript(仓库
gayapp/YitongkanV2,由 Figma Make 初始化) - Tailwind CSS v4(
@tailwindcss/vite插件) - UI 组件:MUI + Radix UI(Dialog / Dropdown 等 headless 原语)+ lucide-react 图标 + motion/react 动画
- i18n:8 语言(zh-CN / zh-TW / en / vi / es / pt / ja / de),全部在
src/app/i18n/translations.ts单文件 TypeScript 对象里,非 JSON - 构建:
npm run build(Vite 出到dist/),CF Pages 自动部署到ytk.yitongcs.com
3.2 目录结构(frontend/src/)
main.tsx Vite 入口
app/
App.tsx 路由分发 + 顶层状态
components/ 各业务组件(*.tsx)
context/ React Context providers(Router / Language / User)
hooks/ 自定义 hooks
i18n/
translations.ts 8 语言 UI 文案
aboutContent.ts 关于/隐私/条款长文
errorMap.ts 后端 code → 本地化 message
imports/ Figma Make 导入资源
lib/
api.ts Workers API 客户端 + 数据类型
fingerprint.ts Fingerprint.com 集成
styles/ 全局样式
3.3 与后端的契约
- 所有 API 以
/api/开头,发到ytk-api.yitongcs.com - 登录态:
Authorization: Bearer base64(${userId}:${token}),token 存localStorage的ytk_uid/ytk_token;服务端在 KVuser:{token}持久化身份 JSON - 旧接口(
/api/Gv/*、/api/Video/*、部分/api/content/*/play与/download)由 Workers 透传到PHP_ORIGIN - 人机校验用前端滑块(
/api/auth/slider-challenge),不再走 PHP 验证码
4. Workers API 层
4.1 代码结构(workers/src/)
index.ts 主路由分发(1500+ 行,按前缀分支)
types.ts Env / 数据类型
do/ Durable Object(CounterAggregator)
middleware/
cors.ts CORS 头
fetch.ts 6s 超时包装
metrics.ts AE 上报(每请求)
proxy.ts proxyToPhp 白名单透传
routes/
base.ts /api/base/bootstrap
content.ts /api/content/* (热门列表 + 缓存 epoch)
services/
auth / avatar / comments / content / dbproxy / domainshare
email / favorites / feedback / filters / fingerprint
history.ts ★ 观看历史(分片 + 时长自愈)
history_archive.ts 90 天归档到 R2 + 校验
media / notifications / order / pay / profile
search_log / shards.ts ★ 分片路由工具
slider / usersite / views / votes / alerts
utils/
cache.ts 边缘 caches.default 封装
response.ts JSON / CORS 响应包装
4.2 路由清单(~60 个端点)
公共 / 基础
GET /api/base/bootstrap— 启动配置(60s edge cache)GET /api/auth/slider-challenge·POST /api/auth/slider-verifyGET /api/auth/test-domain·POST /api/auth/report-test-domain
登录 / 注册
POST /api/auth/register|login|logoutPOST /api/auth/referral— 邀请码
会员
GET /api/member/me— 资料 + VIP 状态GET /api/member/vip|flow·GET /api/member/invite/stats|listGET/PATCH /api/member/profile·PATCH /api/member/passwordPOST /api/member/avatar(R2)·POST /api/member/bind-ucPOST /api/member/email/send-code|verifyPOST /api/member/order/create
收藏(D1 / DB_FAV)
GET /api/member/favorites·GET /api/member/favorites/check·POST /api/member/favorites
观看历史(D1 分片 × 16)
GET/POST/DELETE /api/member/historyGET /api/member/history/archive— 查询归档(>90d)
站内信 / 反馈 / 评论
GET /api/member/notifications·GET /api/member/notifications/unread-count·POST /api/member/notifications/read-allPOST /api/member/feedback·GET /api/member/feedback/listGET/POST /api/comments(分片 bycontent_id%4)
搜索 / 内容 / 媒体
GET /api/search/trending|recent·POST /api/search/logGET /api/content/gv?page=&page_size=&type=&sort=latest|favorites|random&seed=(60s edge + 600s KV,epoch 失效)GET /api/content/gv/:id·GET /api/content/gv/:id/play·GET /api/content/gv/:id/downloadGET /api/content/mv|tv|pic·GET /api/content/{sec}/:id·/playGET /api/content/search?wd=§ion=&page=GET /api/Gv/list?page=(老版 App 兼容;/api/Gv/view·/api/Video/getVideoUrl透传 PHP)/api/Gv/*·/api/Video/*— 透传到 PHP/api/media/*— m3u8 签名回源
管理 / 同步(双重门禁:X-Sync-Key header + CF-Connecting-IP IP 白名单)
默认白名单 = SYNC_ALLOWED_IPS(149.28.233.43 + 108.61.23.146 + 173.208.185.170 + 63.141.249.154,后两个是外部收藏同步源);验证逻辑在 middleware/admin_auth.ts。
GET /admin/sync/status— 当前 KV 游标sync:max_tp_idPOST /admin/sync·POST /admin/sync-meta— 批量 UPSERT / 回填 meta(10min cron 调用)GET /admin/videos/missing-duration|missing-meta— 列出缺字段的 GV 行POST /admin/videos/backfill-duration— m3u8 EXTINF 解析补时长(日 cron 也走这条)POST /admin/favorites/import— 收藏批量 / 增量导入;body{mode, uid?, rows[]},mode ∈ {append, upsert, replace},单次上限 1000 行;append走 INSERT OR IGNORE(幂等),upsert冲突更新 metadata,replace先按 uid 清空再插入POST /admin/init-favcount/init-search-log/init-comments— 一次性迁移脚本,幂等回放POST /api/admin/history/archive·POST /api/admin/archive/verify·POST /api/admin/archive/snapshot-checksums— 归档 / 校验 / 月度 checksum 转存POST /api/admin/notifications/create— 管理员发站内信 / 广播GET /api/admin/metrics/alerts— 手动触发 alert sweep(?webhook_test=1测试联通)
4.3 中间件管线
5. 数据库说明
5.1 D1 实例清单(24 个)
| 绑定 | 名称 | 用途 | 主键策略 |
|---|---|---|---|
DB | ytk | 主库:videos / legacy_* / site_config / hotlinks / feedback | 按表自然键 |
DB_FAV | ytk-favorites | 收藏 | (uid, section, content_id) |
DB_SEARCHLOG | ytk-search-log | 搜索热词(独立 DB) | query |
DB_NOTIF | ytk-notifications | 站内信 + 已读态(广播 + 私信同库) | id / (userid, notif_id) |
DB_COMMENTS_0..3 | ytk-comments-0..3 | 评论分片 | id,路由按 content_id % 4 |
DB_HISTORY_0..15 | ytk-history-0..15 | 观看历史分片 | (uid, section, content_id, episode_id),路由按 uid % 16 |
5.2 主库 ytk 表结构
videos — GV 视频
CREATE TABLE videos (
id INTEGER PRIMARY KEY,
type INTEGER DEFAULT 0,
index_pic_path TEXT,
orgfile TEXT,
md5 TEXT UNIQUE, -- UPSERT 冲突目标
ts_480_path, ts_720_path, ts_1080_path TEXT,
end_time TEXT,
scheck INTEGER DEFAULT 0,
-- 运行时 ALTER 追加列
size_bytes INTEGER DEFAULT 0,
download_path TEXT DEFAULT '',
duration_seconds INTEGER DEFAULT 0,
favorites_count INTEGER DEFAULT 0
);
INDEX: type, end_time, favorites_count DESC, orgfile
其他主库表
- legacy_mv / legacy_tv / legacy_tv_episodes / legacy_images — 老版影/剧/剧集/图集,
source_id对应原 MySQL id - feedback — 意见反馈,单表含状态机(open / replying / closed)
- site_config / hotlinks — 站点全局配置 + 防盗链 IP 白名单
schema.sql 历史版本里还建过 comments / search_log / notifications / notification_reads 四张表,但它们现在都不在主库了:comments 迁到 DB_COMMENTS_0..3 四分片(§5.3);search_log 迁到 DB_SEARCHLOG 单库;notifications + notification_reads 迁到 DB_NOTIF 单库。主库残留的空表不再被代码访问,保留是为了回滚安全。
5.3 分片数据库 schema
ytk-favorites
CREATE TABLE favorites (
uid, section, content_id,
title, cover, year, duration_seconds, added_at,
PRIMARY KEY (uid, section, content_id)
);
INDEX idx_fav_uid_added (uid, added_at DESC)
INDEX idx_fav_content (section, content_id)
ytk-comments-{0..3}
每分片独立 comments 表,id INTEGER PRIMARY KEY AUTOINCREMENT;跨分片 id 不保证全局唯一,API 总是带 content_id co-route 到同一分片,无歧义。
ytk-search-log
CREATE TABLE search_log (
query TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 1,
last_used INTEGER NOT NULL
);
INDEX idx_search_log_count (count DESC)
INDEX idx_search_log_last (last_used DESC)
ytk-notifications
整体是"单库"不是分片,但文件名延续了 shard- 前缀,保留不改。
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userid INTEGER NOT NULL, -- 0 广播 / >0 私信
type, title, content, meta, created_at, expires_at
);
INDEX idx_notif_user_time (userid, created_at DESC)
INDEX idx_notif_expires_at
CREATE TABLE notification_reads (
userid, notif_id, read_at,
PRIMARY KEY (userid, notif_id)
);
INDEX idx_notif_reads_user (userid)
ytk-history-{0..15}
CREATE TABLE watch_history (
uid, section, content_id, episode_id,
episode_label, title, cover,
progress, duration_seconds, watched_seconds, watched_at,
PRIMARY KEY (uid, section, content_id, episode_id)
);
INDEX idx_history_uid_watched (uid, watched_at DESC)
INDEX idx_history_watched_at -- 归档 cron 用
5.4 分片路由(services/shards.ts)
historyShard(env, uid) → env[`DB_HISTORY_${uid % 16}`]
commentsShard(env, contentId) → env[`DB_COMMENTS_${contentId % 4}`]
allHistoryShards(env) → D1Database[] // 16 个
allCommentsShards(env) → D1Database[] // 4 个
shardIndex(key, n) → ((k % n) + n) % n // 纯函数,负数/NaN 兜底 0
services/shards.test.ts。
5.5 关键索引与查询模式
| 查询 | 执行路径 | 复杂度 |
|---|---|---|
| 我的历史(默认首屏) | idx_history_uid_watched 单分片 | O(log N + k) |
| 收藏列表 | idx_fav_uid_added | O(log N + k) |
| 视频列表(按热度) | 主库 idx_videos_favcount DESC + edge 60s + KV 600s | O(k) with cache hit |
| 评论列表 | idx_comments_content 单分片 | O(log N + k) |
| 搜索热词榜 | idx_search_log_count DESC | O(k) |
| 归档 cron | idx_history_watched_at 全分片扫描 | O(N) per day |
5.6 一致性与去重策略
| 表 | 冲突处理 |
|---|---|
videos | ON CONFLICT(md5) 按列分两种模式:文本列(type/index_pic_path/orgfile/ts_*/end_time/scheck/download_path)用 COALESCE(NULLIF(excluded.X, ''), videos.X)(非空覆盖空);数值列(size_bytes / duration_seconds)用 CASE WHEN excluded.X > 0 THEN excluded.X ELSE videos.X END(正值覆盖零)。整体"上游有值就用上游,没值就保留现有"——幂等、cron 可重放 |
favorites | 复合主键 INSERT OR IGNORE;取消走 DELETE |
watch_history | ON CONFLICT(uid,section,content_id,episode_id) DO UPDATE SET watched_at, progress, watched_seconds |
comments | 分片内 id INTEGER PRIMARY KEY AUTOINCREMENT;跨分片 id 不保证唯一,API 总是带 content_id co-route 到同一分片,无歧义 |
notifications | 单库 DB_NOTIF;广播 + 私信共用 AUTOINCREMENT,无冲突 |
notification_reads | 复合主键 (userid, notif_id) INSERT OR IGNORE |
5.7 MySQL 残留
A. Workers 通过 db-proxy(ytk-db.yitongcs.com)直读/直写(workers/scripts/db_proxy.py 实际暴露的表)
| 表 | 用途 | 相关入口 |
|---|---|---|
tp_user | 用户账号(含 vip_time 列 → VIP 到期时间戳) | /api/auth/login|register · /api/member/vip · /api/member/me · /api/member/password |
tp_user_info | 用户扩展资料(email 等) | /api/member/profile · /api/member/email/verify |
tp_share | 邀请/推广关系 | /api/member/invite/list|stats · /api/member/referrals · 注册时写入 |
tp_flows | 流量账单(表名复数) | /api/member/flow · 注册赠送初始流量 |
tp_device_code_log | 设备绑定/流量日志 | 下载扣流量时写入 |
tp_blacklist | 用户/IP 黑名单 | 登录 / 注册时校验 |
B. 外部 cron(149 / 108 上的 ytk_sync.sh)直连 MySQL,增量同步到 D1
| 表 | 用途 | 去向 |
|---|---|---|
ytk.tp_video | 主视频表(数据源) | → D1 videos(SELECT ... WHERE id > cursor) |
ytk.tp_legacy_video_meta | 视频大小 / 下载地址 | → D1 videos.size_bytes / download_path(/admin/sync-meta 回填) |
videocenter.yzm_video(149)/ yzm.yzm_video(108) | 视频时长 duratio | → D1 videos.duration_seconds;NULL 长尾由 duration_backfill.ts 从 m3u8 EXTINF 补 |
ytk.tp_legacy_mv / tp_legacy_tv / tp_legacy_tv_episode / tp_legacy_img / tp_config | 老版影/剧/剧集/图集/配置(已一次性全量迁过) | → D1 legacy_*(导入后只读) |
tp_order / tp_pay_log 等)目前仍走老 PHP,由 Workers proxyToPhp 透传到 php.yitongcs.com,不经 db-proxy,Workers 不直接访问这些表。
6. 对象存储 / KV / 队列 / DO / AE
6.1 R2 桶
| 绑定 | 桶名 | 用途 |
|---|---|---|
USER_ASSETS | ytk-user-assets | 用户头像 / 意见反馈附件 |
ARCHIVE_BUCKET | ytk-archive | 观看历史 >90d 冷归档(JSON 行,SHA-256 存 KV) |
6.2 KV Namespace 键空间
按功能域分组,以代码中实际前缀为准:
| 前缀 | 说明 | TTL |
|---|---|---|
| 缓存 | ||
cache:list_epoch | 全局列表版本号;写入时 +1 → 作用于所有 gvlist-v4-* | 永久 |
gvlist-v4-{epoch}-{type}-{sort}-{page}-{size} | GV 列表 JSON | 600s |
mvlist-d1v4-{epoch}-... / tvlist-d1v4-{epoch}-... | MV / TV 列表 JSON | 600s |
search-{section}-{kw}-{page}-{size} | 搜索结果缓存 | 10800s |
| 计数(DO flush 每 30s 写回) | ||
views:{section}:{id} | 浏览计数 | 永久 |
likes:{section}:{id} / dislikes:{section}:{id} | 点赞 / 踩 计数 | 永久 |
seen:{section}:{id}:{ip} | 浏览去重标记,IP 1h 内不再累加 | 3600s |
| 用户态 | ||
user:{token} | 登录会话(身份 + VIP + UC 凭证) | 604800s |
commentlike:{uid}:{commentId} | 评论点赞的用户维度状态 | 永久 |
uservote:{uid}:{section}:{id} | 点赞/踩的用户维度状态(-1 / 1 / null) | 永久 |
fp:{fingerprint} | 设备指纹去重(反注册多开) | 7d |
ipreg:{ip} | IP 维度注册节流 | 1d |
| 同步 / 归档 | ||
sync:max_tp_id | 增量同步游标(单调递增,与 md5 UPSERT 解耦) | 永久 |
arc:ck:{r2Key} | 归档文件 SHA-256 + 元数据(每 R2 对象一条) | 120d |
| 功能开关 / 限流 | ||
feature:workers_login / workers_register / workers_register_reward | 灰度开关(1=启用) | 永久 |
fb:daily:{uid}:{yyyymmdd} | 意见反馈的日度次数限流 | 1d |
down:{uid}:{md5} | 下载流量已扣除标记,6h 内同一用户下载同片免扣 | 21600s |
favlist:{uid} 是收藏迁到 D1 DB_FAV 之前的旧键格式,代码里只剩一处迁移期兜底读取,正常链路不再写入。
6.3 Cloudflare Queues(3 条)
| Queue | 消费者 | 用途 | 参数 |
|---|---|---|---|
q-history | 同 Worker | 历史心跳写 D1 分片 | batch=100 / 5s / concurrency=4 / retries=3 |
q-views | 同 Worker | 浏览计数累加到 DO | 同上 |
q-likes | 同 Worker | 点赞/踩累加到 DO | 同上 |
6.4 Durable Object
CounterAggregator(src/do/counter.ts)— 单例 DO,接收 Queue 消费端聚合的计数增量,alarm() 每 30s 把汇总数写回 KV。设计上把 "N 次 D1/KV 写" 摊成 "1 次 KV 写"。
6.5 Analytics Engine
- Dataset:
ytk_request_events(bindingAE) - 每请求
writeRequestMetric一条:indexes:[path](归一化后)blobs:[method, status, country, outcome, rayId]doubles:[durationMs, status, responseBytes]
- 另有
writeCustomEvent给 queue / cron / admin 非 HTTP 事件使用,outcome = queue|cron|admin区分 - 查询方:Grafana(Infinity plugin)通过 CF AE SQL API 直读
7. 定时任务与自愈机制
7.1 Cloudflare Cron(wrangler.toml triggers)
| Cron | 任务 | 入口 |
|---|---|---|
0 3 * * * | 每日 03:00 UTC:①归档 >90d 的 watch_history 到 R2;②紧接着 duration_backfill.ts(上限 100 行/次)从 m3u8 #EXTINF 补齐上游 NULL 的时长 | scheduled() → history_archive.ts + duration_backfill.ts |
*/5 * * * * | 每 5 分钟 alert sweep(Worker 5xx / D1 size / queue backlog / db-proxy 5xx) | scheduled() → alerts.ts |
0 4 1 * * | 每月 1 号 04:00 UTC:把 KV 里全部 arc:ck:* 归档校验码转存 R2(checksums/snapshots/YYYY-MM-DD.ndjson + checksums/latest.ndjson),防止 KV 120d TTL 到期后无法校验 R2 归档完整性 | scheduled() → history_archive.ts#snapshotChecksumsToR2 |
7.2 外部 Cron(*/10 * * * * 在 149 和 108)
脚本:workers/scripts/ytk_sync.sh
GET /admin/sync/status→ 取 KV 游标sync:max_tp_idSELECT ... FROM tp_video LEFT JOIN tp_legacy_video_meta WHERE id > cursor LIMIT 500- 拼 md5 列表,再
SELECT md5, duratio FROM (videocenter|yzm).yzm_video查时长 POST /admin/sync分批 500 行- Backfill 第二遍:
GET /admin/videos/missing-meta?limit=500→ 查缺失 → 从上游补 →POST /admin/sync-meta
7.3 视频时长自愈(多层兜底)
历史现状:曾有 192 条旧视频上游 yzm_video.duratio 为 NULL,cron 的 duratio > 0 过滤永远填不回。已全部修复,当前 D1 videos 缺时长数 = 0 / 89,786。
机制(从最即时到最兜底):
- 写时回填(
services/history.ts#logWatch)— 客户端没传durationSeconds,从videos查一次再写分片 - 读时水合(
services/history.ts#listHistory)— GV 历史行缺时长,批量查videos并回写分片 - 反向回填(心跳 UPDATE)— 客户端心跳带真实时长时(HTML5 解析的 m3u8),若
videos.duration_seconds为 0 则补齐 - 管理员 / 定时 m3u8 解析(
services/duration_backfill.ts)— 直接抓 450-bit m3u8 求和#EXTINF(HTML5 播放器同款算法)。端点POST /admin/videos/backfill-duration手动触发;日 cron 03:00 自动 100 行/次兜底
8. 可观测性与告警
8.1 Grafana
- 数据源:Cloudflare Analytics Engine SQL(Infinity 插件)+ Prometheus(db-proxy
/metrics) - 主要图表:QPS / p50-p99 latency / 5xx 率 / D1 DB size / Queue 积压 / db-proxy 5xx
8.2 告警规则(每 5 分钟)
| 规则 | 阈值 | 动作 |
|---|---|---|
| Worker 5xx 率 | > 1% over 5min | POST ALERT_WEBHOOK_URL(若已配置)+ AE 事件 cron-alerts |
| D1 任一库 size | > 8 GB(留 2 GB 安全区) | webhook |
| Queue 积压 | backlog > 10 k | webhook |
| db-proxy 5xx 率 | > 5% over 5min | webhook |
8.3 Logpush
Workers 请求日志推到 R2(见 docs/logpush-setup.md),30 天保留;历史排查跨 AE 查询窗口时使用。
9. 内容审核与安全
用户提交的任何自由文本(评论 / 昵称 / 个人简介,未来加头像)都经过两道门:本地消毒 + 远程 AI 审核。SQL 注入在 D1 prepared-statement 层天然免疫,XSS 在 React {text} 渲染层天然转义——这两道门是纵深防御。
9.1 消毒 sanitizeUserText
| 规则 | 动机 |
|---|---|
剥离任何 <...> 形状的标签 | <script>、<iframe>、<img onerror=> 等 |
去 javascript: / vbscript: / data:<mime>;base64, | 防 href="javascript:…" 遗留 |
去 HTML 实体形态 </script> | 防双重转义绕过 |
ASCII 控制字符(保留 \n/\t) | 防日志/分析污染 |
| CRLF → LF,连续空行折叠到最多 2 | 显示一致 |
9.2 AI 审核(aicenter-api.1.gay · 异步模式)
ctx.waitUntil 发起上游审核;结果异步到达时再更新 DB + 发站内通知。避免 ~500ms 等待、解耦上游抖动与用户链路。
POST https://aicenter-api.1.gay/v1/moderate
headers:
x-app-id: ${MODERATION_APP_ID}
x-timestamp: <unix seconds>
x-nonce: <32 hex chars>
x-signature: HMAC-SHA256( MODERATION_SECRET, `${ts}\n${nonce}\n${sha256(body)}` )
content-type: application/json
body: { biz_type, biz_id, content }
biz_type 接入矩阵(异步)
| biz_type | 触发点 | 立即返回 | 通过后 | 拒绝后 |
|---|---|---|---|---|
comment | POST /api/comments | 200 + status='pending' + pending=true,作者自己能在列表看到,其他人看不到 | UPDATE comments SET status='visible',全员可见 | DELETE 评论行 + 站内通知(title "评论未通过审核") |
nickname | PATCH /api/member/profile | 200 + profile.pending.nickname,作者自己 /me 能看到,他人看老值 | dbUserUpdate({nickname}) 提交 MySQL + 清 KV pending | 丢弃 pending + 站内通知(title "昵称未通过审核") |
bio (intro) | PATCH /api/member/profile | 200 + profile.pending.intro | dbUserUpdate({intro}) + 清 KV | 丢弃 + 站内通知("个人简介未通过审核") |
avatar | POST /api/member/avatar | — | 未接入(规划中,需异步 webhook 回调端点) | |
pending 存储
- 评论:D1 shard
comments.status TEXT DEFAULT 'visible',新插入行显式='pending' - 资料:KV
profile_pending:{uid}={nickname?, intro?, submittedAt},TTL 24h(兜底过期防 worker 崩时残留)
可见性过滤
listComments:WHERE (status='visible' OR user_id=<currentUserId>),作者自看 pending,别人不见listComments禁止回复 pending 评论(parent 必须已 visible,防 reply-to-own-pending 绕审)getProfileWithPending只在本人调用时把 pending KV 值覆盖到 MySQL 字段;其他场景(评论卡片显示 username 等)读 MySQL 纯净值
fetch 4s 硬超时;upstream 5xx / 解析失败一律放行(评论 → UPDATE status='visible',资料 → 直接 dbUserUpdate)。每次 moderation 决策都写 console.log('[moderation]', ...),用 wrangler tail | grep moderation 可追查。
9.3 异步审核工作流
评论(services/comments.ts)
POST /api/comments
├─ sanitize + validate parent
├─ INSERT comments (..., status='pending')
├─ ctx.waitUntil(moderateCommentAsync(...))
└─ return 200 + item (status='pending', pending=true)
moderateCommentAsync (fire-and-forget):
├─ moderateText('comment', ...)
├─ if reject: DELETE comments WHERE id=? + createNotification(author)
├─ elif pass / softFail: UPDATE comments SET status='visible'
└─ never throws (log & resolve)
资料(services/profile.ts)
PATCH /api/member/profile
├─ validate
├─ commit non-text fields (sex, birthday) to MySQL synchronously
├─ sanitize nickname/intro → KV profile_pending:{uid} (TTL 24h)
├─ ctx.waitUntil(moderateProfileAsync(...))
└─ return 200 + profile (含 pending 字段)
moderateProfileAsync (per-field, 独立决策):
├─ for each field in {nickname, intro}:
│ ├─ moderateText('nickname' | 'bio', ...)
│ ├─ if reject: drop from KV + notify
│ └─ elif pass / softFail: dbUserUpdate + clear KV
└─ never throws
9.4 Secrets
| 变量 | 位置 | 说明 |
|---|---|---|
MODERATION_API_BASE | wrangler.toml [vars] | https://aicenter-api.1.gay |
MODERATION_APP_ID | wrangler.toml [vars] | app_f2ce7d84dec8ad56(非敏感) |
MODERATION_SECRET | wrangler secret put | HMAC 密钥,不入 git |
9.5 SQL 注入 / XSS 防线
| 威胁 | 主防线 | 纵深防线 |
|---|---|---|
| SQL 注入 | D1 prepare().bind() 全路径 | — |
| 存储型 XSS | React {text} 自动转义 | sanitizeUserText 剥标签 + URI scheme |
| 恶意链接 | sanitize 去 javascript: 等 | AI 审核召回可疑外链 |
| 辱骂 / 违禁词 | AI 审核 | — |
| 长度洪水 | 字段硬上限(comment 500 / nickname 20 / intro 255) | — |
10. 分享域名检测 / 裂变拉新
两个系统都围绕"怎样让一个已登录用户把别人拉进来"这件事运作,共用 services/domainshare.ts 里的 TOTP 签名和 tp_share MySQL 表。
10.1 共用基础:TOTP 签名
domain-share.gv.live 上游用 TOTP(RFC 6238,HMAC-SHA1,30s 步长,6 位)校验请求合法性。代码在 services/domainshare.ts#generateTOTP:
counter = floor(now / 30)
hmac = HMAC-SHA1(DOMAIN_SHARE_SECRET, counter-as-8-byte-BE)
code = (dynamic-trunc(hmac) & 0x7FFFFFFF) % 10^6
header: X-Dynamic-Token: <6 digits>
Secret 经 wrangler secret put DOMAIN_SHARE_SECRET 布置;PHP 端用 OTPHP 的 Base32 包装在内部一层,线上对等(wrap 抵消)。
10.2 分享域名分配 & 可用性检测(反 wall 基础设施)
为什么需要:CN 大陆会封主域,分享链路常失效。域名池(domain-share.gv.live 维护)实时下发"当前能用"的域名,每个 (uid, device) 拿到可能不同,方便定向封禁。
| 调用 | 作用 | 触发 |
|---|---|---|
getShareDomain(env, userId, deviceCode) | 内部函数:为该 (uid, device) 拿分享域名,返回 https://<domain>/?fxid=<uid>;KV share_domain:{uid} 48h 正缓存 / 5min 负缓存 | GET /api/member/me · GET /api/member/referrals |
GET /api/auth/test-domain?deviceId= | 前端静默任务:"这个设备当前用哪个域名测试可达" | 前端启动时/登录后后台轮询。仅 CN 大陆 + 识别出运营商才返回;否则 400 IP not eligible |
POST /api/auth/report-test-domain {deviceId, status, domain} | 回报测试域名连通性 | 前端拿到测试域名做一次真请求后上报 pass/fail |
request.cf → 域名池入参:cfToRegionCarrierCountry() 从 CF 边缘拿 country / region / asOrganization,并做两步归一化:
- region:去掉"省"/"市"/"自治区"/"特别行政区"后缀
- carrier:匹配
移动|联通|电信/Mobile|Unicom|Telecom|ChinaNet归类三大运营商
只有 country=CN && region ∉ {HK, MO, TW} && carrier 非空 才真的调 /api/domain/test。
上游端点(domain-share.gv.live 提供):
GET /api/domain/assign?site_id=&device_id=&user_id=→{code:1, data:{domain}}(永久分配)POST /api/domain/test { site_id, region, carrier }→ 下发测试域名POST /api/domain/report { site_id, domain, status, device_id, region, carrier, country, ip }→ 回报
10.3 分享 URL 暴露
/api/member/me 响应里含 shareDomain: "https://gvm1.fun/?fxid=<uid>",前端"邀请好友"弹窗 / 战绩页直接用这个 URL 拼二维码或复制链接。未登录或 deviceCode 丢失的老会话返回 null,前端降级展示。
10.4 裂变拉新:fxid 归因与奖励
fxid 归因有两条并行通道:localStorage + body(主)和 HttpOnly cookie(fallback,仅主站生效)。前端入口分布在多个分享域(gvm1.fun / gvm2.fun / … 经 domain-share 动态下发),所以主路径必须不依赖跨站 cookie。
流程(代码:frontend/src/lib/api.ts + workers/src/services/auth.ts):
- 引流:A 分享
https://gvm1.fun/?fxid=<A.uid>给 B - 落地:B 打开该 URL,
App.tsx在 mount 钩子里:storeFxid(A.uid)→ 写 localStorage(key:yitongkan.fxid)rememberReferral(A.uid)→ POST/api/auth/referral设 HttpOnly cookie。仅同站(主站或 pages.dev 预览)才真正发请求;分享域上rememberReferral直接 return false 跳过,避免必败的跨域 preflight- 从 URL 剥掉
fxid=参数并history.replaceState,防止刷新再触发
- 注册:B 进注册页,
RegisterPage调readStoredFxid()读 localStorage → 作为body.fxid塞进POST /api/auth/register。Workers handler 先信body.fxid,为空才 fallback 读 cookie:
拿到let fxid = Number(body.fxid) || 0; if (!fxid) fxid = Number(readCookie(request, 'fxid')) || 0;fxid后:创建tp_user+ 送初始流量(tp_flows);若fxid > 0 && fxid ≠ B.id→dbShareCreate写tp_share(good=1, is_dood=1) - 奖励(
feature:workers_register_reward=1灰度):dbUserAddVipDays(A.id, N)给 A 加 VIP 天 - 反刷单(入站顺序):
ipreg:{ip}(30d):同 IP 30 天只允许注册一个账号fp:{fingerprint}(7d):设备指纹去重tp_blacklist校验
fxid无效
跨域名场景的归因局限(已知取舍)
| 场景 | fxid 归因 |
|---|---|
B 打开 gvm1.fun/?fxid=A,同一分享域内注册 | ✅ localStorage 路径工作 |
B 打开 gvm1.fun/?fxid=A,关闭后从主站注册 | ❌ 跨域 localStorage 不共享 + cookie 跨站拿不到 → 丢归因 |
B 打开 gvm1.fun/?fxid=A,关闭后从gvm2.fun注册 | ❌ 跨分享域 localStorage 不共享;Chrome CHIPS 分区(即使改 SameSite=None)也无法共享 cookie |
B 打开 ytk.yitongcs.com/?fxid=A 注册 | ✅ localStorage + cookie 双通道 |
对冲方式:生成分享链接时让同一用户所有入口走一致的分享域(domain-share 的 /api/domain/assign 对同一 (uid, device) 返回稳定结果,满足这点)。
10.5 邀请战绩
| 端点 | 数据源 | 返回 |
|---|---|---|
GET /api/member/invite/stats | dbShareStats(username) → SELECT COUNT(*) FROM tp_share WHERE fx=? 分 good=1 / good=0 | {share, sharePending, shareRewarded} |
GET /api/member/invite/list?page=&page_size= | dbShareList(username, page, size) | 新注册人名单 + 时间 |
GET /api/member/referrals | 上面两个 + getShareDomain | 前端战绩页一次请求所需的全部数据,2min KV cache |
10.6 相关 Secrets / 配置
| 变量 | 来源 | 说明 |
|---|---|---|
DOMAIN_SHARE_URL | wrangler.toml [vars] | https://domain-share.gv.live |
DOMAIN_SHARE_SITE_ID | wrangler.toml [vars] | 1(一同看站点 id) |
DOMAIN_SHARE_SECRET | wrangler secret put | TOTP HMAC-SHA1 密钥 |
FINGERPRINT_API_KEY | wrangler secret put | Fingerprint.com 服务端 key(用于 fp:* 去重) |
10.7 风险 / 已知坑
- TOTP 30s 窗口:Worker 和 domain-share 时间漂移 > 15s 会令牌失效。CF 边缘时间精度足够,未触发过
- fxid cookie 只 5h TTL:cookie 通道仅主站有效且 5h 到期;localStorage 主路径不受此限
feature:workers_register_reward=1必须单独开:默认 NOT 给邀请者加天,只开feature:workers_register=1是注册迁 Workers 但不走奖励
10.8 裂变反作弊(注册流水线)
POST /api/auth/register 按固定顺序跑 7 道检查。不是拒登 vs 放行的二元逻辑 —— 前 4 道硬拒(HTTP 201 业务错),后 3 道失败也让账号创建成功,只把 trial_vip 压到 0。这是刻意设计:让攻击者看不出在哪条规则卡了,同时把奖励经济绑死在 trial_vip > 0 上。
| # | 检查 | 实现 | 不通过 |
|---|---|---|---|
| 1 | Slider HMAC 挑战 | services/slider.ts;token = <nonce>:<expiresAt>:<HMAC>,HMAC key = DB_PROXY_KEY,TTL 5min,最小年龄 1.5s(防 fetch + 立即 POST) | 硬拒(HTTP 201 业务错,不往下走) |
| 2 | 格式校验 | username ^[A-Za-z0-9_-]{3,30}$,密码 6-32 | 硬拒 |
| 3 | 黑名单 | tp_blacklist via db-proxy:dbBlacklistCheck(type=2, fingerprint) + (type=1, username) | 硬拒 |
| 4 | 用户名占用 | dbUserFind | 硬拒 |
| 5 | 设备指纹 KV 去重 | fp:{visitorId} KV(TTL 0 = 永久) | 账号创建但 trial_vip=0,邀请者不拿奖励 |
| 6 | Fingerprint.com 服务端校验 | GET api.fpjs.io/visitors/{id}?limit=1(看 existed)+ GET /events/{requestId}(看 botd.result=='notDetected' && vpn==false && incognito==false) | existed || !clean → trial_vip=0;API 挂掉按 !clean(偏执默认) |
| 7 | IP 注册锁 | ipreg:{ip} KV(TTL 30 天) | 同 IP 30 天内第二次起 trial_vip=0 |
通过全部 7 道的用户:
trial_vip = now + 3600s(1 小时试用 VIP,常量REGISTRATION_GRACE_VIP_SECS)fp:{visitorId}立即markFingerprintSeen(永久标记)ipreg:{ip}立即markIpRegistered(30 天标记)
"同一设备 / 同一 IP 多久后再算新用户?"(业务答疑)
设备指纹和 IP 的生命周期不同,两道门独立作用:
| 因素 | KV 键 | TTL | 自然复位 |
|---|---|---|---|
Fingerprint.com visitorId(前端 fp lib 上报) | fp:{visitorId} | 永久 | ❌ 永不过期 —— 同一 visitorId 永远算"已见过" |
| IP 地址 | ipreg:{ip} | 30 天 | ✅ 30 天后自动过期,同 IP 可再拿 trial VIP |
wrangler kv key delete "fp:<visitorId>"。IP 门自愈:30 天自动过期;或手工清 ipreg:<ip>。两道门是 AND 关系 —— 要重拿 trial VIP 必须同时:门 5 过(设备指纹新)+ 门 6 过(Fingerprint.com clean)+ 门 7 过(IP 没锁)。
为什么 fp 选永久 TTL?直译 PHP 的 Cache::set ttl=0。业务侧取舍是"宁可错杀诚实老用户换新机的 trial VIP,也不给刷号者返回试用"。如果以后放宽,改成 { expirationTtl: 86400*180 }(180 天)即可;只涉及 markFingerprintSeen 一行。
邀请者奖励(AND 四条件才给)
opts.rewardEnabled(KVfeature:workers_register_reward=1)fxid > 0(归因到合法邀请人)vipTime !== 0(被邀请人自己通过了所有反作弊门)referrer.id !== newUserId(防自邀)
满足才调:
dbUserAddVipDays(referrer.id, 1)— 邀请者 VIP +1 天dbShareCreate(..., good=1, is_dood=1)— 写tp_share
trial_vip=0,邀请者就拿不到奖励。把"真实新用户"的判定同时用作"计入裂变战绩"的判定,反刷成本直接落在经济激励上。
登录路径也续 ipreg:{ip} 30 天锁(见 handleLogin 尾部):老用户登录续锁,防止攻击者"先登录让 IP 看起来有人,再换小号注册绕 IP 门"。
已知局限
- Slider HMAC 挡不住 headless browser 真滑动 —— 挡得住脚本 POST,挡不住专业刷号平台
- Fingerprint.com
incognito=false对隐私模式敏感 —— 老 iOS Safari、某些 Brave 模式会被误判!clean,trial VIP 被没收;业务侧接受(偏执默认) fp:{visitorId}永久 TTL 的双刃剑 —— 同一真实用户换浏览器 / 清缓存就重置 visitorId,会拿到新 trial VIP(被薅羊毛);反之老用户真的换设备要新试用也拿不到(被误杀)tp_share.good=1写入即定格 —— 当前没有"被邀请人活跃度不够就把 good 降成 0"的 downgrade 流程;is_dood字段预留但未使用
11. 开发 / 部署流程
11.1 目录 → 仓库映射
| 目录 | GitHub | 分支 |
|---|---|---|
frontend/ | gayapp/YitongkanV2 | main |
workers/ | gayapp/ytk-api | master |
根目录 *.md / docs/ | gayapp/ytk-docs | master |
11.2 本地开发
# Workers 本地
cd workers && npm install && npx wrangler dev # http://localhost:8787
# 前端本地
cd frontend && npm install && npm start # http://localhost:4200
11.3 部署
# Workers
cd workers
CLOUDFLARE_API_TOKEN=$CLOUDFLARE_TEST_DOMAIN_API_TOKEN npx wrangler deploy
# 成功标志:Deployed ytk-api triggers
# Secrets
wrangler secret put M3U8_KEY # 签名密钥
wrangler secret put AES_KEY # 跨域分享
# D1 migration
wrangler d1 execute ytk --file=schema.sql
wrangler d1 execute ytk-favorites --file=migrations/favorites.sql
wrangler d1 execute ytk-history-0 --file=migrations/shard-history.sql # × 16
wrangler d1 execute ytk-comments-0 --file=migrations/shard-comments.sql # × 4
# 前端
cd frontend && npm run build
# CF Pages 接 GitHub 自动发布到 ytk.yitongcs.com
11.4 提交规范
feat: 新增功能
fix: 修复 bug
refactor: 重构,不改变行为
chore: 配置 / 依赖 / 脚本
docs: 文档
11.5 四阶段工作流(强制)
- 分解任务 → 列步骤清单
- 逐步执行 + 验收(curl / build / SSH)
- 部署(wrangler deploy)
- git commit & push
12. 成本预估
12.1 价格基准(Cloudflare 官方,2026-01)
| 资源 | 免费额度 | 超出后单价 |
|---|---|---|
| Workers 请求 | 10M/月 | $0.30 / 百万(Paid 起价 $5/月含 10M) |
| Workers CPU 时间 | 30M ms/月(Bundled) | Unbound $12.50 / M CPU-ms |
| D1 读 | 5 B/月(Paid) | $1.00 / M 读 |
| D1 写 | 50 M/月 | $1.00 / M 写 |
| D1 存储 | 5 GB | $0.75 / GB·月 |
| KV 读 | 10 M/月 | $0.50 / M |
| KV 写 | 1 M/月 | $5.00 / M |
| KV 存储 | 1 GB | $0.50 / GB·月 |
| R2 存储 | 10 GB | $0.015 / GB·月 |
| R2 A 类操作(写/列) | 1 M/月 | $4.50 / M |
| R2 B 类操作(读) | 10 M/月 | $0.36 / M |
| R2 出口 | 免费 | |
| Queues | 10 M 操作/月 | $0.40 / M |
| Durable Object 请求 | 1 M/月 | $0.15 / M |
| Durable Object 时长 | 400 k GB-s/月 | $12.50 / M GB-s |
| Analytics Engine | 10 M 写/月 | $0.25 / M 写 |
12.2 用量假设(1M DAU 满载)
| 指标 | 估算值 | 推导 |
|---|---|---|
| 日请求 | ~50 M | 1 M DAU × 50 次/天 |
| 月请求 | ~1.5 B | — |
| 心跳写(历史) | 600 M/月 原始 → 60 M D1 写 | Queue 合并 |
| 浏览 / 点赞 | 900 M/月 原始 → 1 M KV 写/天 | DO 聚合 |
| D1 读 | ~2.25 B/月 | 大部分命中 cache |
| D1 存储总量 | ~25 GB(分布在 21 库) | 视频 + 历史 + 收藏 |
| KV 读 | ~1 B/月 | bootstrap / list cache 命中 |
| KV 写 | ~30 M/月 | DO flush + epoch bump |
| R2 存储 | ~50–80 GB | 归档 12 月 + 头像 |
| Queues 操作 | ~1.5 B/月 | — |
| DO 请求 | ~1.8 B/月 | 计数合并 |
| DO 时长 | ~7 M GB-s/月 | alarm 30s × 2880 次/天 |
| AE 写 | 1.5 B/月 | 每请求一条 |
12.3 月成本估算(1M DAU 满载)
| 项目 | 用量 | 成本(USD/月) |
|---|---|---|
| Workers Paid 基础 | — | $5 |
| Workers 请求超额 | 1.5 B − 10 M | ~$447 |
| Workers CPU 超额 | 按 Bundled 多数情况下不单算 | ~$0(或 Unbound ~$370) |
| D1 读 | Paid 含 25 B | $0 |
| D1 写 | 10 M 超额 | $10 |
| D1 存储 | 20 GB 超额 | $15 |
| KV 读 | 990 M 超额 | ~$495 |
| KV 写 | 29 M 超额 | ~$145 |
| KV 存储 | <1 GB | $0 |
| R2 存储 | 70 GB 超额 | $1.05 |
| R2 A 类 | 4 M 超额 | $18 |
| R2 B 类 | 10 M 超额 | $3.60 |
| Queues | 1.5 B / M × $0.40 | ~$600 |
| DO 请求 | 1.8 B / M × $0.15 | ~$270 |
| DO 时长 | 6.6 M GB-s 超额 | $82.50 |
| AE 写 | 1.5 B / M × $0.25 | ~$375 |
| Logpush → R2 | 存储已计 | $0 |
| Cloudflare 小计 | — | ~$2,900 |
| + MySQL 服务器(149/108) | Vultr 2 节点 | ~$100 |
| 合计上限 | — | ~$3,000 / 月 |
12.4 成本优化杠杆
| 优化 | 节省 | 代价 |
|---|---|---|
| 列表缓存 TTL 600s → 3600s | KV 读 -60% / ≈ -$300 | 新上线视频延迟 1h 可见 |
| Queue batch 100→200,timeout 5→10s | Queue ops -50% / ≈ -$300 | 计数延迟 +5s |
| DO alarm 30s → 120s | DO 时长 -75% / ≈ -$60 | 同上 |
| AE 采样率 100% → 10% | AE 写 -90% / ≈ -$340 | 粗粒度 QPS 仍可观测 |
| bootstrap cache TTL 60s → 300s | 请求 -30% / ≈ -$130 | bootstrap 5min 延迟 |
CDN Cache Rules 覆盖 /api/content/* | Workers 调用减半 / ≈ -$200 | 需设计 purge |
13. 容量与扩展路径
13.1 当前硬上限
| 资源 | 限额 | 当前用量 | 余量 |
|---|---|---|---|
| D1 单库 10 GB | 21 × 10 GB = 210 GB | ~25 GB | 宽裕 |
| KV 单 key 25 MB | — | list JSON ~200 KB | 宽裕 |
| Workers 单次 CPU 50 ms(Bundled) | — | p95 ~15 ms | 宽裕 |
| Workers 请求体 100 MB | — | 头像 ~5 MB | 宽裕 |
| R2 桶容量 | 无上限 | — | 无忧 |
| DO 单实例 | 软阈值 | 单例聚合 | 需观察 |
13.2 扩展预案
| 触发条件 | 动作 |
|---|---|
| history 任一分片 > 8 GB | 按 uid % 32 重新分片(schema 不变,加绑定) |
| favorites > 8 GB | DB_FAV_0..N |
| comments 任一分片 > 8 GB | content_id % 8 扩展 |
| DO 计数瓶颈(>1 k req/s) | 按 contentId % K 起 K 个 DO 实例 |
| db-proxy 成为瓶颈 | 切 CF Hyperdrive,MySQL 放到 Planetscale/TiDB Cloud |
| AE 写量逼近免费额 | 采样 10%,保 p99 精度 |
14. 待办与已知风险
14.1 待办
frontend/仓库的 CI 没接 CF Pages 自动构建,目前手动npm run build/admin/*管理端点缺少前端管理面板,靠 curl + 脚本操作alerts.tswebhook 是临时 URL,应改为 Slack / 企业微信官方 hook- 头像上传(
POST /api/member/avatar)尚未接入 aicenter-api 审核;需要多开一个/api/callback/moderation异步回调端点 + 回调签名校验 X-Sync-Key 共享秘钥需加 IP 白名单(已于b007399落地,middleware/admin_auth.ts+SYNC_ALLOWED_IPS)归档 SHA-256 仅存 KV,KV TTL/丢 key 则无法校验(已于2f14dfd落地,月度 cron 把arc:ck:*转存 R2checksums/...)
14.2 风险
| 风险 | 影响 | 缓解 |
|---|---|---|
| MySQL 主机 149 单点 | db-proxy / VIP / 订单全挂 | 规划中:Planetscale 迁移 |
| 计数最终一致(DO 30s flush) | 点赞数短暂不同步 | 已记录;UI 下次 fetch 自愈 |
MODERATION_SECRET 是共享静态密钥 | 泄露后第三方可以冒签审核请求 | wrangler secret 存储不入 git;轮换需和 aicenter-api 运营方走工单换 key |
| aicenter-api 审核抖动或超时 | 评论 / 昵称 / 简介提交被延迟或漏审 | 4s 硬超时 + fail-open,softFail 事件写 AE 便于告警 |