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

13 KiB
Raw Blame History

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 tagdata),导致 res.data 为 undefined

修复内容

  1. api() 函数增加兼容逻辑:当 json.data.Data 存在而 json.data.data 不存在时,自动取 json.data.Data
  2. api() 函数增加 console.log 调试日志,打印原始响应和解包后的数据
  3. 四个编辑函数(openBizModal/openReportModal/openFieldModal/openExtractModal
    • 增加 fallbackres.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.goModel() 方法使用了 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/mapOmitEmptyWhere() 对此无效
  • 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 / GetReportby codeGetExtractLog:同上,追加 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

const d = res && res.data !== undefined ? res.data : (res && res.Data !== undefined ? res.Data : res);
  • 第一层:res.data双层包裹json tag 小写)
  • 第二层:res.DataGo 字段名大写 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 序列化 BusinessConfigJSON 中无 tenant_id → insert map 中无 tenant_id key
  2. insertHook 检查 in.Data[i]["tenant_id"] 是否存在 → 不存在 → 不设置 → DB 用 DEFAULT 0
  3. selectHookctxWithUser 获取 TenantId=1,注入 WHERE tenant_id = 1
  4. DB 行 tenant_id=0 与查询 tenant_id=1 不匹配 → 查不到行 → 返回零值结构体

修复common/report/model/model.go):给 BusinessConfigReportConfigFieldConfigExtractConfig 四个结构体新增 TenantId uint64 \orm:"tenant_id" json:"tenant_id"``。

原理:添加后序列化 JSON 包含 "tenant_id": 0insertHook 检测到 key 存在且为零值,自动赋值为 userInfo.TenantId

调试日志common/report/config/loader.goGetBusinessByID 增加 g.Log().Infof 打印 iderrIsEmptybiz.IDbiz.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

// 旧写法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 }

影响的方法GetBusinessGetBusinessByIDGetReportGetReportByIDGetFieldByIDGetExtractConfigByIDGetExtractLog