Add skills-lock.json to manage skill dependencies for drawio-skill and prd
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
## 决策
|
||||
|
||||
每个 `domains/*` 模块内部采用简化分层,`domain` 层**可选**,判断标准与 Flutter 端 [02-layering.md](../02-layering.md) 保持一致的思路:
|
||||
每个 `domains/*` 模块内部采用简化分层,`domain` 层**可选**,判断标准与 Flutter 端 [02-layering.md](../flutter-app/02-layering.md) 保持一致的思路:
|
||||
|
||||
```
|
||||
domains/xxx/
|
||||
|
||||
@@ -25,7 +25,7 @@ domains/identity-store/
|
||||
|
||||
## 端点契约(与客户端已实现的行为对齐)
|
||||
|
||||
以下五个端点的路径、请求体、响应体**必须**与客户端文档 [../05-networking.md](../05-networking.md)、[../11-store-context-and-session.md](../11-store-context-and-session.md) 一致,客户端的自动刷新、切店、登出、冷启动恢复流程已经按这个契约实现:
|
||||
以下五个端点的路径、请求体、响应体**必须**与客户端文档 [../flutter-app/05-networking.md](../flutter-app/05-networking.md)、[../flutter-app/11-store-context-and-session.md](../flutter-app/11-store-context-and-session.md) 一致,客户端的自动刷新、切店、登出、冷启动恢复流程已经按这个契约实现:
|
||||
|
||||
| 端点 | 认证 | 说明 |
|
||||
| --- | --- | --- |
|
||||
@@ -272,7 +272,7 @@ class ApiResultAuthenticationEntryPoint(
|
||||
}
|
||||
```
|
||||
|
||||
**为什么这两个 handler 必须存在**:Spring Security 的过滤器跑在 `DispatcherServlet` **之前**,这里抛出的异常 `@RestControllerAdvice`([06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler`)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 [../05-networking.md](../05-networking.md) 是**严格按 HTTP 401 + `ApiResult` 结构**来判断"要不要触发 token 刷新"的。这里返回错了,整条自动刷新链路就是断的,表现为用户莫名其妙被登出。
|
||||
**为什么这两个 handler 必须存在**:Spring Security 的过滤器跑在 `DispatcherServlet` **之前**,这里抛出的异常 `@RestControllerAdvice`([06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler`)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 [../flutter-app/05-networking.md](../flutter-app/05-networking.md) 是**严格按 HTTP 401 + `ApiResult` 结构**来判断"要不要触发 token 刷新"的。这里返回错了,整条自动刷新链路就是断的,表现为用户莫名其妙被登出。
|
||||
|
||||
## Refresh Token:不用 Redis,直接用现有 MySQL
|
||||
|
||||
@@ -389,7 +389,7 @@ class RefreshTokenService(
|
||||
data class RotatedTokens(val userId: Long, val refreshToken: String)
|
||||
```
|
||||
|
||||
- **轮换(rotation)**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`。旧 token 之后又被用一次,就落进上面的重放分支——**注意"从来没见过的 token"和"已撤销的 token"必须分成两个分支处理**,合在一起写会让重放检测形同虚设(客户端 [../11-store-context-and-session.md](../11-store-context-and-session.md) 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)。
|
||||
- **轮换(rotation)**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`。旧 token 之后又被用一次,就落进上面的重放分支——**注意"从来没见过的 token"和"已撤销的 token"必须分成两个分支处理**,合在一起写会让重放检测形同虚设(客户端 [../flutter-app/11-store-context-and-session.md](../flutter-app/11-store-context-and-session.md) 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)。
|
||||
- **过期清理**:加一个定时任务定期删掉 `expires_at < now()` 且已撤销的行。**注意多副本下这个任务会在每个 Pod 各跑一次**,处理方式见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
|
||||
|
||||
### 为什么现阶段不引入 Redis
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 05. 集成层设计(F6 / Mini 域)
|
||||
# 05. 集成层设计(F6 / Mini 域 / 阿里云 OCR)
|
||||
|
||||
## 决策
|
||||
|
||||
供应商(F6)和历史 Mini 域的调用统一收口在 `integration/f6-adapter` / `integration/mini-clients` 模块(模块位置见 [01-project-structure.md](./01-project-structure.md)),业务 domain 不直接持有 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断、并发隔离。
|
||||
供应商(F6)、历史 Mini 域、以及阿里云 OCR(车牌识别)的调用统一收口在 `integration/f6-adapter` / `integration/mini-clients` / `integration/aliyun-ocr` 模块(模块位置见 [01-project-structure.md](./01-project-structure.md)),业务 domain 不直接持有 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断、并发隔离。
|
||||
|
||||
HTTP 客户端用 **`RestClient`(同步)**,底层是 Apache HttpClient 5 连接池,不用 `WebClient`/`Mono`。
|
||||
|
||||
@@ -30,6 +30,9 @@ integration/f6-adapter/
|
||||
|
||||
integration/mini-clients/
|
||||
对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装,供 workbench / bff-orchestration 调用
|
||||
|
||||
integration/aliyun-ocr/
|
||||
车牌识别:OSS 临时上传 + RecognizeLicensePlate 调用、AK/SK 持有、按次计费的用量管控
|
||||
```
|
||||
|
||||
`platform-integration` 依赖 `platform-web`(为了复用 `BusinessException` 和 `ErrorCode`)是本次允许的少数几个 platform 间依赖之一。
|
||||
@@ -221,7 +224,7 @@ class F6ClientException(message: String) :
|
||||
IntegrationException(ErrorCode.F6_BUSINESS_ERROR, "供应商请求被拒绝")
|
||||
```
|
||||
|
||||
错误码是 `Int`,落在 `3xxxx` 集成段(`ErrorCode.F6_UNAVAILABLE = 30001`),完整分段表见 [06-api-design.md](./06-api-design.md)——客户端 [../12-error-and-api-contract.md](../12-error-and-api-contract.md) 的 `ApiCode` 也是数字,两边必须一致。异常里带的原始 `message`(含 F6 的状态码和响应体)只进日志,**不进返回给 APP 的 `message`**,避免把供应商的协议细节泄漏出去。
|
||||
错误码是 `Int`,落在 `3xxxx` 集成段(`ErrorCode.F6_UNAVAILABLE = 30001`),完整分段表见 [06-api-design.md](./06-api-design.md)——客户端 [../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md) 的 `ApiCode` 也是数字,两边必须一致。异常里带的原始 `message`(含 F6 的状态码和响应体)只进日志,**不进返回给 APP 的 `message`**,避免把供应商的协议细节泄漏出去。
|
||||
|
||||
## Mini 域客户端示例(内部系统,策略更宽松)
|
||||
|
||||
@@ -244,6 +247,59 @@ class O2OClient(private val miniRestClient: RestClient) {
|
||||
}
|
||||
```
|
||||
|
||||
## 阿里云 OCR 客户端(车牌识别,按次计费)
|
||||
|
||||
车牌识别用[阿里云视觉智能开放平台](https://help.aliyun.com/zh/viapi/developer-reference/api-u92rj0)的 `RecognizeLicensePlate`,客户端不直连、由后端代理,理由与整条链路见 [../flutter-app/07-native-integration.md](../flutter-app/07-native-integration.md)。它和 F6、Mini 域是同一级别的外部依赖,收口在 `integration/aliyun-ocr`。
|
||||
|
||||
它有两点和前面两个都不一样,配置策略要跟着变:
|
||||
|
||||
**1. 按次计费,所以不配 `@Retry`。** F6 重试一次只是多花点时间,OCR 重试一次是多花一次钱,而且识别失败通常是图片本身的问题(模糊、遮挡、角度太偏),重试同一张图大概率还是失败。**失败就返回失败,让用户重拍或手工输码**——这一条要在代码注释里写清楚,否则后人看到别的 client 都有 `@Retry` 会以为这里是漏了。
|
||||
|
||||
**2. 收 `ImageURL` 不收图片二进制**,所以调 OCR 之前必须先把图片落到 OSS。这两步一起构成一次识别,中间任何一步失败对客户端都是"识别失败"。
|
||||
|
||||
```kotlin
|
||||
// integration/aliyun-ocr/.../LicensePlateClient.kt
|
||||
@Component
|
||||
class LicensePlateClient(
|
||||
private val ossClient: OssClient,
|
||||
private val ocrRestClient: RestClient,
|
||||
) {
|
||||
// 只做超时 + 舱壁 + 熔断,不加 @Retry:按次计费,且重试同一张图收益极低
|
||||
@Bulkhead(name = "aliyun-ocr")
|
||||
@CircuitBreaker(name = "aliyun-ocr", fallbackMethod = "fallbackRecognize")
|
||||
fun recognize(image: ByteArray, contentType: String): PlateRecognition {
|
||||
require(image.size <= 4 * 1024 * 1024) { "图片超过阿里云 4 MB 限制" }
|
||||
|
||||
val objectKey = ossClient.putTemp(image, contentType) // 临时对象,见下方生命周期
|
||||
val resp = ocrRestClient.post()
|
||||
.uri("/?Action=RecognizeLicensePlate")
|
||||
.body(mapOf("ImageURL" to ossClient.signedUrl(objectKey)))
|
||||
.retrieve()
|
||||
.body(AliyunPlateResponse::class.java)!!
|
||||
|
||||
return PlateRecognition(
|
||||
plateNumber = resp.data.plateNumber,
|
||||
plateType = resp.data.plateType,
|
||||
confidence = resp.data.confidence, // 阈值判断交给上层,这里只如实返回
|
||||
)
|
||||
}
|
||||
|
||||
private fun fallbackRecognize(image: ByteArray, contentType: String, ex: Throwable): PlateRecognition {
|
||||
log.warn("车牌识别不可用,转手工输码", ex)
|
||||
throw IntegrationException(ErrorCode.OCR_UNAVAILABLE, "车牌识别暂不可用,请手动输入车牌")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里的降级和 Mini 域的"返回空对象"不同:**车牌识别没有合理的空值**,识别不出来就是识别不出来,只能明确抛错让 APP 切到手工输码。返回一个空字符串会被误当成识别成功。
|
||||
|
||||
几条配套约束:
|
||||
|
||||
- **AK/SK 走 [07-config-governance.md](./07-config-governance.md) 的密钥管理**,和数据库口令同级,绝不进代码库、绝不下发到客户端。
|
||||
- **上传的车辆照片是临时对象**:OSS 上设生命周期规则自动过期(识别完就没用了),不要让门店车辆照片无限期堆在桶里——这既是成本问题也是合规问题。
|
||||
- **要有调用量监控和告警**。按次计费的接口一旦被误用(比如客户端做成连续帧调用)账单会很难看,用量指标进 [08-observability.md](./08-observability.md) 的看板。
|
||||
- **Region 固定在上海或深圳**(该能力只在这两个 Region 提供),配置里写死,不要跟着其它服务的 region 走。
|
||||
|
||||
## traceId 透传
|
||||
|
||||
```kotlin
|
||||
@@ -267,7 +323,8 @@ class TracePropagationInterceptor : ClientHttpRequestInterceptor {
|
||||
- **F6 是外部供应商域**,稳定性不可控,必须配齐超时 + 重试 + 熔断 + 舱壁,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。
|
||||
- **异常统一转换**:F6 / Mini 域的异常和非标准错误,在集成模块内部转换成内部标准错误码,业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。
|
||||
- **Mini 域调用相对可控**(内部系统),熔断可以不加,但超时和舱壁不能省,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"。
|
||||
- **只对幂等调用配 `@Retry`**。GET 查询可以放心重试;有副作用的调用(下单、扣减)**默认不重试**,确实需要时接口必须带幂等 key,规则见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
|
||||
- **只对幂等调用配 `@Retry`**。GET 查询可以放心重试;有副作用的调用(下单、扣减)**默认不重试**,确实需要时接口必须带幂等 key,规则见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。**按次计费的调用(阿里云 OCR)同样不重试**,理由见上文。
|
||||
- **降级返回要和业务语义对得上**:Mini 域可以返回空对象(首页 tile 空着好过整页 500),但**车牌识别不能**——识别不出来只能明确抛错让 APP 切手工输码,返回空字符串会被误当成识别成功。
|
||||
- **不在数据库事务里调外部 HTTP**,理由见 [03-persistence.md](./03-persistence.md)。
|
||||
- 业务 domain(如 `workbench`)只依赖 `integration/*` 暴露的接口,不自己构造 `RestClient` 发请求。
|
||||
- `workbench` 首页把多个下游并行拉起来的写法(线程池、整体超时预算、按 tile 降级)见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)——那不属于单个客户端的职责。
|
||||
|
||||
@@ -140,7 +140,7 @@ data class StoreResponse(
|
||||
)
|
||||
```
|
||||
|
||||
路径和响应体是照着客户端 [../11-store-context-and-session.md](../11-store-context-and-session.md)、[../05-networking.md](../05-networking.md) 写的——**这两个端点客户端已经实现了,后端对齐客户端,不是反过来**。切店返回的是含新 `accessToken` 和菜单的完整上下文,不是空 body,理由见 04。
|
||||
路径和响应体是照着客户端 [../flutter-app/11-store-context-and-session.md](../flutter-app/11-store-context-and-session.md)、[../flutter-app/05-networking.md](../flutter-app/05-networking.md) 写的——**这两个端点客户端已经实现了,后端对齐客户端,不是反过来**。切店返回的是含新 `accessToken` 和菜单的完整上下文,不是空 body,理由见 04。
|
||||
|
||||
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段被不小心带出去。
|
||||
|
||||
@@ -182,7 +182,7 @@ interface StoreMapper {
|
||||
|
||||
## 统一请求头约定
|
||||
|
||||
客户端每个请求固定携带以下头(见 [../05-networking.md](../05-networking.md)),后端的处理规则:
|
||||
客户端每个请求固定携带以下头(见 [../flutter-app/05-networking.md](../flutter-app/05-networking.md)),后端的处理规则:
|
||||
|
||||
| 请求头 | 必带 | 后端处理 |
|
||||
| --- | --- | --- |
|
||||
@@ -196,7 +196,7 @@ interface StoreMapper {
|
||||
|
||||
## 数据格式约定
|
||||
|
||||
这一节的每一条都要求前后端一字不差地对齐,客户端侧对应 [../12-error-and-api-contract.md](../12-error-and-api-contract.md)。
|
||||
这一节的每一条都要求前后端一字不差地对齐,客户端侧对应 [../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md)。
|
||||
|
||||
- **时间**:一律 ISO-8601 UTC 字符串,带毫秒和 `Z` 后缀——`"2026-08-14T03:21:45.123Z"`。Kotlin 侧类型是 `Instant`。**不传时间戳数字**(数字看不出单位是秒还是毫秒,出过太多次事),**不传本地时间**(不带时区的时间在跨时区场景下无解)。库里存的也是 UTC,见 [03-persistence.md](./03-persistence.md)。
|
||||
- **金额**:Kotlin 侧 `BigDecimal`,序列化成**字符串**(`"1234.56"`)而不是 JSON number。JSON number 在很多客户端会被解析成双精度浮点,`0.1 + 0.2` 那一类精度问题会直接变成对不上账。单位统一为元,小数位固定两位。
|
||||
@@ -208,7 +208,7 @@ interface StoreMapper {
|
||||
|
||||
## 分页与排序约定
|
||||
|
||||
(这条同时解决客户端 [../05-networking.md](../05-networking.md) 里挂着的"分页字段名待定"。)
|
||||
(这条同时解决客户端 [../flutter-app/05-networking.md](../flutter-app/05-networking.md) 里挂着的"分页字段名待定"。)
|
||||
|
||||
**请求参数**:
|
||||
|
||||
@@ -250,13 +250,43 @@ interface StoreMapper {
|
||||
| `20xxx` / `21xxx` | 采购 / 库存 |
|
||||
| `30xxx` | F6 集成 |
|
||||
| `31xxx` | Mini 域集成 |
|
||||
| `32xxx` | 阿里云 OCR 集成(车牌识别) |
|
||||
|
||||
分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
|
||||
|
||||
- **分段和下面这组基础码现在定死,业务码在开发对应模块时随接口增补**。不等一份"完整码表"齐了再开工——需求还在动的时候那份表不可能齐,等它等于卡住所有人。
|
||||
|
||||
```kotlin
|
||||
object ErrorCode {
|
||||
const val OK = 0
|
||||
|
||||
// 10xxx 平台通用
|
||||
const val INVALID_PARAM = 10001
|
||||
const val UNAUTHORIZED = 10401
|
||||
const val FORBIDDEN = 10403
|
||||
const val NOT_FOUND = 10404
|
||||
const val RATE_LIMITED = 10429
|
||||
const val INTERNAL_ERROR = 10500
|
||||
|
||||
// 11xxx 认证与门店
|
||||
const val STORE_NOT_ACCESSIBLE = 11001
|
||||
const val NO_STORE_PERMISSION = 11002
|
||||
|
||||
// 3xxxx 集成
|
||||
const val F6_UNAVAILABLE = 30001
|
||||
const val MINI_UNAVAILABLE = 31001
|
||||
const val OCR_UNAVAILABLE = 32001 // 车牌识别不可用 → APP 切手工输码
|
||||
const val OCR_NO_PLATE_FOUND = 32002 // 图片里没找到车牌 → 提示重拍
|
||||
}
|
||||
```
|
||||
|
||||
这组码**客户端有对应分支逻辑**(触发刷新、重拉门店上下文、展示 traceId 等),改动要两边一起改。段内新增的业务码不需要客户端配合——客户端默认直接展示 `message`。
|
||||
|
||||
- **码表和代码放在一起维护**(`ErrorCode` 常量即码表),不落在某个人的 Excel 里。新增码值时在常量上写注释说明触发场景。
|
||||
- **不允许在业务代码里写裸数字**,一律走 `ErrorCode` 常量。数字码在监控里聚合方便(可以直接 `group by code`),代价是不自解释——所以 `message` 必须始终是给人看的,日志里 `code` 和 `message` 一起打。
|
||||
- **`message` 是给用户看的**,不放技术细节(SQL、堆栈、下游状态码、内部服务名)。技术细节进日志,用 `traceId` 关联。
|
||||
- **HTTP status code 仍然要用对**:成功 200,参数错 400,未认证 401,无权限 403,不存在 404,并发冲突 409,下游不可用 502。客户端主要看 `code`,但 401 是例外——它触发自动刷新逻辑,必须准确(见 04)。网关、监控、日志分析也都依赖 status code。
|
||||
- 新增错误码时**同步更新客户端的 `ApiCode`**([../12-error-and-api-contract.md](../12-error-and-api-contract.md)),两边分段方案必须一致。
|
||||
- 新增**需要客户端特殊 UX** 的错误码时,同步更新客户端的 `ApiCode`([../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md)),两边分段方案必须一致。
|
||||
|
||||
## 版本与兼容
|
||||
|
||||
@@ -289,7 +319,7 @@ interface StoreMapper {
|
||||
|
||||
## 待补充
|
||||
|
||||
- **完整错误码表**:分段方案已定(见上),但各 domain 段内的具体码值还没分配,需要各 domain 负责人一起填,并与客户端的 `ApiCode`([../12-error-and-api-contract.md](../12-error-and-api-contract.md))保持同步。
|
||||
- **各 domain 段内的业务码**:分段方案和基础码已定死(见上),采购、库存、集成段内的具体码值由各 domain 负责人在开发对应模块时随接口增补,不集中排期。只有需要客户端特殊 UX 的码才要同步到客户端 `ApiCode`([../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md))。
|
||||
- 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
@@ -46,7 +46,7 @@ management:
|
||||
|
||||
### 与客户端 `X-Trace-Id` 的对接契约
|
||||
|
||||
客户端每个请求都会带一个自己生成的 `X-Trace-Id`(见 [../05-networking.md](../05-networking.md)),要求"后端复用它",这样一次用户操作在 APP 日志和服务端日志里是同一个 ID。但 Micrometer 认的是 W3C 的 `traceparent` 头,所以中间需要一层桥接:
|
||||
客户端每个请求都会带一个自己生成的 `X-Trace-Id`(见 [../flutter-app/05-networking.md](../flutter-app/05-networking.md)),要求"后端复用它",这样一次用户操作在 APP 日志和服务端日志里是同一个 ID。但 Micrometer 认的是 W3C 的 `traceparent` 头,所以中间需要一层桥接:
|
||||
|
||||
**契约(同时解决客户端文档里挂着的那条待确认项)**:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user