当前系统已支持家长子席位,但现有实现主要面向后台逐条或小批量开通,存在以下问题:
count <= 50,循环单条创建 5 万账号会造成请求超时、数据库压力大、任务中断后难以恢复。User.seat_bound_at 字段已经存在,但明细页面尚未使用。本项目新增“家长子席位礼品卡模式”,支持品牌方:
本次范围分为两部分:
后台新增或改造家长子席位批量开号功能,支持:
原有单次 count <= 50 的同步接口不再作为 5 万生成的主要实现方式。可以保留原接口兼容已有调用,但新增批量任务接口。
seat_bound_at:激活/绑定时间;expires_at:激活时间 + 配置的有效期;沿用现有激活逻辑:
seat_bound_at;本项目不建设品牌方扫码登录入口,不负责生成二维码。
批量生成成功后可导出 Excel,列顺序固定为:
| 列 | 字段 |
|---|---|
| 1 | 账号 |
| 2 | 密码 |
| 3 | 机构名称 |
| 4 | 开通日期 |
其中:
provisioned_at;本期不增加批次号字段和批次号筛选功能。
不同生成任务之间通过以下信息区分:
说明:由于没有批次号,后续若需要精确定位某次生成任务,只能依赖任务记录和时间范围,不能在账号明细中直接按批次筛选。
当前导出逻辑硬编码:
page = 1
size = 200
导致仅导出前 200 条数据。
本次改为:
除原有字段外,新增“绑定时间”列。
建议明细导出字段至少包括:
其中:
User.seat_bound_at;明细页新增以下筛选维度:
用户选择一种时间类型后输入时间范围:
时间类型:开通时间 / 绑定时间 / 到期时间
开始时间:YYYY-MM-DD
结束时间:YYYY-MM-DD
示例:
要求:
>= start 00:00:00< end + 1 day 00:00:00保留三种状态:
支持多选,例如:
本期不单独增加“已绑定”状态。
状态定义保持现有逻辑:
| 状态 | 判定条件 |
|---|---|
| 待绑定 | 未登录或 seat_bound_at IS NULL,且未到期 |
| 使用中 | 已登录或 seat_bound_at IS NOT NULL,且当前未到期 |
| 已到期 | 到期时间已达到或超过当前时间 |
需要明确状态优先级:
如果 expires_at <= now,则为已到期;
否则如果 seat_bound_at IS NULL,则为待绑定;
否则为使用中。
这样可以避免一个已绑定账号到期后仍被显示为“使用中”。
家长子席位从本次需求上线后采用独立账号号码规则,门店员工继续沿用旧规则。
当前门店可能只有几十个员工席位,但家长席位可能达到上万。如果共用全局号码池,员工账号会消耗家长账号可用号码。
现有 4 位序号每月最多 9999 个,无法覆盖单机构超过 9999 的需求。
旧账号已经被印刷、发送或使用,强制修改会导致历史卡片失效、登录失败和售后成本增加。
通过独立前缀和新序号池即可兼容现有登录逻辑,不需要迁移老号。
新账号可以通过固定前缀识别为家长账号,减少账号类型判断歧义。
数据库不保存可逆明文密码,初始密码只在生成任务的导出环节被读取和输出。
但是,5 万账号必须支持任务失败恢复,因此“只返一次”不能简单理解为“生成后任何地方都不保存”。正确做法是:
生成日期只作为开通日期,不作为有效期起算日;有效期从首次激活登录日开始。
礼品卡可能在仓库、门店或经销商处保存数月。如果从生成日计算,家长购卡后可能发现剩余有效期不足,影响销售和使用体验。
本期不增加批次号,按开通日期区分。
该方案能降低开发量,但精确追溯能力较弱。建议至少保留内部生成任务表和任务 ID,虽然不对前台展示批次号,但用于:
本系统不负责二维码内容配置。
不迁移老号
老账号继续按原规则运行。
不新增批次号
不提供批次号筛选。
不支持导出用户当前登录密码
已修改密码的账号无法恢复原始明文密码。
不改变现有激活流程
seat_bound_at。seat_bound_at。新规则需要满足:
推荐格式:
P + 机构ID编码 + 机构内序号
例如采用十进制固定长度:
P001234000001
P001234000002
P00123410000
建议进一步固定长度,例如:
P + 6位机构编码 + 6位机构内序号
示例:
P001234000001
P001234000002
含义:
P:Parent,表示家长席位;001234:不可变机构 ID 的固定长度编码;000001:该机构的家长席位序号。P 前缀;格式:
P + 全局家长序列
例如:
P000000000001
P000000000002
P000000000003
也可以使用 Base36 缩短长度:
P00A1B2C3D4
推荐采用:
P + 固定长度机构ID + 固定长度机构内序号
推荐理由:
P 前缀彻底区分;无论采用哪种规则,必须增加唯一约束:
UNIQUE KEY uk_user_login_account (account)
如果当前账号字段已有唯一约束,应确认:
账号生成不能只依赖代码判断,数据库唯一索引必须作为最终兜底。
不建议继续采用:
HTTP 请求
-> for 循环
-> 单条调用开号逻辑
-> 单条 INSERT
原因:
count <= 50 不适合此场景。流程如下:
后台提交任务
↓
创建 provision_task
↓
立即返回 task_id
↓
消息队列/任务队列消费
↓
按 500~2000 条分块生成
↓
批量 INSERT
↓
更新任务进度
↓
生成导出文件
↓
任务完成
建议新增:
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 仍应保留,用于系统内部追踪。
新增机构家长席位序列表:
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
多个任务并发时:
如果数据库支持独立 Sequence,可为家长账号使用独立序列。
但如果推荐方案是“机构内序号”,则更适合使用机构序列表或号段服务。
每批建议 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);
}
}
注意:
需要同时控制三类并发:
通过机构号段原子分配避免序号重复。
同一任务重复消费
通过数据库行锁或分布式锁保证只有一个消费者处理同一任务。
接口重复提交
request_id + institution_id 建唯一约束;理论上不应发生,但发生时:
通过任务表恢复:
已持久化成功的 offset 不再重复处理;
从 next_offset 继续。
如果采用号段一次性预分配,未使用的号段可以:
如果只保存密码哈希:
password_hash = hash(raw_password)
则无法从哈希恢复初始密码。
因此下面两件事不能同时成立:
可落地的做法是:
生成每条账号时同时得到:
account
raw_password
password_hash
institution_name
provisioned_at
业务表保存:
account
password_hash
institution_id
provisioned_at
导出临时数据保存为加密文件:
encrypted_payload
建议使用:
加密内容可以是 CSV 分片、JSONL 或临时数据库表,不建议直接把明文密码写入普通日志。
1. 创建任务
2. 分配账号号段
3. 生成随机密码
4. 数据库保存密码哈希
5. 临时加密保存账号+明文密码
6. 所有分块完成
7. 生成 XLSX 文件
8. 文件校验行数、列数、账号数
9. 文件上传私有对象存储
10. 更新任务为 SUCCESS
11. 按保留策略删除临时密码包
建议定义为:
如果业务要求“文件只能下载一次”,可以实现一次性下载,但需注意:
更推荐:
保留加密临时凭证包,重新生成 Excel,不重新生成账号和密码。
通过任务状态恢复:
如果业务数据库只有哈希,则无法恢复原密码。
此时只能:
因此临时凭证包必须:
Math.random();建议接口参数:
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
如保留分页参数,也只能用于页面列表,不能限制导出结果。
建议统一字段:
| 业务字段 | 数据字段 |
|---|---|
| 开通日期 | provisioned_at |
| 绑定时间 | seat_bound_at |
| 到期时间 | expires_at |
| 账号 | account |
| 机构名称 | institution.name |
现有 seat_bound_at 已存在,但需要:
需要特别确认:当前 seat_bound_at 是在“账号密码登录成功”时写入,而不是在微信关系真正建立时写入。如果业务定义的“绑定时间”必须是微信绑定时间,则需要先确认数据语义;本期可将其定义为现有激活/绑定时间,避免对历史数据重新解释。
不要拼接动态列名,避免 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 精度问题。
状态是动态计算结果,不建议直接依赖一个可能过期的状态字段。
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。
必须显示为“已到期”,不能显示为“使用中”。
按新规则,待绑定账号通常没有到期时间,因此:
seat_bound_at IS NULL 的账号不应命中任何绑定时间范围。
统一使用:
导出可能持续数秒至数分钟,期间账号可能激活或到期。
推荐在导出任务创建时记录:
snapshot_time
导出状态按该时间计算,保证同一份导出文件内部一致。列表查询则可使用当前时间实时计算。
不建议同步查询后直接在 HTTP 请求内导出 5 万甚至更多数据。
推荐:
提交导出任务
↓
保存完整筛选条件
↓
后台分页读取数据库
↓
流式写入 XLSX
↓
上传对象存储
↓
生成下载地址
实现要点:
LIMIT 50000 OFFSET 100000;id > last_id 读取;建议分页查询:
SELECT ...
FROM parent_seat
WHERE id > :lastId
AND ...
ORDER BY id
LIMIT 1000
根据实际查询频率建立组合索引,至少考虑:
(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)
不要一次性盲目建立过多索引,应结合:
当前“点本周显示全部”的根因是:
start/end 通道。本次必须联调确认:
timeType/startTime/endTime;最终应以接口层、SQL 层和导出文件三处结果一致作为验收标准。