Skip to content

后端开发规范

适用于所有 Java 微服务(基于 hiapi-fast-frame)。

铁律(违反即打回)

  1. 租户隔离:所有业务查询必须按 mid 过滤。继承基类时实现 getMid() 返回 TokenGet.getMid()
  2. 字段更新UpdateFields,不做整实体覆盖:
    java
    service.update(
        UpdateFields.newBuilder().put("status", 1).build(),
        QueryWrapper.create().eq("id", id).eq("mid", mid));
  3. 缓存失效在事务提交后:
    java
    TransactionSynchronization.afterCommit(() -> userService.updateCache(mid, uid));
  4. 服务间调用只认框架 SPI:一律用 cn.hiapi.core.basic.cloud.ICloudXxxService,取实现走 dispatchContext.getCloudService(...)(不是 getServiceOne(...)——后者取容器里第一个,本地实现和 feign 适配器共存时结果不可预期)。禁止手写 RestTemplate/HttpClient 调兄弟服务,也禁止 import 别人的 Feign* 客户端。契约放各提供方仓的 <svc>-contract{,-feign},详见根 CLAUDE.md
  5. 实体/VO 字段必须写 Javadoc 注释(/** 商户ID */)——这是接口文档的唯一来源。

控制器体系

BasicQueryController<T, ID, VO, Q>     GET /query(分页) GET /get
    └── BasicCurdController<...>       + POST /save POST /update DELETE /delete

可覆写钩子:

钩子时机/用途
getMid()返回当前商户 ID,租户隔离
parseData(BasicFieldsEntity)入参 → 实体,保存前
buildFields(BasicFieldsEntity)入参 → UpdateFields,更新前
toListVo(List<T>) / toDataVo(T)实体 → VO,出参
saveCallback(fields, entity)保存后(事务内)
deleteBeforeIntercept(List<T>)删除前校验

⚠️ 多账户类型共享逻辑不要做中间抽象 Controller 继承(泛型反射会 CCE),用组合 @Component,见 ADR-0005

Service 层

实现 BasicService<T, ID>(继承 AbsBasicService): get / getOne / findList / findPage / save / update / delete,查询条件统一 QueryWrapper.create().eq(...)

Token 与身份

java
TokenGet.getMid()   // 商户ID(租户键)
TokenGet.getFid()   // 当前登录账号ID
TokenGet.get()      // 完整 Token

保护模块(hiapi-core-public)的特殊限制

受保护模块在 Spring refresh 之后经 JNI 载入,因此:

  • ✅ 可以放:@Service / @Component / @RestController
  • ❌ 不能放:JPA @EntityRepository(启动崩 Not a managed type)——实体和仓库放 boot 期依赖模块

详见 ADR-0002

子应用必做项

按 nuwa 应用商店子应用形态交付的服务(vem、shop…),必须<svc>-appcn.hiapi.install 包下实现两个接口:

  • AppInstallService —— 安装/升级/卸载回调。缺了会在应用商店装到一半 500
  • AppLinkProvider —— 向装修器公布可跳转页面。缺了商户在装修器里选不到你任何页面

⚠️ 框架里那个"缺了就启动失败"的检查写在 BasicCloudApplication,但全仓没人继承它, 所以它拦不住你。靠 checklist 守,别靠框架守。

完整写法、options() 的租户隔离要求、现状台账见 子应用接入规范

异步与通知

  • 业务事件通知走 Outbox 本地消息表(NotifyRecord + Dispatcher + 15s 兜底),不要在业务事务里同步调外部 webhook。
  • 长任务/webhook 投递交给 hiapi-task-worker

框架实战速查(2026-08 商城 M1 重构实测沉淀)

按月分表(ShardingMode.TIME)三件套与硬约束

java
@Entity
@Table(name = "hiapi_xxx_order")               // 必须有 @Table,否则启动抛异常
public class XxxOrder extends BasicShardingEntity {
    @Override
    public ShardingOption getOption() { return new ShardingOption(ShardingMode.TIME); }

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;                            // 实际主键由 SS snowflake 生成,跨月表全局唯一

    @ShardingField
    private long created;                       // 分表键,毫秒时间戳
}
  • @Id@ShardingField 必须声明在实体自身类里(框架用 getDeclaredFields() 扫描,放 @MappedSuperclass 父类会被漏掉,规则静默退化)。
  • 分表规则纯注解驱动,无需任何 yaml 注册;ShardingTableScancn.hiapi 包自动登记。
  • 月表由 ShardingTableTaskImpl 启动后 20s + 每天零点 CREATE TABLE ... LIKE 裸表 自动预建(未来约 3 个月);裸表是模板,算法永不路由到它。spring.hiapi.sharding.create: false 会关掉预建 —— 跨月写入将静默丢数据(算法只 warn 返回 null),只有只读账号场景才可关。
  • /edit 前端必须回传 created(基类把分表键并入 where);查询尽量带 created 范围收敛分片,不带 = 扫全部月表。
  • 新分片表命名不能是另一张分片表名的前缀(show tables like 'xxx%' + ^xxx_\d+$ 识别月表,前缀关系会误纳数据节点)。
  • 多副本部署必须注入 HIAPI_WORKER_ID(0~1023),否则 snowflake worker-id 随机,有主键碰撞风险。
  • 分表实体的 update 条件带上 created(等值)+ 原状态,兼做路由收敛与乐观并发。

实体与 DDL

  • 实体基类 BasicEntity 不带任何字段(只有 copy/toJSONString),id/mid/created/updated 全部自己写;created = System.currentTimeMillis() 就地默认值。
  • 不在 @Table 写 indexes;二级索引进 Flyway 基线/迁移,且一律不写索引名(SS 改写 DDL 会给约束名追加表名两遍,超 MySQL 64 字符上限)。业务唯一键用 @Table(uniqueConstraints = @UniqueConstraint(columnNames = {...}))(不命名),EntityDdl 会内联生成。
  • EntityDdl.java 从实体生成基线时,target/classes 必须放在 classpath 最前——mvn install 过的旧 jar 会遮蔽新编译的类,改了实体生成结果却不变。

事务与编排层

  • 跨表事务编排放 logic 包:@Service + extends AbsBasicLogic(构造只收 DispatchContext),入口公有方法挂 @Transactional,内部一切依赖用 dispatchContext.getServiceOne(...) 取。
  • BasicCurdControllersaveCallback/updateCallback/deleteXxx 钩子是基类内部自调用,挂 @Transactional 无效(事务由 /add /edit 入口覆盖)。复杂聚合保存不要挤进钩子,开独立端点直进 logic。
  • 状态流转统一"带原状态条件的 CAS 更新"(update(fields, wrapper.eq("status", 原状态)),受影响行数=0 即并发冲突报错),不做读-改-写。

常用 API 的真实签名(容易记错)

  • UpdateFields.newBuilder() 返回的就是 UpdateFields 自身(没有内部 Builder 类);build() 返回 Map<String, FieldValue<Object>>,service.update 收的是这个 Map:service.update(fields.build(), wrapper)。原子增减用 put("stock", delta, SqlFieldType.MATH)
  • TokenGethiapi-core-security-token 构件(api 模块要显式依赖,hiapi-core-controller 不传递)。
  • 错误码:Language 枚举实现 ILanguageSource(getCode/getMessage/getDefault() —— 注意是 getDefault 不是 getDefMessage),抛 BasicException(ILanguageSource)。号段约定:vem=8801xxxxx,shop=8802xxxxx。
  • AbsResponse 没有 toResult(ILanguageSource) 重载 —— controller 里要么 toError(String),要么直接抛 BasicException(全局处理器统一格式化)。
  • MQ 监听 = @Service implements IEventMessageListener<Payload> 三个方法(getQueue/routingKey/onMessage),没有 @RabbitListener,框架扫 bean 自建队列绑定。

MQ 事件体系实战(2026-08 商城 M2/M3 实测沉淀)

发布与订阅的完整套路

  • 交换机固定 topic hiapi-cloud-event。发布只用 EventPublisher.publish(routingKey, BaseEvent.builder()...build()),不要直接碰 RabbitTemplateBaseEvent.eventId/timestamp/version 有 @Builder.Default,不用手填。
  • 订阅 = @Service implements IEventMessageListener<Payload>,框架 RabbitConfig 扫 bean 自动建队列(durable + 同名 .dlq 死信)+ 绑定;消费容器 prefetch=1、失败 Spring Retry 3 次指数退避后进 DLQ。同一应用多个监听器队列名必须不同(container 按队列名路由),命名约定 <spring.application.name>-<用途>
  • appId 两端必须用同一个属性:MsgRoutingKey.financePaySuccess(appId) 的注释写的是 spring.application.name,但 vem/shop 实际全链路用 spring.hiapi.cloud-app.app-id——下单传给 finance 的 appId 和 RabbitConfig 绑定 routingKey 时读的是同一个 key,错一边就永远收不到回调。
  • 幂等下沉到业务:监听器本身不做去重,靠业务状态 CAS(条件更新 0 行=重投,静默 return 不抛异常)。语义是 at-least-once。

三条现成的跨服务 MQ 通路(业务方直接用,别新建契约)

通路routingKey消费方用途与约束
PaymentSuccessEventfinance.pay.success.<appId>各业务方自订阅支付成功回调。事件里没有 details、amountType 恒 null,按 payCode 回查自己的单
NotifyEventMsgRoutingKey.SYSTEM_NOTIFYpublic 侧已上线跨服务发站内信。templateCode+bizId 组幂等键(都非空才去重);receiverType 用 AccountType 小写字符串
AssetsChangeEventMsgRoutingKey.FINANCE_ASSETS_CHANGEfinance 侧已上线跨进程给用户加钱(扣款别走异步)。消费端按 (mid,fid,type,sourceType,sourceId) 幂等(2026-08-12 补),发布方必须传稳定 sourceId

finance 资产同步契约(1.0.1 新增,2026-08-12)

  • FinanceAssetsApi(contract 1.0.1)/ FinanceAssetsFeignClient / /feign/finance/assets:GET /get 查余额、POST /change 同步资产变动(正入负扣,扣减余额不足返回业务 error 不打 500)。
  • change 强制 sourceType+sourceId,服务端按 (mid,fid,type,sourceType,sourceId) 幂等 —— feign 超时重试不双记账。与 MQ 通路的分工:只加钱且不需要应答用 AssetsChangeEvent;需要同步应答(尤其扣减)用这个。第一个消费方:shop 售后冲正。
  • 契约版本从 1.0.0 抬到 1.0.1,contract/contract-feign 必须成对发私仓,消费方 pom 显式声明 1.0.1。

finance 支付契约的真实形态(集成前必读,别信旧文档)

  • PaymentApi 只有四个方法:POST /create(body PaymentRequest)、GET /cancelPOST /refund(全 @RequestParam,无 body)、GET /status(方法名叫 info)。客户端 PaymentFeignClientdispatchContext.getServiceOne(...) 取——支付没有 ICloud SPI,getCloudService 编译期就过不去。
  • createPayment 按 (mid,payCode) 幂等:同号重调原样返回旧支付单(即使已 CLOSE)。"再次支付"= 先 /status 查,WAIT_PAYMENT 同号重调拿回旧收银台;CLOSE 必须派生新 payCode 重建。
  • cancelPayment 非幂等:对已关/已付的单返回业务 error("支付单状态错误"),必须容忍并接 /status 判断——若其实已支付,应放弃取消让支付回调接管。
  • PaymentRequest 没有 amountType 字段;amountSplits 是支付成功约 30 秒后由定时任务即时打钱(PLATFORM 渠道走 changeAssets),没有"确认收货才计入"机制;退款不做分账冲正。需要"确认收货才结算"的业务(如商城)不要用 amountSplits,自己在确认时发 AssetsChangeEvent 结算。
  • /status 返回的是 finance 内部 VO(不在契约包),消费方按 JSON 解析 status 字段,枚举值:WAIT_PAYMENT / PAYMENT_SUCCESS / REFUND_PART / REFUND_COMPLETE / CLOSE。
  • 业务超时关单阈值必须 大于 传给 finance 的 expireMinutes(vem 约定),保证"钱还能付的单一定还开着"。

其它实测坑

  • **分页用框架的 cn.hiapi.core.basic.pageable.PageRequest.of(...)(1 基)**,不要 import Spring 的 PageRequest`(0 基)——import 错了第 1 页会静默变第 2 页。
  • 定时任务样板(vem/finance/shop 一致):@Component + @Scheduled(fixedRate=…) + LockConst.getLock("<服务名>","<任务名>") + ILockService.lock() 抢不到就跳过 + 分页限量 + 单笔 try/catch + finally unlock。hiapi-task-worker 是 webhook 重试执行器,不是定时调度中心,业务超时任务别往里塞。
  • /public/** 无 token 接口识别租户:RequestUtils.getMerchantParams(request)getCloudService(ICloudMerchantService.class).getMerchant(params),优先级 Cookie hiapi-mid → Query hiapi-mid/mid → 域名反查(查不到抛"域名还未授权")。
  • BasicFieldsEntity extends JSONObject(fastjson2):取参用 getLongValue/getIntValue/getBooleanValue(原始类型,防 null 拆箱 NPE),数组用 getList(key, Long.class)
  • ResUtils.toResult(ILanguageSource) 静态方法存在(AbsResponse 实例方法没有这个重载),controller 想按错误码返回而不抛异常时用它。
  • BasicQueryControllertoListVo/toDataVo 都是抽象方法,VO=实体本身也必须显式覆写透传。