diff --git a/code/g3soft-erp/server/.mvn/wrapper/maven-wrapper.properties b/code/g3soft-erp/server/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..216df058 --- /dev/null +++ b/code/g3soft-erp/server/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/code/g3soft-erp/server/README.md b/code/g3soft-erp/server/README.md index 450ffe8b..e2e8e9eb 100644 --- a/code/g3soft-erp/server/README.md +++ b/code/g3soft-erp/server/README.md @@ -1,6 +1,13 @@ -# server —— 后端(未开始) +# server —— 后端 -本目录预留给后端服务,**当前为空占位**。 +后端服务(Maven + Spring Boot)。现有代码是从原型 `fms-api` 迁入的元数据驱动引擎实现: +包名 `cn.g3soft.erp`,artifactId `erp-api`,启动类 `ErpApiApplication`。 + +构建 `mvnw.cmd test`;启动 `start.cmd`(`spring-boot:run`,默认 8088、context-path `/api`)。 + +> 迁入时**未带入**三项:流程引擎(`service/workflow`、`WorkflowController`、 +> `WorkflowRuntimeController`)、数据库迁移脚本(`config/migrations`、`tools/migration`)、 +> 本地文件存储数据(`data/files`)。 ## 已定的技术栈 @@ -17,14 +24,64 @@ | 其他 | ip2region、ua-parser | | 构建 | Maven | -## 规划的分包方式 +## IP 归属地 -按业务域分包(已确认): +登录日志里的 IP 归属地走**本地 `ip2region` 库**,不联网。实测:加载 5~7ms,单次查询 1~4 微秒, +所以它不构成启动瓶颈(应用自身启动约 1.2~1.7s)。 + +> **部署须知**:`ip2region.xdb`(约 11MB)被 `.gitignore` 排除,不在仓库里,部署时必须手工放到 +> `src/main/resources/`。漏放**不会导致启动失败**,只会让所有归属地静默变成「未知」。 + +内网 / 保留地址(RFC1918、回环、链路本地、IPv6 ULA)直接记「内网」,不外呼。 + +## 数据访问层(`cn.g3soft.erp.database`) + +`database` 下分两块,**两者零互相引用**(一边管"连接怎么来",一边管"拿到连接后怎么读写"): + +| 包 / 类 | 职责 | +|---|---| +| `database/`(根) | 通用数据访问,方法都走 JDBC、无 ORM | +| `database.MetadataReader` | 表结构内省:`loadTableMetadata` / `getColumn` / `describeColumns`;列名归一化也在这里 | +| `database.RowReader` | 只读查询与分页:`loadData` / `loadDataBySql` / `page`;唯一持有默认数据源 | +| `database.RowWriter` | 通用写入与条件更新语义:`insert` / `update` / `delete`、`$original` / `$expr` / guard、零行原因定位 | +| `database.model` | 纯数据载体,无任何逻辑:`TableMetadata` / `ColumnMetadata` / `PageResult` / `GuardCondition` / `ZeroRowCause` | +| `database.org` | 多机构数据库连接:`OrgContext`(请求级机构/用户上下文)、`OrgDatabaseProperties` / `OrgDatabaseConfig*`(配置与热加载)、`OrgDataSourceFactory` / `OrgDataSourceManager` / `OrgRoutingDataSource`(装配与路由) | +| `common.util.SqlText` | SQL 文本与安全校验:标识符校验与加引号、只读 SQL 校验、排序子句构建;纯静态 | + +三条约定: + +1. `SqlText` 不依赖任何人,所以依赖方向固定为 `service → database → common`,不成环。 +2. `RowReader` 之外的方法都接收调用方传入的 `Connection`(不自己开事务),事务边界留在服务层。 +3. 认证要按 `orgId` 取该机构连接(登录时机构上下文尚未建立),所以那条查询在 + `service.auth.UserAccountReader` 里,**不在**访问层。 + +## 分割现状与规划 + +**现状**(迁入的原型代码,已按职责归过一轮): ``` cn.g3soft.erp -├── common/ # 通用返回、异常、工具 -├── engine/ # 元数据驱动引擎(查询 / 保存 / 权限)—— 未设计 +├── common/ # 通用返回、异常、纯静态工具 +│ ├── ApiResponse.java # HTTP 响应外壳 + 全部错误码常量 +│ ├── exception/ # 6 个异常与 GlobalExceptionHandler +│ └── util/ # ParamUtils / SqlText / StringUtils / TimeUtils / +│ # UuidV7Generator + snowflake/(内嵌的雪花 ID 生成器) +├── config/ # AuthProperties / FileStorageProperties / JacksonConfig / JwtAuthFilter +├── controller/ # 6 个 Controller +├── database/ # 数据访问层(见上):RowReader / RowWriter / MetadataReader + model/ + org/ +└── service/ + ├── auth/ # 登录、会话、登录日志、JwtUtils,以及 client/(客户端信息采集) + ├── data/ # 通用保存 / 删除 / 查询编排 + ├── export/ file/ formula/ permission/ +``` + +**规划**(新代码按此落位,老代码后续按域重构): + +``` +cn.g3soft.erp +├── common/ # 通用返回、异常、工具 —— 已建 +├── engine/ # 元数据驱动引擎(查询 / 保存 / 权限)—— 未设计; +│ # 现在的 database + service/{data,formula,permission} 是它的雏形 └── module/ ├── ocean/ # 海运 ├── air/ # 空运 diff --git a/code/g3soft-erp/server/config/dbconfigs/README.md b/code/g3soft-erp/server/config/dbconfigs/README.md new file mode 100644 index 00000000..06d85bdd --- /dev/null +++ b/code/g3soft-erp/server/config/dbconfigs/README.md @@ -0,0 +1,27 @@ +# Organization Database Configs + +Each organization uses one SQL Server connection file named `{ORG_ID}.properties`. +Organization IDs are normalized to uppercase and may contain only letters, numbers, +underscores, and hyphens. + +Example: `G3HD.properties` + +```properties +url=jdbc:sqlserver://127.0.0.1:1433;databaseName=G3HD +username=sa +password=your-password +driver=com.microsoft.sqlserver.jdbc.SQLServerDriver +``` + +The `*.properties` files in this directory are ignored by Git. + +## Development startup for legacy SQL Server + +Run the following command from the `server` directory when connecting to a +legacy SQL Server that only supports TLS 1.0: + +```batch +set JAVA_HOME=D:\devtool\jdk\jdk-21 +set PATH=%JAVA_HOME%\bin;%PATH% +mvn "-Dspring-boot.run.jvmArguments=-Djava.security.properties=./config/legacy-tls.security" spring-boot:run +``` diff --git a/code/g3soft-erp/server/config/legacy-tls.security b/code/g3soft-erp/server/config/legacy-tls.security new file mode 100644 index 00000000..8d78ee1c --- /dev/null +++ b/code/g3soft-erp/server/config/legacy-tls.security @@ -0,0 +1,3 @@ +# Development-only compatibility for SQL Server 2008 R2. +# Keep this override scoped to the erp-api JVM. +jdk.tls.disabledAlgorithms=SSLv3, TLSv1.1, DTLSv1.0, RC4, DES, MD5withRSA, DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL, ECDH diff --git a/code/g3soft-erp/server/mvnw b/code/g3soft-erp/server/mvnw new file mode 100644 index 00000000..bd8896bf --- /dev/null +++ b/code/g3soft-erp/server/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/code/g3soft-erp/server/mvnw.cmd b/code/g3soft-erp/server/mvnw.cmd new file mode 100644 index 00000000..92450f93 --- /dev/null +++ b/code/g3soft-erp/server/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/code/g3soft-erp/server/pom.xml b/code/g3soft-erp/server/pom.xml new file mode 100644 index 00000000..6dc046cc --- /dev/null +++ b/code/g3soft-erp/server/pom.xml @@ -0,0 +1,102 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.1.0 + + + cn.g3soft + erp-api + 0.0.1-SNAPSHOT + erp-api + erp-api + + 21 + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.alibaba + druid + 1.2.18 + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + + com.fasterxml.jackson.core + jackson-databind + + + org.lionsoul + ip2region + 3.3.7 + + + + com.github.ua-parser + uap-java + 1.6.1 + + + + + org.apache.fesod + fesod-sheet + 2.0.2-incubating + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + com.microsoft.sqlserver + mssql-jdbc + runtime + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/ErpApiApplication.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/ErpApiApplication.java new file mode 100644 index 00000000..c786d3d8 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/ErpApiApplication.java @@ -0,0 +1,21 @@ +package cn.g3soft.erp; + +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorOptions; +import cn.g3soft.erp.common.util.snowflake.idgen.IdGenerator; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class ErpApiApplication { + + public static void main(String[] args) { + IdGeneratorOptions options = new IdGeneratorOptions(); + options.WorkerId = 1; + IdGenerator.setIdGenerator(options); + + SpringApplication.run(ErpApiApplication.class, args); + } + +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/ApiResponse.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/ApiResponse.java new file mode 100644 index 00000000..3198a14d --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/ApiResponse.java @@ -0,0 +1,35 @@ +package cn.g3soft.erp.common; + +public record ApiResponse( + int code, + String message, + T data +) { + + public static final int SUCCESS_CODE = 0; + public static final int AUTH_ERROR_CODE = 401; + public static final int SESSION_REPLACED_CODE = 40101; + public static final int SAVE_ERROR_CODE = 2001; + /** guard 前置条件不成立(《FMS通用保存接口条件更新设计》§5.1) */ + public static final int GUARD_ERROR_CODE = 2002; + /** $original 原值 / b_row_version 不匹配,并发编辑冲突(§5.1) */ + public static final int CONFLICT_ERROR_CODE = 2003; + public static final int SYSTEM_ERROR_CODE = 500; + public static final int BUSINESS_ERROR_CODE = 1000; + + public static ApiResponse success(T data) { + return new ApiResponse<>(SUCCESS_CODE, "操作成功", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(SUCCESS_CODE, message, data); + } + + public static ApiResponse fail(String message) { + return fail(BUSINESS_ERROR_CODE, message); + } + + public static ApiResponse fail(int code, String message) { + return new ApiResponse<>(code, message, null); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/AuthenticationException.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/AuthenticationException.java new file mode 100644 index 00000000..9000dcf4 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/AuthenticationException.java @@ -0,0 +1,8 @@ +package cn.g3soft.erp.common.exception; + +public class AuthenticationException extends BusinessException { + + public AuthenticationException(String message) { + super(message); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/BusinessException.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/BusinessException.java new file mode 100644 index 00000000..4b3059ef --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/BusinessException.java @@ -0,0 +1,8 @@ +package cn.g3soft.erp.common.exception; + +public class BusinessException extends RuntimeException { + + public BusinessException(String message) { + super(message); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/GlobalExceptionHandler.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..8cd6301e --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,148 @@ +package cn.g3soft.erp.common.exception; + +import cn.g3soft.erp.common.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(AuthenticationException.class) + public ApiResponse handleAuthenticationException(AuthenticationException exception) { + return ApiResponse.fail(ApiResponse.AUTH_ERROR_CODE, exception.getMessage()); + } + + @ExceptionHandler(BusinessException.class) + public ApiResponse handleBusinessException(BusinessException exception) { + return ApiResponse.fail(exception.getMessage()); + } + + @ExceptionHandler(SaveObjectException.class) + public ApiResponse> handleSaveObjectException( + SaveObjectException exception + ) { + LOGGER.error( + "保存失败,table={},action={},index={}", + exception.getTable(), + exception.getAction(), + exception.getIndex(), + exception + ); + Map details = new LinkedHashMap<>(); + details.put("table", exception.getTable()); + details.put("action", exception.getAction()); + details.put("index", exception.getIndex()); + String rootCause = exception.rootCauseMessage(); + if (rootCause != null) { + details.put("cause", rootCause); + } + return new ApiResponse<>( + ApiResponse.SAVE_ERROR_CODE, + rootCause == null ? "保存失败" : "保存失败: " + rootCause, + details + ); + } + + /** + * guard 前置条件不成立(2002,§4.4 / §5.1):文案可直接展示给用户。 + * 日志走 WARN 不打堆栈(§5.3)——这是正常业务竞态,不是系统故障; + * condition 原文与 refs 只进日志,不进用户可见文案。 + */ + @ExceptionHandler(SaveGuardException.class) + public ApiResponse> handleSaveGuardException( + SaveGuardException exception + ) { + LOGGER.warn( + "保存前置条件不成立,table={},action={},index={},refs={},condition={}", + exception.getTable(), + exception.getAction(), + exception.getIndex(), + exception.getRefs(), + exception.getCondition() + ); + Map details = new LinkedHashMap<>(); + details.put("table", exception.getTable()); + details.put("action", exception.getAction()); + details.put("index", exception.getIndex()); + return new ApiResponse<>( + ApiResponse.GUARD_ERROR_CODE, + exception.userMessage(), + details + ); + } + + /** + * 并发编辑冲突(2003,§5.1):{@code $original} 原值或 {@code b_row_version} 不匹配。 + * 与 guard 的区别是这里不使用业务文案,统一走并发冲突话术; + * fields 随响应带出,供前端重新拉取当前行后标记冲突字段(§6.3)。 + */ + @ExceptionHandler(SaveConflictException.class) + public ApiResponse> handleSaveConflictException( + SaveConflictException exception + ) { + LOGGER.warn( + "并发编辑冲突,table={},action={},index={},fields={}", + exception.getTable(), + exception.getAction(), + exception.getIndex(), + exception.getFields() + ); + Map details = new LinkedHashMap<>(); + details.put("table", exception.getTable()); + details.put("action", exception.getAction()); + details.put("index", exception.getIndex()); + details.put("fields", exception.getFields()); + return new ApiResponse<>( + ApiResponse.CONFLICT_ERROR_CODE, + exception.userMessage(), + details + ); + } + + @ExceptionHandler({SQLException.class, DataAccessException.class}) + public ApiResponse handleDatabaseException( + Exception exception, + HttpServletRequest request + ) { + logSystemException(exception, request); + return systemErrorResponse(); + } + + @ExceptionHandler(Exception.class) + public ApiResponse handleException( + Exception exception, + HttpServletRequest request + ) { + logSystemException(exception, request); + return systemErrorResponse(); + } + + private void logSystemException( + Exception exception, + HttpServletRequest request + ) { + LOGGER.error( + "系统异常,method={},uri={}", + request.getMethod(), + request.getRequestURI(), + exception + ); + } + + private ApiResponse systemErrorResponse() { + return ApiResponse.fail( + ApiResponse.SYSTEM_ERROR_CODE, + "系统异常,请稍后重试" + ); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/SaveConflictException.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/SaveConflictException.java new file mode 100644 index 00000000..55eb1b60 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/SaveConflictException.java @@ -0,0 +1,51 @@ +package cn.g3soft.erp.common.exception; + +import java.util.List; + +/** + * 乐观并发冲突:行内 {@code $original} 声明的字段原值或 {@code b_row_version} 与库中当前值不匹配 + * (《FMS通用保存接口条件更新设计》§3.1 / §5.1),返回 2003。 + * + *

与 {@link SaveGuardException} 的区别:guard 是「业务规则不允许」,这里是「别人先改了」。 + * 因此**不使用某条 guard 的业务文案**,统一走并发冲突话术(§5.1 第 8 项已定)。 + * + *

冲突字段随异常带出,供前端重新拉取当前行后标记冲突字段(§6.3)。 + */ +public class SaveConflictException extends RuntimeException { + + private static final String MESSAGE = "该数据已被其他操作修改,本次操作未生效,请刷新后重试。"; + + private final String table; + private final String action; + private final int index; + private final List fields; + + public SaveConflictException(String table, String action, int index, List fields) { + super("并发编辑冲突"); + this.table = table; + this.action = action; + this.index = index; + this.fields = fields == null ? List.of() : List.copyOf(fields); + } + + public String getTable() { + return table; + } + + public String getAction() { + return action; + } + + public int getIndex() { + return index; + } + + /** 参与比较但不匹配的字段名;整行版本冲突时为 {@code b_row_version} */ + public List getFields() { + return fields; + } + + public String userMessage() { + return MESSAGE; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/SaveGuardException.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/SaveGuardException.java new file mode 100644 index 00000000..780d83ca --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/SaveGuardException.java @@ -0,0 +1,81 @@ +package cn.g3soft.erp.common.exception; + +import java.util.List; + +/** + * 保存前置条件(guard)不成立,写入命中 0 行(《FMS通用保存接口条件更新设计》§4.4 / §5.1)。 + * + *

与 {@link SaveObjectException} 的区别:这是**可识别的业务竞态**,文案由写条件的人 + * 负责描述、可直接展示给用户,日志走 WARN 不打堆栈(§5.3);不是系统故障,也不是普通保存失败。 + * + *

携带 table / action / index 供前端区分「改不动」还是「删不掉」, + * condition / refs 只进日志不进文案(§5.3)。 + */ +public class SaveGuardException extends RuntimeException { + + /** guard 未声明 message 时的通用话术(§5.2) */ + private static final String DEFAULT_MESSAGE = "该数据已被更新或不满足操作条件"; + + private final String table; + private final String action; + private final int index; + private final String condition; + private final String guardMessage; + private final boolean retryable; + private final List refs; + + public SaveGuardException( + String table, + String action, + int index, + String condition, + String guardMessage, + boolean retryable, + List refs + ) { + super("保存前置条件不成立"); + this.table = table; + this.action = action; + this.index = index; + this.condition = condition; + this.guardMessage = guardMessage; + this.retryable = retryable; + this.refs = refs == null ? List.of() : List.copyOf(refs); + } + + public String getTable() { + return table; + } + + public String getAction() { + return action; + } + + public int getIndex() { + return index; + } + + public String getCondition() { + return condition; + } + + public boolean isRetryable() { + return retryable; + } + + public List getRefs() { + return refs; + } + + /** + * 可直接展示给用户的完整文案(§5.2):{@code ,本次操作未生效。}。 + * 「本次操作未生效」固定不变——整个请求是一个事务,用户会以为存了一半; + * 「请刷新后重试」由 retryable 决定,刷新改变不了的终态条件不能让用户去刷。 + */ + public String userMessage() { + String base = guardMessage == null || guardMessage.isBlank() + ? DEFAULT_MESSAGE + : guardMessage.trim(); + return base + ",本次操作未生效。" + (retryable ? "请刷新后重试。" : ""); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/SaveObjectException.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/SaveObjectException.java new file mode 100644 index 00000000..dc98acab --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/exception/SaveObjectException.java @@ -0,0 +1,44 @@ +package cn.g3soft.erp.common.exception; + +public class SaveObjectException extends RuntimeException { + + private final String table; + private final String action; + private final int index; + + public SaveObjectException( + String table, + String action, + int index, + Throwable cause + ) { + super("保存失败", cause); + this.table = table; + this.action = action; + this.index = index; + } + + public String getTable() { + return table; + } + + public String getAction() { + return action; + } + + public int getIndex() { + return index; + } + + /** + * 根因摘要(如 SQLServerException 的 "Violation of UNIQUE constraint ..."), + * 用于接口响应中提示具体失败原因,避免只看到笼统的「保存失败」。 + */ + public String rootCauseMessage() { + Throwable root = getCause(); + while (root != null && root.getCause() != null) { + root = root.getCause(); + } + return root == null ? null : root.getMessage(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/ParamUtils.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/ParamUtils.java new file mode 100644 index 00000000..1bc55bac --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/ParamUtils.java @@ -0,0 +1,59 @@ +package cn.g3soft.erp.common.util; + +import cn.g3soft.erp.common.exception.BusinessException; + +import java.util.List; +import java.util.Map; + +public final class ParamUtils { + + private ParamUtils() { + } + + public static String getString(Map params, String field) { + return get(params, field, String.class); + } + + public static String getRequiredString(Map params, String field) { + String value = getString(params, field); + if (StringUtils.isBlank(value)) { + throw new BusinessException(field + " 不能为空"); + } + return value; + } + + public static T get(Map params, String field, Class type) { + Object value = getValue(params, field); + if (value == null) { + return null; + } + if (!type.isInstance(value)) { + throw new BusinessException(field + " 参数类型不正确"); + } + return type.cast(value); + } + + @SuppressWarnings("unchecked") + public static List getList(Map params, String field) { + Object value = getValue(params, field); + if (value == null) { + return null; + } + if (!(value instanceof List values)) { + throw new BusinessException(field + " 参数类型不正确"); + } + return (List) values; + } + + public static Object[] getArray(Map params, String field) { + List values = getList(params, field); + if (values == null) { + return null; + } + return values.toArray(); + } + + private static Object getValue(Map params, String field) { + return params == null ? null : params.get(field); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/SqlText.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/SqlText.java new file mode 100644 index 00000000..070ce42b --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/SqlText.java @@ -0,0 +1,169 @@ +package cn.g3soft.erp.common.util; + +import cn.g3soft.erp.common.exception.BusinessException; + +import java.util.Arrays; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * SQL 文本处理与安全校验:标识符校验与加引号、只读 SQL 校验、排序子句构建、字符串字面量转义。 + * + *

纯静态、不持有连接、不依赖任何数据模型——任何层都可以依赖它,它不反向依赖任何人, + * 因此「服务层 → 数据访问层 → 本类」的依赖方向不会成环。 + */ +public final class SqlText { + + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + private static final Pattern READ_SQL_PATTERN = Pattern.compile("(?is)^\\s*(select|with)\\b"); + private static final Pattern WRITE_SQL_PATTERN = Pattern.compile( + "(?i)\\b(insert|update|delete|merge|drop|truncate|alter|create|exec|execute|grant|revoke|backup|restore|dbcc|kill|use|into)\\b" + ); + private static final Pattern SQL_COMMENT_PATTERN = Pattern.compile("--|/\\*|\\*/"); + + private SqlText() { + } + + public static String toSqlStringLiteral(String value) { + if (value == null) { + return "NULL"; + } + return "N'" + value.replace("'", "''") + "'"; + } + + public static void validateIdentifier(String identifier) { + if (!hasText(identifier) || !SAFE_IDENTIFIER.matcher(identifier).matches()) { + throw new BusinessException("非法数据库标识符: " + identifier); + } + } + + public static void validateReadOnlySql(String sql) { + if (!hasText(sql)) { + throw new BusinessException("查询 SQL 不能为空"); + } + + String normalizedSql = sql.trim(); + if (!READ_SQL_PATTERN.matcher(normalizedSql).find()) { + throw new BusinessException("只允许执行只读查询"); + } + String sqlStructure = maskStringLiterals(normalizedSql); + if (sqlStructure.contains(";")) { + throw new BusinessException("查询 SQL 不允许包含分号"); + } + if (containsSqlComment(sqlStructure)) { + throw new BusinessException("查询 SQL 不允许包含注释"); + } + if (WRITE_SQL_PATTERN.matcher(sqlStructure).find()) { + throw new BusinessException("查询 SQL 包含非只读关键字"); + } + } + + /** + * 等长掩码:把字符串字面量整体抹成空格,位置与原文一一对应。 + * 之后在掩码结果上做的定位(如 guard 占位符替换)就能天然排除字面量内部的假命中。 + */ + public static String maskStringLiterals(String sql) { + StringBuilder result = new StringBuilder(sql.length()); + boolean inString = false; + + for (int index = 0; index < sql.length(); index++) { + char character = sql.charAt(index); + if (character != '\'') { + result.append(inString ? ' ' : character); + continue; + } + + if (inString && index + 1 < sql.length() && sql.charAt(index + 1) == '\'') { + result.append(" "); + index++; + continue; + } + + inString = !inString; + result.append(' '); + } + + if (inString) { + throw new BusinessException("SQL 字符串未闭合"); + } + return result.toString(); + } + + public static boolean containsSqlComment(String text) { + return SQL_COMMENT_PATTERN.matcher(text).find(); + } + + private static String buildOrderBy(String orderBy) { + String trimmed = orderBy.trim(); + String lower = trimmed.toLowerCase(); + + // CASE 表达式:从末尾剥离可选 ASC/DESC,其余直接返回 + if (lower.startsWith("case")) { + String direction = ""; + if (lower.endsWith(" asc")) { + direction = " ASC"; + trimmed = trimmed.substring(0, trimmed.length() - 4); + } else if (lower.endsWith(" desc")) { + direction = " DESC"; + trimmed = trimmed.substring(0, trimmed.length() - 5); + } + if (!trimmed.trim().toLowerCase().endsWith("end")) { + throw new BusinessException("CASE 表达式必须以 END 结尾"); + } + return trimmed + direction; + } + + String[] parts = trimmed.split("\\s+"); + if (parts.length > 2) { + throw new BusinessException("排序字段格式不正确"); + } + + String direction = ""; + if (parts.length == 2) { + if (!"asc".equalsIgnoreCase(parts[1]) && !"desc".equalsIgnoreCase(parts[1])) { + throw new BusinessException("排序方向只允许 ASC 或 DESC"); + } + direction = " " + parts[1].toUpperCase(); + } + return quoteQualifiedIdentifier(parts[0]) + direction; + } + + private static String buildPageOrderBy(String orderBy) { + String requiredOrderBy = requireText(orderBy, "order_by"); + return Arrays.stream(requiredOrderBy.split(",")) + .map(String::trim) + .map(SqlText::buildOrderBy) + .collect(Collectors.joining(", ")); + } + + /** + * 对外暴露的排序子句构建:校验并加引号,格式与分页/查询完全一致 + * (`field [ASC|DESC]` 或 `CASE ... END`,逗号分隔多列)。 + * 导出等需要自己拼 SQL 的场景复用它,避免另写一套校验。 + */ + public static String buildSafeOrderBy(String orderBy) { + return buildPageOrderBy(orderBy); + } + + public static String quoteQualifiedIdentifier(String identifier) { + if (!hasText(identifier)) { + throw new BusinessException("数据库标识符不能为空"); + } + + return Arrays.stream(identifier.trim().split("\\.")) + .peek(SqlText::validateIdentifier) + .map(part -> "[" + part + "]") + .collect(Collectors.joining(".")); + } + + public static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + public static String requireText(String value, String fieldName) { + if (!hasText(value)) { + throw new BusinessException(fieldName + " 不能为空"); + } + return value.trim(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/StringUtils.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/StringUtils.java new file mode 100644 index 00000000..5b8b916f --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/StringUtils.java @@ -0,0 +1,11 @@ +package cn.g3soft.erp.common.util; + +public final class StringUtils { + + private StringUtils() { + } + + public static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/TimeUtils.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/TimeUtils.java new file mode 100644 index 00000000..c2597954 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/TimeUtils.java @@ -0,0 +1,201 @@ +package cn.g3soft.erp.common.util; + +import cn.g3soft.erp.common.exception.BusinessException; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 时间口径工具:库中 datetime 列统一存 UTC 墙钟值,接口出口统一发带 Z 的 ISO-8601 串。 + * + *

口径与边界(全链路唯一约定,禁在各消费点自行裁剪): + *

    + *
  • 写入:库端用 {@code sysutcdatetime()}(不再用受服务器时区影响的 + * {@code getdate() / sysdatetime()});应用层写时间列用 {@link #utcNow()}。 + * 各机构库即使部署在不同时区,落库口径一致,不依赖服务器时区。
  • + *
  • 出口:列表 / 详情 / 查询 / 工作流统一经 {@link #toApiValue} 落成带 Z 的 + * ISO 串(如 {@code 2026-09-26T02:00:00Z}),前端按浏览器时区转本地展示。
  • + *
  • 入口:通用保存经 {@link #toStorageValue} 把带时区标记的 ISO 串折回 UTC 墙钟串; + * 不带标记的串视为已经是 UTC 墙钟值(兼容内部调用与存量脚本),不做时区猜测 —— + * 因此前端提交时间值必须带 {@code Z} 或 {@code ±HH:mm} 偏移。
  • + *
  • date / time 列不参与换算:纯日历值与纯时钟值没有时区语义,原样读写。
  • + *
  • 导出、下载文件名、自动编码周期等「给人看 / 按业务日算」的场景用 {@link #BUSINESS_ZONE} + * 折算,不随服务器或 JVM 默认时区漂移。
  • + *
+ * + *

关于 {@link Timestamp#toLocalDateTime()}:SQL Server 驱动把无时区的 datetime2 库值 + * 按 JVM 默认时区解释成瞬时,再按同一时区还原成 LocalDateTime,二者相抵得到库里的字面值。 + * 因此读出口拿到的就是库中的 UTC 墙钟串(不随 JVM 时区变化),只需补上 Z 标记。 + */ +public final class TimeUtils { + + /** 业务时区:业务日、导出文本、文件名等「给人看」场景的折算基准 */ + public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + /** 库中时间列的墙钟格式(datetime2 字面值形态) */ + public static final DateTimeFormatter WALL_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** 出口 ISO 串的时间部分:末尾由 {@link #toApiValue} 补 Z */ + private static final DateTimeFormatter ISO_UTC_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + /** 尾部带 ±HH:mm / ±HHmm 偏移的时间串 */ + private static final Pattern OFFSET_TAIL = Pattern.compile("[+-]\\d{2}:?\\d{2}$"); + + /** 只认这几种带时间语义的列;date / time / timestamp(rowversion) 不在此列 */ + private static final Set DATE_TIME_TYPES = Set.of("datetime", "datetime2", "smalldatetime"); + + private TimeUtils() { + } + + /** 当前 UTC 时刻,供应用层写时间列(替代 LocalDateTime.now(业务时区)) */ + public static LocalDateTime utcNow() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + /** 当前 UTC 墙钟串(写 SQL 文本、拼日志等场景) */ + public static String utcNowWall() { + return utcNow().format(WALL_FORMATTER); + } + + /** 业务时区的当前时刻(自动编码周期、下载文件名等按业务日算的场景) */ + public static LocalDateTime businessNow() { + return LocalDateTime.now(BUSINESS_ZONE); + } + + /** 业务时区的今天 */ + public static LocalDate businessToday() { + return LocalDate.now(BUSINESS_ZONE); + } + + /** 该列类型是否按 UTC 口径读写(datetime / datetime2 / smalldatetime) */ + public static boolean isDateTimeColumn(String typeName) { + return typeName != null && DATE_TIME_TYPES.contains(typeName.toLowerCase(Locale.ROOT).trim()); + } + + /** + * 读出口:JDBC 原值 → 接口值。 + * datetime2 → {@code "2026-09-26T02:00:00Z"}(UTC,带 Z 标记);date → {@code "2026-09-26"}; + * time → {@code "10:00:00"};其余类型原样返回。 + */ + public static Object toApiValue(Object value) { + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().format(ISO_UTC_FORMATTER) + "Z"; + } + if (value instanceof java.sql.Date date) { + return date.toLocalDate().format(DATE_FORMATTER); + } + if (value instanceof java.sql.Time time) { + return time.toLocalTime().format(TIME_FORMATTER); + } + if (value instanceof LocalDateTime dateTime) { + return dateTime.format(ISO_UTC_FORMATTER) + "Z"; + } + return value; + } + + /** + * 写入口:请求值 → 库中 UTC 墙钟值(仅 {@link #isDateTimeColumn} 的列参与)。 + * + *

    + *
  • 带 {@code Z} / {@code ±HH:mm} 的 ISO 串 → 折算成 UTC 墙钟串;
  • + *
  • 不带时区标记的时间串 → 视为已是 UTC 墙钟值,只补全格式(T→空格、补秒);
  • + *
  • 非法时间串 → 抛 {@link BusinessException},不静默写脏数据;
  • + *
  • 非字符串值(null / 数字 / 对象)与其他列原样返回。
  • + *
+ */ + public static Object toStorageValue(String typeName, Object value) { + if (!isDateTimeColumn(typeName) || !(value instanceof String text)) { + return value; + } + String trimmed = text.trim(); + if (trimmed.isEmpty()) { + return value; + } + return toUtcWall(trimmed); + } + + /** 带时区标记的时间串折算成 UTC 墙钟串 */ + public static String toUtcWall(String text) { + String normalized = text.trim().replace(' ', 'T'); + try { + if (normalized.endsWith("Z") || normalized.endsWith("z")) { + return Instant.parse(normalized).atZone(ZoneOffset.UTC).toLocalDateTime() + .format(WALL_FORMATTER); + } + if (OFFSET_TAIL.matcher(normalized).find()) { + return OffsetDateTime.parse(normalized).withOffsetSameInstant(ZoneOffset.UTC) + .toLocalDateTime().format(WALL_FORMATTER); + } + return normalizeWall(text); + } catch (DateTimeParseException error) { + throw new BusinessException("时间值格式不正确: " + text); + } + } + + /** 无时区标记的串:视为 UTC 墙钟值,仅做格式规范化(补秒 / T→空格) */ + private static String normalizeWall(String text) { + String normalized = text.trim().replace('T', ' '); + if (normalized.length() == 10) { + return LocalDate.parse(normalized).atStartOfDay().format(WALL_FORMATTER); + } + if (normalized.length() == 16) { + normalized = normalized + ":00"; + } + return LocalDateTime.parse(normalized, WALL_FORMATTER).format(WALL_FORMATTER); + } + + /** + * 解析接口值 / 库值成瞬时,供耗时统计、超时判定等计算使用;无法解析时返回 null。 + * 支持带 Z 的 ISO 串、带偏移的 ISO 串、UTC 墙钟串与 JDBC Timestamp。 + */ + public static Instant toInstant(Object value) { + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toInstant(ZoneOffset.UTC); + } + if (value instanceof LocalDateTime dateTime) { + return dateTime.toInstant(ZoneOffset.UTC); + } + if (value instanceof String text) { + String normalized = text.trim().replace(' ', 'T'); + if (normalized.isEmpty()) { + return null; + } + try { + if (normalized.endsWith("Z") || normalized.endsWith("z")) { + return Instant.parse(normalized); + } + if (OFFSET_TAIL.matcher(normalized).find()) { + return OffsetDateTime.parse(normalized).toInstant(); + } + // UTC 墙钟串按 ISO 解析(已把空格换成 T);纯日期按当天零点 + if (normalized.length() == 10) { + return LocalDate.parse(normalized).atStartOfDay().toInstant(ZoneOffset.UTC); + } + return LocalDateTime.parse(normalized).toInstant(ZoneOffset.UTC); + } catch (DateTimeParseException error) { + return null; + } + } + return null; + } + + /** UTC 墙钟值 → 业务时区文本(导出 Excel 等给人看的场景) */ + public static String businessText(LocalDateTime utcWall) { + return utcWall.atZone(ZoneOffset.UTC).withZoneSameInstant(BUSINESS_ZONE) + .toLocalDateTime().format(WALL_FORMATTER); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/UuidV7Generator.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/UuidV7Generator.java new file mode 100644 index 00000000..0aaa9221 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/UuidV7Generator.java @@ -0,0 +1,37 @@ +package cn.g3soft.erp.common.util; + +import java.security.SecureRandom; +import java.util.UUID; + +/** + * 业务层 UUIDv7 生成器(RFC 9562 时间有序版本)。 + *

UUID 主键不再依赖雪花 bigint:日志表 s_log_* 使用 uniqueidentifier 主键, + * 主键非聚集、按 b_occurdatetime 建立聚集索引,因此只需按时间大致有序即可。 + *

生成结果满足 UUIDv7 布局: + *

+ *  0                   1                   2                   3
+ *  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |                           unix_ts_ms                          |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |          unix_ts_ms           |  ver  |       rand_a          |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * |var|                        rand_b                             |
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
+ * 
+ */ +public final class UuidV7Generator { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private UuidV7Generator() { + } + + public static UUID nextUuid() { + long unixTsMs = System.currentTimeMillis() & 0xFFFFFFFFFFFFL; + long mostSigBits = (unixTsMs << 16) | (RANDOM.nextLong() & 0x0FFFL); + mostSigBits = (mostSigBits & ~0xF000L) | 0x7000L; + long leastSigBits = (RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL) | 0x8000000000000000L; + return new UUID(mostSigBits, leastSigBits); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/IIdGenerator.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/IIdGenerator.java new file mode 100644 index 00000000..35a9df09 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/IIdGenerator.java @@ -0,0 +1,5 @@ +package cn.g3soft.erp.common.util.snowflake.contract; + +public interface IIdGenerator { + long newLong() throws IdGeneratorException; +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/ISnowWorker.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/ISnowWorker.java new file mode 100644 index 00000000..6a3843e6 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/ISnowWorker.java @@ -0,0 +1,5 @@ +package cn.g3soft.erp.common.util.snowflake.contract; + +public interface ISnowWorker { + long nextId() throws IdGeneratorException; +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/IdGeneratorException.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/IdGeneratorException.java new file mode 100644 index 00000000..3b365960 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/IdGeneratorException.java @@ -0,0 +1,25 @@ +package cn.g3soft.erp.common.util.snowflake.contract; + +public class IdGeneratorException extends RuntimeException { + + public IdGeneratorException() { + super(); + } + + public IdGeneratorException(String message) { + super(message); + } + + public IdGeneratorException(Throwable cause) { + super(cause); + } + + public IdGeneratorException(String message, Throwable cause) { + super(message, cause); + } + + public IdGeneratorException(String msgFormat, Object... args) { + super(String.format(msgFormat, args)); + } + +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/IdGeneratorOptions.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/IdGeneratorOptions.java new file mode 100644 index 00000000..db42b334 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/IdGeneratorOptions.java @@ -0,0 +1,60 @@ +package cn.g3soft.erp.common.util.snowflake.contract; + +public class IdGeneratorOptions { + + /** + * 雪花计算方法 + * (1-漂移算法|2-传统算法),默认1 + */ + public short Method = 1; + + /** + * 基础时间(ms单位) + * 不能超过当前系统时间 + */ + public long BaseTime = 1582136402000L; + + /** + * 机器码 + * 必须由外部设定,最大值 2^WorkerIdBitLength-1 + */ + public short WorkerId = 0; + + /** + * 机器码位长 + * 默认值6,取值范围 [1, 15](要求:序列数位长+机器码位长不超过22) + */ + public byte WorkerIdBitLength = 6; + + /** + * 序列数位长 + * 默认值6,取值范围 [3, 21](要求:序列数位长+机器码位长不超过22) + */ + public byte SeqBitLength = 6; + + /** + * 最大序列数(含) + * 设置范围 [MinSeqNumber, 2^SeqBitLength-1],默认值0,表示最大序列数取最大值(2^SeqBitLength-1]) + */ + public short MaxSeqNumber = 0; + + /** + * 最小序列数(含) + * 默认值5,取值范围 [5, MaxSeqNumber],每毫秒的前5个序列数对应编号是0-4是保留位,其中1-4是时间回拨相应预留位,0是手工新值预留位 + */ + public short MinSeqNumber = 5; + + /** + * 最大漂移次数(含) + * 默认2000,推荐范围500-10000(与计算能力有关) + */ + public short TopOverCostCount = 2000; + + public IdGeneratorOptions() { + + } + + public IdGeneratorOptions(short workerId) { + WorkerId = workerId; + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/OverCostActionArg.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/OverCostActionArg.java new file mode 100644 index 00000000..c4cc2715 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/contract/OverCostActionArg.java @@ -0,0 +1,45 @@ +package cn.g3soft.erp.common.util.snowflake.contract; +/** + * Id生成时回调参数 + */ +public class OverCostActionArg { + /** + * 事件类型 + * 1-开始,2-结束,8-漂移 + */ + public int ActionType = 0; + + /** + * 时间戳 + */ + public long TimeTick = 0; + + /** + * 机器码 + */ + public short WorkerId = 0; + + /** + * + */ + public int OverCostCountInOneTerm = 0; + + /** + * 漂移期间生产ID个数 + */ + public int GenCountInOneTerm = 0; + + /** + * 漂移周期 + */ + public int TermIndex = 0; + + public OverCostActionArg(short workerId, long timeTick, int actionType, int overCostCountInOneTerm, int genCountWhenOverCost, int index) { + ActionType = actionType; + TimeTick = timeTick; + WorkerId = workerId; + OverCostCountInOneTerm = overCostCountInOneTerm; + GenCountInOneTerm = genCountWhenOverCost; + TermIndex = index; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/core/SnowWorkerM1.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/core/SnowWorkerM1.java new file mode 100644 index 00000000..adcee5d3 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/core/SnowWorkerM1.java @@ -0,0 +1,229 @@ +package cn.g3soft.erp.common.util.snowflake.core; + +import cn.g3soft.erp.common.util.snowflake.contract.ISnowWorker; +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorException; +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorOptions; +import cn.g3soft.erp.common.util.snowflake.contract.OverCostActionArg; + +public class SnowWorkerM1 implements ISnowWorker { + /** + * 基础时间 + */ + protected final long BaseTime; + + /** + * 机器码 + */ + protected final short WorkerId; + + /** + * 机器码位长 + */ + protected final byte WorkerIdBitLength; + + /** + * 自增序列数位长 + */ + protected final byte SeqBitLength; + + /** + * 最大序列数(含) + */ + protected final int MaxSeqNumber; + + /** + * 最小序列数(含) + */ + protected final short MinSeqNumber; + + /** + * 最大漂移次数(含) + */ + protected final int TopOverCostCount; + + protected final byte _TimestampShift; + protected final static byte[] _SyncLock = new byte[0]; + + protected short _CurrentSeqNumber; + protected long _LastTimeTick = 0; + protected long _TurnBackTimeTick = 0; + protected byte _TurnBackIndex = 0; + + protected boolean _IsOverCost = false; + protected int _OverCostCountInOneTerm = 0; + protected int _GenCountInOneTerm = 0; + protected int _TermIndex = 0; + + public SnowWorkerM1(IdGeneratorOptions options) { + BaseTime = options.BaseTime != 0 ? options.BaseTime : 1582136402000L; + WorkerIdBitLength = options.WorkerIdBitLength == 0 ? 6 : options.WorkerIdBitLength; + WorkerId = options.WorkerId; + SeqBitLength = options.SeqBitLength == 0 ? 6 : options.SeqBitLength; + MaxSeqNumber = options.MaxSeqNumber <= 0 ? (1 << SeqBitLength) - 1 : options.MaxSeqNumber; + MinSeqNumber = options.MinSeqNumber; + // TopOverCostCount = options.TopOverCostCount == 0 ? 2000 : options.TopOverCostCount; + TopOverCostCount = options.TopOverCostCount; + _TimestampShift = (byte) (WorkerIdBitLength + SeqBitLength); + _CurrentSeqNumber = MinSeqNumber; + } + + private void DoGenIdAction(OverCostActionArg arg) { + + } + + private void BeginOverCostAction(long useTimeTick) { + + } + + private void EndOverCostAction(long useTimeTick) { + // if (_TermIndex > 10000) { + // _TermIndex = 0; + // } + } + + private void BeginTurnBackAction(long useTimeTick) { + + } + + private void EndTurnBackAction(long useTimeTick) { + + } + + private long NextOverCostId() { + long currentTimeTick = GetCurrentTimeTick(); + + if (currentTimeTick > _LastTimeTick) { + EndOverCostAction(currentTimeTick); + + _LastTimeTick = currentTimeTick; + _CurrentSeqNumber = MinSeqNumber; + _IsOverCost = false; + _OverCostCountInOneTerm = 0; + _GenCountInOneTerm = 0; + + return CalcId(_LastTimeTick); + } + + if (_OverCostCountInOneTerm >= TopOverCostCount) { + EndOverCostAction(currentTimeTick); + + _LastTimeTick = GetNextTimeTick(); + _CurrentSeqNumber = MinSeqNumber; + _IsOverCost = false; + _OverCostCountInOneTerm = 0; + _GenCountInOneTerm = 0; + + return CalcId(_LastTimeTick); + } + + if (_CurrentSeqNumber > MaxSeqNumber) { + _LastTimeTick++; + _CurrentSeqNumber = MinSeqNumber; + _IsOverCost = true; + _OverCostCountInOneTerm++; + _GenCountInOneTerm++; + + return CalcId(_LastTimeTick); + } + + _GenCountInOneTerm++; + return CalcId(_LastTimeTick); + } + + private long NextNormalId() throws IdGeneratorException { + long currentTimeTick = GetCurrentTimeTick(); + + if (currentTimeTick < _LastTimeTick) { + if (_TurnBackTimeTick < 1) { + _TurnBackTimeTick = _LastTimeTick - 1; + _TurnBackIndex++; + // 每毫秒序列数的前5位是预留位,0用于手工新值,1-4是时间回拨次序 + // 支持4次回拨次序(避免回拨重叠导致ID重复),可无限次回拨(次序循环使用)。 + if (_TurnBackIndex > 4) { + _TurnBackIndex = 1; + } + //BeginTurnBackAction(_TurnBackTimeTick); + } + + // try { + // Thread.sleep(1); + // } catch (InterruptedException e) { + // e.printStackTrace(); + // } + + return CalcTurnBackId(_TurnBackTimeTick); + } + + // 时间追平时,_TurnBackTimeTick清零 + if (_TurnBackTimeTick > 0) { + EndTurnBackAction(_TurnBackTimeTick); + _TurnBackTimeTick = 0; + } + + if (currentTimeTick > _LastTimeTick) { + _LastTimeTick = currentTimeTick; + _CurrentSeqNumber = MinSeqNumber; + + return CalcId(_LastTimeTick); + } + + if (_CurrentSeqNumber > MaxSeqNumber) { + BeginOverCostAction(currentTimeTick); + + _TermIndex++; + _LastTimeTick++; + _CurrentSeqNumber = MinSeqNumber; + _IsOverCost = true; + _OverCostCountInOneTerm = 1; + _GenCountInOneTerm = 1; + + return CalcId(_LastTimeTick); + } + + return CalcId(_LastTimeTick); + } + + private long CalcId(long useTimeTick) { + long result = ((useTimeTick << _TimestampShift) + + ((long) WorkerId << SeqBitLength) + + (int) _CurrentSeqNumber); + + _CurrentSeqNumber++; + return result; + } + + private long CalcTurnBackId(long useTimeTick) { + long result = ((useTimeTick << _TimestampShift) + + ((long) WorkerId << SeqBitLength) + _TurnBackIndex); + + _TurnBackTimeTick--; + return result; + } + + protected long GetCurrentTimeTick() { + long millis = System.currentTimeMillis(); + return millis - BaseTime; + } + + protected long GetNextTimeTick() { + long tempTimeTicker = GetCurrentTimeTick(); + + while (tempTimeTicker <= _LastTimeTick) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + e.printStackTrace(); + } + tempTimeTicker = GetCurrentTimeTick(); + } + + return tempTimeTicker; + } + + @Override + public long nextId() { + synchronized (_SyncLock) { + return _IsOverCost ? NextOverCostId() : NextNormalId(); + } + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/core/SnowWorkerM2.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/core/SnowWorkerM2.java new file mode 100644 index 00000000..44e2e550 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/core/SnowWorkerM2.java @@ -0,0 +1,37 @@ +package cn.g3soft.erp.common.util.snowflake.core; + + +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorException; +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorOptions; + +public class SnowWorkerM2 extends SnowWorkerM1 { + public SnowWorkerM2(IdGeneratorOptions options) { + super(options); + } + + @Override + public long nextId() { + synchronized (_SyncLock) { + long currentTimeTick = GetCurrentTimeTick(); + + if (_LastTimeTick == currentTimeTick) { + if (_CurrentSeqNumber++ > MaxSeqNumber) { + _CurrentSeqNumber = MinSeqNumber; + currentTimeTick = GetNextTimeTick(); + } + } else { + _CurrentSeqNumber = MinSeqNumber; + } + + if (currentTimeTick < _LastTimeTick) { + throw new IdGeneratorException("Time error for {0} milliseconds", _LastTimeTick - currentTimeTick); + } + + _LastTimeTick = currentTimeTick; + long result = ((currentTimeTick << _TimestampShift) + ((long) WorkerId << SeqBitLength) + (int) _CurrentSeqNumber); + + return result; + } + + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/idgen/DefaultIdGenerator.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/idgen/DefaultIdGenerator.java new file mode 100644 index 00000000..412af5b2 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/idgen/DefaultIdGenerator.java @@ -0,0 +1,90 @@ +package cn.g3soft.erp.common.util.snowflake.idgen; + + +import cn.g3soft.erp.common.util.snowflake.contract.IIdGenerator; +import cn.g3soft.erp.common.util.snowflake.contract.ISnowWorker; +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorException; +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorOptions; +import cn.g3soft.erp.common.util.snowflake.core.SnowWorkerM1; +import cn.g3soft.erp.common.util.snowflake.core.SnowWorkerM2; + +public class DefaultIdGenerator implements IIdGenerator { + + private static ISnowWorker _SnowWorker = null; + + public DefaultIdGenerator(IdGeneratorOptions options) throws IdGeneratorException { + if (options == null) { + throw new IdGeneratorException("options error."); + } + + // 1.BaseTime + if (options.BaseTime < 315504000000L || options.BaseTime > System.currentTimeMillis()) { + throw new IdGeneratorException("BaseTime error."); + } + + // 2.WorkerIdBitLength + if (options.WorkerIdBitLength <= 0) { + throw new IdGeneratorException("WorkerIdBitLength error.(range:[1, 21])"); + } + if (options.WorkerIdBitLength + options.SeqBitLength > 22) { + throw new IdGeneratorException("error:WorkerIdBitLength + SeqBitLength <= 22"); + } + + // 3.WorkerId + int maxWorkerIdNumber = (1 << options.WorkerIdBitLength) - 1; + if (maxWorkerIdNumber == 0) { + maxWorkerIdNumber = 63; + } + if (options.WorkerId < 0 || options.WorkerId > maxWorkerIdNumber) { + throw new IdGeneratorException( + "WorkerId error. (range:[0, " + (maxWorkerIdNumber > 0 ? maxWorkerIdNumber : 63) + "]"); + } + + // 4.SeqBitLength + if (options.SeqBitLength < 2 || options.SeqBitLength > 21) { + throw new IdGeneratorException("SeqBitLength error. (range:[2, 21])"); + } + + // 5.MaxSeqNumber + int maxSeqNumber = (1 << options.SeqBitLength) - 1; + if (maxSeqNumber == 0) { + maxSeqNumber = 63; + } + if (options.MaxSeqNumber < 0 || options.MaxSeqNumber > maxSeqNumber) { + throw new IdGeneratorException("MaxSeqNumber error. (range:[1, " + maxSeqNumber + "]"); + } + + // 6.MinSeqNumber + if (options.MinSeqNumber < 5 || options.MinSeqNumber > maxSeqNumber) { + throw new IdGeneratorException("MinSeqNumber error. (range:[5, " + maxSeqNumber + "]"); + } + + // 7.TopOverCostCount + if (options.TopOverCostCount < 0 || options.TopOverCostCount > 10000) { + throw new IdGeneratorException("TopOverCostCount error. (range:[0, 10000]"); + } + + switch (options.Method) { + case 2: + _SnowWorker = new SnowWorkerM2(options); + break; + case 1: + default: + _SnowWorker = new SnowWorkerM1(options); + break; + } + + if (options.Method == 1) { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + + @Override + public long newLong() { + return _SnowWorker.nextId(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/idgen/IdGenerator.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/idgen/IdGenerator.java new file mode 100644 index 00000000..e3a96d0e --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/common/util/snowflake/idgen/IdGenerator.java @@ -0,0 +1,36 @@ +package cn.g3soft.erp.common.util.snowflake.idgen; + +import cn.g3soft.erp.common.util.snowflake.contract.IIdGenerator; +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorException; +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorOptions; + +public class IdGenerator { + private static IIdGenerator idGenInstance = null; + + public static IIdGenerator getIdGenInstance() { + return idGenInstance; + } + + /** + * 设置参数,建议程序初始化时执行一次 + */ + public static void setIdGenerator(IdGeneratorOptions options) throws IdGeneratorException { + idGenInstance = new DefaultIdGenerator(options); + } + + /** + * 生成新的Id + * 调用本方法前,请确保调用了 SetIdGenerator 方法做初始化。 + * + */ + public static long nextId() throws IdGeneratorException { + // if (idGenInstance == null) { + // idGenInstance = new DefaultIdGenerator(new IdGeneratorOptions((short) 1)); + // } + + if (idGenInstance == null) + throw new IdGeneratorException("Please initialize Yitter.IdGeneratorOptions first."); + + return idGenInstance.newLong(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/AuthProperties.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/AuthProperties.java new file mode 100644 index 00000000..e3a60b62 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/AuthProperties.java @@ -0,0 +1,28 @@ +package cn.g3soft.erp.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "erp.auth") +public class AuthProperties { + + private String jwtSecret; + private Duration jwtExpiration = Duration.ofHours(24); + + public String getJwtSecret() { + return jwtSecret; + } + + public void setJwtSecret(String jwtSecret) { + this.jwtSecret = jwtSecret; + } + + public Duration getJwtExpiration() { + return jwtExpiration; + } + + public void setJwtExpiration(Duration jwtExpiration) { + this.jwtExpiration = jwtExpiration; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/FileStorageProperties.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/FileStorageProperties.java new file mode 100644 index 00000000..7ba6d329 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/FileStorageProperties.java @@ -0,0 +1,68 @@ +package cn.g3soft.erp.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "erp.file") +public class FileStorageProperties { + + private String storageType; + private Local local = new Local(); + private Oss oss = new Oss(); + + public String getStorageType() { + return storageType; + } + + public void setStorageType(String storageType) { + this.storageType = storageType; + } + + public Local getLocal() { + return local; + } + + public void setLocal(Local local) { + this.local = local; + } + + public Oss getOss() { + return oss; + } + + public void setOss(Oss oss) { + this.oss = oss; + } + + public static class Local { + private String storageDir; + private String urlPrefix; + + public String getStorageDir() { + return storageDir; + } + + public void setStorageDir(String storageDir) { + this.storageDir = storageDir; + } + + public String getUrlPrefix() { + return urlPrefix; + } + + public void setUrlPrefix(String urlPrefix) { + this.urlPrefix = urlPrefix; + } + } + + public static class Oss { + private String g3ossUrl; + + public String getG3ossUrl() { + return g3ossUrl; + } + + public void setG3ossUrl(String g3ossUrl) { + this.g3ossUrl = g3ossUrl; + } + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JacksonConfig.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JacksonConfig.java new file mode 100644 index 00000000..c9481674 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JacksonConfig.java @@ -0,0 +1,18 @@ +package cn.g3soft.erp.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.std.ToStringSerializer; + +@Configuration +public class JacksonConfig { + + @Bean + public SimpleModule longAsStringModule() { + SimpleModule module = new SimpleModule("long-as-string"); + module.addSerializer(Long.class, ToStringSerializer.instance); + module.addSerializer(Long.TYPE, ToStringSerializer.instance); + return module; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JwtAuthFilter.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JwtAuthFilter.java new file mode 100644 index 00000000..42bfda34 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/config/JwtAuthFilter.java @@ -0,0 +1,101 @@ +package cn.g3soft.erp.config; + +import cn.g3soft.erp.common.ApiResponse; +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.service.auth.ActiveSessionRegistry; +import cn.g3soft.erp.service.auth.JwtUtils; +import io.jsonwebtoken.JwtException; +import jakarta.annotation.Resource; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + private static final String LOGIN_PATH = "/auth/login"; + + @Resource + private JwtUtils jwtUtils; + + @Resource + private ActiveSessionRegistry activeSessionRegistry; + + @Resource + private ObjectMapper objectMapper; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return "OPTIONS".equalsIgnoreCase(request.getMethod()) + || LOGIN_PATH.equals(request.getServletPath()); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + JwtUtils.TokenClaims claims; + try { + String token = getToken(request); + claims = jwtUtils.parseToken(token); + } catch (JwtException | IllegalArgumentException exception) { + writeUnauthorized( + response, + ApiResponse.AUTH_ERROR_CODE, + "未登录或登录已失效" + ); + return; + } + + // JWT 已通过签名和到期校验;重启后缺失的内存会话由首个有效请求恢复。 + if (!activeSessionRegistry.isActive( + claims.orgId(), + claims.userId(), + claims.sessionId() + )) { + writeUnauthorized( + response, + ApiResponse.SESSION_REPLACED_CODE, + "账号已在其他设备登录,请重新登录" + ); + return; + } + + OrgContext.setOrgId(claims.orgId()); + OrgContext.setUserId(claims.userId()); + try { + filterChain.doFilter(request, response); + } finally { + OrgContext.clear(); + } + } + + private String getToken(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) { + throw new IllegalArgumentException("缺少登录凭证"); + } + return authorization.substring(BEARER_PREFIX.length()).trim(); + } + + private void writeUnauthorized( + HttpServletResponse response, + int code, + String message + ) throws IOException { + ApiResponse body = ApiResponse.fail(code, message); + response.setStatus(200); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + objectMapper.writeValue(response.getWriter(), body); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/AuthController.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/AuthController.java new file mode 100644 index 00000000..ed45c1ce --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/AuthController.java @@ -0,0 +1,56 @@ +package cn.g3soft.erp.controller; + +import cn.g3soft.erp.common.ApiResponse; +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.ParamUtils; +import cn.g3soft.erp.common.util.StringUtils; +import cn.g3soft.erp.service.auth.AuthService; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.sql.SQLException; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + @Resource + private AuthService authService; + + @PostMapping("/login") + public ApiResponse> login( + @RequestBody(required = false) Map request, + HttpServletRequest httpRequest + ) throws SQLException { + String orgId = ParamUtils.getString(request, "orgid"); + String userId = ParamUtils.getString(request, "userid"); + String password = ParamUtils.getString(request, "password"); + + if (StringUtils.isBlank(orgId)) { + throw new BusinessException("机构码不能为空"); + } + if (StringUtils.isBlank(userId)) { + throw new BusinessException("用户ID不能为空"); + } + + Optional> loginResponse = authService.login( + orgId, + userId, + password, + httpRequest + ); + if (loginResponse.isEmpty()) { + return ApiResponse.fail( + ApiResponse.AUTH_ERROR_CODE, + "机构码、账号或密码错误" + ); + } + return ApiResponse.success(loginResponse.get()); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/DataController.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/DataController.java new file mode 100644 index 00000000..e2ac2ee2 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/DataController.java @@ -0,0 +1,110 @@ +package cn.g3soft.erp.controller; + +import cn.g3soft.erp.common.ApiResponse; +import cn.g3soft.erp.common.util.ParamUtils; +import cn.g3soft.erp.common.util.snowflake.idgen.IdGenerator; +import cn.g3soft.erp.service.data.DataDeleteService; +import cn.g3soft.erp.service.data.DataSaveService; +import cn.g3soft.erp.service.data.DataService; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/data") +public class DataController { + + @Resource + private DataService dataService; + + @Resource + private DataSaveService dataSaveService; + + @Resource + private DataDeleteService dataDeleteService; + + @PostMapping("/loaddata") + public ApiResponse>> loadData( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(dataService.loadData(params)); + } + + @PostMapping("/loaddatabysql") + public ApiResponse>> loadDataBySql( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(dataService.loadDataBySql(params)); + } + + @PostMapping("/page") + public ApiResponse> page( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(dataService.page(params)); + } + + @PostMapping("/saveobjt") + public ApiResponse saveObject( + @RequestBody(required = false) List> requests + ) throws SQLException { + dataSaveService.save(requests); + return ApiResponse.success("保存成功", null); + } + + /** + * 统一删除接口:模块数据传 target=module + moduleCode + ids; + * 受控后端服务传 target=direct + table + key_field + ids(设计 §6.1、§1.1)。 + */ + @PostMapping("/delete") + public ApiResponse> delete( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(dataDeleteService.delete(params)); + } + + @PostMapping("/nextid") + public ApiResponse nextId(@RequestBody(required = false) Map params) { + int count = 1; + if (params != null && params.get("count") instanceof Number num) { + count = num.intValue(); + } + if (count <= 1) { + return ApiResponse.success(IdGenerator.nextId()); + } + List ids = new ArrayList<>(); + for (int i = 0; i < count; i++) { + ids.add(IdGenerator.nextId()); + } + return ApiResponse.success(ids); + } + + @PostMapping("/nextcode") + public ApiResponse nextCode(@RequestBody(required = false) Map params) + throws SQLException { + String moduleId = ParamUtils.getRequiredString(params, "moduleId"); + int count = 1; + if (params != null && params.get("count") instanceof Number num) { + count = num.intValue(); + } + List codes = dataSaveService.nextCode(moduleId, count); + if (codes.size() == 1) { + return ApiResponse.success(codes.get(0)); + } + return ApiResponse.success(codes); + } + + @PostMapping("/describe") + public ApiResponse>> describe( + @RequestBody(required = false) Map params + ) throws SQLException { + return ApiResponse.success(dataService.describe(ParamUtils.getRequiredString(params, "table"))); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/ExportController.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/ExportController.java new file mode 100644 index 00000000..a7c0ca2f --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/ExportController.java @@ -0,0 +1,111 @@ +package cn.g3soft.erp.controller; + +import cn.g3soft.erp.common.ApiResponse; +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.ParamUtils; +import cn.g3soft.erp.service.export.ExportService; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 列表导出。返回文件流而不是 ApiResponse;校验失败时在写流之前抛出, + * 由 GlobalExceptionHandler 以 HTTP 200 + JSON 业务错误返回(前端按 content-type 区分)。 + */ +@RestController +@RequestMapping("/export") +public class ExportController { + + @Resource + private ExportService exportService; + + @PostMapping("/list") + public void exportList( + @RequestBody(required = false) Map params, + HttpServletResponse response + ) throws IOException { + exportService.export(readRequest(params), response); + } + + /** + * 符合条件的行数(批量导出弹窗的「共 N 条」)。 + * 走与导出完全相同的权限链路,因此数字与真正导出的行数一致。 + */ + @PostMapping("/count") + public ApiResponse> count( + @RequestBody(required = false) Map params + ) { + Map result = new LinkedHashMap<>(); + result.put("total", exportService.count(readRequest(params))); + return ApiResponse.success(result); + } + + /** + * 各合计列的聚合值(列表底部的「总合计」)。 + * 与导出共用同一条权限链路与同一套聚合 SQL,因此与导出文件里的合计行必然一致。 + */ + @PostMapping("/totals") + public ApiResponse> totals( + @RequestBody(required = false) Map params + ) { + return ApiResponse.success(exportService.totals(readRequest(params))); + } + + private ExportService.ExportRequest readRequest(Map params) { + return new ExportService.ExportRequest( + ParamUtils.getRequiredString(params, "moduleId"), + ParamUtils.getString(params, "searchCondition"), + ParamUtils.getString(params, "orderBy"), + readRowKeys(params), + ParamUtils.getString(params, "keyField"), + ParamUtils.getList(params, "columns"), + readInteger(params, "rangeStart"), + readInteger(params, "rangeEnd"), + readInteger(params, "volumeSize"), + readBoolean(params, "withSummary") + ); + } + + /** + * 勾选行主键。JSON 数字可能被解析成 Integer/Long/Double,这里统一按原值透传给 + * PreparedStatement,不做字符串化(避免数字主键变成字符串后索引失效)。 + */ + private List readRowKeys(Map params) { + List values = ParamUtils.getList(params, "rowKeys"); + if (values == null) { + return null; + } + return List.copyOf(values); + } + + private Integer readInteger(Map params, String field) { + Number number = ParamUtils.get(params, field, Number.class); + if (number == null) return null; + try { + return new BigDecimal(number.toString()).intValueExact(); + } catch (ArithmeticException | NumberFormatException exception) { + throw new BusinessException(field + " 必须是有效整数"); + } + } + + /** 缺省输出合计行 */ + private boolean readBoolean(Map params, String field) { + Object value = params == null ? null : params.get(field); + if (value == null) { + return true; + } + if (value instanceof Boolean flag) { + return flag; + } + return Boolean.parseBoolean(String.valueOf(value)); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/FileController.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/FileController.java new file mode 100644 index 00000000..244710b8 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/controller/FileController.java @@ -0,0 +1,112 @@ +package cn.g3soft.erp.controller; + +import cn.g3soft.erp.common.ApiResponse; +import cn.g3soft.erp.config.FileStorageProperties; +import cn.g3soft.erp.service.file.FileService; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +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 org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/file") +public class FileController { + + @Resource + private FileStorageProperties props; + + @Resource + private FileService fileService; + + /** + * 返回存储模式配置 + */ + @PostMapping("/config") + public ApiResponse> config() { + Map cfg = new LinkedHashMap<>(); + cfg.put("storageType", props.getStorageType().toLowerCase()); + return ApiResponse.success(cfg); + } + + /** + * 本地上传(支持多文件,FormData key: "files") + */ + @PostMapping("/upload") + public ApiResponse>> upload( + @RequestParam("files") List files, + @RequestParam("father") String father, + @RequestParam("moduleId") String moduleId, + @RequestParam(value = "cateId", required = false) String cateId + ) throws IOException, SQLException { + return ApiResponse.success(fileService.localUpload(files, father, moduleId, cateId)); + } + + /** + * OSS 上传凭证(bucket = 机构码) + */ + @PostMapping("/upload-credential") + public ApiResponse uploadCredential() throws IOException, InterruptedException { + return ApiResponse.success(fileService.ossCredential()); + } + + /** + * 保存文件记录到 DB(OSS 直传后回调) + */ + @PostMapping("/save-record") + public ApiResponse> saveRecord(@RequestBody Map body) + throws SQLException { + return ApiResponse.success(fileService.saveRecord(body)); + } + + /** + * 删除文件 + */ + @PostMapping("/delete") + public ApiResponse delete(@RequestBody Map body) + throws SQLException, IOException, InterruptedException { + String subid = String.valueOf(body.get("subid")); + fileService.delete(subid); + return ApiResponse.success(null); + } + + /** + * 本地文件访问(路径含子目录: {orgId}/{yyyy}/{MM}/{dd}/{file}) + */ + @GetMapping("/static/{fileName:.+}") + public void serveFile(@PathVariable String fileName, HttpServletResponse response) throws IOException { + Path dir = Path.of(props.getLocal().getStorageDir()).toAbsolutePath().normalize(); + Path file = dir.resolve(fileName).normalize(); + + // 路径穿越防护 + if (!file.startsWith(dir) || !Files.isRegularFile(file)) { + response.setStatus(404); + return; + } + + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); + response.setContentLengthLong(Files.size(file)); + + try (OutputStream out = response.getOutputStream()) { + Files.copy(file, out); + } + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/MetadataReader.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/MetadataReader.java new file mode 100644 index 00000000..43c718fd --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/MetadataReader.java @@ -0,0 +1,113 @@ +package cn.g3soft.erp.database; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.SqlText; +import cn.g3soft.erp.database.model.ColumnMetadata; +import cn.g3soft.erp.database.model.TableMetadata; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 表结构内省:用 {@code SELECT * FROM t WHERE 1 = 0} 读结果集元数据,得到列名、类型、 + * 可空、长度、精度与可写性。所有方法接收已建好的 {@link Connection},本身不持有数据源。 + */ +@Component +public class MetadataReader { + + public TableMetadata loadTableMetadata( + Connection connection, + String tableName + ) throws SQLException { + if (connection == null) { + throw new IllegalArgumentException("数据库连接不能为空"); + } + + String requestedName = SqlText.requireText(tableName, "table"); + String quotedName = SqlText.quoteQualifiedIdentifier(requestedName); + String sql = "SELECT * FROM " + quotedName + " WHERE 1 = 0"; + LinkedHashMap columns = new LinkedHashMap<>(); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { + ResultSetMetaData metadata = resultSet.getMetaData(); + for (int index = 1; index <= metadata.getColumnCount(); index++) { + String columnName = metadata.getColumnName(index); + SqlText.validateIdentifier(columnName); + String normalizedName = normalizeName(columnName); + String typeName = metadata.getColumnTypeName(index); + boolean generated = metadata.isAutoIncrement(index) + || metadata.isReadOnly(index) + || "timestamp".equalsIgnoreCase(typeName) + || "rowversion".equalsIgnoreCase(typeName); + boolean nullable = metadata.isNullable(index) != ResultSetMetaData.columnNoNulls; + int size = metadata.getPrecision(index); + // 数值列的 precision / scale 单独保留:公式落库时要按真实列定义收敛精度(《FMS计算公式设计》7.7) + int precision = metadata.getPrecision(index); + int scale = metadata.getScale(index); + columns.put( + normalizedName, + new ColumnMetadata( + columnName, + normalizedName, + !generated, + typeName, + nullable, + size, + precision, + scale + ) + ); + } + } + + if (columns.isEmpty()) { + throw new BusinessException("目标表没有可识别字段: " + requestedName); + } + return new TableMetadata(requestedName, quotedName, columns); + } + + public ColumnMetadata getColumn(TableMetadata table, String fieldName) { + String requiredFieldName = SqlText.requireText(fieldName, "字段名"); + SqlText.validateIdentifier(requiredFieldName); + return table.columnsByName().get(normalizeName(requiredFieldName)); + } + + /** + * 读取表或视图的列结构(列名、类型、可空、长度),用于前端同步字段等场景。 + * 入参 table 会经后端标识符校验,不会拼接到 SQL。 + */ + public List> describeColumns( + Connection connection, + String tableName + ) throws SQLException { + TableMetadata table = loadTableMetadata(connection, tableName); + List> result = new ArrayList<>(); + for (ColumnMetadata column : table.columnsByName().values()) { + Map describe = new LinkedHashMap<>(); + describe.put("name", column.name()); + describe.put("type", column.typeName()); + describe.put("nullable", column.nullable()); + describe.put("size", column.size()); + result.add(describe); + } + return result; + } + + /** + * 列名归一化:与 {@link TableMetadata#columnsByName()} 的键、请求行里的字段名口径一致。 + * 写入侧按元数据匹配字段时也要用它,所以对外开放。 + */ + public static String normalizeName(String value) { + return value.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/RowReader.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/RowReader.java new file mode 100644 index 00000000..a551226a --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/RowReader.java @@ -0,0 +1,259 @@ +package cn.g3soft.erp.database; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.SqlText; +import cn.g3soft.erp.common.util.TimeUtils; +import cn.g3soft.erp.database.model.PageResult; +import jakarta.annotation.Resource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 只读查询:按表/视图名、条件、排序与字段列表查询,以及分页与自定义只读 SQL。 + * + *

不接 {@link Connection} 的两个入口({@link #loadData(String)}、{@link #page})走 + * 机构路由后的默认数据源;其余入口由调用方传入连接,以便和写入共用同一事务。 + */ +@Component +public class RowReader { + + /** 分页 SQL 里注入的总记录数别名,读出时按它识别并剔除 */ + private static final String PAGE_TOTAL_COLUMN = "__erp_page_total_count"; + + @Resource + private DataSource dataSource; + + public List> loadData(String viewName) throws SQLException { + return loadData(viewName, null, null, null); + } + + public List> loadData( + String viewName, + String searchCondition, + String orderBy, + List searchColumns + ) throws SQLException { + String sql = buildLoadDataSql(viewName, searchCondition, orderBy, searchColumns); + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + return executeQuery(connection, sql); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private String buildLoadDataSql( + String viewName, + String searchCondition, + String orderBy, + List searchColumns + ) { + String columnsSql = buildColumns(searchColumns); + StringBuilder sql = new StringBuilder("SELECT ") + .append(columnsSql) + .append(" FROM ") + .append(SqlText.quoteQualifiedIdentifier(viewName)); + + if (SqlText.hasText(searchCondition)) { + sql.append(" WHERE ").append(searchCondition.trim()); + } + if (SqlText.hasText(orderBy)) { + sql.append(" ORDER BY ").append(SqlText.buildSafeOrderBy(orderBy)); + } + + String querySql = sql.toString(); + SqlText.validateReadOnlySql(querySql); + return querySql; + } + + public List> loadDataBySql(String sql) throws SQLException { + SqlText.validateReadOnlySql(sql); + + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + return executeQuery(connection, sql); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + public List> loadDataBySql( + Connection connection, + String sql + ) throws SQLException { + if (connection == null) { + throw new IllegalArgumentException("数据库连接不能为空"); + } + SqlText.validateReadOnlySql(sql); + + return executeQuery(connection, sql); + } + + public PageResult page( + String viewName, + List searchColumns, + String searchCondition, + String orderBy, + int pageNo, + int pageSize + ) throws SQLException { + if (pageNo < 1) { + throw new BusinessException("page_no 必须大于 0"); + } + if (pageSize < 1) { + throw new BusinessException("page_size 必须大于 0"); + } + + String querySql = buildLoadDataSql( + viewName, + searchCondition, + null, + searchColumns + ); + String orderBySql = SqlText.buildSafeOrderBy(orderBy); + String pageSql = "SELECT [page_source].*, COUNT_BIG(1) OVER() AS [" + + PAGE_TOTAL_COLUMN + + "] FROM (" + + querySql + + ") AS [page_source] ORDER BY " + + orderBySql + + " OFFSET ? ROWS FETCH NEXT ? ROWS ONLY"; + long offset = (long) (pageNo - 1) * pageSize; + + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + PageResult result = executePageQuery( + connection, + pageSql, + offset, + pageSize, + pageNo + ); + if (!result.rows().isEmpty() || pageNo == 1) { + return result; + } + return new PageResult( + result.rows(), + countRows(connection, querySql), + pageNo, + pageSize + ); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private List> executeQuery( + Connection connection, + String sql + ) throws SQLException { + try (Statement statement = connection.createStatement()) { + try (ResultSet resultSet = statement.executeQuery(sql)) { + return readRows(resultSet); + } + } + } + + private PageResult executePageQuery( + Connection connection, + String sql, + long offset, + int pageSize, + int pageNo + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, offset); + statement.setInt(2, pageSize); + try (ResultSet resultSet = statement.executeQuery()) { + ResultSetMetaData metadata = resultSet.getMetaData(); + int totalColumnIndex = -1; + for (int index = 1; index <= metadata.getColumnCount(); index++) { + if (PAGE_TOTAL_COLUMN.equalsIgnoreCase(metadata.getColumnLabel(index))) { + totalColumnIndex = index; + } + } + if (totalColumnIndex < 0) { + throw new SQLException("分页查询缺少总记录数字段"); + } + + List> rows = new ArrayList<>(); + long total = 0; + while (resultSet.next()) { + total = resultSet.getLong(totalColumnIndex); + Map row = new LinkedHashMap<>(); + for (int index = 1; index <= metadata.getColumnCount(); index++) { + if (index != totalColumnIndex) { + row.put( + metadata.getColumnLabel(index), + normalizeJdbcValue(resultSet.getObject(index)) + ); + } + } + rows.add(row); + } + return new PageResult(rows, total, pageNo, pageSize); + } + } + } + + private long countRows(Connection connection, String sql) throws SQLException { + String countSql = "SELECT COUNT_BIG(1) FROM (" + + sql + + ") AS [count_source]"; + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(countSql)) { + resultSet.next(); + return resultSet.getLong(1); + } + } + + private String buildColumns(List searchColumns) { + if (searchColumns == null || searchColumns.isEmpty()) { + return "*"; + } + return searchColumns.stream() + .map(SqlText::quoteQualifiedIdentifier) + .collect(Collectors.joining(", ")); + } + + private List> readRows(ResultSet resultSet) throws SQLException { + ResultSetMetaData metadata = resultSet.getMetaData(); + int columnCount = metadata.getColumnCount(); + List> rows = new ArrayList<>(); + + while (resultSet.next()) { + Map row = new LinkedHashMap<>(columnCount); + for (int columnIndex = 1; columnIndex <= columnCount; columnIndex++) { + row.put( + metadata.getColumnLabel(columnIndex), + normalizeJdbcValue(resultSet.getObject(columnIndex)) + ); + } + rows.add(row); + } + + return rows; + } + + /** + * 读出口统一走 {@link TimeUtils#toApiValue}:库里 datetime 列存 UTC 墙钟值, + * 出口落成带 Z 的 ISO 串("2026-09-26T02:00:00Z"),前端展示时按浏览器时区转本地; + * date / time 没有时区语义,原样输出。完整口径见 {@link TimeUtils}。 + */ + private static Object normalizeJdbcValue(Object value) { + return TimeUtils.toApiValue(value); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/RowWriter.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/RowWriter.java new file mode 100644 index 00000000..870fffec --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/RowWriter.java @@ -0,0 +1,950 @@ +package cn.g3soft.erp.database; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.SqlText; +import cn.g3soft.erp.common.util.TimeUtils; +import cn.g3soft.erp.database.model.ColumnMetadata; +import cn.g3soft.erp.database.model.GuardCondition; +import cn.g3soft.erp.database.model.TableMetadata; +import cn.g3soft.erp.database.model.ZeroRowCause; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +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.StringJoiner; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 通用写入与条件更新语义:新增 / 修改 / 删除的参数化 SQL 拼装与执行, + * 以及 {@code $original}、{@code $expr}、guard 前置条件与零行原因定位。 + * + *

所有方法接收调用方传入的 {@link Connection}(由它在事务里统一管理),本类不持有数据源、 + * 不开启事务。条件更新的返回值语义是「实际影响行数,0 行不抛异常」,由调用方按 §4.3 + * 回查区分「目标行不存在 / 原值冲突 / guard 不成立」。 + */ +@Component +public class RowWriter { + + /** + * 行内保留元字段:声明「改动前的原值」,只进 WHERE 不进 SET(条件更新设计 §3.1)。 + * 业务列一律 {@code b_} 前缀,{@code $} 前缀保证永不撞名。 + */ + public static final String ORIGINAL_META = "$original"; + + /** 行内保留元字段:表达式值(§8.5),必须显式包装,裸字符串按普通值处理 */ + private static final String EXPR_META = "$expr"; + + /** {@code $original} 里的整行版本键:命中时走整行版本锁语义,成功后 +1(§3.1) */ + private static final String ROW_VERSION_FIELD = "b_row_version"; + + /** guard 条件的受控占位符:只识别完整的 {@code {合法列名}} token */ + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([A-Za-z_][A-Za-z0-9_]*)\\}"); + + /** 表达式值语法:本表列 ± 数值常量,不允许函数、子查询、多列或多个运算符(§8.5) */ + private static final Pattern EXPRESSION_PATTERN = Pattern.compile( + "\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*([+-])\\s*(-?(?:\\d+(?:\\.\\d+)?|\\.\\d+))\\s*" + ); + + /** 条件更新里目标表的固定别名:guard 与 $original 一律解析到 {@code t.[列]}(§3.1) */ + private static final String TARGET_ALIAS = "t"; + + /** + * 通用新增:不再自动生成主键(字符串业务键方案下,主键由请求方提供, + * 例如 s_module.b_id=模块编码、s_field 的 (b_module_id,b_field))。 + * 需要数据库生成主键的场景(如未来 bigint 业务表、bf_files 文件表) + * 由调用方先取号(IdGenerator / 文件服务 subid)再随行写入。 + */ + public int insert( + Connection connection, + TableMetadata table, + ColumnMetadata keyColumn, + Map row + ) throws SQLException { + Map normalizedRow = normalizeRow(row); + LinkedHashMap values = writableValues( + table, + normalizedRow, + null + ); + if (values.isEmpty()) { + throw new BusinessException("没有可新增的字段: " + table.requestedName()); + } + + String columnsSql = joinColumns(values.keySet().stream().toList()); + String placeholders = String.join(", ", Collections.nCopies(values.size(), "?")); + String sql = "INSERT INTO " + table.quotedName() + + " (" + columnsSql + ") VALUES (" + placeholders + ")"; + return executeWrite(connection, sql, new ArrayList<>(values.values()), "新增"); + } + + public int update( + Connection connection, + TableMetadata table, + ColumnMetadata keyColumn, + Map row + ) throws SQLException { + return update(connection, table, List.of(keyColumn), row); + } + + /** + * 通用修改:支持单列或多列业务键定位(字符串业务键方案下, + * 如 s_field 以 (b_module_id, b_field) 为键)。主键值从 row 中读取。 + */ + public int update( + Connection connection, + TableMetadata table, + List keyColumns, + Map row + ) throws SQLException { + if (keyColumns == null || keyColumns.isEmpty()) { + throw new BusinessException("更新操作必须指定主键字段"); + } + Map normalizedRow = normalizeRow(row); + for (ColumnMetadata keyColumn : keyColumns) { + requiredKeyValue(normalizedRow, keyColumn, table.requestedName()); + } + LinkedHashMap values = writableValues( + table, + normalizedRow, + null + ); + for (ColumnMetadata keyColumn : keyColumns) { + values.remove(keyColumn); + } + if (values.isEmpty()) { + throw new BusinessException("没有可修改的字段: " + table.requestedName()); + } + + String setSql = values.keySet().stream() + .map(column -> quoteColumn(column) + " = ?") + .collect(Collectors.joining(", ")); + String whereSql = keyColumns.stream() + .map(column -> quoteColumn(column) + " = ?") + .collect(Collectors.joining(" AND ")); + String sql = "UPDATE " + table.quotedName() + + " SET " + setSql + + " WHERE " + whereSql; + + List parameters = new ArrayList<>(values.values()); + for (ColumnMetadata keyColumn : keyColumns) { + parameters.add(normalizedRow.get(keyColumn.normalizedName())); + } + return executeWrite(connection, sql, parameters, "修改"); + } + + /** + * 带条件的通用修改(条件更新设计 §3.1 / §4.2):在主键定位之上追加 + * {@code $original} 原值比较与 guards 前置条件,条件不成立则命中 0 行。 + * + *

与 {@link #update(Connection, TableMetadata, List, Map)} 的关键差异是**返回值语义**: + * 这里返回实际影响行数且 0 行不抛异常——调用方要按 §4.3 顺序回查区分 + * 「目标行不存在 / 原值冲突 / guard 不成立」,三者错误码不同(2001 / 2003 / 2002)。 + * 只有命中多行才是系统异常(条件写宽了),在这里直接抛。 + * + *

{@code $original} 是保留元字段不会进入 SET,值为 null 时生成 {@code IS NULL}; + * 含 {@code b_row_version} 时额外追加 {@code SET [b_row_version] = t.[b_row_version] + 1}(整行版本锁)。 + */ + public int update( + Connection connection, + TableMetadata table, + List keyColumns, + Map row, + List guards + ) throws SQLException { + if (keyColumns == null || keyColumns.isEmpty()) { + throw new BusinessException("更新操作必须指定主键字段"); + } + Map normalizedRow = normalizeRow(row); + for (ColumnMetadata keyColumn : keyColumns) { + requiredKeyValue(normalizedRow, keyColumn, table.requestedName()); + } + + Map original = extractOriginal(table, normalizedRow); + LinkedHashMap expressions = extractExpressions(table, normalizedRow); + LinkedHashMap values = writableValues(table, normalizedRow, null); + expressions.keySet().forEach(values::remove); + for (ColumnMetadata keyColumn : keyColumns) { + values.remove(keyColumn); + } + ColumnMetadata versionColumn = original.containsKey(ROW_VERSION_FIELD) + ? requireRowVersionColumn(table) + : null; + if (versionColumn != null) { + // 版本列由服务端 +1;请求里同名的普通值不生效,避免 SET 里出现两次该列(§3.1) + values.remove(versionColumn); + } + if (values.isEmpty() && expressions.isEmpty() && versionColumn == null) { + throw new BusinessException("没有可修改的字段: " + table.requestedName()); + } + + String setSql = buildSetClause(values, expressions, versionColumn); + List parameters = new ArrayList<>(values.values()); + for (ExpressionValue expression : expressions.values()) { + parameters.add(expression.delta()); + } + + StringBuilder whereSql = new StringBuilder(); + String separator = ""; + List keyValues = new ArrayList<>(); + for (ColumnMetadata keyColumn : keyColumns) { + whereSql.append(separator) + .append(TARGET_ALIAS).append(".").append(quoteColumn(keyColumn)).append(" = ?"); + keyValues.add(normalizedRow.get(keyColumn.normalizedName())); + separator = " AND "; + } + // $original:null 走 IS NULL,不能写 = NULL(§3.1) + for (Map.Entry entry : original.entrySet()) { + ColumnMetadata column = table.columnsByName().get(entry.getKey()); + whereSql.append(separator).append(TARGET_ALIAS).append(".").append(quoteColumn(column)); + if (entry.getValue() == null) { + whereSql.append(" IS NULL"); + } else { + whereSql.append(" = ?"); + keyValues.add(entry.getValue()); + } + separator = " AND "; + } + parameters.addAll(keyValues); + for (GuardCondition guard : guards) { + whereSql.append(separator).append("(") + .append(resolveCondition(guard.condition(), table, normalizedRow, null)) + .append(")"); + separator = " AND "; + } + + String sql = "UPDATE " + TARGET_ALIAS + + " SET " + setSql + + " FROM " + table.quotedName() + " AS " + TARGET_ALIAS + + " WHERE " + whereSql; + int affectedRows = executeUpdate(connection, sql, parameters); + if (affectedRows > 1) { + throw new SQLException("条件更新命中多行: " + affectedRows); + } + return affectedRows; + } + + /** + * 带条件的通用新增(§3.1 / §7):guard 拼在 {@code INSERT ... SELECT ... WHERE} 的 WHERE 上, + * 让「判断与写入在同一条语句里」。占位符从当前请求行取值并绑定为参数(§2.3 第 3 条,不拼字面量)。 + * + *

返回实际影响行数,0 行不抛异常,由调用方按 §4.3 回查定位是哪条 guard 不成立。 + */ + public int insert( + Connection connection, + TableMetadata table, + ColumnMetadata keyColumn, + Map row, + List guards + ) throws SQLException { + Map normalizedRow = normalizeRow(row); + LinkedHashMap values = writableValues(table, normalizedRow, null); + if (values.isEmpty()) { + throw new BusinessException("没有可新增的字段: " + table.requestedName()); + } + if (guards.isEmpty()) { + // 没有条件就是普通新增:不能拼出空的 WHERE + return insert(connection, table, keyColumn, row); + } + + String columnsSql = joinColumns(values.keySet().stream().toList()); + String placeholders = String.join(", ", Collections.nCopies(values.size(), "?")); + // SELECT 的列值占位符在前,guard 的占位符在后,参数顺序与 SQL 出现顺序一致 + List parameters = new ArrayList<>(values.values()); + StringBuilder whereSql = new StringBuilder(); + String separator = ""; + for (GuardCondition guard : guards) { + whereSql.append(separator).append("(") + .append(resolveCondition(guard.condition(), table, normalizedRow, parameters)) + .append(")"); + separator = " AND "; + } + + String sql = "INSERT INTO " + table.quotedName() + + " (" + columnsSql + ") SELECT " + placeholders + + " WHERE " + whereSql; + int affectedRows = executeUpdate(connection, sql, parameters); + if (affectedRows > 1) { + throw new SQLException("条件新增命中多行: " + affectedRows); + } + return affectedRows; + } + + /** 目标行是否存在(§4.3 第一步):不存在沿用普通保存失败 2001,不算 guard 失败 */ + public boolean existsByKey( + Connection connection, + TableMetadata table, + List keyColumns, + Map row + ) throws SQLException { + Map normalizedRow = normalizeRow(row); + List parameters = new ArrayList<>(); + StringBuilder whereSql = new StringBuilder(); + String separator = ""; + for (ColumnMetadata keyColumn : keyColumns) { + whereSql.append(separator).append(quoteColumn(keyColumn)).append(" = ?"); + parameters.add(normalizedRow.get(keyColumn.normalizedName())); + separator = " AND "; + } + return exists(connection, "SELECT TOP 1 1 FROM " + table.quotedName() + " WHERE " + whereSql, parameters); + } + + /** + * 逐个比较 {@code $original} 声明的字段,返回第一个与库中当前值不匹配的列名(§4.3 第二步); + * 全部匹配返回 null。先整体比一次,命中就只花一次查询,只有冲突时才逐个定位。 + */ + public String firstMismatchedOriginal( + Connection connection, + TableMetadata table, + List keyColumns, + Map row, + Map original + ) throws SQLException { + if (original == null || original.isEmpty()) { + return null; + } + if (existsWithConditions(connection, table, keyColumns, row, original)) { + return null; + } + // 并发窗口内可能整体不匹配但逐条都匹配(数据又变了),此时回退到第一个声明字段 + for (String fieldName : original.keySet()) { + Map single = new LinkedHashMap<>(); + single.put(fieldName, original.get(fieldName)); + if (!existsWithConditions(connection, table, keyColumns, row, single)) { + return table.columnsByName().get(fieldName).name(); + } + } + return table.columnsByName().get(original.keySet().iterator().next()).name(); + } + + /** + * 单条 guard 条件是否成立(§4.3 第三步)。 + * + *

insert 的回查**不能带 key**——失败时那行还不存在,带 key 必然为空、 + * 结果永远落在第一条 guard 上,定位就废了(§4.3)。 + */ + public boolean matchesGuard( + Connection connection, + TableMetadata table, + List keyColumns, + GuardCondition guard, + Map row, + boolean insert + ) throws SQLException { + Map normalizedRow = normalizeRow(row); + List parameters = new ArrayList<>(); + StringBuilder sql = new StringBuilder("SELECT TOP 1 1"); + if (insert) { + sql.append(" WHERE (") + .append(resolveCondition(guard.condition(), table, normalizedRow, parameters)) + .append(")"); + return exists(connection, sql.toString(), parameters); + } + sql.append(" FROM ").append(table.quotedName()).append(" AS ").append(TARGET_ALIAS).append(" WHERE "); + String separator = ""; + for (ColumnMetadata keyColumn : keyColumns) { + sql.append(separator).append(TARGET_ALIAS).append(".").append(quoteColumn(keyColumn)).append(" = ?"); + parameters.add(normalizedRow.get(keyColumn.normalizedName())); + separator = " AND "; + } + sql.append(" AND (") + .append(resolveCondition(guard.condition(), table, normalizedRow, null)) + .append(")"); + return exists(connection, sql.toString(), parameters); + } + + /** + * update 命中 0 行时的原因定位(§4.3):顺序固定为 + * 目标行不存在 → {@code $original} 原值不匹配 → 逐条 guard 回查,第一个不成立的就是它。 + * + *

归因是 best-effort——回查期间数据可能又变了(§12 第 4 条), + * 若目标行存在、original 通过且所有 guard 都成立,failedGuard 为 null, + * 调用方应给通用并发冲突文案,而不是声称某条条件失败。 + */ + public ZeroRowCause diagnoseUpdateZeroRows( + Connection connection, + TableMetadata table, + List keyColumns, + Map row, + List guards + ) throws SQLException { + if (!existsByKey(connection, table, keyColumns, row)) { + return new ZeroRowCause(false, null, null); + } + String mismatched = firstMismatchedOriginal( + connection, + table, + keyColumns, + row, + extractOriginal(table, normalizeRow(row)) + ); + if (mismatched != null) { + return new ZeroRowCause(true, mismatched, null); + } + for (GuardCondition guard : guards) { + if (!matchesGuard(connection, table, keyColumns, guard, row, false)) { + return new ZeroRowCause(true, null, guard); + } + } + return new ZeroRowCause(true, null, null); + } + + /** + * insert 命中 0 行时的定位(§4.3):回查**不带 key**,返回第一条不成立的 guard; + * 全部成立返回 null(并发窗口,不伪造某条 guard)。 + */ + public GuardCondition diagnoseInsertZeroRows( + Connection connection, + TableMetadata table, + Map row, + List guards + ) throws SQLException { + for (GuardCondition guard : guards) { + if (!matchesGuard(connection, table, List.of(), guard, row, true)) { + return guard; + } + } + return null; + } + + /** + * 写入前预校验 guard 条件(§4.1):占位符列名不存在、条件含注释、insert 行缺被引用列 + * 这类**参数错误**必须在任何一行写入之前暴露,不能出现「前几行已写入、随后才拒绝」。 + * 这里只做干跑解析,产生的参数丢弃不用。 + */ + public void validateGuards( + TableMetadata table, + List guards, + List> rows, + boolean insert + ) { + if (guards.isEmpty()) { + return; + } + for (GuardCondition guard : guards) { + for (Map row : rows) { + resolveCondition( + guard.condition(), + table, + normalizeRow(row), + insert ? new ArrayList<>() : null + ); + } + } + } + + /** + * 带条件的通用删除(条件更新设计 §4.1):guard 拼在 {@code DELETE t FROM ... AS t WHERE} 上, + * 典型用法是「主表已审核就不许删明细」「费用已生成发票就不许删」。 + * + *

与无 guards 的 {@link #delete(Connection, TableMetadata, Map)} 有两个关键差异: + *

    + *
  • **必须唯一定位**:行内只允许完整主键列。批量条件删除带 guard 会产生歧义 + * (部分行满足时该删哪些),且 0 行时无法归因到具体行。
  • + *
  • **返回影响行数且 0 行不抛异常**,由调用方按 §4.3 区分「目标行不存在」(2001) 与「guard 不成立」(2002)。
  • + *
+ */ + public int delete( + Connection connection, + TableMetadata table, + List keyColumns, + Map row, + List guards + ) throws SQLException { + if (keyColumns == null || keyColumns.isEmpty()) { + throw new BusinessException("删除操作必须指定主键字段"); + } + Map normalizedRow = normalizeRow(row); + requireUniqueKeyRow(table, normalizedRow, keyColumns); + + StringBuilder whereSql = new StringBuilder(); + String separator = ""; + List parameters = new ArrayList<>(); + for (ColumnMetadata keyColumn : keyColumns) { + whereSql.append(separator) + .append(TARGET_ALIAS).append(".").append(quoteColumn(keyColumn)).append(" = ?"); + parameters.add(normalizedRow.get(keyColumn.normalizedName())); + separator = " AND "; + } + for (GuardCondition guard : guards) { + whereSql.append(separator).append("(") + .append(resolveCondition(guard.condition(), table, normalizedRow, null)) + .append(")"); + separator = " AND "; + } + + String sql = "DELETE " + TARGET_ALIAS + + " FROM " + table.quotedName() + " AS " + TARGET_ALIAS + + " WHERE " + whereSql; + int affectedRows = executeUpdate(connection, sql, parameters); + if (affectedRows > 1) { + throw new SQLException("条件删除命中多行: " + affectedRows); + } + return affectedRows; + } + + /** + * 带 guards 的删除要求**唯一定位**:行内只能有完整主键列(复合键要齐全), + * 不能混入业务列做成批量条件——批量删除带 guard 时「部分行满足」没有合理语义, + * 且 0 行时无法归因到具体哪一行。 + */ + private void requireUniqueKeyRow( + TableMetadata table, + Map normalizedRow, + List keyColumns + ) { + Set keyNames = new LinkedHashSet<>(); + for (ColumnMetadata keyColumn : keyColumns) { + keyNames.add(keyColumn.normalizedName()); + } + Set rowNames = new LinkedHashSet<>(); + for (String fieldName : normalizedRow.keySet()) { + if (fieldName.startsWith("$")) { + continue; + } + if (table.columnsByName().get(fieldName) == null) { + throw new BusinessException( + "删除行包含不存在的字段: " + table.requestedName() + "." + fieldName + ); + } + rowNames.add(fieldName); + } + if (!rowNames.equals(keyNames)) { + throw new BusinessException( + "带 guards 的删除行必须只携带完整主键: " + table.requestedName() + ); + } + } + + /** + * delete 命中 0 行时的原因定位(§4.3):目标行不存在 → 2001,逐条 guard 回查 → 2002。 + * 删除没有 {@code $original},所以不产生 2003。 + */ + public ZeroRowCause diagnoseDeleteZeroRows( + Connection connection, + TableMetadata table, + List keyColumns, + Map row, + List guards + ) throws SQLException { + if (!existsByKey(connection, table, keyColumns, row)) { + return new ZeroRowCause(false, null, null); + } + for (GuardCondition guard : guards) { + if (!matchesGuard(connection, table, keyColumns, guard, row, false)) { + return new ZeroRowCause(true, null, guard); + } + } + return new ZeroRowCause(true, null, null); + } + + /** + * 写入前预校验行内元字段(§4.1):{@code $original} 的字段名与版本列、{@code $expr} 的语法与列类型, + * 都必须在任何一行写入之前暴露,否则会出现「前几行已写入、第 N 行才发现字段名写错」。 + * + * @param insert true 时反过来校验:insert 行**不允许**带这两个元字段(新增的行没有原值可比) + */ + public void validateRowMeta( + TableMetadata table, + List> rows, + boolean insert + ) { + for (Map row : rows) { + Map normalizedRow = normalizeRow(row); + if (insert) { + if (normalizedRow.containsKey(ORIGINAL_META)) { + throw new BusinessException("insert 行不支持 $original:新增的行没有原值可比较"); + } + if (hasExpressions(normalizedRow)) { + throw new BusinessException("insert 行不支持 $expr:表达式值只对已存在行的自增有意义"); + } + continue; + } + Map original = extractOriginal(table, normalizedRow); + extractExpressions(table, normalizedRow); + if (original.containsKey(ROW_VERSION_FIELD)) { + requireRowVersionColumn(table); + } + } + } + + /** + * 行内是否有表达式值(某字段的值是 {@code {"$expr": ...}})。 + * 调用方据此决定是否走条件更新路径——普通路径会把整个 Map 当参数绑定,直接报类型转换错。 + */ + public boolean hasExpressions(Map row) { + if (row == null) { + return false; + } + for (Object value : row.values()) { + if (value instanceof Map wrapper && wrapper.containsKey(EXPR_META)) { + return true; + } + } + return false; + } + + private boolean existsWithConditions( + Connection connection, + TableMetadata table, + List keyColumns, + Map row, + Map conditions + ) throws SQLException { + Map normalizedRow = normalizeRow(row); + List parameters = new ArrayList<>(); + StringBuilder whereSql = new StringBuilder(); + String separator = ""; + for (ColumnMetadata keyColumn : keyColumns) { + whereSql.append(separator).append(quoteColumn(keyColumn)).append(" = ?"); + parameters.add(normalizedRow.get(keyColumn.normalizedName())); + separator = " AND "; + } + for (Map.Entry entry : conditions.entrySet()) { + ColumnMetadata column = table.columnsByName().get(entry.getKey()); + whereSql.append(separator).append(quoteColumn(column)); + if (entry.getValue() == null) { + whereSql.append(" IS NULL"); + } else { + whereSql.append(" = ?"); + parameters.add(entry.getValue()); + } + separator = " AND "; + } + return exists(connection, "SELECT TOP 1 1 FROM " + table.quotedName() + " WHERE " + whereSql, parameters); + } + + private boolean exists( + Connection connection, + String sql, + List parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.size(); index++) { + statement.setObject(index + 1, parameters.get(index)); + } + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next(); + } + } + } + + private int executeUpdate( + Connection connection, + String sql, + List parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.size(); index++) { + statement.setObject(index + 1, parameters.get(index)); + } + return statement.executeUpdate(); + } + } + + /** + * 解析 guard 条件里的 {@code {列名}} 占位符(§3.1)。 + * + *

只替换完整的 token:先用 {@link SqlText#maskStringLiterals} 做等长掩码, + * 掩码后被抹成空格的位置说明落在字符串字面量内,不做替换;条件里也禁止注释, + * 从根上排除「在注释里替换」的情况。 + * + * @param parameters 为 null 时按 update / delete 处理(占位符 → {@code t.[列]}); + * 非 null 时按 insert 处理(占位符 → {@code ?},值从请求行绑定) + */ + private String resolveCondition( + String condition, + TableMetadata table, + Map normalizedRow, + List parameters + ) { + String text = SqlText.requireText(condition, "guard condition"); + if (SqlText.containsSqlComment(text)) { + throw new BusinessException("guard condition 不允许包含注释"); + } + String masked = SqlText.maskStringLiterals(text); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(masked); + StringBuilder resolved = new StringBuilder(); + int copied = 0; + while (matcher.find()) { + if (!masked.substring(matcher.start(), matcher.end()) + .equals(text.substring(matcher.start(), matcher.end()))) { + continue; + } + String fieldName = matcher.group(1); + SqlText.validateIdentifier(fieldName); + resolved.append(text, copied, matcher.start()); + if (parameters == null) { + ColumnMetadata column = table.columnsByName().get(MetadataReader.normalizeName(fieldName)); + if (column == null) { + throw new BusinessException( + "guard 占位符字段不存在: " + table.requestedName() + "." + fieldName + ); + } + resolved.append(TARGET_ALIAS).append(".").append(quoteColumn(column)); + } else { + if (!normalizedRow.containsKey(MetadataReader.normalizeName(fieldName))) { + throw new BusinessException("insert 行缺少 guard 引用的字段: " + fieldName); + } + resolved.append("?"); + parameters.add(normalizedRow.get(MetadataReader.normalizeName(fieldName))); + } + copied = matcher.end(); + } + resolved.append(text.substring(copied)); + return resolved.toString(); + } + + /** {@code $original}:只认表里的真实列,值按列类型走与写入一致的口径转换 */ + private Map extractOriginal( + TableMetadata table, + Map normalizedRow + ) { + Object meta = normalizedRow.get(ORIGINAL_META); + if (meta == null) { + return Map.of(); + } + if (!(meta instanceof Map original)) { + throw new BusinessException("$original 必须是对象"); + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : original.entrySet()) { + String fieldName = SqlText.requireText(String.valueOf(entry.getKey()), "$original 字段名"); + SqlText.validateIdentifier(fieldName); + ColumnMetadata column = table.columnsByName().get(MetadataReader.normalizeName(fieldName)); + if (column == null) { + throw new BusinessException( + "$original 字段不存在: " + table.requestedName() + "." + fieldName + ); + } + result.put( + column.normalizedName(), + TimeUtils.toStorageValue(column.typeName(), entry.getValue()) + ); + } + return result; + } + + /** {@code $expr}:值必须是 {@code {"$expr": "列 ± 常量"}} 的显式包装(§8.5),裸字符串按普通值处理 */ + private LinkedHashMap extractExpressions( + TableMetadata table, + Map normalizedRow + ) { + LinkedHashMap expressions = new LinkedHashMap<>(); + for (Map.Entry entry : normalizedRow.entrySet()) { + if (!(entry.getValue() instanceof Map wrapper) || !wrapper.containsKey(EXPR_META)) { + continue; + } + ColumnMetadata column = table.columnsByName().get(entry.getKey()); + if (column == null) { + throw new BusinessException("表达式字段不存在: " + table.requestedName() + "." + entry.getKey()); + } + if (!column.writable()) { + throw new BusinessException("表达式字段不可写: " + column.name()); + } + if (!isNumericColumn(column)) { + throw new BusinessException("表达式字段必须是数值类型: " + column.name()); + } + if (wrapper.size() != 1) { + throw new BusinessException("$expr 不能与其他键同时使用"); + } + expressions.put(column, parseExpression(column, String.valueOf(wrapper.get(EXPR_META)))); + } + return expressions; + } + + /** + * 表达式只支持「被赋值的列 ± 数值常量」(§8.5):不允许函数、子查询、其他列或多个运算符。 + * 限制在自增列自身,是为了让 {@code SET [列] = t.[列] + ?} 始终成立,账本并发自增不产生假冲突。 + */ + private ExpressionValue parseExpression(ColumnMetadata column, String expression) { + Matcher matcher = EXPRESSION_PATTERN.matcher(expression == null ? "" : expression); + if (!matcher.matches()) { + throw new BusinessException("表达式格式不正确,只允许「本表列 ± 数值常量」: " + expression); + } + if (!matcher.group(1).equalsIgnoreCase(column.name())) { + throw new BusinessException( + "表达式只能引用被赋值的列: " + column.name() + ",实际为 " + matcher.group(1) + ); + } + String literal = matcher.group(3); + Number delta = literal.contains(".") ? new BigDecimal(literal) : Long.valueOf(literal); + return new ExpressionValue(matcher.group(2), delta); + } + + private boolean isNumericColumn(ColumnMetadata column) { + String typeName = column.typeName() == null ? "" : column.typeName().toLowerCase(Locale.ROOT); + return typeName.contains("int") + || typeName.contains("decimal") + || typeName.contains("numeric") + || typeName.contains("money") + || typeName.contains("float") + || typeName.contains("real"); + } + + private ColumnMetadata requireRowVersionColumn(TableMetadata table) { + ColumnMetadata column = table.columnsByName().get(ROW_VERSION_FIELD); + if (column == null) { + throw new BusinessException( + "整行版本比较要求表存在 " + ROW_VERSION_FIELD + " 列: " + table.requestedName() + ); + } + if (!column.writable()) { + throw new BusinessException(ROW_VERSION_FIELD + " 列不可写: " + table.requestedName()); + } + return column; + } + + private String buildSetClause( + LinkedHashMap values, + LinkedHashMap expressions, + ColumnMetadata versionColumn + ) { + StringJoiner joiner = new StringJoiner(", "); + for (ColumnMetadata column : values.keySet()) { + joiner.add(quoteColumn(column) + " = ?"); + } + for (Map.Entry entry : expressions.entrySet()) { + ColumnMetadata column = entry.getKey(); + joiner.add(quoteColumn(column) + + " = " + TARGET_ALIAS + "." + quoteColumn(column) + + " " + entry.getValue().operator() + " ?"); + } + if (versionColumn != null) { + joiner.add(quoteColumn(versionColumn) + + " = " + TARGET_ALIAS + "." + quoteColumn(versionColumn) + " + 1"); + } + return joiner.toString(); + } + + /** + * 按行内全部列值删除:列值组合为 AND 条件({b_id: x} 即按主键删, + * {b_module_id: m} 即按外键批量删)。值为 null 的条件按 SQL 语义匹配不到任何行。 + * 行内没有表中的列时拒绝执行,避免无 WHERE 条件的全表删除。 + */ + public int delete( + Connection connection, + TableMetadata table, + Map row + ) throws SQLException { + Map normalizedRow = normalizeRow(row); + LinkedHashMap conditions = writableValues(table, normalizedRow, null); + if (conditions.isEmpty()) { + throw new BusinessException("删除条件不能为空: " + table.requestedName()); + } + String whereSql = conditions.keySet().stream() + .map(column -> quoteColumn(column) + " = ?") + .collect(Collectors.joining(" AND ")); + String sql = "DELETE FROM " + table.quotedName() + " WHERE " + whereSql; + return executeDelete(connection, sql, new ArrayList<>(conditions.values())); + } + + private int executeDelete( + Connection connection, + String sql, + List parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.size(); index++) { + statement.setObject(index + 1, parameters.get(index)); + } + return statement.executeUpdate(); + } + } + + private int executeWrite( + Connection connection, + String sql, + List parameters, + String action + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.size(); index++) { + statement.setObject(index + 1, parameters.get(index)); + } + int affectedRows = statement.executeUpdate(); + if (affectedRows != 1) { + throw new SQLException(action + "记录数异常: " + affectedRows); + } + return affectedRows; + } + } + + private LinkedHashMap writableValues( + TableMetadata table, + Map normalizedRow, + String excludedColumn + ) { + LinkedHashMap values = new LinkedHashMap<>(); + for (ColumnMetadata column : table.columnsByName().values()) { + if (!column.writable() + || column.normalizedName().equals(excludedColumn) + || !normalizedRow.containsKey(column.normalizedName())) { + continue; + } + // 时间列按 UTC 口径规范化(带时区标记的 ISO 串 → UTC 墙钟值),其余列原样 + values.put( + column, + TimeUtils.toStorageValue(column.typeName(), normalizedRow.get(column.normalizedName())) + ); + } + return values; + } + + private Object requiredKeyValue( + Map normalizedRow, + ColumnMetadata keyColumn, + String tableName + ) { + Object value = normalizedRow.get(keyColumn.normalizedName()); + if (value == null || value instanceof String text && text.isBlank()) { + throw new BusinessException( + "缺少主键字段: " + tableName + "." + keyColumn.name() + ); + } + return value; + } + + private Map normalizeRow(Map row) { + if (row == null) { + throw new BusinessException("保存记录不能为空"); + } + + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : row.entrySet()) { + String fieldName = SqlText.requireText(entry.getKey(), "字段名"); + String normalizedName = MetadataReader.normalizeName(fieldName); + if (normalized.containsKey(normalizedName)) { + throw new BusinessException("保存记录包含重复字段: " + fieldName); + } + normalized.put(normalizedName, entry.getValue()); + } + return normalized; + } + + private String joinColumns(List columns) { + return columns.stream() + .map(this::quoteColumn) + .collect(Collectors.joining(", ")); + } + + private String quoteColumn(ColumnMetadata column) { + return SqlText.quoteQualifiedIdentifier(column.name()); + } + + /** 解析后的表达式值:被赋值的列 ± 数值常量(§8.5) */ + private record ExpressionValue(String operator, Number delta) { + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/ColumnMetadata.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/ColumnMetadata.java new file mode 100644 index 00000000..4011180e --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/ColumnMetadata.java @@ -0,0 +1,18 @@ +package cn.g3soft.erp.database.model; + +/** + * 表列描述:由 JDBC 结果集元数据探测得到,是通用读写与公式落库的共同依据。 + */ +public record ColumnMetadata( + String name, + String normalizedName, + boolean writable, + String typeName, + boolean nullable, + int size, + /** 数值列总位数(非数值列为 0):公式结果溢出检查用 */ + int precision, + /** 数值列小数位(非数值列为 0):公式结果在落库边界按它收敛 */ + int scale +) { +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/GuardCondition.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/GuardCondition.java new file mode 100644 index 00000000..a3ae5717 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/GuardCondition.java @@ -0,0 +1,20 @@ +package cn.g3soft.erp.database.model; + +import java.util.List; + +/** + * 保存前置条件(条件更新设计 §3.1):由调用方**按动作**声明,服务端只负责拼装与回查, + * 不理解业务语义。 + * + * @param condition SQL 片段,{@code {列名}} 是受控占位符 + * @param refs 声明引用的表(§3.4),只进日志 + * @param message 条件不成立时给用户的话,**由写条件的人负责描述** + * @param retryable 默认 true;为 false 时文案不追加「请刷新后重试」(§5.2) + */ +public record GuardCondition( + String condition, + List refs, + String message, + boolean retryable +) { +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/PageResult.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/PageResult.java new file mode 100644 index 00000000..8df7a1f4 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/PageResult.java @@ -0,0 +1,15 @@ +package cn.g3soft.erp.database.model; + +import java.util.List; +import java.util.Map; + +/** + * 分页查询结果:当前页行、总记录数与请求页码。 + */ +public record PageResult( + List> rows, + long total, + int pageNo, + int pageSize +) { +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/TableMetadata.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/TableMetadata.java new file mode 100644 index 00000000..eeddd1f0 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/TableMetadata.java @@ -0,0 +1,13 @@ +package cn.g3soft.erp.database.model; + +import java.util.LinkedHashMap; + +/** + * 表或视图的结构描述:请求名、加引号后的 SQL 名,以及按归一化列名索引的列定义。 + */ +public record TableMetadata( + String requestedName, + String quotedName, + LinkedHashMap columnsByName +) { +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/ZeroRowCause.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/ZeroRowCause.java new file mode 100644 index 00000000..46a55cdf --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/model/ZeroRowCause.java @@ -0,0 +1,12 @@ +package cn.g3soft.erp.database.model; + +/** + * 0 行原因定位结果(§4.3)。三者互斥,按顺序判定: + * 目标行不存在 → 原值冲突字段 → 不成立的 guard。 + */ +public record ZeroRowCause( + boolean targetExists, + String mismatchedField, + GuardCondition failedGuard +) { +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgContext.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgContext.java new file mode 100644 index 00000000..13e49023 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgContext.java @@ -0,0 +1,54 @@ +package cn.g3soft.erp.database.org; + +public final class OrgContext { + + private static final ThreadLocal ORG_ID_HOLDER = new ThreadLocal<>(); + private static final ThreadLocal USER_ID_HOLDER = new ThreadLocal<>(); + + private OrgContext() { + } + + // -- orgId -- + + public static void setOrgId(String orgId) { + if (orgId == null || orgId.isBlank()) { + throw new IllegalArgumentException("机构码不能为空"); + } + ORG_ID_HOLDER.set(orgId); + } + + public static String getOrgId() { + return ORG_ID_HOLDER.get(); + } + + public static String requireOrgId() { + String orgId = getOrgId(); + if (orgId == null) { + throw new IllegalStateException("当前请求未设置机构上下文"); + } + return orgId; + } + + // -- userId -- + + public static void setUserId(String userId) { + USER_ID_HOLDER.set(userId); + } + + public static String getUserId() { + return USER_ID_HOLDER.get(); + } + + public static String requireUserId() { + String userId = getUserId(); + if (userId == null) { + throw new IllegalStateException("当前请求未设置用户上下文"); + } + return userId; + } + + public static void clear() { + ORG_ID_HOLDER.remove(); + USER_ID_HOLDER.remove(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDataSourceFactory.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDataSourceFactory.java new file mode 100644 index 00000000..a26eb9e6 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDataSourceFactory.java @@ -0,0 +1,91 @@ +package cn.g3soft.erp.database.org; + +import com.alibaba.druid.pool.DruidDataSource; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.sql.SQLException; +import java.util.Locale; + +@Component +public class OrgDataSourceFactory { + + @Resource + private OrgDatabaseProperties properties; + + public DruidDataSource create(String orgId, OrgDatabaseConfig config) { + OrgDatabaseProperties.Pool poolProperties = properties.getPool(); + DruidDataSource dataSource = new DruidDataSource(); + dataSource.setName(orgId); + dataSource.setUrl(config.url()); + dataSource.setUsername(config.username()); + dataSource.setPassword(config.password()); + dataSource.setDriverClassName(config.driver()); + dataSource.setInitialSize(poolProperties.getInitialSize()); + dataSource.setMinIdle(poolProperties.getMinIdle()); + dataSource.setMaxActive(poolProperties.getMaxActive()); + dataSource.setMaxWait(poolProperties.getMaxWait()); + dataSource.setValidationQuery(poolProperties.getValidationQuery()); + dataSource.setTestWhileIdle(poolProperties.isTestWhileIdle()); + dataSource.setTestOnBorrow(poolProperties.isTestOnBorrow()); + dataSource.setTestOnReturn(poolProperties.isTestOnReturn()); + // 建连失败后必须继续重试,绝不能停掉建连线程: + // breakAfterAcquireFailure=true 会让 Druid 的 CreateConnectionThread 直接 return, + // 这个池从此再也建不出连接,failContinuous 也永远不会被重置 -> 只能重启进程 + dataSource.setConnectionErrorRetryAttempts(1); + dataSource.setBreakAfterAcquireFailure(false); + dataSource.setTimeBetweenConnectErrorMillis(2_000); + // failFast=true 时,只要 failContinuous 被置位,getConnection 会直接抛 + // DataSourceNotAvailableException(缓存的第一次 createError),不再尝试建连, + // 即使网络早已恢复也一直报错。快速失败语义由 initial-size + init() 抛异常保证 + dataSource.setFailFast(false); + // 公网链路必须有握手/读超时上限:缺 socketTimeout 时一次卡死的查询会长期占满 maxActive + dataSource.setConnectionProperties(buildConnectionProperties(config.url())); + // 连接最长存活 30 分钟,主动轮换,避开 NAT/防火墙静默回收空闲 TCP 会话 + dataSource.setPhyTimeoutMillis(1_800_000); + // 兜底连接泄漏:maxActive 只有 10 条,泄漏几小时就足以拖垮整个机构 + dataSource.setRemoveAbandoned(true); + dataSource.setRemoveAbandonedTimeoutMillis(180_000); + dataSource.setLogAbandoned(true); + long timeBetweenEvictionRunsMillis = poolProperties.getTimeBetweenEvictionRuns().toMillis(); + dataSource.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis); + dataSource.setMinEvictableIdleTimeMillis( + poolProperties.getMinEvictableIdleTime().toMillis() + ); + // 定期 ping 空闲连接,对抗云 NAT/防火墙回收空闲 TCP 会话(需配合 min-idle >= 1 才生效) + // Druid 要求 keepAliveBetweenTimeMillis 严格大于 timeBetweenEvictionRunsMillis + dataSource.setKeepAlive(true); + dataSource.setKeepAliveBetweenTimeMillis(Math.max(120_000L, timeBetweenEvictionRunsMillis * 2)); + + try { + dataSource.init(); + return dataSource; + } catch (SQLException exception) { + dataSource.close(); + throw new IllegalStateException("初始化机构数据库连接池失败: " + orgId, exception); + } + } + + /** + * SQL Server JDBC 的超时属性单位不统一:connectTimeout / loginTimeout 为秒,socketTimeout 为毫秒。 + * 机构配置里已显式声明的属性不覆盖。 + */ + private String buildConnectionProperties(String url) { + String lowerUrl = url.toLowerCase(Locale.ROOT); + StringBuilder builder = new StringBuilder(); + appendIfAbsent(builder, lowerUrl, "loginTimeout", "loginTimeout=10"); + appendIfAbsent(builder, lowerUrl, "connectTimeout", "connectTimeout=10"); + appendIfAbsent(builder, lowerUrl, "socketTimeout", "socketTimeout=60000"); + return builder.toString(); + } + + private void appendIfAbsent(StringBuilder builder, String lowerUrl, String key, String property) { + if (lowerUrl.contains(key.toLowerCase(Locale.ROOT))) { + return; + } + if (builder.length() > 0) { + builder.append(';'); + } + builder.append(property); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDataSourceManager.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDataSourceManager.java new file mode 100644 index 00000000..56981f0f --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDataSourceManager.java @@ -0,0 +1,198 @@ +package cn.g3soft.erp.database.org; + +import cn.g3soft.erp.common.exception.AuthenticationException; +import com.alibaba.druid.pool.DruidDataSource; +import jakarta.annotation.PreDestroy; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 机构连接池注册表:按机构码懒加载并缓存连接池,定期检测配置文件变更。 + * 配置(url/密码等)变更后无需重启,到期自动重建连接池并延迟关闭旧池, + * 保证在途请求有足够时间归还旧连接。 + */ +@Component +public class OrgDataSourceManager { + + private static final Logger log = LoggerFactory.getLogger(OrgDataSourceManager.class); + + private final ConcurrentMap dataSources = new ConcurrentHashMap<>(); + private final ScheduledExecutorService retiredPoolCloser = Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "org-datasource-retired-close"); + thread.setDaemon(true); + return thread; + }); + + @Resource + private OrgDatabaseConfigLoader configLoader; + + @Resource + private OrgDataSourceFactory dataSourceFactory; + + @Resource + private OrgDatabaseProperties properties; + + public DruidDataSource getDataSource(String orgId) { + String normalizedOrgId = configLoader.normalizeOrgId(orgId); + PoolEntry entry = dataSources.computeIfAbsent(normalizedOrgId, this::createEntry); + if (tryClaimRefreshCheck(entry)) { + return refreshIfConfigChanged(normalizedOrgId, entry).dataSource(); + } + return entry.dataSource(); + } + + public Connection getConnection(String orgId) throws SQLException { + return getDataSource(orgId).getConnection(); + } + + public Connection getConnection() throws SQLException { + return getConnection(OrgContext.requireOrgId()); + } + + public boolean closeDataSource(String orgId) { + String normalizedOrgId = configLoader.normalizeOrgId(orgId); + PoolEntry entry = dataSources.remove(normalizedOrgId); + if (entry == null) { + return false; + } + entry.dataSource().close(); + return true; + } + + public int getDataSourceCount() { + return dataSources.size(); + } + + @PreDestroy + public void closeAll() { + retiredPoolCloser.shutdownNow(); + dataSources.values().forEach(entry -> entry.dataSource().close()); + dataSources.clear(); + } + + /** + * 配置文件变更事件回调:立即核对并切换连接池(仅对已缓存的机构生效)。 + * 由配置监听线程调用,配置读取与建池的 I/O 不占用请求线程。 + */ + public void onConfigChanged(String orgId) { + String normalizedOrgId = configLoader.normalizeOrgId(orgId); + PoolEntry entry = dataSources.get(normalizedOrgId); + if (entry == null) { + return; + } + try { + refreshIfConfigChanged(normalizedOrgId, entry); + } catch (AuthenticationException exception) { + // 配置文件已删除,旧池已在 refreshIfConfigChanged 中淘汰 + } + } + + /** + * TTL 到期后由一个线程认领检测任务(CAS 防止并发重复 stat 文件)。 + */ + private boolean tryClaimRefreshCheck(PoolEntry entry) { + long intervalNanos = properties.getRefreshCheckInterval().toNanos(); + if (intervalNanos <= 0) { + return false; + } + long last = entry.checkedAtNanos.get(); + long now = System.nanoTime(); + if (now - last < intervalNanos) { + return false; + } + return entry.checkedAtNanos.compareAndSet(last, now); + } + + private PoolEntry refreshIfConfigChanged(String orgId, PoolEntry entry) { + long lastModified = configLoader.lastModified(orgId); + if (lastModified == entry.configLastModified()) { + return entry; + } + PoolEntry fresh; + try { + fresh = createEntry(orgId); + } catch (AuthenticationException exception) { + // 配置文件已不存在:机构已下线,淘汰旧池,按"机构码错误"处理 + if (dataSources.remove(orgId, entry)) { + scheduleClose(entry.dataSource()); + } + throw exception; + } catch (RuntimeException exception) { + // 配置读取或建池瞬时失败:保留旧池,下个检测周期重试 + log.warn("刷新机构数据库连接池失败,继续使用旧配置: {}", orgId, exception); + return entry; + } + if (fresh.config().equals(entry.config())) { + // 内容未变(仅时间戳被 touch):避免无谓重建 + entry.configLastModified(fresh.configLastModified()); + fresh.dataSource().close(); + return entry; + } + if (dataSources.replace(orgId, entry, fresh)) { + scheduleClose(entry.dataSource()); + log.info("机构数据库配置已变更,已切换到新连接池: {}", orgId); + return fresh; + } + // 并发刷新:其他线程已完成切换或已淘汰旧条目,丢弃本次多余的池 + fresh.dataSource().close(); + PoolEntry current = dataSources.get(orgId); + if (current != null) { + return current; + } + // 旧条目被并发淘汰且无新条目(如配置文件删除后又被重建):退回懒加载语义 + return dataSources.computeIfAbsent(orgId, this::createEntry); + } + + private PoolEntry createEntry(String orgId) { + // 先取时间戳再加载:若加载期间文件又被修改,记录的是旧时间戳,下个周期能检测到 + long lastModified = configLoader.lastModified(orgId); + OrgDatabaseConfig config = configLoader.load(orgId); + return new PoolEntry(dataSourceFactory.create(orgId, config), config, lastModified); + } + + private void scheduleClose(DruidDataSource dataSource) { + long delayMillis = properties.getRetiredPoolCloseDelay().toMillis(); + retiredPoolCloser.schedule(dataSource::close, delayMillis, TimeUnit.MILLISECONDS); + } + + private static final class PoolEntry { + + private final DruidDataSource dataSource; + private final OrgDatabaseConfig config; + private volatile long configLastModified; + private final AtomicLong checkedAtNanos = new AtomicLong(System.nanoTime()); + + private PoolEntry(DruidDataSource dataSource, OrgDatabaseConfig config, long configLastModified) { + this.dataSource = dataSource; + this.config = config; + this.configLastModified = configLastModified; + } + + private DruidDataSource dataSource() { + return dataSource; + } + + private OrgDatabaseConfig config() { + return config; + } + + private long configLastModified() { + return configLastModified; + } + + private void configLastModified(long configLastModified) { + this.configLastModified = configLastModified; + } + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfig.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfig.java new file mode 100644 index 00000000..5ddcd581 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfig.java @@ -0,0 +1,9 @@ +package cn.g3soft.erp.database.org; + +public record OrgDatabaseConfig( + String url, + String username, + String password, + String driver +) { +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoader.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoader.java new file mode 100644 index 00000000..186e7108 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoader.java @@ -0,0 +1,120 @@ +package cn.g3soft.erp.database.org; + +import cn.g3soft.erp.common.exception.AuthenticationException; +import cn.g3soft.erp.common.exception.BusinessException; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Properties; +import java.util.regex.Pattern; + +@Component +public class OrgDatabaseConfigLoader { + + private static final Pattern ORG_ID_PATTERN = Pattern.compile("[A-Z0-9_-]{1,32}"); + private static final String DEFAULT_SQL_SERVER_DRIVER = + "com.microsoft.sqlserver.jdbc.SQLServerDriver"; + + @Resource + private OrgDatabaseProperties properties; + + private Path configDir; + + @PostConstruct + public void init() { + this.configDir = properties.getConfigDir().toAbsolutePath().normalize(); + } + + public OrgDatabaseConfig load(String orgId) { + String normalizedOrgId = normalizeOrgId(orgId); + Path configFile = resolveConfigFile(normalizedOrgId); + if (!Files.isRegularFile(configFile)) { + throw new AuthenticationException("机构码错误"); + } + + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(configFile); + InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + properties.load(reader); + } catch (IOException exception) { + throw new IllegalStateException("读取机构数据库配置文件失败: " + configFile, exception); + } + + String url = required(properties, "url", configFile); + if (!url.toLowerCase(Locale.ROOT).startsWith("jdbc:sqlserver:")) { + throw new IllegalStateException("机构数据库必须使用 SQL Server: " + configFile); + } + + return new OrgDatabaseConfig( + url, + required(properties, "username", configFile), + requiredPassword(properties, configFile), + readDriver(properties) + ); + } + + public String normalizeOrgId(String orgId) { + if (orgId == null) { + throw new BusinessException("机构码不能为空"); + } + + String normalized = orgId.trim().toUpperCase(Locale.ROOT); + if (!ORG_ID_PATTERN.matcher(normalized).matches()) { + throw new BusinessException("机构码格式不正确"); + } + return normalized; + } + + public Path getConfigDir() { + return configDir; + } + + /** + * 配置文件修改时间戳,用于检测配置变更。文件不存在时返回 -1。 + */ + public long lastModified(String orgId) { + Path configFile = resolveConfigFile(normalizeOrgId(orgId)); + try { + return Files.getLastModifiedTime(configFile).toMillis(); + } catch (IOException exception) { + return -1L; + } + } + + private Path resolveConfigFile(String normalizedOrgId) { + Path configFile = configDir.resolve(normalizedOrgId + ".properties").normalize(); + if (!configFile.startsWith(configDir)) { + throw new IllegalArgumentException("机构配置文件路径不合法"); + } + return configFile; + } + + private String required(Properties properties, String key, Path configFile) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalStateException("机构数据库配置缺少 " + key + ": " + configFile); + } + return value.trim(); + } + + private String requiredPassword(Properties properties, Path configFile) { + String password = properties.getProperty("password"); + if (password == null || password.isBlank()) { + throw new IllegalStateException("机构数据库配置缺少 password: " + configFile); + } + return password; + } + + private String readDriver(Properties properties) { + String driver = properties.getProperty("driver"); + return driver == null || driver.isBlank() ? DEFAULT_SQL_SERVER_DRIVER : driver.trim(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigWatcher.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigWatcher.java new file mode 100644 index 00000000..e9c66949 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseConfigWatcher.java @@ -0,0 +1,141 @@ +package cn.g3soft.erp.database.org; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.ClosedWatchServiceException; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 监听机构配置目录,配置文件变更时立即通知 {@link OrgDataSourceManager} 重建连接池, + * 使配置变更毫秒级生效,配置读取与建池的 I/O 全部发生在监听线程,不占用请求线程。 + * 监听注册失败、事件溢出或目录失效时自动降级为定期轮询兜底,不影响服务。 + */ +@Component +public class OrgDatabaseConfigWatcher { + + private static final Logger log = LoggerFactory.getLogger(OrgDatabaseConfigWatcher.class); + private static final Pattern CONFIG_FILE_PATTERN = + Pattern.compile("([A-Za-z0-9_-]{1,32})\\.properties"); + private static final long DEBOUNCE_MILLIS = 500; + + @Resource + private OrgDatabaseConfigLoader configLoader; + + @Resource + private OrgDataSourceManager dataSourceManager; + + private WatchService watchService; + private volatile Thread watcherThread; + + @PostConstruct + public void start() { + Path configDir = configLoader.getConfigDir(); + try { + watchService = FileSystems.getDefault().newWatchService(); + configDir.register( + watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_DELETE + ); + } catch (IOException exception) { + log.warn("注册机构配置目录监听失败,配置变更检测降级为定期轮询: {}", configDir, exception); + closeWatchService(); + return; + } + Thread thread = new Thread(this::watch, "org-dbconfig-watcher"); + thread.setDaemon(true); + this.watcherThread = thread; + thread.start(); + log.info("已启动机构配置目录监听: {}", configDir); + } + + @PreDestroy + public void stop() { + Thread thread = watcherThread; + if (thread != null) { + thread.interrupt(); + } + closeWatchService(); + } + + private void watch() { + try { + while (true) { + WatchKey key = watchService.take(); + Set orgIds = new HashSet<>(); + collect(key, orgIds); + // 合并安静期内的后续事件(编辑器保存常触发多次写入),避免重复重建连接池 + long deadline = System.currentTimeMillis() + DEBOUNCE_MILLIS; + while (System.currentTimeMillis() < deadline) { + WatchKey more = watchService.poll( + deadline - System.currentTimeMillis(), TimeUnit.MILLISECONDS); + if (more == null) { + break; + } + collect(more, orgIds); + } + orgIds.forEach(this::notifyChanged); + } + } catch (ClosedWatchServiceException | InterruptedException exception) { + if (exception instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + // 正常退出:服务关闭或线程中断,轮询兜底仍然生效 + log.debug("机构配置目录监听线程退出"); + } + } + + private void collect(WatchKey key, Set orgIds) { + for (WatchEvent event : key.pollEvents()) { + if (event.kind() == StandardWatchEventKinds.OVERFLOW) { + continue; + } + if (event.context() instanceof Path path) { + Matcher matcher = CONFIG_FILE_PATTERN.matcher(path.getFileName().toString()); + if (matcher.matches()) { + orgIds.add(matcher.group(1).toUpperCase(Locale.ROOT)); + } + } + } + if (!key.reset()) { + log.warn("机构配置目录已不可用,文件监听停止,配置变更检测降级为定期轮询"); + closeWatchService(); + } + } + + private void notifyChanged(String orgId) { + try { + dataSourceManager.onConfigChanged(orgId); + } catch (RuntimeException exception) { + log.warn("处理机构配置变更事件失败: {}", orgId, exception); + } + } + + private void closeWatchService() { + if (watchService != null) { + try { + watchService.close(); + } catch (IOException ignored) { + // 关闭失败无需处理 + } + } + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseProperties.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseProperties.java new file mode 100644 index 00000000..f9e93758 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgDatabaseProperties.java @@ -0,0 +1,137 @@ +package cn.g3soft.erp.database.org; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.nio.file.Path; +import java.time.Duration; + +@ConfigurationProperties(prefix = "erp.database") +public class OrgDatabaseProperties { + + private Path configDir = Path.of("./config/dbconfigs"); + private Duration refreshCheckInterval = Duration.ofSeconds(30); + private Duration retiredPoolCloseDelay = Duration.ofSeconds(60); + private final Pool pool = new Pool(); + + public Path getConfigDir() { + return configDir; + } + + public void setConfigDir(Path configDir) { + this.configDir = configDir; + } + + public Duration getRefreshCheckInterval() { + return refreshCheckInterval; + } + + public void setRefreshCheckInterval(Duration refreshCheckInterval) { + this.refreshCheckInterval = refreshCheckInterval; + } + + public Duration getRetiredPoolCloseDelay() { + return retiredPoolCloseDelay; + } + + public void setRetiredPoolCloseDelay(Duration retiredPoolCloseDelay) { + this.retiredPoolCloseDelay = retiredPoolCloseDelay; + } + + public Pool getPool() { + return pool; + } + + public static class Pool { + + private int initialSize; + private int minIdle; + private int maxActive = 10; + private long maxWait = 5000; + private String validationQuery = "SELECT 1"; + private boolean testWhileIdle = true; + private boolean testOnBorrow; + private boolean testOnReturn; + private Duration timeBetweenEvictionRuns = Duration.ofMinutes(1); + private Duration minEvictableIdleTime = Duration.ofMinutes(5); + + public int getInitialSize() { + return initialSize; + } + + public void setInitialSize(int initialSize) { + this.initialSize = initialSize; + } + + public int getMinIdle() { + return minIdle; + } + + public void setMinIdle(int minIdle) { + this.minIdle = minIdle; + } + + public int getMaxActive() { + return maxActive; + } + + public void setMaxActive(int maxActive) { + this.maxActive = maxActive; + } + + public long getMaxWait() { + return maxWait; + } + + public void setMaxWait(long maxWait) { + this.maxWait = maxWait; + } + + public String getValidationQuery() { + return validationQuery; + } + + public void setValidationQuery(String validationQuery) { + this.validationQuery = validationQuery; + } + + public boolean isTestWhileIdle() { + return testWhileIdle; + } + + public void setTestWhileIdle(boolean testWhileIdle) { + this.testWhileIdle = testWhileIdle; + } + + public boolean isTestOnBorrow() { + return testOnBorrow; + } + + public void setTestOnBorrow(boolean testOnBorrow) { + this.testOnBorrow = testOnBorrow; + } + + public boolean isTestOnReturn() { + return testOnReturn; + } + + public void setTestOnReturn(boolean testOnReturn) { + this.testOnReturn = testOnReturn; + } + + public Duration getTimeBetweenEvictionRuns() { + return timeBetweenEvictionRuns; + } + + public void setTimeBetweenEvictionRuns(Duration timeBetweenEvictionRuns) { + this.timeBetweenEvictionRuns = timeBetweenEvictionRuns; + } + + public Duration getMinEvictableIdleTime() { + return minEvictableIdleTime; + } + + public void setMinEvictableIdleTime(Duration minEvictableIdleTime) { + this.minEvictableIdleTime = minEvictableIdleTime; + } + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgRoutingDataSource.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgRoutingDataSource.java new file mode 100644 index 00000000..2f48d936 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/database/org/OrgRoutingDataSource.java @@ -0,0 +1,28 @@ +package cn.g3soft.erp.database.org; + +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.AbstractDataSource; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.SQLException; + +@Primary +@Component +public class OrgRoutingDataSource extends AbstractDataSource { + + @Resource + private OrgDataSourceManager dataSourceManager; + + @Override + public Connection getConnection() throws SQLException { + return dataSourceManager.getConnection(); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + return dataSourceManager.getDataSource(OrgContext.requireOrgId()) + .getConnection(username, password); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/ActiveSessionRegistry.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/ActiveSessionRegistry.java new file mode 100644 index 00000000..59e46f8e --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/ActiveSessionRegistry.java @@ -0,0 +1,32 @@ +package cn.g3soft.erp.service.auth; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class ActiveSessionRegistry { + + private final Map activeSessions = new ConcurrentHashMap<>(); + + public void activate(String orgId, String userId, String sessionId) { + activeSessions.put(new AccountKey(orgId, userId), sessionId); + } + + /** + * 必须在 JWT 签名和有效期校验通过后调用。 + * 重启后由首个有效请求恢复账号会话,不持久化;重启前的互踢历史也随之清空。 + */ + public boolean isActive(String orgId, String userId, String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return false; + } + // 原子登记:并发首次请求只能有一个会话胜出,也不会覆盖并发的新登录。 + String activeSession = activeSessions.putIfAbsent(new AccountKey(orgId, userId), sessionId); + return activeSession == null || sessionId.equals(activeSession); + } + + private record AccountKey(String orgId, String userId) { + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/AuthService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/AuthService.java new file mode 100644 index 00000000..f1f9e8fc --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/AuthService.java @@ -0,0 +1,111 @@ +package cn.g3soft.erp.service.auth; + +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Service +public class AuthService { + + @Resource + private UserAccountReader userAccountReader; + + @Resource + private JwtUtils jwtUtils; + + @Resource + private ActiveSessionRegistry activeSessionRegistry; + + @Resource + private LoginLogService loginLogService; + + public Optional> login( + String orgId, + String userId, + String password, + HttpServletRequest request + ) throws SQLException { + String normalizedOrgId = orgId.trim().toUpperCase(Locale.ROOT); + String normalizedUserId = userId.trim(); + List> users = userAccountReader.findActiveAccount( + normalizedOrgId, + normalizedUserId + ); + + if (users.isEmpty()) { + loginLogService.recordLogin( + normalizedOrgId, normalizedUserId, + false, "账号不存在", null, request); + return Optional.empty(); + } + + Map account = users.get(0); + String storedPassword = getString(account, "b_password"); + if (!passwordMatches(storedPassword, password)) { + loginLogService.recordLogin( + normalizedOrgId, normalizedUserId, + false, "密码错误", null, request); + return Optional.empty(); + } + + // 字符串业务键方案:b_user.b_id 即用户账号本身 + String accountId = getString(account, "b_id"); + if (accountId == null || accountId.isBlank()) { + throw new IllegalStateException("登录用户缺少有效的 b_id"); + } + String name = getString(account, "b_name"); + String sessionId = UUID.randomUUID().toString(); + String token = jwtUtils.generateToken(accountId, normalizedOrgId, sessionId); + activeSessionRegistry.activate(normalizedOrgId, accountId, sessionId); + + loginLogService.recordLogin( + normalizedOrgId, accountId, + true, null, sessionId, request); + + Map user = new LinkedHashMap<>(); + user.put("id", accountId); + user.put("account", accountId); + user.put("name", name); + + Map result = new LinkedHashMap<>(); + result.put("token", token); + result.put("orgid", normalizedOrgId); + result.put("user", user); + return Optional.of(result); + } + + private String getString(Map row, String column) { + Object value = getValue(row, column); + return value == null ? null : value.toString(); + } + + private Object getValue(Map row, String column) { + for (Map.Entry entry : row.entrySet()) { + if (entry.getKey().equalsIgnoreCase(column)) { + return entry.getValue(); + } + } + return null; + } + + private boolean passwordMatches(String storedPassword, String submittedPassword) { + if (storedPassword == null || submittedPassword == null) { + return false; + } + + return MessageDigest.isEqual( + storedPassword.getBytes(StandardCharsets.UTF_8), + submittedPassword.getBytes(StandardCharsets.UTF_8) + ); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/JwtUtils.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/JwtUtils.java new file mode 100644 index 00000000..1c2e890a --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/JwtUtils.java @@ -0,0 +1,81 @@ +package cn.g3soft.erp.service.auth; + +import cn.g3soft.erp.config.AuthProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +@Component +public class JwtUtils { + + @Resource + private AuthProperties properties; + + private SecretKey signingKey; + private Duration expiration; + private JwtParser jwtParser; + + @PostConstruct + public void init() { + String jwtSecret = properties.getJwtSecret(); + if (jwtSecret == null || jwtSecret.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("JWT 密钥长度不能少于 32 字节"); + } + if (properties.getJwtExpiration() == null + || properties.getJwtExpiration().isZero() + || properties.getJwtExpiration().isNegative()) { + throw new IllegalStateException("JWT 有效期必须大于 0"); + } + + this.signingKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); + this.expiration = properties.getJwtExpiration(); + this.jwtParser = Jwts.parser().verifyWith(signingKey).build(); + } + + public String generateToken(String userId, String orgId, String sessionId) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(expiration); + + return Jwts.builder() + .subject(userId) + .id(sessionId) + .claim("orgid", orgId) + .issuedAt(Date.from(issuedAt)) + .expiration(Date.from(expiresAt)) + .signWith(signingKey) + .compact(); + } + + public TokenClaims parseToken(String token) { + Claims claims = jwtParser.parseSignedClaims(token).getPayload(); + return new TokenClaims( + required(claims.getSubject(), "用户ID"), + required(claims.get("orgid", String.class), "机构码"), + required(claims.getId(), "会话ID") + ); + } + + public String getOrgId(String token) { + return parseToken(token).orgId(); + } + + private String required(String value, String claimName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("登录凭证缺少" + claimName); + } + return value; + } + + public record TokenClaims(String userId, String orgId, String sessionId) { + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/LoginLogService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/LoginLogService.java new file mode 100644 index 00000000..5f7ad1cc --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/LoginLogService.java @@ -0,0 +1,90 @@ +package cn.g3soft.erp.service.auth; + +import cn.g3soft.erp.common.util.TimeUtils; +import cn.g3soft.erp.common.util.UuidV7Generator; +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.service.auth.client.ClientInfo; +import cn.g3soft.erp.service.auth.client.ClientInfoExtractor; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import cn.g3soft.erp.service.data.DataSaveService; + +/** + * 登录日志服务:在登录成功或失败时采集客户端信息,并通过通用 saveobjt 机制写入 s_log_login 表。 + * 写日志失败不影响登录主流程;机构上下文通过 OrgContext 临时设置并在 finally 中清理。 + */ +@Service +public class LoginLogService { + + private static final Logger log = LoggerFactory.getLogger(LoginLogService.class); + + /** 登录类型:密码登录(与 s_log_login.b_login_type 约定一致) */ + private static final String LOGIN_TYPE_PASSWORD = "password"; + + private final DataSaveService dataSaveService; + private final ClientInfoExtractor clientInfoExtractor; + + public LoginLogService(DataSaveService dataSaveService, ClientInfoExtractor clientInfoExtractor) { + this.dataSaveService = dataSaveService; + this.clientInfoExtractor = clientInfoExtractor; + } + + public void recordLogin( + String orgId, + String account, + boolean success, + String failReason, + String sessionId, + HttpServletRequest request + ) { + if (orgId == null || orgId.isBlank()) { + return; + } + try { + ClientInfo info = clientInfoExtractor.extract(request); + + Map row = new LinkedHashMap<>(); + // 字符串业务键方案:主键为业务层生成的 UUIDv7 + row.put("b_id", UuidV7Generator.nextUuid().toString()); + // b_user_id 引用 b_user.b_id(账号字符串,即稳定用户标识)。 + // 失败也照记:失败记录的账号恰恰是审计最需要的信息 —— 没有它就无法回答 + // 「哪个账号正在被爆破 / 被探测」;日志列表直接展示本列(v_s_log_login.v_user_name)。 + row.put("b_user_id", account); + row.put("b_login_type", LOGIN_TYPE_PASSWORD); + // datetime2 无时区语义:全库统一写 UTC 墙钟值(口径见 TimeUtils), + // 读出口再补 Z 标记、由前端按浏览器时区展示;不随 JVM / 服务器时区漂移。 + row.put("b_occurdatetime", TimeUtils.utcNow()); + row.put("b_ip", info.ip()); + row.put("b_ip_location", info.ipLocation()); + row.put("b_browser", info.browser()); + row.put("b_browser_version", info.browserVersion()); + row.put("b_os", info.os()); + row.put("b_os_version", info.osVersion()); + row.put("b_device_type", info.deviceType()); + row.put("b_user_agent", info.userAgent()); + row.put("b_login_result", success ? 1 : 0); + row.put("b_fail_reason", failReason); + row.put("b_session_id", sessionId); + + Map tableOp = new LinkedHashMap<>(); + tableOp.put("table", "s_log_login"); + tableOp.put("key_field", "b_id"); + tableOp.put("inserts", List.of(row)); + + OrgContext.setOrgId(orgId); + try { + dataSaveService.save(List.of(tableOp)); + } finally { + OrgContext.clear(); + } + } catch (Exception e) { + log.error("写入登录日志失败 orgId={} account={}", orgId, account, e); + } + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/UserAccountReader.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/UserAccountReader.java new file mode 100644 index 00000000..d37b61f0 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/UserAccountReader.java @@ -0,0 +1,64 @@ +package cn.g3soft.erp.service.auth; + +import cn.g3soft.erp.database.org.OrgDataSourceManager; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 登录账号查询:按机构取连接,读 {@code b_user} 里认证需要的三列。 + * + *

它不属于通用数据访问层——SQL 与列名都是认证业务的固定契约, + * 且刻意绕开默认数据源、按 {@code orgId} 取该机构的连接(登录时机构上下文尚未建立)。 + */ +@Component +public class UserAccountReader { + + @Resource + private OrgDataSourceManager dataSourceManager; + + /** + * 取该机构下启用的账号行(字符串业务键方案:{@code b_user.b_id} 即用户账号本身)。 + * 查不到返回空列表,由调用方决定「账号不存在」的文案与登录日志。 + */ + public List> findActiveAccount( + String orgId, + String account + ) throws SQLException { + try (Connection connection = dataSourceManager.getConnection(orgId)) { + String sql = "SELECT [b_id], [b_name], [b_password] " + + "FROM [b_user] WHERE [b_id] = ? AND [b_canuse] = 1"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, account); + try (ResultSet resultSet = statement.executeQuery()) { + return readRows(resultSet); + } + } + } + } + + private List> readRows(ResultSet resultSet) throws SQLException { + ResultSetMetaData metadata = resultSet.getMetaData(); + int columnCount = metadata.getColumnCount(); + List> rows = new ArrayList<>(); + + while (resultSet.next()) { + Map row = new LinkedHashMap<>(columnCount); + for (int columnIndex = 1; columnIndex <= columnCount; columnIndex++) { + row.put(metadata.getColumnLabel(columnIndex), resultSet.getObject(columnIndex)); + } + rows.add(row); + } + + return rows; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/ClientInfo.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/ClientInfo.java new file mode 100644 index 00000000..2da26bee --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/ClientInfo.java @@ -0,0 +1,15 @@ +package cn.g3soft.erp.service.auth.client; + +/** + * 登录时采集到的客户端环境信息载体。 + */ +public record ClientInfo( + String ip, + String ipLocation, + String browser, + String browserVersion, + String os, + String osVersion, + String deviceType, + String userAgent) { +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/ClientInfoExtractor.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/ClientInfoExtractor.java new file mode 100644 index 00000000..c11d37ba --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/ClientInfoExtractor.java @@ -0,0 +1,110 @@ +package cn.g3soft.erp.service.auth.client; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import ua_parser.Client; +import ua_parser.Parser; + +/** + * 组合 IpUtils、IpRegionSearcher 与 uap-java,从 HttpServletRequest 提取客户端环境信息。 + */ +@Component +public class ClientInfoExtractor { + + private static final Logger log = LoggerFactory.getLogger(ClientInfoExtractor.class); + + private final IpRegionSearcher ipRegionSearcher; + + // uap-java 的 Parser 加载一次正则表达式后即可安全复用(无状态、线程安全) + private final Parser uaParser = new Parser(); + + public ClientInfoExtractor(IpRegionSearcher ipRegionSearcher) { + this.ipRegionSearcher = ipRegionSearcher; + } + + public ClientInfo extract(HttpServletRequest request) { + if (request == null) { + return new ClientInfo("unknown", "未知", "未知", "", "未知", "", "Unknown", null); + } + String ip = IpUtils.getClientIp(request); + String userAgent = request.getHeader("User-Agent"); + String ipLocation = ipRegionSearcher.search(ip); + + String browser = "未知"; + String browserVersion = ""; + String os = "未知"; + String osVersion = ""; + String deviceType = "Unknown"; + + if (userAgent != null && !userAgent.isBlank()) { + try { + Client client = uaParser.parse(userAgent); + if (client.userAgent != null) { + browser = orDefault(client.userAgent.family, "未知"); + browserVersion = buildVersion( + client.userAgent.major, client.userAgent.minor, client.userAgent.patch); + } + if (client.os != null) { + os = orDefault(client.os.family, "未知"); + osVersion = buildVersion(client.os.major, client.os.minor, client.os.patch); + } + if (client.device != null) { + deviceType = deriveDeviceType(client.device.family, userAgent); + } + } catch (Exception e) { + log.warn("User-Agent 解析失败: {}", userAgent, e); + } + } + return new ClientInfo(ip, ipLocation, browser, browserVersion, os, osVersion, deviceType, userAgent); + } + + private static String buildVersion(String major, String minor, String patch) { + StringBuilder sb = new StringBuilder(); + if (isNotBlank(major)) { + sb.append(major); + } + if (isNotBlank(minor)) { + sb.append('.').append(minor); + } + if (isNotBlank(patch)) { + sb.append('.').append(patch); + } + return sb.toString(); + } + + private static String deriveDeviceType(String deviceFamily, String userAgent) { + if (deviceFamily == null) { + return "Unknown"; + } + String family = deviceFamily.toLowerCase(); + if (family.contains("ipad") || family.contains("tablet") || family.contains("kindle")) { + return "Tablet"; + } + if (family.contains("spider") || family.contains("bot") || family.contains("crawler")) { + return "Bot"; + } + if (family.contains("iphone") || family.contains("android") + || family.contains("windows phone") || family.contains("phone")) { + return "Mobile"; + } + // device.family 通常为 "Other",再依据 UA 关键字推断 + String ua = userAgent.toLowerCase(); + if (ua.contains("mobile")) { + return "Mobile"; + } + if (family.equals("other")) { + return "Desktop"; + } + return "Desktop"; + } + + private static String orDefault(String value, String def) { + return (value == null || value.isBlank()) ? def : value; + } + + private static boolean isNotBlank(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpRegionSearcher.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpRegionSearcher.java new file mode 100644 index 00000000..758895af --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpRegionSearcher.java @@ -0,0 +1,114 @@ +package cn.g3soft.erp.service.auth.client; + +import jakarta.annotation.PostConstruct; +import org.lionsoul.ip2region.xdb.LongByteArray; +import org.lionsoul.ip2region.xdb.Searcher; +import org.lionsoul.ip2region.xdb.Version; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; + +/** + * 基于本地 ip2region xdb 数据库的 IP 归属地查询。 + * 启动时将 xdb 文件读入内存,避免每次查询的磁盘 IO。 + */ +@Component +public class IpRegionSearcher { + + private static final Logger log = LoggerFactory.getLogger(IpRegionSearcher.class); + + private Searcher searcher; + + @PostConstruct + public void init() { + try (InputStream in = new ClassPathResource("ip2region.xdb").getInputStream()) { + byte[] bytes = in.readAllBytes(); + LongByteArray buffer = new LongByteArray(); + buffer.append(bytes); + searcher = Searcher.newWithBuffer(Version.IPv4, buffer); + log.info("ip2region Searcher 初始化完成,数据大小 {} 字节", bytes.length); + } catch (IOException e) { + log.error("未找到 classpath 下的 ip2region.xdb,IP 归属地将返回「未知」", e); + searcher = null; + } catch (Exception e) { + log.error("ip2region Searcher 初始化失败,IP 归属地将返回「未知」", e); + searcher = null; + } + } + + /** + * 根据 IP 查询归属地,返回格式如 国家|省|市|运营商。 + * 内网/保留地址直接返回「内网」;任何异常或数据缺失时返回「未知」,不影响主流程。 + */ + public String search(String ip) { + if (ip == null || ip.isBlank()) { + return "未知"; + } + if (searcher == null) { + return "未知"; + } + if (isInternalIp(ip)) { + return "内网"; + } + try { + String region = searcher.search(ip); + return (region == null || region.isBlank()) ? "未知" : region; + } catch (Exception e) { + log.warn("ip2region 查询失败 ip={}", ip, e); + return "未知"; + } + } + + /** + * 判断是否为内网/保留地址(IPv4 RFC1918、回环、链路本地、未指定地址,以及 IPv6 回环/唯一本地地址)。 + */ + private static boolean isInternalIp(String ip) { + if (ip.contains(".")) { + String v4 = ip; + int colon = ip.lastIndexOf(':'); + if (colon >= 0 && ip.substring(colon + 1).contains(".")) { + v4 = ip.substring(colon + 1); + } + String[] parts = v4.split("\\."); + if (parts.length != 4) { + return false; + } + int[] o = new int[4]; + try { + for (int i = 0; i < 4; i++) { + int n = Integer.parseUnsignedInt(parts[i]); + if (n > 255) { + return false; + } + o[i] = n; + } + } catch (NumberFormatException e) { + return false; + } + if (o[0] == 0) { + return true; // 0.0.0.0/8 未指定 + } + if (o[0] == 10) { + return true; // 10.0.0.0/8 + } + if (o[0] == 127) { + return true; // 127.0.0.0/8 回环 + } + if (o[0] == 169 && o[1] == 254) { + return true; // 169.254.0.0/16 链路本地 + } + if (o[0] == 172 && o[1] >= 16 && o[1] <= 31) { + return true; // 172.16.0.0/12 + } + return o[0] == 192 && o[1] == 168; // 192.168.0.0/16 + } + if (ip.equalsIgnoreCase("::1")) { + return true; // IPv6 回环 + } + return ip.startsWith("fe80") || ip.startsWith("fc") || ip.startsWith("fd"); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpUtils.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpUtils.java new file mode 100644 index 00000000..40f93fb3 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/auth/client/IpUtils.java @@ -0,0 +1,51 @@ +package cn.g3soft.erp.service.auth.client; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 从 HttpServletRequest 中解析客户端真实 IP。 + * 获取顺序兼容 Nginx 等反向代理:X-Real-IP → X-Forwarded-For(首个) → 远程地址。 + */ +public final class IpUtils { + + private static final String UNKNOWN = "unknown"; + + private IpUtils() { + } + + public static String getClientIp(HttpServletRequest request) { + if (request == null) { + return UNKNOWN; + } + String ip = request.getHeader("X-Real-IP"); + if (isEffective(ip)) { + return ip.trim(); + } + ip = request.getHeader("X-Forwarded-For"); + if (isEffective(ip)) { + // X-Forwarded-For 可能包含多个以逗号分隔的 IP,取第一个 + return ip.split(",")[0].trim(); + } + ip = request.getHeader("Proxy-Client-IP"); + if (isEffective(ip)) { + return ip.trim(); + } + ip = request.getHeader("WL-Proxy-Client-IP"); + if (isEffective(ip)) { + return ip.trim(); + } + ip = request.getRemoteAddr(); + if (ip == null || ip.isBlank()) { + return UNKNOWN; + } + // 归一化 IPv6 本地回环地址 + if ("0:0:0:0:0:0:0:1".equals(ip)) { + ip = "127.0.0.1"; + } + return ip.trim(); + } + + private static boolean isEffective(String ip) { + return ip != null && !ip.isBlank() && !UNKNOWN.equalsIgnoreCase(ip.trim()); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportHeadBuilder.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportHeadBuilder.java new file mode 100644 index 00000000..38240d43 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportHeadBuilder.java @@ -0,0 +1,64 @@ +package cn.g3soft.erp.service.export; + + +import java.util.ArrayList; +import java.util.List; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportNode; + +/** + * 把 view schema 的表头树(分组节点 + 叶子列)转成 Fesod 需要的 + * {@code List>} 表头结构:外层是列,内层是该列自上而下的各层标题。 + * + * 例:`费用 > 明细 > b_a` 这一列 → `["费用", "明细", "b_a"]`;顶层叶子 `b_d` → `["b_d"]`。 + * 各列内层长度可以不同(顶层叶子只有 1 层),Fesod 会按「底部对齐」补空行 —— + * 也就是说列名一定落在表头最后一行,这正是多级表头的常规形态。 + * + * 合并由 Fesod 的 `automaticMergeHead(true)` 负责,本类不生成任何合并区域 + * (原先手写合并的 ExportHeaderWriter 已删除)。 + */ +final class ExportHeadBuilder { + + private ExportHeadBuilder() { + } + + /** 表头结构:外层 = 叶子列(顺序与数据列一致),内层 = 该列自上而下的标题 */ + static List> headOf(List nodes) { + List> head = new ArrayList<>(); + collect(nodes, new ArrayList<>(), head); + return head; + } + + private static void collect(List nodes, List path, List> out) { + for (ExportNode node : nodes) { + if (node.isGroup()) { + List next = new ArrayList<>(path); + next.add(node.title() == null ? "" : node.title()); + collect(node.children(), next, out); + continue; + } + List column = new ArrayList<>(path); + column.add(node.column().caption()); + out.add(column); + } + } + + /** 表头层数(最长列的层数):单行表头为 1,用于冻结窗格 */ + static int depth(List nodes) { + int depth = 1; + for (ExportNode node : nodes) { + if (node.isGroup()) { + depth = Math.max(depth, 1 + depth(node.children())); + } + } + return depth; + } + + /** 叶子列数,应与数据列数一致 */ + static int leafCount(List nodes) { + int count = 0; + for (ExportNode node : nodes) { + count += node.isGroup() ? leafCount(node.children()) : 1; + } + return count; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportSchemaService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportSchemaService.java new file mode 100644 index 00000000..8411f2f6 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportSchemaService.java @@ -0,0 +1,385 @@ +package cn.g3soft.erp.service.export; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.SqlText; +import jakarta.annotation.Resource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportColumn; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportNode; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportSchema; + +/** + * 导出列来源:按模块编码读元数据,产出与列表**同一份**列定义。 + * + * 列表(FmsModuleListPage)的列是这样来的:s_module 拿查询视图 → s_field 拿启用字段与名称 + * → s_module_schema.view JSON 按文档顺序取叶子节点(分组节点递归展开)→ 过滤 visible=false + * 与未知字段。本服务照同一顺序还原,保证「普通导出与列表一致」不是两边各自实现, + * 而是同源推导;差别只在导出额外套一层字段权限(b_view / b_export)。 + * + * 列上的 summary(sum / count)与分组节点一起从 view JSON 读取,供合计行与多级表头使用。 + */ +@Service +public class ExportSchemaService { + + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + /** 无配置列宽时的兜底字符宽度(Excel 列宽单位是字符数) */ + private static final int DEFAULT_COLUMN_WIDTH = 16; + private static final double PIXELS_PER_CHARACTER = 8.0; + + @Resource + private DataSource dataSource; + + @Resource + private ObjectMapper objectMapper; + + /** 导出列定义 */ + public record ExportColumn(String field, String caption, int width, String summary) { + public boolean summable() { + return "sum".equals(summary) || "count".equals(summary); + } + } + + /** 导出表头节点:children 非空即分组,column 非空即叶子列 */ + public record ExportNode(String title, ExportColumn column, List children) { + public boolean isGroup() { + return children != null && !children.isEmpty(); + } + } + + /** 一个模块的导出定义 */ + public record ExportSchema( + String moduleId, + String moduleName, + String viewTable, + String keyField, + String orderSql, + List nodes + ) { + /** 按表头顺序摊平的叶子列 */ + public List columns() { + List columns = new ArrayList<>(); + collectColumns(nodes, columns); + return columns; + } + + private static void collectColumns(List nodes, List out) { + for (ExportNode node : nodes) { + if (node.isGroup()) { + collectColumns(node.children(), out); + } else if (node.column() != null) { + out.add(node.column()); + } + } + } + } + + /** + * 读取模块的导出定义(未做字段权限过滤,由 {@link #filter} 负责裁剪)。 + */ + public ExportSchema load(String moduleId) { + String id = requireText(moduleId, "moduleId"); + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + Map module = firstRow( + connection, + "SELECT [b_id], [b_name], [b_module_type], [b_view_table], [b_key_field], [b_order_sql] " + + "FROM [s_module] WHERE [b_id] = ? AND [b_canuse] = 1", + id + ); + if (module == null) { + throw new BusinessException("模块不存在或已停用: " + id); + } + String moduleType = text(module.get("b_module_type")); + if (!"data".equals(moduleType) && !"virtual".equals(moduleType)) { + throw new BusinessException("模块不是数据模块,无法导出: " + id); + } + String viewTable = text(module.get("b_view_table")); + if (viewTable.isBlank()) { + throw new BusinessException("模块未配置查询视图,无法导出: " + id); + } + SqlText.validateIdentifier(viewTable); + + Map captions = loadFieldCaptions(connection, id); + String schemaJson = firstString( + connection, + "SELECT [b_schema_json] FROM [s_module_schema] " + + "WHERE [b_module_id] = ? AND [b_schema_type] = 'view' AND [b_canuse] = 1", + id + ); + List nodes = buildNodes(parseColumns(schemaJson), captions); + + String keyField = text(module.get("b_key_field")); + if (!keyField.isBlank()) { + SqlText.validateIdentifier(keyField); + } + return new ExportSchema( + id, + text(module.get("b_name")), + viewTable, + keyField.isBlank() ? null : keyField, + text(module.get("b_order_sql")).isBlank() ? null : text(module.get("b_order_sql")), + nodes + ); + } catch (SQLException exception) { + throw new BusinessException("导出配置读取失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 按字段权限与调用方勾选的列裁剪表头树:叶子被移除后空分组一并丢弃。 + * + * @param deniedFields 不允许导出的字段(b_view = 0 或 b_export = 0) + * @param selectedColumns 调用方勾选的字段编码;null 表示沿用列表全部列 + */ + public ExportSchema filter( + ExportSchema schema, + Set deniedFields, + List selectedColumns + ) { + Set selected = selectedColumns == null ? null : new LinkedHashSet<>(selectedColumns); + List nodes = filterNodes(schema.nodes(), deniedFields, selected); + if (selected != null) { + nodes = orderNodes(nodes, selected); + } + if (nodes.isEmpty()) { + throw new BusinessException("没有可导出的列"); + } + return new ExportSchema( + schema.moduleId(), + schema.moduleName(), + schema.viewTable(), + schema.keyField(), + schema.orderSql(), + nodes + ); + } + + private List filterNodes( + List nodes, + Set deniedFields, + Set selected + ) { + List kept = new ArrayList<>(); + for (ExportNode node : nodes) { + if (node.isGroup()) { + List children = filterNodes(node.children(), deniedFields, selected); + if (!children.isEmpty()) { + kept.add(new ExportNode(node.title(), null, children)); + } + continue; + } + ExportColumn column = node.column(); + if (column == null) { + continue; + } + if (deniedFields.contains(column.field())) { + continue; + } + if (selected != null && !selected.contains(column.field())) { + continue; + } + kept.add(node); + } + return kept; + } + + /** 按用户选择的叶子顺序重建表头;跨组拖拽时分开不相邻的同组列,保留每列的分组路径。 */ + private List orderNodes(List nodes, Set selected) { + Map> paths = new LinkedHashMap<>(); + collectPaths(nodes, List.of(), paths); + List ordered = new ArrayList<>(); + List previous = List.of(); + List> levels = new ArrayList<>(); + levels.add(ordered); + for (String field : selected) { + List path = paths.get(field); + if (path == null) continue; + int shared = 0; + while (shared < path.size() - 1 && shared < previous.size() - 1 + && path.get(shared) == previous.get(shared)) { + shared++; + } + while (levels.size() > shared + 1) levels.remove(levels.size() - 1); + for (int depth = shared; depth < path.size() - 1; depth++) { + List children = new ArrayList<>(); + levels.get(depth).add(new ExportNode(path.get(depth).title(), null, children)); + levels.add(children); + } + levels.get(path.size() - 1).add(path.get(path.size() - 1)); + previous = path; + } + return ordered; + } + + private void collectPaths( + List nodes, List parents, Map> paths + ) { + for (ExportNode node : nodes) { + List path = new ArrayList<>(parents); + path.add(node); + if (node.isGroup()) collectPaths(node.children(), path, paths); + else if (node.column() != null) paths.putIfAbsent(node.column().field(), path); + } + } + + /* ================= 元数据读取 ================= */ + + private Map loadFieldCaptions(Connection connection, String moduleId) throws SQLException { + Map captions = new LinkedHashMap<>(); + try (PreparedStatement statement = connection.prepareStatement( + "SELECT [b_field], [b_name] FROM [s_field] " + + "WHERE [b_module_id] = ? AND [b_canuse] = 1 ORDER BY [b_xh], [b_field]")) { + statement.setString(1, moduleId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String field = text(resultSet.getString("b_field")); + // 与列表一致:字段编码必须能安全参与 SQL 才可用 + if (!SAFE_IDENTIFIER.matcher(field).matches()) { + continue; + } + captions.put(field, text(resultSet.getString("b_name"))); + } + } + } + return captions; + } + + /** 解析 view JSON 的 columns 数组;缺失或非法时返回空列表(导出会因无列而报错) */ + private List parseColumns(String schemaJson) { + if (schemaJson == null || schemaJson.isBlank()) { + return List.of(); + } + JsonNode root; + try { + root = objectMapper.readTree(schemaJson); + } catch (RuntimeException exception) { + throw new BusinessException("模块列表配置解析失败"); + } + JsonNode columns = root == null ? null : root.path("columns"); + if (columns == null || !columns.isArray()) { + return List.of(); + } + List nodes = new ArrayList<>(); + for (JsonNode node : columns) { + nodes.add(node); + } + return nodes; + } + + private List buildNodes(List jsonNodes, Map captions) { + List nodes = new ArrayList<>(); + for (JsonNode node : jsonNodes) { + if (node == null || !node.isObject()) { + continue; + } + if ("group".equals(node.path("type").asString(""))) { + List children = new ArrayList<>(); + JsonNode childNodes = node.path("children"); + if (childNodes != null && childNodes.isArray()) { + for (JsonNode child : childNodes) { + children.add(child); + } + } + List childResult = buildNodes(children, captions); + if (!childResult.isEmpty()) { + nodes.add(new ExportNode(node.path("title").asString(""), null, childResult)); + } + continue; + } + String field = text(node.path("field").asString(null)); + if (field.isBlank() || !SAFE_IDENTIFIER.matcher(field).matches() || !captions.containsKey(field)) { + continue; + } + // 与列表一致:visible=false 的节点不出现在列表,也不出现在导出 + if (node.has("visible") && !node.path("visible").asBoolean(true)) { + continue; + } + String caption = captions.get(field); + if (caption == null || caption.isBlank()) { + caption = field; + } + nodes.add(new ExportNode( + null, + new ExportColumn( + field, + caption, + toCharacterWidth(node.path("width")), + text(node.path("summary").asString(null)) + ), + null + )); + } + return nodes; + } + + /** 列表配置里的宽度是像素;Excel 列宽单位是字符数,统一在这里换算 */ + private int toCharacterWidth(JsonNode widthNode) { + if (widthNode == null || widthNode.isMissingNode() || widthNode.isNull()) { + return DEFAULT_COLUMN_WIDTH; + } + double pixels = widthNode.asDouble(0); + if (pixels <= 0) { + return DEFAULT_COLUMN_WIDTH; + } + return (int) Math.min(60, Math.max(8, Math.round(pixels / PIXELS_PER_CHARACTER))); + } + + /* ================= 小工具 ================= */ + + private Map firstRow( + Connection connection, + String sql, + Object... parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + int columnCount = resultSet.getMetaData().getColumnCount(); + Map row = new LinkedHashMap<>(columnCount); + for (int index = 1; index <= columnCount; index++) { + row.put(resultSet.getMetaData().getColumnLabel(index), resultSet.getObject(index)); + } + return row; + } + } + } + + private String firstString(Connection connection, String sql, Object... parameters) throws SQLException { + Map row = firstRow(connection, sql, parameters); + return row == null ? null : text(row.values().iterator().next()); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new BusinessException(fieldName + " 不能为空"); + } + return value.trim(); + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportService.java new file mode 100644 index 00000000..2e107cf1 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportService.java @@ -0,0 +1,777 @@ +package cn.g3soft.erp.service.export; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.SqlText; +import cn.g3soft.erp.common.util.TimeUtils; +import cn.g3soft.erp.database.org.OrgContext; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportColumn; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportSchema; +import cn.g3soft.erp.service.permission.SqlPermissionService; + +/** + * 列表导出(xlsx)。 + * + * 三条硬约束: + * 1. **后端是唯一出口**:导出列由后端按 moduleId 读元数据推导(ExportSchemaService), + * 不信任调用方传入的列清单,字段权限(b_view / b_export)在推导后统一裁剪; + * 2. **权限与校验必须先于写流**:一旦开始写 {@code response.getOutputStream()} 就不能再改 + * header 与状态码,失败只会给用户一个损坏的文件。因此本服务把参数校验、动作权限、 + * 数据范围、列推导、聚合全部放在 {@link #writeWorkbook} 之前完成; + * 3. **流式**:JDBC 游标(forward-only + fetchSize)配 POI SXSSF,内存与总行数无关。 + * + * 并发保护(暂不设行数上限的替代手段):全局同时最多 2 个导出,同一用户同时 1 个。 + */ +@Service +public class ExportService { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + /** 明细查询的游标批量:SQL Server 驱动按此批量取行,避免一次拉全量 */ + private static final int FETCH_SIZE = 500; + /** 按行范围聚合时的行号列名:取名刻意生僻,避免与业务列冲突 */ + private static final String ROW_NUMBER_COLUMN = "__erp_row_number"; + + private static final Semaphore GLOBAL_SLOTS = new Semaphore(2); + private static final Set RUNNING_USERS = ConcurrentHashMap.newKeySet(); + + @Resource + private DataSource dataSource; + + @Resource + private SqlPermissionService sqlPermissionService; + + @Resource + private ExportSchemaService exportSchemaService; + + /** + * 导出请求。 + * + * @param moduleId 模块编码(必填),后端据此读元数据与权限 + * @param searchCondition 查询条件(不含 WHERE),与列表当前条件一致;后端会再 AND 数据范围 + * @param orderBy 排序子句;缺省用模块默认排序 s_module.b_order_sql + * @param rowKeys 勾选行主键值;非空时只导出这些行 + * @param keyField 勾选行主键列;缺省用模块默认主键 s_module.b_key_field + * @param columns 勾选导出的字段;null 表示沿用列表全部列 + * @param rangeStart 按条数区间的起始(1 基,闭区间);与 rangeEnd 同时给出才生效 + * @param rangeEnd 按条数区间的结束(1 基,闭区间) + * @param volumeSize 分卷行数:> 0 时返回 zip(每卷一个 xlsx),与 range 互斥 + * @param withSummary 是否输出合计行 + */ + public record ExportRequest( + String moduleId, + String searchCondition, + String orderBy, + List rowKeys, + String keyField, + List columns, + Integer rangeStart, + Integer rangeEnd, + Integer volumeSize, + boolean withSummary + ) { + } + + /** 一次导出请求预处理后的产物:列、WHERE、ORDER 都已定稿 */ + private record PreparedExport( + String userId, + ExportSchema schema, + List columns, + String whereSql, + String orderSql + ) { + } + + /** + * 符合条件的行数(批量导出弹窗的「共 N 条」)。 + * 与导出走**同一条**权限链路(动作权限 + 字段裁剪 + 数据范围),所以显示的数字 + * 与真正导出的行数一致 —— 不能用列表分页的总数代替,那个口径不受数据范围约束。 + */ + public long count(ExportRequest request) { + String userId = OrgContext.requireUserId(); + return countRows(prepare(request, userId), request); + } + + private long countRows(PreparedExport prepared, ExportRequest request) { + String sql = "SELECT COUNT_BIG(1) FROM [" + prepared.schema().viewTable() + "]" + + (prepared.whereSql() == null ? "" : " WHERE " + prepared.whereSql()); + SqlText.validateReadOnlySql(sql); + + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + bindWhereParameters(statement, request); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getLong(1); + } + } catch (SQLException exception) { + throw new BusinessException("行数统计失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 各合计列的聚合值(列表底部的「总合计」)。 + * + * 与导出**共用** {@link #prepare} 与 {@link #loadTotals},所以这个数字与导出文件里的合计行 + * 必然一致 —— 前端自己算或用别的接口算都可能对不上(数据范围、字段权限、NULL 口径都会差)。 + * + * @return 字段编码 → 聚合值;没有配 summary 的列不出现在结果里 + */ + public Map totals(ExportRequest request) { + String userId = OrgContext.requireUserId(); + PreparedExport prepared = prepare(request, userId); + List whereParameters = bindWhereParameters(new ArrayList<>(), request); + List totals = loadTotals(prepared, whereParameters, null, null); + + // loadTotals 用「空列表」表示没有可合计的列(导出侧据此决定不写合计行) + List columns = prepared.columns(); + if (totals.size() != columns.size()) { + return Map.of(); + } + + Map result = new LinkedHashMap<>(); + for (int index = 0; index < columns.size(); index++) { + BigDecimal value = totals.get(index); + if (value != null) { + result.put(columns.get(index).field(), value); + } + } + return result; + } + + public void export(ExportRequest request, HttpServletResponse response) throws IOException { + String userId = OrgContext.requireUserId(); + requireText(request.moduleId(), "moduleId"); + + if (!RUNNING_USERS.add(userId)) { + throw new BusinessException("已有导出任务正在进行,请等待完成后再试"); + } + boolean globalSlotHeld = false; + try { + globalSlotHeld = GLOBAL_SLOTS.tryAcquire(); + if (!globalSlotHeld) { + throw new BusinessException("导出任务繁忙,请稍后重试"); + } + + // ---- 以下全部在写流之前完成:任何失败都还能返回 JSON 错误 ---- + PreparedExport prepared = prepare(request, userId); + if (isVolumed(request)) { + writeVolumes(response, prepared, request); + return; + } + String selectSql = buildSelectSql( + prepared.schema(), + prepared.columns(), + prepared.whereSql(), + prepared.orderSql(), + request + ); + // 与查询同一条只读校验:必须以 select 开头、无分号、无注释、无写关键字 + SqlText.validateReadOnlySql(selectSql); + + List whereParameters = bindWhereParameters(new ArrayList<>(), request); + List parameters = new ArrayList<>(whereParameters); + if (isRanged(request)) { + parameters.add((long) request.rangeStart() - 1); + parameters.add((long) (request.rangeEnd() - request.rangeStart() + 1)); + } + + // 合计口径 = 本次导出的数据范围(当前页 / 勾选行 / 区间),与列表底部的「该页合计」一致 + List totals = request.withSummary() + ? loadTotals( + prepared, + whereParameters, + isRanged(request) ? request.rangeStart().longValue() : null, + isRanged(request) ? request.rangeEnd().longValue() : null + ) + : List.of(); + + writeWorkbook(response, prepared.schema(), prepared.columns(), selectSql, parameters, totals); + } finally { + if (globalSlotHeld) { + GLOBAL_SLOTS.release(); + } + RUNNING_USERS.remove(userId); + } + } + + /** 动作权限 → 列推导 + 字段裁剪 → WHERE / ORDER,导出与计数共用这一条链路 */ + private PreparedExport prepare(ExportRequest request, String userId) { + String moduleId = requireText(request.moduleId(), "moduleId"); + sqlPermissionService.requireAction(moduleId, userId, "export"); + ExportSchema schema = exportSchemaService.filter( + exportSchemaService.load(moduleId), + sqlPermissionService.deniedExportFields(moduleId, userId), + request.columns() + ); + List columns = schema.columns(); + return new PreparedExport( + userId, + schema, + columns, + buildWhereClause(moduleId, userId, schema, request), + resolveOrderSql(schema, request.orderBy()) + ); + } + + /** WHERE 子句的绑定值:勾选行主键(数据范围是拼进去的 SQL 片段,不带参数) */ + private int bindWhereParameters(PreparedStatement statement, ExportRequest request) throws SQLException { + int index = 1; + if (request.rowKeys() != null) { + for (Object key : request.rowKeys()) { + statement.setObject(index++, key); + } + } + return index; + } + + private List bindWhereParameters(List parameters, ExportRequest request) { + if (request.rowKeys() != null) { + parameters.addAll(request.rowKeys()); + } + return parameters; + } + + private boolean isVolumed(ExportRequest request) { + Integer volumeSize = request.volumeSize(); + if (volumeSize == null) { + return false; + } + if (volumeSize <= 0) { + throw new BusinessException("每卷行数必须大于 0"); + } + if (isRanged(request)) { + throw new BusinessException("分卷导出与按区间导出不能同时使用"); + } + return true; + } + + /* ================= SQL 拼装 ================= */ + + /** + * WHERE 子句:调用方条件 + 数据范围 + 勾选行,三者一律 AND。 + * 数据范围只能收窄调用方条件,不能被其覆盖(开发规范 通用原则 10)。 + */ + String buildWhereClause( + String moduleId, + String userId, + ExportSchema schema, + ExportRequest request + ) { + List conditions = new ArrayList<>(); + String searchCondition = request.searchCondition(); + if (searchCondition != null && !searchCondition.isBlank()) { + conditions.add("(" + searchCondition.trim() + ")"); + } + String dataScope = sqlPermissionService.buildDataScopeCondition(moduleId, userId, "export"); + if (dataScope != null) { + conditions.add(dataScope); + } + if (request.rowKeys() != null && !request.rowKeys().isEmpty()) { + String keyField = resolveKeyField(schema, request.keyField()); + String placeholders = String.join(", ", Collections.nCopies(request.rowKeys().size(), "?")); + conditions.add("[" + keyField + "] IN (" + placeholders + ")"); + } + return conditions.isEmpty() ? null : String.join(" AND ", conditions); + } + + private String resolveKeyField(ExportSchema schema, String requestedKeyField) { + String keyField = requestedKeyField == null || requestedKeyField.isBlank() + ? schema.keyField() + : requestedKeyField.trim(); + if (keyField == null || keyField.isBlank()) { + throw new BusinessException("模块未配置主键字段,无法按勾选行导出"); + } + SqlText.validateIdentifier(keyField); + return keyField; + } + + String resolveOrderSql(ExportSchema schema, String requestedOrderBy) { + String orderBy = requestedOrderBy == null || requestedOrderBy.isBlank() + ? schema.orderSql() + : requestedOrderBy.trim(); + if (orderBy == null || orderBy.isBlank()) { + orderBy = schema.keyField() == null ? null : schema.keyField() + " ASC"; + } else if (schema.keyField() != null && !sortsBy(orderBy, schema.keyField())) { + // 相同排序值以主键打破平局,明细、区间合计与各卷使用同一行顺序。 + // 排序子句里已经有主键时不能再追加:同一列出现两次,SQL Server 会直接报错。 + orderBy += ", " + schema.keyField() + " ASC"; + } + return orderBy == null ? null : SqlText.buildSafeOrderBy(orderBy); + } + + /** 排序子句是否已经排过该列(取每段的首个词,忽略 ASC/DESC) */ + private boolean sortsBy(String orderBy, String field) { + return Arrays.stream(orderBy.split(",")) + .map(part -> part.trim().split("\\s+")[0]) + .anyMatch(name -> name.equalsIgnoreCase(field)); + } + + String buildSelectSql( + ExportSchema schema, + List columns, + String whereSql, + String orderSql, + ExportRequest request + ) { + return buildSelectSql(schema, columns, whereSql, orderSql, isRanged(request)); + } + + /** @param paged true 时追加 OFFSET/FETCH(按区间导出与分卷导出都需要) */ + private String buildSelectSql( + ExportSchema schema, + List columns, + String whereSql, + String orderSql, + boolean paged + ) { + String columnSql = columns.stream() + .map(column -> "[" + column.field() + "]") + .reduce((left, right) -> left + ", " + right) + .orElseThrow(() -> new BusinessException("没有可导出的列")); + + StringBuilder sql = new StringBuilder("SELECT ") + .append(columnSql) + .append(" FROM [") + .append(schema.viewTable()) + .append("]"); + if (whereSql != null) { + sql.append(" WHERE ").append(whereSql); + } + if (paged) { + // OFFSET/FETCH 必须有 ORDER BY;无排序配置时用常量排序占位(与分页同一取舍) + sql.append(" ORDER BY ").append(orderSql == null ? "(SELECT NULL)" : orderSql); + sql.append(" OFFSET ? ROWS FETCH NEXT ? ROWS ONLY"); + } else if (orderSql != null) { + sql.append(" ORDER BY ").append(orderSql); + } + return sql.toString(); + } + + private boolean isRanged(ExportRequest request) { + Integer start = request.rangeStart(); + Integer end = request.rangeEnd(); + if (start == null && end == null) { + return false; + } + if (start == null || end == null || start < 1 || end < start) { + throw new BusinessException("导出区间不正确"); + } + return true; + } + + /** + * 合计行:在 SQL 层聚合(开发规范 通用原则 4),不把明细拉到内存再算。 + * 口径统一为「**导出数据的合计**」:不传范围就是全量合计,传了范围(当前页 / 区间 / 单卷) + * 就是该范围的合计 —— 这样表格底部的「该页合计」与导出文件的合计行必然一致。 + */ + private List loadTotals( + PreparedExport prepared, + List whereParameters, + Long rangeStart, + Long rangeEnd + ) { + List columns = prepared.columns(); + List indexes = summableIndexes(columns); + if (indexes.isEmpty()) { + return List.of(); + } + boolean ranged = rangeStart != null && rangeEnd != null; + String sql = buildTotalsSql( + prepared.schema().viewTable(), + columns, + prepared.whereSql(), + ranged ? orderByOrDefault(prepared.orderSql()) : null + ); + SqlText.validateReadOnlySql(sql); + + List totals = new ArrayList<>(Collections.nCopies(columns.size(), null)); + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + int index = 1; + for (Object parameter : whereParameters) { + statement.setObject(index++, parameter); + } + if (ranged) { + // ROW_NUMBER 从 1 开始,BETWEEN 接收闭区间端点,不是 OFFSET/FETCH 的偏移和条数。 + statement.setLong(index++, rangeStart); + statement.setLong(index, rangeEnd); + } + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + for (int position = 0; position < indexes.size(); position++) { + BigDecimal value = resultSet.getBigDecimal(position + 1); + totals.set(indexes.get(position), value == null ? BigDecimal.ZERO : value); + } + } + } + } catch (SQLException exception) { + throw new BusinessException("合计计算失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + return totals; + } + + private String orderByOrDefault(String orderSql) { + return orderSql == null ? "(SELECT NULL)" : orderSql; + } + + /** 配了 summary 的列下标(sum → SUM,count → COUNT) */ + static List summableIndexes(List columns) { + List indexes = new ArrayList<>(); + for (int index = 0; index < columns.size(); index++) { + if (columns.get(index).summable()) { + indexes.add(index); + } + } + return indexes; + } + + /** 合计查询:与明细同一条 WHERE,逐列聚合。orderSql 非空表示要按行范围聚合 */ + static String buildTotalsSql( + String viewTable, + List columns, + String whereSql, + String orderSql + ) { + List expressions = new ArrayList<>(); + for (ExportColumn column : columns) { + if (!column.summable()) { + continue; + } + expressions.add(("count".equals(column.summary()) ? "COUNT" : "SUM") + + "([" + column.field() + "])"); + } + String aggregateSql = String.join(", ", expressions); + if (orderSql == null) { + StringBuilder sql = new StringBuilder("SELECT ") + .append(aggregateSql) + .append(" FROM [") + .append(viewTable) + .append("]"); + if (whereSql != null) { + sql.append(" WHERE ").append(whereSql); + } + return sql.toString(); + } + + // 按行范围聚合(当前页 / 区间 / 单卷):先给符合条件的行编号,再对范围内的行聚合。 + // 不能直接对明细求和后按比例折算,也不能把行拉到内存里算(开发规范 通用原则 4)。 + StringBuilder inner = new StringBuilder("SELECT "); + List innerColumns = new ArrayList<>(); + for (ExportColumn column : columns) { + if (column.summable()) { + innerColumns.add("[" + column.field() + "]"); + } + } + inner.append(String.join(", ", innerColumns)) + .append(innerColumns.isEmpty() ? "" : ", ") + .append("ROW_NUMBER() OVER (ORDER BY ") + .append(orderSql == null ? "(SELECT NULL)" : orderSql) + .append(") AS [") + .append(ROW_NUMBER_COLUMN) + .append("] FROM [") + .append(viewTable) + .append("]"); + if (whereSql != null) { + inner.append(" WHERE ").append(whereSql); + } + return "SELECT " + aggregateSql + " FROM (" + inner + ") AS [totals_source]" + + " WHERE [totals_source].[" + ROW_NUMBER_COLUMN + "] BETWEEN ? AND ?"; + } + + /* ================= 分卷导出(批量导出的一种方式) ================= */ + + /** + * 分卷导出:按 volumeSize 切成若干 xlsx,打包成一个 zip 直接下发。 + * + * 与旧系统 `g3hd` 的做法不同:那边是前端逐段请求、前端 JSZip 打包,N 次往返 + N 个临时 + * blob;这里一次请求由后端切卷,前端只负责保存文件。每一卷用 OFFSET/FETCH 单独查, + * 单卷内存受 volumeSize 约束(所以是「有界内存」而不是「流式」,卷大小要合理)。 + * + * 每卷输出**该卷的合计**(口径与其它导出方式统一:合计 = 该文件里数据的合计)。 + */ + private void writeVolumes( + HttpServletResponse response, + PreparedExport prepared, + ExportRequest request + ) throws IOException { + int volumeSize = request.volumeSize(); + long total = countRows(prepared, request); + String sheetName = sheetNameOf(prepared.schema()); + String selectSql = buildSelectSql( + prepared.schema(), + prepared.columns(), + prepared.whereSql(), + prepared.orderSql(), + true + ); + SqlText.validateReadOnlySql(selectSql); + + long volumeCount = total == 0 ? 1 : (total + volumeSize - 1) / volumeSize; + // 第一卷查询、合计和渲染成功后才开始响应,首卷失败仍能返回业务错误。 + byte[] firstVolume = renderVolume(prepared, request, selectSql, sheetName, 0, + (int) Math.min(volumeSize, total)); + applyResponseHeader(response, prepared.schema().moduleName(), "zip", "application/zip"); + try (ZipOutputStream zip = new ZipOutputStream(response.getOutputStream(), StandardCharsets.UTF_8)) { + for (long index = 0; index < volumeCount; index++) { + long offset = index * volumeSize; + int count = (int) Math.min(volumeSize, Math.max(0, total - offset)); + byte[] content = index == 0 ? firstVolume + : renderVolume(prepared, request, selectSql, sheetName, offset, count); + zip.putNextEntry(new ZipEntry(volumeEntryName(prepared.schema().moduleName(), offset, count))); + zip.write(content); + firstVolume = null; + zip.closeEntry(); + } + zip.finish(); + } + response.flushBuffer(); + } + + /** 单卷内容渲染到内存:卷大小已限定行数,因此内存有界 */ + private byte[] renderVolume( + PreparedExport prepared, + ExportRequest request, + String selectSql, + String sheetName, + long offset, + int count + ) throws IOException { + List whereParameters = bindWhereParameters(new ArrayList<>(), request); + // 该卷的合计:与明细同一个行范围,口径与其它导出方式一致 + List totals = request.withSummary() && count > 0 + ? loadTotals(prepared, whereParameters, offset + 1, offset + count) + : List.of(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + if (count <= 0) { + // 0 行也要给出一份带表头的文件,否则用户拿到空 zip 会以为导出失败 + ExportWorkbookWriter.write( + buffer, + sheetName, + prepared.schema().nodes(), + prepared.columns(), + emptyRowSource(), + List.of() + ); + return buffer.toByteArray(); + } + + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement( + selectSql, + ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY + )) { + int index = bindWhereParameters(statement, request); + statement.setLong(index++, offset); + statement.setInt(index, count); + statement.setFetchSize(FETCH_SIZE); + try (ResultSet resultSet = statement.executeQuery()) { + ExportWorkbookWriter.write( + buffer, + sheetName, + prepared.schema().nodes(), + prepared.columns(), + new ResultSetRowSource(resultSet, prepared.columns().size()), + totals + ); + } + return buffer.toByteArray(); + } catch (SQLException exception) { + throw new BusinessException("分卷导出失败: " + exception.getMessage()); + } catch (IOException exception) { + throw new BusinessException("分卷导出失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private ExportWorkbookWriter.RowSource emptyRowSource() { + return new ExportWorkbookWriter.RowSource() { + @Override + public boolean hasNext() { + return false; + } + + @Override + public List next() { + throw new IllegalStateException("没有下一行"); + } + }; + } + + private String volumeEntryName(String moduleName, long offset, int count) { + String base = moduleName == null || moduleName.isBlank() ? "导出数据" : moduleName; + return base + "_" + (offset + 1) + "-" + (offset + count) + ".xlsx"; + } + + private String sheetNameOf(ExportSchema schema) { + return schema.moduleName() == null || schema.moduleName().isBlank() + ? "数据" + : schema.moduleName(); + } + + /* ================= 写出 ================= */ + + /** + * 打开游标 → 设响应头 → 流式写出。 + * + * 顺序很关键:**查询失败要发生在设响应头之前**,否则用户拿到的是半截文件而不是 JSON 错误。 + * 因此这里是「先 executeQuery,再 applyResponseHeader,最后 write」;代价是一旦开始写流, + * 中途的数据库异常无法再以 JSON 形式回传(流式导出的固有权衡)。 + */ + private void writeWorkbook( + HttpServletResponse response, + ExportSchema schema, + List columns, + String selectSql, + List parameters, + List totals + ) throws IOException { + String sheetName = sheetNameOf(schema); + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement( + selectSql, + ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY + )) { + for (int index = 0; index < parameters.size(); index++) { + statement.setObject(index + 1, parameters.get(index)); + } + statement.setFetchSize(FETCH_SIZE); + try (ResultSet resultSet = statement.executeQuery()) { + applyResponseHeader(response, schema.moduleName()); + ExportWorkbookWriter.write( + response.getOutputStream(), + sheetName, + schema.nodes(), + columns, + new ResultSetRowSource(resultSet, columns.size()), + totals + ); + response.flushBuffer(); + } + } catch (SQLException exception) { + throw new BusinessException("导出查询失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** JDBC 游标行来源:边读边交给 Fesod,不把结果集攒进内存 */ + private record ResultSetRowSource(ResultSet resultSet, int columnCount) implements + ExportWorkbookWriter.RowSource { + + @Override + public boolean hasNext() { + try { + return resultSet.next(); + } catch (SQLException exception) { + throw new BusinessException("导出读取数据失败: " + exception.getMessage()); + } + } + + @Override + public List next() { + List row = new ArrayList<>(columnCount); + try { + for (int index = 1; index <= columnCount; index++) { + row.add(normalizeValue(resultSet.getObject(index))); + } + } catch (SQLException exception) { + throw new BusinessException("导出读取数据失败: " + exception.getMessage()); + } + return row; + } + } + + /** + * 单元格值归一化:时间列落成「业务时区(Asia/Shanghai)的本地文本」—— + * 库里存 UTC,导出 Excel 是给人看的单据,按业务时区折算,不随服务器/JVM 时区漂移; + * date / time 没有时区语义原样输出;其余保持原类型交给 Fesod 写出(数字仍是数字,Excel 里可继续参与计算)。 + */ + static Object normalizeValue(Object value) { + if (value instanceof Timestamp timestamp) { + return TimeUtils.businessText(timestamp.toLocalDateTime()); + } + if (value instanceof java.sql.Date date) { + return date.toLocalDate().format(DATE_FORMATTER); + } + if (value instanceof java.sql.Time time) { + return time.toLocalTime().format(TIME_FORMATTER); + } + if (value instanceof LocalDateTime dateTime) { + return TimeUtils.businessText(dateTime); + } + return value; + } + + /* ================= 响应 ================= */ + + private void applyResponseHeader(HttpServletResponse response, String moduleName) throws IOException { + applyResponseHeader( + response, + moduleName, + "xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + } + + private void applyResponseHeader( + HttpServletResponse response, + String moduleName, + String extension, + String contentType + ) throws IOException { + String baseName = moduleName == null || moduleName.isBlank() ? "导出数据" : moduleName; + String fileName = baseName + "_" + + TimeUtils.businessNow().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + + "." + extension; + String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"); + response.setStatus(HttpServletResponse.SC_OK); + // 二进制响应不设 charset:加了会让 content-type 变成 "application/zip;charset=UTF-8" + response.setContentType(contentType); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded); + response.setHeader("Access-Control-Expose-Headers", "Content-Disposition"); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new BusinessException(fieldName + " 不能为空"); + } + return value.trim(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportStyleHandler.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportStyleHandler.java new file mode 100644 index 00000000..d0b5da59 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportStyleHandler.java @@ -0,0 +1,131 @@ +package cn.g3soft.erp.service.export; + +import org.apache.fesod.sheet.write.handler.CellWriteHandler; +import org.apache.fesod.sheet.write.handler.context.CellWriteHandlerContext; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; + +import java.util.List; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportColumn; + +/** + * xlsx 单元格样式与列宽:表头(灰底加粗居中)、数据行、合计行(灰底加粗)三种, + * 外加按列表配置换算出来的逐列宽度。 + * + * 样式在 {@code afterCellDispose} 里统一落地(Fesod 最后一个单元格钩子),因此一定覆盖 + * 库的默认样式。合计行只能**事后标记**:数据是流式写出的,写之前不知道总行数, + * 所以由 {@link ExportWorkbookWriter} 在写出合计行之前调用 {@link #markTotalRow(int)}。 + */ +final class ExportStyleHandler implements CellWriteHandler { + + /** Excel 列宽单位是「字符数」× 256 */ + private static final int WIDTH_UNIT = 256; + + private final int[] columnWidths; + private final boolean[] widthApplied; + + private CellStyle headerStyle; + private CellStyle dataStyle; + private CellStyle totalStyle; + private int totalRowIndex = -1; + + ExportStyleHandler(List columns) { + this.columnWidths = new int[columns.size()]; + this.widthApplied = new boolean[columns.size()]; + for (int index = 0; index < columns.size(); index++) { + columnWidths[index] = columns.get(index).width(); + } + } + + /** 合计行的行号(表头层数 + 明细行数);写合计行之前调用 */ + void markTotalRow(int rowIndex) { + this.totalRowIndex = rowIndex; + } + + @Override + public void afterCellDispose(CellWriteHandlerContext context) { + Cell cell = context.getCell(); + if (cell == null) { + return; + } + applyColumnWidth(context, cell); + cell.setCellStyle(styleOf(context)); + } + + private void applyColumnWidth(CellWriteHandlerContext context, Cell cell) { + Integer columnIndex = cell.getColumnIndex(); + if (columnIndex == null || columnIndex < 0 || columnIndex >= columnWidths.length) { + return; + } + if (widthApplied[columnIndex]) { + return; + } + Sheet sheet = context.getWriteSheetHolder().getSheet(); + sheet.setColumnWidth(columnIndex, columnWidths[columnIndex] * WIDTH_UNIT); + widthApplied[columnIndex] = true; + } + + private CellStyle styleOf(CellWriteHandlerContext context) { + Workbook workbook = context.getWriteSheetHolder().getSheet().getWorkbook(); + if (Boolean.TRUE.equals(context.getHead())) { + if (headerStyle == null) { + headerStyle = createHeaderStyle(workbook); + } + return headerStyle; + } + Integer rowIndex = context.getRowIndex(); + if (totalRowIndex >= 0 && rowIndex != null && rowIndex == totalRowIndex) { + if (totalStyle == null) { + totalStyle = createTotalStyle(workbook); + } + return totalStyle; + } + if (dataStyle == null) { + dataStyle = createDataStyle(workbook); + } + return dataStyle; + } + + private CellStyle createHeaderStyle(Workbook workbook) { + CellStyle style = baseStyle(workbook); + style.setAlignment(HorizontalAlignment.CENTER); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private CellStyle createTotalStyle(Workbook workbook) { + CellStyle style = baseStyle(workbook); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private CellStyle createDataStyle(Workbook workbook) { + return baseStyle(workbook); + } + + private CellStyle baseStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + return style; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportWorkbookWriter.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportWorkbookWriter.java new file mode 100644 index 00000000..3e8df59d --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/export/ExportWorkbookWriter.java @@ -0,0 +1,111 @@ +package cn.g3soft.erp.service.export; + +import org.apache.fesod.sheet.ExcelWriter; +import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.write.metadata.WriteSheet; +import org.apache.poi.ss.util.WorkbookUtil; + +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportColumn; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportNode; + +/** + * 把表头、明细行、合计行写成一个 xlsx(Apache Fesod / 原 EasyExcel)。 + * + * 行来源抽象成 {@link RowSource},因此本类不关心数据是 JDBC 游标还是内存列表: + * 生产走游标 + 分批 write(Fesod 底层是 SXSSF,内存与总行数无关),测试直接喂内存行, + * 于是「多级表头 + 数据行 + 合计行」这条最容易出错的路径可以脱离数据库验证。 + */ +final class ExportWorkbookWriter { + + /** 每批交给 Fesod 的行数:批太大失去流式意义,太小写入次数过多 */ + private static final int BATCH_SIZE = 1000; + + private ExportWorkbookWriter() { + } + + /** 行来源:生产实现是 JDBC ResultSet,测试实现是内存列表 */ + interface RowSource { + boolean hasNext(); + + List next(); + } + + /** + * @param nodes 表头树(分组 + 叶子),用于生成多级表头 + * @param columns 叶子列(顺序与 {@link RowSource} 返回的行内元素一致),用于列宽 + * @param rows 明细行,元素个数应与 columns 一致 + * @param totals 合计行,元素个数应与 columns 一致;空列表表示不输出合计行 + * @return 写出的明细行数 + */ + static int write( + OutputStream outputStream, + String sheetName, + List nodes, + List columns, + RowSource rows, + List totals + ) throws IOException { + List> head = ExportHeadBuilder.headOf(nodes); + ExportStyleHandler styleHandler = new ExportStyleHandler(columns); + + ExcelWriter writer = FesodSheet.write(outputStream) + .head(head) + // 相邻同值表头自动合并(分组横向、叶子纵向由库按表头结构推导) + .automaticMergeHead(true) + .registerWriteHandler(styleHandler) + .build(); + try { + WriteSheet sheet = FesodSheet.writerSheet(WorkbookUtil.createSafeSheetName(sheetName)).build(); + + int rowCount = 0; + List> batch = new ArrayList<>(BATCH_SIZE); + while (rows.hasNext()) { + batch.add(rows.next()); + rowCount++; + if (batch.size() >= BATCH_SIZE) { + writer.write(batch, sheet); + batch = new ArrayList<>(BATCH_SIZE); + } + } + // 最后一批一定要调一次(可能为空):Fesod 只在 write 被调用时才真正创建工作表, + // 0 行导出若不调用会得到一个「没有任何工作表」的空 xlsx + writer.write(batch, sheet); + + if (totals != null && !totals.isEmpty()) { + // 合计行只能事后标记:流式写出时事先不知道总行数 + styleHandler.markTotalRow(ExportHeadBuilder.depth(nodes) + rowCount); + writer.write(Collections.singletonList(buildTotalRow(columns.size(), totals)), sheet); + } + return rowCount; + } finally { + writer.finish(); + } + } + + /** + * 合计行:各列写聚合值,「合计」标签落在**第一个没有聚合值的列**(正常就是首列, + * 首列一般是编码/名称这类不可求和的列)。若所有列都有聚合值,则不加标签。 + */ + private static List buildTotalRow(int columnCount, List totals) { + List row = new ArrayList<>(columnCount); + boolean labelled = false; + for (int index = 0; index < columnCount; index++) { + BigDecimal total = index < totals.size() ? totals.get(index) : null; + if (total != null) { + row.add(total); + } else if (!labelled) { + row.add("合计"); + labelled = true; + } else { + row.add(null); + } + } + return row; + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/file/FileService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/file/FileService.java new file mode 100644 index 00000000..42d45f81 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/file/FileService.java @@ -0,0 +1,272 @@ +package cn.g3soft.erp.service.file; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.TimeUtils; +import cn.g3soft.erp.common.util.snowflake.idgen.IdGenerator; +import cn.g3soft.erp.config.FileStorageProperties; +import cn.g3soft.erp.database.RowReader; +import cn.g3soft.erp.database.org.OrgContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.sql.DataSource; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Duration; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import cn.g3soft.erp.service.data.DataDeleteService; + +@Service +public class FileService { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + @Resource + private FileStorageProperties props; + + @Resource + private RowReader rowReader; + + @Resource + private DataSource dataSource; + + @Resource + private DataDeleteService dataDeleteService; + + /** + * 本地上传:逐个写磁盘 → 写 DB → 返回元数据列表 + */ + public List> localUpload(List files, String father, String moduleId, String cateId) + throws IOException, SQLException { + List> results = new ArrayList<>(); + for (MultipartFile file : files) { + results.add(uploadOne(file, father, moduleId, cateId)); + } + return results; + } + + private Map uploadOne(MultipartFile file, String father, String moduleId, String cateId) + throws IOException, SQLException { + long subid = IdGenerator.nextId(); + String originalName = file.getOriginalFilename(); + String ext = extension(originalName); + String fileName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext); + + // 机构码/年/月/日/文件名(按业务日 Asia/Shanghai 分桶,不随服务器时区漂移) + String orgId = OrgContext.requireOrgId(); + LocalDate today = TimeUtils.businessToday(); + String subPath = String.format("%s/%04d/%02d/%02d/%s", + orgId, today.getYear(), today.getMonthValue(), today.getDayOfMonth(), fileName); + + Path baseDir = Path.of(props.getLocal().getStorageDir()).toAbsolutePath().normalize(); + Path target = baseDir.resolve(subPath); + Files.createDirectories(target.getParent()); + Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + + String mapFilename = props.getLocal().getUrlPrefix() + "/" + subPath; + + insertFileRecord(subid, father, moduleId, cateId, + originalName, String.valueOf(file.getSize()), ext, mapFilename); + + Map meta = new LinkedHashMap<>(); + meta.put("subid", String.valueOf(subid)); + meta.put("mx_filename", originalName); + meta.put("mx_filesize", String.valueOf(file.getSize())); + meta.put("mx_fileext", ext); + meta.put("mx_mapfilename", mapFilename); + meta.put("father", father); + meta.put("mx_moduleid", moduleId); + meta.put("mx_cate_id", cateId); + return meta; + } + + /** + * 保存文件记录到 DB(OSS 模式:前端直传后回调写入) + */ + public Map saveRecord(Map meta) throws SQLException { + long subid = Long.parseLong(String.valueOf(meta.get("subid"))); + String father = String.valueOf(meta.get("father")); + String moduleId = String.valueOf(meta.get("mx_moduleid")); + String cateId = meta.containsKey("mx_cate_id") ? String.valueOf(meta.get("mx_cate_id")) : null; + String filename = String.valueOf(meta.get("mx_filename")); + String filesize = String.valueOf(meta.get("mx_filesize")); + String fileext = String.valueOf(meta.get("mx_fileext")); + String mapfilename = String.valueOf(meta.get("mx_mapfilename")); + + insertFileRecord(subid, father, moduleId, cateId, + filename, filesize, fileext, mapfilename); + + Map result = new LinkedHashMap<>(); + result.put("subid", String.valueOf(subid)); + result.put("message", "记录已保存"); + return result; + } + + private void insertFileRecord(long subid, String father, String moduleId, String cateId, + String filename, String filesize, String fileext, String mapfilename) + throws SQLException { + String userId = OrgContext.getUserId(); + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + String sql = """ + INSERT INTO [bf_files] ( + [subid], [father], [mx_filename], [mx_filesize], [mx_fileext], + [mx_mapfilename], [mx_moduleid], [mx_cate_id], [mx_xh], [mx_sh], + [mx_inputuser_id], [mx_inputdatetime] + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, SYSUTCDATETIME()) + """; + try (PreparedStatement ps = connection.prepareStatement(sql)) { + ps.setLong(1, subid); + // father 自引用 bf_files.subid,仍为 bigint + ps.setObject(2, toLongOrNull(father)); + ps.setString(3, filename); + ps.setString(4, filesize); + ps.setString(5, fileext); + ps.setString(6, mapfilename); + // 字符串业务键:模块编码 / 分类编码 / 用户账号均按字符串存储 + ps.setString(7, moduleId); + // 分类主键为 bigint 雪花(bf_files_category.b_id),按数字写入 + ps.setObject(8, toLongOrNull(cateId)); + ps.setString(9, userId); + ps.executeUpdate(); + } + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private static Long toLongOrNull(String s) { + if (s == null || s.isBlank()) return null; + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * OSS 凭证:调 g3oss,bucket = 当前机构码 + */ + @SuppressWarnings("unchecked") + public Map ossCredential() throws IOException, InterruptedException { + String orgId = OrgContext.requireOrgId(); + String url = props.getOss().getG3ossUrl() + "/upload?orgid=" + orgId + "&bucket=" + orgId; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .GET() + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new BusinessException("获取 OSS 上传凭证失败: " + response.statusCode()); + } + + return OBJECT_MAPPER.readValue(response.body(), Map.class); + } + + /** + * 删除文件:查 bf_files → 删存储 → 走统一删除执行器删 DB 行(设计 §6.3)。 + * + *

bf_files 属于无业务模块的技术表,用 target=direct 只做物理删除, + * 不触发 s_relation 与 s_delete_rule。外部对象操作不属于数据库事务: + * 对象删除失败时不删数据库行;数据库删除失败时保留可重试的清理记录。 + */ + public void delete(String subid) throws SQLException, IOException, InterruptedException { + List> rows = rowReader.loadData("bf_files", "subid = " + subid, null, null); + if (rows.isEmpty()) { + return; + } + + String mapfilename = (String) rows.get(0).get("mx_mapfilename"); + + // 删存储文件 + if ("aliyun-oss".equalsIgnoreCase(props.getStorageType())) { + deleteOssFile(mapfilename); + } else { + deleteLocalFile(mapfilename); + } + + // 删 DB 行:复用统一直接表删除 + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + dataDeleteService.deleteDirect( + connection, "bf_files", "subid", List.of(subid), null); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private void deleteLocalFile(String mapfilename) throws IOException { + if (mapfilename == null) return; + // mapfilename: /api/file/static/{orgId}/{yyyy}/{MM}/{dd}/{file} + String prefix = props.getLocal().getUrlPrefix() + "/"; + if (!mapfilename.startsWith(prefix)) return; + String relativePath = mapfilename.substring(prefix.length()); + Path file = Path.of(props.getLocal().getStorageDir()).toAbsolutePath().normalize().resolve(relativePath); + Files.deleteIfExists(file); + } + + private void deleteOssFile(String mapfilename) throws IOException, InterruptedException { + if (mapfilename == null || mapfilename.isBlank()) { + return; + } + // 从 URL 提取 objectName(bucket 直接用机构码) + String objectName = mapfilename; + if (objectName.contains("://")) { + objectName = objectName.substring(objectName.indexOf("://") + 3); + } + // 跳过 host + bucket 两段 + int firstSlash = objectName.indexOf('/'); + if (firstSlash > 0) { + objectName = objectName.substring(firstSlash + 1); + int secondSlash = objectName.indexOf('/'); + if (secondSlash > 0) { + objectName = objectName.substring(secondSlash + 1); + } + } + + String bucket = OrgContext.requireOrgId(); + String url = props.getOss().getG3ossUrl() + + "/delete?bucketName=" + bucket + "&objectName=" + objectName; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .GET() + .build(); + + HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new BusinessException("OSS 删除文件失败: " + response.statusCode()); + } + } + + private String extension(String name) { + if (name == null || !name.contains(".")) { + return ""; + } + return name.substring(name.lastIndexOf('.') + 1).toLowerCase(); + } +} diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaAst.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaAst.java new file mode 100644 index 00000000..922b78d3 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaAst.java @@ -0,0 +1,37 @@ +package cn.g3soft.erp.service.formula; + +import java.util.List; + +/** + * 公式 AST 节点(与前端 {@code parser.js} 产出的对象一一对应,每个节点都带 pos 位置)。 + * + *

    + *
  • {@link Literal}:字面量,valueType ∈ decimal | text | boolean | null; + * decimal 保存原始数字文本(如 "1.5"),由 {@link FormulaValue#ofDecimal(String)} 解析;
  • + *
  • {@link Ref}:字段引用,relationId 非空表示关系前缀写法 {@code relationId.field}(子表阶段);
  • + *
  • {@link Unary}:一元运算,op ∈ '-' | '!'(NOT 已被解析层归一为 '!');
  • + *
  • {@link Binary}:二元运算,op 为运算符或 AND / OR 关键字形式;
  • + *
  • {@link Call}:函数调用,name 全大写。
  • + *
+ */ +public sealed interface FormulaAst + permits FormulaAst.Literal, FormulaAst.Ref, FormulaAst.Unary, FormulaAst.Binary, FormulaAst.Call { + + /** 节点在表达式中的 0-based 起始字符下标 */ + int pos(); + + record Literal(String valueType, Object value, int pos) implements FormulaAst { + } + + record Ref(String relationId, String field, int pos) implements FormulaAst { + } + + record Unary(String op, FormulaAst operand, int pos) implements FormulaAst { + } + + record Binary(String op, FormulaAst left, FormulaAst right, int pos) implements FormulaAst { + } + + record Call(String name, List args, int pos) implements FormulaAst { + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaConfigService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaConfigService.java new file mode 100644 index 00000000..6db7447f --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaConfigService.java @@ -0,0 +1,782 @@ +package cn.g3soft.erp.service.formula; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.ParamUtils; +import cn.g3soft.erp.common.util.SqlText; +import cn.g3soft.erp.common.util.TimeUtils; +import cn.g3soft.erp.database.MetadataReader; +import cn.g3soft.erp.database.model.ColumnMetadata; +import cn.g3soft.erp.database.model.TableMetadata; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.ResultSetMetaData; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * 计算公式的服务端权威入口(《FMS计算公式设计》6.1 / 10.6 / 12.3 / 14.2)。 + * + *

职责三块,全部在调用方({@link cn.g3soft.erp.service.data.DataSaveService})的事务连接内执行: + *

    + *
  1. 配置保存校验:写 {@code s_module_schema.edit} 时校验公式语法 / 字段 / 类型 / 循环 / + * 自动编码互斥 / 目标列可写且可空,任一失败即拒绝整个事务;
  2. + *
  3. 业务数据保存:带公式模块的写入必须声明 {@code moduleCode} 并携带 {@code formulaRevision}, + * 服务端按当前配置重算严格计算字段、校验覆写声明,客户端提交的计算值不被信任;
  4. + *
  5. 拒绝绕过:只传 {@code table + key_field} 的普通直写落在带公式模块的保存表上时直接拒绝, + * 接口 / 导入 / 工作流回写必须走同一入口(本设计 §14.2)。
  6. + *
+ * + *

公式配置仍只存 {@code s_module_schema},这里不新建表;摘要({@code formulaRevision})由 + * {@link FormulaRevision} 按规范化公式节点计算,与前端算法一致,用于拦截「按过期口径保存」。 + */ +@Service +public class FormulaConfigService { + + /** 保存请求元数据键(与前端 FmsModuleEditModal 一致;都不是业务字段,不落库) */ + public static final String MODULE_CODE = "moduleCode"; + public static final String FORMULA_REVISION = "formulaRevision"; + public static final String FORMULA_OVERRIDES = "formulaOverrides"; + + private static final String SCHEMA_TABLE = "s_module_schema"; + /** 系统 / 元数据表前缀:这些表由配置页写入,不做「带公式模块」反查 */ + private static final String SYSTEM_TABLE_PREFIX = "s_"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** 字段元数据(s_field 启用行 + 公式类型) */ + public record FieldMeta(String field, String name, String bType, String formulaType) { + } + + /** + * 模块公式配置:字段、公式节点、解析结果、依赖图与摘要。 + * + * @param issues 配置级校验错误(语法 / 字段 / 类型 / 循环);非空时禁止保存业务数据 + */ + public record ModuleConfig( + String moduleId, + String moduleName, + String saveTable, + String keyField, + LinkedHashMap fields, + List nodes, + Map asts, + FormulaGraph.Graph graph, + String revision, + List issues + ) { + + public boolean hasFormulas() { + return !nodes.isEmpty(); + } + + public Map fieldTypes() { + Map types = new LinkedHashMap<>(); + for (FieldMeta meta : fields.values()) { + types.put(meta.field(), meta.formulaType()); + } + return types; + } + + public Set formulaFields() { + Set names = new LinkedHashSet<>(); + for (FormulaNode node : nodes) { + names.add(node.field()); + } + return names; + } + } + + /* ================= 配置装载 ================= */ + + /** + * 装载模块公式配置;模块不存在或不是 data 模块时返回 null。 + * 配置级错误不在这里抛:由调用方在真正要写业务数据时拒绝(历史数据仍可读)。 + */ + public ModuleConfig loadConfig(Connection connection, String moduleCode) throws SQLException { + ModuleRow module = loadModuleRow(connection, moduleCode); + if (module == null) { + return null; + } + LinkedHashMap fields = loadFields(connection, module.moduleId()); + List nodes = parseFormulaNodes(loadEditSchemaJson(connection, module.moduleId())); + FormulaValidator.FormulasResult validated = FormulaValidator.validateFormulas( + nodes, + FormulaValidator.Options.of(fieldMetaMap(fields)) + ); + Map asts = new LinkedHashMap<>(); + for (FormulaValidator.FieldResult result : validated.results()) { + if (result.ast() != null) { + asts.put(result.field(), result.ast()); + } + } + return new ModuleConfig( + module.moduleId(), + module.moduleName(), + module.saveTable(), + module.keyField(), + fields, + nodes, + asts, + validated.graph(), + FormulaRevision.formulaRevision(nodes), + validated.errors() + ); + } + + /** 反查「以该表为保存表」且带公式的模块:用于拒绝缺少 moduleCode 的普通直写 */ + public List findFormulaConfigsBySaveTable(Connection connection, String saveTable) + throws SQLException { + List configs = new ArrayList<>(); + if (saveTable == null || saveTable.isBlank()) { + return configs; + } + String sql = "SELECT b_id FROM dbo.s_module " + + "WHERE b_save_table = ? AND b_module_type = 'data' AND b_canuse = 1"; + List moduleIds = new ArrayList<>(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, saveTable.trim()); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + moduleIds.add(resultSet.getString("b_id")); + } + } + } + for (String moduleId : moduleIds) { + ModuleConfig config = loadConfig(connection, moduleId); + if (config != null && config.hasFormulas()) { + configs.add(config); + } + } + return configs; + } + + private ModuleRow loadModuleRow(Connection connection, String moduleCode) throws SQLException { + if (moduleCode == null || moduleCode.isBlank()) { + return null; + } + String sql = "SELECT b_id, b_name, b_save_table, b_key_field, b_module_type FROM dbo.s_module " + + "WHERE b_id = ? AND b_canuse = 1"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, moduleCode.trim()); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + return new ModuleRow( + resultSet.getString("b_id"), + resultSet.getString("b_name"), + resultSet.getString("b_save_table"), + resultSet.getString("b_key_field"), + resultSet.getString("b_module_type") + ); + } + } + } + + private LinkedHashMap loadFields(Connection connection, String moduleId) + throws SQLException { + LinkedHashMap fields = new LinkedHashMap<>(); + String sql = "SELECT b_field, b_name, b_type FROM dbo.s_field " + + "WHERE b_module_id = ? AND b_canuse = 1 ORDER BY b_xh ASC, b_field ASC"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, moduleId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String field = resultSet.getString("b_field"); + String bType = resultSet.getString("b_type"); + fields.put(field, new FieldMeta( + field, + resultSet.getString("b_name"), + bType, + FormulaValue.formulaTypeOf(bType) + )); + } + } + } + return fields; + } + + private String loadEditSchemaJson(Connection connection, String moduleId) throws SQLException { + String sql = "SELECT TOP 1 b_schema_json FROM dbo.s_module_schema " + + "WHERE b_module_id = ? AND b_schema_type = 'edit' AND b_canuse = 1"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, moduleId); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? resultSet.getString("b_schema_json") : null; + } + } + } + + private Set loadAutoCodeFields(Connection connection, String moduleId) throws SQLException { + Set fields = new LinkedHashSet<>(); + String sql = "SELECT b_field FROM dbo.s_autocode WHERE b_module_id = ? AND b_canuse = 1"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, moduleId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + fields.add(resultSet.getString("b_field")); + } + } + } + return fields; + } + + /** edit JSON → 公式节点(分组 / 行节点递归展开;只认带 field 且 formula 非空的节点) */ + public List parseFormulaNodes(String schemaJson) { + List nodes = new ArrayList<>(); + if (schemaJson == null || schemaJson.isBlank()) { + return nodes; + } + try { + collectFormulaNodes(objectMapper.readTree(schemaJson), nodes); + } catch (Exception exception) { + throw new BusinessException("表单配置 JSON 解析失败:" + exception.getMessage()); + } + return nodes; + } + + private void collectFormulaNodes(JsonNode node, List out) { + if (node == null || node.isNull()) { + return; + } + if (node.isArray()) { + for (JsonNode child : node) { + collectFormulaNodes(child, out); + } + return; + } + String formula = node.path("formula").asText("").trim(); + JsonNode field = node.get("field"); + if (field != null && !field.isNull() && !formula.isEmpty()) { + out.add(new FormulaNode( + field.asText(), + formula, + node.path("overridable").asBoolean(false) + )); + } + JsonNode children = node.get("children"); + if (children != null) { + collectFormulaNodes(children, out); + } + } + + private Map fieldMetaMap(LinkedHashMap fields) { + Map meta = new LinkedHashMap<>(); + for (FieldMeta field : fields.values()) { + meta.put(field.field(), field.bType()); + } + return meta; + } + + /* ================= 配置保存校验(写 s_module_schema.edit) ================= */ + + /** + * 校验待保存的表单配置里的公式:语法 / 字段存在性 / 类型 / 循环引用 / 自动编码互斥 / 目标列可写可空。 + * 任一不通过抛 {@link BusinessException},由外层事务统一回滚。 + */ + public void validateModuleSchema( + Connection connection, + String moduleId, + String schemaJson, + MetadataReader metadataReader + ) throws SQLException { + List nodes = parseFormulaNodes(schemaJson); + if (nodes.isEmpty()) { + return; + } + ModuleRow module = loadModuleRow(connection, moduleId); + if (module == null || !"data".equalsIgnoreCase(module.moduleType())) { + throw new BusinessException("计算公式仅支持 data 模块(当前模块:" + moduleId + ")"); + } + LinkedHashMap fields = loadFields(connection, moduleId); + FormulaValidator.FormulasResult validated = FormulaValidator.validateFormulas( + nodes, + FormulaValidator.Options.of(fieldMetaMap(fields)) + ); + if (!validated.errors().isEmpty()) { + throw new BusinessException("计算公式校验失败:" + issueText(validated.errors().get(0))); + } + Set autoCodeFields = loadAutoCodeFields(connection, moduleId); + for (FormulaNode node : nodes) { + if (autoCodeFields.contains(node.field())) { + throw new BusinessException( + "字段「" + fieldLabel(fields, node.field()) + "」已配置自动编码,不能再挂计算公式" + ); + } + } + if (module.saveTable() == null || module.saveTable().isBlank()) { + throw new BusinessException("模块未配置保存表,无法保存计算公式"); + } + TableMetadata table = metadataReader.loadTableMetadata(connection, module.saveTable()); + for (FormulaNode node : nodes) { + ColumnMetadata column = metadataReader.getColumn(table, node.field()); + if (column == null || !column.writable()) { + throw new BusinessException( + "公式目标字段不是保存表的可写列:" + module.saveTable() + "." + node.field() + ); + } + if (!column.nullable()) { + throw new BusinessException( + "公式目标列不允许为空,而公式结果可能为空:" + + module.saveTable() + "." + node.field() + ); + } + } + } + + /* ================= 业务数据保存(事务内) ================= */ + + /** 公式处理后的行集合:调用方用返回值替换原始列表(处理过程可能新增/删除行,且不改调用方的列表与行对象) */ + public record SaveRows(List> updates, List> inserts) { + } + + /** + * 保存前的公式处理:写回严格计算字段的服务端结果、校验覆写声明与精度。 + * + * @param request 单个表的保存请求(含 moduleCode / formulaRevision / formulaOverrides) + * @param updates 更新行 + * @param inserts 新增行 + * @return 处理后的行集合(新增计算值;只带主键的更新行会被摘掉) + */ + public SaveRows applyToSave( + Connection connection, + Map request, + TableMetadata table, + List keyColumns, + List> updates, + List> inserts, + MetadataReader metadataReader + ) throws SQLException { + String tableName = table.requestedName(); + // 1) 配置表:校验待写入的 edit 配置里的公式(设计 §12.3「服务端保存配置时必须再次校验」) + if (SCHEMA_TABLE.equalsIgnoreCase(tableName)) { + validateSchemaRows(connection, updates, inserts, metadataReader); + return new SaveRows(updates, inserts); + } + String moduleCode = ParamUtils.getString(request, MODULE_CODE); + ModuleConfig config = moduleCode == null || moduleCode.isBlank() + ? null + : loadConfig(connection, moduleCode.trim()); + + if (config == null || !config.hasFormulas()) { + // 2) 未声明模块 / 模块无公式:目标表若是带公式模块的保存表,说明调用方绕过了入口 + if (!isSystemTable(tableName)) { + List byTable = findFormulaConfigsBySaveTable(connection, tableName); + if (!byTable.isEmpty()) { + throw new BusinessException( + "表 " + tableName + " 属于带计算公式的模块(" + + byTable.get(0).moduleId() + + "),保存必须携带 moduleCode 并由公式保存处理器校验" + ); + } + } + return new SaveRows(updates, inserts); + } + if (!tableName.equalsIgnoreCase(config.saveTable())) { + throw new BusinessException( + "模块 " + config.moduleId() + " 的保存表是 " + config.saveTable() + + ",不能用于写表 " + tableName + ); + } + if (!config.issues().isEmpty()) { + throw new BusinessException("计算公式配置有误,已拒绝保存:" + issueText(config.issues().get(0))); + } + + // 3) 配置摘要:防止编辑页面按过期口径提交 + String revision = ParamUtils.getString(request, FORMULA_REVISION); + if (revision == null || !revision.equals(config.revision())) { + throw new BusinessException("计算公式配置已变更,请刷新页面后重新录入并保存"); + } + + // 4) 覆写声明:只能覆盖「配置允许覆写」的公式字段(不落库,仅本事务有效) + List overrides = overrideFields(request, config); + + // 逐行处理都在副本上进行:既不改调用方持有的请求对象,也不受 List.of / Map.of 这类不可变集合限制 + List> outInserts = new ArrayList<>(); + for (Map row : inserts) { + Map working = new LinkedHashMap<>(row); + applyInsert(config, working, overrides, table, metadataReader); + outInserts.add(working); + } + List> outUpdates = new ArrayList<>(); + for (Map row : updates) { + Map working = new LinkedHashMap<>(row); + if (applyUpdate(connection, config, working, overrides, table, keyColumns, metadataReader)) { + outUpdates.add(working); + } + } + return new SaveRows(outUpdates, outInserts); + } + + private void validateSchemaRows( + Connection connection, + List> updates, + List> inserts, + MetadataReader metadataReader + ) throws SQLException { + List> rows = new ArrayList<>(updates); + rows.addAll(inserts); + for (Map row : rows) { + if (row == null) { + continue; + } + String schemaType = stringValue(row.get("b_schema_type")); + if (!"edit".equalsIgnoreCase(schemaType)) { + continue; + } + String moduleId = stringValue(row.get("b_module_id")); + String schemaJson = stringValue(row.get("b_schema_json")); + if (moduleId == null || schemaJson == null) { + continue; + } + validateModuleSchema(connection, moduleId, schemaJson, metadataReader); + } + } + + /** 新增:严格计算字段全部由服务端计算(客户端值被忽略);覆写字段在声明时才采用人工值 */ + private void applyInsert( + ModuleConfig config, + Map row, + List overrides, + TableMetadata table, + MetadataReader metadataReader + ) { + Map working = new LinkedHashMap<>(); + for (String field : config.fields().keySet()) { + working.put(field, row.get(field)); + } + // 新增没有历史值:视为「所有来源字段都变了」,公式全量计算 + Map computed = computeValues( + config, + working, + new HashSet<>(overrides), + new LinkedHashSet<>(config.fields().keySet()) + ); + for (Map.Entry entry : computed.entrySet()) { + row.put(entry.getKey(), storageValue(config, table, entry.getKey(), entry.getValue(), metadataReader)); + } + for (String field : config.formulaFields()) { + if (overrides.contains(field)) { + Object manual = row.get(field); + row.put(field, storageValue(config, table, field, manual, metadataReader)); + } + } + } + + /** 编辑:依赖未变的严格字段保留历史值;依赖变化的重算;显式改写严格字段必须与公式结果一致 */ + private boolean applyUpdate( + Connection connection, + ModuleConfig config, + Map row, + List overrides, + TableMetadata table, + List keyColumns, + MetadataReader metadataReader + ) throws SQLException { + Map oldRow = loadRowForUpdate(connection, table, keyColumns, row, metadataReader); + if (oldRow == null) { + // 目标行不存在:交给普通更新(0 行受影响由写入层报错),不在这里造错 + return true; + } + Map merged = new LinkedHashMap<>(oldRow); + merged.putAll(row); + + Set changedFields = new LinkedHashSet<>(); + for (String field : config.fields().keySet()) { + if (config.formulaFields().contains(field)) { + continue; + } + if (!sameValue(oldRow.get(field), merged.get(field))) { + changedFields.add(field); + } + } + // 覆写声明本身也是「依赖源变化」:人工值可能影响引用它的下游公式 + changedFields.addAll(overrides); + + Map computed = computeValues( + config, + merged, + new HashSet<>(overrides), + changedFields + ); + + for (String field : config.formulaFields()) { + if (overrides.contains(field)) { + row.put(field, storageValue(config, table, field, row.get(field), metadataReader)); + continue; + } + if (computed.containsKey(field)) { + row.put(field, storageValue(config, table, field, computed.get(field), metadataReader)); + continue; + } + // 依赖未变:保留库中历史值(快照语义),显式提交的同值请求也不写列 + if (row.containsKey(field)) { + Object expected = evaluateField(config, mergedWithOld(config, oldRow), field); + if (!sameValue(expected, row.get(field))) { + throw new BusinessException( + "计算字段 " + field + " 与公式结果不一致(依赖未变时应保持原值):" + + "提交值 " + stringValue(row.get(field)) + ",公式结果 " + stringValue(expected) + ); + } + row.remove(field); + } + } + // 只带主键的更新行会被写入层判为「没有可修改的字段」:摘掉(等价于无事发生) + Set keyNames = new HashSet<>(); + for (ColumnMetadata column : keyColumns) { + keyNames.add(column.normalizedName()); + } + return row.keySet().stream() + .anyMatch(name -> !keyNames.contains(String.valueOf(name).toLowerCase(Locale.ROOT))); + } + + /** 依赖未变时求值用的上下文:以库中旧值为准(提交值不参与) */ + private Map mergedWithOld(ModuleConfig config, Map oldRow) { + Map working = new LinkedHashMap<>(); + for (String field : config.fields().keySet()) { + working.put(field, oldRow.get(field)); + } + return working; + } + + /** + * 按拓扑序重算「受变更影响」的公式字段。 + * + * @param seedFields 视为已变化的来源字段(新增 = 全部字段;编辑 = 变化的普通字段 + 覆写声明字段) + * @return 字段 → 计算结果(原始值) + */ + private Map computeValues( + ModuleConfig config, + Map working, + Set overridden, + Set seedFields + ) { + Map computed = new LinkedHashMap<>(); + Set changed = new LinkedHashSet<>(seedFields); + for (String nodeId : config.graph().order()) { + FormulaDeps.NodeId parsed = FormulaDeps.parseNodeId(nodeId); + String field = parsed == null ? null : parsed.field(); + if (field == null || overridden.contains(field)) { + continue; + } + boolean affected = false; + for (String depId : config.graph().depsOf().getOrDefault(nodeId, List.of())) { + FormulaDeps.NodeId dep = FormulaDeps.parseNodeId(depId); + if (dep != null && changed.contains(dep.field())) { + affected = true; + break; + } + } + if (!affected) { + continue; + } + Object value = evaluateField(config, working, field); + working.put(field, value); + computed.put(field, value); + changed.add(field); + } + return computed; + } + + /** 单字段求值(失败即拒绝整个事务,不把空值伪装成合法结果) */ + private Object evaluateField(ModuleConfig config, Map working, String field) { + FormulaAst ast = config.asts().get(field); + if (ast == null) { + throw new BusinessException("计算字段 " + field + " 的公式缺失或未通过校验"); + } + Map contextFields = new LinkedHashMap<>(); + for (String name : config.fields().keySet()) { + contextFields.put(name, working.get(name)); + } + FormulaEvaluator.EvalResult result = FormulaEvaluator.evaluate( + ast, + FormulaEvaluator.Context.of(contextFields, config.fieldTypes()) + ); + if (result.error() != null) { + throw new BusinessException("计算字段 " + field + " 求值失败:" + result.error().message()); + } + return FormulaValue.rawValue(result.value()); + } + + /** 目标列校验:可空性 + 数值精度(落库边界按列 scale 收敛) */ + private Object storageValue( + ModuleConfig config, + TableMetadata table, + String field, + Object value, + MetadataReader metadataReader + ) { + ColumnMetadata column = metadataReader.getColumn(table, field); + if (column == null) { + throw new BusinessException("公式目标字段不存在:" + table.requestedName() + "." + field); + } + if (value == null) { + if (!column.nullable()) { + throw new BusinessException("计算字段 " + field + " 结果为空,但目标列不允许为空"); + } + return null; + } + if (!isNumericColumn(column)) { + return value; + } + BigDecimal decimal = FormulaValue.parseDecimal(stringValue(value)); + if (decimal == null) { + throw new BusinessException("计算字段 " + field + " 的结果不是合法数值:" + value); + } + BigDecimal scaled = column.scale() > 0 + ? decimal.setScale(column.scale(), RoundingMode.HALF_UP) + : decimal.setScale(0, RoundingMode.HALF_UP); + if (column.precision() > 0) { + int integerDigits = scaled.precision() - scaled.scale(); + if (integerDigits > column.precision() - column.scale()) { + throw new BusinessException( + "计算字段 " + field + " 的结果超出列精度(" + column.precision() + "," + + column.scale() + "):" + value + ); + } + } + return scaled; + } + + private boolean isNumericColumn(ColumnMetadata column) { + String type = column.typeName() == null ? "" : column.typeName().toLowerCase(Locale.ROOT); + return type.contains("int") || type.contains("decimal") || type.contains("numeric") + || type.contains("money") || type.contains("float") || type.contains("real"); + } + + /** 读取旧行(行锁):依赖变更判断与重算都要以库中值为基准,避免基于过期数据计算 */ + private Map loadRowForUpdate( + Connection connection, + TableMetadata table, + List keyColumns, + Map row, + MetadataReader metadataReader + ) throws SQLException { + List conditions = new ArrayList<>(); + List values = new ArrayList<>(); + for (ColumnMetadata column : keyColumns) { + conditions.add(SqlText.quoteQualifiedIdentifier(column.name()) + " = ?"); + values.add(row.get(column.name())); + } + if (conditions.isEmpty()) { + return null; + } + String sql = "SELECT * FROM " + table.quotedName() + " WITH (UPDLOCK, ROWLOCK) WHERE " + + String.join(" AND ", conditions); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < values.size(); index++) { + statement.setObject(index + 1, values.get(index)); + } + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + Map loaded = new LinkedHashMap<>(); + ResultSetMetaData metadata = resultSet.getMetaData(); + for (int index = 1; index <= metadata.getColumnCount(); index++) { + loaded.put(metadata.getColumnName(index), TimeUtils.toApiValue(resultSet.getObject(index))); + } + return loaded; + } + } + } + + /** 覆写声明:只允许「配置为 overridable 的公式字段」,其它一律拒绝(防止伪造覆写) */ + private List overrideFields(Map request, ModuleConfig config) { + List overrides = new ArrayList<>(); + List raw = ParamUtils.getList(request, FORMULA_OVERRIDES); + if (raw == null) { + return overrides; + } + Set overridable = new LinkedHashSet<>(); + for (FormulaNode node : config.nodes()) { + if (Boolean.TRUE.equals(node.overridable())) { + overridable.add(node.field()); + } + } + for (Object item : raw) { + String field = stringValue(item); + if (field == null || field.isBlank()) { + continue; + } + String name = field.trim(); + if (!overridable.contains(name)) { + throw new BusinessException("字段 " + name + " 不允许人工覆写(未开启「允许人工改写」)"); + } + overrides.add(name); + } + return overrides; + } + + /* ================= 比较与文案 ================= */ + + /** 值比较:空值与空串同值;数值按十进制比较;其余按文本(不确定时按「已变化」处理,偏保守) */ + private boolean sameValue(Object left, Object right) { + String a = normalizeValue(left); + String b = normalizeValue(right); + return a.equals(b); + } + + private String normalizeValue(Object value) { + if (value == null) { + return ""; + } + if (value instanceof Number number) { + BigDecimal decimal = number instanceof BigDecimal bigDecimal + ? bigDecimal + : new BigDecimal(number.toString()); + return decimal.signum() == 0 ? "0" : decimal.stripTrailingZeros().toPlainString(); + } + if (value instanceof Boolean flag) { + return flag ? "1" : "0"; + } + String text = String.valueOf(value).trim(); + if (text.isEmpty()) { + return ""; + } + // 时间串归一:'T' 与空格等价、去掉尾部时区标记(出口是 UTC 墙钟串 + Z) + String normalized = text.replace('T', ' '); + if (normalized.endsWith("Z")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private String issueText(FormulaValidator.FieldIssue issue) { + String position = issue.issue().pos() == null ? "" : "第 " + (issue.issue().pos() + 1) + " 列 "; + String field = issue.field() == null ? "" : "字段「" + issue.field() + "」"; + return field + position + issue.issue().message(); + } + + private String fieldLabel(LinkedHashMap fields, String field) { + FieldMeta meta = fields.get(field); + return meta == null || meta.name() == null || meta.name().isBlank() ? field : meta.name(); + } + + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } + + private boolean isSystemTable(String tableName) { + return tableName != null + && tableName.toLowerCase(Locale.ROOT).startsWith(SYSTEM_TABLE_PREFIX); + } + + private record ModuleRow(String moduleId, String moduleName, String saveTable, String keyField, + String moduleType) { + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaDeps.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaDeps.java new file mode 100644 index 00000000..5a4e7727 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaDeps.java @@ -0,0 +1,124 @@ +package cn.g3soft.erp.service.formula; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 依赖抽取(与前端 {@code deps.js} 一致,《FMS计算公式设计》9.2):AST → 带作用域的引用与聚合引用。 + * + *

依赖节点 ID 必须带作用域,禁止只保存字段名: + *

    + *
  • {@code main:b_amount} —— 主表字段
  • + *
  • {@code row::mx_amount} —— 当前明细行字段(子表阶段)
  • + *
+ * + *

聚合引用形如 {@code {fn:'SUM', relationId:'lines', field:'mx_amount'}}, + * 用于把「明细行集合变化」也纳入依赖(子表阶段启用;V1 校验层直接拒绝)。 + */ +public final class FormulaDeps { + + private FormulaDeps() { + } + + public record Ref(String scope, String relationId, String field, String id, int pos) { + } + + public record AggRef(String fn, String relationId, String field, String id, int pos) { + } + + public record RefsResult(List refs, List aggregates, boolean usesRow) { + } + + /** refsOf 的选项:rowFields 当前行可用字段集合(子表阶段);relationId 当前关系(子表阶段) */ + public record Options(List rowFields, String relationId) { + + public static Options empty() { + return new Options(List.of(), null); + } + } + + /** 依赖节点 ID 构造(前端与 Java 实现必须一致) */ + public static String fieldNodeId(String scope, String relationId, String field) { + if ("row".equals(scope)) { + return "row:" + (relationId == null ? "" : relationId) + ":" + field; + } + return "main:" + field; + } + + /** 依赖节点 ID 解析(依赖图 / 错误信息展示复用) */ + public static NodeId parseNodeId(String id) { + String text = id == null ? "" : id; + if (text.startsWith("main:")) { + return new NodeId("main", null, text.substring(5)); + } + if (text.startsWith("row:")) { + String rest = text.substring(4); + int separator = rest.indexOf(':'); + if (separator < 0) { + return new NodeId("row", null, rest); + } + return new NodeId("row", rest.substring(0, separator), rest.substring(separator + 1)); + } + return new NodeId("main", null, text); + } + + /** 依赖节点 ID → 展示名(错误信息里给人看) */ + public static String nodeLabel(String id) { + NodeId parsed = parseNodeId(id); + if ("row".equals(parsed.scope())) { + return (parsed.relationId() == null ? "" : parsed.relationId() + ".") + parsed.field() + "(明细)"; + } + return parsed.field(); + } + + public record NodeId(String scope, String relationId, String field) { + } + + public static RefsResult refsOf(FormulaAst ast, Options options) { + Options opts = options == null ? Options.empty() : options; + Set rowFieldSet = new LinkedHashSet<>(opts.rowFields() == null ? List.of() : opts.rowFields()); + List refs = new ArrayList<>(); + List aggregates = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + boolean[] usesRow = {false}; + + FormulaParser.walkRefs(ast, (node) -> { + String scope = node.relationId() == null && rowFieldSet.contains(node.field()) ? "row" : "main"; + if ("row".equals(scope)) { + usesRow[0] = true; + } + String id = fieldNodeId(scope, node.relationId() != null ? node.field() : opts.relationId(), node.field()); + if (seen.contains(id)) { + return; + } + seen.add(id); + refs.add(new Ref(scope, node.relationId(), node.field(), id, node.pos())); + }); + + FormulaParser.walkCalls(ast, (node) -> { + FormulaFunctions.FunctionSpec spec = FormulaFunctions.functionOf(node.name()); + if (spec == null || !"aggregate".equals(spec.category())) { + return; + } + FormulaAst.Ref target = null; + for (FormulaAst arg : node.args()) { + if (arg instanceof FormulaAst.Ref ref) { + target = ref; + break; + } + } + if (target == null) { + return; + } + aggregates.add(new AggRef(node.name(), target.relationId(), target.field(), + target.relationId() != null + ? node.name() + ":" + target.relationId() + "." + target.field() + : node.name() + ":main:" + target.field(), + node.pos())); + }); + + return new RefsResult(refs, aggregates, usesRow[0]); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaError.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaError.java new file mode 100644 index 00000000..8fe5b4a1 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaError.java @@ -0,0 +1,43 @@ +package cn.g3soft.erp.service.formula; + +import java.util.Map; + +/** + * 公式错误(求值 / 校验统一的异常类型),与前端 {@code messages.js} 的 FormulaError 对应。 + * + *

异常只携带错误码、参数与位置,文案在这里由 {@link FormulaMessages#messageOf} 渲染; + * 位置 pos 为表达式内的 0-based 字符下标,没有位置时允许为 null(与 JS 一致)。 + */ +public class FormulaError extends RuntimeException { + + private final String code; + private final Map params; + private final Integer pos; + + public FormulaError(String code, Map params, Integer pos) { + super(FormulaMessages.messageOf(code, params)); + this.code = code; + this.params = params == null ? Map.of() : params; + this.pos = pos; + } + + public FormulaError(String code) { + this(code, Map.of(), null); + } + + public FormulaError(String code, Map params) { + this(code, params, null); + } + + public String code() { + return code; + } + + public Map params() { + return params; + } + + public Integer pos() { + return pos; + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaEvaluator.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaEvaluator.java new file mode 100644 index 00000000..cdc96f05 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaEvaluator.java @@ -0,0 +1,439 @@ +package cn.g3soft.erp.service.formula; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 公式求值 + 静态类型推断(与前端 {@code evaluate.js} 一致, + * 《FMS计算公式设计》7.6 空值与错误语义、9.1 求值上下文)。 + * + *

对外形如 {@code evaluate(ast, ctx) → { value, error }},同步纯函数: + *

    + *
  • 值类型统一为 {@link FormulaValue};类型不符抛 {@link FormulaError},由本类收敛为字段级错误;
  • + *
  • 空值语义:参与算术按 0、拼接按空串、比较结果为 NULL(三值逻辑),除零 / 取模零 → NULL;
  • + *
  • ctx.now 供 TODAY / NOW 使用(V1 配置期已禁止持久化使用)。
  • + *
+ * + *

同时提供 {@link #inferType} 与 {@link #typeIssueOf}:静态类型推断与求值共用同一套运算规则, + * 避免校验与运行两处口径漂移。 + */ +public final class FormulaEvaluator { + + private static final Set ARITHMETIC = Set.of("+", "-", "*", "/", "%"); + private static final Set COMPARISON = Set.of(">", ">=", "<", "<=", "==", "!="); + + private FormulaEvaluator() { + } + + /** 求值上下文:fields 主表字段原始值,fieldTypes 字段编码 → 公式类型,row 当前明细行,now 供 TODAY / NOW */ + public record Context(Map fields, Map fieldTypes, + Map row, String now) { + + public static Context empty() { + return new Context(Map.of(), Map.of(), null, null); + } + + public static Context of(Map fields, Map fieldTypes) { + return new Context(fields, fieldTypes, null, null); + } + } + + public record EvalResult(FormulaValue value, FormulaMessages.Issue error) { + } + + public static EvalResult evaluate(FormulaAst ast, Context ctx) { + try { + return new EvalResult(evaluateNode(ast, ctx == null ? Context.empty() : ctx), null); + } catch (Exception error) { + return new EvalResult(FormulaValue.NULL_VALUE, FormulaMessages.toIssue(error)); + } + } + + private static FormulaValue evaluateNode(FormulaAst node, Context ctx) { + if (node instanceof FormulaAst.Literal literal) { + return literalValue(literal); + } + if (node instanceof FormulaAst.Ref ref) { + return resolveRef(ref, ctx); + } + if (node instanceof FormulaAst.Unary unary) { + return evaluateUnary(unary, ctx); + } + if (node instanceof FormulaAst.Binary binary) { + return evaluateBinary(binary, ctx); + } + if (node instanceof FormulaAst.Call call) { + return evaluateCall(call, ctx); + } + throw new FormulaError(FormulaMessages.E_INTERNAL, + Map.of("node", node == null ? "null" : node.getClass().getSimpleName()), + node == null ? null : node.pos()); + } + + private static FormulaValue literalValue(FormulaAst.Literal node) { + return switch (node.valueType()) { + case FormulaValue.DECIMAL -> FormulaValue.ofDecimal((String) node.value()); + case FormulaValue.TEXT -> FormulaValue.ofText(node.value()); + case FormulaValue.BOOLEAN -> FormulaValue.ofBooleanValue((Boolean) node.value()); + default -> FormulaValue.NULL_VALUE; + }; + } + + /** 字段取值:先本行(子表阶段)再主表;取不到报错,不静默当空值 */ + private static FormulaValue resolveRef(FormulaAst.Ref node, Context ctx) { + if (node.relationId() != null) { + throw new FormulaError(FormulaMessages.E_FIELD_SCOPE_DEFERRED, + Map.of("name", node.relationId() + "." + node.field()), node.pos()); + } + Map row = ctx.row(); + boolean inRow = row != null && row.containsKey(node.field()); + Map source = inRow ? row : (ctx.fields() == null ? Map.of() : ctx.fields()); + if (!source.containsKey(node.field())) { + throw new FormulaError(FormulaMessages.R_REF_MISSING, Map.of("name", node.field()), node.pos()); + } + // ctx.fieldTypes 已是公式类型(公式类型映射只做一次,二次映射会落回文本) + String type = ctx.fieldTypes() == null + ? FormulaValue.TEXT + : ctx.fieldTypes().getOrDefault(node.field(), FormulaValue.TEXT); + FormulaValue value = FormulaValue.fieldValue(type, source.get(node.field())); + if (value == null) { + throw new FormulaError(FormulaMessages.R_FIELD_VALUE_INVALID, + Map.of("name", node.field(), "type", FormulaFunctions.typeLabel(type)), node.pos()); + } + return value; + } + + private static FormulaValue evaluateUnary(FormulaAst.Unary node, Context ctx) { + FormulaValue operand = evaluateNode(node.operand(), ctx); + if ("!".equals(node.op())) { + if (FormulaValue.isNull(operand)) { + return FormulaValue.NULL_VALUE; + } + if (!FormulaValue.BOOLEAN.equals(operand.type())) { + throw new FormulaError(FormulaMessages.E_TYPE_UNARY, + Map.of("op", "!", "type", FormulaFunctions.typeLabel(operand.type())), node.pos()); + } + return FormulaValue.ofBooleanValue(!(Boolean) operand.value()); + } + // 一元负号:空值按 0(与算术一致) + if (FormulaValue.isNull(operand)) { + return FormulaValue.ofDecimal(BigDecimal.ZERO); + } + if (!FormulaValue.DECIMAL.equals(operand.type())) { + throw new FormulaError(FormulaMessages.E_TYPE_UNARY, + Map.of("op", "-", "type", FormulaFunctions.typeLabel(operand.type())), node.pos()); + } + return FormulaValue.ofDecimal(((BigDecimal) operand.value()).negate()); + } + + private static FormulaValue evaluateBinary(FormulaAst.Binary node, Context ctx) { + String op = node.op(); + if ("AND".equals(op) || "OR".equals(op)) { + FormulaValue left = evaluateNode(node.left(), ctx); + FormulaValue right = evaluateNode(node.right(), ctx); + FormulaFunctions.FunctionSpec spec = new FormulaFunctions.FunctionSpec( + op, "logical", null, false, false, 1, Integer.MAX_VALUE, + List.of(FormulaValue.BOOLEAN), FormulaValue.BOOLEAN, FormulaValue.BOOLEAN, null); + return "AND".equals(op) + ? FormulaFunctions.andValues(List.of(left, right), spec, ctx) + : FormulaFunctions.orValues(List.of(left, right), spec, ctx); + } + + FormulaValue left = evaluateNode(node.left(), ctx); + FormulaValue right = evaluateNode(node.right(), ctx); + + if (ARITHMETIC.contains(op)) { + BigDecimal a = numericOperand(left, op, node); + BigDecimal b = numericOperand(right, op, node); + return switch (op) { + case "+" -> FormulaValue.ofDecimal(a.add(b)); + case "-" -> FormulaValue.ofDecimal(a.subtract(b)); + case "*" -> FormulaValue.ofDecimal(a.multiply(b)); + case "/" -> b.signum() == 0 + ? FormulaValue.NULL_VALUE + : FormulaValue.ofDecimal(a.divide(b, FormulaFunctions.DIV_SCALE, RoundingMode.DOWN)); + default -> b.signum() == 0 + ? FormulaValue.NULL_VALUE + : FormulaValue.ofDecimal(a.remainder(b)); + }; + } + + if ("&".equals(op)) { + return FormulaValue.ofText(concatOperand(left, op, node) + concatOperand(right, op, node)); + } + + if (COMPARISON.contains(op)) { + // 任一为空 → 结果为空(三值逻辑) + if (FormulaValue.isNull(left) || FormulaValue.isNull(right)) { + return FormulaValue.NULL_VALUE; + } + return FormulaValue.ofBooleanValue(compare(op, left, right, node)); + } + + throw new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", op, "left", FormulaFunctions.typeLabel(left.type()), + "right", FormulaFunctions.typeLabel(right.type())), node.pos()); + } + + /** 算术操作数:空值按 0,其它类型报运算符类型错误 */ + private static BigDecimal numericOperand(FormulaValue value, String op, FormulaAst.Binary node) { + if (FormulaValue.isNull(value)) { + return BigDecimal.ZERO; + } + if (!FormulaValue.DECIMAL.equals(value.type())) { + throw new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", op, "left", FormulaFunctions.typeLabel(value.type()), "right", "数值"), node.pos()); + } + return (BigDecimal) value.value(); + } + + /** 拼接操作数:空值按空串,仅接受文本(数值请显式 TEXT(x)) */ + private static String concatOperand(FormulaValue value, String op, FormulaAst.Binary node) { + if (FormulaValue.isNull(value)) { + return ""; + } + if (!FormulaValue.TEXT.equals(value.type())) { + throw new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", op, "left", FormulaFunctions.typeLabel(value.type()), "right", "文本"), node.pos()); + } + return (String) value.value(); + } + + /** 比较:两侧类型必须一致;布尔只支持相等 / 不等 */ + private static boolean compare(String op, FormulaValue left, FormulaValue right, FormulaAst.Binary node) { + if (!left.type().equals(right.type())) { + throw new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", op, "left", FormulaFunctions.typeLabel(left.type()), + "right", FormulaFunctions.typeLabel(right.type())), node.pos()); + } + if (FormulaValue.BOOLEAN.equals(left.type()) && !"==".equals(op) && !"!=".equals(op)) { + throw new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", op, "left", "布尔", "right", "布尔"), node.pos()); + } + int order; + if (FormulaValue.DECIMAL.equals(left.type())) { + order = ((BigDecimal) left.value()).compareTo((BigDecimal) right.value()); + } else { + order = String.valueOf(left.value()).compareTo(String.valueOf(right.value())); + } + return switch (op) { + case ">" -> order > 0; + case ">=" -> order >= 0; + case "<" -> order < 0; + case "<=" -> order <= 0; + case "==" -> order == 0; + default -> order != 0; + }; + } + + private static FormulaValue evaluateCall(FormulaAst.Call node, Context ctx) { + FormulaFunctions.FunctionSpec spec = FormulaFunctions.functionOf(node.name()); + if (spec == null) { + throw new FormulaError(FormulaMessages.E_FUNC_UNKNOWN, Map.of("name", node.name()), node.pos()); + } + if ("deferred".equals(spec.phase())) { + throw new FormulaError( + "aggregate".equals(spec.category()) ? FormulaMessages.E_AGG_DEFERRED : FormulaMessages.E_FUNC_DEFERRED, + Map.of("name", node.name()), node.pos()); + } + checkArity(spec, node); + + // IF 惰性求值:只计算命中的分支 + if (spec.lazy()) { + FormulaValue condition = evaluateNode(node.args().get(0), ctx); + Boolean flag = FormulaValue.isNull(condition) + ? Boolean.FALSE + : (FormulaValue.BOOLEAN.equals(condition.type()) ? (Boolean) condition.value() : null); + if (flag == null) { + throw new FormulaError(FormulaMessages.E_FUNC_ARG_TYPE, + Map.of("name", spec.name(), "index", 1, "expected", "布尔", + "actual", FormulaFunctions.typeLabel(condition.type())), node.pos()); + } + return evaluateNode(node.args().get(flag ? 1 : 2), ctx); + } + + List args = new ArrayList<>(node.args().size()); + for (FormulaAst arg : node.args()) { + args.add(evaluateNode(arg, ctx)); + } + return spec.impl().apply(args, spec, ctx); + } + + /** 参数个数校验(validate 与求值共用;变参函数 maxArgs 为 Integer.MAX_VALUE) */ + public static void checkArity(FormulaFunctions.FunctionSpec spec, FormulaAst.Call node) { + int actual = node.args().size(); + if (actual < spec.minArgs() || actual > spec.maxArgs()) { + throw new FormulaError(FormulaMessages.E_FUNC_ARGS, + Map.of("name", spec.name(), "expected", expectedArgsText(spec), "actual", actual), node.pos()); + } + } + + public static String expectedArgsText(FormulaFunctions.FunctionSpec spec) { + if (spec.maxArgs() == Integer.MAX_VALUE) { + return "至少 " + spec.minArgs() + " 个"; + } + if (spec.minArgs() == spec.maxArgs()) { + return spec.minArgs() + " 个"; + } + return spec.minArgs() + " 到 " + spec.maxArgs() + " 个"; + } + + /* ================= 静态类型推断(与求值同规则) ================= */ + + public static String inferType(FormulaAst ast, Map fieldTypes) { + Map types = fieldTypes == null ? Map.of() : fieldTypes; + if (ast == null) { + return "any"; + } + if (ast instanceof FormulaAst.Literal literal) { + return literal.valueType(); + } + if (ast instanceof FormulaAst.Ref ref) { + return types.getOrDefault(ref.field(), "any"); + } + if (ast instanceof FormulaAst.Unary unary) { + return "!".equals(unary.op()) ? FormulaValue.BOOLEAN : FormulaValue.DECIMAL; + } + if (ast instanceof FormulaAst.Binary binary) { + if (ARITHMETIC.contains(binary.op())) { + return FormulaValue.DECIMAL; + } + return "&".equals(binary.op()) ? FormulaValue.TEXT : FormulaValue.BOOLEAN; + } + FormulaAst.Call call = (FormulaAst.Call) ast; + FormulaFunctions.FunctionSpec spec = FormulaFunctions.functionOf(call.name()); + if (spec == null) { + return "any"; + } + if ("union".equals(spec.returnsType())) { + List candidates = "IF".equals(spec.name()) + ? List.of(call.args().get(1), call.args().get(2)) + : call.args(); + List unique = new ArrayList<>(); + for (FormulaAst candidate : candidates) { + String type = inferType(candidate, types); + if (!FormulaValue.NULL.equals(type) && !unique.contains(type)) { + unique.add(type); + } + } + if (unique.isEmpty()) { + return FormulaValue.NULL; + } + return unique.size() == 1 ? unique.get(0) : "any"; + } + return spec.returnsType(); + } + + /** 运算符 / 函数的静态类型检查(供 validate 使用;无法确定时放行,交由运行期报错) */ + public static FormulaError typeIssueOf(FormulaAst ast, Map fieldTypes) { + Map types = fieldTypes == null ? Map.of() : fieldTypes; + if (ast == null) { + return null; + } + if (ast instanceof FormulaAst.Unary unary) { + String operand = inferType(unary.operand(), types); + boolean known = isKnownType(operand); + if ("-".equals(unary.op()) && known && !FormulaValue.DECIMAL.equals(operand)) { + return new FormulaError(FormulaMessages.E_TYPE_UNARY, + Map.of("op", "-", "type", FormulaFunctions.typeLabel(operand)), unary.pos()); + } + if ("!".equals(unary.op()) && known && !FormulaValue.BOOLEAN.equals(operand)) { + return new FormulaError(FormulaMessages.E_TYPE_UNARY, + Map.of("op", "!", "type", FormulaFunctions.typeLabel(operand)), unary.pos()); + } + return typeIssueOf(unary.operand(), types); + } + if (ast instanceof FormulaAst.Binary binary) { + String left = inferType(binary.left(), types); + String right = inferType(binary.right(), types); + if (ARITHMETIC.contains(binary.op())) { + if (isKnownType(left) && !FormulaValue.DECIMAL.equals(left)) { + return new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", binary.op(), "left", FormulaFunctions.typeLabel(left), "right", "数值"), + binary.pos()); + } + if (isKnownType(right) && !FormulaValue.DECIMAL.equals(right)) { + return new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", binary.op(), "left", "数值", "right", FormulaFunctions.typeLabel(right)), + binary.pos()); + } + } else if ("&".equals(binary.op())) { + if (isKnownType(left) && !FormulaValue.TEXT.equals(left)) { + return new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", "&", "left", FormulaFunctions.typeLabel(left), "right", "文本"), + binary.pos()); + } + if (isKnownType(right) && !FormulaValue.TEXT.equals(right)) { + return new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", "&", "left", "文本", "right", FormulaFunctions.typeLabel(right)), + binary.pos()); + } + } else if ("AND".equals(binary.op()) || "OR".equals(binary.op())) { + for (String type : List.of(left, right)) { + if (isKnownType(type) && !FormulaValue.BOOLEAN.equals(type)) { + return new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", binary.op(), "left", FormulaFunctions.typeLabel(type), "right", "布尔"), + binary.pos()); + } + } + } else if (COMPARISON.contains(binary.op()) && isKnownType(left) && isKnownType(right) + && !left.equals(right)) { + return new FormulaError(FormulaMessages.E_TYPE_OPERAND, + Map.of("op", binary.op(), "left", FormulaFunctions.typeLabel(left), + "right", FormulaFunctions.typeLabel(right)), binary.pos()); + } + FormulaError leftIssue = typeIssueOf(binary.left(), types); + return leftIssue != null ? leftIssue : typeIssueOf(binary.right(), types); + } + if (ast instanceof FormulaAst.Call call) { + FormulaFunctions.FunctionSpec spec = FormulaFunctions.functionOf(call.name()); + if (spec == null) { + return null; + } + List paramTypes = spec.paramTypes() == null ? List.of() : spec.paramTypes(); + for (int index = 0; index < call.args().size(); index++) { + String expected = index < paramTypes.size() + ? paramTypes.get(index) + : (spec.variadicType() == null ? "any" : spec.variadicType()); + if ("any".equals(expected)) { + continue; + } + String actual = inferType(call.args().get(index), types); + if (FormulaValue.NULL.equals(actual) || "any".equals(actual)) { + continue; + } + List accepted = List.of(expected.split("\\|")); + if (accepted.contains(actual)) { + continue; + } + List labels = new ArrayList<>(); + for (String type : accepted) { + labels.add(FormulaFunctions.typeLabel(type)); + } + Map params = new LinkedHashMap<>(); + params.put("name", spec.name()); + params.put("index", index + 1); + params.put("expected", String.join(" 或 ", labels)); + params.put("actual", FormulaFunctions.typeLabel(actual)); + return new FormulaError(FormulaMessages.E_FUNC_ARG_TYPE, params, call.args().get(index).pos()); + } + for (FormulaAst arg : call.args()) { + FormulaError issue = typeIssueOf(arg, types); + if (issue != null) { + return issue; + } + } + } + return null; + } + + /** 静态类型是否已确定(any / null 视为不确定,JS 的 known 谓词) */ + private static boolean isKnownType(String type) { + return !"any".equals(type) && !FormulaValue.NULL.equals(type); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaFunctions.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaFunctions.java new file mode 100644 index 00000000..6d6ce548 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaFunctions.java @@ -0,0 +1,686 @@ +package cn.g3soft.erp.service.formula; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 公式函数库(与前端 {@code functions.js} 一致,《FMS计算公式设计》8 章)。 + * + *

每个函数声明:分类、阶段(phase)、是否非确定性(volatile)、参数个数、静态参数类型与返回类型 + * (供校验做静态检查)、运行时实现(impl)。 + *

    + *
  • {@code phase = "deferred"} 的函数属于子表阶段能力,V1 校验直接拒绝(不静默降级);
  • + *
  • {@code volatile} 的函数(TODAY / NOW)V1 不允许用于持久化公式;
  • + *
  • IF 需要惰性求值(只算命中分支),由 {@link FormulaEvaluator} 特判,这里只声明 spec(impl 为 null);
  • + *
  • 类型不符、参数非法抛 {@link FormulaError},由求值层捕获后转成字段级错误。
  • + *
+ * + *

除法 scale = 20 且趋零截断;取模 / 除零 → NULL;POW 仅整数指数、结果 scale 上限 40; + * SQRT 负数 → NULL,结果保留 max(自身小数位, 12) 位 —— 这些边界都与 JS 的 decimalMath 一致。 + */ +public final class FormulaFunctions { + + /** 除法默认小数位数(截断后再由调用方舍入) */ + public static final int DIV_SCALE = 20; + /** POW 结果允许的最大小数位数,防止指数放大后精度爆炸 */ + private static final int MAX_POW_SCALE = 40; + + private static final Pattern TODAY_PATTERN = Pattern.compile("^(\\d{4}-\\d{2}-\\d{2})"); + private static final Pattern NOW_PATTERN = Pattern.compile("^(\\d{4}-\\d{2}-\\d{2})(?:[T ](\\d{2}:\\d{2}:\\d{2}))?"); + private static final Pattern DATE_PARTS = Pattern.compile( + "^(\\d{4})-(\\d{2})-(\\d{2})(?:[ T](\\d{2}):(\\d{2}):(\\d{2}))?$"); + + private static final Map REGISTRY = new LinkedHashMap<>(); + + private FormulaFunctions() { + } + + /** 函数运行时实现;只有 TODAY / NOW 会用到 ctx.now */ + @FunctionalInterface + public interface FunctionImpl { + FormulaValue apply(List args, FunctionSpec spec, FormulaEvaluator.Context ctx); + } + + public record FunctionSpec( + String name, + String category, + String phase, + boolean volatileFlag, + boolean lazy, + int minArgs, + int maxArgs, + List paramTypes, + String variadicType, + String returnsType, + FunctionImpl impl + ) { + public boolean isVolatile() { + return volatileFlag; + } + } + + /** 日期分量('YYYY-MM-DD[ HH:mm:ss]' 解析结果) */ + public record DateParts(int year, int month, int day, int hour, int minute, int second) { + } + + public static FunctionSpec functionOf(String name) { + return name == null ? null : REGISTRY.get(name); + } + + public static List functionNames() { + return List.copyOf(REGISTRY.keySet()); + } + + private static void define(String name, String category, String phase, boolean volatileFlag, boolean lazy, + int minArgs, int maxArgs, List paramTypes, String variadicType, + String returnsType, FunctionImpl impl) { + REGISTRY.put(name, new FunctionSpec(name, category, phase, volatileFlag, lazy, minArgs, maxArgs, + paramTypes, variadicType, returnsType, impl)); + } + + /* ================= 参数取值助手 ================= */ + + private static BigDecimal asDecimal(FormulaValue value, FunctionSpec spec, int index) { + if (FormulaValue.isNull(value)) { + return null; + } + if (!FormulaValue.DECIMAL.equals(value.type())) { + throw new FormulaError(FormulaMessages.E_FUNC_ARG_TYPE, Map.of( + "name", spec.name(), "index", index, "expected", "数值", "actual", typeLabel(value.type()))); + } + return (BigDecimal) value.value(); + } + + private static String asText(FormulaValue value, FunctionSpec spec, int index) { + if (FormulaValue.isNull(value)) { + return ""; + } + if (!FormulaValue.TEXT.equals(value.type())) { + throw new FormulaError(FormulaMessages.E_FUNC_ARG_TYPE, Map.of( + "name", spec.name(), "index", index, "expected", "文本", "actual", typeLabel(value.type()))); + } + return (String) value.value(); + } + + /** 日期字段(date)与文本字面量('2026-01-01')都接受:文本按 ISO 日期解析 */ + private static DateParts asDateParts(FormulaValue value, FunctionSpec spec, int index) { + if (FormulaValue.isNull(value)) { + return null; + } + if (FormulaValue.DATE.equals(value.type()) || FormulaValue.TEXT.equals(value.type())) { + return parseDateParts((String) value.value()); + } + throw new FormulaError(FormulaMessages.E_FUNC_ARG_TYPE, Map.of( + "name", spec.name(), "index", index, "expected", "日期", "actual", typeLabel(value.type()))); + } + + private static Boolean asBoolean(FormulaValue value, FunctionSpec spec, int index) { + if (FormulaValue.isNull(value)) { + return null; + } + if (!FormulaValue.BOOLEAN.equals(value.type())) { + throw new FormulaError(FormulaMessages.E_FUNC_ARG_TYPE, Map.of( + "name", spec.name(), "index", index, "expected", "布尔", "actual", typeLabel(value.type()))); + } + return (Boolean) value.value(); + } + + /** 整数参数(ROUND 的小数位、LEFT 的长度等):非整数报 E_FUNC_ARG_VALUE */ + private static Integer asInteger(FormulaValue value, FunctionSpec spec, int index) { + BigDecimal decimal = asDecimal(value, spec, index); + if (decimal == null) { + return null; + } + BigDecimal normalized = decimal.stripTrailingZeros(); + if (normalized.scale() > 0) { + throw new FormulaError(FormulaMessages.E_FUNC_ARG_VALUE, Map.of("name", spec.name(), "index", index)); + } + return normalized.toBigIntegerExact().intValueExact(); + } + + public static String typeLabel(String type) { + if (type == null) { + return null; + } + return switch (type) { + case FormulaValue.DECIMAL -> "数值"; + case FormulaValue.TEXT -> "文本"; + case FormulaValue.BOOLEAN -> "布尔"; + case FormulaValue.DATE -> "日期"; + case FormulaValue.NULL -> "空值"; + case "any" -> "任意"; + default -> type; + }; + } + + /* ================= 十进制定点运算(镜像 decimalMath.js) ================= */ + + private static BigDecimal div(BigDecimal left, BigDecimal right) { + return left.divide(right, DIV_SCALE, RoundingMode.DOWN); + } + + /** 取模(余数符号随被除数);模数为零返回 null */ + private static BigDecimal mod(BigDecimal left, BigDecimal right) { + return left.remainder(right); + } + + /** 整数次幂;指数非整数、结果位数超限或负指数遇零底数返回 null(后者在 JS 中会抛错,这里同样由上层归一) */ + private static BigDecimal pow(BigDecimal base, BigDecimal exponent) { + BigDecimal normalizedExponent = exponent.stripTrailingZeros(); + if (normalizedExponent.scale() > 0) { + // 非整数指数(等价 JS 的 exponent.isInteger()) + return null; + } + BigInteger powerBig = normalizedExponent.toBigIntegerExact(); + if (powerBig.abs().compareTo(BigInteger.valueOf(1000)) > 0) { + return null; + } + int power = powerBig.intValueExact(); + if (power == 0) { + return BigDecimal.ONE; + } + boolean negative = power < 0; + int times = Math.abs(power); + int scale = Math.min(base.scale() * times, MAX_POW_SCALE); + BigDecimal result = new BigDecimal(base.unscaledValue().pow(times), base.scale() * times); + if (result.scale() > scale) { + result = result.setScale(scale, RoundingMode.DOWN); + } + if (!negative) { + return result; + } + // 负指数等价于 1 / 结果;底数为 0 时除零在 JS 里是异常,这里让 BigDecimal 抛 ArithmeticException, + // 由求值层归一为 E_INTERNAL,保持与前端一致 + return div(BigDecimal.ONE, result); + } + + /** 平方根(负数返回 null);结果保留 max(自身小数位, 12) 位,等价 JS 的 isqrt 缩放法 */ + private static BigDecimal sqrt(BigDecimal value) { + BigInteger unscaled = value.unscaledValue(); + if (unscaled.signum() < 0) { + return null; + } + int targetScale = Math.max(value.scale(), 12); + int shift = targetScale - value.scale(); + BigInteger scaled = unscaled.multiply(BigInteger.TEN.pow(value.scale() + 2 * shift)); + return new BigDecimal(scaled.sqrt(), targetScale); + } + + /* ================= 数值 ================= */ + + private static void defineNumeric() { + define("ROUND", "numeric", null, false, false, 1, 2, List.of(FormulaValue.DECIMAL, FormulaValue.DECIMAL), + null, FormulaValue.DECIMAL, (args, spec, ctx) -> { + BigDecimal value = asDecimal(args.get(0), spec, 1); + Integer digits = args.size() > 1 ? asInteger(args.get(1), spec, 2) : 0; + if (value == null || digits == null) { + return FormulaValue.NULL_VALUE; + } + return FormulaValue.ofDecimal(value.setScale(Math.max(digits, 0), RoundingMode.HALF_UP)); + }); + define("ROUNDUP", "numeric", null, false, false, 1, 2, List.of(FormulaValue.DECIMAL, FormulaValue.DECIMAL), + null, FormulaValue.DECIMAL, (args, spec, ctx) -> { + BigDecimal value = asDecimal(args.get(0), spec, 1); + Integer digits = args.size() > 1 ? asInteger(args.get(1), spec, 2) : 0; + if (value == null || digits == null) { + return FormulaValue.NULL_VALUE; + } + return FormulaValue.ofDecimal(value.setScale(Math.max(digits, 0), RoundingMode.UP)); + }); + define("ROUNDDOWN", "numeric", null, false, false, 1, 2, List.of(FormulaValue.DECIMAL, FormulaValue.DECIMAL), + null, FormulaValue.DECIMAL, (args, spec, ctx) -> { + BigDecimal value = asDecimal(args.get(0), spec, 1); + Integer digits = args.size() > 1 ? asInteger(args.get(1), spec, 2) : 0; + if (value == null || digits == null) { + return FormulaValue.NULL_VALUE; + } + return FormulaValue.ofDecimal(value.setScale(Math.max(digits, 0), RoundingMode.DOWN)); + }); + define("ABS", "numeric", null, false, false, 1, 1, List.of(FormulaValue.DECIMAL), null, FormulaValue.DECIMAL, + (args, spec, ctx) -> { + BigDecimal value = asDecimal(args.get(0), spec, 1); + return value == null ? FormulaValue.NULL_VALUE : FormulaValue.ofDecimal(value.abs()); + }); + define("CEIL", "numeric", null, false, false, 1, 1, List.of(FormulaValue.DECIMAL), null, FormulaValue.DECIMAL, + (args, spec, ctx) -> { + BigDecimal value = asDecimal(args.get(0), spec, 1); + return value == null ? FormulaValue.NULL_VALUE + : FormulaValue.ofDecimal(value.setScale(0, RoundingMode.CEILING)); + }); + define("FLOOR", "numeric", null, false, false, 1, 1, List.of(FormulaValue.DECIMAL), null, FormulaValue.DECIMAL, + (args, spec, ctx) -> { + BigDecimal value = asDecimal(args.get(0), spec, 1); + return value == null ? FormulaValue.NULL_VALUE + : FormulaValue.ofDecimal(value.setScale(0, RoundingMode.FLOOR)); + }); + define("MOD", "numeric", null, false, false, 2, 2, List.of(FormulaValue.DECIMAL, FormulaValue.DECIMAL), + null, FormulaValue.DECIMAL, (args, spec, ctx) -> { + BigDecimal left = asDecimal(args.get(0), spec, 1); + BigDecimal right = asDecimal(args.get(1), spec, 2); + if (left == null || right == null) { + return FormulaValue.NULL_VALUE; + } + return right.signum() == 0 + ? FormulaValue.NULL_VALUE + : FormulaValue.ofDecimal(mod(left, right)); + }); + define("POW", "numeric", null, false, false, 2, 2, List.of(FormulaValue.DECIMAL, FormulaValue.DECIMAL), + null, FormulaValue.DECIMAL, (args, spec, ctx) -> { + BigDecimal base = asDecimal(args.get(0), spec, 1); + BigDecimal exponent = asDecimal(args.get(1), spec, 2); + if (base == null || exponent == null) { + return FormulaValue.NULL_VALUE; + } + BigDecimal result = pow(base, exponent); + return result == null ? FormulaValue.NULL_VALUE + : FormulaValue.ofDecimal(result.setScale(DIV_SCALE, RoundingMode.DOWN)); + }); + define("SQRT", "numeric", null, false, false, 1, 1, List.of(FormulaValue.DECIMAL), null, FormulaValue.DECIMAL, + (args, spec, ctx) -> { + BigDecimal value = asDecimal(args.get(0), spec, 1); + if (value == null) { + return FormulaValue.NULL_VALUE; + } + BigDecimal result = sqrt(value); + return result == null ? FormulaValue.NULL_VALUE : FormulaValue.ofDecimal(result); + }); + define("MIN", "numeric", null, false, false, 1, Integer.MAX_VALUE, List.of(FormulaValue.DECIMAL), + FormulaValue.DECIMAL, FormulaValue.DECIMAL, (args, spec, ctx) -> { + List values = collectDecimals(args, spec); + if (values.isEmpty()) { + return FormulaValue.NULL_VALUE; + } + BigDecimal min = values.get(0); + for (BigDecimal item : values) { + if (item.compareTo(min) < 0) { + min = item; + } + } + return FormulaValue.ofDecimal(min); + }); + define("MAX", "numeric", null, false, false, 1, Integer.MAX_VALUE, List.of(FormulaValue.DECIMAL), + FormulaValue.DECIMAL, FormulaValue.DECIMAL, (args, spec, ctx) -> { + List values = collectDecimals(args, spec); + if (values.isEmpty()) { + return FormulaValue.NULL_VALUE; + } + BigDecimal max = values.get(0); + for (BigDecimal item : values) { + if (item.compareTo(max) > 0) { + max = item; + } + } + return FormulaValue.ofDecimal(max); + }); + } + + /** MIN / MAX / 未来聚合:收集非空数值参数 */ + private static List collectDecimals(List args, FunctionSpec spec) { + List values = new ArrayList<>(); + for (int index = 0; index < args.size(); index++) { + BigDecimal value = asDecimal(args.get(index), spec, index + 1); + if (value != null) { + values.add(value); + } + } + return values; + } + + /* ================= 条件与空值 ================= */ + + private static void defineLogical() { + // IF 惰性求值:由求值层特判,这里只声明 spec + define("IF", "logical", null, false, true, 3, 3, + List.of(FormulaValue.BOOLEAN, "any", "any"), null, "union", null); + define("IFBLANK", "logical", null, false, false, 2, 2, List.of("any", "any"), null, "union", + (args, spec, ctx) -> FormulaValue.isNull(args.get(0)) ? args.get(1) : args.get(0)); + define("COALESCE", "logical", null, false, false, 1, Integer.MAX_VALUE, List.of("any"), "any", "union", + (args, spec, ctx) -> { + for (FormulaValue value : args) { + if (!FormulaValue.isNull(value)) { + return value; + } + } + return FormulaValue.NULL_VALUE; + }); + define("ISBLANK", "logical", null, false, false, 1, 1, List.of("any"), null, FormulaValue.BOOLEAN, + (args, spec, ctx) -> FormulaValue.ofBooleanValue(FormulaValue.isNull(args.get(0)))); + define("AND", "logical", null, false, false, 1, Integer.MAX_VALUE, List.of(FormulaValue.BOOLEAN), + FormulaValue.BOOLEAN, FormulaValue.BOOLEAN, FormulaFunctions::andValues); + define("OR", "logical", null, false, false, 1, Integer.MAX_VALUE, List.of(FormulaValue.BOOLEAN), + FormulaValue.BOOLEAN, FormulaValue.BOOLEAN, FormulaFunctions::orValues); + define("NOT", "logical", null, false, false, 1, 1, List.of(FormulaValue.BOOLEAN), null, FormulaValue.BOOLEAN, + (args, spec, ctx) -> { + Boolean value = asBoolean(args.get(0), spec, 1); + return value == null ? FormulaValue.NULL_VALUE : FormulaValue.ofBooleanValue(!value); + }); + } + + /** 三值逻辑 AND:任一 false → false;否则有 null → null;否则 true */ + public static FormulaValue andValues(List args, FunctionSpec spec, FormulaEvaluator.Context ctx) { + boolean hasNull = false; + for (int index = 0; index < args.size(); index++) { + Boolean value = asBoolean(args.get(index), spec, index + 1); + if (Boolean.FALSE.equals(value)) { + return FormulaValue.ofBooleanValue(false); + } + if (value == null) { + hasNull = true; + } + } + return hasNull ? FormulaValue.NULL_VALUE : FormulaValue.ofBooleanValue(true); + } + + /** 三值逻辑 OR:任一 true → true;否则有 null → null;否则 false */ + public static FormulaValue orValues(List args, FunctionSpec spec, FormulaEvaluator.Context ctx) { + boolean hasNull = false; + for (int index = 0; index < args.size(); index++) { + Boolean value = asBoolean(args.get(index), spec, index + 1); + if (Boolean.TRUE.equals(value)) { + return FormulaValue.ofBooleanValue(true); + } + if (value == null) { + hasNull = true; + } + } + return hasNull ? FormulaValue.NULL_VALUE : FormulaValue.ofBooleanValue(false); + } + + /* ================= 文本 ================= */ + + private static void defineText() { + define("CONCAT", "text", null, false, false, 1, Integer.MAX_VALUE, List.of(FormulaValue.TEXT), + FormulaValue.TEXT, FormulaValue.TEXT, (args, spec, ctx) -> { + StringBuilder builder = new StringBuilder(); + // CONCAT 运行期不做类型检查,与 JS 一致:任何值都取其纯文本形式 + for (FormulaValue value : args) { + builder.append(FormulaValue.plainText(value)); + } + return FormulaValue.ofText(builder.toString()); + }); + define("LEN", "text", null, false, false, 1, 1, List.of(FormulaValue.TEXT), null, FormulaValue.DECIMAL, + (args, spec, ctx) -> FormulaValue.ofDecimal( + String.valueOf(codePointLength(asText(args.get(0), spec, 1))))); + define("LEFT", "text", null, false, false, 2, 2, List.of(FormulaValue.TEXT, FormulaValue.DECIMAL), null, + FormulaValue.TEXT, (args, spec, ctx) -> { + String text = asText(args.get(0), spec, 1); + Integer count = asInteger(args.get(1), spec, 2); + if (count == null) { + return FormulaValue.NULL_VALUE; + } + return FormulaValue.ofText(codePointSlice(text, 0, Math.max(count, 0))); + }); + define("RIGHT", "text", null, false, false, 2, 2, List.of(FormulaValue.TEXT, FormulaValue.DECIMAL), null, + FormulaValue.TEXT, (args, spec, ctx) -> { + String text = asText(args.get(0), spec, 1); + Integer count = asInteger(args.get(1), spec, 2); + if (count == null) { + return FormulaValue.NULL_VALUE; + } + if (count <= 0) { + return FormulaValue.ofText(""); + } + int length = codePointLength(text); + int from = Math.max(length - count, 0); + return FormulaValue.ofText(codePointSlice(text, from, length - from)); + }); + define("MID", "text", null, false, false, 3, 3, + List.of(FormulaValue.TEXT, FormulaValue.DECIMAL, FormulaValue.DECIMAL), null, FormulaValue.TEXT, + (args, spec, ctx) -> { + String text = asText(args.get(0), spec, 1); + Integer start = asInteger(args.get(1), spec, 2); + Integer count = asInteger(args.get(2), spec, 3); + if (start == null || count == null) { + return FormulaValue.NULL_VALUE; + } + // 起始位置 1-based(与表格软件一致);越界返回空串 + int from = Math.max(start, 1) - 1; + return FormulaValue.ofText(codePointSlice(text, from, Math.max(count, 0))); + }); + define("UPPER", "text", null, false, false, 1, 1, List.of(FormulaValue.TEXT), null, FormulaValue.TEXT, + (args, spec, ctx) -> FormulaValue.ofText(asText(args.get(0), spec, 1).toUpperCase(java.util.Locale.ROOT))); + define("LOWER", "text", null, false, false, 1, 1, List.of(FormulaValue.TEXT), null, FormulaValue.TEXT, + (args, spec, ctx) -> FormulaValue.ofText(asText(args.get(0), spec, 1).toLowerCase(java.util.Locale.ROOT))); + define("TRIM", "text", null, false, false, 1, 1, List.of(FormulaValue.TEXT), null, FormulaValue.TEXT, + (args, spec, ctx) -> FormulaValue.ofText(asText(args.get(0), spec, 1).strip())); + define("REPLACE", "text", null, false, false, 3, 3, + List.of(FormulaValue.TEXT, FormulaValue.TEXT, FormulaValue.TEXT), null, FormulaValue.TEXT, + (args, spec, ctx) -> { + String text = asText(args.get(0), spec, 1); + String from = asText(args.get(1), spec, 2); + String to = asText(args.get(2), spec, 3); + return FormulaValue.ofText(from.isEmpty() ? text : text.replace(from, to)); + }); + define("TEXT", "text", null, false, false, 1, 1, List.of("any"), null, FormulaValue.TEXT, + (args, spec, ctx) -> { + FormulaValue value = args.get(0); + if (FormulaValue.isNull(value)) { + return FormulaValue.ofText(""); + } + if (FormulaValue.BOOLEAN.equals(value.type())) { + return FormulaValue.ofText((Boolean) value.value() ? "TRUE" : "FALSE"); + } + return FormulaValue.ofText(FormulaValue.plainText(value)); + }); + define("NUMBER", "text", null, false, false, 1, 1, List.of(FormulaValue.TEXT), null, FormulaValue.DECIMAL, + (args, spec, ctx) -> { + String raw = asText(args.get(0), spec, 1).trim(); + if (raw.isEmpty()) { + return FormulaValue.NULL_VALUE; + } + BigDecimal parsed = FormulaValue.parseDecimal(raw); + return parsed == null ? FormulaValue.NULL_VALUE : FormulaValue.ofDecimal(parsed); + }); + } + + private static int codePointLength(String text) { + return text.codePointCount(0, text.length()); + } + + /** 按 Unicode 码点截取,等价 JS 的 Array.from(text).slice(from, from+count) */ + private static String codePointSlice(String text, int from, int count) { + int length = codePointLength(text); + if (count <= 0 || from >= length) { + return ""; + } + int start = Math.max(from, 0); + int end = Math.min(start + count, length); + int startIndex = text.offsetByCodePoints(0, start); + int endIndex = text.offsetByCodePoints(0, end); + return text.substring(startIndex, endIndex); + } + + /* ================= 日期 ================= */ + + private static void defineDate() { + define("TODAY", "date", null, true, false, 0, 0, List.of(), null, FormulaValue.DATE, (args, spec, ctx) -> { + String now = ctx == null || ctx.now() == null ? "" : ctx.now(); + Matcher matcher = TODAY_PATTERN.matcher(String.valueOf(now)); + return matcher.find() ? FormulaValue.ofDate(matcher.group(1)) : FormulaValue.NULL_VALUE; + }); + define("NOW", "date", null, true, false, 0, 0, List.of(), null, FormulaValue.DATE, (args, spec, ctx) -> { + String now = ctx == null || ctx.now() == null ? "" : ctx.now(); + Matcher matcher = NOW_PATTERN.matcher(String.valueOf(now)); + if (!matcher.find()) { + return FormulaValue.NULL_VALUE; + } + return FormulaValue.ofDate(matcher.group(2) == null + ? matcher.group(1) + : matcher.group(1) + " " + matcher.group(2)); + }); + define("DATEADD", "date", null, false, false, 3, 3, + List.of("date|text", FormulaValue.DECIMAL, FormulaValue.TEXT), null, FormulaValue.DATE, + (args, spec, ctx) -> { + DateParts parts = asDateParts(args.get(0), spec, 1); + Integer amount = asInteger(args.get(1), spec, 2); + String unit = asText(args.get(2), spec, 3); + if (parts == null || amount == null) { + return FormulaValue.NULL_VALUE; + } + return FormulaValue.ofDate(shiftDate(parts, amount, unit, spec)); + }); + define("DATEDIFF", "date", null, false, false, 3, 3, + List.of("date|text", "date|text", FormulaValue.TEXT), null, FormulaValue.DECIMAL, + (args, spec, ctx) -> { + DateParts from = asDateParts(args.get(0), spec, 1); + DateParts to = asDateParts(args.get(1), spec, 2); + String unit = asText(args.get(2), spec, 3); + if (from == null || to == null) { + return FormulaValue.NULL_VALUE; + } + return FormulaValue.ofDecimal(diffDate(from, to, unit, spec)); + }); + defineDatePart("YEAR", (parts) -> parts.year()); + defineDatePart("MONTH", (parts) -> parts.month()); + defineDatePart("DAY", (parts) -> parts.day()); + define("FORMATDATE", "date", null, false, false, 2, 2, + List.of("date|text", FormulaValue.TEXT), null, FormulaValue.TEXT, (args, spec, ctx) -> { + DateParts parts = asDateParts(args.get(0), spec, 1); + String format = asText(args.get(1), spec, 2); + if (parts == null) { + return FormulaValue.NULL_VALUE; + } + return FormulaValue.ofText(format + .replace("yyyy", pad(parts.year(), 4, true)) + .replace("MM", pad(parts.month(), 2, false)) + .replace("dd", pad(parts.day(), 2, false)) + .replace("HH", pad(parts.hour(), 2, false)) + .replace("mm", pad(parts.minute(), 2, false)) + .replace("ss", pad(parts.second(), 2, false))); + }); + } + + private interface DatePartSelector { + int select(DateParts parts); + } + + private static void defineDatePart(String name, DatePartSelector selector) { + define(name, "date", null, false, false, 1, 1, List.of("date|text"), null, FormulaValue.DECIMAL, + (args, spec, ctx) -> { + DateParts parts = asDateParts(args.get(0), spec, 1); + return parts == null + ? FormulaValue.NULL_VALUE + : FormulaValue.ofDecimal(String.valueOf(selector.select(parts))); + }); + } + + /* ================= 聚合(子表阶段,V1 拒绝保存) ================= */ + + private static void defineAggregate() { + for (String name : List.of("SUM", "AVG", "COUNT")) { + define(name, "aggregate", "deferred", false, false, 1, 2, List.of("any"), "any", FormulaValue.DECIMAL, + (args, spec, ctx) -> { + throw new FormulaError(FormulaMessages.E_FUNC_DEFERRED, Map.of("name", spec.name())); + }); + } + } + + /* ================= 日期计算助手 ================= */ + + /** 'YYYY-MM-DD[ HH:mm:ss]' → 日期分量;非法返回 null */ + public static DateParts parseDateParts(String text) { + Matcher matcher = DATE_PARTS.matcher(String.valueOf(text).trim()); + if (!matcher.matches()) { + return null; + } + int year = Integer.parseInt(matcher.group(1)); + int month = Integer.parseInt(matcher.group(2)); + int day = Integer.parseInt(matcher.group(3)); + if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) { + return null; + } + return new DateParts(year, month, day, + parseInt(matcher.group(4)), parseInt(matcher.group(5)), parseInt(matcher.group(6))); + } + + public static int daysInMonth(int year, int month) { + return YearMonth.of(year, month).lengthOfMonth(); + } + + private static String shiftDate(DateParts parts, int amount, String unit, FunctionSpec spec) { + boolean withTime = parts.hour() != 0 || parts.minute() != 0 || parts.second() != 0; + switch (unit) { + case "day": { + LocalDate moved = LocalDate.of(parts.year(), parts.month(), parts.day()).plusDays(amount); + return formatParts(moved.getYear(), moved.getMonthValue(), moved.getDayOfMonth(), withTime, parts); + } + case "month", "year": { + long months = "month".equals(unit) ? amount : (long) amount * 12; + long total = (long) parts.year() * 12 + (parts.month() - 1) + months; + int year = (int) Math.floorDiv(total, 12L); + int month = (int) Math.floorMod(total, 12) + 1; + // 月末钳制:1-31 + 1 月 → 2-28/29 + int day = Math.min(parts.day(), daysInMonth(year, month)); + return formatParts(year, month, day, withTime, parts); + } + default: + throw new FormulaError(FormulaMessages.E_FUNC_ARG_VALUE, Map.of("name", spec.name(), "index", 3)); + } + } + + private static String diffDate(DateParts from, DateParts to, String unit, FunctionSpec spec) { + long fromEpoch = LocalDate.of(from.year(), from.month(), from.day()).toEpochDay(); + long toEpoch = LocalDate.of(to.year(), to.month(), to.day()).toEpochDay(); + switch (unit) { + case "day": + return Long.toString(toEpoch - fromEpoch); + case "month", "year": { + int months = (to.year() - from.year()) * 12 + (to.month() - from.month()); + int dayDelta = to.day() - from.day(); + if (dayDelta < 0) { + months -= 1; + } + if (toEpoch < fromEpoch && months > 0) { + months -= 1; + } + return Integer.toString("year".equals(unit) ? monthToYear(months) : months); + } + default: + throw new FormulaError(FormulaMessages.E_FUNC_ARG_VALUE, Map.of("name", spec.name(), "index", 3)); + } + } + + /** 完整月数 → 完整年数(不足一年按 0 计,负数对称) */ + private static int monthToYear(int months) { + return months >= 0 ? months / 12 : -((-months) / 12); + } + + private static String formatParts(int year, int month, int day, boolean withTime, DateParts time) { + String date = pad(year, 4, true) + "-" + pad(month, 2, false) + "-" + pad(day, 2, false); + if (!withTime) { + return date; + } + return date + " " + pad(time.hour(), 2, false) + ":" + pad(time.minute(), 2, false) + + ":" + pad(time.second(), 2, false); + } + + private static int parseInt(String text) { + return text == null ? 0 : Integer.parseInt(text); + } + + private static String pad(int value, int width, boolean year) { + String text = String.valueOf(Math.abs(value)); + StringBuilder builder = new StringBuilder(); + for (int index = text.length(); index < width; index++) { + builder.append('0'); + } + return (value < 0 ? "-" : "") + builder + text; + } + + static { + defineNumeric(); + defineLogical(); + defineText(); + defineDate(); + defineAggregate(); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaGraph.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaGraph.java new file mode 100644 index 00000000..aae669b0 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaGraph.java @@ -0,0 +1,213 @@ +package cn.g3soft.erp.service.formula; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * 依赖图与拓扑重算(与前端 {@code graph.js} 一致, + * 《FMS计算公式设计》9.3 拓扑重算、9.4 循环引用检测)。 + * + *

节点是带作用域的 ID({@code main:x} / {@code row:rel:mx}),不是裸字段名; + * 依赖集合里出现「不属于公式节点的字段」时视为外部输入(普通字段值), + * 它们不参与拓扑排序,但作为重算的触发源(changedIds)。 + * + *

{@link #buildGraph} 返回依赖图与拓扑序;有环时 order 只包含无环部分,cycles 给出环路路径。 + * {@link #createRecalculator} 在图上做「变更集 → 下游闭包 → 拓扑序重算」。 + */ +public final class FormulaGraph { + + private FormulaGraph() { + } + + /** 图输入:公式节点 ID 及其依赖(deps 可含外部字段 ID) */ + public record Entry(String id, List deps) { + } + + public record Graph( + List ids, + Map> depsOf, + Map> dependents, + List order, + List> cycles, + boolean hasCycle + ) { + } + + public static Graph buildGraph(List entries) { + List list = entries == null ? List.of() : entries; + List ids = new ArrayList<>(); + Map> depsOf = new LinkedHashMap<>(); + for (Entry entry : list) { + if (entry == null || entry.id() == null) { + continue; + } + String id = entry.id(); + if (depsOf.containsKey(id)) { + continue; + } + ids.add(id); + depsOf.put(id, new ArrayList<>(new LinkedHashSet<>(entry.deps() == null ? List.of() : entry.deps()))); + } + + Map> dependents = new LinkedHashMap<>(); + Map indegree = new LinkedHashMap<>(); + for (String id : ids) { + dependents.put(id, new ArrayList<>()); + indegree.put(id, 0); + } + for (String id : ids) { + for (String dep : depsOf.get(id)) { + // 外部输入(普通字段)不是公式节点、不参与拓扑排序,但要能找到它的下游, + // 以便「变更集 → 下游闭包」从普通字段出发(如 main:b_qty → main:b_amount); + // 入度只统计公式节点之间的边,否则带外部依赖的节点永远无法入队、被误判成环 + dependents.computeIfAbsent(dep, key -> new ArrayList<>()).add(id); + if (depsOf.containsKey(dep)) { + indegree.put(id, indegree.get(id) + 1); + } + } + } + + Deque queue = new ArrayDeque<>(); + for (String id : ids) { + if (indegree.get(id) == 0) { + queue.add(id); + } + } + List order = new ArrayList<>(); + Map remaining = new LinkedHashMap<>(indegree); + while (!queue.isEmpty()) { + String id = queue.poll(); + order.add(id); + for (String next : dependents.get(id)) { + int left = remaining.get(next) - 1; + remaining.put(next, left); + if (left == 0) { + queue.add(next); + } + } + } + + List> cycles = new ArrayList<>(); + if (order.size() != ids.size()) { + Set resolved = new LinkedHashSet<>(order); + List unresolved = new ArrayList<>(); + for (String id : ids) { + if (!resolved.contains(id)) { + unresolved.add(id); + } + } + cycles.addAll(findCycles(unresolved, depsOf)); + } + + return new Graph(ids, depsOf, dependents, order, cycles, !cycles.isEmpty()); + } + + /** 在未解析子图里找出若干条环路路径(用于报「检测到循环引用:a → b → a」) */ + private static List> findCycles(List candidates, Map> depsOf) { + Set candidateSet = new LinkedHashSet<>(candidates); + List> cycles = new ArrayList<>(); + Set reported = new LinkedHashSet<>(); + List visiting = new ArrayList<>(); + Set visited = new LinkedHashSet<>(); + + for (String id : candidates) { + walkCycle(id, candidateSet, depsOf, cycles, reported, visiting, visited); + } + return cycles; + } + + private static void walkCycle(String id, Set candidateSet, Map> depsOf, + List> cycles, Set reported, + List visiting, Set visited) { + if (visiting.contains(id)) { + int start = visiting.indexOf(id); + List path = new ArrayList<>(visiting.subList(start, visiting.size())); + path.add(id); + List keyParts = new ArrayList<>(path); + keyParts.sort(String::compareTo); + String key = String.join("|", keyParts); + if (!reported.contains(key)) { + reported.add(key); + cycles.add(path); + } + return; + } + if (visited.contains(id)) { + return; + } + visiting.add(id); + for (String dep : depsOf.getOrDefault(id, List.of())) { + if (candidateSet.contains(dep)) { + walkCycle(dep, candidateSet, depsOf, cycles, reported, visiting, visited); + } + } + visiting.remove(visiting.size() - 1); + visited.add(id); + } + + /** 环路 ID 序列 → 可读路径文本 */ + public static String cycleText(List cycle) { + List labels = new ArrayList<>(); + for (String id : cycle) { + labels.add(FormulaDeps.nodeLabel(id)); + } + return String.join(" → ", labels); + } + + /** 运行期重算器:在图上做增量重算 */ + public record Recalculator(Graph graph) { + + /** 变更集 → 需要重算的节点(拓扑序) */ + public List affected(Iterable changedIds) { + List pending = new ArrayList<>(); + for (String id : changedIds) { + pending.add(id); + } + Set seen = new LinkedHashSet<>(); + Deque queue = new ArrayDeque<>(pending); + while (!queue.isEmpty()) { + String id = queue.poll(); + for (String next : graph.dependents().getOrDefault(id, List.of())) { + if (seen.contains(next)) { + continue; + } + seen.add(next); + queue.add(next); + } + } + // 变更节点本身若是公式节点(链式传递的中间节点),也在重算范围内 + for (String id : pending) { + if (graph.depsOf().containsKey(id)) { + seen.add(id); + } + } + List affected = new ArrayList<>(); + for (String id : graph.order()) { + if (seen.contains(id)) { + affected.add(id); + } + } + return affected; + } + + /** 按拓扑序重算并返回 nodeId → value;中间结果回灌由调用方在 compute 里负责 */ + public Map recalc(Iterable changedIds, Function compute) { + Map updated = new LinkedHashMap<>(); + for (String id : affected(changedIds)) { + updated.put(id, compute.apply(id)); + } + return updated; + } + } + + public static Recalculator createRecalculator(Graph graph) { + return new Recalculator(graph); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaLexer.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaLexer.java new file mode 100644 index 00000000..c4fdb248 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaLexer.java @@ -0,0 +1,183 @@ +package cn.g3soft.erp.service.formula; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 公式词法分析(与前端 {@code lexer.js} 一致,《FMS计算公式设计》7.1)。 + * + *

词法约定(大小写敏感,天然区分字段与函数): + *

    + *
  • 字段引用:小写开头 {@code [a-z][a-z0-9_]*};关系前缀写法 {@code relationId.field}
  • + *
  • 函数名:全大写 {@code [A-Z][A-Z0-9_]*};大小写混用报 E_IDENT_CASE
  • + *
  • 数字:{@code 123} / {@code 1.5}(不支持千分位与科学计数法,负号由一元负号表达)
  • + *
  • 字符串:单引号包裹,{@code \'} 转义
  • + *
  • 关键字:TRUE / FALSE / NULL / AND / OR / NOT;运算符:+ - * / % & ( ) , . > >= < <= == != !
  • + *
+ * + *

出错时不抛异常,返回 tokens + errors;错误只带码、参数与位置,文案由 {@link FormulaMessages} 生成。 + */ +public final class FormulaLexer { + + public static final String NUMBER = "number"; + public static final String STRING = "string"; + public static final String FIELD = "field"; + public static final String FUNC = "func"; + public static final String KEYWORD = "keyword"; + public static final String OP = "op"; + public static final String LPAREN = "lparen"; + public static final String RPAREN = "rparen"; + public static final String COMMA = "comma"; + public static final String DOT = "dot"; + public static final String EOF = "eof"; + + private static final Set KEYWORDS = Set.of("TRUE", "FALSE", "NULL", "AND", "OR", "NOT"); + + /** 多字符运算符优先匹配 */ + private static final List OPERATORS = List.of(">=", "<=", "==", "!=", ">", "<", "+", "-", "*", "/", "%", "&", "!"); + + private FormulaLexer() { + } + + public record Token(String type, String value, int pos, int end) { + } + + public record Result(List tokens, List errors) { + } + + public static Result tokenize(String text) { + String source = text == null ? "" : text; + List tokens = new ArrayList<>(); + List errors = new ArrayList<>(); + int index = 0; + + while (index < source.length()) { + char current = source.charAt(index); + if (current == ' ' || current == '\t' || current == '\n' || current == '\r') { + index += 1; + continue; + } + + // 字符串字面量 + if (current == '\'') { + int start = index; + index += 1; + StringBuilder value = new StringBuilder(); + boolean closed = false; + while (index < source.length()) { + char inner = source.charAt(index); + if (inner == '\\' && index + 1 < source.length() && source.charAt(index + 1) == '\'') { + value.append('\''); + index += 2; + continue; + } + if (inner == '\'') { + closed = true; + index += 1; + break; + } + value.append(inner); + index += 1; + } + if (!closed) { + errors.add(new FormulaError(FormulaMessages.E_STRING_UNTERMINATED, Map.of(), start)); + break; + } + tokens.add(new Token(STRING, value.toString(), start, index)); + continue; + } + + // 数字字面量 + if (isDigit(current)) { + int start = index; + while (index < source.length() && isDigit(source.charAt(index))) { + index += 1; + } + if (index < source.length() && source.charAt(index) == '.' + && index + 1 < source.length() && isDigit(source.charAt(index + 1))) { + index += 1; + while (index < source.length() && isDigit(source.charAt(index))) { + index += 1; + } + } + tokens.add(new Token(NUMBER, source.substring(start, index), start, index)); + continue; + } + + // 标识符:按大小写区分字段与函数 + if (isIdentStart(current)) { + int start = index; + while (index < source.length() && isIdentPart(source.charAt(index))) { + index += 1; + } + String raw = source.substring(start, index); + boolean hasUpper = raw.chars().anyMatch(c -> c >= 'A' && c <= 'Z'); + boolean hasLower = raw.chars().anyMatch(c -> c >= 'a' && c <= 'z'); + if (hasUpper && hasLower) { + errors.add(new FormulaError(FormulaMessages.E_IDENT_CASE, Map.of("name", raw), start)); + continue; + } + if (hasUpper) { + tokens.add(new Token(KEYWORDS.contains(raw) ? KEYWORD : FUNC, raw, start, index)); + } else { + tokens.add(new Token(FIELD, raw, start, index)); + } + continue; + } + + // 运算符与括号 + String operator = null; + for (String candidate : OPERATORS) { + if (source.startsWith(candidate, index)) { + operator = candidate; + break; + } + } + if (operator != null) { + tokens.add(new Token(OP, operator, index, index + operator.length())); + index += operator.length(); + continue; + } + if (current == '(') { + tokens.add(new Token(LPAREN, "(", index, index + 1)); + index += 1; + continue; + } + if (current == ')') { + tokens.add(new Token(RPAREN, ")", index, index + 1)); + index += 1; + continue; + } + if (current == ',') { + tokens.add(new Token(COMMA, ",", index, index + 1)); + index += 1; + continue; + } + if (current == '.') { + tokens.add(new Token(DOT, ".", index, index + 1)); + index += 1; + continue; + } + + errors.add(new FormulaError(FormulaMessages.E_CHAR_UNEXPECTED, Map.of("char", String.valueOf(current)), index)); + index += 1; + } + + tokens.add(new Token(EOF, "", source.length(), source.length())); + return new Result(tokens, errors); + } + + private static boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + private static boolean isIdentStart(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; + } + + private static boolean isIdentPart(char c) { + return isIdentStart(c) || isDigit(c); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaMessages.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaMessages.java new file mode 100644 index 00000000..6c37c24e --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaMessages.java @@ -0,0 +1,154 @@ +package cn.g3soft.erp.service.formula; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 公式错误码与中文文案 —— 与前端 {@code formula/messages.js} 逐条一致。 + * + *

引擎内部只产生「错误码 + 参数 + 位置」,可读文案统一在这里生成: + * 前端按码取本地化文案,服务端保存校验返回的是同一批码,两端不会因为文案措辞不同而误判。 + * 文案里 {@code {name}} 之类的占位符与 JS 的 messageOf 同一套替换规则(参数缺失时保留原样)。 + */ +public final class FormulaMessages { + + // 词法 / 语法 + public static final String E_STRING_UNTERMINATED = "E_STRING_UNTERMINATED"; + public static final String E_IDENT_CASE = "E_IDENT_CASE"; + public static final String E_CHAR_UNEXPECTED = "E_CHAR_UNEXPECTED"; + public static final String E_OPERAND_EXPECTED = "E_OPERAND_EXPECTED"; + public static final String E_CALL_LPAREN = "E_CALL_LPAREN"; + public static final String E_CALL_RPAREN = "E_CALL_RPAREN"; + public static final String E_PAREN_UNCLOSED = "E_PAREN_UNCLOSED"; + public static final String E_COMPARE_CHAINED = "E_COMPARE_CHAINED"; + public static final String E_TRAILING_TOKEN = "E_TRAILING_TOKEN"; + public static final String E_REF_SCOPE_FIELD = "E_REF_SCOPE_FIELD"; + public static final String E_FORMULA_EMPTY = "E_FORMULA_EMPTY"; + + // 函数 + public static final String E_FUNC_UNKNOWN = "E_FUNC_UNKNOWN"; + public static final String E_FUNC_DEFERRED = "E_FUNC_DEFERRED"; + public static final String E_FUNC_VOLATILE = "E_FUNC_VOLATILE"; + public static final String E_FUNC_ARGS = "E_FUNC_ARGS"; + public static final String E_FUNC_ARG_VALUE = "E_FUNC_ARG_VALUE"; + public static final String E_FUNC_ARG_TYPE = "E_FUNC_ARG_TYPE"; + + // 字段与作用域 + public static final String E_FIELD_UNKNOWN = "E_FIELD_UNKNOWN"; + public static final String E_FIELD_SCOPE_DEFERRED = "E_FIELD_SCOPE_DEFERRED"; + public static final String E_AGG_DEFERRED = "E_AGG_DEFERRED"; + public static final String E_TARGET_NOT_WRITABLE = "E_TARGET_NOT_WRITABLE"; + public static final String E_TARGET_NOT_NULLABLE = "E_TARGET_NOT_NULLABLE"; + + // 比较与类型 + public static final String E_NULL_COMPARE = "E_NULL_COMPARE"; + public static final String E_TYPE_OPERAND = "E_TYPE_OPERAND"; + public static final String E_TYPE_UNARY = "E_TYPE_UNARY"; + public static final String E_TYPE_TARGET = "E_TYPE_TARGET"; + + // 依赖图 + public static final String E_CYCLE = "E_CYCLE"; + public static final String E_TARGET_MISSING = "E_TARGET_MISSING"; + + // 运行期 + public static final String R_DIV_ZERO = "R_DIV_ZERO"; + public static final String R_REF_MISSING = "R_REF_MISSING"; + public static final String R_FIELD_VALUE_INVALID = "R_FIELD_VALUE_INVALID"; + + // 内部兜底(非 FormulaError 的异常统一归一为它,便于排查) + public static final String E_INTERNAL = "E_INTERNAL"; + + private static final Map MESSAGES = buildMessages(); + private static final Pattern PLACEHOLDER = Pattern.compile("\\{(\\w+)}"); + + private FormulaMessages() { + } + + private static Map buildMessages() { + Map messages = new LinkedHashMap<>(); + // 词法 / 语法 + messages.put(E_STRING_UNTERMINATED, "字符串缺少结束的单引号"); + messages.put(E_IDENT_CASE, "标识符「{name}」大小写混用:字段用小写,函数用全大写"); + messages.put(E_CHAR_UNEXPECTED, "无法识别的字符「{char}」"); + messages.put(E_OPERAND_EXPECTED, "此处需要字段、数字、字符串或函数,实际是「{token}」"); + messages.put(E_CALL_LPAREN, "函数名后需要左括号,实际是「{token}」"); + messages.put(E_CALL_RPAREN, "函数参数缺少右括号"); + messages.put(E_PAREN_UNCLOSED, "括号没有闭合"); + messages.put(E_COMPARE_CHAINED, "不支持连续比较(如 a < b < c),请改用 AND 连接"); + messages.put(E_TRAILING_TOKEN, "表达式末尾有多余内容「{token}」"); + messages.put(E_REF_SCOPE_FIELD, "关系前缀后必须是字段编码"); + messages.put(E_FORMULA_EMPTY, "公式为空"); + + // 函数 + messages.put(E_FUNC_UNKNOWN, "未知函数「{name}」"); + messages.put(E_FUNC_DEFERRED, "函数「{name}」属于子表阶段能力,V1 暂不支持"); + messages.put(E_FUNC_VOLATILE, "函数「{name}」结果随时间变化,V1 不允许用于持久化公式,请改用业务日期字段"); + messages.put(E_FUNC_ARGS, "函数「{name}」参数个数不正确(期望 {expected},实际 {actual})"); + messages.put(E_FUNC_ARG_VALUE, "函数「{name}」的第 {index} 个参数取值不合法"); + messages.put(E_FUNC_ARG_TYPE, "函数「{name}」的第 {index} 个参数类型应为 {expected},实际是 {actual}"); + + // 字段与作用域 + messages.put(E_FIELD_UNKNOWN, "字段「{name}」不存在或未在模块字段中登记"); + messages.put(E_FIELD_SCOPE_DEFERRED, "明细/关系字段「{name}」属于子表阶段能力,V1 暂不支持"); + messages.put(E_AGG_DEFERRED, "聚合函数(SUM / AVG / COUNT)属于子表阶段能力,V1 暂不支持"); + messages.put(E_TARGET_NOT_WRITABLE, "公式目标字段「{name}」不是可写数据表的真实列"); + messages.put(E_TARGET_NOT_NULLABLE, "公式目标字段「{name}」不允许为空,但公式结果可能为空"); + + // 比较与类型 + messages.put(E_NULL_COMPARE, "空值不能直接参与 {op} 比较,请改用 ISBLANK(x) 或 IFBLANK(x, y)"); + messages.put(E_TYPE_OPERAND, "运算符 {op} 不支持 {left} 与 {right} 之间的运算"); + messages.put(E_TYPE_UNARY, "运算符 {op} 不能作用于 {type}"); + messages.put(E_TYPE_TARGET, "公式结果类型 {actual} 与目标字段类型 {expected} 不一致"); + + // 依赖图 + messages.put(E_CYCLE, "检测到循环引用:{path}"); + messages.put(E_TARGET_MISSING, "公式字段「{name}」没有对应的引用定义"); + + // 运行期 + messages.put(R_DIV_ZERO, "除数为零,结果为空值"); + messages.put(R_REF_MISSING, "取不到字段「{name}」的值"); + messages.put(R_FIELD_VALUE_INVALID, "字段「{name}」的值无法按 {type} 解析"); + + messages.put(E_INTERNAL, "公式引擎内部错误"); + return Collections.unmodifiableMap(messages); + } + + /** 取文案(未知错误码回退为错误码本身,便于排查) */ + public static String messageOf(String code, Map params) { + String template = MESSAGES.getOrDefault(code, code); + Matcher matcher = PLACEHOLDER.matcher(template); + StringBuilder builder = new StringBuilder(); + while (matcher.find()) { + String key = matcher.group(1); + Object value = params == null ? null : params.get(key); + String replacement = value == null ? matcher.group(0) : String.valueOf(value); + matcher.appendReplacement(builder, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(builder); + return builder.toString(); + } + + /** 归一化为可序列化的错误对象(引擎对外统一返回这个形状,等价于 JS 的 toIssue) */ + public static Issue toIssue(Throwable error) { + if (error instanceof FormulaError formulaError) { + return new Issue(formulaError.code(), formulaError.params(), formulaError.pos(), formulaError.getMessage()); + } + String message = error == null ? "null" : error.getMessage(); + if (message == null || message.isEmpty()) { + message = String.valueOf(error); + } + return new Issue(E_INTERNAL, Map.of(), null, message); + } + + /** 错误码 → i18n 资源键(文案接入 i18n 时的映射入口) */ + public static String messageKeyOf(String code) { + return "formula.msg." + String.valueOf(code).toLowerCase(); + } + + /** 对外统一的错误形状:码 + 参数 + 位置 + 已渲染文案 */ + public record Issue(String code, Map params, Integer pos, String message) { + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaNode.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaNode.java new file mode 100644 index 00000000..5d422838 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaNode.java @@ -0,0 +1,11 @@ +package cn.g3soft.erp.service.formula; + +/** + * 公式节点(配置里一条「字段 = 公式」),与前端 editSchema 中带 formula 的字段节点对应。 + * + * @param field 目标字段编码(写入方),如 {@code b_amount} + * @param formula 公式文本 + * @param overridable 是否允许会话内人工覆写(参与配置摘要,null 视为 false,与 JS === true 口径一致) + */ +public record FormulaNode(String field, String formula, Boolean overridable) { +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaParser.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaParser.java new file mode 100644 index 00000000..9eb7512e --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaParser.java @@ -0,0 +1,316 @@ +package cn.g3soft.erp.service.formula; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +/** + * 公式语法分析(与前端 {@code parser.js} 一致,《FMS计算公式设计》7.3 优先级由高到低)。 + * + *

递归下降,产出带位置(pos,0-based 字符下标)的 {@link FormulaAst}。 + * 比较运算不允许链式({@code a < b < c} 语义含混),出现连续比较符即 E_COMPARE_CHAINED; + * NOT 与 ! 同义,统一归一为 '!';AND / OR 允许关键字函数形式 {@code AND(...)}。 + * + *

未知函数、字段存在性、类型与空值比较等语义检查在 {@link FormulaValidator} / {@link FormulaEvaluator} 完成, + * 本层只保证语法结构正确。 + */ +public final class FormulaParser { + + private static final Set LOGICAL_OR = Set.of("OR"); + private static final Set LOGICAL_AND = Set.of("AND"); + private static final Set COMPARISON = Set.of(">", ">=", "<", "<=", "==", "!="); + private static final Set CONCAT = Set.of("&"); + private static final Set ADDITIVE = Set.of("+", "-"); + private static final Set MULTIPLICATIVE = Set.of("*", "/", "%"); + /** 一元运算符(含关键字 NOT,与 ! 等价;matchOperator 同时匹配 op 与 keyword) */ + private static final Set UNARY = Set.of("-", "!", "NOT"); + + private FormulaParser() { + } + + public record Result(FormulaAst ast, List errors) { + } + + public static Result parse(String text) { + FormulaLexer.Result lexed = FormulaLexer.tokenize(text); + if (!lexed.errors().isEmpty()) { + return new Result(null, lexed.errors()); + } + return new State(lexed.tokens()).run(); + } + + /** 收集 AST 中所有字段引用(含关系前缀),供依赖抽取与校验复用 */ + public static void walkRefs(FormulaAst ast, Consumer visit) { + if (ast == null) { + return; + } + if (ast instanceof FormulaAst.Ref ref) { + visit.accept(ref); + } else if (ast instanceof FormulaAst.Unary unary) { + walkRefs(unary.operand(), visit); + } else if (ast instanceof FormulaAst.Binary binary) { + walkRefs(binary.left(), visit); + walkRefs(binary.right(), visit); + } else if (ast instanceof FormulaAst.Call call) { + for (FormulaAst arg : call.args()) { + walkRefs(arg, visit); + } + } + } + + /** 收集 AST 中所有函数调用 */ + public static void walkCalls(FormulaAst ast, Consumer visit) { + if (ast == null) { + return; + } + if (ast instanceof FormulaAst.Unary unary) { + walkCalls(unary.operand(), visit); + } else if (ast instanceof FormulaAst.Binary binary) { + walkCalls(binary.left(), visit); + walkCalls(binary.right(), visit); + } else if (ast instanceof FormulaAst.Call call) { + visit.accept(call); + for (FormulaAst arg : call.args()) { + walkCalls(arg, visit); + } + } + } + + /** 语法中断信号:fail 记录错误后用它跳出递归,避免每层都写错误分支 */ + private static final class SyntaxAbort extends RuntimeException { + private SyntaxAbort() { + super(null, null, false, false); + } + } + + private static final class State { + + private final List tokens; + private final List errors = new ArrayList<>(); + private int cursor = 0; + + private State(List tokens) { + this.tokens = tokens; + } + + private Result run() { + FormulaAst ast = null; + try { + ast = parseExpression(); + FormulaLexer.Token trailing = peek(); + if (!FormulaLexer.EOF.equals(trailing.type())) { + throw fail(FormulaMessages.E_TRAILING_TOKEN, Map.of("token", describeToken(trailing)), trailing.pos()); + } + } catch (SyntaxAbort ignored) { + // 错误已记录在 errors 中 + } + return new Result(errors.isEmpty() ? ast : null, errors); + } + + private FormulaLexer.Token peek() { + return tokens.get(Math.min(cursor, tokens.size() - 1)); + } + + private SyntaxAbort fail(String code, Map params, int pos) { + errors.add(new FormulaError(code, params, pos)); + return new SyntaxAbort(); + } + + /** 匹配运算符类 token(op 或 keyword) */ + private FormulaLexer.Token matchOperator(Set operatorSet) { + FormulaLexer.Token token = peek(); + if (FormulaLexer.OP.equals(token.type()) && operatorSet.contains(token.value())) { + cursor += 1; + return token; + } + if (FormulaLexer.KEYWORD.equals(token.type()) && operatorSet.contains(token.value())) { + cursor += 1; + return token; + } + return null; + } + + private FormulaLexer.Token expect(String type, String code) { + FormulaLexer.Token token = peek(); + if (!type.equals(token.type())) { + throw fail(code, Map.of("token", describeToken(token)), token.pos()); + } + cursor += 1; + return token; + } + + private FormulaAst parseExpression() { + return parseOr(); + } + + private FormulaAst parseOr() { + FormulaAst left = parseAnd(); + for (; ; ) { + FormulaLexer.Token op = matchOperator(LOGICAL_OR); + if (op == null) { + return left; + } + FormulaAst right = parseAnd(); + left = new FormulaAst.Binary(op.value(), left, right, op.pos()); + } + } + + private FormulaAst parseAnd() { + FormulaAst left = parseComparison(); + for (; ; ) { + FormulaLexer.Token op = matchOperator(LOGICAL_AND); + if (op == null) { + return left; + } + FormulaAst right = parseComparison(); + left = new FormulaAst.Binary(op.value(), left, right, op.pos()); + } + } + + private FormulaAst parseComparison() { + FormulaAst left = parseConcat(); + FormulaLexer.Token op = matchOperator(COMPARISON); + if (op == null) { + return left; + } + FormulaAst right = parseConcat(); + FormulaLexer.Token chained = peek(); + if (FormulaLexer.OP.equals(chained.type()) && COMPARISON.contains(chained.value())) { + throw fail(FormulaMessages.E_COMPARE_CHAINED, null, chained.pos()); + } + return new FormulaAst.Binary(op.value(), left, right, op.pos()); + } + + private FormulaAst parseConcat() { + FormulaAst left = parseAdditive(); + for (; ; ) { + FormulaLexer.Token op = matchOperator(CONCAT); + if (op == null) { + return left; + } + FormulaAst right = parseAdditive(); + left = new FormulaAst.Binary(op.value(), left, right, op.pos()); + } + } + + private FormulaAst parseAdditive() { + FormulaAst left = parseMultiplicative(); + for (; ; ) { + FormulaLexer.Token op = matchOperator(ADDITIVE); + if (op == null) { + return left; + } + FormulaAst right = parseMultiplicative(); + left = new FormulaAst.Binary(op.value(), left, right, op.pos()); + } + } + + private FormulaAst parseMultiplicative() { + FormulaAst left = parseUnary(); + for (; ; ) { + FormulaLexer.Token op = matchOperator(MULTIPLICATIVE); + if (op == null) { + return left; + } + FormulaAst right = parseUnary(); + left = new FormulaAst.Binary(op.value(), left, right, op.pos()); + } + } + + private FormulaAst parseUnary() { + FormulaLexer.Token op = matchOperator(UNARY); + if (op == null) { + return parsePrimary(); + } + FormulaAst operand = parseUnary(); + // NOT 与 ! 同义,统一归一为 '!'(求值 / 类型推断只需处理一种) + String normalized = "NOT".equals(op.value()) ? "!" : op.value(); + return new FormulaAst.Unary(normalized, operand, op.pos()); + } + + private FormulaAst parsePrimary() { + FormulaLexer.Token token = peek(); + switch (token.type()) { + case FormulaLexer.NUMBER: + cursor += 1; + return new FormulaAst.Literal(FormulaValue.DECIMAL, token.value(), token.pos()); + case FormulaLexer.STRING: + cursor += 1; + return new FormulaAst.Literal(FormulaValue.TEXT, token.value(), token.pos()); + case FormulaLexer.KEYWORD: + if ("TRUE".equals(token.value()) || "FALSE".equals(token.value())) { + cursor += 1; + return new FormulaAst.Literal(FormulaValue.BOOLEAN, "TRUE".equals(token.value()), token.pos()); + } + if ("NULL".equals(token.value())) { + cursor += 1; + return new FormulaAst.Literal(FormulaValue.NULL, null, token.pos()); + } + // AND(...) / OR(...) 函数形式(关键字后紧跟左括号);NOT(...) 已由 parseUnary 归一为 ! + if (peekAt(cursor + 1).type().equals(FormulaLexer.LPAREN)) { + cursor += 1; + List args = parseCallArgs(); + return new FormulaAst.Call(token.value(), args, token.pos()); + } + throw fail(FormulaMessages.E_OPERAND_EXPECTED, Map.of("token", token.value()), token.pos()); + case FormulaLexer.FIELD: + cursor += 1; + return parseFieldRef(token); + case FormulaLexer.FUNC: + cursor += 1; + return new FormulaAst.Call(token.value(), parseCallArgs(), token.pos()); + case FormulaLexer.LPAREN: { + cursor += 1; + FormulaAst inner = parseExpression(); + expect(FormulaLexer.RPAREN, FormulaMessages.E_PAREN_UNCLOSED); + return inner; + } + case FormulaLexer.EOF: + throw fail(FormulaMessages.E_OPERAND_EXPECTED, Map.of("token", "表达式末尾"), token.pos()); + default: + throw fail(FormulaMessages.E_OPERAND_EXPECTED, Map.of("token", describeToken(token)), token.pos()); + } + } + + private List parseCallArgs() { + List args = new ArrayList<>(); + expect(FormulaLexer.LPAREN, FormulaMessages.E_CALL_LPAREN); + if (!FormulaLexer.RPAREN.equals(peek().type())) { + args.add(parseExpression()); + while (FormulaLexer.COMMA.equals(peek().type())) { + cursor += 1; + args.add(parseExpression()); + } + } + expect(FormulaLexer.RPAREN, FormulaMessages.E_CALL_RPAREN); + return args; + } + + /** 字段引用:field 或 relationId.field(关系前缀用于后续子表阶段) */ + private FormulaAst parseFieldRef(FormulaLexer.Token first) { + if (!FormulaLexer.DOT.equals(peek().type())) { + return new FormulaAst.Ref(null, first.value(), first.pos()); + } + cursor += 1; + FormulaLexer.Token second = peek(); + if (!FormulaLexer.FIELD.equals(second.type())) { + throw fail(FormulaMessages.E_REF_SCOPE_FIELD, null, second.pos()); + } + cursor += 1; + return new FormulaAst.Ref(first.value(), second.value(), first.pos()); + } + + private FormulaLexer.Token peekAt(int position) { + return tokens.get(Math.min(position, tokens.size() - 1)); + } + } + + private static String describeToken(FormulaLexer.Token token) { + if (FormulaLexer.EOF.equals(token.type())) { + return "表达式末尾"; + } + return token.value().isEmpty() ? token.type() : token.value(); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaRevision.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaRevision.java new file mode 100644 index 00000000..f902215d --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaRevision.java @@ -0,0 +1,74 @@ +package cn.g3soft.erp.service.formula; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 公式配置摘要(与前端 {@code revision.js} 一致)。 + * + *

用途(《FMS计算公式设计》6.2 / 10.5):编辑页面加载配置后计算摘要,保存请求携带; + * 服务端按同一算法重算并比对,不一致即拒绝本次保存并要求刷新,避免按过期口径写入。 + * + *

算法:对规范化后的公式节点取 FNV-1a 64 摘要(不新建表、不需要额外接口)。 + * 规范化:仅保留 {field, formula, overridable},按 field 升序,字段间以 \u0002 分隔、 + * 字段内以 \u0001 分隔(formula 先 trim)。Java 用 BigInteger + 64 位掩码完成同样的乘法溢出语义, + * UTF-8 字节逐字节累加,保证与前端摘要逐字节一致。 + */ +public final class FormulaRevision { + + private static final BigInteger FNV_OFFSET = new BigInteger("cbf29ce484222325", 16); + private static final BigInteger FNV_PRIME = new BigInteger("100000001b3", 16); + private static final BigInteger MASK = new BigInteger("ffffffffffffffff", 16); + + private FormulaRevision() { + } + + /** FNV-1a 64(对大整数乘法按 64 位掩码截断),输出 16 位小写十六进制 */ + public static String fnv1a64(String text) { + BigInteger hash = FNV_OFFSET; + for (byte value : String.valueOf(text).getBytes(StandardCharsets.UTF_8)) { + hash = hash.xor(BigInteger.valueOf(value & 0xFFL)); + hash = hash.multiply(FNV_PRIME).and(MASK); + } + String hex = hash.toString(16); + return "0".repeat(Math.max(0, 16 - hex.length())) + hex; + } + + /** 规范化文本(前后端必须一致;null / 空公式节点不参与摘要) */ + public static String canonicalFormulaText(List nodes) { + List normalized = new ArrayList<>(); + for (FormulaNode node : nodes == null ? List.of() : nodes) { + if (node == null || node.field() == null || node.field().isEmpty()) { + continue; + } + String formula = node.formula() == null ? "" : node.formula(); + if (formula.trim().isEmpty()) { + continue; + } + normalized.add(node); + } + normalized.sort(Comparator.comparing(FormulaNode::field)); + StringBuilder builder = new StringBuilder(); + boolean first = true; + for (FormulaNode node : normalized) { + if (!first) { + builder.append('\u0002'); + } + first = false; + builder.append(node.field()) + .append('\u0001') + .append(node.formula().trim()) + .append('\u0001') + .append(Boolean.TRUE.equals(node.overridable()) ? '1' : '0'); + } + return builder.toString(); + } + + /** 公式配置摘要,形如 fnv1a64:0f3a9c1d2b4e5f60 */ + public static String formulaRevision(List nodes) { + return "fnv1a64:" + fnv1a64(canonicalFormulaText(nodes)); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaValidator.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaValidator.java new file mode 100644 index 00000000..ced377d7 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaValidator.java @@ -0,0 +1,268 @@ +package cn.g3soft.erp.service.formula; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +/** + * 公式校验(与前端 {@code validate.js} 一致,《FMS计算公式设计》12.3、15 章校验规则)。 + * + *

设计器(实时 + 保存前)与服务端保存配置共用同一批规则与错误码:语法、函数 + * (未知 / 子表阶段 / 非确定性 / 参数个数)、字段存在性与作用域、空值比较、静态类型、循环引用。 + * + *

目标列的 nullable / precision / scale 需要数据库元数据,由服务端在保存时校验(见服务端保存实现)。 + */ +public final class FormulaValidator { + + private static final Set COMPARISON_OPS = Set.of(">", ">=", "<", "<=", "==", "!="); + + private FormulaValidator() { + } + + /** + * 校验选项。 + * + * @param fields 字段编码 → b_type(或 {b_type} 形式的元数据) + * @param phase 'v1'(默认)| 'subtable' + * @param targetType 目标字段的公式类型(可选,用于结果类型校验) + * @param targetField 目标字段编码(可选,仅用于文案 / 透传) + * @param rowFields 当前行可用字段(子表阶段) + */ + public record Options(Map fields, String phase, String targetType, String targetField, + List rowFields) { + + public static Options of(Map fields) { + return new Options(fields, "v1", null, null, List.of()); + } + } + + public record ValidateResult(FormulaAst ast, List errors, + List refs, String type) { + } + + public record FieldResult(String field, FormulaAst ast, List errors, + List refs, String type) { + } + + /** 模块级错误项:字段名 + 归一化错误(字段可能为 null,如循环引用) */ + public record FieldIssue(String field, FormulaMessages.Issue issue) { + } + + public record FormulasResult(List errors, FormulaGraph.Graph graph, List results) { + } + + public static ValidateResult validateFormula(String text, Options options) { + Options opts = options == null ? Options.of(null) : options; + List errors = new ArrayList<>(); + Map fieldTypes = fieldTypeMap(opts.fields()); + String phase = opts.phase() == null ? "v1" : opts.phase(); + String source = text == null ? "" : text.trim(); + + if (source.isEmpty()) { + errors.add(FormulaMessages.toIssue(new FormulaError(FormulaMessages.E_FORMULA_EMPTY, Map.of(), 0))); + return new ValidateResult(null, errors, List.of(), "any"); + } + + FormulaParser.Result parsed = FormulaParser.parse(source); + if (!parsed.errors().isEmpty() || parsed.ast() == null) { + for (FormulaError parseError : parsed.errors()) { + errors.add(FormulaMessages.toIssue(parseError)); + } + return new ValidateResult(null, errors, List.of(), "any"); + } + FormulaAst ast = parsed.ast(); + + collectFunctionIssues(ast, phase, errors); + collectRefIssues(ast, fieldTypes, errors, phase); + collectNullCompareIssues(ast, errors); + + FormulaError typeIssue = FormulaEvaluator.typeIssueOf(ast, fieldTypes); + if (typeIssue != null) { + errors.add(FormulaMessages.toIssue(typeIssue)); + } + + String inferred = FormulaEvaluator.inferType(ast, fieldTypes); + if (opts.targetType() != null && !"any".equals(inferred) && !FormulaValue.NULL.equals(inferred)) { + if (!inferred.equals(opts.targetType())) { + errors.add(FormulaMessages.toIssue(new FormulaError(FormulaMessages.E_TYPE_TARGET, + Map.of("actual", typeLabelOf(inferred), "expected", typeLabelOf(opts.targetType())), null))); + } + } + + List refs = FormulaDeps + .refsOf(ast, new FormulaDeps.Options(opts.rowFields(), null)).refs(); + return new ValidateResult(ast, errors, refs, inferred); + } + + private static Map fieldTypeMap(Map fields) { + Map map = new LinkedHashMap<>(); + if (fields == null) { + return map; + } + for (Map.Entry entry : fields.entrySet()) { + map.put(entry.getKey(), FormulaValue.formulaTypeOf(rawBType(entry.getValue()))); + } + return map; + } + + private static String rawBType(Object meta) { + if (meta instanceof Map map) { + Object bType = map.get("b_type"); + return bType == null ? null : String.valueOf(bType); + } + return meta == null ? null : String.valueOf(meta); + } + + private static String typeLabelOf(String type) { + return switch (type) { + case FormulaValue.DECIMAL -> "数值"; + case FormulaValue.TEXT -> "文本"; + case FormulaValue.BOOLEAN -> "布尔"; + case FormulaValue.DATE -> "日期"; + default -> type; + }; + } + + /** 函数检查:未知 / 子表阶段 / 非确定性 / 参数个数 */ + private static void collectFunctionIssues(FormulaAst ast, String phase, List errors) { + walk(ast, (node) -> { + if (!(node instanceof FormulaAst.Call call)) { + return; + } + FormulaFunctions.FunctionSpec spec = FormulaFunctions.functionOf(call.name()); + if (spec == null) { + errors.add(FormulaMessages.toIssue(new FormulaError(FormulaMessages.E_FUNC_UNKNOWN, + Map.of("name", call.name()), call.pos()))); + return; + } + if ("deferred".equals(spec.phase()) && "v1".equals(phase)) { + errors.add(FormulaMessages.toIssue(new FormulaError( + "aggregate".equals(spec.category()) + ? FormulaMessages.E_AGG_DEFERRED + : FormulaMessages.E_FUNC_DEFERRED, + Map.of("name", call.name()), call.pos()))); + return; + } + if (spec.isVolatile() && "v1".equals(phase)) { + errors.add(FormulaMessages.toIssue(new FormulaError(FormulaMessages.E_FUNC_VOLATILE, + Map.of("name", call.name()), call.pos()))); + } + try { + FormulaEvaluator.checkArity(spec, call); + } catch (FormulaError error) { + errors.add(FormulaMessages.toIssue(error)); + } + }); + } + + /** 引用检查:关系前缀(子表阶段)、字段存在性 */ + private static void collectRefIssues(FormulaAst ast, Map fieldTypes, + List errors, String phase) { + walk(ast, (node) -> { + if (!(node instanceof FormulaAst.Ref ref)) { + return; + } + if (ref.relationId() != null && "v1".equals(phase)) { + errors.add(FormulaMessages.toIssue(new FormulaError(FormulaMessages.E_FIELD_SCOPE_DEFERRED, + Map.of("name", ref.relationId() + "." + ref.field()), ref.pos()))); + return; + } + if (!fieldTypes.containsKey(ref.field())) { + errors.add(FormulaMessages.toIssue(new FormulaError(FormulaMessages.E_FIELD_UNKNOWN, + Map.of("name", ref.field()), ref.pos()))); + } + }); + } + + /** 空值比较:禁止 == NULL / != NULL,统一 ISBLANK */ + private static void collectNullCompareIssues(FormulaAst ast, List errors) { + walk(ast, (node) -> { + if (!(node instanceof FormulaAst.Binary binary) || !COMPARISON_OPS.contains(binary.op())) { + return; + } + if (isNullLiteral(binary.left()) || isNullLiteral(binary.right())) { + errors.add(FormulaMessages.toIssue(new FormulaError(FormulaMessages.E_NULL_COMPARE, + Map.of("op", binary.op()), binary.pos()))); + } + }); + } + + private static boolean isNullLiteral(FormulaAst node) { + return node instanceof FormulaAst.Literal literal && FormulaValue.NULL.equals(literal.valueType()); + } + + private static void walk(FormulaAst ast, Consumer visit) { + if (ast == null) { + return; + } + visit.accept(ast); + if (ast instanceof FormulaAst.Unary unary) { + walk(unary.operand(), visit); + } else if (ast instanceof FormulaAst.Binary binary) { + walk(binary.left(), visit); + walk(binary.right(), visit); + } else if (ast instanceof FormulaAst.Call call) { + for (FormulaAst arg : call.args()) { + walk(arg, visit); + } + } + } + + /** 整个模块的公式校验:逐条校验 + 依赖图 + 循环引用 */ + public static FormulasResult validateFormulas(List nodes, Options options) { + Options opts = options == null ? Options.of(null) : options; + List errors = new ArrayList<>(); + List results = new ArrayList<>(); + Map fieldTypes = fieldTypeMap(opts.fields()); + + for (FormulaNode node : nodes == null ? List.of() : nodes) { + String field = node == null || node.field() == null ? "" : node.field(); + String formula = node == null || node.formula() == null ? "" : node.formula(); + if (field.isEmpty()) { + continue; + } + if (!fieldTypes.containsKey(field)) { + errors.add(new FieldIssue(field, FormulaMessages.toIssue(new FormulaError( + FormulaMessages.E_TARGET_MISSING, Map.of("name", field), null)))); + continue; + } + String targetType = FormulaValue.formulaTypeOf( + rawBType(opts.fields() == null ? null : opts.fields().get(field))); + ValidateResult result = validateFormula(formula, + new Options(opts.fields(), opts.phase(), targetType, field, opts.rowFields())); + results.add(new FieldResult(field, result.ast(), result.errors(), result.refs(), result.type())); + for (FormulaMessages.Issue issue : result.errors()) { + errors.add(new FieldIssue(field, issue)); + } + } + + List entries = new ArrayList<>(); + for (FieldResult result : results) { + if (result.ast() == null) { + continue; + } + List deps = new ArrayList<>(); + for (FormulaDeps.Ref ref : result.refs()) { + deps.add(ref.id()); + } + entries.add(new FormulaGraph.Entry(FormulaDeps.fieldNodeId("main", null, result.field()), deps)); + } + FormulaGraph.Graph graph = FormulaGraph.buildGraph(entries); + if (graph.hasCycle()) { + for (List cycle : graph.cycles()) { + errors.add(new FieldIssue(null, FormulaMessages.toIssue(new FormulaError( + FormulaMessages.E_CYCLE, Map.of("path", FormulaGraph.cycleText(cycle)), null)))); + } + } + + return new FormulasResult(errors, graph, results); + } + + /** 校验是否通过(无错误) */ + public static boolean isValid(List errors) { + return errors == null || errors.isEmpty(); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaValue.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaValue.java new file mode 100644 index 00000000..90ae137d --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/formula/FormulaValue.java @@ -0,0 +1,220 @@ +package cn.g3soft.erp.service.formula; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 公式值类型系统(与前端 {@code values.js} 等价)。 + * + *

与《FMS计算公式设计》7.2 对应:{@code s_field.b_type} → 公式类型 + *

    + *
  • {@code number} / {@code money} → decimal(十进制定点,Java 用 BigDecimal)
  • + *
  • {@code checkbox} → boolean
  • + *
  • {@code date} / {@code datetime} → date(ISO 文本:YYYY-MM-DD / YYYY-MM-DD HH:mm:ss)
  • + *
  • {@code input} / {@code textarea} / {@code select} → text
  • + *
+ * + *

不做隐式转换(decimal 与整数字面量之间除外),类型不符即错误;空值统一用 {@link #NULL} 单例表示。 + * 文本输出口径({@link #toPlain})与 JS 的 Decimal.toPlain 一致:去掉小数末尾多余的 0,零输出 "0"。 + */ +public final class FormulaValue { + + public static final String DECIMAL = "decimal"; + public static final String BOOLEAN = "boolean"; + public static final String TEXT = "text"; + public static final String DATE = "date"; + public static final String NULL = "null"; + + /** 空值单例 */ + public static final FormulaValue NULL_VALUE = new FormulaValue(NULL, null); + + private static final String[] FORMULA_TYPES = {DECIMAL, BOOLEAN, TEXT, DATE, NULL}; + + /** 数字文本:±整数[.小数],与 JS Decimal.parse 的正则完全一致(不支持科学计数法与千分位) */ + private static final Pattern DECIMAL_TEXT = Pattern.compile("^([+-]?)(\\d+)(?:\\.(\\d+))?$"); + /** ISO 日期(不带锚点,与 JS 一样容忍后面的时间/时区后缀) */ + private static final Pattern ISO_DATE = Pattern.compile("^(\\d{4}-\\d{2}-\\d{2})(?:[T ](\\d{2}:\\d{2}:\\d{2}))?"); + + private final String t; + private final Object v; + + private FormulaValue(String t, Object v) { + this.t = t; + this.v = v; + } + + public String type() { + return t; + } + + /** 原始值:decimal 为 BigDecimal,boolean 为 Boolean,text/date 为 String,null 为 null */ + public Object value() { + return v; + } + + public boolean isNull() { + return NULL.equals(t); + } + + public static String[] formulaTypes() { + return FORMULA_TYPES.clone(); + } + + /** {@code s_field.b_type} → 公式类型(未知类型按文本处理,与字段控件默认一致) */ + public static String formulaTypeOf(String bType) { + return switch (bType == null ? "" : bType.toLowerCase()) { + case "number", "money" -> DECIMAL; + case "checkbox" -> BOOLEAN; + case "date", "datetime" -> DATE; + default -> TEXT; + }; + } + + public static FormulaValue ofDecimal(BigDecimal value) { + return value == null ? null : new FormulaValue(DECIMAL, value); + } + + /** 文本 → decimal;非法文本返回 null(由调用方按类型错误处理) */ + public static FormulaValue ofDecimal(String text) { + BigDecimal parsed = parseDecimal(text); + return parsed == null ? null : new FormulaValue(DECIMAL, parsed); + } + + public static FormulaValue ofBooleanValue(boolean flag) { + return new FormulaValue(BOOLEAN, flag); + } + + /** 兼容 0/1、true/false、'1'/'0'、'TRUE'/'FALSE' 等来源;非法返回 null(与 JS ofBoolean 一致) */ + public static FormulaValue ofBoolean(Object value) { + if (value instanceof Boolean flag) { + return ofBooleanValue(flag); + } + if (value instanceof Number number) { + if (number.doubleValue() == 1d) { + return ofBooleanValue(true); + } + if (number.doubleValue() == 0d) { + return ofBooleanValue(false); + } + return null; + } + if (value instanceof String text) { + return switch (text) { + case "1", "true", "TRUE" -> ofBooleanValue(true); + case "0", "false", "FALSE" -> ofBooleanValue(false); + default -> null; + }; + } + return null; + } + + public static FormulaValue ofText(Object value) { + return new FormulaValue(TEXT, value == null ? "" : String.valueOf(value)); + } + + /** + * 文本 → date:支持 'YYYY-MM-DD'、'YYYY-MM-DD HH:mm:ss'、带 T 的 ISO 串(截断到秒,忽略时区后缀)。 + * 空文本返回 {@link #NULL_VALUE},无法识别返回 null。 + */ + public static FormulaValue ofDate(Object value) { + String text = value == null ? "" : String.valueOf(value).trim(); + if (text.isEmpty()) { + return NULL_VALUE; + } + Matcher matcher = ISO_DATE.matcher(text); + if (!matcher.find()) { + return null; + } + return new FormulaValue(DATE, matcher.group(2) == null + ? matcher.group(1) + : matcher.group(1) + " " + matcher.group(2)); + } + + /** 运行时字段原始值 → 公式值(按字段类型;空值一律归一为 NULL) */ + public static FormulaValue fieldValue(String formulaType, Object raw) { + if (raw == null) { + return NULL_VALUE; + } + if (raw instanceof String text && text.trim().isEmpty()) { + return NULL_VALUE; + } + return switch (formulaType == null ? TEXT : formulaType) { + case DECIMAL -> ofDecimal(String.valueOf(raw).trim()); + case BOOLEAN -> ofBoolean(raw); + case DATE -> ofDate(raw); + default -> ofText(raw); + }; + } + + public static boolean isNull(FormulaValue value) { + return value == null || value.isNull(); + } + + /** 值的纯文本形式(拼接 / CONCAT / 展示用;空值为空串) */ + public static String plainText(FormulaValue value) { + if (isNull(value)) { + return ""; + } + if (DECIMAL.equals(value.t)) { + return toPlain((BigDecimal) value.v); + } + return String.valueOf(value.v); + } + + /** 数值文本(落库 / 展示口径;空值返回空串) */ + public static String numericText(FormulaValue value) { + if (isNull(value)) { + return ""; + } + return DECIMAL.equals(value.t) ? toPlain((BigDecimal) value.v) : String.valueOf(value.v); + } + + /** 值 → 落库 / 预览用的原始值(decimal 输出普通十进制文本,boolean 输出 true/false) */ + public static Object rawValue(FormulaValue value) { + if (isNull(value)) { + return null; + } + if (DECIMAL.equals(value.t)) { + return toPlain((BigDecimal) value.v); + } + return value.v; + } + + /** 调试用:类型 + 文本 */ + public static String describeValue(FormulaValue value) { + if (isNull(value)) { + return "null"; + } + return value.t + ":" + plainText(value); + } + + /** 普通十进制文本(去尾零;零输出 "0"),等价 JS Decimal.toPlain */ + public static String toPlain(BigDecimal value) { + if (value == null) { + return ""; + } + if (value.signum() == 0) { + return "0"; + } + return value.stripTrailingZeros().toPlainString(); + } + + /** 文本 → BigDecimal(非法返回 null);与 JS Decimal.parse 保持同样的正则与缩放构造 */ + public static BigDecimal parseDecimal(String text) { + if (text == null) { + return null; + } + Matcher matcher = DECIMAL_TEXT.matcher(text.trim()); + if (!matcher.matches()) { + return null; + } + String fractional = matcher.group(3) == null ? "" : matcher.group(3); + BigInteger unscaled = new BigInteger(matcher.group(2) + fractional); + if ("-".equals(matcher.group(1))) { + unscaled = unscaled.negate(); + } + return new BigDecimal(unscaled, fractional.length()); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/permission/SqlPermissionService.java b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/permission/SqlPermissionService.java new file mode 100644 index 00000000..da4fffd0 --- /dev/null +++ b/code/g3soft-erp/server/src/main/java/cn/g3soft/erp/service/permission/SqlPermissionService.java @@ -0,0 +1,320 @@ +package cn.g3soft.erp.service.permission; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.SqlText; +import jakarta.annotation.Resource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 后端 SQL 权限出口(开发规范 通用原则 10)。 + * + * 前端隐藏菜单/按钮/字段只是体验,真正生效的过滤必须在后端拼进 SQL。本服务是这一层 + * 的唯一实现,供导出使用;后续列表/查询接入时复用同一套判定,不再各写一份。 + * + * 三层,与《FMS新系统核心表结构设计》第 14 节一一对应: + * 1. 动作权限(14.1 / 14.2):s_power(action.{模块}.{动作})+ s_user_power,白名单语义; + * 2. 字段权限(14.3):s_user_field_power 的 b_view / b_export,无记录按模块默认(可看可导出); + * 3. 数据范围(14.4):s_user_data_power,按操作精确匹配优先、多条规则 OR、整体再与其它条件 AND; + * 按范围判的规则所用的字段取自模块配置 s_module.b_scope_field(记录归属人字段), + * self 直接比该字段,dept / dept_tree 由当前用户反查部门后再比。 + */ +@Service +public class SqlPermissionService { + + /** 管理员账号:与前端 permission store 的提权口径保持一致 */ + public static final String ADMIN_ACCOUNT = "g3soft"; + + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + private static final Pattern WRITE_KEYWORD_PATTERN = Pattern.compile( + "(?i)\\b(insert|update|delete|merge|drop|truncate|alter|create|exec|execute|grant|revoke|backup|restore|dbcc|kill|use|into)\\b" + ); + private static final Pattern SQL_COMMENT_PATTERN = Pattern.compile("--|/\\*|\\*/"); + + @Resource + private DataSource dataSource; + + /** 是否管理员账号(提权:跳过动作权限校验) */ + public boolean isAdministrator(String userId) { + return ADMIN_ACCOUNT.equalsIgnoreCase(String.valueOf(userId).trim()); + } + + /** + * 动作权限校验。白名单语义:s_power 中不存在该权限点、或用户未被授权、 + * 或任一侧 b_canuse = 0 时一律拒绝。 + */ + public void requireAction(String moduleId, String userId, String action) { + if (isAdministrator(userId)) { + return; + } + String sql = "SELECT TOP 1 p.[b_id] FROM [s_power] p " + + "JOIN [s_user_power] up ON up.[b_power_id] = p.[b_id] AND up.[b_canuse] = 1 " + + "WHERE p.[b_object_type] = 'module' AND p.[b_object_id] = ? AND p.[b_action] = ? " + + "AND p.[b_canuse] = 1 AND up.[b_user_id] = ?"; + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + if (query(connection, sql, moduleId, action, userId).isEmpty()) { + throw new BusinessException("没有导出权限"); + } + } catch (SQLException exception) { + throw new BusinessException("权限校验失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 不允许导出的字段集合:b_view = 0(在列表/表单/查询/导出中统一移除)或 b_export = 0。 + * 无记录视为可导出,与 14.3 的模块默认行为一致。 + */ + public Set deniedExportFields(String moduleId, String userId) { + Set denied = new LinkedHashSet<>(); + String sql = "SELECT [b_field_id], [b_view], [b_export] FROM [s_user_field_power] " + + "WHERE [b_user_id] = ? AND [b_module_id] = ? AND [b_canuse] = 1"; + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + for (Map row : query(connection, sql, userId, moduleId)) { + String field = String.valueOf(row.get("b_field_id")); + if (number(row.get("b_view")) == 0 || number(row.get("b_export")) == 0) { + denied.add(field); + } + } + } catch (SQLException exception) { + throw new BusinessException("字段权限读取失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + return denied; + } + + /** + * 数据范围条件(不含 WHERE 关键字,形如 `([b_inputuser_id] = N'u001')`)。 + * 无生效规则返回 null,表示该模块无数据范围限制。 + */ + public String buildDataScopeCondition(String moduleId, String userId, String operation) { + String sql = "SELECT [b_operation], [b_scope_type], [b_condition_sql] " + + "FROM [s_user_data_power] " + + "WHERE [b_user_id] = ? AND [b_module_id] = ? AND [b_canuse] = 1 " + + "AND ([b_operation] = ? OR [b_operation] = '*') " + + "ORDER BY [b_xh]"; + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + List> rows = query(connection, sql, userId, moduleId, operation); + List> exact = new ArrayList<>(); + List> wildcard = new ArrayList<>(); + for (Map row : rows) { + // 精确操作优先:命中精确规则时不再叠加 '*' 规则(14.4 判定规则 1) + if (operation.equalsIgnoreCase(text(row.get("b_operation")))) { + exact.add(row); + } else { + wildcard.add(row); + } + } + List> effective = exact.isEmpty() ? wildcard : exact; + + String deptId = null; + // 判断字段只来自模块配置(s_module.b_scope_field,记录归属人字段):同一个模块的 + // 所有用户必须用同一字段判范围,所以授权行上的同名列不参与判定。按需读一次, + // 只有按范围判的规则(self / dept / dept_tree)才需要它 + String moduleScopeField = null; + List fragments = new ArrayList<>(); + for (Map row : effective) { + String scopeType = text(row.get("b_scope_type")).toLowerCase(Locale.ROOT); + switch (scopeType) { + case "all", "" -> { + // 全部数据:不追加条件 + } + case "self" -> { + if (moduleScopeField == null) { + moduleScopeField = loadModuleScopeField(connection, moduleId); + } + String field = requireScopeField(moduleScopeField); + fragments.add(quote(field) + " = " + SqlText.toSqlStringLiteral(userId)); + } + case "dept", "dept_tree" -> { + if (moduleScopeField == null) { + moduleScopeField = loadModuleScopeField(connection, moduleId); + } + String field = requireScopeField(moduleScopeField); + if (deptId == null) { + deptId = loadDeptId(connection, userId); + } + // 未归属部门时该规则不生效(14.4) + if (deptId == null || deptId.isBlank()) { + break; + } + if ("dept".equals(scopeType)) { + fragments.add(quote(field) + " = " + SqlText.toSqlStringLiteral(deptId)); + } else { + String deptPath = loadDeptPath(connection, deptId); + if (deptPath == null || deptPath.isBlank()) { + break; + } + fragments.add(quote(field) + " IN (SELECT [b_id] FROM [b_dept] WHERE [b_path] LIKE " + + SqlText.toSqlStringLiteral(deptPath + "%") + ")"); + } + } + case "custom" -> { + String fragment = substitutePlaceholders(text(row.get("b_condition_sql")), userId, deptId); + if (!fragment.isBlank()) { + validateConditionFragment(fragment); + fragments.add("(" + fragment + ")"); + } + } + default -> throw new BusinessException("不支持的数据范围类型: " + scopeType); + } + } + if (fragments.isEmpty()) { + return null; + } + return "(" + String.join(" OR ", fragments) + ")"; + } catch (SQLException exception) { + throw new BusinessException("数据范围读取失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private String requireScopeField(String scopeField) { + if (scopeField == null || scopeField.isBlank()) { + throw new BusinessException("数据范围规则未配置判断字段"); + } + return scopeField; + } + + private String loadDeptId(Connection connection, String userId) throws SQLException { + List> rows = query( + connection, + "SELECT [b_dept_id] FROM [b_user] WHERE [b_id] = ?", + userId + ); + return rows.isEmpty() ? null : text(rows.get(0).get("b_dept_id")); + } + + /** + * 模块的数据范围字段(s_module.b_scope_field,记录归属人字段如 b_inputuser_id)。 + * 配在模块上而不是授权行上:同一个模块的所有用户必须用同一字段判范围。 + * 未配置时返回空串,由 requireScopeField 明确报错,不静默放行。 + */ + private String loadModuleScopeField(Connection connection, String moduleId) throws SQLException { + List> rows = query( + connection, + "SELECT [b_scope_field] FROM [s_module] WHERE [b_id] = ?", + moduleId + ); + return rows.isEmpty() ? "" : text(rows.get(0).get("b_scope_field")); + } + + private String loadDeptPath(Connection connection, String deptId) throws SQLException { + List> rows = query( + connection, + "SELECT [b_path] FROM [b_dept] WHERE [b_id] = ?", + deptId + ); + return rows.isEmpty() ? null : text(rows.get(0).get("b_path")); + } + + /** b_condition_sql 只由管理员配置,运行时仅替换受控占位符(14.5) */ + private String substitutePlaceholders(String conditionSql, String userId, String deptId) { + if (conditionSql == null) { + return ""; + } + return conditionSql.trim() + .replace("{userId}", userId) + .replace("{deptId}", deptId == null ? "" : deptId); + } + + /** + * 条件片段校验:与 SqlText.validateReadOnlySql 同一套口径,但允许不以 select 开头 + * (片段是 WHERE 里的布尔表达式)。 + */ + private void validateConditionFragment(String fragment) { + if (fragment.contains(";")) { + throw new BusinessException("数据范围条件不允许包含分号"); + } + String structure = removeStringLiterals(fragment); + if (SQL_COMMENT_PATTERN.matcher(structure).find()) { + throw new BusinessException("数据范围条件不允许包含注释"); + } + if (WRITE_KEYWORD_PATTERN.matcher(structure).find()) { + throw new BusinessException("数据范围条件包含非只读关键字"); + } + } + + /** 剔除字符串字面量,避免字面量里的关键字/分号被误判(与 SqlText.maskStringLiterals 同一做法) */ + private String removeStringLiterals(String sql) { + StringBuilder result = new StringBuilder(sql.length()); + boolean inString = false; + for (int index = 0; index < sql.length(); index++) { + char character = sql.charAt(index); + if (character != '\'') { + result.append(inString ? ' ' : character); + continue; + } + if (inString && index + 1 < sql.length() && sql.charAt(index + 1) == '\'') { + result.append(" "); + index++; + continue; + } + inString = !inString; + result.append(' '); + } + if (inString) { + throw new BusinessException("数据范围条件字符串未闭合"); + } + return result.toString(); + } + + private String quote(String field) { + if (!SAFE_IDENTIFIER.matcher(field).matches()) { + throw new BusinessException("非法数据库标识符: " + field); + } + return "[" + field + "]"; + } + + private static int number(Object value) { + return value instanceof Number num ? num.intValue() : 1; + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private List> query( + Connection connection, + String sql, + Object... parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + int columnCount = resultSet.getMetaData().getColumnCount(); + List> rows = new ArrayList<>(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(columnCount); + for (int index = 1; index <= columnCount; index++) { + row.put(resultSet.getMetaData().getColumnLabel(index), resultSet.getObject(index)); + } + rows.add(row); + } + return rows; + } + } + } +} diff --git a/code/g3soft-erp/server/src/main/resources/application.yaml b/code/g3soft-erp/server/src/main/resources/application.yaml new file mode 100644 index 00000000..7c4c15a5 --- /dev/null +++ b/code/g3soft-erp/server/src/main/resources/application.yaml @@ -0,0 +1,42 @@ +server: + port: 8088 + servlet: + context-path: /api + +spring: + application: + name: erp-api + +erp: + auth: + jwt-secret: "${ERP_JWT_SECRET:erp-api-development-jwt-secret-change-me}" + jwt-expiration: 24h + database: + config-dir: ./config/dbconfigs + # 机构配置文件变更检测周期(文件监听的兜底,正常由监听毫秒级触发;设为 0s 关闭轮询) + refresh-check-interval: 30s + # 配置变更切换后,旧连接池延迟关闭时间,等待在途请求归还连接 + retired-pool-close-delay: 60s + pool: + # 建池时先建 1 条物理连接:配置指向不可达的库时快速失败,保留旧池不切换 + initial-size: 1 + # 保留 1 条温连接:min-idle=0 时 keepAlive 无连接可保活,空闲连接被回收后每次请求都要跨公网重新建连 + min-idle: 1 + max-active: 10 + # 公网建连本身可能超过 5s(握手上限 10s),池空时需留够等待时间 + max-wait: 10000 + validation-query: SELECT 1 + test-while-idle: true + # 每次借连接都做一次跨公网 SELECT 1,既慢又放大失败面,由 testWhileIdle + keepAlive 兜底 + test-on-borrow: false + test-on-return: false + time-between-eviction-runs: 60s + # 原 30s:业务稍一空闲连接就被销毁,每个请求都要重新建连,公网建连超时概率被大幅放大 + min-evictable-idle-time: 10m + file: + storage-type: local + local: + storage-dir: ./data/files + url-prefix: /api/file/static + oss: + g3oss-url: http://g3oss.g3soft.cn:8082 diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/ErpApiApplicationTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/ErpApiApplicationTests.java new file mode 100644 index 00000000..bc21c811 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/ErpApiApplicationTests.java @@ -0,0 +1,13 @@ +package cn.g3soft.erp; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ErpApiApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/exception/GlobalExceptionHandlerTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/exception/GlobalExceptionHandlerTests.java new file mode 100644 index 00000000..18ca46a6 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/exception/GlobalExceptionHandlerTests.java @@ -0,0 +1,48 @@ +package cn.g3soft.erp.common.exception; + +import cn.g3soft.erp.common.ApiResponse; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.sql.SQLException; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class GlobalExceptionHandlerTests { + + @Test + void databaseExceptionReturnsSystemError() { + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/auth/login"); + + ApiResponse response = handler.handleDatabaseException( + new SQLException("connection failed"), + request + ); + + assertThat(response.code()).isEqualTo(ApiResponse.SYSTEM_ERROR_CODE); + assertThat(response.message()).isEqualTo("系统异常,请稍后重试"); + assertThat(response.data()).isNull(); + } + + @Test + void saveExceptionReturnsOperationLocation() { + GlobalExceptionHandler handler = new GlobalExceptionHandler(); + SaveObjectException exception = new SaveObjectException( + "bs_business", + "update", + 2, + new SQLException("update failed") + ); + + ApiResponse> response = handler.handleSaveObjectException(exception); + + assertThat(response.code()).isEqualTo(ApiResponse.SAVE_ERROR_CODE); + assertThat(response.message()).isEqualTo("保存失败: update failed"); + assertThat(response.data()).containsEntry("table", "bs_business"); + assertThat(response.data()).containsEntry("action", "update"); + assertThat(response.data()).containsEntry("index", 2); + assertThat(response.data()).containsEntry("cause", "update failed"); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/TimeUtilsTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/TimeUtilsTests.java new file mode 100644 index 00000000..71e3edfa --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/TimeUtilsTests.java @@ -0,0 +1,82 @@ +package cn.g3soft.erp.common.util; + +import cn.g3soft.erp.common.exception.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.sql.Time; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 时间口径契约(见 {@link TimeUtils}):库中 datetime 列存 UTC 墙钟值, + * 出口发带 Z 的 ISO 串,入口把带时区标记的串折回 UTC 墙钟。 + * + *

断言不依赖运行环境时区:实现与用例都用「同一时区往返」的写法(Timestamp.valueOf ↔ toLocalDateTime)。 + */ +class TimeUtilsTests { + + @Test + @DisplayName("出口:datetime 落成带 Z 的 UTC 串,date / time 原样") + void toApiValueLandsUtcIsoString() { + assertThat(TimeUtils.toApiValue(Timestamp.valueOf("2026-09-26 02:00:00"))) + .isEqualTo("2026-09-26T02:00:00Z"); + assertThat(TimeUtils.toApiValue(java.sql.Date.valueOf("2026-09-26"))).isEqualTo("2026-09-26"); + assertThat(TimeUtils.toApiValue(Time.valueOf("10:20:30"))).isEqualTo("10:20:30"); + assertThat(TimeUtils.toApiValue("原样")).isEqualTo("原样"); + } + + @Test + @DisplayName("入口:带 Z / 带偏移的串折成 UTC 墙钟,跨日也正确") + void toStorageValueFoldsZonedTextToUtcWall() { + // 北京时间 2026-09-26 10:00 = UTC 2026-09-26 02:00 + assertThat(TimeUtils.toStorageValue("datetime2", "2026-09-26T10:00:00+08:00")) + .isEqualTo("2026-09-26 02:00:00"); + assertThat(TimeUtils.toStorageValue("datetime2", "2026-09-26T02:00:00Z")) + .isEqualTo("2026-09-26 02:00:00"); + // 跨日:北京时间 00:30 落在前一天的 UTC + assertThat(TimeUtils.toStorageValue("datetime2", "2026-09-26T00:30:00+08:00")) + .isEqualTo("2026-09-25 16:30:00"); + // 无时区标记:视为已是 UTC 墙钟值,只补全格式 + assertThat(TimeUtils.toStorageValue("datetime2", "2026-09-26 02:00")) + .isEqualTo("2026-09-26 02:00:00"); + assertThat(TimeUtils.toStorageValue("datetime2", "2026-09-26")) + .isEqualTo("2026-09-26 00:00:00"); + // date / 非时间列不参与折算,非法时间串不静默落库 + assertThat(TimeUtils.toStorageValue("date", "2026-09-26")).isEqualTo("2026-09-26"); + assertThat(TimeUtils.toStorageValue("nvarchar", "abc")).isEqualTo("abc"); + assertThatThrownBy(() -> TimeUtils.toStorageValue("datetime2", "不是时间")) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("解析:带 Z / UTC 墙钟串 / Timestamp 都能取到瞬时,垃圾值返回 null") + void toInstantParsesApiAndStorageValues() { + Instant expected = Instant.parse("2026-09-26T02:00:00Z"); + assertThat(TimeUtils.toInstant("2026-09-26T02:00:00Z")).isEqualTo(expected); + assertThat(TimeUtils.toInstant("2026-09-26 02:00:00")).isEqualTo(expected); + assertThat(TimeUtils.toInstant(Timestamp.valueOf("2026-09-26 02:00:00"))).isEqualTo(expected); + assertThat(TimeUtils.toInstant("")).isNull(); + assertThat(TimeUtils.toInstant("不是时间")).isNull(); + assertThat(TimeUtils.toInstant(null)).isNull(); + } + + @Test + @DisplayName("列类型判定与业务时区折算") + void columnTypeAndBusinessZone() { + assertThat(TimeUtils.isDateTimeColumn("datetime2")).isTrue(); + assertThat(TimeUtils.isDateTimeColumn("DATETIME")).isTrue(); + assertThat(TimeUtils.isDateTimeColumn("smalldatetime")).isTrue(); + assertThat(TimeUtils.isDateTimeColumn("date")).isFalse(); + assertThat(TimeUtils.isDateTimeColumn("time")).isFalse(); + assertThat(TimeUtils.isDateTimeColumn("timestamp")).isFalse(); + assertThat(TimeUtils.isDateTimeColumn(null)).isFalse(); + // 库里 UTC 02:00 在业务时区(Asia/Shanghai)是 10:00 + assertThat(TimeUtils.businessText(LocalDate.of(2026, 9, 26).atTime(2, 0))) + .isEqualTo("2026-09-26 10:00:00"); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/snowflake/DefaultIdGeneratorTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/snowflake/DefaultIdGeneratorTests.java new file mode 100644 index 00000000..b5771aa4 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/common/util/snowflake/DefaultIdGeneratorTests.java @@ -0,0 +1,130 @@ +package cn.g3soft.erp.common.util.snowflake; + +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorException; +import cn.g3soft.erp.common.util.snowflake.contract.IdGeneratorOptions; +import cn.g3soft.erp.common.util.snowflake.idgen.DefaultIdGenerator; +import cn.g3soft.erp.common.util.snowflake.idgen.IdGenerator; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DefaultIdGeneratorTests { + + private static IdGeneratorOptions options(short workerId) { + IdGeneratorOptions o = new IdGeneratorOptions(workerId); + o.Method = 1; // 漂移算法(生产默认,自带时钟回拨保护) + return o; + } + + @Test + void generatedIds_areUniqueInBulk() throws Exception { + DefaultIdGenerator gen = new DefaultIdGenerator(options((short) 1)); + + int n = 100_000; + Set ids = ConcurrentHashMap.newKeySet(n); + for (int i = 0; i < n; i++) { + ids.add(gen.newLong()); + } + + assertThat(ids).hasSize(n); + } + + @Test + void generatedIds_areStrictlyIncreasingInSingleThread() throws Exception { + DefaultIdGenerator gen = new DefaultIdGenerator(options((short) 1)); + + long prev = gen.newLong(); + for (int i = 0; i < 10_000; i++) { + long cur = gen.newLong(); + assertThat(cur).isGreaterThan(prev); + prev = cur; + } + } + + @Test + void generatedIds_concurrentNoDuplicates() throws Exception { + DefaultIdGenerator gen = new DefaultIdGenerator(options((short) 1)); + + int threads = 8; + int perThread = 10_000; + Set ids = ConcurrentHashMap.newKeySet(); + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + for (int t = 0; t < threads; t++) { + pool.submit(() -> { + try { + start.await(); + for (int i = 0; i < perThread; i++) { + ids.add(gen.newLong()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + + start.countDown(); + assertThat(done.await(30, TimeUnit.SECONDS)).isTrue(); + pool.shutdownNow(); + + assertThat(ids).hasSize(threads * perThread); + } + + @Test + void generatedId_encodesWorkerIdAndApproximateTimestamp() throws Exception { + IdGeneratorOptions o = options((short) 7); + DefaultIdGenerator gen = new DefaultIdGenerator(o); + + long id = gen.newLong(); + + int timestampShift = o.WorkerIdBitLength + o.SeqBitLength; // 12 + long timestamp = id >>> timestampShift; + long workerId = (id >>> o.SeqBitLength) & ((1L << o.WorkerIdBitLength) - 1); + + assertThat(workerId).isEqualTo(7L); + long expectedTimestamp = System.currentTimeMillis() - o.BaseTime; + assertThat(timestamp).isBetween(expectedTimestamp - 5_000, expectedTimestamp + 5_000); + } + + @Test + void invalidOptions_areRejected() { + IdGeneratorOptions badWorkerId = options((short) -1); + assertThatThrownBy(() -> new DefaultIdGenerator(badWorkerId)) + .isInstanceOf(IdGeneratorException.class); + + IdGeneratorOptions badBaseTime = options((short) 1); + badBaseTime.BaseTime = 1000L; // 早于校验下限 315504000000 + assertThatThrownBy(() -> new DefaultIdGenerator(badBaseTime)) + .isInstanceOf(IdGeneratorException.class); + + IdGeneratorOptions tooManyBits = options((short) 1); + tooManyBits.WorkerIdBitLength = 22; // 22 + 6 > 22 + assertThatThrownBy(() -> new DefaultIdGenerator(tooManyBits)) + .isInstanceOf(IdGeneratorException.class); + + IdGeneratorOptions tooSmallSeq = options((short) 1); + tooSmallSeq.SeqBitLength = 1; // 下限 2 + assertThatThrownBy(() -> new DefaultIdGenerator(tooSmallSeq)) + .isInstanceOf(IdGeneratorException.class); + } + + @Test + void staticSingleton_worksAfterInitializationLikeApplicationStartup() throws Exception { + IdGenerator.setIdGenerator(options((short) 1)); + + long id = IdGenerator.nextId(); + + assertThat(id).isPositive(); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/config/JwtAuthFilterTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/config/JwtAuthFilterTests.java new file mode 100644 index 00000000..00512014 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/config/JwtAuthFilterTests.java @@ -0,0 +1,124 @@ +package cn.g3soft.erp.config; + +import cn.g3soft.erp.database.org.OrgContext; +import cn.g3soft.erp.service.auth.ActiveSessionRegistry; +import cn.g3soft.erp.service.auth.JwtUtils; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; +import tools.jackson.databind.ObjectMapper; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +class JwtAuthFilterTests { + + private static final String SECRET = "restart-test-signing-key-at-least-32-bytes"; + private final ObjectMapper objectMapper = new ObjectMapper(); + private ActiveSessionRegistry sessions; + + @BeforeEach + void setUp() { + sessions = new ActiveSessionRegistry(); + } + + private JwtUtils newJwtUtils() { + AuthProperties properties = new AuthProperties(); + properties.setJwtSecret(SECRET); + JwtUtils jwt = new JwtUtils(); + ReflectionTestUtils.setField(jwt, "properties", properties); + jwt.init(); + return jwt; + } + + private JwtAuthFilter newFilter() { + JwtAuthFilter filter = new JwtAuthFilter(); + ReflectionTestUtils.setField(filter, "jwtUtils", newJwtUtils()); + ReflectionTestUtils.setField(filter, "activeSessionRegistry", sessions); + ReflectionTestUtils.setField(filter, "objectMapper", objectMapper); + return filter; + } + + private MockHttpServletRequest request(String token) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/data/load"); + request.setServletPath("/data/load"); + request.addHeader("Authorization", "Bearer " + token); + return request; + } + + @Test + void 重建签名校验器和过滤器后原Token仍然通过校验() throws Exception { + String token = newJwtUtils().generateToken("user", "G3HD", "session-a"); + sessions.activate("G3HD", "user", "session-a"); + sessions = new ActiveSessionRegistry(); + FilterChain chain = mock(FilterChain.class); + var request = request(token); + var response = new MockHttpServletResponse(); + doAnswer(invocation -> { + assertThat(OrgContext.requireOrgId()).isEqualTo("G3HD"); + assertThat(OrgContext.requireUserId()).isEqualTo("user"); + return null; + }).when(chain).doFilter(request, response); + + newFilter().doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + assertThat(response.getContentAsString()).isEmpty(); + assertThat(OrgContext.getOrgId()).isNull(); + assertThat(sessions.isActive("G3HD", "user", "session-a")).isTrue(); + assertThat(sessions.isActive("G3HD", "user", "session-other")).isFalse(); + } + + @Test + void 后登录替换会话后旧Token返回互踢错误() throws Exception { + String token = newJwtUtils().generateToken("user", "G3HD", "session-a"); + sessions.activate("G3HD", "user", "session-b"); + var request = request(token); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + newFilter().doFilter(request, response, chain); + + assertThat(objectMapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(40101); + verify(chain, never()).doFilter(request, response); + } + + @Test + void 无效签名不能恢复内存会话() throws Exception { + String invalid = Jwts.builder().subject("user").id("session-a").claim("orgid", "G3HD") + .expiration(Date.from(Instant.now().plusSeconds(60))) + .signWith(Keys.hmacShaKeyFor((SECRET + "-wrong").getBytes(StandardCharsets.UTF_8))).compact(); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + newFilter().doFilter(request(invalid), response, chain); + assertThat(objectMapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(401); + verifyNoInteractions(chain); + assertThat(sessions.isActive("G3HD", "user", "valid-session")).isTrue(); + } + + @Test + void 过期Token不因重启恢复会话而延长有效期() throws Exception { + String expired = Jwts.builder().subject("user").id("session-a").claim("orgid", "G3HD") + .expiration(Date.from(Instant.now().minusSeconds(60))) + .signWith(Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8))).compact(); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + newFilter().doFilter(request(expired), response, chain); + assertThat(objectMapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(401); + verifyNoInteractions(chain); + assertThat(sessions.isActive("G3HD", "user", "valid-session")).isTrue(); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDataSourceFactoryTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDataSourceFactoryTests.java new file mode 100644 index 00000000..0cf8f3a1 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDataSourceFactoryTests.java @@ -0,0 +1,42 @@ +package cn.g3soft.erp.database.org; + +import com.alibaba.druid.pool.DruidDataSource; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 机构连接池的失败策略:**建连失败后必须继续重试,绝不能把池子废掉**。 + * + *

下面几项与 {@link OrgDataSourceFactory} 里的取舍一一对应,改之前先读那边的注释: + * {@code breakAfterAcquireFailure=true} 会让 Druid 的建连线程永久退出,池子再也建不出连接、 + * 只能重启进程;{@code failFast=true} 则会把第一次建连错误缓存下来一直抛,即使网络早已恢复。 + * + *

局限:这里只钉住配置值,验不了「池子能否自愈」——那需要真实数据库。 + */ +class OrgDataSourceFactoryTests { + + @Test + @DisplayName("建连失败后继续重试,不停掉建连线程、不缓存失败") + void connectionFailureKeepsPoolRetryingInsteadOfBreakingIt() { + OrgDatabaseProperties properties = new OrgDatabaseProperties(); + OrgDataSourceFactory factory = new OrgDataSourceFactory(); + ReflectionTestUtils.setField(factory, "properties", properties); + + OrgDatabaseConfig config = new OrgDatabaseConfig( + "jdbc:sqlserver://127.0.0.1:1433;databaseName=test", + "test", + "test", + "com.microsoft.sqlserver.jdbc.SQLServerDriver" + ); + + try (DruidDataSource dataSource = factory.create("TEST", config)) { + assertThat(dataSource.getConnectionErrorRetryAttempts()).isEqualTo(1); + assertThat(dataSource.isBreakAfterAcquireFailure()).isFalse(); + assertThat(dataSource.isFailFast()).isFalse(); + assertThat(dataSource.getTimeBetweenConnectErrorMillis()).isEqualTo(2_000L); + } + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDataSourceManagerTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDataSourceManagerTests.java new file mode 100644 index 00000000..8435ecbf --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDataSourceManagerTests.java @@ -0,0 +1,175 @@ +package cn.g3soft.erp.database.org; + +import cn.g3soft.erp.common.exception.AuthenticationException; +import com.alibaba.druid.pool.DruidDataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrgDataSourceManagerTests { + + @TempDir + Path configDir; + + private String failPassword; + + @Test + void unreachableNewConfigKeepsOldPool() throws Exception { + OrgDataSourceManager manager = newManager(Duration.ofSeconds(30), Duration.ofMillis(200)); + try { + writeConfig("old-password"); + DruidDataSource first = manager.getDataSource("TEST"); + + failPassword = "unreachable-password"; + writeConfig("unreachable-password"); + bumpMtime(); + manager.onConfigChanged("TEST"); + + // 新配置建池失败(fail-fast):保留旧池继续服务,等下次再试 + assertThat(manager.getDataSource("TEST")).isSameAs(first); + assertThat(first.isClosed()).isFalse(); + } finally { + manager.closeAll(); + } + } + + @Test + void configChangeSwitchesToNewDataSource() throws Exception { + OrgDataSourceManager manager = newManager(Duration.ofMillis(50), Duration.ofMillis(200)); + try { + writeConfig("old-password"); + DruidDataSource first = manager.getDataSource("TEST"); + assertThat(manager.getDataSource("TEST")).isSameAs(first); + + writeConfig("new-password"); + bumpMtime(); + + Thread.sleep(150); + DruidDataSource second = manager.getDataSource("TEST"); + assertThat(second).isNotSameAs(first); + assertThat(second.getUsername()).isEqualTo("test"); + + // 旧池延迟关闭 + Thread.sleep(400); + assertThat(first.isClosed()).isTrue(); + assertThat(second.isClosed()).isFalse(); + } finally { + manager.closeAll(); + } + } + + @Test + void untouchedMtimeReusesPool() throws Exception { + OrgDataSourceManager manager = newManager(Duration.ofMillis(50), Duration.ofMillis(200)); + try { + writeConfig("password"); + DruidDataSource first = manager.getDataSource("TEST"); + + Thread.sleep(150); + assertThat(manager.getDataSource("TEST")).isSameAs(first); + assertThat(first.isClosed()).isFalse(); + } finally { + manager.closeAll(); + } + } + + @Test + void missingConfigFileEvictsPool() throws Exception { + OrgDataSourceManager manager = newManager(Duration.ofMillis(50), Duration.ofMillis(200)); + try { + writeConfig("password"); + DruidDataSource first = manager.getDataSource("TEST"); + + Files.delete(configDir.resolve("TEST.properties")); + Thread.sleep(150); + + assertThatThrownBy(() -> manager.getDataSource("TEST")) + .isInstanceOf(AuthenticationException.class); + assertThat(manager.getDataSourceCount()).isZero(); + } finally { + manager.closeAll(); + } + } + + @Test + void configChangeEventSwitchesPoolImmediately() throws Exception { + OrgDataSourceManager manager = newManager(Duration.ofSeconds(30), Duration.ofMillis(200)); + try { + writeConfig("old-password"); + DruidDataSource first = manager.getDataSource("TEST"); + + writeConfig("new-password"); + bumpMtime(); + manager.onConfigChanged("test"); // 模拟监听线程的事件回调(小写验证归一化) + + DruidDataSource second = manager.getDataSource("TEST"); + assertThat(second).isNotSameAs(first); + assertThat(first.isClosed()).isFalse(); // 延迟关闭,在途请求仍可用 + } finally { + manager.closeAll(); + } + } + + private OrgDataSourceManager newManager(Duration refreshInterval, Duration closeDelay) { + OrgDatabaseProperties properties = new OrgDatabaseProperties(); + properties.setConfigDir(configDir); + properties.setRefreshCheckInterval(refreshInterval); + properties.setRetiredPoolCloseDelay(closeDelay); + + OrgDatabaseConfigLoader loader = new OrgDatabaseConfigLoader(); + ReflectionTestUtils.setField(loader, "properties", properties); + loader.init(); + + OrgDataSourceFactory factory = new OrgDataSourceFactory() { + @Override + public DruidDataSource create(String orgId, OrgDatabaseConfig config) { + if (config.password().equals(failPassword)) { + throw new IllegalStateException("模拟新库不可达"); + } + // 测试不真实连接数据库(initialSize=0 时 init 不建物理连接) + DruidDataSource dataSource = new DruidDataSource(); + dataSource.setName(orgId); + dataSource.setUrl(config.url()); + dataSource.setUsername(config.username()); + dataSource.setDriverClassName(config.driver()); + try { + dataSource.init(); + } catch (SQLException exception) { + throw new IllegalStateException(exception); + } + return dataSource; + } + }; + + OrgDataSourceManager manager = new OrgDataSourceManager(); + ReflectionTestUtils.setField(manager, "configLoader", loader); + ReflectionTestUtils.setField(manager, "dataSourceFactory", factory); + ReflectionTestUtils.setField(manager, "properties", properties); + return manager; + } + + private void writeConfig(String password) throws Exception { + Files.writeString(configDir.resolve("TEST.properties"), """ + url=jdbc:sqlserver://127.0.0.1:1433;databaseName=test + username=test + password=%s + """.formatted(password)); + } + + private void bumpMtime() throws Exception { + Files.setLastModifiedTime( + configDir.resolve("TEST.properties"), + FileTime.from(Instant.now().plusSeconds(60)) + ); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoaderTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoaderTests.java new file mode 100644 index 00000000..929d7b05 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDatabaseConfigLoaderTests.java @@ -0,0 +1,33 @@ +package cn.g3soft.erp.database.org; + +import cn.g3soft.erp.common.exception.AuthenticationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrgDatabaseConfigLoaderTests { + + @TempDir + Path configDir; + + @Test + void missingConfigFileIsReportedAsInvalidOrgId() { + OrgDatabaseProperties properties = new OrgDatabaseProperties(); + properties.setConfigDir(configDir); + + OrgDatabaseConfigLoader loader = new OrgDatabaseConfigLoader(); + ReflectionTestUtils.setField(loader, "properties", properties); + loader.init(); + + AuthenticationException exception = assertThrows( + AuthenticationException.class, + () -> loader.load("UNKNOWN") + ); + assertEquals("机构码错误", exception.getMessage()); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDatabaseConfigWatcherTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDatabaseConfigWatcherTests.java new file mode 100644 index 00000000..c0c4df2d --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/database/org/OrgDatabaseConfigWatcherTests.java @@ -0,0 +1,138 @@ +package cn.g3soft.erp.database.org; + +import cn.g3soft.erp.common.exception.AuthenticationException; +import com.alibaba.druid.pool.DruidDataSource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrgDatabaseConfigWatcherTests { + + private static final long AWAIT_MILLIS = 5000; + + @TempDir + Path configDir; + + private OrgDataSourceManager manager; + private OrgDatabaseConfigWatcher watcher; + + @Test + void fileChangeTriggersPoolSwitchWithoutPolling() throws Exception { + start(Duration.ofMinutes(10)); + try { + writeConfig("TEST", "old-password"); + DruidDataSource first = manager.getDataSource("TEST"); + + writeConfig("TEST", "new-password"); + bumpMtime("TEST"); + + DruidDataSource current = first; + long deadline = System.currentTimeMillis() + AWAIT_MILLIS; + while (current == first && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + current = manager.getDataSource("TEST"); + } + assertThat(current).isNotSameAs(first); + assertThat(current.getUsername()).isEqualTo("test"); + } finally { + stop(); + } + } + + @Test + void renamingOrgCodeEvictsOldOrgAndServesNewOne() throws Exception { + start(Duration.ofMinutes(10)); + try { + writeConfig("G3HD", "old-password"); + manager.getDataSource("G3HD"); + + Files.move(configDir.resolve("G3HD.properties"), configDir.resolve("NEWORG.properties")); + + // 删除事件应淘汰旧机构码的池(而不是像改造前一样悄悄继续用旧库) + long deadline = System.currentTimeMillis() + AWAIT_MILLIS; + while (manager.getDataSourceCount() > 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(100); + } + assertThat(manager.getDataSourceCount()).isZero(); + assertThatThrownBy(() -> manager.getDataSource("G3HD")) + .isInstanceOf(AuthenticationException.class); + + // 新机构码走懒加载,正常建池 + assertThat(manager.getDataSource("NEWORG")).isNotNull(); + } finally { + stop(); + } + } + + private void start(Duration refreshInterval) { + OrgDatabaseProperties properties = new OrgDatabaseProperties(); + properties.setConfigDir(configDir); + properties.setRefreshCheckInterval(refreshInterval); + properties.setRetiredPoolCloseDelay(Duration.ofMillis(200)); + + OrgDatabaseConfigLoader loader = new OrgDatabaseConfigLoader(); + ReflectionTestUtils.setField(loader, "properties", properties); + loader.init(); + + OrgDataSourceFactory factory = new OrgDataSourceFactory() { + @Override + public DruidDataSource create(String orgId, OrgDatabaseConfig config) { + DruidDataSource dataSource = new DruidDataSource(); + dataSource.setName(orgId); + dataSource.setUrl(config.url()); + dataSource.setUsername(config.username()); + dataSource.setDriverClassName(config.driver()); + try { + dataSource.init(); + } catch (SQLException exception) { + throw new IllegalStateException(exception); + } + return dataSource; + } + }; + + manager = new OrgDataSourceManager(); + ReflectionTestUtils.setField(manager, "configLoader", loader); + ReflectionTestUtils.setField(manager, "dataSourceFactory", factory); + ReflectionTestUtils.setField(manager, "properties", properties); + + watcher = new OrgDatabaseConfigWatcher(); + ReflectionTestUtils.setField(watcher, "configLoader", loader); + ReflectionTestUtils.setField(watcher, "dataSourceManager", manager); + watcher.start(); + } + + private void stop() { + if (watcher != null) { + watcher.stop(); + } + if (manager != null) { + manager.closeAll(); + } + } + + private void writeConfig(String orgId, String password) throws Exception { + Files.writeString(configDir.resolve(orgId + ".properties"), """ + url=jdbc:sqlserver://127.0.0.1:1433;databaseName=test + username=test + password=%s + """.formatted(password)); + } + + private void bumpMtime(String orgId) throws Exception { + Files.setLastModifiedTime( + configDir.resolve(orgId + ".properties"), + FileTime.from(Instant.now().plusSeconds(60)) + ); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/auth/ActiveSessionRegistryTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/auth/ActiveSessionRegistryTests.java new file mode 100644 index 00000000..0c2b7916 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/auth/ActiveSessionRegistryTests.java @@ -0,0 +1,74 @@ +package cn.g3soft.erp.service.auth; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class ActiveSessionRegistryTests { + + private final ActiveSessionRegistry registry = new ActiveSessionRegistry(); + + @Test + void newerLoginReplacesPreviousSessionForSameAccount() { + registry.activate("G3HD", "USER_A", "session-a"); + registry.activate("G3HD", "USER_A", "session-b"); + + assertThat(registry.isActive("G3HD", "USER_A", "session-a")).isFalse(); + assertThat(registry.isActive("G3HD", "USER_A", "session-b")).isTrue(); + } + + @Test + void sameUserIdInDifferentOrganizationsHasIndependentSessions() { + registry.activate("G3HD", "USER_A", "g3hd-session"); + registry.activate("OTHER", "USER_A", "other-session"); + + assertThat(registry.isActive("G3HD", "USER_A", "g3hd-session")).isTrue(); + assertThat(registry.isActive("OTHER", "USER_A", "other-session")).isTrue(); + } + + @Test + void 重启后首次有效请求恢复会话而新登录仍能顶掉它() { + registry.activate("G3HD", "USER_A", "session-a"); + ActiveSessionRegistry restarted = new ActiveSessionRegistry(); + + assertThat(restarted.isActive("G3HD", "USER_A", "session-a")).isTrue(); + assertThat(restarted.isActive("G3HD", "USER_A", "session-a")).isTrue(); + restarted.activate("G3HD", "USER_A", "session-b"); + assertThat(restarted.isActive("G3HD", "USER_A", "session-a")).isFalse(); + assertThat(restarted.isActive("G3HD", "USER_A", "session-b")).isTrue(); + } + + @Test + void 空会话不能占用重启后的账号() { + assertThat(registry.isActive("G3HD", "USER_A", null)).isFalse(); + assertThat(registry.isActive("G3HD", "USER_A", " ")).isFalse(); + assertThat(registry.isActive("G3HD", "USER_A", "valid-session")).isTrue(); + } + + @Test + void 并发首次恢复只有一个不同会话能成功() throws Exception { + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> { + start.await(); + return registry.isActive("G3HD", "USER_A", "session-a"); + }); + var second = executor.submit(() -> { + start.await(); + return registry.isActive("G3HD", "USER_A", "session-b"); + }); + start.countDown(); + boolean firstAccepted = first.get(5, TimeUnit.SECONDS); + boolean secondAccepted = second.get(5, TimeUnit.SECONDS); + assertThat(firstAccepted).isNotEqualTo(secondAccepted); + String winner = firstAccepted ? "session-a" : "session-b"; + String loser = firstAccepted ? "session-b" : "session-a"; + assertThat(registry.isActive("G3HD", "USER_A", winner)).isTrue(); + assertThat(registry.isActive("G3HD", "USER_A", loser)).isFalse(); + } + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/auth/AuthServiceTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/auth/AuthServiceTests.java new file mode 100644 index 00000000..a41f75d0 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/auth/AuthServiceTests.java @@ -0,0 +1,68 @@ +package cn.g3soft.erp.service.auth; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AuthServiceTests { + + private UserAccountReader userAccountReader; + private JwtUtils jwtUtils; + private ActiveSessionRegistry activeSessionRegistry; + private LoginLogService loginLogService; + private AuthService authService; + + @BeforeEach + void setUp() { + userAccountReader = mock(UserAccountReader.class); + jwtUtils = mock(JwtUtils.class); + activeSessionRegistry = new ActiveSessionRegistry(); + loginLogService = mock(LoginLogService.class); + authService = new AuthService(); + + ReflectionTestUtils.setField(authService, "userAccountReader", userAccountReader); + ReflectionTestUtils.setField(authService, "jwtUtils", jwtUtils); + ReflectionTestUtils.setField( + authService, + "activeSessionRegistry", + activeSessionRegistry + ); + ReflectionTestUtils.setField(authService, "loginLogService", loginLogService); + } + + @Test + void hiddenSuperAdministratorCanLoginWithEmptyPassword() throws Exception { + when(userAccountReader.findActiveAccount( + "FMS", + "g3soft" + )).thenReturn(List.of(Map.of( + "b_id", "g3soft", + "b_name", "G3Soft", + "b_password", "" + ))); + when(jwtUtils.generateToken( + eq("g3soft"), + eq("FMS"), + org.mockito.ArgumentMatchers.anyString() + )) + .thenReturn("token"); + + Optional> result = authService.login("fms", "g3soft", "", null); + + assertThat(result).isPresent(); + assertThat(result.orElseThrow()).containsEntry("token", "token"); + Map user = (Map) result.orElseThrow().get("user"); + assertThat(user.get("id")).isEqualTo("g3soft"); + assertThat(user.get("account")).isEqualTo("g3soft"); + assertThat(user.get("name")).isEqualTo("G3Soft"); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/auth/JwtUtilsTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/auth/JwtUtilsTests.java new file mode 100644 index 00000000..8927c7e3 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/auth/JwtUtilsTests.java @@ -0,0 +1,37 @@ +package cn.g3soft.erp.service.auth; + +import cn.g3soft.erp.config.AuthProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtUtilsTests { + + private JwtUtils jwtUtils; + + @BeforeEach + void setUp() { + AuthProperties properties = new AuthProperties(); + properties.setJwtSecret("test-jwt-secret-with-at-least-32-bytes"); + properties.setJwtExpiration(Duration.ofHours(1)); + + jwtUtils = new JwtUtils(); + ReflectionTestUtils.setField(jwtUtils, "properties", properties); + jwtUtils.init(); + } + + @Test + void tokenContainsAccountAndSessionClaims() { + String token = jwtUtils.generateToken("USER_A", "G3HD", "session-a"); + + JwtUtils.TokenClaims claims = jwtUtils.parseToken(token); + + assertThat(claims.userId()).isEqualTo("USER_A"); + assertThat(claims.orgId()).isEqualTo("G3HD"); + assertThat(claims.sessionId()).isEqualTo("session-a"); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/export/ExportServiceIntegrationTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/export/ExportServiceIntegrationTests.java new file mode 100644 index 00000000..5af1d93c --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/export/ExportServiceIntegrationTests.java @@ -0,0 +1,350 @@ +package cn.g3soft.erp.service.export; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.database.org.OrgContext; +import jakarta.annotation.Resource; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.sql.PreparedStatement; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportColumn; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportNode; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportSchema; +import cn.g3soft.erp.service.permission.SqlPermissionService; + +/** + * 导出端到端(连真实库,只读)。验证「元数据 → SQL → POI 流 → 可解析的 xlsx」整条链路, + * 以及校验失败发生在写流之前(还能返回 JSON 而不是半个损坏文件)。 + * + * 用管理员账号跑,动作权限提权、字段权限与数据范围表为空即无过滤,因此本用例只验证链路本身; + * 权限裁剪的规则由 ExportServiceTests 与 SqlPermissionService 的判定逻辑覆盖。 + */ +@SpringBootTest +class ExportServiceIntegrationTests { + + /** 探针确认过有数据的模块:箱型管理,视图 b_con_type,主键 b_id */ + private static final String MODULE_WITH_DATA = "b_con_type"; + + @Resource + private ExportService exportService; + + @Resource + private DataSource dataSource; + + @Test + void 导出真实模块得到可解析的xlsx() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + MockHttpServletResponse response = new MockHttpServletResponse(); + exportService.export( + new ExportService.ExportRequest( + MODULE_WITH_DATA, null, null, null, null, null, null, null, null, true + ), + response + ); + + assertThat(response.getContentType()) + .contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + assertThat(response.getHeader("Content-Disposition")) + .contains("attachment") + .contains("filename*=UTF-8''"); + + byte[] bytes = response.getContentAsByteArray(); + assertThat(bytes).isNotEmpty(); + + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) { + Sheet sheet = workbook.getSheetAt(0); + assertThat(workbook.getNumberOfSheets()).isEqualTo(1); + assertThat(sheet.getSheetName()).isNotBlank(); + + // 该模块未配分组表头,第一行即列名;至少一列 + assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isNotBlank(); + // 不传区间时后端不加 OFFSET/FETCH,导出的是当前条件下的**全部行**(不是当前页) + assertThat(sheet.getLastRowNum()).isEqualTo(rowCountOfModule()); + assertThat(sheet.getRow(1).getCell(0)).isNotNull(); + } + } finally { + OrgContext.clear(); + } + } + + @Test + void 勾选行导出只包含选中的行() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + String firstKey = firstKeyOfModule(); + MockHttpServletResponse response = new MockHttpServletResponse(); + exportService.export( + new ExportService.ExportRequest( + MODULE_WITH_DATA, null, null, java.util.List.of(firstKey), "b_id", + null, null, null, null, true + ), + response + ); + + try (Workbook workbook = new XSSFWorkbook( + new ByteArrayInputStream(response.getContentAsByteArray()))) { + Sheet sheet = workbook.getSheetAt(0); + // 单行表头 + 1 行数据 + assertThat(sheet.getLastRowNum()).isEqualTo(1); + } + } finally { + OrgContext.clear(); + } + } + + @Test + void 模块不存在时在写流之前报错() { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + MockHttpServletResponse response = new MockHttpServletResponse(); + assertThatThrownBy(() -> exportService.export( + new ExportService.ExportRequest( + "不存在的模块", null, null, null, null, null, null, null, null, true + ), + response + )).isInstanceOf(BusinessException.class) + .hasMessageContaining("模块"); + // 关键:报错时响应体仍是空的,前端能拿到 JSON 错误而不是损坏的 xlsx + assertThat(response.getContentAsByteArray()).isEmpty(); + } finally { + OrgContext.clear(); + } + } + + @Test + void 计数与导出走同一权限链路() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + long total = exportService.count(request(null)); + assertThat(total).isEqualTo(rowCountOfModule()); + + // 条件同样参与计数(b_id 非空即全部) + assertThat(exportService.count(request("b_id IS NOT NULL"))).isEqualTo(total); + // 匹配不到任何行的条件返回 0,而不是报错。 + // 注意 b_con_type.b_id 是 bigint(新系统业务表用雪花 ID),不能拿字符串字面量去比 + assertThat(exportService.count(request("b_id = -1"))).isZero(); + } finally { + OrgContext.clear(); + } + } + + @Test + void 分卷导出返回zip且每卷行数正确() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + int total = rowCountOfModule(); + int volumeSize = 3; + MockHttpServletResponse response = new MockHttpServletResponse(); + exportService.export(volumeRequest(volumeSize), response); + + assertThat(response.getContentType()).isEqualTo("application/zip"); + assertThat(response.getHeader("Content-Disposition")).contains(".zip"); + + Map rowsByEntry = new LinkedHashMap<>(); + try (ZipInputStream zip = new ZipInputStream( + new ByteArrayInputStream(response.getContentAsByteArray()), StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + byte[] content = zip.readAllBytes(); + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) { + // 表头 1 行,其余为数据行 + rowsByEntry.put(entry.getName(), workbook.getSheetAt(0).getLastRowNum()); + } + } + } + + int expectedVolumes = (total + volumeSize - 1) / volumeSize; + assertThat(rowsByEntry).hasSize(expectedVolumes); + // 每卷行数:前 n-1 卷是 volumeSize,最后一卷是余数 + List expectedRowsPerVolume = new ArrayList<>(); + for (long remaining = total; remaining > 0; remaining -= volumeSize) { + expectedRowsPerVolume.add((int) Math.min(volumeSize, remaining)); + } + assertThat(rowsByEntry.values()).containsExactlyElementsOf(expectedRowsPerVolume); + } finally { + OrgContext.clear(); + } + } + + @Test + void 按行范围聚合的合计SQL在真实库上可执行且数值正确() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + // 「当前页 / 区间 / 单卷」的合计都靠这段 SQL:字符串对不代表能在 SQL Server 上跑通 + String sql = ExportService.buildTotalsSql( + MODULE_WITH_DATA, + List.of(new ExportSchemaService.ExportColumn("b_id", "ID", 16, "sum")), + null, + "[b_id] ASC" + ); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, 1); + statement.setLong(2, 3); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + BigDecimal ranged = resultSet.getBigDecimal(1); + // 期望值:同一视图按同一排序取前 3 行直接求和 + try (Statement plain = connection.createStatement(); + ResultSet expected = plain.executeQuery( + "SELECT SUM([b_id]) FROM (SELECT TOP 3 [b_id] FROM [b_con_type] " + + "ORDER BY [b_id] ASC) AS [expected_source]")) { + expected.next(); + assertThat(ranged).isEqualByComparingTo(expected.getBigDecimal(1)); + } + } + } + } finally { + OrgContext.clear(); + } + } + + @Test + void 当页区间勾选及各分卷合计与文件内明细一致() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + // 只替换内存里的导出配置,真实 SQL Server 查询与工作簿写出照常执行,不改数据库配置。 + ExportSchemaService.ExportSchema schema = new ExportSchemaService.ExportSchema( + MODULE_WITH_DATA, "合计回归", MODULE_WITH_DATA, "b_id", "b_xh ASC", + List.of( + new ExportSchemaService.ExportNode(null, + new ExportSchemaService.ExportColumn("b_name", "名称", 16, ""), null), + new ExportSchemaService.ExportNode(null, + new ExportSchemaService.ExportColumn("b_xh", "序号求和", 16, "sum"), null), + new ExportSchemaService.ExportNode(null, + new ExportSchemaService.ExportColumn("b_id", "记录计数", 16, "count"), null))); + ExportSchemaService schemas = mock(ExportSchemaService.class); + when(schemas.load(MODULE_WITH_DATA)).thenReturn(schema); + when(schemas.filter(any(), any(), any())).thenCallRealMethod(); + ExportService service = new ExportService(); + ReflectionTestUtils.setField(service, "dataSource", dataSource); + ReflectionTestUtils.setField(service, "sqlPermissionService", mock(SqlPermissionService.class)); + ReflectionTestUtils.setField(service, "exportSchemaService", schemas); + + List keys = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery( + "SELECT TOP 5 [b_id] FROM [b_con_type] ORDER BY [b_id]")) { + while (result.next()) keys.add(result.getObject(1)); + } + assertThat(keys).hasSize(5); + for (Integer[] range : new Integer[][]{{null, null}, {1, 1}, {3, 4}, {5, 5}, {8, 9}}) { + MockHttpServletResponse response = new MockHttpServletResponse(); + service.export(new ExportService.ExportRequest(MODULE_WITH_DATA, "b_id IS NOT NULL", + null, keys, null, null, range[0], range[1], null, true), response); + int expectedRows = range[0] == null ? 5 : Math.max(0, Math.min(5, range[1]) - range[0] + 1); + assertSummaryMatchesDetails(response.getContentAsByteArray(), expectedRows); + } + + MockHttpServletResponse response = new MockHttpServletResponse(); + service.export(new ExportService.ExportRequest(MODULE_WITH_DATA, null, null, keys, + null, null, null, null, 2, true), response); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(response.getContentAsByteArray()))) { + for (int expectedRows : List.of(2, 2, 1)) { + assertThat(zip.getNextEntry()).isNotNull(); + assertSummaryMatchesDetails(zip.readAllBytes(), expectedRows); + } + assertThat(zip.getNextEntry()).isNull(); + } + } finally { + OrgContext.clear(); + } + } + + private void assertSummaryMatchesDetails(byte[] bytes, int expectedRows) throws Exception { + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) { + Sheet sheet = workbook.getSheetAt(0); + assertThat(sheet.getLastRowNum()).isEqualTo(expectedRows + 1); + double sum = 0; + for (int row = 1; row <= expectedRows; row++) { + var cell = sheet.getRow(row).getCell(1); + if (cell != null && cell.getCellType() == org.apache.poi.ss.usermodel.CellType.NUMERIC) { + sum += cell.getNumericCellValue(); + } + } + var summary = sheet.getRow(expectedRows + 1); + assertThat(summary.getCell(0).getStringCellValue()).isEqualTo("合计"); + assertThat(summary.getCell(1).getNumericCellValue()).isEqualTo(sum); + assertThat(summary.getCell(2).getNumericCellValue()).isEqualTo(expectedRows); + } + } + + @Test + void 总合计接口在没有配合计列时返回空() { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + // 目前没有任何模块配 summary,所以结果为空 —— 验证整条链路不会因为「没有可合计列」出错 + assertThat(exportService.totals(request(null))).isEmpty(); + } finally { + OrgContext.clear(); + } + } + + private ExportService.ExportRequest request(String searchCondition) { + return new ExportService.ExportRequest( + MODULE_WITH_DATA, searchCondition, null, null, null, null, null, null, null, true + ); + } + + private ExportService.ExportRequest volumeRequest(int volumeSize) { + return new ExportService.ExportRequest( + MODULE_WITH_DATA, null, null, null, null, null, null, null, volumeSize, true + ); + } + + private String firstKeyOfModule() throws Exception { + // 走应用自己的机构数据源(OrgContext 已在用例内设好),不在测试里写死连接串 + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT TOP 1 [b_id] FROM [b_con_type] ORDER BY [b_id]")) { + resultSet.next(); + return resultSet.getString(1); + } + } + + /** 模块视图的总行数,用于断言「不传区间 = 导出全部行」 */ + private int rowCountOfModule() throws Exception { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT_BIG(1) FROM [b_con_type]")) { + resultSet.next(); + return resultSet.getInt(1); + } + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/export/ExportServiceTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/export/ExportServiceTests.java new file mode 100644 index 00000000..d274cf91 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/export/ExportServiceTests.java @@ -0,0 +1,318 @@ +package cn.g3soft.erp.service.export; + +import cn.g3soft.erp.common.exception.BusinessException; +import cn.g3soft.erp.common.util.SqlText; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportColumn; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportNode; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportSchema; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import cn.g3soft.erp.service.permission.SqlPermissionService; + +/** + * 导出 SQL 拼装与表头结构(纯逻辑,不连库)。 + * 表头 → xlsx 的写出验证见 ExportWorkbookWriterTests,连库端到端见 ExportServiceIntegrationTests。 + */ +class ExportServiceTests { + + private final ExportService service = new ExportService(); + + private static ExportColumn column(String field) { + return new ExportColumn(field, field, 16, ""); + } + + private static ExportNode leaf(String field) { + return new ExportNode(null, column(field), null); + } + + private static ExportSchema schema(String orderSql, List nodes) { + return new ExportSchema("m1", "测试模块", "v_test", "b_id", orderSql, nodes); + } + + private static ExportService.ExportRequest request( + String searchCondition, + String orderBy, + List rowKeys, + String keyField, + Integer rangeStart, + Integer rangeEnd + ) { + return new ExportService.ExportRequest( + "m1", searchCondition, orderBy, rowKeys, keyField, null, rangeStart, rangeEnd, null, true + ); + } + + /* ================= SELECT 拼装 ================= */ + + @Test + void 默认无条件时只拼表名与列() { + ExportSchema schema = schema(null, List.of(leaf("b_name"), leaf("b_bz"))); + String sql = service.buildSelectSql( + schema, + schema.columns(), + null, + null, + request(null, null, null, null, null, null) + ); + + assertThat(sql).isEqualTo("SELECT [b_name], [b_bz] FROM [v_test]"); + } + + @Test + void 条件与排序都参与拼装且标识符被加引号() { + ExportSchema schema = schema("b_xh ASC", List.of(leaf("b_name"))); + // 排序子句由 resolveOrderSql 经 SqlText.buildSafeOrderBy 校验加引号后传入 + String sql = service.buildSelectSql( + schema, + schema.columns(), + "(b_canuse = 1)", + "[b_xh] ASC", + request(null, null, null, null, null, null) + ); + + assertThat(sql).isEqualTo("SELECT [b_name] FROM [v_test] WHERE (b_canuse = 1) ORDER BY [b_xh] ASC"); + } + + @Test + void 区间导出走OFFSET_FETCH且无排序时用常量占位() { + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + String sql = service.buildSelectSql( + schema, + schema.columns(), + null, + null, + request(null, null, null, null, 101, 200) + ); + + assertThat(sql).isEqualTo( + "SELECT [b_name] FROM [v_test] ORDER BY (SELECT NULL) OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" + ); + } + + @Test + void 排序子句非法时被拒绝() { + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + // 排序必须经 resolveOrderSql 校验(导出主流程就是这条路径),不能把原始串直接塞进 SQL + assertThatThrownBy(() -> service.resolveOrderSql(schema, "b_name; drop table x")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveOrderSql(schema, "b_name b_xh")) + .isInstanceOf(BusinessException.class); + } + + @Test + void 排序缺省时回退模块默认排序且加引号() { + ExportSchema schema = schema("b_xh DESC", List.of(leaf("b_name"))); + assertThat(service.resolveOrderSql(schema, null)).isEqualTo("[b_xh] DESC, [b_id] ASC"); + assertThat(service.resolveOrderSql(schema, "b_name ASC")).isEqualTo("[b_name] ASC, [b_id] ASC"); + assertThat(service.resolveOrderSql(schema("", List.of(leaf("b_name"))), null)).isEqualTo("[b_id] ASC"); + } + + @Test + void 排序已含主键时不再追加主键() { + // ORDER BY 同一列出现两次会被 SQL Server 拒绝(v_wf_definition 的 b_order_sql 就是 b_id ASC) + ExportSchema schema = schema("b_id ASC", List.of(leaf("b_name"))); + assertThat(service.resolveOrderSql(schema, null)).isEqualTo("[b_id] ASC"); + assertThat(service.resolveOrderSql(schema, "b_module_id ASC, b_id DESC")).isEqualTo("[b_module_id] ASC, [b_id] DESC"); + } + + @Test + void 不完整或逆序区间不能退化成全部导出() { + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + for (ExportService.ExportRequest request : List.of( + request(null, null, null, null, 3, null), + request(null, null, null, null, null, 4), + request(null, null, null, null, 4, 3))) { + assertThatThrownBy(() -> service.buildSelectSql(schema, schema.columns(), null, null, request)) + .isInstanceOf(BusinessException.class).hasMessageContaining("区间"); + } + } + + @Test + void 导出列采用选择顺序且跨组拖拽后保留各列分组和权限() { + ExportSchema schema = schema(null, List.of( + new ExportNode("A", null, List.of(leaf("b_a"), leaf("b_b"))), + leaf("b_c"), leaf("b_denied"))); + ExportSchema result = new ExportSchemaService().filter(schema, java.util.Set.of("b_denied"), + List.of("b_b", "b_c", "b_a", "b_denied", "b_unknown", "b_b")); + assertThat(result.columns()).extracting(ExportColumn::field).containsExactly("b_b", "b_c", "b_a"); + assertThat(ExportHeadBuilder.headOf(result.nodes())).containsExactly( + List.of("A", "b_b"), List.of("b_c"), List.of("A", "b_a")); + } + + /* ================= WHERE 拼装:数据范围只收窄不放开 ================= */ + + @Test + void 调用方条件与数据范围按AND组合() { + SqlPermissionService permission = mock(SqlPermissionService.class); + when(permission.buildDataScopeCondition("m1", "u001", "export")) + .thenReturn("([b_inputuser_id] = N'u001')"); + ReflectionTestUtils.setField(service, "sqlPermissionService", permission); + + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + String where = service.buildWhereClause( + "m1", + "u001", + schema, + request("b_status = N'正常'", null, null, null, null, null) + ); + + assertThat(where).isEqualTo("(b_status = N'正常') AND ([b_inputuser_id] = N'u001')"); + } + + @Test + void 勾选行用参数占位而不是字面量拼接() { + SqlPermissionService permission = mock(SqlPermissionService.class); + when(permission.buildDataScopeCondition("m1", "u001", "export")).thenReturn(null); + ReflectionTestUtils.setField(service, "sqlPermissionService", permission); + + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + String where = service.buildWhereClause( + "m1", + "u001", + schema, + request(null, null, List.of(1L, 2L, 3L), null, null, null) + ); + + assertThat(where).isEqualTo("[b_id] IN (?, ?, ?)"); + } + + @Test + void 无任何条件时不生成WHERE子句() { + SqlPermissionService permission = mock(SqlPermissionService.class); + when(permission.buildDataScopeCondition("m1", "u001", "export")).thenReturn(null); + ReflectionTestUtils.setField(service, "sqlPermissionService", permission); + + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + assertThat(service.buildWhereClause( + "m1", + "u001", + schema, + request(null, null, null, null, null, null) + )).isNull(); + } + + /* ================= 合计 ================= */ + + @Test + void 只有配了summary的列参与合计() { + List columns = List.of( + new ExportColumn("b_name", "名称", 16, ""), + new ExportColumn("b_amount", "金额", 16, "sum"), + new ExportColumn("b_count", "笔数", 16, "count") + ); + + assertThat(ExportService.summableIndexes(columns)).containsExactly(1, 2); + assertThat(ExportService.buildTotalsSql("v_test", columns, "(b_canuse = 1)", null)) + .isEqualTo("SELECT SUM([b_amount]), COUNT([b_count]) FROM [v_test] WHERE (b_canuse = 1)"); + } + + @Test + void 按行范围聚合时用行号子查询限定范围() { + List columns = List.of( + new ExportColumn("b_name", "名称", 16, ""), + new ExportColumn("b_amount", "金额", 16, "sum") + ); + + assertThat(ExportService.buildTotalsSql("v_test", columns, "(b_canuse = 1)", "[b_xh] DESC")) + .isEqualTo("SELECT SUM([b_amount]) FROM (" + + "SELECT [b_amount], ROW_NUMBER() OVER (ORDER BY [b_xh] DESC) AS [__erp_row_number] " + + "FROM [v_test] WHERE (b_canuse = 1)" + + ") AS [totals_source] WHERE [totals_source].[__erp_row_number] BETWEEN ? AND ?"); + } + + @Test + void 无排序配置时按行范围聚合用常量排序占位() { + List columns = List.of(new ExportColumn("b_amount", "金额", 16, "sum")); + + assertThat(ExportService.buildTotalsSql("v_test", columns, null, "(SELECT NULL)")) + .isEqualTo("SELECT SUM([b_amount]) FROM (" + + "SELECT [b_amount], ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS [__erp_row_number] " + + "FROM [v_test]) AS [totals_source] " + + "WHERE [totals_source].[__erp_row_number] BETWEEN ? AND ?"); + } + + @Test + void 合计SQL能通过只读校验() { + // 行号子查询里出现了 ROW_NUMBER / AS,必须仍被 validateReadOnlySql 接受 + ExportService service = new ExportService(); + List columns = List.of(new ExportColumn("b_amount", "金额", 16, "sum")); + String sql = ExportService.buildTotalsSql("v_test", columns, "(b_canuse = 1)", "[b_xh] ASC"); + + assertThatCode(() -> SqlText.validateReadOnlySql(sql)).doesNotThrowAnyException(); + } + + @Test + void 没有可合计列时不生成合计SQL() { + List columns = List.of(column("b_name")); + assertThat(ExportService.summableIndexes(columns)).isEmpty(); + } + + /* ================= 表头结构(转成 Fesod 的 List>) ================= */ + + @Test + void 单行表头每列只有一个标题() { + List nodes = List.of(leaf("b_name"), leaf("b_bz")); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("b_name"), + List.of("b_bz") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(1); + assertThat(ExportHeadBuilder.leafCount(nodes)).isEqualTo(2); + } + + @Test + void 两级表头按分组路径展开且列名在最后() { + ExportNode groupA = new ExportNode("基本信息", null, List.of(leaf("b_name"), leaf("b_bz"))); + ExportNode groupB = new ExportNode("其他", null, List.of(leaf("b_xh"))); + List nodes = List.of(groupA, groupB); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("基本信息", "b_name"), + List.of("基本信息", "b_bz"), + List.of("其他", "b_xh") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(2); + assertThat(ExportHeadBuilder.leafCount(nodes)).isEqualTo(3); + } + + @Test + void 顶层叶子与分组混排时叶子路径更短() { + // 各列内层长度不同是允许的:Fesod 会底部对齐补空行,列名仍落在最后一行 + ExportNode groupA = new ExportNode("基本信息", null, List.of(leaf("b_name"), leaf("b_bz"))); + List nodes = List.of(groupA, leaf("b_xh")); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("基本信息", "b_name"), + List.of("基本信息", "b_bz"), + List.of("b_xh") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(2); + } + + @Test + void 三层表头路径完整且分组标题可为空() { + ExportNode inner = new ExportNode("明细", null, List.of(leaf("b_a"), leaf("b_b"))); + ExportNode outer = new ExportNode("费用", null, List.of(inner, leaf("b_c"))); + ExportNode untitled = new ExportNode(null, null, List.of(leaf("b_e"))); + List nodes = List.of(outer, leaf("b_d"), untitled); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("费用", "明细", "b_a"), + List.of("费用", "明细", "b_b"), + List.of("费用", "b_c"), + List.of("b_d"), + List.of("", "b_e") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(3); + assertThat(ExportHeadBuilder.leafCount(nodes)).isEqualTo(5); + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/export/ExportWorkbookWriterTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/export/ExportWorkbookWriterTests.java new file mode 100644 index 00000000..2609b20d --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/export/ExportWorkbookWriterTests.java @@ -0,0 +1,211 @@ +package cn.g3soft.erp.service.export; + +import cn.g3soft.erp.service.export.ExportSchemaService.ExportColumn; +import cn.g3soft.erp.service.export.ExportSchemaService.ExportNode; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 表头 + 明细 + 合计 → xlsx 的写出验证(Apache Fesod,不连库)。 + * + * 这里覆盖的是最容易出错、又没法用真实模块验证的部分:**分组表头与顶层叶子混排**时, + * 各列的标题层数不同,必须仍然「列名落在表头最后一行、分组标题横向合并」。 + * 库里 71 份 view schema 都没有配过分组,所以这条路径只能靠本用例守住。 + */ +class ExportWorkbookWriterTests { + + private static ExportColumn column(String field, int width) { + return new ExportColumn(field, field, width, ""); + } + + private static ExportNode leaf(String field) { + return new ExportNode(null, column(field, 12), null); + } + + private static ExportWorkbookWriter.RowSource rowsOf(List> rows) { + var iterator = rows.iterator(); + return new ExportWorkbookWriter.RowSource() { + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public List next() { + return iterator.next(); + } + }; + } + + private static Workbook write( + List nodes, + List columns, + List> rows, + List totals + ) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ExportWorkbookWriter.write(out, "测试表", nodes, columns, rowsOf(rows), totals); + return new XSSFWorkbook(new ByteArrayInputStream(out.toByteArray())); + } + + private static String text(Sheet sheet, int rowIndex, int columnIndex) { + Row row = sheet.getRow(rowIndex); + if (row == null || row.getCell(columnIndex) == null) { + return null; + } + return row.getCell(columnIndex).getStringCellValue(); + } + + @Test + void 单行表头加明细与合计行() throws Exception { + List columns = List.of(column("b_name", 12), column("b_amount", 20)); + List nodes = List.of(leaf("b_name"), leaf("b_amount")); + List totals = new ArrayList<>(); + totals.add(null); + totals.add(new BigDecimal("300.50")); + + try (Workbook workbook = write( + nodes, + columns, + List.of(List.of("甲", 100.5), List.of("乙", 200)), + totals + )) { + assertThat(workbook.getSheetName(0)).isEqualTo("测试表"); + Sheet sheet = workbook.getSheetAt(0); + + // 表头 1 行 + 明细 2 行 + 合计 1 行 + assertThat(sheet.getLastRowNum()).isEqualTo(3); + assertThat(text(sheet, 0, 0)).isEqualTo("b_name"); + assertThat(text(sheet, 0, 1)).isEqualTo("b_amount"); + assertThat(text(sheet, 1, 0)).isEqualTo("甲"); + assertThat(sheet.getRow(1).getCell(1).getNumericCellValue()).isEqualTo(100.5); + // 合计行:首列是「合计」标签,数值列是聚合结果 + assertThat(text(sheet, 3, 0)).isEqualTo("合计"); + assertThat(sheet.getRow(3).getCell(1).getNumericCellValue()).isEqualTo(300.50); + } + } + + @Test + void 分组表头横向合并且列名落在最后一行() throws Exception { + List columns = List.of( + column("b_name", 12), + column("b_bz", 12), + column("b_xh", 8) + ); + ExportNode group = new ExportNode("基本信息", null, List.of(leaf("b_name"), leaf("b_bz"))); + List nodes = List.of(group, leaf("b_xh")); + + try (Workbook workbook = write( + nodes, + columns, + List.of(List.of("甲", "备注一", 1)), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + + // 两行表头 + 一行明细 + assertThat(sheet.getLastRowNum()).isEqualTo(2); + assertThat(text(sheet, 0, 0)).isEqualTo("基本信息"); + // 列名一律落在表头最后一行:分组下的两列与顶层叶子都一样 + assertThat(text(sheet, 1, 0)).isEqualTo("b_name"); + assertThat(text(sheet, 1, 1)).isEqualTo("b_bz"); + assertThat(text(sheet, 1, 2)).isEqualTo("b_xh"); + // 分组标题横向合并跨两列 + assertThat(sheet.getMergedRegions()).contains(new CellRangeAddress(0, 0, 0, 1)); + } + } + + @Test + void 三层表头分组逐级合并() throws Exception { + List columns = List.of( + column("b_a", 12), + column("b_b", 12), + column("b_c", 12), + column("b_d", 12) + ); + ExportNode inner = new ExportNode("明细", null, List.of(leaf("b_a"), leaf("b_b"))); + ExportNode outer = new ExportNode("费用", null, List.of(inner, leaf("b_c"))); + List nodes = List.of(outer, leaf("b_d")); + + try (Workbook workbook = write( + nodes, + columns, + List.of(List.of(1, 2, 3, 4)), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + + assertThat(sheet.getLastRowNum()).isEqualTo(3); + assertThat(text(sheet, 0, 0)).isEqualTo("费用"); + assertThat(text(sheet, 1, 0)).isEqualTo("明细"); + assertThat(text(sheet, 2, 0)).isEqualTo("b_a"); + assertThat(text(sheet, 2, 1)).isEqualTo("b_b"); + assertThat(text(sheet, 2, 2)).isEqualTo("b_c"); + assertThat(text(sheet, 2, 3)).isEqualTo("b_d"); + // 费用跨 3 列、明细跨 2 列 + assertThat(sheet.getMergedRegions()).contains(new CellRangeAddress(0, 0, 0, 2)); + assertThat(sheet.getMergedRegions()).contains(new CellRangeAddress(1, 1, 0, 1)); + } + } + + @Test + void 列宽按列表配置换算后写入() throws Exception { + List columns = List.of(column("b_name", 12), column("b_amount", 20)); + + try (Workbook workbook = write( + List.of(leaf("b_name"), leaf("b_amount")), + columns, + List.of(List.of("甲", 1)), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + // POI 的列宽单位是 1/256 字符 + assertThat(sheet.getColumnWidth(0)).isEqualTo(12 * 256); + assertThat(sheet.getColumnWidth(1)).isEqualTo(20 * 256); + } + } + + @Test + void 无数据时仍写出表头() throws Exception { + try (Workbook workbook = write( + List.of(leaf("b_name"), leaf("b_bz")), + List.of(column("b_name", 12), column("b_bz", 12)), + List.of(), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + assertThat(sheet.getLastRowNum()).isZero(); + assertThat(text(sheet, 0, 0)).isEqualTo("b_name"); + assertThat(text(sheet, 0, 1)).isEqualTo("b_bz"); + } + } + + @Test + void 超过一批的行数也能全部写出() throws Exception { + List columns = List.of(column("b_name", 12)); + List> rows = new ArrayList<>(); + for (int index = 0; index < 2500; index++) { + rows.add(List.of("行" + index)); + } + + try (Workbook workbook = write(List.of(leaf("b_name")), columns, rows, List.of())) { + Sheet sheet = workbook.getSheetAt(0); + // 1 行表头 + 2500 行明细(跨越多个写入批次) + assertThat(sheet.getLastRowNum()).isEqualTo(2500); + assertThat(text(sheet, 1, 0)).isEqualTo("行0"); + assertThat(text(sheet, 2500, 0)).isEqualTo("行2499"); + } + } +} diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/formula/FormulaConfigServiceTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/formula/FormulaConfigServiceTests.java new file mode 100644 index 00000000..2404c6fa --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/formula/FormulaConfigServiceTests.java @@ -0,0 +1,81 @@ +package cn.g3soft.erp.service.formula; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 公式配置解析({@link FormulaConfigService#parseFormulaNodes})单测。 + * + *

保存处理器里与数据库无关的一步:表单 JSON → 公式节点。依赖库元数据的部分(目标列校验、 + * 依赖变更判断、事务内重算)需要真实数据库,由后续的集成测试覆盖。 + */ +class FormulaConfigServiceTests { + + private final FormulaConfigService service = new FormulaConfigService(); + + @Test + void 分组与行节点递归展开且只取带公式的字段节点() { + String schema = """ + { + "schemaVersion": 2, + "children": [ + { + "type": "group", "id": "g1", "title": "基础", + "children": [ + { "type": "row", "children": [ + { "field": "b_qty" }, + { "field": "b_price" } + ] }, + { "type": "row", "children": [ + { "field": "b_amount", "readonly": true, "formula": "ROUND(b_qty * b_price, 2)" } + ] } + ] + }, + { + "type": "row", + "children": [ + { "field": "b_tax", "readonly": true, "formula": "b_amount * 0.1", "overridable": true } + ] + } + ] + } + """; + List nodes = service.parseFormulaNodes(schema); + + assertEquals(2, nodes.size()); + assertEquals("b_amount", nodes.get(0).field()); + assertEquals("ROUND(b_qty * b_price, 2)", nodes.get(0).formula()); + assertFalse(nodes.get(0).overridable()); + assertEquals("b_tax", nodes.get(1).field()); + assertTrue(nodes.get(1).overridable()); + } + + @Test + void 空公式与空配置不产出节点() { + assertTrue(service.parseFormulaNodes(null).isEmpty()); + assertTrue(service.parseFormulaNodes("").isEmpty()); + assertTrue(service.parseFormulaNodes(""" + { "children": [ { "type": "row", "children": [ { "field": "b_qty" }, + { "field": "b_price", "formula": " " } ] } ] } + """).isEmpty()); + } + + @Test + void 摘要与节点顺序无关且随公式变化() { + List nodes = List.of( + new FormulaNode("b_amount", "b_qty * b_price", false), + new FormulaNode("b_tax", "b_amount * 0.1", true) + ); + String revision = FormulaRevision.formulaRevision(nodes); + assertEquals(revision, FormulaRevision.formulaRevision(List.of(nodes.get(1), nodes.get(0)))); + assertFalse(revision.equals(FormulaRevision.formulaRevision(List.of( + new FormulaNode("b_amount", "b_qty * b_price + 1", false), + nodes.get(1) + )))); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/formula/FormulaEngineTests.java b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/formula/FormulaEngineTests.java new file mode 100644 index 00000000..18f6f551 --- /dev/null +++ b/code/g3soft-erp/server/src/test/java/cn/g3soft/erp/service/formula/FormulaEngineTests.java @@ -0,0 +1,268 @@ +package cn.g3soft.erp.service.formula; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import cn.g3soft.erp.service.formula.FormulaDeps.RefsResult; +import cn.g3soft.erp.service.formula.FormulaEvaluator.EvalResult; +import cn.g3soft.erp.service.formula.FormulaGraph.Graph; +import cn.g3soft.erp.service.formula.FormulaGraph.Recalculator; +import cn.g3soft.erp.service.formula.FormulaParser.Result; +import cn.g3soft.erp.service.formula.FormulaValidator.FormulasResult; +import cn.g3soft.erp.service.formula.FormulaValidator.ValidateResult; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 公式引擎单测:跑测试向量 {@code src/test/resources/formula-vectors.json}。 + * + *

逐条覆盖求值 / 校验 / 依赖图 / 配置摘要,另外覆盖链式重算的拓扑序与自环检测。 + * + *

该向量是从前端原型仓库拷入的副本;等前端公式实现迁入 {@code web/} 且需要两边共用一份时, + * 再改成从共享位置读取。 + */ +@DisplayName("公式引擎 · 测试向量") +class FormulaEngineTests { + + private static JsonNode vectors; + private static Map fieldMeta; + private static Map fieldTypes; + + @BeforeAll + static void loadVectors() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + try (InputStream vectorsStream = FormulaEngineTests.class.getResourceAsStream("/formula-vectors.json")) { + if (vectorsStream == null) { + throw new IOException("测试向量缺失:src/test/resources/formula-vectors.json"); + } + vectors = mapper.readTree(vectorsStream); + } + + fieldMeta = new LinkedHashMap<>(); + fieldTypes = new LinkedHashMap<>(); + for (JsonNode field : vectors.get("fields")) { + String name = field.get("b_field").asText(); + String bType = field.get("b_type").asText(); + fieldMeta.put(name, bType); + fieldTypes.put(name, FormulaValue.formulaTypeOf(bType)); + } + } + + @Test + @DisplayName("求值向量") + void evaluateVectors() { + for (JsonNode testCase : vectors.get("evaluate")) { + String id = testCase.get("id").asText(); + String formula = testCase.get("formula").asText(); + Result parsed = FormulaParser.parse(formula); + assertThat(parsed.errors()).as("语法应通过:%s (%s)", id, formula).isEmpty(); + + Map values = valueMap(testCase.get("values")); + EvalResult result = FormulaEvaluator.evaluate(parsed.ast(), FormulaEvaluator.Context.of(values, fieldTypes)); + + if (testCase.hasNonNull("expectError")) { + assertThat(result.error()).as("应报错:%s", id).isNotNull(); + assertThat(result.error().code()).as("错误码:%s", id).isEqualTo(testCase.get("expectError").asText()); + continue; + } + assertThat(result.error()).as("不应报错:%s (%s)", id, formula).isNull(); + assertThat(normalized(result.value())).as("结果:%s (%s)", id, formula) + .isEqualTo(expected(testCase.get("expect"))); + } + } + + @Test + @DisplayName("校验向量") + void validateVectors() { + for (JsonNode testCase : vectors.get("validate")) { + String id = testCase.get("id").asText(); + String formula = testCase.get("formula").asText(); + String targetType = testCase.hasNonNull("targetType") ? testCase.get("targetType").asText() : null; + ValidateResult result = FormulaValidator.validateFormula(formula, + new FormulaValidator.Options(fieldMeta, "v1", targetType, null, List.of())); + + List codes = new ArrayList<>(); + for (FormulaMessages.Issue issue : result.errors()) { + codes.add(issue.code()); + } + List expected = new ArrayList<>(); + for (JsonNode item : testCase.get("expect")) { + expected.add(item.asText()); + } + assertThat(codes).as("校验错误码:%s (%s)", id, formula).containsExactlyElementsOf(expected); + } + } + + @Test + @DisplayName("依赖图 · 链式公式检测出循环引用") + void graphChainHasCycle() { + JsonNode config = vectors.get("graph").get("chain"); + FormulasResult result = FormulaValidator.validateFormulas(nodesOf(config.get("formulas")), + FormulaValidator.Options.of(fieldMeta)); + assertThat(result.graph().hasCycle()).isEqualTo(config.get("expectCycle").asBoolean()); + assertThat(result.graph().cycles()).isNotEmpty(); + assertThat(FormulaGraph.cycleText(result.graph().cycles().get(0))).isNotEmpty(); + } + + @Test + @DisplayName("依赖图 · 无环公式给出拓扑序,节点 ID 带作用域") + void graphAcyclicHasOrder() { + JsonNode config = vectors.get("graph").get("acyclic"); + FormulasResult result = FormulaValidator.validateFormulas(nodesOf(config.get("formulas")), + FormulaValidator.Options.of(fieldMeta)); + assertThat(result.errors()).isEmpty(); + assertThat(result.graph().hasCycle()).isEqualTo(config.get("expectCycle").asBoolean()); + List expectedOrder = new ArrayList<>(); + for (JsonNode item : config.get("expectOrder")) { + expectedOrder.add(item.asText()); + } + assertThat(result.graph().order()).containsExactlyElementsOf(expectedOrder); + } + + @Test + @DisplayName("依赖抽取带作用域前缀") + void depsUseScopedIds() { + Result parsed = FormulaParser.parse("b_qty * b_price"); + RefsResult refs = FormulaDeps.refsOf(parsed.ast(), FormulaDeps.Options.empty()); + assertThat(refs.usesRow()).isFalse(); + List ids = new ArrayList<>(); + for (FormulaDeps.Ref ref : refs.refs()) { + ids.add(ref.id()); + } + assertThat(ids).containsExactly( + FormulaDeps.fieldNodeId("main", null, "b_qty"), + FormulaDeps.fieldNodeId("main", null, "b_price")); + } + + @Test + @DisplayName("buildGraph 对自环给出环路") + void buildGraphSelfLoop() { + Graph graph = FormulaGraph.buildGraph(List.of(new FormulaGraph.Entry("main:a", List.of("main:a")))); + assertThat(graph.hasCycle()).isTrue(); + assertThat(graph.order()).isEmpty(); + } + + @Test + @DisplayName("链式重算按拓扑序推进,且只算受影响字段") + void recalculatorChainOrder() { + Graph graph = FormulaGraph.buildGraph(List.of( + new FormulaGraph.Entry("main:b_amount", List.of("main:b_qty", "main:b_price")), + new FormulaGraph.Entry("main:b_rate", List.of("main:b_amount")))); + Recalculator recalculator = FormulaGraph.createRecalculator(graph); + + assertThat(recalculator.affected(List.of("main:b_qty"))).containsExactly("main:b_amount", "main:b_rate"); + assertThat(recalculator.affected(List.of("main:b_name"))).isEmpty(); + + Map formulas = new LinkedHashMap<>(); + formulas.put("b_amount", "ROUND(b_qty * b_price, 2)"); + formulas.put("b_rate", "b_amount / 2"); + Map working = new LinkedHashMap<>(); + working.put("b_qty", 3); + working.put("b_price", "12.50"); + + Map values = recalculator.recalc(List.of("main:b_qty"), nodeId -> { + String field = nodeId.substring("main:".length()); + Result parsed = FormulaParser.parse(formulas.get(field)); + EvalResult evaluated = FormulaEvaluator.evaluate(parsed.ast(), FormulaEvaluator.Context.of(working, fieldTypes)); + assertThat(evaluated.error()).isNull(); + Object raw = FormulaValue.rawValue(evaluated.value()); + // 中间结果回灌,链式公式才能逐级取到上游新值 + working.put(field, raw); + return raw; + }); + + assertThat(values.get("main:b_amount")).isEqualTo("37.5"); + assertThat(values.get("main:b_rate")).isEqualTo("18.75"); + } + + @Test + @DisplayName("配置摘要 · 与共享向量一致,且对顺序不敏感、对内容敏感") + void revisionMatchesVector() { + List nodes = revisionNodes(); + assertThat(FormulaRevision.formulaRevision(nodes)).isEqualTo(vectors.get("revision").get("expect").asText()); + + List reordered = new ArrayList<>(nodes); + java.util.Collections.reverse(reordered); + assertThat(FormulaRevision.formulaRevision(reordered)).isEqualTo(FormulaRevision.formulaRevision(nodes)); + + List changed = new ArrayList<>(); + for (int index = 0; index < nodes.size(); index++) { + FormulaNode node = nodes.get(index); + changed.add(index == 0 + ? new FormulaNode(node.field(), "b_qty * b_price + 1", node.overridable()) + : node); + } + assertThat(FormulaRevision.formulaRevision(changed)).isNotEqualTo(FormulaRevision.formulaRevision(nodes)); + } + + private static List nodesOf(JsonNode formulas) { + List nodes = new ArrayList<>(); + formulas.fields().forEachRemaining(entry -> + nodes.add(new FormulaNode(entry.getKey(), entry.getValue().asText(), false))); + return nodes; + } + + private static List revisionNodes() { + List nodes = new ArrayList<>(); + for (JsonNode node : vectors.get("revision").get("nodes")) { + nodes.add(new FormulaNode(node.get("field").asText(), node.get("formula").asText(), + node.get("overridable").asBoolean())); + } + return nodes; + } + + /** 向量的 values 对象 → 字段原始值(保留 null 值,字段存在但为空同样要归一为 NULL) */ + private static Map valueMap(JsonNode values) { + Map map = new LinkedHashMap<>(); + if (values == null || values.isNull()) { + return map; + } + values.fields().forEachRemaining(entry -> { + JsonNode value = entry.getValue(); + if (value.isNull()) { + map.put(entry.getKey(), null); + } else if (value.isBoolean()) { + map.put(entry.getKey(), value.asBoolean()); + } else if (value.isIntegralNumber()) { + map.put(entry.getKey(), value.asLong()); + } else if (value.isNumber()) { + map.put(entry.getKey(), value.asDouble()); + } else { + map.put(entry.getKey(), value.asText()); + } + }); + return map; + } + + /** rawValue 归一化口径:decimal 用 stripTrailingZeros().toPlainString(),null → null */ + private static Object normalized(FormulaValue value) { + if (FormulaValue.isNull(value)) { + return null; + } + if (FormulaValue.DECIMAL.equals(value.type())) { + BigDecimal decimal = (BigDecimal) value.value(); + return decimal.signum() == 0 ? "0" : decimal.stripTrailingZeros().toPlainString(); + } + return value.value(); + } + + private static Object expected(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + if (node.isBoolean()) { + return node.asBoolean(); + } + return node.asText(); + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/src/test/resources/formula-vectors.json b/code/g3soft-erp/server/src/test/resources/formula-vectors.json new file mode 100644 index 00000000..72bcb20b --- /dev/null +++ b/code/g3soft-erp/server/src/test/resources/formula-vectors.json @@ -0,0 +1,179 @@ +{ + "_comment": "公式引擎共享测试向量:前端 vitest 与 Java JUnit 读同一份文件,防止两套实现漂移。expect 为落库口径原始值(decimal 用字符串,null 表示空值)。", + "fields": [ + { "b_field": "b_qty", "b_type": "number" }, + { "b_field": "b_price", "b_type": "money" }, + { "b_field": "b_amount", "b_type": "money" }, + { "b_field": "b_rate", "b_type": "number" }, + { "b_field": "b_name", "b_type": "input" }, + { "b_field": "b_no", "b_type": "input" }, + { "b_field": "b_flag", "b_type": "checkbox" }, + { "b_field": "b_day", "b_type": "date" }, + { "b_field": "b_end", "b_type": "date" } + ], + "evaluate": [ + { "id": "arith_literals", "formula": "1 + 2", "expect": "3" }, + { "id": "arith_precedence", "formula": "2 + 3 * 4", "expect": "14" }, + { "id": "arith_parens", "formula": "(2 + 3) * 4", "expect": "20" }, + { "id": "unary_minus", "formula": "-3 + 10", "expect": "7" }, + { "id": "decimal_no_float_error", "formula": "0.1 + 0.2", "expect": "0.3" }, + { "id": "decimal_mul", "formula": "1.1 * 3", "expect": "3.3" }, + { "id": "division", "formula": "10 / 4", "expect": "2.5" }, + { "id": "division_by_zero", "formula": "1 / 0", "expect": null }, + { "id": "modulo", "formula": "10 % 3", "expect": "1" }, + { "id": "modulo_negative", "formula": "MOD(-7, 3)", "expect": "-1" }, + { "id": "modulo_by_zero", "formula": "10 % 0", "expect": null }, + { + "id": "field_multiply", + "formula": "b_qty * b_price", + "values": { "b_qty": 3, "b_price": "12.50" }, + "expect": "37.5" + }, + { + "id": "field_chain_discount", + "formula": "ROUND(b_qty * b_price * (1 - b_rate), 2)", + "values": { "b_qty": 3, "b_price": "10.00", "b_rate": "0.13" }, + "expect": "26.1" + }, + { "id": "round_half_up", "formula": "ROUND(2.345, 2)", "expect": "2.35" }, + { "id": "round_negative_half_up", "formula": "ROUND(-2.345, 2)", "expect": "-2.35" }, + { "id": "round_no_digits", "formula": "ROUND(2.5)", "expect": "3" }, + { "id": "roundup", "formula": "ROUNDUP(2.341, 2)", "expect": "2.35" }, + { "id": "rounddown", "formula": "ROUNDDOWN(2.349, 2)", "expect": "2.34" }, + { "id": "abs", "formula": "ABS(-5)", "expect": "5" }, + { "id": "ceil", "formula": "CEIL(1.1)", "expect": "2" }, + { "id": "ceil_negative", "formula": "CEIL(-1.1)", "expect": "-1" }, + { "id": "floor", "formula": "FLOOR(1.9)", "expect": "1" }, + { "id": "floor_negative", "formula": "FLOOR(-1.1)", "expect": "-2" }, + { "id": "pow", "formula": "POW(2, 10)", "expect": "1024" }, + { "id": "sqrt", "formula": "SQRT(9)", "expect": "3" }, + { "id": "sqrt_negative", "formula": "SQRT(-4)", "expect": null }, + { "id": "min_max", "formula": "MAX(1, 5, 3) - MIN(2, 1)", "expect": "4" }, + { + "id": "if_true", + "formula": "IF(b_qty > 0, b_price, 0)", + "values": { "b_qty": 2, "b_price": "9.5" }, + "expect": "9.5" + }, + { "id": "if_false", "formula": "IF(0 > 0, 1, 2)", "expect": "2" }, + { "id": "if_null_condition", "formula": "IF(NULL, 1, 2)", "expect": "2" }, + { + "id": "ifblank", + "formula": "IFBLANK(b_price, 0)", + "values": { "b_price": null }, + "expect": "0" + }, + { + "id": "coalesce", + "formula": "COALESCE(b_price, b_name, 'x')", + "values": { "b_price": null, "b_name": "a" }, + "expect": "a" + }, + { "id": "isblank", "formula": "ISBLANK(b_price)", "values": { "b_price": null }, "expect": true }, + { "id": "null_arithmetic", "formula": "b_qty + 1", "values": { "b_qty": null }, "expect": "1" }, + { + "id": "text_concat", + "formula": "b_name & '-' & b_no", + "values": { "b_name": "A", "b_no": "01" }, + "expect": "A-01" + }, + { + "id": "text_concat_null", + "formula": "b_name & b_no", + "values": { "b_name": "A", "b_no": null }, + "expect": "A" + }, + { "id": "and_three_valued", "formula": "TRUE AND NULL", "expect": null }, + { "id": "and_false_wins", "formula": "FALSE AND NULL", "expect": false }, + { "id": "or_true_wins", "formula": "TRUE OR NULL", "expect": true }, + { "id": "or_three_valued", "formula": "FALSE OR NULL", "expect": null }, + { "id": "not_boolean", "formula": "NOT(b_flag)", "values": { "b_flag": 0 }, "expect": true }, + { "id": "compare_true", "formula": "b_qty >= 3", "values": { "b_qty": 3 }, "expect": true }, + { "id": "compare_null", "formula": "b_qty > 3", "values": { "b_qty": null }, "expect": null }, + { "id": "checkbox_equals", "formula": "b_flag == TRUE", "values": { "b_flag": 1 }, "expect": true }, + { + "id": "text_functions", + "formula": "UPPER(LEFT(b_name, 2)) & TRIM(' x ')", + "values": { "b_name": "abcd" }, + "expect": "ABx" + }, + { + "id": "text_len_mid", + "formula": "LEN(MID(b_name, 2, 2))", + "values": { "b_name": "abcd" }, + "expect": "2" + }, + { "id": "text_replace", "formula": "REPLACE(b_no, '0', '')", "values": { "b_no": "A001" }, "expect": "A1" }, + { "id": "text_cast", "formula": "TEXT(b_qty)", "values": { "b_qty": 3 }, "expect": "3" }, + { "id": "number_cast", "formula": "NUMBER('2.50')", "expect": "2.5" }, + { "id": "number_cast_invalid", "formula": "NUMBER('abc')", "expect": null }, + { "id": "datediff_days", "formula": "DATEDIFF('2026-01-01', '2026-03-15', 'day')", "expect": "73" }, + { "id": "datediff_months", "formula": "DATEDIFF('2026-01-15', '2026-03-14', 'month')", "expect": "1" }, + { "id": "dateadd_month_clamp", "formula": "DATEADD('2026-01-31', 1, 'month')", "expect": "2026-02-28" }, + { "id": "dateadd_day", "formula": "DATEADD('2026-02-28', 1, 'day')", "expect": "2026-03-01" }, + { + "id": "date_parts", + "formula": "YEAR(b_day) * 100 + MONTH(b_day)", + "values": { "b_day": "2026-03-15" }, + "expect": "202603" + }, + { "id": "formatdate", "formula": "FORMATDATE('2026-03-05', 'yyyy/MM/dd')", "expect": "2026/03/05" }, + { "id": "err_unknown_function", "formula": "FOO(1)", "expectError": "E_FUNC_UNKNOWN" }, + { "id": "err_deferred_aggregate", "formula": "SUM(b_amount)", "expectError": "E_AGG_DEFERRED" }, + { "id": "err_arity", "formula": "ROUND(1, 2, 3)", "expectError": "E_FUNC_ARGS" }, + { + "id": "err_type_operand", + "formula": "b_name * 2", + "values": { "b_name": "abc" }, + "expectError": "E_TYPE_OPERAND" + } + ], + "validate": [ + { "id": "ok_basic", "formula": "ROUND(b_qty * b_price, 2)", "expect": [] }, + { "id": "ok_concat", "formula": "b_name & '-' & b_no", "expect": [] }, + { "id": "ok_ifblank", "formula": "IFBLANK(b_amount, 0)", "expect": [] }, + { "id": "err_aggregate", "formula": "SUM(b_amount)", "expect": ["E_AGG_DEFERRED"] }, + { "id": "err_volatile_today", "formula": "TODAY()", "expect": ["E_FUNC_VOLATILE"] }, + { "id": "err_volatile_now", "formula": "NOW()", "expect": ["E_FUNC_VOLATILE"] }, + { "id": "err_relation_scope", "formula": "lines.b_amount", "expect": ["E_FIELD_SCOPE_DEFERRED"] }, + { "id": "err_unknown_field", "formula": "b_nope + 1", "expect": ["E_FIELD_UNKNOWN"] }, + { "id": "err_null_compare", "formula": "b_amount == NULL", "expect": ["E_NULL_COMPARE"] }, + { "id": "err_type_operand", "formula": "b_name * 2", "expect": ["E_TYPE_OPERAND"] }, + { "id": "err_compare_chained", "formula": "b_qty < 1 < 2", "expect": ["E_COMPARE_CHAINED"] }, + { "id": "err_syntax_tail", "formula": "b_qty +", "expect": ["E_OPERAND_EXPECTED"] }, + { "id": "err_func_args", "formula": "DATEDIFF(b_day)", "expect": ["E_FUNC_ARGS"] }, + { "id": "err_unknown_function", "formula": "FOO(b_qty)", "expect": ["E_FUNC_UNKNOWN"] }, + { "id": "err_empty", "formula": " ", "expect": ["E_FORMULA_EMPTY"] }, + { + "id": "err_target_type", + "formula": "b_qty * b_price", + "targetType": "text", + "expect": ["E_TYPE_TARGET"] + } + ], + "graph": { + "chain": { + "fields": ["b_qty", "b_price", "b_amount"], + "formulas": { + "b_amount": "b_qty * b_price", + "b_price": "b_amount / 2" + }, + "expectCycle": true + }, + "acyclic": { + "fields": ["b_qty", "b_price", "b_amount"], + "formulas": { + "b_amount": "ROUND(b_qty * b_price, 2)" + }, + "expectCycle": false, + "expectOrder": ["main:b_amount"] + } + }, + "revision": { + "nodes": [ + { "field": "b_amount", "formula": "b_qty * b_price", "overridable": false }, + { "field": "b_price", "formula": "1 + 2", "overridable": true } + ], + "expect": "fnv1a64:e4bcb9ebe6caeac0" + } +} \ No newline at end of file diff --git a/code/g3soft-erp/server/start.cmd b/code/g3soft-erp/server/start.cmd new file mode 100644 index 00000000..80536163 --- /dev/null +++ b/code/g3soft-erp/server/start.cmd @@ -0,0 +1,7 @@ +@echo off +set "JAVA_HOME=D:\devtool\jdk\jdk-21" +set "MAVEN_HOME=D:\devtool\maven\apache-maven-3.8.9" +set "PATH=%JAVA_HOME%\bin;%MAVEN_HOME%\bin;%PATH%" + +cd /d "%~dp0" +call "%MAVEN_HOME%\bin\mvn.cmd" -s "%MAVEN_HOME%\conf\settings.xml" spring-boot:run