重构数据引擎和报表引擎

This commit is contained in:
2026-06-11 13:06:54 +08:00
parent 285a0fc632
commit 419473f266
53 changed files with 8434 additions and 375 deletions

View File

@@ -0,0 +1,179 @@
# 2026-06-10
## 通用报表引擎 - 补充前端 CRUD 能力
`common/report` 包补充了配置的 CRUD 接口,使前端可以直接维护报表配置,无需操作数据库。
### 新增文件变更
- `model/model.go`: 新增 SaveBusinessReq/SaveReportReq/SaveFieldReq/SaveExtractConfigReq 等请求结构体SaveResult/DeleteResult 响应结构体
- `config/loader.go`: 新增 Create/Update/Delete + GetByID 方法Business/Report/Field/ExtractConfig 四种配置)
- `api.go`: 新增 SaveBusiness/SaveReport/SaveField/SaveExtractConfig + Delete/Get 接口(新增/修改合一id 为空则新增)
- `example_usage.go`: 新增第五、六节CRUD 调用示例 + 任意新平台零代码接入流程
### 关键设计决策
- **Save 合一**:通过 `ID *int64` 区分新增nil和修改有值简化前端调用
- **软删除**status → INACTIVE + deleted_at=NOW(),数据不丢失
- **缓存失效**:写操作后自动失效对应业务/报表缓存,保证下次读取一致性
- 所有 CRUD 方法先调用 initTables() 确保表存在
### example_usage.go 完整重写
将示例文件从分节的注释风格重写为6个完整场景的 Go 函数示例:
1. **快手电商完整接入**最详细从业务注册到前端查询6步全流程
2. **已有配置的 CRUD 二次开发参考**(业务/报表/字段/抽取 增删改查)
3. **任意平台接入通用模式**(淘宝示例,零硬编码)
4. **外部业务服务调用**goview-report 等直接 import
5. **Direct 模式示例**(不聚合的逐行抽取)
6. **前端 HTTP API 交互流程**(伪代码,展示 API 调用顺序)
每个场景都有实际可运行的代码片段、生成的 SQL 预览、返回值输出示例。
## 报表引擎管理前端页面
为报表引擎创建了可直接使用的前端管理页面,访问 `/admin/report`
### 新增文件
- `controller/report/report_controller.go`: HTTP API Controller16个端点暴露公共报表服务的全部能力业务/报表/字段/抽取配置的 CRUD、数据抽取触发、自动建表、用户选择查询
- `controller/report/report_admin_controller.go`: 嵌入式HTML SPA5个Tab页
- **业务管理**:列表 CRUD
- **报表配置**:按业务筛选列表 CRUD
- **字段配置**:按业务+报表筛选,维度/指标/筛选角色管理
- **抽取配置**:按业务+报表筛选,支持 DIRECT/AGGREGATE 模式配置 + 一键执行抽取
- **数据查询**:可视化维度选择器、指标聚合选择器、筛选条件构建器、查询结果展示
- `main.go`: 注册 reportCtrl.ReportController 到 RouteRegister + `/admin/report` 页面路由
### 技术要点
- 纯原生 HTML/CSS/JS零框架依赖与现有管理后台 `/admin` 风格统一
- 所有 CRUD 通过 `ID *int64` 区分新增nil/修改(有值)
- 查询Tab支持动态字段选择维度多选、指标聚合方式选择、筛选条件动态构建支持 BETWEEN/IN 等操作符)
- 管理后台页面增加了返回链接 `← 返回管理后台`
## 修复 report_common_ddl.sql 语法错误
PostgreSQL 不支持 MySQL 风格的内联 `COMMENT '...'` 语法。修复了全部5张表的列定义
- 移除所有列定义中的内联 `COMMENT 'xxx'`MySQL 语法)
- 已有的 `COMMENT ON COLUMN` 独立语句保留不变
- `report_extract_log` 表补充了缺失的 `COMMENT ON COLUMN` 语句business_code/report_code/extract_code/stat_date/extract_type/status/total_count/success_count/fail_count
关键区别MySQL 支持 `column_name VARCHAR(64) COMMENT 'xxx'`PostgreSQL 必须用 `COMMENT ON COLUMN table.column IS 'xxx'`
## 补充 tenant_id 到报表系统表
5张报表系统表原先缺失 `tenant_id`,与项目核心表(`init_core_tables.sql`)不一致。修改内容:
- **DDL SQL** (`sql/report_common_ddl.sql`)5张表全部新增 `tenant_id BIGINT NOT NULL DEFAULT 0` 列,所有 UNIQUE 约束和索引都加入 tenant_id
- **Go initTables** (`common/report/report.go`):同步更新 5 张表的 DDL 定义,加上 tenant_id
- 约束变更:`uk_business_report_code UNIQUE (business_code, report_code)``(tenant_id, business_code, report_code)`,其余类推
当前 report 模块租户支持为占位符级别(硬编码 tenant_id=1后续需从 context 动态获取。
- **单独迁移脚本**: `sql/report_add_tenant_id.sql`,对已存在的表执行 ALTER TABLE ADD COLUMN + 重建约束/索引
## 快手电商报表初始化种子数据
**初版(已废弃)**:字段名随意捏造,与真实 DDL 不对齐。
**重写版** `sql/seed_data_report_kuaishou.sql`(基于 seed_data_kuaishou.sql 实际 DDL
- 1 条业务配置 (kuaishou_ecommerce)
- 5 条报表配置order_analysis / item_analysis / refund_analysis / cps_order_analysis / dropshipping_analysis
- 76 条字段配置订单23+商品15+售后12+分销13+代发13含维度/指标/衍生字段
- 5 条抽取配置AGGREGATE 聚合模式,每个报表一条)
**严格设计原则**
- sourceField 对齐源表实际列名camelCaseoid, status, createTime, totalFee, buyerNick 等)
- targetField / field_code 使用 snake_casestat_date, total_fee, buyer_nick 等)
- 金额字段源表存分(BIGINT),报表提供衍生元字段(CALCULATED expression / 100.0)
- 增量抽取依据统一用 updateTime毫秒时间戳 BIGINT
- transform_rulests_to_date FORMAT createTime → stat_date(yyyy-MM-dd)
- 所有 INSERT 使用 ON CONFLICT DO NOTHING 保证幂等
## 修复报表管理前端编辑不显示数据问题
`report_admin_controller.go` 中修复了编辑弹窗不显示数据的问题:
**根因**
- GoFrame `MiddlewareHandlerResponse` 包裹响应为 `{code:0, data: <struct>}`,而单条查询返回结构体为 `getBusinessRes{Data: ...}` 序列化为 `{"data": {...}}`,最终响应为 `{code:0, data: {"data": {...}}}` 双层嵌套
- 某些 GoFrame 版本可能序列化 Go 字段名(首字母大写 `Data`)而非 json tag`data`),导致 `res.data` 为 undefined
**修复内容**
1. `api()` 函数增加兼容逻辑:当 `json.data.Data` 存在而 `json.data.data` 不存在时,自动取 `json.data.Data`
2. `api()` 函数增加 `console.log` 调试日志,打印原始响应和解包后的数据
3. 四个编辑函数(`openBizModal/openReportModal/openFieldModal/openExtractModal`
- 增加 fallback`res.data !== undefined ? res.data : res.Data`
- 增加数据有效性校验:`if (!d || !d.id)` 时弹出错误提示并阻止打开空表单
- 增加 `console.log` 调试日志
## 修复 report_report_config 查询 SQL 永假条件问题
**现象**:查询 `report_report_config` 时生成的 SQL 包含 `WHERE (("id" = 1) AND ("id" = 0 AND "business_code" = '' AND ...))`id 不可能同时为 1 和 0导致查询始终返回 0 行。
**根因**`common/db/gfdb/gfdb.go``Model()` 方法使用了 `OmitNil()`(只过滤 nil 指针,不过滤零值)。当 GoFrame v2 内部的软删除机制(`deleted_at IS NULL`)自动构建 struct WHERE 条件时,由于 `OmitNil()` 不忽略零值字段,结构体中所有字段的零值(`id=0`, `business_code=""`, `report_code=""` 等)都被加入 WHERE 条件,与显式的 `Where("id", 1)` 叠加形成 `id=1 AND id=0` 的永假条件。
**修复**`OmitNil()` **保留**,额外追加 `.OmitEmptyWhere()`,两者同时生效:
- `OmitNil()`:保持 INSERT/UPDATE 的 Data() 行为不变nil 指针/map 字段不写入 SQL走 DB 默认值),避免破坏数据引擎写入逻辑
- `OmitEmptyWhere()`:过滤 struct WHERE 条件中的零值字段,解决软删除 hook 注入零值条件的问题
最终代码:`OmitNil().OmitEmptyWhere()`
**影响范围**`common/db/gfdb/gfdb.go` - 全局 DB Model 初始化。写入行为完全兼容,查询条件不再包含零值 struct 字段。
**安全性审计2026-06-10**:逐文件审查了数据引擎核心同步路径全部 40+ 处 `gfdb.Model()` 调用:
- 100% 传字符串表名(非 struct不会触发 struct WHERE 推断
- 100% 使用 `Where("col", val)` 字符串键值对(非 struct/map`OmitEmptyWhere()` 对此无效
- `OmitEmptyWhere()` 不影响 `Data()`/`Insert()`/`Update()`/`Save()`
- 结论:`OmitEmptyWhere()` 对数据引擎核心同步逻辑完全无影响,唯一生效场景是 `common/report` 软删除 hook
## 修复 GetXxxByID 方法零值结构体返回问题
**现象**:前端编辑报表/业务/字段/抽取配置时一直提示"未获取到XX数据ID=N"。与上述 OmitNil 问题同根SQL 返回 0 行时GoFrame 的 `One()` 可能不返回 `sql.ErrNoRows`(驱动差异),此时 `GetXxxByID` 判断 `err == nil` 就返回了零值结构体 `{ID: 0, ...}`。Controller 包装为 `{code:0, data:{"data":{id:0}}}`,前端 `!d.id` 判定失败弹出错误提示。
**修复**`common/report/config/loader.go` 中全部 7 个 `One()` 调用方法,在 `err` 检查之外增加 `result.IsEmpty()` 检查:
- `GetBusinessByID` / `GetReportByID` / `GetFieldByID` / `GetExtractConfigByID`:四者模式一致,`_` 改为 `result` 变量并追加 `if result.IsEmpty()`
- `GetBusiness` / `GetReport`by code`GetExtractLog`:同上,追加 `IsEmpty()` 守卫
**双重防御**gfdb.go 的 `OmitEmptyWhere()` 是根本修复(消除错误 SQLloader.go 的 `IsEmpty()` 是兜底守卫(即使 SQL 返回 0 行也不返回零值结构体)。
## 修复编辑弹窗数据提取的三层兼容
**根因**`api()` 返回 `json.data` 给调用者。当 GoFrame `MiddlewareHandlerResponse` 双层包裹时(`{code:0, data: {"data": {...}}}``api()` 返回 `{"data": {...}}`,调用者通过 `res.data` 正确提取。但当 GoFrame 单层包裹时(`{code:0, data: {...}}``api()` 直接返回 `{...config...}`,调用者 `res.data` 为 undefined → `d` 为 undefined → 触发 "未获取到XX数据" 错误。
**修复**4 个编辑函数(`openBizModal`/`openReportModal`/`openFieldModal`/`openExtractModal`)的数据提取改为三层 fallback
```javascript
const d = res && res.data !== undefined ? res.data : (res && res.Data !== undefined ? res.Data : res);
```
- 第一层:`res.data`双层包裹json tag 小写)
- 第二层:`res.Data`Go 字段名大写 D
- 第三层:`res` 本身(单层包裹,数据直接在 res 里)
同时 console.log 增加打印 `raw:``data:` 便于排查。
## 修复报表引擎实体缺失 TenantId 字段 → 查询 tenant_id 不匹配返回零值
**现象**`GET /report/business?id=1` 返回 `code: 0` 但 data 为全零值 `{id: 0, businessCode: "", ...}`,尽管 DB 中实际有数据。
**根因**:四个报表引擎实体结构体均缺失 `TenantId` 字段,但 DDL 中表定义有 `tenant_id BIGINT NOT NULL DEFAULT 0`
1. `CreateBusiness` 序列化 `BusinessConfig`JSON 中无 `tenant_id` → insert map 中无 `tenant_id` key
2. `insertHook` 检查 `in.Data[i]["tenant_id"]` 是否存在 → 不存在 → 不设置 → DB 用 DEFAULT 0
3. `selectHook``ctxWithUser` 获取 `TenantId=1`,注入 `WHERE tenant_id = 1`
4. DB 行 `tenant_id=0` 与查询 `tenant_id=1` 不匹配 → 查不到行 → 返回零值结构体
**修复**`common/report/model/model.go`):给 `BusinessConfig``ReportConfig``FieldConfig``ExtractConfig` 四个结构体新增 `TenantId uint64 \`orm:"tenant_id" json:"tenant_id"\``。
**原理**:添加后序列化 JSON 包含 `"tenant_id": 0``insertHook` 检测到 key 存在且为零值,自动赋值为 `userInfo.TenantId`。
**调试日志**`common/report/config/loader.go``GetBusinessByID` 增加 `g.Log().Infof` 打印 `id`、`err`、`IsEmpty`、`biz.ID`、`biz.BusinessCode`,便于排查后续类似问题。同时补充 import `"github.com/gogf/gf/v2/frame/g"`。
## 修复 GoFrame One(&struct) 直接映射失效 → 改用两步模式
**现象**:修复 TenantId 后SQL 能正确执行并返回1行`[rows:1]`, `IsEmpty=false`),但 struct 字段全为零值(`biz.ID=0, biz.BusinessCode=""`)。
**根因**`loader.go` 中 7 处 `One(&struct)` 直接传结构体指针,在 GoFrame v2.10.0 + `selectHook` 修改 SQL 的组合下struct 自动映射失效 —— 查询返回 `gdb.Record` 但未 populate 结构体。
**修复**:全部 7 处改为项目已验证的两步模式(参照 `dao/dict/api_interface_dao.go`
```go
// 旧写法struct 映射失效)
result, err := gfdb.DB(ctx).Model(ctx, "table").Where(...).One(&biz)
// 新写法(先取 Record再手动 Struct
r, err := gfdb.DB(ctx).Model(ctx, "table").Where(...).One()
if err = r.Struct(&biz); err != nil { return nil, err }
```
**影响的方法**`GetBusiness`、`GetBusinessByID`、`GetReport`、`GetReportByID`、`GetFieldByID`、`GetExtractConfigByID`、`GetExtractLog`。