diff --git a/cmd/report_build_http_test.go b/cmd/report_build_http_test.go new file mode 100644 index 0000000..b1b030a --- /dev/null +++ b/cmd/report_build_http_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + businessbiz "kra/internal/biz/business" + taskbiz "kra/internal/biz/task" + businessdata "kra/internal/data/business" + taskdata "kra/internal/data/task" + "kra/internal/server/handler" + "kra/internal/server/router" + taskservice "kra/internal/service/task" + "kra/internal/worker" + platformtask "kra/pkg/task" +) + +type reportTaskHTTPData struct{ businessHTTPData } + +func (reportTaskHTTPData) DatabaseReady() bool { return true } + +func TestReportBuildThroughNativeTaskHTTP(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "report-task.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + t.Cleanup(func() { _ = pool.Close() }) + for _, step := range append(taskdata.Migrations(), businessdata.Migrations()...) { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + for _, sql := range []string{ + "CREATE TABLE tab_user(id INTEGER,source_db TEXT,puid INTEGER,promote_id INTEGER,fgame_id INTEGER,idcard TEXT,register_time TEXT)", + "CREATE TABLE tab_user_day_login(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,login_time INTEGER)", + "CREATE TABLE tab_user_play_info(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,create_time INTEGER,play_time INTEGER)", + "CREATE TABLE tab_spend(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,pay_time INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))", + "CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)", + "INSERT INTO tab_game VALUES(1,'lx','核验游戏')", + "CREATE TABLE tab_sw_report_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,new_pay_cnt INTEGER,total_pay DECIMAL(20,2))", + "CREATE TABLE tab_sw_report_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)", + "CREATE TABLE tab_sw_report_server_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,total_pay DECIMAL(20,2))", + "CREATE TABLE tab_sw_report_server_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + stamp := time.Date(2026, 11, 4, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).Unix() + for _, sql := range []string{"INSERT INTO tab_user VALUES(1,'lx',0,10,1,'aX',?)", "INSERT INTO tab_user_day_login VALUES(1,'lx',10,1,?)", "INSERT INTO tab_spend VALUES(1,'lx',10,1,'001',?,1,12.50)"} { + if err := db.Exec(sql, stamp).Error; err != nil { + t.Fatal(err) + } + } + if err := db.Exec("INSERT INTO tab_user_play_info VALUES(1,'lx',10,1,'001',?,?)", stamp, stamp).Error; err != nil { + t.Fatal(err) + } + provider := businessHTTPData{db} + registry := platformtask.NewRegistry() + tasks := taskbiz.NewTaskUsecaseWithRegistry(taskdata.NewTaskRepo(reportTaskHTTPData{provider}), registry) + reports := businessbiz.NewReportBuildUsecase(businessdata.NewReportBuildRepo(provider)) + worker.NewTaskMethods(tasks, nil, nil, nil, reports).RegisterTasks(registry) + scheduler := worker.NewTaskScheduler(tasks, nil, worker.NewTaskExecutorWithRegistry(tasks, registry), nil) + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = scheduler.Stop(ctx) + }() + h := handler.NewTask(taskservice.NewTaskService(taskbiz.NewTaskApplicationUsecase(tasks, scheduler))) + engine := gin.New() + router.RegisterTask(engine.Group(""), h) + srv := httptest.NewServer(engine) + defer srv.Close() + call := func(method, path string, body any) map[string]any { + t.Helper() + raw, _ := json.Marshal(body) + req, _ := http.NewRequest(method, srv.URL+"/timedTask/"+path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + res, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var value map[string]any + if err := json.NewDecoder(res.Body).Decode(&value); err != nil { + t.Fatal(err) + } + if value["code"] != float64(0) { + t.Fatalf("原生任务接口失败: %+v", value) + } + return value["data"].(map[string]any) + } + methods := call("GET", "getRegisteredMethods", nil)["methods"].([]any) + if len(methods) != 8 { + t.Fatalf("六个商务汇总方法未注册: %d", len(methods)) + } + for index, method := range []string{"swReportDayBackfillTask", "swReportServerDayBackfillTask"} { + name := fmt.Sprintf("隔离汇总任务%d", index) + call("POST", "createTimedTask", map[string]any{"name": name, "spec": "@daily", "executorType": "method", "methodName": method, "params": map[string]any{"startDate": "2026-11-04", "endDate": "2026-11-04"}, "enabled": false}) + var id uint + if err := db.Table("sys_timed_tasks").Select("id").Where("name=?", name).Scan(&id).Error; err != nil || id == 0 { + t.Fatal("任务创建未落库") + } + call("POST", "triggerTimedTask", map[string]any{"id": id}) + deadline := time.Now().Add(8 * time.Second) + done := false + for time.Now().Before(deadline) { + logs := call("GET", fmt.Sprintf("getTimedTaskLogList?taskId=%d&page=1&pageSize=10", id), nil)["list"].([]any) + if len(logs) > 0 { + if logs[0].(map[string]any)["status"] != "success" { + t.Fatalf("汇总任务失败: %+v", logs) + } + done = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !done { + t.Fatal("未等到汇总任务完成") + } + } + for _, table := range []string{"tab_sw_report_day", "tab_sw_report_server_day"} { + var rows []struct { + TotalPay string + NewRegisterCnt, ActiveCnt, PayCnt int + } + if err := db.Table(table).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[0].TotalPay != "12.5" || rows[0].NewRegisterCnt != 1 || rows[0].ActiveCnt != 1 || rows[0].PayCnt != 1 { + t.Fatalf("任务没有实际生成双口径汇总: %+v", rows) + } + } + var enabled int64 + db.Table("sys_timed_tasks").Where("enabled=1").Count(&enabled) + if enabled != 0 { + t.Fatal("手动回跑不应启用自动计划") + } +} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index aeff065..cdee1b1 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -288,7 +288,9 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg routes := router.NewRoutes(set) maintenanceRepo := system.NewMaintenanceRepo(dataData) maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo) - taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store) + reportBuildRepo := business.NewReportBuildRepo(dataData) + reportBuildUsecase := business2.NewReportBuildUsecase(reportBuildRepo) + taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store, reportBuildUsecase) routeRegistrar := buildRuntime(registry, routes, taskMethods) websocketServer, cleanup2, err := websocket.New(runtimeconfigStore) if err != nil { diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index d719865..ddeedb9 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -27,7 +27,7 @@ | 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 | | 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 | | 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 | -| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏/区服统计、独立排行榜与工作台仪表盘已接入,汇总构建及预警任务待迁移 | +| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、明细及导出、各报表/排行榜、工作台和两类汇总构建已接入,预警检测任务与最终实库验收待完成 | | 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 | | 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 | @@ -101,3 +101,7 @@ - 工作台批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建通过;真实 sw7 请求记录见 `workbench-http-2026-11-04.json`。缺业务表时六卡返回 null、榜单明确报未接入。未执行页面交互检查或最终旧库复制。 - 独立排行榜渠道、商务、游戏三个接口和 Kra 页面已接入,各有注册/充值/付费率/活跃 TOP20,保留平台、维度独立日期记忆、近 7 天默认周期和等长上期。付费率用百分点差,其余用百分比环比。商务按直属下级展示、合并其整个下级绑定树,再累加各渠道人数;三级不展示商务排行。 - 排行批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建、真实 sw7 接口检查通过,见 `ranking-http-2026-11-04.json` 与 `ranking-source-notes.md`。当前 SQL 清单 63 个目标接口中 51 个已注册;其余 12 个均为 `SysUser` 历史系统别名,详见 `system-api-audit.md`,不能将接口注册数量当作全迁移完成证明。汇总和预警任务、最终旧库迁移验收仍待完成。 +- 已迁移日汇总/区服汇总的今日、昨日、按日期回跑六个任务方法,复用 Kra 原生任务创建、手动触发、执行日志和取消机制。汇总和身份键同事务替换,按报表类型与日期持有数据库行锁;增加 `202611040008_business_report_build_locks`。只注册方法,不自动新建或启用计划。当前真实 sw7 的 7 个导入任务均保持禁用。 +- 汇总领域/仓储测试和原生任务实际 TCP HTTP 测试通过,覆盖账号/身份证双口径、主/子账号差异、无平台登录歧义、原始账号新增付费交集、自然日格式、幂等回跑及写入失败回滚。全量 Go 通过;真实服务方法和禁用状态验证见 `report-build-http-2026-11-04.json`。未触发实际 sw7 的数据重建;完整源库重跑比较仍留待最终阶段。 +- 用户反馈仅排行榜图标可显示,定位为原 SQL 直接复制 RuoYi SVG 名称。已修正原 SQL 首次迁移和重复执行分支,模块菜单改用统一的 14 项有效 Kra 图标;新增 `202611040009_business_menu_icons` 修复已迁移库,单独补丁为 `menu-icon-repair.sql`。仅替换旧迁移值,保留其他自定义图标。 +- 14 个图标都已通过本地已安装组件的 SVG 输出验证;实际 8000 和 18000 菜单接口全部返回正确图标,见 `menu-icons-components-2026-11-04.json`、`menu-icons-live-http-2026-11-04.json`、`menu-icons-http-2026-11-04.json`。这次按用户明确要求修改原迁移 SQL,原有暂存内容继续保留;未执行整个旧库迁移脚本。 diff --git a/docs/sw6-to-kra/menu-icon-repair.sql b/docs/sw6-to-kra/menu-icon-repair.sql new file mode 100644 index 0000000..2c0b177 --- /dev/null +++ b/docs/sw6-to-kra/menu-icon-repair.sql @@ -0,0 +1,22 @@ +-- 已完成迁移的 Kra 数据库可单独执行;仅更新商务菜单的旧图标值。 +START TRANSACTION; +-- RuoYi SVG 名称转换为 Kra 已注册图标;只修复旧值,保留后续自定义选择。 +UPDATE sys_base_menus SET icon=CASE name + WHEN 'sw_BusMgmt' THEN 'Briefcase' + WHEN 'sw_BusinessList' THEN 'UserFilled' + WHEN 'sw_ChannelList' THEN 'Connection' + WHEN 'sw_ChannelActivationList' THEN 'CircleCheck' + WHEN 'sw_DataAnalysis' THEN 'DataAnalysis' + WHEN 'sw_ChannelDataStatistics' THEN 'Histogram' + WHEN 'sw_RechargeDetails' THEN 'Money' + WHEN 'sw_RegistrationDetails' THEN 'User' + WHEN 'sw_GameAnalytics' THEN 'TrendCharts' + WHEN 'sw_ServerAnalysis' THEN 'Monitor' + WHEN 'sw_RankingAnalysis' THEN 'Star' + WHEN 'sw_AlertCenter' THEN 'Bell' + WHEN 'sw_AlertList' THEN 'Warning' + WHEN 'sw_AlertRules' THEN 'Setting' + ELSE icon END +WHERE name IN ('sw_BusMgmt','sw_BusinessList','sw_ChannelList','sw_ChannelActivationList','sw_DataAnalysis','sw_ChannelDataStatistics','sw_RechargeDetails','sw_RegistrationDetails','sw_GameAnalytics','sw_ServerAnalysis','sw_RankingAnalysis','sw_AlertCenter','sw_AlertList','sw_AlertRules') + AND (icon IS NULL OR icon IN ('', 'category', 'email', 'chart', 'company', 'international', 'color', 'dict', 'cascader', 'education', 'excel', 'star', 'process-definition', 'documentation', 'example', 'data-analysis', 'user', 'money', 'warn')); +COMMIT; diff --git a/docs/sw6-to-kra/menu-icons-components-2026-11-04.json b/docs/sw6-to-kra/menu-icons-components-2026-11-04.json new file mode 100644 index 0000000..d48f739 --- /dev/null +++ b/docs/sw6-to-kra/menu-icons-components-2026-11-04.json @@ -0,0 +1,76 @@ +{ + "method": "本地已安装组件的 SSR SVG 验证,包含首次/重复迁移 SQL 与模块定义一致性检查", + "icons": [ + { + "name": "sw_BusMgmt", + "icon": "Briefcase", + "svgRendered": true + }, + { + "name": "sw_BusinessList", + "icon": "UserFilled", + "svgRendered": true + }, + { + "name": "sw_ChannelList", + "icon": "Connection", + "svgRendered": true + }, + { + "name": "sw_ChannelActivationList", + "icon": "CircleCheck", + "svgRendered": true + }, + { + "name": "sw_DataAnalysis", + "icon": "DataAnalysis", + "svgRendered": true + }, + { + "name": "sw_ChannelDataStatistics", + "icon": "Histogram", + "svgRendered": true + }, + { + "name": "sw_RechargeDetails", + "icon": "Money", + "svgRendered": true + }, + { + "name": "sw_RegistrationDetails", + "icon": "User", + "svgRendered": true + }, + { + "name": "sw_GameAnalytics", + "icon": "TrendCharts", + "svgRendered": true + }, + { + "name": "sw_ServerAnalysis", + "icon": "Monitor", + "svgRendered": true + }, + { + "name": "sw_RankingAnalysis", + "icon": "Star", + "svgRendered": true + }, + { + "name": "sw_AlertCenter", + "icon": "Bell", + "svgRendered": true + }, + { + "name": "sw_AlertList", + "icon": "Warning", + "svgRendered": true + }, + { + "name": "sw_AlertRules", + "icon": "Setting", + "svgRendered": true + } + ], + "passed": true +} diff --git a/docs/sw6-to-kra/menu-icons-http-2026-11-04.json b/docs/sw6-to-kra/menu-icons-http-2026-11-04.json new file mode 100644 index 0000000..d17529d --- /dev/null +++ b/docs/sw6-to-kra/menu-icons-http-2026-11-04.json @@ -0,0 +1,91 @@ +{ + "baseURL": "http://127.0.0.1:18000", + "menuCount": 14, + "checks": [ + { + "name": "sw_BusMgmt", + "icon": "Briefcase", + "expected": "Briefcase", + "passed": true + }, + { + "name": "sw_BusinessList", + "icon": "UserFilled", + "expected": "UserFilled", + "passed": true + }, + { + "name": "sw_ChannelList", + "icon": "Connection", + "expected": "Connection", + "passed": true + }, + { + "name": "sw_ChannelActivationList", + "icon": "CircleCheck", + "expected": "CircleCheck", + "passed": true + }, + { + "name": "sw_DataAnalysis", + "icon": "DataAnalysis", + "expected": "DataAnalysis", + "passed": true + }, + { + "name": "sw_ChannelDataStatistics", + "icon": "Histogram", + "expected": "Histogram", + "passed": true + }, + { + "name": "sw_RechargeDetails", + "icon": "Money", + "expected": "Money", + "passed": true + }, + { + "name": "sw_RegistrationDetails", + "icon": "User", + "expected": "User", + "passed": true + }, + { + "name": "sw_GameAnalytics", + "icon": "TrendCharts", + "expected": "TrendCharts", + "passed": true + }, + { + "name": "sw_ServerAnalysis", + "icon": "Monitor", + "expected": "Monitor", + "passed": true + }, + { + "name": "sw_RankingAnalysis", + "icon": "Star", + "expected": "Star", + "passed": true + }, + { + "name": "sw_AlertCenter", + "icon": "Bell", + "expected": "Bell", + "passed": true + }, + { + "name": "sw_AlertList", + "icon": "Warning", + "expected": "Warning", + "passed": true + }, + { + "name": "sw_AlertRules", + "icon": "Setting", + "expected": "Setting", + "passed": true + } + ], + "passed": true +} diff --git a/docs/sw6-to-kra/menu-icons-live-http-2026-11-04.json b/docs/sw6-to-kra/menu-icons-live-http-2026-11-04.json new file mode 100644 index 0000000..0e05812 --- /dev/null +++ b/docs/sw6-to-kra/menu-icons-live-http-2026-11-04.json @@ -0,0 +1,91 @@ +{ + "baseURL": "http://127.0.0.1:8000", + "menuCount": 14, + "checks": [ + { + "name": "sw_BusMgmt", + "icon": "Briefcase", + "expected": "Briefcase", + "passed": true + }, + { + "name": "sw_BusinessList", + "icon": "UserFilled", + "expected": "UserFilled", + "passed": true + }, + { + "name": "sw_ChannelList", + "icon": "Connection", + "expected": "Connection", + "passed": true + }, + { + "name": "sw_ChannelActivationList", + "icon": "CircleCheck", + "expected": "CircleCheck", + "passed": true + }, + { + "name": "sw_DataAnalysis", + "icon": "DataAnalysis", + "expected": "DataAnalysis", + "passed": true + }, + { + "name": "sw_ChannelDataStatistics", + "icon": "Histogram", + "expected": "Histogram", + "passed": true + }, + { + "name": "sw_RechargeDetails", + "icon": "Money", + "expected": "Money", + "passed": true + }, + { + "name": "sw_RegistrationDetails", + "icon": "User", + "expected": "User", + "passed": true + }, + { + "name": "sw_GameAnalytics", + "icon": "TrendCharts", + "expected": "TrendCharts", + "passed": true + }, + { + "name": "sw_ServerAnalysis", + "icon": "Monitor", + "expected": "Monitor", + "passed": true + }, + { + "name": "sw_RankingAnalysis", + "icon": "Star", + "expected": "Star", + "passed": true + }, + { + "name": "sw_AlertCenter", + "icon": "Bell", + "expected": "Bell", + "passed": true + }, + { + "name": "sw_AlertList", + "icon": "Warning", + "expected": "Warning", + "passed": true + }, + { + "name": "sw_AlertRules", + "icon": "Setting", + "expected": "Setting", + "passed": true + } + ], + "passed": true +} diff --git a/docs/sw6-to-kra/menu-icons.md b/docs/sw6-to-kra/menu-icons.md new file mode 100644 index 0000000..7dfe7b0 --- /dev/null +++ b/docs/sw6-to-kra/menu-icons.md @@ -0,0 +1,19 @@ +# 商务菜单图标修复 + +旧 SQL 使用 `COALESCE(n.icon,'')` 原样迁移 RuoYi 图标名。实际菜单接口返回 category/email/chart/international 等,Kra 未注册这些图标;star 恰好对应 Element Plus 的 Star。 + +Kra 的 `web/src/core/global.js` 注册全部 `@element-plus/icons-vue` 导出组件,以及仓库本地 SVG。现在明确使用组件导出名,避免依赖旧 SVG 文件或大小写猜测。 + +| 菜单 | Kra 图标 | +| --- | --- | +| 商务管理 / 商务列表 | Briefcase / UserFilled | +| 渠道列表 / 渠道激活 | Connection / CircleCheck | +| 数据分析 / 渠道数据统计 | DataAnalysis / Histogram | +| 充值明细 / 注册明细 | Money / User | +| 游戏数据 / 区服分析 | TrendCharts / Monitor | +| 排行榜 | Star | +| 预警中心 / 预警列表 / 预警规则 | Bell / Warning / Setting | + +首次迁移按旧菜单 path 映射;重复执行分支和 `202611040009_business_menu_icons` 修复已迁移库。仅替换原迁移、早期模块默认图标或空值,其他自定义图标保留。无需完整重跑数据库迁移,独立 SQL 补丁见 `menu-icon-repair.sql`。 + +`scripts/sw_menu_icons_verify.cjs` 检查当前安装的真实组件、SVG 输出、模块定义和 SQL 两条分支的一致性。实际 8000/18000 菜单接口均已通过 14 项检查;没有进行浏览器交互检查。刷新页面重新加载菜单后生效。 diff --git a/docs/sw6-to-kra/report-build-http-2026-11-04.json b/docs/sw6-to-kra/report-build-http-2026-11-04.json new file mode 100644 index 0000000..9d0f532 --- /dev/null +++ b/docs/sw6-to-kra/report-build-http-2026-11-04.json @@ -0,0 +1,14 @@ +{ + "scope": "真实 sw7 会话,只读任务接口;正向创建和触发通过隔离数据库原生任务 HTTP 测试。", + "registeredMethods": [ + "swReportDayBackfillTask", + "swReportDayTodayTask", + "swReportDayYesterdayTask", + "swReportServerDayBackfillTask", + "swReportServerDayTodayTask", + "swReportServerDayYesterdayTask" + ], + "importedTasks": 7, + "enabledImportedTasks": 0, + "passed": true +} diff --git a/docs/sw6-to-kra/report-build-source-notes.md b/docs/sw6-to-kra/report-build-source-notes.md new file mode 100644 index 0000000..db9e4d7 --- /dev/null +++ b/docs/sw6-to-kra/report-build-source-notes.md @@ -0,0 +1,27 @@ +# 日汇总和区服汇总任务迁移 + +核对旧 main 的两个 `SwReport*BuildServiceImpl`、两个 SnailJob 执行器、`ReportIdentityHelper` 和 `ReportBuildRangeSupport`。 + +## 原始数据口径 + +- 日汇总注册只取 `tab_user.puid=0`,注册时间按原 `CAST(register_time AS UNSIGNED)` 语义。按平台、渠道、首次游戏分组。 +- 日汇总活跃来自 `tab_user_day_login`,必须能找到同平台主账号。记录无平台时,仅当全库同 ID 恰好一个主账号时才接受;跨平台存在多个主账号时跳过。缺渠道回退账号的渠道。 +- 日汇总充值取当天成功支付,金额不限制主账号;付款用户不存在时模式 0 仍计账号,模式 1 因无身份证不计身份。没有付款用户 ID 的成功单仍计金额。 +- 新增付费先取同一分组内“当日注册账号 ∩ 当日付费账号”,再转身份证;不能直接用身份证交集,否则会把同证件的其他账号误认成新增付费。 +- 区服注册取 `tab_user_play_info.create_time` 的创角,活跃取 `play_time`,不套用日注册/登录的主账号限制。区服编号去首尾空白,保留前导零;创角/活跃缺游戏 ID 时忽略,充值缺游戏 ID 沿用 0。区服汇总没有新增付费指标。 +- 两类报表均按日、平台、渠道、游戏(区服额外编号)生成模式 0/1 两行及对应身份键。模式 1 去首尾空格、仅将末尾 X 转 x,空身份证丢弃。金额两种模式相同,查询层仍固定读取模式 0。 +- 游戏名作为当前游戏资料的快照写入。游戏/用户按关联 ID 批量加载,平台作为字典键的一部分,不混淆跨平台同号。 + +## 原子替换与任务 + +- 所有原始数据读取、计算和两张报表替换在同一事务。按报表类型+日期的锁表串行同分区重建,避免不同任务同时回跑同一天。读不到必要源表/目标表时拒绝重建,不清空既有数据。 +- 汇总、身份键按批写入,失败或取消回滚原有分区;其他日期不变。输出日期固定 `yyyy-MM-dd`,不采用驱动返回的时间格式作为写入日期。 +- 任务方法保留 `swReportDayTodayTask` / `YesterdayTask` / `BackfillTask` 和对应 `swReportServerDay*` 六个名字。已有迁移 SQL 保留旧执行器名和 `_execution` 参数,可直接绑定 Kra 方法执行器。 +- 回跑参数为 startDate/endDate;每个缺省日期独立取昨日,闭区间逐日重建、日间暂停一秒,最后一天不暂停。支持上下文取消,非法/倒置日期报错。 +- 不新建默认商务计划、不启用旧导入计划。原始同步表和四张业务报表表沿用旧库结构;本批只新建日期互斥锁表,不在系统库中伪造完整业务数据源。 + +## 验证边界 + +领域与存储测试覆盖身份/账号规则、重复构建、自然日筛选和身份明细写失败后的原子回滚。真实 TCP HTTP 使用隔离数据库和 Kra 原生任务创建、手动触发、执行日志,核对两种报表实际落库且任务仍为禁用。 + +全量 Go 测试通过。当前真实 sw7 仅查询注册方法和计划状态,7 个导入任务均禁用。没有在真实 sw7 重建报表,也没有开始最终旧数据库复制与同源结果比较;MySQL 并发锁效果须在最终隔离旧库联调中验证。 diff --git a/internal/biz/biz.go b/internal/biz/biz.go index f126cd9..3c639c5 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -11,4 +11,4 @@ import ( ) // ProviderSet is biz providers. -var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase) +var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase) diff --git a/internal/biz/business/report_build.go b/internal/biz/business/report_build.go new file mode 100644 index 0000000..a2c346b --- /dev/null +++ b/internal/biz/business/report_build.go @@ -0,0 +1,284 @@ +package business + +import ( + "context" + "math/big" + "sort" + "strconv" + "strings" + "time" + + "github.com/go-kratos/kratos/v3/errors" +) + +var ErrReportBuildUnavailable = errors.BadRequest("SW_REPORT_BUILD_UNAVAILABLE", "原始数据或汇总表尚未接入,不能重建报表") + +type ReportBuildKind string + +const ( + DailyReport ReportBuildKind = "day" + ServerReport ReportBuildKind = "server" +) + +type ReportEvent struct { + SourceDB string + UserID, PromoteID, GameID *int64 + ServerNum string + Amount string +} +type ReportUserKey struct { + SourceDB string + ID int64 +} +type ReportUser struct { + ReportUserKey + ParentID, PromoteID *int64 + IDCard string +} +type ReportInput struct { + Registers, Actives, Payments []ReportEvent + Users map[ReportUserKey]ReportUser + Games map[AnalyticsKey]string +} +type ReportGroup struct { + SourceDB string + PromoteID, GameID int64 + ServerNum string +} +type ReportBuiltRow struct { + ReportGroup + Mode int + GameName, Pay string + Counts PortraitCounts + Keys map[int][]string +} +type ReportBuildRepo interface { + Rebuild(context.Context, ReportBuildKind, time.Time, func(*ReportInput) ([]*ReportBuiltRow, error)) error +} +type ReportBuildUsecase struct { + repo ReportBuildRepo + now func() time.Time + pause func(context.Context) error +} + +func NewReportBuildUsecase(repo ReportBuildRepo) *ReportBuildUsecase { + return &ReportBuildUsecase{repo: repo, now: time.Now, pause: func(ctx context.Context) error { + timer := time.NewTimer(time.Second) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } + }} +} +func (uc *ReportBuildUsecase) Run(ctx context.Context, kind ReportBuildKind, period, start, end string) error { + if kind != DailyReport && kind != ServerReport { + return ErrAnalyticsInput + } + w := workbenchWindow(uc.now()) + begin, finish := w.Yesterday, w.Yesterday + switch period { + case "today": + begin, finish = w.Today, w.Today + case "yesterday": + case "backfill": + var err error + if strings.TrimSpace(start) != "" { + begin, err = time.ParseInLocation("2006-01-02", strings.TrimSpace(start), portraitLocation) + if err != nil { + return ErrAnalyticsDate + } + } + if strings.TrimSpace(end) != "" { + finish, err = time.ParseInLocation("2006-01-02", strings.TrimSpace(end), portraitLocation) + if err != nil { + return ErrAnalyticsDate + } + } + default: + return ErrAnalyticsInput + } + if finish.Before(begin) { + return ErrAnalyticsDateOrder + } + for day := begin; !day.After(finish); day = day.AddDate(0, 0, 1) { + if err := ctx.Err(); err != nil { + return err + } + if err := uc.repo.Rebuild(ctx, kind, day, func(input *ReportInput) ([]*ReportBuiltRow, error) { return compileReport(kind, input) }); err != nil { + return err + } + if day.Before(finish) { + if err := uc.pause(ctx); err != nil { + return err + } + } + } + return nil +} + +type reportAggregate struct { + users map[int]map[int64]bool + pay *big.Rat +} + +func compileReport(kind ReportBuildKind, input *ReportInput) ([]*ReportBuiltRow, error) { + groups := map[ReportGroup]*reportAggregate{} + number := func(value *int64) int64 { + if value == nil { + return 0 + } + return *value + } + group := func(event ReportEvent) ReportGroup { + return ReportGroup{SourceDB: event.SourceDB, PromoteID: number(event.PromoteID), GameID: number(event.GameID), ServerNum: func() string { + if kind == ServerReport { + return strings.TrimSpace(event.ServerNum) + } + return "" + }()} + } + get := func(key ReportGroup) *reportAggregate { + if groups[key] == nil { + groups[key] = &reportAggregate{pay: new(big.Rat), users: map[int]map[int64]bool{1: {}, 2: {}, 3: {}, 4: {}}} + } + return groups[key] + } + // 无平台登录仅在全库唯一主账号匹配时归属,提前建立索引避免逐条扫描账号。 + unique := map[int64]*ReportUser{} + ambiguous := map[int64]bool{} + for _, user := range input.Users { + if user.ParentID == nil || *user.ParentID != 0 { + continue + } + if _, ok := unique[user.ID]; ok { + ambiguous[user.ID] = true + } + copy := user + unique[user.ID] = © + } + for _, event := range input.Registers { + if event.UserID == nil { + continue + } + if kind == ServerReport && (strings.TrimSpace(event.ServerNum) == "" || event.GameID == nil) { + continue + } + get(group(event)).users[1][*event.UserID] = true + } + for _, event := range input.Actives { + if event.UserID == nil { + continue + } + if kind == ServerReport { + if strings.TrimSpace(event.ServerNum) == "" || event.GameID == nil { + continue + } + } else { + var user *ReportUser + if strings.TrimSpace(event.SourceDB) == "" { + if ambiguous[*event.UserID] { + continue + } + user = unique[*event.UserID] + if user != nil { + event.SourceDB = user.SourceDB + } + } else { + if value, ok := input.Users[ReportUserKey{SourceDB: event.SourceDB, ID: *event.UserID}]; ok { + user = &value + } + } + if user == nil || user.ParentID == nil || *user.ParentID != 0 { + continue + } + if event.PromoteID == nil { + event.PromoteID = user.PromoteID + } + } + get(group(event)).users[2][*event.UserID] = true + } + for _, event := range input.Payments { + if kind == ServerReport && strings.TrimSpace(event.ServerNum) == "" { + continue + } + a := get(group(event)) + if event.UserID != nil { + a.users[3][*event.UserID] = true + } + amount, err := portraitNumber(event.Amount) + if err != nil { + return nil, err + } + a.pay.Add(a.pay, amount) + } + keys := make([]ReportGroup, 0, len(groups)) + for key := range groups { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + a, b := keys[i], keys[j] + if a.SourceDB != b.SourceDB { + return a.SourceDB < b.SourceDB + } + if a.PromoteID != b.PromoteID { + return a.PromoteID < b.PromoteID + } + if a.GameID != b.GameID { + return a.GameID < b.GameID + } + return a.ServerNum < b.ServerNum + }) + out := []*ReportBuiltRow{} + for _, key := range keys { + a := groups[key] + if len(a.users[1])+len(a.users[2])+len(a.users[3]) == 0 && a.pay.Sign() == 0 { + continue + } + if kind == DailyReport { + for id := range a.users[3] { + if a.users[1][id] { + a.users[4][id] = true + } + } + } + for mode := 0; mode <= 1; mode++ { + row := &ReportBuiltRow{ReportGroup: key, Mode: mode, GameName: input.Games[AnalyticsKey{SourceDB: key.SourceDB, ID: key.GameID}], Pay: portraitDecimal(a.pay, 2), Keys: map[int][]string{}} + for metric, ids := range a.users { + if metric == 4 && kind == ServerReport { + continue + } + identities := map[string]bool{} + for id := range ids { + identity := strconv.FormatInt(id, 10) + if mode == 1 { + identity = normalizeIDCard(input.Users[ReportUserKey{SourceDB: key.SourceDB, ID: id}].IDCard) + } + if identity != "" { + identities[identity] = true + } + } + values := make([]string, 0, len(identities)) + for value := range identities { + values = append(values, value) + } + sort.Strings(values) + row.Keys[metric] = values + switch metric { + case 1: + row.Counts.Register = int64(len(values)) + case 2: + row.Counts.Active = int64(len(values)) + case 3: + row.Counts.Pay = int64(len(values)) + case 4: + row.Counts.NewPay = int64(len(values)) + } + } + out = append(out, row) + } + } + return out, nil +} diff --git a/internal/biz/business/report_build_test.go b/internal/biz/business/report_build_test.go new file mode 100644 index 0000000..be8a8b1 --- /dev/null +++ b/internal/biz/business/report_build_test.go @@ -0,0 +1,105 @@ +package business + +import ( + "context" + "testing" + "time" +) + +func reportPtr(n int64) *int64 { return &n } +func TestReportBuildIdentityRulesAndAnonymousLogins(t *testing.T) { + input := &ReportInput{Users: map[ReportUserKey]ReportUser{}, Games: map[AnalyticsKey]string{{SourceDB: "lx", ID: 1}: "游戏"}} + for _, u := range []ReportUser{{ReportUserKey: ReportUserKey{"lx", 1}, ParentID: reportPtr(0), PromoteID: reportPtr(10), IDCard: " aaX "}, {ReportUserKey: ReportUserKey{"hw", 1}, ParentID: reportPtr(0), PromoteID: reportPtr(20), IDCard: "b"}, {ReportUserKey: ReportUserKey{"lx", 2}, ParentID: reportPtr(0), PromoteID: reportPtr(10), IDCard: "aax"}, {ReportUserKey: ReportUserKey{"lx", 3}, ParentID: reportPtr(1), PromoteID: reportPtr(10), IDCard: "sub"}, {ReportUserKey: ReportUserKey{"lx", 4}, PromoteID: reportPtr(10)}, {ReportUserKey: ReportUserKey{"lx", 5}, ParentID: reportPtr(0), PromoteID: reportPtr(10)}} { + input.Users[u.ReportUserKey] = u + } + event := func(id int64) ReportEvent { + return ReportEvent{SourceDB: "lx", UserID: reportPtr(id), PromoteID: reportPtr(10), GameID: reportPtr(1)} + } + input.Registers = []ReportEvent{event(1), event(2), event(5)} + missing := event(2) + missing.SourceDB = "" + missing.PromoteID = nil + ambiguous := event(1) + ambiguous.SourceDB = "" + input.Actives = []ReportEvent{event(1), missing, ambiguous, event(3), event(4)} + for _, id := range []int64{1, 3, 99} { + p := event(id) + p.Amount = "10" + input.Payments = append(input.Payments, p) + } + noUser := event(1) + noUser.UserID = nil + noUser.Amount = "1" + input.Payments = append(input.Payments, noUser) + rows, err := compileReport(DailyReport, input) + if err != nil || len(rows) != 2 { + t.Fatalf("汇总分组错误: %+v %v", rows, err) + } + for _, row := range rows { + if row.Pay != "31.00" || row.GameName != "游戏" || row.Counts.NewPay != 1 { + t.Fatalf("金额、名称或新增付费错误: %+v", row) + } + if row.Mode == 0 && (row.Counts.Register != 3 || row.Counts.Active != 2 || row.Counts.Pay != 3) { + t.Fatalf("账号口径未保留主账号登录/原始付费账号: %+v", row) + } + if row.Mode == 1 && (row.Counts.Register != 1 || row.Counts.Active != 1 || row.Counts.Pay != 2) { + t.Fatalf("身份证口径、空证件或末尾 X 归一错误: %+v", row) + } + } + input.Registers = []ReportEvent{event(2)} + input.Payments = []ReportEvent{event(1)} + input.Payments[0].Amount = "10" + rows, err = compileReport(DailyReport, input) + if err != nil { + t.Fatal(err) + } + for _, row := range rows { + if row.Counts.NewPay != 0 { + t.Fatal("新增付费必须先取原始账号交集,不能把相同证件的其他付费账号算入") + } + } + server := event(3) + server.ServerNum = " 001 " + input.Registers = []ReportEvent{server} + input.Actives = []ReportEvent{server} + input.Payments = []ReportEvent{server} + input.Payments[0].Amount = "20" + rows, err = compileReport(ServerReport, input) + if err != nil || len(rows) != 2 { + t.Fatal("区服汇总失败") + } + for _, row := range rows { + if row.ServerNum != "001" || row.Counts.Register != 1 || row.Counts.Active != 1 || row.Counts.Pay != 1 || len(row.Keys[4]) != 0 { + t.Fatal("区服创角/活跃不能套用主账号限制,也不能生成新增付费指标") + } + } +} + +type reportBuildFake struct{ days []string } + +func (r *reportBuildFake) Rebuild(_ context.Context, _ ReportBuildKind, day time.Time, _ func(*ReportInput) ([]*ReportBuiltRow, error)) error { + r.days = append(r.days, day.Format("2006-01-02")) + return nil +} +func TestReportBuildBackfillRangeAndCancellation(t *testing.T) { + r := &reportBuildFake{} + uc := NewReportBuildUsecase(r) + uc.now = func() time.Time { return time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation) } + pauses := 0 + uc.pause = func(context.Context) error { pauses++; return nil } + if err := uc.Run(context.Background(), DailyReport, "backfill", "2026-10-31", "2026-11-02"); err != nil || len(r.days) != 3 || pauses != 2 || r.days[2] != "2026-11-02" { + t.Fatal("回跑日期应闭区间且仅日间暂停") + } + if err := uc.Run(context.Background(), DailyReport, "backfill", "bad", ""); err != ErrAnalyticsDate { + t.Fatal("非法日期未拒绝") + } + if err := uc.Run(context.Background(), DailyReport, "backfill", "2026-11-05", "2026-11-04"); err != ErrAnalyticsDateOrder { + t.Fatal("倒置区间未拒绝") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + count := len(r.days) + if err := uc.Run(ctx, ServerReport, "today", "", ""); err != context.Canceled || len(r.days) != count { + t.Fatal("取消后仍重建报表") + } +} diff --git a/internal/data/business/menu_icons.go b/internal/data/business/menu_icons.go new file mode 100644 index 0000000..dbe8d22 --- /dev/null +++ b/internal/data/business/menu_icons.go @@ -0,0 +1,39 @@ +package business + +import "gorm.io/gorm" + +// 使用 Kra 全局注册的 Element Plus 组件名;不可直接沿用 RuoYi 的 SVG 文件名。 +var businessMenuIcons = map[string]string{ + "sw_BusMgmt": "Briefcase", + "sw_BusinessList": "UserFilled", + "sw_ChannelList": "Connection", + "sw_ChannelActivationList": "CircleCheck", + "sw_DataAnalysis": "DataAnalysis", + "sw_ChannelDataStatistics": "Histogram", + "sw_RechargeDetails": "Money", + "sw_RegistrationDetails": "User", + "sw_GameAnalytics": "TrendCharts", + "sw_ServerAnalysis": "Monitor", + "sw_RankingAnalysis": "Star", + "sw_AlertCenter": "Bell", + "sw_AlertList": "Warning", + "sw_AlertRules": "Setting", +} + +func MenuIcon(name string) string { return businessMenuIcons[name] } + +func migrateBusinessMenuIcons(db *gorm.DB) error { + if !db.Migrator().HasTable("sys_base_menus") { + return nil + } + // 仅替换旧迁移和早期模块默认值,保留管理员后来选取的其他图标。 + legacy := []string{"", "category", "email", "chart", "company", "international", "color", "dict", "cascader", "education", "excel", "star", "process-definition", "documentation", "example", "data-analysis", "user", "money", "warn"} + return db.Transaction(func(tx *gorm.DB) error { + for name, icon := range businessMenuIcons { + if err := tx.Table("sys_base_menus").Where("name=? AND (icon IS NULL OR icon IN ?)", name, legacy).Update("icon", icon).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/internal/data/business/menu_icons_test.go b/internal/data/business/menu_icons_test.go new file mode 100644 index 0000000..46789cb --- /dev/null +++ b/internal/data/business/menu_icons_test.go @@ -0,0 +1,34 @@ +package business + +import "testing" + +func TestBusinessMenuIconsRepairLegacyAndPreserveCustom(t *testing.T) { + db := openTestDB(t) + if err := db.Exec("CREATE TABLE sys_base_menus(id INTEGER PRIMARY KEY,name TEXT,icon TEXT)").Error; err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + "INSERT INTO sys_base_menus VALUES(1,'sw_BusMgmt','category'),(2,'sw_BusinessList','email'),(3,'sw_ChannelList','Connection'),(4,'sw_ChannelActivationList','CirclePlus'),(5,'sw_DataAnalysis',NULL),(6,'dashboard','category')", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for attempt := 0; attempt < 2; attempt++ { + if err := migrateBusinessMenuIcons(db); err != nil { + t.Fatal(err) + } + } + var rows []struct { + ID int + Icon string + } + if err := db.Table("sys_base_menus").Order("id").Scan(&rows).Error; err != nil { + t.Fatal(err) + } + for i, want := range []string{"Briefcase", "UserFilled", "Connection", "CirclePlus", "DataAnalysis", "category"} { + if rows[i].Icon != want { + t.Fatalf("菜单%d图标错误或误改自定义值: %+v", i, rows) + } + } +} diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index fa7050c..e4732e3 100644 --- a/internal/data/business/migrations.go +++ b/internal/data/business/migrations.go @@ -6,7 +6,7 @@ import ( ) func Migrations() []migration.Step { - return []migration.Step{{ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error { + return []migration.Step{{ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{}) }}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &bindingLockPO{}) diff --git a/internal/data/business/report_build.go b/internal/data/business/report_build.go new file mode 100644 index 0000000..6195021 --- /dev/null +++ b/internal/data/business/report_build.go @@ -0,0 +1,206 @@ +package business + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "kra/internal/biz/business" +) + +type reportBuildRepo struct{ data Provider } + +func NewReportBuildRepo(data Provider) business.ReportBuildRepo { return &reportBuildRepo{data: data} } + +type reportBuildLockPO struct { + Kind string `gorm:"size:16;primaryKey"` + StatDate string `gorm:"type:date;primaryKey"` +} + +func (reportBuildLockPO) TableName() string { return "sys_business_report_build_locks" } + +type reportEventPO struct { + SourceDB string `gorm:"column:source_db"` + UserID, PromoteID, GameID *int64 + ServerID, PayAmount string +} + +func toReportEvent(p reportEventPO) business.ReportEvent { + return business.ReportEvent{SourceDB: p.SourceDB, UserID: p.UserID, PromoteID: p.PromoteID, GameID: p.GameID, ServerNum: p.ServerID, Amount: p.PayAmount} +} +func (r *reportBuildRepo) Rebuild(ctx context.Context, kind business.ReportBuildKind, day time.Time, compile func(*business.ReportInput) ([]*business.ReportBuiltRow, error)) error { + if kind != business.DailyReport && kind != business.ServerReport { + return business.ErrAnalyticsInput + } + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + reportTable, keyTable := "tab_sw_report_day", "tab_sw_report_day_key" + rawTable := "tab_user_day_login" + if kind == business.ServerReport { + reportTable, keyTable = "tab_sw_report_server_day", "tab_sw_report_server_day_key" + rawTable = "tab_user_play_info" + } + for _, table := range []string{reportTable, keyTable, rawTable, "tab_user", "tab_spend", "tab_game"} { + available, err := businessTableAvailable(tx, table) + if err != nil { + return err + } + if !available { + return business.ErrReportBuildUnavailable + } + } + statDate := day.Format("2006-01-02") + lock := reportBuildLockPO{Kind: string(kind), StatDate: statDate} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&lock).Error; err != nil { + return business.ErrStorage + } + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("kind=? AND stat_date=?", lock.Kind, lock.StatDate).Take(&lock).Error; err != nil { + return business.ErrStorage + } + input, err := loadReportInput(tx, kind, day) + if err != nil { + return err + } + rows, err := compile(input) + if err != nil { + return err + } + if err := tx.Exec("DELETE FROM "+keyTable+" WHERE stat_date=?", statDate).Error; err != nil { + return business.ErrStorage + } + if err := tx.Exec("DELETE FROM "+reportTable+" WHERE stat_date=?", statDate).Error; err != nil { + return business.ErrStorage + } + reports := make([]map[string]any, 0, len(rows)) + keys := []map[string]any{} + for _, row := range rows { + base := func() map[string]any { + out := map[string]any{"stat_date": statDate, "source_db": row.SourceDB, "promote_id": row.PromoteID, "game_id": row.GameID, "game_name": row.GameName, "dedup_mode": row.Mode} + if kind == business.ServerReport { + out["server_num"] = row.ServerNum + } + return out + } + report := base() + report["new_register_cnt"], report["active_cnt"], report["pay_cnt"], report["total_pay"] = row.Counts.Register, row.Counts.Active, row.Counts.Pay, row.Pay + if kind == business.DailyReport { + report["new_pay_cnt"] = row.Counts.NewPay + } + reports = append(reports, report) + for metric := 1; metric <= 4; metric++ { + for _, identity := range row.Keys[metric] { + key := base() + key["metric"], key["identity_key"] = metric, identity + keys = append(keys, key) + } + } + } + // 汇总和身份键始终同事务替换,失败或取消会恢复原日报。 + for _, batch := range []struct { + table string + rows []map[string]any + }{{reportTable, reports}, {keyTable, keys}} { + if len(batch.rows) > 0 { + if err := tx.Table(batch.table).CreateInBatches(batch.rows, 100).Error; err != nil { + return business.ErrStorage + } + } + } + return nil + }) +} +func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time) (*business.ReportInput, error) { + input := &business.ReportInput{Users: map[business.ReportUserKey]business.ReportUser{}, Games: map[business.AnalyticsKey]string{}} + begin, end := day.Unix(), day.AddDate(0, 0, 1).Unix()-1 + userIDs, gameIDs := map[int64]bool{}, map[int64]bool{} + collect := func(rows []reportEventPO, target *[]business.ReportEvent) { + for _, po := range rows { + *target = append(*target, toReportEvent(po)) + if po.UserID != nil { + userIDs[*po.UserID] = true + } + if po.GameID != nil { + gameIDs[*po.GameID] = true + } else { + gameIDs[0] = true + } + } + } + if kind == business.DailyReport { + cast := "CAST(register_time AS INTEGER)" + if db.Dialector.Name() == "mysql" { + cast = "CAST(register_time AS UNSIGNED)" + } + var registers, actives []reportEventPO + if err := db.Table("tab_user").Select("id AS user_id,source_db,promote_id,fgame_id AS game_id").Where("puid=0").Where(cast+" BETWEEN ? AND ?", begin, end).Scan(®isters).Error; err != nil { + return nil, business.ErrStorage + } + if err := db.Table("tab_user_day_login").Select("user_id,source_db,promote_id,game_id").Where("login_time BETWEEN ? AND ?", begin, end).Scan(&actives).Error; err != nil { + return nil, business.ErrStorage + } + collect(registers, &input.Registers) + collect(actives, &input.Actives) + } else { + for _, query := range []struct { + column string + target *[]business.ReportEvent + }{{"create_time", &input.Registers}, {"play_time", &input.Actives}} { + var rows []reportEventPO + if err := db.Table("tab_user_play_info").Select("user_id,source_db,promote_id,game_id,server_id").Where("server_id<>''").Where(query.column+" BETWEEN ? AND ?", begin, end).Scan(&rows).Error; err != nil { + return nil, business.ErrStorage + } + collect(rows, query.target) + } + } + pay := db.Table("tab_spend").Where("pay_status=1 AND pay_time BETWEEN ? AND ?", begin, end) + columns := "user_id,source_db,promote_id,game_id,pay_amount" + if kind == business.ServerReport { + pay = pay.Where("server_id<>''") + columns += ",server_id" + } + var pays []reportEventPO + if err := pay.Select(columns).Scan(&pays).Error; err != nil { + return nil, business.ErrStorage + } + collect(pays, &input.Payments) + ids := make([]int64, 0, len(userIDs)) + for id := range userIDs { + ids = append(ids, id) + } + for start := 0; start < len(ids); start += 500 { + var users []struct { + ID int64 + SourceDB string `gorm:"column:source_db"` + Puid, PromoteID *int64 + IDCard string `gorm:"column:idcard"` + } + if err := db.Table("tab_user").Select("id,source_db,puid,promote_id,idcard").Where("id IN ?", ids[start:min(start+500, len(ids))]).Scan(&users).Error; err != nil { + return nil, business.ErrStorage + } + for _, po := range users { + key := business.ReportUserKey{SourceDB: po.SourceDB, ID: po.ID} + input.Users[key] = business.ReportUser{ReportUserKey: key, ParentID: po.Puid, PromoteID: po.PromoteID, IDCard: po.IDCard} + } + } + ids = make([]int64, 0, len(gameIDs)) + for id := range gameIDs { + ids = append(ids, id) + } + for start := 0; start < len(ids); start += 500 { + var games []struct { + ID int64 + SourceDB string `gorm:"column:source_db"` + GameName string + } + if err := db.Table("tab_game").Select("id,source_db,game_name").Where("id IN ?", ids[start:min(start+500, len(ids))]).Scan(&games).Error; err != nil { + return nil, business.ErrStorage + } + for _, po := range games { + if strings.TrimSpace(po.SourceDB) != "" && strings.TrimSpace(po.GameName) != "" { + input.Games[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}] = po.GameName + } + } + } + return input, nil +} diff --git a/internal/data/business/report_build_test.go b/internal/data/business/report_build_test.go new file mode 100644 index 0000000..c052801 --- /dev/null +++ b/internal/data/business/report_build_test.go @@ -0,0 +1,103 @@ +package business + +import ( + "context" + "kra/internal/biz/business" + "testing" + "time" +) + +func TestReportBuildStorageReplacesBothTablesAtomically(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + day := time.Date(2026, 11, 4, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + for _, sql := range []string{ + "CREATE TABLE tab_user(id INTEGER,source_db TEXT,puid INTEGER,promote_id INTEGER,fgame_id INTEGER,idcard TEXT,register_time TEXT)", + "CREATE TABLE tab_user_day_login(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,login_time INTEGER)", + "CREATE TABLE tab_user_play_info(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,create_time INTEGER,play_time INTEGER)", + "CREATE TABLE tab_spend(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,pay_time INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))", + "CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)", + "INSERT INTO tab_game VALUES(1,'lx','当前游戏')", + "CREATE TABLE tab_sw_report_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,new_pay_cnt INTEGER,total_pay DECIMAL(20,2))", + "CREATE TABLE tab_sw_report_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)", + "CREATE TABLE tab_sw_report_server_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,total_pay DECIMAL(20,2))", + "CREATE TABLE tab_sw_report_server_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for _, sql := range []string{ + "INSERT INTO tab_user VALUES(1,'lx',0,10,1,'cardX',?),(2,'lx',1,10,1,'sub',?),(3,'lx',NULL,10,1,'null',?)", + } { + if err := db.Exec(sql, day.Unix(), day.Unix(), day.Unix()).Error; err != nil { + t.Fatal(err) + } + } + for _, sql := range []string{ + "INSERT INTO tab_user_day_login VALUES(1,'lx',NULL,1,?),(2,'lx',10,1,?),(3,'lx',10,1,?)", + "INSERT INTO tab_spend VALUES(1,'lx',10,1,'001',?,1,10),(2,'lx',10,1,'001',?,1,20),(3,'lx',10,1,'001',?,0,999)", + } { + if err := db.Exec(sql, day.Unix(), day.Unix(), day.Unix()).Error; err != nil { + t.Fatal(err) + } + } + if err := db.Exec("INSERT INTO tab_user_play_info VALUES(2,'lx',10,1,' 001 ',?,?)", day.Unix(), day.Unix()).Error; err != nil { + t.Fatal(err) + } + uc := business.NewReportBuildUsecase(NewReportBuildRepo(testData{db})) + run := func(kind business.ReportBuildKind) error { + return uc.Run(ctx, kind, "backfill", "2026-11-04", "2026-11-04") + } + if err := run(business.DailyReport); err != nil { + t.Fatal(err) + } + var counts []struct { + DedupMode, NewRegisterCnt, ActiveCnt, PayCnt, NewPayCnt int + TotalPay string + GameName string + } + if err := db.Table("tab_sw_report_day").Order("dedup_mode").Scan(&counts).Error; err != nil { + t.Fatal(err) + } + if len(counts) != 2 || counts[0].NewRegisterCnt != 1 || counts[0].ActiveCnt != 1 || counts[0].PayCnt != 2 || counts[0].NewPayCnt != 1 || portraitTestMoney(counts[0].TotalPay) != "30.00" || counts[0].GameName != "当前游戏" { + t.Fatalf("日报主账号/付费/金额规则错误: %+v", counts) + } + if err := run(business.ServerReport); err != nil { + t.Fatal(err) + } + var server struct { + NewRegisterCnt, ActiveCnt, PayCnt int + ServerNum string + } + if err := db.Table("tab_sw_report_server_day").Where("dedup_mode=0").Take(&server).Error; err != nil { + t.Fatal(err) + } + if server.NewRegisterCnt != 1 || server.ActiveCnt != 1 || server.PayCnt != 2 || server.ServerNum != "001" { + t.Fatalf("区服创角应包含子账号: %+v", server) + } + if err := run(business.DailyReport); err != nil { + t.Fatal(err) + } + var before int64 + if err := db.Table("tab_sw_report_day").Where("stat_date=?", "2026-11-04").Count(&before).Error; err != nil || before != 2 { + t.Fatal("回跑日期必须保持自然日格式且不能重复累积行") + } + db.Table("tab_sw_report_day_key").Count(&before) + if err := db.Exec("CREATE TRIGGER reject_report_key BEFORE INSERT ON tab_sw_report_day_key BEGIN SELECT RAISE(ABORT,'测试写入失败'); END").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("UPDATE tab_spend SET pay_amount=100 WHERE user_id=1").Error; err != nil { + t.Fatal(err) + } + if err := run(business.DailyReport); err == nil { + t.Fatal("身份键写入失败必须回滚") + } + var after int64 + db.Table("tab_sw_report_day_key").Count(&after) + var amount string + db.Table("tab_sw_report_day").Select("total_pay").Where("dedup_mode=0").Scan(&amount) + if before != after || portraitTestMoney(amount) != "30.00" { + t.Fatal("失败后丢失原身份键或覆盖原汇总") + } +} diff --git a/internal/data/data.go b/internal/data/data.go index 7316bfe..e4d33c1 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -46,6 +46,7 @@ var ProviderSet = wire.NewSet( databusiness.NewServerAnalysisRepo, databusiness.NewWorkbenchRepo, databusiness.NewRankingRepo, + databusiness.NewReportBuildRepo, NewIntegrationRuntime, wire.Bind(new(datasystem.Provider), new(*Data)), wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index e83bc03..6d5a833 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { t.Fatal(err) } - if versions != 21 { - t.Fatalf("migration versions = %d, want 21", versions) + if versions != 23 { + t.Fatalf("migration versions = %d, want 23", versions) } var communicationRows []dataintegration.ConfigPO if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { diff --git a/internal/modules/business/definition.go b/internal/modules/business/definition.go index 7348f17..7fcb34d 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -9,20 +9,20 @@ import ( func Definition() module.Definition { return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{ Menus: []module.Menu{ - {Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: "star", Sort: 6}, - {Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: "data-analysis", Sort: 5}, - {Name: "sw_ChannelDataStatistics", Path: "ChannelDataStatistics", ParentName: "sw_DataAnalysis", Component: "view/sw/ChannelDataStatistics.vue", Title: "渠道数据统计", Icon: "data-analysis", Sort: 1}, - {Name: "sw_GameAnalytics", Path: "GameAnalytics", ParentName: "sw_DataAnalysis", Component: "view/sw/GameAnalytics.vue", Title: "游戏数据", Icon: "data-analysis", Sort: 4}, - {Name: "sw_DataAnalysis", Path: "sw-DataAnalysis", Component: "view/routerHolder.vue", Title: "数据分析", Icon: "data-analysis", Sort: 3}, - {Name: "sw_RegistrationDetails", Path: "RegistrationDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RegistrationDetails.vue", Title: "注册明细", Icon: "user", Sort: 3}, - {Name: "sw_RechargeDetails", Path: "RechargeDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RechargeDetails.vue", Title: "充值明细", Icon: "money", Sort: 2}, - {Name: "sw_ChannelList", Path: "ChannelList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelList.vue", Title: "渠道列表", Icon: "company", Sort: 2}, - {Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "渠道激活", Icon: "company", Sort: 3}, - {Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: "warn", Sort: 1}, - {Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: "warn", Sort: 4}, - {Name: "sw_AlertRules", Path: "AlertRules", ParentName: "sw_AlertCenter", Component: "view/sw/AlertRules.vue", Title: "预警规则", Icon: "warn", Sort: 2}, - {Name: "sw_BusMgmt", Path: "sw-BusMgmt", Component: "view/routerHolder.vue", Title: "商务管理", Icon: "user", Sort: 2}, - {Name: "sw_BusinessList", Path: "BusinessList", ParentName: "sw_BusMgmt", Component: "view/sw/BusinessList.vue", Title: "商务列表", Icon: "user", Sort: 1}, + {Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: databusiness.MenuIcon("sw_RankingAnalysis"), Sort: 6}, + {Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: databusiness.MenuIcon("sw_ServerAnalysis"), Sort: 5}, + {Name: "sw_ChannelDataStatistics", Path: "ChannelDataStatistics", ParentName: "sw_DataAnalysis", Component: "view/sw/ChannelDataStatistics.vue", Title: "渠道数据统计", Icon: databusiness.MenuIcon("sw_ChannelDataStatistics"), Sort: 1}, + {Name: "sw_GameAnalytics", Path: "GameAnalytics", ParentName: "sw_DataAnalysis", Component: "view/sw/GameAnalytics.vue", Title: "游戏数据", Icon: databusiness.MenuIcon("sw_GameAnalytics"), Sort: 4}, + {Name: "sw_DataAnalysis", Path: "sw-DataAnalysis", Component: "view/routerHolder.vue", Title: "数据分析", Icon: databusiness.MenuIcon("sw_DataAnalysis"), Sort: 3}, + {Name: "sw_RegistrationDetails", Path: "RegistrationDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RegistrationDetails.vue", Title: "注册明细", Icon: databusiness.MenuIcon("sw_RegistrationDetails"), Sort: 3}, + {Name: "sw_RechargeDetails", Path: "RechargeDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RechargeDetails.vue", Title: "充值明细", Icon: databusiness.MenuIcon("sw_RechargeDetails"), Sort: 2}, + {Name: "sw_ChannelList", Path: "ChannelList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelList.vue", Title: "渠道列表", Icon: databusiness.MenuIcon("sw_ChannelList"), Sort: 2}, + {Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "渠道激活", Icon: databusiness.MenuIcon("sw_ChannelActivationList"), Sort: 3}, + {Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: databusiness.MenuIcon("sw_AlertList"), Sort: 1}, + {Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: databusiness.MenuIcon("sw_AlertCenter"), Sort: 4}, + {Name: "sw_AlertRules", Path: "AlertRules", ParentName: "sw_AlertCenter", Component: "view/sw/AlertRules.vue", Title: "预警规则", Icon: databusiness.MenuIcon("sw_AlertRules"), Sort: 2}, + {Name: "sw_BusMgmt", Path: "sw-BusMgmt", Component: "view/routerHolder.vue", Title: "商务管理", Icon: databusiness.MenuIcon("sw_BusMgmt"), Sort: 2}, + {Name: "sw_BusinessList", Path: "BusinessList", ParentName: "sw_BusMgmt", Component: "view/sw/BusinessList.vue", Title: "商务列表", Icon: databusiness.MenuIcon("sw_BusinessList"), Sort: 1}, }, APIs: surface.APIsForPrefix("/sw/", 0), }} diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 19ce0e3..ba555a4 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -25,8 +25,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { t.Fatalf("definition[%d] = %q, want %q", index, got, name) } } - if got := catalog.MigrationSteps(); len(got) != 21 { - t.Fatalf("module migrations = %d, want 21", len(got)) + if got := catalog.MigrationSteps(); len(got) != 23 { + t.Fatalf("module migrations = %d, want 23", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { diff --git a/internal/worker/business_report_tasks.go b/internal/worker/business_report_tasks.go new file mode 100644 index 0000000..2438a21 --- /dev/null +++ b/internal/worker/business_report_tasks.go @@ -0,0 +1,43 @@ +package worker + +import ( + "context" + "encoding/json" + "fmt" + "kra/internal/biz/business" + platformtask "kra/pkg/task" +) + +// 只注册旧执行器对应方法,不创建或启用计划;迁移库保留原参数与禁用状态。 +func (methods *TaskMethods) registerBusinessReports(registry *platformtask.Registry) { + for _, job := range []struct { + name, description, period string + kind business.ReportBuildKind + }{ + {"swReportDayTodayTask", "重建当日商务日汇总", "today", business.DailyReport}, + {"swReportDayYesterdayTask", "重建昨日商务日汇总", "yesterday", business.DailyReport}, + {"swReportDayBackfillTask", "按日期区间重建商务日汇总", "backfill", business.DailyReport}, + {"swReportServerDayTodayTask", "重建当日区服日汇总", "today", business.ServerReport}, + {"swReportServerDayYesterdayTask", "重建昨日区服日汇总", "yesterday", business.ServerReport}, + {"swReportServerDayBackfillTask", "按日期区间重建区服日汇总", "backfill", business.ServerReport}, + } { + registry.Register(platformtask.Method{Name: job.name, Description: job.description, Run: func(ctx context.Context, params json.RawMessage) error { + if methods.reports == nil { + return fmt.Errorf("商务汇总任务依赖未就绪") + } + if ctx == nil { + ctx = context.Background() + } + var args struct { + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + } + if job.period == "backfill" && len(params) > 0 { + if err := json.Unmarshal(params, &args); err != nil { + return fmt.Errorf("日期回跑参数必须是 JSON 对象: %w", err) + } + } + return methods.reports.Run(ctx, job.kind, job.period, args.StartDate, args.EndDate) + }}) + } +} diff --git a/internal/worker/task_registry.go b/internal/worker/task_registry.go index bc83fb2..1e6f703 100644 --- a/internal/worker/task_registry.go +++ b/internal/worker/task_registry.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "kra/internal/biz/business" "kra/internal/biz/system" taskbiz "kra/internal/biz/task" @@ -18,16 +19,18 @@ type TaskMethods struct { maintenance *system.MaintenanceUsecase media *system.MediaUsecase runtime *config.Store + reports *business.ReportBuildUsecase } -func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store) *TaskMethods { - return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime} +func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store, reports *business.ReportBuildUsecase) *TaskMethods { + return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime, reports: reports} } func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) { if methods == nil || registry == nil { return } + methods.registerBusinessReports(registry) descriptions := map[string]string{} for _, task := range taskmodule.TimedTasks() { descriptions[task.MethodName] = task.Description diff --git a/scripts/sw_menu_icons_http_smoke.py b/scripts/sw_menu_icons_http_smoke.py new file mode 100644 index 0000000..7a951c2 --- /dev/null +++ b/scripts/sw_menu_icons_http_smoke.py @@ -0,0 +1,42 @@ +"""通过真实菜单接口检查商务图标映射,不进行页面交互。""" +import argparse +import json +import re +import urllib.request +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--base-url', default='http://127.0.0.1:18000') + parser.add_argument('--token-file', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + token = args.token_file.read_text(encoding='utf-8').strip() + icons = dict(re.findall(r'"(sw_[^"]+)":\s*"([^"]+)"', Path('internal/data/business/menu_icons.go').read_text(encoding='utf-8'))) + req = urllib.request.Request(args.base_url + '/menu/getMenu', data=b'{}', headers={'x-token': token, 'Content-Type': 'application/json'}) + with urllib.request.urlopen(req, timeout=30) as response: + data = json.load(response) + assert data['code'] == 0, data.get('msg') + rows = [] + def walk(value): + if isinstance(value, dict): + name = value.get('name') + if name in icons and 'component' in value: + actual = value.get('meta', {}).get('icon') + rows.append({'name': name, 'icon': actual, 'expected': icons[name], 'passed': actual == icons[name]}) + for item in value.values(): + walk(item) + elif isinstance(value, list): + for item in value: + walk(item) + walk(data) + report = {'baseURL': args.base_url, 'menuCount': len(rows), 'checks': rows, 'passed': len(rows) == len(icons) and all(row['passed'] for row in rows)} + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + print(json.dumps(report, ensure_ascii=False)) + if not report['passed']: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/sw_menu_icons_verify.cjs b/scripts/sw_menu_icons_verify.cjs new file mode 100644 index 0000000..d155c68 --- /dev/null +++ b/scripts/sw_menu_icons_verify.cjs @@ -0,0 +1,32 @@ +// 核验实际安装的图标组件及 SVG 输出,不启动浏览器、不检查页面交互。 +const fs = require('node:fs') +const path = require('node:path') +const assert = require('node:assert/strict') +const { createRequire } = require('node:module') +const root = path.resolve(__dirname, '..') +const localRequire = createRequire(path.join(root, 'web', 'package.json')) +const icons = localRequire('@element-plus/icons-vue') +const { createSSRApp, h } = localRequire('vue') +const { renderToString } = localRequire('@vue/server-renderer') + +;(async () => { + const source = fs.readFileSync(path.join(root, 'internal/data/business/menu_icons.go'), 'utf8') + const entries = [...source.matchAll(/"(sw_[^"]+)":\s*"([^"]+)"/g)].map(match => ({ name: match[1], icon: match[2] })) + assert.equal(entries.length, 14) + const sql = fs.readFileSync(path.join(root, 'docs/sw6-to-kra/sw_to_kra_migrate.sql'), 'utf8') + const moduleSource = fs.readFileSync(path.join(root, 'internal/modules/business/definition.go'), 'utf8') + for (const row of entries) { + assert.ok(icons[row.icon], `${row.icon} 未注册`) + assert.ok(sql.includes(`WHEN '${row.name}' THEN '${row.icon}'`), `${row.name} 缺少重复执行修复`) + assert.ok(sql.includes(`WHEN '${row.name.slice(3)}' THEN '${row.icon}'`), `${row.name} 缺少首次迁移映射`) + assert.ok(moduleSource.includes(`Icon: databusiness.MenuIcon("${row.name}")`)) + const rendered = await renderToString(createSSRApp({ render: () => h(icons[row.icon]) })) + assert.match(rendered, /]/) + assert.match(rendered, /<(path|circle|rect|polygon|line)[\s>]/) + row.svgRendered = true + } + assert.ok(icons.Menu, 'SQL 默认图标必须已注册') + const result = { method: '本地已安装组件的 SSR SVG 验证,包含首次/重复迁移 SQL 与模块定义一致性检查', icons: entries, passed: true } + fs.writeFileSync(path.join(root, 'docs/sw6-to-kra/menu-icons-components-2026-11-04.json'), JSON.stringify(result, null, 2) + '\n') + console.log(JSON.stringify(result)) +})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/sw_report_build_http_smoke.py b/scripts/sw_report_build_http_smoke.py new file mode 100644 index 0000000..d8df518 --- /dev/null +++ b/scripts/sw_report_build_http_smoke.py @@ -0,0 +1,34 @@ +"""只读核验汇总任务方法已注册、导入计划保持禁用,不触发实际数据重建。""" +import argparse +import json +import urllib.request +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--base-url', default='http://127.0.0.1:18000') + parser.add_argument('--token-file', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + token = args.token_file.read_text(encoding='utf-8').strip() + def get(path): + req = urllib.request.Request(args.base_url + path, headers={'x-token': token}) + with urllib.request.urlopen(req, timeout=30) as response: + data = json.load(response) + assert data['code'] == 0, data.get('msg') + return data['data'] + methods = get('/timedTask/getRegisteredMethods')['methods'] + names = {row['name'] for row in methods} + expected = {f'swReport{kind}{period}Task' for kind in ['Day', 'ServerDay'] for period in ['Today', 'Yesterday', 'Backfill']} + tasks = get('/timedTask/getTimedTaskList?page=1&pageSize=1000') + imported = [row for row in tasks['list'] if row.get('name', '').startswith('SW/')] + report = {'scope': '真实 sw7 会话,只读任务接口;正向创建和触发通过隔离数据库原生任务 HTTP 测试。', 'registeredMethods': sorted(names & expected), 'importedTasks': len(imported), 'enabledImportedTasks': sum(bool(row['enabled']) for row in imported), 'passed': expected <= names and not any(row['enabled'] for row in imported)} + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + print(json.dumps(report, ensure_ascii=False)) + if not report['passed']: + raise SystemExit(1) + + +if __name__ == '__main__': + main()