This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
backend/mvnw.cmd text eol=crlf whitespace=-trailing-space
|
||||
frontend/CHANGELOG.md text whitespace=-trailing-space
|
||||
@@ -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
@@ -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
|
||||
@@ -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、类型检查、迁移或接口结果;
|
||||
- 定位到具体错误、文件、行号或根因;
|
||||
- 完成文件修改并得到针对性验证;
|
||||
- 获得子代理可核验的结论或明确的阻断条件。
|
||||
|
||||
状态更新只报告上述有效进展,不报告占位、空轮询或没有信息增量的动作。
|
||||
@@ -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`;把这两个值写入
|
||||
受控部署记录,再替换上述一键安装命令中的占位符。
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+295
@@ -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 "$@"
|
||||
Vendored
+189
@@ -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
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+1116
File diff suppressed because it is too large
Load Diff
+757
@@ -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 <= 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 <= 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 <= 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 <= 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 <> '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 <> '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 <> '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 <> '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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+1261
File diff suppressed because it is too large
Load Diff
+234
@@ -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) {
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
}
|
||||
}
|
||||
+302
@@ -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 <= #{scopeAsOf}
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= #{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 >= #{occurredFrom}
|
||||
AND audit.created_at < #{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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+311
@@ -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 >= #{dateFrom}</if>
|
||||
<if test="dateToExclusive != null">AND ledger.occurred_at < #{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 >= #{dateFrom}</if>
|
||||
<if test="dateToExclusive != null">AND ledger.occurred_at < #{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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+936
@@ -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) {
|
||||
}
|
||||
}
|
||||
+300
@@ -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) {
|
||||
}
|
||||
+217
@@ -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
|
||||
) {
|
||||
}
|
||||
+10
@@ -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
|
||||
) {
|
||||
}
|
||||
+10
@@ -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()));
|
||||
}
|
||||
}
|
||||
+11
@@ -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) {
|
||||
}
|
||||
+61
@@ -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) {
|
||||
}
|
||||
}
|
||||
+852
@@ -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) {
|
||||
}
|
||||
}
|
||||
+317
@@ -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) {
|
||||
}
|
||||
}
|
||||
+461
@@ -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) {
|
||||
}
|
||||
}
|
||||
+182
@@ -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; }
|
||||
}
|
||||
+5
@@ -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) {
|
||||
}
|
||||
+609
@@ -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 <= 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 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 <= 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 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 <= 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))
|
||||
)
|
||||
<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 <= 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))
|
||||
)
|
||||
<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 <> #{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);
|
||||
|
||||
}
|
||||
+97
@@ -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) {
|
||||
}
|
||||
}
|
||||
+279
@@ -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 <= 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))
|
||||
)
|
||||
<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 <= 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))
|
||||
)
|
||||
<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 <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= 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 <= UTC_TIMESTAMP(3)
|
||||
AND (scope.valid_to IS NULL OR scope.valid_to >= 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);
|
||||
|
||||
}
|
||||
+36
@@ -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) {
|
||||
}
|
||||
}
|
||||
+103
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+245
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+217
@@ -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
Reference in New Issue
Block a user