Files
workspace/code/g3soft-erp/server/README.md
T
2026-10-13 17:29:55 +08:00

182 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/`** 并版本化