Files
data-engine/.codebuddy/memory/2026-06-10.md
2026-06-11 13:06:54 +08:00

180 lines
13 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.
# 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`。