交付出件箱

← 返回出件箱

批量开号 5 万(礼品卡)方案复核 · 18 维物理 verify(2026-09-28)

代码基线:origin/test @ bc983dba(2026-09-26);生产库实查走 sudo db-prod(只读 SELECT)。
标注:【事实】= 有命令原样输出;【推断】= 由事实推出、未实测;【外推】= 按实测单次值线性计算。
子代理报告的关键结论由主会话亲自复跑 grep 复核(见各条「主会话复核」)。子代理 1 条错误结论已纠正(见 A8)。


一、结论先行

  1. 数据表撑得住,但「在网站进程里开 5 万号」会卡住全站。5 万行对 PostgreSQL 是小数,可后端只有 1 个工作进程,开号时逐个算密码,要跑 49~131 分钟。这段时间全站所有人的请求都会排队。
  2. 「一个子席位号多人共用」不可行。现有代码第一个微信登录后就锁死这个号,别人登录一律被拒;就算把这道锁拆掉,所有家长的孩子档案、报告、聊天、额度都挂在同一个账号上,会互相看到,属于隐私事故。
  3. 业界通用做法是「发码不发号」(Apple 礼品卡、京东 E 卡、爱奇艺/腾讯会员激活码、微信卡券都是这样):预先只生成兑换码,家长扫码、微信授权的那一刻,再给这个微信单独建账号并核销这张码。
  4. 本系统已有现成底座:品牌码(已上生产)就是「一个码多人扫、每个微信各自自动建独立账号」。在它上面加一层「一卡一码」就是礼品卡,不用预开 5 万个号,也不用存 5 万个明文密码。

二、问题 1:导出/批量开几万,影响其他功能吗?效率?表撑得住吗?

A1 生产数据库现状【事实 · db-prod 实查】

 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 万行属于小表【推断】。

A2 后端只有 1 个工作进程【事实 · 主会话复核】

origin/test:deploy/Dockerfile.api:67:CMD ["uvicorn", "app.main:app", ... "--workers", "1", ...]

gunicorn 全仓 0 命中(对照组:uvicorn 在同文件命中)。

A3 开号时逐个在请求里算密码【事实 · 主会话复核】

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 万个号在网站进程里跑,会独占唯一的工作进程,期间全站请求都要排队【推断】。

A4 算密码的耗时【事实+外推】

A5 现有开号接口的数量上限【事实】

admin_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 万个。

A6 后台任务(Celery)生产上没有工作进程【事实 · 主会话复核】

deploy/docker-compose.prod.yml 顶层 service:postgres / redis / api / web(无 celery)

全仓唯一的 .delay() 调用是导演台 director_studio.py:337(导演台未上生产)。
→ 要把开号挪到后台跑,得先让 root 在生产加一个后台工作进程。这是部署改动【推断,生产是否另有裸进程需 root 核实】。

A7 导出【事实+实测】

A8 索引【事实 · 生产库实查 · 纠正子代理】

生产 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 万行时大概在几十毫秒量级,还能接受【推断,未实测】。

A9 数据库连接【事实】

origin/test:app/database.py:25:    poolclass=NullPool,
deploy/docker-compose.prod.yml: max_connections=50

全仓没有设置 statement_timeout(对照组:create_async_engine 能命中)→ 慢查询没有超时兜底【事实】。

A10 取号锁【事实】

_alloc_org_seq_batch 用一条 INSERT … ON CONFLICT … RETURNING 锁住计数器表里该机构的那一行,随后立即 commit,不会跨越后面的插入循环 → 批量开号期间,同机构其他人开号不会被长时间卡住【推断】。

问题 1 小结

风险 程度 依据
在网站进程里开 5 万号 → 全站卡 49~131 分钟 🔴 高 A2+A3+A4
生产没有后台工作进程,要 root 改部署 🟠 中 A6
子席位导出每个机构只出 2 万行,5 万导不全 🟠 中 A7
表容量 / 登录索引 🟢 撑得住 A1+A8
模糊搜索 / 列表统计随数据变慢 🟡 低 A8
5 万个预开的号大多用不上,却永久占着核心用户表(我没有 DELETE 权限,清理要找 root) 🟠 中 A1

三、问题 2:一个子席位号,靠记住 openid 多人共用,行不行?

B1 现有「防共享」:第一个微信登录后锁死这个号【事实 · 主会话复核】

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 的标题原文就是「席位微信身份绑定 · 防共享」。
→ 第二个家长用自己的微信登录同一个号,会直接被拒。

B2 数据归属键是账号 id,不是 openid【事实 · 主会话复核】

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}"   # 报告/额度

→ 如果多人共用一个号,所有家长会看到彼此孩子的姓名、视力数据、报告和聊天记录,额度也共用一份。这是隐私事故级别的后果。

B3 改密码/手机号改的是同一行【事实】

seat_auth.py:1044 起 seat.password_hash = … / seat.phone = … → 一个人改了,其他人全部登录不了。

B4 多设备登录不会互踢【事实 · 主会话复核】

seat_auth.py:9「多设备共存 · 不再踢旧设备」。但 B1 那道锁在更前面,别的家长根本走不到这一步。

B5 品牌码 = 现成的「一码多人、每人独立账号」【事实 · 主会话复核】

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 行(功能已上线,机构开关全关)。

B6 仓库里还没有兑换码/礼品卡概念【事实】

gift_card|redeem|voucher|兑换码|激活码|coupon 只命中结算模块的「付款凭证 PaymentVoucher」,跟礼品卡无关(对照组 brand_code 命中 4 处)。

B7 统计口径【事实+推断】

机构正式席位统计显式排除家长号(internal_settlement.py 只统计 seat_type IS NULL / 'store');品牌码开通按去重后的微信身份记录在 brand_code_activations。
→ 共用一个号的话,系统没有任何一张表能数清「到底多少个家庭用了卡」,也就没法核销、对账【推断】。

B8 openid 的局限【事实+外部资料】

生产真实发生过「拿不到 unionid」(提交 6c9997fd:9-04 72 小时内 22 次 500,后改为 409 unionid_unavailable)。openid 只在同一个公众号内稳定,换到小程序或其他入口就会变(微信官方文档)。在同一个公众号内用 openid 区分家长是稳的;以后要打通小程序/App,就得靠 unionid。


四、业界通用做法(子代理 WebSearch 实查,附来源)

场景 做法 来源
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 模式)+ 作废」;
③ 家长扫卡 → 微信授权 → 复用品牌码的「找回或新建独立家长号」→ 核销;
④ 兑换接口限流,防止有人批量猜码。


六、需要长官定的事

  1. 同意改走兑换码方案吗?(不预开 5 万号,也不共用一个号)
  2. 卡面印什么:每张卡一个独立二维码(扫码直接兑换,推荐),还是印一串码让家长手动输入?
  3. 一张卡开通多长时间的服务?卡本身有没有兑换截止日期?
  4. 卡要不要绑定某个机构(兑换后家长归这个机构),还是由平台统一发?


七、9-28 追加:长官选定方案 A(预开号·挂所选机构·密码保留可补下载·单次≤5万)· 导出影响复核

C1 数据表实插 5 万行【事实 · 本地 PG 真插,事务内 ROLLBACK,前后 count 均 46654】

查询 走索引 耗时
账号登录 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

→ 表撑得住,登录不受影响。

C2 现有导出链路撑不住 5 万【事实 · 主会话复核】

C3 保存密码 = 从零新建【事实】

C4 其他功能影响【事实+推断】

方案 A 导出的处理方式(建议)

  1. 开号时,把每个号的初始密码加密后存进新的「开号批次」表(一批一条记录 + 每个号一行)
  2. 下载时不再走「JSON → 浏览器拼 Excel」:后台线程用 write_only 模式生成文件(约 8 秒、44MB,不卡站),存成「下载记录」,管理员点下载直接拿文件
  3. 家长首登改密后,该行显示「已激活」,不再给旧密码(旧密码已失效)
  4. 现有「子席位导出」(不含密码、上限 2 万)保持不动

八、9-28 追加:开 5 万个要多久 / 开号期间对其他功能的影响(本机实测 · 2 vCPU)

项 结果
算密码 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 秒),查库基本无感;不要开多线程。建议夜里跑。

附:待 root 核实(不影响结论)