Compare commits

..
43 Commits
Author SHA1 Message Date
liuyihe 6b077c20db 暂存提交 2026-08-19 09:28:40 +08:00
liuyihe 0192380e81 暂存提交 2026-08-14 15:15:43 +08:00
liuyihe 8540147992 Merge branch 'test' into test-lastlag
# Conflicts:
#	auth/src/main/java/com/baiyue/auth/service/AuthService.java
#	auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java
2026-08-14 09:24:12 +08:00
ouyangxq db80701534 优化 2026-08-14 09:14:52 +08:00
ouyangxq 274a3f34c5 优化 2026-08-13 20:00:31 +08:00
ouyangxq 5bc62a109e 优化 2026-08-13 20:00:30 +08:00
liuyihe af646c6f1f 暂存提交 2026-08-13 19:49:19 +08:00
ouyangxq 50251240f4 优化 2026-08-13 19:23:44 +08:00
ouyangxq 808fabaf41 优化 2026-08-13 19:19:29 +08:00
liuyihe 073cb6a577 暂存提交 2026-08-13 19:01:16 +08:00
ouyangxq cca1bc002e 优化 2026-08-13 19:00:33 +08:00
ouyangxq 8ba04fc87d 优化 2026-08-13 18:35:19 +08:00
ouyangxq 8cf6709bde 优化 2026-08-13 18:34:44 +08:00
ouyangxq f779ab56dd fix: restore shared HS256 authentication 2026-08-13 17:58:26 +08:00
ouyangxq 9eaaa9a0a0 ci: streamline test environment deployments 2026-08-13 17:40:55 +08:00
ouyangxq 8ff49b501f fix: handle unfiltered domestic container lists 2026-08-13 17:30:03 +08:00
ouyangxq 6ad438e733 优化 2026-08-13 17:24:40 +08:00
ouyangxq 885ff9dc9e Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-13 17:14:54 +08:00
ouyangxq e9564a21a2 优化 2026-08-13 17:14:46 +08:00
liuyihe c43d581ba6 打印箱唛修改 尾程垮桥事件提交 2026-08-13 17:13:58 +08:00
ouyangxq b004edaf9e 1 2026-08-13 17:01:34 +08:00
ouyangxq b780d8bad2 1 2026-08-13 17:01:30 +08:00
ouyangxq 38e8494216 优化 2026-08-13 16:58:17 +08:00
ouyangxq bd523036d0 fix: add inbound fee partner snapshot columns 2026-08-13 16:41:59 +08:00
ouyangxq 6d10f9e502 fix: remove duplicate service center permission counts 2026-08-13 16:23:58 +08:00
ouyangxq 226ef6a839 优化 2026-08-13 16:14:54 +08:00
ouyangxq 8bcd23f307 优化 2026-08-13 15:59:05 +08:00
ouyangxq 8b393d1906 优化 2026-08-13 15:51:27 +08:00
ouyangxq 319900e359 优化 2026-08-13 15:11:01 +08:00
ouyangxq ef9852e757 优化 2026-08-13 14:55:50 +08:00
ouyangxq 939e90912a 优化 2026-08-13 14:38:04 +08:00
ouyangxq 65cd7e0a39 优化 2026-08-13 14:37:51 +08:00
ouyangxq bc62650ca9 优化 2026-08-13 14:03:54 +08:00
ouyangxq 18cebd3fdd 优化 2026-08-13 13:40:15 +08:00
ouyangxq 1df3805334 fix customer statistics mapper scanning 2026-08-13 13:39:36 +08:00
ouyangxq a16b41eb8c fix iam and order deployment test contracts 2026-08-13 13:01:51 +08:00
ouyangxq e5fda0899e fix firstleg deployment contract failures 2026-08-13 12:54:34 +08:00
ouyangxq 756a6b13eb 优化 2026-08-13 12:37:46 +08:00
ouyangxq e742abd1cf 优化 2026-08-13 12:22:31 +08:00
ouyangxq 16dc03a2e2 优化 2026-08-13 11:57:33 +08:00
ouyangxq 5a48992d78 优化 2026-08-13 09:52:59 +08:00
ouyangxq c331133016 Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test 2026-08-13 09:51:29 +08:00
ouyangxq af75569c78 优化 2026-08-13 09:51:27 +08:00
355 changed files with 10473 additions and 907 deletions
+2
View File
@@ -27,3 +27,5 @@ reports/
uploads/
.codex-temp*
.codex-tmp*
.codex-*.out
.codex-*.err
+12 -6
View File
@@ -26,6 +26,9 @@
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
@@ -39,17 +42,20 @@
## 契约、数据库与验证入口
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
## 全量测试脚本路由
## 全量 HTTP 验收脚本路由
- 本地后端静态/单元回归:在 `baiyue-firstleg/` 执行 `pwsh scripts/backend-full-regression.ps1 -DryRun` 先检查覆盖文件,再按需去掉 `-DryRun` 执行 `order + firstleg` Maven 测试;脚本不访问数据库、Gateway 或测试环境。
- 本地前端回归:在 `baiyue-firstleg/` 执行 `pwsh scripts/frontend-full-regression.ps1 -DryRun` 先检查入口,再按需执行 Admin/Client 的 `pnpm test` 和 `pnpm typecheck`;脚本不启动开发服务器、不访问 Gateway。
- 测试环境 API 全量验收:`scripts/admin-test-env-acceptance.mjs`、`scripts/client-test-env-acceptance.mjs`、`scripts/phase2-test-env-acceptance.ps1` 及 `scripts/admin-test-env-modules/` 属于有数据写入的场景脚本,会创建测试客户、订单或业务记录;只有用户在当前对话明确授权测试环境验收时才执行,并使用报告目录留存结果。
- `scripts/local-db-full-acceptance.ps1` 仅允许 `127.0.0.1`/`localhost` 数据库,先用 `-DryRun` 校验统一 SQL 快照和注释门禁;不得改参数连接远程或测试数据库。
- 验收脚本按“先本地静态回归,再测试环境 API 验收,最后人工浏览器样式验收”顺序使用;脚本通过不等于页面视觉、权限和真实数据链路全部通过。
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
## 阶段文档入口
Vendored
+17 -3
View File
@@ -24,6 +24,8 @@ def commonPaths = [
'Jenkinsfile.service', 'deploy/'
]
def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
pipeline {
@@ -83,6 +85,7 @@ pipeline {
error('DOWNSTREAM_JOB_PREFIX 不能为空')
}
env.RESOLVED_JOB_PREFIX = jobPrefix
env.DATABASE_CHANGE_DETECTED = 'false'
List selected = []
if (params.BUILD_MODE == 'ALL') {
@@ -101,8 +104,13 @@ pipeline {
} else {
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
.trim().split('\\n').collect { it.trim() }.findAll { it }
boolean databaseChanged = changed.any { path ->
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
}
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
boolean commonChanged = changed.any { path ->
commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
}
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
changed.any { it == module || it.startsWith("${module}/") }
@@ -119,13 +127,19 @@ pipeline {
env.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) {
currentBuild.description = '没有需要构建的业务服务'
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务'
} else {
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}"
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
}
if (env.DATABASE_CHANGE_DETECTED == 'true') {
echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。'
}
}
}
}
+8 -1
View File
@@ -110,7 +110,14 @@ pipeline {
stage('Maven 测试与打包') {
steps {
script {
String testOption = params.SKIP_TESTS ? '-DskipTests' : ''
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
}
}
+3 -1
View File
@@ -11,6 +11,7 @@
|---|---|
| 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` |
| 内部接收 API | `controller/AuditIngestController.java`(`/internal/audit/events`) |
| 平台日志查询 API | `controller/PlatformAuditLogController.java`(`/api/platform/v1/audit/logs`)、`service/AuditLogQueryService.java`;列表与详情统一使用 `platform:audit:list` |
| 接收 DTO | `dto/AuditIngestRequest.java` |
| 入库编排 | `service/AuditIngestService.java` |
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
@@ -23,5 +24,6 @@
- `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。
- `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。
- 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。
- 平台日志列表不得返回变更前后快照,详情才返回已脱敏 JSON;客户账号不得访问平台查询接口。
- 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。
- 当前没有专属自动化测试;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
- 自动化测试位于 `src/test/java/com/baiyue/audit/`,覆盖平台日志查询、Controller 权限、MQ 接收/死信和持久化;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
+3 -2
View File
@@ -12,7 +12,8 @@
|---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| Token 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java` |
| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
| DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` |
@@ -21,7 +22,7 @@
## 修改约束
- 密码、Token、敏感 Claim 不得写日志;修改 Claim、Redis Key 或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
+76
View File
@@ -0,0 +1,76 @@
# 国内与海外 JWT/JWKS 冻结契约
## 双轨边界
### 国内现有链路
- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。
- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。
- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。
- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。
- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。
### 海外尾程链路
- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。
- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。
- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。
- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。
- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。
## 海外签发契约
- `issuer`:`auth-service`
- `audience`:`lastleg-api`
- 算法白名单:仅 `RS256`
- Access Token 默认有效期:15 分钟
- 兑换接口:`POST /api/auth/overseas/token`
- JWKS 路径:`/api/auth/.well-known/jwks.json`
- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json`
- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300`
海外 Token 的 JSON 声明名称和类型固定如下:
| 声明 | JSON 类型 | 说明 |
|---|---|---|
| `sub` | string | 用户 ID 的字符串形式 |
| `jti` | string | Token 唯一标识 |
| `iat` | number | 签发时间 |
| `exp` | number | 过期时间 |
| `aud` | string array | 固定包含 `lastleg-api` |
| `userId` | number | 认证用户 ID |
| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 |
| `appCode` | string | `PLATFORM` 或 `MERCHANT` |
| `roles` | string array | 当前角色编码快照 |
| `authorities` | string array | 当前权限编码快照 |
| `permissionVersion` | number | IAM 单调权限版本 |
海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。
## 海外配置
```yaml
baiyue:
security:
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:false}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
```
只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。
## 密钥轮换
- JWK Set 只允许 RSA 密钥。
- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。
- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。
- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。
- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。
## IAM 安全事件
安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。
@@ -20,4 +20,5 @@ public class AuthorizationSnapshot {
private String clientType;
private Set<String> roles;
private Set<String> permissions;
private Long permissionVersion;
}
@@ -1,6 +1,5 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.baiyue.common.security.JwtAuthenticationFilter;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.nio.charset.StandardCharsets;
/**
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
@@ -31,6 +27,7 @@ public class AuthSecurityConfig {
"/api/auth/refresh",
"/api/auth/logout",
"/api/auth/impersonations/exchange",
"/api/auth/.well-known/jwks.json",
"/actuator/health",
"/v3/api-docs/**",
"/swagger-ui/**",
@@ -40,13 +37,6 @@ public class AuthSecurityConfig {
@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
@Bean
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
}
@Bean
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter,
@@ -8,7 +8,7 @@ import java.time.Duration;
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
*/
@ConfigurationProperties(prefix = "baiyue.security")
public record AuthSecurityProperties(String jwtSecret, String issuer,
public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
Duration accessTokenTtl, Duration refreshTokenTtl,
int maxLoginFailures, Duration lockDuration) {
}
@@ -0,0 +1,22 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import java.nio.charset.StandardCharsets;
/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */
@Configuration(proxyBeanMethods = false)
public class DomesticJwtConfiguration {
@Bean("jwtEncoder")
@Primary
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
}
}
@@ -0,0 +1,37 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OverseasJwtProperties.class)
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasJwtConfiguration {
@Bean("overseasJwtEncoder")
JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) {
try {
JWKSet set = JWKSet.parse(properties.rsaJwkSet());
RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null;
if (current == null || !current.isPrivate()) {
throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID())
&& jwk instanceof RSAKey rsa && rsa.isPrivate())) {
throw new IllegalStateException("海外非当前密钥只能配置公钥");
}
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
} catch (java.text.ParseException exception) {
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
}
}
}
@@ -0,0 +1,10 @@
package com.baiyue.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/** 海外尾程 RS256 短时令牌配置,默认关闭。 */
@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt")
public record OverseasJwtProperties(boolean enabled, String issuer, String audience,
String rsaJwkSet, String currentKid, Duration accessTokenTtl) { }
@@ -23,6 +23,8 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
import com.baiyue.auth.dto.AccountManagementDtos;
import com.baiyue.auth.service.AccountManagementService;
import com.baiyue.auth.service.OverseasTokenService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.access.prepost.PreAuthorize;
/**
@@ -38,6 +40,18 @@ public class AuthController {
private MerchantRegistrationService registrationService;
@Resource
private AccountManagementService accountManagementService;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
@PostMapping("/overseas/token")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
@AuthenticationPrincipal AuthenticatedUser user) {
OverseasTokenService service = overseasTokens.getIfAvailable();
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
return ApiResponse.success(service.exchange(user.userId()));
}
@PutMapping("/password")
@PreAuthorize("isAuthenticated()")
@@ -0,0 +1,31 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.nimbusds.jose.jwk.JWKSet;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
@RestController
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class JwksController {
private final OverseasJwtProperties properties;
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
@GetMapping("/api/auth/.well-known/jwks.json")
@SecurityRequirements
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
public ResponseEntity<Map<String, Object>> jwks() throws Exception {
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
}
}
@@ -30,6 +30,7 @@ import java.time.LocalDateTime;
import java.util.Base64;
import java.util.UUID;
import java.time.Duration;
import java.util.List;
/**
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
@@ -108,7 +109,8 @@ public class AuthService {
String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.subject(account.getId().toString())
.claim("user_id", account.getId())
.claim("username", account.getUsername())
@@ -119,9 +121,13 @@ public class AuthService {
.claim("client_type", authorization.getClientType())
.claim("token_version", account.getTokenVersion())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
.claim("userId", account.getId())
.claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode())
.claim("permissionVersion", account.getTokenVersion())
.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
signingHeader(), claims)).getTokenValue();
byte[] random = new byte[48];
secureRandom.nextBytes(random);
@@ -167,16 +173,20 @@ public class AuthService {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
String jwtId = UUID.randomUUID().toString();
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()).id(jwtId)
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
.audience(List.of(properties.audience())).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", "MERCHANT")
.claim("permissionVersion", account.getTokenVersion())
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
authorization.getPermissions(), Duration.ofMinutes(15));
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
@@ -184,6 +194,10 @@ public class AuthService {
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
private JwsHeader signingHeader() {
return JwsHeader.with(MacAlgorithm.HS256).build();
}
private static BizException invalidCredentials() {
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
}
@@ -0,0 +1,56 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
@Service
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class OverseasTokenService {
@Resource private AuthAccountRepository accounts;
@Resource private AuthorizationLoader authorizationLoader;
@Resource private OverseasJwtProperties properties;
@Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder;
public OverseasTokenResponse exchange(long authenticatedUserId) {
AuthAccount account = accounts.findById(authenticatedUserId);
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
}
AuthorizationSnapshot authorization = authorizationLoader.load(account);
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt)
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
.build();
String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
}
+9 -1
View File
@@ -34,10 +34,18 @@ baiyue.security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
issuer: ${JWT_ISSUER:auth-service}
access-token-ttl: ${ACCESS_TOKEN_TTL:24h}
audience: ${JWT_AUDIENCE:lastleg-api}
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
max-login-failures: ${MAX_LOGIN_FAILURES:5}
lock-duration: ${LOGIN_LOCK_DURATION:15m}
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:false}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
baiyue.clients:
iam:
@@ -72,6 +72,6 @@ class AuthorizationLoaderTest {
private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) {
return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode,
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"));
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L);
}
}
@@ -15,8 +15,9 @@ class AuthTokenTtlConfigurationContractTest {
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
assertThat(configuration)
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:2h}")
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
}
}
@@ -0,0 +1,29 @@
package com.baiyue.auth.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class DualTrackJwtConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class))
.withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties(
"domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api",
java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5,
java.time.Duration.ofMinutes(15)))
.withPropertyValues(
"baiyue.security.overseas-jwt.enabled=false");
@Test
void startsDomesticEncoderWithoutRsaConfiguration() {
runner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasBean("jwtEncoder");
assertThat(context).doesNotHaveBean("overseasJwtEncoder");
assertThat(context).hasSingleBean(JwtEncoder.class);
});
}
}
@@ -0,0 +1,40 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.util.Base64URL;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class JwksControllerTest {
@Test
void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception {
RSAKey current = key("current", true);
RSAKey previous = key("previous", false);
String json = new JWKSet(List.of(current, previous)).toString(false);
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json,
"current", Duration.ofMinutes(15));
var body = new JwksController(properties).jwks().getBody();
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
assertThat(published).contains("current", "previous", "\"n\"", "\"e\"")
.doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"");
}
private RSAKey key(String kid, boolean privatePart) {
RSAKey.Builder builder = new RSAKey.Builder(
Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17)))
.keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256);
if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753)));
return builder.build();
}
}
@@ -12,6 +12,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
@@ -39,13 +40,14 @@ class AuthServiceSmallJwtTest {
static class TestConfig {
@Bean
AuthSecurityProperties properties() {
return new AuthSecurityProperties("unused", "auth-service", Duration.ofMinutes(30),
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api",
Duration.ofMinutes(30),
Duration.ofDays(7), 5, Duration.ofMinutes(10));
}
}
@Test
void signsIdentityOnlyJwtAndStoresAuthorizationInRedis() {
void signsCrossRegionClaimsAndStoresSession() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setUsername("operator");
@@ -72,12 +74,57 @@ class AuthServiceSmallJwtTest {
verify(jwtEncoder).encode(jwt.capture());
assertThat(jwt.getValue().getClaims().getClaims())
.containsEntry("user_id", 42L)
.containsEntry("userId", 42L)
.containsEntry("tenantId", 0L)
.containsEntry("appCode", "PLATFORM")
.containsEntry("permissionVersion", 1)
.containsEntry("membership_id", 84L)
.doesNotContainKeys("roles", "permissions", "authorities", "scope");
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")),
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
}
@Test
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
AuthAccount account = new AuthAccount();
account.setId(43L);
account.setUsername("merchant-owner");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(10001L);
account.setOperatorTenantId(0L);
account.setDefaultMembershipId(85L);
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(2);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
when(tokenSessionStore.consumeImpersonationTicket("one-time-code")).thenReturn(ticket);
when(repository.findById(43L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
service.exchangeImpersonationTicket("one-time-code");
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture());
assertThat(jwt.getValue().getClaims().getClaims())
.containsEntry("appCode", "MERCHANT")
.containsEntry("impersonated", true)
.containsEntry("membership_id", 85L)
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
}
}
@@ -0,0 +1,74 @@
package com.baiyue.auth.service;
import com.baiyue.auth.client.AuthorizationLoader;
import com.baiyue.auth.client.AuthorizationSnapshot;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import java.time.Duration;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true")
class OverseasTokenServiceTest {
@MockBean private AuthAccountRepository accounts;
@MockBean private AuthorizationLoader authorizationLoader;
@MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder;
@Resource private OverseasTokenService service;
@Configuration(proxyBeanMethods = false)
static class Config {
@Bean OverseasJwtProperties properties() {
return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid",
Duration.ofMinutes(15));
}
}
@Test
void signsShortRs256TokenWithCrossRegionClaims() {
AuthAccount account = new AuthAccount();
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L);
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB",
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(accounts.findById(42L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(encoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("overseas-token");
var response = service.exchange(42L);
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder).encode(captor.capture());
var parameters = captor.getValue();
assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(parameters.getClaims().getClaims())
.containsEntry("userId", 42L).containsEntry("tenantId", 100L)
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
.containsEntry("authorities", Set.of("merchant:order:list"))
.doesNotContainKeys("user_id", "tenant_id", "app_code");
assertThat(response.expiresIn()).isEqualTo(900);
}
}
@@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer {
if (payload == null || payload.isNull()) {
throw new IllegalArgumentException("事件负载不能为空");
}
return write(sort(payload));
}
/** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */
public static byte[] canonicalizeLegacy(JsonNode payload) {
if (payload == null || payload.isNull()) {
throw new IllegalArgumentException("事件负载不能为空");
}
return write(sortLegacy(payload));
}
private static byte[] write(JsonNode payload) {
try {
return OBJECT_MAPPER.writeValueAsBytes(sort(payload));
return OBJECT_MAPPER.writeValueAsBytes(payload);
} catch (JsonProcessingException exception) {
throw new IllegalArgumentException("事件负载无法规范化", exception);
}
@@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer {
node.forEach(child -> result.add(sort(child)));
return result;
}
if (node.isNumber()) {
return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros());
}
return node;
}
private static JsonNode sortLegacy(JsonNode node) {
if (node.isObject()) {
var names = new java.util.ArrayList<String>();
node.fieldNames().forEachRemaining(names::add);
names.sort(String::compareTo);
var result = OBJECT_MAPPER.createObjectNode();
names.forEach(name -> result.set(name, sortLegacy(node.get(name))));
return result;
}
if (node.isArray()) {
var result = OBJECT_MAPPER.createArrayNode();
node.forEach(child -> result.add(sortLegacy(child)));
return result;
}
return node;
}
}
@@ -9,6 +9,10 @@ public final class EventTypes {
public static final String ORDER_APPROVED = "OrderApproved";
public static final String ORDER_CANCELLED = "OrderCancelled";
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
@@ -0,0 +1,74 @@
package com.baiyue.common.event;
import java.math.BigDecimal;
import java.util.List;
/** 头程装柜完成后交给海外尾程的不可变 V1 快照。 */
public record FirstLegLoadingPlanCompletedV1(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
List<Order> orders) {
public record Order(
String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
public Order {
items = items == null ? List.of() : items;
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
}
}
public record Item(
String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark, String palletNo, List<String> cartonMarks) {
public Item {
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
}
public Item(String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark) {
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
productQuantity, palletCount, grossWeightKg, netWeightKg,
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
shipmentNo, shippingMark, null, List.of());
}
}
/** 一键代发入仓单的托盘冻结快照。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */
public record SkuPackageSnapshot(String skuCode, String productName,
int packageCount, int productQuantity,
List<String> cartonMarks) {}
}
@@ -0,0 +1,18 @@
package com.baiyue.common.event;
import java.time.Instant;
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
public final class IamSecurityEventsV1 {
public static final int VERSION = 1;
private IamSecurityEventsV1() { }
public record UserStatusChanged(long authUserId, long tenantId, String appCode,
String status, long permissionVersion, Instant changedAt) { }
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
long permissionVersion, String reason, Instant changedAt) { }
}
@@ -0,0 +1,29 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
class EventPayloadCanonicalizerTest {
private final ObjectMapper json = new ObjectMapper();
@Test
void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception {
var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}");
var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}");
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized));
assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)),
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized)));
}
@Test
void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception {
var negativeZero = json.readTree("{\"value\":-0.000}");
var zero = json.readTree("{\"value\":0}");
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero));
assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)),
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero)));
}
}
@@ -0,0 +1,50 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FirstLegLoadingPlanCompletedV1Test {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void v1PayloadRoundTripsWithoutLosingLargeIds() throws Exception {
var item = new FirstLegLoadingPlanCompletedV1.Item("2088000000000000001", "SKU-1", "产品", "Product",
2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), new BigDecimal("12.000"),
new BigDecimal("0.200"), "2088000000000000002", "ONT8", "ONT8", "FBA-1", "SHIP-1", "MARK");
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
new BigDecimal("0.200"), List.of(item));
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
String json = mapper.writeValueAsString(payload);
assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload);
assertThat(json).contains("2088000000000000003", "sourceInboundDetailId");
}
@Test
void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception {
var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
"SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002"));
var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
"3", "海外仓", List.of(order));
String json = mapper.writeValueAsString(payload);
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
assertThat(restored).isEqualTo(payload);
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
}
}
@@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
*/
@AutoConfiguration(
after = HmacJwtDecoderAutoConfiguration.class,
after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class},
before = SecurityAutoConfiguration.class)
@EnableMethodSecurity
public class BaiyueSecurityAutoConfiguration {
@@ -47,11 +47,18 @@ public class BaiyueSecurityAutoConfiguration {
@ConditionalOnMissingBean
JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
AuthenticationEntryPoint entryPoint,
StringRedisTemplate redisTemplate,
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
ObjectMapper objectMapper,
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint,
new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix, objectMapper));
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
AccessTokenSessionValidator validator;
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
else {
StringRedisTemplate template=redis.getIfAvailable();
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper);
}
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
}
@Bean
@@ -0,0 +1,16 @@
package com.baiyue.common.security;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.LinkedHashSet;
import java.util.Set;
/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */
public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator {
@Override public AccessTokenSession validate(Jwt jwt) {
Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id")));
return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")),
values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions"))));
}
private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} }
private static Set<String> values(Object value){ Set<String> result=new LinkedHashSet<>(); if(value instanceof Iterable<?> it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; }
}
@@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets;
* HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。
*/
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
@ConditionalOnProperty(name = "baiyue.security.jwt-secret")
@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true)
public class HmacJwtDecoderAutoConfiguration {
@Bean
@ConditionalOnMissingBean(JwtDecoder.class)
@@ -0,0 +1,32 @@
package com.baiyue.common.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import java.util.List;
/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks")
public class JwksJwtDecoderAutoConfiguration {
@Bean @ConditionalOnMissingBean(JwtDecoder.class)
JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri,
@Value("${baiyue.security.issuer:auth-service}") String issuer,
@Value("${baiyue.security.audience:lastleg-api}") String audience) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri)
.jwsAlgorithm(SignatureAlgorithm.RS256).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtClaimValidator<List<String>>("aud", values -> values != null && values.contains(audience))));
return decoder;
}
}
@@ -23,20 +23,21 @@ public class JwtClaimsMapper {
addRoles(permissions, session.roles());
}
String appCode = jwt.getClaimAsString("app_code");
String appCode = firstNonBlank(jwt.getClaimAsString("appCode"), jwt.getClaimAsString("app_code"));
if (appCode != null && !appCode.isBlank()) {
permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT));
}
String username = firstNonBlank(jwt.getClaimAsString("username"),
jwt.getClaimAsString("preferred_username"), jwt.getSubject());
Long userId = longClaim(jwt, "user_id");
Long userId = longClaim(jwt, "userId");
if (userId == null) userId = longClaim(jwt, "user_id");
AuthenticatedUser user = new AuthenticatedUser(
userId == null ? longValue(jwt.getSubject()) : userId,
longClaim(jwt, "tenant_id"),
longClaimEither(jwt, "tenantId", "tenant_id"),
longClaim(jwt, "operator_tenant_id"),
longClaim(jwt, "membership_id"),
username,
jwt.getClaimAsString("app_code"),
appCode,
jwt.getClaimAsString("client_type"),
permissions,
Boolean.TRUE.equals(jwt.getClaim("impersonated")),
@@ -76,6 +77,10 @@ public class JwtClaimsMapper {
if (value instanceof String text) return longValue(text);
return null;
}
private Long longClaimEither(Jwt jwt, String first, String second) {
Long value = longClaim(jwt, first);
return value == null ? longClaim(jwt, second) : value;
}
private String firstNonBlank(String... values) {
for (String value : values) if (value != null && !value.isBlank()) return value;
@@ -1,2 +1,3 @@
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
com.baiyue.common.security.JwksJwtDecoderAutoConfiguration
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
+8 -1
View File
@@ -6,5 +6,12 @@ spring:
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
security:
access-token-ttl: ${ACCESS_TOKEN_TTL:2h}
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
overseas-jwt:
enabled: ${OVERSEAS_JWT_ENABLED:true}
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
+1
View File
@@ -19,6 +19,7 @@ x-common: &common
REDIS_DATABASE: ${REDIS_DATABASE:-0}
JWT_SECRET: ${JWT_SECRET}
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
BAIYUE_AUDIT_ENDPOINT: ${BAIYUE_AUDIT_ENDPOINT:-http://audit-service:9700/internal/audit/events}
SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB}
AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false}
AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false}
+30 -12
View File
@@ -4,36 +4,54 @@
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors>
<mirror>
<id>aliyun-public</id>
<name>阿里云 Maven 公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
<id>baiyue-nexus-public</id>
<name>柏悦 Nexus Maven 聚合仓库</name>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<mirrorOf>external:*</mirrorOf>
</mirror>
</mirrors>
<servers>
<server>
<id>baiyue-nexus-public</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-releases</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-snapshots</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
</servers>
<profiles>
<profile>
<id>国内仓库</id>
<id>柏悦 Nexus</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
<snapshots><enabled>true</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>国内仓库</activeProfile>
<activeProfile>柏悦 Nexus</activeProfile>
</activeProfiles>
</settings>
+1
View File
@@ -11,6 +11,7 @@
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
- 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。
- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。
- IAM 跨区安全事件基线已内联到固定入口,包括主体权限版本、客户状态版本和可靠事件发送箱 3 张表;模块溯源脚本为 `iam/src/main/resources/db/security-event-outbox.sql`。IAM 新版本启动前必须先执行固定入口,避免安全事件 Relay 和授权快照查询因缺表失败。
- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。
后续追加增量必须满足:
+583 -72
View File
@@ -1494,7 +1494,7 @@ VALUES
'/downloads', 40, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no),
status = 'ACTIVE', built_in = 1, deleted = 0;
status = VALUES(status), built_in = 1, deleted = VALUES(deleted);
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
@@ -2401,7 +2401,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、PREPARING草稿预入库、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜',
order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废',
@@ -2657,6 +2657,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数',
carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标',
label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '内部兼容批次字段,固定为1且不进入箱码',
batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废',
cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹',
voided_at DATETIME(3) NULL COMMENT '批次作废时间',
box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照',
length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照',
width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照',
@@ -2664,11 +2668,67 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code),
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index),
UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index),
KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version),
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
-- 存量环境可能缺少稳定箱码映射兼容字段;CREATE TABLE IF NOT EXISTS 不会补列。
DROP PROCEDURE IF EXISTS unified_carton_label_add_column;
DELIMITER $$
CREATE PROCEDURE unified_carton_label_add_column(IN p_column VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE()
AND table_name='fl_inbound_carton_label'
AND column_name=p_column) THEN
SET @ddl = CONCAT('ALTER TABLE fl_inbound_carton_label ADD COLUMN `', p_column, '` ', p_definition);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
CALL unified_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
CALL unified_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
CALL unified_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
CALL unified_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
DROP PROCEDURE IF EXISTS unified_carton_label_add_column;
UPDATE fl_inbound_carton_label
SET label_version=1,
batch_status='ACTIVE'
WHERE label_version IS NULL OR batch_status IS NULL;
DELETE FROM fl_inbound_carton_label WHERE batch_status='VOID';
UPDATE fl_inbound_carton_label
SET label_version=1,
box_code=CONCAT(inbound_code, '#', total_carton_count, '-', carton_index)
WHERE batch_status='ACTIVE';
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_inbound_carton_label'
AND index_name='uk_carton_label_index'),
'ALTER TABLE fl_inbound_carton_label DROP INDEX uk_carton_label_index', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
NOT EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_inbound_carton_label'
AND index_name='uk_carton_label_index'),
'ALTER TABLE fl_inbound_carton_label ADD UNIQUE KEY uk_carton_label_index (tenant_id,inbound_id,label_version,carton_index)',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
NOT EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_inbound_carton_label'
AND index_name='idx_carton_label_active'),
'ALTER TABLE fl_inbound_carton_label ADD KEY idx_carton_label_active (tenant_id,inbound_id,batch_status,label_version)',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -4346,7 +4406,7 @@ BEGIN
'platform:overseas-storage-fee:list', 'platform:overseas-storage-fee:create',
'platform:overseas-storage-fee:edit', 'platform:overseas-storage-fee:delete')) <> 4 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用权限未完整创建';
SET MESSAGE_TEXT = '统一迁移失败:海外仓阶梯费用列表/编辑权限未完整创建';
END IF;
END$$
DELIMITER ;
@@ -4395,6 +4455,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付',
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空',
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空',
business_date DATE NOT NULL COMMENT '业务发生日',
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
@@ -8686,7 +8748,7 @@ BEGIN
WHERE (id BETWEEN 5102140 AND 5102149
AND permission_code <> CASE id
WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list'
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:view'
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list'
WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create'
WHEN 5102143 THEN 'platform:firstleg:ocean-booking:edit'
WHEN 5102144 THEN 'platform:firstleg:ocean-booking:submit'
@@ -8712,7 +8774,6 @@ INSERT INTO iam_permission
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
(5102141,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:view','查看订舱',NULL,'GET',10,'ACTIVE',1,0),
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
(5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0),
(5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0),
@@ -8807,7 +8868,7 @@ BEGIN
WHERE (id BETWEEN 5102150 AND 5102154
AND permission_code <> CASE id
WHEN 5102150 THEN 'platform:firstleg:truck-appointment:list'
WHEN 5102151 THEN 'platform:firstleg:truck-appointment:view'
WHEN 5102151 THEN 'platform:firstleg:truck-appointment:list'
WHEN 5102152 THEN 'platform:firstleg:truck-appointment:create'
WHEN 5102153 THEN 'platform:firstleg:truck-appointment:edit'
WHEN 5102154 THEN 'platform:firstleg:truck-appointment:operate'
@@ -8828,7 +8889,6 @@ INSERT INTO iam_permission
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list','拖车预约','/first-leg/truck-appointments','GET',90,'ACTIVE',1,0),
(5102151,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:view','查看拖车预约',NULL,'GET',10,'ACTIVE',1,0),
(5102152,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:create','创建拖车预约',NULL,'POST',20,'ACTIVE',1,0),
(5102153,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:edit','编辑拖车预约',NULL,'PUT',30,'ACTIVE',1,0),
(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate','操作拖车预约',NULL,'POST',40,'ACTIVE',1,0)
@@ -8859,10 +8919,10 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102140 AND 5102149
AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 10
AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 9
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102150 AND 5102154
AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 5 THEN
AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约权限未完整写入';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code))
@@ -8870,13 +8930,13 @@ BEGIN
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 20
AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 18
OR (SELECT COUNT(DISTINCT CONCAT(r.role_code, '|', p.permission_code))
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND p.permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 10 THEN
AND p.permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约角色授权未完整写入';
END IF;
END$$
@@ -10202,6 +10262,13 @@ CREATE TABLE IF NOT EXISTS fin_subject (
UNIQUE KEY uk_fin_subject_code (subject_code)
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
INSERT INTO fin_subject
(subject_code, subject_name, status, version, created_by, updated_by)
VALUES
('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)
ON DUPLICATE KEY UPDATE
subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0;
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -10438,6 +10505,80 @@ CREATE TABLE IF NOT EXISTS fin_audit_log (
KEY idx_fin_audit_operator (operator_id, created_at)
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
CREATE TABLE IF NOT EXISTS fin_settlement (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式应收或应付结算单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
settlement_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一结算单号',
direction VARCHAR(16) NOT NULL COMMENT '结算方向:RECEIVABLE应收、PAYABLE应付',
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '结算生成时客户名称快照',
supplier_id BIGINT UNSIGNED NULL COMMENT '应付结算供应商ID;应收结算为空',
supplier_name_snapshot VARCHAR(200) NULL COMMENT '应付结算供应商名称快照',
reconciliation_statement_id BIGINT UNSIGNED NULL COMMENT '应收结算关联的已出账客户对账单ID',
business_period VARCHAR(32) NULL COMMENT '结算业务期间快照',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数',
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数',
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数',
generated_date DATE NOT NULL COMMENT '结算单生成日期',
due_date DATE NOT NULL COMMENT '结算到期日期',
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_no (tenant_id, settlement_no),
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
CONSTRAINT chk_fin_settlement_amounts CHECK (total_amount >= 0 AND paid_amount >= 0 AND outstanding_amount >= 0 AND total_amount = paid_amount + outstanding_amount)
) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生';
CREATE TABLE IF NOT EXISTS fin_settlement_line (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
fee_source_id BIGINT UNSIGNED NOT NULL COMMENT '结算固化的Finance费用来源ID',
amount DECIMAL(20,6) NOT NULL COMMENT '结算明细金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_line_source (settlement_id, fee_source_id),
KEY idx_fin_settlement_line_customer (tenant_id, settlement_id),
KEY idx_fin_settlement_line_source (fee_source_id),
CONSTRAINT chk_fin_settlement_line_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_line_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_line_amount CHECK (amount > 0)
) ENGINE=InnoDB COMMENT='正式结算单费用来源金额快照明细';
CREATE TABLE IF NOT EXISTS fin_settlement_payment (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单收付款记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
transaction_type VARCHAR(16) NOT NULL COMMENT '交易类型:RECEIPT收款、PAYMENT付款',
transaction_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收付款业务号',
transaction_date DATE NOT NULL COMMENT '实际收款或付款日期',
amount DECIMAL(20,6) NOT NULL COMMENT '本次收款或付款金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
method VARCHAR(32) NOT NULL COMMENT '人工登记的收付款方式',
reference_no VARCHAR(128) NULL COMMENT '银行流水号或外部参考号',
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '登记操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no),
KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at),
CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
USE baiyue_firstleg;
-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql
-- Firstleg -> Finance 费用来源事件和柜次费用模型(MySQL 8,可重复执行)
@@ -10545,6 +10686,10 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_id',
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_name',
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id',
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name',
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
@@ -10566,6 +10711,11 @@ BEGIN
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用往来单位快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用往来单位快照字段不完整';
END IF;
END$$
DELIMITER ;
CALL firstleg_finance_assert_schema();
@@ -11965,6 +12115,8 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbound_c
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbound_id', '入库单ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'inbounded_at', '入库完成时间快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'invoice_title', '发票抬头快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_id', '应付往来单位ID快照;应收为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'partner_name', '应付往来单位名称快照;应收为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'package_count', '件数快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'publication_status', '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'published_at', '发布时间');
@@ -12164,7 +12316,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'package_count'
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'pallet_count', '已分配总托盘数快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'parent_inbound_id', '直属主入仓单ID;主单为空');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode', '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'process_status', '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'process_status', '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'public_remark', '客户可见备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'root_inbound_id', '根主入仓单ID;存量主单可为空,按自身ID解释');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'split_purpose', '履约分片用途:MASTER、LOADED、REMAINDER');
@@ -13571,11 +13723,19 @@ BEGIN
WHERE table_schema = 'baiyue_finance'
AND table_name IN ('fin_fee_source', 'fin_inbox_event', 'fin_subject',
'fin_customer_settlement_profile', 'fin_bill', 'fin_bill_line',
'fin_receipt', 'fin_receipt_bill', 'fin_cost', 'fin_cost_allocation',
'fin_bill_revision', 'fin_idempotency_record', 'fin_audit_log')) <> 13 THEN
'fin_receipt', 'fin_receipt_bill', 'fin_cost', 'fin_cost_allocation',
'fin_bill_revision', 'fin_idempotency_record', 'fin_audit_log',
'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:Finance 首期人工财务表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema='baiyue_finance' AND table_name='fin_settlement'
AND index_name='idx_fin_settlement_customer_direction_status'
AND column_name IN ('tenant_id','direction','status')) <> 3 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:Finance 客户结算汇总索引未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_finance'
AND ((table_name = 'fin_customer_settlement_profile' AND column_name IN ('tenant_id', 'credit_days'))
@@ -13605,6 +13765,21 @@ BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:稳定箱号映射表未创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_carton_label'
AND column_name IN ('label_version', 'batch_status', 'cargo_fingerprint', 'voided_at')) <> 4 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:稳定箱码映射兼容字段未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_carton_label'
AND index_name = 'idx_carton_label_active'
AND non_unique = 1) <> 4 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:箱唛有效批次索引未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_measurement_item'
@@ -13891,7 +14066,6 @@ INSERT INTO baiyue_iam.iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES
(5105000, 0, 'PLATFORM', 'MENU', 0, 'platform:finance', '财务管理', '/finance', NULL, 60, 'ACTIVE', 0),
(5105010, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:view', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0),
(5105011, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:create', '创建账单', NULL, 'POST', 20, 'ACTIVE', 0),
(5105012, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:update', '修改草稿账单', NULL, 'PUT', 30, 'ACTIVE', 0),
(5105013, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:bill:issue', '出账', NULL, 'POST', 40, 'ACTIVE', 0),
@@ -13916,9 +14090,7 @@ BEGIN
IF EXISTS (
SELECT 1
FROM baiyue_iam.iam_permission
WHERE (id = 5105022 AND permission_code <> 'platform:finance:account:view')
OR (id = 5105023 AND permission_code <> 'platform:finance:fee-source:view')
OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
WHERE (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement')
OR (id = 5105026 AND permission_code <> 'platform:finance:reconciliation:create')
OR (id = 5105027 AND permission_code <> 'platform:finance:reconciliation:review')
@@ -13927,11 +14099,8 @@ BEGIN
OR (id = 5105030 AND permission_code <> 'platform:finance:reconciliation:void')
OR (id = 5105031 AND permission_code <> 'platform:finance:settlement-ar:create')
OR (id = 5105032 AND permission_code <> 'platform:finance:settlement-ap:create')
OR (id = 5105033 AND permission_code <> 'platform:finance:settlement-ar:view')
OR (id = 5105034 AND permission_code <> 'platform:finance:settlement-ap:view')
OR (id = 5105035 AND permission_code <> 'platform:finance:payment:create')
OR (permission_code = 'platform:finance:account:view' AND id <> 5105022)
OR (permission_code = 'platform:finance:fee-source:view' AND id <> 5105023)
OR (id = 5105036 AND permission_code <> 'platform:finance:settlement:void')
OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024)
OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025)
OR (permission_code = 'platform:finance:reconciliation:create' AND id <> 5105026)
@@ -13941,10 +14110,9 @@ BEGIN
OR (permission_code = 'platform:finance:reconciliation:void' AND id <> 5105030)
OR (permission_code = 'platform:finance:settlement-ar:create' AND id <> 5105031)
OR (permission_code = 'platform:finance:settlement-ap:create' AND id <> 5105032)
OR (permission_code = 'platform:finance:settlement-ar:view' AND id <> 5105033)
OR (permission_code = 'platform:finance:settlement-ap:view' AND id <> 5105034)
OR (permission_code = 'platform:finance:payment:create' AND id <> 5105035)
OR (id BETWEEN 5105022 AND 5105035
OR (permission_code = 'platform:finance:settlement:void' AND id <> 5105036)
OR (id BETWEEN 5105022 AND 5105036
AND (tenant_id <> 0 OR app_code <> 'PLATFORM'))
) THEN
SIGNAL SQLSTATE '45000'
@@ -13956,9 +14124,9 @@ BEGIN
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5105022, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:account:view',
'查看客户财务账户及流水', NULL, 'GET', 130, 'ACTIVE', 1, 0),
'查看客户财务账户及流水(已停用)', NULL, 'GET', 130, 'INACTIVE', 1, 1),
(5105023, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:view',
'查看财务费用来源', NULL, 'GET', 140, 'ACTIVE', 1, 0),
'查看财务费用来源(已停用)', NULL, 'GET', 140, 'INACTIVE', 1, 1),
(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review',
'审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0),
(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement',
@@ -13970,14 +14138,13 @@ BEGIN
(5105030, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:reconciliation:void', '作废或重开客户对账单', NULL, 'POST', 210, 'ACTIVE', 1, 0),
(5105031, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:create', '生成应收结算单', NULL, 'POST', 220, 'ACTIVE', 1, 0),
(5105032, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:create', '生成应付结算单', NULL, 'POST', 230, 'ACTIVE', 1, 0),
(5105033, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ar:view', '查看应收结算单', NULL, 'GET', 240, 'ACTIVE', 1, 0),
(5105034, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement-ap:view', '查看应付结算单', NULL, 'GET', 250, 'ACTIVE', 1, 0),
(5105035, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:payment:create', '登记应付付款', NULL, 'POST', 260, 'ACTIVE', 1, 0)
(5105035, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:payment:create', '登记应付付款', NULL, 'POST', 260, 'ACTIVE', 1, 0),
(5105036, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:settlement:void', '作废或重开结算单', NULL, 'POST', 270, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = VALUES(http_method), sort_no = VALUES(sort_no),
status = 'ACTIVE', built_in = 1, deleted = 0;
status = VALUES(status), built_in = 1, deleted = VALUES(deleted);
IF (SELECT COUNT(*)
FROM baiyue_iam.iam_permission
@@ -13989,8 +14156,6 @@ BEGIN
AND built_in = 1
AND deleted = 0
AND permission_code IN (
'platform:finance:account:view',
'platform:finance:fee-source:view',
'platform:finance:fee-source:review',
'platform:finance:fee-source:settlement',
'platform:finance:reconciliation:create',
@@ -14000,9 +14165,8 @@ BEGIN
'platform:finance:reconciliation:void',
'platform:finance:settlement-ar:create',
'platform:finance:settlement-ap:create',
'platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view',
'platform:finance:payment:create')) <> 14 THEN
'platform:finance:payment:create',
'platform:finance:settlement:void')) <> 11 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'finance G1-G4 permission catalog is incomplete';
END IF;
@@ -14011,11 +14175,19 @@ DELIMITER ;
CALL migrate_finance_g1_g4_permissions();
DROP PROCEDURE IF EXISTS migrate_finance_g1_g4_permissions;
DELETE rp FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
UPDATE baiyue_iam.iam_permission SET status='INACTIVE',deleted=1
WHERE tenant_id=0 AND app_code='PLATFORM'
AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN (
'platform:finance',
'platform:finance:bill:view', 'platform:finance:bill:create',
'platform:finance:bill:create',
'platform:finance:bill:update', 'platform:finance:bill:issue',
'platform:finance:bill:adjust', 'platform:finance:bill:void',
'platform:finance:receipt:view', 'platform:finance:receipt:create',
@@ -14025,42 +14197,31 @@ ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN (
'platform:finance:account:view', 'platform:finance:fee-source:view',
'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement',
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
'platform:finance:reconciliation:void', 'platform:finance:settlement-ar:create',
'platform:finance:settlement-ap:create', 'platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view', 'platform:finance:payment:create')
'platform:finance:settlement-ap:create', 'platform:finance:payment:create',
'platform:finance:settlement:void')
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission
WHERE tenant_id=10001 AND app_code='MERCHANT' AND (permission_code='merchant:finance:statement' OR permission_code LIKE 'merchant:finance:%')
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
-- Finance 权限和角色授权断言。
DELIMITER $$
CREATE PROCEDURE assert_finance_permissions()
BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 26 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view') AND status='ACTIVE' AND deleted=0) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 27 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整';
END IF;
END$$
DELIMITER ;
CALL assert_finance_permissions();
DROP PROCEDURE IF EXISTS assert_finance_permissions;
-- 开发期 list-only 收口:先移除角色关联并停用旧只读资源,再执行最终 Finance 断言。
DELETE rp FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view',
'platform:finance:fee-source:view','platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view');
UPDATE baiyue_iam.iam_permission SET status='INACTIVE', deleted=1
WHERE tenant_id=0 AND app_code='PLATFORM'
AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view',
'platform:finance:fee-source:view','platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view');
CALL assert_unified_final_schema();
DROP PROCEDURE IF EXISTS assert_unified_final_schema;
CALL assert_loading_pick_task_shapes(1);
DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes;
@@ -14236,6 +14397,18 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_container_transport_history (
PRIMARY KEY(id),KEY idx_container_transport_history(container_booking_id,created_at,id)
) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计';
ALTER TABLE baiyue_firstleg.fl_container_transport_history
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '运输字段变更历史ID',
MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定柜次ID',
MODIFY COLUMN before_container_no VARCHAR(64) NULL COMMENT '变更前真实集装箱号',
MODIFY COLUMN after_container_no VARCHAR(64) NULL COMMENT '变更后真实集装箱号',
MODIFY COLUMN before_voyage_no VARCHAR(64) NULL COMMENT '变更前航次号',
MODIFY COLUMN after_voyage_no VARCHAR(64) NULL COMMENT '变更后航次号',
MODIFY COLUMN reason VARCHAR(500) NULL COMMENT '运输字段变更原因',
MODIFY COLUMN operator_id BIGINT UNSIGNED NOT NULL COMMENT '变更操作人ID',
MODIFY COLUMN operator_name VARCHAR(64) NULL COMMENT '变更操作人名称快照',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '变更记录创建时间';
/* IAM 消息中心基线。来源:iam/src/main/resources/db/notification-center.sql */
DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_permission_ids;
DELIMITER $$
@@ -14356,26 +14529,23 @@ BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM 消息中心唯一索引不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0
AND (permission_code LIKE 'platform:notification%' OR permission_code='merchant:notification:view')) <> 11 THEN
AND (permission_code LIKE 'platform:notification%' OR permission_code='merchant:notification:view')) <> 13 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM 消息中心权限不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code LIKE 'platform:notification%') <> 10 THEN
WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code LIKE 'platform:notification%') <> 12 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员消息中心授权不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE r.role_code='MERCHANT_OWNER' AND p.permission_code='merchant:notification:view') <> 1 THEN
WHERE r.role_code='MERCHANT_OWNER' AND p.permission_code IN ('merchant:notification:view','merchant:notification:read','merchant:notification:read-all')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:企业管理员消息查看授权不完整';
END IF;
END$$
DELIMITER ;
CALL baiyue_iam.assert_notification_center_schema();
DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_center_schema;
-- 货柜清关与装柜清单报关采用同一份轻量文件事实,不再要求独立报关 case 状态机。
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column;
@@ -14428,7 +14598,72 @@ BEGIN
END$$
DELIMITER ;
-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。
-- 二、三期入库完成后的唯一状态为 WAIT_SPLIT。
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
id BIGINT UNSIGNED NOT NULL COMMENT '异常历史ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '稳定货柜预订ID',
action VARCHAR(16) NOT NULL COMMENT 'MARK标记异常/CANCEL取消异常',
reason VARCHAR(500) NOT NULL COMMENT '标记或取消原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间',
PRIMARY KEY (id), UNIQUE KEY uk_container_exception_operation (container_booking_id, operator_id, operation_id),
KEY idx_container_exception_history (container_booking_id, occurred_at, id),
CONSTRAINT fk_container_exception_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_container_exception_action CHECK (action IN ('MARK','CANCEL'))
) ENGINE=InnoDB COMMENT='货柜异常标记及取消原因历史';
DROP PROCEDURE IF EXISTS phase3_container_exception_add_column;
DELIMITER $$
CREATE PROCEDURE phase3_container_exception_add_column(IN p_name VARCHAR(64),IN p_definition TEXT)
BEGIN
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_exception_history' AND column_name=p_name) THEN
SET @ddl=CONCAT('ALTER TABLE fl_container_exception_history ADD COLUMN ',p_name,' ',p_definition);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL phase3_container_exception_add_column('operation_id',"VARCHAR(128) NULL COMMENT '同一操作人幂等键' AFTER operator_name");
CALL phase3_container_exception_add_column('request_hash',"CHAR(64) NULL COMMENT '规范化请求摘要' AFTER operation_id");
DROP PROCEDURE phase3_container_exception_add_column;
UPDATE fl_container_exception_history SET operation_id=CONCAT('LEGACY-',id) WHERE operation_id IS NULL OR operation_id='';
UPDATE fl_container_exception_history SET request_hash=SHA2(CONCAT(action,'|',container_booking_id,'|',id,'|',reason),256) WHERE request_hash IS NULL OR request_hash='';
ALTER TABLE fl_container_exception_history
MODIFY operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键',
MODIFY request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要';
SET @has_exception_operation_index=(SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_exception_history' AND index_name='uk_container_exception_operation');
SET @ddl=IF(@has_exception_operation_index=0,'ALTER TABLE fl_container_exception_history ADD UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id)','SELECT 1');
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
DROP TRIGGER IF EXISTS trg_container_exception_history_before_update;
DROP TRIGGER IF EXISTS trg_container_exception_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_container_exception_history_before_update BEFORE UPDATE ON fl_container_exception_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可修改'; END$$
CREATE TRIGGER trg_container_exception_history_before_delete BEFORE DELETE ON fl_container_exception_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可删除'; END$$
DELIMITER ;
-- Admin 原型菜单与财务五页权限补充(来源:iam/src/main/resources/db/admin-prototype-menu-permissions.sql)。
USE baiyue_iam;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5105040,0,'PLATFORM','MENU',5105000,'platform:finance:customer-account:list','客户账户','/finance/customer-accounts','GET',10,'ACTIVE',1,0),
(5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0),
(5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0),
(5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0),
(5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:overseas-storage-fee:create','platform:overseas-storage-fee:delete');
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
UPDATE iam_permission SET status='INACTIVE', deleted=1 WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ('platform:finance:bill:view','platform:finance:account:view','platform:finance:fee-source:view','platform:finance:settlement-ar:view','platform:finance:settlement-ap:view');
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list');
UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:settlement',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON';
INSERT INTO baiyue_iam.iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES
@@ -14451,6 +14686,282 @@ ALTER TABLE baiyue_order.ord_order_cargo
MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克);草稿可空,提交前必须大于0',
MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米);草稿可空,提交前必须大于0';
-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。
-- 平台公共供应商业务档案及权限(来源:warehouse-supplier.sql / platform-supplier-permissions.sql)。
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_supplier (
id BIGINT UNSIGNED NOT NULL COMMENT '供应商ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
name VARCHAR(160) NOT NULL COMMENT '供应商名称', category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证',
scope VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL', region VARCHAR(80) NOT NULL COMMENT '地区',
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表', contact_name VARCHAR(80) NOT NULL COMMENT '联系人',
phone VARCHAR(40) NOT NULL COMMENT '联系电话', email VARCHAR(160) NULL COMMENT '电子邮箱', address VARCHAR(255) NULL COMMENT '公司地址',
business_license_no VARCHAR(64) NULL COMMENT '营业执照号', license_file_id VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、INACTIVE停用', settlement_period VARCHAR(32) NOT NULL COMMENT '结算周期', remark VARCHAR(500) NULL COMMENT '备注',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
UNIQUE KEY uk_wh_supplier_name(tenant_id,name), KEY idx_wh_supplier_filter(tenant_id,category,status,region),
CONSTRAINT chk_wh_supplier_category CHECK(category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
CONSTRAINT chk_wh_supplier_scope CHECK((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)),
CONSTRAINT chk_wh_supplier_status CHECK(status IN ('ACTIVE','INACTIVE')),
CONSTRAINT chk_wh_supplier_settlement CHECK(settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案';
DROP PROCEDURE IF EXISTS supplier_add_column_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_column_if_missing(IN p_name VARCHAR(64),IN p_definition TEXT)
BEGIN
IF NOT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND column_name=p_name) THEN
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD COLUMN ',p_name,' ',p_definition); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
END IF;
END$$
DELIMITER ;
CALL supplier_add_column_if_missing('scope',"VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL' AFTER category");
CALL supplier_add_column_if_missing('license_file_id',"VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no");
CALL supplier_add_column_if_missing('created_by',"BIGINT UNSIGNED NULL COMMENT '创建人ID'");
CALL supplier_add_column_if_missing('created_at',"DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'");
CALL supplier_add_column_if_missing('updated_by',"BIGINT UNSIGNED NULL COMMENT '最后修改人ID'");
CALL supplier_add_column_if_missing('updated_at',"DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间'");
CALL supplier_add_column_if_missing('deleted',"TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'");
CALL supplier_add_column_if_missing('version',"INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'");
DROP PROCEDURE supplier_add_column_if_missing;
SET @supplier_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='uk_wh_supplier_name'),'SELECT 1','ALTER TABLE wh_supplier ADD UNIQUE KEY uk_wh_supplier_name(tenant_id,name)'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
SET @supplier_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter(tenant_id,category,status,region)'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
DROP PROCEDURE IF EXISTS supplier_add_check_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_check_if_missing(IN p_name VARCHAR(64),IN p_expression TEXT)
BEGIN
IF NOT EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_name=p_name AND constraint_type='CHECK') THEN
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD CONSTRAINT ',p_name,' CHECK(',p_expression,')'); PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
END IF;
END$$
DELIMITER ;
CALL supplier_add_check_if_missing('chk_wh_supplier_category',"category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')");
CALL supplier_add_check_if_missing('chk_wh_supplier_scope',"(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)");
CALL supplier_add_check_if_missing('chk_wh_supplier_status',"status IN ('ACTIVE','INACTIVE')");
CALL supplier_add_check_if_missing('chk_wh_supplier_settlement',"settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')");
DROP PROCEDURE supplier_add_check_if_missing;
USE baiyue_iam;
DROP PROCEDURE IF EXISTS guard_platform_supplier_permission_ids;
DELIMITER $$
CREATE PROCEDURE guard_platform_supplier_permission_ids()
BEGIN
IF EXISTS(SELECT 1 FROM iam_permission WHERE id BETWEEN 5103080 AND 5103083 AND permission_code<>CASE id WHEN 5103080 THEN 'platform:supplier:list' WHEN 5103081 THEN 'platform:supplier:create' WHEN 5103082 THEN 'platform:supplier:edit' WHEN 5103083 THEN 'platform:supplier:status' END)
OR EXISTS(SELECT 1 FROM iam_permission WHERE permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status') AND id NOT BETWEEN 5103080 AND 5103083) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限固定ID或权限码已被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL guard_platform_supplier_permission_ids(); DROP PROCEDURE guard_platform_supplier_permission_ids;
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) VALUES
(5103080,0,'PLATFORM','MENU',5103000,'platform:supplier:list','供应商列表','/suppliers','GET',80,'ACTIVE',1,0),
(5103081,0,'PLATFORM','BUTTON',5103080,'platform:supplier:create','新增供应商',NULL,'POST',10,'ACTIVE',1,0),
(5103082,0,'PLATFORM','BUTTON',5103080,'platform:supplier:edit','编辑供应商',NULL,'PUT',20,'ACTIVE',1,0),
(5103083,0,'PLATFORM','BUTTON',5103080,'platform:supplier:status','启停供应商',NULL,'PATCH',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type),parent_id=VALUES(parent_id),permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status');
DROP PROCEDURE IF EXISTS assert_platform_supplier_permissions;
DELIMITER $$
CREATE PROCEDURE assert_platform_supplier_permissions()
BEGIN
IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_supplier WHERE tenant_id<>0)<>0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商存在非平台作用域数据'; END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter'))<>3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表索引不完整'; END IF;
IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 AND permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限资源初始化不完整'; END IF;
IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PLATFORM_ADMIN供应商权限授权不完整'; END IF;
END$$
DELIMITER ;
CALL assert_platform_supplier_permissions(); DROP PROCEDURE assert_platform_supplier_permissions;
-- 国内仓整柜入库、出库、库存和不可变交易流水。
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_domestic_container_order (
id BIGINT UNSIGNED NOT NULL COMMENT '整柜订单ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', business_type VARCHAR(16) NOT NULL COMMENT '业务类型:INBOUND入库、OUTBOUND出库',
order_no VARCHAR(40) NOT NULL COMMENT '业务单号', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '平台国内仓ID', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称',
container_no VARCHAR(32) NOT NULL COMMENT '柜号', cargo_name VARCHAR(128) NOT NULL COMMENT '货物名称', box_count INT UNSIGNED NOT NULL COMMENT '箱数',
volume_cbm DECIMAL(16,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', operation_time DATETIME(3) NOT NULL COMMENT '操作时间', status VARCHAR(16) NOT NULL COMMENT '状态:PENDING待处理、COMPLETED已完成',
remark VARCHAR(500) NULL COMMENT '备注', created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED 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_dc_order_no(tenant_id,order_no,deleted),
KEY idx_dc_order_list(tenant_id,business_type,warehouse_id,status,operation_time),
KEY idx_dc_order_container(tenant_id,warehouse_id,container_no,business_type),
CONSTRAINT chk_dc_order_scope CHECK(tenant_id=0), CONSTRAINT chk_dc_order_type CHECK(business_type IN('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_order_status CHECK(status IN('PENDING','COMPLETED')), CONSTRAINT chk_dc_order_quantity CHECK(box_count>0 AND volume_cbm>=0)
) ENGINE=InnoDB COMMENT='国内仓独立整柜入出库单';
ALTER TABLE wh_domestic_container_order
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '整柜订单ID';
CREATE TABLE IF NOT EXISTS wh_domestic_container_inventory (
id BIGINT UNSIGNED NOT NULL COMMENT '库存ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '平台国内仓ID',
warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', container_no VARCHAR(32) NOT NULL COMMENT '柜号', cargo_name VARCHAR(128) NOT NULL COMMENT '货物名称',
inbound_boxes INT UNSIGNED NOT NULL COMMENT '入库箱数', outbound_boxes INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '出库箱数', available_boxes INT UNSIGNED NOT NULL COMMENT '可用箱数',
inbound_volume_cbm DECIMAL(16,4) NOT NULL COMMENT '入库体积(立方米)', inventory_status VARCHAR(24) NOT NULL COMMENT '库存状态:IN_STOCK在库、PARTIALLY_OUTBOUND部分出库、OUTBOUND已出库',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED 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_dc_inventory_container(tenant_id,warehouse_id,container_no,deleted),
KEY idx_dc_inventory_list(tenant_id,warehouse_id,inventory_status,updated_at),
CONSTRAINT chk_dc_inventory_scope CHECK(tenant_id=0),
CONSTRAINT chk_dc_inventory_balance CHECK(inbound_boxes=outbound_boxes+available_boxes),
CONSTRAINT chk_dc_inventory_status CHECK(inventory_status IN('IN_STOCK','PARTIALLY_OUTBOUND','OUTBOUND'))
) ENGINE=InnoDB COMMENT='平台国内仓整柜库存台账';
CREATE TABLE IF NOT EXISTS wh_domestic_container_transaction (
id BIGINT UNSIGNED NOT NULL COMMENT '流水ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', inventory_id BIGINT UNSIGNED NOT NULL COMMENT '库存台账ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '平台国内仓ID', container_no VARCHAR(32) NOT NULL COMMENT '柜号', business_type VARCHAR(16) NOT NULL COMMENT '业务类型:INBOUND入库、OUTBOUND出库',
order_id BIGINT UNSIGNED NOT NULL COMMENT '业务单ID', order_no VARCHAR(40) NOT NULL COMMENT '业务单号', quantity INT UNSIGNED NOT NULL COMMENT '变动箱数',
operation_time DATETIME(3) NOT NULL COMMENT '操作时间', remark VARCHAR(500) NULL COMMENT '备注', created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_by BIGINT UNSIGNED 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_dc_transaction_order(tenant_id,business_type,order_id),
KEY idx_dc_transaction_detail(tenant_id,inventory_id,operation_time),
CONSTRAINT chk_dc_transaction_scope CHECK(tenant_id=0), CONSTRAINT chk_dc_transaction_type CHECK(business_type IN('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_transaction_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='国内仓整柜不可变进销存交易流水';
CREATE TABLE IF NOT EXISTS wh_domestic_container_idempotency (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', operation_type VARCHAR(16) NOT NULL COMMENT '操作类型:INBOUND入库、OUTBOUND出库',
idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求哈希', resource_id BIGINT UNSIGNED NULL COMMENT '业务资源ID',
resource_no VARCHAR(40) NULL COMMENT '业务资源编号', operation_status VARCHAR(16) NOT NULL COMMENT '状态:PROCESSING处理中、COMPLETED已完成',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_dc_idempotency(tenant_id,operation_type,idempotency_key),
CONSTRAINT chk_dc_idempotency_scope CHECK(tenant_id=0), CONSTRAINT chk_dc_idempotency_type CHECK(operation_type IN('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_idempotency_status CHECK(operation_status IN('PROCESSING','COMPLETED'))
) ENGINE=InnoDB COMMENT='国内仓整柜写操作幂等记录';
USE baiyue_iam;
DROP PROCEDURE IF EXISTS migrate_domestic_container_permissions;
DELIMITER $$
CREATE PROCEDURE migrate_domestic_container_permissions()
BEGIN
IF EXISTS(SELECT 1 FROM iam_permission WHERE (id=5103070 AND permission_code<>'platform:domestic-container:list') OR (id=5103071 AND permission_code<>'platform:domestic-container:inbound:create') OR (id=5103072 AND permission_code<>'platform:domestic-container:outbound:create') OR (permission_code='platform:domestic-container:list' AND id<>5103070) OR (permission_code='platform:domestic-container:inbound:create' AND id<>5103071) OR (permission_code='platform:domestic-container:outbound:create' AND id<>5103072)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='domestic container permission id/code conflict';
END IF;
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) VALUES
(5103070,0,'PLATFORM','MENU',5103000,'platform:domestic-container:list','整柜业务','/domestic-containers','GET',70,'ACTIVE',1,0),
(5103071,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:inbound:create','新建整柜入库单','/api/platform/v1/domestic-containers/inbounds','POST',10,'ACTIVE',1,0),
(5103072,0,'PLATFORM','BUTTON',5103070,'platform:domestic-container:outbound:create','新建整柜出库单','/api/platform/v1/domestic-containers/outbounds','POST',20,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type),parent_id=VALUES(parent_id),permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='PLATFORM'
WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code IN('platform:domestic-container:list','platform:domestic-container:inbound:create','platform:domestic-container:outbound:create');
IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 AND permission_code IN('platform:domestic-container:list','platform:domestic-container:inbound:create','platform:domestic-container:outbound:create'))<>3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='domestic container permission catalog is incomplete'; END IF;
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_warehouse' AND table_name IN('wh_domestic_container_order','wh_domestic_container_inventory','wh_domestic_container_transaction','wh_domestic_container_idempotency'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='domestic container tables are incomplete'; END IF;
END$$
DELIMITER ;
CALL migrate_domestic_container_permissions(); DROP PROCEDURE migrate_domestic_container_permissions;
-- 消息页面菜单与已读操作权限分离。
USE baiyue_iam;
UPDATE iam_permission SET permission_type='MENU',parent_id=5106000,permission_name='模板管理',route_path='/notifications/templates',http_method='GET',status='ACTIVE',deleted=0 WHERE id=5106003 AND permission_code='platform:notification:template:list';
UPDATE iam_permission SET permission_type='MENU',parent_id=5106000,permission_name='消息管理',route_path='/notifications/messages',http_method='GET',status='ACTIVE',deleted=0 WHERE id=5106007 AND permission_code='platform:notification:task:list';
INSERT INTO iam_permission(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) VALUES
(5106011,0,'PLATFORM','BUTTON',5106001,'platform:notification:read','标记消息已读','/api/platform/v1/notifications/{id}/read','PUT',610,'ACTIVE',1,0),
(5106012,0,'PLATFORM','BUTTON',5106001,'platform:notification:read-all','全部标记已读','/api/platform/v1/notifications/read-all','PUT',611,'ACTIVE',1,0),
(5106013,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read','标记消息已读','/api/merchant/v1/notifications/{id}/read','PUT',901,'ACTIVE',1,0),
(5106014,0,'MERCHANT','BUTTON',5106010,'merchant:notification:read-all','全部标记已读','/api/merchant/v1/notifications/read-all','PUT',902,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type),parent_id=VALUES(parent_id),permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(tenant_id,role_id,permission_id,created_at)
SELECT 0,r.id,p.id,CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0
WHERE r.tenant_id=0 AND r.status='ACTIVE' AND r.deleted=0
AND ((r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:notification:read','platform:notification:read-all'))
OR (r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND p.permission_code IN('merchant:notification:read','merchant:notification:read-all')));
CALL baiyue_iam.assert_notification_center_schema();
DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_center_schema;
-- 开发期不保留独立详情权限:列表权限是唯一只读权限。
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
WHERE p.permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:supplier:view');
UPDATE iam_permission SET status='INACTIVE',deleted=1
WHERE permission_code IN('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view','platform:supplier:view');
-- 入库完成后的唯一状态为 WAIT_SPLIT。
-- Finance 权限和角色授权断言必须位于五个财务列表菜单及其授权写入之后,保证整份脚本可重复执行。
DROP PROCEDURE IF EXISTS assert_finance_permissions;
DELIMITER $$
CREATE PROCEDURE assert_finance_permissions()
BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view') AND status='ACTIVE' AND deleted=0) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 只读权限不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 28 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 只读授权不完整';
END IF;
END$$
DELIMITER ;
CALL assert_finance_permissions();
DROP PROCEDURE IF EXISTS assert_finance_permissions;
-- IAM 跨区安全事件可靠发送箱。来源:iam/src/main/resources/db/security-event-outbox.sql
USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_security_subject_version (
auth_user_id BIGINT NOT NULL COMMENT '认证账号ID',
permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (auth_user_id)
) ENGINE=InnoDB COMMENT='安全主体权限版本';
CREATE TABLE IF NOT EXISTS iam_security_tenant_version (
tenant_id BIGINT NOT NULL COMMENT '客户ID',
status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (tenant_id)
) ENGINE=InnoDB COMMENT='客户状态事件版本';
CREATE TABLE IF NOT EXISTS iam_security_event_outbox (
event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型',
aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION',
aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识',
payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD',
attempt_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL,
last_error VARCHAR(1000) NULL,
sent_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (event_id),
KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at),
KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at)
) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱';
DROP PROCEDURE IF EXISTS assert_iam_security_event_schema;
DELIMITER $$
CREATE PROCEDURE assert_iam_security_event_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_iam'
AND table_name IN ('iam_security_subject_version','iam_security_tenant_version','iam_security_event_outbox')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件表不完整';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema='baiyue_iam' AND table_name='iam_security_event_outbox'
AND index_name IN ('PRIMARY','idx_security_outbox_publish','idx_security_outbox_aggregate')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件索引不完整';
END IF;
END$$
DELIMITER ;
CALL assert_iam_security_event_schema();
DROP PROCEDURE IF EXISTS assert_iam_security_event_schema;
USE baiyue_firstleg;
-- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。
ALTER TABLE fl_inbound_outbox
MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
ALTER TABLE fl_inbound_outbox
MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法';
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
+3 -3
View File
@@ -54,7 +54,7 @@
| `PROCESSING` | 履约处理中。 |
| `COMPLETED` | 订单完成。 |
统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`INBOUNDED`、`LOADING`、`LOADED`、`DELIVERED`。
统一订单列表的主状态是展示分组,不等于订单表的内部状态:`PENDING_REVIEW`、`WAIT_MEASURE`、`WAIT_SPLIT`、`LOADING`、`LOADED`、`DELIVERED`。
## 仓库与库位
@@ -89,13 +89,13 @@
| `WAIT_PRICING` | 测量通过或异常放行,等待计费。 |
| `FEE_CONFIRMING` | 已生成费用,等待费用确认和发送。 |
| `INBOUNDING` | 费用已确认,正在占用托盘、库位并完成入库。 |
| `INBOUNDED` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 |
| `WAIT_SPLIT` | 入库完成,可进入智能装柜或预装确认;费用状态独立维护。 |
| `LOADING` | 已进入装柜作业,PDA 可执行拣货。 |
| `LOADED` | 装柜完成,等待派送完成。 |
| `DELIVERED` | 已完成派送。 |
| `VOIDED` | 已作废,不允许继续处理。 |
主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `INBOUNDED` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。
主链路为:`WAIT_MEASURE` -> `MEASURING` -> `MEASURE_PENDING` -> `WAIT_PRICING` -> `FEE_CONFIRMING` -> `INBOUNDING` -> `WAIT_SPLIT` -> `LOADING` -> `LOADED` -> `DELIVERED`。测量驳回走 `MEASURE_PENDING` -> `MEASURE_REJECTED`;异常放行后回到 `WAIT_PRICING`。
### 测量、重量和费用状态
@@ -35,7 +35,9 @@ public class EventInboxService {
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
}
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash());
boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash());
if (!currentHash && !legacyHash) {
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
}
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
@@ -102,7 +102,7 @@ public class PlatformQualificationFileController {
}
@GetMapping("/{tenantId}/{fileName}")
@PreAuthorize("hasAuthority('platform:customer:view')")
@PreAuthorize("hasAuthority('platform:customer:list')")
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable String fileName) {
@@ -20,7 +20,9 @@ public class InternalBusinessFileService {
"CUSTOMS_RECEIPT",
"FINANCE_VOUCHER",
"CONTAINER_CLEARANCE_DOCUMENT",
"LOADING_CUSTOMS_DOCUMENT");
"LOADING_CUSTOMS_DOCUMENT",
"CONTAINER_DELIVERY_ORDER",
"CONTAINER_PICKUP_ORDER");
@Resource private FileObjectMapper files;
@Resource private BusinessFileStorage storage;
@@ -82,11 +82,11 @@ class QualificationFileContractTest {
}
@Test
void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception {
void platformQualificationPreviewUsesTheCustomerListPermission() throws Exception {
var method = PlatformQualificationFileController.class.getDeclaredMethod(
"download", AuthenticatedUser.class, long.class, String.class);
assertEquals("hasAuthority('platform:customer:view')", method.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value());
}
@Test
@@ -46,6 +46,8 @@ class InternalBusinessFileServiceTest {
var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file);
var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file);
var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file);
var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file);
assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
@@ -53,7 +55,9 @@ class InternalBusinessFileServiceTest {
assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51");
verify(storage).storeLoading(0L, 41L, file);
assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER");
assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER");
verify(storage, times(3)).storeLoading(0L, 41L, file);
verify(storage).storeLoading(0L, 51L, file);
}
+1
View File
@@ -15,6 +15,7 @@
| 一期账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
| 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` |
| Firstleg 费用来源事件入口 | `controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java` |
| 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` |
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
@@ -9,9 +9,11 @@ import java.util.List;
@FeignClient(name="firstleg-service", contextId="financeFirstlegCostSnapshotClient", url="${baiyue.clients.firstleg.url:}", path="/internal/firstleg/v1/finance-cost-snapshots")
public interface FirstlegCostSnapshotClient {
@GetMapping("/customers/{customerId}/containers/{containerId}")
ApiResponse<ContainerSnapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long customerId, @PathVariable long containerId);
record ContainerSnapshot(long containerId, long customerId, List<OrderVolume> orders) {}
@GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}")
ApiResponse<ContainerSnapshot> loadingPlan(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long customerId,
@PathVariable long loadingPlanId);
record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId,
List<OrderVolume> orders) {}
record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
}
@@ -27,7 +27,7 @@ public class FinancePhase1OperationController {
public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; }
@GetMapping("/accounts")
@PreAuthorize("hasAuthority('platform:finance:account:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword,
@@ -36,7 +36,7 @@ public class FinancePhase1OperationController {
}
@GetMapping("/accounts/{customerId}/ledger")
@PreAuthorize("hasAuthority('platform:finance:account:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public Object listDerivedCustomerAccountLedger(@PathVariable long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@@ -47,7 +47,7 @@ public class FinancePhase1OperationController {
}
@GetMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:fee-source:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId));
}
@@ -8,6 +8,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetailEnvelope;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementListEnvelope;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceSettlementService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -40,7 +41,7 @@ public class FinanceSettlementController {
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:view')")
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:list')")
@Operation(summary = "查询正式应收结算单")
public SettlementListEnvelope receivableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -49,7 +50,7 @@ public class FinanceSettlementController {
}
@GetMapping("/payable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ap:view')")
@PreAuthorize("hasAuthority('platform:finance:settlement-ap:list')")
@Operation(summary = "查询正式应付结算单")
public SettlementListEnvelope payableSettlements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -76,13 +77,14 @@ public class FinanceSettlementController {
}
@GetMapping("/settlements/{settlementId}")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:view','platform:finance:settlement-ap:view')")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:list','platform:finance:settlement-ap:list')")
public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long settlementId) {
SettlementDetail detail = service.getSettlement(settlementId);
String permission = "RECEIVABLE".equals(detail.summary().direction())
? "platform:finance:settlement-ar:view" : "platform:finance:settlement-ap:view";
if (user == null || !user.permissions().contains(permission)) {
String direction = detail.summary().direction();
String listPermission = "RECEIVABLE".equals(direction)
? "platform:finance:settlement-ar:list" : "platform:finance:settlement-ap:list";
if (user == null || !user.permissions().contains(listPermission)) {
throw new AccessDeniedException("无权查看该方向结算单");
}
return success(detail);
@@ -98,6 +100,16 @@ public class FinanceSettlementController {
return success(service.createSettlementPayment(settlementId, user.userId(), idempotencyKey, request));
}
@PostMapping("/settlements/{settlementId}/actions")
@PreAuthorize("hasAuthority('platform:finance:settlement:void')")
public SettlementDetailEnvelope actionSettlement(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long settlementId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody SettlementActionRequest request) {
return success(service.actionSettlement(settlementId, user.userId(), idempotencyKey, request));
}
private static SettlementDetailEnvelope success(SettlementDetail detail) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
@@ -45,14 +45,21 @@ public class PlatformFinanceController {
}
@GetMapping("/customer-accounts/{customerId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
@Operation(summary = "客户人工应收账户及流水")
public ApiResponse<?> customerAccount(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccount(customerId));
}
@GetMapping("/customer-accounts/{customerId}/summary")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
@Operation(summary = "客户正式应收应付汇总")
public ApiResponse<?> customerAccountSummary(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccountSummary(customerId));
}
@GetMapping("/fee-pool")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
@Operation(summary = "客户应收应付费用池")
public ApiResponse<?> feePool(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -62,7 +69,7 @@ public class PlatformFinanceController {
}
@GetMapping("/statements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
@Operation(summary = "客户对账单")
public ApiResponse<?> statements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@@ -87,7 +94,7 @@ public class PlatformFinanceController {
public ApiResponse<?> terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); }
@GetMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
@Operation(summary = "查询平台账单")
public ApiResponse<?> bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); }
@@ -115,11 +122,11 @@ public class PlatformFinanceController {
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@GetMapping("/bills/{billId}/revisions")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) {
return FinanceResponses.success(queryService.billRevisions(customerId, billId));
}
@@ -168,12 +175,20 @@ public class PlatformFinanceController {
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
if ("CONTAINER_SHARED".equals(request.costType())) {
if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次");
var response = costSnapshotClient.container(fileToken, request.customerId(), request.containerId());
var response = costSnapshotClient.loadingPlan(fileToken, request.customerId(), request.containerId());
var snapshot = response == null ? null : response.data();
if (snapshot == null || snapshot.customerId() != request.customerId() || snapshot.containerId() != request.containerId() || snapshot.orders() == null || snapshot.orders().isEmpty()) {
if (response == null || !response.successful()) {
throw new IllegalArgumentException(response == null || response.message() == null
? "Firstleg柜内订单体积快照不可用" : response.message());
}
if (snapshot == null || snapshot.customerId() != request.customerId()
|| snapshot.loadingPlanId() != request.containerId()
|| snapshot.containerBookingId() <= 0
|| snapshot.orders() == null || snapshot.orders().isEmpty()) {
throw new IllegalArgumentException("Firstleg未返回有效柜内订单体积快照");
}
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), v.inboundId(), snapshot.containerId(), snapshot.customerId(), v.volumeCbm())).toList();
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(),
v.inboundId(), snapshot.loadingPlanId(), snapshot.customerId(), v.volumeCbm())).toList();
}
return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key)));
}
@@ -36,7 +36,7 @@ public class ReconciliationStatementController {
}
@GetMapping("/reconciliation-statements/{statementId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<ReconciliationDetail> getReconciliationStatement(@PathVariable long statementId) {
return FinanceResponses.success(service.detailForPlatform(statementId));
}
@@ -3,6 +3,7 @@ package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
/** Stable read projections used by the Finance pages. */
@@ -18,6 +19,17 @@ public final class FinanceReadModels {
BigDecimal outstandingAmount,
List<AccountEntry> entries) {}
public record CustomerAccountSummary(
long customerId,
String currency,
BigDecimal totalReceivable,
BigDecimal receivedAmount,
BigDecimal outstandingReceivable,
BigDecimal pendingBilling,
BigDecimal outstandingPayable,
Instant asOf,
LocalDateTime lastChangedAt) {}
public record AccountEntry(
String entryType,
long resourceId,
@@ -56,7 +68,11 @@ public final class FinanceReadModels {
BigDecimal outstandingAmount,
String currency,
String status,
int version) {}
int version,
long createdBy,
LocalDateTime createdAt,
long updatedBy,
LocalDateTime updatedAt) {}
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
@@ -18,6 +18,7 @@ public final class FinanceSettlementDtos {
public record CreateSettlementPaymentRequest(LocalDate transactionDate, BigDecimal amount, String currency,
String method, String referenceNo, List<String> voucherFileIds,
Integer expectedVersion, String remark) {}
public record SettlementActionRequest(String action, String reason, Integer expectedVersion) {}
public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
@@ -23,7 +23,8 @@ public final class ReconciliationStatementDtos {
int creditDays, String currency, BigDecimal totalReceivable,
BigDecimal receivedAmount, BigDecimal outstandingAmount,
boolean settled, String status, int version,
LocalDateTime createdAt, LocalDateTime updatedAt, String remark,
long createdBy, LocalDateTime createdAt,
long updatedBy, LocalDateTime updatedAt, String remark,
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
List<ReconciliationRevision> revisions) {}
@@ -7,6 +7,7 @@ import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.dto.FinanceReadModels.BillRevision;
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccount;
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccountSummary;
import com.baiyue.finance.dto.FinanceReadModels.CostAllocation;
import com.baiyue.finance.dto.FinanceReadModels.CostDetail;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
@@ -17,6 +18,7 @@ import com.baiyue.finance.dto.FinanceReadModels.Statement;
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -26,6 +28,7 @@ import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -58,6 +61,34 @@ public class FinanceQueryService {
return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries));
}
public CustomerAccountSummary customerAccountSummary(long customerId) {
requireCustomer(customerId);
String sql = "SELECT " +
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.total_amount ELSE 0 END),0) total_receivable," +
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.paid_amount ELSE 0 END),0) received_amount," +
"COALESCE(SUM(CASE WHEN s.direction='RECEIVABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_receivable," +
"COALESCE(SUM(CASE WHEN s.direction='PAYABLE' AND s.status<>'VOIDED' THEN s.outstanding_amount ELSE 0 END),0) outstanding_payable," +
"(SELECT COALESCE(SUM(f.amount),0) FROM fin_fee_source f WHERE f.customer_id=? AND f.direction='RECEIVABLE' " +
"AND f.currency_code='CNY' AND f.status='AVAILABLE' " +
"AND f.review_status='APPROVED' AND f.settlement_included=1 " +
"AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id " +
"WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND b.status<>'VOIDED')) pending_billing," +
"GREATEST(COALESCE(MAX(CASE WHEN s.status<>'VOIDED' THEN s.updated_at END),'1970-01-01')," +
"COALESCE((SELECT MAX(f.updated_at) FROM fin_fee_source f WHERE f.customer_id=?),'1970-01-01')) last_changed_at " +
"FROM fin_settlement s WHERE s.tenant_id=?";
try {
return jdbc.queryForObject(sql, (rs, rowNum) -> new CustomerAccountSummary(customerId, CNY,
money(rs, "total_receivable"), money(rs, "received_amount"), money(rs, "outstanding_receivable"),
money(rs, "pending_billing"), money(rs, "outstanding_payable"), Instant.now(), time(rs, "last_changed_at")),
customerId, customerId, customerId);
} catch (BadSqlGrammarException ignored) {
// Empty/partially migrated Finance databases may not have a settlement projection yet.
// The read-only account summary must still be usable as a zero CNY baseline.
return new CustomerAccountSummary(customerId, CNY, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, Instant.now(), null);
}
}
public List<FeePoolItem> feePool(Long customerId, String direction) {
validateOptionalCustomer(customerId);
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
@@ -93,22 +124,24 @@ public class FinanceQueryService {
public List<Statement> statements(Long customerId, boolean clientVisibleOnly) {
validateOptionalCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE 1=1";
String sql = "SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE 1=1";
Object[] args = new Object[0];
if (customerId != null) { sql += " AND tenant_id=?"; args = new Object[]{customerId}; }
return jdbc.query(sql + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), args);
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), args);
}
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version,created_by,created_at,updated_by,updated_at FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), customerId, billId);
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version"),
rs.getLong("created_by"), time(rs, "created_at"), rs.getLong("updated_by"), time(rs, "updated_at")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
List<StatementLine> lines = jdbc.query("SELECT id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",
(rs, rowNum) -> new StatementLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_id"), rs.getInt("source_version"),
@@ -198,6 +231,11 @@ public class FinanceQueryService {
return value == null ? BigDecimal.ZERO : value;
}
private static BigDecimal money(ResultSet rs, String column) throws SQLException {
BigDecimal value = rs.getBigDecimal(column);
return value == null ? BigDecimal.ZERO : value;
}
private static Long nullableLong(ResultSet rs, String column) throws SQLException {
long value = rs.getLong(column);
return rs.wasNull() ? null : value;
@@ -4,14 +4,12 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baiyue.finance.client.FinanceFileClient;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
@@ -26,14 +24,12 @@ import java.util.Map;
public class FinanceService {
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
@Autowired(required = false)
private FinanceFileClient fileClient;
@org.springframework.beans.factory.annotation.Value("${baiyue.security.internal-service-token:}")
private String fileToken;
private final FinanceVoucherValidator voucherValidator;
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
public FinanceService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
}
public Map<String, Object> customerTerms(long tenantId) {
@@ -203,20 +199,8 @@ public class FinanceService {
}
private void validateVoucherFiles(long tenantId, List<String> fileIds) {
if (fileIds == null || fileIds.isEmpty()) throw bad("财务凭证不能为空");
if (fileClient == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "File服务校验能力不可用");
for (String raw : fileIds) {
long id; try { id = Long.parseLong(raw); } catch (Exception ex) { throw bad("凭证文件ID无效"); }
var response = fileClient.metadata(fileToken, tenantId, tenantId, id);
var metadata = response == null ? null : response.data();
if (metadata == null || metadata.fileId() == null || !tenantIdEquals(metadata.tenantId(), tenantId)
|| !"FINANCE_VOUCHER".equals(metadata.businessType()) || metadata.contentType() == null
|| !metadata.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) {
throw bad("凭证文件不存在、归属不匹配或不是有效图片");
}
}
voucherValidator.validate(tenantId, fileIds, "财务凭证");
}
private static boolean tenantIdEquals(Long actual, long expected) { return actual != null && actual == expected; }
@Transactional
public Map<String, Object> createCost(long tenantId, long operatorId, CostRequest request, String idempotencyKey) {
@@ -1,6 +1,5 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
@@ -9,11 +8,11 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
@@ -46,15 +45,12 @@ public class FinanceSettlementService {
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceFileClient fileClient;
private final FinanceVoucherValidator voucherValidator;
@Value("${baiyue.security.internal-service-token:}")
private String fileToken;
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceFileClient fileClient) {
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.fileClient = fileClient;
this.voucherValidator = voucherValidator;
}
@Transactional
@@ -250,6 +246,43 @@ public class FinanceSettlementService {
return getSettlement(settlementId);
}
@Transactional
public SettlementDetail actionSettlement(long settlementId, long operatorId, String key,
SettlementActionRequest request) {
validateSettlementAction(request);
Map<String, Object> settlement = one(
"SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " +
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
long customerId = number(settlement, "tenant_id").longValue();
Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST",
"/api/platform/finance/v1/settlements/" + settlementId + "/actions", request);
if (replay != null) return getSettlement(replay);
if (number(settlement, "version").intValue() != request.expectedVersion()) {
throw error("FIN_409", "结算单版本冲突");
}
String currentStatus = text(settlement, "status");
String targetStatus;
if ("VOID".equals(request.action())) {
if (!"ISSUED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
throw error("FIN_409", "只有未发生收付款的生效结算单可以作废");
}
targetStatus = "VOIDED";
} else {
if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) {
throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开");
}
targetStatus = "ISSUED";
}
int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " +
"WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId,
request.expectedVersion());
if (changed != 1) throw error("FIN_409", "结算单版本冲突");
audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement),
settlementStateSnapshot(settlement, targetStatus), operatorId);
complete(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, settlementId);
return getSettlement(settlementId);
}
private long insertSettlement(long customerId, String no, String direction, String customerName,
Long supplierId, String supplierName, Long reconciliationId,
String businessPeriod, String currency, BigDecimal total,
@@ -308,29 +341,18 @@ public class FinanceSettlementService {
if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "付款备注不能超过500字");
}
private void validateVoucherFiles(long customerId, List<String> fileIds) {
if (fileClient == null || !StringUtils.hasText(fileToken)) throw error("FIN_503", "File服务校验能力不可用");
for (String raw : fileIds) {
long fileId;
try { fileId = Long.parseLong(raw); } catch (RuntimeException invalid) { throw error("FIN_400", "付款凭证文件ID无效"); }
FinanceFileClient.Metadata metadata;
try {
var response = fileClient.metadata(fileToken, customerId, customerId, fileId);
metadata = response == null ? null : response.data();
} catch (RuntimeException unavailable) {
throw error("FIN_503", "File服务校验不可用");
}
if (metadata == null || metadata.fileId() == null || metadata.fileId() != fileId
|| metadata.tenantId() == null || metadata.tenantId() != customerId
|| !"FINANCE_VOUCHER".equals(metadata.businessType())
|| !String.valueOf(customerId).equals(metadata.businessId())
|| !StringUtils.hasText(metadata.contentType())
|| !metadata.contentType().toLowerCase(Locale.ROOT).startsWith("image/")) {
throw error("FIN_400", "付款凭证不存在、客户归属不匹配或文件类型无效");
}
private void validateSettlementAction(SettlementActionRequest request) {
if (request == null || !Set.of("VOID", "REOPEN").contains(request.action())
|| !StringUtils.hasText(request.reason()) || request.reason().length() > 500
|| request.expectedVersion() == null || request.expectedVersion() < 0) {
throw error("FIN_400", "结算单操作、原因或版本无效");
}
}
private void validateVoucherFiles(long customerId, List<String> fileIds) {
voucherValidator.validate(customerId, fileIds, "付款凭证");
}
private Long reserve(long customerId, long operatorId, String operation, String key,
String method, String path, Object body) {
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN_400", "必须提供有效的Idempotency-Key");
@@ -435,6 +457,16 @@ public class FinanceSettlementService {
"outstandingAmount", totalAmount.subtract(paidAmount));
}
private static Map<String, Object> settlementStateSnapshot(Map<String, Object> settlement) {
return settlementStateSnapshot(settlement, text(settlement, "status"));
}
private static Map<String, Object> settlementStateSnapshot(Map<String, Object> settlement, String status) {
return Map.of("totalAmount", decimal(settlement, "total_amount"),
"paidAmount", decimal(settlement, "paid_amount"),
"outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status);
}
private String documentNo(String prefix) {
return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" +
UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
@@ -0,0 +1,56 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.client.FinanceFileClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Locale;
import java.util.HashSet;
@Component
public class FinanceVoucherValidator {
private final FinanceFileClient fileClient;
private final String fileToken;
@Autowired
public FinanceVoucherValidator(FinanceFileClient fileClient,
@Value("${baiyue.security.internal-service-token:}") String fileToken) {
this.fileClient = fileClient;
this.fileToken = fileToken;
}
public void validate(long customerId, List<String> fileIds, String message) {
if (fileIds == null || fileIds.isEmpty()) throw bad(message + "不能为空");
if (fileClient == null || !StringUtils.hasText(fileToken)) throw unavailable("File服务校验能力不可用");
if (new HashSet<>(fileIds).size() != fileIds.size()) throw bad(message + "文件ID不能重复");
for (String raw : fileIds) {
final long fileId;
try { fileId = Long.parseLong(raw); } catch (RuntimeException invalid) { throw bad(message + "文件ID无效"); }
FinanceFileClient.Metadata metadata;
try {
var response = fileClient.metadata(fileToken, customerId, customerId, fileId);
metadata = response == null ? null : response.data();
} catch (RuntimeException unavailable) {
throw unavailable("File服务校验不可用");
}
if (metadata == null || metadata.fileId() == null || metadata.fileId() != fileId
|| metadata.tenantId() == null || metadata.tenantId() != customerId
|| !String.valueOf(customerId).equals(metadata.businessId())
|| !"FINANCE_VOUCHER".equals(metadata.businessType())
|| !StringUtils.hasText(metadata.contentType())
|| !metadata.contentType().toLowerCase(Locale.ROOT).startsWith("image/")) {
throw bad(message + "不存在、归属不匹配或不是有效图片");
}
}
}
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException unavailable(String message) {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
}
}
@@ -169,8 +169,8 @@ public class ReconciliationStatementService {
(rs, row) -> revision(rs, row + 1), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(), bill.createdAt(),
bill.updatedAt(), bill.remark(), lines, payments, revisions);
bill.outstanding(), bill.outstanding().signum() == 0, reconciliationStatus(bill.status()), bill.version(),
bill.createdBy(), bill.createdAt(), bill.updatedBy(), bill.updatedAt(), bill.remark(), lines, payments, revisions);
}
public ReconciliationDetail detailForPlatform(long statementId) {
@@ -420,7 +420,8 @@ public class ReconciliationStatementService {
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
rs.getInt("credit_days"), rs.getString("remark"), rs.getTimestamp("created_at").toLocalDateTime(),
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
rs.getTimestamp("updated_at").toLocalDateTime());
}
@@ -461,5 +462,6 @@ public class ReconciliationStatementService {
String feeItemName, BigDecimal amount) {}
private record Bill(long id, long customerId, String number, String customerName, String status,
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
int creditDays, String remark, LocalDateTime createdAt, LocalDateTime updatedAt) {}
int creditDays, String remark, long createdBy, LocalDateTime createdAt,
long updatedBy, LocalDateTime updatedAt) {}
}
+81
View File
@@ -65,6 +65,13 @@ CREATE TABLE IF NOT EXISTS fin_subject (
UNIQUE KEY uk_fin_subject_code (subject_code)
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
INSERT INTO fin_subject
(subject_code, subject_name, status, version, created_by, updated_by)
VALUES
('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)
ON DUPLICATE KEY UPDATE
subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0;
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -290,3 +297,77 @@ CREATE TABLE IF NOT EXISTS fin_audit_log (
KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at),
KEY idx_fin_audit_operator (operator_id, created_at)
) ENGINE=InnoDB COMMENT='Finance不可变业务审计明细';
CREATE TABLE IF NOT EXISTS fin_settlement (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式应收或应付结算单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
settlement_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一结算单号',
direction VARCHAR(16) NOT NULL COMMENT '结算方向:RECEIVABLE应收、PAYABLE应付',
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '结算生成时客户名称快照',
supplier_id BIGINT UNSIGNED NULL COMMENT '应付结算供应商ID;应收结算为空',
supplier_name_snapshot VARCHAR(200) NULL COMMENT '应付结算供应商名称快照',
reconciliation_statement_id BIGINT UNSIGNED NULL COMMENT '应收结算关联的已出账客户对账单ID',
business_period VARCHAR(32) NULL COMMENT '结算业务期间快照',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
total_amount DECIMAL(20,6) NOT NULL COMMENT '正式结算总金额,精度6位小数',
paid_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '累计已收或已付金额,精度6位小数',
outstanding_amount DECIMAL(20,6) NOT NULL COMMENT '未结清金额,精度6位小数',
generated_date DATE NOT NULL COMMENT '结算单生成日期',
due_date DATE NOT NULL COMMENT '结算到期日期',
payment_status VARCHAR(24) NOT NULL DEFAULT 'UNPAID' COMMENT '收付款状态:UNPAID未收付、PARTIALLY_PAID部分收付、PAID已结清',
status VARCHAR(24) NOT NULL DEFAULT 'ISSUED' COMMENT '结算状态:ISSUED已生效、PARTIALLY_PAID部分收付、PAID已结清、VOIDED已作废',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_no (tenant_id, settlement_no),
UNIQUE KEY uk_fin_settlement_receivable_statement (tenant_id, reconciliation_statement_id, direction),
KEY idx_fin_settlement_customer_direction_status (tenant_id, direction, status),
KEY idx_fin_settlement_supplier_status (supplier_id, direction, status),
CONSTRAINT chk_fin_settlement_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
CONSTRAINT chk_fin_settlement_amounts CHECK (total_amount >= 0 AND paid_amount >= 0 AND outstanding_amount >= 0 AND total_amount = paid_amount + outstanding_amount)
) ENGINE=InnoDB COMMENT='正式应收与应付结算单;客户账户金额由本表派生';
CREATE TABLE IF NOT EXISTS fin_settlement_line (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
fee_source_id BIGINT UNSIGNED NOT NULL COMMENT '结算固化的Finance费用来源ID',
amount DECIMAL(20,6) NOT NULL COMMENT '结算明细金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_line_source (settlement_id, fee_source_id),
KEY idx_fin_settlement_line_customer (tenant_id, settlement_id),
KEY idx_fin_settlement_line_source (fee_source_id),
CONSTRAINT chk_fin_settlement_line_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_line_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_line_amount CHECK (amount > 0)
) ENGINE=InnoDB COMMENT='正式结算单费用来源金额快照明细';
CREATE TABLE IF NOT EXISTS fin_settlement_payment (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '正式结算单收付款记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
settlement_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式结算单ID',
transaction_type VARCHAR(16) NOT NULL COMMENT '交易类型:RECEIPT收款、PAYMENT付款',
transaction_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收付款业务号',
transaction_date DATE NOT NULL COMMENT '实际收款或付款日期',
amount DECIMAL(20,6) NOT NULL COMMENT '本次收款或付款金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
method VARCHAR(32) NOT NULL COMMENT '人工登记的收付款方式',
reference_no VARCHAR(128) NULL COMMENT '银行流水号或外部参考号',
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组',
remark VARCHAR(500) NULL COMMENT '收付款内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '登记操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_settlement_payment_no (tenant_id, transaction_no),
KEY idx_fin_settlement_payment_settlement (tenant_id, settlement_id, created_at),
CONSTRAINT chk_fin_settlement_payment_tenant CHECK (tenant_id > 0),
CONSTRAINT chk_fin_settlement_payment_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_settlement_payment_amount CHECK (amount > 0)
) ENGINE=InnoDB COMMENT='正式结算单人工收付款与凭证记录';
@@ -0,0 +1,120 @@
package com.baiyue.finance;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccountSummary;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.FinanceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.security.access.prepost.PreAuthorize;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.springframework.jdbc.BadSqlGrammarException;
class CustomerAccountSummaryBehaviorTest {
@Test
void aggregatesFormalSettlementsInOneCustomerScopedQuery() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("300.00", "140.00", "160.00", "25.00", "80.00");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
CustomerAccountSummary result = service.customerAccountSummary(9L);
assertThat(result.customerId()).isEqualTo(9L);
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.totalReceivable()).isEqualByComparingTo("300.00");
assertThat(result.receivedAmount()).isEqualByComparingTo("140.00");
assertThat(result.outstandingReceivable()).isEqualByComparingTo("160.00");
assertThat(result.pendingBilling()).isEqualByComparingTo("25.00");
assertThat(result.outstandingPayable()).isEqualByComparingTo("80.00");
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).queryForObject(sql.capture(), any(RowMapper.class),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(9L));
assertThat(sql.getValue())
.contains("FROM fin_settlement s WHERE s.tenant_id=?")
.contains("s.direction='RECEIVABLE' AND s.status<>'VOIDED'")
.contains("s.direction='PAYABLE' AND s.status<>'VOIDED'")
.contains("f.customer_id=?", "f.status='AVAILABLE'", "f.review_status='APPROVED'", "f.settlement_included=1")
.contains("b.status<>'VOIDED'")
.doesNotContain("JSON_EXTRACT")
.doesNotContain("SUM(received_amount)", "FROM fin_receipt r");
}
@Test
void emptyCustomerReturnsZeroAmounts() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
ResultSet rs = summaryRow("0", "0", "0", "0", "0");
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any())).thenAnswer(invocation ->
((RowMapper<?>) invocation.getArgument(1)).mapRow(rs, 0));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(77L);
assertThat(result.totalReceivable()).isZero();
assertThat(result.receivedAmount()).isZero();
assertThat(result.outstandingReceivable()).isZero();
assertThat(result.pendingBilling()).isZero();
assertThat(result.outstandingPayable()).isZero();
}
@Test
void missingSettlementProjectionReturnsZeroCnyBaseline() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(anyString(), any(RowMapper.class), any(), any(), any()))
.thenThrow(new BadSqlGrammarException("customer summary", "SELECT ...", null));
CustomerAccountSummary result = new FinanceQueryService(jdbc, new ObjectMapper()).customerAccountSummary(88L);
assertThat(result.customerId()).isEqualTo(88L);
assertThat(result.currency()).isEqualTo("CNY");
assertThat(result.totalReceivable()).isZero();
assertThat(result.receivedAmount()).isZero();
assertThat(result.outstandingReceivable()).isZero();
assertThat(result.pendingBilling()).isZero();
assertThat(result.outstandingPayable()).isZero();
assertThat(result.lastChangedAt()).isNull();
}
@Test
void controllerUsesListPermissionAndDelegatesSummary() throws Exception {
FinanceQueryService query = mock(FinanceQueryService.class);
PlatformFinanceController controller = new PlatformFinanceController(mock(FinanceService.class),
mock(FinanceFileClient.class), mock(FirstlegCostSnapshotClient.class), query);
controller.customerAccountSummary(11L);
verify(query).customerAccountSummary(11L);
PreAuthorize permission = PlatformFinanceController.class
.getMethod("customerAccountSummary", long.class).getAnnotation(PreAuthorize.class);
assertThat(permission.value()).isEqualTo("hasAuthority('platform:finance:customer-account:list')");
}
private static ResultSet summaryRow(String total, String paid, String outstanding,
String pending, String payable) throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getBigDecimal("total_receivable")).thenReturn(new BigDecimal(total));
when(rs.getBigDecimal("received_amount")).thenReturn(new BigDecimal(paid));
when(rs.getBigDecimal("outstanding_receivable")).thenReturn(new BigDecimal(outstanding));
when(rs.getBigDecimal("pending_billing")).thenReturn(new BigDecimal(pending));
when(rs.getBigDecimal("outstanding_payable")).thenReturn(new BigDecimal(payable));
when(rs.getTimestamp("last_changed_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 8, 13, 12, 0)));
return rs;
}
}
@@ -26,7 +26,7 @@ class FinanceContractTest {
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java"));
assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile"));
assertTrue(controller.contains("costSnapshotClient.container") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
assertTrue(operations.contains("bl.source_id=f.id"));
@@ -15,18 +15,20 @@ class FinancePhase1PermissionContractTest {
@Test
void freezesG1AndG2ControllerPermissionsAndHttpActions() throws Exception {
assertEndpoint("listDerivedCustomerAccounts", GetMapping.class,
"hasAuthority('platform:finance:account:view')", int.class, int.class, String.class, String.class);
"platform:finance:customer-account:list",
int.class, int.class, String.class, String.class);
assertEndpoint("listDerivedCustomerAccountLedger", GetMapping.class,
"hasAuthority('platform:finance:account:view')", long.class, int.class, int.class,
"platform:finance:customer-account:list",
long.class, int.class, int.class,
String.class, java.time.LocalDate.class, java.time.LocalDate.class);
assertEndpoint("getFinanceFeeSource", GetMapping.class,
"hasAuthority('platform:finance:fee-source:view')", long.class);
"platform:finance:customer-arap:list", long.class);
assertEndpoint("reviewFinanceFeeSource", PostMapping.class,
"hasAuthority('platform:finance:fee-source:review')",
"platform:finance:fee-source:review",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest.class);
assertEndpoint("updateFeeSourceSettlementInclusion", PutMapping.class,
"hasAuthority('platform:finance:fee-source:settlement')",
"platform:finance:fee-source:settlement",
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class,
com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest.class);
}
@@ -37,6 +39,7 @@ class FinancePhase1PermissionContractTest {
Class<?>... parameterTypes) throws Exception {
Method method = FinancePhase1OperationController.class.getMethod(methodName, parameterTypes);
assertThat(method.isAnnotationPresent(mapping)).isTrue();
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(authority);
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains(authority);
assertThat(method.getAnnotation(PreAuthorize.class).value()).doesNotContain(":view");
}
}
@@ -2,6 +2,8 @@ package com.baiyue.finance;
import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.controller.FinanceSettlementController;
import com.baiyue.finance.controller.FinancePhase1OperationController;
import com.baiyue.finance.controller.ReconciliationStatementController;
import io.swagger.v3.oas.annotations.Parameter;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -28,12 +30,37 @@ class FinancePlatformListContractTest {
@Test
void platformListPermissionsRemainSeparatedByReceivableAndPayableData() throws Exception {
Map<String, String> expected = Map.of(
"feePool", "hasAuthority('platform:finance:bill:view')",
"statements", "hasAuthority('platform:finance:bill:view')",
"receivableSettlements", "hasAuthority('platform:finance:settlement-ar:view')",
"payableSettlements", "hasAuthority('platform:finance:settlement-ap:view')");
"feePool", "platform:finance:customer-arap:list",
"statements", "platform:finance:customer-statement:list",
"receivableSettlements", "platform:finance:settlement-ar:list",
"payableSettlements", "platform:finance:settlement-ap:list");
for (var entry : expected.entrySet()) {
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value()).isEqualTo(entry.getValue());
assertThat(find(entry.getKey()).getAnnotation(PreAuthorize.class).value())
.contains(entry.getValue())
.doesNotContain(":view");
}
}
@Test
void platformReadEndpointsDoNotAcceptLegacyViewAuthorities() {
String[] legacyAuthorities = {
"platform:finance:account:view",
"platform:finance:fee-source:view",
"platform:finance:bill:view",
"platform:finance:settlement-ar:view",
"platform:finance:settlement-ap:view"
};
for (Class<?> controller : new Class<?>[]{
PlatformFinanceController.class,
FinancePhase1OperationController.class,
ReconciliationStatementController.class,
FinanceSettlementController.class
}) {
for (Method method : controller.getDeclaredMethods()) {
PreAuthorize permission = method.getAnnotation(PreAuthorize.class);
if (permission == null) continue;
assertThat(permission.value()).doesNotContain(legacyAuthorities);
}
}
}
@@ -1,6 +1,7 @@
package com.baiyue.finance;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
@@ -18,7 +19,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import org.springframework.test.util.ReflectionTestUtils;
class FinanceServiceValidationTest {
@Test
@@ -49,7 +49,7 @@ class FinanceServiceValidationTest {
@Test
void rejectsUnsupportedCostTypeBeforeWrite() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
FinanceService service = service(jdbc);
var request = new FinanceService.CostRequest("C-1", "TICKET", 1L, null, null, null, null, "MANUAL", "拖车", BigDecimal.ONE, List.of("f"), null);
assertThrows(RuntimeException.class, () -> service.createCost(1L, 2L, request, "k1"));
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), (Object[]) any());
@@ -58,7 +58,7 @@ class FinanceServiceValidationTest {
@Test
void rejectsNonPositiveAllocation() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
FinanceService service = service(jdbc);
var request = new FinanceService.ReceiptRequest("R-1", 1L, null, "BANK_TRANSFER", null, List.of("f"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ZERO)));
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k2"));
@@ -67,7 +67,7 @@ class FinanceServiceValidationTest {
@Test
void rejectsDuplicateBillAllocationInsteadOfMerging() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
FinanceService service = service(jdbc);
var request = new FinanceService.ReceiptRequest("R-2", 1L, null, "BANK_TRANSFER", null, List.of("f"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE), new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3"));
@@ -76,7 +76,8 @@ class FinanceServiceValidationTest {
@Test
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
FinanceService service = new FinanceService(jdbc, new ObjectMapper(),
new FinanceVoucherValidator(null, ""));
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
@@ -92,8 +93,7 @@ class FinanceServiceValidationTest {
FinanceFileClient files = mock(FinanceFileClient.class);
when(files.metadata(anyString(), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "v.jpg", "sha", "image/jpeg", 10L)));
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(service, "fileClient", files);
FinanceService service = service(jdbc, files);
var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
assertThrows(RuntimeException.class, () -> service.createReceipt(9L, 2L, request, "k4"));
@@ -117,9 +117,7 @@ class FinanceServiceValidationTest {
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
return 1;
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(service, "fileClient", files);
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
FinanceService service = service(jdbc, files);
var request = new FinanceService.ReceiptRequest("R-4", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
"BANK_TRANSFER", null, List.of("77"), "首笔收款",
List.of(new FinanceService.ReceiptAllocation(50L, new BigDecimal("40.00"))));
@@ -154,9 +152,7 @@ class FinanceServiceValidationTest {
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
return 1;
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
FinanceService service = new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(service, "fileClient", files);
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
FinanceService service = service(jdbc, files);
var request = new FinanceService.ReceiptRequest("R-300", 1L, java.time.LocalDateTime.of(2026, 8, 11, 10, 0),
"BANK_TRANSFER", "BANK-300", List.of("77"), "三单收款", List.of(
new FinanceService.ReceiptAllocation(50L, new BigDecimal("100.00")),
@@ -176,7 +172,7 @@ class FinanceServiceValidationTest {
void rejectsSingleOrderWhenOrderNotOwnedByCustomer() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Number.class), any(), any())).thenReturn(0);
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
FinanceService service = service(jdbc);
var request = new FinanceService.CostRequest("C-2", "SINGLE_ORDER", 99L, null, null, null, null, "MANUAL", "货拉拉", BigDecimal.ONE, List.of("1"), null);
assertThrows(RuntimeException.class, () -> service.createCost(9L, 2L, request, "k5"));
}
@@ -198,9 +194,7 @@ class FinanceServiceValidationTest {
holder.getKeyList().add(java.util.Map.of("GENERATED_KEY", 701L));
return 1;
}).when(jdbc).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class), any(org.springframework.jdbc.support.KeyHolder.class));
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
ReflectionTestUtils.setField(service, "fileClient", files);
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
FinanceService service = service(jdbc, files);
var request = new FinanceService.CostRequest("C-3", "CONTAINER_SHARED", null, null, 88L,
null, null, "MANUAL", "整柜成本", new BigDecimal("300.00"), List.of("77"), null);
var snapshot = List.of(new FinanceService.ContainerOrderVolume(
@@ -221,7 +215,7 @@ class FinanceServiceValidationTest {
.thenReturn(1);
var rows = List.<java.util.Map<String, Object>>of(java.util.Map.of("version", 2));
when(jdbc.queryForList(startsWith("SELECT id,bill_id,version"), eq(9L), eq(50L))).thenReturn(rows);
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
FinanceService service = service(jdbc);
assertEquals(rows, service.billRevisions(9L, 50L));
}
@@ -231,9 +225,19 @@ class FinanceServiceValidationTest {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill"), eq(Number.class), eq(50L), eq(9L)))
.thenReturn(0);
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
FinanceService service = service(jdbc);
assertThrows(RuntimeException.class, () -> service.billRevisions(9L, 50L));
verify(jdbc, never()).queryForList(startsWith("SELECT id,bill_id,version"), (Object[]) any());
}
private static FinanceService service(JdbcTemplate jdbc, FinanceFileClient files) {
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(files, "internal-token"));
}
private static FinanceService service(JdbcTemplate jdbc) {
return new FinanceService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceVoucherValidator.class));
}
}
@@ -1,6 +1,8 @@
package com.baiyue.finance;
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.finance.client.FinanceFileClient;
import com.baiyue.finance.controller.FinanceSettlementController;
@@ -9,8 +11,10 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRe
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceSettlementService;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -53,7 +57,7 @@ class FinanceSettlementBehaviorTest {
FinanceSettlementController controller = new FinanceSettlementController(controllerService);
SettlementDetail payable = detail("PAYABLE", "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 0);
when(controllerService.getSettlement(88L)).thenReturn(payable);
AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:view"));
AuthenticatedUser apViewer = user(Set.of("platform:finance:settlement-ap:list"));
var response = controller.getSettlement(apViewer, 88L);
@@ -61,7 +65,7 @@ class FinanceSettlementBehaviorTest {
assertThat(response.data()).isSameAs(payable);
assertThat(response.requestId()).isNotBlank();
assertThatThrownBy(() -> controller.getSettlement(
user(Set.of("platform:finance:settlement-ar:view")), 88L)).isInstanceOf(AccessDeniedException.class);
user(Set.of("platform:finance:settlement-ar:list")), 88L)).isInstanceOf(AccessDeniedException.class);
}
@Test
@@ -135,8 +139,11 @@ class FinanceSettlementBehaviorTest {
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenThrow(new IllegalStateException("down"));
FinanceSettlementService service = service(jdbc, fileClient);
assertCode(() -> service.createSettlementPayment(88L, 7L, "pay-key",
payment(new BigDecimal("40.00"), 0)), "FIN_503");
assertThatThrownBy(() -> service.createSettlementPayment(88L, 7L, "pay-key",
payment(new BigDecimal("40.00"), 0)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET paid_amount"), (Object[]) any());
@@ -161,6 +168,55 @@ class FinanceSettlementBehaviorTest {
verify(jdbc, never()).update(startsWith("INSERT INTO fin_settlement_payment"), (Object[]) any());
}
@Test
void unpaidSettlementCanBeVoidedAndReopenedWithAuditAndVersionCheck() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
Map<String, Object> issued = settlementRow("PAYABLE", "ISSUED", BigDecimal.ZERO,
new BigDecimal("100.00"), 2);
Map<String, Object> voided = settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO,
new BigDecimal("100.00"), 3);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(issued));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_STATE_ACTION"), eq("void-key"))).thenReturn(List.of());
stubSettlementDetail(jdbc, voided);
FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
SettlementDetail result = service.actionSettlement(88L, 7L, "void-key",
new SettlementActionRequest("VOID", "业务取消", 2));
assertThat(result.summary().status()).isEqualTo("VOIDED");
verify(jdbc).update(startsWith("UPDATE fin_settlement SET status="), eq("VOIDED"), eq(7L),
eq(88L), eq(10L), eq(2));
verify(jdbc).update(startsWith("INSERT INTO fin_audit_log"), eq(10L), eq("FIN_SETTLEMENT"),
eq(88L), eq("VOID"), eq("业务取消"), any(), any(), eq(7L), any(), any(), any());
}
@Test
void settlementActionRejectsPaidInvalidStateAndStaleVersion() {
JdbcTemplate paidJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(paidJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"),
new BigDecimal("60.00"), 2)));
when(paidJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_STATE_ACTION"), anyString())).thenReturn(List.of());
FinanceSettlementService paidService = service(paidJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> paidService.actionSettlement(88L, 7L, "paid-key",
new SettlementActionRequest("VOID", "不能作废", 2)), "FIN_409");
JdbcTemplate staleJdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
when(staleJdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L)))
.thenReturn(List.of(settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO,
new BigDecimal("100.00"), 3)));
when(staleJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_STATE_ACTION"), eq("stale-action"))).thenReturn(List.of());
FinanceSettlementService staleService = service(staleJdbc, org.mockito.Mockito.mock(FinanceFileClient.class));
assertCode(() -> staleService.actionSettlement(88L, 7L, "stale-action",
new SettlementActionRequest("REOPEN", "恢复结算", 2)), "FIN_409");
verify(staleJdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any());
}
@Test
void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
@@ -466,10 +522,8 @@ class FinanceSettlementBehaviorTest {
}
private static FinanceSettlementService service(JdbcTemplate jdbc, FinanceFileClient fileClient) {
FinanceSettlementService service = new FinanceSettlementService(
jdbc, new ObjectMapper().findAndRegisterModules(), fileClient);
ReflectionTestUtils.setField(service, "fileToken", "internal-token");
return service;
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"));
}
private static CreateSettlementPaymentRequest payment(BigDecimal amount, int version) {
@@ -0,0 +1,37 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSettlementSchemaContractTest {
@Test
void moduleSchemaCreatesFormalSettlementTablesAndSummaryIndex() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (")
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (")
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (")
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
.contains("total_amount DECIMAL(20,6)", "paid_amount DECIMAL(20,6)", "outstanding_amount DECIMAL(20,6)")
.contains("updated_at DATETIME(3)");
assertThat(unified)
.contains("CREATE TABLE IF NOT EXISTS fin_settlement (")
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_line (")
.contains("CREATE TABLE IF NOT EXISTS fin_settlement_payment (")
.contains("idx_fin_settlement_customer_direction_status (tenant_id, direction, status)")
.contains("'fin_settlement', 'fin_settlement_line', 'fin_settlement_payment')) <> 16");
}
private static Path resolveUnifiedSql() {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
}
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(13);
assertThat(columnCount).isEqualTo(190);
assertThat(tableCount).isEqualTo(16);
assertThat(columnCount).isEqualTo(233);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -0,0 +1,25 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSubjectSeedContractTest {
@Test
void moduleAndUnifiedSqlSeedOneActivePlatformSubjectIdempotently() throws Exception {
String module = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
for (String sql : new String[] {module, unified}) {
assertThat(sql)
.contains("INSERT INTO fin_subject")
.contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)")
.contains("ON DUPLICATE KEY UPDATE")
.contains("status = 'ACTIVE'");
}
}
}
@@ -0,0 +1,87 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.service.FinanceVoucherValidator;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceVoucherValidatorTest {
@Test
void acceptsCustomerOwnedFinanceImage() {
FinanceFileClient files = mock(FinanceFileClient.class);
when(files.metadata("token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L)));
new FinanceVoucherValidator(files, "token").validate(9L, List.of("77"), "财务凭证");
verify(files).metadata("token", 9L, 9L, 77L);
}
@Test
void rejectsMismatchedFileIdentityOwnershipTypeAndMime() {
FinanceFileClient files = mock(FinanceFileClient.class);
List<FinanceFileClient.Metadata> invalid = List.of(
new FinanceFileClient.Metadata(78L, 9L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L),
new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L),
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "10", "receipt.jpg", "sha", "image/jpeg", 10L),
new FinanceFileClient.Metadata(77L, 9L, "OTHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L),
new FinanceFileClient.Metadata(77L, 9L, "FINANCE_VOUCHER", "9", "receipt.pdf", "sha", "application/pdf", 10L));
for (FinanceFileClient.Metadata metadata : invalid) {
when(files.metadata(eq("token"), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success(metadata));
assertThatThrownBy(() -> new FinanceVoucherValidator(files, "token")
.validate(9L, List.of("77"), "付款凭证"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.BAD_REQUEST);
}
}
@Test
void failsClosedWhenFileDependencyUnavailable() {
FinanceFileClient files = mock(FinanceFileClient.class);
when(files.metadata("token", 9L, 9L, 77L)).thenThrow(new IllegalStateException("down"));
assertThatThrownBy(() -> new FinanceVoucherValidator(files, "token")
.validate(9L, List.of("77"), "付款凭证"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
}
@Test
void rejectsMissingInvalidAndDuplicateFileIds() {
FinanceVoucherValidator validator = new FinanceVoucherValidator(mock(FinanceFileClient.class), "token");
assertResult(() -> validator.validate(9L, List.of(), "财务凭证"), ResultCode.BAD_REQUEST);
assertResult(() -> validator.validate(9L, List.of("invalid"), "财务凭证"), ResultCode.BAD_REQUEST);
assertResult(() -> validator.validate(9L, List.of("77", "77"), "付款凭证"), ResultCode.BAD_REQUEST);
}
@Test
void rejectsWrongFileIdAndCustomerOwnership() {
FinanceFileClient files = mock(FinanceFileClient.class);
when(files.metadata("token", 9L, 9L, 77L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(78L, 10L, "FINANCE_VOUCHER", "9", "receipt.jpg", "sha", "image/jpeg", 10L)));
assertResult(() -> new FinanceVoucherValidator(files, "token")
.validate(9L, List.of("77"), "付款凭证"), ResultCode.BAD_REQUEST);
}
@Test
void rejectsMissingToken() {
assertResult(() -> new FinanceVoucherValidator(mock(FinanceFileClient.class), "")
.validate(9L, List.of("77"), "财务凭证"), ResultCode.SERVICE_UNAVAILABLE);
}
private static void assertResult(org.assertj.core.api.ThrowableAssert.ThrowingCallable call,
ResultCode resultCode) {
assertThatThrownBy(call).isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(resultCode);
}
}
@@ -14,6 +14,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -45,15 +46,15 @@ class PlatformFinanceControllerBehaviorTest {
var request = new PlatformFinanceController.CostRequest(
10L, "CONTAINER_SHARED", null, 88L, "清关费", new BigDecimal("300.00"),
"CNY", List.of("91"), "整柜成本");
var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of(
var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of(
new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, new BigDecimal("1.25")),
new FirstlegCostSnapshotClient.OrderVolume(102L, 1002L, new BigDecimal("2.75"))));
when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot));
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot));
when(service.createCost(eq(10L), eq(7L), any(), any(), eq("cost-key"))).thenReturn(Map.of("id", 1L));
controller.cost(user, "cost-key", request);
verify(snapshotClient).container("internal-token", 10L, 88L);
verify(snapshotClient).loadingPlan("internal-token", 10L, 88L);
verify(service).createCost(eq(10L), eq(7L), any(), eq(List.of(
new FinanceService.ContainerOrderVolume(101L, 1001L, 88L, 10L, new BigDecimal("1.25")),
new FinanceService.ContainerOrderVolume(102L, 1002L, 88L, 10L, new BigDecimal("2.75")))), eq("cost-key"));
@@ -64,8 +65,35 @@ class PlatformFinanceControllerBehaviorTest {
var request = new PlatformFinanceController.CostRequest(
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
"CNY", List.of("91"), null);
when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(
new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of())));
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(
new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of())));
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
.hasMessageContaining("未返回有效柜内订单体积快照");
verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any());
}
@Test
void containerCostPreservesFirstlegBusinessFailure() {
var request = new PlatformFinanceController.CostRequest(
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
"CNY", List.of("91"), null);
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(
new ApiResponse<>("A0404", "柜次不存在", null, "request", "trace", Instant.now()));
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
.hasMessage("柜次不存在");
verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any());
}
@Test
void containerCostRejectsMismatchedLoadingPlanIdentity() {
var request = new PlatformFinanceController.CostRequest(
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
"CNY", List.of("91"), null);
var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(89L, 99L, 10L, List.of(
new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, BigDecimal.ONE)));
when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot));
assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
.hasMessageContaining("未返回有效柜内订单体积快照");
+5 -3
View File
@@ -2,7 +2,7 @@
## 职责与边界
- 负责履约任务/分配,以及二期入库单、测量/OCR 结果、审核、FBA 计费、完成入库编排。
- 负责履约任务/分配、入库单、测量/OCR、审核、FBA 计费、完成入库编排、客户在途柜量统计及国内出仓履约链路。
- 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。
- 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。
@@ -13,11 +13,13 @@
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` |
| 箱唛箱码生成与版本化 | `repository/InboundCartonLabelRepository.java`、`service/InboundCartonLabelService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` |
| 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
@@ -30,7 +32,7 @@
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
| 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` |
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
## 修改约束
@@ -43,7 +45,7 @@
## 三期装柜与 PDA 资源结算
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;首次打印冻结箱码映射,重复打印和墨家测量复用同一箱码,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
+1
View File
@@ -6,6 +6,7 @@
<artifactId>firstleg-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
@@ -17,7 +17,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
"com.baiyue.firstleg.oceanbooking.mapper",
"com.baiyue.firstleg.outbound.dispatch.mapper",
"com.baiyue.firstleg.portschedule.mapper",
"com.baiyue.firstleg.customs.mapper"
"com.baiyue.firstleg.customs.mapper",
"com.baiyue.firstleg.customerstatistics.mapper"
})
@EnableFeignClients(basePackages = "com.baiyue.firstleg")
@EnableScheduling
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
/** 将本地业务 Outbox 投递到跨区事件桥。 */
@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}")
public interface EventBridgeClient {
@PostMapping("/internal/event-bridge/events")
ApiResponse<Acknowledgement> receive(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.ResponseEntity;
/** Validates that a referenced File object belongs to the exact loading business resource. */
@FeignClient(name = "file-service", contextId = "firstlegLoadingDocumentFileClient",
@@ -24,6 +25,10 @@ public interface LoadingDocumentFileClient {
@PathVariable long tenantId, @PathVariable String businessType,
@PathVariable String businessId, @PathVariable long fileId);
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.util.List;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
@FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient",
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes")
@@ -24,6 +25,12 @@ public interface OrderCargoClient {
@PathVariable long tenantId,
@PathVariable long orderId);
@GetMapping("/{tenantId}/{orderId}/inbound-snapshot")
ApiResponse<OrderApprovedInboundCommand> inboundSnapshot(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long orderId);
record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
@@ -62,6 +62,33 @@ public class InternalInboundOrderController {
return ApiResponse.success(service.createFromApprovedOrder(command));
}
@PostMapping("/order-preparing")
public ApiResponse<Long> prepareFromDraft(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody OrderApprovedInboundCommand command) {
requireToken(token);
requireSnapshot(command);
return ApiResponse.success(service.prepareFromDraft(command));
}
@PostMapping("/order-preparing/batch")
public ApiResponse<List<InboundOrderReference.Item>> prepareFromDraftBatch(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody List<OrderApprovedInboundCommand> commands) {
requireToken(token);
if (commands == null || commands.isEmpty() || commands.size() > 200) {
throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须为1至200条");
}
commands.forEach(this::requireSnapshot);
long tenantId = commands.get(0).tenantId();
if (commands.stream().anyMatch(command -> command.tenantId() != tenantId)) {
throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须属于同一客户");
}
commands.forEach(service::prepareFromDraft);
return ApiResponse.success(service.findReferences(tenantId,
commands.stream().map(OrderApprovedInboundCommand::orderId).distinct().toList()));
}
@PostMapping("/order-references")
public ApiResponse<List<InboundOrderReference.Item>> references(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -101,4 +128,17 @@ public class InternalInboundOrderController {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
private void requireSnapshot(OrderApprovedInboundCommand command) {
if (command == null || command.tenantId() <= 0 || command.orderId() <= 0
|| command.orderNo() == null || command.orderNo().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank()
|| command.inboundCode().length() > 40
|| command.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0
|| command.warehouseName() == null || command.warehouseName().isBlank()
|| command.businessType() == null || command.businessType().isBlank()) {
throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整");
}
}
}
@@ -29,7 +29,7 @@ public class MerchantInboundBillingController {
@GetMapping("/{orderId}/inbound-billing")
@PreAuthorize("hasAuthority('merchant:order:view')")
@Operation(summary = "查询订单入库结果和最新费用",
description = "客户只读查看已达到 INBOUNDED + APPROVED + CONFIRMED 的二期费用,不需要确认费用。")
description = "客户只读查看已达到 WAIT_SPLIT + APPROVED + CONFIRMED 的二期费用,不需要确认费用。")
public ApiResponse<MerchantInboundBillingResponse> find(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long orderId) {
return ApiResponse.success(service.find(FirstlegScopeGuard.merchant(user), orderId));
@@ -0,0 +1,67 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Min;
import org.springframework.http.ResponseEntity;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestBody;
import java.util.List;
/** 客户端下载本客户入库单箱唛。 */
@Tag(name = "客户端入库箱唛")
@Validated
@RestController
@RequestMapping("/api/merchant/v1/first-leg")
public class MerchantInboundCartonLabelController {
@Resource
private InboundCartonLabelPrintService cartonLabelPrintService;
@Operation(summary = "生成入仓箱唛 PDF")
@PostMapping("/inbounds/{inboundId}/carton-label-pdf")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> print(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) long inboundId,
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
return cartonLabelPrintService.generate(
FirstlegScopeGuard.merchant(user), inboundId, cartonIndex, requestId);
}
@Operation(summary = "按订单生成入仓箱唛 PDF")
@PostMapping("/orders/{orderId}/carton-label-pdf")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> printByOrder(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) long orderId,
@RequestParam(required = false) List<@Min(1) Integer> cartonIndex,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
return cartonLabelPrintService.generateByOrder(
FirstlegScopeGuard.merchant(user), orderId, cartonIndex, requestId);
}
@Operation(summary = "批量按订单生成箱唛 PDF")
@PostMapping("/orders/carton-label-pdf")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> printByOrders(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody List<Long> orderIds,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
return cartonLabelPrintService.generateByOrders(
FirstlegScopeGuard.merchant(user), orderIds, requestId);
}
}
@@ -15,6 +15,8 @@ import com.baiyue.firstleg.service.LoadingListService;
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.dto.ContainerOverviewDtos.*;
import com.baiyue.firstleg.service.ContainerOverviewService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -23,6 +25,10 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import java.nio.charset.StandardCharsets;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
@@ -34,16 +40,39 @@ import org.springframework.web.multipart.MultipartFile;
public class PlatformContainerController {
@Resource private ContainerService service;
@Resource private LoadingListService loadingLists;
@Resource private ContainerOverviewService overview;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query));
}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:view')")
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
}
@GetMapping("/{id}/overview") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id));
}
@PostMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ExceptionRequest request) {
FirstlegScopeGuard.platform(user); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
}
@DeleteMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<Void> cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ExceptionRequest request) {
FirstlegScopeGuard.platform(user); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
}
@GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
@PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestPart("expectedVersion") @Min(0) int version,@RequestPart(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
@GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.service.MeasurementReviewService;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import io.swagger.v3.oas.annotations.Operation;
@@ -35,6 +36,7 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/v1/inbounds")
public class PlatformInboundMeasurementReviewController {
@Resource private MeasurementReviewService service;
@Resource private AutomaticInboundCompletionService automaticCompletion;
@BusinessAudit(event = "INBOUND_MEASURE_APPROVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "测量审核通过")
@@ -50,10 +52,12 @@ public class PlatformInboundMeasurementReviewController {
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody MeasurementReviewRequest request) {
validateScope(user, tenantId);
// 审核服务事务在此调用返回时已提交;随后自动编排从新事务/新查询读取 WAIT_PRICING,
// 避免在未提交的审核事务中读取旧状态。自动流程失败时保留已审核状态,原样返回业务错误,
// 使用同一幂等键重试会从持久化阶段继续推进。
service.approve(tenantId, user.userId(), user.username(), inboundId, idempotencyKey, request);
// 统一订单主线要求测量审核后直接进入 INBOUNDED,不把计费、库位或其他资料
// 作为本版本的强制门禁。保留原 FeeSnapshotResponse 泛型以兼容既有客户端。
return ApiResponse.success(null);
return ApiResponse.success(automaticCompletion.completeApproved(
tenantId, inboundId, user.userId(), user.username(), request.measurementId(), request.expectedVersion() + 1));
}
@BusinessAudit(event = "INBOUND_MEASURE_REJECT", resourceType = "INBOUND",
@@ -58,7 +58,7 @@ public class PlatformLoadingListController {
@Operation(summary = "查询装柜详情", description = "装柜中可维护未拣货明细;完成装柜后永久只读。")
@GetMapping("/loading-plans/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:preload:view')")
@PreAuthorize("hasAuthority('platform:firstleg:preload:list')")
public ApiResponse<PreloadDetail> preloadDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
@@ -91,7 +91,7 @@ public class PlatformLoadingListController {
@Operation(summary = "查询完成装柜清单明细", description = "已完成主单和明细全部只读。")
@GetMapping("/loading-lists/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ApiResponse<ConfirmedDetail> confirmedDetail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
@@ -101,7 +101,7 @@ public class PlatformLoadingListController {
@Operation(summary = "查询装柜清单出口报关文档")
@GetMapping("/loading-lists/{planId}/customs")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ApiResponse<BusinessDocument> declaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
@@ -2,9 +2,9 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; 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.*; import java.time.LocalDateTime;
@Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<PageResult<TruckAppointmentEntity>> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
@PostMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> attach(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@Valid @RequestBody AttachmentRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attach(id,r,u.userId()));}
@PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam(required=false) String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));}
@DeleteMapping("/{id}/attachments/{attachmentId}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<Void> deleteAttachment(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@PathVariable @Positive long attachmentId){FirstlegScopeGuard.platform(u);service.deleteAttachment(id,attachmentId);return ApiResponse.success(null);}
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.customerstatistics.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Positive;
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.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/platform/v1/customers")
public class PlatformCustomerTransitStatisticsController {
@Resource private CustomerTransitStatisticsService service;
@GetMapping("/{customerId}/container-transit-summary")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<TransitSummary> summary(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long customerId) {
FirstlegScopeGuard.platform(user, customerId);
return ApiResponse.success(service.summarize(customerId));
}
}
@@ -0,0 +1,11 @@
package com.baiyue.firstleg.customerstatistics.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class CustomerTransitStatisticsDtos {
private CustomerTransitStatisticsDtos() {}
public record TransitSummary(Long customerId, long containerCount, BigDecimal totalTeu,
long unknownContainerCount, LocalDateTime asOf, String definition) {}
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.customerstatistics.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Mapper
public interface CustomerTransitStatisticsMapper {
@Select("""
SELECT #{customerId} AS customerId,
COUNT(*) AS containerCount,
COALESCE(SUM(teu), 0) AS totalTeu,
SUM(CASE WHEN teu IS NULL THEN 1 ELSE 0 END) AS unknownContainerCount,
CURRENT_TIMESTAMP(3) AS asOf
FROM (
SELECT DISTINCT x.customer_id, x.container_booking_id,
CASE
WHEN UPPER(TRIM(x.container_type)) LIKE '20%' THEN 1.0
WHEN UPPER(TRIM(x.container_type)) LIKE '40%' THEN 2.0
WHEN UPPER(TRIM(x.container_type)) LIKE '45%' THEN 2.25
ELSE NULL
END AS teu
FROM (
SELECT b.customer_id, c.id AS container_booking_id, c.container_type
FROM fl_container_booking c
JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id
WHERE c.actual_sailing_at IS NOT NULL
AND c.actual_warehouse_arrival_at IS NULL
AND b.status NOT IN ('CANCELLED', 'FAILED')
AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan p
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id
WHERE p.container_booking_id = c.id AND i.deleted = 0
)
UNION ALL
SELECT i.account_id AS customer_id, c.id AS container_booking_id, c.container_type
FROM fl_container_booking c
JOIN fl_ocean_booking b ON b.id = c.ocean_booking_id
JOIN fl_loading_plan p ON p.container_booking_id = c.id
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
WHERE c.actual_sailing_at IS NOT NULL
AND c.actual_warehouse_arrival_at IS NULL
AND b.status NOT IN ('CANCELLED', 'FAILED')
AND i.account_id IS NOT NULL AND i.account_id > 0
) x
WHERE x.customer_id = #{customerId}
) scoped
""")
SummaryRow summarize(@Param("customerId") long customerId);
record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu,
Long unknownContainerCount, LocalDateTime asOf) {}
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.customerstatistics.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class CustomerTransitStatisticsService {
private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。";
@Resource private CustomerTransitStatisticsMapper mapper;
public TransitSummary summarize(long customerId) {
if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数");
var row = mapper.summarize(customerId);
return new TransitSummary(customerId,
value(row == null ? null : row.containerCount()),
row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(),
value(row == null ? null : row.unknownContainerCount()),
row == null ? null : row.asOf(), DEFINITION);
}
private static long value(Long value) { return value == null ? 0L : value; }
}
@@ -10,8 +10,8 @@ public enum InboundListTab {
WAIT_REVIEW(List.of("MEASURE_PENDING")),
/** 待发送费用:等待计费、费用确认或正在完成入库。 */
WAIT_FEE_SEND(List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING")),
/** 已入库:费用已发送且入库完成。 */
INBOUNDED(List.of("INBOUNDED"));
/** 待分柜:入库和费用发布完成,等待形成有效分柜方案。 */
WAIT_SPLIT(List.of("WAIT_SPLIT"));
private final List<String> processStatuses;
@@ -6,6 +6,8 @@ import java.util.Set;
public final class InboundProcessStatus {
/** 等待录入或接收测量数据。 */
public static final String WAIT_MEASURE = "WAIT_MEASURE";
/** 草稿预入库:允许生成箱唛,但尚未进入正式测量流程。 */
public static final String PREPARING = "PREPARING";
/** 已存在测量草稿、重量 OCR 或人工修正,尚未提交审核。 */
public static final String MEASURING = "MEASURING";
/** 测量已提交,等待平台审核。 */
@@ -18,8 +20,6 @@ public final class InboundProcessStatus {
public static final String FEE_CONFIRMING = "FEE_CONFIRMING";
/** 费用已确认,正在执行托盘、库位占用和入库收口。 */
public static final String INBOUNDING = "INBOUNDING";
/** 历史已入库终态;存量数据仍允许进入分柜。 */
public static final String INBOUNDED = "INBOUNDED";
/** 入库和费用发布完成,等待分柜。 */
public static final String WAIT_SPLIT = "WAIT_SPLIT";
/** 已进入装柜作业,PDA 可执行拣货。 */
@@ -32,8 +32,8 @@ public final class InboundProcessStatus {
public static final String VOIDED = "VOIDED";
public static final Set<String> VALUES = Set.of(
WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, INBOUNDED, WAIT_SPLIT,
PREPARING, WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED,
WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, WAIT_SPLIT,
LOADING, LOADED, DELIVERED, VOIDED
);
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class ContainerOverviewDtos {
private ContainerOverviewDtos() {}
public record Overview(ContainerBookingEntityView container, Long loadingPlanId, PdaSummary pda,
List<Document> documents, ExceptionState exception, List<ExceptionHistory> exceptionHistory,
List<TimelineEntry> timeline) {}
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
Integer version) {}
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
List<PhotoCount> photoCounts, List<Photo> photos, List<String> exceptionReasons) {}
public record PhotoCount(String evidenceType, Integer count) {}
public record Photo(Long evidenceId, Long tenantId, Long inboundId, String evidenceType,
Long fileId, String fileName, String status, String downloadPath, LocalDateTime uploadedAt) {}
public record Document(Long id, String documentType, Long fileId, String fileName, String fileHash,
Long uploadedBy, String uploadedByName, LocalDateTime uploadedAt, Integer version, boolean completed) {}
public record ExceptionState(boolean active, String reason, Long markedBy, String markedByName, LocalDateTime markedAt) {}
public record ExceptionHistory(Long id, String action, String reason, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
public record TimelineEntry(String eventType, String detail, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
public record ExceptionRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=500) String reason) {}
}
@@ -30,6 +30,9 @@ public final class InboundMeasurementResponses {
Long measurementId,
String sourceType,
String measurementStatus,
@Schema(description = "入库主单乐观锁版本,用于测量审核、驳回和异常放行", example = "3")
Integer inboundVersion,
@Schema(description = "测量批次乐观锁版本,用于测量草稿和明细维护", example = "4")
Integer version,
String originalDocumentNo,
BigDecimal originalGrossWeightKg,
@@ -17,4 +17,5 @@ public final class InboundOrderReference {
public record Item(Long orderId, Long inboundId, String inboundCode, String processStatus) {
}
}
@@ -14,7 +14,7 @@ public record UnifiedInboundQuery(
Long tenantId,
@Size(max = 64) String keyword,
@Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED"})
@Size(max = 32) String mainStatus,
Long customerId,
@@ -5,6 +5,7 @@ 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("fl_container_transport_history")
@@ -18,4 +19,5 @@ public class ContainerTransportHistoryEntity {
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -27,6 +27,10 @@ public class InboundCartonLabelEntity {
private String boxCode;
private Integer totalCartonCount;
private Integer cartonIndex;
private Integer labelVersion;
private String batchStatus;
private String cargoFingerprint;
private LocalDateTime voidedAt;
private String boxSpecification;
private BigDecimal lengthCm;
private BigDecimal widthCm;
@@ -17,13 +17,13 @@ public class InternalFinanceCostSnapshotController {
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
public InternalFinanceCostSnapshotController(FinanceCostSnapshotService service) { this.service = service; }
@GetMapping("/customers/{customerId}/containers/{containerId}")
public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> container(
@GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}")
public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> loadingPlan(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable long customerId, @PathVariable long containerId) {
@PathVariable long customerId, @PathVariable long loadingPlanId) {
if (token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
return ApiResponse.success(service.container(customerId, containerId));
return ApiResponse.success(service.loadingPlan(customerId, loadingPlanId));
}
}
@@ -62,7 +62,7 @@ public interface LoadingInboundMapper {
AND loaded.process_status<>'LOADED' AND loaded.deleted=0
SET root.process_status='LOADED', root.updated_by=#{operatorId}, root.version=root.version+1
WHERE root.tenant_id=#{tenantId} AND root.id=#{rootInboundId} AND root.deleted=0
AND root.inbound_role='MASTER' AND root.process_status IN ('INBOUNDED','LOADING')
AND root.inbound_role='MASTER' AND root.process_status IN ('WAIT_SPLIT','LOADING')
AND remainder.id IS NULL AND loaded.id IS NULL
""")
int aggregateRootLoaded(@Param("tenantId") long tenantId,

Some files were not shown because too many files have changed in this diff Show More