Appearance
后端开发规范
适用于所有 Java 微服务(基于 hiapi-fast-frame)。
铁律(违反即打回)
- 租户隔离:所有业务查询必须按
mid过滤。继承基类时实现getMid()返回TokenGet.getMid()。 - 字段更新用
UpdateFields,不做整实体覆盖:javaservice.update( UpdateFields.newBuilder().put("status", 1).build(), QueryWrapper.create().eq("id", id).eq("mid", mid)); - 缓存失效在事务提交后:java
TransactionSynchronization.afterCommit(() -> userService.updateCache(mid, uid)); - 服务间调用只认框架 SPI:一律用
cn.hiapi.core.basic.cloud.ICloudXxxService,取实现走dispatchContext.getCloudService(...)(不是getServiceOne(...)——后者取容器里第一个,本地实现和 feign 适配器共存时结果不可预期)。禁止手写 RestTemplate/HttpClient 调兄弟服务,也禁止import别人的Feign*客户端。契约放各提供方仓的<svc>-contract{,-feign},详见根CLAUDE.md。 - 实体/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
@Entity、Repository(启动崩Not a managed type)——实体和仓库放 boot 期依赖模块
详见 ADR-0002。
子应用必做项
按 nuwa 应用商店子应用形态交付的服务(vem、shop…),必须在 <svc>-app 的 cn.hiapi.install 包下实现两个接口:
AppInstallService—— 安装/升级/卸载回调。缺了会在应用商店装到一半 500AppLinkProvider—— 向装修器公布可跳转页面。缺了商户在装修器里选不到你任何页面
⚠️ 框架里那个"缺了就启动失败"的检查写在 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 注册;
ShardingTableScan扫cn.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(...)取。 BasicCurdController的saveCallback/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)。TokenGet在hiapi-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()),不要直接碰 RabbitTemplate。BaseEvent.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 | 消费方 | 用途与约束 |
|---|---|---|---|
PaymentSuccessEvent | finance.pay.success.<appId> | 各业务方自订阅 | 支付成功回调。事件里没有 details、amountType 恒 null,按 payCode 回查自己的单 |
NotifyEvent | MsgRoutingKey.SYSTEM_NOTIFY | public 侧已上线 | 跨服务发站内信。templateCode+bizId 组幂等键(都非空才去重);receiverType 用 AccountType 小写字符串 |
AssetsChangeEvent | MsgRoutingKey.FINANCE_ASSETS_CHANGE | finance 侧已上线 | 跨进程给用户加钱(扣款别走异步)。消费端按 (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 /cancel、POST /refund(全 @RequestParam,无 body)、GET /status(方法名叫info)。客户端PaymentFeignClient用dispatchContext.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),优先级 Cookiehiapi-mid→ Queryhiapi-mid/mid→ 域名反查(查不到抛"域名还未授权")。BasicFieldsEntity extends JSONObject(fastjson2):取参用getLongValue/getIntValue/getBooleanValue(原始类型,防 null 拆箱 NPE),数组用getList(key, Long.class)。ResUtils.toResult(ILanguageSource)静态方法存在(AbsResponse实例方法没有这个重载),controller 想按错误码返回而不抛异常时用它。BasicQueryController的toListVo/toDataVo都是抽象方法,VO=实体本身也必须显式覆写透传。