代码基线:origin/test @ bc983dba(2026-09-26);生产库实查走
sudo db-prod(只读 SELECT)。
标注:【事实】= 有命令原样输出;【推断】= 由事实推出、未实测;【外推】= 按实测单次值线性计算。
子代理报告的关键结论由主会话亲自复跑 grep 复核(见各条「主会话复核」)。子代理 1 条错误结论已纠正(见 A8)。
users | 188 | 312 kB
organizations | 48 | 112 kB
db_size | max_conn | shared_buffers | pg_ver
41 MB | 50 | 128MB | 16.14
→ 开 5 万号 = users 表从 188 行涨到约 5 万行(约 266 倍)。按 PG 常规,5 万行属于小表【推断】。
origin/test:deploy/Dockerfile.api:67:CMD ["uvicorn", "app.main:app", ... "--workers", "1", ...]
gunicorn 全仓 0 命中(对照组:uvicorn 在同文件命中)。
origin/test:app/services/seat_service.py:290: raise HTTPException(status_code=400, detail="count 必须 1-100")
origin/test:app/services/seat_service.py:326: password_hash=hash_password(temp_pw),
hash_password 在 for 循环里同步调用,没有用 to_thread/run_in_executor 包起来;整批只在最后 commit 一次。
→ 5 万个号在网站进程里跑,会独占唯一的工作进程,期间全站请求都要排队【推断】。
argon2__time_cost=3 / memory_cost=64 MiB / parallelism=4(password_utils.py:17-19)total_elapsed=31.501s avg_per_hash=157.51ms,峰值内存约 77MBadmin_parent_seats.py:64: count: int = Field(1, ge=1, le=50) # 平台后台开家长号 50/次
store_subseat.py:36:MAX_PROVISION_PER_CALL = 20 # 门店开家长号 20/次
admin_seats.py:34: count: int = Field(..., ge=1, le=100)
seat_service.py:290 count 必须 1-100 # 底层服务硬顶 100
→ 现在没有任何一条链路能一次开 5 万个。
deploy/docker-compose.prod.yml 顶层 service:postgres / redis / api / web(无 celery)
全仓唯一的 .delay() 调用是导演台 director_studio.py:337(导演台未上生产)。
→ 要把开号挪到后台跑,得先让 root 在生产加一个后台工作进程。这是部署改动【推断,生产是否另有裸进程需 root 核实】。
truncatedorigin/test:app/services/parent_seat_service.py:33:EXPORT_MAX_ROWS = 20000Workbook() 普通模式,整本在内存里建好再返回,没有行数上限生产 users 表 13 条索引,与本题相关的:
users_username_key UNIQUE (username) ← 账号登录
uq_users_phone_parent UNIQUE (phone) WHERE org_seat+parent ← 手机号登录
ix_users_parent_seat_org_created (linked_org_id, created_at) WHERE role='org_seat' AND seat_type='parent'
⚠️ 子代理说「没有 (机构, 创建时间) 复合索引」是错的:该索引由 migrations/versions/phase74_006_exam_image_keys.py:73 用原生 SQL 建立,生产上确实存在。
- 仍然成立:后台搜索用 ILIKE '%x%' 查 username/phone/昵称三列,没有 pg_trgm 索引,每次搜索都要把家长号扫一遍;列表页每次加载打 5 个 COUNT。5 万行时大概在几十毫秒量级,还能接受【推断,未实测】。
origin/test:app/database.py:25: poolclass=NullPool,
deploy/docker-compose.prod.yml: max_connections=50
全仓没有设置 statement_timeout(对照组:create_async_engine 能命中)→ 慢查询没有超时兜底【事实】。
_alloc_org_seq_batch 用一条 INSERT … ON CONFLICT … RETURNING 锁住计数器表里该机构的那一行,随后立即 commit,不会跨越后面的插入循环 → 批量开号期间,同机构其他人开号不会被长时间卡住【推断】。
| 风险 | 程度 | 依据 |
|---|---|---|
| 在网站进程里开 5 万号 → 全站卡 49~131 分钟 | 🔴 高 | A2+A3+A4 |
| 生产没有后台工作进程,要 root 改部署 | 🟠 中 | A6 |
| 子席位导出每个机构只出 2 万行,5 万导不全 | 🟠 中 | A7 |
| 表容量 / 登录索引 | 🟢 撑得住 | A1+A8 |
| 模糊搜索 / 列表统计随数据变慢 | 🟡 低 | A8 |
| 5 万个预开的号大多用不上,却永久占着核心用户表(我没有 DELETE 权限,清理要找 root) | 🟠 中 | A1 |
origin/test:app/api/seat_auth.py:572: if not bound_any: # 首登 → 绑定(unionid 可能 None·公众号未绑开放平台)
origin/test:app/api/seat_auth.py:574: seat.seat_bound_openid = cur_openid
origin/test:app/api/seat_auth.py:589: detail="登录失败,请席位本人登录,感谢",
迁移 phase50_seat_wx_binding.py 的标题原文就是「席位微信身份绑定 · 防共享」。
→ 第二个家长用自己的微信登录同一个号,会直接被拒。
origin/test:app/models/flywheel.py:52: owner_seat_id = Column( # 孩子档案归属 = 家长席位 users.id
origin/test:app/services/refractive_record_service.py:214: own_clause = Patient.owner_seat_id == owner_seat_id
app/api/h5_chat.py:129: return org, f"seat_{seat.id}" # 聊天归属
app/services/parent_quota_service.py:49: H5Report.external_user_id == f"seat_{seat.id}" # 报告/额度
→ 如果多人共用一个号,所有家长会看到彼此孩子的姓名、视力数据、报告和聊天记录,额度也共用一份。这是隐私事故级别的后果。
seat_auth.py:1044 起 seat.password_hash = … / seat.phone = … → 一个人改了,其他人全部登录不了。
seat_auth.py:9「多设备共存 · 不再踢旧设备」。但 B1 那道锁在更前面,别的家长根本走不到这一步。
origin/test:app/services/brand_code_service.py:58:async def _find_existing_family_seat( # 按 机构+微信身份 找回老账号
origin/test:app/services/brand_code_service.py:80:async def _provision_family_seat( # 找不到 → 给这个微信新建 1 个独立家长号并绑定
origin/test:app/services/brand_code_service.py:102: seat_source="brand_code",
同一个微信重复扫不会重复开号;品牌码本身没有名额上限字段(对照组 ACTIVATION_VALID_DAYS = 365 能命中)。
生产现状:brand_codes 0 行 / brand_code_activations 0 行(功能已上线,机构开关全关)。
gift_card|redeem|voucher|兑换码|激活码|coupon 只命中结算模块的「付款凭证 PaymentVoucher」,跟礼品卡无关(对照组 brand_code 命中 4 处)。
机构正式席位统计显式排除家长号(internal_settlement.py 只统计 seat_type IS NULL / 'store');品牌码开通按去重后的微信身份记录在 brand_code_activations。
→ 共用一个号的话,系统没有任何一张表能数清「到底多少个家庭用了卡」,也就没法核销、对账【推断】。
生产真实发生过「拿不到 unionid」(提交 6c9997fd:9-04 72 小时内 22 次 500,后改为 409 unionid_unavailable)。openid 只在同一个公众号内稳定,换到小程序或其他入口就会变(微信官方文档)。在同一个公众号内用 openid 区分家长是稳的;以后要打通小程序/App,就得靠 unionid。
| 场景 | 做法 | 来源 |
|---|---|---|
| Apple 礼品卡/优惠码 | 一次性码,兑换后记到兑换人自己的账号 | https://developer.apple.com/help/app-store-connect/manage-subscriptions/set-up-subscription-offer-codes/ |
| 京东 E 卡 | 发兑换码,用户登录自己的账号后绑卡,绑定后不能解绑 | https://help.jd.com/user/issue/265-261.html |
| 爱奇艺会员激活码 | 16/20 位码,只能用于兑换的那个账号,不能转移或重复用 | https://help.iqiyi.com/m/passport/questionInfo/5026.html |
| 微信卡券 | 平台预生成码池;核销前先查码状态,防止重复核销 | https://developers.weixin.qq.com/doc/offiaccount/Cards_and_Offer/Redeeming_a_coupon_voucher_or_card.html |
| Netflix / Spotify 家庭 | 共享的是访问权,不共享登录;每人独立账号,账单集中付 | https://support.spotify.com/us/article/family-plan/ |
| 共享账号 | 审计无法归因、合规与计费隐患 | https://jumpcloud.com/blog/shared-saas-accounts-and-how-to-pinpoint-them |
| 防暴力枚举 | 限流、锁定、异常监控 | https://owasp.org/www-community/controls/Blocking_Brute_Force_Attacks |
共识:发码不发号。 账号在核销那一刻按真实身份(这里就是微信授权)现建,一张码只绑一个人。
| 对比项 | 原方案:预开 5 万号 | 一个号共用 | 兑换码(建议) |
|---|---|---|---|
| 生成 5 万张卡 | 49~131 分钟,卡全站 | — | 只生成随机码,不算密码,秒级完成【推断】 |
| 核心用户表 | 立刻多出 5 万行,大多用不上 | 1 行 | 只有真正兑换的家长才建号 |
| 明文密码 | 要么存 5 万条明文,要么只能下载一次 | 1 个,人人知道 | 没有密码,家长走微信登录 |
| 隐私 | 独立 ✅ | 🔴 全部串 | 独立 ✅ |
| 统计核销 | 能 | 🔴 不能 | 能:哪张卡、谁、什么时候用的 |
| 要改部署 | 要加后台工作进程(root) | — | 大概率不用【推断】 |
要新做的部分(粗估,尚未写 tech 方案):
① 新建一张兑换码表(码 / 批次 / 所属机构 / 状态:未用·已用·作废 / 兑换人 / 兑换时间 / 有效期);
② 后台「生成一批码 + 导出 xlsx(用 write_only 模式)+ 作废」;
③ 家长扫卡 → 微信授权 → 复用品牌码的「找回或新建独立家长号」→ 核销;
④ 兑换接口限流,防止有人批量猜码。
| 查询 | 走索引 | 耗时 |
|---|---|---|
| 账号登录 username | users_username_key | 0.024ms |
| 手机号登录 | uq_users_phone_parent | 0.047ms |
| 按主键取用户(每个请求鉴权) | users_pkey | 0.016ms |
| 子席位统计 count | ix_users_role | 14.8ms |
| 按机构分组 | ix_users_role | 18.8ms |
| 单机构导出 5 万行 | ix_users_parent_seat_org_created | 21.0ms |
| 明细首页 50 行 | 同上(倒序) | 0.056ms |
| 明细最后一页 | 同上 + 磁盘排序 | 24.1ms |
| 模糊搜索三列 ILIKE | 只能缩小范围后逐行过滤 | 24.6ms |
→ 表撑得住,登录不受影响。
page.tsx:399 = 账号/机构/家长手机/微信昵称/状态/开通/到期/来源 → 没有密码列parent_seat_service.py:33 EXPORT_MAX_ROWS = 20000,单机构截断,没有续拉web/lib/xlsx.ts)seat_service.py:326)batch_id|download_record 0 命中(对照 org_parent_seat_counter 命中)app/utils/wx_crypt.py)seat_auth.py:1052 / 1162 seat.must_change_password = Falseuser_mgmt_service.py:79 只按 is_active 过滤(无索引)→ 会扫全表,约几十毫秒【推断】docker-compose.prod.yml:106),单 worker| 项 | 结果 |
|---|---|
| 算密码 1 线程 | 125.3ms/个,7.98 个/秒 → 5 万个约 104 分钟【外推】 |
| 算密码 2 线程 | 233.1ms/个,8.53 个/秒 → 约 98 分钟【外推】(2 核上开 2 线程几乎不提速) |
| 数据库写入 | 逐条插 2000 行 0.85s(每行 0.425ms)→ 5 万行约 21 秒【外推】(事务 ROLLBACK,前后 46654 行) |
| 生产速度 | 记载 0.059s/个 → 约 49 分钟;⚠️ 本次无法重新核实:生产 users 表有 RLS;account_lifecycle_event 的 seat_added 是 commit 后统一 fire-and-forget 写入(seat_service.py:360-363),时间差不反映算密码耗时 |
开号期间,其他请求的延迟:
| 场景 | 家长登录校验 p50 / p95 / max | 查库 p50 / p95 / max | 事件循环心跳 max |
|---|---|---|---|
| 空闲 | 117 / 123 / 140 ms | 0.65 / 0.95 / 1.4 ms | 26ms |
| 后台 1 线程开号 | 250 / 402 / 610 ms | 0.85 / 1.2 / 6.7 ms | 34ms |
| 后台 2 线程开号 | 692 / 1095 / 1174 ms | 0.88 / 7.7 / 10.2 ms | 301ms |
→ 结论:用 1 个后台线程开号。登录会从约 0.1 秒变成约 0.25 秒(最慢约 0.6 秒),查库基本无感;不要开多线程。建议夜里跑。