Skip to content

取号管理(开发视角) ​

适用端:后端 + 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.javagenerateInternal:201、doGenerate:258、occupy:312、saveGenerateLog:358
拼号纯算法framework/billtemplate/core/BillNumberGenerator.javagenerate:61、sequencePart:107、groupKey:148
规格归一层framework/billtemplate/core/BillNumberSpec.javaof(DO):101、needSequence:121、ttl:135
原子自增dal/redis/billtemplate/BillTemplateRedisDAO.javanextSequence:53、buildNoKey:117、tenantId:132
内联规则求值framework/billtemplate/core/{InlineRuleEvaluator, InlineRuleConfig}.javaevaluate:51、resolveSegment:108
表单字段识别framework/billtemplate/core/FormBillFieldResolver.javaCOMPONENT_TYPE:43、resolve:93
保存时填号framework/billtemplate/core/BillTemplateFormFiller.javafill:54、acceptOccupied:170
撞号重试service/formdesign/runtime/FormDesignRuntimeServiceImpl.javacreate:339、BILL_NUMBER_MAX_ATTEMPTS:366
发布校验service/formdesign/FormDesignPublishValidator.javavalidateInlineRule:583
跨模块 APIapi/billtemplate/{BillTemplateApi, BillTemplateApiImpl}.javagenerate(code)、preview(code)
DO / Mapperdal/dataobject/billtemplate/{BillTemplateDO, BillTemplateLogDO}.java、dal/mysql/billtemplate/{BillTemplateMapper, BillTemplateLogMapper}.javaupdateStatistics:42
枚举enums/billtemplate/{BillGenTypeEnum, BillDateFormats, ResetCycleEnum, SegmentSourceEnum}.java—
前端管理页views/infra/billTemplate/{index, BillTemplateForm, BillTemplateLogList, BillTemplateTestPanel}.vue、constants.ts—
前端 APIapi/infra/billTemplate/index.tsoccupyBillNo: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 UUID36 位 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 逐字:

java
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 逐字:

java
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_cycleTTL
DAY2 天
MONTH40 天
YEAR400 天
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/createinfra:bill-template:create创建
PUT/infra/bill-template/updateinfra:bill-template:update更新
DELETE/infra/bill-template/deleteinfra:bill-template:delete删除(已生成过单号的模板不允许删)
PUT/infra/bill-template/update-statusinfra:bill-template:update启用/停用
GET/infra/bill-template/getinfra:bill-template:query获得单个
GET/infra/bill-template/pageinfra:bill-template:query分页
GET/infra/bill-template/simple-list无 @PreAuthorize(:134-139 有理由注释)精简列表(只返回已启用的,供表单设计器下拉)
POST/infra/bill-template/previewinfra:bill-template:query试算范例(不消耗流水)
POST/infra/bill-template/testinfra:bill-template:query测试取号(模拟/真实两模式)
POST/infra/bill-template/occupy无 @PreAuthorize预占一个单号
GET/infra/bill-template/log-pageinfra:bill-template:query取号日志分页

刻意不开放「直接生成单号」的 HTTP 接口

理由写在 BillTemplateController.java:28-34:生成会消耗流水,开放等于把流水号交给任何能开浏览器控制台的人去刷。前端拿号只有幂等的 occupy;程序化出号走 BillTemplateApi(不经 HTTP)。

怎么扩展 ​

新增一种占位符(例如「按部门编码」) ​

  1. enums/billtemplate/SegmentSourceEnum.java 加枚举值。⚠️ typeOf 兜底返回 CONSTANT(:49-53),脏数据会按字面值输出而不报错。
  2. 🔴 framework/billtemplate/core/InlineRuleEvaluator.java:108-122 resolveSegment 加分支。若取值需要当前用户/租户上下文(部门编码通常需要),现有签名 evaluate(formCode, field, config, rowData, mainData) 传不进来,得引入一个求值上下文参数 —— 这是改动量最大的一处。
  3. framework/billtemplate/core/InlineRuleConfig.java 的 RuleSegment:若需额外参数(如部门层级)要加字段。
  4. service/formdesign/FormDesignPublishValidator.java:583 validateInlineRule:新来源不属于 FIELD,要确保跳过「引用字段必须存在」的校验(现有逻辑只校验 SegmentSourceEnum.FIELD)。
  5. 前端 components/FormCreate/src/components/BillInlineRuleSegmentList.vue(sourceType 下拉 + 对应 value 控件,见 :27-55)与 BillInlineRuleEditor.vue。
  6. 补 src/test/java/.../framework/billtemplate/core/InlineRuleEvaluatorTest.java。

若走模板模式:prefix / suffix 是纯字符串列,没有任何占位符语法可扩展,只能改 BillNumberGenerator.java:63-66 的拼接逻辑。

测试 ​

7 个测试类,共 84 个 @Test:

测试类数量覆盖
BillNumberGeneratorTest15前后缀+日期+补零、NONE、各粒度 pattern、起始值/步长换算、位数溢出不截断、随机/UUID、groupKey 分组与冒号安全
InlineRuleEvaluatorTest16段求值、字段作用域(子表回退主表)、parse 脏数据返 null
FormBillFieldResolverTest19递归识别布局容器内的单据组件、子表归属判定
ResetCycleEnumTest10AUTO 解析、period/ttl
BillTemplateRedisDAOTest13递增、周期/分组/作用域隔离、TTL、预占隔离与核销、并发不重号
BillTemplateServiceImplTest6取号日志:来源区分、预占复用不记日志、内联不记日志
BillNumberRetryTest5新事务重试、重取号、没填号不重试、子表数据副本

并发不重号的实测证据在 BillTemplateRedisDAOTest.java:104-133:32 线程池提交 200 个任务、CountDownLatch 同时开跑,断言结果集恰为 1..200 且无重复。

缺口:无 BillTemplateController 层测试;前端 58 个 .spec.ts 里没有一个覆盖 billTemplate / BillNumberInput。

坑与注意 ​

  1. 菜单叫「取号管理」,代码/实体/权限串仍叫「单据模板」 —— 刻意分歧(BillTemplateController.java:36-41):菜单名说用户干什么,代码名说这张表是什么。infra:bill-template:* 一改,已授出的权全部失效。菜单树:基础设施(/infra, id 2) → 数据管理(data-manage, id 9000050902) → 取号管理(bill-template, id 2572, component infra/billTemplate/index)(SQL:33244,33919,33612)。
  2. update 显式丢弃前端传来的 currentNumber / lastNo / lastPeriod,否则前端能篡改流水进度(BillTemplateServiceImpl.java:121-125)。
  3. updateStatistics 是只碰三列的定点 UPDATE,不用 updateById(整个 DO) —— 整对象更新会把并发请求各自读到的旧配置写回,覆盖管理员刚改的配置(BillTemplateMapper.java:35-41)。
  4. 模拟取号(test?real=false)只读干跑:不 INCR、不回写、不记日志(:231-244)。严格取号场景下测试绝不能烧号。
  5. 前端不自己算号,预览一律走后端 preview,避免两侧算法漂移(BillNumberGenerator.java:159-167);resetCycleActual 也在后端解析(BillTemplateController.java:124-132)。
  6. 🔴 FormBillFieldResolver 的缓存键曾经有洞:原来是「表单编码:版本号」,而发布校验拿到的版本号是 N、要校验的却是即将成为 N+1 的内容,导致「配好了模板仍报未绑定,直到重启」。现改为只用表单编码做键、命中后比对 formFields 原文(:56-78,95-99)。
  7. 单据组件名 BillNumberInput 前后端必须一致:后端靠它在 form-create 规则里认字段(FormBillFieldResolver.java:41-43、BillNumberInput.vue:25-26)。
  8. 内联规则引用的字段没填值时输出空串而不报错(号会短一截但单子能存进去,InlineRuleEvaluator.java:114-120);这类问题只在发布校验拦。
  9. UUID 方式主体 36 位,加前后缀很容易超 varchar(64)(BillNumberGenerator.java:80-82)。
  10. 预占失败前端静默降级为「保存后自动生成」,不挡用户填表(BillNumberInput.vue:99-104)。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

未证实 ​

  • 🔴 BillTemplateApi 当前没有任何调用方。 全仓(排除 target/)grep BillTemplateApi 只命中它自己包内的 3 个文件。接口注释说「供其它业务模块(erp / mes / wms 等)程序化出号,替代各模块自己写死的 XxxNoRedisDAO」(BillTemplateApi.java:5-7),但这些模块在本仓不存在 —— 它是预留扩展点,尚未接线。
  • 工作流实例号不走取号管理:enlev-module-bpm 内没有任何对 BillTemplateService / BillTemplateApi 的引用。
  • POST /occupy 没有 @PreAuthorize,也没有像 simple-list 那样的理由注释(对比 :134-139 vs :168-175)。类注释只说它是「前端拿号的唯一路径、幂等不烧号」,但严格说它对同一用户同一 scope 才幂等。是刻意还是遗漏,源码里没有说明。
  • SQL:29324-29327 里 infra:bill-template:* 四条 sys_resource 记录的 deleted = b'1',而同批的 infra:portal:*(SQL:29316-29323)是 b'0';对应的 system_menu 2573-2576 则是 b'0'。@ss.hasPermission 的实际解析源在 enlev-spring-boot-starter-security/.../SecurityFrameworkServiceImpl.java,未展开读,因此这批软删记录对鉴权的实际影响未证实。
  • group_by_prefix 用 md5 前 8 位做分组 key,源码承认「即便碰撞,后果也只是两个前缀共用一个计数器(号码依然唯一,只是流水不连续)」(BillNumberGenerator.java:36-40)—— 碰撞概率未实测。
  • 号段(批量预取)、作废号回收、跨天补号:代码里不存在,不是「没找到」。

相关页面 ​