交付出件箱

← 返回出件箱

第一部分:近视防控 SaaS「家长子席位」模块需求立项文档

一、项目背景与目标

1.1 项目背景

当前系统已支持家长子席位,但现有实现主要面向后台逐条或小批量开通,存在以下问题:

  1. 家长账号与门店员工席位共用账号号码池,门店员工账号可能占用家长账号可用号码。
  2. 当前账号规则为“4 位序号 + 年月”,全局按月最多 9999 个,无法满足单机构上万名家长账号的发卡需求。
  3. 现有开通接口单次 count <= 50,循环单条创建 5 万账号会造成请求超时、数据库压力大、任务中断后难以恢复。
  4. 初始密码仅在接口响应中明文返回一次,数据库只保存密码哈希,生成后如果导出失败无法恢复密码。
  5. 明细导出固定为第 1 页、200 条,导致大于 200 条的数据丢失。
  6. 明细页没有真正传递时间范围参数,点击“本周”等筛选条件后后端仍返回全部数据。
  7. 状态目前为三态:待绑定、使用中、已到期;需要支持多选筛选,但不新增“已绑定”状态。
  8. 现有 User.seat_bound_at 字段已经存在,但明细页面尚未使用。

1.2 项目目标

本项目新增“家长子席位礼品卡模式”,支持品牌方:


二、功能范围

本次范围分为两部分:

  1. 批量开号;
  2. 明细导出增强。

2.1 批量开号

2.1.1 批量开号入口

后台新增或改造家长子席位批量开号功能,支持:

原有单次 count <= 50 的同步接口不再作为 5 万生成的主要实现方式。可以保留原接口兼容已有调用,但新增批量任务接口。

2.1.2 账号规则

2.1.3 初始密码

2.1.4 有效期

2.1.5 激活流程

沿用现有激活逻辑:

  1. 家长在微信内打开品牌方提供的登录入口;
  2. 强制进行微信授权,否则返回 403;
  3. 输入礼品卡上的账号和初始密码;
  4. 登录成功;
  5. 写入 seat_bound_at;
  6. 强制修改 6 位密码;
  7. 填写手机号;
  8. 进入正常使用状态。

本项目不建设品牌方扫码登录入口,不负责生成二维码。

2.1.6 导出 Excel

批量生成成功后可导出 Excel,列顺序固定为:

列 字段
1 账号
2 密码
3 机构名称
4 开通日期

其中:

2.1.7 关于批次号

本期不增加批次号字段和批次号筛选功能。

不同生成任务之间通过以下信息区分:

说明:由于没有批次号,后续若需要精确定位某次生成任务,只能依赖任务记录和时间范围,不能在账号明细中直接按批次筛选。


2.2 明细导出增强

2.2.1 导出改为全量

当前导出逻辑硬编码:

page = 1
size = 200

导致仅导出前 200 条数据。

本次改为:

2.2.2 导出字段

除原有字段外,新增“绑定时间”列。

建议明细导出字段至少包括:

其中:

2.2.3 时间范围筛选

明细页新增以下筛选维度:

用户选择一种时间类型后输入时间范围:

时间类型:开通时间 / 绑定时间 / 到期时间
开始时间:YYYY-MM-DD
结束时间:YYYY-MM-DD

示例:

要求:

2.2.4 状态多选

保留三种状态:

  1. 待绑定;
  2. 使用中;
  3. 已到期。

支持多选,例如:

本期不单独增加“已绑定”状态。

状态定义保持现有逻辑:

状态 判定条件
待绑定 未登录或 seat_bound_at IS NULL,且未到期
使用中 已登录或 seat_bound_at IS NOT NULL,且当前未到期
已到期 到期时间已达到或超过当前时间

需要明确状态优先级:

如果 expires_at <= now,则为已到期;
否则如果 seat_bound_at IS NULL,则为待绑定;
否则为使用中。

这样可以避免一个已绑定账号到期后仍被显示为“使用中”。


三、关键决策及理由

3.1 家长账号使用独立号码规则

决策

家长子席位从本次需求上线后采用独立账号号码规则,门店员工继续沿用旧规则。

理由

  1. 避免资源互相挤占

当前门店可能只有几十个员工席位,但家长席位可能达到上万。如果共用全局号码池,员工账号会消耗家长账号可用号码。

  1. 满足单机构上万账号需求

现有 4 位序号每月最多 9999 个,无法覆盖单机构超过 9999 的需求。

  1. 避免历史数据迁移风险

旧账号已经被印刷、发送或使用,强制修改会导致历史卡片失效、登录失败和售后成本增加。

  1. 降低兼容成本

通过独立前缀和新序号池即可兼容现有登录逻辑,不需要迁移老号。

  1. 便于后续识别

新账号可以通过固定前缀识别为家长账号,减少账号类型判断歧义。

3.2 初始密码只返一次

决策

数据库不保存可逆明文密码,初始密码只在生成任务的导出环节被读取和输出。

理由

但是,5 万账号必须支持任务失败恢复,因此“只返一次”不能简单理解为“生成后任何地方都不保存”。正确做法是:

3.3 有效期从激活日计算

决策

生成日期只作为开通日期,不作为有效期起算日;有效期从首次激活登录日开始。

理由

礼品卡可能在仓库、门店或经销商处保存数月。如果从生成日计算,家长购卡后可能发现剩余有效期不足,影响销售和使用体验。

3.4 不新增批次号

决策

本期不增加批次号,按开通日期区分。

影响

该方案能降低开发量,但精确追溯能力较弱。建议至少保留内部生成任务表和任务 ID,虽然不对前台展示批次号,但用于:


四、边界与非目标

4.1 本期范围内

4.2 非目标

  1. 不开发品牌方登录入口
  2. 品牌方自行生成二维码;
  3. 本系统不负责二维码生成;
  4. 本系统不负责二维码内容配置。

  5. 不迁移老号

  6. 老家长账号号码不改;
  7. 老门店员工账号不改;
  8. 老账号继续按原规则运行。

  9. 不新增批次号

  10. 前台不展示批次号;
  11. 不提供批次号筛选。

  12. 不支持导出用户当前登录密码

  13. 只能导出生成时的初始密码;
  14. 已修改密码的账号无法恢复原始明文密码。

  15. 不改变现有激活流程

  16. 仍需要微信授权;
  17. 仍需要账号密码登录;
  18. 仍需修改密码和填写手机号。

五、验收清单

5.1 批量开号

5.2 激活逻辑

5.3 明细查询

5.4 明细导出


第二部分:技术方案

1. 家长账号新号码规则

1.1 设计要求

新规则需要满足:


1.2 候选方案一:固定家长前缀 + 机构 ID + 机构内序号

推荐格式:

P + 机构ID编码 + 机构内序号

例如采用十进制固定长度:

P001234000001
P001234000002
P00123410000

建议进一步固定长度,例如:

P + 6位机构编码 + 6位机构内序号

示例:

P001234000001
P001234000002

含义:

优点

风险与处理


1.3 候选方案二:固定家长前缀 + 全局独立序列

格式:

P + 全局家长序列

例如:

P000000000001
P000000000002
P000000000003

也可以使用 Base36 缩短长度:

P00A1B2C3D4

优点

缺点


1.4 推荐方案

推荐采用:

P + 固定长度机构ID + 固定长度机构内序号

推荐理由:

  1. 更符合家长卡按机构发放的业务模式;
  2. 单机构容量足够大;
  3. 与旧账号通过 P 前缀彻底区分;
  4. 机构内连续编号,方便盘点和客服排查;
  5. 机构之间生成任务互不影响;
  6. 未来可扩展到每机构几十万甚至百万账号。

1.5 数据库约束

无论采用哪种规则,必须增加唯一约束:

UNIQUE KEY uk_user_login_account (account)

如果当前账号字段已有唯一约束,应确认:

账号生成不能只依赖代码判断,数据库唯一索引必须作为最终兜底。


2. 一次生成 5 万账号的可靠实现

2.1 不建议的实现

不建议继续采用:

HTTP 请求
  -> for 循环
      -> 单条调用开号逻辑
          -> 单条 INSERT

原因:


2.2 推荐架构:异步任务 + 分块事务 + 数据库号段

流程如下:

后台提交任务
    ↓
创建 provision_task
    ↓
立即返回 task_id
    ↓
消息队列/任务队列消费
    ↓
按 500~2000 条分块生成
    ↓
批量 INSERT
    ↓
更新任务进度
    ↓
生成导出文件
    ↓
任务完成

2.3 任务表

建议新增:

parent_seat_provision_task

主要字段:

id
institution_id
requested_count
success_count
failed_count
status
current_offset
file_id
error_message
created_by
created_at
started_at
finished_at

状态建议:

PENDING
RUNNING
SUCCESS
PARTIAL_FAILED
FAILED
CANCELLED

尽管前台不展示“批次号”,任务表中的 task_id 仍应保留,用于系统内部追踪。


2.4 序号分配方案

方案 A:机构序列表

新增机构家长席位序列表:

institution_parent_seat_sequence

字段:

institution_id
next_value

获取号段时执行原子更新:

UPDATE institution_parent_seat_sequence
SET next_value = next_value + :count
WHERE institution_id = :institution_id;

通过事务或数据库返回更新前后的值,得到连续号段:

start = old_next_value
end   = old_next_value + count - 1

多个任务并发时:

方案 B:数据库 Sequence

如果数据库支持独立 Sequence,可为家长账号使用独立序列。

但如果推荐方案是“机构内序号”,则更适合使用机构序列表或号段服务。

2.5 批量 INSERT

每批建议 500~2,000 条,具体根据数据库和字段数量压测确定。

伪代码:

task = createTask(institutionId, count);

range = sequenceService.allocate(institutionId, count);

for (int offset = 0; offset < count; offset += CHUNK_SIZE) {
    int chunkCount = min(CHUNK_SIZE, count - offset);

    List<SeatRecord> records = new ArrayList<>();

    for (int i = 0; i < chunkCount; i++) {
        seq = range.start + offset + i;
        account = buildParentAccount(institutionId, seq);
        rawPassword = randomPassword();
        passwordHash = hash(rawPassword);

        records.add(
            buildSeat(account, passwordHash, rawPassword)
        );
    }

    transaction {
        batchInsert(records);
        saveEncryptedExportPayload(records);
        updateTaskProgress(chunkCount);
    }
}

注意:

2.6 并发控制

需要同时控制三类并发:

  1. 同一机构多个开号任务
  2. 通过机构号段原子分配避免序号重复。

  3. 同一任务重复消费

  4. 使用任务状态锁;
  5. 消息消费增加幂等键;
  6. 通过数据库行锁或分布式锁保证只有一个消费者处理同一任务。

  7. 接口重复提交

  8. 前端提交请求携带幂等键;
  9. 后端对 request_id + institution_id 建唯一约束;
  10. 重复请求返回原任务 ID,不重复开号。

2.7 失败处理

分块插入失败

数据库唯一冲突

理论上不应发生,但发生时:

任务进程宕机

通过任务表恢复:

已持久化成功的 offset 不再重复处理;
从 next_offset 继续。

如果采用号段一次性预分配,未使用的号段可以:


3. 明文密码只存在一次及失败恢复

3.1 核心矛盾

如果只保存密码哈希:

password_hash = hash(raw_password)

则无法从哈希恢复初始密码。

因此下面两件事不能同时成立:

  1. 明文密码生成后完全不保存;
  2. 导出失败后还能保证原账号密码可恢复。

可落地的做法是:

3.2 推荐方案:加密临时凭证包

生成每条账号时同时得到:

account
raw_password
password_hash
institution_name
provisioned_at

业务表保存:

account
password_hash
institution_id
provisioned_at

导出临时数据保存为加密文件:

encrypted_payload

建议使用:

加密内容可以是 CSV 分片、JSONL 或临时数据库表,不建议直接把明文密码写入普通日志。

3.3 生成和导出流程

1. 创建任务
2. 分配账号号段
3. 生成随机密码
4. 数据库保存密码哈希
5. 临时加密保存账号+明文密码
6. 所有分块完成
7. 生成 XLSX 文件
8. 文件校验行数、列数、账号数
9. 文件上传私有对象存储
10. 更新任务为 SUCCESS
11. 按保留策略删除临时密码包

3.4 关于“只返一次”

建议定义为:

如果业务要求“文件只能下载一次”,可以实现一次性下载,但需注意:

更推荐:

3.5 导出失败如何处理

情况一:账号已生成,Excel 尚未生成

保留加密临时凭证包,重新生成 Excel,不重新生成账号和密码。

情况二:Excel 生成中途失败

情况三:Excel 已生成但上传失败

情况四:任务执行中途服务宕机

通过任务状态恢复:

情况五:临时密码包损坏或丢失

如果业务数据库只有哈希,则无法恢复原密码。

此时只能:

  1. 将任务标记为不可恢复;
  2. 对尚未发卡的账号执行作废或删除;
  3. 重新生成一批新的账号密码;
  4. 不能静默修改原账号密码,否则可能导致已印刷卡片与系统密码不一致。

因此临时凭证包必须:

3.6 密码安全要求


4. 明细导出、时间筛选和状态多选方案

4.1 查询参数设计

建议接口参数:

GET /api/admin/parent-seats

参数:

institutionId
status[]              // PENDING_BIND, USING, EXPIRED
timeType              // PROVISIONED, BOUND, EXPIRED
startTime
endTime
keyword
page
size

导出接口:

POST /api/admin/parent-seats/export

请求体:

{
  "institutionId": 1001,
  "statuses": ["PENDING_BIND", "USING"],
  "timeType": "BOUND",
  "startTime": "2025-01-01 00:00:00",
  "endTime": "2025-01-31 23:59:59",
  "keyword": ""
}

导出接口不要接收或依赖前端的:

page=1
size=200

如保留分页参数,也只能用于页面列表,不能限制导出结果。

4.2 字段映射

建议统一字段:

业务字段 数据字段
开通日期 provisioned_at
绑定时间 seat_bound_at
到期时间 expires_at
账号 account
机构名称 institution.name

现有 seat_bound_at 已存在,但需要:

需要特别确认:当前 seat_bound_at 是在“账号密码登录成功”时写入,而不是在微信关系真正建立时写入。如果业务定义的“绑定时间”必须是微信绑定时间,则需要先确认数据语义;本期可将其定义为现有激活/绑定时间,避免对历史数据重新解释。

4.3 时间筛选实现

不要拼接动态列名,避免 SQL 注入。后端将 timeType 映射到固定字段:

switch (timeType) {
    case PROVISIONED:
        column = "provisioned_at";
        break;
    case BOUND:
        column = "seat_bound_at";
        break;
    case EXPIRED:
        column = "expires_at";
        break;
    default:
        throw new IllegalArgumentException();
}

SQL 条件建议:

column >= :startTime
AND column < :endTimeExclusive

例如用户选择:

2025-01-01 至 2025-01-31

后端转换为:

startTime = 2025-01-01 00:00:00
endTimeExclusive = 2025-02-01 00:00:00

这样可以避免 23:59:59.999 精度问题。

4.4 状态查询实现

状态是动态计算结果,不建议直接依赖一个可能过期的状态字段。

SQL 逻辑可表达为:

CASE
    WHEN expires_at IS NOT NULL
         AND expires_at <= CURRENT_TIMESTAMP
        THEN 'EXPIRED'
    WHEN seat_bound_at IS NULL
        THEN 'PENDING_BIND'
    ELSE 'USING'
END

多选本质是 OR 条件:

(
    status = 'PENDING_BIND'
    OR status = 'USING'
)

但不同数据库对 CASE 查询和索引支持不同,实际落地建议改写为业务条件:

seat_bound_at IS NULL
AND (expires_at IS NULL OR expires_at > :now)
seat_bound_at IS NOT NULL
AND (expires_at IS NULL OR expires_at > :now)
expires_at IS NOT NULL
AND expires_at <= :now

多选时按选中的条件组装为 OR。

4.5 状态筛选的坑

坑一:已绑定但已到期

必须显示为“已到期”,不能显示为“使用中”。

坑二:待绑定账号的到期时间

按新规则,待绑定账号通常没有到期时间,因此:

坑三:按绑定时间筛选未绑定账号

seat_bound_at IS NULL 的账号不应命中任何绑定时间范围。

坑四:数据库时间和应用时间不一致

统一使用:

坑五:导出时状态发生变化

导出可能持续数秒至数分钟,期间账号可能激活或到期。

推荐在导出任务创建时记录:

snapshot_time

导出状态按该时间计算,保证同一份导出文件内部一致。列表查询则可使用当前时间实时计算。

4.6 全量导出实现

不建议同步查询后直接在 HTTP 请求内导出 5 万甚至更多数据。

推荐:

提交导出任务
    ↓
保存完整筛选条件
    ↓
后台分页读取数据库
    ↓
流式写入 XLSX
    ↓
上传对象存储
    ↓
生成下载地址

实现要点:

建议分页查询:

SELECT ...
FROM parent_seat
WHERE id > :lastId
  AND ...
ORDER BY id
LIMIT 1000

4.7 索引建议

根据实际查询频率建立组合索引,至少考虑:

(institution_id, provisioned_at, id)
(institution_id, seat_bound_at, id)
(institution_id, expires_at, id)
(institution_id, id)

状态筛选如果完全依赖 seat_bound_at、expires_at 动态组合,可能需要根据压测结果增加:

(institution_id, expires_at, seat_bound_at, id)

不要一次性盲目建立过多索引,应结合:

4.8 前后端联调重点

当前“点本周显示全部”的根因是:

本次必须联调确认:

最终应以接口层、SQL 层和导出文件三处结果一致作为验收标准。