取号管理(开发视角)
适用端:后端 + Web | 类型:机制
定制功能
取号管理(代码里叫「单据模板」,bill-template)是本项目自研能力,上游 yudao 没有对应模块。
取号管理负责生成单据编号 —— SO-20261016-0001 这种。它的难点从来不在拼字符串,而在并发下不重号、周期切换不靠定时任务、失败时不归还号。本页把这三个决定讲透。
前置阅读:数据库约定与表总览(了解 BaseDO 与租户列)。
它解决什么问题
代码在哪
后端前缀 enlev-module-infra/src/main/java/cn/enlev/ainb/module/infra/,前端前缀 ainb-ui-admin-vue3/src/:
| 职责 | 文件 | 关键方法 |
|---|---|---|
| HTTP 入口 | controller/admin/billtemplate/BillTemplateController.java | /infra/bill-template,11 个端点 |
| 编排 | service/billtemplate/BillTemplateServiceImpl.java | generateInternal:201、doGenerate:258、occupy:312、saveGenerateLog:358 |
| 拼号纯算法 | framework/billtemplate/core/BillNumberGenerator.java | generate:61、sequencePart:107、groupKey:148 |
| 规格归一层 | framework/billtemplate/core/BillNumberSpec.java | of(DO):101、needSequence:121、ttl:135 |
| 原子自增 | dal/redis/billtemplate/BillTemplateRedisDAO.java | nextSequence:53、buildNoKey:117、tenantId:132 |
| 内联规则求值 | framework/billtemplate/core/{InlineRuleEvaluator, InlineRuleConfig}.java | evaluate:51、resolveSegment:108 |
| 表单字段识别 | framework/billtemplate/core/FormBillFieldResolver.java | COMPONENT_TYPE:43、resolve:93 |
| 保存时填号 | framework/billtemplate/core/BillTemplateFormFiller.java | fill:54、acceptOccupied:170 |
| 撞号重试 | service/formdesign/runtime/FormDesignRuntimeServiceImpl.java | create:339、BILL_NUMBER_MAX_ATTEMPTS:366 |
| 发布校验 | service/formdesign/FormDesignPublishValidator.java | validateInlineRule:583 |
| 跨模块 API | api/billtemplate/{BillTemplateApi, BillTemplateApiImpl}.java | generate(code)、preview(code) |
| DO / Mapper | dal/dataobject/billtemplate/{BillTemplateDO, BillTemplateLogDO}.java、dal/mysql/billtemplate/{BillTemplateMapper, BillTemplateLogMapper}.java | updateStatistics:42 |
| 枚举 | enums/billtemplate/{BillGenTypeEnum, BillDateFormats, ResetCycleEnum, SegmentSourceEnum}.java | — |
| 前端管理页 | views/infra/billTemplate/{index, BillTemplateForm, BillTemplateLogList, BillTemplateTestPanel}.vue、constants.ts | — |
| 前端 API | api/infra/billTemplate/index.ts | occupyBillNo:188、previewBillTemplate:178 |
| 前端表单控件 | components/FormCreate/src/components/{BillNumberInput, BillInlineRuleEditor, BillInlineRuleSegmentList}.vue、config/useBillFieldRule.ts | — |
核心机制
规则不是占位符字符串,是结构化列
没有 {yyyy}{MM}{dd}{seq} 这种 pattern 字段。号码 = prefix + 主体 + suffix,主体按 gen_type 分三种(BillGenTypeEnum):
| gen_type | 主体 | 相关列 |
|---|---|---|
| 1 时间流水 | 日期段 + 左补零流水 | date_format(白名单 BillDateFormats.ALL,NONE 表示无日期段)、digit、start_number、step |
| 2 随机数 | 随机串 | random_digit、random_type |
| 3 UUID | 36 位 UUID | — |
流水值的换算是 start + (seq-1) * step(BillNumberGenerator.java:112)。
另有内联模式:规则挂在表单字段的 ruleConfig 上,前后缀由「段」列表拼成,段来源 = 常量 / 表单字段值(SegmentSourceEnum:FIELD=1、CONSTANT=2)。典型场景 客户编号-20260906-001(InlineRuleConfig.java 类注释)。两种模式最终都归一到 BillNumberSpec,共用同一套拼号代码(BillNumberSpec.java:18-30)。
并发安全:Redis INCR 是权威计数,数据库唯一索引 + 有限重试是兜底
没有悲观锁、没有乐观锁版本号、没有号段批量预取。 BillTemplateRedisDAO.java:53-62 逐字:
public long nextSequence(String scope, String group, String period, Duration ttl) {
String key = buildNoKey(scope, group, period);
Long seq = stringRedisTemplate.opsForValue().increment(key);
if (ttl != null) {
// 每次都续期而不是只在首次创建时设置:若进程在 INCR 与 EXPIRE 之间崩溃,
// 只设一次会留下永不过期的 key。重复续期的代价仅仅是 key 多活一个周期
stringRedisTemplate.expire(key, ttl);
}
return seq != null ? seq : 1L;
}计数 key 结构(:117-119,前缀见 dal/redis/RedisKeyConstants.java:24):
infra:bill_template:no:{tenantId}:{scope}:{group}:{period}scope:模板模式 = 模板code;内联模式 =inline:{表单编码}:{字段}(InlineRuleEvaluator.java:36,56)group:不分组时固定_;group_by_prefix=true时取md5(prefix)前 8 位(BillNumberGenerator.java:41,148-157)。用哈希是为了防止前缀里的冒号污染 key 分段。
为什么不用数据库列自增,理由写在 BillTemplateRedisDAO.java:14-18:
把当前流水存在模板表的某一列上、靠 UPDATE 递增,会让同一模板的所有并发请求都去抢这一行的行锁。出号是业务保存路径上的必经步骤,这里排队会直接拖慢整个表单提交。Redis
INCR是单线程原子操作,天然无锁且不会重号。
infra_bill_template.current_number 只是展示值 —— 出号后旁路回写、失败只打 WARN(BillTemplateServiceImpl.java:278-289;BillTemplateDO.java:24-28)。看到它和真实流水不一致不是 bug。
第二道防线:目标列唯一索引撞车时重取号重试,且每次尝试必须新开事务(事务内重试会被 rollback-only 标记吃掉)。FormDesignRuntimeServiceImpl.java:346-355 逐字:
try {
return transactionTemplate.execute(
status -> createInTransaction(code, attemptData, filled));
} catch (DuplicateKeyException e) {
if (!filled.value || attempt >= BILL_NUMBER_MAX_ATTEMPTS) {
throw e;
}
log.warn("[create][表单({})的单据编号撞上唯一索引,第 {} 次重取号重试]",
code, attempt, e);
}BILL_NUMBER_MAX_ATTEMPTS = 3(:366);filled.value == false(这张表单没填号)时不重试 —— 冲突必来自别的唯一索引,重试只会白跑三次(:350、BillTemplateFormFiller.java:50-53)。每次尝试用数据副本 copyForAttempt(data)(:344),避免看到上一次已作废的号。
周期切换不靠定时任务
period 段编进 Redis key。跨天时 key 变新、INCR 从 1 开始,旧 key 靠 TTL 自然消亡(BillTemplateRedisDAO.java:20-22)。没有任何 job 参与。
TTL = 周期 + 富余(ResetCycleEnum.java:43-47,68-75):
| reset_cycle | TTL |
|---|---|
| DAY | 2 天 |
| MONTH | 40 天 |
| YEAR | 400 天 |
| NONE | 永不过期 |
AUTO 按 date_format 里出现的最小单位解析:含 dd → DAY、含 MM → MONTH、含 yyyy → YEAR、都不含 → NONE(:102-119)。刻意不解析已生成号码的字符串形态(:19-21)—— 那样会把格式变更和周期判定耦在一起。
跳号是有意的,号不归还
BillTemplateRedisDAO.java:24-27、BillTemplateApi.java:11-13、FormDesignRuntimeServiceImpl.java:439-442 三处都写了这条纪律:
号在事务提交前就已消耗,若后续 SQL 失败导致事务回滚,该号即跳过。不做「回滚归还」—— 归还在并发下会导致两个请求拿到同一个号,重号的代价远大于跳号。
预占(表单打开时先给用户看的号)TTL 30 分钟(OCCUPY_TTL,BillTemplateRedisDAO.java:39),过期即作废、无回收。核销只在号码一致时删 key,防多标签页误删(:106-113)。同一用户同一 scope 重复调用 occupy 复用已有号、不烧号也不记日志(BillTemplateServiceImpl.java:315-320)。
没有号段(批量预取)概念,也没有作废号回收表 —— 全仓「号段」一词只出现在 agent 模块的错误码注释里。
位数溢出不截断
BillNumberGenerator.java:107-120:流水超出 digit 位时直接输出更长的号码并打 WARN,不截断。因为截断会让 9999 与 19999 撞号 —— 号码变长是可见的,重号是致命的。
出号调用顺序
FormDesignRuntimeServiceImpl.create:339
→ createInTransaction
→ BillTemplateFormFiller.fill:443
├─ 主表 fillMainField:80(先 acceptOccupied 判预占,命中则核销并沿用;否则 generate)
└─ 子表 fillSubTableField:103(逐行独立生成,put 覆盖而非判空,:118-124)
→ BillTemplateServiceImpl.generate:194 → generateInternal:201
→ doGenerate:258(仅 needSequence() 才 INCR)
→ BillNumberGenerator.generate
→ writeBackStatistics:278(旁路) + saveGenerateLog:358(独立事务)出号方法本身不加 @Transactional —— 回写只是展示值,不该拖垮调用方业务事务;真正需要事务的是调用方(BillTemplateServiceImpl.java:49-52)。
数据模型
infra_bill_template
DDL ainb/sql/mysql/ainb-init.sql:13864。唯一索引 uk_tenant_code(tenant_id, code);普通索引 idx_category(category)。
- 规则列:
gen_type、prefix、suffix、date_format、digit、start_number、step、reset_cycle、group_by_prefix、random_digit、random_type - 展示/审计列:
example(保存时算好)、last_no、last_period、current_number(权威值在 Redis)、log_enabled、status、sort
code 是租户内唯一,不同租户可重码;表单与其它模块通过 code 引用。
infra_bill_template_log
DDL SQL:13931。无唯一索引;idx_template_code(template_code, create_time)、idx_create_time(create_time)。
- 快照列
gen_type/period/seq记的是取号当时的规则与进度 —— 模板日后改了仍能还原(BillTemplateLogDO.java:19-22) source:1 业务出号 / 2 表单预占 / 3 管理页测试取号(BillTemplateServiceImpl.java:69,73,77)success、error_msg(截断 500 字符,:81,373,383)、user_id
为什么单独一张表:日志必须在独立事务里提交,否则调用方业务回滚会把「回滚导致的跳号」这条最该留痕的记录一起抹掉(BillTemplateServiceImpl.java:350-357,PROPAGATION_REQUIRES_NEW 见 :375-377),且写失败要吞掉不影响主流程(:378-380)。仅 log_enabled=1 才写(:360)。
接口清单
权限串与摘要逐字取自 BillTemplateController.java 注解:
| 方法 | 路径 | 权限串 | 摘要 |
|---|---|---|---|
| POST | /infra/bill-template/create | infra:bill-template:create | 创建 |
| PUT | /infra/bill-template/update | infra:bill-template:update | 更新 |
| DELETE | /infra/bill-template/delete | infra:bill-template:delete | 删除(已生成过单号的模板不允许删) |
| PUT | /infra/bill-template/update-status | infra:bill-template:update | 启用/停用 |
| GET | /infra/bill-template/get | infra:bill-template:query | 获得单个 |
| GET | /infra/bill-template/page | infra:bill-template:query | 分页 |
| GET | /infra/bill-template/simple-list | 无 @PreAuthorize(:134-139 有理由注释) | 精简列表(只返回已启用的,供表单设计器下拉) |
| POST | /infra/bill-template/preview | infra:bill-template:query | 试算范例(不消耗流水) |
| POST | /infra/bill-template/test | infra:bill-template:query | 测试取号(模拟/真实两模式) |
| POST | /infra/bill-template/occupy | 无 @PreAuthorize | 预占一个单号 |
| GET | /infra/bill-template/log-page | infra:bill-template:query | 取号日志分页 |
刻意不开放「直接生成单号」的 HTTP 接口
理由写在 BillTemplateController.java:28-34:生成会消耗流水,开放等于把流水号交给任何能开浏览器控制台的人去刷。前端拿号只有幂等的 occupy;程序化出号走 BillTemplateApi(不经 HTTP)。
怎么扩展
新增一种占位符(例如「按部门编码」)
enums/billtemplate/SegmentSourceEnum.java加枚举值。⚠️typeOf兜底返回CONSTANT(:49-53),脏数据会按字面值输出而不报错。- 🔴
framework/billtemplate/core/InlineRuleEvaluator.java:108-122 resolveSegment加分支。若取值需要当前用户/租户上下文(部门编码通常需要),现有签名evaluate(formCode, field, config, rowData, mainData)传不进来,得引入一个求值上下文参数 —— 这是改动量最大的一处。 framework/billtemplate/core/InlineRuleConfig.java的RuleSegment:若需额外参数(如部门层级)要加字段。service/formdesign/FormDesignPublishValidator.java:583 validateInlineRule:新来源不属于FIELD,要确保跳过「引用字段必须存在」的校验(现有逻辑只校验SegmentSourceEnum.FIELD)。- 前端
components/FormCreate/src/components/BillInlineRuleSegmentList.vue(sourceType下拉 + 对应value控件,见:27-55)与BillInlineRuleEditor.vue。 - 补
src/test/java/.../framework/billtemplate/core/InlineRuleEvaluatorTest.java。
若走模板模式:prefix / suffix 是纯字符串列,没有任何占位符语法可扩展,只能改 BillNumberGenerator.java:63-66 的拼接逻辑。
测试
7 个测试类,共 84 个 @Test:
| 测试类 | 数量 | 覆盖 |
|---|---|---|
BillNumberGeneratorTest | 15 | 前后缀+日期+补零、NONE、各粒度 pattern、起始值/步长换算、位数溢出不截断、随机/UUID、groupKey 分组与冒号安全 |
InlineRuleEvaluatorTest | 16 | 段求值、字段作用域(子表回退主表)、parse 脏数据返 null |
FormBillFieldResolverTest | 19 | 递归识别布局容器内的单据组件、子表归属判定 |
ResetCycleEnumTest | 10 | AUTO 解析、period/ttl |
BillTemplateRedisDAOTest | 13 | 递增、周期/分组/作用域隔离、TTL、预占隔离与核销、并发不重号 |
BillTemplateServiceImplTest | 6 | 取号日志:来源区分、预占复用不记日志、内联不记日志 |
BillNumberRetryTest | 5 | 新事务重试、重取号、没填号不重试、子表数据副本 |
并发不重号的实测证据在 BillTemplateRedisDAOTest.java:104-133:32 线程池提交 200 个任务、CountDownLatch 同时开跑,断言结果集恰为 1..200 且无重复。
缺口:无 BillTemplateController 层测试;前端 58 个 .spec.ts 里没有一个覆盖 billTemplate / BillNumberInput。
坑与注意
- 菜单叫「取号管理」,代码/实体/权限串仍叫「单据模板」 —— 刻意分歧(
BillTemplateController.java:36-41):菜单名说用户干什么,代码名说这张表是什么。infra:bill-template:*一改,已授出的权全部失效。菜单树:基础设施(/infra, id 2) → 数据管理(data-manage, id 9000050902) → 取号管理(bill-template, id 2572, componentinfra/billTemplate/index)(SQL:33244,33919,33612)。 update显式丢弃前端传来的currentNumber/lastNo/lastPeriod,否则前端能篡改流水进度(BillTemplateServiceImpl.java:121-125)。updateStatistics是只碰三列的定点 UPDATE,不用updateById(整个 DO)—— 整对象更新会把并发请求各自读到的旧配置写回,覆盖管理员刚改的配置(BillTemplateMapper.java:35-41)。- 模拟取号(
test?real=false)只读干跑:不 INCR、不回写、不记日志(:231-244)。严格取号场景下测试绝不能烧号。 - 前端不自己算号,预览一律走后端
preview,避免两侧算法漂移(BillNumberGenerator.java:159-167);resetCycleActual也在后端解析(BillTemplateController.java:124-132)。 - 🔴
FormBillFieldResolver的缓存键曾经有洞:原来是「表单编码:版本号」,而发布校验拿到的版本号是 N、要校验的却是即将成为 N+1 的内容,导致「配好了模板仍报未绑定,直到重启」。现改为只用表单编码做键、命中后比对formFields原文(:56-78,95-99)。 - 单据组件名
BillNumberInput前后端必须一致:后端靠它在 form-create 规则里认字段(FormBillFieldResolver.java:41-43、BillNumberInput.vue:25-26)。 - 内联规则引用的字段没填值时输出空串而不报错(号会短一截但单子能存进去,
InlineRuleEvaluator.java:114-120);这类问题只在发布校验拦。 - UUID 方式主体 36 位,加前后缀很容易超
varchar(64)(BillNumberGenerator.java:80-82)。 - 预占失败前端静默降级为「保存后自动生成」,不挡用户填表(
BillNumberInput.vue:99-104)。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
未证实
- 🔴
BillTemplateApi当前没有任何调用方。 全仓(排除target/)grepBillTemplateApi只命中它自己包内的 3 个文件。接口注释说「供其它业务模块(erp / mes / wms 等)程序化出号,替代各模块自己写死的XxxNoRedisDAO」(BillTemplateApi.java:5-7),但这些模块在本仓不存在 —— 它是预留扩展点,尚未接线。 - 工作流实例号不走取号管理:
enlev-module-bpm内没有任何对BillTemplateService/BillTemplateApi的引用。 POST /occupy没有@PreAuthorize,也没有像simple-list那样的理由注释(对比:134-139vs:168-175)。类注释只说它是「前端拿号的唯一路径、幂等不烧号」,但严格说它对同一用户同一 scope 才幂等。是刻意还是遗漏,源码里没有说明。SQL:29324-29327里infra:bill-template:*四条sys_resource记录的deleted = b'1',而同批的infra:portal:*(SQL:29316-29323)是b'0';对应的system_menu2573-2576 则是b'0'。@ss.hasPermission的实际解析源在enlev-spring-boot-starter-security/.../SecurityFrameworkServiceImpl.java,未展开读,因此这批软删记录对鉴权的实际影响未证实。group_by_prefix用 md5 前 8 位做分组 key,源码承认「即便碰撞,后果也只是两个前缀共用一个计数器(号码依然唯一,只是流水不连续)」(BillNumberGenerator.java:36-40)—— 碰撞概率未实测。- 号段(批量预取)、作废号回收、跨天补号:代码里不存在,不是「没找到」。
相关页面
- 表单设计后端 —— 发布校验里的内联规则检查
- 表单运行时与扩展点 ——
create的三次重试在运行时链路的哪一步 - 数据模型 —— 单据编号列的类型与唯一索引约束
- 使用手册 02 基础设施 · 取号管理 —— 界面上长什么样
