一同看(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 无服务器栈。

用户 → Cloudflare CDN (ytk.yitongcs.com) │ ├── React SPA (Vite + Tailwind + MUI/Radix) │ └── API 请求 → Workers (ytk-api.yitongcs.com) │ ├── D1(24 个数据库:主 / 收藏 / 搜索日志 / 站内信 / 4 评论分片 / 16 历史分片) ├── KV(缓存 + 计数器落地 + 站点配置) ├── R2(用户资产 / 归档冷数据) ├── Queues(写入削峰:q-history / q-views / q-likes) ├── Durable Object(CounterAggregator 聚合计数) ├── Analytics Engine(每请求指标 → Grafana) │ └── 回源到 db-proxy (ytk-db.yitongcs.com) → MySQL (149/108) 仅保留少量会员/支付/邀请写

关键设计决策

决策动机
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 入 KVD1 空间不被长尾拖死,可验证归档完整性
db-proxy 作为 Workers↔MySQL 唯一通道(11 端点白名单)避免 Hyperdrive 费用 + IP 白名单管理
人机校验保留前端滑块迁移阶段 C 决策,减少跨系统耦合

2. 运行时拓扑

2.1 域名 / 服务映射

域名归属作用
ytk.yitongcs.comCloudflare Pages / Worker前端 SPA
ytk-api.yitongcs.comWorkers (ytk-api)主 API + 管理接口
ytk-db.yitongcs.com149.28.233.43:8080 Python db-proxyWorkers 访问 MySQL 的唯一代理层
php.yitongcs.com旧 PHP (149)白名单兜底(proxyToPhp 已收口)
user.gayboys.vip站外用户中心USERSITE_DOMAIN,少量老接口

2.2 生产机

IP端口角色数据库
149.28.233.4322主 MySQL + db-proxy + cron 同步宿主ytk, videocenter, tp_legacy_video_meta
108.61.23.14622324视频数据库(yzm)yzm.yzm_video

两台机每 10 分钟跑 ytk_sync.sh(149 用 videocenter,108 用 yzm),把增量行 POST 到 /admin/sync,再做第二遍 backfill(填时长/大小/下载地址)。

3. 前端(React + Vite)

3.1 技术栈

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 与后端的契约

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 个端点)

公共 / 基础

登录 / 注册

会员

收藏(D1 / DB_FAV)

观看历史(D1 分片 × 16)

站内信 / 反馈 / 评论

搜索 / 内容 / 媒体

管理 / 同步(双重门禁: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

4.3 中间件管线

Request → CORS pre-flight 拦截(middleware/cors.ts) → 6s fetch 超时包装(middleware/fetch.ts,作用于所有出站 subrequest) → 业务路由(index.ts 分发) └── /admin/* · /api/admin/* 分支命中 isAdminAuthorized(request, env) · X-Sync-Key 值必须等于 env.SYNC_KEY · CF-Connecting-IP 必须在 env.SYNC_ALLOWED_IPS 列表中 任一不满足 → 403 Forbidden(middleware/admin_auth.ts) → metrics 上报 AE(status / path / latency / uid / responseBytes / rayId,middleware/metrics.ts) → 响应

5. 数据库说明

5.1 D1 实例清单(24 个)

绑定名称用途主键策略
DBytk主库:videos / legacy_* / site_config / hotlinks / feedback按表自然键
DB_FAVytk-favorites收藏(uid, section, content_id)
DB_SEARCHLOGytk-search-log搜索热词(独立 DB)query
DB_NOTIFytk-notifications站内信 + 已读态(广播 + 私信同库)id / (userid, notif_id)
DB_COMMENTS_0..3ytk-comments-0..3评论分片id,路由按 content_id % 4
DB_HISTORY_0..15ytk-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

其他主库表

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
重要: 读写必须用同一 uid/content_id 算分片。跨分片聚合(如评论总榜)在应用层做 fan-out。单元测试见 services/shards.test.ts

5.5 关键索引与查询模式

查询执行路径复杂度
我的历史(默认首屏)idx_history_uid_watched 单分片O(log N + k)
收藏列表idx_fav_uid_addedO(log N + k)
视频列表(按热度)主库 idx_videos_favcount DESC + edge 60s + KV 600sO(k) with cache hit
评论列表idx_comments_content 单分片O(log N + k)
搜索热词榜idx_search_log_count DESCO(k)
归档 cronidx_history_watched_at 全分片扫描O(N) per day

5.6 一致性与去重策略

冲突处理
videosON 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_historyON 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_*(导入后只读)
C. 订单 / 支付链路(tp_order / tp_pay_log 等)目前仍走老 PHP,由 Workers proxyToPhp 透传到 php.yitongcs.com不经 db-proxy,Workers 不直接访问这些表。

6. 对象存储 / KV / 队列 / DO / AE

6.1 R2 桶

绑定桶名用途
USER_ASSETSytk-user-assets用户头像 / 意见反馈附件
ARCHIVE_BUCKETytk-archive观看历史 >90d 冷归档(JSON 行,SHA-256 存 KV)
开启 Bucket Lock(R2 的不可删保护,替代 S3 Versioning),防止误删/覆盖归档。

6.2 KV Namespace 键空间

按功能域分组,以代码中实际前缀为准:

前缀说明TTL
缓存
cache:list_epoch全局列表版本号;写入时 +1 → 作用于所有 gvlist-v4-*永久
gvlist-v4-{epoch}-{type}-{sort}-{page}-{size}GV 列表 JSON600s
mvlist-d1v4-{epoch}-... / tvlist-d1v4-{epoch}-...MV / TV 列表 JSON600s
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

CounterAggregatorsrc/do/counter.ts)— 单例 DO,接收 Queue 消费端聚合的计数增量,alarm() 每 30s 把汇总数写回 KV。设计上把 "N 次 D1/KV 写" 摊成 "1 次 KV 写"。

6.5 Analytics Engine

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

  1. GET /admin/sync/status → 取 KV 游标 sync:max_tp_id
  2. SELECT ... FROM tp_video LEFT JOIN tp_legacy_video_meta WHERE id > cursor LIMIT 500
  3. 拼 md5 列表,再 SELECT md5, duratio FROM (videocenter|yzm).yzm_video 查时长
  4. POST /admin/sync 分批 500 行
  5. 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

机制(从最即时到最兜底)

  1. 写时回填services/history.ts#logWatch)— 客户端没传 durationSeconds,从 videos 查一次再写分片
  2. 读时水合services/history.ts#listHistory)— GV 历史行缺时长,批量查 videos 并回写分片
  3. 反向回填(心跳 UPDATE)— 客户端心跳带真实时长时(HTML5 解析的 m3u8),若 videos.duration_seconds 为 0 则补齐
  4. 管理员 / 定时 m3u8 解析services/duration_backfill.ts)— 直接抓 450-bit m3u8 求和 #EXTINF(HTML5 播放器同款算法)。端点 POST /admin/videos/backfill-duration 手动触发;日 cron 03:00 自动 100 行/次兜底

8. 可观测性与告警

8.1 Grafana

8.2 告警规则(每 5 分钟)

规则阈值动作
Worker 5xx 率> 1% over 5minPOST ALERT_WEBHOOK_URL(若已配置)+ AE 事件 cron-alerts
D1 任一库 size> 8 GB(留 2 GB 安全区)webhook
Queue 积压backlog > 10 kwebhook
db-proxy 5xx 率> 5% over 5minwebhook

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 实体形态 &lt;/script>防双重转义绕过
ASCII 控制字符(保留 \n/\t防日志/分析污染
CRLF → LF,连续空行折叠到最多 2显示一致

9.2 AI 审核(aicenter-api.1.gay · 异步模式

2026-04-24 起从同步改为异步。用户提交内容(评论 / 昵称 / 简介)立即返 HTTP 200,Worker 通过 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触发点立即返回通过后拒绝后
commentPOST /api/comments200 + status='pending' + pending=true,作者自己能在列表看到,其他人看不到UPDATE comments SET status='visible',全员可见DELETE 评论行 + 站内通知(title "评论未通过审核")
nicknamePATCH /api/member/profile200 + profile.pending.nickname,作者自己 /me 能看到,他人看老值dbUserUpdate({nickname}) 提交 MySQL + 清 KV pending丢弃 pending + 站内通知(title "昵称未通过审核")
bio (intro)PATCH /api/member/profile200 + profile.pending.introdbUserUpdate({intro}) + 清 KV丢弃 + 站内通知("个人简介未通过审核")
avatarPOST /api/member/avatar未接入(规划中,需异步 webhook 回调端点)

pending 存储

可见性过滤

超时/失败策略: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
允许 per-field 独立通过(不再 all-or-nothing):nickname 通过但 intro 被拒时,nickname 会提交 MySQL,intro 被丢弃并发通知,两者独立。

9.4 Secrets

变量位置说明
MODERATION_API_BASEwrangler.toml [vars]https://aicenter-api.1.gay
MODERATION_APP_IDwrangler.toml [vars]app_f2ce7d84dec8ad56(非敏感)
MODERATION_SECRETwrangler secret putHMAC 密钥,不入 git

9.5 SQL 注入 / XSS 防线

威胁主防线纵深防线
SQL 注入D1 prepare().bind() 全路径
存储型 XSSReact {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,并做两步归一化:

只有 country=CN && region ∉ {HK, MO, TW} && carrier 非空 才真的调 /api/domain/test

上游端点(domain-share.gv.live 提供)

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

  1. 引流:A 分享 https://gvm1.fun/?fxid=<A.uid> 给 B
  2. 落地:B 打开该 URL,App.tsx 在 mount 钩子里:
    1. storeFxid(A.uid) → 写 localStorage(key: yitongkan.fxid
    2. rememberReferral(A.uid) → POST /api/auth/referral 设 HttpOnly cookie。仅同站(主站或 pages.dev 预览)才真正发请求;分享域上 rememberReferral 直接 return false 跳过,避免必败的跨域 preflight
    3. 从 URL 剥掉 fxid= 参数并 history.replaceState,防止刷新再触发
  3. 注册:B 进注册页,RegisterPagereadStoredFxid() 读 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.iddbShareCreatetp_sharegood=1, is_dood=1
  4. 奖励feature:workers_register_reward=1 灰度):dbUserAddVipDays(A.id, N) 给 A 加 VIP 天
  5. 反刷单(入站顺序):
    • 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/statsdbShareStats(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_URLwrangler.toml [vars]https://domain-share.gv.live
DOMAIN_SHARE_SITE_IDwrangler.toml [vars]1(一同看站点 id)
DOMAIN_SHARE_SECRETwrangler secret putTOTP HMAC-SHA1 密钥
FINGERPRINT_API_KEYwrangler secret putFingerprint.com 服务端 key(用于 fp:* 去重)

10.7 风险 / 已知坑

10.8 裂变反作弊(注册流水线)

POST /api/auth/register固定顺序跑 7 道检查。不是拒登 vs 放行的二元逻辑 —— 前 4 道硬拒(HTTP 201 业务错),后 3 道失败也让账号创建成功,只把 trial_vip 压到 0。这是刻意设计:让攻击者看不出在哪条规则卡了,同时把奖励经济绑死在 trial_vip > 0 上。

#检查实现不通过
1Slider 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,邀请者不拿奖励
6Fingerprint.com 服务端校验GET api.fpjs.io/visitors/{id}?limit=1(看 existed)+ GET /events/{requestId}(看 botd.result=='notDetected' && vpn==false && incognito==falseexisted || !cleantrial_vip=0;API 挂掉按 !clean(偏执默认)
7IP 注册锁ipreg:{ip} KV(TTL 30 天同 IP 30 天内第二次起 trial_vip=0

通过全部 7 道的用户:

"同一设备 / 同一 IP 多久后再算新用户?"(业务答疑)

设备指纹和 IP 的生命周期不同,两道门独立作用:

因素KV 键TTL自然复位
Fingerprint.com visitorId(前端 fp lib 上报)fp:{visitorId}永久❌ 永不过期 —— 同一 visitorId 永远算"已见过"
IP 地址ipreg:{ip}30 天✅ 30 天后自动过期,同 IP 可再拿 trial VIP
结论:只要 Fingerprint.com 仍返回同一 visitorId,该设备永远不会被当作新用户拿到试用 VIP 或让邀请者加天。唯一"复位" fp 门的途径:换设备;换浏览器(不同 profile 通常给不同 visitorId);清 IndexedDB / cookie 让 visitorId 重生(但 visitorId 有跨清理持久度,不一定每次都换);或运营手工清 KV 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 四条件才给)

满足才调:

vipTime 是奖励的前置条件。被邀请人只要被第 5/6/7 道拦到 trial_vip=0,邀请者就拿不到奖励。把"真实新用户"的判定同时用作"计入裂变战绩"的判定,反刷成本直接落在经济激励上。

登录路径也续 ipreg:{ip} 30 天锁(见 handleLogin 尾部):老用户登录续锁,防止攻击者"先登录让 IP 看起来有人,再换小号注册绕 IP 门"。

已知局限

11. 开发 / 部署流程

11.1 目录 → 仓库映射

目录GitHub分支
frontend/gayapp/YitongkanV2main
workers/gayapp/ytk-apimaster
根目录 *.md / docs/gayapp/ytk-docsmaster

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 四阶段工作流(强制)

  1. 分解任务 → 列步骤清单
  2. 逐步执行 + 验收(curl / build / SSH)
  3. 部署(wrangler deploy)
  4. git commit & push
高风险操作(SSH 写、prod MySQL 改、force push、删 DNS/KV/D1)必须用户在当前会话授权

12. 成本预估

当前(<10 万 DAU)
< $30
/ 月
1M DAU 满载
~$3,000
/ 月
优化后(1M DAU)
~$1,800
/ 月

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 出口免费
Queues10 M 操作/月$0.40 / M
Durable Object 请求1 M/月$0.15 / M
Durable Object 时长400 k GB-s/月$12.50 / M GB-s
Analytics Engine10 M 写/月$0.25 / M 写

12.2 用量假设(1M DAU 满载)

指标估算值推导
日请求~50 M1 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
Queues1.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 → 3600sKV 读 -60% / ≈ -$300新上线视频延迟 1h 可见
Queue batch 100→200,timeout 5→10sQueue ops -50% / ≈ -$300计数延迟 +5s
DO alarm 30s → 120sDO 时长 -75% / ≈ -$60同上
AE 采样率 100% → 10%AE 写 -90% / ≈ -$340粗粒度 QPS 仍可观测
bootstrap cache TTL 60s → 300s请求 -30% / ≈ -$130bootstrap 5min 延迟
CDN Cache Rules 覆盖 /api/content/*Workers 调用减半 / ≈ -$200需设计 purge
组合应用后,1M DAU 月成本可压到 $1,800–2,000。 建议增长至 30 万 DAU 时再启动优化。

13. 容量与扩展路径

13.1 当前硬上限

资源限额当前用量余量
D1 单库 10 GB21 × 10 GB = 210 GB~25 GB宽裕
KV 单 key 25 MBlist JSON ~200 KB宽裕
Workers 单次 CPU 50 ms(Bundled)p95 ~15 ms宽裕
Workers 请求体 100 MB头像 ~5 MB宽裕
R2 桶容量无上限无忧
DO 单实例软阈值单例聚合需观察

13.2 扩展预案

触发条件动作
history 任一分片 > 8 GBuid % 32 重新分片(schema 不变,加绑定)
favorites > 8 GBDB_FAV_0..N
comments 任一分片 > 8 GBcontent_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 待办

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 便于告警