feat: deliver first finance preview with setup wizard
Release / release (push) Failing after 18s

This commit is contained in:
Qiufeng
2026-08-17 14:08:40 +08:00
parent 47403fd337
commit c8b0151769
558 changed files with 140774 additions and 3 deletions
+2
View File
@@ -0,0 +1,2 @@
backend/mvnw.cmd text eol=crlf whitespace=-trailing-space
frontend/CHANGELOG.md text whitespace=-trailing-space
+116
View File
@@ -0,0 +1,116 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate release tag
id: release_meta
env:
GITEA_REF_NAME: ${{ github.ref_name }}
GITEA_SHA: ${{ github.sha }}
run: |
VERSION=${GITEA_REF_NAME#v}
test "$GITEA_REF_NAME" = "v$VERSION"
./scripts/check-semver.sh "$VERSION"
test "$(git rev-parse "refs/tags/$GITEA_REF_NAME^{commit}")" = "$GITEA_SHA"
test -z "$(git status --porcelain --untracked-files=all)"
printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT"
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Verify backend
env:
RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
run: ./backend/mvnw -f backend/pom.xml -Drevision="$RELEASE_VERSION" test
- name: Verify frontend
working-directory: frontend
run: |
HUSKY=0 npm ci
npm run lint -- --no-fix
npm run stylelint
npm test
npm run audit:dependencies
npm run build
npx playwright install --with-deps chromium
npm run test:e2e
- name: Verify release contracts and scripts
run: |
./scripts/check-openapi.sh openapi.yaml
./scripts/test-install-fixture.sh
./scripts/test-git-install-fixture.sh
./scripts/test-update-fixture.sh
./scripts/test-gitea-publish-fixture.sh
shellcheck deploy/install.sh deploy/install-from-git.sh deploy/update.sh scripts/check-semver.sh \
scripts/package-release.sh scripts/publish-gitea-release.sh \
scripts/generate-release-key.sh scripts/test-install-fixture.sh \
scripts/test-git-install-fixture.sh \
scripts/test-update-fixture.sh scripts/test-gitea-publish-fixture.sh \
scripts/verify-release.sh
- name: Build and sign release assets
env:
RELEASE_SIGNING_KEY_B64: ${{ secrets.RELEASE_SIGNING_KEY_B64 }}
RELEASE_VERSION: ${{ steps.release_meta.outputs.version }}
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
KAIDI_RELEASE_NOTES: Kaidi Finance ${{ github.ref_name }}
KAIDI_SOURCE_REVISION: ${{ github.sha }}
KAIDI_SOURCE_REF: ${{ github.ref_name }}
KAIDI_SOURCE_DIRTY: 'false'
run: |
test -n "$RELEASE_SIGNING_KEY_B64"
trap 'shred -u "$RUNNER_TEMP/release-key.pem" 2>/dev/null || rm -f "$RUNNER_TEMP/release-key.pem"' EXIT
printf '%s' "$RELEASE_SIGNING_KEY_B64" | base64 --decode > "$RUNNER_TEMP/release-key.pem"
chmod 600 "$RUNNER_TEMP/release-key.pem"
KAIDI_RELEASE_SIGNING_KEY="$RUNNER_TEMP/release-key.pem" \
./scripts/package-release.sh "$RELEASE_VERSION"
- name: Verify signed release assets
env:
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256: ${{ vars.KAIDI_RELEASE_PUBLIC_KEY_SHA256 }}
KAIDI_EXPECTED_SOURCE_REVISION: ${{ github.sha }}
KAIDI_EXPECTED_SOURCE_REF: ${{ github.ref_name }}
KAIDI_EXPECTED_SOURCE_DIRTY: 'false'
run: ./scripts/verify-release.sh dist/release
- name: Publish Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_REF_NAME: ${{ github.ref_name }}
GITEA_SHA: ${{ github.sha }}
run: ./scripts/publish-gitea-release.sh
- name: Upload Playwright report after failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ github.run_id }}
path: frontend/playwright-report
if-no-files-found: ignore
retention-days: 7
+15
View File
@@ -0,0 +1,15 @@
.DS_Store
backend/target/
frontend/dist/
frontend/node_modules/
frontend/playwright-report/
frontend/test-results/
.idea/
.vscode/
*.iml
*.log
.env.local
runtime/
/dist/
test-results/
*signing-private.pem
+32
View File
@@ -0,0 +1,32 @@
# 项目执行约束
## 禁止空跑
- 禁止执行 `true`、`:`、无内容的 `echo` / `printf`、仅用于占位的 `sleep`,以及其他不产生验证证据或项目进展的命令。
- 禁止空 `functions.exec` 脚本、仅含注释的脚本(如 `// placeholder`)、只输出空白的调用(如 `text(" ")`),以及只为制造一条工具记录而输出“正在委派”等状态文字的调用。
- `functions.exec`、`exec_command` 及 shell 不能用作子代理编排的心跳、轮次衔接、保活或等待屏障;编排状态使用对话更新,等待只使用对应的 `wait_agent` / `wait` / `write_stdin`。
- 禁止为了等待子代理、工具或长任务而插入占位命令。等待运行中命令必须使用对应会话的 `write_stdin` / `wait`;等待子代理必须使用代理状态或等待工具。
- 每次命令调用前必须能明确回答:要验证什么、预期得到什么证据、结果将影响哪个下一步。回答不出来就不执行。
- 对成功时本来静默的有效检查,必须显式输出结论,例如 `git diff --check && printf 'diff-check: PASS\n'`,避免把“检查通过”与“无意义空跑”混在一起。
## 截止与接管
- 同一任务连续两次没有新增证据、文件变化、错误定位或可执行结论时,停止继续轮询,由主代理立即接管。
- 子代理等待以最多 120 秒为一个时间片。单个子代理累计运行 10 分钟仍未交付时,主代理必须只检查一次现有状态:有部分证据就接收后终止,无证据就直接终止,并由主代理接管或拆成更小任务;不得继续盲等。
- 同一子代理连续两个等待时间片没有任何新增信息时,不等满 10 分钟,立即执行上述接管规则。状态检查本身不算项目进展,禁止用更多状态检查延长截止时间。
- 一旦出现空命令、空工具脚本或空白输出占位,立即停止该轮委派/轮询节奏;不得换成另一种空操作重试,由主代理直接继续实际工作。
- 长时间运行的构建、测试和服务只跟踪原会话,不另起占位命令。30 秒以上无新输出时只报告当前阶段;确认卡死后终止并诊断。
- 同一失败命令最多原样重试一次。再次失败时必须改变诊断手段、缩小范围或由主代理直接处理。
- 所有截止均由工具自身的超时参数或对应等待工具实现,不得用 `true`、空输出、`sleep` 或新建无关命令模拟截止。
## 有效进展标准
以下至少满足一项,才算一次有效执行:
- 读取到完成当前判断所需的文件或运行状态;
- 产生明确的测试、构建、Lint、类型检查、迁移或接口结果;
- 定位到具体错误、文件、行号或根因;
- 完成文件修改并得到针对性验证;
- 获得子代理可核验的结论或明确的阻断条件。
状态更新只报告上述有效进展,不报告占位、空轮询或没有信息增量的动作。
+278 -3
View File
@@ -1,7 +1,282 @@
# Kaifi
# Kaidi 财务项目基础系统
ERP 团队项目。
本仓库当前处于 R1 开发执行阶段,需求基线见下方唯一总方案。
唯一需求、开发、测试和交付基线:
- [财务项目基础系统开发交付总方案](docs/财务系统开发交付总方案.md)
## 本地开发
项目环境与启动方式将在代码接入后补充。
后端默认连接本机 MySQL `127.0.0.1:3307`,启动 Java 服务:
```bash
cd backend
./mvnw spring-boot:run
```
前端使用官方 TDesign Vue Next Starter,来源记录见 [`frontend/UPSTREAM.md`](frontend/UPSTREAM.md)。
```bash
cd frontend
HUSKY=0 npm ci
npm run dev:linux -- --host 0.0.0.0 --port 3002
```
访问:<http://localhost:3002/>
本地 `local` profile 会创建 `admin`、`project`、`finance`、`archive`、`demo` 演示账号,初始密码均为
`LocalOnly@123`;该固定密码只用于本机开发,生产安装会生成随机管理员密码。
正式构建:
```bash
cd frontend
npm run build
```
后端运行后更新唯一 OpenAPI 机器制品:
```bash
./scripts/export-openapi.sh http://127.0.0.1:18080
```
## R1 Preview 一键安装
Release 发布后,在 Linux 服务器执行下面一组命令即可安装。代码仓库和更新源固定为私有 Gitea
`https://git.awaioi.com/ERP-Team/kaidi`;服务器读取
`https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest`,不在生产机执行 `git pull` 或现场编译。
先从受信任的 CI 输出或内部发布记录取得 `RELEASE_TAG`、`INSTALLER_SHA256` 和 `PUBLIC_KEY_SHA256`。
后两项也会写入 Release 的 `bootstrap-checksums.txt`,但首次安装必须通过独立渠道核对,不能把同源下载值
直接当作信任根。私有仓库 Token 只授予仓库/Release 读取权限,不得复用 CI 的发布写 Token。
执行命令的机器需预装 `bash`、`sudo`、`curl`、`mktemp` 和 `sha256sum`,并能访问目标 Gitea;`jq`、Java、
Nginx 和数据库客户端由安装器补齐。应用固定安装到 `/opt/kaidi`、`/var/lib/kaidi`、
`/var/lib/kaidi-update` 和 `/etc/kaidi`。目标机应为专用主机,或确认现有 Nginx 默认站点可以被替换且
80 端口可用;安装器会接管默认 HTTP 站点。
### 直接 curl 安装
```bash
(
set -Eeuo pipefail
RELEASE_TAG=v1.0.0-preview.8
INSTALLER_SHA256=33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7
PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9
REINSTALL=false
SETUP_WIZARD=true
read -rsp 'Gitea 只读 Token: ' GITEA_READ_TOKEN; printf '\n'
token_file=$(mktemp); header_file=$(mktemp); installer=$(mktemp)
trap 'rm -f "$token_file" "$header_file" "$installer"' EXIT
printf '%s' "$GITEA_READ_TOKEN" > "$token_file"
printf 'Authorization: token %s\n' "$GITEA_READ_TOKEN" > "$header_file"
unset GITEA_READ_TOKEN
chmod 0600 "$token_file" "$header_file" "$installer"
curl --fail --silent --show-error --proto '=https' --tlsv1.2 \
--header "@$header_file" \
"https://git.awaioi.com/ERP-Team/kaidi/releases/download/$RELEASE_TAG/install.sh" \
-o "$installer"
printf '%s %s\n' "$INSTALLER_SHA256" "$installer" | sha256sum -c -
sudo env \
KAIDI_RELEASE_API_URL=https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest \
KAIDI_RELEASE_TOKEN_FILE="$token_file" \
KAIDI_RELEASE_PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \
KAIDI_REINSTALL="$REINSTALL" \
KAIDI_SETUP_WIZARD="$SETUP_WIZARD" \
bash "$installer"
)
```
### 从固定 Git tag 拉取后安装
需要保留源码快照时,可以拉取与 Release 对应的固定 tag,再运行仓库内包装器。私有仓库会在 `git clone`
时要求 Gitea 凭据,包装器随后单独要求只读 Release Token;它会在 `sudo` 前校验 `deploy/install.sh`
的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。
```bash
git clone --branch v1.0.0-preview.8 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview
cd kaidi-preview
KAIDI_SETUP_WIZARD=true ./deploy/install-from-git.sh
```
首次安装使用向导时不要传 `KAIDI_DB_URL`、`KAIDI_DB_USERNAME` 或 `KAIDI_DB_PASSWORD`;这些值在浏览器中填写。
只有已完成安装的修复性重装才从运行时配置读取数据库值,详见下文。
```bash
KAIDI_DB_URL='jdbc:mysql://MYSQL_HOST:3306/kaidi_finance?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&serverTimezone=UTC' \
KAIDI_DB_USERNAME=kaidi KAIDI_DB_PASSWORD='DB_PASSWORD' ./deploy/install-from-git.sh
```
安装器会完成以下动作:
- 仅在 Linux + systemd 环境执行;首版 32 位支持基线为带 systemd 的 Debian/Ubuntu x86 32 位 Linux。
- 识别 `x86_64`、`aarch64`、`armv7` 或 32 位 `i386/i486/i586/i686`,校验 SHA-256 后安装对应的 Azul Java 17 JRE。
- 使用安装命令固定的 SHA-256 指纹校验 Release 公钥,再用该公钥验证发布清单 RSA 签名。
- 首次安装默认启用 `/setup` 向导,不在命令行保存数据库密码;向导只接受 MySQL 8.4.x,并在提交前验证 DDL/DML 权限。
- 安装签名 Release 到 `/opt/kaidi/releases/<version>`,以 `/opt/kaidi/current` 原子切换当前版本。
- 安装 Nginx、`kaidi-finance.service`、更新监听服务和健康检查。
- 向导只初始化一个由操作者填写的 `SYSTEM_ADMIN` 管理员,不创建项目、财务、资料或演示账号。
- 安装器把一次性安装码写入仅 root 可读的 `/root/kaidi-first-login.txt`;完成向导后写入锁定标记并切换正式应用。
安装完成后先执行 `sudo cat /root/kaidi-first-login.txt`,访问其中的 `/setup` 地址完成数据库和管理员配置;完成后再访问
`http://SERVER_IP/` 登录。Preview 使用 HTTP 时安装器默认设置
`SESSION_COOKIE_SECURE=false`;配置 HTTPS 反向代理后,应在 `/etc/kaidi/kaidi.env` 改为
`SESSION_COOKIE_SECURE=true` 并执行 `sudo systemctl restart kaidi-finance`。
安装后执行以下命令确认应用、反向代理和首次登录信息:
```bash
curl -fsS http://127.0.0.1/actuator/health | jq -e '.status == "UP"'
sudo systemctl --no-pager --full status kaidi-finance kaidi-update.path
sudo cat /root/kaidi-first-login.txt
```
### Linux 32 位
应用已按 Java 17 字节码构建,安装器会在 32 位 Linux 下载 `i686` JRE。MySQL 8.4 没有可用于该部署方式的
32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令:
```bash
(
set -Eeuo pipefail
RELEASE_TAG=v1.0.0-preview.8; INSTALLER_SHA256=33d4921bcad7ef2be12f20bc0512a5f6df4227ea6dd3b7e6225164077d9cd9e7; PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9
read -rsp 'Gitea 只读 Token: ' GITEA_READ_TOKEN; printf '\n'
token_file=$(mktemp); header_file=$(mktemp); installer=$(mktemp)
trap 'rm -f "$token_file" "$header_file" "$installer"' EXIT
printf '%s' "$GITEA_READ_TOKEN" > "$token_file"
printf 'Authorization: token %s\n' "$GITEA_READ_TOKEN" > "$header_file"
unset GITEA_READ_TOKEN; chmod 0600 "$token_file" "$header_file" "$installer"
curl --fail --silent --show-error --proto '=https' --tlsv1.2 --header "@$header_file" \
"https://git.awaioi.com/ERP-Team/kaidi/releases/download/$RELEASE_TAG/install.sh" -o "$installer"
printf '%s %s\n' "$INSTALLER_SHA256" "$installer" | sha256sum -c -
sudo env KAIDI_RELEASE_TOKEN_FILE="$token_file" KAIDI_RELEASE_PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \
KAIDI_SETUP_WIZARD=true bash "$installer"
)
```
32 位主机不能运行安装器自动创建的 MySQL 容器,因此在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的
DDL、DML 权限。向导会连接数据库、核验 MySQL 8.4.x 版本并用临时表验证权限,全部通过后 Flyway 才会建表。
数据库管理员可在 MySQL 8.4 中按实际应用服务器地址执行以下基线 SQL:
```sql
CREATE DATABASE kaidi_finance CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE USER 'kaidi'@'KAIDI_SERVER_IP' IDENTIFIED BY 'DB_PASSWORD';
GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP';
```
### 修复性重装
正常升级统一使用后台“在线更新”。只有安装文件损坏且后台更新不可用时,才在原服务器执行修复性重装;
安装器会优先读取向导完成后生成的 `/var/lib/kaidi/setup/application.env`,再回退到 `/etc/kaidi/kaidi.env`,保留数据库、字段加密密钥、
管理员数据和运维人员新增的环境变量:
```bash
# 使用上方同一安装命令,把 REINSTALL=false 改成 REINSTALL=true。
```
重装失败会恢复原应用链接、Java 运行时、环境文件、systemd 单元和 Nginx 配置;脚本会明确报告回滚不完整,
不会把恢复失败吞掉。
如果安装器已完成但向导尚未提交,可使用同一命令同时设置 `REINSTALL=true` 和 `KAIDI_SETUP_WIZARD=true` 重新生成一次性安装码;
该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。
### 停用并移除程序
以下命令移除应用程序和服务,但保留 `/var/lib/kaidi`、`/var/lib/kaidi-update`、`/etc/kaidi` 以及数据库,
便于审计、备份或重新安装。确认数据备份前不要删除这些保留目录或 MySQL 数据卷。
安装器已经删除原 Nginx 默认站点;停用后需按该主机原有配置恢复或另行创建默认站点。
```bash
sudo systemctl disable --now kaidi-update.path kaidi-update.service kaidi-finance.service
sudo rm -f /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path
sudo rm -f /etc/nginx/conf.d/kaidi-finance.conf
sudo systemctl daemon-reload
sudo nginx -t && sudo systemctl reload nginx
sudo rm -rf /opt/kaidi
```
## Release 与在线更新
首次建立发布仓库时生成一次签名密钥:
```bash
./scripts/generate-release-key.sh release-signing-private.pem
base64 < release-signing-private.pem | tr -d '\n'
```
将私钥的 Base64 内容保存为 Gitea Actions Secret `RELEASE_SIGNING_KEY_B64`,并将命令输出的公钥 SHA-256
保存为 Gitea Actions Variable `KAIDI_RELEASE_PUBLIC_KEY_SHA256`。Gitea 内建 `GITEA_TOKEN` 只用于该工作流创建
Release;生产服务器使用另一个只读 Token。私钥不得提交到 Git。`.gitea/workflows/release.yml` 要求
act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前端、OpenAPI、Shell、安装/更新和浏览器门禁。
工作流先建立不可见草稿,再显式上传并核对 9 个资产的名称和大小,最后才发布为 `/releases/latest`。Gitea 的
`latest` 会排除 `prerelease=true`,因此即使 tag 名含 `preview`,发布记录的 `prerelease` 也固定为 `false`;
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
```bash
git tag v1.0.0-preview.8
git push origin v1.0.0-preview.8
```
在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。
更新源由 root 在 `/etc/kaidi/update.env` 固定为私有 Gitea Latest Release API;页面和普通 API 都看不到 Token,
也不能提交 URL、脚本或命令。更新流程固定为:
1. 点击“获取更新”,后端先读取 Release 元数据,再自动排队 `DOWNLOAD`;root 更新器从 Gitea 下载 manifest、签名和应用包,执行 RSA、
SHA-256、版本、文件名和压缩包路径校验后缓存到 `/var/lib/kaidi-update/cache/<version>`。业务服务不停机。
3. 页面显示 `READY/等待重启` 后才出现“立即重启”;管理员点击后提交安装。未缓存或版本不一致
的包不能进入安装。
4. 安装请求持久领取到 `/var/lib/kaidi-update/processing`;进程或主机中断后由 systemd 恢复未完成事务。
5. root 更新器重新验签和验哈希,确认 `mysqldump` 成功并生成权限为 `0600` 的备份,默认保留最近 5 份。
6. 校验更新脚本和 systemd 单元后,原子切换 updater、systemd、Nginx 和应用版本。
7. 同时检查后端直连、Nginx 健康端点、更新 path unit 和静态首页。全部通过后页面显示 10 秒倒计时并自动
刷新;刷新或短暂断线发生在安装中时,页面会恢复 3 秒轮询。任一检查失败则恢复并验证上一版本。
忙碌期间检查、下载和安装按钮保持禁用,防止重复请求;这就是更新执行冷却。10 秒只用于成功后的页面刷新,
不会延迟服务端切换。systemd 在 300 秒内连续失败 3 次后停止自动重试,避免失败任务空跑。
安装器会把 API 地址和只读 Token 同步写入 root-only 的 `/etc/kaidi/kaidi.env` 与
`/etc/kaidi/update.env`,两者权限均为 `0600`:前者供 Java 后端“检查更新”读取,后者供 root 更新器下载资产。
轮换 Token 时必须同时更新两个文件,再执行 `sudo systemctl restart kaidi-finance`;Token 不写入页面、状态 JSON、
审计参数或更新日志。
数据库迁移必须保持至少一个版本的向后兼容。查看状态和日志:
```bash
sudo systemctl status kaidi-finance kaidi-update.path
sudo journalctl -u kaidi-update.service -n 100 --no-pager
cat /var/lib/kaidi-update/status.json
```
如果 `status.json` 显示 `FAILED` 且日志提示回滚未完成,不要删除
`/var/lib/kaidi-update/processing/request.json` 或活动事务目录。systemd 在 300 秒内连续失败 3 次后会停止自动重试,
修复日志所示的磁盘、权限、Nginx 或旧版本健康问题后执行:
```bash
sudo systemctl reset-failed kaidi-update.service kaidi-update.path
sudo systemctl start kaidi-update.service
sudo journalctl -u kaidi-update.service -n 100 --no-pager
cat /var/lib/kaidi-update/status.json
```
只有状态恢复为 `SUCCEEDED`、`CURRENT` 或确定性的终态 `FAILED`,且 `transactions/active` 已处理完成后,
才算本次恢复结束。后台会保留真实失败状态,不会把待恢复的 processing 请求误显示成普通排队。
## 手工生成 Release
```bash
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/package-release.sh 1.0.0-preview.8
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
./scripts/verify-release.sh dist/release
```
输出位于 `dist/release/`,包含安装脚本、签名清单、公钥、SHA-256 清单、前后端 CycloneDX SBOM、
首次安装指纹文件和完整应用压缩包。
打包脚本会把 Maven `revision` 与 npm 包版本临时绑定到 Release SemVer,构建结束后恢复工作区源文件;
JAR、两个 SBOM、签名清单或 tag 的版本只要有一项不一致,发布即失败。
签名清单同时绑定应用包、两份 SBOM、安装器、公钥、bootstrap 指纹和 Git 源码修订;本地脏工作区会明确记录
`source.dirty=true`,tag 工作流只接受干净 checkout 并记录 `source.dirty=false`。
发布命令同时在终端输出 `Trusted release public-key SHA-256` 与 `Installer SHA-256`;把这两个值写入
受控部署记录,再替换上述一键安装命令中的占位符。
+12
View File
@@ -0,0 +1,12 @@
# Third-Party Notices
## TDesign Vue Next Starter
- Project: `Tencent/tdesign-vue-next-starter`
- Source: https://github.com/Tencent/tdesign-vue-next-starter
- Baseline commit: `1f183fa089d07183235dc69dbd76b8b2c4a6d8bb`
- Imported: 2026-08-07
- License: MIT
- Local license file: `frontend/LICENSE`
The upstream source is used as the frontend application starter and will be modified for this project's authentication, authorization, workflows, pages, and deployment requirements.
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+186
View File
@@ -0,0 +1,186 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.kaidi</groupId>
<artifactId>finance-system</artifactId>
<version>${revision}</version>
<name>Kaidi Finance System</name>
<description>Project finance workflow, accounting preparation and archive system</description>
<properties>
<revision>1.0.0-SNAPSHOT</revision>
<start-class>com.kaidi.finance.FinanceApplication</start-class>
<java.version>17</java.version>
<mybatis-spring-boot.version>3.0.5</mybatis-spring-boot.version>
<flyway.version>11.20.3</flyway.version>
<springdoc.version>2.8.14</springdoc.version>
<testcontainers.version>1.21.4</testcontainers.version>
<archunit.version>1.4.1</archunit.version>
<bouncycastle.version>1.80</bouncycastle.version>
<pdfbox.version>3.0.6</pdfbox.version>
<cyclonedx.version>2.9.3</cyclonedx.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis-spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<id>enforce-java</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<requireJavaVersion><version>[17,22)</version></requireJavaVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.13</version>
<executions>
<execution><goals><goal>prepare-agent</goal></goals></execution>
<execution><id>report</id><phase>verify</phase><goals><goal>report</goal></goals></execution>
</executions>
</plugin>
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<version>${cyclonedx.version}</version>
<executions>
<execution>
<id>make-runtime-sbom</id>
<phase>package</phase>
<goals><goal>makeAggregateBom</goal></goals>
<configuration>
<projectType>application</projectType>
<schemaVersion>1.6</schemaVersion>
<includeBomSerialNumber>true</includeBomSerialNumber>
<includeCompileScope>true</includeCompileScope>
<includeProvidedScope>true</includeProvidedScope>
<includeRuntimeScope>true</includeRuntimeScope>
<includeSystemScope>true</includeSystemScope>
<includeTestScope>false</includeTestScope>
<outputFormat>json</outputFormat>
<outputName>backend-sbom</outputName>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,36 @@
package com.kaidi.finance;
import com.kaidi.finance.iam.application.BootstrapProperties;
import com.kaidi.finance.shared.file.FileScannerProperties;
import com.kaidi.finance.shared.file.FileStorageProperties;
import com.kaidi.finance.update.application.SystemUpdateProperties;
import com.kaidi.setup.SetupApplication;
import java.util.Arrays;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
@MapperScan(basePackages = "com.kaidi.finance", annotationClass = org.apache.ibatis.annotations.Mapper.class)
@EnableConfigurationProperties({BootstrapProperties.class, FileStorageProperties.class, FileScannerProperties.class,
SystemUpdateProperties.class})
public class FinanceApplication {
public static void main(String[] args) {
if (setupModeEnabled(args)) {
SetupApplication.main(args);
return;
}
SpringApplication.run(FinanceApplication.class, args);
}
private static boolean setupModeEnabled(String[] args) {
if (Boolean.parseBoolean(System.getenv().getOrDefault("FINANCE_SETUP_ENABLED", "false"))) {
return true;
}
return Arrays.stream(args).anyMatch(argument -> "--finance.setup.enabled=true".equals(argument)
|| "--FINANCE_SETUP_ENABLED=true".equals(argument));
}
}
@@ -0,0 +1,92 @@
package com.kaidi.finance.accounting.api;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public final class AccountingContracts {
private AccountingContracts() {
}
public record GenerateDraftRequest(
@NotNull Long version,
@NotBlank @Size(max = 64) String ruleVersion
) {
}
public record VoucherEntryRequest(
@NotNull Integer lineNo,
@NotBlank @Pattern(regexp = "DEBIT|CREDIT") String direction,
@NotBlank @Size(max = 26) String accountId,
@NotNull @DecimalMin("0.01") BigDecimal amount,
@NotBlank @Size(max = 500) String summary,
@Size(max = 4000) String auxiliaryJson
) {
}
public record VoucherUpdateRequest(
@NotNull Long version,
@NotBlank @Pattern(regexp = "\\d{4}-\\d{2}") String period,
@NotNull LocalDate businessDate,
@NotBlank @Size(max = 500) String summary,
@NotEmpty @Size(max = 100) List<@Valid VoucherEntryRequest> entries,
@NotBlank @Size(max = 1000) String changeReason
) {
}
public record VersionCommandRequest(@NotNull Long version) {
}
public record ReturnRequest(
@NotNull Long version,
@NotBlank @Size(max = 1000) String opinion
) {
}
public record ResultRequest(
@NotNull Long version,
@NotBlank @Size(max = 100) String externalVoucherNo,
@NotNull LocalDate resultAt,
@NotEmpty @Size(max = 20) List<@NotBlank @Size(max = 26) String> evidenceFileIds,
@Size(max = 1000) String remark
) {
}
public record VerifyResultRequest(
@NotNull Long version,
@NotBlank @Size(min = 2, max = 1000)
@Pattern(regexp = "(?s).*\\S.*\\S.*", message = "must contain at least 2 non-whitespace characters")
String opinion
) {
}
public record ReverseResultRequest(
@NotNull Long version,
@NotBlank @Size(min = 2, max = 1000)
@Pattern(regexp = "(?s).*\\S.*\\S.*", message = "must contain at least 2 non-whitespace characters")
String reason
) {
}
public record ReopenResultRequest(
@NotNull Long version,
@NotBlank @Size(min = 2, max = 1000)
@Pattern(regexp = "(?s).*\\S.*\\S.*", message = "must contain at least 2 non-whitespace characters")
String reason
) {
}
public record VoidRequest(
@NotNull Long version,
@NotBlank @Size(max = 1000) String reason
) {
}
}
@@ -0,0 +1,262 @@
package com.kaidi.finance.accounting.api;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kaidi.finance.accounting.api.AccountingContracts.GenerateDraftRequest;
import com.kaidi.finance.accounting.api.AccountingContracts.ResultRequest;
import com.kaidi.finance.accounting.api.AccountingContracts.ReopenResultRequest;
import com.kaidi.finance.accounting.api.AccountingContracts.ReverseResultRequest;
import com.kaidi.finance.accounting.api.AccountingContracts.ReturnRequest;
import com.kaidi.finance.accounting.api.AccountingContracts.VerifyResultRequest;
import com.kaidi.finance.accounting.api.AccountingContracts.VersionCommandRequest;
import com.kaidi.finance.accounting.api.AccountingContracts.VoidRequest;
import com.kaidi.finance.accounting.api.AccountingContracts.VoucherUpdateRequest;
import com.kaidi.finance.accounting.api.AccountingViews.AccountView;
import com.kaidi.finance.accounting.api.AccountingViews.AccountingEventView;
import com.kaidi.finance.accounting.api.AccountingViews.VoucherDetailView;
import com.kaidi.finance.accounting.api.AccountingViews.VoucherExportView;
import com.kaidi.finance.accounting.api.AccountingViews.VoucherView;
import com.kaidi.finance.accounting.application.AccountingApplicationService;
import com.kaidi.finance.shared.api.ApiResponse;
import com.kaidi.finance.shared.api.PageResult;
import com.kaidi.finance.shared.file.FileApplicationService.FileDownload;
import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.nio.charset.StandardCharsets;
import java.util.List;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/accounting")
public class AccountingController {
private final AccountingApplicationService service;
private final IdempotencyApplicationService idempotencyService;
public AccountingController(AccountingApplicationService service,
IdempotencyApplicationService idempotencyService) {
this.service = service;
this.idempotencyService = idempotencyService;
}
@GetMapping("/accounts")
@Operation(operationId = "listAccountingAccounts", summary = "查询会计科目")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')")
public ApiResponse<List<AccountView>> accounts() {
return ApiResponse.ok(service.accounts());
}
@GetMapping("/events")
@Operation(operationId = "listAccountingEvents", summary = "查询财务事件")
@PreAuthorize("@authorizationService.hasPermission('accounting:event:view')")
public ApiResponse<List<AccountingEventView>> events(
@RequestParam(required = false) String period,
@RequestParam(required = false) String projectId,
@RequestParam(required = false) String eventType,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "businessDate,asc") String sort,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<AccountingEventView> result = service.listEvents(period, projectId, eventType, keyword, sort,
page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/vouchers")
@Operation(operationId = "listVouchers", summary = "查询凭证草稿")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')")
public ApiResponse<List<VoucherView>> vouchers(
@RequestParam(required = false) String tab,
@RequestParam(required = false) String period,
@RequestParam(required = false) String projectId,
@RequestParam(required = false) String eventType,
@RequestParam(required = false) String status,
@RequestParam(required = false) String externalVoucherNo,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "updatedAt,desc") String sort,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<VoucherView> result = service.listVouchers(tab, period, projectId, eventType, status,
externalVoucherNo, keyword, sort, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/vouchers/{publicId}")
@Operation(operationId = "getVoucher", summary = "查询凭证详情")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')")
public ApiResponse<VoucherDetailView> detail(@PathVariable String publicId) {
return ApiResponse.ok(service.getVoucher(publicId));
}
@PostMapping("/events/{publicId}/generate-draft")
@Operation(operationId = "generateVoucherDraft", summary = "生成凭证草稿")
@PreAuthorize("@authorizationService.hasPermission('accounting:event:generate')")
public ApiResponse<VoucherDetailView> generateDraft(@PathVariable String publicId,
@Valid @RequestBody GenerateDraftRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.generateDraft(publicId, request)));
}
@PatchMapping("/vouchers/{publicId}")
@Operation(operationId = "updateVoucher", summary = "修改凭证草稿")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:edit')")
public ApiResponse<VoucherDetailView> update(@PathVariable String publicId,
@Valid @RequestBody VoucherUpdateRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.updateVoucher(publicId, request)));
}
@PostMapping("/vouchers/{publicId}/submit")
@Operation(operationId = "submitVoucher", summary = "提交凭证复核")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:edit')")
public ApiResponse<VoucherDetailView> submit(@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.submitVoucher(publicId, request.version())));
}
@PostMapping("/vouchers/{publicId}/approve")
@Operation(operationId = "approveVoucher", summary = "复核通过凭证")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:review')")
public ApiResponse<VoucherDetailView> approve(@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.approveVoucher(publicId, request.version())));
}
@PostMapping("/vouchers/{publicId}/return")
@Operation(operationId = "returnVoucher", summary = "退回凭证")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:review')")
public ApiResponse<VoucherDetailView> returnVoucher(@PathVariable String publicId,
@Valid @RequestBody ReturnRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.returnVoucher(publicId, request)));
}
@PostMapping("/vouchers/{publicId}/export")
@Operation(operationId = "exportVoucher", summary = "导出凭证")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:export')")
public ApiResponse<VoucherExportView> export(@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherExportView>() { },
() -> service.exportVoucher(publicId, request.version())));
}
@GetMapping("/vouchers/{publicId}/export-file")
@Operation(operationId = "downloadVoucherExport", summary = "下载凭证导出文件")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:export')")
public ResponseEntity<Resource> exportFile(@PathVariable String publicId) {
FileDownload download = service.downloadExport(publicId);
return downloadResponse(download);
}
@GetMapping("/vouchers/{voucherPublicId}/result-files/{filePublicId}/content")
@Operation(operationId = "downloadVoucherResultEvidence", summary = "下载凭证结果附件")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:view')")
public ResponseEntity<Resource> resultEvidenceFile(@PathVariable String voucherPublicId,
@PathVariable String filePublicId) {
FileDownload download = service.downloadResultEvidence(voucherPublicId, filePublicId);
return downloadResponse(download);
}
private ResponseEntity<Resource> downloadResponse(FileDownload download) {
var metadata = download.metadata();
ContentDisposition disposition = ContentDisposition.attachment()
.filename(metadata.getOriginalName(), StandardCharsets.UTF_8).build();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(metadata.getMediaType()))
.contentLength(metadata.getSizeBytes())
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.header(HttpHeaders.CACHE_CONTROL, "private, no-store")
.header("X-File-SHA256", metadata.getSha256())
.header("X-Content-Type-Options", "nosniff")
.body(download.resource());
}
@PostMapping("/vouchers/{publicId}/record-result")
@Operation(operationId = "recordVoucherResult", summary = "登记人工记账结果")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:record-result')")
public ApiResponse<VoucherDetailView> recordResult(@PathVariable String publicId,
@Valid @RequestBody ResultRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.recordResult(publicId, request)));
}
@PostMapping("/vouchers/{publicId}/verify-result")
@Operation(operationId = "verifyVoucherResult", summary = "复核人工记账结果")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:verify-result')")
public ApiResponse<VoucherDetailView> verifyResult(@PathVariable String publicId,
@Valid @RequestBody VerifyResultRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.verifyResult(publicId, request)));
}
@PostMapping("/vouchers/{publicId}/reverse-result")
@Operation(operationId = "reverseVoucherResult", summary = "冲销已复核人工记账结果")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:reverse-result')")
public ApiResponse<VoucherDetailView> reverseResult(@PathVariable String publicId,
@Valid @RequestBody ReverseResultRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.reverseResult(publicId, request)));
}
@PostMapping("/vouchers/{publicId}/reopen-result")
@Operation(operationId = "reopenVoucherResult", summary = "重开已冲销人工记账结果")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:reopen-result')")
public ApiResponse<VoucherDetailView> reopenResult(@PathVariable String publicId,
@Valid @RequestBody ReopenResultRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.reopenResult(publicId, request)));
}
@PostMapping("/vouchers/{publicId}/void")
@Operation(operationId = "voidVoucher", summary = "作废凭证")
@PreAuthorize("@authorizationService.hasPermission('accounting:voucher:void')")
public ApiResponse<VoucherDetailView> voidVoucher(@PathVariable String publicId,
@Valid @RequestBody VoidRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<VoucherDetailView>() { },
() -> service.voidVoucher(publicId, request)));
}
private <T> T command(String key, HttpServletRequest request, Object body, TypeReference<T> type,
java.util.function.Supplier<T> action) {
return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, type, action);
}
}
@@ -0,0 +1,149 @@
package com.kaidi.finance.accounting.api;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class AccountingViews {
private AccountingViews() {
}
public record ReferenceView(String publicId, String code, String name) {
}
public record AccountView(
String publicId,
String code,
String name,
String accountType,
boolean auxiliaryRequired
) {
}
public record AccountingEventView(
String publicId,
String businessNo,
String eventType,
String sourceType,
String sourcePublicId,
String sourceBusinessNo,
String sourceVersion,
int eventSequence,
String sourceEventKey,
ReferenceView company,
ReferenceView project,
ReferenceView counterparty,
LocalDate businessDate,
String period,
String amount,
String currency,
String status,
String voucherPublicId,
String voucherStatus,
long version,
LocalDateTime createdAt,
List<String> allowedActions
) {
}
public record VoucherEntryView(
String publicId,
int lineNo,
String direction,
ReferenceView account,
String amount,
String summary,
String auxiliaryJson
) {
}
public record VoucherView(
String publicId,
String businessNo,
String eventPublicId,
String eventBusinessNo,
ReferenceView company,
ReferenceView project,
String period,
LocalDate businessDate,
String summary,
String debitTotal,
String creditTotal,
String balanceStatus,
String status,
String submittedByName,
String reviewedByName,
LocalDateTime exportedAt,
String exportBatchNo,
String exportSha256,
String exportFileId,
String ruleVersion,
String externalVoucherNo,
LocalDate resultAt,
int resultCycleNo,
List<String> evidenceFileIds,
String resultRecordedByName,
LocalDateTime resultRecordedAt,
String resultRecordRemark,
String resultVerifiedByName,
LocalDateTime resultVerifiedAt,
String resultVerifyOpinion,
String voidReason,
long version,
LocalDateTime createdAt,
LocalDateTime updatedAt,
List<String> allowedActions
) {
}
public record VoucherDetailView(
VoucherView voucher,
AccountingEventView event,
List<VoucherEntryView> entries,
List<ResultEvidenceFileView> evidenceFiles,
List<VoucherResultActionView> resultActions
) {
}
public record ResultEvidenceFileView(
String publicId,
String originalName,
String mediaType,
long sizeBytes,
String sha256,
String scanStatus
) {
}
public record VoucherResultActionView(
String publicId,
int sequenceNo,
int resultCycleNo,
String actionType,
String reason,
String externalVoucherNo,
LocalDate resultAt,
String resultRecordedByName,
LocalDateTime resultRecordedAt,
String resultRecordRemark,
String resultVerifiedByName,
LocalDateTime resultVerifiedAt,
String resultVerifyOpinion,
List<ResultEvidenceFileView> evidenceFiles,
String operatedByName,
LocalDateTime operatedAt,
long versionBefore,
long versionAfter
) {
}
public record VoucherExportView(
VoucherView voucher,
String batchNo,
String sha256,
String fileId,
int rowCount
) {
}
}
@@ -0,0 +1,757 @@
package com.kaidi.finance.accounting.infrastructure;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@org.apache.ibatis.annotations.Mapper
public interface AccountingMapper {
String EVENT_SELECT = """
SELECT event.id, event.public_id, event.business_no, event.event_type, event.source_type,
event.source_public_id,
COALESCE(source_receipt.business_no, source_invoice.business_no,
source_payable.business_no, source_payment.business_no) AS source_business_no,
event.source_version, event.event_sequence,
event.source_event_key, event.company_id, company.public_id AS company_public_id,
company.business_no AS company_code, company.name AS company_name,
event.project_id, project.public_id AS project_public_id,
project.business_no AS project_code, project.name AS project_name,
event.counterparty_id, counterparty.public_id AS counterparty_public_id,
counterparty.business_no AS counterparty_code, counterparty.name AS counterparty_name,
event.business_date, event.period, event.amount, event.currency, event.status,
event.voucher_id, voucher.public_id AS voucher_public_id, voucher.status AS voucher_status,
event.version, event.created_at
FROM acc_event event
JOIN md_company company ON company.id = event.company_id
LEFT JOIN md_project project ON project.id = event.project_id
LEFT JOIN md_counterparty counterparty ON counterparty.id = event.counterparty_id
LEFT JOIN acc_voucher voucher ON voucher.id = event.voucher_id
LEFT JOIN fin_receipt source_receipt
ON event.source_type = 'RECEIPT' AND source_receipt.public_id = event.source_public_id
LEFT JOIN fin_invoice source_invoice
ON event.source_type = 'INVOICE' AND source_invoice.public_id = event.source_public_id
LEFT JOIN fin_payable source_payable
ON event.source_type = 'PAYABLE' AND source_payable.public_id = event.source_public_id
LEFT JOIN fin_payment_request source_payment
ON event.source_type IN ('PAYMENT', 'PAYMENT_REFUND')
AND source_payment.public_id = event.source_public_id
""" + " ";
String VOUCHER_SELECT = """
SELECT voucher.id, voucher.public_id, voucher.business_no, voucher.event_id,
event.public_id AS event_public_id, event.business_no AS event_business_no,
voucher.company_id, company.public_id AS company_public_id,
company.business_no AS company_code, company.name AS company_name,
voucher.project_id, project.public_id AS project_public_id,
project.business_no AS project_code, project.name AS project_name,
voucher.rule_version, CAST(voucher.rule_snapshot_json AS CHAR) AS rule_snapshot_json,
voucher.period, voucher.business_date, voucher.summary, event.amount AS event_amount,
voucher.debit_total, voucher.credit_total, voucher.status,
voucher.submitted_by, submitter.display_name AS submitted_by_name,
voucher.reviewed_by, reviewer.display_name AS reviewed_by_name,
voucher.exported_at, voucher.export_sha256, voucher.export_batch_no,
export_file.public_id AS export_file_id,
voucher.external_voucher_no, voucher.result_at, voucher.result_recorded_by,
recorder.display_name AS result_recorded_by_name, voucher.result_recorded_at,
voucher.result_record_remark,
voucher.result_verified_by, verifier.display_name AS result_verified_by_name,
voucher.result_verified_at, voucher.result_verify_opinion, voucher.result_cycle_no,
voucher.void_reason,
voucher.last_change_reason, voucher.created_by, voucher.updated_by,
voucher.version, voucher.created_at, voucher.updated_at
FROM acc_voucher voucher
JOIN acc_event event ON event.id = voucher.event_id
JOIN md_company company ON company.id = voucher.company_id
LEFT JOIN md_project project ON project.id = voucher.project_id
LEFT JOIN iam_user submitter ON submitter.id = voucher.submitted_by
LEFT JOIN iam_user reviewer ON reviewer.id = voucher.reviewed_by
LEFT JOIN iam_user recorder ON recorder.id = voucher.result_recorded_by
LEFT JOIN iam_user verifier ON verifier.id = voucher.result_verified_by
LEFT JOIN acc_export_batch export_batch ON export_batch.voucher_id = voucher.id
LEFT JOIN file_object export_file ON export_file.id = export_batch.file_id
""" + " ";
String EVENT_SCOPE = " " + """
EXISTS (
SELECT 1
FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId}
AND role.code = #{roleCode}
AND permission.code = 'accounting:event:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (
scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)
)
AND (scope.amount_limit IS NULL OR event.amount &lt;= scope.amount_limit)
)
""" + " ";
String VOUCHER_SCOPE = " " + """
EXISTS (
SELECT 1
FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId}
AND role.code = #{roleCode}
AND permission.code = 'accounting:voucher:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (
scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id)
)
AND (scope.amount_limit IS NULL OR event.amount &lt;= scope.amount_limit)
)
""" + " ";
@Select("""
SELECT account.id, account.public_id, account.code, account.name,
account.account_type, account.auxiliary_required, account.status
FROM md_account account
WHERE account.status = 'ACTIVE'
ORDER BY account.code
""")
List<AccountRow> listAccounts();
@Select("""
<script>
""" + EVENT_SELECT + """
WHERE """ + EVENT_SCOPE + """
<if test="status != null">AND event.status = #{status}</if>
<if test="status == null">AND event.status &lt;&gt; 'VOID'</if>
<if test="period != null">AND event.period = #{period}</if>
<if test="projectId != null">AND project.public_id = #{projectId}</if>
<if test="eventType != null">AND event.event_type = #{eventType}</if>
<if test="keyword != null">
AND (event.business_no LIKE CONCAT('%', #{keyword}, '%')
OR event.source_public_id LIKE CONCAT('%', #{keyword}, '%')
OR project.name LIKE CONCAT('%', #{keyword}, '%'))
</if>
ORDER BY ${orderBy}, event.public_id ASC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<EventRow> listEvents(@Param("userId") long userId,
@Param("roleCode") String roleCode,
@Param("status") String status,
@Param("period") String period,
@Param("projectId") String projectId,
@Param("eventType") String eventType,
@Param("keyword") String keyword,
@Param("orderBy") String orderBy,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("""
<script>
SELECT COUNT(*)
FROM acc_event event
JOIN md_company company ON company.id = event.company_id
LEFT JOIN md_project project ON project.id = event.project_id
WHERE """ + EVENT_SCOPE + """
<if test="status != null">AND event.status = #{status}</if>
<if test="status == null">AND event.status &lt;&gt; 'VOID'</if>
<if test="period != null">AND event.period = #{period}</if>
<if test="projectId != null">AND project.public_id = #{projectId}</if>
<if test="eventType != null">AND event.event_type = #{eventType}</if>
<if test="keyword != null">
AND (event.business_no LIKE CONCAT('%', #{keyword}, '%')
OR event.source_public_id LIKE CONCAT('%', #{keyword}, '%')
OR project.name LIKE CONCAT('%', #{keyword}, '%'))
</if>
</script>
""")
long countEvents(@Param("userId") long userId,
@Param("roleCode") String roleCode,
@Param("status") String status,
@Param("period") String period,
@Param("projectId") String projectId,
@Param("eventType") String eventType,
@Param("keyword") String keyword);
@Select("""
<script>
""" + VOUCHER_SELECT + """
WHERE """ + VOUCHER_SCOPE + """
<choose>
<when test="tab == 'all'">AND voucher.status &lt;&gt; 'VOID'</when>
<when test="tab == 'draft'">AND voucher.status IN ('DRAFT', 'RETURNED')</when>
<when test="tab == 'review'">AND voucher.status = 'REVIEWING'</when>
<when test="tab == 'exported'">AND voucher.status IN ('APPROVED', 'EXPORTED')</when>
<when test="tab == 'result'">AND voucher.status = 'RESULT_RECORDED'</when>
<when test="tab == 'completed'">AND voucher.status = 'COMPLETED'</when>
<when test="tab == 'reversed'">AND voucher.status = 'RESULT_REVERSED'</when>
<when test="tab == 'void'">AND voucher.status = 'VOID'</when>
</choose>
<if test="period != null">AND voucher.period = #{period}</if>
<if test="projectId != null">AND project.public_id = #{projectId}</if>
<if test="eventType != null">AND event.event_type = #{eventType}</if>
<if test="status != null">AND voucher.status = #{status}</if>
<if test="externalVoucherNo != null">AND voucher.external_voucher_no LIKE CONCAT('%', #{externalVoucherNo}, '%')</if>
<if test="keyword != null">
AND (voucher.business_no LIKE CONCAT('%', #{keyword}, '%')
OR event.business_no LIKE CONCAT('%', #{keyword}, '%')
OR voucher.summary LIKE CONCAT('%', #{keyword}, '%')
OR project.name LIKE CONCAT('%', #{keyword}, '%'))
</if>
ORDER BY ${orderBy}, voucher.public_id ASC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<VoucherRow> listVouchers(@Param("userId") long userId,
@Param("roleCode") String roleCode,
@Param("tab") String tab,
@Param("period") String period,
@Param("projectId") String projectId,
@Param("eventType") String eventType,
@Param("status") String status,
@Param("externalVoucherNo") String externalVoucherNo,
@Param("keyword") String keyword,
@Param("orderBy") String orderBy,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("""
<script>
SELECT COUNT(*)
FROM acc_voucher voucher
JOIN acc_event event ON event.id = voucher.event_id
JOIN md_company company ON company.id = voucher.company_id
LEFT JOIN md_project project ON project.id = voucher.project_id
WHERE """ + VOUCHER_SCOPE + """
<choose>
<when test="tab == 'all'">AND voucher.status &lt;&gt; 'VOID'</when>
<when test="tab == 'draft'">AND voucher.status IN ('DRAFT', 'RETURNED')</when>
<when test="tab == 'review'">AND voucher.status = 'REVIEWING'</when>
<when test="tab == 'exported'">AND voucher.status IN ('APPROVED', 'EXPORTED')</when>
<when test="tab == 'result'">AND voucher.status = 'RESULT_RECORDED'</when>
<when test="tab == 'completed'">AND voucher.status = 'COMPLETED'</when>
<when test="tab == 'reversed'">AND voucher.status = 'RESULT_REVERSED'</when>
<when test="tab == 'void'">AND voucher.status = 'VOID'</when>
</choose>
<if test="period != null">AND voucher.period = #{period}</if>
<if test="projectId != null">AND project.public_id = #{projectId}</if>
<if test="eventType != null">AND event.event_type = #{eventType}</if>
<if test="status != null">AND voucher.status = #{status}</if>
<if test="externalVoucherNo != null">AND voucher.external_voucher_no LIKE CONCAT('%', #{externalVoucherNo}, '%')</if>
<if test="keyword != null">
AND (voucher.business_no LIKE CONCAT('%', #{keyword}, '%')
OR event.business_no LIKE CONCAT('%', #{keyword}, '%')
OR voucher.summary LIKE CONCAT('%', #{keyword}, '%')
OR project.name LIKE CONCAT('%', #{keyword}, '%'))
</if>
</script>
""")
long countVouchers(@Param("userId") long userId,
@Param("roleCode") String roleCode,
@Param("tab") String tab,
@Param("period") String period,
@Param("projectId") String projectId,
@Param("eventType") String eventType,
@Param("status") String status,
@Param("externalVoucherNo") String externalVoucherNo,
@Param("keyword") String keyword);
@Select(EVENT_SELECT + " WHERE event.public_id = #{publicId}")
EventRow findEvent(String publicId);
@Select(EVENT_SELECT + " WHERE event.id = #{id}")
EventRow findEventById(long id);
@Select(EVENT_SELECT + " WHERE event.id = #{id} FOR UPDATE")
EventRow lockEvent(long id);
@Select(VOUCHER_SELECT + " WHERE voucher.public_id = #{publicId}")
VoucherRow findVoucher(String publicId);
@Select(VOUCHER_SELECT + " WHERE voucher.id = #{id}")
VoucherRow findVoucherById(long id);
@Select("""
SELECT id, public_id, event_id, company_id, project_id, period, business_date,
summary, debit_total, credit_total, status, submitted_by, reviewed_by,
result_recorded_by, result_verified_by, result_cycle_no, version, created_by
FROM acc_voucher WHERE id = #{id} FOR UPDATE
""")
VoucherLock lockVoucher(long id);
@Select("""
SELECT entry.id, entry.public_id, entry.line_no, entry.direction, entry.account_id,
account.public_id AS account_public_id, account.code AS account_code,
account.name AS account_name, account.auxiliary_required,
account.status AS account_status,
entry.amount, entry.summary, CAST(entry.auxiliary_json AS CHAR) AS auxiliary_json
FROM acc_voucher_entry entry
JOIN md_account account ON account.id = entry.account_id
WHERE entry.voucher_id = #{voucherId}
ORDER BY entry.line_no
""")
List<EntryRow> listEntries(long voucherId);
@Select("""
SELECT account.id, account.public_id, account.code, account.name,
account.account_type, account.auxiliary_required, account.status
FROM md_account account WHERE account.public_id = #{publicId}
""")
AccountRow findAccount(String publicId);
@Select("SELECT id FROM md_account WHERE code = #{code} AND status = 'ACTIVE'")
Long activeAccountId(String code);
@Select("""
SELECT id, public_id, version_no, CAST(value_json AS CHAR) AS value_json, status
FROM sys_parameter_version
WHERE parameter_group = 'ACCOUNTING_RULE_TEMPLATE'
AND status = 'ACTIVE' AND effective_at IS NOT NULL
AND effective_at <= UTC_TIMESTAMP(3)
AND JSON_UNQUOTE(JSON_EXTRACT(value_json, '$.ruleVersion')) = #{ruleVersion}
ORDER BY version_no DESC
LIMIT 1
""")
RuleParameterRow findAccountingRuleTemplate(String ruleVersion);
@Insert("""
INSERT INTO acc_voucher (
public_id, business_no, event_id, company_id, project_id, rule_version, rule_snapshot_json,
period, business_date,
summary, debit_total, credit_total, status, created_by, updated_by
) VALUES (
#{publicId}, #{businessNo}, #{eventId}, #{companyId}, #{projectId}, #{ruleVersion},
CAST(#{ruleSnapshotJson} AS JSON), #{period}, #{businessDate},
#{summary}, #{amount}, #{amount}, 'DRAFT', #{actorId}, #{actorId}
)
""")
int insertVoucher(@Param("publicId") String publicId,
@Param("businessNo") String businessNo,
@Param("eventId") long eventId,
@Param("companyId") long companyId,
@Param("projectId") Long projectId,
@Param("ruleVersion") String ruleVersion,
@Param("ruleSnapshotJson") String ruleSnapshotJson,
@Param("period") String period,
@Param("businessDate") LocalDate businessDate,
@Param("summary") String summary,
@Param("amount") BigDecimal amount,
@Param("actorId") long actorId);
@Select("SELECT id FROM acc_voucher WHERE public_id = #{publicId}")
Long voucherId(String publicId);
@Insert("""
INSERT INTO acc_voucher_entry
(public_id, voucher_id, line_no, direction, account_id, amount, summary, auxiliary_json)
VALUES
(#{publicId}, #{voucherId}, #{lineNo}, #{direction}, #{accountId}, #{amount}, #{summary},
CASE WHEN #{auxiliaryJson} IS NULL THEN NULL ELSE CAST(#{auxiliaryJson} AS JSON) END)
""")
int insertEntry(@Param("publicId") String publicId,
@Param("voucherId") long voucherId,
@Param("lineNo") int lineNo,
@Param("direction") String direction,
@Param("accountId") long accountId,
@Param("amount") BigDecimal amount,
@Param("summary") String summary,
@Param("auxiliaryJson") String auxiliaryJson);
@Update("""
UPDATE acc_event
SET status = 'DRAFTED', voucher_id = #{voucherId}, version = version + 1
WHERE id = #{eventId} AND version = #{version} AND status = 'PENDING'
""")
int markEventDrafted(@Param("eventId") long eventId,
@Param("voucherId") long voucherId,
@Param("version") long version);
@Update("""
UPDATE acc_voucher
SET period = #{period}, business_date = #{businessDate}, summary = #{summary},
debit_total = #{debitTotal}, credit_total = #{creditTotal},
last_change_reason = #{changeReason}, updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int updateVoucher(@Param("id") long id,
@Param("version") long version,
@Param("period") String period,
@Param("businessDate") LocalDate businessDate,
@Param("summary") String summary,
@Param("debitTotal") BigDecimal debitTotal,
@Param("creditTotal") BigDecimal creditTotal,
@Param("changeReason") String changeReason,
@Param("actorId") long actorId);
@Delete("DELETE FROM acc_voucher_entry WHERE voucher_id = #{voucherId}")
int deleteEntries(long voucherId);
@Insert("""
INSERT INTO acc_voucher_change
(public_id, voucher_id, reason, before_json, after_json, changed_by)
VALUES
(#{publicId}, #{voucherId}, #{reason}, CAST(#{beforeJson} AS JSON), CAST(#{afterJson} AS JSON), #{actorId})
""")
int insertChange(@Param("publicId") String publicId,
@Param("voucherId") long voucherId,
@Param("reason") String reason,
@Param("beforeJson") String beforeJson,
@Param("afterJson") String afterJson,
@Param("actorId") long actorId);
@Update("""
UPDATE acc_voucher
SET status = 'REVIEWING', submitted_by = #{actorId}, reviewed_by = NULL,
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int submitVoucher(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId);
@Update("""
UPDATE acc_voucher
SET status = 'RETURNED', reviewed_by = #{actorId}, last_change_reason = #{opinion},
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING'
""")
int returnVoucher(@Param("id") long id,
@Param("version") long version,
@Param("opinion") String opinion,
@Param("actorId") long actorId);
@Update("""
UPDATE acc_voucher
SET status = 'APPROVED', reviewed_by = #{actorId}, updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING'
""")
int approveVoucher(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId);
@Update("""
UPDATE acc_voucher
SET status = 'EXPORTED', exported_at = UTC_TIMESTAMP(3), export_sha256 = #{sha256},
export_batch_no = #{batchNo}, updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'APPROVED'
""")
int markExported(@Param("id") long id,
@Param("version") long version,
@Param("sha256") String sha256,
@Param("batchNo") String batchNo,
@Param("actorId") long actorId);
@Update("""
UPDATE acc_voucher
SET status = 'RESULT_RECORDED', external_voucher_no = #{externalVoucherNo},
result_at = #{resultAt}, result_record_remark = #{remark}, result_recorded_by = #{actorId},
result_recorded_at = UTC_TIMESTAMP(3), result_verified_by = NULL,
result_verified_at = NULL, result_verify_opinion = NULL,
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'EXPORTED'
""")
int recordResult(@Param("id") long id,
@Param("version") long version,
@Param("externalVoucherNo") String externalVoucherNo,
@Param("resultAt") LocalDate resultAt,
@Param("remark") String remark,
@Param("actorId") long actorId);
@Update("""
UPDATE acc_voucher
SET status = 'COMPLETED', result_verified_by = #{actorId},
result_verified_at = UTC_TIMESTAMP(3), result_verify_opinion = #{opinion},
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'RESULT_RECORDED'
""")
int verifyResult(@Param("id") long id,
@Param("version") long version,
@Param("opinion") String opinion,
@Param("actorId") long actorId);
@Update("""
UPDATE acc_voucher
SET status = 'RESULT_REVERSED', updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'COMPLETED'
""")
int reverseResult(@Param("id") long id,
@Param("version") long version,
@Param("actorId") long actorId);
@Update("""
UPDATE acc_event
SET status = 'DRAFTED', version = version + 1
WHERE id = #{eventId} AND voucher_id = #{voucherId} AND status = 'COMPLETED'
""")
int reopenCompletedEvent(@Param("eventId") long eventId, @Param("voucherId") long voucherId);
@Update("""
UPDATE acc_voucher
SET status = 'EXPORTED', external_voucher_no = NULL, result_at = NULL,
result_recorded_by = NULL, result_recorded_at = NULL, result_record_remark = NULL,
result_verified_by = NULL, result_verified_at = NULL, result_verify_opinion = NULL,
result_cycle_no = result_cycle_no + 1,
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'RESULT_REVERSED'
""")
int reopenResult(@Param("id") long id,
@Param("version") long version,
@Param("actorId") long actorId);
@Update("""
UPDATE acc_voucher
SET status = 'VOID', void_reason = #{reason}, updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int voidVoucher(@Param("id") long id,
@Param("version") long version,
@Param("reason") String reason,
@Param("actorId") long actorId);
@Update("""
UPDATE acc_event SET status = #{status}, version = version + 1
WHERE id = #{eventId} AND status = 'DRAFTED'
""")
int completeEvent(@Param("eventId") long eventId, @Param("status") String status);
@Select("""
SELECT COUNT(*)
FROM acc_voucher_result_action
WHERE company_id = #{companyId} AND period = #{period}
AND historical_external_voucher_no = #{externalVoucherNo}
""")
int historicalExternalVoucherNoExists(@Param("companyId") long companyId,
@Param("period") String period,
@Param("externalVoucherNo") String externalVoucherNo);
@Select("""
SELECT COALESCE(MAX(sequence_no), 0) + 1
FROM acc_voucher_result_action
WHERE voucher_id = #{voucherId}
""")
int nextResultActionSequence(long voucherId);
@Insert("""
INSERT INTO acc_voucher_result_action (
public_id, voucher_id, company_id, period, sequence_no, result_cycle_no, action_type,
reason, historical_external_voucher_no, result_snapshot_json, operated_by,
version_before, version_after
) VALUES (
#{publicId}, #{voucherId}, #{companyId}, #{period}, #{sequenceNo}, #{resultCycleNo}, #{actionType},
#{reason}, #{historicalExternalVoucherNo}, CAST(#{resultSnapshotJson} AS JSON), #{actorId},
#{versionBefore}, #{versionAfter}
)
""")
int insertResultAction(@Param("publicId") String publicId,
@Param("voucherId") long voucherId,
@Param("companyId") long companyId,
@Param("period") String period,
@Param("sequenceNo") int sequenceNo,
@Param("resultCycleNo") int resultCycleNo,
@Param("actionType") String actionType,
@Param("reason") String reason,
@Param("historicalExternalVoucherNo") String historicalExternalVoucherNo,
@Param("resultSnapshotJson") String resultSnapshotJson,
@Param("actorId") long actorId,
@Param("versionBefore") long versionBefore,
@Param("versionAfter") long versionAfter);
@Select("""
SELECT action.id, action.public_id, action.voucher_id, action.sequence_no,
action.result_cycle_no, action.action_type, action.reason,
CAST(action.result_snapshot_json AS CHAR) AS result_snapshot_json,
action.operated_by, operator.display_name AS operated_by_name,
action.operated_at, action.version_before, action.version_after
FROM acc_voucher_result_action action
JOIN iam_user operator ON operator.id = action.operated_by
WHERE action.voucher_id = #{voucherId}
ORDER BY action.sequence_no DESC
""")
List<ResultActionRow> listResultActions(long voucherId);
@Select("""
SELECT action.id, action.public_id, action.voucher_id, action.sequence_no,
action.result_cycle_no, action.action_type, action.reason,
CAST(action.result_snapshot_json AS CHAR) AS result_snapshot_json,
action.operated_by, operator.display_name AS operated_by_name,
action.operated_at, action.version_before, action.version_after
FROM acc_voucher_result_action action
JOIN iam_user operator ON operator.id = action.operated_by
WHERE action.voucher_id = #{voucherId}
ORDER BY action.sequence_no DESC
LIMIT 1
""")
ResultActionRow latestResultAction(long voucherId);
@Update("""
UPDATE acc_event
SET status = 'PENDING', voucher_id = NULL, version = version + 1
WHERE id = #{eventId} AND status = 'DRAFTED' AND voucher_id = #{voucherId}
""")
int releaseVoidedEvent(@Param("eventId") long eventId, @Param("voucherId") long voucherId);
@Select("""
SELECT batch.id, batch.public_id, batch.batch_no, batch.voucher_id, batch.rule_version,
CAST(batch.range_json AS CHAR) AS range_json, file.public_id AS file_public_id,
batch.sha256, batch.row_count, batch.exported_by, batch.exported_at
FROM acc_export_batch batch
JOIN file_object file ON file.id = batch.file_id
WHERE batch.voucher_id = #{voucherId}
""")
ExportBatchRow findExportBatch(long voucherId);
@Insert("""
INSERT INTO acc_export_batch
(public_id, batch_no, voucher_id, rule_version, range_json, file_id, sha256, row_count,
exported_by)
SELECT #{publicId}, #{batchNo}, #{voucherId}, #{ruleVersion}, CAST(#{rangeJson} AS JSON), file.id,
#{sha256}, #{rowCount}, #{actorId}
FROM file_object file
WHERE file.public_id = #{filePublicId} AND file.scan_status = 'AVAILABLE'
""")
int insertExportBatch(@Param("publicId") String publicId,
@Param("batchNo") String batchNo,
@Param("voucherId") long voucherId,
@Param("ruleVersion") String ruleVersion,
@Param("rangeJson") String rangeJson,
@Param("filePublicId") String filePublicId,
@Param("sha256") String sha256,
@Param("rowCount") int rowCount,
@Param("actorId") long actorId);
@Select("""
SELECT file.id, file.public_id, file.original_name, file.scan_status,
(file.uploaded_by = #{actorPublicId}) AS uploaded_by_actor,
EXISTS (
SELECT 1
FROM acc_voucher_result_file result_file
WHERE result_file.voucher_id = #{voucherId} AND result_file.file_id = file.id
) AS attached_to_voucher,
EXISTS (
SELECT 1
FROM file_link link
WHERE link.file_id = file.id AND link.active = TRUE
AND link.archive_status IN ('ACTIVE', 'ARCHIVED', 'FROZEN')
AND (link.company_public_id = #{companyPublicId}
OR link.project_public_id = #{projectPublicId})
) AS linked_to_target
FROM file_object file
WHERE file.public_id = #{publicId}
""")
EvidenceFileRow findEvidenceFile(@Param("publicId") String publicId,
@Param("actorPublicId") String actorPublicId,
@Param("voucherId") long voucherId,
@Param("companyPublicId") String companyPublicId,
@Param("projectPublicId") String projectPublicId);
@Insert("""
INSERT INTO acc_voucher_result_file
(public_id, voucher_id, result_cycle_no, file_id, created_by)
VALUES (#{publicId}, #{voucherId}, #{resultCycleNo}, #{fileId}, #{actorId})
""")
int insertResultFile(@Param("publicId") String publicId,
@Param("voucherId") long voucherId,
@Param("resultCycleNo") int resultCycleNo,
@Param("fileId") long fileId,
@Param("actorId") long actorId);
@Select("""
SELECT file.public_id, file.original_name, file.media_type, file.size_bytes,
file.sha256, file.scan_status
FROM acc_voucher_result_file result_file
JOIN acc_voucher voucher ON voucher.id = result_file.voucher_id
JOIN file_object file ON file.id = result_file.file_id
WHERE result_file.voucher_id = #{voucherId}
AND result_file.result_cycle_no = voucher.result_cycle_no
ORDER BY result_file.created_at, result_file.public_id
""")
List<ResultFileRow> listResultFiles(long voucherId);
@Select("""
SELECT file.public_id, file.original_name, file.media_type, file.size_bytes,
file.sha256, file.scan_status
FROM acc_voucher_result_file result_file
JOIN file_object file ON file.id = result_file.file_id
WHERE result_file.voucher_id = #{voucherId} AND file.public_id = #{filePublicId}
ORDER BY result_file.result_cycle_no DESC
LIMIT 1
""")
ResultFileRow findResultFile(@Param("voucherId") long voucherId,
@Param("filePublicId") String filePublicId);
record AccountRow(long id, String publicId, String code, String name, String accountType,
boolean auxiliaryRequired, String status) {
}
record RuleParameterRow(long id, String publicId, int versionNo, String valueJson, String status) {
}
record EventRow(long id, String publicId, String businessNo, String eventType, String sourceType,
String sourcePublicId, String sourceBusinessNo, String sourceVersion, int eventSequence,
String sourceEventKey,
long companyId, String companyPublicId, String companyCode,
String companyName, Long projectId, String projectPublicId, String projectCode,
String projectName, Long counterpartyId, String counterpartyPublicId,
String counterpartyCode, String counterpartyName, LocalDate businessDate,
String period, BigDecimal amount, String currency, String status, Long voucherId,
String voucherPublicId, String voucherStatus, long version, LocalDateTime createdAt) {
}
record VoucherRow(long id, String publicId, String businessNo, long eventId,
String eventPublicId, String eventBusinessNo, long companyId,
String companyPublicId, String companyCode, String companyName, Long projectId,
String projectPublicId, String projectCode, String projectName, String ruleVersion,
String ruleSnapshotJson, String period,
LocalDate businessDate, String summary, BigDecimal eventAmount, BigDecimal debitTotal,
BigDecimal creditTotal,
String status, Long submittedBy, String submittedByName, Long reviewedBy,
String reviewedByName, LocalDateTime exportedAt, String exportSha256,
String exportBatchNo, String exportFileId, String externalVoucherNo, LocalDate resultAt,
Long resultRecordedBy,
String resultRecordedByName, LocalDateTime resultRecordedAt, String resultRecordRemark,
Long resultVerifiedBy, String resultVerifiedByName, LocalDateTime resultVerifiedAt,
String resultVerifyOpinion, int resultCycleNo,
String voidReason, String lastChangeReason, long createdBy, long updatedBy,
long version, LocalDateTime createdAt, LocalDateTime updatedAt) {
}
record VoucherLock(long id, String publicId, long eventId, long companyId, Long projectId,
String period, LocalDate businessDate, String summary, BigDecimal debitTotal,
BigDecimal creditTotal, String status, Long submittedBy, Long reviewedBy,
Long resultRecordedBy, Long resultVerifiedBy, int resultCycleNo,
long version, long createdBy) {
}
record EntryRow(long id, String publicId, int lineNo, String direction, long accountId,
String accountPublicId, String accountCode, String accountName,
boolean auxiliaryRequired, String accountStatus, BigDecimal amount,
String summary, String auxiliaryJson) {
}
record ExportBatchRow(long id, String publicId, String batchNo, long voucherId, String ruleVersion,
String rangeJson, String filePublicId, String sha256, int rowCount,
long exportedBy, LocalDateTime exportedAt) {
}
record EvidenceFileRow(long id, String publicId, String originalName, String scanStatus,
boolean uploadedByActor, boolean attachedToVoucher, boolean linkedToTarget) {
}
record ResultFileRow(String publicId, String originalName, String mediaType, long sizeBytes,
String sha256, String scanStatus) {
}
record ResultActionRow(long id, String publicId, long voucherId, int sequenceNo,
int resultCycleNo, String actionType, String reason,
String resultSnapshotJson, long operatedBy, String operatedByName,
LocalDateTime operatedAt, long versionBefore, long versionAfter) {
}
}
@@ -0,0 +1,96 @@
package com.kaidi.finance.archive.api;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class ArchiveContracts {
private ArchiveContracts() {
}
public record PackageCreateRequest(
@NotBlank @Size(max = 26) String projectId,
@Min(1) Integer retentionYears,
@Size(max = 500) String physicalLocation
) {
}
public record PackageRevisionRequest(
@NotNull Long version,
@NotBlank @Size(min = 2, max = 1000) String reason
) {
}
public record PackageCommandRequest(
@NotNull Long version,
@Size(max = 1000) String opinion,
@Size(max = 50) List<@Pattern(regexp = "[0-9A-HJKMNP-TV-Z]{26}") String> returnItemIds
) {
}
public record FreezeRequest(
@NotNull Long version,
@NotBlank @Size(max = 1000) String reason
) {
}
public record NotApplicableRequest(
@NotNull Long version,
@NotBlank @Size(min = 2, max = 500) String reason
) {
}
public record NotApplicableReviewRequest(
@NotNull Long version,
@NotNull Boolean approved,
@NotBlank @Size(min = 2, max = 1000) String opinion
) {
}
public record FileLinkMetadata(
@NotBlank @Size(max = 26) String projectId,
@Size(max = 26) String packageId,
@Size(max = 26) String packageItemId,
@Size(max = 26) String contractId,
@Size(max = 26) String counterpartyId,
@Size(max = 32) String formType,
@NotBlank @Size(max = 64) String fileType,
@Size(max = 64) String originalType,
@Size(max = 26) String replacesFileId,
@Size(max = 500) String replacementReason,
@Size(max = 500) String notApplicableReason
) {
}
public record BorrowCreateRequest(
@NotBlank @Size(max = 26) String fileId,
@NotBlank @Size(min = 2, max = 500) String purpose,
@NotNull @FutureOrPresent LocalDateTime dueAt
) {
}
public record BorrowCommandRequest(
@NotNull Long version,
@Size(min = 2, max = 1000) String opinion,
@Size(max = 500) String returnCondition
) {
}
public record ReportExportRequest(
@Size(max = 26) String companyId,
@Size(max = 26) String projectId,
@Size(max = 100) String keyword,
@Size(max = 32) String status,
LocalDate dateFrom,
LocalDate dateTo,
@Size(max = 20) String[] columns
) {
}
}
@@ -0,0 +1,354 @@
package com.kaidi.finance.archive.api;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kaidi.finance.archive.api.ArchiveContracts.BorrowCommandRequest;
import com.kaidi.finance.archive.api.ArchiveContracts.BorrowCreateRequest;
import com.kaidi.finance.archive.api.ArchiveContracts.FileLinkMetadata;
import com.kaidi.finance.archive.api.ArchiveContracts.FreezeRequest;
import com.kaidi.finance.archive.api.ArchiveContracts.NotApplicableRequest;
import com.kaidi.finance.archive.api.ArchiveContracts.NotApplicableReviewRequest;
import com.kaidi.finance.archive.api.ArchiveContracts.PackageCommandRequest;
import com.kaidi.finance.archive.api.ArchiveContracts.PackageCreateRequest;
import com.kaidi.finance.archive.api.ArchiveContracts.PackageRevisionRequest;
import com.kaidi.finance.archive.api.ArchiveViews.ArchiveFileDetailView;
import com.kaidi.finance.archive.api.ArchiveViews.ArchiveFileView;
import com.kaidi.finance.archive.api.ArchiveViews.BorrowView;
import com.kaidi.finance.archive.api.ArchiveViews.PackageView;
import com.kaidi.finance.archive.application.ArchiveApplicationService;
import com.kaidi.finance.shared.api.ApiResponse;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriUtils;
@RestController
@RequestMapping({"/api/v1/archive", "/api/v1/archives"})
public class ArchiveController {
private final ArchiveApplicationService service;
private final IdempotencyApplicationService idempotencyService;
public ArchiveController(ArchiveApplicationService service,
IdempotencyApplicationService idempotencyService) {
this.service = service;
this.idempotencyService = idempotencyService;
}
@GetMapping("/packages")
@Operation(operationId = "listArchivePackages", summary = "查询项目档案包")
@PreAuthorize("@authorizationService.hasPermission('archive:package:view')")
public ApiResponse<List<PackageView>> packages(
@RequestParam(required = false) String view,
@RequestParam(required = false) String companyId,
@RequestParam(required = false) String projectId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String completeness,
@RequestParam(required = false) Boolean frozen,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "updatedAt,desc") String sort,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<PackageView> result = service.listPackages(view, companyId, projectId, status, completeness,
frozen, keyword, sort, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/packages/{publicId}")
@Operation(operationId = "getArchivePackage", summary = "查询档案包详情")
@PreAuthorize("@authorizationService.hasPermission('archive:package:view')")
public ApiResponse<PackageView> packageDetail(@PathVariable String publicId) {
return ApiResponse.ok(service.getPackage(publicId));
}
@PostMapping("/packages")
@Operation(operationId = "createArchivePackage", summary = "生成档案包")
@PreAuthorize("@authorizationService.hasPermission('archive:package:create')")
public ApiResponse<PackageView> createPackage(@Valid @RequestBody PackageCreateRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.createPackage(request)));
}
@PostMapping("/packages/{publicId}/check")
@Operation(operationId = "checkArchivePackage", summary = "检查档案包完整性")
@PreAuthorize("@authorizationService.hasPermission('archive:package:submit')")
public ApiResponse<PackageView> checkPackage(@PathVariable String publicId,
@Valid @RequestBody PackageCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.checkPackage(publicId, request)));
}
@PostMapping("/packages/{publicId}/submit")
@Operation(operationId = "submitArchivePackage", summary = "提交档案包审核")
@PreAuthorize("@authorizationService.hasPermission('archive:package:submit')")
public ApiResponse<PackageView> submitPackage(@PathVariable String publicId,
@Valid @RequestBody PackageCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.submitPackage(publicId, request)));
}
@PostMapping("/packages/{publicId}/return")
@Operation(operationId = "returnArchivePackage", summary = "退回档案包")
@PreAuthorize("@authorizationService.hasPermission('archive:package:review')")
public ApiResponse<PackageView> returnPackage(@PathVariable String publicId,
@Valid @RequestBody PackageCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.returnPackage(publicId, request)));
}
@PostMapping("/packages/{publicId}/revise")
@Operation(operationId = "reviseArchivePackage", summary = "生成档案包补件版本")
@PreAuthorize("@authorizationService.hasPermission('archive:package:submit')")
public ApiResponse<PackageView> revisePackage(@PathVariable String publicId,
@Valid @RequestBody PackageRevisionRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.revisePackage(publicId, request)));
}
@PostMapping("/packages/{publicId}/archive")
@Operation(operationId = "archivePackage", summary = "确认归档")
@PreAuthorize("@authorizationService.hasPermission('archive:package:archive')")
public ApiResponse<PackageView> archivePackage(@PathVariable String publicId,
@Valid @RequestBody PackageCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.archivePackage(publicId, request)));
}
@PostMapping("/packages/{publicId}/freeze")
@Operation(operationId = "freezeArchivePackage", summary = "冻结档案包")
@PreAuthorize("@authorizationService.hasPermission('archive:package:freeze')")
public ApiResponse<PackageView> freezePackage(@PathVariable String publicId,
@Valid @RequestBody FreezeRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.freezePackage(publicId, request)));
}
@PostMapping("/packages/{publicId}/unfreeze")
@Operation(operationId = "unfreezeArchivePackage", summary = "解冻档案包")
@PreAuthorize("@authorizationService.hasPermission('archive:package:unfreeze')")
public ApiResponse<PackageView> unfreezePackage(@PathVariable String publicId,
@Valid @RequestBody PackageCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.unfreezePackage(publicId, request)));
}
@PostMapping("/packages/{packageId}/items/{itemId}/not-applicable")
@Operation(operationId = "markArchiveItemNotApplicable", summary = "申请档案项不涉及")
@PreAuthorize("@authorizationService.hasPermission('archive:package:submit')")
public ApiResponse<PackageView> notApplicable(@PathVariable String packageId, @PathVariable String itemId,
@Valid @RequestBody NotApplicableRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.markNotApplicable(packageId, itemId, request)));
}
@PostMapping("/packages/{packageId}/items/{itemId}/not-applicable/review")
@Operation(operationId = "reviewArchiveItemNotApplicable", summary = "复核档案项不涉及")
@PreAuthorize("@authorizationService.hasPermission('archive:package:na-review')")
public ApiResponse<PackageView> reviewNotApplicable(
@PathVariable String packageId, @PathVariable String itemId,
@Valid @RequestBody NotApplicableReviewRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<PackageView>() { },
() -> service.reviewNotApplicable(packageId, itemId, request)));
}
@PostMapping(value = "/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(operationId = "uploadArchiveFile", summary = "上传档案文件")
@PreAuthorize("@authorizationService.hasPermission('archive:file:upload')")
public ApiResponse<ArchiveFileView> uploadFile(@Valid @RequestPart("metadata") FileLinkMetadata metadata,
@RequestPart("file") MultipartFile file,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
ArchiveFileView result = idempotencyService.executeMultipart(key, httpRequest.getMethod(),
httpRequest.getRequestURI(), metadata, file, new TypeReference<ArchiveFileView>() { },
view -> "QUARANTINED".equals(view.scanStatus()) ? 503 : 200,
() -> service.uploadFile(metadata, file));
if ("QUARANTINED".equals(result.scanStatus())) {
throw new BusinessException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_SCANNER_UNAVAILABLE,
"文件安全检查暂不可用,文件已隔离,请稍后重试");
}
return ApiResponse.ok(result);
}
@GetMapping("/files")
@Operation(operationId = "listArchiveFiles", summary = "查询档案文件")
@PreAuthorize("@authorizationService.hasPermission('archive:file:view')")
public ApiResponse<List<ArchiveFileView>> files(
@RequestParam(required = false) String companyId,
@RequestParam(required = false) String projectId,
@RequestParam(required = false) String contractId,
@RequestParam(required = false) String counterpartyId,
@RequestParam(required = false) String formType,
@RequestParam(required = false) String fileName,
@RequestParam(required = false) String uploadedBy,
@RequestParam(required = false) LocalDate uploadedDateFrom,
@RequestParam(required = false) LocalDate uploadedDateTo,
@RequestParam(required = false) String originalType,
@RequestParam(required = false) String archiveStatus,
@RequestParam(required = false) String scanStatus,
@RequestParam(defaultValue = "uploadedAt,desc") String sort,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<ArchiveFileView> result = service.listFiles(companyId, projectId, contractId, counterpartyId,
formType, fileName, uploadedBy, uploadedDateFrom, uploadedDateTo, originalType, archiveStatus,
scanStatus, sort, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/files/{publicId}")
@Operation(operationId = "getArchiveFile", summary = "查询档案文件详情")
@PreAuthorize("@authorizationService.hasPermission('archive:file:view')")
public ApiResponse<ArchiveFileDetailView> fileDetail(@PathVariable String publicId) {
return ApiResponse.ok(service.getFile(publicId));
}
@PostMapping("/files/{publicId}/rescan")
@Operation(operationId = "rescanArchiveFile", summary = "重新扫描业务范围内的隔离档案")
@PreAuthorize("@authorizationService.hasPermission('archive:file:upload')")
public ApiResponse<ArchiveFileView> rescanFile(
@PathVariable String publicId,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
ArchiveFileView result = idempotencyService.execute(key, httpRequest.getMethod(),
httpRequest.getRequestURI(), Map.of("filePublicId", publicId),
new TypeReference<ArchiveFileView>() { },
view -> "QUARANTINED".equals(view.scanStatus()) ? 503
: "REJECTED".equals(view.scanStatus()) ? 422 : 200,
() -> service.rescanFile(publicId));
if ("QUARANTINED".equals(result.scanStatus())) {
throw new BusinessException(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.FILE_SCANNER_UNAVAILABLE,
"文件安全检查暂不可用,文件仍处于隔离状态");
}
if ("REJECTED".equals(result.scanStatus())) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_REJECTED,
"文件未通过安全检查,已拒绝接收");
}
return ApiResponse.ok(result);
}
@GetMapping("/files/{publicId}/content")
@Operation(operationId = "getArchiveFileContent", summary = "读取档案文件内容")
@PreAuthorize("#preview ? @authorizationService.hasPermission('archive:file:preview') : "
+ "@authorizationService.hasPermission('archive:file:download')")
public ResponseEntity<Resource> fileContent(@PathVariable String publicId,
@RequestParam(defaultValue = "false") boolean preview) {
ArchiveApplicationService.ControlledDownload controlled = service.downloadFile(publicId, preview);
var content = controlled.content();
ContentDisposition disposition = (controlled.preview() ? ContentDisposition.inline() : ContentDisposition.attachment())
.filename(content.fileName(), StandardCharsets.UTF_8).build();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(content.mediaType()))
.contentLength(content.bytes().length)
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.header("X-Archive-Watermark", UriUtils.encode(controlled.watermark(), StandardCharsets.UTF_8))
.header("X-Archive-Watermark-Encoding", "uri-component")
.header("X-File-SHA256", content.sourceSha256())
.header("X-Derived-File-SHA256", content.derivedSha256())
.header("X-Content-Type-Options", "nosniff")
.body(new ByteArrayResource(content.bytes()));
}
@GetMapping("/borrows")
@Operation(operationId = "listBorrows", summary = "查询档案借阅")
@PreAuthorize("@authorizationService.hasPermission('archive:file:view')")
public ApiResponse<List<BorrowView>> borrows(@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<BorrowView> result = service.listBorrows(status, keyword, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@PostMapping("/borrows")
@Operation(operationId = "createBorrow", summary = "申请档案借阅")
@PreAuthorize("@authorizationService.hasPermission('archive:borrow:apply')")
public ApiResponse<BorrowView> createBorrow(@Valid @RequestBody BorrowCreateRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<BorrowView>() { },
() -> service.createBorrow(request)));
}
@PostMapping("/borrows/{publicId}/approve")
@Operation(operationId = "approveBorrow", summary = "批准档案借阅")
@PreAuthorize("@authorizationService.hasPermission('archive:borrow:approve')")
public ApiResponse<BorrowView> approveBorrow(@PathVariable String publicId,
@Valid @RequestBody BorrowCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<BorrowView>() { },
() -> service.approveBorrow(publicId, request)));
}
@PostMapping("/borrows/{publicId}/reject")
@Operation(operationId = "rejectBorrow", summary = "驳回档案借阅")
@PreAuthorize("@authorizationService.hasPermission('archive:borrow:reject')")
public ApiResponse<BorrowView> rejectBorrow(@PathVariable String publicId,
@Valid @RequestBody BorrowCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<BorrowView>() { },
() -> service.rejectBorrow(publicId, request)));
}
@PostMapping("/borrows/{publicId}/return")
@Operation(operationId = "returnBorrow", summary = "登记档案归还")
@PreAuthorize("@authorizationService.hasPermission('archive:borrow:return')")
public ApiResponse<BorrowView> returnBorrow(@PathVariable String publicId,
@Valid @RequestBody BorrowCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, new TypeReference<BorrowView>() { },
() -> service.returnBorrow(publicId, request)));
}
private <T> T command(String key, HttpServletRequest request, Object body, TypeReference<T> type,
java.util.function.Supplier<T> action) {
return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body, type, action);
}
}
@@ -0,0 +1,206 @@
package com.kaidi.finance.archive.api;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
public final class ArchiveViews {
private ArchiveViews() {
}
public record ReferenceView(String publicId, String businessNo, String name) {
}
public record PackageItemView(
String publicId,
String itemType,
String itemName,
String objectType,
String objectPublicId,
String filePublicId,
String fileName,
String fileSha256,
boolean required,
String status,
String missingReason,
String returnReason,
String notApplicableReason,
String notApplicableRequestedByName,
LocalDateTime notApplicableRequestedAt,
String notApplicableReviewedByName,
LocalDateTime notApplicableReviewedAt,
String notApplicableReviewOpinion,
List<String> allowedActions
) {
}
public record PackageActionView(
int sequenceNo,
String actionCode,
String fromStatus,
String toStatus,
String opinion,
String actorName,
LocalDateTime occurredAt,
String manifestSha256
) {
}
public record PackageObjectSnapshotView(
String publicId,
String itemType,
String objectType,
String objectPublicId,
String objectBusinessNo,
String objectName,
String objectStatus,
Long objectVersion,
String objectFormType,
String sourceVersionPublicId,
String snapshotSha256,
LocalDateTime capturedAt
) {
}
public record PackageView(
String publicId,
String businessNo,
ReferenceView company,
ReferenceView project,
int packageVersion,
String rootPackagePublicId,
String supersedesPackagePublicId,
String revisionReason,
String ruleVersion,
String completeness,
int missingCount,
int retentionYears,
LocalDate retentionExpiresOn,
String retentionStatus,
String physicalLocation,
boolean frozen,
String status,
String manifestSha256,
String submittedByName,
String reviewedByName,
LocalDateTime submittedAt,
LocalDateTime archivedAt,
LocalDateTime updatedAt,
long version,
List<String> allowedActions,
List<PackageItemView> items,
List<PackageObjectSnapshotView> objectSnapshots,
List<PackageActionView> actions
) {
}
public record ArchiveFileView(
String publicId,
String displayName,
String mediaType,
String originalType,
long sizeBytes,
String sha256,
String scanStatus,
String companyPublicId,
String companyName,
String projectPublicId,
String projectBusinessNo,
String projectName,
String contractPublicId,
String counterpartyPublicId,
String formType,
String documentType,
String seriesPublicId,
int versionNo,
String archiveStatus,
String borrowStatus,
String packagePublicId,
String packageStatus,
String uploadedBy,
LocalDateTime uploadedAt,
boolean sensitive,
List<String> allowedActions
) {
}
public record ArchiveFileDetailView(
ArchiveFileView file,
List<ArchiveFileView> versions,
List<BorrowView> borrows
) {
}
public record BorrowView(
String publicId,
String businessNo,
String filePublicId,
String fileName,
String projectPublicId,
String projectName,
String purpose,
LocalDateTime dueAt,
String status,
String applicantName,
String approvedByName,
LocalDateTime approvedAt,
String reviewOpinion,
String returnCondition,
String returnedByName,
LocalDateTime returnedAt,
long version,
List<String> allowedActions
) {
}
public record ArchiveContentView(
String filePublicId,
String displayName,
String mediaType,
long sizeBytes,
String sha256,
String watermark,
boolean preview
) {
}
public record ReportRow(
String rowId,
Map<String, Object> values
) {
}
public record ReportView(
String reportCode,
String definitionVersion,
String filterHash,
List<String> columns,
List<ReportRow> rows,
Map<String, String> summary,
long totalElements,
int page,
int size,
int totalPages,
List<String> allowedActions
) {
}
public record ReportDrilldownView(
String reportCode,
String rowId,
List<Map<String, Object>> groups
) {
}
public record ReportExportView(
String exportId,
String reportCode,
String filterHash,
int rowCount,
String sha256,
String content
) {
}
}
@@ -0,0 +1,234 @@
package com.kaidi.finance.archive.application;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.file.FileApplicationService;
import com.kaidi.finance.shared.file.FileObjectRecord;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;
import javax.imageio.ImageIO;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.graphics.blend.BlendMode;
import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.util.Matrix;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@Service
public class ArchiveWatermarkService {
private static final long MAX_IMAGE_PIXELS = 40_000_000L;
private static final int MAX_PDF_PAGES = 2_000;
private static final String VISIBLE_PREFIX = "KAIDI CONTROLLED COPY";
private final FileApplicationService fileService;
public ArchiveWatermarkService(FileApplicationService fileService) {
this.fileService = fileService;
}
public WatermarkedContent render(FileObjectRecord source, String watermark) {
byte[] original = fileService.readAvailable(source.getPublicId());
String extension = source.getExtension().toLowerCase(Locale.ROOT);
String visibleText = VISIBLE_PREFIX + " | " + asciiWatermark(watermark);
try {
return switch (extension) {
case "pdf" -> content(watermarkPdfOrControlledCopy(source, original, visibleText), "application/pdf",
controlledName(source.getOriginalName(), "pdf"), source.getSha256());
case "jpg", "jpeg" -> content(watermarkImage(original, visibleText, "jpg"), "image/jpeg",
controlledName(source.getOriginalName(), extension), source.getSha256());
case "png" -> content(watermarkImage(original, visibleText, "png"), "image/png",
controlledName(source.getOriginalName(), "png"), source.getSha256());
default -> controlledPdf(source, original, visibleText);
};
} catch (BusinessException exception) {
throw exception;
} catch (IOException | RuntimeException exception) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_WATERMARK_UNSUPPORTED,
"文件内容无法生成受控水印副本");
}
}
private byte[] watermarkPdfOrControlledCopy(FileObjectRecord source, byte[] original, String text) throws IOException {
try {
return watermarkPdf(original, text);
} catch (IOException | RuntimeException exception) {
return controlledPdf(source, original, text).bytes();
}
}
private byte[] watermarkPdf(byte[] original, String text) throws IOException {
try (PDDocument document = Loader.loadPDF(original)) {
if (document.getNumberOfPages() > MAX_PDF_PAGES) {
throw unsupported("PDF 页数超过受控预览限制");
}
var font = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
for (PDPage page : document.getPages()) {
float width = page.getMediaBox().getWidth();
float height = page.getMediaBox().getHeight();
try (PDPageContentStream stream = new PDPageContentStream(document, page,
PDPageContentStream.AppendMode.APPEND, true, true)) {
PDExtendedGraphicsState state = new PDExtendedGraphicsState();
state.setNonStrokingAlphaConstant(0.16f);
state.setBlendMode(BlendMode.MULTIPLY);
stream.setGraphicsStateParameters(state);
stream.setNonStrokingColor(new Color(176, 31, 45));
for (float y = 40; y < height + width; y += 150) {
stream.beginText();
stream.setFont(font, 10);
stream.setTextMatrix(Matrix.getRotateInstance(Math.toRadians(32), -80, y));
stream.showText(text);
stream.endText();
}
stream.beginText();
stream.setFont(font, 7);
stream.newLineAtOffset(18, 12);
stream.showText(text);
stream.endText();
}
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
document.save(output);
return output.toByteArray();
}
}
private byte[] watermarkImage(byte[] original, String text, String format) throws IOException {
BufferedImage decoded = ImageIO.read(new java.io.ByteArrayInputStream(original));
if (decoded == null || (long) decoded.getWidth() * decoded.getHeight() > MAX_IMAGE_PIXELS) {
throw unsupported("图片尺寸超过受控预览限制或格式无效");
}
int type = "jpg".equals(format) ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB;
BufferedImage outputImage = new BufferedImage(decoded.getWidth(), decoded.getHeight(), type);
Graphics2D graphics = outputImage.createGraphics();
try {
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, decoded.getWidth(), decoded.getHeight());
graphics.drawImage(decoded, 0, 0, null);
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.22f));
graphics.setColor(new Color(176, 31, 45));
int fontSize = Math.max(12, Math.min(28, decoded.getWidth() / 32));
graphics.setFont(new Font(Font.SANS_SERIF, Font.BOLD, fontSize));
FontMetrics metrics = graphics.getFontMetrics();
int textWidth = metrics.stringWidth(text);
AffineTransform originalTransform = graphics.getTransform();
graphics.rotate(-Math.PI / 6, decoded.getWidth() / 2.0, decoded.getHeight() / 2.0);
for (int y = -decoded.getHeight(); y < decoded.getHeight() * 2; y += fontSize * 6) {
for (int x = -decoded.getWidth(); x < decoded.getWidth() * 2; x += textWidth + fontSize * 5) {
graphics.drawString(text, x, y);
}
}
graphics.setTransform(originalTransform);
} finally {
graphics.dispose();
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
if (!ImageIO.write(outputImage, format, output)) throw unsupported("图片格式不支持写入水印");
return output.toByteArray();
}
private WatermarkedContent controlledPdf(FileObjectRecord source, byte[] original, String text) throws IOException {
try (PDDocument document = new PDDocument()) {
PDPage page = new PDPage();
document.addPage(page);
var regular = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
var bold = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
stream.setNonStrokingColor(new Color(176, 31, 45));
stream.beginText();
stream.setFont(bold, 16);
stream.newLineAtOffset(48, 730);
stream.showText("CONTROLLED ARCHIVE COPY");
stream.endText();
stream.setNonStrokingColor(Color.DARK_GRAY);
String[] lines = {
"Original file: " + asciiWatermark(source.getOriginalName()),
"Original format: " + source.getExtension().toUpperCase(Locale.ROOT),
"Original bytes: " + original.length,
"Original SHA-256: " + source.getSha256(),
"Access watermark: " + text,
"The original binary is attached to this controlled PDF."
};
float y = 690;
for (String line : lines) {
stream.beginText();
stream.setFont(regular, 10);
stream.newLineAtOffset(48, y);
stream.showText(truncate(line, 100));
stream.endText();
y -= 24;
}
}
var names = document.getDocumentCatalog().getNames();
if (names == null) {
names = new org.apache.pdfbox.pdmodel.PDDocumentNameDictionary(document.getDocumentCatalog());
document.getDocumentCatalog().setNames(names);
}
var embeddedFiles = new org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode();
var embedded = new org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile(document,
new java.io.ByteArrayInputStream(original));
embedded.setSize(original.length);
var specification = new org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification();
specification.setFile(source.getOriginalName());
specification.setEmbeddedFile(embedded);
embeddedFiles.setNames(java.util.Map.of(source.getOriginalName(), specification));
names.setEmbeddedFiles(embeddedFiles);
ByteArrayOutputStream output = new ByteArrayOutputStream();
document.save(output);
return content(output.toByteArray(), "application/pdf",
controlledName(source.getOriginalName(), "pdf"), source.getSha256());
}
}
private static WatermarkedContent content(byte[] bytes, String mediaType, String fileName, String sourceSha256) {
return new WatermarkedContent(bytes, mediaType, fileName, sourceSha256, sha256(bytes));
}
private static String controlledName(String original, String extension) {
int dot = original.lastIndexOf('.');
String base = dot > 0 ? original.substring(0, dot) : original;
return base + "-受控副本." + extension;
}
private static String asciiWatermark(String value) {
return value == null ? "" : value.replaceAll("[^\\x20-\\x7E]", "?");
}
private static String truncate(String value, int maximum) {
return value.length() <= maximum ? value : value.substring(0, maximum);
}
private static BusinessException unsupported(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.FILE_WATERMARK_UNSUPPORTED, message);
}
private static String sha256(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
public record WatermarkedContent(byte[] bytes, String mediaType, String fileName,
String sourceSha256, String derivedSha256) {
}
}
@@ -0,0 +1,28 @@
package com.kaidi.finance.audit.api;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.Instant;
public final class AuditContracts {
private AuditContracts() {
}
public record AuditExportRequest(
Instant occurredFrom,
Instant occurredTo,
@Size(max = 26) String actorId,
@Size(max = 40) String identityCode,
@Size(max = 80) String objectType,
@Size(max = 26) String objectId,
@Size(max = 100) String action,
@Size(max = 26) String requestId,
@Size(max = 40) String result,
@Size(max = 100) String keyword,
@Schema(allowableValues = {"occurredAt,asc", "occurredAt,desc", "eventSequence,asc", "eventSequence,desc"})
@Pattern(regexp = "(occurredAt|eventSequence),(asc|desc)") String sort
) {
}
}
@@ -0,0 +1,100 @@
package com.kaidi.finance.audit.api;
import com.kaidi.finance.audit.api.AuditContracts.AuditExportRequest;
import com.kaidi.finance.audit.api.AuditViews.AuditExportView;
import com.kaidi.finance.audit.api.AuditViews.AuditLogView;
import com.kaidi.finance.audit.application.AuditApplicationService;
import com.kaidi.finance.shared.api.ApiResponse;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value = "/api/v1/audit", produces = MediaType.APPLICATION_JSON_VALUE)
public class AuditController {
private static final Set<String> LIST_QUERY_PARAMS = Set.of(
"occurredFrom", "occurredTo", "actorId", "identityCode", "objectType", "objectId",
"action", "requestId", "result", "keyword", "sort", "page", "size"
);
private final AuditApplicationService service;
public AuditController(AuditApplicationService service) {
this.service = service;
}
@GetMapping("/logs")
@Operation(operationId = "listAuditLogs", summary = "查询审计日志")
@PreAuthorize("@authorizationService.hasPermission('audit:log:view')")
public ApiResponse<List<AuditLogView>> logs(
@RequestParam(required = false) Instant occurredFrom,
@RequestParam(required = false) Instant occurredTo,
@RequestParam(required = false) @Size(max = 26) String actorId,
@RequestParam(required = false) @Size(max = 40) String identityCode,
@RequestParam(required = false) @Size(max = 80) String objectType,
@RequestParam(required = false) @Size(max = 26) String objectId,
@RequestParam(required = false) @Size(max = 100) String action,
@RequestParam(required = false) @Size(max = 26) String requestId,
@Parameter(schema = @Schema(allowableValues = {"SUCCESS", "FAILED", "DENIED", "BLOCKED"}))
@RequestParam(required = false) @Size(max = 40) String result,
@RequestParam(required = false) @Size(max = 100) String keyword,
@Parameter(schema = @Schema(allowableValues = {
"occurredAt,asc", "occurredAt,desc", "eventSequence,asc", "eventSequence,desc"
}))
@RequestParam(defaultValue = "occurredAt,desc")
@Pattern(regexp = "(occurredAt|eventSequence),(asc|desc)") String sort,
@RequestParam(defaultValue = "1") int page,
@Parameter(schema = @Schema(type = "integer", format = "int32", allowableValues = {"20", "50", "100"}))
@RequestParam(defaultValue = "20") int size,
@Parameter(hidden = true) @RequestParam MultiValueMap<String, String> query) {
validateQuery(query);
PageResult<AuditLogView> resultPage = service.list(occurredFrom, occurredTo, actorId, identityCode,
objectType, objectId, action, requestId, result, keyword, sort, page, size);
return ApiResponse.ok(resultPage.items(), resultPage.meta());
}
@GetMapping("/logs/{publicId}")
@Operation(operationId = "getAuditLog", summary = "读取审计日志详情")
@PreAuthorize("@authorizationService.hasPermission('audit:log:view')")
public ApiResponse<AuditLogView> detail(@PathVariable String publicId) {
return ApiResponse.ok(service.detail(publicId));
}
@PostMapping("/exports")
@Operation(operationId = "exportAuditLogs", summary = "导出审计日志")
@PreAuthorize("@authorizationService.hasPermission('audit:log:export')")
public ApiResponse<AuditExportView> export(@Valid @RequestBody(required = false) AuditExportRequest request) {
return ApiResponse.ok(service.export(request));
}
private static void validateQuery(Map<String, ?> query) {
for (String name : query.keySet()) {
if (!LIST_QUERY_PARAMS.contains(name)) {
throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID,
"不支持的查询参数: " + name);
}
}
}
}
@@ -0,0 +1,34 @@
package com.kaidi.finance.audit.api;
import java.time.Instant;
import java.util.List;
public final class AuditViews {
private AuditViews() {
}
public record AuditLogView(
String publicId,
long eventSequence,
String requestId,
String userPublicId,
String username,
String activeRole,
String companyPublicId,
String projectPublicId,
String actionCode,
String objectType,
String objectPublicId,
String resultCode,
String reason,
String beforeJson,
String afterJson,
Instant occurredAt,
List<String> allowedActions
) {
}
public record AuditExportView(String exportId, int rowCount, String sha256, String fileName, String content) {
}
}
@@ -0,0 +1,302 @@
package com.kaidi.finance.audit.application;
import com.kaidi.finance.audit.api.AuditContracts.AuditExportRequest;
import com.kaidi.finance.audit.api.AuditViews.AuditExportView;
import com.kaidi.finance.audit.api.AuditViews.AuditLogView;
import com.kaidi.finance.audit.infrastructure.AuditQueryMapper;
import com.kaidi.finance.audit.infrastructure.AuditQueryMapper.AuditRow;
import com.kaidi.finance.iam.domain.FinancePrincipal;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.id.UlidGenerator;
import com.kaidi.finance.shared.infrastructure.RequestContext;
import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuditApplicationService {
private static final Set<String> RESULT_CODES = Set.of("SUCCESS", "FAILED", "DENIED", "BLOCKED");
private static final Set<String> SORTS = Set.of(
"occurredAt,asc", "occurredAt,desc", "eventSequence,asc", "eventSequence,desc"
);
private static final String VIEW_PERMISSION = "audit:log:view";
private static final String EXPORT_PERMISSION = "audit:log:export";
private static final int EXPORT_ROW_LIMIT = 50_000;
private final AuditQueryMapper mapper;
private final AuthorizationService authorizationService;
private final IdentityContext identityContext;
private final AuditService auditService;
private final UlidGenerator ulidGenerator;
private final ObjectMapper objectMapper;
public AuditApplicationService(AuditQueryMapper mapper, AuthorizationService authorizationService,
IdentityContext identityContext, AuditService auditService,
UlidGenerator ulidGenerator, ObjectMapper objectMapper) {
this.mapper = mapper;
this.authorizationService = authorizationService;
this.identityContext = identityContext;
this.auditService = auditService;
this.ulidGenerator = ulidGenerator;
this.objectMapper = objectMapper;
}
@Transactional(readOnly = true)
public PageResult<AuditLogView> list(Instant occurredFrom, Instant occurredTo, String actorId,
String identityCode, String objectType, String objectId, String action,
String requestId, String result, String keyword, String sort,
int page, int size) {
authorizationService.requirePermission(VIEW_PERMISSION);
Page requested = page(page, size);
String safeRequestId = clean(requestId);
TimeRange range = timeRange(occurredFrom, occurredTo, safeRequestId != null);
Filter filter = new Filter(range.from(), range.to(), clean(actorId), clean(identityCode), clean(objectType),
clean(objectId), clean(action), safeRequestId, cleanResult(result), clean(keyword), cleanSort(sort));
Actor actor = actor();
LocalDateTime scopeAsOf = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS);
List<AuditLogView> rows = mapper.list(actor.userId(), actor.roleCode(), VIEW_PERMISSION, scopeAsOf,
filter.from(), filter.to(),
filter.actorId(), filter.identityCode(), filter.objectType(), filter.objectId(), filter.action(),
filter.requestId(), filter.result(), filter.keyword(), filter.sort(), requested.size(), requested.offset()).stream()
.map(this::view).toList();
long total = mapper.count(actor.userId(), actor.roleCode(), VIEW_PERMISSION, scopeAsOf, filter.from(), filter.to(), filter.actorId(),
filter.identityCode(), filter.objectType(), filter.objectId(), filter.action(), filter.requestId(),
filter.result(), filter.keyword());
return new PageResult<>(rows, total, requested.page(), requested.size());
}
@Transactional(readOnly = true)
public AuditLogView detail(String publicId) {
authorizationService.requirePermission(VIEW_PERMISSION);
AuditRow row = mapper.findByPublicId(publicId);
if (row == null) throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND,
"审计记录不存在");
// A detail lookup must enforce the same company/project data scope as the
// paged query. The public id is user supplied and is not a scope grant;
// checking only the permission here would allow a caller to read an
// out-of-scope audit row by guessing/obtaining its id from another flow.
authorizationService.requireScope(VIEW_PERMISSION, row.companyPublicId(), row.projectPublicId());
return view(row);
}
@Transactional(isolation = Isolation.REPEATABLE_READ)
public AuditExportView export(AuditExportRequest request) {
authorizationService.requirePermission(EXPORT_PERMISSION);
AuditExportRequest safe = request == null
? new AuditExportRequest(null, null, null, null, null, null, null, null, null, null, null)
: request;
String safeRequestId = clean(safe.requestId());
TimeRange range = timeRange(safe.occurredFrom(), safe.occurredTo(), safeRequestId != null);
Filter filter = new Filter(range.from(), range.to(), clean(safe.actorId()), clean(safe.identityCode()),
clean(safe.objectType()), clean(safe.objectId()), clean(safe.action()), safeRequestId,
cleanResult(safe.result()), clean(safe.keyword()), cleanSort(safe.sort()));
Actor actor = actor();
LocalDateTime scopeAsOf = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS);
long total = mapper.count(actor.userId(), actor.roleCode(), EXPORT_PERMISSION, scopeAsOf, filter.from(),
filter.to(), filter.actorId(), filter.identityCode(), filter.objectType(), filter.objectId(), filter.action(),
filter.requestId(), filter.result(), filter.keyword());
if (total > EXPORT_ROW_LIMIT) {
throw validation("审计导出结果超过 50000 行上限,请缩小筛选范围");
}
List<AuditLogView> rows = mapper.list(actor.userId(), actor.roleCode(), EXPORT_PERMISSION, scopeAsOf,
filter.from(), filter.to(), filter.actorId(), filter.identityCode(), filter.objectType(),
filter.objectId(), filter.action(), filter.requestId(), filter.result(), filter.keyword(),
filter.sort(), Math.toIntExact(total), 0)
.stream().map(this::view).toList();
if (rows.size() != total) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT,
"审计导出快照已变化,请重新执行导出");
}
String content = "\uFEFF" + csv(rows);
String sha256 = sha256(content);
String exportId = ulidGenerator.next();
mapper.insertExportLog(exportId, RequestContext.requestId(), actor.publicId(), actor.roleCode(),
json(filter.values()), json(List.of("eventSequence", "occurredAt", "username", "activeRole", "actionCode", "objectType",
"objectPublicId", "resultCode", "requestId")), rows.size(), sha256);
auditService.record("AUDIT_LOG_EXPORT", "AUDIT_LOG", null, "SUCCESS", null,
null, Map.of("exportId", exportId, "rowCount", rows.size(), "sha256", sha256));
return new AuditExportView(exportId, rows.size(), sha256, "audit-logs.csv", content);
}
private AuditLogView view(AuditRow row) {
return new AuditLogView(row.publicId(), row.eventSequence(), row.requestId(), row.userPublicId(), row.username(), row.activeRole(),
row.companyPublicId(), row.projectPublicId(), row.actionCode(), row.objectType(), row.objectPublicId(),
row.resultCode(), maskReason(row.reason()), mask(row.beforeJson()), mask(row.afterJson()),
row.occurredAt().toInstant(ZoneOffset.UTC),
List.of("VIEW"));
}
private String mask(String value) {
if (value == null || value.isBlank()) return value;
try {
JsonNode node = objectMapper.readTree(value);
maskNode(node);
return objectMapper.writeValueAsString(node);
} catch (Exception ignored) {
return value.replaceAll("(?i)(password|token|accountNo|idCard|phone|salary)\\\"?\\s*:\\s*\\\"[^\\\"]*\\\"",
"$1:\"***\"");
}
}
private String maskReason(String value) {
if (value == null || value.isBlank()) return value;
return value.replaceAll("(?<!\\d)\\d{7,15}(\\d{4})(?!\\d)", "***$1");
}
private void maskNode(JsonNode node) {
if (node == null) return;
if (node.isObject()) {
node.fieldNames().forEachRemaining(name -> {
JsonNode child = node.get(name);
if (name.toLowerCase().matches(".*(password|token|account|idcard|phone|salary|secret).*")) {
((com.fasterxml.jackson.databind.node.ObjectNode) node).put(name, "***");
} else {
maskNode(child);
}
});
} else if (node.isArray()) {
node.forEach(this::maskNode);
}
}
private static String csv(List<AuditLogView> rows) {
StringBuilder output = new StringBuilder(
"eventSequence,occurredAt,username,activeRole,actionCode,objectType,objectPublicId,resultCode,requestId\n"
);
for (AuditLogView row : rows) {
List<String> values = java.util.Arrays.asList(String.valueOf(row.eventSequence()),
String.valueOf(row.occurredAt()), row.username(),
row.activeRole(), row.actionCode(), row.objectType(), row.objectPublicId(), row.resultCode(),
row.requestId());
output.append(values.stream().map(AuditApplicationService::csvCell).reduce((a, b) -> a + "," + b).orElse(""))
.append('\n');
}
return output.toString();
}
private static String csvCell(String value) {
String safe = value == null ? "" : value;
String leading = safe.stripLeading();
if (!leading.isEmpty() && "=+-@".indexOf(leading.charAt(0)) >= 0) safe = "'" + safe;
return "\"" + safe.replace("\"", "\"\"") + "\"";
}
private static String sha256(String value) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is not available", exception);
}
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Audit export filter cannot be serialized", exception);
}
}
private Actor actor() {
FinancePrincipal principal = identityContext.requirePrincipal();
return new Actor(principal.userId(), principal.publicId(), identityContext.requireActiveRole());
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private static String cleanResult(String value) {
String result = clean(value);
if (result != null && !RESULT_CODES.contains(result)) throw validation("审计结果参数无效");
return result;
}
private static String cleanSort(String value) {
String sort = clean(value);
if (sort == null) return "occurredAt,desc";
if (!SORTS.contains(sort)) throw validation("审计排序参数无效");
return sort;
}
/**
* The API accepts UTC instants and the database stores UTC timestamps. Keeping
* the conversion at this boundary prevents an offset-less local value from
* silently changing the requested half-open interval.
*/
private static TimeRange timeRange(Instant from, Instant to, boolean fullRequestHistory) {
Instant end = to == null ? Instant.now(Clock.systemUTC()) : to;
// A request-id lookup reconstructs the whole chain unless the caller explicitly narrows its start time.
Instant start = from == null
? (fullRequestHistory ? Instant.EPOCH : end.minusSeconds(24 * 60 * 60))
: from;
if (!end.isAfter(start)) throw validation("审计时间范围无效");
return new TimeRange(LocalDateTime.ofInstant(start, ZoneOffset.UTC),
LocalDateTime.ofInstant(end, ZoneOffset.UTC));
}
private static Page page(int page, int size) {
if (page < 1 || !Set.of(20, 50, 100).contains(size)) throw validation("分页参数无效");
return new Page(page, size);
}
private static BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
}
private record Actor(long userId, String publicId, String roleCode) {
}
private record TimeRange(LocalDateTime from, LocalDateTime to) {
}
private record Filter(LocalDateTime from, LocalDateTime to, String actorId, String identityCode,
String objectType, String objectId, String action, String requestId,
String result, String keyword, String sort) {
Map<String, Object> values() {
Map<String, Object> values = new LinkedHashMap<>();
values.put("occurredFrom", from.toInstant(ZoneOffset.UTC));
values.put("occurredTo", to.toInstant(ZoneOffset.UTC));
values.put("actorId", actorId);
values.put("identityCode", identityCode);
values.put("objectType", objectType);
values.put("objectId", objectId);
values.put("action", action);
values.put("requestId", requestId);
values.put("result", result);
values.put("keyword", keyword);
values.put("sort", sort);
return values;
}
}
private record Page(int page, int size) {
int offset() {
long value = (long) (page - 1) * size;
return value > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value;
}
}
}
@@ -0,0 +1,104 @@
package com.kaidi.finance.audit.infrastructure;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@org.apache.ibatis.annotations.Mapper
public interface AuditQueryMapper {
String SCOPE = """
EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = #{scopePermission} AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= #{scopeAsOf}
AND (scope.valid_to IS NULL OR scope.valid_to &gt;= #{scopeAsOf})
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = audit.company_public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = audit.project_public_id))
)
""";
String FILTERS = """
AND audit.created_at &gt;= #{occurredFrom}
AND audit.created_at &lt; #{occurredTo}
<if test="actorId != null">AND audit.user_public_id = #{actorId}</if>
<if test="identityCode != null">AND audit.active_role = #{identityCode}</if>
<if test="objectType != null">AND audit.object_type = #{objectType}</if>
<if test="objectId != null">AND audit.object_public_id = #{objectId}</if>
<if test="action != null">AND audit.action_code = #{action}</if>
<if test="requestId != null">AND audit.request_id = #{requestId}</if>
<if test="result != null">AND audit.result_code = #{result}</if>
<if test="keyword != null">AND (audit.username LIKE CONCAT('%', #{keyword}, '%')
OR audit.action_code LIKE CONCAT('%', #{keyword}, '%')
OR audit.object_public_id LIKE CONCAT('%', #{keyword}, '%')
OR audit.request_id LIKE CONCAT('%', #{keyword}, '%'))</if>
""";
String ORDERING = """
<choose>
<when test="sort == 'occurredAt,asc'">ORDER BY audit.created_at ASC, audit.id ASC</when>
<when test="sort == 'eventSequence,asc'">ORDER BY audit.id ASC</when>
<when test="sort == 'eventSequence,desc'">ORDER BY audit.id DESC</when>
<otherwise>ORDER BY audit.created_at DESC, audit.id DESC</otherwise>
</choose>
""";
@Select("<script>SELECT audit.id AS event_sequence, audit.public_id, audit.request_id, audit.user_public_id, "
+ "audit.username, audit.active_role, "
+ "audit.company_public_id, audit.project_public_id, audit.action_code, audit.object_type, "
+ "audit.object_public_id, audit.result_code, audit.reason, CAST(audit.before_json AS CHAR) AS before_json, "
+ "CAST(audit.after_json AS CHAR) AS after_json, audit.created_at FROM audit_log audit WHERE "
+ SCOPE + FILTERS + ORDERING + " LIMIT #{limit} OFFSET #{offset}</script>")
List<AuditRow> list(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("scopePermission") String scopePermission,
@Param("scopeAsOf") LocalDateTime scopeAsOf,
@Param("occurredFrom") LocalDateTime occurredFrom,
@Param("occurredTo") LocalDateTime occurredTo, @Param("actorId") String actorId,
@Param("identityCode") String identityCode, @Param("objectType") String objectType,
@Param("objectId") String objectId, @Param("action") String action,
@Param("requestId") String requestId, @Param("result") String result,
@Param("keyword") String keyword, @Param("sort") String sort, @Param("limit") int limit,
@Param("offset") int offset);
@Select("<script>SELECT COUNT(*) FROM audit_log audit WHERE " + SCOPE + FILTERS + "</script>")
long count(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("scopePermission") String scopePermission,
@Param("scopeAsOf") LocalDateTime scopeAsOf,
@Param("occurredFrom") LocalDateTime occurredFrom, @Param("occurredTo") LocalDateTime occurredTo,
@Param("actorId") String actorId, @Param("identityCode") String identityCode,
@Param("objectType") String objectType, @Param("objectId") String objectId,
@Param("action") String action, @Param("requestId") String requestId,
@Param("result") String result, @Param("keyword") String keyword);
@Select("""
SELECT audit.id AS event_sequence, audit.public_id, audit.request_id, audit.user_public_id, audit.username, audit.active_role,
audit.company_public_id, audit.project_public_id, audit.action_code, audit.object_type,
audit.object_public_id, audit.result_code, audit.reason, CAST(audit.before_json AS CHAR) AS before_json,
CAST(audit.after_json AS CHAR) AS after_json, audit.created_at
FROM audit_log audit WHERE audit.public_id = #{publicId}
""")
AuditRow findByPublicId(String publicId);
@Insert("""
INSERT INTO export_log (public_id, request_id, actor_public_id, identity_code, resource_type,
filter_json, columns_json, row_count, sha256)
VALUES (#{publicId}, #{requestId}, #{actorPublicId}, #{identityCode}, 'AUDIT_LOG',
CAST(#{filterJson} AS JSON), CAST(#{columnsJson} AS JSON), #{rowCount}, #{sha256})
""")
int insertExportLog(@Param("publicId") String publicId, @Param("requestId") String requestId,
@Param("actorPublicId") String actorPublicId, @Param("identityCode") String identityCode,
@Param("filterJson") String filterJson, @Param("columnsJson") String columnsJson,
@Param("rowCount") int rowCount, @Param("sha256") String sha256);
record AuditRow(long eventSequence, String publicId, String requestId, String userPublicId, String username, String activeRole,
String companyPublicId, String projectPublicId, String actionCode, String objectType,
String objectPublicId, String resultCode, String reason, String beforeJson, String afterJson,
LocalDateTime occurredAt) {
}
}
@@ -0,0 +1,41 @@
package com.kaidi.finance.fund.api;
import com.kaidi.finance.fund.api.FundLedgerViews.FundLedgerPageView;
import com.kaidi.finance.fund.application.FundLedgerApplicationService;
import com.kaidi.finance.shared.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/funds")
public class FundLedgerController {
private final FundLedgerApplicationService service;
public FundLedgerController(FundLedgerApplicationService service) {
this.service = service;
}
@GetMapping("/ledger")
@Operation(operationId = "listFundLedger", summary = "查询项目逐笔资金流水")
@PreAuthorize("@authorizationService.hasPermission('payment:request:view')")
public ApiResponse<FundLedgerPageView> ledger(
@RequestParam String projectId,
@RequestParam(defaultValue = "CNY") String currency,
@RequestParam(required = false) String entryType,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "occurredAt") String sortBy,
@RequestParam(defaultValue = "desc") String sortDirection) {
return ApiResponse.ok(service.ledger(projectId, currency, entryType, dateFrom, dateTo, page, size,
sortBy, sortDirection));
}
}
@@ -0,0 +1,63 @@
package com.kaidi.finance.fund.api;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
public final class FundLedgerViews {
private FundLedgerViews() {
}
public record FundControlView(
String projectId,
String projectCode,
String projectName,
String currency,
String confirmedReceipt,
String confirmedDeduction,
String approvedAdjustment,
String frozenAmount,
String approvedUnpaid,
String paidAmount,
String availableBalance,
long version
) {
}
public record FundLedgerEntryView(
String publicId,
String entryType,
String sourceType,
String sourcePublicId,
String sourceBusinessNo,
String description,
String currency,
String eventAmount,
String deltaAmount,
String confirmedReceiptDelta,
String confirmedDeductionDelta,
String approvedAdjustmentDelta,
String frozenAmountDelta,
String approvedUnpaidDelta,
String paidAmountDelta,
String confirmedReceiptAfter,
String confirmedDeductionAfter,
String approvedAdjustmentAfter,
String frozenAmountAfter,
String approvedUnpaidAfter,
String paidAmountAfter,
String balanceAfter,
long controlVersionAfter,
String createdByName,
LocalDateTime occurredAt
) {
}
public record FundLedgerPageView(
FundControlView control,
List<FundLedgerEntryView> items,
Map<String, Object> meta
) {
}
}
@@ -0,0 +1,311 @@
package com.kaidi.finance.fund.application;
import com.kaidi.finance.fund.api.FundLedgerViews.FundControlView;
import com.kaidi.finance.fund.api.FundLedgerViews.FundLedgerEntryView;
import com.kaidi.finance.fund.api.FundLedgerViews.FundLedgerPageView;
import com.kaidi.finance.fund.infrastructure.FundLedgerMapper;
import com.kaidi.finance.fund.infrastructure.FundLedgerMapper.FundControlRow;
import com.kaidi.finance.fund.infrastructure.FundLedgerMapper.FundLedgerRow;
import com.kaidi.finance.fund.infrastructure.FundLedgerMapper.ExistingEntry;
import com.kaidi.finance.fund.infrastructure.FundLedgerMapper.ProjectScope;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.id.UlidGenerator;
import com.kaidi.finance.shared.security.AuthorizationService;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class FundLedgerApplicationService {
private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}");
private static final Set<String> ENTRY_TYPES = Set.of(
"OPENING_BALANCE", "RECEIPT_CONFIRMED", "RECEIPT_ALLOCATED_TO_PROJECT", "RECEIPT_VOIDED",
"PAYMENT_FUNDS_RESERVED", "PAYMENT_PAID", "PAYMENT_FUNDS_RELEASED_FAILURE",
"PAYMENT_FUNDS_RELEASED_REFUND", "PAYMENT_REFUNDED"
);
private static final Set<String> SORT_FIELDS = Set.of("occurredAt", "eventAmount", "entryType", "sourceBusinessNo");
private static final BigDecimal ZERO = BigDecimal.ZERO.setScale(2);
private final FundLedgerMapper mapper;
private final AuthorizationService authorizationService;
private final UlidGenerator ulidGenerator;
private final AuditService auditService;
public FundLedgerApplicationService(FundLedgerMapper mapper, AuthorizationService authorizationService,
UlidGenerator ulidGenerator, AuditService auditService) {
this.mapper = mapper;
this.authorizationService = authorizationService;
this.ulidGenerator = ulidGenerator;
this.auditService = auditService;
}
@Transactional(readOnly = true)
public FundLedgerPageView ledger(String projectPublicId, String currency, String entryType,
LocalDate dateFrom, LocalDate dateTo, int page, int size,
String sortBy, String sortDirection) {
if (projectPublicId == null || !PUBLIC_ID.matcher(projectPublicId).matches()) {
throw validation("请选择有效项目");
}
String safeCurrency = currency == null || currency.isBlank()
? "CNY" : currency.trim().toUpperCase(Locale.ROOT);
if (!safeCurrency.matches("[A-Z]{3}")) throw validation("资金币种无效");
String safeEntryType = entryType == null || entryType.isBlank()
? null : entryType.trim().toUpperCase(Locale.ROOT);
if (safeEntryType != null && !ENTRY_TYPES.contains(safeEntryType)) throw validation("资金流水类型无效");
if (dateFrom != null && dateTo != null && dateTo.isBefore(dateFrom)) throw validation("资金流水日期范围无效");
String safeSortBy = sortBy == null || sortBy.isBlank() ? "occurredAt" : sortBy.trim();
String safeSortDirection = sortDirection == null || sortDirection.isBlank()
? "desc" : sortDirection.trim().toLowerCase(Locale.ROOT);
if (!SORT_FIELDS.contains(safeSortBy) || !Set.of("asc", "desc").contains(safeSortDirection)) {
throw validation("资金流水排序无效");
}
int safePage = Math.max(1, page);
int safeSize = Math.max(1, Math.min(size, 100));
ProjectScope project = mapper.findProject(projectPublicId);
if (project == null) throw notFound("项目不存在或已失效");
authorizationService.requireScope("payment:request:view", project.companyPublicId(), project.publicId());
FundControlRow control = mapper.findControl(project.id(), safeCurrency);
if (control == null) throw notFound("项目尚无该币种的资金控制记录");
LocalDateTime from = dateFrom == null ? null : dateFrom.atStartOfDay();
LocalDateTime toExclusive = dateTo == null ? null : dateTo.plusDays(1).atStartOfDay();
int offset = Math.toIntExact(Math.min((long) (safePage - 1) * safeSize, Integer.MAX_VALUE));
List<FundLedgerEntryView> items = mapper.listEntries(project.id(), safeCurrency, safeEntryType,
from, toExclusive, safeSize, offset, safeSortBy, safeSortDirection).stream().map(this::entryView).toList();
long total = mapper.countEntries(project.id(), safeCurrency, safeEntryType, from, toExclusive);
PageResult<FundLedgerEntryView> result = new PageResult<>(items, total, safePage, safeSize);
return new FundLedgerPageView(controlView(control), items, result.meta());
}
@Transactional(propagation = Propagation.MANDATORY)
public void append(FundLedgerCommand command) {
validateCommand(command);
BigDecimal confirmedReceiptDelta = money(command.confirmedReceiptDelta());
BigDecimal confirmedDeductionDelta = money(command.confirmedDeductionDelta());
BigDecimal approvedAdjustmentDelta = money(command.approvedAdjustmentDelta());
BigDecimal frozenAmountDelta = money(command.frozenAmountDelta());
BigDecimal approvedUnpaidDelta = money(command.approvedUnpaidDelta());
BigDecimal paidAmountDelta = money(command.paidAmountDelta());
BigDecimal availableDelta = confirmedReceiptDelta.subtract(confirmedDeductionDelta)
.add(approvedAdjustmentDelta).subtract(frozenAmountDelta)
.subtract(approvedUnpaidDelta).subtract(paidAmountDelta);
FundControlRow control = mapper.lockControl(command.projectId(), command.currency());
if (control == null) throw new IllegalStateException("Fund control row was not found");
// Use a locking read here: the owning domain transaction may already have
// established a repeatable-read snapshot before it updated the control row.
ExistingEntry existing = mapper.lockBySourceEventKey(command.sourceEventKey());
if (existing != null) {
if (sameEntry(existing, command)) return;
throw duplicateSource();
}
String ledgerPublicId = ulidGenerator.next();
Map<String, Object> beforeSnapshot = controlSnapshot(control, confirmedReceiptDelta,
confirmedDeductionDelta, approvedAdjustmentDelta, frozenAmountDelta, approvedUnpaidDelta,
paidAmountDelta, availableDelta, true);
try {
if (mapper.insertEntry(ledgerPublicId, command.projectId(), command.currency(),
command.sourceEventKey(), command.entryType(), command.sourceType(), command.sourcePublicId(),
command.sourceBusinessNo(), command.description(), money(command.eventAmount()), availableDelta,
confirmedReceiptDelta, confirmedDeductionDelta, approvedAdjustmentDelta, frozenAmountDelta,
approvedUnpaidDelta, paidAmountDelta, control.confirmedReceipt(), control.confirmedDeduction(),
control.approvedAdjustment(), control.frozenAmount(), control.approvedUnpaid(), control.paidAmount(),
control.availableBalance(), control.version(), command.actorId()) != 1) {
throw new IllegalStateException("Fund ledger entry was not created");
}
} catch (DuplicateKeyException exception) {
ExistingEntry concurrent = mapper.lockBySourceEventKey(command.sourceEventKey());
if (concurrent != null && sameEntry(concurrent, command)) return;
throw duplicateSource();
}
Map<String, Object> afterSnapshot = controlSnapshot(control, confirmedReceiptDelta,
confirmedDeductionDelta, approvedAdjustmentDelta, frozenAmountDelta, approvedUnpaidDelta,
paidAmountDelta, availableDelta, false);
afterSnapshot.put("ledgerPublicId", ledgerPublicId);
afterSnapshot.put("entryType", command.entryType());
afterSnapshot.put("sourceEventKey", command.sourceEventKey());
afterSnapshot.put("sourceType", command.sourceType());
afterSnapshot.put("sourcePublicId", command.sourcePublicId());
afterSnapshot.put("sourceBusinessNo", command.sourceBusinessNo());
afterSnapshot.put("eventAmount", money(command.eventAmount()));
afterSnapshot.put("deltaAmount", availableDelta);
auditService.recordScoped(control.companyPublicId(), control.projectPublicId(),
"FUND_LEDGER_APPEND", "FUND_CONTROL", control.projectPublicId(), "SUCCESS",
command.description(), beforeSnapshot, afterSnapshot);
}
/**
* The fund-control row is updated by the owning domain service immediately
* before this append call. The locked row therefore represents the
* post-transition state; subtracting the command deltas reconstructs the
* exact before snapshot without introducing a second, weaker read.
*/
private Map<String, Object> controlSnapshot(FundControlRow control,
BigDecimal confirmedReceiptDelta,
BigDecimal confirmedDeductionDelta,
BigDecimal approvedAdjustmentDelta,
BigDecimal frozenAmountDelta,
BigDecimal approvedUnpaidDelta,
BigDecimal paidAmountDelta,
BigDecimal availableDelta,
boolean before) {
BigDecimal confirmedReceipt = control.confirmedReceipt();
BigDecimal confirmedDeduction = control.confirmedDeduction();
BigDecimal approvedAdjustment = control.approvedAdjustment();
BigDecimal frozenAmount = control.frozenAmount();
BigDecimal approvedUnpaid = control.approvedUnpaid();
BigDecimal paidAmount = control.paidAmount();
BigDecimal availableBalance = control.availableBalance();
long version = control.version();
if (before) {
confirmedReceipt = confirmedReceipt.subtract(confirmedReceiptDelta);
confirmedDeduction = confirmedDeduction.subtract(confirmedDeductionDelta);
approvedAdjustment = approvedAdjustment.subtract(approvedAdjustmentDelta);
frozenAmount = frozenAmount.subtract(frozenAmountDelta);
approvedUnpaid = approvedUnpaid.subtract(approvedUnpaidDelta);
paidAmount = paidAmount.subtract(paidAmountDelta);
availableBalance = availableBalance.subtract(availableDelta);
version = Math.max(0, version - 1);
}
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("companyPublicId", control.companyPublicId());
snapshot.put("projectPublicId", control.projectPublicId());
snapshot.put("projectCode", control.projectCode());
snapshot.put("currency", control.currency());
snapshot.put("confirmedReceipt", money(confirmedReceipt));
snapshot.put("confirmedDeduction", money(confirmedDeduction));
snapshot.put("approvedAdjustment", money(approvedAdjustment));
snapshot.put("frozenAmount", money(frozenAmount));
snapshot.put("approvedUnpaid", money(approvedUnpaid));
snapshot.put("paidAmount", money(paidAmount));
snapshot.put("availableBalance", money(availableBalance));
snapshot.put("version", version);
return snapshot;
}
private FundControlView controlView(FundControlRow row) {
return new FundControlView(row.projectPublicId(), row.projectCode(), row.projectName(), row.currency(),
text(row.confirmedReceipt()), text(row.confirmedDeduction()), text(row.approvedAdjustment()),
text(row.frozenAmount()), text(row.approvedUnpaid()), text(row.paidAmount()),
text(row.availableBalance()), row.version());
}
private FundLedgerEntryView entryView(FundLedgerRow row) {
return new FundLedgerEntryView(row.publicId(), row.entryType(), row.sourceType(), row.sourcePublicId(),
row.sourceBusinessNo(), row.description(), row.currency(), text(row.eventAmount()),
text(row.deltaAmount()), text(row.confirmedReceiptDelta()), text(row.confirmedDeductionDelta()),
text(row.approvedAdjustmentDelta()), text(row.frozenAmountDelta()), text(row.approvedUnpaidDelta()),
text(row.paidAmountDelta()), text(row.confirmedReceiptAfter()), text(row.confirmedDeductionAfter()),
text(row.approvedAdjustmentAfter()), text(row.frozenAmountAfter()), text(row.approvedUnpaidAfter()),
text(row.paidAmountAfter()), text(row.balanceAfter()), row.controlVersionAfter(),
row.createdByName(), row.occurredAt());
}
private void validateCommand(FundLedgerCommand command) {
if (command == null || command.projectId() <= 0 || command.actorId() <= 0
|| command.currency() == null || !command.currency().matches("[A-Z]{3}")
|| command.sourceEventKey() == null || command.sourceEventKey().isBlank()
|| command.sourceEventKey().length() > 160
|| command.entryType() == null || !ENTRY_TYPES.contains(command.entryType())
|| command.sourceType() == null || command.sourceType().isBlank() || command.sourceType().length() > 32
|| (command.sourcePublicId() != null && command.sourcePublicId().length() > 26)
|| (command.sourceBusinessNo() != null && command.sourceBusinessNo().length() > 80)
|| command.description() == null || command.description().isBlank()
|| command.description().length() > 500
|| (command.eventAmount() != null && invalidScale(command.eventAmount()))
|| invalidScale(command.confirmedReceiptDelta()) || invalidScale(command.confirmedDeductionDelta())
|| invalidScale(command.approvedAdjustmentDelta()) || invalidScale(command.frozenAmountDelta())
|| invalidScale(command.approvedUnpaidDelta()) || invalidScale(command.paidAmountDelta())
|| (command.eventAmount() != null && command.eventAmount().signum() < 0)) {
throw validation("资金流水命令无效");
}
}
private boolean sameEntry(ExistingEntry existing, FundLedgerCommand command) {
return existing.projectId() == command.projectId()
&& existing.currency().equals(command.currency())
&& existing.entryType().equals(command.entryType())
&& existing.sourceType().equals(command.sourceType())
&& java.util.Objects.equals(existing.sourcePublicId(), command.sourcePublicId())
&& java.util.Objects.equals(existing.sourceBusinessNo(), command.sourceBusinessNo())
&& existing.description().equals(command.description())
&& money(existing.eventAmount()).compareTo(money(command.eventAmount())) == 0
&& money(existing.deltaAmount()).compareTo(availableDelta(command)) == 0
&& money(existing.confirmedReceiptDelta()).compareTo(money(command.confirmedReceiptDelta())) == 0
&& money(existing.confirmedDeductionDelta()).compareTo(money(command.confirmedDeductionDelta())) == 0
&& money(existing.approvedAdjustmentDelta()).compareTo(money(command.approvedAdjustmentDelta())) == 0
&& money(existing.frozenAmountDelta()).compareTo(money(command.frozenAmountDelta())) == 0
&& money(existing.approvedUnpaidDelta()).compareTo(money(command.approvedUnpaidDelta())) == 0
&& money(existing.paidAmountDelta()).compareTo(money(command.paidAmountDelta())) == 0;
}
private BigDecimal availableDelta(FundLedgerCommand command) {
return money(command.confirmedReceiptDelta()).subtract(money(command.confirmedDeductionDelta()))
.add(money(command.approvedAdjustmentDelta())).subtract(money(command.frozenAmountDelta()))
.subtract(money(command.approvedUnpaidDelta())).subtract(money(command.paidAmountDelta()));
}
private BigDecimal money(BigDecimal value) {
return value == null ? ZERO : value.setScale(2, RoundingMode.UNNECESSARY);
}
private String text(BigDecimal value) {
return money(value).toPlainString();
}
private BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
}
private BusinessException notFound(String message) {
return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message);
}
private BusinessException duplicateSource() {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_SOURCE, "资金流水来源事件已处理");
}
private boolean invalidScale(BigDecimal value) {
if (value == null) return false;
try {
money(value);
return false;
} catch (ArithmeticException exception) {
return true;
}
}
public record FundLedgerCommand(
long projectId,
String currency,
String sourceEventKey,
String entryType,
String sourceType,
String sourcePublicId,
String sourceBusinessNo,
String description,
BigDecimal eventAmount,
BigDecimal confirmedReceiptDelta,
BigDecimal confirmedDeductionDelta,
BigDecimal approvedAdjustmentDelta,
BigDecimal frozenAmountDelta,
BigDecimal approvedUnpaidDelta,
BigDecimal paidAmountDelta,
long actorId
) {
}
}
@@ -0,0 +1,213 @@
package com.kaidi.finance.fund.infrastructure;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface FundLedgerMapper {
@Select("""
SELECT project.id, project.public_id, project.business_no, project.name,
company.public_id AS company_public_id
FROM md_project project
JOIN md_company company ON company.id = project.company_id
WHERE project.public_id = #{publicId} AND project.status = 'ACTIVE'
""")
ProjectScope findProject(String publicId);
@Select("""
SELECT control.id, company.public_id AS company_public_id,
project.public_id AS project_public_id,
project.business_no AS project_code, project.name AS project_name,
control.currency, control.confirmed_receipt, control.confirmed_deduction,
control.approved_adjustment, control.frozen_amount, control.approved_unpaid,
control.paid_amount,
control.confirmed_receipt - control.confirmed_deduction + control.approved_adjustment
- control.frozen_amount - control.approved_unpaid - control.paid_amount AS available_balance,
control.version
FROM project_fund_control control
JOIN md_project project ON project.id = control.project_id
JOIN md_company company ON company.id = project.company_id
WHERE control.project_id = #{projectId} AND control.currency = #{currency}
""")
FundControlRow findControl(@Param("projectId") long projectId, @Param("currency") String currency);
@Select("""
SELECT control.id, company.public_id AS company_public_id,
project.public_id AS project_public_id,
project.business_no AS project_code, project.name AS project_name,
control.currency, control.confirmed_receipt, control.confirmed_deduction,
control.approved_adjustment, control.frozen_amount, control.approved_unpaid,
control.paid_amount,
control.confirmed_receipt - control.confirmed_deduction + control.approved_adjustment
- control.frozen_amount - control.approved_unpaid - control.paid_amount AS available_balance,
control.version
FROM project_fund_control control
JOIN md_project project ON project.id = control.project_id
JOIN md_company company ON company.id = project.company_id
WHERE control.project_id = #{projectId} AND control.currency = #{currency}
FOR UPDATE
""")
FundControlRow lockControl(@Param("projectId") long projectId, @Param("currency") String currency);
@Select("""
SELECT project_id, currency, entry_type, source_type, source_public_id,
source_business_no, description,
event_amount, delta_amount, confirmed_receipt_delta, confirmed_deduction_delta,
approved_adjustment_delta, frozen_amount_delta, approved_unpaid_delta, paid_amount_delta
FROM fund_ledger
WHERE source_event_key = #{sourceEventKey}
""")
ExistingEntry findBySourceEventKey(@Param("sourceEventKey") String sourceEventKey);
@Select("""
SELECT project_id, currency, entry_type, source_type, source_public_id,
source_business_no, description,
event_amount, delta_amount, confirmed_receipt_delta, confirmed_deduction_delta,
approved_adjustment_delta, frozen_amount_delta, approved_unpaid_delta, paid_amount_delta
FROM fund_ledger
WHERE source_event_key = #{sourceEventKey}
FOR UPDATE
""")
ExistingEntry lockBySourceEventKey(@Param("sourceEventKey") String sourceEventKey);
@Insert("""
INSERT INTO fund_ledger (
public_id, company_id, project_id, currency, source_event_key, entry_type,
source_type, source_public_id, source_business_no, description, event_amount,
delta_amount, confirmed_receipt_delta, confirmed_deduction_delta,
approved_adjustment_delta, frozen_amount_delta, approved_unpaid_delta, paid_amount_delta,
confirmed_receipt_after, confirmed_deduction_after, approved_adjustment_after,
frozen_amount_after, approved_unpaid_after, paid_amount_after, balance_after,
control_version_after, occurred_at, created_by
)
SELECT
#{publicId}, project.company_id, control.project_id, control.currency,
#{sourceEventKey}, #{entryType}, #{sourceType}, #{sourcePublicId},
#{sourceBusinessNo}, #{description}, #{eventAmount}, #{deltaAmount},
#{confirmedReceiptDelta}, #{confirmedDeductionDelta}, #{approvedAdjustmentDelta},
#{frozenAmountDelta}, #{approvedUnpaidDelta}, #{paidAmountDelta},
#{confirmedReceiptAfter}, #{confirmedDeductionAfter}, #{approvedAdjustmentAfter},
#{frozenAmountAfter}, #{approvedUnpaidAfter}, #{paidAmountAfter}, #{balanceAfter},
#{controlVersionAfter}, UTC_TIMESTAMP(3), #{actorId}
FROM project_fund_control control
JOIN md_project project ON project.id = control.project_id
WHERE control.project_id = #{projectId} AND control.currency = #{currency}
""")
int insertEntry(@Param("publicId") String publicId,
@Param("projectId") long projectId,
@Param("currency") String currency,
@Param("sourceEventKey") String sourceEventKey,
@Param("entryType") String entryType,
@Param("sourceType") String sourceType,
@Param("sourcePublicId") String sourcePublicId,
@Param("sourceBusinessNo") String sourceBusinessNo,
@Param("description") String description,
@Param("eventAmount") BigDecimal eventAmount,
@Param("deltaAmount") BigDecimal deltaAmount,
@Param("confirmedReceiptDelta") BigDecimal confirmedReceiptDelta,
@Param("confirmedDeductionDelta") BigDecimal confirmedDeductionDelta,
@Param("approvedAdjustmentDelta") BigDecimal approvedAdjustmentDelta,
@Param("frozenAmountDelta") BigDecimal frozenAmountDelta,
@Param("approvedUnpaidDelta") BigDecimal approvedUnpaidDelta,
@Param("paidAmountDelta") BigDecimal paidAmountDelta,
@Param("confirmedReceiptAfter") BigDecimal confirmedReceiptAfter,
@Param("confirmedDeductionAfter") BigDecimal confirmedDeductionAfter,
@Param("approvedAdjustmentAfter") BigDecimal approvedAdjustmentAfter,
@Param("frozenAmountAfter") BigDecimal frozenAmountAfter,
@Param("approvedUnpaidAfter") BigDecimal approvedUnpaidAfter,
@Param("paidAmountAfter") BigDecimal paidAmountAfter,
@Param("balanceAfter") BigDecimal balanceAfter,
@Param("controlVersionAfter") long controlVersionAfter,
@Param("actorId") long actorId);
@Select("""
<script>
SELECT ledger.public_id, ledger.entry_type, ledger.source_type, ledger.source_public_id,
ledger.source_business_no, ledger.description, ledger.currency,
ledger.event_amount, ledger.delta_amount, ledger.confirmed_receipt_delta,
ledger.confirmed_deduction_delta, ledger.approved_adjustment_delta,
ledger.frozen_amount_delta, ledger.approved_unpaid_delta, ledger.paid_amount_delta,
ledger.confirmed_receipt_after, ledger.confirmed_deduction_after,
ledger.approved_adjustment_after, ledger.frozen_amount_after,
ledger.approved_unpaid_after, ledger.paid_amount_after, ledger.balance_after,
ledger.control_version_after, creator.display_name AS created_by_name,
ledger.occurred_at
FROM fund_ledger ledger
LEFT JOIN iam_user creator ON creator.id = ledger.created_by
WHERE ledger.project_id = #{projectId} AND ledger.currency = #{currency}
<if test="entryType != null">AND ledger.entry_type = #{entryType}</if>
<if test="dateFrom != null">AND ledger.occurred_at &gt;= #{dateFrom}</if>
<if test="dateToExclusive != null">AND ledger.occurred_at &lt; #{dateToExclusive}</if>
ORDER BY
<choose>
<when test="sortBy == 'eventAmount'">ledger.event_amount</when>
<when test="sortBy == 'entryType'">ledger.entry_type</when>
<when test="sortBy == 'sourceBusinessNo'">ledger.source_business_no</when>
<otherwise>ledger.occurred_at</otherwise>
</choose>
<choose><when test="sortDirection == 'asc'">ASC</when><otherwise>DESC</otherwise></choose>, ledger.id DESC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<FundLedgerRow> listEntries(@Param("projectId") long projectId,
@Param("currency") String currency,
@Param("entryType") String entryType,
@Param("dateFrom") LocalDateTime dateFrom,
@Param("dateToExclusive") LocalDateTime dateToExclusive,
@Param("limit") int limit,
@Param("offset") int offset,
@Param("sortBy") String sortBy,
@Param("sortDirection") String sortDirection);
@Select("""
<script>
SELECT COUNT(*) FROM fund_ledger ledger
WHERE ledger.project_id = #{projectId} AND ledger.currency = #{currency}
<if test="entryType != null">AND ledger.entry_type = #{entryType}</if>
<if test="dateFrom != null">AND ledger.occurred_at &gt;= #{dateFrom}</if>
<if test="dateToExclusive != null">AND ledger.occurred_at &lt; #{dateToExclusive}</if>
</script>
""")
long countEntries(@Param("projectId") long projectId,
@Param("currency") String currency,
@Param("entryType") String entryType,
@Param("dateFrom") LocalDateTime dateFrom,
@Param("dateToExclusive") LocalDateTime dateToExclusive);
record ProjectScope(long id, String publicId, String businessNo, String name, String companyPublicId) {
}
record FundControlRow(long id, String companyPublicId, String projectPublicId,
String projectCode, String projectName,
String currency, BigDecimal confirmedReceipt, BigDecimal confirmedDeduction,
BigDecimal approvedAdjustment, BigDecimal frozenAmount, BigDecimal approvedUnpaid,
BigDecimal paidAmount, BigDecimal availableBalance, long version) {
}
record ExistingEntry(long projectId, String currency, String entryType, String sourceType,
String sourcePublicId, String sourceBusinessNo, String description,
BigDecimal eventAmount, BigDecimal deltaAmount,
BigDecimal confirmedReceiptDelta, BigDecimal confirmedDeductionDelta,
BigDecimal approvedAdjustmentDelta, BigDecimal frozenAmountDelta,
BigDecimal approvedUnpaidDelta, BigDecimal paidAmountDelta) {
}
record FundLedgerRow(String publicId, String entryType, String sourceType, String sourcePublicId,
String sourceBusinessNo, String description, String currency,
BigDecimal eventAmount, BigDecimal deltaAmount, BigDecimal confirmedReceiptDelta,
BigDecimal confirmedDeductionDelta, BigDecimal approvedAdjustmentDelta,
BigDecimal frozenAmountDelta, BigDecimal approvedUnpaidDelta, BigDecimal paidAmountDelta,
BigDecimal confirmedReceiptAfter, BigDecimal confirmedDeductionAfter,
BigDecimal approvedAdjustmentAfter, BigDecimal frozenAmountAfter,
BigDecimal approvedUnpaidAfter, BigDecimal paidAmountAfter, BigDecimal balanceAfter,
long controlVersionAfter, String createdByName,
LocalDateTime occurredAt) {
}
}
@@ -0,0 +1,63 @@
package com.kaidi.finance.governance.api;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
public final class GovernanceContracts {
private GovernanceContracts() {
}
public record ResourceCreateRequest(
@Size(max = 64) String code,
@Size(max = 128) String name,
@Size(max = 255) String description,
@Size(max = 64) String username,
@Size(max = 128) String displayName,
@Size(max = 128) String departmentName,
@Size(max = 64) String password,
@Size(max = 40) String roleCode,
@Size(max = 100) String permissionCode,
@Size(max = 26) String userId,
@Size(max = 16) String scopeType,
@Size(max = 26) String companyId,
@Size(max = 26) String projectId,
@DecimalMin("0.00") BigDecimal amountLimit,
@Size(max = 32) String formType,
Integer templateVersion,
Integer schemaVersion,
@Size(max = 80) String parameterGroup,
Integer versionNo,
@Size(max = 10000) String valueJson,
List<@Size(max = 40) String> roleCodes,
List<@Size(max = 100) String> permissionCodes
) {
}
public record ResourceUpdateRequest(
@NotNull Long version,
@Size(max = 128) String name,
@Size(max = 255) String description,
@Size(max = 128) String displayName,
@Size(max = 128) String departmentName,
Boolean enabled,
@DecimalMin("0.00") BigDecimal amountLimit,
@Size(max = 16) String scopeType,
@Size(max = 26) String companyId,
@Size(max = 26) String projectId,
@Size(max = 10000) String valueJson,
List<@Size(max = 40) String> roleCodes,
List<@Size(max = 100) String> permissionCodes
) {
}
public record ResourceCommandRequest(
@NotNull Long version,
@Size(max = 1000) String reason
) {
}
}
@@ -0,0 +1,94 @@
package com.kaidi.finance.governance.api;
import com.kaidi.finance.governance.api.GovernanceContracts.ResourceCommandRequest;
import com.kaidi.finance.governance.api.GovernanceContracts.ResourceCreateRequest;
import com.kaidi.finance.governance.api.GovernanceContracts.ResourceUpdateRequest;
import com.kaidi.finance.governance.api.GovernanceViews.ResourceListView;
import com.kaidi.finance.governance.api.GovernanceViews.ResourceView;
import com.kaidi.finance.governance.application.GovernanceApplicationService;
import com.kaidi.finance.shared.api.ApiResponse;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value = "/api/v1/admin", produces = MediaType.APPLICATION_JSON_VALUE)
public class GovernanceController {
private static final Set<String> LIST_QUERY_PARAMS = Set.of("keyword", "page", "size");
private final GovernanceApplicationService service;
public GovernanceController(GovernanceApplicationService service) {
this.service = service;
}
@GetMapping("/{resource}")
@Operation(operationId = "listGovernanceResources", summary = "查询系统治理资源")
public ApiResponse<List<ResourceListView>> list(
@Parameter(schema = @Schema(allowableValues = {"users", "roles", "scopes", "templates", "parameters"}))
@PathVariable String resource,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@Parameter(hidden = true) @RequestParam MultiValueMap<String, String> query) {
validateQuery(query);
PageResult<ResourceListView> result = service.list(resource, keyword, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/{resource}/{publicId}")
@Operation(operationId = "getGovernanceResource", summary = "读取系统治理资源详情")
public ApiResponse<ResourceView> detail(@PathVariable String resource, @PathVariable String publicId) {
return ApiResponse.ok(service.detail(resource, publicId));
}
@PostMapping("/{resource}")
@Operation(operationId = "createGovernanceResource", summary = "创建系统治理资源")
public ApiResponse<ResourceView> create(@PathVariable String resource,
@Valid @RequestBody ResourceCreateRequest request) {
return ApiResponse.ok(service.create(resource, request));
}
@PatchMapping("/{resource}/{publicId}")
@Operation(operationId = "updateGovernanceResource", summary = "更新系统治理资源")
public ApiResponse<ResourceView> update(@PathVariable String resource, @PathVariable String publicId,
@Valid @RequestBody ResourceUpdateRequest request) {
return ApiResponse.ok(service.update(resource, publicId, request));
}
@PostMapping("/{resource}/{publicId}/{command:enable|disable|publish|restore}")
@Operation(operationId = "commandGovernanceResource", summary = "执行系统治理资源命令")
public ApiResponse<ResourceView> command(@PathVariable String resource, @PathVariable String publicId,
@PathVariable String command,
@Valid @RequestBody ResourceCommandRequest request) {
return ApiResponse.ok(service.command(resource, publicId, command, request));
}
private static void validateQuery(Map<String, ?> query) {
for (String name : query.keySet()) {
if (!LIST_QUERY_PARAMS.contains(name)) {
throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID,
"不支持的查询参数: " + name);
}
}
}
}
@@ -0,0 +1,51 @@
package com.kaidi.finance.governance.api;
import java.time.Instant;
import java.util.List;
import java.util.Map;
public final class GovernanceViews {
private GovernanceViews() {
}
public record ResourceView(String resource, String publicId, String businessCode, String status,
long version, Map<String, Object> values, List<String> allowedActions) {
}
/** Stable superset used by the five governance list tabs. Null fields are omitted from JSON. */
public record ResourceListView(
String resource,
String publicId,
String username,
String displayName,
String departmentName,
Boolean enabled,
Boolean mustChangePassword,
List<String> roleCodes,
String code,
String name,
String description,
Integer sortOrder,
Long memberCount,
String userPublicId,
String roleCode,
String permissionCode,
String scopeType,
String companyPublicId,
String projectPublicId,
String amountLimit,
String status,
String formType,
Integer templateVersion,
Integer schemaVersion,
String parameterGroup,
Integer versionNo,
Long version,
Instant publishedAt,
Instant effectiveAt,
Instant createdAt,
Instant updatedAt
) {
}
}
@@ -0,0 +1,936 @@
package com.kaidi.finance.governance.application;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kaidi.finance.governance.api.GovernanceContracts.ResourceCommandRequest;
import com.kaidi.finance.governance.api.GovernanceContracts.ResourceCreateRequest;
import com.kaidi.finance.governance.api.GovernanceContracts.ResourceUpdateRequest;
import com.kaidi.finance.governance.api.GovernanceViews.ResourceListView;
import com.kaidi.finance.governance.api.GovernanceViews.ResourceView;
import com.kaidi.finance.governance.infrastructure.GovernanceMapper;
import com.kaidi.finance.governance.infrastructure.GovernanceMapper.ParameterRow;
import com.kaidi.finance.governance.infrastructure.GovernanceMapper.RoleRow;
import com.kaidi.finance.governance.infrastructure.GovernanceMapper.ScopeRow;
import com.kaidi.finance.governance.infrastructure.GovernanceMapper.TemplateRow;
import com.kaidi.finance.governance.infrastructure.GovernanceMapper.UserRow;
import com.kaidi.finance.iam.domain.FinancePrincipal;
import com.kaidi.finance.operations.application.FinanceOperationsApplicationService;
import com.kaidi.finance.payment.domain.PaymentAttachmentMatrix;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.id.UlidGenerator;
import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class GovernanceApplicationService {
private static final Set<String> RESOURCES = Set.of("users", "roles", "scopes", "templates", "parameters");
private static final Set<String> IDENTITY_ROLES = Set.of(
"PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER", "SYSTEM_ADMIN");
private static final Pattern USERNAME = Pattern.compile("[a-z][a-z0-9._-]{2,63}");
private static final Pattern CODE = Pattern.compile("[A-Z][A-Z0-9_-]{2,39}");
private static final Pattern PUBLIC_ID = Pattern.compile("[0-9A-HJKMNP-TV-Z]{26}");
private static final String ACCOUNTING_RULE_TEMPLATE_GROUP = "ACCOUNTING_RULE_TEMPLATE";
private static final Set<String> ACCOUNTING_EVENT_TYPES = Set.of(
"RECEIPT", "INVOICE", "PAYABLE", "PAYMENT", "PAYMENT_REFUND");
private final GovernanceMapper mapper;
private final FinanceOperationsApplicationService operationsService;
private final AuthorizationService authorizationService;
private final IdentityContext identityContext;
private final AuditService auditService;
private final UlidGenerator ulidGenerator;
private final PasswordEncoder passwordEncoder;
private final ObjectMapper objectMapper;
public GovernanceApplicationService(GovernanceMapper mapper,
FinanceOperationsApplicationService operationsService,
AuthorizationService authorizationService,
IdentityContext identityContext,
AuditService auditService,
UlidGenerator ulidGenerator,
PasswordEncoder passwordEncoder,
ObjectMapper objectMapper) {
this.mapper = mapper;
this.operationsService = operationsService;
this.authorizationService = authorizationService;
this.identityContext = identityContext;
this.auditService = auditService;
this.ulidGenerator = ulidGenerator;
this.passwordEncoder = passwordEncoder;
this.objectMapper = objectMapper;
}
@Transactional(readOnly = true)
public PageResult<ResourceListView> list(String resource, String keyword, int page, int size) {
requireSystemAdministrator();
String normalized = resource(resource);
PageResult<Map<String, Object>> rows = operationsService.listAdmin(normalized, keyword, page, size);
return new PageResult<>(rows.items().stream().map(row -> listView(normalized, row)).toList(),
rows.totalElements(), rows.page(), rows.size());
}
@Transactional(readOnly = true)
public ResourceView detail(String resource, String publicId) {
requireSystemAdministrator();
String normalized = resource(resource);
authorizationService.requirePermission(permission(normalized, "view"));
return read(normalized, publicId);
}
@Transactional
public ResourceView create(String resource, ResourceCreateRequest request) {
FinancePrincipal actor = requireSystemAdministrator();
String normalized = resource(resource);
authorizationService.requirePermission(permission(normalized, "create"));
String publicId;
try {
publicId = switch (normalized) {
case "users" -> createUser(request);
case "roles" -> createRole(request);
case "scopes" -> createScope(request);
case "templates" -> createTemplate(request, actor);
case "parameters" -> createParameter(request, actor);
default -> throw invalidResource();
};
} catch (DuplicateKeyException exception) {
throw duplicate(normalized);
}
ResourceView result = read(normalized, publicId);
auditService.record(action(normalized, "CREATE"), objectType(normalized), publicId,
"SUCCESS", null, null, auditValue(result));
return result;
}
@Transactional
public ResourceView update(String resource, String publicId, ResourceUpdateRequest request) {
FinancePrincipal actor = requireSystemAdministrator();
String normalized = resource(resource);
authorizationService.requirePermission(permission(normalized, "edit"));
ResourceView before = read(normalized, publicId);
try {
switch (normalized) {
case "users" -> updateUser(publicId, request);
case "roles" -> updateRole(publicId, request, actor);
case "scopes" -> updateScope(publicId, request);
case "templates" -> updateTemplate(publicId, request, actor);
case "parameters" -> updateParameter(publicId, request, actor);
default -> throw invalidResource();
}
} catch (DuplicateKeyException exception) {
throw duplicate(normalized);
}
ResourceView result = read(normalized, publicId);
auditService.record(action(normalized, "UPDATE"), objectType(normalized), publicId,
"SUCCESS", null, auditValue(before), auditValue(result));
return result;
}
@Transactional
public ResourceView command(String resource, String publicId, String command,
ResourceCommandRequest request) {
FinancePrincipal actor = requireSystemAdministrator();
String normalized = resource(resource);
String action = command == null ? "" : command.trim().toLowerCase(Locale.ROOT);
if (!Set.of("enable", "disable", "publish", "restore").contains(action)) {
throw validation("不支持的配置操作");
}
if (Set.of("publish", "restore").contains(action)
&& !Set.of("templates", "parameters").contains(normalized)) {
throw validation("当前配置不支持该版本操作");
}
if (Set.of("templates", "parameters").contains(normalized) && "enable".equals(action)) {
throw validation("版本配置必须通过发布或恢复命令生效");
}
authorizationService.requirePermission(permission(normalized, action));
ResourceView before = read(normalized, publicId);
long version = request.version();
switch (normalized) {
case "users" -> commandUser(publicId, version, action);
case "roles" -> commandRole(publicId, version, action, actor);
case "scopes" -> commandScope(publicId, version, action);
case "templates" -> commandTemplate(publicId, version, action, actor);
case "parameters" -> commandParameter(publicId, version, action, actor);
default -> throw invalidResource();
}
ResourceView result = read(normalized, publicId);
auditService.record(action(normalized, action.toUpperCase(Locale.ROOT)), objectType(normalized), publicId,
"SUCCESS", clean(request.reason(), 1000), auditValue(before), auditValue(result));
return result;
}
private String createUser(ResourceCreateRequest request) {
String username = required(request.username(), "username", 64).toLowerCase(Locale.ROOT);
if (!USERNAME.matcher(username).matches()) {
throw validation("账号只能使用小写字母、数字、点、下划线和连字符,且至少 3 位");
}
if ("admin".equals(username)) throw validation("系统内置管理员账号不可重复创建");
String displayName = required(request.displayName(), "displayName", 128);
String password = required(request.password(), "password", 64);
validatePassword(password);
List<RoleRow> roles = businessRoles(request.roleCode(), request.roleCodes());
String publicId = ulidGenerator.next();
mapper.insertUser(publicId, username, displayName, clean(request.departmentName(), 128),
passwordEncoder.encode(password));
UserRow user = requireUser(publicId);
for (RoleRow role : roles) mapper.insertUserRole(user.id(), role.id());
return publicId;
}
private String createRole(ResourceCreateRequest request) {
String code = required(request.code(), "code", 40).toUpperCase(Locale.ROOT);
validateRoleCode(code);
mapper.insertRole(code, required(request.name(), "name", 128), clean(request.description(), 255));
RoleRow role = requireRole(code);
replaceRolePermissions(role, request.permissionCodes());
return code;
}
private String createScope(ResourceCreateRequest request) {
String userId = publicId(required(request.userId(), "userId", 26));
UserRow user = requireUser(userId);
RoleRow role = requireBusinessRole(required(request.roleCode(), "roleCode", 40));
if (mapper.userHasRole(user.id(), role.id()) == 0) {
throw validation("该用户尚未被授予所选业务身份");
}
String permissionCode = required(request.permissionCode(), "permissionCode", 100);
Long permissionId = mapper.permissionId(permissionCode);
if (permissionId == null || mapper.roleHasPermission(role.id(), permissionId) == 0) {
throw validation("所选权限不属于该业务身份");
}
ScopeValues scope = scopeValues(request.scopeType(), request.companyId(), request.projectId(),
request.amountLimit());
mapper.insertScope(user.id(), role.id(), permissionId, scope.type(), scope.companyId(), scope.projectId(),
scope.amountLimit());
Long scopeId = mapper.scopeIdForGrant(user.id(), role.id(), permissionId, scope.type(), scope.companyId(),
scope.projectId());
if (scopeId == null) throw new IllegalStateException("Created governance scope cannot be reloaded");
mapper.invalidateSessions(user.username());
return Long.toString(scopeId);
}
private String createTemplate(ResourceCreateRequest request, FinancePrincipal actor) {
String formType = required(request.formType(), "formType", 32).toUpperCase(Locale.ROOT);
int templateVersion = positive(request.templateVersion(), "templateVersion");
int schemaVersion = positive(request.schemaVersion(), "schemaVersion");
mapper.lockTemplateVersions(formType);
TemplateRow source = mapper.findActiveTemplateByFormType(formType);
if (source == null) throw validation("新模板版本必须基于当前生效模板");
String publicId = ulidGenerator.next();
ensureCreated(mapper.insertTemplate(publicId, formType, templateVersion, required(request.name(), "name", 128),
schemaVersion, actor.userId()));
mapper.copyTemplateFields(source.id(), requireTemplate(publicId).id());
return publicId;
}
private String createParameter(ResourceCreateRequest request, FinancePrincipal actor) {
String group = required(request.parameterGroup(), "parameterGroup", 80).toUpperCase(Locale.ROOT);
String valueJson = validJson(required(request.valueJson(), "valueJson", 10_000));
validateParameter(group, valueJson);
Integer locked = mapper.lockParameterVersionGroup(group);
if (locked == null || locked != 1) throw conflict();
try {
mapper.lockParameterVersions(group);
int nextVersion = mapper.nextParameterVersion(group);
if (request.versionNo() != null && request.versionNo() != nextVersion) {
throw validation("参数版本号必须连续,下一版本应为 " + nextVersion);
}
String publicId = ulidGenerator.next();
mapper.insertParameter(publicId, group, nextVersion, valueJson, actor.publicId());
return publicId;
} finally {
mapper.unlockParameterVersionGroup(group);
}
}
private void updateUser(String publicId, ResourceUpdateRequest request) {
UserRow row = requireUser(publicId);
checkVersion(row.version(), request.version());
List<String> currentRoles = mapper.listRoleCodes(row.id());
boolean systemAdmin = currentRoles.contains("SYSTEM_ADMIN");
if (systemAdmin && request.roleCodes() != null) {
throw validation("内置系统管理员身份不可变更");
}
ensureUpdated(mapper.updateUser(row.id(), row.version(), clean(request.displayName(), 128),
clean(request.departmentName(), 128), null));
if (request.roleCodes() != null) {
List<RoleRow> roles = businessRoles(null, request.roleCodes());
mapper.deleteUserScopes(row.id());
mapper.deleteUserRoles(row.id());
for (RoleRow role : roles) mapper.insertUserRole(row.id(), role.id());
}
mapper.invalidateSessions(row.username());
}
private void updateRole(String code, ResourceUpdateRequest request, FinancePrincipal actor) {
RoleRow row = requireRole(code);
protectSystemRole(row);
checkVersion(row.version(), request.version());
ensureUpdated(mapper.updateRole(row.id(), row.version(), clean(request.name(), 128),
clean(request.description(), 255), null, actor.userId()));
if (request.permissionCodes() != null) replaceRolePermissions(row, request.permissionCodes());
invalidateRoleSessions(row);
}
private void updateScope(String publicId, ResourceUpdateRequest request) {
ScopeRow row = requireScope(publicId);
checkVersion(row.version(), request.version());
String type = request.scopeType() == null ? row.scopeType() : request.scopeType();
String companyId = request.scopeType() == null && request.companyId() == null
? row.companyPublicId() : request.companyId();
String projectId = request.scopeType() == null && request.projectId() == null
? row.projectPublicId() : request.projectId();
BigDecimal amount = request.amountLimit() == null ? row.amountLimit() : request.amountLimit();
ScopeValues values = scopeValues(type, companyId, projectId, amount);
ensureUpdated(mapper.updateScope(row.id(), row.version(), values.type(), values.companyId(),
values.projectId(), values.amountLimit(), null));
mapper.invalidateSessions(row.username());
}
private void updateTemplate(String publicId, ResourceUpdateRequest request, FinancePrincipal actor) {
TemplateRow row = requireTemplate(publicId);
checkVersion(row.version(), request.version());
if (!"DRAFT".equals(row.status())) throw invalidState("只有草稿模板可以编辑");
ensureUpdated(mapper.updateTemplate(row.id(), row.version(), clean(request.name(), 128), null,
actor.userId()));
}
private void updateParameter(String publicId, ResourceUpdateRequest request, FinancePrincipal actor) {
ParameterRow row = requireParameter(publicId);
checkVersion(row.version(), request.version());
if (!"DRAFT".equals(row.status())) throw invalidState("只有草稿参数版本可以编辑");
String valueJson = request.valueJson() == null ? null : validJson(request.valueJson());
if (valueJson != null) validateParameter(row.parameterGroup(), valueJson);
ensureUpdated(mapper.updateParameter(row.id(), row.version(), valueJson, null, actor.publicId()));
}
private void commandUser(String publicId, long version, String action) {
if ("publish".equals(action)) throw validation("用户配置不支持发布");
UserRow row = requireUser(publicId);
if ("admin".equals(row.username()) && "disable".equals(action)) {
throw invalidState("内置系统管理员账号不可停用");
}
checkVersion(row.version(), version);
requireToggleTransition(row.enabled(), action, "用户");
ensureUpdated(mapper.setUserEnabled(row.id(), row.version(), "enable".equals(action)));
mapper.invalidateSessions(row.username());
}
private void commandRole(String code, long version, String action, FinancePrincipal actor) {
if ("publish".equals(action)) throw validation("角色配置不支持发布");
RoleRow row = requireRole(code);
protectSystemRole(row);
checkVersion(row.version(), version);
requireToggleTransition(row.enabled(), action, "角色");
ensureUpdated(mapper.setRoleEnabled(row.id(), row.version(), "enable".equals(action), actor.userId()));
invalidateRoleSessions(row);
}
private void commandScope(String publicId, long version, String action) {
if ("publish".equals(action)) throw validation("数据范围不支持发布");
ScopeRow row = requireScope(publicId);
checkVersion(row.version(), version);
requireToggleTransition("ACTIVE".equals(row.status()), action, "数据范围");
ensureUpdated(mapper.setScopeStatus(row.id(), row.version(), "enable".equals(action) ? "ACTIVE" : "DISABLED"));
mapper.invalidateSessions(row.username());
}
private void commandTemplate(String publicId, long version, String action, FinancePrincipal actor) {
TemplateRow row = requireTemplate(publicId);
mapper.lockTemplateVersions(row.formType());
row = requireTemplate(publicId);
checkVersion(row.version(), version);
if ("disable".equals(action)) {
if (!"ACTIVE".equals(row.status())) throw invalidState("只有生效模板可以停用");
ensureUpdated(mapper.setTemplateStatus(row.id(), row.version(), "RETIRED", actor.userId()));
return;
}
if ("publish".equals(action) && !"DRAFT".equals(row.status())) {
throw invalidState("只有草稿模板可以发布");
}
if ("restore".equals(action) && !"RETIRED".equals(row.status())) {
throw invalidState("只有已停用模板可以恢复");
}
if (mapper.countTemplateFields(row.id()) == 0) {
throw invalidState("模板必须包含至少一个字段才可以生效");
}
mapper.retireOtherTemplates(row.formType(), row.id(), actor.userId());
ensureUpdated(mapper.setTemplateStatus(row.id(), row.version(), "ACTIVE", actor.userId()));
}
private void commandParameter(String publicId, long version, String action, FinancePrincipal actor) {
ParameterRow row = requireParameter(publicId);
mapper.lockParameterVersions(row.parameterGroup());
row = requireParameter(publicId);
checkVersion(row.version(), version);
if ("disable".equals(action)) {
if (!"ACTIVE".equals(row.status())) throw invalidState("只有生效参数版本可以停用");
ensureUpdated(mapper.setParameterStatus(row.id(), row.version(), "RETIRED", actor.publicId()));
return;
}
if ("publish".equals(action) && !"DRAFT".equals(row.status())) {
throw invalidState("只有草稿参数版本可以发布");
}
if ("restore".equals(action) && !"RETIRED".equals(row.status())) {
throw invalidState("只有已停用参数版本可以恢复");
}
validateParameter(row.parameterGroup(), row.valueJson());
validateParameterForPublish(row.parameterGroup(), row.valueJson());
mapper.retireOtherParameters(row.parameterGroup(), row.id(), actor.publicId());
ensureUpdated(mapper.setParameterStatus(row.id(), row.version(), "ACTIVE", actor.publicId()));
}
private ResourceListView listView(String resource, Map<String, Object> row) {
Boolean enabled = bool(row, "enabled");
String status = text(row, "status");
if (status == null && enabled != null) status = enabled ? "ACTIVE" : "DISABLED";
return new ResourceListView(
resource,
text(row, "publicId"),
text(row, "username"),
text(row, "displayName"),
text(row, "departmentName"),
enabled,
bool(row, "mustChangePassword"),
"users".equals(resource) ? strings(row, "roleCodes") : null,
text(row, "code"),
text(row, "name"),
text(row, "description"),
integer(row, "sortOrder"),
longValue(row, "memberCount"),
text(row, "userPublicId"),
text(row, "roleCode"),
text(row, "permissionCode"),
text(row, "scopeType"),
text(row, "companyPublicId"),
text(row, "projectPublicId"),
decimal(row, "amountLimit"),
status,
text(row, "formType"),
integer(row, "templateVersion"),
integer(row, "schemaVersion"),
text(row, "parameterGroup"),
integer(row, "versionNo"),
longValue(row, "version"),
instant(row, "publishedAt"),
instant(row, "effectiveAt"),
instant(row, "createdAt"),
instant(row, "updatedAt")
);
}
private static Object rowValue(Map<String, Object> row, String camelName) {
if (row.containsKey(camelName)) return row.get(camelName);
String snakeName = camelName.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
if (row.containsKey(snakeName)) return row.get(snakeName);
for (Map.Entry<String, Object> entry : row.entrySet()) {
if (camelName.equalsIgnoreCase(entry.getKey()) || snakeName.equalsIgnoreCase(entry.getKey())) {
return entry.getValue();
}
}
return null;
}
private static String text(Map<String, Object> row, String name) {
Object value = rowValue(row, name);
return value == null ? null : value.toString();
}
private static Boolean bool(Map<String, Object> row, String name) {
Object value = rowValue(row, name);
if (value == null) return null;
if (value instanceof Boolean bool) return bool;
if (value instanceof Number number) return number.intValue() != 0;
return Boolean.parseBoolean(value.toString());
}
private static Integer integer(Map<String, Object> row, String name) {
Object value = rowValue(row, name);
if (value == null) return null;
if (value instanceof Number number) return number.intValue();
return Integer.valueOf(value.toString());
}
private static Long longValue(Map<String, Object> row, String name) {
Object value = rowValue(row, name);
if (value == null) return null;
if (value instanceof Number number) return number.longValue();
return Long.valueOf(value.toString());
}
private static String decimal(Map<String, Object> row, String name) {
Object value = rowValue(row, name);
if (value == null) return null;
BigDecimal number = value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString());
return number.setScale(2, java.math.RoundingMode.HALF_UP).toPlainString();
}
private static Instant instant(Map<String, Object> row, String name) {
Object value = rowValue(row, name);
if (value == null) return null;
if (value instanceof Instant instant) return instant;
if (value instanceof Timestamp timestamp) return timestamp.toInstant();
if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant();
if (value instanceof LocalDateTime localDateTime) return localDateTime.toInstant(ZoneOffset.UTC);
return Instant.parse(value.toString());
}
private static List<String> strings(Map<String, Object> row, String name) {
Object value = rowValue(row, name);
if (value == null) return List.of();
if (value instanceof Collection<?> collection) {
return collection.stream().filter(java.util.Objects::nonNull).map(Object::toString).toList();
}
return java.util.Arrays.stream(value.toString().split(","))
.map(String::trim).filter(item -> !item.isEmpty()).toList();
}
private ResourceView read(String resource, String publicId) {
return switch (resource) {
case "users" -> userView(requireUser(publicId));
case "roles" -> roleView(requireRole(publicId));
case "scopes" -> scopeView(requireScope(publicId));
case "templates" -> templateView(requireTemplate(publicId));
case "parameters" -> parameterView(requireParameter(publicId));
default -> throw invalidResource();
};
}
private ResourceView userView(UserRow row) {
Map<String, Object> values = values();
values.put("publicId", row.publicId());
values.put("username", row.username());
values.put("displayName", row.displayName());
values.put("departmentName", row.departmentName());
values.put("enabled", row.enabled());
values.put("roleCodes", mapper.listRoleCodes(row.id()));
List<String> actions = new ArrayList<>();
if (authorizationService.hasPermission("admin:user:edit")) actions.add("EDIT");
if (!"admin".equals(row.username())) {
addToggle(actions, "admin:user", row.enabled());
}
return new ResourceView("users", row.publicId(), row.username(), row.enabled() ? "ACTIVE" : "DISABLED",
row.version(), values, actions);
}
private ResourceView roleView(RoleRow row) {
Map<String, Object> values = values();
values.put("code", row.code());
values.put("name", row.name());
values.put("description", row.description());
values.put("enabled", row.enabled());
values.put("permissionCodes", mapper.listPermissionCodes(row.id()));
List<String> actions = new ArrayList<>();
if (!"SYSTEM_ADMIN".equals(row.code())) {
if (authorizationService.hasPermission("admin:role:edit")) actions.add("EDIT");
addToggle(actions, "admin:role", row.enabled());
}
return new ResourceView("roles", row.code(), row.code(), row.enabled() ? "ACTIVE" : "DISABLED",
row.version(), values, actions);
}
private ResourceView scopeView(ScopeRow row) {
Map<String, Object> values = values();
values.put("id", row.id());
values.put("userId", row.userPublicId());
values.put("username", row.username());
values.put("roleCode", row.roleCode());
values.put("permissionCode", row.permissionCode());
values.put("scopeType", row.scopeType());
values.put("companyId", row.companyPublicId());
values.put("projectId", row.projectPublicId());
values.put("amountLimit", row.amountLimit());
List<String> actions = new ArrayList<>();
if (authorizationService.hasPermission("admin:scope:edit")) actions.add("EDIT");
addToggle(actions, "admin:scope", "ACTIVE".equals(row.status()));
return new ResourceView("scopes", Long.toString(row.id()), row.username(), row.status(), row.version(),
values, actions);
}
private ResourceView templateView(TemplateRow row) {
Map<String, Object> values = values();
values.put("publicId", row.publicId());
values.put("formType", row.formType());
values.put("templateVersion", row.templateVersion());
values.put("name", row.name());
values.put("schemaVersion", row.schemaVersion());
values.put("publishedAt", row.publishedAt());
List<String> actions = new ArrayList<>();
if ("DRAFT".equals(row.status()) && authorizationService.hasPermission("admin:template:edit")) {
actions.add("EDIT");
}
if ("DRAFT".equals(row.status()) && authorizationService.hasPermission("admin:template:publish")) {
actions.add("PUBLISH");
}
if ("RETIRED".equals(row.status()) && authorizationService.hasPermission("admin:template:restore")) {
actions.add("RESTORE");
}
if ("ACTIVE".equals(row.status()) && authorizationService.hasPermission("admin:template:disable")) {
actions.add("DISABLE");
}
return new ResourceView("templates", row.publicId(), row.formType(), row.status(), row.version(), values,
actions);
}
private ResourceView parameterView(ParameterRow row) {
Map<String, Object> values = values();
values.put("publicId", row.publicId());
values.put("parameterGroup", row.parameterGroup());
values.put("versionNo", row.versionNo());
values.put("value", jsonNode(row.valueJson()));
values.put("effectiveAt", row.effectiveAt());
List<String> actions = new ArrayList<>();
if ("DRAFT".equals(row.status()) && authorizationService.hasPermission("admin:parameter:edit")) {
actions.add("EDIT");
}
if ("DRAFT".equals(row.status()) && authorizationService.hasPermission("admin:parameter:publish")) {
actions.add("PUBLISH");
}
if ("RETIRED".equals(row.status()) && authorizationService.hasPermission("admin:parameter:restore")) {
actions.add("RESTORE");
}
if ("ACTIVE".equals(row.status()) && authorizationService.hasPermission("admin:parameter:disable")) {
actions.add("DISABLE");
}
return new ResourceView("parameters", row.publicId(), row.parameterGroup(), row.status(), row.version(),
values, actions);
}
private void replaceRolePermissions(RoleRow role, List<String> requested) {
if (requested == null) return;
List<Long> permissionIds = new ArrayList<>();
for (String raw : requested) {
String code = required(raw, "permissionCodes", 100);
if (code.startsWith("admin:") || code.startsWith("audit:log:")) {
throw validation("业务角色不能获得系统治理或审计权限");
}
Long permissionId = mapper.permissionId(code);
if (permissionId == null) throw validation("权限码不存在:" + code);
if (!permissionIds.contains(permissionId)) permissionIds.add(permissionId);
}
mapper.deleteRolePermissions(role.id());
for (Long permissionId : permissionIds) mapper.insertRolePermission(role.id(), permissionId);
}
private List<RoleRow> businessRoles(String single, List<String> requested) {
LinkedHashSet<String> codes = new LinkedHashSet<>();
if (single != null && !single.isBlank()) codes.add(single.trim().toUpperCase(Locale.ROOT));
if (requested != null) {
requested.stream().filter(value -> value != null && !value.isBlank())
.map(value -> value.trim().toUpperCase(Locale.ROOT)).forEach(codes::add);
}
if (codes.isEmpty()) throw validation("至少选择一个业务身份");
if (codes.contains("SYSTEM_ADMIN")) throw validation("系统管理员身份只允许内置隔离账号使用");
List<RoleRow> roles = new ArrayList<>();
for (String code : codes) roles.add(requireBusinessRole(code));
return roles;
}
private RoleRow requireBusinessRole(String code) {
RoleRow role = requireRole(code.trim().toUpperCase(Locale.ROOT));
if (!role.enabled()) throw validation("所选业务身份已停用");
if ("SYSTEM_ADMIN".equals(role.code())) throw validation("系统管理员身份必须与业务账号隔离");
return role;
}
private ScopeValues scopeValues(String rawType, String rawCompanyId, String rawProjectId,
BigDecimal amountLimit) {
String type = required(rawType, "scopeType", 16).toUpperCase(Locale.ROOT);
if (!Set.of("GLOBAL", "COMPANY", "PROJECT").contains(type)) throw validation("数据范围类型无效");
String companyId = optionalPublicId(rawCompanyId);
String projectId = optionalPublicId(rawProjectId);
if ("GLOBAL".equals(type) && (companyId != null || projectId != null)) {
throw validation("全局范围不能指定公司或项目");
}
if ("COMPANY".equals(type) && (companyId == null || projectId != null)) {
throw validation("公司范围必须且只能指定公司");
}
if ("PROJECT".equals(type) && projectId == null) throw validation("项目范围必须指定项目");
if (amountLimit != null && amountLimit.signum() < 0) throw validation("金额阈值不能为负数");
return new ScopeValues(type, companyId, projectId, amountLimit);
}
private UserRow requireUser(String publicId) {
UserRow row = mapper.findUser(publicId(publicId));
if (row == null) throw notFound("用户不存在");
return row;
}
private RoleRow requireRole(String code) {
RoleRow row = mapper.findRole(required(code, "roleCode", 40).toUpperCase(Locale.ROOT));
if (row == null) throw notFound("角色不存在");
return row;
}
private ScopeRow requireScope(String publicId) {
long id;
try {
id = Long.parseLong(publicId);
} catch (NumberFormatException exception) {
throw validation("数据范围编号格式无效");
}
ScopeRow row = mapper.findScope(id);
if (row == null) throw notFound("数据范围不存在");
return row;
}
private TemplateRow requireTemplate(String publicId) {
TemplateRow row = mapper.findTemplate(publicId(publicId));
if (row == null) throw notFound("表单模板不存在");
return row;
}
private ParameterRow requireParameter(String publicId) {
ParameterRow row = mapper.findParameter(publicId(publicId));
if (row == null) throw notFound("参数版本不存在");
return row;
}
private FinancePrincipal requireSystemAdministrator() {
FinancePrincipal principal = identityContext.requirePrincipal();
String activeRole = identityContext.requireActiveRole();
boolean isolated = principal.roles().size() == 1
&& "SYSTEM_ADMIN".equals(principal.roles().get(0).code());
if (!"SYSTEM_ADMIN".equals(activeRole) || !isolated) {
throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED,
"系统配置仅允许隔离的系统管理员账号访问");
}
return principal;
}
private void addToggle(List<String> actions, String prefix, boolean enabled) {
String action = enabled ? "disable" : "enable";
if (authorizationService.hasPermission(prefix + ":" + action)) {
actions.add(action.toUpperCase(Locale.ROOT));
}
}
private static void requireToggleTransition(boolean enabled, String action, String resource) {
if ((enabled && "enable".equals(action)) || (!enabled && "disable".equals(action))) {
throw invalidState(enabled ? resource + "当前已启用" : resource + "当前已停用");
}
}
private void invalidateRoleSessions(RoleRow role) {
for (String username : mapper.listUsernamesByRole(role.id())) mapper.invalidateSessions(username);
}
private static void protectSystemRole(RoleRow role) {
if ("SYSTEM_ADMIN".equals(role.code())) throw invalidState("内置系统管理员角色不可变更");
}
private void validateRoleCode(String code) {
if (!CODE.matcher(code).matches()) throw validation("角色代码格式无效");
if (IDENTITY_ROLES.contains(code)) throw validation("内置身份角色不可重复创建");
}
private static void validatePassword(String password) {
boolean valid = password.length() >= 12
&& password.chars().anyMatch(Character::isUpperCase)
&& password.chars().anyMatch(Character::isLowerCase)
&& password.chars().anyMatch(Character::isDigit)
&& password.chars().anyMatch(value -> !Character.isLetterOrDigit(value));
if (!valid) throw validation("初始密码至少 12 位,并包含大小写字母、数字和特殊字符");
}
private String validJson(String value) {
try {
JsonNode node = objectMapper.readTree(value);
if (node == null || (!node.isObject() && !node.isArray())) {
throw validation("参数值必须是 JSON 对象或数组");
}
return objectMapper.writeValueAsString(node);
} catch (JsonProcessingException exception) {
throw validation("参数值不是合法 JSON");
}
}
private void validateParameter(String group, String valueJson) {
if (ACCOUNTING_RULE_TEMPLATE_GROUP.equals(group)) {
validateAccountingRuleTemplate(valueJson);
return;
}
if (!PaymentAttachmentMatrix.PARAMETER_GROUP.equals(group)) return;
try {
PaymentAttachmentMatrix.parse(objectMapper, valueJson);
} catch (IllegalArgumentException exception) {
throw validation(exception.getMessage());
}
}
private void validateParameterForPublish(String group, String valueJson) {
if (ACCOUNTING_RULE_TEMPLATE_GROUP.equals(group)) {
validateAccountingRuleTemplate(valueJson);
return;
}
if (!PaymentAttachmentMatrix.PARAMETER_GROUP.equals(group)) return;
PaymentAttachmentMatrix.parse(objectMapper, valueJson);
}
private void validateAccountingRuleTemplate(String valueJson) {
try {
JsonNode root = objectMapper.readTree(valueJson);
if (root == null || !root.isObject()
|| root.path("schemaVersion").asInt(0) < 1
|| root.path("ruleVersion").asText("").isBlank()
|| !root.path("eventTypes").isObject()) {
throw validation("凭证规则模板必须包含 schemaVersion、ruleVersion 和 eventTypes");
}
for (String eventType : ACCOUNTING_EVENT_TYPES) {
JsonNode rule = root.path("eventTypes").path(eventType);
if (rule.path("debitAccount").asText("").isBlank()
|| rule.path("creditAccount").asText("").isBlank()) {
throw validation("凭证规则模板缺少 " + eventType + " 借贷科目");
}
}
} catch (JsonProcessingException exception) {
throw validation("凭证规则模板不是有效 JSON");
}
}
private Object jsonNode(String value) {
if (value == null) return null;
try {
return objectMapper.readTree(value);
} catch (JsonProcessingException exception) {
return value;
}
}
private static String resource(String resource) {
String normalized = resource == null ? "" : resource.trim().toLowerCase(Locale.ROOT);
if (!RESOURCES.contains(normalized)) throw invalidResource();
return normalized;
}
private static String permission(String resource, String action) {
String segment = switch (resource) {
case "users" -> "user";
case "roles" -> "role";
case "scopes" -> "scope";
case "templates" -> "template";
case "parameters" -> "parameter";
default -> throw invalidResource();
};
return "admin:" + segment + ":" + action;
}
private static String objectType(String resource) {
return "ADMIN_" + resource.substring(0, resource.length() - 1).toUpperCase(Locale.ROOT);
}
private static String action(String resource, String action) {
return objectType(resource) + "_" + action;
}
private static Map<String, Object> auditValue(ResourceView view) {
Map<String, Object> value = values();
value.put("resource", view.resource());
value.put("businessCode", view.businessCode());
value.put("status", view.status());
value.put("version", view.version());
value.put("values", view.values());
return value;
}
private static Map<String, Object> values() {
return new LinkedHashMap<>();
}
private static String required(String value, String field, int max) {
String result = clean(value, max);
if (result == null) throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY,
ErrorCode.VALIDATION_FAILED, "请填写必填字段", Map.of(field, "此项不能为空"));
return result;
}
private static String clean(String value, int max) {
if (value == null || value.isBlank()) return null;
String result = value.trim();
if (result.length() > max) throw validation("字段长度不能超过 " + max + " 个字符");
return result;
}
private static String publicId(String value) {
String normalized = required(value, "publicId", 26).toUpperCase(Locale.ROOT);
if (!PUBLIC_ID.matcher(normalized).matches()) throw validation("公开编号格式无效");
return normalized;
}
private static String optionalPublicId(String value) {
String normalized = clean(value, 26);
return normalized == null ? null : publicId(normalized);
}
private static int positive(Integer value, String field) {
if (value == null || value < 1) throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY,
ErrorCode.VALIDATION_FAILED, "版本号必须大于零", Map.of(field, "必须大于零"));
return value;
}
private static void checkVersion(long actual, Long expected) {
if (expected == null || actual != expected) throw conflict();
}
private static void ensureUpdated(int changed) {
if (changed != 1) throw conflict();
}
private static void ensureCreated(int changed) {
if (changed != 1) throw validation("新模板版本必须基于当前生效模板");
}
private static BusinessException invalidResource() {
return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID,
"不支持的系统配置资源");
}
private static BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
}
private static BusinessException notFound(String message) {
return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message);
}
private static BusinessException duplicate(String resource) {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE,
resource + " 配置已存在");
}
private static BusinessException conflict() {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT,
"配置已被其他用户更新,请刷新后重试");
}
private static BusinessException invalidState(String message) {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message);
}
private record ScopeValues(String type, String companyId, String projectId, BigDecimal amountLimit) {
}
}
@@ -0,0 +1,300 @@
package com.kaidi.finance.governance.infrastructure;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** Write and detail queries for the isolated system-governance area. */
@org.apache.ibatis.annotations.Mapper
public interface GovernanceMapper {
@Select("""
SELECT id, public_id, username, display_name, department_name, enabled, version
FROM iam_user WHERE public_id = #{publicId}
""")
UserRow findUser(String publicId);
@Select("""
SELECT id, code, name, description, version, enabled
FROM iam_role WHERE code = #{code}
""")
RoleRow findRole(String code);
@Select("SELECT id FROM iam_permission WHERE code = #{code}")
Long permissionId(String code);
@Select("SELECT id FROM iam_scope WHERE id = #{id}")
Long scopeId(long id);
@Select("""
SELECT id FROM iam_scope
WHERE user_id = #{userId} AND role_id = #{roleId} AND permission_id = #{permissionId}
AND scope_type = #{scopeType}
AND (company_public_id <=> #{companyId}) AND (project_public_id <=> #{projectId})
""")
Long scopeIdForGrant(@Param("userId") long userId, @Param("roleId") long roleId,
@Param("permissionId") long permissionId, @Param("scopeType") String scopeType,
@Param("companyId") String companyId, @Param("projectId") String projectId);
@Select("""
SELECT scope.id, user_account.public_id AS user_public_id, user_account.username,
role.code AS role_code, permission.code AS permission_code, scope.scope_type,
scope.company_public_id, scope.project_public_id, scope.amount_limit,
scope.status, scope.version
FROM iam_scope scope
JOIN iam_user user_account ON user_account.id = scope.user_id
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.id = #{id}
""")
ScopeRow findScope(long id);
@Select("""
SELECT id, public_id, form_type, template_version, name, schema_version, status,
published_at, version
FROM src_template WHERE public_id = #{publicId}
""")
TemplateRow findTemplate(String publicId);
@Select("""
SELECT id, public_id, parameter_group, version_no, CAST(value_json AS CHAR) AS value_json,
status, effective_at, version
FROM sys_parameter_version WHERE public_id = #{publicId}
""")
ParameterRow findParameter(String publicId);
@Select("SELECT COALESCE(MAX(version_no), 0) + 1 FROM sys_parameter_version WHERE parameter_group = #{parameterGroup}")
int nextParameterVersion(String parameterGroup);
@Select("SELECT GET_LOCK(CONCAT('kaidi:governance:parameter:', #{parameterGroup}), 10)")
Integer lockParameterVersionGroup(String parameterGroup);
@Select("SELECT RELEASE_LOCK(CONCAT('kaidi:governance:parameter:', #{parameterGroup}))")
Integer unlockParameterVersionGroup(String parameterGroup);
@Select("""
SELECT role.code FROM iam_user_role assignment
JOIN iam_role role ON role.id = assignment.role_id
WHERE assignment.user_id = #{userId}
ORDER BY role.sort_order, role.code
""")
List<String> listRoleCodes(long userId);
@Select("""
SELECT permission.code FROM iam_role_permission assignment
JOIN iam_permission permission ON permission.id = assignment.permission_id
WHERE assignment.role_id = #{roleId}
ORDER BY permission.code
""")
List<String> listPermissionCodes(long roleId);
@Select("""
SELECT DISTINCT user_account.username
FROM iam_user user_account
JOIN iam_user_role assignment ON assignment.user_id = user_account.id
WHERE assignment.role_id = #{roleId}
""")
List<String> listUsernamesByRole(long roleId);
@Select("SELECT COUNT(*) FROM iam_user_role WHERE user_id = #{userId} AND role_id = #{roleId}")
int userHasRole(@Param("userId") long userId, @Param("roleId") long roleId);
@Select("SELECT COUNT(*) FROM iam_role_permission WHERE role_id = #{roleId} AND permission_id = #{permissionId}")
int roleHasPermission(@Param("roleId") long roleId, @Param("permissionId") long permissionId);
@Insert("""
INSERT INTO iam_user (public_id, username, display_name, department_name, password_hash,
enabled, must_change_password)
VALUES (#{publicId}, #{username}, #{displayName}, #{departmentName}, #{passwordHash}, TRUE, TRUE)
""")
int insertUser(@Param("publicId") String publicId, @Param("username") String username,
@Param("displayName") String displayName, @Param("departmentName") String departmentName,
@Param("passwordHash") String passwordHash);
@Insert("INSERT INTO iam_user_role (user_id, role_id) VALUES (#{userId}, #{roleId})")
int insertUserRole(@Param("userId") long userId, @Param("roleId") long roleId);
@Insert("INSERT INTO iam_role_permission (role_id, permission_id) VALUES (#{roleId}, #{permissionId})")
int insertRolePermission(@Param("roleId") long roleId, @Param("permissionId") long permissionId);
@Insert("INSERT INTO iam_role (code, name, description, enabled) VALUES (#{code}, #{name}, #{description}, TRUE)")
int insertRole(@Param("code") String code, @Param("name") String name, @Param("description") String description);
@Insert("""
INSERT INTO iam_scope (user_id, role_id, permission_id, scope_type, company_public_id,
project_public_id, amount_limit, status)
VALUES (#{userId}, #{roleId}, #{permissionId}, #{scopeType}, #{companyId}, #{projectId}, #{amountLimit}, 'ACTIVE')
""")
int insertScope(@Param("userId") long userId, @Param("roleId") long roleId,
@Param("permissionId") long permissionId, @Param("scopeType") String scopeType,
@Param("companyId") String companyId, @Param("projectId") String projectId,
@Param("amountLimit") BigDecimal amountLimit);
@Insert("""
INSERT INTO src_template (public_id, form_type, template_version, name, group_code, purpose,
initiator_roles, schema_version, status, created_by, updated_by)
SELECT #{publicId}, #{formType}, #{templateVersion}, #{name}, source.group_code, source.purpose,
source.initiator_roles, #{schemaVersion}, 'DRAFT', #{actorId}, #{actorId}
FROM src_template source
WHERE source.form_type = #{formType} AND source.status = 'ACTIVE'
ORDER BY source.template_version DESC
LIMIT 1
""")
int insertTemplate(@Param("publicId") String publicId, @Param("formType") String formType,
@Param("templateVersion") int templateVersion, @Param("name") String name,
@Param("schemaVersion") int schemaVersion, @Param("actorId") long actorId);
@Insert("""
INSERT INTO src_template_field (
template_id, field_code, label, section_code, section_name, data_type, required_flag, repeatable,
max_length, numeric_precision, numeric_scale, required_when_json, enum_code, options_json, unit,
sensitivity, masking_rule, mapping_code, import_column, help_text, sort_order
)
SELECT #{targetTemplateId}, field_code, label, section_code, section_name, data_type, required_flag,
repeatable, max_length, numeric_precision, numeric_scale, required_when_json, enum_code,
options_json, unit, sensitivity, masking_rule, mapping_code, import_column, help_text, sort_order
FROM src_template_field
WHERE template_id = #{sourceTemplateId}
""")
int copyTemplateFields(@Param("sourceTemplateId") long sourceTemplateId,
@Param("targetTemplateId") long targetTemplateId);
@Select("""
SELECT id, public_id, form_type, template_version, name, schema_version, status, published_at, version
FROM src_template
WHERE form_type = #{formType} AND status = 'ACTIVE'
ORDER BY template_version DESC
LIMIT 1
""")
TemplateRow findActiveTemplateByFormType(String formType);
@Select("SELECT COUNT(*) FROM src_template_field WHERE template_id = #{templateId}")
int countTemplateFields(long templateId);
@Select("SELECT id FROM src_template WHERE form_type = #{formType} ORDER BY id FOR UPDATE")
List<Long> lockTemplateVersions(String formType);
@Insert("""
INSERT INTO sys_parameter_version (public_id, parameter_group, version_no, value_json, status, created_by)
VALUES (#{publicId}, #{parameterGroup}, #{versionNo}, CAST(#{valueJson} AS JSON), 'DRAFT', #{actorPublicId})
""")
int insertParameter(@Param("publicId") String publicId, @Param("parameterGroup") String parameterGroup,
@Param("versionNo") int versionNo, @Param("valueJson") String valueJson,
@Param("actorPublicId") String actorPublicId);
@Update("DELETE FROM iam_user_role WHERE user_id = #{userId}")
int deleteUserRoles(long userId);
@Update("DELETE FROM iam_scope WHERE user_id = #{userId}")
int deleteUserScopes(long userId);
@Update("DELETE FROM iam_role_permission WHERE role_id = #{roleId}")
int deleteRolePermissions(long roleId);
@Update("""
UPDATE iam_user SET display_name = COALESCE(#{displayName}, display_name),
department_name = COALESCE(#{departmentName}, department_name),
enabled = COALESCE(#{enabled}, enabled), version = version + 1
WHERE id = #{id} AND version = #{version}
""")
int updateUser(@Param("id") long id, @Param("version") long version, @Param("displayName") String displayName,
@Param("departmentName") String departmentName, @Param("enabled") Boolean enabled);
@Update("""
UPDATE iam_role SET name = COALESCE(#{name}, name), description = COALESCE(#{description}, description),
enabled = COALESCE(#{enabled}, enabled), version = version + 1, updated_by = #{actorId}
WHERE id = #{id} AND version = #{version}
""")
int updateRole(@Param("id") long id, @Param("version") long version, @Param("name") String name,
@Param("description") String description, @Param("enabled") Boolean enabled,
@Param("actorId") long actorId);
@Update("""
UPDATE iam_scope SET scope_type = COALESCE(#{scopeType}, scope_type), company_public_id = #{companyId},
project_public_id = #{projectId}, amount_limit = #{amountLimit}, status = COALESCE(#{status}, status),
version = version + 1, updated_at = UTC_TIMESTAMP(3)
WHERE id = #{id} AND version = #{version}
""")
int updateScope(@Param("id") long id, @Param("version") long version, @Param("scopeType") String scopeType,
@Param("companyId") String companyId, @Param("projectId") String projectId,
@Param("amountLimit") BigDecimal amountLimit, @Param("status") String status);
@Update("""
UPDATE src_template SET name = COALESCE(#{name}, name), status = COALESCE(#{status}, status),
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version}
""")
int updateTemplate(@Param("id") long id, @Param("version") long version, @Param("name") String name,
@Param("status") String status, @Param("actorId") long actorId);
@Update("""
UPDATE sys_parameter_version
SET value_json = COALESCE(CAST(#{valueJson} AS JSON), value_json),
status = COALESCE(#{status}, status), updated_by = #{actorPublicId},
version = version + 1, updated_at = UTC_TIMESTAMP(3)
WHERE id = #{id} AND version = #{version}
""")
int updateParameter(@Param("id") long id, @Param("version") long version,
@Param("valueJson") String valueJson, @Param("status") String status,
@Param("actorPublicId") String actorPublicId);
@Update("UPDATE iam_user SET enabled = #{enabled}, version = version + 1 WHERE id = #{id} AND version = #{version}")
int setUserEnabled(@Param("id") long id, @Param("version") long version, @Param("enabled") boolean enabled);
@Update("UPDATE iam_role SET enabled = #{enabled}, version = version + 1, updated_by = #{actorId} WHERE id = #{id} AND version = #{version}")
int setRoleEnabled(@Param("id") long id, @Param("version") long version, @Param("enabled") boolean enabled,
@Param("actorId") long actorId);
@Update("UPDATE iam_scope SET status = #{status}, version = version + 1, updated_at = UTC_TIMESTAMP(3) WHERE id = #{id} AND version = #{version}")
int setScopeStatus(@Param("id") long id, @Param("version") long version, @Param("status") String status);
@Update("UPDATE src_template SET status = #{status}, published_at = CASE WHEN #{status} = 'ACTIVE' THEN UTC_TIMESTAMP(3) ELSE published_at END, version = version + 1, updated_by = #{actorId} WHERE id = #{id} AND version = #{version}")
int setTemplateStatus(@Param("id") long id, @Param("version") long version, @Param("status") String status,
@Param("actorId") long actorId);
@Update("UPDATE src_template SET status = 'RETIRED', updated_by = #{actorId}, version = version + 1 WHERE form_type = #{formType} AND status = 'ACTIVE' AND id <> #{id}")
int retireOtherTemplates(@Param("formType") String formType, @Param("id") long id, @Param("actorId") long actorId);
@Update("UPDATE sys_parameter_version SET status = 'RETIRED', version = version + 1, updated_by = #{actorPublicId}, updated_at = UTC_TIMESTAMP(3) WHERE parameter_group = #{parameterGroup} AND status = 'ACTIVE' AND id <> #{id}")
int retireOtherParameters(@Param("parameterGroup") String parameterGroup, @Param("id") long id,
@Param("actorPublicId") String actorPublicId);
@Select("SELECT id FROM sys_parameter_version WHERE parameter_group = #{parameterGroup} ORDER BY id FOR UPDATE")
List<Long> lockParameterVersions(String parameterGroup);
@Update("""
UPDATE sys_parameter_version SET status = #{status},
effective_at = CASE WHEN #{status} = 'ACTIVE' THEN UTC_TIMESTAMP(3) ELSE effective_at END,
updated_by = #{actorPublicId}, version = version + 1, updated_at = UTC_TIMESTAMP(3)
WHERE id = #{id} AND version = #{version}
""")
int setParameterStatus(@Param("id") long id, @Param("version") long version,
@Param("status") String status, @Param("actorPublicId") String actorPublicId);
@Update("DELETE FROM SPRING_SESSION WHERE PRINCIPAL_NAME = #{username}")
int invalidateSessions(String username);
record UserRow(long id, String publicId, String username, String displayName, String departmentName,
boolean enabled, long version) {
}
record RoleRow(long id, String code, String name, String description, long version, boolean enabled) {
}
record ScopeRow(long id, String userPublicId, String username, String roleCode, String permissionCode,
String scopeType, String companyPublicId, String projectPublicId,
BigDecimal amountLimit, String status, long version) {
}
record TemplateRow(long id, String publicId, String formType, int templateVersion, String name,
int schemaVersion, String status, LocalDateTime publishedAt, long version) {
}
record ParameterRow(long id, String publicId, String parameterGroup, int versionNo, String valueJson,
String status, LocalDateTime effectiveAt, long version) {
}
}
@@ -0,0 +1,89 @@
package com.kaidi.finance.iam.api;
import com.kaidi.finance.iam.application.AuthApplicationService;
import com.kaidi.finance.shared.api.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthApplicationService authService;
public AuthController(AuthApplicationService authService) {
this.authService = authService;
}
@GetMapping("/csrf")
public ApiResponse<CsrfView> csrf(HttpServletRequest request) {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
return ApiResponse.ok(new CsrfView(token.getHeaderName(), token.getParameterName(), token.getToken()));
}
@PostMapping("/login")
public ApiResponse<SessionView> login(@Valid @RequestBody LoginRequest login, HttpServletRequest request) {
return ApiResponse.ok(authService.login(login, request));
}
@GetMapping("/session")
public ApiResponse<SessionView> session(HttpSession session) {
return ApiResponse.ok(authService.current(session));
}
@GetMapping("/profile")
public ApiResponse<UserView> profile(HttpSession session) {
return ApiResponse.ok(authService.profile(session));
}
@GetMapping("/roles")
public ApiResponse<List<RoleView>> roles(HttpSession session) {
return ApiResponse.ok(authService.current(session).roles());
}
@GetMapping("/sessions")
public ApiResponse<List<SessionSummaryView>> sessions(HttpServletRequest request) {
return ApiResponse.ok(authService.sessions(request));
}
@DeleteMapping("/sessions/{sessionId}")
public ApiResponse<Void> deleteSession(@org.springframework.web.bind.annotation.PathVariable String sessionId,
HttpServletRequest request) {
authService.deleteSession(sessionId, request);
return ApiResponse.ok(null);
}
@DeleteMapping("/sessions")
public ApiResponse<Void> deleteAllSessions(HttpServletRequest request) {
authService.deleteAllSessions(request);
return ApiResponse.ok(null);
}
@PostMapping("/select-role")
public ApiResponse<SessionView> selectRole(@Valid @RequestBody SelectRoleRequest selection,
HttpServletRequest request) {
return ApiResponse.ok(authService.selectRole(selection.roleCode(), request));
}
@PatchMapping("/password")
public ApiResponse<SessionView> changePassword(@Valid @RequestBody PasswordChangeRequest change,
HttpServletRequest request) {
return ApiResponse.ok(authService.changePassword(change, request));
}
@PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request) {
authService.logout(request);
return ApiResponse.ok(null);
}
}
@@ -0,0 +1,19 @@
package com.kaidi.finance.iam.api;
import java.math.BigDecimal;
/** A grouped, read-only summary of the current user's effective data scope. */
public record AuthorizationScopeView(
String roleCode,
String roleName,
String scopeType,
String companyPublicId,
String companyCode,
String companyName,
String projectPublicId,
String projectCode,
String projectName,
long permissionCount,
BigDecimal amountLimit
) {
}
@@ -0,0 +1,4 @@
package com.kaidi.finance.iam.api;
public record CsrfView(String headerName, String parameterName, String token) {
}
@@ -0,0 +1,13 @@
package com.kaidi.finance.iam.api;
import com.fasterxml.jackson.annotation.JsonAlias;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record LoginRequest(
@JsonAlias("account") @NotBlank(message = "请输入账号") @Size(max = 64, message = "账号长度不能超过 64 位")
String username,
@NotBlank(message = "请输入密码") @Size(min = 12, max = 64, message = "密码长度必须为 12 到 64 位")
String password
) {
}
@@ -0,0 +1,12 @@
package com.kaidi.finance.iam.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record PasswordChangeRequest(
@NotBlank(message = "请输入当前密码") String currentPassword,
@NotBlank(message = "请输入新密码") @Size(min = 12, max = 100, message = "新密码长度必须为 12 到 100 位")
String newPassword,
@NotBlank(message = "请再次输入新密码") String confirmPassword
) {
}
@@ -0,0 +1,7 @@
package com.kaidi.finance.iam.api;
import java.time.Instant;
/** A deliberately reduced login history view; raw user-agent and IP are never exposed. */
public record RecentLoginView(Instant occurredAt, String deviceSummary, String maskedIp) {
}
@@ -0,0 +1,4 @@
package com.kaidi.finance.iam.api;
public record RoleView(String code, String name, String description, String workbenchRoute) {
}
@@ -0,0 +1,6 @@
package com.kaidi.finance.iam.api;
import jakarta.validation.constraints.NotBlank;
public record SelectRoleRequest(@NotBlank(message = "请选择工作身份") String roleCode) {
}
@@ -0,0 +1,8 @@
package com.kaidi.finance.iam.api;
import java.time.Instant;
public record SessionSummaryView(String sessionId, Instant createdAt, Instant lastAccessedAt,
Instant expiresAt, boolean current, String deviceSummary,
String maskedIp) {
}
@@ -0,0 +1,10 @@
package com.kaidi.finance.iam.api;
import java.util.List;
import java.util.Set;
import java.time.Instant;
public record SessionView(boolean authenticated, UserView user, List<RoleView> roles, String activeRole,
Set<String> permissions, String permissionVersion, Instant sessionExpiresAt,
Instant absoluteSessionExpiresAt) {
}
@@ -0,0 +1,13 @@
package com.kaidi.finance.iam.api;
import java.util.List;
public record UserView(String publicId, String username, String displayName, String departmentName,
boolean mustChangePassword, List<AuthorizationScopeView> authorizationScopes,
List<RecentLoginView> recentLogins) {
public UserView(String publicId, String username, String displayName, String departmentName,
boolean mustChangePassword) {
this(publicId, username, displayName, departmentName, mustChangePassword, null, null);
}
}
@@ -0,0 +1,417 @@
package com.kaidi.finance.iam.application;
import com.kaidi.finance.iam.api.LoginRequest;
import com.kaidi.finance.iam.api.AuthorizationScopeView;
import com.kaidi.finance.iam.api.PasswordChangeRequest;
import com.kaidi.finance.iam.api.RecentLoginView;
import com.kaidi.finance.iam.api.RoleView;
import com.kaidi.finance.iam.api.SessionView;
import com.kaidi.finance.iam.api.SessionSummaryView;
import com.kaidi.finance.iam.api.UserView;
import com.kaidi.finance.iam.domain.FinancePrincipal;
import com.kaidi.finance.iam.domain.RoleAssignment;
import com.kaidi.finance.iam.domain.UserAccount;
import com.kaidi.finance.iam.infrastructure.UserAccountMapper;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.audit.AuditMapper;
import com.kaidi.finance.shared.security.AuthorizationMapper;
import com.kaidi.finance.shared.security.IdentityContext;
import com.kaidi.finance.shared.security.SessionLifecycle;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.Instant;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.ArrayList;
import java.util.Comparator;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuthApplicationService {
private final UserAccountMapper userAccountMapper;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
private final AuditMapper auditMapper;
private final AuthorizationMapper authorizationMapper;
private final IdentityContext identityContext;
private final FindByIndexNameSessionRepository<? extends Session> sessionRepository;
private final Duration absoluteTimeout;
public AuthApplicationService(UserAccountMapper userAccountMapper, PasswordEncoder passwordEncoder,
AuditService auditService, AuditMapper auditMapper,
AuthorizationMapper authorizationMapper, IdentityContext identityContext,
FindByIndexNameSessionRepository<? extends Session> sessionRepository,
@org.springframework.beans.factory.annotation.Value("${finance.session.absolute-timeout:8h}") Duration absoluteTimeout) {
this.userAccountMapper = userAccountMapper;
this.passwordEncoder = passwordEncoder;
this.auditService = auditService;
this.auditMapper = auditMapper;
this.authorizationMapper = authorizationMapper;
this.identityContext = identityContext;
this.sessionRepository = sessionRepository;
this.absoluteTimeout = absoluteTimeout;
}
@Transactional(noRollbackFor = BusinessException.class)
public SessionView login(LoginRequest login, HttpServletRequest request) {
String username = login.username().trim().toLowerCase(Locale.ROOT);
UserAccount account = userAccountMapper.findByUsername(username);
if (account == null) {
auditService.recordForUser(null, username, "AUTH_LOGIN", "FAILED", "BAD_CREDENTIALS");
throw badCredentials();
}
if (!account.isEnabled()) {
auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED", "ACCOUNT_DISABLED");
throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AUTH_ACCOUNT_DISABLED, "账号已停用,请联系管理员");
}
if (account.getLockedUntil() != null
&& account.getLockedUntil().isAfter(LocalDateTime.now(ZoneOffset.UTC))) {
auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED", "ACCOUNT_LOCKED");
throw new BusinessException(HttpStatus.LOCKED, ErrorCode.AUTH_ACCOUNT_LOCKED,
"账号因连续登录失败已临时锁定,请稍后再试");
}
if (!passwordEncoder.matches(login.password(), account.getPasswordHash())) {
userAccountMapper.recordFailedLogin(account.getId());
auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED", "BAD_CREDENTIALS");
throw badCredentials();
}
userAccountMapper.clearFailedLogins(account.getId());
List<RoleAssignment> roles = userAccountMapper.findRoles(account.getId());
if (roles.isEmpty()) {
auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED", "NO_ROLE");
throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.ACCESS_DENIED, "账号尚未分配工作身份");
}
if (hasAdministratorBusinessRoleConflict(roles)) {
auditService.recordForUser(account.getPublicId(), username, "AUTH_LOGIN", "FAILED",
"ROLE_CONFIGURATION_CONFLICT");
throw new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.ACCESS_DENIED,
"系统管理员身份不能与业务身份同时授予同一账号");
}
FinancePrincipal principal = new FinancePrincipal(account.getId(), account.getPublicId(), account.getUsername(),
account.getDisplayName(), account.getDepartmentName(), account.isMustChangePassword(), List.copyOf(roles));
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated(
principal, null, principal.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
HttpSession session = request.getSession(true);
request.changeSessionId();
session.setAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT,
Instant.ofEpochMilli(session.getCreationTime()).plus(absoluteTimeout));
setSessionDeviceMetadata(session, request);
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
session.removeAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY);
auditService.record("AUTH_LOGIN", "SESSION", account.getPublicId(), "SUCCESS", null, null,
Map.of("username", account.getUsername()));
evictExcessSessions(principal.username(), session.getId(), account.getPublicId());
return view(principal, session);
}
@Transactional(readOnly = true)
public SessionView current(HttpSession session) {
return view(identityContext.requirePrincipal(), session);
}
@Transactional(readOnly = true)
public List<SessionSummaryView> sessions(HttpServletRequest request) {
FinancePrincipal principal = identityContext.requirePrincipal();
String currentId = request.getSession(false) == null ? null : request.getSession(false).getId();
List<SessionSummaryView> result = new ArrayList<>();
sessionRepository.findByPrincipalName(principal.username()).values().stream()
.sorted(Comparator.comparing(Session::getCreationTime).thenComparing(Session::getId))
.forEach(session -> {
Instant idleExpiresAt = session.getLastAccessedTime().plus(session.getMaxInactiveInterval());
Instant absoluteExpiresAt = absoluteExpiresAt(session);
Instant expiresAt = idleExpiresAt.isBefore(absoluteExpiresAt) ? idleExpiresAt : absoluteExpiresAt;
result.add(new SessionSummaryView(session.getId(), session.getCreationTime(),
session.getLastAccessedTime(), expiresAt, session.getId().equals(currentId),
stringAttribute(session, SessionLifecycle.DEVICE_SUMMARY, "未知设备"),
stringAttribute(session, SessionLifecycle.MASKED_IP, "未知")));
});
return result;
}
/** Returns the richer,本人范围内 profile payload used by PAGE-03. */
@Transactional(readOnly = true)
public UserView profile(HttpSession session) {
FinancePrincipal principal = identityContext.requirePrincipal();
String activeRole = session == null ? null
: (String) session.getAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY);
List<AuthorizationScopeView> scopes = authorizationMapper.findProfileScopes(principal.userId(), activeRole)
.stream()
.map(scope -> new AuthorizationScopeView(scope.roleCode(), scope.roleName(), scope.scopeType(),
scope.companyPublicId(), scope.companyCode(), scope.companyName(), scope.projectPublicId(),
scope.projectCode(), scope.projectName(), scope.permissionCount(), scope.amountLimit()))
.toList();
List<RecentLoginView> recentLogins = auditMapper.listRecentSuccessfulLogins(principal.publicId(), 10).stream()
.map(login -> new RecentLoginView(login.occurredAt().toInstant(ZoneOffset.UTC),
deviceSummary(login.userAgent()), maskIp(login.ipAddress())))
.toList();
return new UserView(principal.publicId(), principal.username(), principal.displayName(),
principal.departmentName(), principal.mustChangePassword(), scopes, recentLogins);
}
@Transactional
public void deleteSession(String sessionId, HttpServletRequest request) {
FinancePrincipal principal = identityContext.requirePrincipal();
Session target = sessionRepository.findByPrincipalName(principal.username()).get(sessionId);
if (target == null) {
throw new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, "会话不存在");
}
sessionRepository.deleteById(sessionId);
HttpSession current = request.getSession(false);
if (current != null && current.getId().equals(sessionId)) {
current.invalidate();
SecurityContextHolder.clearContext();
}
}
@Transactional
public void deleteAllSessions(HttpServletRequest request) {
FinancePrincipal principal = identityContext.requirePrincipal();
sessionRepository.findByPrincipalName(principal.username()).keySet()
.forEach(sessionRepository::deleteById);
HttpSession current = request.getSession(false);
if (current != null) current.invalidate();
SecurityContextHolder.clearContext();
}
@Transactional(noRollbackFor = BusinessException.class)
public SessionView selectRole(String requestedRole, HttpServletRequest request) {
FinancePrincipal principal = identityContext.requirePrincipal();
String roleCode = requestedRole.trim().toUpperCase(Locale.ROOT);
List<RoleAssignment> currentRoles = currentRoles(principal);
RoleAssignment role = currentRoles.stream().filter(candidate -> candidate.code().equals(roleCode))
.findFirst()
.orElseThrow(() -> new BusinessException(HttpStatus.FORBIDDEN, ErrorCode.AUTH_ROLE_NOT_GRANTED,
"当前账号未被授予该工作身份"));
String before = identityContext.activeRole();
HttpSession session = request.getSession(true);
request.changeSessionId();
setSessionDeviceMetadata(session, request);
session.setAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY, role.code());
auditService.record("AUTH_ROLE_SELECT", "SESSION", principal.publicId(), "SUCCESS", null,
Map.of("activeRole", before == null ? "" : before), Map.of("activeRole", role.code()));
return view(principal, session);
}
@Transactional
public SessionView changePassword(PasswordChangeRequest change, HttpServletRequest request) {
FinancePrincipal principal = identityContext.requirePrincipal();
UserAccount account = userAccountMapper.findByUsername(principal.username());
if (!passwordEncoder.matches(change.currentPassword(), account.getPasswordHash())) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
"当前密码不正确", Map.of("currentPassword", "当前密码不正确"));
}
if (!change.newPassword().equals(change.confirmPassword())) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
"两次输入的新密码不一致", Map.of("confirmPassword", "两次输入的新密码不一致"));
}
validatePassword(change.newPassword());
if (passwordEncoder.matches(change.newPassword(), account.getPasswordHash())) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
"新密码不能与当前密码相同", Map.of("newPassword", "新密码不能与当前密码相同"));
}
int updated = userAccountMapper.updatePassword(account.getId(), account.getVersion(),
passwordEncoder.encode(change.newPassword()));
if (updated != 1) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT,
"账号资料已被更新,请重新登录后再修改密码");
}
auditService.record("AUTH_PASSWORD_CHANGE", "USER", principal.publicId(), "SUCCESS", null, null, null);
SecurityContext context = SecurityContextHolder.getContext();
FinancePrincipal refreshed = new FinancePrincipal(principal.userId(), principal.publicId(), principal.username(),
principal.displayName(), principal.departmentName(), false, principal.roles());
context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(
refreshed, null, refreshed.getAuthorities()));
HttpSession current = request.getSession(false);
if (sessionRepository != null) {
sessionRepository.findByPrincipalName(principal.username()).keySet().stream()
.filter(id -> current == null || !id.equals(current.getId()))
.forEach(sessionRepository::deleteById);
}
if (current != null) {
current.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
}
return view(refreshed, current);
}
@Transactional
public void logout(HttpServletRequest request) {
FinancePrincipal principal = identityContext.requirePrincipal();
auditService.record("AUTH_LOGOUT", "SESSION", principal.publicId(), "SUCCESS", null, null, null);
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
SecurityContextHolder.clearContext();
}
private SessionView view(FinancePrincipal principal, HttpSession session) {
List<RoleAssignment> currentRoles = currentRoles(principal);
String activeRole = session == null ? null : (String) session.getAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY);
String activeRoleSnapshot = activeRole;
boolean activeRoleGranted = activeRoleSnapshot != null
&& currentRoles.stream().anyMatch(role -> role.code().equals(activeRoleSnapshot));
if (activeRole != null && !activeRoleGranted) {
session.removeAttribute(AuditService.ACTIVE_ROLE_SESSION_KEY);
activeRole = null;
}
Set<String> permissions = activeRole == null ? Set.of()
: new LinkedHashSet<>(userAccountMapper.findPermissions(principal.userId(), activeRole));
UserView user = new UserView(principal.publicId(), principal.username(), principal.displayName(),
principal.departmentName(), principal.mustChangePassword());
List<RoleView> roles = currentRoles.stream()
.map(role -> new RoleView(role.code(), role.name(), role.description(), workbenchRoute(role.code())))
.toList();
String permissionVersion = Integer.toUnsignedString(permissions.hashCode(), 16);
Instant sessionExpiresAt = session == null ? null
: Instant.ofEpochMilli(session.getLastAccessedTime()).plusSeconds(session.getMaxInactiveInterval());
Instant absoluteSessionExpiresAt = session == null ? null : absoluteExpiresAt(session);
if (sessionExpiresAt != null && absoluteSessionExpiresAt != null && absoluteSessionExpiresAt.isBefore(sessionExpiresAt)) {
sessionExpiresAt = absoluteSessionExpiresAt;
}
return new SessionView(true, user, roles, activeRole, permissions, permissionVersion, sessionExpiresAt,
absoluteSessionExpiresAt);
}
private Instant absoluteExpiresAt(jakarta.servlet.http.HttpSession session) {
Object value = session.getAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT);
if (value instanceof Instant instant) return instant;
Instant fallback = Instant.ofEpochMilli(session.getCreationTime()).plus(absoluteTimeout);
session.setAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT, fallback);
return fallback;
}
private Instant absoluteExpiresAt(Session session) {
Object value = session.getAttribute(SessionLifecycle.ABSOLUTE_EXPIRES_AT);
if (value instanceof Instant instant) return instant;
return session.getCreationTime().plus(absoluteTimeout);
}
private void setSessionDeviceMetadata(HttpSession session, HttpServletRequest request) {
if (session == null || request == null) return;
session.setAttribute(SessionLifecycle.DEVICE_SUMMARY, deviceSummary(request.getHeader("User-Agent")));
session.setAttribute(SessionLifecycle.MASKED_IP, maskIp(clientIp(request)));
}
private String stringAttribute(Session session, String key, String fallback) {
Object value = session.getAttribute(key);
return value instanceof String text && !text.isBlank() ? text : fallback;
}
private String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) return forwarded.split(",")[0].trim();
return request.getRemoteAddr();
}
private String maskIp(String value) {
if (value == null || value.isBlank()) return "未知";
String ip = value.trim();
if (ip.contains(".")) {
String[] parts = ip.split("\\.");
if (parts.length == 4) return parts[0] + "." + parts[1] + ".*.*";
}
if (ip.contains(":")) {
String[] parts = ip.split(":", -1);
int visible = Math.min(3, parts.length);
return String.join(":", java.util.Arrays.copyOf(parts, visible)) + ":*";
}
return "已脱敏";
}
private String deviceSummary(String userAgent) {
if (userAgent == null || userAgent.isBlank()) return "未知设备";
String ua = userAgent.toLowerCase(Locale.ROOT);
String browser = ua.contains("edg/") ? "Edge" : ua.contains("chrome/") ? "Chrome"
: ua.contains("firefox/") ? "Firefox" : ua.contains("safari/") ? "Safari" : "浏览器";
String platform = ua.contains("iphone") || ua.contains("ipad") || ua.contains("android") ? "移动端"
: ua.contains("mac os") || ua.contains("macintosh") ? "macOS" : ua.contains("windows") ? "Windows"
: ua.contains("linux") ? "Linux" : "桌面端";
return browser + " · " + platform;
}
private void evictExcessSessions(String username, String currentId, String userPublicId) {
synchronized (("kaidi-session-limit:" + username).intern()) {
List<? extends Session> sessions = sessionRepository.findByPrincipalName(username).values().stream()
.sorted(Comparator.comparing(Session::getCreationTime).thenComparing(Session::getId))
.toList();
boolean currentAlreadyIndexed = sessions.stream().anyMatch(session -> session.getId().equals(currentId));
int excess = sessions.size() + (currentAlreadyIndexed ? 0 : 1) - SessionLifecycle.MAX_SESSIONS_PER_USER;
for (Session candidate : sessions) {
if (excess <= 0) break;
if (candidate.getId().equals(currentId)) continue;
sessionRepository.deleteById(candidate.getId());
auditService.recordForUser(userPublicId, username, "AUTH_SESSION_EVICTED", "SUCCESS",
candidate.getId());
excess--;
}
}
}
private String workbenchRoute(String roleCode) {
return switch (roleCode) {
case "PROJECT_MANAGER" -> "/workbench/project";
case "FINANCE_MANAGER" -> "/workbench/finance";
case "ARCHIVE_MANAGER" -> "/workbench/archive";
case "SYSTEM_ADMIN" -> "/governance/settings";
default -> "/role-select";
};
}
private boolean hasAdministratorBusinessRoleConflict(List<RoleAssignment> roles) {
boolean hasSystemAdministrator = roles.stream().anyMatch(role -> "SYSTEM_ADMIN".equals(role.code()));
return hasSystemAdministrator && roles.stream().anyMatch(role -> !"SYSTEM_ADMIN".equals(role.code()));
}
private List<RoleAssignment> currentRoles(FinancePrincipal principal) {
List<RoleAssignment> roles = userAccountMapper.findRoles(principal.userId());
if (hasAdministratorBusinessRoleConflict(roles)) {
auditService.record("AUTH_ROLE_CONFIGURATION_CONFLICT", "SESSION", principal.publicId(), "FAILED",
"ROLE_CONFIGURATION_CONFLICT", null, null);
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.ACCESS_DENIED,
"系统管理员身份不能与业务身份同时授予同一账号");
}
return roles;
}
private BusinessException badCredentials() {
return new BusinessException(HttpStatus.UNAUTHORIZED, ErrorCode.AUTH_BAD_CREDENTIALS, "账号或密码不正确");
}
private void validatePassword(String password) {
boolean valid = password.length() >= 12
&& password.chars().anyMatch(Character::isUpperCase)
&& password.chars().anyMatch(Character::isLowerCase)
&& password.chars().anyMatch(Character::isDigit)
&& password.chars().anyMatch(character -> !Character.isLetterOrDigit(character));
if (!valid) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
"新密码必须至少 12 位,并包含大小写字母、数字和特殊字符",
Map.of("newPassword", "必须包含大小写字母、数字和特殊字符"));
}
}
}
@@ -0,0 +1,7 @@
package com.kaidi.finance.iam.application;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "finance.bootstrap")
public record BootstrapProperties(boolean enabled, String password, boolean demoData) {
}
@@ -0,0 +1,217 @@
package com.kaidi.finance.iam.application;
import com.kaidi.finance.shared.id.UlidGenerator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class BootstrapUserInitializer implements ApplicationRunner {
private final BootstrapProperties properties;
private final JdbcTemplate jdbcTemplate;
private final PasswordEncoder passwordEncoder;
private final UlidGenerator ulidGenerator;
public BootstrapUserInitializer(BootstrapProperties properties, JdbcTemplate jdbcTemplate,
PasswordEncoder passwordEncoder, UlidGenerator ulidGenerator) {
this.properties = properties;
this.jdbcTemplate = jdbcTemplate;
this.passwordEncoder = passwordEncoder;
this.ulidGenerator = ulidGenerator;
}
@Override
@Transactional
public void run(ApplicationArguments args) {
ensureSystemAdministratorGrants();
if (!properties.enabled()) {
return;
}
if (properties.password() == null || properties.password().length() < 12) {
throw new IllegalStateException("Bootstrap password must contain at least 12 characters");
}
Map<String, SeedUser> users = new LinkedHashMap<>();
users.put("admin", new SeedUser("系统管理员", "信息中心", List.of("SYSTEM_ADMIN")));
if (properties.demoData()) {
users.put("project", new SeedUser("项目经办员", "项目管理部", List.of("PROJECT_MANAGER")));
users.put("finance", new SeedUser("财务审核员", "财务部", List.of("FINANCE_MANAGER")));
users.put("archive", new SeedUser("资料管理员", "资料管理部", List.of("ARCHIVE_MANAGER")));
users.put("demo", new SeedUser("多岗位演示用户", "项目财务中心",
List.of("PROJECT_MANAGER", "FINANCE_MANAGER", "ARCHIVE_MANAGER")));
}
String encodedPassword = passwordEncoder.encode(properties.password());
users.forEach((username, seed) -> ensureUser(username, seed, encodedPassword, !properties.demoData()));
if (properties.demoData()) {
ensureFinanceReferenceData();
}
}
@Transactional
public void ensureSystemAdministratorGrants() {
jdbcTemplate.update("""
INSERT IGNORE INTO iam_role_permission (role_id, permission_id)
SELECT role.id, permission.id
FROM iam_role role
CROSS JOIN iam_permission permission
WHERE role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE
""");
jdbcTemplate.update("""
UPDATE iam_scope scope_grant
JOIN iam_user_role user_role
ON user_role.user_id = scope_grant.user_id
AND user_role.role_id = scope_grant.role_id
JOIN iam_role role ON role.id = user_role.role_id
SET scope_grant.status = 'ACTIVE',
scope_grant.amount_limit = NULL,
scope_grant.valid_from = LEAST(scope_grant.valid_from, UTC_TIMESTAMP(3)),
scope_grant.valid_to = NULL,
scope_grant.version = scope_grant.version + 1
WHERE role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE
AND scope_grant.scope_type = 'GLOBAL'
AND (
scope_grant.status <> 'ACTIVE'
OR scope_grant.amount_limit IS NOT NULL
OR scope_grant.valid_from > UTC_TIMESTAMP(3)
OR scope_grant.valid_to IS NOT NULL
)
""");
jdbcTemplate.update("""
INSERT IGNORE INTO iam_scope (
user_id, role_id, permission_id, scope_type, company_public_id, project_public_id,
amount_limit, status
)
SELECT user_role.user_id, user_role.role_id, role_permission.permission_id,
'GLOBAL', NULL, NULL, NULL, 'ACTIVE'
FROM iam_user_role user_role
JOIN iam_role role ON role.id = user_role.role_id
JOIN iam_role_permission role_permission ON role_permission.role_id = role.id
WHERE role.code = 'SYSTEM_ADMIN' AND role.enabled = TRUE
""");
}
private void ensureUser(String username, SeedUser seed, String encodedPassword, boolean mustChangePassword) {
Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM iam_user WHERE username = ?", Integer.class,
username);
if (count == null || count == 0) {
jdbcTemplate.update("""
INSERT INTO iam_user (public_id, username, display_name, department_name, password_hash,
enabled, must_change_password)
VALUES (?, ?, ?, ?, ?, TRUE, ?)
""", ulidGenerator.next(), username, seed.displayName(), seed.departmentName(), encodedPassword,
mustChangePassword);
}
Long userId = jdbcTemplate.queryForObject("SELECT id FROM iam_user WHERE username = ?", Long.class, username);
for (String roleCode : seed.roles()) {
jdbcTemplate.update("""
INSERT IGNORE INTO iam_user_role (user_id, role_id)
SELECT ?, id FROM iam_role WHERE code = ? AND enabled = TRUE
""", userId, roleCode);
ensureGlobalScopes(userId, roleCode);
}
}
/**
* Keep local fixture accounts aligned with permissions introduced by later Flyway migrations.
* The method is also useful to tests and maintenance commands that need to refresh a built-in
* user's grants after the application has already started.
*/
public void ensureGlobalScopes(long userId, String roleCode) {
jdbcTemplate.update("""
INSERT IGNORE INTO iam_scope (
user_id, role_id, permission_id, scope_type, company_public_id, project_public_id,
amount_limit, status
)
SELECT ?, role.id, role_permission.permission_id, 'GLOBAL', NULL, NULL, NULL, 'ACTIVE'
FROM iam_role role
JOIN iam_role_permission role_permission ON role_permission.role_id = role.id
WHERE role.code = ? AND role.enabled = TRUE
""", userId, roleCode);
}
/**
* Flyway creates the finance reference tables before this runner creates the
* local bootstrap users. Seed the built-in accounts and tax rates here so a
* fresh local database has the same usable baseline as an upgraded database.
* Existing rows are left untouched because they may already be in review or
* disabled states.
*/
private void ensureFinanceReferenceData() {
Long ownerId = jdbcTemplate.queryForObject(
"SELECT id FROM iam_user WHERE username = 'finance' LIMIT 1", Long.class);
if (ownerId == null) {
return;
}
jdbcTemplate.update("""
INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by)
SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?)
""", "00000000000000000000000311", "1002", "银行存款", "ASSET", false,
ownerId, ownerId, "1002");
jdbcTemplate.update("""
INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by)
SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?)
""", "00000000000000000000000312", "1122", "应收账款", "ASSET", true,
ownerId, ownerId, "1122");
jdbcTemplate.update("""
INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by)
SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?)
""", "00000000000000000000000313", "2202", "应付账款", "LIABILITY", true,
ownerId, ownerId, "2202");
jdbcTemplate.update("""
INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by)
SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?)
""", "00000000000000000000000314", "5401", "工程施工成本", "COST", true,
ownerId, ownerId, "5401");
jdbcTemplate.update("""
INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, status, created_by, updated_by)
SELECT ?, ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_account WHERE code = ?)
""", "00000000000000000000000315", "6001", "主营业务收入", "INCOME", true,
ownerId, ownerId, "6001");
jdbcTemplate.update("""
INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by)
SELECT ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?)
""", "00000000000000000000000321", "VAT-0", "免税/零税率", 0.0,
ownerId, ownerId, "VAT-0");
jdbcTemplate.update("""
INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by)
SELECT ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?)
""", "00000000000000000000000322", "VAT-3", "增值税 3%", 0.03,
ownerId, ownerId, "VAT-3");
jdbcTemplate.update("""
INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by)
SELECT ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?)
""", "00000000000000000000000323", "VAT-6", "增值税 6%", 0.06,
ownerId, ownerId, "VAT-6");
jdbcTemplate.update("""
INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by)
SELECT ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?)
""", "00000000000000000000000324", "VAT-9", "增值税 9%", 0.09,
ownerId, ownerId, "VAT-9");
jdbcTemplate.update("""
INSERT INTO md_tax_rate (public_id, code, name, rate, status, created_by, updated_by)
SELECT ?, ?, ?, ?, 'ACTIVE', ?, ?
WHERE NOT EXISTS (SELECT 1 FROM md_tax_rate WHERE code = ?)
""", "00000000000000000000000325", "VAT-13", "增值税 13%", 0.13,
ownerId, ownerId, "VAT-13");
}
private record SeedUser(String displayName, String departmentName, List<String> roles) {
}
}
@@ -0,0 +1,32 @@
package com.kaidi.finance.iam.domain;
import java.io.Serial;
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public record FinancePrincipal(long userId, String publicId, String username, String displayName,
String departmentName, boolean mustChangePassword,
List<RoleAssignment> roles) implements UserDetails, Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_AVAILABLE_" + role.code())).toList();
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return username;
}
}
@@ -0,0 +1,6 @@
package com.kaidi.finance.iam.domain;
import java.io.Serializable;
public record RoleAssignment(long id, String code, String name, String description) implements Serializable {
}
@@ -0,0 +1,41 @@
package com.kaidi.finance.iam.domain;
import java.time.LocalDateTime;
public class UserAccount {
private long id;
private String publicId;
private String username;
private String displayName;
private String departmentName;
private String passwordHash;
private boolean enabled;
private boolean mustChangePassword;
private int failedLoginCount;
private LocalDateTime lockedUntil;
private long version;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getPublicId() { return publicId; }
public void setPublicId(String publicId) { this.publicId = publicId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getDepartmentName() { return departmentName; }
public void setDepartmentName(String departmentName) { this.departmentName = departmentName; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public boolean isMustChangePassword() { return mustChangePassword; }
public void setMustChangePassword(boolean mustChangePassword) { this.mustChangePassword = mustChangePassword; }
public int getFailedLoginCount() { return failedLoginCount; }
public void setFailedLoginCount(int failedLoginCount) { this.failedLoginCount = failedLoginCount; }
public LocalDateTime getLockedUntil() { return lockedUntil; }
public void setLockedUntil(LocalDateTime lockedUntil) { this.lockedUntil = lockedUntil; }
public long getVersion() { return version; }
public void setVersion(long version) { this.version = version; }
}
@@ -0,0 +1,86 @@
package com.kaidi.finance.iam.infrastructure;
import com.kaidi.finance.iam.domain.RoleAssignment;
import com.kaidi.finance.iam.domain.UserAccount;
import java.util.List;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@org.apache.ibatis.annotations.Mapper
public interface UserAccountMapper {
@Select("""
SELECT id, public_id, username, display_name, department_name, password_hash, enabled,
must_change_password, failed_login_count, locked_until, version
FROM iam_user WHERE username = #{username}
""")
UserAccount findByUsername(String username);
@Select("""
SELECT r.id, r.code, r.name, r.description
FROM iam_role r
JOIN iam_user_role ur ON ur.role_id = r.id
WHERE ur.user_id = #{userId} AND r.enabled = TRUE
ORDER BY r.sort_order, r.id
""")
List<RoleAssignment> findRoles(long userId);
@Select("""
SELECT p.code
FROM iam_permission p
JOIN iam_role_permission rp ON rp.permission_id = p.id
JOIN iam_role r ON r.id = rp.role_id
JOIN iam_user_role ur ON ur.role_id = r.id
WHERE ur.user_id = #{userId} AND r.code = #{roleCode} AND r.enabled = TRUE
ORDER BY p.code
""")
List<String> findPermissions(@Param("userId") long userId, @Param("roleCode") String roleCode);
@Select("""
SELECT COUNT(*)
FROM iam_permission p
JOIN iam_role_permission rp ON rp.permission_id = p.id
JOIN iam_role r ON r.id = rp.role_id
JOIN iam_user_role ur ON ur.role_id = r.id
WHERE ur.user_id = #{userId} AND r.code = #{roleCode} AND p.code = #{permissionCode}
AND r.enabled = TRUE
""")
int countPermission(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("permissionCode") String permissionCode);
@Update("""
UPDATE iam_user
SET failed_login_count = failed_login_count + 1,
locked_until = CASE WHEN failed_login_count >= 5
THEN DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE) ELSE locked_until END,
version = version + 1
WHERE id = #{userId}
""")
int recordFailedLogin(long userId);
@Update("""
UPDATE iam_user
SET failed_login_count = 0, locked_until = NULL, version = version + 1
WHERE id = #{userId}
""")
int clearFailedLogins(long userId);
@Update("""
UPDATE iam_user
SET password_hash = #{passwordHash}, must_change_password = FALSE, version = version + 1
WHERE id = #{userId} AND version = #{version}
""")
int updatePassword(@Param("userId") long userId, @Param("version") long version,
@Param("passwordHash") String passwordHash);
@Insert("""
INSERT INTO iam_user (public_id, username, display_name, department_name, password_hash,
enabled, must_change_password)
VALUES (#{publicId}, #{username}, #{displayName}, #{departmentName}, #{passwordHash}, TRUE, TRUE)
""")
int insertUser(@Param("publicId") String publicId, @Param("username") String username,
@Param("displayName") String displayName, @Param("departmentName") String departmentName,
@Param("passwordHash") String passwordHash);
}
@@ -0,0 +1,18 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record BankAccountCreateRequest(
@NotBlank String ownerType,
@NotBlank String ownerId,
@NotBlank String accountCategory,
@NotBlank @Size(max = 200) String accountName,
@NotBlank @Size(max = 200) String bankName,
@NotBlank @Size(min = 6, max = 64) String accountNo,
@NotNull LocalDate validFrom,
LocalDate validTo
) {
}
@@ -0,0 +1,18 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record BankAccountUpdateRequest(
@NotBlank String accountCategory,
@NotBlank @Size(max = 200) String accountName,
@NotBlank @Size(max = 200) String bankName,
@Size(min = 6, max = 64) String accountNo,
@NotNull LocalDate validFrom,
LocalDate validTo,
@NotNull @PositiveOrZero Long version
) {
}
@@ -0,0 +1,24 @@
package com.kaidi.finance.masterdata.api;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Set;
public record BankAccountView(
String publicId,
String ownerType,
MasterDataReferenceView owner,
String accountCategory,
String accountName,
String bankName,
String maskedAccountNo,
int versionNo,
LocalDate validFrom,
LocalDate validTo,
String status,
long version,
LocalDateTime createdAt,
LocalDateTime updatedAt,
Set<String> allowedActions
) {
}
@@ -0,0 +1,11 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CompanyCreateRequest(
@NotBlank @Size(max = 64) String businessNo,
@NotBlank @Size(max = 200) String name,
@Size(max = 64) String taxNo
) {
}
@@ -0,0 +1,13 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record CompanyUpdateRequest(
@NotBlank @Size(max = 200) String name,
@Size(max = 64) String taxNo,
@NotNull @PositiveOrZero Long version
) {
}
@@ -0,0 +1,9 @@
package com.kaidi.finance.masterdata.api;
import java.time.LocalDateTime;
import java.util.Set;
public record CompanyView(String publicId, String businessNo, String name, String taxNo, String status,
long version, LocalDateTime createdAt, LocalDateTime updatedAt,
Set<String> allowedActions) {
}
@@ -0,0 +1,16 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record ContractCreateRequest(
@NotBlank String companyId,
@NotBlank String projectId,
@NotBlank String counterpartyId,
@NotBlank @Size(max = 64) String businessNo,
@NotBlank @Size(max = 200) String name,
@NotBlank @Pattern(regexp = "\\d{1,18}(\\.\\d{1,2})?") String originalAmount,
@NotBlank @Pattern(regexp = "[A-Za-z]{3}") String currency
) {
}
@@ -0,0 +1,18 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record ContractUpdateRequest(
@NotBlank String companyId,
@NotBlank String projectId,
@NotBlank String counterpartyId,
@NotBlank @Size(max = 200) String name,
@NotBlank @Pattern(regexp = "\\d{1,18}(\\.\\d{1,2})?") String originalAmount,
@NotBlank @Pattern(regexp = "[A-Za-z]{3}") String currency,
@NotNull @PositiveOrZero Long version
) {
}
@@ -0,0 +1,23 @@
package com.kaidi.finance.masterdata.api;
import java.time.LocalDateTime;
import java.util.Set;
public record ContractView(
String publicId,
MasterDataReferenceView company,
MasterDataReferenceView project,
MasterDataReferenceView counterparty,
String businessNo,
String name,
String originalAmount,
String approvedChangeAmount,
String settlementAmount,
String currency,
String status,
long version,
LocalDateTime createdAt,
LocalDateTime updatedAt,
Set<String> allowedActions
) {
}
@@ -0,0 +1,14 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CounterpartyCreateRequest(
@NotBlank @Size(max = 64) String businessNo,
@NotBlank String type,
@NotBlank @Size(max = 200) String name,
@Size(max = 64) String taxNo,
@Size(max = 100) String contactName,
@Size(max = 32) String contactPhone
) {
}
@@ -0,0 +1,16 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record CounterpartyUpdateRequest(
@NotBlank String type,
@NotBlank @Size(max = 200) String name,
@Size(max = 64) String taxNo,
@Size(max = 100) String contactName,
@Size(max = 32) String contactPhone,
@NotNull @PositiveOrZero Long version
) {
}
@@ -0,0 +1,9 @@
package com.kaidi.finance.masterdata.api;
import java.time.LocalDateTime;
import java.util.Set;
public record CounterpartyView(String publicId, String businessNo, String type, String name, String taxNo,
String contactName, String contactPhone, String status, long version,
LocalDateTime createdAt, LocalDateTime updatedAt, Set<String> allowedActions) {
}
@@ -0,0 +1,14 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record DictionaryCreateRequest(
@NotBlank @Size(max = 64) String code,
@NotBlank @Size(max = 128) String name,
String parentId,
String accountType,
Boolean auxiliaryRequired,
@Size(max = 16) String rate
) {
}
@@ -0,0 +1,16 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record DictionaryUpdateRequest(
@NotBlank @Size(max = 128) String name,
String parentId,
String accountType,
Boolean auxiliaryRequired,
@Size(max = 16) String rate,
@NotNull @PositiveOrZero Long version
) {
}
@@ -0,0 +1,21 @@
package com.kaidi.finance.masterdata.api;
import java.time.LocalDateTime;
import java.util.Set;
public record DictionaryView(
String publicId,
String resource,
String code,
String name,
MasterDataReferenceView parent,
String accountType,
Boolean auxiliaryRequired,
String rate,
String status,
long version,
LocalDateTime createdAt,
LocalDateTime updatedAt,
Set<String> allowedActions
) {
}
@@ -0,0 +1,10 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record LegacyIdentifierCreateRequest(
@NotBlank @Size(max = 64) String sourceSystem,
@NotBlank @Size(max = 128) String legacyCode
) {
}
@@ -0,0 +1,10 @@
package com.kaidi.finance.masterdata.api;
public record LegacyIdentifierResolutionView(
String mappingPublicId,
String resource,
String sourceSystem,
String legacyCode,
String targetPublicId
) {
}
@@ -0,0 +1,18 @@
package com.kaidi.finance.masterdata.api;
import java.time.LocalDateTime;
import java.util.Set;
public record LegacyIdentifierView(
String publicId,
String resource,
String sourceSystem,
String legacyCode,
String targetPublicId,
String status,
long version,
LocalDateTime createdAt,
LocalDateTime updatedAt,
Set<String> allowedActions
) {
}
@@ -0,0 +1,385 @@
package com.kaidi.finance.masterdata.api;
import com.kaidi.finance.masterdata.application.CatalogMasterDataApplicationService;
import com.kaidi.finance.masterdata.application.LegacyIdentifierApplicationService;
import com.kaidi.finance.masterdata.application.MasterDataApplicationService;
import com.kaidi.finance.masterdata.application.MasterDataVersionApplicationService;
import com.kaidi.finance.shared.api.ApiResponse;
import com.kaidi.finance.shared.api.PageResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/masterdata")
public class MasterDataController {
private final MasterDataApplicationService service;
private final CatalogMasterDataApplicationService catalogService;
private final MasterDataVersionApplicationService versionService;
private final LegacyIdentifierApplicationService legacyIdentifierService;
public MasterDataController(MasterDataApplicationService service,
CatalogMasterDataApplicationService catalogService,
MasterDataVersionApplicationService versionService,
LegacyIdentifierApplicationService legacyIdentifierService) {
this.service = service;
this.catalogService = catalogService;
this.versionService = versionService;
this.legacyIdentifierService = legacyIdentifierService;
}
@PostMapping("/companies")
@PreAuthorize("@authorizationService.hasPermission('masterdata:company:create')")
public ApiResponse<CompanyView> createCompany(@Valid @RequestBody CompanyCreateRequest request) {
return ApiResponse.ok(service.createCompany(request));
}
@GetMapping("/companies")
@PreAuthorize("@authorizationService.hasPermission('masterdata:company:view')")
public ApiResponse<java.util.List<CompanyView>> listCompanies(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<CompanyView> result = service.listCompanies(keyword, status, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/companies/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:company:view')")
public ApiResponse<CompanyView> getCompany(@PathVariable String publicId) {
return ApiResponse.ok(service.getCompany(publicId));
}
@PatchMapping("/companies/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:company:edit')")
public ApiResponse<CompanyView> updateCompany(@PathVariable String publicId,
@Valid @RequestBody CompanyUpdateRequest request) {
return ApiResponse.ok(service.updateCompany(publicId, request));
}
@PostMapping("/companies/{publicId}/submit")
@PreAuthorize("@authorizationService.hasPermission('masterdata:company:submit')")
public ApiResponse<CompanyView> submitCompany(@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(service.submitCompany(publicId, request.version()));
}
@PostMapping("/companies/{publicId}/review")
@PreAuthorize("@authorizationService.hasPermission('masterdata:company:review')")
public ApiResponse<CompanyView> reviewCompany(@PathVariable String publicId,
@Valid @RequestBody ReviewRequest request) {
return ApiResponse.ok(service.reviewCompany(publicId, request));
}
@PostMapping("/companies/{publicId}/disable")
@PreAuthorize("@authorizationService.hasPermission('masterdata:company:disable')")
public ApiResponse<CompanyView> disableCompany(@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(service.disableCompany(publicId, request.version()));
}
@PostMapping("/counterparties")
@PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:create')")
public ApiResponse<CounterpartyView> createCounterparty(@Valid @RequestBody CounterpartyCreateRequest request) {
return ApiResponse.ok(service.createCounterparty(request));
}
@GetMapping("/counterparties")
@PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:view')")
public ApiResponse<java.util.List<CounterpartyView>> listCounterparties(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(required = false) String type,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<CounterpartyView> result = service.listCounterparties(keyword, status, type, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/counterparties/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:view')")
public ApiResponse<CounterpartyView> getCounterparty(@PathVariable String publicId) {
return ApiResponse.ok(service.getCounterparty(publicId));
}
@PatchMapping("/counterparties/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:edit')")
public ApiResponse<CounterpartyView> updateCounterparty(@PathVariable String publicId,
@Valid @RequestBody CounterpartyUpdateRequest request) {
return ApiResponse.ok(service.updateCounterparty(publicId, request));
}
@PostMapping("/counterparties/{publicId}/submit")
@PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:submit')")
public ApiResponse<CounterpartyView> submitCounterparty(@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(service.submitCounterparty(publicId, request.version()));
}
@PostMapping("/counterparties/{publicId}/review")
@PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:review')")
public ApiResponse<CounterpartyView> reviewCounterparty(@PathVariable String publicId,
@Valid @RequestBody ReviewRequest request) {
return ApiResponse.ok(service.reviewCounterparty(publicId, request));
}
@PostMapping("/counterparties/{publicId}/disable")
@PreAuthorize("@authorizationService.hasPermission('masterdata:counterparty:disable')")
public ApiResponse<CounterpartyView> disableCounterparty(@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(service.disableCounterparty(publicId, request.version()));
}
@GetMapping("/references")
@PreAuthorize("@authorizationService.hasPermission('masterdata:company:view')")
public ApiResponse<MasterDataReferenceOptionsView> references() {
return ApiResponse.ok(catalogService.references());
}
@PostMapping("/bank-accounts")
@PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:create')")
public ApiResponse<BankAccountView> createBankAccount(
@Valid @RequestBody BankAccountCreateRequest request) {
return ApiResponse.ok(catalogService.createBankAccount(request));
}
@GetMapping("/bank-accounts")
@PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:view')")
public ApiResponse<java.util.List<BankAccountView>> listBankAccounts(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<BankAccountView> result = catalogService.listBankAccounts(keyword, status, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/bank-accounts/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:view')")
public ApiResponse<BankAccountView> getBankAccount(@PathVariable String publicId) {
return ApiResponse.ok(catalogService.getBankAccount(publicId));
}
@PatchMapping("/bank-accounts/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:edit')")
public ApiResponse<BankAccountView> updateBankAccount(
@PathVariable String publicId,
@Valid @RequestBody BankAccountUpdateRequest request) {
return ApiResponse.ok(catalogService.updateBankAccount(publicId, request));
}
@PostMapping("/bank-accounts/{publicId}/submit")
@PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:submit')")
public ApiResponse<BankAccountView> submitBankAccount(
@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(catalogService.submitBankAccount(publicId, request.version()));
}
@PostMapping("/bank-accounts/{publicId}/review")
@PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:review')")
public ApiResponse<BankAccountView> reviewBankAccount(
@PathVariable String publicId,
@Valid @RequestBody ReviewRequest request) {
return ApiResponse.ok(catalogService.reviewBankAccount(publicId, request));
}
@PostMapping("/bank-accounts/{publicId}/disable")
@PreAuthorize("@authorizationService.hasPermission('masterdata:bank-account:disable')")
public ApiResponse<BankAccountView> disableBankAccount(
@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(catalogService.disableBankAccount(publicId, request.version()));
}
@PostMapping("/contracts")
@PreAuthorize("@authorizationService.hasPermission('masterdata:contract:create')")
public ApiResponse<ContractView> createContract(@Valid @RequestBody ContractCreateRequest request) {
return ApiResponse.ok(catalogService.createContract(request));
}
@GetMapping("/contracts")
@PreAuthorize("@authorizationService.hasPermission('masterdata:contract:view')")
public ApiResponse<java.util.List<ContractView>> listContracts(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<ContractView> result = catalogService.listContracts(keyword, status, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/contracts/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:contract:view')")
public ApiResponse<ContractView> getContract(@PathVariable String publicId) {
return ApiResponse.ok(catalogService.getContract(publicId));
}
@PatchMapping("/contracts/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:contract:edit')")
public ApiResponse<ContractView> updateContract(
@PathVariable String publicId,
@Valid @RequestBody ContractUpdateRequest request) {
return ApiResponse.ok(catalogService.updateContract(publicId, request));
}
@PostMapping("/contracts/{publicId}/submit")
@PreAuthorize("@authorizationService.hasPermission('masterdata:contract:submit')")
public ApiResponse<ContractView> submitContract(
@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(catalogService.submitContract(publicId, request.version()));
}
@PostMapping("/contracts/{publicId}/review")
@PreAuthorize("@authorizationService.hasPermission('masterdata:contract:review')")
public ApiResponse<ContractView> reviewContract(
@PathVariable String publicId,
@Valid @RequestBody ReviewRequest request) {
return ApiResponse.ok(catalogService.reviewContract(publicId, request));
}
@PostMapping("/contracts/{publicId}/disable")
@PreAuthorize("@authorizationService.hasPermission('masterdata:contract:disable')")
public ApiResponse<ContractView> disableContract(
@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(catalogService.disableContract(publicId, request.version()));
}
@PostMapping("/{resource:departments|cost-categories|accounts|tax-rates}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:create')")
public ApiResponse<DictionaryView> createDictionary(
@PathVariable String resource,
@Valid @RequestBody DictionaryCreateRequest request) {
return ApiResponse.ok(catalogService.createDictionary(resource, request));
}
@GetMapping("/{resource:departments|cost-categories|accounts|tax-rates}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:view')")
public ApiResponse<java.util.List<DictionaryView>> listDictionaries(
@PathVariable String resource,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
PageResult<DictionaryView> result = catalogService.listDictionaries(resource, keyword, status, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:view')")
public ApiResponse<DictionaryView> getDictionary(
@PathVariable String resource,
@PathVariable String publicId) {
return ApiResponse.ok(catalogService.getDictionary(resource, publicId));
}
@PatchMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:edit')")
public ApiResponse<DictionaryView> updateDictionary(
@PathVariable String resource,
@PathVariable String publicId,
@Valid @RequestBody DictionaryUpdateRequest request) {
return ApiResponse.ok(catalogService.updateDictionary(resource, publicId, request));
}
@PostMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}/submit")
@PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:submit')")
public ApiResponse<DictionaryView> submitDictionary(
@PathVariable String resource,
@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(catalogService.submitDictionary(resource, publicId, request.version()));
}
@PostMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}/review")
@PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:review')")
public ApiResponse<DictionaryView> reviewDictionary(
@PathVariable String resource,
@PathVariable String publicId,
@Valid @RequestBody ReviewRequest request) {
return ApiResponse.ok(catalogService.reviewDictionary(resource, publicId, request));
}
@PostMapping("/{resource:departments|cost-categories|accounts|tax-rates}/{publicId}/disable")
@PreAuthorize("@authorizationService.hasPermission('masterdata:dictionary:disable')")
public ApiResponse<DictionaryView> disableDictionary(
@PathVariable String resource,
@PathVariable String publicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(catalogService.disableDictionary(resource, publicId, request.version()));
}
@GetMapping("/{resource:companies|departments|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/{publicId}/versions")
@Operation(operationId = "listMasterDataVersions", summary = "查询主数据版本记录")
@PreAuthorize("@masterDataVersionApplicationService.canView(#resource)")
public ApiResponse<java.util.List<MasterDataVersionView>> listMasterDataVersions(
@PathVariable String resource,
@PathVariable String publicId,
@RequestParam(defaultValue = "1") int page,
@Parameter(schema = @Schema(type = "integer", format = "int32", allowableValues = {"20", "50", "100"}))
@RequestParam(defaultValue = "20") int size) {
PageResult<MasterDataVersionView> result = versionService.listVersions(resource, publicId, page, size);
return ApiResponse.ok(result.items(), result.meta());
}
@GetMapping("/{resource:companies|departments|projects|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/{publicId}/legacy-identifiers")
@PreAuthorize("@legacyIdentifierApplicationService.canView(#resource)")
public ApiResponse<java.util.List<LegacyIdentifierView>> listLegacyIdentifiers(
@PathVariable String resource,
@PathVariable String publicId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
LegacyIdentifierApplicationService.MappingPage result = legacyIdentifierService.list(
resource, publicId, status, keyword, page, size);
Map<String, Object> meta = new LinkedHashMap<>(result.page().meta());
meta.put("allowedActions", result.allowedActions());
return ApiResponse.ok(result.page().items(), meta);
}
@PostMapping("/{resource:companies|departments|projects|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/{publicId}/legacy-identifiers")
@PreAuthorize("@legacyIdentifierApplicationService.canEdit(#resource)")
public ApiResponse<LegacyIdentifierView> createLegacyIdentifier(
@PathVariable String resource,
@PathVariable String publicId,
@Valid @RequestBody LegacyIdentifierCreateRequest request) {
return ApiResponse.ok(legacyIdentifierService.create(resource, publicId, request));
}
@GetMapping("/{resource:companies|departments|projects|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/legacy-identifiers/resolve")
@PreAuthorize("@legacyIdentifierApplicationService.canView(#resource)")
public ApiResponse<LegacyIdentifierResolutionView> resolveLegacyIdentifier(
@PathVariable String resource,
@RequestParam String sourceSystem,
@RequestParam String legacyCode) {
return ApiResponse.ok(legacyIdentifierService.resolve(resource, sourceSystem, legacyCode));
}
@PostMapping("/{resource:companies|departments|projects|counterparties|bank-accounts|contracts|cost-categories|accounts|tax-rates}/{publicId}/legacy-identifiers/{mappingPublicId}/disable")
@PreAuthorize("@legacyIdentifierApplicationService.canEdit(#resource)")
public ApiResponse<LegacyIdentifierView> disableLegacyIdentifier(
@PathVariable String resource,
@PathVariable String publicId,
@PathVariable String mappingPublicId,
@Valid @RequestBody VersionCommandRequest request) {
return ApiResponse.ok(legacyIdentifierService.disable(resource, publicId, mappingPublicId,
request.version()));
}
}
@@ -0,0 +1,11 @@
package com.kaidi.finance.masterdata.api;
import java.util.List;
public record MasterDataReferenceOptionsView(
List<MasterDataReferenceView> companies,
List<MasterDataReferenceView> projects,
List<MasterDataReferenceView> counterparties,
List<MasterDataReferenceView> costCategories
) {
}
@@ -0,0 +1,4 @@
package com.kaidi.finance.masterdata.api;
public record MasterDataReferenceView(String publicId, String code, String name) {
}
@@ -0,0 +1,16 @@
package com.kaidi.finance.masterdata.api;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.LocalDateTime;
public record MasterDataVersionView(
String auditId,
long version,
String status,
String actionCode,
String actorName,
LocalDateTime changedAt,
JsonNode before,
JsonNode after
) {
}
@@ -0,0 +1,13 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record ReviewRequest(
@NotNull @PositiveOrZero Long version,
@NotBlank String decision,
@NotBlank @Size(min = 2, max = 1000) String opinion
) {
}
@@ -0,0 +1,7 @@
package com.kaidi.finance.masterdata.api;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
public record VersionCommandRequest(@NotNull @PositiveOrZero Long version) {
}
@@ -0,0 +1,61 @@
package com.kaidi.finance.masterdata.application;
import com.kaidi.finance.shared.security.FieldEncryptionKey;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.Locale;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class BankAccountProtector {
private static final int IV_BYTES = 12;
private static final int TAG_BITS = 128;
private final SecretKeySpec key;
private final SecureRandom secureRandom = new SecureRandom();
public BankAccountProtector(
@Value("${finance.crypto.field-key}") String encodedKey,
@Value("${finance.crypto.reject-default:false}") boolean rejectKnownDefault
) {
byte[] decoded = FieldEncryptionKey.decode(encodedKey, rejectKnownDefault);
this.key = new SecretKeySpec(decoded, "AES");
}
public ProtectedAccount protect(String value) {
String normalized = normalize(value);
try {
byte[] iv = new byte[IV_BYTES];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] encrypted = cipher.doFinal(normalized.getBytes(StandardCharsets.UTF_8));
byte[] packed = ByteBuffer.allocate(iv.length + encrypted.length).put(iv).put(encrypted).array();
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(normalized.getBytes(StandardCharsets.UTF_8)));
return new ProtectedAccount(packed, hash, "**** **** " + normalized.substring(normalized.length() - 4));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("Bank account protection failed", exception);
}
}
private String normalize(String value) {
String normalized = value == null ? "" : value.replaceAll("[\\s-]", "").toUpperCase(Locale.ROOT);
if (normalized.length() < 6 || normalized.length() > 64 || !normalized.matches("[0-9A-Z]+")) {
throw new IllegalArgumentException("银行账号只能包含数字或字母,去除空格后长度为 6 至 64 位");
}
return normalized;
}
public record ProtectedAccount(byte[] ciphertext, String hash, String masked) {
}
}
@@ -0,0 +1,852 @@
package com.kaidi.finance.masterdata.application;
import com.kaidi.finance.iam.domain.FinancePrincipal;
import com.kaidi.finance.masterdata.api.BankAccountCreateRequest;
import com.kaidi.finance.masterdata.api.BankAccountUpdateRequest;
import com.kaidi.finance.masterdata.api.BankAccountView;
import com.kaidi.finance.masterdata.api.ContractCreateRequest;
import com.kaidi.finance.masterdata.api.ContractUpdateRequest;
import com.kaidi.finance.masterdata.api.ContractView;
import com.kaidi.finance.masterdata.api.DictionaryCreateRequest;
import com.kaidi.finance.masterdata.api.DictionaryUpdateRequest;
import com.kaidi.finance.masterdata.api.DictionaryView;
import com.kaidi.finance.masterdata.api.MasterDataReferenceOptionsView;
import com.kaidi.finance.masterdata.api.MasterDataReferenceView;
import com.kaidi.finance.masterdata.api.ReviewRequest;
import com.kaidi.finance.masterdata.domain.BankAccountRecord;
import com.kaidi.finance.masterdata.domain.ContractMasterRecord;
import com.kaidi.finance.masterdata.domain.DictionaryRecord;
import com.kaidi.finance.masterdata.domain.MasterDataReferenceRecord;
import com.kaidi.finance.masterdata.infrastructure.CatalogMasterDataMapper;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.id.UlidGenerator;
import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.Normalizer;
import java.time.LocalDate;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogMasterDataApplicationService {
private static final Set<String> DICTIONARY_RESOURCES = Set.of(
"departments", "cost-categories", "accounts", "tax-rates");
private static final Set<String> STATUSES = Set.of(
"DRAFT", "REVIEWING", "RETURNED", "ACTIVE", "SETTLED", "DISABLED", "VOID");
private static final Set<String> ACCOUNT_CATEGORIES = Set.of("BASIC", "GENERAL", "SPECIAL", "PROJECT", "OTHER");
private static final Set<String> ACCOUNT_TYPES = Set.of("ASSET", "LIABILITY", "EQUITY", "INCOME", "COST", "EXPENSE");
private static final String ULID_PATTERN = "[0-9A-HJKMNP-TV-Z]{26}";
private final CatalogMasterDataMapper mapper;
private final IdentityContext identityContext;
private final AuthorizationService authorizationService;
private final AuditService auditService;
private final UlidGenerator ulidGenerator;
private final BankAccountProtector accountProtector;
public CatalogMasterDataApplicationService(
CatalogMasterDataMapper mapper,
IdentityContext identityContext,
AuthorizationService authorizationService,
AuditService auditService,
UlidGenerator ulidGenerator,
BankAccountProtector accountProtector
) {
this.mapper = mapper;
this.identityContext = identityContext;
this.authorizationService = authorizationService;
this.auditService = auditService;
this.ulidGenerator = ulidGenerator;
this.accountProtector = accountProtector;
}
@Transactional(readOnly = true)
public MasterDataReferenceOptionsView references() {
authorizationService.requirePermission("masterdata:company:view");
authorizationService.requirePermission("project:project:view");
authorizationService.requireGlobalScope("masterdata:counterparty:view");
authorizationService.requireGlobalScope("masterdata:dictionary:view");
FinancePrincipal actor = identityContext.requirePrincipal();
String role = identityContext.requireActiveRole();
return new MasterDataReferenceOptionsView(
referenceViews(mapper.listCompanyReferences(actor.userId(), role)),
referenceViews(mapper.listProjectReferences(actor.userId(), role)),
referenceViews(mapper.listCounterpartyReferences()),
referenceViews(mapper.listCostCategoryReferences())
);
}
@Transactional(readOnly = true)
public PageResult<BankAccountView> listBankAccounts(String keyword, String status, int page, int size) {
authorizationService.requirePermission("masterdata:bank-account:view");
Page request = page(page, size);
FinancePrincipal actor = identityContext.requirePrincipal();
String role = identityContext.requireActiveRole();
String safeKeyword = nullableTrim(keyword, 100);
String safeStatus = status(status);
long total = mapper.countBankAccounts(actor.userId(), role, safeKeyword, safeStatus);
List<BankAccountView> items = mapper.listBankAccounts(actor.userId(), role, safeKeyword, safeStatus,
request.size(), request.offset()).stream().map(this::bankAccountView).toList();
return new PageResult<>(items, total, request.page(), request.size());
}
@Transactional(readOnly = true)
public BankAccountView getBankAccount(String publicId) {
BankAccountRecord record = requireBankAccount(publicId);
requireBankScope("masterdata:bank-account:view", record);
return bankAccountView(record);
}
@Transactional
public BankAccountView createBankAccount(BankAccountCreateRequest request) {
Owner owner = owner(request.ownerType(), request.ownerId());
requireOwnerScope("masterdata:bank-account:create", owner);
validateValidity(request.validFrom(), request.validTo());
BankAccountProtector.ProtectedAccount protectedAccount = protect(request.accountNo());
if (mapper.countDuplicateBankAccount(owner.companyId(), owner.counterpartyId(),
protectedAccount.hash(), null) > 0) {
throw duplicate("该主体已存在相同的有效银行账号");
}
FinancePrincipal actor = identityContext.requirePrincipal();
BankAccountRecord record = new BankAccountRecord();
record.setPublicId(ulidGenerator.next());
record.setOwnerType(owner.type());
record.setCompanyId(owner.companyId());
record.setCounterpartyId(owner.counterpartyId());
record.setAccountCategory(accountCategory(request.accountCategory()));
record.setAccountName(request.accountName().trim());
record.setBankName(request.bankName().trim());
applyProtectedAccount(record, protectedAccount);
record.setVersionNo(mapper.nextBankAccountVersion(owner.companyId(), owner.counterpartyId()));
record.setValidFrom(request.validFrom().atStartOfDay());
record.setValidTo(request.validTo() == null ? null : request.validTo().atStartOfDay());
record.setCreatedBy(actor.userId());
record.setUpdatedBy(actor.userId());
try {
mapper.insertBankAccount(record);
} catch (DuplicateKeyException exception) {
throw duplicate("银行账户版本已存在");
}
BankAccountView view = bankAccountView(mapper.findBankAccount(record.getPublicId()));
auditService.record("MASTERDATA_BANK_ACCOUNT_CREATE", "BANK_ACCOUNT", record.getPublicId(),
"SUCCESS", null, null, view);
return view;
}
@Transactional
public BankAccountView updateBankAccount(String publicId, BankAccountUpdateRequest request) {
BankAccountRecord current = requireBankAccount(publicId);
requireBankScope("masterdata:bank-account:edit", current);
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
validateValidity(request.validFrom(), request.validTo());
BankAccountView before = bankAccountView(current);
current.setAccountCategory(accountCategory(request.accountCategory()));
current.setAccountName(request.accountName().trim());
current.setBankName(request.bankName().trim());
if (request.accountNo() != null && !request.accountNo().isBlank()) {
BankAccountProtector.ProtectedAccount protectedAccount = protect(request.accountNo());
if (mapper.countDuplicateBankAccount(current.getCompanyId(), current.getCounterpartyId(),
protectedAccount.hash(), current.getId()) > 0) {
throw duplicate("该主体已存在相同的有效银行账号");
}
applyProtectedAccount(current, protectedAccount);
}
current.setValidFrom(request.validFrom().atStartOfDay());
current.setValidTo(request.validTo() == null ? null : request.validTo().atStartOfDay());
current.setUpdatedBy(identityContext.requirePrincipal().userId());
if (mapper.updateBankAccount(current) != 1) {
throw conflict();
}
BankAccountView after = bankAccountView(mapper.findBankAccount(publicId));
auditService.record("MASTERDATA_BANK_ACCOUNT_UPDATE", "BANK_ACCOUNT", publicId,
"SUCCESS", null, before, after);
return after;
}
@Transactional
public BankAccountView submitBankAccount(String publicId, long version) {
BankAccountRecord current = requireBankAccount(publicId);
requireBankScope("masterdata:bank-account:submit", current);
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
BankAccountView before = bankAccountView(current);
long actorId = identityContext.requirePrincipal().userId();
if (mapper.submitBankAccount(current.getId(), version, actorId) != 1) {
throw conflict();
}
BankAccountView after = bankAccountView(mapper.findBankAccount(publicId));
auditService.record("MASTERDATA_BANK_ACCOUNT_SUBMIT", "BANK_ACCOUNT", publicId,
"SUCCESS", null, before, after);
return after;
}
@Transactional
public BankAccountView reviewBankAccount(String publicId, ReviewRequest request) {
BankAccountRecord current = requireBankAccount(publicId);
requireBankScope("masterdata:bank-account:review", current);
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("REVIEWING"));
long actorId = identityContext.requirePrincipal().userId();
requireDifferentReviewer(current.getCreatedBy(), current.getSubmittedBy(), actorId);
String targetStatus = reviewTarget(request.decision());
if ("ACTIVE".equals(targetStatus) && !"ACTIVE".equals(mapper.findBankAccountOwnerStatus(current.getId()))) {
throw new BusinessException(
HttpStatus.UNPROCESSABLE_ENTITY,
ErrorCode.INVALID_STATE_TRANSITION,
"账户所属公司或供应商尚未生效"
);
}
BankAccountView before = bankAccountView(current);
if (mapper.reviewBankAccount(current.getId(), request.version(), actorId,
targetStatus, request.opinion().trim()) != 1) {
throw conflict();
}
BankAccountView after = bankAccountView(mapper.findBankAccount(publicId));
auditService.record("MASTERDATA_BANK_ACCOUNT_REVIEW", "BANK_ACCOUNT", publicId,
"SUCCESS", request.opinion().trim(), before, after);
return after;
}
@Transactional
public BankAccountView disableBankAccount(String publicId, long version) {
BankAccountRecord current = requireBankAccount(publicId);
requireBankScope("masterdata:bank-account:disable", current);
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("ACTIVE"));
BankAccountView before = bankAccountView(current);
if (mapper.disableBankAccount(current.getId(), version, identityContext.requirePrincipal().userId()) != 1) {
throw conflict();
}
BankAccountView after = bankAccountView(mapper.findBankAccount(publicId));
auditService.record("MASTERDATA_BANK_ACCOUNT_DISABLE", "BANK_ACCOUNT", publicId,
"SUCCESS", null, before, after);
return after;
}
@Transactional(readOnly = true)
public PageResult<ContractView> listContracts(String keyword, String status, int page, int size) {
authorizationService.requirePermission("masterdata:contract:view");
Page request = page(page, size);
FinancePrincipal actor = identityContext.requirePrincipal();
String role = identityContext.requireActiveRole();
String safeKeyword = nullableTrim(keyword, 100);
String safeStatus = status(status);
long total = mapper.countContracts(actor.userId(), role, safeKeyword, safeStatus);
List<ContractView> items = mapper.listContracts(actor.userId(), role, safeKeyword, safeStatus,
request.size(), request.offset()).stream().map(this::contractView).toList();
return new PageResult<>(items, total, request.page(), request.size());
}
@Transactional(readOnly = true)
public ContractView getContract(String publicId) {
ContractMasterRecord record = requireContract(publicId);
authorizationService.requireScope("masterdata:contract:view", record.getCompanyPublicId(),
record.getProjectPublicId());
return contractView(record);
}
@Transactional
public ContractView createContract(ContractCreateRequest request) {
ContractReferences references = contractReferences(request.companyId(), request.projectId(),
request.counterpartyId());
BigDecimal amount = amount(request.originalAmount());
authorizationService.requireScope("masterdata:contract:create", references.company().publicId(),
references.project().publicId(), amount);
FinancePrincipal actor = identityContext.requirePrincipal();
ContractMasterRecord record = new ContractMasterRecord();
record.setPublicId(ulidGenerator.next());
applyContractReferences(record, references);
record.setBusinessNo(normalizeCode(request.businessNo()));
record.setName(request.name().trim());
record.setOriginalAmount(amount);
record.setCurrency(currency(request.currency()));
record.setCreatedBy(actor.userId());
record.setUpdatedBy(actor.userId());
try {
mapper.insertContract(record);
} catch (DuplicateKeyException exception) {
throw duplicate("同一公司下合同编号已存在");
}
ContractView view = contractView(mapper.findContract(record.getPublicId()));
auditService.recordScoped(record.getCompanyPublicId(), record.getProjectPublicId(),
"MASTERDATA_CONTRACT_CREATE", "CONTRACT", record.getPublicId(),
"SUCCESS", null, null, view);
return view;
}
@Transactional
public ContractView updateContract(String publicId, ContractUpdateRequest request) {
ContractMasterRecord current = requireContract(publicId);
authorizationService.requireScope("masterdata:contract:edit", current.getCompanyPublicId(),
current.getProjectPublicId(), current.getOriginalAmount());
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
ContractReferences references = contractReferences(request.companyId(), request.projectId(),
request.counterpartyId());
BigDecimal amount = amount(request.originalAmount());
authorizationService.requireScope("masterdata:contract:edit", references.company().publicId(),
references.project().publicId(), amount);
ContractView before = contractView(current);
applyContractReferences(current, references);
current.setName(request.name().trim());
current.setOriginalAmount(amount);
current.setCurrency(currency(request.currency()));
current.setUpdatedBy(identityContext.requirePrincipal().userId());
if (mapper.updateContract(current) != 1) {
throw conflict();
}
ContractView after = contractView(mapper.findContract(publicId));
ContractMasterRecord stored = mapper.findContract(publicId);
auditService.recordScoped(stored.getCompanyPublicId(), stored.getProjectPublicId(),
"MASTERDATA_CONTRACT_UPDATE", "CONTRACT", publicId,
"SUCCESS", null, before, after);
return after;
}
@Transactional
public ContractView submitContract(String publicId, long version) {
ContractMasterRecord current = requireContract(publicId);
authorizationService.requireScope("masterdata:contract:submit", current.getCompanyPublicId(),
current.getProjectPublicId(), current.getOriginalAmount());
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
ContractView before = contractView(current);
long actorId = identityContext.requirePrincipal().userId();
if (mapper.submitContract(current.getId(), version, actorId) != 1) {
throw conflict();
}
ContractView after = contractView(mapper.findContract(publicId));
ContractMasterRecord stored = mapper.findContract(publicId);
auditService.recordScoped(stored.getCompanyPublicId(), stored.getProjectPublicId(),
"MASTERDATA_CONTRACT_SUBMIT", "CONTRACT", publicId,
"SUCCESS", null, before, after);
return after;
}
@Transactional
public ContractView reviewContract(String publicId, ReviewRequest request) {
ContractMasterRecord current = requireContract(publicId);
authorizationService.requireScope("masterdata:contract:review", current.getCompanyPublicId(),
current.getProjectPublicId(), current.getOriginalAmount());
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("REVIEWING"));
long actorId = identityContext.requirePrincipal().userId();
requireDifferentReviewer(current.getSubmittedBy(), actorId);
ContractView before = contractView(current);
if (mapper.reviewContract(current.getId(), request.version(), actorId,
reviewTarget(request.decision()), request.opinion().trim()) != 1) {
throw conflict();
}
ContractView after = contractView(mapper.findContract(publicId));
ContractMasterRecord stored = mapper.findContract(publicId);
auditService.recordScoped(stored.getCompanyPublicId(), stored.getProjectPublicId(),
"MASTERDATA_CONTRACT_REVIEW", "CONTRACT", publicId,
"SUCCESS", request.opinion().trim(), before, after);
return after;
}
@Transactional
public ContractView disableContract(String publicId, long version) {
ContractMasterRecord current = requireContract(publicId);
authorizationService.requireScope("masterdata:contract:disable", current.getCompanyPublicId(),
current.getProjectPublicId(), current.getOriginalAmount());
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("ACTIVE"));
ContractView before = contractView(current);
if (mapper.disableContract(current.getId(), version, identityContext.requirePrincipal().userId()) != 1) {
throw conflict();
}
ContractView after = contractView(mapper.findContract(publicId));
ContractMasterRecord stored = mapper.findContract(publicId);
auditService.recordScoped(stored.getCompanyPublicId(), stored.getProjectPublicId(),
"MASTERDATA_CONTRACT_DISABLE", "CONTRACT", publicId,
"SUCCESS", null, before, after);
return after;
}
@Transactional(readOnly = true)
public PageResult<DictionaryView> listDictionaries(String resource, String keyword, String status,
int page, int size) {
String safeResource = dictionaryResource(resource);
authorizationService.requireGlobalScope("masterdata:dictionary:view");
Page request = page(page, size);
String safeKeyword = nullableTrim(keyword, 100);
String safeStatus = status(status);
long total = mapper.countDictionaries(safeResource, safeKeyword, safeStatus);
List<DictionaryView> items = mapper.listDictionaries(safeResource, safeKeyword, safeStatus,
request.size(), request.offset()).stream().map(item -> dictionaryView(safeResource, item)).toList();
return new PageResult<>(items, total, request.page(), request.size());
}
@Transactional(readOnly = true)
public DictionaryView getDictionary(String resource, String publicId) {
String safeResource = dictionaryResource(resource);
authorizationService.requireGlobalScope("masterdata:dictionary:view");
return dictionaryView(safeResource, requireDictionary(safeResource, publicId));
}
@Transactional
public DictionaryView createDictionary(String resource, DictionaryCreateRequest request) {
String safeResource = dictionaryResource(resource);
authorizationService.requireGlobalScope("masterdata:dictionary:create");
FinancePrincipal actor = identityContext.requirePrincipal();
DictionaryRecord record = new DictionaryRecord();
record.setPublicId(ulidGenerator.next());
record.setCode(normalizeCode(request.code()));
record.setName(request.name().trim());
applyDictionaryFields(safeResource, record, request.parentId(), request.accountType(),
request.auxiliaryRequired(), request.rate(), null);
record.setCreatedBy(actor.userId());
record.setUpdatedBy(actor.userId());
try {
mapper.insertDictionary(safeResource, record);
} catch (DuplicateKeyException exception) {
throw duplicate("字典编码已存在");
}
DictionaryView view = dictionaryView(safeResource,
mapper.findDictionary(safeResource, record.getPublicId()));
auditService.record("MASTERDATA_DICTIONARY_CREATE", dictionaryObjectType(safeResource),
record.getPublicId(), "SUCCESS", null, null, view);
return view;
}
@Transactional
public DictionaryView updateDictionary(String resource, String publicId, DictionaryUpdateRequest request) {
String safeResource = dictionaryResource(resource);
authorizationService.requireGlobalScope("masterdata:dictionary:edit");
DictionaryRecord current = requireDictionary(safeResource, publicId);
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
DictionaryView before = dictionaryView(safeResource, current);
current.setName(request.name().trim());
applyDictionaryFields(safeResource, current, request.parentId(), request.accountType(),
request.auxiliaryRequired(), request.rate(), publicId);
current.setUpdatedBy(identityContext.requirePrincipal().userId());
if (mapper.updateDictionary(safeResource, current) != 1) {
throw conflict();
}
DictionaryView after = dictionaryView(safeResource, mapper.findDictionary(safeResource, publicId));
auditService.record("MASTERDATA_DICTIONARY_UPDATE", dictionaryObjectType(safeResource), publicId,
"SUCCESS", null, before, after);
return after;
}
@Transactional
public DictionaryView submitDictionary(String resource, String publicId, long version) {
String safeResource = dictionaryResource(resource);
authorizationService.requireGlobalScope("masterdata:dictionary:submit");
DictionaryRecord current = requireDictionary(safeResource, publicId);
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
DictionaryView before = dictionaryView(safeResource, current);
long actorId = identityContext.requirePrincipal().userId();
if (mapper.submitDictionary(safeResource, current.getId(), version, actorId) != 1) {
throw conflict();
}
DictionaryView after = dictionaryView(safeResource, mapper.findDictionary(safeResource, publicId));
auditService.record("MASTERDATA_DICTIONARY_SUBMIT", dictionaryObjectType(safeResource), publicId,
"SUCCESS", null, before, after);
return after;
}
@Transactional
public DictionaryView reviewDictionary(String resource, String publicId, ReviewRequest request) {
String safeResource = dictionaryResource(resource);
authorizationService.requireGlobalScope("masterdata:dictionary:review");
DictionaryRecord current = requireDictionary(safeResource, publicId);
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("REVIEWING"));
long actorId = identityContext.requirePrincipal().userId();
requireDifferentReviewer(current.getSubmittedBy(), actorId);
DictionaryView before = dictionaryView(safeResource, current);
if (mapper.reviewDictionary(safeResource, current.getId(), request.version(), actorId,
reviewTarget(request.decision()), request.opinion().trim()) != 1) {
throw conflict();
}
DictionaryView after = dictionaryView(safeResource, mapper.findDictionary(safeResource, publicId));
auditService.record("MASTERDATA_DICTIONARY_REVIEW", dictionaryObjectType(safeResource), publicId,
"SUCCESS", request.opinion().trim(), before, after);
return after;
}
@Transactional
public DictionaryView disableDictionary(String resource, String publicId, long version) {
String safeResource = dictionaryResource(resource);
authorizationService.requireGlobalScope("masterdata:dictionary:disable");
DictionaryRecord current = requireDictionary(safeResource, publicId);
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("ACTIVE"));
DictionaryView before = dictionaryView(safeResource, current);
if (mapper.disableDictionary(safeResource, current.getId(), version,
identityContext.requirePrincipal().userId()) != 1) {
throw conflict();
}
DictionaryView after = dictionaryView(safeResource, mapper.findDictionary(safeResource, publicId));
auditService.record("MASTERDATA_DICTIONARY_DISABLE", dictionaryObjectType(safeResource), publicId,
"SUCCESS", null, before, after);
return after;
}
private Owner owner(String requestedType, String ownerPublicId) {
validatePublicId(ownerPublicId);
String type = requestedType == null ? "" : requestedType.trim().toUpperCase(Locale.ROOT);
if ("COMPANY".equals(type)) {
MasterDataReferenceRecord company = mapper.findCompanyReference(ownerPublicId);
if (company == null) throw notFound("所属公司不存在或未生效");
return new Owner(type, company.id(), null, company.publicId(), company);
}
if ("COUNTERPARTY".equals(type)) {
MasterDataReferenceRecord counterparty = mapper.findCounterpartyReference(ownerPublicId);
if (counterparty == null) throw notFound("所属往来单位不存在或未生效");
return new Owner(type, null, counterparty.id(), null, counterparty);
}
throw validation("账户主体类型必须是 COMPANY 或 COUNTERPARTY");
}
private ContractReferences contractReferences(String companyId, String projectId, String counterpartyId) {
validatePublicId(companyId);
validatePublicId(projectId);
validatePublicId(counterpartyId);
MasterDataReferenceRecord company = mapper.findCompanyReference(companyId);
MasterDataReferenceRecord project = mapper.findProjectReference(projectId);
MasterDataReferenceRecord counterparty = mapper.findCounterpartyReference(counterpartyId);
if (company == null) throw notFound("合同所属公司不存在或未生效");
if (project == null) throw notFound("合同所属项目不存在或未生效");
if (counterparty == null) throw notFound("合同相对方不存在或未生效");
if (!company.publicId().equals(project.companyPublicId())) {
throw validation("所选项目不属于合同公司");
}
if (!Set.of("CUSTOMER", "SUPPLIER", "BOTH").contains(counterparty.type())) {
throw validation("合同相对方类型无效");
}
return new ContractReferences(company, project, counterparty);
}
private void applyContractReferences(ContractMasterRecord target, ContractReferences references) {
target.setCompanyId(references.company().id());
target.setCompanyPublicId(references.company().publicId());
target.setCompanyName(references.company().name());
target.setProjectId(references.project().id());
target.setProjectPublicId(references.project().publicId());
target.setProjectName(references.project().name());
target.setCounterpartyId(references.counterparty().id());
target.setCounterpartyPublicId(references.counterparty().publicId());
target.setCounterpartyName(references.counterparty().name());
}
private void applyDictionaryFields(String resource, DictionaryRecord target, String parentId,
String accountType, Boolean auxiliaryRequired, String rate,
String currentPublicId) {
target.setParentId(null);
target.setAccountType(null);
target.setAuxiliaryRequired(null);
target.setRate(null);
switch (resource) {
case "departments" -> {
// Departments currently have no resource-specific fields.
}
case "cost-categories" -> {
if (parentId != null && !parentId.isBlank()) {
validatePublicId(parentId);
if (parentId.equals(currentPublicId)) throw validation("成本类别不能把自己设为上级");
MasterDataReferenceRecord parent = mapper.findCostCategoryReference(parentId);
if (parent == null) throw notFound("上级成本类别不存在或未生效");
target.setParentId(parent.id());
}
}
case "accounts" -> {
String normalizedType = accountType == null ? "" : accountType.trim().toUpperCase(Locale.ROOT);
if (!ACCOUNT_TYPES.contains(normalizedType)) {
throw validation("会计科目类型不符合要求");
}
target.setAccountType(normalizedType);
target.setAuxiliaryRequired(Boolean.TRUE.equals(auxiliaryRequired));
}
case "tax-rates" -> target.setRate(rate(rate));
default -> throw queryInvalid("不支持的财务字典资源");
}
}
private BankAccountRecord requireBankAccount(String publicId) {
validatePublicId(publicId);
BankAccountRecord record = mapper.findBankAccount(publicId);
if (record == null) throw notFound("银行账户不存在");
return record;
}
private ContractMasterRecord requireContract(String publicId) {
validatePublicId(publicId);
ContractMasterRecord record = mapper.findContract(publicId);
if (record == null) throw notFound("合同不存在");
return record;
}
private DictionaryRecord requireDictionary(String resource, String publicId) {
validatePublicId(publicId);
DictionaryRecord record = mapper.findDictionary(resource, publicId);
if (record == null) throw notFound("财务字典记录不存在");
return record;
}
private BankAccountView bankAccountView(BankAccountRecord record) {
Set<String> actions = lifecycleActions("masterdata:bank-account", record.getStatus(),
record.getCompanyPublicId(), null, record.getSubmittedBy());
String ownerPublicId = record.getCompanyId() == null
? record.getCounterpartyPublicId() : record.getCompanyPublicId();
String ownerName = record.getCompanyId() == null ? record.getCounterpartyName() : record.getCompanyName();
return new BankAccountView(record.getPublicId(), record.getOwnerType(),
new MasterDataReferenceView(ownerPublicId, null, ownerName), record.getAccountCategory(),
record.getAccountName(), record.getBankName(), record.getMaskedAccountNo(), record.getVersionNo(),
record.getValidFrom().toLocalDate(), record.getValidTo() == null ? null : record.getValidTo().toLocalDate(),
record.getStatus(), record.getVersion(), record.getCreatedAt(), record.getUpdatedAt(), actions);
}
private ContractView contractView(ContractMasterRecord record) {
Set<String> actions = lifecycleActions("masterdata:contract", record.getStatus(),
record.getCompanyPublicId(), record.getProjectPublicId(), record.getSubmittedBy());
return new ContractView(record.getPublicId(),
new MasterDataReferenceView(record.getCompanyPublicId(), null, record.getCompanyName()),
new MasterDataReferenceView(record.getProjectPublicId(), null, record.getProjectName()),
new MasterDataReferenceView(record.getCounterpartyPublicId(), null, record.getCounterpartyName()),
record.getBusinessNo(), record.getName(), decimal(record.getOriginalAmount()),
decimal(record.getApprovedChangeAmount()), decimal(record.getSettlementAmount()), record.getCurrency(),
record.getStatus(), record.getVersion(), record.getCreatedAt(), record.getUpdatedAt(), actions);
}
private DictionaryView dictionaryView(String resource, DictionaryRecord record) {
Set<String> actions = lifecycleActions("masterdata:dictionary", record.getStatus(), null, null,
record.getSubmittedBy());
MasterDataReferenceView parent = record.getParentPublicId() == null ? null
: new MasterDataReferenceView(record.getParentPublicId(), null, record.getParentName());
return new DictionaryView(record.getPublicId(), resource, record.getCode(), record.getName(), parent,
record.getAccountType(), record.getAuxiliaryRequired(), decimal(record.getRate()), record.getStatus(),
record.getVersion(), record.getCreatedAt(), record.getUpdatedAt(), actions);
}
private Set<String> lifecycleActions(String permissionPrefix, String status, String companyId,
String projectId, Long submittedBy) {
Set<String> actions = new LinkedHashSet<>();
if (Set.of("DRAFT", "RETURNED").contains(status)
&& authorizationService.hasScope(permissionPrefix + ":edit", companyId, projectId)) {
actions.add("EDIT");
}
if (Set.of("DRAFT", "RETURNED").contains(status)
&& authorizationService.hasScope(permissionPrefix + ":submit", companyId, projectId)) {
actions.add("SUBMIT");
}
if ("REVIEWING".equals(status)
&& (submittedBy == null || submittedBy != identityContext.requirePrincipal().userId())
&& authorizationService.hasScope(permissionPrefix + ":review", companyId, projectId)) {
actions.add("REVIEW");
}
if ("ACTIVE".equals(status)
&& authorizationService.hasScope(permissionPrefix + ":disable", companyId, projectId)) {
actions.add("DISABLE");
}
return Set.copyOf(actions);
}
private List<MasterDataReferenceView> referenceViews(List<MasterDataReferenceRecord> records) {
return records.stream().map(item -> new MasterDataReferenceView(item.publicId(), item.code(), item.name()))
.toList();
}
private void requireBankScope(String permission, BankAccountRecord record) {
authorizationService.requireScope(permission, record.getCompanyPublicId(), null);
}
private void requireOwnerScope(String permission, Owner owner) {
authorizationService.requireScope(permission, owner.companyPublicId(), null);
}
private void applyProtectedAccount(BankAccountRecord record,
BankAccountProtector.ProtectedAccount protectedAccount) {
record.setAccountNoCiphertext(protectedAccount.ciphertext());
record.setAccountNoHash(protectedAccount.hash());
record.setMaskedAccountNo(protectedAccount.masked());
}
private BankAccountProtector.ProtectedAccount protect(String accountNo) {
try {
return accountProtector.protect(accountNo);
} catch (IllegalArgumentException exception) {
throw validation(exception.getMessage());
}
}
private void validateValidity(LocalDate validFrom, LocalDate validTo) {
if (validTo != null && validTo.isBefore(validFrom)) {
throw validation("失效日期不能早于生效日期");
}
}
private String accountCategory(String value) {
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!ACCOUNT_CATEGORIES.contains(normalized)) {
throw validation("账户类别必须是 BASIC、GENERAL、SPECIAL、PROJECT 或 OTHER");
}
return normalized;
}
private BigDecimal amount(String value) {
try {
BigDecimal result = new BigDecimal(value).setScale(2, RoundingMode.UNNECESSARY);
if (result.signum() < 0 || result.precision() > 20) throw new ArithmeticException();
return result;
} catch (NumberFormatException | ArithmeticException exception) {
throw validation("合同金额必须是非负数且最多保留两位小数");
}
}
private BigDecimal rate(String value) {
if (value == null || value.isBlank()) throw validation("税率不能为空");
try {
BigDecimal result = new BigDecimal(value).setScale(6, RoundingMode.UNNECESSARY);
if (result.compareTo(BigDecimal.ZERO) < 0 || result.compareTo(BigDecimal.ONE) > 0) {
throw new ArithmeticException();
}
return result;
} catch (NumberFormatException | ArithmeticException exception) {
throw validation("税率必须在 0 到 1 之间且最多保留六位小数");
}
}
private String currency(String value) {
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!normalized.matches("[A-Z]{3}")) throw validation("币种必须使用三位 ISO 代码");
return normalized;
}
private String dictionaryResource(String resource) {
if (resource == null || !DICTIONARY_RESOURCES.contains(resource)) {
throw queryInvalid("不支持的财务字典资源");
}
return resource;
}
private String dictionaryObjectType(String resource) {
return switch (resource) {
case "departments" -> "DEPARTMENT";
case "cost-categories" -> "COST_CATEGORY";
case "accounts" -> "ACCOUNT";
case "tax-rates" -> "TAX_RATE";
default -> throw queryInvalid("不支持的财务字典资源");
};
}
private String reviewTarget(String decision) {
return switch (decision.trim().toUpperCase(Locale.ROOT)) {
case "APPROVE" -> "ACTIVE";
case "RETURN" -> "RETURNED";
default -> throw validation("复核结论必须是 APPROVE 或 RETURN");
};
}
private String status(String value) {
if (value == null || value.isBlank()) return null;
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!STATUSES.contains(normalized)) throw queryInvalid("状态筛选值不存在");
return normalized;
}
private Page page(int page, int size) {
if (page < 1 || !Set.of(20, 50, 100).contains(size)) {
throw queryInvalid("分页参数不符合要求");
}
return new Page(page, size, Math.multiplyExact(page - 1, size));
}
private String normalizeCode(String value) {
String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC).trim().toUpperCase(Locale.ROOT);
if (normalized.isBlank() || normalized.length() > 64) throw validation("业务编码格式不符合要求");
return normalized;
}
private String nullableTrim(String value, int maxLength) {
if (value == null || value.isBlank()) return null;
String result = value.trim();
if (result.length() > maxLength) throw queryInvalid("查询条件过长");
return result;
}
private void validatePublicId(String publicId) {
if (publicId == null || !publicId.matches(ULID_PATTERN)) {
throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID,
"公开编号格式不正确");
}
}
private void requireVersion(long actual, long requested) {
if (actual != requested) throw conflict();
}
private void requireState(String actual, Set<String> allowed) {
if (!allowed.contains(actual)) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION,
"当前状态不允许执行该操作");
}
}
private void requireDifferentReviewer(Long submittedBy, long actorId) {
if (submittedBy != null && submittedBy == actorId) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION,
"提交人与复核人不能是同一人");
}
}
private void requireDifferentReviewer(long createdBy, Long submittedBy, long actorId) {
if (createdBy == actorId || (submittedBy != null && submittedBy == actorId)) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION,
"经办人与复核人不能是同一人");
}
}
private String decimal(BigDecimal value) {
return value == null ? null : value.toPlainString();
}
private BusinessException duplicate(String message) {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message);
}
private BusinessException conflict() {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT,
"数据已被其他用户更新,请刷新后重试");
}
private BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
}
private BusinessException notFound(String message) {
return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message);
}
private BusinessException queryInvalid(String message) {
return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message);
}
private record Page(int page, int size, int offset) {
}
private record Owner(String type, Long companyId, Long counterpartyId, String companyPublicId,
MasterDataReferenceRecord reference) {
}
private record ContractReferences(MasterDataReferenceRecord company, MasterDataReferenceRecord project,
MasterDataReferenceRecord counterparty) {
}
}
@@ -0,0 +1,317 @@
package com.kaidi.finance.masterdata.application;
import com.kaidi.finance.masterdata.api.BankAccountView;
import com.kaidi.finance.masterdata.api.ContractView;
import com.kaidi.finance.masterdata.api.LegacyIdentifierCreateRequest;
import com.kaidi.finance.masterdata.api.LegacyIdentifierResolutionView;
import com.kaidi.finance.masterdata.api.LegacyIdentifierView;
import com.kaidi.finance.masterdata.infrastructure.LegacyIdentifierMapper;
import com.kaidi.finance.project.api.ProjectDetailView;
import com.kaidi.finance.project.application.ProjectApplicationService;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.id.UlidGenerator;
import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import java.math.BigDecimal;
import java.text.Normalizer;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class LegacyIdentifierApplicationService {
private static final String ULID_PATTERN = "[0-9A-HJKMNP-TV-Z]{26}";
private static final Set<String> STATUSES = Set.of("ACTIVE", "DISABLED");
private static final Map<String, ResourceDefinition> RESOURCES = Map.of(
"companies", new ResourceDefinition("COMPANY", "masterdata:company:view", "masterdata:company:edit"),
"departments", new ResourceDefinition("DEPARTMENT", "masterdata:dictionary:view", "masterdata:dictionary:edit"),
"projects", new ResourceDefinition("PROJECT", "project:project:view", "project:project:edit"),
"counterparties", new ResourceDefinition("COUNTERPARTY", "masterdata:counterparty:view",
"masterdata:counterparty:edit"),
"bank-accounts", new ResourceDefinition("BANK_ACCOUNT", "masterdata:bank-account:view",
"masterdata:bank-account:edit"),
"contracts", new ResourceDefinition("CONTRACT", "masterdata:contract:view", "masterdata:contract:edit"),
"cost-categories", new ResourceDefinition("COST_CATEGORY", "masterdata:dictionary:view",
"masterdata:dictionary:edit"),
"accounts", new ResourceDefinition("ACCOUNT", "masterdata:dictionary:view", "masterdata:dictionary:edit"),
"tax-rates", new ResourceDefinition("TAX_RATE", "masterdata:dictionary:view", "masterdata:dictionary:edit")
);
private final LegacyIdentifierMapper mapper;
private final MasterDataApplicationService masterDataService;
private final CatalogMasterDataApplicationService catalogService;
private final ProjectApplicationService projectService;
private final AuthorizationService authorizationService;
private final IdentityContext identityContext;
private final AuditService auditService;
private final UlidGenerator ulidGenerator;
public LegacyIdentifierApplicationService(LegacyIdentifierMapper mapper,
MasterDataApplicationService masterDataService,
CatalogMasterDataApplicationService catalogService,
ProjectApplicationService projectService,
AuthorizationService authorizationService,
IdentityContext identityContext,
AuditService auditService,
UlidGenerator ulidGenerator) {
this.mapper = mapper;
this.masterDataService = masterDataService;
this.catalogService = catalogService;
this.projectService = projectService;
this.authorizationService = authorizationService;
this.identityContext = identityContext;
this.auditService = auditService;
this.ulidGenerator = ulidGenerator;
}
public boolean canView(String resource) {
ResourceDefinition definition = RESOURCES.get(resource);
return definition != null && authorizationService.hasPermission(definition.viewPermission());
}
public boolean canEdit(String resource) {
ResourceDefinition definition = RESOURCES.get(resource);
return definition != null && authorizationService.hasPermission(definition.editPermission());
}
@Transactional(readOnly = true)
public MappingPage list(String resource, String targetPublicId, String status, String keyword,
int page, int size) {
ResourceDefinition definition = definition(resource);
TargetAccess target = requireTarget(resource, targetPublicId);
Page request = page(page, size);
String safeStatus = status(status);
String safeKeyword = nullableTrim(keyword, 128);
boolean editable = hasEditScope(definition, target);
long total = mapper.count(definition.resourceType(), targetPublicId, safeStatus, safeKeyword);
var items = mapper.list(definition.resourceType(), targetPublicId, safeStatus, safeKeyword,
request.size(), request.offset()).stream()
.map(row -> view(resource, row, editable))
.toList();
Set<String> allowedActions = editable ? Set.of("CREATE") : Set.of();
return new MappingPage(new PageResult<>(items, total, request.page(), request.size()), allowedActions);
}
@Transactional
public LegacyIdentifierView create(String resource, String targetPublicId,
LegacyIdentifierCreateRequest request) {
ResourceDefinition definition = definition(resource);
TargetAccess target = requireTarget(resource, targetPublicId);
requireEditScope(definition, target);
String sourceSystem = sourceSystem(request.sourceSystem());
String legacyCode = request.legacyCode().trim();
String normalizedLegacyCode = normalizeLegacyCode(legacyCode);
String publicId = ulidGenerator.next();
long actorId = identityContext.requirePrincipal().userId();
try {
mapper.insert(publicId, definition.resourceType(), sourceSystem, legacyCode,
normalizedLegacyCode, targetPublicId, actorId);
} catch (DuplicateKeyException exception) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE,
"该来源系统的旧编号已存在有效映射");
}
LegacyIdentifierView after = view(resource, mapper.findByPublicId(publicId), true);
auditService.recordScoped(target.companyPublicId(), target.projectPublicId(),
"MASTERDATA_LEGACY_IDENTIFIER_CREATE", "MASTERDATA_LEGACY_IDENTIFIER", publicId,
"SUCCESS", null, null, after);
return after;
}
@Transactional(readOnly = true)
public LegacyIdentifierResolutionView resolve(String resource, String sourceSystem, String legacyCode) {
ResourceDefinition definition = definition(resource);
String safeSourceSystem = sourceSystem(sourceSystem);
String safeLegacyCode = normalizeLegacyCode(legacyCode);
LegacyIdentifierMapper.LegacyIdentifierRow row = mapper.resolve(definition.resourceType(),
safeSourceSystem, safeLegacyCode);
if (row == null) {
throw notFound("未找到有效的旧编号映射");
}
requireTarget(resource, row.targetPublicId());
return new LegacyIdentifierResolutionView(row.publicId(), resource, row.sourceSystem(),
row.legacyCode(), row.targetPublicId());
}
@Transactional
public LegacyIdentifierView disable(String resource, String targetPublicId,
String mappingPublicId, long version) {
ResourceDefinition definition = definition(resource);
TargetAccess target = requireTarget(resource, targetPublicId);
requireEditScope(definition, target);
validateMappingPublicId(mappingPublicId);
LegacyIdentifierMapper.LegacyIdentifierRow current = mapper.findForTarget(mappingPublicId,
definition.resourceType(), targetPublicId);
if (current == null) {
throw notFound("旧编号映射不存在或不属于当前主数据");
}
if (current.version() != version) {
throw conflict();
}
if (!"ACTIVE".equals(current.status())) {
throw new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION,
"当前映射已经停用");
}
LegacyIdentifierView before = view(resource, current, true);
if (mapper.disable(current.id(), version, identityContext.requirePrincipal().userId()) != 1) {
throw conflict();
}
LegacyIdentifierView after = view(resource, mapper.findByPublicId(mappingPublicId), true);
auditService.recordScoped(target.companyPublicId(), target.projectPublicId(),
"MASTERDATA_LEGACY_IDENTIFIER_DISABLE", "MASTERDATA_LEGACY_IDENTIFIER", mappingPublicId,
"SUCCESS", null, before, after);
return after;
}
private TargetAccess requireTarget(String resource, String publicId) {
return switch (resource) {
case "companies" -> {
masterDataService.getCompany(publicId);
yield new TargetAccess(publicId, null, null);
}
case "counterparties" -> {
masterDataService.getCounterparty(publicId);
yield new TargetAccess(null, null, null);
}
case "bank-accounts" -> {
BankAccountView view = catalogService.getBankAccount(publicId);
String companyId = "COMPANY".equals(view.ownerType()) ? view.owner().publicId() : null;
yield new TargetAccess(companyId, null, null);
}
case "contracts" -> {
ContractView view = catalogService.getContract(publicId);
yield new TargetAccess(view.company().publicId(), view.project().publicId(),
new BigDecimal(view.originalAmount()));
}
case "departments", "cost-categories", "accounts", "tax-rates" -> {
catalogService.getDictionary(resource, publicId);
yield new TargetAccess(null, null, null);
}
case "projects" -> {
ProjectDetailView view = projectService.get(publicId);
yield new TargetAccess(view.company().publicId(), view.publicId(), null);
}
default -> throw queryInvalid("不支持的主数据资源");
};
}
private boolean hasEditScope(ResourceDefinition definition, TargetAccess target) {
return authorizationService.hasScope(definition.editPermission(), target.companyPublicId(),
target.projectPublicId(), target.amount());
}
private void requireEditScope(ResourceDefinition definition, TargetAccess target) {
authorizationService.requireScope(definition.editPermission(), target.companyPublicId(),
target.projectPublicId(), target.amount());
}
private LegacyIdentifierView view(String resource, LegacyIdentifierMapper.LegacyIdentifierRow row,
boolean editable) {
Set<String> actions = new LinkedHashSet<>();
if (editable && "ACTIVE".equals(row.status())) {
actions.add("DISABLE");
}
return new LegacyIdentifierView(row.publicId(), resource, row.sourceSystem(), row.legacyCode(),
row.targetPublicId(), row.status(), row.version(), row.createdAt(), row.updatedAt(), Set.copyOf(actions));
}
private ResourceDefinition definition(String resource) {
ResourceDefinition definition = RESOURCES.get(resource);
if (definition == null) {
throw queryInvalid("不支持的主数据资源");
}
return definition;
}
private Page page(int page, int size) {
if (page < 1 || !Set.of(20, 50, 100).contains(size)) {
throw queryInvalid("分页参数不符合要求");
}
try {
return new Page(page, size, Math.toIntExact(Math.multiplyExact((long) page - 1L, size)));
} catch (ArithmeticException exception) {
throw queryInvalid("分页参数超出允许范围");
}
}
private String status(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!STATUSES.contains(normalized)) {
throw queryInvalid("映射状态不存在");
}
return normalized;
}
private String sourceSystem(String value) {
String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFKC).toUpperCase(Locale.ROOT);
if (!normalized.matches("[A-Z0-9][A-Z0-9._-]{0,63}")) {
throw validation("来源系统只能使用字母、数字、点、下划线和短横线");
}
return normalized;
}
private String normalizeLegacyCode(String value) {
String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFKC).toUpperCase(Locale.ROOT);
if (normalized.isBlank() || normalized.length() > 128) {
throw validation("旧编号不能为空且长度不得超过 128 个字符");
}
return normalized;
}
private String nullableTrim(String value, int maxLength) {
if (value == null || value.isBlank()) {
return null;
}
String result = value.trim();
if (result.length() > maxLength) {
throw queryInvalid("查询条件过长");
}
return result;
}
private void validateMappingPublicId(String publicId) {
if (publicId == null || !publicId.matches(ULID_PATTERN)) {
throw notFound("旧编号映射不存在或已不可用");
}
}
private BusinessException validation(String message) {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
}
private BusinessException queryInvalid(String message) {
return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message);
}
private BusinessException notFound(String message) {
return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message);
}
private BusinessException conflict() {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT,
"数据已被其他人更新,请重新加载后再操作");
}
public record MappingPage(PageResult<LegacyIdentifierView> page, Set<String> allowedActions) {
}
private record ResourceDefinition(String resourceType, String viewPermission, String editPermission) {
}
private record TargetAccess(String companyPublicId, String projectPublicId, BigDecimal amount) {
}
private record Page(int page, int size, int offset) {
}
}
@@ -0,0 +1,461 @@
package com.kaidi.finance.masterdata.application;
import com.kaidi.finance.iam.domain.FinancePrincipal;
import com.kaidi.finance.masterdata.api.CompanyCreateRequest;
import com.kaidi.finance.masterdata.api.CompanyUpdateRequest;
import com.kaidi.finance.masterdata.api.CompanyView;
import com.kaidi.finance.masterdata.api.CounterpartyCreateRequest;
import com.kaidi.finance.masterdata.api.CounterpartyUpdateRequest;
import com.kaidi.finance.masterdata.api.CounterpartyView;
import com.kaidi.finance.masterdata.api.ReviewRequest;
import com.kaidi.finance.masterdata.domain.CompanyRecord;
import com.kaidi.finance.masterdata.domain.CounterpartyRecord;
import com.kaidi.finance.masterdata.infrastructure.MasterDataMapper;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.id.UlidGenerator;
import com.kaidi.finance.shared.security.AuthorizationService;
import com.kaidi.finance.shared.security.IdentityContext;
import java.text.Normalizer;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MasterDataApplicationService {
private static final Set<String> COMPANY_STATUSES = Set.of("DRAFT", "REVIEWING", "RETURNED", "ACTIVE", "DISABLED");
private static final Set<String> COUNTERPARTY_TYPES = Set.of("CUSTOMER", "SUPPLIER", "BOTH");
private static final String ULID_PATTERN = "[0-9A-HJKMNP-TV-Z]{26}";
private final MasterDataMapper mapper;
private final IdentityContext identityContext;
private final AuthorizationService authorizationService;
private final AuditService auditService;
private final UlidGenerator ulidGenerator;
public MasterDataApplicationService(MasterDataMapper mapper, IdentityContext identityContext,
AuthorizationService authorizationService, AuditService auditService,
UlidGenerator ulidGenerator) {
this.mapper = mapper;
this.identityContext = identityContext;
this.authorizationService = authorizationService;
this.auditService = auditService;
this.ulidGenerator = ulidGenerator;
}
@Transactional
public CompanyView createCompany(CompanyCreateRequest request) {
authorizationService.requireGlobalScope("masterdata:company:create");
FinancePrincipal actor = identityContext.requirePrincipal();
CompanyRecord company = new CompanyRecord();
company.setPublicId(ulidGenerator.next());
company.setBusinessNo(normalizeCode(request.businessNo()));
company.setName(request.name().trim());
company.setNormalizedTaxNo(normalizeNullableCode(request.taxNo()));
company.setCreatedBy(actor.userId());
company.setUpdatedBy(actor.userId());
try {
mapper.insertCompany(company);
} catch (DuplicateKeyException exception) {
throw duplicate("公司编号或税号已存在");
}
CompanyRecord stored = mapper.findCompany(company.getPublicId());
CompanyView view = companyView(stored);
auditService.record("MASTERDATA_COMPANY_CREATE", "COMPANY", stored.getPublicId(), "SUCCESS", null, null, view);
return view;
}
@Transactional(readOnly = true)
public PageResult<CompanyView> listCompanies(String keyword, String status, int page, int size) {
authorizationService.requirePermission("masterdata:company:view");
Page pageRequest = page(page, size);
String normalizedStatus = status(status);
FinancePrincipal actor = identityContext.requirePrincipal();
String role = identityContext.requireActiveRole();
String safeKeyword = nullableTrim(keyword, 100);
long total = mapper.countCompanies(actor.userId(), role, safeKeyword, normalizedStatus);
var items = mapper.listCompanies(actor.userId(), role, safeKeyword, normalizedStatus,
pageRequest.size(), pageRequest.offset()).stream().map(this::companyView).toList();
return new PageResult<>(items, total, pageRequest.page(), pageRequest.size());
}
@Transactional(readOnly = true)
public CompanyView getCompany(String publicId) {
CompanyRecord company = requireCompany(publicId);
authorizationService.requireScope("masterdata:company:view", company.getPublicId(), null);
return companyView(company);
}
@Transactional
public CompanyView updateCompany(String publicId, CompanyUpdateRequest request) {
CompanyRecord current = requireCompany(publicId);
authorizationService.requireScope("masterdata:company:edit", current.getPublicId(), null);
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
CompanyView before = companyView(current);
current.setName(request.name().trim());
current.setNormalizedTaxNo(normalizeNullableCode(request.taxNo()));
current.setUpdatedBy(identityContext.requirePrincipal().userId());
try {
if (mapper.updateCompany(current) != 1) {
throw conflict();
}
} catch (DuplicateKeyException exception) {
throw duplicate("公司税号已存在");
}
CompanyView after = companyView(mapper.findCompany(publicId));
auditService.record("MASTERDATA_COMPANY_UPDATE", "COMPANY", publicId, "SUCCESS", null, before, after);
return after;
}
@Transactional
public CompanyView submitCompany(String publicId, long version) {
CompanyRecord current = requireCompany(publicId);
authorizationService.requireScope("masterdata:company:submit", current.getPublicId(), null);
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
CompanyView before = companyView(current);
long actorId = identityContext.requirePrincipal().userId();
if (mapper.submitCompany(current.getId(), version, actorId) != 1) {
throw conflict();
}
CompanyView after = companyView(mapper.findCompany(publicId));
auditService.record("MASTERDATA_COMPANY_SUBMIT", "COMPANY", publicId, "SUCCESS", null, before, after);
return after;
}
@Transactional
public CompanyView reviewCompany(String publicId, ReviewRequest request) {
CompanyRecord current = requireCompany(publicId);
authorizationService.requireScope("masterdata:company:review", current.getPublicId(), null);
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("REVIEWING"));
long actorId = identityContext.requirePrincipal().userId();
if (current.getSubmittedBy() != null && current.getSubmittedBy() == actorId) {
throw sod();
}
String target = reviewTarget(request.decision());
CompanyView before = companyView(current);
if (mapper.reviewCompany(current.getId(), request.version(), actorId, target, request.opinion().trim()) != 1) {
throw conflict();
}
CompanyView after = companyView(mapper.findCompany(publicId));
auditService.record("MASTERDATA_COMPANY_REVIEW", "COMPANY", publicId, "SUCCESS",
request.opinion().trim(), before, after);
return after;
}
@Transactional
public CompanyView disableCompany(String publicId, long version) {
CompanyRecord current = requireCompany(publicId);
authorizationService.requireScope("masterdata:company:disable", current.getPublicId(), null);
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("ACTIVE"));
CompanyView before = companyView(current);
if (mapper.disableCompany(current.getId(), version, identityContext.requirePrincipal().userId()) != 1) {
throw conflict();
}
CompanyView after = companyView(mapper.findCompany(publicId));
auditService.record("MASTERDATA_COMPANY_DISABLE", "COMPANY", publicId, "SUCCESS", null, before, after);
return after;
}
@Transactional
public CounterpartyView createCounterparty(CounterpartyCreateRequest request) {
authorizationService.requireGlobalScope("masterdata:counterparty:create");
FinancePrincipal actor = identityContext.requirePrincipal();
CounterpartyRecord counterparty = new CounterpartyRecord();
counterparty.setPublicId(ulidGenerator.next());
counterparty.setBusinessNo(normalizeCode(request.businessNo()));
counterparty.setCounterpartyType(type(request.type()));
counterparty.setName(request.name().trim());
counterparty.setNormalizedTaxNo(normalizeNullableCode(request.taxNo()));
counterparty.setContactName(nullableTrim(request.contactName(), 100));
counterparty.setContactPhone(nullableTrim(request.contactPhone(), 32));
counterparty.setCreatedBy(actor.userId());
counterparty.setUpdatedBy(actor.userId());
try {
mapper.insertCounterparty(counterparty);
} catch (DuplicateKeyException exception) {
throw duplicate("往来单位编号或税号已存在");
}
CounterpartyRecord stored = mapper.findCounterparty(counterparty.getPublicId());
CounterpartyView view = counterpartyView(stored);
auditService.record("MASTERDATA_COUNTERPARTY_CREATE", "COUNTERPARTY", stored.getPublicId(), "SUCCESS",
null, null, view);
return view;
}
@Transactional(readOnly = true)
public PageResult<CounterpartyView> listCounterparties(String keyword, String status, String type,
int page, int size) {
authorizationService.requirePermission("masterdata:counterparty:view");
Page pageRequest = page(page, size);
String normalizedStatus = status(status);
String normalizedType = type == null || type.isBlank() ? null : type(type);
FinancePrincipal actor = identityContext.requirePrincipal();
String role = identityContext.requireActiveRole();
String safeKeyword = nullableTrim(keyword, 100);
long total = mapper.countCounterparties(actor.userId(), role, safeKeyword, normalizedStatus, normalizedType);
var items = mapper.listCounterparties(actor.userId(), role, safeKeyword, normalizedStatus, normalizedType,
pageRequest.size(), pageRequest.offset()).stream().map(this::counterpartyView).toList();
return new PageResult<>(items, total, pageRequest.page(), pageRequest.size());
}
@Transactional(readOnly = true)
public CounterpartyView getCounterparty(String publicId) {
authorizationService.requireGlobalScope("masterdata:counterparty:view");
return counterpartyView(requireCounterparty(publicId));
}
@Transactional
public CounterpartyView updateCounterparty(String publicId, CounterpartyUpdateRequest request) {
CounterpartyRecord current = requireCounterparty(publicId);
authorizationService.requireGlobalScope("masterdata:counterparty:edit");
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
CounterpartyView before = counterpartyView(current);
current.setCounterpartyType(type(request.type()));
current.setName(request.name().trim());
current.setNormalizedTaxNo(normalizeNullableCode(request.taxNo()));
current.setContactName(nullableTrim(request.contactName(), 100));
current.setContactPhone(nullableTrim(request.contactPhone(), 32));
current.setUpdatedBy(identityContext.requirePrincipal().userId());
try {
if (mapper.updateCounterparty(current) != 1) {
throw conflict();
}
} catch (DuplicateKeyException exception) {
throw duplicate("往来单位税号已存在");
}
CounterpartyView after = counterpartyView(mapper.findCounterparty(publicId));
auditService.record("MASTERDATA_COUNTERPARTY_UPDATE", "COUNTERPARTY", publicId, "SUCCESS", null,
before, after);
return after;
}
@Transactional
public CounterpartyView submitCounterparty(String publicId, long version) {
CounterpartyRecord current = requireCounterparty(publicId);
authorizationService.requireGlobalScope("masterdata:counterparty:submit");
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("DRAFT", "RETURNED"));
CounterpartyView before = counterpartyView(current);
long actorId = identityContext.requirePrincipal().userId();
if (mapper.submitCounterparty(current.getId(), version, actorId) != 1) {
throw conflict();
}
CounterpartyView after = counterpartyView(mapper.findCounterparty(publicId));
auditService.record("MASTERDATA_COUNTERPARTY_SUBMIT", "COUNTERPARTY", publicId, "SUCCESS", null,
before, after);
return after;
}
@Transactional
public CounterpartyView reviewCounterparty(String publicId, ReviewRequest request) {
CounterpartyRecord current = requireCounterparty(publicId);
authorizationService.requireGlobalScope("masterdata:counterparty:review");
requireVersion(current.getVersion(), request.version());
requireState(current.getStatus(), Set.of("REVIEWING"));
long actorId = identityContext.requirePrincipal().userId();
if (current.getCreatedBy() == actorId
|| (current.getSubmittedBy() != null && current.getSubmittedBy() == actorId)) {
throw sod();
}
String target = reviewTarget(request.decision());
CounterpartyView before = counterpartyView(current);
if (mapper.reviewCounterparty(current.getId(), request.version(), actorId, target,
request.opinion().trim()) != 1) {
throw conflict();
}
CounterpartyView after = counterpartyView(mapper.findCounterparty(publicId));
auditService.record("MASTERDATA_COUNTERPARTY_REVIEW", "COUNTERPARTY", publicId, "SUCCESS",
request.opinion().trim(), before, after);
return after;
}
@Transactional
public CounterpartyView disableCounterparty(String publicId, long version) {
CounterpartyRecord current = requireCounterparty(publicId);
authorizationService.requireGlobalScope("masterdata:counterparty:disable");
requireVersion(current.getVersion(), version);
requireState(current.getStatus(), Set.of("ACTIVE"));
CounterpartyView before = counterpartyView(current);
if (mapper.disableCounterparty(current.getId(), version, identityContext.requirePrincipal().userId()) != 1) {
throw conflict();
}
CounterpartyView after = counterpartyView(mapper.findCounterparty(publicId));
auditService.record("MASTERDATA_COUNTERPARTY_DISABLE", "COUNTERPARTY", publicId, "SUCCESS", null,
before, after);
return after;
}
private CompanyRecord requireCompany(String publicId) {
validatePublicId(publicId);
CompanyRecord company = mapper.findCompany(publicId);
if (company == null) {
throw notFound("公司不存在或已不可用");
}
return company;
}
private CounterpartyRecord requireCounterparty(String publicId) {
validatePublicId(publicId);
CounterpartyRecord counterparty = mapper.findCounterparty(publicId);
if (counterparty == null) {
throw notFound("往来单位不存在或已不可用");
}
return counterparty;
}
private CompanyView companyView(CompanyRecord company) {
Set<String> actions = new LinkedHashSet<>();
if (Set.of("DRAFT", "RETURNED").contains(company.getStatus())
&& authorizationService.hasScope("masterdata:company:edit", company.getPublicId(), null)) {
actions.add("EDIT");
}
if (Set.of("DRAFT", "RETURNED").contains(company.getStatus())
&& authorizationService.hasScope("masterdata:company:submit", company.getPublicId(), null)) {
actions.add("SUBMIT");
}
if ("REVIEWING".equals(company.getStatus())
&& authorizationService.hasScope("masterdata:company:review", company.getPublicId(), null)) {
actions.add("REVIEW");
}
if ("ACTIVE".equals(company.getStatus())
&& authorizationService.hasScope("masterdata:company:disable", company.getPublicId(), null)) {
actions.add("DISABLE");
}
return new CompanyView(company.getPublicId(), company.getBusinessNo(), company.getName(),
company.getNormalizedTaxNo(), company.getStatus(), company.getVersion(), company.getCreatedAt(),
company.getUpdatedAt(), Set.copyOf(actions));
}
private CounterpartyView counterpartyView(CounterpartyRecord counterparty) {
Set<String> actions = new LinkedHashSet<>();
if (Set.of("DRAFT", "RETURNED").contains(counterparty.getStatus())
&& authorizationService.hasScope("masterdata:counterparty:edit", null, null)) {
actions.add("EDIT");
}
if (Set.of("DRAFT", "RETURNED").contains(counterparty.getStatus())
&& authorizationService.hasScope("masterdata:counterparty:submit", null, null)) {
actions.add("SUBMIT");
}
if ("REVIEWING".equals(counterparty.getStatus())
&& authorizationService.hasScope("masterdata:counterparty:review", null, null)) {
actions.add("REVIEW");
}
if ("ACTIVE".equals(counterparty.getStatus())
&& authorizationService.hasScope("masterdata:counterparty:disable", null, null)) {
actions.add("DISABLE");
}
return new CounterpartyView(counterparty.getPublicId(), counterparty.getBusinessNo(),
counterparty.getCounterpartyType(), counterparty.getName(), counterparty.getNormalizedTaxNo(),
counterparty.getContactName(), counterparty.getContactPhone(), counterparty.getStatus(),
counterparty.getVersion(), counterparty.getCreatedAt(), counterparty.getUpdatedAt(), Set.copyOf(actions));
}
private Page page(int page, int size) {
if (page < 1 || !Set.of(20, 50, 100).contains(size)) {
throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID,
"分页参数不符合要求");
}
return new Page(page, size, Math.multiplyExact(page - 1, size));
}
private String status(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!COMPANY_STATUSES.contains(normalized)) {
throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, "状态筛选值不存在");
}
return normalized;
}
private String type(String value) {
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!COUNTERPARTY_TYPES.contains(normalized)) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
"往来单位类型必须是 CUSTOMER、SUPPLIER 或 BOTH");
}
return normalized;
}
private String reviewTarget(String decision) {
return switch (decision.trim().toUpperCase(Locale.ROOT)) {
case "APPROVE" -> "ACTIVE";
case "RETURN" -> "RETURNED";
default -> throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
"复核决定必须是 APPROVE 或 RETURN");
};
}
private String normalizeCode(String value) {
return Normalizer.normalize(value.trim(), Normalizer.Form.NFKC).toUpperCase(Locale.ROOT);
}
private String normalizeNullableCode(String value) {
return value == null || value.isBlank() ? null : normalizeCode(value);
}
private String nullableTrim(String value, int maxLength) {
if (value == null || value.isBlank()) {
return null;
}
String trimmed = value.trim();
if (trimmed.length() > maxLength) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED,
"查询或字段内容超过长度限制");
}
return trimmed;
}
private void validatePublicId(String publicId) {
if (publicId == null || !publicId.matches(ULID_PATTERN)) {
throw notFound("业务对象不存在或已不可用");
}
}
private void requireVersion(long current, long requested) {
if (current != requested) {
throw conflict();
}
}
private void requireState(String current, Set<String> allowed) {
if (!allowed.contains(current)) {
throw new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATE_TRANSITION,
"当前状态不允许执行该操作");
}
}
private BusinessException duplicate(String message) {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.DUPLICATE_RESOURCE, message);
}
private BusinessException conflict() {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.VERSION_CONFLICT,
"数据已被其他人更新,请重新加载后再操作");
}
private BusinessException notFound(String message) {
return new BusinessException(HttpStatus.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND, message);
}
private BusinessException sod() {
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.SOD_VIOLATION,
"经办人与复核人必须是不同人员");
}
private record Page(int page, int size, int offset) {
}
}
@@ -0,0 +1,182 @@
package com.kaidi.finance.masterdata.application;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kaidi.finance.masterdata.api.MasterDataVersionView;
import com.kaidi.finance.masterdata.infrastructure.MasterDataVersionMapper;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import com.kaidi.finance.shared.security.AuthorizationService;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MasterDataVersionApplicationService {
private static final Map<String, ResourceDefinition> RESOURCES = Map.of(
"companies", new ResourceDefinition("COMPANY", "MASTERDATA_COMPANY_", "masterdata:company:view"),
"departments", new ResourceDefinition("DEPARTMENT", "MASTERDATA_DICTIONARY_", "masterdata:dictionary:view"),
"counterparties", new ResourceDefinition("COUNTERPARTY", "MASTERDATA_COUNTERPARTY_",
"masterdata:counterparty:view"),
"bank-accounts", new ResourceDefinition("BANK_ACCOUNT", "MASTERDATA_BANK_ACCOUNT_",
"masterdata:bank-account:view"),
"contracts", new ResourceDefinition("CONTRACT", "MASTERDATA_CONTRACT_", "masterdata:contract:view"),
"cost-categories", new ResourceDefinition("COST_CATEGORY", "MASTERDATA_DICTIONARY_",
"masterdata:dictionary:view"),
"accounts", new ResourceDefinition("ACCOUNT", "MASTERDATA_DICTIONARY_", "masterdata:dictionary:view"),
"tax-rates", new ResourceDefinition("TAX_RATE", "MASTERDATA_DICTIONARY_", "masterdata:dictionary:view")
);
private final MasterDataVersionMapper mapper;
private final MasterDataApplicationService masterDataService;
private final CatalogMasterDataApplicationService catalogService;
private final AuthorizationService authorizationService;
private final ObjectMapper objectMapper;
public MasterDataVersionApplicationService(MasterDataVersionMapper mapper,
MasterDataApplicationService masterDataService,
CatalogMasterDataApplicationService catalogService,
AuthorizationService authorizationService,
ObjectMapper objectMapper) {
this.mapper = mapper;
this.masterDataService = masterDataService;
this.catalogService = catalogService;
this.authorizationService = authorizationService;
this.objectMapper = objectMapper;
}
public boolean canView(String resource) {
ResourceDefinition definition = RESOURCES.get(resource);
return definition != null && authorizationService.hasPermission(definition.viewPermission());
}
@Transactional(readOnly = true)
public PageResult<MasterDataVersionView> listVersions(String resource, String publicId, int page, int size) {
ResourceDefinition definition = definition(resource);
requireResourceAccess(resource, publicId);
Page request = page(page, size);
long total = mapper.count(definition.objectType(), publicId, definition.actionPrefix());
var items = mapper.list(definition.objectType(), publicId, definition.actionPrefix(),
request.size(), request.offset()).stream()
.map(this::view)
.toList();
return new PageResult<>(items, total, request.page(), request.size());
}
private void requireResourceAccess(String resource, String publicId) {
switch (resource) {
case "companies" -> masterDataService.getCompany(publicId);
case "counterparties" -> masterDataService.getCounterparty(publicId);
case "bank-accounts" -> catalogService.getBankAccount(publicId);
case "contracts" -> catalogService.getContract(publicId);
case "departments", "cost-categories", "accounts", "tax-rates" ->
catalogService.getDictionary(resource, publicId);
default -> throw queryInvalid("不支持的主数据资源");
}
}
private MasterDataVersionView view(MasterDataVersionMapper.VersionRow row) {
JsonNode before = snapshot(row.beforeJson());
JsonNode after = snapshot(row.afterJson());
JsonNode current = after != null && !after.isNull() ? after : before;
long version = current == null ? 0 : current.path("version").asLong();
String status = current == null || current.path("status").isMissingNode()
? null : current.path("status").asText();
return new MasterDataVersionView(row.auditId(), version, status, row.actionCode(), row.actorName(),
row.changedAt(), before, after);
}
private JsonNode snapshot(String json) {
if (json == null) {
return null;
}
try {
JsonNode node = objectMapper.readTree(json);
if (!(node instanceof ObjectNode)) {
throw new IllegalStateException("主数据历史快照必须是 JSON 对象");
}
removeAllowedActions(node);
maskSensitiveFields(node);
return node;
} catch (JsonProcessingException exception) {
throw new IllegalStateException("主数据历史快照解析失败", exception);
}
}
private void removeAllowedActions(JsonNode node) {
if (node instanceof ObjectNode object) {
object.remove("allowedActions");
object.elements().forEachRemaining(this::removeAllowedActions);
} else if (node instanceof ArrayNode array) {
array.elements().forEachRemaining(this::removeAllowedActions);
}
}
private void maskSensitiveFields(JsonNode node) {
if (node instanceof ObjectNode object) {
object.properties().forEach(entry -> {
if (isSensitiveField(entry.getKey())) {
object.put(entry.getKey(), "***");
} else {
maskSensitiveFields(entry.getValue());
}
});
} else if (node instanceof ArrayNode array) {
array.elements().forEachRemaining(this::maskSensitiveFields);
}
}
private boolean isSensitiveField(String fieldName) {
String normalized = fieldName.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
return normalized.contains("password")
|| normalized.contains("token")
|| normalized.contains("secret")
|| normalized.contains("privatekey")
|| normalized.contains("encryptionkey")
|| normalized.endsWith("accountno")
|| normalized.endsWith("accountnumber")
|| normalized.endsWith("routingnumber")
|| normalized.contains("idcard")
|| normalized.contains("identitynumber")
|| normalized.endsWith("phone")
|| normalized.endsWith("mobile")
|| normalized.contains("salary");
}
private ResourceDefinition definition(String resource) {
ResourceDefinition definition = RESOURCES.get(resource);
if (definition == null) {
throw queryInvalid("不支持的主数据资源");
}
return definition;
}
private Page page(int page, int size) {
if (page < 1 || !Set.of(20, 50, 100).contains(size)) {
throw queryInvalid("分页参数不符合要求");
}
try {
return new Page(page, size, Math.toIntExact(Math.multiplyExact((long) page - 1L, size)));
} catch (ArithmeticException exception) {
throw queryInvalid("分页参数超出允许范围");
}
}
private BusinessException queryInvalid(String message) {
return new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_INVALID, message);
}
private record ResourceDefinition(String objectType, String actionPrefix, String viewPermission) {
}
private record Page(int page, int size, int offset) {
}
}
@@ -0,0 +1,94 @@
package com.kaidi.finance.masterdata.domain;
import java.time.LocalDateTime;
public class BankAccountRecord {
private long id;
private String publicId;
private String ownerType;
private Long companyId;
private String companyPublicId;
private String companyName;
private Long counterpartyId;
private String counterpartyPublicId;
private String counterpartyName;
private String accountCategory;
private String accountName;
private String bankName;
private byte[] accountNoCiphertext;
private String accountNoHash;
private String maskedAccountNo;
private int versionNo;
private LocalDateTime validFrom;
private LocalDateTime validTo;
private String status;
private long version;
private long createdBy;
private long updatedBy;
private Long submittedBy;
private LocalDateTime submittedAt;
private Long reviewedBy;
private LocalDateTime reviewedAt;
private String reviewOpinion;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getPublicId() { return publicId; }
public void setPublicId(String publicId) { this.publicId = publicId; }
public String getOwnerType() { return ownerType; }
public void setOwnerType(String ownerType) { this.ownerType = ownerType; }
public Long getCompanyId() { return companyId; }
public void setCompanyId(Long companyId) { this.companyId = companyId; }
public String getCompanyPublicId() { return companyPublicId; }
public void setCompanyPublicId(String companyPublicId) { this.companyPublicId = companyPublicId; }
public String getCompanyName() { return companyName; }
public void setCompanyName(String companyName) { this.companyName = companyName; }
public Long getCounterpartyId() { return counterpartyId; }
public void setCounterpartyId(Long counterpartyId) { this.counterpartyId = counterpartyId; }
public String getCounterpartyPublicId() { return counterpartyPublicId; }
public void setCounterpartyPublicId(String counterpartyPublicId) { this.counterpartyPublicId = counterpartyPublicId; }
public String getCounterpartyName() { return counterpartyName; }
public void setCounterpartyName(String counterpartyName) { this.counterpartyName = counterpartyName; }
public String getAccountCategory() { return accountCategory; }
public void setAccountCategory(String accountCategory) { this.accountCategory = accountCategory; }
public String getAccountName() { return accountName; }
public void setAccountName(String accountName) { this.accountName = accountName; }
public String getBankName() { return bankName; }
public void setBankName(String bankName) { this.bankName = bankName; }
public byte[] getAccountNoCiphertext() { return accountNoCiphertext; }
public void setAccountNoCiphertext(byte[] accountNoCiphertext) { this.accountNoCiphertext = accountNoCiphertext; }
public String getAccountNoHash() { return accountNoHash; }
public void setAccountNoHash(String accountNoHash) { this.accountNoHash = accountNoHash; }
public String getMaskedAccountNo() { return maskedAccountNo; }
public void setMaskedAccountNo(String maskedAccountNo) { this.maskedAccountNo = maskedAccountNo; }
public int getVersionNo() { return versionNo; }
public void setVersionNo(int versionNo) { this.versionNo = versionNo; }
public LocalDateTime getValidFrom() { return validFrom; }
public void setValidFrom(LocalDateTime validFrom) { this.validFrom = validFrom; }
public LocalDateTime getValidTo() { return validTo; }
public void setValidTo(LocalDateTime validTo) { this.validTo = validTo; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public long getVersion() { return version; }
public void setVersion(long version) { this.version = version; }
public long getCreatedBy() { return createdBy; }
public void setCreatedBy(long createdBy) { this.createdBy = createdBy; }
public long getUpdatedBy() { return updatedBy; }
public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; }
public Long getSubmittedBy() { return submittedBy; }
public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; }
public LocalDateTime getSubmittedAt() { return submittedAt; }
public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; }
public Long getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public LocalDateTime getReviewedAt() { return reviewedAt; }
public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; }
public String getReviewOpinion() { return reviewOpinion; }
public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,55 @@
package com.kaidi.finance.masterdata.domain;
import java.time.LocalDateTime;
public class CompanyRecord {
private long id;
private String publicId;
private String businessNo;
private String name;
private String normalizedTaxNo;
private String status;
private long version;
private long createdBy;
private long updatedBy;
private Long submittedBy;
private LocalDateTime submittedAt;
private Long reviewedBy;
private LocalDateTime reviewedAt;
private String reviewOpinion;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getPublicId() { return publicId; }
public void setPublicId(String publicId) { this.publicId = publicId; }
public String getBusinessNo() { return businessNo; }
public void setBusinessNo(String businessNo) { this.businessNo = businessNo; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getNormalizedTaxNo() { return normalizedTaxNo; }
public void setNormalizedTaxNo(String normalizedTaxNo) { this.normalizedTaxNo = normalizedTaxNo; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public long getVersion() { return version; }
public void setVersion(long version) { this.version = version; }
public long getCreatedBy() { return createdBy; }
public void setCreatedBy(long createdBy) { this.createdBy = createdBy; }
public long getUpdatedBy() { return updatedBy; }
public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; }
public Long getSubmittedBy() { return submittedBy; }
public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; }
public LocalDateTime getSubmittedAt() { return submittedAt; }
public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; }
public Long getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public LocalDateTime getReviewedAt() { return reviewedAt; }
public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; }
public String getReviewOpinion() { return reviewOpinion; }
public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,92 @@
package com.kaidi.finance.masterdata.domain;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class ContractMasterRecord {
private long id;
private String publicId;
private long companyId;
private String companyPublicId;
private String companyName;
private long projectId;
private String projectPublicId;
private String projectName;
private long counterpartyId;
private String counterpartyPublicId;
private String counterpartyName;
private String businessNo;
private String name;
private BigDecimal originalAmount;
private BigDecimal approvedChangeAmount;
private BigDecimal settlementAmount;
private String currency;
private String status;
private long version;
private long createdBy;
private long updatedBy;
private Long submittedBy;
private LocalDateTime submittedAt;
private Long reviewedBy;
private LocalDateTime reviewedAt;
private String reviewOpinion;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getPublicId() { return publicId; }
public void setPublicId(String publicId) { this.publicId = publicId; }
public long getCompanyId() { return companyId; }
public void setCompanyId(long companyId) { this.companyId = companyId; }
public String getCompanyPublicId() { return companyPublicId; }
public void setCompanyPublicId(String companyPublicId) { this.companyPublicId = companyPublicId; }
public String getCompanyName() { return companyName; }
public void setCompanyName(String companyName) { this.companyName = companyName; }
public long getProjectId() { return projectId; }
public void setProjectId(long projectId) { this.projectId = projectId; }
public String getProjectPublicId() { return projectPublicId; }
public void setProjectPublicId(String projectPublicId) { this.projectPublicId = projectPublicId; }
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public long getCounterpartyId() { return counterpartyId; }
public void setCounterpartyId(long counterpartyId) { this.counterpartyId = counterpartyId; }
public String getCounterpartyPublicId() { return counterpartyPublicId; }
public void setCounterpartyPublicId(String counterpartyPublicId) { this.counterpartyPublicId = counterpartyPublicId; }
public String getCounterpartyName() { return counterpartyName; }
public void setCounterpartyName(String counterpartyName) { this.counterpartyName = counterpartyName; }
public String getBusinessNo() { return businessNo; }
public void setBusinessNo(String businessNo) { this.businessNo = businessNo; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public BigDecimal getOriginalAmount() { return originalAmount; }
public void setOriginalAmount(BigDecimal originalAmount) { this.originalAmount = originalAmount; }
public BigDecimal getApprovedChangeAmount() { return approvedChangeAmount; }
public void setApprovedChangeAmount(BigDecimal approvedChangeAmount) { this.approvedChangeAmount = approvedChangeAmount; }
public BigDecimal getSettlementAmount() { return settlementAmount; }
public void setSettlementAmount(BigDecimal settlementAmount) { this.settlementAmount = settlementAmount; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public long getVersion() { return version; }
public void setVersion(long version) { this.version = version; }
public long getCreatedBy() { return createdBy; }
public void setCreatedBy(long createdBy) { this.createdBy = createdBy; }
public long getUpdatedBy() { return updatedBy; }
public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; }
public Long getSubmittedBy() { return submittedBy; }
public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; }
public LocalDateTime getSubmittedAt() { return submittedAt; }
public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; }
public Long getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public LocalDateTime getReviewedAt() { return reviewedAt; }
public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; }
public String getReviewOpinion() { return reviewOpinion; }
public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,64 @@
package com.kaidi.finance.masterdata.domain;
import java.time.LocalDateTime;
public class CounterpartyRecord {
private long id;
private String publicId;
private String businessNo;
private String counterpartyType;
private String name;
private String normalizedTaxNo;
private String contactName;
private String contactPhone;
private String status;
private long version;
private long createdBy;
private long updatedBy;
private Long submittedBy;
private LocalDateTime submittedAt;
private Long reviewedBy;
private LocalDateTime reviewedAt;
private String reviewOpinion;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getPublicId() { return publicId; }
public void setPublicId(String publicId) { this.publicId = publicId; }
public String getBusinessNo() { return businessNo; }
public void setBusinessNo(String businessNo) { this.businessNo = businessNo; }
public String getCounterpartyType() { return counterpartyType; }
public void setCounterpartyType(String counterpartyType) { this.counterpartyType = counterpartyType; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getNormalizedTaxNo() { return normalizedTaxNo; }
public void setNormalizedTaxNo(String normalizedTaxNo) { this.normalizedTaxNo = normalizedTaxNo; }
public String getContactName() { return contactName; }
public void setContactName(String contactName) { this.contactName = contactName; }
public String getContactPhone() { return contactPhone; }
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public long getVersion() { return version; }
public void setVersion(long version) { this.version = version; }
public long getCreatedBy() { return createdBy; }
public void setCreatedBy(long createdBy) { this.createdBy = createdBy; }
public long getUpdatedBy() { return updatedBy; }
public void setUpdatedBy(long updatedBy) { this.updatedBy = updatedBy; }
public Long getSubmittedBy() { return submittedBy; }
public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; }
public LocalDateTime getSubmittedAt() { return submittedAt; }
public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; }
public Long getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public LocalDateTime getReviewedAt() { return reviewedAt; }
public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; }
public String getReviewOpinion() { return reviewOpinion; }
public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,71 @@
package com.kaidi.finance.masterdata.domain;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class DictionaryRecord {
private long id;
private String publicId;
private String code;
private String name;
private Long parentId;
private String parentPublicId;
private String parentName;
private String accountType;
private Boolean auxiliaryRequired;
private BigDecimal rate;
private String status;
private long version;
private Long createdBy;
private Long updatedBy;
private Long submittedBy;
private LocalDateTime submittedAt;
private Long reviewedBy;
private LocalDateTime reviewedAt;
private String reviewOpinion;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getPublicId() { return publicId; }
public void setPublicId(String publicId) { this.publicId = publicId; }
public String getCode() { return code; }
public void setCode(String code) { this.code = code; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Long getParentId() { return parentId; }
public void setParentId(Long parentId) { this.parentId = parentId; }
public String getParentPublicId() { return parentPublicId; }
public void setParentPublicId(String parentPublicId) { this.parentPublicId = parentPublicId; }
public String getParentName() { return parentName; }
public void setParentName(String parentName) { this.parentName = parentName; }
public String getAccountType() { return accountType; }
public void setAccountType(String accountType) { this.accountType = accountType; }
public Boolean getAuxiliaryRequired() { return auxiliaryRequired; }
public void setAuxiliaryRequired(Boolean auxiliaryRequired) { this.auxiliaryRequired = auxiliaryRequired; }
public BigDecimal getRate() { return rate; }
public void setRate(BigDecimal rate) { this.rate = rate; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public long getVersion() { return version; }
public void setVersion(long version) { this.version = version; }
public Long getCreatedBy() { return createdBy; }
public void setCreatedBy(Long createdBy) { this.createdBy = createdBy; }
public Long getUpdatedBy() { return updatedBy; }
public void setUpdatedBy(Long updatedBy) { this.updatedBy = updatedBy; }
public Long getSubmittedBy() { return submittedBy; }
public void setSubmittedBy(Long submittedBy) { this.submittedBy = submittedBy; }
public LocalDateTime getSubmittedAt() { return submittedAt; }
public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; }
public Long getReviewedBy() { return reviewedBy; }
public void setReviewedBy(Long reviewedBy) { this.reviewedBy = reviewedBy; }
public LocalDateTime getReviewedAt() { return reviewedAt; }
public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; }
public String getReviewOpinion() { return reviewOpinion; }
public void setReviewOpinion(String reviewOpinion) { this.reviewOpinion = reviewOpinion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,5 @@
package com.kaidi.finance.masterdata.domain;
public record MasterDataReferenceRecord(long id, String publicId, String code, String name,
String companyPublicId, String type) {
}
@@ -0,0 +1,609 @@
package com.kaidi.finance.masterdata.infrastructure;
import com.kaidi.finance.masterdata.domain.BankAccountRecord;
import com.kaidi.finance.masterdata.domain.ContractMasterRecord;
import com.kaidi.finance.masterdata.domain.DictionaryRecord;
import com.kaidi.finance.masterdata.domain.MasterDataReferenceRecord;
import java.util.List;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@org.apache.ibatis.annotations.Mapper
public interface CatalogMasterDataMapper {
@Select("""
SELECT company.id, company.public_id, company.business_no AS code, company.name,
company.public_id AS company_public_id, 'COMPANY' AS type
FROM md_company company
WHERE company.public_id = #{publicId} AND company.status = 'ACTIVE'
""")
MasterDataReferenceRecord findCompanyReference(String publicId);
@Select("""
SELECT project.id, project.public_id, project.business_no AS code, project.name,
company.public_id AS company_public_id, 'PROJECT' AS type
FROM md_project project
JOIN md_company company ON company.id = project.company_id
WHERE project.public_id = #{publicId} AND project.status = 'ACTIVE' AND company.status = 'ACTIVE'
""")
MasterDataReferenceRecord findProjectReference(String publicId);
@Select("""
SELECT counterparty.id, counterparty.public_id, counterparty.business_no AS code, counterparty.name,
NULL AS company_public_id, counterparty.counterparty_type AS type
FROM md_counterparty counterparty
WHERE counterparty.public_id = #{publicId} AND counterparty.status = 'ACTIVE'
""")
MasterDataReferenceRecord findCounterpartyReference(String publicId);
@Select("""
SELECT category.id, category.public_id, category.code, category.name,
NULL AS company_public_id, 'COST_CATEGORY' AS type
FROM md_cost_category category
WHERE category.public_id = #{publicId} AND category.status = 'ACTIVE'
""")
MasterDataReferenceRecord findCostCategoryReference(String publicId);
@Select("""
SELECT company.id, company.public_id, company.business_no AS code, company.name,
company.public_id AS company_public_id, 'COMPANY' AS type
FROM md_company company
WHERE company.status = 'ACTIVE'
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'masterdata:company:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id))
)
ORDER BY company.business_no
""")
List<MasterDataReferenceRecord> listCompanyReferences(@Param("userId") long userId,
@Param("roleCode") String roleCode);
@Select("""
SELECT project.id, project.public_id, project.business_no AS code, project.name,
company.public_id AS company_public_id, 'PROJECT' AS type
FROM md_project project
JOIN md_company company ON company.id = project.company_id
WHERE project.status = 'ACTIVE' AND company.status = 'ACTIVE'
AND EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'project:project:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from <= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to >= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
ORDER BY project.business_no
""")
List<MasterDataReferenceRecord> listProjectReferences(@Param("userId") long userId,
@Param("roleCode") String roleCode);
@Select("""
SELECT counterparty.id, counterparty.public_id, counterparty.business_no AS code, counterparty.name,
NULL AS company_public_id, counterparty.counterparty_type AS type
FROM md_counterparty counterparty
WHERE counterparty.status = 'ACTIVE'
ORDER BY counterparty.business_no
""")
List<MasterDataReferenceRecord> listCounterpartyReferences();
@Select("""
SELECT category.id, category.public_id, category.code, category.name,
NULL AS company_public_id, 'COST_CATEGORY' AS type
FROM md_cost_category category
WHERE category.status = 'ACTIVE'
ORDER BY category.code
""")
List<MasterDataReferenceRecord> listCostCategoryReferences();
@Insert("""
INSERT INTO md_bank_account_version (
public_id, owner_type, company_id, counterparty_id, account_category,
account_name, bank_name, account_no_ciphertext, account_no_hash, masked_account_no,
version_no, valid_from, valid_to, created_by, updated_by
) VALUES (
#{publicId}, #{ownerType}, #{companyId}, #{counterpartyId}, #{accountCategory},
#{accountName}, #{bankName}, #{accountNoCiphertext}, #{accountNoHash}, #{maskedAccountNo},
#{versionNo}, #{validFrom}, #{validTo}, #{createdBy}, #{updatedBy}
)
""")
int insertBankAccount(BankAccountRecord record);
@Select("""
SELECT account.*, company.public_id AS company_public_id, company.name AS company_name,
counterparty.public_id AS counterparty_public_id, counterparty.name AS counterparty_name
FROM md_bank_account_version account
LEFT JOIN md_company company ON company.id = account.company_id
LEFT JOIN md_counterparty counterparty ON counterparty.id = account.counterparty_id
WHERE account.public_id = #{publicId}
""")
BankAccountRecord findBankAccount(String publicId);
@Select("""
<script>
SELECT account.*, company.public_id AS company_public_id, company.name AS company_name,
counterparty.public_id AS counterparty_public_id, counterparty.name AS counterparty_name
FROM md_bank_account_version account
LEFT JOIN md_company company ON company.id = account.company_id
LEFT JOIN md_counterparty counterparty ON counterparty.id = account.counterparty_id
WHERE EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'masterdata:bank-account:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to &gt;= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND account.company_id IS NOT NULL
AND scope.company_public_id = company.public_id))
)
<if test="keyword != null">AND (account.account_name LIKE CONCAT('%', #{keyword}, '%') OR account.bank_name LIKE CONCAT('%', #{keyword}, '%') OR account.masked_account_no LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="status != null">AND account.status = #{status}</if>
ORDER BY account.updated_at DESC, account.public_id ASC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<BankAccountRecord> listBankAccounts(@Param("userId") long userId,
@Param("roleCode") String roleCode,
@Param("keyword") String keyword,
@Param("status") String status,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("""
<script>
SELECT COUNT(*)
FROM md_bank_account_version account
LEFT JOIN md_company company ON company.id = account.company_id
WHERE EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'masterdata:bank-account:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to &gt;= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND account.company_id IS NOT NULL
AND scope.company_public_id = company.public_id))
)
<if test="keyword != null">AND (account.account_name LIKE CONCAT('%', #{keyword}, '%') OR account.bank_name LIKE CONCAT('%', #{keyword}, '%') OR account.masked_account_no LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="status != null">AND account.status = #{status}</if>
</script>
""")
long countBankAccounts(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("keyword") String keyword, @Param("status") String status);
@Select("""
SELECT COALESCE(MAX(version_no), 0) + 1
FROM md_bank_account_version
WHERE (#{companyId} IS NOT NULL AND company_id = #{companyId})
OR (#{counterpartyId} IS NOT NULL AND counterparty_id = #{counterpartyId})
""")
int nextBankAccountVersion(@Param("companyId") Long companyId,
@Param("counterpartyId") Long counterpartyId);
@Select("""
SELECT COUNT(*) FROM md_bank_account_version
WHERE account_no_hash = #{hash} AND status <> 'DISABLED'
AND (#{excludeId} IS NULL OR id <> #{excludeId})
AND ((#{companyId} IS NOT NULL AND company_id = #{companyId})
OR (#{counterpartyId} IS NOT NULL AND counterparty_id = #{counterpartyId}))
""")
long countDuplicateBankAccount(@Param("companyId") Long companyId,
@Param("counterpartyId") Long counterpartyId,
@Param("hash") String hash,
@Param("excludeId") Long excludeId);
@Update("""
UPDATE md_bank_account_version
SET account_category = #{accountCategory}, account_name = #{accountName}, bank_name = #{bankName},
account_no_ciphertext = #{accountNoCiphertext}, account_no_hash = #{accountNoHash},
masked_account_no = #{maskedAccountNo}, valid_from = #{validFrom}, valid_to = #{validTo},
updated_by = #{updatedBy}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int updateBankAccount(BankAccountRecord record);
@Update("""
UPDATE md_bank_account_version
SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3),
reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL,
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int submitBankAccount(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId);
@Update("""
UPDATE md_bank_account_version
SET status = #{targetStatus}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3),
review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING'
AND submitted_by <> #{actorId}
""")
int reviewBankAccount(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId, @Param("targetStatus") String targetStatus,
@Param("opinion") String opinion);
@Select("""
SELECT CASE
WHEN account.company_id IS NOT NULL THEN company.status
ELSE counterparty.status
END
FROM md_bank_account_version account
LEFT JOIN md_company company ON company.id = account.company_id
LEFT JOIN md_counterparty counterparty ON counterparty.id = account.counterparty_id
WHERE account.id = #{accountId}
""")
String findBankAccountOwnerStatus(long accountId);
@Update("""
UPDATE md_bank_account_version
SET status = 'DISABLED', valid_to = COALESCE(valid_to, UTC_TIMESTAMP(3)),
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE'
""")
int disableBankAccount(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId);
@Insert("""
INSERT INTO md_contract (
public_id, company_id, project_id, counterparty_id, business_no, name,
original_amount, approved_change_amount, currency, created_by, updated_by
) VALUES (
#{publicId}, #{companyId}, #{projectId}, #{counterpartyId}, #{businessNo}, #{name},
#{originalAmount}, 0, #{currency}, #{createdBy}, #{updatedBy}
)
""")
int insertContract(ContractMasterRecord record);
@Select("""
SELECT contract.*, company.public_id AS company_public_id, company.name AS company_name,
project.public_id AS project_public_id, project.name AS project_name,
counterparty.public_id AS counterparty_public_id, counterparty.name AS counterparty_name
FROM md_contract contract
JOIN md_company company ON company.id = contract.company_id
JOIN md_project project ON project.id = contract.project_id
JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id
WHERE contract.public_id = #{publicId}
""")
ContractMasterRecord findContract(String publicId);
@Select("""
<script>
SELECT contract.*, company.public_id AS company_public_id, company.name AS company_name,
project.public_id AS project_public_id, project.name AS project_name,
counterparty.public_id AS counterparty_public_id, counterparty.name AS counterparty_name
FROM md_contract contract
JOIN md_company company ON company.id = contract.company_id
JOIN md_project project ON project.id = contract.project_id
JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id
WHERE EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'masterdata:contract:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to &gt;= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
<if test="keyword != null">AND (contract.business_no LIKE CONCAT('%', #{keyword}, '%') OR contract.name LIKE CONCAT('%', #{keyword}, '%') OR project.name LIKE CONCAT('%', #{keyword}, '%') OR counterparty.name LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="status != null">AND contract.status = #{status}</if>
ORDER BY contract.updated_at DESC, contract.public_id ASC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<ContractMasterRecord> listContracts(@Param("userId") long userId,
@Param("roleCode") String roleCode,
@Param("keyword") String keyword,
@Param("status") String status,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("""
<script>
SELECT COUNT(*)
FROM md_contract contract
JOIN md_company company ON company.id = contract.company_id
JOIN md_project project ON project.id = contract.project_id
JOIN md_counterparty counterparty ON counterparty.id = contract.counterparty_id
WHERE EXISTS (
SELECT 1 FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId} AND role.code = #{roleCode}
AND permission.code = 'masterdata:contract:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to &gt;= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id)
OR (scope.scope_type = 'PROJECT' AND scope.project_public_id = project.public_id))
)
<if test="keyword != null">AND (contract.business_no LIKE CONCAT('%', #{keyword}, '%') OR contract.name LIKE CONCAT('%', #{keyword}, '%') OR project.name LIKE CONCAT('%', #{keyword}, '%') OR counterparty.name LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="status != null">AND contract.status = #{status}</if>
</script>
""")
long countContracts(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("keyword") String keyword, @Param("status") String status);
@Update("""
UPDATE md_contract
SET company_id = #{companyId}, project_id = #{projectId}, counterparty_id = #{counterpartyId},
name = #{name}, original_amount = #{originalAmount}, currency = #{currency},
updated_by = #{updatedBy}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int updateContract(ContractMasterRecord record);
@Update("""
UPDATE md_contract
SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3),
reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL,
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int submitContract(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId);
@Update("""
UPDATE md_contract
SET status = #{targetStatus}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3),
review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING'
AND submitted_by <> #{actorId}
""")
int reviewContract(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId, @Param("targetStatus") String targetStatus,
@Param("opinion") String opinion);
@Update("""
UPDATE md_contract
SET status = 'DISABLED', updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE'
""")
int disableContract(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId);
@Insert("""
<script>
<choose>
<when test="resource == 'departments'">
INSERT INTO md_department (public_id, code, name, created_by, updated_by)
VALUES (#{record.publicId}, #{record.code}, #{record.name}, #{record.createdBy}, #{record.updatedBy})
</when>
<when test="resource == 'cost-categories'">
INSERT INTO md_cost_category (public_id, code, name, parent_id, status, created_by, updated_by)
VALUES (#{record.publicId}, #{record.code}, #{record.name}, #{record.parentId}, 'DRAFT', #{record.createdBy}, #{record.updatedBy})
</when>
<when test="resource == 'accounts'">
INSERT INTO md_account (public_id, code, name, account_type, auxiliary_required, created_by, updated_by)
VALUES (#{record.publicId}, #{record.code}, #{record.name}, #{record.accountType}, #{record.auxiliaryRequired}, #{record.createdBy}, #{record.updatedBy})
</when>
<otherwise>
INSERT INTO md_tax_rate (public_id, code, name, rate, created_by, updated_by)
VALUES (#{record.publicId}, #{record.code}, #{record.name}, #{record.rate}, #{record.createdBy}, #{record.updatedBy})
</otherwise>
</choose>
</script>
""")
int insertDictionary(@Param("resource") String resource, @Param("record") DictionaryRecord record);
@Select("""
<script>
SELECT * FROM (
<choose>
<when test="resource == 'departments'">
SELECT item.id, item.public_id, item.code, item.name, NULL AS parent_id,
NULL AS parent_public_id, NULL AS parent_name,
NULL AS account_type, NULL AS auxiliary_required, NULL AS rate,
item.status, item.version, item.created_by, item.updated_by,
item.submitted_by, item.submitted_at, item.reviewed_by, item.reviewed_at,
item.review_opinion, item.created_at, item.updated_at
FROM md_department item
</when>
<when test="resource == 'cost-categories'">
SELECT item.id, item.public_id, item.code, item.name, item.parent_id,
parent.public_id AS parent_public_id, parent.name AS parent_name,
NULL AS account_type, NULL AS auxiliary_required, NULL AS rate,
item.status, item.version, item.created_by, item.updated_by,
item.submitted_by, item.submitted_at, item.reviewed_by, item.reviewed_at,
item.review_opinion, item.created_at, item.updated_at
FROM md_cost_category item LEFT JOIN md_cost_category parent ON parent.id = item.parent_id
</when>
<when test="resource == 'accounts'">
SELECT item.id, item.public_id, item.code, item.name, NULL AS parent_id,
NULL AS parent_public_id, NULL AS parent_name,
item.account_type, item.auxiliary_required, NULL AS rate,
item.status, item.version, item.created_by, item.updated_by,
item.submitted_by, item.submitted_at, item.reviewed_by, item.reviewed_at,
item.review_opinion, item.created_at, item.updated_at
FROM md_account item
</when>
<otherwise>
SELECT item.id, item.public_id, item.code, item.name, NULL AS parent_id,
NULL AS parent_public_id, NULL AS parent_name,
NULL AS account_type, NULL AS auxiliary_required, item.rate,
item.status, item.version, item.created_by, item.updated_by,
item.submitted_by, item.submitted_at, item.reviewed_by, item.reviewed_at,
item.review_opinion, item.created_at, item.updated_at
FROM md_tax_rate item
</otherwise>
</choose>
) result
WHERE result.public_id = #{publicId}
</script>
""")
DictionaryRecord findDictionary(@Param("resource") String resource, @Param("publicId") String publicId);
@Select("""
<script>
SELECT * FROM (
<choose>
<when test="resource == 'departments'">
SELECT item.id, item.public_id, item.code, item.name, NULL AS parent_id,
NULL AS parent_public_id, NULL AS parent_name,
NULL AS account_type, NULL AS auxiliary_required, NULL AS rate,
item.status, item.version, item.created_by, item.updated_by,
item.submitted_by, item.submitted_at, item.reviewed_by, item.reviewed_at,
item.review_opinion, item.created_at, item.updated_at
FROM md_department item
</when>
<when test="resource == 'cost-categories'">
SELECT item.id, item.public_id, item.code, item.name, item.parent_id,
parent.public_id AS parent_public_id, parent.name AS parent_name,
NULL AS account_type, NULL AS auxiliary_required, NULL AS rate,
item.status, item.version, item.created_by, item.updated_by,
item.submitted_by, item.submitted_at, item.reviewed_by, item.reviewed_at,
item.review_opinion, item.created_at, item.updated_at
FROM md_cost_category item LEFT JOIN md_cost_category parent ON parent.id = item.parent_id
</when>
<when test="resource == 'accounts'">
SELECT item.id, item.public_id, item.code, item.name, NULL AS parent_id,
NULL AS parent_public_id, NULL AS parent_name,
item.account_type, item.auxiliary_required, NULL AS rate,
item.status, item.version, item.created_by, item.updated_by,
item.submitted_by, item.submitted_at, item.reviewed_by, item.reviewed_at,
item.review_opinion, item.created_at, item.updated_at
FROM md_account item
</when>
<otherwise>
SELECT item.id, item.public_id, item.code, item.name, NULL AS parent_id,
NULL AS parent_public_id, NULL AS parent_name,
NULL AS account_type, NULL AS auxiliary_required, item.rate,
item.status, item.version, item.created_by, item.updated_by,
item.submitted_by, item.submitted_at, item.reviewed_by, item.reviewed_at,
item.review_opinion, item.created_at, item.updated_at
FROM md_tax_rate item
</otherwise>
</choose>
) result
WHERE (#{keyword} IS NULL OR result.code LIKE CONCAT('%', #{keyword}, '%') OR result.name LIKE CONCAT('%', #{keyword}, '%'))
AND (#{status} IS NULL OR result.status = #{status})
ORDER BY result.updated_at DESC, result.public_id ASC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
List<DictionaryRecord> listDictionaries(@Param("resource") String resource,
@Param("keyword") String keyword,
@Param("status") String status,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("""
<script>
SELECT COUNT(*) FROM (
<choose>
<when test="resource == 'departments'">SELECT code, name, status FROM md_department</when>
<when test="resource == 'cost-categories'">SELECT code, name, status FROM md_cost_category</when>
<when test="resource == 'accounts'">SELECT code, name, status FROM md_account</when>
<otherwise>SELECT code, name, status FROM md_tax_rate</otherwise>
</choose>
) result
WHERE (#{keyword} IS NULL OR result.code LIKE CONCAT('%', #{keyword}, '%') OR result.name LIKE CONCAT('%', #{keyword}, '%'))
AND (#{status} IS NULL OR result.status = #{status})
</script>
""")
long countDictionaries(@Param("resource") String resource, @Param("keyword") String keyword,
@Param("status") String status);
@Update("""
<script>
<choose>
<when test="resource == 'departments'">
UPDATE md_department SET name = #{record.name}, updated_by = #{record.updatedBy}, version = version + 1
WHERE id = #{record.id} AND version = #{record.version} AND status IN ('DRAFT', 'RETURNED')
</when>
<when test="resource == 'cost-categories'">
UPDATE md_cost_category SET name = #{record.name}, parent_id = #{record.parentId},
updated_by = #{record.updatedBy}, version = version + 1
WHERE id = #{record.id} AND version = #{record.version} AND status IN ('DRAFT', 'RETURNED')
</when>
<when test="resource == 'accounts'">
UPDATE md_account SET name = #{record.name}, account_type = #{record.accountType},
auxiliary_required = #{record.auxiliaryRequired}, updated_by = #{record.updatedBy}, version = version + 1
WHERE id = #{record.id} AND version = #{record.version} AND status IN ('DRAFT', 'RETURNED')
</when>
<otherwise>
UPDATE md_tax_rate SET name = #{record.name}, rate = #{record.rate},
updated_by = #{record.updatedBy}, version = version + 1
WHERE id = #{record.id} AND version = #{record.version} AND status IN ('DRAFT', 'RETURNED')
</otherwise>
</choose>
</script>
""")
int updateDictionary(@Param("resource") String resource, @Param("record") DictionaryRecord record);
@Update("""
<script>
<choose>
<when test="resource == 'departments'">UPDATE md_department</when>
<when test="resource == 'cost-categories'">UPDATE md_cost_category</when>
<when test="resource == 'accounts'">UPDATE md_account</when>
<otherwise>UPDATE md_tax_rate</otherwise>
</choose>
SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3),
reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL,
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
</script>
""")
int submitDictionary(@Param("resource") String resource, @Param("id") long id,
@Param("version") long version, @Param("actorId") long actorId);
@Update("""
<script>
<choose>
<when test="resource == 'departments'">UPDATE md_department</when>
<when test="resource == 'cost-categories'">UPDATE md_cost_category</when>
<when test="resource == 'accounts'">UPDATE md_account</when>
<otherwise>UPDATE md_tax_rate</otherwise>
</choose>
SET status = #{targetStatus}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3),
review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING'
AND submitted_by &lt;&gt; #{actorId}
</script>
""")
int reviewDictionary(@Param("resource") String resource, @Param("id") long id,
@Param("version") long version, @Param("actorId") long actorId,
@Param("targetStatus") String targetStatus, @Param("opinion") String opinion);
@Update("""
<script>
<choose>
<when test="resource == 'departments'">UPDATE md_department</when>
<when test="resource == 'cost-categories'">UPDATE md_cost_category</when>
<when test="resource == 'accounts'">UPDATE md_account</when>
<otherwise>UPDATE md_tax_rate</otherwise>
</choose>
SET status = 'DISABLED', updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE'
</script>
""")
int disableDictionary(@Param("resource") String resource, @Param("id") long id,
@Param("version") long version, @Param("actorId") long actorId);
}
@@ -0,0 +1,97 @@
package com.kaidi.finance.masterdata.infrastructure;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@org.apache.ibatis.annotations.Mapper
public interface LegacyIdentifierMapper {
String TARGET_FILTERS = """
resource_type = #{resourceType}
AND target_public_id = #{targetPublicId}
<if test="status != null">AND status = #{status}</if>
<if test="keyword != null">
AND (source_system LIKE CONCAT('%', #{keyword}, '%')
OR legacy_code LIKE CONCAT('%', #{keyword}, '%'))
</if>
""";
@Select("<script>SELECT id, public_id, resource_type, source_system, legacy_code, target_public_id, status, "
+ "version, created_at, updated_at FROM md_legacy_identifier_mapping WHERE " + TARGET_FILTERS
+ " ORDER BY updated_at DESC, id DESC LIMIT #{limit} OFFSET #{offset}</script>")
List<LegacyIdentifierRow> list(@Param("resourceType") String resourceType,
@Param("targetPublicId") String targetPublicId,
@Param("status") String status,
@Param("keyword") String keyword,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("<script>SELECT COUNT(*) FROM md_legacy_identifier_mapping WHERE " + TARGET_FILTERS + "</script>")
long count(@Param("resourceType") String resourceType,
@Param("targetPublicId") String targetPublicId,
@Param("status") String status,
@Param("keyword") String keyword);
@Insert("""
INSERT INTO md_legacy_identifier_mapping (
public_id, resource_type, source_system, legacy_code, normalized_legacy_code,
target_public_id, status, version, created_by, updated_by
) VALUES (
#{publicId}, #{resourceType}, #{sourceSystem}, #{legacyCode}, #{normalizedLegacyCode},
#{targetPublicId}, 'ACTIVE', 0, #{actorId}, #{actorId}
)
""")
int insert(@Param("publicId") String publicId,
@Param("resourceType") String resourceType,
@Param("sourceSystem") String sourceSystem,
@Param("legacyCode") String legacyCode,
@Param("normalizedLegacyCode") String normalizedLegacyCode,
@Param("targetPublicId") String targetPublicId,
@Param("actorId") long actorId);
@Select("""
SELECT id, public_id, resource_type, source_system, legacy_code, target_public_id, status,
version, created_at, updated_at
FROM md_legacy_identifier_mapping
WHERE public_id = #{publicId}
""")
LegacyIdentifierRow findByPublicId(String publicId);
@Select("""
SELECT id, public_id, resource_type, source_system, legacy_code, target_public_id, status,
version, created_at, updated_at
FROM md_legacy_identifier_mapping
WHERE public_id = #{publicId} AND resource_type = #{resourceType}
AND target_public_id = #{targetPublicId}
""")
LegacyIdentifierRow findForTarget(@Param("publicId") String publicId,
@Param("resourceType") String resourceType,
@Param("targetPublicId") String targetPublicId);
@Select("""
SELECT id, public_id, resource_type, source_system, legacy_code, target_public_id, status,
version, created_at, updated_at
FROM md_legacy_identifier_mapping
WHERE resource_type = #{resourceType} AND source_system = #{sourceSystem}
AND normalized_legacy_code = #{normalizedLegacyCode} AND status = 'ACTIVE'
""")
LegacyIdentifierRow resolve(@Param("resourceType") String resourceType,
@Param("sourceSystem") String sourceSystem,
@Param("normalizedLegacyCode") String normalizedLegacyCode);
@Update("""
UPDATE md_legacy_identifier_mapping
SET status = 'DISABLED', version = version + 1, updated_by = #{actorId}
WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE'
""")
int disable(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId);
record LegacyIdentifierRow(long id, String publicId, String resourceType, String sourceSystem,
String legacyCode, String targetPublicId, String status, long version,
LocalDateTime createdAt, LocalDateTime updatedAt) {
}
}
@@ -0,0 +1,279 @@
package com.kaidi.finance.masterdata.infrastructure;
import com.kaidi.finance.masterdata.domain.CompanyRecord;
import com.kaidi.finance.masterdata.domain.CounterpartyRecord;
import java.util.List;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@org.apache.ibatis.annotations.Mapper
public interface MasterDataMapper {
String COMPANY_COLUMNS = """
company.id, company.public_id, company.business_no, company.name, company.normalized_tax_no,
company.status, company.version, company.created_by, company.updated_by,
company.submitted_by, company.submitted_at, company.reviewed_by, company.reviewed_at,
company.review_opinion, company.created_at, company.updated_at
""";
String COUNTERPARTY_COLUMNS = """
counterparty.id, counterparty.public_id, counterparty.business_no, counterparty.counterparty_type,
counterparty.name, counterparty.normalized_tax_no, counterparty.contact_name,
counterparty.contact_phone, counterparty.status, counterparty.version,
counterparty.created_by, counterparty.updated_by, counterparty.submitted_by,
counterparty.submitted_at, counterparty.reviewed_by, counterparty.reviewed_at,
counterparty.review_opinion, counterparty.created_at, counterparty.updated_at
""";
@Insert("""
INSERT INTO md_company (
public_id, business_no, name, normalized_tax_no, status, created_by, updated_by
) VALUES (
#{publicId}, #{businessNo}, #{name}, #{normalizedTaxNo}, 'DRAFT', #{createdBy}, #{updatedBy}
)
""")
int insertCompany(CompanyRecord company);
@Select("SELECT " + COMPANY_COLUMNS + " FROM md_company company WHERE company.public_id = #{publicId}")
CompanyRecord findCompany(String publicId);
@Select("""
<script>
SELECT
""" + COMPANY_COLUMNS + """
FROM md_company company
WHERE EXISTS (
SELECT 1
FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId}
AND role.code = #{roleCode}
AND permission.code = 'masterdata:company:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to &gt;= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id))
)
<if test="keyword != null">AND (company.business_no LIKE CONCAT('%', #{keyword}, '%') OR company.name LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="status != null">AND company.status = #{status}</if>
ORDER BY company.updated_at DESC, company.public_id ASC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
@org.apache.ibatis.annotations.Results(id = "companyColumns", value = {
@org.apache.ibatis.annotations.Result(column = "id", property = "id"),
@org.apache.ibatis.annotations.Result(column = "public_id", property = "publicId"),
@org.apache.ibatis.annotations.Result(column = "business_no", property = "businessNo"),
@org.apache.ibatis.annotations.Result(column = "name", property = "name"),
@org.apache.ibatis.annotations.Result(column = "normalized_tax_no", property = "normalizedTaxNo"),
@org.apache.ibatis.annotations.Result(column = "status", property = "status"),
@org.apache.ibatis.annotations.Result(column = "version", property = "version"),
@org.apache.ibatis.annotations.Result(column = "created_by", property = "createdBy"),
@org.apache.ibatis.annotations.Result(column = "updated_by", property = "updatedBy"),
@org.apache.ibatis.annotations.Result(column = "submitted_by", property = "submittedBy"),
@org.apache.ibatis.annotations.Result(column = "submitted_at", property = "submittedAt"),
@org.apache.ibatis.annotations.Result(column = "reviewed_by", property = "reviewedBy"),
@org.apache.ibatis.annotations.Result(column = "reviewed_at", property = "reviewedAt"),
@org.apache.ibatis.annotations.Result(column = "review_opinion", property = "reviewOpinion"),
@org.apache.ibatis.annotations.Result(column = "created_at", property = "createdAt"),
@org.apache.ibatis.annotations.Result(column = "updated_at", property = "updatedAt")
})
List<CompanyRecord> listCompanies(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("keyword") String keyword, @Param("status") String status,
@Param("limit") int limit, @Param("offset") int offset);
@Select("""
<script>
SELECT COUNT(*)
FROM md_company company
WHERE EXISTS (
SELECT 1
FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId}
AND role.code = #{roleCode}
AND permission.code = 'masterdata:company:view'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to &gt;= UTC_TIMESTAMP(3))
AND (scope.scope_type = 'GLOBAL'
OR (scope.scope_type = 'COMPANY' AND scope.company_public_id = company.public_id))
)
<if test="keyword != null">AND (company.business_no LIKE CONCAT('%', #{keyword}, '%') OR company.name LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="status != null">AND company.status = #{status}</if>
</script>
""")
long countCompanies(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("keyword") String keyword, @Param("status") String status);
@Update("""
UPDATE md_company
SET name = #{name}, normalized_tax_no = #{normalizedTaxNo}, updated_by = #{updatedBy},
version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int updateCompany(CompanyRecord company);
@Update("""
UPDATE md_company
SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3),
reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL,
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int submitCompany(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId);
@Update("""
UPDATE md_company
SET status = #{targetStatus}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3),
review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' AND submitted_by <> #{actorId}
""")
int reviewCompany(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId, @Param("targetStatus") String targetStatus,
@Param("opinion") String opinion);
@Update("""
UPDATE md_company
SET status = 'DISABLED', updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE'
""")
int disableCompany(@Param("id") long id, @Param("version") long version, @Param("actorId") long actorId);
@Insert("""
INSERT INTO md_counterparty (
public_id, business_no, counterparty_type, name, normalized_tax_no, contact_name,
contact_phone, status, created_by, updated_by
) VALUES (
#{publicId}, #{businessNo}, #{counterpartyType}, #{name}, #{normalizedTaxNo}, #{contactName},
#{contactPhone}, 'DRAFT', #{createdBy}, #{updatedBy}
)
""")
int insertCounterparty(CounterpartyRecord counterparty);
@Select("SELECT " + COUNTERPARTY_COLUMNS + " FROM md_counterparty counterparty WHERE counterparty.public_id = #{publicId}")
CounterpartyRecord findCounterparty(String publicId);
@Select("""
<script>
SELECT
""" + COUNTERPARTY_COLUMNS + """
FROM md_counterparty counterparty
WHERE EXISTS (
SELECT 1
FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId}
AND role.code = #{roleCode}
AND permission.code = 'masterdata:counterparty:view'
AND scope.scope_type = 'GLOBAL'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to &gt;= UTC_TIMESTAMP(3))
)
<if test="keyword != null">AND (counterparty.business_no LIKE CONCAT('%', #{keyword}, '%') OR counterparty.name LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="status != null">AND counterparty.status = #{status}</if>
<if test="type != null">AND counterparty.counterparty_type = #{type}</if>
ORDER BY counterparty.updated_at DESC, counterparty.public_id ASC
LIMIT #{limit} OFFSET #{offset}
</script>
""")
@org.apache.ibatis.annotations.Results(id = "counterpartyColumns", value = {
@org.apache.ibatis.annotations.Result(column = "id", property = "id"),
@org.apache.ibatis.annotations.Result(column = "public_id", property = "publicId"),
@org.apache.ibatis.annotations.Result(column = "business_no", property = "businessNo"),
@org.apache.ibatis.annotations.Result(column = "counterparty_type", property = "counterpartyType"),
@org.apache.ibatis.annotations.Result(column = "name", property = "name"),
@org.apache.ibatis.annotations.Result(column = "normalized_tax_no", property = "normalizedTaxNo"),
@org.apache.ibatis.annotations.Result(column = "contact_name", property = "contactName"),
@org.apache.ibatis.annotations.Result(column = "contact_phone", property = "contactPhone"),
@org.apache.ibatis.annotations.Result(column = "status", property = "status"),
@org.apache.ibatis.annotations.Result(column = "version", property = "version"),
@org.apache.ibatis.annotations.Result(column = "created_by", property = "createdBy"),
@org.apache.ibatis.annotations.Result(column = "updated_by", property = "updatedBy"),
@org.apache.ibatis.annotations.Result(column = "submitted_by", property = "submittedBy"),
@org.apache.ibatis.annotations.Result(column = "submitted_at", property = "submittedAt"),
@org.apache.ibatis.annotations.Result(column = "reviewed_by", property = "reviewedBy"),
@org.apache.ibatis.annotations.Result(column = "reviewed_at", property = "reviewedAt"),
@org.apache.ibatis.annotations.Result(column = "review_opinion", property = "reviewOpinion"),
@org.apache.ibatis.annotations.Result(column = "created_at", property = "createdAt"),
@org.apache.ibatis.annotations.Result(column = "updated_at", property = "updatedAt")
})
List<CounterpartyRecord> listCounterparties(@Param("userId") long userId,
@Param("roleCode") String roleCode,
@Param("keyword") String keyword,
@Param("status") String status,
@Param("type") String type,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("""
<script>
SELECT COUNT(*)
FROM md_counterparty counterparty
WHERE EXISTS (
SELECT 1
FROM iam_scope scope
JOIN iam_role role ON role.id = scope.role_id
JOIN iam_permission permission ON permission.id = scope.permission_id
WHERE scope.user_id = #{userId}
AND role.code = #{roleCode}
AND permission.code = 'masterdata:counterparty:view'
AND scope.scope_type = 'GLOBAL'
AND scope.status = 'ACTIVE'
AND scope.valid_from &lt;= UTC_TIMESTAMP(3)
AND (scope.valid_to IS NULL OR scope.valid_to &gt;= UTC_TIMESTAMP(3))
)
<if test="keyword != null">AND (counterparty.business_no LIKE CONCAT('%', #{keyword}, '%') OR counterparty.name LIKE CONCAT('%', #{keyword}, '%'))</if>
<if test="status != null">AND counterparty.status = #{status}</if>
<if test="type != null">AND counterparty.counterparty_type = #{type}</if>
</script>
""")
long countCounterparties(@Param("userId") long userId, @Param("roleCode") String roleCode,
@Param("keyword") String keyword, @Param("status") String status,
@Param("type") String type);
@Update("""
UPDATE md_counterparty
SET counterparty_type = #{counterpartyType}, name = #{name},
normalized_tax_no = #{normalizedTaxNo}, contact_name = #{contactName},
contact_phone = #{contactPhone}, updated_by = #{updatedBy}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int updateCounterparty(CounterpartyRecord counterparty);
@Update("""
UPDATE md_counterparty
SET status = 'REVIEWING', submitted_by = #{actorId}, submitted_at = UTC_TIMESTAMP(3),
reviewed_by = NULL, reviewed_at = NULL, review_opinion = NULL,
updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status IN ('DRAFT', 'RETURNED')
""")
int submitCounterparty(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId);
@Update("""
UPDATE md_counterparty
SET status = #{targetStatus}, reviewed_by = #{actorId}, reviewed_at = UTC_TIMESTAMP(3),
review_opinion = #{opinion}, updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'REVIEWING' AND submitted_by <> #{actorId}
""")
int reviewCounterparty(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId, @Param("targetStatus") String targetStatus,
@Param("opinion") String opinion);
@Update("""
UPDATE md_counterparty
SET status = 'DISABLED', updated_by = #{actorId}, version = version + 1
WHERE id = #{id} AND version = #{version} AND status = 'ACTIVE'
""")
int disableCounterparty(@Param("id") long id, @Param("version") long version,
@Param("actorId") long actorId);
}
@@ -0,0 +1,36 @@
package com.kaidi.finance.masterdata.infrastructure;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@org.apache.ibatis.annotations.Mapper
public interface MasterDataVersionMapper {
String FILTER = """
object_type = #{objectType}
AND object_public_id = #{objectPublicId}
AND result_code = 'SUCCESS'
AND action_code LIKE CONCAT(#{actionPrefix}, '%')
""";
@Select("SELECT public_id AS audit_id, action_code, username AS actor_name, "
+ "CAST(before_json AS CHAR) AS before_json, CAST(after_json AS CHAR) AS after_json, "
+ "created_at AS changed_at FROM audit_log WHERE " + FILTER
+ " ORDER BY created_at DESC, id DESC LIMIT #{limit} OFFSET #{offset}")
List<VersionRow> list(@Param("objectType") String objectType,
@Param("objectPublicId") String objectPublicId,
@Param("actionPrefix") String actionPrefix,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("SELECT COUNT(*) FROM audit_log WHERE " + FILTER)
long count(@Param("objectType") String objectType,
@Param("objectPublicId") String objectPublicId,
@Param("actionPrefix") String actionPrefix);
record VersionRow(String auditId, String actionCode, String actorName,
String beforeJson, String afterJson, LocalDateTime changedAt) {
}
}
@@ -0,0 +1,55 @@
package com.kaidi.finance.operations.api;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
public final class ContractCostContracts {
private ContractCostContracts() {
}
public record ContractChangeCreateRequest(
@NotBlank @Size(max = 64) String changeNo,
@NotNull @Digits(integer = 18, fraction = 2) BigDecimal changeAmount,
@NotNull LocalDate effectiveDate,
@NotBlank @Size(max = 1000) String reason,
@NotNull @PositiveOrZero Long contractVersion
) {
}
public record ContractSettlementCreateRequest(
@NotBlank @Size(max = 64) String settlementNo,
@NotNull @DecimalMin("0.00") @Digits(integer = 18, fraction = 2) BigDecimal settlementAmount,
@NotNull LocalDate settlementDate,
@NotBlank @Size(min = 26, max = 26) String sourceDocumentId,
@NotNull @PositiveOrZero Long contractVersion
) {
}
public record PayableCreateRequest(
@NotBlank @Size(max = 64) String businessNo,
@NotBlank @Size(max = 26) String costCategoryId,
@NotBlank @Size(min = 26, max = 26) String sourceDocumentId,
@NotNull LocalDate businessDate,
@NotNull @DecimalMin("0.01") @Digits(integer = 18, fraction = 2) BigDecimal amount,
@NotNull @PositiveOrZero Long contractVersion
) {
}
public record VersionCommandRequest(
@NotNull @PositiveOrZero Long version
) {
}
public record VersionReasonCommandRequest(
@NotNull @PositiveOrZero Long version,
@NotBlank @Size(max = 1000) String reason
) {
}
}
@@ -0,0 +1,173 @@
package com.kaidi.finance.operations.api;
import com.kaidi.finance.operations.api.ContractCostContracts.ContractChangeCreateRequest;
import com.kaidi.finance.operations.api.ContractCostContracts.ContractSettlementCreateRequest;
import com.kaidi.finance.operations.api.ContractCostContracts.PayableCreateRequest;
import com.kaidi.finance.operations.api.ContractCostContracts.VersionCommandRequest;
import com.kaidi.finance.operations.api.ContractCostContracts.VersionReasonCommandRequest;
import com.kaidi.finance.operations.api.ContractCostViews.ContractLedgerDetailView;
import com.kaidi.finance.operations.api.ContractCostViews.ContractLedgerPageView;
import com.kaidi.finance.operations.api.ContractCostViews.FilterOptionsView;
import com.kaidi.finance.operations.application.ContractCostApplicationService;
import com.kaidi.finance.operations.application.ContractCostApplicationService.LedgerPage;
import com.kaidi.finance.shared.api.ApiResponse;
import com.kaidi.finance.shared.idempotency.IdempotencyApplicationService;
import com.fasterxml.jackson.core.type.TypeReference;
import java.time.LocalDate;
import java.util.function.Supplier;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/contract-costs")
public class ContractCostController {
private final ContractCostApplicationService service;
private final IdempotencyApplicationService idempotencyService;
public ContractCostController(ContractCostApplicationService service,
IdempotencyApplicationService idempotencyService) {
this.service = service;
this.idempotencyService = idempotencyService;
}
@GetMapping("/contracts")
@PreAuthorize("@authorizationService.hasPermission('contractcost:ledger:view')")
public ApiResponse<ContractLedgerPageView> list(
@RequestParam(required = false) String companyId,
@RequestParam(required = false) String projectId,
@RequestParam(required = false) String counterpartyId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(required = false) String costCategoryId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateFrom,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateTo,
@RequestParam(defaultValue = "updatedAt,desc") String sort,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
LedgerPage result = service.list(companyId, projectId, counterpartyId, keyword, status,
costCategoryId, businessDateFrom, businessDateTo, sort, page, size);
return ApiResponse.ok(new ContractLedgerPageView(result.page().items(), result.summary()),
result.page().meta());
}
@GetMapping("/contracts/{publicId}")
@PreAuthorize("@authorizationService.hasPermission('contractcost:ledger:view')")
public ApiResponse<ContractLedgerDetailView> detail(@PathVariable String publicId) {
return ApiResponse.ok(service.detail(publicId));
}
@GetMapping("/references")
@PreAuthorize("@authorizationService.hasPermission('contractcost:ledger:view')")
public ApiResponse<FilterOptionsView> references() {
return ApiResponse.ok(service.references());
}
@PostMapping("/contracts/{contractId}/changes")
@PreAuthorize("@authorizationService.hasPermission('contractcost:contract:maintain')")
public ApiResponse<ContractLedgerDetailView> createChange(
@PathVariable String contractId, @Valid @RequestBody ContractChangeCreateRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, () -> service.createChange(contractId, request)));
}
@PostMapping("/changes/{changeId}/void")
@PreAuthorize("@authorizationService.hasPermission('contractcost:contract:maintain')")
public ApiResponse<ContractLedgerDetailView> voidChange(
@PathVariable String changeId, @Valid @RequestBody VersionReasonCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request,
() -> service.voidChange(changeId, request.version(), request.reason())));
}
@PostMapping("/contracts/{contractId}/settlements")
@PreAuthorize("@authorizationService.hasPermission('contractcost:contract:maintain')")
public ApiResponse<ContractLedgerDetailView> createSettlement(
@PathVariable String contractId, @Valid @RequestBody ContractSettlementCreateRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request,
() -> service.createSettlement(contractId, request)));
}
@PostMapping("/settlements/{settlementId}/void")
@PreAuthorize("@authorizationService.hasPermission('contractcost:contract:maintain')")
public ApiResponse<ContractLedgerDetailView> voidSettlement(
@PathVariable String settlementId, @Valid @RequestBody VersionReasonCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request,
() -> service.voidSettlement(settlementId, request.version(), request.reason())));
}
@PostMapping("/contracts/{contractId}/payables")
@PreAuthorize("@authorizationService.hasPermission('contractcost:payable:create')")
public ApiResponse<ContractLedgerDetailView> createPayable(
@PathVariable String contractId, @Valid @RequestBody PayableCreateRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request, () -> service.createPayable(contractId, request)));
}
@PostMapping("/payables/{payableId}/confirm")
@PreAuthorize("@authorizationService.hasPermission('contractcost:payable:confirm')")
public ApiResponse<ContractLedgerDetailView> confirmPayable(
@PathVariable String payableId, @Valid @RequestBody VersionCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request,
() -> service.confirmPayable(payableId, request.version())));
}
@PostMapping("/payables/{payableId}/void")
@PreAuthorize("@authorizationService.hasPermission('contractcost:payable:confirm')")
public ApiResponse<ContractLedgerDetailView> voidPayable(
@PathVariable String payableId, @Valid @RequestBody VersionReasonCommandRequest request,
@RequestHeader(value = "Idempotency-Key", required = false) String key,
HttpServletRequest httpRequest) {
return ApiResponse.ok(command(key, httpRequest, request,
() -> service.voidPayable(payableId, request.version(), request.reason())));
}
@GetMapping(value = "/contracts/export", produces = "text/csv")
@PreAuthorize("@authorizationService.hasPermission('contractcost:ledger:export')")
public ResponseEntity<byte[]> export(
@RequestParam(required = false) String companyId,
@RequestParam(required = false) String projectId,
@RequestParam(required = false) String counterpartyId,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(required = false) String costCategoryId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateFrom,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate businessDateTo,
@RequestParam(defaultValue = "updatedAt,desc") String sort) {
byte[] data = service.export(companyId, projectId, counterpartyId, keyword, status,
costCategoryId, businessDateFrom, businessDateTo, sort);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(new MediaType("text", "csv", java.nio.charset.StandardCharsets.UTF_8));
headers.setContentDisposition(ContentDisposition.attachment().filename("contract-cost-ledger.csv").build());
return ResponseEntity.ok().headers(headers).body(data);
}
private ContractLedgerDetailView command(String key, HttpServletRequest request, Object body,
Supplier<ContractLedgerDetailView> action) {
return idempotencyService.execute(key, request.getMethod(), request.getRequestURI(), body,
new TypeReference<ContractLedgerDetailView>() { }, action);
}
}
@@ -0,0 +1,101 @@
package com.kaidi.finance.operations.api;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class ContractCostViews {
private ContractCostViews() {
}
public record ReferenceView(String publicId, String code, String name) {
}
public record FilterOptionsView(List<ReferenceView> companies,
List<ReferenceView> projects,
List<ReferenceView> counterparties,
List<ReferenceView> costCategories) {
}
public record ContractLedgerItemView(
String publicId,
String businessNo,
String name,
ReferenceView company,
ReferenceView project,
ReferenceView counterparty,
String originalAmount,
String approvedChangeAmount,
String settlementAmount,
String invoicedAmount,
String pendingPayableAmount,
String payableAmount,
String paidAmount,
String availableAmount,
String currency,
String status,
long version,
LocalDateTime updatedAt,
List<String> allowedActions
) {
}
public record ContractLedgerSummaryView(
long contractCount,
String originalAmount,
String approvedChangeAmount,
String settlementAmount,
String invoicedAmount,
String pendingPayableAmount,
String payableAmount,
String paidAmount,
String availableAmount
) {
}
public record ContractLedgerPageView(List<ContractLedgerItemView> items,
ContractLedgerSummaryView summary) {
}
public record PayableLineView(String publicId, String businessNo, LocalDate businessDate,
String sourceDocumentId, String sourceVersionId,
ReferenceView costCategory, String amount, String invoicedAmount,
String paidAmount, String unpaidAmount, String status,
long version, LocalDateTime updatedAt,
List<String> allowedActions) {
}
public record ContractChangeLineView(String publicId, String changeNo, String changeAmount,
LocalDate effectiveDate, String reason, String status,
long version, String createdByName, LocalDateTime createdAt,
List<String> allowedActions) {
}
public record ContractSettlementLineView(String publicId, String settlementNo,
String settlementAmount, LocalDate settlementDate,
String sourceDocumentId, String sourceVersionId,
String status, long version,
String createdByName, LocalDateTime createdAt,
List<String> allowedActions) {
}
public record InvoiceLineView(String publicId, String businessNo, LocalDate requestedDate,
String invoiceType, String amount, String taxRate,
String invoiceNo, LocalDate issuedDate, String status,
LocalDateTime updatedAt) {
}
public record PaymentLineView(String publicId, String businessNo, LocalDate requestedDate,
String requestedAmount, String approvedAmount, String purpose,
String status, LocalDateTime updatedAt) {
}
public record ContractLedgerDetailView(ContractLedgerItemView contract,
List<ContractChangeLineView> changes,
List<ContractSettlementLineView> settlements,
List<PayableLineView> payables,
List<InvoiceLineView> invoices,
List<PaymentLineView> payments) {
}
}
@@ -0,0 +1,103 @@
package com.kaidi.finance.operations.api;
import com.kaidi.finance.operations.application.FinanceOperationsApplicationService;
import com.kaidi.finance.shared.api.ApiResponse;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.api.PageResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1")
public class FinanceOperationsController {
private final FinanceOperationsApplicationService service;
private static final Set<String> LIST_QUERY_PARAMS = Set.of("keyword", "status", "sort", "page", "size");
public FinanceOperationsController(FinanceOperationsApplicationService service) {
this.service = service;
}
@GetMapping("/contracts")
@Operation(operationId = "listFinanceContracts", summary = "查询合同运营台账")
@Parameter(name = "sort", in = ParameterIn.QUERY,
description = "可重复;格式 field,asc|desc",
array = @ArraySchema(schema = @Schema(type = "string",
pattern = "^(updatedAt|businessNo|name|status|contractAmount|settlementAmount),(asc|desc)$")))
public ApiResponse<List<FinanceOperationsViews.ContractListView>> contracts(
@RequestParam(required = false) String keyword,
@Parameter(schema = @Schema(allowableValues = {
"DRAFT", "REVIEWING", "RETURNED", "ACTIVE", "SETTLED", "DISABLED", "VOID"
}))
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@Parameter(hidden = true)
@RequestParam MultiValueMap<String, String> query) {
validateQuery(query);
return page(service.listContracts(keyword, status, query.get("sort"), page, size));
}
@GetMapping("/costs")
@Operation(operationId = "listFinanceCosts", summary = "查询成本运营台账")
@Parameter(name = "sort", in = ParameterIn.QUERY,
description = "可重复;格式 field,asc|desc",
array = @ArraySchema(schema = @Schema(type = "string",
pattern = "^(updatedAt|businessNo|businessDate|amount|status|projectName|counterpartyName),(asc|desc)$")))
public ApiResponse<List<FinanceOperationsViews.PayableListView>> costs(
@RequestParam(required = false) String keyword,
@Parameter(schema = @Schema(allowableValues = {"PENDING", "CONFIRMED", "PART_PAID", "PAID", "VOID"}))
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@Parameter(hidden = true)
@RequestParam MultiValueMap<String, String> query) {
validateQuery(query);
return page(service.listCosts(keyword, status, query.get("sort"), page, size));
}
@GetMapping("/payables")
@Operation(operationId = "listFinancePayables", summary = "查询应付运营台账")
@Parameter(name = "sort", in = ParameterIn.QUERY,
description = "可重复;格式 field,asc|desc",
array = @ArraySchema(schema = @Schema(type = "string",
pattern = "^(updatedAt|businessNo|businessDate|amount|status|projectName|counterpartyName),(asc|desc)$")))
public ApiResponse<List<FinanceOperationsViews.PayableListView>> payables(
@RequestParam(required = false) String keyword,
@Parameter(schema = @Schema(allowableValues = {"PENDING", "CONFIRMED", "PART_PAID", "PAID", "VOID"}))
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@Parameter(hidden = true)
@RequestParam MultiValueMap<String, String> query) {
validateQuery(query);
return page(service.listPayables(keyword, status, query.get("sort"), page, size));
}
private static void validateQuery(Map<String, ?> query) {
for (String name : query.keySet()) {
if (!LIST_QUERY_PARAMS.contains(name)) {
throw new BusinessException(HttpStatus.BAD_REQUEST, ErrorCode.QUERY_PARAMETER_UNKNOWN,
"不支持的查询参数: " + name);
}
}
}
private static <T> ApiResponse<List<T>> page(PageResult<T> result) {
return ApiResponse.ok(result.items(), result.meta());
}
}
@@ -0,0 +1,45 @@
package com.kaidi.finance.operations.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.time.LocalDate;
/** Stable read contracts for the compatibility finance list endpoints. */
public final class FinanceOperationsViews {
private FinanceOperationsViews() {
}
public record ContractListView(
@JsonProperty("public_id") String publicId,
@JsonProperty("business_no") String businessNo,
String name,
@JsonProperty("contract_amount") String contractAmount,
@JsonProperty("change_amount") String changeAmount,
@JsonProperty("settlement_amount") String settlementAmount,
String currency,
String status,
@JsonProperty("project_business_no") String projectBusinessNo,
@JsonProperty("project_name") String projectName,
@JsonProperty("company_name") String companyName,
@JsonProperty("counterparty_name") String counterpartyName,
@JsonProperty("updated_at") Instant updatedAt
) {
}
public record PayableListView(
@JsonProperty("public_id") String publicId,
@JsonProperty("business_no") String businessNo,
@JsonProperty("business_date") LocalDate businessDate,
String amount,
@JsonProperty("invoiced_amount") String invoicedAmount,
@JsonProperty("paid_amount") String paidAmount,
String status,
@JsonProperty("project_business_no") String projectBusinessNo,
@JsonProperty("project_name") String projectName,
@JsonProperty("counterparty_name") String counterpartyName,
@JsonProperty("cost_category_name") String costCategoryName,
@JsonProperty("updated_at") Instant updatedAt
) {
}
}
@@ -0,0 +1,245 @@
package com.kaidi.finance.operations.application;
import com.kaidi.finance.operations.infrastructure.ContractCostMapper;
import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ApprovedPayableCostCategory;
import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ApprovedPayableReferences;
import com.kaidi.finance.operations.infrastructure.ContractCostMapper.PayableProjection;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.id.UlidGenerator;
import java.math.BigDecimal;
import java.text.Normalizer;
import java.time.LocalDate;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ApprovedPayableProjectionService {
private final ContractCostMapper mapper;
private final UlidGenerator ulidGenerator;
private final AuditService auditService;
public ApprovedPayableProjectionService(
ContractCostMapper mapper,
UlidGenerator ulidGenerator,
AuditService auditService
) {
this.mapper = mapper;
this.ulidGenerator = ulidGenerator;
this.auditService = auditService;
}
@Transactional(propagation = Propagation.MANDATORY)
public void project(ApprovedPayableCommand command) {
validate(command);
PayableProjection existing = mapper.findPayableProjectionBySourceDocument(command.sourceDocumentId());
if (existing != null) {
if (existing.sourceVersionId() != command.sourceVersionId()) {
throw invalidState("来源单据已有其他版本生成的应付记录");
}
return;
}
if (command.createdBy() == command.confirmedBy()) {
throw new BusinessException(
HttpStatus.UNPROCESSABLE_ENTITY,
ErrorCode.SOD_VIOLATION,
"应付来源申请人与财务终审人必须为不同人员"
);
}
ApprovedPayableReferences references = mapper.lockApprovedPayableReferences(command.contractPublicId());
validateReferences(command, references);
ApprovedPayableCostCategory category = mapper.findApprovedPayableCostCategory(
command.costCategoryPublicId()
);
validateCategory(command, category);
BigDecimal contractLimit = references.settlementAmount() == null
? references.originalAmount().add(references.approvedChangeAmount())
: references.settlementAmount();
if (references.payableAmount().add(command.amount()).compareTo(contractLimit) > 0) {
throw new BusinessException(
HttpStatus.UNPROCESSABLE_ENTITY,
ErrorCode.CONTRACT_LIMIT_EXCEEDED,
"OA-04 应付终审后将超过当前合同或结算额度"
);
}
String payablePublicId = ulidGenerator.next();
try {
if (mapper.insertProjectedPayable(
payablePublicId,
"PBL-" + payablePublicId,
command.companyId(),
command.projectId(),
references.contractId(),
references.counterpartyId(),
command.sourceDocumentId(),
command.sourceVersionId(),
category.id(),
command.businessDate(),
command.amount(),
command.invoicedAmount(),
command.createdBy(),
command.confirmedBy()
) != 1) {
throw conflict();
}
} catch (DuplicateKeyException exception) {
throw new BusinessException(
HttpStatus.CONFLICT,
ErrorCode.DUPLICATE_SOURCE,
"该 OA-04 来源单据已生成应付记录"
);
}
if (mapper.touchContract(references.contractId(), references.contractVersion(), command.confirmedBy()) != 1) {
throw conflict();
}
if (mapper.insertPayableAccountingEvent(
ulidGenerator.next(),
"EVT-" + payablePublicId,
payablePublicId,
"PAYABLE-" + payablePublicId,
1,
command.companyId(),
command.projectId(),
references.counterpartyId(),
command.businessDate(),
command.amount(),
references.currency(),
command.confirmedBy()
) != 1 || !"PENDING".equals(mapper.lockPayableAccountingEventStatus(payablePublicId))) {
throw conflict();
}
auditService.recordScoped(
references.companyPublicId(),
references.projectPublicId(),
"SOURCE_OA04_PAYABLE_PROJECT",
"PAYABLE",
payablePublicId,
"SUCCESS",
command.opinion(),
null,
Map.of(
"sourceDocumentId", command.sourceDocumentPublicId(),
"status", "CONFIRMED",
"contractId", references.contractPublicId(),
"supplierId", references.counterpartyPublicId(),
"costCategoryId", category.publicId(),
"amount", command.amount().toPlainString(),
"invoicedAmount", command.invoicedAmount().toPlainString(),
"currency", references.currency()
)
);
}
private void validate(ApprovedPayableCommand command) {
if (command == null || command.companyId() <= 0 || command.projectId() <= 0
|| command.sourceDocumentId() <= 0 || command.sourceVersionId() <= 0
|| command.createdBy() <= 0 || command.confirmedBy() <= 0
|| blank(command.sourceDocumentPublicId()) || blank(command.contractPublicId())
|| blank(command.supplierPublicId()) || blank(command.costCategoryPublicId())
|| blank(command.contractNo()) || blank(command.supplierName()) || blank(command.costType())
|| command.businessDate() == null || invalidMoney(command.amount(), true)
|| invalidMoney(command.invoicedAmount(), false)) {
throw validation("payableAmount", "应付投影参数无效");
}
}
private void validateReferences(ApprovedPayableCommand command, ApprovedPayableReferences references) {
if (references == null || !"ACTIVE".equals(references.contractStatus())
|| !"ACTIVE".equals(references.companyStatus()) || !"ACTIVE".equals(references.projectStatus())
|| references.companyId() != command.companyId() || references.projectId() != command.projectId()) {
throw validation("contractId", "公司、项目或合同不存在、未生效,或归属关系不一致");
}
if (!Set.of("SUPPLIER", "BOTH").contains(references.counterpartyType())
|| !"ACTIVE".equals(references.counterpartyStatus())
|| !references.counterpartyPublicId().equals(command.supplierPublicId())) {
throw validation("supplierId", "所选供应商未生效、类型不符或不属于该合同");
}
if (!normalized(references.contractBusinessNo()).equals(normalized(command.contractNo()))) {
throw validation("costContractNo", "成本合同编号与所选生效合同不一致");
}
if (!normalized(references.counterpartyName()).equals(normalized(command.supplierName()))) {
throw validation("supplierName", "供应商名称与所选生效供应商不一致");
}
}
private void validateCategory(ApprovedPayableCommand command, ApprovedPayableCostCategory category) {
if (category == null || !"ACTIVE".equals(category.status())) {
throw validation("costCategoryId", "成本分类不存在或未生效");
}
if (!normalizedCostType(category.name()).equals(normalizedCostType(command.costType()))) {
throw validation("costType", "费用成本类型与所选成本分类不一致");
}
}
private boolean invalidMoney(BigDecimal value, boolean positive) {
return value == null || value.scale() != 2 || value.precision() > 20
|| (positive ? value.signum() <= 0 : value.signum() < 0);
}
private String normalizedCostType(String value) {
String normalized = normalized(value);
return normalized.endsWith("费") ? normalized.substring(0, normalized.length() - 1) : normalized;
}
private String normalized(String value) {
return Normalizer.normalize(value, Normalizer.Form.NFKC).strip().toUpperCase(Locale.ROOT);
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
private BusinessException validation(String field, String message) {
return new BusinessException(
HttpStatus.UNPROCESSABLE_ENTITY,
ErrorCode.VALIDATION_FAILED,
"审批来源不能生成应付记录",
Map.of(field, message)
);
}
private BusinessException invalidState(String message) {
return new BusinessException(HttpStatus.CONFLICT, ErrorCode.INVALID_STATE_TRANSITION, message);
}
private BusinessException conflict() {
return new BusinessException(
HttpStatus.CONFLICT,
ErrorCode.CONCURRENT_MODIFICATION,
"应付投影并发冲突,请重试"
);
}
public record ApprovedPayableCommand(
long companyId,
long projectId,
long sourceDocumentId,
String sourceDocumentPublicId,
long sourceVersionId,
String contractPublicId,
String supplierPublicId,
String costCategoryPublicId,
String contractNo,
String supplierName,
String costType,
LocalDate businessDate,
BigDecimal amount,
BigDecimal invoicedAmount,
long createdBy,
long confirmedBy,
String opinion
) {
}
}
@@ -0,0 +1,217 @@
package com.kaidi.finance.operations.application;
import com.kaidi.finance.operations.infrastructure.ContractCostMapper;
import com.kaidi.finance.operations.infrastructure.ContractCostMapper.ApprovedSettlementReferences;
import com.kaidi.finance.operations.infrastructure.ContractCostMapper.SettlementProjection;
import com.kaidi.finance.shared.api.BusinessException;
import com.kaidi.finance.shared.api.ErrorCode;
import com.kaidi.finance.shared.audit.AuditService;
import com.kaidi.finance.shared.id.UlidGenerator;
import java.math.BigDecimal;
import java.text.Normalizer;
import java.time.LocalDate;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ApprovedSettlementProjectionService {
private final ContractCostMapper mapper;
private final UlidGenerator ulidGenerator;
private final AuditService auditService;
public ApprovedSettlementProjectionService(
ContractCostMapper mapper,
UlidGenerator ulidGenerator,
AuditService auditService
) {
this.mapper = mapper;
this.ulidGenerator = ulidGenerator;
this.auditService = auditService;
}
@Transactional(propagation = Propagation.MANDATORY)
public void project(ApprovedSettlementCommand command) {
validate(command);
if (existingProjection(command)) {
return;
}
ApprovedSettlementReferences references = mapper.lockApprovedSettlementReferences(
command.companyId(), command.contractNo()
);
validateReferences(command, references);
if (command.settlementAmount().compareTo(references.payableAmount()) < 0) {
throw contractLimitExceeded("OA-11 结算金额不能低于现有有效应付金额");
}
String settlementPublicId = ulidGenerator.next();
try {
if (mapper.insertContractSettlement(
settlementPublicId,
references.contractId(),
command.settlementNo(),
command.settlementAmount(),
command.settlementDate(),
command.sourceDocumentId(),
command.sourceVersionId(),
command.confirmedBy()
) != 1) {
throw conflict();
}
} catch (DuplicateKeyException exception) {
if (existingProjection(command)) {
return;
}
throw new BusinessException(
HttpStatus.CONFLICT,
ErrorCode.DUPLICATE_RESOURCE,
"OA-11 合同结算编号已存在"
);
}
if (mapper.refreshSettlementAmount(
references.contractId(), references.contractVersion(), command.confirmedBy()
) != 1) {
throw conflict();
}
auditService.recordScoped(
references.companyPublicId(),
references.projectPublicId(),
"SOURCE_OA11_SETTLEMENT_PROJECT",
"CONTRACT_SETTLEMENT",
settlementPublicId,
"SUCCESS",
command.opinion(),
null,
Map.of(
"sourceDocumentId", command.sourceDocumentPublicId(),
"sourceVersionId", command.sourceVersionId(),
"contractId", references.contractPublicId(),
"settlementNo", command.settlementNo(),
"settlementAmount", command.settlementAmount().toPlainString(),
"settlementDate", command.settlementDate().toString(),
"status", "CONFIRMED"
)
);
}
private boolean existingProjection(ApprovedSettlementCommand command) {
SettlementProjection existing = mapper.findSettlementProjectionBySourceDocument(
command.sourceDocumentId()
);
if (existing == null) {
return false;
}
if (existing.sourceVersionId() == null || existing.sourceVersionId() != command.sourceVersionId()) {
throw new BusinessException(
HttpStatus.CONFLICT,
ErrorCode.INVALID_STATE_TRANSITION,
"来源单据已有其他版本生成的合同结算"
);
}
return true;
}
private void validate(ApprovedSettlementCommand command) {
if (command == null || command.companyId() <= 0 || command.projectId() <= 0
|| command.sourceDocumentId() <= 0 || command.sourceVersionId() <= 0
|| command.confirmedBy() <= 0 || blank(command.sourceDocumentPublicId())
|| blank(command.contractNo()) || blank(command.supplierName())
|| blank(command.settlementNo()) || command.settlementDate() == null
|| invalidMoney(command.contractAmount()) || invalidMoney(command.settlementAmount())
|| invalidMoney(command.invoicedAmount()) || invalidMoney(command.paidAmount())) {
throw validation("amounts", "合同结算投影参数无效");
}
}
private void validateReferences(
ApprovedSettlementCommand command,
ApprovedSettlementReferences references
) {
if (references == null || !"ACTIVE".equals(references.contractStatus())
|| !"ACTIVE".equals(references.companyStatus()) || !"ACTIVE".equals(references.projectStatus())
|| references.companyId() != command.companyId() || references.projectId() != command.projectId()) {
throw validation("costContractNo", "公司、项目或合同不存在、未生效,或归属关系不一致");
}
if (!Set.of("SUPPLIER", "BOTH").contains(references.counterpartyType())
|| !"ACTIVE".equals(references.counterpartyStatus())
|| !normalized(references.counterpartyName()).equals(normalized(command.supplierName()))) {
throw validation("supplierName", "供应商不存在、未生效、类型不符或与合同不一致");
}
BigDecimal contractAmount = references.originalAmount().add(references.approvedChangeAmount());
if (contractAmount.compareTo(command.contractAmount()) != 0) {
throw validation("amounts", "合同金额与当前合同及已批准变更金额不一致");
}
if (references.invoicedAmount().compareTo(command.invoicedAmount()) != 0) {
throw validation("amounts", "累计已开票金额与系统记录不一致");
}
if (references.paidAmount().compareTo(command.paidAmount()) != 0) {
throw validation("amounts", "累计已付款金额与系统记录不一致");
}
}
private boolean invalidMoney(BigDecimal value) {
return value == null || value.scale() != 2 || value.precision() > 20 || value.signum() < 0;
}
private String normalized(String value) {
return Normalizer.normalize(value, Normalizer.Form.NFKC).strip().toUpperCase(Locale.ROOT);
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
private BusinessException validation(String field, String message) {
return new BusinessException(
HttpStatus.UNPROCESSABLE_ENTITY,
ErrorCode.VALIDATION_FAILED,
"审批来源不能生成合同结算",
Map.of(field, message)
);
}
private BusinessException contractLimitExceeded(String message) {
return new BusinessException(
HttpStatus.UNPROCESSABLE_ENTITY,
ErrorCode.CONTRACT_LIMIT_EXCEEDED,
message
);
}
private BusinessException conflict() {
return new BusinessException(
HttpStatus.CONFLICT,
ErrorCode.CONCURRENT_MODIFICATION,
"合同结算投影并发冲突,请重试"
);
}
public record ApprovedSettlementCommand(
long companyId,
long projectId,
long sourceDocumentId,
String sourceDocumentPublicId,
long sourceVersionId,
String contractNo,
String supplierName,
BigDecimal contractAmount,
BigDecimal settlementAmount,
BigDecimal invoicedAmount,
BigDecimal paidAmount,
String settlementNo,
LocalDate settlementDate,
long confirmedBy,
String opinion
) {
}
}

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