.dockerignore
New file @@ -0,0 +1,10 @@ # 只放行各服务 fat jar + 共享配置目录,压缩 build context(排除 .git/源码/文档) ** !jnpf-gateway/target/*.jar !jnpf-*/jnpf-*-server/target/*.jar # 补丁 #8:各业务 Dockerfile 会 COPY config/shared /config/shared, # deny-all 白名单下需逐级放行父目录(.dockerignore 语义同 .gitignore, # 父目录被排除时无法靠后续规则单独放行子路径) !config/ !config/shared/ !config/shared/** .env.example
New file @@ -0,0 +1,257 @@ # docker-compose / start-all.sh 环境变量模板 # # ══ 用法 ══ # # 任何环境(开发本机/测试服务器/交付):cp .env.example .env,按下面的【场景】改值。 # compose 自动读仓库根 .env;start-all.sh 启动时会加载全部 KEY=VALUE。.env 不入库。 # # 切客户库 = 手改 .env 的 4 个键(§1 坐标 2 个 + §2/§3 密码 2 个)。改完必须重启 # (Druid 池在启动时定死,配置不热改),并先跑 ./docker/preflight.sh 防连错库: # 容器栈:docker compose up -d --force-recreate # 宿主栈:./start-all.sh stop && ./start-all.sh # # 各客户 PG 实例坐标与真实密码总表:.env.tencent(gitignored,本文件不放任何真实 # 密码;同事需要连 CVM 时找 yangsui 单独拿对应客户段)。 # # ══ 场景速查:数据库连哪 × 服务怎么跑,两维正交、自由组合 ══ # # ── 维度一:数据库坐标(只动 §1-§3 的 4 个键)──────────────────────────── # # | 数据库 | CUSTOMER_DB_HOST/PORT | 两对 DB 密码(§2 §3) | # |-------------------|-------------------------|--------------------------| # | 本机容器 PG(默认)| cx-postgres / 5432 | 默认值即可(出厂密码) | # | 本机自装 PG | 127.0.0.1 / 自装端口 | 自装库的真实密码 | # # 业务库 jnpf_init 与流程引擎库 jnpf_flow 都由这组坐标推导,必然同源(§3 红线)。 # # Redis 同样三档(§5 的 JNPF_REDIS_HOST/PORT/PASSWORD 三键,全不设 = 本机容器 cx-redis # 零配置)。CVM 每客户一实例,端口 16431+N 与 PG 15431+N 尾数对齐: # 客户A YOUR_DB_HOST:16432 | 客户B YOUR_DB_HOST:16433(密码在 .env.tencent 对应客户段)。 # 切 CVM 客户时 PG 4 键 + Redis 3 键一起改,对照 .env.tencent 整段抄。 # # ── 维度二:服务怎么跑 ────────────────────────────────────────────────── # # | 跑法 | 要求 | # |----------------------------|-----------------------------------------------| # | 全栈容器 docker compose up | .env 原样即用,无额外要求 | # | 宿主 ./start-all.sh | /etc/hosts 需下面 5 条 cx-*;cx-postgres 坐标 | # |(基础设施仍跑容器) | 会自动翻译成宿主发布端口 127.0.0.1:5433 | # # 宿主跑法的 /etc/hosts(一次性,原理见 docs/docker-compose-guide.md §3): # 127.0.0.1 cx-infra cx-redis cx-flow-engine # # ── redis 想用宿主自装的(不跑 Redis 容器)────────────────────────────── # 同时支持宿主 ./start-all.sh 和 Docker 业务容器时,按 §5 分别设置宿主/容器坐标: # 宿主 JVM 用 127.0.0.1,容器用 cx-infra(compose 已映射到 host-gateway)。 # requirepass 必须与 §5 的 JNPF_REDIS_PASSWORD 相同。 # ⚠️ 流程引擎 cx-flow-engine 没有自装形态,任何跑法都要起它的容器。 # (RocketMQ 已于 2026-08-10 移除,事件总线改走 Redis pub/sub,见补丁 #10) # # ── 测试环境(192.168.21.151) 额外改 ───────────────────────────────────── # 数据库 = CVM 客户A那档;域名四键必设 http://192.168.21.151(§7); # COMPOSE_FILE 用 testenv 组合(§9);其余键沿用默认(是服务器自己的本机栈)。 # # ── OnlyOffice 文档在线编辑 ───────────────────────────────────────────── # §6 一节,只有 ONLYOFFICE_JWT_SECRET 必填(缺了 compose 直接报错起不来), # 其余 10 个键都有默认值 = 本机 compose 形态。交付环境务必换掉这个 secret: # 回调端点匿名可达,它是唯一的安全边界。 # ════════════════════════════════════════════════════════════════════════════ # 1. 客户坐标(唯一开关:业务库 jnpf_init 与引擎库 jnpf_flow 都由它推导,必然同源) # ════════════════════════════════════════════════════════════════════════════ # 本机 = compose 栈内的 cx-postgres。宿主 java -jar(start-all.sh)会自动把 # cx-postgres:5432 翻译成 127.0.0.1:5433(宿主发布端口),无需另配。 # 测试/CVM:改成 YOUR_DB_HOST / 15432(客户A);客户2 客户B 15433;客户N 15431+N。 CUSTOMER_DB_HOST=cx-postgres CUSTOMER_DB_PORT=5432 # 栈内 PG 开关,必须与上面这对坐标配套(cx-postgres 收在 localdb profile 内,默认不启动): # CUSTOMER_DB_HOST=cx-postgres → 保留本行,compose 才会起 cx-postgres 容器 # CUSTOMER_DB_HOST=YOUR_DB_HOST → 注释掉本行,本机不起 PG(起了也没人连,纯占内存, # 还会因 external 卷 postgres_pgdata 不存在把整栈拦下) # 两者不配套时 preflight 会直接报错,不会让你带着错配起栈。 COMPOSE_PROFILES=localdb # ════════════════════════════════════════════════════════════════════════════ # 2. 业务库凭据(须与目标 PG 实例的真实角色密码一致) # ════════════════════════════════════════════════════════════════════════════ # 默认值 = 本机容器 PG 出厂密码(install.sh 建库时写入),本机场景不用改。 # 切 CVM 时必须换成 .env.tencent 对应客户段的真实密码(沿用默认会认证失败)。 JNPF_APP_DB_USER=jnpf_app JNPF_APP_DB_PASSWORD=jnpf_app_2026 # ════════════════════════════════════════════════════════════════════════════ # 3. 流程引擎库凭据 # ════════════════════════════════════════════════════════════════════════════ # 地址由 §1 客户坐标自动推导,与业务库结构性同源。 # 🔴 绝对不要设 JNPF_FLOW_DB_URL 来指引擎库!它是整串覆盖的逃生口,会把引擎库与 # 业务库拆成两个来源(半切)——2026-08-03 事故:49 个流程模板 44 个报「找不到流程 # 模板」。见 docs/adr-multi-customer-db-isolation.md §4。 JNPF_FLOW_DB_USER=jnpf_flow_app JNPF_FLOW_DB_PASSWORD=jnpf_flow_app_2026 #JNPF_FLOW_DB_URL=jdbc:postgresql://YOUR_DB_HOST:15432/jnpf_flow # ════════════════════════════════════════════════════════════════════════════ # 4. 本机栈私有(跟「连哪个客户」无关,是这台机器自己容器栈的设置) # ════════════════════════════════════════════════════════════════════════════ # 两角色的 PG 连接数配额(供 create-app-roles.sh;防单角色占满连接槽饿死流程引擎,L029)。 # 多客户实例(max_conn=150):120/20;客户1老实例(400):350/35;交付单环境(200):150/30。 # 铁律:两者之和 < max_connections − 3。未设时脚本内置默认 150/30。 JNPF_APP_CONN_LIMIT=120 JNPF_FLOW_CONN_LIMIT=20 # PG 超级用户密码(仅运维用;只在 initdb 全新数据卷时写入,既有卷改密须 ALTER USER) JNPF_PG_PASSWORD=jnpf_pg_2026 # ════════════════════════════════════════════════════════════════════════════ # 5. Redis # ════════════════════════════════════════════════════════════════════════════ # cx-redis 的 requirepass;业务侧经 compose / start-all 透传(datasource.yaml 占位符), # 改这里即全局轮换。 JNPF_REDIS_PASSWORD=jnpf_redis_2026 # Redis 坐标(datasource.yaml 占位符;与 PG 的 CUSTOMER_DB_HOST/PORT 同构)。 # 不设 = 本机/交付形态(栈内 cx-redis:6379)。指 CVM 共享实例时解开并按客户改: # 客户A 16432 / 客户B 16433(端口 16431+N),密码同时换成 .env.tencent 对应客户段的值。 #JNPF_REDIS_HOST=YOUR_DB_HOST #JNPF_REDIS_PORT=16432 # 宿主自装 Redis、且业务可能在宿主和 Docker 两种形态运行时使用下面四行: # JNPF_REDIS_HOST/PORT 供 ./start-all.sh;JNPF_DOCKER_REDIS_HOST/PORT 仅由 compose 注入容器。 #JNPF_REDIS_HOST=127.0.0.1 #JNPF_REDIS_PORT=6379 #JNPF_DOCKER_REDIS_HOST=cx-infra #JNPF_DOCKER_REDIS_PORT=6379 # ════════════════════════════════════════════════════════════════════════════ # 6. OnlyOffice Document Server(Office 文档在线预览/编辑) # ════════════════════════════════════════════════════════════════════════════ # 消费方两侧:cx-onlyoffice 容器(DS 本体)+ cx-biz-common 的 onlyoffice 能力模块 # (经 config/shared/jnpf-biz-common.yaml 的占位符)。方案见 tasks/onlyoffice-integration-plan.md。 # 本节只有 JWT_SECRET 必填,其余全部有默认值 = 本机 compose 形态,cp 即用。 # ── 必填:DS 与后端之间的唯一信任凭据 ── # 两侧必须同值: # - cx-onlyoffice 容器:作为 JWT_SECRET 环境变量注入(docker-compose.yml) # - cx-biz-common:经 config/shared/jnpf-biz-common.yaml 的 onlyoffice.jwt-secret 占位符消费 # 后端用它签发编辑器 config,DS 用它签发保存回调;不一致的表现是编辑器一直转圈 # 或回调静默失败,且两侧日志都不明显——改这里即全局轮换,改完两个容器都要重启。 # 🔴 回调路径 /api/biz/onlyoffice/callback 已进网关白名单(平台补丁 #9),该端点 # 匿名可达,这个 secret 是它唯一的安全边界,交付环境务必换成随机值。 ONLYOFFICE_JWT_SECRET=onlyoffice_jwt_2026 # ── 四个地址键:每个都是**某一方的视角**,别混着填 ── # 容器栈(docker compose):四个全不设即可,默认值就是本机形态。 # 宿主栈(./start-all.sh):脚本已自动 export 下面 3 个 host.docker.internal 形态的值 # (DS 在容器里、网关和后端在宿主上),一般不用在 .env 里设;显式设了则以 .env 为准。 # # 键 | 视角 | 容器栈默认 | 宿主栈(start-all.sh 自动值) # ----------------------------|---------|----------------------|------------------------------ # ONLYOFFICE_DS_PUBLIC_URL | 浏览器 | http://localhost:20000 | 同左(override 发布的端口) # ONLYOFFICE_FILE_FETCH_BASE_URL | DS | http://cx-gateway:30000 | http://host.docker.internal:30000 # ONLYOFFICE_CALLBACK_BASE_URL | DS | http://cx-gateway:30000 | http://host.docker.internal:30000 # ONLYOFFICE_DS_INTERNAL_URL | 后端 | http://cx-onlyoffice | http://localhost:20000 # # 浏览器视角:加载编辑器 api.js 的地址。容器名浏览器解析不了,本机走 override 发布的 20000。 # 交付环境改为 Nginx 反代的 https://<域名>/onlyoffice(方案 §8.4,与 §7 域名四键配套改)。 #ONLYOFFICE_DS_PUBLIC_URL=http://localhost:20000 # DS 视角:DS 回源拉原始文档。/api/file/Image/** 在网关白名单内,DS 无登录态靠 securityKey 取件。 #ONLYOFFICE_FILE_FETCH_BASE_URL=http://cx-gateway:30000 # DS 视角:DS 保存回调。必须经网关——直连 30015 按 L042 会被 inner-auth 拒。 #ONLYOFFICE_CALLBACK_BASE_URL=http://cx-gateway:30000 # 后端视角:后端反向拉取 DS 产物。回调里 DS 给的 url 是它自己视角的主机名,后端不一定直连得到, # 配了本项就只保留其 path+query 换到这个主机上;留空则原样使用 DS 给的 url。 #ONLYOFFICE_DS_INTERNAL_URL=http://cx-onlyoffice # ── 编辑行为开关 ── # 编辑锁的心跳超时(分钟)。锁正常由 status 2/4 回调释放,本项是浏览器崩溃等异常场景的 # 兜底,否则文档会永久锁死。 #ONLYOFFICE_LOCK_TIMEOUT_MINUTES=30 # 编辑器里是否给下载/打印入口,默认全关。 # ⚠️ 只收界面入口,**不是安全边界**:知道 URL 的人照样能经 /api/file/Image/** 把原文件拉走。 #ONLYOFFICE_ALLOW_DOWNLOAD=false #ONLYOFFICE_ALLOW_PRINT=false # 未登记 bizModule 时是否一律只读。默认 false(放行编辑 + 一条 warn),兼容不传 bizModule 的 # 既有调用方。🔴 该登记的都登记完之后应当置 true——否则「漏登记」的表现是悄悄放行编辑, # 而不是能被人发现的报错。登记表在 config/shared/jnpf-biz-common.yaml 的 edit-permission.modules。 #ONLYOFFICE_EDIT_PERMISSION_STRICT=false # ── ELN 模板标注插件 ── # 公网基础地址(浏览器视角,DS 从这里加载插件资源)+ 发布版本(对应不可变发布目录)。 #ONLYOFFICE_PLUGIN_PUBLIC_BASE_URL=http://localhost:4173/onlyoffice-plugins #ONLYOFFICE_PLUGIN_RELEASE=0.1.0-dev # ════════════════════════════════════════════════════════════════════════════ # 7. 域名四键(L023 双消费方:须浏览器可达且容器内可达) # ════════════════════════════════════════════════════════════════════════════ # 本机不设(config/shared/system-config.yaml 内置默认即本机形态)。 # 测试环境必设为服务器实际地址,例如: #JNPF_API_DOMAIN=http://192.168.21.151 #JNPF_APP_DOMAIN=http://192.168.21.151/ #JNPF_FLOW_DOMAIN=http://cx-flow-engine:31000 #JNPF_FRONT_DOMAIN=http://192.168.21.151/ # ════════════════════════════════════════════════════════════════════════════ # 8. 文件存储与日志落盘 # ════════════════════════════════════════════════════════════════════════════ # ── 业务文件(附件/图片/导出件)── # 宿主侧文件根目录(compose 挂载用);Mac 默认 /Users/Shared,可不设。 # 业务进程读的是 JNPF_FILE_DIR(resources.yaml);容器内固定 /data/jnpf-files/。 # Win/Linux 按各自习惯改,例如 JNPF_DATA_DIR=D:/jnpf-data 或 /data/jnpf。 #JNPF_DATA_DIR=/Users/Shared #JNPF_FILE_DIR=/Users/Shared/ # ── DMS SeaweedFS(S3 兼容接口,独立于现有 MinIO 配置)── # 只有 DMS 专用上传接口会显式选择 seaweedfs-1,不会改变 default-platform。 #SEAWEEDFS_ENABLED=false #SEAWEEDFS_ACCESS_KEY=change-me #SEAWEEDFS_SECRET_KEY=change-me #SEAWEEDFS_ENDPOINT=http://127.0.0.1:8333/ #SEAWEEDFS_BUCKET_NAME=jnpf-dms #SEAWEEDFS_BASE_PATH=dms/ # ── 应用日志(2026-08-11 起已 bind mount 出容器)── # 🔴 两个变量别搞混,后缀 _HOST 的才是你要改的那个: # # 变量 | 在哪一侧 | 值 | 谁消费 # --------------------|-----------|---------------|-------------------------------- # JNPF_LOG_DIR_HOST | 宿主机 ★ | 默认 ./logs | docker-compose.yml 的 volumes 源 # JNPF_LOG_DIR | 容器内 | /data/jnpf-logs | logger.yaml 的 log.path(compose 已固定,不用改) # # docker-compose.yml 里那行挂载长这样: # - ${JNPF_LOG_DIR_HOST:-./logs}:/data/jnpf-logs # └── 宿主机目录 ────────────┘ └─ 容器内目录 ─┘ # # 各服务自动写 <宿主目录>/<服务名>/log_{error,warn,info,debug,total}.log, # 直接 tail -f logs/jnpf-lims/log_error.log 即可,容器重建/删除都不丢。 # 按天+按大小双维度滚动(logback SizeAndTimeBasedRollingPolicy:跨天或满 10MB 即切, # 归档进 <服务名>/{error,warn,info,total}/YYYY-MM-DD/ 子目录,保留 7 天),归档同样在宿主上。 # # 交付/测试环境建议指到数据盘,别让日志堆在部署目录里(下面是**宿主机**路径): #JNPF_LOG_DIR_HOST=/var/log/jnpf # ⚠️ Linux 上该目录由容器内 root 创建,非 root 用户读日志需要 sudo。 # ════════════════════════════════════════════════════════════════════════════ # 9. Compose / 构建 # ════════════════════════════════════════════════════════════════════════════ # 全栈统一产品版本:自建镜像 tag 随此值走,发版=改这一个值;未设默认 1.0.0 #CX_VERSION=1.0.0 # 测试环境(基线 + testenv;Windows 分隔符默认分号): #COMPOSE_FILE=docker-compose.yml;docker-compose.testenv.yml # 交付/生产(只用基线,排除 override 的本地调试端口发布): #COMPOSE_FILE=docker-compose.yml # 访问不了 Docker Hub 时解开,SWR 国内源(开发机同样适用,2026-08-10 换机实证: # 不解开则 cx-web 构建报 auth.docker.io i/o timeout,业务镜像的 JRE 基底同理): #BASE_IMAGE=swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/bellsoft/liberica-openjre-rocky:21 # nginx 基底两种写法二选一: # ① 短名(推荐,同 cx-redis 待遇)——由 make pull-infra 从 SWR retag, # 必须经 make images/pull-infra,否则 docker 会去 docker.io 找不存在的 library/cx-nginx #NGINX_BASE_IMAGE=cx-nginx:1.27-alpine # ② SWR 长名(不依赖 retag,任何 docker compose build 都能用) #NGINX_BASE_IMAGE=swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:1.27-alpine .gitattributes
New file @@ -0,0 +1,41 @@ # Set default behavior to automatically normalize line endings * text=auto # Java sources *.java text eol=lf *.xml text eol=lf *.yml text eol=lf *.yaml text eol=lf *.properties text eol=lf *.sql text eol=lf *.md text eol=lf *.json text eol=lf *.txt text eol=lf # Build *.gradle text eol=lf pom.xml text eol=lf # Shell scripts *.sh text eol=lf # 现场消费文件(Windows 检出必须保持 LF:.env 值尾随 \r 会毒化密码/compose 变量, # Makefile 配方与 nginx.conf 同理;对应 Windows Server + Git Bash 部署路线) .env.example text eol=lf *.conf text eol=lf Makefile text eol=lf # Windows scripts *.bat text eol=crlf *.cmd text eol=crlf # Docker Dockerfile text eol=lf # Binary files *.jar binary *.class binary *.png binary *.jpg binary *.gif binary *.ico binary .gitignore
New file @@ -0,0 +1,119 @@ # For Java *.lck target/ *.json /log/ # 容器栈的应用日志挂载点(docker-compose.yml 的 ${JNPF_LOG_DIR_HOST:-./logs},2026-08-11) /logs/ logback-test.xml # Compiled class file *.class # Log file *.log # BlueJ files *.ctxt # Mobile Tools for Java (J2ME) .mtj.tmp/ # Package Files # *.war *.nar *.ear *.zip *.tar.gz *.rar # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* # Image snapshot diff __diff_output__/ /jest-stare *.iml .idea/ .ipr .iws *~ ~* *.diff *.patch *.bak .DS_Store Thumbs.db .project .*proj .svn/ *.swp *.swo *.log.* *.json.gzip node_modules/ .buildpath .settings !**/resources/dist npm-debug.log nohup.out _site _data report.html /lib /es #elasticsearch-* config/base.yaml /.vscode/ /coverage /.history *.tmp !**/nacos-server/target/** !**/seata-server/lib/** **/nacos-server/data/* .pids # Local-only files (Claude Code workspace / 本地报告 / 厂商工具产物) .claude/ docs/reports/ *.vdd # docker compose / start-all.sh 本地环境变量(cp .env.example 起步,手工维护) # 含明文凭据不入库;.env.example 是模板,需保持跟踪(见下方 ! 例外) # .env.common / .env.customer.* 是已退役拼装机制(2026-08-07)的产物, # 规则保留防各机器残留文件误提交 .env .env.local !.env.example # 流程引擎镜像构建暂存(jar 60MB+,由 docker/flow-engine/prepare-context.sh 生成) /docker/flow-engine/context/ # 自有流程引擎工程构建产物(独立工程,不在父 POM,target 不入 git) /jnpf-flow-engine/target/ .playwright-mcp/ .worktrees/ # CodeGraph 本地代码索引,由各开发环境自行生成 /.codegraph/ # 离线交付包产物(make images-offline 生成,数 GB) /dist/ docker/nacos/backup/ # 腾讯云 CVM PG 专用密码(独立于本地 .env,绝不进 git) .env.tencent .superpowers/ /audit-spool/ # 改 .env 前的手工备份(cp .env .env.bak.$(date +%Y%m%d)) .env.bak.* # Maven config contains this workstation's absolute local-repository path. /.mvn/maven.config # Local sibling checkouts (symlinks on this machine; independent repos) /jnpf-web-monorepo-framework-v6.1/ /flowApi/ # macOS AppleDouble / resource-fork junk ._* Makefile
New file @@ -0,0 +1,120 @@ # JNPF 全栈 docker-compose 操作入口(详见 docs/docker-compose-guide.md) .PHONY: help jars jars-microservice flow-engine prepare web-dist check-web-dist pull-infra images preflight up up-no-pull images-offline down restart ps logs smoke # ── 第三方基础设施镜像:SWR 长名 → 本地短名 ────────────────────────────────── # compose 里写的是短名(cx-redis / cx-onlyoffice / cx-nginx),由下面的 pull-infra retag 而来。 # `docker tag` 只是给同一个 image ID 挂别名,不复制数据(0 字节开销)。 SWR := swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io help: ## 显示本帮助 @grep -E '^[a-z-]+:.*##' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " make %-10s %s\n", $$1, $$2}' jars: ## Maven 打包全部业务 jar(业务镜像构建的前置) mvn clean package -DskipTests # 2026-08-11 平台聚合:默认(monolith)产出 jnpf-platform 一个 fat jar,取代原来 # oauth/system/visualdev/flowable/file/message/permission/visualdata/app 九个。 # 九个 *-server 模块的源码**保留未删**,由各自父 pom 的 microservice profile 门控。 # 回滚到微服务形态 = 本目标 + 还原 config/*/discovery.yaml 的九个服务名端口 # + 放回 docker-compose.yml / start-all.sh 里被注释的九段。不需要 git revert。 jars-microservice: ## 回滚用:按微服务形态打包(额外产出九个平台服务 fat jar) mvn -P microservice clean package -DskipTests flow-engine: ## 构建自有流程引擎 jar(独立工程,不在父 POM,故单独构建) mvn -f jnpf-flow-engine/pom.xml clean package -DskipTests # 业务镜像 COPY 宿主 target/*.jar(非多阶段构建),故 images 前必须先有业务 jar。 # 用哨兵校验避免"干净机器忘了 make jars → docker COPY 阶段晦涩失败":缺失即报错提示。 # 不直接让 images 依赖 jars,是为保留本地"编一次、反复起"的迭代速度(全量 mvn 约 10min)。 check-jars: @test -f jnpf-gateway/target/jnpf-gateway-*.jar || { \ echo "错误:未找到业务 jar(jnpf-gateway/target/*.jar)"; \ echo " 请先构建全部业务 jar:make jars"; exit 1; } prepare: flow-engine ## 构建并暂存自建流程引擎 jar 到镜像上下文(不入 git) ./docker/flow-engine/prepare-context.sh web-dist: ## 构建前端并快照 dist 到镜像上下文(前端仓库路径可 FRONTEND_REPO= 覆盖;只拷不构建 SKIP_BUILD=1) ./docker/frontend/sync-dist.sh # 与 check-jars 同理:不让 images 直接依赖 web-dist(前端构建数分钟级), # 只做存在性哨兵;dist 是否新鲜看 docker/frontend/dist/.build-info check-web-dist: @test -f docker/frontend/dist/index.html || { \ echo "错误:未找到前端产物(docker/frontend/dist/index.html)"; \ echo " 请先构建前端:make web-dist"; exit 1; } pull-infra: ## 拉取第三方基础设施镜像并 retag 成短名(cx-redis / cx-onlyoffice / cx-nginx) docker pull --platform linux/amd64 $(SWR)/redis:8.4.0-alpine docker tag $(SWR)/redis:8.4.0-alpine cx-redis:8.4.0-alpine # OnlyOffice DS 同样走 SWR(2026-08-19):Hub 直连在国内网络稳定超时。 # 镜像 ~2GB,首次拉取偏慢;compose 里对应短名 cx-onlyoffice:9.4.0.1。 docker pull --platform linux/amd64 $(SWR)/onlyoffice/documentserver:9.4.0.1 docker tag $(SWR)/onlyoffice/documentserver:9.4.0.1 cx-onlyoffice:9.4.0.1 # cx-nginx 是 cx-web 的**构建基底**(Dockerfile FROM),不是 compose 的 image 字段—— # 故它只在 .env 设了 NGINX_BASE_IMAGE=cx-nginx:1.27-alpine 时被消费;未设则走 # compose 默认的 Docker Hub 多架构 nginx:1.27-alpine(arm64 原生,但需 Hub 可达)。 docker pull --platform linux/amd64 $(SWR)/nginx:1.27-alpine docker tag $(SWR)/nginx:1.27-alpine cx-nginx:1.27-alpine @echo "基础设施镜像已就绪(短名 → 架构):" @for i in cx-redis:8.4.0-alpine cx-onlyoffice:9.4.0.1 cx-nginx:1.27-alpine; do \ printf ' %-24s %s\n' "$$i" "$$(docker image inspect --format '{{.Architecture}}' $$i 2>/dev/null || echo '缺失')"; \ done # 取代原先的 `docker compose pull --ignore-buildable`:compose 里没有 build 段的服务 # 恰好就是那三个第三方基础设施服务(redis/namesrv/broker),它们现在用短名, # 裸 pull 会去 docker.io 找不存在的仓库。pull-infra 覆盖同一批镜像且带 retag。 # --profile localdb 是构建期必须的:cx-postgres 在该 profile 内(默认不启动,见 compose 服务块), # 不带它 `compose build` 会跳过 PG 镜像,交付/正式环境起栈时才发现没镜像。 # 构建全集 ≠ 启动集合:这里只保证镜像齐,起哪些服务仍由 .env 的 COMPOSE_PROFILES 决定。 images: check-jars check-web-dist prepare pull-infra ## 确保全部镜像就绪:自建的构建(cx/postgres、cx/flow-engine、业务、前端),第三方的拉取+retag docker compose --profile localdb build # up/up-no-pull 强制前置 preflight(Backlog C1):空卷/空库/缺 .env 键在 compose up 前拦下, # 消灭"空库 PG 却 healthy、业务全线连不上"的静默失败;确要跳过 SKIP_PREFLIGHT=1 preflight: ## 部署自举校验:docker/内存/.env 必填键/数据卷/三库 schema/种子数据/共享配置文件/业务角色 @if [ "$(SKIP_PREFLIGHT)" = "1" ]; then echo "跳过 preflight(SKIP_PREFLIGHT=1)"; \ else ./docker/preflight.sh; fi up: images preflight ## 镜像就绪后一键启动全栈(冷启动约 5-8 分钟:PG→业务自动排队) docker compose up -d docker compose ps up-no-pull: preflight ## 跳过构建与拉取,用本地已有镜像起全栈(离线/快速迭代;镜像缺失直接报错) docker compose up -d --no-build --pull never docker compose ps images-offline: check-jars check-web-dist prepare ## 组装整栈 amd64 离线交付包到 dist/(详见 docs/docker-compose-guide.md §14) ./docker/delivery/assemble-offline-package.sh # --profile localdb 不能省:compose 的 down 只覆盖当前启用 profile 内的服务(实测:profile 关闭时 # 该容器会被原样留着跑)。带上它,无论本环境用不用栈内 PG,停栈都是干净的(没起过则是空操作)。 down: ## 全部停止并移除容器(数据安全:pgdata 是 external 卷,永不删除) docker compose --profile localdb down restart: ## 重启全栈容器(原地重启,不重建容器/不丢 MQ 无持久化消息) docker compose restart ps: ## 查看容器状态与健康 docker compose ps logs: ## 跟踪某服务日志:make logs s=cx-lims docker compose logs -f $(s) smoke: ## 冒烟三段:gateway 登录 + 前端静态 200 + 经 80 反代登录(L017 脚本;与 site.Makefile 同步维护) @set -o pipefail; \ MD5=$$(command -v md5 >/dev/null 2>&1 && echo md5 || echo md5sum); \ hash5() { if [ "$$MD5" = md5 ]; then md5; else md5sum | cut -d' ' -f1; fi; }; \ KEYHEX=$$(printf '%s' "$$(printf '%s' 'EY8WePvjM5GGwQzn' | hash5)" | xxd -p | tr -d '\n'); \ ENC=$$(printf '%s' "$$(printf '%s' '123456' | hash5)" | openssl enc -aes-256-ecb -K "$$KEYHEX" -nosalt | xxd -p | tr -d '\n'); \ RESP=$$(curl -fsS -X POST http://127.0.0.1:30000/api/oauth/Login -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode account=admin --data-urlencode "password=$$ENC" \ --data-urlencode grant_type=password --data-urlencode origin=password); \ echo "$$RESP" | head -c 200; echo; \ echo "$$RESP" | grep -q '"code":200' || { echo "冒烟失败:登录未返回 code 200(gateway 30000 直连)"; exit 1; }; \ curl -fsS -o /dev/null http://127.0.0.1:80/ || { echo "冒烟失败:前端静态入口 http://127.0.0.1:80/ 不通"; exit 1; }; \ RESP2=$$(curl -fsS -X POST http://127.0.0.1:80/api/oauth/Login -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode account=admin --data-urlencode "password=$$ENC" \ --data-urlencode grant_type=password --data-urlencode origin=password); \ echo "$$RESP2" | grep -q '"code":200' || { echo "冒烟失败:经前端 80 反代登录未返回 code 200"; exit 1; }; \ echo "冒烟通过 ✓(gateway 直连 + 前端静态 + 80 反代登录)" README.md
@@ -1,4 +1,303 @@ ## JNPF_ITMS # JNPF 后端服务 培训系统 基于 Spring Cloud Alibaba 的低代码微服务平台(v6.1.0-RELEASE),业务侧包含 LIMS(实验室信息管理)、DMS(文档管理)等模块。 配套仓库: ``` bostal/ ├── jnpf-java-cloud-v6x # 后端(本仓库) └── jnpf-web-monorepo-framework-v6.1 # 前端(pnpm monorepo) ``` --- ## 一、macOS 本机启动(start-all.sh) `start-all.sh` 是宿主机 `java -jar` 形态的批量启动脚本:Java 服务直接跑在 macOS 上,基础设施(PostgreSQL / Redis / 流程引擎 / OnlyOffice)跑在 Docker 里。相比全栈容器,改一行代码重启单个服务只要几十秒。 ### 0. 前置条件 | 依赖 | 要求 | 说明 | |---|---|---| | JDK | 21(推荐)或 17 | 镜像基底是 `bellsoft/liberica-openjre-rocky:21`;流程引擎工程要求 21 | | Maven | 3.6.3+ | 无 mvnw wrapper,需系统安装 | | Docker Desktop | 任意近期版本 | 起基础设施容器 | | PostgreSQL | 已建好的实例 | **库结构与初始数据不在本仓库内,由甲方提供**。需要两个库:业务库 `jnpf_init`(角色 `jnpf_app`)、流程引擎库 `jnpf_flow`(角色 `jnpf_flow_app`) | macOS 自带 bash 是 3.2,脚本已按 3.2 语法编写(无关联数组、空数组用 `${arr[@]+...}` 展开),直接 `./start-all.sh` 即可,不需要装 bash 5。 ### 1. 配置 hosts 宿主进程要用容器名解析到本机,一次性写入 `/etc/hosts`: ``` 127.0.0.1 cx-infra cx-redis cx-flow-engine ``` ### 2. 准备 .env ```bash cp .env.example .env ``` 至少确认这几项(`.env.example` 内每一节都有详细注释): | 键 | 说明 | |---|---| | `CUSTOMER_DB_HOST` / `CUSTOMER_DB_PORT` | 数据库坐标。业务库和流程引擎库都由这一组推导,**必然同源** | | `JNPF_APP_DB_USER` / `JNPF_APP_DB_PASSWORD` | 业务库凭据 | | `JNPF_FLOW_DB_USER` / `JNPF_FLOW_DB_PASSWORD` | 流程引擎库凭据 | | `JNPF_REDIS_PASSWORD` | Redis `requirepass`,业务侧同值透传 | | `ONLYOFFICE_JWT_SECRET` | 必填,缺了 compose 起不来;只在需要文档在线编辑时才关心 | | `COMPOSE_PROFILES` | 用外部数据库时**注释掉这一行**(它控制是否启动栈内 PG 容器) | 两个坑: - **不要设 `JNPF_FLOW_DB_URL`**。它是整串覆盖的逃生口,会把流程引擎库和业务库拆成两个来源,症状是流程模板大面积「找不到流程模板」。 - 数据库坐标写 `cx-postgres` 时,`start-all.sh` 会**自动翻译成 `127.0.0.1:5433`**(compose 只把栈内 PG 发布到 5433,避免和系统 PG 撞车)。指向外部实例(IP 坐标)时这个翻译不生效。启动时脚本会打印实际使用的坐标,可以据此确认连的是哪个库。 ### 3. 启动服务 ```bash ./start-all.sh ``` 前台运行,实时打印每个服务的就绪状态,`Ctrl+C` 统一停止。首次运行若缺 JAR 会自动编译对应模块。 ### 命令速查 ```bash ./start-all.sh # 前台启动全部(缺 JAR 自动补编) ./start-all.sh --build # 先全量编译再启动 ./start-all.sh restart # 停止 → 编译 → 启动 ./start-all.sh stop # 停止全部(可在另一个终端执行) ./start-all.sh status # 查看运行状态 ./start-all.sh help # 完整帮助 ./start-all.sh jnpf-lims # 只启动 lims ./start-all.sh restart jnpf-lims # 只重启 lims(仅编译该模块,快很多) ./start-all.sh stop jnpf-lims # 只停 lims,不影响其他服务 ./start-all.sh status jnpf-lims # 只看 lims ./start-all.sh restart jnpf-lims jnpf-platform # 多服务一起 ``` **日常开发最常用的是 `./start-all.sh restart jnpf-lims`** —— 它用 `mvn -pl <模块> -am` 只编译目标模块及其依赖,比全量 `mvn package` 快一个数量级。 ### 启动编排 脚本不是一股脑全拉起来的: 1. **阶段 1**:先起网关(30000),等端口就绪 2. **阶段 2**:业务服务分四波,波与波之间过负载闸门 | 波次 | 服务 | |---|---| | 1 | `jnpf-platform` | | 2 | `jnpf-biz-common` | | 3 | `jnpf-lims`、`jnpf-dms` | | 4 | 其余(当前是 `jnpf-eln`) | 闸门逻辑:读 `vm.loadavg`,负载 ≥ `LOAD_GATE`(默认 9)就等 20s 再放下一波。4 个业务 JVM 同时启动会瞬间打满 CPU,导致 Druid 建连超时。 3. 指定服务子集且不超过 4 个时**直启不分波**,重启单个服务不会被波次拖慢。 可调环境变量: | 变量 | 默认 | 说明 | |---|---|---| | `SERVICE_READY_TIMEOUT` | 360 | 等待就绪的最长秒数 | | `JNPF_SERVICE_MAX_HEAP` | 384m | 单服务堆上限 | | `JNPF_PLATFORM_MAX_HEAP` | 1536m | `jnpf-platform` 例外档(九个模块同一个 JVM) | | `LOAD_GATE` | 9 | 分波负载闸门 | 用法:`JNPF_SERVICE_MAX_HEAP=512m ./start-all.sh jnpf-lims` ### 日志与排障 | 位置 | 内容 | |---|---| | `log/<服务名>/startup.log` | 宿主栈的启动与运行日志(脚本重定向的 stdout/stderr) | | `.pids/<服务名>.pid` | 进程 PID,`stop` / `status` 靠它跨终端管理 | 常见状态含义: - `[跳过] ... 已在运行` — PID 文件里的进程还活着 - `[失败] ... 端口 X 已被未登记进程占用` — 端口上有进程但不是本脚本启动的,脚本不会去动它,需要自己 `lsof -i :X` 处理 - `[未登记]`(status 输出)— 同上,端口在监听但没有对应 PID 文件 - `[超时]` — 进程活着但超时未监听端口,去 `log/<服务名>/startup.log` 看栈 ### 关于 OnlyOffice 宿主栈下,Document Server 在容器里而后端在宿主上,容器内的 `127.0.0.1` 指向容器自己。脚本已自动导出三个地址变量走 `host.docker.internal`,正常情况下不用在 `.env` 里配;显式配了则以 `.env` 为准。 --- ## 二、模块划分 ### 服务拓扑 | 服务 | 端口 | 职责 | |---|---|---| | `jnpf-gateway` | 30000 | API 网关,路由转发。基于 WebFlux,与 servlet 栈不能同进程,必须独立部署 | | **`jnpf-platform`** | **30002** | **平台聚合服务**:下列九个平台模块合并在一个 JVM 内 | | `jnpf-scheduletask` | 30009 | 定时任务(xxl-job admin),未并入 platform | | `jnpf-eln` | 30013 | ELN 电子实验记录(当前为骨架) | | `jnpf-biz-common` | 30015 | 公共业务能力聚合:OnlyOffice、审计等 | | `jnpf-dms` | 30016 | 文档管理 | | **`jnpf-lims`** | **30019** | **LIMS 实验室信息管理,主要业务模块** | | `jnpf-flow-engine` | 31000 | Flowable 7.0.1 流程引擎。**独立工程,不在根 pom**,用独立数据库 `jnpf_flow` | `jnpf-platform` 内含的九个模块: | 模块 | 原端口 | 职责 | |---|---|---| | `jnpf-oauth` | 30001 | 认证授权(Sa-Token + JWT) | | `jnpf-system` | 30002 | 平台核心:字典、权限、数据接口;同时是 Dubbo `LogProvider` 的唯一 provider(20880) | | `jnpf-visualdev` | 30003 | 在线开发引擎(表单/列表设计器) | | `jnpf-flowable` | 30004 | 工作流**业务层**,注册名是 `jnpf-workflow`(真引擎是上面的 flow-engine) | | `jnpf-file` | 30005 | 文件管理 | | `jnpf-message` | 30008 | 消息中心 | | `jnpf-permission` | 30010 | 权限管理 | | `jnpf-visualdata` | 30011 | 数据可视化 | | `jnpf-app` | 30012 | 移动端服务 | > 九个模块合并进一个 JVM 后内存占用从约 6.7GB 降到 1.07GB。**九个服务名全部保留为服务发现表里的别名**,指向 30002 —— 所以 60 多个 `@FeignClient`、网关的 `lb://` 路由、各类脚本都不需要改动,写代码时照旧按原服务名调用即可。九个 `*-server` 模块的源码也保留着,由各自父 pom 的 `microservice` profile 门控,回滚不需要改代码。 > > 同理,审计能力已并入 `jnpf-biz-common`,但服务名 `jnpf-audit` 和路由前缀 `/api/audit/` 都刻意保留为别名,调用方零改动。 ### API 路由前缀 前端请求 → 网关(30000)→ 按前缀转发: | 前缀 | 目标 | |---|---| | `/api/lims/`、`/api/extend/`(旧前缀,同指) | jnpf-lims | | `/api/dms/` | jnpf-dms | | `/api/eln/` | jnpf-eln | | `/api/biz/` | jnpf-biz-common(如 `/api/biz/onlyoffice/xxx`) | | `/api/audit/` | jnpf-biz-common(别名保留) | | `/api/oauth/` `/api/system/` `/api/visualdev/` `/api/workflow/` `/api/file/` `/api/message/` … | jnpf-platform(经服务名别名解析) | 完整路由表在 `config/shared/router.yaml`。 ### 业务模块分层 以 `jnpf-lims` 为例,其他业务模块(dms/eln)结构相同: ``` jnpf-lims/ ├── jnpf-lims-entity # 实体类、DTO、枚举 ├── jnpf-lims-biz # Service + Mapper ├── jnpf-lims-controller # REST 控制器 ├── jnpf-lims-api # Feign 远程调用接口 └── jnpf-lims-server # Spring Boot 启动入口 + 配置 ``` 包名统一用 `lims` 前缀: | 层 | 路径 | |---|---| | Entity | `jnpf-lims-entity/src/main/java/jnpf/limsEntity/` | | Mapper | `jnpf-lims-biz/src/main/java/jnpf/limsMapper/` | | Service | `jnpf-lims-biz/src/main/java/jnpf/limsService/` | | Controller | `jnpf-lims-controller/src/main/java/jnpf/limsController/` | ### 公共依赖链 ``` jnpf-common(parent POM) └── jnpf-public/jnpf-cloud-base # 静态服务发现 + 核心工具 └── jnpf-public/jnpf-common-springaop # AOP + Sa-Token + Sentinel + Swagger └── 各业务服务 ``` ### 开发约定 - Controller 只做请求/响应转换,业务逻辑在 Service 层 - 新建 Mapper 后确认启动类的 `@MapperScan` 能扫描到 - 实体字段命名跟随数据库列名(拼音风格,如 `jianyan_liushuihao`、`chanpin_mingcheng`) - 🔴 **业务字段禁止以 `f_` 开头**:这是平台技术列的保留前缀(`f_id` / `f_tenant_id` / `f_version` / `f_delete_mark` / `f_creator_time` / `f_flow_state` 等),审计层正是靠这个前缀剔除技术列 - ORM 用 MyBatis-Plus(`@TableName` + `BaseMapper<T>`) - 标准响应格式:`{code: 200, msg: "...", data: ...}` ### 构建 ```bash mvn clean package -DskipTests # 全量(约 10 分钟) mvn clean package -DskipTests -pl jnpf-lims/jnpf-lims-server -am # 只构建 lims make jars # 打包全部业务 jar ``` 构建 profile:`default-package`(fat JAR,默认)、`external-package`(依赖外置)。 --- ## 三、配置机制 **没有配置中心**。配置是仓库内的 YAML 文件 + 环境变量占位符,三层组合。 ### 三层结构 | 层 | 位置 | 作用 | 是否进镜像 | |---|---|---|---| | 共享配置 | `config/shared/*.yaml` | **单一事实源**。所有环境共用同一份结构 | ✅ `/config/shared` | | 宿主覆盖 | `config/host-override/*.yaml` | 宿主 `java -jar` 栈的差异覆盖 | ❌ 只存在于工作区 | | 环境值 | 仓库根 `.env` | 真实的密码、坐标、域名 | ❌ 不入库 | 规则很简单:**`config/shared/` 里只写结构和占位符,真实值一律走 `.env`**。 ```yaml # config/shared/datasource.yaml 里长这样 datasource: db-type: PostgreSQL db-name: jnpf_init host: ${CUSTOMER_DB_HOST:cx-postgres} port: ${CUSTOMER_DB_PORT:5432} username: ${JNPF_APP_DB_USER:jnpf_app} password: ${JNPF_APP_DB_PASSWORD:jnpf_app_2026} ``` 占位符的默认值 = 本机 compose 开发形态,所以 `cp .env.example .env` 之后基本可以直接跑。 `config/shared/` 下按关注点分文件:`datasource.yaml`(数据源/Redis)、`discovery.yaml`(服务发现)、`router.yaml`(网关路由)、`logger.yaml`(日志)、`resources.yaml`(文件存储)、`system-config.yaml`(域名等)、`jnpf-biz-common.yaml`、`jnpf-dms.yaml`(模块专属)等。 ### 两种运行形态共用一份配置 `.env` 同时被两边读取: - **容器栈**:`docker compose` 自动读仓库根 `.env`,注入容器环境 - **宿主栈**:`start-all.sh` 启动时逐行解析 `.env` 并 `export` 差异只在 `config/host-override/`。目前只有一份 `discovery.yaml` —— 服务发现表里,容器栈解析到容器名(`cx-platform:30002`),宿主栈整表覆盖为 `127.0.0.1:30002`。这个文件在容器内不存在,Spring 的 `optional:` 导入落空即跳过,所以同一份 `application.yml` 能双栈通用。 `.env` 里同名键**后出现的覆盖先出现的**;启动前已在环境里的变量不会被 `.env` 覆盖,所以可以 `FOO=bar ./start-all.sh` 临时改值。 ### 静态服务发现 没有 Nacos。`config/shared/discovery.yaml` 是一张 `SimpleDiscoveryClient` 实例表,直接把服务名映射到 URI: ```yaml spring.cloud.discovery.client.simple.instances: jnpf-lims: - uri: http://cx-lims:30019 ``` 消费方是所有 `@FeignClient` 的 `lb` 解析、网关的 `lb://` 路由、knife4j 的文档聚合。 > ⚠️ **新增服务要同步三处**:`config/shared/discovery.yaml`、`config/host-override/discovery.yaml`、`docker-compose.yml`。漏掉任何一处,对应形态下的调用会 502。 ### 改配置后怎么生效 | 场景 | 操作 | |---|---| | 改 `config/**` 或 `.env`,宿主栈 | `./start-all.sh stop && ./start-all.sh`(Druid 连接池在启动时定死,不热更新) | | 改 `config/**`,容器栈 | rebuild 镜像,或用 volume 挂载覆盖 `/config/shared`(开发用的 override 已经挂好) | | 交付现场改结构级配置 | 挂载覆盖 `/config/shared` 即可,不必重建镜像 | ### 数据库 - 主库 PostgreSQL;代码层同时兼容 MySQL、Oracle、SQLServer、DM8、KingbaseES - 业务库 `jnpf_init` 和流程引擎库 `jnpf_flow` 由同一组坐标推导,**必须同源** - 连接池配额通过 `JNPF_APP_CONN_LIMIT` / `JNPF_FLOW_CONN_LIMIT` 分配,两者之和要小于实例 `max_connections − 3`,避免单个角色占满连接槽饿死另一个 config/README.md
New file @@ -0,0 +1,10 @@ # config/ — 全栈共享配置(补丁 #8 起替代 Nacos 配置中心) - `shared/`:单一事实源。环境差异一律占位符 `${VAR:默认值}`,默认值 = 本机 compose 开发形态;真实值(密码/域名/客户坐标)从 `.env` 经 compose 注入,**永不写进本目录**。 - `host-override/`:宿主 `java -jar` 栈(start-all.sh)的差异覆盖,目前只有 discovery 主机名一份。不进镜像。 - 镜像内路径 `/config/shared`(Dockerfile COPY + `ENV JNPF_CONFIG_DIR=/config`); 交付现场要改结构级配置时,volume 挂载覆盖 `/config/shared` 即可,不必重建镜像。 - 改文件后:宿主栈重启服务即生效;容器栈需 rebuild 或挂载(开发 override 已挂载)。 - 客户级「值」的管理(.env 怎么组织)见 docs/delivery/customer-config-repo-README.md。 config/generated/onlyoffice-plugins-dev.yaml
New file @@ -0,0 +1,29 @@ 'plugins': 'definitions': 'eln-template-annotator': 'guid': 'asc.{2D6D6CB6-F6FC-45B7-8EC7-0CC1940F1A6F}' 'version': '0.1.0' 'public-base-url': 'http://localhost:4173/onlyoffice-plugins' 'release': '0.1.0-dev' 'api-base-url': 'http://localhost:30000' 'eln-template-filler': 'guid': 'asc.{7F4E8F35-5D66-47F8-A5B4-6AB3FAACF565}' 'version': '0.1.0' 'public-base-url': 'http://localhost:4173/onlyoffice-plugins' 'release': '0.1.0-dev' 'api-base-url': 'http://localhost:30000' 'scenes': - 'biz-scene': 'eln.template.annotate' 'biz-module': 'eln.template' 'plugin-codes': - 'eln-template-annotator' 'autostart-plugin-code': 'eln-template-annotator' 'disabled-plugin-guids': - 'asc.{AA2EA9B6-9EC2-415F-9762-634EE8D9A95E}' - 'biz-scene': 'eln.template.fill' 'biz-module': 'eln.template' 'plugin-codes': - 'eln-template-filler' 'autostart-plugin-code': 'eln-template-filler' 'disabled-plugin-guids': - 'asc.{AA2EA9B6-9EC2-415F-9762-634EE8D9A95E}' config/host-override/discovery.yaml
New file @@ -0,0 +1,52 @@ # 宿主 java -jar 栈(start-all.sh)的发现表覆盖:全部 127.0.0.1。 # 本文件仅存在于仓库工作区,不进镜像(Dockerfile 只 COPY config/shared), # 容器内 optional: 导入落空即跳过——同一份 application.yml 双栈通用的机关就在这里。 spring: cloud: discovery: client: simple: instances: jnpf-gateway: - uri: http://127.0.0.1:30000 # ══════════════════════════════════════════════════════════════════ # 以下 9 个服务名全部是 **jnpf-platform:30002 的别名**(2026-08-11 合并)。 # 名字一个都不删:60 个 @FeignClient 的 name 常量、网关 router.yaml 的 # lb:// 路由、回归脚本全按名字解析——留别名 = 调用方与脚本零改动 # (沿用 audit 并入 biz-common 时验证过的做法)。 # 回滚:把下面 9 条改回各自原端口(30001/30002/30003/30004/30005/ # 30008/30010/30011/30012)+ mvn -P microservice 用旧 jar 启动。 # ══════════════════════════════════════════════════════════════════ jnpf-oauth: - uri: http://127.0.0.1:30002 jnpf-system: - uri: http://127.0.0.1:30002 jnpf-visualdev: - uri: http://127.0.0.1:30002 # 注意:flowable 服务的注册名是 jnpf-workflow,容器名是 cx-flowable jnpf-workflow: - uri: http://127.0.0.1:30002 jnpf-file: - uri: http://127.0.0.1:30002 jnpf-message: - uri: http://127.0.0.1:30002 jnpf-permission: - uri: http://127.0.0.1:30002 jnpf-visualdata: - uri: http://127.0.0.1:30002 jnpf-app: - uri: http://127.0.0.1:30002 # ── 以下不在 platform 内,保持独立 ── jnpf-scheduletask: - uri: http://127.0.0.1:30009 jnpf-eln: - uri: http://127.0.0.1:30013 # 别名,理由同 config/shared/discovery.yaml:audit 已搬入 biz-common(30015) jnpf-audit: - uri: http://127.0.0.1:30015 jnpf-biz-common: - uri: http://127.0.0.1:30015 jnpf-dms: - uri: http://127.0.0.1:30016 jnpf-lims: - uri: http://127.0.0.1:30019 config/shared/audit-layer0-forms.yaml
New file @@ -0,0 +1,85 @@ # 审计层 0(在线表单事件)表单注册表 —— Task C1 # # 这份配置同时回答两个问题:**哪些应用前台请求要审计,以及已知业务表如何补充审计元数据**。 # 它取代了 lims 侧 BizFormRegistry 里那张 18 条的硬编码静态 Map——原先每接一个新表单都要改 # lims 代码、重新打包、重启服务;现在各业务模块自助在这里登记,audit 服务不动代码。 # # 为什么单独一个配置文件(而不是并进服务自身的配置):这份表由**业务模块**维护、改动频繁, # 而 jnpf-biz-common.yaml 是服务自身的日志等配置。混在一起,业务改一行表名就有把服务配置改坏的机会。 # (2026-08-11 前对照的是 jnpf-audit.yaml,该文件随 audit 搬入 biz-common 已删除。) # # ── 怎么加一张表 ─────────────────────────────────────────────────────────────── # 1. 认的是**主表名**(不是 modelId)。modelId 是无意义的雪花数字、且同一张主表常有多个表单视图 # (列表页/详情页/移动端各一个 modelId),按表登记只需一条。modelId → 主表名由 audit 服务 # 反查 base_visual_release.F_TABLES_DATA(typeId=1 那张)并缓存,无需在此登记。 # 2. **清单是 YAML 列表(`- table: xxx`),不是 map**。这不是风格偏好:Spring Boot Binder 对 # map 属性做的是**合并**,而不是整体替换——当年走 Nacos 热推送时,删掉一行**不会**生效, # 那张表会一直被审计到下次重启为止(C1 实测踩到并已修)。补丁 #8 起配置只在服务启动/重启时 # 整体绑定一次,已无热推送场景,但列表格式仍保留:增删语义清晰,行为不随加载机制变化。 # 3. 除 table 外都可选,缺了只是记得糙一点,**不会让事件丢失**: # biz-module 落 audit_events.biz_module,用户视角的「在什么应用上操作的」 # biz-type 落 biz_type,业务分类,与旧 lims_biz_log 的 BizLogType 同位 # biz-code-field 从表单数据里取业务单号的字段名,落 biz_code;没有业务单号就别配 # masked-fields 脱敏字段:只记「已变更」不记值 # 4. 改完这份文件后需要让 jnpf-audit 重新读取配置才生效(补丁 #8 起不再有 Nacos 热推送): # 宿主 java -jar 栈(start-all.sh)直接重启 jnpf-audit 即可;容器栈(docker compose)需要 # 重建该服务镜像,或用 volume 挂载覆盖 /config/shared 后重启容器(开发 override 已挂载, # 改完直接重启容器生效)。即便如此,相对 lims 旧硬编码依然是数量级的提效——那张表改一行 # 过去要改代码、重新打包、重启服务,现在只是改配置文件 + 重启。 # 5. ⚠️ **要停掉全部层 0 审计,写 `enabled: false` 或 `forms: []`,不要把 forms 这个键整个删掉。** # 键还在(哪怕是空列表)binder 才会调 setter 让清单整体替换;键被删掉时 binder 根本不调 setter, # 服务里会留着上一版的清单继续审计——同 L062 一个家族的坑,只是另一半形态。 # # ── 已知边界(不是 bug,但会影响你怎么读产出)───────────────────────────────── # · **脱敏列判定不了「是否变更」**:层 0 的变更前值来自上一条事件的快照,而快照里脱敏列存的是 # `***`。拿它跟真值比必然不等,会把「没改过」报成「改过」——所以层 0 对脱敏列**一律不产出 # diff**,只在 extra.maskedUndecidable 里列出这些列名。 # ⚠️ 2026-08-11 起这是**无覆盖手段的已知缺口**:原先此处写「要判定脱敏字段是否真的变了,用层 1 # (before-image 取自 SELECT 原值)」,而层 1 已整套移除(自交付起从未启用,source_layer=1 零行)。 # 现在没有任何层次能回答「脱敏字段是否真的变了」,只能知道「这次操作动过这张表」。 # · **快速连续修改会丢 diff**:同一行在秒级内被连改多次时,前 1-2 条事件会因并发消费窗口 # 取不到 baseline,记 extra.baselineSource=missing(可见降级,非静默丢失,快照链不断)。 # 量化见 tasks/audit-l0-concurrency-probe.sh。 # · **字段中文标签取自事件 listLog**:只有「本次实际发生变化」的字段才在 listLog 里带标签; # 同值保存等情况下该字段不在 listLog 中,diff 的 fieldName 会回落成字段名本身。 # # ── ⚠️ 范围怎么定的(2026-07-27 上线-2 修订)──────────────────────────────────── # 原先这里写着「在做完 B6-后续 ⑥(~50-65 ms 未归因开销)之前不要批量放开」。 # **这条前提已撤下**——它是按「放开 18 张表 + 未知量级」推的,而共享库真实用量实测推翻了它: # lims_qingyandan 共 70 行、lims_jianyan_xiang 126 条、lims_biz_log 72 行 / 2 个月。 # 每次表单保存多十几毫秒,在这个量级上没有意义。 # # **登记的是主表名,一条覆盖该表的全部表单视图。** 实测(本机库 base_visual_release 148 条): # lims_qingyandan ← 15 个表单视图(产品请验单/待取样记录/待接样记录/制作报告/原辅包请验单/ # 自制溶液请验单/个人请验台账/已取样记录/已接样记录/报告台账/报告审核/ # 报告批准/样品分检弹窗/样品组合分检弹窗/细胞库·菌种库请验单) # lims_sxt_quyang_jihua ← 1 个(水系统取样计划) # 共享库真实用量里的 7 个在用 modelId 全部落在这两张表内,故**两条即可**,不需要按 modelId 逐条登记。 # # ⚠️ **已知缺口:层 0 不处理子表**(spec §3.3 勘误)。「制作报告」的 F_TABLES_DATA 里 # 主表 lims_qingyandan(typeId=1) + **子表 lims_jianyan_xiang(typeId=0)**——子表数据的改动 # 不会产生 diff,静默丢失。 # ⚠️ 2026-08-11 起这也是**无覆盖手段的已知缺口**:原先此处写「要覆盖子表改动得靠层 1 # (它在 SQL 层看得到 lims_jianyan_xiang 的写)」,而层 1 已整套移除。 # 现在子表改动只能靠业务侧加层 2 @AuditLog 埋点覆盖,没有自动兜底。 # # ⚠️ **biz_type 由静态配置决定,与旧实现的动态判定有形状差**。旧 listener 对同一个 modelId # 会按动作给出不同 biz_type(如 813788483029051525 既产 QINGYANDAN 也产 QUYANG, # 814206006757174789 既产 QINGYANDAN 也产 JIEYANG,实测共 6 行);新注册表按表配, # 这些一律落 QINGYANDAN。新审计模块要区分取样/接样,按 action_code 而不是 biz_type。 # # 下面把 lims 旧硬编码的 18 张表原样备在注释里,需要时解注释即可, # 不必再去翻已废弃的 BizFormRegistry 源码。 audit: layer0: # 层 0 总开关。关掉则整个 listener 直接 return(连注册表都不查)。 enabled: true # 仅记录 /app_<应用编码> 前台请求;/app_backend_<应用编码> 与无应用前缀的总控台不记录。 frontend-only: true # 当前审计范围只包含 LIMS 应用前台。比较时忽略大小写。 allowed-app-codes: [ LIMS ] # 应用前台访问的在线表单全部纳入审计;下面 forms 仍用于提供更准确的模块、分类和业务单号。 include-unregistered-forms: true # READ 默认关闭。开启后列表查询和详情查看各按一次请求记录一条,不保存查询条件与返回数据。 record-read: false forms: config/shared/datasource-scheduletask.yaml
New file @@ -0,0 +1,33 @@ # ===================== 任务调度配置 ===================== xxl: job: accessToken: '432e62f3b488bc861d91b0e274e850cc' i18n: zh_CN logretentiondays: 30 triggerpool: fast: max: 200 slow: max: 100 # xxl-job服务端地址 admin: addresses: http://127.0.0.1:30020/xxl-job-admin/ executor: address: '' appname: xxl-job-executor-sample1 ip: '' logpath: /data/applogs/xxl-job/jobhandler logretentiondays: 30 port: 39999 # rest调用xxl-job接口地址 admin: register: handle-query-address: ${xxl.job.admin.addresses}api/handler/queryList job-info-address: ${xxl.job.admin.addresses}api/jobinfo log-query-address: ${xxl.job.admin.addresses}api/log task-list-address: ${xxl.job.admin.addresses}api/ScheduleTask/List task-info-address: ${xxl.job.admin.addresses}api/ScheduleTask/getInfo task-save-address: ${xxl.job.admin.addresses}api/ScheduleTask task-update-address: ${xxl.job.admin.addresses}api/ScheduleTask task-remove-address: ${xxl.job.admin.addresses}api/ScheduleTask/remove task-start-or-remove-address: ${xxl.job.admin.addresses}api/ScheduleTask/updateTask config/shared/datasource.yaml
New file @@ -0,0 +1,116 @@ spring: # redis单机模式 # 🔴 键位是 spring.data.redis 而非上游的 spring.redis(2026-08-11 收口):Spring Boot 3 起 # spring.redis.* 整体搬到 spring.data.redis.*。此前留在旧键位仍能连上,是靠依赖树里的 # spring-boot-properties-migrator 在运行时重映射兜着——那是「迁移期临时依赖」,不该长期承担 # 运行时职责;而且它对无替代键的 lettuce.pool.* 每次启动刷 4 条 ERROR,淹没真错误。 # 同时删掉了 lettuce pool 段:本栈连接层是 redisson-spring-boot-starter(3.52.0), # 不读 lettuce 配置,连接池走 Redisson 默认(connectionPoolSize=64/minIdle=24 等), # 那段配置在新旧键位下都是死的。 data: redis: #缓存库编号 database: 1 # 坐标走 env(与 PG 的 CUSTOMER_DB_HOST/PORT 同构):默认值 = 交付/本机形态(栈内 cx-redis 容器), # 指 CVM 共享实例时在 .env 设 JNPF_REDIS_HOST=YOUR_DB_HOST + 对应客户端口(客户A 16432/客户B 16433, # 端口=16431+N 与 PG 15431+N 尾数对齐;密码见 .env.tencent 对应客户段) host: ${JNPF_REDIS_HOST:cx-redis} port: ${JNPF_REDIS_PORT:6379} # 密码走 env(compose x-cx-env 透传,值在 .env);默认值仅本机开发兜底。 # 交付环境务必在 .env 设强密码——本文件进 git,不许出现真实交付密码。 password: ${JNPF_REDIS_PASSWORD:jnpf_redis_2026} # 密码为空时,请将本行注释 timeout: 3000 #超时时间(单位:秒) # redis集群模式(上游原样保留作对照;启用时同样要挂在 spring.data.redis 下) # data: # redis: # cluster: # nodes: # - 192.168.0.225:6380 # - 192.168.0.225:6381 # - 192.168.0.225:6382 # - 192.168.0.225:6383 # - 192.168.0.225:6384 # - 192.168.0.225:6385 # password: 123456 # 密码为空时,请将本行注释 # timeout: 3000 # 超时时间(单位:秒) # 数据源 datasource: # 数据库类型(可选值 MySQL、SQLServer、Oracle、DM、KingbaseES、PostgreSQL,请严格按可选值填写) db-type: PostgreSQL db-name: jnpf_init # 客户坐标:唯一开关,由各环境 .env 提供(docs/adr-multi-customer-db-isolation.md §3)。 # 引擎库 jnpf_flow 在 docker-compose.yml 里读同一对变量,两库必然同源—— # 结构上杜绝 2026-08-03「业务库切了引擎库没切」导致 44/49 流程报错的故障。 # 默认值 cx-postgres:5432 = 交付形态(PG 与业务服务在同一个 compose 栈内)。 host: ${CUSTOMER_DB_HOST:cx-postgres} port: ${CUSTOMER_DB_PORT:5432} # 账号密码同样走占位符:每个客户实例的角色密码不同,写死就得每切一个客户 # 进 Nacos 控制台改一次——那等于把「单一开关」又拆成两处。 # 值来自各环境 .env 的 JNPF_APP_DB_USER / JNPF_APP_DB_PASSWORD,由 compose 透传进容器。 username: ${JNPF_APP_DB_USER:jnpf_app} password: ${JNPF_APP_DB_PASSWORD:jnpf_app_2026} db-schema: #金仓达梦选填 prepare-url: #自定义url # 表空间(当数据库为Oracle、达梦DM、金仓KingbaseES时表空间必须指定,其他数据库为空即可) tablespace: # 多数据源配置 dynamic: seata: false # 设置默认的数据源或者数据源组,默认值即为master primary: master # 严格匹配数据源,默认false. true未匹配到指定数据源时抛异常,false使用默认数据源 strict: true druid: # 空闲时执行连接测试 test-while-idle: true # 连接测试最小间隔 time-between-eviction-runs-millis: 60000 # 获取连接等待3秒 根据网络情况设定 max-wait: 3000 # 初始化1个连接(多客户多实例后每实例只有 150 槽,见 adr-multi-customer-db-isolation §5) initial-size: 1 # 最大5个连接(原 10:实测单服务 active 连接恒为 0、纯池空占;一套长跑栈 45→~25 连接) max-active: 5 # 最少保持1个空闲连接(min-idle 是"永远保温"地板,×服务数×环境数即恒占) min-idle: 1 # 空闲连接保活, 超过配置的空闲时间会进行连接检查完成保活操作(数据库自身会断开达到空闲时间的连接, 程序使用断开的连接会报错) keep-alive: true # 连接超时 connect-timeout: 10000 # 连接超时 socket-timeout: 10000 # 查询超时 query-timeout: 90000 # 事务查询超时 transaction-query-timeout: 90000 # 数据库转码 # proxy-filters: druidEncodingFilter # connection-properties: # clientEncoding: GB2312 # serverEncoding: windows-1252 # 解除注释后Druid连接池打印SQL语句 忽略日志等级配置 #filters: slf4j slf4j: statementLogEnabled: true resultSetLogEnabled: false connectionLogEnabled: false dataSourceLogEnabled: false statementCreateAfterLogEnabled: false statementCloseAfterLogEnabled: false statementExecuteAfterLogEnabled: false #打印SQL替换参数 statementExecutableSqlLogEnable: true statementPrepareAfterLogEnabled: false statementPrepareCallAfterLogEnabled: false statementParameterSetLogEnabled: false # datasource: # # 额外的数据源 Service类中使用DS动态切换 # SQLSERVER11: # url: jdbc:mysql://192.168.0.30:3306/jnpf_project?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowMultiQueries=true&serverTimezone=GMT%2B8 # username: jnpf_project # password: DsNnnYHscifFDfEZ # driver-class-name: com.mysql.cj.jdbc.Driver config/shared/discovery.yaml
New file @@ -0,0 +1,57 @@ # 静态服务发现(补丁 #8 去 Nacos):SimpleDiscoveryClient 实例表。 # 消费方:60 个 @FeignClient 的 lb 解析、网关 lb:// 路由、knife4j DISCOVER 聚合。 # 默认值 = compose 容器名(交付形态零配置);宿主 java -jar 栈由 # config/host-override/discovery.yaml 整表覆盖为 127.0.0.1。 # ⚠️ 新增服务三处同步:本表、host-override 表、docker-compose.yml。 spring: cloud: discovery: client: simple: instances: jnpf-gateway: - uri: http://cx-gateway:30000 # ══════════════════════════════════════════════════════════════════ # 以下 9 个服务名全部是 **cx-platform:30002 的别名**(2026-08-11 平台聚合)。 # 名字一个都不删:60 个 @FeignClient 的 name 常量、网关 router.yaml 的 lb:// 路由、 # 回归脚本全按名字解析——留别名 = 调用方与脚本零改动(沿用 audit 并入 biz-common 的做法)。 # 回滚:把下面 9 条改回 cx-oauth:30001 / cx-system:30002 / cx-visualdev:30003 / # cx-flowable:30004 / cx-file:30005 / cx-message:30008 / cx-permission:30010 / # cx-visualdata:30011 / cx-app:30012,并 mvn -P microservice 用旧镜像启动。 # ══════════════════════════════════════════════════════════════════ jnpf-oauth: - uri: http://cx-platform:30002 jnpf-system: - uri: http://cx-platform:30002 jnpf-visualdev: - uri: http://cx-platform:30002 # 注意:flowable 服务的注册名是 jnpf-workflow(容器原名 cx-flowable,已并入 cx-platform) jnpf-workflow: - uri: http://cx-platform:30002 jnpf-file: - uri: http://cx-platform:30002 jnpf-message: - uri: http://cx-platform:30002 jnpf-permission: - uri: http://cx-platform:30002 jnpf-visualdata: - uri: http://cx-platform:30002 jnpf-app: - uri: http://cx-platform:30002 # ── 以下不在 platform 内,保持独立 ── jnpf-scheduletask: - uri: http://cx-scheduletask:30009 jnpf-eln: - uri: http://cx-eln:30013 # jnpf-audit 保留为**别名**:审计已于 2026-08-11 搬入 jnpf-biz-common、cx-audit 退役, # 但 Feign 名常量(AuditFeignName.AUDIT_SERVER_NAME)与调用方一行未改—— # 让这个服务名解析到 biz-common 即可兜住全部调用方与回归脚本。删掉此条 = lims 的 # 审计投递第一时间 502。 jnpf-audit: - uri: http://cx-biz-common:30015 jnpf-biz-common: - uri: http://cx-biz-common:30015 jnpf-dms: - uri: http://cx-dms:30016 jnpf-lims: - uri: http://cx-lims:30019 config/shared/frame-config.yaml
New file @@ -0,0 +1,137 @@ # ===================== 启动 banner 关闭(2026-08-11)===================== # 容器日志里这些 ASCII 艺术字没人看,还把真正有用的启动信息挤散。 # Spring Boot 自己的 banner 走 compose 的 SPRING_MAIN_BANNER_MODE=off(环境变量,全服务一处); # 下面两个是 ORM 框架各自打的,只能按各自的配置键关。gateway 不 import 本文件(无 ORM),无影响。 # # · mybatis-plus-join.banner ✅ **有效**,1.5.4 那个 banner 已消失 # · mybatis-plus.global-config.banner ❌ **本栈无效**,3.5.14 那个 banner 关不掉,原因见下 # # 🔴 为什么 MyBatis-Plus 那条无效(实测 + 反编译确认,别再花时间试): # jnpf-common-database 的 MybatisPlusConfig.createSqlSessionFactory() 里是 # `new GlobalConfig()`(banner 字段默认 true)再 setGlobalConfig(),**完全绕开**了 # MybatisPlusProperties 的属性绑定——本配置项在本栈根本没有消费者。 # 硬关的三条路都不划算:覆盖那个 23KB 的闭源配置类(含动态数据源/多租户/逻辑删除全套, # 平台一升级就失效)、BeanPostProcessor(拦不到,它在 @Bean 方法内部 new 出来直接 # getObject()、不经容器)、ENTRYPOINT 管道 grep(破坏日志实时性还吞退出码)。 # **保留这行**是因为它对上游原生形态是正确写法:将来平台若改回标准 # MybatisPlusAutoConfiguration,它会自动生效,届时不必再来一遍排查。 mybatis-plus: global-config: banner: false mybatis-plus-join: banner: false spring: cloud: sentinel: # 取消控制台懒加载 eager: true transport: dashboard: jnpf-infra:30098 log: dir: log/${spring.application.name}/sentinel # LOGGING日志等级, ALL, INFO, WARNING, SEVERE, OFF level: OFF # inetutils: # #填写多个网段或IP, 或者全部匹配的正则表达式 # preferred-networks: # - 192.168.0 # - 192.168.20 # ===================== AI配置-Start ===================== ai: openai: enabled: false # 超时时间, 秒, 根据AI平台性能调整超时时间 timeout: 300 # 每个用户限制时间内的请求次数 user-limit-count: 1 # 每个用户限制时间频率 user-limit-time: 3s # 全部请求限制时间内的请求次数 total-limit-count: 500 # 全部请求限制时间频率 total-limit-time: 1m # 阿里百联平台 api-host: https://dashscope.aliyuncs.com/compatible-mode/ api-key: chat: mode: qwen2.5-1.5b-instruct # GPT转发平台 # api-host: https://api.chatanywhere.tech/ # api-key: # chat: # mode: gpt-3.5-turbo # DeepSeek # api-host: https://api.deepseek.com/ # api-key: # chat: # mode: deepseek-chat # ===================== AI配置-End ===================== # SpringDoc接口文档 访问地址:http://127.0.0.1:30000/doc.html springdoc: api-docs: enabled: true #SpringDoc增强 #knife4j: # enable: true # basic: #接口文档访问鉴权 # enable: true # username: jnpf # password: 123456 lock4j: aop: # Lock4j注解是否启用 enabled: false feign: sentinel: enabled: true client: config: default: connectTimeout: 5000 readTimeout: 300000 httpclient: connection-timeout: 5000 seata: enabled: false # 补丁 #8:原 nacos 型 config/registry 子树已随 Nacos 移除。 # 将来若启用 seata,用 file 型注册配置重写本段(git 历史有原文)。 # dubbo 配置(补丁 #8:去注册中心,直连模式) dubbo: # 指定Dubbo服务实现类的包 scan: base-packages: jnpf.provider.impl protocols: dubbo: # 不配 host = Dubbo 默认绑 0.0.0.0(回环/局域网/容器路径全通)。 # 原 host: ${spring.cloud.client.ip-address} 会在多网卡宿主上强绑单一接口, # 消费端直连 127.0.0.1:20880 被拒(2026-08-06 宿主栈实测);直连模式下 # 通告地址无意义(registry N/A,地址由 @DubboReference url 显式给出),删之无副作用。 name: dubbo # jnpf-system 是核心栈唯一 provider,经 DUBBO_PROTOCOL_PORT=20880 固定端口 # (compose 的 cx-system 段 / start-all.sh 设置);其余服务无导出,保持 -1 不绑端口。 # 注意 ${VAR:-1} 中 ":-1" 是 Spring 占位符默认值语法,默认即 -1。 port: ${DUBBO_PROTOCOL_PORT:-1} registry: # 去注册中心:address=N/A 为 Dubbo 官方 registry-less 直连形态, # 消费端地址由 @DubboReference(url=...) 给出(jnpf-common-springaop,补丁 #8) address: N/A # 启动时不检查 consumer consumer: check: false application: name: dubbo-${spring.application.name} logger: slf4j # 检查允许序列化列表 # serialize-check-status: DISABLE # 检查序列化对象是否继承serializable # check-serializable: false config/shared/jnpf-biz-common.yaml
New file @@ -0,0 +1,118 @@ onlyoffice: # 浏览器加载编辑器 api.js 的地址。注意这是**浏览器视角**的地址,不是容器视角—— # 容器名 cx-onlyoffice 浏览器解析不了,本机开发走 override 发布的 20000 端口。 # 交付环境改为 Nginx 反代的 https://<域名>/onlyoffice(方案 §8.4)。 ds-public-url: ${ONLYOFFICE_DS_PUBLIC_URL:http://localhost:20000} # 给 DS 回源拉原始文档用(DS 视角)。路径 /api/file/Image/** 在 GatewayWhite # 硬编码白名单内,DS 无登录态也能取,靠 URL 上的 securityKey 票据鉴权。 # 宿主 java -jar 栈需覆盖为 http://host.docker.internal:30000(DS 在容器、网关在宿主)。 file-fetch-base-url: ${ONLYOFFICE_FILE_FETCH_BASE_URL:http://cx-gateway:30000} # 给 DS 回调后端用(DS 视角)。必须经网关——直连 30015 按 L042 会被 inner-auth 拒, # 而 DS 不是 JNPF 服务、注入不了 SA-SAME-TOKEN。回调路径已进网关白名单(平台补丁 #9)。 callback-base-url: ${ONLYOFFICE_CALLBACK_BASE_URL:http://cx-gateway:30000} # 后端反向拉取 DS 产物的地址(后端视角)。保存回调里 DS 给的下载 url 用的是它自己 # 视角的主机名,后端不一定能直连;配了本项就只保留其 path+query 换到这个主机上。 # 留空则原样使用 DS 给的 url。宿主 java -jar 栈需覆盖为 http://localhost:20000。 ds-internal-url: ${ONLYOFFICE_DS_INTERNAL_URL:http://cx-onlyoffice} # 与 DS 容器的 JWT_SECRET 同源(都读 .env 的 ONLYOFFICE_JWT_SECRET)。 # 后端用它签发编辑器 config,并校验 DS 回调的 Authorization: Bearer。 # 🔴 回调端点匿名可达(白名单放行),这个 secret 是它唯一的安全边界。 # 无默认值:缺失即启动失败,避免用空串静默跑起来后回调全被拒、且日志不明显。 jwt-secret: ${ONLYOFFICE_JWT_SECRET} # 编辑锁的心跳超时(分钟)。锁正常由 status 2/4 回调释放;浏览器崩溃等异常 # 场景下靠这个兜底,否则文档会永久锁死。见方案 §6.2。 lock-timeout-minutes: ${ONLYOFFICE_LOCK_TIMEOUT_MINUTES:30} # 编辑器里是否给下载/打印入口(「文件 → 下载为」「文件 → 打印」与顶栏右侧那两个图标)。 # 默认全关。 # ⚠️ 这两项只收界面入口,**不是安全边界**:/api/file/Image/** 在网关白名单内、只靠 # securityKey 票据鉴权,知道 URL 的人照样能把原文件拉走。真正的下载管控要落在文件服务层。 # 需要按人区分时,用 edit-permission 那套 bizModule→moduleId 登记表判 # moduleId::btn_download / btn_batchPrint(PermissionConst 里已有这两个码),机制现成。 allow-download: ${ONLYOFFICE_ALLOW_DOWNLOAD:false} allow-print: ${ONLYOFFICE_ALLOW_PRINT:false} # ELN 模板标注插件。公网基础地址从部署环境注入,release 由不可变发布目录决定。 plugins: definitions: eln-template-annotator: guid: asc.{2D6D6CB6-F6FC-45B7-8EC7-0CC1940F1A6F} version: 0.1.0 public-base-url: ${ONLYOFFICE_PLUGIN_PUBLIC_BASE_URL:http://localhost:4173/onlyoffice-plugins} release: ${ONLYOFFICE_PLUGIN_RELEASE:0.1.0-dev} api-base-url: ${ONLYOFFICE_PLUGIN_API_BASE_URL:http://localhost:30000} eln-template-filler: guid: asc.{7F4E8F35-5D66-47F8-A5B4-6AB3FAACF565} version: 0.1.0 public-base-url: ${ONLYOFFICE_PLUGIN_PUBLIC_BASE_URL:http://localhost:4173/onlyoffice-plugins} release: ${ONLYOFFICE_PLUGIN_RELEASE:0.1.0-dev} api-base-url: ${ONLYOFFICE_PLUGIN_API_BASE_URL:http://localhost:30000} scenes: - biz-scene: eln.template.annotate biz-module: eln.template plugin-codes: - eln-template-annotator autostart-plugin-code: eln-template-annotator disabled-plugin-guids: - asc.{AA2EA9B6-9EC2-415F-9762-634EE8D9A95E} - biz-scene: eln.template.fill biz-module: eln.template plugin-codes: - eln-template-filler autostart-plugin-code: eln-template-filler disabled-plugin-guids: - asc.{AA2EA9B6-9EC2-415F-9762-634EE8D9A95E} # ── 谁能编辑(里程碑 4,方案 §6.1)─────────────────────────────────────────── # 粒度是**菜单/按钮级**,和平台在线表单的单条更新端点一个口径(那边也只做 # StpUtil.checkPermission,数据范围只用于列表 WHERE)。权限码登录时已进 Sa-Token # 会话,判定纯本地、无远程调用。 # # 做不到「记录级」(这一条检验单该不该给这个人改)是有意为之:那要读业务表的班组/ # 负责人/流程状态,属于业务规则,biz-common 的准入规则禁止它含单一业务模块的专属逻辑。 # 需要记录级时,由业务模块自己判完再打开编辑器。 edit-permission: # 未登记的 bizModule(或请求压根没带 bizModule)时的取向。 # false(默认)= 放行编辑 + 一条 warn。兼容默认值:前端最简调用 # onlineUtils.openOfficeDocument({ file }) 并不传 bizModule,一上来就严格会 # 让所有既有调用方突然全变只读。 # true = 一律只读。**该登记的都登记完之后应当打开它**——否则「漏登记」的表现是 # 悄悄放行编辑,而不是能被人发现的报错。 strict: ${ONLYOFFICE_EDIT_PERMISSION_STRICT:false} # bizModule → 在线表单 moduleId 的登记表。业务侧自助加行,不用改 biz-common 代码。 # 用 List 不用 Map:Spring Boot Binder 对 Map 是合并语义、对 List 是整体替换, # 写成 Map 的话删掉一条登记根本不生效(audit 的 layer0 注册表踩过这个坑)。 # # ⚠️ require-btn-edit 是陷阱位:在线表单只有勾了「启用按钮权限」,用户权限表里才会有 # <moduleId>::btn_edit。没启用却留 true,结果是**所有人都被降级只读**。 # 该表单没启用按钮权限时填 false,此时只校验菜单级权限。 # # modules: # - biz-module: lims_jianyan_baogao # module-id: '507748874527276549' # base_module.f_id # require-btn-edit: true modules: [] # ══ 2026-08-11:由 jnpf-audit.yaml 迁移而来 ══ # audit 搬入 biz-common、jnpf-audit-server 退役后,原 config/shared/jnpf-audit.yaml 已删除。 # 该文件里唯一的活配置就是下面这行日志级别,**必须跟着服务名一起迁移**: # logback-spring.xml 取的是 `log.level.<spring.application.name>` # (jnpf-biz-common-server/src/main/resources/logback-spring.xml:14 # <springProperty ... source="log.level.jnpf-biz-common"/>), # 服务名从 jnpf-audit 变成 jnpf-biz-common,原来那行 `log.level.jnpf-audit` 就再也不会被读到—— # 表现不是报错,而是**审计消费/落库链路的 log.info 被共享 logger.yaml 的 root: ERROR 静默压掉**。 # # 原 jnpf-audit.yaml 中另一段(AUDIT_EVENT 的 Spring Cloud Stream binding 为何必须移除) # 已随降级链第一级 MQ 一并作废,其历史论证转记于 # docs/handoff-2026-08-11-audit-into-biz-common.md,不在配置文件里留存。 log: level: # 共享 logger.yaml 把 log.level.root 压到 ERROR;本文件在 application.yml 的 # spring.config.import 列表里排在 logger.yaml 之后,优先级更高,这里覆盖回 INFO # 让审计消费/落库链路的 log.info 可见,不动共享文件。 jnpf-biz-common: INFO config/shared/jnpf-dms.yaml
New file @@ -0,0 +1,18 @@ # DMS 专属共享配置入口。 dubbo: application: qos-enable: false dms: permission: admin-role-code: DMS.PERMISSION.ADMIN max-grant-batch-size: 500 max-decision-batch-size: 200 default-page-size: 20 max-page-size: 100 max-permission-group-scan: 1000 # 生产环境必须通过环境变量覆盖;游标签名用于拒绝篡改和跨用户、跨目录重放。 cursor-signing-secret: ${DMS_CURSOR_SIGNING_SECRET:jnpf-dms-local-cursor-signing-key-2026} # 必须显式填写实际调用方的 spring.application.name,不允许通配符。 resource-registration-service-ids: - jnpf-file config/shared/logger.yaml
New file @@ -0,0 +1,52 @@ logging: config: classpath:logback-spring.xml level: #自定义第三方包名日志等级 io.seata: ERROR org.apache.dubbo: ERROR # 🔴 启动成功日志的「防吞」开关(2026-08-11)。 # 背景:下面 log.level.root 是 ERROR,没有单独条目的服务(gateway / lims)root 就是 ERROR, # 启动类那句 log.info("xx启动成功") 会被静默吞掉——排障时看不到服务到底起没起来。 # 这里按**类**精确放行,而不是把整个服务开成 INFO:logback 中子 logger 级别覆盖 root, # 于是只有启动类这几行 INFO 能过,控制台仍保持干净(gateway 实测 11 行,含 Spring 自带的 # Starting/Started 两行——它们用的正是主类 logger,附带启动耗时,是净收益)。 # 新增服务时如果它在 log.level 里没有 INFO 条目,就往这里补一行。 jnpf.JnpfGatewayApplication: INFO jnpf.JnpfLimsApplication: INFO # 解除注释后Druid连接池打印SQL语句 druid.sql.Statement: debug # druid.sql.DataSource: debug # druid.sql.Connection: debug # druid.sql.ResultSet: debug org.redisson.cluster: ERROR log: level: root: ERROR # 🔴 平台聚合服务(2026-08-11)。logback 的 root level 取 log.level.${spring.application.name}, # 缺条目就回落到上面的 root: ERROR——那样连「启动成功」都不打印,排障等于瞎跑。 # 下面被注释的 9 个平台服务名(jnpf-system / jnpf-visualdev / jnpf-workflow …) # 在聚合后**全部失效**:它们的 spring.application.name 已统一为 jnpf-platform。 jnpf-platform: INFO #自定义服务的日志等级, 名称是服务中的spring.application.name: 等级 TRACE,DEBUG,INFO,WARN,ERROR # jnpf-oauth: ERROR # jnpf-system: ERROR # jnpf-lims: ERROR # jnpf-datareport: ERROR # jnpf-visualdev: ERROR jnpf-workflow: INFO # jnpf-file: ERROR # jnpf-tenant: ERROR # jnpf-message: ERROR # jnpf-scheduletask: ERROR # jnpf-permission: ERROR # jnpf-visualdata: ERROR # jnpf-app: ERROR # jnpf-gateway: ERROR # jnpf-eln: ERROR # 日志根参数化(2026-08-11):日志文件此前写在容器**可写层**且无挂载——容器一 recreate # 就全部清零(当天为改配置重建过 3 次,前两次日志全丢),且悄悄撑大 docker 数据盘 # (cx-platform 空闲 9 分钟就写了 227KB ≈ 36MB/天/服务)。 # 🔴 默认值必须保持相对路径 `log`:宿主栈(start-all.sh)就靠它把日志落在仓库工作目录下, # 改成绝对路径会让宿主栈往 / 底下写。容器栈通过 compose 的 JNPF_LOG_DIR=/data/jnpf-logs 覆盖。 # 路径里的 ${spring.application.name} 让所有服务共用一个挂载点也不会互相覆盖—— # 宿主侧自动长成 logs/jnpf-gateway/、logs/jnpf-platform/ …,compose 里只需一行 volumes。 path: ${JNPF_LOG_DIR:log}/${spring.application.name} config/shared/mq.yaml
New file @@ -0,0 +1,58 @@ # config/shared/mq.yaml —— 消息中间件配置(2026-08-10 起为空配置,保留文件) # # ══ 为什么是空的 ══ # 事件总线已由 RocketMQ 切到 Redis pub/sub,见 config/shared/system-config.yaml 的 # `event.event-publish-type: redis`。切换后: # - `defMsgToptic` / `ssoEventReceiver` 这两个 @Bean 位于 jnpf-common-mq 的 # MqAutoConfiguration$MqExistsdConfiguration,其 @ConditionalOnProperty 要求 # event-publish-type=mq,**redis 档下不再注册**; # - 于是原来的 `spring.cloud.function.definition` 与 `spring.cloud.stream.bindings` # 全部指向不存在的函数。Spring Cloud Stream 4.x 对缺失 Bean 只 WARN 跳过不报错, # 但**留着会让 RocketMQ binder 照样去连 namesrv**(消费者绑定先于函数解析建立), # 在没有 MQ 容器的环境里就是启动期无谓重连 + 日志噪音。故整体清空。 # # ══ 为什么不删这个文件 ══ # 14 个服务的 application.yml 都把它列在 `spring.config.import` 里,且是 # **非 optional 的 `file:`**(补丁 #8 的刻意设计:缺文件启动即失败,防静默半配置)。 # 删文件等于要同时改 14 处 import 列表,收益为零、回滚面变大。保留空文件是最小改动。 # # ══ 要改回 RocketMQ 怎么办 ══ # `git log --oneline -- config/shared/mq.yaml` 找到本次提交,revert 即可拿回完整的 # binder 配置(含 RabbitMQ / Kafka 的原厂注释样例);同时把 system-config.yaml 的 # event-publish-type 改回 mq、并恢复 docker-compose.yml 里的三个 mq 服务。 # # ══ 为什么还要显式写一个空的 function.definition ══ # 光清空 bindings **不够**。jnpf-common-mq 的 MqAutoConfiguration$MqExistsdConfiguration 里 # 三个 @Bean 的条件并不一致(2026-08-10 javap 实查,属 JNPF 上游的不一致): # defMsgToptic @Bean + @ConditionalOnProperty(event-publish-type=mq) ← 受门控 # ProjectEventMQSender @Bean + @ConditionalOnProperty(event-publish-type=mq) ← 受门控 # ssoEventReceiver @Bean + **仅 @ConditionalOnMissingBean** ← 不受门控! # 于是 redis 档下 ssoEventReceiver 这个 Consumer<Message<T>> 照样注册(类级条件只要求 # StreamBridge 在 classpath 上)。而 Spring Cloud Stream 4.x 在 function.definition 未设时 # 会**自动发现**函数式 bean 并为其建立入站绑定 → 去连 name-server(已被清空)→ # 实测每 30s 一次 `DefaultMQPushConsumer init failed ... connect to null failed`。 # 两条走不通的弯路(2026-08-10 都实测踩过,别再试): # ① `definition: ""` —— 空串被当成「未设置」,SCS 照样回退到自动发现。现象很有迷惑性: # cx-audit 干净而 cx-lims/visualdev/system/oauth 每 30s 报错一次,因为 audit 当时有两个 # 函数式 bean(ssoEventReceiver + auditEventReceiver),自动发现遇歧义会放弃; # 其余服务只有 ssoEventReceiver 一个,正好被自动绑上。 # (2026-08-11 起 auditEventReceiver 已随 MQ 消费者一并删除,audit 也只剩 ssoEventReceiver # 一个——**这条豁免不再成立**,autodetect: false 对它同样是必需的,别按上面的现象推断。) # ② `definition: <不存在的函数名>` —— 更糟:SCS **不会**因为函数 bean 不存在就跳过入站绑定, # 而是照样按这个名字建 `<占位名>-in-0` 绑定去连 broker。实测日志里出现 # `errorChannel '….eventBusDisabledPlaceholder-in-0.errors'`,报错照旧。 # (jnpf-audit.yaml 原注释里「缺失 Bean 的项框架 WARN 跳过」在入站绑定场景不成立。) # # 正解是关掉自动发现这件事本身:StreamFunctionProperties.autodetect。 spring: cloud: stream: function: autodetect: false # ⚠️ 审计事件(原 AUDIT_EVENT topic)与本文件已无关系: # 2026-08-11 起 AuditEventPublisher 的第一级 MQ(streamBridge.send("AUDIT_EVENT", …)) # 与服务端消费者 auditEventReceiver 一并移除,降级链现为 **Feign 同步 → 本地 spool 落盘** 两级。 # 移除理由:切 redis 后第一级每次必然失败再由 Feign 接管,它已不是降级级次、只是每条事件 # 多一次异常构造。仍在 audit-sender 守护线程池内执行,不阻塞业务线程。 # 详见 jnpf-biz-common/jnpf-audit-sdk/.../AuditEventPublisher.java。 config/shared/resources.yaml
New file @@ -0,0 +1,61 @@ config: TestVersion: false # 是否开启一个账号可多人登录 synchroLogin: false # 文件存储配置 file-storage: #文件存储配置,不使用的情况下可以不写 default-platform: local-plus-1 #默认使用的存储平台 thumbnail-suffix: ".min.jpg" #缩略图后缀,例如【.min.jpg】【.png】 local-plus: # 本地存储升级版 - platform: local-plus-1 # 存储平台标识 enable-storage: true #启用存储 enable-access: true #启用访问(线上请使用 Nginx 配置,效率更高) domain: "" # 访问域名,例如:“http://127.0.0.1:8030/”,注意后面要和 path-patterns 保持一致,“/”结尾,本地存储建议使用相对路径,方便后期更换域名 base-path: ./jnpf-resources/ # 基础路径 path-patterns: /** # 访问路径 storage-path: "${JNPF_FILE_DIR:/Users/Shared/}" # 存储根目录:默认/Users/Shared,各人可设环境变量JNPF_FILE_DIR覆盖;文件落在<根>/jnpf-resources/ aliyun-oss: # 阿里云 OSS ,不使用的情况下可以不写 - platform: aliyun-oss-1 # 存储平台标识 enable-storage: false # 启用存储 access-key: LTAI5tF3jr6GG8AoPnidv3xD secret-key: c0Y5d4JTL3fhHhHV12WRFk2BrYjP8a end-point: oss-cn-beijing.aliyuncs.com bucket-name: jnpfsoftoss domain: https://oss-cn-beijing.aliyuncs.com # 访问域名,注意“/”结尾,例如:https://abc.oss-cn-shanghai.aliyuncs.com/ base-path: # 基础路径 qiniu-kodo: # 七牛云 kodo ,不使用的情况下可以不写 - platform: qiniu-kodo-1 # 存储平台标识 enable-storage: false # 启用存储 access-key: arpc9Ds6-EwhDWu565hgvEWb4hfaQiiN4LPElkdi secret-key: T6auiKDFModKQUoF4GT8am6wNuer5cLO0jad6lss bucket-name: jnpfoss domain: http://rdr5xb0kp.bkt.clouddn.com # 访问域名,注意“/”结尾,例如:http://abc.hn-bkt.clouddn.com/ base-path: # 基础路径 tencent-cos: # 腾讯云 COS - platform: tencent-cos-1 # 存储平台标识 enable-storage: false # 启用存储 secret-id: ?? secret-key: ?? region: ?? #存仓库所在地域 bucket-name: ?? domain: ?? # 访问域名,注意“/”结尾,例如:https://abc.cos.ap-nanjing.myqcloud.com/ base-path: hy/ # 基础路径 minio: # MinIO,由于 MinIO SDK 支持 AWS S3,其它兼容 AWS S3 协议的存储平台也都可配置在这里 - platform: minio-1 # 存储平台标识 enable-storage: false # 启用存储 access-key: minioadmin secret-key: wNN8V4unEzVsIc5w end-point: http://192.168.0.207:9000/ #服务端地址 bucket-name: jnpfsoftoss domain: # 访问域名,注意“/”结尾,例如:http://minio.abc.com/abc/ base-path: # 基础路径 seaweedfs: # SeaweedFS 使用 S3 协议,底层复用现有 MinIO SDK,但与 minio 配置完全隔离 - platform: seaweedfs-1 # DMS 专用存储平台标识,不改变 default-platform enable-storage: ${SEAWEEDFS_ENABLED:false} access-key: ${SEAWEEDFS_ACCESS_KEY:minioadmin} secret-key: ${SEAWEEDFS_SECRET_KEY:wNN8V4unEzVsIc5w} end-point: ${SEAWEEDFS_ENDPOINT:http://192.168.0.207:9000/} bucket-name: ${SEAWEEDFS_BUCKET_NAME:jnpfsoftoss} domain: # 访问域名,注意“/”结尾,例如:http://minio.abc.com/abc/ base-path: ${SEAWEEDFS_BASE_PATH:dms/} config/shared/router.yaml
New file @@ -0,0 +1,202 @@ spring: cloud: gateway: routes: # 认证中心 - id: jnpf-oauth uri: lb://jnpf-oauth predicates: - Path=/api/oauth/** filters: - StripPrefix=2 # 系统基础服务 - id: jnpf-system uri: lb://jnpf-system predicates: - Path=/api/system/** filters: - StripPrefix=2 # lims 业务服务 - id: jnpf-lims uri: lb://jnpf-lims predicates: - Path=/api/lims/** filters: - StripPrefix=2 # lims 兼容路由:前端 dist 与 DB 数据接口仍用 /api/extend/**(69 处硬编码), # 退役条件见 docs/superpowers/specs/2026-07-18-eln-module-design.md §7 - id: jnpf-lims-legacy uri: lb://jnpf-lims predicates: - Path=/api/extend/** filters: - StripPrefix=2 # eln 业务服务(空壳占位,profile 起用) - id: jnpf-eln uri: lb://jnpf-eln predicates: - Path=/api/eln/** filters: - StripPrefix=2 # 审计追踪(2026-08-11 搬入 jnpf-biz-common,cx-audit 退役)。 # **路由与前缀刻意保持不变**:uri 直接指向 biz-common,/api/audit/** 继续可用, # 前端与 42 个回归脚本零改动。审计能力因此有两条入口——本条与下面的 /api/biz/**, # 都落到同一个服务,不冲突。 - id: jnpf-audit uri: lb://jnpf-biz-common predicates: - Path=/api/audit/** filters: - StripPrefix=2 # 公共业务能力聚合服务(统一前缀 /api/biz/**:新增能力零网关改动, # 形如 /api/biz/onlyoffice/xxx → StripPrefix=2 → 服务内 /onlyoffice/xxx) - id: jnpf-biz-common uri: lb://jnpf-biz-common predicates: - Path=/api/biz/** filters: - StripPrefix=2 # DMS 文档管理服务 - id: jnpf-dms uri: lb://jnpf-dms predicates: - Path=/api/dms/** filters: - StripPrefix=2 # 报表服务 - id: jnpf-datareport uri: lb://jnpf-datareport predicates: - Path=/api/datareport/** filters: - StripPrefix=2 # 🔴 表单外链专用路由(2026-08-11 平台聚合)。**必须排在下面 jnpf-visualdev 通用路由之前** # ——网关按配置顺序取第一个匹配项,放到后面就永远匹配不到。 # 起因:聚合后 visualdev 的 ShortLinkController 与 message 的在同一 JVM 内路径相撞 #(详见 VisualdevShortLinkController 类注释),故把它的内部路径改名为 /VisualdevShortLink。 # 这里用 RewritePath 原样重写,保证**对外路径 /api/visualdev/ShortLink/** 一字不变**, # 已生成并发出去的表单外链继续有效。正则里 (?<segment>/?.*) 的可选斜杠是为了让 # PUT /api/visualdev/ShortLink(无尾段)也能正确重写成 /VisualdevShortLink。 - id: jnpf-visualdev-shortlink uri: lb://jnpf-visualdev predicates: - Path=/api/visualdev/ShortLink/** filters: - RewritePath=/api/visualdev/ShortLink(?<segment>/?.*), /VisualdevShortLink$\{segment} # 可视化开发服务 - id: jnpf-visualdev uri: lb://jnpf-visualdev predicates: - Path=/api/visualdev/** filters: - StripPrefix=2 # 工作流服务 - id: jnpf-workflow uri: lb://jnpf-workflow predicates: - Path=/api/workflow/** filters: - StripPrefix=2 # 文件服务 - id: jnpf-file uri: lb://jnpf-file predicates: - Path=/api/file/** filters: - StripPrefix=2 # 多租户 - id: jnpf-tenant uri: lb://jnpf-tenant predicates: - Path=/api/tenant/** filters: - StripPrefix=2 # 消息 - id: jnpf-message uri: lb://jnpf-message predicates: - Path=/api/message/** filters: - StripPrefix=2 # 任务调度 - id: jnpf-scheduletask uri: lb://jnpf-scheduletask predicates: - Path=/api/scheduletask/** filters: - StripPrefix=2 # 权限 - id: jnpf-permission uri: lb://jnpf-permission predicates: - Path=/api/permission/** filters: - StripPrefix=2 # 大屏 - id: jnpf-visualdata uri: lb://jnpf-visualdata predicates: - Path=/api/blade-visual/** filters: - StripPrefix=2 # app - id: jnpf-app uri: lb://jnpf-app predicates: - Path=/api/app/** filters: - StripPrefix=2 # 工作流表单 - id: jnpf-flowForm uri: lb://jnpf-flowForm predicates: - Path=/api/flowForm/** filters: - StripPrefix=2 #接口放行地址 与GatewayWhite中的默认URL合并 gateway: #禁止访问 block-url: # 审计写端点 /Internal/** 只供服务间 Feign 调用(直连 lb://jnpf-audit,不经网关); # 经网关的外部请求一律 AD101 禁止访问,堵任意登录用户伪造投递审计事件(append-only 完整性)。 # AuthFilter 的 block 检查在鉴权链最前、匹配的是 StripPrefix 前原始路径 /api/audit/Internal/**。 # 服务侧 AuditInternalController 另有 SA-SAME-TOKEN 校验兜底(双保险,见该类注释)。 - /api/audit/Internal/** # Discovery Locator 自动路由标准路径:bootstrap-dev.yml discovery.locator.enabled=true + # lowerCaseServiceId=true 会为每个注册服务生成 /jnpf-audit/**,绕过上面的 /api/audit/** 前缀 → # 补堵其 /Internal 写端点标准路径(小写服务名,与 lowerCaseServiceId 生成形态一致)。 # 大写变体 /JNPF-AUDIT/Internal/** 实测在下游 Spring MVC 因路径大小写敏感 404(不达 record()), # 双斜杠变体经平台"非法请求"过滤器拦截,均不落库;服务侧 SAME-TOKEN 是权威边界,此处只覆盖数据面标准路径。 - /jnpf-audit/Internal/** # DMS Internal 端点仅允许服务间直连;服务侧仍必须校验 SA-SAME-TOKEN。 - /api/dms/Internal/** - /jnpf-dms/Internal/** # 🔴 2026-08-11 audit 搬入 jnpf-biz-common 后**新增的两条到达路径**,缺一条就是绕过缺口: # · /api/biz/Internal/** —— biz-common 的对外前缀 /api/biz/** 同样 StripPrefix=2, # `/api/biz/Internal/audit/events` 去掉两段后正是服务内的 `/Internal/audit/events`; # · /jnpf-biz-common/Internal/** —— Discovery Locator 按新服务名生成的标准路径 # (原 /jnpf-audit/** 那条仍需保留:discovery.yaml 里 jnpf-audit 作为别名依然注册)。 # 设计文档 2026-08-07-biz-common-design.md §4.2 预先写明了"出现 Internal 端点必须同时加两条", # 这里是它第一次真正生效。 - /api/biz/Internal/** - /jnpf-biz-common/Internal/** #不验证Token, 记录访问 white-url: # OnlyOffice 编辑保存回调(平台补丁 #9):DS 既不是 JNPF 服务(注入不了 SA-SAME-TOKEN) # 也无登录态,直连服务端口按 L042 必被 inner-auth 拒,故只能经网关且必须放行。 # 放行不等于裸奔——转发阶段的 MvcSecurityConfig#getMyHeadersFilter 与白名单相互独立, # 仍会注入 INNER_GATEWAY_TOKEN;真正的安全边界是服务侧对 OnlyOffice 回调 JWT 的验签。 # ⚠️ 只放行 callback 这一条精确路径,不可扩成 /api/biz/onlyoffice/** # (config 端点会下发 securityKey,必须保持登录态保护)。 - /api/biz/onlyoffice/callback # ELN 模板填写数据:无登录 token,安全边界是端点内部的 8 小时专用 Ticket 校验。 - /api/eln/onlyoffice/template-fill/data #- /api/message/Notice #- /api/permission/Users/* #放行不记录 exclude-url: #禁止访问地址的白名单IP white-ip: #- 192.168 config/shared/socials.yaml
New file @@ -0,0 +1,27 @@ socials: # 第三方登录功能开关(false-关闭,true-开启) socials-enabled: false config: - # 微信 provider: wechat_open client-id: your-client-id client-secret: your-client-secret - # 企业微信 provider: wechat_enterprise client-id: your-client-id client-secret: your-client-secret agentId: your-agentId - # 钉钉 provider: dingtalk client-id: your-client-id client-secret: your-client-secret agentId: your-agentId - # 飞书 provider: feishu client-id: your-client-id client-secret: your-client-secret - # 小程序 provider: wechat_applets client-id: your-client-id client-secret: your-client-secret config/shared/sso.yaml
New file @@ -0,0 +1,54 @@ oauth: #启用单点登录, 普通登录不可用 ssoEnabled: false #轮询票据有效期 ticketTimeout: 120 #默认单点登录协议 defaultSSO: cas #后端登录接口地址 loginPath: http://127.0.0.1:30000/api/oauth/Login #login: #JWT生成秘钥 不填写为默认值 #jwtSecretKey: WviMjFNC72VKwGqm5LPoheQo5XN9iN4d sso: #单点登录系统地址 baseUrl: http://127.0.0.1:8527 #登录成功后跳转到前端的页面 sucessFrontUrl: http://127.0.0.1:3100/sso #logoutFrontUrl: http://sso.maxkey.top:8527/maxkey #错误信息是否输出到页面 ticketOutMessage: false #单点注销后端接口地址, 配置启用后JNPF退出会请求单点系统退出, 触发单点注销退出全部应用 #ssoLogoutApiUrl: ${oauth.sso.baseUrl}/sign/logout auth2: enabled: true clientId: 747887288041603072 clientSecret: MYgMMjIwNzIwMjIxNTU4MTAxNzQlKQ baseUrl: ${oauth.sso.baseUrl} authorizeUrl: ${oauth.sso.auth2.baseUrl}/sign/authz/oauth/v20/authorize accessTokenUrl: ${oauth.sso.auth2.baseUrl}/sign/authz/oauth/v20/token userInfoUrl: ${oauth.sso.auth2.baseUrl}/sign/api/oauth/v20/me cas: enabled: true baseUrl: ${oauth.sso.baseUrl} serverLoginUrl: ${oauth.sso.cas.baseUrl}/sign/authz/cas/login serverValidateUrl: ${oauth.sso.cas.baseUrl}/sign/authz/cas jnpf: sso: # ===================== 单点登录(用户信息拉取)配置-Start ===================== connector: # 是否开启用户信息拉取 enabled: false # ===================== 单点登录(用户信息拉取)配置-End ===================== # ===================== 单点登录(用户信息推送)配置-Start ===================== pull: # 是否开启用户信息推送 enabled: false create-rest-address: http://localhost:9526/sso-mgt-api/api/idm/Account replace-rest-address: http://localhost:9526/sso-mgt-api/api/idm/Account change-password-rest-address: http://localhost:9526/sso-mgt-api/api/idm/Account/changePassword delete-rest-address: http://localhost:9526/sso-mgt-api/api/idm/Account credential-type: Basic user-name: 747887288041603072 password: MYgMMjIwNzIwMjIxNTU4MTAxNzQlKQ # ===================== 单点登录(用户信息推送)配置-End ===================== config/shared/system-config.yaml
New file @@ -0,0 +1,161 @@ config: # ===============域名配置================== #后端域名带端口(消息跳转、第三方登录回调、工作流表单数据流转使用) ApiDomain: ${JNPF_API_DOMAIN:http://cx-infra:30000} #前端域名带端口(消息跳转使用) FrontDomain: ${JNPF_FRONT_DOMAIN:http://localhost/} #H5域名带端口(消息跳转使用) AppDomain: ${JNPF_APP_DOMAIN:http://localhost/} #工作流域名(工作流使用) FlowDomain: ${JNPF_FLOW_DOMAIN:http://cx-flow-engine:31000} # ===============多租户配置================== MultiTenancy: false MultiTenancyUrl: http://127.0.0.1:30006/api/tenant/DbName/ # ===============静态资源目录映射================== WebAnnexFilePath: WebAnnexFile DataBackupFilePath: DataBackupFile TemporaryFilePath: TemporaryFile SystemFilePath: SystemFile TemplateFilePath: TemplateFile EmailFilePath: EmailFile DocumentFilePath: DocumentFile DocumentPreviewPath: DocumentPreview UserAvatarFilePath: UserAvatar IMContentFilePath: IMContentFile MPMaterialFilePath: MPMaterial TemplateCodePath: TemplateCode BiVisualPath: BiVisualPath # ===============功能格式限制================== MPUploadFileType: bmp,png,jpeg,jpg,gif,mp3,wma,wav,amr,mp4 WeChatUploadFileType: jpg,png,doc,docx,ppt,pptx,xls,xlsx,pdf,txt,rar,zip,csv,amr,mp4 AllowUploadImageType: jpg,gif,png,bmp,jpeg,tiff,psd,swf,svg,pcx,dxf,wmf,emf,lic,eps,tga AllowUploadFileType: jpg,gif,png,bmp,jpeg,doc,docx,ppt,pptx,xls,xlsx,pdf,txt,rar,zip,csv AllowPreviewFileType: doc,docx,xls,xlsx,ppt,pptx,pdf,txt #允许预览文件类型 PreviewType: kkfile #文件预览方式 (1.yozo 2.kkfile)默认使用kk kkFileUrl: http://localhost:8012/ #kk文件预览服务地址(本地需自行启动 kkFileView) # 代码生成器命名空间 CodeAreasName: example WebDirectoryPath: # ===============系统错误邮件报告反馈相关================== SoftName: JNPF.JAVA SoftFullName: JNPF软件开发平台 AppVersion: V6.0.0 AppUpdateContent: ; # ===================================== RecordLog: true ErrorReport: false ErrorReportTo: surrpot@yinmaisoft.com IgexinEnabled: true IgexinAppid: HLFY9T2d1z7MySY8hwGwh4 IgexinAppkey: 6Uiduugq648YDChhCjAt59 IgexinMastersecret: pEyQm156SJ9iS7PbyjLCZ6 SoftVersion: V6.0.0 AccessKeyId: LTAI4Fu4iJnKmDu5QG78ibfa AccessKeySecret: pMSYlgOz3Tm6sYqWA8sKiyxgNY97J7 # ===============跨域配置================== Origins: http://127.0.0.1 Methods: GET,HEAD,POST,PUT,DELETE,OPTIONS # =========永中配置文件========= YozoAppId: yozoAgR41jgC0062 YozoAppKey: fc3134a9ba8bc6f4c69d635f9adf YozoCloudDomain: //dmc.yozocloud.cn YozoDomain: //dcsapi.com/ YozoDomainKey: 57462250284462899344489 YozoEditDomain: //eic.yozocloud.cn # ==================== ApacheShardingSphere 配置开关 ===================== sharding-sphere-enabled: false #===================== 系统功能配置 ===================== #是否开启逻辑删除 EnableLogicDelete: true # 秒 CodeCertificateTimeout: 180 #检查上传的PDF文件安全 check-file-pdf: false #是否开启身份管理功能 standingSwitch: true spring: messages: # 国际化语言文件名配置, 配合当前命名空间下的对应语言文件 message_zh_CN.properties 等等 basename: message mvc: #csrf-origins: ${config.FrontDomain} cors: # 允许跨域的域名 #allowed-origins: ${config.FrontDomain} # 允许跨域的域名正则匹配 如需全部匹配设置为 '**', 因为包含凭据的请求不允许直接写* 这个写法可以直接返回当前请求域名 allowed-origin-patterns: '**' # 允许跨域的请求方法 GET, POST, PUT, DELETE allowed-methods: "*" # 允许跨域的头部信息 Content-Type, Authorization allowed-headers: "*" options-max-age: 18000 headers: server-name: '' x-frame-options: sameorigin x-content-type-options: disabled x-xss-protection: enabled_mode_block security: # AES加密秘钥 security-key: EY8WePvjM5GGwQzn # 是否开启接口鉴权 enable-pre-auth: true # 接口加密 enable-rest-encrypt: true # 请求来源鉴权 enable-inner-auth: true # 事件配置 event: # 事件总线实现:local(进程内)/ redis(pub/sub)/ mq(Spring Cloud Stream binder)。 # # 2026-08-10 由 mq 切 redis(去 RocketMQ,省 1.14 GiB / 2 容器 / 2 JVM)。 # 这是 JNPF 官方的可插拔点,不是自研改造: # redis 档 → jnpf-common-event 的 ProjectEventRedisSender + ProjectEventRedisMessageHandler # mq 档 → jnpf-common-mq 的 ProjectEventMQSender + defMsgToptic/ssoEventReceiver # 两组 @Bean 的 @ConditionalOnProperty(prefix=event, name=event-publish-type) 完全对称, # 且**无 matchIfMissing**——本项必须显式设值,留空则两组都不注册、事件总线整个哑掉。 # # 业务代码零改动:发布方一律走 PublishEventUtil.publish + ProjectEventSender 接口, # 消费方一律 @ProjectEventListener,与具体实现解耦。 event-publish-type: redis # Redis 监听模式:current / all。 # 🔴 **它不是隔离开关**(2026-08-10 字节码实查,反直觉,别照字面理解): # all → channel = "myevent_" + <事件channel> # current → channel = "lock:" + "myevent_" + <事件channel> # (前缀取自 RedisConst.getRedisLock4JPrefix(),是**硬编码常量 "lock:"**, # 不含实例/租户/应用标识) # 发送端 ProjectEventRedisSender.send 与订阅端 ProjectEventRedisMessageHandler 构造器 # 用同一个值推导同一前缀,故两档都自洽;差别仅是 channel 名多不多 "lock:"。 # 真正的隔离靠「每客户一个 Redis 实例」——连同一实例的多台开发机会互相收到事件 # (Redis pub/sub 还是**跨 db 全局**的,分 database 也隔离不了)。 redis-publish-type: current # 将配置设置到系统属性中 conf2system: # sentinel日志目录 csp.sentinel.log.dir: log/${spring.application.name}/sentinel # sentinel日志等级 LOGGING日志等级, ALL, INFO, WARNING, SEVERE, OFF csp.sentinel.log.level: "OFF" # (原 rocketmq.log.level 已随 RocketMQ 于 2026-08-10 移除,见补丁 #10) # 在线表单、流程表单字段权限豁免。主表填写 vModel,子表填写 parentVModel-vModel。 # 配置全局生效;这里只跳过字段权限检查,其他表单校验和系统字段保护仍按原逻辑执行。 jnpf: visualdev: permission: exempt-fields: - biz_sign docker-compose.override.yml
New file @@ -0,0 +1,141 @@ # 本地开发覆盖(Backlog A 端口收敛后基线只发布 gateway 30000;补丁 #8 追加 config/shared 挂载)。 # # 用途: # 1. 宿主侧调试入口:psql/redis-cli/mqadmin/curl 业务端口、引擎契约测试脚本 # 2. 补丁 #8:16 个业务服务只读挂载 ./config/shared:/config/shared:ro,落实 # config/README.md 与 config/shared/audit-layer0-forms.yaml 里"开发 override 已挂载 # config/shared,改完重启生效"的承诺——改配置文件后 `docker compose restart <服务>` # 即生效,不必重建镜像;镜像内 COPY 的同份配置作交付/无挂载场景的自包含兜底 # # 规则: # - 端口全部绑 127.0.0.1,本机可用、不暴露局域网 # - docker compose 自动合并本文件(无需 -f);**交付包不含本文件**(assemble-offline-package.sh # 只 cp docker-compose.yml + .env.example),交付环境数据面改配置中心服务名直连 # (见 docs/release-checklist.md §0),无需这些端口,也不受下方 profiles 门控影响 # - 需要临时对局域网开放某端口(如手机联调)时,改本文件对应行去掉 127.0.0.1: 前缀 # - ports/volumes 合并都是数组拼接(非按 key 覆盖):同一服务的同一端口映射/同一挂载点 # 不要在两个文件里重复写,否则 docker 会重复绑定同一宿主端口 / 重复挂载而报错 # - ⚠️ !override / !reset 需要 docker compose v2.24+ # 补丁 #8:单一锚点,16 个业务服务共用,改挂载源只改这一处 x-config-shared-volume: &config-shared-volume - ./config/shared:/config/shared:ro # 本机 .env 的 CUSTOMER_DB_HOST/JNPF_REDIS_HOST 已指 CVM(YOUR_DB_HOST:15432 / :16432), # 栈内 cx-postgres / cx-redis 没有任何消费者。但基线 x-cx 锚点对两者是 service_healthy 强依赖, # depends_on 会隐式把它们拉起来——白占内存,且 cx/postgres 是 amd64 镜像,本机走 Rosetta 最吃 CPU。 # 故:① profiles 门控让它们不随 up -d 启动;② 清空业务服务的本地数据面依赖。 # # 回切本机沙盒档(.env 抄 .env.local,坐标改回 cx-postgres:5432)时二选一: # docker compose --profile local-db --profile local-redis up -d # 或 .env 里设 COMPOSE_PROFILES=local-db,local-redis # PG 数据卷 pgdata 保留未删,拉回即用。docker/preflight.sh 的自愈路径(显式 # `docker compose up -d cx-postgres`)仍有效——显式点名服务会自动激活其 profile。 # (PG/Redis 在 CVM,MQ 已删)。用 !reset 而非 !override 清空,与下方 cx-flow-engine 同款写法。 # ⚠️ 这里必须跟着基线一起改:本锚点是 !override/!reset 语义,会**整体替换** # docker-compose.yml 里 x-cx 的 depends_on——只改基线不改这里,改动会被这里覆盖回去。 x-no-local-data-plane: &no-local-data-plane depends_on: !override {} services: cx-postgres: profiles: [local-db] ports: # 本机 Homebrew postgresql@18 占 5432(127.0.0.1 精确绑定优先,L016),沿用 5433 - "127.0.0.1:5433:5432" cx-redis: profiles: [local-redis] ports: - "127.0.0.1:6379:6379" # cx-mq-namesrv / cx-mq-broker 的调试端口发布已随 RocketMQ 一并移除 cx-flow-engine: # 引擎库同样在 CVM(FLOW_DB_URL 由 CUSTOMER_DB_HOST/PORT 推导),本地无依赖 depends_on: !reset {} ports: # 引擎测试脚本入口(cx-flow-engine/test/*.sh http://127.0.0.1:31000)+ 本地 FlowDomain hairpin - "127.0.0.1:31000:31000" cx-onlyoffice: ports: # 浏览器直连 DS 载入 api.js 并渲染编辑器(前端 VITE_GLOB_ONLYOFFICE_URL 指向这里)。 # 与其他调试端口不同,这条是**功能必需**而非仅供调试:DS 的编辑器前端资源由浏览器 # 直接加载,不经网关。交付环境改为 Nginx 反代 https://<域名>/onlyoffice/(见方案 §8.4)。 - "127.0.0.1:20000:80" # 以下业务服务:<<: *no-local-data-plane 清空 depends_on # 平台聚合后默认后端为 gateway/platform/biz-common/lims/dms;eln/scheduletask 按 profile 启用。 # (PG/Redis 在 CVM,MQ 已于 2026-08-10 移除,本地已无任何数据面依赖可等) cx-gateway: <<: *no-local-data-plane volumes: *config-shared-volume # 平台聚合服务(2026-08-11):取代原 cx-oauth / cx-system / cx-visualdev / cx-flowable / # cx-file / cx-message / cx-permission / cx-visualdata / cx-app 九段。 # 只发布 30002 一个调试口——九个模块同进程,原来那 8 个端口已无对应监听。 # 回滚时把下面被注释的九段放回来(与 docker-compose.yml 的注释段一一对应)。 cx-platform: <<: *no-local-data-plane ports: ["127.0.0.1:30002:30002"] volumes: *config-shared-volume # ── 以下九段随平台聚合停用,保留以便回滚 ── # cx-oauth: # <<: *no-local-data-plane # ports: ["127.0.0.1:30001:30001"] # volumes: *config-shared-volume # cx-system: # <<: *no-local-data-plane # ports: ["127.0.0.1:30002:30002"] # volumes: *config-shared-volume # cx-visualdev: # <<: *no-local-data-plane # ports: ["127.0.0.1:30003:30003"] # volumes: *config-shared-volume # cx-flowable: # <<: *no-local-data-plane # ports: ["127.0.0.1:30004:30004"] # volumes: *config-shared-volume # cx-file: # <<: *no-local-data-plane # ports: ["127.0.0.1:30005:30005"] # volumes: *config-shared-volume # cx-message: # <<: *no-local-data-plane # ports: ["127.0.0.1:30008:30008"] # volumes: *config-shared-volume # cx-permission: # <<: *no-local-data-plane # ports: ["127.0.0.1:30010:30010"] # volumes: *config-shared-volume cx-lims: <<: *no-local-data-plane ports: ["127.0.0.1:30019:30019"] volumes: *config-shared-volume cx-eln: <<: *no-local-data-plane profiles: ["eln"] ports: ["127.0.0.1:30013:30013"] volumes: *config-shared-volume cx-biz-common: <<: *no-local-data-plane ports: ["127.0.0.1:30015:30015"] volumes: *config-shared-volume cx-dms: <<: *no-local-data-plane ports: ["127.0.0.1:30016:30016"] volumes: *config-shared-volume # cx-visualdata 随平台聚合停用,保留以便回滚 # cx-visualdata: # <<: *no-local-data-plane # ports: ["127.0.0.1:30011:30011"] # volumes: *config-shared-volume cx-scheduletask: <<: *no-local-data-plane ports: ["127.0.0.1:30009:30009"] volumes: *config-shared-volume # cx-app 随平台聚合停用,保留以便回滚 # cx-app: # <<: *no-local-data-plane # ports: ["127.0.0.1:30012:30012"] # volumes: *config-shared-volume docker-compose.testenv.yml
New file @@ -0,0 +1,73 @@ # cx-postgres 与 cx-redis 都退出测试环境栈: # - profiles 门控让它们不随 docker compose up -d 启动。PG 数据卷保留, # 要临时拉回:docker compose --profile local-db up -d cx-postgres # (redis 同理 --profile local-redis) # - 业务服务的 depends_on 用 !override 清空(PG/Redis 在 CVM;MQ 已于 2026-08-10 退栈)—— # 不改的话基线对 cx-postgres/cx-redis 的 service_healthy 依赖会在每次 up # 时把它们拉起来 # - 移除旧拓扑的 3000->5432 端口发布(「本机=正式库、开发机 VPN 直连 3000」 # 时代的产物,见 docs/adr-single-db-topology.md(已过时);0.0.0.0 暴露 + # 出厂密码是安全隐患)。Windows 防火墙的 cx-pg-3000 放行规则可一并删除。 # # 启用方式:服务器 .env 设 COMPOSE_FILE=docker-compose.yml;docker-compose.testenv.yml # (Windows 下 compose 路径分隔符默认是分号)。交付基线 / 本地开发均不引用本文件。 # ⚠️ !override / !reset 需要 docker compose v2.24+。 services: cx-postgres: profiles: [local-db] # 测试环境 Redis 也在 CVM(cx-redis-customer1:16432),本机实例同样退栈 cx-redis: profiles: [local-redis] # 引擎库同样在 CVM(FLOW_DB_URL 由 CUSTOMER_DB_HOST/PORT 推导),本地无依赖 cx-flow-engine: depends_on: !reset {} # 2026-08-19:DS 编辑器端口。基线不发布任何 DS 端口,开发机靠 docker-compose.override.yml # 的 "127.0.0.1:20000:80"——但**测试环境不加载 override**(本文件顶部的 COMPOSE_FILE 只串 # docker-compose.yml + 本文件),所以那条对这里不生效,表现就是 `compose ps` 只见 # 80/tcp、443/tcp 而无宿主映射。 # # 🔴 与 override 的关键差异:这里**不能**绑 127.0.0.1。DS 的编辑器前端资源(api.js 及 # 其后续静态件)是由**测试人员自己电脑上的浏览器**直接加载的,不经网关也不经 cx-web; # 绑 127.0.0.1 就只有登到这台 server 上开浏览器才打得开。故按 0.0.0.0 发布,与基线 # 已经这么发布的 80 / 30000 一致(都是测试环境刻意对局域网开放的口)。 # # 配套:服务器 .env 必须设 ONLYOFFICE_DS_PUBLIC_URL=http://<本机局域网IP>:20000 # (浏览器视角的地址,默认值 http://localhost:20000 里的 localhost 指的是浏览器所在的 # 那台机器,在测试环境是错的)。另两个 DS 视角的键保持默认即可——DS 在栈内, # cx-gateway:30000 解析得到。 cx-onlyoffice: ports: - "20000:80" # 业务服务两件事(锚点复用): # ① 清空 depends_on:本地 cx-postgres / cx-redis 已退栈,namesrv 随 RocketMQ 一并移除; # ② 挂载 config/shared(与开发 override 同款「配置跟仓库走」形态)——不挂载的话 # 容器读的是构建镜像时烧入的旧配置,git pull 下来的 datasource.yaml 等对容器 # 不可见(2026-08-08 实踩:redis 坐标参数化后测试环境仍连 cx-redis,因旧镜像内 # yaml 无占位符)。挂载与基线的 jnpf-resources 卷按 target 合并,互不覆盖。 cx-gateway: &no-local-pg # 2026-08-10 事件总线切 Redis、RocketMQ 退栈后,本地数据面依赖归零(PG/Redis 在 CVM) depends_on: !override {} volumes: - ./config/shared:/config/shared:ro # 2026-08-11 平台聚合:cx-oauth / cx-system / cx-visualdev / cx-flowable / cx-file / # cx-message / cx-permission / cx-visualdata / cx-app 九个已合并为 cx-platform。 # 回滚时把下面注释的九行放回来(与 docker-compose.yml 的注释段一一对应)。 cx-platform: *no-local-pg # cx-oauth: *no-local-pg # cx-system: *no-local-pg # cx-visualdev: *no-local-pg # cx-flowable: *no-local-pg # cx-file: *no-local-pg # cx-message: *no-local-pg # cx-permission: *no-local-pg # cx-visualdata: *no-local-pg # cx-app: *no-local-pg cx-lims: *no-local-pg cx-eln: *no-local-pg cx-biz-common: *no-local-pg cx-dms: *no-local-pg cx-scheduletask: *no-local-pg docker-compose.yml
New file @@ -0,0 +1,643 @@ # JNPF 全栈一键启动 = 微服务 + 基础设施(Redis/PG)+ 前端 cx-web,本地/服务器同一份编排 # (RocketMQ 已于 2026-08-10 移除,事件总线改 Redis pub/sub,见补丁 #10) # # 地址拓扑三层规则(详见 docs/docker-compose-guide.md): # 配置/发现(补丁 #8 起):镜像烧入 config/shared 静态配置 + SimpleDiscoveryClient 静态实例列表, # 无独立注册/配置中心容器;Dubbo LogProvider 改 registry-less 直连 dubbo://cx-platform:20880(原 cx-system,2026-08-11 聚合) # 数据面(业务→PG/Redis):本地与交付一致=服务名直连 # cx-postgres:5432 等(release-checklist §0.10) # 对外宣告(ApiDomain):cx-infra(浏览器+后端双消费方;本地 hosts + 容器 extra_hosts 双侧解析) # # 前提: # 1. 宿主 /etc/hosts 含「127.0.0.1 cx-infra」(本机代理软件须加 cx-infra 直连规则,L026) # 2. 构建前先打包:mvn clean package -DskipTests # 3. 库在哪由 .env 的 CUSTOMER_DB_HOST 决定,两种形态: # 远端客户库(开发机/测试环境,CUSTOMER_DB_HOST=YOUR_DB_HOST):本机不起 PG,什么都不用准备 # 栈内 PG(交付/正式,CUSTOMER_DB_HOST=cx-postgres):.env 设 COMPOSE_PROFILES=localdb, # 且全新机器先 docker volume create postgres_pgdata 并灌数据(见 docker/postgres/migration/) # 4. 宿主上不能有同端口进程(旧 nohup 服务 / Homebrew redis 均须停) # # 基础设施镜像统一 linux/amd64 + 华为云 SWR 源(swr.cn-north-4.myhuaweicloud.com/ddn-k8s/...): # 本地 Apple Silicon 经 Rosetta 跑,x86 服务器原生;一份 compose 两侧零差异 # # ⚠️ 第三方基础设施镜像在本文件里用**短名**(cx-redis 等), # 它们是 `make pull-infra` 从上述 SWR 长名 retag 出来的本地别名(docker tag 只挂别名、 # 不复制数据)。**因此裸 `docker compose pull` 拉不到它们**——本地没有这些 tag 时 # Docker 会去 docker.io 找不存在的 library/cx-redis。起栈一律走 `make up` # (其 images 目标已含 pull-infra)或离线包的 `make load`。preflight 会在 up 前拦截缺失。 # # 端口策略(Backlog A 安全加固,2026-07-17;Backlog F 增 80): # 本文件=交付基线,发布 cx-web 80(页面入口)+ gateway 30000(API/ApiDomain 直连口); # 其余端口(PG/Redis/引擎/业务调试口)在 docker-compose.override.yml(本地开发用,compose 自动合并,交付包不含)。 # 交付环境数据面服务名直连(见 docs/release-checklist.md §0),不依赖 hairpin 发布端口。 # # 凭据策略:密码/密钥一律经 .env 注入(${VAR:?} 必填,缺失时 compose 直接报错),仓库只留 .env.example 占位。 # # 用法: # cp .env.example .env # 首次;本地默认值可直接用,交付必须改值 # docker compose build && docker compose up -d # 冷启动自动串行:PG → 业务 # docker compose --profile extra up -d # 附带 scheduletask(app 已并入 cx-platform) name: cx # 公共环境变量(单独成锚点,便于某服务只覆盖个别变量而不丢其余: # environment: # <<: *cx-env # JAVA_TOOL_OPTIONS: -Xms512m -Xmx1536m # ) x-cx-env: &cx-env # 官方 Dockerfile ENTRYPOINT 无 $JAVA_OPTS 透传,堆参数走 JVM 自动拾取的 JAVA_TOOL_OPTIONS # stdout/stderr.encoding(2026-08-11):JDK 19+ 起 System.out 的编码由 stdout.encoding 决定, # 与 Dockerfile 里已有的 -Dfile.encoding=utf8 **无关**;rocky 基础镜像没设 locale # (native encoding = ANSI_X3.4-1968),于是中文 println 逐字变 `?`——gateway 的 # 「网关启动成功」在日志里就是那行 `??????`。走 JAVA_TOOL_OPTIONS 而不是改 Dockerfile, # 是为了零重建、且一处覆盖全部服务。 JAVA_TOOL_OPTIONS: -Xms256m -Xmx768m -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 # Spring Boot 的 ASCII banner:容器日志里没人看,白占 8 行。 # 🔴 引号不能省:YAML 1.1 里裸 off 会被解析成布尔 false,compose 拒绝布尔型 environment 值。 SPRING_MAIN_BANNER_MODE: "off" # 日志落盘根目录(2026-08-11):config/shared/logger.yaml 的 log.path 占位符消费, # 与下方 x-cx 的 volumes 那行**成对**——改一个必须改另一个,否则日志又回到容器可写层。 # 各服务自动写 /data/jnpf-logs/<spring.application.name>/ 子目录,故一个挂载点够全部服务用。 JNPF_LOG_DIR: /data/jnpf-logs # resources.yaml: storage-path ${JNPF_FILE_DIR:/Users/Shared/}(容器内路径), # 文件落 <根>/jnpf-resources/ JNPF_FILE_DIR: /data/jnpf-files/ # 补丁 #8:域名四键(system-config.yaml 占位符消费;默认值即本机开发形态,交付在 .env 设真值) JNPF_API_DOMAIN: ${JNPF_API_DOMAIN:-http://cx-infra:30000} JNPF_FRONT_DOMAIN: ${JNPF_FRONT_DOMAIN:-http://localhost/} JNPF_APP_DOMAIN: ${JNPF_APP_DOMAIN:-http://localhost/} JNPF_FLOW_DOMAIN: ${JNPF_FLOW_DOMAIN:-http://cx-flow-engine:31000} # 🔴 Dubbo LogProvider 消费端直连地址(2026-08-11 平台聚合必须加)。 # 消费方是 jnpf-common-springaop 的 RequestLogAspect / ResultException,它们的 # @DubboReference 默认值**硬编码为 dubbo://cx-system:20880**——而 cx-system 已并入 # cx-platform、容器不复存在,不覆盖就每分钟刷 UnknownHostException: cx-system。 # 宿主栈(start-all.sh)设的是 dubbo://127.0.0.1:20880,不含服务名,故宿主形态**测不出** # 这个问题;它只在容器形态暴露。改服务名时这里必须跟着改。 # 遗留优化项:聚合后 provider 与 consumer 同处一个 JVM,本可用 injvm 走进程内直调, # 省掉这一跳本地 TCP 与重连定时器;但那要改平台代码的 injvm=false,另案评估。 JNPF_LOG_PROVIDER_URL: ${JNPF_LOG_PROVIDER_URL:-dubbo://cx-platform:20880} # 客户坐标(adr-multi-customer-db-isolation §3):业务库经 config/shared/datasource.yaml 的 # ${CUSTOMER_DB_HOST}/${CUSTOMER_DB_PORT} 占位符消费,故必须透传进业务容器。 # 与下方 cx-flow-engine 的 FLOW_DB_URL 同源——两库要么一起切要么一起不切。 CUSTOMER_DB_HOST: ${CUSTOMER_DB_HOST:-cx-postgres} CUSTOMER_DB_PORT: ${CUSTOMER_DB_PORT:-5432} # 业务库账号也透传:config/shared/datasource.yaml 的 username/password 同样是占位符, # 否则每切一个客户就得改这份文件的默认值,「单一开关」就名存实亡了。 JNPF_APP_DB_USER: ${JNPF_APP_DB_USER:-jnpf_app} JNPF_APP_DB_PASSWORD: ${JNPF_APP_DB_PASSWORD:?缺 JNPF_APP_DB_PASSWORD,见 .env.example} # Redis 密码透传给业务服务(datasource.yaml 的 spring.redis.password 占位符消费)。 # 此前业务侧读的是 yaml 明文字面值、仅 cx-redis 容器自己吃这个 env——两边靠碰巧同值工作; # 参数化后交付环境改 .env 一处即可轮换(2026-08-06 用户指出后收口)。 JNPF_REDIS_PASSWORD: ${JNPF_REDIS_PASSWORD:?缺 JNPF_REDIS_PASSWORD,见 .env.example} # Redis 坐标透传(datasource.yaml 的 spring.redis.host/port 占位符消费)。 # JNPF_DOCKER_REDIS_* 是容器侧专用覆盖:宿主 JVM 可继续使用 127.0.0.1,容器则通过 # cx-infra(host-gateway) 访问宿主 Redis。未设置专用覆盖时沿用 JNPF_REDIS_*,兼容远端 Redis。 # 两组都不设 = 栈内 cx-redis(交付/本机容器形态,见 .env.example §5)。 # 🔴 必须写 ${VAR:-默认值} 而非裸 ${VAR}:宿主未设时裸形式注入空串,会把 yaml 里的默认值静默清空。 JNPF_REDIS_HOST: ${JNPF_DOCKER_REDIS_HOST:-${JNPF_REDIS_HOST:-cx-redis}} JNPF_REDIS_PORT: ${JNPF_DOCKER_REDIS_PORT:-${JNPF_REDIS_PORT:-6379}} x-cx: &cx # 业务镜像当前以 /bin/sh -c 启动 Java;用 init 转发停止信号并回收子进程, # 避免 compose 重建时 PID 变成 zombie 后无法停止旧容器。 init: true restart: unless-stopped networks: [cx] extra_hosts: - "cx-infra:host-gateway" environment: *cx-env # 冷启动排队(C4 补齐强依赖;补丁 #8 起业务改显式依赖 PG): # PG healthy 才放业务(C2 深化后=业务/流程引擎库真就绪,L022 重连空转窗口同样适用); # Redis=会话/缓存**兼事件总线**(2026-08-10 起,见 config/shared/system-config.yaml), # 起在依赖前只会刷错重连。 # MQ namesrv 已移除:RocketMQ 于 2026-08-10 随事件总线切 Redis 一并退栈。 depends_on: cx-postgres: condition: service_healthy # 连远端客户库时本机不起 PG(cx-postgres 在 localdb profile 内,见其服务块注释)。 # required:false = 该服务不在本次启动集合里就跳过、且不自动把它的 profile 拉进来; # 在集合里(COMPOSE_PROFILES=localdb)则照旧等它 healthy——两种形态共用这一份编排。 required: false cx-redis: condition: service_healthy volumes: # 与宿主进程共享同一文件存储根(混合调试两侧看到同一批文件); # 跨平台:宿主侧根目录用 JNPF_DATA_DIR 参数化,Win/Linux 在仓库根 .env 里 # 设 JNPF_DATA_DIR=D:/jnpf-data 或 /data/jnpf 即可,本机 Mac 默认 /Users/Shared - ${JNPF_DATA_DIR:-/Users/Shared}/jnpf-resources:/data/jnpf-files/jnpf-resources # 应用日志落宿主(2026-08-11):与 x-cx-env 的 JNPF_LOG_DIR 成对。 # 此前日志写在容器可写层,容器 recreate 即全丢、且不占可见磁盘配额。 # 宿主侧长成 logs/<服务名>/log_{error,warn,info,debug,total}.log,直接 tail -f 即可; # 滚动归档(按天+满 10MB,保留 7 天)进 <服务名>/{error,info,warn,total}/YYYY-MM-DD/,同样在宿主上。 # 🔴 冒号左边 = 宿主机目录(改这个),右边 = 容器内目录(由 JNPF_LOG_DIR 固定,别改)。 # 交付现场若不想写进部署目录,在 .env 设 JNPF_LOG_DIR_HOST=/var/log/jnpf 这类宿主绝对路径。 # ⚠️ Linux 上该目录由容器内 root 创建,非 root 用户读日志需要 sudo。 - ${JNPF_LOG_DIR_HOST:-./logs}:/data/jnpf-logs logging: # 容器 stdout 的 json-file 轮转上限;查看用 docker compose logs -f <svc> # (注意这份和上面的文件日志是两套:stdout 走 json-file 存 /var/lib/docker, # 容器删除即消失;要留存的日志看挂出来的 logs/ 目录) options: max-size: "50m" max-file: "3" # 基础设施公共模板(无业务 env、无文件存储卷) x-infra: &infra restart: unless-stopped networks: [cx] extra_hosts: - "cx-infra:host-gateway" logging: options: max-size: "50m" max-file: "3" services: # ================= 基础设施(M2 收编;调试端口见 docker-compose.override.yml) ================= cx-postgres: <<: *infra container_name: cx-postgres # 「栈内 PG」只是两种库形态之一,故收进 profile,默认不启动(2026-08-07): # 开发机 / 测试环境:CUSTOMER_DB_HOST 指向 CVM 上的客户实例,本机这个 PG 是多余的 # (起它还会因 external 卷 postgres_pgdata 不存在而直接把整栈拦下) # 交付 / 正式:本机 PG 就是唯一库,须在 .env 设 COMPOSE_PROFILES=localdb 才会起 # 谁该设这个开关有唯一判据:CUSTOMER_DB_HOST=cx-postgres 就必须设,preflight 会核对。 # 构建与离线打包不受 profile 影响(Makefile images / assemble-offline-package.sh 显式带上), # 否则交付包会缺 PG 镜像。 profiles: ["localdb"] # 定义自 docker/postgres/docker-compose.yml 平移(该文件已废弃);镜像本地已构建,交付走 tar image: cx/postgres:18.4 build: docker/postgres platform: linux/amd64 shm_size: 1g # 端口不在基线发布(宿主 psql 走 override 的 127.0.0.1:5433,或 docker exec cx-postgres psql) environment: TZ: Asia/Shanghai POSTGRES_USER: postgres # 仅 initdb 时生效;既有 pgdata 卷改密码须 ALTER USER(见 release-checklist §0) POSTGRES_PASSWORD: ${JNPF_PG_PASSWORD:?缺 JNPF_PG_PASSWORD,先 cp .env.example .env} # PG18 官方镜像 PGDATA 迁至 /var/lib/postgresql/18/docker,volume 挂父目录 POSTGRES_INITDB_ARGS: "--locale=C.UTF-8 --encoding=UTF8" volumes: - pgdata:/var/lib/postgresql command: - postgres - -c - shared_preload_libraries=pg_stat_statements,auto_explain - -c - pg_stat_statements.track=all - -c - auto_explain.log_min_duration=1s - -c - timezone=Asia/Shanghai # 9 个业务服务 Druid 池稳态 ~98 连接,默认 100 在真实页面流量下打满(2026-07-17 回归实测) - -c - max_connections=200 healthcheck: # C2 深化:pg_isready 只验"接受连接",空卷/漏灌的 PG 照样 healthy(全新机器静默失败根源)。 # 追加两库连得上 + 关键表在:坏库不放行,下游(业务/流程引擎 service_healthy)等真就绪。 # 实现要点:连上目标库本身即证明"库存在且接受连接"(pg_isready 冗余,省掉); # jnpf_xxljob 无关键表,用 1/count 除零技巧并入首个连接;共 2 次 psql——冷启动 13 个 JVM # 打满 CPU 时(本地 Rosetta)每个进程 spawn 都慢,多进程版实测超 5s timeout 误判 unhealthy。 # 只读校验走容器内 socket=trust,与密码无关(L030 不适用)。 # 补丁 #8:去掉第三库探活——原注册/配置中心的库仍在 PG 里(未删,见 # tasks/jnpf-platform-patches.md 补丁 #8),但栈内无服务再消费它,健康检查不必再等它。 test: - CMD-SHELL - >- psql -qU postgres -d jnpf_init -v ON_ERROR_STOP=1 -c 'SELECT 1 FROM base_user LIMIT 0' -c "SELECT 1/count(*) FROM pg_database WHERE datname='jnpf_xxljob'" >/dev/null && psql -qU postgres -d jnpf_flow -v ON_ERROR_STOP=1 -c 'SELECT 1 FROM act_ru_task LIMIT 0' >/dev/null interval: 15s # 冷启动 CPU 饱和窗口进程 spawn 慢(上);稳态 <1s,20s 只是兜底 timeout: 20s retries: 5 # 大卷 crash recovery 可能远超 5×15s,放宽到 120s:start_period 内失败不计 retries、 # 一旦成功立即 healthy(不拖慢正常冷启动),只延后"真坏"的 unhealthy 判定 start_period: 120s cx-redis: <<: *infra container_name: cx-redis image: cx-redis:8.4.0-alpine platform: linux/amd64 # requirepass 经 .env 注入(config/shared/datasource.yaml 的 spring.redis.password 须同值)。 # # 🔴 2026-08-10 起开持久化(此前是 `--save ""` 不落盘不挂卷)。 # 原来的理由是「纯缓存/会话,数据可丢,反正 jnpf-oauth 每次启动会清全库」—— # **那个理由不成立**:JnpfListener 的清库被 `"false".equals(getTestVersion())` 门控, # 而 test-version 全仓库未配置 → null → 条件为 false → 清库从不执行。 # 实测(CVM 客户A):唯一无 TTL 的 key 是 `idgenerator_Index:`(ID 生成器索引,值已到 174), # 丢了会让雪花 workerId 复用 → 主键冲突/跳号。详见 docs/release-checklist.md §2.2。 # 注意:事件总线走 pub/sub,**不是**开持久化的理由(在途消息落盘也救不回)。 command: - redis-server - --protected-mode - "yes" - --requirepass - ${JNPF_REDIS_PASSWORD:?缺 JNPF_REDIS_PASSWORD,先 cp .env.example .env} # AOF 为主(everysec:最多丢 1 秒),RDB 兜底便于整卷搬迁 - --appendonly - "yes" - --appendfsync - everysec - --save - "900 1" # 🔴 交付基线此前**既无 maxmemory 也无 mem_limit**——Redis 可以吃光整台机器。 # 512mb 是给中小客户的保守起步值;正式环境应按实测定,方法见 # docs/release-checklist.md §2.2「容量与告警」。 # 不变式:若给容器加 mem_limit,必须 ≥ 2 × maxmemory + 64M(AOF rewrite 的 COW 峰值)。 - --maxmemory - 512mb # 保持 noeviction:会话/缓存以外还有无 TTL 的 idgenerator_Index:, # LRU 静默逐出会变成跳号/主键冲突这类诡异故障;打满报错反而立刻暴露问题。 # 代价是打满 = 写入全失败,所以**必须配 70% 告警**,别等撞墙。 - --maxmemory-policy - noeviction volumes: - redisdata:/data environment: # redis-cli 自动读取,healthcheck 免显式 -a(避免密码出现在进程参数) REDISCLI_AUTH: ${JNPF_REDIS_PASSWORD} healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 # ── RocketMQ(cx-mq-namesrv / cx-mq-store-init / cx-mq-broker)已于 2026-08-10 整体移除 ── # 事件总线改用 Redis pub/sub(config/shared/system-config.yaml 的 event.event-publish-type: redis), # binder 依赖也已从 jnpf-common-springaop 排除。要回滚见 tasks/jnpf-platform-patches.md 补丁 #10。 cx-flow-engine: <<: *infra container_name: cx-flow-engine # 独立流程引擎(官方 Flowable 7.0.1 + JNPF 定制层,自有源码工程 jnpf-flow-engine/,详见 docker/flow-engine/README.md); # cx-flowable(30004) 经 FlowDomain=http://cx-flow-engine:31000 服务名直连调用(§0.10 数据面规则)。 # 构建前置:mvn -f jnpf-flow-engine/pom.xml package + ./docker/flow-engine/prepare-context.sh(jar 不入 git) image: cx/flow-engine:${CX_VERSION:-1.0.0} build: docker/flow-engine platform: linux/amd64 # Dockerfile ENTRYPOINT 是 sh -c 形式(PID 1 不收割子进程),曾出僵尸进程导致容器停不掉(L031) init: true # 数据源 = PG 的 jnpf_flow 库(随 pgdata 卷,Flowable 7 同版本 schema); # 连库账号经 env 注入(conf/application-dev.yml 只留占位),A4 起用专用角色 jnpf_flow_app environment: # URL 用 :- 带默认值:默认容器网直连; # 开发机连测试环境共享库时在 .env 覆盖(docs/adr-single-db-topology.md §5.2) # 引擎库与业务库共用同一对坐标变量 → 物理上不可能半切(adr-multi-customer-db-isolation §4)。 # JNPF_FLOW_DB_URL 保留为逃生口(整串覆盖),日常不要用——用它就重新引入了半切风险。 FLOW_DB_URL: ${JNPF_FLOW_DB_URL:-jdbc:postgresql://${CUSTOMER_DB_HOST:-cx-postgres}:${CUSTOMER_DB_PORT:-5432}/jnpf_flow} FLOW_DB_USER: ${JNPF_FLOW_DB_USER:-postgres} FLOW_DB_PASSWORD: ${JNPF_FLOW_DB_PASSWORD:?缺 JNPF_FLOW_DB_PASSWORD,先 cp .env.example .env} depends_on: cx-postgres: condition: service_healthy # 同 x-cx 的说明:栈内 PG 在 localdb profile 内,不在启动集合时跳过而非报错 required: false healthcheck: # 无 actuator,用 springdoc 文档端点探活 test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:31000/v3/api-docs"] interval: 15s timeout: 5s start_period: 90s retries: 5 cx-onlyoffice: <<: *infra container_name: cx-onlyoffice # OnlyOffice Document Server 社区版:Office 文档在线预览/编辑,由 cx-biz-common 的 # onlyoffice 能力模块驱动(方案见 tasks/onlyoffice-integration-plan.md)。 # 用 *infra 而非 *cx:它不是 JNPF JVM,不需要 JNPF_* 环境变量与 PG/Redis/MQ 依赖。 # # ⚠️ 版本必须固定:9.4.0.1 是 Docker Hub 当前 latest(Hub manifest list digest # sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212); # tag 9.4.0 / 9.4 是更早的另一个 digest,不要混用。 # # 🔴 2026-08-19:由 Docker Hub 长名改为短名 cx-onlyoffice,与 cx-redis 同机制—— # 国内网络 `docker.io/onlyoffice/documentserver` 拉取超时(registry-1.docker.io:443 # 连不上,Windows 测试环境实测)。短名由 `make pull-infra` 从 SWR 长名 # $SWR/onlyoffice/documentserver:9.4.0.1 retag 而来,故裸 `docker compose pull` # 同样拉不到它(详见本文件顶部 §短名约定与 docs/docker-compose-guide.md §11.1)。 # SWR 侧是已解开的 amd64 单架构 manifest(digest # sha256:66e06b404e11d532832501acf5a78bc759940c58859a80676330f71aedb1ef99, # config sha256:4934c364adccc2898d0998951fb09d455496454de417cce2a74b7b1e808daab0), # 与上面 Hub 的 manifest-list digest 天然不同,**不能据此判断内容不一致**。 # ✅ 2026-08-19 已实证两源内容同一(digest 比不了:本机连不通 Hub,且本地 Hub tag # 在 containerd image store 下只剩 index 引用,inspect 出来 Architecture 空、 # RootFS.Layers=0,无从取层)。改在容器内比,两侧完全一致: # · dpkg 包版本 onlyoffice-documentserver 9.4.0-129(amd64) # · 核心二进制 server/DocService/docservice # sha256:556df4aab7b1a87123046a4459baee34e60f57deaf7bcd189bccd6f271bd8fcb # 并且 SWR 镜像单起容器 20s 内跑通官方 /healthcheck。 image: cx-onlyoffice:9.4.0.1 platform: linux/amd64 environment: # JWT 是 DS 与后端之间的唯一信任凭据:后端签发编辑器 config、DS 签发保存回调, # 两侧共用同一 secret。与 config/shared/jnpf-biz-common.yaml 的 # onlyoffice.jwt-secret 同源(都读 .env 的 ONLYOFFICE_JWT_SECRET)—— # 两边不一致的表现是编辑器一直转圈或回调静默失败,且日志不明显。 JWT_ENABLED: "true" JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?缺 ONLYOFFICE_JWT_SECRET,先 cp .env.example .env} volumes: # DS 内置 PostgreSQL 存文档缓存与编辑状态,不挂卷则重启丢失(表现为编辑中的 # 文档回到上一个保存点)。字体不挂载——镜像自带 22 个中文字体已够用(实测)。 - onlyoffice-data:/var/www/onlyoffice/Data - onlyoffice-log:/var/log/onlyoffice - onlyoffice-db:/var/lib/postgresql healthcheck: # DS 官方探活端点,就绪时返回字面量 true test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/healthcheck | grep -q true"] interval: 15s timeout: 10s retries: 5 # 首次启动要初始化内置 PG + 生成字体索引,Rosetta 上偏慢 start_period: 90s # ================= JNPF 业务服务(M1) ================= cx-gateway: <<: *cx container_name: cx-gateway image: cx/gateway:${CX_VERSION:-1.0.0} build: { context: ., dockerfile: dockerfile/gateway/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } ports: ["30000:30000"] # 说明:2026-08-11 一度在这里加过 -Dlogging.level.jnpf.util.Ip2RegionUtil=OFF 来压 # 「ip2region xdb 找不到」的两条 ERROR。补丁 #15 从根上解决后(gateway 自带同包替身类 # jnpf/util/Ip2RegionUtil.java,见该文件注释)已撤掉——压噪配置留着会掩盖真问题, # 也会让人误以为那两条错误还在。本服务现在完全走 x-cx-env 的公共环境变量。 healthcheck: &hc-30000 test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30000"] interval: 30s # 冷启动 CPU 饱和下业务真实启动 4-6 分钟,180s 会经历"瞬时 unhealthy 再自愈"的误导态 # (三轮冷启动实测);start_period 内失败不计 retries、成功立即 healthy,放宽无副作用(L034) start_period: 300s retries: 3 # ================= 平台聚合服务(2026-08-11)================= # system / permission / oauth / visualdev / visualdata / message / file / app / flowable # 九个模块合一个 JVM,取代下面九个已注释的 cx-* 容器。 # 实测:RSS 约 1.07GB(对比九个独立容器约 6.7GB),fat jar 328MB(对比九个各 198MB)。 # 服务名不变——config/shared/discovery.yaml 里九个 jnpf-* 名字全部别名到 cx-platform:30002, # 网关 router.yaml 与 60 个 @FeignClient 一行未改。 cx-platform: <<: *cx container_name: cx-platform image: cx/platform:${CX_VERSION:-1.0.0} build: { context: ., dockerfile: dockerfile/platform/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } environment: <<: *cx-env # 九个模块的类元数据同处一个 JVM,堆比单模块服务大一档(模板默认 -Xms256m -Xmx768m)。 # 实测堆 used 约 361MB,1536m 留足余量;即便如此总占用仍远低于九容器形态。 # ⚠️ 本行整体覆盖 x-cx-env 的同名值,编码参数必须跟着抄一份,否则本服务中文日志变问号。 JAVA_TOOL_OPTIONS: -Xms512m -Xmx1536m -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 # 原 jnpf-system 是 Dubbo LogProvider 唯一 provider(补丁 #8),聚合后本服务接管。 # 不设此项 → 每个请求的 RequestLogAspect 都调用失败。 DUBBO_PROTOCOL_PORT: "20880" healthcheck: <<: *hc-30000 test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30002"] # ── cx-oauth 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ── # cx-oauth: # <<: *cx # container_name: cx-oauth # image: cx/oauth:${CX_VERSION:-1.0.0} # build: { context: ., dockerfile: dockerfile/oauth/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # healthcheck: # <<: *hc-30000 # test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30001"] # ── cx-system 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ── # cx-system: # <<: *cx # container_name: cx-system # image: cx/system:${CX_VERSION:-1.0.0} # build: { context: ., dockerfile: dockerfile/system/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # environment: # <<: *cx-env # # 补丁 #8:jnpf-system 是 Dubbo LogProvider 唯一 provider,固定协议端口供直连 # DUBBO_PROTOCOL_PORT: "20880" # healthcheck: # <<: *hc-30000 # test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30002"] # ── cx-visualdev 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ── # cx-visualdev: # <<: *cx # container_name: cx-visualdev # image: cx/visualdev:${CX_VERSION:-1.0.0} # build: { context: ., dockerfile: dockerfile/visualdev/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # healthcheck: # <<: *hc-30000 # test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30003"] # ── cx-flowable 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ── # cx-flowable: # <<: *cx # container_name: cx-flowable # image: cx/flowable:${CX_VERSION:-1.0.0} # build: { context: ., dockerfile: dockerfile/flowable/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # healthcheck: # <<: *hc-30000 # test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30004"] # ── cx-file 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ── # cx-file: # <<: *cx # container_name: cx-file # image: cx/file:${CX_VERSION:-1.0.0} # build: { context: ., dockerfile: dockerfile/file/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # healthcheck: # <<: *hc-30000 # test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30005"] # ── cx-message 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ── # cx-message: # <<: *cx # container_name: cx-message # image: cx/message:${CX_VERSION:-1.0.0} # build: { context: ., dockerfile: dockerfile/message/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # healthcheck: # <<: *hc-30000 # test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30008"] # ── cx-permission 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ── # cx-permission: # <<: *cx # container_name: cx-permission # image: cx/permission:${CX_VERSION:-1.0.0} # build: { context: ., dockerfile: dockerfile/permission/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # healthcheck: # <<: *hc-30000 # test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30010"] cx-lims: <<: *cx container_name: cx-lims image: cx/lims:${CX_VERSION:-1.0.0} build: { context: ., dockerfile: dockerfile/lims/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } environment: <<: *cx-env # Spring relaxed binding → audit.spool-dir(AuditSdkAutoConfiguration @Value("${audit.spool-dir:./audit-spool}"))。 # cx-lims 是当前**唯一**的 audit-sdk 宿主,也就是唯一需要 spool 兜底的服务。 AUDIT_SPOOL_DIR: /jnpf/audit-spool # M2 Task A1:audit-sdk 已接入 lims。relaxed binding 自动映射到 audit.enabled(总开关, # AuditSdkAutoConfiguration @ConditionalOnProperty),无需在 application.yml 重复声明占位符。 # 双态断言见 tasks/audit-m2-startup-check.sh。 AUDIT_ENABLED: ${AUDIT_ENABLED:-true} # 2026-08-11:AUDIT_LAYER1_ENABLED / AUDIT_LAYER1_PER_TABLE_MASKED_COLUMNS 随层 1 整套移除。 # 层 1 自交付起一直默认 false,audit_events 表内 source_layer=1 零行,属未启用即负债。 volumes: # ⚠️ 服务级 volumes 会**整体覆盖** x-cx 锚点的那份,不是追加——所以 jnpf-resources # 与日志挂载都得在这里再写一遍,漏一行该服务就静默退回容器内路径(2026-08-11 踩过)。 - ${JNPF_DATA_DIR:-/Users/Shared}/jnpf-resources:/data/jnpf-files/jnpf-resources - ${JNPF_LOG_DIR_HOST:-./logs}:/data/jnpf-logs - audit-spool-lims:/jnpf/audit-spool healthcheck: <<: *hc-30000 test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30019"] # eln 业务模块(ADR 售卖单元制):profile 隔离,默认 make up 不起(本地 VM 内存账,L024)。 # 起用:docker compose --profile eln up -d cx-eln: <<: *cx container_name: cx-eln image: cx/eln:${CX_VERSION:-1.0.0} profiles: ["eln"] build: { context: ., dockerfile: dockerfile/eln/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } healthcheck: <<: *hc-30000 test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30013"] # ══ cx-audit(30014)已于 2026-08-11 退役 ══ # 审计整体搬入 jnpf-biz-common(见下),JVM/容器/镜像各减一个。 # 服务名 jnpf-audit 在 config/shared/discovery.yaml 里保留为**别名**指向 cx-biz-common:30015, # 故 lims 的 Feign 投递与 /api/audit/** 路由一行未改。 # 原先这里的 AUDIT_SPOOL_DIR + audit-spool-audit 卷是**从未生效的配置**:spool 属 audit-sdk, # 而 audit 服务端的依赖链(server→controller→biz→api)从不含 sdk——退役前实测该卷内始终为空。 # 公共业务能力聚合服务(jnpf-biz-common):默认启动(无 profiles,区别于 cx-eln)。 # 一个 JVM 承载多个能力模块(onlyoffice、audit…),准入纪律见 specs/2026-08-07-biz-common-design.md §3 cx-biz-common: <<: *cx container_name: cx-biz-common image: cx/biz-common:${CX_VERSION:-1.0.0} build: { context: ., dockerfile: dockerfile/biz-common/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # 只有本服务消费这个密钥,故不进公共 *cx-env 锚点,单独覆盖(保留锚点其余变量)。 # 🔴 漏了它会静默失败而不是报错:config/shared/jnpf-biz-common.yaml 里写的是 # ${ONLYOFFICE_JWT_SECRET},而 Spring Boot 的 Binder 对解析不了的占位符是原样放行, # 后端会拿字面量 "${ONLYOFFICE_JWT_SECRET}" 当密钥签名,DS 验签必然失败, # 表现为编辑器里「文档安全令牌的格式不正确」——2026-08-08 实踩。 environment: <<: *cx-env ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?缺 ONLYOFFICE_JWT_SECRET,先 cp .env.example .env} healthcheck: <<: *hc-30000 test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30015"] cx-dms: <<: *cx container_name: cx-dms image: cx/dms:${CX_VERSION:-1.0.0} build: { context: ., dockerfile: dockerfile/dms/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } healthcheck: <<: *hc-30000 test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30016"] # ── cx-visualdata 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ── # cx-visualdata: # <<: *cx # container_name: cx-visualdata # image: cx/visualdata:${CX_VERSION:-1.0.0} # build: { context: ., dockerfile: dockerfile/visualdata/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # healthcheck: # <<: *hc-30000 # test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30011"] # ================= 前端(Backlog F)================= # nginx 静态托管 dist + /api、/websocket 反代 gateway;非 JVM 用 *infra 模板(x-cx 的 # JAVA_TOOL_OPTIONS/文件卷/depends_on 均不适用)。不加 depends_on:nginx.conf 用变量 # proxy_pass + resolver 惰性解析,gateway 未起时 nginx 照常启动、重建换 IP 自动重解析。 # dist 由 make web-dist 快照进 docker/frontend/dist/(独立 build context——根 .dockerignore # 是 deny-all 白名单,context: . 拿不到前端文件)。 cx-web: <<: *infra container_name: cx-web image: cx/web:${CX_VERSION:-1.0.0} build: context: docker/frontend args: NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" ports: - "80:80" # - "443:443" # TLS 现场启用(证书 + nginx.conf 443 块,三步见交付包 INSTALL.md §5) volumes: # 挂载为准(现场免构建改反代/开 TLS),镜像内 COPY 的同份 conf 作自包含兜底 - ./docker/frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro # - ./docker/frontend/certs:/etc/nginx/certs:ro # TLS 证书挂载点 healthcheck: # alpine 无 bash 用不了 &hc-30000 的 /dev/tcp;busybox wget 探静态入口即可 test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1/"] interval: 30s timeout: 5s retries: 3 start_period: 10s # ---- 以下 2 个本地暂未使用,按需 --profile extra 启动 ---- cx-scheduletask: <<: *cx profiles: [extra] container_name: cx-scheduletask image: cx/scheduletask:${CX_VERSION:-1.0.0} build: { context: ., dockerfile: dockerfile/scheduletask/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } healthcheck: <<: *hc-30000 test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30009"] # ── cx-app 已并入 cx-platform(2026-08-11),整段注释保留以便回滚 ── # cx-app: # <<: *cx # profiles: [extra] # container_name: cx-app # image: cx/app:${CX_VERSION:-1.0.0} # build: { context: ., dockerfile: dockerfile/app/Dockerfile, args: { BASE_IMAGE: "${BASE_IMAGE:-bellsoft/liberica-openjre-rocky:21}" } } # healthcheck: # <<: *hc-30000 # test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/30012"] networks: cx: name: cx volumes: # Redis 持久化卷(AOF+RDB)。普通内部卷:`down -v` 会删, # 但 idgenerator_Index: 丢失只影响雪花 workerId 分配,重建即可,不是跨机迁移数据。 redisdata: # 复用 PG 迁移期创建的既有卷(原 docker/postgres 项目);external=compose 永不删除(含 down -v), # 全新机器需先 docker volume create postgres_pgdata 再灌数据(docker/postgres/migration/) pgdata: name: postgres_pgdata external: true # audit-sdk spool 兜底目录(两级降级的最后一级):per-service 独立卷(Codex 批次二 #2: # 多实例共享 spool 卷会有孤儿租约风险,per-service 隔离规避)。 # 2026-08-11:audit-spool-audit 随 cx-audit 退役一并删除(它从未被写入过——spool 属 sdk, # 而 audit 服务端不含 sdk,退役前实测卷内为空)。当前只有 SDK 宿主 cx-lims 需要。 audit-spool-lims: # OnlyOffice DS 状态:Data=文档缓存与转换产物,db=内置 PG(编辑会话状态),log=日志。 # 均为可重建数据(丢失只影响编辑中的未保存内容,已保存的文档在 jnpf-file 存储里), # 故用普通内部卷,不设 external onlyoffice-data: onlyoffice-log: onlyoffice-db: docker/delivery/INSTALL.md
New file @@ -0,0 +1,98 @@ # JNPF 整栈离线交付包 现场安装手册 > 本包 = 全栈(业务服务 + 前端页面 cx-web + Redis/PostgreSQL/流程引擎)的 > **linux/amd64 离线镜像** + 编排文件 + 迁移工具。现场**不需要外网、不需要源码、不构建任何镜像**。 > 深入资料在包内 `docs/`:`release-checklist.md`(上线对照 §0,**必须逐项过**)、 > `docker-compose-guide.md`(编排全景)、`server-pg-cutover-checklist.md`(MySQL→PG 割接时序)。 ## 1. 前置条件 | 项 | 要求 | 验证 | |---|---|---| | 架构 | x86_64 Linux | `uname -m` → `x86_64` | | Docker | Docker Engine + compose 插件 v2 | `docker compose version` | | 内存 | **≥12GB 可用**(17 容器全栈;不足会 OOM 连锁:PG crash 循环 + gRPC 超时风暴) | `free -g` | | 磁盘 | ≥30GB(镜像解压 + PG 数据 + 备份) | `df -h` | | 工具 | GNU make(可选,无则按本文用裸命令)、curl、python3(≥3.6,DLQ/spool 重放预检脚本 `tasks/audit-event-validate.sh` 依赖);冒烟另需 openssl + md5sum | `command -v make curl python3 openssl md5sum` | | 主机名 | `/etc/hosts` 加一行 `127.0.0.1 cx-infra`(ApiDomain 双消费方解析约定) | `getent hosts cx-infra` | | 端口 | 宿主 **80**(前端页面入口)与 **30000**(API/ApiDomain 直连口)无占用;5432/6379 等**不对外发布** | `ss -ltn` | ## 2. 安装步骤(按序执行) ```bash # ① 解包并校验完整性 tar xf cx-offline-*.tar && cd cx-offline-*/ make verify # = sha256sum -c SHA256SUMS # ② 导入镜像(含迁移辅助镜像) make load # = docker load -i images/*.tar.gz # ③ 配置凭据 —— 全部【密码/密钥】改现场专用值; # 【用户名/角色名】保持默认不改(jnpf_app 等已硬编码进建角色 SQL 与配置模板) cp .env.example .env && vi .env # 每个键的生成方法见文件内注释与 docs/release-checklist.md §0.7 # Nacos 客户端账号(NACOS_CLIENT_*)默认注释=回落 nacos/nacos,交付必须启用并轮换(§0.7/§0.11) # ⚠️ COMPOSE_PROFILES=localdb 必须保留(.env.example 默认已带):栈内 PG 收在该 profile 内, # 不设则 cx-postgres 根本不启动、全栈无库可连。**升级既有现场时尤其注意**——沿用旧 .env # 的话这个键不存在,须手工补上(make up 前的 preflight 会拦下并给同样提示)。 # 仅当本环境改连远端共享库(CUSTOMER_DB_HOST 非 cx-postgres)时才注释掉它。 # ④ 数据卷(复用旧卷前先确认卷内数据归属;PG 超管密码只在全新卷 initdb 时生效,见 §0.11) docker volume create postgres_pgdata # (原 mq-store 卷创建与 chown 步骤已随 RocketMQ 于 2026-08-10 移除,见补丁 #10) # ⑤ 数据初始化(二选一) # a. 全新环境:起 PG → 建 schema → 建业务角色 docker compose up -d cx-postgres # 此时容器 unhealthy 属预期(健康检查探测业务库,库由 install.sh 创建); # 日志刷 "FATAL: database ... does not exist" 是探测噪音,install 成功后即停、随后转 healthy ( cd migration && ./install.sh && ./create-app-roles.sh ) # b. MySQL 割接:按 docs/archive/server-pg-cutover-checklist.md 全时序执行(含停机窗口迁数据) # ⑥ Nacos 配置中心内容就位,并把数据面切成服务名直连(不切必炸——基线不发布数据端口): # datasource.yaml → cx-postgres:5432 / cx-redis;(mq.yaml 已停用,事件总线走 Redis); # FlowDomain → http://cx-flow-engine:31000;ApiDomain → 服务器实际地址 # 逐项对照 docs/release-checklist.md §0.9/§0.10 # (Nacos 单独已在 ⑤ 起来:浏览器 127.0.0.1:30099/nacos 或经 API 操作) # ⑦ 起全栈并验证 make up # 自动先跑 preflight(校验 ③-⑥ 成果:.env/卷/四库/种子/角色, # 缺项给修复指引;单独跑 make preflight,跳过 SKIP_PREFLIGHT=1) make wait # 等全部 healthy(冷启动 5-8 分钟) make smoke # 三段冒烟:gateway 登录 + 前端静态 + 经 80 反代登录(admin 种子密码) # ⑧ 浏览器验证:http://<服务器>/ 应出登录页;登录后任意内页刷新不 404; # 消息铃铛正常(WebSocket 经 80 的 /websocket/ 反代) ``` ## 3. 上线放行门禁 冒烟通过后,**逐项过 `docs/release-checklist.md` §0(0.1–0.15)**:PG max_connections、 凭据轮换与一致性对照表、端口暴露面复查(`ss -ltn` 应只见 80、30000 与 127.0.0.1:30099)、 FrontDomain 指现场前端地址(§0.14)、(§0.15,`make preflight` 会 非阻断提示)、备份 cron(`migration/backup.sh`,含 nacos_config;宿主文件存储目录另行文件级备份)。 ## 4. 日常运维速查 | 目的 | 命令 | |---|---| | 状态 / 日志 | `make ps` / `make logs s=cx-lims` | | 重启 / 停止 | `make restart` / `make down`(pgdata 外部卷永不删除) | | 进 PG | `docker exec -it cx-postgres psql -U postgres -d jnpf_init` | | 备份 | `BACKUP_DIR=/data/pg-backup ./migration/backup.sh`(配 cron 见 migration/README) | | 镜像溯源 | `images/manifest.txt`(digest/ID/构建 commit) | ## 5. 常见问题 - **业务容器 3-5 分钟才 healthy**:healthcheck start_period=180s,冷启动属正常,别急着判故障。 - **全栈起不来 + PG 日志 `all server processes terminated`**:内存不足(前置 ≥12GB 硬要求)。 - **登录/取配置 403 或 unknown user**:`.env` 的 NACOS_CLIENT_* 与 Nacos 用户库密码不一致(§0.11)。 - **业务报 `password authentication failed`**:`.env` 与 Nacos `datasource.yaml`/角色实际密码不同步—— 按 §0.11 铁律顺序:定稿 .env → 跑 create-app-roles.sh → 同步 datasource.yaml → 重启消费方。 - **文件上传成功但预览 502**:Nacos `system-config.yaml` 的 `config.ApiDomain` 未指现场地址,改后重启 cx-file。 - **页面打不开或接口 502**:`docker compose logs cx-web` 看 nginx 报错——upstream 连不上多半是 gateway 还在冷启动(`make wait` 等 healthy);页面样式/接口地址异常先查 `_app.config.js` 是否被改动(默认三地址全空=同源,勿乱填)。反代规则在 `docker/frontend/nginx.conf`(挂载文件, 改完 `docker compose restart cx-web` 即生效,无需镜像)。 - **启用 HTTPS(三步,零构建)**:① 证书放 `docker/frontend/certs/{server.crt,server.key}` ② `docker-compose.yml` 里 cx-web 解开 `"443:443"` 与 certs 挂载两行注释 ③ `docker/frontend/nginx.conf` 解开文末 443 server 块注释,`docker compose up -d cx-web`。 docker/delivery/assemble-offline-package.sh
New file @@ -0,0 +1,239 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # 构建机专用:组装整栈离线交付包(Backlog B / M3,方案见 tasks/backlog-b-offline-package-plan.md) # # 产物 dist/jnpf-offline-<日期>-<gitsha>/: # images/cx-images.tar.gz 9 唯一镜像(linux/amd64,单 tar 共享层去重) # images/cx-migration-tools.tar.gz 迁移辅助镜像 mysql:8.0 + pgloader(割接后可删) # images/manifest.txt 镜像 digest/ID/架构/大小 + git commit # docker-compose.yml / .env.example / Makefile(现场版) / INSTALL.md # (docker/rocketmq/ 已随 RocketMQ 于 2026-08-10 移除,见补丁 #10;补丁 #8 起 # 注册/配置中心整体摘除,其挂载配置目录已不再打包,config/shared/ 随镜像烧入即含全部配置) # migration/ MySQL→PG 迁移工具包(白名单复制,绝不含 dump/) # docs/ release-checklist / compose-guide / cutover-checklist # SHA256SUMS 全包完整性清单(现场 make verify 校验) # # 用法:make images-offline(推荐,自动校验 jar 与 flow-engine context) # 环境变量: # ALLOW_DIRTY=1 工作区脏时仍打包(验证期用;正式交付必须干净提交) # SKIP_MIGRATION_TOOLS=1 跳过 mysql/pgloader 辅助镜像(包更小,现场迁移须自备) # # 安全机制:本脚本会把本地业务镜像 tag 临时覆盖为 amd64,结束(含失败)时 trap 自动 # 重建原生镜像恢复——否则下次 make up 会以 11 个 Rosetta JVM 运行(L024 事故面)。 # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" cd "$REPO_ROOT" log() { printf '\033[1;34m[offline]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[offline][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[offline][err]\033[0m %s\n' "$*" >&2; exit 1; } # 交付构建的受控源(均为 SWR amd64 单架构;与 flow-engine 既有基座同源) SWR_JRE="swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/bellsoft/liberica-openjre-rocky:21" SWR_REDIS="swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/redis:8.4.0-alpine" # OnlyOffice DS(2026-08-19 由 Hub 长名切到 SWR 短名,同 cx-redis 机制) SWR_ONLYOFFICE="swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/onlyoffice/documentserver:9.4.0.1" # 前端 nginx 基底(2026-07-17 imagetools inspect 实测:单 manifest amd64,L032 验证过) SWR_NGINX="swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:1.27-alpine" MYSQL_IMAGE="mysql:8.0" PGLOADER_IMAGE="dimitri/pgloader:latest" # amd64 构建环境:DOCKER_DEFAULT_PLATFORM 覆盖未指定 platform 的业务服务。 # COMPOSE_PROFILES=localdb 是打包期必须的:cx-postgres 在该 profile 内(默认不启动),不带它 # build 会跳过 PG 镜像、`config --images` 也数不到它,交付包直接缺库镜像。交付现场的 # .env 同样要设这个键(PG 就是那边的唯一库),见 .env.example §1。 compose_amd64() { env DOCKER_DEFAULT_PLATFORM=linux/amd64 COMPOSE_PROFILES=localdb \ docker compose "$@" } # ── 0. 前置校验 ────────────────────────────────────────────────────────────── command -v docker >/dev/null || die "未找到 docker" DIRTY="" if [ -n "$(git status --porcelain)" ]; then if [ "${ALLOW_DIRTY:-0}" = "1" ]; then DIRTY="-dirty"; warn "工作区有未提交改动(ALLOW_DIRTY=1 放行)——正式交付必须在干净提交上打包" else die "工作区有未提交改动——交付产物必须可追溯到唯一 commit。先提交,或验证期用 ALLOW_DIRTY=1" fi fi # 5 个默认后端服务的 jar 逐一校验(平台聚合 + 独立 DMS) BIZ_JARS=( jnpf-gateway/target jnpf-platform/jnpf-platform-server/target jnpf-biz-common/jnpf-biz-common-server/target jnpf-lims/jnpf-lims-server/target jnpf-dms/jnpf-dms-server/target ) for d in "${BIZ_JARS[@]}"; do ls "$d"/*.jar >/dev/null 2>&1 || die "缺业务 jar:${d}/*.jar —— 先 make jars" done ls docker/flow-engine/context/*.jar >/dev/null 2>&1 || die "缺 flow-engine context jar —— 先 make prepare" [ -f docker/frontend/dist/index.html ] || die "缺前端产物 docker/frontend/dist/ —— 先 make web-dist(新鲜度看 dist/.build-info)" # migration sql 组装校验(缺则尝试自动组装,官方脚本目录不在则明确报错) if [ ! -f docker/postgres/migration/sql/jnpf_dbnull_init.sql ]; then log "migration/sql/ 未组装,尝试自动组装 …" ./docker/postgres/migration/assemble-package.sh || die "sql/ 组装失败(官方脚本目录缺失时传 DB_REPO=... 重试)" fi GIT_SHA="$(git rev-parse --short HEAD)" PKG_NAME="cx-offline-$(date +%Y%m%d)-${GIT_SHA}${DIRTY}" PKG="dist/$PKG_NAME" rm -rf "$PKG" mkdir -p "$PKG/images" # ── 恢复机制:无论成败,退出时把本地镜像恢复为原生架构 ──────────────────────── NEED_RESTORE_BUILD=0 NEED_RESTORE_MYSQL=0 restore() { local rc=$? trap - EXIT if [ "$NEED_RESTORE_BUILD" = 1 ]; then log "恢复本地原生业务镜像(arm64 层缓存命中,通常秒级)…" if env COMPOSE_PROFILES=localdb docker compose build >/dev/null; then log "本地镜像已恢复:cx/gateway=$(docker image inspect --format '{{.Architecture}}' "cx/gateway:${CX_VERSION:-1.0.0}" 2>/dev/null || echo '?')" else warn "本地镜像恢复失败!手动执行 make images —— 否则 make up 将以 amd64 镜像运行(L024 风险)" fi fi if [ "$NEED_RESTORE_MYSQL" = 1 ]; then docker pull --platform linux/arm64 "$MYSQL_IMAGE" >/dev/null 2>&1 \ || warn "mysql:8.0 arm64 恢复失败(仅影响本地 mysql 客户端容器走模拟层,可稍后手动 pull)" fi exit "$rc" } trap restore EXIT # 未捕获的 INT/TERM 不会触发 EXIT trap(bash 语义),显式转成 exit 保证恢复必然执行 trap 'exit 130' INT trap 'exit 143' TERM # ── 1. amd64 构建(业务基座注入 SWR;postgres/flow-engine 已 platform 固定=缓存命中) ── log "===== amd64 构建(5 后端业务镜像 + 自建镜像;基座 ${SWR_JRE##*/})=====" NEED_RESTORE_BUILD=1 compose_amd64 build --build-arg BASE_IMAGE="$SWR_JRE" --build-arg NGINX_BASE_IMAGE="$SWR_NGINX" # ── 2. amd64 基础设施拉取 + retag 成 compose 用的短名 ──────────────────────── # 不能再用 `compose pull --ignore-buildable`:compose 里这两个服务写的是短名 # (cx-redis;补丁 #8 摘除注册/配置中心、补丁 #10 摘除 RocketMQ 前还有更多基础设施短名), # 裸 pull 会去 docker.io 找不存在的仓库。这里显式拉 SWR amd64 长名再 retag, # 等价于 amd64 版的 `make pull-infra`。 log "===== amd64 基础设施镜像拉取(SWR)+ retag 短名 =====" docker pull --platform linux/amd64 "$SWR_REDIS" docker tag "$SWR_REDIS" cx-redis:8.4.0-alpine # OnlyOffice DS ~2GB,是这一步耗时的大头(首次拉取;后续走本地缓存) docker pull --platform linux/amd64 "$SWR_ONLYOFFICE" docker tag "$SWR_ONLYOFFICE" cx-onlyoffice:9.4.0.1 # ── 3. 镜像清单 + 逐一架构校验 ──────────────────────────────────────────────── # 下限 8 的来历(2026-08-20 实测 `docker compose config --images | sort -u`,默认 profile): # · cx-onlyoffice 在默认 profile,必须由上面的 SWR pull+retag 供货。 # · 平台聚合后,默认清单为 cx/gateway、cx/platform、cx/lims、cx/biz-common、cx/dms、 # cx/flow-engine、cx/web、cx-onlyoffice。DMS 独立服务使清单从 7 个增加到 8 个。 # 维护提示:这个数字**每次增删 compose 服务都要跟着改**,它的价值是挡住"漏了某个镜像", # 所以宁可精确、不要留富余。 IMAGES=() while IFS= read -r img; do IMAGES+=("$img"); done < <(compose_amd64 config --images | sort -u) [ "${#IMAGES[@]}" -ge 8 ] || die "compose 解析出的镜像数异常:${#IMAGES[@]}(期望 ≥8)" log "===== 架构校验(todo 要求:打包前逐一 docker inspect)=====" for img in "${IMAGES[@]}"; do arch="$(docker image inspect --format '{{.Architecture}}' "$img" 2>/dev/null)" \ || die "镜像不存在:${img}" [ "$arch" = "amd64" ] || die "架构不符:${img} 是 ${arch}(期望 amd64)" printf ' %-78s amd64 ✓\n' "$img" done # ── 4. 导出运行镜像(单 tar 合并 save:共享基座层只存一份) ─────────────────── log "===== docker save ${#IMAGES[@]} 个镜像 → images/cx-images.tar.gz(数 GB,耐心)=====" docker save "${IMAGES[@]}" | gzip > "$PKG/images/cx-images.tar.gz" # ── 5. 迁移辅助镜像(migrate-data.sh 的隐藏 Docker Hub 运行时依赖,收编离线化) ─ TOOL_IMAGES=() if [ "${SKIP_MIGRATION_TOOLS:-0}" != "1" ]; then log "===== 迁移辅助镜像(amd64)=====" NEED_RESTORE_MYSQL=1 docker pull --platform linux/amd64 "$MYSQL_IMAGE" docker pull --platform linux/amd64 "$PGLOADER_IMAGE" for img in "$MYSQL_IMAGE" "$PGLOADER_IMAGE"; do arch="$(docker image inspect --format '{{.Architecture}}' "$img")" [ "$arch" = "amd64" ] || die "架构不符:${img} 是 ${arch}" done TOOL_IMAGES=("$MYSQL_IMAGE" "$PGLOADER_IMAGE") docker save "${TOOL_IMAGES[@]}" | gzip > "$PKG/images/cx-migration-tools.tar.gz" else warn "SKIP_MIGRATION_TOOLS=1:不打迁移辅助镜像,现场停机窗口需自备 mysql:8.0 与 pgloader" fi # ── 6. manifest ────────────────────────────────────────────────────────────── { echo "JNPF 整栈离线交付包 镜像清单" echo "包名 : $PKG_NAME" echo "git commit : $(git rev-parse HEAD)${DIRTY}" echo "打包时间 : $(date '+%F %T %z')" echo "目标平台 : linux/amd64" echo printf '%-78s %-7s %-8s %s\n' "IMAGE" "ARCH" "SIZE" "IMAGE_ID | REPO_DIGEST" for img in "${IMAGES[@]}" ${TOOL_IMAGES[@]+"${TOOL_IMAGES[@]}"}; do docker image inspect --format \ "$(printf '%-78s' "$img") {{.Architecture}} {{.Size}}B {{.Id}} | {{if .RepoDigests}}{{index .RepoDigests 0}}{{else}}(本地构建,无 RepoDigest){{end}}" \ "$img" done } > "$PKG/images/manifest.txt" # ── 7. 运行时文件(相对路径与仓库一致,compose 原样可用) ───────────────────── log "===== 拷贝运行时文件与文档 =====" cp docker-compose.yml .env.example "$PKG/" # 部署自举校验(Backlog C1):包内路径与仓库一致,包 Makefile 的 up 强制前置 cp docker/preflight.sh "$PKG/docker/" # 前端 nginx.conf:compose 以相对路径挂载(现场免构建改反代/开 TLS),包内缺它 up 直接失败 mkdir -p "$PKG/docker/frontend" cp docker/frontend/nginx.conf "$PKG/docker/frontend/" # DLQ/spool 重放预检脚本:guide §19「DLQ 重放闭环」逐条引用,INSTALL.md §1 的 python3 前置 # 即为它——此前遗漏未打包,现场按手册操作会因文件缺失而失败(Codex review) mkdir -p "$PKG/tasks" cp tasks/audit-event-validate.sh "$PKG/tasks/" # migration 工具包:白名单复制(绝不 cp -R 整目录——dump/ 含现场业务数据,codex review) mkdir -p "$PKG/migration/sql" for f in install.sh migrate-data.sh backup.sh create-app-roles.sh create_app_roles.sql \ schema-drift-check.sh \ README-迁移操作手册.md; do cp "docker/postgres/migration/$f" "$PKG/migration/" done cp docker/postgres/migration/sql/* "$PKG/migration/sql/" mkdir -p "$PKG/docs" cp docs/release-checklist.md docs/docker-compose-guide.md docs/archive/server-pg-cutover-checklist.md "$PKG/docs/" cp "$SCRIPT_DIR/INSTALL.md" "$PKG/INSTALL.md" cp "$SCRIPT_DIR/site.Makefile" "$PKG/Makefile" # ── 8. 完整性清单 ──────────────────────────────────────────────────────────── log "===== 生成 SHA256SUMS =====" SHACMD="sha256sum"; command -v sha256sum >/dev/null || SHACMD="shasum -a 256" ( cd "$PKG" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 $SHACMD > SHA256SUMS ) # ── 8b. 组包后关键交付物存在性断言(Codex review)────────────────────────────── [ -f "$PKG/tasks/audit-event-validate.sh" ] \ || die "组包异常:DLQ/spool 重放预检脚本未进入交付包(tasks/audit-event-validate.sh 缺失,guide §19/INSTALL.md §1 引用会失效)" # 精确锚定 SHA256SUMS 行尾(`<hash>␠␠./路径`,sha256sum/shasum 文本模式统一格式),不用子串匹配 # (grep -qF 会被更长路径的偶然子串误判为"已覆盖",如 .../not-tasks/audit-event-validate.sh 或 # tasks/audit-event-validate.sh.bak 均含该子串但并非同一交付文件) grep -qE ' \./tasks/audit-event-validate\.sh$' "$PKG/SHA256SUMS" \ || die "组包异常:SHA256SUMS 未覆盖 tasks/audit-event-validate.sh" # ── 9. 汇总 ────────────────────────────────────────────────────────────────── log "===== 打包完成 =====" du -sh "$PKG" | awk '{print " 总大小: "$1}' du -sh "$PKG"/images/*.tar.gz | sed 's/^/ /' echo log "交付包目录:$PKG" log "单文件传输(镜像已 gzip,外层不再压缩):" log " tar cf dist/${PKG_NAME}.tar -C dist ${PKG_NAME}" log "现场步骤见包内 INSTALL.md(make verify → load → 配 .env → up → wait → smoke)" docker/delivery/site.Makefile
New file @@ -0,0 +1,65 @@ # JNPF 整栈离线交付包 现场操作入口(本文件随包分发为 Makefile;流程详见 INSTALL.md) # 现场机器不需要源码、不需要外网:镜像全部来自 images/ 目录的离线 tar。 .PHONY: help verify load preflight up down restart ps logs wait smoke help: ## 显示本帮助 @grep -E '^[a-z-]+:.*##' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " make %-10s %s\n", $$1, $$2}' # up 强制前置 preflight:空卷/空库/缺 .env 键在 compose up 前拦下并给修复指引; # 确要跳过(自担风险):SKIP_PREFLIGHT=1 make up preflight: ## 部署自举校验:docker/内存/.env 必填键/数据卷/四库 schema/种子数据/业务角色 @if [ "$(SKIP_PREFLIGHT)" = "1" ]; then echo "跳过 preflight(SKIP_PREFLIGHT=1)"; \ else ./docker/preflight.sh; fi verify: ## 校验交付包完整性(sha256,镜像 tar 较大需数分钟) @command -v sha256sum >/dev/null && sha256sum -c SHA256SUMS || shasum -a 256 -c SHA256SUMS @echo "完整性校验通过 ✓" load: ## docker load 全部离线镜像(运行镜像 + 迁移辅助镜像) docker load -i images/cx-images.tar.gz @test -f images/cx-migration-tools.tar.gz && docker load -i images/cx-migration-tools.tar.gz || true up: preflight ## 用已 load 的本地镜像起全栈(绝不构建/拉取;冷启动 5-8 分钟后 make wait) docker compose up -d --no-build --pull never docker compose ps wait: ## 等待全部带健康检查的容器 healthy(业务 start_period 180s,冷启动约 5-8 分钟) @for i in $$(seq 1 60); do \ total=$$(docker compose ps --format '{{.Name}} {{.Health}}' | awk 'NF==2' | wc -l | tr -d ' '); \ ok=$$(docker compose ps --format '{{.Name}} {{.Health}}' | awk '$$2=="healthy"' | wc -l | tr -d ' '); \ echo "healthy $$ok/$$total"; \ [ -n "$$total" ] && [ "$$total" != "0" ] && [ "$$ok" = "$$total" ] && { echo "全部 healthy ✓"; exit 0; }; \ sleep 10; \ done; echo "超时:仍有容器未 healthy,docker compose ps 查看(L024:先确认内存 ≥12GB)"; exit 1 # --profile localdb 不能省:compose 的 down 只覆盖当前启用 profile 内的服务,漏带会把栈内 PG # 原样留着跑。带上它,无论本环境用不用栈内 PG,停栈都是干净的(没起过则是空操作)。 down: ## 全部停止并移除容器(数据安全:pgdata 是 external 卷,永不删除) docker compose --profile localdb down restart: ## 重启全栈容器 docker compose restart ps: ## 查看容器状态与健康 docker compose ps logs: ## 跟踪某服务日志:make logs s=cx-lims docker compose logs -f $(s) smoke: ## 冒烟三段:gateway 登录 + 前端静态 200 + 经 80 反代登录(依赖 curl/openssl/md5sum 或 md5) @set -o pipefail; \ MD5=$$(command -v md5 >/dev/null 2>&1 && echo md5 || echo md5sum); \ hash5() { if [ "$$MD5" = md5 ]; then md5; else md5sum | cut -d' ' -f1; fi; }; \ KEYHEX=$$(printf '%s' "$$(printf '%s' 'EY8WePvjM5GGwQzn' | hash5)" | xxd -p | tr -d '\n'); \ ENC=$$(printf '%s' "$$(printf '%s' '123456' | hash5)" | openssl enc -aes-256-ecb -K "$$KEYHEX" -nosalt | xxd -p | tr -d '\n'); \ RESP=$$(curl -fsS -X POST http://127.0.0.1:30000/api/oauth/Login -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode account=admin --data-urlencode "password=$$ENC" \ --data-urlencode grant_type=password --data-urlencode origin=password); \ echo "$$RESP" | head -c 200; echo; \ echo "$$RESP" | grep -q '"code":200' || { echo "冒烟失败:登录未返回 code 200(gateway 30000 直连)"; exit 1; }; \ curl -fsS -o /dev/null http://127.0.0.1:80/ || { echo "冒烟失败:前端静态入口 http://127.0.0.1:80/ 不通"; exit 1; }; \ RESP2=$$(curl -fsS -X POST http://127.0.0.1:80/api/oauth/Login -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode account=admin --data-urlencode "password=$$ENC" \ --data-urlencode grant_type=password --data-urlencode origin=password); \ echo "$$RESP2" | grep -q '"code":200' || { echo "冒烟失败:经前端 80 反代登录未返回 code 200"; exit 1; }; \ echo "冒烟通过 ✓(gateway 直连 + 前端静态 + 80 反代登录)" docker/flow-engine/Dockerfile
New file @@ -0,0 +1,19 @@ # JNPF 独立流程引擎(jnpf-workflow-admin = 官方 Flowable 7.0.1 + 6 个 JNPF 定制 REST 类) # 构建前置:先运行 ./prepare-context.sh 把 jar 暂存到 context/(63MB 不入 git) # 基座与 Nacos 同源:SWR amd64,构建产物 tar 可直接交付 x86 服务器 FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/bellsoft/liberica-openjre-rocky:21 LABEL maintainer=cx-team ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /opt/flow-engine COPY context/jnpf-workflow-admin-1.0.0-RELEASE.jar app.jar # Spring Boot 自动读 WORKDIR 下 config/ 目录(优先级高于 jar 内配置) COPY conf/ config/ EXPOSE 31000 # 参数对齐宿主 server.sh(-Xms600m -Xmx600m + add-opens + DisableAttachMechanism) ENTRYPOINT ["/bin/sh","-c","java -Xms600m -Xmx600m --add-opens java.base/java.lang=ALL-UNNAMED -XX:+DisableAttachMechanism -Dfile.encoding=utf8 -jar /opt/flow-engine/app.jar"] docker/flow-engine/conf/application-dev.yml
New file @@ -0,0 +1,30 @@ server: port: 31000 logging: level: root: info org.flowable.engine.impl.persistence.entity.*: debug org.flowable.task.service.impl.persistence.entity.*: debug spring: datasource: # 【必须显式指定 type】jar 内置 application-dev.yml 是 MySQL 模板 # (type: com.mysql.cj.jdbc.MysqlDataSource);Spring Boot 外部 config 与 jar 内配置是 # 「按属性合并」而非整体替换——只去掉本文件的 type,会让 jar 内的 MysqlDataSource 复活, # 导致 MySQL 驱动去连 postgres URL 报 "Connector/J cannot handle jdbc:postgresql://..."。 # 原 PGSimpleDataSource 也是靠这行显式 type 压住 jar 内 MySQL 的;改池就把它换成 Hikari。 type: com.zaxxer.hikari.HikariDataSource driver-class-name: org.postgresql.Driver url: ${FLOW_DB_URL:jdbc:postgresql://cx-postgres:5432/jnpf_flow} username: ${FLOW_DB_USER:postgres} # 密码无默认值:由 compose 注入(值在 .env,见 .env.example) password: ${FLOW_DB_PASSWORD} hikari: # 原 PGSimpleDataSource 无池、每事务开/关连接 churn 大且突发无上限;换 Hikari 小而封顶。 # 15 够 Flowable 多引擎(bpmn/cmmn/dmn) timer+async 采集线程 + HTTP 请求共享,且 < 角色配额。 maximum-pool-size: 15 minimum-idle: 2 connection-timeout: 5000 # 库满时 5s 快速失败,不无限挂起拖垮采集线程 keepalive-time: 120000 # 空闲连接保活,防被 PG/防火墙静默断开 max-lifetime: 1800000 # 连接最长寿命 30min,定期轮换避免老化 docker/flow-engine/conf/application.yml
New file @@ -0,0 +1,16 @@ spring: application: name: jnpf-workflow profiles: # 指定环境配置 dev(开发环境-默认)、test(测试环境)、preview(预生产)、prod(生产环境) active: dev main: allow-bean-definition-overriding: true allow-circular-references: true logging: config: classpath:logback-spring.xml springdoc: default-flat-param-object: true api-docs: enabled: true docker/flow-engine/prepare-context.sh
New file @@ -0,0 +1,46 @@ #!/usr/bin/env bash # 把独立流程引擎 jar 暂存进构建上下文(context/ 不入 git) # 取源规则: # - 默认:仓库自建工程 jnpf-flow-engine/target/ 的构建产物(`make flow-engine` 或 # `mvn -f jnpf-flow-engine/pom.xml package` 生成)。产物不存在直接报错,**不静默降级黑盒**, # 以免交付机把黑盒当自建引擎误发。 # - 回滚黑盒:必须显式 `FLOWAPI_JAR=<黑盒jar路径>`(不再自动降级),杜绝"以为发的是自建、实际是黑盒"。 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" CTX="${SCRIPT_DIR}/context" ENGINE_JAR="${REPO_ROOT}/jnpf-flow-engine/target/jnpf-workflow-admin-1.0.0-RELEASE.jar" if [ -n "${FLOWAPI_JAR:-}" ]; then JAR="${FLOWAPI_JAR}" ORIGIN="FLOWAPI_JAR 显式指定(回滚/自定义)" elif [ -f "${ENGINE_JAR}" ]; then JAR="${ENGINE_JAR}" ORIGIN="仓库自建 jnpf-flow-engine(任务 B 重建工程)" else echo "错误:未找到自建引擎产物 ${ENGINE_JAR}" >&2 echo " 请先构建:make flow-engine(或 mvn -f jnpf-flow-engine/pom.xml clean package -DskipTests)" >&2 echo " 如需回滚黑盒:FLOWAPI_JAR=~/Infra/jnpf/flowApi/jnpf-workflow-admin-1.0.0-RELEASE.jar $0" >&2 exit 1 fi [ -f "${JAR}" ] || { echo "错误:找不到 ${JAR}(可用 FLOWAPI_JAR 指定 jar 或先构建 jnpf-flow-engine)" >&2; exit 1; } JAR_SIZE=$(wc -c < "${JAR}" | tr -d ' ') [ "${JAR_SIZE}" -gt 30000000 ] || { echo "错误:${JAR} 只有 ${JAR_SIZE} 字节,疑似残缺" >&2; exit 1; } # 原子替换:先拷到临时目录校验成功再挪,避免中途失败留下半成品/空上下文 TMP="${CTX}.tmp.$$" rm -rf "${TMP}" mkdir -p "${TMP}" cp "${JAR}" "${TMP}/jnpf-workflow-admin-1.0.0-RELEASE.jar" rm -rf "${CTX}" mv "${TMP}" "${CTX}" echo "取源:${ORIGIN}" echo " ${JAR}" echo "已暂存到 ${CTX}:" du -sh "${CTX}"/*.jar echo "下一步:docker compose build cx-flow-engine" docker/frontend/.gitignore
New file @@ -0,0 +1,4 @@ # 构建产物快照(make web-dist 生成);untracked 会卡离线打包的干净提交校验,必须忽略 dist/ # 现场 TLS 证书挂载点(真实证书绝不入 git) certs/ docker/frontend/Dockerfile
New file @@ -0,0 +1,13 @@ # 前端静态托管 + API/WS 反代(Backlog F) # dist 由 make web-dist(sync-dist.sh)从前端仓库构建后快照进本目录,不入 git。 # ARG 名刻意避开 BASE_IMAGE:离线打包对全部服务统一注入 --build-arg BASE_IMAGE=<SWR JRE>, # 撞名会把 nginx 基底覆盖成 JRE 镜像(assemble-offline-package.sh 用 NGINX_BASE_IMAGE 单独注入)。 # 默认 Docker Hub 多架构(本地 arm64 原生);alpine 无 tzdata,access log 时间为 UTC。 ARG NGINX_BASE_IMAGE=nginx:1.27-alpine FROM ${NGINX_BASE_IMAGE} # conf 同时 COPY 进镜像(自包含兜底);compose 侧挂载同一文件为准,现场免构建可改 COPY nginx.conf /etc/nginx/conf.d/default.conf COPY dist/ /usr/share/nginx/html/ EXPOSE 80 docker/frontend/nginx.conf
New file @@ -0,0 +1,110 @@ # cx-web:SPA 静态托管 + /api、/websocket 反代 cx-gateway(compose 服务名直连) # 本文件被 compose 挂载到容器 /etc/nginx/conf.d/default.conf(镜像内亦有同份 COPY 兜底); # 现场改反代/开 TLS 直接编辑后 docker compose restart cx-web,无需重构建镜像。 server { listen 80; server_name _; root /usr/share/nginx/html; index index.html; # 上传经 /api 反代(分片上传单片默认几 MB,放宽到 512m 兜底整文件直传场景) client_max_body_size 512m; gzip on; gzip_min_length 1k; gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml font/woff2; # 变量 proxy_pass + docker 内嵌 DNS 惰性解析: # ① gateway 容器未起时 nginx 也能正常启动(静态 proxy_pass 会在启动期解析失败直接退出) # ② gateway 重建换 IP 后按 valid=30s 重新解析,不会缓存旧 IP 一直 502 resolver 127.0.0.11 valid=30s ipv6=off; set $gw http://cx-gateway:30000; # API:完整路径透传(proxy_pass 为变量且不带 URI => 原样转发,/api 前缀保留,网关按前缀路由) # ^~:命中此前缀后不再参与正则匹配——否则 /api/file/Image/xxx.jpg 会被下方 # 扩展名长缓存正则劫走当本地静态文件找而 404(图片预览经 80 反代的场景,2026-07-18 现场实证) location ^~ /api/ { proxy_pass $gw; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 🔴 2026-08-11 补:本 location 原先没设读超时,走 nginx 默认 60s #(那个 3600s 只写在下方 /websocket/ 里,对 /api/ 不生效)。 # 后果:任何 /api/** 超过 60 秒必然 504,且 nginx 只回一个自造的错误页, # 前端看不到后端的任何信息。实测撞上的是 # POST /api/workflow/operator/CandidateUser/0(选审批人)首次调用, # nginx error log 原文 "upstream timed out ... while reading response header"。 # 取 300s 与 Feign 的 client.config.default.readTimeout: 300000 对齐 #(config/shared/frame-config.yaml),使超时判定发生在后端而非 nginx—— # 后端超时至少能落日志、能回结构化错误。 # ⚠️ 这只是不让它 504,**没有解决"接口慢"本身**: # 流程相关接口偏慢是既有问题,定位与优化见 tasks/todo.md 的待优化章节。 proxy_read_timeout 300s; proxy_send_timeout 300s; } # 报表设计(Univer 版):前端 VITE_GLOB_REPORT_API_URL 生产构建为空,报表请求落到本域 # /api/Report,而网关 router.yaml 无此路由(该服务是独立发行包、不注册 Nacos)→ 404 # "No static resource api/Report."。此处按最长前缀优先(比下方 ^~ /api/ 更长,与顺序无关) # 单独截走,直连服务。 # ⚠️ 临时联调态:现指向宿主 java -jar 起的 32000;容器化(cx-report)后改成 $gw 同款 # 变量形式 http://cx-report:32000。host.docker.internal 走容器 /etc/hosts, # 故此处用静态 proxy_pass(变量形式会绕过 hosts 直查 DNS 解析不到)。 location ^~ /api/Report { proxy_pass http://host.docker.internal:32000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # WebSocket:前端生产环境连 ${location.origin}/websocket/<token>, # 网关实际路由是 /api/message/websocket/**(2026-07-17 curl 实证:前者 200、后者 101), # 故重写补前缀再反代(rewrite 的 URI 会替换 proxy_pass 转发路径) location ^~ /websocket/ { rewrite ^/websocket/(.*)$ /api/message/websocket/$1 break; proxy_pass $gw; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } # 运行时配置注入点(VITE_GLOB_* 三地址):改此文件免重构建生效,绝不能被长缓存 location = /_app.config.js { add_header Cache-Control "no-cache"; } # vite 产物带内容 hash(分布在 assets/ js/ css/ cdn/ 等目录),按扩展名长缓存; # html 不在列(index.html 走下方 no-cache);_app.config.js 由上方精确匹配优先兜住 location ~* \.(js|mjs|css|woff2?|ttf|eot|png|jpe?g|gif|svg|ico|mp3|mp4)$ { add_header Cache-Control "public, max-age=2592000, immutable"; } # history 路由:内页刷新/直达一律回落 index.html;index 本身不缓存(发版即生效) location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; } } # ── TLS(本期不启用,现场有证书后三步开启,详见交付包 INSTALL.md §5)────────── # ① 证书放到仓库/交付包根 docker/frontend/certs/{server.crt,server.key} # ② docker-compose.yml 的 cx-web 解开 "443:443" 端口与 certs 挂载两行注释 # ③ 解开下方 server 块注释后 docker compose restart cx-web # # server { # listen 443 ssl; # server_name _; # ssl_certificate /etc/nginx/certs/server.crt; # ssl_certificate_key /etc/nginx/certs/server.key; # ssl_protocols TLSv1.2 TLSv1.3; # # # 与 80 端口 server 块保持一致(root/反代/缓存各 location 原样复制到此处) # } docker/frontend/sync-dist.sh
New file @@ -0,0 +1,61 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # 前端 dist 生产链(make web-dist 的实现体): # 前端仓库 pnpm build:main → 产物快照到本目录 dist/(Docker build context,不入 git) # # 与 flow-engine 的 prepare-context.sh 同构:产物快照进部署仓库构建,镜像溯源 # 靠 dist/.build-info(前端 git sha/分支/dirty/构建时间)。 # # 用法: # ./sync-dist.sh # 构建 + 拷贝(默认) # SKIP_BUILD=1 ./sync-dist.sh # 只拷现有 dist(前端刚手工构建过时的快捷路径) # FRONTEND_REPO=/path ./sync-dist.sh # 前端仓库不在默认位置时显式指定 # # 前置:node >= 20.19、pnpm >= 9.12(前端仓库 engines 要求);构建吃 8GB 堆 #(NODE_OPTIONS 已在前端仓库 build script 内设置)。 # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" FRONTEND_REPO="${FRONTEND_REPO:-$REPO_ROOT/../jnpf-web-monorepo-framework-v6.1}" APP_DIST="$FRONTEND_REPO/apps/jnpf-web-apps-main/dist" log() { printf '\033[1;32m[web-dist]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[web-dist][err]\033[0m %s\n' "$*" >&2; exit 1; } [ -d "$FRONTEND_REPO" ] || die "前端仓库不存在:${FRONTEND_REPO}(用 FRONTEND_REPO=/path 指定)" if [ "${SKIP_BUILD:-0}" = "1" ]; then log "SKIP_BUILD=1:跳过构建,直接拷贝现有 dist" else command -v pnpm >/dev/null || die "未找到 pnpm(前端构建需要 pnpm >= 9.12)" log "pnpm build:main(前端仓库 ${FRONTEND_REPO},约需数分钟、8GB 堆)…" ( cd "$FRONTEND_REPO" && pnpm build:main ) fi # 产物完整性:index.html=入口;_app.config.js=运行时配置注入点(缺=构建链变化,必须人工确认) [ -f "$APP_DIST/index.html" ] || die "构建产物缺 index.html:${APP_DIST}" [ -f "$APP_DIST/_app.config.js" ] || die "构建产物缺 _app.config.js(运行时配置注入点):${APP_DIST}" # 先清后拷:防旧 hash 文件在 context 里无限堆积 DEST="$SCRIPT_DIR/dist" rm -rf "$DEST" cp -R "$APP_DIST" "$DEST" # 溯源信息:本地构建镜像无 RepoDigest,靠它对应回前端仓库 commit FE_SHA="$(git -C "$FRONTEND_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown)" FE_BRANCH="$(git -C "$FRONTEND_REPO" rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)" FE_DIRTY="" [ -n "$(git -C "$FRONTEND_REPO" status --porcelain 2>/dev/null)" ] && FE_DIRTY="-dirty" cat > "$DEST/.build-info" <<EOF frontend_repo=${FRONTEND_REPO} frontend_git=${FE_BRANCH}@${FE_SHA}${FE_DIRTY} built_at=$(date '+%Y-%m-%d %H:%M:%S %z') EOF log "dist 就绪:${DEST}($(du -sh "$DEST" | cut -f1),前端 ${FE_BRANCH}@${FE_SHA}${FE_DIRTY})" [ -n "$FE_DIRTY" ] && printf '\033[1;33m[web-dist][warn]\033[0m %s\n' \ "前端仓库有未提交改动——正式交付前请先在前端仓库提交,保证镜像可溯源" exit 0 docker/nacos/import-configs.sh
New file @@ -0,0 +1,6 @@ #!/usr/bin/env bash # ⛔ 已退役(2026-08-06,平台补丁 #8):Nacos 配置中心已从全栈移除。 # 配置现于仓库 config/shared/(见 config/README.md);本脚本保留仅为 # 补丁 #8 回滚路径服务(revert 合并后解除本护栏、从 git 历史恢复脚本原文再用)。 echo "⛔ import-configs.sh 已退役:Nacos 已移除(补丁 #8),配置见 config/shared/" >&2 exit 1 docker/postgres/Dockerfile
New file @@ -0,0 +1,41 @@ # JNPF PG 迁移标准镜像(方案见 docs/pg-migration-plan.md §1/§4) # 基底:SWR 搬运的官方 postgres:18.4(= Docker Hub 18.4-trixie,Debian 13 + PG 18.4-1.pgdg13+1, # 实测 os-release/PG_VERSION 一致,2026-07-17 切换)。SWR 无 -trixie 后缀 tag、单架构 amd64—— # 本镜像经 compose `platform: linux/amd64` 恒定 amd64 构建,无需业务基座那样的 ARG 双轨。 # 2026-07-14 决策修订:业务无 GIS 需求,弃用 postgis/postgis 基底(省 ~200MB、 # 供应链更干净);将来需要地图能力时换回 postgis 基底重打即可。 # # 构建机残余外部依赖(现场零依赖,产物随离线包走):Debian/PGDG apt + GitHub pg_bigm 源码。 # pg_bigm:PGDG apt 无此包,从官方上游源码编译(v1.2-20250903 起支持 PG18)。 FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/postgres:18.4 AS pg_bigm_builder # Debian apt 换华为云镜像(209MB 工具链的大头提速;debian.sources=Debian 13 deb822 格式)。 # PGDG 保留官方 apt.postgresql.org:其 trixie-pgdg 用版本化组件(main + '18'),国内镜像站 # 普遍只同步 main 缺 '18'(华为云实测缺,TUNA 全但测试服务器 https 不通);官方源包量小 # (server-dev-18+pgvector 约 2MB)且测试服务器实测可达。GitHub 浅克隆 ~100KB 不处理。 RUN sed -i 's|deb.debian.org|mirrors.huaweicloud.com|g' /etc/apt/sources.list.d/debian.sources RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ postgresql-server-dev-18 \ libicu-dev \ git \ ca-certificates \ && git clone --depth 1 --branch v1.2-20250903 \ https://github.com/pgbigm/pg_bigm.git /tmp/pg_bigm \ && make -C /tmp/pg_bigm USE_PGXS=1 PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config \ && make -C /tmp/pg_bigm USE_PGXS=1 PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config install FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/postgres:18.4 # Debian 换华为云镜像,同 builder 阶段(PGDG 保留官方,理由见上) RUN sed -i 's|deb.debian.org|mirrors.huaweicloud.com|g' /etc/apt/sources.list.d/debian.sources RUN apt-get update \ && apt-get install -y --no-install-recommends \ postgresql-18-pgvector \ && rm -rf /var/lib/apt/lists/* COPY --from=pg_bigm_builder /usr/lib/postgresql/18/lib/pg_bigm.so /usr/lib/postgresql/18/lib/ COPY --from=pg_bigm_builder /usr/share/postgresql/18/extension/pg_bigm* /usr/share/postgresql/18/extension/ docker/postgres/migration/.gitignore
New file @@ -0,0 +1,6 @@ # 交付包 payload:体积大 / 可从 repo 组装,不进 git(用 assemble-package.sh 生成) *.tar.gz *.log sql/ # 迁移中间产物:含现场业务数据,绝不进 git / 交付包(codex review 2026-07-17) dump/ docker/postgres/migration/README-迁移操作手册.md
New file @@ -0,0 +1,271 @@ # JNPF MySQL → PostgreSQL 迁移工具包操作手册 > ## 🏁 迁移已完成,本工具包大部分已退役(2026-07-25 标注) > > MySQL→PG 迁移于 **2026-07-21 全部完成**(数据落在腾讯云 CVM 共享 PG `YOUR_DB_HOST:15432`, > 开发与测试共用一库)。本目录内脚本的现状分三类: > > | 脚本 | 现状 | > |---|---| > | `backup.sh` | ✅ **仍是主力**——CVM 库每日 03:00 四库备份即由它执行 | > | `create-app-roles.sh` | ✅ **仍需要**——新环境(含将来的正式环境)restore 前必须先建角色,否则 owner 落不对 | > | `install.sh` / `assemble-*.sh` | ⚠️ **仅"全新空库"备用**——上线已改为整库 dump/restore(见 `docs/release-checklist.md` §1),不再现场重建 schema | > | `migrate-data.sh` / `migrate-nacos-config.sh` / `schema-drift-check.sh` | ⛔ **已退役,勿再执行**——它们的前提是"腾讯云 MySQL 是事实源",该前提已消失(MySQL 数据停在 07-16,仅作冷备回退底牌保留) | > > **误跑 `migrate-data.sh` 的后果是真实的数据事故**:它会用 MySQL 旧快照 truncate 重灌目标库。 > 脚本已加执行护栏(启动即拒绝,须显式设 `I_KNOW_MIGRATION_IS_RETIRED=1` 才放行)。 > > 当前拓扑与连接纪律:`docs/db-connection-pool-guide.md`。 > **定位(2026-07-17,Backlog B 方案 (c) 合流后)**:本目录是**一次性 MySQL→PG 数据迁移工具包**, > 只覆盖「schema 初始化 + 停机窗口迁数据 + 备份」;现场割接完成后即退役。 > **PG 容器的部署统一走仓库根 `docker-compose.yml`**(Backlog A 加固基线:端口收敛 + `${VAR:?}` > 硬约束凭据),旧 `docker-compose.prod.yml` 单机部署面已删除。 > 整栈离线交付见 `docs/docker-compose-guide.md` §14(`make images-offline`);本目录会被收入 > 交付包的 `migration/` 子目录。方案背景见 `docs/archive/pg-migration-plan.md`,踩坑见 `tasks/lessons.md`。 --- ## 0. 工具包内容 | 文件 | 说明 | |---|---| | `install.sh` | schema 初始化(全新空库):建四库(jnpf_init/jnpf_flow/jnpf_xxljob/nacos_config)+ 建表 | | `migrate-data.sh` | 停机窗口一次性数据迁移(现场 MySQL → PG) | | `backup.sh` | pg_dump 定时备份 + 保留策略(配 cron),默认含 nacos_config | | `create-app-roles.sh` + `create_app_roles.sql` | 建业务专用连库角色(三库三角色,读 .env,幂等,L030) | | `schema-drift-check.sh` | 表级+列级漂移检查(远程 MySQL 事实源 vs 本地 PG);合并 main 或数据刷新**之前**先跑,阻断项直接给类型明细(SOP 见 docs/docker-compose-guide.md §18,L035) | | `assemble-package.sh` | **构建机专用**,把 repo 里的 SQL 组装进 `sql/`(现场不用) | | `sql/` | 全部初始化/迁移 SQL(构建机 assemble 生成,不进 git) | ### 构建机组装(交付前,在开发机跑一次) ```bash cd docker/postgres/migration ./assemble-package.sh # 把 sql/ 填满(含官方 init + 自建表 DDL + pgloader 模板) ``` 组装结果随 `make images-offline` 一并进整栈离线交付包,不再单独打 tar。 --- ## 1. 现场前置条件 - Linux x86_64,已装 **Docker + docker compose 插件**(`docker compose version` 可用)。 - 数据盘:`/data` 分区建议 ≥ 20GB(PG 数据 + 备份 + 临时 dump)。 - 与现场 MySQL(源库)网络可达。 - PG 容器已按根 compose 拉起(见 §2);离线环境镜像已 `docker load`(含 `mysql:8.0` 与 `dimitri/pgloader` 迁移辅助镜像,在交付包 `images/jnpf-migration-tools-*.tar.gz`)。 --- ## 2. 起 PG 容器与建 schema(可提前做,不需停机) PG 由根 compose(仓库根或交付包根)拉起——凭据经 `.env` 注入、基线不对外发布端口: ```bash # 在仓库根 / 交付包根: cp .env.example .env && vi .env # 全部密码改现场值(角色名/用户名保持默认,SQL 已硬编码) docker volume create postgres_pgdata # 全新机器 docker compose up -d cx-postgres # 只起 PG(healthcheck 过了即可) ``` > **混合形态**(JNPF 服务暂留宿主进程、仅 PG 容器化):基线不发布 PG 端口,需临时 override—— > ```yaml > # docker-compose.override.yml(现场临时,切全容器后删除) > services: > cx-postgres: > ports: ["127.0.0.1:5432:5432"] > ``` > 全容器形态无需此步(业务经 compose 网络服务名 `cx-postgres:5432` 直连,见 release-checklist §0.10)。 然后建 schema(**只用于全新空库**;库里已有表会拒绝执行,防止 init 里的 DROP TABLE 清数据): ```bash cd migration/ # 交付包内;仓库内则 cd docker/postgres/migration ./install.sh # 建四库 → 官方 init → 自建表 → 数据接口修正 → Nacos 存储库 schema ``` 结束会打印表数核对,应为: ``` jnpf_init BASE TABLE = 366 (官方293 + 自建73) jnpf_flow BASE TABLE = 70 jnpf_xxljob BASE TABLE = 11 nacos_config BASE TABLE = 12 ``` > 此刻库是**空 schema**,业务数据在下一步停机窗口迁入。 > schema 就绪后按 `docs/release-checklist.md` §0.3 跑 `./create-app-roles.sh` 建业务角色 > (顺序铁律 L030:先定稿 .env → 跑脚本 → 同步 Nacos datasource.yaml → 重启消费方)。 --- ## 3. 停机窗口:数据迁移 > 迁移期间需**停止对源 MySQL 的写入**(停 JNPF 业务服务),保证数据一致。 ### 3.1 时序 ``` ① 停 JNPF 业务服务(停止对源 MySQL 写入) ② cd migration/ && SRC_MYSQL_HOST=... SRC_MYSQL_PWD='***' ./migrate-data.sh all ③ 核对 migrate-data.sh 末尾抽样校验 + 跑全量 rowcount 校验(§3.3) ④ 切 Nacos datasource.yaml 到 PG(§4) ⑤ 重启 JNPF 服务,冒烟验证 ⑥ 清理临时资源:./migrate-data.sh cleanup ``` ### 3.2 `migrate-data.sh all` 做了什么 1. **dump**:从现场 MySQL `mysqldump`(`--single-transaction --hex-blob`,跳过 `base_sys_log` 大日志表)导出 `jnpf_init` / `jnpf_flow`。 2. **mirror**:起临时 `mysql:8.0` 中转容器(`mysql_native_password`),灌入 dump。 —— 为何中转:pgloader 的 qmynd 库不支持 MySQL8 握手,必须经 native_password 的 mirror。 3. **load**: - `pgloader` 灌 `jnpf_init`(排除 `base_sys_log`、`blade_visual_map`)与 `jnpf_flow`(大写 ACT/FLW 表)。 - `blade_visual_map`(巨行表,pgloader 会堆爆)走 `mysqldump --tab` → PG `COPY`(NULL=`\N` 天然兼容)。 - 重放数据接口修正 `data_interface_pg_fixes.sql`(4 条 UPDATE)。 - `lims_biz_log.log_seq` 序列 setval 到当前最大值。 - 抽样行数校验(mirror vs PG)。 源库地址与密码**必须显式传入**(不设软默认,codex review 2026-07-17);目标 PG 密码默认读 仓库根/交付包根 `.env` 的 `JNPF_PG_PASSWORD`: ```bash SRC_MYSQL_HOST=x.x.x.x SRC_MYSQL_PORT=3306 SRC_MYSQL_USER=root SRC_MYSQL_PWD='***' \ ./migrate-data.sh all ``` > 迁移产生的 `dump/` 目录含完整业务数据,验收后务必 `./migrate-data.sh cleanup` 清除。 ### 3.3 全量逐表行数校验(强烈建议) ```bash # mirror 仍在时,逐表对比 MySQL 源 vs PG(应输出「不一致 0」) MYSQL_HOST=cx-mysql-mirror MYSQL_PORT=3306 MYSQL_USER=root MYSQL_PWD_V=mirror_2026 \ MYSQL_DOCKER_NET=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' cx-postgres) \ PG_CONTAINER=cx-postgres \ bash sql/rowcount_check.sh jnpf_init # jnpf_flow 同理: ... bash sql/rowcount_check.sh jnpf_flow ``` --- ## 4. 切换 Nacos 数据源 `datasource.yaml` 改为 PG(示例值,现场按实际填): ```yaml jnpf: datasource: db-type: PostgreSQL host: 127.0.0.1 # PG 容器宿主 port: 5432 db-name: jnpf_init username: postgres password: 改成强密码 # 与 install 时 PG_PASSWORD 一致 ``` 改法二选一: - **控制台**:Nacos → 配置管理 → `datasource.yaml`(group=DEFAULT_GROUP、对应命名空间)→ 编辑发布。 - **API**: ```bash TOKEN=$(curl -s -X POST 'http://<nacos>:8848/nacos/v1/auth/login' \ -d 'username=nacos&password=nacos' | sed 's/.*"accessToken":"\([^"]*\)".*/\1/') curl -X POST "http://<nacos>:8848/nacos/v1/cs/configs?accessToken=$TOKEN" \ --data-urlencode 'dataId=datasource.yaml' \ --data-urlencode 'group=DEFAULT_GROUP' \ --data-urlencode 'tenant=<命名空间id>' \ --data-urlencode 'type=yaml' \ --data-urlencode 'content@datasource.yaml' ``` > ⚠️ **`ConfigValueUtil` 没有 `@RefreshScope`**:改完 Nacos **必须重启 JNPF 服务**才生效,不会热更新。 重启后冒烟:登录 → 菜单 → 打开若干 LIMS 台账列表(数据/中文正常)→ 文件上传预览(注意 L015:Nacos `config.ApiDomain` 要指现场网关域名)。 --- ## 5. 定时备份 ```bash # 手动跑一次确认可用(产物落 volume 外) BACKUP_DIR=/data/pg-backup ./backup.sh # crontab -e,每天 02:00 备份,保留 14 天(路径按现场实际解包位置调整) 0 2 * * * BACKUP_DIR=/data/pg-backup RETENTION_DAYS=14 /opt/jnpf/offline/migration/backup.sh >> /var/log/jnpf-pg-backup.log 2>&1 ``` 还原(custom 格式 `-Fc`,可选择性还原): ```bash docker exec -i cx-postgres pg_restore -U postgres -d jnpf_init --clean --if-exists < /data/pg-backup/jnpf_init_YYYYMMDD_HHMMSS.dump ``` --- ## 5.5 附加:Nacos 与 xxl-job-admin 存储库也切 PG(可选,建议同窗口做) > 本地已验证(2026-07-16):JNPF 定制 Nacos 与 xxl-job-admin 均原生支持 PG,切换后 MySQL 可完全下线。 ### Nacos 存储库 1. PG 建库跑 schema:`install.sh` 已把 `nacos_config` 作为第四库一并建好(含 nacos/nacos 登录种子—— 官方 mysql-schema.sql 无 INSERT,漏这步空库无法登录)。老版本单独建库的命令保留备查: ```bash docker exec cx-postgres psql -U postgres -c "CREATE DATABASE nacos_config ENCODING 'UTF8'" docker exec -i cx-postgres psql -U postgres -d nacos_config -v ON_ERROR_STOP=1 -f - < sql/nacos_config_pg.sql ``` 2. (已有 Nacos 迁移时)**先经旧 Nacos API 导出**全部 namespace + 配置(逐条 GET content 落盘)。 3. 改 `nacos/conf/application.properties`(先备份;文件里自带 Postgre 注释示例段,照抄改连接串): ```properties spring.datasource.convert=postgre # platform 保持 mysql 不动 db.url.0=jdbc:postgresql://<PG宿主>:5432/nacos_config?currentSchema=public db.user=postgres db.password=<PG密码> ``` 4. 重启 Nacos,`logs/start.out` 出现 `SwitchDatabase:postgre` + `use external storage` 即成功;登录(nacos/nacos,尽快改密)→ 同 id 重建 namespace(`customNamespaceId` 参数)→ 重发配置 → 逐条 GET 回来与导出内容 diff。 5. **切换顺序(重要,实测教训)**:`建库跑 schema → 导出旧配置 → 停全部 JNPF 业务服务 → 改配置重启 Nacos → 导入配置并 diff → 按序拉起业务服务`。**不要让业务服务带着旧连接硬扛 Nacos 重启**——nacos-client 在服务端不可达窗口会泄漏 grpc 重连线程(实测 1.5h 后打满每进程 4096 上限,服务假活、注册表逐个掉线,见 tasks/lessons.md L022)。 6. 回滚:恢复 application.properties 备份 + 重启。 ### xxl-job-admin 1. `application-dev.yml` 里注释 MySQL 段、启用自带的 PostgreSQL 注释段: ```yaml datasource: url: jdbc:postgresql://<PG宿主>:5432/jnpf_xxljob username: postgres password: <PG密码> driver-class-name: org.postgresql.Driver # 驱动 jar 已内置 postgresql-42.7.8 ``` 2. `server.sh restart`,登录 `/xxl-job-admin`(admin/123456 种子)验证列表/日志页正常。 3. ⚠️ **JNPF 商业 license**:admin 启动要求 `jnpf.license.licensePath`(默认 `/data/jnpfsoft/license.lic`)存在且有效(绑定硬件),缺失即退出——本地开发机无 license 未验启动,**此步必须在现场有 license 的机器上做**。 4. jnpf-scheduletask(30009) 服务本身走主 `datasource.yaml`(已是 PG),`datasource-scheduletask.yaml` 里只有 admin 地址无 DB 连接,无需改。 ## 6. 回滚方案 PG 侧数据是**新写入**,源 MySQL 在停机窗口内**未被改动** → 回滚零数据损失: 1. 把 Nacos `datasource.yaml` 切回 **MySQL 原连接**: ```yaml db-type: MySQL host: <现场源 MySQL 地址> port: 3306 db-name: jnpf_init username: <原用户> password: <原密码> ``` 2. **重启 JNPF 服务**(同样因无 `@RefreshScope`)。 3. 业务恢复到 MySQL。PG 容器可保留待排查,不影响回滚。 > 回滚判据:切 PG 后核心链路冒烟失败、或 §3.3 校验发现数据缺失且无法快速修复。 --- ## 7. 常见问题(对应 lessons.md) - **服务报 `database "jnpf_init" does not exist` 但 `docker exec psql` 能看到表**(L016):宿主机另有 PG 占了 5432,服务连错实例。`lsof -iTCP:5432 -sTCP:LISTEN` 查占用;现场应确保只有容器监听 5432。 - **脚本化登录报"不支持此登录方式"**(L017):JNPF 登录是 `form-urlencoded + AES(MD5(密码))`,不能用 JSON 裸调。 - **某在线模型 List 返回空**(L018):先查 `base_visual_release.f_web_type`,webType=1 纯表单无列表属正常,非数据丢失。 - **文件上传成功但预览/下载 502**(L015):Nacos `system-config.yaml` 的 `config.ApiDomain` 要指现场网关;改后重启 `jnpf-file`。 --- ## 8. 命令速查 | 目的 | 命令 | |---|---| | 起 PG 容器(根 compose) | `docker volume create postgres_pgdata && docker compose up -d cx-postgres` | | 建 schema(全新空库) | `./install.sh` | | 建业务角色(.env 定稿后) | `./create-app-roles.sh` | | 停机窗口迁数据 | `SRC_MYSQL_HOST=... SRC_MYSQL_PWD='***' ./migrate-data.sh all` | | 只跑 PG 侧(mirror 已就绪) | `./migrate-data.sh load` | | 全量行数校验 | 见 §3.3 | | 清理临时 mirror/dump | `./migrate-data.sh cleanup` | | 备份 | `BACKUP_DIR=/data/pg-backup ./backup.sh` | | 进 PG 命令行 | `docker exec -it cx-postgres psql -U postgres -d jnpf_init` | docker/postgres/migration/assemble-package.sh
New file @@ -0,0 +1,65 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # 构建机专用:把 repo 里的 DDL/初始化脚本组装进 delivery/sql/,让交付包自包含。 # 现场工程师【不】跑此脚本——他们收到的是已组装好的 delivery/ 目录。 # # 用法(在本机 repo 内跑一次): # ./assemble-package.sh # 可选环境变量: # DB_REPO 官方 PostgreSQL 初始化脚本所在目录 # 默认 /Users/yangsui/Code/clients/qiansheng/jnpf-database-v6.1.x/PostgreSQL # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" # delivery → postgres → docker → repo 根 DDL_DIR="$REPO_ROOT/docs/pg-migration-ddl" DB_REPO="${DB_REPO:-/Users/yangsui/Code/clients/qiansheng/jnpf-database-v6.1.x/PostgreSQL}" SQL_OUT="$SCRIPT_DIR/sql" echo "==> repo 根 : $REPO_ROOT" echo "==> DDL 产物目录 : $DDL_DIR" echo "==> 官方脚本目录 : $DB_REPO" echo "==> 组装输出 : $SQL_OUT" [ -d "$DDL_DIR" ] || { echo "!! 缺 $DDL_DIR"; exit 1; } [ -d "$DB_REPO" ] || { echo "!! 缺 ${DB_REPO}(传 DB_REPO=... 指定官方脚本目录)"; exit 1; } mkdir -p "$SQL_OUT" # 1) 官方三库初始化脚本(jnpf_init 用 dbnull + 1127 增量;flow / xxljob 各一份) copy_official() { local src="$1" [ -f "$src" ] || { echo "!! 缺官方脚本 $src"; exit 1; } cp -f "$src" "$SQL_OUT/" echo " + $(basename "$src") ($(du -h "$src" | cut -f1))" } echo "-- 官方初始化脚本 --" copy_official "$DB_REPO/jnpf_dbnull_init.sql" copy_official "$DB_REPO/Update/Update_PostgreSQL_1127.sql" copy_official "$DB_REPO/jnpf_flow_init.sql" copy_official "$DB_REPO/jnpf_xxljob_init.sql" # 2) 自建表 DDL + 数据接口修正 + Nacos 存储库 schema + 行数校验脚本 echo "-- DDL 产物 --" for f in self_built_tables_pg.sql update_lims_huanjing_20260717.sql update_lims_sxt_wendingxing_20260717.sql update_lims_sxt_wendingxing_20260720.sql update_lims_sxt_flow_biz_status_20260719.sql update_lims_sxt_quyang_zhongzhi_shenqing_biz_status_20260723.sql update_lims_sxt_quyang_zhongzhi_shenqing_qingyandan_link_20260723.sql update_lims_sxt_plan_status_20260720.sql update_lims_wendingxing_biangeng_20260720.sql create_lims_sxt_quyang_jihua_renwu_20260724.sql alter_lims_sxt_quyang_jihua_renwu_jianyan_xiang_kuaizhao_20260724.sql create_audit_events_ext_20260724.sql create_audit_events_20260724.sql update_audit_events_title_entry_20260810.sql update_audit_events_ingest_merge_20260818.sql repair_audit_events_integrity_20260824.sql create_dms_permission_v2_2_20260815.sql upgrade_dms_permission_v2_2_to_v2_3_20260817.sql create_dms_file_objects_20260817.sql upgrade_dms_file_properties_20260820.sql data_interface_pg_fixes.sql nacos_config_pg.sql update_audit_events_target_timeline_index_20260823.sql rowcount_check.sh; do [ -f "$DDL_DIR/$f" ] || { echo "!! 缺 $DDL_DIR/$f"; exit 1; } cp -f "$DDL_DIR/$f" "$SQL_OUT/" echo " + $f ($(du -h "$DDL_DIR/$f" | cut -f1))" done # 3) pgloader 模板 echo "-- pgloader 模板 --" for f in pgloader_jnpf_init.load.tpl pgloader_jnpf_init_bigtable.load.tpl pgloader_jnpf_flow.load.tpl; do [ -f "$DDL_DIR/$f" ] || { echo "!! 缺 $DDL_DIR/$f"; exit 1; } cp -f "$DDL_DIR/$f" "$SQL_OUT/" echo " + $f" done chmod +x "$SQL_OUT/rowcount_check.sh" echo "" echo "==> 组装完成。sql/ 内容:" ls -la "$SQL_OUT" echo "" echo "下一步:本目录不再单独打 tar 交付——整栈离线交付包会把 migration/(含本 sql/)" echo "一并收入:make images-offline(见 docker/delivery/ 与 docs/docker-compose-guide.md §14)" docker/postgres/migration/assemble-sql-onsite.sh
New file @@ -0,0 +1,68 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # 现场组装 sql/(git 拉码部署路线专用;离线包路线的包内 sql/ 已由构建机组装好,不用本脚本) # # 官方初始化 SQL:sql-seed/*.sql.gz 解压(入库的 gzip 种子,jnpf_dbnull_init 原文件 # 101MB 超 GitHub 单文件上限,故以 gz 形态入库) # 自建 DDL/模板 :docs/pg-migration-ddl/ 直接拷贝(git 已跟踪) # # 用法(仓库任意位置执行均可,幂等可重跑): # ./docker/postgres/migration/assemble-sql-onsite.sh # 之后照常:./install.sh && ./create-app-roles.sh # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" DDL_DIR="$REPO_ROOT/docs/pg-migration-ddl" SEED_DIR="$SCRIPT_DIR/sql-seed" OUT="$SCRIPT_DIR/sql" log() { printf '\033[1;32m[assemble]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[assemble][err]\033[0m %s\n' "$*" >&2; exit 1; } [ -d "$SEED_DIR" ] || die "缺 ${SEED_DIR}(git pull 拿最新代码)" [ -d "$DDL_DIR" ] || die "缺 ${DDL_DIR}" mkdir -p "$OUT" log "解压官方初始化 SQL(sql-seed → sql/)…" for gz in "$SEED_DIR"/*.sql.gz; do base="$(basename "$gz" .gz)" gunzip -c "$gz" > "$OUT/$base" log " + ${base} ($(du -h "$OUT/$base" | cut -f1))" done log "拷贝自建 DDL / pgloader 模板(docs/pg-migration-ddl → sql/)…" for f in self_built_tables_pg.sql update_lims_huanjing_20260717.sql \ update_lims_sxt_wendingxing_20260717.sql update_lims_sxt_wendingxing_20260720.sql \ update_lims_sxt_flow_biz_status_20260719.sql update_lims_sxt_quyang_zhongzhi_shenqing_biz_status_20260723.sql \ update_lims_sxt_quyang_zhongzhi_shenqing_qingyandan_link_20260723.sql \ update_lims_sxt_plan_status_20260720.sql \ update_lims_wendingxing_biangeng_20260720.sql \ create_lims_sxt_quyang_jihua_renwu_20260724.sql \ alter_lims_sxt_quyang_jihua_renwu_jianyan_xiang_kuaizhao_20260724.sql \ create_audit_events_ext_20260724.sql \ create_audit_events_20260724.sql \ update_audit_events_title_entry_20260810.sql \ update_audit_events_ingest_merge_20260818.sql \ repair_audit_events_integrity_20260824.sql \ create_dms_permission_v2_2_20260815.sql \ upgrade_dms_permission_v2_2_to_v2_3_20260817.sql \ create_dms_file_objects_20260817.sql \ upgrade_dms_file_properties_20260820.sql \ update_audit_events_target_timeline_index_20260823.sql \ data_interface_pg_fixes.sql \ nacos_config_pg.sql rowcount_check.sh \ pgloader_jnpf_init.load.tpl pgloader_jnpf_init_bigtable.load.tpl pgloader_jnpf_flow.load.tpl; do [ -f "$DDL_DIR/$f" ] || die "缺 ${DDL_DIR}/${f}" cp -f "$DDL_DIR/$f" "$OUT/" log " + ${f}" done chmod +x "$OUT/rowcount_check.sh" # 完整性终检:install.sh / migrate-data.sh 消费的关键文件逐一在位 for f in jnpf_dbnull_init.sql Update_PostgreSQL_1127.sql jnpf_flow_init.sql jnpf_xxljob_init.sql \ self_built_tables_pg.sql nacos_config_pg.sql; do [ -f "$OUT/$f" ] || die "组装后仍缺 ${f}" done log "sql/ 组装完成 ✓($(command ls "$OUT" | wc -l | tr -d ' ') 个文件,$(du -sh "$OUT" | cut -f1))" docker/postgres/migration/backup.sh
New file @@ -0,0 +1,79 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # JNPF PostgreSQL 定时备份 # - docker exec pg_dump 逐库导出(custom 格式 -Fc,已压缩、可 pg_restore 选择性还原) # - 备份产物落 volume 外的宿主目录(BACKUP_DIR),容器/卷损坏也不丢 # - 每份导出用 pg_restore -l 校验可读性 # - 保留最近 RETENTION_DAYS 天,更早的自动删除 # # 用法: # ./backup.sh # 备份全部三库 # DATABASES="jnpf_init" ./backup.sh # # 参数(环境变量): # CONTAINER PG 容器名 默认 cx-pg-customer1(2026-08-08 客户A改名,交付环境用时显式传 CONTAINER=cx-postgres) # PGUSER 超级用户 默认 postgres # DATABASES 待备份库(空格分隔) 默认 "jnpf_init jnpf_flow jnpf_xxljob nacos_config" # BACKUP_DIR 宿主备份目录 默认 /data/pg-backup(务必在 volume 外) # RETENTION_DAYS 保留天数 默认 14 # # ── cron 定时(现场 crontab -e,每天 02:00)───────────────────────────────── # 0 2 * * * /opt/jnpf/offline/migration/backup.sh >> /var/log/jnpf-pg-backup.log 2>&1 # 注意:宿主文件存储(.env JNPF_DATA_DIR 下 jnpf-resources/,上传附件所在)不在 PG 里, # 需另行纳入文件级备份(rsync/tar 均可) # 还原示例: # gunzip -c jnpf_init_YYYYMMDD_HHMMSS.dump.gz > /tmp/x.dump # 若已 gzip # docker exec -i cx-pg-customer1 pg_restore -U postgres -d jnpf_init --clean --if-exists < /tmp/x.dump # (-Fc 自身已压缩,本脚本不再额外 gzip;下方 .dump 可直接 pg_restore) # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail CONTAINER="${CONTAINER:-cx-pg-customer1}" PGUSER="${PGUSER:-postgres}" # nacos_config 一并备份(配置中心内容只存 PG,codex review 2026-07-17 补) DATABASES="${DATABASES:-jnpf_init jnpf_flow jnpf_xxljob nacos_config}" # Windows/Git Bash 护栏:默认 /data/pg-backup 会映射进 Git 安装目录(静默备份到错误位置), # 故 MSYS 环境必须显式传 BACKUP_DIR(如 /d/pg-backup) case "$(uname -s)" in MINGW*|MSYS*) [ -n "${BACKUP_DIR:-}" ] || { printf '\033[1;31m[backup][err]\033[0m %s\n' \ "Windows/Git Bash 下必须显式指定 BACKUP_DIR,如:BACKUP_DIR=/d/pg-backup $0" >&2 exit 1 } ;; esac BACKUP_DIR="${BACKUP_DIR:-/data/pg-backup}" RETENTION_DAYS="${RETENTION_DAYS:-14}" TS="$(date +%Y%m%d_%H%M%S)" log() { printf '\033[1;34m[backup]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[backup][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[backup][err]\033[0m %s\n' "$*" >&2; exit 1; } docker inspect "$CONTAINER" >/dev/null 2>&1 || die "容器 $CONTAINER 不在" mkdir -p "$BACKUP_DIR" fail=0 for db in $DATABASES; do out="$BACKUP_DIR/${db}_${TS}.dump" log "pg_dump ${db} → $(basename "$out")" # -Fc custom 格式;失败则删残缺文件并计入失败 if docker exec "$CONTAINER" pg_dump -U "$PGUSER" -Fc -d "$db" > "$out" 2>/dev/null; then # 校验:pg_restore -l 能列出目录即视为可读 if docker exec -i "$CONTAINER" pg_restore -l < "$out" >/dev/null 2>&1; then log " ✓ ${db} $(du -h "$out" | cut -f1) 校验通过" else warn " ✗ ${db} 导出文件 pg_restore -l 校验失败"; fail=$((fail+1)) fi else warn " ✗ ${db} pg_dump 失败"; rm -f "$out"; fail=$((fail+1)) fi done log "清理 ${RETENTION_DAYS} 天前的旧备份 …" find "$BACKUP_DIR" -maxdepth 1 -name '*.dump' -type f -mtime "+${RETENTION_DAYS}" -print -delete | while read -r f; do log " 删除 $(basename "$f")" done log "当前备份目录占用:$(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1)" [ "$fail" = 0 ] || die "有 ${fail} 个库备份失败,检查日志" log "备份完成 ✓(${BACKUP_DIR})" docker/postgres/migration/create-app-roles.sh
New file @@ -0,0 +1,116 @@ #!/usr/bin/env bash # Backlog A4:创建业务专用连库角色(jnpf_app / jnpf_flow_app / nacos_app)并移交存量对象所有权。 # 幂等,可重复执行;密码取自仓库根 .env(键见下),执行后按 .env 重置角色密码。 # # 前置:目标 PG 容器在跑 # # 参数(环境变量): # CONTAINER 目标 PG 容器名,默认 cx-postgres # ENV_FILE 凭据文件,默认从脚本目录向上找第一个 .env # # 🔴 本脚本会 ALTER ROLE ... PASSWORD 【重置】角色密码。多实例机器上跑错 CONTAINER # =把另一个客户的密码改掉、配额改掉,业务当场连不上。开跑前必看它打印的目标容器。 # 用法:./docker/postgres/migration/create-app-roles.sh(仓库根) # 或 migration/create-app-roles.sh(离线交付包内,.env 在包根) # # 执行后的联动(手工,见 docs/release-checklist.md §0.3): # 1. config/shared/datasource.yaml:spring.datasource.username/password → jnpf_app # 2. .env:JNPF_FLOW_DB_USER/PASSWORD → jnpf_flow_app;JNPF_NACOS_DB_USER/PASSWORD → nacos_app # 3. docker compose up -d(重建 cx-flow-engine)+ 重启业务服务 set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 目标容器:多实例机器上必须显式传,默认值只为兼容单实例的历史调用 CONTAINER="${CONTAINER:-cx-postgres}" docker inspect "$CONTAINER" >/dev/null 2>&1 || { echo "错误:容器 $CONTAINER 不在"; exit 1; } printf '\033[1;33m目标 PG 容器:%s\033[0m(本脚本会重置该实例的角色密码与连接配额)\n' "$CONTAINER" # 凭据文件:优先 ENV_FILE 显式指定,否则从脚本目录向上找第一个含 .env 的目录(仓库根/交付包根)。 # # ENV_FILE 是为多客户拓扑加的(docs/adr-multi-customer-db-isolation.md §8):一台机器上跑着多个 # 客户的 PG 实例,而密码与连接配额是【按实例】不同的。没有这个开关就只能"临时改共享 .env → 跑脚本 # → 记得改回来",漏改一次就会在下次对别的实例跑本脚本时把那个客户的密码改成这个客户的。 # 用法:ENV_FILE=/home/ubuntu/jnpf-pg/.env.c2 CONTAINER=cx-pg-c2 ./create-app-roles.sh if [ -n "${ENV_FILE:-}" ]; then [ -f "$ENV_FILE" ] || { echo "错误:ENV_FILE 指定的 $ENV_FILE 不存在"; exit 1; } ENV_PATH="$ENV_FILE" else ROOT=""; d="$SCRIPT_DIR" while [ "$d" != "/" ]; do if [ -f "$d/.env" ]; then ROOT="$d"; break; fi d="$(dirname "$d")" done [ -n "$ROOT" ] || { echo "错误:向上未找到 .env(在仓库根/交付包根 cp .env.example .env 并定稿后重试,L030)"; exit 1; } ENV_PATH="$ROOT/.env" fi echo "凭据文件:$ENV_PATH" # 只按键取值,不 source(.env 可能含任意字符)。 # `|| true` 不可省:set -euo pipefail 下 grep 无匹配返回 1,经 pipefail 传给命令替换会令赋值失败 # 并被 set -e 直接终止脚本——下面那些"缺 XXX"的友好报错反而永远走不到。 envval() { grep -E "^$1=" "$ENV_PATH" | tail -1 | cut -d= -f2- || true; } APP_PW="$(envval JNPF_APP_DB_PASSWORD)" FLOW_PW="$(envval JNPF_FLOW_DB_PASSWORD)" NACOS_PW="$(envval JNPF_NACOS_DB_PASSWORD)" for pair in "JNPF_APP_DB_PASSWORD:${APP_PW}" "JNPF_FLOW_DB_PASSWORD:${FLOW_PW}"; do [ -n "${pair#*:}" ] || { echo "错误:.env 缺 ${pair%%:*}(模板见 .env.example)"; exit 1; } done # nacos_app:补丁 #8 后新栈不再使用,.env.example 已不含 JNPF_NACOS_DB_PASSWORD。 # 只有运维显式在 ${ENV_FILE:-.env} 里设置该键(兼容仍跑旧 Nacos 架构的实例)时才处理这个角色; # 缺键时不当错误处理,直接跳过,不许因此报错退出。 NACOS_ENABLED=false if [ -n "$NACOS_PW" ]; then NACOS_ENABLED=true else echo "跳过 nacos_app(补丁 #8 后栈不再使用;旧实例兼容需自行设 JNPF_NACOS_DB_PASSWORD)" fi # 连接数配额(L029 纵深防御:防单角色占满连接槽饿死流程引擎等)。缺省 = 正式/单套环境推荐值; # CVM 共享库在 .env 覆盖为 350/35/10。铁律:三者之和须 < (max_connections − superuser_reserved)。 APP_CONN_LIMIT="$(envval JNPF_APP_CONN_LIMIT)"; APP_CONN_LIMIT="${APP_CONN_LIMIT:-150}" FLOW_CONN_LIMIT="$(envval JNPF_FLOW_CONN_LIMIT)"; FLOW_CONN_LIMIT="${FLOW_CONN_LIMIT:-30}" NACOS_CONN_LIMIT="$(envval JNPF_NACOS_CONN_LIMIT)"; NACOS_CONN_LIMIT="${NACOS_CONN_LIMIT:-10}" # 校验为非负整数(psql -v 直接内插进 CONNECTION LIMIT,非整数会致语法错/注入) # nacos_app 未启用时不校验其配额——不参与求和也不会被用到,避免遗留脏值挡住 app/flow 两个角色 CONN_CHECK_PAIRS=("JNPF_APP_CONN_LIMIT:${APP_CONN_LIMIT}" "JNPF_FLOW_CONN_LIMIT:${FLOW_CONN_LIMIT}") [ "$NACOS_ENABLED" = true ] && CONN_CHECK_PAIRS+=("JNPF_NACOS_CONN_LIMIT:${NACOS_CONN_LIMIT}") for pair in "${CONN_CHECK_PAIRS[@]}"; do case "${pair#*:}" in ''|*[!0-9]*) echo "错误:${pair%%:*} 必须是非负整数,当前='${pair#*:}'(.env 或改默认)"; exit 1;; esac done # 提示文案按实际处理的角色数计算(nacos_app 跳过时不计入求和) if [ "$NACOS_ENABLED" = true ]; then echo "连接配额:jnpf_app=${APP_CONN_LIMIT} / jnpf_flow_app=${FLOW_CONN_LIMIT} / nacos_app=${NACOS_CONN_LIMIT}(和=$((APP_CONN_LIMIT+FLOW_CONN_LIMIT+NACOS_CONN_LIMIT)),须 < max_connections−3)" else echo "连接配额:jnpf_app=${APP_CONN_LIMIT} / jnpf_flow_app=${FLOW_CONN_LIMIT}(和=$((APP_CONN_LIMIT+FLOW_CONN_LIMIT)),须 < max_connections−3;nacos_app 已跳过,不计入)" fi docker exec -i "$CONTAINER" psql -U postgres -d postgres \ -v ON_ERROR_STOP=1 \ -v nacos_enabled="${NACOS_ENABLED}" \ -v app_pw="${APP_PW}" \ -v flow_pw="${FLOW_PW}" \ -v nacos_pw="${NACOS_PW}" \ -v app_conn_limit="${APP_CONN_LIMIT}" \ -v flow_conn_limit="${FLOW_CONN_LIMIT}" \ -v nacos_conn_limit="${NACOS_CONN_LIMIT}" \ -f - < "$SCRIPT_DIR/create_app_roles.sql" echo echo "== 验证:各角色 TCP 连各自库(容器 eth0 地址,走 scram 真验密码;socket/127.0.0.1 是 trust 验不出错密码) ==" PGIP=$(docker exec "$CONTAINER" hostname -i | awk '{print $1}') docker exec "$CONTAINER" psql "postgresql://jnpf_app:${APP_PW}@${PGIP}:5432/jnpf_init" -Atc "SELECT 'jnpf_app→jnpf_init OK'" docker exec "$CONTAINER" psql "postgresql://jnpf_flow_app:${FLOW_PW}@${PGIP}:5432/jnpf_flow" -Atc "SELECT 'jnpf_flow_app→jnpf_flow OK'" if [ "$NACOS_ENABLED" = true ]; then docker exec "$CONTAINER" psql "postgresql://nacos_app:${NACOS_PW}@${PGIP}:5432/nacos_config" -Atc "SELECT 'nacos_app→nacos_config OK'" fi echo "== 验证:越库应被拒(期望报 permission denied) ==" docker exec "$CONTAINER" psql "postgresql://jnpf_app:${APP_PW}@${PGIP}:5432/nacos_config" -Atc "SELECT 1" 2>&1 | head -1 || true echo "完成 ✓" docker/postgres/migration/create_app_roles.sql
New file @@ -0,0 +1,150 @@ -- Backlog A4(2026-07-17):业务连库账号与超管分离 —— 三库三角色 -- jnpf_app → jnpf_init (9 个业务服务,Nacos datasource.yaml 消费) -- jnpf_flow_app → jnpf_flow (独立流程引擎,compose env FLOW_DB_* 消费) -- nacos_app → nacos_config (Nacos 服务端,compose env NACOS_DB_* 消费) -- -- 动机(L029):业务用 postgres 超管连库使 superuser_reserved_connections=3 形同虚设, -- 连接打满时运维无法救火;且权限过大。 -- -- 执行入口:同目录 create-app-roles.sh(从仓库根 .env 读密码,docker exec 进容器执行)。 -- 幂等:可重复执行;每次执行都会按 .env 重置角色密码。 -- -- psql 变量(由 wrapper 传入): -- nacos_enabled:true/false —— 补丁 #8 后新栈默认 false(.env 未设 JNPF_NACOS_DB_PASSWORD), -- nacos_app 段整体跳过;旧实例兼容时 wrapper 会传 true,行为与本文件历史版本一致 -- 密码:app_pw / flow_pw / nacos_pw(nacos_enabled=false 时 nacos_pw 不会被用到) -- 连接配额:app_conn_limit / flow_conn_limit / nacos_conn_limit(整数,直接内插,wrapper 已校验为非负整数) -- -- 设计要点: -- 1. 存量对象所有权移交给对应角色 —— 在线开发(visualdev)运行时会对既有表 ALTER/DROP, -- Flowable/Nacos 升级也可能改 schema;仅 GRANT 不够。 -- 覆盖 relkind r/p/S/v/m(表/分区表/序列/视图/物化视图);如未来引入 FDW 外部表(f)或 -- 自定义复合类型(c)需在循环里补充处理 -- 2. 排除扩展对象(pg_depend deptype='e',如 pg_stat_statements 的视图),不动扩展归属 -- 3. REVOKE PUBLIC 的默认 CONNECT —— 角色只能连自己的库 -- 4. postgres 建的新对象通过 ALTER DEFAULT PRIVILEGES 自动授予(运维补丁场景) \set ON_ERROR_STOP on -- ========== 角色(幂等创建 + 每次重置密码) ========== SELECT 'CREATE ROLE jnpf_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'jnpf_app') \gexec SELECT 'CREATE ROLE jnpf_flow_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'jnpf_flow_app') \gexec \if :nacos_enabled SELECT 'CREATE ROLE nacos_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nacos_app') \gexec \else \echo '跳过 nacos_app(补丁 #8 后栈不再使用;旧实例兼容需 wrapper 传 nacos_enabled=true)' \endif ALTER ROLE jnpf_app PASSWORD :'app_pw'; ALTER ROLE jnpf_flow_app PASSWORD :'flow_pw'; \if :nacos_enabled ALTER ROLE nacos_app PASSWORD :'nacos_pw'; \endif -- ========== 连接数配额(L029 纵深防御:防单角色占满连接槽饿死其他角色,如流程引擎) ========== -- 值由 wrapper 从 .env 传入(未设用脚本默认 150/30/10)。铁律:三者之和须 < -- (max_connections − superuser_reserved_connections),否则等于没预留。整数由 wrapper 校验后直接内插。 ALTER ROLE jnpf_app CONNECTION LIMIT :app_conn_limit; ALTER ROLE jnpf_flow_app CONNECTION LIMIT :flow_conn_limit; \if :nacos_enabled ALTER ROLE nacos_app CONNECTION LIMIT :nacos_conn_limit; \endif -- 角色只能连各自的库。nacos_config 的 REVOKE 不放进 \if:即便本次跳过 nacos_app, -- 该库也不该继续留着默认的 PUBLIC CONNECT——只有随后的 GRANT(把连接权还给 nacos_app)才需要条件跳过。 REVOKE CONNECT ON DATABASE jnpf_init FROM PUBLIC; REVOKE CONNECT ON DATABASE jnpf_flow FROM PUBLIC; REVOKE CONNECT ON DATABASE nacos_config FROM PUBLIC; GRANT CONNECT ON DATABASE jnpf_init TO jnpf_app; GRANT CONNECT ON DATABASE jnpf_flow TO jnpf_flow_app; \if :nacos_enabled GRANT CONNECT ON DATABASE nacos_config TO nacos_app; \endif -- ========== jnpf_init → jnpf_app ========== \c jnpf_init GRANT USAGE, CREATE ON SCHEMA public TO jnpf_app; DO $$ DECLARE r record; BEGIN FOR r IN SELECT c.oid::regclass AS obj FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind IN ('r','p','S','v','m') AND c.relowner <> 'jnpf_app'::regrole AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e') -- 表自有序列(serial/identity)不可单独改 owner,随其表的 ALTER TABLE 自动移交 AND NOT (c.relkind = 'S' AND EXISTS ( SELECT 1 FROM pg_depend d2 WHERE d2.classid = 'pg_class'::regclass AND d2.objid = c.oid AND d2.deptype IN ('a','i'))) LOOP EXECUTE format('ALTER TABLE %s OWNER TO jnpf_app', r.obj); END LOOP; END $$; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO jnpf_app; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO jnpf_app; -- ========== jnpf_flow → jnpf_flow_app ========== \c jnpf_flow GRANT USAGE, CREATE ON SCHEMA public TO jnpf_flow_app; DO $$ DECLARE r record; BEGIN FOR r IN SELECT c.oid::regclass AS obj FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind IN ('r','p','S','v','m') AND c.relowner <> 'jnpf_flow_app'::regrole AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e') -- 表自有序列(serial/identity)不可单独改 owner,随其表的 ALTER TABLE 自动移交 AND NOT (c.relkind = 'S' AND EXISTS ( SELECT 1 FROM pg_depend d2 WHERE d2.classid = 'pg_class'::regclass AND d2.objid = c.oid AND d2.deptype IN ('a','i'))) LOOP EXECUTE format('ALTER TABLE %s OWNER TO jnpf_flow_app', r.obj); END LOOP; END $$; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO jnpf_flow_app; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO jnpf_flow_app; -- ========== nacos_config → nacos_app ========== \if :nacos_enabled \c nacos_config GRANT USAGE, CREATE ON SCHEMA public TO nacos_app; DO $$ DECLARE r record; BEGIN FOR r IN SELECT c.oid::regclass AS obj FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind IN ('r','p','S','v','m') AND c.relowner <> 'nacos_app'::regrole AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e') -- 表自有序列(serial/identity)不可单独改 owner,随其表的 ALTER TABLE 自动移交 AND NOT (c.relkind = 'S' AND EXISTS ( SELECT 1 FROM pg_depend d2 WHERE d2.classid = 'pg_class'::regclass AND d2.objid = c.oid AND d2.deptype IN ('a','i'))) LOOP EXECUTE format('ALTER TABLE %s OWNER TO nacos_app', r.obj); END LOOP; END $$; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO nacos_app; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO nacos_app; \endif -- ========== 汇总 ========== \c postgres SELECT rolname, rolsuper, rolcanlogin, rolconnlimit FROM pg_roles WHERE rolname IN ('postgres','jnpf_app','jnpf_flow_app','nacos_app') ORDER BY rolname; docker/postgres/migration/install.sh
New file @@ -0,0 +1,168 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # JNPF PostgreSQL schema 初始化(全新空库专用) # 1) 建四库 jnpf_init / jnpf_flow / jnpf_xxljob / nacos_config # 2) 跑官方初始化脚本(结构,无业务数据) # 3) 跑自建表 DDL self_built_tables_pg.sql # 4) 跑数据接口修正 data_interface_pg_fixes.sql(此刻空表→幂等 no-op,数据迁移后由 migrate-data.sh 再放一次) # 5) 建 Nacos 存储库 schema(nacos_config_pg.sql) # # 前置:cx-postgres 容器已由根 compose 拉起(安全基线随根 compose,本脚本不再部署容器): # docker volume create postgres_pgdata # 全新机器 # docker compose up -d cx-postgres # 在仓库根 / 离线交付包根目录执行 # # 安全守卫:目标库已存在且含表时**拒绝执行**(官方 init 含 DROP TABLE IF EXISTS, # 重放会清空既有数据);确需重灌请显式 FORCE_RESEED=1。 # # 用法: # ./install.sh # 建库 + 建表(= schema) # ./install.sh schema # 同上(兼容旧参数写法) # # 参数(环境变量,默认=现场标准值): # CONTAINER PG 容器名 默认 cx-postgres # PGUSER 超级用户 默认 postgres(exec psql 走容器本地 trust,无需密码) # DB_INIT / DB_FLOW / DB_XXLJOB / DB_NACOS 四库名(测试时可改 *_t8 隔离) # CREATE_EXTENSIONS 是否建常用扩展(1/0),默认 1(非致命,失败仅告警) # FORCE_RESEED 目标库非空时仍强制重灌(1/0),默认 0 # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SQL_DIR="$SCRIPT_DIR/sql" CONTAINER="${CONTAINER:-cx-postgres}" PGUSER="${PGUSER:-postgres}" DB_INIT="${DB_INIT:-jnpf_init}" DB_FLOW="${DB_FLOW:-jnpf_flow}" DB_XXLJOB="${DB_XXLJOB:-jnpf_xxljob}" DB_NACOS="${DB_NACOS:-nacos_config}" CREATE_EXTENSIONS="${CREATE_EXTENSIONS:-1}" FORCE_RESEED="${FORCE_RESEED:-0}" STEP="${1:-schema}" log() { printf '\033[1;34m[install]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[install][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[install][err]\033[0m %s\n' "$*" >&2; exit 1; } # 在容器内跑 psql(本地 unix socket,postgres 超级用户 trust,无需密码/端口) psql_exec() { docker exec -i "$CONTAINER" psql -U "$PGUSER" "$@"; } # 等 PG 进程就绪(首次 initdb 数十秒)。此阶段容器健康检查 unhealthy 属预期—— # 它探测的业务库正是本脚本要创建的,healthy 只会出现在本脚本成功之后 for _i in $(seq 1 60); do if docker exec "$CONTAINER" pg_isready -qU "$PGUSER" 2>/dev/null; then break; fi [ "$_i" = 60 ] && { echo "错误:等待 PG 就绪超时(docker logs $CONTAINER 查看)" >&2; exit 1; } sleep 2 done # 把宿主 SQL 文件喂给容器内 psql,遇错即停 run_sql_file() { local db="$1" file="$2" [ -f "$file" ] || die "缺 SQL 文件:${file}(git 拉码路线:先跑 ./assemble-sql-onsite.sh;离线包路线:包内 sql/ 应已组装,联系构建机)" log "→ [$db] 执行 $(basename "$file") ($(du -h "$file" | cut -f1))" psql_exec -d "$db" -v ON_ERROR_STOP=1 -q -f - < "$file" } db_exists() { psql_exec -tAc "SELECT 1 FROM pg_database WHERE datname='$1'" | grep -q 1; } create_db() { local db="$1" if db_exists "$db"; then warn "库 $db 已存在,跳过 CREATE DATABASE(幂等)" else log "创建库 $db" psql_exec -d postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$db\" ENCODING 'UTF8'" fi } # 只数 BASE TABLE(排除 pg_stat_statements 等扩展视图,计数不受 CREATE_EXTENSIONS 影响) table_count() { psql_exec -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'" -d "$1"; } # 全新 schema 基线:官方 293 张表 + 自建 99 张表(含 DMS V2.3 十表及文件对象映射表)。 verify_init_table_count() { local init_table_count init_custom_table_count init_table_count="$(table_count "$DB_INIT")" init_custom_table_count=$((init_table_count - 293)) if [ "$init_table_count" -ne 392 ] || [ "$init_custom_table_count" -ne 99 ]; then die "表数核对失败:$DB_INIT 共 ${init_table_count} 张 BASE TABLE,自建表 ${init_custom_table_count} 张;期望总数 392 张、自建 99 张" fi } # 非空守卫:init SQL 含 DROP TABLE IF EXISTS,对已有数据的库重放=清库 guard_empty() { local db="$1" n db_exists "$db" || return 0 n="$(table_count "$db")" [ "$n" -eq 0 ] && return 0 if [ "$FORCE_RESEED" = "1" ]; then warn "库 $db 已有 $n 张表,FORCE_RESEED=1 → 继续重灌(既有数据将被 init 脚本覆盖/清空)" else die "库 $db 已有 $n 张表——本脚本只用于全新空库(init 含 DROP TABLE,重放会清数据)。确需重灌:FORCE_RESEED=1 ./install.sh" fi } # ── schema:建库 + 官方 init + 自建表 + 数据接口修正 + Nacos 存储库 ────────── do_schema() { docker inspect "$CONTAINER" >/dev/null 2>&1 \ || die "容器 $CONTAINER 不在——先在仓库根/交付包根目录:docker volume create postgres_pgdata && docker compose up -d cx-postgres" log "===== 非空守卫 =====" guard_empty "$DB_INIT"; guard_empty "$DB_FLOW"; guard_empty "$DB_XXLJOB"; guard_empty "$DB_NACOS" log "===== 建四库 =====" create_db "$DB_INIT"; create_db "$DB_FLOW"; create_db "$DB_XXLJOB"; create_db "$DB_NACOS" if [ "$CREATE_EXTENSIONS" = "1" ]; then log "建常用扩展(非致命)" for ext in pg_stat_statements pg_trgm pgcrypto pg_bigm vector; do psql_exec -d "$DB_INIT" -c "CREATE EXTENSION IF NOT EXISTS \"$ext\"" >/dev/null 2>&1 \ && log " ext $ext ✓" || warn " ext $ext 跳过(镜像未含或无需)" done fi log "===== 官方初始化脚本 =====" run_sql_file "$DB_INIT" "$SQL_DIR/jnpf_dbnull_init.sql" run_sql_file "$DB_INIT" "$SQL_DIR/Update_PostgreSQL_1127.sql" run_sql_file "$DB_FLOW" "$SQL_DIR/jnpf_flow_init.sql" run_sql_file "$DB_XXLJOB" "$SQL_DIR/jnpf_xxljob_init.sql" log "===== 自建表 DDL =====" run_sql_file "$DB_INIT" "$SQL_DIR/self_built_tables_pg.sql" log "===== 自建表增量(迁移快照后的列级对齐,幂等)=====" run_sql_file "$DB_INIT" "$SQL_DIR/update_lims_huanjing_20260717.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_lims_sxt_wendingxing_20260717.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_lims_sxt_wendingxing_20260720.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_lims_sxt_flow_biz_status_20260719.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_lims_sxt_quyang_zhongzhi_shenqing_biz_status_20260723.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_lims_sxt_quyang_zhongzhi_shenqing_qingyandan_link_20260723.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_lims_sxt_plan_status_20260720.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_lims_wendingxing_biangeng_20260720.sql" run_sql_file "$DB_INIT" "$SQL_DIR/create_lims_sxt_quyang_jihua_renwu_20260724.sql" run_sql_file "$DB_INIT" "$SQL_DIR/alter_lims_sxt_quyang_jihua_renwu_jianyan_xiang_kuaizhao_20260724.sql" run_sql_file "$DB_INIT" "$SQL_DIR/create_audit_events_ext_20260724.sql" run_sql_file "$DB_INIT" "$SQL_DIR/create_audit_events_20260724.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_audit_events_title_entry_20260810.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_audit_events_ingest_merge_20260818.sql" run_sql_file "$DB_INIT" "$SQL_DIR/repair_audit_events_integrity_20260824.sql" run_sql_file "$DB_INIT" "$SQL_DIR/create_dms_permission_v2_2_20260815.sql" run_sql_file "$DB_INIT" "$SQL_DIR/upgrade_dms_permission_v2_2_to_v2_3_20260817.sql" run_sql_file "$DB_INIT" "$SQL_DIR/create_dms_file_objects_20260817.sql" run_sql_file "$DB_INIT" "$SQL_DIR/upgrade_dms_file_properties_20260820.sql" run_sql_file "$DB_INIT" "$SQL_DIR/update_audit_events_target_timeline_index_20260823.sql" log "===== 数据接口修正(空表幂等,数据迁移后 migrate-data.sh 再放一次)=====" run_sql_file "$DB_INIT" "$SQL_DIR/data_interface_pg_fixes.sql" log "===== Nacos 存储库 schema =====" run_sql_file "$DB_NACOS" "$SQL_DIR/nacos_config_pg.sql" log "===== 表数核对 =====" verify_init_table_count log " $DB_INIT BASE TABLE = $(table_count "$DB_INIT") (期望 392:官方293 + 自建99,含 DMS V2.3 十表和文件对象映射表)" log " $DB_FLOW BASE TABLE = $(table_count "$DB_FLOW") (期望 70)" log " $DB_XXLJOB BASE TABLE = $(table_count "$DB_XXLJOB") (期望 11)" log " $DB_NACOS BASE TABLE = $(table_count "$DB_NACOS") (期望 12)" log "schema 就绪。下一步:停机窗口跑 ./migrate-data.sh 迁数据;Nacos 配置内容按 docs/archive/server-pg-cutover-checklist.md SW-7 导入。" } case "$STEP" in schema) do_schema ;; container|all) die "步骤 '$STEP' 已废弃:PG 容器统一由根 compose 拉起(docker compose up -d cx-postgres),本脚本只做 schema" ;; *) die "未知步骤 '$STEP'(仅支持 schema)" ;; esac log "install.sh [$STEP] 完成 ✓" docker/postgres/migration/migrate-data.sh
New file @@ -0,0 +1,242 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # JNPF 数据迁移(停机窗口一次性):现场 MySQL → 中转 mirror → pgloader → PG # # dump :从现场 MySQL mysqldump(跳 base_sys_log 日志)到本地 dump/ # mirror :起临时 mysql:8.0 中转容器,灌入 dump(pgloader 的 qmynd 不支持 MySQL8 握手,故中转) # load :pgloader 灌 jnpf_init/jnpf_flow + blade_visual_map 走 --tab→COPY # + 数据接口修正 + log_seq setval + 抽样校验 # cleanup :删除临时 mirror 容器与 dump/ # all :dump → mirror → load(不含 cleanup) # # 用法: # ./migrate-data.sh all # 现场停机窗口全流程 # ./migrate-data.sh load # 只跑 PG 侧(mirror 已就绪时,或隔离测试) # ./migrate-data.sh cleanup # 迁移验收后清理临时资源 # # 参数(环境变量): # 源库 SRC_MYSQL_HOST/PORT/USER/PWD **无软默认**:HOST 与 PWD 必须显式传入(dump 步校验) # 中转 MIRROR(容器名) MIRROR_PWD 默认 cx-mysql-mirror / mirror_2026(临时容器,随 cleanup 销毁) # 目标 CONTAINER PGUSER 默认 cx-postgres / postgres # PG_PASSWORD 默认读仓库根/交付包根 .env 的 JNPF_PG_PASSWORD(load 步校验; # pgloader 走 TCP scram 真验密码,值必须正确,L030) # PG_INIT_DB PG_FLOW_DB 默认 jnpf_init / jnpf_flow(隔离测试可改 *_t8) # PG_INT_PORT 容器内 PG 端口,默认 5432 # NET docker 网络 默认自动取 CONTAINER 所在网络 # DUMP_DIR / PGLOADER_IMAGE / MYSQL_IMAGE # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail # ── 退役护栏(2026-07-25)───────────────────────────────────────────────────── # 本脚本的前提「腾讯云 MySQL = 事实源」自 2026-07-20 起已消失:现事实源是腾讯云 CVM 共享 PG # (YOUR_DB_HOST:15432,开发与测试共用一库),MySQL 数据停在 07-16,仅作冷备回退底牌保留。 # 本脚本会 truncate 重灌目标库,误跑 = 用废弃旧快照覆盖当前业务数据,是真实数据事故。 # 背景与当前拓扑:README-迁移操作手册.md 文首 / docs/db-connection-pool-guide.md if [ "${I_KNOW_MIGRATION_IS_RETIRED:-}" != "1" ]; then printf '\033[1;31m[已退役·拒绝执行]\033[0m %s\n' "migrate-data.sh 自 2026-07-21 迁移完成后已退役。" >&2 printf ' 前提已不成立:腾讯云 MySQL 不再是事实源(现为 CVM 共享 PG,MySQL 停在 07-16)。\n' >&2 printf ' 误跑后果:用废弃快照 truncate 重灌目标库 —— 会抹掉当前业务数据。\n' >&2 printf ' 若确在全新环境做一次性历史迁移,显式声明后重试:\n' >&2 printf ' I_KNOW_MIGRATION_IS_RETIRED=1 %s %s\n' "$0" "${*:-}" >&2 exit 1 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SQL_DIR="$SCRIPT_DIR/sql" # 凭据不设软默认(codex review 2026-07-17);.env 从脚本目录向上探测(仓库根或交付包根) env_file() { local d="$SCRIPT_DIR" while [ "$d" != "/" ]; do if [ -f "$d/.env" ]; then printf '%s\n' "$d/.env"; return 0; fi d="$(dirname "$d")" done return 1 } envval() { local f f="$(env_file)" || return 0 grep -E "^$1=" "$f" | tail -1 | cut -d= -f2- || true } SRC_MYSQL_HOST="${SRC_MYSQL_HOST:-}" SRC_MYSQL_PORT="${SRC_MYSQL_PORT:-3306}" SRC_MYSQL_USER="${SRC_MYSQL_USER:-root}" SRC_MYSQL_PWD="${SRC_MYSQL_PWD:-}" MIRROR="${MIRROR:-cx-mysql-mirror}" MIRROR_USER="${MIRROR_USER:-root}" MIRROR_PWD="${MIRROR_PWD:-mirror_2026}" CONTAINER="${CONTAINER:-cx-postgres}" PGUSER="${PGUSER:-postgres}" PG_PASSWORD="${PG_PASSWORD:-$(envval JNPF_PG_PASSWORD)}" PG_INIT_DB="${PG_INIT_DB:-jnpf_init}" PG_FLOW_DB="${PG_FLOW_DB:-jnpf_flow}" PG_INT_PORT="${PG_INT_PORT:-5432}" DUMP_DIR="${DUMP_DIR:-$SCRIPT_DIR/dump}" PGLOADER_IMAGE="${PGLOADER_IMAGE:-dimitri/pgloader:latest}" MYSQL_IMAGE="${MYSQL_IMAGE:-mysql:8.0}" STEP="${1:-all}" log() { printf '\033[1;34m[migrate]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[migrate][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[migrate][err]\033[0m %s\n' "$*" >&2; exit 1; } discover_net() { docker inspect "$CONTAINER" >/dev/null 2>&1 || die "目标容器 $CONTAINER 不在——先 docker compose up -d cx-postgres 再跑 install.sh" docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$CONTAINER" | awk '{print $1}' } NET="${NET:-$(discover_net)}" mirror_sql() { docker exec -i -e MYSQL_PWD="$MIRROR_PWD" "$MIRROR" mysql --default-character-set=utf8mb4 -u"$MIRROR_USER" "$@"; } pg_sql() { docker exec -i "$CONTAINER" psql -U "$PGUSER" "$@"; } mirror_count(){ mirror_sql -N -e "SELECT COUNT(*) FROM \`$2\`" "$1" 2>/dev/null; } pg_count() { pg_sql -tAc "SELECT count(*) FROM $2" -d "$1"; } # ── dump:现场 MySQL → dump/ ───────────────────────────────────────────────── do_dump() { [ -n "$SRC_MYSQL_HOST" ] || die "缺 SRC_MYSQL_HOST(源 MySQL 地址须显式传入,如 SRC_MYSQL_HOST=127.0.0.1)" [ -n "$SRC_MYSQL_PWD" ] || die "缺 SRC_MYSQL_PWD(源 MySQL 密码须显式传入,不设软默认)" mkdir -p "$DUMP_DIR" local common="--single-transaction --hex-blob --set-gtid-purged=OFF --default-character-set=utf8mb4 --column-statistics=0" log "mysqldump jnpf_init(跳 base_sys_log)… 源 $SRC_MYSQL_HOST:$SRC_MYSQL_PORT" docker run --rm -e MYSQL_PWD="$SRC_MYSQL_PWD" -v "$DUMP_DIR":/dump "$MYSQL_IMAGE" \ sh -c "mysqldump -h$SRC_MYSQL_HOST -P$SRC_MYSQL_PORT -u$SRC_MYSQL_USER $common --ignore-table=jnpf_init.base_sys_log jnpf_init > /dump/jnpf_init.sql" log "mysqldump jnpf_flow …" docker run --rm -e MYSQL_PWD="$SRC_MYSQL_PWD" -v "$DUMP_DIR":/dump "$MYSQL_IMAGE" \ sh -c "mysqldump -h$SRC_MYSQL_HOST -P$SRC_MYSQL_PORT -u$SRC_MYSQL_USER $common jnpf_flow > /dump/jnpf_flow.sql" log "dump 完成:$(du -sh "$DUMP_DIR" | cut -f1) @ $DUMP_DIR" } # ── mirror:起临时 mysql8 中转 + 灌 dump ───────────────────────────────────── do_mirror() { [ -f "$DUMP_DIR/jnpf_init.sql" ] || die "缺 $DUMP_DIR/jnpf_init.sql,先跑 ./migrate-data.sh dump" # 就绪探测走真实认证:mysqladmin ping 在"认证被拒"时也返回 0,会在镜像首次初始化的 # 临时服务器窗口(root 密码尚未生效)假通过,随后灌入撞 Access denied(L038) wait_mirror_ready() { log "等待 mirror 就绪(真实认证探测)…" for i in $(seq 1 60); do docker exec -e MYSQL_PWD="$MIRROR_PWD" "$MIRROR" mysql -u"$MIRROR_USER" -e "SELECT 1" >/dev/null 2>&1 && return 0 sleep 3 done die "mirror 60×3s 未就绪(docker logs $MIRROR 查看初始化进度)" } if docker inspect "$MIRROR" >/dev/null 2>&1; then if [ "$(docker inspect -f '{{.State.Running}}' "$MIRROR")" = "true" ]; then warn "mirror 容器 $MIRROR 运行中,复用(如需重灌先 ./migrate-data.sh cleanup)" else log "mirror 容器 $MIRROR 存在但已停止,启动(L035:不再盲目复用停止态容器)…" docker start "$MIRROR" >/dev/null wait_mirror_ready fi else log "起临时 mirror(mysql_native_password + skip-log-bin + innodb_strict_mode=OFF)于网络 $NET" docker run -d --name "$MIRROR" --network "$NET" -e MYSQL_ROOT_PASSWORD="$MIRROR_PWD" "$MYSQL_IMAGE" \ --default-authentication-plugin=mysql_native_password --skip-log-bin --innodb-strict-mode=OFF >/dev/null wait_mirror_ready fi log "建库并灌入 dump(大表较慢)…" mirror_sql -e "CREATE DATABASE IF NOT EXISTS jnpf_init DEFAULT CHARSET utf8mb4; CREATE DATABASE IF NOT EXISTS jnpf_flow DEFAULT CHARSET utf8mb4;" mirror_sql jnpf_init < "$DUMP_DIR/jnpf_init.sql" mirror_sql jnpf_flow < "$DUMP_DIR/jnpf_flow.sql" log "mirror 灌入完成:jnpf_init blade_visual_map=$(mirror_count jnpf_init blade_visual_map) 行" } # ── pgloader 模板渲染 ──────────────────────────────────────────────────────── render_pgloader() { local tpl="$1" out="$2" pgdb="$3" [ -f "$tpl" ] || die "缺模板 $tpl" sed -e "s#{{MYSQL_USER}}#$MIRROR_USER#g" \ -e "s#{{MYSQL_PASSWORD}}#$MIRROR_PWD#g" \ -e "s#{{MYSQL_HOST}}#$MIRROR#g" \ -e "s#{{MYSQL_PORT}}#3306#g" \ -e "s#{{PG_USER}}#$PGUSER#g" \ -e "s#{{PG_PASSWORD}}#$PG_PASSWORD#g" \ -e "s#{{PG_HOST}}#$CONTAINER#g" \ -e "s#{{PG_PORT}}#$PG_INT_PORT#g" \ -e "s#{{PG_DB}}#$pgdb#g" \ "$tpl" > "$out" } run_pgloader() { local loadfile="$1" docker run --rm --network "$NET" -v "$(dirname "$loadfile")":/loads "$PGLOADER_IMAGE" \ pgloader "/loads/$(basename "$loadfile")" } # ── blade_visual_map:mysqldump --tab(NULL=\N,天然兼容)→ PG COPY ─────────── load_blade_visual_map() { log "blade_visual_map:mysqldump --tab → PG COPY(pgloader 对该巨行表会堆爆,故走 COPY)" docker exec -e MYSQL_PWD="$MIRROR_PWD" "$MIRROR" sh -c \ "rm -f /var/lib/mysql-files/blade_visual_map.* && mysqldump --tab=/var/lib/mysql-files --default-character-set=utf8mb4 -u$MIRROR_USER jnpf_init blade_visual_map" pg_sql -d "$PG_INIT_DB" -c "TRUNCATE blade_visual_map" docker exec "$MIRROR" cat /var/lib/mysql-files/blade_visual_map.txt \ | pg_sql -d "$PG_INIT_DB" -c "COPY blade_visual_map FROM STDIN" docker exec "$MIRROR" rm -f /var/lib/mysql-files/blade_visual_map.* log "blade_visual_map → PG $PG_INIT_DB = $(pg_count "$PG_INIT_DB" blade_visual_map) 行" } # ── load:pgloader + blade + 业务数据回填 + 数据接口修正 + setval + 抽样 ────── do_load() { docker inspect "$MIRROR" >/dev/null 2>&1 || die "mirror $MIRROR 不在,先跑 ./migrate-data.sh mirror" [ -n "$PG_PASSWORD" ] || die "缺 PG_PASSWORD(显式传入,或在仓库根/交付包根 .env 设 JNPF_PG_PASSWORD)" # 渲染目录放 DUMP_DIR 下而非 mktemp:Git Bash 的 /tmp 是 MSYS 虚拟路径, # docker -v 挂载时 daemon 只会建空目录(L036/L038 同族);DUMP_DIR 的盘符形态已被 dump 步实证可挂载。 # 注意 .loads 内含渲染后的连接密码——函数返回即删,cleanup 步兜底 local rdir; rdir="$DUMP_DIR/.loads"; rm -rf "$rdir"; mkdir -p "$rdir"; trap 'rm -rf "$rdir"' RETURN log "===== pgloader jnpf_init → PG ${PG_INIT_DB}(排除 base_sys_log/blade_visual_map)=====" render_pgloader "$SQL_DIR/pgloader_jnpf_init.load.tpl" "$rdir/init.load" "$PG_INIT_DB" run_pgloader "$rdir/init.load" load_blade_visual_map log "===== pgloader jnpf_flow → PG ${PG_FLOW_DB}(大写 ACT/FLW 表)=====" render_pgloader "$SQL_DIR/pgloader_jnpf_flow.load.tpl" "$rdir/flow.load" "$PG_FLOW_DB" run_pgloader "$rdir/flow.load" log "===== 水系统计划流程状态回填(历史数据导入后执行)=====" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_flow_biz_status_20260719.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_quyang_zhongzhi_shenqing_biz_status_20260723.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_quyang_zhongzhi_shenqing_qingyandan_link_20260723.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/update_lims_sxt_plan_status_20260720.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/create_lims_sxt_quyang_jihua_renwu_20260724.sql" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/alter_lims_sxt_quyang_jihua_renwu_jianyan_xiang_kuaizhao_20260724.sql" log "===== 数据接口修正(含 4 条 UPDATE + 残留检查,应 0 行)=====" pg_sql -d "$PG_INIT_DB" -v ON_ERROR_STOP=1 -f - < "$SQL_DIR/data_interface_pg_fixes.sql" log "===== lims_biz_log.log_seq setval =====" pg_sql -d "$PG_INIT_DB" -tAc \ "SELECT setval(pg_get_serial_sequence('lims_biz_log','log_seq'), COALESCE((SELECT MAX(log_seq) FROM lims_biz_log),1), true)" log "===== 抽样行数校验(mirror vs PG)=====" local bad=0 for t in base_user zhiliang_biaozhun_guanli lims_qingyandan lims_jianyan_xiang lims_biz_log blade_visual_map; do local m p; m="$(mirror_count jnpf_init "$t")"; p="$(pg_count "$PG_INIT_DB" "$t")" if [ "$m" = "$p" ]; then printf ' %-30s mirror=%-8s pg=%-8s ✓\n' "$t" "$m" "$p" else printf ' %-30s mirror=%-8s pg=%-8s ✗\n' "$t" "$m" "$p"; bad=$((bad+1)); fi done [ "$bad" = 0 ] && log "抽样一致 ✓" || warn "抽样有 $bad 处不一致,跑 sql/rowcount_check.sh 全量核对" log "数据迁移完成。全量逐表核对:sql/rowcount_check.sh(详见 README)。" } # ── cleanup:删临时 mirror + dump ──────────────────────────────────────────── do_cleanup() { if docker inspect "$MIRROR" >/dev/null 2>&1; then log "删除临时 mirror 容器 $MIRROR"; docker rm -f "$MIRROR" >/dev/null fi [ -d "$DUMP_DIR" ] && { log "删除 dump 目录 $DUMP_DIR"; rm -rf "$DUMP_DIR"; } log "cleanup 完成 ✓" } case "$STEP" in dump) do_dump ;; mirror) do_mirror ;; load) do_load ;; cleanup) do_cleanup ;; all) do_dump; do_mirror; do_load ;; *) die "未知步骤 '$STEP'(可选 dump|mirror|load|cleanup|all)" ;; esac log "migrate-data.sh [$STEP] 完成 ✓" docker/postgres/migration/migrate-nacos-config.sh
New file @@ -0,0 +1,108 @@ #!/usr/bin/env bash # 迁移 nacos_config(MySQL → PG,pgloader 全自动重建结构+数据)。 # migrate-data.sh 只覆盖 jnpf_init/jnpf_flow;本脚本补 nacos_config(Nacos 存储库)。 # jnpf_xxljob 是空库(0 行),无需迁数据,install.sh 建的空 schema 即可。 # # 用法:SRC_MYSQL_HOST=172.17.0.1 SRC_MYSQL_PWD='***' PG_PASSWORD='***' bash migrate-nacos-config.sh # SRC_MYSQL_HOST 源 MySQL(默认 172.17.0.1 = CVM docker0 网关→homework-mysql) # SRC_MYSQL_PWD 源 MySQL root 密码(必填) # PG_PASSWORD 目标 PG postgres 密码(必填;或读 ~/jnpf-pg/.env 的 JNPF_PG_PASSWORD) # CONTAINER PG 容器名(默认 cx-postgres) set -euo pipefail # ── 退役护栏(2026-07-25)───────────────────────────────────────────────────── # nacos_config 的 MySQL→PG 迁移已于 2026-07-21 完成(config_info 28 / his 105 / tenant 4 与源一致)。 # 本脚本用 pgloader 重建结构+数据,重跑会覆盖 Nacos 存储库 —— 即抹掉配置中心的现有配置。 if [ "${I_KNOW_MIGRATION_IS_RETIRED:-}" != "1" ]; then printf '\033[1;31m[已退役·拒绝执行]\033[0m %s\n' "migrate-nacos-config.sh 已于 2026-07-21 完成使命。" >&2 printf ' 重跑会用废弃 MySQL 的旧配置覆盖 CVM 上的 nacos_config —— 配置中心会被打回七月中旬。\n' >&2 printf ' 确需重跑请显式声明:I_KNOW_MIGRATION_IS_RETIRED=1 %s\n' "$0" >&2 exit 1 fi SRC_MYSQL_HOST="${SRC_MYSQL_HOST:-172.17.0.1}" SRC_MYSQL_PWD="${SRC_MYSQL_PWD:?缺 SRC_MYSQL_PWD}" CONTAINER="${CONTAINER:-cx-postgres}" PG_PASSWORD="${PG_PASSWORD:-$(grep ^JNPF_PG_PASSWORD "$HOME/jnpf-pg/.env" 2>/dev/null | cut -d= -f2)}" [ -n "$PG_PASSWORD" ] || { echo "!! 缺 PG_PASSWORD"; exit 1; } MIRROR=cx-nacos-mirror PGLOADER_IMAGE="${PGLOADER_IMAGE:-dimitri/pgloader:latest}" NET="$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' "$CONTAINER")" log(){ printf '\033[1;34m[nacos-mig]\033[0m %s\n' "$*"; } log "1) mysqldump nacos_config 源 ${SRC_MYSQL_HOST}" docker run --rm mysql:8.0 mysqldump -h"$SRC_MYSQL_HOST" -uroot -p"$SRC_MYSQL_PWD" \ --single-transaction --hex-blob --skip-add-locks --set-gtid-purged=OFF nacos_config > /tmp/nacos_dump.sql 2>/dev/null log " dump $(wc -l < /tmp/nacos_dump.sql) 行" log "2) 起临时 mirror(native_password)灌 dump" docker rm -f "$MIRROR" >/dev/null 2>&1 || true docker run -d --name "$MIRROR" --network "$NET" -e MYSQL_ROOT_PASSWORD=mirror_2026 \ mysql:8.0 --default-authentication-plugin=mysql_native_password --skip-log-bin >/dev/null for i in $(seq 1 40); do docker exec "$MIRROR" mysql -uroot -pmirror_2026 -e "SELECT 1" >/dev/null 2>&1 && break; sleep 2; done docker exec "$MIRROR" mysql -uroot -pmirror_2026 -e "CREATE DATABASE nacos_config DEFAULT CHARSET utf8mb4" docker exec -i "$MIRROR" mysql -uroot -pmirror_2026 nacos_config < /tmp/nacos_dump.sql log " mirror config_info=$(docker exec "$MIRROR" mysql -uroot -pmirror_2026 -N -e 'SELECT count(*) FROM nacos_config.config_info') 行" log "3) pgloader 全自动 mirror→PG(drop+建表+数据)" cat > /tmp/nacos.load <<LOADEOF LOAD DATABASE FROM mysql://root:mirror_2026@${MIRROR}:3306/nacos_config INTO postgresql://postgres:${PG_PASSWORD}@${CONTAINER}:5432/nacos_config WITH include drop, create tables, create indexes, reset sequences, workers = 1, concurrency = 1 SET work_mem to '128MB', maintenance_work_mem to '256MB'; LOADEOF docker run --rm --network "$NET" -v /tmp/nacos.load:/tmp/nacos.load "$PGLOADER_IMAGE" pgloader /tmp/nacos.load log "4) 整合 schema:pgloader 默认把 MySQL 库名当成 PG schema,数据落进了 nacos_config schema" # 而 install.sh 的 canonical Nacos 表在 public。把 pgloader 的表移进 public、丢弃 public 空表、 # 删空 nacos_config schema,得到单一可被 Nacos 直接使用的 public schema(详见 lessons L040)。 # owned 序列/索引/PK/注释随表自动移动;列默认值按 OID 存储不断裂。整个过程放一个事务里原子完成。 docker exec -i "$CONTAINER" psql -U postgres -d nacos_config <<'PSQL' \set ON_ERROR_STOP on BEGIN; DO $$ DECLARE r record; BEGIN -- 若 install.sh 已在 public 建空 Nacos 表,先丢弃(仅丢与 nacos_config schema 同名的) FOR r IN SELECT tablename FROM pg_tables WHERE schemaname='public' AND tablename IN (SELECT tablename FROM pg_tables WHERE schemaname='nacos_config') LOOP EXECUTE format('DROP TABLE public.%I CASCADE', r.tablename); END LOOP; -- 移表(owned 序列/索引/PK/注释随表自动移动,不能单独移 owned 序列) FOR r IN SELECT tablename FROM pg_tables WHERE schemaname='nacos_config' LOOP EXECUTE format('ALTER TABLE nacos_config.%I SET SCHEMA public', r.tablename); END LOOP; END $$; DROP SCHEMA IF EXISTS nacos_config; -- 此时应为空;无 CASCADE = 残留则报错回滚(安全阀) -- pgloader 建的表归 postgres;仅 GRANT 不够——Nacos 升级/开发改 schema 时 DDL 需 owner(L041)。 -- 按 create_app_roles.sql 同款逻辑把所有权移交 nacos_app:排除扩展对象(deptype='e')、owned 序列随表。 GRANT USAGE, CREATE ON SCHEMA public TO nacos_app; DO $$ DECLARE r record; BEGIN FOR r IN SELECT c.oid::regclass AS obj FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname='public' AND c.relkind IN ('r','p','S','v','m') AND c.relowner <> 'nacos_app'::regrole AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid=c.oid AND d.deptype='e') AND NOT (c.relkind='S' AND EXISTS ( SELECT 1 FROM pg_depend d2 WHERE d2.classid='pg_class'::regclass AND d2.objid=c.oid AND d2.deptype IN ('a','i'))) LOOP EXECUTE format('ALTER TABLE %s OWNER TO nacos_app', r.obj); END LOOP; END $$; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO nacos_app; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO nacos_app; COMMIT; PSQL log "5) 校验 PG nacos_config(public schema,非限定查询=Nacos 视角)" docker exec "$CONTAINER" psql -U postgres -d nacos_config -c \ "SELECT 'config_info' t,count(*) c FROM config_info UNION ALL SELECT 'his_config_info',count(*) FROM his_config_info UNION ALL SELECT 'tenant_info',count(*) FROM tenant_info ORDER BY 1" log "6) 清理 mirror" docker rm -f "$MIRROR" >/dev/null && rm -f /tmp/nacos_dump.sql /tmp/nacos.load log "完成 ✓" docker/postgres/migration/product-baseline-cleanup-flow.sql
New file @@ -0,0 +1,56 @@ -- 产品母版洗数据 · 流程引擎库侧(docs/adr-multi-customer-db-isolation.md §6.2) -- -- 只在母版实例 cx-pg-template 的 jnpf_flow 上跑。幂等可重放。 -- 用法:docker exec -i cx-pg-template psql -U postgres -d jnpf_flow -v ON_ERROR_STOP=1 < 本文件 -- -- 与 product-baseline-cleanup.sql(jnpf_init 侧)【必须成套执行】: -- workflow_template.f_flowable_id 引用 act_re_deployment.id_,只洗一边等于人为制造 -- ADR §4 的半切故障(2026-08-03 就因此让测试环境 44/49 流程报「找不到流程模板」)。 -- -- 保留【流程定义】,清空【运行时 + 历史 + 身份】。同样用白名单: -- 漏清 → 客户1 的流程实例数据流到新客户;误清 → 定义没了,装完就发现。风险不对称。 -- -- ⚠️ 两类表绝对不能清,清了引擎会崩或重跑迁移: -- · *_databasechangelog / *_databasechangeloglock —— Liquibase 的 schema 版本账本 -- · act_ge_property —— 引擎 schema 版本 -- ⚠️ act_ge_bytearray 同时存【部署资源】与【运行时变量 blob】,整表清会毁掉流程定义。 -- 2026-08-04 实查客户1 库:276 行【全部】有 deployment_id_,运行时 blob 为 0 行, -- 故整表保留不会带入业务数据。仍保留下方按 deployment_id_ IS NULL 的删除作为通则, -- 将来若出现运行时 blob 会被自动清掉。 -- ⚠️ 跑前已实查:没有任何 keep 表的 FK 指向将被清空的表,故 TRUNCATE ... CASCADE 不会波及定义。 BEGIN; DO $$ DECLARE keep text[] := ARRAY[ -- ① BPMN 流程定义(核心:与 jnpf_init.workflow_template.f_flowable_id 配对的就是它) 'act_re_deployment','act_re_procdef','act_re_model','act_procdef_info', -- ② 定义资源与引擎版本(清了引擎起不来) 'act_ge_bytearray','act_ge_property', -- ③ DMN / CMMN / App / Event 的【定义】部分(对应的 hi_/ru_ 在下面被清) 'act_dmn_decision','act_dmn_deployment','act_dmn_deployment_resource', 'act_cmmn_casedef','act_cmmn_deployment','act_cmmn_deployment_resource', 'act_app_appdef','act_app_deployment','act_app_deployment_resource', 'flw_channel_definition','flw_event_definition','flw_event_deployment','flw_event_resource', -- ④ Liquibase schema 版本账本(清了会重跑迁移) 'act_app_databasechangelog','act_app_databasechangeloglock', 'act_cmmn_databasechangelog','act_cmmn_databasechangeloglock', 'act_dmn_databasechangelog','act_dmn_databasechangeloglock', 'flw_ev_databasechangelog','flw_ev_databasechangeloglock' ]; t text; BEGIN FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND NOT (tablename = ANY(keep)) LOOP EXECUTE format('TRUNCATE TABLE %I CASCADE', t); END LOOP; END $$; -- act_ge_bytearray 的精确清理:只删运行时变量 blob,保留部署资源。 -- 通则形式而非按具体 id——将来出现运行时 blob 会被自动清掉。 DELETE FROM act_ge_bytearray WHERE deployment_id_ IS NULL; COMMIT; docker/postgres/migration/product-baseline-cleanup.sql
New file @@ -0,0 +1,66 @@ -- 产品母版洗数据(docs/adr-multi-customer-db-isolation.md §6.2) -- -- 只在母版实例 cx-pg-template 的 jnpf_init 上跑。幂等可重放。 -- 用法:docker exec -i cx-pg-template psql -U postgres -d jnpf_init -v ON_ERROR_STOP=1 < 本文件 -- -- ═══ 为什么是白名单(枚举保留),不是黑名单(枚举清空)═══ -- 两个方向的错误后果不对称: -- 漏清一张业务表 → 客户1 的数据流到新客户,可能很久才被发现; -- 误清一张配置表 → 新客户少一份配置,装完就发现。 -- 所以枚举「保留什么」,其余一律清空。新增业务表不用改本文件(默认被清); -- 新增【配置类】表才需要加进 keep[]。 -- -- ═══ 保留/清空的产品决策(用户 2026-08-04 拍板)═══ -- · LIMS 基础档案(检验项目/请验类型/单位/质量标准/储存条件)→ 不分发,清空。 -- 理由:客户1 的档案带给客户2 大概率是错的,而且错得不明显;新客户按其实际标准录入。 -- · JNPF 自带 demo 数据(ext_* 10 张 / wform_* 6 张)→ 清空。平台示例,非产品资产。 -- · 组织架构与角色授权(base_organize/base_position/base_role/base_authorize)→ 全清。 -- 新客户组织结构必然不同;admin 是超管,不依赖 role 也能登录并配权限。 -- -- ⚠️ 本文件必须与 jnpf_flow 侧的清理【成套执行】: -- workflow_template.f_flowable_id 引用 act_re_deployment.id_,只洗一边 -- 等于人为制造 ADR §4 的半切故障。配套脚本见 product-baseline-cleanup-flow.sql。 -- ⚠️ 跑之前确认没有 FK 的 parent 落在 keep[] 上(TRUNCATE ... CASCADE 会连带清空)。 -- 2026-08-04 实查:全库仅 4 条 FK,全在 lims_* 内部且为 RESTRICT,不波及 keep[]。 BEGIN; DO $$ DECLARE keep text[] := ARRAY[ -- ① 低代码表单 / 流程 / 大屏 / 报表【定义】——产品资产核心 'base_visual_dev','base_visual_release','base_visual_filter','base_visual_kit', 'base_visual_link','base_visual_personal', 'blade_visual','blade_visual_config','blade_visual_component','blade_visual_assets','blade_visual_map', 'report_template','report_charts','report_version', 'workflow_template','workflow_version','workflow_node','workflow_candidates','workflow_operator', -- ② 菜单 / 字典 / 数据接口 / 打印模板 / 门户 / 系统配置 'base_module','base_module_button','base_module_data', 'base_dictionary_type','base_dictionary_data', 'base_data_interface','base_data_interface_oauth','base_data_set', 'base_sys_config','base_system','base_system_top','base_common_fields','base_common_words', 'base_bill_rule','base_code_num','base_print_template','base_print_version', 'base_portal','base_portal_data','base_portal_manage','base_standing', 'base_msg_template','base_msg_template_param','base_msg_send_template', 'base_integrate','base_integrate_node','base_permission_group','base_action','base_sign_img', -- ③ 平台自带基础数据(与客户无关的字典性数据) 'base_province','base_province_atlas','base_language', -- ④ 部分保留:整表豁免 TRUNCATE,下面用 DELETE 精确清(只留 admin) 'base_user','base_user_relation' ]; t text; BEGIN FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND NOT (tablename = ANY(keep)) LOOP EXECUTE format('TRUNCATE TABLE %I CASCADE', t); END LOOP; END $$; -- ④ 的精确清理:只留 admin 一个账号及其关联 DELETE FROM base_user_relation WHERE f_user_id NOT IN (SELECT f_id FROM base_user WHERE f_account = 'admin'); DELETE FROM base_user WHERE f_account <> 'admin'; COMMIT; docker/postgres/migration/promote-db.sh
New file @@ -0,0 +1,262 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # JNPF 挪库发布:把共享库(开发+测试共用)整体搬到下一个环境(如正式环境) # # 为什么是"挪库"而不是"现场建 schema": # JNPF 是低代码平台,表单/菜单/字典/数据接口/流程定义全部存在数据库表里。 # 现场跑 install.sh 只能得到空结构,配置一个都不会有。所以上线 = 把整个库搬过去, # 配置和数据一次性带走(用户 2026-07-21 定的策略,见 docs/release-checklist.md §1)。 # # 两阶段分离,各自在不同机器上执行: # dump 在任何能连源库的机器上跑(拉出四库的 -Fc 备份) # restore 【必须在目标机本地跑】——建库要超级用户,而 postgres 按设计禁止远程登录 # verify 在目标机上跑(逐库表数/行数与 dump 记录的清单比对) # # 用法: # # ① 在能连源库的机器上(把 dump 目录整个拷到目标机) # ./promote-db.sh dump # # ② 在目标机上(先确保 PG 容器已起、且已跑过 create-app-roles.sh) # DST_CONTAINER=cx-postgres DUMP_DIR=/d/promote-20260725 ./promote-db.sh restore # DST_CONTAINER=cx-postgres DUMP_DIR=/d/promote-20260725 ./promote-db.sh verify # # 参数(环境变量): # SRC_HOST/SRC_PORT/SRC_USER/SRC_PASSWORD 源库(默认读 .env.tencent 的 CVM 共享库) # DST_CONTAINER 目标 PG 容器名(默认 cx-postgres) # DUMP_DIR dump 产物目录(默认 ./promote-dump-<日期>) # DATABASES 默认 "jnpf_init jnpf_flow jnpf_xxljob nacos_config" # ALLOW_NONEMPTY=1 允许目标库非空(默认拒绝,防误覆盖生产) # # ⚠️ 本脚本刻意【不做】的事(都是需要人判断、不该由脚本替你决定的): # - 不清洗测试业务数据:哪些请验单/流程实例算"测试垃圾"只有业务能定,见配套文档「洗数据」节 # - 不改 Nacos 配置里的环境相关值(ApiDomain/FrontDomain 等):restore 后按文档清单人工核对 # - 不建角色:restore 前必须先跑 create-app-roles.sh(用目标环境的密码),否则 owner 落不对 # 配套文档:docs/db-promote-guide.md(含不确定点与未来可能的变化) # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" # 默认只挪三个库。**刻意不含 jnpf_xxljob**:它的 11 张表 owner 是 postgres,而 CVM/生产的 # postgres 按设计禁止远程登录 → 远程 dump 不了;且它装的是 xxl-job 的任务注册与执行日志, # 正式环境本就该是全新的,不该把测试环境的任务日志搬过去。正式环境跑 install.sh 建空结构即可。 # (确要连它一起搬,只能在源机本地用超户 dump,见 docs/db-promote-guide.md「jnpf_xxljob 怎么办」) DATABASES="${DATABASES:-jnpf_init jnpf_flow nacos_config}" DUMP_DIR="${DUMP_DIR:-$PWD/promote-dump-$(date +%Y%m%d)}" DST_CONTAINER="${DST_CONTAINER:-cx-postgres}" # 三个业务角色:restore 前必须已存在,否则 dump 里的 OWNER TO 落不下去 REQUIRED_ROLES="${REQUIRED_ROLES:-jnpf_app jnpf_flow_app nacos_app}" # 每个库归属不同角色(三库三角色隔离),dump 必须用各自的 owner —— 用单一 app 角色会在 # 别的库上撞 "permission denied for table"(实测 2026-07-25)。格式:库=角色:密码键 db_role() { case "$1" in jnpf_init) echo "jnpf_app|JNPF_APP_DB_PASSWORD" ;; jnpf_flow) echo "jnpf_flow_app|JNPF_FLOW_DB_PASSWORD" ;; nacos_config) echo "nacos_app|JNPF_NACOS_DB_PASSWORD" ;; *) echo "" ;; esac } log() { printf '\033[1;34m[promote]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[promote][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[promote][err]\033[0m %s\n' "$*" >&2; exit 1; } step() { printf '\n\033[1;36m── %s ──\033[0m\n' "$*"; } # 源库连接:默认取 CVM 共享库(各角色密码在仓库根 .env.tencent,已 gitignore) SRC_HOST="${SRC_HOST:-YOUR_DB_HOST}" SRC_PORT="${SRC_PORT:-15432}" SRC_ENV_FILE="${SRC_ENV_FILE:-$REPO_ROOT/.env.tencent}" # 从 env 文件取指定键的密码;SRC_PASSWORD_<键> 形式的环境变量可覆盖(供非 CVM 源使用) src_password_for() { # $1=密码键名,如 JNPF_APP_DB_PASSWORD local key="$1" ov ov="$(eval "printf '%s' \"\${${key}:-}\"")" if [ -n "$ov" ]; then printf '%s' "$ov"; return 0; fi [ -f "$SRC_ENV_FILE" ] || return 1 grep -E "^${key}=" "$SRC_ENV_FILE" | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" } # pg_dump/pg_restore 来源:优先宿主自带(版本须 ≥ 服务端 18),否则用本地 PG 镜像跑 PG_IMAGE="${PG_IMAGE:-cx/postgres:18.4}" have_local_pgdump() { command -v pg_dump >/dev/null 2>&1; } usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 1; } # ─────────────────────────────────────────────────────────────── dump ──────── do_dump() { step "阶段 1/3:从源库导出($SRC_HOST:${SRC_PORT})" mkdir -p "$DUMP_DIR" local fail=0 for db in $DATABASES; do local map usr key pwd out map="$(db_role "$db")" if [ -z "$map" ]; then warn " ✗ ${db} 没有对应的业务角色(其表多半归 postgres,而超户禁远程)" warn " → 该库只能在源机本地用超户 dump,或确认它是否真需要挪;见 docs/db-promote-guide.md" fail=$((fail+1)); continue fi usr="${map%%|*}"; key="${map##*|}" pwd="$(src_password_for "$key" || true)" [ -n "$pwd" ] || { warn " ✗ ${db} 取不到密码(键 $key,查 $SRC_ENV_FILE 或设同名环境变量)"; fail=$((fail+1)); continue; } out="$DUMP_DIR/${db}.dump" log "pg_dump ${db}(角色 ${usr})…" if have_local_pgdump; then PGPASSWORD="$pwd" pg_dump -h "$SRC_HOST" -p "$SRC_PORT" -U "$usr" \ -Fc -d "$db" > "$out" 2>"$DUMP_DIR/${db}.dump.err" || { warn " ✗ ${db} 导出失败,见 ${db}.dump.err"; rm -f "$out"; fail=$((fail+1)); continue; } else docker run --rm -e PGPASSWORD="$pwd" "$PG_IMAGE" \ pg_dump -h "$SRC_HOST" -p "$SRC_PORT" -U "$usr" -Fc -d "$db" > "$out" 2>"$DUMP_DIR/${db}.dump.err" \ || { warn " ✗ ${db} 导出失败,见 ${db}.dump.err"; rm -f "$out"; fail=$((fail+1)); continue; } fi log " ✓ ${db} $(du -h "$out" | cut -f1)" done [ "$fail" = 0 ] || die "有 ${fail} 个库导出失败" step "记录源库清单(供 verify 阶段比对)" local manifest="$DUMP_DIR/MANIFEST.txt" { echo "# JNPF 挪库发布 dump 清单" echo "# 源: $SRC_HOST:$SRC_PORT 导出时间: $(date '+%Y-%m-%d %H:%M:%S')" for db in $DATABASES; do local n n=$(query_src "$db" "select count(*) from information_schema.tables where table_schema='public' and table_type='BASE TABLE'") echo "TABLES|$db|$n" # jnpf_init 逐表行数(业务主库,值得精确记录) if [ "$db" = "jnpf_init" ]; then query_src "$db" "select 'ROWS|$db|'||relname||'|'||(xpath('/row/c/text()', query_to_xml(format('select count(*) as c from public.%I', relname), false, true, '')))[1]::text from pg_stat_user_tables order by relname" fi done } > "$manifest" 2>/dev/null || warn "清单生成部分失败(不影响 dump 本身)" local nrows; nrows=$(grep -c '^ROWS' "$manifest" 2>/dev/null) || nrows=0 log "清单已写入 $(basename "$manifest")(含 ${nrows} 张表的精确行数)" step "dump 完成" log "产物目录:$DUMP_DIR" log "下一步:把整个目录拷到目标机,然后在【目标机上】执行 restore" warn "该目录含全部业务数据,传输与留存按敏感数据对待;导入完成后清理" } query_src() { # $1=库 $2=SQL —— 自动用该库对应的 owner 角色连接 local map usr key pwd map="$(db_role "$1")"; [ -n "$map" ] || return 1 usr="${map%%|*}"; key="${map##*|}" pwd="$(src_password_for "$key" || true)"; [ -n "$pwd" ] || return 1 if have_local_pgdump; then PGPASSWORD="$pwd" psql -h "$SRC_HOST" -p "$SRC_PORT" -U "$usr" -d "$1" -tAc "$2" 2>/dev/null else docker run --rm -e PGPASSWORD="$pwd" "$PG_IMAGE" \ psql -h "$SRC_HOST" -p "$SRC_PORT" -U "$usr" -d "$1" -tAc "$2" 2>/dev/null fi } dst_psql() { docker exec -i "$DST_CONTAINER" psql -U postgres -tAc "$1" 2>/dev/null; } # ───────────────────────────────────────────────────────────── restore ─────── do_restore() { step "阶段 2/3:灌入目标库(容器 ${DST_CONTAINER})" [ -d "$DUMP_DIR" ] || die "dump 目录不存在:${DUMP_DIR}(先在源侧跑 dump 并把目录拷过来)" docker inspect "$DST_CONTAINER" >/dev/null 2>&1 || die "目标容器 $DST_CONTAINER 不在(先 docker compose up -d cx-postgres)" step "前置检查" # ① 角色必须已存在——否则 dump 里的 OWNER TO 落不下去,将来 app 角色改不了表(L041) for r in $REQUIRED_ROLES; do if [ "$(dst_psql "select 1 from pg_roles where rolname='$r'")" = "1" ]; then log " ✓ 角色 $r 已存在" else die "角色 $r 不存在。restore 前必须先用【目标环境的密码】跑 create-app-roles.sh, 否则 dump 中的 OWNER TO 无处落地,事后 app 角色会改不动表(lessons L041)。" fi done # ② 目标库应为空——防止把已有生产数据覆盖掉 for db in $DATABASES; do if [ "$(dst_psql "select 1 from pg_database where datname='$db'")" = "1" ]; then local n n=$(docker exec -i "$DST_CONTAINER" psql -U postgres -d "$db" -tAc \ "select count(*) from information_schema.tables where table_schema='public'" 2>/dev/null || echo 0) if [ "${n:-0}" -gt 0 ]; then if [ "${ALLOW_NONEMPTY:-}" = "1" ]; then warn " ! $db 已有 $n 张表,ALLOW_NONEMPTY=1 → 将以 --clean --if-exists 覆盖" else die "$db 已存在且有 $n 张表。默认拒绝覆盖以防抹掉生产数据。 确认要覆盖请显式声明:ALLOW_NONEMPTY=1 $0 restore" fi fi fi done step "开始灌入" local fail=0 for db in $DATABASES; do local f="$DUMP_DIR/${db}.dump" [ -f "$f" ] || { warn " ✗ 缺 ${db}.dump,跳过"; fail=$((fail+1)); continue; } dst_psql "select 1 from pg_database where datname='$db'" >/dev/null 2>&1 if [ "$(dst_psql "select 1 from pg_database where datname='$db'")" != "1" ]; then log " 建库 $db" docker exec -i "$DST_CONTAINER" createdb -U postgres "$db" || { warn " ✗ 建库 $db 失败"; fail=$((fail+1)); continue; } fi log "pg_restore ${db} …" local opts="-U postgres -d $db --no-privileges --exit-on-error" [ "${ALLOW_NONEMPTY:-}" = "1" ] && opts="$opts --clean --if-exists" # shellcheck disable=SC2086 if docker exec -i "$DST_CONTAINER" pg_restore $opts < "$f" 2>"$DUMP_DIR/${db}.restore.err"; then log " ✓ ${db}" else warn " ✗ ${db} 灌入报错,见 ${db}.restore.err(前 3 行):" head -3 "$DUMP_DIR/${db}.restore.err" | sed 's/^/ /' fail=$((fail+1)) fi done [ "$fail" = 0 ] || die "有 ${fail} 个库灌入失败" step "restore 完成 —— 但发布还没结束" cat <<'NEXT' 必须人工接着做的三件事(脚本刻意不代劳,详见 docs/db-promote-guide.md): 1. 核对 Nacos 环境相关配置:ApiDomain / FrontDomain / 各服务地址 —— 这些值随环境变, 搬过来的是源环境的值,不改会导致文件预览 302 跳到错误域名(lessons L040) 2. 决定是否清洗测试业务数据:配置必须带走,但测试期的请验单/流程实例通常该清 3. 跑 verify 子命令做行数比对,再按 docs/release-checklist.md 过发布门禁 NEXT } # ────────────────────────────────────────────────────────────── verify ─────── do_verify() { step "阶段 3/3:校验(目标容器 $DST_CONTAINER vs dump 清单)" local manifest="$DUMP_DIR/MANIFEST.txt" [ -f "$manifest" ] || die "缺清单 ${manifest}(dump 阶段生成,需与 dump 目录一同拷来)" local bad=0 step "表数量比对" while IFS='|' read -r tag db n; do [ "$tag" = "TABLES" ] || continue local m m=$(docker exec -i "$DST_CONTAINER" psql -U postgres -d "$db" -tAc \ "select count(*) from information_schema.tables where table_schema='public' and table_type='BASE TABLE'" 2>/dev/null || echo "-") if [ "$m" = "$n" ]; then log " ✓ $db $m 张表"; else warn " ✗ $db 源 $n / 目标 $m"; bad=$((bad+1)); fi done < <(grep '^TABLES' "$manifest") step "jnpf_init 逐表行数比对" local diff=0 checked=0 while IFS='|' read -r tag db tbl n; do [ "$tag" = "ROWS" ] || continue checked=$((checked+1)) local m m=$(docker exec -i "$DST_CONTAINER" psql -U postgres -d "$db" -tAc "select count(*) from public.\"$tbl\"" 2>/dev/null || echo "-") if [ "$m" != "$n" ]; then printf ' ✗ %-46s 源=%-8s 目标=%s\n' "$tbl" "$n" "$m"; diff=$((diff+1)); fi done < <(grep '^ROWS' "$manifest") log "逐表比对:$checked 张表,$diff 张不一致" [ "$diff" = 0 ] || warn "不一致的表若全是日志类(base_sys_log 等),可能是 dump 后源库仍在写入所致,人工确认" [ "$bad" = 0 ] && [ "$diff" = 0 ] && { step "校验通过 ✓"; return 0; } step "校验未全通过 —— 逐条确认后再放行"; return 1 } case "${1:-}" in dump) do_dump ;; restore) do_restore ;; verify) do_verify ;; all) die "不提供 all:dump 与 restore 按设计在不同机器上执行(restore 需目标机本地超户),请分别执行" ;; *) usage ;; esac docker/postgres/migration/schema-drift-check.sh
New file @@ -0,0 +1,147 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # schema 漂移检查:对照远程 MySQL(事实源)与本地 PG 的表级 + 列级差异 # # 背景:self_built_tables_pg.sql 是迁移期快照,远端是活跃开发库(L035)——此后 # 远端持续加列/建表。合并 main 带业务表变更、或数据刷新(migrate-data.sh)之前, # 先跑本脚本;否则漂移会静默溜进交付包(启动/冒烟全过,点到新功能才 # "column does not exist")。已发生两例:环境监测 31 列(update_lims_huanjing_ # 20260717.sql)、sxt/稳定性 7 列 + 6 表(update_lims_sxt_wendingxing_20260717.sql)。 # # 用法(凭据经环境变量传入,绝不写进脚本;远程库连接参数见项目内部手册): # MYSQL_HOST=<host> MYSQL_PASSWORD=<pw> ./schema-drift-check.sh # # 参数(环境变量): # MYSQL_HOST 必填 # MYSQL_PASSWORD 必填 # MYSQL_PORT=3306 MYSQL_USER=root MYSQL_DB=jnpf_init # MYSQL_CLIENT_IMAGE=mysql:8.0 本地 mysql 客户端是 9.x,走容器客户端(项目约定,L010 utf8mb4) # CONTAINER=cx-postgres PG_DB=jnpf_init # EXCLUDE_RE= 按表名正则排除(如 '_bak$|_tmp$'),两侧同时生效 # # 判定与退出码: # MySQL 有而 PG 缺(表/列)=阻断性漂移 → 打印明细含类型/注释(可直接照抄写增量 SQL),exit 1 # PG 有而 MySQL 缺 =警告(远端已删列/本地保留,删列有数据风险,默认不处理),exit 0 # 两侧列名统一小写比较(PG 未加引号标识符折叠小写,L035 大小写折叠坑) # # 发现阻断性漂移后的处置(SOP 详见 docs/docker-compose-guide.md §18): # 1. 按输出明细写增量 SQL(幂等 ADD COLUMN IF NOT EXISTS)入 docs/pg-migration-ddl/ # 2. 接线 install.sh + assemble-package.sh(照 update_lims_huanjing_20260717.sql 先例) # 3. 本地 PG 应用 → 重跑本脚本应转绿 → make jars && make up && make smoke # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail # ── 退役护栏(2026-07-25)───────────────────────────────────────────────────── # 本脚本以「远程 MySQL = 事实源」为前提做 schema 对照,该前提自 2026-07-20 起消失: # 现事实源是腾讯云 CVM 共享 PG,MySQL 停在 07-16 且不再有人改它。 # 脚本本身只读无破坏性,但其结论已失效 —— 会把"PG 比 MySQL 多出来的新表/新列"报成漂移, # 照着去"补"反而会破坏当前 schema。故默认拒绝,避免有人拿失效结论行动。 # 现在要查 schema 差异,应改为对照「本地 PG ↔ CVM 共享 PG」,而非 MySQL。 if [ "${I_KNOW_MIGRATION_IS_RETIRED:-}" != "1" ]; then printf '\033[1;31m[已退役·拒绝执行]\033[0m %s\n' "schema-drift-check.sh 的对照基准(远程 MySQL)已不是事实源。" >&2 printf ' 它会把 PG 侧新增的表/列误报成"缺列漂移",照此修改会破坏当前 schema。\n' >&2 printf ' 查 schema 差异请改为对照 本地 PG ↔ CVM 共享 PG(YOUR_DB_HOST:15432)。\n' >&2 printf ' 仅需与历史 MySQL 做考古对照时:I_KNOW_MIGRATION_IS_RETIRED=1 %s\n' "$0" >&2 exit 1 fi MYSQL_HOST="${MYSQL_HOST:?必填:远程 MySQL 地址(连接参数见项目内部手册)}" MYSQL_PASSWORD="${MYSQL_PASSWORD:?必填:远程 MySQL 密码(经环境变量传入,勿写脚本)}" MYSQL_PORT="${MYSQL_PORT:-3306}" MYSQL_USER="${MYSQL_USER:-root}" MYSQL_DB="${MYSQL_DB:-jnpf_init}" MYSQL_CLIENT_IMAGE="${MYSQL_CLIENT_IMAGE:-mysql:8.0}" CONTAINER="${CONTAINER:-cx-postgres}" PG_DB="${PG_DB:-jnpf_init}" EXCLUDE_RE="${EXCLUDE_RE:-}" log() { printf '\033[1;34m[drift]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[drift][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[drift][err]\033[0m %s\n' "$*" >&2; exit 1; } TMP="$(mktemp -d)" cleanup() { rm -rf "$TMP"; } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM docker exec "$CONTAINER" true >/dev/null 2>&1 || die "PG 容器 ${CONTAINER} 不在运行" # ── 1. 两侧各一次 information_schema 全量导出(只取 BASE TABLE,排除视图/扩展视图) ── log "导出远程 MySQL schema(${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DB})…" docker run --rm "$MYSQL_CLIENT_IMAGE" mysql --default-character-set=utf8mb4 \ -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" \ information_schema --batch -sN -e " SELECT LOWER(c.table_name), LOWER(c.column_name), c.column_type, c.is_nullable, c.column_comment FROM columns c JOIN tables t ON t.table_schema=c.table_schema AND t.table_name=c.table_name WHERE c.table_schema='$MYSQL_DB' AND t.table_type='BASE TABLE' ORDER BY 1,2" 2>/dev/null > "$TMP/mysql.tsv" \ || die "远程 MySQL 导出失败(网络/凭据?)" log "导出本地 PG schema(${CONTAINER}/${PG_DB})…" docker exec "$CONTAINER" psql -U postgres -d "$PG_DB" -tA -F$'\t' -c " SELECT lower(c.table_name), lower(c.column_name) FROM information_schema.columns c JOIN information_schema.tables t ON t.table_schema=c.table_schema AND t.table_name=c.table_name WHERE c.table_schema='public' AND t.table_type='BASE TABLE' ORDER BY 1,2" > "$TMP/pg.tsv" \ || die "本地 PG 导出失败" # 表.列 键集合(可选排除) mkkeys() { # $1=in tsv $2=out keys if [ -n "$EXCLUDE_RE" ]; then awk -F'\t' -v re="$EXCLUDE_RE" '$1 !~ re {print $1"."$2}' "$1" > "$2" else awk -F'\t' '{print $1"."$2}' "$1" > "$2" fi } mkkeys "$TMP/mysql.tsv" "$TMP/mysql.keys" mkkeys "$TMP/pg.tsv" "$TMP/pg.keys" cut -d. -f1 "$TMP/mysql.keys" | sort -u > "$TMP/mysql.tables" cut -d. -f1 "$TMP/pg.keys" | sort -u > "$TMP/pg.tables" N_MY_T=$(wc -l < "$TMP/mysql.tables" | tr -d ' '); N_PG_T=$(wc -l < "$TMP/pg.tables" | tr -d ' ') log "MySQL 表 ${N_MY_T} / PG 表 ${N_PG_T}(列键 $(wc -l < "$TMP/mysql.keys" | tr -d ' ') / $(wc -l < "$TMP/pg.keys" | tr -d ' '))" # ── 2. 表级差集 ────────────────────────────────────────────────────────────── comm -23 "$TMP/mysql.tables" "$TMP/pg.tables" > "$TMP/tables.missing" # 阻断 comm -13 "$TMP/mysql.tables" "$TMP/pg.tables" > "$TMP/tables.extra" # 警告 # ── 3. 列级差集(只看两侧都有的表,整表缺失已在表级报过) ──────────────────── comm -12 "$TMP/mysql.tables" "$TMP/pg.tables" > "$TMP/tables.common" grep -F -f <(sed 's/$/./' "$TMP/tables.common") "$TMP/mysql.keys" 2>/dev/null \ | comm -23 - "$TMP/pg.keys" > "$TMP/cols.missing" || true # 阻断 grep -F -f <(sed 's/$/./' "$TMP/tables.common") "$TMP/pg.keys" 2>/dev/null \ | comm -13 "$TMP/mysql.keys" - > "$TMP/cols.extra" || true # 警告 # ── 4. 报告 ────────────────────────────────────────────────────────────────── BLOCKING=0 echo if [ -s "$TMP/tables.missing" ]; then BLOCKING=1 printf '\033[1;31m[drift] ✗ MySQL 有而 PG 缺的表(%s 张,阻断):\033[0m\n' "$(wc -l < "$TMP/tables.missing" | tr -d ' ')" sed 's/^/ /' "$TMP/tables.missing" fi if [ -s "$TMP/cols.missing" ]; then BLOCKING=1 printf '\033[1;31m[drift] ✗ MySQL 有而 PG 缺的列(%s 个,阻断)——含类型/注释,可照抄写增量 SQL:\033[0m\n' "$(wc -l < "$TMP/cols.missing" | tr -d ' ')" # 从 mysql.tsv 反查类型与注释 awk -F'\t' 'NR==FNR{miss[$0]=1; next} (($1"."$2) in miss){printf " %-40s %-30s %-14s %s %s\n", $1, $2, $3, ($4=="YES"?"NULL":"NOT NULL"), $5}' \ "$TMP/cols.missing" "$TMP/mysql.tsv" fi if [ -s "$TMP/tables.extra" ]; then warn "PG 有而 MySQL 缺的表($(wc -l < "$TMP/tables.extra" | tr -d ' ') 张,通常=远端已删或本地自建,默认保留):" sed 's/^/ /' "$TMP/tables.extra" fi if [ -s "$TMP/cols.extra" ]; then warn "PG 有而 MySQL 缺的列($(wc -l < "$TMP/cols.extra" | tr -d ' ') 个,远端已删/本地保留,删列有数据风险,默认不处理):" sed 's/^/ /' "$TMP/cols.extra" fi echo if [ "$BLOCKING" = 1 ]; then printf '\033[1;31m[drift][err]\033[0m 存在阻断性漂移:按上方明细写增量 SQL(照 docs/pg-migration-ddl/update_lims_huanjing_20260717.sql 先例:幂等 ADD COLUMN IF NOT EXISTS + 接线 install.sh/assemble-package.sh),SOP 见 docs/docker-compose-guide.md §18\n' >&2 exit 1 fi log "无阻断性漂移 ✓(警告项如上,均为 PG 侧多出,无害)" docker/postgres/migration/sql-seed/Update_PostgreSQL_1127.sql.gzBinary files differ
docker/postgres/migration/sql-seed/jnpf_dbnull_init.sql.gzBinary files differ
docker/postgres/migration/sql-seed/jnpf_flow_init.sql.gzBinary files differ
docker/postgres/migration/sql-seed/jnpf_xxljob_init.sql.gzBinary files differ
docker/postgres/tencent/Dockerfile.tencent
New file @@ -0,0 +1,34 @@ # 腾讯云 CVM 专用加速构建:把 apt 源换成国内镜像(debian→腾讯云内网、pgdg→阿里云), # 其余与仓库根 docker/postgres/Dockerfile 完全一致。 # 仅用于在腾讯云 CVM 上构建 cx/postgres:18.4;原 Dockerfile 保持官方源(交付其他客户现场用)。 # # 换源点: # /etc/apt/sources.list.d/debian.sources deb.debian.org → mirrors.tencentyun.com(内网直连) # /etc/apt/sources.list.d/pgdg.list apt.postgresql.org/pub/repos/apt → mirrors.aliyun.com/postgresql/repos/apt FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/postgres:18.4 AS pg_bigm_builder RUN sed -i 's|deb.debian.org|mirrors.tencentyun.com|g' /etc/apt/sources.list.d/debian.sources \ && sed -i 's|apt.postgresql.org/pub/repos/apt|mirrors.aliyun.com/postgresql/repos/apt|g' /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ postgresql-server-dev-18 \ libicu-dev \ git \ ca-certificates \ && git clone --depth 1 --branch v1.2-20250903 \ https://github.com/pgbigm/pg_bigm.git /tmp/pg_bigm \ && make -C /tmp/pg_bigm USE_PGXS=1 PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config \ && make -C /tmp/pg_bigm USE_PGXS=1 PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config install FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/postgres:18.4 RUN sed -i 's|deb.debian.org|mirrors.tencentyun.com|g' /etc/apt/sources.list.d/debian.sources \ && sed -i 's|apt.postgresql.org/pub/repos/apt|mirrors.aliyun.com/postgresql/repos/apt|g' /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ && apt-get install -y --no-install-recommends \ postgresql-18-pgvector \ && rm -rf /var/lib/apt/lists/* COPY --from=pg_bigm_builder /usr/lib/postgresql/18/lib/pg_bigm.so /usr/lib/postgresql/18/lib/ COPY --from=pg_bigm_builder /usr/share/postgresql/18/extension/pg_bigm* /usr/share/postgresql/18/extension/ docker/postgres/tencent/README.md
New file @@ -0,0 +1,14 @@ # 腾讯云 CVM PG 部署(jnpf 共享库) 本目录是腾讯云共享 CVM(`YOUR_DB_HOST`,`ssh ys-tencent`)上 jnpf PG 的专用编排。 CVM 是共享生产机(跑着 FastGPT/homework 等),jnpf PG 寄住、资源限死。 - `docker-compose.tencent-pg.yml` — 只起 `cx-postgres`(`cx/postgres:18.4`,mem_limit 3g / shared_buffers 512M / 端口 `15432` / build context 指向上级 `docker/postgres`)。 - `harden-pg-hba.sh` — 起容器 + 建角色后跑一次,禁 `postgres` 超户远程登录。 凭据经仓库根 `.env` 注入(`JNPF_PG_PASSWORD` 等,gitignored)。容器名 `cx-postgres` (与 `../migration/` 脚本默认值一致,零改动复用 install/migrate/backup)。 完整步骤(清理→搭建→迁移→分阶段割接→退役→备份)见 `docs/superpowers/plans/2026-07-20-tencent-cloud-pg-shared-db.md`。 docker/postgres/tencent/add-customer.sh
New file @@ -0,0 +1,114 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # 开一个客户的 PG 实例(docs/adr-multi-customer-db-isolation.md §8)。 # # 只是把四个【已有且都支持 CONTAINER 参数】的脚本串起来,本身不含任何建库逻辑: # compose up → install.sh(建四库+schema)→ create-app-roles.sh(角色+配额) # → harden-pg-hba.sh(postgres 超户禁公网) # 幂等:容器已在跑会跳过重建;install.sh 对非空库会自己拒绝(除非 FORCE_RESEED=1)。 # # 用法(在 /home/ubuntu/jnpf-pg/ 或仓库根)。客户 ID 直接用【客户拼音】(2026-08-08 命名规范: # 容器名 cx-pg-<拼音>,一眼可辨客户;c2 是客户B的历史 ID,其容器名靠 CUSTOMER_NAME 对齐拼音): # ./postgres/tencent/add-customer.sh customer22 15434 # ./postgres/tencent/add-customer.sh tpl 15439 # # 凭据文件:每个客户【一份自己的】,用 ENV_FILE 指定(强烈建议,见下): # ENV_FILE=/home/ubuntu/jnpf-pg/.env.某客户 ./postgres/tencent/add-customer.sh customer22 15434 # 不指定则回退到共享的 .env。 # # ⚠️ 为什么要每客户一份:密码与连接配额是【按实例】不同的,而 create-app-roles.sh 会按凭据 # 文件【重置】角色密码。共用一份 .env 就只能"临时改 → 跑 → 记得改回来",漏改一次就会在 # 下次对别的实例跑本脚本时,把那个客户的密码改成这个客户的。ENV_FILE 让这类事故不可能发生。 # # 该文件需要的键(照 .env.example 抄,密码换成本客户独有的): # JNPF_PG_PASSWORD 超户密码 # JNPF_APP_DB_PASSWORD / JNPF_FLOW_DB_PASSWORD / JNPF_NACOS_DB_PASSWORD # JNPF_APP_CONN_LIMIT=120 / JNPF_FLOW_CONN_LIMIT=20 / JNPF_NACOS_CONN_LIMIT=5 # (对应本实例 max_connections=150,和 145 < 147) # # 另需:要从外网连时,云安全组已放行该端口(限来源 IP,别开 0.0.0.0/0)。 # # 本脚本【不做】灌产品基线——那要人判断洗哪些数据,见 ADR §8 第 6 步。 # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail CUSTOMER_ID="${1:-}" CUSTOMER_PG_PORT="${2:-}" [ -n "$CUSTOMER_ID" ] && [ -n "$CUSTOMER_PG_PORT" ] || { echo "用法:add-customer.sh <客户ID,如 c2/c3/tpl> <端口,客户N=15431+N,母版=15439>" >&2 exit 1 } log() { printf '\033[1;34m[add-customer]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[add-customer][err]\033[0m %s\n' "$*" >&2; exit 1; } case "$CUSTOMER_ID" in ''|*[!a-z0-9]*) die "客户ID 只允许小写字母与数字(当前 '$CUSTOMER_ID'),它会成为容器名与卷名的一部分";; esac case "$CUSTOMER_PG_PORT" in ''|*[!0-9]*) die "端口必须是整数(当前 '$CUSTOMER_PG_PORT')";; esac SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PG_DIR="$(dirname "$SCRIPT_DIR")" # .../postgres CONTAINER="cx-pg-${CUSTOMER_ID}" # 护栏:绝不允许本脚本碰客户1 的既有实例(cx-postgres:15432,编排/内存/连接数都不同, # 且 create-app-roles.sh 会按当前 .env 重置角色密码——跑错实例就改错了客户1 的密码)。 # 判据用【端口】而不是容器名:容器名恒为 cx-pg-<id>,永远不可能等于 cx-postgres,那种检查是死代码。 [ "$CUSTOMER_PG_PORT" = "15432" ] && die "15432 是客户1 既有实例 cx-postgres 的端口,本脚本不得操作它(新客户用 15431+N,母版用 15439)" if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$CONTAINER"; then log "容器 ${CONTAINER} 已存在——按幂等继续(compose up 不会重建配置未变的容器)" fi for f in "$SCRIPT_DIR/docker-compose.customer-pg.yml" \ "$PG_DIR/migration/install.sh" \ "$PG_DIR/migration/create-app-roles.sh" \ "$SCRIPT_DIR/harden-pg-hba.sh"; do [ -f "$f" ] || die "缺 $f(先把仓库的 docker/postgres/ 整目录同步过来,见 ADR §8)" done # 凭据文件:ENV_FILE 优先,否则用共享 .env(compose 与建角色脚本必须读同一份, # 否则超户密码与业务角色密码会来自两个来源) if [ -n "${ENV_FILE:-}" ]; then [ -f "$ENV_FILE" ] || die "ENV_FILE 指定的 $ENV_FILE 不存在" ENV_PATH="$(cd "$(dirname "$ENV_FILE")" && pwd)/$(basename "$ENV_FILE")" else ENV_PATH=""; d="$SCRIPT_DIR" while [ "$d" != "/" ]; do [ -f "$d/.env" ] && { ENV_PATH="$d/.env"; break; } d="$(dirname "$d")" done [ -n "$ENV_PATH" ] || die "向上未找到 .env,且未指定 ENV_FILE" log "未指定 ENV_FILE,回退共享凭据文件 $ENV_PATH —— 多客户场景建议每客户一份,见文件头说明" fi log "凭据文件:$ENV_PATH" log "1/4 起容器 ${CONTAINER}(端口 ${CUSTOMER_PG_PORT},max_connections=150,shared_buffers=128MB)" CUSTOMER_ID="$CUSTOMER_ID" CUSTOMER_PG_PORT="$CUSTOMER_PG_PORT" \ docker compose --env-file "$ENV_PATH" \ -f "$SCRIPT_DIR/docker-compose.customer-pg.yml" up -d log "2/4 建四库 + schema(install.sh,库名与客户1 完全一致)" # 用 bash 显式调用,不依赖权限位——rsync/tar/zip 传播过程中执行位很容易丢 CONTAINER="$CONTAINER" bash "$PG_DIR/migration/install.sh" log "3/4 建角色 + 连接配额(create-app-roles.sh,读 ${ENV_PATH} 的密码与 *_CONN_LIMIT)" CONTAINER="$CONTAINER" ENV_FILE="$ENV_PATH" bash "$PG_DIR/migration/create-app-roles.sh" log "4/4 收紧 pg_hba(postgres 超户禁止公网登录)" CONTAINER="$CONTAINER" bash "$SCRIPT_DIR/harden-pg-hba.sh" cat <<EOF $(printf '\033[1;32m✓\033[0m') ${CONTAINER} 就绪。坐标 = <CVM公网IP>:${CUSTOMER_PG_PORT} 下一步: 1. 灌产品基线:起 cx-pg-template → DATABASES="jnpf_init jnpf_flow" promote-db.sh dump/restore (务必显式限定这两个库,默认值含 nacos_config,那是环境私有的,见 ADR §3.5) 2. 该客户各环境的 .env 填: CUSTOMER_DB_HOST=<CVM公网IP> CUSTOMER_DB_PORT=${CUSTOMER_PG_PORT} 3. 本次用的凭据文件:${ENV_PATH} $(if [ -z "${ENV_FILE:-}" ]; then printf ' \033[1;33m⚠\033[0m 用的是共享 .env——若刚才为本客户改过密码/配额,现在必须还原成客户1 的值,\n 否则下次对 cx-postgres 跑建角色脚本会改错密码。改用 ENV_FILE=<每客户一份> 可根除这个风险。'; else printf ' \033[1;32m✓\033[0m 用的是该客户专属凭据文件,未触碰其他客户的 .env'; fi) EOF docker/postgres/tencent/docker-compose.customer-pg.yml
New file @@ -0,0 +1,70 @@ # 多客户 PG 实例(docs/adr-multi-customer-db-isolation.md)。 # # 客户1 继续用同目录的 docker-compose.tencent-pg.yml,本文件只服务 c2 及以后的客户 # + 产品母版 tpl——刻意不合并进那个文件:合并会改掉客户1 的 compose 项目名(jnpf-pg), # 导致 docker compose 认为是新项目并重建它正在跑的容器。 # # 库名与角色名【全客户一致】(jnpf_init / jnpf_flow / jnpf_xxljob / nacos_config, # jnpf_app / jnpf_flow_app / nacos_app),客户之间只靠【端口】区分—— # 这是整个方案让 install.sh / create-app-roles.sh / backup.sh / promote-db.sh # / harden-pg-hba.sh 零改动复用的支点。 # # 用法(在 /home/ubuntu/jnpf-pg/ 下,.env 提供 JNPF_PG_PASSWORD): # CUSTOMER_ID=c2 CUSTOMER_PG_PORT=15433 docker compose -f postgres/tencent/docker-compose.customer-pg.yml up -d # CUSTOMER_ID=tpl CUSTOMER_PG_PORT=15439 docker compose -f postgres/tencent/docker-compose.customer-pg.yml up -d # 一般不直接用,走 ./postgres/tencent/add-customer.sh <客户ID> <端口>。 # # 端口约定:客户N = 15431+N(客户1=15432 已被既有实例占用),产品母版 = 15439。 name: jnpf-pg-${CUSTOMER_ID:?缺 CUSTOMER_ID,如 customer2 / tpl(新客户直接用拼音;c2 是客户B的历史 ID,卷名沿用)} services: pg: # 容器名可与 CUSTOMER_ID 解耦(CUSTOMER_NAME 未设时=CUSTOMER_ID): # 客户B历史 CUSTOMER_ID=c2(项目名/卷名 jnpf_pgdata_c2 由它派生,改了会挂空卷、绝不能动), # 2026-08-08 起容器名规范为 cx-pg-<客户拼音>,.env.客户B 里设 CUSTOMER_NAME=customer2 即可。 # 新客户 CUSTOMER_ID 直接用拼音,无需设 CUSTOMER_NAME。 container_name: cx-pg-${CUSTOMER_NAME:-${CUSTOMER_ID}} # 与客户1 同镜像(cx/postgres:18.4,含 pg_bigm/pgvector)。刻意不写 build: # CVM 上该镜像已存在,新客户不该触发重新构建。 image: cx/postgres:18.4 platform: linux/amd64 restart: unless-stopped shm_size: 256m # 客户1 的既有实例是 3g(400 连接 + 512MB shared_buffers);新实例按 # ADR §5 的 150 连接 + 128MB shared_buffers 预算,1g 足够,6 个实例才 ~3G。 mem_limit: 1g memswap_limit: 1g # CVM 无 swap,禁止溢出 environment: TZ: Asia/Shanghai POSTGRES_USER: postgres POSTGRES_PASSWORD: ${JNPF_PG_PASSWORD:?缺 JNPF_PG_PASSWORD,先在 .env 设} POSTGRES_INITDB_ARGS: "--locale=C.UTF-8 --encoding=UTF8" volumes: - pgdata:/var/lib/postgresql ports: - "${CUSTOMER_PG_PORT:?缺 CUSTOMER_PG_PORT,客户N=15431+N,母版=15439}:5432" command: - postgres - -c # 每实例独算,不再抢客户1 那 400 槽的全局池。 # 实测一套长跑全栈 45 连接、Druid 压到 5/1 后 ~25,150 可容 ~5 套栈。 - max_connections=150 - -c # 一整套库才 435MB 且实测 active 连接恒为 0(全是池空占),512MB 是浪费。 # 客户1 的既有实例维持 512MB 不动。 - shared_buffers=128MB - -c - shared_preload_libraries=pg_stat_statements,auto_explain - -c - pg_stat_statements.track=all - -c - timezone=Asia/Shanghai healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] interval: 15s timeout: 10s retries: 10 start_period: 60s volumes: pgdata: # 显式命名,避免 compose 按项目名生成 jnpf-pg-c2_pgdata 这种不好辨认的卷名 name: jnpf_pgdata_${CUSTOMER_ID} docker/postgres/tencent/docker-compose.tencent-pg.yml
New file @@ -0,0 +1,52 @@ # 腾讯云 CVM 专用:只起 jnpf PG(资源限死,寄住共享生产机 YOUR_DB_HOST)。 # 背景/割接见 docs/superpowers/plans/2026-07-20-tencent-cloud-pg-shared-db.md。 # 镜像 cx/postgres:18.4 由 build docker/postgres 产出(amd64,含 pg_bigm/pgvector)。 # 凭据经仓库根 .env 注入;端口 15432(5432/5433 已被 fastgpt-pg/homework-pg 占)。 # 🔴 项目名 jnpf-pg 与 service key 绝不能改:卷名 jnpf-pg_jnpf_pgdata 由项目名派生 # (下方 jnpf_pgdata 未显式 name:),改了新容器挂空卷、客户A数据"消失"。 name: jnpf-pg services: cx-postgres: # 2026-08-08 CVM 容器名规范化:带客户拼音(原 cx-postgres,与本机沙盒/交付基线同名易混淆)。 # 仅容器名改,service key / 项目名 / 卷不动。 container_name: cx-pg-customer1 image: cx/postgres:18.4 build: context: .. # tencent/ 上一级 = docker/postgres(含 Dockerfile) platform: linux/amd64 restart: unless-stopped shm_size: 512m mem_limit: 3g memswap_limit: 3g # 无 swap,禁止溢出到 swap environment: TZ: Asia/Shanghai POSTGRES_USER: postgres POSTGRES_PASSWORD: ${JNPF_PG_PASSWORD:?缺 JNPF_PG_PASSWORD,先在仓库根 .env 设} POSTGRES_INITDB_ARGS: "--locale=C.UTF-8 --encoding=UTF8" volumes: - jnpf_pgdata:/var/lib/postgresql ports: - "15432:5432" # 对公网(安全组只放 15432,靠强密码+非超户防护) command: - postgres - -c - shared_buffers=512MB - -c - shared_preload_libraries=pg_stat_statements,auto_explain - -c - pg_stat_statements.track=all - -c # 共享库:开发(本地~10服务)+测试(192.168.21.151~10服务)两环境 Druid 池叠加, # 稳态曾达 197/200 逼满(L029 共库放大)。单环境 9 服务先例=200 → 双环境提到 400。 # 400 连接内存 ~700-900MB,仍在 mem_limit 3g / CVM 可用内存内。 - max_connections=400 - -c - timezone=Asia/Shanghai healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] interval: 15s timeout: 10s retries: 10 start_period: 60s volumes: jnpf_pgdata: docker/postgres/tencent/harden-pg-hba.sh
New file @@ -0,0 +1,39 @@ #!/usr/bin/env bash # 收紧 cx-postgres 的 pg_hba.conf:postgres 超户【只允许 docker 内网】、公网一律 reject,然后 reload。 # # 策略(在第一条 "host all all all" 规则【之前】插入 3 行,pg_hba 首个匹配即生效): # 1) host all postgres 172.16.0.0/12 scram-sha-256 —— docker 内网(pgloader 迁移用)放行,需密码 # 2) host all postgres 0.0.0.0/0 reject —— 其余(公网)一律拒绝超户 # 3) host all postgres ::/0 reject # 业务角色(jnpf_app 等)不匹配上述 user=postgres 规则,继续走末尾 host-all-all-all scram,照常远程可连。 # 为何放行 172.16/12:Part C 的 S1 重迁 pgloader 从 docker 内网(172.x)用 postgres 灌数据,需保留; # 公网(非私有网段)命中 reject,纵深防御(即便超户密码泄露也无法从公网登录)。 # 本地 127.0.0.1/socket 的 trust 规则更靠前,不受影响(docker exec 照常)。 # # 幂等:已插入则跳过。容器内 psql 走 socket=trust,无需密码。 set -euo pipefail CONTAINER="${CONTAINER:-cx-postgres}" docker exec -i "$CONTAINER" bash -s <<'INNER' set -euo pipefail PGDATA="$(ls -d /var/lib/postgresql/*/docker 2>/dev/null | head -1)" HBA="$PGDATA/pg_hba.conf" [ -f "$HBA" ] || { echo "!! 找不到 pg_hba.conf ($HBA)"; exit 1; } if grep -qE 'postgres[[:space:]]+172\.16\.0\.0/12' "$HBA"; then echo "pg_hba 已含 postgres 内网放行+公网 reject,跳过" else awk ' !done && $1=="host" && $2=="all" && $3=="all" && $4=="all" { print "host all postgres 172.16.0.0/12 scram-sha-256" print "host all postgres 0.0.0.0/0 reject" print "host all postgres ::/0 reject" done=1 } { print } ' "$HBA" > "$HBA.new" mv "$HBA.new" "$HBA" echo "已在 host-all-all-all 前插入 postgres 内网放行+公网 reject" fi INNER docker exec "$CONTAINER" psql -U postgres -c "SELECT pg_reload_conf();" docker/preflight.sh
New file @@ -0,0 +1,354 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # JNPF 全栈部署自举校验(Backlog C1) # make up / make up-no-pull 的强制前置(SKIP_PREFLIGHT=1 逃生),也可独立运行。 # 目标:把"空库 PG 却 healthy、引擎/业务全线连不上"的静默失败拦在 # compose up 之前,缺项直接给出修复指引(migration/install.sh、INSTALL.md)。 # # 校验分三层: # 硬前置(失败立即退出) :docker CLI / daemon 可达 / compose v2 插件 / 编排文件存在 # 聚合检查(跑完统一汇总):.env 必填键(从 compose 的 ${VAR:?} 动态提取)、 # 客户坐标(CUSTOMER_DB_HOST/PORT 必填 + 实连探测 + 与 localdb profile 配套)、 # postgres_pgdata 卷、PG 容器可起且接受连接、三库表数 ≥ 基准、 # 种子数据(jnpf_init 有 admin)、共享配置文件就位(补丁 #8)、业务角色齐 # 警告(不拦截):Docker 可用内存 <12GB(L024)、宿主 hosts 缺 cx-infra、 # # 两种库形态,检查项按 CUSTOMER_DB_HOST 自动分流(2026-08-07): # 栈内 PG(=cx-postgres,交付/正式):全套跑,含卷/容器/三库/种子/角色 # 远端客户库(=CVM 上的客户实例,开发机/测试环境):3-5 与角色检查跳过——那边的 # 卷、schema、种子、角色由该 PG 实例自身的运维负责(docs/db-connection-pool-guide.md), # 本机既起不了那个容器也 docker exec 不进去;本机侧只验坐标可达与共享配置文件 # # 用法: # ./docker/preflight.sh # 仓库根执行(离线交付包内同路径,包根执行) # # 参数(环境变量,默认=现场标准值;改名可做隔离测试,不动真库): # CONTAINER PG 容器名 默认 cx-postgres # VOLUME_NAME PG external 数据卷名 默认 postgres_pgdata # ENV_FILE 凭据文件 默认 .env # COMPOSE_FILE_PATH 编排文件 默认 docker-compose.yml # DB_INIT / DB_FLOW / DB_XXLJOB 三库名 # PG_WAIT_SECS 等 PG 接受连接的上限秒数 默认 180(含大卷 crash recovery 窗口) # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail trap 'exit 130' INT trap 'exit 143' TERM CONTAINER="${CONTAINER:-cx-postgres}" VOLUME_NAME="${VOLUME_NAME:-postgres_pgdata}" ENV_FILE="${ENV_FILE:-.env}" COMPOSE_FILE_PATH="${COMPOSE_FILE_PATH:-docker-compose.yml}" DB_INIT="${DB_INIT:-jnpf_init}" DB_FLOW="${DB_FLOW:-jnpf_flow}" DB_XXLJOB="${DB_XXLJOB:-jnpf_xxljob}" PG_WAIT_SECS="${PG_WAIT_SECS:-180}" # 三库表数基准(下限,只允许 ≥,业务扩表不误报;install.sh 用精确匹配核对,本处刻意保守) # 注:73→74 只为 20260724 新增 audit_events +1;与 install.sh 现行「官方293+自建84=377」的 # 精确基准存在既有 10 张漂移(早于本次改动,未在本次范围内一并追平,避免误改无关基准) BASE_INIT=367 # 官方 293 + 自建 74 BASE_FLOW=70 BASE_XXLJOB=11 log() { printf '\033[1;34m[preflight]\033[0m %s\n' "$*"; } warnp(){ printf '\033[1;33m[preflight][warn]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[preflight][err]\033[0m %s\n' "$*" >&2; exit 1; } # 修复指引的路径按运行位置自适应:离线交付包根有 migration/,仓库根在 docker/postgres/ 下 if [ -f migration/install.sh ]; then MIG_DIR="migration" else MIG_DIR="docker/postgres/migration" fi # ── 聚合失败机制:单项失败不中断,跑完统一汇总(前置失败的项标记跳过) ────── FAILS=() WARNS=() ok() { printf ' \033[1;32m✓\033[0m %s\n' "$1"; } bad() { printf ' \033[1;31m✗\033[0m %s\n' "$1"; FAILS+=("$1"$'\n'" 修复:$2"); } warn2(){ printf ' \033[1;33m!\033[0m %s\n' "$1"; WARNS+=("$1 —— $2"); } # $2 可选:跳过原因后缀,默认「前置失败」(外部库模式等主动跳过场景传自己的原因) skip() { printf ' \033[1;90m-\033[0m %s(%s,跳过)\n' "$1" "${2:-前置失败}"; } psql_exec() { docker exec "$CONTAINER" psql -U postgres "$@"; } get_env() { { grep -E "^$1=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2-; } || true; } CDH=""; CDP="" # ── 0. 硬前置:docker / compose ────────────────────────────────────────────── log "===== 0/6 docker 环境 =====" command -v docker >/dev/null 2>&1 \ || die "未找到 docker 命令——先安装 Docker Engine(含 compose v2 插件)" docker info >/dev/null 2>&1 \ || die "docker daemon 不可达——确认 Docker/OrbStack 已启动,且当前用户有权限(docker info 报错内容为准)" docker compose version >/dev/null 2>&1 \ || die "docker compose v2 插件不可用——需要 Docker Compose V2(docker compose version 验证)" ok "docker CLI / daemon / compose v2 就绪" # 编排文件存在性提前到此处硬前置检查(原放在下方"1. .env 必填键",但下面若干 # 检查更早就用 awk 读取该文件——set -e 下 awk 遇文件不存在会以 awk 自身退出码裸退,绕过所有 # die() 提示;提到最前面确保任何读取 COMPOSE_FILE_PATH 的分支之前已保证文件存在) [ -f "$COMPOSE_FILE_PATH" ] \ || die "缺编排文件 ${COMPOSE_FILE_PATH}——请在仓库根/离线交付包根目录执行" # 内存红线(L024:全栈 16 容器 <12GB 会 OOM 连锁:PG crash 循环 + gRPC 超时风暴) # 四舍五入到 GiB:VM 名义 12GB 会被内核保留吃掉零头(12288MiB 实报 ~12.2GiB→整除变 11 误报) MEM_TOTAL="$(docker info --format '{{.MemTotal}}' 2>/dev/null || echo 0)" MEM_GIB=$(( (MEM_TOTAL + 512*1024*1024) / 1024 / 1024 / 1024 )) if [ "$MEM_TOTAL" -gt 0 ] && [ "$MEM_GIB" -lt 12 ]; then warn2 "Docker 可用内存仅 ${MEM_GIB}GB(<12GB 红线)" "全栈需 ≥12GB:Linux 加物理内存/减容器;OrbStack 用 orb config set memory_mib 12288(L024)" else ok "Docker 可用内存 ${MEM_GIB}GB(≥12GB)" fi # cx-infra 宿主解析(ApiDomain 双消费方约定;ApiDomain 已改现场地址的纯容器部署可忽略本项) # Git Bash 下 /etc/hosts 是 Git 自带文件,Windows 真 hosts 在 System32/drivers/etc/hosts HOSTS_FILE=/etc/hosts case "$(uname -s)" in MINGW*|MSYS*) HOSTS_FILE="$(cygpath -u "$SYSTEMROOT")/System32/drivers/etc/hosts" ;; esac if grep -q 'cx-infra' "$HOSTS_FILE" 2>/dev/null; then ok "宿主 hosts 已含 cx-infra" else warn2 "宿主 hosts 缺 cx-infra" "补一行「127.0.0.1 cx-infra」(本机浏览器调试用;ApiDomain 已改为现场地址的纯容器部署可忽略)" fi # (RocketMQ 已于 2026-08-10 移除,原 mq-store 卷属主检查一并删除,见补丁 #10) # ── 1. .env 必填键(从 compose 文件的 \${VAR:?} 动态提取,改 compose 自动跟进) ── log "===== 1/6 .env 必填键 =====" ENV_OK=1 # 编排文件存在性已在 0/6 硬前置检查(早于本节对该文件的 awk/grep 读取) if [ ! -f "$ENV_FILE" ]; then ENV_OK=0 bad "缺 ${ENV_FILE}(compose 凭据来源)" "cp .env.example .env 并按注释改值(交付环境必须全部改密,见 docs/release-checklist.md §0.7)" else # 先滤注释行再提取(compose 头注释里有 ${VAR:?} 字样的说明文字,不滤会误提取) REQUIRED_KEYS="$(grep -vE '^[[:space:]]*#' "$COMPOSE_FILE_PATH" | grep -oE '\$\{[A-Za-z_][A-Za-z0-9_]*:\?' | sed -E 's/^\$\{//; s/:\?$//' | sort -u)" MISSING=() for key in $REQUIRED_KEYS; do # .env 里有非空值,或已在环境变量里(compose 两种来源都认) if grep -qE "^${key}=..*" "$ENV_FILE" || [ -n "${!key:-}" ]; then :; else MISSING+=("$key"); fi done if [ "${#MISSING[@]}" -eq 0 ]; then ok "必填键齐:$(echo "$REQUIRED_KEYS" | tr '\n' ' ')" else ENV_OK=0 bad "${ENV_FILE} 缺必填键:${MISSING[*]}" "对照 .env.example 补齐(每个键的生成方法见文件内注释)" fi fi # ── 2. 客户坐标(docs/adr-multi-customer-db-isolation.md §3)──────────────── # 缺配会静默回退到默认 cx-postgres:5432——在开发机上就是连到本机沙盒而不是客户库, # 数据写错地方且无任何提示。这两个变量同时决定业务库 jnpf_init 与引擎库 jnpf_flow # (compose 的 FLOW_DB_URL 由它们推导),故两库不可能半切。 log "===== 2/6 客户坐标 =====" if [ "$ENV_OK" = 0 ]; then skip "客户坐标" else # `|| true` 不可省:set -euo pipefail 下 grep 无匹配返回 1,会经 pipefail 传给命令替换, # 令赋值失败并被 set -e 直接终止脚本——表现为打完节标题就静默退出(缺配恰恰是要报的情况) CDH="$(grep -E '^CUSTOMER_DB_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" CDP="$(grep -E '^CUSTOMER_DB_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" if [ -z "$CDH" ] || [ -z "$CDP" ]; then bad "${ENV_FILE} 缺 CUSTOMER_DB_HOST / CUSTOMER_DB_PORT" \ "从 .env.example 抄这两行:同栈 PG 填 cx-postgres / 5432;客户库填 YOUR_DB_HOST / 154xx(客户1=15432、客户2=15433)" elif [ -n "$(printf '%s' "$CDP" | tr -d '0-9')" ]; then bad "CUSTOMER_DB_PORT 必须是整数,当前='${CDP}'" "改 ${ENV_FILE}" else echo " 生效坐标:${CDH}:${CDP}" # 栈内 PG 收在 localdb profile 内(默认不启动),开关必须与坐标配套,否则两种错配: # 坐标指栈内却没开 profile → PG 根本不起,全栈无库可连(硬失败) # 坐标指远端却开了 profile → 起一个没人连的 PG,还要求 external 卷存在(警告) PROFILES="$(printf '%s' "${COMPOSE_PROFILES:-$(get_env COMPOSE_PROFILES)}" | tr ' ' ',')" case ",${PROFILES}," in *,localdb,*) HAS_LOCALDB=1 ;; *) HAS_LOCALDB=0 ;; esac if [ "$CDH" = "cx-postgres" ] && [ "$HAS_LOCALDB" = 0 ]; then bad "CUSTOMER_DB_HOST=cx-postgres 但未启用 localdb profile(栈内 PG 不会启动,业务全线无库可连)" \ "在 ${ENV_FILE} 加一行 COMPOSE_PROFILES=localdb(见 .env.example §1)" elif [ "$CDH" != "cx-postgres" ] && [ "$HAS_LOCALDB" = 1 ]; then warn2 "库在远端 ${CDH}:${CDP},却启用了 localdb profile" \ "注释掉 ${ENV_FILE} 的 COMPOSE_PROFILES=localdb——否则会起一个没人连的 PG,且缺 external 卷 ${VOLUME_NAME} 时整栈起不来" fi if [ "$CDH" = "cx-postgres" ]; then # compose 内部服务名,宿主解析不了;起栈后由 PG healthcheck 与下面 3/6 保证 ok "客户坐标 = 同栈 PG(${CDH}:${CDP}),跳过宿主实连探测" elif command -v nc >/dev/null 2>&1 && nc -z -w 5 "$CDH" "$CDP" >/dev/null 2>&1; then ok "客户坐标 ${CDH}:${CDP} 可达" elif ! command -v nc >/dev/null 2>&1 && \ (exec 3<>"/dev/tcp/${CDH}/${CDP}") >/dev/null 2>&1; then # Git Bash 无 nc,退回 bash /dev/tcp(无超时控制,不可达时可能等到 TCP SYN 超时) ok "客户坐标 ${CDH}:${CDP} 可达(/dev/tcp 探测)" else bad "客户坐标 ${CDH}:${CDP} 连不上" \ "确认端口填对(客户1=15432 / 客户2=15433)、云安全组已放行来源 IP、本机网络出站不受限" fi fi # Compose 生效的 Redis 坐标。JNPF_DOCKER_REDIS_* 允许宿主 JVM 用 127.0.0.1、 # 容器改走 cx-infra(host-gateway),避免容器把 127.0.0.1 误当成宿主机。 RDH_HOST="$(grep -E '^JNPF_REDIS_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" RDP_HOST="$(grep -E '^JNPF_REDIS_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" RDH="$(grep -E '^JNPF_DOCKER_REDIS_HOST=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" RDP="$(grep -E '^JNPF_DOCKER_REDIS_PORT=' "$ENV_FILE" | tail -1 | cut -d= -f2- || true)" RDH="${RDH:-${RDH_HOST:-cx-redis}}"; RDP="${RDP:-${RDP_HOST:-6379}}" echo " Compose Redis 坐标:${RDH}:${RDP}" if [ "$RDH" = "cx-redis" ]; then ok "Redis 坐标 = 同栈 redis(${RDH}:${RDP}),跳过宿主实连探测" else # 这两个名称只在容器内指向 host-gateway;预检运行在宿主,等价探测回环地址。 REDIS_PROBE_HOST="$RDH" case "$RDH" in cx-infra|host.docker.internal) REDIS_PROBE_HOST=127.0.0.1 ;; esac if command -v nc >/dev/null 2>&1 && nc -z -w 5 "$REDIS_PROBE_HOST" "$RDP" >/dev/null 2>&1; then ok "Redis 坐标 ${RDH}:${RDP} 可达" elif ! command -v nc >/dev/null 2>&1 && \ (exec 3<>"/dev/tcp/${REDIS_PROBE_HOST}/${RDP}") >/dev/null 2>&1; then ok "Redis 坐标 ${RDH}:${RDP} 可达(/dev/tcp 探测)" else bad "Redis 坐标 ${RDH}:${RDP} 连不上" \ "宿主自装 Redis 请设 JNPF_DOCKER_REDIS_HOST=cx-infra,并确认 Redis 正监听对应端口;远端实例确认云安全组已放行来源 IP" fi fi fi # ── 外部库模式判定 ─────────────────────────────────────────────────────────── # 坐标指 CVM/远程实例时,本机 cx-postgres 不是数据面(测试环境已按 testenv.yml 用 # profiles 把它请出栈):3/6-6/6 的本地卷/容器/schema/种子检查全部跳过——否则 4/6 # 会把已停用的本地 PG 又拉起来。远程库连通性 2/6 已实连探测;schema/密码由 CVM 侧 # 管理 + 服务启动连库行为兜底。 EXTERNAL_DB=0 if [ "$ENV_OK" = 1 ] && [ -n "${CDH:-}" ] && [ "$CDH" != "cx-postgres" ]; then EXTERNAL_DB=1 fi # ── 3. PG 数据卷 ───────────────────────────────────────────────────────────── log "===== 3/6 数据卷 ${VOLUME_NAME} =====" VOL_OK=1 if [ "$EXTERNAL_DB" = 1 ]; then VOL_OK=0 skip "数据卷检查(本地 PG 非数据面)" "外部库模式 ${CDH}:${CDP}" elif docker volume inspect "$VOLUME_NAME" >/dev/null 2>&1; then ok "external 卷 ${VOLUME_NAME} 存在" else VOL_OK=0 bad "external 卷 ${VOLUME_NAME} 不存在(compose 声明 external,缺卷直接起不来)" \ "docker volume create ${VOLUME_NAME},再灌数据:( cd ${MIG_DIR} && ./install.sh && ./create-app-roles.sh )(INSTALL.md §2 ⑤)" fi # ── 4. PG 容器可起且接受连接 ───────────────────────────────────────────────── log "===== 4/6 PG 容器 ${CONTAINER} =====" PG_OK=0 if [ "$EXTERNAL_DB" = 1 ]; then skip "PG 容器检查(不拉起本地 ${CONTAINER})" "外部库模式" elif docker exec "$CONTAINER" true >/dev/null 2>&1; then PG_OK=1 elif [ "$VOL_OK" = 1 ] && [ "$ENV_OK" = 1 ]; then log "容器未在运行,尝试拉起:docker compose up -d cx-postgres" if docker compose up -d cx-postgres >/dev/null 2>&1; then PG_OK=1 else bad "PG 容器拉起失败(docker compose up -d cx-postgres)" \ "镜像缺失则先 make images(仓库)/ make load(离线包);再看 docker compose logs cx-postgres" fi else skip "PG 容器拉起(.env 或数据卷未就绪)" fi if [ "$PG_OK" = 1 ]; then waited=0 until docker exec "$CONTAINER" pg_isready -q -U postgres >/dev/null 2>&1; do if [ "$waited" -ge "$PG_WAIT_SECS" ]; then PG_OK=0 bad "PG ${PG_WAIT_SECS}s 内未接受连接(大卷 crash recovery 可能更久)" \ "docker compose logs cx-postgres 看恢复进度/报错;确认内存充足(L024)后可调大 PG_WAIT_SECS 重跑" break fi [ "$waited" = 0 ] && log "等待 PG 接受连接(上限 ${PG_WAIT_SECS}s,冷启动/crash recovery 属正常)…" sleep 3; waited=$((waited+3)) done [ "$PG_OK" = 1 ] && ok "PG 接受连接(pg_isready)" fi # ── 5. 三库 schema:库存在 + 表数 ≥ 基准 ──────────────────────────────────── log "===== 5/6 三库 schema =====" DBS_OK=1 if [ "$EXTERNAL_DB" = 1 ]; then DBS_OK=0 skip "三库 schema 校验" "外部库模式" elif [ "$PG_OK" = 1 ]; then check_db() { local db="$1" base="$2" n if ! psql_exec -tAc "SELECT 1 FROM pg_database WHERE datname='$db'" 2>/dev/null | grep -q 1; then DBS_OK=0 bad "库 ${db} 不存在" "全新环境跑 ( cd ${MIG_DIR} && ./install.sh )(建四库+schema;INSTALL.md §2 ⑤)" return fi n="$(psql_exec -d "$db" -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'" 2>/dev/null || echo 0)" if [ "${n:-0}" -ge "$base" ]; then ok "库 ${db}:${n} 张表(基准 ≥${base})" else DBS_OK=0 bad "库 ${db} 表数 ${n} < 基准 ${base}(schema 不完整/灌库中断)" \ "重跑 ( cd ${MIG_DIR} && ./install.sh )——目标库非空会被守卫拦下,按提示 FORCE_RESEED=1(会清该库数据,先确认归属)" fi } check_db "$DB_INIT" "$BASE_INIT" check_db "$DB_FLOW" "$BASE_FLOW" check_db "$DB_XXLJOB" "$BASE_XXLJOB" else DBS_OK=0 skip "三库 schema 校验" fi # ── 6. 种子数据 + 共享配置文件 + 业务角色 ─────────────────────────────────── log "===== 6/6 种子数据、共享配置文件与业务角色 =====" # 共享配置文件就位(补丁 #8:替代原配置中心种子检查)——与库无关,外部库模式也要查 for f in datasource resources system-config frame-config logger mq discovery; do if [ -f "config/shared/$f.yaml" ]; then ok "config/shared/${f}.yaml 就位" else bad "缺 config/shared/${f}.yaml(业务服务取不到该项配置,起来也全挂)" \ "确认仓库内 config/shared/ 目录完整(补丁 #8 后配置随仓库分发,不再经配置中心下发)" fi done if [ "$PG_OK" = 1 ] && [ "$DBS_OK" = 1 ]; then n_admin="$(psql_exec -d "$DB_INIT" -tAc "SELECT count(*) FROM base_user WHERE f_account='admin'" 2>/dev/null || echo 0)" if [ "${n_admin:-0}" -ge 1 ]; then ok "种子用户 admin 在(${DB_INIT}.base_user)" else bad "${DB_INIT}.base_user 无 admin(种子数据缺失,登录/冒烟必失败)" \ "全新环境:install.sh 的官方 init 含种子;割接环境:按 docs/archive/server-pg-cutover-checklist.md 跑 migrate-data.sh" fi # 业务角色(角色名以 .env 为准,缺省=交付标准值;密码是否匹配 preflight 不验——见 L030, # socket/127.0.0.1 是 trust 假验证,真实验证由服务启动后的连库行为兜底) APP_ROLE="$(get_env JNPF_APP_DB_USER)"; APP_ROLE="${APP_ROLE:-jnpf_app}" FLOW_ROLE="$(get_env JNPF_FLOW_DB_USER)"; FLOW_ROLE="${FLOW_ROLE:-jnpf_flow_app}" for role in "$APP_ROLE" "$FLOW_ROLE"; do if psql_exec -tAc "SELECT 1 FROM pg_roles WHERE rolname='$role'" 2>/dev/null | grep -q 1; then ok "业务角色 ${role} 在" else bad "业务角色 ${role} 不存在(对应服务连库必报 password authentication failed)" \ "先定稿 ${ENV_FILE} 再跑 ( cd ${MIG_DIR} && ./create-app-roles.sh )(幂等,按 .env 重置密码;§0.11 铁律顺序)" fi done else [ "$EXTERNAL_DB" = 1 ] && skip "种子数据与业务角色校验(由 CVM 侧管理)" "外部库模式" \ || skip "种子数据与业务角色校验" fi # ── 汇总 ───────────────────────────────────────────────────────────────────── echo if [ "${#WARNS[@]}" -gt 0 ]; then warnp "警告 ${#WARNS[@]} 项(不拦截):" for w in "${WARNS[@]}"; do printf ' · %s\n' "$w"; done fi if [ "${#FAILS[@]}" -eq 0 ]; then log "preflight 通过 ✓(可继续 docker compose up)" else printf '\033[1;31m[preflight][err]\033[0m 未通过 %s 项:\n' "${#FAILS[@]}" >&2 i=1 for f in "${FAILS[@]}"; do printf ' %2d. %s\n' "$i" "$f" >&2; i=$((i+1)); done printf '\033[1;31m[preflight][err]\033[0m 修复后重跑 make preflight;确要跳过:SKIP_PREFLIGHT=1 make up\n' >&2 exit 1 fi docker/up-staged.sh
New file @@ -0,0 +1,101 @@ #!/bin/bash # ───────────────────────────────────────────────────────────────────────────── # 分波启动全栈:先基础设施,再按依赖重要度分波放业务服务,波间带负载闸门。 # # 为什么需要:compose 的 depends_on 只保证「基础设施 healthy 才放业务」,但多个 # 业务 JVM 会在同一瞬间冷启动。本机(Apple Silicon 跑 amd64 模拟)CPU 被打满后, # Druid 建连超时 → Spring 启动失败 → 容器僵在 unhealthy(2026-08-06 恢复库后实测: # 一次性 up 大面积僵死,分波 + 负载闸门全绿)。交付服务器(原生 amd64)通常不需要 # 本脚本,直接 docker compose up -d 即可。 # # 用法(仓库根执行): # ./docker/up-staged.sh # 全栈分波起 # LOAD_GATE=6 ./docker/up-staged.sh # 收紧负载闸门(默认 9) # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail cd "$(dirname "$0")/.." LOAD_GATE="${LOAD_GATE:-9}" WAIT_BUDGET="${WAIT_BUDGET:-420}" # 每波等 healthy 的秒数预算 load_now() { # macOS / Linux 双兼容:取 1 分钟负载的整数部分 if command -v sysctl >/dev/null 2>&1 && sysctl -n vm.loadavg >/dev/null 2>&1; then sysctl -n vm.loadavg | awk '{print int($2)}' else awk '{print int($1)}' /proc/loadavg fi } wait_load() { local l while :; do l=$(load_now) [ "$l" -lt "$LOAD_GATE" ] && return 0 echo " 负载 $l ≥ 闸门 ${LOAD_GATE},等 30s…" sleep 30 done } wait_healthy() { local deadline=$((SECONDS+WAIT_BUDGET)) all c s while [ $SECONDS -lt $deadline ]; do all=1 for c in "$@"; do s=$(docker inspect "$c" --format '{{.State.Health.Status}}' 2>/dev/null || echo missing) [ "$s" = "healthy" ] || all=0 done [ "$all" = 1 ] && return 0 sleep 15 done echo " ⚠️ 超时未全绿:$*(继续下一波,稍后可单独 docker restart)" return 1 } wave() { echo "── 波次:$* ──" wait_load docker compose up -d "$@" || exit 1 wait_healthy "$@" && echo " ✓ 全绿" } # 波 0:基础设施(PG/Redis)——compose depends_on 的根。 # 2026-08-10:PG/Redis 按 .env 坐标动态入波。坐标指 CVM 时它们已被 # docker-compose.override.yml 的 profiles 门控挡在栈外,此处再显式点名反而会把它们拉起来 # (compose 显式点名服务会自动激活其 profile),白占内存——所以只在本机栈形态才入波。 envval() { grep -E "^$1=" .env 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '"'\''[:space:]'; } CDH="$(envval CUSTOMER_DB_HOST)"; CDH="${CDH:-cx-postgres}" # 未设 = compose 默认,即栈内 PG RDH="$(envval JNPF_REDIS_HOST)"; RDH="${RDH:-cx-redis}" # 同上 WAVE0=() [ "$CDH" = "cx-postgres" ] && WAVE0+=(cx-postgres) [ "$RDH" = "cx-redis" ] && WAVE0+=(cx-redis) # ⚠️ 变量必须写 ${VAR} 形式:紧跟中文全角标点时,裸 $VAR 会把全角字符的字节吞进变量名 # 2026-08-11 修:bash 3.2(macOS 自带)在 set -u 下,对**空数组**取 "${arr[@]}" 与 "${#arr[@]}" # 都会报 `WAVE0[@]: unbound variable` 直接退出。而 PG 与 Redis 双双外置到 CVM 时 WAVE0 正是空数组—— # 也就是说本脚本自 2026-08-10「按 .env 坐标动态入波」改动起,在当前数据面拓扑下**根本跑不起来**。 # 统一用 ${arr[@]+...} 形式取值绕开;顺带订正原注释「波 0 只起 MQ」——RocketMQ 已于同日移除, # 两者都在远端时波 0 是**空的**,直接跳过。 WAVE0_ITEMS="${WAVE0[@]+${WAVE0[@]}}" if [ -z "${WAVE0_ITEMS}" ]; then echo "数据面在远端(PG=${CDH} / Redis=${RDH}),波 0 无本地组件,跳过" else wave ${WAVE0_ITEMS} fi # 波 1:核心链(流程引擎 + 平台聚合服务 + 网关)——其余服务的运行时依赖 # 2026-08-11 平台聚合:cx-oauth / cx-system / cx-visualdev / cx-flowable / cx-file / # cx-message / cx-permission / cx-visualdata / cx-app 九个已合并为 cx-platform。 # ⚠️ wave() 是 `docker compose up -d "$@" || exit 1`——名字对不上会让整个分批启动 # **直接退出**,不是跳过。改 compose 服务名时本表必须同步(这条教训见上一条 cx-audit 注释)。 wave cx-flow-engine cx-platform cx-gateway # 波 2:主业务(原波 2 的 cx-flowable / cx-file / cx-permission 已在 cx-platform 内) wave cx-lims # 波 3:外围业务(原波 3 的 cx-message / cx-visualdev / cx-visualdata 已在 cx-platform 内) # cx-audit 于 2026-08-11 搬入 cx-biz-common;后者自 P1 建服起就漏在本表外,一并补上。 wave cx-biz-common cx-dms # 波 4:收尾(前端;原 cx-app 已在 cx-platform 内) # cx-scheduletask 保留为 extra profile,按需单独启动。 wave cx-web echo "══ 最终状态 ══" docker compose ps --format '{{.Name}}\t{{.Status}}' | sort dockerfile/app/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-app # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-app/jnpf-app-server/target/*.jar ./jnpf-app.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30012 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-app.jar"] dockerfile/biz-common/Dockerfile
New file @@ -0,0 +1,24 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-biz-common # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-biz-common/jnpf-biz-common-server/target/*.jar ./jnpf-biz-common.jar # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30015 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-biz-common.jar"] dockerfile/dms/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-dms # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-dms/jnpf-dms-server/target/*.jar ./jnpf-dms.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30016 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-dms.jar"] dockerfile/eln/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-eln # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-eln/jnpf-eln-server/target/*.jar ./jnpf-eln.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30013 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-eln.jar"] dockerfile/file/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-file # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-file/jnpf-file-server/target/*.jar ./jnpf-file.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30005 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-file.jar"] dockerfile/flowable/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo 'Asia/Shanghai' >/etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-flowable # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-flowable/jnpf-flowable-server/target/*.jar ./jnpf-flowable.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30004 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-flowable.jar"] dockerfile/gateway/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-gateway # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-gateway/target/*.jar ./jnpf-gateway.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30000 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-gateway.jar"] dockerfile/lims/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-lims # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-lims/jnpf-lims-server/target/*.jar ./jnpf-lims.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30019 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-lims.jar"] dockerfile/message/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-message # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-message/jnpf-message-server/target/*.jar ./jnpf-message.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30008 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-message.jar"] dockerfile/oauth/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-oauth # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-oauth/jnpf-oauth-server/target/*.jar ./jnpf-oauth.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30001 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-oauth.jar"] dockerfile/permission/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-permission # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-permission/jnpf-permission-server/target/*.jar ./jnpf-permission.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30010 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-permission.jar"] dockerfile/platform/Dockerfile
New file @@ -0,0 +1,36 @@ # 平台聚合服务:system / permission / oauth / visualdev / visualdata / message / # file / app / flowable 九个模块合一(2026-08-11)。取代原来的九个 cx-* 容器。 # # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-platform # 将构建产物jar包拷贝到运行时目录中(约 328MB,含九个模块; # 对比原九个独立 fat jar 各约 198MB、合计约 1.8GB) COPY jnpf-platform/jnpf-platform-server/target/*.jar ./jnpf-platform.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config # 沿用原 jnpf-system 的端口:discovery.yaml 里九个服务名全部别名到这里 EXPOSE 30002 # 指定容器启动时要运行的命令 # ⚠️ 堆上限比单模块服务大:九个模块的类元数据都在同一个 JVM 里(实测 RSS 约 1.07GB, # 堆 used 约 361MB)。compose 的 cx-platform 段用 JAVA_TOOL_OPTIONS 注入 -Xmx。 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-platform.jar"] dockerfile/scheduletask/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-scheduletask # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-scheduletask/jnpf-scheduletask-server/target/*.jar ./jnpf-scheduletask.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30009 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-scheduletask.jar"] dockerfile/system/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-system # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-system/jnpf-system-server/target/*.jar ./jnpf-system.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30002 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-system.jar"] dockerfile/visualdata/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-visualdata # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-visualdata/jnpf-visualdata-server/target/*.jar ./jnpf-visualdata.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30011 # 指定容器启动时要运行的命令 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-visualdata.jar"] dockerfile/visualdev/Dockerfile
New file @@ -0,0 +1,32 @@ # 基础镜像:本地默认 Docker Hub 多架构(arm64 原生,L024);交付构建注入 SWR amd64 单架构 # (make images-offline 传 --build-arg BASE_IMAGE=swr...,见 docker/delivery/) ARG BASE_IMAGE=bellsoft/liberica-openjre-rocky:21 FROM ${BASE_IMAGE} # FROM bellsoft/liberica-openjre-rocky:17 # FROM bellsoft/liberica-openjre-rocky:11 # FROM bellsoft/liberica-openjre-rocky:8 LABEL maintainer=cx-team # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 解决连接SQLServer安全错误 # 如您使用SQLServer数据库,把以下注释取消 # COPY security/java.security /opt/java/openjdk/lib/security # 指定运行时的工作目录 WORKDIR /data/jnpfsoft/javaApi/jnpf-visualdev # 将构建产物jar包拷贝到运行时目录中 COPY jnpf-visualdev/jnpf-visualdev-server/target/*.jar ./jnpf-visualdev.jar # 指定容器内运行端口 # 补丁 #8:共享配置随镜像(默认值形态);交付现场结构级覆盖 = 挂载 /config/shared COPY config/shared /config/shared ENV JNPF_CONFIG_DIR=/config EXPOSE 30003 # 启动服务 ENTRYPOINT ["/bin/sh","-c","java --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.invoke=ALL-UNNAMED --add-opens=java.base/java.math=ALL-UNNAMED --add-opens=java.base/sun.net.util=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.net=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.security=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/jdk.internal.module=ALL-UNNAMED --add-opens=java.base/sun.security.util=ALL-UNNAMED -Dfile.encoding=utf8 -Djava.security.egd=file:/dev/./urandom -jar jnpf-visualdev.jar"] jnpf-app/jnpf-app-api/pom.xml
New file @@ -0,0 +1,26 @@ <?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>jnpf-app</artifactId> <groupId>com.jnpf</groupId> <version>6.1.0-RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>jnpf-app-api</artifactId> <dependencies> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-app-entity</artifactId> <version>${project.version}</version> </dependency> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-common-feign</artifactId> </dependency> </dependencies> </project> jnpf-app/jnpf-app-api/src/main/java/jnpf/app/AppApi.java
New file @@ -0,0 +1,11 @@ package jnpf.app; import jnpf.app.fallback.AppApiFallback; import jnpf.utils.FeignName; import org.springframework.cloud.openfeign.FeignClient; @FeignClient(name = FeignName.APP_SERVER_NAME, fallback = AppApiFallback.class, path = "/Data") public interface AppApi { } jnpf-app/jnpf-app-api/src/main/java/jnpf/app/fallback/AppApiFallback.java
New file @@ -0,0 +1,20 @@ package jnpf.app.fallback; import jnpf.app.AppApi; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; /** * api接口 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司(https://www.jnpfsoft.com) * @date 2021/3/15 11:55 */ @Component @Slf4j public class AppApiFallback implements AppApi { } jnpf-app/jnpf-app-biz/pom.xml
New file @@ -0,0 +1,49 @@ <?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>jnpf-app</artifactId> <groupId>com.jnpf</groupId> <version>6.1.0-RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>jnpf-app-biz</artifactId> <dependencies> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-cloud-base</artifactId> <version>${project.version}</version> </dependency> <!-- data模块 --> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-common-database</artifactId> </dependency> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-app-entity</artifactId> <version>${project.version}</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-permission-api</artifactId> <version>${project.version}</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-system-api</artifactId> <version>${project.version}</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-common-file</artifactId> </dependency> </dependencies> </project> jnpf-app/jnpf-app-biz/src/main/java/jnpf/mapper/AppDataMapper.java
New file @@ -0,0 +1,60 @@ package jnpf.mapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import jnpf.base.UserInfo; import jnpf.base.mapper.SuperMapper; import jnpf.entity.AppDataEntity; import jnpf.util.RandomUtil; import jnpf.util.UserProvider; import java.util.Date; import java.util.List; /** * app常用数据 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-07-08 */ public interface AppDataMapper extends SuperMapper<AppDataEntity> { default List<AppDataEntity> getList() { QueryWrapper<AppDataEntity> queryWrapper = new QueryWrapper<>(); return this.selectList(queryWrapper); } default AppDataEntity getInfo(String objectId) { UserInfo userInfo = UserProvider.getUser(); QueryWrapper<AppDataEntity> queryWrapper = new QueryWrapper<>(); queryWrapper.lambda().eq(AppDataEntity::getObjectId, objectId).eq(AppDataEntity::getCreatorUserId, userInfo.getUserId()); return this.selectOne(queryWrapper); } default boolean isExistByObjectId(String objectId, String systemId) { UserInfo userInfo = UserProvider.getUser(); QueryWrapper<AppDataEntity> queryWrapper = new QueryWrapper<>(); queryWrapper.lambda().eq(AppDataEntity::getObjectId, objectId) .eq(AppDataEntity::getCreatorUserId, userInfo.getUserId()) .eq(AppDataEntity::getSystemId, systemId); return this.selectCount(queryWrapper) > 0; } default void create(AppDataEntity entity) { UserInfo userInfo = UserProvider.getUser(); entity.setId(RandomUtil.uuId()); entity.setCreatorUserId(userInfo.getUserId()); entity.setCreatorTime(new Date()); entity.setEnabledMark(1); entity.setSystemId(userInfo.getAppSystemId()); this.insert(entity); } default void delete(AppDataEntity entity) { this.deleteById(entity.getId()); } } jnpf-app/jnpf-app-biz/src/main/java/jnpf/service/AppDataService.java
New file @@ -0,0 +1,56 @@ package jnpf.service; import jnpf.base.service.SuperService; import jnpf.entity.AppDataEntity; import java.util.List; /** * app常用数据 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-08-08 */ public interface AppDataService extends SuperService<AppDataEntity> { /** * 列表 * * @return */ List<AppDataEntity> getList(); /** * 信息 * * @param objectId 对象主键 * @return */ AppDataEntity getInfo(String objectId); /** * 验证名称 * * @param objectId 对象主键 * @return */ boolean isExistByObjectId(String objectId, String systemId); /** * 创建 * * @param entity 实体对象 */ void create(AppDataEntity entity); /** * 删除 * * @param entity 实体对象 */ void delete(AppDataEntity entity); } jnpf-app/jnpf-app-biz/src/main/java/jnpf/service/AppService.java
New file @@ -0,0 +1,23 @@ package jnpf.service; import jnpf.model.AppUserInfoVO; import jnpf.model.AppUsersVO; /** * app用户信息 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-08-08 */ public interface AppService { /** * 通讯录 * @return */ AppUserInfoVO getInfo(String id); } jnpf-app/jnpf-app-biz/src/main/java/jnpf/service/impl/AppDataServiceImpl.java
New file @@ -0,0 +1,50 @@ package jnpf.service.impl; import jnpf.base.service.SuperServiceImpl; import jnpf.entity.AppDataEntity; import jnpf.mapper.AppDataMapper; import jnpf.service.AppDataService; import org.springframework.stereotype.Service; import java.util.List; /** * app常用数据 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-08-08 */ @Service public class AppDataServiceImpl extends SuperServiceImpl<AppDataMapper, AppDataEntity> implements AppDataService { @Override public List<AppDataEntity> getList() { return this.baseMapper.getList(); } @Override public AppDataEntity getInfo(String objectId) { return this.baseMapper.getInfo(objectId); } @Override public boolean isExistByObjectId(String objectId, String systemId) { return this.baseMapper.isExistByObjectId(objectId, systemId); } @Override public void create(AppDataEntity entity) { this.baseMapper.create(entity); } @Override public void delete(AppDataEntity entity) { this.baseMapper.delete(entity); } } jnpf-app/jnpf-app-biz/src/main/java/jnpf/service/impl/AppServiceImpl.java
New file @@ -0,0 +1,56 @@ package jnpf.service.impl; import jnpf.model.AppUserInfoVO; import jnpf.permission.OrganizeApi; import jnpf.permission.PositionApi; import jnpf.permission.UserApi; import jnpf.permission.entity.OrganizeEntity; import jnpf.permission.entity.UserEntity; import jnpf.service.AppService; import jnpf.util.JsonUtil; import jnpf.util.StringUtil; import jnpf.util.UploaderUtil; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; /** * app用户信息 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-08-08 */ @Service @RequiredArgsConstructor public class AppServiceImpl implements AppService { private final UserApi userApi; private final PositionApi positionApi; private final OrganizeApi organizeApi; @Override public AppUserInfoVO getInfo(String id) { AppUserInfoVO userInfoVO = new AppUserInfoVO(); UserEntity entity = userApi.getInfoById(id); if (entity != null) { userInfoVO = JsonUtil.getJsonToBean(entity, AppUserInfoVO.class); List<String> positionIds = StringUtil.isNotEmpty(entity.getPositionId()) ? Arrays.asList(entity.getPositionId().split(",")) : new ArrayList<>(); List<String> positionName = positionApi.getPositionName(positionIds, false).stream().map(t -> t.getFullName()).collect(Collectors.toList()); userInfoVO.setPositionName(String.join(",", positionName)); OrganizeEntity info = organizeApi.getInfoById(entity.getOrganizeId()); userInfoVO.setOrganizeName(info != null ? info.getFullName() : ""); userInfoVO.setHeadIcon(UploaderUtil.uploaderImg(userInfoVO.getHeadIcon())); } return userInfoVO; } } jnpf-app/jnpf-app-controller/pom.xml
New file @@ -0,0 +1,35 @@ <?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>jnpf-app</artifactId> <groupId>com.jnpf</groupId> <version>6.1.0-RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>jnpf-app-controller</artifactId> <dependencies> <!--导入aop--> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-common-springaop</artifactId> <version>${project.version}</version> </dependency> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-app-biz</artifactId> <version>${project.version}</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-app-api</artifactId> <version>${project.version}</version> <scope>compile</scope> </dependency> </dependencies> </project> jnpf-app/jnpf-app-controller/src/main/java/jnpf/controller/AppDataController.java
New file @@ -0,0 +1,72 @@ package jnpf.controller; import jnpf.base.controller.SuperController; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Operation; import jnpf.base.ActionResult; import jnpf.constant.MsgCode; import jnpf.entity.AppDataEntity; import jnpf.model.AppDataCrForm; import jnpf.service.AppDataService; import jnpf.util.JsonUtil; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; /** * app常用数据 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司(https://www.jnpfsoft.com) * @date 2021-07-08 */ @Tag(name = "app常用数据", description = "data") @RestController @RequestMapping("/api/app/Data") @RequiredArgsConstructor public class AppDataController extends SuperController<AppDataService, AppDataEntity> { private final AppDataService appDataService; /** * 新建 * * @param appDataCrForm 新建模型 * @return */ @PostMapping @Operation(summary = "新建") @Parameter(name = "appDataCrForm", description = "常用模型", required = true) public ActionResult<Object> create(@RequestBody @Valid AppDataCrForm appDataCrForm) { AppDataEntity entity = JsonUtil.getJsonToBean(appDataCrForm, AppDataEntity.class); if (appDataService.isExistByObjectId(entity.getObjectId(), appDataCrForm.getSystemId())) { return ActionResult.fail(MsgCode.FA036.get()); } appDataService.create(entity); return ActionResult.success(MsgCode.SU001.get()); } /** * 删除 * * @param objectId 主键 * @return */ @Operation(summary = "删除") @DeleteMapping("/{objectId}") @Parameter(name = "objectId", description = "主键", required = true) public ActionResult<Object> create(@PathVariable("objectId") String objectId) { AppDataEntity entity = appDataService.getInfo(objectId); if (entity != null) { appDataService.delete(entity); return ActionResult.success(MsgCode.SU003.get()); } return ActionResult.fail(MsgCode.FA003.get()); } } jnpf-app/jnpf-app-controller/src/main/java/jnpf/controller/AppMenuController.java
New file @@ -0,0 +1,131 @@ package jnpf.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jnpf.base.ActionResult; import jnpf.base.Page; import jnpf.base.SystemApi; import jnpf.base.UserInfo; import jnpf.base.model.base.SystemBaeModel; import jnpf.base.model.module.ModuleModel; import jnpf.base.vo.ListVO; import jnpf.constant.JnpfConst; import jnpf.model.AppMenuListVO; import jnpf.model.UserMenuModel; import jnpf.model.login.UserSystemVO; import jnpf.permission.AuthorizeApi; import jnpf.permission.model.authorize.AuthorizeVO; import jnpf.util.JsonUtil; import jnpf.util.StringUtil; import jnpf.util.UserProvider; import jnpf.util.context.RequestContext; import jnpf.util.treeutil.ListToTreeUtil; import jnpf.util.treeutil.SumTree; import jnpf.util.treeutil.newtreeutil.TreeDotUtils; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.*; import java.util.stream.Collectors; /** * app应用 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司(https://www.jnpfsoft.com) * @date 2021-07-08 */ @Tag(name = "app应用", description = "Menu") @RestController @RequestMapping("/Menu") @RequiredArgsConstructor public class AppMenuController { private final AuthorizeApi authorizeService; /** * 获取菜单列表 * * @param page 分页模型 * @return */ @Operation(summary = "获取菜单列表") @GetMapping public ActionResult<ListVO<AppMenuListVO>> list(Page page) { AuthorizeVO authorizeModel = authorizeService.getAuthorize(false, RequestContext.getAppCode(), 0); List<ModuleModel> buttonListAll = authorizeModel.getModuleList().stream().filter(t -> JnpfConst.APP.equals(t.getCategory()) && !Objects.equals(t.getNoShow(), 1) && !JnpfConst.MODULE_CODE.contains(t.getEnCode())).collect(Collectors.toList()); // 通过系统id捞取相应的菜单 buttonListAll = buttonListAll.stream().filter(t -> UserProvider.getUser().getAppSystemId() != null && UserProvider.getUser().getAppSystemId().equals(t.getSystemId())).collect(Collectors.toList()); List<ModuleModel> buttonList = buttonListAll; if (StringUtil.isNotEmpty(page.getKeyword())) { buttonList = buttonListAll.stream().filter(t -> t.getFullName().contains(page.getKeyword())).collect(Collectors.toList()); } List<UserMenuModel> list = JsonUtil.getJsonToList(ListToTreeUtil.treeWhere(buttonList, buttonListAll), UserMenuModel.class); List<SumTree<UserMenuModel>> menuAll = TreeDotUtils.convertListToTreeDot(list, "-1"); List<AppMenuListVO> data = JsonUtil.getJsonToList(menuAll, AppMenuListVO.class); ListVO<AppMenuListVO> listVO = new ListVO<>(); listVO.setList(data); return ActionResult.success(listVO); } /** * 获取子集菜单 * * @return */ @Operation(summary = "获取子集菜单") @GetMapping("/getChildList/{id}") public ActionResult<List<AppMenuListVO>> getChildList(@PathVariable("id") String id) { AuthorizeVO authorizeModel = authorizeService.getAuthorize(false, RequestContext.getAppCode(), 0); List<ModuleModel> buttonListAll = authorizeModel.getModuleList().stream().filter(t -> JnpfConst.APP.equals(t.getCategory())).collect(Collectors.toList()); // 通过系统id捞取相应的菜单 buttonListAll = buttonListAll.stream().filter(t -> UserProvider.getUser().getAppSystemId() != null && UserProvider.getUser().getAppSystemId().equals(t.getSystemId())).collect(Collectors.toList()); Set<ModuleModel> models = new HashSet<>(); next(buttonListAll, id, models); List<UserMenuModel> list = JsonUtil.getJsonToList(models, UserMenuModel.class); List<SumTree<UserMenuModel>> menuAll = TreeDotUtils.convertListToTreeDot(list); List<AppMenuListVO> data = JsonUtil.getJsonToList(menuAll, AppMenuListVO.class); return ActionResult.success(data); } private void next(List<ModuleModel> buttonListAll, String parentId, Set<ModuleModel> list) { List<ModuleModel> menuList = buttonListAll.stream().filter(t -> t.getId().equals(parentId) || t.getParentId().equals(parentId)).collect(Collectors.toList()); for (ModuleModel model : menuList) { if (!list.contains(model)) { list.add(model); next(buttonListAll, model.getId(), list); } } } @Operation(summary = "获取应用列表") @GetMapping("/sys") public ActionResult<List<UserSystemVO>> listSys() { AuthorizeVO authorizeModel = authorizeService.getAuthorizeByUser(false); List<SystemBaeModel> systemList = authorizeModel.getSystemList(); UserInfo userInfo = UserProvider.getUser(); List<UserSystemVO> jsonToList1 = new ArrayList<>(); systemList.forEach(t -> { UserSystemVO systemVO = new UserSystemVO(); systemVO.setId(t.getId()); systemVO.setName(t.getFullName()); systemVO.setIcon(t.getIcon()); if (userInfo.getAppSystemId().equals(t.getId())) { systemVO.setCurrentSystem(true); } systemVO.setEnCode(t.getEnCode()); jsonToList1.add(systemVO); }); return ActionResult.success(jsonToList1); } } jnpf-app/jnpf-app-controller/src/main/java/jnpf/controller/AppUserController.java
New file @@ -0,0 +1,43 @@ package jnpf.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jnpf.base.ActionResult; import jnpf.model.AppUserInfoVO; import jnpf.service.AppService; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * 用户信息 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司(https://www.jnpfsoft.com) * @date 2021-07-08 */ @Tag(name = "app用户信息", description = "User") @RestController @RequestMapping("/api/app/User") @RequiredArgsConstructor(onConstructor_ = {@Autowired}) public class AppUserController { private final AppService appService; @Operation(summary = "通讯录详情") @GetMapping("/{id}") @Parameter(name = "id", description = "主键", required = true) public ActionResult<AppUserInfoVO> userInfo(@PathVariable("id") String id) { AppUserInfoVO userInfoVO = appService.getInfo(id); return ActionResult.success(userInfoVO); } } jnpf-app/jnpf-app-controller/src/main/java/jnpf/controller/AppVersionController.java
New file @@ -0,0 +1,47 @@ package jnpf.controller; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; import jnpf.base.ActionResult; import jnpf.config.ConfigValueUtil; import jnpf.util.NoDataSourceBind; import lombok.RequiredArgsConstructor; import org.apache.commons.collections4.map.HashedMap; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; /** * 获取AppVersion * * @author :JNPF开发平台组 * @version: V3.1.0 * @copyright 引迈信息技术有限公司 * @date :2022/3/31 11:26 */ @Tag(name = "获取APP版本号", description = "AppVersion") @RestController @RequestMapping("/api/app") @RequiredArgsConstructor public class AppVersionController { private final ConfigValueUtil configValueUtil; /** * 判断是否需要验证码 * * @return */ @NoDataSourceBind() @Operation(summary = "判断是否需要验证码") @GetMapping("/Version") public ActionResult<Map<String, String>> getAppVersion() { Map<String, String> map = new HashedMap<>(); map.put("sysVersion", configValueUtil.getAppVersion()); return ActionResult.success(map); } } jnpf-app/jnpf-app-entity/pom.xml
New file @@ -0,0 +1,21 @@ <?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>jnpf-app</artifactId> <groupId>com.jnpf</groupId> <version>6.1.0-RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>jnpf-app-entity</artifactId> <dependencies> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-common-core</artifactId> </dependency> </dependencies> </project> jnpf-app/jnpf-app-entity/src/main/java/jnpf/entity/AppDataEntity.java
New file @@ -0,0 +1,44 @@ package jnpf.entity; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import jnpf.base.entity.SuperExtendEntity; import lombok.Data; /** * app常用数据 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-08-08 */ @Data @TableName("base_app_data") public class AppDataEntity extends SuperExtendEntity.SuperExtendDEEntity<String> { /** * 对象类型 */ @TableField("f_object_type") private String objectType; /** * 对象主键 */ @TableField("f_object_id") private String objectId; /** * 数据 */ @TableField("f_object_data") private String objectData; /** * 关联系统id */ @TableField("f_system_id") private String systemId; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppDataCrForm.java
New file @@ -0,0 +1,30 @@ package jnpf.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import jakarta.validation.constraints.NotBlank; /** * app常用数据 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-07-08 */ @Data @Schema(description="常用模型") public class AppDataCrForm { @NotBlank(message = "必填") @Schema(description ="应用类型") private String objectType; @NotBlank(message = "必填") @Schema(description ="应用主键") private String objectId; @Schema(description ="数据") private String objectData; @Schema(description ="系统主键") private String systemId; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppDataListAllVO.java
New file @@ -0,0 +1,40 @@ package jnpf.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; /** * app常用数据 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-07-08 */ @Data @Schema(description="常用模型") public class AppDataListAllVO { @Schema(description ="主键") private String id; @Schema(description ="是否有下级菜单") private Boolean hasChildren; @Schema(description ="菜单名称") private String fullName; @Schema(description =" 图标") private String icon; @Schema(description ="链接地址") private String urlAddress; @Schema(description ="父级id") private String parentId; @Schema(description ="菜单类型",example = "1") private Integer type; @Schema(description ="扩展字段") private String propertyJson; @Schema(description ="是否常用") private Boolean isData; @Schema(description ="下级菜单列表") private List<AppDataListAllVO> children; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppDataListVO.java
New file @@ -0,0 +1,24 @@ package jnpf.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; /** * app常用数据 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-07-08 */ @Data @Schema(description="常用模型") public class AppDataListVO { @Schema(description ="主键") private String id; @Schema(description ="应用主键") private String objectId; @Schema(description ="数据") private String objectData; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppFlowListAllVO.java
New file @@ -0,0 +1,30 @@ package jnpf.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; /** * app常用数据 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-07-08 */ @Data @Schema(description="常用模型") public class AppFlowListAllVO { @Schema(description ="主键") private String id; @Schema(description ="名称") private String fullName; @Schema(description ="图标") private String icon; @Schema(description ="图标背景色") private String iconBackground; @Schema(description ="编码") private String enCode; @Schema(description ="是否常用") private Boolean isData; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppMenuListVO.java
New file @@ -0,0 +1,44 @@ package jnpf.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; /** * app应用 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-07-08 */ @Data @Schema(description="常用模型") public class AppMenuListVO { @Schema(description ="主键") private String id; @Schema(description ="是否有下级菜单") private Boolean hasChildren; @Schema(description ="父级id") private String parentId; @Schema(description = "菜单编码") private String enCode; @Schema(description ="菜单名称") private String fullName; @Schema(description =" 图标") private String icon; @Schema(description ="是否常用") private Boolean isData; @Schema(description ="链接地址") private String urlAddress; @Schema(description = "页面地址") private String pageAddress; @Schema(description ="菜单类型",example = "1") private Integer type; @Schema(description ="扩展字段") private String propertyJson; @Schema(description ="下级菜单列表") private List<AppMenuListVO> children; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppObjectDataModel.java
New file @@ -0,0 +1,20 @@ package jnpf.model; import lombok.Data; @Data public class AppObjectDataModel { private String id; private boolean hasChildren; private String fullName; private String icon; private String urlAddress; private String parentId; private Integer type; private String propertyJson; private boolean isData; private String children; private String iconBackground; private String moduleId; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppPositionVO.java
New file @@ -0,0 +1,22 @@ package jnpf.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; /** * app应用 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-08-08 */ @Data @Schema(description="常用模型") public class AppPositionVO { @Schema(description ="岗位id") private String id; @Schema(description ="岗位名称") private String name; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppTreeModel.java
New file @@ -0,0 +1,28 @@ package jnpf.model; import jnpf.util.treeutil.SumTree; import lombok.Data; /** * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 */ @Data public class AppTreeModel extends SumTree<AppTreeModel> { private String enCode; private Long num; private String fullName; private String formType; private String type; private String icon; private String category; private String iconBackground; private String visibleType; private String creatorUser; private Long creatorTime; private Long sortCode; private Integer enabledMark; private Boolean isData; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppUserInfoVO.java
New file @@ -0,0 +1,37 @@ package jnpf.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; /** * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021/3/12 15:31 */ @Data @Schema(description="常用模型") public class AppUserInfoVO { @Schema(description ="主键") private String id; @Schema(description ="户名") private String realName; @Schema(description ="部门名称") private String organizeName; @Schema(description ="账号") private String account; @Schema(description ="岗位名称") private String positionName; @Schema(description ="办公电话") private String telePhone; @Schema(description ="办公座机") private String landline; @Schema(description ="手机号码") private String mobilePhone; @Schema(description ="用户头像") private String headIcon; @Schema(description ="邮箱") private String email; } jnpf-app/jnpf-app-entity/src/main/java/jnpf/model/AppUsersVO.java
New file @@ -0,0 +1,50 @@ package jnpf.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; /** * 用户 * * @author JNPF开发平台组 * @version V3.1.0 * @copyright 引迈信息技术有限公司 * @date 2021-07-08 */ @Data @Schema(description="常用模型") public class AppUsersVO { @Schema(description ="用户id") private String userId; @Schema(description ="用户账号") private String userAccount; @Schema(description ="用户姓名") private String userName; @Schema(description ="用户头像") private String headIcon; @Schema(description ="组织主键") private String organizeId; @Schema(description ="组织名称") private String organizeName; @Schema(description ="角色主键") private String roleId; @Schema(description ="角色名称") private String roleName; @Schema(description ="性别") private String gender; @Schema(description ="岗位") private List<AppPositionVO> positionIds; @Schema(description ="生日") private Long birthday; @Schema(description ="手机") private String mobilePhone; @Schema(description ="邮箱") private String email; @Schema(description ="直属主管") private String manager; } jnpf-app/jnpf-app-server/pom.xml
New file @@ -0,0 +1,100 @@ <?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>jnpf-app</artifactId> <groupId>com.jnpf</groupId> <version>6.1.0-RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>jnpf-app-server</artifactId> <dependencies> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-app-controller</artifactId> <version>${project.version}</version> <scope>compile</scope> </dependency> </dependencies> <profiles> <profile> <id>default-package</id> <activation> <jdk>[,]</jdk> </activation> <build> <finalName>jnpf-app-${project.version}</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <!-- 指定该Main Class为全局的唯一入口 --> <mainClass>jnpf.JnpfApplication</mainClass> <layout>ZIP</layout> </configuration> <executions> <execution> <goals> <goal>repackage</goal><!--可以把依赖的包都打包到生成的Jar包中--> </goals> </execution> </executions> </plugin> </plugins> </build> </profile> <profile> <id>external-package</id> <build> <finalName>jnpf-app-${project.version}</finalName> <plugins> <!-- 复制依赖到外部目录 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-dependency-plugin</artifactId> <executions> <execution> <id>copy-dependencies</id> <phase>package</phase> <goals> <goal>copy-dependencies</goal> </goals> <configuration> <outputDirectory>${external-lib-out-path}</outputDirectory> <overWriteReleases>true</overWriteReleases> <overWriteSnapshots>true</overWriteSnapshots> <overWriteIfNewer>true</overWriteIfNewer> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifest> <!--启动类path--> <mainClass>jnpf.JnpfApplication</mainClass> <addClasspath>true</addClasspath> <classpathPrefix>${external-lib-start-path}</classpathPrefix> </manifest> <manifestEntries> <!--如果不指定,classpath 可能会找不到,加载资源有问题--> <Class-Path>.</Class-Path> </manifestEntries> </archive> </configuration> </plugin> </plugins> </build> </profile> </profiles> </project> jnpf-app/jnpf-app-server/src/main/java/jnpf/JnpfApplication.java
New file @@ -0,0 +1,16 @@ package jnpf; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; @Slf4j @SpringBootApplication @EnableFeignClients public class JnpfApplication { public static void main(String[] args) { SpringApplication.run(JnpfApplication.class, args); JnpfApplication.log.info("app启动成功"); } } jnpf-app/jnpf-app-server/src/main/resources/application.yml
New file @@ -0,0 +1,44 @@ # 应用服务器 server: tomcat: uri-encoding: UTF-8 port: 30012 # Spring spring: application: name: jnpf-app mvc: servlet: load-on-startup: 0 main: allow-bean-definition-overriding: true allow-circular-references: true config: # 补丁 #8:配置来源 = 仓库 config/(镜像内 /config,经 JNPF_CONFIG_DIR 指向)。 # file:(非 optional)= 缺文件启动即失败,防静默半配置;host-override 是宿主栈 # 专用覆盖(容器内不存在,optional 落空跳过)。 import: - file:${JNPF_CONFIG_DIR:config}/shared/datasource.yaml - file:${JNPF_CONFIG_DIR:config}/shared/resources.yaml - file:${JNPF_CONFIG_DIR:config}/shared/system-config.yaml - file:${JNPF_CONFIG_DIR:config}/shared/frame-config.yaml - file:${JNPF_CONFIG_DIR:config}/shared/logger.yaml - file:${JNPF_CONFIG_DIR:config}/shared/mq.yaml - file:${JNPF_CONFIG_DIR:config}/shared/discovery.yaml - optional:file:${JNPF_CONFIG_DIR:config}/host-override/discovery.yaml profiles: # 环境配置 active: test management: endpoints: web: exposure: include: '*' endpoint: health: show-details: always # 开启在线日志查看功能 logfile: enabled: true jnpf-app/jnpf-app-server/src/main/resources/logback-spring.xml
New file @@ -0,0 +1,335 @@ <?xml version="1.0" encoding="UTF-8"?> <configuration> <!--日志格式应用spring boot默认的格式,也可以自己更改--> <include resource="org/springframework/boot/logging/logback/defaults.xml"/> <property name="FILE_LOG_PATTERN" value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5level] [%thread] [%logger{50}] [%M] [%line] - %msg%n" /> <springProperty scope="context" name="SERVICE_NAME" source="spring.application.name" defaultValue="jnpf"/> <!--定义日志存放的位置,默认存放在项目启动的相对路径的目录--> <springProperty scope="context" name="LOG_PATH" source="log.path" defaultValue="log/${SERVICE_NAME}"/> <!-- 全局日志等级 --> <springProperty scope="context" name="LOG_LEVEL_ROOT" source="log.level.root" defaultValue="INFO"/> <!-- 服务自定义等级 如需自定义服务日志等级 修改下方的【自定义服务名】与nacos上的log.level.自定义服务名=等级 --> <springProperty scope="context" name="LOG_LEVEL" source="log.level.jnpf-app" defaultValue="${LOG_LEVEL_ROOT}"/> <!-- 日志记录器,日期滚动记录,level为 ERROR 日志 --> <appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 正在记录的日志文件的路径及文件名 --> <file>${LOG_PATH}/log_error.log</file> <!-- 日志记录器的滚动策略,按日期,按大小记录 --> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 归档的日志文件的路径,%d{yyyy-MM-dd}指定日期格式,%i指定索引 --> <fileNamePattern>${LOG_PATH}/error/%d{yyyy-MM-dd,aux}/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern> <!--日志最大的历史7天--> <maxHistory>7</maxHistory> <!-- 除按日志记录之外,还配置了日志文件不能超过2M,若超过2M,日志文件会以索引0开始, 命名日志文件,例如log-error-2013-12-21.0.log --> <maxFileSize>10MB</maxFileSize> </rollingPolicy> <!-- 追加方式记录日志 --> <append>true</append> <!-- 日志文件的格式 --> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>${FILE_LOG_PATTERN}</pattern> <charset>utf-8</charset> </encoder> <!-- 此日志文件只记录error级别的 --> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>error</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> </appender> <!-- 日志记录器,日期滚动记录,level为 INFO 日志 --> <appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 正在记录的日志文件的路径及文件名 --> <file>${LOG_PATH}/log_info.log</file> <!-- 日志记录器的滚动策略,按日期,按大小记录 --> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 归档的日志文件的路径,%d{yyyy-MM-dd}指定日期格式,%i指定索引 --> <fileNamePattern>${LOG_PATH}/info/%d{yyyy-MM-dd,aux}/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern> <!--日志最大的历史7天--> <maxHistory>7</maxHistory> <!-- 除按日志记录之外,还配置了日志文件不能超过2M,若超过2M,日志文件会以索引0开始, 命名日志文件,例如log-error-2013-12-21.0.log --> <maxFileSize>10MB</maxFileSize> </rollingPolicy> <!-- 追加方式记录日志 --> <append>true</append> <!-- 日志文件的格式 --> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>${FILE_LOG_PATTERN}</pattern> <charset>utf-8</charset> </encoder> <!-- 此日志文件只记录info级别的 --> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>info</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> </appender> <!-- 日志记录器,日期滚动记录,level为 WARN 日志 --> <appender name="FILE_WARN" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 正在记录的日志文件的路径及文件名 --> <file>${LOG_PATH}/log_warn.log</file> <!-- 日志记录器的滚动策略,按日期,按大小记录 --> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 归档的日志文件的路径,%d{yyyy-MM-dd}指定日期格式,%i指定索引 --> <fileNamePattern>${LOG_PATH}/warn/%d{yyyy-MM-dd,aux}/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern> <!--日志最大的历史7天--> <maxHistory>7</maxHistory> <!-- 除按日志记录之外,还配置了日志文件不能超过2M,若超过2M,日志文件会以索引0开始, 命名日志文件,例如log-error-2013-12-21.0.log --> <maxFileSize>10MB</maxFileSize> </rollingPolicy> <!-- 追加方式记录日志 --> <append>true</append> <!-- 日志文件的格式 --> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>${FILE_LOG_PATTERN}</pattern> <charset>utf-8</charset> </encoder> <!-- 此日志文件只记录warn级别的 --> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>warn</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> </appender> <!-- 日志记录器,日期滚动记录,level为 DEBUG 日志 --> <appender name="FILE_DEBUG" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 正在记录的日志文件的路径及文件名 --> <file>${LOG_PATH}/log_debug.log</file> <!-- 日志记录器的滚动策略,按日期,按大小记录 --> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 归档的日志文件的路径,%d{yyyy-MM-dd}指定日期格式,%i指定索引 --> <fileNamePattern>${LOG_PATH}/debug/%d{yyyy-MM-dd,aux}/log-debug-%d{yyyy-MM-dd}.%i.log</fileNamePattern> <!--日志最大的历史7天--> <maxHistory>7</maxHistory> <!-- 除按日志记录之外,还配置了日志文件不能超过2M,若超过2M,日志文件会以索引0开始, 命名日志文件,例如log-error-2013-12-21.0.log --> <maxFileSize>10MB</maxFileSize> </rollingPolicy> <!-- 追加方式记录日志 --> <append>true</append> <!-- 日志文件的格式 --> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>${FILE_LOG_PATTERN}</pattern> <charset>utf-8</charset> </encoder> <!-- 此日志文件只记录debug级别的 --> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>debug</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> </appender> <!-- 日志记录器,日期滚动记录,所有日志 --> <appender name="FILE_ALL" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 正在记录的日志文件的路径及文件名 --> <file>${LOG_PATH}/log_total.log</file> <!-- 日志记录器的滚动策略,按日期,按大小记录 --> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 归档的日志文件的路径,%d{yyyy-MM-dd}指定日期格式,%i指定索引 --> <fileNamePattern>${LOG_PATH}/total/%d{yyyy-MM-dd,aux}/log-total-%d{yyyy-MM-dd}.%i.log</fileNamePattern> <!--日志最大的历史7天--> <maxHistory>7</maxHistory> <!-- 除按日志记录之外,还配置了日志文件不能超过2M,若超过2M,日志文件会以索引0开始, 命名日志文件,例如log-error-2013-12-21.0.log --> <maxFileSize>10MB</maxFileSize> </rollingPolicy> <!-- 追加方式记录日志 --> <append>true</append> <!-- 日志文件的格式 --> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>${FILE_LOG_PATTERN}</pattern> <charset>utf-8</charset> </encoder> </appender> <!-- 日志记录器,日期滚动记录,level 根据配置动态输出日志 --> <appender name="FILE_RELEASE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <!-- 正在记录的日志文件的路径及文件名 --> <file>${LOG_PATH}/log_release.log</file> <!-- 日志记录器的滚动策略,按日期,按大小记录 --> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <!-- 归档的日志文件的路径,%d{yyyy-MM-dd}指定日期格式,%i指定索引 --> <fileNamePattern>${LOG_PATH}/release/%d{yyyy-MM-dd,aux}/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern> <!--日志最大的历史7天--> <maxHistory>7</maxHistory> <!-- 除按日志记录之外,还配置了日志文件不能超过2M,若超过2M,日志文件会以索引0开始, 命名日志文件,例如log-error-2013-12-21.0.log --> <maxFileSize>10MB</maxFileSize> </rollingPolicy> <!-- 追加方式记录日志 --> <append>true</append> <!-- 日志文件的格式 --> <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"> <pattern>${FILE_LOG_PATTERN}</pattern> <charset>utf-8</charset> </encoder> <!-- 此日志文件只记录warn级别的 --> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>${LOG_LEVEL}</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> </appender> <!-- 异步输出 DEBUG --> <appender name="ASYNC_FILE_DEBUG" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <queueSize>256</queueSize> <appender-ref ref="FILE_DEBUG"/> </appender> <!-- 异步输出 INFO --> <appender name="ASYNC_FILE_INFO" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <queueSize>256</queueSize> <appender-ref ref="FILE_INFO"/> </appender> <!-- 异步输出 WARN --> <appender name="ASYNC_FILE_WARN" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <queueSize>256</queueSize> <appender-ref ref="FILE_WARN"/> </appender> <!-- 异步输出 ERROR --> <appender name="ASYNC_FILE_ERROR" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <queueSize>256</queueSize> <appender-ref ref="FILE_ERROR"/> </appender> <!-- 异步输出 ALL --> <appender name="ASYNC_FILE_ALL" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <queueSize>256</queueSize> <appender-ref ref="FILE_ALL"/> </appender> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>${CONSOLE_LOG_PATTERN}</pattern> <charset>utf-8</charset> </encoder> </appender> <!-- 异步输出 控制台 --> <appender name="ASYNC_STDOUT" class="ch.qos.logback.classic.AsyncAppender"> <discardingThreshold>0</discardingThreshold> <queueSize>256</queueSize> <appender-ref ref="STDOUT"/> </appender> <!--<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender"> <!– 必填:目标:LogStash的 IP:Port –> <destination>192.168.0.50:50000</destination> <!– 可选:保持程序存活时间 –> <keepAliveDuration>5 minutes</keepAliveDuration> <!– 可选:重连延迟时长 –> <reconnectionDelay>10 second</reconnectionDelay> <!– 可选:等待策略 –> <waitStrategyType>sleeping</waitStrategyType> <!– ============ encoder必须配置,有多种可选 ============= –> <!– 编码器二:LoggingEventCompositeJsonEncoder –> <encoder charset="UTF-8" class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder"> <providers> <!– 时间戳:时区 –> <timestamp> <timeZone>UTC</timeZone> </timestamp> <!– 模式 –> <pattern> <pattern> { "severity": "%level", "service": "${SERVICE_NAME:-}", "trace": "%X{X-B3-TraceId:-}", "span": "%X{X-B3-SpanId:-}", "exportable": "%X{X-Span-Export:-}", "pid": "${PID:-}", "thread": "%thread", "class": "%logger{40}", "msg": "%message" <!–"idx_pre": "elk-original-third-access",–> <!–"json": "#asJson{%message}" 这个asJson可以把对应的字符串作为json对象取出来,这样es可以对json里面的字段索引了–> } </pattern> </pattern> </providers> </encoder> </appender>--> <!--<appender name="skywalking-log" class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender"> <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.mdc.TraceIdMDCPatternLogbackLayout"> <Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{tid}] [%thread] %-5level %logger{36} -%msg%n</Pattern> </layout> </encoder> </appender>--> <root level="${LOG_LEVEL}"> <appender-ref ref="ASYNC_STDOUT"/> <appender-ref ref="ASYNC_FILE_ERROR"/> <appender-ref ref="ASYNC_FILE_INFO"/> <appender-ref ref="ASYNC_FILE_WARN"/> <appender-ref ref="ASYNC_FILE_DEBUG"/> <appender-ref ref="ASYNC_FILE_ALL"/> <!--<appender-ref ref="LOGSTASH"/>--> <!--<appender-ref ref="skywalking-log"/>--> </root> </configuration> jnpf-app/pom.xml
New file @@ -0,0 +1,40 @@ <?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>jnpf-java-cloud</artifactId> <groupId>com.jnpf</groupId> <version>6.1.0-RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>jnpf-app</artifactId> <packaging>pom</packaging> <modules> <module>jnpf-app-entity</module> <module>jnpf-app-controller</module> <module>jnpf-app-biz</module> <module>jnpf-app-api</module> </modules> <dependencies> </dependencies> <!-- 平台聚合(2026-08-11):本模块的 *-server 启动壳只在微服务形态下才需要构建。 默认(monolith)九个平台模块由 jnpf-platform 聚合进一个 JVM,各自的 fat jar 不再产出 ——省掉 9 次 repackage(每个约 198MB)。 **源码刻意保留不删**:回滚 = mvn -P microservice 重新打出 jar + 把 discovery.yaml 的服务名改回各自端口,不需要 git revert。 --> <profiles> <profile> <id>microservice</id> <modules> <module>jnpf-app-server</module> </modules> </profile> </profiles> </project> jnpf-biz-common/jnpf-audit-api/pom.xml
New file @@ -0,0 +1,36 @@ <?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>jnpf-biz-common</artifactId> <groupId>com.jnpf</groupId> <version>6.1.0-RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <!-- 审计对外契约模块(2026-08-11 随 audit 搬入 jnpf-biz-common,artifactId 与包名 jnpf.audit.* **刻意保持不变**:jnpf-lims-biz 的 pom 坐标、60 个 @FeignClient 调用方、42 个回归脚本 全部零改动)。 本模块的准入判据 = **业务进程(SDK 宿主)也要用到的东西**,三类: · AuditEventApi / AuditEventDTO / fallback —— Feign 契约本身 · AuditConsts —— event_type 与 action_code 枚举,lims 埋点直接引用 · AuditFieldDiff —— lims 的 @AuditLog SpEL 里以 T(...).MASKED 形式硬编码引用, **运行时求值**,类不在 classpath 上不会编译报错、只会在切面执行时炸。 服务端才用的实体/VO/Mapper 一律留在 jnpf-biz-common-audit,别往这里加。 --> <artifactId>jnpf-audit-api</artifactId> <dependencies> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-common-feign</artifactId> </dependency> <!-- AuditFieldDiff 用到 jnpf.util.JsonUtil;原先经 jnpf-audit-entity 传递进来, 该模块已合并进 jnpf-biz-common-audit(服务端),故在此显式声明,避免反向依赖服务端。 --> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-common-core</artifactId> </dependency> </dependencies> </project> jnpf-biz-common/jnpf-audit-api/src/main/java/jnpf/audit/AuditApiConsts.java
New file @@ -0,0 +1,30 @@ package jnpf.audit; /** * audit-api 模块常量:跨服务共享的 MDC key 等。Task 7 SDK 写入/读取时引用同一常量, * 避免调用方与 SDK 各自硬编码字符串导致漂移。 */ public final class AuditApiConsts { /** MDC key:请求级 operationId,用于把同一次操作触发的多条审计事件关联起来。 */ public static final String MDC_OPERATION_ID = "audit.operationId"; /** 自定义业务请求携带的签名凭证,仅用于关联同一次前端操作产生的审计事件。 */ public static final String HEADER_BIZ_SIGN = "X-Audit-Biz-Sign"; /** * 租户哨兵值:采集侧取不到租户时的缺省值(单租户部署 / 无会话的系统链路)。 * * <p>取 {@code "0"} 的依据:本平台单租户部署下 {@code base_user.f_tenant_id} 即为 {@code "0"}, * 而登录会话的 {@code UserProvider.getUser().getTenantId()} 返回**空串**(实测:lims_biz_log 全部 * 存量行 tenant_id 均为空串)。lims 既有代码(LimsSxtJihuaTaskServiceImpl / LimsSxtKanbanServiceImpl / * LimsSxtQushiServiceImpl 等)一律按 {@code !hasText ? "0"} 回落,本常量与之对齐。 * * <p>语义边界:**仅作为空白时的 fallback**,多租户(COLUMN 模式)部署下会话有真实租户号, * 不会触发回落;服务端 {@code validate} 的「tenantId 不能为空」保持严格,用于拦住绕过 SDK 的投递 * (Feign 直调、DLQ/spool 人工补录)。 */ public static final String DEFAULT_TENANT_ID = "0"; private AuditApiConsts() { } } jnpf-biz-common/jnpf-audit-api/src/main/java/jnpf/audit/AuditConsts.java
New file @@ -0,0 +1,46 @@ package jnpf.audit; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Set; /** * 审计事件常量:event_type 受控枚举 + action_code SDK 内置常量。 */ public final class AuditConsts { /** event_type 受控枚举(spec v2.2 枚举分层治理:新增须经 audit 模块评审) */ public static final String TYPE_DATA_CHANGE = "DATA_CHANGE"; public static final String TYPE_BIZ_ACTION = "BIZ_ACTION"; public static final String TYPE_E_SIGNATURE = "E_SIGNATURE"; // JDK 8 兼容写法(仓库有效 maven.compiler.source=1.8,禁用 Set.of 等 Java 9+ API) public static final Set<String> VALID_TYPES = Collections.unmodifiableSet( new HashSet<>(Arrays.asList(TYPE_DATA_CHANGE, TYPE_BIZ_ACTION, TYPE_E_SIGNATURE))); /** * action_code SDK 内置常量:**只内置最基础的三种数据动作**(开放命名空间,业务仍可自定义大写蛇形)。 * * <p>业务动作变体(禁用/启用/审批/驳回/归档/发布…)一律<b>不再新增常量</b>——它们本质都是 * 这三种之一,按业务语义枚举下去永远加不完。正确做法是用最接近的基础动作打底, * 把"具体是哪一种"交给细化字段承载: * <pre> * 禁用用户 → actionCode = UPDATE * actionLabel = "禁用用户" (人读语义) * fieldDiffs = enabled_mark: 1 → 0 (字段级 before-after,机器可判) * reason / extra (需要原因或额外结构化信息时) * </pre> * 这样既能按 UPDATE 统一筛选统计,又比多一个枚举值携带更多信息。 */ public static final String ACT_CREATE = "CREATE"; public static final String ACT_READ = "READ"; public static final String ACT_UPDATE = "UPDATE"; public static final String ACT_DELETE = "DELETE"; /** 复核(在线表单「复核」按钮,由 lims_sign.meta_data.is_review_button 判定)。 */ public static final String ACT_REVIEW = "REVIEW"; /** * 例外:CORRECTION 不是业务动作,而是审计域自身的元操作——更正一条已写错的审计记录 * (append-only 下靠追加新事件实现,契约 {@code extra.correctsEventId} = 被更正事件 id,spec §4.3)。 * 它必须能与"业务数据被修改"区分开,降级成 UPDATE 会丢掉这个区分,故保留。 */ public static final String ACT_CORRECTION = "CORRECTION"; private AuditConsts() {} } jnpf-biz-common/jnpf-audit-api/src/main/java/jnpf/audit/AuditDeliveryException.java
New file @@ -0,0 +1,14 @@ package jnpf.audit; /** * 审计事件投递失败异常:Feign 调用 jnpf-audit 失败(网络/超时/服务不可用等)时抛出。 * * <p>L009 教训:fallbackFactory 若静默吞掉异常只记日志,调用方会误判"投递成功", * 审计链路出现无声丢事件。本异常必须由 {@link jnpf.audit.fallback.AuditEventApiFallbackFactory} * 重新抛出,让 SDK 降级链(Task 7)感知失败并触发本地兜底(如落盘补偿)。 */ public class AuditDeliveryException extends RuntimeException { public AuditDeliveryException(String message, Throwable cause) { super(message, cause); } } jnpf-biz-common/jnpf-audit-api/src/main/java/jnpf/audit/AuditEventApi.java
New file @@ -0,0 +1,31 @@ package jnpf.audit; import jnpf.audit.fallback.AuditEventApiFallbackFactory; import jnpf.audit.model.AuditEventDTO; import jnpf.base.ActionResult; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; /** * 审计事件投递 Feign 接口:业务服务通过它把审计事件同步投递给 jnpf-audit。 * path 走内部接收前缀 /Internal/audit(不经网关白名单暴露,见 AuditInternalController)。 */ @FeignClient(name = AuditFeignName.AUDIT_SERVER_NAME, path = "/Internal/audit", fallbackFactory = AuditEventApiFallbackFactory.class) public interface AuditEventApi { /** * 投递一条审计事件。 * * <p><b>调用方契约(Task 7 SDK 的实施依据)</b>:调用方必须校验返回值: * {@code result == null || !ActionResultCode.SUCCESS.getCode().equals(result.getCode())} * 视为投递失败,抛 {@link jnpf.audit.AuditDeliveryException}(L013:成功码用平台常量 * {@code jnpf.base.ActionResultCode.SUCCESS.getCode()} 判定,禁止硬编码 200)。 * HTTP 层失败(网络/超时/服务不可用)由 {@link AuditEventApiFallbackFactory} 抛出; * 业务层失败(HTTP 200 + fail body,如落库校验不通过)必须靠本校验捕获——平台全局异常 * 处理器会把服务端异常包装成 HTTP 200 的 {@code ActionResult.fail},Feign 对非 void * 返回值同样不会因此抛异常,只有显式校验 code 才能感知。 */ @PostMapping("/events") ActionResult<Integer> record(@RequestBody AuditEventDTO dto); } jnpf-biz-common/jnpf-audit-api/src/main/java/jnpf/audit/AuditFeignName.java
New file @@ -0,0 +1,8 @@ package jnpf.audit; /** * 审计服务 Feign 名称常量:与 Nacos 注册的 spring.application.name 一致。 */ public class AuditFeignName { public static final String AUDIT_SERVER_NAME = "jnpf-audit"; } jnpf-biz-common/jnpf-audit-api/src/main/java/jnpf/audit/AuditOperationIds.java
New file @@ -0,0 +1,34 @@ package jnpf.audit; /** 由后端签发的 biz_sign 派生稳定的审计操作分组键。 */ public final class AuditOperationIds { private static final String PREFIX = "BS-"; private static final int MAX_SIGN_LENGTH = 40; public static String correlation(String bizSign) { String normalized = normalize(bizSign); return normalized == null ? null : PREFIX + normalized; } private static String normalize(String bizSign) { if (bizSign == null) { return null; } String value = bizSign.trim(); if (value.isEmpty() || value.length() > MAX_SIGN_LENGTH) { return null; } for (int i = 0; i < value.length(); i++) { char ch = value.charAt(i); boolean asciiLetter = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); if (!asciiLetter && (ch < '0' || ch > '9') && ch != '-' && ch != '_') { return null; } } return value; } private AuditOperationIds() { } } jnpf-biz-common/jnpf-audit-api/src/main/java/jnpf/audit/diff/AuditFieldDiff.java
New file @@ -0,0 +1,476 @@ package jnpf.audit.diff; import com.alibaba.fastjson.JSON; import jnpf.util.JsonUtil; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; /** * 审计事件的泛化字段 diff——**层 0 与层 1 共用的数据契约**(B3 建立,C1 下沉共享)。 * * <p>住在 {@code jnpf-audit-entity} 而不是某一层的实现包里,是因为它产出的正是 * {@code audit_events.field_diffs} 这一列的形状:两层各写一份就必然漂移,而 * {@code field_diffs} 一旦跨层不同构,查询侧与前端就得按层分支渲染,审计数据也不再可比。 * * <p>与 lims 既有的 {@code LimsFieldDiff} 是**同一套输出形状**({@code VisualLogModel[]}: * field / fieldName / oldData / newData / type),前端渲染可以直接复用;区别在于本类面对的是 * 「两张按列名/字段名索引的 Map」而非实体类: * <ul> * <li>比较的是**数据库列**(层 1,列全集来自 {@code SELECT *})或**表单字段**(层 0, * 来自在线表单事件的 newData),新加的列/字段自动纳入;</li> * <li>{@code fieldName} 默认回落成列名,可由 {@code labels} 参数补中文标签。 * <b>标签的来源必须是元数据而不是手写配置</b>——66 张 lims 表手写要上千条、且必然与表结构 * 漂移,那是「看起来做完了、其实没有」的典型。层 0 的标签取自在线表单事件自带的 * {@code listLog}(表单模型元数据,随表单发布自动更新);层 1 暂无元数据来源, * 故传 null 走列名回落。</li> * </ul> * * <h3>为什么要做类型归一(L006 的层 1 版本)</h3> * <p>before-image 与 after-image 虽然都来自 JDBC,但**同一列在两次查询里可能拿到不同的 Java 类型** * ——最典型的是数值列({@code BigDecimal} vs {@code Integer} vs {@code Long},取决于驱动与列定义) * 与时间列({@code Timestamp} vs {@code Date})。直接 {@code equals} 会把「没改过」判成「改了」, * 产出一堆假 diff,审计数据的可信度就毁了。故:数值一律按 {@link BigDecimal#compareTo} 比、 * 时间一律按 epoch 毫秒比、字节数组按内容比。 * * <h3>脱敏</h3> * <p>命中脱敏列(层 1 来自 {@code AuditTableRegistry#maskedColumns},层 0 来自 Nacos 表单注册表) * 时**只记「已变更」不记值**—— * 新旧值都写占位符。注意占位符要**成对出现**:只脱敏新值会让旧值把秘密漏出去。 */ public final class AuditFieldDiff { /** 脱敏占位符:前端直接展示这个串,语义是「变了,但内容不予记录」。 */ public static final String MASKED = "***"; /** 前端按 {@code jnpfKey=='table'} 渲染子表行级变更入口。 */ public static final String JNPF_KEY_TABLE = "table"; private AuditFieldDiff() { } /** * 单个字段的**展示元数据**(不是值)。来源必须是表单模型元数据,不是手写配置。 * * <p>D1 门禁查出:新 {@code field_diffs} 比 lims 旧 {@code data_log} 少四个键, * 而**每一个都有前端消费方**(`FormExtraPanel/DataLogList.vue` 的四条渲染分支)。 * 本类补上其中两个——它们与已在用的 {@code fieldName} 同源同性质(都取自事件自带的 * {@code listLog},是**元数据不是值**,故不受 L003「平台 oldData 不可信」约束): * * <ul> * <li>{@link #nameModified}——<b>这一个是有实际危害的那条</b>。前端在它为 true 时 * 只渲染「已修改」、**不打印值**。缺了它,字典/动态字段会把**存储原值** * (雪花 ID / 字典码)当明文渲染出来:既没有可读性,又把不该直接示人的原值摆到了页面上。 * D1 实测旧数据 318 个 diff 项中 <b>63 项</b>为 true(select 42 + userSelect 21)。</li> * <li>{@link #jnpfKey}——控件类型,驱动分支渲染:{@code sign}/{@code signature} 渲染成 * 可点开的旧/新签名图片预览,其余走明文新旧值。缺了它签名会退化成一串 URL 文本。</li> * </ul> * * <h3>子表({@code chidData} / {@code chidField})由调用方追加</h3> * <p>这两个键同样在 {@code listLog} 里,但不是普通标量字段: * <ul> * <li>普通字段仍由本类比较主表快照;子表必须按子表主键匹配行,不能把整段 JSON 当标量。</li> * <li>visualdev 在业务写入前后读取数据库,并用原生日志算法生成完整的 * {@code chidData/chidField};统一审计监听器校验两者同时存在后原样追加。</li> * <li>旧事件或非标准发布方缺少可信 {@code listLog} 时,调用方继续写 * {@code extra.subTableUndecidable},不构造伪差异。</li> * </ul> * <p>本类只承载子表控件元数据;行级结构由监听器追加,避免破坏层 1 的数据库列 diff 契约。 */ public static final class FieldMeta { private final String label; private final String jnpfKey; private final Boolean nameModified; private final String valueType; private final String componentType; private final String format; private final Map<String, String> optionLabels; private final String dictionaryType; private final Boolean hidden; public FieldMeta(String label, String jnpfKey, Boolean nameModified) { this(label, jnpfKey, nameModified, null, null, null, null, null, null); } public FieldMeta(String label, String jnpfKey, Boolean nameModified, String valueType, String componentType, String format, Map<String, String> optionLabels) { this(label, jnpfKey, nameModified, valueType, componentType, format, optionLabels, null, null); } public FieldMeta(String label, String jnpfKey, Boolean nameModified, String valueType, String componentType, String format, Map<String, String> optionLabels, String dictionaryType, Boolean hidden) { this.label = label; this.jnpfKey = jnpfKey; this.nameModified = nameModified; this.valueType = valueType; this.componentType = componentType; this.format = format; this.optionLabels = optionLabels == null || optionLabels.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap<>(optionLabels)); this.dictionaryType = dictionaryType; this.hidden = hidden; } public String label() { return label; } public String jnpfKey() { return jnpfKey; } public Boolean nameModified() { return nameModified; } public String valueType() { return valueType; } public String componentType() { return componentType; } public String format() { return format; } public Map<String, String> optionLabels() { return optionLabels; } public String dictionaryType() { return dictionaryType; } public Boolean hidden() { return hidden; } /** 使用表单在事件发生时携带的 options 生成展示快照;无法完整解析时返回 null。 */ public String optionDisplay(String raw) { if (raw == null || raw.trim().isEmpty() || optionLabels.isEmpty()) { return raw == null || raw.trim().isEmpty() ? "" : null; } List<String> values = optionValues(raw); if (values.isEmpty()) { return null; } List<String> labels = new ArrayList<>(values.size()); for (String value : values) { String label = optionLabels.get(value); if (label == null) { return null; } labels.add(label); } return String.join("、", labels); } @SuppressWarnings("unchecked") private static List<String> optionValues(String raw) { String trimmed = raw.trim(); if (!trimmed.startsWith("[")) { return Collections.singletonList(trimmed); } try { Object parsed = JSON.parse(trimmed); if (!(parsed instanceof Collection)) { return Collections.emptyList(); } List<String> values = new ArrayList<>(); for (Object value : (Collection<Object>) parsed) { if (value != null) { values.add(String.valueOf(value)); } } return values; } catch (Throwable ignored) { return Collections.emptyList(); } } } /** * 计算 before → after 的字段级 diff(无标签来源,{@code fieldName} 回落列名)。 * * @param before before-image 行(DELETE 场景传删除前的行,after 传 null) * @param after after-image 行(INSERT 场景传插入后的行,before 传 null) * @param masked 脱敏列(小写列名) * @return {@code VisualLogModel[]} 形状的 JSON 字符串;无差异时返回 {@code "[]"}(不返回 null) */ public static String diff(Map<String, Object> before, Map<String, Object> after, Set<String> masked) { return diff(before, after, masked, null); } /** * 计算 before → after 的字段级 diff,并用 {@code meta} 补展示元数据。 * * @param meta 字段名 → {@link FieldMeta}(中文标签 / 控件类型 / 是否只显示「已修改」)。 * null 或查不到的字段:{@code fieldName} 回落成字段名本身, * {@code jnpfKey}/{@code nameModified} 两个键**整个不出现**。 * <p><b>「查不到就不出这个键」是刻意的,不是省事</b>:层 1 没有元数据来源 * (它面对的是数据库列,没有表单模型),恒传 null。若无条件补上 * {@code "jnpfKey":null} 之类,层 1 的 190 条既有断言会因形状变化而红, * 而它的语义一点没变——那是纯粹的自伤。缺席即「本层给不出」, * 前端按 key 存在与否分支,天然兼容。 * <p>元数据**不参与比较、不影响是否记录**,所以它缺失最坏只是显示得糙一点, * 绝不会让一处真实变更消失。 */ public static String diff(Map<String, Object> before, Map<String, Object> after, Set<String> masked, Map<String, FieldMeta> meta) { List<Map<String, Object>> items = new ArrayList<>(); for (String column : unionColumns(before, after)) { Object oldValue = before == null ? null : before.get(column); Object newValue = after == null ? null : after.get(column); if (valuesEqual(oldValue, newValue)) { continue; } FieldMeta fm = fieldMeta(meta, column); boolean hide = isMasked(column, masked) || isCredentialMetadata(fm); Map<String, Object> item = new LinkedHashMap<>(); item.put("field", column); String label = fm == null ? null : fm.label(); item.put("fieldName", label == null || label.isEmpty() ? column : label); // 顺序对齐 lims 旧 VisualLogModel,方便与旧 data_log 逐项对照;前端按 key 取,不依赖顺序。 if (fm != null && fm.jnpfKey() != null && !fm.jnpfKey().isEmpty()) { item.put("jnpfKey", fm.jnpfKey()); } String oldData = hide ? MASKED : stringify(oldValue); String newData = hide ? MASKED : stringify(newValue); item.put("oldData", oldData); item.put("newData", newData); if (fm != null && fm.valueType() != null) { item.put("valueType", fm.valueType()); } if (fm != null && fm.componentType() != null) { item.put("componentType", fm.componentType()); } if (fm != null && fm.format() != null) { item.put("format", fm.format()); } if (fm != null && fm.dictionaryType() != null) { item.put("dictionaryType", fm.dictionaryType()); } if (!hide && fm != null) { String oldDisplay = fm.optionDisplay(oldData); String newDisplay = fm.optionDisplay(newData); if (oldDisplay != null) { item.put("oldDisplay", oldDisplay); } if (newDisplay != null) { item.put("newDisplay", newDisplay); } } // nameModified=true 时前端只渲染「已修改」不打印值。**脱敏列上照样带**—— // 两者不冲突:脱敏管的是「值不记」,nameModified 管的是「值不展示」, // 同时成立时前端显示「已修改」而不是 `***`,语义更准。 if (fm != null && fm.nameModified() != null) { item.put("nameModified", fm.nameModified()); } // 与 LimsFieldDiff 同义:0=从无到有(新增),1=修改。 // **脱敏列恒记 1**(B5 评审 M-3):type=0 等于告诉读者「这列此前没有值」—— // 内容没漏,存在性漏了半格(「初始密码是第一次设置」本身就是信息)。 // 脱敏的承诺是「只记已变更」,那就连这半格也不给。 item.put("type", !hide && isEmpty(oldValue) ? 0 : 1); items.add(item); } return JsonUtil.getObjectToString(items); } /** * 表单 vModel 保留 camelCase,而 PostgreSQL 未加引号的列名会折叠为小写。 * 元数据注册表统一存小写键,因此先精确匹配,再按小写键回退。 */ private static FieldMeta fieldMeta(Map<String, FieldMeta> meta, String column) { if (meta == null || column == null) { return null; } FieldMeta exact = meta.get(column); return exact != null ? exact : meta.get(column.toLowerCase(Locale.ROOT)); } /** * 整行快照(落 {@code data_snapshot})。DELETE 用 before、其余用 after—— * 快照的意义是「这次操作之后这行长什么样」,删除则是「删掉的是什么」。 */ public static String snapshot(Map<String, Object> row, Set<String> masked) { return snapshot(row, masked, null); } /** 整行快照,额外使用字段元数据识别非固定列名的签名控件。 */ public static String snapshot(Map<String, Object> row, Set<String> masked, Map<String, FieldMeta> meta) { if (row == null) { return null; } Map<String, Object> out = new LinkedHashMap<>(); for (Map.Entry<String, Object> entry : row.entrySet()) { String column = entry.getKey(); FieldMeta fm = fieldMeta(meta, column); out.put(column, isMasked(column, masked) || isCredentialMetadata(fm) ? MASKED : stringify(entry.getValue())); } return JsonUtil.getObjectToString(out); } /** 签名控件的脱敏由元数据决定,不依赖字段名约定。 */ public static boolean isCredentialMetadata(FieldMeta meta) { if (meta == null) { return false; } String valueType = lower(meta.valueType()); String componentType = lower(meta.componentType()); String jnpfKey = lower(meta.jnpfKey()); return "masked".equals(valueType) || "sign".equals(componentType) || "signature".equals(componentType) || "sign".equals(jnpfKey) || "signature".equals(jnpfKey); } private static String lower(String value) { return value == null ? null : value.trim().toLowerCase(Locale.ROOT); } /** after 的列在前、before 独有的列补在后:输出顺序对人读 diff 更友好,且稳定可断言。 */ private static Set<String> unionColumns(Map<String, Object> before, Map<String, Object> after) { Set<String> columns = new LinkedHashSet<>(); if (after != null) { columns.addAll(after.keySet()); } if (before != null) { columns.addAll(before.keySet()); } return columns; } private static boolean isMasked(String column, Set<String> masked) { return masked != null && column != null && masked.contains(column.toLowerCase(Locale.ROOT)); } /** * 归一化比较。**顺序有讲究**:先处理 null,再按「数值 / 时间 / 字节数组」三类归一, * 最后才退回 equals——反过来写会让 {@code BigDecimal("1.0")} 与 {@code 1} 判成不等。 * * <p><b>整段包 try(B3 评审 I-1)</b>:比较单个值时抛出的异常曾能穿出 {@code diff()}, * 被 emitter 的总兜底吃掉,结果是**该行连同其后所有行的事件全部静默消失**—— * 漏记真实变更比记错更严重。现在退化成「按不相等处理」:最坏是多记一条字段变更, * 不会让整条事件消失。 */ public static boolean valuesEqual(Object a, Object b) { try { if (isEmpty(a) && isEmpty(b)) { return true; } if (a == null || b == null) { return a == null && b == null; } if (a instanceof Number && b instanceof Number) { return numbersEqual((Number) a, (Number) b); } if (a instanceof Date && b instanceof Date) { // 用 compareTo 而非 getTime()(评审 M-3):java.sql.Timestamp 的纳秒精度不在 // getTime() 里,亚毫秒差异会被判成"没变"——那是漏记真实变更。 // Timestamp.compareTo(Date) 与 Date.compareTo 都按各自完整精度比。 return compareDates((Date) a, (Date) b); } if (a instanceof byte[] && b instanceof byte[]) { // 字节数组的 equals 是引用比较,不归一会让每次 diff 都把 BLOB 列报成「已变更」 return Arrays.equals((byte[]) a, (byte[]) b); } return Objects.equals(a, b); } catch (Throwable t) { return false; // 比较不了就当"变了":宁可多记一条,也不让整条事件消失 } } /** * 数值比较。**先挡住非有限值**(NaN / ±Infinity):它们是 PG float8/real 的合法值, * 而 {@code BigDecimal.valueOf(NaN)} 直接抛 {@code NumberFormatException}(评审 I-1 实证, * 连 {@code NaN vs NaN} 都炸)。非有限值一律退回 {@code Double.compare} 的语义—— * 它把 {@code NaN == NaN} 判为相等、{@code NaN != 1.0} 判为不等,正是审计想要的。 */ private static boolean numbersEqual(Number a, Number b) { if (isNonFinite(a) || isNonFinite(b)) { return Double.compare(a.doubleValue(), b.doubleValue()) == 0; } return toBigDecimal(a).compareTo(toBigDecimal(b)) == 0; } private static boolean isNonFinite(Number number) { if (number instanceof Double || number instanceof Float) { double value = number.doubleValue(); return Double.isNaN(value) || Double.isInfinite(value); } return false; } /** {@code Timestamp} 与 {@code Date} 混比时以 Timestamp 一侧的完整精度为准。 */ private static boolean compareDates(Date a, Date b) { if (a instanceof java.sql.Timestamp || b instanceof java.sql.Timestamp) { java.sql.Timestamp left = a instanceof java.sql.Timestamp ? (java.sql.Timestamp) a : new java.sql.Timestamp(a.getTime()); java.sql.Timestamp right = b instanceof java.sql.Timestamp ? (java.sql.Timestamp) b : new java.sql.Timestamp(b.getTime()); return left.compareTo(right) == 0; } return a.getTime() == b.getTime(); } private static BigDecimal toBigDecimal(Number number) { if (number instanceof BigDecimal) { return (BigDecimal) number; } if (number instanceof Double || number instanceof Float) { return BigDecimal.valueOf(number.doubleValue()); } if (number instanceof java.math.BigInteger) { return new BigDecimal((java.math.BigInteger) number); // 别经 longValue() 截断 } return BigDecimal.valueOf(number.longValue()); } /** 展示用字符串。null → 空串(与 LimsFieldDiff 一致,前端按空串判「无值」)。 */ public static String stringify(Object value) { if (value == null) { return ""; } if (value instanceof Date) { return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format((Date) value); } if (value instanceof byte[]) { return "byte[" + ((byte[]) value).length + "]"; // 不把二进制塞进审计文本 } return String.valueOf(value); } private static boolean isEmpty(Object value) { if (value == null) { return true; } if (value instanceof CharSequence) { return value.toString().trim().isEmpty(); } if (value instanceof Collection) { return ((Collection<?>) value).isEmpty(); } return value instanceof Map && ((Map<?, ?>) value).isEmpty(); } } jnpf-biz-common/jnpf-audit-api/src/main/java/jnpf/audit/fallback/AuditEventApiFallbackFactory.java
New file @@ -0,0 +1,20 @@ package jnpf.audit.fallback; import jnpf.audit.AuditDeliveryException; import jnpf.audit.AuditEventApi; import jnpf.audit.model.AuditEventDTO; import lombok.extern.slf4j.Slf4j; import org.springframework.cloud.openfeign.FallbackFactory; import org.springframework.stereotype.Component; @Component @Slf4j public class AuditEventApiFallbackFactory implements FallbackFactory<AuditEventApi> { @Override public AuditEventApi create(Throwable cause) { return dto -> { // 关键:把异常重新抛出,让 SDK 降级链感知失败(L009 教训:静态 fallback 静默吞会误判成功) throw new AuditDeliveryException("Feign 投递审计事件失败", cause); }; } } jnpf-biz-common/jnpf-audit-api/src/main/java/jnpf/audit/model/AuditEventDTO.java
New file @@ -0,0 +1,59 @@ package jnpf.audit.model; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; /** * 审计事件 DTO:字段与 {@link jnpf.bizcommon.audit.entity.AuditEventEntity} 同名同型, * id/createdAt 除外(DB 生成字段,不由调用方提供)。 * * <p>toBuilder 供 SDK 做不可变拷贝;@NoArgsConstructor 是硬性规则 L004—— * 本类要经 JSON/MQ 反序列化,缺了会在 fastjson/jackson 处踩雷。 */ @Data @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor public class AuditEventDTO { private String clientEventId; private String operationId; private Date eventTime; private String tenantId; private String operatorId; private String operatorName; private String operatorOrgId; private String appName; private String bizModule; private String ip; private String ipRegion; private String browser; private String os; private String requestUri; private String eventType; private String actionCode; private String actionLabel; private Integer sourceLayer; private String targetTable; private String targetId; private String bizType; private String bizCode; private String fieldDiffs; private String extra; private String dataSnapshot; private String reason; private String recordTitle; private String entryType; private String entryId; private String entryName; private String prevHash; /** * 本次成功业务操作使用的电子签名 id,仅供审计接收端回读签名凭证并补充签名事件。 * 该字段不映射 audit_events,也不会作为业务事件详情返回。 */ private String relatedSignId; } jnpf-biz-common/jnpf-audit-api/src/test/java/jnpf/audit/AuditOperationIdsChecks.java
New file @@ -0,0 +1,24 @@ package jnpf.audit; /** 无测试框架依赖的关联键语义探针。 */ public class AuditOperationIdsChecks { public static void main(String[] args) { String signId = "839875756384423365"; require(("BS-" + signId).equals(AuditOperationIds.correlation(signId)), "同一次前端操作的关联键不稳定"); require(!AuditOperationIds.correlation(signId).endsWith("-BIZ"), "operationId 不应包含业务类别"); require(!AuditOperationIds.correlation(signId).endsWith("-SIGN"), "operationId 不应包含签名类别"); require(AuditOperationIds.correlation("invalid sign") == null, "非法签名值不应参与关联"); require(AuditOperationIds.correlation("中文签名") == null, "关联值仅允许 ASCII 安全字符"); require(AuditOperationIds.correlation(null) == null, "空签名值不应参与关联"); } private static void require(boolean condition, String message) { if (!condition) { throw new AssertionError(message); } } } jnpf-biz-common/jnpf-audit-sdk/pom.xml
New file @@ -0,0 +1,58 @@ <?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>jnpf-biz-common</artifactId> <groupId>com.jnpf</groupId> <version>6.1.0-RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <!-- 审计采集 SDK:跑在**业务进程内**(当前宿主 jnpf-lims),不是 biz-common 服务的一部分。 2026-08-11 随 audit 搬入本目录,artifactId 与包名 jnpf.audit.sdk.* 保持不变 (jnpf-lims-biz 的依赖坐标零改动)。 --> <artifactId>jnpf-audit-sdk</artifactId> <dependencies> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-audit-api</artifactId> <version>${project.version}</version> </dependency> <!-- Codex 批次二 #5:删除 mybatis-plus-spring-boot3-starter——SDK 全部源码不使用 MyBatis (grep 证实无 mybatis/baomidou/@TableName/BaseMapper 引用),且该坐标为 boot3 专属、仅在 jnpf-dependencies 的 boot3 profile 有托管版本;在 boot2 profile 下无托管版本会令构建报 "version is missing",直接破坏 JDK8/boot2 交付线。此依赖属死重,予以移除。 --> <!-- 2026-08-11:spring-cloud-stream(provided)随降级链第一级 MQ 移除而删除。 AuditEventPublisher 不再引用 StreamBridge,降级链现为 Feign → 本地 spool 两级。 ⚠️ 注意宿主侧(jnpf-cloud-base / jnpf-common-springaop)的 spring-cloud-stream 核心 依赖**不能**跟着删:jnpf-common-mq 的 ProjectEventMQSender 与 MqAutoConfiguration$MqExistsdConfiguration 字节码同样引用 StreamBridge(已 javap 核实)。 --> <!-- 事务 holder(TransactionSynchronizationManager/TransactionSynchronization);宿主既有依赖提供,provided --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <scope>provided</scope> </dependency> <!-- @AutoConfiguration/@Bean/Environment 等;宿主既有依赖提供,provided --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <scope>provided</scope> </dependency> <!-- 层 2 @AuditLog 切面(AspectJ @Aspect/@Around + SpEL);宿主 jnpf-lims-biz 已带该 starter, optional=true 防止拖到未引入 AOP 的其它宿主上(ConditionalOnClass 兜底装配缺席) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> <optional>true</optional> </dependency> <!-- 2026-08-11:org.mybatis:mybatis 与 com.github.jsqlparser:jsqlparser 两个 provided 依赖 已随层 1(MyBatis Executor 插件)整套移除。它们只被 jnpf.audit.sdk.layer1 的 SQL 解析用到, 层 1 自交付起一直默认关、audit_events 表内 source_layer=1 零行。 连带作废的还有原注释里那段"jsqlparser 版本必须取两条交付线下界 4.9"的论证—— SDK 现在不碰 MyBatis 与 SQL 解析,无版本分叉问题。 --> </dependencies> </project> jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditClient.java
New file @@ -0,0 +1,61 @@ package jnpf.audit.sdk; import jnpf.audit.model.AuditEventDTO; /** * 层 3 审计入口(Task 7 Step 4):业务方唯一入口。 * * <p>{@code record(dto)} = 校验 eventType/actionCode 非空 → {@link AuditContextCollector#fill(AuditEventDTO)} * 填充冻结 → {@code sourceLayer} 缺省补 3(层 3 入口默认 sourceLayer=3;调用方已显式传值则不覆盖,见下) * → {@link AuditTxHolder#submit(AuditEventDTO, AuditEventPublisher)} 交事务 holder 缓冲 * (有事务则 afterCommit 才发、回滚不发;无事务直发)。 * * <p>{@code audit.enabled=false} 或当前请求不在 {@link AuditRequestScopeFilter} 允许范围内时, * {@code record} 直接 no-op。 */ public class AuditClient { private final AuditContextCollector collector; private final AuditEventPublisher publisher; private final boolean enabled; private final AuditRequestScopeFilter requestScopeFilter; public AuditClient(AuditContextCollector collector, AuditEventPublisher publisher, boolean enabled) { this(collector, publisher, enabled, new AuditRequestScopeFilter(false, "")); } public AuditClient(AuditContextCollector collector, AuditEventPublisher publisher, boolean enabled, AuditRequestScopeFilter requestScopeFilter) { this.collector = collector; this.publisher = publisher; this.enabled = enabled; this.requestScopeFilter = requestScopeFilter; } public void record(AuditEventDTO dto) { if (!enabled || !requestScopeFilter.acceptsCurrentRequest()) { return; } if (dto == null) { throw new IllegalArgumentException("审计事件 dto 不能为空"); } if (isBlank(dto.getEventType())) { throw new IllegalArgumentException("eventType 不能为空"); } if (isBlank(dto.getActionCode())) { throw new IllegalArgumentException("actionCode 不能为空"); } collector.fill(dto); if (dto.getSourceLayer() == null) { // 裁定修复 B:本 SDK 是层 3 显式接口入口,语义上 sourceLayer 恒为 3; // 调用方未显式传值时在此补齐,否则服务端 validate(sourceLayer 非空硬校验)会拒收该事件。 // 调用方已显式传值(如上层框架转发已知层号)则尊重不覆盖。 dto.setSourceLayer(3); } AuditTxHolder.submit(dto, publisher); } private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditContextCollector.java
New file @@ -0,0 +1,131 @@ package jnpf.audit.sdk; import cn.hutool.http.useragent.UserAgent; import cn.hutool.http.useragent.UserAgentUtil; import jnpf.audit.AuditApiConsts; import jnpf.audit.model.AuditEventDTO; import jnpf.base.UserInfo; import jnpf.util.IpUtil; import jnpf.util.RandomUtil; import jnpf.util.ServletUtil; import jnpf.util.UserProvider; import org.springframework.web.context.request.RequestContextHolder; import java.util.Date; /** * 审计上下文采集器(Task 7 Step 1):只填 DTO 的空缺字段,不覆盖调用方已给值。 * * <p>操作人/租户取自 {@link UserProvider}(Sa-Token 登录上下文);IP/浏览器/OS 取自请求上下文, * 工具与平台 {@code jnpf.RequestLogAspect} 同款({@link IpUtil#getIpAddr()} / {@link IpUtil#getIpCity(String)} / * {@link ServletUtil#getUserAgent()} + hutool {@link UserAgentUtil#parse(String)})。 * * <p>关键约束(终验#6):operationId **不由本采集器生成、也不写 MDC**——生成与 MDC 写入统一归 * {@link AuditTxHolder};采集器若先写 MDC,会让 holder 读到的 previousMdc 变成新值本身,恢复等于没恢复。 */ public class AuditContextCollector { /** 无登录上下文时的操作人占位(异步/系统触发链路) */ private static final String OPERATOR_SYSTEM = "SYSTEM"; private final String appName; public AuditContextCollector(String appName) { this.appName = appName; } /** 填充空缺字段并返回同一 dto(供 AuditClient 冻结后交 holder 缓冲)。 */ public AuditEventDTO fill(AuditEventDTO dto) { if (isBlank(dto.getClientEventId())) { dto.setClientEventId(RandomUtil.uuId()); // 平台雪花(= String.valueOf(YitIdHelper.nextId())) } if (dto.getEventTime() == null) { dto.setEventTime(new Date()); } if (isBlank(dto.getAppName())) { dto.setAppName(appName); } fillOperator(dto); fillRequest(dto); return dto; } /** * 操作人/租户:有登录用户则填 userId/userName/organizeId/tenantId;无则 operator=SYSTEM。 * * <p>租户回落(M2 阻断项修复):单租户部署下 {@code user.getTenantId()} 返回**空串**, * 无会话链路更是无从取得——两种情况都在最后统一回落到 {@link AuditApiConsts#DEFAULT_TENANT_ID}, * 否则服务端 validate 会以「tenantId 不能为空」拒收,事件进 %RETRY% 直至 DLQ。 * 回落只在空白时发生,多租户部署下真实租户号不受影响。 */ private void fillOperator(AuditEventDTO dto) { UserInfo user = currentUser(); if (user != null && !isBlank(user.getUserId())) { if (isBlank(dto.getOperatorId())) { dto.setOperatorId(user.getUserId()); } if (isBlank(dto.getOperatorName())) { dto.setOperatorName(user.getUserName()); } if (isBlank(dto.getOperatorOrgId())) { dto.setOperatorOrgId(user.getOrganizeId()); } if (isBlank(dto.getTenantId())) { dto.setTenantId(user.getTenantId()); } } else if (isBlank(dto.getOperatorId())) { // 无登录上下文:operator=SYSTEM(tenantId 由下方哨兵回落兜底) dto.setOperatorId(OPERATOR_SYSTEM); } if (isBlank(dto.getTenantId())) { dto.setTenantId(AuditApiConsts.DEFAULT_TENANT_ID); } } /** IP/浏览器/OS:仅在有请求上下文时采集,best-effort,采集失败静默降级不影响审计主流程。 */ private void fillRequest(AuditEventDTO dto) { if (RequestContextHolder.getRequestAttributes() == null) { return; // 无请求上下文(异步/系统触发) } try { if (isBlank(dto.getIp())) { String ip = IpUtil.getIpAddr(); dto.setIp(ip); if (isBlank(dto.getIpRegion())) { dto.setIpRegion(IpUtil.getIpCity(ip)); } } String ua = ServletUtil.getUserAgent(); if (!isBlank(ua)) { UserAgent agent = UserAgentUtil.parse(ua); if (isBlank(dto.getBrowser())) { dto.setBrowser(join(agent.getBrowser().getName(), agent.getVersion())); } if (isBlank(dto.getOs())) { dto.setOs(join(agent.getPlatform().getName(), agent.getOsVersion())); } } } catch (Exception ignore) { // 上下文采集属 best-effort,任何异常不得冒泡影响业务与审计主链路 } } private static UserInfo currentUser() { try { return UserProvider.getUser(); } catch (Exception e) { return null; // 未登录/无 token(Sa-Token 可能抛异常),视为无用户 } } private static String join(String a, String b) { String x = a == null ? "" : a.trim(); String y = b == null ? "" : b.trim(); String r = (x + " " + y).trim(); return r.isEmpty() ? null : r; } private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditEventPublisher.java
New file @@ -0,0 +1,131 @@ package jnpf.audit.sdk; import jnpf.audit.AuditDeliveryException; import jnpf.audit.AuditEventApi; import jnpf.audit.model.AuditEventDTO; import jnpf.base.ActionResult; import jnpf.base.ActionResultCode; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.ObjectProvider; import java.util.List; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * 两级降级异步发送器:Feign(AuditEventApi 同步)→ 本地 spool 落盘。 * * <p><b>2026-08-11 变更</b>:原第一级 MQ({@code streamBridge.send("AUDIT_EVENT", …)})已移除。 * 事件总线于 2026-08-10 切 Redis 后栈内不再有 RocketMQ binder,该级**每次必然失败**再由 Feign 接管—— * 它已不是降级级次,只是每条事件多一次异常构造。移除后行为等价(此前本就每条都走 Feign)。 * 若将来要恢复异步投递,正解是提供一个写 Redis 的 {@link AuditEventApi} 实现 bean 替代 Feign, * 本类一行不用改(见 tasks/todo.md 阶段 4,当前因 Redis 无持久化 + noeviction 暂不做)。 * * <p>本类的并发/时序注释对应降级链路上经过验证的并发安全设计——重构时不要随手删改, * 每一处都对应一个真实存在过的竞态场景。 * deliverOnce 只做 Feign 一级并给出明确成败信号(不写 spool),供普通路径与 replayer 各自决定失败处理。 */ @Slf4j public class AuditEventPublisher implements DisposableBean { private final ObjectProvider<AuditEventApi> feignApi; private final AuditSpoolWriter spool; private final ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 2, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), r -> { Thread t = new Thread(r, "audit-sender"); t.setDaemon(true); return t; }, new ThreadPoolExecutor.AbortPolicy()); // 队列满走 catch → spool,不阻塞业务 /** pending 集合:提交前在调用线程登记、run() finally 移除——统一覆盖"排队中/执行中/已出队未开跑"三态, 无登记窗口(终验三轮④:原 run() 内 add 的写法在出队与登记之间有窗口,shutdownNow 撞上会双漏) */ private final Set<AuditSendTask> pending = ConcurrentHashMap.newKeySet(); public AuditEventPublisher(ObjectProvider<AuditEventApi> feignApi, AuditSpoolWriter spool) { this.feignApi = feignApi; this.spool = spool; } /** 任务自携事件对象:异常/停机 drain 时能恢复出具体事件落 spool(评审#2,裸 lambda 恢复不出事件) */ class AuditSendTask implements Runnable { final AuditEventDTO event; AuditSendTask(AuditEventDTO event) { this.event = event; } @Override public void run() { try { deliverOnce(event); } catch (Throwable t) { // 最外层兜底:任何异常(含 JSON 序列化)都落 spool,绝不静默丢(评审#2) spool.write(event); log.error("AUDIT-LOST clientEventId={} type={} target={} 已落 spool", event.getClientEventId(), event.getEventType(), event.getTargetTable(), t); } finally { pending.remove(this); } } } public void publishAsync(List<AuditEventDTO> events) { for (AuditEventDTO e : events) { AuditSendTask task = new AuditSendTask(e); pending.add(task); // 先登记再入池(终验三轮④) try { pool.execute(task); } catch (RejectedExecutionException full) { // 先写 spool 再移除登记(终验四轮②:反序会在 remove 与 write 之间留下双侧不可见窗口) try { spool.write(e); // 队列满直接落 spool(spec §5.5) } finally { pending.remove(task); } } } } /** * 单次投递:Feign 同步,成功返回、失败抛异常——本方法不写 spool。 * 终验#2/#3:普通路径与 replayer 都需要明确的成败信号各自决定失败处理, * 原 send() 失败时自己写 spool 正常返回,replayer 无法判断该不该保留待重试行。 */ void deliverOnce(AuditEventDTO e) { ActionResult<Integer> result = feignApi.getObject().record(e); if (result == null || !ActionResultCode.SUCCESS.getCode().equals(result.getCode())) { // HTTP 层失败由 fallbackFactory 抛出;业务层失败(HTTP 200 + fail body)靠本校验捕获(Codex 批次一 Critical#1) throw new AuditDeliveryException("Feign 投递返回非成功: " + (result == null ? "null" : result.getCode()), null); } } /** * Codex 批次二 #5:以 {@link DisposableBean#destroy()} 替代 jakarta {@code @PreDestroy}, * 跨 boot2(JDK8)/boot3 中立——{@code @PreDestroy} 在 boot2 走 {@code javax.annotation}、 * boot3 走 {@code jakarta.annotation},SDK 二进制无法同时兼容;DisposableBean 是 Spring 原生接口, * 两版一致。Spring 对 {@code @Bean} 会自动识别 DisposableBean,{@code AuditSdkAutoConfiguration} 无需改。 */ @Override public void destroy() { drain(); } public void drain() { // 停机 drain(spec §5.5);不向外抛异常(终验三轮④:异常逃出会绕过全部兜底) pool.shutdown(); try { if (!pool.awaitTermination(10, TimeUnit.SECONDS)) { pool.shutdownNow(); spoolPending(); } } catch (InterruptedException ie) { pool.shutdownNow(); spoolPending(); Thread.currentThread().interrupt(); // 兜底做完再恢复中断标记 } } private void spoolPending() { // pending 统一覆盖排队中/执行中/出队未开跑三态;与任务自身 catch 落盘可能重复—— // spool 重放与落库均按 client_event_id 幂等,重复无害 for (AuditSendTask task : pending) { spool.write(task.event); } } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditRequestCorrelationResolver.java
New file @@ -0,0 +1,22 @@ package jnpf.audit.sdk; import jnpf.audit.AuditApiConsts; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; /** 读取前端统一提交拦截器传入的审计关联信息。 */ public final class AuditRequestCorrelationResolver { public static String currentBizSign() { RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); if (!(attributes instanceof ServletRequestAttributes)) { return null; } return ((ServletRequestAttributes) attributes).getRequest() .getHeader(AuditApiConsts.HEADER_BIZ_SIGN); } private AuditRequestCorrelationResolver() { } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditRequestScopeFilter.java
New file @@ -0,0 +1,56 @@ package jnpf.audit.sdk; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Locale; import java.util.Set; /** Restricts SDK audit events to configured application request sources. */ public class AuditRequestScopeFilter { private static final String HEADER_APP_CODE = "App-Code"; private static final String HEADER_APP_SCOPE = "Jnpf-App-Scope"; private final boolean frontendOnly; private final Set<String> allowedAppCodes; public AuditRequestScopeFilter(boolean frontendOnly, String allowedAppCodes) { this.frontendOnly = frontendOnly; Set<String> normalized = new LinkedHashSet<>(); if (allowedAppCodes != null) { for (String code : allowedAppCodes.split(",")) { if (code != null && !code.trim().isEmpty()) { normalized.add(code.trim().toUpperCase(Locale.ROOT)); } } } this.allowedAppCodes = normalized.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(normalized); } public boolean acceptsCurrentRequest() { RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); if (!(attributes instanceof ServletRequestAttributes)) { return accepts(null, null); } ServletRequestAttributes servletAttributes = (ServletRequestAttributes) attributes; return accepts(servletAttributes.getRequest().getHeader(HEADER_APP_SCOPE), servletAttributes.getRequest().getHeader(HEADER_APP_CODE)); } public boolean accepts(String appScope, String appCode) { if (frontendOnly && !"FRONTEND".equalsIgnoreCase(trim(appScope))) { return false; } return allowedAppCodes.isEmpty() || allowedAppCodes.contains(trim(appCode).toUpperCase(Locale.ROOT)); } private static String trim(String value) { return value == null ? "" : value.trim(); } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditSdkAutoConfiguration.java
New file @@ -0,0 +1,107 @@ package jnpf.audit.sdk; import jnpf.audit.AuditEventApi; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.core.env.Environment; /** * audit-sdk 自动装配(Task 7 Step 5)。 * * <p>注册机制:{@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} * ——平台默认构建走 JDK21/boot3 profile(Spring Boot 3.5.7),Spring Boot 3 已移除 spring.factories * 的自动装配入口,必须用 {@code .imports};且该机制自 Spring Boot 2.7 起即受支持,对 boot2 profile 亦向后兼容。 * * <p>{@code audit.enabled}(默认 true,缺省视为启用)为总开关;置 false 时整套 Bean 不装配。 * 各 Bean 均加 {@link ConditionalOnMissingBean} 供宿主按需覆盖。 * * <p><b>层次现状(2026-08-11 起)</b>:SDK 侧只剩层 2({@code @AuditLog} 切面)与层 3 * ({@link AuditClient} 显式埋点)。层 1(MyBatis Executor 插件)已整套移除——它自交付起 * 一直默认关,{@code audit_events} 表内 {@code source_layer=1} 零行,属未启用即负债。 * 需要恢复见 {@code docs/handoff-2026-08-11-audit-into-biz-common.md} 记的 git revert 坐标。 */ @AutoConfiguration @ConditionalOnProperty(name = "audit.enabled", matchIfMissing = true) public class AuditSdkAutoConfiguration { @Bean @ConditionalOnMissingBean public AuditContextCollector auditContextCollector(Environment environment) { return new AuditContextCollector(environment.getProperty("spring.application.name")); } /** * spool 双层锁(Codex 批次二复验 #1/#2 终局修复):writer 与 replayer <b>共享同一实例</b>, * 把彼此的 spool 文件操作串行化(进程内 monitor + 跨进程 {@code spool.lock} FileLock)。 */ @Bean @ConditionalOnMissingBean public AuditSpoolLock auditSpoolLock( @Value("${audit.spool-dir:./audit-spool}") String spoolDir) { return new AuditSpoolLock(spoolDir); } @Bean @ConditionalOnMissingBean public AuditSpoolWriter auditSpoolWriter( @Value("${audit.spool-dir:./audit-spool}") String spoolDir, AuditSpoolLock spoolLock) { return new AuditSpoolWriter(spoolDir, spoolLock); } @Bean @ConditionalOnMissingBean public AuditEventPublisher auditEventPublisher(ObjectProvider<AuditEventApi> feignApi, AuditSpoolWriter spoolWriter) { return new AuditEventPublisher(feignApi, spoolWriter); } @Bean @ConditionalOnMissingBean public AuditSpoolReplayer auditSpoolReplayer(AuditEventPublisher publisher, @Value("${audit.spool-dir:./audit-spool}") String spoolDir, @Value("${audit.spool-replay-enabled:true}") boolean replayEnabled, // 调度周期(毫秒):默认 300000(5 分钟,spec);测试环境可调短加速降级链重放验证。 // 仅改轮询频率,不改孤儿年龄安全阈值(见 AuditSpoolReplayer.ORPHAN_REPLAYING_AGE_MS)。 @Value("${audit.spool-replay-interval-ms:300000}") long replayIntervalMs, AuditSpoolLock spoolLock) { return new AuditSpoolReplayer(publisher, spoolDir, replayEnabled, spoolLock, replayIntervalMs); } @Bean @ConditionalOnMissingBean public AuditRequestScopeFilter auditRequestScopeFilter( @Value("${audit.request-scope.frontend-only:false}") boolean frontendOnly, @Value("${audit.request-scope.allowed-app-codes:}") String allowedAppCodes) { return new AuditRequestScopeFilter(frontendOnly, allowedAppCodes); } @Bean @ConditionalOnMissingBean public AuditClient auditClient(AuditContextCollector collector, AuditEventPublisher publisher, AuditRequestScopeFilter requestScopeFilter, @Value("${audit.enabled:true}") boolean enabled) { return new AuditClient(collector, publisher, enabled, requestScopeFilter); } /** * 层 2 切面(默认开启,可用 audit.layer2-enabled=false 单独关)。 * @ConditionalOnClass 防止宿主没引 AOP starter 时装配失败。 */ @Bean @ConditionalOnMissingBean @ConditionalOnClass(org.aspectj.lang.ProceedingJoinPoint.class) @ConditionalOnProperty(name = "audit.layer2-enabled", matchIfMissing = true) public jnpf.audit.sdk.aspect.AuditLogAspect auditLogAspect(AuditClient auditClient, AuditEventPublisher publisher, ApplicationContext applicationContext) { return new jnpf.audit.sdk.aspect.AuditLogAspect(auditClient, publisher, applicationContext); } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditSdkProbe.java
New file @@ -0,0 +1,31 @@ package jnpf.audit.sdk; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; /** * SDK 装配自检探针。存在两重价值: * 1) 它本身就是「宿主必须用 ObjectProvider 注入」的示范——audit.enabled=false 时 * AuditSdkAutoConfiguration 整套 Bean 缺席(不是 no-op),普通 @Autowired 会让宿主起不来; * 2) 给出可断言的启动产物(tasks/audit-m2-startup-check.sh 双态断言此行)。 * * 用 warn 级:平台 logger.yaml 强制 root:ERROR,业务服务内 info 埋点会被压制。 */ public class AuditSdkProbe implements ApplicationRunner { private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AuditSdkProbe.class); private final ObjectProvider<AuditClient> auditClient; private final String appName; public AuditSdkProbe(ObjectProvider<AuditClient> auditClient, String appName) { this.auditClient = auditClient; this.appName = appName; } @Override public void run(ApplicationArguments args) { log.warn("[AUDIT-SDK] app={} present={}", appName, auditClient.getIfAvailable() != null); } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditSdkProbeAutoConfiguration.java
New file @@ -0,0 +1,17 @@ package jnpf.audit.sdk; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.core.env.Environment; /** 探针的装配入口:**刻意不带** audit.enabled 条件,否则关开关时探针自己也消失,无法断言 present=false。 */ @AutoConfiguration public class AuditSdkProbeAutoConfiguration { @Bean @ConditionalOnMissingBean public AuditSdkProbe auditSdkProbe(ObjectProvider<AuditClient> auditClient, Environment environment) { return new AuditSdkProbe(auditClient, environment.getProperty("spring.application.name")); } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditSpoolLock.java
New file @@ -0,0 +1,328 @@ package jnpf.audit.sdk; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.DisposableBean; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; import java.nio.file.Files; /** * spool 双层锁协议(Codex 批次二复验 #1/#2 终局修复;批次二三轮 #A/#D 加固)。 * * <p>{@link AuditSpoolWriter} 与 {@link AuditSpoolReplayer} <b>共享同一实例</b>(由 * {@code AuditSdkAutoConfiguration} 注入单例),把彼此的 spool 文件操作串行化,从根上消除两条竞态: * <ul> * <li><b>Codex #1(Critical)</b>:writer 每次 open-append-close 与 replayer 的 * "认领 rename + readAllLines" 之间的窗口——writer 恰在 replayer 改名旧文件后经已打开的 FD * 写入被改名 inode,事件随 {@code .replaying} 被删而永久丢失。</li> * <li><b>Codex #2(Important)</b>:认领 rename 与 setLastModified 刷新租约是两个独立操作, * 实例 B 可在实例 A rename 成功后、刷新 mtime 前把它当孤儿抢走。</li> * </ul> * 两条竞态的正确性从此由本锁保证(不再依赖 60 秒静默窗口这种活跃度启发式;静默窗口降级为纯优化)。 * * <h3>两层结构</h3> * <ol> * <li><b>进程内层</b>:单一监视器 {@link #monitor},writer/replayer 所有临界区共用,保证同 JVM 串行。 * 它同时保证同 JVM 内 {@link FileChannel#tryLock()} 请求永不重叠——否则 JDK 会抛 * {@link OverlappingFileLockException}(FileLock 是 JVM 级而非线程级),故本类先取进程内锁、 * 在 {@code synchronized} 块内获/放 FileLock,天然无重叠。</li> * <li><b>跨进程层</b>:spool 目录下常驻的 {@code spool.lock} 文件 + {@link FileChannel#lock()} 系 * 独占 {@link FileLock}。锁文件 {@link RandomAccessFile}/{@link FileChannel} 惰性打开、常驻, * {@link DisposableBean#destroy()} 时释放。</li> * </ol> * * <h3>降级与终局(绝不让业务/发送线程崩溃)</h3> * <ul> * <li>打开锁文件或 {@code tryLock} 抛 {@link IOException}(不支持的文件系统等)→ 置 * {@link #fileLockDisabled},之后<b>不再尝试跨进程锁</b>,{@link #warnDegradeOnce} 去重 warn 一次。</li> * <li><b>writer 路径</b> {@link #lockAndRun(IoRunnable, IoRunnable)}:拿到跨进程 FileLock → 执行 * {@code lockedAction}(正常追加共享活动文件);<b>未拿到</b>(超时/中断/不可用/已销毁)→ 执行 * {@code degradedAction}(Codex#A:写唯一名 {@code .retry} 应急段——一事件一文件,无共享名=无 * rename 竞态,天然是重放协议的输入,宁多文件不丢事件)。进程内 {@link #monitor} 全程持有。</li> * <li><b>replayer 路径</b> {@link #tryLockAndRun}:非阻塞 {@code tryLock} 拿不到(他进程持有)就<b>跳过本轮</b> * (返回 {@code false},下轮再来);降级态仅进程内锁执行返回 {@code true}(该退化路径仅在"单实例 * 拓扑"下安全——多实例共享同一 spool 卷时跨进程互斥完全失效,需运维保证不支持 FileLock 的文件系统 * 只单实例部署,既有建议);<b>已销毁态直接返回 {@code false}、不执行 action</b>(Codex 四轮 * Critical#1,见下一条)。</li> * <li><b>Codex#D 终止位</b> {@link #destroyed}:{@link #destroy()} 后 {@link #ensureChannelOpen} 拒绝 * 重开 channel——迟到的收尾/写入调用({@link #lockAndRun(IoRunnable, IoRunnable)},"始终执行"语义) * 只走进程内锁执行 degradedAction({@link #warnPostDestroyOnce} 去重 warn 一次),杜绝"已 close * 的 raf/channel 被重开、停机后又持有跨进程锁"。</li> * <li><b>Codex 四轮 Critical#1</b>(destroyed 态认领未拒绝):{@link #tryLockAndRun}(认领/孤儿回收 * 路径,"可延后到下轮"语义)在 destroyed 后<b>直接返回 {@code false},不执行 action</b>——而非像 * 旧版那样退化为"仅进程内锁执行并返回 true"。停机窗口内不应该再对 spool 文件做任何认领:认领 * 延后到下一轮/下一个存活实例处理无害,而写入路径 {@link #lockAndRun(IoRunnable, IoRunnable)} * 必须"始终执行"(事件不能丢),二者语义有意不同,不可混用同一套 destroyed 处理。</li> * </ul> * * <p><b>残余声明</b>:单文件重放(readAllLines→网络 deliver→收尾)超 10 分钟仍可能被其它进程当孤儿 * 抢走 → 后果=重复重放,落库按 client_event_id 幂等保证不丢不重入库,可接受。{@code spool.lock} * 自身永不参与扫描/重放/删除({@link AuditSpoolReplayer} 扫描过滤已按 {@link #LOCK_FILE_NAME} 排除)。 */ @Slf4j public class AuditSpoolLock implements DisposableBean { /** 跨进程锁文件名——常驻 spool 目录,永不参与扫描/重放/删除。 */ static final String LOCK_FILE_NAME = "spool.lock"; /** writer 路径 FileLock 获取的最长等待(带重试);超时后降级为唯一名应急段。 */ private static final long WRITER_LOCK_WAIT_MS = 5_000L; /** 重试轮询间隔。 */ private static final long RETRY_SLEEP_MS = 50L; /** * 进程内层:writer/replayer 所有临界区共用的单一监视器,保证同 JVM 串行; * 亦保证同 JVM 内 FileLock 请求永不重叠(否则抛 {@link OverlappingFileLockException})。 */ private final Object monitor = new Object(); private final File spoolDir; private final File lockFile; /** 跨进程层常驻句柄,destroy 时释放;仅在持有 {@link #monitor} 时读写。 */ private RandomAccessFile raf; private FileChannel channel; /** 降级标志:FileLock 不支持/锁文件打不开时置位,之后不再尝试跨进程锁;仅在持有 {@link #monitor} 时读写。 */ private boolean fileLockDisabled = false; /** warn 去重标志;仅在持有 {@link #monitor} 时读写。 */ private boolean degradeWarned = false; /** Codex#D:终止位——destroy 后拒绝重开 channel,迟到调用只走进程内锁。仅在持有 {@link #monitor} 时写。 */ private volatile boolean destroyed = false; /** * 已销毁后迟到调用的 warn 去重标志;仅在持有 {@link #monitor} 时读写。两条调用路径共用一个去重开关: * {@link #acquireFileLockWithTimeout()}(writer/{@code lockAndRun} 路径,destroyed → 返回 {@code null} * → 调用方走 degradedAction,仅进程内锁执行)与 {@link #tryLockAndRun}(replayer/认领路径,destroyed → * 直接返回 {@code false}、不执行 action——Codex 四轮 Critical#1)。 */ private boolean postDestroyWarned = false; public AuditSpoolLock(String spoolDir) { this.spoolDir = new File(spoolDir); this.lockFile = new File(this.spoolDir, LOCK_FILE_NAME); } /** 可抛 {@link IOException} 的无返回回调(JDK8 函数式接口)。 */ @FunctionalInterface public interface IoRunnable { void run() throws IOException; } /** * writer 语义(Codex#A 降级隔离):<b>始终执行</b>,但按是否持有跨进程锁二选一执行。 * * <p>顺序:进程内 {@link #monitor} → 尽力获 FileLock(带超时重试,共最多 {@link #WRITER_LOCK_WAIT_MS})。 * <ul> * <li>拿到 FileLock → 执行 {@code lockedAction}(正常追加共享活动文件),倒序释放。</li> * <li>未拿到(超时/中断/不可用/已销毁)→ 执行 {@code degradedAction}(写唯一名 {@code .retry} 应急段)。 * 此时<b>绝不</b>再追加共享活动名——唯一名文件无共享、无 rename 竞态,天然是重放协议的输入 * (Codex#A:审计事件宁可多几个文件也不丢弃)。</li> * </ul> * * @param lockedAction 持有跨进程锁时执行的动作(共享活动文件追加) * @param degradedAction 未持锁时执行的降级动作(唯一名应急段写入) * @throws IOException 所选动作抛出的 IO 异常,由调用方按原有兜底处理 */ void lockAndRun(IoRunnable lockedAction, IoRunnable degradedAction) throws IOException { synchronized (monitor) { FileLock fileLock = acquireFileLockWithTimeout(); if (fileLock == null) { // Codex#A:未持有跨进程锁(超时/中断/不可用/已销毁)→ 走降级动作(写唯一名,消灭共享) degradedAction.run(); return; } try { lockedAction.run(); } finally { releaseQuietly(fileLock); } } } /** * writer 语义便捷重载:降级动作与持锁动作<b>相同</b>。 * * <p>适用于收尾等"操作对象本就是唯一名/owner-token 名"的调用方(如 {@link AuditSpoolReplayer} 的 * {@code finishReplay}:写 {@code .retry}/{@code .bad}、删 owner-token 的 {@code .replaying.<uuid>} * 都是唯一名,仅进程内锁即安全,无需 FileLock 也可正确落地)。 */ void lockAndRun(IoRunnable action) throws IOException { lockAndRun(action, action); } /** * replayer 语义:FileLock 被<b>别的进程</b>持有则跳过(返回 {@code false},不执行 {@code action},下轮再来)。 * * <p>顺序:进程内 {@link #monitor} → 已销毁则直接拒绝 → 非阻塞 {@link FileChannel#tryLock()} → 执行 → 倒序释放。 * 不支持 FileLock(降级态)→ 仅进程内锁执行并返回 {@code true}(仅单实例拓扑安全,见类 Javadoc)。 * * <p><b>Codex 四轮 Critical#1(destroyed 态认领未拒绝)</b>:旧版 destroyed 与 fileLockDisabled 走同一 * 分支——仅进程内锁执行 {@code action} 并返回 {@code true},即停机后仍会"认领"。认领/孤儿回收是可延后 * 到下一轮或下一个存活实例处理的操作,停机窗口内没有必要、也不应该再对 spool 文件动手,故 destroyed * 单独判断、直接返回 {@code false}、<b>不执行 action</b>——与"必须始终执行"的写入语义 * {@link #lockAndRun(IoRunnable, IoRunnable)} 明确区分开。 * * @return {@code true}=已执行(拿到 FileLock 或已降级);{@code false}=FileLock 被他进程持有本轮跳过, * 或已销毁直接拒绝 * @throws IOException {@code action} 抛出的 IO 异常 */ boolean tryLockAndRun(IoRunnable action) throws IOException { synchronized (monitor) { if (destroyed) { // Codex 四轮 Critical#1:destroyed 后直接拒绝认领,不执行 action、不退化为仅进程内锁执行。 warnPostDestroyOnce(); return false; } ensureChannelOpen(); if (fileLockDisabled || channel == null) { // fileLockDisabled(不支持 FileLock 的文件系统)保持原有退化行为:仅进程内锁执行 action。 // 该退化路径仅在"单实例拓扑"下安全——多实例共享同一 spool 卷时跨进程互斥完全失效, // 需运维保证不支持 FileLock 的文件系统只单实例部署(既有建议,见类 Javadoc)。 action.run(); return true; } FileLock fileLock; try { fileLock = channel.tryLock(); // 非阻塞,整文件独占 } catch (OverlappingFileLockException overlap) { // 理论不可达:monitor 已串行化同 JVM,FileLock 请求不会重叠。防御性当作"被占"跳过本轮。 return false; } catch (IOException unsupported) { markDegraded(unsupported); // 不支持的 FS 等 → 降级,仅进程内锁执行 action.run(); return true; } if (fileLock == null) { return false; // 别的进程持有 → 跳过本轮 } try { action.run(); return true; } finally { releaseQuietly(fileLock); } } } /** * 带超时重试获取 FileLock;拿不到返回 {@code null}(调用方走降级动作)。调用方必须持有 {@link #monitor}。 */ private FileLock acquireFileLockWithTimeout() { ensureChannelOpen(); if (fileLockDisabled || destroyed || channel == null) { if (destroyed) { warnPostDestroyOnce(); } return null; // 已降级/已销毁/未开 channel:调用方走降级动作 } long deadline = System.currentTimeMillis() + WRITER_LOCK_WAIT_MS; while (true) { try { FileLock lock = channel.tryLock(); if (lock != null) { return lock; } } catch (OverlappingFileLockException overlap) { // 理论不可达(monitor 串行化);当作瞬时,落入重试/超时逻辑 } catch (IOException unsupported) { markDegraded(unsupported); // 不支持 → 降级 return null; } if (System.currentTimeMillis() >= deadline) { // 超时 → 走降级动作(唯一名应急段,审计事件宁可多文件也不丢);不置 fileLockDisabled(他进程本次持有,属瞬时) warnDegradeOnce("FileLock 获取超时(" + WRITER_LOCK_WAIT_MS + "ms),本次写入降级为唯一名应急段"); return null; } try { Thread.sleep(RETRY_SLEEP_MS); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return null; // 被中断 → 走降级动作(唯一名应急段),尽快完成写入 } } } /** * 惰性打开 {@code spool.lock} 常驻句柄;打开失败即降级。<b>已销毁则拒绝重开</b>(Codex#D)。 * 调用方必须持有 {@link #monitor}。 */ private void ensureChannelOpen() { if (channel != null || fileLockDisabled || destroyed) { return; // Codex#D:destroyed 时不重开 channel,迟到调用走进程内锁 } try { if (!spoolDir.exists()) { Files.createDirectories(spoolDir.toPath()); } raf = new RandomAccessFile(lockFile, "rw"); channel = raf.getChannel(); } catch (IOException openFail) { markDegraded(openFail); // 无法打开锁文件 → 仅进程内锁 } } /** 置永久降级标志并 warn 一次。调用方必须持有 {@link #monitor}。 */ private void markDegraded(Throwable cause) { fileLockDisabled = true; warnDegradeOnce("跨进程 FileLock 不可用,降级(写入走唯一名应急段/重放走仅进程内锁): " + cause); } /** warn 去重。调用方必须持有 {@link #monitor}。 */ private void warnDegradeOnce(String msg) { if (!degradeWarned) { degradeWarned = true; log.warn("[audit] spool 锁降级: {}", msg); } } /** Codex#D:已销毁后迟到调用的 warn 去重。调用方必须持有 {@link #monitor}。 */ private void warnPostDestroyOnce() { if (!postDestroyWarned) { postDestroyWarned = true; log.warn("[audit] spool 锁已销毁,迟到调用按路径降级(写入路径=仅进程内锁执行," + "认领路径=直接拒绝,不再重开跨进程 channel)"); } } private void releaseQuietly(FileLock fileLock) { if (fileLock == null) { return; } try { fileLock.release(); } catch (IOException releaseFail) { log.warn("[audit] spool FileLock 释放失败: {}", releaseFail.toString()); } } /** * Codex#D:置终止位、释放跨进程句柄。置 {@link #destroyed} 后 {@link #ensureChannelOpen} 永不再重开 * channel,迟到的收尾/写入调用只走进程内锁(不会因 {@code ensureChannelOpen} 复活一个停机后仍持有的跨进程锁)。 */ @Override public void destroy() { synchronized (monitor) { destroyed = true; closeQuietly(channel); closeQuietly(raf); channel = null; raf = null; } } private static void closeQuietly(Closeable c) { if (c != null) { try { c.close(); } catch (IOException ignore) { // 停机释放,忽略 } } } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditSpoolReplayer.java
New file @@ -0,0 +1,592 @@ package jnpf.audit.sdk; import jnpf.audit.model.AuditEventDTO; import jnpf.util.JsonUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * spool 重放器(Task 7 Step 3;评审#3:spool 必须可恢复才算兜底)。 * * <p>周期性扫描 spool 目录的 {@code .jsonl} / {@code .retry} 文件 → 原子重命名加 * {@code .replaying.<owner-token>} 后缀(认领,token 每次新生成,见"#B+#C"段)→ 逐行反序列化走 * {@link AuditEventPublisher#deliverOnce(AuditEventDTO)} * (成功丢弃该行、抛异常保留)→ 全部成功删 {@code .replaying};部分失败把剩余行写入 * {@code <稳定 base 文件名>.<UUID>.retry} 新文件(base+UUID 保证同轮多文件失败不撞名,终验五轮⑤; * base 名的取法见下文"内审 Important 修复"段 {@link #stableBaseName(String)}), * {@code .retry} 写成功后删对应 {@code .replaying}(不留孤儿);<b>绝不写回活动原名</b>(终验四轮③: * 重命名期间写入端可能已在原名新建文件追加新事件,写回原名会覆盖丢数据)。落库幂等(client_event_id) * 使重放天然安全。 * * <p>调度手段:自建单线程 daemon {@link ScheduledExecutorService}(非 @Scheduled),不依赖宿主 * 是否开启 @EnableScheduling,生命周期由本 bean 自控(见 task-7-report 决策说明)。 * 由 {@code audit.spool-replay-enabled}(默认 true)开关。 * * <p><b>双层锁协议(Codex 批次二复验 #1/#2 终局修复)</b>:与 {@link AuditSpoolWriter} 共享同一 * {@link AuditSpoolLock}(进程内 monitor + 跨进程 {@code spool.lock} FileLock)。快速文件操作在锁内、 * 慢速网络重放在锁外,临界区划分: * <ol> * <li><b>认领</b>({@link #replayFile}):{@code rename→.replaying.<owner-token>} + {@code setLastModified} * 刷新租约在<b>同一临界区</b>——#1 与 writer 的 append 互斥(消除"取走文件瞬间"与"正在 append"的窗口 * 竞态);#2 rename 与刷新原子成对(消除"rename 成功后、刷新 mtime 前被孤儿回收误抢"的窗口)。 * 临界区内<b>先锁内重验</b>(#B)再 rename。replayer 路径 {@code tryLock} 拿不到就跳过本轮,下轮再来。</li> * <li><b>readAllLines</b>:锁外——认领后 {@code .replaying} 已不在任何写入端/扫描集视线内(孤儿回收 * 10 分钟阈值 + 刚刷新的 mtime 保护),读取安全且不阻塞 writer。</li> * <li><b>逐行 deliverOnce</b>:锁外(网络慢操作)。</li> * <li><b>收尾</b>({@link #finishReplay}):写 {@code .bad} / 写 {@code .retry} / 删 {@code .replaying} * 在锁内(各自快操作)。</li> * <li><b>孤儿回收 rename</b>({@link #recoverOrphanedReplaying}):检查+rename 在锁内。</li> * </ol> * 60 秒静默窗口({@link #QUIET_WINDOW_MS})<b>保留为纯优化</b>(减少活跃文件搅动),非正确性依赖—— * 正确性已由锁协议保证。<b>残余</b>:单文件重放超 10 分钟仍可能被其它进程当孤儿抢走 → 后果=重复重放, * 落库按 client_event_id 幂等保证不丢不重入库,可接受;{@code spool.lock} 永不参与扫描/重放/删除 * (扫描过滤已按 {@link AuditSpoolLock#LOCK_FILE_NAME} 排除)。 * * <p><b>裁定修复 D(孤儿 .replaying 恢复)</b>:进程若在"改名成 .replaying"之后、"处理完"之前崩溃, * 会留下孤儿——扫描集固定为 {@code {.jsonl,.retry}},永远不会认领它,事件永久滞留。每轮扫描时, * 额外把<b>修改时间早于(当前时刻 - 10 分钟,即两个调度周期)</b>的 .replaying 文件原子改名为 * {@code <稳定 base 文件名>.<UUID>.retry}(锁内),回归 retry 通道由下一轮自然消化;不在回收动作 * 内直接处理文件内容,避免与"本实例/并发实例正在重放中的文件"混淆。年龄阈值即为防止误抢:正常一轮 * 处理远快于一个调度周期,若 .replaying 存活跨越两个周期才可判定为孤儿。落库按 client_event_id * 幂等,重复重放安全。 * * <p><b>Codex 批次二三轮 #B+#C(认领名 owner-token 化 + 锁内重验)</b>:认领 rename 目标由固定后缀 * {@code .replaying} 改为 owner-token 化的 {@code .replaying.<ownerUUID>}(token 每次认领新生成, * {@link #REPLAYING_TOKEN_SUFFIX} 识别)。收尾/删除只操作本 owner token 的路径——陈旧 owner(被抢后收尾) * 与新 owner 永不同名,{@code deleteIfExists} 物理上不可能删掉新 owner 刚认领的文件(消除 #C 固定名跨 * owner 冲突)。同时认领({@link #replayFile})与孤儿回收({@link #recoverOrphanedReplaying})都改为 * <b>锁内先重验</b>(exists + mtime 仍超阈/仍静默)再 rename——锁外 {@code listFiles} 预筛只作候选收集, * 消除"预筛后、锁内 rename 前,路径被删除并复用为新文件 → 旧检查结果误搬新文件"的窗口(#B)。 * {@code setLastModified} 失败保留 warn+继续:owner-token 唯一名下租约失败的最坏后果=被孤儿回收→重复重放 * (幂等吸收),不再有丢失路径。 * * <p><b>Codex 批次二三轮 #D(优雅退出)</b>:{@link #destroy()} 在 {@code shutdownNow} 后 * {@code awaitTermination}({@link #SHUTDOWN_AWAIT_MS}) 等待重放线程真正退出(超时 warn),与 * {@link AuditSpoolLock} 的终止位配合,避免"停机返回但重放线程仍在锁内收尾"。 * * <p><b>内审 Important 修复(retry 文件名收敛,防 NAME_MAX 无界增长)</b>:部分失败写 {@code .retry}、 * 孤儿回收改名、坏行隔离写 {@code .bad},前缀都先经 {@link #stableBaseName(String)} 从文件名剥离<b>所有</b> * {@code .<UUID>.retry} 层与 {@code .replaying.<UUID>} token 后缀还原出稳定 base(如 {@code audit-events-20260723.jsonl}), * 再拼<b>恰好一层</b>新 UUID,文件名长度从此有界,不随失败轮次增长。UUID 段用 {@link #RETRY_LAYER_SUFFIX} * 严格匹配标准 8-4-4-4-12 十六进制格式并锚定字符串末尾,不会误剥业务文件名自身携带的点段(如 {@code .jsonl})。 * (Codex 五轮起:{@link #recoverOrphanedWriting()} 的隔离改名不再走本机制——直接在原名后追加 * {@code .stale},见其专属段;{@code .retry.writing} 层剥离分支保留作通用防御,当前无调用方会触发。) * * <p><b>坏行隔离与脱敏(Codex 批次二 #3/#4)</b>:反序列化失败的坏行不静默丢、不进 remaining,收尾时批量 * 隔离进 {@code <稳定 base>.<UUID>.bad}(不参与任何自动重放,留人工修复补录,见 {@link #finishReplay}); * 坏行 ERROR 日志只记文件名/行号/行长/内容 SHA-256 前 12 位({@link #sha256Prefix12}),绝不输出行内容正文。 * * <p><b>写后原子发布孤儿回收(Codex 四轮 Critical#1)</b>:{@link AuditSpoolWriter} 降级路径改为「先写 * 唯一名 {@code .writing} 临时文件、再原子改名发布为 {@code .retry}」(消灭跨进程半写窗口,见其类 * Javadoc)。若 writer 恰好崩溃于两步之间(或改名本身因极端 IO 异常失败),会遗留 * {@code <稳定base>.<UUID>.retry.writing} 孤儿——扫描集固定排除 {@code .writing}(见 * {@link #replayOnce()}),主流程永远不会认领它。每轮额外调用 {@link #recoverOrphanedWriting()} * 处理这类孤儿;具体动作见下一段——Codex 五轮复验推翻了本段最初"回归 .retry 通道当正常事件重放"的方案。 * * <p><b>Codex 五轮修复(.writing 隔离区,不能仅凭年龄自动发布)</b>:四轮版本曾把超龄 * {@code .retry.writing} 直接改名回归 {@code .retry} 通道当正常事件重放——Codex 五轮复验指出这不安全: * degraded writer(未持 FileLock 的降级路径)若恰好在其唯一一次 {@link Files#write} 调用<b>内部</b> * 被操作系统/磁盘 IO 阻塞超过 10 分钟,该 writer 线程仍然存活、仍持有该文件的打开 FD,只是尚未返回; * 此时它的 {@code .writing} 文件在孤儿回收眼中已"超龄",若照旧改名发布为 {@code .retry},重放器会把它 * 当正常事件认领(rename→{@code .replaying})、读到空文件(内容还没真正落盘)、判定"处理完毕"并删除 * {@code .replaying};随后卡住的 write 调用恢复,向着已被 unlink 的 inode 追加——数据静默消失,不留 * 任何错误痕迹。半行截断时同理:坏行隔离机制只能吸收"取走时刻"已经写入的前缀,写调用尚未完成的后半段 * 一样会丢。 * * <p>结论:<b>超龄不足以断言 writer 已死</b>,{@link #recoverOrphanedWriting()} 因此只做<b>隔离</b>、 * 绝不发布、绝不删除——锁内重验通过后把 {@code .retry.writing} 原子改名为 {@code <原名>.stale}(同 * 目录),{@code log.error} 告警(脱敏:仅记文件名/大小/mtime,不含内容)后即止步:不进 {@code .retry} * 通道(不会被重放器认领)、不进 {@link #finishReplay} 收尾(不会被删除)。{@code .stale} 排除在一切 * 扫描/认领之外——主扫描({@link #replayOnce()})只认 {@code .jsonl}/{@code .retry}, * {@link #recoverOrphanedReplaying()} 只认 {@code .replaying.<uuid>},{@link #recoverOrphanedWriting()} * 自身只认 {@code .retry.writing},三者的后缀判定都不会匹配以 {@code .stale} 收尾的文件名,故隔离后的 * 文件物理上不可能被本类任何逻辑再次触碰;也无需纳入 {@link #stableBaseName(String)} 的剥层范围—— * {@code .stale} 从不参与任何"新建 retry/bad 文件"的前缀推导。 * * <p>安全性论证的核心是 POSIX {@code rename(2)} 的语义:<b>rename 不 unlink</b>——它只改变目录项指向的 * 名字,被卡住的 write 调用持有的是对 inode 的 FD,与路径名无关,恢复后仍会把内容完整写入<b>同一个 * inode</b>(现在挂在 {@code .stale} 这个名字下),不会因改名而丢失或写偏。事件因此保全在隔离区,等待 * 人工核查后手工补录(对照 {@code clientEventId} 去重);隔离区本身永不被任何自动化流程删除,因此 * "重放器把仍在写的文件当孤儿抢走→空读→删除→静默丢失"这条路径被<b>物理消除</b>(不是靠时序窗口收窄, * 而是代码上根本不存在能删除 {@code .stale} 的路径)。writer 侧针对"发布源被隔离"的自愈见 * {@link AuditSpoolWriter} 类 Javadoc"Codex 五轮修复(自愈重发)"段——move 目标源被隔离后不会让事件 * 真的丢,只会在 {@code .stale} 里留一份至多被人工补录时重复的副本(落库按 {@code clientEventId} 幂等 * 吸收)。 */ @Slf4j public class AuditSpoolReplayer implements InitializingBean, DisposableBean { private static final long DEFAULT_REPLAY_INTERVAL_MS = 300_000L; // spec: fixedDelay 5 分钟(默认,可经 audit.spool-replay-interval-ms 覆盖) private static final String REPLAYING_SUFFIX = ".replaying"; // 裁定修复 D:孤儿 .replaying.<uuid> 年龄阈值 = 两个默认调度周期(10 分钟),防止误抢并发实例正在处理的文件。 // 刻意锚定 DEFAULT_REPLAY_INTERVAL_MS 而非实例调度周期:孤儿年龄阈值是"多久才敢判定文件被崩溃遗弃"的 // 崩溃恢复安全底线,与"多久轮询一次"是两个关注点——把轮询周期调短(如测试环境 15s 加速验证)不应连带 // 缩小这条安全窗口,否则一次略慢的正常重放就可能被误判为孤儿并被并发实例抢走(幂等吸收但徒增搅动)。 private static final long ORPHAN_REPLAYING_AGE_MS = 2 * DEFAULT_REPLAY_INTERVAL_MS; // Codex 批次二 #1:活动 .jsonl 静默窗口——距今不足 60 秒本轮跳过。锁协议落地后此窗口降级为纯优化 // (减少活跃文件搅动),非正确性依赖;正确性由 AuditSpoolLock 双层锁保证 private static final long QUIET_WINDOW_MS = 60_000L; // 内审 Important 修复:精确匹配"尾部恰好一层 <UUID>.retry",UUID 严格按 8-4-4-4-12 十六进制格式 // 锚定字符串末尾($),避免误剥业务文件名自身携带的点段(如 audit-events-20260723.jsonl 的 .jsonl) private static final Pattern RETRY_LAYER_SUFFIX = Pattern.compile( "\\.[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\.retry$"); // Codex 批次二三轮 #B+#C:认领名 owner-token 化——rename 目标 <src原名>.replaying.<ownerUUID>, // 每次认领新生成 owner token。孤儿匹配/stableBaseName 剥离都以本模式识别 .replaying.<uuid> 层, // 锚定字符串末尾($),UUID 严格 8-4-4-4-12 十六进制格式 private static final Pattern REPLAYING_TOKEN_SUFFIX = Pattern.compile( "\\.replaying\\.[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); // Codex 四轮 Critical#1:AuditSpoolWriter 降级路径写后原子发布的临时名 <稳定base>.<UUID>.retry.writing—— // 崩溃/rename 失败会遗留孤儿,本模式供扫描排除与 recoverOrphanedWriting() 孤儿回收识别, // 锚定字符串末尾($),UUID 严格 8-4-4-4-12 十六进制格式 private static final Pattern RETRY_WRITING_SUFFIX = Pattern.compile( "\\.[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\.retry\\.writing$"); // Codex 五轮:recoverOrphanedWriting() 隔离目标后缀——只隔离不发布不删除。任何以此结尾的文件名都不再 // 匹配 RETRY_WRITING_SUFFIX/REPLAYING_TOKEN_SUFFIX(二者均要求恰好以 .retry.writing / .replaying.<uuid> // 收尾),也不以 .jsonl/.retry 结尾,故隔离后天然不会被本类任何扫描逻辑再次命中,见类 Javadoc"Codex 五轮修复"段 private static final String STALE_SUFFIX = ".stale"; // Codex 批次二三轮 #D:destroy 时等待重放线程退出的上限 private static final long SHUTDOWN_AWAIT_MS = 5_000L; private final AuditEventPublisher publisher; private final File spoolDir; private final boolean enabled; private final AuditSpoolLock spoolLock; // 调度周期:默认 DEFAULT_REPLAY_INTERVAL_MS(5 分钟),可经 audit.spool-replay-interval-ms 覆盖。 // 仅影响"多久轮询一次 spool",不影响孤儿年龄阈值(ORPHAN_REPLAYING_AGE_MS,固定安全底线,见其注释)。 // 生产默认不动;测试环境可调短(如 15s)加速降级链恢复重放验证。 private final long replayIntervalMs; private ScheduledExecutorService scheduler; public AuditSpoolReplayer(AuditEventPublisher publisher, String spoolDir, boolean enabled, AuditSpoolLock spoolLock) { this(publisher, spoolDir, enabled, spoolLock, DEFAULT_REPLAY_INTERVAL_MS); } public AuditSpoolReplayer(AuditEventPublisher publisher, String spoolDir, boolean enabled, AuditSpoolLock spoolLock, long replayIntervalMs) { this.publisher = publisher; this.spoolDir = new File(spoolDir); this.enabled = enabled; this.spoolLock = spoolLock; // 兜底:非正数(配置笔误/0)回落默认周期,绝不让 scheduleWithFixedDelay 收到非法周期 this.replayIntervalMs = replayIntervalMs > 0 ? replayIntervalMs : DEFAULT_REPLAY_INTERVAL_MS; } @Override public void afterPropertiesSet() { if (!enabled) { log.info("[audit] spool replayer 已禁用 (audit.spool-replay-enabled=false)"); return; } scheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "audit-spool-replayer"); t.setDaemon(true); return t; }); // initialDelay 取与周期一致:避免启动瞬间与业务争 IO;遗留 spool 至多延迟一个周期被重放 scheduler.scheduleWithFixedDelay(this::replaySafely, replayIntervalMs, replayIntervalMs, TimeUnit.MILLISECONDS); } @Override public void destroy() { if (scheduler == null) { return; } // Codex 批次二三轮 #D:shutdownNow 后等待重放线程真正退出,避免"停机返回但重放线程仍在锁内/ // 正调 AuditSpoolLock 收尾"的窗口——与 AuditSpoolLock.destroy 的终止位配合优雅退出。 scheduler.shutdownNow(); try { if (!scheduler.awaitTermination(SHUTDOWN_AWAIT_MS, TimeUnit.MILLISECONDS)) { log.warn("[audit] spool 重放线程 {}ms 内未退出(可能卡在 IO/网络重放中)", SHUTDOWN_AWAIT_MS); } } catch (InterruptedException ie) { Thread.currentThread().interrupt(); // 恢复中断标记后返回 } } private void replaySafely() { try { replayOnce(); } catch (Throwable t) { // 调度线程抛异常会导致 scheduleWithFixedDelay 停摆,后续轮次不再执行——必须吞住 log.error("[audit] spool 重放轮次异常", t); } } void replayOnce() { if (!spoolDir.isDirectory()) { return; } recoverOrphanedReplaying(); // Codex 四轮 Critical#1:孤儿 .retry.writing 回收——AuditSpoolWriter 降级路径写后原子发布中断 // (崩溃/rename 失败)遗留的临时文件,扫描集固定排除 .writing,永远不会被下面的主流程认领 recoverOrphanedWriting(); // 扫描集只含 .jsonl/.retry,显式排除 spool.lock(锁文件永不参与扫描/重放)与 .writing // (Codex 四轮 Critical#1:写后原子发布的临时文件,内容可能不完整,绝不能被本扫描直接命中; // Codex 五轮起,超龄 .writing 孤儿只会被 recoverOrphanedWriting() 隔离为 .stale,不再回归本扫描集)。 // .stale(隔离区,见类 Javadoc"Codex 五轮修复"段)天然不以 .jsonl/.retry 结尾,被本谓词排除, // 无需额外条件 File[] targets = spoolDir.listFiles((dir, name) -> !name.equals(AuditSpoolLock.LOCK_FILE_NAME) && !name.endsWith(".writing") && (name.endsWith(".jsonl") || name.endsWith(".retry"))); if (targets == null) { return; } long now = System.currentTimeMillis(); for (File src : targets) { // Codex 批次二 #1:60 秒静默窗口——距今不足 60 秒的 .jsonl 视为写入端可能活跃,本轮跳过, // 下一轮再认领(<0.5 QPS,延迟一周期无害)。锁协议落地后此跳过仅为优化(减少活跃文件搅动), // 即便不跳过、正确性也由 AuditSpoolLock 的 append↔rename 互斥保证。.retry 由本重放器自建、 // 无外部写入端,不受此限;.bad 本就不在扫描集(且从不重放)。 if (src.getName().endsWith(".jsonl") && now - src.lastModified() < QUIET_WINDOW_MS) { continue; } replayFile(src); } } /** * 内审 Important 修复:从文件名剥离<b>所有</b> {@code .<UUID>.retry} 层与 {@code .replaying.<UUID>} token 后缀, * 还原出稳定 base name(如 {@code audit-events-20260723.jsonl}),见类注释"内审 Important 修复"段。 * * <p>剥离顺序:先去掉末尾 owner-token 化的 {@code .replaying.<UUID>} 后缀(认领时最后追加, * 是最外层,最多一层,用 {@link #REPLAYING_TOKEN_SUFFIX} 匹配),再去掉 {@link AuditSpoolWriter} * 降级路径写后原子发布的临时层 {@code .<UUID>.retry.writing}(Codex 四轮 Critical#1, * {@link #RETRY_WRITING_SUFFIX} 匹配,同样最多一层、与 replaying token 互斥不共存),最后循环剥离 * 末尾的 {@code .<UUID>.retry} 层,直到不再匹配为止(病态场景下可能有多层,历史累积的每一层都会被剥掉)。 * UUID 段用 {@link #RETRY_LAYER_SUFFIX}/{@link #REPLAYING_TOKEN_SUFFIX}/{@link #RETRY_WRITING_SUFFIX} * 严格匹配标准 8-4-4-4-12 十六进制格式并锚定字符串末尾,不会误剥业务文件名本身自带的点段。 * * @param name 原始文件名(可能已带若干层 retry 后缀 + 一层 replaying token 或一层 writing 临时层, * 也可能是从未重放过的原始文件名) * @return 剥离干净的稳定 base name */ private String stableBaseName(String name) { String base = name; Matcher replayingMatcher = REPLAYING_TOKEN_SUFFIX.matcher(base); if (replayingMatcher.find()) { base = base.substring(0, replayingMatcher.start()); } // Codex 四轮 Critical#1:.retry.writing 只会是 AuditSpoolWriter 降级路径追加的最外层(写后原子 // 发布的临时名,从不嵌套),与 .replaying.<uuid> 互斥同时出现,故也只剥最多一层 Matcher writingMatcher = RETRY_WRITING_SUFFIX.matcher(base); if (writingMatcher.find()) { base = base.substring(0, writingMatcher.start()); } Matcher matcher; while ((matcher = RETRY_LAYER_SUFFIX.matcher(base)).find()) { base = base.substring(0, matcher.start()); } return base; } /** * 裁定修复 D:回收崩溃遗留的 .replaying 孤儿文件,见类注释。 * * <p>只对"修改时间早于 now - {@link #ORPHAN_REPLAYING_AGE_MS}"的 {@code .replaying.<uuid>} 生效—— * 年龄阈值防止误抢并发实例(或本实例本轮稍早)正在处理中的文件;检查+rename 在 * {@link AuditSpoolLock} 临界区(锁协议临界区 5)完成,交回 {@code .retry} 通道由本轮或下一轮 * {@link #replayFile(File)} 正常消化,本方法不直接读取/处理文件内容。 * * <p><b>Codex 批次二三轮 #B(锁内重验)</b>:锁外 {@code listFiles} 预筛只作候选收集;预筛与锁内 * rename 之间,该路径可能已被删除并复用为一个<b>新</b>文件(同名巧合),若沿用预筛结果直接搬走会把 * 新文件误当旧孤儿。故锁内 rename 前<b>重新确认</b> orphan 仍存在且 mtime 仍超阈值,才执行 rename。 */ private void recoverOrphanedReplaying() { File[] orphans = spoolDir.listFiles((dir, name) -> !name.equals(AuditSpoolLock.LOCK_FILE_NAME) && REPLAYING_TOKEN_SUFFIX.matcher(name).find()); if (orphans == null || orphans.length == 0) { return; } long now = System.currentTimeMillis(); for (final File orphan : orphans) { if (now - orphan.lastModified() < ORPHAN_REPLAYING_AGE_MS) { continue; // 锁外预筛:未超龄,可能正被处理中,本轮不碰 } String orphanName = orphan.getName(); // 内审 Important 修复:先归一到稳定 base 再拼恰好一层新 UUID,防止孤儿在已累积多层 // 后缀的名字上继续追加、文件名无界增长 final File retry = new File(orphan.getParentFile(), stableBaseName(orphanName) + "." + UUID.randomUUID() + ".retry"); final boolean[] moved = {false}; try { // 锁协议临界区 5:检查+rename 在锁内,与并发实例/本进程认领互斥;tryLock 拿不到则本轮跳过该孤儿 boolean lockRun = spoolLock.tryLockAndRun(() -> { // Codex#B 锁内重验:预筛后路径可能已被删除并复用为新文件,重新确认仍存在且仍超龄才搬走 if (!orphan.exists()) { return; } if (System.currentTimeMillis() - orphan.lastModified() < ORPHAN_REPLAYING_AGE_MS) { return; } Files.move(orphan.toPath(), retry.toPath(), StandardCopyOption.ATOMIC_MOVE); moved[0] = true; }); if (lockRun && moved[0]) { log.warn("[audit] 回收孤儿 .replaying 文件: {} -> {}", orphanName, retry.getName()); } // lockRun=false(他进程持锁)或 moved=false(锁内重验未过):本轮跳过该孤儿,下轮再判 } catch (IOException moveFail) { // 改名失败:可能已被其他实例/本轮稍早处理,下一轮再判一次 log.warn("[audit] 孤儿 .replaying 回收改名失败,下轮再试: {}", orphanName); } } } /** * Codex 五轮修复:回收 {@link AuditSpoolWriter} 降级路径「写后原子发布」中断遗留的 * {@code <稳定base>.<UUID>.retry.writing} 孤儿——<b>只隔离,永不发布为 .retry,永不删除</b>。 * * <p>四轮版本曾把超龄孤儿直接改名回归 {@code .retry} 通道当正常事件重放,Codex 五轮复验指出这不安全: * 超龄(mtime 早于 {@link #ORPHAN_REPLAYING_AGE_MS})只能说明"这个文件很久没有被更新",不能证明 * writer 已经死亡——它可能仍卡在那唯一一次 {@link java.nio.file.Files#write} 调用内部(磁盘/网络 * 存储 IO 阻塞),FD 仍打开、进程仍存活。若照旧回归 {@code .retry} 通道,重放器会认领→读到空文件 * (或崩溃语义下的半行前缀)→判定处理完毕→删除;卡住的 write 恢复后写向已 unlink 的 inode,数据 * 静默消失,且不留任何错误日志。完整论证见类 Javadoc"Codex 五轮修复(.writing 隔离区)"段。 * * <p>修复后动作:锁内重验(exists + 仍超龄,防止误抢正在写入中的文件)通过后,把 {@code .retry.writing} * <b>原子改名为 {@code <原名>.stale}</b>(同目录)并 {@code log.error} 告警(脱敏:仅文件名/大小/mtime, * 不含内容)——不解析内容、不写回任何可被扫描/认领的名字、不删除。{@code rename} 不 unlink:即便该 * writer 随后真的恢复并把内容写完,也是写进同一个 inode(现名 {@code .stale}),内容不会因改名而 * 丢失,只是滞留隔离区待人工核查补录(对照 {@code clientEventId} 去重,落库天然幂等)。writer 侧针对 * "发布源被隔离"的自愈见 {@link AuditSpoolWriter} 类 Javadoc。 */ private void recoverOrphanedWriting() { // RETRY_WRITING_SUFFIX 只匹配以 ".<uuid>.retry.writing" 结尾的文件名——本方法产出的 .stale // 文件不再以此结尾,天然不会被下一轮扫描重新命中(见类 Javadoc"Codex 五轮修复"段) File[] orphans = spoolDir.listFiles((dir, name) -> !name.equals(AuditSpoolLock.LOCK_FILE_NAME) && RETRY_WRITING_SUFFIX.matcher(name).find()); if (orphans == null || orphans.length == 0) { return; } long now = System.currentTimeMillis(); for (final File orphan : orphans) { if (now - orphan.lastModified() < ORPHAN_REPLAYING_AGE_MS) { continue; // 锁外预筛:未超龄,可能正被 writer 写入中(或卡在 Files.write 内部),本轮不碰 } String orphanName = orphan.getName(); // Codex 五轮:只隔离,原名后追加 .stale——不发布为 .retry(不会被重放器认领)、不删除 // (.stale 排除在一切扫描之外,见类 Javadoc"Codex 五轮修复"段的完整论证) final File stale = new File(orphan.getParentFile(), orphanName + STALE_SUFFIX); final long[] quarantinedSize = {-1L}; final long[] quarantinedMtime = {-1L}; final boolean[] moved = {false}; try { // 锁内重验(同 recoverOrphanedReplaying 的 #B 模式):预筛后、锁内 rename 前,路径可能 // 已被 writer 完成发布并删除、或被别的实例先一步隔离,重新确认仍存在且仍超龄才动手 boolean lockRun = spoolLock.tryLockAndRun(() -> { if (!orphan.exists()) { return; } if (System.currentTimeMillis() - orphan.lastModified() < ORPHAN_REPLAYING_AGE_MS) { return; } quarantinedSize[0] = orphan.length(); quarantinedMtime[0] = orphan.lastModified(); Files.move(orphan.toPath(), stale.toPath(), StandardCopyOption.ATOMIC_MOVE); moved[0] = true; }); if (lockRun && moved[0]) { // log.error 而非 warn:这是需要人工核查补录的隔离事件,不是可自愈的瞬时状况 log.error("[audit] 超龄 .retry.writing 隔离至 .stale(不自动发布/不自动删除," + "需人工核查补录,见 AuditSpoolReplayer 类 Javadoc" + "\".writing 隔离区处置\"说明段): file={} size={}bytes mtime={}", stale.getName(), quarantinedSize[0], quarantinedMtime[0]); } // lockRun=false(他进程持锁)或 moved=false(锁内重验未过):本轮跳过该孤儿,下轮再判 } catch (IOException moveFail) { // 改名失败:可能已被其他实例/本轮稍早处理,下一轮再判一次 log.warn("[audit] 孤儿 .retry.writing 隔离改名失败,下轮再试: {}", orphanName); } } } private void replayFile(final File src) { // 锁协议临界区 2(认领):Codex 批次二 #1/#2/#B/#C。 // #B+#C 认领名 owner-token 化——每次认领新生成 ownerToken,rename 目标 <src原名>.replaying.<ownerToken>。 // 收尾/删除只操作本 owner token 的路径:陈旧 owner(被抢后收尾)与新 owner 永不同名, // deleteIfExists 物理上不可能误删他 owner 刚认领的同名文件(消除 Codex#C 固定名跨 owner 冲突)。 final File replaying = new File(src.getParentFile(), src.getName() + REPLAYING_SUFFIX + "." + UUID.randomUUID()); final boolean isJsonl = src.getName().endsWith(".jsonl"); final boolean[] renamed = {false}; // rename→.replaying.<owner> 与 setLastModified 刷新租约在同一临界区(进程内 monitor + 跨进程 FileLock): // #1:与 writer 的 append 互斥,消除"取走文件瞬间"与"正在 append"的窗口竞态; // #2:rename 与刷新原子成对,消除"rename 成功后、刷新 mtime 前被孤儿回收误抢"的窗口。 try { boolean lockRun = spoolLock.tryLockAndRun(() -> { // Codex#B 锁内重验:锁外预筛(quiet window)与锁内之间,src 可能已被他 owner 取走/删除。 // 锁内重新确认 src 仍存在(.jsonl 且仍在静默窗口内说明写入端可能活跃,让给下轮)才认领。 if (!src.exists()) { return; } if (isJsonl && System.currentTimeMillis() - src.lastModified() < QUIET_WINDOW_MS) { return; } Files.move(src.toPath(), replaying.toPath(), StandardCopyOption.ATOMIC_MOVE); // setLastModified 返回 false 仅告警不中断。Codex#C:owner-token 唯一名下,租约建立失败的最坏 // 后果=本文件超 10 分钟后被孤儿回收→重复重放(落库按 client_event_id 幂等吸收),不再有丢失路径。 if (!replaying.setLastModified(System.currentTimeMillis())) { log.warn("[audit] 认领后刷新 spool 租约 mtime 失败(最坏后果=重复重放,幂等吸收): {}", replaying.getName()); } renamed[0] = true; }); if (!lockRun) { // FileLock 被别的进程持有 → 本轮跳过该文件,下轮再认领 return; } if (!renamed[0]) { // 锁内重验未过(已被他 owner 取走/删除,或 .jsonl 仍活跃)→ 本轮跳过,下轮再判 return; } } catch (IOException moveFail) { // 改名失败(正被他进程/他轮取走)→ 跳过,下轮再试 log.warn("[audit] spool 文件占用改名失败,跳过: {}", src.getName()); return; } // 锁协议临界区外(readAllLines):认领后 .replaying 已被本进程独占,读取不阻塞 writer List<String> lines; try { lines = Files.readAllLines(replaying.toPath(), StandardCharsets.UTF_8); } catch (IOException readFail) { // 读失败不删 .replaying(避免读失败即丢数据),留待人工/下次进程处理 log.error("[audit] spool 读取失败,保留 .replaying: {}", replaying.getName(), readFail); return; } // 锁协议临界区外(逐行 deliverOnce=网络慢操作):把行分类为 remaining(投递失败待重试) // 与 badLines(反序列化失败待隔离),此循环不做任何 spool 文件写操作 List<String> remaining = new ArrayList<>(); List<String> badLines = new ArrayList<>(); for (int i = 0; i < lines.size(); i++) { String line = lines.get(i); if (line == null || line.trim().isEmpty()) { continue; } AuditEventDTO event; try { event = JsonUtil.getJsonToBean(line, AuditEventDTO.class); } catch (Exception parseFail) { // Codex 批次二 #3:坏行留待收尾批量隔离到 .bad(不进 remaining、不静默丢)。 // Codex 批次二 #4:日志只记文件名/行号/行长/内容 SHA-256 前 12 位 + 异常类名,绝不输出行正文 // (parseFail 只取类名——Jackson 异常正文可能内嵌源片段,故不整体打印)。 badLines.add(line); log.error("[audit] spool 坏行检出待隔离: file={} lineNo={} lineLen={} sha256_12={} cause={}", replaying.getName(), i + 1, line.length(), sha256Prefix12(line), parseFail.getClass().getName()); continue; } try { publisher.deliverOnce(event); // 成功→丢弃该行;失败→保留待下轮 } catch (Exception deliverFail) { // 好行投递失败(AuditDeliveryException)→ 保留待下轮,行为不变 remaining.add(line); } } // 锁协议临界区 4(收尾):写 .bad / 写 .retry / 删 .replaying 在锁内(各自快操作)。 // 用 writer 语义 lockAndRun(始终执行):投递已完成,收尾必须落地,避免留 .replaying 被重复重放 try { spoolLock.lockAndRun(() -> finishReplay(src, replaying, remaining, badLines)); } catch (IOException writeFail) { log.error("[audit] spool 重放收尾写盘失败,保留 .replaying: {}", replaying.getName(), writeFail); } } /** * 锁协议临界区 4(收尾):批量写坏行 {@code .bad}、写剩余行 {@code .retry}、删 {@code .replaying}。 * 全程由 {@link AuditSpoolLock#lockAndRun} 保护,仅做快速文件操作。 * * <p>Codex 批次二 #3:坏行批量隔离到 {@code <稳定 base>.<UUID>.bad}(不重放、不静默丢);写 {@code .bad} * 失败则该批坏行退回 remaining(宁可重试也不丢)。剩余行(含投递失败 + 隔离失败退回的坏行)非空则写 * {@code <稳定 base>.<UUID>.retry}(终验四轮③:绝不写回活动原名),否则删 {@code .replaying};有 remaining * 时也在 {@code .retry} 写成功后删 {@code .replaying}(不留孤儿)。 * * @throws IOException 写 {@code .retry} 或删 {@code .replaying} 失败——调用方据此保留 {@code .replaying} 待下轮 */ private void finishReplay(File src, File replaying, List<String> remaining, List<String> badLines) throws IOException { List<String> retryLines = new ArrayList<>(remaining); if (!badLines.isEmpty()) { try { writeNewSpoolFile(src, ".bad", badLines); } catch (IOException badFail) { // 写 .bad 失败:坏行退回重试(宁可重试也不丢);日志不含正文,只记文件名/条数 retryLines.addAll(badLines); log.warn("[audit] spool 坏行隔离写盘失败,退回重试: file={} count={}", replaying.getName(), badLines.size(), badFail); } } if (retryLines.isEmpty()) { Files.deleteIfExists(replaying.toPath()); // 全部成功:删 .replaying } else { // 部分失败:剩余行写 <稳定 base>.<UUID>.retry 新文件,绝不写回活动原名(终验四轮③) writeNewSpoolFile(src, ".retry", retryLines); Files.deleteIfExists(replaying.toPath()); // .retry 写成功后删 .replaying,不留孤儿 } } /** * 把 {@code lines} 写入一个新建的 {@code <稳定 base>.<UUID><suffix>} 文件({@code .retry} 或 {@code .bad})。 * 前缀先经 {@link #stableBaseName(String)} 归一,再拼恰好一层新 UUID,文件名长度有界(内审 Important 修复); * {@link StandardOpenOption#CREATE_NEW} 保证不覆盖既有文件,UUID 使撞名概率可忽略。 */ private void writeNewSpoolFile(File src, String suffix, List<String> lines) throws IOException { File out = new File(src.getParentFile(), stableBaseName(src.getName()) + "." + UUID.randomUUID() + suffix); StringBuilder sb = new StringBuilder(); for (String line : lines) { sb.append(line).append('\n'); } Files.write(out.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); } /** * Codex 批次二 #4:坏行内容的 SHA-256 十六进制<b>前 12 位</b>,供日志定位坏行而不泄露正文 * (spec "ERROR 日志不含字段值正文")。SHA-256 为 JDK 标配算法,{@link NoSuchAlgorithmException} * 理论不可达;万一不可达则回落到固定占位串,绝不回退成打印正文。 */ private static String sha256Prefix12(String line) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest(line.getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(12); for (byte b : digest) { hex.append(Character.forDigit((b >> 4) & 0xF, 16)); hex.append(Character.forDigit(b & 0xF, 16)); if (hex.length() >= 12) { break; } } return hex.substring(0, 12); } catch (NoSuchAlgorithmException e) { return "sha256-unavailable"; } } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditSpoolWriter.java
New file @@ -0,0 +1,178 @@ package jnpf.audit.sdk; import jnpf.util.JsonUtil; import lombok.extern.slf4j.Slf4j; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; import jnpf.audit.model.AuditEventDTO; /** * 本地兜底 spool 写入器(Task 7 Step 3):三级降级最后一级。 * * <p>按日追加一行 JSON 到 {@code ${audit.spool-dir}/audit-events-yyyyMMdd.jsonl}; * IO 失败仅 {@code log.error} + 丢弃计数,<b>绝不向业务抛异常</b>——审计落盘失败不能反噬业务。 * * <p><b>并发安全(Codex 批次二复验 #1,双层锁协议终局修复)</b>:{@link #write} 的 * "open(append)→写一行→flush→close" 整段搬进与 {@link AuditSpoolReplayer} 共享的 * {@link AuditSpoolLock} 临界区(进程内 monitor + 跨进程 {@code spool.lock} FileLock)。因此 * append 与重放器的"认领 rename"严格互斥——彻底消除"writer 经已打开 FD 向被改名 inode 写入、 * 事件随 {@code .replaying} 被删而永久丢失"的窗口竞态(原 60 秒静默窗口仅活跃度启发式,不足以保证正确性, * 现降级为纯优化)。{@link Files#write} 单次调用内即开-写-关(写入量 <0.5 QPS,每次开关流性能无虞), * 锁内只做这一快操作;重放器把活动文件 rename 走后,下一次 write 以 {@link StandardOpenOption#CREATE} * 在原名新建文件继续追加,天然不写回旧 inode(终验四轮③)。 * * <p><b>降级隔离(Codex 批次二三轮 #A:消灭共享名,杜绝降级路径 rename 竞态)</b>:跨进程 FileLock * 一时拿不到(超时/中断/不可用/已销毁)时,{@link AuditSpoolLock#lockAndRun(AuditSpoolLock.IoRunnable, AuditSpoolLock.IoRunnable)} * 不再让本次写入去<b>追加共享活动名</b>(那样会与其它进程的"认领 rename+删除"重演 #1 丢事件时序:无锁 * open 活动文件 → 他进程 rename+读 → 本进程向被改名 inode 写 → 他进程删 → 事件丢失),而是改走 * "应急段"路径:把该行写入唯一名文件(一事件一文件,仅降级场景、低 QPS 可接受),由重放器锁内认领 * 消费——宁多几个文件也不丢事件。进程内 monitor 全程持有(同 JVM 串行不变)。 * * <p><b>写后原子发布(Codex 四轮复验 Critical#1,消灭跨进程半写窗口)</b>:上一版把应急段直接用可扫描的 * {@code .retry} 名一步 {@link StandardOpenOption#CREATE_NEW} 建文件+写入——但"目录项创建可见"与 * "内容写完 close"之间存在窗口,另一进程的 {@link AuditSpoolReplayer} 按文件名扫描/认领可能落进这个 * 窗口:读到空文件 → 收尾把它当处理完删掉(token 丢失)而本进程仍持已打开 FD 向已被 unlink 的 inode * 追加 → 事件静默丢失;或读到半行 → 前缀被当坏行隔离进 {@code .bad},行内容本不完整却被判定"已处理"。 * 现改为<b>先写后发布</b>两步,rename 的原子性物理消灭这个窗口: * <ol> * <li>写入唯一名 {@code <活动名>.<UUID>.retry.writing}({@link StandardOpenOption#CREATE_NEW}, * {@link Files#write} 单次调用内写完 flush+close)——此时文件名不在重放器扫描集 * ({@code .retry}/{@code .jsonl})内,任何进程都不会认领它;</li> * <li>{@link Files#move} + {@link StandardCopyOption#ATOMIC_MOVE} 原子改名发布为 * {@code <活动名>.<UUID>.retry}——rename 是文件系统原子操作,{@code .retry} 这个可扫描名 * 从"不存在"直接跳到"内容完整存在",不存在中间态,跨进程认领窗口物理消失。</li> * </ol> * rename 因源文件已不存在而失败({@link NoSuchFileException})时,说明本次写入在上面第①步 * {@link Files#write} 调用<b>内部</b>被阻塞过久(磁盘/网络存储 IO 卡顿),导致 * {@link AuditSpoolReplayer#recoverOrphanedWriting()} 误判其超龄并把源文件隔离为 {@code .stale}—— * 此为 Codex 五轮修复的自愈场景,见下一段。其它 rename 失败(磁盘满、权限突变等极端 IO 异常)时仍只 * {@code log.error}(脱敏,仅记文件名与 {@code clientEventId},不记事件正文)、<b>保留 {@code .writing} * 文件不删、不重试</b>——内容已完整落盘,不计入 {@link #discardCount}(不算事件丢失);交由 * {@link AuditSpoolReplayer} 的孤儿回收兜底(mtime 超阈值后锁内重验+隔离为 {@code .stale},等待人工 * 核查补录,见其类 Javadoc)。 * * <p><b>Codex 五轮修复(自愈重发:源被隔离/挪走时重试一次)</b>:{@code .writing} 源文件被 * {@link AuditSpoolReplayer#recoverOrphanedWriting()} 隔离为 {@code .stale} 后,本次 rename 会抛 * {@link NoSuchFileException}——此时事件内容并未真的丢失(已完整落在 {@code .stale} 里,等待人工 * 核查),但本次投递尚未自动完成。为了不让审计事件仅仅因为一次极端的 IO 阻塞就必须依赖人工补录, * {@link #write} 在此情形下<b>只重试一次</b>:换一个新 {@code UUID} 重新走一遍「写 {@code .writing} * → move 发布为 {@code .retry}」两步协议(见 {@link #publishOnceOrRetryAfterQuarantine})。重试 * 成功——事件已正常投递到重放通道,{@code .stale} 里的旧副本至多在人工核查补录时造成一次重复,落库按 * {@code clientEventId} 幂等吸收,无害;重试仍以 {@code NoSuchFileException} 失败(理论上极罕见,需 * 连续两次不同 UUID 的文件都被隔离)则不再重试,{@code log.error}(含 {@code clientEventId})交由 * 人工核查 {@code .stale} 隔离区,原样保留其中已经写好的完整内容。 */ @Slf4j public class AuditSpoolWriter { private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyyMMdd"); private final File spoolDir; private final AuditSpoolLock spoolLock; private final AtomicLong discardCount = new AtomicLong(); public AuditSpoolWriter(String spoolDir, AuditSpoolLock spoolLock) { this.spoolDir = new File(spoolDir); this.spoolLock = spoolLock; } public void write(AuditEventDTO event) { try { String fileName = "audit-events-" + LocalDate.now().format(DAY) + ".jsonl"; Path path = new File(spoolDir, fileName).toPath(); byte[] bytes = (JsonUtil.getObjectToString(event) + "\n") // jsonl 规范用 \n .getBytes(StandardCharsets.UTF_8); // Codex 批次二复验 #1:持锁路径 open-append-flush-close 搬进共享临界区,与 replayer 的认领 rename 互斥 spoolLock.lockAndRun( // lockedAction:持有跨进程锁 → 正常追加共享活动文件 () -> { if (!spoolDir.exists()) { Files.createDirectories(spoolDir.toPath()); } Files.write(path, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND); }, // degradedAction(Codex 批次二三轮 #A + 四轮 Critical#1 + 五轮自愈):未持锁 → 写后 // 原子发布唯一名 .retry 应急段,消灭共享名/杜绝 rename 竞态、消灭跨进程半写窗口(见类 // Javadoc「写后原子发布」段);发布源被 AuditSpoolReplayer 孤儿隔离时自愈重试一次 // (见类 Javadoc「Codex 五轮修复(自愈重发)」段)。 () -> { if (!spoolDir.exists()) { Files.createDirectories(spoolDir.toPath()); } publishOnceOrRetryAfterQuarantine(fileName, bytes, event); }); } catch (Throwable t) { long n = discardCount.incrementAndGet(); log.error("AUDIT-SPOOL-LOST clientEventId={} 落盘失败,累计丢弃={}", event == null ? null : event.getClientEventId(), n, t); } } /** * degradedAction 主体:写后原子发布两步协议(写唯一名 {@code .writing} → move 发布为 * {@code .retry}),源被孤儿隔离({@link NoSuchFileException})时自愈<b>只重试一次</b>——见类 * Javadoc「写后原子发布」与「Codex 五轮修复(自愈重发)」两段的完整论证。 * * @throws IOException 非隔离原因的 rename 失败(磁盘满等)已在方法内 log.error 吸收并返回, * 仅 {@code Files.write} 本身失败(如磁盘已满导致写不进去)会外抛, * 交由 {@link #write} 的外层 catch 计入 {@link #discardCount} */ private void publishOnceOrRetryAfterQuarantine(String fileName, byte[] bytes, AuditEventDTO event) throws IOException { boolean retried = false; while (true) { String uuid = UUID.randomUUID().toString(); Path writing = new File(spoolDir, fileName + "." + uuid + ".retry.writing").toPath(); Path published = new File(spoolDir, fileName + "." + uuid + ".retry").toPath(); // 第一步:写唯一名 .writing 临时文件,CREATE_NEW+单次 write 调用内写完 flush+close Files.write(writing, bytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); try { // 第二步:原子改名发布为 .retry——rename 原子性使可扫描名下只可能出现完整内容 Files.move(writing, published, StandardCopyOption.ATOMIC_MOVE); return; // 发布成功 } catch (NoSuchFileException sourceGone) { // 源已被 AuditSpoolReplayer#recoverOrphanedWriting() 隔离为 .stale(本次写入曾在 // Files.write 内被阻塞超过孤儿回收阈值)——内容未丢,只是滞留隔离区。只重试一次: // 换新 UUID 重写一份再发布;.stale 里的旧副本至多造成人工补录时的重复(幂等吸收) if (retried) { log.error("AUDIT-SPOOL-RETRY-PUBLISH-FAIL file={} clientEventId={} " + "源文件已被隔离且重试后仍失败,需人工核查 .stale 隔离区补录", writing.getFileName(), event == null ? null : event.getClientEventId(), sourceGone); return; } retried = true; log.warn("[audit] degraded 发布源文件被隔离(写入曾阻塞超过孤儿回收阈值),自愈重写一次: " + "file={} clientEventId={}", writing.getFileName(), event == null ? null : event.getClientEventId()); // 继续循环:新 UUID 重写一份再尝试发布 } catch (IOException renameFail) { // 其它极端 IO 异常(磁盘满/权限突变等):内容已完整落在 .writing(上一步写入已成功), // 不算事件丢失——不计入 discardCount,保留 .writing 交孤儿回收兜底 log.error("AUDIT-SPOOL-RETRY-PUBLISH-FAIL file={} clientEventId={} " + "原子发布失败,内容已保留于 .writing 待孤儿回收", writing.getFileName(), event == null ? null : event.getClientEventId(), renameFail); return; } } } /** 累计因落盘 IO 失败而丢弃的事件数(观测用)。 */ public long discardCount() { return discardCount.get(); } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/AuditTxHolder.java
New file @@ -0,0 +1,210 @@ package jnpf.audit.sdk; import jnpf.audit.AuditApiConsts; import jnpf.audit.model.AuditEventDTO; import jnpf.util.RandomUtil; import org.slf4j.MDC; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * 事务级审计缓冲 holder(spec §3.3/§5.5 契约的最小实现)。 * * <p>职责:把同一事务内提交的审计事件缓冲到 afterCommit 才异步发出(回滚不发);operationId 的 * 生成与 MDC 写入统一归本类单源管理(终验#6:collector 先写 MDC 会破坏 previousMdc 语义)。 * * <p><b>operationId 语义(M2 spec §2)</b>:归属最外层的「操作作用域」({@link #beginOperation} * 返回的 OperationScope),作用域内的全部事务——含 REQUIRES_NEW 子事务——共享同一个 id; * 没有作用域时退化为每个事务自己一个 id。缓冲仍按事务划分(回滚不发的语义不变), * 只是「缓冲身份」与「操作身份」自此解耦。 * * <p>全类只有一条 id 规则:<b>决定 id 时先读 MDC,有值则沿用,无值才生成</b>—— * {@link #submit} 无事务分支、{@link #beginOperation} 无事务分支、{@link #createBuffer} * 三处一致,无例外。跨事务(REQUIRES_NEW)的沿用在挂起点由两条途径承接: * {@code previousMdc} 非 null 时靠它本身,为 null 时靠 {@code TxAuditBuffer.scopeOwned}—— * 两者的等价性依赖下面 {@link #createBuffer} 处标明的不变量。 * * <p>本类的时序注释对应事务级缓冲的并发时序契约,均经过验证——重构时不要随手删改, * 每一处都对应一个真实存在过的竞态场景。 * 回归守卫:{@code bash tasks/audit-opid-checks.sh}(十一项并发时序矩阵)。 * 平台雪花工具经现场核实为 {@link RandomUtil#uuId()}(= {@code String.valueOf(YitIdHelper.nextId())})。 */ public class AuditTxHolder { private static final Object RESOURCE_KEY = new Object(); /** 事务级缓冲:{operationId + 不可变事件列表};MDC 保存/恢复防线程池串号(评审#6) */ static class TxAuditBuffer { final String operationId; final String previousMdc; // 进入前的 MDC 旧值,afterCompletion 恢复 final List<AuditEventDTO> events = new ArrayList<>(); /** * 作用域是否**经由 {@link #beginOperation} 的事务分支**挂在本 buffer 上。 * * <p><b>注意它不等于「本 buffer 的 id 归属某个作用域」</b>:形态甲(作用域在事务外开启、 * buffer 由 {@link #submit} 在事务内创建)下 id 同样归属该作用域,但本标记为 false—— * 那种形态的沿用由 {@code previousMdc} 承接,不走本标记。本标记只负责补上 * {@code previousMdc == null} 那一格。 * * <p>为什么需要它:作用域由事务分支自己创建时 previousMdc 恒为 null,此时若挂起点按 * previousMdc 恢复会把 MDC 清空、内层子事务另生成 id → 同一次操作分叉 * (spec §1.3;这正是今日 lims 四处的事务形态)。 * * <p>非 final:作用域可能**晚于** buffer 出现(层 1 先建 buffer,随后才进入标注方法, * spec §4 形态丁),故 beginOperation 的创建与复用两条路径都要置位。 * buffer 绑在 TSM 事务资源上、线程内独占,无并发写风险。 */ boolean scopeOwned; TxAuditBuffer(String operationId, String previousMdc) { this.operationId = operationId; this.previousMdc = previousMdc; } } /** operationId 的生成与 MDC 写入统一归本类管理(终验#6:collector 先写 MDC 会破坏 previousMdc 语义) */ public static void submit(AuditEventDTO event, AuditEventPublisher publisher) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { String outerMdc = MDC.get(AuditApiConsts.MDC_OPERATION_ID); String opId = event.getOperationId() != null ? event.getOperationId() : (outerMdc != null ? outerMdc : RandomUtil.uuId()); // 平台雪花工具,类名对照现场 publisher.publishAsync(Collections.singletonList(event.toBuilder().operationId(opId).build())); if (outerMdc == null) { MDC.remove(AuditApiConsts.MDC_OPERATION_ID); // 自己没写过就保持干净 } return; } TxAuditBuffer buffer = (TxAuditBuffer) TransactionSynchronizationManager.getResource(RESOURCE_KEY); if (buffer == null) { buffer = createBuffer(publisher); } buffer.events.add(event.toBuilder().operationId(buffer.operationId).build()); } /** 操作作用域:close() 只在**无事务**分支需要恢复 MDC;有事务时由 afterCompletion 统一恢复。 */ public interface OperationScope extends AutoCloseable { String operationId(); @Override void close(); // 覆写掉 AutoCloseable 的 throws Exception,调用方无需 catch } /** * getOrCreate 语义(M2 契约兑现,M1 已在 currentOperationId 的 javadoc 预告): * 进入业务方法时即确保「本事务有 operationId 且已写入 MDC」,供内层旧 BizLogAspect 读取。 * * <p>为什么不能靠 {@link #submit}:submit 发生在 proceed() 之后,而内层旧切面写日志更早, * 那时 MDC 还没值 → A/B 配不上对。 * * <p>MDC 生命周期仍锁在本类一处(单源契约):有事务走 afterCompletion 恢复, * 无事务由返回的 scope.close() 恢复——两条路径都保存进入前旧值,防线程池复用串号。 */ public static OperationScope beginOperation(AuditEventPublisher publisher) { return beginOperation(publisher, null); } /** * 使用可信业务凭证派生的显式关联键开启作用域;已有外层作用域时仍以外层为准。 */ public static OperationScope beginOperation(AuditEventPublisher publisher, String preferredOperationId) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { final String previousMdc = MDC.get(AuditApiConsts.MDC_OPERATION_ID); final String opId = previousMdc != null ? previousMdc : (preferredOperationId != null ? preferredOperationId : RandomUtil.uuId()); MDC.put(AuditApiConsts.MDC_OPERATION_ID, opId); return new OperationScope() { @Override public String operationId() { return opId; } @Override public void close() { restoreMdc(previousMdc); } }; } TxAuditBuffer buffer = (TxAuditBuffer) TransactionSynchronizationManager.getResource(RESOURCE_KEY); if (buffer == null) { buffer = createBuffer(publisher, preferredOperationId); } // 作用域标记:创建与复用两条路径都要置(spec §4 形态丁——层 1 可能先建了 buffer, // 作用域晚于它出现;只在创建路径置位会让该形态的 REQUIRES_NEW 内层重新分叉) buffer.scopeOwned = true; final String opId = buffer.operationId; return new OperationScope() { @Override public String operationId() { return opId; } @Override public void close() { /* 有事务:MDC 由 afterCompletion 恢复,此处刻意 no-op */ } }; } private static TxAuditBuffer createBuffer(final AuditEventPublisher publisher) { return createBuffer(publisher, null); } private static TxAuditBuffer createBuffer(final AuditEventPublisher publisher, String preferredOperationId) { // 关键时序:先保存外层旧值,再决定本事务 operationId 并写 MDC(终验#6) String previousMdc = MDC.get(AuditApiConsts.MDC_OPERATION_ID); // M2 语义(spec §2):operationId 归属最外层操作作用域——MDC 已有值即代表外层已有 // 作用域,一律沿用;无值才生成。与 submit(:42)/beginOperation(:77) 两条无事务分支的 // 写法在此拉齐,全类只有一条规则:"先看 MDC,有则沿用"。 // // ⚠️ 本行确立一条**全类依赖的不变量**:凡本方法产出的 buffer, // previousMdc != null ⟹ operationId == previousMdc // suspend 的三目(scopeOwned ? operationId : previousMdc)正是靠它让两支殊途同归—— // previousMdc 非 null 时两支取值必然相等,故三目只在 previousMdc == null 时才真正分岔 // (即行为变更半径 = spec §4 形态丙/丁/戊)。**若将来给 id 决定逻辑加旁路(例如允许调用方 // 显式传入 id)而打破该不变量,suspend 的第二支会静默失去沿用能力——改这里先去看 suspend。** final TxAuditBuffer created = new TxAuditBuffer( previousMdc != null ? previousMdc : (preferredOperationId != null ? preferredOperationId : RandomUtil.uuId()), previousMdc); MDC.put(AuditApiConsts.MDC_OPERATION_ID, created.operationId); TransactionSynchronizationManager.bindResource(RESOURCE_KEY, created); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { private TxAuditBuffer suspended; @Override public void suspend() { // REQUIRES_NEW 内层开始:TSM 与 MDC 同步挂起 suspended = (TxAuditBuffer) TransactionSynchronizationManager.unbindResource(RESOURCE_KEY); // spec §4,按 (scopeOwned, previousMdc) 四组合读: // (true , null) 形态丙/丁/戊 → 恢复 operationId,内层沿用 ←【本次唯一被改变的组合】 // (true , X ) → 恢复 operationId,但由 createBuffer 的不变量 X == operationId, // 与走第二支等价,无行为变化 // (false, X ) 形态甲 → 恢复 previousMdc = X,内层照样沿用(作用域存在但不置标记) // (false, null) 形态乙 → 恢复 null(MDC 清空),内层另生成 id // 后两格逐字保留 M1「终验#6:只解绑资源不动 MDC 的话,内层会看到外层 opId」的原行为 // (S5 断言守之);第一格是本次**有意覆盖**该结论——终验#6 是在「一个事务一个 id」 // 语义下作出的,M2 引入 beginOperation 后语义改为「一次用户操作一个 id」。 // 改动前先读 spec §2 与 createBuffer 处标明的不变量。 restoreMdc(suspended.scopeOwned ? suspended.operationId : suspended.previousMdc); } @Override public void resume() { TransactionSynchronizationManager.bindResource(RESOURCE_KEY, suspended); MDC.put(AuditApiConsts.MDC_OPERATION_ID, suspended.operationId); } @Override public void afterCommit() { if (created.events.isEmpty()) { return; // beginOperation 建了缓冲但本次没有事件(如注解方法内无审计动作),不空发 } publisher.publishAsync(new ArrayList<>(created.events)); // 回滚不发 } @Override public void afterCompletion(int status) { if (TransactionSynchronizationManager.hasResource(RESOURCE_KEY)) { TransactionSynchronizationManager.unbindResource(RESOURCE_KEY); // 防泄漏 } restoreMdc(created.previousMdc); // 恢复外层值 / 清理防线程池串号 } }); return created; } private static void restoreMdc(String previous) { if (previous != null) { MDC.put(AuditApiConsts.MDC_OPERATION_ID, previous); } else { MDC.remove(AuditApiConsts.MDC_OPERATION_ID); } } /** * 读接口(终验四轮①,M1 只声明不消费):返回当前事务已有的 operationId; * 无活跃缓冲返回 null,**本方法不触发创建**(终验五轮①:javadoc 不承诺超出代码能力的语义)。 * M2 设计契约:@AuditLog aspect 不得自行生成 operationId——若需要 getOrCreate 语义, * M2 时在本类新增对应方法,由 holder 单源生成后写 MDC 供旧 BizLogAspect 读取; * aspect 与 holder 双源生成会破坏 A/B 精确配对。 */ public static String currentOperationId() { TxAuditBuffer buffer = (TxAuditBuffer) TransactionSynchronizationManager.getResource(RESOURCE_KEY); return buffer != null ? buffer.operationId : null; } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/annotation/AuditLog.java
New file @@ -0,0 +1,119 @@ package jnpf.audit.sdk.annotation; import jnpf.audit.AuditConsts; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 层 2 审计注解:给非 CRUD 动作或需要精确动作名的场景一行声明语义。 * * <p>与 lims 旧 {@code @BizLog} 的关系(M2 影子期):**并存不互斥**,同方法双标注、各写各表, * 靠 operationId 精确配对做 A/B;M3 达标后才删旧的。 * * <p>已知限制沿袭:同类自调用绕开代理时注解不生效(与 @BizLog 同)。 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AuditLog { /** 事件类型,受控枚举(DATA_CHANGE / BIZ_ACTION / E_SIGNATURE),默认业务动作 */ String eventType() default AuditConsts.TYPE_BIZ_ACTION; /** 动作码:基础三动作 CREATE/UPDATE/DELETE 或业务自定义大写蛇形 */ String action(); /** * 方法抛异常时用这个动作码记一条**失败事件**;留空(默认)= 抛异常时不记录。 * * <p>默认留空是为了**向后兼容**:M2 已有的层 2 埋点行为一字不变(只在成功后记录)。 * 想要"失败也留痕"的场景(电子签名、权限校验、密码修改等)显式声明即可, * 例如 {@code action="SIGN", failureAction="SIGN_FAIL"}。 * * <p>为什么拆成两个动作码而不是共用一个加标记:`audit_events` 上有 * {@code idx_audit_events_action_code} 索引,按动作码筛是最快路径;而"签名失败尝试" * 在合规上是独立关注对象(连续失败可能是有人在试密码)。共用一个码就只能去 extra * 的 text 列里捞。 * * <p>⚠️ 失败事件求值时 {@code #result} 恒为 null(方法没有返回值)。凡引用了 * {@code #result} 的 SpEL 在失败路径上取不到值——切面对**每个字段单独兜底** * (求值异常只丢该字段并 WARN,不会丢掉整条事件),但写表达式时优先用安全导航 * {@code #result?.id},让意图显式。 */ String failureAction() default ""; /** 失败事件的人读动作名,留空则回落 {@link #failureAction()} */ String failureLabel() default ""; /** * 成功时是否记录事件。默认 true(M2 既有埋点行为一字不变)。 * * <p>声明 {@code false} 的唯一正当场景是:<b>成功事实要由别处更完整地记录</b>。 * 当前只有电子签名——签名成功当场落库会产生「签名有记录、业务失败没记录」的孤儿行, * 药厂合规上不接受(签名 + 业务是一个整体)。成功侧改由层 0 在<b>业务保存成功后</b> * 据表单里的签名字段补写,失败侧({@code SIGN_FAIL})仍在此记录—— * 失败的签名尝试是独立的安全关注对象(连续失败可能是有人在试密码), * 它不隶属于任何一次成功业务,没有"跟着业务一起不记"的道理。 * * <p>⚠️ 声明 false 前必须先确认成功事实<b>确实</b>有另一条链路在记。 * 否则这就是在静默地删掉一个埋点。 */ boolean recordSuccess() default true; /** * {@link #bizCodeExpr()} 求值为空时是否**跳过**记录。默认 true(保持 M2 原行为)。 * * <p>声明 {@code false} 表示"没有业务单号也照记"——审计红线是**这次操作发生过不能缺**, * bizCode 只是便于检索。层 0 早就是这个取舍(见 {@code AuditVisualLogListener} 类注释: * 旧实现取不到单号就整条丢弃,新实现留空照记)。 * * <p>典型场景:**电子签名**。签名往往先于业务对象存在(先签名拿 biz_sign、再创建请验单), * 此时 dataId 天然为空。留着默认 true 会让签名事件被静默跳过、只在日志里留一行 WARN。 */ boolean bizCodeRequired() default true; /** 人读动作名,如"请验单 撤回" */ String label() default ""; /** 业务对象类型(对应 audit_events.biz_type) */ String bizType() default ""; /** 业务单号 SpEL,如 {@code "#qingyandan.jianyanLiushuihao"};求值为空则跳过记录并告警 */ String bizCodeExpr(); /** 附加元数据 SpEL,求值结果须为 Map;写进 audit_events.extra */ String extraExpr() default ""; /** * 操作原因 SpEL,写进 {@code audit_events.reason} 独立列。典型是用户在界面填的 * "说明 / 原因"(如签名的 note、退回的原因)。成功与失败**都求值**(它取自入参,失败时同样在)。 * * <p>🔴 **本列的语义固定为"操作者陈述的业务原因",不随成败切换含义**。失败的技术原因 * (异常 message)由切面写进 {@code extra.failureReason},不占用本列——否则查询侧 * 拿到一个 reason 值时无法判断它是"用户填的"还是"系统报的",这一列就没法解释了。 */ String reasonExpr() default ""; /** 目标表名(对应 audit_events.target_table) */ String targetTable() default ""; /** 目标行主键 SpEL(对应 audit_events.target_id) */ String targetIdExpr() default ""; /** * 批量目标主键 SpEL,结果可为 Collection、Iterable、数组或逗号分隔字符串。 * * <p>配置后,切面对每个主键分别查询修改前后数据,并在每个 field_diffs 项中写入 * targetTable/targetId。事件顶层 target_id 仍由 targetIdExpr 决定,通常取第一条。 */ String targetIdsExpr() default ""; /** 是否做字段级 diff;true 时 entityClass + targetIdExpr/targetIdsExpr 至少配置一项 */ boolean diff() default false; /** diff=true 时用于反射定位 Mapper 的实体类 */ Class<?> entityClass() default Void.class; } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/aspect/AuditLogAspect.java
New file @@ -0,0 +1,502 @@ package jnpf.audit.sdk.aspect; import com.alibaba.fastjson.JSON; import jnpf.audit.diff.AuditFieldDiff; import jnpf.audit.AuditOperationIds; import jnpf.audit.model.AuditEventDTO; import jnpf.audit.sdk.AuditClient; import jnpf.audit.sdk.AuditEventPublisher; import jnpf.audit.sdk.AuditRequestCorrelationResolver; import jnpf.audit.sdk.AuditTxHolder; import jnpf.audit.sdk.annotation.AuditLog; import jnpf.util.JsonUtil; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.context.ApplicationContext; import org.springframework.context.expression.MethodBasedEvaluationContext; import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.core.annotation.Order; import org.springframework.expression.EvaluationContext; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import java.io.Serializable; import java.lang.annotation.Annotation; import java.lang.reflect.Array; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 层 2 切面。 * * <p><b>@Order(50) 是硬约束</b>:必须小于 lims 旧 {@code BizLogAspect} 的 {@code @Order(100)}, * 使本切面包在旧切面**外层**——只有这样,本切面在 proceed() 之前写入 MDC 的 operationId, * 内层旧切面才读得到并写进 extra_json,A/B 才能按 operation_id 精确 join。顺序写反 = 配对全空。 * * <p>operationId 一律经 {@link AuditTxHolder#beginOperation} 取,**切面不得自行生成**(单源契约)。 */ @Aspect @Order(50) public class AuditLogAspect { private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AuditLogAspect.class); private final AuditClient auditClient; private final AuditEventPublisher publisher; private final ApplicationContext applicationContext; private final Map<Class<?>, Object> mapperCache = new ConcurrentHashMap<>(); private final ExpressionParser parser = new SpelExpressionParser(); private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer(); public AuditLogAspect(AuditClient auditClient, AuditEventPublisher publisher, ApplicationContext applicationContext) { this.auditClient = auditClient; this.publisher = publisher; this.applicationContext = applicationContext; } @Pointcut("@annotation(jnpf.audit.sdk.annotation.AuditLog)") public void auditLogPointcut() {} @Around("auditLogPointcut()") public Object around(ProceedingJoinPoint pjp) throws Throwable { MethodSignature signature = (MethodSignature) pjp.getSignature(); Method method = signature.getMethod(); AuditLog ann = AnnotationUtils.findAnnotation(method, AuditLog.class); if (ann == null) { return pjp.proceed(); } // 进入即确保 operationId 存在并写入 MDC(必须在 proceed 之前——内层旧切面要读) String correlatedOperationId = AuditOperationIds.correlation( AuditRequestCorrelationResolver.currentBizSign()); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(publisher, correlatedOperationId); try { DiffCapture diffCapture = captureBefore(ann, method, pjp.getArgs()); Object result; try { result = pjp.proceed(); } catch (Throwable biz) { // 失败留痕:只在显式声明 failureAction 时记录(默认留空 = M2 原行为不变)。 // 三条硬约束:① 记录失败绝不能改变业务异常的传播——原样 rethrow,不包装不吞; // ② 审计自身出错也不能影响业务,故内层再套一层 catch;③ 业务异常先于审计发生, // 事务很可能已标记回滚,所以失败事件走的仍是 holder 缓冲那条路(与成功路径一致), // 由 SDK 决定最终投递方式,切面不自作主张直发。 if (!ann.failureAction().isEmpty()) { try { record(ann, method, pjp.getArgs(), null, scope.operationId(), biz, diffCapture); } catch (Throwable t) { log.error("[AuditLog] 记录失败事件时自身出错: method={}, failureAction={}", method.getName(), ann.failureAction(), t); } } throw biz; } // recordSuccess=false = 成功事实由别处记(当前只有电子签名,见注解 javadoc)。 // 只关成功侧这一扇门:上面失败侧的 failureAction 分支完全不受影响。 if (ann.recordSuccess()) { try { record(ann, method, pjp.getArgs(), result, scope.operationId(), null, diffCapture); } catch (Throwable t) { // 审计写入不得影响业务返回(与旧 BizLogAspect 同款护栏) log.error("[AuditLog] 记录审计失败: method={}, action={}", method.getName(), ann.action(), t); } } return result; } finally { scope.close(); } } /** * @param failure 非 null 表示这是一条**失败事件**:动作码取 {@code failureAction}、 * 异常 message 落 {@code reason},且 {@code #result} 在 SpEL 里恒为 null */ private void record(AuditLog ann, Method method, Object[] args, Object result, String operationId, Throwable failure, DiffCapture diffCapture) { boolean failed = failure != null; EvaluationContext ctx = buildContext(method, args, result); String bizCode = evalString(ann.bizCodeExpr(), ctx, method, "bizCodeExpr"); if (bizCode == null || bizCode.isEmpty()) { if (ann.bizCodeRequired()) { log.warn("[AuditLog] {} 的 bizCodeExpr={} 求值为空,跳过", method.getName(), ann.bizCodeExpr()); return; } // bizCodeRequired=false:留空照记(审计红线是"这次操作发生过"不能缺,见注解 javadoc) bizCode = null; } String actionCode = failed ? ann.failureAction() : ann.action(); String actionLabel = failed ? (ann.failureLabel().isEmpty() ? actionCode : ann.failureLabel()) : (ann.label().isEmpty() ? actionCode : ann.label()); AuditEventDTO dto = AuditEventDTO.builder() .operationId(operationId) .eventType(ann.eventType()) .actionCode(actionCode) .actionLabel(actionLabel) .bizType(ann.bizType().isEmpty() ? null : ann.bizType()) .bizCode(bizCode) .targetTable(ann.targetTable().isEmpty() ? null : ann.targetTable()) .targetId(resolveTargetId(ann, ctx, method, diffCapture)) .sourceLayer(2) .build(); // reason 列 = 操作者陈述的业务原因(如签名 note),成功失败都填、语义不随成败变 dto.setReason(evalString(ann.reasonExpr(), ctx, method, "reasonExpr")); if (!failed && ann.diff()) { populateDiff(dto, ann, method, ctx, diffCapture); } String requestTitle = AuditRequestTitleResolver.resolveCurrentRequest(); dto.setRecordTitle(requestTitle == null ? dto.getTargetId() : requestTitle); Map<String, Object> extra = new LinkedHashMap<>(); if (!ann.extraExpr().isEmpty()) { // 失败路径上 extraExpr 若引用 #result 会求值失败——只丢 extra 不丢整条事件 try { Object extraObj = parser.parseExpression(ann.extraExpr()).getValue(ctx); if (extraObj instanceof Map) { // 拷进可变 map:SpEL 字面量 map 的可变性没有契约保证,而下面要 put failureReason ((Map<?, ?>) extraObj).forEach((k, v) -> extra.put(String.valueOf(k), v)); } } catch (RuntimeException e) { log.warn("[AuditLog] {} 的 extraExpr={} 求值失败(failed={}),本条事件不带 extra: {}", method.getName(), ann.extraExpr(), failed, e.getMessage()); } } if (failed) { // 失败的**技术**原因归 extra,不占 reason 列(见 @AuditLog#reasonExpr 的语义约定) extra.put("failureReason", failure.getMessage()); extra.put("failureType", failure.getClass().getSimpleName()); } if (diffCapture != null && diffCapture.unavailable != null) { extra.put("diffUnavailable", diffCapture.unavailable); } if (diffCapture != null && !diffCapture.unavailableTargetIds.isEmpty()) { extra.put("diffUnavailableTargetIds", new ArrayList<>(diffCapture.unavailableTargetIds)); } if (!extra.isEmpty()) { dto.setExtra(JsonUtil.getObjectToString(extra)); // String 存 JSON → 落 text 列(L043) } auditClient.record(dto); // 内部经 collector 填齐八必填 + tenant 回落,再交 holder 缓冲 } private DiffCapture captureBefore(AuditLog ann, Method method, Object[] args) { if (!ann.diff()) { return null; } DiffCapture capture = new DiffCapture(); if (ann.entityClass() == Void.class || (ann.targetIdExpr().isEmpty() && ann.targetIdsExpr().isEmpty())) { capture.unavailable = "diff=true requires entityClass and targetIdExpr/targetIdsExpr"; return capture; } try { if (!ann.targetIdsExpr().isEmpty()) { capture.entityIds = evalSerializableIds( ann.targetIdsExpr(), buildContext(method, args, null)); for (Serializable entityId : capture.entityIds) { try { capture.batchBefore.put(entityId, selectAsMap(ann.entityClass(), entityId)); } catch (Throwable t) { capture.unavailableTargetIds.add(String.valueOf(entityId)); log.warn("[AuditLog] {} 查询批量修改前数据失败: targetId={}, err={}", method.getName(), entityId, t.getMessage()); } } return capture; } capture.entityId = evalSerializable(ann.targetIdExpr(), buildContext(method, args, null)); if (capture.entityId != null) { capture.before = selectAsMap(ann.entityClass(), capture.entityId); } } catch (Throwable t) { capture.unavailable = t.getMessage(); log.warn("[AuditLog] {} 查询修改前数据失败: {}", method.getName(), t.getMessage()); } return capture; } private void populateDiff(AuditEventDTO dto, AuditLog ann, Method method, EvaluationContext ctx, DiffCapture capture) { if (capture == null) { return; } if (capture.unavailable != null) { return; } try { if (!capture.entityIds.isEmpty()) { populateBatchDiff(dto, ann, method, capture); return; } Serializable entityId = capture.entityId; if (entityId == null) { entityId = evalSerializable(ann.targetIdExpr(), ctx); } Map<String, Object> after = entityId == null ? null : selectAsMap(ann.entityClass(), entityId); dto.setFieldDiffs(AuditFieldDiff.diff(capture.before, after, Collections.emptySet())); dto.setDataSnapshot(AuditFieldDiff.snapshot( "DELETE".equalsIgnoreCase(ann.action()) ? capture.before : after, Collections.emptySet())); } catch (Throwable t) { capture.unavailable = t.getMessage(); log.warn("[AuditLog] {} 查询修改后数据或生成 diff 失败: {}", method.getName(), t.getMessage()); } } private void populateBatchDiff(AuditEventDTO dto, AuditLog ann, Method method, DiffCapture capture) { Map<Serializable, Map<String, Object>> afterById = new LinkedHashMap<>(); for (Serializable entityId : capture.entityIds) { if (capture.unavailableTargetIds.contains(String.valueOf(entityId))) { continue; } try { afterById.put(entityId, selectAsMap(ann.entityClass(), entityId)); } catch (Throwable t) { capture.unavailableTargetIds.add(String.valueOf(entityId)); log.warn("[AuditLog] {} 查询批量修改后数据失败: targetId={}, err={}", method.getName(), entityId, t.getMessage()); } } boolean delete = "DELETE".equalsIgnoreCase(ann.action()); dto.setFieldDiffs(batchFieldDiffs( ann.targetTable(), capture.entityIds, capture.batchBefore, afterById)); dto.setDataSnapshot(batchSnapshot( capture.entityIds, delete ? capture.batchBefore : afterById)); } static String batchFieldDiffs(String targetTable, List<Serializable> entityIds, Map<Serializable, Map<String, Object>> beforeById, Map<Serializable, Map<String, Object>> afterById) { List<Map<String, Object>> combined = new ArrayList<>(); for (Serializable entityId : entityIds) { if (!beforeById.containsKey(entityId) || !afterById.containsKey(entityId)) { continue; } String json = AuditFieldDiff.diff( beforeById.get(entityId), afterById.get(entityId), Collections.emptySet()); List<Map> items = JSON.parseArray(json, Map.class); for (Map item : items) { Map<String, Object> scoped = new LinkedHashMap<>(); scoped.put("targetTable", targetTable); scoped.put("targetId", String.valueOf(entityId)); scoped.putAll(item); combined.add(scoped); } } return JSON.toJSONString(combined); } static String batchSnapshot(List<Serializable> entityIds, Map<Serializable, Map<String, Object>> rowsById) { List<Map<String, Object>> snapshots = new ArrayList<>(); for (Serializable entityId : entityIds) { if (!rowsById.containsKey(entityId)) { continue; } Map<String, Object> snapshot = new LinkedHashMap<>(); snapshot.put("targetId", String.valueOf(entityId)); String data = AuditFieldDiff.snapshot(rowsById.get(entityId), Collections.emptySet()); snapshot.put("data", data == null ? null : JSON.parseObject(data, Map.class)); snapshots.add(snapshot); } return JSON.toJSONString(snapshots); } private Serializable evalSerializable(String expr, EvaluationContext ctx) { Object value = parser.parseExpression(expr).getValue(ctx); return value instanceof Serializable ? (Serializable) value : null; } private List<Serializable> evalSerializableIds(String expr, EvaluationContext ctx) { return normalizeSerializableIds(parser.parseExpression(expr).getValue(ctx)); } static List<Serializable> normalizeSerializableIds(Object value) { LinkedHashSet<Serializable> ids = new LinkedHashSet<>(); if (value == null) { return new ArrayList<>(); } if (value instanceof Collection) { for (Object item : (Collection<?>) value) { addSerializableId(ids, item); } } else if (value instanceof Iterable) { for (Object item : (Iterable<?>) value) { addSerializableId(ids, item); } } else if (value.getClass().isArray()) { for (int i = 0; i < Array.getLength(value); i++) { addSerializableId(ids, Array.get(value, i)); } } else if (value instanceof CharSequence && String.valueOf(value).contains(",")) { for (String item : String.valueOf(value).split(",")) { addSerializableId(ids, item); } } else { addSerializableId(ids, value); } return new ArrayList<>(ids); } private static void addSerializableId(LinkedHashSet<Serializable> ids, Object value) { if (!(value instanceof Serializable)) { return; } if (value instanceof CharSequence) { String text = String.valueOf(value).trim(); if (!text.isEmpty()) { ids.add(text); } return; } ids.add((Serializable) value); } private Map<String, Object> selectAsMap(Class<?> entityClass, Serializable id) throws Exception { Object mapper = findMapperFor(entityClass); Method selectById = null; for (Method candidate : mapper.getClass().getMethods()) { if ("selectById".equals(candidate.getName()) && candidate.getParameterTypes().length == 1) { selectById = candidate; break; } } if (selectById == null) { throw new IllegalStateException("Mapper does not expose selectById: " + mapper.getClass().getName()); } Object entity = selectById.invoke(mapper, id); return entity == null ? null : entityToColumnMap(entity); } private Object findMapperFor(Class<?> entityClass) { Object cached = mapperCache.get(entityClass); if (cached != null) { return cached; } String simpleName = entityClass.getSimpleName(); if (!simpleName.endsWith("Entity")) { throw new IllegalArgumentException("entityClass must end with Entity: " + simpleName); } String mapperName = Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1, simpleName.length() - "Entity".length()) + "Mapper"; Object mapper = applicationContext.getBean(mapperName); mapperCache.put(entityClass, mapper); return mapper; } /** 将实体属性按 MyBatis-Plus 注解转换为真实数据库列名,供查询侧匹配列备注。 */ private static Map<String, Object> entityToColumnMap(Object entity) throws IllegalAccessException { Map<String, Object> result = new LinkedHashMap<>(); for (Class<?> type = entity.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { for (Field field : type.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) { continue; } String column = mappedColumn(field); if (column == null) { continue; } field.setAccessible(true); result.put(column, field.get(entity)); } } return result; } private static String mappedColumn(Field field) { for (Annotation annotation : field.getAnnotations()) { String annotationName = annotation.annotationType().getName(); if (!"com.baomidou.mybatisplus.annotation.TableField".equals(annotationName) && !"com.baomidou.mybatisplus.annotation.TableId".equals(annotationName)) { continue; } try { if ("com.baomidou.mybatisplus.annotation.TableField".equals(annotationName)) { Object exists = annotation.annotationType().getMethod("exist").invoke(annotation); if (Boolean.FALSE.equals(exists)) { return null; } } Object value = annotation.annotationType().getMethod("value").invoke(annotation); if (value != null && !String.valueOf(value).trim().isEmpty()) { return String.valueOf(value).trim(); } } catch (ReflectiveOperationException ignored) { // 注解版本差异时回落到属性名转换,不能让审计影响业务。 } } return camelToSnake(field.getName()); } private static String camelToSnake(String value) { StringBuilder out = new StringBuilder(value.length() + 8); for (int i = 0; i < value.length(); i++) { char ch = value.charAt(i); if (Character.isUpperCase(ch)) { out.append('_').append(Character.toLowerCase(ch)); } else { out.append(ch); } } return out.toString(); } private static final class DiffCapture { private Serializable entityId; private Map<String, Object> before; private List<Serializable> entityIds = Collections.emptyList(); private final Map<Serializable, Map<String, Object>> batchBefore = new LinkedHashMap<>(); private final LinkedHashSet<String> unavailableTargetIds = new LinkedHashSet<>(); private String unavailable; } private EvaluationContext buildContext(Method method, Object[] args, Object result) { MethodBasedEvaluationContext ctx = new MethodBasedEvaluationContext(new Object(), method, args, nameDiscoverer); ctx.setVariable("result", result); return ctx; } /** * SpEL 求值 + **逐字段兜底**:求值抛异常只丢这个字段并 WARN,不让整条事件消失。 * * <p>为什么必须兜底:失败事件的上下文天然不完整({@code #result} 为 null), * 而 {@code #result.id} 这种非安全导航在 null 上取属性会抛 {@code SpelEvaluationException}。 * 若不兜底,一个写得不够谨慎的表达式就会让**失败事件整条消失**——那正是最需要留痕的时刻。 */ private String evalString(String expr, EvaluationContext ctx, Method method, String exprName) { if (expr == null || expr.isEmpty()) return null; try { Object v = parser.parseExpression(expr).getValue(ctx); return v == null ? null : v.toString(); } catch (RuntimeException e) { log.warn("[AuditLog] {} 的 {}={} 求值失败,该字段留空: {}", method.getName(), exprName, expr, e.getMessage()); return null; } } private String resolveTargetId(AuditLog ann, EvaluationContext ctx, Method method, DiffCapture capture) { String targetId = evalString(ann.targetIdExpr(), ctx, method, "targetIdExpr"); if (targetId != null || capture == null || capture.entityIds.isEmpty()) { return targetId; } return String.valueOf(capture.entityIds.get(0)); } } jnpf-biz-common/jnpf-audit-sdk/src/main/java/jnpf/audit/sdk/aspect/AuditRequestTitleResolver.java
New file @@ -0,0 +1,72 @@ package jnpf.audit.sdk.aspect; import jnpf.util.JsonUtil; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; import java.util.Map; /** Resolves the display-only audit title supplied by the scoped online form request wrapper. */ final class AuditRequestTitleResolver { static final String HEADER_NAME = "X-Audit-Display-Fields"; private static final int MAX_HEADER_LENGTH = 8192; private static final int MAX_FIELDS = 50; private static final int MAX_TITLE_LENGTH = 400; private AuditRequestTitleResolver() { } static String resolveCurrentRequest() { RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); if (!(attributes instanceof ServletRequestAttributes)) { return null; } String encoded = ((ServletRequestAttributes) attributes).getRequest().getHeader(HEADER_NAME); return resolveEncoded(encoded); } static String resolveEncoded(String encoded) { if (encoded == null || encoded.isEmpty() || encoded.length() > MAX_HEADER_LENGTH) { return null; } try { String json = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8); List<Map> fields = JsonUtil.getJsonToList(json, Map.class); StringBuilder title = new StringBuilder(); int count = 0; for (Map field : fields) { if (field == null || count++ >= MAX_FIELDS) { break; } String value = clean(field.get("displayValue")); if (value.isEmpty()) { continue; } int separatorLength = title.length() > 0 ? 1 : 0; int remaining = MAX_TITLE_LENGTH - title.length() - separatorLength; if (remaining <= 0) { break; } if (separatorLength > 0) { title.append('/'); } title.append(value, 0, Math.min(value.length(), remaining)); } return title.length() == 0 ? null : title.toString(); } catch (Throwable ignored) { return null; } } private static String clean(Object value) { if (value == null) { return ""; } return String.valueOf(value).replace('\r', ' ').replace('\n', ' ').trim(); } } jnpf-biz-common/jnpf-audit-sdk/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
New file @@ -0,0 +1,2 @@ jnpf.audit.sdk.AuditSdkAutoConfiguration jnpf.audit.sdk.AuditSdkProbeAutoConfiguration jnpf-biz-common/jnpf-audit-sdk/src/test/java/jnpf/audit/sdk/AuditRequestScopeFilterChecks.java
New file @@ -0,0 +1,28 @@ package jnpf.audit.sdk; /** Source-scope acceptance matrix; runnable without a test framework. */ public final class AuditRequestScopeFilterChecks { private AuditRequestScopeFilterChecks() { } public static void main(String[] args) { AuditRequestScopeFilter limsFrontend = new AuditRequestScopeFilter(true, "LIMS"); check("FRONTEND + LIMS", limsFrontend.accepts("FRONTEND", "LIMS")); check("case-insensitive app code", limsFrontend.accepts("frontend", "lims")); check("BACKEND rejected", !limsFrontend.accepts("BACKEND", "LIMS")); check("CONSOLE rejected", !limsFrontend.accepts("CONSOLE", "LIMS")); check("missing scope rejected", !limsFrontend.accepts(null, "LIMS")); check("other app rejected", !limsFrontend.accepts("FRONTEND", "ELN")); AuditRequestScopeFilter unrestricted = new AuditRequestScopeFilter(false, ""); check("disabled filter remains backward compatible", unrestricted.accepts(null, null)); System.out.println("AuditRequestScopeFilterChecks: all checks passed"); } private static void check(String name, boolean condition) { if (!condition) { throw new AssertionError(name); } } } jnpf-biz-common/jnpf-audit-sdk/src/test/java/jnpf/audit/sdk/AuditTxHolderConcurrencyChecks.java
New file @@ -0,0 +1,447 @@ package jnpf.audit.sdk; import jnpf.audit.AuditApiConsts; import jnpf.audit.AuditOperationIds; import jnpf.audit.model.AuditEventDTO; import org.slf4j.MDC; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationUtils; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; /** * AuditTxHolder 的并发时序矩阵断言(M2 operationId 分叉修复,spec §6-A)。 * * <p><b>本类模拟事务生命周期的编排顺序,逐条对照 spring-tx 6.2.12 的 * {@code AbstractPlatformTransactionManager} 字节码得出,改动前请先复核:</b> * <pre> * 挂起 doSuspendSynchronization: * getSynchronizations()(偏移 0)→ 逐个 suspend()(偏移 31)→ clearSynchronization()(偏移 39) * 恢复 doResumeSynchronization: * initSynchronization()(偏移 0)→ 循环内**逐元素交替** resume()(偏移 30)→ registerSynchronization()(偏移 36) * ——注意是 resume 在前、register 在后,不是"先全部注册再全部恢复" * 完成 triggerAfterCompletion: * getSynchronizations()(偏移 7)→ clearSynchronization()(偏移 11)→ invokeAfterCompletion()(偏移 31) * ——afterCompletion 回调在同步**被清除之后**执行,故本类用 * TransactionSynchronizationUtils.invokeAfterCompletion(list, status) 而非 * triggerAfterCompletion(int)(后者要求同步仍活跃,与真实相反) * </pre> * * <p>本类是 A 轮保真度缺口的唯一书面依据:TransactionSynchronizationUtils 没有 suspend/resume * 的公开触发器,挂起/恢复顺序全靠本类自行编排。真实链路交叉验证见 tasks/audit-opid-e2e.sh。 * * <p>运行:bash tasks/audit-opid-checks.sh */ public class AuditTxHolderConcurrencyChecks { private static final String KEY = AuditApiConsts.MDC_OPERATION_ID; private static final List<AuditEventDTO> SENT = new ArrayList<AuditEventDTO>(); private static int failures = 0; private static int checksRun = 0; private static int dirtyTail = 0; /** 假发送器:只记事件,不触碰 Feign / spool 两级降级链路(spec §6-A 已核实可行) */ private static final AuditEventPublisher PUB = new AuditEventPublisher(null, null) { @Override public void publishAsync(List<AuditEventDTO> events) { SENT.addAll(events); } }; // ==================== 事务生命周期模拟(顺序依据见类注释)==================== private static void beginTx() { TransactionSynchronizationManager.initSynchronization(); } private static void commitTx() { TransactionSynchronizationUtils.triggerBeforeCommit(false); TransactionSynchronizationUtils.triggerBeforeCompletion(); TransactionSynchronizationUtils.triggerAfterCommit(); completeTx(TransactionSynchronization.STATUS_COMMITTED); } private static void rollbackTx() { TransactionSynchronizationUtils.triggerBeforeCompletion(); completeTx(TransactionSynchronization.STATUS_ROLLED_BACK); } private static void completeTx(int status) { List<TransactionSynchronization> syncs = TransactionSynchronizationManager.getSynchronizations(); TransactionSynchronizationManager.clearSynchronization(); TransactionSynchronizationUtils.invokeAfterCompletion(syncs, status); } private static List<TransactionSynchronization> suspendTx() { List<TransactionSynchronization> syncs = TransactionSynchronizationManager.getSynchronizations(); for (TransactionSynchronization s : syncs) { s.suspend(); } TransactionSynchronizationManager.clearSynchronization(); return syncs; } private static void resumeTx(List<TransactionSynchronization> syncs) { TransactionSynchronizationManager.initSynchronization(); for (TransactionSynchronization s : syncs) { s.resume(); TransactionSynchronizationManager.registerSynchronization(s); } } // ==================== 事件构造 ==================== /** 层 1 / 层 3 事件:不带 operationId,由 holder 决定(对应 Executor 插件与显式 record) */ private static void emitInner() { AuditTxHolder.submit(dto(null), PUB); } /** 层 2 事件:切面显式带上 scope 的 id(照抄 AuditLogAspect.java:68,90) */ private static void emitOuter(String operationId) { AuditTxHolder.submit(dto(operationId), PUB); } private static AuditEventDTO dto(String operationId) { return AuditEventDTO.builder() .operationId(operationId) .eventType("BIZ_ACTION") .actionCode("UPDATE") .sourceLayer(1) .build(); } private static void emitSignUsed() { AuditTxHolder.submit(AuditEventDTO.builder() .eventType("E_SIGNATURE") .actionCode("SIGN_USED") .bizType("JIANCE_TASK") .bizCode("LS-ROLLBACK") .targetTable("lims_sign") .targetId("sign-rollback") .sourceLayer(3) .build(), PUB); } // ==================== 断言工具 ==================== private static void reset() { SENT.clear(); MDC.remove(KEY); if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.clearSynchronization(); } } private static void check(String name, boolean ok, String detail) { checksRun++; if (ok) { System.out.println(" [PASS] " + name); } else { failures++; System.out.println(" [FAIL] " + name + " —— " + detail); } } /** 每个场景收尾都要跑:MDC 必须归零、holder 资源必须已解绑(场景 9 的实现) */ private static void assertClean(String scenario) { String mdc = MDC.get(KEY); String res = AuditTxHolder.currentOperationId(); boolean ok = (mdc == null) && (res == null); if (!ok) { dirtyTail++; } check(scenario + " 末态干净(MDC 与 holder 资源均已清)", ok, "MDC=" + mdc + ", currentOperationId=" + res); } private static Set<String> idsOf(List<AuditEventDTO> events) { Set<String> ids = new HashSet<String>(); for (AuditEventDTO e : events) { ids.add(e.getOperationId()); } return ids; } private static String dump() { StringBuilder sb = new StringBuilder("发出 ").append(SENT.size()).append(" 条: "); for (AuditEventDTO e : SENT) { sb.append(e.getOperationId()).append(' '); } return sb.toString(); } // ==================== 十一项场景 ==================== /** 场景 1:无事务进入、无内部事务提交(现状不回归) */ private static void s1() { System.out.println("[S1] 无事务进入、无内部事务提交"); reset(); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB); String scopeId = scope.operationId(); emitOuter(scopeId); scope.close(); check("S1 层2 事件用 scope 的 id", SENT.size() == 1 && scopeId.equals(SENT.get(0).getOperationId()), dump()); assertClean("S1"); } /** 场景 2:事务已激活进入 + 事务内提交(今日 lims 四处的形态,现状不回归) */ private static void s2() { System.out.println("[S2] 事务已激活进入 + 事务内提交(今日 lims 形态)"); reset(); beginTx(); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB); String scopeId = scope.operationId(); emitInner(); emitOuter(scopeId); scope.close(); commitTx(); check("S2 层1 与层2 同 id", SENT.size() == 2 && idsOf(SENT).size() == 1, dump()); check("S2 该 id 即 scope 的 id", idsOf(SENT).contains(scopeId), "scopeId=" + scopeId + ", " + dump()); assertClean("S2"); } /** 场景 3:无事务进入 + 内部事务内提交(spec §1.2 分叉;全未改时必红) */ private static void s3() { System.out.println("[S3] 无事务进入 + 内部事务内提交(§1.2 分叉)"); reset(); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB); String scopeId = scope.operationId(); beginTx(); emitInner(); commitTx(); emitOuter(scopeId); scope.close(); check("S3 层1 与层2 同 id", SENT.size() == 2 && idsOf(SENT).size() == 1, dump()); assertClean("S3"); } /** 场景 4:形态甲——作用域在事务外 + REQUIRES_NEW,buffer 由 submit 创建 */ private static void s4() { System.out.println("[S4] 形态甲:作用域在事务外 + REQUIRES_NEW(buffer 由 submit 建)"); reset(); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB); String scopeId = scope.operationId(); beginTx(); // 防假绿:本场景 buffer 必须由 submit 创建,且创建时 MDC 已有值 check("S4 前置:进入外层事务时 MDC 已被作用域写入", scopeId.equals(MDC.get(KEY)), "MDC=" + MDC.get(KEY)); check("S4 前置:外层事务此刻尚无 buffer", AuditTxHolder.currentOperationId() == null, "已有 buffer"); emitInner(); List<TransactionSynchronization> susp = suspendTx(); beginTx(); emitInner(); commitTx(); resumeTx(susp); emitInner(); commitTx(); emitOuter(scopeId); scope.close(); check("S4 内外事务与层2 全部同 id", SENT.size() == 4 && idsOf(SENT).size() == 1, dump()); assertClean("S4"); } /** 场景 5:形态乙——无作用域 + REQUIRES_NEW,内外必须各自独立(守 M1 终验#6) */ private static void s5() { System.out.println("[S5] 形态乙:无作用域 + REQUIRES_NEW(内外必须不同 id)"); reset(); beginTx(); check("S5 前置:无作用域,MDC 为空", MDC.get(KEY) == null, "MDC=" + MDC.get(KEY)); emitInner(); List<TransactionSynchronization> susp = suspendTx(); beginTx(); check("S5 前置:挂起后 MDC 必须被清空", MDC.get(KEY) == null, "MDC=" + MDC.get(KEY)); emitInner(); commitTx(); resumeTx(susp); commitTx(); check("S5 内外事务 id 不同(终验#6 原行为)", SENT.size() == 2 && idsOf(SENT).size() == 2, dump()); assertClean("S5"); } /** * 场景 6:形态丙——事务已激活进入 + REQUIRES_NEW,buffer 由 beginOperation 创建。 * 这是 v1 设计漏掉的形态:只改 createBuffer 一行时**仍然红**,必须靠 scopeOwned 才转绿。 */ private static void s6() { System.out.println("[S6] 形态丙:事务已激活进入 + REQUIRES_NEW(buffer 由 beginOperation 建)"); reset(); beginTx(); // 防假绿(spec §6-A):必须是 MDC 为空时由 beginOperation 建 buffer, // 否则会退化成场景 4 的 previousMdc 路径而假绿 check("S6 前置:beginOperation 之前 MDC 为空", MDC.get(KEY) == null, "MDC=" + MDC.get(KEY)); check("S6 前置:beginOperation 之前无 buffer", AuditTxHolder.currentOperationId() == null, "已有 buffer"); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB); String scopeId = scope.operationId(); check("S6 前置:buffer 确由 beginOperation 创建", scopeId != null && scopeId.equals(AuditTxHolder.currentOperationId()), "scopeId=" + scopeId + ", current=" + AuditTxHolder.currentOperationId()); List<TransactionSynchronization> susp = suspendTx(); beginTx(); emitInner(); commitTx(); resumeTx(susp); emitOuter(scopeId); scope.close(); commitTx(); check("S6 内层 REQUIRES_NEW 与层2 同 id", SENT.size() == 2 && idsOf(SENT).size() == 1, dump()); check("S6 该 id 即 scope 的 id", idsOf(SENT).contains(scopeId), "scopeId=" + scopeId + ", " + dump()); assertClean("S6"); } /** 场景 7:形态丁——submit 先建 buffer、随后才进作用域 + REQUIRES_NEW(验"复用路径也置位") */ private static void s7() { System.out.println("[S7] 形态丁:submit 先建 buffer、后进作用域 + REQUIRES_NEW"); reset(); beginTx(); emitInner(); String bufferId = AuditTxHolder.currentOperationId(); check("S7 前置:buffer 由 submit 创建", bufferId != null, "无 buffer"); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB); String scopeId = scope.operationId(); check("S7 前置:作用域复用既有 buffer", bufferId.equals(scopeId), "bufferId=" + bufferId + ", scopeId=" + scopeId); List<TransactionSynchronization> susp = suspendTx(); beginTx(); emitInner(); commitTx(); resumeTx(susp); emitOuter(scopeId); scope.close(); commitTx(); check("S7 全链路同 id", SENT.size() == 3 && idsOf(SENT).size() == 1, dump()); assertClean("S7"); } /** 场景 8:形态戊——标注方法自身 REQUIRES_NEW、调用方有事务 */ private static void s8() { System.out.println("[S8] 形态戊:标注方法自身 REQUIRES_NEW、调用方有事务"); reset(); beginTx(); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB); String scopeId = scope.operationId(); List<TransactionSynchronization> susp = suspendTx(); beginTx(); emitInner(); commitTx(); resumeTx(susp); emitOuter(scopeId); scope.close(); commitTx(); check("S8 自身事务与调用方事务同 id", SENT.size() == 2 && idsOf(SENT).size() == 1, dump()); check("S8 该 id 即 scope 的 id", idsOf(SENT).contains(scopeId), "scopeId=" + scopeId + ", " + dump()); assertClean("S8"); } /** 场景 10:回滚不发(单事务,守 M1 原行为) */ private static void s10() { System.out.println("[S10] 回滚不发(单事务)"); reset(); beginTx(); emitInner(); rollbackTx(); check("S10 回滚后无事件发出", SENT.isEmpty(), dump()); assertClean("S10"); } /** 场景 11:形态丙下 内层回滚 + 外层提交;并含双回滚变体 */ private static void s11() { System.out.println("[S11] 形态丙下 内层回滚 + 外层提交"); reset(); beginTx(); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB); String scopeId = scope.operationId(); List<TransactionSynchronization> susp = suspendTx(); beginTx(); emitInner(); rollbackTx(); resumeTx(susp); emitOuter(scopeId); scope.close(); commitTx(); check("S11 内层回滚不发、外层照发", SENT.size() == 1 && scopeId.equals(SENT.get(0).getOperationId()), dump()); assertClean("S11"); System.out.println("[S11b] 双回滚变体"); reset(); beginTx(); AuditTxHolder.OperationScope scope2 = AuditTxHolder.beginOperation(PUB); List<TransactionSynchronization> susp2 = suspendTx(); beginTx(); emitInner(); rollbackTx(); resumeTx(susp2); emitOuter(scope2.operationId()); scope2.close(); rollbackTx(); check("S11b 内外双回滚:一条都不发", SENT.isEmpty(), dump()); assertClean("S11b"); } /** SIGN_USED 与业务事件同事务:共享 operationId,整体回滚时均不发。 */ private static void s12() { System.out.println("[S12] SIGN_USED 与业务事件的提交/回滚语义"); reset(); beginTx(); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB); emitInner(); emitSignUsed(); scope.close(); commitTx(); check("S12 提交时业务事件与 SIGN_USED 共享 operationId", SENT.size() == 2 && idsOf(SENT).size() == 1, dump()); assertClean("S12-commit"); reset(); beginTx(); AuditTxHolder.OperationScope rollbackScope = AuditTxHolder.beginOperation(PUB); emitInner(); emitSignUsed(); rollbackScope.close(); rollbackTx(); check("S12 回滚时 SIGN_USED 与业务事件均不发", SENT.isEmpty(), dump()); assertClean("S12-rollback"); } /** 同一 biz_sign 的业务与签名事件共享一个稳定 operationId。 */ private static void s13() { System.out.println("[S13] biz_sign 关联作用域统一 operationId"); reset(); String signId = "839875756384423365"; beginTx(); AuditTxHolder.OperationScope scope = AuditTxHolder.beginOperation(PUB, AuditOperationIds.correlation(signId)); emitInner(); emitSignUsed(); scope.close(); commitTx(); check("S13 同一前端操作的业务与签名事件共享 operationId", SENT.size() == 2 && idsOf(SENT).size() == 1 && idsOf(SENT).contains(AuditOperationIds.correlation(signId)), dump()); assertClean("S13"); } public static void main(String[] args) { System.out.println("=== AuditTxHolder 并发时序矩阵(spec §6-A + SIGN_USED 回滚)==="); s1(); s2(); s3(); s4(); s5(); s6(); s7(); s8(); s10(); s11(); s12(); s13(); System.out.println(); System.out.println("[S9] 线程复用防线汇总:末态不干净的场景数 = " + dirtyTail); System.out.println(); System.out.println("=== 断言 " + checksRun + " 条,失败 " + failures + " 条 ==="); if (failures > 0) { System.out.println("红。改动前这是预期结果——对照 plan 的两层红基线表核对红的是哪几项。"); System.exit(1); } System.out.println("全绿。"); } } jnpf-biz-common/jnpf-audit-sdk/src/test/java/jnpf/audit/sdk/aspect/AuditLogBatchDiffChecks.java
New file @@ -0,0 +1,78 @@ package jnpf.audit.sdk.aspect; import com.alibaba.fastjson.JSON; import java.io.Serializable; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** AuditLog 批量主键归一化、逐记录 diff 与快照契约检查。 */ public final class AuditLogBatchDiffChecks { private static int passed; private static int failed; public static void main(String[] args) { List<Serializable> ids = AuditLogAspect.normalizeSerializableIds( Arrays.asList(" row-1 ", "row-2", "", "row-1", null)); check("批量主键去空、去重并保持顺序", ids.equals(Arrays.asList("row-1", "row-2"))); check("逗号分隔主键可归一化", AuditLogAspect.normalizeSerializableIds("row-1,row-2") .equals(Arrays.asList("row-1", "row-2"))); List<Serializable> tenIds = AuditLogAspect.normalizeSerializableIds( new String[] {"row-1", "row-2", "row-3", "row-4", "row-5", "row-6", "row-7", "row-8", "row-9", "row-10"}); Map<Serializable, Map<String, Object>> before = new LinkedHashMap<>(); Map<Serializable, Map<String, Object>> after = new LinkedHashMap<>(); for (Serializable id : tenIds) { before.put(id, row("pending")); after.put(id, row("done")); } String fieldDiffs = AuditLogAspect.batchFieldDiffs( "lims_task", tenIds, before, after); List<Map> diffs = JSON.parseArray(fieldDiffs, Map.class); check("10条记录修改同一字段保留10条差异", diffs.size() == 10); check("每条差异携带物理表和记录主键", diffs.stream().allMatch(item -> "lims_task".equals(item.get("targetTable")) && item.get("targetId") != null)); check("批量差异保留真实前后值", diffs.stream().allMatch(item -> "pending".equals(item.get("oldData")) && "done".equals(item.get("newData")))); String snapshot = AuditLogAspect.batchSnapshot(tenIds, after); List<Map> snapshots = JSON.parseArray(snapshot, Map.class); check("批量操作后快照保留10条记录", snapshots.size() == 10); check("每条快照按 targetId 包装数据", snapshots.stream().allMatch(item -> item.get("targetId") != null && item.get("data") instanceof Map)); System.out.println("=== AuditLog 批量差异检查:" + passed + " 通过 / " + failed + " 失败 ==="); if (failed > 0) { System.exit(1); } } private static Map<String, Object> row(String status) { Map<String, Object> row = new LinkedHashMap<>(); row.put("status", status); return row; } private static void check(String name, boolean ok) { if (ok) { passed++; System.out.println(" [PASS] " + name); } else { failed++; System.out.println(" [FAIL] " + name); } } private AuditLogBatchDiffChecks() { } } jnpf-biz-common/jnpf-audit-sdk/src/test/java/jnpf/audit/sdk/aspect/AuditRequestTitleResolverChecks.java
New file @@ -0,0 +1,43 @@ package jnpf.audit.sdk.aspect; import java.nio.charset.StandardCharsets; import java.util.Base64; /** Header decoding and title sanitizing checks; runnable without a test framework. */ public final class AuditRequestTitleResolverChecks { private AuditRequestTitleResolverChecks() { } public static void main(String[] args) { String encoded = Base64.getEncoder().encodeToString(("[" + "{\"field\":\"code\",\"displayValue\":\"LIMS-001\"}," + "{\"field\":\"product\",\"displayValue\":\"阿莫西林\\n胶囊\"}" + "]").getBytes(StandardCharsets.UTF_8)); check("LIMS-001/阿莫西林 胶囊".equals(AuditRequestTitleResolver.resolveEncoded(encoded))); String full = repeat('a', 400); String capped = encode("[{\"displayValue\":\"" + full + "\"},{\"displayValue\":\"tail\"}]"); check(full.equals(AuditRequestTitleResolver.resolveEncoded(capped))); check(AuditRequestTitleResolver.resolveEncoded("not-base64") == null); check(AuditRequestTitleResolver.resolveEncoded(null) == null); System.out.println("AuditRequestTitleResolverChecks: all checks passed"); } private static void check(boolean condition) { if (!condition) { throw new AssertionError(); } } private static String encode(String value) { return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8)); } private static String repeat(char value, int count) { StringBuilder out = new StringBuilder(count); for (int i = 0; i < count; i++) { out.append(value); } return out.toString(); } } jnpf-biz-common/jnpf-biz-common-audit/pom.xml
New file @@ -0,0 +1,47 @@ <?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>jnpf-biz-common</artifactId> <groupId>com.jnpf</groupId> <version>6.1.0-RELEASE</version> </parent> <modelVersion>4.0.0</modelVersion> <!-- 审计追踪能力(服务端)。2026-08-11 由原 jnpf-audit 的 -entity + -biz + -controller 三个模块合并而来,按 biz-common 的**单模块制**(设计文档 §D6 于当日改判:原定"保留 6 模块 结构不重构",实际压到 3 个)。 包分层:jnpf.bizcommon.audit.{entity,mapper,service,controller} 仍独立在外的两个模块及其理由: · jnpf-audit-api —— Feign 契约 + 业务进程也要用的常量,被 sdk 与本模块共用 · jnpf-audit-sdk —— 跑在业务进程内(jnpf-lims),不属于本服务 原 jnpf-audit-server 已退役,启动入口由 jnpf-biz-common-server 统一承担。 --> <artifactId>jnpf-biz-common-audit</artifactId> <dependencies> <!-- 原 -biz 模块的依赖 --> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-cloud-base</artifactId> <version>${project.version}</version> </dependency> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-common-database</artifactId> </dependency> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-audit-api</artifactId> <version>${project.version}</version> <scope>compile</scope> </dependency> <!-- 原 -controller 模块的依赖(AOP + Sa-Token + Sentinel + Swagger) --> <dependency> <groupId>com.jnpf</groupId> <artifactId>jnpf-common-springaop</artifactId> <version>${project.version}</version> </dependency> </dependencies> </project> jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/controller/AuditInternalController.java
New file @@ -0,0 +1,68 @@ package jnpf.bizcommon.audit.controller; import cn.dev33.satoken.same.SaSameUtil; import jnpf.audit.model.AuditEventDTO; import jnpf.bizcommon.audit.service.AuditEventService; import jnpf.bizcommon.audit.service.DataInterfaceSignAuditService; import jnpf.base.ActionResult; import org.springframework.beans.factory.annotation.Autowired; 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; /** * 审计事件内部接收端点:只服务 {@link jnpf.audit.AuditEventApi} 的 Feign 调用, * 走平台内部鉴权链,不经网关白名单暴露给外部。path 与 AuditEventApi 的 @FeignClient * path 保持字面一致(/Internal/audit + /events)。 * * <p><b>写端点鉴权(权威安全边界)</b>:本方法首行的服务侧 * {@link SaSameUtil#checkCurrentRequestToken()} 是审计写端点的<b>权威安全边界</b>——它拒绝一切 * 不携带合法 {@code SA-SAME-TOKEN} 的请求,覆盖网关层的<b>所有绕过变体</b>(Discovery Locator * 自动路由 {@code /jnpf-audit/**}、大小写变体、多斜杠、white-ip 白名单跳过 block 等), * 使本端点<b>只接受携带有效 {@code SA-SAME-TOKEN} 的服务间 Feign 调用</b>: * jnpf-common-feign 的 {@code FeignConfig} 请求拦截器对所有 Feign 出站请求自动注入 * {@code SA-SAME-TOKEN = UserProvider.getInnerAuthToken() = SaSameUtil.getToken()}(已反编译实证); * 而网关转发的外部请求只被注入 {@code SA-SAME-TOKEN_GATEWAY}(MvcSecurityConfig),<b>不带 * {@code SA-SAME-TOKEN}</b>,故 {@code checkCurrentRequestToken()} 读不到 header 即抛 * {@link cn.dev33.satoken.exception.SameTokenInvalidException}(sa-token 1.44.0 的 same-token 校验异常, * 该版本无 {@code SaSameException})拒收——堵住任意登录用户经网关伪造投递审计事件、破坏 append-only 完整性的漏洞。 * 该校验与请求路径大小写、前缀形态无关,是伪造防线的最终裁定层。 * * <p><b>网关 block-url 是纵深第一层(非权威边界)</b>:router.yaml 的 {@code gateway.block-url} * 已覆盖数据面标准路径 {@code /api/audit/Internal/**}(前端/DB 数据接口用)与 * {@code /jnpf-audit/Internal/**}(Discovery Locator 自动路由标准路径); * <b>2026-08-11 审计搬入 jnpf-biz-common 后又多出两条到达路径,已一并补堵</b>: * {@code /api/biz/Internal/**}(biz-common 的对外前缀同样 StripPrefix=2, * {@code /api/biz/Internal/audit/events} 去掉两段后正是本端点)与 * {@code /jnpf-biz-common/Internal/**}(Locator 按新服务名生成;原 {@code /jnpf-audit/**} * 那条仍须保留,因为 discovery.yaml 里 jnpf-audit 作为别名依然注册)。它是纵深第一层, * 按用户决策<b>只覆盖数据面标准路径、不穷尽所有变体</b>:大写/混合大小写变体(如 * {@code /JNPF-AUDIT/Internal/audit/events})实测在下游 Spring MVC 因 controller 路径映射 * 大小写敏感直接 404(不达本方法),多斜杠变体被平台"非法请求"过滤器拦截——均无法落库, * 兜底全部由上述服务侧 SAME-TOKEN 承担。网关层完整覆盖(全局 {@code /Internal} 屏蔽 + * Discovery Locator 收敛)作为独立平台加固项另议。 * * <p><b style="color:red">⚠ 新增任何 /Internal 写端点,必须在方法首行复制 * {@code SaSameUtil.checkCurrentRequestToken()} 校验</b>——网关 block-url 不保证覆盖新路径, * 服务侧 SAME-TOKEN 才是权威边界,漏写即等于对外开放伪造投递面。 */ @RestController @RequestMapping("/Internal/audit") public class AuditInternalController { @Autowired private AuditEventService auditEventService; @Autowired private DataInterfaceSignAuditService dataInterfaceSignAuditService; @PostMapping("/events") public ActionResult<Integer> record(@RequestBody AuditEventDTO dto) { // 权威安全边界:仅放行携带有效 SA-SAME-TOKEN 的服务间 Feign 调用;网关转发的普通登录请求 // 无此 header,抛 SameTokenInvalidException(映射 AD106)——覆盖一切网关绕过变体(见类注释) SaSameUtil.checkCurrentRequestToken(); int rows = auditEventService.saveIdempotent(dto); dataInterfaceSignAuditService.recordRelatedSign(dto); return ActionResult.success(rows); } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/controller/AuditPingController.java
New file @@ -0,0 +1,23 @@ package jnpf.bizcommon.audit.controller; import jnpf.base.ActionResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; /** * 链路验收探针:网关 /api/audit/ping(StripPrefix=2)→ 本服务 /ping。 * * <p><b>类名不叫 PingController</b>:本服务同 JVM 内已有 jnpf.bizcommon.onlyoffice.controller.PingController, * Spring 组件扫描的默认 bean 名都是 pingController,同名不同类会抛 ConflictingBeanDefinitionException * ——该冲突**不受** spring.main.allow-bean-definition-overriding 保护(scanner 自己的 isCompatible 检查)。 * 路径 /ping 刻意保持不变:网关路由与回归脚本因此零改动。 * 放行配置见 application.yml(sa-token / 租户 / 内部鉴权三处)。 */ @RestController public class AuditPingController { @GetMapping("/ping") public ActionResult<String> ping() { return ActionResult.success("pong"); } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/controller/AuditQueryController.java
New file @@ -0,0 +1,47 @@ package jnpf.bizcommon.audit.controller; import cn.dev33.satoken.annotation.SaCheckPermission; import jnpf.bizcommon.audit.entity.model.AuditEventDetailVO; import jnpf.bizcommon.audit.entity.model.AuditEventListVO; import jnpf.bizcommon.audit.entity.model.AuditPageQuery; import jnpf.bizcommon.audit.service.AuditQueryService; import jnpf.base.ActionResult; import jnpf.base.vo.PageListVO; import org.springframework.beans.factory.annotation.Autowired; 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; import java.util.List; /** * 审计事件查询 API(spec §7.1):操作分页 / 单事件详情 / 操作时间线。两级权限点: * audit.query(列表、时间线,无字段值正文)与 audit.detail(详情,含变更前后值等敏感内容)。 */ @RestController @RequestMapping("/audit/events") public class AuditQueryController { @Autowired private AuditQueryService auditQueryService; @SaCheckPermission("audit.query") @PostMapping("/page") public ActionResult<PageListVO<AuditEventListVO>> page(@RequestBody AuditPageQuery query) { return ActionResult.success(auditQueryService.page(query)); } @SaCheckPermission("audit.detail") @GetMapping("/{id}") public ActionResult<AuditEventDetailVO> detail(@PathVariable("id") Long id) { return ActionResult.success(auditQueryService.detail(id)); } @SaCheckPermission("audit.query") @GetMapping("/timeline") public ActionResult<List<AuditEventListVO>> timeline(@RequestParam("targetId") String targetId) { return ActionResult.success(auditQueryService.timeline(targetId)); } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/entity/AuditEventEntity.java
New file @@ -0,0 +1,56 @@ package jnpf.bizcommon.audit.entity; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.NoArgsConstructor; import java.util.Date; /** * 审计事件实体,对应表 audit_events(PG-only)。 */ @Data @NoArgsConstructor @TableName("audit_events") public class AuditEventEntity { @TableId(value = "id", type = IdType.AUTO) private Long id; @TableField("client_event_id") private String clientEventId; @TableField("operation_id") private String operationId; @TableField("event_time") private Date eventTime; @TableField("tenant_id") private String tenantId; @TableField("operator_id") private String operatorId; @TableField("operator_name") private String operatorName; @TableField("operator_org_id") private String operatorOrgId; @TableField("app_name") private String appName; @TableField("biz_module") private String bizModule; @TableField("ip") private String ip; @TableField("ip_region") private String ipRegion; @TableField("browser") private String browser; @TableField("os") private String os; @TableField("request_uri") private String requestUri; @TableField("event_type") private String eventType; @TableField("event_category") private String eventCategory; @TableField("action_code") private String actionCode; @TableField("action_label") private String actionLabel; @TableField("source_layer") private Integer sourceLayer; @TableField("target_table") private String targetTable; @TableField("target_id") private String targetId; @TableField("biz_type") private String bizType; @TableField("biz_code") private String bizCode; @TableField("field_diffs") private String fieldDiffs; @TableField("extra") private String extra; @TableField("data_snapshot") private String dataSnapshot; @TableField("reason") private String reason; @TableField("record_title") private String recordTitle; @TableField("entry_type") private String entryType; @TableField("entry_id") private String entryId; @TableField("entry_name") private String entryName; @TableField("prev_hash") private String prevHash; @TableField(value = "created_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER) private Date createdAt; // DB DEFAULT NOW(),应用不写 } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/entity/model/AuditEventDetailVO.java
New file @@ -0,0 +1,83 @@ package jnpf.bizcommon.audit.entity.model; import lombok.Data; import java.util.Date; import java.util.List; /** * 审计事件详情(spec §7.1):全字段,含 field_diffs/extra/data_snapshot 正文与环境信息。 * 权限点 audit.detail 单独控制,与列表接口(audit.query)分级。 */ @Data public class AuditEventDetailVO { private Long id; private String clientEventId; private String operationId; private Date eventTime; private String tenantId; private String operatorId; private String operatorName; private String operatorOrgId; private String appName; private String bizModule; private String ip; private String ipRegion; private String browser; private String os; private String requestUri; private String eventType; private String actionCode; private String actionLabel; private Integer sourceLayer; private String targetTable; private String targetId; private String bizType; private String bizCode; private String fieldDiffs; /** 向后兼容的结构化字段变更;fieldDiffs 字符串在兼容期内继续保留。 */ private List<AuditFieldDiffViewVO> fieldDiffList; private String extra; private String dataSnapshot; private String reason; private String recordTitle; private String entryType; private String entryId; private String entryName; private String prevHash; private Date createdAt; } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/entity/model/AuditEventListVO.java
New file @@ -0,0 +1,95 @@ package jnpf.bizcommon.audit.entity.model; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.Date; import java.util.List; /** * 审计操作列表行(spec §7.1):主行是 BUSINESS 物理事件,subEvents 是同 operationId * 下的签名物理事件。查询层只建立关联,不合并 fieldDiffs/extra/dataSnapshot 正文。 */ @Data public class AuditEventListVO { private Long id; @Schema(description = "操作关联键;无跨层关联时为 null(单事件分组)") private String operationId; private String clientEventId; private Date eventTime; private String tenantId; private String operatorId; private String operatorName; private String operatorOrgId; private String appName; private String bizModule; private String ip; private String ipRegion; private String browser; private String os; private String requestUri; private String eventType; @Schema(description = "电子签名角色:PRIMARY 主签名、REVIEW 复审签名;非签名事件为空") private String signatureRole; private String actionCode; private String actionLabel; private Integer sourceLayer; private String targetTable; private String targetId; private String bizType; private String bizCode; @Schema(description = "field_diffs 变更字段数摘要,正文见详情接口") private Integer diffCount; private String reason; @Schema(description = "操作记录标题(主表行);子表行见 subTitles") private String recordTitle; private String entryType; private String entryId; @Schema(description = "操作入口名称,entry_type=MENU 时为菜单名") private String entryName; private Date createdAt; @Schema(description = "本次操作是否含电子签名(含子事件);列表据此显示签名标记") private Boolean signed; @Schema(description = "子表标题行,最多 3 条;全量在详情的 extra.recordSubTitles") private List<String> subTitles; @Schema(description = "子表标题总行数") private Integer subTitleCount; @Schema(description = "同一操作下的关联签名物理事件,用于详情逐条切换;不参与业务差异合并") private List<AuditEventListVO> subEvents; } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/entity/model/AuditFieldDiffViewVO.java
New file @@ -0,0 +1,49 @@ package jnpf.bizcommon.audit.entity.model; import lombok.Data; import java.util.List; import java.util.Map; /** * 审计详情中的结构化字段变更。原始值只承担证据职责,展示优先使用 oldDisplay/newDisplay。 */ @Data public class AuditFieldDiffViewVO { private String field; private String fieldName; private String targetTable; private String changeType; private String valueType; private String componentType; private String jnpfKey; private Boolean nameModified; private Integer type; private String oldData; private String newData; private String oldDisplay; private String newDisplay; private Boolean masked; private Boolean technical; /** 沿用 JNPF 原生日志字段名,兼容既有 ChildLogModal。 */ private List<Map<String, Object>> chidData; /** 沿用 JNPF 原生日志字段名,兼容既有 ChildLogModal。 */ private List<Map<String, Object>> chidField; } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/entity/model/AuditOperationGroupRow.java
New file @@ -0,0 +1,12 @@ package jnpf.bizcommon.audit.entity.model; import lombok.Data; import java.util.Date; /** 审计列表按 operationId 分页的轻量分组行。 */ @Data public class AuditOperationGroupRow { private String gid; private Date grpTime; } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/entity/model/AuditPageQuery.java
New file @@ -0,0 +1,74 @@ package jnpf.bizcommon.audit.entity.model; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import jnpf.base.Pagination; import lombok.Data; import lombok.EqualsAndHashCode; import java.util.Date; /** * 审计事件分页查询入参(spec §7.1)。沿平台 Pagination 惯例继承分页基类 * (currentPage/pageSize 取自 {@link Pagination},默认 currentPage=1、pageSize=20)。 */ @Data @EqualsAndHashCode(callSuper = true) public class AuditPageQuery extends Pagination { /** * 起止时间(Task D1 拍板,收 M2 遗留清单 §五-③)。 * * <p>本类走 {@code @RequestBody} 由 Jackson 反序列化。**不加这个注解时, * 传空格分隔的 {@code "yyyy-MM-dd HH:mm:ss"} 会报 `System abnormality`**——Jackson 默认只认 * ISO8601 与 epoch 数字,不认空格分隔(注意:平台的 `JsonUtil` 走 fastjson、认这个格式, * 但那是**消息通道**的反序列化器,与 Web 入参不是同一条路,早前的排查在这里绕过弯)。 * * <p>选择「加注解」而不是「让前端对齐 epoch 毫秒」的理由:`yyyy-MM-dd HH:mm:ss` 是本平台 * 前端日期控件的默认出参格式,`jnpf-flowable` / `jnpf-visualdata` 等模块的入参模型都是这么标的 * (如 `FlowableInstanceModel`),跟着平台惯例走比让每个接入方记住一个例外便宜。 * * <p>⚠️ **两种格式都要能收**:既有脚本(`tasks/audit-test.sh` 等)传的是 epoch 毫秒**数字**。 * Jackson 的 `DateDeserializer` 对 `VALUE_NUMBER_INT` 先行短路、不过 pattern,故加 pattern * 不影响数字形态——这一点**已用探针实测**而非推断,见 `tasks/audit-query-datefmt-probe.sh`。 * 但 epoch 毫秒若以**字符串**形态传(`"1785120484000"`)则会被 pattern 拒收,脚本勿改成那样。 */ @Schema(description = "起始时间(含)。接受 \"yyyy-MM-dd HH:mm:ss\" 或 epoch 毫秒数字") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date startTime; @Schema(description = "结束时间(含)。接受 \"yyyy-MM-dd HH:mm:ss\" 或 epoch 毫秒数字") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date endTime; @Schema(description = "操作人姓名,模糊匹配") private String operatorName; @Schema(description = "应用名,如 jnpf-lims") private String appName; /** * Codex 批次三a Important#1:spec §7.1 层 0 事件的 app_name 固定为 jnpf-visualdev(发起服务), * 用户视角"按业务应用筛选"应落在 biz_module(业务模块标识,如 lims/eln)。app_name 保留供技术侧筛选。 */ @Schema(description = "业务模块,如 lims/eln(用户视角应用筛选,区别于技术侧 app_name)") private String bizModule; @Schema(description = "事件类型:DATA_CHANGE / BIZ_ACTION / E_SIGNATURE") private String eventType; @Schema(description = "动作码:CREATE/UPDATE/DELETE/...") private String actionCode; @Schema(description = "目标表名") private String targetTable; @Schema(description = "业务单号") private String bizCode; @Schema(description = "业务对象类型") private String bizType; @Schema(description = "关键字,匹配 field_diffs 内容(pg_bigm 加速)") private String keyword; } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/mapper/AuditEventMapper.java
New file @@ -0,0 +1,32 @@ package jnpf.bizcommon.audit.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import jnpf.bizcommon.audit.entity.AuditEventEntity; import org.apache.ibatis.annotations.Param; public interface AuditEventMapper extends BaseMapper<AuditEventEntity> { int insertIgnore(AuditEventEntity e); AuditEventEntity selectByOperationCategoryForUpdate(@Param("operationId") String operationId, @Param("eventCategory") String eventCategory); int updateMerged(AuditEventEntity e); /** * 这批签名 id 里,哪些**已经被记过签名事件**(A1-b,2026-08-03)。 * * <p>同样走 {@code idx_audit_events_target (target_table, target_id)}——{@code signTable} * 是前导列。判定语义与 fail 方向见 * {@link jnpf.bizcommon.audit.service.listener.AuditVisualLogListener#recordedSignIds}。 * * @param operationId 本次操作的 operation_id,**排除**在外:MQ 重投递与批量共享 batchOpId * 的场景下,同一次操作自己写下的签名事件不能把自己判成历史。 */ java.util.List<String> selectRecordedSignIds(@Param("signTable") String signTable, @Param("eventType") String eventType, @Param("actionCode") String actionCode, @Param("sourceLayer") Integer sourceLayer, @Param("signIds") java.util.Collection<String> signIds, @Param("operationId") String operationId, @Param("tenantId") String tenantId); } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/mapper/AuditQueryMapper.java
New file @@ -0,0 +1,29 @@ package jnpf.bizcommon.audit.mapper; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.toolkit.Constants; import jnpf.bizcommon.audit.entity.AuditEventEntity; import jnpf.bizcommon.audit.entity.model.AuditOperationGroupRow; import org.apache.ibatis.annotations.Param; import java.util.List; /** * 审计事件查询侧自定义 Mapper:按 operationId 分页 + 关联物理事件 + 时间线。与 Task 4 的 * {@link AuditEventMapper}(写入侧 insertIgnore)职责分离,避免相互影响。 * * fieldDiffs 合并已在写入侧完成;查询层仅建立 BUSINESS 主行与签名事件的关联。 */ public interface AuditQueryMapper { List<AuditOperationGroupRow> selectOperationPage(@Param(Constants.WRAPPER) Wrapper<AuditEventEntity> ew, @Param("offset") long offset, @Param("size") long size); long countOperations(@Param(Constants.WRAPPER) Wrapper<AuditEventEntity> ew); List<AuditEventEntity> selectByOperationGids(@Param("gids") List<String> gids); /** 时间线分组种子:按 targetId、数据库自增 id 正序,调用方传入 LIMIT 常量(spec:500)。 */ List<AuditEventEntity> selectTimeline(@Param("targetId") String targetId, @Param("limit") int limit); } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/AuditEventService.java
New file @@ -0,0 +1,17 @@ package jnpf.bizcommon.audit.service; import jnpf.audit.model.AuditEventDTO; public interface AuditEventService { /** * 幂等落库审计事件:同一 operationId 按 BUSINESS / PRIMARY_SIGN / REVIEW_SIGN * 事务合并,数据库唯一索引保证并发下每类最多一行。 * * @param dto 审计事件 DTO * @return 1 = 新插入或合并更新,0 = client_event_id 重复,已忽略 * @throws IllegalArgumentException eventType 不在受控枚举内,或必填字段 * (clientEventId/operatorId/appName/actionCode/eventTime/tenantId/sourceLayer)缺失 */ int saveIdempotent(AuditEventDTO dto); } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/AuditFieldDiffViewService.java
New file @@ -0,0 +1,485 @@ package jnpf.bizcommon.audit.service; import com.alibaba.fastjson.JSON; import jnpf.bizcommon.audit.entity.AuditEventEntity; import jnpf.audit.diff.AuditFieldDiff; import jnpf.bizcommon.audit.entity.model.AuditFieldDiffViewVO; import jnpf.bizcommon.audit.service.support.AuditQueryResponseSanitizer; import jnpf.bizcommon.audit.service.value.AuditFieldValueContext; import jnpf.bizcommon.audit.service.value.AuditFieldValueResolver; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; /** 将兼容字符串 fieldDiffs 装配成结构化、可展示且可脱敏的字段变更。 */ @Service @Slf4j public class AuditFieldDiffViewService { private final List<AuditFieldValueResolver> resolvers; private final ZoneId businessZone; @Autowired public AuditFieldDiffViewService( List<AuditFieldValueResolver> resolvers, @Value("${audit.query.business-time-zone:Asia/Shanghai}") String businessTimeZone) { this.resolvers = resolvers == null ? Collections.emptyList() : new ArrayList<>(resolvers); this.businessZone = parseZone(businessTimeZone); } /** * sanitizedFieldDiffs 已由查询脱敏器过滤无意义项并处理凭证引用,原始事件不会被修改。 */ @SuppressWarnings("unchecked") public List<AuditFieldDiffViewVO> build(AuditEventEntity event, String sanitizedFieldDiffs, Map<String, AuditFieldDiff.FieldMeta> currentMetadata) { if (sanitizedFieldDiffs == null || sanitizedFieldDiffs.trim().isEmpty()) { return Collections.emptyList(); } Object parsed; try { parsed = JSON.parse(sanitizedFieldDiffs); } catch (Throwable ignored) { return Collections.emptyList(); } if (!(parsed instanceof List)) { return Collections.emptyList(); } List<AuditFieldDiffViewVO> views = new ArrayList<>(); Map<String, String> dictionaryTypes = new LinkedHashMap<>(); for (Object raw : (List<?>) parsed) { if (!(raw instanceof Map)) { continue; } Map<String, Object> item = (Map<String, Object>) raw; AuditFieldDiffViewVO view = toView(item, currentMetadata, event == null ? null : event.getActionCode()); if (view != null) { views.add(view); AuditFieldDiff.FieldMeta meta = metadata(currentMetadata, view.getField()); String dictionaryType = firstText(item.get("dictionaryType"), meta == null ? null : meta.dictionaryType()); if (dictionaryType != null) { dictionaryTypes.put(view.getField().toLowerCase(Locale.ROOT), dictionaryType); } } } AuditFieldValueContext context = new AuditFieldValueContext( event == null ? null : event.getTargetTable(), event == null ? null : event.getTenantId(), event == null ? null : event.getEventTime(), dictionaryTypes); List<AuditFieldDiffViewVO> resolvedViews = views; for (AuditFieldValueResolver resolver : resolvers) { if (resolver != null) { try { if (!resolver.supports(context.getTargetTable())) { continue; } List<AuditFieldDiffViewVO> candidate = copyViews(resolvedViews); resolver.resolve(context, candidate); resolvedViews = candidate; } catch (RuntimeException ex) { // 解析失败只能降级当前 resolver,日志不携带字段值或详情正文。 log.warn("[AUDIT-QUERY] 字段值解析器失败 resolver={} err={}", resolver.getClass().getSimpleName(), ex.getClass().getSimpleName()); } } } for (AuditFieldDiffViewVO view : resolvedViews) { finishDisplay(view); } return resolvedViews; } /** * 为新事件固化可可靠解析的类型和展示值,同时保留旧 field_diffs 的 type/jnpfKey 等键。 * 解析失败时原样返回,不能因为展示增强造成 Layer 0 事件丢失。 */ @SuppressWarnings("unchecked") public String enrichForPersistence(AuditEventEntity event, String fieldDiffs, Map<String, AuditFieldDiff.FieldMeta> metadata) { if (fieldDiffs == null || fieldDiffs.trim().isEmpty()) { return fieldDiffs; } try { Object parsed = JSON.parse(fieldDiffs); if (!(parsed instanceof List)) { return fieldDiffs; } List<AuditFieldDiffViewVO> views = build(event, fieldDiffs, metadata); Map<String, AuditFieldDiffViewVO> byField = new LinkedHashMap<>(); for (AuditFieldDiffViewVO view : views) { if (view.getField() != null) { byField.put(view.getField().toLowerCase(Locale.ROOT), view); } } for (Object raw : (List<?>) parsed) { if (!(raw instanceof Map)) { continue; } Map<String, Object> item = (Map<String, Object>) raw; String field = text(item.get("field")); AuditFieldDiffViewVO view = field == null ? null : byField.get(field.toLowerCase(Locale.ROOT)); if (view == null) { continue; } putIfMissing(item, "valueType", view.getValueType()); putIfMissing(item, "componentType", view.getComponentType()); if (Boolean.TRUE.equals(view.getMasked())) { item.put("masked", true); item.put("valueType", "masked"); item.put("componentType", "sign"); if (item.containsKey("oldData")) { item.put("oldData", AuditFieldDiff.MASKED); } if (item.containsKey("newData")) { item.put("newData", AuditFieldDiff.MASKED); } item.remove("oldDisplay"); item.remove("newDisplay"); continue; } if (Boolean.TRUE.equals(view.getTechnical())) { item.put("technical", true); } if (snapshotDisplayType(view.getValueType())) { putDisplayIfResolved(item, "oldDisplay", view.getOldDisplay()); putDisplayIfResolved(item, "newDisplay", view.getNewDisplay()); } } return JSON.toJSONString(parsed); } catch (Throwable ex) { log.warn("[AUDIT-L0] 字段展示快照固化失败 err={}", ex.getClass().getSimpleName()); return fieldDiffs; } } private AuditFieldDiffViewVO toView(Map<String, Object> item, Map<String, AuditFieldDiff.FieldMeta> currentMetadata, String actionCode) { String field = text(item.get("field")); if (field == null) { return null; } AuditFieldDiff.FieldMeta meta = metadata(currentMetadata, field); boolean credential = AuditQueryResponseSanitizer.isCredentialReference(field, item, meta); boolean masked = credential || AuditFieldDiff.MASKED.equals(text(item.get("oldData"))) || AuditFieldDiff.MASKED.equals(text(item.get("newData"))); AuditFieldDiffViewVO view = new AuditFieldDiffViewVO(); view.setField(field); view.setTargetTable(text(item.get("targetTable"))); view.setFieldName(credential ? "电子签名" : fieldName(item, meta)); view.setChangeType(changeType(item, actionCode, masked)); String componentType = credential ? "sign" : firstText(item.get("componentType"), item.get("jnpfKey"), meta == null ? null : meta.componentType()); String valueType = masked ? "masked" : firstText(item.get("valueType"), meta == null ? null : meta.valueType()); if (valueType == null && isTemporalComponent(componentType)) { valueType = inferValueType(componentType); } view.setValueType(valueType); view.setComponentType(componentType); view.setJnpfKey(firstText(item.get("jnpfKey"), componentType)); view.setNameModified(bool(item.get("nameModified"))); view.setType(integer(item.get("type"))); view.setChidData(mapList(item.get("chidData"))); view.setChidField(mapList(item.get("chidField"))); view.setMasked(masked); view.setTechnical(bool(item.get("technical"))); if (masked) { view.setOldData(null); view.setNewData(null); view.setOldDisplay(null); view.setNewDisplay(null); return view; } String oldData = rawText(item.get("oldData")); String newData = rawText(item.get("newData")); view.setOldData(oldData); view.setNewData(newData); view.setOldDisplay(firstTextPreserveEmpty(item.get("oldDisplay"), optionDisplay(meta, oldData), temporalDisplay(view.getValueType(), item, meta, oldData))); view.setNewDisplay(firstTextPreserveEmpty(item.get("newDisplay"), optionDisplay(meta, newData), temporalDisplay(view.getValueType(), item, meta, newData))); return view; } private void finishDisplay(AuditFieldDiffViewVO view) { if (Boolean.TRUE.equals(view.getMasked())) { return; } if (view.getValueType() == null) { view.setValueType(inferValueType(view.getComponentType())); } if (view.getComponentType() == null) { view.setComponentType("input"); } view.setOldDisplay(fallbackDisplay(view.getValueType(), view.getOldData(), view.getOldDisplay())); view.setNewDisplay(fallbackDisplay(view.getValueType(), view.getNewData(), view.getNewDisplay())); if (view.getTechnical() == null) { view.setTechnical(Boolean.FALSE); } if (view.getMasked() == null) { view.setMasked(Boolean.FALSE); } } private String fallbackDisplay(String valueType, String raw, String display) { if (display != null) { return display; } if (raw == null || raw.trim().isEmpty()) { return ""; } if ("reference".equals(valueType) || "multiReference".equals(valueType) || "enum".equals(valueType) || "multiEnum".equals(valueType)) { return "名称暂不可用"; } return raw; } private String temporalDisplay(String valueType, Map<String, Object> item, AuditFieldDiff.FieldMeta meta, String raw) { if (!("date".equals(valueType) || "datetime".equals(valueType)) || raw == null || raw.trim().isEmpty()) { return null; } try { long epochMillis = Long.parseLong(raw.trim()); String pattern = firstText(item.get("format"), meta == null ? null : meta.format(), "date".equals(valueType) ? "yyyy-MM-dd" : "yyyy-MM-dd HH:mm:ss"); return DateTimeFormatter.ofPattern(pattern, Locale.ROOT) .withZone(businessZone).format(Instant.ofEpochMilli(epochMillis)); } catch (RuntimeException ignored) { return null; } } private static String optionDisplay(AuditFieldDiff.FieldMeta meta, String raw) { return meta == null ? null : meta.optionDisplay(raw); } private static boolean snapshotDisplayType(String valueType) { return "enum".equals(valueType) || "multiEnum".equals(valueType) || "reference".equals(valueType) || "multiReference".equals(valueType) || "date".equals(valueType) || "datetime".equals(valueType); } private static void putIfMissing(Map<String, Object> item, String key, String value) { if (!item.containsKey(key) && value != null) { item.put(key, value); } } private static void putDisplayIfResolved(Map<String, Object> item, String key, String display) { if (!item.containsKey(key) && display != null && !display.trim().isEmpty() && !"名称暂不可用".equals(display)) { item.put(key, display); } } private static AuditFieldDiff.FieldMeta metadata( Map<String, AuditFieldDiff.FieldMeta> metadata, String field) { if (metadata == null || field == null) { return null; } AuditFieldDiff.FieldMeta exact = metadata.get(field); if (exact != null) { return exact; } AuditFieldDiff.FieldMeta normalized = metadata.get(field.toLowerCase(Locale.ROOT)); if (normalized != null) { return normalized; } StringBuilder snake = new StringBuilder(field.length() + 8); for (int i = 0; i < field.length(); i++) { char ch = field.charAt(i); if (Character.isUpperCase(ch)) { snake.append('_').append(Character.toLowerCase(ch)); } else { snake.append(Character.toLowerCase(ch)); } } return metadata.get(snake.toString()); } private static String fieldName(Map<String, Object> item, AuditFieldDiff.FieldMeta meta) { String name = text(item.get("fieldName")); if (name != null) { return name; } return meta != null && meta.label() != null && !meta.label().trim().isEmpty() ? meta.label() : text(item.get("field")); } private static String changeType(Map<String, Object> item, String actionCode, boolean masked) { if (masked) { return "updated"; } String explicit = text(item.get("changeType")); if ("created".equals(explicit) || "updated".equals(explicit) || "deleted".equals(explicit)) { return explicit; } if (actionCode != null && actionCode.toUpperCase(Locale.ROOT).contains("DELETE")) { return "deleted"; } Object type = item.get("type"); return type != null && "0".equals(String.valueOf(type)) ? "created" : "updated"; } private static String inferValueType(String component) { if (component == null) { return "text"; } String key = component.toLowerCase(Locale.ROOT); if (key.contains("time")) { return "datetime"; } if (key.contains("date")) { return "date"; } if (key.contains("number") || "slider".equals(key) || "rate".equals(key)) { return "number"; } if ("switch".equals(key)) { return "boolean"; } if ("checkbox".equals(key)) { return "multiEnum"; } if (key.contains("select") || "radio".equals(key)) { return "enum"; } if ("table".equals(key) || key.contains("upload")) { return "json"; } return "text"; } private static boolean isTemporalComponent(String component) { if (component == null) { return false; } String key = component.toLowerCase(Locale.ROOT); return key.contains("date") || key.contains("time"); } private static List<AuditFieldDiffViewVO> copyViews(List<AuditFieldDiffViewVO> source) { List<AuditFieldDiffViewVO> copy = new ArrayList<>(source.size()); for (AuditFieldDiffViewVO value : source) { AuditFieldDiffViewVO item = new AuditFieldDiffViewVO(); item.setField(value.getField()); item.setTargetTable(value.getTargetTable()); item.setFieldName(value.getFieldName()); item.setChangeType(value.getChangeType()); item.setValueType(value.getValueType()); item.setComponentType(value.getComponentType()); item.setJnpfKey(value.getJnpfKey()); item.setNameModified(value.getNameModified()); item.setType(value.getType()); item.setChidData(value.getChidData()); item.setChidField(value.getChidField()); item.setOldData(value.getOldData()); item.setNewData(value.getNewData()); item.setOldDisplay(value.getOldDisplay()); item.setNewDisplay(value.getNewDisplay()); item.setMasked(value.getMasked()); item.setTechnical(value.getTechnical()); copy.add(item); } return copy; } private static ZoneId parseZone(String zone) { try { return ZoneId.of(zone == null || zone.trim().isEmpty() ? "Asia/Shanghai" : zone.trim()); } catch (RuntimeException ignored) { return ZoneId.of("Asia/Shanghai"); } } private static boolean bool(Object value) { return value instanceof Boolean ? (Boolean) value : value != null && "true".equalsIgnoreCase(String.valueOf(value)); } private static Integer integer(Object value) { if (value instanceof Number) { return ((Number) value).intValue(); } try { return value == null ? null : Integer.valueOf(String.valueOf(value)); } catch (NumberFormatException ignored) { return null; } } @SuppressWarnings("unchecked") private static List<Map<String, Object>> mapList(Object value) { if (!(value instanceof List)) { return null; } List<Map<String, Object>> result = new ArrayList<>(); for (Object item : (List<?>) value) { if (item instanceof Map) { result.add(new LinkedHashMap<>((Map<String, Object>) item)); } } return result.isEmpty() ? null : result; } private static String rawText(Object value) { return value == null ? "" : String.valueOf(value); } private static String text(Object value) { if (value == null) { return null; } String result = String.valueOf(value).trim(); return result.isEmpty() ? null : result; } private static String firstText(Object... values) { if (values != null) { for (Object value : values) { String result = text(value); if (result != null) { return result; } } } return null; } private static String firstTextPreserveEmpty(Object... values) { if (values != null) { for (Object value : values) { if (value != null) { return String.valueOf(value); } } } return null; } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/AuditFormRegistry.java
New file @@ -0,0 +1,1129 @@ package jnpf.bizcommon.audit.service; import jnpf.audit.diff.AuditFieldDiff; import jnpf.bizcommon.audit.service.title.AuditListTitleSpecParser; import jnpf.bizcommon.audit.service.title.AuditTitleSpec; import jnpf.bizcommon.audit.service.title.AuditTitleSpecParser; import jnpf.util.JsonUtil; import jnpf.util.visiual.JnpfKeyConsts; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.ResultSet; import java.util.ArrayList; import java.util.Collections; 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.concurrent.ConcurrentHashMap; /** * 层 0 表单注册表(Task C1):回答「这个在线表单要不要审计、算哪个业务模块」。 * * <p>取代 lims 侧 {@code BizFormRegistry} 的 18 条硬编码静态 Map——那张表每接一个新表单就要改 * lims 的代码、重新打包、重启服务,业务模块之间还会互相踩。现在改成 <b>Nacos 配置驱动</b> * (data-id {@code audit-layer0-forms.yaml}),各业务模块自助注册自己的表单,audit 服务不动代码。 * * <p><b>按主表名注册,而不是按 modelId</b>:modelId 是无意义的雪花数字({@code 844231923957086149}), * 写进配置没人看得懂也没人敢改;而且同一张业务主表常有多个表单视图(列表页/详情页/移动端各一个 * modelId),按表登记只需一条。modelId → 主表名这一跳仍走 JDBC 反查 {@code base_visual_release} * 并缓存,与旧实现同口径——A/B 比对时少一个变量。 * * <p>是否只按清单审计由 {@code includeUnregisteredForms} 决定。开启后,允许的应用前台中所有在线 * 表单都纳入审计;清单继续提供模块、业务类型、脱敏字段等更准确的元数据。 */ @Slf4j @Component @ConfigurationProperties(prefix = "audit.layer0") public class AuditFormRegistry { /** * 层 0 总开关。默认关——与层 1 同纪律:新埋点默认不上,按表灰度开。 * volatile:Nacos 刷新线程写、MQ 消费线程读(评审 M-1)。 */ private volatile boolean enabled = false; /** 仅接受前端应用前台请求;旧消息没有 appScope 时按非前台处理。 */ private volatile boolean frontendOnly = false; /** READ 日志独立开关,默认关闭以避免列表轮询产生大量日志。 */ private volatile boolean recordRead = false; /** 允许的前端应用编码;空集合表示不限制具体应用。 */ private List<String> allowedAppCodes = new ArrayList<>(); private volatile Set<String> allowedAppCodeSet = Collections.emptySet(); /** true 时,应用前台访问的未显式登记在线表单也纳入审计。 */ private volatile boolean includeUnregisteredForms = false; /** * 注册清单,由 Nacos 配置注入。 * * <p><b>形态必须是 List 而不是 Map<表名, 元数据></b>(C1 实测踩到):Spring Boot Binder * 对 Map 属性做的是<b>合并</b>——刷新时它先调 getter 拿到现有 Map,再把新配置 put 进去, * 于是<b>被删掉的表单永远留在注册表里</b>,「从清单移除一张表」只有重启才生效。 * 审计范围只能加不能减,是运营上的真隐患(实测:撤回配置后那张表照记不误)。 * 索引集合(List)的绑定则是整体替换,setter 每次都拿到完整的新清单,增删都即时生效。 */ private List<FormMeta> forms = new ArrayList<>(); /** 主表名(小写)→ 元数据。由 {@link #setForms} 从清单重建,每次刷新整体替换。 */ private volatile Map<String, FormMeta> formsByTable = Collections.emptyMap(); /** * modelId → 主表名 的运行时缓存。 * * <p><b>只缓存确定性结果</b>(空串占位表示「确定地查不到」):查得到发布记录但里面没有表、 * 或那段 JSON 根本不合法——重试一万次也一样。**瞬时故障一律不缓存**(连接抖动、超时、 * OOM/SO)——L061 ①:缓存"失败"必须区分确定性与瞬时,而且必须用白名单判定。 * 这里的缓存是进程级、无过期的,一次连接抖动若被记成「这个 modelId 没有主表」, * 该表单从此到重启前**永远不被审计**,而且日志里什么都没有。 * * <p>缓存使用有限 TTL;注册表热刷新和 TTL 配置变化还会主动清空。表单重新发布但未触发 * 注册表刷新时,最迟在 TTL 到期后重新读取,避免历史查询长期绑定旧配置。 */ private final Map<String, CacheValue<String>> modelIdToMainTable = new ConcurrentHashMap<>(); /** modelId → 已发布表单字段元数据;只缓存确定性查询/解析结果。 */ private final Map<String, CacheValue<Map<String, AuditFieldDiff.FieldMeta>>> modelIdToFieldMeta = new ConcurrentHashMap<>(); /** modelId → 标题勾选配置;与字段元数据同一套 TTL / 只缓存确定性结果的口径。 */ private final Map<String, CacheValue<AuditTitleSpec>> modelIdToTitleSpec = new ConcurrentHashMap<>(); /** 列表标题规格缓存(D6)。与 modelIdToTitleSpec 分开:同一 modelId 两种口径不能共用一格。 */ private final Map<String, CacheValue<AuditTitleSpec>> modelIdToListTitleSpec = new ConcurrentHashMap<>(); /** 签名字段配置缓存(D4)。 */ private final Map<String, CacheValue<List<String>>> modelIdToSignFields = new ConcurrentHashMap<>(); /** 数据库字段备注缓存;查询异常时不写缓存。 */ private final Map<String, CacheValue<Map<String, AuditFieldDiff.FieldMeta>>> tableToCommentMeta = new ConcurrentHashMap<>(); /** 发布表单、主表反查及 COMMENT 元数据缓存 TTL;Nacos 刷新注册表时还会主动清空。 */ private volatile long metadataCacheTtlMs = 300_000L; /** 反查不到主表的 modelId 只告警一次,避免高频表单把日志刷爆。 */ private final Set<String> warnedModels = ConcurrentHashMap.newKeySet(); @Autowired private JdbcTemplate jdbcTemplate; /** * 一个在线表单在审计里的身份。 * 除 {@code table} 外字段全部可选,缺了只是记得糙一点,不影响事件是否落库。 */ @Data public static class FormMeta { /** 业务主表名(清单的主键)。缺失或空白的条目会被 {@link #setForms} 丢弃并告警。 */ private String table; /** 业务模块语义,落 {@code audit_events.biz_module},如「环境监测-区域管理」。 */ private String bizModule; /** 业务分类,落 {@code biz_type},与旧 {@code BizLogType} 同位。 */ private String bizType; /** 从表单数据里取业务单号的字段名,落 {@code biz_code};取不到就留空,**不丢事件**。 */ private String bizCodeField; /** 脱敏字段(小写):只记「已变更」不记值,与层 1 的 masked-columns 同语义。 */ private Set<String> maskedFields = Collections.emptySet(); } /** * 启动完成后打印一次**权威**生效态。 * * <p>{@link #setForms} 里那行也打了 enabled,但属性绑定时 setter 的调用顺序不定, * 那一行可能早于 {@link #setEnabled} 而显示上一拍的值。这里在绑定全部完成后再报一次, * 排查「到底开没开」时以这行为准。 */ @jakarta.annotation.PostConstruct public void logEffectiveState() { log.info("[AUDIT-L0] 层0 生效态:enabled={},注册表 {} 张表 {}", enabled, formsByTable.size(), formsByTable.keySet()); } public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } public boolean isRecordRead() { return recordRead; } public void setRecordRead(boolean recordRead) { this.recordRead = recordRead; } public boolean isFrontendOnly() { return frontendOnly; } public void setFrontendOnly(boolean frontendOnly) { this.frontendOnly = frontendOnly; } public boolean isIncludeUnregisteredForms() { return includeUnregisteredForms; } public void setIncludeUnregisteredForms(boolean includeUnregisteredForms) { this.includeUnregisteredForms = includeUnregisteredForms; clearMetadataCaches(); } public List<String> getAllowedAppCodes() { return allowedAppCodes; } public void setAllowedAppCodes(List<String> allowedAppCodes) { this.allowedAppCodes = allowedAppCodes == null ? new ArrayList<>() : allowedAppCodes; Set<String> normalized = new LinkedHashSet<>(); for (String code : this.allowedAppCodes) { if (code != null && !code.trim().isEmpty()) { normalized.add(code.trim().toUpperCase(Locale.ROOT)); } } this.allowedAppCodeSet = normalized.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(normalized); } /** 判断请求来源是否属于配置允许的应用前台。 */ public boolean acceptsRequest(String appScope, String appCode) { if (frontendOnly && !"FRONTEND".equalsIgnoreCase(appScope == null ? "" : appScope.trim())) { return false; } if (allowedAppCodeSet.isEmpty()) { return true; } return appCode != null && allowedAppCodeSet.contains(appCode.trim().toUpperCase(Locale.ROOT)); } public long getMetadataCacheTtlMs() { return metadataCacheTtlMs; } public void setMetadataCacheTtlMs(long metadataCacheTtlMs) { this.metadataCacheTtlMs = Math.max(0L, metadataCacheTtlMs); clearMetadataCaches(); } public List<FormMeta> getForms() { return forms; } /** * 从清单重建查找表,**整体替换**(这正是用 List 的意义,见 {@link #forms} 注释)。 * * <p>表名与脱敏字段一律归一成小写:PG 里的表名是小写,而配置是手写的(大小写、空格随人), * 在入口处统一一次,比在每个查询点各写一次 toLowerCase 可靠——漏一处就是脱敏静默失效。 */ public void setForms(List<FormMeta> forms) { this.forms = forms == null ? new ArrayList<>() : forms; Map<String, FormMeta> rebuilt = new LinkedHashMap<>(); for (FormMeta meta : this.forms) { if (meta == null) { continue; } String table = meta.getTable() == null ? null : lower(meta.getTable().trim()); if (table == null || table.isEmpty()) { // 没有 table 的条目无从匹配,静默丢弃就成了「配了却不生效」的哑谜 log.error("[AUDIT-L0] 注册表里有一条缺 table 的记录,已忽略:{}", meta); continue; } meta.setTable(table); // 归一化写回,免得将来有人用 meta.getTable() 拿到大小写不一的原值 meta.setMaskedFields(lowerAll(meta.getMaskedFields())); rebuilt.put(table, meta); } this.formsByTable = rebuilt; clearMetadataCaches(); // **总开关一并打印**(评审 M-5):只报「载入了几张表」的话,关态与「一张没配」在日志上 // 长得一模一样,排查时分不出是没配置还是被关掉了。 log.info("[AUDIT-L0] 表单注册表已载入 {} 张表(层0 总开关 enabled={}):{}", rebuilt.size(), enabled, rebuilt.keySet()); } /** * 解析 modelId 对应的审计元数据;未注册(或反查不到主表)返回 null = 不审计该表单。 */ public FormMeta resolve(String modelId) { String table = resolveMainTable(modelId); if (table == null) { return null; } FormMeta configured = formsByTable.get(table); if (configured != null || !includeUnregisteredForms) { return configured; } FormMeta fallback = new FormMeta(); fallback.setTable(table); return fallback; } /** * 查询在线开发表单名称;查不到或查询异常时返回 null,由调用方沿用原 bizModule 回退逻辑。 */ public String resolveFullName(String modelId) { if (modelId == null || modelId.trim().isEmpty()) { return null; } String normalizedModelId = modelId.trim(); try { String fullName = jdbcTemplate.queryForObject( "SELECT f_full_name FROM base_visual_dev WHERE f_id = ?", String.class, normalizedModelId); return fullName == null || fullName.trim().isEmpty() ? null : fullName.trim(); } catch (org.springframework.dao.EmptyResultDataAccessException e) { return null; } catch (Throwable t) { log.warn("[AUDIT-L0] 反查在线开发表单名称失败,继续使用原 bizModule modelId={} err={}", normalizedModelId, t.toString()); return null; } } /** * 取当前已发布表单的字段元数据,数据库字段备注仅补表单中缺失的字段。 * 表名必须在层 0 注册表白名单内,避免元数据 SQL 被任意表名驱动。 */ public Map<String, AuditFieldDiff.FieldMeta> resolveFieldMetadata(String modelId, String table) { String normalizedTable = table == null ? null : lower(table.trim()); if (normalizedTable == null || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable))) { return Collections.emptyMap(); } Map<String, AuditFieldDiff.FieldMeta> comments = commentMetadata(normalizedTable); if (modelId == null || modelId.trim().isEmpty()) { return comments; } // 查询历史事件时 sourceModelId 来自事件 extra,不能假定它一定属于 targetTable。 // 不匹配时只保留当前表 COMMENT,避免用另一张表的发布元数据错误装饰字段名。 if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) { return comments; } Map<String, AuditFieldDiff.FieldMeta> published = publishedFieldMetadata(modelId); return mergeFieldMetadata(comments, published); } /** * CREATE 建立**完整** baseline 用的「已知可审计字段」集合(小写)。 * * <p><b>为什么需要这个</b>:平台把值为空的字段整个从事件的 {@code newData} 里剥掉了 * (2026-07-30 实测:{@code lims_qingyandan} 的 CREATE 快照 21 键 vs 表单模型 23 字段, * 少的正是建单时留空的 {@code youxiaoqi} / {@code piliang} / {@code piliang_danwei})。 * 于是 baseline 天生缺这些键,用户第一次把它们从空填成非空时,UPDATE 的两侧交集会把它们 * 排除掉——**真实变更被漏记**。CREATE 时按本方法把这些字段显式补成空值即可闭合。 * * <p><b>为什么是「已发布表单字段 ∩ 主表物理列」这个交集,而不是任一单侧</b>: * <ul> * <li>只用物理列 → 会把**后端代码写、不经表单**的列(如 {@code jieshouren})也宣称为 * 「建单时已知为空」。那些列的变更不产生层 0 事件,baseline 会长期停在假的空值上, * 下次表单碰到它就编出一条 {@code ""→X} 的假 diff——正是 B3 划的「没有 before * 不许给 field_diffs」那条红线。</li> * <li>只用表单字段 → 会带进子表 children 的 vModel({@link #collectFields} 是递归的), * 那些不是主表的列,补进主表快照只是噪音。</li> * </ul> * 取交集后剩下的正是「这张表单控制、且确实是主表一列」的字段——建单提交的就是整份表单, * 没被携带即等于留空,这个推断有据可依。 * * <p><b>任一侧取不到就返回空集</b>(非 PG 部署、元数据查询瞬时失败、modelId 与 table 对不上): * 不补 = 退化成今天的「baseline 缺字段 → 显式标记不可判定」,是安全方向。 * 宁可继续少记一条,也不能凭猜造出一个旧值。 */ public Set<String> resolveBaselineFields(String modelId, String table) { String normalizedTable = table == null ? null : lower(table.trim()); if ((normalizedTable == null || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable))) || modelId == null || modelId.trim().isEmpty()) { return Collections.emptySet(); } // 与 resolveFieldMetadata 同一道闸:不能用另一张表的发布模型给当前表补 baseline if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) { return Collections.emptySet(); } Map<String, AuditFieldDiff.FieldMeta> published = publishedFieldMetadata(modelId.trim()); Map<String, AuditFieldDiff.FieldMeta> columns = commentMetadata(normalizedTable); if (published.isEmpty() || columns.isEmpty()) { return Collections.emptySet(); } Set<String> fields = new LinkedHashSet<>(); for (String field : published.keySet()) { if (columns.containsKey(field)) { fields.add(field); } } return fields.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(fields); } /** * 返回物理表真实列名(小写)。只使用 JDBC 元数据,不拼接 SQL;解析失败返回空集, * 调用方据此选择保留原始审计数据,不能把元数据瞬时故障误判成“表中没有任何字段”。 */ public Set<String> resolvePhysicalColumns(String table) { String normalized = table == null ? null : lower(table.trim()); if (normalized == null || !normalized.matches("[a-z0-9_]+")) { return Collections.emptySet(); } Map<String, AuditFieldDiff.FieldMeta> columns = commentMetadata(normalized); return columns.isEmpty() ? Collections.emptySet() : Collections.unmodifiableSet(new LinkedHashSet<>(columns.keySet())); } /** * 取当前已发布表单的「操作记录标题」勾选配置。 * * <p>两道闸与 {@link #resolveFieldMetadata} 完全相同,不能松: * <ul> * <li>表名必须在层 0 注册表白名单内——否则元数据 SQL 会被任意表名驱动;</li> * <li>modelId 反查出的主表必须与 {@code table} 一致——查询历史事件时 * {@code sourceModelId} 来自事件 extra,不能假定它属于当前表; * 用另一张表的发布模型解析标题,会把别的表的字段名当成本表的勾选。</li> * </ul> * 任一条不满足返回 {@link AuditTitleSpec#EMPTY} = 不产出标题(安全方向)。 */ public AuditTitleSpec resolveTitleSpec(String modelId, String table) { String normalizedTable = table == null ? null : lower(table.trim()); if ((normalizedTable == null || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable))) || modelId == null || modelId.trim().isEmpty()) { return AuditTitleSpec.EMPTY; } if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) { return AuditTitleSpec.EMPTY; } return publishedTitleSpec(modelId.trim()); } private AuditTitleSpec publishedTitleSpec(String modelId) { AuditTitleSpec cached = cachedValue(modelIdToTitleSpec, modelId); if (cached != null) { return cached; } String formData; try { formData = jdbcTemplate.queryForObject( "SELECT F_FORM_DATA FROM base_visual_release WHERE F_ID = ?", String.class, modelId); } catch (org.springframework.dao.EmptyResultDataAccessException e) { cacheValue(modelIdToTitleSpec, modelId, AuditTitleSpec.EMPTY); return AuditTitleSpec.EMPTY; } catch (Throwable t) { // 瞬时故障不缓存(L061 ①):缓存了等于该表单到重启前永远没有标题 log.error("[AUDIT-L0] 读取表单标题配置失败(不缓存)modelId={}", modelId, t); return AuditTitleSpec.EMPTY; } AuditTitleSpec spec = AuditTitleSpecParser.parse(formData); cacheValue(modelIdToTitleSpec, modelId, spec); return spec; } /** * 解析列表场景的标题规格(D6):读 {@code F_COLUMN_DATA} 的 * {@code columnList[].biz_log_enabled},字段编码取 {@code prop}。 * * <p>与 {@link #resolveTitleSpec} 同样的两道闸,理由也一样: * 表名须在层 0 注册表白名单内(否则 SQL 被任意表名驱动);modelId 反查出的主表 * 须与 {@code table} 一致(否则会拿另一张表的发布模型解析本表的勾选)。 */ public AuditTitleSpec resolveListTitleSpec(String modelId, String table) { String normalizedTable = table == null ? null : lower(table.trim()); if ((normalizedTable == null || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable))) || modelId == null || modelId.trim().isEmpty()) { return AuditTitleSpec.EMPTY; } if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) { return AuditTitleSpec.EMPTY; } return publishedListTitleSpec(modelId.trim()); } private AuditTitleSpec publishedListTitleSpec(String modelId) { AuditTitleSpec cached = cachedValue(modelIdToListTitleSpec, modelId); if (cached != null) { return cached; } String columnData; try { columnData = jdbcTemplate.queryForObject( "SELECT F_COLUMN_DATA FROM base_visual_release WHERE F_ID = ?", String.class, modelId); } catch (org.springframework.dao.EmptyResultDataAccessException e) { cacheValue(modelIdToListTitleSpec, modelId, AuditTitleSpec.EMPTY); return AuditTitleSpec.EMPTY; } catch (Throwable t) { // 瞬时故障不缓存(L061 ①):缓存了等于该表单到重启前永远没有列表标题 log.error("[AUDIT-L0] 读取列表标题配置失败(不缓存)modelId={}", modelId, t); return AuditTitleSpec.EMPTY; } AuditTitleSpec spec = AuditListTitleSpecParser.parse(columnData); cacheValue(modelIdToListTitleSpec, modelId, spec); return spec; } /** * 解析表单配置里显式指定的签名字段(D4 权威来源)。 * * <p>{@code confirmBtnConfig.biz_sign_field}(确定按钮)与 * {@code reviewBtnConfig.biz_sign_field}(复核按钮)。多人签名场景下字段名可以是任意的, * 光靠 {@code _sign} 命名规则会漏——这就是为什么必须读配置而不是只靠猜。 * 实测「测试表单」两者分别是 {@code biz_sign} 与 {@code fuhe_sign}。 * * <p>返回小写字段名,与 {@link jnpf.bizcommon.audit.service.sign.AuditSignEvidenceReader#candidateFields} * 的口径一致。解析不出返回空列表(调用方自会退回命名规则+撞库兜底),不抛异常。 */ public List<String> resolveSignFields(String modelId, String table) { String normalizedTable = table == null ? null : lower(table.trim()); if ((normalizedTable == null || (!includeUnregisteredForms && !formsByTable.containsKey(normalizedTable))) || modelId == null || modelId.trim().isEmpty()) { return Collections.emptyList(); } if (!normalizedTable.equals(resolveMainTable(modelId.trim()))) { return Collections.emptyList(); } return publishedSignFields(modelId.trim()); } private List<String> publishedSignFields(String modelId) { List<String> cached = cachedValue(modelIdToSignFields, modelId); if (cached != null) { return cached; } String formData; try { formData = jdbcTemplate.queryForObject( "SELECT F_FORM_DATA FROM base_visual_release WHERE F_ID = ?", String.class, modelId); } catch (org.springframework.dao.EmptyResultDataAccessException e) { cacheValue(modelIdToSignFields, modelId, Collections.<String>emptyList()); return Collections.emptyList(); } catch (Throwable t) { // 瞬时故障不缓存(L061 ①) log.error("[AUDIT-L0] 读取签名字段配置失败(不缓存)modelId={}", modelId, t); return Collections.emptyList(); } List<String> fields = parseSignFields(formData); cacheValue(modelIdToSignFields, modelId, fields); return fields; } /** 从表单根对象读两个按钮配置的 biz_sign_field。永不抛异常,解析不出返回空表。 */ private static List<String> parseSignFields(String formDataJson) { if (formDataJson == null || formDataJson.trim().isEmpty()) { return Collections.emptyList(); } try { Map<String, Object> root = JsonUtil.stringToMap(formDataJson); if (root == null) { return Collections.emptyList(); } List<String> out = new ArrayList<>(); String[] configKeys = {"confirmBtnConfig", "reviewBtnConfig"}; for (String key : configKeys) { Object raw = root.get(key); if (!(raw instanceof Map)) { continue; } Object field = ((Map<?, ?>) raw).get("biz_sign_field"); if (field == null) { continue; } String name = lower(String.valueOf(field).trim()); if (name != null && !name.isEmpty() && !out.contains(name)) { out.add(name); } } return out.isEmpty() ? Collections.<String>emptyList() : Collections.unmodifiableList(out); } catch (Throwable t) { return Collections.emptyList(); } } /** preferred 按字段名(忽略大小写)覆盖 fallback。 */ public static Map<String, AuditFieldDiff.FieldMeta> mergeFieldMetadata( Map<String, AuditFieldDiff.FieldMeta> fallback, Map<String, AuditFieldDiff.FieldMeta> preferred) { if ((fallback == null || fallback.isEmpty()) && (preferred == null || preferred.isEmpty())) { return Collections.emptyMap(); } Map<String, AuditFieldDiff.FieldMeta> merged = new LinkedHashMap<>(); putAllNormalized(merged, fallback); if (preferred != null) { for (Map.Entry<String, AuditFieldDiff.FieldMeta> entry : preferred.entrySet()) { String field = lower(entry.getKey()); if (field == null || entry.getValue() == null) { continue; } merged.put(field, mergeFieldMeta(merged.get(field), entry.getValue())); } } return merged; } private Map<String, AuditFieldDiff.FieldMeta> publishedFieldMetadata(String modelId) { if (modelId == null || modelId.trim().isEmpty()) { return Collections.emptyMap(); } String key = modelId.trim(); Map<String, AuditFieldDiff.FieldMeta> cached = cachedValue(modelIdToFieldMeta, key); if (cached != null) { return cached; } String formData; try { formData = jdbcTemplate.queryForObject( "SELECT F_FORM_DATA FROM base_visual_release WHERE F_ID = ?", String.class, key); } catch (org.springframework.dao.EmptyResultDataAccessException e) { cacheValue(modelIdToFieldMeta, key, Collections.emptyMap()); return Collections.emptyMap(); } catch (Throwable t) { log.error("[AUDIT-L0] 读取发布表单元数据失败(不缓存)modelId={}", key, t); return Collections.emptyMap(); } try { Map<String, AuditFieldDiff.FieldMeta> parsed = parsePublishedFieldMetadata(formData); Map<String, AuditFieldDiff.FieldMeta> immutable = parsed.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(parsed); cacheValue(modelIdToFieldMeta, key, immutable); return immutable; } catch (Throwable t) { if (deterministicParseFailure(t) || t instanceof RuntimeException) { log.warn("[AUDIT-L0] 发布表单元数据无法解析(已缓存空结果)modelId={} err={}", key, t.toString()); cacheValue(modelIdToFieldMeta, key, Collections.emptyMap()); } else { log.error("[AUDIT-L0] 解析发布表单元数据失败(不缓存)modelId={}", key, t); } return Collections.emptyMap(); } } private Map<String, AuditFieldDiff.FieldMeta> commentMetadata(String table) { Map<String, AuditFieldDiff.FieldMeta> cached = cachedValue(tableToCommentMeta, table); if (cached != null) { return cached; } try (Connection connection = jdbcTemplate.getDataSource().getConnection()) { Map<String, AuditFieldDiff.FieldMeta> parsed = new LinkedHashMap<>(); DatabaseMetaData metadata = connection.getMetaData(); String catalog = connection.getCatalog(); String schema = currentSchema(connection); collectColumnMetadata(metadata, catalog, schema, table, parsed); if (parsed.isEmpty() && schema != null) { collectColumnMetadata(metadata, catalog, null, table, parsed); } if (parsed.isEmpty()) { collectColumnMetadata(metadata, null, schema, table, parsed); } if (parsed.isEmpty()) { collectColumnMetadata(metadata, catalog, schema, table.toUpperCase(Locale.ROOT), parsed); } Map<String, AuditFieldDiff.FieldMeta> immutable = parsed.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(parsed); cacheValue(tableToCommentMeta, table, immutable); return immutable; } catch (Throwable t) { // 元数据驱动不返回备注或短暂连接故障:不阻断事件,也不缓存失败。 log.warn("[AUDIT-L0] 读取字段 COMMENT 失败(不缓存)table={} err={}", table, t.toString()); return Collections.emptyMap(); } } private static String currentSchema(Connection connection) { try { return connection.getSchema(); } catch (Throwable ignored) { return null; } } private static void collectColumnMetadata(DatabaseMetaData metadata, String catalog, String schema, String table, Map<String, AuditFieldDiff.FieldMeta> target) throws Exception { try (ResultSet columns = metadata.getColumns(catalog, schema, table, null)) { while (columns.next()) { String field = str(columns.getObject("COLUMN_NAME")); if (field == null) { continue; } String label = str(columns.getObject("REMARKS")); String jdbcType = str(columns.getObject("TYPE_NAME")); putNormalized(target, field, jdbcFieldMeta(label, jdbcType)); } } } /** 纯解析入口,供针对性探针覆盖发布表单的嵌套 fields 结构。 */ @SuppressWarnings({"rawtypes", "unchecked"}) static Map<String, AuditFieldDiff.FieldMeta> parsePublishedFieldMetadata(String formDataJson) { if (formDataJson == null || formDataJson.trim().isEmpty()) { return Collections.emptyMap(); } Map<String, Object> root = JsonUtil.stringToMap(formDataJson); if (root == null) { return Collections.emptyMap(); } Object rawFields = root.get("fields"); List<Map> fields; if (rawFields instanceof List) { fields = (List<Map>) rawFields; } else if (rawFields != null) { fields = JsonUtil.getJsonToList(String.valueOf(rawFields), Map.class); } else { fields = Collections.emptyList(); } Map<String, AuditFieldDiff.FieldMeta> result = new LinkedHashMap<>(); collectFields(fields, result); return result; } @SuppressWarnings({"rawtypes", "unchecked"}) private static void collectFields(List<?> fields, Map<String, AuditFieldDiff.FieldMeta> result) { if (fields == null) { return; } for (Object raw : fields) { if (!(raw instanceof Map)) { continue; } Map item = (Map) raw; Object rawConfig = item.containsKey("config") ? item.get("config") : item.get("__config__"); if (!(rawConfig instanceof Map)) { continue; } Map config = (Map) rawConfig; String field = str(item.containsKey("vModel") ? item.get("vModel") : item.get("__vModel__")); String label = Boolean.TRUE.equals(bool(config.get("noShow"))) ? null : str(config.get("label")); String jnpfKey = str(config.get("jnpfKey")); String dataType = str(config.get("dataType")); String dictionaryType = str(config.get("dictionaryType")); Boolean hidden = bool(config.get("noShow")); if (field != null) { boolean nameModified = jnpfKey != null && JnpfKeyConsts.getNameModified().contains(jnpfKey) && (JnpfKeyConsts.getNameModifiedNotDynamic().contains(jnpfKey) || "dynamic".equals(dataType)); Map<String, String> options = optionLabels(item.get("options"), item.get("props")); String componentType = componentType(jnpfKey); String valueType = valueType(jnpfKey, dataType, options, bool(item.get("multiple"))); String format = firstText(item.get("format"), config.get("format")); putNormalized(result, field, new AuditFieldDiff.FieldMeta(label, jnpfKey, nameModified, valueType, componentType, format, options, dictionaryType, hidden)); } Object children = config.get("children"); if (children instanceof List) { collectFields((List<?>) children, result); } else if (children != null && !String.valueOf(children).trim().isEmpty()) { collectFields(JsonUtil.getJsonToList(String.valueOf(children), Map.class), result); } } } private static void putAllNormalized(Map<String, AuditFieldDiff.FieldMeta> target, Map<String, AuditFieldDiff.FieldMeta> source) { if (source == null) { return; } for (Map.Entry<String, AuditFieldDiff.FieldMeta> entry : source.entrySet()) { putNormalized(target, entry.getKey(), entry.getValue()); } } private static void putNormalized(Map<String, AuditFieldDiff.FieldMeta> target, String field, AuditFieldDiff.FieldMeta meta) { if (field == null || meta == null) { return; } String normalized = lower(field.trim()); if (normalized != null && !normalized.isEmpty()) { target.put(normalized, meta); } } private static AuditFieldDiff.FieldMeta mergeFieldMeta( AuditFieldDiff.FieldMeta fallback, AuditFieldDiff.FieldMeta preferred) { if (fallback == null) { return preferred; } if (preferred == null) { return fallback; } Boolean hidden = preferred.hidden() != null ? preferred.hidden() : fallback.hidden(); return new AuditFieldDiff.FieldMeta( Boolean.TRUE.equals(hidden) ? fallback.label() : firstText(preferred.label(), fallback.label()), firstText(preferred.jnpfKey(), fallback.jnpfKey()), preferred.nameModified() != null ? preferred.nameModified() : fallback.nameModified(), firstText(preferred.valueType(), fallback.valueType()), firstText(preferred.componentType(), fallback.componentType()), firstText(preferred.format(), fallback.format()), preferred.optionLabels().isEmpty() ? fallback.optionLabels() : preferred.optionLabels(), firstText(preferred.dictionaryType(), fallback.dictionaryType()), hidden); } private static AuditFieldDiff.FieldMeta jdbcFieldMeta(String label, String jdbcType) { String type = lower(jdbcType); String valueType = null; String componentType = null; if (type != null) { if (type.startsWith("timestamp")) { valueType = "datetime"; componentType = "datePicker"; } else if (type.equals("date")) { valueType = "date"; componentType = "datePicker"; } else if (type.startsWith("bool")) { valueType = "boolean"; componentType = "switch"; } else if (type.startsWith("smallint") || type.startsWith("integer") || type.startsWith("bigint") || type.startsWith("numeric") || type.startsWith("decimal") || type.startsWith("real") || type.startsWith("double")) { valueType = "number"; componentType = "inputNumber"; } else if (type.startsWith("json") || type.endsWith("[]")) { valueType = "json"; componentType = "input"; } } return new AuditFieldDiff.FieldMeta(label, null, null, valueType, componentType, null, Collections.emptyMap()); } @SuppressWarnings({"rawtypes", "unchecked"}) private static Map<String, String> optionLabels(Object rawOptions, Object rawProps) { List<?> options; if (rawOptions instanceof List) { options = (List<?>) rawOptions; } else if (rawOptions == null || String.valueOf(rawOptions).trim().isEmpty()) { return Collections.emptyMap(); } else { options = JsonUtil.getJsonToList(String.valueOf(rawOptions), Map.class); } Map props = rawProps instanceof Map ? (Map) rawProps : Collections.emptyMap(); String labelKey = firstText(props.get("label"), "fullName"); String valueKey = firstText(props.get("value"), "id"); String childrenKey = firstText(props.get("children"), "children"); Map<String, String> result = new LinkedHashMap<>(); collectOptionLabels(options, labelKey, valueKey, childrenKey, result); return result; } @SuppressWarnings("rawtypes") private static void collectOptionLabels(List<?> options, String labelKey, String valueKey, String childrenKey, Map<String, String> result) { if (options == null) { return; } for (Object raw : options) { if (!(raw instanceof Map)) { continue; } Map option = (Map) raw; String value = firstText(option.get(valueKey), option.get("enCode"), option.get("id"), option.get("value")); String label = firstText(option.get(labelKey), option.get("fullName"), option.get("label"), option.get("name")); if (value != null && label != null) { result.put(value, label); } Object children = option.get(childrenKey); if (children instanceof List) { collectOptionLabels((List<?>) children, labelKey, valueKey, childrenKey, result); } } } private static String componentType(String jnpfKey) { if (jnpfKey == null) { return null; } return "signature".equalsIgnoreCase(jnpfKey) ? "sign" : jnpfKey; } private static String valueType(String jnpfKey, String dataType, Map<String, String> options, Boolean multiple) { String key = lower(jnpfKey); String source = lower(dataType); if (key == null) { return null; } if ("sign".equals(key) || "signature".equals(key)) { return "masked"; } if ("datepicker".equals(key)) { return "date"; } if ("timepicker".equals(key)) { return "datetime"; } if ("inputnumber".equals(key) || "slider".equals(key) || "rate".equals(key)) { return "number"; } if ("switch".equals(key)) { return "boolean"; } if ("table".equals(key) || "uploadfile".equals(key) || "uploadimg".equals(key)) { return "json"; } if ("userselect".equals(key) || "departmentselect".equals(key) || "roleselect".equals(key) || "groupselect".equals(key) || "popupselect".equals(key) || "relationform".equals(key)) { return Boolean.TRUE.equals(multiple) ? "multiReference" : "reference"; } if ("checkbox".equals(key) || "cascader".equals(key) || "treeselect".equals(key)) { return ("dynamic".equals(source) && (options == null || options.isEmpty())) ? "multiReference" : "multiEnum"; } if ("select".equals(key) || "radio".equals(key)) { if ("dynamic".equals(source) && (options == null || options.isEmpty())) { return Boolean.TRUE.equals(multiple) ? "multiReference" : "reference"; } return Boolean.TRUE.equals(multiple) ? "multiEnum" : "enum"; } return "text"; } private void clearMetadataCaches() { modelIdToMainTable.clear(); modelIdToFieldMeta.clear(); modelIdToTitleSpec.clear(); modelIdToListTitleSpec.clear(); modelIdToSignFields.clear(); tableToCommentMeta.clear(); } private <T> T cachedValue(Map<String, CacheValue<T>> cache, String key) { CacheValue<T> cached = cache.get(key); if (cached == null) { return null; } if (metadataCacheTtlMs <= 0L || System.currentTimeMillis() - cached.createdAt >= metadataCacheTtlMs) { cache.remove(key, cached); return null; } return cached.value; } private <T> void cacheValue(Map<String, CacheValue<T>> cache, String key, T value) { if (metadataCacheTtlMs > 0L) { cache.put(key, new CacheValue<>(value)); } } private static final class CacheValue<T> { private final T value; private final long createdAt = System.currentTimeMillis(); private CacheValue(T value) { this.value = value; } } /** 解析 modelId 对应的主表名(小写);反查不到返回 null。 */ public String resolveMainTable(String modelId) { if (modelId == null || modelId.isEmpty()) { return null; } // ConcurrentHashMap 存不了 null,用空串当「确定性地查不到」的占位(L061 ① 的哨兵写法) String cached = cachedValue(modelIdToMainTable, modelId); if (cached != null) { return cached.isEmpty() ? null : cached; } Outcome outcome = queryMainTable(modelId); if (outcome.transientFailure) { // 瞬时失败(连接抖动/超时)**不进缓存**:下次事件重试一次,代价只是多一次查询; // 缓存了则是该表单从此静默不被审计,两者的坏处不在一个量级。 log.error("[AUDIT-L0] 反查主表失败(瞬时,不缓存,下次事件会重试)modelId={} err={}", modelId, outcome.error); return null; } cacheValue(modelIdToMainTable, modelId, outcome.table == null ? "" : outcome.table); if (outcome.table == null && warnedModels.add(modelId)) { log.warn("[AUDIT-L0] modelId={} 反查不到主表(base_visual_release.F_TABLES_DATA 无 typeId=1 且无表项)," + "该表单的变更不会被审计。同一 modelId 只报一次", modelId); } return outcome.table; } /** 查询结果三态:拿到表名 / 确定性地没有 / 瞬时失败(后者不可缓存)。 */ private static final class Outcome { private String table; private boolean transientFailure; private String error; } @SuppressWarnings({"rawtypes", "unchecked"}) private Outcome queryMainTable(String modelId) { Outcome outcome = new Outcome(); String tablesJson; try { tablesJson = jdbcTemplate.queryForObject( "SELECT F_TABLES_DATA FROM base_visual_release WHERE F_ID = ?", String.class, modelId); } catch (org.springframework.dao.EmptyResultDataAccessException e) { // 确定性:这个 modelId 根本没有发布记录,再查一万次也一样 return outcome; } catch (Throwable t) { outcome.transientFailure = true; outcome.error = String.valueOf(t); return outcome; } // 以下都是「查询成功、只是内容里没有主表」——确定性结果,可以缓存 if (tablesJson == null || tablesJson.isEmpty()) { return outcome; } // **解析单独一个 try**:下面 deterministicParseFailure 的白名单之所以敢认 // NumberFormatException,前提正是「这个 try 里除了解析没有别的东西」—— // 让这个前提由**代码结构**保证,而不是靠注释约定(约定会在下一次改动里悄悄失效)。 List<Map> tables; try { tables = JsonUtil.getJsonToList(tablesJson, Map.class); } catch (Throwable t) { // **白名单,不是黑名单**(L061 ①,独立评审 I-3 指出这里退化成了黑名单): // 只认解析器对这段文本的固有判决;OOM / StackOverflowError 这类**瞬时**故障 // 若被判成确定性并永久负缓存,等于该表单从此静默不被审计直到重启。 if (deterministicParseFailure(t)) { log.warn("[AUDIT-L0] modelId={} 的 F_TABLES_DATA 不是合法 JSON(确定性失败,已缓存): {}", modelId, t.toString()); return outcome; } outcome.transientFailure = true; outcome.error = String.valueOf(t); return outcome; } if (tables == null || tables.isEmpty()) { return outcome; } // 以下是纯 Map 取值,与解析无关。这里出的异常(元素不是 Map 等)同样是这条发布记录的 // 固有形态、重试结果恒定,故一律按确定性处理。 try { for (Map t : tables) { if ("1".equals(String.valueOf(t.get("typeId")))) { // typeId=1 是主表 outcome.table = lower(str(t.get("table"))); if (outcome.table != null) { return outcome; } } } // 没有 typeId=1 的标记(老表单结构里出现过):退而取第一张表,与旧 BizFormRegistry 同口径 outcome.table = lower(str(tables.get(0).get("table"))); return outcome; } catch (Throwable t) { log.warn("[AUDIT-L0] modelId={} 的 F_TABLES_DATA 结构异常(确定性失败,已缓存): {}", modelId, t.toString()); outcome.table = null; return outcome; } } /** * 解析失败是否为「这段文本的固有判决」(重试结果恒定),只有这种才允许进负缓存。 * * <p>白名单两项,都经实测确认(第二项由 codex 独立复核时用 fastjson 1.2.83 字节码级探针发现): * <ul> * <li>{@link com.alibaba.fastjson.JSONException}——语法错、顶层非数组、元素非对象、 * 超大指数等,fastjson 统一包成它;</li> * <li>{@link NumberFormatException}——**畸形 Unicode 转义(如 {@code \\uZZZZ})与畸形数字 * 字面量会抛裸的 NFE、不被包装**。它同样是文本的固有属性,漏在白名单外只会让这类 * 表单每次事件都白查一次库(不影响正确性,但属实打实的效率死角)。</li> * </ul> * * <p><b>刻意不收 Error</b>:{@code StackOverflowError}(深层嵌套 JSON)实测**原样穿透、 * 不被包装成 JSONException</b>,落进瞬时分支不缓存——正是想要的行为。 */ private static boolean deterministicParseFailure(Throwable t) { for (Throwable c = t; c != null; c = (c.getCause() == c ? null : c.getCause())) { if (c instanceof NumberFormatException) { return true; } if (c instanceof com.alibaba.fastjson.JSONException) { return true; } } return false; } private static String str(Object value) { if (value == null) { return null; } String s = value.toString().trim(); return s.isEmpty() ? null : s; } private static Boolean bool(Object value) { if (value instanceof Boolean) { return (Boolean) value; } if (value == null) { return null; } String text = String.valueOf(value).trim(); if ("true".equalsIgnoreCase(text)) { return Boolean.TRUE; } if ("false".equalsIgnoreCase(text)) { return Boolean.FALSE; } return null; } private static String firstText(Object... values) { if (values == null) { return null; } for (Object value : values) { String text = str(value); if (text != null) { return text; } } return null; } private static String lower(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); } private static Set<String> lowerAll(Set<String> values) { if (values == null || values.isEmpty()) { return Collections.emptySet(); } Set<String> out = new LinkedHashSet<>(values.size()); for (String v : values) { if (v != null && !v.trim().isEmpty()) { out.add(lower(v.trim())); } } return out; } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/AuditQueryService.java
New file @@ -0,0 +1,26 @@ package jnpf.bizcommon.audit.service; import jnpf.bizcommon.audit.entity.model.AuditEventDetailVO; import jnpf.bizcommon.audit.entity.model.AuditEventListVO; import jnpf.bizcommon.audit.entity.model.AuditPageQuery; import jnpf.base.vo.PageListVO; import java.util.List; /** * 审计事件查询侧服务(spec §7.1):按 operationId 的业务主行分页、单事件详情、操作时间线。 */ public interface AuditQueryService { /** * 分页查询:写入侧已完成物理合并,直接返回审计表行。 * pageSize 强制 <= 100。 */ PageListVO<AuditEventListVO> page(AuditPageQuery query); /** 详情:单条事件全字段(含 field_diffs/extra/data_snapshot 正文)。id 不存在时抛 DataException。 */ AuditEventDetailVO detail(Long id); /** 时间线:按 targetId 和自增 id 正序查操作,按 operationId 返回 BUSINESS 主行及关联签名。 */ List<AuditEventListVO> timeline(String targetId); } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/DataInterfaceSignAuditService.java
New file @@ -0,0 +1,127 @@ package jnpf.bizcommon.audit.service; import jnpf.audit.AuditConsts; import jnpf.audit.model.AuditEventDTO; import jnpf.bizcommon.audit.service.sign.AuditSignEvidence; import jnpf.bizcommon.audit.service.sign.AuditSignEvidenceReader; import jnpf.bizcommon.audit.service.sign.AuditSignMetaData; import jnpf.util.JsonUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; /** 为数据接口操作事实补充请求所携带的电子签名事实。 */ @Slf4j @Service @RequiredArgsConstructor public class DataInterfaceSignAuditService { private static final String SIGN_ACTION = "SIGN"; private static final String REQUEST_SIGN_FIELD = "__request_biz_sign__"; private final AuditSignEvidenceReader signEvidenceReader; private final AuditEventService auditEventService; /** * 只处理显式携带 relatedSignId 的数据接口事件。异常向上交给审计投递器进入 spool 重试, * 调用发生在审计服务内部,不会影响原始业务请求。 */ public void recordRelatedSign(AuditEventDTO businessEvent) { if (businessEvent == null || isBlank(businessEvent.getRelatedSignId()) || !AuditConsts.TYPE_DATA_CHANGE.equals(businessEvent.getEventType()) || !"DATA_INTERFACE".equals(businessEvent.getBizType())) { return; } String signId = businessEvent.getRelatedSignId().trim(); Map<String, AuditSignEvidence> evidences = signEvidenceReader.read( Collections.singletonList(signId), businessEvent.getTenantId()); AuditSignEvidence evidence = evidences.get(signId); if (evidence == null) { log.warn("[DATA-INTERFACE-AUDIT] 未查询到同租户签名记录,跳过签名事件 clientEventId={}", businessEvent.getClientEventId()); return; } if (isBlank(evidence.operatorId()) || !evidence.operatorId().equals(businessEvent.getOperatorId())) { log.warn("[DATA-INTERFACE-AUDIT] 签名归属与当前操作人不一致,跳过签名事件 clientEventId={}", businessEvent.getClientEventId()); return; } AuditSignMetaData metaData = AuditSignMetaData.parse(evidence.metaData()); String signatureRole = metaData.isReviewButton() ? "REVIEW" : "PRIMARY"; Map<String, Object> extra = new LinkedHashMap<>(); extra.put("signField", REQUEST_SIGN_FIELD); extra.put("signatureRole", signatureRole); extra.put("auditSource", "DATA_INTERFACE"); if (!isBlank(evidence.accountName())) { extra.put("accountName", evidence.accountName()); } if (!isBlank(evidence.opType())) { extra.put("opType", evidence.opType()); } AuditEventDTO signEvent = AuditEventDTO.builder() .clientEventId(signClientEventId(businessEvent.getOperationId(), signId)) .operationId(businessEvent.getOperationId()) .eventTime(evidence.signedAt() == null ? businessEvent.getEventTime() : evidence.signedAt()) .tenantId(businessEvent.getTenantId()) .operatorId(evidence.operatorId()) .operatorName(businessEvent.getOperatorName()) .operatorOrgId(businessEvent.getOperatorOrgId()) .appName(businessEvent.getAppName()) .bizModule(businessEvent.getBizModule()) .ip(businessEvent.getIp()) .ipRegion(businessEvent.getIpRegion()) .browser(businessEvent.getBrowser()) .os(businessEvent.getOs()) .requestUri(businessEvent.getRequestUri()) .eventType(AuditConsts.TYPE_E_SIGNATURE) .actionCode(SIGN_ACTION) .actionLabel(metaData.isReviewButton() ? "复审签名" : "电子签名") .sourceLayer(3) .targetTable(AuditSignEvidenceReader.SIGN_TABLE) .targetId(signId) .bizType(businessEvent.getBizType()) .bizCode(businessEvent.getBizCode()) .reason(evidence.note()) .extra(JsonUtil.getObjectToString(extra)) .recordTitle(businessEvent.getRecordTitle()) .entryType(businessEvent.getEntryType()) .entryId(businessEvent.getEntryId()) .entryName(businessEvent.getEntryName()) .build(); auditEventService.saveIdempotent(signEvent); } private static String signClientEventId(String operationId, String signId) { String source = value(operationId) + "\n" + value(signId); try { byte[] digest = MessageDigest.getInstance("SHA-256") .digest(source.getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder("DIS-"); for (byte item : digest) { hex.append(String.format("%02x", item & 0xff)); } return hex.toString(); } catch (Exception e) { throw new IllegalStateException("生成数据接口签名事件幂等键失败", e); } } private static String value(String text) { return text == null ? "" : text; } private static boolean isBlank(String text) { return text == null || text.trim().isEmpty(); } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/entry/AuditEntryResolver.java
New file @@ -0,0 +1,89 @@ package jnpf.bizcommon.audit.service.entry; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 「操作入口」解析:用户是从哪个菜单点进来的。 * * <p><b>为什么不按 modelId 反查</b>:一个表单模型可以挂多个菜单——实测 modelId * {@code 846320770681526725} 对应 4 个菜单(待取样沉降菌/浮游菌/悬浮粒子/表面微生物测试)。 * 反查只能取其中一个,写进审计就是<b>记错事实</b>。所以入口必须由请求携带 * (前端全局拦截器注入 {@code Jnpf-Menu-Id} → 平台 AUDIT-P1 补丁塞进 {@code auditCtx.menuId}), * 本类只负责把 id 翻译成当时的名字。 * * <p>缓存口径与 {@code AuditFormRegistry} 同族:只缓存确定性结果(空串 = 确定地查不到), * 瞬时故障不缓存(L061 ①)——一次连接抖动若被永久负缓存,该菜单从此到重启前都没有名字。 * * <p><b>返回 null 的三种成因由调用方区分</b>:menuId 为空(请求没带)、确定地查不到、瞬时故障。 * 本类只保证「查不到就返回 null,绝不编一个名字」;「没带」与「带了但查不到」写成两种 * {@code entrySource} 留痕,是编排侧(Task 6)的事——共用一个表示就会遮住一处漏记。 */ @Slf4j @Component public class AuditEntryResolver { /** 入口类型:菜单。预留 FORM / FLOW 等,用于解释 entry_id 指向哪张表。 */ public static final String TYPE_MENU = "MENU"; private final Map<String, CacheValue> cache = new ConcurrentHashMap<String, CacheValue>(); @Autowired private JdbcTemplate jdbcTemplate; @Value("${audit.layer0.metadata-cache-ttl-ms:300000}") private long ttlMs = 300_000L; /** 菜单名;menuId 为空、查不到、或瞬时故障时返回 null(调用方据此写 entrySource)。 */ public String resolveMenuName(String menuId) { if (menuId == null || menuId.trim().isEmpty()) { return null; } String key = menuId.trim(); CacheValue cached = cache.get(key); if (cached != null && ttlMs > 0L && System.currentTimeMillis() - cached.createdAt < ttlMs) { return cached.value.isEmpty() ? null : cached.value; } String name; try { // 排除已删菜单:菜单被删后 f_id 仍是可追溯的事实,但它的名字不该再被当成"当时的入口名" // 写进新记录。f_delete_mark 在 PG 里是 integer,未删除时为 NULL 或 0。 name = jdbcTemplate.queryForObject( "SELECT f_full_name FROM base_module WHERE f_id = ? " + "AND (f_delete_mark IS NULL OR f_delete_mark <> 1) LIMIT 1", String.class, key); } catch (org.springframework.dao.EmptyResultDataAccessException e) { // 确定性:这个 menuId 没有菜单行,再查一万次也一样 put(key, ""); return null; } catch (Throwable t) { log.warn("[AUDIT-L0] 反查菜单名失败(瞬时,不缓存)menuId={} err={}", key, t.toString()); return null; } String normalized = name == null ? "" : name.trim(); put(key, normalized); return normalized.isEmpty() ? null : normalized; } private void put(String key, String value) { if (ttlMs > 0L) { cache.put(key, new CacheValue(value)); } } private static final class CacheValue { private final String value; private final long createdAt = System.currentTimeMillis(); private CacheValue(String value) { this.value = value; } } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/impl/AuditEventServiceImpl.java
New file @@ -0,0 +1,895 @@ package jnpf.bizcommon.audit.service.impl; import com.alibaba.fastjson.JSON; import jnpf.audit.model.AuditEventDTO; import jnpf.audit.AuditConsts; import jnpf.bizcommon.audit.entity.AuditEventEntity; import jnpf.bizcommon.audit.mapper.AuditEventMapper; import jnpf.bizcommon.audit.service.AuditEventService; import jnpf.bizcommon.audit.service.AuditFormRegistry; import jnpf.bizcommon.audit.service.support.AuditDiffKey; import jnpf.bizcommon.audit.service.support.AuditEventCategories; import jnpf.util.JsonUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; 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.function.Function; @Slf4j @Service public class AuditEventServiceImpl implements AuditEventService { @Autowired private AuditEventMapper auditEventMapper; @Autowired private AuditFormRegistry auditFormRegistry; @Override @Transactional(rollbackFor = Exception.class) public int saveIdempotent(AuditEventDTO dto) { validate(dto); AuditEventEntity entity = toEntity(dto); entity.setEventCategory(AuditEventCategories.resolve(entity.getEventType(), entity.getExtra())); entity.setFieldDiffs(scopeFieldDiffs(entity.getFieldDiffs(), entity.getTargetTable(), entity.getTargetId())); entity.setFieldDiffs(filterFieldDiffs(entity.getFieldDiffs(), entity.getTargetTable(), auditFormRegistry::resolvePhysicalColumns)); int rows = auditEventMapper.insertIgnore(entity); if (rows > 0 || isBlank(entity.getOperationId())) { return rows; } AuditEventEntity existing = auditEventMapper.selectByOperationCategoryForUpdate( entity.getOperationId(), entity.getEventCategory()); if (existing == null || entity.getClientEventId().equals(existing.getClientEventId())) { log.info("[audit] duplicate client_event_id ignored, clientEventId={}", dto.getClientEventId()); return 0; } mergeInto(existing, entity); auditEventMapper.updateMerged(existing); log.info("[audit] merged operation event, operationId={}, category={}, sourceClientEventId={}", entity.getOperationId(), entity.getEventCategory(), entity.getClientEventId()); return 1; } private void mergeInto(AuditEventEntity existing, AuditEventEntity incoming) { // 升级前的已存事件没有逐项 targetId;参与迟到事件/重试合并时,用事件顶层主键补齐。 // 只修改本次锁定的内存实体,历史数据无需离线迁移。 existing.setFieldDiffs(scopeFieldDiffs(existing.getFieldDiffs(), existing.getTargetTable(), existing.getTargetId())); Map<String, Object> existingExtra = parseExtra(existing.getExtra()); Map<String, Object> incomingExtra = parseExtra(incoming.getExtra()); String existingSnapshotKey = snapshotKey(existing.getTargetTable(), existing.getTargetId()); String existingSnapshot = existing.getDataSnapshot(); String incomingSnapshotKey = snapshotKey(incoming.getTargetTable(), incoming.getTargetId()); String incomingSnapshot = incoming.getDataSnapshot(); long existingTime = time(existing.getEventTime()); long incomingTime = time(incoming.getEventTime()); long firstTime = longValue(existingExtra.get("mergeFirstEventTime"), existingTime); long lastTime = longValue(existingExtra.get("mergeLastEventTime"), existingTime); boolean incomingIsEarlier = incomingTime < firstTime; boolean incomingIsLater = incomingTime >= lastTime; boolean business = AuditEventCategories.BUSINESS.equals(existing.getEventCategory()); boolean existingIsFormSource = business && Integer.valueOf(0).equals(existing.getSourceLayer()); boolean existingHasFormSource = business && (existingIsFormSource || Boolean.TRUE.equals(existingExtra.get("mergeHasFormSource"))); boolean incomingIsFormSource = business && Integer.valueOf(0).equals(incoming.getSourceLayer()); long lastSupplementTime = longValue(existingExtra.get("mergeLastSupplementEventTime"), existingIsFormSource ? Long.MIN_VALUE : existingTime); boolean incomingIsLatestSupplement = !incomingIsFormSource && incomingTime >= lastSupplementTime; Set<String> formProtectedFields = normalizedProtectedFields( existingExtra.get("mergeFormProtectedFields")); if (business && Integer.valueOf(0).equals(existing.getSourceLayer())) { formProtectedFields.addAll(protectedFormFields(existing.getFieldDiffs())); } if (incomingIsFormSource) { formProtectedFields.addAll(protectedFormFields(incoming.getFieldDiffs())); } String mergedDiffs = mergeFieldDiffs( existing.getFieldDiffs(), incoming.getFieldDiffs(), incomingIsEarlier, existingHasFormSource ? incomingIsLatestSupplement : incomingIsLater, existingHasFormSource, incomingIsFormSource, formProtectedFields); String storedFormSnapshot = strValue(existingExtra.get("mergeFormSnapshot")); String storedFormSnapshotKey = strValue(existingExtra.get("mergeFormSnapshotKey")); String formSnapshot = incomingIsFormSource ? incomingSnapshot : (storedFormSnapshot != null ? storedFormSnapshot : (existingIsFormSource ? existingSnapshot : null)); String mergedSnapshot = mergeSnapshots(existingSnapshot, incomingSnapshot, incomingIsLater, incomingIsLatestSupplement, business, existingHasFormSource, incomingIsFormSource, formSnapshot); int incomingPriority = sourcePriority(existing.getEventCategory(), incoming.getSourceLayer()); int existingPriority = sourcePriority(existing.getEventCategory(), existing.getSourceLayer()); boolean preferIncoming = incomingPriority > existingPriority || (incomingPriority == existingPriority && incomingIsLater); if (preferIncoming) { copyPresentation(existing, incoming); } existing.setEventTime(new Date(Math.max(lastTime, incomingTime))); existing.setFieldDiffs(mergedDiffs); existing.setDataSnapshot(mergedSnapshot); existing.setExtra(mergeExtra(existingExtra, incomingExtra, existing.getClientEventId(), incoming.getClientEventId(), Math.min(firstTime, incomingTime), Math.max(lastTime, incomingTime), existingSnapshotKey, existingSnapshot, incomingSnapshotKey, incomingSnapshot, existingHasFormSource || incomingIsFormSource, formProtectedFields, formSnapshot, incomingIsFormSource ? incomingSnapshotKey : (storedFormSnapshotKey != null ? storedFormSnapshotKey : (existingIsFormSource ? existingSnapshotKey : null)), incomingIsLatestSupplement ? incomingTime : lastSupplementTime)); } static String mergeFieldDiffs(String existingJson, String incomingJson, boolean incomingIsEarlier, boolean incomingIsLater) { return mergeFieldDiffs(existingJson, incomingJson, incomingIsEarlier, incomingIsLater, false, false, Collections.emptySet()); } static String mergeFieldDiffs(String existingJson, String incomingJson, boolean incomingIsEarlier, boolean incomingIsLater, boolean existingHasFormSource, boolean incomingIsFormSource, Set<String> formProtectedFields) { if (incomingIsFormSource) { // 无论 MQ 谁先到,已有接口差异都只能作为表单基线的补充。 return mergeFormFirstDiffs(incomingJson, existingJson, true, formProtectedFields); } if (existingHasFormSource) { return mergeFormFirstDiffs(existingJson, incomingJson, incomingIsLater, formProtectedFields); } LinkedHashMap<String, Map<String, Object>> merged = new LinkedHashMap<>(); addDiffs(merged, existingJson, false, true); addDiffs(merged, incomingJson, incomingIsEarlier, incomingIsLater); return merged.isEmpty() ? null : JSON.toJSONString(new ArrayList<>(merged.values())); } /** 表单差异决定字段顺序、初始值与展示元数据;接口只补充表单缺失、空值或零值字段。 */ private static String mergeFormFirstDiffs(String formFirstJson, String supplementalJson, boolean supplementIsLater, Set<String> formProtectedFields) { LinkedHashMap<String, Map<String, Object>> merged = new LinkedHashMap<>(); addDiffs(merged, formFirstJson, false, true); addSupplementalDiffs(merged, supplementalJson, supplementIsLater, formProtectedFields); return merged.isEmpty() ? null : JSON.toJSONString(new ArrayList<>(merged.values())); } @SuppressWarnings("unchecked") private static void addSupplementalDiffs(LinkedHashMap<String, Map<String, Object>> merged, String json, boolean replaceNew, Set<String> formProtectedFields) { if (isBlank(json)) { return; } try { Object parsed = JSON.parse(json); if (!(parsed instanceof List)) { return; } for (Object raw : (List<?>) parsed) { if (!(raw instanceof Map)) { continue; } Map<String, Object> diff = new LinkedHashMap<>((Map<String, Object>) raw); String field = strValue(diff.get("field")); if (field == null) { continue; } Map<String, Object> current = merged.get(recordDiffKey(diff)); if (current == null) { merged.put(recordDiffKey(diff), diff); } else if (!isFormProtected(formProtectedFields, diff) && hasDataValue(diff.get("newData")) && (replaceNew || !hasDataValue(current.get("newData")))) { copyIfPresent(diff, current, "newData"); if (diff.containsKey("newDisplay")) { current.put("newDisplay", diff.get("newDisplay")); } else if (diff.containsKey("newData")) { current.remove("newDisplay"); } } } } catch (Throwable ignored) { // 损坏的补充 diff 不得破坏已经取得的表单基线。 } } private static String strValue(Object value) { if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } private static Set<String> protectedFormFields(String json) { Set<String> fields = new LinkedHashSet<>(); if (isBlank(json)) { return fields; } try { Object parsed = JSON.parse(json); if (!(parsed instanceof List)) { return fields; } for (Object raw : (List<?>) parsed) { if (!(raw instanceof Map)) { continue; } Map<?, ?> diff = (Map<?, ?>) raw; String field = strValue(diff.get("field")); if (field != null && (hasDataValue(diff.get("newData")) || hasChildDiff(diff))) { fields.add(fieldDiffKey(diff)); } } } catch (Throwable ignored) { // 解析失败时不猜保护字段,原始表单差异仍会作为合并基线保留。 } return fields; } private static boolean isFormProtected(Set<String> protectedFields, Map<String, Object> diff) { if (protectedFields.contains(fieldDiffKey(diff))) { return true; } boolean hasScopedKey = protectedFields.stream().anyMatch(AuditDiffKey::isScoped); // 兼容修复前已写入 extra 的无表作用域字段名。 return !hasScopedKey && protectedFields.contains(strValue(diff.get("field"))); } /** 用户定义的空值规则:null、空字符串及数值零可由接口补充,其余表单值不可覆盖。 */ private static boolean hasDataValue(Object value) { if (value == null) { return false; } if (value instanceof Number) { return ((Number) value).doubleValue() != 0D; } String text = String.valueOf(value).trim(); if (text.isEmpty()) { return false; } try { return new java.math.BigDecimal(text).compareTo(java.math.BigDecimal.ZERO) != 0; } catch (NumberFormatException ignored) { return true; } } private static boolean hasChildDiff(Map<?, ?> diff) { Object data = diff == null ? null : diff.get("chidData"); return data instanceof Collection && !((Collection<?>) data).isEmpty(); } static String mergeSnapshots(String existingJson, String incomingJson, boolean incomingIsLater, boolean incomingIsLatestSupplement, boolean business, boolean existingHasFormSource, boolean incomingIsFormSource, String formSnapshot) { if (business && !isBlank(formSnapshot) && (existingHasFormSource || incomingIsFormSource)) { if (incomingIsFormSource) { return mergeFormFirstSnapshots(formSnapshot, existingJson); } return incomingIsLatestSupplement ? mergeFormFirstSnapshots(formSnapshot, existingJson, incomingJson) : mergeFormFirstSnapshots(formSnapshot, incomingJson, existingJson); } return incomingJson != null && incomingIsLater ? incomingJson : existingJson; } /** 表单快照决定字段顺序和有效值;其他来源只补字段,或填充表单中的 null、空串和零值。 */ @SuppressWarnings("unchecked") static String mergeFormFirstSnapshots(String formJson, String... supplementalJson) { Map<String, Object> form = parseJsonObject(formJson); if (form == null) { return formJson; } Map<String, Object> merged = new LinkedHashMap<>(form); Set<String> protectedFields = new LinkedHashSet<>(); for (Map.Entry<String, Object> entry : form.entrySet()) { if (hasDataValue(entry.getValue())) { protectedFields.add(entry.getKey()); } } for (String json : supplementalJson) { Map<String, Object> supplemental = parseJsonObject(json); if (supplemental == null) { continue; } for (Map.Entry<String, Object> entry : supplemental.entrySet()) { if (!protectedFields.contains(entry.getKey()) && hasDataValue(entry.getValue())) { merged.put(entry.getKey(), entry.getValue()); } } } return JSON.toJSONString(merged); } @SuppressWarnings("unchecked") private static Map<String, Object> parseJsonObject(String json) { if (isBlank(json)) { return null; } try { Object parsed = JSON.parse(json); return parsed instanceof Map ? new LinkedHashMap<>((Map<String, Object>) parsed) : null; } catch (Throwable ignored) { return null; } } @SuppressWarnings("unchecked") private static void addDiffs(LinkedHashMap<String, Map<String, Object>> merged, String json, boolean replaceOld, boolean replaceNew) { if (isBlank(json)) { return; } try { Object parsed = JSON.parse(json); if (!(parsed instanceof List)) { return; } for (Object raw : (List<?>) parsed) { if (!(raw instanceof Map)) { continue; } Map<String, Object> diff = new LinkedHashMap<>((Map<String, Object>) raw); Object rawField = diff.get("field"); if (rawField == null || isBlank(String.valueOf(rawField))) { continue; } String field = String.valueOf(rawField); String key = recordDiffKey(diff); Map<String, Object> current = merged.get(key); if (current == null) { merged.put(key, diff); continue; } if (replaceOld) { copyIfPresent(diff, current, "oldData"); copyIfPresent(diff, current, "oldDisplay"); } if (replaceNew) { copyIfPresent(diff, current, "newData"); copyIfPresent(diff, current, "newDisplay"); copyMetadata(diff, current); } } } catch (Throwable ignored) { // 损坏的历史 diff 不参与组合,后续合法来源仍可补齐。 } } private static void copyMetadata(Map<String, Object> source, Map<String, Object> target) { String[] keys = {"fieldName", "changeType", "componentType", "jnpfKey", "masked", "nameModified", "technical", "type", "valueType", "chidData", "chidField", "targetTable", "targetId"}; for (String key : keys) { Object value = source.get(key); if (value != null && !String.valueOf(value).isEmpty()) { target.put(key, value); } } } private static String fieldDiffKey(Map<?, ?> diff) { String field = strValue(diff == null ? null : diff.get("field")); String table = strValue(diff == null ? null : diff.get("targetTable")); return AuditDiffKey.of(table, field); } /** * 批量操作必须按记录隔离同名字段;旧日志没有 targetId 时退回原有表+字段键。 */ private static String recordDiffKey(Map<?, ?> diff) { String fieldKey = fieldDiffKey(diff); String targetId = strValue(diff == null ? null : diff.get("targetId")); return targetId == null ? fieldKey : fieldKey + "@target:" + targetId.length() + ":" + targetId; } /** * 单记录生产端无需重复填写作用域;批量生产端显式携带的逐项 targetId 优先保留。 */ @SuppressWarnings("unchecked") static String scopeFieldDiffs(String json, String defaultTable, String defaultTargetId) { if (isBlank(json)) { return json; } try { Object parsed = JSON.parse(json); if (!(parsed instanceof List)) { return json; } List<Map<String, Object>> scoped = new ArrayList<>(); for (Object raw : (List<?>) parsed) { if (!(raw instanceof Map)) { continue; } Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) raw); if (strValue(item.get("targetTable")) == null && !isBlank(defaultTable)) { item.put("targetTable", defaultTable); } if (strValue(item.get("targetId")) == null && !isBlank(defaultTargetId)) { item.put("targetId", defaultTargetId); } scoped.add(item); } return JSON.toJSONString(scoped); } catch (Throwable ignored) { return json; } } /** 每个字段只按其所属物理表校验;元数据不可用时保留原始差异,避免静默丢审计。 */ @SuppressWarnings("unchecked") static String filterFieldDiffs(String json, String defaultTable, Function<String, Set<String>> columnResolver) { if (isBlank(json) || columnResolver == null) { return json; } try { Object parsed = JSON.parse(json); if (!(parsed instanceof List)) { return json; } List<Map<String, Object>> filtered = new ArrayList<>(); for (Object raw : (List<?>) parsed) { if (!(raw instanceof Map)) { continue; } Map<String, Object> item = new LinkedHashMap<>((Map<String, Object>) raw); boolean child = "table".equalsIgnoreCase(strValue(item.get("jnpfKey"))); String table = strValue(item.get("targetTable")); if (!child && table == null) { table = strValue(defaultTable); } if (table == null) { // 旧子表事件没有物理表归属,无法安全判断;保留证据,不拿主表列误删。 filtered.add(item); continue; } table = table.toLowerCase(Locale.ROOT); item.put("targetTable", table); Set<String> resolved = columnResolver.apply(table); Set<String> columns = lowerSet(resolved); if (columns.isEmpty()) { filtered.add(item); continue; } if (child) { Map<String, Object> childItem = filterChildDiff(item, columns); if (childItem != null) { filtered.add(childItem); } continue; } String field = strValue(item.get("field")); if (field != null && columns.contains(field.toLowerCase(Locale.ROOT))) { filtered.add(item); } } return JSON.toJSONString(filtered); } catch (Throwable ignored) { return json; } } @SuppressWarnings("unchecked") private static Map<String, Object> filterChildDiff(Map<String, Object> item, Set<String> columns) { List<Map<String, Object>> fields = new ArrayList<>(); Object rawFields = item.get("chidField"); if (rawFields instanceof List) { for (Object raw : (List<?>) rawFields) { if (!(raw instanceof Map)) { continue; } Map<String, Object> field = new LinkedHashMap<>((Map<String, Object>) raw); String prop = strValue(field.get("prop")); if (prop != null && columns.contains(prop.toLowerCase(Locale.ROOT))) { fields.add(field); } } } if (fields.isEmpty()) { return null; } Set<String> allowed = new LinkedHashSet<>(); for (Map<String, Object> field : fields) { allowed.add(strValue(field.get("prop")).toLowerCase(Locale.ROOT)); } List<Map<String, Object>> rows = new ArrayList<>(); Object rawRows = item.get("chidData"); if (rawRows instanceof List) { for (Object raw : (List<?>) rawRows) { if (!(raw instanceof Map)) { continue; } Map<String, Object> row = new LinkedHashMap<>(); boolean hasBusinessField = false; for (Map.Entry<?, ?> entry : ((Map<?, ?>) raw).entrySet()) { String key = entry.getKey() == null ? null : String.valueOf(entry.getKey()); if (key == null) { continue; } String normalized = key.toLowerCase(Locale.ROOT); String actual = normalized.startsWith("jnpf_old_") ? normalized.substring("jnpf_old_".length()) : normalized; if (allowed.contains(actual)) { row.put(key, entry.getValue()); hasBusinessField = true; } else if ("jnpf_type".equals(normalized)) { row.put(key, entry.getValue()); } } if (hasBusinessField) { rows.add(row); } } } if (rows.isEmpty()) { return null; } item.put("chidField", fields); item.put("chidData", rows); return item; } private static Set<String> lowerSet(Set<String> values) { if (values == null || values.isEmpty()) { return Collections.emptySet(); } Set<String> result = new LinkedHashSet<>(); for (String value : values) { if (value != null && !value.trim().isEmpty()) { result.add(value.trim().toLowerCase(Locale.ROOT)); } } return result; } private static void copyIfPresent(Map<String, Object> source, Map<String, Object> target, String key) { if (source.containsKey(key)) { target.put(key, source.get(key)); } } private static String mergeExtra(Map<String, Object> existing, Map<String, Object> incoming, String existingClientId, String incomingClientId, long firstTime, long lastTime, String existingSnapshotKey, String existingSnapshot, String incomingSnapshotKey, String incomingSnapshot, boolean hasFormSource, Set<String> formProtectedFields, String formSnapshot, String formSnapshotKey, long lastSupplementEventTime) { Map<String, Object> merged = new LinkedHashMap<>(existing); merged.putAll(incoming); Set<String> sourceIds = new LinkedHashSet<>(); addStrings(sourceIds, existing.get("mergedClientEventIds")); sourceIds.add(existingClientId); sourceIds.add(incomingClientId); merged.put("mergedClientEventIds", new ArrayList<>(sourceIds)); merged.put("mergeFirstEventTime", firstTime); merged.put("mergeLastEventTime", lastTime); if (hasFormSource) { merged.put("mergeHasFormSource", true); merged.put("mergeFormProtectedFields", new ArrayList<>(formProtectedFields)); if (formSnapshot != null) { merged.put("mergeFormSnapshot", formSnapshot); } if (formSnapshotKey != null) { merged.put("mergeFormSnapshotKey", formSnapshotKey); } } if (lastSupplementEventTime != Long.MIN_VALUE) { merged.put("mergeLastSupplementEventTime", lastSupplementEventTime); } Map<String, Object> snapshots = objectMap(existing.get("mergedSnapshots")); if (existingSnapshotKey != null && existingSnapshot != null) { snapshots.put(existingSnapshotKey, existingSnapshot); } if (incomingSnapshotKey != null && incomingSnapshot != null) { snapshots.put(incomingSnapshotKey, incomingSnapshot); } if (!snapshots.isEmpty()) { merged.put("mergedSnapshots", snapshots); } return JsonUtil.getObjectToString(merged); } @SuppressWarnings("unchecked") private static Map<String, Object> objectMap(Object value) { if (value instanceof Map) { return new LinkedHashMap<>((Map<String, Object>) value); } return new LinkedHashMap<>(); } private static String snapshotKey(String targetTable, String targetId) { return isBlank(targetTable) || isBlank(targetId) ? null : targetTable + ":" + targetId; } private static Map<String, Object> parseExtra(String json) { if (!isBlank(json)) { try { Map<String, Object> parsed = JsonUtil.stringToMap(json); if (parsed != null) { return new LinkedHashMap<>(parsed); } } catch (Throwable ignored) { // 原文在下面保留,避免静默丢失损坏的历史 extra。 } } Map<String, Object> result = new LinkedHashMap<>(); if (!isBlank(json)) { result.put("mergedOriginalExtra", json); } return result; } private static void addStrings(Set<String> target, Object value) { if (value instanceof Iterable) { for (Object item : (Iterable<?>) value) { if (item != null) { target.add(String.valueOf(item)); } } } } static Set<String> normalizedProtectedFields(Object value) { Set<String> result = new LinkedHashSet<>(); if (value instanceof Iterable) { for (Object item : (Iterable<?>) value) { if (item != null) { String normalized = AuditDiffKey.normalizeStored(String.valueOf(item)); if (normalized != null) { result.add(normalized); } } } } return result; } static int sourcePriority(String category, Integer sourceLayer) { if (AuditEventCategories.BUSINESS.equals(category)) { return Integer.valueOf(0).equals(sourceLayer) ? 3 : (Integer.valueOf(2).equals(sourceLayer) ? 2 : 1); } if (Integer.valueOf(0).equals(sourceLayer)) { return 3; // 层 0 是实际签名凭证,优先于 SIGN_USED 等使用记录。 } return Integer.valueOf(2).equals(sourceLayer) ? 2 : 1; } private static void copyPresentation(AuditEventEntity target, AuditEventEntity source) { target.setTenantId(source.getTenantId()); target.setOperatorId(source.getOperatorId()); target.setOperatorName(source.getOperatorName()); target.setOperatorOrgId(source.getOperatorOrgId()); target.setAppName(source.getAppName()); target.setBizModule(source.getBizModule()); target.setIp(source.getIp()); target.setIpRegion(source.getIpRegion()); target.setBrowser(source.getBrowser()); target.setOs(source.getOs()); target.setRequestUri(source.getRequestUri()); target.setEventType(source.getEventType()); target.setActionCode(source.getActionCode()); target.setActionLabel(source.getActionLabel()); target.setSourceLayer(source.getSourceLayer()); target.setTargetTable(source.getTargetTable()); target.setTargetId(source.getTargetId()); target.setBizType(source.getBizType()); target.setBizCode(source.getBizCode()); target.setReason(source.getReason()); target.setRecordTitle(source.getRecordTitle()); target.setEntryType(source.getEntryType()); target.setEntryId(source.getEntryId()); target.setEntryName(source.getEntryName()); target.setPrevHash(source.getPrevHash()); } private static long time(Date value) { return value == null ? 0L : value.getTime(); } private static long longValue(Object value, long fallback) { if (value instanceof Number) { return ((Number) value).longValue(); } try { return value == null ? fallback : Long.parseLong(String.valueOf(value)); } catch (NumberFormatException ignored) { return fallback; } } /** * 校验规则(spec v2.2 裁定 + 追加裁定,逐字执行): * eventType 必须在 {@link AuditConsts#VALID_TYPES} 内; * operatorId/appName/actionCode/eventTime/tenantId 任一为空则拒绝; * clientEventId 为空则拒绝(DB NOT NULL + UNIQUE 幂等键,null 无幂等语义); * sourceLayer 为 null 则拒绝(DB NOT NULL 列即必填事实源,早抛可读异常优于 DB 层裸抛; * 取值范围 0-3 留给 DB CHECK 约束校验,不在此重复)。 * * <p><b>标识类字段超长一律拒绝、不截断</b>(C1 评审 I-5):{@code clientEventId} 是幂等键, * 截断会让两个不同事件塌成同一个键、被 ON CONFLICT 当成重复而丢弃;{@code operationId} 截断 * 会把本不相干的操作折叠到一组。这两者截断造成的错误比拒绝更隐蔽,故宁可拒绝—— * 而且它们都由 SDK/服务自己构造(层 0 是 "L0-"+雪花=22 字符),超长本身就说明调用方有问题。 * 描述类字段则相反,见 {@link #toEntity}。 */ private void validate(AuditEventDTO dto) { if (isBlank(dto.getClientEventId())) { throw new IllegalArgumentException("clientEventId 不能为空"); } if (dto.getClientEventId().length() > LEN_CLIENT_EVENT_ID) { throw new IllegalArgumentException("clientEventId 超过 " + LEN_CLIENT_EVENT_ID + " 字符,截断会破坏幂等语义,拒绝入库: " + dto.getClientEventId()); } if (dto.getOperationId() != null && dto.getOperationId().length() > LEN_OPERATION_ID) { throw new IllegalArgumentException("operationId 超过 " + LEN_OPERATION_ID + " 字符,截断会破坏操作关联语义,拒绝入库: " + dto.getOperationId()); } if (dto.getEventType() == null || !AuditConsts.VALID_TYPES.contains(dto.getEventType())) { throw new IllegalArgumentException("eventType 不在受控枚举 VALID_TYPES 内: " + dto.getEventType()); } if (isBlank(dto.getOperatorId())) { throw new IllegalArgumentException("operatorId 不能为空"); } if (isBlank(dto.getAppName())) { throw new IllegalArgumentException("appName 不能为空"); } if (isBlank(dto.getActionCode())) { throw new IllegalArgumentException("actionCode 不能为空"); } if (dto.getEventTime() == null) { throw new IllegalArgumentException("eventTime 不能为空"); } if (isBlank(dto.getTenantId())) { throw new IllegalArgumentException("tenantId 不能为空"); } if (dto.getSourceLayer() == null) { throw new IllegalArgumentException("sourceLayer 不能为空"); } } /** * DTO → 实体,**描述类 varchar 列按列宽截断**(C1 评审 I-5)。 * * <p>不截断会怎样:{@code biz_code} 按设计直取业务原始值(层 0 取表单字段、层 2 取 SpEL 求值), * {@code browser}/{@code os}/{@code request_uri} 则由客户端 header 决定——任一超长,PG 抛 * {@code 22001 value too long},而 {@code ON CONFLICT} 在冲突判定之前就已经抛了。异常冒泡回 * 采集侧被 {@code catch(Throwable)} 兜住(层 0 的 L002 防御、层 1 的总兜底都如此),结果是 * **整条审计事件永久消失,只剩一行 error 日志**。 * * <p>更该警惕的是**它不自愈**:spec §6 层 0 的容错前提是「这次失败了,下次事件会自举补上」, * 但字段若是**长期**超长(某类单据的编号天生就长),该行的每一条事件都会丢,永远补不回来—— * 直接突破了 spec 声明的可靠性包络。 * * <p><b>截断必须留痕</b>(与 spec §8 迁移脚本「禁静默截断」同一条纪律):被截断的列名记进 * {@code extra.truncatedFields}。审计可以记得不全,但不能让人以为记全了。 */ private AuditEventEntity toEntity(AuditEventDTO dto) { AuditEventEntity entity = new AuditEventEntity(); List<String> truncated = new ArrayList<>(); // 标识类:已在 validate 里卡死长度,这里原样搬运 entity.setClientEventId(dto.getClientEventId()); entity.setOperationId(dto.getOperationId()); entity.setEventTime(dto.getEventTime()); entity.setTenantId(clip(dto.getTenantId(), LEN_TENANT_ID, "tenant_id", truncated)); entity.setOperatorId(clip(dto.getOperatorId(), LEN_OPERATOR_ID, "operator_id", truncated)); entity.setOperatorName(clip(dto.getOperatorName(), LEN_OPERATOR_NAME, "operator_name", truncated)); entity.setOperatorOrgId(clip(dto.getOperatorOrgId(), LEN_OPERATOR_ORG_ID, "operator_org_id", truncated)); entity.setAppName(clip(dto.getAppName(), LEN_APP_NAME, "app_name", truncated)); entity.setBizModule(clip(dto.getBizModule(), LEN_BIZ_MODULE, "biz_module", truncated)); entity.setIp(clip(dto.getIp(), LEN_IP, "ip", truncated)); entity.setIpRegion(clip(dto.getIpRegion(), LEN_IP_REGION, "ip_region", truncated)); entity.setBrowser(clip(dto.getBrowser(), LEN_BROWSER, "browser", truncated)); entity.setOs(clip(dto.getOs(), LEN_OS, "os", truncated)); entity.setRequestUri(clip(dto.getRequestUri(), LEN_REQUEST_URI, "request_uri", truncated)); entity.setEventType(dto.getEventType()); // 受控枚举,validate 已挡 entity.setActionCode(clip(dto.getActionCode(), LEN_ACTION_CODE, "action_code", truncated)); entity.setActionLabel(clip(dto.getActionLabel(), LEN_ACTION_LABEL, "action_label", truncated)); entity.setSourceLayer(dto.getSourceLayer()); entity.setTargetTable(clip(dto.getTargetTable(), LEN_TARGET_TABLE, "target_table", truncated)); entity.setTargetId(clip(dto.getTargetId(), LEN_TARGET_ID, "target_id", truncated)); entity.setBizType(clip(dto.getBizType(), LEN_BIZ_TYPE, "biz_type", truncated)); entity.setBizCode(clip(dto.getBizCode(), LEN_BIZ_CODE, "biz_code", truncated)); // 以下都是 text 列,无长度约束 entity.setFieldDiffs(dto.getFieldDiffs()); entity.setDataSnapshot(dto.getDataSnapshot()); entity.setReason(dto.getReason()); entity.setRecordTitle(clip(dto.getRecordTitle(), LEN_RECORD_TITLE, "record_title", truncated)); entity.setEntryType(clip(dto.getEntryType(), LEN_ENTRY_TYPE, "entry_type", truncated)); entity.setEntryId(clip(dto.getEntryId(), LEN_ENTRY_ID, "entry_id", truncated)); entity.setEntryName(clip(dto.getEntryName(), LEN_ENTRY_NAME, "entry_name", truncated)); entity.setPrevHash(clip(dto.getPrevHash(), LEN_PREV_HASH, "prev_hash", truncated)); entity.setExtra(truncated.isEmpty() ? dto.getExtra() : noteTruncation(dto.getExtra(), truncated)); return entity; } /** * 列宽常量,与 {@code audit_events} 的 DDL 逐列对齐(PG-only,见 spec §4 表定义)。 * DDL 若放宽,这里只是截得早一点(保守、不致命);DDL 若收窄,必须同步改这里。 */ private static final int LEN_CLIENT_EVENT_ID = 70; private static final int LEN_OPERATION_ID = 64; private static final int LEN_TENANT_ID = 64; private static final int LEN_OPERATOR_ID = 64; private static final int LEN_OPERATOR_NAME = 100; private static final int LEN_OPERATOR_ORG_ID = 64; private static final int LEN_APP_NAME = 50; private static final int LEN_BIZ_MODULE = 100; private static final int LEN_IP = 50; private static final int LEN_IP_REGION = 100; private static final int LEN_BROWSER = 100; private static final int LEN_OS = 100; private static final int LEN_REQUEST_URI = 500; private static final int LEN_ACTION_CODE = 50; private static final int LEN_ACTION_LABEL = 200; private static final int LEN_TARGET_TABLE = 100; private static final int LEN_TARGET_ID = 64; private static final int LEN_BIZ_TYPE = 50; private static final int LEN_BIZ_CODE = 100; private static final int LEN_RECORD_TITLE = 500; private static final int LEN_ENTRY_TYPE = 20; private static final int LEN_ENTRY_ID = 50; private static final int LEN_ENTRY_NAME = 200; private static final int LEN_PREV_HASH = 64; /** 超长则截断并把列名记进 {@code truncated};否则原样返回。 */ private static String clip(String value, int max, String column, List<String> truncated) { if (value == null || value.length() <= max) { return value; } truncated.add(column); log.warn("[audit] {} 超长({} > {}),已截断入库并记入 extra.truncatedFields", column, value.length(), max); return value.substring(0, max); } /** * 把截断记录并进 {@code extra}。 * * <p>原 {@code extra} 不是合法 JSON 时**不能就这么丢掉留痕**(那又变回静默截断), * 改为包一层并把原文完整保留在 {@code originalExtra} 里——{@code extra} 是 text 列,装得下。 */ private String noteTruncation(String extra, List<String> truncated) { Map<String, Object> merged = null; if (extra != null && !extra.trim().isEmpty()) { try { merged = JsonUtil.stringToMap(extra); } catch (Throwable ignore) { merged = null; } } if (merged == null) { merged = new LinkedHashMap<>(); if (extra != null && !extra.trim().isEmpty()) { merged.put("originalExtra", extra); // 原文不是 JSON,原样兜住,不丢信息 } } merged.put("truncatedFields", truncated); return JsonUtil.getObjectToString(merged); } private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/impl/AuditQueryServiceImpl.java
New file @@ -0,0 +1,353 @@ package jnpf.bizcommon.audit.service.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import jnpf.audit.AuditConsts; import jnpf.bizcommon.audit.entity.AuditEventEntity; import jnpf.bizcommon.audit.entity.model.AuditEventDetailVO; import jnpf.bizcommon.audit.entity.model.AuditEventListVO; import jnpf.bizcommon.audit.entity.model.AuditOperationGroupRow; import jnpf.bizcommon.audit.entity.model.AuditPageQuery; import jnpf.bizcommon.audit.mapper.AuditEventMapper; import jnpf.bizcommon.audit.mapper.AuditQueryMapper; import jnpf.bizcommon.audit.service.AuditFieldDiffViewService; import jnpf.bizcommon.audit.service.AuditFormRegistry; import jnpf.bizcommon.audit.service.AuditQueryService; import jnpf.bizcommon.audit.service.support.AuditQueryResponseSanitizer; import jnpf.bizcommon.audit.service.support.AuditEventCategories; import jnpf.base.vo.PageListVO; import jnpf.base.vo.PaginationVO; import jnpf.exception.DataException; import jnpf.util.JsonUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** 审计事件查询服务;写入侧完成物理合并,查询侧仅按 operationId 组织展示关系。 */ @Service public class AuditQueryServiceImpl implements AuditQueryService { /** timeline 全量上限(spec:LIMIT 500) */ private static final int TIMELINE_LIMIT = 500; /** 列表只下发前 3 条子表标题 + 总数——保持「列表不含 extra 正文」的敏感面约定。 */ private static final int LIST_SUB_TITLE_LIMIT = 3; /** 分页大小上限(Step 2 契约:强制 pageSize<=100) */ private static final long MAX_PAGE_SIZE = 100L; private static final long DEFAULT_PAGE_SIZE = 20L; @Autowired private AuditQueryMapper auditQueryMapper; @Autowired private AuditEventMapper auditEventMapper; @Autowired private AuditFormRegistry auditFormRegistry; @Autowired private AuditFieldDiffViewService auditFieldDiffViewService; @Override public PageListVO<AuditEventListVO> page(AuditPageQuery query) { long pageSize = query.getPageSize(); if (pageSize <= 0) { pageSize = DEFAULT_PAGE_SIZE; } else if (pageSize > MAX_PAGE_SIZE) { pageSize = MAX_PAGE_SIZE; } long currentPage = query.getCurrentPage(); if (currentPage <= 0) { currentPage = 1L; } // Codex 批次三a Minor#3:OFFSET 溢出防护——currentPage 传超大值(如 Long.MAX_VALUE)时 // (currentPage-1)*pageSize 会 long 乘法溢出成负数,拼进 SQL LIMIT/OFFSET 语义未定义。 // Math.multiplyExact 显式检测溢出,命中则降级为空页(total 仍按条件正常返回,不查明细页)。 long offset; boolean offsetOverflow; try { offset = Math.multiplyExact(currentPage - 1, pageSize); offsetOverflow = false; } catch (ArithmeticException ex) { offset = 0L; offsetOverflow = true; } QueryWrapper<AuditEventEntity> ew = buildWrapper(query); long total = auditQueryMapper.countOperations(ew); List<AuditEventListVO> list; if (offsetOverflow) { list = Collections.emptyList(); } else { List<AuditOperationGroupRow> groups = auditQueryMapper.selectOperationPage(ew, offset, pageSize); list = buildOperationRows(groups); } // 回写生效后的分页参数供返回包装读取(对照 LimsBizLogController:Pagination 承载 total, // JsonUtil.getJsonToBean 窄化成 PaginationVO——同一惯例,仅挪到 Service 层以贴合本任务 // 字面执行的 Controller 签名) query.setCurrentPage(currentPage); query.setPageSize(pageSize); query.setTotal(total); PageListVO<AuditEventListVO> vo = new PageListVO<>(); vo.setList(list); vo.setPagination(JsonUtil.getJsonToBean(query, PaginationVO.class)); return vo; } @Override public AuditEventDetailVO detail(Long id) { AuditEventEntity entity = auditEventMapper.selectById(id); if (entity == null) { throw new DataException("审计事件不存在:id=" + id); } return toDetailVo(entity); } @Override public List<AuditEventListVO> timeline(String targetId) { List<AuditOperationGroupRow> groups = groupRows( auditQueryMapper.selectTimeline(targetId, TIMELINE_LIMIT)); // target_id 只属于业务目标行;签名等关联事件使用自己的 target_id,需按 operation_id 补查。 return buildOperationRows(groups); } private List<AuditEventListVO> buildOperationRows(List<AuditOperationGroupRow> groups) { if (groups.isEmpty()) { return Collections.emptyList(); } List<String> gids = new ArrayList<>(groups.size()); for (AuditOperationGroupRow group : groups) { gids.add(group.getGid()); } return buildOperationRows(groups, auditQueryMapper.selectByOperationGids(gids)); } /** 将已物理合并的事件挂到 BUSINESS 主行;这里不得再次组合 fieldDiffs。 */ private List<AuditEventListVO> buildOperationRows(List<AuditOperationGroupRow> groups, List<AuditEventEntity> events) { Map<String, List<AuditEventEntity>> eventsByGid = new LinkedHashMap<>(); for (AuditEventEntity event : events) { String gid = operationGid(event); eventsByGid.computeIfAbsent(gid, ignored -> new ArrayList<>()).add(event); } List<AuditEventListVO> result = new ArrayList<>(groups.size()); for (AuditOperationGroupRow group : groups) { List<AuditEventEntity> related = eventsByGid.get(group.getGid()); if (related == null) { continue; } AuditEventEntity business = null; List<AuditEventListVO> signatures = new ArrayList<>(2); for (AuditEventEntity event : related) { if (AuditEventCategories.BUSINESS.equals(event.getEventCategory())) { business = event; } else if (AuditEventCategories.PRIMARY_SIGN.equals(event.getEventCategory()) || AuditEventCategories.REVIEW_SIGN.equals(event.getEventCategory())) { signatures.add(toListVo(event)); } } if (business == null) { continue; } AuditEventListVO main = toListVo(business); main.setSubEvents(signatures); main.setSigned(!signatures.isEmpty()); result.add(main); } return result; } private List<AuditOperationGroupRow> groupRows(List<AuditEventEntity> events) { Map<String, AuditOperationGroupRow> groups = new LinkedHashMap<>(); for (AuditEventEntity event : events) { String gid = operationGid(event); AuditOperationGroupRow group = groups.get(gid); if (group == null) { group = new AuditOperationGroupRow(); group.setGid(gid); group.setGrpTime(event.getEventTime()); groups.put(gid, group); } } return new ArrayList<>(groups.values()); } private String operationGid(AuditEventEntity event) { return StringUtils.hasText(event.getOperationId()) ? event.getOperationId() : event.getClientEventId(); } private QueryWrapper<AuditEventEntity> buildWrapper(AuditPageQuery query) { QueryWrapper<AuditEventEntity> ew = new QueryWrapper<>(); if (query.getStartTime() != null) { ew.ge("event_time", query.getStartTime()); } if (query.getEndTime() != null) { ew.le("event_time", query.getEndTime()); } if (StringUtils.hasText(query.getOperatorName())) { ew.like("operator_name", query.getOperatorName()); } if (StringUtils.hasText(query.getAppName())) { ew.eq("app_name", query.getAppName()); } // Codex 批次三a Important#1:biz_module 是用户视角的业务应用筛选(app_name 层 0 固定为 jnpf-visualdev) if (StringUtils.hasText(query.getBizModule())) { ew.eq("biz_module", query.getBizModule()); } if (StringUtils.hasText(query.getEventType())) { ew.eq("event_type", query.getEventType()); } if (StringUtils.hasText(query.getActionCode())) { ew.eq("action_code", query.getActionCode()); } if (StringUtils.hasText(query.getTargetTable())) { ew.eq("target_table", query.getTargetTable()); } if (StringUtils.hasText(query.getBizCode())) { ew.eq("biz_code", query.getBizCode()); } if (StringUtils.hasText(query.getBizType())) { ew.eq("biz_type", query.getBizType()); } if (StringUtils.hasText(query.getKeyword())) { ew.like("field_diffs", query.getKeyword()); } return ew; } private AuditEventListVO toListVo(AuditEventEntity e) { AuditEventListVO vo = new AuditEventListVO(); vo.setId(e.getId()); vo.setOperationId(e.getOperationId()); vo.setClientEventId(e.getClientEventId()); vo.setEventTime(e.getEventTime()); vo.setTenantId(e.getTenantId()); vo.setOperatorId(e.getOperatorId()); vo.setOperatorName(e.getOperatorName()); vo.setOperatorOrgId(e.getOperatorOrgId()); vo.setAppName(e.getAppName()); vo.setBizModule(e.getBizModule()); vo.setIp(e.getIp()); vo.setIpRegion(e.getIpRegion()); vo.setBrowser(e.getBrowser()); vo.setOs(e.getOs()); vo.setRequestUri(e.getRequestUri()); vo.setEventType(e.getEventType()); if (AuditEventCategories.REVIEW_SIGN.equals(e.getEventCategory())) { vo.setSignatureRole("REVIEW"); } else if (AuditEventCategories.PRIMARY_SIGN.equals(e.getEventCategory())) { vo.setSignatureRole("PRIMARY"); } vo.setActionCode(e.getActionCode()); vo.setActionLabel(e.getActionLabel()); vo.setSourceLayer(e.getSourceLayer()); vo.setTargetTable(e.getTargetTable()); vo.setTargetId(AuditQueryResponseSanitizer.hidesTargetId(e.getTargetTable()) ? null : e.getTargetId()); vo.setBizType(e.getBizType()); vo.setBizCode(e.getBizCode()); vo.setDiffCount(computeDiffCount(e.getFieldDiffs())); vo.setReason(e.getReason()); vo.setRecordTitle(e.getRecordTitle()); vo.setEntryType(e.getEntryType()); vo.setEntryId(e.getEntryId()); vo.setEntryName(e.getEntryName()); vo.setCreatedAt(e.getCreatedAt()); // 子事件保留自身签名标记;BUSINESS 主行会在组装后按关联签名重新赋值。 vo.setSigned(AuditConsts.TYPE_E_SIGNATURE.equals(e.getEventType())); applySubTitles(vo, e.getExtra()); return vo; } /** * 从 extra.recordSubTitles 取子表标题行,只下发前 {@link #LIST_SUB_TITLE_LIMIT} 条 + 总数。 * 不整体下发 extra:正文里除 dataSnapshot 外还有各种来源标记与上下文,列表接口不该带出去。 */ @SuppressWarnings("unchecked") private void applySubTitles(AuditEventListVO vo, String extraJson) { if (!StringUtils.hasText(extraJson)) { return; } try { Map<String, Object> extra = JsonUtil.stringToMap(extraJson); Object raw = extra == null ? null : extra.get("recordSubTitles"); if (!(raw instanceof List)) { return; } List<?> all = (List<?>) raw; List<String> head = new ArrayList<>(); for (Object item : all) { if (head.size() >= LIST_SUB_TITLE_LIMIT) { break; } if (item != null) { head.add(String.valueOf(item)); } } vo.setSubTitles(head); vo.setSubTitleCount(all.size()); } catch (Throwable ignored) { // extra 解析失败不影响列表主行——标题是锦上添花,不能炸掉整页 } } private AuditEventDetailVO toDetailVo(AuditEventEntity e) { AuditEventDetailVO vo = new AuditEventDetailVO(); vo.setId(e.getId()); vo.setClientEventId(e.getClientEventId()); vo.setOperationId(e.getOperationId()); vo.setEventTime(e.getEventTime()); vo.setTenantId(e.getTenantId()); vo.setOperatorId(e.getOperatorId()); vo.setOperatorName(e.getOperatorName()); vo.setOperatorOrgId(e.getOperatorOrgId()); vo.setAppName(e.getAppName()); vo.setBizModule(e.getBizModule()); vo.setIp(e.getIp()); vo.setIpRegion(e.getIpRegion()); vo.setBrowser(e.getBrowser()); vo.setOs(e.getOs()); vo.setRequestUri(e.getRequestUri()); vo.setEventType(e.getEventType()); vo.setActionCode(e.getActionCode()); vo.setActionLabel(e.getActionLabel()); vo.setSourceLayer(e.getSourceLayer()); vo.setTargetTable(e.getTargetTable()); vo.setTargetId(AuditQueryResponseSanitizer.hidesTargetId(e.getTargetTable()) ? null : e.getTargetId()); vo.setBizType(e.getBizType()); vo.setBizCode(e.getBizCode()); String modelId = AuditQueryResponseSanitizer.sourceModelId(e.getExtra()); Map<String, jnpf.audit.diff.AuditFieldDiff.FieldMeta> metadata = auditFormRegistry.resolveFieldMetadata(modelId, e.getTargetTable()); String sanitizedFieldDiffs = AuditQueryResponseSanitizer.sanitizeFieldDiffs( e.getFieldDiffs(), metadata); vo.setFieldDiffs(sanitizedFieldDiffs); vo.setFieldDiffList(auditFieldDiffViewService.build(e, sanitizedFieldDiffs, metadata)); vo.setExtra(AuditQueryResponseSanitizer.sanitizeJson(e.getExtra())); vo.setDataSnapshot(AuditQueryResponseSanitizer.sanitizeJson(e.getDataSnapshot(), metadata)); vo.setReason(e.getReason()); vo.setRecordTitle(e.getRecordTitle()); vo.setEntryType(e.getEntryType()); vo.setEntryId(e.getEntryId()); vo.setEntryName(e.getEntryName()); vo.setPrevHash(e.getPrevHash()); vo.setCreatedAt(e.getCreatedAt()); return vo; } /** field_diffs 是 VisualLogModel[] JSON;空/非法 JSON 一律降级为 0,不让摘要计算炸列表接口。 */ private Integer computeDiffCount(String fieldDiffs) { if (!StringUtils.hasText(fieldDiffs)) { return 0; } return AuditQueryResponseSanitizer.effectiveDiffCount(fieldDiffs); } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/listener/AuditVisualLogListener.java
New file @@ -0,0 +1,1678 @@ package jnpf.bizcommon.audit.service.listener; import cn.hutool.http.useragent.UserAgent; import cn.hutool.http.useragent.UserAgentUtil; import com.alibaba.fastjson.JSON; import jnpf.audit.AuditApiConsts; import jnpf.audit.AuditOperationIds; import jnpf.audit.model.AuditEventDTO; import jnpf.audit.AuditConsts; import jnpf.bizcommon.audit.entity.AuditEventEntity; import jnpf.audit.diff.AuditFieldDiff; import jnpf.bizcommon.audit.mapper.AuditEventMapper; import jnpf.bizcommon.audit.service.AuditEventService; import jnpf.bizcommon.audit.service.AuditFieldDiffViewService; import jnpf.bizcommon.audit.service.AuditFormRegistry; import jnpf.bizcommon.audit.service.entry.AuditEntryResolver; import jnpf.bizcommon.audit.service.sign.AuditSignEvidence; import jnpf.bizcommon.audit.service.sign.AuditSignEvidenceReader; import jnpf.bizcommon.audit.service.sign.AuditSignMetaData; import jnpf.bizcommon.audit.service.title.AuditRecordTitle; import jnpf.bizcommon.audit.service.title.AuditRecordTitleBuilder; import jnpf.bizcommon.audit.service.title.AuditTitleSpec; import jnpf.constant.JnpfConst; import jnpf.event.ProjectEventListener; import jnpf.module.ProjectEventInstance; import jnpf.util.JsonUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** * 层 0:在线表单变更事件 → {@code audit_events}(Task C1)。 * * <p>jnpf-visualdev 在 {@code VisualLogServiceImpl.createEventLog} 以 BROADCASTING 发布 * {@code VSLOG_EVENT_KEY} 事件(平台补丁 #1),经 RocketMQ 跨 JVM 到达本服务。本类是 lims 侧 * {@code BaseVisualLogListener} 的迁入版,与它<b>同时订阅</b>——存储天然隔离(新写 audit_events、 * 旧写 lims_biz_log),影子期两条链路互不干扰。 * * <h3>三条防御是照着实测教训写的,改动前先读 tasks/lessons.md</h3> * <ul> * <li><b>L002</b>:整个 handler 包 try/catch(Throwable)。JNPF 的 {@code ProjectEventProccess} * 是 for-iterator 分发,任一 consumer 抛异常会 break 整个循环——同 channel 上的其它 * listener(含 lims 旧 listener)会跟着一起哑掉。审计绝不能拖垮别人。</li> * <li><b>L003</b>:UPDATE 使用 visualdev 在数据库写入前、写入后分别查询得到的 * {@code oldData}/{@code newData}。两份数据必须随同一事件固化,MQ 消费端不回读业务表, * 避免异步延迟读到后续操作产生的未来状态。</li> * <li><b>L004</b>:跨 JVM 后 source 是 {@code JSONObject},当 Map 取。 * {@code VisualLogForm} 用了 {@code @Builder} 没有无参构造器,反序列化不回去。</li> * </ul> * * <h3>数据库写入边界快照</h3> * <p>每条层 0 事件仍在 {@code data_snapshot} 保存操作后的数据,但 UPDATE 的 * {@code field_diffs} 直接比较事件中的 {@code oldData}/{@code newData}。字段只存在于新数据时, * 旧值按空处理并记录为新增;只存在于旧数据时,记录为清空。不再查询上一条审计日志作为基线。 * * <h3>与旧 listener 的一处**刻意不同**(A/B 比对必须知道)</h3> * <p>旧 listener 把 {@code jianyan_liushuihao} 当硬过滤器:取不到就整条丢弃。本实现<b>不跟</b>—— * bizCode 取不到就留空,事件照记。理由是审计的红线是「这次改过不能缺」(B3 确立),而 * {@code jianyan_liushuihao} 是 LIMS 检验流水号,环境监测/水系统等模块的表单根本没有这个字段, * 按旧口径它们一条都不会被记。<b>后果</b>:同一批表单上 {@code audit_events} 会比 * {@code lims_biz_log} 多出事件,C2 的配对率算法必须按此口径解读,否则会把「新表更全」 * 误判成「配对失败」。 */ @Slf4j @Component public class AuditVisualLogListener { /** 幂等键前缀。广播模式下每个实例都会收到同一条事件,靠 UNIQUE(client_event_id) 挡重复。 */ private static final String EVENT_ID_PREFIX = "L0-"; /** 层 0 事件的 app_name = 事实产生进程;用户视角的「在哪个应用上」由 biz_module 承担(spec §5.4)。 */ private static final String SOURCE_APP = "jnpf-visualdev"; private static final String UNKNOWN = "UNKNOWN"; /** * 平台技术列的保留前缀。业务规范禁止业务字段以此开头(仓库 CLAUDE.md「Conventions」), * 故可安全按前缀剔除,不需要维护一张会漂移的列名白名单。详见 {@link #withoutTechColumns}。 */ private static final String TECH_COLUMN_PREFIX = "f_"; /** * 虽带平台前缀,但它们描述的是业务记录的创建/修改责任人和时间,属于审计事实。 * 这些字段必须进入 field_diffs,不能随其余平台技术列一起剔除。 */ private static final Set<String> AUDITED_PLATFORM_COLUMNS = Collections.unmodifiableSet( new LinkedHashSet<>(Arrays.asList( "f_creator_user_id", "f_creator_time", "f_last_modify_user_id", "f_last_modify_time"))); /** * 签名 id 来自请求顶层(非数据字段)时,落进 {@code signExtra.signField} 的<b>展示标签</b>。 * * <p>🔴 <b>它只是标签,不是判据</b>(2026-08-05 Codex 复审 High①)。此前它同时兼着 * 「来源标记」与 {@code Map<String,String> signFieldToId} 的 <b>key</b>,于是「够不够格 * 改写 action_code」这件事被编码在了一个<b>可碰撞的字符串</b>里:业务表只要真有一个 * 同名列,它的数据列证据就会被当成请求来源({@code reviewFromRequest=true} → 把普通 * UPDATE 永久写成 REVIEW),而且 {@code reordered.putAll} 会让数据列的值<b>覆盖</b> * 请求值。全库实查当前 0 个同名列,是埋着的陷阱不是活的故障——但 L081 的规矩正是 * 「来源必须单独记成标记跟着值走」,用字段名编码来源等于把标记又塞回了内容里。 * 现在来源是 {@link SignCandidate#fromRequest} 这个独立布尔量,本常量退回纯展示用途, * 值保持不变是为了历史数据的 {@code signExtra.signField} 语义连续。 */ private static final String REQUEST_SIGN_FIELD = "__request_biz_sign__"; /** * 本类产出的签名事件的 action_code 与 source_layer。 * * <p><b>抽成常量是刻意的</b>(2026-08-03 Codex 终审缺陷 2):A1-b 的历史反查必须按 * 与写入侧**完全一样**的四元组去查(表名 + event_type + action_code + source_layer)。 * 实测同一张 {@code lims_sign} 上,层 2 也写 {@code action_code=SIGN}、层 3 还写 * {@code SIGN_USED}、层 2 写 {@code SIGN_FAIL}——少钉一个维度就会把别的层记的事件 * 算成「本 listener 记过了」,真实的复核签名被判成历史、本次签名事件被静默抑制。 * 两处分别写字面量,改一处漏一处不报任何错。 */ private static final String SIGN_ACTION_CODE = "SIGN"; /** 本 listener 补写的签名属于显式签名事实层;反查与写入共用,理由同 {@link #SIGN_ACTION_CODE}。 */ private static final int SIGN_SOURCE_LAYER = 3; /** * extra.frontendBizData 的长度上限。extra 列不在 {@code AuditEventServiceImpl.clip} 的 * 白名单里,没有任何护栏——批量场景下 N 条事件共享同一份整批 biz_data 快照, * 不设上限会线性叠加到百 MB 级(见调用处注释的实测数字)。 */ private static final int FRONTEND_BIZ_DATA_MAX = 8192; /** * 请求携带的签名「多新才算新鲜」(批 D,2026-08-05)。**超出只留痕、不拒绝**。 * * <p>取 30 分钟是权衡:用户点签名到点确定之间要填表、翻页、被打断,卡太紧会把正常操作 * 误报成可疑;而重放攻击真正的克星是「一次性消费」(签过就作废)而不是时间窗—— * 那要真加消费状态,属批 D 完整版。这里只把年龄如实摆出来给查询侧判断, * 所以阈值宽一点也不会掩盖什么:真要查,{@code signAuthStaleMillis} 里有确切毫秒数。 */ private static final long SIGN_FRESH_WINDOW_MILLIS = 30L * 60 * 1000; @Autowired private AuditEventService auditEventService; @Autowired private AuditEventMapper auditEventMapper; @Autowired private AuditFormRegistry registry; @Autowired private AuditFieldDiffViewService fieldDiffViewService; @Autowired private AuditRecordTitleBuilder recordTitleBuilder; @Autowired private AuditSignEvidenceReader signEvidenceReader; @Autowired private AuditEntryResolver entryResolver; @ProjectEventListener(channelRegex = JnpfConst.VSLOG_EVENT_KEY + ".*") public void onVisualLog(ProjectEventInstance event) { try { handle(event); } catch (Throwable t) { // L002:绝不让异常出去——同 channel 上的其它 listener 是全有或全无的命运共同体 log.error("[AUDIT-L0] 处理 VSLOG 事件失败(业务与其它监听者不受影响)eventId={}", event == null ? null : event.getEventId(), t); } } private void handle(ProjectEventInstance event) { if (!registry.isEnabled() || event == null) { return; } Map<String, Object> form = asMap(event.getSource()); if (form == null) { return; } String modelId = str(form.get("modelId")); if (modelId == null) { return; } AuditFormRegistry.FormMeta meta = registry.resolve(modelId); if (meta == null) { return; // 清单外的表单:默认不记,正常路径,不留痕(否则每次保存都刷一行日志) } // 直接读 meta.getTable(),不再调一次 resolveMainTable(评审 M-10:resolve() 内部已解析过一次)。 // 安全性来自 AuditFormRegistry.setForms 会把 table 归一化写回 meta——两者是同一个小写表名。 String table = meta.getTable(); String dataId = str(form.get("dataId")); Object typeRaw = form.get("type"); boolean create = typeRaw instanceof Number && ((Number) typeRaw).intValue() == 0; // type=2 是 AUDIT 平台补丁(2026-08-01)新增的删除事件,平台原生只发 0/1。 boolean delete = typeRaw instanceof Number && ((Number) typeRaw).intValue() == 2; boolean read = typeRaw instanceof Number && ((Number) typeRaw).intValue() == 3; if (read && !registry.isRecordRead()) { return; } String actionCode = read ? AuditConsts.ACT_READ : (delete ? AuditConsts.ACT_DELETE : (create ? AuditConsts.ACT_CREATE : AuditConsts.ACT_UPDATE)); // UPDATE 的两侧都是 visualdev 在数据库写入边界读取的快照;MQ 消费端不再查询历史审计快照。 Map<String, Object> oldData = asMap(form.get("oldData")); Map<String, Object> newData = asMap(form.get("newData")); // 两侧统一字符串化,避免 JDBC/JSON 数值与日期类型差异制造假 diff。 Map<String, Object> beforeRow = stringifyValues(oldData); Map<String, Object> eventRow = stringifyValues(newData); Map<String, AuditFieldDiff.FieldMeta> labels = AuditFormRegistry.mergeFieldMetadata( registry.resolveFieldMetadata(modelId, table), fieldMeta(form.get("listLog"))); List<Map<String, Object>> childDiffs = childDiffs(form.get("listLog")); Set<String> childFields = new LinkedHashSet<>(subTableFields(labels)); Set<String> masked = effectiveMasked(meta.getMaskedFields(), eventRow, labels); masked.addAll(effectiveMasked(meta.getMaskedFields(), beforeRow, labels)); Map<String, Object> extra = new LinkedHashMap<>(); // C2 的就近配对靠这两个键 join 旧表的 source_model_id / source_data_id,别改名 extra.put("sourceModelId", modelId); if (dataId != null) { extra.put("sourceDataId", dataId); } AuditCtx ctx = AuditCtx.parse(form.get("auditCtx"), event.getTenantId(), extra); if (!registry.acceptsRequest(ctx.appScope, ctx.appCode)) { return; } extra.put("appScope", ctx.appScope); extra.put("appCode", ctx.appCode); // 操作入口:用户从哪个菜单点进来的。三种状态必须分得开(L075:「没携带」≠「不存在」): // 没带 header → degraded(旧前端 / 非浏览器调用 / 定时任务) // 带了但查不到菜单行 → unresolved(菜单被删了,但 entry_id 仍是可追溯的事实) // 正常 → 不写标记 String entryType = null; String entryId = null; String entryName = null; if (ctx.menuId == null) { extra.put("entrySource", "degraded"); } else { entryType = AuditEntryResolver.TYPE_MENU; entryId = ctx.menuId; entryName = entryResolver.resolveMenuName(ctx.menuId); if (entryName == null) { extra.put("entrySource", "unresolved"); } } if (read) { recordRead(event, form, meta, table, dataId, ctx, extra, entryType, entryId, entryName); return; } String fieldDiffs = null; if (!childDiffs.isEmpty()) { extra.put("subTableDiffSource", "visualLogListLog"); } else if (!(form.get("listLog") instanceof List) && !childFields.isEmpty()) { // 兼容补丁上线前或非标准发布方:没有 listLog 契约时仍如实标记不可判定。 extra.put("subTableUndecidable", new ArrayList<>(childFields)); } // 脱敏列用数据库写入前后的真实值判等,确定发生变化后再由 AuditFieldDiff 将两侧值替换为 ***。 Map<String, Object> snapshotRow = eventRow; if (eventRow == null) { extra.put("degraded", "事件未携带 newData,本次变更内容不可得"); } else if (delete) { // 删除不产 diff:没有 after 值,编出来的 diff 是假的(B3 红线)。 // 只留 data_snapshot——「删掉的是什么」正是删除审计的全部价值。 extra.put("snapshotSource", "eventNewData"); extra.put("snapshotScope", "deleted"); } else if (create) { // CREATE 的 before 为 null ⇒ 每一列都算新增,故技术列必须在此剔掉(见 forDiff) fieldDiffs = AuditFieldDiff.diff(null, withoutFields(forDiff(eventRow), childFields), masked, labels); extra.put("snapshotSource", "eventNewData"); // **平台把空值字段整个从 newData 里剥掉了**(2026-07-30 实测,见 knownEmptyFields 注释)。 // 只存事件携带的键 ⇒ baseline 天生缺字段 ⇒ 那些字段**第一次被填写**时会被 UPDATE 的 // 两侧交集排除,真实变更漏记。这里按已发布表单模型把「已知为空」显式补进快照, // 让「已知为空」与「baseline 真正未知」从此是两种状态、不再共用一个"缺键"表示。 Set<String> formFields = registry.resolveBaselineFields(modelId, table); List<String> knownEmpty = knownEmptyFields(eventRow, formFields, masked, labels); snapshotRow = withKnownEmpty(eventRow, knownEmpty); // 补没补、补了哪些,一律留痕:补齐依赖表单元数据可读,取不到时会静默退回旧行为, // 而"审计有洞而无人知道"正是这套代码里反复被咬的那个坑(同 whoSource/timeSource)。 // **按 formFields 而不是按 knownEmpty 判**:整份表单都填满了(无需补)与 // 元数据根本取不到(补不了)是两回事,混成一个值就分辨不出快照到底完不完整。 extra.put("baselineScope", formFields.isEmpty() ? "eventOnly" : "formModel"); if (!knownEmpty.isEmpty()) { extra.put("baselineKnownEmpty", knownEmpty); } } else { extra.put("snapshotSource", "eventNewData"); if (beforeRow == null) { extra.put("snapshotDegraded", true); extra.put("diffSource", "missingOldData"); } else { fieldDiffs = databaseBoundaryDiff(beforeRow, eventRow, masked, labels, childFields); extra.put("diffSource", "databaseBeforeAfter"); } } fieldDiffs = appendChildDiffs(fieldDiffs, childDiffs); if (fieldDiffs != null) { AuditEventEntity valueContext = new AuditEventEntity(); valueContext.setTargetTable(table); valueContext.setTenantId(ctx.tenantId); valueContext.setEventTime(ctx.eventTime); valueContext.setActionCode(actionCode); fieldDiffs = fieldDiffViewService.enrichForPersistence( valueContext, fieldDiffs, labels); } // ── 标题规格分流(D6)─────────────────────────────────────────────────── // 表单操作读表单勾选,列表操作(删除)读列表勾选。前端把开关做在两个界面上, // 用户的心智就是「在哪勾的在哪生效」。 // 注意:titleSpec 不止喂下面的标题渲染,也喂签名字段候选判定(见下一段注释)—— // 删除路径的签名候选因此也会跟着改读列表勾选,而不是表单勾选。命名规则命中的 // 凭证字段恒为候选(不看 spec,见 AuditSignEvidenceReader.candidateFields), // 真正受影响的只是「表单勾了 + 列表没勾 + 命名规则又不命中」这类字段: // 它们在删除事件里会从签名候选中掉出去。 AuditTitleSpec titleSpec = delete ? registry.resolveListTitleSpec(modelId, table) : registry.resolveTitleSpec(modelId, table); // ── 签名字段判定(spec §5.4,D4)────────────────────────────────────────── // 权威来源是表单配置 confirmBtnConfig/reviewBtnConfig 的 biz_sign_field(D4)—— // 多人签名场景下字段名可以是任意的,光靠命名规则+勾选会漏。命名规则命中的字段与 // 勾选字段仍保留作兜底:同一个 biz_log_enabled 开关能同时服务「进标题」与「是签名」 // 两种语义,而不需要前端再加一个开关。三条来源合并后仍要拿值去签名表撞一下, // 命中的才真正算签名字段(防误判的最后一关,配置不代表这次真的触发了签名)。 List<String> configuredSignFields = registry.resolveSignFields(modelId, table); List<String> signCandidateFields = AuditSignEvidenceReader.candidateFields( eventRow, titleSpec, configuredSignFields); // 🔴 候选是**带来源标记的三元组列表**,不是 field→signId 的 Map(2026-08-05 Codex // 复审 High①,理由见 REQUEST_SIGN_FIELD 的 javadoc)。顺序即优先级,与原先 // LinkedHashMap 的迭代序一致;来源由 SignCandidate.fromRequest 承担,与字段名解耦。 List<SignCandidate> signCandidates = new ArrayList<>(); Map<String, AuditSignEvidence> evidences = Collections.emptyMap(); List<String> ids = new ArrayList<>(); /** 请求声称携带、但在签名表里查不到的那个 id(不存在/已删/跨租户)。见剔除处的注释。 */ String requestSignNotFound = null; // Task 1 平台补丁把「本次操作」的签名 id 放在事件顶层(删除路径前端只能这么传, // 因为被删数据里的签名字段存的是创建/编辑时的历史签名,不是这次删除的)。 // signExtra.signField 落 REQUEST_SIGN_FIELD 这个标签,查询时能看出这条签名是 // 「请求携带」还是「数据字段里读到的」——但判定一律走 fromRequest,不认标签。 // **先加、排在最前**:它是本次操作最权威的签名来源,撞 MAX_IDS 上限时先保住它 // (原先靠 reordered.putAll + ids.add(0,…) 事后重排,现在靠添加顺序天然成立)。 String eventBizSign = str(form.get("bizSign")); if (eventBizSign != null && !eventBizSign.trim().isEmpty()) { signCandidates.add(new SignCandidate(REQUEST_SIGN_FIELD, eventBizSign.trim(), true)); ids.add(eventBizSign.trim()); } for (String field : signCandidateFields) { String value = AuditFieldDiff.stringify(lookup(eventRow, field)).trim(); if (!value.isEmpty()) { signCandidates.add(new SignCandidate(field, value, false)); ids.add(value); } } if (!ids.isEmpty()) { // 按租户读(批 D):f_id 单列唯一不是数据库不变量,跨租户的 signId 直接读不出来, // 于是候选在下面被剔掉、连签名事件都不会产出。见 read 的 javadoc。 evidences = signEvidenceReader.read(ids, ctx.tenantId); // 没命中签名表的候选不是签名字段,剔回标题侧。 Iterator<SignCandidate> pruning = signCandidates.iterator(); while (pruning.hasNext()) { SignCandidate pruned = pruning.next(); if (!evidences.containsKey(pruned.signId)) { // 🔴 数据列候选落空是**正常**的(那个字段本来就不是签名字段,剔回标题侧即可); // 但**请求声称携带了签名、凭证却查不到**是另一回事:不存在、已删、 // 或属于别的租户(D1 收窄之后跨租户的正是在这里落空)。 // 不留痕的话它与「请求压根没带签名」长得一模一样——等于把一次可疑的声称 // 洗成了「本来就没签」,而且**越高明的攻击反而越安静**(跨租户静默、 // 冒用有痕),方向正好反了。(L075:两种缺席不能共用一个表示。) if (pruned.fromRequest) { requestSignNotFound = pruned.signId; } pruning.remove(); } } } // ── 请求携带签名的授权判定(批 D,2026-08-05,Codex 终审 High①)──────────────── // 修复前 form.bizSign 恒为 null,这条无校验的信任路径是**死的**;是 P1a 把它接通的。 // 接通之后,请求里塞任意一个已知的 signId 都会被当成「本次操作的签名」,而签名事件的 // operatorId 取的是**那条凭证的主人**——等于凭一个 id 就能把「谁在何时签的名」这个 // 事实伪造进 append-only、存十年的审计表。实测库里有 5 个用户的签名共 1306 条。 // // 本层只做**审计侧**的收口(用户 2026-08-05 拍板):listener 跑在 MQ 消费端、是异步的, // 拦不住那次删除已经发生——但它能拒绝把伪造的归属**写进审计**,而这正是审计的本职。 // 同步拒绝请求要改 jnpf-visualdev 控制器(平台补丁),另开一轮。 // // 判据是免费的:evidence.operatorId() 就是 lims_sign.f_creator_user_id,ctx.userId 就在手边。 for (SignCandidate candidate : signCandidates) { if (!candidate.fromRequest) { continue; // 数据列证据不走这条路:它们本来就要过历史反查那一关 } AuditSignEvidence evidence = evidences.get(candidate.signId); if (evidence == null) { continue; } String owner = evidence.operatorId(); if (owner == null || ctx.userId == null || UNKNOWN.equals(ctx.userId)) { // 判不了 ≠ 判非法(L075)。auditCtx 降级(旧前端/非浏览器调用)时拿不到操作人, // 这时拒绝会把正常路径一起打死。**不拒绝,但如实留痕**——留痕本身就是线索: // 「这条签名归属没被验证过」与「验证通过了」在查询侧必须分得开。 extra.put("signAuthUndecidable", owner == null ? "signOwnerUnknown" : "operatorUnknown"); continue; } if (!owner.equals(ctx.userId)) { // 🔴 **只留痕,不拒绝**(2026-08-05 Codex 补审,用户拍板)。 // 第一版这里判「冒用」并拒收,那是**错的**——`POST /lims/biz/sign/verify_user` // (LimsSignServiceImpl.verifyUserAndRecord)是「指定用户签名」的正规入口: // 操作人 A 在浏览器前操作,复核人 B 走过来输自己的账号密码完成复核签名, // 凭证主人是 B、发请求的 session 仍是 A,`allow_self != yes` 时甚至**强制** // 两者不同。这是标准的 GMP 多人会签,全库 1306 条签名里就有 5 个不同的主人。 // 本仓库自己的 docs/handoff-2026-07-29-audit-query-semantics.md 早就写着: // 「creator_user_id …**对 verify_user 场景不能只看当前请求操作人**」。 // // 那能不能补个判据把「会签」与「盗用 signId」分开?**不能**——两者在数据上 // 长得一模一样:lims_sign 没有任何列记录「谁取得了这份凭证」,层 2 的 SIGN // 事件只覆盖 8/1306(0.6%)。信息不在表里,加谓词补不了(批 A 方案 B 同款形状)。 // 硬判的代价是把一次**真实的合规会签**明文记成「伪造尝试」,而审计表 append-only // 洗不掉——**「错的判成了」比「没判」更坏**(L075)。 // // 所以审计层的本分是**把不一致照出来,而不是拦下来**:签名事件照常产出、 // 正确记在签名人名下(会签的复核签名不能丢),同时如实标注「签名人≠操作人」, // 让审计员自己看。真正的拦截属于平台层同步校验(另开一轮), // 根治属于批 D 完整版(服务端消费签名时显式记归属 + 一次性消费)。 Map<String, Object> diff = new LinkedHashMap<>(); diff.put("signField", candidate.signField); diff.put("signId", candidate.signId); diff.put("signOwner", owner); diff.put("operator", ctx.userId); extra.put("signerDiffersFromOperator", diff); continue; } // 陈旧签名**只留痕不拒绝**:硬时间阈值会误伤慢用户与时钟偏移,而重放的根治是 // 「一次性消费」(签过就不能再用),那要真加消费状态——批 D 完整版的活。 // 这里只把「这份凭证签于多久以前」如实摆出来,让查询侧自己判断。 long ageMillis = ctx.eventTime != null && evidence.signedAt() != null ? ctx.eventTime.getTime() - evidence.signedAt().getTime() : -1L; if (ageMillis > SIGN_FRESH_WINDOW_MILLIS) { extra.put("signAuthStaleMillis", ageMillis); } } // 「哪些字段不许进标题」——请求携带的那条也放进来(它不对应真实列,多排除一个 // 不存在的名字无害;万一业务真有同名列,排除是安全的那个方向)。 Set<String> signFields = new LinkedHashSet<>(); for (SignCandidate candidate : signCandidates) { signFields.add(candidate.signField); } // 批量场景判定提前到这里:下面的 frontendBizData 膨胀防护要用,D3 的标题后缀 // (见后文)也要用,避免同一个 form 字段读两次。 Integer batchSize = form.get("batchSize") instanceof Number ? ((Number) form.get("batchSize")).intValue() : null; // 批量删除时 N 条事件共享 batchOpId 当 operationId,查询侧 // 写入侧按 operation_id + event_category 物理合并;operation_id 代表完整前端操作。 // clientEventId 仍按事件唯一,否则撞 uni_audit_events_client_event_id。 // **位置上移**(2026-08-03,A1-b):下面的历史签名反查要拿它当「排除本次操作」的判据, // 而它原先算在 DTO 组装处。只读 form 字段,上移无副作用。 String batchOpId = str(form.get("batchOpId")); // operationId 的事实源与层 2 @AuditLog 保持一致:优先使用请求线程固化的请求头。 // 签名事件本身仍只根据 evidences 生成;关联业务日志不等于认可签名凭证。 String correlatedBizSign = ctx.auditBizSign != null ? ctx.auditBizSign : eventBizSign; String correlatedOperationId = AuditOperationIds.correlation(correlatedBizSign); String operationId = correlatedOperationId != null ? correlatedOperationId : ((batchOpId != null && !batchOpId.trim().isEmpty()) ? batchOpId.trim() : EVENT_ID_PREFIX + event.getEventId()); // ── 本次操作签的 vs 行里留着的历史签名(A1-b,2026-08-03 Codex 终审 High③)────── // L078 立的规矩原先只在删除路径生效(P1b),非删除路径恒判「本次」。真实数据形态下 // 这是错的(本轮实测): // · 用户点一次「复核」再点「确定」会生成**两条**签名,分别落进 fuhe_sign / biz_sign; // · biz_sign 列每次保存都会刷新成新生成的确定签名,所以它几乎不可能是历史; // · 真正会变成历史的是 fuhe_sign 这类**只在复核那次写、之后普通保存不再更新**的列—— // 之后每次普通保存都会把它重发一条签名事件,event_time 停在上次复核那一刻, // 却挂在这次保存的 operation 下。这正是 L078 骂的时间倒流,只是换到了更新路径。 // // 判据取自 audit_events 而不是 lims_sign.meta_data 的 biz_data(用户 2026-08-03 拍板 D7): // meta_data 整块由前端 POST、后端 LimsSignServiceImpl 原样落库、零校验,拿它判定等于 // 把「凭空产出一条签名事件」的口子交给前端报文;而「这条签名之前记过没有」这个信息 // 本来就只存在于审计表里。也别想拿 baseline 比对签名列有没有变——快照里签名列是 `***`。 List<Map<String, Object>> historicalSignRefs = new ArrayList<>(); List<Map<String, Object>> signAuthRejected = new ArrayList<>(); if (!signCandidates.isEmpty()) { // 删除路径**不查**:数据列候选恒判历史(P1b 规则不动),批量删 N 条一次都不查。 Set<String> recorded = Collections.emptySet(); if (!delete) { List<String> dataFieldIds = new ArrayList<>(); for (SignCandidate candidate : signCandidates) { if (!candidate.fromRequest) { dataFieldIds.add(candidate.signId); } } if (!dataFieldIds.isEmpty()) { recorded = recordedSignIds(dataFieldIds, operationId, ctx.tenantId); if (recorded == null) { // D8 fail-closed:查询失败时全判历史。append-only 表里的假签名洗不掉, // 比一个带留痕的已知缺口更糟;与 AuditSignEvidenceReader.read 失败时 // 「宁可这次不产出签名事件」同口径。signId 仍进 historicalSignRefs, // 信息不丢,只是从「签名事件」降级成「可追的引用」。 extra.put("signHistoryDegraded", true); recorded = new LinkedHashSet<>(dataFieldIds); } } } // 判定结果**记在候选自己身上**,不再另建一个「字段名集合」——集合按名字装, // 而名字既可能撞车(High①)也不是候选的身份;下游两个循环直接读这个标记。 for (SignCandidate candidate : signCandidates) { candidate.thisOperation = signedByThisOperation( delete, candidate.fromRequest, candidate.signId, recorded); if (!candidate.thisOperation) { historicalSignRefs.add( historicalSignRef(candidate.signField, candidate.signId, evidences.get(candidate.signId))); } } } // 请求声称携带的签名压根查不到(不存在/已删/跨租户)——同样是一次「声称签了名」 // 但拿不出凭证的事实,与冒用并列记进同一个键,让查询侧一处就能看全所有可疑声称。 if (requestSignNotFound != null) { Map<String, Object> ref = new LinkedHashMap<>(); ref.put("signField", REQUEST_SIGN_FIELD); ref.put("signId", requestSignNotFound); ref.put("reason", "signNotFound"); ref.put("presentedBy", ctx.userId); signAuthRejected.add(ref); } // 被拒的凭证如实留痕:操作照旧发生了(本层异步、拦不住),但审计表里绝不写下 // 一条「某某在此时签了名」的假事实——只记「有人提交了一份不属于自己的凭证」。 if (!signAuthRejected.isEmpty()) { extra.put("signAuthRejected", signAuthRejected); } // A2(Codex 终审 Medium⑤):判为历史的候选留一份可追的引用。层 0 上线前的老数据、 // 或当年 MQ 丢过事件的记录,行内有 biz_sign 却没有对应的 SIGN 事件——删除后快照里 // 那一列被脱敏成 `***`,判定又把它跳过,关联就永久不可恢复了。 // **刻意不带本次的 operation/eventTime**:这些是历史事实的引用,不是本次操作的证据 // (L078 的规矩);signedAtMillis 是签名凭证自身的创建时刻。 if (!historicalSignRefs.isEmpty()) { extra.put("historicalSignRefs", historicalSignRefs); } // ── 动作语义覆盖(spec D5)─────────────────────────────────────────────────── // meta_data 唯一的不可替代价值——「用户点的是哪个按钮」。层 0 从 type 只能分出 // CREATE/UPDATE/DELETE,分不出「复核」与「普通保存」,而复核在 GMP 语境下是 // 必须独立追溯的动作类型。多个签名证据只认第一份(一次操作只有一个动作语义)。 // // ⚠️ 按 signFieldToId 的顺序取,不按 evidences.values():evidences 是 // `WHERE f_id IN (...)` 查回来的 Map,行序是数据库返回序,不保证等于 IN 列表顺序; // 删除事件可能同时命中「本次删除的 bizSign(Step 2.5 已排最前)」和「被删数据里的 // 历史 biz_sign」两个候选,若按 evidences.values() 取第一个,选中哪份纯看数据库 // 返回顺序,会把 Step 2.5 精心建立的优先级架空。与下面的签名事件循环同一手法 // (signFieldToId.entrySet() + evidences.get(id)),那里已经这么写,这里跟齐。 // // 🔴 删除场景不接受 meta_data 的覆盖,理由分两条,**别顺手把下面两处 delete 短路 // "优化"掉**(评审修复轮次 1,2026-08-02,Step 2.5 落地后才暴露的新组合,brief 从未 // 权衡过这个交叉): // ① action_code:Step 2.5 把签名证据喂给了删除事件之后,只要那条 lims_sign 的 // is_review_button=true(业务把删除二次确认配成了复核按钮),action_code 就会从 // DELETE 被悄悄改写成 REVIEW——查询侧是 action_code 精确匹配,这条删除记录会从 // 「按 DELETE 筛」的结果里整个消失,直撞「不许丢掉『这次改过』」的审计红线。 // DELETE 是硬事实,任何 meta_data 都不能盖过它,REVIEW 覆盖必须带 !delete 短路。 // ② action_label:biz_button 的可信度分场景——is_biz_form=true 的「复核」「确定」是 // 平台语义、值域受控;is_biz_form=false 的「批量按钮」「列表按钮」是业务开发者在 // 列表设计器里随手起的 UI 文案,没人审。把它当审计动作名, // action_code=DELETE + action_label=批量按钮 这种组合对合规审查员而言正是 // actionLabel(boolean) javadoc 骂过的「既不是模块也不是动作的混合物」。 // 所以调用处(见 dto.actionLabel(...))delete 恒显示「删除」,按钮名仍存进 // extra.bizButton 留档,只是不进这一列人读的动作名。 // 🔴 **先选定,再施加**,不再「取第一份就 break」(A1-a,2026-08-03 Codex 终审 High③)。 // 旧写法在真实数据形态下选错了:一次复核保存必然同时带确定签名(is_review_button=false) // 与复核签名(is_review_button=true),而候选顺序由表单配置决定、实测确定那份恒排在前, // 于是 REVIEW 永远轮不到——实测 audit_events id 101 出的是 action_code=CREATE + // bizButton=「确定」,全库仅有的 3 条 REVIEW 全部来自 e2e 脚本人为构造的靶子记录 // (那条记录 biz_sign 列刻意为空),**真实用户走不到 REVIEW**。 // 「一次操作只认一份动作语义」的约束不变,变的是选哪一份:复核证据优先,没有才取第一份。 // 🔴 复核证据只有在它**真的能改写动作语义**时才优先(2026-08-03 端到端第二次实测补)。 // CREATE/DELETE 是生命周期硬事实、覆盖不了,那时仍取第一份——也就是触发这次保存/删除 // 的那个按钮。否则 extra.bizButton 会记成「复核」,而复核并没有产生这次数据变更 // (它自己那条签名事件已经独立记着了)。实测 audit_events id 146:新建时同时点了复核, // CREATE 事件的 bizButton 被记成「复核」,把「用户点确定保存」这个事实盖掉了。 // **短路放在选证据这一步、不放在下游**:放下游就会出现「选中了却用不上」的中间态, // action_code 与 extra.bizButton 各说各话。 // 🔴🔴 **REVIEW 只认请求携带的签名**(2026-08-03 Codex 终审缺陷 1/3/4,用户拍板走方案 B)。 // 反查 audit_events 只是「这次刚签的吗」的**代理指标**,而它两个方向都会错: // · 查不到 ≠ 刚签的——层 0 上线前的老数据、当年 MQ 丢过事件的记录都查不到, // 于是一次普通保存会被**永久**写成 REVIEW(append-only,改不回来); // · 查得到 ≠ 历史——MQ 乱序时,同一条记录的两次操作谁先被消费不定, // 后发生的那次可能先落库,把先发生的那次挤成「历史」。 // 信息本身不在那张表里,加谓词补不了。所以**动作语义这一侧退回删除路径的同口径**: // 只有 REQUEST_SIGN_FIELD(请求顶层显式带下来的签名)才够格改写 action_code。 // 代价是前端补传之前 REVIEW 出不来——但那是诚实的「判不了」,不是错的「判成了」。 // 反查**保留**,只用在低风险的那一半:抑制重复的签名事件(错了顶多多发/少发一条, // 且下次操作自愈,不会往业务事件上焊死一个错误的 action_code)。 // 根治方向是服务端在消费签名时显式记录归属(lims_sign.data_id 现在是空的), // 排进批 D 与 High①(签名授权)一起做。 boolean reviewCanApply = !delete && !create; String bizButtonLabel = null; AuditSignMetaData md = null; // 选定的那一份动作语义;null = 本次操作没有签名证据 // md 是从数据列证据里选中的(= 它「是不是这次签的」由反查这个代理指标判出来,可能错)。 // 只影响 extra 留痕的可信度标注,不参与任何判定(2026-08-05 Codex 复审 Medium②)。 boolean mdFromDataField = false; boolean reviewSeenButUntrusted = false; // md 选中的那份复核证据**是不是请求携带的**。只看 md.isReviewButton() 不够—— // 兜底分支(md == null 时取第一份)照样会把数据列里的复核证据放进 md, // 于是 action_code 又被它改写了(2026-08-03 端到端第三次实测,audit_events id 204)。 // 「够不够格改写」是**来源**属性,不是内容属性,必须单独记,不能从 md 反推。 boolean reviewFromRequest = false; for (SignCandidate candidate : signCandidates) { AuditSignEvidence evidence = evidences.get(candidate.signId); // 判为历史的证据**只为 reviewUndecidable 留痕才解析**:parse 会整份 JSON 解出来 // 再把 biz_data 重新序列化一遍(实测可达数百 KB),批量删 N 条 × M 个历史签名列 // 全解析会压垮 MQ 消费线程;而 delete/create 路径上 reviewUndecidable 恒不产出 // (reviewCanApply=false),那里解析纯属白费。 if (evidence == null || (!candidate.thisOperation && !reviewCanApply)) { continue; } AuditSignMetaData parsed = AuditSignMetaData.parse(evidence.metaData()); // 🔴 留痕条件**与历史判定解耦**(2026-08-05 Codex 复审 Medium②):数据列里的复核 // 证据无论被反查判成本次还是历史,动作语义都不认它、用户看到的都是「没有 REVIEW」, // 那就都得留下「为什么没有」。此前这行挂在 thisOperation 守卫之后——被判成历史的 // 那条复核证据连 reviewUndecidable 都不留,把「判不了」又伪装成了「没发生」(L075)。 if (!candidate.fromRequest && parsed.isReviewButton()) { reviewSeenButUntrusted = true; } if (!candidate.thisOperation) { continue; // 历史签名的 meta_data 描述的是当时那次操作,不是这次 } if (md == null) { md = parsed; // 没有复核证据时的兜底 = 第一份(既有行为) mdFromDataField = !candidate.fromRequest; } if (!parsed.isReviewButton()) { continue; } if (candidate.fromRequest) { md = parsed; mdFromDataField = false; reviewFromRequest = true; break; // 请求携带的复核证据优先,找到即定 } // 数据列里的复核证据:判不了它是不是这次签的,**不拿它改写 action_code** // (留痕已在上面与历史判定解耦地做过了)。 } if (reviewCanApply && reviewSeenButUntrusted && !AuditConsts.ACT_REVIEW.equals(actionCode)) { extra.put("reviewUndecidable", "dataFieldOnly"); } // REVIEW 覆盖同时短路 delete **与 create**(见上面 reviewCanApply): // 「新建一条记录的同时点了复核」是真实工作流(实测 audit_events id 101、119), // 而 CREATE 与 DELETE 一样是**生命周期硬事实**——按 action_code='CREATE' 筛时这条 // 记录的创建事实会整个消失,与下面注释①骂 DELETE 那条是同一个红线。 // 这个坑在 A1-a 之前就潜伏着(候选顺序里复核排前就会中招,只是实测确定恒排前所以 // 没现形),A1-a 把「复核证据优先」定死之后变成必现,由端到端第 ① 场景抓出。 // 复核这件事没有丢:复核签名照样产出独立的签名事件。 boolean reviewApplied = reviewCanApply && reviewFromRequest; if (md != null) { if (reviewApplied) { actionCode = AuditConsts.ACT_REVIEW; } if (md.bizButton() != null && !md.bizButton().isEmpty()) { extra.put("bizButton", md.bizButton()); // 机器可读留档,恒留 if (mdFromDataField) { // 这份按钮名来自**数据列**证据,而「它是不是这次操作签的」是反查这个 // 代理指标判出来的——两个方向都可能错(老数据查不到 / MQ 乱序)。 // 请求携带的证据没有这个不确定性,两者必须在查询侧分得开 // (2026-08-05 Codex 复审 Medium②)。 extra.put("bizButtonSource", "dataField"); } // 🔴 只有复核按钮的文案才够格当 action_label(2026-08-02 端到端实测修复)。 // 收窄前这里无条件覆盖,于是新增/编辑两次实测的 action_label 都是「确定」—— // 前端确定按钮上那个 UI 文案,把「新建」「编辑」这两个系统判定出来的动作词 // 顶掉了:action_code 还是对的(CREATE/UPDATE),只有给人读的这一列退化成了 // 界面文案。复核是唯一的例外,因为 is_review_button=true 时上面同时把 // action_code 改成了 REVIEW——动作码已经变了,动作名跟着走才自洽;而且这条 // 分支的 biz_button 值域受控(is_biz_form=true 的平台按钮),不是业务开发者 // 在列表设计器里随手起的名字。 // 判据用 reviewApplied 而不是 md.isReviewButton():上面那句「动作码已经变了、 // 动作名跟着走才自洽」只有在覆盖**真的发生了**时才成立。create/delete 被短路时 // action_code 仍是 CREATE/DELETE,动作名再显示「复核」就又成了那个 // 「既不是模块也不是动作的混合物」。两者绑同一个布尔量,不靠巧合对齐。 if (reviewApplied) { bizButtonLabel = md.bizButton(); } } if (md.rawBizData() != null) { // 批量场景下每条事件共享同一份整批快照,N 条事件各存一份就是 N² 膨胀 // (500 行 × 250KB × 500 条 ≈ 125MB,AUDIT_SNAPSHOT_LIMIT=500 时的实测量级), // 而 extra 列不在 clip 白名单里、无护栏。批量时它对单条记录本来就没有指向性, // 不写;单条时也设上限,防超大表单。 if (batchSize != null && batchSize > 1) { extra.put("frontendBizDataOmitted", "batch"); } else { String raw = md.rawBizData(); if (raw.length() > FRONTEND_BIZ_DATA_MAX) { extra.put("frontendBizData", raw.substring(0, FRONTEND_BIZ_DATA_MAX)); extra.put("frontendBizDataTruncated", raw.length()); } else { extra.put("frontendBizData", raw); } } } } // ── 记录标题(spec §5.2)─────────────────────────────────────────────────── Set<String> titleExcluded = new LinkedHashSet<>(masked); titleExcluded.addAll(signFields); AuditEventEntity titleContext = new AuditEventEntity(); titleContext.setTargetTable(table); titleContext.setTenantId(ctx.tenantId); titleContext.setEventTime(ctx.eventTime); titleContext.setActionCode(actionCode); Map<String, String> frontendDisplayValues = frontendDisplayValues( form.get("auditDisplayFields"), titleSpec, titleExcluded); AuditRecordTitle title = recordTitleBuilder.build( titleSpec, eventRow, titleContext, labels, titleExcluded, frontendDisplayValues); if (!frontendDisplayValues.isEmpty()) { extra.put("titleDisplaySource", "frontendSubmit"); } String baseRecordTitle = recordTitleBuilder.resolveMainTitle( titleSpec, title, eventRow, dataId); if (titleSpec.isEmpty()) { // 一个字段都没勾——当前库里 148 份表单的真实状态。delete 分支下这句指的是 // 「列表一个字段都没勾」(D6 分流,见上),非 delete 分支指的是表单勾选。 // 标题回落到当前业务记录 f_id;留痕以便区别于「配了但渲染不出来」。 extra.put("titleScope", "unconfigured"); if (baseRecordTitle != null) { extra.put("titleFallback", "f_id"); } } if (!title.subTitles().isEmpty()) { extra.put("recordSubTitles", title.subTitles()); if (title.subTable() != null) { extra.put("recordSubTitleTable", title.subTable()); } } if (title.subDataUnavailable()) { extra.put("subTitleSource", "unavailable"); } // 子表标题完整性(2026-08-05 Codex 复审 Low)。两道上限一直是**悄悄**截的,读的人 // 看到 200 条会以为那就是全部;资源上界下推之后又多一种截法——额度耗尽时后面整张 // 子表都不碰,连 subDataUnavailable 都测不出来。缺席 = 完整,不缺席就说清是哪种截断。 if (title.subScope() != null) { extra.put("subTitleScope", title.subScope()); } // D3:批量删除标题缀「共 N 条」。批量操作物理合并后,主行只显示其中一条的标题—— // 不缀条数,用户看不出这一行代表的是 1 条还是 50 条。N 来自 Task 1 已加的 // VisualLogForm.batchSize(每条事件都带同一个值),审计侧直接读——batchSize 变量已在 // 前面的动作语义覆盖块之前读取(frontendBizData 膨胀防护也要用),这里直接复用。 String recordTitle = baseRecordTitle; if (batchSize != null && batchSize > 1) { extra.put("batchSize", batchSize); if (recordTitle != null && !recordTitle.isEmpty()) { recordTitle = recordTitle + "(共 " + batchSize + " 条)"; } } String bizCode = bizCode(snapshotRow, meta); String bizModule = meta.getBizModule() != null ? meta.getBizModule() : ctx.appCode; if (bizModule.equals(ctx.appCode)) { String visualDevFullName = registry.resolveFullName(modelId); if (visualDevFullName != null) { bizModule = visualDevFullName; } } AuditEventDTO dto = AuditEventDTO.builder() .clientEventId(EVENT_ID_PREFIX + event.getEventId()) // 没有 batchOpId(新增/普通修改)时 operationId 与 clientEventId 仍同值, // 分组结果与改动前逐字节一致;批量删除时两者不同值,靠 operationId 把 // 一批 N 条事件在写入侧合并为一行,clientEventId 继续按来源事件唯一。 .operationId(operationId) .eventTime(ctx.eventTime) .tenantId(ctx.tenantId) .operatorId(ctx.userId) .operatorName(ctx.userName) .appName(SOURCE_APP) .bizModule(bizModule) .ip(ctx.ip) .browser(ctx.browser) .os(ctx.os) .requestUri(ctx.uri) .eventType(AuditConsts.TYPE_DATA_CHANGE) .actionCode(actionCode) // actionLabel(boolean) 本身仍是二分支(新增/修改)。delete 优先级最高、恒显示 // 「删除」——不接受 meta_data 的 bizButtonLabel 覆盖(评审修复轮次 1,理由见 // 前面动作语义覆盖块的 🔴 注释②:业务开发者随手起的列表按钮文案不该冒充删除 // 这个硬事实)。非删除场景才轮到 bizButtonLabel 优先于 actionLabel(create)。 // 不拼模块名,见 actionLabel 的 javadoc。 .actionLabel(delete ? "删除" : (bizButtonLabel != null ? bizButtonLabel : actionLabel(create))) .sourceLayer(0) .targetTable(table) .targetId(dataId) .bizType(meta.getBizType()) .bizCode(bizCode) .fieldDiffs(fieldDiffs) .dataSnapshot(snapshotRow == null ? null : AuditFieldDiff.snapshot(snapshotRow, masked, labels)) .extra(JsonUtil.getObjectToString(extra)) .recordTitle(recordTitle) .entryType(entryType) .entryId(entryId) .entryName(entryName) .build(); auditEventService.saveIdempotent(dto); // ── 签名事件(spec §6)───────────────────────────────────────────────────── // **顺序不可调换**:业务事件先落库,签名事件才写。表单没保存成功 → 没有 MQ 事件 // → 这里根本不会执行 → 签名日志天然不产生。药厂要的「业务失败则签名也不记」 // 由链路结构保证,不靠事后补偿。 // // 每个签名一条,clientEventId 带 -SIGN-<signId> 后缀(多签名场景互不覆盖), // 靠 UNIQUE(client_event_id) 挡广播重复消费。 // 标题/入口/单号继承业务事件——即使单独按 eventType=E_SIGNATURE 查, // 也看得到"签的是哪张单子"。 // 请求顶层与保存后的数据列经常携带同一个 signId。候选必须都保留到动作语义判定, // 但签名事实只能输出一次;请求候选排在最前,因此重复时稳定保留请求来源的展示标签。 Set<String> emittedSignIds = new LinkedHashSet<>(); for (SignCandidate candidate : signCandidates) { AuditSignEvidence evidence = evidences.get(candidate.signId); if (evidence == null) { continue; } if (!candidate.thisOperation) { continue; // 历史签名不在这里重发,已记进 extra.historicalSignRefs } if (!emittedSignIds.add(candidate.signId)) { continue; } Map<String, Object> signExtra = new LinkedHashMap<>(); signExtra.put("signField", candidate.signField); signExtra.put("sourceModelId", modelId); AuditSignMetaData signMetaData = AuditSignMetaData.parse(evidence.metaData()); boolean reviewSignature = signMetaData.isReviewButton(); signExtra.put("signatureRole", reviewSignature ? "REVIEW" : "PRIMARY"); if (evidence.accountName() != null) { signExtra.put("accountName", evidence.accountName()); } if (evidence.opType() != null) { signExtra.put("opType", evidence.opType()); } AuditEventDTO signDto = AuditEventDTO.builder() .clientEventId(operationId + "-SIGN-" + evidence.signId()) .operationId(operationId) // 签名时刻取签名凭证自身的创建时刻,而不是本次表单保存时刻—— // 两者之间隔着用户填表的时间,混用会让时间线错位。 .eventTime(evidence.signedAt() != null ? evidence.signedAt() : ctx.eventTime) .tenantId(ctx.tenantId) .operatorId(evidence.operatorId() != null ? evidence.operatorId() : ctx.userId) .operatorName(ctx.userName) .appName(SOURCE_APP) .bizModule(bizModule) .ip(ctx.ip) .browser(ctx.browser) .os(ctx.os) .requestUri(ctx.uri) .eventType(AuditConsts.TYPE_E_SIGNATURE) // 与 A1-b 历史反查同一组常量,别退回字面量(见 SIGN_ACTION_CODE 的 javadoc) .actionCode(SIGN_ACTION_CODE) .actionLabel(reviewSignature ? "复审签名" : "电子签名") .sourceLayer(SIGN_SOURCE_LAYER) // 与 A1-b 历史反查同一个常量,别退回字面量(见 SIGN_TABLE 的 javadoc) .targetTable(AuditSignEvidenceReader.SIGN_TABLE) .targetId(evidence.signId()) .bizType(meta.getBizType()) .bizCode(bizCode) .reason(evidence.note()) .extra(JsonUtil.getObjectToString(signExtra)) .recordTitle(baseRecordTitle) .entryType(entryType) .entryId(entryId) .entryName(entryName) .build(); auditEventService.saveIdempotent(signDto); } } /** READ 不保存查询参数、返回数据或字段快照,只记录访问事实。 */ private void recordRead(ProjectEventInstance event, Map<String, Object> form, AuditFormRegistry.FormMeta meta, String table, String dataId, AuditCtx ctx, Map<String, Object> extra, String entryType, String entryId, String entryName) { String readScope = str(form.get("readScope")); if (readScope == null) { readScope = dataId == null ? "LIST" : "DETAIL"; } extra.put("readScope", readScope); String eventId = EVENT_ID_PREFIX + event.getEventId(); AuditEventDTO dto = AuditEventDTO.builder() .clientEventId(eventId) .operationId(eventId) .eventTime(ctx.eventTime) .tenantId(ctx.tenantId) .operatorId(ctx.userId) .operatorName(ctx.userName) .appName(SOURCE_APP) .bizModule(meta.getBizModule() != null ? meta.getBizModule() : ctx.appCode) .ip(ctx.ip) .browser(ctx.browser) .os(ctx.os) .requestUri(ctx.uri) .eventType(AuditConsts.TYPE_BIZ_ACTION) .actionCode(AuditConsts.ACT_READ) .actionLabel("DETAIL".equalsIgnoreCase(readScope) ? "查看详情" : "查询列表") .sourceLayer(0) .targetTable(table) .targetId(dataId) .bizType(meta.getBizType()) .extra(JsonUtil.getObjectToString(extra)) .entryType(entryType) .entryId(entryId) .entryName(entryName) .build(); auditEventService.saveIdempotent(dto); } /** * 这份签名候选是不是「本次操作」签的。三条互斥的分支,顺序即优先级: * * <ol> * <li><b>请求携带的恒为真</b>({@link SignCandidate#fromRequest}):它按定义就是本次操作的 * 凭证,不需要、也不能拿历史反查去否定它;</li> * <li><b>删除场景的数据列恒为假</b>:被删数据里的签名列存的是历史,见下方长注释(P1b);</li> * <li><b>非删除场景的数据列查 {@code recordedSignIds}</b>:这个 signId 已经被记过一条 * 签名事件(且不是本次 operation 记的)⇒ 历史。见 {@link #recordedSignIds}。</li> * </ol> * * <p><b>非删除场景为什么不能像原先那样恒为真</b>(2026-08-03 Codex 终审 High③):实测 * 一次复核保存会生成两条签名(确定 + 复核),分别落进 {@code biz_sign} / {@code fuhe_sign}; * {@code biz_sign} 每次保存都刷新成新的确定签名,而 {@code fuhe_sign} 只在复核那次写—— * 之后每次普通保存都会把它重发一条签名事件,{@code event_time} 停在上次复核那一刻却挂在 * 这次保存的 {@code operation_id} 下。就是 L078 骂的时间倒流,换到了更新路径上。 * * <p><b>删除场景只认请求携带的那一份</b>({@code fromRequest},Task 1 平台补丁 * 从请求体的 {@code biz_sign} 传下来)。被删数据里的 {@code biz_sign}/{@code fuhe_sign} * 存的是这条记录<b>创建/编辑时</b>的历史签名,它们各自早在当时那次操作里记过一条签名事件了; * 在删除事件上重发一遍会同时踩三个坑(2026-08-02 端到端实测,audit_events id 39/40/43-46): * <ol> * <li><b>时间倒流</b>:签名事件的 {@code event_time} 取签名凭证自身的创建时刻,于是 * 15:08 的一次删除产出了四条 {@code event_time} 停在 <b>7-31</b> 的「签名」事件, * 挂在这次删除的 {@code operation_id} 下——写入侧合并成一行后,看起来就是 * 「这次删除时签了四个名」,而事实是一个都没签在这次删除上;</li> * <li><b>批量放大</b>:批量删 N 条 × 每条 M 个历史签名列 = N×M 条签名事件,实测删 2 条 * 产出 4 条,本该只有用户真正签的那 1 条;</li> * <li><b>动作语义取错</b>:动作语义只认第一份证据,历史签名的 {@code meta_data} 会把 * 当时那次操作的 {@code biz_button}(实测「确定」)和 {@code biz_data} 当成这次删除的 * 上下文写进 {@code extra}。</li> * </ol> * * <p><b>为什么不干脆把它们从 {@code signCandidates} 里剔掉</b>:这个列表还兼着 * 「哪些字段不许进标题」(见 {@code titleExcluded})。命名规则命中的签名列有 * {@code masked} 兜底,但 D4 的配置来源({@code confirmBtnConfig.biz_sign_field})允许 * 任意字段名,剔掉就等于让一个签名 id 明文进标题——审计表 append-only 存十年,洗不掉。 * 所以只在「产出事件」这一侧收窄。 * * <p><b>入参是 {@code fromRequest} 布尔量而不是字段名</b>(2026-08-05 Codex 复审 High①): * 拿字段名与 {@link #REQUEST_SIGN_FIELD} 比对,等于让一个可碰撞的字符串来决定 * 「这份证据够不够格」——业务表真有同名列时判定就被劫持了。来源跟着候选走。 */ private static boolean signedByThisOperation(boolean delete, boolean fromRequest, String signId, Set<String> recordedSignIds) { if (fromRequest) { // 🔴 **审计层不做授权判定**(2026-08-05 定案)。这里一度收过一个 authorized 参数, // 由「签名人 == 操作人」推出——那个判据不成立:verify_user 的多人会签本就要求 // 两者不同,而「合法会签」与「盗用 signId」在数据上不可区分(见授权循环处长注释)。 // 请求携带的凭证按定义就是本次操作提交的凭证,它是不是**该**被提交, // 由平台层同步校验回答,不是这里。不一致的事实已在 extra.signerDiffersFromOperator // 里如实标注——照出来,不拦下来。 // (凭证压根不存在/跨租户的那一类是**可判定**的,已在候选剔除那一步拦掉并留痕。) return true; } if (delete) { return false; } return recordedSignIds == null || !recordedSignIds.contains(signId); } /** * 这批 signId 里哪些**已经被记过签名事件**(= 历史)。 * * <p><b>判据为什么取自 audit_events</b>(用户 2026-08-03 拍板 D7):候选是不是「本次刚签的」, * 唯一不可篡改的依据就是「审计表里记过没有」。{@code lims_sign.meta_data}(含 * {@code biz_data})整块由前端 POST、后端原样落库、零校验,拿它判定等于把「凭空产出一条 * 签名事件」的口子交给前端报文;而 {@code data_snapshot} 里签名列是 {@code ***}, * 拿 baseline 比对「签名列变没变」也永远比不出来。 * * <p><b>为什么排除本次 operation_id</b>:MQ 重投递会让同一条事件被消费两次,批量场景下 * N 条事件还共享同一个 {@code batchOpId}。不排除的话,第一条事件写下的签名事件会让后面 * 那些「同一次操作」的事件把它当成历史——判定必须对「同一次操作」保持稳定。 * * <p><b>自愈</b>:某条签名当年因 MQ/落库失败没记上,下一次操作会把它当「本次」补一条。 * 补上的那条 {@code event_time} 仍是签名凭证自身的时刻,但 {@code operation_id} 属于补记 * 的那次操作——这是可见的代价,好过永远查不到这条签名。 * * @return 已记过的 signId 集合;<b>查询失败返回 {@code null}</b>——调用方据此走 D8 的 * fail-closed(全判历史 + {@code extra.signHistoryDegraded}),不要当成空集。 */ private Set<String> recordedSignIds(Collection<String> signIds, String operationId, String tenantId) { try { List<String> rows = auditEventMapper.selectRecordedSignIds( AuditSignEvidenceReader.SIGN_TABLE, AuditConsts.TYPE_E_SIGNATURE, SIGN_ACTION_CODE, SIGN_SOURCE_LAYER, signIds, operationId, tenantId); Set<String> recorded = new LinkedHashSet<>(); if (rows != null) { for (String id : rows) { if (id != null && !id.trim().isEmpty()) { recorded.add(id.trim()); } } } return recorded; } catch (Throwable t) { log.warn("[AUDIT-L0] 历史签名反查失败,本次按 fail-closed 全判历史 count={} err={}", signIds.size(), t.toString()); return null; } } /** * 一条历史签名的可追引用(A2,Codex 终审 Medium⑤)。 * * <p><b>刻意不带本次的 operation/eventTime</b>:这是历史事实的引用,不是本次操作的证据 * (L078 的规矩)。{@code signedAtMillis} 取签名凭证自身的创建时刻(epoch 毫秒), * 缺席表示凭证行读不到创建时刻,不是「没签过」。 */ private static Map<String, Object> historicalSignRef(String signField, String signId, AuditSignEvidence evidence) { Map<String, Object> ref = new LinkedHashMap<>(); ref.put("signField", signField); ref.put("signId", signId); if (evidence != null && evidence.signedAt() != null) { ref.put("signedAtMillis", evidence.signedAt().getTime()); } return ref; } /** * CREATE 时应当以「已知为空」显式写进 baseline 的字段(小写、稳定顺序)。 * * <p><b>要解决的事实</b>(2026-07-30 联调报障,本机实测复现):在线表单保存时,平台会把值为空 * 的字段整个从 {@code newData} 里剥掉——{@code lims_qingyandan} 的 CREATE 事件快照只有 21 键, * 而已发布表单模型有 23 个字段,少的正是建单时留空的那几个。结果是这些字段**第一次被填写** * 时,UPDATE 分支的两侧交集({@code onlyFields} 互取对方 keySet)把它们整个排除, * {@code field_diffs} 漏记一次真实变更;它们进过一次快照之后反而正常了。 * * <p><b>补的是"已知为空",不是"猜一个旧值"</b>。判据来自 * {@link AuditFormRegistry#resolveBaselineFields}:该字段属于**这张已发布表单**、且确是主表 * 一列。建单提交的就是整份表单,没被携带即等于留空——这是有据可依的推断,而不是对 * 数据库写入前快照里的未知字段瞎填。该补齐只用于 CREATE 的操作后快照,UPDATE 差异 * 始终直接使用数据库写入前后的实际查询结果。 * * <h3>四类字段不补,每一类都有代价对应</h3> * <ul> * <li><b>本次已携带</b>:{@code eventRow} 里有的值优先,补了只会造出大小写重复键。</li> * <li><b>平台技术列</b>:{@code forDiff} 本就要剔,补进来纯噪音。</li> * <li><b>脱敏列 / 签名控件</b>:层 0 判定不了它们的变更(快照里存的是占位符), * 补一个"已知为空"等于宣称"初始密码是第一次设置"——这正是 B5 评审 M-3 不给的那半格。</li> * <li><b>子表字段</b>:baseline 只存主表快照,子表明细本层给不出(见 * {@link AuditFieldDiff.FieldMeta} 类注释),补空会把"判不了"伪装成"本来就没有"。</li> * </ul> */ static List<String> knownEmptyFields(Map<String, Object> eventRow, Set<String> formFields, Set<String> masked, Map<String, AuditFieldDiff.FieldMeta> labels) { if (formFields == null || formFields.isEmpty()) { return Collections.emptyList(); } Set<String> carried = new java.util.HashSet<>(); if (eventRow != null) { for (String key : eventRow.keySet()) { if (key != null) { carried.add(key.toLowerCase(Locale.ROOT)); } } } List<String> known = new ArrayList<>(); for (String field : formFields) { if (field == null) { continue; } String lower = field.toLowerCase(Locale.ROOT); if (carried.contains(lower) || isTechColumn(lower) || isMasked(lower, masked) || isCredentialReference(lower)) { continue; } AuditFieldDiff.FieldMeta fm = labels == null ? null : labels.get(lower); if (AuditFieldDiff.isCredentialMetadata(fm)) { continue; } if (fm != null && AuditFieldDiff.JNPF_KEY_TABLE.equals(fm.jnpfKey())) { continue; } known.add(lower); } return known; } /** * 把「已知为空」显式写进 CREATE 快照;事件明确携带的值恒优先。 * * <p>用空串而不是 {@code null}:{@code data_snapshot} 落库前会过 * {@link AuditFieldDiff#snapshot}({@code stringify(null)} 同样得空串),两者存进去一模一样, * 但空串让人直接读 JSON 时看得出"这个字段当时就是空的",而不是像个漏写的键。 * 比较侧不受影响:{@link AuditFieldDiff#valuesEqual} 把 null 与空串一律视作空。 */ static Map<String, Object> withKnownEmpty(Map<String, Object> eventRow, List<String> knownEmpty) { if (knownEmpty == null || knownEmpty.isEmpty()) { return eventRow; } Map<String, Object> row = new LinkedHashMap<>(); for (String field : knownEmpty) { row.put(field, ""); } if (eventRow != null) { row.putAll(eventRow); } return row; } /** * 从事件的 {@code listLog} 里提取「字段名 → 展示元数据」。 * * <p>这是层 0 相对层 1 白捡的一块元数据:listLog 由 visualdev 按**在线表单模型**生成, * 随表单发布自动更新,不需要任何手写映射表({@code AuditFieldDiff} 类注释里说的 * 「正确的来源是元数据」)。 * * <p><b>只取元数据、不取它的 oldData/newData</b>——那两个是 visualdev 用不可信的 * oldData 算出来的**值**(L003),值一律以 PG baseline 为准。 * 取的三个({@code fieldName}/{@code jnpfKey}/{@code nameModified})都是**字段的属性** * 而不是这次改了什么,故不受 L003 约束。 * * <p>{@code jnpfKey}/{@code nameModified} 是 D1 补的(见 {@link AuditFieldDiff.FieldMeta}): * 它们各自对应前端一条渲染分支,缺了 {@code nameModified} 会把字典字段的存储原值当明文渲染。 */ private Map<String, AuditFieldDiff.FieldMeta> fieldMeta(Object listLog) { if (!(listLog instanceof List)) { return null; } Map<String, AuditFieldDiff.FieldMeta> meta = new LinkedHashMap<>(); for (Object item : (List<?>) listLog) { Map<String, Object> row = asMap(item); if (row == null) { continue; } String field = str(row.get("field")); if (field == null) { continue; } // nameModified 在平台 VisualLogModel 上是 boolean 基本类型,跨 JVM 反序列化后 // 按 Map 取(L004)可能是 Boolean 也可能是字符串 "true",两种都认;认不出就传 null // (= 该键不出现 = 前端走明文分支),**不猜默认值**——猜错的方向是「把该藏的值露出来」。 Object nm = row.get("nameModified"); Boolean nameModified = null; if (nm instanceof Boolean) { nameModified = (Boolean) nm; } else if (nm != null) { String s = String.valueOf(nm).trim(); if ("true".equalsIgnoreCase(s)) { nameModified = Boolean.TRUE; } else if ("false".equalsIgnoreCase(s)) { nameModified = Boolean.FALSE; } } meta.put(field, new AuditFieldDiff.FieldMeta(str(row.get("fieldName")), str(row.get("jnpfKey")), nameModified)); } return meta.isEmpty() ? null : meta; } /** 从平台原生 listLog 中提取已由数据库前后快照计算完成的子表行级变化。 */ @SuppressWarnings("unchecked") static List<Map<String, Object>> childDiffs(Object listLog) { if (!(listLog instanceof List)) { return Collections.emptyList(); } List<Map<String, Object>> result = new ArrayList<>(); for (Object raw : (List<?>) listLog) { if (!(raw instanceof Map)) { continue; } Map<String, Object> row = (Map<String, Object>) raw; if (str(row.get("field")) == null || !AuditFieldDiff.JNPF_KEY_TABLE.equals(str(row.get("jnpfKey"))) || !(row.get("chidData") instanceof List) || ((List<?>) row.get("chidData")).isEmpty() || !(row.get("chidField") instanceof List) || ((List<?>) row.get("chidField")).isEmpty()) { continue; } Map<String, Object> child = new LinkedHashMap<>(); child.put("field", row.get("field")); child.put("fieldName", row.get("fieldName")); child.put("jnpfKey", AuditFieldDiff.JNPF_KEY_TABLE); child.put("componentType", AuditFieldDiff.JNPF_KEY_TABLE); child.put("targetTable", row.get("targetTable")); child.put("type", row.get("type") == null ? 1 : row.get("type")); child.put("nameModified", false); child.put("chidData", row.get("chidData")); child.put("chidField", row.get("chidField")); result.add(child); } return result; } @SuppressWarnings("unchecked") static String appendChildDiffs(String fieldDiffs, List<Map<String, Object>> childDiffs) { if (childDiffs == null || childDiffs.isEmpty()) { return fieldDiffs; } List<Object> merged = new ArrayList<>(); if (fieldDiffs != null && !fieldDiffs.trim().isEmpty()) { try { Object parsed = JSON.parse(fieldDiffs); if (parsed instanceof List) { merged.addAll((List<Object>) parsed); } } catch (Throwable ignored) { // 普通字段 JSON 损坏时仍保留可信的子表变化。 } } merged.addAll(childDiffs); return JSON.toJSONString(merged); } static Map<String, Object> withoutFields(Map<String, Object> row, Set<String> fields) { if (row == null || row.isEmpty() || fields == null || fields.isEmpty()) { return row; } Set<String> normalized = new LinkedHashSet<>(); for (String field : fields) { if (field != null) { normalized.add(field.toLowerCase(Locale.ROOT)); } } Map<String, Object> result = new LinkedHashMap<>(); for (Map.Entry<String, Object> entry : row.entrySet()) { if (entry.getKey() == null || !normalized.contains(entry.getKey().toLowerCase(Locale.ROOT))) { result.put(entry.getKey(), entry.getValue()); } } return result; } /** * 本次变更里命中「子表」控件的字段名。 * * <p>标准在线表单事件通过 listLog 携带平台按数据库前后数据算出的 {@code chidData}; * 只有补丁上线前的旧事件或非标准发布方缺少 listLog 时,层 0 才无法还原子表行级明细。 * 此时把「判不了」如实写进 extra,避免把空明细误解成「子表没有修改」。 */ private List<String> subTableFields(Map<String, AuditFieldDiff.FieldMeta> meta) { if (meta == null) { return java.util.Collections.emptyList(); } List<String> hit = new ArrayList<>(); for (Map.Entry<String, AuditFieldDiff.FieldMeta> e : meta.entrySet()) { if (AuditFieldDiff.JNPF_KEY_TABLE.equals(e.getValue().jnpfKey())) { hit.add(e.getKey()); } } return hit; } /** * 业务单号。<b>取不到不丢事件</b>(与旧 listener 的关键差异,见类注释)。 * 未配置取值字段时直接留空——大量表单本就没有业务单号这一说。 */ private String bizCode(Map<String, Object> newData, AuditFormRegistry.FormMeta meta) { String field = meta.getBizCodeField(); if (field == null || field.isEmpty() || newData == null) { return null; } return str(newData.get(field)); } /** * 层 0 的动作名只给<b>纯动作词</b>。 * * <p>模块语义已经由 {@code entry_name}(用户点进来的菜单)承担,再把它拼进动作名 * 就成了「LIMS-请验单 新建」这种既不是模块也不是动作的混合物。 * 层 2 事件(列表按钮)仍用各自的 {@code actionLabel}——那里的动作词是业务自定义的。 */ private String actionLabel(boolean create) { return create ? "新增" : "修改"; } /** * diff 的输入预处理:剔除平台技术列。脱敏列保留到比较阶段,确认变化后再隐藏值。 * * <p><b>为什么合成一个方法而不是在调用处各套两层</b>:UPDATE 路径的 beforeRow 与 afterRow * 必须经**完全相同**的剔除规则,否则单边少剔一列就会产出一条假 diff(该列在一侧存在、 * 另一侧不存在)。把"两侧同规则"这件事交给代码结构保证,而不是靠调用处的注释约定 * ——约定会在下一次改动里悄悄失效(同 L066)。 * * <p><b>只用于 diff,不用于 {@code data_snapshot}</b>:快照保留完整行(含技术列), * 剔除只发生在“要不要给人看这条变更”这一层。 * * <p>{@code static} + 包内可见是为了让 {@code AuditL0PartialSnapshotProbe} 直接驱动它: * “技术列被剔、脱敏列只隐藏值”这条口径的错法是静默地多出、少掉或泄露一列。 */ static Map<String, Object> forDiff(Map<String, Object> row) { return withoutTechColumns(row); } /** 比较同一次数据库写入前后的两份快照;单侧缺失字段按空值参与比较。 */ static String databaseBoundaryDiff(Map<String, Object> beforeRow, Map<String, Object> afterRow, Set<String> masked, Map<String, AuditFieldDiff.FieldMeta> labels, Set<String> childFields) { Map<String, Object> before = withoutFields(forDiff(beforeRow), childFields); Map<String, Object> after = withoutFields(forDiff(afterRow), childFields); return AuditFieldDiff.diff(before, after, masked, labels); } /** * 剔除平台技术列后的副本。 * * <p>判据默认是**前缀 {@code f_}**,依据是仓库 CLAUDE.md「Conventions」里的业务规范: * 业务字段禁止以 {@code f_} 开头,该前缀为平台技术列保留。全库实查坐实(2026-07-27): * lims 的 65 张表里带该前缀的列共 15 个,全部是平台列({@code f_id} / {@code f_tenant_id} / * {@code f_version} / {@code f_delete_mark} / {@code f_creator_time} / {@code f_flow_state} / * {@code f_foreign_id} 等),无一例业务字段。 * 其中创建人、创建时间、最后修改人、最后修改时间是审计需要展示的操作责任信息,作为明确例外保留。 * * <p>顺带剔除裸 {@code id}:前端提交时把主键同时放在 {@code f_id} 和 {@code id} 两个键上, * 后者是别名、语义重复。 * * <p><b>为什么要剔</b>:CREATE 路径的 before 为 null,于是**每一列都算新增**, * 技术列会混进 field_diffs 变成噪音——{@code f_id} 与 {@code target_id} 列完全重复、 * {@code f_version} 恒为 0。旧 lims listener 按 {@code @FieldLabel} 只记业务字段, * 本方法把这个口径补回来(2026-07-27,用户拍板"做干净点")。 */ private static Map<String, Object> withoutTechColumns(Map<String, Object> row) { if (row == null || row.isEmpty()) { return row; } Map<String, Object> out = new LinkedHashMap<>(row.size()); for (Map.Entry<String, Object> entry : row.entrySet()) { if (!isTechColumn(entry.getKey())) { out.put(entry.getKey(), entry.getValue()); } } return out; } /** 与 {@link #isMasked} 同口径按小写比,避免大小写混写的列名漏判。 */ private static boolean isTechColumn(String column) { if (column == null) { return false; } String lower = column.toLowerCase(Locale.ROOT); return (lower.startsWith(TECH_COLUMN_PREFIX) && !AUDITED_PLATFORM_COLUMNS.contains(lower)) || "id".equals(lower); } /** 与 {@code AuditFieldDiff} 同口径:脱敏清单是小写的,列名按小写比。 */ private static boolean isMasked(String column, Set<String> masked) { return column != null && masked.contains(column.toLowerCase(Locale.ROOT)); } /** * 签名凭证引用属于内建敏感字段,不依赖 Nacos 的 maskedFields 是否漏配。 */ private static Set<String> effectiveMasked(Set<String> configured, Map<String, Object> row, Map<String, AuditFieldDiff.FieldMeta> metadata) { Set<String> result = new java.util.LinkedHashSet<>(); if (configured != null) { result.addAll(configured); } if (row != null) { for (String field : row.keySet()) { if (isCredentialReference(field)) { result.add(field.toLowerCase(Locale.ROOT)); } } } if (metadata != null) { for (Map.Entry<String, AuditFieldDiff.FieldMeta> entry : metadata.entrySet()) { if (entry.getKey() != null && AuditFieldDiff.isCredentialMetadata(entry.getValue())) { result.add(entry.getKey().toLowerCase(Locale.ROOT)); } } } return result; } public static boolean isCredentialReference(String field) { if (field == null) { return false; } String lower = field.trim().toLowerCase(Locale.ROOT); return "biz_sign".equals(lower) || "bizsign".equals(lower) || "sign_id".equals(lower) || "signid".equals(lower) || lower.endsWith("_sign") || field.endsWith("Sign") || field.endsWith("SignId"); } /** 按小写字段名从事件行取值,兼容大小写混写的列名。 */ private static Object lookup(Map<String, Object> row, String lowerField) { if (row == null || lowerField == null) { return null; } for (Map.Entry<String, Object> entry : row.entrySet()) { if (entry.getKey() != null && lowerField.equals(entry.getKey().toLowerCase(Locale.ROOT))) { return entry.getValue(); } } return null; } /** * 把事件跨 JVM 序列化后的值统一成展示用字符串。写入前后的两侧应用完全相同的规则, * 避免 JSON 数值、日期等运行时类型差异产生假 diff。用 * {@link AuditFieldDiff#stringify} 保证最终展示值与层 1 的字段差异契约一致。 */ private Map<String, Object> stringifyValues(Map<String, Object> row) { if (row == null) { return null; } Map<String, Object> out = new LinkedHashMap<>(row.size()); for (Map.Entry<String, Object> entry : row.entrySet()) { out.put(entry.getKey(), AuditFieldDiff.stringify(entry.getValue())); } return out; } /** * 事件 source 统一成 Map(L004)。跨 JVM 来的是 {@code JSONObject}(本身就是 Map); * 同 JVM 本地事件是 {@code VisualLogForm},走 {@code JSON.toJSON} 转过去。 */ @SuppressWarnings("unchecked") private Map<String, Object> asMap(Object source) { if (source == null) { return null; } if (source instanceof Map) { return (Map<String, Object>) source; } Object json = JSON.toJSON(source); return json instanceof Map ? (Map<String, Object>) json : null; } private static String str(Object value) { if (value == null) { return null; } String s = value.toString().trim(); return s.isEmpty() ? null : s; } /** * 客户端显示值只能补充标题解析,不能决定标题字段。这里按发布表单主字段白名单、 * 脱敏/签名排除集和固定长度上限收口,非法或多余字段直接丢弃。 */ private static Map<String, String> frontendDisplayValues(Object raw, AuditTitleSpec spec, Set<String> excludedFields) { if (!(raw instanceof Collection) || spec == null || spec.mainFields().isEmpty()) { return Collections.emptyMap(); } Set<String> allowed = new LinkedHashSet<String>(spec.mainFields()); Map<String, String> result = new LinkedHashMap<String, String>(); int scanned = 0; for (Object item : (Collection<?>) raw) { if (++scanned > 50) { break; } if (!(item instanceof Map)) { continue; } Map<?, ?> value = (Map<?, ?>) item; String field = str(value.get("field")); String display = str(value.get("displayValue")); if (field == null || display == null) { continue; } String normalized = field.toLowerCase(Locale.ROOT); if (!allowed.contains(normalized) || containsIgnoreCase(excludedFields, normalized)) { continue; } display = display.replace('\r', ' ').replace('\n', ' ').trim(); if (!display.isEmpty()) { result.put(normalized, display.length() <= 400 ? display : display.substring(0, 400)); } } return result; } private static boolean containsIgnoreCase(Set<String> values, String expected) { if (values == null) { return false; } for (String value : values) { if (value != null && expected.equals(value.toLowerCase(Locale.ROOT))) { return true; } } return false; } /** * 一份签名候选:<b>值</b>({@code signId})、<b>来源</b>({@code fromRequest})、 * <b>判定结果</b>({@code thisOperation})三者绑在一起走完全程。 * * <p><b>为什么不是 {@code Map<String,String> signFieldToId}</b>(2026-08-05 Codex 复审 High①): * 那个结构把「来源」编码进了 map 的 <b>key</b>(伪字段名 {@link #REQUEST_SIGN_FIELD}), * 于是一个可碰撞的字符串同时承担了标识与授权两件事——业务表只要有同名列,数据列证据 * 就会被当成请求来源改写 {@code action_code},且 {@code putAll} 会用数据列的值覆盖请求值。 * 判定结果同理:原先记在一个「字段名集合」里,也是按名字装。名字不是身份, * 来源与判定都必须是候选自己的属性(L081:来源是属性,不能从内容反推)。 * * <p>{@code signField} 只剩一个用途:落进 {@code signExtra.signField} / 历史引用给人看, * 以及当「哪些列不许进标题」的名单。任何判定都不许再读它。 */ private static final class SignCandidate { /** 数据列名;请求携带的那份是展示标签 {@link #REQUEST_SIGN_FIELD}。 */ private final String signField; private final String signId; /** 是否来自请求顶层的 {@code bizSign}(Task 1 平台补丁)——唯一够格改写动作语义的来源。 */ private final boolean fromRequest; /** 是不是「本次操作」签的,由 {@link #signedByThisOperation} 一处判定、下游共用。 */ private boolean thisOperation; // 【曾经有过一个 authReject 字段,2026-08-05 当天撤掉了】——它给「签名人≠操作人」 // 当拒收判据,而那个判据不成立(verify_user 会签合法且不可区分,见授权循环处的长注释)。 // 撤掉而不是留着不用:死字段会让下一个人以为审计层做了授权判定,而它没有、也做不了。 private SignCandidate(String signField, String signId, boolean fromRequest) { this.signField = signField; this.signId = signId; this.fromRequest = fromRequest; } } /** * AUDIT-P1 注入的请求上下文(平台补丁 #5,登记于 tasks/jnpf-platform-patches.md)。 * * <p>发布方把它序列化成 JSON **字符串**挂在 form.auditCtx 上({@code ProjectEventBuilder} * 没有任何 extras 通道,只能挂在 form 自己的新增字段里)。取不到时走降级路径并**留痕**—— * 静默降级等于「审计有洞而无人知道」。 */ private static final class AuditCtx { private String userId = UNKNOWN; private String userName; private String tenantId = AuditApiConsts.DEFAULT_TENANT_ID; private String ip; private String browser; private String os; private String uri; /** 前端路由识别出的应用编码与运行范围(FRONTEND/BACKEND/CONSOLE)。 */ private String appCode; private String appScope; /** 用户点进来的菜单 id(AUDIT-P1 从 {@code Jnpf-Menu-Id} 请求头透传)。没带就是 null。 */ private String menuId; /** 请求线程固化的 X-Audit-Biz-Sign;用于跨 HTTP/MQ 链路关联同一次前端操作。 */ private String auditBizSign; private Date eventTime; /** * @param eventTenantId {@code ProjectEvent.tenantId}——事件框架原生的租户通道。 * 计划 §Task C1 与 spec §5.4 指定的就是它;auditCtx 是 AUDIT-P1 * 后来加的、信息更全的通道,故按「auditCtx → 事件原生 → 哨兵」回落。 * 单租户下两条都是空串、结果都回落 "0",差别只在多租户 + auditCtx * 缺席(补丁未部署 / 在途旧消息)时——那时事件原生通道能救回真租户, * 少了它就会把真租户写成 "0"(评审 I-4)。 */ static AuditCtx parse(Object raw, String eventTenantId, Map<String, Object> extra) { AuditCtx ctx = new AuditCtx(); Map<String, Object> src = null; try { if (raw instanceof Map) { src = castMap(raw); } else if (raw != null) { String json = raw.toString().trim(); if (!json.isEmpty()) { src = JsonUtil.stringToMap(json); } } } catch (Throwable t) { src = null; // 解析不了就按「没有上下文」处理,下面统一走降级 } if (src == null || src.isEmpty()) { // 补丁未部署 / 在途的旧消息 / 非 Web 线程触发的保存(定时任务等) extra.put("whoSource", "degraded"); extra.put("timeSource", "degraded"); ctx.eventTime = new Date(); ctx.applyTenant(null, eventTenantId); return ctx; } String userId = str(src.get("userId")); if (userId == null) { extra.put("whoSource", "degraded"); } else { ctx.userId = userId; } ctx.userName = str(src.get("userName")); ctx.applyTenant(str(src.get("tenantId")), eventTenantId); ctx.ip = str(src.get("ip")); ctx.uri = str(src.get("uri")); ctx.appCode = str(src.get("appCode")); ctx.appScope = str(src.get("appScope")); ctx.menuId = str(src.get("menuId")); ctx.auditBizSign = str(src.get("auditBizSign")); ctx.parseUserAgent(str(src.get("userAgent"))); // 操作时刻取自发布方(请求线程),而不是这里的消费时刻——两者之间隔着 MQ 投递, // 积压时能差出好几分钟,那会让审计时间线整体错位。 Object opTime = src.get("opTime"); if (opTime instanceof Number) { ctx.eventTime = new Date(((Number) opTime).longValue()); } else { extra.put("timeSource", "degraded"); ctx.eventTime = new Date(); } return ctx; } /** 租户回落链:auditCtx.tenantId → ProjectEvent.tenantId → 哨兵(见 {@link #parse} 注释)。 */ private void applyTenant(String fromAuditCtx, String fromEvent) { if (fromAuditCtx != null) { this.tenantId = fromAuditCtx; } else if (str(fromEvent) != null) { this.tenantId = str(fromEvent); } // 两者都空白时保持字段初始值 AuditApiConsts.DEFAULT_TENANT_ID } private void parseUserAgent(String ua) { if (ua == null) { return; } try { UserAgent agent = UserAgentUtil.parse(ua); if (agent != null) { this.browser = join(agent.getBrowser().getName(), agent.getVersion()); this.os = join(agent.getPlatform().getName(), agent.getOsVersion()); } } catch (Throwable ignore) { // UA 解析纯属锦上添花,失败不影响事件 } } @SuppressWarnings("unchecked") private static Map<String, Object> castMap(Object raw) { return (Map<String, Object>) raw; } private static String join(String a, String b) { String x = a == null ? "" : a.trim(); String y = b == null ? "" : b.trim(); String r = (x + " " + y).trim(); return r.isEmpty() ? null : r; } } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/sign/AuditSignEvidence.java
New file @@ -0,0 +1,61 @@ package jnpf.bizcommon.audit.service.sign; import java.util.Date; /** * 一次电子签名的事实(读自签名凭证表)。**不含凭证本身可用于再次消费的任何内容**—— * {@code signId} 只作为幂等键与 targetId,密码/凭证从不进入本对象。 */ public final class AuditSignEvidence { private final String signId; private final String accountName; private final String operatorId; private final String note; private final String opType; private final Date signedAt; private final String metaData; public AuditSignEvidence(String signId, String accountName, String operatorId, String note, String opType, Date signedAt, String metaData) { this.signId = signId; this.accountName = accountName; this.operatorId = operatorId; this.note = note; this.opType = opType; this.signedAt = signedAt == null ? null : new Date(signedAt.getTime()); this.metaData = metaData; } public String signId() { return signId; } /** 签名账号(lims_sign.account_name)。 */ public String accountName() { return accountName; } /** 签名人用户 id(lims_sign.f_creator_user_id)。 */ public String operatorId() { return operatorId; } /** 签名时填写的原因(lims_sign.note)→ 落 audit_events.reason。 */ public String note() { return note; } public String opType() { return opType; } public Date signedAt() { return signedAt == null ? null : new Date(signedAt.getTime()); } /** 签名时前端一并提交的业务元数据(lims_sign.meta_data,原样 JSON)。见 {@link AuditSignMetaData}。 */ public String metaData() { return metaData; } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/sign/AuditSignEvidenceReader.java
New file @@ -0,0 +1,200 @@ package jnpf.bizcommon.audit.service.sign; import jnpf.audit.diff.AuditFieldDiff; import jnpf.bizcommon.audit.service.listener.AuditVisualLogListener; import jnpf.bizcommon.audit.service.title.AuditTitleSpec; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** * 读取电子签名凭证事实。 * * <p><b>为什么硬编码 {@code lims_sign} 而不做成配置</b>:签名将整体挪进通用服务 * (规划中的 {@code jnpf-biz-common} 之类),配置化是为一个已计划废弃的中间态买单。 * 所以把耦合<b>收在本类一处</b>:对外只有 {@link #read},届时换本类实现即可,调用方零改动。 * * <p><b>批量读,不逐个查</b>:与 {@code AuditFieldValueResolver} 的 SPI 约定同口径—— * 层 0 跑在 MQ 消费线程上,逐 ID 查库会把消费速率拖垮。 */ @Slf4j @Component public class AuditSignEvidenceReader { /** * 签名凭证表。挪到通用服务时改这里(连同下面的列名)。 * * <p><b>public 是刻意的</b>:签名事件的 {@code target_table} 写的是它,而 A1-b 的历史反查 * ({@code selectRecordedSignIds})要按同一个值去查——两处分别写一份字面量,改表名时 * 漏掉一处就会让反查恒查不到、历史签名重新被当成本次签的,而且不报任何错。 */ public static final String SIGN_TABLE = "lims_sign"; /** 单次 IN 的上限。一份表单上的签名字段个位数,超了说明判定跑偏,宁可少查也不打爆库。 */ private static final int MAX_IDS = 20; @Autowired private JdbcTemplate jdbcTemplate; /** * 本次事件里「可能是签名字段」的候选(小写、去重、稳定顺序)。 * * <p>三条来源,按优先级排列——{@code ordered} 是 {@link LinkedHashSet}(保序), * {@link #MAX_IDS} 顶格截断时先保住排在前面的: * <ol> * <li>{@code configuredFields}(D4 权威来源,见 * {@link jnpf.bizcommon.audit.service.AuditFormRegistry#resolveSignFields}):表单配置 * {@code confirmBtnConfig}/{@code reviewBtnConfig} 里显式指定的 {@code biz_sign_field}。 * 多人签名场景下字段名可以是任意的,必须<b>最先</b>放进候选集,否则会被后两条来源 * 挤到 20 上限之外;</li> * <li>命名规则命中 {@code isCredentialReference} 的字段——恒为候选,不需要勾选, * 这批字段本就已被强制脱敏(命名规则+撞库兜底,覆盖配置没提到的字段);</li> * <li>表单上勾了 {@code biz_log_enabled} 的主表字段——是不是签名由 {@link #read} * 查表定,本方法只负责挑出"值得去查一下"的那些。</li> * </ol> * * <p><b>值为空的一律不进候选</b>:空值查签名表纯属浪费,还会把空串塞进 IN 参数。 */ public static List<String> candidateFields(Map<String, Object> row, AuditTitleSpec spec, List<String> configuredFields) { if (row == null || row.isEmpty()) { return Collections.emptyList(); } Set<String> ordered = new LinkedHashSet<String>(); if (configuredFields != null) { for (String field : configuredFields) { if (field == null) { continue; } String lowerField = field.toLowerCase(Locale.ROOT); if (hasValue(valueOf(row, lowerField))) { ordered.add(lowerField); } } } for (Map.Entry<String, Object> entry : row.entrySet()) { String field = entry.getKey(); if (field != null && AuditVisualLogListener.isCredentialReference(field) && hasValue(entry.getValue())) { ordered.add(field.toLowerCase(Locale.ROOT)); } } if (spec != null) { for (String field : spec.mainFields()) { if (field == null) { continue; } String lower = field.toLowerCase(Locale.ROOT); if (hasValue(valueOf(row, lower))) { ordered.add(lower); } } } return ordered.isEmpty() ? Collections.<String>emptyList() : Collections.unmodifiableList(new ArrayList<String>(ordered)); } /** * 按签名 id 批量读取签名事实。查不到的 id 不出现在结果里(= 那个字段不是签名字段)。 * * <p>任何异常都返回空 Map 并告警:签名事实读不到时,宁可这次不产出签名事件, * 也不能因此丢掉业务事件——审计的红线是「这次改过不能缺」。 * * <p>🔴 <b>按租户收窄</b>(2026-08-05 批 D):{@code lims_sign} 主键是 * {@code (f_id, f_tenant_id)},<b>{@code f_id} 单列唯一不是数据库不变量</b>——「雪花 id * 全局唯一」是实现约定不是约束。批 A 已经在历史反查侧({@code selectRecordedSignIds}) * 钉过这一条,但<b>读取侧这一半当时漏了</b>:不收窄的话,请求里塞一个别的租户的 signId * 照样能读出那一行,然后被当成本次操作的签名凭证写进 append-only 的审计表。 * 实查当前库 1306 条签名全在租户 '0'、0 个跨租户重复的 f_id——是埋着的陷阱不是活的故障, * 但两侧口径必须一致,否则下一个人只会看到「反查按租户、读取不按」而无从判断哪个是对的。 * * @param tenantId 调用方的租户({@code AuditCtx.tenantId},永不为 null,见其默认值) */ public Map<String, AuditSignEvidence> read(Collection<String> signIds, String tenantId) { if (signIds == null || signIds.isEmpty()) { return Collections.emptyMap(); } List<String> ids = new ArrayList<String>(); for (String id : signIds) { if (id != null && !id.trim().isEmpty() && ids.size() < MAX_IDS) { ids.add(id.trim()); } } if (ids.isEmpty()) { return Collections.emptyMap(); } StringBuilder sql = new StringBuilder( "SELECT f_id, account_name, f_creator_user_id, note, op_type, f_creator_time, meta_data FROM ") .append(SIGN_TABLE).append(" WHERE f_id IN ("); for (int i = 0; i < ids.size(); i++) { sql.append(i == 0 ? "?" : ",?"); } sql.append(") AND f_tenant_id = ?"); List<Object> args = new ArrayList<Object>(ids); args.add(tenantId); try { List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); Map<String, AuditSignEvidence> out = new LinkedHashMap<String, AuditSignEvidence>(); for (Map<String, Object> r : rows) { String id = str(r.get("f_id")); if (id == null) { continue; } out.put(id, new AuditSignEvidence(id, str(r.get("account_name")), str(r.get("f_creator_user_id")), str(r.get("note")), str(r.get("op_type")), date(r.get("f_creator_time")), str(r.get("meta_data")))); } return out; } catch (Throwable t) { log.error("[AUDIT-L0] 读取签名凭证失败,本次不产出签名事件(业务事件不受影响)ids={}", ids.size(), t); return Collections.emptyMap(); } } private static Object valueOf(Map<String, Object> row, String lowerField) { for (Map.Entry<String, Object> entry : row.entrySet()) { if (entry.getKey() != null && lowerField.equals(entry.getKey().toLowerCase(Locale.ROOT))) { return entry.getValue(); } } return null; } private static boolean hasValue(Object value) { return !AuditFieldDiff.stringify(value).trim().isEmpty(); } private static String str(Object value) { if (value == null) { return null; } String s = value.toString().trim(); return s.isEmpty() ? null : s; } private static Date date(Object value) { if (value instanceof Timestamp) { return new Date(((Timestamp) value).getTime()); } return value instanceof Date ? (Date) value : null; } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/sign/AuditSignMetaData.java
New file @@ -0,0 +1,75 @@ package jnpf.bizcommon.audit.service.sign; import jnpf.util.JsonUtil; import java.util.Map; /** * 解析 {@code lims_sign.meta_data}——前端签名时一并提交的业务元数据。 * * <p><b>本类只暴露 D5 认可的三样</b>:按钮名、是否复核、原始 biz_data(仅供留档)。 * <b>刻意不提供</b>从 {@code biz_data} 取业务字段值的方法:业务数据一律以后端查库为准 * (D2),开了这个口子迟早有人拿前端报文当事实——而它是可篡改的。 * * <p>前端实测两种形态: * <pre> * {"is_review_button": true, "biz_button": "复核", "biz_data": [...], "is_biz_form": true} * {"is_review_button": false, "biz_button": "批量按钮", "biz_data": [...], "is_biz_form": false} * </pre> * {@code biz_module} / {@code biz_title} 前端<b>永远是空串</b>(三个组装点全硬编码),别指望。 * * <p>永不抛异常:解析失败返回 {@link #EMPTY}。动作语义拿不到只该让 action_label 糙一点, * 不该让「这次改过」缺席。 */ public final class AuditSignMetaData { public static final AuditSignMetaData EMPTY = new AuditSignMetaData(null, false, null); private final String bizButton; private final boolean reviewButton; private final String rawBizData; private AuditSignMetaData(String bizButton, boolean reviewButton, String rawBizData) { this.bizButton = bizButton; this.reviewButton = reviewButton; this.rawBizData = rawBizData; } public static AuditSignMetaData parse(String json) { if (json == null || json.trim().isEmpty()) { return EMPTY; } try { Map<String, Object> root = JsonUtil.stringToMap(json); if (root == null) { return EMPTY; } Object button = root.get("biz_button"); String name = button == null ? null : String.valueOf(button).trim(); if (name != null && name.isEmpty()) { name = null; } boolean review = Boolean.TRUE.equals(root.get("is_review_button")); Object data = root.get("biz_data"); String rawData = data == null ? null : JsonUtil.getObjectToString(data); return new AuditSignMetaData(name, review, rawData); } catch (Throwable t) { return EMPTY; } } /** 用户点的按钮文案,如「确定」「复核」「批量按钮」。取不到返回 null。 */ public String bizButton() { return bizButton; } /** 是否复核按钮。层 0 从 type 分不出复核与普通保存,只能靠它。 */ public boolean isReviewButton() { return reviewButton; } /** 前端当时展示的数据,原样 JSON。<b>仅供 extra 留档,不作准</b>。 */ public String rawBizData() { return rawBizData; } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/support/AuditDiffKey.java
New file @@ -0,0 +1,63 @@ package jnpf.bizcommon.audit.service.support; import java.util.Locale; /** 为同名字段附加物理表作用域的可持久化复合键。 */ public final class AuditDiffKey { private static final String SCOPED_PREFIX = "@table:"; private AuditDiffKey() { } /** * 长度前缀让表名和字段名的边界无歧义,同时只使用 PostgreSQL JSONB 可接受的可打印字符。 */ public static String of(String targetTable, String field) { String normalizedField = normalize(field); String normalizedTable = normalize(targetTable); if (normalizedTable == null) { return normalizedField == null ? "" : normalizedField; } String effectiveField = normalizedField == null ? "" : normalizedField; return SCOPED_PREFIX + normalizedTable.length() + ":" + normalizedTable + ":" + effectiveField; } public static boolean isScoped(String key) { if (key == null || !key.startsWith(SCOPED_PREFIX)) { return false; } int lengthEnd = key.indexOf(':', SCOPED_PREFIX.length()); if (lengthEnd < 0) { return false; } try { int tableLength = Integer.parseInt(key.substring(SCOPED_PREFIX.length(), lengthEnd)); long fieldSeparator = (long) lengthEnd + 1L + tableLength; return tableLength >= 0 && fieldSeparator < key.length() && key.charAt((int) fieldSeparator) == ':'; } catch (NumberFormatException ignored) { return false; } } /** 只用于兼容已加载到内存的旧键;返回值不会再携带旧 NUL 分隔符。 */ public static String normalizeStored(String key) { if (key == null) { return null; } int separator = key.indexOf(Character.MIN_VALUE); if (separator < 0) { return key; } return of(key.substring(0, separator), key.substring(separator + 1)); } private static String normalize(String value) { if (value == null) { return null; } String normalized = value.trim().toLowerCase(Locale.ROOT); return normalized.isEmpty() ? null : normalized; } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/support/AuditEventCategories.java
New file @@ -0,0 +1,50 @@ package jnpf.bizcommon.audit.service.support; import jnpf.audit.AuditConsts; import jnpf.util.JsonUtil; import org.springframework.util.StringUtils; import java.util.Locale; import java.util.Map; /** 审计事件物理合并分类;同一 operationId 每类最多一行。 */ public final class AuditEventCategories { public static final String BUSINESS = "BUSINESS"; public static final String PRIMARY_SIGN = "PRIMARY_SIGN"; public static final String REVIEW_SIGN = "REVIEW_SIGN"; private AuditEventCategories() { } public static String resolve(String eventType, String extraJson) { if (!AuditConsts.TYPE_E_SIGNATURE.equals(eventType)) { return BUSINESS; } String explicitRole = extraValue(extraJson, "signatureRole"); if ("REVIEW".equalsIgnoreCase(explicitRole)) { return REVIEW_SIGN; } String signField = extraValue(extraJson, "signField"); if (StringUtils.hasText(signField)) { String normalized = signField.toLowerCase(Locale.ROOT); if (normalized.contains("review") || normalized.contains("fuhe") || normalized.contains("复审") || normalized.contains("复核")) { return REVIEW_SIGN; } } return PRIMARY_SIGN; } private static String extraValue(String extraJson, String key) { if (!StringUtils.hasText(extraJson)) { return null; } try { Map<String, Object> extra = JsonUtil.stringToMap(extraJson); Object value = extra == null ? null : extra.get(key); return value == null ? null : String.valueOf(value); } catch (Throwable ignored) { return null; } } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/support/AuditQueryResponseSanitizer.java
New file @@ -0,0 +1,322 @@ package jnpf.bizcommon.audit.service.support; import com.alibaba.fastjson.JSON; import jnpf.audit.diff.AuditFieldDiff; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** 审计查询响应的字段名装饰与签名凭证脱敏;不修改原始事件。 */ public final class AuditQueryResponseSanitizer { public static final String SIGN_REFERENCE_LABEL = "电子签名已关联"; private AuditQueryResponseSanitizer() { } /** * 装饰 field_diffs 的展示名,并对历史 biz_sign / *_sign 值做响应级脱敏。 * JSON 已损坏时返回 null,避免绕过结构化脱敏。 */ @SuppressWarnings("unchecked") public static String sanitizeFieldDiffs( String json, Map<String, AuditFieldDiff.FieldMeta> metadata) { if (json == null || json.trim().isEmpty()) { return json; } try { Object parsed = JSON.parse(json); if (!(parsed instanceof List)) { return null; } List<Object> effective = new ArrayList<>(); for (Object raw : (List<?>) parsed) { if (!(raw instanceof Map)) { continue; } Map<String, Object> item = (Map<String, Object>) raw; if (isNoopDiff(item)) { continue; } sanitizeChildDiff(item); String field = string(item.get("field")); if (field == null) { continue; } AuditFieldDiff.FieldMeta meta = lookup(metadata, field); if (isCredentialReference(field, item, meta)) { item.put("fieldName", SIGN_REFERENCE_LABEL); item.put("masked", true); item.put("valueType", "masked"); item.put("componentType", "sign"); if (item.containsKey("oldData")) { item.put("oldData", AuditFieldDiff.MASKED); } if (item.containsKey("newData")) { item.put("newData", AuditFieldDiff.MASKED); } item.remove("oldDisplay"); item.remove("newDisplay"); effective.add(item); continue; } String fieldName = string(item.get("fieldName")); if (fieldName == null || (field != null && fieldName.equalsIgnoreCase(field))) { String label = meta == null ? null : meta.label(); item.put("fieldName", label == null || label.trim().isEmpty() ? databaseFieldName(metadata, field) : label); } effective.add(item); } return JSON.toJSONString(effective); } catch (Throwable t) { return null; } } /** 深度脱敏 extra / data_snapshot 中以敏感键承载的凭证值。 */ public static String sanitizeJson(String json) { return sanitizeJson(json, null); } /** 深度脱敏,并用字段元数据识别非固定列名的签名控件。 */ public static String sanitizeJson( String json, Map<String, AuditFieldDiff.FieldMeta> metadata) { if (json == null || json.trim().isEmpty()) { return json; } try { Object parsed = JSON.parse(json); sanitizeValue(parsed, metadata); return JSON.toJSONString(parsed); } catch (Throwable t) { return null; } } /** 从层 0 extra 取 sourceModelId,只用于查询当前字段元数据。 */ @SuppressWarnings("unchecked") public static String sourceModelId(String extra) { if (extra == null || extra.trim().isEmpty()) { return null; } try { Object parsed = JSON.parse(extra); if (!(parsed instanceof Map)) { return null; } return string(((Map<String, Object>) parsed).get("sourceModelId")); } catch (Throwable t) { return null; } } public static boolean hidesTargetId(String targetTable) { return targetTable != null && "lims_sign".equalsIgnoreCase(targetTable.trim()); } /** 列表摘要与详情共用的有效 diff 计数;会排除空到空及相同值伪变更。 */ @SuppressWarnings("unchecked") public static int effectiveDiffCount(String json) { return effectiveDiffFields(json).size(); } /** 返回有效变更字段,供同一操作的多条业务事件按字段并集合并摘要。 */ @SuppressWarnings("unchecked") public static Set<String> effectiveDiffFields(String json) { Set<String> fields = new LinkedHashSet<>(); if (json == null || json.trim().isEmpty()) { return fields; } try { Object parsed = JSON.parse(json); if (!(parsed instanceof List)) { return fields; } for (Object raw : (List<?>) parsed) { if (!(raw instanceof Map)) { continue; } Map<String, Object> item = (Map<String, Object>) raw; String field = string(item.get("field")); if (field != null && !isNoopDiff(item)) { String targetTable = string(item.get("targetTable")); fields.add(AuditDiffKey.of(targetTable, field)); } } } catch (Throwable ignored) { // 非法历史 JSON 按无变更降级,不能影响列表查询。 } return fields; } public static boolean isCredentialReference(String field) { if (field == null) { return false; } String trimmed = field.trim(); String lower = trimmed.toLowerCase(Locale.ROOT); return "biz_sign".equals(lower) || "bizsign".equals(lower) || "sign_id".equals(lower) || "signid".equals(lower) || lower.endsWith("_sign") || trimmed.endsWith("Sign") || trimmed.endsWith("SignId"); } /** 字段名、事件快照元数据和当前表单元数据任一确认是签名凭证,都必须脱敏。 */ public static boolean isCredentialReference(String field, Map<String, Object> item, AuditFieldDiff.FieldMeta metadata) { return isCredentialReference(field) || bool(item == null ? null : item.get("masked")) || "masked".equalsIgnoreCase(string(item == null ? null : item.get("valueType"))) || isSignComponent(item == null ? null : item.get("componentType")) || isSignComponent(item == null ? null : item.get("jnpfKey")) || AuditFieldDiff.isCredentialMetadata(metadata); } private static boolean isNoopDiff(Map<String, Object> item) { // 历史坏数据可能只保留 field/fieldName。没有完整前后值时无法证明是伪变更, // 应继续返回供证据追踪;只有两侧值键都存在时才过滤空到空或完全相同。 if (!item.containsKey("oldData") || !item.containsKey("newData")) { return false; } Object oldValue = item.get("oldData"); Object newValue = item.get("newData"); if (AuditFieldDiff.MASKED.equals(String.valueOf(oldValue)) || AuditFieldDiff.MASKED.equals(String.valueOf(newValue))) { return false; } return canonical(oldValue).equals(canonical(newValue)); } @SuppressWarnings("unchecked") private static void sanitizeChildDiff(Map<String, Object> item) { Object fieldsRaw = item.get("chidField"); Object dataRaw = item.get("chidData"); if (!(fieldsRaw instanceof List) || !(dataRaw instanceof List)) { return; } for (Object rawField : (List<?>) fieldsRaw) { if (!(rawField instanceof Map)) { continue; } Map<String, Object> field = (Map<String, Object>) rawField; String prop = string(field.get("prop")); boolean credential = prop != null && (isCredentialReference(prop) || isSignComponent(field.get("jnpfKey")) || bool(field.get("masked"))); if (!credential) { continue; } field.put("masked", true); for (Object rawRow : (List<?>) dataRaw) { if (!(rawRow instanceof Map)) { continue; } Map<String, Object> row = (Map<String, Object>) rawRow; if (row.containsKey(prop)) { row.put(prop, AuditFieldDiff.MASKED); } String oldProp = "jnpf_old_" + prop; if (row.containsKey(oldProp)) { row.put(oldProp, AuditFieldDiff.MASKED); } } } } private static String canonical(Object value) { if (value == null) { return ""; } if (value instanceof CharSequence) { String text = value.toString(); return text.trim().isEmpty() ? "" : "string:" + text; } if (value instanceof java.util.Collection && ((java.util.Collection<?>) value).isEmpty()) { return ""; } if (value instanceof Map && ((Map<?, ?>) value).isEmpty()) { return ""; } return "json:" + JSON.toJSONString(value); } @SuppressWarnings("unchecked") private static void sanitizeValue( Object value, Map<String, AuditFieldDiff.FieldMeta> metadata) { if (value instanceof Map) { Map<Object, Object> map = (Map<Object, Object>) value; for (Map.Entry<Object, Object> entry : map.entrySet()) { String field = String.valueOf(entry.getKey()); if (isCredentialReference(field) || AuditFieldDiff.isCredentialMetadata(lookup(metadata, field))) { entry.setValue(AuditFieldDiff.MASKED); } else { sanitizeValue(entry.getValue(), metadata); } } } else if (value instanceof List) { for (Object item : (List<?>) value) { sanitizeValue(item, metadata); } } } private static boolean isSignComponent(Object value) { String component = string(value); return "sign".equalsIgnoreCase(component) || "signature".equalsIgnoreCase(component); } private static boolean bool(Object value) { return value instanceof Boolean ? (Boolean) value : value != null && "true".equalsIgnoreCase(String.valueOf(value)); } private static AuditFieldDiff.FieldMeta lookup( Map<String, AuditFieldDiff.FieldMeta> metadata, String field) { if (metadata == null || field == null) { return null; } AuditFieldDiff.FieldMeta exact = metadata.get(field); if (exact != null) { return exact; } AuditFieldDiff.FieldMeta normalized = metadata.get(field.toLowerCase(Locale.ROOT)); return normalized != null ? normalized : metadata.get(camelToSnake(field)); } private static String databaseFieldName( Map<String, AuditFieldDiff.FieldMeta> metadata, String field) { String snake = camelToSnake(field); return metadata != null && metadata.containsKey(snake) ? snake : field; } private static String camelToSnake(String value) { StringBuilder out = new StringBuilder(value.length() + 8); for (int i = 0; i < value.length(); i++) { char ch = value.charAt(i); if (Character.isUpperCase(ch)) { out.append('_').append(Character.toLowerCase(ch)); } else { out.append(Character.toLowerCase(ch)); } } return out.toString(); } private static String string(Object value) { if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/title/AuditListTitleSpecParser.java
New file @@ -0,0 +1,74 @@ package jnpf.bizcommon.audit.service.title; import jnpf.util.JsonUtil; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; /** * 从已发布列表 JSON({@code base_visual_release.F_COLUMN_DATA})解析 * {@code columnList[].biz_log_enabled} 勾选。 * * <p>与 {@link AuditTitleSpecParser} 的三处差异,别混: * <ul> * <li>开关在**列项顶层**,不在 {@code __config__} 里;</li> * <li>字段编码取 {@code prop}(表单侧取 {@code __vModel__})——前端设计文档 * 「服务端接入建议」第 3 条明确了这两套口径;</li> * <li>{@code columnList} 是平铺数组,没有布局容器嵌套,**不递归**。</li> * </ul> * * <p><b>纯静态、无依赖、永不抛异常</b>:解析失败一律返回 {@link AuditTitleSpec#EMPTY}—— * 标题缺席可以,「这次删过」不能缺。 * * <p>列表侧没有子表概念,故 {@link AuditTitleSpec#subTables()} 恒为空。 */ public final class AuditListTitleSpecParser { private AuditListTitleSpecParser() { } @SuppressWarnings("rawtypes") public static AuditTitleSpec parse(String columnDataJson) { if (columnDataJson == null || columnDataJson.trim().isEmpty()) { return AuditTitleSpec.EMPTY; } try { Map<String, Object> root = JsonUtil.stringToMap(columnDataJson); if (root == null) { return AuditTitleSpec.EMPTY; } Object raw = root.get("columnList"); if (!(raw instanceof List)) { return AuditTitleSpec.EMPTY; } List<String> main = new ArrayList<String>(); for (Object item : (List) raw) { if (!(item instanceof Map)) { continue; } Map col = (Map) item; if (!Boolean.TRUE.equals(col.get("biz_log_enabled"))) { continue; } Object prop = col.get("prop"); if (prop == null) { continue; } String field = String.valueOf(prop).trim(); // 主表字段口径与 AuditTitleSpec.mainFields() 一致:小写 if (!field.isEmpty() && !main.contains(field.toLowerCase(Locale.ROOT))) { main.add(field.toLowerCase(Locale.ROOT)); } } if (main.isEmpty()) { return AuditTitleSpec.EMPTY; } return new AuditTitleSpec(main, new ArrayList<AuditTitleSpec.SubTableSpec>()); } catch (Throwable t) { // 同 AuditTitleSpecParser:不记日志,每次操作都会走到这里,报错会刷爆日志 return AuditTitleSpec.EMPTY; } } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/title/AuditRecordTitle.java
New file @@ -0,0 +1,78 @@ package jnpf.bizcommon.audit.service.title; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** 一次操作的记录标题:主表一行 + 子表每条记录一行。 */ public final class AuditRecordTitle { /** {@link #subScope()} 的取值:输入侧没看全——还有子表行(甚至整张子表)根本没读。 */ public static final String SCOPE_SCAN_TRUNCATED = "scanTruncated"; /** {@link #subScope()} 的取值:扫描完整,但产出的标题条数撞了上限被截。 */ public static final String SCOPE_ROWS_TRUNCATED = "rowsTruncated"; public static final AuditRecordTitle EMPTY = new AuditRecordTitle(null, Collections.<String>emptyList(), null, false, null); private final String mainTitle; private final List<String> subTitles; private final String subTable; private final boolean subDataUnavailable; private final String subScope; public AuditRecordTitle(String mainTitle, List<String> subTitles, String subTable, boolean subDataUnavailable, String subScope) { this.mainTitle = mainTitle == null || mainTitle.trim().isEmpty() ? null : mainTitle; this.subTitles = subTitles == null || subTitles.isEmpty() ? Collections.<String>emptyList() : Collections.unmodifiableList(new ArrayList<String>(subTitles)); this.subTable = subTable; this.subDataUnavailable = subDataUnavailable; this.subScope = subScope; } /** 主表标题行;一个字段都没勾、或勾了但全为空时返回 null(调用方回落 bizCode)。 */ public String mainTitle() { return mainTitle; } /** 子表标题行,一条子记录一行。**未必是全量**,看 {@link #subScope()}。 */ public List<String> subTitles() { return subTitles; } /** * 子表标题的完整性。{@code null} = 完整;否则是 {@link #SCOPE_SCAN_TRUNCATED} * 或 {@link #SCOPE_ROWS_TRUNCATED}。 * * <p><b>为什么必须有这个</b>(2026-08-05 Codex 复审 Low):两道上限一直是**悄悄**截的, * 读的人看到 200 条会以为那就是全部。资源上界下推之后又多了一种截法——额度耗尽时 * 后面整张子表都不碰,连「事件里没带这批数据」({@link #subDataUnavailable()})都测不出来, * 于是「判不了」被伪装成了「没发生」(L075,本批反复在防的同一个形状)。 * * <p><b>为什么不是「把没看的补看完」</b>:那要为每张剩余子表整份反序列化一遍, * 正是资源上界刚拆掉的那笔开销。诚实地说「我没看全」比花大价钱看全更划算—— * 真要全量,读的人可以去查那条记录本身。 * * <p>{@code scanTruncated} 优先于 {@code rowsTruncated}:输入侧就没看全时, * 产出侧截没截已经不是最重要的信息了。 */ public String subScope() { return subScope; } public String subTable() { return subTable; } /** 表单配了子表标题字段,但事件里取不到子表数据——如实留痕,不能当成"没有子表"。 */ public boolean subDataUnavailable() { return subDataUnavailable; } public boolean isEmpty() { return mainTitle == null && subTitles.isEmpty() && !subDataUnavailable; } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/title/AuditRecordTitleBuilder.java
New file @@ -0,0 +1,443 @@ package jnpf.bizcommon.audit.service.title; import jnpf.bizcommon.audit.entity.AuditEventEntity; import jnpf.audit.diff.AuditFieldDiff; import jnpf.bizcommon.audit.entity.model.AuditFieldDiffViewVO; import jnpf.bizcommon.audit.service.AuditFieldDiffViewService; import jnpf.util.JsonUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** * 把表单勾选渲染成人可读的「操作记录标题」。 * * <p><b>用展示值不用存储值</b>:字典/引用/枚举字段的存储值是雪花 ID 或字典码,直接拼进标题 * 就是一串数字。这里复用 {@link AuditFieldDiffViewService#build}——也就是 {@code field_diffs} * 走的<b>同一条</b>展示解析路径,保证标题与字段明细里的同一个字段显示成同一个值。 * * <p><b>三类字段绝不进标题</b>(顺序即优先级): * <ol> * <li>调用方传入的 {@code excludedFields}——脱敏列与判定为签名字段的列。 * 标题是列表明文列,凭证/敏感值进去就洗不掉(审计表 append-only 保留 10 年);</li> * <li>展示解析后仍被标记 {@code masked} 的——双保险,防调用方漏传;</li> * <li>值为空的——跳过而不是留一个空的 {@code /} 段。</li> * </ol> */ @Slf4j @Component public class AuditRecordTitleBuilder { /** 字段间分隔符。业务口径:a/b/c。 */ public static final String SEPARATOR = "/"; /** 单行标题的字符上限。主表行落 varchar(500),留出余量;子表行同口径避免 extra 无限膨胀。 */ private static final int MAX_LINE = 400; /** * 子表**标题条数**上限(产出侧)。超出的丢弃——列表只显示前 3 行,全量给详情。 * 2026-08-05 起丢弃这件事会如实留痕({@link AuditRecordTitle#SCOPE_ROWS_TRUNCATED}), * 此前是悄悄截的:读的人看到 200 条会以为那就是全部。 */ private static final int MAX_SUB_ROWS = 200; /** * 子表**扫描行数**上限(输入侧,2026-08-03 合批后新增)。 * * <p>比 {@link #MAX_SUB_ROWS} 大一档:整行标题字段为空的行渲染不出标题却仍要占一个扫描名额, * 两者同值时「前 200 行恰好全空」就会让 recordSubTitles 直接空掉(Codex 终审缺陷 6)。 * 放大到 4 倍是权衡——够吸收常见的空行比例,又把合成 diff 的规模钉在常数级。 * * <p>2026-08-05 起它同时是<b>物化预算</b>:{@link #subRows} 按剩余额度截断复制, * 外层名额用尽直接 break 掉剩余子表(Codex 复审 Medium④)。此前它只挡渲染、挡不住复制。 */ private static final int MAX_SUB_SCAN = MAX_SUB_ROWS * 4; @Autowired private AuditFieldDiffViewService fieldDiffViewService; public AuditRecordTitle build(AuditTitleSpec spec, Map<String, Object> row, AuditEventEntity valueContext, Map<String, AuditFieldDiff.FieldMeta> labels, Set<String> excludedFields) { return build(spec, row, valueContext, labels, excludedFields, Collections.<String, String>emptyMap()); } public AuditRecordTitle build(AuditTitleSpec spec, Map<String, Object> row, AuditEventEntity valueContext, Map<String, AuditFieldDiff.FieldMeta> labels, Set<String> excludedFields, Map<String, String> frontendDisplayValues) { if (spec == null || spec.isEmpty() || row == null || row.isEmpty()) { return AuditRecordTitle.EMPTY; } try { // ── 主表 + 全部子表行**一次解析**(2026-08-03,Codex 终审 Low:查询放大)──────── // 原先每行各调一次 displayValues,而解析链里的 JnpfUserFieldValueResolver 每次一条 // SQL——200 个子表行就是 201 次查询,全压在 MQ 消费线程上。解析器本身早就是批量的 // (整批一次 IN),放大来自「按行切碎了喂给它」。这里先把所有待渲染的行攒齐, // 合成**一份** diff 交给同一条解析路径,再按位置分发回各行。 List<List<String>> lineFields = new ArrayList<List<String>>(); List<Map<String, Object>> lineRows = new ArrayList<Map<String, Object>>(); lineFields.add(spec.mainFields()); lineRows.add(row); // 下标 0 恒为主表行 String subTable = null; boolean unavailable = false; // 输入侧没看全(额度耗尽)。**必须留痕**(2026-08-05 Codex 复审 Low):额度耗尽后 // 整张子表都不碰,连「事件里没带这批数据」都测不出来,unavailable 恒为 false—— // 「判不了」就被伪装成了「没发生」(L075)。补看的代价正是资源上界刚拆掉的那笔, // 所以选择如实说「我没看全」,而不是花钱看全。 boolean scanTruncated = false; for (AuditTitleSpec.SubTableSpec sub : spec.subTables()) { if (sub.fields().isEmpty()) { continue; } // 🔴 上限**下推到取行那一步**(2026-08-05 Codex 复审 Medium④):原先这道闸只挡住 // 「进入渲染的行数」,而 subRows() 已经把整个子表反序列化并复制完了,外层达到 // 上限之后还会继续把后面每一张子表都解析一遍——真正的资源上界从来没建立起来。 // 现在名额用尽就 break,剩下的子表连碰都不碰;名额没用尽的传剩余额度进去, // 由 subRows 在物化时截断。 int remaining = MAX_SUB_SCAN - (lineRows.size() - 1); if (remaining <= 0) { scanTruncated = true; break; } subTable = sub.tableName() != null ? sub.tableName() : sub.vModel(); // **多要一行当哨兵**:拿回 remaining+1 行就说明这张子表还有没读的。 // 只按 remaining 要则 rows.size()==remaining 时分不清「恰好读完」与「被截断」, // 而这两者对读的人是完全不同的两件事。多物化的那一行是 O(1),换来精确判定。 List<Map<String, Object>> rows = subRows(row, sub.vModel(), remaining + 1); if (rows != null && rows.size() > remaining) { scanTruncated = true; rows = rows.subList(0, remaining); } if (rows == null) { // 表单配了子表标题字段,事件里却没有这批数据。**如实留痕**—— // 静默当成"没有子表"会把"判不了"伪装成"本来就没有"(同 subTableUndecidable 惯例)。 unavailable = true; continue; } for (Map<String, Object> subRow : rows) { lineFields.add(sub.fields()); lineRows.add(subRow); } } // 🔴 两道上限,口径不同,缺一不可(2026-08-03 Codex 终审缺陷 6): // · MAX_SUB_SCAN 限**参与解析的原始行数**——合批后行数直接决定合成 diff 的大小, // 没有它就得把所有行都塞进去才知道够不够,那正是要防的膨胀; // · MAX_SUB_ROWS 限**产出的非空标题条数**——这是改动前的既有口径,必须保住。 // 只留前者会退化:子表前 200 行标题字段恰好全空时,第 201 行起的有效标题全被丢掉, // recordSubTitles 直接空掉。所以扫描窗口开大一档,产出仍按老口径截断。 List<String> lines = renderLines(lineFields, lineRows, valueContext, labels, excludedFields, frontendDisplayValues); List<String> subTitles = new ArrayList<String>(); // 产出侧截断同样留痕:这道上限从改动前就是**悄悄**截的,读的人看到 200 条 // 会以为那就是全部(2026-08-05 Codex 复审 Low 顺带覆盖)。 boolean rowsTruncated = false; // ⚠️ 先跳空行再判上限,顺序不能反:反过来的话「剩下的行恰好全渲染不出标题」 // 也会被报成 truncated——那是把「看全了、只是没内容」说成了「没看全」, // 与 L075 骂的「两种缺席共用一个表示」是同一个错,只是方向相反。 for (int i = 1; i < lines.size(); i++) { if (lines.get(i) == null) { continue; } if (subTitles.size() >= MAX_SUB_ROWS) { rowsTruncated = true; break; } subTitles.add(lines.get(i)); } // scanTruncated 优先:输入侧就没看全时,产出侧截没截已不是最重要的信息。 String subScope = scanTruncated ? AuditRecordTitle.SCOPE_SCAN_TRUNCATED : (rowsTruncated ? AuditRecordTitle.SCOPE_ROWS_TRUNCATED : null); return new AuditRecordTitle(lines.get(0), subTitles, subTable, unavailable, subScope); } catch (Throwable t) { // 标题渲染失败只该让标题缺席,不该让整条事件缺席(L002 的同款取舍,但作用域更小) log.warn("[AUDIT-L0] 记录标题渲染失败,本次事件不带标题:{}", t.toString()); return AuditRecordTitle.EMPTY; } } /** * 表单未启用任何审计标题字段时,以当前业务记录的 {@code f_id} 作为标题。 * 事件里的 {@code dataId} 是创建场景的可靠回退,因为提交原始数据不一定回填主键。 */ public String resolveMainTitle(AuditTitleSpec spec, AuditRecordTitle title, Map<String, Object> row, String dataId) { if (spec != null && !spec.isEmpty()) { return title == null ? null : title.mainTitle(); } Object rowId = valueOf(row, "f_id"); String recordId = rowId == null ? null : String.valueOf(rowId).trim(); if (recordId == null || recordId.isEmpty()) { recordId = dataId == null ? null : dataId.trim(); } if (recordId == null || recordId.isEmpty()) { return null; } return recordId.length() <= MAX_LINE ? recordId : recordId.substring(0, MAX_LINE); } /** * 多行**一次解析**:所有行的字段合成一份 diff 交给 {@link AuditFieldDiffViewService#build}, * 再按位置切回各行。返回值与入参逐位对应,渲染不出内容的行为 {@code null}。 * * <p><b>为什么按位置分发而不是按字段名</b>:字段名在跨行时会重复(每个子表行都有同一批列), * 按名字建 Map 会互相覆盖。{@code build} 逐项等长返回且保序——它只在 {@code field} 缺席时 * 丢项,而这里每一项都带非空 field,所以位置是可靠的键。 * * <p><b>等长前提一旦不成立就退回逐行解析</b>:多查几次只是慢,而错位会把 A 行的值显示到 * B 行的标题上——{@code record_title} 是 append-only 存十年的明文列,写错洗不掉。 */ private List<String> renderLines(List<List<String>> lineFields, List<Map<String, Object>> lineRows, AuditEventEntity valueContext, Map<String, AuditFieldDiff.FieldMeta> labels, Set<String> excludedFields, Map<String, String> frontendDisplayValues) { List<Map<String, Object>> items = new ArrayList<Map<String, Object>>(); List<Integer> starts = new ArrayList<Integer>(); for (int i = 0; i < lineRows.size(); i++) { starts.add(items.size()); appendItems(items, lineFields.get(i), lineRows.get(i), excludedFields); } List<String> out = new ArrayList<String>(lineRows.size()); if (items.isEmpty()) { for (int i = 0; i < lineRows.size(); i++) { out.add(null); } return out; } List<AuditFieldDiffViewVO> views = fieldDiffViewService.build( valueContext, JsonUtil.getObjectToString(items), labels); if (views == null || views.size() != items.size()) { log.warn("[AUDIT-L0] 标题合批解析返回 {} 项、期望 {} 项,退回逐行解析", views == null ? -1 : views.size(), items.size()); for (int i = 0; i < lineRows.size(); i++) { out.add(renderLine(lineFields.get(i), lineRows.get(i), valueContext, labels, excludedFields, i == 0 ? frontendDisplayValues : null)); } return out; } for (int i = 0; i < lineRows.size(); i++) { int from = starts.get(i); int to = i + 1 < starts.size() ? starts.get(i + 1) : items.size(); Map<String, String> displays = toDisplays(views.subList(from, to)); if (i == 0) { mergeFrontendDisplays(displays, lineFields.get(i), excludedFields, frontendDisplayValues); } out.add(displays.isEmpty() ? null : assembleLine(lineFields.get(i), displays)); } return out; } /** 渲染一行;全部字段为空或全被排除时返回 null(不产出空行)。合批前提不成立时的退路。 */ private String renderLine(List<String> fields, Map<String, Object> row, AuditEventEntity valueContext, Map<String, AuditFieldDiff.FieldMeta> labels, Set<String> excludedFields, Map<String, String> frontendDisplayValues) { Map<String, String> displays = displayValues(fields, row, valueContext, labels, excludedFields); mergeFrontendDisplays(displays, fields, excludedFields, frontendDisplayValues); if (displays.isEmpty()) { return null; } return assembleLine(fields, displays); } private void mergeFrontendDisplays(Map<String, String> displays, List<String> configuredFields, Set<String> excludedFields, Map<String, String> frontendDisplayValues) { if (frontendDisplayValues == null || frontendDisplayValues.isEmpty()) { return; } for (String field : configuredFields) { String normalized = field.toLowerCase(Locale.ROOT); if (containsIgnoreCase(excludedFields, normalized)) { continue; } String value = frontendDisplayValues.get(normalized); if (value == null || value.trim().isEmpty()) { continue; } displays.put(normalized, truncate(value.trim(), MAX_LINE)); } } /** 把解析好的展示值按字段顺序拼成 a/b/c;全空返回 null。 */ private String assembleLine(List<String> fields, Map<String, String> displays) { StringBuilder sb = new StringBuilder(); for (String field : fields) { String display = displays.get(field.toLowerCase(Locale.ROOT)); if (display == null || display.isEmpty()) { continue; // 空值跳过,不产生 a//c } if (sb.length() > 0) { sb.append(SEPARATOR); } sb.append(display); if (sb.length() >= MAX_LINE) { return sb.substring(0, MAX_LINE); } } return sb.length() == 0 ? null : sb.toString(); } /** * 走 {@code field_diffs} 的同一条展示解析:把待渲染字段包成一份合成 diff, * 交给 {@link AuditFieldDiffViewService#build} 解析出 {@code newDisplay}。 * * <p>{@code "type": 0} 是 field_diffs 契约里的"新增",让解析侧只看 newData 那一侧。 */ private Map<String, String> displayValues(List<String> fields, Map<String, Object> row, AuditEventEntity valueContext, Map<String, AuditFieldDiff.FieldMeta> labels, Set<String> excludedFields) { List<Map<String, Object>> items = new ArrayList<Map<String, Object>>(); appendItems(items, fields, row, excludedFields); if (items.isEmpty()) { return Collections.emptyMap(); } return toDisplays(fieldDiffViewService.build( valueContext, JsonUtil.getObjectToString(items), labels)); } /** * 把一行里待渲染的字段追加成合成 diff 的项。**每一项的 field 必然非空**—— * 这是 {@link #renderLines} 按位置分发的前提({@code build} 只在 field 缺席时丢项)。 */ private static void appendItems(List<Map<String, Object>> items, List<String> fields, Map<String, Object> row, Set<String> excludedFields) { if (fields == null || row == null) { return; } for (String field : fields) { if (field == null) { continue; } String lower = field.toLowerCase(Locale.ROOT); if (lower.isEmpty()) { continue; } if (excludedFields != null && excludedFields.contains(lower)) { continue; } String raw = AuditFieldDiff.stringify(valueOf(row, lower)); if (raw.trim().isEmpty()) { continue; } Map<String, Object> item = new LinkedHashMap<String, Object>(); item.put("field", lower); item.put("newData", raw); item.put("type", 0); items.add(item); } } /** 解析结果 → 字段名到展示值的映射。**调用方须保证这批 view 来自同一行**(字段名会跨行重复)。 */ private static Map<String, String> toDisplays(List<AuditFieldDiffViewVO> views) { Map<String, String> out = new LinkedHashMap<String, String>(); if (views == null) { return out; } for (AuditFieldDiffViewVO view : views) { if (view.getField() == null || Boolean.TRUE.equals(view.getMasked())) { continue; // 双保险:解析侧判定为凭证的,即使调用方漏排除也不进标题 } String display = view.getNewDisplay() != null && !view.getNewDisplay().isEmpty() ? view.getNewDisplay() : view.getNewData(); if (display != null && !display.trim().isEmpty()) { out.put(view.getField().toLowerCase(Locale.ROOT), display.trim()); } } return out; } /** * 取子表行;键缺席或形态不对返回 null(= 不可得),空数组返回空列表(= 确实没有子记录)。 * * <p><b>{@code limit} 是本方法的资源上界</b>(2026-08-05 Codex 复审 Medium④):**物化时就截断**, * 不是先全量复制再交给调用方挑。调用方那道 {@link #MAX_SUB_SCAN} 只管得住「渲染几行」, * 管不住这里复制几行——一张 5000 行的子表照样会被整份搬进内存,而 MQ 消费线程上 * 每条事件都要走一遍。 * * <p><b>没被 limit 覆盖的那一半</b>:值是 JSON 字符串时 Jackson 仍要把整个数组解析完 * (流式解析要换掉 {@link JsonUtil} 的入口,代价不成比例)。但那份原始文本本来就随事件 * 报文一起在内存里了,真正会二次放大的是这里的对象复制与后续的合成 diff,两者都已按 limit 收住。 */ @SuppressWarnings("unchecked") private List<Map<String, Object>> subRows(Map<String, Object> row, String vModel, int limit) { Object raw = valueOf(row, vModel == null ? null : vModel.toLowerCase(Locale.ROOT)); if (raw == null) { return null; } Object parsed = raw; if (raw instanceof String) { String text = ((String) raw).trim(); if (text.isEmpty()) { return Collections.emptyList(); } try { parsed = JsonUtil.getJsonToList(text, Map.class); } catch (Throwable t) { return null; } } if (!(parsed instanceof List)) { return null; } List<Map<String, Object>> out = new ArrayList<Map<String, Object>>(); for (Object item : (List<?>) parsed) { if (out.size() >= limit) { break; // 名额用尽:物化即止,不把整张子表搬进内存 } if (item instanceof Map) { out.add((Map<String, Object>) item); } } return out; } private static Object valueOf(Map<String, Object> row, String lowerField) { if (row == null || lowerField == null) { return null; } for (Map.Entry<String, Object> entry : row.entrySet()) { if (entry.getKey() != null && lowerField.equals(entry.getKey().toLowerCase(Locale.ROOT))) { return entry.getValue(); } } return null; } private static boolean containsIgnoreCase(Set<String> values, String expected) { if (values == null || expected == null) { return false; } for (String value : values) { if (value != null && expected.equals(value.toLowerCase(Locale.ROOT))) { return true; } } return false; } private static String truncate(String value, int maxLength) { return value.length() <= maxLength ? value : value.substring(0, maxLength); } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/title/AuditTitleSpec.java
New file @@ -0,0 +1,78 @@ package jnpf.bizcommon.audit.service.title; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * 一张在线表单上「哪些字段进操作记录标题」的解析结果。 * * <p>主表字段与子表字段**必须分开**:标题的形态是「主表一行 + 子表每条记录各一行」, * 混在一起就拼不出来。这也是本类不复用 {@code AuditFormRegistry} 内部那套拍平式 * {@code collectFields} 的原因。 * * <p>不可变。{@link #EMPTY} 是所有"取不到/解析不了"路径的统一返回值—— * 取不到勾选配置时应当**不产出标题**,而不是猜一个。 */ public final class AuditTitleSpec { public static final AuditTitleSpec EMPTY = new AuditTitleSpec(Collections.<String>emptyList(), Collections.<SubTableSpec>emptyList()); private final List<String> mainFields; private final List<SubTableSpec> subTables; public AuditTitleSpec(List<String> mainFields, List<SubTableSpec> subTables) { this.mainFields = mainFields == null || mainFields.isEmpty() ? Collections.<String>emptyList() : Collections.unmodifiableList(new ArrayList<String>(mainFields)); this.subTables = subTables == null || subTables.isEmpty() ? Collections.<SubTableSpec>emptyList() : Collections.unmodifiableList(new ArrayList<SubTableSpec>(subTables)); } /** 主表上勾选的字段名(小写),按表单出现顺序。 */ public List<String> mainFields() { return mainFields; } /** 每个 {@code jnpfKey=table} 子表控件一条。 */ public List<SubTableSpec> subTables() { return subTables; } public boolean isEmpty() { return mainFields.isEmpty() && subTables.isEmpty(); } /** 一个子表控件在标题里的取值口径。 */ public static final class SubTableSpec { private final String vModel; private final String tableName; private final List<String> fields; public SubTableSpec(String vModel, String tableName, List<String> fields) { this.vModel = vModel; this.tableName = tableName; this.fields = fields == null || fields.isEmpty() ? Collections.<String>emptyList() : Collections.unmodifiableList(new ArrayList<String>(fields)); } /** 事件 newData 里装这批子记录的键,如 {@code tableField6da682}。 */ public String vModel() { return vModel; } /** 子表物理表名,如 {@code lims_jianyan_xiang};表单没配时为 null。 */ public String tableName() { return tableName; } /** 子表内勾选的字段名(小写),按表单出现顺序。 */ public List<String> fields() { return fields; } } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/title/AuditTitleSpecParser.java
New file @@ -0,0 +1,163 @@ package jnpf.bizcommon.audit.service.title; import jnpf.util.JsonUtil; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; /** * 从已发布表单 JSON({@code base_visual_release.F_FORM_DATA})解析 * {@code __config__.biz_log_enabled} 勾选。 * * <p><b>纯静态、无依赖、永不抛异常</b>:解析失败一律返回 {@link AuditTitleSpec#EMPTY}。 * 层 0 的 handler 外层虽有 {@code catch(Throwable)} 兜底(L002),但那会丢掉**整条事件**—— * 标题解析失败只该让标题缺席,不该让"这次改过"缺席。 * * <p><b>递归规则</b>:布局容器(栅格 row / 标签页 tab / 卡片 card / 表格布局 tableGridTr…)的 * children 是主表字段,必须继续往下收;只有 {@code jnpfKey=table}(子表控件)的 children * 才是另一张表的字段,独立成组且**不再往主表收**。 */ public final class AuditTitleSpecParser { /** 子表控件的 jnpfKey,与 {@code AuditFieldDiff.JNPF_KEY_TABLE} 同值。 */ private static final String JNPF_KEY_TABLE = "table"; private AuditTitleSpecParser() { } @SuppressWarnings({"rawtypes", "unchecked"}) public static AuditTitleSpec parse(String formDataJson) { if (formDataJson == null || formDataJson.trim().isEmpty()) { return AuditTitleSpec.EMPTY; } try { Map<String, Object> root = JsonUtil.stringToMap(formDataJson); if (root == null) { return AuditTitleSpec.EMPTY; } List<?> fields = asList(root.get("fields")); if (fields.isEmpty()) { return AuditTitleSpec.EMPTY; } List<String> main = new ArrayList<String>(); List<AuditTitleSpec.SubTableSpec> subs = new ArrayList<AuditTitleSpec.SubTableSpec>(); collect(fields, main, subs); if (main.isEmpty() && subs.isEmpty()) { return AuditTitleSpec.EMPTY; } return new AuditTitleSpec(main, subs); } catch (Throwable t) { // 这段文本解析不了就是解析不了,返回 EMPTY = 不产出标题。不记日志: // 同一个表单每次保存都会走到这里,报错会把日志刷爆;调用方按 EMPTY 写 extra 留痕。 return AuditTitleSpec.EMPTY; } } @SuppressWarnings("rawtypes") private static void collect(List<?> fields, List<String> main, List<AuditTitleSpec.SubTableSpec> subs) { for (Object raw : fields) { if (!(raw instanceof Map)) { continue; } Map item = (Map) raw; Map config = config(item); if (config == null) { continue; } // 子表 vModel 保留原始大小写(如 tableField6da682):Task 4 要拿它去 newData 里 // 按键取子表明细,newData 的键就是表单设计器写的原样,lower() 过就永远查不到。 // 主表字段则要小写——见 AuditTitleSpec.mainFields() 的口径。 String rawVModel = str(item.containsKey("vModel") ? item.get("vModel") : item.get("__vModel__")); String vModel = lower(rawVModel); String jnpfKey = str(config.get("jnpfKey")); List<?> children = asList(config.get("children")); if (JNPF_KEY_TABLE.equals(jnpfKey)) { List<String> subFields = new ArrayList<String>(); collectEnabledFlat(children, subFields); if (rawVModel != null && !subFields.isEmpty()) { subs.add(new AuditTitleSpec.SubTableSpec( rawVModel, lower(str(config.get("tableName"))), subFields)); } continue; // 子表 children 已单独处理,绝不能再往主表收 } if (vModel != null && enabled(config)) { main.add(vModel); } if (!children.isEmpty()) { collect(children, main, subs); // 布局容器:children 仍是主表字段 } } } /** 子表内部不再嵌套子表(平台不支持),故只收一层勾选字段。 */ @SuppressWarnings("rawtypes") private static void collectEnabledFlat(List<?> fields, List<String> out) { for (Object raw : fields) { if (!(raw instanceof Map)) { continue; } Map item = (Map) raw; Map config = config(item); if (config == null) { continue; } String vModel = lower(str(item.containsKey("vModel") ? item.get("vModel") : item.get("__vModel__"))); if (vModel != null && enabled(config)) { out.add(vModel); } List<?> children = asList(config.get("children")); if (!children.isEmpty()) { collectEnabledFlat(children, out); // 子表里的布局容器 } } } @SuppressWarnings("rawtypes") private static Map config(Map item) { Object raw = item.containsKey("config") ? item.get("config") : item.get("__config__"); return raw instanceof Map ? (Map) raw : null; } @SuppressWarnings("rawtypes") private static boolean enabled(Map config) { Object raw = config.get("biz_log_enabled"); if (raw instanceof Boolean) { return (Boolean) raw; } // 跨 JVM / 存量表单里可能是字符串 "true";认不出就当没勾——**不猜**, // 猜错的方向是把没勾的字段写进明文标题列。 return raw != null && "true".equalsIgnoreCase(String.valueOf(raw).trim()); } @SuppressWarnings({"rawtypes", "unchecked"}) private static List<?> asList(Object raw) { if (raw instanceof List) { return (List<?>) raw; } if (raw == null || String.valueOf(raw).trim().isEmpty()) { return Collections.emptyList(); } List<Map> parsed = JsonUtil.getJsonToList(String.valueOf(raw), Map.class); return parsed == null ? Collections.emptyList() : parsed; } private static String str(Object value) { if (value == null) { return null; } String s = value.toString().trim(); return s.isEmpty() ? null : s; } private static String lower(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/value/AuditFieldValueContext.java
New file @@ -0,0 +1,36 @@ package jnpf.bizcommon.audit.service.value; import lombok.Getter; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.Map; /** 单条审计事件的值解析边界;引用查询必须受 targetTable 与 tenantId 约束。 */ @Getter public class AuditFieldValueContext { private final String targetTable; private final String tenantId; private final Date eventTime; /** 字段名(小写)到 JNPF dictionaryType;事件快照优先于当前发布配置。 */ private final Map<String, String> dictionaryTypes; public AuditFieldValueContext(String targetTable, String tenantId, Date eventTime) { this(targetTable, tenantId, eventTime, Collections.emptyMap()); } public AuditFieldValueContext(String targetTable, String tenantId, Date eventTime, Map<String, String> dictionaryTypes) { this.targetTable = targetTable; this.tenantId = tenantId; this.eventTime = eventTime; this.dictionaryTypes = dictionaryTypes == null || dictionaryTypes.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap<>(dictionaryTypes)); } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/value/AuditFieldValueResolver.java
New file @@ -0,0 +1,15 @@ package jnpf.bizcommon.audit.service.value; import jnpf.bizcommon.audit.entity.model.AuditFieldDiffViewVO; import java.util.List; /** * 字段值语义解析 SPI。实现必须按整批字段收集引用,不得逐字段或逐 ID 查询。 */ public interface AuditFieldValueResolver { boolean supports(String targetTable); void resolve(AuditFieldValueContext context, List<AuditFieldDiffViewVO> fields); } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/value/JnpfDictionaryFieldValueResolver.java
New file @@ -0,0 +1,233 @@ package jnpf.bizcommon.audit.service.value; import com.alibaba.fastjson.JSON; import jnpf.bizcommon.audit.entity.model.AuditFieldDiffViewVO; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.annotation.Order; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** * 按 JNPF dictionaryType 批量解析字典项;一次详情至多执行一条字典查询。 * * <p>{@code @Order} 的含义见 {@link LimsQingyandanFieldValueResolver} 的类注释。 */ @Slf4j @Component @Order(JnpfDictionaryFieldValueResolver.ORDER) public class JnpfDictionaryFieldValueResolver implements AuditFieldValueResolver { /** 平台通用解析器,排在表专用(100)之后、用户兜底(300)之前。 */ public static final int ORDER = 200; private static final String UNAVAILABLE = "名称暂不可用"; private final DictionaryLookup lookup; @Autowired public JnpfDictionaryFieldValueResolver(JdbcTemplate jdbcTemplate) { this(new JdbcDictionaryLookup(jdbcTemplate)); } JnpfDictionaryFieldValueResolver(DictionaryLookup lookup) { this.lookup = lookup; } @Override public boolean supports(String targetTable) { return true; } @Override public void resolve(AuditFieldValueContext context, List<AuditFieldDiffViewVO> fields) { if (context == null || blank(context.getTenantId()) || fields == null || fields.isEmpty() || context.getDictionaryTypes().isEmpty()) { return; } Set<String> dictionaryTypes = new LinkedHashSet<>(); Set<String> values = new LinkedHashSet<>(); for (AuditFieldDiffViewVO field : fields) { String dictionaryType = dictionaryType(context, field); if (dictionaryType == null || displaysResolved(field)) { continue; } dictionaryTypes.add(dictionaryType); values.addAll(parseValues(field.getOldData())); values.addAll(parseValues(field.getNewData())); } if (dictionaryTypes.isEmpty() || values.isEmpty()) { return; } Map<String, Map<String, String>> labels = lookup.labels( context.getTenantId().trim(), dictionaryTypes, values); for (AuditFieldDiffViewVO field : fields) { String dictionaryType = dictionaryType(context, field); Map<String, String> dictionary = dictionaryType == null ? null : labels.get(dictionaryType); if (dictionary == null) { continue; } boolean multiple = "multiEnum".equals(field.getValueType()) || startsWithArray(field.getOldData()) || startsWithArray(field.getNewData()); if (blank(field.getValueType())) { field.setValueType(multiple ? "multiEnum" : "enum"); } if (field.getOldDisplay() == null) { field.setOldDisplay(display(field.getOldData(), dictionary)); } if (field.getNewDisplay() == null) { field.setNewDisplay(display(field.getNewData(), dictionary)); } } } private static String dictionaryType(AuditFieldValueContext context, AuditFieldDiffViewVO field) { if (field == null || blank(field.getField())) { return null; } return context.getDictionaryTypes().get(field.getField().trim().toLowerCase(Locale.ROOT)); } private static boolean displaysResolved(AuditFieldDiffViewVO field) { return (blank(field.getOldData()) || field.getOldDisplay() != null) && (blank(field.getNewData()) || field.getNewDisplay() != null); } private static String display(String raw, Map<String, String> labels) { if (blank(raw)) { return ""; } List<String> values = parseValues(raw); if (values.isEmpty()) { return UNAVAILABLE; } List<String> result = new ArrayList<>(values.size()); for (String value : values) { String label = labels.get(value); result.add(blank(label) ? UNAVAILABLE : label); } return String.join("、", result); } @SuppressWarnings("unchecked") private static List<String> parseValues(String raw) { if (blank(raw)) { return Collections.emptyList(); } String trimmed = raw.trim(); if (!trimmed.startsWith("[")) { return Collections.singletonList(trimmed); } try { Object parsed = JSON.parse(trimmed); if (!(parsed instanceof Collection)) { return Collections.emptyList(); } List<String> result = new ArrayList<>(); for (Object value : (Collection<Object>) parsed) { if (value != null && !String.valueOf(value).trim().isEmpty()) { result.add(String.valueOf(value).trim()); } } return result; } catch (RuntimeException ignored) { return Collections.emptyList(); } } private static boolean startsWithArray(String raw) { return raw != null && raw.trim().startsWith("["); } private static boolean blank(String value) { return value == null || value.trim().isEmpty(); } interface DictionaryLookup { Map<String, Map<String, String>> labels(String tenantId, Set<String> dictionaryTypes, Set<String> values); } /** 表名和列名固定;dictionaryType、值和租户全部参数化。 */ private static final class JdbcDictionaryLookup implements DictionaryLookup { private final NamedParameterJdbcTemplate jdbc; private JdbcDictionaryLookup(JdbcTemplate jdbcTemplate) { this.jdbc = new NamedParameterJdbcTemplate(jdbcTemplate); } @Override public Map<String, Map<String, String>> labels( String tenantId, Set<String> dictionaryTypes, Set<String> values) { try { MapSqlParameterSource params = new MapSqlParameterSource() .addValue("dictionaryTypes", dictionaryTypes) .addValue("values", values) .addValue("tenantId", tenantId); List<Map<String, Object>> rows = jdbc.queryForList( "SELECT t.f_id AS type_id, t.f_en_code AS type_code, " + "d.f_id AS data_id, d.f_en_code AS data_code, d.f_full_name AS data_name " + "FROM base_dictionary_data d " + "JOIN base_dictionary_type t ON t.f_id = d.f_dictionary_type_id " + "WHERE (t.f_id IN (:dictionaryTypes) OR t.f_en_code IN (:dictionaryTypes)) " + "AND (d.f_id IN (:values) OR d.f_en_code IN (:values)) " + "AND COALESCE(NULLIF(t.f_tenant_id, ''), '0') = :tenantId " + "AND COALESCE(NULLIF(d.f_tenant_id, ''), '0') = :tenantId " + "AND COALESCE(t.f_delete_mark, 0) = 0 " + "AND COALESCE(d.f_delete_mark, 0) = 0", params); Map<String, Map<String, String>> result = new LinkedHashMap<>(); for (Map<String, Object> row : rows) { String typeId = text(row.get("type_id")); String typeCode = text(row.get("type_code")); String dataId = text(row.get("data_id")); String dataCode = text(row.get("data_code")); String label = text(row.get("data_name")); put(result, typeId, dataId, dataCode, label); put(result, typeCode, dataId, dataCode, label); } return result; } catch (RuntimeException ex) { log.warn("[AUDIT-QUERY] JNPF 字典批量解析失败 typeCount={} valueCount={} err={}", dictionaryTypes.size(), values.size(), ex.getClass().getSimpleName()); return Collections.emptyMap(); } } private static void put(Map<String, Map<String, String>> result, String type, String dataId, String dataCode, String label) { if (type == null || label == null) { return; } Map<String, String> values = result.computeIfAbsent(type, key -> new LinkedHashMap<>()); if (dataId != null) { values.put(dataId, label); } if (dataCode != null) { values.put(dataCode, label); } } private static String text(Object value) { if (value == null) { return null; } String result = String.valueOf(value).trim(); return result.isEmpty() ? null : result; } } } jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/value/JnpfUserFieldValueResolver.java
New file @@ -0,0 +1,235 @@ package jnpf.bizcommon.audit.service.value; import com.alibaba.fastjson.JSON; import jnpf.bizcommon.audit.entity.model.AuditFieldDiffViewVO; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.annotation.Order; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** * 把 {@code userSelect} 控件存的用户 id 解析成「真实姓名/账号」。 * * <p><b>为什么需要它</b>:{@code userSelect} 的 valueType 是 {@code reference}(见 * {@link jnpf.bizcommon.audit.service.AuditFormRegistry} 的 valueType 映射),而 * {@link jnpf.bizcommon.audit.service.AuditFieldDiffViewService} 对没人认领的 reference 一律回落成 * 「名称暂不可用」。此前只有字典解析器和请验单专用解析器,用户 id 无人认领——2026-08-02 * 实测删除标题 {@code ys0001/ys0001修改下备注/名称暂不可用},最后那段就是提交人。 * <b>这是既有缺陷而不是删除场景引入的</b>:以前标题只勾了编号所以没暴露,D6 让列表勾选 * (含提交人)进标题才第一次现形。 * * <p><b>展示格式跟随前端</b>:{@code realName/account}(如「管理员/admin」)。平台自己在 * {@code FormInfoUtils}/{@code OnlineDevInfoUtils} 等十几处都是这个拼法,审计里换个写法 * 只会让人怀疑是不是另一个人。带上账号是审计的刚需——重名的两个人靠姓名分不开。 * * <p><b>不过滤 f_delete_mark / f_enabled_mark</b>:审计追的是「当时是谁」。人离职了、账号 * 停用了、用户被删了,事件里那个 id 指向的仍然是同一个人,这时候把名字换成「名称暂不可用」 * 恰恰是把最需要追溯的那条记录变成不可追溯。租户边界仍然照约束——那是数据隔离,不是历史。 * * <p>与 {@link LimsQingyandanFieldValueResolver} 的分工:那个是表专用解析器({@code @Order} * 更靠前),{@code lims_qingyandan.qingyan_ren} 由它按自己的格式解析完,本解析器看到 * display 已经有值就不再插手({@code displaysResolved}),两者不会打架。 */ @Slf4j @Component @Order(JnpfUserFieldValueResolver.ORDER) public class JnpfUserFieldValueResolver implements AuditFieldValueResolver { /** 通用兜底解析器,排在表专用(100)与字典(200)之后。 */ public static final int ORDER = 300; private static final String UNAVAILABLE = "名称暂不可用"; /** 认领的控件类型(小写比对)。只认人员选择器,部门/角色/岗位各有各的名称表,不在本类范围。 */ private static final String USER_COMPONENT = "userselect"; private final UserLookup lookup; @Autowired public JnpfUserFieldValueResolver(JdbcTemplate jdbcTemplate) { this(new JdbcUserLookup(jdbcTemplate)); } JnpfUserFieldValueResolver(UserLookup lookup) { this.lookup = lookup; } @Override public boolean supports(String targetTable) { return true; // userSelect 是平台控件,哪张业务表都可能有 } @Override public void resolve(AuditFieldValueContext context, List<AuditFieldDiffViewVO> fields) { if (context == null || blank(context.getTenantId()) || fields == null || fields.isEmpty()) { return; } Set<String> userIds = new LinkedHashSet<>(); for (AuditFieldDiffViewVO field : fields) { if (!isUserField(field) || displaysResolved(field)) { continue; } userIds.addAll(parseValues(field.getOldData())); userIds.addAll(parseValues(field.getNewData())); } if (userIds.isEmpty()) { return; } // 整批一次 IN 查询(SPI 约定:层 0 跑在 MQ 消费线程上,逐 id 查会拖垮消费速率) Map<String, String> names = lookup.names(context.getTenantId().trim(), userIds); for (AuditFieldDiffViewVO field : fields) { if (!isUserField(field)) { continue; } if (field.getOldDisplay() == null) { field.setOldDisplay(display(field.getOldData(), names)); } if (field.getNewDisplay() == null) { field.setNewDisplay(display(field.getNewData(), names)); } } } /** * 判据是<b>控件类型</b>而不是字段名:字段名是业务起的({@code tijiao_ren}/{@code fuhe_ren}/ * {@code qingyan_ren}…),穷举不完;{@code jnpfKey} 才是「这一列存的是用户 id」的权威声明。 */ private static boolean isUserField(AuditFieldDiffViewVO field) { return field != null && field.getComponentType() != null && USER_COMPONENT.equals(field.getComponentType().trim().toLowerCase(Locale.ROOT)); } /** 两侧要么本来就空、要么已被前序解析器填过,就不必再查库。 */ private static boolean displaysResolved(AuditFieldDiffViewVO field) { return (blank(field.getOldData()) || field.getOldDisplay() != null) && (blank(field.getNewData()) || field.getNewDisplay() != null); } private static String display(String raw, Map<String, String> names) { if (blank(raw)) { return ""; } List<String> ids = parseValues(raw); if (ids.isEmpty()) { return UNAVAILABLE; } List<String> result = new ArrayList<>(ids.size()); for (String id : ids) { String name = names.get(id); result.add(blank(name) ? UNAVAILABLE : name); } return String.join("、", result); } /** 多选(multiple=true)的 userSelect 存的是 JSON 数组,单选存裸 id。 */ @SuppressWarnings("unchecked") private static List<String> parseValues(String raw) { if (blank(raw)) { return Collections.emptyList(); } String trimmed = raw.trim(); if (!trimmed.startsWith("[")) { return Collections.singletonList(trimmed); } try { Object parsed = JSON.parse(trimmed); if (!(parsed instanceof Collection)) { return Collections.emptyList(); } List<String> ids = new ArrayList<>(); for (Object value : (Collection<Object>) parsed) { if (value != null && !String.valueOf(value).trim().isEmpty()) { ids.add(String.valueOf(value).trim()); } } return ids; } catch (Throwable ignored) { return Collections.emptyList(); } } /** * 前端展示格式即 {@code realName/account}(如「管理员/admin」);缺一边时退化成还剩的 * 那一边,不留孤零零的斜杠。 * * <p>放在外层类而不是 {@link JdbcUserLookup} 里,是为了让探针够得着——格式本身就是 * 本次修复的验收标准之一(「删除标题里提交人显示管理员/admin」),埋在 JDBC 实现里 * 就只能靠端到端才验得出来。 */ static String userDisplay(String realName, String account) { if (blank(realName)) { return blank(account) ? null : account.trim(); } return blank(account) ? realName.trim() : realName.trim() + "/" + account.trim(); } private static boolean blank(String value) { return value == null || value.trim().isEmpty(); } interface UserLookup { Map<String, String> names(String tenantId, Set<String> ids); } /** 表名和列名固定;id 与租户全部参数化。 */ private static final class JdbcUserLookup implements UserLookup { private final NamedParameterJdbcTemplate jdbc; private JdbcUserLookup(JdbcTemplate jdbcTemplate) { this.jdbc = new NamedParameterJdbcTemplate(jdbcTemplate); } @Override public Map<String, String> names(String tenantId, Set<String> ids) { if (ids.isEmpty()) { return Collections.emptyMap(); } try { MapSqlParameterSource params = new MapSqlParameterSource() .addValue("ids", ids) .addValue("tenantId", tenantId); List<Map<String, Object>> rows = jdbc.queryForList( "SELECT f_id, f_real_name, f_account FROM base_user " + "WHERE f_id IN (:ids) " + "AND COALESCE(NULLIF(f_tenant_id, ''), '0') = :tenantId", params); Map<String, String> result = new LinkedHashMap<>(); for (Map<String, Object> row : rows) { String id = text(row.get("f_id")); String display = userDisplay(text(row.get("f_real_name")), text(row.get("f_account"))); if (id != null && display != null) { result.put(id, display); } } return result; } catch (RuntimeException ex) { log.warn("[AUDIT-QUERY] 用户名称批量解析失败 count={} err={}", ids.size(), ex.getClass().getSimpleName()); return Collections.emptyMap(); } } private static String text(Object value) { if (value == null) { return null; } String text = String.valueOf(value).trim(); return text.isEmpty() ? null : text; } } } Diff truncated after the above file
jnpf-biz-common/jnpf-biz-common-audit/src/main/java/jnpf/bizcommon/audit/service/value/LimsQingyandanFieldValueResolver.java jnpf-biz-common/jnpf-biz-common-audit/src/main/resources/mapper/AuditEventMapper.xml jnpf-biz-common/jnpf-biz-common-audit/src/main/resources/mapper/AuditQueryMapper.xml jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/AuditFieldValueSemanticsProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/AuditL0FieldMetaProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/AuditL0ParseFailureProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/AuditQuerySemanticsProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/impl/AuditEventIngestMergeProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/listener/AuditChildTableDiffProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/listener/AuditL0PartialSnapshotProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/sign/AuditSignFieldProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/sign/AuditSignMetaDataProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/title/AuditRecordTitleBatchProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/title/AuditTitleSpecProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/value/JnpfDictionaryFieldValueResolverProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/value/JnpfUserFieldValueResolverProbe.java jnpf-biz-common/jnpf-biz-common-audit/src/test/java/jnpf/bizcommon/audit/service/value/LimsQingyandanFieldValueResolverProbe.java jnpf-biz-common/jnpf-biz-common-onlyoffice/pom.xml jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/controller/OnlyOfficeController.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/controller/PingController.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/entity/OnlyOfficeCallbackParam.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/entity/OnlyOfficeConfigParam.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/entity/OnlyOfficeProperties.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/entity/OnlyOfficeSessionEntity.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/entity/package-info.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/mapper/OnlyOfficeSessionMapper.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/mapper/package-info.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/service/OnlyOfficeService.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/service/impl/OnlyOfficeFillTicketIssuer.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/service/impl/OnlyOfficePluginResolver.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/service/impl/OnlyOfficeServiceImpl.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/main/java/jnpf/bizcommon/onlyoffice/service/package-info.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/test/java/jnpf/bizcommon/onlyoffice/service/impl/OnlyOfficeFillTicketIssuerTest.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/test/java/jnpf/bizcommon/onlyoffice/service/impl/OnlyOfficePluginResolverTest.java jnpf-biz-common/jnpf-biz-common-onlyoffice/src/test/java/jnpf/bizcommon/onlyoffice/service/impl/OnlyOfficeServiceImplTest.java jnpf-biz-common/jnpf-biz-common-server/pom.xml jnpf-biz-common/jnpf-biz-common-server/src/main/java/jnpf/JnpfBizCommonApplication.java jnpf-biz-common/jnpf-biz-common-server/src/main/resources/application.yml jnpf-biz-common/jnpf-biz-common-server/src/main/resources/logback-spring.xml jnpf-biz-common/pom.xml jnpf-dms/jnpf-dms-api/pom.xml jnpf-dms/jnpf-dms-api/src/main/java/jnpf/dms/DmsResourceApi.java jnpf-dms/jnpf-dms-biz/pom.xml jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsDatabase/DmsMybatisConfig.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsDatabase/DmsUuidTypeHandler.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsIntegration/audit/DmsAuditAdapter.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsAccessExclusionMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsDanganCundangShenqingDanganheMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsDanganCundangShenqingMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsDanganXiaohuiShenqingMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsDanganXiaohuiShenqingZixiangMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsDocumentMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsFileCenterMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsFileObjectMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsFilePermissionMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsFileVersionMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsFolderMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsPermissionGroupMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsPermissionResourceMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsPermissionRevisionMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsPermissionUsageMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsVersionStateMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianBanbenKongzhiMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianBianhaoGuizeFeiqiMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianBianhaoGuizeMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianBianhaoGuizeZixiangMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianBianhaoLiushuiJishuMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianBianhaoMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianBianhaoShenqingMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianMingchengBiangengShenqingMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianXinzengGuanliMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianXinzengShenheMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianXiudingShenheMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsMapper/DmsWenjianXiudingShenqingMapper.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsAccessExclusionResolver.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsCurrentIdentity.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsCursorCodec.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsFileCenterCursorCodec.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsFileCenterQuery.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsPermissionError.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsPermissionException.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsPermissionExecutionContext.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsPermissionGroupMembershipResolver.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsPermissionGuard.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsPermissionMatcher.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsPermissionOperation.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsPermissionProperties.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsPrincipalValidator.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsResourceResolver.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsSubjectResolver.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsTrustedServiceGuard.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsPermission/DmsUserDisplayNameResolver.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsAccessExclusionService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsDanganCundangJieshouService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsDanganXiaohuiShenqingTongguoService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsFileCenterService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsFileRevisionService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsFileUploadService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsPermissionAdminQueryService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsPermissionDecisionService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsPermissionGrantService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsResourceQueryService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsResourceService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsWenjianBanbenService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsWenjianBianhaoApplicationService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsWenjianBianhaoGuizeFeiqiService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/DmsWenjianBianhaoPermissionService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/impl/AllowAllDmsWenjianBianhaoPermissionService.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/impl/DmsDanganCundangJieshouServiceImpl.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/impl/DmsDanganXiaohuiShenqingTongguoServiceImpl.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/impl/DmsWenjianBanbenServiceImpl.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/impl/DmsWenjianBianhaoApplicationServiceImpl.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/impl/DmsWenjianBianhaoGuizeFeiqiServiceImpl.java jnpf-dms/jnpf-dms-biz/src/main/java/jnpf/dmsService/support/DmsWenjianBianhaoFlowStateResolver.java jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianBanbenKongzhiMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianBianhaoGuizeMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianBianhaoGuizeZixiangMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianBianhaoLiushuiJishuMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianBianhaoMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianBianhaoShenqingMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianMingchengBiangengShenqingMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianXinzengGuanliMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianXinzengShenheMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianXiudingShenheMapper.xml jnpf-dms/jnpf-dms-biz/src/main/resources/mapper/DmsWenjianXiudingShenqingMapper.xml jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsDatabase/DmsUuidTypeHandlerTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsMapper/DmsAccessExclusionMapperSqlTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsMapper/DmsFileCenterMapperSqlTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsMapper/DmsFilePermissionMapperSqlTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsMapper/DmsPermissionResourceMapperSqlTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsMapper/DmsVersionStateMapperSqlTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsMapper/DmsWenjianBanbenMapperXmlTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsMapper/DmsWenjianBianhaoMapperXmlTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsPermission/DmsAccessExclusionResolverTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsPermission/DmsCurrentIdentityTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsPermission/DmsCursorCodecTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsPermission/DmsFileCenterCursorCodecTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsPermission/DmsPermissionGuardTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsPermission/DmsPermissionMatcherTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsPermission/DmsTrustedServiceGuardTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsPermission/DmsUserDisplayNameResolverTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/AllowAllDmsWenjianBianhaoPermissionServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsAccessExclusionServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsDanganCundangJieshouServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsDanganXiaohuiShenqingTongguoServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsFileCenterServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsFileRevisionServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsFileUploadServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsPermissionAdminQueryServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsPermissionDecisionServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsPermissionGrantServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsResourceQueryServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/DmsResourceServiceTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/impl/DmsWenjianBanbenServiceImplTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/impl/DmsWenjianBianhaoApplicationServiceImplTest.java jnpf-dms/jnpf-dms-biz/src/test/java/jnpf/dmsService/support/DmsWenjianBianhaoFlowStateResolverTest.java jnpf-dms/jnpf-dms-biz/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker jnpf-dms/jnpf-dms-biz/src/test/resources/sql/dms_file_objects_test.sql jnpf-dms/jnpf-dms-biz/src/test/resources/sql/dms_file_properties_test.sql jnpf-dms/jnpf-dms-biz/src/test/resources/sql/dms_permission_v2_2_test.sql jnpf-dms/jnpf-dms-biz/src/test/resources/sql/dms_permission_v2_3_test.sql jnpf-dms/jnpf-dms-controller/pom.xml jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/DmsDanganCundangJieshouController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/DmsDanganXiaohuiShenqingController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/DmsWenjianBanbenController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/DmsWenjianBianhaoController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/DmsWenjianBianhaoGuizeController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/PingController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/filecenter/FileCenterController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/permission/AccessExclusionController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/permission/DmsInternalResourceController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/permission/DmsPermissionExceptionHandler.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/permission/PermissionDecisionController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/permission/PermissionGrantController.java jnpf-dms/jnpf-dms-controller/src/main/java/jnpf/dmsController/permission/PermissionResourceController.java jnpf-dms/jnpf-dms-controller/src/test/java/jnpf/dmsController/DmsDanganCundangJieshouControllerTest.java jnpf-dms/jnpf-dms-controller/src/test/java/jnpf/dmsController/DmsDanganXiaohuiShenqingControllerTest.java jnpf-dms/jnpf-dms-controller/src/test/java/jnpf/dmsController/DmsWenjianBanbenControllerTest.java jnpf-dms/jnpf-dms-controller/src/test/java/jnpf/dmsController/DmsWenjianBianhaoControllerTest.java jnpf-dms/jnpf-dms-controller/src/test/java/jnpf/dmsController/DmsWenjianBianhaoGuizeControllerTest.java jnpf-dms/jnpf-dms-controller/src/test/java/jnpf/dmsController/PingControllerTest.java jnpf-dms/jnpf-dms-entity/pom.xml jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsDanganCundangShenqingDanganheEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsDanganCundangShenqingEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsDanganXiaohuiShenqingEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsDanganXiaohuiShenqingZixiangEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianBanbenKongzhiEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianBianhaoEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianBianhaoGuizeEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianBianhaoGuizeFeiqiEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianBianhaoGuizeZixiangEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianBianhaoLiushuiJishuEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianBianhaoShenqingEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianMingchengBiangengShenqingEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianXinzengGuanliEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianXinzengShenheEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianXiudingShenheEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/DmsWenjianXiudingShenqingEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/form/DmsDanganCundangJieshouCheckForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/form/DmsDanganCundangJieshouDanganheForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/form/DmsDanganCundangJieshouForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/entity/DmsAccessExclusionEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/entity/DmsDocumentEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/entity/DmsFilePermissionEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/entity/DmsFileVersionEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/entity/DmsFolderEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/entity/DmsPermissionResourceEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/entity/DmsPermissionRevisionEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/entity/DmsPermissionUsageEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/enums/DmsActorType.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/enums/DmsPermissionAction.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/enums/DmsPrincipalType.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/enums/DmsResourceScope.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/enums/DmsResourceType.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/enums/DmsSourceType.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/AccessExclusionBatchForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/AccessExclusionItemForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/AccessExclusionRevokeForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/DocumentCreateForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/FileVersionCreateForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/FolderCreateForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/FolderCreateUserForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/PermissionBatchDecisionForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/PermissionDecisionForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/PermissionGrantBatchForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/PermissionGrantItemForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/form/PermissionRevokeForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/model/DmsFolderAncestorRow.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/model/DmsResourcePath.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/model/DmsSubjectSet.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsAccessExclusionVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsAdminCapabilitiesVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsCursorPageVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsErrorVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsFileCenterItemVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsPermissionDecisionVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsPermissionGrantVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsPermissionResourceVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsPermissionUsageVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsPrincipalOptionVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/permission/vo/DmsResourceTypeDefinitionVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/storage/entity/DmsFileObjectEntity.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/storage/form/DmsFileRevisionRegisterForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/storage/form/DmsFileUploadRegisterForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/storage/vo/DmsFileUploadVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/wenjianbianhao/DmsFlowEventType.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/wenjianbianhao/form/DmsFlowEventForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/wenjianbianhao/form/DmsWenjianBianhaoPreviewForm.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/wenjianbianhao/vo/DmsWenjianBianhaoOptionVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/wenjianbianhao/vo/DmsWenjianBianhaoPreviewVO.java jnpf-dms/jnpf-dms-entity/src/main/java/jnpf/dmsEntity/wenjianbianhao/vo/DmsWenjianBianhaoRuleVO.java jnpf-dms/jnpf-dms-server/pom.xml jnpf-dms/jnpf-dms-server/src/main/java/jnpf/JnpfDmsApplication.java jnpf-dms/jnpf-dms-server/src/main/resources/application.yml jnpf-dms/jnpf-dms-server/src/main/resources/logback-spring.xml jnpf-dms/pom.xml jnpf-eln/jnpf-eln-api/pom.xml jnpf-eln/jnpf-eln-biz/pom.xml jnpf-eln/jnpf-eln-biz/src/main/java/jnpf/elnMapper/ElnDianziJiluMubanMapper.java jnpf-eln/jnpf-eln-biz/src/main/java/jnpf/elnService/ElnDianziJiluMubanService.java jnpf-eln/jnpf-eln-biz/src/main/java/jnpf/elnService/TemplateFillTicketService.java jnpf-eln/jnpf-eln-biz/src/main/java/jnpf/elnService/impl/ElnDianziJiluMubanServiceImpl.java jnpf-eln/jnpf-eln-biz/src/test/java/jnpf/elnMapper/ElnDianziJiluMubanMapperTest.java jnpf-eln/jnpf-eln-biz/src/test/java/jnpf/elnService/TemplateFillTicketServiceTest.java jnpf-eln/jnpf-eln-biz/src/test/java/jnpf/elnService/impl/ElnDianziJiluMubanServiceImplTest.java jnpf-eln/jnpf-eln-controller/pom.xml jnpf-eln/jnpf-eln-controller/src/main/java/jnpf/elnController/ElnDianziJiluMubanController.java jnpf-eln/jnpf-eln-controller/src/main/java/jnpf/elnController/PingController.java jnpf-eln/jnpf-eln-controller/src/main/java/jnpf/elnController/TemplateFillController.java jnpf-eln/jnpf-eln-controller/src/test/java/jnpf/elnController/ElnDianziJiluMubanControllerTest.java jnpf-eln/jnpf-eln-controller/src/test/java/jnpf/elnController/TemplateFillControllerTest.java jnpf-eln/jnpf-eln-entity/pom.xml jnpf-eln/jnpf-eln-entity/src/main/java/jnpf/elnEntity/ElnMubanFileVo.java jnpf-eln/jnpf-eln-server/pom.xml jnpf-eln/jnpf-eln-server/src/main/java/jnpf/JnpfElnApplication.java jnpf-eln/jnpf-eln-server/src/main/resources/application.yml jnpf-eln/jnpf-eln-server/src/main/resources/logback-spring.xml jnpf-eln/pom.xml jnpf-file/jnpf-file-api/pom.xml jnpf-file/jnpf-file-api/src/main/java/jnpf/file/DocumentApi.java jnpf-file/jnpf-file-api/src/main/java/jnpf/file/FileApi.java jnpf-file/jnpf-file-api/src/main/java/jnpf/file/FileUploadApi.java jnpf-file/jnpf-file-api/src/main/java/jnpf/file/YozoApi.java jnpf-file/jnpf-file-api/src/main/java/jnpf/file/fallback/DocumentApiFallback.java jnpf-file/jnpf-file-api/src/main/java/jnpf/file/fallback/FileApiFallback.java jnpf-file/jnpf-file-api/src/main/java/jnpf/file/fallback/FileUploadApiFallback.java jnpf-file/jnpf-file-api/src/main/java/jnpf/file/fallback/YozoApiFallback.java jnpf-file/jnpf-file-api/src/main/java/jnpf/file/util/ExcelTool.java jnpf-file/jnpf-file-api/src/test/java/jnpf/file/FileUploadApiTest.java jnpf-file/jnpf-file-biz/pom.xml jnpf-file/jnpf-file-biz/src/main/java/jnpf/mapper/DocumentLogMapper.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/mapper/DocumentMapper.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/mapper/DocumentShareMapper.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/service/DocumentLogService.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/service/DocumentService.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/service/DocumentShareService.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/service/FileService.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/service/impl/DocumentLogServiceImpl.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/service/impl/DocumentServiceImpl.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/service/impl/DocumentShareServiceImpl.java jnpf-file/jnpf-file-biz/src/main/java/jnpf/service/impl/FileServiceImpl.java jnpf-file/jnpf-file-model/pom.xml jnpf-file/jnpf-file-model/src/main/java/jnpf/entity/DocumentEntity.java jnpf-file/jnpf-file-model/src/main/java/jnpf/entity/DocumentLogEntity.java jnpf-file/jnpf-file-model/src/main/java/jnpf/entity/DocumentShareEntity.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/Chunk.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/ChunkRes.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/ExportModel.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/MergeChunkDto.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/MinioCopyObjectModel.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/PathTypeModel.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/PreviewParams.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/UploaderVO.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/VisualDataImgModel.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentCrForm.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentFolderTreeModel.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentFolderTreeVO.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentInfoVO.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentListVO.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentShareForm.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentSoutListVO.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentStomeListVO.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentSuserListVO.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentTrashListVO.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentUpForm.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/DocumentUploader.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/FlowFileModel.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/document/PageDocument.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/documentpreview/FileInfoVO.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/upload/DownFileModel.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/upload/PathTypeModel.java jnpf-file/jnpf-file-model/src/main/java/jnpf/model/upload/UploadFileModel.java jnpf-file/jnpf-file-model/src/main/java/jnpf/util/VisusalImgUrl.java jnpf-file/jnpf-file-server/pom.xml jnpf-file/jnpf-file-server/src/main/java/jnpf/JnpfFileAplication.java jnpf-file/jnpf-file-server/src/main/resources/application.yml jnpf-file/jnpf-file-server/src/main/resources/logback-spring.xml jnpf-file/jnpf-file-storage/pom.xml jnpf-file/jnpf-file-storage/src/main/java/jnpf/controller/DmsFileUploadController.java jnpf-file/jnpf-file-storage/src/main/java/jnpf/controller/DocumentController.java jnpf-file/jnpf-file-storage/src/main/java/jnpf/controller/UtilsController.java jnpf-file/jnpf-file-storage/src/main/java/jnpf/controller/util/FileSizeUtil.java jnpf-file/jnpf-file-storage/src/main/java/jnpf/fileStorage/SeaweedFsStorageProperties.java jnpf-file/jnpf-file-storage/src/main/java/jnpf/fileStorage/SeaweedFsStorageRegistrar.java jnpf-file/jnpf-file-storage/src/main/java/jnpf/fileStorage/dms/DmsFileStorageService.java jnpf-file/jnpf-file-storage/src/main/java/jnpf/fileStorage/dms/UuidV7Generator.java jnpf-file/jnpf-file-storage/src/test/java/jnpf/fileStorage/SeaweedFsStorageRegistrarTest.java jnpf-file/jnpf-file-storage/src/test/java/jnpf/fileStorage/dms/DmsFileStorageServiceTest.java jnpf-file/jnpf-file-storage/src/test/java/jnpf/fileStorage/dms/UuidV7GeneratorTest.java jnpf-file/jnpf-file-storage/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker jnpf-file/jnpf-file-upload/pom.xml jnpf-file/jnpf-file-upload/src/main/java/jnpf/upload/controller/FileUploadController.java jnpf-file/jnpf-file-upload/src/test/java/jnpf/upload/controller/FileUploadControllerTest.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/pom.xml jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/mapper/FileMapper.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/service/YozoService.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/service/impl/YozoServiceImpl.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/util/YozoUtils.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/yozo/client/AppAuthenticator.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/yozo/client/UaaAppAuthenticator.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/yozo/client/UaaAppConfigClient.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/yozo/constants/EnumResultCode.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/yozo/constants/UaaConstant.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/yozo/utils/DefaultResult.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/yozo/utils/HttpRequestUtils.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/yozo/utils/IResult.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-biz/src/main/java/jnpf/yozo/utils/SecretSignatureUtils.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-controller/pom.xml jnpf-file/jnpf-file-yozo/jnpf-file-yozo-controller/src/main/java/jnpf/controller/YozoFileController.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-entity/pom.xml jnpf-file/jnpf-file-yozo/jnpf-file-yozo-entity/src/main/java/jnpf/entity/FileEntity.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-entity/src/main/java/jnpf/model/SuffixParams.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-entity/src/main/java/jnpf/model/YozoFileParams.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-entity/src/main/java/jnpf/model/YozoParams.java jnpf-file/jnpf-file-yozo/jnpf-file-yozo-entity/src/main/java/jnpf/utils/SplicingUrlUtil.java jnpf-file/jnpf-file-yozo/pom.xml jnpf-file/pom.xml jnpf-flow-engine/.gitignore jnpf-flow-engine/README.md jnpf-flow-engine/pom.xml jnpf-flow-engine/src/main/java/jnpf/JnpfFlowableApplication.java jnpf-flow-engine/src/main/java/jnpf/workflow/admin/controller/DefinitionController.java jnpf-flow-engine/src/main/java/jnpf/workflow/admin/controller/InstanceController.java jnpf-flow-engine/src/main/java/jnpf/workflow/admin/controller/TaskController.java jnpf-flow-engine/src/main/java/jnpf/workflow/admin/filter/RequestTimingFilter.java jnpf-flow-engine/src/main/java/jnpf/workflow/admin/handle/GlobalExceptionHandler.java jnpf-flow-engine/src/main/java/jnpf/workflow/admin/result/Result.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/exception/BizException.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/exception/ResultCode.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/CompensateFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/DefinitionDeleteFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/DefinitionDeployFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/FlowTargetTaskFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/InfoModel.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/InstanceDeleteFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/InstanceStartFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/JumpFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/MoveMultiToSingleFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/MoveSingleToMultiFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/TaskAfterFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/TaskBackFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/TaskCompleteFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/TaskNextFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/TaskOutgoingFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/fo/TaskPrevFo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/vo/DefinitionVo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/vo/DeploymentVo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/vo/FlowElementVo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/vo/FlowVo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/vo/HistoricInstanceVo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/vo/HistoricNodeVo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/vo/InstanceVo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/vo/NodeElementVo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/model/vo/TaskVo.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/service/IDefinitionService.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/service/IInstanceService.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/service/ITaskService.java jnpf-flow-engine/src/main/java/jnpf/workflow/common/util/FlowUtil.java jnpf-flow-engine/src/main/java/jnpf/workflow/flowable/cmd/JumpCmd.java jnpf-flow-engine/src/main/java/jnpf/workflow/flowable/service/DefinitionServiceImpl.java jnpf-flow-engine/src/main/java/jnpf/workflow/flowable/service/InstanceServiceImpl.java jnpf-flow-engine/src/main/java/jnpf/workflow/flowable/service/TaskServiceImpl.java jnpf-flow-engine/src/main/java/jnpf/workflow/flowable/util/FlowableUtil.java jnpf-flow-engine/src/main/java/liquibase/snapshot/JdbcDatabaseSnapshot.java jnpf-flow-engine/src/main/resources/application-dev.yml jnpf-flow-engine/src/main/resources/application-preview.yml jnpf-flow-engine/src/main/resources/application-prod.yml jnpf-flow-engine/src/main/resources/application-test.yml jnpf-flow-engine/src/main/resources/application.yml jnpf-flow-engine/src/main/resources/logback-spring.xml jnpf-flow-engine/test/contract-smoke.sh jnpf-flow-engine/test/lifecycle.sh jnpf-flowable/jnpf-flowable-api/pom.xml jnpf-flowable/jnpf-flowable-api/src/main/java/jnpf/flowable/TaskApi.java jnpf-flowable/jnpf-flowable-api/src/main/java/jnpf/flowable/TemplateApi.java jnpf-flowable/jnpf-flowable-api/src/main/java/jnpf/flowable/TriggerApi.java jnpf-flowable/jnpf-flowable-api/src/main/java/jnpf/flowable/WorkFlowApi.java jnpf-flowable/jnpf-flowable-api/src/main/java/jnpf/flowable/fallback/TaskApiFallback.java jnpf-flowable/jnpf-flowable-api/src/main/java/jnpf/flowable/fallback/TemplateApiFallback.java jnpf-flowable/jnpf-flowable-api/src/main/java/jnpf/flowable/fallback/TriggerApiFallback.java jnpf-flowable/jnpf-flowable-api/src/main/java/jnpf/flowable/fallback/WorkFlowApiFallback.java jnpf-flowable/jnpf-flowable-biz/pom.xml jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/AutoAuditJob.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/AutoTransferJob.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/FlowJobConfig.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/FlowJobUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/FlowTime.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/JobRunner.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/QuartzJobUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/TimeTriggerJob.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/TimeTriggerRunner.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/job/TriggerJobUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/CandidatesMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/CirculateMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/CommentMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/CommonMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/DelegateInfoMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/DelegateMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/EventLogMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/LaunchUserMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/NodeRecordMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/OperatorMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/RecordMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/RejectDataMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/RevokeMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/SubtaskDataMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/TaskLineMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/TaskMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/TemplateJsonMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/TemplateMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/TemplateNodeMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/TemplateUseNumMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/TriggerLaunchflowMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/TriggerRecordMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/mapper/TriggerTaskMapper.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/CandidatesService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/CirculateService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/CommentService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/CommonService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/DelegateInfoService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/DelegateService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/EventLogService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/LaunchUserService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/NodeRecordService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/OperatorService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/RecordService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/RejectDataService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/RevokeService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/SubtaskDataService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/TaskLineService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/TaskService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/TemplateJsonService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/TemplateNodeService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/TemplateService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/TemplateUseNumService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/TriggerLaunchflowService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/TriggerRecordService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/TriggerTaskService.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/CandidatesServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/CirculateServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/CommentServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/CommonServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/DelegateInfoServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/DelegateServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/EventLogServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/LaunchUserServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/NodeRecordServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/OperatorServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/RecordServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/RejectDataServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/RevokeServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/SubtaskDataServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/TaskLineServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/TaskServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/TemplateJsonServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/TemplateNodeServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/TemplateServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/TemplateUseNumServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/TriggerLaunchflowServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/TriggerRecordServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/service/impl/TriggerTaskServiceImpl.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/ButtonUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/ConditionUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/FlowJsonUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/FlowUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/MsgUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/NodeUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/OperatorUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/OutsideUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/OverTimeUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/RecordUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/RedisLock.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/ServiceUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/TaskUtil.java jnpf-flowable/jnpf-flowable-biz/src/main/java/jnpf/flowable/util/TimeUtil.java jnpf-flowable/jnpf-flowable-biz/src/test/java/jnpf/flowable/util/FlowStatusEventOrderChecks.java jnpf-flowable/jnpf-flowable-controller/pom.xml jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/CommentController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/DelegateController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/OperatorController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/TaskController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/TaskForFileController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/TemplateController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/TriggerController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/TriggerTaskController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/TriggerWebHookController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/WorkFlowAiController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/WorkFlowApiController.java jnpf-flowable/jnpf-flowable-controller/src/main/java/jnpf/flowable/controller/WorkflowMonitorController.java jnpf-flowable/jnpf-flowable-entity/pom.xml jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/CandidatesEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/CirculateEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/CommentEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/CommonEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/DelegateEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/DelegateInfoEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/EventLogEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/LaunchUserEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/NodeRecordEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/OperatorEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/RecordEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/RejectDataEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/RevokeEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/SubtaskDataEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/TaskEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/TaskLineEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/TemplateEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/TemplateJsonEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/TemplateNodeEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/TemplateUseNumEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/TriggerLaunchflowEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/TriggerRecordEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/entity/TriggerTaskEntity.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/ActionEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/CategoryEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/DivideRuleEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/ErrorRuleEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/EventEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/ExtraRuleEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/FieldEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/NodeEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/NodeStateEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/NodeTypeEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/OpTypeEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/OperatorEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/OperatorStateEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/PrintEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/RecordEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/TaskStatusEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/TemplateJsonStatueEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/TemplateStatueEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/enums/TriggerRecordEnum.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/candidates/CandidateCheckFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/candidates/CandidateCheckVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/candidates/CandidateListModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/candidates/CandidateUserVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/candidates/CandidatesVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/comment/CommentCrForm.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/comment/CommentInfoVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/comment/CommentListVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/comment/CommentPagination.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/delegate/DelegateCrForm.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/delegate/DelegateInfoModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/delegate/DelegateInfoVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/delegate/DelegateListVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/delegate/DelegatePagination.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/delegate/DelegateUpForm.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/AfterFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/BackFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/CompensateFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/CompleteFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/FlowAbleData.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/FlowAbleForm.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/FlowAbleModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/FlowAbleUrl.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/FlowableHistoricModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/FlowableInstanceModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/FlowableNodeModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/FlowableTaskModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/InstanceStartFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/JumpFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/NextOrPrevFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/flowable/OutgoingFlowsFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/free/FreeConnect.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/free/FreeModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/message/ContModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/message/DelegateModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/message/FlowEventModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/message/FlowMsgModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/monitor/MonitorModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/monitor/MonitorVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/operator/AddSignModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/operator/FlowBatchModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/operator/OperatorVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/outside/OutsideModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/record/NodeRecordModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/record/ProgressModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/record/RecordModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/record/RecordVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/record/UserItem.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/AuditModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/FileModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/FlowHandleModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/FlowMethod.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/FlowModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/FlowTodoVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/RevokeFormDataModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/SubParameterModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/TaskFlowTodoVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/TaskPagination.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/TaskTo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/TaskUserListModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/TaskVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/task/ViewFormModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/BeforeInfoVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/FlowByFormModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/FlowConfigModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplateByFormModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplateCrForm.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplateExportModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplateInfoVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplatePageLisVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplatePageVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplatePagination.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplateTreeListVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplateUpForm.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/TemplateUseNumVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/template/UpDownModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatejson/FlowFormModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatejson/FlowListModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatejson/FlowParamModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatejson/TemplateJsonExportModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatejson/TemplateJsonInfoVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatejson/TemplateJsonSelectVO.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/BackNodeModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/ButtonModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/FlowErrorModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/TaskNodeModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/TemplateNodeCrFrom.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/TemplateNodeUpFrom.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/ApproversConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/Assign.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/AssignRule.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/AutoAuditRule.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/AutoSubmitConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/AuxiliaryInfo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/AuxiliaryInfoConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/CounterSignConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/FileConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/FuncConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/GroupsModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/IntegrateParamModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/IntegrateTplModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/InterfaceConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/MsgConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/NodeModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/PrintConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/ProperCond.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/SendConfigJson.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/SortModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/TemplateJsonModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/templatenode/nodejson/TimeConfig.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/time/FlowTimeModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/time/TimeModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/ExecuteModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/ExtraData.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TimeTriggerModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TriggerDataFo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TriggerDataModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TriggerEventModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TriggerInfoListModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TriggerInfoModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TriggerModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TriggerPagination.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TriggerTaskModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/trigger/TriggerWebHookInfoVo.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/EventModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/FlowConstant.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/FlowContextHolder.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/FlowEventHolder.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/FlowNature.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/FlowOperatorHolder.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/FlowOperatorModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/FlowStatusHolder.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/SystemAuditHolder.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/SystemAuditModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/TriggerHolder.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/util/TriggerRecordHolder.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/Definitions.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/FlowXmlUtil.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/XmlModel.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/diagram/Bounds.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/diagram/Diagram.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/diagram/Edge.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/diagram/Label.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/diagram/Plane.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/diagram/Shape.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/diagram/Waypoint.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/process/Condition.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/process/Event.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/process/GateWay.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/process/Group.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/process/Process.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/process/Sequence.java jnpf-flowable/jnpf-flowable-entity/src/main/java/jnpf/flowable/model/xml/process/Task.java jnpf-flowable/jnpf-flowable-server/pom.xml jnpf-flowable/jnpf-flowable-server/src/main/java/jnpf/JnpfFlowableApplication.java jnpf-flowable/jnpf-flowable-server/src/main/resources/application.yml jnpf-flowable/jnpf-flowable-server/src/main/resources/logback-spring.xml jnpf-flowable/pom.xml jnpf-gateway/pom.xml jnpf-gateway/src/main/java/com/github/xiaoymin/knife4j/spring/gateway/utils/ServiceUtils.java jnpf-gateway/src/main/java/jnpf/JnpfGatewayApplication.java jnpf-gateway/src/main/java/jnpf/config/CorsConfig.java jnpf-gateway/src/main/java/jnpf/config/GatewayConfig.java jnpf-gateway/src/main/java/jnpf/config/Knife4jConfig.java jnpf-gateway/src/main/java/jnpf/config/MyLoadBalancerConfig.java jnpf-gateway/src/main/java/jnpf/config/MyServiceChangeListener.java jnpf-gateway/src/main/java/jnpf/config/MyServiceDiscoverHandler.java jnpf-gateway/src/main/java/jnpf/filter/AuthFilter.java jnpf-gateway/src/main/java/jnpf/filter/ContextGatewayFilterForReactor.java jnpf-gateway/src/main/java/jnpf/filter/MvcSecurityConfig.java jnpf-gateway/src/main/java/jnpf/handler/FileChunkLoadBalancerHandler.java jnpf-gateway/src/main/java/jnpf/handler/GatewayHandler.java jnpf-gateway/src/main/java/jnpf/handler/ILoadBalancerHandler.java jnpf-gateway/src/main/java/jnpf/handler/MyGatewayServiceExcludeService.java jnpf-gateway/src/main/java/jnpf/handler/MyLoadBalancer.java jnpf-gateway/src/main/java/jnpf/handler/SentinelHandler.java jnpf-gateway/src/main/java/jnpf/util/GatewayUtil.java jnpf-gateway/src/main/java/jnpf/util/Ip2RegionUtil.java jnpf-gateway/src/main/java/jnpf/util/ReactorUtil.java jnpf-gateway/src/main/resources/application.yml jnpf-gateway/src/main/resources/logback-spring.xml jnpf-lims/jnpf-lims-api/pom.xml jnpf-lims/jnpf-lims-api/src/main/java/jnpf/CustomerApi.java jnpf-lims/jnpf-lims-api/src/main/java/jnpf/EmailApi.java jnpf-lims/jnpf-lims-api/src/main/java/jnpf/fallback/EmailApiFallback.java jnpf-lims/jnpf-lims-api/src/main/java/jnpf/utils/LimsFeignName.java jnpf-lims/jnpf-lims-biz/pom.xml jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/config/FeignClientJacksonConfig.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/config/JacksonConfig.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/JianyanXiangmuGuanliMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsAdDiaochabiaoMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsApplyTestItemMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsApplyTestMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsBaogaoShouquanMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsBaseUserMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsBizLogMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsBusinessReminderMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiAlertMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiChukuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiChukuShenqingMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiChukuShenqingZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiChukuZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiJiechuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiLogMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiRukuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiRukuYanshouMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiRukuZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiXiaohuiShenqingMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiXiaohuiShenqingZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHaocaiYanshouShenqingMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingCaiyangdianMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingCaiyangdianZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingJianceBaogaoMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingJianceBaogaoZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingJianceBiaozhunMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingJianceJihuaMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingJianceJihuaZhongzhiMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingJianceJihuaZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingJianceJiluMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingJianceJiluZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingJianceYijuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsHuanjingQuyuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsJianceTaskRemindMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsJianyanJinduMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsJianyanXiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsJieyangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsJieyangTuihuiMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsLiuyangBiangengShenqingMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsLiuyangJihuaBiangengShenqingMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsLiuyangJihuaBiangengShenqingZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsLiuyangJihuaMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsLiuyangJihuaZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsLiuyangJiluMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsMessageAudienceMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsMessageConfigMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsMessageDeliveryMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsMessageEventMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsOosOotDiaochabiaoMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsPrintTemplateMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsQingyandanJiancexiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsQingyandanMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsQingyandanReportMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsQingyandanYichangShenqingMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsQingyandanYichangShenqingZiduanMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsQualityManagerItemMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsQualityManagerMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsQuyangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSampleInfoMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSamplingRecordMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiJiliangQijuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiJiaozhunNeibuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiJiaozhunWaibuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiJiliangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiPeijianGenghuanMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiPeijianGenghuanZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiQijuBaofeiMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiWeibaoMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiWeibaoZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiXinxiMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsShebeiYiqiYanzhengMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSignMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtKanbanMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQingyanXiangmuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQingyanXiangmuZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQushiMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangJihuaMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangJihuaRenwuJianyanXiangKuaizhaoMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangJihuaRenwuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangJihuaRenwuPeizhiKuaizhaoMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangJihuaRenwuRenyuanKuaizhaoMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangJihuaRenwuShijianMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangJihuaZhongzhiMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangJihuaZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangZhongzhiShenqingMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangZhongzhiShenqingZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsSxtQuyangdianMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsTestTaskMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsUserSignPasswordMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsWendingxingChurukuMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsWendingxingChurukuZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsWendingxingFenxiMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsWendingxingKaocaJihuaBiangengMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsWendingxingKaocaJihuaBiangengZhouqiMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsWendingxingKaocaJihuaMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsWendingxingKaocaJihuaZhouqiMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsWendingxingWanchengMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LimsWendingxingXiaohuiShenqingMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/LiuyangWendingxingZhouqiZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/WorkflowTemplateMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/YangpinGuanliMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/ZhiliangBiaozhunGuanliMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsMapper/ZhiliangBiaozhunGuanliZixiangMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsAdDiaochabiaoService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsApplyTestItemService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsApplyTestService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsBaogaoShouquanService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsBizLogService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsBusinessReminderService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsFlowTemplateResolver.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsHaocaiAlertService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsHaocaiService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsHuanjingJianceService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsJianceTaskRemindService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsJianceTaskService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsJianyanJinduService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsJianyanXiangService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsJieyangService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsJieyangTuihuiService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsLiuyangBiangengShenqingService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsLiuyangJihuaBiangengShenqingService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsLiuyangJihuaBiangengShenqingZixiangService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsLiuyangJihuaService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsLiuyangJihuaZixiangService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsLiuyangJiluService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsMessageService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsOosOotDiaochabiaoService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsPrintTemplateService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsQingyandanJiancexiangService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsQingyandanReportService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsQingyandanService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsQingyandanYichangService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsQualityManagerItemService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsQualityManagerService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsQuyangService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsRecipientResolver.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsSampleInfoService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsSamplingRecordService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsShebeiYiqiJiaozhunNeibuService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsShebeiYiqiJiaozhunWaibuService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsShebeiYiqiPeijianGenghuanService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsShebeiYiqiQijuBaofeiService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsShebeiYiqiWeibaoService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsShebeiYiqiXinxiService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsSignService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsSxtJihuaService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsSxtKanbanService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsSxtQingyanXiangmuService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsSxtQushiService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsSxtQuyangJihuaRenwuService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsTestTaskService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsWendingxingChurukuService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsWendingxingFenxiService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsWendingxingKaocaJihuaZhouqiService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsWendingxingWanchengService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsWendingxingXiaohuiShenqingService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LimsYichangDiaochaService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/LiuyangWendingxingZhouqiZixiangService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/ZhiliangBiaozhunGuanliService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/ZhiliangBiaozhunGuanliZixiangService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/client/LimsMessageClient.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/event/LimsMessageDispatchEvent.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/event/LimsSxtPlanApprovedEvent.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsAdDiaochabiaoServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsApplyTestItemServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsApplyTestServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsBaogaoShouquanServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsBizLogServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsBusinessReminderServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsHaocaiAlertServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsHaocaiServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsHuanjingJianceServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsJianceTaskRemindServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsJianceTaskServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsJianyanJinduServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsJianyanXiangServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsJieyangServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsJieyangTuihuiServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsLiuyangBiangengShenqingServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsLiuyangJihuaBiangengShenqingServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsLiuyangJihuaBiangengShenqingZixiangServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsLiuyangJihuaServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsLiuyangJihuaZixiangServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsLiuyangJiluServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsMessageAfterCommitPublisher.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsMessageConfigValidator.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsMessageLedgerService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsMessageRetryService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsMessageServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsOosOotDiaochabiaoServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsOperationalMessagePublisher.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsPrintTemplateServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsQingyandanJiancexiangServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsQingyandanReportServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsQingyandanServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsQingyandanYichangServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsQualityManagerItemServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsQualityManagerServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsQuyangServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsRecipientResolverImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsSampleInfoServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsSamplingRecordServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsShebeiYiqiJiaozhunNeibuServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsShebeiYiqiJiaozhunWaibuServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsShebeiYiqiPeijianGenghuanServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsShebeiYiqiQijuBaofeiServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsShebeiYiqiWeibaoServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsShebeiYiqiXinxiServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsSignServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsSxtJihuaServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsSxtKanbanServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsSxtQingyanXiangmuServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsSxtQushiServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsSxtQuyangJihuaRenwuServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsSxtRenwuQingyandanZhuangtaiTongbuService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsTestTaskServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsWendingxingChurukuServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsWendingxingFenxiServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsWendingxingKaocaJihuaZhouqiServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsWendingxingWanchengServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsWendingxingXiaohuiShenqingServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LimsYichangDiaochaServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/LiuyangWendingxingZhouqiZixiangServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/ZhiliangBiaozhunGuanliServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/impl/ZhiliangBiaozhunGuanliZixiangServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/listener/LimsMessageDispatchListener.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/listener/LimsSxtPlanApprovedEventListener.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/listener/LimsWaterSamplingMessageListener.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/print/HuanjingChenjiangjunRenderer.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/print/HuanjingFuyoujunRenderer.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/print/HuanjingWeishengwuRenderer.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/print/HuanjingXuanfuliziRenderer.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/print/JianyanBaogaoRenderer.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/print/PrintRendererRegistry.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/print/PrintTemplateRenderer.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/print/WendingxingKaocaHuizongRenderer.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/support/LimsAuditTargetIds.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/support/LimsSignUsageSupport.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/support/LimsSxtFlowStatusResolver.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/support/LimsSxtPlanStatusResolver.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/support/LimsSxtQushiTrendSupport.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/support/LimsSxtQuyangJihuaRenwuPlanner.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/support/LimsSxtQuyangJihuaRenwuStatus.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/support/LimsWendingxingFenxiSupport.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/limsService/support/LimsWendingxingXiaohuiFlowStatusResolver.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/BigDataMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/CustomerMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/EmailConfigMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/EmailReceiveMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/EmailSendMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/EmployeeMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/LeaveApplyMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/OrderEntryMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/OrderMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/OrderReceivableMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/ProductEntryMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/ProductGoodsMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/ProductMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/ProductclassifyMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/ProjectGanttMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/SalesOrderEntryMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/SalesOrderMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/TableExampleMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/WorkLogMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/mapper/WorkLogShareMapper.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/BigDataService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/CustomerService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/EmailConfigService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/EmailReceiveService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/EmailSendService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/EmployeeService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/LeaveApplyService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/OrderEntryService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/OrderReceivableService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/OrderService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/ProductEntryService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/ProductGoodsService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/ProductService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/ProductclassifyService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/ProjectGanttService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/SalesOrderEntryService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/SalesOrderService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/TableExampleService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/WorkLogService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/WorkLogShareService.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/BigDataServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/CustomerServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/EmailConfigServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/EmailReceiveServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/EmailSendServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/EmployeeServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/LeaveApplyServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/OrderEntryServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/OrderReceivableServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/OrderServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/ProductEntryServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/ProductGoodsServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/ProductServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/ProductclassifyServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/ProjectGanttServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/SalesOrderEntryServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/SalesOrderServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/TableExampleServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/WorkLogServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/service/impl/WorkLogShareServiceImpl.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/util/LimsSxtJihuaUtil.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/util/OptimizeUtil.java jnpf-lims/jnpf-lims-biz/src/main/java/jnpf/util/R.java jnpf-lims/jnpf-lims-biz/src/main/resources/mapper/BigDataMapper.xml jnpf-lims/jnpf-lims-biz/src/main/resources/mapper/LimsJianyanJinduMapper.xml jnpf-lims/jnpf-lims-biz/src/main/resources/mapper/LimsSxtKanbanMapper.xml jnpf-lims/jnpf-lims-biz/src/main/resources/mapper/LimsSxtQushiMapper.xml jnpf-lims/jnpf-lims-biz/src/main/resources/mapper/LimsSxtQuyangJihuaMapper.xml jnpf-lims/jnpf-lims-biz/src/main/resources/mapper/LimsSxtQuyangJihuaRenwuMapper.xml jnpf-lims/jnpf-lims-biz/src/main/resources/mapper/LimsSxtQuyangdianMapper.xml jnpf-lims/jnpf-lims-biz/src/main/resources/mapper/LimsWendingxingFenxiMapper.xml jnpf-lims/jnpf-lims-biz/src/main/resources/mapper/LimsWendingxingWanchengMapper.xml jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/impl/LimsJianceTaskBatchValidationProbe.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/impl/LimsMessageFailurePolicyChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/impl/LimsSxtQuyangZhongzhiShenqingExecutionChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/AuditLogEntityColumnProbe.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsMessageContractChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSignUsageSupportProbe.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSignUsageWiringProbe.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtFlowStatusResolverChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtKanbanApiChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtPlanStatusResolverChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtQushiApiChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtQushiTrendSupportChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtQuyangJihuaRenwuExecutionChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtQuyangJihuaRenwuPlannerChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtQuyangJihuaRenwuSchemaChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtQuyangJihuaRenwuServiceChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtQuyangJihuaRenwuStatusChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtQuyangZhongzhiPendingChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtQuyangZhongzhiShenqingFlowStatusChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsSxtRoutePrefixChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsWaterSamplingMessageWiringChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsWendingxingFenxiApiChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsWendingxingFenxiSupportChecks.java jnpf-lims/jnpf-lims-biz/src/test/java/jnpf/limsService/support/LimsWendingxingXiaohuiFlowStatusChecks.java jnpf-lims/jnpf-lims-controller/pom.xml jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/BarCodeController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/BigDataController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/CustomerController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/DocumentEditController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/DocumentPreviewController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/EmailController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/EmployeeController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/LeaveApplyController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/OrderController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/ProductController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/ProductGoodsController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/ProductclassifyController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/ProjectGanttController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/ReportManageController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/SalesOrderController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/TableExampleController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/controller/WorkLogController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/AppLimsController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/CommonController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsBaogaoController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsBizConfigController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsBizLogController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsFlowController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsHaocaiController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsHuanjingJianceController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsJianceTaskController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsJianyanJinduController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsJieyangController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsLiuyangController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsMessageReminderController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsPrintController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsQingyandanController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsQingyandanYichangController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsQuyangController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsShebeiYiqiController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsSignController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsSxtFlowStatusController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsSxtKanbanController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsSxtQingyanXiangmuController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsSxtQushiController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsSxtQuyangZhongzhiController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsWendingxingChurukuController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsWendingxingFenxiController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsWendingxingKaocaJianceZhixingController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/LimsWendingxingKaocaJihuaController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/WorkflowTableConfigTestController.java jnpf-lims/jnpf-lims-controller/src/main/java/jnpf/limsController/export/LimsWendingxingFenxiWorkbookBuilder.java jnpf-lims/jnpf-lims-controller/src/test/java/jnpf/limsController/export/LimsWendingxingFenxiWorkbookBuilderTest.java jnpf-lims/jnpf-lims-entity/pom.xml jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/BigDataEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/CustomerEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/EmailReceiveEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/EmailSendEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/EmployeeEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/LeaveApplyEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/OrderEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/OrderEntryEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/OrderReceivableEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/ProductEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/ProductEntryEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/ProductGoodsEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/ProductclassifyEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/ProjectGanttEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/SalesOrderEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/SalesOrderEntryEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/TableExampleEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/WorkLogEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/entity/WorkLogShareEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/enums/LimsSampleEnum.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/BaogaoShenheJibie.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/BaogaoStatus.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/BizEnabledEnum.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/BizStatus.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/FenjianZhuangtai.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/JianceFangshi.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/JianyanJielun.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/JianyanStatus.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/JianyanXiangmuGuanliEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/JieyangStatus.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/JsonNodeAsStringDeserializer.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsAdDiaochabiaoEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsApplyTestEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsApplyTestItemEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsBaogaoShouquanEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsBaogaoSubmitParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsBizLogEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsConsumableExpiryReminderRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsEnvironmentSamplingReminderRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsEquipmentCalibrationReminderRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsFenjianItem.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiAlertRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiChukuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiChukuShenqingEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiChukuShenqingZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiChukuZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiJiechuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiLogEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiRukuBatchParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiRukuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiRukuSubmitResult.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiRukuYanshouEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiRukuZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiXiaohuiShenqingEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiXiaohuiShenqingZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiYanshouLingquParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHaocaiYanshouShenqingEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingCaiyangdianEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingCaiyangdianVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingCaiyangdianZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceActionParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceBaogaoEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceBaogaoZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceBiaozhunEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceJihuaEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceJihuaZhongzhiEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceJihuaZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceJiluEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceJiluZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceKanbanItemVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceKanbanRowVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceQushiDataVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceQushiParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceQushiRecordVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceQushiStandardVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceQuyangParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingJianceYijuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsHuanjingQuyuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianceTaskAcceptParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanAssignItem.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanAssignParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanAuditFailParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanAuditItem.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanAuditOutcome.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanAuditParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanJinduDetailVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanJinduQingyandanRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanResultItem.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanResultParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJianyanXiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJieyangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJieyangTuihuiEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsJieyangTuihuiParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsLiuyangBiangengShenqingEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsLiuyangJihuaBiangengCalculateParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsLiuyangJihuaBiangengShenqingEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsLiuyangJihuaBiangengShenqingZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsLiuyangJihuaCalculateParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsLiuyangJihuaCalculateResult.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsLiuyangJihuaEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsLiuyangJihuaZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsLiuyangJiluEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsMessageConfigRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsMessageDeliveryEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsMessageDeliveryStatus.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsMessageEvent.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsMessageEventEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsMessageEventStatus.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsMessageLedger.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsMessageRequest.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsMessageSendResult.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsModifySignPasswordParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsOosOotDiaochabiaoEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsPrintTemplateEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsQingyandanEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsQingyandanJiancexiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsQingyandanReportEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsQingyandanYichangChuliParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsQingyandanYichangShenqingEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsQingyandanYichangShenqingZiduanEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsQualityManagerEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsQualityManagerItemEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsQuyangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsRecipientRef.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsRecipientType.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsRetainDestructionReminderRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsRetainObservationReminderRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSampleInfoEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSamplingRecordEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiJiliangQijuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiJiaozhunNeibuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiJiaozhunNeibuParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiJiaozhunWaibuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiJiaozhunWaibuParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiJiliangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiPeijianGenghuanEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiPeijianGenghuanParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiPeijianGenghuanZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiQijuBaofeiEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiWeibaoEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiWeibaoParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiWeibaoZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiXinxiEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsShebeiYiqiYanzhengEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSignEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSignUsageTarget.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSignVerifyParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsStabilityCompletionRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsStabilityPeriodReminderRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSubmitResultOutcome.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtFlowEventParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtJihuaQuyangdianParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtKanbanItemVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtKanbanOptionVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtKanbanOptionsVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtKanbanRowVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQingyanXiangmuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQingyanXiangmuParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQingyanXiangmuZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQushiDataVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQushiJianyanXiangmuVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQushiOptionVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQushiOptionsVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQushiParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQushiQuyangdianVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQushiRecordVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQushiStandardVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangJihuaEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangJihuaRenwuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangJihuaRenwuJianyanXiangKuaizhaoEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangJihuaRenwuPeizhiKuaizhaoEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangJihuaRenwuRenyuanKuaizhaoEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangJihuaRenwuShijianEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangJihuaZhongzhiEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangJihuaZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangZhongzhiShenqingEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangZhongzhiShenqingZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsSxtQuyangdianEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsTestTaskEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsUserSignPasswordEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWaterSamplingMessageEvent.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingChurukuEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingChurukuSubmitParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingChurukuZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingFenxiBatchParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingFenxiCompletenessRecordVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingFenxiCompletenessVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingFenxiDataVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingFenxiExpectedPeriodVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingFenxiOptionVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingFenxiOptionsVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingFenxiParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingFenxiRowVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingJianceZhixingVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingJianyanCountRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingJihuaZhixingCountRow.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingKaocaJihuaBiangengEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingKaocaJihuaBiangengZhouqiEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingKaocaJihuaEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingKaocaJihuaZhouqiEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LimsWendingxingXiaohuiShenqingEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LiuyangStatus.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/LiuyangWendingxingZhouqiZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/QingyanFenlei.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/RenwuTihuanJieguo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/RenwuTihuanPeizhiMingling.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/SignOpType.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/StringOrArrayDeserializer.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/YangpinGuanliEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/YichangDiaochaLeixing.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/ZhiliangBiaozhunGuanliEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/ZhiliangBiaozhunGuanliZixiangEntity.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/bizlog/BizLogAction.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/bizlog/BizLogType.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/bizlog/LimsBizLogQueryParam.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/bizlog/LimsBizLogVo.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsEntity/bizlog/VisualLogModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsModel/LimsApplyTestModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsModel/LimsSxtQuyangdianKuaizhaoModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/limsModel/LimsSxtQuyangdianVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/EmployeeModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/ReportManageModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/bidata/BigBigDataListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/customer/CustomerCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/customer/CustomerInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/customer/CustomerListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/customer/CustomerUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailCheckForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailCofigInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailDraftListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailInfoModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailReceiveListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailSendCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailSentListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/EmailStarredListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/email/PaginationEmail.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/emailconfig/EmailConfigCheckForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/emailconfig/EmailConfigSavaForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/employee/EmployeeCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/employee/EmployeeExportVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/employee/EmployeeImportVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/employee/EmployeeInfoModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/employee/EmployeeInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/employee/EmployeeListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/employee/EmployeeUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/employee/PaginationEmployee.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/leaveapply/LeaveApplyForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/leaveapply/LeaveApplyInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderCustomerVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderEntryListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderEntryModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderExportModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderGoodsVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderInfoOrderEntryModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderInfoOrderReceivableModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderReceivableListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/OrderReceivableModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/order/PaginationOrder.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/product/ProductCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/product/ProductInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/product/ProductListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/product/ProductPagination.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/product/ProductUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productclassify/ProductclassifyCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productclassify/ProductclassifyInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productclassify/ProductclassifyListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productclassify/ProductclassifyModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productclassify/ProductclassifyUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productentry/ProductEntryInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productentry/ProductEntryListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productentry/ProductEntryMdoel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productgoods/ProductGoodsCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productgoods/ProductGoodsInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productgoods/ProductGoodsListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productgoods/ProductGoodsPagination.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/productgoods/ProductGoodsUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttManagerIModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttTaskInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttTaskTreeModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttTaskTreeVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttTreeModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttTreeVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttTsakCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttTsakUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/projectgantt/ProjectGanttUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/salesorder/SalesOrderEntryEntityInfoModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/salesorder/SalesOrderForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/salesorder/SalesOrderInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/schedule/ScheduleCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/schedule/ScheduleInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/schedule/ScheduleListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/schedule/ScheduleTime.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/schedule/ScheduleTimes.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/schedule/ScheduleUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/PaginationTableExample.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleCityListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleIndustryListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleListAllVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleListDTO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleRowUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleSignUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleTreeModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/TableExampleUpForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/postil/PostilInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/postil/PostilModel.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/tableexample/postil/PostilSendForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/worklog/WorkLogCrForm.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/worklog/WorkLogInfoVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/worklog/WorkLogListVO.java jnpf-lims/jnpf-lims-entity/src/main/java/jnpf/model/worklog/WorkLogUpForm.java jnpf-lims/jnpf-lims-server/pom.xml jnpf-lims/jnpf-lims-server/src/main/java/jnpf/JnpfLimsApplication.java jnpf-lims/jnpf-lims-server/src/main/resources/application.yml jnpf-lims/jnpf-lims-server/src/main/resources/logback-spring.xml jnpf-lims/pom.xml jnpf-message/jnpf-message-api/pom.xml jnpf-message/jnpf-message-api/src/main/java/jnpf/message/MessageTemplateConfigApi.java jnpf-message/jnpf-message-api/src/main/java/jnpf/message/NoticeApi.java jnpf-message/jnpf-message-api/src/main/java/jnpf/message/SendMsgConfigApi.java jnpf-message/jnpf-message-api/src/main/java/jnpf/message/SentMessageApi.java jnpf-message/jnpf-message-api/src/main/java/jnpf/message/UserDeviceApi.java jnpf-message/jnpf-message-api/src/main/java/jnpf/message/fallback/MessageTemplateConfigApiFallback.java jnpf-message/jnpf-message-api/src/main/java/jnpf/message/fallback/NoticeApiFallback.java jnpf-message/jnpf-message-api/src/main/java/jnpf/message/fallback/SendMsgConfigApiFallback.java jnpf-message/jnpf-message-api/src/main/java/jnpf/message/fallback/SentMessageApiFallback.java jnpf-message/jnpf-message-api/src/main/java/jnpf/message/fallback/UserDeviceApiFallback.java jnpf-message/jnpf-message-biz/pom.xml jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/AccountConfigMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/ImContentMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/ImReplyMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/MessageMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/MessageMonitorMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/MessageTemplateConfigMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/MessagereceiveMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/SendConfigTemplateMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/SendMessageConfigMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/ShortLInkMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/SmsFieldMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/TemplateParamMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/UserDeviceMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/mapper/WechatUserMapper.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/AccountConfigService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/ImContentService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/ImReplyService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/MessageMonitorService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/MessageService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/MessageTemplateConfigService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/MessagereceiveService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/SendConfigTemplateService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/SendMessageConfigService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/ShortLinkService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/SmsFieldService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/TemplateParamService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/UserDeviceService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/WechatUserService.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/AccountConfigServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/ImContentServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/ImReplyServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/MessageMonitorServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/MessageServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/MessageTemplateConfigServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/MessagereceiveServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/SendConfigTemplateServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/SendMessageConfigServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/ShortLinkServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/SmsFieldServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/TemplateParamServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/UserDeviceServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/service/impl/WechatUserServiceImpl.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/ConnectionType.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/EmailUtil.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/JnpfMessageUtil.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/MenuMessageUtil.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/MessageChannelType.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/MessageParameterEnum.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/OnlineUserModel.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/OnlineUserProvider.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/PushMessageUtil.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/SendFlowMsgUtil.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/SendMessageTypeEnum.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/SentMessageUtil.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/TestSendConfigUtil.java jnpf-message/jnpf-message-biz/src/main/java/jnpf/message/util/WebHookUtil.java jnpf-message/jnpf-message-biz/src/main/resources/mapper/ImContentMapper.xml jnpf-message/jnpf-message-biz/src/main/resources/mapper/ImReplyMapper.xml jnpf-message/jnpf-message-biz/src/main/resources/mapper/MessageMapper.xml jnpf-message/jnpf-message-controller/pom.xml jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/config/WebsocketConfig.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/AccountConfigController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/ImReplyController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/MessageMonitorController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/MessageTemplateConfigController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/NoticeController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/SendMessageConfigController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/SentMessageController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/ShortLinkController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/UserDeviceController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/WeChatEnterpriseFunctionController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/controller/WxGZHFunctionController.java jnpf-message/jnpf-message-controller/src/main/java/jnpf/message/websocket/WebSocket.java jnpf-message/jnpf-message-entity/pom.xml jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/AccountConfigEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/ImContentEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/ImReplyEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/MessageEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/MessageMonitorEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/MessageReceiveEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/MessageTemplateConfigEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/SendConfigTemplateEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/SendMessageConfigEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/ShortLinkEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/SmsFieldEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/TemplateParamEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/UserDeviceEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/entity/WechatUserEntity.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/enums/MessageTypeEnum.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/EmailModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/ImReplyListModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/ImReplyListVo.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/ImReplySavaModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/ImUnreadNumModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/MessageFlowForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/MessageInfoVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/MessageNoticeVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/MessageRecordForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/NoticeCrForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/NoticeInfoVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/NoticeModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/NoticePagination.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/NoticeUpForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/NoticeVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/PaginationMessage.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/SentFlowMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/SentMessageForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/SentMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/SmsModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/SynThirdInfoVo.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/WxgzhMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/accountconfig/AccountConfigForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/accountconfig/AccountConfigInfoVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/accountconfig/AccountConfigListVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/accountconfig/AccountConfigPagination.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/imrecord/PaginationImRecordModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/mail/MailAccount.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/mail/MailFile.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/mail/MailModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/mail/Pop3Util.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/mail/PopMailModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/mail/ReceiveModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/mail/SmtpUtil.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagedatatype/MessageDataTypeForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagedatatype/MessageDataTypeInfoVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagedatatype/MessageDataTypeListVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagedatatype/MessageDataTypePagination.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagemonitor/MessageMonitorForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagemonitor/MessageMonitorInfoVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagemonitor/MessageMonitorListVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagemonitor/MessageMonitorPagination.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagemonitor/MsgDelForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagetemplateconfig/MessageTemplateConfigForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagetemplateconfig/MessageTemplateConfigInfoVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagetemplateconfig/MessageTemplateConfigListVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagetemplateconfig/MessageTemplateConfigPagination.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagetemplateconfig/SmsFieldModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/messagetemplateconfig/TemplateParamModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/mq/SentMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendconfigrecord/SendConfigRecordForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendconfigrecord/SendConfigRecordInfoVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendconfigrecord/SendConfigRecordListVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendconfigrecord/SendConfigRecordPagination.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendmessageconfig/MsgTemplateJsonModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendmessageconfig/SendConfigListVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendmessageconfig/SendConfigTemplateModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendmessageconfig/SendConfigTestResultModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendmessageconfig/SendMessageConfigForm.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendmessageconfig/SendMessageConfigInfoVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendmessageconfig/SendMessageConfigListVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/sendmessageconfig/SendMessageConfigPagination.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/MessageListVo.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/PaginationMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/SendMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/model/MessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/onclose/OnCloseModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/onconnettion/OnConnectionModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/onconnettion/OnLineModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/receivemessage/ReceiveMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/receivemessage/ReceiveMessageVO.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/savafile/ImageMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/savafile/MessageTypeModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/savafile/VoiceMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/model/websocket/savamessage/SavaMessageModel.java jnpf-message/jnpf-message-entity/src/main/java/jnpf/message/util/SynThirdConsts.java jnpf-message/jnpf-message-server/pom.xml jnpf-message/jnpf-message-server/src/main/java/jnpf/JnpfMessageApplication.java jnpf-message/jnpf-message-server/src/main/resources/application.yml jnpf-message/jnpf-message-server/src/main/resources/logback-spring.xml jnpf-message/pom.xml jnpf-oauth/jnpf-oauth-api/pom.xml jnpf-oauth/jnpf-oauth-api/src/main/java/jnpf/oauth/AuthApi.java jnpf-oauth/jnpf-oauth-api/src/main/java/jnpf/oauth/fallback/AuthApiFallback.java jnpf-oauth/jnpf-oauth-api/src/main/java/jnpf/util/AuthUtil.java jnpf-oauth/jnpf-oauth-biz/pom.xml jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/granter/ImplicitTokenGranter.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/granter/JnpfOfficialTokenGranter.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/granter/PasswordTokenGranter.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/granter/ScanCodeTokenGranter.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/granter/SocialsTokenGranter.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/granter/TempUserTokenGranter.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/granter/TenantSaasGranter.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/listener/InterfacePermissionListener.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/service/AuthService.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/service/impl/AuthServiceImpl.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/service/impl/LoginServiceImpl.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/service/impl/UserDetailsByUserAccountServiceImpl.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/service/impl/UserDetailsByUserIdServiceImpl.java jnpf-oauth/jnpf-oauth-biz/src/main/java/jnpf/utils/LoginHolder.java jnpf-oauth/jnpf-oauth-controller/pom.xml jnpf-oauth/jnpf-oauth-controller/src/main/java/jnpf/controller/LoginController.java jnpf-oauth/jnpf-oauth-entity/pom.xml jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/entity/TenantEntity.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/BuildUserCommonInfoModel.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/JoinTenantModel.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/LoginConfigModel.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/LoginForm.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/LoginModel.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/LoginVO.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/SocialUnbindModel.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/app/AppDataModel.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/app/AppFlowFormModel.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/app/AppInfoModel.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/app/AppMenuModel.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/app/AppUserVO.java jnpf-oauth/jnpf-oauth-entity/src/main/java/jnpf/model/password/PassContextHolder.java jnpf-oauth/jnpf-oauth-server/pom.xml jnpf-oauth/jnpf-oauth-server/src/main/java/jnpf/JnpfOauthApplication.java jnpf-oauth/jnpf-oauth-server/src/main/java/jnpf/listener/JnpfListener.java jnpf-oauth/jnpf-oauth-server/src/main/java/jnpf/listener/LoginListener.java jnpf-oauth/jnpf-oauth-server/src/main/resources/application.yml jnpf-oauth/jnpf-oauth-server/src/main/resources/logback-spring.xml jnpf-oauth/pom.xml jnpf-permission/jnpf-permission-api/pom.xml jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/AuthorizeApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/CodeNumApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/GroupApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/OrganizeApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/PermissionGroupApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/PositionApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/RoleApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/RoleRelationApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/SignApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/SocialsUserApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/UserApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/UserRelationApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/UserSettingApi.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/AuthorizeApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/CodeNumApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/GroupApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/OrganizeApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/PermissionGroupApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/PositionApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/RoleApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/RoleRelationApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/SignApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/SocialsUserApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/UserApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/UserRelationApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/fallback/UserSettingApiFallback.java jnpf-permission/jnpf-permission-api/src/main/java/jnpf/permission/util/CodeNumUtil.java jnpf-permission/jnpf-permission-biz/pom.xml jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/ActionMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/AuthorizeMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/CodeNumMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/ColumnsPurviewMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/GroupMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/OrganizeMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/PermissionGroupMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/PositionMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/RoleMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/RoleRelationMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/SignMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/SocialsUserMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/StandingMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/UserExtraMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/UserMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/UserOldPasswordMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/mapper/UserRelationMapper.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/rest/PullUserUtil.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/ActionService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/AuthorizeService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/CodeNumService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/ColumnsPurviewService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/GroupService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/OrganizeService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/PermissionGroupService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/PositionService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/RoleRelationService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/RoleService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/SignService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/SocialsUserService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/StandingService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/UserExtraService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/UserOldPasswordService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/UserRelationService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/UserService.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/ActionServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/AuthorizeServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/CodeNumServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/ColumnsPurviewServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/GroupServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/OrganizeServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/PermissionGroupServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/PositionServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/RoleRelationServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/RoleServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/SignServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/SocialsUserServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/StandingServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/UserExtraServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/UserInfoServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/UserOldPasswordServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/UserRelationServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/service/impl/UserServiceImpl.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/util/AuthPermUtil.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/util/PermissionUtil.java jnpf-permission/jnpf-permission-biz/src/main/java/jnpf/permission/util/UserUtil.java jnpf-permission/jnpf-permission-biz/src/main/resources/mapper/AuthorizeMapper.xml jnpf-permission/jnpf-permission-biz/src/main/resources/mapper/PositionMapper.xml jnpf-permission/jnpf-permission-biz/src/main/resources/mapper/RoleMapper.xml jnpf-permission/jnpf-permission-biz/src/main/resources/mapper/UserMapper.xml jnpf-permission/jnpf-permission-controller/pom.xml jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/ActionController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/AuthorizeController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/CodeNumController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/GroupController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/OrganizeController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/PermissionGroupController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/PositionController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/RoleController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/RoleRelationController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/SignController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/SocialsUserController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/StandingController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/UserController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/UserRelationController.java jnpf-permission/jnpf-permission-controller/src/main/java/jnpf/permission/controller/UserSettingController.java jnpf-permission/jnpf-permission-entity/pom.xml jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/constant/OrgColumnMap.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/constant/PosColumnMap.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/constant/RoleColumnMap.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/constant/UserColumnMap.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/ActionEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/AuthorizeEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/CodeNumEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/ColumnsPurviewEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/GroupEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/OrganizeEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/PermissionEntityBase.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/PermissionGroupEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/PositionEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/RoleEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/RoleRelationEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/SignEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/SocialsUserEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/StandingEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/UserEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/UserExtraEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/UserOldPasswordEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/entity/UserRelationEntity.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/SynOrganizeDeleteModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/SynOrganizeModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/SynThirdModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/SynThirdQyModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/SynThirdUnifyModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/SynUnifyModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/SysThirdDeleteModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/SystemParamModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/action/ActionForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/action/ActionPagination.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeConditionEnum.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeConditionModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeDataModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeDataReturnModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeDataReturnVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeDataUpForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeDataVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeDataValuesVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeItemObjIdsVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeParam.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeSaveParam.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/AuthorizeVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/ConditionModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/DataValuesQuery.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/OnlineDynamicSqlModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/OtherModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/ResourceVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/authorize/SaveBatchForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/check/CheckResult.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/columnspurview/ColumnsPurviewUpForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/condition/AuthConditionModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/condition/AuthGroup.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/condition/AuthItem.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/condition/AuthItemConfig.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeConditionModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeCrForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeCrModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeDepartCrForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeDepartUpForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeInfoVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeListVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizePagination.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizePositonVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeSelectorVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organize/OrganizeUpForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organizeadministrator/ModuleSelectorModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organizeadministrator/ModuleSelectorVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organizeadministrator/OrganizeAdminIsTratorCrForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organizeadministrator/OrganizeAdministratorCrModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organizeadministrator/OrganizeAdministratorListVo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organizeadministrator/OrganizeAdministratorModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organizeadministrator/OrganizeAdministratorSelectedVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organizeadministrator/OrganizeAdministratorSelectorVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/organizeadministrator/SystemSelectorVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/permission/PermissionModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/permission/PermissionVoBase.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/permissiongroup/PaginationPermissionGroup.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/permissiongroup/PermissionGroupListVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/permissiongroup/PermissionGroupModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/permissiongroup/ViewPermissionsModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/permissiongroup/ViewPermissionsVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PaginationPosition.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PosConModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PosistionCurrentModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PositionCrForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PositionInfoVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PositionListAllVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PositionListVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PositionPagination.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PositionTreeModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PositionUpForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/position/PositionVo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/role/RoleCrForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/role/RoleInfoVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/role/RoleListVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/role/RoleModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/role/RolePagination.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/role/RoleSelectorVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/role/RoleUpForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/rolerelaiton/AddRolesForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/rolerelaiton/RoleListPage.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/rolerelaiton/RoleRelationForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/rolerelaiton/RoleRelationModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/rolerelaiton/RoleRelationOrgVo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/rolerelaiton/RoleRelationPage.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/rolerelaiton/RoleRelationUserVo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/sign/SignForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/sign/SignListVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/socails/SocialsUserInfo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/socails/SocialsUserModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/socails/SocialsUserVo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/standing/StandingActionForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/standing/StandingForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/standing/StandingInfoVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/standing/StandingModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/standing/StandingPagination.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/standing/StandingVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/tenant/JoinCompanyModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/tenant/TenantJoinForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/UserAuthForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/UserIdListVo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/UserInfoModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/UserLogForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/UserLogVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/UserModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/UserRelationIds.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/UserSystemCountModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/UserUpdateModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/WorkHandoverModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserAppDataForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserBatchForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserCrForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserInfoForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserLanguageForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserModifyPasswordForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserResetPasswordForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserSettingForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserThemeForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserUpAdminForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/form/UserUpForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/mod/UserAuthorizeModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/mod/UserByRoleModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/mod/UserConditionModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/mod/UserIdModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/mod/UserIdModelByPage.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/mod/UserImportModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/mod/UserSelectorModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/mod/UserUpModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/mod/UsersByPositionModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/page/PageUser.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/page/PaginationUser.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/page/UserPagination.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/BaseInfoVo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/ImUserListVo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserAdminVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserAllVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserAuthorizeVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserBaseInfoVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserBaseVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserByRoleVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserExportExceptionVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserExportVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserImportVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserInfoVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserListVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserSelectorVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserSubordinateVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/UserVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/user/vo/WorkHandoverVo.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/usergroup/GroupForm.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/usergroup/GroupInfoVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/usergroup/GroupPagination.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/usergroup/GroupSelectorVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/usergroup/GroupTreeModel.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/usergroup/GroupVO.java jnpf-permission/jnpf-permission-entity/src/main/java/jnpf/permission/model/userrelation/UserRelationForm.java jnpf-permission/jnpf-permission-server/pom.xml jnpf-permission/jnpf-permission-server/src/main/java/jnpf/JnpfPermissionApplication.java jnpf-permission/jnpf-permission-server/src/main/java/jnpf/aop/PermissionAdminBase.java jnpf-permission/jnpf-permission-server/src/main/java/jnpf/aop/PermissionRoleAspect.java jnpf-permission/jnpf-permission-server/src/main/java/jnpf/aop/constant/PermissionConstant.java jnpf-permission/jnpf-permission-server/src/main/resources/application.yml jnpf-permission/jnpf-permission-server/src/main/resources/logback-spring.xml jnpf-permission/pom.xml jnpf-platform/jnpf-platform-server/pom.xml jnpf-platform/jnpf-platform-server/src/main/java/jnpf/JnpfPlatformApplication.java jnpf-platform/jnpf-platform-server/src/main/resources/application.yml jnpf-platform/jnpf-platform-server/src/main/resources/logback-spring.xml jnpf-platform/pom.xml jnpf-provider/jnpf-provider-system/pom.xml jnpf-provider/jnpf-provider-system/src/main/java/jnpf/provider/system/LogProvider.java jnpf-provider/pom.xml jnpf-public/jnpf-cloud-base/pom.xml jnpf-public/jnpf-cloud-base/src/main/java/i18n/ClasspathMessageSourceProvider.java jnpf-public/jnpf-cloud-base/src/main/java/jnpf/config/I18nCloudAutoConfiguration.java jnpf-public/jnpf-cloud-base/src/main/resources/META-INF/spring.factories jnpf-public/jnpf-cloud-base/src/main/resources/message.properties jnpf-public/jnpf-cloud-base/src/main/resources/message_en.properties jnpf-public/jnpf-cloud-base/src/main/resources/message_zh_CN.properties jnpf-public/jnpf-cloud-base/src/main/resources/message_zh_TW.properties jnpf-public/jnpf-common-springaop/pom.xml jnpf-public/jnpf-common-springaop/src/main/java/jnpf/DataSourceBindAspect.java jnpf-public/jnpf-common-springaop/src/main/java/jnpf/RequestLogAspect.java jnpf-public/jnpf-common-springaop/src/main/java/jnpf/VisiualOpaAspect.java jnpf-public/jnpf-common-springaop/src/main/java/jnpf/aop/MyFileAspect.java jnpf-public/jnpf-common-springaop/src/main/java/jnpf/database/util/LogWriteUtil.java jnpf-public/jnpf-common-springaop/src/main/java/jnpf/exception/ResultException.java jnpf-public/jnpf-common-springaop/src/main/resources/ip2region/ip2region_v4.xdb jnpf-public/jnpf-common-springaop/src/main/resources/ip2region/ip2region_v6.xdb jnpf-public/jnpf-generater-base/pom.xml jnpf-public/jnpf-generater-base/src/main/java/jnpf/model/FieLdsModelExcel.java jnpf-public/jnpf-generater-base/src/main/java/jnpf/model/GeneraterSwapModel.java jnpf-public/jnpf-generater-base/src/main/java/jnpf/model/QueryAllModel.java jnpf-public/jnpf-generater-base/src/main/java/jnpf/model/QueryModel.java jnpf-public/jnpf-generater-base/src/main/java/jnpf/util/GenUtil.java jnpf-public/jnpf-generater-base/src/main/java/jnpf/util/GeneraterSwapUtil.java jnpf-public/jnpf-generater-base/src/main/java/jnpf/util/QueryUtil.java jnpf-scheduletask/jnpf-scheduletask-api/pom.xml jnpf-scheduletask/jnpf-scheduletask-api/src/main/java/jnpf/scheduletask/ScheduletaskApi.java jnpf-scheduletask/jnpf-scheduletask-api/src/main/java/jnpf/scheduletask/fallback/ScheduletaskApiFallback.java jnpf-scheduletask/jnpf-scheduletask-controller/pom.xml jnpf-scheduletask/jnpf-scheduletask-controller/src/main/java/jnpf/scheduletask/controller/ScheduleTaskController.java jnpf-scheduletask/jnpf-scheduletask-server/pom.xml jnpf-scheduletask/jnpf-scheduletask-server/src/main/java/jnpf/JnpfScheduleTaskApplication.java jnpf-scheduletask/jnpf-scheduletask-server/src/main/resources/application.yml jnpf-scheduletask/jnpf-scheduletask-server/src/main/resources/logback-spring.xml jnpf-scheduletask/jnpf-scheduletask-service/pom.xml jnpf-scheduletask/jnpf-scheduletask-service/src/main/java/jnpf/scheduletask/task/ScheduleTaskHandler.java jnpf-scheduletask/pom.xml jnpf-system/jnpf-system-api/pom.xml jnpf-system/jnpf-system-api/src/main/java/jnpf/base/AiApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/AreaApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/BillRuleApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/DataInterFaceApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/DataModelApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/DataSourceApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/DictionaryDataApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/LogApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/MessageTemplateApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/ModuleApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/ModuleButtonApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/ModuleColumnApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/ModuleDataAuthorizeSchemeApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/ModuleFormApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/ModuleUseNumApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/PrintApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/ScheduleNewApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/SmsTemplateApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/SynThirdInfoApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/SysConfigApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/SystemApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/UserOnlineApi.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/AiApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/AreaApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/BillRuleApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/DataInterFaceApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/DataModelApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/DataSourceApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/DictionaryDataApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/LogApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/MessageTemplateApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/ModuleApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/ModuleButtonApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/ModuleColumnApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/ModuleDataAuthorizeSchemeApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/ModuleFormApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/ModuleUseNumApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/PrintApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/ScheduleNewApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/SmsTemplateApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/SynThirdInfoApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/SysConfigApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/SystemApiFallback.java jnpf-system/jnpf-system-api/src/main/java/jnpf/base/fallback/UserOnlineApiFallback.java jnpf-system/jnpf-system-biz/pom.xml jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/AdvancedQueryMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/AiChatMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/AiHistoryMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/AiMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/BaseComFieldsMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/BaseLangMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/BillRuleMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/CommonWordsMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DataInterfaceLogMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DataInterfaceMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DataInterfaceUserMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DataInterfaceVariateMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DataMapMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DataSetMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DbLinkMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DbTableMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DictionaryDataMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/DictionaryTypeMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/InterfaceOauthMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/LogMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/MessageTemplateMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ModuleButtonMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ModuleColumnMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ModuleDataAuthorizeLinkDataMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ModuleDataAuthorizeMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ModuleDataAuthorizeSchemeMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ModuleDataMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ModuleFormMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ModuleMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ModuleUseNumMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/PrintDevMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/PrintLogMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/PrintVersionMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ProvinceAtlasMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ProvinceMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ScheduleLogMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ScheduleNewMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/ScheduleNewUserMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/SignatureMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/SignatureUserMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/SmsTemplateMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/SynThirdInfoMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/SysconfigMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/SystemMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/SystemTopMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/VisualDataMapMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/mapper/VisualKitMapper.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/AdvancedQueryService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/AiChatService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/AiHistoryService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/AiService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/BaseLangService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/BillRuleService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ComFieldsService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/CommonWordsService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DataInterfaceAuditRecorder.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DataInterfaceLogService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DataInterfaceService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DataInterfaceUserService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DataInterfaceVariateService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DataMapService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DataSetService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DbLinkService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DbSyncService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DbTableService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DictionaryDataService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/DictionaryTypeService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/InterfaceOauthService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/LogService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/MessageTemplateService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ModuleButtonService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ModuleColumnService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ModuleDataAuthorizeLinkDataService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ModuleDataAuthorizeSchemeService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ModuleDataAuthorizeService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ModuleDataService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ModuleFormService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ModuleService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ModuleUseNumService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/PrintDevService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/PrintLogService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/PrintVersionService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ProvinceAtlasService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ProvinceService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ScheduleLogService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ScheduleNewService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/ScheduleNewUserService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/SignatureService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/SignatureUserService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/SmsTemplateService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/SynThirdDingTalkService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/SynThirdInfoService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/SynThirdQyService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/SysconfigService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/SystemService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/SystemTopService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/UserOnlineService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/VisualDataMapService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/VisualKitService.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/AdvancedQueryServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/AiChatServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/AiHistoryServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/AiServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/BaseLangServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/BillRuleServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ComFieldsServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/CommonWordsServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DataInterfaceLogServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DataInterfaceServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DataInterfaceUserServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DataInterfaceVariateServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DataMapServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DataSetServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DbLinkServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DbSyncServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DbTableServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DictionaryDataServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/DictionaryTypeServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/InterfaceOauthServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/LogServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/MessageTemplateServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ModuleButtonServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ModuleColumnServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ModuleDataAuthorizeLinkDataServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ModuleDataAuthorizeSchemeServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ModuleDataAuthorizeServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ModuleDataServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ModuleFormServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ModuleServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ModuleUseNumServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/PrintDevServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/PrintLogServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/PrintVersionServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ProvinceAtlasServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ProvinceServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ScheduleLogServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ScheduleNewServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/ScheduleNewUserServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/SignatureServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/SignatureUserServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/SmsTemplateServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/SynThirdDingTalkServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/SynThirdInfoServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/SynThirdQyServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/SysconfigServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/SystemServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/SystemTopServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/UserOnlineServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/VisualDataMapServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/service/impl/VisualKitServiceImpl.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/AnnotationType.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/DataInterfaceParamUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/MonitorUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/OracleUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/ReportExportUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/SynDingTalkUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/SynQyWebChatUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/SynThirdUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/constant/SystemConstant.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/dataset/DataSetConfigUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/dataset/DataSetConstant.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/dataset/DataSetExecutor.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/dataset/DataSetSwapUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/interfaceutil/InterfaceUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/job/Schedule.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/job/ScheduleConfig.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/job/ScheduleJobUtil.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/result/Impl/AllResultStrategy.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/result/Impl/EvenNumResultStrategy.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/result/Impl/FirstNumDataStrategy.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/result/Impl/LastNumDataStrategy.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/result/Impl/OddNumResultStrategy.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/result/Impl/SpecifiedDataStrategy.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/result/ResultStrategy.java jnpf-system/jnpf-system-biz/src/main/java/jnpf/base/util/visualutil/PubulishUtil.java jnpf-system/jnpf-system-biz/src/main/resources/mapper/CommonwordsMapper.xml jnpf-system/jnpf-system-biz/src/main/resources/mapper/DbBackupMapper.xml jnpf-system/jnpf-system-biz/src/main/resources/mapper/DictionaryDataMapper.xml jnpf-system/jnpf-system-biz/src/main/resources/mapper/PrintLog.xml jnpf-system/jnpf-system-biz/src/main/resources/mapper/SynThirdInfoMapper.xml jnpf-system/jnpf-system-biz/src/main/resources/mapper/SysconfigMapper.xml jnpf-system/jnpf-system-controller/pom.xml jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/AdvancedQueryController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/AiChatController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/AiController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/AreaController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/BaseLangController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/BillRuleController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/CacheManageController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/CommonFieldsController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/CommonWordsController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/DataInterfaceController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/DataInterfaceLogController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/DataInterfaceVariateController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/DataSetController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/DataSyncController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/DbLinkController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/DbTableController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/DictionaryDataController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/DictionaryTypeController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/HomeDataController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/InterfaceOauthController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/LocationController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/LogController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/MessageTemplateController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ModuleButtonController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ModuleColumnController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ModuleController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ModuleDataAuthorizeController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ModuleDataAuthorizeLinkController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ModuleDataAuthorizeSchemeController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ModuleDataController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ModuleFormController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/MonitorController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/OnlineUserController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/PrintDevController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/PrintLogController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ProvinceAtlasController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/ScheduleNewController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/SignatureController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/SmsTemplateController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/SynThirdInfoController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/SysConfigController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/SystemController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/TenantController.java jnpf-system/jnpf-system-controller/src/main/java/jnpf/base/controller/VisualKitController.java jnpf-system/jnpf-system-dubboservice/pom.xml jnpf-system/jnpf-system-dubboservice/src/main/java/jnpf/provider/impl/LogProviderImpl.java jnpf-system/jnpf-system-entity/pom.xml jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/AdvancedQueryEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/AiChatEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/AiEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/AiHistoryEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/BaseLangEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/BillRuleEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ComFieldsEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/CommonWordsEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/DataInterfaceEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/DataInterfaceLogEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/DataInterfaceUserEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/DataInterfaceVariateEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/DataSetEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/DbBackupEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/DictionaryDataEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/DictionaryTypeEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/EmailConfigEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/InterfaceOauthEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/LogEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/MessageTemplateEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ModuleButtonEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ModuleColumnEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ModuleDataAuthorizeEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ModuleDataAuthorizeLinkEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ModuleDataAuthorizeSchemeEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ModuleDataEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ModuleEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ModuleFormEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ModuleUserNumEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/OperatorRecordEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/PrintDevEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/PrintLogEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/PrintVersionEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ProvinceAtlasEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ProvinceEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ScheduleLogEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ScheduleNewEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/ScheduleNewUserEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/SignatureEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/SignatureUserEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/SmsTemplateEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/SynThirdInfoEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/SysConfigEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/SystemEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/SystemTopEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/VisualDataMapEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/entity/VisualKitEntity.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/AppAuthorizationModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/AppAuthorizeVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/UserAuthorize.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/UserOnlineModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/UserOnlineVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/advancedquery/AdvancedQueryListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/advancedquery/AdvancedQuerySchemeForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/ai/AICrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/ai/AiChatVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/ai/AiForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/ai/AiHisVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/ai/AiInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/ai/AiListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/ai/AiPagination.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/ai/AiParam.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/ai/AiUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemApiByIdsModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemApiListModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemApiModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemBaeModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemCrModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemPageVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemTopModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemUpModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/base/SystemVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/billrule/BillRuleCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/billrule/BillRuleInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/billrule/BillRuleListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/billrule/BillRulePagination.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/billrule/BillRuleUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ButtonListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ButtonModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ButtonTreeListModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ButtonTreeListSelectVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ButtonTreeListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ButtonVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ModuleButtonCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ModuleButtonInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ModuleButtonModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/button/ModuleButtonUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/cache/CacheManageInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/cache/CacheManageListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/cache/PaginationCacheManage.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/column/ColumnBatchForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/column/ColumnListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/column/ColumnModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/column/ModuleColumnCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/column/ModuleColumnInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/column/ModuleColumnUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/comfields/ComFieldsCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/comfields/ComFieldsInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/comfields/ComFieldsListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/comfields/ComFieldsUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/commonword/ComWordsPagination.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/commonword/ComWordsTreeModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/commonword/CommonWordsForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/commonword/CommonWordsVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/ApiDateModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataConfigJsonModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceActionModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceActionVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceGetListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceInvokeModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceLogVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceMarkModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfacePage.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceParamModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceTreeModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceTreeVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/DataInterfaceVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/ExtraModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/FieldModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/HeadModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/PaginationDataInterface.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/PaginationDataInterfaceSelector.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/ParamModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterface/SqlDateModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterfacevariate/DataInterfaceVariateListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterfacevariate/DataInterfaceVariateModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterfacevariate/DataInterfaceVariateSelectorVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/datainterfacevariate/DataInterfaceVariateVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataFormModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataSetConfig.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataSetForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataSetInfo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataSetOptions.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataSetPagination.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataSetQuery.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataSetSwapModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DataSetViewInfo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DsConfigFields.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DsConfigModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DsParamModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DsRelationConfig.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/DsRelationModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dataset/TableTreeModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbbackup/DbBackupListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dblink/DbLinkBaseForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dblink/DbLinkCreUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dblink/DbLinkInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dblink/DbLinkListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dblink/DbLinkModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dblink/DbLinkSelectorListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dblink/PaginationDbLink.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbsync/DbSyncForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbsync/DbSyncVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbtable/dto/DbTableFieldDTO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbtable/form/DbFieldForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbtable/form/DbTableForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbtable/vo/DbFieldVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbtable/vo/DbTableInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbtable/vo/DbTableListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dbtable/vo/DbTableVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataAllModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataAllVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataExportModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataListTreeVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataSelectVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryDataUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/DictionaryExportModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarydata/PageDictionaryData.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarytype/DictionaryTypeCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarytype/DictionaryTypeInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarytype/DictionaryTypeListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarytype/DictionaryTypeModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarytype/DictionaryTypeSelectModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarytype/DictionaryTypeSelectVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/dictionarytype/DictionaryTypeUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/exception/DataTypeException.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/export/PortalExportDataVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/export/PrintExportVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/export/SystemExportVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/export/TemplateExportVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/export/VisualExportVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/export/VisualScreenExportVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/form/FormBatchForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/form/ModuleFormApiModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/form/ModuleFormCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/form/ModuleFormInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/form/ModuleFormListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/form/ModuleFormModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/form/ModuleFormUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/home/ChartModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/home/HomeModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/home/MenuModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/home/TeamWorkModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/interfaceoauth/IdentDataInterfaceLogVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/interfaceoauth/IdentInterfaceListModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/interfaceoauth/InterfaceIdentForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/interfaceoauth/InterfaceIdentListVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/interfaceoauth/InterfaceIdentVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/interfaceoauth/InterfaceUserForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/interfaceoauth/InterfaceUserVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/interfaceoauth/PaginationIntrfaceLog.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/interfaceoauth/PaginationOauth.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/language/BaseLangColumn.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/language/BaseLangForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/language/BaseLangListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/language/BaseLangModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/language/BaseLangPage.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/language/BaseLangVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/logmodel/ErrorLogVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/logmodel/HandleLogVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/logmodel/LogCrFrom.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/logmodel/LogDelForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/logmodel/LogInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/logmodel/LoginLogVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/logmodel/PaginationLogModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/logmodel/RequestLogVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/logmodel/WriteLogModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/map/MapCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/map/MapInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/map/MapListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/map/MapSelectorVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/map/MapUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/messagetemplate/MessageTemplateCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/messagetemplate/MessageTemplateListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/messagetemplate/MessageTemplateSelector.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/messagetemplate/MessageTemplateUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/messagetemplate/MessageTemplateVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/MenuListModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/MenuListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/MenuNumModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/MenuSelectAllVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/MenuSelectByUseNumVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/MenuSelectVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleApiByIdAndMarkModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleApiByIdsModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleApiModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleExportModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleNameVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModulePagination.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModulePermissionModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModulePermissionVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleSelectorVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/ModuleVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/PaginationMenu.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/module/PropertyJsonModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorize/DataAuthorizeCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorize/DataAuthorizeInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorize/DataAuthorizeLinkForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorize/DataAuthorizeListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorize/DataAuthorizeTableNameVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorize/DataAuthorizeUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorizescheme/DataAuthorizeSchemeCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorizescheme/DataAuthorizeSchemeInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorizescheme/DataAuthorizeSchemeListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorizescheme/DataAuthorizeSchemeUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/moduledataauthorizescheme/ModuleDataAuthorizeSchemeModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/monitor/CpuModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/monitor/DiskModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/monitor/MemoryModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/monitor/MonitorListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/monitor/SwapModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/monitor/SystemModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/mp/MPSavaModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/online/AuthFlieds.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/online/BatchOnlineModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/online/PerColModels.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/online/VisualMenuModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/portalmanage/PortalListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/portalmanage/PortalVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/portalmanage/SavePortalAuthModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/print/GlobalConfig.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/print/PaginationPrint.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/print/PrintDevFormDTO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/print/PrintDevInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/print/PrintDevTreeModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/print/PrintDevUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/print/PrintOption.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/print/PrintVersionListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/print/SliceConfig.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/printlog/PrintLogInfo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/printlog/PrintLogQuery.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/AreaModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/AtlasFeaturesModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/AtlasJsonModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/AtlasPropModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/MapParams.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/PaginationProvince.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/ProvinceCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/ProvinceInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/ProvinceListTreeVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/ProvinceListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/ProvinceSelectListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/ProvinceSelectModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/province/ProvinceUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/query/PrintDevDataQuery.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/query/PrintDevFieldsQuery.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/query/PrintDevParam.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/resource/ResourceModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/schedule/ScheduleDetailModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/schedule/ScheduleJobModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/schedule/ScheduleNewAppListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/schedule/ScheduleNewCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/schedule/ScheduleNewDetailInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/schedule/ScheduleNewInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/schedule/ScheduleNewListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/schedule/ScheduleNewTime.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/schedule/ScheduleNewUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/signature/InnerUserModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/signature/PaginationSignature.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/signature/SignatureCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/signature/SignatureInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/signature/SignatureListByIdsModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/signature/SignatureListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/signature/SignatureSelectorListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/signature/SignatureUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/smstemplate/SmsTemplateCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/smstemplate/SmsTemplateListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/smstemplate/SmsTemplateSelector.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/smstemplate/SmsTemplateUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/smstemplate/SmsTemplateVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/DingTalkDeptModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/DingTalkModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/DingTalkUserModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/PaginationSynThirdInfo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/QyWebChatDeptModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/QyWebChatModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/QyWebChatUserModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/SynThirdInfoCrForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/SynThirdInfoInfoVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/SynThirdInfoUpForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/SynThirdMsgModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/synthirdinfo/SynThirdTotal.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/systemconfig/EmailTestForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/systemconfig/PermissionSwitchModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/systemconfig/SaveSysConfigForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/systemconfig/SocialsSysVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/systemconfig/SysConfigListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/systemconfig/SysConfigModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/systemconfig/SystemStrategyModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/EntityFieldModel.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template1/Template1Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template2/FormControl2Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template2/IndexGridField2Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template2/Template2Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template3/DbTableRelation3Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template3/FormControl3Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template3/FormControlGridField3Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template3/IndexGridEntry3Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template3/IndexGridField3Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template3/Template3Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template4/DbTableRelation4Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template4/FormControl4Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template4/FormControlGridField4Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template4/Template4Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template5/DbTableRelation5Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template5/FormControl5Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template5/FormControlGridField5Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template5/IndexGridEntry5Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template5/IndexGridField5Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/template/template5/Template5Model.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/util/DownloadVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/util/LanguageVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/visualkit/KitPagination.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/visualkit/KitTreeVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/visualkit/VisualKitForm.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/visualkit/VisualKitInfoVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/visualkit/VisualKitVo.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/vo/PrintDevListVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/vo/PrintDevVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/vo/PrintLogVO.java jnpf-system/jnpf-system-entity/src/main/java/jnpf/base/model/vo/UserOnLineModelVo.java jnpf-system/jnpf-system-server/pom.xml jnpf-system/jnpf-system-server/src/main/java/jnpf/JnpfSystemApplication.java jnpf-system/jnpf-system-server/src/main/resources/application.yml jnpf-system/jnpf-system-server/src/main/resources/logback-spring.xml jnpf-system/pom.xml jnpf-visualdata/jnpf-visualdata-api/pom.xml jnpf-visualdata/jnpf-visualdata-api/src/main/java/jnpf/visualdata/VisualScreenApi.java jnpf-visualdata/jnpf-visualdata-api/src/main/java/jnpf/visualdata/fallback/VisualScreenApiFallback.java jnpf-visualdata/jnpf-visualdata-biz/pom.xml jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/mapper/VisualAssetsMapper.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/mapper/VisualCategoryMapper.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/mapper/VisualComponentMapper.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/mapper/VisualConfigMapper.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/mapper/VisualDbMapper.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/mapper/VisualGlobMapper.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/mapper/VisualMapMapper.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/mapper/VisualMapper.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/mapper/VisualRecordMapper.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/VisualAssetsService.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/VisualCategoryService.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/VisualComponentService.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/VisualConfigService.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/VisualDbService.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/VisualGlobService.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/VisualMapService.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/VisualRecordService.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/VisualService.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/impl/VisualAssetsServiceImpl.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/impl/VisualCategoryServiceImpl.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/impl/VisualComponentServiceImpl.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/impl/VisualConfigServiceImpl.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/impl/VisualDbServiceImpl.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/impl/VisualGlobServiceImpl.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/impl/VisualMapServiceImpl.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/impl/VisualRecordServiceImpl.java jnpf-visualdata/jnpf-visualdata-biz/src/main/java/jnpf/visualdata/service/impl/VisualServiceImpl.java jnpf-visualdata/jnpf-visualdata-controller/pom.xml jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/controller/VisualAssetsController.java jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/controller/VisualCategoryController.java jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/controller/VisualComponentController.java jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/controller/VisualController.java jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/controller/VisualDbController.java jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/controller/VisualGlobController.java jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/controller/VisualMapController.java jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/controller/VisualRecordController.java jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/controller/aop/VisualBindAspect.java jnpf-visualdata/jnpf-visualdata-controller/src/main/java/jnpf/visualdata/utils/VisualDataPermissionUtil.java jnpf-visualdata/jnpf-visualdata-entity/pom.xml jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/entity/VisualAssetsEntity.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/entity/VisualCategoryEntity.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/entity/VisualComponentEntity.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/entity/VisualConfigEntity.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/entity/VisualDbEntity.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/entity/VisualEntity.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/entity/VisualGlobEntity.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/entity/VisualMapEntity.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/entity/VisualRecordEntity.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/enums/VisualImgEnum.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/VisualPageVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/VisualPagination.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualApiRequest.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualCateModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualCrModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualCrform.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualInfoModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualInfoVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualListVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualPaginationModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualProxyModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualSelectorVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualUpModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visual/VisualUpform.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualassets/VisualAssetsCrForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualassets/VisualAssetsInfoVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualassets/VisualAssetsListVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualassets/VisualAssetsUpForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualcategory/VisualCategoryCrForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualcategory/VisualCategoryInfoVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualcategory/VisualCategoryListVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualcategory/VisualCategoryUpForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualcomponent/VisualComponentCrForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualcomponent/VisualComponentInfoVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualcomponent/VisualComponentListVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualcomponent/VisualComponentPaginationModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualcomponent/VisualComponentUpForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualconfig/VisualConfigCrForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualconfig/VisualConfigInfoModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualconfig/VisualConfigUpForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualdb/VisualDbCrForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualdb/VisualDbInfoVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualdb/VisualDbListVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualdb/VisualDbQueryForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualdb/VisualDbSelectVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualdb/VisualDbUpForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualfile/ImageVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualglob/VisualGlobCrForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualglob/VisualGlobInfoVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualglob/VisualGlobListVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualglob/VisualGlobPaginationModel.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualglob/VisualGlobUpForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualmap/VisualMapCrForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualmap/VisualMapInfoVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualmap/VisualMapListVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualmap/VisualMapUpForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualrecord/VisualRecordCrForm.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualrecord/VisualRecordInfoVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualrecord/VisualRecordListVO.java jnpf-visualdata/jnpf-visualdata-entity/src/main/java/jnpf/visualdata/model/visualrecord/VisualRecordUpForm.java jnpf-visualdata/jnpf-visualdata-server/pom.xml jnpf-visualdata/jnpf-visualdata-server/src/main/java/jnpf/JnpfVisualdataApplication.java jnpf-visualdata/jnpf-visualdata-server/src/main/resources/application.yml jnpf-visualdata/jnpf-visualdata-server/src/main/resources/logback-spring.xml jnpf-visualdata/pom.xml jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-api/pom.xml jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-api/src/main/java/jnpf/base/VisualFilterApi.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-api/src/main/java/jnpf/base/fallback/VisualFilterApiFallback.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/pom.xml jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/config/VisualdevPermissionProperties.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/mapper/BillNumMapper.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/mapper/FlowFormDataMapper.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/mapper/FlowFormRelationMapper.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/mapper/SystemShareMapper.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/mapper/VisualAliasMapper.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/mapper/VisualdevMapper.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/mapper/VisualdevReleaseMapper.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/mapper/VisualdevShortLinkMapper.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/BillNumService.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/FlowFormRelationService.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/SystemShareService.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/VisualAiService.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/VisualAliasService.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/VisualdevReleaseService.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/VisualdevService.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/VisualdevShortLinkService.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/impl/BillNumServiceImpl.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/impl/FlowFormRelationServiceImpl.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/impl/SystemShareServiceImpl.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/impl/VisualAiServiceImpl.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/impl/VisualAliasServiceImpl.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/impl/VisualdevReleaseServiceImpl.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/impl/VisualdevServiceImpl.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/service/impl/VisualdevShortLinkServiceImpl.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/ConcurrencyUtils.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/DateTimeFormatConstant.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/FlowFormDataUtil.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/FlowFormHttpReqUtils.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/FormCheckUtils.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/FormExecelUtils.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/FormInfoUtils.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/FormPublicUtils.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/ReadFile.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/ServiceBaseUtil.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/SourceUtil.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/VisualBillUtil.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/VisualDevTableCre.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/VisualUtil.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/VisualUtils.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/app/AppGenModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/app/AppGenUtil.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/common/AliasModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/common/DataControlUtils.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/common/FunctionFormPublicUtil.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/common/GenerateCommon.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/common/GenerateInterface.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/common/GenerateParamModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/common/SuperQueryUtil.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/custom/CustomGenerator.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/custom/CustomTemplateEngine.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/custom/VelocityEnum.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/fuctionvue3/GenerateDataView.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/fuctionvue3/GenerateForm.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/fuctionvue3/GenerateFormFlow.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/fuctionvue3/GenerateFormList.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/fuctionvue3/GenerateFormListFlow.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-biz/src/main/java/jnpf/base/util/fuctionvue3/GenerateWorkFlow.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-controller/pom.xml jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-controller/src/main/java/jnpf/base/controller/OcrController.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-controller/src/main/java/jnpf/base/controller/SystemShareController.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-controller/src/main/java/jnpf/base/controller/VisualAiController.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-controller/src/main/java/jnpf/base/controller/VisualdevController.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-controller/src/main/java/jnpf/base/controller/VisualdevShortLinkController.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/pom.xml jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/entity/BillNumEntity.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/entity/FilterEntity.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/entity/FlowFormRelationEntity.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/entity/SystemShareEntity.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/entity/VisualAliasEntity.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/entity/VisualdevEntity.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/entity/VisualdevReleaseEntity.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/entity/VisualdevShortLinkEntity.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/CheckFormModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ColumnDataModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/DownloadCodeForm.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ExportSelectedModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/FormDataField.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/FormDeleteResult.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/PaginationVisualdev.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualAliasForm.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualConst.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualDevCrForm.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualDevInfoVO.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualDevJsonModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualDevListVO.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualDevPubModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualDevUpForm.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualFunctionModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualLogModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualWebTypeEnum.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/VisualdevTreeVO.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ai/VisualAiModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/flow/DataModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/flow/FlowFormDataModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/flow/FlowLaunchModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/flow/FlowStateModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/flow/FlowTempInfoModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/form/DraftJsonModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/form/FlowFieldModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/form/FlowFormModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/form/FlowFormPage.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/form/FlowSelectVo.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/form/FormCheckModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/form/NameCodeModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/form/VisualFieldModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/form/VisualTableModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/OcrConstant.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/OcrForm.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/OcrModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/OcrResModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/model/BankCardModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/model/BusinessLicModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/model/DrivingLicModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/model/IdCardModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/model/InvoiceDetail.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/model/InvoiceLicModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/model/TrainTicketModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/ocr/model/VehicleLicModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/online/ExcelImportModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/online/ImportDataModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/online/ImportExcelFieldModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/online/ImportFormCheckUniqueModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/online/OnlineExportExceptionVO.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/online/VisualImportModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/online/VisualdevModelDataInfoVO.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/read/ReadEnum.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/read/ReadListVO.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/read/ReadModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/share/SystemShareForm.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/share/SystemShareVo.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/shortlink/VisualdevShortLinkConfigVo.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/shortlink/VisualdevShortLinkForm.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/shortlink/VisualdevShortLinkModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/shortlink/VisualdevShortLinkPwd.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/shortlink/VisualdevShortLinkVo.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/template/AuthorityModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/template/BtnData.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/template/ChildrenModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/template/ColumnListField.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/template/HtmlModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/template/KeyModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/template/MastModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/template/Template7DataModel.java jnpf-visualdev/jnpf-visualdev-base/jnpf-visualdev-base-entity/src/main/java/jnpf/base/model/template/Template7Model.java jnpf-visualdev/jnpf-visualdev-base/pom.xml jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-api/pom.xml jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-biz/pom.xml jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-biz/src/main/java/jnpf/generater/factory/CodeGenerateFactoryV3.java jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-biz/src/main/java/jnpf/generater/service/VisualdevGenService.java jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-biz/src/main/java/jnpf/generater/service/impl/VisualdevGenServiceImpl.java jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-controller/pom.xml jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-controller/src/main/java/jnpf/generater/controller/VisualdevGenController.java jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-entity/pom.xml jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-entity/src/main/java/jnpf/generater/model/ColumnChildListModel.java jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-entity/src/main/java/jnpf/generater/model/ColumnListDataModel.java jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-entity/src/main/java/jnpf/generater/model/ColumnListModel.java jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-entity/src/main/java/jnpf/generater/model/ListSearchGroupModel.java jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-entity/src/main/java/jnpf/generater/model/SearchTypeModel.java jnpf-visualdev/jnpf-visualdev-generater/jnpf-visualdev-generater-entity/src/main/java/jnpf/generater/model/TemplateMethodEnum.java jnpf-visualdev/jnpf-visualdev-generater/pom.xml jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-api/pom.xml jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-api/src/main/java/jnpf/onlinedev/VisualdevOnlineApi.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-api/src/main/java/jnpf/onlinedev/VisualdevOnlineApiAuditFeignConfig.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-api/src/main/java/jnpf/onlinedev/fallback/VisualdevOnlineApiFallback.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/pom.xml jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/mapper/VisualLogMapper.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/mapper/VisualPersonalMapper.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/mapper/VisualdevModelDataMapper.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/VisualDevInfoService.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/VisualDevListService.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/VisualLogService.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/VisualPersonalService.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/VisualdevModelDataService.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/impl/VisualDevInfoServiceImpl.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/impl/VisualDevListServiceImpl.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/impl/VisualLogServiceImpl.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/impl/VisualPersonalServiceImpl.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/service/impl/VisualdevModelDataServiceImpl.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/DisplayOnlyFieldUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/FlowFormCustomUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/IntegrateUtil.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/OnlineDatabaseUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/OnlineDevInfoUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/OnlineDevListUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/OnlineExcelUtil.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/OnlineExecutor.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/OnlineProductSqlUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/OnlinePublicUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/OnlineQuerySqlUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/OnlineSwapDataUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-biz/src/main/java/jnpf/onlinedev/util/RelationFormUtils.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-controller/pom.xml jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-controller/src/main/java/jnpf/onlinedev/controller/VisualLogController.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-controller/src/main/java/jnpf/onlinedev/controller/VisualPersonalController.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-controller/src/main/java/jnpf/onlinedev/controller/VisualdevForFlowController.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-controller/src/main/java/jnpf/onlinedev/controller/VisualdevModelDataController.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/pom.xml jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/entity/VisualLogEntity.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/entity/VisualPersonalEntity.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/entity/VisualdevModelDataEntity.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/AppDataInfoVO.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/AsyncExecuteModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/BatchRemoveIdsVo.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/ColumnDataInfoVO.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/DataInfoVO.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/ExcelImFieldModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/FormDataInfoVO.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/OnlineInfoModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/PaginationModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/PaginationModelExport.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/PortalDefaultDTO.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/RelationQuery.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/VisualErrInfo.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/VisualInfoParam.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/VisualParamModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/VisualdevModelDataCrForm.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/VisualdevModelDataListVO.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/VisualdevModelDataUpForm.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/enums/CacheKeyEnum.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/enums/MultipleControlEnum.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/enums/OnlineDataTypeEnum.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/log/VisualLogForm.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/log/VisualLogPage.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/log/VisualLogVo.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/InterefaceParamModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/OnlineColumnChildFieldModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/OnlineColumnFieldModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/OnlineConfigModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/OnlineDevListDataVO.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/OnlineFieldsModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/OnlineListSqlModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/PropsFatherModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/PropsModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/SlotModel.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/ValidationRes.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/online/VisualColumnSearchVO.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/personal/VisualPersConst.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/personal/VisualPersPagiantion.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/personal/VisualPersonalForm.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/personal/VisualPersonalInfo.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/personal/VisualPersonalJson.java jnpf-visualdev/jnpf-visualdev-onlinedev/jnpf-visualdev-onlinedev-entity/src/main/java/jnpf/onlinedev/model/personal/VisualPersonalVo.java jnpf-visualdev/jnpf-visualdev-onlinedev/pom.xml jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-api/pom.xml jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-api/src/main/java/jnpf/portal/PortalApi.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-api/src/main/java/jnpf/portal/fallback/PortalApiFallback.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-biz/pom.xml jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-biz/src/main/java/jnpf/portal/mapper/PortalDataMapper.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-biz/src/main/java/jnpf/portal/mapper/PortalMapper.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-biz/src/main/java/jnpf/portal/service/PortalDataService.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-biz/src/main/java/jnpf/portal/service/PortalService.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-biz/src/main/java/jnpf/portal/service/impl/PortalDataServiceImpl.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-biz/src/main/java/jnpf/portal/service/impl/PortalServiceImpl.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-biz/src/main/resources/mapper/PortalDataMapper.xml jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-controller/pom.xml jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-controller/src/main/java/jnpf/portal/controller/DashboardController.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-controller/src/main/java/jnpf/portal/controller/PortalController.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-controller/src/main/java/jnpf/portal/controller/PortalDataController.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/pom.xml jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/constant/PortalConst.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/entity/PortalDataEntity.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/entity/PortalEntity.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/DefaultModel.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/EmailVO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/FlowTodo.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/FlowTodoCountVO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/FlowTodoVO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/MyFlowTodoVO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalCrForm.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalCustomPrimary.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalDataForm.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalDefaultDTO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalInfoAuthVO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalInfoVO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalListModel.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalListVO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalModPrimary.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalPagination.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalReleaseForm.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalReleasePrimary.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalReleaseVO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalSelectModel.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalSelectVO.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalUpForm.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/PortalViewPrimary.java jnpf-visualdev/jnpf-visualdev-portal/jnpf-visualdev-portal-entity/src/main/java/jnpf/portal/model/ReleaseModel.java jnpf-visualdev/jnpf-visualdev-portal/pom.xml jnpf-visualdev/jnpf-visualdev-server/pom.xml jnpf-visualdev/jnpf-visualdev-server/src/main/java/jnpf/JnpfVisualdevApplication.java jnpf-visualdev/jnpf-visualdev-server/src/main/resources/application.yml jnpf-visualdev/jnpf-visualdev-server/src/main/resources/logback-spring.xml jnpf-visualdev/pom.xml pom.xml security/java.security start-all.bat start-all.ps1 start-all.sh stop-all.bat stop-all.ps1