# 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`)。 ## 已定的技术栈 | 项 | 选型 | |---|---| | 框架 | Spring Boot 4.1.0 | | 语言 | Java 21 | | Web | spring-boot-starter-webmvc | | 持久层 | spring-boot-starter-jdbc(**无 ORM**) | | 连接池 | Druid 1.2.18 | | 数据库 | **SQL Server**(mssql-jdbc) | | 鉴权 | JJWT 0.12.6 | | Excel | Apache Fesod 2.0.2-incubating | | 其他 | 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)直接记「内网」,不外呼。 ## 日志 ### 输出与落盘 控制台输出彩色文本;文件输出**结构化 JSON**(每行一个对象,logstash 格式),默认落在 `server/logs/` (相对进程工作目录的 `./logs`,根 `.gitignore` 已忽略)。 | 文件 | 内容 | |---|---| | `logs/erp-api.jsonl` | 全量日志 | | `logs/erp-api-error.jsonl` | 仅 `ERROR` | | `logs/erp-api.<日期>.<序号>.jsonl.gz` | 滚动归档(gzip) | 滚动策略:单文件 10MB、保留 30 天;主文件总量上限 1GB、错误文件 256MB。文件写入经 `AsyncAppender`, 队列满时**背压而非丢日志**,不阻塞请求线程。 配置在 `src/main/resources/logback-spring.xml`(appender / 滚动 / 编码器)+ `application.yaml` 的 `logging` 块(路径 / 格式 / 级别)。 > **必须命名为 `logback-spring.xml`**:结构化编码器依赖 Spring 注入的 `Environment`,改成 `logback.xml` 会启动失败。 ### 请求追踪 每个请求分配一个 `traceId`(优先复用上游 `X-Request-Id` / W3C `traceparent`),并回写到响应头 `X-Request-Id`。 通过 `MDC` 注入,因此**该请求的所有日志行**都带同一 `traceId`,按它即可串起完整链路。 > 入参 `X-Request-Id` 需匹配 `^[A-Za-z0-9._-]{8,64}$` 才会被复用,否则**静默忽略并自行生成**(防日志注入 / 换行伪造)。 > 若发现上游的 traceId 没串起来,先检查这个格式约束。W3C `traceparent` 则要求标准 `00-<32hex>-<16hex>-<2hex>` 格式。 访问日志每请求一行(`logger_name=ACCESS`、`message=http_access`)。主要字段(均为 JSON 顶层字段): | 字段 | 说明 | |---|---| | `@timestamp` / `level` / `logger_name` / `thread_name` | 标准字段 | | `traceId` | 请求链路 ID | | `method` / `uri` | `uri` 仅为 path,**不含 query** | | `status` / `durationMs` / `slow` | HTTP 状态 / 耗时 / 是否超过慢请求阈值 | | `orgId` / `userId` | 鉴权通过后由 `JwtAuthFilter` 回写 MDC | | `clientIp` / `userAgent` | 客户端信息(`userAgent` 截断 256 字符) | | `params` | 请求参数,**仅错误或慢请求时出现** | | `stack_trace` | 异常堆栈,仅错误行 | > 注意:本项目异常统一返回 **HTTP 200**,错误码在响应 body 里。所以判断出错要看 `level` / `erp-api-error.jsonl`,**不能看 `status`**。 > 另外,被 `JwtAuthFilter` 直接拒绝的请求(未登录 / 会话被顶)不会读取 body,因此没有 `params`。 ### 请求参数记录 请求参数(query + body)**只在出错或慢请求时**记录到 `params`,正常请求不记,避免刷屏与大 body 常态落盘; 文件上传(multipart / 二进制流)跳过,超出上限按 UTF-8 字节边界截断(不会切断中文)。 ```yaml erp: log: slow-request-ms: 1000 # 超过则视为慢请求,记录 params max-param-bytes: 2048 # 参数记录上限(字节),同时是请求体缓存上限 exclude-param-paths: # 这些路径完全不记参数(与 getServletPath() 精确匹配) - /auth/login # 登录接口含明文密码,默认排除 ``` > **不做脱敏**:除 `exclude-param-paths` 外,参数原样写入日志(含业务数据、明文密码)。 > 上线前请确认日志文件的访问权限与流转范围;如需收口,把对应接口加进排除名单即可。 ### 分级与噪音 `ERROR`=系统故障、`WARN`=降级 / 预期内业务竞争、`INFO`=生命周期与访问、`DEBUG`=排障。 第三方噪音(Druid、SQL Server JDBC、Spring)默认降到 `WARN`,可在 `application.yaml` 的 `logging.level.*` 调整; 临时排障时可把 `cn.g3soft.erp` 调到 `DEBUG`。 > **级别以 `application.yaml` 为准**:`logback-spring.xml` 里也给 `cn.g3soft.erp` / `ACCESS` 各写了一份 `INFO`, > 但 Boot 是在加载 logback 配置**之后**才应用 `logging.level.*`,所以 YAML 会覆盖 XML。**改级别请改 YAML**, > XML 里那两行只是兜底默认值。 ### 查看与分析 - **图形界面**:双击 `server/log-viewer.html`(Edge / Chrome),选择 `logs`(或 `logs-demo`)文件夹即可。 支持 `.jsonl` 与轮转 `.gz`,**本地解析、不上传**。提供概览 / 错误 / 慢请求 / 按机构 / 按接口 / 状态码 / 按小时 / traceId 追踪。 - **命令行**:活动文件直接 `jq`,归档用 `gzip -dc`;Windows 亦可用自带 PowerShell(`ConvertFrom-Json`),无需安装 jq。 - `server/logs-demo/` 是**模拟日志**(已 gitignore),仅用于演示查看器,可随时删除。 ## 数据访问层(`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/ # 通用返回、异常、纯静态工具 │ ├── ApiResponse.java # HTTP 响应外壳 + 全部错误码常量 │ ├── exception/ # 6 个异常与 GlobalExceptionHandler │ └── util/ # ParamUtils / SqlText / StringUtils / TimeUtils / RequestParams / │ # UuidV7Generator + snowflake/(内嵌的雪花 ID 生成器) ├── config/ # AuthProperties / LogProperties / FileStorageProperties / │ # JacksonConfig / JwtAuthFilter / RequestLoggingFilter ├── 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/ # 空运 ├── container/ # 箱管 ├── finance/ # 财务 ├── crm/ # CRM └── system/ # 系统管理 ``` ## 与前端的关系 **唯一契约是 HTTP 接口。** 前端不读后端源码,后端不管前端怎么调。 同仓的三条纪律(见根 `README.md`): 1. **独立构建** —— `mvn package` 不触发前端构建,反之亦然 2. **独立版本** —— tag 分开打(`api-v1.2.0` / `web-v1.2.0`) 3. **接口契约写进 `docs/`** 并版本化