提交代码

This commit is contained in:
lmk
2026-07-02 10:23:07 +08:00
parent 7f6ec3a757
commit 802139c349
24 changed files with 7373 additions and 314 deletions
+30
View File
@@ -0,0 +1,30 @@
{
"permissions": {
"allow": [
"Bash(go build *)",
"Bash(go test *)",
"Bash(go run *)",
"Bash(go mod *)",
"Bash(curl *)",
"Bash(npx *)",
"Bash(brew *)",
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(python3 *)",
"Bash(psql *)",
"Bash(PGPASSWORD=*)",
"Bash(lsof *)",
"Bash(pkill *)",
"Bash(sleep *)",
"Bash(mkdir *)",
"Bash(which *)",
"Bash(pwd *)",
"Bash(echo *)",
"Bash(source *)",
"Bash(export *)",
"Bash(unset *)",
"Bash(package *)",
"Bash(kill *)"
]
}
}
+2
View File
@@ -1,3 +1,5 @@
/.idea/*
/data-engine
/resource/*
.gstack/
/docs/*
+16
View File
@@ -1,5 +1,7 @@
# data-engine
> ⚠️ **启动时必读:** 每次会话开始时,先读取 `docs/INDEX.md` 了解项目全景和最近变更。
## Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
@@ -18,3 +20,17 @@ Key routing rules:
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
- Author a backlog-ready spec/issue → invoke /spec
## Agent skills
### Issue tracker
Issues tracked as local markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`.
### Triage labels
Default five-role vocabulary (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.
### Domain docs
Single-context repo. See `docs/agents/domain.md`.
+402 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"dataengine/common/report/config"
"dataengine/common/report/ddlsync"
@@ -13,8 +14,18 @@ import (
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/database/gdb"
)
// getUserNameFromCtx 从上下文中获取当前用户名
func getUserNameFromCtx(ctx context.Context) string {
if user, ok := ctx.Value("user").(*beans.User); ok && user != nil && user.UserName != "" {
return user.UserName
}
return "system"
}
// ReportService 报表公共服务
// 对外暴露的统一接口
type ReportService struct {
@@ -108,7 +119,68 @@ func (s *ReportService) ExtractDailyData(ctx context.Context, businessCode, repo
}
// ============================================================
// 核心接口 3: 用户选择查询(最核心)
// 核心接口 3: 批量回填数据
// 首次接入时对日期范围逐天抽取
// ============================================================
// Backfill 批量回填数据
// 遍历 startDate → endDate 逐天调用 ExtractDailyData
// 返回汇总统计
func (s *ReportService) Backfill(ctx context.Context, req *model.BackfillReq) (*model.BackfillResp, error) {
start := time.Now()
startDate, err := time.Parse("2006-01-02", req.StartDate)
if err != nil {
return nil, fmt.Errorf("开始日期格式错误: %w", err)
}
endDate, err := time.Parse("2006-01-02", req.EndDate)
if err != nil {
return nil, fmt.Errorf("结束日期格式错误: %w", err)
}
if endDate.Before(startDate) {
return nil, fmt.Errorf("结束日期不能早于开始日期")
}
totalDays := 0
successDays := 0
failDays := 0
var lastErr error
for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
totalDays++
dateStr := d.Format("2006-01-02")
resp, err := s.ExtractDailyData(ctx, req.BusinessCode, req.ReportCode, dateStr, req.Executor)
if err != nil {
failDays++
lastErr = fmt.Errorf("回填 %s 失败: %w", dateStr, err)
continue
}
if !resp.Success {
failDays++
lastErr = fmt.Errorf("回填 %s 异常: %s", dateStr, resp.ErrorMsg)
continue
}
successDays++
}
execTime := time.Since(start).Milliseconds()
resp := &model.BackfillResp{
Success: failDays == 0,
TotalDays: totalDays,
SuccessDays: successDays,
FailDays: failDays,
ExecTimeMs: execTime,
}
if lastErr != nil {
resp.ErrorMsg = lastErr.Error()
}
return resp, nil
}
// ============================================================
// 核心接口 4: 用户选择查询(最核心)
// 前端用户选择条件 → 实时构建SQL → 返回报表数据
// ============================================================
@@ -145,11 +217,21 @@ func (s *ReportService) GetReportFields(ctx context.Context, businessCode, repor
}
// GetAllBusinesses 获取所有启用业务列表
// ListBusinesses 分页获取业务列表
func (s *ReportService) ListBusinesses(ctx context.Context, pageNum, pageSize int) ([]model.BusinessConfig, int, error) {
return s.configLoader.ListBusinesses(ctx, pageNum, pageSize)
}
func (s *ReportService) GetAllBusinesses(ctx context.Context) ([]model.BusinessConfig, error) {
return s.configLoader.GetAllBusinesses(ctx)
}
// GetAllReports 获取业务下所有报表列表
// ListReports 分页获取报表列表
func (s *ReportService) ListReports(ctx context.Context, businessCode, reportName string, pageNum, pageSize int) ([]model.ReportConfig, int, error) {
return s.configLoader.ListReports(ctx, businessCode, reportName, pageNum, pageSize)
}
func (s *ReportService) GetAllReports(ctx context.Context, businessCode string) ([]model.ReportConfig, error) {
return s.configLoader.GetAllReports(ctx, businessCode)
}
@@ -174,8 +256,20 @@ func (s *ReportService) SaveBusiness(ctx context.Context, req *model.SaveBusines
return nil, fmt.Errorf("初始化系统表失败: %w", err)
}
// 新增时校验 businessCode 唯一性
if req.ID == nil || *req.ID == 0 {
r, err := gfdb.DB(ctx).GetAll(ctx, "SELECT id FROM report_business_config WHERE business_code = $1 LIMIT 1", req.BusinessCode)
if err != nil {
return nil, fmt.Errorf("校验业务编码失败: %w", err)
}
if !r.IsEmpty() {
return nil, fmt.Errorf("业务编码 %s 已存在", req.BusinessCode)
}
}
userName := getUserNameFromCtx(ctx)
biz := &model.BusinessConfig{
SQLBaseDO: beans.SQLBaseDO{Creator: req.Operator, Updater: req.Operator},
SQLBaseDO: beans.SQLBaseDO{Creator: userName, Updater: userName, TenantId: 1},
BusinessCode: req.BusinessCode,
BusinessName: req.BusinessName,
Description: req.Description,
@@ -213,6 +307,14 @@ func (s *ReportService) DeleteBusiness(ctx context.Context, id int64) (*model.De
if err != nil {
return nil, err
}
// 级联检查:该业务下是否存在报表
r, err := gfdb.DB(ctx).GetAll(ctx, "SELECT COUNT(1) AS cnt FROM report_report_config WHERE business_code = $1 AND deleted_at IS NULL LIMIT 1", biz.BusinessCode)
if err == nil && !r.IsEmpty() {
cnt := r[0]["cnt"].Int()
if cnt > 0 {
return &model.DeleteResult{Success: false, Message: fmt.Sprintf("业务 %s 下存在 %d 个报表配置,请先删除关联报表后再删除业务", biz.BusinessCode, cnt)}, nil
}
}
if err := s.configLoader.DeleteBusiness(ctx, id, biz.BusinessCode); err != nil {
return nil, err
}
@@ -234,8 +336,21 @@ func (s *ReportService) SaveReport(ctx context.Context, req *model.SaveReportReq
return nil, fmt.Errorf("初始化系统表失败: %w", err)
}
// 新增时校验 reportCode 唯一性(同业务下)
if req.ID == nil || *req.ID == 0 {
r, err := gfdb.DB(ctx).GetAll(ctx, "SELECT id FROM report_report_config WHERE business_code = $1 AND report_code = $2 LIMIT 1",
req.BusinessCode, req.ReportCode)
if err != nil {
return nil, fmt.Errorf("校验报表编码失败: %w", err)
}
if !r.IsEmpty() {
return nil, fmt.Errorf("业务 %s 下报表编码 %s 已存在", req.BusinessCode, req.ReportCode)
}
}
userName := getUserNameFromCtx(ctx)
rpt := &model.ReportConfig{
SQLBaseDO: beans.SQLBaseDO{Creator: req.Operator, Updater: req.Operator},
SQLBaseDO: beans.SQLBaseDO{Creator: userName, Updater: userName, TenantId: 1},
BusinessCode: req.BusinessCode,
ReportCode: req.ReportCode,
ReportName: req.ReportName,
@@ -286,6 +401,18 @@ func (s *ReportService) DeleteReport(ctx context.Context, id int64) (*model.Dele
if err != nil {
return nil, err
}
// 级联检查:该报表下是否存在未删除的字段
r, err := gfdb.DB(ctx).GetAll(ctx, "SELECT COUNT(1) AS cnt FROM report_field_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL LIMIT 1",
rpt.BusinessCode, rpt.ReportCode)
if err == nil && !r.IsEmpty() && r[0]["cnt"].Int() > 0 {
return &model.DeleteResult{Success: false, Message: fmt.Sprintf("报表 %s 下存在字段配置,请先删除关联字段后再删除报表", rpt.ReportCode)}, nil
}
// 级联检查:该报表下是否存在抽取配置
r, err = gfdb.DB(ctx).GetAll(ctx, "SELECT COUNT(1) AS cnt FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL LIMIT 1",
rpt.BusinessCode, rpt.ReportCode)
if err == nil && !r.IsEmpty() && r[0]["cnt"].Int() > 0 {
return &model.DeleteResult{Success: false, Message: fmt.Sprintf("报表 %s 下存在抽取配置,请先删除关联抽取配置后再删除报表", rpt.ReportCode)}, nil
}
if err := s.configLoader.DeleteReport(ctx, id, rpt.BusinessCode, rpt.ReportCode); err != nil {
return nil, err
}
@@ -294,7 +421,223 @@ func (s *ReportService) DeleteReport(ctx context.Context, id int64) (*model.Dele
// GetReport 获取单个报表配置
func (s *ReportService) GetReport(ctx context.Context, id int64) (*model.ReportConfig, error) {
return s.configLoader.GetReportByID(ctx, id)
rpt, err := s.configLoader.GetReportByID(ctx, id)
if err != nil {
return nil, err
}
// 同时加载全部字段(含已失效),编辑时前端可直接回显
allFields, err := s.configLoader.GetAllFields(ctx, rpt.BusinessCode, rpt.ReportCode)
if err == nil {
rpt.Fields = allFields
}
return rpt, nil
}
// ============================================================
// 合并操作:报表+字段一起保存
// ============================================================
// SaveReportWithFields 保存报表及字段配置(报表+字段全量替换)
func (s *ReportService) SaveReportWithFields(ctx context.Context, req *model.SaveReportWithFieldsReq) (*model.SaveResult, error) {
if err := initTables(ctx); err != nil {
return nil, fmt.Errorf("初始化系统表失败: %w", err)
}
// 自动填充每个字段的 businessCode/reportCode(从报表级继承)
for i := range req.Fields {
if req.Fields[i].BusinessCode == "" {
req.Fields[i].BusinessCode = req.BusinessCode
}
if req.Fields[i].ReportCode == "" {
req.Fields[i].ReportCode = req.ReportCode
}
}
var reportID int64
userName := getUserNameFromCtx(ctx)
// 事务:要么全成功,要么不动
err := gfdb.DB(ctx).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 1. 保存报表(新增或更新)
now := time.Now()
if req.ID != nil && *req.ID > 0 {
// 更新报表
data := map[string]interface{}{
"business_code": req.BusinessCode,
"report_code": req.ReportCode,
"report_name": req.ReportName,
"description": req.Description,
"status": req.Status,
"stat_table_name": req.StatTableName,
"stat_table_comment": req.StatTableComment,
"date_field": req.DateField,
"primary_keys": req.PrimaryKeys,
"conflict_keys": req.ConflictKeys,
"config": req.Config,
"updated_at": now,
"updater": userName,
}
if _, err := tx.Model("report_report_config").Where("id", *req.ID).Data(data).Update(); err != nil {
return fmt.Errorf("更新报表失败: %w", err)
}
reportID = *req.ID
} else {
// 新增时校验 reportCode 唯一性
cnt, err := tx.Model("report_report_config").Where("business_code", req.BusinessCode).Where("report_code", req.ReportCode).Count()
if err != nil {
return fmt.Errorf("校验报表编码失败: %w", err)
}
if cnt > 0 {
return fmt.Errorf("业务 %s 下报表编码 %s 已存在", req.BusinessCode, req.ReportCode)
}
data := map[string]interface{}{
"business_code": req.BusinessCode,
"report_code": req.ReportCode,
"report_name": req.ReportName,
"description": req.Description,
"status": ifVal(req.Status, model.StatusActive),
"stat_table_name": req.StatTableName,
"stat_table_comment": req.StatTableComment,
"date_field": ifVal(req.DateField, "stat_date"),
"primary_keys": req.PrimaryKeys,
"conflict_keys": req.ConflictKeys,
"config": req.Config,
"created_at": now,
"updated_at": now,
"creator": userName,
"updater": userName,
"tenant_id": 1,
}
if data["primary_keys"] == nil || len(data["primary_keys"].([]string)) == 0 {
data["primary_keys"] = []string{"id"}
}
if data["conflict_keys"] == nil || len(data["conflict_keys"].([]string)) == 0 {
data["conflict_keys"] = []string{ifVal(req.DateField, "stat_date")}
}
result, err := tx.Model("report_report_config").Data(data).Insert()
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
return fmt.Errorf("业务 %s 下报表编码 %s 已存在", req.BusinessCode, req.ReportCode)
}
return fmt.Errorf("创建报表失败: %w", err)
}
rid, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("获取自增ID失败: %w", err)
}
reportID = rid
}
// 2. 获取报表下现有字段(含已删除,编辑时重新添加的字段需走 UPDATE 而非 INSERT
rows, err := tx.Model("report_field_config").
Where("business_code", req.BusinessCode).
Where("report_code", req.ReportCode).
All()
if err != nil {
return fmt.Errorf("获取现有字段失败: %w", err)
}
existingMap := make(map[string]int64) // fieldCode -> id
for _, row := range rows {
existingMap[row["field_code"].String()] = row["id"].Int64()
}
// 3. 处理请求中的字段(新增或更新)
for i := range req.Fields {
f := &req.Fields[i]
status := ifVal(f.Status, model.StatusActive)
dataType := ifVal(f.DataType, model.FieldTypeString)
if existingID, ok := existingMap[f.FieldCode]; ok {
// 已存在 → 更新
updateData := map[string]interface{}{
"field_name": f.FieldName,
"field_type": f.FieldType,
"data_type": dataType,
"field_role": f.FieldRole,
"is_aggregatable": f.IsAggregatable,
"is_filterable": f.IsFilterable,
"is_queryable": f.IsQueryable,
"is_sortable": f.IsSortable,
"default_aggregate": f.DefaultAggregate,
"valid_aggregates": f.ValidAggregates,
"filter_operators": f.FilterOperators,
"expression": f.Expression,
"expression_type": f.ExpressionType,
"format_pattern": f.FormatPattern,
"unit": f.Unit,
"dict_code": f.DictCode,
"sort_order": f.SortOrder,
"group_name": f.GroupName,
"status": status,
"updated_at": now,
"updater": userName,
}
if _, err := tx.Model("report_field_config").Where("id", existingID).Data(updateData).Update(); err != nil {
return fmt.Errorf("更新字段 %s 失败: %w", f.FieldCode, err)
}
delete(existingMap, f.FieldCode)
} else {
// 不存在 → 新增
insertData := map[string]interface{}{
"business_code": req.BusinessCode,
"report_code": req.ReportCode,
"field_code": f.FieldCode,
"field_name": f.FieldName,
"field_type": f.FieldType,
"data_type": dataType,
"field_role": f.FieldRole,
"is_aggregatable": f.IsAggregatable,
"is_filterable": f.IsFilterable,
"is_queryable": f.IsQueryable,
"is_sortable": f.IsSortable,
"default_aggregate": f.DefaultAggregate,
"valid_aggregates": f.ValidAggregates,
"filter_operators": f.FilterOperators,
"expression": f.Expression,
"expression_type": f.ExpressionType,
"format_pattern": f.FormatPattern,
"unit": f.Unit,
"dict_code": f.DictCode,
"sort_order": f.SortOrder,
"group_name": f.GroupName,
"status": status,
"created_at": now,
"updated_at": now,
"creator": userName,
"updater": userName,
"tenant_id": 1,
}
if _, err := tx.Model("report_field_config").Data(insertData).Insert(); err != nil {
if strings.Contains(err.Error(), "duplicate key") {
return fmt.Errorf("字段编码 %s 在当前报表下已存在,请更换字段编码", f.FieldCode)
}
return fmt.Errorf("创建字段 %s 失败: %w", f.FieldCode, err)
}
}
}
// 4. 删除剩余字段(不在请求中的旧字段)
for fieldCode, existingID := range existingMap {
if _, err := tx.Model("report_field_config").Where("id", existingID).Data(map[string]interface{}{
"status": model.StatusInactive,
"deleted_at": now,
"updated_at": now,
"updater": userName,
}).Update(); err != nil {
return fmt.Errorf("删除字段 %s 失败: %w", fieldCode, err)
}
}
return nil
})
if err != nil {
return nil, err
}
// 清理缓存
s.configLoader.InvalidateCache(req.BusinessCode, req.ReportCode)
return &model.SaveResult{Success: true, ID: reportID, Message: "报表及字段保存成功"}, nil
}
// ============================================================
@@ -307,8 +650,16 @@ func (s *ReportService) SaveField(ctx context.Context, req *model.SaveFieldReq)
return nil, fmt.Errorf("初始化系统表失败: %w", err)
}
if req.BusinessCode == "" {
return nil, fmt.Errorf("businessCode 不能为空")
}
if req.ReportCode == "" {
return nil, fmt.Errorf("reportCode 不能为空")
}
userName := getUserNameFromCtx(ctx)
field := &model.FieldConfig{
SQLBaseDO: beans.SQLBaseDO{Creator: req.Operator, Updater: req.Operator},
SQLBaseDO: beans.SQLBaseDO{Creator: userName, Updater: userName, TenantId: 1},
BusinessCode: req.BusinessCode,
ReportCode: req.ReportCode,
FieldCode: req.FieldCode,
@@ -418,8 +769,21 @@ func (s *ReportService) SaveExtractConfig(ctx context.Context, req *model.SaveEx
return nil, fmt.Errorf("初始化系统表失败: %w", err)
}
// 新增时校验 extractCode 唯一性(同业务+报表下)
if req.ID == nil || *req.ID == 0 {
r, err := gfdb.DB(ctx).GetAll(ctx, "SELECT id FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND extract_code = $3 LIMIT 1",
req.BusinessCode, req.ReportCode, req.ExtractCode)
if err != nil {
return nil, fmt.Errorf("校验抽取编码失败: %w", err)
}
if !r.IsEmpty() {
return nil, fmt.Errorf("业务 %s/报表 %s 下抽取编码 %s 已存在", req.BusinessCode, req.ReportCode, req.ExtractCode)
}
}
userName := getUserNameFromCtx(ctx)
ec := &model.ExtractConfig{
SQLBaseDO: beans.SQLBaseDO{Creator: req.Operator, Updater: req.Operator},
SQLBaseDO: beans.SQLBaseDO{Creator: userName, Updater: userName, TenantId: 1},
BusinessCode: req.BusinessCode,
ReportCode: req.ReportCode,
ExtractCode: req.ExtractCode,
@@ -499,6 +863,38 @@ func (s *ReportService) GetExtractConfig(ctx context.Context, id int64) (*model.
}
// GetExtractConfigs 获取业务报表下所有抽取配置
// ListExtractConfigs 分页获取抽取配置列表
func (s *ReportService) ListExtractConfigs(ctx context.Context, businessCode, reportCode string, pageNum, pageSize int) ([]model.ExtractConfig, int, error) {
return s.configLoader.ListExtractConfigs(ctx, businessCode, reportCode, pageNum, pageSize)
}
func (s *ReportService) GetExtractConfigs(ctx context.Context, businessCode, reportCode string) ([]model.ExtractConfig, error) {
return s.configLoader.GetExtractConfigs(ctx, businessCode, reportCode)
}
// GetExtractConfigsAll 获取业务报表下所有抽取配置(不限 status,用于填充字段展示)
func (s *ReportService) GetExtractConfigsAll(ctx context.Context, businessCode, reportCode string) ([]model.ExtractConfig, error) {
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT id, extract_code, extract_name, status FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND status != $3 ORDER BY id ASC",
businessCode, reportCode, model.StatusInactive)
if err != nil {
return nil, err
}
var configs []model.ExtractConfig
for _, record := range r {
var ec model.ExtractConfig
if err := record.Struct(&ec); err != nil {
return nil, err
}
configs = append(configs, ec)
}
return configs, nil
}
// ifVal 三目运算:非空返回 s,否则返回 fallback
func ifVal(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
+9 -2
View File
@@ -92,8 +92,13 @@ func (b *SQLBuilder) BuildQuerySQL(ctx context.Context, req *model.UserSelectQue
countSql += " WHERE " + whereClause
}
if groupByClause != "" {
countSql = fmt.Sprintf("SELECT COUNT(*) FROM (SELECT 1 FROM %s WHERE %s GROUP BY %s) AS t",
fromClause, whereClause, groupByClause)
if whereClause != "" {
countSql = fmt.Sprintf("SELECT COUNT(*) FROM (SELECT 1 FROM %s WHERE %s GROUP BY %s) AS t",
fromClause, whereClause, groupByClause)
} else {
countSql = fmt.Sprintf("SELECT COUNT(*) FROM (SELECT 1 FROM %s GROUP BY %s) AS t",
fromClause, groupByClause)
}
}
metadata := map[string]interface{}{
@@ -352,6 +357,8 @@ func (b *SQLBuilder) buildTimeGroupExpr(dateField, timeGroup string) string {
return fmt.Sprintf("DATE_TRUNC('week', %s::date)::text AS time_group", dateField)
case "month":
return fmt.Sprintf("TO_CHAR(%s::date, 'YYYY-MM') AS time_group", dateField)
case "year":
return fmt.Sprintf("TO_CHAR(%s::date, 'YYYY') AS time_group", dateField)
case "quarter":
return "TO_CHAR(" + dateField + "::date, 'YYYY-\"Q\"Q') AS time_group"
default:
+6 -5
View File
@@ -9,12 +9,12 @@ import (
// Test data - reusable field configs
var testFields = map[string]*model.FieldConfig{
"shop_id": {FieldCode: "shop_id", FieldName: "店铺ID", FieldType: "STRING", FieldRole: "DIMENSION", IsFilterable: true, IsSortable: true, FilterOperators: []string{"=", "IN"}},
"shop_name": {FieldCode: "shop_name", FieldName: "店铺名称", FieldType: "STRING", FieldRole: "DIMENSION", IsFilterable: true, IsSortable: true, FilterOperators: []string{"=", "LIKE"}},
"stat_date": {FieldCode: "stat_date", FieldName: "统计日期", FieldType: "DATE", FieldRole: "DIMENSION", IsFilterable: true, IsSortable: true, FilterOperators: []string{"=", ">=", "<=", "BETWEEN"}},
"order_count": {FieldCode: "order_count", FieldName: "订单数", FieldType: "INT", FieldRole: "INDICATOR", IsAggregatable: true, DefaultAggregate: "SUM", ValidAggregates: []string{"SUM", "COUNT"}},
"shop_id": {FieldCode: "shop_id", FieldName: "店铺ID", FieldType: "STRING", FieldRole: "DIMENSION", IsFilterable: true, IsSortable: true, FilterOperators: []string{"=", "IN"}},
"shop_name": {FieldCode: "shop_name", FieldName: "店铺名称", FieldType: "STRING", FieldRole: "DIMENSION", IsFilterable: true, IsSortable: true, FilterOperators: []string{"=", "LIKE"}},
"stat_date": {FieldCode: "stat_date", FieldName: "统计日期", FieldType: "DATE", FieldRole: "DIMENSION", IsFilterable: true, IsSortable: true, FilterOperators: []string{"=", ">=", "<=", "BETWEEN"}},
"order_count": {FieldCode: "order_count", FieldName: "订单数", FieldType: "INT", FieldRole: "INDICATOR", IsAggregatable: true, DefaultAggregate: "SUM", ValidAggregates: []string{"SUM", "COUNT"}},
"order_amount": {FieldCode: "order_amount", FieldName: "订单金额", FieldType: "FLOAT", FieldRole: "INDICATOR", IsAggregatable: true, IsSortable: true, DefaultAggregate: "SUM", ValidAggregates: []string{"SUM", "AVG", "MAX", "MIN"}},
"refund_rate": {FieldCode: "refund_rate", FieldName: "退款率", FieldType: "FLOAT", FieldRole: "INDICATOR", IsAggregatable: true, DefaultAggregate: "AVG", ValidAggregates: []string{"AVG"}, Expression: "{refund_amount} / NULLIF({order_amount}, 0) * 100", ExpressionType: "CALCULATED"},
"refund_rate": {FieldCode: "refund_rate", FieldName: "退款率", FieldType: "FLOAT", FieldRole: "INDICATOR", IsAggregatable: true, DefaultAggregate: "AVG", ValidAggregates: []string{"AVG"}, Expression: "{refund_amount} / NULLIF({order_amount}, 0) * 100", ExpressionType: "CALCULATED"},
"order_status": {FieldCode: "order_status", FieldName: "订单状态", FieldType: "STRING", FieldRole: "FILTER", IsFilterable: true, FilterOperators: []string{"=", "IN"}},
}
@@ -303,6 +303,7 @@ func TestBuildTimeGroupExpr(t *testing.T) {
group string
wantSub string
}{
{"year", "YYYY"},
{"week", "DATE_TRUNC"},
{"month", "YYYY-MM"},
{"quarter", "Q"},
+131 -12
View File
@@ -51,8 +51,8 @@ func (l *ConfigLoader) GetBusiness(ctx context.Context, businessCode string) (*m
var biz model.BusinessConfig
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_business_config WHERE business_code = $1 AND status = $2 LIMIT 1",
businessCode, model.StatusActive)
"SELECT * FROM report_business_config WHERE business_code = $1 AND deleted_at IS NULL LIMIT 1",
businessCode)
if err != nil {
return nil, fmt.Errorf("查询业务配置失败: %w", err)
}
@@ -86,8 +86,8 @@ func (l *ConfigLoader) GetReport(ctx context.Context, businessCode, reportCode s
var rpt model.ReportConfig
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_report_config WHERE business_code = $1 AND report_code = $2 AND status = $3 LIMIT 1",
businessCode, reportCode, model.StatusActive)
"SELECT * FROM report_report_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL LIMIT 1",
businessCode, reportCode)
if err != nil {
return nil, fmt.Errorf("查询报表配置失败: %w", err)
}
@@ -126,8 +126,8 @@ func (l *ConfigLoader) GetFields(ctx context.Context, businessCode, reportCode s
l.mu.RUnlock()
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_field_config WHERE business_code = $1 AND report_code = $2 AND status = $3 ORDER BY sort_order ASC",
businessCode, reportCode, model.StatusActive)
"SELECT * FROM report_field_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL ORDER BY sort_order ASC",
businessCode, reportCode)
if err != nil {
return nil, err
}
@@ -153,6 +153,31 @@ func (l *ConfigLoader) GetFields(ctx context.Context, businessCode, reportCode s
return fields, nil
}
// GetAllFields 获取报表全部字段(含 INACTIVE,不含已删除,用于编辑回显)
func (l *ConfigLoader) GetAllFields(ctx context.Context, businessCode, reportCode string) ([]model.FieldConfig, error) {
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_field_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL ORDER BY sort_order ASC",
businessCode, reportCode)
if err != nil {
return nil, err
}
var fields []model.FieldConfig
for _, record := range r {
var f model.FieldConfig
if err := record.Struct(&f); err != nil {
return nil, err
}
if f.ValidAggregates == nil {
f.ValidAggregates = []string{}
}
if f.FilterOperators == nil {
f.FilterOperators = []string{"=", "!=", ">", "<", ">=", "<=", "IN", "LIKE", "BETWEEN"}
}
fields = append(fields, f)
}
return fields, nil
}
// GetFieldMap 获取字段配置Map
func (l *ConfigLoader) GetFieldMap(ctx context.Context, businessCode, reportCode string) (map[string]*model.FieldConfig, error) {
fields, err := l.GetFields(ctx, businessCode, reportCode)
@@ -178,8 +203,8 @@ func (l *ConfigLoader) GetExtractConfigs(ctx context.Context, businessCode, repo
l.mu.RUnlock()
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND status = $3 AND is_enabled = $4",
businessCode, reportCode, model.StatusActive, true)
"SELECT * FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL ORDER BY id ASC",
businessCode, reportCode)
if err != nil {
return nil, err
}
@@ -586,8 +611,9 @@ func (l *ConfigLoader) GetExtractConfigByID(ctx context.Context, id int64) (*mod
// GetAllBusinesses 获取所有业务配置
func (l *ConfigLoader) GetAllBusinesses(ctx context.Context) ([]model.BusinessConfig, error) {
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_business_config WHERE status = $1 ORDER BY id ASC",
model.StatusActive)
"SELECT * FROM report_business_config WHERE deleted_at IS NULL ORDER BY id ASC",
)
// (removed status filter)
if err != nil {
return nil, err
}
@@ -605,8 +631,8 @@ func (l *ConfigLoader) GetAllBusinesses(ctx context.Context) ([]model.BusinessCo
// GetAllReports 获取所有报表配置
func (l *ConfigLoader) GetAllReports(ctx context.Context, businessCode string) ([]model.ReportConfig, error) {
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_report_config WHERE business_code = $1 AND status = $2 ORDER BY id ASC",
businessCode, model.StatusActive)
"SELECT * FROM report_report_config WHERE business_code = $1 AND deleted_at IS NULL ORDER BY id ASC",
businessCode)
if err != nil {
return nil, err
}
@@ -621,6 +647,99 @@ func (l *ConfigLoader) GetAllReports(ctx context.Context, businessCode string) (
return reports, nil
}
// ListBusinesses 分页获取业务列表
func (l *ConfigLoader) ListBusinesses(ctx context.Context, pageNum, pageSize int) ([]model.BusinessConfig, int, error) {
total, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT COUNT(*) AS cnt FROM report_business_config WHERE deleted_at IS NULL")
if err != nil {
return nil, 0, err
}
count := 0
if !total.IsEmpty() {
count = total[0]["cnt"].Int()
}
offset := (pageNum - 1) * pageSize
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_business_config WHERE deleted_at IS NULL ORDER BY id ASC LIMIT $1 OFFSET $2",
pageSize, offset)
if err != nil {
return nil, 0, err
}
var businesses []model.BusinessConfig
for _, record := range r {
var biz model.BusinessConfig
if err := record.Struct(&biz); err != nil {
return nil, 0, err
}
businesses = append(businesses, biz)
}
return businesses, count, nil
}
// ListReports 分页获取报表列表
func (l *ConfigLoader) ListReports(ctx context.Context, businessCode, reportName string, pageNum, pageSize int) ([]model.ReportConfig, int, error) {
namePattern := "%" + reportName + "%"
total, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT COUNT(*) AS cnt FROM report_report_config WHERE business_code = $1 AND report_name ILIKE $2 AND deleted_at IS NULL",
businessCode, namePattern)
if err != nil {
return nil, 0, err
}
count := 0
if !total.IsEmpty() {
count = total[0]["cnt"].Int()
}
offset := (pageNum - 1) * pageSize
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_report_config WHERE business_code = $1 AND report_name ILIKE $2 AND deleted_at IS NULL ORDER BY id ASC LIMIT $3 OFFSET $4",
businessCode, namePattern, pageSize, offset)
if err != nil {
return nil, 0, err
}
var reports []model.ReportConfig
for _, record := range r {
var rpt model.ReportConfig
if err := record.Struct(&rpt); err != nil {
return nil, 0, err
}
reports = append(reports, rpt)
}
return reports, count, nil
}
// ListExtractConfigs 分页获取抽取配置列表
func (l *ConfigLoader) ListExtractConfigs(ctx context.Context, businessCode, reportCode string, pageNum, pageSize int) ([]model.ExtractConfig, int, error) {
total, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT COUNT(*) AS cnt FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL",
businessCode, reportCode)
if err != nil {
return nil, 0, err
}
count := 0
if !total.IsEmpty() {
count = total[0]["cnt"].Int()
}
offset := (pageNum - 1) * pageSize
r, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT * FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL ORDER BY id ASC LIMIT $3 OFFSET $4",
businessCode, reportCode, pageSize, offset)
if err != nil {
return nil, 0, err
}
var configs []model.ExtractConfig
for _, record := range r {
var ec model.ExtractConfig
if err := record.Struct(&ec); err != nil {
return nil, 0, err
}
configs = append(configs, ec)
}
l.mu.Lock()
l.extractCache[businessCode+":"+reportCode] = configs
l.mu.Unlock()
return configs, count, nil
}
// GetReportFields 获取报表可用字段(按角色分类)
func (l *ConfigLoader) GetReportFields(ctx context.Context, businessCode, reportCode string) (*model.GetReportFieldsResp, error) {
fields, err := l.GetFields(ctx, businessCode, reportCode)
+66
View File
@@ -0,0 +1,66 @@
package report
import (
"context"
"time"
"github.com/gogf/gf/v2/os/gcron"
"github.com/gogf/gf/v2/os/gctx"
"github.com/sirupsen/logrus"
)
// StartDailyExtractJob 启动每日自动抽取定时任务
// 每天凌晨 2:00 遍历所有已启用的业务+报表,抽取前一天数据
// 在 main.go 中调用:report.StartDailyExtractJob()
func StartDailyExtractJob() {
svc := GetService()
// 每天凌晨 2:00 执行
_, err := gcron.Add(gctx.New(), "0 0 2 * * *", func(ctx context.Context) {
logrus.Info("[报表引擎] 开始每日自动抽取任务")
// 获取所有启用业务
businesses, err := svc.GetAllBusinesses(ctx)
if err != nil {
logrus.Errorf("[报表引擎] 获取业务列表失败: %v", err)
return
}
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
for _, biz := range businesses {
// 获取业务下所有启用报表
reports, err := svc.GetAllReports(ctx, biz.BusinessCode)
if err != nil {
logrus.Errorf("[报表引擎] 获取报表列表失败 [%s]: %v", biz.BusinessCode, err)
continue
}
for _, rpt := range reports {
logrus.Infof("[报表引擎] 开始抽取 %s/%s 日期 %s", biz.BusinessCode, rpt.ReportCode, yesterday)
resp, err := svc.ExtractDailyData(ctx, biz.BusinessCode, rpt.ReportCode, yesterday, "system")
if err != nil {
logrus.Errorf("[报表引擎] 抽取失败 %s/%s %s: %v", biz.BusinessCode, rpt.ReportCode, yesterday, err)
continue
}
if resp.Success {
logrus.Infof("[报表引擎] 抽取成功 %s/%s %s 总数:%d 耗时:%dms",
biz.BusinessCode, rpt.ReportCode, yesterday, resp.TotalCount, resp.ExecTimeMs)
} else {
logrus.Errorf("[报表引擎] 抽取异常 %s/%s %s: %s", biz.BusinessCode, rpt.ReportCode, yesterday, resp.ErrorMsg)
}
}
}
logrus.Info("[报表引擎] 每日自动抽取任务完成")
}, "daily-report-extract")
if err != nil {
logrus.Errorf("[报表引擎] 启动定时任务失败: %v", err)
return
}
logrus.Info("[报表引擎] 每日自动抽取定时任务已启动 (02:00)")
}
+5 -20
View File
@@ -115,8 +115,10 @@ func (c *StatTableCreator) buildCreateTableSQL(tableName string, report *model.R
// 业务字段
for _, f := range fields {
colDef := c.fieldTypeToColumn(f.FieldCode, f.FieldType)
cols = append(cols, colDef)
if f.FieldCode == dateField {
continue
}
cols = append(cols, fmt.Sprintf("%s %s", f.FieldCode, model.FieldTypeToPG(f.FieldType)))
}
// 原始数据
@@ -127,22 +129,6 @@ func (c *StatTableCreator) buildCreateTableSQL(tableName string, report *model.R
return sql
}
// fieldTypeToColumn 字段类型转PG列类型
func (c *StatTableCreator) fieldTypeToColumn(fieldCode, fieldType string) string {
switch fieldType {
case model.FieldTypeInt, model.FieldTypeFloat:
return fmt.Sprintf("%s NUMERIC(20,4) DEFAULT 0", fieldCode)
case model.FieldTypeDate:
return fmt.Sprintf("%s VARCHAR(16) DEFAULT ''", fieldCode)
case model.FieldTypeDatetime:
return fmt.Sprintf("%s TIMESTAMP WITH TIME ZONE", fieldCode)
case model.FieldTypeJsonb:
return fmt.Sprintf("%s JSONB DEFAULT '{}'", fieldCode)
default: // STRING
return fmt.Sprintf("%s VARCHAR(256) DEFAULT ''", fieldCode)
}
}
// DropStatTable 删除统计宽表
func (c *StatTableCreator) DropStatTable(ctx context.Context, businessCode, reportCode string) error {
report, err := c.loader.GetReport(ctx, businessCode, reportCode)
@@ -200,8 +186,7 @@ func (c *StatTableCreator) AlterTableAddColumns(ctx context.Context, tableName s
if existingMap[f.FieldCode] {
continue
}
colDef := c.fieldTypeToColumn(f.FieldCode, f.FieldType)
sql := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS %s", tableName, colDef)
sql := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS %s %s", tableName, f.FieldCode, model.FieldTypeToPG(f.FieldType))
if _, err := gfdb.DB(ctx).Exec(ctx, sql); err != nil {
return fmt.Errorf("添加字段 %s 失败: %w", f.FieldCode, err)
}
+1
View File
@@ -169,6 +169,7 @@ func (e *QueryExecutor) validateReq(req *model.UserSelectQueryReq) error {
"week": true,
"month": true,
"quarter": true,
"year": true,
}
if !validGroups[req.TimeGroup] {
return fmt.Errorf("不支持的时间分组: %s", req.TimeGroup)
+83 -57
View File
@@ -3,6 +3,7 @@ package extract
import (
"context"
"fmt"
"sort"
"strings"
"time"
@@ -91,7 +92,9 @@ func (e *DailyExtractor) ExtractDailyData(ctx context.Context, businessCode, rep
Executor: executor,
StartTime: &start,
}
_ = e.loader.CreateExtractLog(ctx, extractLog)
if err := e.loader.CreateExtractLog(ctx, extractLog); err != nil {
logger.Errorf("创建抽取日志失败: %v", err)
}
// 执行抽取
c, s, f, err := e.executeExtract(ctx, &ec, report, fieldMap, statDate)
@@ -476,7 +479,7 @@ func (e *DailyExtractor) applyTransformRules(ec *model.ExtractConfig, row map[st
}
}
// ensureStatTableExists 确保统计宽表存在
// ensureStatTableExists 确保统计宽表存在,如有新增字段自动加列
func (e *DailyExtractor) ensureStatTableExists(ctx context.Context, report *model.ReportConfig, fieldMap map[string]*model.FieldConfig) error {
tableName := report.StatTableName
@@ -495,10 +498,48 @@ func (e *DailyExtractor) ensureStatTableExists(ctx context.Context, report *mode
return e.createStatTable(ctx, report, fieldMap)
}
// 表已存在,检查并添加新增字段
if err := e.alterTableAddColumns(ctx, tableName, fieldMap); err != nil {
logrus.Warnf("自动加列失败 %s: %v", tableName, err)
// 不返回 error,加列失败不应阻塞抽取
}
logrus.Infof("统计宽表 %s 已存在", tableName)
return nil
}
// alterTableAddColumns 为已存在的统计宽表添加新增字段
func (e *DailyExtractor) alterTableAddColumns(ctx context.Context, tableName string, fieldMap map[string]*model.FieldConfig) error {
// 获取已有列
rows, err := gfdb.DB(ctx).GetAll(ctx,
"SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public'",
strings.ToLower(tableName))
if err != nil {
return fmt.Errorf("查询表结构失败: %w", err)
}
existingCols := make(map[string]bool)
for _, row := range rows.List() {
if col, ok := row["column_name"].(string); ok {
existingCols[col] = true
}
}
for _, fc := range fieldMap {
if existingCols[fc.FieldCode] {
continue
}
colType := model.FieldTypeToPG(fc.FieldType)
sql := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS %s %s", tableName, fc.FieldCode, colType)
if _, err := gfdb.DB(ctx).Exec(ctx, sql); err != nil {
return fmt.Errorf("添加字段 %s 失败: %w", fc.FieldCode, err)
}
logrus.Infof("统计宽表 %s 自动添加字段 %s %s", tableName, fc.FieldCode, colType)
}
return nil
}
// createStatTable 创建统计宽表
func (e *DailyExtractor) createStatTable(ctx context.Context, report *model.ReportConfig, fieldMap map[string]*model.FieldConfig) error {
var cols []string
@@ -523,7 +564,10 @@ func (e *DailyExtractor) createStatTable(ctx context.Context, report *model.Repo
// 业务字段
for _, fc := range fieldMap {
fc := fc
colType := fieldTypeToPG(fc.FieldType)
if fc.FieldCode == dateField {
continue
}
colType := model.FieldTypeToPG(fc.FieldType)
cols = append(cols, fmt.Sprintf("%s %s", fc.FieldCode, colType))
}
@@ -564,23 +608,43 @@ func (e *DailyExtractor) createStatTable(ctx context.Context, report *model.Repo
return nil
}
// batchUpsert 批量upsert写入
// batchUpsert 批量写入统计宽表(使用原始SQL INSERT,避免ORM兼容问题)
func (e *DailyExtractor) batchUpsert(ctx context.Context, tableName string, conflictKeys []string, rows []map[string]interface{}) (int, []string, error) {
if len(rows) == 0 {
return 0, nil, nil
}
now := time.Now()
for i := range rows {
if rows[i] == nil {
rows[i] = make(map[string]interface{})
// 去除 id 和 created_at 列(数据库自增/默认值)
var keys []string
for k := range rows[0] {
if k == "id" || k == "created_at" || k == "updated_at" || k == "deleted_at" {
continue
}
rows[i]["updated_at"] = now
keys = append(keys, k)
}
sort.Strings(keys)
cols := strings.Join(keys, ", ")
placeholders := make([]string, len(keys))
for i := range keys {
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
phStr := strings.Join(placeholders, ", ")
sqlStr := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", tableName, cols, phStr)
if len(conflictKeys) > 0 {
var updateParts []string
for _, k := range keys {
if k == "id" || k == "created_at" {
continue
}
updateParts = append(updateParts, fmt.Sprintf("%s = EXCLUDED.%s", k, k))
}
sqlStr += " ON CONFLICT (" + strings.Join(conflictKeys, ", ") + ") DO UPDATE SET " + strings.Join(updateParts, ", ")
}
batchSize := 100
total := 0
var allColumns []string
for i := 0; i < len(rows); i += batchSize {
end := i + batchSize
@@ -589,56 +653,18 @@ func (e *DailyExtractor) batchUpsert(ctx context.Context, tableName string, conf
}
batch := rows[i:end]
m := gfdb.DB(ctx).Model(ctx, tableName).Data(batch)
if len(conflictKeys) > 0 {
keys := make([]interface{}, len(conflictKeys))
for j, k := range conflictKeys {
keys[j] = k
for _, row := range batch {
var args []interface{}
for _, k := range keys {
args = append(args, row[k])
}
m = m.OnConflict(keys...)
}
_, err := m.Save()
if err != nil {
logrus.Errorf("批量写入 %s 失败: %v", tableName, err)
// 逐条重试
for _, row := range batch {
mm := gfdb.DB(ctx).Model(ctx, tableName).Data(row)
if len(conflictKeys) > 0 {
keys := make([]interface{}, len(conflictKeys))
for j, k := range conflictKeys {
keys[j] = k
}
mm = mm.OnConflict(keys...)
}
if _, e := mm.Save(); e != nil {
logrus.Errorf("逐条写入失败: %v", e)
} else {
total++
}
if _, err := gfdb.DB(ctx).Exec(ctx, sqlStr, args...); err != nil {
logrus.Errorf("逐行写入 %s 失败: %v", tableName, err)
} else {
total++
}
} else {
total += len(batch)
}
}
return total, allColumns, nil
}
// fieldTypeToPG 字段类型转PG类型
func fieldTypeToPG(fieldType string) string {
switch fieldType {
case model.FieldTypeInt:
return "NUMERIC(20,0) DEFAULT 0"
case model.FieldTypeFloat:
return "NUMERIC(20,4) DEFAULT 0"
case model.FieldTypeDate:
return "VARCHAR(16) DEFAULT ''"
case model.FieldTypeDatetime:
return "TIMESTAMP WITH TIME ZONE"
case model.FieldTypeJsonb:
return "JSONB DEFAULT '{}'"
default:
return "VARCHAR(256) DEFAULT ''"
}
return total, nil, nil
}
+259 -189
View File
@@ -13,99 +13,101 @@ import (
// BusinessConfig 业务配置
type BusinessConfig struct {
beans.SQLBaseDO `orm:",inherit"`
BusinessCode string `orm:"business_code" json:"businessCode"`
BusinessName string `orm:"business_name" json:"businessName"`
Description string `orm:"description" json:"description"`
Status string `orm:"status" json:"status"`
Config map[string]interface{} `orm:"config" json:"config"`
BusinessCode string `orm:"business_code" json:"businessCode" dc:"业务编码" eg:"KUAISHOU"`
BusinessName string `orm:"business_name" json:"businessName" dc:"业务名称" eg:"快手电商"`
Description string `orm:"description" json:"description" dc:"描述" eg:"快手电商店铺每日统计报表"`
Status string `orm:"status" json:"status" dc:"status" eg:"ACTIVE"`
Config map[string]interface{} `orm:"config" json:"config" dc:"扩展配置"`
}
// ReportConfig 报表配置
type ReportConfig struct {
beans.SQLBaseDO `orm:",inherit"`
BusinessCode string `orm:"business_code" json:"businessCode"`
ReportCode string `orm:"report_code" json:"reportCode"`
ReportName string `orm:"report_name" json:"reportName"`
Description string `orm:"description" json:"description"`
Status string `orm:"status" json:"status"`
StatTableName string `orm:"stat_table_name" json:"statTableName"`
StatTableComment string `orm:"stat_table_comment" json:"statTableComment"`
DateField string `orm:"date_field" json:"dateField"`
PrimaryKeys []string `orm:"primary_keys" json:"primaryKeys"`
ConflictKeys []string `orm:"conflict_keys" json:"conflictKeys"`
Config map[string]interface{} `orm:"config" json:"config"`
BusinessCode string `orm:"business_code" json:"businessCode" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `orm:"report_code" json:"reportCode" dc:"报表编码" eg:"shop_daily"`
ReportName string `orm:"report_name" json:"reportName" dc:"报表名称" eg:"店铺日报"`
Description string `orm:"description" json:"description" dc:"描述" eg:"快手电商店铺每日统计报表"`
Status string `orm:"status" json:"status" dc:"状态 ACTIVE/INACTIVE" eg:"ACTIVE"`
StatTableName string `orm:"stat_table_name" json:"statTableName" dc:"统计宽表名" eg:"stat_kuaishou_shop_daily"`
StatTableComment string `orm:"stat_table_comment" json:"statTableComment" dc:"统计宽表注释" eg:"快手电商-店铺日报"`
DateField string `orm:"date_field" json:"dateField" dc:"日期字段名" eg:"stat_date"`
PrimaryKeys []string `orm:"primary_keys" json:"primaryKeys" dc:"主键字段列表"`
ConflictKeys []string `orm:"conflict_keys" json:"conflictKeys" dc:"冲突键(唯一索引)"`
Config map[string]interface{} `orm:"config" json:"config" dc:"扩展配置"`
ExtractConfigIDs string `orm:"-" json:"extractConfigIds" dc:"抽取配置ID列表,逗号分隔" eg:"1,2,3"`
Fields []FieldConfig `orm:"-" json:"fields,omitempty" dc:"字段配置列表(查询时返回)"`
}
// FieldConfig 字段配置
type FieldConfig struct {
beans.SQLBaseDO `orm:",inherit"`
BusinessCode string `orm:"business_code" json:"businessCode"`
ReportCode string `orm:"report_code" json:"reportCode"`
FieldCode string `orm:"field_code" json:"fieldCode"`
FieldName string `orm:"field_name" json:"fieldName"`
FieldType string `orm:"field_type" json:"fieldType"`
DataType string `orm:"data_type" json:"dataType"`
FieldRole string `orm:"field_role" json:"fieldRole"`
IsAggregatable bool `orm:"is_aggregatable" json:"isAggregatable"`
IsFilterable bool `orm:"is_filterable" json:"isFilterable"`
IsQueryable bool `orm:"is_queryable" json:"isQueryable"`
IsSortable bool `orm:"is_sortable" json:"isSortable"`
DefaultAggregate string `orm:"default_aggregate" json:"defaultAggregate"`
ValidAggregates []string `orm:"valid_aggregates" json:"validAggregates"`
FilterOperators []string `orm:"filter_operators" json:"filterOperators"`
Expression string `orm:"expression" json:"expression"`
ExpressionType string `orm:"expression_type" json:"expressionType"`
FormatPattern string `orm:"format_pattern" json:"formatPattern"`
Unit string `orm:"unit" json:"unit"`
DictCode string `orm:"dict_code" json:"dictCode"`
SortOrder int `orm:"sort_order" json:"sortOrder"`
GroupName string `orm:"group_name" json:"groupName"`
Status string `orm:"status" json:"status"`
BusinessCode string `orm:"business_code" json:"businessCode" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `orm:"report_code" json:"reportCode" dc:"reportCode" eg:"shop_daily"`
FieldCode string `orm:"field_code" json:"fieldCode" dc:"字段编码" eg:"order_amount"`
FieldName string `orm:"field_name" json:"fieldName" dc:"字段名称" eg:"订单金额"`
FieldType string `orm:"field_type" json:"fieldType" dc:"字段类型 STRING/INT/FLOAT/DATE/DATETIME/JSONB" eg:"FLOAT"`
DataType string `orm:"data_type" json:"dataType" dc:"数据存储类型" eg:"STRING"`
FieldRole string `orm:"field_role" json:"fieldRole" dc:"字段角色 DIMENSION(维度)/INDICATOR(指标)/FILTER(筛选)/FILTER_ONLY(仅筛选)" eg:"DIMENSION"`
IsAggregatable bool `orm:"is_aggregatable" json:"isAggregatable" dc:"是否可聚合" eg:"true"`
IsFilterable bool `orm:"is_filterable" json:"isFilterable" dc:"是否可筛选" eg:"true"`
IsQueryable bool `orm:"is_queryable" json:"isQueryable" dc:"是否可查询" eg:"true"`
IsSortable bool `orm:"is_sortable" json:"isSortable" dc:"是否可排序" eg:"true"`
DefaultAggregate string `orm:"default_aggregate" json:"defaultAggregate" dc:"默认聚合方式 SUM/COUNT/AVG/MAX/MIN" eg:"SUM"`
ValidAggregates []string `orm:"valid_aggregates" json:"validAggregates" dc:"可选聚合列表"`
FilterOperators []string `orm:"filter_operators" json:"filterOperators" dc:"可选操作符列表 =/!=/>/</>=/<=/IN/LIKE/BETWEEN"`
Expression string `orm:"expression" json:"expression" dc:"表达式(衍生字段)" eg:"order_amount - refund_amount"`
ExpressionType string `orm:"expression_type" json:"expressionType" dc:"表达式类型 DIRECT/CALCULATED" eg:"DIRECT"`
FormatPattern string `orm:"format_pattern" json:"formatPattern" dc:"格式化模板" eg:"#,##0.00"`
Unit string `orm:"unit" json:"unit" dc:"单位"`
DictCode string `orm:"dict_code" json:"dictCode" dc:"字典编码" eg:"dict_order_type"`
SortOrder int `orm:"sort_order" json:"sortOrder" dc:"排序号" eg:"1"`
GroupName string `orm:"group_name" json:"groupName" dc:"分组名称" eg:"交易指标"`
Status string `orm:"status" json:"status" dc:"status" eg:"ACTIVE"`
}
// ExtractConfig 抽取配置
type ExtractConfig struct {
beans.SQLBaseDO `orm:",inherit"`
BusinessCode string `orm:"business_code" json:"businessCode"`
ReportCode string `orm:"report_code" json:"reportCode"`
ExtractCode string `orm:"extract_code" json:"extractCode"`
ExtractName string `orm:"extract_name" json:"extractName"`
SourceTableName string `orm:"source_table_name" json:"sourceTableName"`
SourceTableAlias string `orm:"source_table_alias" json:"sourceTableAlias"`
TargetTableName string `orm:"target_table_name" json:"targetTableName"`
IsEnabled bool `orm:"is_enabled" json:"isEnabled"`
ExtractType string `orm:"extract_type" json:"extractType"`
ExtractMode string `orm:"extract_mode" json:"extractMode"`
ExtractKeyField string `orm:"extract_key_field" json:"extractKeyField"`
ExtractKeyFormat string `orm:"extract_key_format" json:"extractKeyFormat"`
GroupByFields []string `orm:"group_by_fields" json:"groupByFields"`
FilterExpression string `orm:"filter_expression" json:"filterExpression"`
JoinConfigs []JoinConfig `orm:"join_configs" json:"joinConfigs"`
FieldMappings []FieldMapping `orm:"field_mappings" json:"fieldMappings"`
TransformRules []TransformRule `orm:"transform_rules" json:"transformRules"`
BatchSize int `orm:"batch_size" json:"batchSize"`
Status string `orm:"status" json:"status"`
BusinessCode string `orm:"business_code" json:"businessCode" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `orm:"report_code" json:"reportCode" dc:"reportCode" eg:"shop_daily"`
ExtractCode string `orm:"extract_code" json:"extractCode" dc:"抽取编码" eg:"kuaishou_order_extract"`
ExtractName string `orm:"extract_name" json:"extractName" dc:"抽取名称" eg:"订单抽取"`
SourceTableName string `orm:"source_table_name" json:"sourceTableName" dc:"源表名" eg:"ods_kuaishou_order"`
SourceTableAlias string `orm:"source_table_alias" json:"sourceTableAlias" dc:"源表别名" eg:"o"`
TargetTableName string `orm:"target_table_name" json:"targetTableName" dc:"目标表名" eg:"stat_kuaishou_shop_daily"`
IsEnabled bool `orm:"is_enabled" json:"isEnabled" dc:"是否启用" eg:"true"`
ExtractType string `orm:"extract_type" json:"extractType" dc:"抽取类型 FULL/INCREMENTAL" eg:"INCREMENTAL"`
ExtractMode string `orm:"extract_mode" json:"extractMode" dc:"抽取模式 DIRECT(逐行)/AGGREGATE(聚合)" eg:"DIRECT"`
ExtractKeyField string `orm:"extract_key_field" json:"extractKeyField" dc:"抽取关键字段(增量依据)" eg:"updated_at"`
ExtractKeyFormat string `orm:"extract_key_format" json:"extractKeyFormat" dc:"关键字段格式" eg:"yyyy-MM-dd HH:mm:ss"`
GroupByFields []string `orm:"group_by_fields" json:"groupByFields" dc:"GROUP BY 字段列表"`
FilterExpression string `orm:"filter_expression" json:"filterExpression" dc:"过滤表达式" eg:"status = 'PAID'"`
JoinConfigs []JoinConfig `orm:"join_configs" json:"joinConfigs" dc:"JOIN配置"`
FieldMappings []FieldMapping `orm:"field_mappings" json:"fieldMappings" dc:"字段映射列表"`
TransformRules []TransformRule `orm:"transform_rules" json:"transformRules" dc:"转换规则列表"`
BatchSize int `orm:"batch_size" json:"batchSize" dc:"批处理大小" eg:"1000"`
Status string `orm:"status" json:"status" dc:"status" eg:"ACTIVE"`
}
// ExtractLog 抽取记录(不含 Creator/Updater/DeletedAt,表结构不同)
type ExtractLog struct {
ID int64 `orm:"id" json:"id"`
TenantId uint64 `orm:"tenant_id" json:"tenantId"`
BusinessCode string `orm:"business_code" json:"businessCode"`
ReportCode string `orm:"report_code" json:"reportCode"`
ExtractCode string `orm:"extract_code" json:"extractCode"`
StatDate string `orm:"stat_date" json:"statDate"`
ExtractType string `orm:"extract_type" json:"extractType"`
Status string `orm:"status" json:"status"`
TotalCount int `orm:"total_count" json:"totalCount"`
SuccessCount int `orm:"success_count" json:"successCount"`
FailCount int `orm:"fail_count" json:"failCount"`
StartTime *time.Time `orm:"start_time" json:"startTime"`
EndTime *time.Time `orm:"end_time" json:"endTime"`
ErrorMessage string `orm:"error_message" json:"errorMessage"`
Executor string `orm:"executor" json:"executor"`
CreatedAt *time.Time `orm:"created_at" json:"createdAt"`
UpdatedAt *time.Time `orm:"updated_at" json:"updatedAt"`
ID int64 `orm:"id" json:"id" dc:"主键ID" eg:"1"`
TenantId uint64 `orm:"tenant_id" json:"tenantId" dc:"租户ID" eg:"1"`
BusinessCode string `orm:"business_code" json:"businessCode" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `orm:"report_code" json:"reportCode" dc:"reportCode" eg:"shop_daily"`
ExtractCode string `orm:"extract_code" json:"extractCode" dc:"抽取编码" eg:"kuaishou_order_extract"`
StatDate string `orm:"stat_date" json:"statDate" dc:"统计日期" eg:"2026-06-01"`
ExtractType string `orm:"extract_type" json:"extractType" dc:"抽取类型 FULL/INCREMENTAL" eg:"INCREMENTAL"`
Status string `orm:"status" json:"status" dc:"status" eg:"ACTIVE"`
TotalCount int `orm:"total_count" json:"totalCount" dc:"总记录数" eg:"100"`
SuccessCount int `orm:"success_count" json:"successCount" dc:"成功记录数" eg:"98"`
FailCount int `orm:"fail_count" json:"failCount" dc:"失败记录数" eg:"2"`
StartTime *time.Time `orm:"start_time" json:"startTime" dc:"开始时间"`
EndTime *time.Time `orm:"end_time" json:"endTime" dc:"结束时间"`
ErrorMessage string `orm:"error_message" json:"errorMessage" dc:"错误信息"`
Executor string `orm:"executor" json:"executor" dc:"执行人" eg:"system"`
CreatedAt *time.Time `orm:"created_at" json:"createdAt" dc:"创建时间"`
UpdatedAt *time.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"`
}
// ============================================================
@@ -114,30 +116,30 @@ type ExtractLog struct {
// JoinConfig 关联配置
type JoinConfig struct {
JoinTable string `json:"joinTable"`
JoinAlias string `json:"joinAlias"`
JoinType string `json:"joinType"` // LEFT/RIGHT/INNER
JoinCondition string `json:"joinCondition"`
FieldMappings []FieldMapping `json:"fieldMappings"`
JoinTable string `json:"joinTable" dc:"关联表名" eg:"ods_kuaishou_shop"`
JoinAlias string `json:"joinAlias" dc:"关联表别名" eg:"s"`
JoinType string `json:"joinType" dc:"LEFT/RIGHT/INNER" eg:"LEFT"`
JoinCondition string `json:"joinCondition" dc:"关联条件" eg:"o.shop_id = s.shop_id"`
FieldMappings []FieldMapping `json:"fieldMappings" dc:"字段映射列表"`
}
// FieldMapping 字段映射
type FieldMapping struct {
SourceField string `json:"sourceField"`
TargetField string `json:"targetField"`
FieldType string `json:"fieldType"`
AggregateFunction string `json:"aggregateFunction"`
DefaultValue interface{} `json:"defaultValue"`
TransformRule *TransformRule `json:"transformRule,omitempty"`
SourceField string `json:"sourceField" dc:"源字段"`
TargetField string `json:"targetField" dc:"目标字段"`
FieldType string `json:"fieldType" dc:"字段类型 STRING/INT/FLOAT/DATE/DATETIME/JSONB" eg:"FLOAT"`
AggregateFunction string `json:"aggregateFunction" dc:"聚合函数 SUM/COUNT/AVG/MAX/MIN"`
DefaultValue interface{} `json:"defaultValue" dc:"默认值"`
TransformRule *TransformRule `json:"transformRule,omitempty" dc:"transformRule"`
}
// TransformRule 转换规则
type TransformRule struct {
RuleCode string `json:"ruleCode"`
RuleType string `json:"ruleType"` // DIRECT/MAPPING/FORMAT/CALCULATE
Expression string `json:"expression"`
Format string `json:"format"`
Mapping map[string]interface{} `json:"mapping"`
RuleCode string `json:"ruleCode" dc:"规则编码" eg:"AMOUNT_CONVERT"`
RuleType string `json:"ruleType" dc:"DIRECT/MAPPING/FORMAT/CALCULATE" eg:"FORMAT"`
Expression string `json:"expression" dc:"表达式(衍生字段)" eg:"order_amount - refund_amount"`
Format string `json:"format" dc:"format" eg:"%Y-%m-%d"`
Mapping map[string]interface{} `json:"mapping" dc:"映射表(MAPPING类型时使用)"`
}
// ============================================================
@@ -146,205 +148,255 @@ type TransformRule struct {
// UserSelectQueryReq 用户选择查询请求
type UserSelectQueryReq struct {
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码" eg:"shop_daily"`
Dimensions []string `json:"dimensions" dc:"统计维度列表,如 shop_id/anchor_id/date"`
Indicators []IndicatorSelect `json:"indicators" dc:"统计指标列表(含聚合方式)"`
Filters []FilterCondition `json:"filters" dc:"筛选条件列表"`
TimeRange *TimeRange `json:"timeRange" dc:"时间范围"`
TimeGroup string `json:"timeGroup" dc:"时间分组: day/week/month/quarter"`
TimeGroup string `json:"timeGroup" dc:"时间分组: day/week/month/quarter" eg:"day"`
OrderBy []OrderCondition `json:"orderBy" dc:"排序条件"`
Page int `json:"page" dc:"页码" d:"1"`
PageSize int `json:"pageSize" dc:"每页条数" d:"20"`
Page int `json:"page" dc:"页码" d:"1" eg:"1"`
PageSize int `json:"pageSize" dc:"每页条数" d:"20" eg:"20"`
}
// IndicatorSelect 指标选择
type IndicatorSelect struct {
FieldCode string `json:"fieldCode" dc:"字段编码"`
Aggregate string `json:"aggregate" dc:"聚合方式: SUM/COUNT/AVG/MAX/MIN"`
Alias string `json:"alias" dc:"别名"`
FieldCode string `json:"fieldCode" dc:"字段编码" eg:"order_amount"`
Aggregate string `json:"aggregate" dc:"聚合方式: SUM/COUNT/AVG/MAX/MIN" eg:"SUM"`
Alias string `json:"alias" dc:"别名" eg:"总订单金额"`
}
// FilterCondition 筛选条件
type FilterCondition struct {
FieldCode string `json:"fieldCode" dc:"字段编码"`
Operator string `json:"operator" dc:"操作符: =/!=/>/</>=/<=/IN/LIKE/BETWEEN"`
FieldCode string `json:"fieldCode" dc:"字段编码" eg:"order_amount"`
Operator string `json:"operator" dc:"操作符: =/!=/>/</>=/<=/IN/LIKE/BETWEEN" eg:"="`
Value interface{} `json:"value" dc:"值"`
Value2 interface{} `json:"value2" dc:"第二个值(BETWEEN时使用)"`
}
// TimeRange 时间范围
type TimeRange struct {
StartDate string `json:"startDate" dc:"开始日期 yyyy-MM-dd"`
EndDate string `json:"endDate" dc:"结束日期 yyyy-MM-dd"`
StartDate string `json:"startDate" dc:"开始日期 yyyy-MM-dd" eg:"2026-06-01"`
EndDate string `json:"endDate" dc:"结束日期 yyyy-MM-dd" eg:"2026-06-30"`
}
// OrderCondition 排序条件
type OrderCondition struct {
FieldCode string `json:"fieldCode" dc:"字段编码"`
Direction string `json:"direction" dc:"排序方向: ASC/DESC"`
FieldCode string `json:"fieldCode" dc:"字段编码" eg:"order_amount"`
Direction string `json:"direction" dc:"排序方向: ASC/DESC" eg:"DESC"`
}
// UserSelectQueryResp 用户选择查询响应
type UserSelectQueryResp struct {
List []map[string]interface{} `json:"list" dc:"数据列表"`
Total int64 `json:"total" dc:"总数"`
Page int `json:"page" dc:"当前页"`
PageSize int `json:"pageSize" dc:"每页条数"`
TotalPages int `json:"totalPages" dc:"总页数"`
Sql string `json:"sql,omitempty" dc:"执行的SQL(调试用)"`
ExecTimeMs int64 `json:"execTimeMs" dc:"执行耗时(毫秒)"`
Total int64 `json:"total" dc:"总数" eg:"100"`
Page int `json:"page" dc:"当前页" eg:"1"`
PageSize int `json:"pageSize" dc:"每页条数" eg:"20"`
TotalPages int `json:"totalPages" dc:"总页数" eg:"5"`
Sql string `json:"sql,omitempty" dc:"执行的SQL(调试用)" eg:"SELECT 1"`
ExecTimeMs int64 `json:"execTimeMs" dc:"执行耗时(毫秒)" eg:"1234"`
}
// ExtractDailyDataReq 按天抽取数据请求
type ExtractDailyDataReq struct {
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码"`
StatDate string `json:"statDate" v:"required" dc:"统计日期 yyyy-MM-dd"`
Executor string `json:"executor" dc:"执行人"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码" eg:"shop_daily"`
StatDate string `json:"statDate" v:"required" dc:"统计日期 yyyy-MM-dd" eg:"2026-06-01"`
Executor string `json:"executor" dc:"执行人" eg:"system"`
}
// ExtractDailyDataResp 按天抽取数据响应
type ExtractDailyDataResp struct {
Success bool `json:"success" dc:"是否成功"`
TotalCount int `json:"totalCount" dc:"总记录数"`
SuccessCount int `json:"successCount" dc:"成功记录数"`
FailCount int `json:"failCount" dc:"失败记录数"`
ExecTimeMs int64 `json:"execTimeMs" dc:"执行耗时(毫秒)"`
Success bool `json:"success" dc:"是否成功" eg:"true"`
TotalCount int `json:"totalCount" dc:"总记录数" eg:"100"`
SuccessCount int `json:"successCount" dc:"成功记录数" eg:"98"`
FailCount int `json:"failCount" dc:"失败记录数" eg:"2"`
ExecTimeMs int64 `json:"execTimeMs" dc:"执行耗时(毫秒)" eg:"1234"`
ErrorMsg string `json:"errorMsg" dc:"错误信息"`
}
// AutoCreateStatTableReq 自动创建统计宽表请求
type AutoCreateStatTableReq struct {
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码"`
Creator string `json:"creator" dc:"创建人"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码" eg:"shop_daily"`
Creator string `json:"creator" dc:"创建人" eg:"admin"`
}
// AutoCreateStatTableResp 自动创建统计宽表响应
type AutoCreateStatTableResp struct {
Success bool `json:"success" dc:"是否成功"`
TableName string `json:"tableName" dc:"创建的表名"`
ColumnCount int `json:"columnCount" dc:"字段数量"`
ExecTimeMs int64 `json:"execTimeMs" dc:"执行耗时(毫秒)"`
Success bool `json:"success" dc:"是否成功" eg:"true"`
TableName string `json:"tableName" dc:"创建的表名" eg:"stat_kuaishou_shop_daily"`
ColumnCount int `json:"columnCount" dc:"字段数量" eg:"15"`
ExecTimeMs int64 `json:"execTimeMs" dc:"执行耗时(毫秒)" eg:"1234"`
}
// GetReportFieldsResp 获取报表可用字段响应
type GetReportFieldsResp struct {
BusinessCode string `json:"businessCode" dc:"业务编码"`
ReportCode string `json:"reportCode" dc:"报表编码"`
BusinessCode string `json:"businessCode" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" dc:"报表编码" eg:"shop_daily"`
Dimensions []FieldConfig `json:"dimensions" dc:"维度字段列表"`
Indicators []FieldConfig `json:"indicators" dc:"指标字段列表"`
Filters []FieldConfig `json:"filters" dc:"筛选字段列表"`
}
// BackfillReq 批量回填请求
type BackfillReq struct {
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码" eg:"shop_daily"`
StartDate string `json:"startDate" v:"required" dc:"开始日期 yyyy-MM-dd" eg:"2026-06-01"`
EndDate string `json:"endDate" v:"required" dc:"结束日期 yyyy-MM-dd" eg:"2026-06-30"`
Executor string `json:"executor" dc:"执行人" eg:"admin"`
}
// BackfillResp 批量回填响应
type BackfillResp struct {
Success bool `json:"success" dc:"是否全部成功" eg:"true"`
TotalDays int `json:"totalDays" dc:"总天数" eg:"30"`
SuccessDays int `json:"successDays" dc:"成功天数" eg:"28"`
FailDays int `json:"failDays" dc:"失败天数" eg:"2"`
ExecTimeMs int64 `json:"execTimeMs" dc:"总耗时(毫秒)" eg:"5678"`
ErrorMsg string `json:"errorMsg" dc:"错误信息"`
}
// ============================================================
// 配置 CRUD 请求/响应
// ============================================================
// SaveBusinessReq 保存业务配置请求(新增/修改合一)
type SaveBusinessReq struct {
ID *int64 `json:"id"` // 有值为更新,无值为新增
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码"`
BusinessName string `json:"businessName" v:"required" dc:"业务名称"`
Description string `json:"description" dc:"描述"`
Status string `json:"status" dc:"状态 ACTIVE/INACTIVE" d:"ACTIVE"`
ID *int64 `json:"id" dc:"有值为更新,无值为新增" eg:"1"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码" eg:"KUAISHOU"`
BusinessName string `json:"businessName" v:"required" dc:"业务名称" eg:"快手电商"`
Description string `json:"description" dc:"描述" eg:"快手电商店铺每日统计报表"`
Status string `json:"status" dc:"状态 ACTIVE/INACTIVE" d:"ACTIVE" eg:"ACTIVE"`
Config map[string]interface{} `json:"config" dc:"扩展配置"`
Operator string `json:"operator" dc:"操作人"`
Operator string `json:"operator" dc:"操作人" eg:"="`
}
// SaveReportReq 保存报表配置请求
type SaveReportReq struct {
ID *int64 `json:"id"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码"`
ReportName string `json:"reportName" v:"required" dc:"报表名称"`
Description string `json:"description" dc:"描述"`
Status string `json:"status" dc:"状态" d:"ACTIVE"`
ID *int64 `json:"id" dc:"主键ID" eg:"1"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码" eg:"shop_daily"`
ReportName string `json:"reportName" v:"required" dc:"报表名称" eg:"店铺日报"`
Description string `json:"description" dc:"描述" eg:"快手电商店铺每日统计报表"`
Status string `json:"status" dc:"状态" d:"ACTIVE" eg:"ACTIVE"`
StatTableName string `json:"statTableName" v:"required" dc:"统计宽表名"`
StatTableComment string `json:"statTableComment" dc:"统计宽表注释"`
DateField string `json:"dateField" dc:"日期字段" d:"stat_date"`
DateField string `json:"dateField" dc:"日期字段" d:"stat_date" eg:"stat_date"`
PrimaryKeys []string `json:"primaryKeys" dc:"主键字段"`
ConflictKeys []string `json:"conflictKeys" dc:"冲突键(唯一索引)"`
Config map[string]interface{} `json:"config" dc:"扩展配置"`
Operator string `json:"operator" dc:"操作人"`
Operator string `json:"operator" dc:"操作人" eg:"="`
}
// SaveFieldReq 保存字段配置请求
type SaveFieldReq struct {
ID *int64 `json:"id"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码"`
FieldCode string `json:"fieldCode" v:"required" dc:"字段编码"`
FieldName string `json:"fieldName" v:"required" dc:"字段名称"`
FieldType string `json:"fieldType" v:"required" dc:"字段类型 STRING/INT/FLOAT/DATE/DATETIME/JSONB"`
DataType string `json:"dataType" dc:"数据存储类型" d:"STRING"`
FieldRole string `json:"fieldRole" v:"required" dc:"字段角色 DIMENSION/INDICATOR/FILTER/FILTER_ONLY"`
IsAggregatable bool `json:"isAggregatable" dc:"是否可聚合"`
IsFilterable bool `json:"isFilterable" dc:"是否可筛选" d:"true"`
IsQueryable bool `json:"isQueryable" dc:"是否可查询" d:"true"`
IsSortable bool `json:"isSortable" dc:"是否可排序" d:"true"`
DefaultAggregate string `json:"defaultAggregate" dc:"默认聚合方式"`
ID *int64 `json:"id" dc:"主键ID" eg:"1"`
BusinessCode string `json:"businessCode" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" dc:"报表编码" eg:"shop_daily"`
FieldCode string `json:"fieldCode" v:"required" dc:"字段编码" eg:"order_amount"`
FieldName string `json:"fieldName" v:"required" dc:"字段名称" eg:"订单金额"`
FieldType string `json:"fieldType" v:"required" dc:"字段类型 STRING/INT/FLOAT/DATE/DATETIME/JSONB" eg:"FLOAT"`
DataType string `json:"dataType" dc:"数据存储类型" d:"STRING" eg:"STRING"`
FieldRole string `json:"fieldRole" v:"required" dc:"字段角色 DIMENSION/INDICATOR/FILTER/FILTER_ONLY" eg:"DIMENSION"`
IsAggregatable bool `json:"isAggregatable" dc:"是否可聚合" eg:"true"`
IsFilterable bool `json:"isFilterable" dc:"是否可筛选" d:"true" eg:"true"`
IsQueryable bool `json:"isQueryable" dc:"是否可查询" d:"true" eg:"true"`
IsSortable bool `json:"isSortable" dc:"是否可排序" d:"true" eg:"true"`
DefaultAggregate string `json:"defaultAggregate" dc:"默认聚合方式" eg:"SUM"`
ValidAggregates []string `json:"validAggregates" dc:"可选聚合列表"`
FilterOperators []string `json:"filterOperators" dc:"可选操作符列表"`
Expression string `json:"expression" dc:"表达式(衍生字段)"`
ExpressionType string `json:"expressionType" dc:"表达式类型 DIRECT/CALCULATED"`
FormatPattern string `json:"formatPattern" dc:"格式化模板"`
Unit string `json:"unit" dc:"单位"`
DictCode string `json:"dictCode" dc:"字典编码"`
SortOrder int `json:"sortOrder" dc:"排序"`
GroupName string `json:"groupName" dc:"分组名称"`
Status string `json:"status" dc:"状态" d:"ACTIVE"`
Operator string `json:"operator" dc:"操作人"`
Expression string `json:"expression" dc:"表达式(衍生字段)" eg:"order_amount - refund_amount"`
ExpressionType string `json:"expressionType" dc:"表达式类型 DIRECT/CALCULATED" eg:"DIRECT"`
FormatPattern string `json:"formatPattern" dc:"格式化模板" eg:"#,##0.00"`
Unit string `json:"unit" dc:"单位" eg:"元"`
DictCode string `json:"dictCode" dc:"字典编码" eg:"dict_order_type"`
SortOrder int `json:"sortOrder" dc:"排序" eg:"1"`
GroupName string `json:"groupName" dc:"分组名称" eg:"交易指标"`
Status string `json:"status" dc:"状态" d:"ACTIVE" eg:"ACTIVE"`
Operator string `json:"operator" dc:"操作人" eg:"admin"`
}
// SaveReportWithFieldsReq 保存报表及字段配置请求(字段全量替换)
type SaveReportWithFieldsReq struct {
ID *int64 `json:"id" dc:"主键ID(更新时必填,新建为空)"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码" eg:"shop_daily"`
ReportName string `json:"reportName" v:"required" dc:"报表名称" eg:"店铺日报"`
Description string `json:"description" dc:"描述" eg:"快手电商店铺每日统计报表"`
Status string `json:"status" dc:"状态" d:"ACTIVE" eg:"ACTIVE"`
StatTableName string `json:"statTableName" v:"required" dc:"统计宽表名" eg:"stat_kuaishou_shop_daily"`
StatTableComment string `json:"statTableComment" dc:"统计宽表注释" eg:"快手电商-店铺日报"`
DateField string `json:"dateField" dc:"日期字段" d:"stat_date" eg:"stat_date"`
PrimaryKeys []string `json:"primaryKeys" dc:"主键字段"`
ConflictKeys []string `json:"conflictKeys" dc:"冲突键(唯一索引)"`
Config map[string]interface{} `json:"config" dc:"扩展配置"`
Fields []SaveFieldReq `json:"fields" dc:"字段配置列表(全量替换,不在列表中的字段将被删除)"`
}
// SaveExtractConfigReq 保存抽取配置请求
type SaveExtractConfigReq struct {
ID *int64 `json:"id"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码"`
ExtractCode string `json:"extractCode" v:"required" dc:"抽取编码"`
ExtractName string `json:"extractName" v:"required" dc:"抽取名称"`
SourceTableName string `json:"sourceTableName" v:"required" dc:"源表名"`
SourceTableAlias string `json:"sourceTableAlias" dc:"源表别名"`
TargetTableName string `json:"targetTableName" v:"required" dc:"目标表名"`
IsEnabled bool `json:"isEnabled" dc:"是否启用" d:"true"`
ExtractType string `json:"extractType" dc:"抽取类型 FULL/INCREMENTAL" d:"INCREMENTAL"`
ExtractMode string `json:"extractMode" dc:"抽取模式 DIRECT/AGGREGATE" d:"DIRECT"`
ExtractKeyField string `json:"extractKeyField" dc:"抽取关键字段(增量依据)"`
ExtractKeyFormat string `json:"extractKeyFormat" dc:"关键字段格式"`
ID *int64 `json:"id" dc:"主键ID" eg:"1"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码" eg:"shop_daily"`
ExtractCode string `json:"extractCode" v:"required" dc:"抽取编码" eg:"kuaishou_order_extract"`
ExtractName string `json:"extractName" v:"required" dc:"抽取名称" eg:"订单抽取"`
SourceTableName string `json:"sourceTableName" v:"required" dc:"源表名" eg:"ods_kuaishou_order"`
SourceTableAlias string `json:"sourceTableAlias" dc:"源表别名" eg:"o"`
TargetTableName string `json:"targetTableName" v:"required" dc:"目标表名" eg:"stat_kuaishou_shop_daily"`
IsEnabled bool `json:"isEnabled" dc:"是否启用" d:"true" eg:"true"`
ExtractType string `json:"extractType" dc:"抽取类型 FULL/INCREMENTAL" d:"INCREMENTAL" eg:"INCREMENTAL"`
ExtractMode string `json:"extractMode" dc:"抽取模式 DIRECT/AGGREGATE" d:"DIRECT" eg:"DIRECT"`
ExtractKeyField string `json:"extractKeyField" dc:"抽取关键字段(增量依据)" eg:"updated_at"`
ExtractKeyFormat string `json:"extractKeyFormat" dc:"关键字段格式" eg:"yyyy-MM-dd HH:mm:ss"`
GroupByFields []string `json:"groupByFields" dc:"GROUP BY 字段列表"`
FilterExpression string `json:"filterExpression" dc:"过滤表达式"`
FilterExpression string `json:"filterExpression" dc:"过滤表达式" eg:"status = 'PAID'"`
JoinConfigs []JoinConfig `json:"joinConfigs" dc:"JOIN配置"`
FieldMappings []FieldMapping `json:"fieldMappings" dc:"字段映射列表"`
TransformRules []TransformRule `json:"transformRules" dc:"转换规则列表"`
BatchSize int `json:"batchSize" dc:"批处理大小" d:"1000"`
Status string `json:"status" dc:"状态" d:"ACTIVE"`
Operator string `json:"operator" dc:"操作人"`
BatchSize int `json:"batchSize" dc:"批处理大小" d:"1000" eg:"1000"`
Status string `json:"status" dc:"状态" d:"ACTIVE" eg:"ACTIVE"`
Operator string `json:"operator" dc:"操作人" eg:"="`
}
// PageReq 分页请求参数
type PageReq struct {
PageNum int `json:"pageNum" d:"1" dc:"页码"`
PageSize int `json:"pageSize" d:"20" dc:"每页条数"`
}
// PageRes 分页响应
type PageRes struct {
Total int `json:"total" dc:"总数"`
Page int `json:"page" dc:"当前页"`
PageSize int `json:"pageSize" dc:"每页条数"`
TotalPages int `json:"totalPages" dc:"总页数"`
}
// IdReq 通用 ID 请求
type IdReq struct {
ID int64 `json:"id" v:"required" dc:"主键ID"`
ID int64 `json:"id" v:"required" dc:"主键ID" eg:"1"`
}
// SaveResult 写操作通用返回
type SaveResult struct {
Success bool `json:"success"`
ID int64 `json:"id"`
Message string `json:"message"`
Success bool `json:"success" dc:"是否成功" eg:"true"`
ID int64 `json:"id" dc:"主键ID" eg:"1"`
Message string `json:"message" dc:"结果消息" eg:"操作成功"`
}
// DeleteResult 删除结果
type DeleteResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Success bool `json:"success" dc:"是否成功" eg:"true"`
Message string `json:"message" dc:"结果消息" eg:"操作成功"`
}
// GetExtractConfigsReq 获取抽取配置列表请求
type GetExtractConfigsReq struct {
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码"`
BusinessCode string `json:"businessCode" v:"required" dc:"业务编码" eg:"KUAISHOU"`
ReportCode string `json:"reportCode" v:"required" dc:"报表编码" eg:"shop_daily"`
}
// ============================================================
@@ -401,3 +453,21 @@ const (
ExtractStatusSuccess = "SUCCESS"
ExtractStatusFailed = "FAILED"
)
// FieldTypeToPG 字段类型转PG列类型定义
func FieldTypeToPG(fieldType string) string {
switch fieldType {
case FieldTypeInt:
return "NUMERIC(20,0) DEFAULT 0"
case FieldTypeFloat:
return "NUMERIC(20,4) DEFAULT 0"
case FieldTypeDate:
return "VARCHAR(16) DEFAULT ''"
case FieldTypeDatetime:
return "TIMESTAMP WITH TIME ZONE"
case FieldTypeJsonb:
return "JSONB DEFAULT '{}'"
default:
return "VARCHAR(256) DEFAULT ''"
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ sync:
retry_count: 3 # 最大重试次数
sync_interval_minutes: 60 # 自动同步间隔(分钟)
compensation_interval_seconds: 300 # 补偿调度器扫描间隔(秒)
auto_sync_enabled: true # 是否启用自动同步
auto_sync_enabled: false # 是否启用自动同步 
sync_timeout_minutes: 120 # 单次同步超时(分钟),全量超大表可适当调大
default_lookback_days: 89 # 全量同步默认回溯天数(快手限制90天内,留1天余量)(单接口可通过 request_config.full_sync_start_time 覆盖)
default_tenant_id: 1 # 自动同步使用的租户 ID(多租户部署时配置)
@@ -55,7 +55,7 @@ redis:
writeTimeout: "30s" #TCP的Write操作超时时间,使用时间字符串例如30s/1m/1d
maxActive: 100
consul:
address: 116.204.74.41:8500
address: 192.168.3.135:8500
#k8s:
# apiServer: "https://192.168.3.37:6443"
# token: "eyJhbGciOiJSUzI1NiIsImtpZCI6IlR6X0QtelVDYkZPYnpjTDlfamZzOEFjbERJb2dUMTRqMjNfYUNncGcwRW8ifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrM3MiXSwiZXhwIjoxODA3ODY3MTA1LCJpYXQiOjE3NzYzMzExMDUsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiNjAwMGY0ODctZTQ4Ni00NTUxLWIyNjgtMzE1MWE1MDE5YjU4Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJrdWJlcm5ldGVzLWRhc2hib2FyZCIsInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkYXNoYm9hcmQtYWRtaW4iLCJ1aWQiOiJlZjAxY2IxNS0xNTc0LTRlYTYtODg4Ny03YjZhODY3OWFkYmIifX0sIm5iZiI6MTc3NjMzMTEwNSwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50Omt1YmVybmV0ZXMtZGFzaGJvYXJkOmRhc2hib2FyZC1hZG1pbiJ9.Z5MEy-dhWq6lruuR6QSI--cYRZzDoeEes78a4lLMtXa8lSLt1FZy16kNVn2pJli74RSk0kKS2F5GAatyuyGjF_yB7Tm1Tb7iCjzeUM7xRpdvXEmk4MUImlzwGeW31NXpwFrfR0oPS-9TLlbahEmsgwL0DUOC0CekSiToVbIduwEmmrB0FMPFayr5_wqGDmxdqtaAH3K-LJNCqPiAMqevsxTebrhyJSrThU-Pi7iJYm_sUd8VHKrRxrwDiGA77j4lfita_hZSdyrZe8qsrbGqomHxFWk9ZzcJJ9q7OLS13OXo-Xbcu-_TZcfDOreZhune9ctM2lmuOtmYnad47gYRcA"
@@ -174,6 +174,7 @@ tr:hover td { background: #fafafa; }
</div>
<div class="toolbar-right">
<button class="btn btn-success btn-sm" onclick="execExtract()">执行抽取</button>
<button class="btn btn-warning btn-sm" onclick="execBackfill()">批量回填</button>
<button class="btn btn-primary btn-sm" onclick="openExtractModal()">+ 新建抽取配置</button>
</div>
</div>
@@ -734,6 +735,22 @@ async function execExtract() {
// ==================== 数据查询 ====================
let queryCtx = { dimensions: [], indicators: [], filters: [], availableFields: null };
async function execBackfill() {
const bc = v('extract-biz-select');
const rc = v('extract-report-select');
if (!bc || !rc) { toast('请先选择业务和报表', 'error'); return; }
const start = prompt('请输入开始日期 (yyyy-MM-dd)');
if (!start) return;
const end = prompt('请输入结束日期 (yyyy-MM-dd)');
if (!end) return;
if (!confirm('确定回填 ' + start + ' ~ ' + end + ' 的数据?')) return;
try {
const res = await api('POST', '/report/backfill', { businessCode: bc, reportCode: rc, startDate: start, endDate: end, executor: 'admin' });
toast('回填完成!成功: ' + res.successDays + '/' + res.totalDays + ' 天, 耗时: ' + res.execTimeMs + 'ms' + (res.failDays > 0 ? ' 失败: ' + res.failDays + ' 天' : ''), res.success ? 'success' : 'error');
} catch(e) { toast(e.message, 'error'); }
}
async function onQueryBizChange() {
const bc = v('query-biz-select');
const rs = document.getElementById('query-report-select');
+118 -10
View File
@@ -2,12 +2,15 @@ package report
import (
"context"
"strconv"
"strings"
reportSvc "dataengine/common/report"
"dataengine/common/report/model"
"gitea.redpowerfuture.com/red-future/common/beans"
"github.com/gogf/gf/v2/frame/g"
"github.com/sirupsen/logrus"
)
type report struct{}
@@ -28,19 +31,23 @@ func ctxWithUser(ctx context.Context) context.Context {
type listBusinessesReq struct {
g.Meta `path:"/businesses" method:"get" tags:"报表引擎" summary:"业务列表"`
model.PageReq
}
type listBusinessesRes struct {
List []model.BusinessConfig `json:"list"`
model.PageRes
}
func (c *report) ListBusinesses(ctx context.Context, req *listBusinessesReq) (*listBusinessesRes, error) {
ctx = ctxWithUser(ctx)
list, err := svc().GetAllBusinesses(ctx)
list, total, err := svc().ListBusinesses(ctx, req.PageNum, req.PageSize)
if err != nil {
logrus.Errorf("[报表引擎] 获取业务列表失败: %v", err)
return nil, err
}
return &listBusinessesRes{List: list}, nil
totalPages := (total + req.PageSize - 1) / req.PageSize
return &listBusinessesRes{List: list, PageRes: model.PageRes{Total: total, Page: req.PageNum, PageSize: req.PageSize, TotalPages: totalPages}}, nil
}
type getBusinessReq struct {
@@ -56,6 +63,7 @@ func (c *report) GetBusiness(ctx context.Context, req *getBusinessReq) (*getBusi
ctx = ctxWithUser(ctx)
data, err := svc().GetBusiness(ctx, req.ID)
if err != nil {
logrus.Errorf("[报表引擎] 获取业务失败 id=%d: %v", req.ID, err)
return nil, err
}
return &getBusinessRes{Data: data}, nil
@@ -74,6 +82,7 @@ func (c *report) SaveBusiness(ctx context.Context, req *saveBusinessReq) (*saveB
ctx = ctxWithUser(ctx)
result, err := svc().SaveBusiness(ctx, &req.SaveBusinessReq)
if err != nil {
logrus.Errorf("[报表引擎] 保存业务失败: %v", err)
return nil, err
}
return &saveBusinessRes{SaveResult: result}, nil
@@ -92,6 +101,7 @@ func (c *report) DeleteBusiness(ctx context.Context, req *deleteBusinessReq) (*d
ctx = ctxWithUser(ctx)
result, err := svc().DeleteBusiness(ctx, req.ID)
if err != nil {
logrus.Errorf("[报表引擎] 删除业务失败 id=%d: %v", req.ID, err)
return nil, err
}
return &deleteBusinessRes{DeleteResult: result}, nil
@@ -104,19 +114,37 @@ func (c *report) DeleteBusiness(ctx context.Context, req *deleteBusinessReq) (*d
type listReportsReq struct {
g.Meta `path:"/reports" method:"get" tags:"报表引擎" summary:"报表列表"`
BusinessCode string `json:"businessCode" v:"required"`
ReportName string `json:"reportName" dc:"报表名称(模糊搜索)"`
model.PageReq
}
type listReportsRes struct {
List []model.ReportConfig `json:"list"`
model.PageRes
}
func (c *report) ListReports(ctx context.Context, req *listReportsReq) (*listReportsRes, error) {
ctx = ctxWithUser(ctx)
list, err := svc().GetAllReports(ctx, req.BusinessCode)
list, total, err := svc().ListReports(ctx, req.BusinessCode, req.ReportName, req.PageNum, req.PageSize)
if err != nil {
logrus.Errorf("[报表引擎] 获取报表列表失败 %s: %v", req.BusinessCode, err)
return nil, err
}
return &listReportsRes{List: list}, nil
// 填充抽取配置ID(逗号分隔,不限 status)
for i := range list {
configs, err := svc().GetExtractConfigsAll(ctx, list[i].BusinessCode, list[i].ReportCode)
if err == nil {
ids := make([]string, 0, len(configs))
for _, ec := range configs {
ids = append(ids, strconv.FormatInt(ec.Id, 10))
}
list[i].ExtractConfigIDs = strings.Join(ids, ",")
}
}
totalPages := (total + req.PageSize - 1) / req.PageSize
return &listReportsRes{List: list, PageRes: model.PageRes{Total: total, Page: req.PageNum, PageSize: req.PageSize, TotalPages: totalPages}}, nil
}
type getReportReq struct {
@@ -132,8 +160,20 @@ func (c *report) GetReport(ctx context.Context, req *getReportReq) (*getReportRe
ctx = ctxWithUser(ctx)
data, err := svc().GetReport(ctx, req.ID)
if err != nil {
logrus.Errorf("[报表引擎] 获取报表失败 id=%d: %v", req.ID, err)
return nil, err
}
// 填充抽取配置ID(逗号分隔,不限 status)
configs, err := svc().GetExtractConfigsAll(ctx, data.BusinessCode, data.ReportCode)
if err == nil {
ids := make([]string, 0, len(configs))
for _, ec := range configs {
ids = append(ids, strconv.FormatInt(ec.Id, 10))
}
data.ExtractConfigIDs = strings.Join(ids, ",")
}
return &getReportRes{Data: data}, nil
}
@@ -150,11 +190,32 @@ func (c *report) SaveReport(ctx context.Context, req *saveReportReq) (*saveRepor
ctx = ctxWithUser(ctx)
result, err := svc().SaveReport(ctx, &req.SaveReportReq)
if err != nil {
logrus.Errorf("[报表引擎] 保存报表失败: %v", err)
return nil, err
}
return &saveReportRes{SaveResult: result}, nil
}
type saveReportWithFieldsReq struct {
g.Meta `path:"/report/saveWithFields" method:"post" tags:"报表引擎" summary:"保存报表及字段配置(全量替换)"`
model.SaveReportWithFieldsReq
}
type saveReportWithFieldsRes struct {
*model.SaveResult
}
func (c *report) SaveReportWithFields(ctx context.Context, req *saveReportWithFieldsReq) (*saveReportWithFieldsRes, error) {
ctx = ctxWithUser(ctx)
result, err := svc().SaveReportWithFields(ctx, &req.SaveReportWithFieldsReq)
if err != nil {
logrus.Errorf("[报表引擎] 保存报表及字段失败 %s/%s: %v", req.BusinessCode, req.ReportCode, err)
return nil, err
}
logrus.Infof("[报表引擎] 保存报表及字段成功 %s/%s id=%d", req.BusinessCode, req.ReportCode, result.ID)
return &saveReportWithFieldsRes{SaveResult: result}, nil
}
type deleteReportReq struct {
g.Meta `path:"/report" method:"delete" tags:"报表引擎" summary:"删除报表"`
ID int64 `json:"id" v:"required"`
@@ -168,6 +229,7 @@ func (c *report) DeleteReport(ctx context.Context, req *deleteReportReq) (*delet
ctx = ctxWithUser(ctx)
result, err := svc().DeleteReport(ctx, req.ID)
if err != nil {
logrus.Errorf("[报表引擎] 删除报表失败 id=%d: %v", req.ID, err)
return nil, err
}
return &deleteReportRes{DeleteResult: result}, nil
@@ -181,11 +243,17 @@ type getReportFieldsReq struct {
g.Meta `path:"/fields" method:"get" tags:"报表引擎" summary:"报表字段列表(按角色分组)"`
BusinessCode string `json:"businessCode" v:"required"`
ReportCode string `json:"reportCode" v:"required"`
model.PageReq
}
func (c *report) GetReportFields(ctx context.Context, req *getReportFieldsReq) (*model.GetReportFieldsResp, error) {
ctx = ctxWithUser(ctx)
return svc().GetReportFields(ctx, req.BusinessCode, req.ReportCode)
resp, err := svc().GetReportFields(ctx, req.BusinessCode, req.ReportCode)
if err != nil {
logrus.Errorf("[报表引擎] 获取报表字段失败 %s/%s: %v", req.BusinessCode, req.ReportCode, err)
return nil, err
}
return resp, nil
}
type getFieldReq struct {
@@ -201,6 +269,7 @@ func (c *report) GetField(ctx context.Context, req *getFieldReq) (*getFieldRes,
ctx = ctxWithUser(ctx)
data, err := svc().GetField(ctx, req.ID)
if err != nil {
logrus.Errorf("[报表引擎] 获取字段失败 id=%d: %v", req.ID, err)
return nil, err
}
return &getFieldRes{Data: data}, nil
@@ -219,6 +288,7 @@ func (c *report) SaveField(ctx context.Context, req *saveFieldReq) (*saveFieldRe
ctx = ctxWithUser(ctx)
result, err := svc().SaveField(ctx, &req.SaveFieldReq)
if err != nil {
logrus.Errorf("[报表引擎] 保存字段失败: %v", err)
return nil, err
}
return &saveFieldRes{SaveResult: result}, nil
@@ -237,6 +307,7 @@ func (c *report) DeleteField(ctx context.Context, req *deleteFieldReq) (*deleteF
ctx = ctxWithUser(ctx)
result, err := svc().DeleteField(ctx, req.ID)
if err != nil {
logrus.Errorf("[报表引擎] 删除字段失败 id=%d: %v", req.ID, err)
return nil, err
}
return &deleteFieldRes{DeleteResult: result}, nil
@@ -250,19 +321,23 @@ type getExtractConfigsReq struct {
g.Meta `path:"/extractConfigs" method:"get" tags:"报表引擎" summary:"抽取配置列表"`
BusinessCode string `json:"businessCode" v:"required"`
ReportCode string `json:"reportCode" v:"required"`
model.PageReq
}
type getExtractConfigsRes struct {
List []model.ExtractConfig `json:"list"`
model.PageRes
}
func (c *report) GetExtractConfigs(ctx context.Context, req *getExtractConfigsReq) (*getExtractConfigsRes, error) {
ctx = ctxWithUser(ctx)
list, err := svc().GetExtractConfigs(ctx, req.BusinessCode, req.ReportCode)
list, total, err := svc().ListExtractConfigs(ctx, req.BusinessCode, req.ReportCode, req.PageNum, req.PageSize)
if err != nil {
logrus.Errorf("[报表引擎] 获取抽取配置列表失败 %s/%s: %v", req.BusinessCode, req.ReportCode, err)
return nil, err
}
return &getExtractConfigsRes{List: list}, nil
totalPages := (total + req.PageSize - 1) / req.PageSize
return &getExtractConfigsRes{List: list, PageRes: model.PageRes{Total: total, Page: req.PageNum, PageSize: req.PageSize, TotalPages: totalPages}}, nil
}
type getExtractConfigReq struct {
@@ -278,6 +353,7 @@ func (c *report) GetExtractConfig(ctx context.Context, req *getExtractConfigReq)
ctx = ctxWithUser(ctx)
data, err := svc().GetExtractConfig(ctx, req.ID)
if err != nil {
logrus.Errorf("[报表引擎] 获取抽取配置失败 id=%d: %v", req.ID, err)
return nil, err
}
return &getExtractConfigRes{Data: data}, nil
@@ -296,6 +372,7 @@ func (c *report) SaveExtractConfig(ctx context.Context, req *saveExtractConfigRe
ctx = ctxWithUser(ctx)
result, err := svc().SaveExtractConfig(ctx, &req.SaveExtractConfigReq)
if err != nil {
logrus.Errorf("[报表引擎] 保存抽取配置失败: %v", err)
return nil, err
}
return &saveExtractConfigRes{SaveResult: result}, nil
@@ -314,6 +391,7 @@ func (c *report) DeleteExtractConfig(ctx context.Context, req *deleteExtractConf
ctx = ctxWithUser(ctx)
result, err := svc().DeleteExtractConfig(ctx, req.ID)
if err != nil {
logrus.Errorf("[报表引擎] 删除抽取配置失败 id=%d: %v", req.ID, err)
return nil, err
}
return &deleteExtractConfigRes{DeleteResult: result}, nil
@@ -330,7 +408,27 @@ type extractDataReq struct {
func (c *report) ExtractData(ctx context.Context, req *extractDataReq) (*model.ExtractDailyDataResp, error) {
ctx = ctxWithUser(ctx)
return svc().ExtractDailyData(ctx, req.BusinessCode, req.ReportCode, req.StatDate, req.Executor)
resp, err := svc().ExtractDailyData(ctx, req.BusinessCode, req.ReportCode, req.StatDate, req.Executor)
if err != nil {
logrus.Errorf("[报表引擎] 按天抽取失败 %s/%s %s: %v", req.BusinessCode, req.ReportCode, req.StatDate, err)
return nil, err
}
return resp, nil
}
type backfillReq struct {
g.Meta `path:"/backfill" method:"post" tags:"报表引擎" summary:"批量回填数据"`
model.BackfillReq
}
func (c *report) Backfill(ctx context.Context, req *backfillReq) (*model.BackfillResp, error) {
ctx = ctxWithUser(ctx)
resp, err := svc().Backfill(ctx, &req.BackfillReq)
if err != nil {
logrus.Errorf("[报表引擎] 批量回填失败 %s/%s %s~%s: %v", req.BusinessCode, req.ReportCode, req.StartDate, req.EndDate, err)
return nil, err
}
return resp, nil
}
type autoCreateTableReq struct {
@@ -340,7 +438,12 @@ type autoCreateTableReq struct {
func (c *report) AutoCreateTable(ctx context.Context, req *autoCreateTableReq) (*model.AutoCreateStatTableResp, error) {
ctx = ctxWithUser(ctx)
return svc().AutoCreateStatTable(ctx, req.BusinessCode, req.ReportCode)
resp, err := svc().AutoCreateStatTable(ctx, req.BusinessCode, req.ReportCode)
if err != nil {
logrus.Errorf("[报表引擎] 自动建表失败 %s/%s: %v", req.BusinessCode, req.ReportCode, err)
return nil, err
}
return resp, nil
}
type queryReportReq struct {
@@ -350,7 +453,12 @@ type queryReportReq struct {
func (c *report) QueryReport(ctx context.Context, req *queryReportReq) (*model.UserSelectQueryResp, error) {
ctx = ctxWithUser(ctx)
return svc().QueryReportByUserSelect(ctx, &req.UserSelectQueryReq)
resp, err := svc().QueryReportByUserSelect(ctx, &req.UserSelectQueryReq)
if err != nil {
logrus.Errorf("[报表引擎] 查询报表失败 %s/%s: %v", req.BusinessCode, req.ReportCode, err)
return nil, err
}
return resp, nil
}
type initTablesReq struct {
+359
View File
@@ -0,0 +1,359 @@
-- ============================================================
-- 报表引擎 — 快手电商演示数据初始化脚本
-- ============================================================
-- 使用方式:
-- PGPASSWORD='Bjang09@686^*^' psql -h 116.204.74.41 \
-- -p 15432 -U postgres -d engine -f demo_kuaishou.sql
-- ============================================================
BEGIN;
-- ============================================================
-- 第〇步:确保系统表存在(CREATE TABLE IF NOT EXISTS
-- ============================================================
CREATE TABLE IF NOT EXISTS report_business_config (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
business_code VARCHAR(64) NOT NULL,
business_name VARCHAR(128) NOT NULL,
description TEXT DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
config JSONB DEFAULT '{}',
creator VARCHAR(64) DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updater VARCHAR(64) DEFAULT '',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE TABLE IF NOT EXISTS report_report_config (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
business_code VARCHAR(64) NOT NULL,
report_code VARCHAR(64) NOT NULL,
report_name VARCHAR(128) NOT NULL,
description TEXT DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
stat_table_name VARCHAR(128) NOT NULL,
stat_table_comment VARCHAR(256) DEFAULT '',
date_field VARCHAR(64) DEFAULT 'stat_date',
primary_keys JSONB DEFAULT '["id"]'::jsonb,
conflict_keys JSONB DEFAULT '["stat_date"]'::jsonb,
config JSONB DEFAULT '{}',
creator VARCHAR(64) DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updater VARCHAR(64) DEFAULT '',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT uk_business_report_code UNIQUE (tenant_id, business_code, report_code)
);
CREATE TABLE IF NOT EXISTS report_field_config (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
business_code VARCHAR(64) NOT NULL,
report_code VARCHAR(64) NOT NULL,
field_code VARCHAR(64) NOT NULL,
field_name VARCHAR(128) NOT NULL,
field_type VARCHAR(32) NOT NULL,
data_type VARCHAR(32) NOT NULL DEFAULT 'STRING',
field_role VARCHAR(32) NOT NULL,
is_aggregatable BOOLEAN DEFAULT FALSE,
is_filterable BOOLEAN DEFAULT TRUE,
is_queryable BOOLEAN DEFAULT TRUE,
is_sortable BOOLEAN DEFAULT TRUE,
default_aggregate VARCHAR(32) DEFAULT '',
valid_aggregates JSONB DEFAULT '[]'::jsonb,
filter_operators JSONB DEFAULT '["=","!=",">","<",">=","<=","IN","LIKE","BETWEEN"]'::jsonb,
expression VARCHAR(512) DEFAULT '',
expression_type VARCHAR(32) DEFAULT '',
format_pattern VARCHAR(64) DEFAULT '',
unit VARCHAR(32) DEFAULT '',
dict_code VARCHAR(64) DEFAULT '',
sort_order INT DEFAULT 0,
group_name VARCHAR(64) DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
creator VARCHAR(64) DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updater VARCHAR(64) DEFAULT '',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT uk_business_report_field_code UNIQUE (tenant_id, business_code, report_code, field_code)
);
CREATE TABLE IF NOT EXISTS report_extract_config (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
business_code VARCHAR(64) NOT NULL,
report_code VARCHAR(64) NOT NULL,
extract_code VARCHAR(64) NOT NULL,
extract_name VARCHAR(128) NOT NULL,
source_table_name VARCHAR(128) NOT NULL,
source_table_alias VARCHAR(64) DEFAULT '',
target_table_name VARCHAR(128) NOT NULL,
is_enabled BOOLEAN DEFAULT TRUE,
extract_type VARCHAR(32) NOT NULL DEFAULT 'FULL',
extract_mode VARCHAR(32) NOT NULL DEFAULT 'DIRECT',
extract_key_field VARCHAR(64) DEFAULT '',
extract_key_format VARCHAR(64) DEFAULT '',
group_by_fields JSONB DEFAULT '[]'::jsonb,
filter_expression TEXT DEFAULT '',
join_configs JSONB DEFAULT '[]'::jsonb,
field_mappings JSONB DEFAULT '[]'::jsonb,
transform_rules JSONB DEFAULT '[]'::jsonb,
batch_size INT DEFAULT 1000,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
creator VARCHAR(64) DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updater VARCHAR(64) DEFAULT '',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT uk_business_report_extract_code UNIQUE (tenant_id, business_code, report_code, extract_code)
);
CREATE TABLE IF NOT EXISTS report_extract_log (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL DEFAULT 0,
business_code VARCHAR(64) NOT NULL,
report_code VARCHAR(64) NOT NULL,
extract_code VARCHAR(64) NOT NULL,
stat_date VARCHAR(16) NOT NULL,
extract_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'RUNNING',
total_count INT DEFAULT 0,
success_count INT DEFAULT 0,
fail_count INT DEFAULT 0,
start_time TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
end_time TIMESTAMP WITH TIME ZONE,
error_message TEXT DEFAULT '',
executor VARCHAR(64) DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uk_extract_keys UNIQUE (tenant_id, business_code, report_code, extract_code, stat_date)
);
-- ============================================================
-- 第一步:创建源数据表 + 模拟数据
-- ============================================================
-- 店铺表
DROP TABLE IF EXISTS demo_kuaishou_shop CASCADE;
CREATE TABLE demo_kuaishou_shop (
shop_id VARCHAR(64) PRIMARY KEY,
shop_name VARCHAR(128) NOT NULL,
shop_level VARCHAR(16) DEFAULT '普通',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
INSERT INTO demo_kuaishou_shop VALUES
('KS001', '快手官方旗舰店', '旗舰'),
('KS002', '数码3C专营店', '高级'),
('KS003', '美妆个护优选', '普通'),
('KS004', '食品生鲜超市', '高级'),
('KS005', '服装潮流馆', '普通');
-- 订单明细表
DROP TABLE IF EXISTS demo_kuaishou_orders CASCADE;
CREATE TABLE demo_kuaishou_orders (
id BIGSERIAL PRIMARY KEY,
order_no VARCHAR(64) NOT NULL,
shop_id VARCHAR(64) NOT NULL,
order_status VARCHAR(16) NOT NULL DEFAULT '已完成',
product_name VARCHAR(256),
product_category VARCHAR(64),
quantity INT DEFAULT 1,
unit_price NUMERIC(12,2) DEFAULT 0,
order_amount NUMERIC(12,2) DEFAULT 0,
refund_amount NUMERIC(12,2) DEFAULT 0,
shipping_cost NUMERIC(10,2) DEFAULT 0,
payment_method VARCHAR(32) DEFAULT '微信',
buyer_id VARCHAR(64) DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 生成30天模拟订单
DO $$
DECLARE
v_date DATE;
v_order_date DATE := CURRENT_DATE - INTERVAL '30 days';
v_order_count INT;
v_shop_id VARCHAR(64);
v_shop_ids VARCHAR[] := ARRAY['KS001','KS002','KS003','KS004','KS005'];
v_categories VARCHAR[] := ARRAY['手机','电脑','美妆','食品','服装'];
v_payments VARCHAR[] := ARRAY['微信','支付宝','快手支付'];
v_statuses VARCHAR[] := ARRAY['已完成','已完成','已完成','已完成','已完成','已完成','已完成','已取消','已完成'];
v_price NUMERIC(12,2);
v_qty INT;
BEGIN
FOR day_offset IN 0..29 LOOP
v_date := v_order_date + day_offset;
v_order_count := 20 + floor(random() * 31)::int;
FOR i IN 1..v_order_count LOOP
v_shop_id := v_shop_ids[1 + floor(random() * 5)::int];
v_qty := 1 + floor(random() * 5)::int;
v_price := round((50 + random() * 2000)::numeric, 2);
INSERT INTO demo_kuaishou_orders (order_no, shop_id, order_status, product_name,
product_category, quantity, unit_price, order_amount, refund_amount,
shipping_cost, payment_method, buyer_id, created_at, updated_at)
VALUES (
'KS' || to_char(v_date, 'YYYYMMDD') || '-' || lpad(i::text, 4, '0'),
v_shop_id, v_statuses[1 + floor(random() * 9)::int],
'商品' || (1000 + floor(random() * 9000)::int),
v_categories[1 + floor(random() * 5)::int],
v_qty, v_price, round((v_price * v_qty)::numeric, 2),
CASE WHEN random() < 0.1 THEN round((v_price * v_qty * random() * 0.5)::numeric, 2) ELSE 0 END,
round((5 + random() * 15)::numeric, 2),
v_payments[1 + floor(random() * 3)::int],
'buyer_' || (10000 + floor(random() * 90000)::int),
v_date + time '08:00:00' + (random() * interval '12 hours'),
v_date + time '08:00:00' + (random() * interval '12 hours')
);
END LOOP;
END LOOP;
END $$;
ANALYZE demo_kuaishou_orders;
-- ============================================================
-- 第二步:插入系统配置(先清空避免重复)
-- ============================================================
DELETE FROM report_business_config WHERE business_code = 'KUAISHOU';
DELETE FROM report_report_config WHERE business_code = 'KUAISHOU';
DELETE FROM report_field_config WHERE business_code = 'KUAISHOU';
DELETE FROM report_extract_config WHERE business_code = 'KUAISHOU';
-- 1. 业务配置
INSERT INTO report_business_config (tenant_id, business_code, business_name, description, status, config, creator)
VALUES (1, 'KUAISHOU', '快手电商', '快手电商平台订单数据', 'ACTIVE', '{}', 'system');
-- 2. 报表配置
INSERT INTO report_report_config (tenant_id, business_code, report_code, report_name, description,
status, stat_table_name, stat_table_comment, date_field, primary_keys, conflict_keys, config, creator)
VALUES (1, 'KUAISHOU', 'shop_daily', '店铺日报',
'按店铺+日期统计订单数据', 'ACTIVE',
'stat_kuaishou_shop_daily', '快手电商店铺日报统计宽表',
'stat_date', '["id"]'::jsonb, '["stat_date", "shop_id"]'::jsonb, '{}', 'system');
-- 3. 字段配置
INSERT INTO report_field_config (tenant_id, business_code, report_code, field_code, field_name,
field_type, data_type, field_role, is_aggregatable, is_filterable, is_queryable, is_sortable,
default_aggregate, valid_aggregates, filter_operators, sort_order, group_name, status, creator)
VALUES
-- 维度
(1, 'KUAISHOU', 'shop_daily', 'stat_date', '统计日期',
'DATE', 'STRING', 'DIMENSION', false, true, true, true,
'', '[]'::jsonb, '["=",">=","<=","BETWEEN"]'::jsonb, 1, '时间维度', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'order_no', '订单号',
'STRING', 'STRING', 'DIMENSION', false, true, true, true,
'', '[]'::jsonb, '["=","IN"]'::jsonb, 0, '订单维度', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'shop_id', '店铺ID',
'STRING', 'STRING', 'DIMENSION', false, true, true, true,
'', '[]'::jsonb, '["=","IN"]'::jsonb, 2, '店铺维度', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'shop_name', '店铺名称',
'STRING', 'STRING', 'DIMENSION', false, true, true, true,
'', '[]'::jsonb, '["=","LIKE"]'::jsonb, 3, '店铺维度', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'product_category', '商品类目',
'STRING', 'STRING', 'DIMENSION', false, true, true, true,
'', '[]'::jsonb, '["=","IN"]'::jsonb, 4, '商品维度', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'order_status', '订单状态',
'STRING', 'STRING', 'DIMENSION', false, true, true, true,
'', '[]'::jsonb, '["=","IN"]'::jsonb, 5, '订单维度', 'ACTIVE', 'system'),
-- 指标
(1, 'KUAISHOU', 'shop_daily', 'order_count', '订单数',
'INT', 'INT', 'INDICATOR', true, false, true, true,
'SUM', '["SUM","COUNT"]'::jsonb, '[]'::jsonb, 10, '交易指标', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'order_amount', '订单金额',
'FLOAT', 'FLOAT', 'INDICATOR', true, false, true, true,
'SUM', '["SUM","AVG","MAX","MIN"]'::jsonb, '[]'::jsonb, 11, '交易指标', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'refund_amount', '退款金额',
'FLOAT', 'FLOAT', 'INDICATOR', true, false, true, true,
'SUM', '["SUM","AVG"]'::jsonb, '[]'::jsonb, 12, '交易指标', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'shipping_cost', '运费',
'FLOAT', 'FLOAT', 'INDICATOR', true, false, true, true,
'SUM', '["SUM","AVG"]'::jsonb, '[]'::jsonb, 13, '交易指标', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'total_quantity', '商品数量',
'INT', 'INT', 'INDICATOR', true, false, true, true,
'SUM', '["SUM","AVG"]'::jsonb, '[]'::jsonb, 14, '交易指标', 'ACTIVE', 'system'),
(1, 'KUAISHOU', 'shop_daily', 'avg_price', '平均单价',
'FLOAT', 'FLOAT', 'INDICATOR', true, false, true, true,
'AVG', '["AVG","MAX","MIN"]'::jsonb, '[]'::jsonb, 15, '交易指标', 'ACTIVE', 'system');
-- 4. 抽取配置(DIRECT 模式:逐行抽取,查询时动态聚合)
INSERT INTO report_extract_config (tenant_id, business_code, report_code, extract_code, extract_name,
source_table_name, source_table_alias, target_table_name, is_enabled,
extract_type, extract_mode, extract_key_field, group_by_fields, filter_expression,
field_mappings, join_configs, batch_size, status, creator)
VALUES (1, 'KUAISHOU', 'shop_daily', 'shop_daily_direct', '店铺日订单(逐行,可自由组合查询)',
'demo_kuaishou_orders', 'o', 'stat_kuaishou_shop_daily',
true, 'INCREMENTAL', 'DIRECT', 'created_at',
'[]'::jsonb,
'o.order_status = ''已完成''',
'[
{"sourceField":"order_no", "targetField":"order_no", "fieldType":"STRING", "aggregateFunction":"", "defaultValue":""},
{"sourceField":"shop_id", "targetField":"shop_id", "fieldType":"STRING", "aggregateFunction":"", "defaultValue":""},
{"sourceField":"product_category", "targetField":"product_category", "fieldType":"STRING", "aggregateFunction":"", "defaultValue":""},
{"sourceField":"order_status", "targetField":"order_status", "fieldType":"STRING", "aggregateFunction":"", "defaultValue":""},
{"sourceField":"order_amount", "targetField":"order_amount", "fieldType":"FLOAT", "aggregateFunction":"", "defaultValue":0},
{"sourceField":"refund_amount", "targetField":"refund_amount", "fieldType":"FLOAT", "aggregateFunction":"", "defaultValue":0},
{"sourceField":"shipping_cost", "targetField":"shipping_cost", "fieldType":"FLOAT", "aggregateFunction":"", "defaultValue":0},
{"sourceField":"quantity", "targetField":"total_quantity", "fieldType":"INT", "aggregateFunction":"", "defaultValue":0},
{"sourceField":"unit_price", "targetField":"unit_price", "fieldType":"FLOAT", "aggregateFunction":"", "defaultValue":0}
]'::jsonb,
'[
{
"joinTable":"demo_kuaishou_shop",
"joinAlias":"s",
"joinType":"LEFT",
"joinCondition":"o.shop_id = s.shop_id",
"fieldMappings":[
{"sourceField":"shop_name", "targetField":"shop_name", "fieldType":"STRING", "aggregateFunction":""},
{"sourceField":"shop_level", "targetField":"shop_level", "fieldType":"STRING", "aggregateFunction":""}
]
}
]'::jsonb,
1000, 'ACTIVE', 'system'
);
-- ============================================================
-- 第三步:验证
-- ============================================================
SELECT '✅ 源表数据' AS info, COUNT(*) AS FROM demo_kuaishou_orders
UNION ALL
SELECT '✅ 店铺数', COUNT(*) FROM demo_kuaishou_shop
UNION ALL
SELECT '✅ 业务配置', COUNT(*) FROM report_business_config WHERE business_code = 'KUAISHOU'
UNION ALL
SELECT '✅ 报表配置', COUNT(*) FROM report_report_config WHERE business_code = 'KUAISHOU'
UNION ALL
SELECT '✅ 字段配置', COUNT(*) FROM report_field_config WHERE business_code = 'KUAISHOU'
UNION ALL
SELECT '✅ 抽取配置', COUNT(*) FROM report_extract_config WHERE business_code = 'KUAISHOU';
SELECT '📅 数据日期范围:' AS info, MIN(created_at::date)::text || ' ~ ' || MAX(created_at::date)::text AS FROM demo_kuaishou_orders;
COMMIT;
-- ============================================================
-- 使用说明
-- ============================================================
-- 1. 启动项目(会自动创建系统表)
-- 2. 执行本脚本
-- 3. 打开 http://localhost:3013/admin/report
-- 4. 抽取配置 → 选"快手电商/shop_daily" → 点"执行抽取"
-- 5. 数据查询 → 选"快手电商/shop_daily" → 自由选维度/指标查询
-- ============================================================
--
-- 上线前清理 Demo 数据:
-- DELETE FROM report_business_config WHERE business_code = 'KUAISHOU';
-- DELETE FROM report_report_config WHERE business_code = 'KUAISHOU';
-- DELETE FROM report_field_config WHERE business_code = 'KUAISHOU';
-- DELETE FROM report_extract_config WHERE business_code = 'KUAISHOU';
-- DELETE FROM report_extract_log WHERE business_code = 'KUAISHOU';
-- DROP TABLE IF EXISTS stat_kuaishou_shop_daily CASCADE;
-- DROP TABLE IF EXISTS demo_kuaishou_orders CASCADE;
-- DROP TABLE IF EXISTS demo_kuaishou_shop CASCADE;
-- ============================================================
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Add pagination to all list endpoints + change status filter to deleted_at."""
# ========== 1. Controller: request/response structs + handlers ==========
ctrl_path = '/Users/xujiaqian/GolandProjects/HDWL/data-engine/controller/report/report_controller.go'
with open(ctrl_path) as f:
ctrl = f.read()
# 1a. listBusinessesReq - add PageReq embed
ctrl = ctrl.replace(
'type listBusinessesReq struct {\n\t\tg.Meta `path:"/businesses" method:"get" tags:"报表引擎" summary:"业务列表"`\n\t}',
'type listBusinessesReq struct {\n\t\tg.Meta `path:"/businesses" method:"get" tags:"报表引擎" summary:"业务列表"`\n\t\tmodel.PageReq\n\t}'
)
# 1b. listBusinessesRes - add PageRes embed + rename List
ctrl = ctrl.replace(
'type listBusinessesRes struct {\n\t\tList []model.BusinessConfig `json:"list"`\n\t}',
'type listBusinessesRes struct {\n\t\tList []model.BusinessConfig `json:"list"`\n\t\tmodel.PageRes\n\t}'
)
# 1c. ListBusinesses handler - pass page params + calc TotalPages
ctrl = ctrl.replace(
'\t\tlist, err := svc().GetAllBusinesses(ctx)',
'\t\tlist, total, err := svc().GetAllBusinesses(ctx, req.PageNum, req.PageSize)'
)
ctrl = ctrl.replace(
'\t\treturn &listBusinessesRes{List: list}, nil',
'\t\ttotalPages := (total + req.PageSize - 1) / req.PageSize\n\t\treturn &listBusinessesRes{List: list, PageRes: model.PageRes{Total: total, Page: req.PageNum, PageSize: req.PageSize, TotalPages: totalPages}}, nil'
)
# 1d. listReportsReq - add PageReq embed
ctrl = ctrl.replace(
'type listReportsReq struct {\n\t\tg.Meta `path:"/reports" method:"get" tags:"报表引擎" summary:"报表列表"`\n\t\tBusinessCode string `json:"businessCode" v:"required"`\n\t}',
'type listReportsReq struct {\n\t\tg.Meta `path:"/reports" method:"get" tags:"报表引擎" summary:"报表列表"`\n\t\tBusinessCode string `json:"businessCode" v:"required"`\n\t\tmodel.PageReq\n\t}'
)
# 1e. listReportsRes - add PageRes embed
ctrl = ctrl.replace(
'type listReportsRes struct {\n\t\tList []model.ReportConfig `json:"list"`\n\t}',
'type listReportsRes struct {\n\t\tList []model.ReportConfig `json:"list"`\n\t\tmodel.PageRes\n\t}'
)
# 1f. ListReports handler
ctrl = ctrl.replace(
'\t\tlist, err := svc().GetAllReports(ctx, req.BusinessCode)',
'\t\tlist, total, err := svc().GetAllReports(ctx, req.BusinessCode, req.PageNum, req.PageSize)'
)
ctrl = ctrl.replace(
'\t\treturn &listReportsRes{List: list}, nil',
'\t\ttotalPages := (total + req.PageSize - 1) / req.PageSize\n\t\treturn &listReportsRes{List: list, PageRes: model.PageRes{Total: total, Page: req.PageNum, PageSize: req.PageSize, TotalPages: totalPages}}, nil'
)
# 1g. getExtractConfigsReq - add PageReq embed
ctrl = ctrl.replace(
'type getExtractConfigsReq struct {\n\t\tg.Meta `path:"/extractConfigs" method:"get" tags:"报表引擎" summary:"抽取配置列表"`\n\t\tBusinessCode string `json:"businessCode" v:"required"`\n\t\tReportCode string `json:"reportCode" v:"required"`\n\t}',
'type getExtractConfigsReq struct {\n\t\tg.Meta `path:"/extractConfigs" method:"get" tags:"报表引擎" summary:"抽取配置列表"`\n\t\tBusinessCode string `json:"businessCode" v:"required"`\n\t\tReportCode string `json:"reportCode" v:"required"`\n\t\tmodel.PageReq\n\t}'
)
# 1h. getExtractConfigsRes - add PageRes embed
ctrl = ctrl.replace(
'type getExtractConfigsRes struct {\n\t\tList []model.ExtractConfig `json:"list"`\n\t}',
'type getExtractConfigsRes struct {\n\t\tList []model.ExtractConfig `json:"list"`\n\t\tmodel.PageRes\n\t}'
)
# 1i. GetExtractConfigs handler
ctrl = ctrl.replace(
'\t\tlist, err := svc().GetExtractConfigs(ctx, req.BusinessCode, req.ReportCode)',
'\t\tlist, total, err := svc().GetExtractConfigs(ctx, req.BusinessCode, req.ReportCode, req.PageNum, req.PageSize)'
)
ctrl = ctrl.replace(
'\t\treturn &getExtractConfigsRes{List: list}, nil',
'\t\ttotalPages := (total + req.PageSize - 1) / req.PageSize\n\t\treturn &getExtractConfigsRes{List: list, PageRes: model.PageRes{Total: total, Page: req.PageNum, PageSize: req.PageSize, TotalPages: totalPages}}, nil'
)
with open(ctrl_path, 'w') as f:
f.write(ctrl)
print('✅ Controller done')
# ========== 2. API service layer ==========
api_path = '/Users/xujiaqian/GolandProjects/HDWL/data-engine/common/report/api.go'
with open(api_path) as f:
api = f.read()
# 2a. GetAllBusinesses
api = api.replace(
'func (s *ReportService) GetAllBusinesses(ctx context.Context) ([]model.BusinessConfig, error) {\n\t\treturn s.configLoader.GetAllBusinesses(ctx)',
'func (s *ReportService) GetAllBusinesses(ctx context.Context, pageNum, pageSize int) ([]model.BusinessConfig, int, error) {\n\t\treturn s.configLoader.GetAllBusinesses(ctx, pageNum, pageSize)'
)
# 2b. GetAllReports
api = api.replace(
'func (s *ReportService) GetAllReports(ctx context.Context, businessCode string) ([]model.ReportConfig, error) {\n\t\treturn s.configLoader.GetAllReports(ctx, businessCode)',
'func (s *ReportService) GetAllReports(ctx context.Context, businessCode string, pageNum, pageSize int) ([]model.ReportConfig, int, error) {\n\t\treturn s.configLoader.GetAllReports(ctx, businessCode, pageNum, pageSize)'
)
# 2c. GetExtractConfigs
api = api.replace(
'func (s *ReportService) GetExtractConfigs(ctx context.Context, businessCode, reportCode string) ([]model.ExtractConfig, error) {\n\t\treturn s.configLoader.GetExtractConfigs(ctx, businessCode, reportCode)',
'func (s *ReportService) GetExtractConfigs(ctx context.Context, businessCode, reportCode string, pageNum, pageSize int) ([]model.ExtractConfig, int, error) {\n\t\treturn s.configLoader.GetExtractConfigs(ctx, businessCode, reportCode, pageNum, pageSize)'
)
with open(api_path, 'w') as f:
f.write(api)
print('✅ Service done')
# ========== 3. Loader layer ==========
loader_path = '/Users/xujiaqian/GolandProjects/HDWL/data-engine/common/report/config/loader.go'
with open(loader_path) as f:
loader = f.read()
# 3a. GetAllBusinesses - add deleted_at + pagination
old_biz = '''func (l *ConfigLoader) GetAllBusinesses(ctx context.Context) ([]model.BusinessConfig, error) {
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_business_config WHERE status = $1 ORDER BY id ASC",
\t\tmodel.StatusActive)
\tif err != nil {
\t\treturn nil, err
\t}
\tvar businesses []model.BusinessConfig
\tfor _, record := range r {
\t\tvar biz model.BusinessConfig
\t\tif err := record.Struct(&biz); err != nil {
\t\t\treturn nil, err
\t\t}
\t\tbusinesses = append(businesses, biz)
\t}
\treturn businesses, nil
}'''
new_biz = '''func (l *ConfigLoader) GetAllBusinesses(ctx context.Context, pageNum, pageSize int) ([]model.BusinessConfig, int, error) {
\t// 查询总数
\ttotal, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT COUNT(*) AS cnt FROM report_business_config WHERE deleted_at IS NULL")
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tcount := 0
\tif !total.IsEmpty() {
\t\tcount = total[0]["cnt"].Int()
\t}
\toffset := (pageNum - 1) * pageSize
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_business_config WHERE deleted_at IS NULL ORDER BY id ASC LIMIT $1 OFFSET $2",
\t\tpageSize, offset)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tvar businesses []model.BusinessConfig
\tfor _, record := range r {
\t\tvar biz model.BusinessConfig
\t\tif err := record.Struct(&biz); err != nil {
\t\t\treturn nil, 0, err
\t\t}
\t\tbusinesses = append(businesses, biz)
\t}
\treturn businesses, count, nil
}'''
loader = loader.replace(old_biz, new_biz, 1)
# 3b. GetAllReports - add deleted_at + pagination
old_rpt = '''func (l *ConfigLoader) GetAllReports(ctx context.Context, businessCode string) ([]model.ReportConfig, error) {
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_report_config WHERE business_code = $1 ORDER BY id ASC",
\t\tbusinessCode)
\tif err != nil {
\t\treturn nil, err
\t}
\tvar reports []model.ReportConfig
\tfor _, record := range r {
\t\tvar rpt model.ReportConfig
\t\tif err := record.Struct(&rpt); err != nil {
\t\t\treturn nil, err
\t\t}
\t\treports = append(reports, rpt)
\t}
\treturn reports, nil
}'''
new_rpt = '''func (l *ConfigLoader) GetAllReports(ctx context.Context, businessCode string, pageNum, pageSize int) ([]model.ReportConfig, int, error) {
\t// 查询总数
\ttotal, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT COUNT(*) AS cnt FROM report_report_config WHERE business_code = $1 AND deleted_at IS NULL",
\t\tbusinessCode)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tcount := 0
\tif !total.IsEmpty() {
\t\tcount = total[0]["cnt"].Int()
\t}
\toffset := (pageNum - 1) * pageSize
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_report_config WHERE business_code = $1 AND deleted_at IS NULL ORDER BY id ASC LIMIT $2 OFFSET $3",
\t\tbusinessCode, pageSize, offset)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tvar reports []model.ReportConfig
\tfor _, record := range r {
\t\tvar rpt model.ReportConfig
\t\tif err := record.Struct(&rpt); err != nil {
\t\t\treturn nil, 0, err
\t\t}
\t\treports = append(reports, rpt)
\t}
\treturn reports, count, nil
}'''
loader = loader.replace(old_rpt, new_rpt, 1)
# 3c. GetExtractConfigs - add deleted_at + pagination
old_ec = '''func (l *ConfigLoader) GetExtractConfigs(ctx context.Context, businessCode, reportCode string) ([]model.ExtractConfig, error) {
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND status = $3 AND is_enabled = $4",
\t\tbusinessCode, reportCode, model.StatusActive, true)
\tif err != nil {
\t\treturn nil, err
\t}
\tvar configs []model.ExtractConfig
\tfor _, record := range r {
\t\tvar ec model.ExtractConfig
\t\tif err := record.Struct(&ec); err != nil {
\t\t\treturn nil, err
\t\t}
\t\tconfigs = append(configs, ec)
\t}
\tl.mu.Lock()
\tl.extractCache[businessCode+":"+reportCode] = configs
\tl.mu.Unlock()
\treturn configs, nil
}'''
new_ec = '''func (l *ConfigLoader) GetExtractConfigs(ctx context.Context, businessCode, reportCode string, pageNum, pageSize int) ([]model.ExtractConfig, int, error) {
\t// 查询总数
\ttotal, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT COUNT(*) AS cnt FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL",
\t\tbusinessCode, reportCode)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tcount := 0
\tif !total.IsEmpty() {
\t\tcount = total[0]["cnt"].Int()
\t}
\toffset := (pageNum - 1) * pageSize
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL ORDER BY id ASC LIMIT $3 OFFSET $4",
\t\tbusinessCode, reportCode, pageSize, offset)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tvar configs []model.ExtractConfig
\tfor _, record := range r {
\t\tvar ec model.ExtractConfig
\t\tif err := record.Struct(&ec); err != nil {
\t\t\treturn nil, 0, err
\t\t}
\t\tconfigs = append(configs, ec)
\t}
\treturn configs, count, nil
}'''
loader = loader.replace(old_ec, new_ec, 1)
with open(loader_path, 'w') as f:
f.write(loader)
print('✅ Loader done')
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
import re
path = '/Users/xujiaqian/GolandProjects/HDWL/data-engine/common/report/config/loader.go'
with open(path) as f:
content = f.read()
# Fix `\$1` → `$1` (the \ came from Python escaping in previous script)
content = content.replace('\\$1', '$1')
content = content.replace('\\$2', '$2')
with open(path, 'w') as f:
f.write(content)
print('Fixed')
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Final pass: apply all list-pagination + deleted_at filter changes."""
import re
# ============= LOADER =============
lp = '/Users/xujiaqian/GolandProjects/HDWL/data-engine/common/report/config/loader.go'
with open(lp) as f:
l = f.read()
# 1. GetAllBusinesses: status=ACTIVE -> deleted_at IS NULL
l = l.replace(
'"SELECT * FROM report_business_config WHERE status = $1 ORDER BY id ASC"',
'"SELECT * FROM report_business_config WHERE deleted_at IS NULL ORDER BY id ASC"'
)
l = l.replace('\t\tmodel.StatusActive)', ')\n\t\t// (removed status filter)')
# 2. GetAllReports: add deleted_at IS NULL (keep existing)
# Already has no status filter, just verify
# (The user already removed status filter from GetAllReports earlier)
# 3. GetFields: status=ACTIVE -> deleted_at IS NULL
l = l.replace(
'"SELECT * FROM report_field_config WHERE business_code = $1 AND report_code = $2 AND status = $3 ORDER BY sort_order ASC"',
'"SELECT * FROM report_field_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL ORDER BY sort_order ASC"'
)
# Remove StatusActive arg from GetFields call
l = l.replace(
'businessCode, reportCode, model.StatusActive)',
'businessCode, reportCode)'
)
# 4. GetExtractConfigs: status=ACTIVE AND is_enabled=true -> deleted_at IS NULL
l = l.replace(
'"SELECT * FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND status = $3 AND is_enabled = $4"',
'"SELECT * FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL ORDER BY id ASC"'
)
l = l.replace(
'businessCode, reportCode, model.StatusActive, true)',
'businessCode, reportCode)'
)
# 5. Add GetAllFields (for edit query, returns all non-deleted regardless of status)
if 'GetAllFields' not in l:
# Add after GetFields function ends
marker = 'return fields, nil\n}\n\n// GetFieldMap 获取字段配置Map'
add = '''return fields, nil
}
// GetAllFields 获取报表全部字段(含 INACTIVE,不含已删除,用于编辑回显)
func (l *ConfigLoader) GetAllFields(ctx context.Context, businessCode, reportCode string) ([]model.FieldConfig, error) {
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_field_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL ORDER BY sort_order ASC",
\t\tbusinessCode, reportCode)
\tif err != nil {
\t\treturn nil, err
\t}
\tvar fields []model.FieldConfig
\tfor _, record := range r {
\t\tvar f model.FieldConfig
\t\tif err := record.Struct(&f); err != nil {
\t\t\treturn nil, err
\t\t}
\t\tif f.ValidAggregates == nil {
\t\t\tf.ValidAggregates = []string{}
\t\t}
\t\tif f.FilterOperators == nil {
\t\t\tf.FilterOperators = []string{"=", "!=", ">", "<", ">=", "<=", "IN", "LIKE", "BETWEEN"}
\t\t}
\t\tfields = append(fields, f)
\t}
\treturn fields, nil
}
// GetFieldMap 获取字段配置Map'''
l = l.replace(marker, add, 1)
# 6. Add paginated list methods
# ListBusinesses
if 'ListBusinesses' not in l:
add = '''// ListBusinesses 分页获取业务列表
func (l *ConfigLoader) ListBusinesses(ctx context.Context, pageNum, pageSize int) ([]model.BusinessConfig, int, error) {
\ttotal, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT COUNT(*) AS cnt FROM report_business_config WHERE deleted_at IS NULL")
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tcount := 0
\tif !total.IsEmpty() {
\t\tcount = total[0]["cnt"].Int()
\t}
\toffset := (pageNum - 1) * pageSize
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_business_config WHERE deleted_at IS NULL ORDER BY id ASC LIMIT $1 OFFSET $2",
\t\tpageSize, offset)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tvar businesses []model.BusinessConfig
\tfor _, record := range r {
\t\tvar biz model.BusinessConfig
\t\tif err := record.Struct(&biz); err != nil {
\t\t\treturn nil, 0, err
\t\t}
\t\tbusinesses = append(businesses, biz)
\t}
\treturn businesses, count, nil
}
// ListReports 分页获取报表列表
func (l *ConfigLoader) ListReports(ctx context.Context, businessCode string, pageNum, pageSize int) ([]model.ReportConfig, int, error) {
\ttotal, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT COUNT(*) AS cnt FROM report_report_config WHERE business_code = $1 AND deleted_at IS NULL",
\t\tbusinessCode)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tcount := 0
\tif !total.IsEmpty() {
\t\tcount = total[0]["cnt"].Int()
\t}
\toffset := (pageNum - 1) * pageSize
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_report_config WHERE business_code = $1 AND deleted_at IS NULL ORDER BY id ASC LIMIT $2 OFFSET $3",
\t\tbusinessCode, pageSize, offset)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tvar reports []model.ReportConfig
\tfor _, record := range r {
\t\tvar rpt model.ReportConfig
\t\tif err := record.Struct(&rpt); err != nil {
\t\t\treturn nil, 0, err
\t\t}
\t\treports = append(reports, rpt)
\t}
\treturn reports, count, nil
}
// ListExtractConfigs 分页获取抽取配置列表
func (l *ConfigLoader) ListExtractConfigs(ctx context.Context, businessCode, reportCode string, pageNum, pageSize int) ([]model.ExtractConfig, int, error) {
\ttotal, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT COUNT(*) AS cnt FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL",
\t\tbusinessCode, reportCode)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tcount := 0
\tif !total.IsEmpty() {
\t\tcount = total[0]["cnt"].Int()
\t}
\toffset := (pageNum - 1) * pageSize
\tr, err := gfdb.DB(ctx).GetAll(ctx,
\t\t"SELECT * FROM report_extract_config WHERE business_code = $1 AND report_code = $2 AND deleted_at IS NULL ORDER BY id ASC LIMIT $3 OFFSET $4",
\t\tbusinessCode, reportCode, pageSize, offset)
\tif err != nil {
\t\treturn nil, 0, err
\t}
\tvar configs []model.ExtractConfig
\tfor _, record := range r {
\t\tvar ec model.ExtractConfig
\t\tif err := record.Struct(&ec); err != nil {
\t\t\treturn nil, 0, err
\t\t}
\t\tconfigs = append(configs, ec)
\t}
\tl.mu.Lock()
\tl.extractCache[businessCode+":"+reportCode] = configs
\tl.mu.Unlock()
\treturn configs, count, nil
}
'''
# Insert before the "//" comment separator near the end
marker2 = '\n// GetReportFields 获取报表可用字段(按角色分类)'
if marker2 in l:
l = l.replace(marker2, add + marker2, 1)
else:
l += add
with open(lp, 'w') as f:
f.write(l)
print('✅ Loader')
# ============= SERVICE (api.go) =============
ap = '/Users/xujiaqian/GolandProjects/HDWL/data-engine/common/report/api.go'
with open(ap) as f:
a = f.read()
# Add ListBusinesses/ListReports/ListExtractConfigs service methods
if 'func (s *ReportService) ListBusinesses' not in a:
add_svc = '''
// ListBusinesses 分页获取业务列表
func (s *ReportService) ListBusinesses(ctx context.Context, pageNum, pageSize int) ([]model.BusinessConfig, int, error) {
\treturn s.configLoader.ListBusinesses(ctx, pageNum, pageSize)
}
// ListReports 分页获取报表列表
func (s *ReportService) ListReports(ctx context.Context, businessCode string, pageNum, pageSize int) ([]model.ReportConfig, int, error) {
\treturn s.configLoader.ListReports(ctx, businessCode, pageNum, pageSize)
}
// ListExtractConfigs 分页获取抽取配置列表
func (s *ReportService) ListExtractConfigs(ctx context.Context, businessCode, reportCode string, pageNum, pageSize int) ([]model.ExtractConfig, int, error) {
\treturn s.configLoader.ListExtractConfigs(ctx, businessCode, reportCode, pageNum, pageSize)
}
'''
# Insert after the service type definition
a = a.replace(
's.configLoader.GetAllBusinesses(ctx)\n}',
's.configLoader.GetAllBusinesses(ctx)\n}' + add_svc
)
with open(ap, 'w') as f:
f.write(a)
print('✅ Service')
# ============= CONTROLLER =============
cp = '/Users/xujiaqian/GolandProjects/HDWL/data-engine/controller/report/report_controller.go'
with open(cp) as f:
c = f.read()
# Fix ListBusinesses handler
c = c.replace(
'list, err := svc().GetAllBusinesses(ctx)',
'list, total, err := svc().ListBusinesses(ctx, req.PageNum, req.PageSize)'
)
c = c.replace(
'return &listBusinessesRes{List: list}, nil',
'totalPages := (total + req.PageSize - 1) / req.PageSize\n\t\treturn &listBusinessesRes{List: list, PageRes: model.PageRes{Total: total, Page: req.PageNum, PageSize: req.PageSize, TotalPages: totalPages}}, nil'
)
# Fix ListReports handler
c = c.replace(
'list, err := svc().GetAllReports(ctx, req.BusinessCode)',
'list, total, err := svc().ListReports(ctx, req.BusinessCode, req.PageNum, req.PageSize)'
)
c = c.replace(
'return &listReportsRes{List: list}, nil',
'totalPages := (total + req.PageSize - 1) / req.PageSize\n\t\treturn &listReportsRes{List: list, PageRes: model.PageRes{Total: total, Page: req.PageNum, PageSize: req.PageSize, TotalPages: totalPages}}, nil'
)
# Fix GetExtractConfigs handler
c = c.replace(
'list, err := svc().GetExtractConfigs(ctx, req.BusinessCode, req.ReportCode)',
'list, total, err := svc().ListExtractConfigs(ctx, req.BusinessCode, req.ReportCode, req.PageNum, req.PageSize)'
)
c = c.replace(
'return &getExtractConfigsRes{List: list}, nil',
'totalPages := (total + req.PageSize - 1) / req.PageSize\n\t\treturn &getExtractConfigsRes{List: list, PageRes: model.PageRes{Total: total, Page: req.PageNum, PageSize: req.PageSize, TotalPages: totalPages}}, nil'
)
with open(cp, 'w') as f:
f.write(c)
print('✅ Controller')
print('\nAll done!')
-5
View File
@@ -15,7 +15,6 @@ require (
require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
@@ -41,7 +40,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gogf/gf/contrib/registry/consul/v2 v2.9.5 // indirect
github.com/gogf/gf/contrib/trace/otlphttp/v2 v2.9.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/glog v1.2.5 // indirect
@@ -61,20 +59,17 @@ require (
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/serf v0.10.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+4
View File
@@ -1,6 +1,7 @@
package main
import (
"dataengine/common/report"
"dataengine/controller/debug"
"dataengine/controller/dict"
"dataengine/controller/public"
@@ -28,6 +29,9 @@ func main() {
// 启动自动同步(后台循环执行,首次全量后续增量)
syncSvc.InitAndStartAutoSync(ctx)
// 启动每日自动抽取(每天凌晨2点抽取前一天数据)
report.StartDailyExtractJob()
http.RouteRegister([]interface{}{
// 接口管理
dict.ApiInterface,
File diff suppressed because it is too large Load Diff
+468
View File
@@ -0,0 +1,468 @@
{
"openapi": "3.0.0",
"info": {
"title": "报表引擎 - saveWithFields",
"version": "1.0.0"
},
"servers": [
{
"url": "http://localhost:3013",
"description": "本地开发"
}
],
"paths": {
"/report/report/saveWithFields": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/dataengine.controller.report.saveReportWithFieldsReq",
"description": ""
},
"example": {
"id": null,
"businessCode": "KUAISHOU",
"reportCode": "shop_daily",
"reportName": "店铺日报",
"description": "快手电商店铺每日统计报表",
"status": "ACTIVE",
"statTableName": "stat_kuaishou_shop_daily",
"statTableComment": "快手电商-店铺日报",
"dateField": "stat_date",
"primaryKeys": [
"shop_id",
"stat_date"
],
"conflictKeys": [
"shop_id",
"stat_date"
],
"config": {
"sync_frequency": "daily",
"retention_days": 90
},
"fields": [
{
"id": null,
"businessCode": "KUAISHOU",
"reportCode": "shop_daily",
"fieldCode": "shop_id",
"fieldName": "店铺ID",
"fieldType": "STRING",
"dataType": "STRING",
"fieldRole": "DIMENSION",
"isAggregatable": false,
"isFilterable": true,
"isQueryable": true,
"isSortable": false,
"defaultAggregate": "",
"validAggregates": [],
"filterOperators": [
"=",
"!=",
"IN"
],
"expression": "",
"expressionType": "DIRECT",
"formatPattern": "",
"unit": "",
"dictCode": "",
"sortOrder": 1,
"groupName": "基本信息",
"status": "ACTIVE"
},
{
"id": null,
"businessCode": "KUAISHOU",
"reportCode": "shop_daily",
"fieldCode": "order_amount",
"fieldName": "订单金额",
"fieldType": "FLOAT",
"dataType": "STRING",
"fieldRole": "INDICATOR",
"isAggregatable": true,
"isFilterable": false,
"isQueryable": true,
"isSortable": true,
"defaultAggregate": "SUM",
"validAggregates": [
"SUM",
"COUNT",
"AVG",
"MAX",
"MIN"
],
"filterOperators": [],
"expression": "",
"expressionType": "DIRECT",
"formatPattern": "#,##0.00",
"unit": "元",
"dictCode": "",
"sortOrder": 2,
"groupName": "交易指标",
"status": "ACTIVE"
}
]
}
}
}
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/dataengine.controller.report.saveReportWithFieldsRes",
"description": ""
},
"example": {
"success": true,
"id": 1,
"message": "报表及字段保存成功"
}
}
},
"description": ""
}
},
"summary": "保存报表及字段配置(全量替换)",
"tags": [
"报表引擎"
]
}
}
},
"components": {
"schemas": {
"dataengine.controller.report.saveReportWithFieldsRes": {
"properties": {
"success": {
"format": "bool",
"type": "boolean",
"example": true
},
"id": {
"format": "int64",
"type": "integer",
"example": 1
},
"message": {
"format": "string",
"type": "string",
"example": "报表及字段保存成功"
}
},
"type": "object"
},
"dataengine.controller.report.saveReportWithFieldsReq": {
"properties": {
"id": {
"format": "*int64",
"type": "integer"
},
"businessCode": {
"description": "业务编码",
"format": "string",
"type": "string",
"example": "KUAISHOU"
},
"reportCode": {
"description": "报表编码",
"format": "string",
"type": "string",
"example": "shop_daily"
},
"reportName": {
"description": "报表名称",
"format": "string",
"type": "string",
"example": "店铺日报"
},
"description": {
"description": "描述",
"format": "string",
"type": "string",
"example": "快手电商店铺每日统计报表"
},
"status": {
"default": "ACTIVE",
"description": "状态",
"format": "string",
"type": "string",
"example": "ACTIVE"
},
"statTableName": {
"description": "统计宽表名",
"format": "string",
"type": "string",
"example": "stat_kuaishou_shop_daily"
},
"statTableComment": {
"description": "统计宽表注释",
"format": "string",
"type": "string",
"example": "快手电商-店铺日报"
},
"dateField": {
"default": "stat_date",
"description": "日期字段",
"format": "string",
"type": "string",
"example": "stat_date"
},
"primaryKeys": {
"description": "主键字段",
"format": "[]string",
"items": {
"format": "string",
"type": "string"
},
"type": "array",
"example": [
"shop_id",
"stat_date"
]
},
"conflictKeys": {
"description": "冲突键(唯一索引)",
"format": "[]string",
"items": {
"format": "string",
"type": "string"
},
"type": "array",
"example": [
"shop_id",
"stat_date"
]
},
"config": {
"additionalProperties": {
"$ref": "#/components/schemas/interface",
"description": ""
},
"description": "扩展配置",
"format": "map[string]interface {}",
"type": "object"
},
"fields": {
"description": "字段配置列表(全量替换,不在列表中的字段将被删除)",
"format": "[]model.SaveFieldReq",
"items": {
"$ref": "#/components/schemas/dataengine.common.report.model.SaveFieldReq",
"description": ""
},
"type": "array",
"example": [
{
"fieldCode": "shop_id",
"fieldName": "店铺ID",
"fieldType": "STRING",
"fieldRole": "DIMENSION",
"isFilterable": true,
"isQueryable": true,
"sortOrder": 1,
"status": "ACTIVE"
},
{
"fieldCode": "order_amount",
"fieldName": "订单金额",
"fieldType": "FLOAT",
"fieldRole": "INDICATOR",
"isAggregatable": true,
"defaultAggregate": "SUM",
"validAggregates": [
"SUM",
"COUNT",
"AVG"
],
"sortOrder": 2,
"status": "ACTIVE"
}
]
}
},
"required": [
"businessCode",
"reportCode",
"reportName",
"statTableName"
],
"type": "object"
},
"dataengine.common.report.model.SaveFieldReq": {
"properties": {
"id": {
"format": "*int64",
"type": "integer"
},
"businessCode": {
"description": "业务编码",
"format": "string",
"type": "string"
},
"reportCode": {
"description": "报表编码",
"format": "string",
"type": "string"
},
"fieldCode": {
"description": "字段编码",
"format": "string",
"type": "string",
"example": "order_amount"
},
"fieldName": {
"description": "字段名称",
"format": "string",
"type": "string",
"example": "订单金额"
},
"fieldType": {
"description": "字段类型 STRING/INT/FLOAT/DATE/DATETIME/JSONB",
"format": "string",
"type": "string",
"example": "FLOAT"
},
"dataType": {
"default": "STRING",
"description": "数据存储类型",
"format": "string",
"type": "string",
"example": "STRING"
},
"fieldRole": {
"description": "字段角色 DIMENSION/INDICATOR/FILTER/FILTER_ONLY",
"format": "string",
"type": "string",
"example": "INDICATOR"
},
"isAggregatable": {
"description": "是否可聚合",
"format": "bool",
"type": "boolean",
"example": true
},
"isFilterable": {
"default": true,
"description": "是否可筛选",
"format": "bool",
"type": "boolean",
"example": true
},
"isQueryable": {
"default": true,
"description": "是否可查询",
"format": "bool",
"type": "boolean",
"example": true
},
"isSortable": {
"default": true,
"description": "是否可排序",
"format": "bool",
"type": "boolean",
"example": true
},
"defaultAggregate": {
"description": "默认聚合方式",
"format": "string",
"type": "string",
"example": "SUM"
},
"validAggregates": {
"description": "可选聚合列表",
"format": "[]string",
"items": {
"format": "string",
"type": "string"
},
"type": "array",
"example": [
"SUM",
"COUNT",
"AVG"
]
},
"filterOperators": {
"description": "可选操作符列表",
"format": "[]string",
"items": {
"format": "string",
"type": "string"
},
"type": "array",
"example": [
"=",
"!=",
">",
"<",
"IN"
]
},
"expression": {
"description": "表达式(衍生字段)",
"format": "string",
"type": "string",
"example": ""
},
"expressionType": {
"description": "表达式类型 DIRECT/CALCULATED",
"format": "string",
"type": "string",
"example": "DIRECT"
},
"formatPattern": {
"description": "格式化模板",
"format": "string",
"type": "string",
"example": "#,##0.00"
},
"unit": {
"description": "单位",
"format": "string",
"type": "string",
"example": "元"
},
"dictCode": {
"description": "字典编码",
"format": "string",
"type": "string",
"example": ""
},
"sortOrder": {
"description": "排序",
"format": "int",
"type": "integer",
"example": 1
},
"groupName": {
"description": "分组名称",
"format": "string",
"type": "string",
"example": "交易指标"
},
"status": {
"default": "ACTIVE",
"description": "状态",
"format": "string",
"type": "string",
"example": "ACTIVE"
}
},
"required": [
"fieldCode",
"fieldName",
"fieldType",
"fieldRole"
],
"type": "object"
},
"interface": {
"properties": {},
"type": "object"
}
}
}
}
+5 -6
View File
@@ -15,6 +15,7 @@ import (
entity "dataengine/model/entity/dict"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/os/grpool"
"github.com/sirupsen/logrus"
)
@@ -595,16 +596,14 @@ func syncWithPrefetch(ctx context.Context, api *ApiClient, platform *PlatformCon
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, concurrency)
pool := grpool.New(concurrency)
globalMaxTime := lastSyncTime
for idx, entityVal := range allEntities {
idx, val := idx, entityVal
wg.Add(1)
sem <- struct{}{}
go func(idx int, val interface{}) {
pool.Add(ctx, func(ctx context.Context) {
defer wg.Done()
defer func() { <-sem }()
logrus.Infof(" 处理实体 [%d/%d]: %v", idx+1, len(allEntities), val)
entityMaxTime := int64(0)
@@ -728,7 +727,7 @@ func syncWithPrefetch(ctx context.Context, api *ApiClient, platform *PlatformCon
}
mu.Unlock()
}
}(idx, entityVal)
})
}
wg.Wait()