暂存提交
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# 仅用于测试基础设施容器。所有 SQL 均使用 CREATE ... IF NOT EXISTS 或幂等扩展语句,
|
||||||
|
# 不删除已有业务数据;仓库样例数据使用固定 ID 重复写入。
|
||||||
|
MYSQL_CONTAINER="${TEST_MYSQL_CONTAINER:-firstleg-test-mysql}"
|
||||||
|
|
||||||
|
if ! docker inspect --type container "$MYSQL_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
echo "测试 MySQL 容器不存在:$MYSQL_CONTAINER" >&2
|
||||||
|
echo "请先执行 docker compose -f docker-compose-infra.yml up -d" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$MYSQL_CONTAINER")" != "true" ]; then
|
||||||
|
echo "测试 MySQL 容器未运行:$MYSQL_CONTAINER" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for sql_file in \
|
||||||
|
iam/src/main/resources/db/iam.sql \
|
||||||
|
order/src/main/resources/db/order.sql \
|
||||||
|
order/src/main/resources/db/phase1-order-extension.sql \
|
||||||
|
order/src/main/resources/db/phase1-masterdata-extension.sql \
|
||||||
|
warehouse/src/main/resources/db/warehouse.sql \
|
||||||
|
warehouse/src/main/resources/db/warehouse-partner.sql \
|
||||||
|
warehouse/src/main/resources/db/warehouse-seed.sql
|
||||||
|
do
|
||||||
|
if [ ! -f "$sql_file" ]; then
|
||||||
|
echo "找不到初始化 SQL:$sql_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "初始化测试数据库:$sql_file"
|
||||||
|
docker exec -i "$MYSQL_CONTAINER" sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4' < "$sql_file"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "测试数据库初始化完成。"
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 后端缺口修复回归报告
|
||||||
|
|
||||||
|
日期:2026-07-31
|
||||||
|
范围:Auth、IAM、Order、Firstleg、Warehouse、File 及其公共依赖
|
||||||
|
测试边界:未连接或写入测试数据库,未启动业务服务,未占用现有服务端口。
|
||||||
|
|
||||||
|
## 1. 修复结论
|
||||||
|
|
||||||
|
### 1.1 订单权限与草稿稳定性
|
||||||
|
|
||||||
|
- 商户订单列表增加 `merchant:order:list`,详情增加 `merchant:order:view`。
|
||||||
|
- 平台订单列表和详情统一增加已初始化的 `platform:order:list`。
|
||||||
|
- 订单号改为基于雪花 ID 生成,消除原秒级时间加三位随机数在并发下重复的风险。
|
||||||
|
- 仓库、往来单位返回空名称、错误仓库 ID、空地址元素或空能力元素时,转换为受控业务错误,不再进入未捕获空指针。
|
||||||
|
- 地址和收货能力快照格式化增加空元素防护。
|
||||||
|
|
||||||
|
### 1.2 平台内部用户与权限
|
||||||
|
|
||||||
|
- 内部用户列表改为明确 DTO,返回 `membershipId`、`userId`、`authUserId`、登录名、姓名、电话、邮箱、成员编号、状态和 `roleIds`。
|
||||||
|
- 列表增加 `platform:user:list` 服务端权限校验。
|
||||||
|
- 保留原新增路径,兼容关联已有 Auth 账号,同时支持完整创建平台账号。
|
||||||
|
- 新增编辑、启停和重置密码接口,分别校验 `platform:user:edit`、`platform:user:status`、`platform:user:reset-password`。
|
||||||
|
- Auth 支持平台应用域账号、账号创建幂等、显示名同步、状态变更和密码重置。
|
||||||
|
- IAM/Auth 创建、资料和状态写入增加补偿处理;成功写入后不再执行可能破坏一致性的远程查询。
|
||||||
|
- 企业资料查询增加 `merchant:profile:view` 权限校验。
|
||||||
|
|
||||||
|
### 1.3 企业资质文件契约
|
||||||
|
|
||||||
|
- File multipart 接口成为唯一公开资质上传入口。
|
||||||
|
- 删除 IAM 旧 JSON POST 路由、DTO、Service 固定冲突方法以及 OpenAPI 成功声明。
|
||||||
|
- 初始化 SQL 不再生成 `/phase1-seed/...` 虚假已审核资质。
|
||||||
|
- 新增 `企业资质文件迁移说明.md`,要求取得真实原文件、写入 File 存储并校验后,才能事务更新旧记录 URL。
|
||||||
|
|
||||||
|
### 1.4 订单持久化幂等与错误语义
|
||||||
|
|
||||||
|
- 创建草稿和提交订单强制接收 `Idempotency-Key`,唯一范围为租户、操作者、操作和幂等键。
|
||||||
|
- 请求摘要包含资源与请求内容;同键同请求回放首次结果,同键不同请求或处理中请求返回 409。
|
||||||
|
- 幂等记录、业务写入和完成状态位于同一本地事务,数据库唯一键负责并发互斥。
|
||||||
|
- 不存在订单优先返回 404;Warehouse 下游异常、空响应和无效主数据转换为受控 404、403、422 或 503。
|
||||||
|
|
||||||
|
### 1.5 Firstleg 与 Warehouse 管理闭环
|
||||||
|
|
||||||
|
- Firstleg 增加任务分页、详情、按订单查询、分配历史和分配/改派权限,所有仓储查询均带目标租户。
|
||||||
|
- `idx_task_tenant_created` 同时写入新库建表脚本和可重复执行的既有库增量迁移。
|
||||||
|
- Warehouse 增加租户级往来单位、快递公司分页、详情、新增、编辑和启停;停用保留历史主数据,不物理删除。
|
||||||
|
- IAM 同步新增六个 Warehouse 按钮权限及四个 Firstleg 权限,并保持平台管理员、订单运营角色授权一致。
|
||||||
|
|
||||||
|
## 2. 自动化回归
|
||||||
|
|
||||||
|
独立总审执行命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
mvn -pl iam,warehouse,order,firstleg -am clean test
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:在完整纯 ASCII 临时构建副本中执行,10 个 Reactor 模块全部构建成功。副本文件数与实施工作树一致;未连接数据库、未启动服务。正式仓同步后还会再次执行同一 `clean test`。
|
||||||
|
|
||||||
|
| 模块 | 测试数 | 结果 |
|
||||||
|
|---|---:|---|
|
||||||
|
| common-core | 2 | 通过 |
|
||||||
|
| common-web | 4 | 通过 |
|
||||||
|
| common-security | 6 | 通过 |
|
||||||
|
| auth-service | 10 | 通过 |
|
||||||
|
| iam-service | 48 | 通过 |
|
||||||
|
| order-service | 14 | 通过 |
|
||||||
|
| firstleg-service | 6 | 通过 |
|
||||||
|
| warehouse-service | 23 | 通过 |
|
||||||
|
|
||||||
|
重点新增覆盖:订单持久化幂等、404/409/503 语义、平台用户完整生命周期、权限初始化、企业分页、Firstleg 租户过滤与权限、Warehouse CRUD/唯一性/Schema 契约,以及 OpenAPI YAML 解析。
|
||||||
|
|
||||||
|
静态检查:`git diff --check` 通过;仅存在工作树既有的 CRLF/LF 转换提示,不属于补丁空白错误。
|
||||||
|
|
||||||
|
## 3. 仍需环境联调确认
|
||||||
|
|
||||||
|
- 历史两次订单 `S0500` 的完整异常栈仍未取得,因此不能声称已还原历史唯一根因;本次已消除代码中能够确定的编号碰撞和空数据未受控风险。
|
||||||
|
- 测试库旧 `/phase1-seed/...` 记录没有原文件,不能安全自动改成随机 UUID。本次没有写测试库,需取得真实营业执照文件后按迁移说明处理。
|
||||||
|
- 本地 Warehouse 五张表与 Entity/SQL 的只读核验一致;历史查询 500 没有异常栈,不能把其唯一根因归结为 Schema 漂移。
|
||||||
|
- `PENDING`、`REJECTED` 资质样本仍需由测试负责人准备脱敏数据后,完成浏览器五状态验收。
|
||||||
|
- Dashboard 数量统计、并发请求隔离、示例 Store 和前端强制授予 `dashboard:view` 属于前端问题,不在后端增加虚假兼容逻辑。
|
||||||
|
|
||||||
|
## 4. 索引同步
|
||||||
|
|
||||||
|
- 已更新 `order/AGENTS.md`、`iam/AGENTS.md`、`auth/AGENTS.md`、`file/AGENTS.md`。
|
||||||
|
- 新增平台内部用户 DTO、Service 和测试入口后,已同步更新根目录 `AGENTS.md`。
|
||||||
|
- 企业资质迁移说明和本报告均为中文文档。
|
||||||
|
- 已更新 `warehouse/AGENTS.md`、`firstleg/AGENTS.md` 和根 `AGENTS.md`,并在中文 API 文档补充 IAM、订单幂等、Firstleg 与 Warehouse 管理契约。
|
||||||
|
- 正式仓同步与最终测试结果在同步完成后写入本报告末尾。
|
||||||
|
|
||||||
|
## 5. 正式仓同步与最终复核
|
||||||
|
|
||||||
|
- 审核通过的文件已按显式文件清单安全同步至 `D:\baiyue-firstleg`;未使用目录覆盖、`reset` 或清理操作,正式仓原有未提交修改已保留。
|
||||||
|
- 正式仓已于 2026-07-31 14:52:33 执行并通过:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
mvn "-DargLine=-Djdk.attach.allowAttachSelf=true" -pl iam,warehouse,order,firstleg -am clean test
|
||||||
|
```
|
||||||
|
|
||||||
|
共 10 个 Reactor 模块构建成功;该 JVM 参数仅规避本机 Mockito 动态代理加载异常缓慢,不改变业务配置。
|
||||||
|
- 正式仓 `git diff --check` 通过。本轮未写数据库、未启动业务服务、未占用业务端口。
|
||||||
|
|
||||||
|
### 5.1 未提交 SQL 执行清单(尚未执行)
|
||||||
|
|
||||||
|
1. `order/src/main/resources/db/order.sql`:创建 `ord_idempotency_record`。
|
||||||
|
2. `iam/src/main/resources/db/phase1-admin-rbac.sql`:补齐 Firstleg、Warehouse 权限和角色授权。
|
||||||
|
3. `warehouse/src/main/resources/db/warehouse-express-company.sql`:创建快递公司表。
|
||||||
|
4. `firstleg/src/main/resources/db/firstleg.sql`:新库基础结构。
|
||||||
|
5. `firstleg/src/main/resources/db/phase1-firstleg-task-list-index.sql`:对既有 Firstleg 库可重复执行,补齐 `fl_fulfillment_task.idx_task_tenant_created`;新库已在基础建表脚本中包含该索引。
|
||||||
|
|
||||||
|
如后续获得明确数据库写入授权,应先开发库、校验后测试库,并逐项保留执行结果;本报告不构成实际写库记录。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 企业资质文件迁移说明
|
||||||
|
|
||||||
|
## 统一接口边界
|
||||||
|
|
||||||
|
企业资质的唯一真实上传入口是:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/merchant/v1/files/qualifications
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
表单字段为 `file`,可选字段为 `certificateNo`。File 服务先校验并保存真实图片,再通过内部接口通知 IAM 创建资质和审核记录。IAM 的商户资质接口只负责查询,不再提供 JSON 上传入口。
|
||||||
|
|
||||||
|
有效的商户预览地址格式为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/merchant/v1/files/qualifications/{uuid}.jpg
|
||||||
|
/api/merchant/v1/files/qualifications/{uuid}.png
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库中的 URL 不是文件本身。禁止只生成 UUID 并修改 `document_url`,也禁止用空文件或任意图片冒充营业执照。
|
||||||
|
|
||||||
|
## 历史数据盘点
|
||||||
|
|
||||||
|
发布前先以只读方式盘点历史路径:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT id, tenant_id, qualification_type, document_url, status
|
||||||
|
FROM baiyue_iam.iam_tenant_qualification
|
||||||
|
WHERE deleted = 0
|
||||||
|
AND (document_url IS NULL
|
||||||
|
OR document_url NOT REGEXP '^/api/merchant/v1/files/qualifications/[0-9a-fA-F-]{36}\\.(jpg|png)$');
|
||||||
|
```
|
||||||
|
|
||||||
|
盘点结果中的每条记录都必须找到原始图片。找不到原始图片时,将记录标记为“待业务补传”,不得伪造文件,也不得直接改 URL。
|
||||||
|
|
||||||
|
## 可落地迁移步骤
|
||||||
|
|
||||||
|
1. 暂停对应资质的替换操作,记录资质 ID、租户 ID、旧 URL 和原文件校验和。
|
||||||
|
2. 从可信旧存储取得原始图片,校验文件签名、扩展名和不超过 5 MB 的大小限制。
|
||||||
|
3. 使用迁移工具按 File 服务同一规则生成 UUID 文件名,将真实文件写入共享存储的 `qualifications/{tenantId}/` 目录。
|
||||||
|
4. 写入完成后,从 File 服务平台预览接口读取新文件并核对 SHA-256;读取失败时不得修改数据库。
|
||||||
|
5. 在单条事务中按 `id + tenant_id + 旧 document_url` 条件更新 IAM 的 `document_url`。受影响行数不是 1 时立即回滚。
|
||||||
|
6. 再次通过平台预览接口验收;验收完成前保留旧文件和旧 URL 清单,以便回滚。
|
||||||
|
|
||||||
|
数据库更新模板如下,执行时必须替换为经过文件校验的真实值:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
START TRANSACTION;
|
||||||
|
UPDATE baiyue_iam.iam_tenant_qualification
|
||||||
|
SET document_url = :verified_new_url
|
||||||
|
WHERE id = :qualification_id
|
||||||
|
AND tenant_id = :tenant_id
|
||||||
|
AND document_url = :expected_old_url
|
||||||
|
AND deleted = 0;
|
||||||
|
-- 仅当 ROW_COUNT() = 1 且新文件已通过 File 预览校验时提交,否则回滚。
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新环境初始化
|
||||||
|
|
||||||
|
初始化脚本不再创建 `/phase1-seed/...` 虚假资质。测试环境需要可下单的商户时,应使用脱敏但真实存在的 JPG/PNG 文件走 File 上传、平台审核完整流程,使租户达到 `ACTIVE` 且资质达到 `APPROVED`。初始化账号、文件和审核结果应作为一套环境数据验收,不得仅靠 SQL 绕过文件保存。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 重量 OCR 接入说明
|
||||||
|
|
||||||
|
## 功能位置
|
||||||
|
|
||||||
|
重量 OCR 已作为功能模块并入 `integration-service`,不再作为独立微服务部署,不新增端口、数据库或注册服务名。
|
||||||
|
|
||||||
|
主要代码:
|
||||||
|
|
||||||
|
- `controller/WeightOcrController.java`:内部批量识别接口。
|
||||||
|
- `service/ocr/WeightOcrBatchService.java`:批次编排、结果汇总和文件落盘。
|
||||||
|
- `service/ocr/ImageCropper.java`:图片下方中央区域裁剪。
|
||||||
|
- `service/ocr/WeightCandidateSelector.java`:重量候选筛选。
|
||||||
|
- `service/ocr/WeightOcrStoragePolicy.java`:输入、输出目录边界保护。
|
||||||
|
- `provider/ocr/BaiduMeterClient.java`:百度令牌缓存、识别、限速和重试。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /internal/integrations/weight-ocr/batches
|
||||||
|
X-Internal-Service-Token: 内部服务令牌
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"inputDirectory": "batch-001",
|
||||||
|
"outputDirectory": null,
|
||||||
|
"cropStartRatio": 0.5,
|
||||||
|
"horizontalTrimRatio": 0.25,
|
||||||
|
"recursive": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`inputDirectory` 默认相对于 `WEIGHT_OCR_INPUT_ROOT`,`outputDirectory` 默认在 `WEIGHT_OCR_OUTPUT_ROOT` 下按批次 ID 自动创建。接口拒绝访问两个受控根目录之外的路径。
|
||||||
|
|
||||||
|
## 必需配置
|
||||||
|
|
||||||
|
```text
|
||||||
|
BAIDU_OCR_ENABLED=true
|
||||||
|
BAIDU_OCR_API_KEY=百度应用的API Key
|
||||||
|
BAIDU_OCR_SECRET_KEY=百度应用的Secret Key
|
||||||
|
INTERNAL_SERVICE_TOKEN=至少32字节的内部令牌
|
||||||
|
```
|
||||||
|
|
||||||
|
本地默认目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${java.io.tmpdir}/baiyue-weight-ocr/input
|
||||||
|
${java.io.tmpdir}/baiyue-weight-ocr/output
|
||||||
|
```
|
||||||
|
|
||||||
|
国内服务器默认目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/baiyue/weight-ocr/input
|
||||||
|
/data/baiyue/weight-ocr/output
|
||||||
|
```
|
||||||
|
|
||||||
|
可选配置:
|
||||||
|
|
||||||
|
| 环境变量 | 默认值 | 说明 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `WEIGHT_OCR_MAX_IMAGE_SIZE` | `3MB` | 单张原图及裁剪图上限 |
|
||||||
|
| `WEIGHT_OCR_MAX_IMAGE_PIXELS` | `40000000` | 解码后最大像素数 |
|
||||||
|
| `WEIGHT_OCR_MAX_BATCH_SIZE` | `50` | 单批图片上限 |
|
||||||
|
| `BAIDU_OCR_CONNECT_TIMEOUT` | `10s` | 建连超时 |
|
||||||
|
| `BAIDU_OCR_REQUEST_TIMEOUT` | `60s` | 单次请求超时 |
|
||||||
|
| `BAIDU_OCR_MIN_REQUEST_INTERVAL` | `600ms` | 百度请求最小间隔 |
|
||||||
|
| `BAIDU_OCR_MAX_QPS_RETRIES` | `3` | 百度 QPS 限制重试次数 |
|
||||||
|
|
||||||
|
## 输出文件
|
||||||
|
|
||||||
|
```text
|
||||||
|
批次输出目录/
|
||||||
|
├─ batch-results.json
|
||||||
|
├─ cropped/
|
||||||
|
└─ raw/
|
||||||
|
```
|
||||||
|
|
||||||
|
接口和结果文件仅返回相对于受控根目录的路径,不返回服务器绝对路径。真实百度响应不写入日志,只保存在批次的 `raw` 目录。
|
||||||
|
|
||||||
|
## 安全边界
|
||||||
|
|
||||||
|
- 接口仅供内部服务调用,必须校验 `X-Internal-Service-Token`。
|
||||||
|
- 百度能力默认关闭,密钥只从环境变量或配置中心密文注入。
|
||||||
|
- 同一实例一次只执行一个 OCR 批次,避免并发压垮第三方接口。
|
||||||
|
- 单批顺序调用百度接口,并对 QPS 错误执行有上限的退避重试。
|
||||||
|
- 支持 `jpg`、`jpeg`、`png`、`bmp`,同时限制文件大小和解码像素数。
|
||||||
|
- 当前不访问业务数据库,识别结果由调用方按自己的业务边界处理。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.baiyue.file.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class QualificationFileContractTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fileServiceExposesTheOnlyPublicMultipartUploadEntry() {
|
||||||
|
RequestMapping baseMapping = QualificationFileController.class.getAnnotation(RequestMapping.class);
|
||||||
|
assertTrue(Arrays.asList(baseMapping.value())
|
||||||
|
.contains("/api/merchant/v1/files/qualifications"));
|
||||||
|
|
||||||
|
boolean hasMultipartPost = Arrays.stream(QualificationFileController.class.getDeclaredMethods())
|
||||||
|
.map(method -> method.getAnnotation(PostMapping.class))
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.flatMap(mapping -> Arrays.stream(mapping.consumes()))
|
||||||
|
.anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals);
|
||||||
|
|
||||||
|
assertTrue(hasMultipartPost, "企业资质真实上传入口必须保持 multipart/form-data 契约");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.baiyue.file.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class LocalQualificationStorageTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path storageRoot;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeReturnsFileServiceUrlBackedByARealTenantFile() throws Exception {
|
||||||
|
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||||
|
byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01};
|
||||||
|
var upload = new MockMultipartFile("file", "license.png", "image/png", png);
|
||||||
|
|
||||||
|
var stored = storage.store(10001L, upload);
|
||||||
|
|
||||||
|
assertTrue(stored.documentUrl().matches(
|
||||||
|
"/api/merchant/v1/files/qualifications/[0-9a-f-]{36}\\.png"));
|
||||||
|
assertTrue(Files.isRegularFile(stored.path()));
|
||||||
|
assertEquals(stored.path().toAbsolutePath(),
|
||||||
|
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacySeedPathCannotBePretendedToBeAStoredFile() {
|
||||||
|
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||||
|
|
||||||
|
assertThrows(BizException.class,
|
||||||
|
() -> storage.load(10001L, "demo-business-license.jpg"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.baiyue.firstleg.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
/** 头程履约任务分页查询条件。 */
|
||||||
|
@Schema(name = "FulfillmentTaskQuery", description = "头程履约任务分页查询条件")
|
||||||
|
public record FulfillmentTaskQuery(
|
||||||
|
@Schema(description = "订单号关键字") @Size(max = 64) String keyword,
|
||||||
|
@Schema(description = "任务状态")
|
||||||
|
@Size(max = 24) @Pattern(regexp = "PENDING_ASSIGNMENT|ASSIGNED|PROCESSING|COMPLETED|CANCELLED") String status,
|
||||||
|
@Schema(description = "负责人用户ID") Long assigneeId,
|
||||||
|
@Schema(description = "页码", defaultValue = "1") @Min(1) long page,
|
||||||
|
@Schema(description = "每页条数", defaultValue = "20") @Min(1) @Max(200) long size
|
||||||
|
) {
|
||||||
|
public FulfillmentTaskQuery {
|
||||||
|
if (page == 0) {
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
if (size == 0) {
|
||||||
|
size = 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
USE baiyue_firstleg;
|
||||||
|
|
||||||
|
-- 既有履约任务表补充分页查询索引;重复执行时不重复创建。
|
||||||
|
SET @firstleg_task_list_index_sql = IF(
|
||||||
|
EXISTS(SELECT 1 FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'fl_fulfillment_task'
|
||||||
|
AND index_name = 'idx_task_tenant_created'),
|
||||||
|
'SELECT 1',
|
||||||
|
'CREATE INDEX idx_task_tenant_created ON fl_fulfillment_task (tenant_id, created_at)'
|
||||||
|
);
|
||||||
|
PREPARE firstleg_task_list_index_stmt FROM @firstleg_task_list_index_sql;
|
||||||
|
EXECUTE firstleg_task_list_index_stmt;
|
||||||
|
DEALLOCATE PREPARE firstleg_task_list_index_stmt;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: 柏悦头程履约 API
|
||||||
|
version: 1.1.0
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
paths:
|
||||||
|
/api/platform/v1/first-leg/tasks:
|
||||||
|
get:
|
||||||
|
summary: 分页查询履约任务
|
||||||
|
description: 需要 platform:firstleg:task:list 权限
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- $ref: '#/components/parameters/Page'
|
||||||
|
- $ref: '#/components/parameters/Size'
|
||||||
|
- {name: keyword, in: query, schema: {type: string, maxLength: 64}}
|
||||||
|
- {name: status, in: query, schema: {type: string, enum: [PENDING_ASSIGNMENT, ASSIGNED, PROCESSING, COMPLETED, CANCELLED]}}
|
||||||
|
- {name: assigneeId, in: query, schema: {type: integer, format: int64}}
|
||||||
|
responses:
|
||||||
|
'200': {description: 查询成功}
|
||||||
|
'403': {description: 无查询权限}
|
||||||
|
/api/platform/v1/first-leg/tasks/{taskId}:
|
||||||
|
get:
|
||||||
|
summary: 查询履约任务详情
|
||||||
|
description: 需要 platform:firstleg:task:view 权限
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- $ref: '#/components/parameters/TaskId'
|
||||||
|
responses:
|
||||||
|
'200': {description: 查询成功}
|
||||||
|
'403': {description: 无查看权限}
|
||||||
|
'404': {description: 任务不存在}
|
||||||
|
/api/platform/v1/first-leg/tasks/by-order/{orderId}:
|
||||||
|
get:
|
||||||
|
summary: 按订单查询履约任务
|
||||||
|
description: 需要 platform:firstleg:task:view 权限
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- {name: orderId, in: path, required: true, schema: {type: integer, format: int64}}
|
||||||
|
responses:
|
||||||
|
'200': {description: 查询成功}
|
||||||
|
'403': {description: 无查看权限}
|
||||||
|
'404': {description: 任务不存在}
|
||||||
|
/api/platform/v1/first-leg/tasks/{taskId}/assignment-history:
|
||||||
|
get:
|
||||||
|
summary: 分页查询任务分配历史
|
||||||
|
description: 需要 platform:firstleg:assignment:history 权限
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- $ref: '#/components/parameters/TaskId'
|
||||||
|
- $ref: '#/components/parameters/Page'
|
||||||
|
- $ref: '#/components/parameters/Size'
|
||||||
|
responses:
|
||||||
|
'200': {description: 查询成功}
|
||||||
|
'403': {description: 无历史查看权限}
|
||||||
|
'404': {description: 任务不存在}
|
||||||
|
/api/platform/v1/first-leg/tasks/{taskId}/assignment:
|
||||||
|
post:
|
||||||
|
summary: 分配或改派履约任务
|
||||||
|
description: 需要 platform:firstleg:assignment:assign 权限
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- $ref: '#/components/parameters/TaskId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/AssignRequest'}
|
||||||
|
responses:
|
||||||
|
'200': {description: 分配成功}
|
||||||
|
'403': {description: 无分配权限}
|
||||||
|
'404': {description: 任务不存在}
|
||||||
|
'409': {description: 任务已关闭或发生并发更新}
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
parameters:
|
||||||
|
TenantId:
|
||||||
|
name: tenantId
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema: {type: integer, format: int64, minimum: 1}
|
||||||
|
TaskId:
|
||||||
|
name: taskId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: {type: integer, format: int64}
|
||||||
|
Page:
|
||||||
|
name: page
|
||||||
|
in: query
|
||||||
|
schema: {type: integer, format: int64, minimum: 1, default: 1}
|
||||||
|
Size:
|
||||||
|
name: size
|
||||||
|
in: query
|
||||||
|
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
|
||||||
|
schemas:
|
||||||
|
AssignRequest:
|
||||||
|
type: object
|
||||||
|
required: [assigneeId, assigneeName]
|
||||||
|
properties:
|
||||||
|
assigneeId: {type: integer, format: int64}
|
||||||
|
assigneeName: {type: string, maxLength: 64}
|
||||||
|
reason: {type: string, maxLength: 500}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.baiyue.firstleg.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.firstleg.dto.AssignRequest;
|
||||||
|
import com.baiyue.firstleg.dto.FulfillmentTaskQuery;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FulfillmentTaskControllerContractTest {
|
||||||
|
@Test
|
||||||
|
void 查询和分配接口声明对应权限码() throws Exception {
|
||||||
|
assertAuthority("page", "hasAuthority('platform:firstleg:task:list')",
|
||||||
|
AuthenticatedUser.class, long.class, FulfillmentTaskQuery.class);
|
||||||
|
assertAuthority("detail", "hasAuthority('platform:firstleg:task:view')",
|
||||||
|
AuthenticatedUser.class, long.class, long.class);
|
||||||
|
assertAuthority("byOrder", "hasAuthority('platform:firstleg:task:view')",
|
||||||
|
AuthenticatedUser.class, long.class, long.class);
|
||||||
|
assertAuthority("assignmentHistory", "hasAuthority('platform:firstleg:assignment:history')",
|
||||||
|
AuthenticatedUser.class, long.class, long.class, long.class, long.class);
|
||||||
|
assertAuthority("assign", "hasAuthority('platform:firstleg:assignment:assign')",
|
||||||
|
AuthenticatedUser.class, long.class, long.class, AssignRequest.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 分页查询缺省值为第一页每页二十条() {
|
||||||
|
FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 0, 0);
|
||||||
|
assertThat(query.page()).isEqualTo(1);
|
||||||
|
assertThat(query.size()).isEqualTo(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void OpenAPI覆盖四类只读接口和对应权限() throws Exception {
|
||||||
|
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(openapi)
|
||||||
|
.contains("/api/platform/v1/first-leg/tasks:")
|
||||||
|
.contains("/api/platform/v1/first-leg/tasks/{taskId}:")
|
||||||
|
.contains("/api/platform/v1/first-leg/tasks/by-order/{orderId}:")
|
||||||
|
.contains("/api/platform/v1/first-leg/tasks/{taskId}/assignment-history:")
|
||||||
|
.contains("platform:firstleg:task:list")
|
||||||
|
.contains("platform:firstleg:assignment:history");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertAuthority(String methodName, String permission, Class<?>... parameterTypes)
|
||||||
|
throws NoSuchMethodException {
|
||||||
|
Method method = PlatformFulfillmentAssignmentController.class.getDeclaredMethod(methodName, parameterTypes);
|
||||||
|
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.dto.FulfillmentTaskQuery;
|
||||||
|
import com.baiyue.firstleg.entity.FulfillmentTaskEntity;
|
||||||
|
import com.baiyue.firstleg.repository.FulfillmentTaskRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class FulfillmentTaskServiceTest {
|
||||||
|
@Test
|
||||||
|
void 列表查询保留租户和分页条件() {
|
||||||
|
FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class);
|
||||||
|
FulfillmentTaskQuery query = new FulfillmentTaskQuery(null, null, null, 1, 20);
|
||||||
|
PageResult<FulfillmentTaskEntity> expected = new PageResult<>(List.of(), 1, 20, 0, 0);
|
||||||
|
when(repository.page(10001L, query)).thenReturn(expected);
|
||||||
|
|
||||||
|
assertThat(service(repository).page(10001L, query)).isSameAs(expected);
|
||||||
|
verify(repository).page(10001L, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 详情和按订单查询不存在时返回404() {
|
||||||
|
FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class);
|
||||||
|
FulfillmentTaskService service = service(repository);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.detail(10001L, 1L))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||||
|
.isEqualTo(ResultCode.NOT_FOUND);
|
||||||
|
assertThatThrownBy(() -> service.byOrder(10001L, 2L))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||||
|
.isEqualTo(ResultCode.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 查询分配历史前先校验任务归属租户() {
|
||||||
|
FulfillmentTaskRepository repository = mock(FulfillmentTaskRepository.class);
|
||||||
|
FulfillmentTaskEntity task = new FulfillmentTaskEntity();
|
||||||
|
when(repository.findOwned(10001L, 1L)).thenReturn(task);
|
||||||
|
PageResult<com.baiyue.firstleg.entity.AssignmentHistoryEntity> expected =
|
||||||
|
new PageResult<>(List.of(), 1, 20, 0, 0);
|
||||||
|
when(repository.pageHistory(10001L, 1L, 1, 20)).thenReturn(expected);
|
||||||
|
|
||||||
|
assertThat(service(repository).assignmentHistory(10001L, 1L, 1, 20)).isSameAs(expected);
|
||||||
|
verify(repository).findOwned(10001L, 1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FulfillmentTaskService service(FulfillmentTaskRepository repository) {
|
||||||
|
FulfillmentTaskService service = new FulfillmentTaskService();
|
||||||
|
ReflectionTestUtils.setField(service, "repository", repository);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.baiyue.iam.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.iam.service.AuthorizationSnapshotService;
|
||||||
|
import com.baiyue.iam.service.TenantGuard;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前登录成员的有效权限查询接口。
|
||||||
|
*
|
||||||
|
* <p>此接口只要求调用方已通过对应应用端的 JWT 鉴权;不能以角色管理权限作为
|
||||||
|
* 初始化登录会话的前置条件。权限从 IAM 当前有效成员、角色和权限资源实时计算,
|
||||||
|
* 因此角色被停用或权限被回收后,下一次查询会立即生效。</p>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "Authorization / 当前用户授权", description = "Returns current member effective permission codes.")
|
||||||
|
public class AuthorizationController {
|
||||||
|
@Resource
|
||||||
|
private AuthorizationSnapshotService authorizationSnapshotService;
|
||||||
|
|
||||||
|
@GetMapping("/api/{app}/v1/iam/authorization/permissions")
|
||||||
|
@Operation(summary = "Get current effective permissions / 查询当前用户有效权限",
|
||||||
|
description = "Does not require role-management permissions. / 不依赖角色管理权限。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Permissions returned"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Wrong application scope or inactive identity")
|
||||||
|
})
|
||||||
|
public ApiResponse<Set<String>> permissions(@PathVariable String app,
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
|
String appCode = appCode(app);
|
||||||
|
TenantGuard.tenant(user, appCode);
|
||||||
|
return ApiResponse.success(authorizationSnapshotService.currentPermissions(user, appCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String appCode(String app) {
|
||||||
|
return switch (app) {
|
||||||
|
case "platform" -> "PLATFORM";
|
||||||
|
case "merchant" -> "MERCHANT";
|
||||||
|
default -> throw new BizException(ResultCode.BAD_REQUEST, "Unsupported application scope / 不支持的应用端");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.baiyue.iam.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 平台内部用户公开接口的数据契约,不向前端暴露密码摘要等认证敏感字段。 */
|
||||||
|
public final class PlatformInternalUserDtos {
|
||||||
|
private PlatformInternalUserDtos() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同时兼容历史的 authUserId 关联模式和完整账号创建模式。
|
||||||
|
* authUserId 有值时关联已有认证账号;否则 username、password 必填并由 IAM 编排 Auth 创建账号。
|
||||||
|
*/
|
||||||
|
public record CreateInternalUserRequest(
|
||||||
|
Long authUserId,
|
||||||
|
String userType,
|
||||||
|
@NotBlank @Size(max = 64) String displayName,
|
||||||
|
@Size(max = 32) String phone,
|
||||||
|
@Email @Size(max = 128) String email,
|
||||||
|
@Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
|
||||||
|
@Size(min = 8, max = 64)
|
||||||
|
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
||||||
|
String password,
|
||||||
|
@Size(max = 64) String memberNo,
|
||||||
|
List<Long> roleIds) { }
|
||||||
|
|
||||||
|
public record UpdateInternalUserRequest(
|
||||||
|
@NotBlank @Size(max = 64) String displayName,
|
||||||
|
@Size(max = 32) String phone,
|
||||||
|
@Email @Size(max = 128) String email,
|
||||||
|
@NotBlank @Size(max = 64) String memberNo,
|
||||||
|
@NotNull List<Long> roleIds) { }
|
||||||
|
|
||||||
|
public record ChangeInternalUserStatusRequest(
|
||||||
|
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { }
|
||||||
|
|
||||||
|
public record ResetInternalUserPasswordRequest(
|
||||||
|
@NotBlank @Size(min = 8, max = 64)
|
||||||
|
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
||||||
|
String newPassword) { }
|
||||||
|
|
||||||
|
public record InternalUserResponse(
|
||||||
|
Long membershipId,
|
||||||
|
Long userId,
|
||||||
|
Long authUserId,
|
||||||
|
String username,
|
||||||
|
String displayName,
|
||||||
|
String phone,
|
||||||
|
String email,
|
||||||
|
String memberType,
|
||||||
|
String memberNo,
|
||||||
|
String status,
|
||||||
|
List<Long> roleIds) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
package com.baiyue.iam.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.audit.BusinessAudit;
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.iam.client.AuthAccountClient;
|
||||||
|
import com.baiyue.iam.dto.PlatformInternalUserDtos.*;
|
||||||
|
import com.baiyue.iam.entity.IamMembership;
|
||||||
|
import com.baiyue.iam.entity.IamRole;
|
||||||
|
import com.baiyue.iam.entity.IamUser;
|
||||||
|
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||||
|
import com.baiyue.iam.repository.MemberRepository;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import feign.FeignException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** 平台内部用户编排服务:IAM 保存人员和角色,账号凭据只由 Auth 保存。 */
|
||||||
|
@Service
|
||||||
|
public class PlatformInternalUserService {
|
||||||
|
private static final long PLATFORM_TENANT_ID = 0L;
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PlatformInternalUserService.class);
|
||||||
|
|
||||||
|
private final MemberRepository repository;
|
||||||
|
private final IamRoleMapper roleMapper;
|
||||||
|
private final AuthAccountClient authClient;
|
||||||
|
private final String internalToken;
|
||||||
|
|
||||||
|
public PlatformInternalUserService(MemberRepository repository, IamRoleMapper roleMapper,
|
||||||
|
AuthAccountClient authClient,
|
||||||
|
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.roleMapper = roleMapper;
|
||||||
|
this.authClient = authClient;
|
||||||
|
this.internalToken = internalToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<InternalUserResponse> list() {
|
||||||
|
return repository.findByTenant(PLATFORM_TENANT_ID).stream()
|
||||||
|
.map(this::response).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_CREATE", resourceType = "MEMBERSHIP",
|
||||||
|
resourceId = "#request.username", description = "新增平台内部用户", captureAfter = false)
|
||||||
|
public InternalUserResponse create(CreateInternalUserRequest request) {
|
||||||
|
validateLegacyType(request.userType());
|
||||||
|
List<Long> roleIds = normalizeRoles(request.roleIds());
|
||||||
|
validateRoles(roleIds);
|
||||||
|
|
||||||
|
if (request.authUserId() != null) return linkExistingAccount(request, roleIds);
|
||||||
|
if (!StringUtils.hasText(request.username()) || !StringUtils.hasText(request.password())) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "新建平台用户时登录名和密码必填");
|
||||||
|
}
|
||||||
|
|
||||||
|
long authUserId = IdWorker.getId();
|
||||||
|
long userId = IdWorker.getId();
|
||||||
|
long membershipId = IdWorker.getId();
|
||||||
|
String memberNo = memberNo(request.memberNo(), membershipId);
|
||||||
|
validateMemberNo(memberNo, null);
|
||||||
|
|
||||||
|
call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest(
|
||||||
|
authUserId, request.username(), request.password(), request.displayName(),
|
||||||
|
PLATFORM_TENANT_ID, membershipId, "PLATFORM")));
|
||||||
|
try {
|
||||||
|
IamUser user = newUser(userId, authUserId, request.displayName(), request.phone(), request.email());
|
||||||
|
IamMembership membership = newMembership(membershipId, userId, memberNo);
|
||||||
|
repository.insertUser(user);
|
||||||
|
repository.insert(membership);
|
||||||
|
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
|
||||||
|
return response(membership, user, request.username(), roleIds);
|
||||||
|
} catch (RuntimeException localFailure) {
|
||||||
|
compensateCreate(authUserId, localFailure);
|
||||||
|
throw localFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_UPDATE", resourceType = "MEMBERSHIP",
|
||||||
|
resourceId = "#membershipId", description = "编辑平台内部用户", captureAfter = false)
|
||||||
|
public InternalUserResponse update(Long membershipId, UpdateInternalUserRequest request) {
|
||||||
|
List<Long> roleIds = normalizeRoles(request.roleIds());
|
||||||
|
validateRoles(roleIds);
|
||||||
|
validateMemberNo(request.memberNo(), membershipId);
|
||||||
|
IamMembership membership = required(membershipId);
|
||||||
|
IamUser user = requiredUser(membership.getUserId());
|
||||||
|
AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId());
|
||||||
|
boolean displayNameChanged = !Objects.equals(user.getDisplayName(), request.displayName());
|
||||||
|
if (displayNameChanged) {
|
||||||
|
call(() -> authClient.profile(internalToken, user.getAuthUserId(),
|
||||||
|
new AuthAccountClient.ProfileRequest(request.displayName())));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
user.setDisplayName(request.displayName());
|
||||||
|
user.setPhone(request.phone());
|
||||||
|
user.setEmail(request.email());
|
||||||
|
membership.setMemberNo(request.memberNo());
|
||||||
|
repository.saveUser(user);
|
||||||
|
repository.save(membership);
|
||||||
|
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
|
||||||
|
} catch (RuntimeException localFailure) {
|
||||||
|
if (displayNameChanged) compensateProfile(user.getAuthUserId(), oldAccount.displayName(), localFailure);
|
||||||
|
throw localFailure;
|
||||||
|
}
|
||||||
|
return response(membership, user, request.displayName(), roleIds, oldAccount.username());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP",
|
||||||
|
resourceId = "#membershipId", description = "启停平台内部用户", captureAfter = false)
|
||||||
|
public InternalUserResponse changeStatus(Long membershipId, ChangeInternalUserStatusRequest request,
|
||||||
|
Long operatorMembershipId) {
|
||||||
|
if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status())) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "平台内部用户状态只允许 ACTIVE 或 DISABLED");
|
||||||
|
}
|
||||||
|
IamMembership membership = required(membershipId);
|
||||||
|
if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户");
|
||||||
|
}
|
||||||
|
IamUser user = requiredUser(membership.getUserId());
|
||||||
|
List<Long> roleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId);
|
||||||
|
AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId());
|
||||||
|
call(() -> authClient.status(internalToken, user.getAuthUserId(),
|
||||||
|
new AuthAccountClient.StatusRequest(request.status())));
|
||||||
|
try {
|
||||||
|
membership.setStatus(request.status());
|
||||||
|
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
|
||||||
|
user.setStatus(request.status());
|
||||||
|
repository.saveUser(user);
|
||||||
|
repository.save(membership);
|
||||||
|
return response(membership, user, authAccount.username(), roleIds);
|
||||||
|
} catch (RuntimeException localFailure) {
|
||||||
|
compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure);
|
||||||
|
throw localFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP",
|
||||||
|
resourceId = "#membershipId", description = "重置平台内部用户密码", captureAfter = false)
|
||||||
|
public void resetPassword(Long membershipId, ResetInternalUserPasswordRequest request) {
|
||||||
|
IamMembership membership = required(membershipId);
|
||||||
|
IamUser user = requiredUser(membership.getUserId());
|
||||||
|
call(() -> authClient.reset(internalToken, user.getAuthUserId(),
|
||||||
|
new AuthAccountClient.PasswordRequest(request.newPassword())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private InternalUserResponse linkExistingAccount(CreateInternalUserRequest request, List<Long> roleIds) {
|
||||||
|
IamUser existingUser = repository.findUserByAuthId(request.authUserId());
|
||||||
|
IamMembership existingMembership = repository.findByTenantAndAuthUser(
|
||||||
|
PLATFORM_TENANT_ID, request.authUserId());
|
||||||
|
if (existingMembership != null) return response(existingMembership);
|
||||||
|
if (existingUser != null && !"INTERNAL".equals(existingUser.getUserType())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "认证账号已关联其他类型的 IAM 用户");
|
||||||
|
}
|
||||||
|
AuthAccountClient.AccountSummary authAccount = account(request.authUserId());
|
||||||
|
long membershipId = IdWorker.getId();
|
||||||
|
String memberNo = memberNo(request.memberNo(), membershipId);
|
||||||
|
validateMemberNo(memberNo, null);
|
||||||
|
IamUser user = existingUser == null
|
||||||
|
? newUser(IdWorker.getId(), request.authUserId(), request.displayName(), request.phone(), request.email())
|
||||||
|
: existingUser;
|
||||||
|
if (existingUser == null) repository.insertUser(user);
|
||||||
|
IamMembership membership = newMembership(membershipId, user.getId(), memberNo);
|
||||||
|
repository.insert(membership);
|
||||||
|
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
|
||||||
|
return response(membership, user, authAccount.username(), roleIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InternalUserResponse response(IamMembership membership) {
|
||||||
|
IamUser user = requiredUser(membership.getUserId());
|
||||||
|
AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId());
|
||||||
|
return response(membership, user, authAccount.username(),
|
||||||
|
repository.findRoleIds(PLATFORM_TENANT_ID, membership.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private InternalUserResponse response(IamMembership membership, IamUser user,
|
||||||
|
String username, List<Long> roleIds) {
|
||||||
|
return response(membership, user, user.getDisplayName(), roleIds, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InternalUserResponse response(IamMembership membership, IamUser user, String displayName,
|
||||||
|
List<Long> roleIds, String username) {
|
||||||
|
return new InternalUserResponse(membership.getId(), user.getId(), user.getAuthUserId(), username,
|
||||||
|
displayName, user.getPhone(), user.getEmail(), membership.getMemberType(),
|
||||||
|
membership.getMemberNo(), membership.getStatus(), List.copyOf(roleIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IamUser newUser(long userId, long authUserId, String displayName, String phone, String email) {
|
||||||
|
IamUser user = new IamUser();
|
||||||
|
user.setId(userId);
|
||||||
|
user.setAuthUserId(authUserId);
|
||||||
|
user.setUserType("INTERNAL");
|
||||||
|
user.setDisplayName(displayName);
|
||||||
|
user.setPhone(phone);
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setStatus("ACTIVE");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IamMembership newMembership(long membershipId, long userId, String memberNo) {
|
||||||
|
IamMembership membership = new IamMembership();
|
||||||
|
membership.setId(membershipId);
|
||||||
|
membership.setTenantId(PLATFORM_TENANT_ID);
|
||||||
|
membership.setUserId(userId);
|
||||||
|
membership.setMemberType("EMPLOYEE");
|
||||||
|
membership.setMemberNo(memberNo);
|
||||||
|
membership.setStatus("ACTIVE");
|
||||||
|
membership.setJoinedAt(LocalDateTime.now());
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IamMembership required(Long membershipId) {
|
||||||
|
IamMembership membership = repository.findByTenantAndId(PLATFORM_TENANT_ID, membershipId);
|
||||||
|
if (membership == null) throw new BizException(ResultCode.NOT_FOUND, "平台内部用户不存在");
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IamUser requiredUser(Long userId) {
|
||||||
|
IamUser user = repository.findUser(userId);
|
||||||
|
if (user == null || !"INTERNAL".equals(user.getUserType())) {
|
||||||
|
throw new BizException(ResultCode.NOT_FOUND, "平台内部用户档案不存在");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> normalizeRoles(List<Long> roleIds) {
|
||||||
|
if (roleIds == null) return List.of();
|
||||||
|
if (roleIds.stream().anyMatch(Objects::isNull)) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "角色 ID 不能为空");
|
||||||
|
}
|
||||||
|
return List.copyOf(new LinkedHashSet<>(roleIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRoles(List<Long> roleIds) {
|
||||||
|
for (Long roleId : roleIds) {
|
||||||
|
IamRole role = roleMapper.selectById(roleId);
|
||||||
|
if (role == null || role.getTenantId() == null || role.getTenantId() != PLATFORM_TENANT_ID
|
||||||
|
|| !"PLATFORM".equals(role.getAppCode()) || !"ACTIVE".equals(role.getStatus())) {
|
||||||
|
throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMemberNo(String memberNo, Long excludedMembershipId) {
|
||||||
|
if (repository.existsMemberNo(PLATFORM_TENANT_ID, memberNo, excludedMembershipId)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "平台成员编号已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String memberNo(String requested, long membershipId) {
|
||||||
|
return StringUtils.hasText(requested) ? requested.trim() : "PLATFORM-" + membershipId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateLegacyType(String userType) {
|
||||||
|
if (StringUtils.hasText(userType)
|
||||||
|
&& !"INTERNAL".equals(userType) && !"PLATFORM".equals(userType)) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "平台内部用户类型必须是 INTERNAL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthAccountClient.AccountSummary account(Long authUserId) {
|
||||||
|
return data(call(() -> authClient.summary(internalToken, authUserId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void compensateCreate(long authUserId, RuntimeException localFailure) {
|
||||||
|
try {
|
||||||
|
call(() -> authClient.compensate(internalToken, authUserId));
|
||||||
|
} catch (RuntimeException compensationFailure) {
|
||||||
|
log.error("平台用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||||
|
"平台用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void compensateProfile(long authUserId, String oldDisplayName, RuntimeException localFailure) {
|
||||||
|
try {
|
||||||
|
call(() -> authClient.profile(internalToken, authUserId,
|
||||||
|
new AuthAccountClient.ProfileRequest(oldDisplayName)));
|
||||||
|
} catch (RuntimeException compensationFailure) {
|
||||||
|
log.error("平台用户编辑失败且认证资料补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||||
|
"平台用户编辑未完成,认证资料补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void compensateStatus(long authUserId, String oldStatus, RuntimeException localFailure) {
|
||||||
|
try {
|
||||||
|
call(() -> authClient.status(internalToken, authUserId,
|
||||||
|
new AuthAccountClient.StatusRequest(oldStatus)));
|
||||||
|
} catch (RuntimeException compensationFailure) {
|
||||||
|
log.error("平台用户状态修改失败且认证状态补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||||
|
"平台用户状态未完成,认证状态补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> ApiResponse<T> call(RemoteCall<T> action) {
|
||||||
|
try {
|
||||||
|
ApiResponse<T> result = action.call();
|
||||||
|
if (result == null || !result.successful()) throw unavailable();
|
||||||
|
return result;
|
||||||
|
} catch (FeignException exception) {
|
||||||
|
if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
|
||||||
|
if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在");
|
||||||
|
throw unavailable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T data(ApiResponse<T> response) {
|
||||||
|
if (response.data() == null) throw unavailable();
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
private BizException unavailable() {
|
||||||
|
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface RemoteCall<T> { ApiResponse<T> call(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.baiyue.iam.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
|
class AuthorizationControllerContractTest {
|
||||||
|
@Test
|
||||||
|
void 当前权限接口不应依赖角色管理权限() throws Exception {
|
||||||
|
Method method = AuthorizationController.class.getMethod("permissions", String.class,
|
||||||
|
AuthenticatedUser.class);
|
||||||
|
|
||||||
|
assertEquals("/api/{app}/v1/iam/authorization/permissions",
|
||||||
|
method.getAnnotation(GetMapping.class).value()[0]);
|
||||||
|
assertNull(method.getAnnotation(PreAuthorize.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.iam.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class IamOpenApiContractTest {
|
||||||
|
@Test
|
||||||
|
void OpenApi应声明权限分页和内部用户的真实响应结构() throws IOException {
|
||||||
|
String document;
|
||||||
|
try (var stream = getClass().getResourceAsStream("/openapi/iam-v1.yaml")) {
|
||||||
|
assertThat(stream).isNotNull();
|
||||||
|
document = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(document).contains("/api/platform/v1/iam/authorization/permissions:")
|
||||||
|
.contains("/api/merchant/v1/iam/authorization/permissions:")
|
||||||
|
.contains("PermissionCodesApiResponse:")
|
||||||
|
.contains("TenantPageApiResponse:")
|
||||||
|
.contains("InternalUserListApiResponse:")
|
||||||
|
.contains("membershipId: {type: integer, format: int64}")
|
||||||
|
.contains("roleIds: {type: array, items: {type: integer, format: int64}}")
|
||||||
|
.contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}");
|
||||||
|
|
||||||
|
Map<String, Object> openApi = new Yaml().load(document);
|
||||||
|
assertThat(openApi).containsEntry("openapi", "3.0.3");
|
||||||
|
assertThat(openApi.get("paths")).isInstanceOf(Map.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.baiyue.iam.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class PlatformInternalUserContractTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 内部用户列表必须校验列表权限() throws Exception {
|
||||||
|
Method method = ProfileResourceController.class.getMethod("internalUsers",
|
||||||
|
com.baiyue.common.security.AuthenticatedUser.class);
|
||||||
|
|
||||||
|
assertEquals("hasAuthority('platform:user:list')",
|
||||||
|
method.getAnnotation(PreAuthorize.class).value());
|
||||||
|
assertEquals("/api/platform/v1/iam/internal-users",
|
||||||
|
method.getAnnotation(GetMapping.class).value()[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 平台用户写接口以成员关系主键寻址() {
|
||||||
|
assertMapping("updateInternalUser", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}");
|
||||||
|
assertMapping("changeInternalUserStatus",
|
||||||
|
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status");
|
||||||
|
assertMapping("resetInternalUserPassword",
|
||||||
|
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/password");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 企业资料查看必须校验查看权限() throws Exception {
|
||||||
|
Method method = MerchantTenantController.class.getMethod("profile",
|
||||||
|
com.baiyue.common.security.AuthenticatedUser.class);
|
||||||
|
assertEquals("hasAuthority('merchant:profile:view')",
|
||||||
|
method.getAnnotation(PreAuthorize.class).value());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertMapping(String methodName, String path) {
|
||||||
|
Method method = java.util.Arrays.stream(ProfileResourceController.class.getMethods())
|
||||||
|
.filter(candidate -> candidate.getName().equals(methodName))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals(path, method.getAnnotation(PutMapping.class).value()[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.baiyue.iam.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
import java.lang.annotation.Annotation;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
class PlatformTenantControllerContractTest {
|
||||||
|
@Test
|
||||||
|
void 客户分页接口声明权限分页约束和错误状态() throws Exception {
|
||||||
|
Method method = PlatformTenantController.class.getMethod("page", long.class, long.class,
|
||||||
|
String.class, String.class, AuthenticatedUser.class);
|
||||||
|
|
||||||
|
assertEquals("/tenants", method.getAnnotation(GetMapping.class).value()[0]);
|
||||||
|
assertEquals("hasAuthority('platform:customer:list')",
|
||||||
|
method.getAnnotation(PreAuthorize.class).value());
|
||||||
|
assertNotNull(annotation(method, 0, Min.class));
|
||||||
|
assertNotNull(annotation(method, 1, Min.class));
|
||||||
|
assertEquals(200, annotation(method, 1, Max.class).value());
|
||||||
|
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED",
|
||||||
|
annotation(method, 3, Pattern.class).regexp());
|
||||||
|
|
||||||
|
Set<String> statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
|
||||||
|
.map(io.swagger.v3.oas.annotations.responses.ApiResponse::responseCode)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
assertEquals(Set.of("200", "400", "401", "403", "422"), statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T extends Annotation> T annotation(Method method, int parameter, Class<T> type) {
|
||||||
|
return method.getParameters()[parameter].getAnnotation(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.baiyue.iam.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class QualificationUploadContractTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void iamControllerDoesNotExposeQualificationUpload() {
|
||||||
|
boolean exposesLegacyPost = java.util.Arrays.stream(ProfileResourceController.class.getDeclaredMethods())
|
||||||
|
.map(method -> method.getAnnotation(PostMapping.class))
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.flatMap(mapping -> java.util.Arrays.stream(mapping.value()))
|
||||||
|
.anyMatch("/api/merchant/v1/iam/tenant/qualifications"::equals);
|
||||||
|
|
||||||
|
assertFalse(exposesLegacyPost, "IAM 商户资质接口只读,上传必须由 File 服务处理");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void staticOpenApiDoesNotAdvertiseLegacyQualificationPost() throws IOException {
|
||||||
|
try (var input = getClass().getClassLoader().getResourceAsStream("openapi/iam-v1.yaml")) {
|
||||||
|
assertNotNull(input);
|
||||||
|
String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
int pathStart = yaml.indexOf(" /api/merchant/v1/iam/tenant/qualifications:");
|
||||||
|
int nextPath = yaml.indexOf("\n /api/", pathStart + 1);
|
||||||
|
assertTrue(pathStart >= 0 && nextPath > pathStart);
|
||||||
|
String pathContract = yaml.substring(pathStart, nextPath);
|
||||||
|
|
||||||
|
assertFalse(pathContract.contains("\n post:"));
|
||||||
|
assertFalse(yaml.contains(" QualificationRequest:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.baiyue.iam.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.iam.client.AuthAccountClient;
|
||||||
|
import com.baiyue.iam.dto.PlatformInternalUserDtos.*;
|
||||||
|
import com.baiyue.iam.entity.IamMembership;
|
||||||
|
import com.baiyue.iam.entity.IamRole;
|
||||||
|
import com.baiyue.iam.entity.IamUser;
|
||||||
|
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||||
|
import com.baiyue.iam.repository.MemberRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class PlatformInternalUserServiceTest {
|
||||||
|
private final MemberRepository repository = mock(MemberRepository.class);
|
||||||
|
private final IamRoleMapper roleMapper = mock(IamRoleMapper.class);
|
||||||
|
private final AuthAccountClient auth = mock(AuthAccountClient.class);
|
||||||
|
private final PlatformInternalUserService service =
|
||||||
|
new PlatformInternalUserService(repository, roleMapper, auth, "internal-token");
|
||||||
|
private IamMembership membership;
|
||||||
|
private IamUser user;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
membership = new IamMembership();
|
||||||
|
membership.setId(30L); membership.setTenantId(0L); membership.setUserId(20L);
|
||||||
|
membership.setMemberType("EMPLOYEE"); membership.setMemberNo("PLATFORM-30");
|
||||||
|
membership.setStatus("ACTIVE");
|
||||||
|
user = new IamUser();
|
||||||
|
user.setId(20L); user.setAuthUserId(10L); user.setUserType("INTERNAL");
|
||||||
|
user.setDisplayName("平台运营"); user.setPhone("13800138000"); user.setStatus("ACTIVE");
|
||||||
|
when(repository.findByTenantAndId(0L, 30L)).thenReturn(membership);
|
||||||
|
when(repository.findUser(20L)).thenReturn(user);
|
||||||
|
when(repository.findRoleIds(0L, 30L)).thenReturn(List.of(40L));
|
||||||
|
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
|
||||||
|
new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "ACTIVE")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 列表返回成员主键联系方式和当前角色() {
|
||||||
|
when(repository.findByTenant(0L)).thenReturn(List.of(membership));
|
||||||
|
|
||||||
|
InternalUserResponse result = service.list().get(0);
|
||||||
|
|
||||||
|
assertEquals(30L, result.membershipId());
|
||||||
|
assertEquals("operator", result.username());
|
||||||
|
assertEquals("13800138000", result.phone());
|
||||||
|
assertEquals(List.of(40L), result.roleIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 新建平台用户在Auth创建平台账号并写入IAM成员() {
|
||||||
|
IamRole role = new IamRole();
|
||||||
|
role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE");
|
||||||
|
when(roleMapper.selectById(40L)).thenReturn(role);
|
||||||
|
when(auth.create(anyString(), any())).thenReturn(ApiResponse.success(
|
||||||
|
new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE")));
|
||||||
|
CreateInternalUserRequest request = new CreateInternalUserRequest(null, null, "新运营",
|
||||||
|
"13900139000", "operator@example.com", "new.operator", "Password1",
|
||||||
|
"PLATFORM-NEW", List.of(40L));
|
||||||
|
|
||||||
|
InternalUserResponse result = service.create(request);
|
||||||
|
|
||||||
|
assertEquals("new.operator", result.username());
|
||||||
|
ArgumentCaptor<AuthAccountClient.CreateAccountRequest> captor =
|
||||||
|
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
|
||||||
|
verify(auth).create(eq("internal-token"), captor.capture());
|
||||||
|
assertEquals("PLATFORM", captor.getValue().appCode());
|
||||||
|
assertEquals(0L, captor.getValue().tenantId());
|
||||||
|
verify(repository).insertUser(any(IamUser.class));
|
||||||
|
verify(repository).insert(any(IamMembership.class));
|
||||||
|
verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 本地创建失败会补偿认证账号且不记录密码() {
|
||||||
|
when(auth.create(anyString(), any())).thenReturn(ApiResponse.success(
|
||||||
|
new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE")));
|
||||||
|
when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null));
|
||||||
|
doThrow(new RuntimeException("IAM 写入失败")).when(repository).insertUser(any());
|
||||||
|
CreateInternalUserRequest request = new CreateInternalUserRequest(null, null, "新运营",
|
||||||
|
null, null, "new.operator", "Password1", null, List.of());
|
||||||
|
|
||||||
|
assertThrows(RuntimeException.class, () -> service.create(request));
|
||||||
|
|
||||||
|
verify(auth).compensate(eq("internal-token"), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 不能停用当前登录的平台成员() {
|
||||||
|
assertThrows(BizException.class, () -> service.changeStatus(30L,
|
||||||
|
new ChangeInternalUserStatusRequest("DISABLED"), 30L));
|
||||||
|
|
||||||
|
verify(auth, never()).status(anyString(), anyLong(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 状态本地写入失败时恢复Auth且成功后不再远程查询() {
|
||||||
|
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||||
|
doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user);
|
||||||
|
|
||||||
|
assertThrows(RuntimeException.class, () -> service.changeStatus(30L,
|
||||||
|
new ChangeInternalUserStatusRequest("DISABLED"), 99L));
|
||||||
|
|
||||||
|
verify(auth).status(eq("internal-token"), eq(10L),
|
||||||
|
argThat(value -> "DISABLED".equals(value.status())));
|
||||||
|
verify(auth).status(eq("internal-token"), eq(10L),
|
||||||
|
argThat(value -> "ACTIVE".equals(value.status())));
|
||||||
|
verify(auth, times(1)).summary("internal-token", 10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 状态补偿应恢复Auth原状态而不是IAM旧状态() {
|
||||||
|
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
|
||||||
|
new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "DISABLED")));
|
||||||
|
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||||
|
doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user);
|
||||||
|
|
||||||
|
assertThrows(RuntimeException.class, () -> service.changeStatus(30L,
|
||||||
|
new ChangeInternalUserStatusRequest("ACTIVE"), 99L));
|
||||||
|
|
||||||
|
verify(auth).status(eq("internal-token"), eq(10L),
|
||||||
|
argThat(value -> "ACTIVE".equals(value.status())));
|
||||||
|
verify(auth).status(eq("internal-token"), eq(10L),
|
||||||
|
argThat(value -> "DISABLED".equals(value.status())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 重置密码只转交Auth不写入IAM() {
|
||||||
|
when(auth.reset(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||||
|
|
||||||
|
service.resetPassword(30L, new ResetInternalUserPasswordRequest("ResetPass1"));
|
||||||
|
|
||||||
|
verify(auth).reset(eq("internal-token"), eq(10L),
|
||||||
|
argThat(value -> "ResetPass1".equals(value.newPassword())));
|
||||||
|
verify(repository, never()).saveUser(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.baiyue.iam.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.iam.entity.IamTenant;
|
||||||
|
import com.baiyue.iam.repository.TenantRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class TenantServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pageMerchantsMapsTenantEntitiesToApiResponses() {
|
||||||
|
TenantRepository repository = mock(TenantRepository.class);
|
||||||
|
TenantService service = new TenantService();
|
||||||
|
ReflectionTestUtils.setField(service, "repository", repository);
|
||||||
|
|
||||||
|
IamTenant tenant = new IamTenant();
|
||||||
|
tenant.setId(10001L);
|
||||||
|
tenant.setTenantCode("TEN10001");
|
||||||
|
tenant.setTenantName("Example Merchant");
|
||||||
|
tenant.setStatus("ACTIVE");
|
||||||
|
tenant.setLegalName("Example Merchant Ltd");
|
||||||
|
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
|
||||||
|
tenant.setContactName("Alice");
|
||||||
|
tenant.setContactPhone("13800138000");
|
||||||
|
tenant.setContactEmail("alice@example.com");
|
||||||
|
when(repository.pageMerchants(1, 10, "Example", "ACTIVE"))
|
||||||
|
.thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1));
|
||||||
|
|
||||||
|
var result = service.pageMerchants(1, 10, "Example", "ACTIVE");
|
||||||
|
|
||||||
|
assertEquals(1, result.total());
|
||||||
|
assertEquals("TEN10001", result.records().get(0).tenantCode());
|
||||||
|
assertEquals("Alice", result.records().get(0).contactName());
|
||||||
|
verify(repository).pageMerchants(1, 10, "Example", "ACTIVE");
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package com.baiyue.integration.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest;
|
||||||
|
import com.baiyue.integration.dto.ocr.WeightOcrBatchResult;
|
||||||
|
import com.baiyue.integration.security.InternalServiceTokenGuard;
|
||||||
|
import com.baiyue.integration.service.ocr.WeightOcrBatchService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供平台内部服务调用的称重图片 OCR 接口。
|
||||||
|
*/
|
||||||
|
@Tag(name = "重量 OCR", description = "受内部服务令牌保护的称重图片批量识别能力")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/internal/integrations/weight-ocr")
|
||||||
|
public class WeightOcrController {
|
||||||
|
@Resource
|
||||||
|
private WeightOcrBatchService batchService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private InternalServiceTokenGuard tokenGuard;
|
||||||
|
|
||||||
|
@Operation(summary = "批量识别称重图片")
|
||||||
|
@PostMapping("/batches")
|
||||||
|
public ApiResponse<WeightOcrBatchResult> recognize(
|
||||||
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
|
@Valid @RequestBody WeightOcrBatchRequest request) throws IOException {
|
||||||
|
tokenGuard.verify(token);
|
||||||
|
return ApiResponse.success(batchService.recognize(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.baiyue.integration.dto.ocr;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 百度 OCR 返回的文字候选及其识别框。
|
||||||
|
*/
|
||||||
|
public record OcrCandidate(
|
||||||
|
String text,
|
||||||
|
Double confidence,
|
||||||
|
Integer left,
|
||||||
|
Integer top,
|
||||||
|
Integer width,
|
||||||
|
Integer height
|
||||||
|
) {
|
||||||
|
public long area() {
|
||||||
|
if (width == null || height == null || width <= 0 || height <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (long) width * height;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.baiyue.integration.dto.ocr;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重量 OCR 批次请求。目录必须位于服务配置的允许根目录内。
|
||||||
|
*/
|
||||||
|
public record WeightOcrBatchRequest(
|
||||||
|
@NotBlank(message = "输入目录不能为空") String inputDirectory,
|
||||||
|
String outputDirectory,
|
||||||
|
@DecimalMin(value = "0.0", message = "纵向裁剪起始比例不能小于 0")
|
||||||
|
@DecimalMax(value = "0.95", message = "纵向裁剪起始比例不能大于 0.95")
|
||||||
|
Double cropStartRatio,
|
||||||
|
@DecimalMin(value = "0.0", message = "横向裁剪比例不能小于 0")
|
||||||
|
@DecimalMax(value = "0.49", message = "横向裁剪比例不能大于 0.49")
|
||||||
|
Double horizontalTrimRatio,
|
||||||
|
Boolean recursive
|
||||||
|
) {
|
||||||
|
public double effectiveCropStartRatio() {
|
||||||
|
return cropStartRatio == null ? 0.5 : cropStartRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double effectiveHorizontalTrimRatio() {
|
||||||
|
return horizontalTrimRatio == null ? 0.25 : horizontalTrimRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean effectiveRecursive() {
|
||||||
|
return Boolean.TRUE.equals(recursive);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.integration.dto.ocr;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重量 OCR 批次汇总结果。
|
||||||
|
*/
|
||||||
|
public record WeightOcrBatchResult(
|
||||||
|
String batchId,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant finishedAt,
|
||||||
|
String inputDirectory,
|
||||||
|
String outputDirectory,
|
||||||
|
int total,
|
||||||
|
int recognized,
|
||||||
|
int noWeightFound,
|
||||||
|
int failed,
|
||||||
|
List<WeightOcrImageResult> results
|
||||||
|
) {
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.integration.dto.ocr;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单张称重图片识别结果。路径均为相对于受控根目录的相对路径。
|
||||||
|
*/
|
||||||
|
public record WeightOcrImageResult(
|
||||||
|
String sourceImage,
|
||||||
|
String croppedImage,
|
||||||
|
String rawResponseFile,
|
||||||
|
Status status,
|
||||||
|
BigDecimal weight,
|
||||||
|
Double confidence,
|
||||||
|
List<OcrCandidate> candidates,
|
||||||
|
String error
|
||||||
|
) {
|
||||||
|
public enum Status {
|
||||||
|
RECOGNIZED,
|
||||||
|
NO_WEIGHT_FOUND,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
package com.baiyue.integration.provider.ocr;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.integration.config.IntegrationProperties;
|
||||||
|
import com.baiyue.integration.dto.ocr.OcrCandidate;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 百度仪器仪表盘读数识别客户端,负责令牌缓存、限速和受控重试。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BaiduMeterClient {
|
||||||
|
private static final int MAX_FORM_BYTES = 4 * 1024 * 1024;
|
||||||
|
private static final int QPS_LIMIT_ERROR_CODE = 18;
|
||||||
|
private static final long INITIAL_RETRY_DELAY_MILLIS = 1_000;
|
||||||
|
|
||||||
|
private final IntegrationProperties.BaiduOcr config;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final Object requestRateLock = new Object();
|
||||||
|
|
||||||
|
private volatile CachedToken cachedToken;
|
||||||
|
private long nextRequestAtMillis;
|
||||||
|
|
||||||
|
public BaiduMeterClient(IntegrationProperties properties, ObjectMapper objectMapper) {
|
||||||
|
this.config = properties.baiduOcr();
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(config == null ? java.time.Duration.ofSeconds(10)
|
||||||
|
: config.effectiveConnectTimeout())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validateReady() {
|
||||||
|
if (config == null || !config.enabled()) {
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "重量 OCR 能力未启用");
|
||||||
|
}
|
||||||
|
if (!config.configured()) {
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "百度 OCR 密钥或接口地址未配置");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MeterResponse recognize(Path image) throws IOException, InterruptedException {
|
||||||
|
validateReady();
|
||||||
|
long imageBytes = Files.size(image);
|
||||||
|
if (imageBytes <= 0 || imageBytes > config.effectiveMaxImageSize().toBytes()) {
|
||||||
|
throw new IOException("裁剪图片超过允许大小: " + image.getFileName());
|
||||||
|
}
|
||||||
|
String base64 = Base64.getEncoder().encodeToString(Files.readAllBytes(image));
|
||||||
|
String form = "image=" + encode(base64) + "&probability=true&poly_location=true";
|
||||||
|
if (form.getBytes(StandardCharsets.UTF_8).length > MAX_FORM_BYTES) {
|
||||||
|
throw new IOException("裁剪图片编码后超过百度接口 4MB 限制: " + image.getFileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int retry = 0; ; retry++) {
|
||||||
|
waitForRequestSlot();
|
||||||
|
try {
|
||||||
|
return sendRecognitionRequest(form);
|
||||||
|
} catch (BaiduApiException exception) {
|
||||||
|
if (exception.errorCode() != QPS_LIMIT_ERROR_CODE
|
||||||
|
|| retry >= config.effectiveMaxQpsRetries()) {
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
Thread.sleep(INITIAL_RETRY_DELAY_MILLIS << retry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeterResponse sendRecognitionRequest(String form) throws IOException, InterruptedException {
|
||||||
|
String separator = config.meterUrl().toString().contains("?") ? "&" : "?";
|
||||||
|
URI uri = URI.create(config.meterUrl() + separator + "access_token=" + encode(accessToken()));
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(uri)
|
||||||
|
.timeout(config.effectiveRequestTimeout())
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(form))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = httpClient.send(
|
||||||
|
request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
ensureSuccessfulHttpStatus(response.statusCode());
|
||||||
|
|
||||||
|
JsonNode root = objectMapper.readTree(response.body());
|
||||||
|
ensureSuccessfulBaiduResponse(root);
|
||||||
|
return new MeterResponse(response.body(), parseCandidates(root.path("words_result")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void waitForRequestSlot() throws InterruptedException {
|
||||||
|
synchronized (requestRateLock) {
|
||||||
|
long waitMillis = nextRequestAtMillis - System.currentTimeMillis();
|
||||||
|
if (waitMillis > 0) {
|
||||||
|
Thread.sleep(waitMillis);
|
||||||
|
}
|
||||||
|
nextRequestAtMillis = System.currentTimeMillis()
|
||||||
|
+ config.effectiveMinRequestInterval().toMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String accessToken() throws IOException, InterruptedException {
|
||||||
|
CachedToken current = cachedToken;
|
||||||
|
if (usable(current)) {
|
||||||
|
return current.value();
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
current = cachedToken;
|
||||||
|
if (usable(current)) {
|
||||||
|
return current.value();
|
||||||
|
}
|
||||||
|
String form = "grant_type=client_credentials"
|
||||||
|
+ "&client_id=" + encode(config.apiKey())
|
||||||
|
+ "&client_secret=" + encode(config.secretKey());
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(config.tokenUrl())
|
||||||
|
.timeout(config.effectiveRequestTimeout())
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(form))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = httpClient.send(
|
||||||
|
request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
ensureSuccessfulHttpStatus(response.statusCode());
|
||||||
|
|
||||||
|
JsonNode root = objectMapper.readTree(response.body());
|
||||||
|
String token = root.path("access_token").asText();
|
||||||
|
if (token.isBlank()) {
|
||||||
|
throw new IOException("百度 OAuth 获取 access_token 失败: " + safeError(root));
|
||||||
|
}
|
||||||
|
long expiresIn = Math.max(120, root.path("expires_in").asLong(2_592_000));
|
||||||
|
cachedToken = new CachedToken(token, Instant.now().plusSeconds(expiresIn));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean usable(CachedToken token) {
|
||||||
|
return token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<OcrCandidate> parseCandidates(JsonNode wordsResult) {
|
||||||
|
List<OcrCandidate> candidates = new ArrayList<>();
|
||||||
|
if (!wordsResult.isArray()) {
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
for (JsonNode item : wordsResult) {
|
||||||
|
JsonNode probability = item.path("probability");
|
||||||
|
JsonNode location = item.path("location");
|
||||||
|
candidates.add(new OcrCandidate(
|
||||||
|
item.path("words").asText(),
|
||||||
|
nullableDouble(probability.get("average")),
|
||||||
|
nullableInt(location.get("left")),
|
||||||
|
nullableInt(location.get("top")),
|
||||||
|
nullableInt(location.get("width")),
|
||||||
|
nullableInt(location.get("height"))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return List.copyOf(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureSuccessfulHttpStatus(int statusCode) throws IOException {
|
||||||
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
|
throw new IOException("百度接口 HTTP 状态异常: " + statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureSuccessfulBaiduResponse(JsonNode root) throws IOException {
|
||||||
|
if (root.has("error_code")) {
|
||||||
|
int errorCode = root.path("error_code").asInt(-1);
|
||||||
|
throw new BaiduApiException(errorCode, "百度 OCR 调用失败: " + safeError(root));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeError(JsonNode root) {
|
||||||
|
return root.path("error_code").asText("unknown") + " - "
|
||||||
|
+ root.path("error_msg").asText("unknown error");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encode(String value) {
|
||||||
|
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double nullableDouble(JsonNode node) {
|
||||||
|
return node == null || !node.isNumber() ? null : node.doubleValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer nullableInt(JsonNode node) {
|
||||||
|
return node == null || !node.isNumber() ? null : node.intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MeterResponse(String rawJson, List<OcrCandidate> candidates) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CachedToken(String value, Instant expiresAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class BaiduApiException extends IOException {
|
||||||
|
private final int errorCode;
|
||||||
|
|
||||||
|
private BaiduApiException(int errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int errorCode() {
|
||||||
|
return errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package com.baiyue.integration.service.ocr;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.imageio.IIOImage;
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import javax.imageio.ImageWriteParam;
|
||||||
|
import javax.imageio.ImageWriter;
|
||||||
|
import javax.imageio.stream.ImageOutputStream;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Iterator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 裁剪称重图片的下方中央区域,并统一输出为 JPEG。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ImageCropper {
|
||||||
|
private static final float JPEG_QUALITY = 0.90f;
|
||||||
|
|
||||||
|
public void cropBottomCenter(Path source, Path target, double startRatio,
|
||||||
|
double horizontalTrimRatio, long maxImagePixels) throws IOException {
|
||||||
|
validateRatios(startRatio, horizontalTrimRatio);
|
||||||
|
BufferedImage sourceImage = ImageIO.read(source.toFile());
|
||||||
|
if (sourceImage == null) {
|
||||||
|
throw new IOException("无法解析图片: " + source.getFileName());
|
||||||
|
}
|
||||||
|
long pixels = (long) sourceImage.getWidth() * sourceImage.getHeight();
|
||||||
|
if (pixels <= 0 || pixels > maxImagePixels) {
|
||||||
|
throw new IOException("图片像素超过允许上限: " + source.getFileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
int startY = (int) Math.floor(sourceImage.getHeight() * startRatio);
|
||||||
|
int startX = (int) Math.floor(sourceImage.getWidth() * horizontalTrimRatio);
|
||||||
|
int croppedWidth = sourceImage.getWidth() - startX * 2;
|
||||||
|
int croppedHeight = sourceImage.getHeight() - startY;
|
||||||
|
if (croppedWidth <= 0 || croppedHeight <= 0) {
|
||||||
|
throw new IOException("图片裁剪区域无效: " + source.getFileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedImage cropped = new BufferedImage(croppedWidth, croppedHeight, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D graphics = cropped.createGraphics();
|
||||||
|
try {
|
||||||
|
graphics.setColor(Color.WHITE);
|
||||||
|
graphics.fillRect(0, 0, croppedWidth, croppedHeight);
|
||||||
|
graphics.drawImage(sourceImage, 0, 0, croppedWidth, croppedHeight,
|
||||||
|
startX, startY, startX + croppedWidth, startY + croppedHeight, null);
|
||||||
|
} finally {
|
||||||
|
graphics.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Files.createDirectories(target.getParent());
|
||||||
|
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
|
||||||
|
if (!writers.hasNext()) {
|
||||||
|
throw new IOException("当前 Java 环境不支持 JPEG 编码");
|
||||||
|
}
|
||||||
|
ImageWriter writer = writers.next();
|
||||||
|
try (ImageOutputStream output = ImageIO.createImageOutputStream(target.toFile())) {
|
||||||
|
writer.setOutput(output);
|
||||||
|
ImageWriteParam parameters = writer.getDefaultWriteParam();
|
||||||
|
parameters.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||||
|
parameters.setCompressionQuality(JPEG_QUALITY);
|
||||||
|
writer.write(null, new IIOImage(cropped, null, null), parameters);
|
||||||
|
} finally {
|
||||||
|
writer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRatios(double startRatio, double horizontalTrimRatio) {
|
||||||
|
if (startRatio < 0 || startRatio >= 1) {
|
||||||
|
throw new IllegalArgumentException("cropStartRatio 必须在 [0, 1) 范围内");
|
||||||
|
}
|
||||||
|
if (horizontalTrimRatio < 0 || horizontalTrimRatio >= 0.5) {
|
||||||
|
throw new IllegalArgumentException("horizontalTrimRatio 必须在 [0, 0.5) 范围内");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
package com.baiyue.integration.service.ocr;
|
||||||
|
|
||||||
|
import com.baiyue.integration.dto.ocr.OcrCandidate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 OCR 候选内容中选择最可能的重量数值。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WeightCandidateSelector {
|
||||||
|
private static final Pattern WEIGHT_PATTERN = Pattern.compile("^[+-]?\\d{1,8}(?:\\.\\d{1,4})?$");
|
||||||
|
|
||||||
|
public Optional<SelectedWeight> select(List<OcrCandidate> candidates) {
|
||||||
|
if (candidates == null || candidates.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return candidates.stream()
|
||||||
|
.map(this::toSelectedWeight)
|
||||||
|
.flatMap(Optional::stream)
|
||||||
|
.max(Comparator
|
||||||
|
.comparingLong((SelectedWeight selected) -> selected.candidate().area())
|
||||||
|
.thenComparing(selected -> selected.confidence() == null ? -1 : selected.confidence()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<SelectedWeight> toSelectedWeight(OcrCandidate candidate) {
|
||||||
|
if (candidate == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String normalized = normalize(candidate.text());
|
||||||
|
if (!WEIGHT_PATTERN.matcher(normalized).matches()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.of(new SelectedWeight(
|
||||||
|
new BigDecimal(normalized), candidate.confidence(), candidate));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.strip()
|
||||||
|
.replace(" ", "")
|
||||||
|
.replace(',', '.')
|
||||||
|
.replace('+', '+')
|
||||||
|
.replace('-', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SelectedWeight(BigDecimal weight, Double confidence, OcrCandidate candidate) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
package com.baiyue.integration.service.ocr;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.integration.config.IntegrationProperties;
|
||||||
|
import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest;
|
||||||
|
import com.baiyue.integration.dto.ocr.WeightOcrBatchResult;
|
||||||
|
import com.baiyue.integration.dto.ocr.WeightOcrImageResult;
|
||||||
|
import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status;
|
||||||
|
import com.baiyue.integration.provider.ocr.BaiduMeterClient;
|
||||||
|
import com.baiyue.integration.service.ocr.WeightOcrStoragePolicy.BatchPaths;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.Semaphore;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编排称重图片批量裁剪、百度 OCR 调用和结果落盘。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WeightOcrBatchService {
|
||||||
|
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp");
|
||||||
|
|
||||||
|
private final IntegrationProperties properties;
|
||||||
|
private final WeightOcrStoragePolicy storagePolicy;
|
||||||
|
private final ImageCropper imageCropper;
|
||||||
|
private final BaiduMeterClient baiduMeterClient;
|
||||||
|
private final WeightCandidateSelector candidateSelector;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Semaphore batchSlot = new Semaphore(1);
|
||||||
|
|
||||||
|
public WeightOcrBatchService(IntegrationProperties properties,
|
||||||
|
WeightOcrStoragePolicy storagePolicy,
|
||||||
|
ImageCropper imageCropper,
|
||||||
|
BaiduMeterClient baiduMeterClient,
|
||||||
|
WeightCandidateSelector candidateSelector,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.storagePolicy = storagePolicy;
|
||||||
|
this.imageCropper = imageCropper;
|
||||||
|
this.baiduMeterClient = baiduMeterClient;
|
||||||
|
this.candidateSelector = candidateSelector;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WeightOcrBatchResult recognize(WeightOcrBatchRequest request) throws IOException {
|
||||||
|
baiduMeterClient.validateReady();
|
||||||
|
if (!batchSlot.tryAcquire()) {
|
||||||
|
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "已有重量 OCR 批次正在执行");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return doRecognize(request);
|
||||||
|
} finally {
|
||||||
|
batchSlot.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeightOcrBatchResult doRecognize(WeightOcrBatchRequest request) throws IOException {
|
||||||
|
String batchId = UUID.randomUUID().toString();
|
||||||
|
Instant startedAt = Instant.now();
|
||||||
|
BatchPaths paths = storagePolicy.resolve(
|
||||||
|
request.inputDirectory(), request.outputDirectory(), batchId);
|
||||||
|
List<Path> images = findImages(paths.inputDirectory(), paths.outputDirectory(),
|
||||||
|
request.effectiveRecursive(), config().effectiveMaxBatchSize());
|
||||||
|
if (images.isEmpty()) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "输入目录中没有支持的图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WeightOcrImageResult> results = new ArrayList<>(images.size());
|
||||||
|
for (Path image : images) {
|
||||||
|
results.add(recognizeOne(paths, image, request));
|
||||||
|
}
|
||||||
|
WeightOcrBatchResult result = buildResult(batchId, startedAt, paths, results);
|
||||||
|
objectMapper.writerWithDefaultPrettyPrinter()
|
||||||
|
.writeValue(paths.outputDirectory().resolve("batch-results.json").toFile(), result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeightOcrImageResult recognizeOne(BatchPaths paths, Path source,
|
||||||
|
WeightOcrBatchRequest request) {
|
||||||
|
Path relative = paths.inputDirectory().relativize(source);
|
||||||
|
Path cropped = paths.outputDirectory().resolve("cropped")
|
||||||
|
.resolve(replaceExtension(relative, ".jpg"));
|
||||||
|
Path rawResponse = paths.outputDirectory().resolve("raw")
|
||||||
|
.resolve(replaceExtension(relative, ".json"));
|
||||||
|
try {
|
||||||
|
long sourceBytes = Files.size(source);
|
||||||
|
if (sourceBytes <= 0 || sourceBytes > config().effectiveMaxImageSize().toBytes()) {
|
||||||
|
throw new IOException("原始图片超过允许大小");
|
||||||
|
}
|
||||||
|
imageCropper.cropBottomCenter(source, cropped,
|
||||||
|
request.effectiveCropStartRatio(), request.effectiveHorizontalTrimRatio(),
|
||||||
|
config().effectiveMaxImagePixels());
|
||||||
|
BaiduMeterClient.MeterResponse response = baiduMeterClient.recognize(cropped);
|
||||||
|
Files.createDirectories(rawResponse.getParent());
|
||||||
|
Files.writeString(rawResponse, response.rawJson(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
return candidateSelector.select(response.candidates())
|
||||||
|
.map(selected -> success(relative, paths, cropped, rawResponse,
|
||||||
|
response, selected))
|
||||||
|
.orElseGet(() -> noWeight(relative, paths, cropped, rawResponse, response));
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return failed(relative, paths, cropped, rawResponse, "调用百度 OCR 时线程被中断");
|
||||||
|
} catch (Exception exception) {
|
||||||
|
return failed(relative, paths, cropped, rawResponse, safeMessage(exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeightOcrImageResult success(Path source, BatchPaths paths, Path cropped,
|
||||||
|
Path rawResponse, BaiduMeterClient.MeterResponse response,
|
||||||
|
WeightCandidateSelector.SelectedWeight selected) {
|
||||||
|
return new WeightOcrImageResult(
|
||||||
|
source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse),
|
||||||
|
Status.RECOGNIZED, selected.weight(), selected.confidence(),
|
||||||
|
response.candidates(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeightOcrImageResult noWeight(Path source, BatchPaths paths, Path cropped,
|
||||||
|
Path rawResponse, BaiduMeterClient.MeterResponse response) {
|
||||||
|
return new WeightOcrImageResult(
|
||||||
|
source.toString(), outputRelative(paths, cropped), outputRelative(paths, rawResponse),
|
||||||
|
Status.NO_WEIGHT_FOUND, null, null, response.candidates(),
|
||||||
|
"百度响应中没有符合重量格式的候选值");
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeightOcrImageResult failed(Path source, BatchPaths paths, Path cropped,
|
||||||
|
Path rawResponse, String error) {
|
||||||
|
return new WeightOcrImageResult(
|
||||||
|
source.toString(), Files.exists(cropped) ? outputRelative(paths, cropped) : null,
|
||||||
|
Files.exists(rawResponse) ? outputRelative(paths, rawResponse) : null,
|
||||||
|
Status.FAILED, null, null, List.of(), error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WeightOcrBatchResult buildResult(String batchId, Instant startedAt, BatchPaths paths,
|
||||||
|
List<WeightOcrImageResult> results) {
|
||||||
|
return new WeightOcrBatchResult(
|
||||||
|
batchId, startedAt, Instant.now(), paths.inputDisplayPath(), paths.outputDisplayPath(),
|
||||||
|
results.size(), count(results, Status.RECOGNIZED),
|
||||||
|
count(results, Status.NO_WEIGHT_FOUND), count(results, Status.FAILED),
|
||||||
|
List.copyOf(results));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int count(List<WeightOcrImageResult> results, Status status) {
|
||||||
|
return (int) results.stream().filter(result -> result.status() == status).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Path> findImages(Path input, Path output, boolean recursive, int maxBatchSize)
|
||||||
|
throws IOException {
|
||||||
|
int maxDepth = recursive ? Integer.MAX_VALUE : 1;
|
||||||
|
List<Path> images;
|
||||||
|
try (Stream<Path> paths = Files.walk(input, maxDepth)) {
|
||||||
|
images = paths.filter(Files::isRegularFile)
|
||||||
|
.filter(path -> !path.startsWith(output))
|
||||||
|
.filter(this::isSupportedImage)
|
||||||
|
.sorted(Comparator.comparing(Path::toString))
|
||||||
|
.limit((long) maxBatchSize + 1)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (images.size() > maxBatchSize) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST,
|
||||||
|
"单批图片数量不能超过 " + maxBatchSize + " 张");
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSupportedImage(Path path) {
|
||||||
|
String name = path.getFileName().toString();
|
||||||
|
int dot = name.lastIndexOf('.');
|
||||||
|
return dot >= 0 && dot < name.length() - 1
|
||||||
|
&& SUPPORTED_EXTENSIONS.contains(name.substring(dot + 1).toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path replaceExtension(Path relative, String extension) {
|
||||||
|
String name = relative.getFileName().toString();
|
||||||
|
int dot = name.lastIndexOf('.');
|
||||||
|
String baseName = dot > 0 ? name.substring(0, dot) : name;
|
||||||
|
return relative.getParent() == null
|
||||||
|
? Path.of(baseName + extension)
|
||||||
|
: relative.getParent().resolve(baseName + extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String outputRelative(BatchPaths paths, Path path) {
|
||||||
|
return paths.outputDirectory().relativize(path).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeMessage(Exception exception) {
|
||||||
|
String message = exception.getMessage();
|
||||||
|
return message == null || message.isBlank() ? "OCR 识别失败" : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntegrationProperties.BaiduOcr config() {
|
||||||
|
return properties.baiduOcr();
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package com.baiyue.integration.service.ocr;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.integration.config.IntegrationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 OCR 文件访问限制在配置的输入、输出根目录内。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WeightOcrStoragePolicy {
|
||||||
|
private final IntegrationProperties properties;
|
||||||
|
|
||||||
|
public WeightOcrStoragePolicy(IntegrationProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BatchPaths resolve(String inputValue, String outputValue, String batchId) throws IOException {
|
||||||
|
IntegrationProperties.BaiduOcr config = requireConfig();
|
||||||
|
Path inputRoot = prepareRoot(config.inputRoot(), "OCR 输入根目录");
|
||||||
|
Path outputRoot = prepareRoot(config.outputRoot(), "OCR 输出根目录");
|
||||||
|
|
||||||
|
Path input = resolveRequested(inputRoot, inputValue).toRealPath();
|
||||||
|
requireInside(inputRoot, input, "输入目录超出允许范围");
|
||||||
|
if (!Files.isDirectory(input) || !Files.isReadable(input)) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "输入目录不存在或不可读");
|
||||||
|
}
|
||||||
|
|
||||||
|
String outputName = outputValue == null || outputValue.isBlank() ? batchId : outputValue;
|
||||||
|
Path output = resolveRequested(outputRoot, outputName);
|
||||||
|
requireInside(outputRoot, output, "输出目录超出允许范围");
|
||||||
|
Files.createDirectories(output);
|
||||||
|
output = output.toRealPath();
|
||||||
|
requireInside(outputRoot, output, "输出目录超出允许范围");
|
||||||
|
return new BatchPaths(inputRoot, outputRoot, input, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntegrationProperties.BaiduOcr requireConfig() {
|
||||||
|
IntegrationProperties.BaiduOcr config = properties.baiduOcr();
|
||||||
|
if (config == null || config.inputRoot() == null || config.outputRoot() == null) {
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "重量 OCR 存储目录未配置");
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path prepareRoot(Path configured, String name) throws IOException {
|
||||||
|
Path root = configured.toAbsolutePath().normalize();
|
||||||
|
Files.createDirectories(root);
|
||||||
|
if (!Files.isDirectory(root) || !Files.isReadable(root) || !Files.isWritable(root)) {
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, name + "不可用");
|
||||||
|
}
|
||||||
|
return root.toRealPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path resolveRequested(Path root, String value) {
|
||||||
|
try {
|
||||||
|
Path requested = Path.of(value);
|
||||||
|
return (requested.isAbsolute() ? requested : root.resolve(requested)).normalize();
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "目录格式无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireInside(Path root, Path path, String message) {
|
||||||
|
if (!path.startsWith(root)) {
|
||||||
|
throw new BizException(ResultCode.FORBIDDEN, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BatchPaths(Path inputRoot, Path outputRoot, Path inputDirectory, Path outputDirectory) {
|
||||||
|
public String inputDisplayPath() {
|
||||||
|
return display(inputRoot, inputDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String outputDisplayPath() {
|
||||||
|
return display(outputRoot, outputDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String display(Path root, Path path) {
|
||||||
|
String relative = root.relativize(path).toString();
|
||||||
|
return relative.isBlank() ? "." : relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.baiyue.integration.service.ocr;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class ImageCropperTest {
|
||||||
|
@TempDir
|
||||||
|
Path tempDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cropsConfiguredBottomCenterRegion() throws Exception {
|
||||||
|
Path source = createImage();
|
||||||
|
Path target = tempDirectory.resolve("cropped/result.jpg");
|
||||||
|
|
||||||
|
new ImageCropper().cropBottomCenter(source, target, 0.5, 0.25, 10_000);
|
||||||
|
|
||||||
|
BufferedImage cropped = ImageIO.read(target.toFile());
|
||||||
|
assertThat(cropped.getWidth()).isEqualTo(50);
|
||||||
|
assertThat(cropped.getHeight()).isEqualTo(40);
|
||||||
|
assertThat(new Color(cropped.getRGB(10, 10))).isEqualTo(Color.BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsImageOverPixelLimit() throws Exception {
|
||||||
|
Path source = createImage();
|
||||||
|
assertThatThrownBy(() -> new ImageCropper()
|
||||||
|
.cropBottomCenter(source, tempDirectory.resolve("result.jpg"), 0.5, 0.25, 100))
|
||||||
|
.hasMessageContaining("像素超过");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path createImage() throws Exception {
|
||||||
|
BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D graphics = image.createGraphics();
|
||||||
|
graphics.setColor(Color.WHITE);
|
||||||
|
graphics.fillRect(0, 0, 100, 40);
|
||||||
|
graphics.setColor(Color.BLACK);
|
||||||
|
graphics.fillRect(0, 40, 100, 40);
|
||||||
|
graphics.dispose();
|
||||||
|
Path source = tempDirectory.resolve("source.png");
|
||||||
|
ImageIO.write(image, "png", source.toFile());
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.baiyue.integration.service.ocr;
|
||||||
|
|
||||||
|
import com.baiyue.integration.dto.ocr.OcrCandidate;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class WeightCandidateSelectorTest {
|
||||||
|
private final WeightCandidateSelector selector = new WeightCandidateSelector();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectsLargestNumericCandidateAndNormalizesDecimalSeparator() {
|
||||||
|
List<OcrCandidate> candidates = List.of(
|
||||||
|
new OcrCandidate("10:38", 0.99, 10, 10, 40, 10),
|
||||||
|
new OcrCandidate(" 12,35 ", 0.97, 20, 30, 120, 60),
|
||||||
|
new OcrCandidate("29", 0.99, 160, 10, 20, 10),
|
||||||
|
new OcrCandidate("kg", 0.99, 150, 70, 30, 15));
|
||||||
|
|
||||||
|
WeightCandidateSelector.SelectedWeight selected = selector.select(candidates).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(selected.weight()).isEqualByComparingTo("12.35");
|
||||||
|
assertThat(selected.confidence()).isEqualTo(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNonNumericCandidates() {
|
||||||
|
assertThat(selector.select(List.of(
|
||||||
|
new OcrCandidate("kg", 0.99, 0, 0, 20, 10),
|
||||||
|
new OcrCandidate("10:38", 0.99, 0, 0, 20, 10)))).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package com.baiyue.integration.service.ocr;
|
||||||
|
|
||||||
|
import com.baiyue.integration.config.IntegrationProperties;
|
||||||
|
import com.baiyue.integration.dto.ocr.OcrCandidate;
|
||||||
|
import com.baiyue.integration.dto.ocr.WeightOcrBatchRequest;
|
||||||
|
import com.baiyue.integration.dto.ocr.WeightOcrImageResult.Status;
|
||||||
|
import com.baiyue.integration.provider.ocr.BaiduMeterClient;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.util.unit.DataSize;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class WeightOcrBatchServiceTest {
|
||||||
|
@TempDir
|
||||||
|
Path tempDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completesBatchAndWritesArtifactsWithoutCallingRealProvider() throws Exception {
|
||||||
|
Path inputRoot = Files.createDirectories(tempDirectory.resolve("input"));
|
||||||
|
Path outputRoot = tempDirectory.resolve("output");
|
||||||
|
Path batchInput = Files.createDirectories(inputRoot.resolve("batch-a"));
|
||||||
|
BufferedImage image = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB);
|
||||||
|
for (int y = 0; y < image.getHeight(); y++) {
|
||||||
|
for (int x = 0; x < image.getWidth(); x++) {
|
||||||
|
image.setRGB(x, y, Color.BLACK.getRGB());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImageIO.write(image, "png", batchInput.resolve("weight.png").toFile());
|
||||||
|
|
||||||
|
IntegrationProperties properties = properties(inputRoot, outputRoot);
|
||||||
|
BaiduMeterClient client = mock(BaiduMeterClient.class);
|
||||||
|
when(client.recognize(any())).thenReturn(new BaiduMeterClient.MeterResponse(
|
||||||
|
"{\"words_result\":[]}",
|
||||||
|
List.of(new OcrCandidate("12.35", 0.98, 10, 10, 80, 30))));
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
WeightOcrBatchService service = new WeightOcrBatchService(
|
||||||
|
properties, new WeightOcrStoragePolicy(properties), new ImageCropper(), client,
|
||||||
|
new WeightCandidateSelector(), objectMapper);
|
||||||
|
|
||||||
|
var result = service.recognize(new WeightOcrBatchRequest("batch-a", null, null, null, false));
|
||||||
|
|
||||||
|
assertThat(result.total()).isEqualTo(1);
|
||||||
|
assertThat(result.recognized()).isEqualTo(1);
|
||||||
|
assertThat(result.results().get(0).status()).isEqualTo(Status.RECOGNIZED);
|
||||||
|
Path batchOutput = outputRoot.resolve(result.outputDirectory());
|
||||||
|
assertThat(batchOutput.resolve("cropped/weight.jpg")).isRegularFile();
|
||||||
|
assertThat(batchOutput.resolve("raw/weight.json")).isRegularFile();
|
||||||
|
assertThat(batchOutput.resolve("batch-results.json")).isRegularFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntegrationProperties properties(Path inputRoot, Path outputRoot) {
|
||||||
|
IntegrationProperties.BaiduOcr baidu = new IntegrationProperties.BaiduOcr(
|
||||||
|
true, "key", "secret", URI.create("https://example.com/token"),
|
||||||
|
URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1),
|
||||||
|
inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L,
|
||||||
|
50, Duration.ZERO, 0);
|
||||||
|
return new IntegrationProperties(baidu, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package com.baiyue.integration.service.ocr;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.integration.config.IntegrationProperties;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.util.unit.DataSize;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class WeightOcrStoragePolicyTest {
|
||||||
|
@TempDir
|
||||||
|
Path tempDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesDirectoriesInsideConfiguredRoots() throws Exception {
|
||||||
|
Path input = Files.createDirectories(tempDirectory.resolve("input/batch-a"));
|
||||||
|
Path outputRoot = tempDirectory.resolve("output");
|
||||||
|
WeightOcrStoragePolicy policy = new WeightOcrStoragePolicy(properties(input.getParent(), outputRoot));
|
||||||
|
|
||||||
|
WeightOcrStoragePolicy.BatchPaths paths = policy.resolve("batch-a", null, "batch-id");
|
||||||
|
|
||||||
|
assertThat(paths.inputDirectory()).isEqualTo(input.toRealPath());
|
||||||
|
assertThat(paths.outputDirectory()).isEqualTo(outputRoot.resolve("batch-id").toRealPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInputAndOutputOutsideConfiguredRoots() throws Exception {
|
||||||
|
Path inputRoot = Files.createDirectories(tempDirectory.resolve("input"));
|
||||||
|
Path outputRoot = tempDirectory.resolve("output");
|
||||||
|
Path outside = Files.createDirectories(tempDirectory.resolve("outside"));
|
||||||
|
WeightOcrStoragePolicy policy = new WeightOcrStoragePolicy(properties(inputRoot, outputRoot));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> policy.resolve(outside.toString(), null, "batch-id"))
|
||||||
|
.isInstanceOfSatisfying(BizException.class,
|
||||||
|
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN));
|
||||||
|
assertThatThrownBy(() -> policy.resolve(".", "../outside", "batch-id"))
|
||||||
|
.isInstanceOfSatisfying(BizException.class,
|
||||||
|
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntegrationProperties properties(Path inputRoot, Path outputRoot) {
|
||||||
|
IntegrationProperties.BaiduOcr baidu = new IntegrationProperties.BaiduOcr(
|
||||||
|
true, "key", "secret", URI.create("https://example.com/token"),
|
||||||
|
URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1),
|
||||||
|
inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L,
|
||||||
|
50, Duration.ZERO, 0);
|
||||||
|
return new IntegrationProperties(baidu, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.baiyue.order.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单写入请求的持久化幂等记录。
|
||||||
|
* 同一个租户、操作者、操作和幂等键只能有一条记录,请求摘要负责识别资源与请求内容变化。
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("ord_idempotency_record")
|
||||||
|
public class OrderIdempotencyRecordEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
private Long tenantId;
|
||||||
|
private Long operatorId;
|
||||||
|
private String operation;
|
||||||
|
private Long resourceId;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String requestHash;
|
||||||
|
private String status;
|
||||||
|
private Long resultOrderId;
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.baiyue.order.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.order.entity.OrderIdempotencyRecordEntity;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
|
/** 订单写入幂等记录 Mapper。 */
|
||||||
|
public interface OrderIdempotencyRecordMapper extends BaseMapper<OrderIdempotencyRecordEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: 柏悦头程订单 API
|
||||||
|
version: 1.1.0
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
paths:
|
||||||
|
/api/merchant/v1/orders/first-leg:
|
||||||
|
post:
|
||||||
|
summary: 创建订单草稿
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/IdempotencyKey'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 创建成功或同请求重放成功,返回相同订单ID
|
||||||
|
'400':
|
||||||
|
description: 缺少或使用了无效幂等键
|
||||||
|
'409':
|
||||||
|
description: 相同幂等键对应不同请求或请求仍在处理中
|
||||||
|
'503':
|
||||||
|
description: 仓库或往来单位下游服务异常
|
||||||
|
/api/merchant/v1/orders/first-leg/{id}/submit:
|
||||||
|
post:
|
||||||
|
summary: 提交订单
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/OrderId'
|
||||||
|
- $ref: '#/components/parameters/IdempotencyKey'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 提交成功或同请求重放成功
|
||||||
|
'400':
|
||||||
|
description: 缺少或使用了无效幂等键
|
||||||
|
'404':
|
||||||
|
description: 订单不存在
|
||||||
|
'409':
|
||||||
|
description: 幂等键冲突、请求处理中或订单状态冲突
|
||||||
|
'503':
|
||||||
|
description: 仓库或往来单位下游服务异常
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
parameters:
|
||||||
|
OrderId:
|
||||||
|
name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
IdempotencyKey:
|
||||||
|
name: Idempotency-Key
|
||||||
|
in: header
|
||||||
|
required: true
|
||||||
|
description: 同一租户和操作者重试同一请求时必须保持不变
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 128
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.order.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.order.dto.OrderRequests;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class OrderControllerPermissionTest {
|
||||||
|
@Test
|
||||||
|
void 商户订单列表和详情具有对应查询权限码() throws Exception {
|
||||||
|
assertAuthority(MerchantFirstLegOrderController.class, "page", "hasAuthority('merchant:order:list')",
|
||||||
|
AuthenticatedUser.class, OrderRequests.QueryRequest.class);
|
||||||
|
assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')",
|
||||||
|
AuthenticatedUser.class, long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 平台订单列表和详情使用已配置的订单列表权限码() throws Exception {
|
||||||
|
assertAuthority(PlatformFirstLegOrderController.class, "page", "hasAuthority('platform:order:list')",
|
||||||
|
AuthenticatedUser.class, long.class, OrderRequests.QueryRequest.class);
|
||||||
|
assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')",
|
||||||
|
AuthenticatedUser.class, long.class, long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertAuthority(Class<?> controller, String methodName, String expected, Class<?>... parameterTypes)
|
||||||
|
throws NoSuchMethodException {
|
||||||
|
Method method = controller.getDeclaredMethod(methodName, parameterTypes);
|
||||||
|
PreAuthorize authorization = method.getAnnotation(PreAuthorize.class);
|
||||||
|
assertThat(authorization).isNotNull();
|
||||||
|
assertThat(authorization.value()).isEqualTo(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.baiyue.order.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.order.dto.OrderRequests;
|
||||||
|
import com.baiyue.order.entity.OrderIdempotencyRecordEntity;
|
||||||
|
import com.baiyue.order.repository.OrderRepository;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class OrderIdempotencyServiceTest {
|
||||||
|
@Test
|
||||||
|
void 同键同请求返回首次创建的订单ID() {
|
||||||
|
OrderRepository repository = mock(OrderRepository.class);
|
||||||
|
OrderIdempotencyRecordEntity existing = completed(998L);
|
||||||
|
AtomicReference<OrderIdempotencyRecordEntity> attempted = new AtomicReference<>();
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
OrderIdempotencyRecordEntity record = invocation.getArgument(0);
|
||||||
|
attempted.set(record);
|
||||||
|
existing.setRequestHash(record.getRequestHash());
|
||||||
|
throw new DuplicateKeyException("并发唯一键冲突");
|
||||||
|
}).when(repository).insertIdempotency(any());
|
||||||
|
when(repository.findIdempotency(10001L, 20001L, "CREATE_DRAFT", "draft-key"))
|
||||||
|
.thenReturn(existing);
|
||||||
|
|
||||||
|
Long result = service(repository).createDraft(10001L, 20001L, mock(OrderRequests.SaveRequest.class), "draft-key");
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(998L);
|
||||||
|
assertThat(attempted.get().getTenantId()).isEqualTo(10001L);
|
||||||
|
assertThat(attempted.get().getOperatorId()).isEqualTo(20001L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 同键不同请求返回冲突() {
|
||||||
|
OrderRepository repository = mock(OrderRepository.class);
|
||||||
|
doAnswer(invocation -> { throw new DuplicateKeyException("并发唯一键冲突"); })
|
||||||
|
.when(repository).insertIdempotency(any());
|
||||||
|
OrderIdempotencyRecordEntity existing = completed(998L);
|
||||||
|
existing.setRequestHash("different-request");
|
||||||
|
when(repository.findIdempotency(anyLong(), anyLong(), anyString(), anyString()))
|
||||||
|
.thenReturn(existing);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service(repository).createDraft(
|
||||||
|
10001L, 20001L, mock(OrderRequests.SaveRequest.class), "draft-key"))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||||
|
.isEqualTo(ResultCode.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 并发请求尚未完成时返回冲突() {
|
||||||
|
OrderRepository repository = mock(OrderRepository.class);
|
||||||
|
OrderIdempotencyRecordEntity existing = new OrderIdempotencyRecordEntity();
|
||||||
|
existing.setStatus("PROCESSING");
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
OrderIdempotencyRecordEntity record = invocation.getArgument(0);
|
||||||
|
existing.setRequestHash(record.getRequestHash());
|
||||||
|
throw new DuplicateKeyException("并发唯一键冲突");
|
||||||
|
}).when(repository).insertIdempotency(any());
|
||||||
|
when(repository.findIdempotency(10001L, 20001L, "CREATE_DRAFT", "draft-key"))
|
||||||
|
.thenReturn(existing);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service(repository).createDraft(
|
||||||
|
10001L, 20001L, mock(OrderRequests.SaveRequest.class), "draft-key"))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||||
|
.isEqualTo(ResultCode.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 不存在订单统一返回404() {
|
||||||
|
OrderRepository repository = mock(OrderRepository.class);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service(repository).detail(10001L, 404L))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||||
|
.isEqualTo(ResultCode.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void SQL唯一键包含租户操作者操作和幂等键() throws Exception {
|
||||||
|
String sql = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(sql).contains(
|
||||||
|
"UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void OpenAPI声明创建和提交均要求幂等键() throws Exception {
|
||||||
|
String openapi = new ClassPathResource("openapi/order-v1.yaml")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(openapi)
|
||||||
|
.contains("/api/merchant/v1/orders/first-leg:")
|
||||||
|
.contains("/api/merchant/v1/orders/first-leg/{id}/submit:")
|
||||||
|
.contains("name: Idempotency-Key")
|
||||||
|
.contains("required: true");
|
||||||
|
}
|
||||||
|
|
||||||
|
private OrderApplicationService service(OrderRepository repository) {
|
||||||
|
OrderApplicationService service = new OrderApplicationService();
|
||||||
|
ReflectionTestUtils.setField(service, "repository", repository);
|
||||||
|
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
|
||||||
|
ReflectionTestUtils.setField(service, "eligibilityGuard", mock(OrderEligibilityGuard.class));
|
||||||
|
ReflectionTestUtils.setField(service, "masterDataGuard", mock(OrderMasterDataGuard.class));
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OrderIdempotencyRecordEntity completed(long orderId) {
|
||||||
|
OrderIdempotencyRecordEntity record = new OrderIdempotencyRecordEntity();
|
||||||
|
record.setStatus("COMPLETED");
|
||||||
|
record.setResultOrderId(orderId);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package com.baiyue.warehouse.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest;
|
||||||
|
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
|
||||||
|
import com.baiyue.warehouse.service.ExpressCompanyQueryService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springdoc.core.annotations.ParameterObject;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 平台快递公司管理接口。 */
|
||||||
|
@Validated
|
||||||
|
@Tag(name = "平台快递公司", description = "按目标租户分页查询快递公司主数据")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/v1/express-companies")
|
||||||
|
public class PlatformExpressCompanyController {
|
||||||
|
private final ExpressCompanyQueryService service;
|
||||||
|
|
||||||
|
public PlatformExpressCompanyController(ExpressCompanyQueryService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:express:list')")
|
||||||
|
@Operation(summary = "分页查询快递公司", description = "按目标租户、关键字和状态分页查询快递公司")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台快递公司查询权限")
|
||||||
|
})
|
||||||
|
public ApiResponse<PageResult<ExpressCompanyEntity>> page(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@Valid @ParameterObject ExpressCompanyPageQuery query) {
|
||||||
|
requirePlatform(user);
|
||||||
|
return ApiResponse.success(service.page(tenantId, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:express:list')")
|
||||||
|
@Operation(summary = "查询快递公司详情", description = "查询目标租户内的快递公司详情")
|
||||||
|
public ApiResponse<ExpressCompanyEntity> detail(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@PathVariable long id) {
|
||||||
|
requirePlatform(user);
|
||||||
|
return ApiResponse.success(service.detail(tenantId, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:express:create')")
|
||||||
|
@Operation(summary = "新增快递公司", description = "在目标租户内新增快递公司")
|
||||||
|
public ApiResponse<Long> create(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@Valid @RequestBody ExpressCompanySaveRequest request) {
|
||||||
|
requirePlatform(user);
|
||||||
|
return ApiResponse.success(service.create(tenantId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:express:edit')")
|
||||||
|
@Operation(summary = "编辑快递公司", description = "编辑目标租户内的快递公司")
|
||||||
|
public ApiResponse<Void> update(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@PathVariable long id,
|
||||||
|
@Valid @RequestBody ExpressCompanySaveRequest request) {
|
||||||
|
requirePlatform(user);
|
||||||
|
service.update(tenantId, id, request);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/status")
|
||||||
|
@PreAuthorize("hasAuthority('platform:express:status')")
|
||||||
|
@Operation(summary = "启用或停用快递公司", description = "通过状态变更停用主数据,不物理删除历史记录")
|
||||||
|
public ApiResponse<Void> changeStatus(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@PathVariable long id,
|
||||||
|
@Valid @RequestBody StatusRequest request) {
|
||||||
|
requirePlatform(user);
|
||||||
|
service.changeStatus(tenantId, id, request.status());
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requirePlatform(AuthenticatedUser user) {
|
||||||
|
if (user == null || !"PLATFORM".equals(user.appCode())) {
|
||||||
|
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package com.baiyue.warehouse.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest;
|
||||||
|
import com.baiyue.warehouse.entity.PartnerEntity;
|
||||||
|
import com.baiyue.warehouse.service.PlatformPartnerQueryService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springdoc.core.annotations.ParameterObject;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 平台往来单位管理接口。 */
|
||||||
|
@Validated
|
||||||
|
@Tag(name = "平台往来单位", description = "按目标租户分页查询往来单位主数据")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/v1/partners")
|
||||||
|
public class PlatformPartnerController {
|
||||||
|
private final PlatformPartnerQueryService service;
|
||||||
|
|
||||||
|
public PlatformPartnerController(PlatformPartnerQueryService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:counterparty:list')")
|
||||||
|
@Operation(summary = "分页查询往来单位", description = "按目标租户、关键字、类型和状态分页查询往来单位")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台往来单位查询权限")
|
||||||
|
})
|
||||||
|
public ApiResponse<PageResult<PartnerEntity>> page(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@Valid @ParameterObject PartnerPageQuery query) {
|
||||||
|
requirePlatform(user);
|
||||||
|
return ApiResponse.success(service.page(tenantId, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:counterparty:list')")
|
||||||
|
@Operation(summary = "查询往来单位详情", description = "查询目标租户内的往来单位详情")
|
||||||
|
public ApiResponse<PartnerEntity> detail(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@PathVariable long id) {
|
||||||
|
requirePlatform(user);
|
||||||
|
return ApiResponse.success(service.detail(tenantId, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:counterparty:create')")
|
||||||
|
@Operation(summary = "新增往来单位", description = "在目标租户内新增往来单位")
|
||||||
|
public ApiResponse<Long> create(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@Valid @RequestBody PartnerSaveRequest request) {
|
||||||
|
requirePlatform(user);
|
||||||
|
return ApiResponse.success(service.create(tenantId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:counterparty:edit')")
|
||||||
|
@Operation(summary = "编辑往来单位", description = "编辑目标租户内的往来单位")
|
||||||
|
public ApiResponse<Void> update(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@PathVariable long id,
|
||||||
|
@Valid @RequestBody PartnerSaveRequest request) {
|
||||||
|
requirePlatform(user);
|
||||||
|
service.update(tenantId, id, request);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/status")
|
||||||
|
@PreAuthorize("hasAuthority('platform:counterparty:status')")
|
||||||
|
@Operation(summary = "启用或停用往来单位", description = "通过状态变更停用主数据,不物理删除历史记录")
|
||||||
|
public ApiResponse<Void> changeStatus(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam @Min(1) long tenantId,
|
||||||
|
@PathVariable long id,
|
||||||
|
@Valid @RequestBody StatusRequest request) {
|
||||||
|
requirePlatform(user);
|
||||||
|
service.changeStatus(tenantId, id, request.status());
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requirePlatform(AuthenticatedUser user) {
|
||||||
|
if (user == null || !"PLATFORM".equals(user.appCode())) {
|
||||||
|
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.baiyue.warehouse.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/** 平台基础资料请求契约。 */
|
||||||
|
public final class MasterDataQueries {
|
||||||
|
private MasterDataQueries() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema(name = "PartnerPageQuery", description = "平台往来单位分页查询条件")
|
||||||
|
public record PartnerPageQuery(
|
||||||
|
@Schema(description = "页码,从 1 开始", example = "1", defaultValue = "1")
|
||||||
|
@Min(1) long page,
|
||||||
|
@Schema(description = "每页条数", example = "20", defaultValue = "20")
|
||||||
|
@Min(1) @Max(200) long size,
|
||||||
|
@Schema(description = "编码、名称、联系人或联系电话关键字", example = "示例客户")
|
||||||
|
@Size(max = 128) String keyword,
|
||||||
|
@Schema(description = "往来单位类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"})
|
||||||
|
@Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH)$") String type,
|
||||||
|
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||||
|
@Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status
|
||||||
|
) {
|
||||||
|
public PartnerPageQuery {
|
||||||
|
if (page == 0) {
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
if (size == 0) {
|
||||||
|
size = 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema(name = "ExpressCompanyPageQuery", description = "平台快递公司分页查询条件")
|
||||||
|
public record ExpressCompanyPageQuery(
|
||||||
|
@Schema(description = "页码,从 1 开始", example = "1", defaultValue = "1")
|
||||||
|
@Min(1) long page,
|
||||||
|
@Schema(description = "每页条数", example = "20", defaultValue = "20")
|
||||||
|
@Min(1) @Max(200) long size,
|
||||||
|
@Schema(description = "快递公司编码或名称关键字", example = "顺丰")
|
||||||
|
@Size(max = 128) String keyword,
|
||||||
|
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||||
|
@Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status
|
||||||
|
) {
|
||||||
|
public ExpressCompanyPageQuery {
|
||||||
|
if (page == 0) {
|
||||||
|
page = 1;
|
||||||
|
}
|
||||||
|
if (size == 0) {
|
||||||
|
size = 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema(name = "PartnerSaveRequest", description = "平台往来单位保存请求")
|
||||||
|
public record PartnerSaveRequest(
|
||||||
|
@Schema(description = "租户内唯一往来单位编码", example = "CUS-001")
|
||||||
|
@NotBlank @Size(max = 50) String partnerCode,
|
||||||
|
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
|
||||||
|
@NotBlank @Size(max = 128) String partnerName,
|
||||||
|
@Schema(description = "往来单位类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"})
|
||||||
|
@NotBlank @Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH)$") String partnerType,
|
||||||
|
@Schema(description = "联系人", example = "张三")
|
||||||
|
@NotBlank @Size(max = 64) String contactName,
|
||||||
|
@Schema(description = "联系电话", example = "13800138000")
|
||||||
|
@NotBlank @Size(max = 32) String contactPhone,
|
||||||
|
@Schema(description = "联系邮箱", example = "contact@example.com")
|
||||||
|
@Email @Size(max = 128) String contactEmail,
|
||||||
|
@Schema(description = "国家二字码", example = "CN")
|
||||||
|
@NotBlank @Pattern(regexp = "^[A-Z]{2}$") String countryCode,
|
||||||
|
@Schema(description = "省或州", example = "广东省")
|
||||||
|
@Size(max = 64) String province,
|
||||||
|
@Schema(description = "城市", example = "深圳市")
|
||||||
|
@NotBlank @Size(max = 64) String city,
|
||||||
|
@Schema(description = "区县", example = "宝安区")
|
||||||
|
@Size(max = 64) String district,
|
||||||
|
@Schema(description = "详细地址", example = "航城街道示例路 1 号")
|
||||||
|
@NotBlank @Size(max = 255) String addressLine,
|
||||||
|
@Schema(description = "邮政编码", example = "518000")
|
||||||
|
@Size(max = 20) String postalCode,
|
||||||
|
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||||
|
@NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema(name = "ExpressCompanySaveRequest", description = "平台快递公司保存请求")
|
||||||
|
public record ExpressCompanySaveRequest(
|
||||||
|
@Schema(description = "租户内唯一快递公司编码", example = "SF")
|
||||||
|
@NotBlank @Size(max = 50) String companyCode,
|
||||||
|
@Schema(description = "快递公司名称", example = "顺丰速运")
|
||||||
|
@NotBlank @Size(max = 128) String companyName,
|
||||||
|
@Schema(description = "客服电话", example = "95338")
|
||||||
|
@Size(max = 32) String servicePhone,
|
||||||
|
@Schema(description = "官方网站", example = "https://www.sf-express.com")
|
||||||
|
@Size(max = 255) String website,
|
||||||
|
@Schema(description = "轨迹查询地址模板,可包含 {trackingNo}")
|
||||||
|
@Size(max = 500) String trackingUrlTemplate,
|
||||||
|
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||||
|
@NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status,
|
||||||
|
@Schema(description = "备注")
|
||||||
|
@Size(max = 500) String remark
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema(name = "MasterDataStatusRequest", description = "基础资料状态变更请求")
|
||||||
|
public record StatusRequest(
|
||||||
|
@Schema(description = "目标状态", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||||
|
@NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.warehouse.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import com.baiyue.common.core.model.BaseEntity;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/** 租户维度快递公司主数据。 */
|
||||||
|
@Schema(name = "ExpressCompany", description = "租户维度快递公司主数据")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("wh_express_company")
|
||||||
|
public class ExpressCompanyEntity extends BaseEntity {
|
||||||
|
@Schema(description = "快递公司记录 ID", example = "192000000000000001")
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
@Schema(description = "所属租户 ID", example = "10001")
|
||||||
|
private Long tenantId;
|
||||||
|
@Schema(description = "租户内唯一快递公司编码", example = "SF")
|
||||||
|
private String companyCode;
|
||||||
|
@Schema(description = "快递公司名称", example = "顺丰速运")
|
||||||
|
private String companyName;
|
||||||
|
@Schema(description = "客服电话", example = "95338")
|
||||||
|
private String servicePhone;
|
||||||
|
@Schema(description = "官方网站", example = "https://www.sf-express.com")
|
||||||
|
private String website;
|
||||||
|
@Schema(description = "轨迹查询地址模板,可包含 {trackingNo}")
|
||||||
|
private String trackingUrlTemplate;
|
||||||
|
@Schema(description = "状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||||
|
private String status;
|
||||||
|
@Schema(description = "备注")
|
||||||
|
private String remark;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.baiyue.warehouse.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
|
||||||
|
|
||||||
|
/** 快递公司持久化映射。 */
|
||||||
|
public interface ExpressCompanyMapper extends BaseMapper<ExpressCompanyEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.baiyue.warehouse.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery;
|
||||||
|
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
|
||||||
|
import com.baiyue.warehouse.mapper.ExpressCompanyMapper;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/** 快递公司主数据仓储。 */
|
||||||
|
@Repository
|
||||||
|
public class ExpressCompanyRepository {
|
||||||
|
private final ExpressCompanyMapper mapper;
|
||||||
|
|
||||||
|
public ExpressCompanyRepository(ExpressCompanyMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<ExpressCompanyEntity> page(long tenantId, ExpressCompanyPageQuery query) {
|
||||||
|
LambdaQueryWrapper<ExpressCompanyEntity> wrapper = new LambdaQueryWrapper<ExpressCompanyEntity>()
|
||||||
|
.eq(ExpressCompanyEntity::getTenantId, tenantId)
|
||||||
|
.eq(StringUtils.hasText(query.status()), ExpressCompanyEntity::getStatus, query.status())
|
||||||
|
.and(StringUtils.hasText(query.keyword()), condition -> condition
|
||||||
|
.like(ExpressCompanyEntity::getCompanyCode, query.keyword())
|
||||||
|
.or()
|
||||||
|
.like(ExpressCompanyEntity::getCompanyName, query.keyword()))
|
||||||
|
.orderByAsc(ExpressCompanyEntity::getCompanyName)
|
||||||
|
.orderByAsc(ExpressCompanyEntity::getId);
|
||||||
|
return PageResult.from(mapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpressCompanyEntity findOwned(long tenantId, long id) {
|
||||||
|
return mapper.selectOne(new LambdaQueryWrapper<ExpressCompanyEntity>()
|
||||||
|
.eq(ExpressCompanyEntity::getTenantId, tenantId)
|
||||||
|
.eq(ExpressCompanyEntity::getId, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsCode(long tenantId, String code, Long excludedId) {
|
||||||
|
return mapper.selectCount(new LambdaQueryWrapper<ExpressCompanyEntity>()
|
||||||
|
.eq(ExpressCompanyEntity::getTenantId, tenantId)
|
||||||
|
.eq(ExpressCompanyEntity::getCompanyCode, code)
|
||||||
|
.ne(excludedId != null, ExpressCompanyEntity::getId, excludedId)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insert(ExpressCompanyEntity entity) {
|
||||||
|
mapper.insert(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(ExpressCompanyEntity entity) {
|
||||||
|
mapper.updateById(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.baiyue.warehouse.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.audit.BusinessAudit;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest;
|
||||||
|
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
|
||||||
|
import com.baiyue.warehouse.repository.ExpressCompanyRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** 平台快递公司管理服务。 */
|
||||||
|
@Service
|
||||||
|
public class ExpressCompanyQueryService {
|
||||||
|
private final ExpressCompanyRepository repository;
|
||||||
|
|
||||||
|
public ExpressCompanyQueryService(ExpressCompanyRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<ExpressCompanyEntity> page(long tenantId, ExpressCompanyPageQuery query) {
|
||||||
|
return repository.page(tenantId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpressCompanyEntity detail(long tenantId, long id) {
|
||||||
|
return owned(tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "EXPRESS_COMPANY_CREATE", resourceType = "EXPRESS_COMPANY", description = "新增快递公司", captureBefore = false)
|
||||||
|
public Long create(long tenantId, ExpressCompanySaveRequest request) {
|
||||||
|
ensureCodeUnique(tenantId, request.companyCode(), null);
|
||||||
|
ExpressCompanyEntity entity = new ExpressCompanyEntity();
|
||||||
|
entity.setTenantId(tenantId);
|
||||||
|
copy(entity, request);
|
||||||
|
try {
|
||||||
|
repository.insert(entity);
|
||||||
|
} catch (DataIntegrityViolationException exception) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在");
|
||||||
|
}
|
||||||
|
return entity.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "EXPRESS_COMPANY_UPDATE", resourceType = "EXPRESS_COMPANY", resourceId = "#id", description = "编辑快递公司")
|
||||||
|
public void update(long tenantId, long id, ExpressCompanySaveRequest request) {
|
||||||
|
ExpressCompanyEntity entity = owned(tenantId, id);
|
||||||
|
ensureCodeUnique(tenantId, request.companyCode(), id);
|
||||||
|
copy(entity, request);
|
||||||
|
try {
|
||||||
|
repository.save(entity);
|
||||||
|
} catch (DataIntegrityViolationException exception) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "EXPRESS_COMPANY_STATUS_CHANGE", resourceType = "EXPRESS_COMPANY", resourceId = "#id", description = "启停快递公司")
|
||||||
|
public void changeStatus(long tenantId, long id, String status) {
|
||||||
|
requireStatus(status);
|
||||||
|
ExpressCompanyEntity entity = owned(tenantId, id);
|
||||||
|
entity.setStatus(status);
|
||||||
|
repository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExpressCompanyEntity owned(long tenantId, long id) {
|
||||||
|
ExpressCompanyEntity entity = repository.findOwned(tenantId, id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new BizException(ResultCode.NOT_FOUND, "快递公司不存在");
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureCodeUnique(long tenantId, String code, Long excludedId) {
|
||||||
|
if (repository.existsCode(tenantId, code, excludedId)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireStatus(String status) {
|
||||||
|
if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "状态只能设置为 ACTIVE 或 INACTIVE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copy(ExpressCompanyEntity entity, ExpressCompanySaveRequest request) {
|
||||||
|
entity.setCompanyCode(request.companyCode());
|
||||||
|
entity.setCompanyName(request.companyName());
|
||||||
|
entity.setServicePhone(request.servicePhone());
|
||||||
|
entity.setWebsite(request.website());
|
||||||
|
entity.setTrackingUrlTemplate(request.trackingUrlTemplate());
|
||||||
|
entity.setStatus(request.status());
|
||||||
|
entity.setRemark(request.remark());
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package com.baiyue.warehouse.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.audit.BusinessAudit;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest;
|
||||||
|
import com.baiyue.warehouse.entity.PartnerEntity;
|
||||||
|
import com.baiyue.warehouse.repository.PartnerRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** 平台往来单位管理服务。 */
|
||||||
|
@Service
|
||||||
|
public class PlatformPartnerQueryService {
|
||||||
|
private final PartnerRepository repository;
|
||||||
|
|
||||||
|
public PlatformPartnerQueryService(PartnerRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<PartnerEntity> page(long tenantId, PartnerPageQuery query) {
|
||||||
|
return repository.page(tenantId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartnerEntity detail(long tenantId, long id) {
|
||||||
|
return owned(tenantId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "PARTNER_CREATE", resourceType = "PARTNER", description = "新增往来单位", captureBefore = false)
|
||||||
|
public Long create(long tenantId, PartnerSaveRequest request) {
|
||||||
|
ensureCodeUnique(tenantId, request.partnerCode(), null);
|
||||||
|
PartnerEntity entity = new PartnerEntity();
|
||||||
|
entity.setTenantId(tenantId);
|
||||||
|
copy(entity, request);
|
||||||
|
try {
|
||||||
|
repository.insert(entity);
|
||||||
|
} catch (DataIntegrityViolationException exception) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "往来单位编码已存在");
|
||||||
|
}
|
||||||
|
return entity.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "PARTNER_UPDATE", resourceType = "PARTNER", resourceId = "#id", description = "编辑往来单位")
|
||||||
|
public void update(long tenantId, long id, PartnerSaveRequest request) {
|
||||||
|
PartnerEntity entity = owned(tenantId, id);
|
||||||
|
ensureCodeUnique(tenantId, request.partnerCode(), id);
|
||||||
|
copy(entity, request);
|
||||||
|
try {
|
||||||
|
repository.save(entity);
|
||||||
|
} catch (DataIntegrityViolationException exception) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "往来单位编码已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "PARTNER_STATUS_CHANGE", resourceType = "PARTNER", resourceId = "#id", description = "启停往来单位")
|
||||||
|
public void changeStatus(long tenantId, long id, String status) {
|
||||||
|
requireStatus(status);
|
||||||
|
PartnerEntity entity = owned(tenantId, id);
|
||||||
|
entity.setStatus(status);
|
||||||
|
repository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PartnerEntity owned(long tenantId, long id) {
|
||||||
|
PartnerEntity entity = repository.findOwned(tenantId, id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new BizException(ResultCode.NOT_FOUND, "往来单位不存在");
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureCodeUnique(long tenantId, String code, Long excludedId) {
|
||||||
|
if (repository.existsCode(tenantId, code, excludedId)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "往来单位编码已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireStatus(String status) {
|
||||||
|
if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "状态只能设置为 ACTIVE 或 INACTIVE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copy(PartnerEntity entity, PartnerSaveRequest request) {
|
||||||
|
entity.setPartnerCode(request.partnerCode());
|
||||||
|
entity.setPartnerName(request.partnerName());
|
||||||
|
entity.setPartnerType(request.partnerType());
|
||||||
|
entity.setContactName(request.contactName());
|
||||||
|
entity.setContactPhone(request.contactPhone());
|
||||||
|
entity.setContactEmail(request.contactEmail());
|
||||||
|
entity.setCountryCode(request.countryCode());
|
||||||
|
entity.setProvince(request.province());
|
||||||
|
entity.setCity(request.city());
|
||||||
|
entity.setDistrict(request.district());
|
||||||
|
entity.setAddressLine(request.addressLine());
|
||||||
|
entity.setPostalCode(request.postalCode());
|
||||||
|
entity.setStatus(request.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
USE baiyue_warehouse;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wh_express_company (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '快递公司记录ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所属租户ID',
|
||||||
|
company_code VARCHAR(50) NOT NULL COMMENT '租户内唯一快递公司编码',
|
||||||
|
company_name VARCHAR(128) NOT NULL COMMENT '快递公司名称',
|
||||||
|
service_phone VARCHAR(32) DEFAULT NULL COMMENT '客服电话',
|
||||||
|
website VARCHAR(255) DEFAULT NULL COMMENT '官方网站',
|
||||||
|
tracking_url_template VARCHAR(500) DEFAULT NULL COMMENT '轨迹查询地址模板,可包含{trackingNo}',
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '快递公司状态',
|
||||||
|
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
|
||||||
|
created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
|
updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
|
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||||
|
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted),
|
||||||
|
KEY idx_wh_express_tenant_status (tenant_id, status, deleted)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户维度快递公司主数据';
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package com.baiyue.warehouse.controller;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PlatformMasterDataControllerContractTest {
|
||||||
|
@Test
|
||||||
|
void 往来单位分页路径权限和接口说明完整() throws Exception {
|
||||||
|
assertContract(PlatformPartnerController.class, "platform:counterparty:list", "/api/platform/v1/partners");
|
||||||
|
assertMethod(PlatformPartnerController.class, "detail", GetMapping.class, "platform:counterparty:list");
|
||||||
|
assertMethod(PlatformPartnerController.class, "create", PostMapping.class, "platform:counterparty:create");
|
||||||
|
assertMethod(PlatformPartnerController.class, "update", PutMapping.class, "platform:counterparty:edit");
|
||||||
|
assertMethod(PlatformPartnerController.class, "changeStatus", PatchMapping.class, "platform:counterparty:status");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 快递公司分页路径权限和接口说明完整() throws Exception {
|
||||||
|
assertContract(PlatformExpressCompanyController.class, "platform:express:list", "/api/platform/v1/express-companies");
|
||||||
|
assertMethod(PlatformExpressCompanyController.class, "detail", GetMapping.class, "platform:express:list");
|
||||||
|
assertMethod(PlatformExpressCompanyController.class, "create", PostMapping.class, "platform:express:create");
|
||||||
|
assertMethod(PlatformExpressCompanyController.class, "update", PutMapping.class, "platform:express:edit");
|
||||||
|
assertMethod(PlatformExpressCompanyController.class, "changeStatus", PatchMapping.class, "platform:express:status");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertContract(Class<?> controller, String authority, String path) throws Exception {
|
||||||
|
RequestMapping mapping = controller.getAnnotation(RequestMapping.class);
|
||||||
|
assertThat(mapping.value()).containsExactly(path);
|
||||||
|
|
||||||
|
Method page = controller.getDeclaredMethod("page",
|
||||||
|
com.baiyue.common.security.AuthenticatedUser.class,
|
||||||
|
long.class,
|
||||||
|
controller == PlatformPartnerController.class
|
||||||
|
? com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery.class
|
||||||
|
: com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery.class);
|
||||||
|
assertThat(page.getAnnotation(GetMapping.class)).isNotNull();
|
||||||
|
assertThat(page.getAnnotation(PreAuthorize.class).value()).contains(authority);
|
||||||
|
assertThat(page.getAnnotation(Operation.class).summary()).contains("分页查询");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertMethod(Class<?> controller, String methodName,
|
||||||
|
Class<? extends java.lang.annotation.Annotation> mapping,
|
||||||
|
String authority) {
|
||||||
|
Method method = java.util.Arrays.stream(controller.getDeclaredMethods())
|
||||||
|
.filter(candidate -> candidate.getName().equals(methodName))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
assertThat(method.getAnnotation(mapping)).isNotNull();
|
||||||
|
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(authority);
|
||||||
|
assertThat(method.getAnnotation(Operation.class).summary()).isNotBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.baiyue.warehouse.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery;
|
||||||
|
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
|
||||||
|
import com.baiyue.warehouse.mapper.ExpressCompanyMapper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ExpressCompanyRepositoryTest {
|
||||||
|
@BeforeAll
|
||||||
|
static void 初始化实体字段缓存() {
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), ExpressCompanyEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 平台分页强制租户并支持筛选() {
|
||||||
|
ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class);
|
||||||
|
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
new ExpressCompanyRepository(mapper).page(10001L,
|
||||||
|
new ExpressCompanyPageQuery(1, 20, "顺丰", "ACTIVE"));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<LambdaQueryWrapper<ExpressCompanyEntity>> wrapper =
|
||||||
|
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||||
|
verify(mapper).selectPage(any(Page.class), wrapper.capture());
|
||||||
|
wrapper.getValue().getSqlSegment();
|
||||||
|
assertThat(wrapper.getValue().getParamNameValuePairs().values())
|
||||||
|
.contains(10001L, "ACTIVE", "%顺丰%");
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package com.baiyue.warehouse.repository;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class WarehouseSchemaContractTest {
|
||||||
|
@Test
|
||||||
|
void 仓库三张表包含实体全部持久化字段() throws IOException {
|
||||||
|
String sql = read("warehouse.sql");
|
||||||
|
assertColumns(sql, "wh_warehouse", "warehouse_code", "warehouse_name", "warehouse_type", "country_code",
|
||||||
|
"timezone", "contact_name", "contact_phone", "contact_email", "status", "remark");
|
||||||
|
assertColumns(sql, "wh_warehouse_address", "warehouse_id", "address_type", "country_code", "state_province",
|
||||||
|
"city", "district", "postal_code", "address_line1", "address_line2", "latitude", "longitude");
|
||||||
|
assertColumns(sql, "wh_service_capability", "warehouse_id", "capability_code", "capability_name", "enabled",
|
||||||
|
"cutoff_time", "description");
|
||||||
|
assertBaseColumns(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 往来单位表包含实体全部持久化字段() throws IOException {
|
||||||
|
String sql = read("warehouse-partner.sql");
|
||||||
|
assertColumns(sql, "wh_partner", "partner_code", "partner_name", "partner_type", "contact_name",
|
||||||
|
"contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line",
|
||||||
|
"postal_code", "status");
|
||||||
|
assertBaseColumns(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 快递公司表包含实体全部持久化字段() throws IOException {
|
||||||
|
String sql = read("warehouse-express-company.sql");
|
||||||
|
assertColumns(sql, "wh_express_company", "company_code", "company_name", "service_phone", "website",
|
||||||
|
"tracking_url_template", "status", "remark");
|
||||||
|
assertBaseColumns(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String read(String file) throws IOException {
|
||||||
|
return Files.readString(Path.of("src/main/resources/db", file));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertColumns(String sql, String table, String... columns) {
|
||||||
|
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS " + table);
|
||||||
|
for (String column : columns) {
|
||||||
|
assertThat(sql).contains(column + " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertBaseColumns(String sql) {
|
||||||
|
assertColumns(sql, tableName(sql), "tenant_id", "created_by", "created_at", "updated_by", "updated_at",
|
||||||
|
"deleted", "version");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String tableName(String sql) {
|
||||||
|
int start = sql.indexOf("CREATE TABLE IF NOT EXISTS ") + "CREATE TABLE IF NOT EXISTS ".length();
|
||||||
|
int end = sql.indexOf(' ', start);
|
||||||
|
return sql.substring(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
package com.baiyue.warehouse.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest;
|
||||||
|
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest;
|
||||||
|
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
|
||||||
|
import com.baiyue.warehouse.entity.PartnerEntity;
|
||||||
|
import com.baiyue.warehouse.repository.ExpressCompanyRepository;
|
||||||
|
import com.baiyue.warehouse.repository.PartnerRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class PlatformMasterDataManagementServiceTest {
|
||||||
|
@Test
|
||||||
|
void 往来单位详情强制租户隔离() {
|
||||||
|
PartnerRepository repository = mock(PartnerRepository.class);
|
||||||
|
PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.detail(10002L, 1L)).isInstanceOf(BizException.class);
|
||||||
|
verify(repository).findOwned(10002L, 1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 往来单位编码在租户内必须唯一() {
|
||||||
|
PartnerRepository repository = mock(PartnerRepository.class);
|
||||||
|
when(repository.existsCode(10001L, "CUS-001", null)).thenReturn(true);
|
||||||
|
PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.create(10001L, partnerRequest()))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("编码");
|
||||||
|
verify(repository, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 往来单位并发重复编码统一返回业务冲突() {
|
||||||
|
PartnerRepository repository = mock(PartnerRepository.class);
|
||||||
|
doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any());
|
||||||
|
PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.create(10001L, partnerRequest()))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("编码");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 往来单位新增写入租户和请求字段() {
|
||||||
|
PartnerRepository repository = mock(PartnerRepository.class);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
PartnerEntity entity = invocation.getArgument(0);
|
||||||
|
entity.setId(18L);
|
||||||
|
return null;
|
||||||
|
}).when(repository).insert(any());
|
||||||
|
PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository);
|
||||||
|
|
||||||
|
assertThat(service.create(10001L, partnerRequest())).isEqualTo(18L);
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<PartnerEntity> entity = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class);
|
||||||
|
verify(repository).insert(entity.capture());
|
||||||
|
assertThat(entity.getValue().getTenantId()).isEqualTo(10001L);
|
||||||
|
assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001");
|
||||||
|
assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 往来单位编辑保留归属并更新字段() {
|
||||||
|
PartnerRepository repository = mock(PartnerRepository.class);
|
||||||
|
PartnerEntity entity = new PartnerEntity();
|
||||||
|
entity.setId(19L);
|
||||||
|
entity.setTenantId(10001L);
|
||||||
|
when(repository.findOwned(10001L, 19L)).thenReturn(entity);
|
||||||
|
PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository);
|
||||||
|
|
||||||
|
service.update(10001L, 19L, partnerRequest());
|
||||||
|
|
||||||
|
assertThat(entity.getTenantId()).isEqualTo(10001L);
|
||||||
|
assertThat(entity.getPartnerName()).isEqualTo("示例客户");
|
||||||
|
verify(repository).save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 往来单位停用保留主数据() {
|
||||||
|
PartnerRepository repository = mock(PartnerRepository.class);
|
||||||
|
PartnerEntity entity = new PartnerEntity();
|
||||||
|
entity.setId(8L);
|
||||||
|
entity.setTenantId(10001L);
|
||||||
|
entity.setStatus("ACTIVE");
|
||||||
|
when(repository.findOwned(10001L, 8L)).thenReturn(entity);
|
||||||
|
PlatformPartnerQueryService service = new PlatformPartnerQueryService(repository);
|
||||||
|
|
||||||
|
service.changeStatus(10001L, 8L, "INACTIVE");
|
||||||
|
|
||||||
|
assertThat(entity.getStatus()).isEqualTo("INACTIVE");
|
||||||
|
verify(repository).save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 快递公司详情强制租户隔离() {
|
||||||
|
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
|
||||||
|
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.detail(10002L, 1L)).isInstanceOf(BizException.class);
|
||||||
|
verify(repository).findOwned(10002L, 1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 快递公司编码在租户内必须唯一() {
|
||||||
|
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
|
||||||
|
when(repository.existsCode(10001L, "SF", null)).thenReturn(true);
|
||||||
|
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.create(10001L, expressRequest()))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("编码");
|
||||||
|
verify(repository, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 快递公司并发重复编码统一返回业务冲突() {
|
||||||
|
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
|
||||||
|
doThrow(new DataIntegrityViolationException("duplicate")).when(repository).insert(any());
|
||||||
|
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.create(10001L, expressRequest()))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("编码");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 快递公司新增写入租户和请求字段() {
|
||||||
|
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
ExpressCompanyEntity entity = invocation.getArgument(0);
|
||||||
|
entity.setId(28L);
|
||||||
|
return null;
|
||||||
|
}).when(repository).insert(any());
|
||||||
|
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
|
||||||
|
|
||||||
|
assertThat(service.create(10001L, expressRequest())).isEqualTo(28L);
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<ExpressCompanyEntity> entity =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(ExpressCompanyEntity.class);
|
||||||
|
verify(repository).insert(entity.capture());
|
||||||
|
assertThat(entity.getValue().getTenantId()).isEqualTo(10001L);
|
||||||
|
assertThat(entity.getValue().getCompanyCode()).isEqualTo("SF");
|
||||||
|
assertThat(entity.getValue().getCompanyName()).isEqualTo("顺丰速运");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 快递公司编辑保留归属并更新字段() {
|
||||||
|
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
|
||||||
|
ExpressCompanyEntity entity = new ExpressCompanyEntity();
|
||||||
|
entity.setId(29L);
|
||||||
|
entity.setTenantId(10001L);
|
||||||
|
when(repository.findOwned(10001L, 29L)).thenReturn(entity);
|
||||||
|
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
|
||||||
|
|
||||||
|
service.update(10001L, 29L, expressRequest());
|
||||||
|
|
||||||
|
assertThat(entity.getTenantId()).isEqualTo(10001L);
|
||||||
|
assertThat(entity.getCompanyName()).isEqualTo("顺丰速运");
|
||||||
|
verify(repository).save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 快递公司停用保留主数据() {
|
||||||
|
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
|
||||||
|
ExpressCompanyEntity entity = new ExpressCompanyEntity();
|
||||||
|
entity.setId(9L);
|
||||||
|
entity.setTenantId(10001L);
|
||||||
|
entity.setStatus("ACTIVE");
|
||||||
|
when(repository.findOwned(10001L, 9L)).thenReturn(entity);
|
||||||
|
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
|
||||||
|
|
||||||
|
service.changeStatus(10001L, 9L, "INACTIVE");
|
||||||
|
|
||||||
|
assertThat(entity.getStatus()).isEqualTo("INACTIVE");
|
||||||
|
verify(repository).save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PartnerSaveRequest partnerRequest() {
|
||||||
|
return new PartnerSaveRequest("CUS-001", "示例客户", "CUSTOMER", "张三", "13800138000",
|
||||||
|
"test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpressCompanySaveRequest expressRequest() {
|
||||||
|
return new ExpressCompanySaveRequest("SF", "顺丰速运", "95338", "https://www.sf-express.com",
|
||||||
|
"https://example.com/{trackingNo}", "ACTIVE", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user