From 297e80827d285b56396afe3715fe9b5b58fa816b Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Wed, 4 Nov 2026 14:45:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E6=88=90=E5=95=86=E5=8A=A1?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=A4=8D=E6=A0=B8=E4=B8=8E=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B9=B6=E6=B8=85=E7=90=86=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=B5=84=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + cmd/alert_trigger_http_test.go | 126 ++ cmd/main.go | 7 +- cmd/main_test.go | 8 +- cmd/ranking_http_test.go | 14 +- cmd/report_build_http_test.go | 4 +- cmd/wire_gen.go | 4 +- docs/ADMIN_BACKEND.md | 2 +- docs/API_REVIEW.md | 55 + docs/README.md | 9 + docs/code-review-issues.md | 78 - docs/sw6-to-kra/README.md | 28 + .../activation-http-2026-11-03.json | 44 - .../activation-page-2026-11-03.json | 11 - .../alert-records-http-2026-11-03.json | 123 -- .../alert-records-page-2026-11-03.json | 116 -- .../alert-rules-http-2026-11-03.json | 38 - .../alert-rules-page-2026-11-03.json | 34 - .../sw6-to-kra/analytics-http-2026-11-04.json | 56 - .../sw6-to-kra/analytics-page-2026-11-04.json | 25 - docs/sw6-to-kra/analytics-source-notes.md | 3 +- .../business-page-parity-2026-11-03.json | 47 - docs/sw6-to-kra/channel-http-2026-11-03.json | 87 - docs/sw6-to-kra/channel-page-2026-11-03.json | 14 - .../channel-portrait-http-2026-11-03.json | 35 - .../channel-portrait-page-2026-11-03.json | 13 - .../channel-portrait-source-notes.md | 3 +- docs/sw6-to-kra/channel-source-notes.md | 6 +- .../direct-channels-http-2026-11-03.json | 29 - .../http-business-bindings-2026-11-03.json | 133 -- .../http-verification-2026-11-03.json | 64 - docs/sw6-to-kra/implementation.md | 107 - docs/sw6-to-kra/ledger-http-2026-11-04.json | 86 - docs/sw6-to-kra/ledger-page-2026-11-04.json | 27 - docs/sw6-to-kra/ledger-source-notes.md | 4 +- .../login-page-verification-2026-11-03.json | 12 - docs/sw6-to-kra/menu-icon-repair.sql | 22 - .../menu-icons-components-2026-11-04.json | 76 - .../menu-icons-http-2026-11-04.json | 91 - .../menu-icons-live-http-2026-11-04.json | 91 - docs/sw6-to-kra/menu-icons.md | 19 - docs/sw6-to-kra/page-parity.md | 200 -- .../password-reset-http-2026-11-03.json | 38 - docs/sw6-to-kra/portrait-http-2026-11-03.json | 60 - docs/sw6-to-kra/portrait-page-2026-11-03.json | 17 - docs/sw6-to-kra/portrait-source-notes.md | 4 +- docs/sw6-to-kra/ranking-http-2026-11-04.json | 72 - docs/sw6-to-kra/ranking-source-notes.md | 4 +- .../report-build-http-2026-11-04.json | 14 - docs/sw6-to-kra/report-build-source-notes.md | 2 +- .../server-analysis-http-2026-11-04.json | 70 - .../server-analysis-source-notes.md | 2 +- docs/sw6-to-kra/source-inventory.json | 1848 ----------------- .../sw8-sw9-recheck-summary-2026-11-04.json | 558 +++++ docs/sw6-to-kra/sw_to_kra_migrate.sql | 1768 ++++++++++++++++ docs/sw6-to-kra/system-api-audit.md | 12 +- .../user-status-http-2026-11-03.json | 35 - docs/sw6-to-kra/validation.md | 68 + .../sw6-to-kra/workbench-http-2026-11-04.json | 84 - docs/sw6-to-kra/workbench-source-notes.md | 4 +- docs/system-pkg-audit.md | 87 - docs/system-structure-audit.md | 63 - internal/biz/biz.go | 2 +- internal/biz/business/activation.go | 23 +- internal/biz/business/activation_test.go | 13 +- internal/biz/business/alert_record.go | 18 +- internal/biz/business/alert_trigger.go | 270 +++ internal/biz/business/alert_trigger_test.go | 112 + internal/biz/business/ranking.go | 10 +- internal/biz/business/ranking_test.go | 36 +- internal/biz/business/workbench.go | 77 +- internal/biz/business/workbench_test.go | 51 + .../biz/integration/integration_config.go | 7 +- internal/biz/integration/secret_alias_test.go | 16 + internal/biz/payment/diagnostic_error.go | 5 + internal/biz/task/execution.go | 59 + internal/biz/task/execution_test.go | 24 + internal/biz/task/task.go | 3 + internal/config/swagger.go | 16 + internal/config/swagger_test.go | 19 + internal/data/business/activation.go | 16 +- internal/data/business/activation_test.go | 6 +- internal/data/business/alert_trigger.go | 168 ++ internal/data/business/alert_trigger_test.go | 110 + internal/data/business/analytics.go | 77 +- internal/data/business/channel.go | 12 +- internal/data/business/channel_write.go | 4 +- internal/data/business/ledger.go | 35 +- internal/data/business/migrations.go | 2 +- .../data/business/mysql_rehearsal_test.go | 279 +++ internal/data/business/query_indexes.go | 70 + internal/data/business/query_indexes_test.go | 50 + internal/data/business/ranking.go | 105 +- internal/data/business/registration_index.go | 70 + internal/data/business/report_build.go | 54 +- internal/data/business/schema.go | 30 +- internal/data/business/schema_test.go | 49 + internal/data/business/server_report_index.go | 18 + internal/data/business/workbench.go | 162 +- internal/data/business/workbench_test.go | 29 + internal/data/data.go | 1 + internal/data/data_scope_audit.go | 36 +- internal/data/data_scope_audit_test.go | 29 + .../integration_config_runtime_test.go | 20 + internal/data/migrations_test.go | 4 +- internal/data/system/media.go | 5 +- internal/data/system/media_edit_test.go | 40 + internal/modules/catalog_test.go | 4 +- internal/server/gin.go | 6 +- internal/server/gin_test.go | 42 +- .../server/middleware/payment_audit_test.go | 13 + internal/server/middleware/redact.go | 8 +- internal/service/business/account.go | 3 + internal/service/business/analytics.go | 5 +- internal/service/business/date_range_test.go | 33 + internal/service/business/ledger.go | 5 +- internal/service/business/ledger_excel.go | 4 + .../service/business/ledger_excel_test.go | 6 + internal/service/payment/payment.go | 11 +- internal/service/payment/payment_test.go | 28 + internal/worker/task_executor.go | 34 +- internal/worker/task_executor_test.go | 43 + internal/worker/task_registry.go | 14 +- pkg/database/gormkit/logger.go | 22 +- pkg/database/gormkit/logger_test.go | 45 + scripts/README.md | 49 + scripts/start-sw-rehearsal.ps1 | 37 + scripts/sw_account_create_verify.py | 73 + scripts/sw_account_write_verify.py | 75 + scripts/sw_activation_http_smoke.py | 42 - scripts/sw_activation_smoke.cjs | 75 - scripts/sw_alert_http_smoke.py | 58 - scripts/sw_alert_records_smoke.cjs | 156 -- scripts/sw_alert_rules_smoke.cjs | 36 - scripts/sw_analytics_http_smoke.py | 37 - scripts/sw_analytics_page_smoke.cjs | 62 - scripts/sw_api_compare.py | 201 ++ scripts/sw_channel_http_smoke.py | 52 - scripts/sw_channel_page_smoke.cjs | 94 - scripts/sw_channel_portrait_http_smoke.py | 47 - scripts/sw_channel_portrait_page_smoke.cjs | 75 - scripts/sw_dashboard_layout_smoke.cjs | 218 ++ scripts/sw_database_rehearsal.py | 188 ++ scripts/sw_endpoint_benchmark.py | 76 + scripts/sw_endpoint_parity.py | 79 + scripts/sw_export_compare.py | 76 + scripts/sw_http_smoke.py | 72 - scripts/sw_ledger_http_smoke.py | 59 - scripts/sw_ledger_page_smoke.cjs | 72 - scripts/sw_login_smoke.cjs | 52 - scripts/sw_menu_icons_http_smoke.py | 42 - scripts/sw_menu_icons_verify.cjs | 32 - scripts/sw_migration_inventory.py | 48 - scripts/sw_page_smoke.cjs | 86 - scripts/sw_portrait_http_smoke.py | 68 - scripts/sw_portrait_page_smoke.cjs | 101 - scripts/sw_preference_compare.py | 61 + scripts/sw_ranking_http_smoke.py | 36 - scripts/sw_report_build_http_smoke.py | 34 - scripts/sw_report_rehearsal.py | 119 ++ scripts/sw_run_report_tasks.py | 58 + scripts/sw_sdk_write_verify.py | 118 ++ scripts/sw_server_analysis_http_smoke.py | 44 - scripts/sw_user_status_http_smoke.py | 48 - scripts/sw_workbench_http_smoke.py | 41 - scripts/sw_write_verify.py | 207 ++ .../components/businessPreference/index.vue | 50 +- web/src/utils/request.js | 19 +- .../components/BusinessDashboard.vue | 49 +- web/src/view/dashboard/index.vue | 19 +- web/src/view/layout/header/index.vue | 8 +- web/src/view/layout/header/tools.vue | 4 +- web/src/view/sw/BusinessList.vue | 15 +- web/src/view/sw/ChannelActivationList.vue | 13 +- web/src/view/sw/RankingAnalysis.vue | 20 +- .../sw/components/CompactRankingTable.vue | 73 + 176 files changed, 6803 insertions(+), 6002 deletions(-) create mode 100644 cmd/alert_trigger_http_test.go create mode 100644 docs/API_REVIEW.md create mode 100644 docs/README.md delete mode 100644 docs/code-review-issues.md create mode 100644 docs/sw6-to-kra/README.md delete mode 100644 docs/sw6-to-kra/activation-http-2026-11-03.json delete mode 100644 docs/sw6-to-kra/activation-page-2026-11-03.json delete mode 100644 docs/sw6-to-kra/alert-records-http-2026-11-03.json delete mode 100644 docs/sw6-to-kra/alert-records-page-2026-11-03.json delete mode 100644 docs/sw6-to-kra/alert-rules-http-2026-11-03.json delete mode 100644 docs/sw6-to-kra/alert-rules-page-2026-11-03.json delete mode 100644 docs/sw6-to-kra/analytics-http-2026-11-04.json delete mode 100644 docs/sw6-to-kra/analytics-page-2026-11-04.json delete mode 100644 docs/sw6-to-kra/business-page-parity-2026-11-03.json delete mode 100644 docs/sw6-to-kra/channel-http-2026-11-03.json delete mode 100644 docs/sw6-to-kra/channel-page-2026-11-03.json delete mode 100644 docs/sw6-to-kra/channel-portrait-http-2026-11-03.json delete mode 100644 docs/sw6-to-kra/channel-portrait-page-2026-11-03.json delete mode 100644 docs/sw6-to-kra/direct-channels-http-2026-11-03.json delete mode 100644 docs/sw6-to-kra/http-business-bindings-2026-11-03.json delete mode 100644 docs/sw6-to-kra/http-verification-2026-11-03.json delete mode 100644 docs/sw6-to-kra/implementation.md delete mode 100644 docs/sw6-to-kra/ledger-http-2026-11-04.json delete mode 100644 docs/sw6-to-kra/ledger-page-2026-11-04.json delete mode 100644 docs/sw6-to-kra/login-page-verification-2026-11-03.json delete mode 100644 docs/sw6-to-kra/menu-icon-repair.sql delete mode 100644 docs/sw6-to-kra/menu-icons-components-2026-11-04.json delete mode 100644 docs/sw6-to-kra/menu-icons-http-2026-11-04.json delete mode 100644 docs/sw6-to-kra/menu-icons-live-http-2026-11-04.json delete mode 100644 docs/sw6-to-kra/menu-icons.md delete mode 100644 docs/sw6-to-kra/page-parity.md delete mode 100644 docs/sw6-to-kra/password-reset-http-2026-11-03.json delete mode 100644 docs/sw6-to-kra/portrait-http-2026-11-03.json delete mode 100644 docs/sw6-to-kra/portrait-page-2026-11-03.json delete mode 100644 docs/sw6-to-kra/ranking-http-2026-11-04.json delete mode 100644 docs/sw6-to-kra/report-build-http-2026-11-04.json delete mode 100644 docs/sw6-to-kra/server-analysis-http-2026-11-04.json delete mode 100644 docs/sw6-to-kra/source-inventory.json create mode 100644 docs/sw6-to-kra/sw8-sw9-recheck-summary-2026-11-04.json create mode 100644 docs/sw6-to-kra/sw_to_kra_migrate.sql delete mode 100644 docs/sw6-to-kra/user-status-http-2026-11-03.json create mode 100644 docs/sw6-to-kra/validation.md delete mode 100644 docs/sw6-to-kra/workbench-http-2026-11-04.json delete mode 100644 docs/system-pkg-audit.md delete mode 100644 docs/system-structure-audit.md create mode 100644 internal/biz/business/alert_trigger.go create mode 100644 internal/biz/business/alert_trigger_test.go create mode 100644 internal/biz/integration/secret_alias_test.go create mode 100644 internal/biz/payment/diagnostic_error.go create mode 100644 internal/biz/task/execution.go create mode 100644 internal/biz/task/execution_test.go create mode 100644 internal/config/swagger.go create mode 100644 internal/config/swagger_test.go create mode 100644 internal/data/business/alert_trigger.go create mode 100644 internal/data/business/alert_trigger_test.go create mode 100644 internal/data/business/mysql_rehearsal_test.go create mode 100644 internal/data/business/query_indexes.go create mode 100644 internal/data/business/query_indexes_test.go create mode 100644 internal/data/business/registration_index.go create mode 100644 internal/data/business/schema_test.go create mode 100644 internal/data/business/server_report_index.go create mode 100644 internal/data/system/media_edit_test.go create mode 100644 internal/service/business/date_range_test.go create mode 100644 internal/service/payment/payment_test.go create mode 100644 pkg/database/gormkit/logger_test.go create mode 100644 scripts/README.md create mode 100644 scripts/start-sw-rehearsal.ps1 create mode 100644 scripts/sw_account_create_verify.py create mode 100644 scripts/sw_account_write_verify.py delete mode 100644 scripts/sw_activation_http_smoke.py delete mode 100644 scripts/sw_activation_smoke.cjs delete mode 100644 scripts/sw_alert_http_smoke.py delete mode 100644 scripts/sw_alert_records_smoke.cjs delete mode 100644 scripts/sw_alert_rules_smoke.cjs delete mode 100644 scripts/sw_analytics_http_smoke.py delete mode 100644 scripts/sw_analytics_page_smoke.cjs create mode 100644 scripts/sw_api_compare.py delete mode 100644 scripts/sw_channel_http_smoke.py delete mode 100644 scripts/sw_channel_page_smoke.cjs delete mode 100644 scripts/sw_channel_portrait_http_smoke.py delete mode 100644 scripts/sw_channel_portrait_page_smoke.cjs create mode 100644 scripts/sw_dashboard_layout_smoke.cjs create mode 100644 scripts/sw_database_rehearsal.py create mode 100644 scripts/sw_endpoint_benchmark.py create mode 100644 scripts/sw_endpoint_parity.py create mode 100644 scripts/sw_export_compare.py delete mode 100644 scripts/sw_http_smoke.py delete mode 100644 scripts/sw_ledger_http_smoke.py delete mode 100644 scripts/sw_ledger_page_smoke.cjs delete mode 100644 scripts/sw_login_smoke.cjs delete mode 100644 scripts/sw_menu_icons_http_smoke.py delete mode 100644 scripts/sw_menu_icons_verify.cjs delete mode 100644 scripts/sw_migration_inventory.py delete mode 100644 scripts/sw_page_smoke.cjs delete mode 100644 scripts/sw_portrait_http_smoke.py delete mode 100644 scripts/sw_portrait_page_smoke.cjs create mode 100644 scripts/sw_preference_compare.py delete mode 100644 scripts/sw_ranking_http_smoke.py delete mode 100644 scripts/sw_report_build_http_smoke.py create mode 100644 scripts/sw_report_rehearsal.py create mode 100644 scripts/sw_run_report_tasks.py create mode 100644 scripts/sw_sdk_write_verify.py delete mode 100644 scripts/sw_server_analysis_http_smoke.py delete mode 100644 scripts/sw_user_status_http_smoke.py delete mode 100644 scripts/sw_workbench_http_smoke.py create mode 100644 scripts/sw_write_verify.py create mode 100644 web/src/view/sw/components/CompactRankingTable.vue diff --git a/.gitignore b/.gitignore index c108be9..ecd1080 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ bin/ web/dist/ web/node_modules/ .pnpm-store/ +__pycache__/ +*.pyc # Local reference sources and desktop-only tooling state /gva/ diff --git a/cmd/alert_trigger_http_test.go b/cmd/alert_trigger_http_test.go new file mode 100644 index 0000000..eb4af81 --- /dev/null +++ b/cmd/alert_trigger_http_test.go @@ -0,0 +1,126 @@ +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" +) + +func TestAlertTriggerThroughNativeTaskHTTP(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "alert-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 sys_users(id INTEGER,username TEXT,level INTEGER,parent_id INTEGER,id_path TEXT,authority_id INTEGER,deleted_at DATETIME)", + "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,account TEXT,promote_level INTEGER,status INTEGER,last_login_time INTEGER,create_time INTEGER)", + "INSERT INTO sys_users VALUES(10,'一级',1,0,'0',901,NULL),(20,'二级',2,10,'0,10',902,NULL),(30,'三级',3,20,'0,10,20',903,NULL)", + "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(30,'lx',7,'绑定','0')", + "INSERT INTO tab_promote VALUES(10,'lx',7,'测试渠道',1,1,0,1)", + "INSERT INTO sys_alert_rule(rule_name,rule_type,monitor_metric,creator_id,unlogin_days,warning_color,enabled,is_deleted) VALUES('未登录规则','未登录',3,10,1,'red',1,0)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + provider := businessHTTPData{db} + registry := platformtask.NewRegistry() + tasks := taskbiz.NewTaskUsecaseWithRegistry(taskdata.NewTaskRepo(reportTaskHTTPData{provider}), registry) + alerts := businessbiz.NewAlertTriggerUsecase(businessdata.NewAlertTriggerRepo(provider)) + worker.NewTaskMethods(tasks, nil, nil, nil, nil, alerts).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) + }() + engine := gin.New() + router.RegisterTask(engine.Group(""), handler.NewTask(taskservice.NewTaskService(taskbiz.NewTaskApplicationUsecase(tasks, scheduler)))) + server := httptest.NewServer(engine) + defer server.Close() + call := func(method, path string, body any) map[string]any { + t.Helper() + raw, _ := json.Marshal(body) + req, _ := http.NewRequest(method, server.URL+"/timedTask/"+path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var result map[string]any + if err := json.NewDecoder(res.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result["code"] != float64(0) { + t.Fatalf("预警任务 HTTP 失败: %+v", result) + } + return result["data"].(map[string]any) + } + call("POST", "createTimedTask", map[string]any{"name": "预警 HTTP 演练", "spec": "@daily", "executorType": "method", "methodName": "alertTriggerTask", "enabled": false}) + var id uint + if err := db.Table("sys_timed_tasks").Select("id").Where("name=?", "预警 HTTP 演练").Scan(&id).Error; err != nil || id == 0 { + t.Fatalf("任务未创建: %v", err) + } + for iteration := 0; iteration < 2; iteration++ { + call("POST", "triggerTimedTask", map[string]any{"id": id}) + deadline := time.Now().Add(8 * time.Second) + finished := 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) > iteration { + if logs[0].(map[string]any)["status"] != "success" { + t.Fatalf("预警任务失败: %+v", logs) + } + finished = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !finished { + t.Fatal("没有收到预警执行完成日志") + } + } + var rows []struct { + BusinessID, TargetPromoteID uint + Status int + PlatformCode string + } + if err := db.Table("sys_alert_record").Scan(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].BusinessID != 10 || rows[0].TargetPromoteID != 10 || rows[0].PlatformCode != "lx" || rows[0].Status != 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/main.go b/cmd/main.go index 4778ea2..d55b8dc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -103,11 +103,8 @@ func httpAddress(server *config.Server) string { } func swaggerAddress(server *config.Server, admin *config.Admin) string { - if admin != nil && admin.App != nil { - switch strings.ToLower(strings.TrimSpace(admin.App.Env)) { - case "production", "prod", "live", "staging": - return "disabled" - } + if !config.SwaggerEnabled(admin) { + return "disabled" } address := httpAddress(server) if strings.HasPrefix(address, ":") { diff --git a/cmd/main_test.go b/cmd/main_test.go index c33e34a..7a90de2 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -13,9 +13,11 @@ func TestSwaggerAddress(t *testing.T) { admin *config.Admin want string }{ - {name: "default", want: "http://127.0.0.1:8000/swagger/index.html"}, - {name: "wildcard with prefix", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "0.0.0.0:9000"}}, admin: &config.Admin{RouterPrefix: "/admin/"}, want: "http://127.0.0.1:9000/admin/swagger/index.html"}, - {name: "host", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "example.test:8080"}}, want: "http://example.test:8080/swagger/index.html"}, + {name: "missing environment", want: "disabled"}, + {name: "qa", admin: &config.Admin{App: &config.App{Env: "qa"}}, want: "disabled"}, + {name: "development default", admin: &config.Admin{App: &config.App{Env: "dev"}}, want: "http://127.0.0.1:8000/swagger/index.html"}, + {name: "wildcard with prefix", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "0.0.0.0:9000"}}, admin: &config.Admin{RouterPrefix: "/admin/", App: &config.App{Env: "test"}}, want: "http://127.0.0.1:9000/admin/swagger/index.html"}, + {name: "host", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "example.test:8080"}}, admin: &config.Admin{App: &config.App{Env: "local"}}, want: "http://example.test:8080/swagger/index.html"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/cmd/ranking_http_test.go b/cmd/ranking_http_test.go index 3e61412..2fd351b 100644 --- a/cmd/ranking_http_test.go +++ b/cmd/ranking_http_test.go @@ -115,7 +115,19 @@ func TestRankingHTTP(t *testing.T) { } } if lx == nil || lx["metricValue"] != "50.00" || lx["momValue"] != "-50.00" || lx["momStatus"] != float64(-1) { - t.Fatalf("付费率应计算百分点差: %+v", lx) + t.Fatalf("付费率应计算相对上期增幅: %+v", lx) + } + for _, identity := range []string{"B", "C", "D"} { + if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,1,'2026-11-03',2,?)", identity).Error; err != nil { + t.Fatal(err) + } + } + p = call(7, "channel", query, 0) + for _, value := range p["payRateRankList"].([]any) { + row := value.(map[string]any) + if row["sourceDb"] == "lx" && (row["metricValue"] != "50.00" || row["momValue"] != "100.00" || row["momStatus"] != float64(1)) { + t.Fatalf("25%% 升到 50%% 应为 +100%% 环比: %+v", row) + } } p = call(7, "business", query, 0) rows := p["registerRankList"].([]any) diff --git a/cmd/report_build_http_test.go b/cmd/report_build_http_test.go index b1b030a..f8e2f99 100644 --- a/cmd/report_build_http_test.go +++ b/cmd/report_build_http_test.go @@ -70,7 +70,7 @@ func TestReportBuildThroughNativeTaskHTTP(t *testing.T) { 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) + worker.NewTaskMethods(tasks, nil, nil, nil, reports, nil).RegisterTasks(registry) scheduler := worker.NewTaskScheduler(tasks, nil, worker.NewTaskExecutorWithRegistry(tasks, registry), nil) defer func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -102,7 +102,7 @@ func TestReportBuildThroughNativeTaskHTTP(t *testing.T) { return value["data"].(map[string]any) } methods := call("GET", "getRegisteredMethods", nil)["methods"].([]any) - if len(methods) != 8 { + if len(methods) != 9 { t.Fatalf("六个商务汇总方法未注册: %d", len(methods)) } for index, method := range []string{"swReportDayBackfillTask", "swReportServerDayBackfillTask"} { diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index cdee1b1..2bed735 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -290,7 +290,9 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo) reportBuildRepo := business.NewReportBuildRepo(dataData) reportBuildUsecase := business2.NewReportBuildUsecase(reportBuildRepo) - taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store, reportBuildUsecase) + alertTriggerRepo := business.NewAlertTriggerRepo(dataData) + alertTriggerUsecase := business2.NewAlertTriggerUsecase(alertTriggerRepo) + taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store, reportBuildUsecase, alertTriggerUsecase) routeRegistrar := buildRuntime(registry, routes, taskMethods) websocketServer, cleanup2, err := websocket.New(runtimeconfigStore) if err != nil { diff --git a/docs/ADMIN_BACKEND.md b/docs/ADMIN_BACKEND.md index 4f18ae4..701cfa6 100644 --- a/docs/ADMIN_BACKEND.md +++ b/docs/ADMIN_BACKEND.md @@ -23,7 +23,7 @@ Kra 管理后台由 Kratos 应用承载生命周期,Gin 提供兼容管理端 | 目录 | 职责 | |---|---| | `internal/server` | Gin 路由、中间件和 Kratos transport 生命周期 | -| `internal/service` | HTTP DTO 与领域对象转换、用例编排 | +| `internal/service` | HTTP 输入校验、DTO 与领域对象转换 | | `internal/biz` | 领域对象、业务规则和仓储接口 | | `internal/data` | GORM、MySQL、Redis 和本地文件存储实现 | | `web` | Vue 3 管理端 | diff --git a/docs/API_REVIEW.md b/docs/API_REVIEW.md new file mode 100644 index 0000000..167fcf4 --- /dev/null +++ b/docs/API_REVIEW.md @@ -0,0 +1,55 @@ +# 当前接口审查(2026-11-04) + +## 范围 + +以 `internal/routecatalog/catalog.go` 与实际 Gin 注册为基线:共 **252** 个接口,其中商务 **55** 个(包括 4 个找回密码接口),原生框架 **197** 个。公开接口 15 个,其余 237 个均检查认证入口;分别在空前缀和 `/api` 前缀下执行未登录请求,共 **474** 项认证回归。 + +路由完整性与鉴权检查覆盖全部入口。按领域审查商务查询、权限范围、输入/排序/分页、写入事务、导出和定时任务,以及原生认证、权限、上传、支付、集成配置和审计的公共处理。真实外部支付、短信发送、OA 审批和所有原生管理写操作未在共享测试数据上逐一执行,不能将路由覆盖等同于这些外部流程全量验收。 + +## 本轮修复 + +| 问题 | 修改与回归 | +|---|---| +| 渠道激活只按商务账号名授权,跨平台同名可能串数据 | 列表和流水明细均按已通过绑定的“平台+账号”匹配;测试同名异平台、无绑定、管理员及状态范围 | +| 日期只传一端时被忽略,统计结果悄悄回到默认区间 | 商务列表、注册/充值、渠道/游戏统计、区服分析及排行榜统一拒绝不完整/非法/倒置范围;合法默认与单日保留 | +| 审计写入无 deadline,数据库异常可能拖住退出 | 每批写入限时 2 秒,Close 最多等 5 秒;关闭后拒绝入队,测试阻塞写入被取消 | +| 证书的 content/path/file 别名未脱敏 | 配置读回、掩码提交合并与审计共享识别;覆盖内联证书、密钥、PKCS12、嵌套字段和普通 content 字段 | +| 支付测试的结构化步骤消息透传适配器原始错误 | HTTP 返回安全错误和步骤信息,服务端保留诊断;避免 URL、凭据、SQL 经 data 字段泄露 | +| 已有错误弹窗时 401 回调被丢弃 | 会话失效立即清理并跳转,不依赖弹窗确认;旧请求不覆盖新令牌或注销新会话;真实 Chrome 验证 | +| Swagger 的环境判定与启动日志不同,空环境默认开放 | 配置统一判定,仅显式 dev/development/test/local 开放;启动地址和路由一致,包含缺省及 qa/uat 测试 | +| 媒体分类保存原名会把自身当重复记录 | 重名检查排除当前 ID,仍拒绝其他同名分类;媒体重命名缺失记录映射领域 NotFound | +| 偏好对比脚本在 HTTP 恢复失败时跳过数据库恢复 | 双层 finally 确保尝试恢复原行和更新时间 | + +本次提交也包含此前完成但尚未提交的迁移任务、SQL 修正、查询索引、性能优化及页面修复。性能和统计口径见 [商务验收](sw6-to-kra/validation.md)。 + +## 验证结果与数据漂移 + +- `make generate`、`go test ./...`、`go vet ./...`、服务构建通过;Wire 由工具重新生成。 +- 独立 MySQL 5.7 的 5 组测试通过:预警并发去重、渠道并发创建、注册 CAST 索引、区服时间索引、查询索引幂等及冲突拒绝。 +- 前端完整 ESLint 和生产构建通过;构建仍提示大分块及第三方 BigInt 目标兼容告警,没有把告警视为旧浏览器兼容验证。Chrome 实测覆盖另一个错误弹窗已打开时的 401 清理和跳转。 +- 新旧真实读取共 75 组:管理员 36,绑定三级 21,专用一级/二级/三级各 6。**73 组相等,2 组预警列表因数据快照不同而不等**,失败原始报告已保留。 +- 漂移核实:sw8 的预警在 **2026-11-04 14:00** 被更新,当前 480 条;sw9 为原快照 478 条,其中 55 个同 ID 记录的时间已不同。本机旧服务配置仍关闭调度,未从其日志确认更新来源;没有覆盖、删除或回写 sw8 的新记录。 +- 使用之前保存的同参数 sw8 快照重新核对当前 sw9 的完整预警列表,通过;报告明确 `source_capture_reused=true`。这证明当前列表符合该快照,不代表两库当前内容相等。 +- 新版已启动在日常 8000;8 组工作台、排行榜、激活第二页、偏好和任务注册结果与验证实例一致。 + +本轮原始报告位于仓库外 `D:/Users/Yvan/backups/sw9/20261104_rehearsal/review_20261104/`。此前完整写入/导出/重建及恢复证据见 [历史综合索引](sw6-to-kra/sw8-sw9-recheck-summary-2026-11-04.json)。本机无可用 CGO 编译环境,未运行 `-race`,不宣称竞态检测通过。 + +## 保留的框架风险与验证边界 + +旧审查材料中尚未消除的内容移到这里,避免删除历史文档时丢失问题。它们不属于“已修复”清单: + +| 范围 | 当前边界 | +|---|---| +| 媒体并发 | 删除只有进程内互斥,秒传复用创建与删除缺少统一的跨实例引用锁;长时间合并仍受陈旧会话清理宽限影响,需要独立并发/故障方案 | +| 配置保存 | storage、email 和配置文件分步持久化,中途失败可能形成部分成功;尚无跨存储的原子提交/补偿流程 | +| 历史支付订单 | 请求指纹严格匹配;缺少旧指纹样本的幂等重放验证,不直接放宽金额、业务身份的冲突检查 | +| 错误协议 | 框架仍有裸错误与字符串拼接、响应净化规则;此次堵住支付测试 data 泄露,但未统一全框架 typed error 协议 | +| 认证与前端路由 | token 仍由既有 localStorage 方案保存,登录与守卫仍各有动态路由注册;改成仅 HttpOnly cookie 或统一注册需要独立兼容设计 | +| 调度启动 | 永久非法 cron 会保持旧计划并继续启动重试;数据库读失败不清空既有计划,但固定重试间隔仍可改成有界退避 | +| 审计可读性 | 一些通用 key/序列号字段仍被旧脱敏策略掩盖;未为恢复可读性而放宽密钥保护 | + +## 文档与脚本清理 + +104 份阶段报告、重复 JSON、旧目录审查和独立菜单 SQL,以及 26 个旧脚本已逐文件复制到仓库外并校验 SHA-256 后移除。保留统一迁移 SQL、业务口径、综合验收索引和 14 个当前验证工具,补充维护入口与脚本说明。 + +真实本地配置 `configs/config.yaml`、私有运行数据,以及其他会话的 V2 估算和 outputs 成果不纳入本次提交。未推送远端。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..fd3dc1f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,9 @@ +# 文档入口 + +- [商务迁移](sw6-to-kra/README.md):统一 SQL、业务口径、接口与任务验收。 +- [当前接口审查](API_REVIEW.md):此次修复、验证范围及保留的技术风险。 +- [后台说明](ADMIN_BACKEND.md)、[支付接入](PAYMENT.md)、[短信接入](SMS_INTEGRATION.md)。 +- [OA 迁移计划](OA_MIGRATION_PLAN.md):独立 OA 工作的设计材料,不是当前商务迁移的完成证明。 +- [验证脚本](../scripts/README.md):保留工具的用途和私有输入约定。 + +历史阶段记录已归档到仓库外;后续将最新结果更新到对应维护文档,不再每个接口生成一份日期报告。其他会话生成的商务 V2 估算及表格保持原状,本次清理和提交不包含这些文件。 diff --git a/docs/code-review-issues.md b/docs/code-review-issues.md deleted file mode 100644 index 48e8b33..0000000 --- a/docs/code-review-issues.md +++ /dev/null @@ -1,78 +0,0 @@ -# 代码审查问题清单(internal + pkg) - -- 审查日期:2026-08-27 ~ 2026-08-28,共十一轮全量审查。第十一轮(2026-08-28):核查第十轮划线项(22 项)+ 全新视角回归审查;`go build ./...` 编译验证通过 -- 文档结构:只保留待修复问题,按**类型**归类;每条标注发现轮次与处置状态(专项待修/待修);已修复并经复查确认的、经评估决定保留的、用户决策不修的直接删除 -- gva/ 目录是遗留参考库(独立 module 不参与 kra 编译),不在审查范围 -- 依赖方向合规:pkg 无 import internal;integration 不 import data/service;data 不 import integration/payment;无循环依赖 -- 第十一轮总评:核心分层契约、日志防循环、CleanupStale 顺序反转、调度器重试、微文件合并等历轮修复验收干净;当前风险集中在**边缘入口的防护不对称**——公开写接口无治理、TTL 语义错位、停机路径无超时、前端凭证存储薄弱 - ---- - -## 一、安全与正确性缺陷 - -### 专项设计待修(用户已确认需专项处理) - -| # | 问题 | 位置 | 状态 | -|---|------|------|------| -| ~~V-39~~ | ~~公开无限流写接口 `/sysError/createSysError`~~:已移入鉴权路由组、纳入安全限流,并限制 `Info` 最大长度 | ~~server/router/audit.go;middleware/rate_limit.go;service/dto/audit.go~~ | ~~十一轮~~ | -| V-33 | 支付指纹严格相等校验对存量订单幂等重放的冲突风险(initialized/pending 存量订单重放报参数冲突而非幂等短路)——修复需兼容旧指纹、区分订单状态、覆盖并发重放,直接放宽会削弱防篡改 | biz/payment/payment.go:645-657 | 十轮发现,专项 | -| V-24 | Delete↔Create 竞态窗口仍开(锁仅覆盖 Delete,秒传/上传/Complete 的 CreateMedia 路径不持锁);多副本需数据库/分布式锁 | biz/system/media.go:50,111-143;media_upload.go:100-107,262 | 八轮发现,专项 | -| V-13b | swagger:空 env fail-open(gin.go:68 仍注册);gin 白名单 vs main.go 黑名单双清单不一致(qa/uat 时日志谎报);env 门禁零测试——需统一单一判定函数+环境矩阵测试 | server/gin.go:64-70;cmd/main.go:105-126,171 | 七轮发现,专项 | -| V-27 | redis_list 仍按下标回退(configuration.go:439-441,database_list 已修✓);别名匹配失败时哨兵字面量落盘无拒绝校验——需重新定义配置项稳定标识与密钥继承规则 | internal/initialize/configuration.go:427-446 | 九轮发现,专项 | -| V-20 | 前端 token 拼入 URL(完整 JWT 拼二维码 URL)——需后端一次性短时效票据+扫码协议迁移 | web/src/components/upload/QR-code.vue:55 | 六轮发现,专项 | -| V-15 | **envelope 净化剩余三洞**(全角冒号已修✓——response.go:53 分隔符已扩 ["失败:","失败:"]):① 17 处无冒号/紧贴拼接调用截断不可命中(api.go:137,156、authority.go 9 处、menu.go:170,178,198、user.go:150),非黑名单错误原样透出;② **CodeError data 通道完全无净化**——payment TestProvider Stages[].Message 原始 err 随 data 到前端;③ 净化静默无日志("不泄露也不可排障")。另有误伤:宽泛词 marker+约 45 处截断吞 biz 可读提示——需统一错误模型+结构化 Data 脱敏+日志策略 | server/httpx/response.go:32-59;data/payment/payment.go:79-139 | 六轮发现,专项 | -| V-19 | **证书别名缺口仍在(三层同源✓但键表未补别名——"已纳入键表"声称不实)**:app_cert_content/root_cert_content/pkcs12_content/cert_content/key_content/cert_path 等仍不命中 IsIntegrationSecretKey(后缀表无 *_content/*_path 项,integration_config.go:402-413)→ API 直连提交明文落库并回显,日志 resp_data 同泄;修法:剥后缀匹配词干或显式枚举(勿用裸 content/path 后缀防误伤) | biz/integration/integration_config.go:402-413,329-332,427-435 | 六轮发现,十轮部分+十一轮复核未闭合 | -| ~~V-30~~ | ~~douyin `platform_serial_no` required 标记与别名校验不一致~~:字段已标记 required,支付 required 校验同时识别 `platform_cert_serial` 别名 | ~~biz/integration/integration_config_definition.go;integration_config.go~~ | ~~十一轮复核~~ | -| V-38 | 媒体域裸错误(ErrMediaTooLarge/ErrUploadSessionNotFound)vs AGENTS typed errors 契约——需连同错误码表与 HTTP 映射一起迁移 | biz/system/media.go:52、media_upload.go:21 | 十轮发现,专项 | - -### 待修(正确性缺陷) - -| # | 问题 | 位置 | 状态 | -|---|------|------|------| -| ~~V-40~~ | ~~上传会话 TTL 未按最后活动时间刷新~~:每次分片写入后同步更新上传会话 `updated_at` | ~~data/system/media_upload.go~~ | ~~十一轮~~ | -| V-41 | (新发现,中低)merging 仅 1h 宽限(data:129),远程存储 Compose 超 1h 的合法合并被清理并整体失败(有回滚无泄漏,功能误杀) | data/system/media_upload.go:129 | 十一轮 | -| V-42 | **(新发现,中)dataScopeAuditWriter.Close 无超时且 MySQL DSN 无网络超时**:Close 无限期 `<-w.done`(:67-73)、flush 用 context.Background 无 deadline(:88)、DSN 无 readTimeout/writeTimeout(config.yaml:9,15)——DB 故障时 cleanup 链(data.go:191-198 先 auditLog.Close 后关 DB)被挂起 flush 永久阻塞,进程无法优雅退出 | data/data_scope_audit.go:67-73,88;configs/config.yaml:9,15 | 十一轮 | -| V-43 | **(新发现,中)前端 token 存 localStorage(7 天长期凭证 XSS 可窃)+ 401 登出回调可被并发弹窗吞掉**(application/index.vue:16 已有弹窗时 show-error 被忽略、handleClose 置 null cb 不执行 → 401 后 token 残留循环) | web/src/pinia/modules/user.js:23;components/application/index.vue:16;utils/request.js:231-241 | 十一轮 | -| ~~V-44~~ | ~~V-36 词表收窄导致“导出token无效或已过期”不再命中~~:已补充 `token无效` 匹配 | ~~middleware/error_audit.go~~ | ~~十一轮~~ | -| ~~V-45~~ | ~~sysError 写路径失败无日志~~:创建失败时已记录结构化错误日志 | ~~server/handler/audit.go~~ | ~~十一轮~~ | -| V-46 | (新发现,低)调度器启动重试无上限无退避:永久性错误(cron 解析失败整批 return err,task_scheduler.go:204-207)会每 5s 重试永不成功+刷 Warn 日志(约 720 条/小时)——需区分永久/暂时性错误 | worker/task_scheduler.go:93-112,204-207 | 十一轮(V-35 修复残留) | -| V-47 | (新发现,低)PersistRuntimeConfig 三段写非原子(先 storage 再 email 再 config,任一失败返回)——前面已写的集成配置与未替换的 runtime 出现不一致窗口 | data/initialization_backend.go:74-83 | 十一轮 | -| V-31 残留 | UpdateMediaName 仍透传 gorm 原文(404 语义丢失) | data/system/media.go:125-127 | 九轮,部分修复 | - -## 二、死代码与碎屑 - -| # | 问题 | 位置 | 状态 | -|---|------|------|------| -| Z-15 | 软删字段死重量(4 个 PO 的 DeletedAt+索引在删除路径全 Unscoped 后成死配置)——存量表结构清理需迁移与回滚策略,待数据库迁移窗口 | data/system/media.go:16,34;media_upload.go:17,35,104 | 九轮,专项 | -| Z-9 | audit.go:76/83 ctxRespTextKey/ctxRespTruncatedKey 消费分支生产链路不可达(后置键);access_log 先对截断体完整脱敏再覆盖(白做功)——需连同响应捕获链路整体重构 | server/middleware/audit.go:76-85;access_log.go:110-121 | 八轮,专项 | -| Z-13 残留 | cors.go:13-14 逗号空格、audit.go:104 单行 13 字段、audit.go:46-47 空 else-if——纯格式问题(用户评估:格式化工具统一处理) | server/middleware/ | 八轮,评估暂缓 | -| Z-17 | scanUpload.vue 空 catch 吞路由就绪异常——需统一前端错误上报 | web/src/view/media/scanUpload.vue:115-116 | 九轮,专项 | -| Z-22 | (新发现)Z-19 清理后前端死代码残留:image.js:32-35 空 if 块+未使用 newImgSize;global.js:51,54 未使用 allKeys+悬空比较表达式;另 3 处已注释 console 代码(cropper.vue:198、upload.vue:363、menus.vue:302) | web/src/utils/image.js:32-35;core/global.js:51,54 | 十一轮 | -| ~~Z-23~~ | ~~douyin 别名分支放错校验器~~:已移除通信校验器中的不可达分支,别名逻辑归入支付校验器 | ~~biz/integration/integration_config.go~~ | ~~十一轮~~ | -| Z-24 | (新发现)秒传分支变量名 `copy` 遮蔽内置函数(可读性) | biz/system/media_upload.go:100 | 十一轮 | - -## 三、重复实现 / 双轨残留 - -| # | 问题 | 位置 | 状态 | -|---|------|------|------| -| Y-7 | 掩码三套实现+键表多层漂移(data Secret∪IsIntegrationSecretKey / service 仅后者 / redact.go 第四张表);哨兵双轨(****** vs ***)——需先定义跨层协议(专项) | data/integration:225-245;service/integration:69-96;redact.go:18-27 | 九轮,专项 | -| Y-12 | **(新发现,中低)redact 键表误伤非机密标识符,审计记录失去回放价值**:redact.go:22 精确项 "key"、:24-25 platformserialno/webhookid 是序列号/ID 非秘密——实证误伤 dto/menu.go:26,90(菜单路由 key)、dto/media.go:15,76(存储 key)、settings.go:45,56,117、version.go:39,这些写接口的操作审计响应中 key 全为 `***`,菜单/媒体/设置类审计基本无法回放排障(安全无泄漏,可用性受损) | server/middleware/redact.go:22,24-25;dto/menu.go:26 等 | 十一轮 | -| Y-5 | 集成配置校验双重执行(biz 层对掩码值总通过半失效)——用户评估:分层防线保留 | biz/integration:122-129 | 八轮,评估保留 | -| Y-2 剩余 | capture.go JSON 成功路径先 marshal 后判超限(超长白做)——用户评估:安全优先保留现状 | middleware/capture.go:49-76 | 六轮,评估保留 | -| Y-10 | 前端两套动态路由注册逻辑(登录整树 vs 守卫扁平化+redirect)——需先补路由矩阵测试 | web/src/pinia/modules/user.js:85-87;permission.js:78-146 | 十轮,专项 | - -## 四、文档漂移 - -| # | 问题 | 位置 | 状态 | -|---|------|------|------| -| F-37 | **CLAUDE.md:19 integration 描述仍为 "(cache, email, payment, storage)" 4 项("已同步"声称不实——实际目录 8 个模块,AGENTS.md 版本已含全部)** | CLAUDE.md:19 | 十轮发现,声称不实 | -| F-39 | (新,微)根 README.md:7-18 目录树缺 internal/{integration,modules,paymentkit,routecatalog,logging,utils} 与 pkg/(AGENTS.md 为权威无错,README 属简化版) | README.md:7-18 | 十一轮 | - -## 处置建议(按优先级) - -1. **V-39 公开写接口治理**(限流覆盖/长度 binding/或移入鉴权组——安全优先)+ **V-44/V-45 ErrorAudit 噪音与盲区**(词表补导出 token 文案或改 4xx;CreateError 失败补日志) -2. **V-40 TTL 改活动时间**(UpsertChunk touch 会话 updated_at——一行修复)+ **V-42 Close/DSN 超时** + **V-46 重试上限/退避** -3. **V-19 键表补别名**(剥后缀匹配词干——一处三层受益;连带 Z-23 死分支归位)+ **V-30 douyin required**(通用循环感知别名后置 true) -4. **V-43 前端凭证**(401 回调修复+token 存储评估)+ **F-37 一行同步**(本轮声称不实项) -5. **V-41/V-47/V-31 残留 + Z-22/23/24 碎屑 + Y-12 redact 误伤面收敛**(与 Y-7 专项一并设计) -6. **专项批次**:V-15 错误模型统一、V-33 指纹兼容窗口、V-24 分布式锁、V-13b 单一判定函数、V-27 稳定标识、V-20 短时票据、V-38 错误码迁移、Z-15 迁移窗口、Z-9 响应链重构、Y-7 跨层协议、Y-10 路由矩阵 diff --git a/docs/sw6-to-kra/README.md b/docs/sw6-to-kra/README.md new file mode 100644 index 0000000..380161c --- /dev/null +++ b/docs/sw6-to-kra/README.md @@ -0,0 +1,28 @@ +# 商务迁移维护入口 + +旧 Java 商务使用测试库 **sw8**,当前 Go 商务使用 **sw9**。两库来自相同线上副本;未执行正式库 sw6 迁移。 + +## 执行顺序 + +1. 准备完整业务副本及 SQL 要求的 26 张框架源表,保存数据库备份。 +2. 在 [统一迁移 SQL](sw_to_kra_migrate.sql) 开头修改 `USE`,当前为 `USE sw9`。用支持 `DELIMITER` 的客户端在同一连接执行,遇错停止。 +3. 启动当前 Go 版本,执行版本迁移,补齐业务锁、密码重置表、附件引用和查询索引。大表首次建索引需要等待完成。 +4. 配置主库和 `lx`、`hw`、`yq`、`oa` 数据源。配置和凭据保存在仓库外;数据库连接信息不写入本目录。 +5. 对照 [验收记录](validation.md) 与 [验证脚本说明](../../scripts/README.md) 复核接口和任务。迁入计划默认停用,启用前应确认业务所需运行时段。 + +SQL 自动区分首次迁移、已完成迁移的菜单调整和异常中间状态;不会对完成迁移的库重新导入账号。重复执行及旧表已归档/清理的路径均有演练记录。 + +当前 SQL v5 按 LF 规范化 SHA-256: +`07e2c8395b6f43f064fe973407f374a962b39d07474bfba68aeb2b3c5ffe8bf2`。 + +## 维护材料 + +- [验收与性能记录](validation.md):数据、任务、已知契约差异和性能基线。 +- [历史逐接口验收索引](sw8-sw9-recheck-summary-2026-11-04.json):63 项旧契约及 230 项检查;时间点为最近一次完整写入演练,不代表以后每轮都重跑写操作。 +- [原生系统接口与旧别名](system-api-audit.md)、[附件引用转换](alert-attachment-migration.md)。 +- 各域 `*-source-notes.md`:长期保留的查询、权限和统计口径。 +- [当前接口审查](../API_REVIEW.md):此次修复、测试范围与需继续验证的框架边界。 + +SQL 生成器位于旧仓库 `script/sql/sw6_to_kra/build_sw7_migration.py`。修改生成器后同步本目录统一 SQL,并重新核对散列及迁移结果;不要恢复分阶段 SQL 补丁。 + +阶段报告、重复 JSON、旧 sw7 冒烟脚本已归档到本机仓库外 `D:/Users/Yvan/backups/sw9/20261104_rehearsal/cleanup-archive/`。该目录以及相邻的运行配置、原始响应、分区备份不纳入 Git。历史索引中 `reports` 项的文件名可在归档内查找。 diff --git a/docs/sw6-to-kra/activation-http-2026-11-03.json b/docs/sw6-to-kra/activation-http-2026-11-03.json deleted file mode 100644 index 15e5f3f..0000000 --- a/docs/sw6-to-kra/activation-http-2026-11-03.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "scope": "当前 sw7 运行实例,只读请求;OA 未接入场景明确返回 available=false。完整 OA 与流水数据通过两个隔离数据库的 TCP HTTP 测试。", - "checks": [ - { - "method": "GET", - "path": "/sw/promoteActivePool/bindList?pageNum=1&pageSize=10", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": false - }, - { - "method": "GET", - "path": "/sw/promoteActivePool/bindList?platformNames=lx,hw&statuses=reached", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": false - }, - { - "method": "GET", - "path": "/sw/promoteActivePool/bindList?statuses=%E5%B7%B2%E8%BE%BE%E6%A0%87&busierAccount=does-not-exist", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": false - }, - { - "method": "GET", - "path": "/sw/promoteActivePool/bindSpendDetail?id=0", - "http": 200, - "code": 7, - "msg": "绑定记录不存在", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/activation-page-2026-11-03.json b/docs/sw6-to-kra/activation-page-2026-11-03.json deleted file mode 100644 index 6954812..0000000 --- a/docs/sw6-to-kra/activation-page-2026-11-03.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "method": "真实 Chrome,受控 OA 和流水响应,无写入", - "passed": true, - "rejectedHidesFlow": true, - "pendingShowsReasonWithoutFakeTotal": true, - "reachedCutoffDisplayed": true, - "exactLargeAmount": true, - "filtersAndReset": true, - "unavailableSourceNotice": true, - "pageErrors": [] -} diff --git a/docs/sw6-to-kra/alert-records-http-2026-11-03.json b/docs/sw6-to-kra/alert-records-http-2026-11-03.json deleted file mode 100644 index 7828306..0000000 --- a/docs/sw6-to-kra/alert-records-http-2026-11-03.json +++ /dev/null @@ -1,123 +0,0 @@ -{ - "scope": "既有 sw7,只读查询及必然拒绝的无效写请求;正向流转另用隔离 SQLite 和 TCP HTTP 测试", - "base_url": "http://127.0.0.1:18000", - "checks": [ - { - "method": "GET", - "path": "/sw/alertRecord/list?pageNum=1&pageSize=10", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": true, - "status_count": {} - }, - { - "method": "GET", - "path": "/sw/alertRecord/list?platformCodes=lx,hw&status=5", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": true, - "status_count": {} - }, - { - "method": "GET", - "path": "/sw/alertRule/ruleTypes", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true - }, - { - "method": "GET", - "path": "/sw/business/list?pageNum=1&pageSize=500", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true - }, - { - "method": "GET", - "path": "/sw/alertRecord/list?businessId=10007", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": false, - "status_count": {} - }, - { - "method": "GET", - "path": "/sw/alertRecord/list?businessId=10001", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": true, - "status_count": {} - }, - { - "method": "GET", - "path": "/sw/alertRecord/detail/0", - "http": 200, - "code": 7, - "msg": "预警记录参数不正确", - "passed": true - }, - { - "method": "POST", - "path": "/sw/alertRecord/startProcess", - "http": 200, - "code": 7, - "msg": "预警记录参数不正确", - "passed": true - }, - { - "method": "POST", - "path": "/sw/alertRecord/submitAudit", - "http": 200, - "code": 7, - "msg": "预警记录参数不正确", - "passed": true - }, - { - "method": "POST", - "path": "/sw/alertRecord/approve", - "http": 200, - "code": 7, - "msg": "预警记录参数不正确", - "passed": true - }, - { - "method": "POST", - "path": "/sw/alertRecord/reject", - "http": 200, - "code": 7, - "msg": "预警记录参数不正确", - "passed": true - }, - { - "method": "POST", - "path": "/sw/alertRecord/reProcess", - "http": 200, - "code": 7, - "msg": "预警记录参数不正确", - "passed": true - }, - { - "method": "POST", - "path": "/sw/alertRecord/addNote", - "http": 200, - "code": 7, - "msg": "预警记录参数不正确", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/alert-records-page-2026-11-03.json b/docs/sw6-to-kra/alert-records-page-2026-11-03.json deleted file mode 100644 index ffc8840..0000000 --- a/docs/sw6-to-kra/alert-records-page-2026-11-03.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "method": "真实 Chrome 渲染;受控身份、预警数据、写接口和图片上传;未修改真实业务数据", - "results": [ - { - "level": 0, - "stateButtons": [ - { - "status": 1, - "label": "处理" - }, - { - "status": 2, - "label": "继续处理" - }, - { - "status": 3, - "label": "查看" - }, - { - "status": 4, - "label": "重新处理" - }, - { - "status": 5, - "label": "查看" - } - ], - "passed": true, - "pageErrors": [] - }, - { - "level": 1, - "stateButtons": [ - { - "status": 1, - "label": "处理" - }, - { - "status": 2, - "label": "继续处理" - }, - { - "status": 3, - "label": "审核" - }, - { - "status": 4, - "label": "重新处理" - }, - { - "status": 5, - "label": "查看" - } - ], - "passed": true, - "workflowAndAttachments": true, - "pasteDropAndRemove": true, - "pageErrors": [] - }, - { - "level": 2, - "stateButtons": [ - { - "status": 1, - "label": "处理" - }, - { - "status": 2, - "label": "继续处理" - }, - { - "status": 3, - "label": "查看" - }, - { - "status": 4, - "label": "重新处理" - }, - { - "status": 5, - "label": "查看" - } - ], - "passed": true, - "statusCardsAndUnavailable": true, - "pageErrors": [] - }, - { - "level": 3, - "stateButtons": [ - { - "status": 1, - "label": "处理" - }, - { - "status": 2, - "label": "继续处理" - }, - { - "status": 3, - "label": "查看" - }, - { - "status": 4, - "label": "重新处理" - }, - { - "status": 5, - "label": "查看" - } - ], - "passed": true, - "pageErrors": [] - } - ] -} diff --git a/docs/sw6-to-kra/alert-rules-http-2026-11-03.json b/docs/sw6-to-kra/alert-rules-http-2026-11-03.json deleted file mode 100644 index 4dc7aea..0000000 --- a/docs/sw6-to-kra/alert-rules-http-2026-11-03.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "scope": "既有 sw7;查询和拒绝无效写请求,未创建真实规则", - "schema_before": { - "column_type": "decimal(7,2)", - "rule_rows": 0 - }, - "schema_after": "decimal(7,2)", - "checks": [ - { - "method": "GET", - "path": "/sw/alertRule/list?pageNum=1&pageSize=10", - "code": 0, - "msg": "成功", - "passed": true - }, - { - "method": "GET", - "path": "/sw/alertRule/ruleTypes", - "code": 0, - "msg": "成功", - "passed": true - }, - { - "method": "POST", - "path": "/sw/alertRule/add", - "code": 7, - "msg": "预警规则参数不正确", - "passed": true - }, - { - "method": "PUT", - "path": "/sw/alertRule/toggle/0?enabled=1", - "code": 7, - "msg": "预警规则参数不正确", - "passed": true - } - ] -} \ No newline at end of file diff --git a/docs/sw6-to-kra/alert-rules-page-2026-11-03.json b/docs/sw6-to-kra/alert-rules-page-2026-11-03.json deleted file mode 100644 index 227ca61..0000000 --- a/docs/sw6-to-kra/alert-rules-page-2026-11-03.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "method": "真实 Chrome 渲染,受控角色及规则数据;不写真实规则", - "results": [ - { - "level": 0, - "createButton": 1, - "editButton": 1, - "passed": true, - "pageErrors": [] - }, - { - "level": 1, - "createButton": 1, - "editButton": 1, - "passed": true, - "metricFields": true, - "pageErrors": [] - }, - { - "level": 2, - "createButton": 0, - "editButton": 0, - "passed": true, - "pageErrors": [] - }, - { - "level": 3, - "createButton": 0, - "editButton": 0, - "passed": true, - "pageErrors": [] - } - ] -} \ No newline at end of file diff --git a/docs/sw6-to-kra/analytics-http-2026-11-04.json b/docs/sw6-to-kra/analytics-http-2026-11-04.json deleted file mode 100644 index a006c1d..0000000 --- a/docs/sw6-to-kra/analytics-http-2026-11-04.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "scope": "真实 sw7 会话,只读请求;完整统计及权限口径另由隔离 TCP HTTP 测试验证。", - "checks": [ - { - "path": "/sw/channelData/list?sourceDb=lx,hw,yq&pageNum=1&pageSize=10", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": false, - "total": 0, - "begin": "2026-11-04", - "end": "2026-11-04" - }, - { - "path": "/sw/channelData/list?beginTime=bad&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计周期格式错误,应为 yyyy-MM-dd", - "passed": true - }, - { - "path": "/sw/channelData/list?beginTime=2026-11-05&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计开始日期不能晚于结束日期", - "passed": true - }, - { - "path": "/sw/gameData/list?sourceDb=lx,hw,yq&pageNum=1&pageSize=10", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": false, - "total": 0, - "begin": "2026-11-04", - "end": "2026-11-04" - }, - { - "path": "/sw/gameData/list?beginTime=bad&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计周期格式错误,应为 yyyy-MM-dd", - "passed": true - }, - { - "path": "/sw/gameData/list?beginTime=2026-11-05&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计开始日期不能晚于结束日期", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/analytics-page-2026-11-04.json b/docs/sw6-to-kra/analytics-page-2026-11-04.json deleted file mode 100644 index b02c262..0000000 --- a/docs/sw6-to-kra/analytics-page-2026-11-04.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "method": "真实 Chrome 渲染,受控业务数据;未写入真实数据", - "reports": [ - { - "kind": "channel", - "passed": true, - "fullSummaryAcrossPages": true, - "filtersResetPage": true, - "platformAndGameFilters": true, - "noInventedExport": true, - "missingDataNotZero": true, - "pageErrors": [] - }, - { - "kind": "game", - "passed": true, - "fullSummaryAcrossPages": true, - "filtersResetPage": true, - "platformAndGameFilters": true, - "noInventedExport": true, - "missingDataNotZero": true, - "pageErrors": [] - } - ] -} diff --git a/docs/sw6-to-kra/analytics-source-notes.md b/docs/sw6-to-kra/analytics-source-notes.md index 2518314..7f9630c 100644 --- a/docs/sw6-to-kra/analytics-source-notes.md +++ b/docs/sw6-to-kra/analytics-source-notes.md @@ -25,4 +25,5 @@ - 使用聚合查询和批量资料查询代替逐行查询,不改变业务口径。日期非法或倒置返回明确错误。 - 隔离 TCP HTTP 和仓储测试覆盖全量汇总与分页、跨平台同号、官方/孤立渠道、根渠道树去重、类型交集、历史游戏名过滤、当前资料展示、偏好切换及空范围。 - 领域测试覆盖大金额精确排序、分母和人数上限。前端检查筛选回第一页、30 天默认值、汇总不随分页变化、缺数据提示、没有额外导出入口。 -- 真实 sw7 库只做只读请求;当前缺少完整业务报表,最终旧库复制与同源数据比较仍未开始。 + +当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。 diff --git a/docs/sw6-to-kra/business-page-parity-2026-11-03.json b/docs/sw6-to-kra/business-page-parity-2026-11-03.json deleted file mode 100644 index 1c76ee9..0000000 --- a/docs/sw6-to-kra/business-page-parity-2026-11-03.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "method": "真实 Chrome 渲染;受控身份和业务数据,权限页签与状态操作核对", - "results": [ - { - "level": 0, - "bindingTab": false, - "editButtons": 0, - "passed": true, - "bindingCalls": 0, - "pageErrors": [] - }, - { - "level": 1, - "bindingTab": false, - "editButtons": 0, - "passed": true, - "sdkDetailReadonly": true, - "bindingCalls": 0, - "pageErrors": [] - }, - { - "level": 2, - "bindingTab": false, - "editButtons": 0, - "passed": true, - "sdkDetailReadonly": true, - "bindingCalls": 0, - "pageErrors": [] - }, - { - "level": 3, - "bindingTab": true, - "editButtons": 0, - "passed": true, - "normalUnbind": 1, - "rejectedUnbind": 0, - "pendingUnbind": 0, - "rejectedDetail": 1, - "rejectedChannelsDisabled": true, - "pendingChannelsDisabled": true, - "channelFilters": true, - "rejectDrawerInViewport": true, - "bindingCalls": 1, - "pageErrors": [] - } - ] -} \ No newline at end of file diff --git a/docs/sw6-to-kra/channel-http-2026-11-03.json b/docs/sw6-to-kra/channel-http-2026-11-03.json deleted file mode 100644 index bbb37b1..0000000 --- a/docs/sw6-to-kra/channel-http-2026-11-03.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "scope": "当前 sw7 实际登录会话;只读查询和无效创建。完整查询、偏好、游戏分布及源库创建已在隔离数据库通过 TCP HTTP 验证。", - "checks": [ - { - "method": "GET", - "path": "/sw/promote/list?pageNum=1&pageSize=10", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": false - }, - { - "method": "GET", - "path": "/sw/promote/list?sourceDb=lx,hw&lockStatus=-1&promoteLevel=1", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": false - }, - { - "method": "GET", - "path": "/sw/promote/list?busierAccount=does-not-exist", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": true - }, - { - "method": "GET", - "path": "/sw/promote/bindOptions?sourceDb=lx", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "rows": 17 - }, - { - "method": "GET", - "path": "/sw/promote/bindOptions?sourceDb=hw", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "rows": 20 - }, - { - "method": "GET", - "path": "/sw/promote/bindOptions?sourceDb=yq", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "rows": 20 - }, - { - "method": "GET", - "path": "/sw/promote/game-distribution?sourceDb=lx&promoteId=0", - "http": 200, - "code": 7, - "msg": "渠道查询参数不正确", - "passed": true - }, - { - "method": "GET", - "path": "/sw/promote/game-distribution?sourceDb=lx&promoteId=1", - "http": 200, - "code": 7, - "msg": "渠道数据尚未接入", - "passed": true - }, - { - "method": "POST", - "path": "/sw/promote", - "http": 200, - "code": 7, - "msg": "请选择有效的游戏商务绑定", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/channel-page-2026-11-03.json b/docs/sw6-to-kra/channel-page-2026-11-03.json deleted file mode 100644 index a19668f..0000000 --- a/docs/sw6-to-kra/channel-page-2026-11-03.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "method": "真实 Chrome 渲染;受控页面数据、身份及平台创建响应,不创建真实平台渠道", - "passed": true, - "level": 1, - "noEditEntry": true, - "createAllowedForUpperBusiness": true, - "platformSwitchClearsBinding": true, - "crossPlatformSameID": true, - "zeroGameCountClickable": true, - "missingMetricsVisible": true, - "sourceDatabaseCreatePayload": true, - "portraitPending": false, - "pageErrors": [] -} diff --git a/docs/sw6-to-kra/channel-portrait-http-2026-11-03.json b/docs/sw6-to-kra/channel-portrait-http-2026-11-03.json deleted file mode 100644 index f990a29..0000000 --- a/docs/sw6-to-kra/channel-portrait-http-2026-11-03.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "scope": "当前 sw7 登录会话,只读检查;完整累计、分层、日期与权限在隔离 TCP HTTP 中验证。", - "checks": [ - { - "path": "/sw/promote/list?pageNum=1&pageSize=1", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": false - }, - { - "path": "/sw/promote/portrait?sourceDb=lx&promoteId=1&period=total", - "http": 200, - "code": 7, - "msg": "渠道数据尚未接入", - "passed": true - }, - { - "path": "/sw/promote/portrait?sourceDb=lx&promoteId=0", - "http": 200, - "code": 7, - "msg": "渠道查询参数不正确", - "passed": true - }, - { - "path": "/sw/promote/portrait?sourceDb=invalid&promoteId=1", - "http": 200, - "code": 7, - "msg": "所属平台不正确", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/channel-portrait-page-2026-11-03.json b/docs/sw6-to-kra/channel-portrait-page-2026-11-03.json deleted file mode 100644 index f9ea63e..0000000 --- a/docs/sw6-to-kra/channel-portrait-page-2026-11-03.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "method": "真实 Chrome,画像、游戏与预警数据为受控响应,不改业务数据", - "passed": true, - "activeMetric": true, - "totalNoComparison": true, - "canonicalLast7": true, - "fourPayTiers": true, - "gameDrawer": true, - "platformAndChannelAlertFilter": true, - "crossPlatformSameID": true, - "unavailableIsNotZero": true, - "pageErrors": [] -} diff --git a/docs/sw6-to-kra/channel-portrait-source-notes.md b/docs/sw6-to-kra/channel-portrait-source-notes.md index e27c333..690bb89 100644 --- a/docs/sw6-to-kra/channel-portrait-source-notes.md +++ b/docs/sw6-to-kra/channel-portrait-source-notes.md @@ -1,6 +1,5 @@ # 渠道画像迁移核对 -已实现 `GET /sw/promote/portrait` 及渠道列表画像入口,来源为旧 `PromotePortraitServiceImpl`、`ChannelProfileDrawer.vue`、日汇总/身份键和支付 Mapper。完整业务库数据仍待最终复制验收。 ## 数据范围与基本资料 @@ -29,4 +28,4 @@ - 旧关联预警误将渠道 ID 传入 businessId;新页面使用 targetPromoteId 与 platformCodes,并继续由预警接口执行查看者数据范围隔离。 - 小R/免费区间文案按实际后端 >0 / ≤0 调整,避免把小于一元的付费玩家误解为免费。 -验证覆盖领域规则、真实仓储查询、隔离数据库 TCP HTTP、浏览器完整交互及当前 sw7 只读接口。详见 `channel-portrait-http-2026-11-03.json` 与 `channel-portrait-page-2026-11-03.json`;全量 Go 测试与前端构建通过。 +当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。 diff --git a/docs/sw6-to-kra/channel-source-notes.md b/docs/sw6-to-kra/channel-source-notes.md index 2cd3c00..f7ea6e7 100644 --- a/docs/sw6-to-kra/channel-source-notes.md +++ b/docs/sw6-to-kra/channel-source-notes.md @@ -1,6 +1,5 @@ # 渠道列表与新增渠道:源码核对记录 -以下是实现核对契约,源码基线与 `implementation.md` 一致。列表、新增、绑定选项、游戏分布及渠道画像已接入并验证;报表构建任务及完整数据验收仍未完成。 ## 页面 @@ -12,7 +11,7 @@ - 状态筛选及列使用 `lockStatus`:0 正常、-1 已锁定、-2 解除合作,与渠道审批字段 `status` 不同。 - “添加渠道”只按按钮权限判断,旧代码没有额外要求三级商务。游戏商务下拉随平台变化加载,选项取平台商务 ID 并去重。 - 新增字段:平台、游戏商务、推广类型、渠道账号、密码。账号为 `[\u4e00-\u9fa5a-zA-Z0-9]{4,30}`,提交前 trim;密码原值保留,按 Java UTF-16 长度 6–15 校验。 -- 推广游戏与画像均是右侧抽屉,必须同时传 `sourceDb` 与渠道 ID。游戏抽屉调用 `/system/promote/game-distribution`,累计口径,列为游戏、玩家数量、充值金额。画像尚待迁移,不能宣称渠道整个菜单完成。 +- 推广游戏与画像均是右侧抽屉,必须同时传 `sourceDb` 与渠道 ID。游戏抽屉调用 `/system/promote/game-distribution`,累计口径,列为游戏、玩家数量、充值金额。画像已迁移。 ## 列表权限和查询 @@ -35,7 +34,6 @@ - `totalPayAmount`:`tab_spend.pay_status=1` 累计 `pay_amount`,不按身份证去重,也没有日期上限。 - 关闭包含下级、层级为空或 >=3:只统计当前渠道。二级:本级与 `parent_id=当前ID AND promote_level=3`。一级:本级与同平台 `top_promote_id=当前ID OR parent_id=当前ID`。 - 注意一级旧加载函数还会加载第二跳子渠道,但最终 `PromotePortraitScopeHelper` 再按 top/parent 条件过滤;不能仅看加载函数就扩大最终统计集合。 -- 当前 sw7 缺业务表,列表本体及各指标要明确表示未接入,不能伪造真实零。 ## 源平台创建事务 @@ -53,3 +51,5 @@ 游戏分布依赖 `PromotePortraitServiceImpl.buildGameDistribution`、`SwReportDayQueryServiceImpl` 和日汇总/身份键 Mapper。当前实现只列根渠道已通过的游戏,用同渠道统计树读取日汇总:人数对 metric=1 的 identity_key 去重,dedup_mode 随偏好;金额固定 dedup_mode=0。累计起点从当前人数模式的最早日汇总取得,缺失则使用上海当天,即使模式 0 有更早数据也不改写该起点。截止上海当天,排除未来日期。先将金额保留两位再按金额降序、游戏 ID 升序排列。 完整渠道画像已接入,规则及验证见 `channel-portrait-source-notes.md`。报表汇总任务、预警生成任务和最终实库验收仍属于后续范围。 + +当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。 diff --git a/docs/sw6-to-kra/direct-channels-http-2026-11-03.json b/docs/sw6-to-kra/direct-channels-http-2026-11-03.json deleted file mode 100644 index b09d5aa..0000000 --- a/docs/sw6-to-kra/direct-channels-http-2026-11-03.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "scope": "既有 sw7 系统库;渠道表尚未接入。完整数据与跨平台范围通过隔离数据库 HTTP/仓储测试。", - "checks": [ - { - "path": "/sw/business/10001/promotes", - "code": 0, - "available": true, - "total": 0, - "rows": 0, - "elapsed_ms": 14 - }, - { - "path": "/sw/business/10007/promotes", - "code": 0, - "available": false, - "total": 0, - "rows": 0, - "elapsed_ms": 15 - }, - { - "path": "/sw/business/10007/promotes?sourceDb=hw&platformBusierId=14", - "code": 0, - "available": false, - "total": 0, - "rows": 0, - "elapsed_ms": 33 - } - ] -} \ No newline at end of file diff --git a/docs/sw6-to-kra/http-business-bindings-2026-11-03.json b/docs/sw6-to-kra/http-business-bindings-2026-11-03.json deleted file mode 100644 index 2b1e5df..0000000 --- a/docs/sw6-to-kra/http-business-bindings-2026-11-03.json +++ /dev/null @@ -1,133 +0,0 @@ -{ - "base_url": "http://127.0.0.1:18000", - "checks": [ - { - "method": "GET", - "path": "/sw/business/list?pageNum=1&pageSize=500", - "http": 200, - "code": 0, - "msg": "成功", - "elapsed_ms": 184, - "passed": true, - "total": 45, - "rows": 45 - }, - { - "method": "GET", - "path": "/sw/business/list?pageNum=2&pageSize=10", - "http": 200, - "code": 0, - "msg": "成功", - "elapsed_ms": 43, - "passed": true, - "total": 45, - "rows": 10 - }, - { - "method": "GET", - "path": "/sw/business/10001", - "http": 200, - "code": 0, - "msg": "成功", - "elapsed_ms": 19, - "passed": true - }, - { - "method": "GET", - "path": "/sw/business/list?userName=admin", - "http": 200, - "code": 0, - "msg": "成功", - "elapsed_ms": 22, - "passed": true, - "total": 1, - "rows": 1 - }, - { - "method": "GET", - "path": "/sw/business/10007/bindPlatform", - "http": 200, - "code": 0, - "msg": "成功", - "elapsed_ms": 25, - "passed": true - }, - { - "method": "GET", - "path": "/sw/user/preference", - "http": 200, - "code": 0, - "msg": "成功", - "elapsed_ms": 94, - "passed": true - }, - { - "method": "GET", - "path": "/sw/business/bindPlatform?pageNum=1&pageSize=10", - "http": 200, - "code": 0, - "msg": "成功", - "elapsed_ms": 22, - "passed": true, - "total": 58, - "rows": 10 - }, - { - "method": "GET", - "path": "/sw/business/bindPlatform?sourceDb=lx,hw&status=0&pageSize=2", - "http": 200, - "code": 0, - "msg": "成功", - "elapsed_ms": 23, - "passed": true, - "total": 37, - "rows": 2 - }, - { - "method": "PUT", - "path": "/sw/user/preference", - "http": 200, - "code": 7, - "msg": "偏好参数不能为空", - "elapsed_ms": 424, - "passed": true - }, - { - "method": "POST", - "path": "/sw/business", - "http": 200, - "code": 7, - "msg": "商务账号参数不正确", - "elapsed_ms": 471, - "passed": true - }, - { - "method": "PUT", - "path": "/sw/business", - "http": 200, - "code": 7, - "msg": "商务账号参数不正确", - "elapsed_ms": 335, - "passed": true - }, - { - "method": "POST", - "path": "/sw/business/bindPlatform", - "http": 200, - "code": 7, - "msg": "商务账号参数不正确", - "elapsed_ms": 521, - "passed": true - }, - { - "method": "DELETE", - "path": "/sw/business/bindPlatform", - "http": 200, - "code": 7, - "msg": "商务账号参数不正确", - "elapsed_ms": 272, - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/http-verification-2026-11-03.json b/docs/sw6-to-kra/http-verification-2026-11-03.json deleted file mode 100644 index 24c44b5..0000000 --- a/docs/sw6-to-kra/http-verification-2026-11-03.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "date": "2026-11-03", - "timezone": "Asia/Shanghai", - "base_url": "http://127.0.0.1:18000", - "database_scope": "沿用已配置的 sw7 系统迁移测试库;未复制或迁移旧正式数据库", - "authentication": "通过本地服务验证码及账号密码正常登录后,携带 JWT 请求;凭据和令牌不记录在报告中", - "observed_failure": "原 8000 实例日志:Error 1146,sw7.tab_promote 不存在导致商务列表返回商务数据访问失败", - "fix": "基础商务资料和已通过绑定数继续返回;有绑定但渠道表未接入时 promoteCount 为 null;完整业务库仍按原口径计算真实渠道数", - "checks": [ - { - "path": "/health", - "http": 200, - "elapsed_ms": 64 - }, - { - "path": "/sw/business/list?pageNum=1&pageSize=10", - "http": 200, - "elapsed_ms": 36, - "code": 0, - "msg": "成功", - "total": 45, - "rows": 10, - "unavailable_promote_counts": 1 - }, - { - "path": "/sw/business/list?pageNum=2&pageSize=10", - "http": 200, - "elapsed_ms": 46, - "code": 0, - "msg": "成功", - "total": 45, - "rows": 10, - "unavailable_promote_counts": 5 - }, - { - "path": "/sw/business/list?userName=admin&pageNum=1&pageSize=10", - "http": 200, - "elapsed_ms": 32, - "code": 0, - "msg": "成功", - "total": 1, - "rows": 1, - "unavailable_promote_counts": 0 - }, - { - "path": "/sw/business/10001", - "http": 200, - "elapsed_ms": 33, - "code": 0, - "msg": "成功" - }, - { - "path": "/sw/user/preference", - "http": 200, - "elapsed_ms": 103, - "code": 0, - "msg": "成功" - } - ], - "limits": [ - "验证实例与 GoLand 的 8000 进程独立;GoLand 必须重新运行才能使用新代码", - "本次未验证完整渠道数据,完整旧库复制和最终一致性核对仍待迁移结束后确认" - ] -} diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md deleted file mode 100644 index ddeedb9..0000000 --- a/docs/sw6-to-kra/implementation.md +++ /dev/null @@ -1,107 +0,0 @@ -# 商务功能迁移执行记录 - -## 基线与方法 - -- 旧后端:`D:/Users/Yvan/projects/yzyz/sw`,`main`,`f9a5e3a90`。 -- 框架基线:`d31082f4a7816d4c83544b8f54e6f42a3b2132b1`。 -- 旧前端:`D:/Users/Yvan/projects/yzyz/sw/web`,`dev/dev1.0`,`ba284011c87fd3d5730e0e3dceffb14a7389730c`。 -- 新实现分支:`codex/sw-business-migration`。 - -按基线至当前版本的最终差异提取功能,以当前 Java 实现、实际调用的前端接口和 SQL 为核对依据。提交记录用于解释修复和撤销,不逐提交搬运。旧前端中未接入的 mock、废弃接口不能作为已实现业务迁移。 - -沿用现有迁移 SQL 的 `/sw/` 路由与 `view/sw/` 菜单组件路径。认证、响应信封、权限、表格及图表采用 Kra;业务字段、统计口径、状态流转、可见范围保留旧实现。登录页、系统名称和品牌资源来自旧商务。新增代码注释与提交信息使用中文。 - -用户补充:允许直接优化旧接口、SQL 和逻辑。优化时记录行为差异并验证业务结果;各批实现必须实际请求 HTTP 接口,不能只以单元测试和构建作为完成依据。 - -2026-11-04 用户调整验证要求:后续不需要检查页面交互,只验证接口并继续迁移,包括工作台/仪表盘。后续保留旧页面源码核对、接口实测、数据断言和构建检查,不再执行 Chrome 交互验收。 - -用户再次指出页面偏差后,明确按旧前端实际页面核对入口,不能从后端接口推断 UI 功能。详见 `page-parity.md`。商务列表已移除错误增加的编辑入口,恢复“绑定列表”名称及仅三级显示规则,并对齐按审核状态显示行操作。 - -用户已有的 `configs/config.yaml` 修改与迁移 SQL 保留。未经最后阶段的数据库确认,不连接、复制或修改用户的旧数据库。 - -## 依赖顺序与完成标准 - -| 阶段 | 范围 | 状态 | -| --- | --- | --- | -| 1 | 商务用户字段、层级与角色、账号资料和审核、偏好、登录及找回密码 | 进行中 | -| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 | -| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 | -| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 | -| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、明细及导出、各报表/排行榜、工作台和两类汇总构建已接入,预警检测任务与最终实库验收待完成 | -| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 | -| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 | - -每项完成需覆盖 DTO → service → biz → data、路由目录、权限、菜单页面和有意义的行为测试。只有真实实现并通过验证的项才标为完成。整项目标在实库验证之前保持未完成。 - -## 已核实的业务约束 - -- 超管创建一级商务,一级创建二级,二级创建三级;三级不能创建下级。层级与祖先链由服务器生成。 -- 新增子商务最终版本默认正常,不采用残留注释中的“默认待审核”。 -- 一/二级商务列表只显示下级树,不显示自己;三级列表为空。报表使用独立渠道可见范围,不能套用 Kra 的部门过滤替代。 -- `includeSubPromote` 与 `idcardDedup` 无记录或数据库值非零时均为开启,新增账号同时初始化开启。 -- SDK 商务绑定数量仅计算已通过记录;渠道计数依照当前直属渠道算法,跨平台 ID 不能混用。 -- 用户审核状态有正常、停用、驳回、待审核四种,须保留 `business_status`,不能压成 `enable` 后丢失含义。 -- 金额查询固定不做身份证去重;人数查询遵守全局偏好。 -- SQL 已建立用户、角色、文件等 ID 映射;平台实体及业务主键保留。迁移验收应按映射比较,不能直接比较所有原始 ID。 - -## 验证记录 - -- 已落地商务用户七个扩展字段的存取及增量补列;登录和既有会话同时检查商务审核状态。 -- 已落地全局偏好 GET/PUT、当前用户隔离、缺省开启、显式关闭及幂等写入;Kra 顶栏增加统计口径入口。 -- 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。 -- 商务、SDK 商务和渠道画像已接入;其余报表及任务继续迁移。后端导出等接口另按源码核对,不能据此给旧页面增加不存在的按钮;最终仍需完整数据源验收。 -- 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面恢复仅三级可见的“绑定列表”页签及只读详情抽屉。绑定流程保持三级限制、平台验密、OA 待审核及驳回重提规则;新增键锁表 `sys_business_binding_locks`,串行处理同一平台商务的并发绑定。 -- 已恢复旧登录页的图片、文案、布局及记住账号功能,系统名称统一为“遨游龙翔游戏平台”。找回密码四个匿名接口位于 `/sw/auth/forgot-password/`,保留账号/手机号检查、60 秒发码限制、两分钟验证码、十分钟凭证、6–15 位密码及失败五次吊销等规则。 -- 找回密码采用同库事务保存验证码摘要和一次性凭证,并原子执行改密/消费凭证;新增 `sys_business_password_codes`、`sys_business_password_resets`。更新密码版本使旧 JWT 失效。短信复用 Kra 集成配置,`SW_PASSWORD_RESET_SMS_PROVIDER` 默认 `tencent`,也支持 `aliyun`;模板和通道凭据在对应集成配置中维护。 -- 旗下渠道接口 `/sw/business/:userId/promotes` 和两处抽屉入口已接入:先校验商务可见范围,再按已通过绑定的 `(source_db, platform_busier_id)` 查询直属一级渠道;按渠道 ID、平台稳定分页。实际 sw7 请求与隔离完整数据 HTTP/仓储测试均通过,浏览器确认绑定入口同时携带平台和平台商务 ID,未通过绑定禁用该入口。 -- 平台数据源通过 Kra 的 `data.database_list` 复用连接,别名为 `lx` / `hw` / `yq`。旧密码算法支持三种格式,兼容盐通过环境变量 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE` 提供,取值应与旧 `XiguPasswordUtils` 对应常量一致;`SW_SDK_TABLE_PREFIX` 缺省为 `sys_`。真实连接凭据和盐不写进新仓库。完整平台库验密仍需部署配置后联调。 -- 已生成 `source-inventory.json`:现有 SQL 的 63 个接口契约、279 个变更源文件及额外找回密码控制器。此清单是范围索引,不代表已完成实现。 -- 已执行 `make generate`、相关 Go 测试、变更前端 ESLint 和生产构建。构建存在原 `arcdash` 依赖的 BigInt 浏览器目标警告。 -- 第一批提交 `ce4b54a` 后 `go test ./...` 全量通过;第二批账号功能的 biz/data/service/modules/server/cmd 检查及前端生产构建通过。 -- 用户反馈实际商务列表失败后,定位为 sw7 缺少 `tab_promote`。已解耦基础列表与渠道统计:有绑定且渠道表缺失时返回 `promoteCount: null`,界面显示“未接入”,无绑定账号仍为真实零。新增通过真实 TCP 请求的 HTTP 回归测试,覆盖系统数据库及完整渠道表两种场景。 -- 已通过正常验证码登录取得测试会话,并在修复后的独立 18000 实例连接同一 sw7 执行 HTTP 请求。列表总数 45,分页、账号筛选、资料和偏好查询全部成功。详见 `http-verification-2026-11-03.json`。8000 为用户 GoLand 旧二进制,需重新运行才加载源码修复。 -- 绑定批次通过 13 项真实 HTTP 检查:商务 45 条、绑定 58 条、平台与审核筛选、详情、偏好查询和写请求校验,结果见 `http-business-bindings-2026-11-03.json`;`scripts/sw_http_smoke.py` 可复用且不记录令牌。真实库不制造测试商务或发起实际平台绑定。新增、修改、偏好关闭、提交绑定及解绑的正向流程已用独立 SQLite 数据库通过实际 TCP HTTP 测试;平台验密外部边界使用测试替身,密码算法另用固定向量验证。 -- 登录/找回密码批次 `go test ./...` 全量通过,前端 ESLint 和生产构建通过。Chrome 已检查桌面/移动登录页及三步找回流程(短信接口受控替身);完整找回密码链路已通过匿名 TCP HTTP + SQLite 测试,覆盖错误验证码后重试、原子消费、改密版本更新和凭证重放拒绝。实际运行实例四个匿名接口校验与既有 13 项商务 HTTP 回归全部通过,未发送真实短信或修改真实用户密码。 -- 预警规则七个接口及 Kra 页面已接入。一级商务管理自己的规则,二/三级读取所属一级的规则,超管可管理全部;启停、软删除、监控指标与原校验一致。分类选项来自历史预警记录,删除规则不会丢失历史分类。编辑不覆盖独立的启停/删除状态,省略的可选指标字段沿用旧值。 -- 新增预警表缺表建表迁移,保留既有表;阈值列支持原页面 9999% 上限。当前 sw7 启动前后均为 `decimal(7,2)`,没有改动既有规则数据。触发检测任务和记录去重写入尚未迁移,不作为规则页面完成范围。 -- 规则批次通过 biz/data/service/cmd/server/modules 检查、前端 ESLint 与生产构建;隔离 SQLite + TCP HTTP 测试覆盖新增、编辑、启停、软删除、历史分类和跨商务权限。`alert-rules-http-2026-11-03.json` 记录真实 sw7 查询与无效写请求,`alert-rules-page-2026-11-03.json` 记录四级角色入口与指标表单联动;可复用脚本为 `scripts/sw_alert_rules_smoke.cjs`。 -- 预警记录八个接口与列表、处理抽屉已接入。列表、详情和写入同时按所属一级商务及本人/下级的已通过绑定隔离,渠道键包含平台,保留停用一级渠道的历史预警。`businessId` 筛选取指定商务渠道范围与查看范围的交集,不误用预警归属一级字段。状态汇总仅随范围、平台和指定渠道变化,不受名称、分类、指标及列表状态筛选影响。 -- 完整工作流为未处理→处理中→待审核→已处理,或待审核→审核驳回→处理中后重新提交。审核只允许一级商务,超管等级为零时也不能审核。状态条件在带权限条件的行锁事务中检查,状态日志及可选备注共同提交;独立备注不改状态,保留原接口允许已关闭记录追加备注的行为(旧页面只读态仍不展示备注编辑区)。 -- 备注图片复用 Kra 上传接口,支持选取、拖拽、粘贴、暂存预览和移除;添加备注时才上传。修复旧前后端矛盾:先要求非空备注再上传,部分上传或备注失败重试时复用已上传结果。历史数字附件批量查询新文件表,URL 附件保持原值,异常 JSON 视为空附件;记录/规则/日志大 ID 使用字符串响应,避免浏览器精度丢失。 -- 补充 `202611030007_business_alert_attachments`,针对原 SQL 遗留的日志数字附件做文件 ID 映射。只转换与 `bak_sw2kra_20261103_sys_alert_operation_log` 备份相符的旧日志,新日志、URL 或已变化引用不改动;缺失旧文件不解释为同号新文件,原引用保留在备份。详见 `alert-attachment-migration.md`。 -- 预警记录批次全量 `go test ./...`、前端 ESLint 与生产构建通过。隔离 SQLite + 实际 TCP HTTP 覆盖全部处理动作、驳回重提、跨商务/平台拒绝、大 ID、附件读取与缺表场景;仓储测试覆盖日志失败回滚与重复请求。MySQL 行锁的真实并发验证留待最终隔离旧库验收。`alert-records-http-2026-11-03.json` 记录当前 sw7 的真实会话请求;当前预警零条,未创建真实预警或上传文件。原商务 45 条、绑定 58 条的 HTTP 回归继续通过。 -- `alert-records-page-2026-11-03.json` 记录真实 Chrome 下四个等级、五种状态的入口检查,以及完整抽屉流程、附件延迟上传、状态卡片筛选、缺渠道数据提示。浏览器业务数据、处理写入和上传使用受控替身;正向服务端行为另由前述 TCP HTTP 测试覆盖。 -- 渠道激活 `/sw/promoteActivePool/bindList` 和 `/sw/promoteActivePool/bindSpendDetail` 及旧同名页面已接入。OA 连接复用 `data.database_list` 中别名 `oa` 的共享客户端;只读 `sys_promote_active_pool_bind`,不创建或修改 OA 表。普通商务按本人已通过绑定的账号集合匹配 OA 认领,不包含下级,超管不限;保持旧实现按账号匹配的规则。 -- 激活流水从主库 `tab_spend` 按游戏汇总,成功支付、同平台、绑定开始(含)到达标/解绑截止(含);渠道树包含本级、直属子级及 `top_promote_id` 匹配项,不依赖全局下级/身份证偏好。达标时间优先于解绑时间,进行中没有结束上限。使用 EXISTS 避免重复渠道行导致金额翻倍,金额及大 ID 以字符串返回,计算和页面格式化避免浮点精度丢失。 -- 激活批次通过全量 `go test ./...`、ESLint 和生产构建,以及主库/OA 两个隔离数据库的实际 TCP HTTP 测试。`activation-http-2026-11-03.json` 记录当前真实会话请求(OA 未接入,明确 available=false);`activation-page-2026-11-03.json` 核对旧页面列、五种状态入口、筛选重置、达标截止与大金额。待审核可以打开流水入口但显示原接口“审核通过后可查看”,已驳回不显示入口;明细读取失败不展示伪造零流水。 -- 已对框架基线至 main 的 Git 差异核实:`SysMessageController` 与 `SysMessageServiceImpl` 未变更,旧导航栏消息入口也被注释,因此不新增商务消息 UI。SW 主库写入待审绑定后由外部 OA 审核,并没有需要照搬的 SW 内部 OA 同步任务;最终联调仍须核对 OA 连接目标及商务用户 ID 映射,不能把隔离替身测试当成外部审核验收。 -- 渠道列表、可用绑定、新增渠道、推广游戏分布四个接口及对应 Kra 页面已接入。列表读取本人及全部下级的正常绑定,超管默认不限;指定商务用户只取该用户本人的绑定。商务账号关键词同时匹配平台账号和 SW 登录名,保留 Java 文字子串语义,不把 `%` 变成通配符。渠道状态筛选使用锁定状态字段。 -- 渠道列表游戏数仅统计当前渠道已通过申请的去重游戏;玩家数和成功充值额按原渠道树及全局偏好汇总。身份证去重跨整棵统计树进行,空证件不计入,末尾 X/x 归一。指标按平台/渠道组合批量查询;缺失指标单独返回 null,已有指标继续显示。真实 TCP HTTP 回归发现并修复 `idcard` 列映射错误,增加存储字段与实际返回人数断言。 -- 游戏分布只列当前渠道已通过的推广游戏,读取 `tab_sw_report_day` / `tab_sw_report_day_key`,人数按当前去重模式去重,金额固定模式 0。累计起点保留旧实现从当前人数口径的最早日汇总取得、缺省当天的规则,截止上海当天;不能与列表原始注册/充值口径混用。金额先保留两位再排序,同金额按游戏 ID。 -- 新增渠道由 biz 决定一级、待审核 `-3`、默认注册类型和七天结算周期;data 在对应源平台库同事务写渠道、结算记录、商务渠道关系。密码复用旧 cmf 格式和 `SW_SDK_AUTH_CODE`。MySQL 创建使用固定连接持有账号命名锁直到事务提交,商务关系在行锁内追加;命名锁的真实并发验证留待最终隔离平台库联调。没有创建真实平台测试渠道,也没有伪造主库同步副本。 -- 渠道批次通过全量 Go 检查、前端 ESLint/生产构建、独立主库和源平台 SQLite 的 TCP HTTP 验证。`channel-http-2026-11-03.json` 记录实际 sw7 会话请求,当前主库渠道数据未接入,lx/hw/yq 正常绑定选项分别为 17/20/20 条。`channel-page-2026-11-03.json` 核对一级商务新增入口、平台切换清空绑定、跨平台同号、零游戏数入口、缺失指标提示及新增提交参数。画像入口继续随画像用例迁移,当前不将整个渠道菜单标为完成。 -- 核对剩余用户接口的基线差异及 Kra 原生能力,记录于 `system-api-audit.md`;保留历史别名未注册、Excel 协议未兼容等差异,不把能力对应表当成接口兼容完成证明。商务页面仍不增加原页面没有的编辑/删除/导入/导出入口。 -- 修复原生用户启停只更新 enable、商务状态不同步的问题:由 biz 决定状态联动,在 data 行锁事务中一起写入;未启用时保留待审/驳回,个人资料请求不能自行解除管理员限制。当前 45 个真实用户只读检查未发现已有冲突,没有批量更新真实账号;正常启停、审核状态保留、失败回滚和越权通过隔离 HTTP/仓储/领域测试。全量 Go 测试通过,实际服务检查记录为 `user-status-http-2026-11-03.json`。 -- 新增 `/sw/business/:userId/portrait` 与 `/sw/business/bindPlatform/portrait`。补齐商务列表画像、正常绑定画像和只读 SDK 详情平台入口,共用 Kra 风格抽屉,包含基本资料、五个时间周期、四项指标、可交互趋势、注册/充值 TOP 5 及独立分页的关联预警。SVG 图表使用 Kra 主题变量,无新增图表依赖,支持键盘数据点和负金额。 -- 普通商务基本资料来自新系统用户,SDK 资料来自对应平台库,父级分别按 SW parent_id 和平台 commerce1/commerce2 解析。SDK 状态 0 仍是待审核,不能当作 SW 正常;关联预警/旗下渠道使用打开时的 SW 用户 ID,平台商务 ID 只作为绑定筛选。保留旧 SDK 画像关联预警按商务及平台的实际范围。 -- 画像日指标与日趋势读取汇总/身份键表,模式 0 人数带平台前缀、模式 1 跨平台身份证去重,金额固定模式 0。付费率分母为活跃人数,人均充值分母为注册人数;上期活跃为零时付费率对照和百分点差为 null。今日/昨日趋势读原始主账号与成功支付、补齐 24 小时;MySQL 保留原 HOUR(FROM_UNIXTIME) 会话时区语义。注册 TOP 5 在渠道树内 COUNT DISTINCT;人数上限在排序截取前应用。 -- 画像批次通过领域/存储/双库实际 TCP HTTP 测试、全量 Go 检查和前端 ESLint/构建;验证月末/闰月对比、四舍五入与负零、去重切换、金额稳定、原始小时口径、父级状态、跨商务拒绝及缺表。`portrait-http-2026-11-03.json` 为真实会话检查:无绑定返回零指标及完整趋势,有绑定缺表保留基本资料并明确未知。`portrait-page-2026-11-03.json` 核对三个入口、周期切换、交互图表、两个 ID 范畴和缺数据提示。 -- 当前没有执行最终旧数据库复制与迁移验收。 -- 渠道画像 `/sw/promote/portrait` 与列表入口已接入,包含基本资料、累计/当期指标、趋势、四档付费分层、关联预警及推广游戏抽屉。复用画像统计并增加经过校验的单渠道树范围、无对照期分支;渠道第四项保持活跃玩家。分层成员取累计注册身份,金额按历史成功支付及原始付款账号/身份证口径,保持无日期过滤,不能误复用指标周期过滤。 -- 修复旧渠道页面两处参数错配:近7/30天改发 last7/last30,关联预警改发 targetPromoteId 而不是 businessId。领域、存储和隔离 TCP HTTP 覆盖分层边界、子账号与跨平台、模式切换、累计无环比、原始小时趋势和缺数据;浏览器验证入口、正确参数、四档分层及游戏抽屉。全量 Go、ESLint/构建通过;当前真实会话请求记录于 `channel-portrait-http-2026-11-03.json`,仍未复制实际旧业务库。 -- 注册/充值明细、两个 XLSX 导出及游戏下拉共五个接口已接入。注册保留 puid=0、同平台玩家全部历史成功充值;充值合计只算成功单,不随列表状态改变。渠道精确匹配、商务文字筛选、官方/未知渠道和跨平台同号均按旧口径处理。 -- 修复注册游戏关联跨平台、超过200个渠道后放宽筛选的问题,批量补充当前页资料。导出超过10000条提前拒绝,保留数字/文本类型及原字段顺序。页面使用 Kra 表格、筛选、日期控件与下载,保留近30天默认显示和大额行高亮;注册导出权限与接口一致。 -- 明细批次通过全量 Go、前端 ESLint/构建、隔离 TCP HTTP、工作簿内容校验和浏览器筛选/下载。真实 sw7 请求及空查询工作簿验证记录见 `ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`,没有导出或修改真实业务行;详细边界见 `ledger-source-notes.md`。 -- 渠道/游戏统计两个接口与 Kra 页面已接入,保留游戏筛选、渠道账号/类型筛选、平台、日期、排序、分页及全量汇总。金额固定模式 0,人数按当前身份证偏好;游戏汇总累加各游戏,渠道超管汇总全局去重、普通商务累加可见渠道,不互相替代。渠道模糊匹配扩到可见同树渠道,并保持类型交集;超管全平台范围包含官方和历史孤立渠道。 -- 统计批次通过全量 Go、隔离数据库真实 TCP HTTP、领域/仓储测试、ESLint/生产构建;真实 sw7 请求和 Chrome 检查见 `analytics-http-2026-11-04.json`、`analytics-page-2026-11-04.json`。分页不改变汇总,缺表明确未接入;非法日期/倒置周期明确报错。没有添加旧统计页面不存在的导出按钮。详细口径与优化见 `analytics-source-notes.md`。 -- 区服列表与区服选项接口、Kra 区服分析页面已接入。统计按平台+游戏+区服编号分组,人数区服内去重、金额固定模式 0;页尾汇总全部筛选行。游戏必选,选项按当前游戏名,报表按历史游戏名,区服编号保留前导零。 -- 区服批次通过全量 Go、隔离数据库真实 TCP HTTP、前端 ESLint/构建和真实 sw7 只读接口请求,结果见 `server-analysis-http-2026-11-04.json`。覆盖跨平台同号、全量汇总、零行处理、偏好切换、资料补全及缺表;按用户新要求未执行页面交互检查。最终旧库验收仍未开始。 -- 工作台四个接口及 Kra 仪表盘业务区域已接入:六项指标、注册/充值 TOP10、未登录 TOP10、最低充值 TOP10 和已有预警的最新五条轮播。保留 Kra 欢迎区域、快捷入口与公告,平台和周期按旧页面设置;请求失败清空旧结果并提示,不能显示其他平台的缓存值。 -- 工作台保留本月对上月同日(截断月末)、模式 0 跨平台 ID 隔离、渠道树注册去重、金额固定模式 0、活跃渠道按自身注册/正充值计数、最低榜含零一级渠道,以及未登录回退创建时间和 99999 哨兵值。详见 `workbench-source-notes.md`。 -- 工作台批次全量 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/ledger-http-2026-11-04.json b/docs/sw6-to-kra/ledger-http-2026-11-04.json deleted file mode 100644 index cdeb04a..0000000 --- a/docs/sw6-to-kra/ledger-http-2026-11-04.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "scope": "真实 sw7 会话,只读查询;导出使用确定为空的商务筛选,仅校验表头工作簿,不导出真实业务行。", - "checks": [ - { - "method": "GET", - "path": "/sw/register/list?sourceDb=lx,hw,yq", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": false - }, - { - "method": "GET", - "path": "/sw/register/list?busierAccount=__kra_empty_export_probe_20261104__", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": true - }, - { - "method": "POST", - "path": "/sw/register/export", - "http": 200, - "xlsx": true, - "worksheet_rows": 1, - "passed": true - }, - { - "method": "POST", - "path": "/sw/register/export", - "http": 200, - "code": 7, - "msg": "明细数据尚未接入,无法导出", - "passed": true - }, - { - "method": "GET", - "path": "/sw/spend/list?sourceDb=lx,hw,yq", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": false - }, - { - "method": "GET", - "path": "/sw/spend/list?busierAccount=__kra_empty_export_probe_20261104__", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "total": 0, - "available": true - }, - { - "method": "POST", - "path": "/sw/spend/export", - "http": 200, - "xlsx": true, - "worksheet_rows": 1, - "passed": true - }, - { - "method": "POST", - "path": "/sw/spend/export", - "http": 200, - "code": 7, - "msg": "明细数据尚未接入,无法导出", - "passed": true - }, - { - "method": "GET", - "path": "/sw/register/list?beginRegisterTime=bad&endRegisterTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "明细查询参数不正确", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/ledger-page-2026-11-04.json b/docs/sw6-to-kra/ledger-page-2026-11-04.json deleted file mode 100644 index 356a417..0000000 --- a/docs/sw6-to-kra/ledger-page-2026-11-04.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "method": "真实 Chrome,列表数据受控;导出使用真实服务的空筛选生成 XLSX,不读取业务数据。", - "reports": [ - { - "kind": "register", - "passed": true, - "defaultThirtyDays": true, - "gameNameSelection": true, - "exactAmountOrSuccessSummary": true, - "xlsxDownload": true, - "exportKeepsFilters": true, - "failedQueryClearsOldData": true, - "pageErrors": [] - }, - { - "kind": "spend", - "passed": true, - "defaultThirtyDays": true, - "gameNameSelection": true, - "exactAmountOrSuccessSummary": true, - "xlsxDownload": true, - "exportKeepsFilters": true, - "failedQueryClearsOldData": true, - "pageErrors": [] - } - ] -} diff --git a/docs/sw6-to-kra/ledger-source-notes.md b/docs/sw6-to-kra/ledger-source-notes.md index 749f127..a95896a 100644 --- a/docs/sw6-to-kra/ledger-source-notes.md +++ b/docs/sw6-to-kra/ledger-source-notes.md @@ -31,6 +31,6 @@ - 保留原 VO 导出字段顺序,包括没有中文 ExcelProperty 的辅助字段;时间保留 Unix 秒,状态/方式保留原代码。 - 数值直接写入数值 XML,不先转 float64;账号、订单号等强制文本,保留前导零并避免当作公式。 - 页面按旧模板提供筛选、游戏远程下拉、日期快捷项、排序、分页、导出;充值金额≥5000的行高亮。查询失败会清除旧行和旧合计。 -- 游戏下拉的默认/批量 limit 行为沿用旧接口;区服分析的 servers/list 尚待后续实现,不能因 games 可用而标记整个区服页面完成。 +- 游戏下拉的默认/批量 limit 行为沿用旧接口;区服分析的 servers/list 现已完成迁移。 -验证:领域/存储测试,实际 TCP HTTP 与 XLSX 内容校验,真实 sw7 的只读查询及确定空查询导出,Chrome 筛选/下载回归。见 `ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`。未修改真实业务数据,最终旧库复制和数据比对尚未开始。 +当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。 diff --git a/docs/sw6-to-kra/login-page-verification-2026-11-03.json b/docs/sw6-to-kra/login-page-verification-2026-11-03.json deleted file mode 100644 index f767a3e..0000000 --- a/docs/sw6-to-kra/login-page-verification-2026-11-03.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "真实 Chrome 渲染;找回密码接口使用受控响应,不发送真实短信", - "title": "遨游龙翔游戏平台", - "actions": [ - "check", - "send-code", - "verify-code", - "reset" - ], - "pageErrors": [], - "passed": true -} \ No newline at end of file diff --git a/docs/sw6-to-kra/menu-icon-repair.sql b/docs/sw6-to-kra/menu-icon-repair.sql deleted file mode 100644 index 2c0b177..0000000 --- a/docs/sw6-to-kra/menu-icon-repair.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 已完成迁移的 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 deleted file mode 100644 index d48f739..0000000 --- a/docs/sw6-to-kra/menu-icons-components-2026-11-04.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "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 deleted file mode 100644 index d17529d..0000000 --- a/docs/sw6-to-kra/menu-icons-http-2026-11-04.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "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 deleted file mode 100644 index 0e05812..0000000 --- a/docs/sw6-to-kra/menu-icons-live-http-2026-11-04.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "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 deleted file mode 100644 index 7dfe7b0..0000000 --- a/docs/sw6-to-kra/menu-icons.md +++ /dev/null @@ -1,19 +0,0 @@ -# 商务菜单图标修复 - -旧 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/page-parity.md b/docs/sw6-to-kra/page-parity.md deleted file mode 100644 index 434efa3..0000000 --- a/docs/sw6-to-kra/page-parity.md +++ /dev/null @@ -1,200 +0,0 @@ -# 旧商务页面功能核对 - -以旧前端 `dev/dev1.0` 的实际模板、事件处理和接口调用为准,不因为后端存在某个接口就向页面增加入口。页面样式采用 Kra;名称、业务可见条件和交互沿用旧商务。只有完成页面、接口和实际验证后才标为整个菜单完成。 - -## 商务列表与绑定列表 - -来源:`sw/web/src/views/BusMgmt/BusinessList.vue`,以及该目录下实际引用的抽屉组件。前端基线 `ba284011c87fd3d5730e0e3dceffb14a7389730c`。 - -| 旧页面行为 | 新页面状态 | -| --- | --- | -| 商务列表没有编辑按钮;操作列为画像 | 已对齐,商务画像及对应抽屉已接入 | -| 第二个页签名为“绑定列表”,仅 `level=3` 显示 | 已对齐;超管、一级、二级均隐藏且不会加载绑定列表 | -| 商务列表筛选:商务名称、商务账号、等级、状态 | 已实现 | -| 添加子商务:账号、密码、姓名、手机号、QQ;展示等级/所属商务 | 已实现;已移除原页面没有的邮箱和编辑审核表单 | -| SDK商务数量可点击,零数量也能打开详情 | 已对齐 | -| SDK商务绑定详情是只读抽屉,上级可以查看;列为平台、ID、游戏商务账号、审核状态、绑定日期 | 已对齐,平台点击可进入游戏商务画像 | -| 商务列表列名“旗下渠道数”,点击进入旗下渠道抽屉 | 已对齐,抽屉显示序号、渠道名称、所属平台及分页 | -| 注册日期、绑定日期显示年月日 | 已对齐 | -| 绑定状态正常:画像、解绑;驳回:查看驳回;待审核:无操作 | 已对齐,画像只出现在正常绑定行 | -| 查看驳回打开审核详情,展示申请商务、平台、账号、状态、申请/审核时间、审核人和审核意见 | 已实现 | -| 添加绑定、解除绑定确认使用右侧抽屉 | 已对齐 | -| 绑定列表点击旗下渠道数时,仅正常状态可查看并携带平台/平台商务筛选 | 已实现并通过浏览器参数核对 | -| 全局偏好变化刷新商务/绑定数据 | 已接入 Kra 页面刷新机制 | - -“仅三级可见”是旧页面页签规则。旧 Java 绑定查询接口仍支持超管全量、一级/二级查询下级,SDK 详情接口允许上级查看下级;这些接口的数据范围不能因页签隐藏而一并误删。 - -目前 sw7 是系统数据演练库,缺少 `tab_promote`。有绑定但渠道表未接入的数量明确返回空值并展示“未接入”,不当作零;完整数据环境继续计算真实数量。 - -## 验证 - -- `business-page-parity-2026-11-03.json`:真实 Chrome 渲染、受控身份/业务数据,检查四个等级的页签和按钮,以及三种审核状态的行操作;无页面运行时错误。 -- `http-business-bindings-2026-11-03.json`:真实运行实例及已有 sw7 测试数据的 HTTP 验证。 -- 旗下渠道已通过仓储与实际 HTTP 回归,覆盖已通过绑定、直属一级、跨平台同号和稳定分页;sw7 缺渠道数据时明确返回 `available=false`。 -- 商务、SDK 商务和渠道画像已通过接口及浏览器验证;真实业务库数值仍待最终验收。 - -## 商务与游戏商务画像 - -来源:旧 `BusinessProfileDrawer.vue` 及 `BusinessList.vue` 的实际入口、`handleProfileOpenChannels` 参数传递。 - -| 旧页面行为 | 新页面状态 | -| --- | --- | -| 商务列表画像、正常绑定行画像、只读 SDK 详情平台入口 | 三个入口已接入,并分别验证一级与三级身份 | -| 基本资料、上级商务、注册时间、状态、一级渠道数、本月新增 | 已实现;SDK 父级来自 commerce1/commerce2,SDK 状态 0 展示待审核 | -| 今日、昨日、近7天、近30天、本月 | 已实现,周期和对照区间由后端统一计算 | -| 注册玩家、充值金额、付费率、人均充值及环比 | 已实现,付费率使用百分点差并保留无对照期时的 null | -| 注册、充值趋势及 TOP 5 | 已实现;使用 Kra 主题样式,图表支持鼠标与键盘查看数据点 | -| 关联预警独立分页,不随指标周期改变查询范围 | 已实现,按 SW businessId 与平台过滤 | -| 画像“旗下渠道”继续打开直属渠道抽屉 | 已对照父组件处理函数;传入 SW 用户 ID,SDK 场景附带平台和平台商务 ID | -| 缺业务数据与没有业务数据 | 分别显示未接入与真实零,避免未知指标显示为零 | - -验证:`portrait-http-2026-11-03.json`、`portrait-page-2026-11-03.json` 及隔离双库 TCP HTTP 测试。完整平台资料与真实业务数值仍待最终实库联调。 - -## 其他页面 - -渠道统计、游戏数据、区服分析和排行榜,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。 - -## 注册与充值明细 - -来源:旧 `RegistrationDetails.vue`、`RechargeDetails.vue`,查询及导出边界见 `ledger-source-notes.md`。 - -| 旧页面行为 | 新页面状态 | -| --- | --- | -| 平台、商务账号、玩家ID、精确渠道账号、游戏名称与日期筛选 | 已实现;游戏选项按平台查询并按名称选择 | -| 页面默认含今日近30天,后台不传日期默认截至昨日7天 | 分别保留;日期快捷为今日/昨日/本周/本月 | -| 注册列没有独立 ID 与角色名称,包含累计充值 | 已对齐;累计充值按同平台玩家全部历史成功支付 | -| 充值包含订单、区服、角色、方式、状态及成功支付合计 | 已实现;合计不跟随失败/处理中状态筛选改变口径 | -| 时间、金额服务端排序 | 已实现;不提供后台不支持的充值 ID 排序 | -| 充值≥5000的行高亮 | 已保留 | -| 导出当前筛选数据,最多10000条 | 已实现 XLSX;注册按钮改用正确的注册导出权限 | -| 缺业务数据或查询失败 | 明确提示,清除旧行与旧合计,不导出假空业务数据 | - -验证:`ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`,实际 TCP HTTP、工作簿内容检查及全量 Go/前端构建。真实服务导出验证仅使用确定无匹配数据的筛选。 - -## 预警规则 - -来源:`sw/web/src/views/AlertCenter/AlertRules.vue` 及其规则编辑组件;规则触发提示同时核对最终 `AlertRecordCreationService`,旧页面“每天一次”提示已失效。 - -| 旧页面行为 | 新页面状态 | -| --- | --- | -| 按监控指标、规则名称筛选 | 已实现 | -| 列为启用状态、规则名称、规则类型、描述、创建人、作用范围、操作 | 已实现;类型显示预警颜色,范围固定一级渠道 | -| 一级商务和超管可新建、编辑、删除、启停;二/三级只读 | 已实现,后端同时校验归属,真实 Chrome 检查四级角色 | -| 新建/编辑右侧抽屉,类型为自由文本 | 已实现 | -| 注册/充值展示周期、方向、阈值;登录展示未登录天数 | 已实现,浏览器验证切换字段及提交参数 | -| 删除需要确认 | 已实现,后端软删除 | -| 预警触发去重说明 | 按最终后端描述平台、渠道、类型的未关闭记录去重;生成任务仍待迁移 | - -验证:`alert-rules-http-2026-11-03.json`、`alert-rules-page-2026-11-03.json`,以及隔离数据的真实 TCP HTTP 生命周期测试。预警检测与生成任务仍待迁移。 - -## 预警列表与处理抽屉 - -来源:`sw/web/src/views/AlertCenter/AlertList.vue`、`components/AlertHandleDialog.vue`,并核对最终 `SysAlertRecordServiceImpl` 和 `SysAlertOperationLogServiceImpl`。 - -| 旧页面行为 | 新页面状态 | -| --- | --- | -| 顶部五种状态卡片,点击筛选、再次点击取消 | 已实现;状态卡片统计保留原后端口径 | -| 平台默认全选;规则名称、类型、状态、归属商务筛选 | 已实现;平台取消全选时清空列表,不误查询全部 | -| 所属平台、触发时间、规则名称、类型、预警对象、归属商务、内容、状态、操作 | 已实现,触发时间按月日时分显示 | -| 行操作按五种状态显示处理、继续处理、审核/查看、重新处理、查看 | 已实现;待审核仅一级显示审核,等级为零的超管显示查看 | -| 右侧处理/详情抽屉展示摘要与不可删除的操作记录 | 已实现,日志按时间、ID 倒序,动作编码映射中文 | -| 只读态隐藏备注与写入按钮 | 已实现;详情最新状态与服务端 canAudit 同时决定可操作性 | -| 开始、提交、驳回、重新处理需要确认;审核通过直接提交 | 已对齐 | -| 操作成功刷新详情与列表,抽屉保持打开 | 已实现,状态变化后按钮和只读态同步更新 | -| 图片选取、拖拽、粘贴、预览、移除,添加备注时才上传 | 已实现;失败重试复用已上传结果,暂存图片阻止直接流转 | -| 旧页面允许只选图片,后端要求非空备注 | 按原后端契约提前校验备注,避免先上传后失败 | -| 缺渠道表无法判断可见范围 | 返回 available=false,卡片显示未接入,不把未知范围当成真实零 | - -验证:`alert-records-http-2026-11-03.json`、`alert-records-page-2026-11-03.json`;后端完整状态流转通过独立 SQLite 和真实 TCP HTTP 测试,浏览器核对四个等级和五种状态的入口。预警检测/生成任务仍未接入,不因列表和工作流完成而宣称整个预警链路完成。 - -## 登录页 - -已从旧 `login.vue` 复用登录模板和样式,复制原品牌图片,适配 Kra 登录接口;保持记住账号而不存密码、验证码、三步找回密码和原文案。系统标题、侧栏标识及页脚已同步旧商务名称。`login-page-verification-2026-11-03.json` 记录 Chrome 的流程验证,短信接口为测试替身,不向真实手机发码。 - -## 渠道激活 - -来源:`sw/web/src/views/BusMgmt/ChannelActivationList.vue`,最终 `PromoteActivePoolBindServiceImpl` 与独立 OA 查询适配器。 - -| 旧页面行为 | 新页面状态 | -| --- | --- | -| 平台默认全选,渠道账号与绑定商务筛选,查询/重置 | 已实现;没有额外增加旧模板不存在的状态筛选 | -| 列为平台、渠道名称、绑定商务、绑定日期、解绑时间、累计流水金额、状态、操作 | 已实现,日期按年月日显示,金额保留两位小数 | -| 已驳回无流水明细按钮,其余状态均显示 | 已实现;待审核按原接口提示“审核通过后可查看” | -| 流水使用居中弹窗,显示平台、渠道、绑定期间、游戏汇总和总额 | 已实现 | -| 普通商务仅看本人认领,超管看全部 | 已实现,和预警的下级树范围分开;在隔离双库 HTTP 测试中验证 | -| 达标/解绑后的流水截止时间 | 保持最终后端计算,并修正旧弹窗达标状态仍显示“起”的文案,显示实际截止日期 | -| 明细加载失败 | 明确展示原因,不保留预填金额冒充成功读取的明细 | -| OA 未接入 | 明确显示未接入;不回退到主库同名表 | - -验证:`activation-http-2026-11-03.json`、`activation-page-2026-11-03.json`,以及独立主库/OA 两个 SQLite 数据库的真实 TCP HTTP 测试。未修改实际 OA 数据。 - -## 渠道列表、新增与推广游戏 - -来源及统计规则详见 `channel-source-notes.md`。页面保持旧 `ChannelList.vue`、`AddChannelDrawer.vue`、`ChannelGameDrawer.vue` 的实际功能。 - -| 旧页面行为 | 新页面状态 | -| --- | --- | -| 平台、商务账号、渠道关键词、层级、类型、锁定状态筛选 | 已实现;修正旧“账号/负责人姓名”占位文案,按实际后端只查询账号 | -| 列表类型个人/公会;新增类型个人/企业 | 按两处实际模板分别保留 | -| 渠道信息、归属商务、上级/负责人、推广游戏、玩家、累计充值、锁定状态 | 已实现;缺失指标明确显示未接入 | -| 没有编辑入口,行操作为画像 | 已对齐,渠道画像入口及完整抽屉已接入 | -| 添加渠道按按钮权限,不额外限制三级 | 已实现并用一级浏览器身份核对,上级可选择下级正常绑定 | -| 切换平台清空游戏商务并重新加载、按平台商务 ID 去重 | 已实现,防止旧请求覆盖新平台选项 | -| 添加成功后关闭抽屉、刷新第一页,允许同步延迟 | 已实现,保持源平台创建及同步延迟提示 | -| 游戏数量零也可打开抽屉;必须携带平台和渠道 ID | 已实现并用跨平台同号与零游戏行验证 | -| 游戏分布显示累计玩家数、充值金额 | 已实现,实际取日汇总/身份键数据,独立于列表原始累计指标 | -| 全局偏好变化刷新页面 | 复用 Kra 全局 reload,后端每次请求读取当前偏好 | - -验证:`channel-http-2026-11-03.json`、`channel-page-2026-11-03.json`,以及实际 TCP HTTP + 隔离主库/平台库。画像新增验证见下表,完整真实数据仍待最终验收。 - -## 渠道画像 - -来源及计算规则见 `channel-portrait-source-notes.md`。 - -| 旧页面行为 | 新页面状态 | -| --- | --- | -| 基本资料、上级渠道、注册与最后登录时间、推广游戏数 | 已实现,一级不显示上级,其余读取同平台当前上级账号 | -| 今日、昨日、近7天、近30天、累计 | 已实现;修正旧 7d/30d 参数错配,发送 last7/last30 | -| 注册玩家、充值金额、付费率、活跃玩家 | 已实现;累计模式无环比值 | -| 两个趋势图与四档付费分层 | 已实现,复用 Kra 主题图表和进度条,分层按累计身份与历史成功支付 | -| 推广游戏数点击打开游戏分布 | 已实现,携带同一平台和渠道 ID,始终累计口径 | -| 关联预警分页 | 已实现;修复旧页面误传 businessId,改为平台+targetPromoteId | -| 缺业务表 | 保留能读取的基本资料,明确提示未接入,不生成假零分层 | - -验证:`channel-portrait-http-2026-11-03.json`、`channel-portrait-page-2026-11-03.json`、隔离数据真实 TCP HTTP 和完整 Go/前端构建检查。 - -## 渠道统计与游戏分析 - -来源及统计细节见 `analytics-source-notes.md`。 - -| 旧页面行为 | 新页面状态 | -| --- | --- | -| 平台默认全选,游戏多选、日期近 30 天,搜索/查询 | 已实现,查询和筛选回到第一页 | -| 渠道另有账号模糊、个人/公会类型筛选 | 已实现,文案明确按账号匹配 | -| 渠道顶部渠道数、注册、金额、付费率四卡片 | 已实现,使用全量后端汇总,与当前分页无关 | -| 渠道表平台、渠道、归属商务、注册/活跃/充值人数、新付费、金额和付费率 | 已实现,沿用 Kra 表格样式 | -| 游戏表平台、游戏、注册/活跃/付费人数、新付费、金额、付费率、ARPU、ARPPU | 已实现,游戏 ID 无无效排序箭头 | -| 游戏表尾汇总来自全部筛选结果 | 已实现,跨页保留全量 summary;渠道页使用顶部卡片 | -| 列排序和分页 | 已实现,服务器排序,同值增加平台/ID 保证稳定 | -| 旧页面无导出接口/按钮 | 不增加导出入口 | -| 缺报表数据 | 明确显示未接入,清空过期汇总,指标不伪造零 | - -验证:`analytics-http-2026-11-04.json`、`analytics-page-2026-11-04.json`;Chrome 使用受控业务数据,完整计算和权限使用隔离数据库的真实 TCP HTTP 验证。 - -## 区服分析 - -按旧 `ServerAnalysis.vue` 源码迁移平台多选、游戏多选、区服单选、近 30 天默认日期、首个游戏自动查询、列排序/分页及全量汇总;沿用 Kra 控件,没有新增导出入口。切换平台清空游戏/区服,切换游戏清空区服,过期异步请求不覆盖新选项。统计字段保持所属平台、区服编号/名称、开服日期、新增注册、活跃玩家、充值金额、付费玩家和 ARPU。 - -接口通过真实 sw7 请求及隔离数据库 TCP HTTP 验证,前端 ESLint/生产构建通过。按用户 2026-11-04 新要求,此后只检查接口,不再检查页面交互。详见 `server-analysis-source-notes.md`。 - -## 工作台 / Kra 仪表盘 - -直接核对旧 `views/index.vue`,在原 Kra 仪表盘中加入六项商务指标、平台多选、注册/充值两组横向榜图、未登录/最低充值两组榜表,以及最新五条待处理预警的四秒轮播和查看全部入口。注册/最高充值默认今日,最低充值默认近 30 天;每个榜单单独切换周期。保留 Kra 欢迎、快捷入口和公告,使用 Kra 卡片、颜色与 Element Plus 控件。没有移植旧页面的自定义图表样式。 - -四个工作台接口和已有预警接口负责真实业务数据;接口缺表或错误明确提示,不将空选择、未接入和真实零混淆。按用户要求仅做接口实测、数据断言和构建,未做页面交互检查。 - -## 独立排行榜 - -按旧 `RankingAnalysis.vue` 保留渠道/商务/游戏三个页签、平台多选(空选表示全部)、各维度独立记忆的日期、默认近 7 天,以及注册/充值/付费率/活跃四组 TOP20。不足 20 条显示占位符,旧请求不覆盖新维度。界面采用 Kra 表格和配色;付费率的环比文案明确为百分点,修复旧页面将百分点也显示成百分比的歧义。没有额外增加分页、游戏选择或导出入口。 - -接口实测、隔离数据 HTTP、全量 Go 和前端构建通过。按用户要求未检查页面交互。详见 `ranking-source-notes.md`。 diff --git a/docs/sw6-to-kra/password-reset-http-2026-11-03.json b/docs/sw6-to-kra/password-reset-http-2026-11-03.json deleted file mode 100644 index 6ba01e9..0000000 --- a/docs/sw6-to-kra/password-reset-http-2026-11-03.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "scope": "运行实例匿名接口校验;不发送短信、不修改真实用户密码", - "base_url": "http://127.0.0.1:18000", - "checks": [ - { - "action": "check", - "http": 200, - "code": 7, - "msg": "账号不存在或未注册", - "elapsed_ms": 3097, - "passed": true - }, - { - "action": "send-code", - "http": 200, - "code": 7, - "msg": "商务账号参数不正确", - "elapsed_ms": 314, - "passed": true - }, - { - "action": "verify-code", - "http": 200, - "code": 7, - "msg": "商务账号参数不正确", - "elapsed_ms": 85, - "passed": true - }, - { - "action": "reset", - "http": 200, - "code": 7, - "msg": "商务账号参数不正确", - "elapsed_ms": 224, - "passed": true - } - ] -} \ No newline at end of file diff --git a/docs/sw6-to-kra/portrait-http-2026-11-03.json b/docs/sw6-to-kra/portrait-http-2026-11-03.json deleted file mode 100644 index 3ff6b7f..0000000 --- a/docs/sw6-to-kra/portrait-http-2026-11-03.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "scope": "真实 sw7 登录会话,只读请求;有绑定但业务表未接入时不伪造零,无绑定时验证真实空画像。完整指标和 SDK 基本信息通过隔离双库 TCP HTTP 测试。", - "checks": [ - { - "path": "/sw/business/list?pageNum=1&pageSize=500", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true - }, - { - "path": "/sw/business/10001/portrait?sourceDbList=lx,hw,yq&period=today", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": true, - "promote_count": 0, - "register_points": 24, - "pay_points": 24 - }, - { - "path": "/sw/business/10001/portrait?sourceDbList=lx&period=thisMonth", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": true, - "promote_count": 0, - "register_points": 3, - "pay_points": 3 - }, - { - "path": "/sw/business/10004/portrait?sourceDbList=lx,hw,yq&period=last7", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": false, - "promote_count": null, - "register_points": 0, - "pay_points": 0 - }, - { - "path": "/sw/business/10001/portrait", - "http": 200, - "code": 7, - "msg": "请选择所属平台后查看数据", - "passed": true - }, - { - "path": "/sw/business/bindPlatform/portrait?sourceDb=lx&platformBusierId=0", - "http": 200, - "code": 7, - "msg": "商务账号参数不正确", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/portrait-page-2026-11-03.json b/docs/sw6-to-kra/portrait-page-2026-11-03.json deleted file mode 100644 index 98c43a9..0000000 --- a/docs/sw6-to-kra/portrait-page-2026-11-03.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "method": "真实 Chrome 渲染;业务接口使用受控数据。验证三个入口、时间切换、交互图表、SDK 状态、SW 用户 ID 与平台 ID 分离、缺数据提示。", - "results": [ - { - "level": 1, - "passed": true, - "entry": "商务列表与只读 SDK 详情", - "pageErrors": [] - }, - { - "level": 3, - "passed": true, - "entry": "三级绑定列表正常行", - "pageErrors": [] - } - ] -} diff --git a/docs/sw6-to-kra/portrait-source-notes.md b/docs/sw6-to-kra/portrait-source-notes.md index 14eeeae..25d2215 100644 --- a/docs/sw6-to-kra/portrait-source-notes.md +++ b/docs/sw6-to-kra/portrait-source-notes.md @@ -1,6 +1,6 @@ # 商务与 SDK 商务画像源码核对 -状态:两种画像接口、三个页面入口及报表读取已实现并通过隔离 HTTP、真实会话和浏览器验证。完整平台数据源与最终旧库数据验收仍未完成。账号状态联动已在 `fc17b38` 完成。 +两种画像接口、三个页面入口及报表读取已实现。接口、平台数据源及账号状态验证统一见 `validation.md`。 ## 范围与入口 @@ -40,4 +40,4 @@ 沿用 DTO → service → biz → data,金额用精确计算,页面采用 Kra 图表/表格风格。不要在 stats 缺表时伪造零;没有任何绑定是已知空范围,可返回真实零和零趋势,有绑定但渠道/汇总未接入时保留可读取基本信息并明确未知指标。 -当前真实测试库仍缺完整业务表、平台/OA 别名尚未配齐。正向指标和权限测试继续用隔离数据与真实 TCP HTTP,真实 18000 实例用现有会话验证查询和缺数据场景。最终仍需用户确认旧库后复制验收。 +当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。 diff --git a/docs/sw6-to-kra/ranking-http-2026-11-04.json b/docs/sw6-to-kra/ranking-http-2026-11-04.json deleted file mode 100644 index 6f29b0a..0000000 --- a/docs/sw6-to-kra/ranking-http-2026-11-04.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "scope": "真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向排行和权限通过隔离 TCP HTTP 测试。", - "checks": [ - { - "path": "/sw/rank/channel?sourceDb=lx,hw,yq", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": false - }, - { - "path": "/sw/rank/channel?beginTime=bad&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计周期格式错误,应为 yyyy-MM-dd", - "passed": true - }, - { - "path": "/sw/rank/channel?beginTime=2026-11-05&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计开始日期不能晚于结束日期", - "passed": true - }, - { - "path": "/sw/rank/business?sourceDb=lx,hw,yq", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": false - }, - { - "path": "/sw/rank/business?beginTime=bad&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计周期格式错误,应为 yyyy-MM-dd", - "passed": true - }, - { - "path": "/sw/rank/business?beginTime=2026-11-05&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计开始日期不能晚于结束日期", - "passed": true - }, - { - "path": "/sw/rank/game?sourceDb=lx,hw,yq", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": false - }, - { - "path": "/sw/rank/game?beginTime=bad&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计周期格式错误,应为 yyyy-MM-dd", - "passed": true - }, - { - "path": "/sw/rank/game?beginTime=2026-11-05&endTime=2026-11-04", - "http": 200, - "code": 7, - "msg": "统计开始日期不能晚于结束日期", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/ranking-source-notes.md b/docs/sw6-to-kra/ranking-source-notes.md index c194803..305c437 100644 --- a/docs/sw6-to-kra/ranking-source-notes.md +++ b/docs/sw6-to-kra/ranking-source-notes.md @@ -8,9 +8,9 @@ - 商务榜超管列出一/二/三级商务,普通商务看直属下级,三级空榜。每个被展示商务的数据来自本人及全部下级的正常绑定渠道。一次关系查询展开目标、下级、绑定、渠道,代替旧每个目标单独查询下级的 N+1;查询条件保持各目标自己的祖先路径范围。 - 商务人数累加各渠道人数,不做跨渠道全局去重;金额累加同一组渠道。逐渠道保留 Java int 上限,商务总数采用 int64 避免旧 int 溢出。祖先与下级可以分别上榜;不是互斥分区。 - 注册、充值、活跃只展示正值;付费率只要求活跃大于 0,保留 0% 行。金额固定模式 0,人数取当前偏好。 -- 注册/充值/活跃环比需要上期正值,否则为 null;付费率只要求上期存在行,上期活跃为零按 0% 比较,结果为百分点差。金额展示先保留两位,金额环比用未舍入汇总,与旧服务一致。 +- 2026-11-04 按用户要求,四榜环比统一为 `(本期值 - 上期值) / 上期值 × 100%`。上期没有有效数据或为零时为 null;本期付费率降到零且上期为正时为 -100%。付费率原来的百分点差已改为相对增幅,这是迁移后明确采用的新口径。金额展示先保留两位,金额环比用未舍入汇总,与旧服务一致。 - 环比保留两位,绝对值小于 0.01 为持平。数值排序用精确有理数,同值追加平台/ID 稳定排序,截取后连续编号。 - 源码还核对了空平台含义:独立排行榜空选表示全部,区别于工作台必须选平台。非法/倒置日期明确拒绝。缺报表时 available=false 和空数组,不伪造零指标或空查询成功。 - 当前菜单名称、父级和路径已通过实际菜单接口核实,仍为 `sw_DataAnalysis` 下 `sw_RankingAnalysis` / `RankingAnalysis`。 -已通过全量 Go、领域/仓储测试、独立 SQLite 的真实 TCP HTTP、ESLint/生产构建、真实 sw7 只读请求。没有页面交互检查,也没有进行最终旧库复制或同源比较。 +当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。 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 deleted file mode 100644 index 9d0f532..0000000 --- a/docs/sw6-to-kra/report-build-http-2026-11-04.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 index db9e4d7..dfb2a27 100644 --- a/docs/sw6-to-kra/report-build-source-notes.md +++ b/docs/sw6-to-kra/report-build-source-notes.md @@ -24,4 +24,4 @@ 领域与存储测试覆盖身份/账号规则、重复构建、自然日筛选和身份明细写失败后的原子回滚。真实 TCP HTTP 使用隔离数据库和 Kra 原生任务创建、手动触发、执行日志,核对两种报表实际落库且任务仍为禁用。 -全量 Go 测试通过。当前真实 sw7 仅查询注册方法和计划状态,7 个导入任务均禁用。没有在真实 sw7 重建报表,也没有开始最终旧数据库复制与同源结果比较;MySQL 并发锁效果须在最终隔离旧库联调中验证。 +当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。 diff --git a/docs/sw6-to-kra/server-analysis-http-2026-11-04.json b/docs/sw6-to-kra/server-analysis-http-2026-11-04.json deleted file mode 100644 index 73ede71..0000000 --- a/docs/sw6-to-kra/server-analysis-http-2026-11-04.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "scope": "真实 sw7 会话只读检查,当前缺少业务报表和游戏区服表;未创建实际业务数据。", - "checks": [ - { - "endpoint": "list", - "query": {}, - "http": 200, - "code": 7, - "msg": "请选择游戏", - "passed": true - }, - { - "endpoint": "servers", - "query": {}, - "http": 200, - "code": 7, - "msg": "请选择游戏", - "passed": true - }, - { - "endpoint": "list", - "query": { - "gameName": "隔离核验不存在游戏", - "sourceDb": "lx,hw,yq" - }, - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": false, - "total": 0 - }, - { - "endpoint": "list", - "query": { - "gameName": "x", - "beginTime": "bad", - "endTime": "2026-11-04" - }, - "http": 200, - "code": 7, - "msg": "统计周期格式错误,应为 yyyy-MM-dd", - "passed": true - }, - { - "endpoint": "list", - "query": { - "gameName": "x", - "beginTime": "2026-11-05", - "endTime": "2026-11-04" - }, - "http": 200, - "code": 7, - "msg": "统计开始日期不能晚于结束日期", - "passed": true - }, - { - "endpoint": "servers", - "query": { - "gameName": "隔离核验不存在游戏", - "sourceDb": "lx" - }, - "http": 200, - "code": 7, - "msg": "游戏或区服数据尚未接入", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/server-analysis-source-notes.md b/docs/sw6-to-kra/server-analysis-source-notes.md index 6ca46be..fe2cac3 100644 --- a/docs/sw6-to-kra/server-analysis-source-notes.md +++ b/docs/sw6-to-kra/server-analysis-source-notes.md @@ -13,4 +13,4 @@ - 缺任一必要业务表时明确未接入,summary 为 null;不能混用不存在区服和缺表错误。未知开服时间保持 null。 - 后端默认上海当天,前端默认近 30 天;非法日期/倒置周期明确拒绝。页长沿用最大 500。 -已通过领域/仓储测试、独立数据库真实 TCP HTTP、全量 Go、ESLint/生产构建与真实 sw7 接口只读请求。用户已明确不需要页面交互检查;没有执行旧数据库复制或最终同源比对。 +当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。 diff --git a/docs/sw6-to-kra/source-inventory.json b/docs/sw6-to-kra/source-inventory.json deleted file mode 100644 index 409c9da..0000000 --- a/docs/sw6-to-kra/source-inventory.json +++ /dev/null @@ -1,1848 +0,0 @@ -{ - "baseline": "d31082f4a7816d4c83544b8f54e6f42a3b2132b1", - "backend_head": "f9a5e3a9085ad0f89588cbdc61720032a80cb66c", - "frontend_head": "ba284011c87fd3d5730e0e3dceffb14a7389730c", - "contracts": [ - { - "method": "GET", - "source_path": "/system/user/preference", - "target_path": "/sw/user/preference", - "permission": "__authenticated__", - "controller": "UserPreference" - }, - { - "method": "PUT", - "source_path": "/system/user/preference", - "target_path": "/sw/user/preference", - "permission": "__authenticated__", - "controller": "UserPreference" - }, - { - "method": "GET", - "source_path": "/system/promote/list", - "target_path": "/sw/promote/list", - "permission": "system:promote:list", - "controller": "Promote" - }, - { - "method": "GET", - "source_path": "/system/promote/portrait", - "target_path": "/sw/promote/portrait", - "permission": "system:promote:list", - "controller": "Promote" - }, - { - "method": "GET", - "source_path": "/system/promote/game-distribution", - "target_path": "/sw/promote/game-distribution", - "permission": "system:promote:list", - "controller": "Promote" - }, - { - "method": "GET", - "source_path": "/system/promote/bindOptions", - "target_path": "/sw/promote/bindOptions", - "permission": "system:promote:add", - "controller": "Promote" - }, - { - "method": "POST", - "source_path": "/system/promote", - "target_path": "/sw/promote", - "permission": "system:promote:add", - "controller": "Promote" - }, - { - "method": "GET", - "source_path": "/system/promoteActivePool/bindList", - "target_path": "/sw/promoteActivePool/bindList", - "permission": "system:promoteActivePool:list", - "controller": "PromoteActivePool" - }, - { - "method": "GET", - "source_path": "/system/promoteActivePool/bindSpendDetail", - "target_path": "/sw/promoteActivePool/bindSpendDetail", - "permission": "system:promoteActivePool:list", - "controller": "PromoteActivePool" - }, - { - "method": "GET", - "source_path": "/system/workbench/summary", - "target_path": "/sw/workbench/summary", - "permission": "system:workbench:query", - "controller": "Workbench" - }, - { - "method": "GET", - "source_path": "/system/workbench/registerTop", - "target_path": "/sw/workbench/registerTop", - "permission": "system:workbench:query", - "controller": "Workbench" - }, - { - "method": "GET", - "source_path": "/system/workbench/payRank", - "target_path": "/sw/workbench/payRank", - "permission": "system:workbench:query", - "controller": "Workbench" - }, - { - "method": "GET", - "source_path": "/system/workbench/unloginTop", - "target_path": "/sw/workbench/unloginTop", - "permission": "system:workbench:query", - "controller": "Workbench" - }, - { - "method": "GET", - "source_path": "/system/rank/channel", - "target_path": "/sw/rank/channel", - "permission": "system:rank:channel", - "controller": "Rank" - }, - { - "method": "GET", - "source_path": "/system/rank/business", - "target_path": "/sw/rank/business", - "permission": "system:rank:channel", - "controller": "Rank" - }, - { - "method": "GET", - "source_path": "/system/rank/game", - "target_path": "/sw/rank/game", - "permission": "system:rank:channel", - "controller": "Rank" - }, - { - "method": "GET", - "source_path": "/system/gameData/list", - "target_path": "/sw/gameData/list", - "permission": "system:gameData:list", - "controller": "GameData" - }, - { - "method": "GET", - "source_path": "/system/channelData/list", - "target_path": "/sw/channelData/list", - "permission": "system:channelData:list", - "controller": "ChannelData" - }, - { - "method": "GET", - "source_path": "/system/register/list", - "target_path": "/sw/register/list", - "permission": "system:register:list", - "controller": "Register" - }, - { - "method": "POST", - "source_path": "/system/register/export", - "target_path": "/sw/register/export", - "permission": "system:register:export", - "controller": "Register" - }, - { - "method": "GET", - "source_path": "/system/spend/list", - "target_path": "/sw/spend/list", - "permission": "system:spend:list", - "controller": "Spend" - }, - { - "method": "POST", - "source_path": "/system/spend/export", - "target_path": "/sw/spend/export", - "permission": "system:spend:export", - "controller": "Spend" - }, - { - "method": "GET", - "source_path": "/system/serverAnalysis/games", - "target_path": "/sw/serverAnalysis/games", - "permission": "system:serverAnalysis:list", - "controller": "ServerAnalysis" - }, - { - "method": "GET", - "source_path": "/system/serverAnalysis/servers", - "target_path": "/sw/serverAnalysis/servers", - "permission": "system:serverAnalysis:list", - "controller": "ServerAnalysis" - }, - { - "method": "GET", - "source_path": "/system/serverAnalysis/list", - "target_path": "/sw/serverAnalysis/list", - "permission": "system:serverAnalysis:list", - "controller": "ServerAnalysis" - }, - { - "method": "GET", - "source_path": "/system/alertRule/list", - "target_path": "/sw/alertRule/list", - "permission": "system:alertRule:list", - "controller": "SysAlertRule" - }, - { - "method": "GET", - "source_path": "/system/alertRule/ruleTypes", - "target_path": "/sw/alertRule/ruleTypes", - "permission": "system:alertRule:list", - "controller": "SysAlertRule" - }, - { - "method": "GET", - "source_path": "/system/alertRule/{id}", - "target_path": "/sw/alertRule/:id", - "permission": "system:alertRule:query", - "controller": "SysAlertRule" - }, - { - "method": "POST", - "source_path": "/system/alertRule/add", - "target_path": "/sw/alertRule/add", - "permission": "system:alertRule:add", - "controller": "SysAlertRule" - }, - { - "method": "PUT", - "source_path": "/system/alertRule/edit", - "target_path": "/sw/alertRule/edit", - "permission": "system:alertRule:edit", - "controller": "SysAlertRule" - }, - { - "method": "DELETE", - "source_path": "/system/alertRule/{id}", - "target_path": "/sw/alertRule/:id", - "permission": "system:alertRule:remove", - "controller": "SysAlertRule" - }, - { - "method": "PUT", - "source_path": "/system/alertRule/toggle/{id}", - "target_path": "/sw/alertRule/toggle/:id", - "permission": "system:alertRule:edit", - "controller": "SysAlertRule" - }, - { - "method": "GET", - "source_path": "/system/alertRecord/list", - "target_path": "/sw/alertRecord/list", - "permission": "system:alertRecord:list", - "controller": "SysAlertRecord" - }, - { - "method": "GET", - "source_path": "/system/alertRecord/detail/{id}", - "target_path": "/sw/alertRecord/detail/:id", - "permission": "system:alertRecord:query", - "controller": "SysAlertRecord" - }, - { - "method": "POST", - "source_path": "/system/alertRecord/startProcess", - "target_path": "/sw/alertRecord/startProcess", - "permission": "system:alertRecord:process", - "controller": "SysAlertRecord" - }, - { - "method": "POST", - "source_path": "/system/alertRecord/submitAudit", - "target_path": "/sw/alertRecord/submitAudit", - "permission": "system:alertRecord:process", - "controller": "SysAlertRecord" - }, - { - "method": "POST", - "source_path": "/system/alertRecord/approve", - "target_path": "/sw/alertRecord/approve", - "permission": "system:alertRecord:audit", - "controller": "SysAlertRecord" - }, - { - "method": "POST", - "source_path": "/system/alertRecord/reject", - "target_path": "/sw/alertRecord/reject", - "permission": "system:alertRecord:audit", - "controller": "SysAlertRecord" - }, - { - "method": "POST", - "source_path": "/system/alertRecord/reProcess", - "target_path": "/sw/alertRecord/reProcess", - "permission": "system:alertRecord:process", - "controller": "SysAlertRecord" - }, - { - "method": "POST", - "source_path": "/system/alertRecord/addNote", - "target_path": "/sw/alertRecord/addNote", - "permission": "system:alertRecord:process", - "controller": "SysAlertRecord" - }, - { - "method": "GET", - "source_path": "/system/user/list", - "target_path": "/sw/business/list", - "permission": "system:user:list", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/{userId}/promotes", - "target_path": "/sw/business/:userId/promotes", - "permission": "system:user:query", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/{userId}/portrait", - "target_path": "/sw/business/:userId/portrait", - "permission": "system:user:query", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/{userId}/bindPlatform", - "target_path": "/sw/business/:userId/bindPlatform", - "permission": "system:user:query", - "controller": "SysUser" - }, - { - "method": "POST", - "source_path": "/system/user/bindPlatform", - "target_path": "/sw/business/bindPlatform", - "permission": "system:user:bind", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/bindPlatform", - "target_path": "/sw/business/bindPlatform", - "permission": "system:user:query", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/bindPlatform/portrait", - "target_path": "/sw/business/bindPlatform/portrait", - "permission": "system:user:query", - "controller": "SysUser" - }, - { - "method": "DELETE", - "source_path": "/system/user/bindPlatform", - "target_path": "/sw/business/bindPlatform", - "permission": "system:user:bind", - "controller": "SysUser" - }, - { - "method": "POST", - "source_path": "/system/user/export", - "target_path": "/sw/business/export", - "permission": "system:user:export", - "controller": "SysUser" - }, - { - "method": "POST", - "source_path": "/system/user/importData", - "target_path": "/sw/business/importData", - "permission": "system:user:import", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/", - "target_path": "/sw/business", - "permission": "system:user:query", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/{userId}", - "target_path": "/sw/business/:userId", - "permission": "system:user:query", - "controller": "SysUser" - }, - { - "method": "POST", - "source_path": "/system/user", - "target_path": "/sw/business", - "permission": "system:user:add", - "controller": "SysUser" - }, - { - "method": "PUT", - "source_path": "/system/user", - "target_path": "/sw/business", - "permission": "system:user:edit", - "controller": "SysUser" - }, - { - "method": "DELETE", - "source_path": "/system/user/{userIds}", - "target_path": "/sw/business/:userIds", - "permission": "system:user:remove", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/optionselect", - "target_path": "/sw/business/optionselect", - "permission": "system:user:query", - "controller": "SysUser" - }, - { - "method": "PUT", - "source_path": "/system/user/resetPwd", - "target_path": "/sw/business/resetPwd", - "permission": "system:user:resetPwd", - "controller": "SysUser" - }, - { - "method": "PUT", - "source_path": "/system/user/changeStatus", - "target_path": "/sw/business/changeStatus", - "permission": "system:user:edit", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/unlock/{userId}", - "target_path": "/sw/business/unlock/:userId", - "permission": "system:user:edit", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/authRole/{userId}", - "target_path": "/sw/business/authRole/:userId", - "permission": "system:user:query", - "controller": "SysUser" - }, - { - "method": "PUT", - "source_path": "/system/user/authRole", - "target_path": "/sw/business/authRole", - "permission": "system:user:edit", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/deptTree", - "target_path": "/sw/business/deptTree", - "permission": "system:user:list", - "controller": "SysUser" - }, - { - "method": "GET", - "source_path": "/system/user/list/dept/{deptId}", - "target_path": "/sw/business/list/dept/:deptId", - "permission": "system:user:list", - "controller": "SysUser" - } - ], - "changed_sources": [ - { - "path": "ruoyi-admin/src/main/java/org/dromara/DromaraApplication.java", - "exists": true, - "sha256": "24915c74216d00295210808e2ad41aeab1d08da2fc6893d6505e378cd2d84f6a" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/controller/ForgotPasswordController.java", - "exists": true, - "sha256": "e4e516dfd96e2c5241baf19550f7cf935a72baef10fd1dfc56e9b3581d1d7647" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/domain/bo/ForgotPasswordResetBo.java", - "exists": true, - "sha256": "178e63e8b090674dcacc512fb9c7bd87065aec688e23fabd5721b092cf717d8c" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/domain/bo/ForgotPasswordUsernameBo.java", - "exists": true, - "sha256": "859118ef306c049a1e5ca663a2d9e7463b5bd4d1417ff7b48060dce03343f69c" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/domain/bo/ForgotPasswordVerifyBo.java", - "exists": true, - "sha256": "73aa7589b87ce857d8b9ac4ca4a18ec695cdbc13872726944a479a95ce75abe7" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/domain/vo/ForgotPasswordCheckVo.java", - "exists": true, - "sha256": "21ea1b187cdda7110bf7b69e8d71d7241baec6b43810605a817126e60e2ea803" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/domain/vo/ForgotPasswordSendVo.java", - "exists": true, - "sha256": "c4e84cf26fc6cc8ac08f372d5bf5651e502abdce83b4f8800dfece4867f36cee" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/domain/vo/ForgotPasswordVerifyVo.java", - "exists": true, - "sha256": "0d779399a83b964e42240c36261cae99ba4756a03e413461cb97eaa78f188d47" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/service/ForgotPasswordSupport.java", - "exists": true, - "sha256": "9b1dba384b4aa5bf28a374b17ad0b0f6e0c47d7dc05bc0388a570352b35c0807" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/service/IForgotPasswordService.java", - "exists": true, - "sha256": "5127c9e889197ca653d98879f9ccaaa06681ae86d4c3882d4380a6cc08e37fc6" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/service/SysLoginService.java", - "exists": true, - "sha256": "e86118365eb8a22dddae1068807fa0f1d41bd24c2a7fa72965655583c0fdf1e8" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/service/impl/EmailAuthStrategy.java", - "exists": true, - "sha256": "b3a0385864d7c7a97affa7b2b4d3dfc536bd3a0aad1c6935fb4ea54b86d5c6e2" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/service/impl/ForgotPasswordServiceImpl.java", - "exists": true, - "sha256": "5dcb3d807e01cb9b2c52d33c896f88777980d3d840183ba1ffc13970548ffc81" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java", - "exists": true, - "sha256": "a19d74766ab6766f2087f352e6bdb59c14cda791662d152276f352af5059f1be" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java", - "exists": true, - "sha256": "922341f813d354cd3837853f8df9ae26c197b89a49a0978d64abd3cb683c9e92" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/service/impl/SocialAuthStrategy.java", - "exists": true, - "sha256": "ac02193be3683343e9605286f3f859d4c958e95ab00636d462cb1869e2beb5a9" - }, - { - "path": "ruoyi-admin/src/main/java/org/dromara/web/service/impl/XcxAuthStrategy.java", - "exists": true, - "sha256": "0e9fa9490232299d6cf09e35c989fa3f12e6fbb7dae2cba10ee78d9903b73fb6" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/AlertRecordCreationServiceTest.java", - "exists": true, - "sha256": "52ca55903b1602dc2d3cef46a7930b6ba88cf593c2d14a94f3117faf0d42c94e" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/AlertRecordDeduplicationTest.java", - "exists": true, - "sha256": "7ea8b451313c22d8cfbd4f7b8f1f1f50b76c2759e63812ed3f61b831285325cc" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/BindPlatformPortraitScopeTest.java", - "exists": true, - "sha256": "850264319f0026548923e79e9ea715680b9c226244081c6f91ed4615bee71793" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/BusinessPortraitPeriodTest.java", - "exists": true, - "sha256": "88ce328aa0ee52e0994a8e27fdc5f3f02ddf01353d8e2e7724a0a962258b1357" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/ChannelDataPromoteNameScopeTest.java", - "exists": true, - "sha256": "35b5209aea6dec3d15e4a2301c2e2876831879efb127faf438146f0b27180c8b" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/ChannelTreeMetricsSupportTest.java", - "exists": true, - "sha256": "365967906a4e0dea74d6709a6c969d537d9fb66b65b240d75fb8a5fcf1e6f183" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/DetailChannelDisplayTest.java", - "exists": true, - "sha256": "d6ba604792f1a74d489a2b00548c95162300184ce693ac18ff2b4faf22bdd69d" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/PlatformBusinessPortraitSupportTest.java", - "exists": true, - "sha256": "d3dc92c49e255d27dfcab23c3e039f1789e5c306d84238fe247fac6414c6b6df" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/PromoteAccountRulesTest.java", - "exists": true, - "sha256": "74c753867d224e6a10c62e867998090512c15e2ac0fb2b9b14e15e5244754b8d" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/PromoteListMetricsSupportTest.java", - "exists": true, - "sha256": "372007f5cb530cc6e266249f0c6bd438e88cb049e126db45f7a7cda57864cb89" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/PromotePortraitPeriodTest.java", - "exists": true, - "sha256": "25e58332b004cff012b0789a614ff801f7467509c35354bee51c0db5f783b525" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/PromotePortraitScopeTest.java", - "exists": true, - "sha256": "7cd20febe528305c2714b42135faab9df2536c55fab39e79bcde8b8a2b17eecb" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/PromoteScopeFiltersTest.java", - "exists": true, - "sha256": "982e3402bdc0ebd0304d838dddad0afce727e651266f177545560bc59c6bc4a3" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/PromoteServiceImplTest.java", - "exists": true, - "sha256": "b5ee09b243fc45ac488fa9a249d6601a9121fdad750bf68e428058ec7ce5435e" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/ReportBuildRangeSupportTest.java", - "exists": true, - "sha256": "2904165f7425a9bad5abd24c19a46c33a25ed249904a1eac50d76c79d61259b0" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/ReportIdentityHelperTest.java", - "exists": true, - "sha256": "3aa193ce1f3911f1f6d31c2a9fca75d0936fb2f8decd984132e13a1d0a1ff1dc" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/ReportMapperPromoteScopeTest.java", - "exists": true, - "sha256": "0332712fe022a69b5c71011ba986560c302592a7b8566bd675139ebb68fc52cd" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/ReportQueryFiltersTest.java", - "exists": true, - "sha256": "742ee36b0050733f3f4c9a7056a7fbb6bd4ce1e2e9eb7eb27720815b056f33f3" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/ReportQueryScopesTest.java", - "exists": true, - "sha256": "8abaee552f43eace5b8d45f91172d03d3be8506e1091857452b751ced27eefad" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/ReportSortRowsNullSortFieldTest.java", - "exists": true, - "sha256": "8cc222a2e9fd6ea54ac5f7f9f44e83b24f0c7742d9aadf83167b1a666d08e992" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/VisiblePromoteScopeCacheKeysTest.java", - "exists": true, - "sha256": "1e7b884e62cf2841c96108585f1d253e23aced39f642268c67400b23f50841d8" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/system/VisiblePromoteScopeLoaderTest.java", - "exists": true, - "sha256": "d1cb28d7a08db2a0346154131b39d6a65b7c536dca3a041bff0382310f8695c6" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/web/ForgotPasswordServiceImplTest.java", - "exists": true, - "sha256": "2487436860b512c36d6ddbebf7ae7e28bb17c0db5ab41397d4af9f1593e23060" - }, - { - "path": "ruoyi-admin/src/test/java/org/dromara/web/ForgotPasswordSupportTest.java", - "exists": true, - "sha256": "e780985c97ee87719d4c48539f30daaa0e6833f9532941d7db7d065a4585e42e" - }, - { - "path": "ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/CacheNames.java", - "exists": true, - "sha256": "30eb4d08f5819e577bf4da5e7964f556c3ed19e48fe9a3a7702e9a28e52c8ede" - }, - { - "path": "ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/GlobalConstants.java", - "exists": true, - "sha256": "65404a89f95d727885efc4824aa78aa85097d9d69b31fd96a9b7034e8bcd0ead" - }, - { - "path": "ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/constant/SystemConstants.java", - "exists": true, - "sha256": "1a79e453ab57f198f7858414b1fd5e57d5c92aa1fc2d3ce07917b95340dc0d68" - }, - { - "path": "ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/enums/UserStatus.java", - "exists": true, - "sha256": "a39f530d41cd3d88c640f886d37d28d14e2fc849198c72c2bd4a868ffa9ae823" - }, - { - "path": "ruoyi-common/ruoyi-common-log/src/main/java/org/dromara/common/log/enums/BusinessType.java", - "exists": true, - "sha256": "fc93efabff03f7d4f1f11dce2804d6a43133b0cad06a612790938cc60f1d2911" - }, - { - "path": "ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/OssClientConfig.java", - "exists": true, - "sha256": "a6e63bdd1fd82d29e0a9726b9aa0b0e96a207c4b117d3481eba729a56e23b0e7" - }, - { - "path": "ruoyi-common/ruoyi-common-redis/src/main/java/org/dromara/common/redis/utils/RedisUtils.java", - "exists": true, - "sha256": "30c5307fe06489ac4b27d24ba4dae2ffbec2268969a752d65c84316c04bbc28f" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/constant/PlatformSourceDb.java", - "exists": true, - "sha256": "ab035c53e16b8ed9dc9373c20a19b5ae7043fd46f9fbc75ce5da7a0582d0c630" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/constant/ReportMetric.java", - "exists": true, - "sha256": "16f08defa82a0a54064c36e6c6c37857eef84d487a9c7150df428a33c7e2b50c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/ChannelDataController.java", - "exists": true, - "sha256": "f97862893745219605954252f8dc82c2ec2ae69da9429b28f7a044a2acf009b0" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/GameDataController.java", - "exists": true, - "sha256": "004855e44ed88b4840c2dd2df4c606a0bea7f9149560dfd62b027569cce396e4" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/PromoteActivePoolController.java", - "exists": true, - "sha256": "577bb406a9b701bfd8442c7a7047d67885c3958d70392204931a152cdbe35c53" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/PromoteController.java", - "exists": true, - "sha256": "a8977e1e5f9b8962f8f7d5132f818bc6913c4bcac8ec63c9cc4ea3da5231ae9a" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/RankController.java", - "exists": true, - "sha256": "e621f595fbe3c0f12d2265ffaf98609f6ad98ce92bc5616c0603efc9143948aa" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/RegisterController.java", - "exists": true, - "sha256": "d3150eb0549d45057082c89927cfa6d4b49cc5f5a4db716f58db8d18ebca4181" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/ServerAnalysisController.java", - "exists": true, - "sha256": "e4ad877b4b7be3c060e182d88db0d5ca3e5d288e209dd8733b10d0c811a5c153" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SpendController.java", - "exists": true, - "sha256": "14707a4adeac9c2b4fe8ea87e794532a6851c5a0a2f28e679a9b2f0b0412454c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysAlertRecordController.java", - "exists": true, - "sha256": "8c82679f89e74408f4c9b784d691cdd4452494e9c55423bffc3f6d6dc7de4fbf" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysAlertRuleController.java", - "exists": true, - "sha256": "cbd4a8a0c2638139831dbbc0e87b85bb38d7145525c7bafba22918fc4bfa7789" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/SysUserController.java", - "exists": true, - "sha256": "e8240b10f44321e22e7b53a3bad1f638b3105a4849282ccaf977325e613cbea9" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/UserPreferenceController.java", - "exists": true, - "sha256": "e1b749146e829c1c574b7161dfd995a4e656c53ed44a755695393b50e67c0d0e" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/controller/system/WorkbenchController.java", - "exists": true, - "sha256": "45306b5be2fe6f1342bd10153e2a0df9077b718021274c5dbe5e3a6d419116a9" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/PlatformTabPromote.java", - "exists": true, - "sha256": "611ffecb45910a8e9622504bd2e7869c5a525dd8839c31b7ed0aa8eb3ee3508a" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysAlertOperationLog.java", - "exists": true, - "sha256": "1a89dd672e7aca6bcfdf927baf9a22cc5c8a9ba796f8cb7720d1c6558159def7" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysAlertRecord.java", - "exists": true, - "sha256": "3450242cebeac918f7e81381976ab7c41ddcf7caac7a91cb4a7ad1a3eda8e657" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysAlertRule.java", - "exists": true, - "sha256": "ddc904bef0a5b80119032cd0faf39275b2e178c8e3144113b2a6bacdecbac4f4" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysPromoteActivePoolBind.java", - "exists": true, - "sha256": "454b78bf9312b24795aea8292b4fdee1e4a9f4a56f170bb192de24c6bbbaf46e" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUser.java", - "exists": true, - "sha256": "ae23a706c92123cb4dbb8f8e7fa0ebacb2b18909e408d807c6854dcf82946f55" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserBindPlatform.java", - "exists": true, - "sha256": "8a0a5b114de04a32f12a91410b8652d3456cc955ca098ea583eb72738bb81cbd" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysUserPreference.java", - "exists": true, - "sha256": "247e3ed364641051fc9ce8687dfdcc3cce229be3c89524382624e84ae128925d" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabDatareportTopPid.java", - "exists": true, - "sha256": "11141feba23835a60053fe78ac5eef6c736e8670c52efacd3d5470cf0a2a4b74" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabGame.java", - "exists": true, - "sha256": "c10d72f022a49bb4c263f3f31a15b628e3e502be21a10ade5b31d3eefcdd5ebf" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabGameServer.java", - "exists": true, - "sha256": "21db3ffbdb6a3096f496b200ff82d40b3c9d1c6ff9f91e849e06e63597f69333" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabPromote.java", - "exists": true, - "sha256": "6e8744abf917de3cd57d32b8c4f79098bb23fc6e0ed985241c53ff66272e43e0" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabPromoteApply.java", - "exists": true, - "sha256": "b2dea3457cbd52bdd541f8d5e5342a8b78a749f50eef0618e882d7eb178f1eff" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabPromoteBusiness.java", - "exists": true, - "sha256": "1a53c44f0163bd7510b132b59eafdb55b6c01220a513729a4ecc051c579252bf" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabPromoteSettlementTime.java", - "exists": true, - "sha256": "ebc92e2b3d940ae178ab8f27ff345bc15299a1134c882c6aedbb1f054649cb3c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabSpend.java", - "exists": true, - "sha256": "6ea8a08f9b6e13571571185622baae3abbdb40c586b9a6a29937aa5af1c4980c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabSwReportDay.java", - "exists": true, - "sha256": "48f6fc23079ea94ce5f922e3137c920fa9dcca5f0ba2ff90328220378839ffe6" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabSwReportDayKey.java", - "exists": true, - "sha256": "8038fb753563e7bb97f5481c6696350d3ed4785dd9a8dc5bc888eacb904187ec" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabSwReportServerDay.java", - "exists": true, - "sha256": "50f4593aa31bb483393b054e9449baabac7e78bf8171f6e53ca6fb3c828f7148" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabSwReportServerDayKey.java", - "exists": true, - "sha256": "86752b4a3dcfbeee4a6b586cb65186ffa93cb4fed579a55652d3177384458d68" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabUser.java", - "exists": true, - "sha256": "bfd41b7f6756ab7e2e616694e9300f785f12928e88b9f94c5f9eec6f15f5afb4" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabUserDayLogin.java", - "exists": true, - "sha256": "32b37251c4e1b31eb27a5b9bed2512e8813f7b2474fe127af220cb5ebb4db338" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/TabUserPlayInfo.java", - "exists": true, - "sha256": "78b8e453926c7beb7634b746af64c39cda2a06ff0243215d7053271603cc1799" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/AlertNoteBo.java", - "exists": true, - "sha256": "b8af0f2adf444059ef5739cdaacdf8d512d7404ff5fcfd871a078bb00020b2f3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/AlertProcessBo.java", - "exists": true, - "sha256": "948e96a3af9bda85acbe70d978fc236e1c5ed2bbe9d9bfc043b3885f016bee36" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/BindGameBusinessBo.java", - "exists": true, - "sha256": "fc710fb637ad18e4a35850a98c2cb22fdbf860ea98072cf4c5d10063e6a1a03b" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/BindPlatformPortraitQueryBo.java", - "exists": true, - "sha256": "2ce4a69f0eb91e75b1427d303360fa392a4b235f123ce59bf2137221906d9b72" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/BusierPair.java", - "exists": true, - "sha256": "c1d30dec62cb8d392091db6f1e392551b382721eab07b4e898737316ac230126" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/BusinessPortraitQueryBo.java", - "exists": true, - "sha256": "a84897fc61ac957183cb1194c39e432334a16d853804a7642cfd78115d43665e" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/ChannelDataQueryBo.java", - "exists": true, - "sha256": "fafd6e00f2130c0bec4a6e369ce1fbe46b433a0fc5786e606b90097f7dc1d39d" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/GameDataQueryBo.java", - "exists": true, - "sha256": "29401e39cbf7b046abb07ef049acfaac9d6c2b57f09e23366b09ab02b4adc316" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/PromoteActivePoolBindQueryBo.java", - "exists": true, - "sha256": "2be3f74e88799ebcd8829222883d539bf8355256cceab0c2c9f100dca23f9f54" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/PromoteAddBo.java", - "exists": true, - "sha256": "b90bde429bf5eadc4082e5553b9407ce3328260837cbb5b796b93b1982fce1fe" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/PromotePortraitQueryBo.java", - "exists": true, - "sha256": "cd5dd236f18b4761219a7ef8f43ba0c049c6f06856c40b17edcd5d31c2ce0bad" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/PromoteQueryBo.java", - "exists": true, - "sha256": "f418449cc11ee4554a1f9f595e146e1082ae328b3b8ee71af216571465b0fc26" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/RankQueryBo.java", - "exists": true, - "sha256": "384629d732ee45575c10e9d0f7fc203a0a816c1428ef1b5d194cb9cb37f12f43" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/RegisterQueryBo.java", - "exists": true, - "sha256": "cfd1fe6146448002681e649514cabd52a16340a8879e2e02b346731672701b2d" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/ReportQueryScope.java", - "exists": true, - "sha256": "0aa28037ee87c7bfae2f611d598455e88165c300d9f3f83dfa84c37050f494d7" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/ReportScopeMode.java", - "exists": true, - "sha256": "ce9965a9e1800fad9f9a0109d1466f9b791d5a7a7adfaee33d04f94183c81314" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/ServerAnalysisGameQueryBo.java", - "exists": true, - "sha256": "c633df1d773f4f121ca9cdffe3409a5a56d745a19385d37b3917de966d1bcb1d" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/ServerAnalysisQueryBo.java", - "exists": true, - "sha256": "7e1747ac38728f756cac1b05564bc4be216b86b30ace227dcc28d12a0a823e19" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/ServerAnalysisServerQueryBo.java", - "exists": true, - "sha256": "4d717b49c11a651936fd581d78c3532a2588eb3007c7bddb7cdd65105f9c28d8" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/ServerReportQueryScope.java", - "exists": true, - "sha256": "ff25be16e8fc41aae8c141085f0e00c2b3fbb681bee2c75e999e0b09cb991d33" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SpendQueryBo.java", - "exists": true, - "sha256": "20d34c0614e8ed36e1daaa0152dcaf7ea01d47e7f21d536643066fb1e4227ff6" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysAlertRecordQueryBo.java", - "exists": true, - "sha256": "61202192f9be390addabbba76db4eb7511d3665d51e1f8ccaff13a7ee59a3e97" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysAlertRuleQueryBo.java", - "exists": true, - "sha256": "e076b3adc548b8c3ef1f81a92ea1cb34f4240a8fc229b8aff81c4b72b49d7a16" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysAlertRuleSaveBo.java", - "exists": true, - "sha256": "19bdaba24b14880377cc4e7cabd538c40954cd702f2a3bf45e61e717ae996827" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserBindPlatformBo.java", - "exists": true, - "sha256": "ad90dcc9a431df873631b9ebc2913ed18ff84c7df37e10ed36c8b4c8c6001a8f" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/SysUserBo.java", - "exists": true, - "sha256": "427dd2c3ba375f966d37dc2ec38c18ce2f7f7f37277f4e05513709e2c8c0b9bf" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/UnbindGameBusinessBo.java", - "exists": true, - "sha256": "3938773c2224801d2e7a86b73c2eb114f44b6a7a31aaa5474964c6d401aaa25d" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/UserPreferenceBo.java", - "exists": true, - "sha256": "c66ebdc507cc83b7efac0c75823a25845e0311649d8d3d47780f367776a00550" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/VisiblePromoteQuery.java", - "exists": true, - "sha256": "f86ddc96dfbacd93fa15ae71e8453edae98e63ba76e687f7f2ed17c32a222795" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/VisiblePromoteScope.java", - "exists": true, - "sha256": "5f2fdd086c79cd474195d6d4123224d419282713ce9a3f6448ed3a8812c342fa" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/VisiblePromoteScopeCacheEntry.java", - "exists": true, - "sha256": "9b7743248926886aca11fa5befdeba896d7d4f2e3fd9189ac216d9da6ae1072e" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/bo/WorkbenchQueryBo.java", - "exists": true, - "sha256": "64a9b48f12fc84131fbadaaade4640c1047e2d56226698162da4969d46b6cffe" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/AlertChannelMetricVo.java", - "exists": true, - "sha256": "2348a4b3e11a6bc1e2f780e079a3b552d013aa407d96d525d45a32cc6af2dedd" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/AlertRecordDetailVo.java", - "exists": true, - "sha256": "1c75bed76e7d984084c216d2af9b4dfeed92e2d24def1fe70dd3fbcc38aea4dd" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/AlertRecordPageVo.java", - "exists": true, - "sha256": "dc4419787cff060b076aaeb7ca89516a5bcd97efa05f7dd371d71b920f46f239" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/BusinessPortraitBasicVo.java", - "exists": true, - "sha256": "9982d42fc5f40aca1fd538f72214f7974c6329e0bfb723dfe6e902ac704a5573" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/BusinessPortraitMetricsVo.java", - "exists": true, - "sha256": "379347988399a9fc1e2420d8798246c00a8d271177b57781f908dea7e45244b2" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/BusinessPortraitTrendPointVo.java", - "exists": true, - "sha256": "34cafaa9aa68dece8f37599f6243c79d10f7558c9688fcb54dbd0dc17f7e7fd2" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/BusinessPortraitVo.java", - "exists": true, - "sha256": "e6f4a11a6696bbb2580b2be654e360fb597a9edd1ae9a69cee7ec40e650f6a90" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/BusinessPromoteVo.java", - "exists": true, - "sha256": "0d941a0ec63532b83d6c5514a9e5da620ca6c607fd4490dee8f3b23a8101194f" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ChannelDataPageVo.java", - "exists": true, - "sha256": "1c0d9acb297f54f016dec93c2e9177b6e90a39b96a728c99c8974a855e737851" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ChannelDataVo.java", - "exists": true, - "sha256": "dc6ad1f4aad8cc2d115f064114fa7b9779ce7a8cb48905f36fa1d430ef04e6ec" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/GameDataPageVo.java", - "exists": true, - "sha256": "238c1ecb5078717d5cd2a1d6c5d5d281d4b62792d77a812635d6077086f31248" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/GameDataVo.java", - "exists": true, - "sha256": "0c7a5f55492d4b7e9a6d65899f769a1482aaad85f2486a400e4acf5c5fc2e56f" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/HourBucketAggVo.java", - "exists": true, - "sha256": "21657e74aec568135c0b4840d619d925a1ec60d008a8a6a4b6cdbc75a8d88e46" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/IdentityPayAggVo.java", - "exists": true, - "sha256": "a5c4188a7744de2c16d250df9c70644e8ba427631229a2f134b5c0dae56c99e6" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/MetricCounts.java", - "exists": true, - "sha256": "48bfc72f4ec1a6d185d55bc509ba0e61344586024a9655e8bb5c534258e388c8" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PlatformBusinessBindingVo.java", - "exists": true, - "sha256": "402cf41c6702af6e9d4f7828594c042ec96f7dccb54ef4254ef71fba7a2ff752" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromoteActivePoolBindSpendDetailVo.java", - "exists": true, - "sha256": "bdcd83f53a3288f4621f776b14abad92579e8fb4aeb7909fe42545cf3a1212cc" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromoteActivePoolBindVo.java", - "exists": true, - "sha256": "dd73859a9044e3bb3b04a32892ec9c45a811b6790a486ed014b343d0d699fc87" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromoteAddResultVo.java", - "exists": true, - "sha256": "7e83646136bb862d34ceaf776c55e2c2e9fe373d0565af5b34d95be5d8e57700" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromoteBindOptionVo.java", - "exists": true, - "sha256": "8167180fa79c6cf2135727f1b2bfa2eeddc9d035bccdc03ad88321e7a141dcc5" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromotePlayerRowVo.java", - "exists": true, - "sha256": "cf1eba2f7537c78578dd2401538a3d003dcdcb6610dc66f2dbfce076af36c089" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromotePortraitBasicVo.java", - "exists": true, - "sha256": "20bf0a14d456cd1b49ed24838ffd5cb3b05f59e1ac9f2800308ef635dba411d4" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromotePortraitGameVo.java", - "exists": true, - "sha256": "a320310c7291485ff19a52f0f1dd1b284eeafbafd0084cd8b33e92565b8c5a3e" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromotePortraitMetricsVo.java", - "exists": true, - "sha256": "9d3bffa4405eaaa2527584e0dfd516e4da693bbccb5bdd78ea79c64ed98dfb8e" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromotePortraitPayTierVo.java", - "exists": true, - "sha256": "58a95c830d663185a1f26360f2a0de1911d068e5d74c3dc4a4cc13e27f851bae" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromotePortraitVo.java", - "exists": true, - "sha256": "1916847880873ff673cedd53fbdf15f6548ae02731bf0ce9f0272d29faa58a65" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/PromoteVo.java", - "exists": true, - "sha256": "d8520435f2e0f71826c45d726e3be588e5089b04c5a69c7215def377715d46f7" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/RankItemVo.java", - "exists": true, - "sha256": "0b19e855d62e34ec985c7c9ace7992e4368bf8e5b9e367765f9de1a7daa5b9bf" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/RankListVo.java", - "exists": true, - "sha256": "33c8fd9fa54d57a5845207acb83e3fa9604dfd271e4c9bad45887be046ab0631" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/RegisterPageVo.java", - "exists": true, - "sha256": "aa0e113ad01aa1a76be74db449b15b98556dfc580b660ee472bba17367625765" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/RegisterVo.java", - "exists": true, - "sha256": "7e8825326516e8c7d64544c27b6f91b693bd64c0e240de55e6efc74709b04f2c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ReportDateMetricCountVo.java", - "exists": true, - "sha256": "a171a8b46ead5df5256dc8d81bf2be1cee2ba69f606ebfd618564140446367e3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ReportDatePayAggVo.java", - "exists": true, - "sha256": "9fb71935128667e6ace209583325deac5bbcc11b920b7b4714d0858448d9a029" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ReportMetricCountVo.java", - "exists": true, - "sha256": "d2595fe4c999633ddbf576c7426cae69085e86b42b84db8d446181c49ad7bfe7" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ReportPayAggVo.java", - "exists": true, - "sha256": "ffba4aa180fd37324d898b93c9051fe5fd24fae627421d56d03a1b52cc74c529" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ReportServerMetricCountVo.java", - "exists": true, - "sha256": "b43cf186f36f509aad60a6091e6ead5f1c7d0006abb77c94ce45eedf3a2238e2" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ReportServerPayAggVo.java", - "exists": true, - "sha256": "727f7014db7382af102d242c3a5276673214b5bf29fc05bbbf44a9e41dd84df5" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ReportSummaryMetricCountVo.java", - "exists": true, - "sha256": "4871ef4df7c1581ae8ed7bdf7a34e26582220ddffd6dbbfaaa4fcaeb8fc3ae5a" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ServerAnalysisGameOptionVo.java", - "exists": true, - "sha256": "e39c23273bbc36cbc507ae622042e99fed5cf47439dbcb94b92d09c10229518c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ServerAnalysisPageVo.java", - "exists": true, - "sha256": "84a86c4b7aaeece2f4d8ea3aa077d8da1ecc8da2870a0e1759a8c5d95621239e" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ServerAnalysisServerOptionVo.java", - "exists": true, - "sha256": "03224103fdbbf275adb23df3d585a0ca561c361c2ba47c9f78a9f99f77b1a03f" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/ServerAnalysisVo.java", - "exists": true, - "sha256": "d05b2d161f67552d77495bd75e7c19aa7b95e88f476e254938645df12d3c359b" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SpendPageVo.java", - "exists": true, - "sha256": "448edad3f32d79a0d98dd697561e75f2f30dbc533f89167676928973f619c24a" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SpendVo.java", - "exists": true, - "sha256": "9ad595303ed6d389cfa4807240365b6b27d7654c47b923cd1e1cb0f2d089625f" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysAlertOperationLogVo.java", - "exists": true, - "sha256": "3dce67f962ea5c978ae140d30170b965e866807dd9579eff80eabdee38918961" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysAlertRecordVo.java", - "exists": true, - "sha256": "46dea0d675c67d180a96009300643f0ce7dd5e457d738855741215d720c91f8d" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysAlertRuleVo.java", - "exists": true, - "sha256": "bf1ee3fc078740dec4df6c3b57da37766b424f6ee792825302542c1c613311b9" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserBindPlatformVo.java", - "exists": true, - "sha256": "03c2c35b8ada0011a7acd7fd18846ed728e8bfb20efb495bca1b7cd4d2023760" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserIdNameVo.java", - "exists": true, - "sha256": "ed14ec83ddb2ea3ede0c6501c971d175528dd64c3f50f72a3f6be261a4fbd6e3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/SysUserVo.java", - "exists": true, - "sha256": "5220f8dbe78e898deb32e5f35cf98806f89035a856a59b667650bdf7cf5d9188" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/UserLevelInfoVo.java", - "exists": true, - "sha256": "4f895ddeaf69a1d05eba569d05f148da744e06ddcbb8919f2fe2fa6113aaba07" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/UserPayAggVo.java", - "exists": true, - "sha256": "aa3ed9c43809a30980f09105e40f0a4b1640639e36dc6dabb40529dc1804217b" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/UserPreferenceVo.java", - "exists": true, - "sha256": "c6021805686f7bd1b44599ad49c735caf491d7e5926592b086be07e1a644bebe" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/WorkbenchMetricCardVo.java", - "exists": true, - "sha256": "90d560e5c214104de5896958335e130ef010d77f8326f2df70f30c30dd946375" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/WorkbenchRankItemVo.java", - "exists": true, - "sha256": "d59a73d3c6007f5acb33b671a0be74ee22f866b5477c9cb4e397e4a355a41a13" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/vo/WorkbenchSummaryVo.java", - "exists": true, - "sha256": "ba29598f64d945be5474e9a4e5b94712f23b56e6608ef4e9846483fdff7c1753" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/enums/AlertActionEnum.java", - "exists": true, - "sha256": "6ee7eee5209fdd922be8ea443195a19a1da38552ec12fa31d80f8959937ecdee" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/enums/AlertLogType.java", - "exists": true, - "sha256": "a037137ad596d3c27b47a819f2bad818a18dfbfd78018e3314892fad9d9074b4" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/enums/AlertStatus.java", - "exists": true, - "sha256": "145f4b685dcc0828f66af74d062ff7969898b0efc4b125401a6e9819aaf04622" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/enums/MomDirection.java", - "exists": true, - "sha256": "885904dfab18f550a1ecd94b4920e327725c8d66f3fda9e8544986293bc1373a" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/enums/MonitorMetric.java", - "exists": true, - "sha256": "b435003b219fd7b02cdc437109399dbdd7e2e8e09017f047696510bc18cbd927" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/enums/RankMetricEnum.java", - "exists": true, - "sha256": "73aa171f970a039d3a7e91ca78c18709a0dd8bcaf44bf09e046237390ff586e9" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/enums/RuleType.java", - "exists": true, - "sha256": "34d6708afc9beee427f1d4ced7dc2be7d0bb9df35d2253ef2db08cb6d0527b7c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/job/AlertJobExecutor.java", - "exists": true, - "sha256": "c674c0566184ca67c903743d08b0303aa6e1d777a4e2ef49f6a263765c3a95c3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/job/SwReportDayJobExecutor.java", - "exists": true, - "sha256": "fbcd04515c0a242beb459faf6e3ba37d2de26d8d74b4c4f7b503189c7cde9725" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/job/SwReportServerDayJobExecutor.java", - "exists": true, - "sha256": "e6bda283d8c3257c9c4a1a3908abfeb924a8fe597884dbe782782db65b2dae8a" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/PlatformTabPromoteMapper.java", - "exists": true, - "sha256": "69fb83f93d1027450a4aae1679b60363f1b5f05f362191e4a2521aa83eac2975" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysAlertOperationLogMapper.java", - "exists": true, - "sha256": "4cdfa9906ba92bb4fd3eaec3756be7d154b9ff17e0b08764a836fc2b99e42f62" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysAlertRecordMapper.java", - "exists": true, - "sha256": "99659f7f80c1c46c027a56bb0323ad6ba3a36a35577124978d03fd32eb0a49f4" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysAlertRuleMapper.java", - "exists": true, - "sha256": "bd0ebf36323c3e32ec3ee98ddae152062d5ce5b6a3c5c39b674882928e0d8f73" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysPromoteActivePoolBindMapper.java", - "exists": true, - "sha256": "d47cc3a3681c13a32ba5a470bcdbef3d8c77d9ecf52d505868825131fd00a2e8" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserBindPlatformMapper.java", - "exists": true, - "sha256": "5a67aa777be291ca90407a619cd710dde9a8b95f3d3feedbc7c3b74e803e4ddd" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/SysUserPreferenceMapper.java", - "exists": true, - "sha256": "787f357f80f395b6ab8fed7c095ecc648996644e3b8c6898dae9b80f063b25c3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabDatareportTopPidMapper.java", - "exists": true, - "sha256": "5a80491c499b45729226f3db0976af1ff2aae7df114841772ef6a3bc30c84f6c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabGameMapper.java", - "exists": true, - "sha256": "ec9610c7a8d6a7de6a4bbf19f57865260455678b88dd3a5d329911791d3c1eaf" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabGameServerMapper.java", - "exists": true, - "sha256": "66ce5cfcdf8cc6285bc77fb286ca9800e1e3be353814fc65c00da025b0a970c2" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabPromoteApplyMapper.java", - "exists": true, - "sha256": "efe03a89e929cd7e6ba09ad2e2cb9b3f35fa757f33816cfff5e8600b8f4db36b" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabPromoteBusinessMapper.java", - "exists": true, - "sha256": "a3a0ed3315ea3fc867ec44c4ee58e12769a8af3bf56ce2f6a589b698f682a326" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabPromoteMapper.java", - "exists": true, - "sha256": "86014c512da235c4c6bcc0dc2484025b02da3b222141850b258a300a99900114" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabPromoteSettlementTimeMapper.java", - "exists": true, - "sha256": "061346767e6a515e9c18543331636980cb81231794735612c00c0a9b031288da" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabSpendMapper.java", - "exists": true, - "sha256": "a03ebaf0dd0cd1eb163226c369e9ca1c9f280b86ed043c18da3ab32bc7782a2b" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabSwReportDayKeyMapper.java", - "exists": true, - "sha256": "8e4dfc3cf907b85750ec681a2d9775f5a583e468f0a5d9af439780daef43f736" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabSwReportDayMapper.java", - "exists": true, - "sha256": "0a1585edf552e085006a415cca6fe03db969ad861ca8c8c7bb997f893181aea4" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabSwReportServerDayKeyMapper.java", - "exists": true, - "sha256": "e8a32dec54e3fbc17d04a4cc437e2451dbc7c7b45ce31de8d31757299d6620ac" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabSwReportServerDayMapper.java", - "exists": true, - "sha256": "89caefcf96732fa9ddbbe38f7a354532c8d31c9f1e97404dc62f725c97852d1e" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabUserDayLoginMapper.java", - "exists": true, - "sha256": "5ad4cf6914d94f0a1c199e364e96b2bfb2c7d820feae9b52cd69657b4f1bbe43" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabUserMapper.java", - "exists": true, - "sha256": "1b96e27ca3a0a932cff007470cc8f9e6e643a6c93639e058fe42ddb7ff9fb5ab" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/mapper/TabUserPlayInfoMapper.java", - "exists": true, - "sha256": "03ae07fc169541084abc20ded43b23eb55d17335e4737060cc5d487ff482e698" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IAlertTriggerService.java", - "exists": true, - "sha256": "b1ee13a6762022f83acee5288f6b8f1e724457610bc812871f3e65b10c66ea18" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IBusinessBindScopeService.java", - "exists": true, - "sha256": "e1f38ff280e2c74ab6d78afbf27929a88b8b670dbb1aff17b8ec8b43ea7c1e68" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IBusinessPortraitService.java", - "exists": true, - "sha256": "5a230ef1f14bcbd41938fce2d3d12f2e66e60ec7b8e153e5e3ba5b7560bdaf9d" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IBusinessPromoteService.java", - "exists": true, - "sha256": "6faa1fe23302d1a64318dcda691aa0df7f2b361c3cb5f76a396092eb72fb0787" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IChannelDataService.java", - "exists": true, - "sha256": "b3f77910e9b2ae870c32920c0736243c61e17cf48e5d95c01f71595e2a02ead3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IGameDataService.java", - "exists": true, - "sha256": "b2a840af847acd8f411e25c8d71db19ce3f36b37a37a75b45f556d3ecb481b5f" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IPlatformPromoteBusinessService.java", - "exists": true, - "sha256": "517d7c1d3860d064a575690e8bf98158d8dc830c948de0c19e4d87f4536d4384" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IPlatformPromoteWriteService.java", - "exists": true, - "sha256": "4bdbbd17536ee874f040205af0e88963868d4b75655860a6f7980ab54f3f800a" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IPromoteActivePoolBindService.java", - "exists": true, - "sha256": "2df5d3b3d102a48faa2b721d1f9ce3315a85c54e93864d7f74a80af77062e16b" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IPromotePortraitService.java", - "exists": true, - "sha256": "5677cbe0a33bc19aff7cb35b3fc405613f9f0957ab6d90eb26973d989eaba859" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IPromoteService.java", - "exists": true, - "sha256": "d4f4f5c1a9612cc8c9c4dac72f4e2e67298a138c910bc7f49b52d17bcec4153c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IRankService.java", - "exists": true, - "sha256": "80af79f9056ee8f7fc754745aa9c8d4327b6c7c63e8834a085eb031f28c0c7f7" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IRegisterService.java", - "exists": true, - "sha256": "da5db13b026ce66bd54eac65e93edcd1b27785fcccb804f968c655ac6875f8a2" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IServerAnalysisService.java", - "exists": true, - "sha256": "204b86c2d89a717790c59ab25a53dda70544c2835c46bc11ee2e677f0784d65d" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISpendService.java", - "exists": true, - "sha256": "f140cfda53cfe0d7f0fe07d699f05f1bded6276525fe135c3adc440f89276a77" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISwReportDayBuildService.java", - "exists": true, - "sha256": "ea7d9073ab6273e594f5fefc10eace2b44177aa109cd29bb31c94d7bebb345f8" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISwReportDayQueryService.java", - "exists": true, - "sha256": "5b18090738ddc581104675739b716819ad7540ef1a342a3eea5b6b6a0786f275" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISwReportServerDayBuildService.java", - "exists": true, - "sha256": "a5adccaa33c65275d0587385b7d8dd24857f169770340b8bf1a4c17f629833ee" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISwReportServerDayQueryService.java", - "exists": true, - "sha256": "88a103695596ee02357290953bb75d78a43e244c6890d4f811ad3db55fa77473" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysAlertOperationLogService.java", - "exists": true, - "sha256": "c0a15d0c0b070695cb43960256a2069db7c645618f8b515888348f6838bc4aff" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysAlertRecordService.java", - "exists": true, - "sha256": "6a9e7bb433fb0dc9d7899c7944e896bb9bb473d82e4b9464a628be3c65947da3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysAlertRuleService.java", - "exists": true, - "sha256": "ac079a3eddbf671585e913892dd46735cc8fc06b4392e60683aab30e59c45e3c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/ISysUserService.java", - "exists": true, - "sha256": "8b9ac396056b160d2e55b58295c673c40a7ddf027aea449cd6f69b52f3c882f1" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IUserPreferenceService.java", - "exists": true, - "sha256": "e320521b57ad3abde37abf76c3c90a685bb516d8d0067a98604bee9e31a74820" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/IWorkbenchService.java", - "exists": true, - "sha256": "ac1a5e0e872b78371a82cb007077448aa9208834ffa77f37e30931612d5c6bd3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/AlertRecordCreationService.java", - "exists": true, - "sha256": "8b505a0032f78fa6726a3f64c8db0660acce6102d29295c9fef345db92c6a363" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/AlertTriggerServiceImpl.java", - "exists": true, - "sha256": "1475f31b5e8a95d56787bdb0465a6ffdd141ffb2c8bbeed8302b962bf2c592b9" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/BusinessBindScopeServiceImpl.java", - "exists": true, - "sha256": "0daa60e7139bf6b0446790225cc6a08488e0d7899d145eb99e309e9b6e90e5f7" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/BusinessPortraitServiceImpl.java", - "exists": true, - "sha256": "81d19a89c501d886a0072c5a7c5126fd0dffc0662a50060b53eb5681a2d3334b" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/BusinessPromoteServiceImpl.java", - "exists": true, - "sha256": "83bfef518d39c14864203de9690b4cce254331b9ca9de74351c914cb8f9f7a37" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/ChannelDataServiceImpl.java", - "exists": true, - "sha256": "a7b58297b1a0161d8a2ab79d7be122c8f241a124686d0ac2b4de26e934249932" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/GameDataServiceImpl.java", - "exists": true, - "sha256": "f2cf2a51b5d233bc58f5c84f3b58e02546978f3b24c69dfdbc279810dec7c5cf" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/PlatformPromoteBusinessServiceImpl.java", - "exists": true, - "sha256": "06fcc8fcc8be5eb9d2bbfe7170a147784e69e7f4f2087a6f6415d1193afcc412" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/PlatformPromoteWriteServiceImpl.java", - "exists": true, - "sha256": "59bf630f5d58685df83630963da5650408b623471d27f85d903f62050a8171b5" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/PromoteActivePoolBindOaHelper.java", - "exists": true, - "sha256": "932a0c14fb7b24a9810a0ea77b91441dcc48526b523ddcf643c8d0c8cd239d69" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/PromoteActivePoolBindServiceImpl.java", - "exists": true, - "sha256": "28ce522f5575b24bc623fd8d6d7b6de6c76ff556aac6f8535dc2fd1cba30fc49" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/PromotePortraitServiceImpl.java", - "exists": true, - "sha256": "3851f69c4f01e780c69193678445b87a11261195e7266bac69306ab71599a1bd" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/PromoteServiceImpl.java", - "exists": true, - "sha256": "70f58cd78caa4bb8424d931bb97143579d52712a76024fe360299e89536ff7f0" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/RankServiceImpl.java", - "exists": true, - "sha256": "8b7a12e21c71b00bf38aa7738682f86e3d4dbc3aa4a5fb6918518a93bfbcc48e" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/RegisterServiceImpl.java", - "exists": true, - "sha256": "cd0a056bbfb6a9557781c2f84a06ed0471e7c94d0d327fc85fd03ec1e40bc6e1" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/ServerAnalysisServiceImpl.java", - "exists": true, - "sha256": "1641ea177e770cf9de7379f8fa21078d939a564f4bb7ea720b4c3433a167b6a3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SpendServiceImpl.java", - "exists": true, - "sha256": "6d20b8f8a145998e7848ed0ec99f791f6c70dd96335a23d451999655e87e89d2" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SwReportDayBuildServiceImpl.java", - "exists": true, - "sha256": "a04680802cb0b21df0d55c7b10d77056f87f5b211076068da57672618216155a" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SwReportDayQueryServiceImpl.java", - "exists": true, - "sha256": "9b6192e630785bd350fcbe2bc32f516ae51dadfef663686b0c6261f8a1ae4007" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SwReportServerDayBuildServiceImpl.java", - "exists": true, - "sha256": "1643199acae662eabe8e174d62d1b5ea68bc7b81390f51f9e5ce745b16e56639" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SwReportServerDayQueryServiceImpl.java", - "exists": true, - "sha256": "26406a9b8e7cc931b192b56586bb93264c2b6624afc15db59451da8ef3e0dbdc" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysAlertOperationLogServiceImpl.java", - "exists": true, - "sha256": "b32680d088c5108f41e841e2bfd54ce927728afc6066fe6c879d3100e9fc8e42" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysAlertRecordServiceImpl.java", - "exists": true, - "sha256": "5d2a648f4893d823361c223bd53d7dee5fe306b95a4c7c481d0d32bf96579f04" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysAlertRuleServiceImpl.java", - "exists": true, - "sha256": "c3d5c5135704b7aa0e65d96f756c03106c7b0aef7c4fa2db0b4164746a39fe05" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserBindPlatformWriteHelper.java", - "exists": true, - "sha256": "07faffffa7d154a8ade4527173d74bf9cb6a7a3d0c9a2c54907339611a956922" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java", - "exists": true, - "sha256": "65a476561bc17b05c3dbeb60e184a650888cd33970a773dddd30208f2b939b82" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/UserPreferenceServiceImpl.java", - "exists": true, - "sha256": "9eb3f75cede56b901765e3711398ceee80554d2601e9ca5b8826f04d38e018ee" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/WorkbenchServiceImpl.java", - "exists": true, - "sha256": "4175dedb22d4d429a3b5da2d72688d1472449e62aa0e449cb83081ba707e095b" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/BusierAccountFilters.java", - "exists": true, - "sha256": "15115fd94698ddb1a77848e75934989c14bf004ef30329b3484507af108cda22" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/BusinessPortraitSupport.java", - "exists": true, - "sha256": "cdfd7728d2247b94424c66773ee5783a5b283055eab871adafda2459021e2b0c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/ChannelTreeMetricsSupport.java", - "exists": true, - "sha256": "7e855488863ab077630c2a7ab31df37c08bff198726ca42e104550b95750d336" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/PlatformBusinessPortraitSupport.java", - "exists": true, - "sha256": "970186190c9bffab766235995839ae55e52e02515b451af44e5d9124b881c972" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/PromoteDisplayNames.java", - "exists": true, - "sha256": "bb4f417bd4d45970a6bc21d86d1dccd4f4c68a8ddf524129632c36e3e116a48f" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/PromoteListMetricsSupport.java", - "exists": true, - "sha256": "e5db85e77cf3b694bd3f10f75378d97fe472ae027315fdcb4e95daa1ab7bbc6c" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/PromotePortraitScopeHelper.java", - "exists": true, - "sha256": "0f3aa89615e05c194902c3c0997bcc8c96597f2f81aa6f1488ae85034a321d26" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/PromoteScopeFilters.java", - "exists": true, - "sha256": "4e59825b83753ed0c0c0d17f36fc4c6aa62705e37c6ad6ffaf4da33846d1fa42" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/ReportBuildRangeSupport.java", - "exists": true, - "sha256": "40cc21c7156d5baf07c3e8d5ca929311da75c8b8ff94d33392cef08db7306418" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/ReportIdentityHelper.java", - "exists": true, - "sha256": "4e74db2a682a95090e7c258d0099e8af5a8dfd3945f7db91e12601cf6786427d" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/ReportQueryFilters.java", - "exists": true, - "sha256": "703a2ab1ab66232daf91ef86005ed70b901b011ed09196b7269395846807b105" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/ReportQueryScopes.java", - "exists": true, - "sha256": "7ef13a45f1fe7f5be715ce870287bf67f773261915fa48e2b264c98002f91dcf" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/VisiblePromoteScopeCacheKeys.java", - "exists": true, - "sha256": "726829693a65d8743d3fd4875659b5f7dd73de35eecdbcca5cf641bdbe3ab4db" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/util/VisiblePromoteScopeLoader.java", - "exists": true, - "sha256": "9deff24523683b511aaa7cdf2e292ceb31eb0962fb840aa2373b705059e1f0d3" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/utils/PromoteAccountRules.java", - "exists": true, - "sha256": "80838e35e55909cfec7e0df5eb63bfdad181237fcb68b529fce62aa88e601131" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/utils/XiguPasswordUtils.java", - "exists": true, - "sha256": "11e4de4438426e1656c297c3b1ae9581113c9b1b5ca85fbec25634c00dfa1c28" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/TabSwReportDayKeyMapper.xml", - "exists": true, - "sha256": "d77a43aa7713860a44adff9a955e62acf2831c21b5f68b552ddd17dd1ceb4d17" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/TabSwReportDayMapper.xml", - "exists": true, - "sha256": "7ad9513aa0a91b2a5fa512048314b8e3b10e053d005cedb433e169e3d115ab1f" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/TabSwReportServerDayKeyMapper.xml", - "exists": true, - "sha256": "3843980ad081613a4d28131d501ff952b62932c5ad20cead84a72321e5668fe9" - }, - { - "path": "ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/TabSwReportServerDayMapper.xml", - "exists": true, - "sha256": "c9eb902446ea88f1194d89b364da524950c6020a2575c1b39c6b7d38d9277b50" - } - ], - "additional_controllers": [ - "ruoyi-admin/src/main/java/org/dromara/web/controller/ForgotPasswordController.java" - ] -} diff --git a/docs/sw6-to-kra/sw8-sw9-recheck-summary-2026-11-04.json b/docs/sw6-to-kra/sw8-sw9-recheck-summary-2026-11-04.json new file mode 100644 index 0000000..4eb9269 --- /dev/null +++ b/docs/sw6-to-kra/sw8-sw9-recheck-summary-2026-11-04.json @@ -0,0 +1,558 @@ +{ + "date": "2026-11-04", + "sourceDatabase": "sw8", + "targetDatabase": "sw9", + "ports": { + "legacy": 18080, + "newRecheck": 19002, + "newDevelopment": 8000 + }, + "businessContractsVerified": 51, + "legacyFrameworkAliasesReplaced": 12, + "readComparisonCases": 104, + "businessTasksMigrated": 7, + "businessTasksEnabled": 0, + "checks": 230, + "reports": [ + { + "file": "sw8-sw9-recheck-account-create-2026-11-04.json", + "checks": 10, + "passed": true + }, + { + "file": "sw8-sw9-recheck-account-write-2026-11-04.json", + "checks": 21, + "passed": true + }, + { + "file": "sw8-sw9-recheck-admin-2026-11-04.json", + "checks": 36, + "passed": true + }, + { + "file": "sw8-sw9-recheck-exports-2026-11-04.json", + "checks": 2, + "passed": true + }, + { + "file": "sw8-sw9-recheck-level3-2026-11-04.json", + "checks": 15, + "passed": true + }, + { + "file": "sw8-sw9-recheck-pagination-2026-11-04.json", + "checks": 19, + "passed": true + }, + { + "file": "sw8-sw9-recheck-preference-matrix-2026-11-04-sub0-dedup0.json", + "checks": 4, + "passed": true + }, + { + "file": "sw8-sw9-recheck-preference-matrix-2026-11-04-sub0-dedup1.json", + "checks": 4, + "passed": true + }, + { + "file": "sw8-sw9-recheck-preference-matrix-2026-11-04-sub1-dedup0.json", + "checks": 4, + "passed": true + }, + { + "file": "sw8-sw9-recheck-preference-matrix-2026-11-04-sub1-dedup1.json", + "checks": 4, + "passed": true + }, + { + "file": "sw8-sw9-recheck-preference-matrix-2026-11-04.json", + "checks": 14, + "passed": true + }, + { + "file": "sw8-sw9-recheck-sdk-write-2026-11-04.json", + "checks": 35, + "passed": true + }, + { + "file": "sw8-sw9-recheck-task-validation-2026-11-04.json", + "checks": 8, + "passed": true + }, + { + "file": "sw8-sw9-recheck-tasks-2026-11-04.json", + "checks": 7, + "passed": true + }, + { + "file": "sw8-sw9-recheck-testlevel1-2026-11-04.json", + "checks": 6, + "passed": true + }, + { + "file": "sw8-sw9-recheck-testlevel2-2026-11-04.json", + "checks": 6, + "passed": true + }, + { + "file": "sw8-sw9-recheck-testlevel3-2026-11-04.json", + "checks": 6, + "passed": true + }, + { + "file": "sw8-sw9-recheck-write-2026-11-04.json", + "checks": 29, + "passed": true + } + ], + "coverage": [ + { + "method": "GET", + "source_path": "/system/user/preference", + "target_path": "/sw/user/preference", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "PUT", + "source_path": "/system/user/preference", + "target_path": "/sw/user/preference", + "status": "verified", + "evidence": "sw8-sw9-recheck-preference-matrix-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/promote/list", + "target_path": "/sw/promote/list", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/promote/portrait", + "target_path": "/sw/promote/portrait", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/promote/game-distribution", + "target_path": "/sw/promote/game-distribution", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/promote/bindOptions", + "target_path": "/sw/promote/bindOptions", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/promote", + "target_path": "/sw/promote", + "status": "verified", + "evidence": "sw8-sw9-recheck-sdk-write-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/promoteActivePool/bindList", + "target_path": "/sw/promoteActivePool/bindList", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/promoteActivePool/bindSpendDetail", + "target_path": "/sw/promoteActivePool/bindSpendDetail", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/workbench/summary", + "target_path": "/sw/workbench/summary", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/workbench/registerTop", + "target_path": "/sw/workbench/registerTop", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/workbench/payRank", + "target_path": "/sw/workbench/payRank", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/workbench/unloginTop", + "target_path": "/sw/workbench/unloginTop", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/rank/channel", + "target_path": "/sw/rank/channel", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/rank/business", + "target_path": "/sw/rank/business", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/rank/game", + "target_path": "/sw/rank/game", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/gameData/list", + "target_path": "/sw/gameData/list", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/channelData/list", + "target_path": "/sw/channelData/list", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/register/list", + "target_path": "/sw/register/list", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/register/export", + "target_path": "/sw/register/export", + "status": "verified", + "evidence": "sw8-sw9-recheck-exports-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/spend/list", + "target_path": "/sw/spend/list", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/spend/export", + "target_path": "/sw/spend/export", + "status": "verified", + "evidence": "sw8-sw9-recheck-exports-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/serverAnalysis/games", + "target_path": "/sw/serverAnalysis/games", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/serverAnalysis/servers", + "target_path": "/sw/serverAnalysis/servers", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/serverAnalysis/list", + "target_path": "/sw/serverAnalysis/list", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/alertRule/list", + "target_path": "/sw/alertRule/list", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/alertRule/ruleTypes", + "target_path": "/sw/alertRule/ruleTypes", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/alertRule/{id}", + "target_path": "/sw/alertRule/:id", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/alertRule/add", + "target_path": "/sw/alertRule/add", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "PUT", + "source_path": "/system/alertRule/edit", + "target_path": "/sw/alertRule/edit", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "DELETE", + "source_path": "/system/alertRule/{id}", + "target_path": "/sw/alertRule/:id", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "PUT", + "source_path": "/system/alertRule/toggle/{id}", + "target_path": "/sw/alertRule/toggle/:id", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/alertRecord/list", + "target_path": "/sw/alertRecord/list", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/alertRecord/detail/{id}", + "target_path": "/sw/alertRecord/detail/:id", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/alertRecord/startProcess", + "target_path": "/sw/alertRecord/startProcess", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/alertRecord/submitAudit", + "target_path": "/sw/alertRecord/submitAudit", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/alertRecord/approve", + "target_path": "/sw/alertRecord/approve", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/alertRecord/reject", + "target_path": "/sw/alertRecord/reject", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/alertRecord/reProcess", + "target_path": "/sw/alertRecord/reProcess", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/alertRecord/addNote", + "target_path": "/sw/alertRecord/addNote", + "status": "verified", + "evidence": "sw8-sw9-recheck-write-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/user/list", + "target_path": "/sw/business/list", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/user/{userId}/promotes", + "target_path": "/sw/business/:userId/promotes", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/user/{userId}/portrait", + "target_path": "/sw/business/:userId/portrait", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/user/{userId}/bindPlatform", + "target_path": "/sw/business/:userId/bindPlatform", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/user/bindPlatform", + "target_path": "/sw/business/bindPlatform", + "status": "verified", + "evidence": "sw8-sw9-recheck-sdk-write-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/user/bindPlatform", + "target_path": "/sw/business/bindPlatform", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "GET", + "source_path": "/system/user/bindPlatform/portrait", + "target_path": "/sw/business/bindPlatform/portrait", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "DELETE", + "source_path": "/system/user/bindPlatform", + "target_path": "/sw/business/bindPlatform", + "status": "verified", + "evidence": "sw8-sw9-recheck-sdk-write-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/user/export", + "target_path": "/sw/business/export", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "POST", + "source_path": "/system/user/importData", + "target_path": "/sw/business/importData", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "GET", + "source_path": "/system/user/", + "target_path": "/sw/business", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "GET", + "source_path": "/system/user/{userId}", + "target_path": "/sw/business/:userId", + "status": "verified", + "evidence": "sw8-sw9-recheck-admin-2026-11-04.json" + }, + { + "method": "POST", + "source_path": "/system/user", + "target_path": "/sw/business", + "status": "verified", + "evidence": "sw8-sw9-recheck-account-create-2026-11-04.json" + }, + { + "method": "PUT", + "source_path": "/system/user", + "target_path": "/sw/business", + "status": "verified", + "evidence": "sw8-sw9-recheck-account-write-2026-11-04.json" + }, + { + "method": "DELETE", + "source_path": "/system/user/{userIds}", + "target_path": "/sw/business/:userIds", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "GET", + "source_path": "/system/user/optionselect", + "target_path": "/sw/business/optionselect", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "PUT", + "source_path": "/system/user/resetPwd", + "target_path": "/sw/business/resetPwd", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "PUT", + "source_path": "/system/user/changeStatus", + "target_path": "/sw/business/changeStatus", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "GET", + "source_path": "/system/user/unlock/{userId}", + "target_path": "/sw/business/unlock/:userId", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "GET", + "source_path": "/system/user/authRole/{userId}", + "target_path": "/sw/business/authRole/:userId", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "PUT", + "source_path": "/system/user/authRole", + "target_path": "/sw/business/authRole", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "GET", + "source_path": "/system/user/deptTree", + "target_path": "/sw/business/deptTree", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + }, + { + "method": "GET", + "source_path": "/system/user/list/dept/{deptId}", + "target_path": "/sw/business/list/dept/:deptId", + "status": "native_framework_replacement", + "note": "not a legacy wire-protocol alias" + } + ], + "explicitDifferences": [ + "Pay-rate ranking uses user-requested relative percentage growth instead of percentage-point change.", + "Only the creation times of three explicitly identified HTTP-created test users are excluded; original migrated users remain strict.", + "Legacy MyBatis returns page one on an out-of-range page; new service returns an empty page. Valid pages match.", + "Disabled/pending/rejected users cannot use existing sessions in the new service; old service permits this." + ], + "passed": true +} diff --git a/docs/sw6-to-kra/sw_to_kra_migrate.sql b/docs/sw6-to-kra/sw_to_kra_migrate.sql new file mode 100644 index 0000000..b5640b8 --- /dev/null +++ b/docs/sw6-to-kra/sw_to_kra_migrate.sql @@ -0,0 +1,1768 @@ +-- SW → Kra 系统框架迁移统一入口,目标库由下方 USE 配置。 +-- 新副本:备份源表、创建 Kra 表、转换框架数据及关联,并归档旧表。 +-- 已迁移副本:自动执行菜单调整,不重新导入账号,不重建已存在的表。 +-- 检测到部分迁移状态时立即停止,防止覆盖未完成的数据。 +-- 首次迁移要求 26 张源表已存在;支持包含真实规则、预警和日志的完整业务副本。 +-- 本脚本不包含账号明文、密码哈希或连接凭据;原始业务大表保留在目标副本中。 +-- 请使用支持 DELIMITER 的 MySQL 客户端,在同一连接执行,遇错停止,不使用 --force。 +-- 工作台复用仪表盘;商务管理、数据分析、预警中心依次排在仪表盘下方。 +-- 与当前 Go 版本配套使用;启动时追加业务锁表等增量迁移,导入的全部任务保持停用。 +-- 已迁移分支不依赖已清理的旧源表或备份表;执行前请备份当前菜单及角色元数据。 +-- 唯一的库名配置:当前测试库填 sw9,正式库填 sw6;连接地址由数据库客户端选择。 +-- MySQL 5.7 不支持 USE @变量,因此只修改下一行,正文自动使用当前数据库。 +USE `sw9`; +SET @sw_kra_target_db = DATABASE(); +SET NAMES utf8mb4; +SET SESSION time_zone='+08:00'; +DELIMITER $$ +DROP PROCEDURE IF EXISTS sw_kra_migrate_20261103$$ +CREATE PROCEDURE sw_kra_migrate_20261103() +migration_run: BEGIN +DECLARE v_actual BIGINT DEFAULT 0; +DECLARE v_expected BIGINT DEFAULT 0; +DECLARE v_iter INT DEFAULT 0; +DECLARE v_lock INT DEFAULT 0; +DECLARE v_lock_name VARCHAR(64); +DECLARE v_dashboard BIGINT UNSIGNED DEFAULT NULL; +DECLARE v_workbench BIGINT UNSIGNED DEFAULT NULL; +DECLARE EXIT HANDLER FOR SQLEXCEPTION +BEGIN + ROLLBACK; + IF v_lock=1 THEN DO RELEASE_LOCK(v_lock_name); END IF; + RESIGNAL; +END; +IF DATABASE() IS NULL OR @sw_kra_target_db IS NULL OR NOT (BINARY DATABASE() <=> BINARY @sw_kra_target_db) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前数据库与配置不一致'; +END IF; +-- 按目标库生成迁移锁,避免不同数据库互相阻塞,名称不超过 MySQL 的 64 字符限制。 +SET v_lock_name=CONCAT('sw_to_kra:',MD5(DATABASE())); +SELECT GET_LOCK(v_lock_name,10) INTO v_lock; +IF COALESCE(v_lock,0)<>1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Migration lock unavailable'; END IF; +-- 一、已迁移库:确认目标表完整、迁移账本已完成,再调整菜单。 +IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='sys_users') THEN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name IN ('sys_schema_migrations','sys_users','sys_authorities','sys_base_menus','sys_user_authority','sys_authority_menus','sys_base_menu_parameters','sys_apis','sys_ignore_apis','sys_authority_apis','casbin_rule','sys_base_menu_btns','sys_authority_btns','sys_departments','sys_positions','sys_user_departments','sys_user_positions','sys_authority_departments','sys_dictionaries','sys_dictionary_details','sys_params','sys_api_tokens','jwt_blacklists','sys_security_config','sys_versions','sys_export_templates','sys_export_template_condition','sys_export_template_join','sys_operation_records','sys_login_logs','sys_data_access_logs','sys_error','media_file_upload_and_downloads','media_attachment_category','media_uploads','media_upload_chunks','sys_announcements','sys_integration_configs','sys_timed_tasks','sys_timed_task_logs','pay_orders','pay_events','sys_user_bind_platform','sys_user_preference','sys_alert_rule','sys_alert_record','sys_alert_operation_log','sys_message','sw_kra_id_map') AND engine='InnoDB')<>49 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Kra target tables incomplete; refusing partial migration'; + END IF; + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='sw_kra_state') THEN + IF NOT EXISTS(SELECT 1 FROM sw_kra_state WHERE id=1 AND phase='completed') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Previous migration not completed'; + END IF; + ELSEIF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='sys_user') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy users remain without completed migration state'; + END IF; + START TRANSACTION; + IF (SELECT COUNT(*) FROM sys_base_menus WHERE name='dashboard' AND parent_id=0 AND deleted_at IS NULL)<>1 + OR (SELECT COUNT(*) FROM sys_base_menus WHERE name='sw_workbench')>1 + OR (SELECT COUNT(DISTINCT name) FROM sys_base_menus WHERE parent_id=0 AND deleted_at IS NULL + AND name IN ('sw_BusMgmt','sw_DataAnalysis','sw_AlertCenter'))<>3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Required menu identities differ'; + END IF; + SELECT id INTO v_dashboard FROM sys_base_menus WHERE name='dashboard' AND parent_id=0 AND deleted_at IS NULL FOR UPDATE; + SET v_workbench=(SELECT id FROM sys_base_menus WHERE name='sw_workbench'); + IF EXISTS(SELECT 1 FROM sys_base_menus WHERE parent_id=v_workbench) + OR EXISTS(SELECT 1 FROM sys_base_menu_btns WHERE sys_base_menu_id=v_workbench AND name<>'sw:workbench:query') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Workbench has unexpected children/buttons'; + END IF; + + -- 先补齐仪表盘授权,再移除重复工作台;已有授权不重复插入。 + INSERT INTO sys_authority_menus(sys_authority_authority_id,sys_base_menu_id) + SELECT a.authority_id,v_dashboard FROM sys_authorities a + WHERE (a.default_router IN ('sw_workbench','dashboard') OR EXISTS( + SELECT 1 FROM sys_authority_menus x WHERE x.sys_authority_authority_id=a.authority_id AND x.sys_base_menu_id=v_workbench)) + AND NOT EXISTS(SELECT 1 FROM sys_authority_menus x WHERE x.sys_authority_authority_id=a.authority_id AND x.sys_base_menu_id=v_dashboard); + UPDATE sys_authorities SET default_router='dashboard' WHERE default_router='sw_workbench'; + + -- 删除工作台页面、查询按钮及其关联,保留业务接口和查询权限。 + DELETE x FROM sys_authority_btns x LEFT JOIN sys_base_menu_btns b ON b.id=x.sys_base_menu_btn_id + WHERE x.sys_menu_id=v_workbench OR b.sys_base_menu_id=v_workbench; + DELETE FROM sys_authority_menus WHERE sys_base_menu_id=v_workbench; + DELETE FROM sys_base_menu_parameters WHERE sys_base_menu_id=v_workbench; + DELETE m FROM sw_kra_id_map m LEFT JOIN sys_base_menu_btns b ON b.id=m.new_id + WHERE m.entity='menu' AND (m.new_id=v_workbench OR b.sys_base_menu_id=v_workbench); + DELETE FROM sys_base_menu_btns WHERE sys_base_menu_id=v_workbench; + DELETE FROM sys_base_menus WHERE id=v_workbench; + + -- 使用固定排序值,确保重复执行结果一致;不调整子菜单排序。 + UPDATE sys_base_menus SET sort=CASE name + WHEN 'dashboard' THEN 1 + WHEN 'sw_BusMgmt' THEN 2 + WHEN 'sw_DataAnalysis' THEN 3 + WHEN 'sw_AlertCenter' THEN 4 + WHEN 'permission' THEN 5 + WHEN 'org' THEN 6 + WHEN 'systemConfig' THEN 7 + WHEN 'monitor' THEN 8 + WHEN 'media' THEN 9 + WHEN 'extensions' THEN 13 + WHEN 'person' THEN 16 + ELSE sort END WHERE parent_id=0; + + -- 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')); + + IF EXISTS(SELECT 1 FROM sys_base_menus WHERE name='sw_workbench' OR path='sw-index') + OR EXISTS(SELECT 1 FROM sys_base_menu_btns WHERE name='sw:workbench:query') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Duplicate workbench remains'; + END IF; + IF EXISTS(SELECT 1 FROM sys_authorities a WHERE NOT EXISTS( + SELECT 1 FROM sys_base_menus m JOIN sys_authority_menus x ON x.sys_base_menu_id=m.id + WHERE x.sys_authority_authority_id=a.authority_id AND m.name=a.default_router AND m.deleted_at IS NULL)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Role default router not accessible'; + END IF; + IF EXISTS(SELECT 1 FROM sys_authority_menus x LEFT JOIN sys_base_menus m ON m.id=x.sys_base_menu_id WHERE m.id IS NULL) + OR EXISTS(SELECT 1 FROM sys_authority_btns x LEFT JOIN sys_base_menu_btns b ON b.id=x.sys_base_menu_btn_id + LEFT JOIN sys_base_menus m ON m.id=x.sys_menu_id WHERE b.id IS NULL OR m.id IS NULL OR b.sys_base_menu_id<>x.sys_menu_id) + OR EXISTS(SELECT 1 FROM sys_base_menu_parameters x LEFT JOIN sys_base_menus m ON m.id=x.sys_base_menu_id WHERE m.id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Dangling menu relationship'; + END IF; + COMMIT; + DO RELEASE_LOCK(v_lock_name); SET v_lock=0; + SELECT '菜单调整完成,原有框架和业务数据已保留' AS result; + LEAVE migration_run; +END IF; + +-- 二、未迁移的新副本:核查源表及迁移状态,再执行完整迁移。 +IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('sw_kra_state','sys_users','sys_schema_migrations','bak_sw2kra_20261103_sys_user')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Target already migrated or partially prepared; do not overwrite'; +END IF; +SELECT COUNT(*) INTO v_actual FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('sys_social','sys_dept','sys_user','sys_post','sys_role','sys_menu','sys_user_role','sys_role_menu','sys_role_dept','sys_user_post','sys_oper_log','sys_dict_type','sys_dict_data','sys_config','sys_login_info','sys_notice','sys_message','sys_oss','sys_oss_config','sys_client','sys_user_bind_platform','sys_user_preference','sj_job','sys_alert_rule','sys_alert_record','sys_alert_operation_log') AND engine='InnoDB'; + +IF v_actual<>26 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Source table set/engine differs'; END IF; + +IF EXISTS(SELECT 1 FROM sys_user WHERE del_flag='0' AND LEFT(password,4) NOT IN ('$2a$','$2b$','$2y$')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Unsupported source password format'; END IF; + +IF EXISTS(SELECT user_name FROM sys_user WHERE del_flag='0' GROUP BY user_name HAVING COUNT(*)>1) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Duplicate source username'; END IF; + +IF EXISTS(SELECT u.user_id FROM sys_user u LEFT JOIN sys_user_role ur ON ur.user_id=u.user_id LEFT JOIN sys_role r ON r.role_id=ur.role_id AND r.del_flag='0' WHERE u.del_flag='0' GROUP BY u.user_id HAVING COUNT(DISTINCT r.role_id)<>1) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Source user needs exactly one active role'; END IF; + +IF EXISTS(SELECT 1 FROM sys_role WHERE del_flag='0' AND data_scope NOT IN ('1','2','3','4','5')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Unsupported source data scope'; END IF; + +IF EXISTS(SELECT 1 FROM sys_user u LEFT JOIN sys_user p ON p.user_id=u.parent_id WHERE u.del_flag='0' AND u.parent_id>0 AND (p.user_id IS NULL OR p.del_flag<>'0' OR p.level>=u.level)) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Invalid source business ancestry'; END IF; + +-- 三、创建迁移账本和批次备份,逐表核对行数与字段内容。 + +CREATE TABLE sw_kra_state (id INT PRIMARY KEY, version VARCHAR(80) NOT NULL, phase VARCHAR(40) NOT NULL, started_at DATETIME(3), completed_at DATETIME(3)) ENGINE=InnoDB; + +INSERT INTO sw_kra_state VALUES(1,'20261103_framework_migration_v5','backup',NOW(3),NULL); + +CREATE TABLE sw_kra_checks (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,stage VARCHAR(32),check_name VARCHAR(160),expected_value BIGINT,actual_value BIGINT,passed TINYINT NOT NULL) ENGINE=InnoDB; + +CREATE TABLE `bak_sw2kra_20261103_sys_social` LIKE `sys_social`; + +CREATE TABLE `bak_sw2kra_20261103_sys_dept` LIKE `sys_dept`; + +CREATE TABLE `bak_sw2kra_20261103_sys_user` LIKE `sys_user`; + +CREATE TABLE `bak_sw2kra_20261103_sys_post` LIKE `sys_post`; + +CREATE TABLE `bak_sw2kra_20261103_sys_role` LIKE `sys_role`; + +CREATE TABLE `bak_sw2kra_20261103_sys_menu` LIKE `sys_menu`; + +CREATE TABLE `bak_sw2kra_20261103_sys_user_role` LIKE `sys_user_role`; + +CREATE TABLE `bak_sw2kra_20261103_sys_role_menu` LIKE `sys_role_menu`; + +CREATE TABLE `bak_sw2kra_20261103_sys_role_dept` LIKE `sys_role_dept`; + +CREATE TABLE `bak_sw2kra_20261103_sys_user_post` LIKE `sys_user_post`; + +CREATE TABLE `bak_sw2kra_20261103_sys_oper_log` LIKE `sys_oper_log`; + +CREATE TABLE `bak_sw2kra_20261103_sys_dict_type` LIKE `sys_dict_type`; + +CREATE TABLE `bak_sw2kra_20261103_sys_dict_data` LIKE `sys_dict_data`; + +CREATE TABLE `bak_sw2kra_20261103_sys_config` LIKE `sys_config`; + +CREATE TABLE `bak_sw2kra_20261103_sys_login_info` LIKE `sys_login_info`; + +CREATE TABLE `bak_sw2kra_20261103_sys_notice` LIKE `sys_notice`; + +CREATE TABLE `bak_sw2kra_20261103_sys_message` LIKE `sys_message`; + +CREATE TABLE `bak_sw2kra_20261103_sys_oss` LIKE `sys_oss`; + +CREATE TABLE `bak_sw2kra_20261103_sys_oss_config` LIKE `sys_oss_config`; + +CREATE TABLE `bak_sw2kra_20261103_sys_client` LIKE `sys_client`; + +CREATE TABLE `bak_sw2kra_20261103_sys_user_bind_platform` LIKE `sys_user_bind_platform`; + +CREATE TABLE `bak_sw2kra_20261103_sys_user_preference` LIKE `sys_user_preference`; + +CREATE TABLE `bak_sw2kra_20261103_sj_job` LIKE `sj_job`; + +CREATE TABLE `bak_sw2kra_20261103_sys_alert_rule` LIKE `sys_alert_rule`; + +CREATE TABLE `bak_sw2kra_20261103_sys_alert_record` LIKE `sys_alert_record`; + +CREATE TABLE `bak_sw2kra_20261103_sys_alert_operation_log` LIKE `sys_alert_operation_log`; + +START TRANSACTION; + +INSERT INTO `bak_sw2kra_20261103_sys_social` (`id`,`user_id`,`auth_id`,`source`,`open_id`,`user_name`,`nick_name`,`email`,`avatar`,`access_token`,`expire_in`,`refresh_token`,`access_code`,`union_id`,`scope`,`token_type`,`id_token`,`mac_algorithm`,`mac_key`,`code`,`oauth_token`,`oauth_token_secret`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`del_flag`) SELECT `id`,`user_id`,`auth_id`,`source`,`open_id`,`user_name`,`nick_name`,`email`,`avatar`,`access_token`,`expire_in`,`refresh_token`,`access_code`,`union_id`,`scope`,`token_type`,`id_token`,`mac_algorithm`,`mac_key`,`code`,`oauth_token`,`oauth_token_secret`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`del_flag` FROM `sys_social`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_social`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_social`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_social backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_social backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_social` s LEFT JOIN `bak_sw2kra_20261103_sys_social` b ON s.`id`=b.`id` WHERE b.`id` IS NULL OR NOT((BINARY s.`id` <=> BINARY b.`id`) AND (BINARY s.`user_id` <=> BINARY b.`user_id`) AND (BINARY s.`auth_id` <=> BINARY b.`auth_id`) AND (BINARY s.`source` <=> BINARY b.`source`) AND (BINARY s.`open_id` <=> BINARY b.`open_id`) AND (BINARY s.`user_name` <=> BINARY b.`user_name`) AND (BINARY s.`nick_name` <=> BINARY b.`nick_name`) AND (BINARY s.`email` <=> BINARY b.`email`) AND (BINARY s.`avatar` <=> BINARY b.`avatar`) AND (BINARY s.`access_token` <=> BINARY b.`access_token`) AND (BINARY s.`expire_in` <=> BINARY b.`expire_in`) AND (BINARY s.`refresh_token` <=> BINARY b.`refresh_token`) AND (BINARY s.`access_code` <=> BINARY b.`access_code`) AND (BINARY s.`union_id` <=> BINARY b.`union_id`) AND (BINARY s.`scope` <=> BINARY b.`scope`) AND (BINARY s.`token_type` <=> BINARY b.`token_type`) AND (BINARY s.`id_token` <=> BINARY b.`id_token`) AND (BINARY s.`mac_algorithm` <=> BINARY b.`mac_algorithm`) AND (BINARY s.`mac_key` <=> BINARY b.`mac_key`) AND (BINARY s.`code` <=> BINARY b.`code`) AND (BINARY s.`oauth_token` <=> BINARY b.`oauth_token`) AND (BINARY s.`oauth_token_secret` <=> BINARY b.`oauth_token_secret`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`del_flag` <=> BINARY b.`del_flag`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_social backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_social backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_dept` (`dept_id`,`parent_id`,`ancestors`,`dept_name`,`dept_category`,`order_num`,`leader`,`phone`,`email`,`status`,`del_flag`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`) SELECT `dept_id`,`parent_id`,`ancestors`,`dept_name`,`dept_category`,`order_num`,`leader`,`phone`,`email`,`status`,`del_flag`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time` FROM `sys_dept`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_dept`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_dept`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_dept backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_dept backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_dept` s LEFT JOIN `bak_sw2kra_20261103_sys_dept` b ON s.`dept_id`=b.`dept_id` WHERE b.`dept_id` IS NULL OR NOT((BINARY s.`dept_id` <=> BINARY b.`dept_id`) AND (BINARY s.`parent_id` <=> BINARY b.`parent_id`) AND (BINARY s.`ancestors` <=> BINARY b.`ancestors`) AND (BINARY s.`dept_name` <=> BINARY b.`dept_name`) AND (BINARY s.`dept_category` <=> BINARY b.`dept_category`) AND (BINARY s.`order_num` <=> BINARY b.`order_num`) AND (BINARY s.`leader` <=> BINARY b.`leader`) AND (BINARY s.`phone` <=> BINARY b.`phone`) AND (BINARY s.`email` <=> BINARY b.`email`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`del_flag` <=> BINARY b.`del_flag`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_dept backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_dept backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_user` (`user_id`,`dept_id`,`user_name`,`nick_name`,`user_type`,`email`,`phone_number`,`gender`,`avatar`,`password`,`status`,`del_flag`,`login_ip`,`login_date`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`,`level`,`parent_id`,`id_path`,`real_name`,`qq_number`,`reject_reason`) SELECT `user_id`,`dept_id`,`user_name`,`nick_name`,`user_type`,`email`,`phone_number`,`gender`,`avatar`,`password`,`status`,`del_flag`,`login_ip`,`login_date`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`,`level`,`parent_id`,`id_path`,`real_name`,`qq_number`,`reject_reason` FROM `sys_user`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_user`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_user`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_user` s LEFT JOIN `bak_sw2kra_20261103_sys_user` b ON s.`user_id`=b.`user_id` WHERE b.`user_id` IS NULL OR NOT((BINARY s.`user_id` <=> BINARY b.`user_id`) AND (BINARY s.`dept_id` <=> BINARY b.`dept_id`) AND (BINARY s.`user_name` <=> BINARY b.`user_name`) AND (BINARY s.`nick_name` <=> BINARY b.`nick_name`) AND (BINARY s.`user_type` <=> BINARY b.`user_type`) AND (BINARY s.`email` <=> BINARY b.`email`) AND (BINARY s.`phone_number` <=> BINARY b.`phone_number`) AND (BINARY s.`gender` <=> BINARY b.`gender`) AND (BINARY s.`avatar` <=> BINARY b.`avatar`) AND (BINARY s.`password` <=> BINARY b.`password`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`del_flag` <=> BINARY b.`del_flag`) AND (BINARY s.`login_ip` <=> BINARY b.`login_ip`) AND (BINARY s.`login_date` <=> BINARY b.`login_date`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`remark` <=> BINARY b.`remark`) AND (BINARY s.`level` <=> BINARY b.`level`) AND (BINARY s.`parent_id` <=> BINARY b.`parent_id`) AND (BINARY s.`id_path` <=> BINARY b.`id_path`) AND (BINARY s.`real_name` <=> BINARY b.`real_name`) AND (BINARY s.`qq_number` <=> BINARY b.`qq_number`) AND (BINARY s.`reject_reason` <=> BINARY b.`reject_reason`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_post` (`post_id`,`dept_id`,`post_code`,`post_category`,`post_name`,`post_sort`,`status`,`del_flag`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) SELECT `post_id`,`dept_id`,`post_code`,`post_category`,`post_name`,`post_sort`,`status`,`del_flag`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark` FROM `sys_post`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_post`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_post`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_post backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_post backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_post` s LEFT JOIN `bak_sw2kra_20261103_sys_post` b ON s.`post_id`=b.`post_id` WHERE b.`post_id` IS NULL OR NOT((BINARY s.`post_id` <=> BINARY b.`post_id`) AND (BINARY s.`dept_id` <=> BINARY b.`dept_id`) AND (BINARY s.`post_code` <=> BINARY b.`post_code`) AND (BINARY s.`post_category` <=> BINARY b.`post_category`) AND (BINARY s.`post_name` <=> BINARY b.`post_name`) AND (BINARY s.`post_sort` <=> BINARY b.`post_sort`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`del_flag` <=> BINARY b.`del_flag`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`remark` <=> BINARY b.`remark`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_post backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_post backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_role` (`role_id`,`role_name`,`role_key`,`role_sort`,`data_scope`,`menu_check_strictly`,`dept_check_strictly`,`status`,`del_flag`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) SELECT `role_id`,`role_name`,`role_key`,`role_sort`,`data_scope`,`menu_check_strictly`,`dept_check_strictly`,`status`,`del_flag`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark` FROM `sys_role`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_role`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_role`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_role backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_role backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_role` s LEFT JOIN `bak_sw2kra_20261103_sys_role` b ON s.`role_id`=b.`role_id` WHERE b.`role_id` IS NULL OR NOT((BINARY s.`role_id` <=> BINARY b.`role_id`) AND (BINARY s.`role_name` <=> BINARY b.`role_name`) AND (BINARY s.`role_key` <=> BINARY b.`role_key`) AND (BINARY s.`role_sort` <=> BINARY b.`role_sort`) AND (BINARY s.`data_scope` <=> BINARY b.`data_scope`) AND (BINARY s.`menu_check_strictly` <=> BINARY b.`menu_check_strictly`) AND (BINARY s.`dept_check_strictly` <=> BINARY b.`dept_check_strictly`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`del_flag` <=> BINARY b.`del_flag`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`remark` <=> BINARY b.`remark`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_role backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_role backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_menu` (`menu_id`,`menu_name`,`parent_id`,`order_num`,`path`,`component`,`query_param`,`is_frame`,`is_cache`,`menu_type`,`visible`,`status`,`perms`,`icon`,`active_menu`,`ext`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) SELECT `menu_id`,`menu_name`,`parent_id`,`order_num`,`path`,`component`,`query_param`,`is_frame`,`is_cache`,`menu_type`,`visible`,`status`,`perms`,`icon`,`active_menu`,`ext`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark` FROM `sys_menu`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_menu`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_menu`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_menu backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_menu backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_menu` s LEFT JOIN `bak_sw2kra_20261103_sys_menu` b ON s.`menu_id`=b.`menu_id` WHERE b.`menu_id` IS NULL OR NOT((BINARY s.`menu_id` <=> BINARY b.`menu_id`) AND (BINARY s.`menu_name` <=> BINARY b.`menu_name`) AND (BINARY s.`parent_id` <=> BINARY b.`parent_id`) AND (BINARY s.`order_num` <=> BINARY b.`order_num`) AND (BINARY s.`path` <=> BINARY b.`path`) AND (BINARY s.`component` <=> BINARY b.`component`) AND (BINARY s.`query_param` <=> BINARY b.`query_param`) AND (BINARY s.`is_frame` <=> BINARY b.`is_frame`) AND (BINARY s.`is_cache` <=> BINARY b.`is_cache`) AND (BINARY s.`menu_type` <=> BINARY b.`menu_type`) AND (BINARY s.`visible` <=> BINARY b.`visible`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`perms` <=> BINARY b.`perms`) AND (BINARY s.`icon` <=> BINARY b.`icon`) AND (BINARY s.`active_menu` <=> BINARY b.`active_menu`) AND (BINARY s.`ext` <=> BINARY b.`ext`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`remark` <=> BINARY b.`remark`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_menu backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_menu backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_user_role` (`user_id`,`role_id`) SELECT `user_id`,`role_id` FROM `sys_user_role`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_user_role`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_user_role`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user_role backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user_role backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_user_role` s LEFT JOIN `bak_sw2kra_20261103_sys_user_role` b ON s.`user_id`=b.`user_id` AND s.`role_id`=b.`role_id` WHERE b.`user_id` IS NULL OR NOT((BINARY s.`user_id` <=> BINARY b.`user_id`) AND (BINARY s.`role_id` <=> BINARY b.`role_id`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user_role backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user_role backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_role_menu` (`role_id`,`menu_id`) SELECT `role_id`,`menu_id` FROM `sys_role_menu`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_role_menu`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_role_menu`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_role_menu backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_role_menu backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_role_menu` s LEFT JOIN `bak_sw2kra_20261103_sys_role_menu` b ON s.`role_id`=b.`role_id` AND s.`menu_id`=b.`menu_id` WHERE b.`role_id` IS NULL OR NOT((BINARY s.`role_id` <=> BINARY b.`role_id`) AND (BINARY s.`menu_id` <=> BINARY b.`menu_id`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_role_menu backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_role_menu backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_role_dept` (`role_id`,`dept_id`) SELECT `role_id`,`dept_id` FROM `sys_role_dept`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_role_dept`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_role_dept`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_role_dept backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_role_dept backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_role_dept` s LEFT JOIN `bak_sw2kra_20261103_sys_role_dept` b ON s.`role_id`=b.`role_id` AND s.`dept_id`=b.`dept_id` WHERE b.`role_id` IS NULL OR NOT((BINARY s.`role_id` <=> BINARY b.`role_id`) AND (BINARY s.`dept_id` <=> BINARY b.`dept_id`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_role_dept backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_role_dept backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_user_post` (`user_id`,`post_id`) SELECT `user_id`,`post_id` FROM `sys_user_post`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_user_post`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_user_post`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user_post backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user_post backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_user_post` s LEFT JOIN `bak_sw2kra_20261103_sys_user_post` b ON s.`user_id`=b.`user_id` AND s.`post_id`=b.`post_id` WHERE b.`user_id` IS NULL OR NOT((BINARY s.`user_id` <=> BINARY b.`user_id`) AND (BINARY s.`post_id` <=> BINARY b.`post_id`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user_post backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user_post backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_oper_log` (`oper_id`,`title`,`business_type`,`method`,`request_method`,`operator_type`,`oper_name`,`user_id`,`dept_id`,`dept_name`,`client_key`,`device_type`,`browser`,`os`,`oper_url`,`oper_ip`,`oper_location`,`oper_param`,`json_result`,`status`,`error_msg`,`oper_time`,`cost_time`) SELECT `oper_id`,`title`,`business_type`,`method`,`request_method`,`operator_type`,`oper_name`,`user_id`,`dept_id`,`dept_name`,`client_key`,`device_type`,`browser`,`os`,`oper_url`,`oper_ip`,`oper_location`,`oper_param`,`json_result`,`status`,`error_msg`,`oper_time`,`cost_time` FROM `sys_oper_log`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_oper_log`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_oper_log`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_oper_log backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_oper_log backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_oper_log` s LEFT JOIN `bak_sw2kra_20261103_sys_oper_log` b ON s.`oper_id`=b.`oper_id` WHERE b.`oper_id` IS NULL OR NOT((BINARY s.`oper_id` <=> BINARY b.`oper_id`) AND (BINARY s.`title` <=> BINARY b.`title`) AND (BINARY s.`business_type` <=> BINARY b.`business_type`) AND (BINARY s.`method` <=> BINARY b.`method`) AND (BINARY s.`request_method` <=> BINARY b.`request_method`) AND (BINARY s.`operator_type` <=> BINARY b.`operator_type`) AND (BINARY s.`oper_name` <=> BINARY b.`oper_name`) AND (BINARY s.`user_id` <=> BINARY b.`user_id`) AND (BINARY s.`dept_id` <=> BINARY b.`dept_id`) AND (BINARY s.`dept_name` <=> BINARY b.`dept_name`) AND (BINARY s.`client_key` <=> BINARY b.`client_key`) AND (BINARY s.`device_type` <=> BINARY b.`device_type`) AND (BINARY s.`browser` <=> BINARY b.`browser`) AND (BINARY s.`os` <=> BINARY b.`os`) AND (BINARY s.`oper_url` <=> BINARY b.`oper_url`) AND (BINARY s.`oper_ip` <=> BINARY b.`oper_ip`) AND (BINARY s.`oper_location` <=> BINARY b.`oper_location`) AND (BINARY s.`oper_param` <=> BINARY b.`oper_param`) AND (BINARY s.`json_result` <=> BINARY b.`json_result`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`error_msg` <=> BINARY b.`error_msg`) AND (BINARY s.`oper_time` <=> BINARY b.`oper_time`) AND (BINARY s.`cost_time` <=> BINARY b.`cost_time`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_oper_log backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_oper_log backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_dict_type` (`dict_id`,`dict_name`,`dict_type`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) SELECT `dict_id`,`dict_name`,`dict_type`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark` FROM `sys_dict_type`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_dict_type`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_dict_type`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_dict_type backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_dict_type backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_dict_type` s LEFT JOIN `bak_sw2kra_20261103_sys_dict_type` b ON s.`dict_id`=b.`dict_id` WHERE b.`dict_id` IS NULL OR NOT((BINARY s.`dict_id` <=> BINARY b.`dict_id`) AND (BINARY s.`dict_name` <=> BINARY b.`dict_name`) AND (BINARY s.`dict_type` <=> BINARY b.`dict_type`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`remark` <=> BINARY b.`remark`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_dict_type backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_dict_type backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_dict_data` (`dict_code`,`dict_sort`,`dict_label`,`dict_value`,`dict_type`,`css_class`,`list_class`,`is_default`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) SELECT `dict_code`,`dict_sort`,`dict_label`,`dict_value`,`dict_type`,`css_class`,`list_class`,`is_default`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark` FROM `sys_dict_data`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_dict_data`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_dict_data`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_dict_data backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_dict_data backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_dict_data` s LEFT JOIN `bak_sw2kra_20261103_sys_dict_data` b ON s.`dict_code`=b.`dict_code` WHERE b.`dict_code` IS NULL OR NOT((BINARY s.`dict_code` <=> BINARY b.`dict_code`) AND (BINARY s.`dict_sort` <=> BINARY b.`dict_sort`) AND (BINARY s.`dict_label` <=> BINARY b.`dict_label`) AND (BINARY s.`dict_value` <=> BINARY b.`dict_value`) AND (BINARY s.`dict_type` <=> BINARY b.`dict_type`) AND (BINARY s.`css_class` <=> BINARY b.`css_class`) AND (BINARY s.`list_class` <=> BINARY b.`list_class`) AND (BINARY s.`is_default` <=> BINARY b.`is_default`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`remark` <=> BINARY b.`remark`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_dict_data backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_dict_data backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_config` (`config_id`,`config_name`,`config_key`,`config_value`,`config_type`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) SELECT `config_id`,`config_name`,`config_key`,`config_value`,`config_type`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark` FROM `sys_config`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_config`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_config`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_config backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_config backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_config` s LEFT JOIN `bak_sw2kra_20261103_sys_config` b ON s.`config_id`=b.`config_id` WHERE b.`config_id` IS NULL OR NOT((BINARY s.`config_id` <=> BINARY b.`config_id`) AND (BINARY s.`config_name` <=> BINARY b.`config_name`) AND (BINARY s.`config_key` <=> BINARY b.`config_key`) AND (BINARY s.`config_value` <=> BINARY b.`config_value`) AND (BINARY s.`config_type` <=> BINARY b.`config_type`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`remark` <=> BINARY b.`remark`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_config backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_config backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_login_info` (`info_id`,`user_name`,`client_key`,`device_type`,`ipaddr`,`login_location`,`browser`,`os`,`status`,`msg`,`login_time`) SELECT `info_id`,`user_name`,`client_key`,`device_type`,`ipaddr`,`login_location`,`browser`,`os`,`status`,`msg`,`login_time` FROM `sys_login_info`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_login_info`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_login_info`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_login_info backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_login_info backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_login_info` s LEFT JOIN `bak_sw2kra_20261103_sys_login_info` b ON s.`info_id`=b.`info_id` WHERE b.`info_id` IS NULL OR NOT((BINARY s.`info_id` <=> BINARY b.`info_id`) AND (BINARY s.`user_name` <=> BINARY b.`user_name`) AND (BINARY s.`client_key` <=> BINARY b.`client_key`) AND (BINARY s.`device_type` <=> BINARY b.`device_type`) AND (BINARY s.`ipaddr` <=> BINARY b.`ipaddr`) AND (BINARY s.`login_location` <=> BINARY b.`login_location`) AND (BINARY s.`browser` <=> BINARY b.`browser`) AND (BINARY s.`os` <=> BINARY b.`os`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`msg` <=> BINARY b.`msg`) AND (BINARY s.`login_time` <=> BINARY b.`login_time`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_login_info backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_login_info backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_notice` (`notice_id`,`notice_title`,`notice_type`,`notice_content`,`status`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) SELECT `notice_id`,`notice_title`,`notice_type`,`notice_content`,`status`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark` FROM `sys_notice`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_notice`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_notice`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_notice backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_notice backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_notice` s LEFT JOIN `bak_sw2kra_20261103_sys_notice` b ON s.`notice_id`=b.`notice_id` WHERE b.`notice_id` IS NULL OR NOT((BINARY s.`notice_id` <=> BINARY b.`notice_id`) AND (BINARY s.`notice_title` <=> BINARY b.`notice_title`) AND (BINARY s.`notice_type` <=> BINARY b.`notice_type`) AND (BINARY s.`notice_content` <=> BINARY b.`notice_content`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`remark` <=> BINARY b.`remark`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_notice backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_notice backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_message` (`message_id`,`category`,`type`,`source`,`title`,`message`,`content`,`data_json`,`path`,`send_user_ids`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`) SELECT `message_id`,`category`,`type`,`source`,`title`,`message`,`content`,`data_json`,`path`,`send_user_ids`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time` FROM `sys_message`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_message`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_message`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_message backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_message backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_message` s LEFT JOIN `bak_sw2kra_20261103_sys_message` b ON s.`message_id`=b.`message_id` WHERE b.`message_id` IS NULL OR NOT((BINARY s.`message_id` <=> BINARY b.`message_id`) AND (BINARY s.`category` <=> BINARY b.`category`) AND (BINARY s.`type` <=> BINARY b.`type`) AND (BINARY s.`source` <=> BINARY b.`source`) AND (BINARY s.`title` <=> BINARY b.`title`) AND (BINARY s.`message` <=> BINARY b.`message`) AND (BINARY s.`content` <=> BINARY b.`content`) AND (BINARY s.`data_json` <=> BINARY b.`data_json`) AND (BINARY s.`path` <=> BINARY b.`path`) AND (BINARY s.`send_user_ids` <=> BINARY b.`send_user_ids`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_message backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_message backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_oss` (`oss_id`,`file_name`,`original_name`,`file_suffix`,`url`,`ext1`,`create_dept`,`create_time`,`create_by`,`update_time`,`update_by`,`service`) SELECT `oss_id`,`file_name`,`original_name`,`file_suffix`,`url`,`ext1`,`create_dept`,`create_time`,`create_by`,`update_time`,`update_by`,`service` FROM `sys_oss`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_oss`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_oss`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_oss backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_oss backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_oss` s LEFT JOIN `bak_sw2kra_20261103_sys_oss` b ON s.`oss_id`=b.`oss_id` WHERE b.`oss_id` IS NULL OR NOT((BINARY s.`oss_id` <=> BINARY b.`oss_id`) AND (BINARY s.`file_name` <=> BINARY b.`file_name`) AND (BINARY s.`original_name` <=> BINARY b.`original_name`) AND (BINARY s.`file_suffix` <=> BINARY b.`file_suffix`) AND (BINARY s.`url` <=> BINARY b.`url`) AND (BINARY s.`ext1` <=> BINARY b.`ext1`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`service` <=> BINARY b.`service`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_oss backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_oss backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_oss_config` (`oss_config_id`,`config_key`,`access_key`,`secret_key`,`bucket_name`,`prefix`,`endpoint`,`domain_url`,`is_https`,`region`,`access_policy`,`status`,`ext1`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark`) SELECT `oss_config_id`,`config_key`,`access_key`,`secret_key`,`bucket_name`,`prefix`,`endpoint`,`domain_url`,`is_https`,`region`,`access_policy`,`status`,`ext1`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`,`remark` FROM `sys_oss_config`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_oss_config`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_oss_config`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_oss_config backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_oss_config backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_oss_config` s LEFT JOIN `bak_sw2kra_20261103_sys_oss_config` b ON s.`oss_config_id`=b.`oss_config_id` WHERE b.`oss_config_id` IS NULL OR NOT((BINARY s.`oss_config_id` <=> BINARY b.`oss_config_id`) AND (BINARY s.`config_key` <=> BINARY b.`config_key`) AND (BINARY s.`access_key` <=> BINARY b.`access_key`) AND (BINARY s.`secret_key` <=> BINARY b.`secret_key`) AND (BINARY s.`bucket_name` <=> BINARY b.`bucket_name`) AND (BINARY s.`prefix` <=> BINARY b.`prefix`) AND (BINARY s.`endpoint` <=> BINARY b.`endpoint`) AND (BINARY s.`domain_url` <=> BINARY b.`domain_url`) AND (BINARY s.`is_https` <=> BINARY b.`is_https`) AND (BINARY s.`region` <=> BINARY b.`region`) AND (BINARY s.`access_policy` <=> BINARY b.`access_policy`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`ext1` <=> BINARY b.`ext1`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`) AND (BINARY s.`remark` <=> BINARY b.`remark`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_oss_config backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_oss_config backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_client` (`id`,`client_id`,`client_key`,`client_secret`,`grant_type`,`device_type`,`access_path`,`ip_whitelist`,`active_timeout`,`timeout`,`status`,`del_flag`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time`) SELECT `id`,`client_id`,`client_key`,`client_secret`,`grant_type`,`device_type`,`access_path`,`ip_whitelist`,`active_timeout`,`timeout`,`status`,`del_flag`,`create_dept`,`create_by`,`create_time`,`update_by`,`update_time` FROM `sys_client`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_client`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_client`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_client backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_client backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_client` s LEFT JOIN `bak_sw2kra_20261103_sys_client` b ON s.`id`=b.`id` WHERE b.`id` IS NULL OR NOT((BINARY s.`id` <=> BINARY b.`id`) AND (BINARY s.`client_id` <=> BINARY b.`client_id`) AND (BINARY s.`client_key` <=> BINARY b.`client_key`) AND (BINARY s.`client_secret` <=> BINARY b.`client_secret`) AND (BINARY s.`grant_type` <=> BINARY b.`grant_type`) AND (BINARY s.`device_type` <=> BINARY b.`device_type`) AND (BINARY s.`access_path` <=> BINARY b.`access_path`) AND (BINARY s.`ip_whitelist` <=> BINARY b.`ip_whitelist`) AND (BINARY s.`active_timeout` <=> BINARY b.`active_timeout`) AND (BINARY s.`timeout` <=> BINARY b.`timeout`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`del_flag` <=> BINARY b.`del_flag`) AND (BINARY s.`create_dept` <=> BINARY b.`create_dept`) AND (BINARY s.`create_by` <=> BINARY b.`create_by`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`update_by` <=> BINARY b.`update_by`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_client backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_client backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_user_bind_platform` (`user_id`,`source_db`,`platform_busier_id`,`platform_busier_account`,`status`,`reject_reason`,`create_time`,`review_time`,`review_account`) SELECT `user_id`,`source_db`,`platform_busier_id`,`platform_busier_account`,`status`,`reject_reason`,`create_time`,`review_time`,`review_account` FROM `sys_user_bind_platform`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_user_bind_platform`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_user_bind_platform`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user_bind_platform backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user_bind_platform backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_user_bind_platform` s LEFT JOIN `bak_sw2kra_20261103_sys_user_bind_platform` b ON s.`user_id`=b.`user_id` AND s.`source_db`=b.`source_db` AND s.`platform_busier_id`=b.`platform_busier_id` WHERE b.`user_id` IS NULL OR NOT((BINARY s.`user_id` <=> BINARY b.`user_id`) AND (BINARY s.`source_db` <=> BINARY b.`source_db`) AND (BINARY s.`platform_busier_id` <=> BINARY b.`platform_busier_id`) AND (BINARY s.`platform_busier_account` <=> BINARY b.`platform_busier_account`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`reject_reason` <=> BINARY b.`reject_reason`) AND (BINARY s.`create_time` <=> BINARY b.`create_time`) AND (BINARY s.`review_time` <=> BINARY b.`review_time`) AND (BINARY s.`review_account` <=> BINARY b.`review_account`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user_bind_platform backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user_bind_platform backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_user_preference` (`user_id`,`include_sub_promote`,`idcard_dedup`,`update_time`) SELECT `user_id`,`include_sub_promote`,`idcard_dedup`,`update_time` FROM `sys_user_preference`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_user_preference`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_user_preference`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user_preference backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user_preference backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_user_preference` s LEFT JOIN `bak_sw2kra_20261103_sys_user_preference` b ON s.`user_id`=b.`user_id` WHERE b.`user_id` IS NULL OR NOT((BINARY s.`user_id` <=> BINARY b.`user_id`) AND (BINARY s.`include_sub_promote` <=> BINARY b.`include_sub_promote`) AND (BINARY s.`idcard_dedup` <=> BINARY b.`idcard_dedup`) AND (BINARY s.`update_time` <=> BINARY b.`update_time`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_user_preference backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_user_preference backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sj_job` (`id`,`namespace_id`,`biz_id`,`group_name`,`job_name`,`args_str`,`args_type`,`next_trigger_at`,`job_status`,`task_type`,`route_key`,`executor_type`,`executor_info`,`trigger_type`,`trigger_interval`,`block_strategy`,`executor_timeout`,`max_retry_times`,`parallel_num`,`retry_interval`,`bucket_index`,`resident`,`notify_ids`,`owner_id`,`labels`,`description`,`ext_attrs`,`deleted`,`create_dt`,`update_dt`) SELECT `id`,`namespace_id`,`biz_id`,`group_name`,`job_name`,`args_str`,`args_type`,`next_trigger_at`,`job_status`,`task_type`,`route_key`,`executor_type`,`executor_info`,`trigger_type`,`trigger_interval`,`block_strategy`,`executor_timeout`,`max_retry_times`,`parallel_num`,`retry_interval`,`bucket_index`,`resident`,`notify_ids`,`owner_id`,`labels`,`description`,`ext_attrs`,`deleted`,`create_dt`,`update_dt` FROM `sj_job`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sj_job`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sj_job`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sj_job backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sj_job backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sj_job` s LEFT JOIN `bak_sw2kra_20261103_sj_job` b ON s.`id`=b.`id` WHERE b.`id` IS NULL OR NOT((BINARY s.`id` <=> BINARY b.`id`) AND (BINARY s.`namespace_id` <=> BINARY b.`namespace_id`) AND (BINARY s.`biz_id` <=> BINARY b.`biz_id`) AND (BINARY s.`group_name` <=> BINARY b.`group_name`) AND (BINARY s.`job_name` <=> BINARY b.`job_name`) AND (BINARY s.`args_str` <=> BINARY b.`args_str`) AND (BINARY s.`args_type` <=> BINARY b.`args_type`) AND (BINARY s.`next_trigger_at` <=> BINARY b.`next_trigger_at`) AND (BINARY s.`job_status` <=> BINARY b.`job_status`) AND (BINARY s.`task_type` <=> BINARY b.`task_type`) AND (BINARY s.`route_key` <=> BINARY b.`route_key`) AND (BINARY s.`executor_type` <=> BINARY b.`executor_type`) AND (BINARY s.`executor_info` <=> BINARY b.`executor_info`) AND (BINARY s.`trigger_type` <=> BINARY b.`trigger_type`) AND (BINARY s.`trigger_interval` <=> BINARY b.`trigger_interval`) AND (BINARY s.`block_strategy` <=> BINARY b.`block_strategy`) AND (BINARY s.`executor_timeout` <=> BINARY b.`executor_timeout`) AND (BINARY s.`max_retry_times` <=> BINARY b.`max_retry_times`) AND (BINARY s.`parallel_num` <=> BINARY b.`parallel_num`) AND (BINARY s.`retry_interval` <=> BINARY b.`retry_interval`) AND (BINARY s.`bucket_index` <=> BINARY b.`bucket_index`) AND (BINARY s.`resident` <=> BINARY b.`resident`) AND (BINARY s.`notify_ids` <=> BINARY b.`notify_ids`) AND (BINARY s.`owner_id` <=> BINARY b.`owner_id`) AND (BINARY s.`labels` <=> BINARY b.`labels`) AND (BINARY s.`description` <=> BINARY b.`description`) AND (BINARY s.`ext_attrs` <=> BINARY b.`ext_attrs`) AND (BINARY s.`deleted` <=> BINARY b.`deleted`) AND (BINARY s.`create_dt` <=> BINARY b.`create_dt`) AND (BINARY s.`update_dt` <=> BINARY b.`update_dt`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sj_job backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sj_job backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_alert_rule` (`id`,`rule_name`,`rule_type`,`monitor_metric`,`stat_period_days`,`mom_direction`,`mom_threshold`,`unlogin_days`,`monitor_scope`,`warning_color`,`creator_id`,`enabled`,`is_deleted`,`created_at`,`updated_at`) SELECT `id`,`rule_name`,`rule_type`,`monitor_metric`,`stat_period_days`,`mom_direction`,`mom_threshold`,`unlogin_days`,`monitor_scope`,`warning_color`,`creator_id`,`enabled`,`is_deleted`,`created_at`,`updated_at` FROM `sys_alert_rule`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_alert_rule`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_alert_rule`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_alert_rule backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_alert_rule backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_alert_rule` s LEFT JOIN `bak_sw2kra_20261103_sys_alert_rule` b ON s.`id`=b.`id` WHERE b.`id` IS NULL OR NOT((BINARY s.`id` <=> BINARY b.`id`) AND (BINARY s.`rule_name` <=> BINARY b.`rule_name`) AND (BINARY s.`rule_type` <=> BINARY b.`rule_type`) AND (BINARY s.`monitor_metric` <=> BINARY b.`monitor_metric`) AND (BINARY s.`stat_period_days` <=> BINARY b.`stat_period_days`) AND (BINARY s.`mom_direction` <=> BINARY b.`mom_direction`) AND (BINARY s.`mom_threshold` <=> BINARY b.`mom_threshold`) AND (BINARY s.`unlogin_days` <=> BINARY b.`unlogin_days`) AND (BINARY s.`monitor_scope` <=> BINARY b.`monitor_scope`) AND (BINARY s.`warning_color` <=> BINARY b.`warning_color`) AND (BINARY s.`creator_id` <=> BINARY b.`creator_id`) AND (BINARY s.`enabled` <=> BINARY b.`enabled`) AND (BINARY s.`is_deleted` <=> BINARY b.`is_deleted`) AND (BINARY s.`created_at` <=> BINARY b.`created_at`) AND (BINARY s.`updated_at` <=> BINARY b.`updated_at`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_alert_rule backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_alert_rule backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_alert_record` (`id`,`platform_code`,`platform_name`,`rule_id`,`rule_name`,`rule_type`,`monitor_metric`,`target_promote_id`,`target_promote_name`,`business_id`,`business_name`,`alert_content`,`status`,`alert_date`,`created_at`,`updated_at`) SELECT `id`,`platform_code`,`platform_name`,`rule_id`,`rule_name`,`rule_type`,`monitor_metric`,`target_promote_id`,`target_promote_name`,`business_id`,`business_name`,`alert_content`,`status`,`alert_date`,`created_at`,`updated_at` FROM `sys_alert_record`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_alert_record`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_alert_record`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_alert_record backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_alert_record backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_alert_record` s LEFT JOIN `bak_sw2kra_20261103_sys_alert_record` b ON s.`id`=b.`id` WHERE b.`id` IS NULL OR NOT((BINARY s.`id` <=> BINARY b.`id`) AND (BINARY s.`platform_code` <=> BINARY b.`platform_code`) AND (BINARY s.`platform_name` <=> BINARY b.`platform_name`) AND (BINARY s.`rule_id` <=> BINARY b.`rule_id`) AND (BINARY s.`rule_name` <=> BINARY b.`rule_name`) AND (BINARY s.`rule_type` <=> BINARY b.`rule_type`) AND (BINARY s.`monitor_metric` <=> BINARY b.`monitor_metric`) AND (BINARY s.`target_promote_id` <=> BINARY b.`target_promote_id`) AND (BINARY s.`target_promote_name` <=> BINARY b.`target_promote_name`) AND (BINARY s.`business_id` <=> BINARY b.`business_id`) AND (BINARY s.`business_name` <=> BINARY b.`business_name`) AND (BINARY s.`alert_content` <=> BINARY b.`alert_content`) AND (BINARY s.`status` <=> BINARY b.`status`) AND (BINARY s.`alert_date` <=> BINARY b.`alert_date`) AND (BINARY s.`created_at` <=> BINARY b.`created_at`) AND (BINARY s.`updated_at` <=> BINARY b.`updated_at`) AND (BINARY s.`unclosed_flag` <=> BINARY b.`unclosed_flag`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_alert_record backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_alert_record backup bytes'; END IF; + +INSERT INTO `bak_sw2kra_20261103_sys_alert_operation_log` (`id`,`alert_id`,`operator_id`,`operator_name`,`log_type`,`action_detail`,`note_content`,`attachment_ids`,`created_at`) SELECT `id`,`alert_id`,`operator_id`,`operator_name`,`log_type`,`action_detail`,`note_content`,`attachment_ids`,`created_at` FROM `sys_alert_operation_log`; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_alert_operation_log`) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `sys_alert_operation_log`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_alert_operation_log backup rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_alert_operation_log backup rows'; END IF; + +SELECT (SELECT COUNT(*) FROM `sys_alert_operation_log` s LEFT JOIN `bak_sw2kra_20261103_sys_alert_operation_log` b ON s.`id`=b.`id` WHERE b.`id` IS NULL OR NOT((BINARY s.`id` <=> BINARY b.`id`) AND (BINARY s.`alert_id` <=> BINARY b.`alert_id`) AND (BINARY s.`operator_id` <=> BINARY b.`operator_id`) AND (BINARY s.`operator_name` <=> BINARY b.`operator_name`) AND (BINARY s.`log_type` <=> BINARY b.`log_type`) AND (BINARY s.`action_detail` <=> BINARY b.`action_detail`) AND (BINARY s.`note_content` <=> BINARY b.`note_content`) AND (BINARY s.`attachment_ids` <=> BINARY b.`attachment_ids`) AND (BINARY s.`created_at` <=> BINARY b.`created_at`))) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('backup','sys_alert_operation_log backup bytes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_alert_operation_log backup bytes'; END IF; + +COMMIT; + +-- 四、按 Kra 当前定义创建目标表,并增加商务账号和岗位扩展字段。 + +CREATE TABLE `sys_schema_migrations` (`id` varchar(255),PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_users` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`uuid` char(36),`username` varchar(191),`password` longtext,`nick_name` longtext,`header_img` longtext,`authority_id` bigint unsigned,`dept_id` bigint unsigned,`phone` longtext,`email` longtext,`enable` bigint DEFAULT 1,`origin_setting` text,`password_updated_at` datetime(3) NULL,`must_change_password` boolean,PRIMARY KEY (`id`),INDEX `idx_sys_users_deleted_at` (`deleted_at`),INDEX `idx_sys_users_uuid` (`uuid`),INDEX `idx_sys_users_username` (`username`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_authorities` (`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`authority_id` bigint unsigned AUTO_INCREMENT,`authority_name` longtext,`parent_id` bigint unsigned,`data_scope` bigint DEFAULT 1,`default_router` varchar(191) DEFAULT 'dashboard',PRIMARY KEY (`authority_id`),INDEX `idx_sys_authorities_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_base_menus` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`menu_level` bigint unsigned,`parent_id` bigint unsigned,`path` longtext,`name` longtext,`hidden` boolean,`component` longtext,`sort` bigint,`active_name` longtext,`keep_alive` boolean,`default_menu` boolean,`title` longtext,`icon` longtext,`close_tab` boolean,`transition_type` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_base_menus_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_user_authority` (`sys_user_id` bigint unsigned,`sys_authority_authority_id` bigint unsigned) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_authority_menus` (`sys_authority_authority_id` bigint unsigned,`sys_base_menu_id` bigint unsigned) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_base_menu_parameters` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`sys_base_menu_id` bigint unsigned,`type` longtext,`key` longtext,`value` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_base_menu_parameters_deleted_at` (`deleted_at`),INDEX `idx_sys_base_menu_parameters_menu_id` (`sys_base_menu_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_apis` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`path` longtext,`description` longtext,`api_group` longtext,`method` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_apis_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_ignore_apis` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`path` longtext,`method` varchar(191) DEFAULT 'POST',PRIMARY KEY (`id`),INDEX `idx_sys_ignore_apis_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_authority_apis` (`authority_id` bigint unsigned,`api_id` bigint unsigned,PRIMARY KEY (`authority_id`,`api_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `casbin_rule` (`id` bigint unsigned AUTO_INCREMENT,`ptype` varchar(100),`v0` varchar(100),`v1` varchar(100),`v2` varchar(100),`v3` varchar(100),`v4` varchar(100),`v5` varchar(100),PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_base_menu_btns` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`name` longtext,`desc` longtext,`sys_base_menu_id` bigint unsigned,PRIMARY KEY (`id`),INDEX `idx_sys_base_menu_btns_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_authority_btns` (`authority_id` bigint unsigned,`sys_menu_id` bigint unsigned,`sys_base_menu_btn_id` bigint unsigned) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_departments` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`name` varchar(191),`parent_id` bigint unsigned,`ancestors` longtext,`sort` bigint,`leader_id` bigint unsigned,`status` boolean DEFAULT true,PRIMARY KEY (`id`),INDEX `idx_sys_departments_deleted_at` (`deleted_at`),INDEX `idx_sys_departments_name` (`name`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_positions` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`name` varchar(191),`code` longtext,`sort` bigint,`status` boolean DEFAULT true,`remark` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_positions_deleted_at` (`deleted_at`),INDEX `idx_sys_positions_name` (`name`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_user_departments` (`sys_user_id` bigint unsigned,`sys_department_id` bigint unsigned) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_user_positions` (`sys_user_id` bigint unsigned,`sys_position_id` bigint unsigned) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_authority_departments` (`sys_authority_authority_id` bigint unsigned,`sys_department_id` bigint unsigned,INDEX `idx_sys_authority_departments_authority_id` (`sys_authority_authority_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_dictionaries` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`name` longtext,`type` longtext,`status` boolean,`desc` longtext,`parent_id` bigint unsigned,PRIMARY KEY (`id`),INDEX `idx_sys_dictionaries_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_dictionary_details` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`label` longtext,`value` longtext,`extend` longtext,`status` boolean,`sort` bigint,`sys_dictionary_id` bigint unsigned,`parent_id` bigint unsigned,`level` bigint,`path` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_dictionary_details_deleted_at` (`deleted_at`),INDEX `idx_sys_dictionary_details_dictionary_id` (`sys_dictionary_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_params` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`name` longtext,`key` longtext,`value` longtext,`desc` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_params_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_api_tokens` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`user_id` bigint unsigned,`authority_id` bigint unsigned,`token` text,`status` boolean,`expires_at` datetime(3) NULL,`remark` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_api_tokens_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `jwt_blacklists` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`jwt` text,PRIMARY KEY (`id`),INDEX `idx_jwt_blacklists_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_security_config` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`captcha_open` bigint DEFAULT 0,`captcha_timeout` bigint DEFAULT 3600,`key_long` bigint DEFAULT 6,`img_width` bigint DEFAULT 240,`img_height` bigint DEFAULT 80,`pwd_min_length` bigint DEFAULT 8,`pwd_require_upper` boolean DEFAULT false,`pwd_require_lower` boolean DEFAULT false,`pwd_require_digit` boolean DEFAULT false,`pwd_require_special` boolean DEFAULT false,`limit_enable` boolean DEFAULT false,`limit_window` bigint DEFAULT 60,`limit_count` bigint DEFAULT 30,`lock_enable` boolean DEFAULT false,`lock_threshold` bigint DEFAULT 5,`lock_duration` bigint DEFAULT 30,`pwd_expire_enable` boolean DEFAULT false,`pwd_expire_days` bigint DEFAULT 90,`force_new_user_change_password` boolean DEFAULT false,PRIMARY KEY (`id`),INDEX `idx_sys_security_config_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_versions` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`version_name` longtext,`version_code` longtext,`description` longtext,`version_data` text,PRIMARY KEY (`id`),INDEX `idx_sys_versions_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_export_templates` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`db_name` longtext,`name` longtext,`table_name` longtext,`template_id` longtext,`template_info` text,`sql` text,`import_sql` text,`limit` bigint,`order` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_export_templates_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_export_template_condition` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`template_id` varchar(191),`from` longtext,`column` longtext,`operator` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_export_template_condition_deleted_at` (`deleted_at`),INDEX `idx_sys_export_template_condition_template_id` (`template_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_export_template_join` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`template_id` varchar(191),`joins` longtext,`table` longtext,`on` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_export_template_join_deleted_at` (`deleted_at`),INDEX `idx_sys_export_template_join_template_id` (`template_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_operation_records` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`ip` longtext,`method` longtext,`path` longtext,`status` bigint,`latency_ms` bigint,`agent` text,`error_message` longtext,`body` text,`resp` text,`user_id` bigint unsigned,`request_id` varchar(191),`trace_id` varchar(191),`device_id` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_operation_records_deleted_at` (`deleted_at`),INDEX `idx_sys_operation_records_request_id` (`request_id`),INDEX `idx_sys_operation_records_trace_id` (`trace_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_login_logs` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`username` longtext,`ip` longtext,`status` boolean,`error_message` longtext,`agent` longtext,`user_id` bigint unsigned,PRIMARY KEY (`id`),INDEX `idx_sys_login_logs_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_data_access_logs` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`event_type` longtext,`target_table` longtext,`operation` longtext,`user_id` bigint unsigned,`authority_id` bigint unsigned,`scope` bigint,`request_id` longtext,`method` longtext,`path` longtext,`detail` longtext,PRIMARY KEY (`id`),INDEX `idx_sys_data_access_logs_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_error` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`form` text,`info` text,`level` longtext,`request_id` varchar(191),`trace_id` varchar(191),`solution` text,`status` varchar(191) DEFAULT '未处理',PRIMARY KEY (`id`),INDEX `idx_sys_error_deleted_at` (`deleted_at`),INDEX `idx_sys_error_request_id` (`request_id`),INDEX `idx_sys_error_trace_id` (`trace_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `media_file_upload_and_downloads` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`name` longtext,`class_id` bigint,`url` longtext,`tag` longtext,`key` varchar(191),`size` bigint,`mime` longtext,`md5` varchar(191),`user_id` bigint unsigned,PRIMARY KEY (`id`),INDEX `idx_media_file_upload_and_downloads_deleted_at` (`deleted_at`),INDEX `idx_media_file_upload_and_downloads_key` (`key`),INDEX `idx_media_file_upload_and_downloads_md5` (`md5`),INDEX `idx_media_file_upload_and_downloads_user_id` (`user_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `media_attachment_category` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`name` longtext,`pid` bigint unsigned,PRIMARY KEY (`id`),INDEX `idx_media_attachment_category_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `media_uploads` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`user_id` bigint unsigned,`file_name` longtext,`file_hash` varchar(191),`file_size` bigint,`chunk_size` bigint,`chunk_total` bigint,`status` longtext,`storage_key` longtext,`media_id` bigint unsigned,PRIMARY KEY (`id`),INDEX `idx_media_uploads_deleted_at` (`deleted_at`),INDEX `idx_media_uploads_user_id` (`user_id`),INDEX `idx_media_uploads_file_hash` (`file_hash`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `media_upload_chunks` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`upload_id` bigint unsigned,`chunk_index` bigint,`chunk_hash` longtext,`size` bigint,PRIMARY KEY (`id`),INDEX `idx_media_upload_chunks_deleted_at` (`deleted_at`),UNIQUE INDEX `idx_upload_chunk` (`upload_id`,`chunk_index`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_announcements` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`title` longtext,`content` text,`user_id` bigint,`attachments` JSON,PRIMARY KEY (`id`),INDEX `idx_sys_announcements_deleted_at` (`deleted_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_integration_configs` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`kind` varchar(32) NOT NULL,`provider` varchar(64) NOT NULL,`enabled` boolean,`config` text NOT NULL,PRIMARY KEY (`id`),UNIQUE INDEX `idx_integration_kind_provider` (`kind`,`provider`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_timed_tasks` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`name` varchar(191),`description` longtext,`spec` longtext,`with_seconds` boolean,`executor_type` longtext,`method_name` longtext,`params` JSON,`http_url` longtext,`http_method` longtext,`http_header` JSON,`http_body` text,`http_allow_private` boolean,`enabled` boolean,PRIMARY KEY (`id`),INDEX `idx_sys_timed_tasks_deleted_at` (`deleted_at`),INDEX `idx_sys_timed_tasks_name` (`name`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_timed_task_logs` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`deleted_at` datetime(3) NULL,`task_id` bigint unsigned,`task_name` longtext,`trigger_type` longtext,`started_at` datetime(3) NULL,`finished_at` datetime(3) NULL,`duration_ms` bigint,`status` longtext,`error_msg` text,`output` text,PRIMARY KEY (`id`),INDEX `idx_sys_timed_task_logs_deleted_at` (`deleted_at`),INDEX `idx_sys_timed_task_logs_task_id` (`task_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `pay_orders` (`id` bigint unsigned AUTO_INCREMENT,`created_at` datetime(3) NULL,`updated_at` datetime(3) NULL,`trade_no` varchar(128) NOT NULL,`provider` varchar(64) NOT NULL,`provider_trade_no` varchar(128),`query_id` varchar(128),`business_type` varchar(64) NOT NULL,`business_id` varchar(128) NOT NULL,`subject` varchar(256) NOT NULL,`payment_mode` varchar(32) NOT NULL DEFAULT 'external',`original_amount` bigint NOT NULL DEFAULT 0,`amount` bigint NOT NULL,`paid_amount` bigint NOT NULL DEFAULT 0,`payer_paid_amount` bigint NOT NULL DEFAULT 0,`cash_paid_amount` bigint NOT NULL DEFAULT 0,`point_paid_amount` bigint NOT NULL DEFAULT 0,`discount_amount` bigint NOT NULL DEFAULT 0,`provider_discount_amount` bigint NOT NULL DEFAULT 0,`merchant_discount_amount` bigint NOT NULL DEFAULT 0,`settlement_amount` bigint NOT NULL DEFAULT 0,`currency` varchar(16) NOT NULL,`payer_currency` varchar(16),`amount_breakdown_known` boolean NOT NULL DEFAULT false,`payment_status` varchar(32) NOT NULL,`provider_status` varchar(64),`fulfillment_status` varchar(32) NOT NULL,`refund_status` varchar(32) NOT NULL,`refunded_amount` bigint NOT NULL DEFAULT 0,`refund_requested_amount` bigint NOT NULL DEFAULT 0,`refund_no` varchar(128),`confirmation_id` varchar(36) NOT NULL,`request_fingerprint` varchar(64) NOT NULL,`create_payload` text,`extra` text,`last_event_id` varchar(128),`last_payload_hash` varchar(64),`last_error` varchar(512),`fulfillment_token` varchar(36),`refund_token` varchar(36),`version` bigint unsigned NOT NULL DEFAULT 1,`paid_at` datetime(3) NULL,`fulfilled_at` datetime(3) NULL,`refunded_at` datetime(3) NULL,`fulfillment_lease_until` datetime(3) NULL,`refund_lease_until` datetime(3) NULL,PRIMARY KEY (`id`),UNIQUE INDEX `idx_pay_orders_provider_trade` (`trade_no`,`provider`),UNIQUE INDEX `idx_pay_orders_provider_provider_trade` (`provider`,`provider_trade_no`),INDEX `idx_pay_orders_provider` (`provider`),INDEX `idx_pay_orders_business` (`business_type`,`business_id`),INDEX `idx_pay_orders_payment_mode` (`payment_mode`),INDEX `idx_pay_orders_payment_status` (`payment_status`),INDEX `idx_pay_orders_fulfillment_status` (`fulfillment_status`),INDEX `idx_pay_orders_refund_status` (`refund_status`),INDEX `idx_pay_orders_refund_no` (`refund_no`),UNIQUE INDEX `idx_pay_orders_confirmation_id` (`confirmation_id`),INDEX `idx_pay_orders_request_fingerprint` (`request_fingerprint`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `pay_events` (`id` bigint unsigned AUTO_INCREMENT,`provider` varchar(64) NOT NULL,`trade_no` varchar(128) NOT NULL,`type` varchar(64) NOT NULL,`source` varchar(64) NOT NULL,`status` varchar(64),`provider_status` varchar(64),`message` varchar(512),`event_id` varchar(128),`payload_hash` varchar(64),`amount` bigint NOT NULL DEFAULT 0,`currency` varchar(16),`operator_id` bigint unsigned,`operator_name` varchar(128),`client_ip` varchar(64),`user_agent` varchar(512),`device_id` varchar(128),`created_at` datetime(3) NULL,PRIMARY KEY (`id`),INDEX `idx_pay_events_order` (`provider`,`trade_no`),INDEX `idx_pay_events_type` (`type`),INDEX `idx_pay_events_source` (`source`),INDEX `idx_pay_events_event_id` (`event_id`),INDEX `idx_pay_events_operator_id` (`operator_id`),INDEX `idx_pay_events_created_at` (`created_at`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC; + +ALTER TABLE sys_users ADD COLUMN level TINYINT UNSIGNED NOT NULL DEFAULT 0,ADD COLUMN parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0,ADD COLUMN id_path VARCHAR(255) NOT NULL DEFAULT '0',ADD COLUMN real_name VARCHAR(30) NOT NULL DEFAULT '',ADD COLUMN qq_number VARCHAR(20) NOT NULL DEFAULT '',ADD COLUMN business_status TINYINT UNSIGNED NOT NULL DEFAULT 0,ADD COLUMN reject_reason VARCHAR(200) NOT NULL DEFAULT '',ADD INDEX idx_sys_users_business_parent(parent_id); + +ALTER TABLE sys_positions ADD COLUMN dept_id BIGINT UNSIGNED NULL,ADD INDEX idx_sys_positions_department(dept_id); + +CREATE TABLE sw_kra_id_map (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,entity VARCHAR(32) NOT NULL,old_id VARCHAR(64) NOT NULL,new_id BIGINT UNSIGNED NULL,UNIQUE KEY uk_source(entity,old_id),UNIQUE KEY uk_target(entity,new_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE sw_kra_menu_permissions (source_menu_id BIGINT UNSIGNED PRIMARY KEY,source_permission VARCHAR(160),button_key VARCHAR(160)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE sw_kra_api_contract (id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,method VARCHAR(12),source_path VARCHAR(255),target_path VARCHAR(255),source_permission VARCHAR(160),controller VARCHAR(80),implementation_status VARCHAR(32) NOT NULL DEFAULT 'pending_go_handler') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +START TRANSACTION; + +-- 五、写入 Kra 必需初始数据,并按 ID 映射转换用户、角色、部门及其他框架数据。 + +INSERT INTO `casbin_rule` (`id`,`ptype`,`v0`,`v1`,`v2`,`v3`,`v4`,`v5`) VALUES +(1,'p','888','/integration/configs/:kind','GET','','',''), +(2,'p','888','/integration/configs/:kind/:provider','DELETE','','',''), +(3,'p','888','/integration/configs/:kind/:provider','GET','','',''), +(4,'p','888','/integration/configs/:kind/:provider','PUT','','',''), +(5,'p','888','/integration/configs/:kind/:provider/test','POST','','',''), +(6,'p','888','/integration/sms/:provider/send','POST','','',''), +(7,'p','888','/payment/create','POST','','',''), +(8,'p','888','/payment/fulfill','POST','','',''), +(9,'p','888','/payment/order','POST','','',''), +(10,'p','888','/payment/orders','GET','','',''), +(11,'p','888','/payment/orders/:provider/:tradeNo','GET','','',''), +(12,'p','888','/payment/orders/:provider/:tradeNo/events','GET','','',''), +(13,'p','888','/payment/orders/:provider/:tradeNo/fulfill','POST','','',''), +(14,'p','888','/payment/orders/:provider/:tradeNo/refund','POST','','',''), +(15,'p','888','/payment/orders/summary','GET','','',''), +(16,'p','888','/payment/providers/:provider/test','POST','','',''), +(17,'p','888','/payment/query','POST','','',''), +(18,'p','888','/payment/refund','POST','','',''), +(19,'p','888','/api/createApi','POST','','',''), +(20,'p','888','/api/deleteApi','POST','','',''), +(21,'p','888','/api/deleteApisByIds','DELETE','','',''), +(22,'p','888','/api/enterSyncApi','POST','','',''), +(23,'p','888','/api/getAllApis','POST','','',''), +(24,'p','888','/api/getApiById','POST','','',''), +(25,'p','888','/api/getApiGroups','GET','','',''), +(26,'p','888','/api/getApiList','POST','','',''), +(27,'p','888','/api/getApiRoles','GET','','',''), +(28,'p','888','/api/ignoreApi','POST','','',''), +(29,'p','888','/api/setApiRoles','POST','','',''), +(30,'p','888','/api/syncApi','GET','','',''), +(31,'p','888','/api/updateApi','POST','','',''), +(32,'p','888','/attachmentCategory/addCategory','POST','','',''), +(33,'p','888','/attachmentCategory/deleteCategory','POST','','',''), +(34,'p','888','/attachmentCategory/getCategoryList','GET','','',''), +(35,'p','888','/authority/copyAuthority','POST','','',''), +(36,'p','888','/authority/createAuthority','POST','','',''), +(37,'p','888','/authority/deleteAuthority','POST','','',''), +(38,'p','888','/authority/getAuthorityList','POST','','',''), +(39,'p','888','/authority/getDataScopeDepts','GET','','',''), +(40,'p','888','/authority/getUsersByAuthority','GET','','',''), +(41,'p','888','/authority/setDataScope','POST','','',''), +(42,'p','888','/authority/setRoleUsers','POST','','',''), +(43,'p','888','/authority/updateAuthority','PUT','','',''), +(44,'p','888','/authorityBtn/canRemoveAuthorityBtn','POST','','',''), +(45,'p','888','/authorityBtn/getAuthorityBtn','POST','','',''), +(46,'p','888','/authorityBtn/setAuthorityBtn','POST','','',''), +(47,'p','888','/casbin/getPolicyPathByAuthorityId','POST','','',''), +(48,'p','888','/casbin/updateCasbin','POST','','',''), +(49,'p','888','/dataAccessLog/deleteDataAccessLogByIds','DELETE','','',''), +(50,'p','888','/dataAccessLog/getDataAccessLogList','POST','','',''), +(51,'p','888','/department/createDepartment','POST','','',''), +(52,'p','888','/department/deleteDepartment','DELETE','','',''), +(53,'p','888','/department/findDepartment','GET','','',''), +(54,'p','888','/department/getDepartmentList','POST','','',''), +(55,'p','888','/department/getDepartmentUsers','GET','','',''), +(56,'p','888','/department/setDepartmentUsers','POST','','',''), +(57,'p','888','/department/updateDepartment','PUT','','',''), +(58,'p','888','/email/emailTest','POST','','',''), +(59,'p','888','/email/sendEmail','POST','','',''), +(60,'p','888','/fileUploadAndDownload/deleteFile','POST','','',''), +(61,'p','888','/fileUploadAndDownload/deleteFiles','POST','','',''), +(62,'p','888','/fileUploadAndDownload/editFileName','POST','','',''), +(63,'p','888','/fileUploadAndDownload/findFile','GET','','',''), +(64,'p','888','/fileUploadAndDownload/getFileList','POST','','',''), +(65,'p','888','/fileUploadAndDownload/importURL','POST','','',''), +(66,'p','888','/fileUploadAndDownload/listOssFiles','POST','','',''), +(67,'p','888','/fileUploadAndDownload/upload','POST','','',''), +(68,'p','888','/info/createInfo','POST','','',''), +(69,'p','888','/info/deleteInfo','DELETE','','',''), +(70,'p','888','/info/deleteInfoByIds','DELETE','','',''), +(71,'p','888','/info/findInfo','GET','','',''), +(72,'p','888','/info/getInfoDataSource','GET','','',''), +(73,'p','888','/info/getInfoList','GET','','',''), +(74,'p','888','/info/updateInfo','PUT','','',''), +(75,'p','888','/jwt/jsonInBlacklist','POST','','',''), +(76,'p','888','/logViewer/content','GET','','',''), +(77,'p','888','/logViewer/dates','GET','','',''), +(78,'p','888','/logViewer/files','GET','','',''), +(79,'p','888','/mediaUpload/:uploadId','DELETE','','',''), +(80,'p','888','/mediaUpload/chunk','POST','','',''), +(81,'p','888','/mediaUpload/complete','POST','','',''), +(82,'p','888','/mediaUpload/init','POST','','',''), +(83,'p','888','/menu/addBaseMenu','POST','','',''), +(84,'p','888','/menu/addMenuAuthority','POST','','',''), +(85,'p','888','/menu/deleteBaseMenu','POST','','',''), +(86,'p','888','/menu/getBaseMenuById','POST','','',''), +(87,'p','888','/menu/getBaseMenuTree','POST','','',''), +(88,'p','888','/menu/getMenu','POST','','',''), +(89,'p','888','/menu/getMenuAuthority','POST','','',''), +(90,'p','888','/menu/getMenuList','POST','','',''), +(91,'p','888','/menu/getMenuRoles','GET','','',''), +(92,'p','888','/menu/setMenuRoles','POST','','',''), +(93,'p','888','/menu/updateBaseMenu','POST','','',''), +(94,'p','888','/payment/callback/:provider','POST','','',''), +(95,'p','888','/position/createPosition','POST','','',''), +(96,'p','888','/position/deletePosition','DELETE','','',''), +(97,'p','888','/position/findPosition','GET','','',''), +(98,'p','888','/position/getPositionList','POST','','',''), +(99,'p','888','/position/getPositionUsers','GET','','',''), +(100,'p','888','/position/setPositionUsers','POST','','',''), +(101,'p','888','/position/updatePosition','PUT','','',''), +(102,'p','888','/securityConfig/getSecurityConfig','GET','','',''), +(103,'p','888','/securityConfig/setSecurityConfig','POST','','',''), +(104,'p','888','/sysApiToken/createApiToken','POST','','',''), +(105,'p','888','/sysApiToken/deleteApiToken','POST','','',''), +(106,'p','888','/sysApiToken/getApiTokenList','POST','','',''), +(107,'p','888','/sysDictionary/createSysDictionary','POST','','',''), +(108,'p','888','/sysDictionary/deleteSysDictionary','DELETE','','',''), +(109,'p','888','/sysDictionary/exportSysDictionary','GET','','',''), +(110,'p','888','/sysDictionary/findSysDictionary','GET','','',''), +(111,'p','888','/sysDictionary/getSysDictionaryList','GET','','',''), +(112,'p','888','/sysDictionary/getSysDictionaryListWithDetails','GET','','',''), +(113,'p','888','/sysDictionary/importSysDictionary','POST','','',''), +(114,'p','888','/sysDictionary/updateSysDictionary','PUT','','',''), +(115,'p','888','/sysDictionaryDetail/createSysDictionaryDetail','POST','','',''), +(116,'p','888','/sysDictionaryDetail/deleteSysDictionaryDetail','DELETE','','',''), +(117,'p','888','/sysDictionaryDetail/findSysDictionaryDetail','GET','','',''), +(118,'p','888','/sysDictionaryDetail/getDictionaryDetailsByParent','GET','','',''), +(119,'p','888','/sysDictionaryDetail/getDictionaryPath','GET','','',''), +(120,'p','888','/sysDictionaryDetail/getDictionaryTreeList','GET','','',''), +(121,'p','888','/sysDictionaryDetail/getDictionaryTreeListByType','GET','','',''), +(122,'p','888','/sysDictionaryDetail/getSysDictionaryDetailList','GET','','',''), +(123,'p','888','/sysDictionaryDetail/updateSysDictionaryDetail','PUT','','',''), +(124,'p','888','/sysError/createSysError','POST','','',''), +(125,'p','888','/sysError/deleteSysError','DELETE','','',''), +(126,'p','888','/sysError/deleteSysErrorByIds','DELETE','','',''), +(127,'p','888','/sysError/findSysError','GET','','',''), +(128,'p','888','/sysError/getSysErrorList','GET','','',''), +(129,'p','888','/sysError/updateSysError','PUT','','',''), +(130,'p','888','/sysExportTemplate/createSysExportTemplate','POST','','',''), +(131,'p','888','/sysExportTemplate/deleteSysExportTemplate','DELETE','','',''), +(132,'p','888','/sysExportTemplate/deleteSysExportTemplateByIds','DELETE','','',''), +(133,'p','888','/sysExportTemplate/exportExcel','GET','','',''), +(134,'p','888','/sysExportTemplate/exportExcelByToken','GET','','',''), +(135,'p','888','/sysExportTemplate/exportTemplate','GET','','',''), +(136,'p','888','/sysExportTemplate/exportTemplateByToken','GET','','',''), +(137,'p','888','/sysExportTemplate/findSysExportTemplate','GET','','',''), +(138,'p','888','/sysExportTemplate/getSysExportTemplateList','GET','','',''), +(139,'p','888','/sysExportTemplate/importExcel','POST','','',''), +(140,'p','888','/sysExportTemplate/previewSQL','GET','','',''), +(141,'p','888','/sysExportTemplate/updateSysExportTemplate','PUT','','',''), +(142,'p','888','/sysLoginLog/deleteLoginLog','DELETE','','',''), +(143,'p','888','/sysLoginLog/deleteLoginLogByIds','DELETE','','',''), +(144,'p','888','/sysLoginLog/findLoginLog','GET','','',''), +(145,'p','888','/sysLoginLog/getLoginLogList','GET','','',''), +(146,'p','888','/sysOperationRecord/deleteSysOperationRecord','DELETE','','',''), +(147,'p','888','/sysOperationRecord/deleteSysOperationRecordByIds','DELETE','','',''), +(148,'p','888','/sysOperationRecord/findSysOperationRecord','GET','','',''), +(149,'p','888','/sysOperationRecord/getSysOperationRecordList','GET','','',''), +(150,'p','888','/sysParams/createSysParams','POST','','',''), +(151,'p','888','/sysParams/deleteSysParams','DELETE','','',''), +(152,'p','888','/sysParams/deleteSysParamsByIds','DELETE','','',''), +(153,'p','888','/sysParams/findSysParams','GET','','',''), +(154,'p','888','/sysParams/getSysParam','GET','','',''), +(155,'p','888','/sysParams/getSysParamsList','GET','','',''), +(156,'p','888','/sysParams/updateSysParams','PUT','','',''), +(157,'p','888','/sysVersion/deleteSysVersion','DELETE','','',''), +(158,'p','888','/sysVersion/deleteSysVersionByIds','DELETE','','',''), +(159,'p','888','/sysVersion/downloadVersionJson','GET','','',''), +(160,'p','888','/sysVersion/exportVersion','POST','','',''), +(161,'p','888','/sysVersion/findSysVersion','GET','','',''), +(162,'p','888','/sysVersion/getSysVersionList','GET','','',''), +(163,'p','888','/sysVersion/importVersion','POST','','',''), +(164,'p','888','/system/getServerInfo','POST','','',''), +(165,'p','888','/system/getSystemConfig','POST','','',''), +(166,'p','888','/system/setSystemConfig','POST','','',''), +(167,'p','888','/timedTask/alertStream','GET','','',''), +(168,'p','888','/timedTask/createTimedTask','POST','','',''), +(169,'p','888','/timedTask/deleteTimedTask','DELETE','','',''), +(170,'p','888','/timedTask/getRegisteredMethods','GET','','',''), +(171,'p','888','/timedTask/getTimedTaskList','GET','','',''), +(172,'p','888','/timedTask/getTimedTaskLogList','GET','','',''), +(173,'p','888','/timedTask/toggleTimedTask','POST','','',''), +(174,'p','888','/timedTask/triggerTimedTask','POST','','',''), +(175,'p','888','/timedTask/updateTimedTask','PUT','','',''), +(176,'p','888','/user/admin_register','POST','','',''), +(177,'p','888','/user/changePassword','POST','','',''), +(178,'p','888','/user/deleteUser','DELETE','','',''), +(179,'p','888','/user/getUserInfo','GET','','',''), +(180,'p','888','/user/getUserList','POST','','',''), +(181,'p','888','/user/resetPassword','POST','','',''), +(182,'p','888','/user/setSelfInfo','PUT','','',''), +(183,'p','888','/user/setSelfSetting','PUT','','',''), +(184,'p','888','/user/setUserAuthorities','POST','','',''), +(185,'p','888','/user/setUserAuthority','POST','','',''), +(186,'p','888','/user/setUserDepartments','POST','','',''), +(187,'p','888','/user/setUserInfo','PUT','','',''), +(188,'p','888','/user/setUserPositions','POST','','',''); + +INSERT INTO `sys_apis` (`api_group`,`created_at`,`deleted_at`,`description`,`id`,`method`,`path`,`updated_at`) VALUES +('集成配置',NOW(3),NULL,'按类型获取集成配置',1,'GET','/integration/configs/:kind',NOW(3)), +('集成配置',NOW(3),NULL,'删除集成配置',2,'DELETE','/integration/configs/:kind/:provider',NOW(3)), +('集成配置',NOW(3),NULL,'获取指定集成配置',3,'GET','/integration/configs/:kind/:provider',NOW(3)), +('集成配置',NOW(3),NULL,'保存集成配置',4,'PUT','/integration/configs/:kind/:provider',NOW(3)), +('集成配置',NOW(3),NULL,'测试通信集成连接',5,'POST','/integration/configs/:kind/:provider/test',NOW(3)), +('短信服务',NOW(3),NULL,'发送单条模板短信',6,'POST','/integration/sms/:provider/send',NOW(3)), +('支付',NOW(3),NULL,'创建支付订单',7,'POST','/payment/create',NOW(3)), +('支付',NOW(3),NULL,'重试支付订单发货',8,'POST','/payment/fulfill',NOW(3)), +('支付',NOW(3),NULL,'查询支付订单',9,'POST','/payment/order',NOW(3)), +('支付',NOW(3),NULL,'分页查询支付订单',10,'GET','/payment/orders',NOW(3)), +('支付',NOW(3),NULL,'按路径查询支付订单',11,'GET','/payment/orders/:provider/:tradeNo',NOW(3)), +('支付',NOW(3),NULL,'查询支付订单事件流水',12,'GET','/payment/orders/:provider/:tradeNo/events',NOW(3)), +('支付',NOW(3),NULL,'按路径重试支付订单发货',13,'POST','/payment/orders/:provider/:tradeNo/fulfill',NOW(3)), +('支付',NOW(3),NULL,'按路径申请支付订单退款',14,'POST','/payment/orders/:provider/:tradeNo/refund',NOW(3)), +('支付',NOW(3),NULL,'汇总支付订单运营指标',15,'GET','/payment/orders/summary',NOW(3)), +('支付',NOW(3),NULL,'测试支付渠道配置与沙箱交易链路',16,'POST','/payment/providers/:provider/test',NOW(3)), +('支付',NOW(3),NULL,'同步支付订单状态',17,'POST','/payment/query',NOW(3)), +('支付',NOW(3),NULL,'申请支付订单退款',18,'POST','/payment/refund',NOW(3)), +('api',NOW(3),NULL,'创建api',19,'POST','/api/createApi',NOW(3)), +('api',NOW(3),NULL,'删除Api',20,'POST','/api/deleteApi',NOW(3)), +('api',NOW(3),NULL,'批量删除api',21,'DELETE','/api/deleteApisByIds',NOW(3)), +('api',NOW(3),NULL,'确认同步API',22,'POST','/api/enterSyncApi',NOW(3)), +('api',NOW(3),NULL,'刷新 Casbin 缓存',23,'GET','/api/freshCasbin',NOW(3)), +('api',NOW(3),NULL,'获取所有api',24,'POST','/api/getAllApis',NOW(3)), +('api',NOW(3),NULL,'获取api详细信息',25,'POST','/api/getApiById',NOW(3)), +('api',NOW(3),NULL,'获取路由组',26,'GET','/api/getApiGroups',NOW(3)), +('api',NOW(3),NULL,'获取api列表',27,'POST','/api/getApiList',NOW(3)), +('api',NOW(3),NULL,'获取指定API关联角色列表',28,'GET','/api/getApiRoles',NOW(3)), +('api',NOW(3),NULL,'忽略API',29,'POST','/api/ignoreApi',NOW(3)), +('api',NOW(3),NULL,'全量覆盖API关联角色列表',30,'POST','/api/setApiRoles',NOW(3)), +('api',NOW(3),NULL,'获取待同步API',31,'GET','/api/syncApi',NOW(3)), +('api',NOW(3),NULL,'更新Api',32,'POST','/api/updateApi',NOW(3)), +('媒体库分类',NOW(3),NULL,'添加/编辑分类',33,'POST','/attachmentCategory/addCategory',NOW(3)), +('媒体库分类',NOW(3),NULL,'删除分类',34,'POST','/attachmentCategory/deleteCategory',NOW(3)), +('媒体库分类',NOW(3),NULL,'分类列表',35,'GET','/attachmentCategory/getCategoryList',NOW(3)), +('角色',NOW(3),NULL,'拷贝角色',36,'POST','/authority/copyAuthority',NOW(3)), +('角色',NOW(3),NULL,'创建角色',37,'POST','/authority/createAuthority',NOW(3)), +('角色',NOW(3),NULL,'删除角色',38,'POST','/authority/deleteAuthority',NOW(3)), +('角色',NOW(3),NULL,'获取角色列表',39,'POST','/authority/getAuthorityList',NOW(3)), +('角色',NOW(3),NULL,'获取角色自定义部门集',40,'GET','/authority/getDataScopeDepts',NOW(3)), +('角色',NOW(3),NULL,'获取角色关联用户ID列表',41,'GET','/authority/getUsersByAuthority',NOW(3)), +('角色',NOW(3),NULL,'设置角色数据权限',42,'POST','/authority/setDataScope',NOW(3)), +('角色',NOW(3),NULL,'全量覆盖角色关联用户',43,'POST','/authority/setRoleUsers',NOW(3)), +('角色',NOW(3),NULL,'更新角色信息',44,'PUT','/authority/updateAuthority',NOW(3)), +('按钮权限',NOW(3),NULL,'删除按钮',45,'POST','/authorityBtn/canRemoveAuthorityBtn',NOW(3)), +('按钮权限',NOW(3),NULL,'获取已有按钮权限',46,'POST','/authorityBtn/getAuthorityBtn',NOW(3)), +('按钮权限',NOW(3),NULL,'设置按钮权限',47,'POST','/authorityBtn/setAuthorityBtn',NOW(3)), +('base',NOW(3),NULL,'获取验证码',48,'POST','/base/captcha',NOW(3)), +('base',NOW(3),NULL,'登录',49,'POST','/base/login',NOW(3)), +('casbin',NOW(3),NULL,'获取权限列表',50,'POST','/casbin/getPolicyPathByAuthorityId',NOW(3)), +('casbin',NOW(3),NULL,'更改角色api权限',51,'POST','/casbin/updateCasbin',NOW(3)), +('数据权限审计',NOW(3),NULL,'批量删除数据权限审计日志',52,'DELETE','/dataAccessLog/deleteDataAccessLogByIds',NOW(3)), +('数据权限审计',NOW(3),NULL,'获取数据权限审计日志',53,'POST','/dataAccessLog/getDataAccessLogList',NOW(3)), +('部门',NOW(3),NULL,'创建部门',54,'POST','/department/createDepartment',NOW(3)), +('部门',NOW(3),NULL,'删除部门',55,'DELETE','/department/deleteDepartment',NOW(3)), +('部门',NOW(3),NULL,'根据ID获取部门',56,'GET','/department/findDepartment',NOW(3)), +('部门',NOW(3),NULL,'获取部门树',57,'POST','/department/getDepartmentList',NOW(3)), +('部门',NOW(3),NULL,'获取部门成员ID列表',58,'GET','/department/getDepartmentUsers',NOW(3)), +('部门',NOW(3),NULL,'设置部门成员(反向分配)',59,'POST','/department/setDepartmentUsers',NOW(3)), +('部门',NOW(3),NULL,'更新部门',60,'PUT','/department/updateDepartment',NOW(3)), +('email',NOW(3),NULL,'发送测试邮件',61,'POST','/email/emailTest',NOW(3)), +('email',NOW(3),NULL,'发送邮件',62,'POST','/email/sendEmail',NOW(3)), +('文件上传与下载',NOW(3),NULL,'删除文件',63,'POST','/fileUploadAndDownload/deleteFile',NOW(3)), +('文件上传与下载',NOW(3),NULL,'批量删除文件',64,'POST','/fileUploadAndDownload/deleteFiles',NOW(3)), +('文件上传与下载',NOW(3),NULL,'文件名或者备注编辑',65,'POST','/fileUploadAndDownload/editFileName',NOW(3)), +('文件上传与下载',NOW(3),NULL,'获取文件详情',66,'GET','/fileUploadAndDownload/findFile',NOW(3)), +('文件上传与下载',NOW(3),NULL,'获取上传文件列表',67,'POST','/fileUploadAndDownload/getFileList',NOW(3)), +('文件上传与下载',NOW(3),NULL,'导入URL',68,'POST','/fileUploadAndDownload/importURL',NOW(3)), +('文件上传与下载',NOW(3),NULL,'获取对象存储文件列表',69,'POST','/fileUploadAndDownload/listOssFiles',NOW(3)), +('文件上传与下载',NOW(3),NULL,'文件上传(建议选择)',70,'POST','/fileUploadAndDownload/upload',NOW(3)), +('base',NOW(3),NULL,'健康检查',71,'GET','/health',NOW(3)), +('公告',NOW(3),NULL,'新建公告',72,'POST','/info/createInfo',NOW(3)), +('公告',NOW(3),NULL,'删除公告',73,'DELETE','/info/deleteInfo',NOW(3)), +('公告',NOW(3),NULL,'批量删除公告',74,'DELETE','/info/deleteInfoByIds',NOW(3)), +('公告',NOW(3),NULL,'根据ID获取公告',75,'GET','/info/findInfo',NOW(3)), +('公告',NOW(3),NULL,'获取公告数据源',76,'GET','/info/getInfoDataSource',NOW(3)), +('公告',NOW(3),NULL,'获取公告列表',77,'GET','/info/getInfoList',NOW(3)), +('公告',NOW(3),NULL,'获取公开公告',78,'GET','/info/getInfoPublic',NOW(3)), +('公告',NOW(3),NULL,'更新公告',79,'PUT','/info/updateInfo',NOW(3)), +('初始化',NOW(3),NULL,'检查数据库',80,'POST','/init/checkdb',NOW(3)), +('初始化',NOW(3),NULL,'初始化数据库',81,'POST','/init/initdb',NOW(3)), +('jwt',NOW(3),NULL,'jwt加入黑名单(退出,必选)',82,'POST','/jwt/jsonInBlacklist',NOW(3)), +('文件日志',NOW(3),NULL,'分块读取日志文件内容',83,'GET','/logViewer/content',NOW(3)), +('文件日志',NOW(3),NULL,'获取存在日志的日期',84,'GET','/logViewer/dates',NOW(3)), +('文件日志',NOW(3),NULL,'获取日期下的日志文件',85,'GET','/logViewer/files',NOW(3)), +('媒体上传',NOW(3),NULL,'取消大文件上传',86,'DELETE','/mediaUpload/:uploadId',NOW(3)), +('媒体上传',NOW(3),NULL,'上传分片',87,'POST','/mediaUpload/chunk',NOW(3)), +('媒体上传',NOW(3),NULL,'完成大文件上传',88,'POST','/mediaUpload/complete',NOW(3)), +('媒体上传',NOW(3),NULL,'初始化大文件上传',89,'POST','/mediaUpload/init',NOW(3)), +('菜单',NOW(3),NULL,'新增菜单',90,'POST','/menu/addBaseMenu',NOW(3)), +('菜单',NOW(3),NULL,'增加menu和角色关联关系',91,'POST','/menu/addMenuAuthority',NOW(3)), +('菜单',NOW(3),NULL,'删除菜单',92,'POST','/menu/deleteBaseMenu',NOW(3)), +('菜单',NOW(3),NULL,'根据id获取菜单',93,'POST','/menu/getBaseMenuById',NOW(3)), +('菜单',NOW(3),NULL,'获取用户动态路由',94,'POST','/menu/getBaseMenuTree',NOW(3)), +('菜单',NOW(3),NULL,'获取菜单树(必选)',95,'POST','/menu/getMenu',NOW(3)), +('菜单',NOW(3),NULL,'获取指定角色menu',96,'POST','/menu/getMenuAuthority',NOW(3)), +('菜单',NOW(3),NULL,'分页获取基础menu列表',97,'POST','/menu/getMenuList',NOW(3)), +('菜单',NOW(3),NULL,'获取菜单关联角色列表',98,'GET','/menu/getMenuRoles',NOW(3)), +('菜单',NOW(3),NULL,'全量覆盖菜单关联角色列表',99,'POST','/menu/setMenuRoles',NOW(3)), +('菜单',NOW(3),NULL,'更新菜单',100,'POST','/menu/updateBaseMenu',NOW(3)), +('支付',NOW(3),NULL,'处理支付回调',101,'POST','/payment/callback/:provider',NOW(3)), +('岗位',NOW(3),NULL,'创建岗位',102,'POST','/position/createPosition',NOW(3)), +('岗位',NOW(3),NULL,'删除岗位',103,'DELETE','/position/deletePosition',NOW(3)), +('岗位',NOW(3),NULL,'根据ID获取岗位',104,'GET','/position/findPosition',NOW(3)), +('岗位',NOW(3),NULL,'获取岗位列表',105,'POST','/position/getPositionList',NOW(3)), +('岗位',NOW(3),NULL,'获取岗位成员ID列表',106,'GET','/position/getPositionUsers',NOW(3)), +('岗位',NOW(3),NULL,'设置岗位成员(反向分配)',107,'POST','/position/setPositionUsers',NOW(3)), +('岗位',NOW(3),NULL,'更新岗位',108,'PUT','/position/updatePosition',NOW(3)), +('安全配置',NOW(3),NULL,'获取安全配置',109,'GET','/securityConfig/getSecurityConfig',NOW(3)), +('安全配置',NOW(3),NULL,'设置安全配置',110,'POST','/securityConfig/setSecurityConfig',NOW(3)), +('base',NOW(3),NULL,'Swagger 文档',111,'GET','/swagger/*any',NOW(3)), +('API Token',NOW(3),NULL,'签发API Token',112,'POST','/sysApiToken/createApiToken',NOW(3)), +('API Token',NOW(3),NULL,'作废API Token',113,'POST','/sysApiToken/deleteApiToken',NOW(3)), +('API Token',NOW(3),NULL,'获取API Token列表',114,'POST','/sysApiToken/getApiTokenList',NOW(3)), +('系统字典',NOW(3),NULL,'新增字典',115,'POST','/sysDictionary/createSysDictionary',NOW(3)), +('系统字典',NOW(3),NULL,'删除字典',116,'DELETE','/sysDictionary/deleteSysDictionary',NOW(3)), +('系统字典',NOW(3),NULL,'导出字典JSON',117,'GET','/sysDictionary/exportSysDictionary',NOW(3)), +('系统字典',NOW(3),NULL,'根据ID获取字典(建议选择)',118,'GET','/sysDictionary/findSysDictionary',NOW(3)), +('系统字典',NOW(3),NULL,'获取字典列表',119,'GET','/sysDictionary/getSysDictionaryList',NOW(3)), +('系统字典',NOW(3),NULL,'获取字典列表(含明细)',120,'GET','/sysDictionary/getSysDictionaryListWithDetails',NOW(3)), +('系统字典',NOW(3),NULL,'导入字典JSON',121,'POST','/sysDictionary/importSysDictionary',NOW(3)), +('系统字典',NOW(3),NULL,'更新字典',122,'PUT','/sysDictionary/updateSysDictionary',NOW(3)), +('系统字典详情',NOW(3),NULL,'新增字典内容',123,'POST','/sysDictionaryDetail/createSysDictionaryDetail',NOW(3)), +('系统字典详情',NOW(3),NULL,'删除字典内容',124,'DELETE','/sysDictionaryDetail/deleteSysDictionaryDetail',NOW(3)), +('系统字典详情',NOW(3),NULL,'根据ID获取字典内容',125,'GET','/sysDictionaryDetail/findSysDictionaryDetail',NOW(3)), +('系统字典详情',NOW(3),NULL,'根据父级ID获取字典详情',126,'GET','/sysDictionaryDetail/getDictionaryDetailsByParent',NOW(3)), +('系统字典详情',NOW(3),NULL,'获取字典详情的完整路径',127,'GET','/sysDictionaryDetail/getDictionaryPath',NOW(3)), +('系统字典详情',NOW(3),NULL,'获取字典数列表',128,'GET','/sysDictionaryDetail/getDictionaryTreeList',NOW(3)), +('系统字典详情',NOW(3),NULL,'根据分类获取字典数列表',129,'GET','/sysDictionaryDetail/getDictionaryTreeListByType',NOW(3)), +('系统字典详情',NOW(3),NULL,'获取字典内容列表',130,'GET','/sysDictionaryDetail/getSysDictionaryDetailList',NOW(3)), +('系统字典详情',NOW(3),NULL,'更新字典内容',131,'PUT','/sysDictionaryDetail/updateSysDictionaryDetail',NOW(3)), +('错误日志',NOW(3),NULL,'新建错误日志',132,'POST','/sysError/createSysError',NOW(3)), +('错误日志',NOW(3),NULL,'删除错误日志',133,'DELETE','/sysError/deleteSysError',NOW(3)), +('错误日志',NOW(3),NULL,'批量删除错误日志',134,'DELETE','/sysError/deleteSysErrorByIds',NOW(3)), +('错误日志',NOW(3),NULL,'根据ID获取错误日志',135,'GET','/sysError/findSysError',NOW(3)), +('错误日志',NOW(3),NULL,'获取错误日志列表',136,'GET','/sysError/getSysErrorList',NOW(3)), +('错误日志',NOW(3),NULL,'更新错误日志',137,'PUT','/sysError/updateSysError',NOW(3)), +('导出模板',NOW(3),NULL,'新增导出模板',138,'POST','/sysExportTemplate/createSysExportTemplate',NOW(3)), +('导出模板',NOW(3),NULL,'删除导出模板',139,'DELETE','/sysExportTemplate/deleteSysExportTemplate',NOW(3)), +('导出模板',NOW(3),NULL,'批量删除导出模板',140,'DELETE','/sysExportTemplate/deleteSysExportTemplateByIds',NOW(3)), +('导出模板',NOW(3),NULL,'导出Excel',141,'GET','/sysExportTemplate/exportExcel',NOW(3)), +('导出模板',NOW(3),NULL,'按令牌导出Excel',142,'GET','/sysExportTemplate/exportExcelByToken',NOW(3)), +('导出模板',NOW(3),NULL,'下载模板',143,'GET','/sysExportTemplate/exportTemplate',NOW(3)), +('导出模板',NOW(3),NULL,'按令牌下载模板',144,'GET','/sysExportTemplate/exportTemplateByToken',NOW(3)), +('导出模板',NOW(3),NULL,'根据ID获取导出模板',145,'GET','/sysExportTemplate/findSysExportTemplate',NOW(3)), +('导出模板',NOW(3),NULL,'获取导出模板列表',146,'GET','/sysExportTemplate/getSysExportTemplateList',NOW(3)), +('导出模板',NOW(3),NULL,'导入Excel',147,'POST','/sysExportTemplate/importExcel',NOW(3)), +('导出模板',NOW(3),NULL,'预览SQL',148,'GET','/sysExportTemplate/previewSQL',NOW(3)), +('导出模板',NOW(3),NULL,'更新导出模板',149,'PUT','/sysExportTemplate/updateSysExportTemplate',NOW(3)), +('登录日志',NOW(3),NULL,'删除登录日志',150,'DELETE','/sysLoginLog/deleteLoginLog',NOW(3)), +('登录日志',NOW(3),NULL,'批量删除登录日志',151,'DELETE','/sysLoginLog/deleteLoginLogByIds',NOW(3)), +('登录日志',NOW(3),NULL,'根据ID获取登录日志',152,'GET','/sysLoginLog/findLoginLog',NOW(3)), +('登录日志',NOW(3),NULL,'获取登录日志列表',153,'GET','/sysLoginLog/getLoginLogList',NOW(3)), +('操作记录',NOW(3),NULL,'删除操作记录',154,'DELETE','/sysOperationRecord/deleteSysOperationRecord',NOW(3)), +('操作记录',NOW(3),NULL,'批量删除操作历史',155,'DELETE','/sysOperationRecord/deleteSysOperationRecordByIds',NOW(3)), +('操作记录',NOW(3),NULL,'根据ID获取操作记录',156,'GET','/sysOperationRecord/findSysOperationRecord',NOW(3)), +('操作记录',NOW(3),NULL,'获取操作记录列表',157,'GET','/sysOperationRecord/getSysOperationRecordList',NOW(3)), +('参数管理',NOW(3),NULL,'新建参数',158,'POST','/sysParams/createSysParams',NOW(3)), +('参数管理',NOW(3),NULL,'删除参数',159,'DELETE','/sysParams/deleteSysParams',NOW(3)), +('参数管理',NOW(3),NULL,'批量删除参数',160,'DELETE','/sysParams/deleteSysParamsByIds',NOW(3)), +('参数管理',NOW(3),NULL,'根据ID获取参数',161,'GET','/sysParams/findSysParams',NOW(3)), +('参数管理',NOW(3),NULL,'按键获取参数',162,'GET','/sysParams/getSysParam',NOW(3)), +('参数管理',NOW(3),NULL,'获取参数列表',163,'GET','/sysParams/getSysParamsList',NOW(3)), +('参数管理',NOW(3),NULL,'更新参数',164,'PUT','/sysParams/updateSysParams',NOW(3)), +('版本控制',NOW(3),NULL,'删除版本',165,'DELETE','/sysVersion/deleteSysVersion',NOW(3)), +('版本控制',NOW(3),NULL,'批量删除版本',166,'DELETE','/sysVersion/deleteSysVersionByIds',NOW(3)), +('版本控制',NOW(3),NULL,'下载版本json',167,'GET','/sysVersion/downloadVersionJson',NOW(3)), +('版本控制',NOW(3),NULL,'创建版本',168,'POST','/sysVersion/exportVersion',NOW(3)), +('版本控制',NOW(3),NULL,'获取单一版本',169,'GET','/sysVersion/findSysVersion',NOW(3)), +('版本控制',NOW(3),NULL,'获取版本列表',170,'GET','/sysVersion/getSysVersionList',NOW(3)), +('版本控制',NOW(3),NULL,'同步版本',171,'POST','/sysVersion/importVersion',NOW(3)), +('系统服务',NOW(3),NULL,'获取服务器信息',172,'POST','/system/getServerInfo',NOW(3)), +('系统服务',NOW(3),NULL,'获取配置文件内容',173,'POST','/system/getSystemConfig',NOW(3)), +('系统服务',NOW(3),NULL,'重载系统配置',174,'POST','/system/reloadSystem',NOW(3)), +('系统服务',NOW(3),NULL,'设置配置文件内容',175,'POST','/system/setSystemConfig',NOW(3)), +('定时任务',NOW(3),NULL,'订阅定时任务失败告警(SSE)',176,'GET','/timedTask/alertStream',NOW(3)), +('定时任务',NOW(3),NULL,'创建定时任务',177,'POST','/timedTask/createTimedTask',NOW(3)), +('定时任务',NOW(3),NULL,'删除定时任务',178,'DELETE','/timedTask/deleteTimedTask',NOW(3)), +('定时任务',NOW(3),NULL,'获取已注册方法列表',179,'GET','/timedTask/getRegisteredMethods',NOW(3)), +('定时任务',NOW(3),NULL,'获取定时任务列表',180,'GET','/timedTask/getTimedTaskList',NOW(3)), +('定时任务',NOW(3),NULL,'获取定时任务执行日志',181,'GET','/timedTask/getTimedTaskLogList',NOW(3)), +('定时任务',NOW(3),NULL,'启用/停用定时任务',182,'POST','/timedTask/toggleTimedTask',NOW(3)), +('定时任务',NOW(3),NULL,'手动触发定时任务',183,'POST','/timedTask/triggerTimedTask',NOW(3)), +('定时任务',NOW(3),NULL,'更新定时任务',184,'PUT','/timedTask/updateTimedTask',NOW(3)), +('系统用户',NOW(3),NULL,'用户注册',185,'POST','/user/admin_register',NOW(3)), +('系统用户',NOW(3),NULL,'修改密码(建议选择)',186,'POST','/user/changePassword',NOW(3)), +('系统用户',NOW(3),NULL,'删除用户',187,'DELETE','/user/deleteUser',NOW(3)), +('系统用户',NOW(3),NULL,'获取自身信息(必选)',188,'GET','/user/getUserInfo',NOW(3)), +('系统用户',NOW(3),NULL,'获取用户列表',189,'POST','/user/getUserList',NOW(3)), +('系统用户',NOW(3),NULL,'重置用户密码',190,'POST','/user/resetPassword',NOW(3)), +('系统用户',NOW(3),NULL,'设置自身信息(必选)',191,'PUT','/user/setSelfInfo',NOW(3)), +('系统用户',NOW(3),NULL,'用户界面配置',192,'PUT','/user/setSelfSetting',NOW(3)), +('系统用户',NOW(3),NULL,'设置权限组',193,'POST','/user/setUserAuthorities',NOW(3)), +('系统用户',NOW(3),NULL,'修改用户角色(必选)',194,'POST','/user/setUserAuthority',NOW(3)), +('系统用户',NOW(3),NULL,'设置用户归属部门',195,'POST','/user/setUserDepartments',NOW(3)), +('系统用户',NOW(3),NULL,'设置用户信息',196,'PUT','/user/setUserInfo',NOW(3)), +('系统用户',NOW(3),NULL,'设置用户岗位',197,'POST','/user/setUserPositions',NOW(3)); + +INSERT INTO `sys_authorities` (`authority_id`,`authority_name`,`created_at`,`data_scope`,`default_router`,`deleted_at`,`parent_id`,`updated_at`) VALUES +(888,'超级管理员',NOW(3),1,'dashboard',NULL,0,NOW(3)); + +INSERT INTO `sys_authority_menus` (`sys_authority_authority_id`,`sys_base_menu_id`) VALUES +(888,1), +(888,9), +(888,13), +(888,16), +(888,20), +(888,28), +(888,2), +(888,10), +(888,14), +(888,17), +(888,21), +(888,29), +(888,3), +(888,11), +(888,15), +(888,18), +(888,22), +(888,4), +(888,12), +(888,19), +(888,23), +(888,30), +(888,5), +(888,24), +(888,31), +(888,6), +(888,25), +(888,34), +(888,26), +(888,35), +(888,27), +(888,32), +(888,33), +(888,7), +(888,8); + +INSERT INTO `sys_base_menus` (`active_name`,`close_tab`,`component`,`created_at`,`default_menu`,`deleted_at`,`hidden`,`icon`,`id`,`keep_alive`,`menu_level`,`name`,`parent_id`,`path`,`sort`,`title`,`transition_type`,`updated_at`) VALUES +('',0,'view/dashboard/index.vue',NOW(3),0,NULL,0,'odometer',1,0,0,'dashboard',0,'dashboard',1,'仪表盘','',NOW(3)), +('',0,'view/routerHolder.vue',NOW(3),0,NULL,0,'perm-kra',2,0,0,'permission',0,'permission',2,'权限管理','',NOW(3)), +('',0,'view/routerHolder.vue',NOW(3),0,NULL,0,'share',3,0,0,'org',0,'org',3,'组织管理','',NOW(3)), +('',0,'view/routerHolder.vue',NOW(3),0,NULL,0,'config-kra',4,0,0,'systemConfig',0,'systemConfig',4,'系统设置','',NOW(3)), +('',0,'view/routerHolder.vue',NOW(3),0,NULL,0,'monitor-kra',5,0,0,'monitor',0,'monitor',5,'运维监控','',NOW(3)), +('',0,'view/routerHolder.vue',NOW(3),0,NULL,0,'folder-opened',6,0,0,'media',0,'media',6,'媒体管理','',NOW(3)), +('',0,'view/routerHolder.vue',NOW(3),0,NULL,0,'cherry',7,0,0,'extensions',0,'extensions',10,'扩展功能','',NOW(3)), +('',0,'view/person/person.vue',NOW(3),0,NULL,1,'postcard',8,0,0,'person',0,'person',13,'个人信息','',NOW(3)), +('',0,'view/superAdmin/authority/authority.vue',NOW(3),0,NULL,0,'role-kra',9,0,1,'authority',2,'authority',1,'角色管理','',NOW(3)), +('',0,'view/superAdmin/menu/menu.vue',NOW(3),0,NULL,0,'tickets',10,1,1,'menu',2,'menu',2,'菜单管理','',NOW(3)), +('',0,'view/superAdmin/api/api.vue',NOW(3),0,NULL,0,'api-kra',11,1,1,'api',2,'api',3,'api管理','',NOW(3)), +('',0,'view/systemTools/apiToken/index.vue',NOW(3),0,NULL,0,'key',12,0,1,'apiToken',2,'apiToken',4,'API Token','',NOW(3)), +('',0,'view/superAdmin/user/user.vue',NOW(3),0,NULL,0,'user',13,0,1,'user',3,'user',1,'用户管理','',NOW(3)), +('',0,'view/superAdmin/department/department.vue',NOW(3),0,NULL,0,'office-building',14,0,1,'department',3,'department',2,'部门管理','',NOW(3)), +('',0,'view/superAdmin/position/position.vue',NOW(3),0,NULL,0,'postcard',15,0,1,'position',3,'position',3,'岗位管理','',NOW(3)), +('',0,'view/systemTools/system/system.vue',NOW(3),0,NULL,0,'config-file-kra',16,0,1,'system',4,'system',1,'配置文件','',NOW(3)), +('',0,'view/superAdmin/dictionary/sysDictionary.vue',NOW(3),0,NULL,0,'notebook',17,0,1,'dictionary',4,'dictionary',2,'字典管理','',NOW(3)), +('',0,'view/superAdmin/params/sysParams.vue',NOW(3),0,NULL,0,'set-up',18,0,1,'sysParams',4,'sysParams',3,'参数管理','',NOW(3)), +('',0,'view/system/security/index.vue',NOW(3),0,NULL,0,'security-kra',19,0,1,'security',4,'security',4,'安全配置','',NOW(3)), +('',0,'view/superAdmin/operation/sysOperationRecord.vue',NOW(3),0,NULL,0,'document',20,0,1,'operation',5,'operation',1,'操作历史','',NOW(3)), +('',0,'view/systemTools/loginLog/index.vue',NOW(3),0,NULL,0,'clock',21,0,1,'loginLog',5,'loginLog',2,'登录日志','',NOW(3)), +('',0,'view/systemTools/sysError/sysError.vue',NOW(3),0,NULL,0,'error-kra',22,0,1,'sysError',5,'sysError',3,'错误日志','',NOW(3)), +('',0,'view/systemTools/version/version.vue',NOW(3),0,NULL,0,'version-kra',23,0,1,'sysVersion',5,'sysVersion',4,'版本管理','',NOW(3)), +('',0,'view/system/state.vue',NOW(3),0,NULL,0,'server',24,0,1,'state',5,'state',5,'服务器状态','',NOW(3)), +('',0,'view/superAdmin/dataAccessLog/dataAccessLog.vue',NOW(3),0,NULL,0,'warning',25,0,1,'dataAccessLog',5,'dataAccessLog',6,'数据权限审计','',NOW(3)), +('',0,'view/systemTools/timedTask/index.vue',NOW(3),0,NULL,0,'timer',26,0,1,'timedTask',5,'timedTask',7,'定时任务','',NOW(3)), +('',0,'view/systemTools/logViewer/index.vue',NOW(3),0,NULL,0,'document',27,0,1,'logViewer',5,'logViewer',8,'文件日志','',NOW(3)), +('',0,'view/media/upload.vue',NOW(3),0,NULL,0,'upload',28,0,1,'upload',6,'upload',1,'媒体库(上传下载)','',NOW(3)), +('',0,'view/media/chunkUpload.vue',NOW(3),0,NULL,0,'folder-add',29,0,1,'chunkUpload',6,'chunkUpload',2,'大文件上传','',NOW(3)), +('',0,'modules/email/view/index.vue',NOW(3),0,NULL,0,'message',30,0,1,'email',7,'email',4,'邮件发送','',NOW(3)), +('',0,'modules/announcement/view/info.vue',NOW(3),0,NULL,0,'bell',31,0,1,'anInfo',7,'anInfo',5,'公告管理','',NOW(3)), +('',0,'view/systemTools/integration/config.vue',NOW(3),0,NULL,0,'connection',32,0,1,'integrationConfig',7,'integrationConfig',8,'通信集成','',NOW(3)), +('',0,'view/systemTools/sms/index.vue',NOW(3),0,NULL,0,'message',33,0,1,'sms',7,'sms',9,'短信服务','',NOW(3)), +('',0,'view/systemTools/payment/orders.vue',NOW(3),0,NULL,0,'wallet',34,0,1,'paymentOrders',7,'paymentOrders',6,'支付订单','',NOW(3)), +('',0,'view/systemTools/payment/config.vue',NOW(3),0,NULL,0,'credit-card',35,0,1,'paymentConfig',7,'paymentConfig',7,'支付配置','',NOW(3)); + +INSERT INTO `sys_export_templates` (`created_at`,`db_name`,`deleted_at`,`id`,`import_sql`,`limit`,`name`,`order`,`sql`,`table_name`,`template_id`,`template_info`,`updated_at`) VALUES +(NOW(3),'',NULL,1,'',NULL,'api','','','sys_apis','api','{ +"path":"路径", +"method":"方法(大写)", +"description":"方法介绍", +"api_group":"方法分组" +}',NOW(3)); + +INSERT INTO `sys_ignore_apis` (`created_at`,`deleted_at`,`id`,`method`,`path`,`updated_at`) VALUES +(NOW(3),NULL,1,'GET','/api/freshCasbin',NOW(3)), +(NOW(3),NULL,2,'GET','/health',NOW(3)), +(NOW(3),NULL,3,'GET','/swagger/*any',NOW(3)), +(NOW(3),NULL,4,'GET','/uploads/file/*filepath',NOW(3)), +(NOW(3),NULL,5,'HEAD','/uploads/file/*filepath',NOW(3)), +(NOW(3),NULL,6,'POST','/system/reloadSystem',NOW(3)), +(NOW(3),NULL,7,'POST','/base/login',NOW(3)), +(NOW(3),NULL,8,'POST','/base/captcha',NOW(3)), +(NOW(3),NULL,9,'POST','/init/initdb',NOW(3)), +(NOW(3),NULL,10,'POST','/init/checkdb',NOW(3)), +(NOW(3),NULL,11,'GET','/info/getInfoPublic',NOW(3)); + +INSERT INTO `sys_integration_configs` (`config`,`created_at`,`enabled`,`id`,`kind`,`provider`,`updated_at`) VALUES +('{"broker":"tcp://127.0.0.1:1883","clean_session":true,"client_id":"kra","connect_timeout":10,"keep_alive":30,"password":"","reconnect_interval":5,"username":""}',NOW(3),0,1,'mq','emqx',NOW(3)), +('{"allow_auto_topic_creation":false,"brokers":["127.0.0.1:9092"],"client_id":"kra","connect_timeout":10,"group_id":"kra","max_bytes":10485760,"max_wait":1,"min_bytes":1,"password":"","reconnect_interval":5,"start_offset":"earliest","tls":false,"tls_skip_verify":false,"username":""}',NOW(3),0,2,'mq','kafka',NOW(3)), +('{"auto_delete":false,"connect_timeout":10,"durable":true,"exchange":"kra","exchange_type":"topic","heartbeat":10,"host":"127.0.0.1","password":"guest","port":5672,"prefetch_count":10,"queue":"kra","reconnect_interval":5,"routing_key":"#","tls":false,"username":"guest","vhost":"/"}',NOW(3),0,3,'mq','rabbitmq',NOW(3)), +('{"allow_origins":[],"concurrent_message_handling":false,"max_message_size":0,"message_buffer_size":0,"path":"/ws","ping_period":"54s","pong_wait":"60s","write_wait":"10s"}',NOW(3),0,4,'websocket','melody',NOW(3)), +('{"app_id":"","environment":"production","gateway_url":"https://openapi.alipay.com/gateway.do","method":"alipay.trade.create","notify_url":"","private_key":"","public_key":"","return_url":"","sign_type":"RSA2","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,5,'payment','alipay',NOW(3)), +('{"api_base_url":"https://openapi.alipay.com","app_cert":"","app_id":"","environment":"production","gateway_url":"https://openapi.alipay.com/gateway.do","method":"alipay.trade.create","notify_url":"","private_key":"","public_cert":"","return_url":"","root_cert":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,6,'payment','alipay-v3',NOW(3)), +('{"app_id":"","currency":"CNY","cus_id":"","environment":"production","notify_url":"","org_id":"","pay_type":"W02","private_key":"","public_key":"","query_order_type":"reqsn","return_url":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,7,'payment','allinpay',NOW(3)), +('{"bundle_id":"","environment":"production","issuer_id":"","key_id":"","notify_url":"","price_divisor":10,"private_key":"","return_url":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false,"test_product_id":"","test_transaction_id":""}',NOW(3),0,8,'payment','apple-iap',NOW(3)), +('{"app_id":"","app_key":"","callback_provider_trade_no_field":"","callback_status_field":"","callback_success_values":"","callback_trade_no_field":"","create_url":"","merchant_id":"","notify_url":"","protocol_version":"v1","query_amount_field":"","query_amount_scale":1,"query_currency_field":"","query_provider_trade_no_field":"","query_status_field":"","query_success_values":"","query_trade_no_field":"","query_url":"","refund_failure_values":"","refund_status_field":"","refund_success_values":"","refund_url":"","return_url":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,9,'payment','chinaums',NOW(3)), +('{"api_key":"","app_id":"","environment":"production","merchant_id":"","notify_url":"","platform_cert":"","platform_serial_no":"","private_key":"","return_url":"","serial_no":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false,"trade_type":"jsapi"}',NOW(3),0,10,'payment','douyin',NOW(3)), +('{"app_id":"","app_key":"","callback_provider_trade_no_field":"","callback_status_field":"","callback_success_values":"","callback_trade_no_field":"","create_url":"","merchant_id":"","notify_url":"","protocol_version":"v1","query_amount_field":"","query_amount_scale":1,"query_currency_field":"","query_provider_trade_no_field":"","query_status_field":"","query_success_values":"","query_trade_no_field":"","query_url":"","refund_failure_values":"","refund_status_field":"","refund_success_values":"","refund_url":"","return_url":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,11,'payment','douyin-game-pay',NOW(3)), +('{"channel":"Wechat","credential_code":"","currency":"CNY","environment":"production","method":"jsapi","notify_url":"","partner_code":"","return_url":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,12,'payment','lakala',NOW(3)), +('{"auto_capture":true,"cancel_url":"","client_id":"","client_secret":"","environment":"sandbox","notify_url":"","return_url":"","test_amount":1,"test_currency":"USD","test_extra":"{}","test_mode":false,"webhook_id":""}',NOW(3),0,13,'payment','paypal',NOW(3)), +('{"api_key":"","cert_file":"","environment":"production","key_file":"","mch_id":"","notify_url":"","return_url":"","sign_type":"MD5","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false,"trade_type":"NATIVE"}',NOW(3),0,14,'payment','qq',NOW(3)), +('{"access_token":"","currency":"CNY","environment":"production","inst_no":"","key":"","merchant_no":"","notify_url":"","pay_type":"010","return_url":"","terminal_id":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,15,'payment','saobei',NOW(3)), +('{"app_id":"","app_key":"","callback_provider_trade_no_field":"","callback_status_field":"","callback_success_values":"","callback_trade_no_field":"","create_url":"","merchant_id":"","notify_url":"","protocol_version":"v1","query_amount_field":"","query_amount_scale":1,"query_currency_field":"","query_provider_trade_no_field":"","query_status_field":"","query_success_values":"","query_trade_no_field":"","query_url":"","refund_failure_values":"","refund_status_field":"","refund_success_values":"","refund_url":"","return_url":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,16,'payment','sft',NOW(3)), +('{"app_id":"","app_key":"","callback_provider_trade_no_field":"","callback_status_field":"","callback_success_values":"","callback_trade_no_field":"","create_url":"","merchant_id":"","notify_url":"","protocol_version":"v1","query_amount_field":"","query_amount_scale":1,"query_currency_field":"","query_provider_trade_no_field":"","query_status_field":"","query_success_values":"","query_trade_no_field":"","query_url":"","refund_failure_values":"","refund_status_field":"","refund_success_values":"","refund_url":"","return_url":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,17,'payment','supper-pay',NOW(3)), +('{"app_id":"","app_key":"","callback_provider_trade_no_field":"","callback_status_field":"","callback_success_values":"","callback_trade_no_field":"","create_url":"","merchant_id":"","notify_url":"","protocol_version":"v1","query_amount_field":"","query_amount_scale":1,"query_currency_field":"","query_provider_trade_no_field":"","query_status_field":"","query_success_values":"","query_trade_no_field":"","query_url":"","refund_failure_values":"","refund_status_field":"","refund_success_values":"","refund_url":"","return_url":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false}',NOW(3),0,18,'payment','wechat-game-pay',NOW(3)), +('{"app_id":"","client_cert":"","client_key":"","mch_key":"","merchant_id":"","notify_url":"","return_url":"","sign_type":"MD5","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false,"trade_type":"NATIVE"}',NOW(3),0,19,'payment','wechat-v2',NOW(3)), +('{"api_v3_key":"","app_id":"","merchant_id":"","notify_url":"","platform_cert":"","platform_serial_no":"","private_key":"","return_url":"","serial_no":"","test_amount":1,"test_currency":"CNY","test_extra":"{}","test_mode":false,"trade_type":"jsapi"}',NOW(3),0,20,'payment','wechat-v3',NOW(3)); + +INSERT INTO `sys_schema_migrations` (`id`) VALUES +('202608200002_system_schema'), +('202608210002_communication_surface'), +('202608210003_communication_test_surface'), +('202608200001_data_infrastructure'), +('202608210001_communication_integration_defaults'), +('202608200004_payment_defaults'), +('202608290004_kafka_integration_default'), +('202608290005_communication_integration_default_upgrade'), +('202609290001_sms_admin_surface'), +('202608220001_task_schema'), +('202608200003_payment_schema'), +('202608290001_payment_event_schema'), +('202608290002_payment_admin_surface'), +('202608290003_payment_order_read_permissions'); + +INSERT INTO `sys_security_config` (`captcha_open`,`captcha_timeout`,`created_at`,`deleted_at`,`force_new_user_change_password`,`id`,`img_height`,`img_width`,`key_long`,`limit_count`,`limit_enable`,`limit_window`,`lock_duration`,`lock_enable`,`lock_threshold`,`pwd_expire_days`,`pwd_expire_enable`,`pwd_min_length`,`pwd_require_digit`,`pwd_require_lower`,`pwd_require_special`,`pwd_require_upper`,`updated_at`) VALUES +(0,3600,NOW(3),NULL,0,1,80,240,6,30,0,60,30,0,5,90,0,8,0,0,0,0,NOW(3)); + +INSERT INTO `sys_timed_tasks` (`created_at`,`deleted_at`,`description`,`enabled`,`executor_type`,`http_allow_private`,`http_body`,`http_header`,`http_method`,`http_url`,`id`,`method_name`,`name`,`params`,`spec`,`updated_at`,`with_seconds`) VALUES +(NOW(3),NULL,'定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)',0,'method',0,'',NULL,'','',1,'ClearDB','ClearDB',NULL,'@daily',NOW(3),0), +(NOW(3),NULL,'定时清理过期大文件上传会话',0,'method',0,'',NULL,'','',2,'CleanStaleUploads','CleanStaleUploads',NULL,'@hourly',NOW(3),0); + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'user',CAST(`user_id` AS CHAR) FROM `sys_user` WHERE del_flag='0' ORDER BY `user_id`; + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'role',CAST(`role_id` AS CHAR) FROM `sys_role` WHERE del_flag='0' ORDER BY `role_id`; + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'dept',CAST(`dept_id` AS CHAR) FROM `sys_dept` WHERE del_flag='0' ORDER BY `dept_id`; + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'post',CAST(`post_id` AS CHAR) FROM `sys_post` WHERE del_flag='0' ORDER BY `post_id`; + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'file',CAST(`oss_id` AS CHAR) FROM `sys_oss` WHERE 1=1 ORDER BY `oss_id`; + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'menu',CAST(`menu_id` AS CHAR) FROM `sys_menu` WHERE menu_id IN ('2084194096229949441','2084194830778408961','2084202636386541569','2084224284976246785','2084224583841378306','2084449385185308674','2084453258847350785','2084458436631752706','2084466605093318657','2084475309624684546','2084478005203202049','2084480429515108353','2084480593562726401','2086033702105710593','2086034409621880833','2086621845875916801','2086623544338350082','2086643768701812738','2086643813702500354','2086643981961199618','2086644092393029633','2087069357514440706','2087385085920727041','2087385679095975937','2087385949666332673','2087386325354336258','2087444380922167298','2087447465111326721','2087831493110956033','2095702540414996481','2095702652168032258') ORDER BY `menu_id`; + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'dict',CAST(`dict_id` AS CHAR) FROM `sys_dict_type` WHERE 1=1 ORDER BY `dict_id`; + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'dict_item',CAST(`dict_code` AS CHAR) FROM `sys_dict_data` WHERE 1=1 ORDER BY `dict_code`; + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'notice',CAST(`notice_id` AS CHAR) FROM `sys_notice` WHERE 1=1 ORDER BY `notice_id`; + +INSERT INTO sw_kra_id_map(entity,old_id) SELECT 'task',CAST(`id` AS CHAR) FROM `sj_job` WHERE deleted=0 ORDER BY `id`; + +UPDATE sw_kra_id_map SET new_id=10000+id; + +UPDATE sw_kra_id_map m JOIN sys_role r ON m.entity='role' AND m.old_id=CAST(r.role_id AS CHAR) SET m.new_id=888 WHERE r.role_key='superadmin'; + +INSERT INTO sys_authorities(authority_id,created_at,updated_at,authority_name,parent_id,data_scope,default_router) SELECT m.new_id,r.create_time,r.update_time,r.role_name,888,CASE r.data_scope WHEN '1' THEN 1 WHEN '2' THEN 5 WHEN '3' THEN 3 WHEN '4' THEN 2 WHEN '5' THEN 4 END,'dashboard' FROM sys_role r JOIN sw_kra_id_map m ON m.entity='role' AND m.old_id=CAST(r.role_id AS CHAR) WHERE r.del_flag='0' AND r.role_key<>'superadmin'; + +INSERT INTO sys_departments(id,created_at,updated_at,name,parent_id,ancestors,sort,leader_id,status) SELECT m.new_id,d.create_time,d.update_time,d.dept_name,COALESCE(p.new_id,0),'0',d.order_num,COALESCE(u.new_id,0),d.status='0' FROM sys_dept d JOIN sw_kra_id_map m ON m.entity='dept' AND m.old_id=CAST(d.dept_id AS CHAR) LEFT JOIN sw_kra_id_map p ON p.entity='dept' AND p.old_id=CAST(d.parent_id AS CHAR) LEFT JOIN sw_kra_id_map u ON u.entity='user' AND u.old_id=CAST(d.leader AS CHAR) WHERE d.del_flag='0'; + +SET v_iter=0; WHILE v_iter<16 DO UPDATE sys_departments d JOIN sys_departments p ON p.id=d.parent_id SET d.ancestors=CONCAT(p.ancestors,',',p.id); SET v_iter=v_iter+1; END WHILE; + +INSERT INTO sys_positions(id,created_at,updated_at,name,code,sort,status,remark,dept_id) SELECT m.new_id,p.create_time,p.update_time,p.post_name,p.post_code,p.post_sort,p.status='0',p.remark,d.new_id FROM sys_post p JOIN sw_kra_id_map m ON m.entity='post' AND m.old_id=CAST(p.post_id AS CHAR) LEFT JOIN sw_kra_id_map d ON d.entity='dept' AND d.old_id=CAST(p.dept_id AS CHAR) WHERE p.del_flag='0'; + +INSERT INTO sys_users(id,created_at,updated_at,uuid,username,password,nick_name,header_img,authority_id,dept_id,phone,email,enable,origin_setting,password_updated_at,must_change_password,level,parent_id,id_path,real_name,qq_number,business_status,reject_reason) SELECT m.new_id,u.create_time,u.update_time,UUID(),u.user_name,u.password,u.nick_name,COALESCE(o.url,''),rm.new_id,COALESCE(dm.new_id,0),u.phone_number,u.email,u.status='0','{}',COALESCE(u.update_time,u.create_time,NOW(3)),0,u.level,COALESCE(pm.new_id,0),'0',COALESCE(u.real_name,''),COALESCE(u.qq_number,''),CAST(u.status AS UNSIGNED),COALESCE(u.reject_reason,'') FROM sys_user u JOIN sw_kra_id_map m ON m.entity='user' AND m.old_id=CAST(u.user_id AS CHAR) JOIN sys_user_role ur ON ur.user_id=u.user_id JOIN sw_kra_id_map rm ON rm.entity='role' AND rm.old_id=CAST(ur.role_id AS CHAR) LEFT JOIN sw_kra_id_map dm ON dm.entity='dept' AND dm.old_id=CAST(u.dept_id AS CHAR) LEFT JOIN sw_kra_id_map pm ON pm.entity='user' AND pm.old_id=CAST(u.parent_id AS CHAR) LEFT JOIN sys_oss o ON o.oss_id=u.avatar WHERE u.del_flag='0'; + +SET v_iter=0; WHILE v_iter<4 DO UPDATE sys_users u JOIN sys_users p ON p.id=u.parent_id SET u.id_path=CONCAT(p.id_path,',',p.id); SET v_iter=v_iter+1; END WHILE; + +INSERT INTO sys_user_authority(sys_user_id,sys_authority_authority_id) SELECT um.new_id,rm.new_id FROM sys_user_role x JOIN sw_kra_id_map um ON um.entity='user' AND um.old_id=CAST(x.user_id AS CHAR) JOIN sw_kra_id_map rm ON rm.entity='role' AND rm.old_id=CAST(x.role_id AS CHAR); + +INSERT INTO sys_user_departments(sys_user_id,sys_department_id) SELECT id,dept_id FROM sys_users WHERE dept_id<>0; + +INSERT INTO sys_user_positions(sys_user_id,sys_position_id) SELECT um.new_id,pm.new_id FROM sys_user_post x JOIN sw_kra_id_map um ON um.entity='user' AND um.old_id=CAST(x.user_id AS CHAR) JOIN sw_kra_id_map pm ON pm.entity='post' AND pm.old_id=CAST(x.post_id AS CHAR); + +INSERT INTO media_file_upload_and_downloads(id,created_at,updated_at,name,class_id,url,tag,`key`,size,mime,md5,user_id) SELECT m.new_id,o.create_time,o.update_time,o.original_name,0,o.url,o.file_suffix,o.file_name,COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(o.ext1),o.ext1,JSON_OBJECT()),'$.fileSize')) AS UNSIGNED),0),COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(o.ext1),o.ext1,JSON_OBJECT()),'$.contentType')),'null'),''),COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(o.ext1),o.ext1,JSON_OBJECT()),'$.md5')),'null'),''),COALESCE(um.new_id,0) FROM sys_oss o JOIN sw_kra_id_map m ON m.entity='file' AND m.old_id=CAST(o.oss_id AS CHAR) LEFT JOIN sw_kra_id_map um ON um.entity='user' AND um.old_id=CAST(o.create_by AS CHAR); + +INSERT INTO sys_dictionaries(id,created_at,updated_at,name,type,status,`desc`,parent_id) SELECT m.new_id,d.create_time,d.update_time,d.dict_name,d.dict_type,1,d.remark,0 FROM sys_dict_type d JOIN sw_kra_id_map m ON m.entity='dict' AND m.old_id=CAST(d.dict_id AS CHAR); + +INSERT INTO sys_dictionary_details(id,created_at,updated_at,label,value,extend,status,sort,sys_dictionary_id,parent_id,level,path) SELECT m.new_id,d.create_time,d.update_time,d.dict_label,d.dict_value,JSON_OBJECT('cssClass',d.css_class,'listClass',d.list_class,'isDefault',d.is_default),1,d.dict_sort,tm.new_id,0,0,'0' FROM sys_dict_data d JOIN sw_kra_id_map m ON m.entity='dict_item' AND m.old_id=CAST(d.dict_code AS CHAR) JOIN sys_dict_type t ON t.dict_type=d.dict_type JOIN sw_kra_id_map tm ON tm.entity='dict' AND tm.old_id=CAST(t.dict_id AS CHAR); + +INSERT INTO sys_announcements(id,created_at,updated_at,title,content,user_id,attachments) SELECT m.new_id,n.create_time,n.update_time,n.notice_title,CONVERT(n.notice_content USING utf8mb4),um.new_id,JSON_ARRAY() FROM sys_notice n JOIN sw_kra_id_map m ON m.entity='notice' AND m.old_id=CAST(n.notice_id AS CHAR) LEFT JOIN sw_kra_id_map um ON um.entity='user' AND um.old_id=CAST(n.create_by AS CHAR) WHERE n.status='0'; + +INSERT INTO sys_params(created_at,updated_at,name,`key`,value,`desc`) SELECT NOW(3),NOW(3),config_name,CONCAT('sw.legacy.',config_key),config_value,'Legacy non-secret setting; application adapter must consume it explicitly' FROM sys_config WHERE config_key IN('sys.account.registerUser','sys.oss.previewListResource'); + +-- 六、导入新增业务菜单及授权;原工作台页面和查询按钮不再导入。 + +INSERT INTO sw_kra_menu_permissions(source_menu_id,source_permission,button_key) VALUES +('2084194096229949441','',''), +('2084194830778408961','system:user:list','sw:business:list'), +('2084202636386541569','system:promote:list','sw:promote:list'), +('2084224284976246785','',''), +('2084224583841378306','system:channelData:list','sw:channelData:list'), +('2084449385185308674','system:spend:list','sw:spend:list'), +('2084453258847350785','system:register:list','sw:register:list'), +('2084458436631752706','system:gameData:list','sw:gameData:list'), +('2084466605093318657','system:serverAnalysis:list','sw:serverAnalysis:list'), +('2084475309624684546','system:rank:channel','sw:rank:channel'), +('2084478005203202049','',''), +('2084480429515108353','system:alertRecord:list','sw:alertRecord:list'), +('2084480593562726401','system:alertRule:list','sw:alertRule:list'), +('2086033702105710593','system:alertRecord:audit','sw:alertRecord:audit'), +('2086034409621880833','system:alertRecord:query','sw:alertRecord:query'), +('2086621845875916801','system:alertRecord:process','sw:alertRecord:process'), +('2086623544338350082','system:oss:upload','sw:oss:upload'), +('2086643768701812738','system:alertRule:query','sw:alertRule:query'), +('2086643813702500354','system:alertRule:add','sw:alertRule:add'), +('2086643981961199618','system:alertRule:edit','sw:alertRule:edit'), +('2086644092393029633','system:alertRule:remove','sw:alertRule:remove'), +('2087069357514440706','system:promote:add','sw:promote:add'), +('2087385085920727041','system:user:add','sw:business:add'), +('2087385679095975937','system:user:query','sw:business:query'), +('2087385949666332673','system:user:edit','sw:business:edit'), +('2087386325354336258','system:user:export','sw:business:export'), +('2087444380922167298','system:user:bind','sw:business:bind'), +('2087447465111326721','system:promote:list','sw:promote:list'), +('2087831493110956033','system:register:export','sw:register:export'), +('2095702540414996481','',''), +('2095702652168032258','system:promoteActivePool:list','sw:promoteActivePool:list'), +('2087072517465325569','system:workbench:query','sw:workbench:query'), +('2087365025688768514','system:workbench:query','sw:workbench:query'); + +INSERT INTO sys_base_menus(id,created_at,updated_at,menu_level,parent_id,path,name,hidden,component,sort,active_name,keep_alive,default_menu,title,icon,close_tab,transition_type) SELECT m.new_id,n.create_time,n.update_time,IF(n.parent_id=0,0,1),COALESCE(pm.new_id,0),IF(n.parent_id=0,CONCAT('sw-',n.path),n.path),CONCAT('sw_',n.path),n.visible='1',IF(n.menu_type='M','view/routerHolder.vue',CONCAT('view/sw/',n.path,'.vue')),n.order_num,'',n.is_cache='Y',0,n.menu_name,CASE n.path + WHEN 'BusMgmt' THEN 'Briefcase' + WHEN 'BusinessList' THEN 'UserFilled' + WHEN 'ChannelList' THEN 'Connection' + WHEN 'ChannelActivationList' THEN 'CircleCheck' + WHEN 'DataAnalysis' THEN 'DataAnalysis' + WHEN 'ChannelDataStatistics' THEN 'Histogram' + WHEN 'RechargeDetails' THEN 'Money' + WHEN 'RegistrationDetails' THEN 'User' + WHEN 'GameAnalytics' THEN 'TrendCharts' + WHEN 'ServerAnalysis' THEN 'Monitor' + WHEN 'RankingAnalysis' THEN 'Star' + WHEN 'AlertCenter' THEN 'Bell' + WHEN 'AlertList' THEN 'Warning' + WHEN 'AlertRules' THEN 'Setting' + ELSE 'Menu' END,0,'' FROM sys_menu n JOIN sw_kra_id_map m ON m.entity='menu' AND m.old_id=CAST(n.menu_id AS CHAR) LEFT JOIN sw_kra_id_map pm ON pm.entity='menu' AND pm.old_id=CAST(n.parent_id AS CHAR) WHERE n.menu_type IN('M','C'); + +UPDATE sys_base_menus SET sort=CASE name WHEN 'dashboard' THEN 1 WHEN 'sw_BusMgmt' THEN 2 WHEN 'sw_DataAnalysis' THEN 3 WHEN 'sw_AlertCenter' THEN 4 WHEN 'permission' THEN 5 WHEN 'org' THEN 6 WHEN 'systemConfig' THEN 7 WHEN 'monitor' THEN 8 WHEN 'media' THEN 9 WHEN 'extensions' THEN 13 WHEN 'person' THEN 16 ELSE sort END WHERE parent_id=0; + +INSERT INTO sys_base_menu_btns(id,created_at,updated_at,name,`desc`,sys_base_menu_id) SELECT m.new_id,n.create_time,n.update_time,p.button_key,n.menu_name,pm.new_id FROM sys_menu n JOIN sw_kra_id_map m ON m.entity='menu' AND m.old_id=CAST(n.menu_id AS CHAR) JOIN sw_kra_id_map pm ON pm.entity='menu' AND pm.old_id=CAST(n.parent_id AS CHAR) JOIN sw_kra_menu_permissions p ON p.source_menu_id=n.menu_id WHERE n.menu_type='F'; + +INSERT INTO sys_authority_menus(sys_authority_authority_id,sys_base_menu_id) SELECT rm.new_id,mm.new_id FROM sys_role_menu x JOIN sw_kra_id_map rm ON rm.entity='role' AND rm.old_id=CAST(x.role_id AS CHAR) JOIN sw_kra_id_map mm ON mm.entity='menu' AND mm.old_id=CAST(x.menu_id AS CHAR) JOIN sys_menu m ON m.menu_id=x.menu_id WHERE m.menu_type IN('M','C'); + +INSERT INTO sys_authority_btns(authority_id,sys_menu_id,sys_base_menu_btn_id) SELECT rm.new_id,pm.new_id,mm.new_id FROM sys_role_menu x JOIN sw_kra_id_map rm ON rm.entity='role' AND rm.old_id=CAST(x.role_id AS CHAR) JOIN sw_kra_id_map mm ON mm.entity='menu' AND mm.old_id=CAST(x.menu_id AS CHAR) JOIN sys_menu m ON m.menu_id=x.menu_id JOIN sw_kra_id_map pm ON pm.entity='menu' AND pm.old_id=CAST(m.parent_id AS CHAR) WHERE m.menu_type='F'; + +INSERT INTO sys_authority_menus(sys_authority_authority_id,sys_base_menu_id) SELECT 888,m.new_id FROM sw_kra_id_map m JOIN sys_menu n ON m.entity='menu' AND m.old_id=CAST(n.menu_id AS CHAR) WHERE n.menu_type IN('M','C'); + +INSERT INTO sys_authority_btns(authority_id,sys_menu_id,sys_base_menu_btn_id) SELECT 888,sys_base_menu_id,id FROM sys_base_menu_btns; + +INSERT INTO sys_authority_menus(sys_authority_authority_id,sys_base_menu_id) SELECT a.authority_id,m.id FROM sys_authorities a JOIN sys_base_menus m ON m.name='dashboard' WHERE a.authority_id<>888 AND a.default_router='dashboard'; + +INSERT INTO sw_kra_api_contract(method,source_path,target_path,source_permission,controller) VALUES +('GET','/system/user/preference','/sw/user/preference','__authenticated__','UserPreference'), +('PUT','/system/user/preference','/sw/user/preference','__authenticated__','UserPreference'), +('GET','/system/promote/list','/sw/promote/list','system:promote:list','Promote'), +('GET','/system/promote/portrait','/sw/promote/portrait','system:promote:list','Promote'), +('GET','/system/promote/game-distribution','/sw/promote/game-distribution','system:promote:list','Promote'), +('GET','/system/promote/bindOptions','/sw/promote/bindOptions','system:promote:add','Promote'), +('POST','/system/promote','/sw/promote','system:promote:add','Promote'), +('GET','/system/promoteActivePool/bindList','/sw/promoteActivePool/bindList','system:promoteActivePool:list','PromoteActivePool'), +('GET','/system/promoteActivePool/bindSpendDetail','/sw/promoteActivePool/bindSpendDetail','system:promoteActivePool:list','PromoteActivePool'), +('GET','/system/workbench/summary','/sw/workbench/summary','system:workbench:query','Workbench'), +('GET','/system/workbench/registerTop','/sw/workbench/registerTop','system:workbench:query','Workbench'), +('GET','/system/workbench/payRank','/sw/workbench/payRank','system:workbench:query','Workbench'), +('GET','/system/workbench/unloginTop','/sw/workbench/unloginTop','system:workbench:query','Workbench'), +('GET','/system/rank/channel','/sw/rank/channel','system:rank:channel','Rank'), +('GET','/system/rank/business','/sw/rank/business','system:rank:channel','Rank'), +('GET','/system/rank/game','/sw/rank/game','system:rank:channel','Rank'), +('GET','/system/gameData/list','/sw/gameData/list','system:gameData:list','GameData'), +('GET','/system/channelData/list','/sw/channelData/list','system:channelData:list','ChannelData'), +('GET','/system/register/list','/sw/register/list','system:register:list','Register'), +('POST','/system/register/export','/sw/register/export','system:register:export','Register'), +('GET','/system/spend/list','/sw/spend/list','system:spend:list','Spend'), +('POST','/system/spend/export','/sw/spend/export','system:spend:export','Spend'), +('GET','/system/serverAnalysis/games','/sw/serverAnalysis/games','system:serverAnalysis:list','ServerAnalysis'), +('GET','/system/serverAnalysis/servers','/sw/serverAnalysis/servers','system:serverAnalysis:list','ServerAnalysis'), +('GET','/system/serverAnalysis/list','/sw/serverAnalysis/list','system:serverAnalysis:list','ServerAnalysis'), +('GET','/system/alertRule/list','/sw/alertRule/list','system:alertRule:list','SysAlertRule'), +('GET','/system/alertRule/ruleTypes','/sw/alertRule/ruleTypes','system:alertRule:list','SysAlertRule'), +('GET','/system/alertRule/{id}','/sw/alertRule/:id','system:alertRule:query','SysAlertRule'), +('POST','/system/alertRule/add','/sw/alertRule/add','system:alertRule:add','SysAlertRule'), +('PUT','/system/alertRule/edit','/sw/alertRule/edit','system:alertRule:edit','SysAlertRule'), +('DELETE','/system/alertRule/{id}','/sw/alertRule/:id','system:alertRule:remove','SysAlertRule'), +('PUT','/system/alertRule/toggle/{id}','/sw/alertRule/toggle/:id','system:alertRule:edit','SysAlertRule'), +('GET','/system/alertRecord/list','/sw/alertRecord/list','system:alertRecord:list','SysAlertRecord'), +('GET','/system/alertRecord/detail/{id}','/sw/alertRecord/detail/:id','system:alertRecord:query','SysAlertRecord'), +('POST','/system/alertRecord/startProcess','/sw/alertRecord/startProcess','system:alertRecord:process','SysAlertRecord'), +('POST','/system/alertRecord/submitAudit','/sw/alertRecord/submitAudit','system:alertRecord:process','SysAlertRecord'), +('POST','/system/alertRecord/approve','/sw/alertRecord/approve','system:alertRecord:audit','SysAlertRecord'), +('POST','/system/alertRecord/reject','/sw/alertRecord/reject','system:alertRecord:audit','SysAlertRecord'), +('POST','/system/alertRecord/reProcess','/sw/alertRecord/reProcess','system:alertRecord:process','SysAlertRecord'), +('POST','/system/alertRecord/addNote','/sw/alertRecord/addNote','system:alertRecord:process','SysAlertRecord'), +('GET','/system/user/list','/sw/business/list','system:user:list','SysUser'), +('GET','/system/user/{userId}/promotes','/sw/business/:userId/promotes','system:user:query','SysUser'), +('GET','/system/user/{userId}/portrait','/sw/business/:userId/portrait','system:user:query','SysUser'), +('GET','/system/user/{userId}/bindPlatform','/sw/business/:userId/bindPlatform','system:user:query','SysUser'), +('POST','/system/user/bindPlatform','/sw/business/bindPlatform','system:user:bind','SysUser'), +('GET','/system/user/bindPlatform','/sw/business/bindPlatform','system:user:query','SysUser'), +('GET','/system/user/bindPlatform/portrait','/sw/business/bindPlatform/portrait','system:user:query','SysUser'), +('DELETE','/system/user/bindPlatform','/sw/business/bindPlatform','system:user:bind','SysUser'), +('POST','/system/user/export','/sw/business/export','system:user:export','SysUser'), +('POST','/system/user/importData','/sw/business/importData','system:user:import','SysUser'), +('GET','/system/user/','/sw/business','system:user:query','SysUser'), +('GET','/system/user/{userId}','/sw/business/:userId','system:user:query','SysUser'), +('POST','/system/user','/sw/business','system:user:add','SysUser'), +('PUT','/system/user','/sw/business','system:user:edit','SysUser'), +('DELETE','/system/user/{userIds}','/sw/business/:userIds','system:user:remove','SysUser'), +('GET','/system/user/optionselect','/sw/business/optionselect','system:user:query','SysUser'), +('PUT','/system/user/resetPwd','/sw/business/resetPwd','system:user:resetPwd','SysUser'), +('PUT','/system/user/changeStatus','/sw/business/changeStatus','system:user:edit','SysUser'), +('GET','/system/user/unlock/{userId}','/sw/business/unlock/:userId','system:user:edit','SysUser'), +('GET','/system/user/authRole/{userId}','/sw/business/authRole/:userId','system:user:query','SysUser'), +('PUT','/system/user/authRole','/sw/business/authRole','system:user:edit','SysUser'), +('GET','/system/user/deptTree','/sw/business/deptTree','system:user:list','SysUser'), +('GET','/system/user/list/dept/{deptId}','/sw/business/list/dept/:deptId','system:user:list','SysUser'); + +INSERT INTO sys_apis(created_at,updated_at,path,description,api_group,method) SELECT NOW(3),NOW(3),target_path,CONCAT('SW ',controller,' - Go handler pending'),'SW business',method FROM sw_kra_api_contract; + +INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) SELECT DISTINCT 'p',CAST(rm.new_id AS CHAR),c.target_path,c.method,'','','' FROM sw_kra_api_contract c JOIN sw_kra_menu_permissions p ON p.source_permission=c.source_permission JOIN sys_role_menu x ON x.menu_id=p.source_menu_id JOIN sw_kra_id_map rm ON rm.entity='role' AND rm.old_id=CAST(x.role_id AS CHAR); + +INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) SELECT 'p','888',target_path,method,'','','' FROM sw_kra_api_contract; + +INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) SELECT 'p',CAST(a.authority_id AS CHAR),p.path,p.method,'','','' FROM sys_authorities a JOIN sys_apis p ON p.path IN('/menu/getMenu','/user/getUserInfo','/user/setSelfInfo','/user/setSelfSetting','/user/setUserAuthority','/user/changePassword') WHERE a.authority_id<>888; + +INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) SELECT 'p',CAST(a.authority_id AS CHAR),c.target_path,c.method,'','','' FROM sys_authorities a JOIN sw_kra_api_contract c ON c.source_permission='__authenticated__' WHERE a.authority_id<>888; + +-- 七、转换旧任务定义,保留超时和重试参数;当前 Go 执行器读取 _execution,任务仍保持停用。 + +INSERT INTO sys_timed_tasks(id,created_at,updated_at,name,description,spec,with_seconds,executor_type,method_name,params,http_allow_private,enabled) SELECT m.new_id,j.create_dt,j.update_dt,CONCAT('SW/',j.job_name),j.description,CASE j.trigger_type WHEN 3 THEN CONCAT('CRON_TZ=Asia/Shanghai ',TRIM(j.trigger_interval)) WHEN 2 THEN CONCAT('@every ',TRIM(j.trigger_interval),'s') END,j.trigger_type=3,'method',j.executor_info,JSON_SET(IF(j.args_str IS NULL OR j.args_str='',JSON_OBJECT(),CAST(j.args_str AS JSON)),'$._execution',JSON_OBJECT('timeoutSeconds',j.executor_timeout,'maxRetryTimes',j.max_retry_times,'retryIntervalSeconds',j.retry_interval)),0,0 FROM sj_job j JOIN sw_kra_id_map m ON m.entity='task' AND m.old_id=CAST(j.id AS CHAR) WHERE j.deleted=0; + +-- 八、回填业务表中的用户及部门引用,保留业务主键和平台实体 ID。 + +UPDATE `sys_user_bind_platform` x JOIN sw_kra_id_map m ON m.entity='user' AND m.old_id=CAST(x.`user_id` AS CHAR) SET x.`user_id`=m.new_id; + +UPDATE `sys_user_preference` x JOIN sw_kra_id_map m ON m.entity='user' AND m.old_id=CAST(x.`user_id` AS CHAR) SET x.`user_id`=m.new_id; + +-- 显式保留更新时间,防止旧表 ON UPDATE CURRENT_TIMESTAMP 把 ID 转换当作业务编辑。 +UPDATE `sys_alert_rule` x JOIN sw_kra_id_map m ON m.entity='user' AND m.old_id=CAST(x.`creator_id` AS CHAR) SET x.`creator_id`=m.new_id,x.`updated_at`=x.`updated_at`; + +UPDATE `sys_alert_record` x JOIN sw_kra_id_map m ON m.entity='user' AND m.old_id=CAST(x.`business_id` AS CHAR) SET x.`business_id`=m.new_id,x.`updated_at`=x.`updated_at`; + +UPDATE `sys_alert_operation_log` x JOIN sw_kra_id_map m ON m.entity='user' AND m.old_id=CAST(x.`operator_id` AS CHAR) SET x.`operator_id`=m.new_id; + +UPDATE `sys_message` x JOIN sw_kra_id_map m ON m.entity='user' AND m.old_id=CAST(x.`create_by` AS CHAR) SET x.`create_by`=m.new_id; + +UPDATE `sys_message` x JOIN sw_kra_id_map m ON m.entity='user' AND m.old_id=CAST(x.`update_by` AS CHAR) SET x.`update_by`=m.new_id; + +UPDATE sys_message x JOIN sw_kra_id_map m ON m.entity='dept' AND m.old_id=CAST(x.create_dept AS CHAR) SET x.create_dept=m.new_id; + +BEGIN DECLARE done INT DEFAULT 0; DECLARE old_user VARCHAR(64); DECLARE new_user BIGINT UNSIGNED; DECLARE users_cursor CURSOR FOR SELECT old_id,new_id FROM sw_kra_id_map WHERE entity='user' ORDER BY id; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done=1; OPEN users_cursor; user_loop: LOOP FETCH users_cursor INTO old_user,new_user; IF done=1 THEN LEAVE user_loop; END IF; UPDATE sys_message SET send_user_ids=TRIM(BOTH ',' FROM REPLACE(CONCAT(',',BINARY send_user_ids,','),CONCAT(',',BINARY old_user,','),CONCAT(',',BINARY new_user,','))) WHERE FIND_IN_SET(BINARY old_user,BINARY send_user_ids)>0; END LOOP; CLOSE users_cursor; END; + +-- 九、核验账号密码、关联、业务引用、菜单和任务;不通过则回滚当前数据事务。 + +SELECT COUNT(*) INTO v_actual FROM sys_alert_rule n JOIN bak_sw2kra_20261103_sys_alert_rule o ON o.id=n.id WHERE NOT(n.created_at <=> o.created_at) OR NOT(n.updated_at <=> o.updated_at); +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','alert rule timestamps unchanged',0,v_actual,v_actual=0); +IF v_actual<>0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='alert rule timestamps changed'; END IF; + +SELECT COUNT(*) INTO v_actual FROM sys_alert_record n JOIN bak_sw2kra_20261103_sys_alert_record o ON o.id=n.id WHERE NOT(n.created_at <=> o.created_at) OR NOT(n.updated_at <=> o.updated_at); +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','alert record timestamps unchanged',0,v_actual,v_actual=0); +IF v_actual<>0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='alert record timestamps changed'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_users) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM sys_user WHERE del_flag='0') INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','user count',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='user count'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_users n JOIN sw_kra_id_map m ON m.entity='user' AND m.new_id=n.id JOIN sys_user o ON CAST(o.user_id AS CHAR)=m.old_id WHERE BINARY n.password=BINARY o.password) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM sys_user WHERE del_flag='0') INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','password bytes unchanged',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='password bytes unchanged'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_users n LEFT JOIN sys_authorities a ON a.authority_id=n.authority_id WHERE a.authority_id IS NULL) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','current role references',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='current role references'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_user_authority) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM sys_user_role) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','user role links',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='user role links'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_users n JOIN sw_kra_id_map m ON m.entity='user' AND m.new_id=n.id JOIN sys_user o ON CAST(o.user_id AS CHAR)=m.old_id LEFT JOIN sw_kra_id_map p ON p.entity='user' AND p.old_id=CAST(o.parent_id AS CHAR) WHERE n.parent_id<>COALESCE(p.new_id,0) OR n.level<>o.level) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','business parent and level',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='business parent and level'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_users u JOIN sys_users p ON p.id=u.parent_id WHERE u.id_path<>CONCAT(p.id_path,',',p.id)) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','business ancestor paths',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='business ancestor paths'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_user_bind_platform b LEFT JOIN sys_users u ON u.id=b.user_id WHERE u.id IS NULL) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','platform binding users',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='platform binding users'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_user_bind_platform) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_user_bind_platform`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','platform binding rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='platform binding rows'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_user_preference p LEFT JOIN sys_users u ON u.id=p.user_id WHERE u.id IS NULL) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','preference users',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='preference users'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_user_preference) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_user_preference`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','preference rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='preference rows'; END IF; + +SELECT (SELECT COUNT(*) FROM media_file_upload_and_downloads) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM sys_oss) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','file rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='file rows'; END IF; + +SELECT (SELECT COUNT(*) FROM media_file_upload_and_downloads n JOIN sw_kra_id_map m ON m.entity='file' AND m.new_id=n.id JOIN sys_oss o ON CAST(o.oss_id AS CHAR)=m.old_id WHERE n.size<>COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(o.ext1),o.ext1,JSON_OBJECT()),'$.fileSize')) AS UNSIGNED),0) OR BINARY n.mime<>BINARY COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(o.ext1),o.ext1,JSON_OBJECT()),'$.contentType')),'null'),'') OR BINARY n.md5<>BINARY COALESCE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(IF(JSON_VALID(o.ext1),o.ext1,JSON_OBJECT()),'$.md5')),'null'),'')) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','file size MIME and MD5',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='file size MIME and MD5'; END IF; + +SELECT (SELECT COUNT(*) FROM sw_kra_id_map WHERE entity='menu') INTO v_actual; + +SELECT (31) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','business source menu nodes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='business source menu nodes'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_base_menus n JOIN sw_kra_id_map m ON m.entity='menu' AND m.new_id=n.id) INTO v_actual; + +SELECT (14) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','business navigation nodes',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='business navigation nodes'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_base_menu_btns) INTO v_actual; + +SELECT (17) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','business buttons',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='business buttons'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_base_menus WHERE name='sw_workbench' OR path='sw-index') INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','workbench reuses dashboard',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='workbench reuses dashboard'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_authorities a WHERE NOT EXISTS(SELECT 1 FROM sys_base_menus m JOIN sys_authority_menus x ON x.sys_base_menu_id=m.id WHERE x.sys_authority_authority_id=a.authority_id AND m.name=a.default_router)) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','default routers accessible',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='default routers accessible'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_base_menus n LEFT JOIN sys_base_menus p ON p.id=n.parent_id WHERE n.parent_id<>0 AND p.id IS NULL) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','menu parent references',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='menu parent references'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_timed_tasks t JOIN sw_kra_id_map m ON m.entity='task' AND m.new_id=t.id) INTO v_actual; + +SELECT (7) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','SW task definitions',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='SW task definitions'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_timed_tasks WHERE enabled=1) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','all tasks disabled for rehearsal',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='all tasks disabled for rehearsal'; END IF; + +SELECT (SELECT COUNT(*) FROM sys_message) INTO v_actual; + +SELECT (SELECT COUNT(*) FROM `bak_sw2kra_20261103_sys_message`) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','message rows',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='message rows'; END IF; + +SELECT (SELECT COUNT(*) FROM (SELECT code FROM sys_positions GROUP BY code HAVING COUNT(*)>1) duplicate_codes) INTO v_actual; + +SELECT (0) INTO v_expected; + +INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) VALUES ('migration','position codes unique',v_expected,v_actual,v_expected=v_actual); + +IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='position codes unique'; END IF; + +UPDATE sw_kra_state SET phase='data_validated' WHERE id=1; + +COMMIT; + +-- 十、数据核验通过后归档旧框架表,登记完成状态并释放迁移锁。 + +RENAME TABLE +`sys_social` TO `legacy_sw_20261103_sys_social`, +`sys_dept` TO `legacy_sw_20261103_sys_dept`, +`sys_user` TO `legacy_sw_20261103_sys_user`, +`sys_post` TO `legacy_sw_20261103_sys_post`, +`sys_role` TO `legacy_sw_20261103_sys_role`, +`sys_menu` TO `legacy_sw_20261103_sys_menu`, +`sys_user_role` TO `legacy_sw_20261103_sys_user_role`, +`sys_role_menu` TO `legacy_sw_20261103_sys_role_menu`, +`sys_role_dept` TO `legacy_sw_20261103_sys_role_dept`, +`sys_user_post` TO `legacy_sw_20261103_sys_user_post`, +`sys_oper_log` TO `legacy_sw_20261103_sys_oper_log`, +`sys_dict_type` TO `legacy_sw_20261103_sys_dict_type`, +`sys_dict_data` TO `legacy_sw_20261103_sys_dict_data`, +`sys_config` TO `legacy_sw_20261103_sys_config`, +`sys_login_info` TO `legacy_sw_20261103_sys_login_info`, +`sys_notice` TO `legacy_sw_20261103_sys_notice`, +`sys_oss` TO `legacy_sw_20261103_sys_oss`, +`sys_oss_config` TO `legacy_sw_20261103_sys_oss_config`, +`sys_client` TO `legacy_sw_20261103_sys_client`; + +UPDATE sw_kra_state SET phase='completed',completed_at=NOW(3) WHERE id=1; + +DO RELEASE_LOCK(v_lock_name); SET v_lock=0; +SELECT '完整框架迁移完成' AS result; +SELECT stage,COUNT(*) AS checks,SUM(passed) AS passed FROM sw_kra_checks GROUP BY stage; +END migration_run$$ +-- 只调用这一个入口,由库的实际状态决定执行完整迁移还是菜单调整。 +CALL sw_kra_migrate_20261103()$$ +DROP PROCEDURE sw_kra_migrate_20261103$$ +DELIMITER ; +-- 输出最终根菜单顺序,便于核对页面效果。 +SELECT id,name,title,sort FROM sys_base_menus WHERE parent_id=0 ORDER BY sort,id; diff --git a/docs/sw6-to-kra/system-api-audit.md b/docs/sw6-to-kra/system-api-audit.md index 581ed8b..1363911 100644 --- a/docs/sw6-to-kra/system-api-audit.md +++ b/docs/sw6-to-kra/system-api-audit.md @@ -4,7 +4,7 @@ ## 商务新增与改动 -`SysUserController` 的 Git 差异主要是商务列表语义、添加子商务,以及直属渠道、平台绑定和两种商务画像。用户列表查询新增商务树、姓名/等级过滤、直属渠道与正常绑定数量;添加子商务新增层级、归属与偏好初始化。当前列表、子商务、偏好和平台绑定已迁移;商务/平台商务画像仍未完成。 +`SysUserController` 的 Git 差异主要是商务列表语义、添加子商务,以及直属渠道、平台绑定和两种商务画像。用户列表查询新增商务树、姓名/等级过滤、直属渠道与正常绑定数量;添加子商务新增层级、归属与偏好初始化。当前列表、子商务、偏好、平台绑定及两种商务画像均已迁移;2026-11-04 已完成 sw8/sw9 同源接口核对。 原用户控制器中的以下方法在基线前已经存在,旧 `BusMgmt/BusinessList.vue` 没有为它们增加实际操作入口。新商务页面不据此增加编辑、删除、导入、导出、角色分配等按钮。 @@ -39,4 +39,12 @@ 隔离 TCP HTTP 测试覆盖原生启停后商务接口返回状态、待审/驳回资料编辑、自行启用被拒绝和不存在用户;仓储测试覆盖失败回滚与越权。现有鉴权测试继续检查停用/待审/驳回会拒绝密码登录和已有会话。 -当前 sw7 的 45 个用户已做只读一致性检查,未发现 enable 与 business_status 冲突,因此没有批量改写真实账号。 +sw8/sw9 的账号状态及原密码保留情况见 `validation.md`。 + +## 2026-11-04 实库复核 + +SQL 的 63 项契约已逐项归类:51 项商务接口完成真实 HTTP 核对,12 项本页所列旧框架别名由 Kra 原生能力承接,不宣称旧 Excel/Redis 解锁等协议兼容。详见 `sw8-sw9-recheck-summary-2026-11-04.json`。 + +真实状态写入验证确认:旧系统停用、待审、驳回后已签发会话仍可访问偏好;新系统会拒绝这些会话。该安全差异保留,恢复正常后旧令牌重新可用。 + +原库一级商务角色没有 `system:promote:add` 权限,新库保持同一授权限制。新增渠道的正向实测使用管理员,未擅自扩大角色权限。 diff --git a/docs/sw6-to-kra/user-status-http-2026-11-03.json b/docs/sw6-to-kra/user-status-http-2026-11-03.json deleted file mode 100644 index 088a1e7..0000000 --- a/docs/sw6-to-kra/user-status-http-2026-11-03.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "scope": "真实 sw7 登录会话,只读检查;未启停任何真实账号。正向启停和审核状态保留由隔离 TCP HTTP 测试覆盖。", - "checks": [ - { - "method": "POST", - "path": "/user/getUserList", - "http": 200, - "code": 0, - "msg": "获取成功", - "passed": true - }, - { - "method": "GET", - "path": "/sw/business/list?pageNum=1&pageSize=500", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true - }, - { - "method": "PUT", - "path": "/user/setUserInfo", - "http": 200, - "code": 7, - "msg": "ID值不能为空", - "passed": true - } - ], - "native_total": 45, - "business_total": 45, - "checked_users": 45, - "inconsistent_states": 0, - "response_mismatches": 0, - "passed": true -} diff --git a/docs/sw6-to-kra/validation.md b/docs/sw6-to-kra/validation.md new file mode 100644 index 0000000..5d88b4a --- /dev/null +++ b/docs/sw6-to-kra/validation.md @@ -0,0 +1,68 @@ +# 商务迁移验收与性能记录 + +核验日期:2026-11-04。Java → sw8,Go → sw9。原始响应与备份位于仓库外 `D:/Users/Yvan/backups/sw9/20261104_rehearsal/`。 + +## 迁移与接口 + +- SQL v5 在独立 MySQL 5.7 完成 52 项备份检查、24 项迁移检查;首次执行、重复执行、异常账本拒绝、旧表归档后执行均通过。45 个原用户密码哈希逐字节保留。 +- 全量旧契约 63 项:51 项商务接口已实测(33 读取、18 写入/导出);12 项旧框架别名由原生功能承接,不能视为旧协议兼容。 +- 最近完整演练共 230 项检查:104 组读取、两个 Excel 逐单元格、账号创建/编辑/状态、规则增删改/启停、预警状态流转/备注、SDK 绑定/重提/解绑、创建渠道和任务配置。 +- 日报与区服报表真实重建后,68,857 行逻辑数据一致,随后恢复原分区;预警实际任务两边生成 480 条后恢复原 478 条。 +- 恢复后的两库均为用户 48、平台绑定 58、规则 5、预警 478、预警日志 313。增加的 3 个层级账号为获授权保留的测试账号。 +- SDK 写入使用本机独立平台夹具,OA 审批使用受控夹具;没有宣称真实第三方审核或线上支付已验收。 + +详细旧契约清单保留在 [综合索引](sw8-sw9-recheck-summary-2026-11-04.json)。本轮新增审查及运行结果见 [接口审查](../API_REVIEW.md)。 + +## 明确保留的差异 + +1. 付费率榜单按用户要求统一 `%`,变化率为 `(本期-上期)/上期×100%`,上期为零返回无可比值;不是百分点差。 +2. 仅三个分别创建的测试账号允许创建时刻不同,原迁移用户时间严格比较。 +3. 超出末页时新接口返回空页;旧 MyBatis 会回到第一页。有效页码和总数保持相同。 +4. 新服务立即拒绝停用、待审、驳回用户的已有会话;旧服务未做这项检查。 +5. 本轮修正渠道激活的跨平台同名账号访问:本人已通过绑定的“平台+账号”才有权限。列表及流水明细使用同一规则。 +6. 日期范围只传一端、倒置或非法时返回参数错误;不再静默忽略筛选。 + +## 定时任务 + +7 条实际商务计划及 7 个方法均迁移,cron、秒字段、上海时区、回跑参数与超时/重试配置已经逐条核对。 + +| 方法 | 周期 | 测试库状态 | +|---|---|---| +| swReportDayYesterdayTask | 每日 01:00 | 停用 | +| swReportServerDayYesterdayTask | 每日 01:05 | 停用 | +| swReportDayTodayTask | 每 5 分钟,第 40 秒 | 停用 | +| swReportServerDayTodayTask | 每 5 分钟 | 停用 | +| swReportDayBackfillTask | 每 60 秒,原配置停用 | 停用 | +| swReportServerDayBackfillTask | 每 60 秒,原配置停用 | 停用 | +| alertTriggerTask | 每 30 分钟 | 停用 | + +新库未开启自动执行;框架自带 ClearDB、CleanStaleUploads 另行保留。旧工程的演示、分片、MapReduce 示例没有实际商务计划,不属于漏迁。方法超时后仍未退出时禁止重复执行,同分区报表和预警写入有事务锁及去重测试。 + +## 已完成的性能优化 + +同一 sw9 副本的多轮中位耗时,单位 ms;首次冷读与负载变化会有波动。数据统计近 30 天,排行榜近 7 天。 + +| 接口 | 优化前 | 优化后 | +|---|---:|---:| +| 渠道列表 | 175 | 108 | +| 渠道数据统计 | 3839 | 1110 | +| 充值明细 | 216 | 113 | +| 注册明细 | 139 | 88 | +| 游戏数据 | 859 | 453 | +| 区服分析 | 3012 | 42 | +| 渠道排行榜 | 863 | 483 | +| 商务排行榜 | 5048 | 325 | +| 游戏排行榜 | 631 | 229 | +| 工作台汇总 | 75 | 42 | +| 工作台最低充值榜 | 1509 | 171 | +| 工作台未登录榜 | 224 | 158 | +| 工作台近 7 天注册榜 | 92 | 74 | +| 工作台近 7 天充值榜 | 147 | 53 | + +优化包括分页主键后回表、按原渠道预聚合、取消相关子查询、合并汇总查询、限制并发为 2、只加载 TOP10 名称,以及关闭 SQL 日志时避免参数展开。61 组业务列表/排行榜和 52 组工作台数据逐字段相等,另有日常 8080 接口调用检查。 + +新增版本 `202611040013_business_query_indexes` 已在 sw9 应用 7 个查询索引,累计 27 个迁移版本;注册转换列索引和区服创角时间索引亦已存在。查询优化不改变用户权限和统计口径。 + +## 页面 + +渠道激活页不再在发起分页请求时清零 total;排行榜与仪表盘四榜并排、付费率用百分比、头部两项偏好直接勾选;三级商务保留商务列表/绑定列表切换。完整页面检查脚本仍保留,运行截图和带业务数据的响应仅存仓库外。 diff --git a/docs/sw6-to-kra/workbench-http-2026-11-04.json b/docs/sw6-to-kra/workbench-http-2026-11-04.json deleted file mode 100644 index f7cebeb..0000000 --- a/docs/sw6-to-kra/workbench-http-2026-11-04.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "scope": "真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向六卡、榜单与权限通过隔离 TCP HTTP 测试。", - "checks": [ - { - "path": "/sw/workbench/summary?", - "http": 200, - "code": 7, - "msg": "请选择所属平台后查看数据", - "passed": true - }, - { - "path": "/sw/workbench/summary?sourceDbList=lx,hw,yq", - "http": 200, - "code": 0, - "msg": "成功", - "passed": true, - "available": false - }, - { - "path": "/sw/workbench/registerTop?", - "http": 200, - "code": 7, - "msg": "请选择所属平台后查看数据", - "passed": true - }, - { - "path": "/sw/workbench/registerTop?sourceDbList=lx,hw,yq", - "http": 200, - "code": 7, - "msg": "工作台所需业务数据尚未接入", - "passed": true - }, - { - "path": "/sw/workbench/registerTop?sourceDbList=lx&period=invalid", - "http": 200, - "code": 7, - "msg": "时间维度无效,应为 today/yesterday/last7/last30", - "passed": true - }, - { - "path": "/sw/workbench/payRank?", - "http": 200, - "code": 7, - "msg": "请选择所属平台后查看数据", - "passed": true - }, - { - "path": "/sw/workbench/payRank?sourceDbList=lx,hw,yq", - "http": 200, - "code": 7, - "msg": "工作台所需业务数据尚未接入", - "passed": true - }, - { - "path": "/sw/workbench/payRank?sourceDbList=lx&period=invalid", - "http": 200, - "code": 7, - "msg": "时间维度无效,应为 today/yesterday/last7/last30", - "passed": true - }, - { - "path": "/sw/workbench/payRank?sourceDbList=lx,hw,yq&order=asc&period=last30", - "http": 200, - "code": 7, - "msg": "工作台所需业务数据尚未接入", - "passed": true - }, - { - "path": "/sw/workbench/unloginTop?", - "http": 200, - "code": 7, - "msg": "请选择所属平台后查看数据", - "passed": true - }, - { - "path": "/sw/workbench/unloginTop?sourceDbList=lx,hw,yq", - "http": 200, - "code": 7, - "msg": "工作台所需业务数据尚未接入", - "passed": true - } - ], - "passed": true -} diff --git a/docs/sw6-to-kra/workbench-source-notes.md b/docs/sw6-to-kra/workbench-source-notes.md index 54ff13e..d20e3cb 100644 --- a/docs/sw6-to-kra/workbench-source-notes.md +++ b/docs/sw6-to-kra/workbench-source-notes.md @@ -22,5 +22,5 @@ - 仪表盘直接调用四个接口,并复用已有预警列表查询 status=1/pageSize=5,实现原最新五条轮播。Kra 快捷入口和公告保留。 - 聚合、资料查询批量执行,未对每个渠道发起独立 SQL。注册榜不依赖金额表,未登录榜不依赖报表,缺少必要表返回明确不可用。 - 六卡扩展 available=false 并返回 null 值;榜单保留数组成功协议,缺表返回业务错误,避免把未知榜单当作真实空榜。 -- 全量 Go、隔离数据库真实 TCP HTTP、前端 ESLint/构建通过;测试覆盖月末对照、零分母、大金额、TOP10、身份去重、下级偏好、负金额/零渠道、未登录回退和跨商务拒绝。实际 sw7 只读结果见 `workbench-http-2026-11-04.json`。 -- 按用户要求不检查页面交互。当前没有复制旧业务库,最终同源验收尚未开始。 + +当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。 diff --git a/docs/system-pkg-audit.md b/docs/system-pkg-audit.md deleted file mode 100644 index b550906..0000000 --- a/docs/system-pkg-audit.md +++ /dev/null @@ -1,87 +0,0 @@ -# `` 到 `pkg` 复用性审查 - -审查原则:公共包只提供跨模块稳定的机制、协议或无状态纯函数;不能依赖 -`app/*/internal`,也不承载 system 的业务表、用例、provider 客户端或运行时 -配置。 - -## 已抽取到 `pkg` - -| 能力 | 公共位置 | 说明 | -| --- | --- | --- | -| HTTP JSON 响应契约 | `pkg/httpx` | `Response`、`PageResult`、状态码和 Gin 响应助手;`server/httpx/response.go` 仅保留 system 适配。 | -| protobuf JSON 局部合并 | `pkg/protoutil` | 与业务无关的字段归一化和局部反序列化;初始化直接使用公共包。 | -| 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议;`biz/payment` 与 `biz/integration` 直接复用。 | -| 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 留在 `internal/integration/payment`。 | -| WebSocket 通用收发 | `pkg/websocket` | Melody 的连接、事件、点对点发送、广播和会话查询封装;`internal/integration` 管理配置与生命周期。 | -| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口;EMQX/Paho 与 RabbitMQ/AMQP 客户端由 `internal/integration` 管理。 | -| 模块、任务和迁移协议 | `pkg/module`、`pkg/task`、`pkg/database/migration` | 供不同业务模块注册贡献,不带 system 业务语义。 | - -## system 内部保留边界 - -- `app`:运行时组合根,汇总依赖注入后的路由和任务贡献。 -- `modules`:静态模块 catalog,汇总各模块迁移、菜单、API 和默认任务。 -- `modules/system`:system 模块的 Definition,声明系统表迁移。 -- `modules/integration`:integration 配置迁移和管理面贡献。 -- `modules/task`:定时任务迁移和默认任务贡献。 -- `modules/payment`:payment 模块的 Definition,声明支付迁移和支付管理面。 -- `biz/system`:用户、权限、菜单、审计、媒体和系统配置等系统领域模型与用例。 -- `biz/payment`:支付订单、支付流程、支付接口和支付日志。 -- `biz/integration`:支付/消息队列/WebSocket 集成配置定义与校验。 -- `biz/task`:定时任务模型、任务用例和任务注册协议。 -- `conf`:system 配置 proto、运行时快照和生成代码。 -- `data`:共享数据库生命周期与配置 watcher;PO/仓储按 `system`、`integration`、`task`、`payment` 子包隔离;后台 JWT claims 与签发/解析位于 `data/system/token.go`。 -- `initialize`:首次安装、配置迁移、种子编排和运行时重载。 -- `integration`:Redis、邮件、存储、支付、WebSocket、EMQX 和 RabbitMQ 的 provider 生命周期。 -- `routecatalog`:HTTP 公开性、操作审计、请求体策略和 API 分组/说明的统一目录。 -- `service`:HTTP DTO(`service/dto`)、DTO 与 DO 转换和应用服务。 -- `server`:Gin 生命周期;handler、middleware、router、HTTP 适配按子包维护。 -- `worker`:任务调度、执行器、SSE 订阅及其并发状态。 - -这些代码都带有 system 的 API、配置、数据表或生命周期语义,不应为了减少 -文件数量搬到 `pkg`。 - -## 目录分类约定 - -```text -internal/ - app/ # 应用组合根 - modules/ # 静态 catalog、业务模块定义及其模块级贡献 - biz/ - system/ # 系统领域 - payment/ # 支付领域 - integration/# 集成配置领域 - task/ # 定时任务领域 - config/ # Viper 配置、快照和热更新 - global/ # 进程级共享资源入口 - data/ - system/ # 系统表与系统仓储,含后台 JWT token.go - integration/# 集成配置表与仓储 - task/ # 定时任务表与仓储 - payment/ # 支付表与仓储 - initialize/ # 首次安装和配置编排 - integration/ # 外部 I/O provider - server/ # Gin 生命周期,内部按 handler/middleware/router/httpx 分类 - service/ # 应用服务;DTO 集中在 dto 子包 - worker/ # 任务运行时 -``` - -技术角色使用子目录表达,子目录内部使用资源名,例如 `handler/payment.go`、 -`router/payment.go`、`dto/payment.go`。只有少量代码且没有独立边界时不建立 -新包;同一角色文件较多时也不应全部堆在父目录。 - -## 暂不抽取的候选 - -1. token cookie 名称、SameSite 和反向代理策略:目前是 system 认证策略。 -2. operation-audit 脱敏和请求采集:包含 system context key 与审计字段。 -3. biz 查询选项和错误:当前绑定 system 的领域接口及 API reason。 - -只有当其他模块出现相同、稳定且不带 system 语义的契约时,才新增公共包;不要 -直接把单个 system 类型搬到 `pkg`。 - -## 验证 - -```text -go test ./... -``` - -当前全仓测试通过,且未发现对已删除旧路径的 Go import。 diff --git a/docs/system-structure-audit.md b/docs/system-structure-audit.md deleted file mode 100644 index 9832817..0000000 --- a/docs/system-structure-audit.md +++ /dev/null @@ -1,63 +0,0 @@ -# `internal` 目录结构优化结论 - -参考 Go Kratos 的分层方式,顶层保留 `app`、`modules`、`biz`、`config`、`global`、 -`data`、`initialize`、`integration`、`security`、`server`、`service`、`worker` 等 -稳定职责。目录不是越少越好:同一技术角色文件较多时,应在所属层下分组,避免 -一个目录堆积几十个文件。 - -## 当前结构 - -```text -internal/ - app/ # 应用组合根 - modules/ # 静态 catalog 和模块定义 - modules/payment/ # payment 模块定义 - biz/ # DO、usecase、repo interface - config/ # Viper 配置、快照和热更新 - global/ # 进程级共享资源 - data/ # PO、repo、数据库和迁移 - initialize/ # 首次安装和配置编排 - integration/ # 外部 I/O provider - security/ # JWT 和安全实现 - server/ - handler/ # Gin handler,按资源命名 - middleware/ # 认证、审计、限流、恢复等中间件 - router/ # 各资源路由及 Routes 聚合 - httpx/ # system HTTP 响应和 cookie 适配 - service/ - dto/ # HTTP 请求、响应和查询 DTO,按模块命名 - worker/ # 任务运行时 -``` - -## 本次调整 - -- DTO 从 `service` 根目录归档到 `service/dto`。 -- handler、middleware、router、HTTP helper 分别归档到 `server` 子包。 -- 子目录内文件直接使用资源名,例如 `handler/payment.go`、 - `router/payment.go`、`dto/payment.go`,不保留重复角色前缀。 -- Wire 直接装配 `handler.ProviderSet` 和 `router.ProviderSet`,server 根目录只 - 负责 Gin/Swagger 生命周期。 -- `security/adminauth/token.go` 合并为 `security/token.go`;单文件子包没有 - 独立边界时不继续拆分。 -- 删除只转发 `pkg/protoutil` 的 `utils/configutil`。 - -## `internal/app` 为什么只保留应用组合 - -`modules/catalog.go` 是静态模块注册点,负责按依赖顺序汇总各模块 -`Definition()`;`app/runtime.go` 只负责任务注册和依赖注入后的路由组合。 -这样模块定义不再和应用组合逻辑混在一起,也不能误并入 `biz`、`service` 或 -`data`。 - -Catalog 只能自动汇总静态模块贡献;新增模块若提供运行时路由或依赖型任务,仍需 -在 cmd/Wire 中显式注册。 - -## 其他目录审查 - -- `data` 与 `integration` 已按仓储或 provider 分类,边界和生命周期明确。 -- `biz`、`service` 根目录采用一资源一文件;进一步拆成资源子包会改变 Go 包 - 边界并容易引入循环依赖,本次不做纯视觉拆分。 -- `security` 当前只有一个文件,但安全实现是明确的依赖边界,后续认证机制也会 - 在此扩展,因此保留顶层包。 - -新增目录应至少满足独立依赖方向、状态生命周期或稳定技术角色之一。不要回到 -一文件一目录,也不要为了减少目录数量把大量不同角色重新铺平。 diff --git a/internal/biz/biz.go b/internal/biz/biz.go index 3c639c5..a7c26a0 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, business.NewReportBuildUsecase) +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, business.NewAlertTriggerUsecase) diff --git a/internal/biz/business/activation.go b/internal/biz/business/activation.go index 83822c7..bbdd0c5 100644 --- a/internal/biz/business/activation.go +++ b/internal/biz/business/activation.go @@ -32,9 +32,13 @@ func (a *Activation) EndAt() *time.Time { return a.UnbindAt } +type ActivationAccount struct { + SourceDB, Account string +} + type ActivationScope struct { All bool - Accounts []string + Accounts []ActivationAccount } type ActivationFilter struct { PlatformNames, Statuses []string @@ -59,7 +63,7 @@ type ActivationSpendDetail struct { } type ActivationRepo interface { - OwnAccounts(context.Context, uint) ([]string, error) + OwnAccounts(context.Context, uint) ([]ActivationAccount, error) List(context.Context, ActivationScope, ActivationFilter, ...ListOption) (*ActivationPage, error) Find(context.Context, uint64) (*Activation, error) Spend(context.Context, ActivationSpendQuery) ([]*ActivationSpendItem, error) @@ -87,11 +91,12 @@ func (uc *ActivationUsecase) scope(ctx context.Context) (ActivationScope, error) return ActivationScope{}, err } scope := ActivationScope{} - seen := map[string]bool{} + seen := map[ActivationAccount]bool{} for _, account := range accounts { - account = strings.TrimSpace(account) - key := strings.ToLower(account) - if key != "" && !seen[key] { + account.Account = strings.TrimSpace(account.Account) + account.SourceDB, err = NormalizePlatform(account.SourceDB) + key := ActivationAccount{SourceDB: account.SourceDB, Account: strings.ToLower(account.Account)} + if err == nil && key.Account != "" && !seen[key] { scope.Accounts = append(scope.Accounts, account) seen[key] = true } @@ -105,10 +110,10 @@ func (uc *ActivationUsecase) List(ctx context.Context, filter ActivationFilter, return nil, err } if !scope.All && filter.BusierAccount != "" { - matched := []string{} + matched := []ActivationAccount{} keyword := strings.ToLower(strings.TrimSpace(filter.BusierAccount)) for _, account := range scope.Accounts { - if strings.Contains(strings.ToLower(account), keyword) { + if strings.Contains(strings.ToLower(account.Account), keyword) { matched = append(matched, account) } } @@ -139,7 +144,7 @@ func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*Activ if !scope.All { found := false for _, account := range scope.Accounts { - if strings.EqualFold(account, strings.TrimSpace(activation.BusierAccount)) { + if strings.EqualFold(account.SourceDB, strings.TrimSpace(activation.PlatformName)) && strings.EqualFold(account.Account, strings.TrimSpace(activation.BusierAccount)) { found = true break } diff --git a/internal/biz/business/activation_test.go b/internal/biz/business/activation_test.go index 713be5c..6290bb5 100644 --- a/internal/biz/business/activation_test.go +++ b/internal/biz/business/activation_test.go @@ -8,7 +8,7 @@ import ( type activationRepoFake struct { ActivationRepo - accounts []string + accounts []ActivationAccount requested uint scope ActivationScope filter ActivationFilter @@ -18,7 +18,7 @@ type activationRepoFake struct { listCalls int } -func (r *activationRepoFake) OwnAccounts(_ context.Context, id uint) ([]string, error) { +func (r *activationRepoFake) OwnAccounts(_ context.Context, id uint) ([]ActivationAccount, error) { r.requested = id return r.accounts, nil } @@ -35,7 +35,7 @@ func (r *activationRepoFake) Spend(_ context.Context, q ActivationSpendQuery) ([ } func activationFixture() (*ActivationUsecase, *activationRepoFake) { a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1, Level: 0}, 7: {ID: 7, Level: 1}, 8: {ID: 8, Level: 2, IDPath: "0,7"}}} - r := &activationRepoFake{accounts: []string{" Own ", "own", "Other", ""}} + r := &activationRepoFake{accounts: []ActivationAccount{{"lx", " Own "}, {"LX", "own"}, {"hw", "Other"}, {"lx", ""}, {"unknown", "Own"}}} return NewActivationUsecase(r, NewAccountUsecase(a)), r } func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) { @@ -43,7 +43,7 @@ func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) { if _, err := uc.List(accountContext(8, 902), ActivationFilter{BusierAccount: "oWn", Statuses: []string{"已达标"}}); err != nil { t.Fatal(err) } - if r.requested != 8 || r.scope.All || len(r.scope.Accounts) != 1 || r.scope.Accounts[0] != "Own" || r.filter.BusierAccount != "" || r.filter.Statuses[0] != "reached" { + if r.requested != 8 || r.scope.All || len(r.scope.Accounts) != 1 || r.scope.Accounts[0] != (ActivationAccount{"lx", "Own"}) || r.filter.BusierAccount != "" || r.filter.Statuses[0] != "reached" { t.Fatalf("本人账号或中文状态筛选错误: %+v", r) } page, err := uc.List(accountContext(7, 901), ActivationFilter{BusierAccount: "下级账号"}) @@ -69,6 +69,11 @@ func TestActivationSpendScopeCutoffAndExactMoney(t *testing.T) { if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != nil || *r.query.End != unbind.Unix() { t.Fatal("解绑截止错误") } + r.record.PlatformName = "hw" + if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope { + t.Fatal("同名商务不能读取另一平台的激活流水") + } + r.record.PlatformName = "lx" r.record.BusierAccount = "not-owned" if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope { t.Fatal("非本人绑定可读取流水") diff --git a/internal/biz/business/alert_record.go b/internal/biz/business/alert_record.go index 554bbe9..141b41f 100644 --- a/internal/biz/business/alert_record.go +++ b/internal/biz/business/alert_record.go @@ -76,6 +76,14 @@ type AlertRecordRepo interface { type AlertAction string +const ( + AlertUnprocessed = 1 + AlertProcessing = 2 + AlertPendingAudit = 3 + AlertAuditRejected = 4 + AlertProcessed = 5 +) + const ( AlertStart AlertAction = "start" AlertSubmit AlertAction = "submit" @@ -148,15 +156,15 @@ func alertTransition(status int, action AlertAction) (int, error) { var message string switch action { case AlertStart: - from, to, message = 1, 2, "仅未处理状态的预警可开始处理" + from, to, message = AlertUnprocessed, AlertProcessing, "仅未处理状态的预警可开始处理" case AlertSubmit: - from, to, message = 2, 3, "仅处理中状态的预警可提交审核" + from, to, message = AlertProcessing, AlertPendingAudit, "仅处理中状态的预警可提交审核" case AlertApprove: - from, to, message = 3, 5, "仅待审核状态的预警可审核通过" + from, to, message = AlertPendingAudit, AlertProcessed, "仅待审核状态的预警可审核通过" case AlertReject: - from, to, message = 3, 4, "仅待审核状态的预警可审核驳回" + from, to, message = AlertPendingAudit, AlertAuditRejected, "仅待审核状态的预警可审核驳回" case AlertReprocess: - from, to, message = 4, 2, "仅审核驳回状态的预警可重新处理" + from, to, message = AlertAuditRejected, AlertProcessing, "仅审核驳回状态的预警可重新处理" default: return status, ErrAlertRecordInput } diff --git a/internal/biz/business/alert_trigger.go b/internal/biz/business/alert_trigger.go new file mode 100644 index 0000000..5765077 --- /dev/null +++ b/internal/biz/business/alert_trigger.go @@ -0,0 +1,270 @@ +package business + +import ( + "context" + "fmt" + "log/slog" + "math/big" + "strconv" + "strings" + "time" +) + +type AlertTriggerChannel struct { + ChannelKey + Account string + BusierID uint64 + LastLoginUnix, CreatedUnix int64 +} + +type AlertTriggerInput struct { + Rules []*AlertRule + Accounts []*Account + Bindings []*Binding + Channels []*AlertTriggerChannel +} + +type AlertComparison struct { + FirstUnix int64 + Current, Previous string +} + +type AlertTriggerRepo interface { + Input(context.Context) (*AlertTriggerInput, error) + Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error) + // 仓储在渠道/分类键锁及事务内读取未关闭记录,由领域决定是否刷新。 + StoreHit(context.Context, *AlertRecord, []int, func(*AlertRecord) bool) (bool, error) +} + +type AlertTriggerResult struct{ Rules, Written, Skipped int } + +type AlertTriggerUsecase struct{ repo AlertTriggerRepo } + +func NewAlertTriggerUsecase(repo AlertTriggerRepo) *AlertTriggerUsecase { + return &AlertTriggerUsecase{repo: repo} +} + +func (uc *AlertTriggerUsecase) Run(ctx context.Context) error { + _, err := uc.Detect(ctx, time.Now()) + return err +} + +func validTriggerRule(rule *AlertRule) bool { + if rule == nil || rule.CreatorID == 0 || strings.TrimSpace(rule.Type) == "" { + return false + } + if rule.Metric == 3 { + return rule.UnloginDays != nil && *rule.UnloginDays > 0 + } + if rule.Metric != 1 && rule.Metric != 2 { + return false + } + if rule.StatPeriodDays == nil || *rule.StatPeriodDays <= 0 || rule.MomDirection == nil || (*rule.MomDirection != 1 && *rule.MomDirection != 2) || rule.MomThreshold == nil { + return false + } + threshold, ok := new(big.Rat).SetString(*rule.MomThreshold) + // 历史规则检测允许零阈值,保持与旧任务一致;新增规则另有边界校验。 + return ok && threshold.Sign() >= 0 +} + +func triggerOwners(input *AlertTriggerInput) (map[ChannelKey]*Account, map[uint]*Account) { + accounts := map[uint]*Account{} + for _, account := range input.Accounts { + if account != nil { + accounts[account.ID] = account + } + } + bindings := map[PlatformKey]uint{} + conflicts := map[PlatformKey]bool{} + for _, binding := range input.Bindings { + if binding == nil || binding.Status != "0" || binding.UserID == 0 || binding.BusierID == 0 { + continue + } + source, err := NormalizePlatform(binding.SourceDB) + if err != nil { + continue + } + key := PlatformKey{SourceDB: source, BusierID: binding.BusierID} + if existing, ok := bindings[key]; ok && existing != binding.UserID { + conflicts[key] = true + } + bindings[key] = binding.UserID + } + owners := map[ChannelKey]*Account{} + for _, channel := range input.Channels { + if channel == nil || channel.ID == 0 { + continue + } + source, err := NormalizePlatform(channel.SourceDB) + if err != nil { + continue + } + channel.SourceDB = source + key := PlatformKey{SourceDB: source, BusierID: channel.BusierID} + account := accounts[bindings[key]] + if conflicts[key] || account == nil || account.Level != 3 { + continue + } + path := strings.Split(account.IDPath, ",") + if len(path) < 2 { + continue + } + id, err := strconv.ParseUint(path[1], 10, 64) + owner := accounts[uint(id)] + if err != nil || owner == nil || owner.Level != 1 { + continue + } + owners[channel.ChannelKey] = owner + } + return owners, accounts +} + +// 一次执行固定上海自然日,避免跨午夜时同一任务的各规则使用不同的比较窗口。 +func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*AlertTriggerResult, error) { + result := &AlertTriggerResult{} + input, err := uc.repo.Input(ctx) + if err != nil { + return result, err + } + if input == nil { + return result, ErrStorage + } + zone := time.FixedZone("Asia/Shanghai", 8*3600) + now = now.In(zone) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, zone) + owners, accounts := triggerOwners(input) + type snapshotKey struct { + creator uint + metric, days int + } + snapshots := map[snapshotKey]map[ChannelKey]AlertComparison{} + var failures []string + for _, rule := range input.Rules { + if err := ctx.Err(); err != nil { + return result, err + } + if !validTriggerRule(rule) { + result.Skipped++ + continue + } + creator := accounts[rule.CreatorID] + if creator == nil || (creator.AuthorityID != 888 && creator.Level != 1) { + result.Skipped++ + continue + } + channels := []*AlertTriggerChannel{} + keys := []ChannelKey{} + for _, channel := range input.Channels { + if channel == nil { + continue + } + owner := owners[channel.ChannelKey] + if owner != nil && (creator.AuthorityID == 888 || owner.ID == creator.ID) { + channels = append(channels, channel) + keys = append(keys, channel.ChannelKey) + } + } + if len(channels) == 0 { + continue + } + result.Rules++ + var snapshot map[ChannelKey]AlertComparison + if rule.Metric != 3 { + key := snapshotKey{creator.ID, rule.Metric, *rule.StatPeriodDays} + var exists bool + snapshot, exists = snapshots[key] + if !exists { + snapshot, err = uc.repo.Comparison(ctx, rule.Metric, keys, today, key.days) + if err != nil { + failures = append(failures, fmt.Sprintf("规则 %d 读取指标失败: %v", rule.ID, err)) + continue + } + snapshots[key] = snapshot + } + } + for _, channel := range channels { + if err := ctx.Err(); err != nil { + return result, err + } + content, hit, err := triggerContent(rule, channel, snapshot[channel.ChannelKey], today) + if err != nil { + failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d: %v", rule.ID, channel.SourceDB, channel.ID, err)) + continue + } + if !hit { + continue + } + owner := owners[channel.ChannelKey] + record := &AlertRecord{RuleID: rule.ID, RuleName: rule.Name, RuleType: strings.TrimSpace(rule.Type), MonitorMetric: &rule.Metric, + PlatformCode: channel.SourceDB, PlatformName: PlatformName(channel.SourceDB), TargetPromoteID: channel.ID, TargetPromoteName: channel.Account, + BusinessID: owner.ID, BusinessName: owner.Username, AlertContent: content, Status: AlertUnprocessed, CreatedAt: now, UpdatedAt: now} + written, err := uc.repo.StoreHit(ctx, record, []int{AlertUnprocessed, AlertProcessing, AlertPendingAudit, AlertAuditRejected}, func(current *AlertRecord) bool { return current == nil || current.Status == AlertUnprocessed }) + if err != nil { + failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d 写入失败: %v", rule.ID, channel.SourceDB, channel.ID, err)) + continue + } + if written { + result.Written++ + } + } + } + if len(failures) > 0 { + for _, failure := range failures { + slog.ErrorContext(ctx, "预警检测失败", "detail", failure) + } + return result, fmt.Errorf("预警规则检测存在 %d 项失败:%s", len(failures), strings.Join(failures[:min(5, len(failures))], ";")) + } + return result, nil +} + +func triggerContent(rule *AlertRule, channel *AlertTriggerChannel, sample AlertComparison, today time.Time) (string, bool, error) { + if rule.Metric == 3 { + stamp := channel.LastLoginUnix + if stamp <= 0 { + stamp = channel.CreatedUnix + } + if stamp <= 0 { + return "", false, nil + } + day := time.Unix(stamp, 0).In(today.Location()) + day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, today.Location()) + days := int(today.Sub(day) / (24 * time.Hour)) + if days < *rule.UnloginDays { + return "", false, nil + } + return fmt.Sprintf("连续%d天未登录", days), true, nil + } + days := *rule.StatPeriodDays + if sample.FirstUnix <= 0 || time.Unix(sample.FirstUnix, 0).In(today.Location()).Format("2006-01-02") > today.AddDate(0, 0, -2*days).Format("2006-01-02") { + return "", false, nil + } + previous, err := portraitNumber(sample.Previous) + if err != nil { + return "", false, err + } + if previous.Sign() <= 0 { + return "", false, nil + } + current, err := portraitNumber(sample.Current) + if err != nil { + return "", false, err + } + rate := new(big.Rat).Quo(new(big.Rat).Sub(current, previous), previous) + // 先对比值保留四位再转百分比,与旧 BigDecimal HALF_UP 的阈值边界一致。 + rate, _ = new(big.Rat).SetString(portraitDecimal(rate, 4)) + rate.Mul(rate, big.NewRat(100, 1)) + threshold, _ := new(big.Rat).SetString(*rule.MomThreshold) + if (*rule.MomDirection == 1 && rate.Cmp(threshold) < 0) || (*rule.MomDirection == 2 && rate.Cmp(new(big.Rat).Neg(threshold)) > 0) { + return "", false, nil + } + direction := "上升" + if rate.Sign() < 0 { + direction = "下降" + } + percent := portraitDecimal(new(big.Rat).Abs(rate), 2) + if rule.Metric == 1 { + return fmt.Sprintf("截至昨日%d个完整自然日注册%s人,较此前%d个完整自然日(%s人)环比%s%s%%", days, portraitDecimal(current, 0), days, portraitDecimal(previous, 0), direction, percent), true, nil + } + yuan := big.NewRat(10000, 1) + return fmt.Sprintf("截至昨日%d个完整自然日充值¥%s万,较此前%d个完整自然日(¥%s万)环比%s%s%%", days, portraitDecimal(new(big.Rat).Quo(current, yuan), 2), days, portraitDecimal(new(big.Rat).Quo(previous, yuan), 2), direction, percent), true, nil +} diff --git a/internal/biz/business/alert_trigger_test.go b/internal/biz/business/alert_trigger_test.go new file mode 100644 index 0000000..68b82d3 --- /dev/null +++ b/internal/biz/business/alert_trigger_test.go @@ -0,0 +1,112 @@ +package business + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +type triggerRepoFake struct { + input *AlertTriggerInput + metrics map[ChannelKey]AlertComparison + hits []*AlertRecord + metricCalls int + current *AlertRecord + failure error +} + +func (r *triggerRepoFake) Input(context.Context) (*AlertTriggerInput, error) { return r.input, nil } +func (r *triggerRepoFake) Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error) { + r.metricCalls++ + return r.metrics, r.failure +} +func (r *triggerRepoFake) StoreHit(_ context.Context, hit *AlertRecord, _ []int, decide func(*AlertRecord) bool) (bool, error) { + if !decide(r.current) { + return false, nil + } + r.hits = append(r.hits, hit) + return true, r.failure +} + +func triggerFixture() (*triggerRepoFake, time.Time) { + now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + r := &triggerRepoFake{input: &AlertTriggerInput{Accounts: []*Account{ + {ID: 1, AuthorityID: 888}, {ID: 10, Level: 1, Username: "一级甲"}, {ID: 11, Level: 1, Username: "一级乙"}, + {ID: 20, Level: 2, IDPath: "0,10"}, {ID: 30, Level: 3, IDPath: "0,10,20"}, {ID: 31, Level: 3, IDPath: "0,11,21"}, + }, Bindings: []*Binding{ + {UserID: 30, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 7}, Status: "0"}, + {UserID: 31, PlatformKey: PlatformKey{SourceDB: "hw", BusierID: 7}, Status: "0"}, + {UserID: 30, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 8}, Status: "0"}, + {UserID: 31, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 8}, Status: "0"}, + }, Channels: []*AlertTriggerChannel{ + {ChannelKey: ChannelKey{"lx", 10}, BusierID: 7, Account: "甲渠道", CreatedUnix: now.AddDate(0, 0, -3).Unix()}, + {ChannelKey: ChannelKey{"hw", 10}, BusierID: 7, Account: "乙渠道", LastLoginUnix: now.AddDate(0, 0, -10).Unix()}, + {ChannelKey: ChannelKey{"lx", 11}, BusierID: 8, Account: "冲突渠道", CreatedUnix: now.AddDate(0, 0, -10).Unix()}, + }}} + return r, now +} + +func TestAlertTriggerSeparatesOwnerPlatformAndConflictingBindings(t *testing.T) { + r, now := triggerFixture() + days := 3 + r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "未登录", Metric: 3, UnloginDays: &days}, {ID: 2, CreatorID: 20, Type: "越级", Metric: 3, UnloginDays: &days}} + result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now) + if err != nil || result.Written != 1 || result.Skipped != 1 || len(r.hits) != 1 { + t.Fatalf("检测结果 %+v %v", result, err) + } + hit := r.hits[0] + if hit.BusinessID != 10 || hit.PlatformCode != "lx" || hit.TargetPromoteID != 10 || hit.AlertContent != "连续3天未登录" { + t.Fatalf("归属或自然日错误: %+v", hit) + } + r.hits = nil + r.current = &AlertRecord{Status: 2} + result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now) + if err != nil || result.Written != 0 || len(r.hits) != 0 { + t.Fatalf("在途记录不应刷新: %+v %v", result, err) + } +} + +func TestAlertTriggerMoMBoundariesAndSnapshots(t *testing.T) { + r, now := triggerFixture() + days, direction := 2, 1 + threshold := "10" + r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "充值", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold}, + {ID: 2, CreatorID: 10, Type: "另一个充值规则", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold}} + r.metrics = map[ChannelKey]AlertComparison{{"lx", 10}: {FirstUnix: now.AddDate(0, 0, -4).Unix(), Current: "110000", Previous: "100000"}} + result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now) + if err != nil || result.Written != 2 || r.metricCalls != 1 { + t.Fatalf("同窗口应共享指标: %+v calls=%d %v", result, r.metricCalls, err) + } + if r.hits[0].AlertContent != "截至昨日2个完整自然日充值¥11.00万,较此前2个完整自然日(¥10.00万)环比上升10.00%" { + t.Fatal(r.hits[0].AlertContent) + } + r.metrics[ChannelKey{"lx", 10}] = AlertComparison{FirstUnix: now.AddDate(0, 0, -3).Unix(), Current: "110000", Previous: "100000"} + r.hits = nil + result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now) + if err != nil || result.Written != 0 { + t.Fatalf("不足双周期不应触发: %+v %v", result, err) + } + r.failure = errors.New("指标不可用") + _, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now) + if err == nil || !strings.Contains(err.Error(), "2 项失败") { + t.Fatalf("读取失败不能报告成功: %v", err) + } +} + +func TestAlertTriggerRoundedThresholdAndNoBaseline(t *testing.T) { + days, direction := 1, 1 + threshold := "10" + rule := &AlertRule{Metric: 1, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold} + _, now := triggerFixture() + for _, test := range []struct { + current, previous string + hit bool + }{{"109.995", "100", true}, {"109.994", "100", false}, {"1", "0", false}, {"1", "-1", false}} { + _, hit, err := triggerContent(rule, &AlertTriggerChannel{}, AlertComparison{FirstUnix: now.AddDate(0, 0, -2).Unix(), Current: test.current, Previous: test.previous}, now) + if err != nil || hit != test.hit { + t.Fatalf("%+v: hit=%v err=%v", test, hit, err) + } + } +} diff --git a/internal/biz/business/ranking.go b/internal/biz/business/ranking.go index ab8d5f8..f910df1 100644 --- a/internal/biz/business/ranking.go +++ b/internal/biz/business/ranking.go @@ -200,12 +200,12 @@ func buildRankingItems(kind RankingKind, metric string, current, previous map[An if err != nil { return nil, err } - if metric == "rate" || valid { + // 四个榜单统一使用相对上期的增长率。上期为零或无有效 + // 活跃基数时不能计算环比,保留 nil 供页面显示无可比数据。 + if valid && prior.Sign() > 0 { change := new(big.Rat).Sub(value, prior) - if metric != "rate" { - change.Mul(change, big.NewRat(100, 1)) - change.Quo(change, prior) - } + change.Mul(change, big.NewRat(100, 1)) + change.Quo(change, prior) item.MoM = stringPointer(portraitDecimal(change, 2)) rounded, _ := portraitNumber(*item.MoM) status := 0 diff --git a/internal/biz/business/ranking_test.go b/internal/biz/business/ranking_test.go index 4a40765..569a1ae 100644 --- a/internal/biz/business/ranking_test.go +++ b/internal/biz/business/ranking_test.go @@ -39,7 +39,7 @@ func TestRankingComparisonsAndBusinessCountSemantics(t *testing.T) { t.Fatalf("排行筛选、命名或环比错误: %+v %v", p, err) } if *p.PayRate[0].MoM != "0.00" || *p.PayRate[0].MoMStatus != 0 || r.filter.PreviousBegin != "2026-10-22" || r.filter.PreviousEnd != "2026-10-28" { - t.Fatal("等长上期或百分点差错误") + t.Fatal("等长上期或付费率环比错误") } r.data.Businesses = []*RankingBusiness{{ID: 8, Name: "二级商务", Level: 2, Channels: []AnalyticsKey{k, zero}}} p, err = uc.List(accountContext(7, 901), BusinessRanking, AnalyticsFilter{}) @@ -74,3 +74,37 @@ func TestRankingTopTwentyAndStatusThreshold(t *testing.T) { t.Fatal("持平环比错误") } } + +func TestRankingPayRateRelativeGrowth(t *testing.T) { + for _, tc := range []struct { + name string + current PortraitCounts + previous *RankingMetrics + want string + status int + }{ + {"increase", PortraitCounts{Pay: 12, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 10, Active: 100}}, "20.00", 1}, + {"decrease", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 20, Active: 100}}, "-50.00", -1}, + {"unchanged", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 10, Active: 100}}, "0.00", 0}, + {"no current payers", PortraitCounts{Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 10, Active: 100}}, "-100.00", -1}, + {"zero prior rate", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Active: 100}}, "", 0}, + {"no prior active players", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 1}}, "", 0}, + {"no prior row", PortraitCounts{Pay: 10, Active: 100}, nil, "", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + key := AnalyticsKey{SourceDB: "lx", ID: 1} + rows, err := buildRankingItems(ChannelRanking, "rate", map[AnalyticsKey]*RankingMetrics{key: {Counts: tc.current}}, map[AnalyticsKey]*RankingMetrics{key: tc.previous}) + if err != nil || len(rows) != 1 { + t.Fatalf("ranking: %v, %v", rows, err) + } + row := rows[0] + if tc.want == "" { + if row.MoM != nil || row.MoMStatus != nil { + t.Fatal("missing or zero baseline must not produce a growth percentage") + } + } else if row.MoM == nil || *row.MoM != tc.want || row.MoMStatus == nil || *row.MoMStatus != tc.status { + t.Fatalf("expected %s%% with status %d, got %+v", tc.want, tc.status, row) + } + }) + } +} diff --git a/internal/biz/business/workbench.go b/internal/biz/business/workbench.go index 0a20ec5..3b8ee74 100644 --- a/internal/biz/business/workbench.go +++ b/internal/biz/business/workbench.go @@ -1,6 +1,7 @@ package business import ( + "container/heap" "context" "math/big" "sort" @@ -57,6 +58,7 @@ type WorkbenchRankData struct { type WorkbenchRepo interface { Summary(context.Context, AnalyticsScope, []string, WorkbenchWindow) (*WorkbenchTotals, error) Ranks(context.Context, AnalyticsScope, AnalyticsFilter, WorkbenchRankKind) (*WorkbenchRankData, error) + FillRankNames(context.Context, []*WorkbenchRankRow) error } type WorkbenchUsecase struct { repo WorkbenchRepo @@ -185,9 +187,8 @@ func (uc *WorkbenchUsecase) Ranks(ctx context.Context, sources []string, period if !data.Available { return nil, ErrWorkbenchUnavailable } - rows := make([]*WorkbenchRankRow, 0, len(data.Rows)) - values := map[*WorkbenchRankRow]*big.Rat{} - for _, row := range data.Rows { + top := &workbenchRankHeap{ascending: kind == WorkbenchPayLow} + for index, row := range data.Rows { if kind == WorkbenchUnlogin { row.Value = big.NewInt(workbenchUnloginDays(row, w.Today)).String() } @@ -201,32 +202,68 @@ func (uc *WorkbenchUsecase) Ranks(ctx context.Context, sources []string, period if (kind == WorkbenchRegister || kind == WorkbenchPayHigh) && n.Sign() <= 0 { continue } - values[row] = n - rows = append(rows, row) + candidate := workbenchRankCandidate{row: row, value: n, index: index} + if top.Len() < 10 { + heap.Push(top, candidate) + } else if top.better(candidate, top.rows[0]) { + top.rows[0] = candidate + heap.Fix(top, 0) + } + } + sort.Slice(top.rows, func(i, j int) bool { return top.better(top.rows[i], top.rows[j]) }) + rows := make([]*WorkbenchRankRow, len(top.rows)) + for i, candidate := range top.rows { + rows[i] = candidate.row + } + if err := uc.repo.FillRankNames(ctx, rows); err != nil { + return nil, err } - sort.SliceStable(rows, func(i, j int) bool { - a, b := rows[i], rows[j] - if c := values[a].Cmp(values[b]); c != 0 { - if kind == WorkbenchPayLow { - return c < 0 - } - return c > 0 - } - if a.ID != b.ID { - return a.ID < b.ID - } - return a.SourceDB < b.SourceDB - }) - rows = rows[:min(10, len(rows))] for i, row := range rows { row.Rank = i + 1 row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote() if kind != WorkbenchUnlogin { - row.Value = portraitDecimal(values[row], 2) + row.Value = portraitDecimal(top.rows[i].value, 2) } } return rows, nil } + +type workbenchRankCandidate struct { + row *WorkbenchRankRow + value *big.Rat + index int +} + +// 堆顶保留最差的入选行,只维护 TOP10;比较规则与完整稳定排序一致。 +type workbenchRankHeap struct { + rows []workbenchRankCandidate + ascending bool +} + +func (h workbenchRankHeap) better(a, b workbenchRankCandidate) bool { + if c := a.value.Cmp(b.value); c != 0 { + if h.ascending { + return c < 0 + } + return c > 0 + } + if a.row.ID != b.row.ID { + return a.row.ID < b.row.ID + } + if a.row.SourceDB != b.row.SourceDB { + return a.row.SourceDB < b.row.SourceDB + } + return a.index < b.index +} +func (h workbenchRankHeap) Len() int { return len(h.rows) } +func (h workbenchRankHeap) Less(i, j int) bool { return h.better(h.rows[j], h.rows[i]) } +func (h workbenchRankHeap) Swap(i, j int) { h.rows[i], h.rows[j] = h.rows[j], h.rows[i] } +func (h *workbenchRankHeap) Push(v any) { h.rows = append(h.rows, v.(workbenchRankCandidate)) } +func (h *workbenchRankHeap) Pop() any { + last := h.rows[len(h.rows)-1] + h.rows = h.rows[:len(h.rows)-1] + return last +} func workbenchUnloginDays(row *WorkbenchRankRow, today time.Time) int64 { stamp := row.LastLoginUnix if stamp == nil || *stamp <= 0 { diff --git a/internal/biz/business/workbench_test.go b/internal/biz/business/workbench_test.go index 20e4c2c..eda744f 100644 --- a/internal/biz/business/workbench_test.go +++ b/internal/biz/business/workbench_test.go @@ -3,6 +3,8 @@ package business import ( "context" "fmt" + "math/big" + "sort" "testing" "time" ) @@ -13,6 +15,52 @@ type workbenchRepoFake struct { window WorkbenchWindow filter AnalyticsFilter scope AnalyticsScope + named []*WorkbenchRankRow +} + +func TestWorkbenchTopTenMatchesFullStableSort(t *testing.T) { + for _, kind := range []WorkbenchRankKind{WorkbenchPayHigh, WorkbenchPayLow} { + uc, repo := workbenchFixture() + for i := 0; i < 3000; i++ { + repo.data.Rows = append(repo.data.Rows, &WorkbenchRankRow{AnalyticsKey: AnalyticsKey{ID: int64(i%11 + 1), SourceDB: []string{"lx", "hw", "yq"}[i%3]}, Name: fmt.Sprintf("candidate-%04d", i), Value: big.NewRat(int64(i%7-2), 100).FloatString(2)}) + } + expected := make([]*WorkbenchRankRow, 0, len(repo.data.Rows)) + for _, row := range repo.data.Rows { + n, _ := new(big.Rat).SetString(row.Value) + if kind == WorkbenchPayLow || n.Sign() > 0 { + expected = append(expected, row) + } + } + sort.SliceStable(expected, func(i, j int) bool { + a, b := expected[i], expected[j] + av, _ := new(big.Rat).SetString(a.Value) + bv, _ := new(big.Rat).SetString(b.Value) + if order := av.Cmp(bv); order != 0 { + if kind == WorkbenchPayLow { + return order < 0 + } + return order > 0 + } + if a.ID != b.ID { + return a.ID < b.ID + } + return a.SourceDB < b.SourceDB + }) + actual, err := uc.Ranks(accountContext(7, 901), []string{"lx", "hw", "yq"}, "last30", kind) + if err != nil || len(actual) != 10 { + t.Fatalf("rank failed: %v", err) + } + for i, row := range actual { + if row.AnalyticsKey != expected[i].AnalyticsKey || row.Value != expected[i].Value || row.Name != expected[i].Name || row.Rank != i+1 { + t.Fatalf("%s rank %d differs from full stable sort: %+v vs %+v", kind, i, row, expected[i]) + } + } + } +} + +func (r *workbenchRepoFake) FillRankNames(_ context.Context, rows []*WorkbenchRankRow) error { + r.named = rows + return nil } func (r *workbenchRepoFake) Summary(_ context.Context, s AnalyticsScope, _ []string, w WorkbenchWindow) (*WorkbenchTotals, error) { @@ -64,6 +112,9 @@ func TestWorkbenchRankLimitsExactSortDefaultsAndUnlogin(t *testing.T) { if err != nil || len(rows) != 10 || rows[0].ID != 13 || rows[9].Rank != 10 { t.Fatal("榜单排序或 TOP10 截断错误") } + if len(r.named) != 10 { + t.Fatal("仅应补充已入选 TOP10 的渠道名称") + } rows, err = uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayLow) if err != nil || rows[0].ID != 0 || rows[0].Value != "0.00" || rows[0].Name != "官方渠道" || r.filter.Begin != "2026-03-02" { t.Fatal("最低榜应包含零值,默认近30天") diff --git a/internal/biz/integration/integration_config.go b/internal/biz/integration/integration_config.go index 2e87006..c0d5452 100644 --- a/internal/biz/integration/integration_config.go +++ b/internal/biz/integration/integration_config.go @@ -501,7 +501,12 @@ func IsIntegrationSecretKey(key string) bool { if strings.Contains(normalized, "secret") || strings.Contains(normalized, "password") || strings.Contains(normalized, "private") || strings.Contains(normalized, "credential") || strings.Contains(normalized, "token") { return true } - for _, item := range []string{"key_pem", "api_key", "mch_key", "client_key", "certificate", "cert", "cert_pem", "p12", "pkcs12", "public_key", "platform_cert", "root_cert"} { + // Providers accept inline PEM, file and path aliases of the same secret. + // Strip only these transport suffixes; ordinary content/path fields stay public. + for _, suffix := range []string{"_content", "_path", "_file", "_pem"} { + normalized = strings.TrimSuffix(normalized, suffix) + } + for _, item := range []string{"key", "api_key", "mch_key", "client_key", "certificate", "cert", "p12", "pkcs12", "public_key", "platform_cert", "root_cert"} { if normalized == item || strings.HasSuffix(normalized, "_"+item) { return true } diff --git a/internal/biz/integration/secret_alias_test.go b/internal/biz/integration/secret_alias_test.go new file mode 100644 index 0000000..e5da75b --- /dev/null +++ b/internal/biz/integration/secret_alias_test.go @@ -0,0 +1,16 @@ +package integration + +import "testing" + +func TestIntegrationCertificateAliases(t *testing.T) { + for _, key := range []string{"app_cert_content", "root_cert_path", "alipay_public_cert_content", "pkcs12_content", "p12_file", "cert_content", "key_content", "cert_path", "key_file", "client_key", "public_key_pem"} { + if !IsIntegrationSecretKey(key) { + t.Errorf("secret alias was not recognized: %s", key) + } + } + for _, key := range []string{"content", "path", "notify_url", "template_content", "content_type", "platform_serial_no", "public_key_id"} { + if IsIntegrationSecretKey(key) { + t.Errorf("public field was masked: %s", key) + } + } +} diff --git a/internal/biz/payment/diagnostic_error.go b/internal/biz/payment/diagnostic_error.go new file mode 100644 index 0000000..4592dc1 --- /dev/null +++ b/internal/biz/payment/diagnostic_error.go @@ -0,0 +1,5 @@ +package payment + +import "github.com/go-kratos/kratos/v3/errors" + +var ErrPaymentTestFailed = errors.BadRequest("PAYMENT_TEST_FAILED", "支付渠道测试失败,请检查配置和服务端日志") diff --git a/internal/biz/task/execution.go b/internal/biz/task/execution.go new file mode 100644 index 0000000..485038e --- /dev/null +++ b/internal/biz/task/execution.go @@ -0,0 +1,59 @@ +package task + +import ( + "bytes" + "encoding/json" + "time" + + "github.com/go-kratos/kratos/v3/errors" +) + +var ErrTaskExecutionOptions = errors.BadRequest("TASK_EXECUTION_OPTIONS_INVALID", "执行参数不正确:超时须为 1–86400 秒,重试次数为 0–10,重试间隔为 0–3600 秒") + +type ExecutionOptions struct { + Timeout time.Duration + MaxRetryTimes int + RetryInterval time.Duration +} + +// MethodExecutionOptions 承接迁移 SQL 保留的 _execution 元数据;普通方法维持原默认行为。 +func MethodExecutionOptions(params []byte) (ExecutionOptions, error) { + options := ExecutionOptions{Timeout: 5 * time.Minute, RetryInterval: time.Second} + params = bytes.TrimSpace(params) + if len(params) == 0 || params[0] != '{' { + return options, nil + } + var envelope struct { + Execution *struct { + TimeoutSeconds *int64 `json:"timeoutSeconds"` + MaxRetryTimes *int `json:"maxRetryTimes"` + RetryIntervalSeconds *int64 `json:"retryIntervalSeconds"` + } `json:"_execution"` + } + if err := json.Unmarshal(params, &envelope); err != nil { + return options, ErrTaskExecutionOptions + } + if envelope.Execution == nil { + return options, nil + } + value := envelope.Execution + if value.TimeoutSeconds != nil { + if *value.TimeoutSeconds < 1 || *value.TimeoutSeconds > 86400 { + return options, ErrTaskExecutionOptions + } + options.Timeout = time.Duration(*value.TimeoutSeconds) * time.Second + } + if value.MaxRetryTimes != nil { + if *value.MaxRetryTimes < 0 || *value.MaxRetryTimes > 10 { + return options, ErrTaskExecutionOptions + } + options.MaxRetryTimes = *value.MaxRetryTimes + } + if value.RetryIntervalSeconds != nil { + if *value.RetryIntervalSeconds < 0 || *value.RetryIntervalSeconds > 3600 { + return options, ErrTaskExecutionOptions + } + options.RetryInterval = time.Duration(*value.RetryIntervalSeconds) * time.Second + } + return options, nil +} diff --git a/internal/biz/task/execution_test.go b/internal/biz/task/execution_test.go new file mode 100644 index 0000000..3734581 --- /dev/null +++ b/internal/biz/task/execution_test.go @@ -0,0 +1,24 @@ +package task + +import ( + "testing" + "time" +) + +func TestMethodExecutionOptionsPreservesImportedPolicy(t *testing.T) { + options, err := MethodExecutionOptions([]byte(`{"startDate":"2026-11-03","_execution":{"timeoutSeconds":1600,"maxRetryTimes":3,"retryIntervalSeconds":1}}`)) + if err != nil || options.Timeout != 1600*time.Second || options.MaxRetryTimes != 3 || options.RetryInterval != time.Second { + t.Fatalf("迁移任务策略丢失: %+v %v", options, err) + } + for _, params := range []string{"", `{}`, `[]`, `"value"`} { + options, err := MethodExecutionOptions([]byte(params)) + if err != nil || options.Timeout != 5*time.Minute || options.MaxRetryTimes != 0 { + t.Fatalf("普通方法默认行为变化: %q %+v %v", params, options, err) + } + } + for _, params := range []string{`{"_execution":{"timeoutSeconds":0}}`, `{"_execution":{"timeoutSeconds":86401}}`, `{"_execution":{"maxRetryTimes":11}}`, `{"_execution":{"retryIntervalSeconds":-1}}`, `{"_execution":"invalid"}`} { + if _, err := MethodExecutionOptions([]byte(params)); err == nil { + t.Fatalf("无效执行参数未拒绝: %s", params) + } + } +} diff --git a/internal/biz/task/task.go b/internal/biz/task/task.go index 2dd4f34..bab1be5 100644 --- a/internal/biz/task/task.go +++ b/internal/biz/task/task.go @@ -108,6 +108,9 @@ func (uc *TaskUsecase) Validate(value *TimedTask) error { if len(value.Params) > 0 && !json.Valid(value.Params) { return errors.New("params 必须是合法 JSON") } + if _, err := MethodExecutionOptions(value.Params); err != nil { + return err + } case TaskExecutorHTTP: parsed, parseErr := url.Parse(value.HTTPURL) if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { diff --git a/internal/config/swagger.go b/internal/config/swagger.go new file mode 100644 index 0000000..c13cd65 --- /dev/null +++ b/internal/config/swagger.go @@ -0,0 +1,16 @@ +package config + +import "strings" + +// SwaggerEnabled exposes API documentation only in explicitly selected local/test environments. +func SwaggerEnabled(admin *Admin) bool { + if admin == nil || admin.App == nil { + return false + } + switch strings.ToLower(strings.TrimSpace(admin.App.Env)) { + case "development", "dev", "test", "local": + return true + default: + return false + } +} diff --git a/internal/config/swagger_test.go b/internal/config/swagger_test.go new file mode 100644 index 0000000..6fe4c34 --- /dev/null +++ b/internal/config/swagger_test.go @@ -0,0 +1,19 @@ +package config + +import "testing" + +func TestSwaggerRequiresExplicitDevelopmentEnvironment(t *testing.T) { + if SwaggerEnabled(nil) || SwaggerEnabled(&Admin{}) { + t.Fatal("missing environment exposed Swagger") + } + for _, env := range []string{"", "production", "prod", "staging", "qa", "uat", "unknown"} { + if SwaggerEnabled(&Admin{App: &App{Env: env}}) { + t.Errorf("Swagger enabled in %q", env) + } + } + for _, env := range []string{"development", "dev", "test", "local", " DEV "} { + if !SwaggerEnabled(&Admin{App: &App{Env: env}}) { + t.Errorf("Swagger disabled in %q", env) + } + } +} diff --git a/internal/data/business/activation.go b/internal/data/business/activation.go index c4a9678..bfbb42d 100644 --- a/internal/data/business/activation.go +++ b/internal/data/business/activation.go @@ -37,9 +37,13 @@ func NewActivationRepo(data Provider, databases PlatformProvider) business.Activ return &activationRepo{data: data, databases: databases} } -func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]string, error) { - accounts := []string{} - err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform").Where("user_id = ? AND status = ? AND platform_busier_account IS NOT NULL", id, "0").Distinct("platform_busier_account").Pluck("platform_busier_account", &accounts).Error +func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]business.ActivationAccount, error) { + var rows []struct{ SourceDB, Account string } + err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform").Where("user_id = ? AND status = ? AND platform_busier_account IS NOT NULL", id, "0").Distinct("source_db,platform_busier_account AS account").Scan(&rows).Error + accounts := make([]business.ActivationAccount, 0, len(rows)) + for _, row := range rows { + accounts = append(accounts, business.ActivationAccount{SourceDB: row.SourceDB, Account: row.Account}) + } return accounts, storageError(err) } @@ -68,11 +72,11 @@ func (r *activationRepo) List(ctx context.Context, scope business.ActivationScop } q := db.Model(&activationPO{}).Where("deleted_at IS NULL") if !scope.All { - accounts := make([]string, 0, len(scope.Accounts)) + accounts := make([][]any, 0, len(scope.Accounts)) for _, account := range scope.Accounts { - accounts = append(accounts, strings.ToLower(account)) + accounts = append(accounts, []any{strings.ToLower(account.SourceDB), strings.ToLower(account.Account)}) } - q = q.Where("LOWER(busier_account) IN ?", accounts) + q = q.Where("(LOWER(platform_name),LOWER(busier_account)) IN ?", accounts) } if len(filter.PlatformNames) > 0 { q = q.Where("platform_name IN ?", filter.PlatformNames) diff --git a/internal/data/business/activation_test.go b/internal/data/business/activation_test.go index 0ec8252..3fbb61b 100644 --- a/internal/data/business/activation_test.go +++ b/internal/data/business/activation_test.go @@ -59,11 +59,11 @@ func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) { db, _, repo := prepareActivations(t) ctx := context.Background() accounts, err := repo.OwnAccounts(ctx, 7) - if err != nil || len(accounts) != 1 || accounts[0] != "Own" { + if err != nil || len(accounts) != 1 || accounts[0] != (business.ActivationAccount{SourceDB: "lx", Account: "Own"}) { t.Fatalf("本人已通过账号范围错误: %+v %v", accounts, err) } - page, err := repo.List(ctx, business.ActivationScope{Accounts: []string{"OWN"}}, business.ActivationFilter{}) - if err != nil || page.Total != 2 || page.Rows[0].ID != 2 { + page, err := repo.List(ctx, business.ActivationScope{Accounts: []business.ActivationAccount{{SourceDB: "lx", Account: "OWN"}}}, business.ActivationFilter{}) + if err != nil || page.Total != 1 || page.Rows[0].ID != 1 { t.Fatalf("OA 列表或排序错误: %+v %v", page, err) } page, err = repo.List(ctx, business.ActivationScope{All: true}, business.ActivationFilter{PlatformNames: []string{"lx"}, Statuses: []string{"reached"}}) diff --git a/internal/data/business/alert_trigger.go b/internal/data/business/alert_trigger.go new file mode 100644 index 0000000..f52d124 --- /dev/null +++ b/internal/data/business/alert_trigger.go @@ -0,0 +1,168 @@ +package business + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "kra/internal/biz/business" +) + +type alertTriggerRepo struct{ data Provider } + +func NewAlertTriggerRepo(data Provider) business.AlertTriggerRepo { + return &alertTriggerRepo{data: data} +} + +type alertTriggerLockPO struct { + SourceDB string `gorm:"column:source_db;primaryKey;size:50"` + PromoteID uint64 `gorm:"primaryKey;autoIncrement:false"` + RuleType string `gorm:"primaryKey;size:50"` +} + +func (alertTriggerLockPO) TableName() string { return "sys_business_alert_locks" } + +func (r *alertTriggerRepo) Input(ctx context.Context) (*business.AlertTriggerInput, error) { + db := r.data.DB().WithContext(ctx) + out := &business.AlertTriggerInput{} + var rules []alertRulePO + if err := db.Where("enabled=1 AND is_deleted=0").Order("id").Find(&rules).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range rules { + out.Rules = append(out.Rules, toBizAlertRule(row)) + } + if len(rules) == 0 { + return out, nil + } + var accounts []accountPO + if err := db.Select("id,username,level,parent_id,id_path,authority_id").Find(&accounts).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range accounts { + out.Accounts = append(out.Accounts, toBizAccount(row)) + } + var bindings []bindingPO + if err := db.Where("status=?", "0").Find(&bindings).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range bindings { + out.Bindings = append(out.Bindings, toBizBinding(row)) + } + if len(bindings) == 0 { + return out, nil + } + available, err := businessTableAvailable(db, "tab_promote") + if err != nil { + return nil, err + } + if !available { + return nil, business.ErrAlertChannelsUnavailable + } + var channels []struct { + ID, BusierID uint64 + SourceDB string `gorm:"column:source_db"` + Account string + LastLoginTime, CreateTime *int64 + } + bound := db.Table("sys_user_bind_platform b").Select("1").Where("b.status=? AND b.source_db=p.source_db AND b.platform_busier_id=p.busier_id", "0") + if err := db.Table("tab_promote p").Select("p.id,p.source_db,p.account,p.busier_id,p.last_login_time,p.create_time").Where("p.promote_level=1 AND p.status=1").Where("EXISTS (?)", bound).Order("p.source_db,p.id").Scan(&channels).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range channels { + channel := &business.AlertTriggerChannel{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}, Account: row.Account, BusierID: row.BusierID} + if row.LastLoginTime != nil { + channel.LastLoginUnix = *row.LastLoginTime + } + if row.CreateTime != nil { + channel.CreatedUnix = *row.CreateTime + } + out.Channels = append(out.Channels, channel) + } + return out, nil +} + +func (r *alertTriggerRepo) Comparison(ctx context.Context, metric int, channels []business.ChannelKey, today time.Time, days int) (map[business.ChannelKey]business.AlertComparison, error) { + db := r.data.DB().WithContext(ctx) + table, stamp, amount, condition := "tab_user", "register_time", "1", "(puid=0 OR puid IS NULL)" + if metric == 2 { + table, stamp, amount, condition = "tab_spend", "pay_time", "pay_amount", "pay_status=1" + } else if metric != 1 { + return nil, business.ErrAlertRuleInput + } + available, err := businessTableAvailable(db, table) + if err != nil { + return nil, err + } + if !available { + return nil, business.ErrReportBuildUnavailable + } + result := map[business.ChannelKey]business.AlertComparison{} + currentStart, previousStart := today.AddDate(0, 0, -days).Unix(), today.AddDate(0, 0, -2*days).Unix() + for start := 0; start < len(channels); start += 500 { + var rows []struct { + SourceDB string `gorm:"column:source_db"` + PromoteID uint64 + FirstUnix *int64 + CurrentValue, PreviousValue string + } + // 原始主账号与成功支付口径,不扩展下级渠道,也不使用身份证偏好。 + selection := "source_db,promote_id,MIN(" + stamp + ") AS first_unix," + + "COALESCE(SUM(CASE WHEN " + stamp + ">=? AND " + stamp + "=? AND " + stamp + " 0 { + // 直接使用等价生成列,使 COUNT 和分页主键查询走覆盖索引。 + // CAST 虽可用于范围定位,但 MySQL 5.7 仍会回表读取原字符串。 + cast = "r.sw_register_time_unix" + } } + return cast, nil +} + +func registrationQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, cast string) *gorm.DB { q := ledgerScopeQuery(db, scope, filter, "tab_user").Where("r.puid = 0").Where(cast+" BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix) if filter.UserID != nil { q = q.Where("r.id = ?", *filter.UserID) @@ -135,7 +148,11 @@ func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerSco page.Available = false return page, nil } - q := registrationQuery(db, scope, filter) + timeExpression, err := registrationTimeExpression(db) + if err != nil { + return nil, err + } + q := registrationQuery(db, scope, filter, timeExpression) if err := q.Count(&page.Total).Error; err != nil { return nil, business.ErrStorage } @@ -146,15 +163,14 @@ func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerSco if filter.Ascending { direction = " ASC" } - order := "CAST(r.register_time AS INTEGER)" - if db.Dialector.Name() == "mysql" { - order = "CAST(r.register_time AS UNSIGNED)" - } + order := timeExpression if filter.SortField == "totalPayAmount" { order = "(SELECT COALESCE(SUM(s.pay_amount),0) FROM tab_spend s WHERE s.source_db=r.source_db AND s.user_id=r.id AND s.pay_status=1)" } var pos []registrationPO - if err := q.Select("r.id,r.source_db,r.register_time,r.fgame_id,r.fgame_name,r.register_ip,r.device_name,r.promote_id").Order(order + direction).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit).Scan(&pos).Error; err != nil { + keysQuery := q.Select("r.id,r.source_db").Order(order + direction).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit) + pageQuery := db.Table("tab_user r").Joins("JOIN (?) page_ids ON page_ids.id=r.id AND page_ids.source_db=r.source_db", keysQuery) + if err := pageQuery.Select("r.id,r.source_db,r.register_time,r.fgame_id,r.fgame_name,r.register_ip,r.device_name,r.promote_id").Order(order + direction).Order("r.id DESC,r.source_db ASC").Scan(&pos).Error; err != nil { return nil, business.ErrStorage } meta := []ledgerMetaTarget{} @@ -280,7 +296,10 @@ func (r *ledgerRepo) Spends(ctx context.Context, scope business.LedgerScope, fil order += " DESC" } var pos []spendPO - if err := q.Select("r.id,r.source_db,r.user_id,r.pay_order_number,r.game_id,r.game_name,r.server_id,r.server_name,r.game_player_name,r.pay_amount,r.pay_time,r.pay_way,r.pay_status,r.spend_ip,r.promote_id,r.promote_account").Order(order).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit).Scan(&pos).Error; err != nil { + // 先在窄查询中选出当前页主键,再读取明细列,避免为排序读取整段流水。 + keysQuery := q.Select("r.id,r.source_db").Order(order).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit) + pageQuery := db.Table("tab_spend r").Joins("JOIN (?) page_ids ON page_ids.id=r.id AND page_ids.source_db=r.source_db", keysQuery) + if err := pageQuery.Select("r.id,r.source_db,r.user_id,r.pay_order_number,r.game_id,r.game_name,r.server_id,r.server_name,r.game_player_name,r.pay_amount,r.pay_time,r.pay_way,r.pay_status,r.spend_ip,r.promote_id,r.promote_account").Order(order).Order("r.id DESC,r.source_db ASC").Scan(&pos).Error; err != nil { return nil, business.ErrStorage } meta := []ledgerMetaTarget{} diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index e4732e3..005ae18 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: "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.Step{{ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {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/mysql_rehearsal_test.go b/internal/data/business/mysql_rehearsal_test.go new file mode 100644 index 0000000..747535f --- /dev/null +++ b/internal/data/business/mysql_rehearsal_test.go @@ -0,0 +1,279 @@ +package business + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + drivermysql "github.com/go-sql-driver/mysql" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "kra/internal/biz/business" +) + +// 显式设置本地隔离 MySQL DSN 才执行;每项测试只创建并清理自己的随机数据库。 +func openRehearsalMySQL(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv("SW_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("未配置隔离 MySQL") + } + cfg, err := drivermysql.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") { + t.Fatal("MySQL 演练测试只接受本地隔离实例") + } + cfg.DBName = "" + cfg.ParseTime = true + cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600) + admin, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + name := fmt.Sprintf("sw_test_business_%d", time.Now().UnixNano()) + if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + _ = admin.Close() + t.Fatal(err) + } + t.Cleanup(func() { + _, err := admin.Exec("DROP DATABASE `" + name + "`") + if err != nil { + t.Error(err) + } + _ = admin.Close() + }) + cfg.DBName = name + db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(20) + t.Cleanup(func() { _ = pool.Close() }) + for _, step := range Migrations() { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + return db +} + +func TestMySQLAlertTriggerConcurrentDeduplication(t *testing.T) { + db := openRehearsalMySQL(t) + if err := db.Exec("ALTER TABLE sys_alert_record ADD COLUMN unclosed_flag TINYINT GENERATED ALWAYS AS (CASE WHEN status=5 THEN NULL ELSE 1 END) VIRTUAL, ADD UNIQUE INDEX uk_alert_record_unclosed(platform_code,target_promote_id,rule_type,unclosed_flag)").Error; err != nil { + t.Fatal(err) + } + r := NewAlertTriggerRepo(testData{db}) + now := time.Now().Truncate(time.Millisecond) + hit := &business.AlertRecord{RuleID: 1, PlatformCode: "lx", PlatformName: "龙翔", TargetPromoteID: 10, RuleType: "注册", Status: 1, BusinessID: 10, CreatedAt: now, UpdatedAt: now} + decide := func(current *business.AlertRecord) bool { return current == nil || current.Status == 1 } + errors := make(chan error, 16) + var group sync.WaitGroup + for i := 0; i < 16; i++ { + group.Add(1) + go func() { + defer group.Done() + _, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide) + errors <- err + }() + } + group.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + var count int64 + if err := db.Model(&alertRecordPO{}).Count(&count).Error; err != nil || count != 1 { + t.Fatalf("并发重复预警 count=%d error=%v", count, err) + } + if err := db.Model(&alertRecordPO{}).Where("platform_code='lx'").Update("status", 2).Error; err != nil { + t.Fatal(err) + } + changed := *hit + changed.AlertContent = "不应覆盖处理中记录" + written, err := r.StoreHit(context.Background(), &changed, []int{1, 2, 3, 4}, decide) + if err != nil || written { + t.Fatalf("在途记录被刷新: %v %v", written, err) + } + var row alertRecordPO + if err := db.First(&row).Error; err != nil { + t.Fatal(err) + } + if row.Status != 2 || row.AlertContent != "" { + t.Fatalf("状态或快照发生变化: %+v", row) + } + if err := db.Model(&row).Update("status", 4).Error; err != nil { + t.Fatal(err) + } + written, err = r.StoreHit(context.Background(), &changed, []int{1, 2, 3, 4}, decide) + if err != nil || written { + t.Fatalf("状态 4 是驳回,不能另建或覆盖未关闭记录: %v %v", written, err) + } + if err := db.Model(&row).Update("status", 5).Error; err != nil { + t.Fatal(err) + } + written, err = r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide) + db.Model(&alertRecordPO{}).Count(&count) + if err != nil || !written || count != 2 { + t.Fatalf("关闭后新一轮预警失败: %v count=%d %v", written, count, err) + } +} + +func TestMySQLRegistrationIndexPreservesCastAndUsesRange(t *testing.T) { + db := openRehearsalMySQL(t) + for _, statement := range []string{ + "CREATE TABLE tab_user(id INT,source_db VARCHAR(10),puid INT,register_time VARCHAR(11),PRIMARY KEY(id,source_db),KEY(register_time))", + "INSERT INTO tab_user VALUES(1,'lx',0,'1793635200'),(2,'lx',0,'01793635200'),(3,'lx',0,'1793635200 '),(4,'lx',1,'1793635200'),(5,'hw',0,'1793635200'),(6,'lx',0,'1793000000')", + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + // 让命中比例接近实际分页查询,避免微型表上优化器合理选择全表扫描。 + outside := make([]map[string]any, 1000) + for i := range outside { + outside[i] = map[string]any{"id": 100 + i, "source_db": "lx", "puid": 0, "register_time": "1793000000"} + } + if err := db.Table("tab_user").CreateInBatches(outside, 200).Error; err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + var beforeMode string + if err := db.Raw("SELECT @@session.sql_mode").Scan(&beforeMode).Error; err != nil { + t.Fatal(err) + } + query := "SELECT id,register_time FROM tab_user r WHERE r.source_db='lx' AND r.puid=0 AND CAST(r.register_time AS UNSIGNED) BETWEEN 1793635200 AND 1793721599 ORDER BY id" + var before, after []struct { + ID int + RegisterTime string + } + if err := db.Raw(query).Scan(&before).Error; err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := migrateRegistrationIndex(db); err != nil { + t.Fatal(err) + } + } + var afterMode string + if err := db.Raw("SELECT @@session.sql_mode").Scan(&afterMode).Error; err != nil || beforeMode != afterMode { + t.Fatalf("索引创建未恢复会话模式: %v", err) + } + if err := db.Raw(query).Scan(&after).Error; err != nil { + t.Fatal(err) + } + if len(before) != 3 || fmt.Sprint(before) != fmt.Sprint(after) { + t.Fatalf("时间转换或原字符串改变: before=%+v after=%+v", before, after) + } + var plan []struct{ Key, Type string } + if err := db.Raw("EXPLAIN SELECT COUNT(*) FROM tab_user r WHERE r.source_db='lx' AND r.puid=0 AND CAST(r.register_time AS UNSIGNED) BETWEEN 1793635200 AND 1793721599").Scan(&plan).Error; err != nil { + t.Fatal(err) + } + if len(plan) != 1 || plan[0].Key != "idx_sw_user_registration" || plan[0].Type != "range" { + t.Fatalf("未使用时间范围索引: %+v", plan) + } + queryAll, err := reportRegistrationQuery(db) + if err != nil { + t.Fatal(err) + } + var allIDs []int + if err := queryAll.Select("id").Where("puid=0 AND CAST(register_time AS UNSIGNED) BETWEEN ? AND ?", 1793635200, 1793721599).Order("id").Scan(&allIDs).Error; err != nil { + t.Fatal(err) + } + if fmt.Sprint(allIDs) != "[1 2 3 5]" { + t.Fatalf("跨平台重建筛选丢失记录: %v", allIDs) + } +} + +func TestMySQLChannelCreationSerializesAccount(t *testing.T) { + db := openRehearsalMySQL(t) + if err := db.AutoMigrate(&platformChannelPO{}, &channelSettlementPO{}); err != nil { + t.Fatal(err) + } + for _, statement := range []string{"CREATE TABLE tab_promote_business(id BIGINT PRIMARY KEY,promote_ids TEXT)", "INSERT INTO tab_promote_business VALUES(7,'5,6')"} { + if err := db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + repo := NewChannelWriteRepo(channelDatabases{db}) + input := &business.ChannelWrite{ChannelCreate: business.ChannelCreate{SourceDB: "lx", Account: "concurrentChannel", Password: "encoded", BusierID: 7}, + Status: -3, Level: 1, SettlementDays: 7, CreatedUnix: 1000, NextCountUnix: 605800} + results := make(chan error, 8) + var group sync.WaitGroup + for i := 0; i < 8; i++ { + group.Add(1) + go func() { + defer group.Done() + _, err := repo.Create(context.Background(), input) + results <- err + }() + } + group.Wait() + close(results) + created, duplicates := 0, 0 + for err := range results { + if err == nil { + created++ + } else if err == business.ErrChannelExists { + duplicates++ + } else { + t.Fatal(err) + } + } + var channelCount, settlementCount int64 + db.Model(&platformChannelPO{}).Count(&channelCount) + db.Model(&channelSettlementPO{}).Count(&settlementCount) + if created != 1 || duplicates != 7 || channelCount != 1 || settlementCount != 1 { + t.Fatalf("并发创建结果错误: created=%d duplicate=%d channels=%d settlements=%d", created, duplicates, channelCount, settlementCount) + } + var relation string + db.Table("tab_promote_business").Select("promote_ids").Where("id=7").Scan(&relation) + if relation != "5,6,1" { + t.Fatalf("商务渠道关系未完整保留: %s", relation) + } + if err := db.Exec("CREATE TRIGGER fail_settlement BEFORE INSERT ON tab_promote_settlement_time FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test failure'").Error; err != nil { + t.Fatal(err) + } + failed := *input + failed.Account = "rollbackChannel" + if _, err := repo.Create(context.Background(), &failed); err == nil { + t.Fatal("结算写入失败应该回滚") + } + db.Model(&platformChannelPO{}).Count(&channelCount) + if channelCount != 1 { + t.Fatal("失败的渠道记录没有回滚") + } + if err := db.Exec("DROP TRIGGER fail_settlement").Error; err != nil { + t.Fatal(err) + } + if _, err := repo.Create(context.Background(), &failed); err != nil { + t.Fatalf("失败后命名锁未释放或无法重试: %v", err) + } +} + +func TestMySQLServerReportIndexIsAdditiveAndIdempotent(t *testing.T) { + db := openRehearsalMySQL(t) + if err := db.Exec("CREATE TABLE tab_user_play_info(id INT PRIMARY KEY,create_time INT,play_time INT,KEY(play_time))").Error; err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := migrateServerReportIndex(db); err != nil { + t.Fatal(err) + } + } + var count int64 + db.Raw("SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user_play_info' AND column_name='create_time' AND seq_in_index=1").Scan(&count) + if count != 1 || !db.Migrator().HasIndex("tab_user_play_info", "play_time") { + t.Fatalf("重复创建索引或覆盖旧索引: %d", count) + } +} diff --git a/internal/data/business/query_indexes.go b/internal/data/business/query_indexes.go new file mode 100644 index 0000000..0fbd334 --- /dev/null +++ b/internal/data/business/query_indexes.go @@ -0,0 +1,70 @@ +package business + +import ( + "fmt" + "strings" + + "gorm.io/gorm" +) + +type businessQueryIndex struct { + table, name string + columns []string +} + +var businessQueryIndexes = []businessQueryIndex{ + {"tab_promote", "idx_sw_promote_created", []string{"source_db", "create_time", "id"}}, + {"tab_spend", "idx_sw_spend_time_summary", []string{"pay_status", "pay_time", "source_db", "pay_amount"}}, + {"tab_spend", "idx_sw_spend_promote_summary", []string{"promote_id", "pay_status", "source_db", "pay_amount"}}, + {"tab_sw_report_day", "idx_sw_report_pay_scan", []string{"dedup_mode", "stat_date", "source_db", "promote_id", "game_id", "total_pay"}}, + {"tab_sw_report_day_key", "idx_sw_report_key_scan", []string{"dedup_mode", "stat_date", "source_db", "promote_id", "game_id", "metric", "identity_key"}}, + {"tab_sw_report_server_day", "idx_sw_server_pay_game_scan", []string{"game_name", "dedup_mode", "stat_date", "source_db", "game_id", "server_num", "total_pay"}}, + {"tab_sw_report_server_day_key", "idx_sw_server_key_game_scan", []string{"game_name", "dedup_mode", "stat_date", "source_db", "game_id", "server_num", "metric", "identity_key"}}, +} + +// 只为已经接入的业务表增加索引,不创建平台表、不改写记录。 +// 按实际筛选前缀排列,并覆盖聚合读取列,避免大范围回表。 +func migrateBusinessQueryIndexes(db *gorm.DB) error { + if db.Dialector.Name() != "mysql" { + return nil + } + groups := make(map[string][]businessQueryIndex) + tables := []string{} + for _, index := range businessQueryIndexes { + if _, exists := groups[index.table]; !exists { + tables = append(tables, index.table) + } + groups[index.table] = append(groups[index.table], index) + } + for _, table := range tables { + available, err := businessTableAvailable(db, table) + if err != nil { + return err + } + if !available { + continue + } + additions := []string{} + for _, index := range groups[table] { + var existing []string + if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? ORDER BY seq_in_index", index.table, index.name).Scan(&existing).Error; err != nil { + return err + } + if len(existing) > 0 { + if strings.Join(existing, ",") != strings.Join(index.columns, ",") { + return fmt.Errorf("%s.%s 的现有索引列不符", index.table, index.name) + } + continue + } + columns := "`" + strings.Join(index.columns, "`,`") + "`" + additions = append(additions, "ADD INDEX `"+index.name+"` ("+columns+")") + } + // 同一大表的多个索引合并构建,减少重复扫描。 + if len(additions) > 0 { + if err := db.Exec("ALTER TABLE `" + table + "` " + strings.Join(additions, ",") + ", ALGORITHM=INPLACE, LOCK=NONE").Error; err != nil { + return fmt.Errorf("add query indexes on %s: %w", table, err) + } + } + } + return nil +} diff --git a/internal/data/business/query_indexes_test.go b/internal/data/business/query_indexes_test.go new file mode 100644 index 0000000..a6c9e9a --- /dev/null +++ b/internal/data/business/query_indexes_test.go @@ -0,0 +1,50 @@ +package business + +import ( + "strings" + "testing" +) + +func TestMySQLBusinessQueryIndexesAreAdditiveAndIdempotent(t *testing.T) { + db := openRehearsalMySQL(t) + definitions := map[string]string{ + "tab_promote": "id BIGINT NOT NULL,source_db VARCHAR(10) NOT NULL,create_time BIGINT,PRIMARY KEY(id,source_db)", + "tab_spend": "id BIGINT NOT NULL,source_db VARCHAR(10) NOT NULL,promote_id BIGINT,pay_status INT,pay_time BIGINT,pay_amount DECIMAL(20,2),PRIMARY KEY(id,source_db)", + "tab_sw_report_day": "dedup_mode INT,stat_date DATE,source_db VARCHAR(10),promote_id BIGINT,game_id BIGINT,total_pay DECIMAL(20,2)", + "tab_sw_report_day_key": "dedup_mode INT,stat_date DATE,source_db VARCHAR(10),promote_id BIGINT,game_id BIGINT,metric INT,identity_key VARCHAR(64)", + "tab_sw_report_server_day": "game_name VARCHAR(64),dedup_mode INT,stat_date DATE,source_db VARCHAR(10),game_id BIGINT,server_num VARCHAR(32),total_pay DECIMAL(20,2)", + "tab_sw_report_server_day_key": "game_name VARCHAR(64),dedup_mode INT,stat_date DATE,source_db VARCHAR(10),game_id BIGINT,server_num VARCHAR(32),metric INT,identity_key VARCHAR(64)", + } + for table, columns := range definitions { + if err := db.Exec("CREATE TABLE `" + table + "` (" + columns + ") ENGINE=InnoDB").Error; err != nil { + t.Fatal(err) + } + } + if err := db.Exec("INSERT INTO tab_spend VALUES(77,'lx',55,1,123456,12.30)").Error; err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + if err := migrateBusinessQueryIndexes(db); err != nil { + t.Fatal(err) + } + } + for _, index := range businessQueryIndexes { + var columns []string + if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? ORDER BY seq_in_index", index.table, index.name).Scan(&columns).Error; err != nil { + t.Fatal(err) + } + if strings.Join(columns, ",") != strings.Join(index.columns, ",") { + t.Fatalf("index %s has unexpected columns: %v", index.name, columns) + } + } + var amount string + if err := db.Raw("SELECT pay_amount FROM tab_spend WHERE id=77 AND source_db='lx'").Scan(&amount).Error; err != nil || amount != "12.30" { + t.Fatalf("index migration changed business data: %s, %v", amount, err) + } + if err := db.Exec("ALTER TABLE tab_spend DROP INDEX idx_sw_spend_time_summary, ADD INDEX idx_sw_spend_time_summary(pay_time)").Error; err != nil { + t.Fatal(err) + } + if err := migrateBusinessQueryIndexes(db); err == nil || !strings.Contains(err.Error(), "idx_sw_spend_time_summary") { + t.Fatalf("must reject a conflicting existing index: %v", err) + } +} diff --git a/internal/data/business/ranking.go b/internal/data/business/ranking.go index da58566..ce219d8 100644 --- a/internal/data/business/ranking.go +++ b/internal/data/business/ranking.go @@ -2,6 +2,8 @@ package business import ( "context" + + "golang.org/x/sync/errgroup" "gorm.io/gorm" "kra/internal/biz/business" ) @@ -44,7 +46,10 @@ func rankingReportQuery(db *gorm.DB, kind business.RankingKind, scope business.A return analyticsQuery(db, scope, f, table, mode) } all := business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true} - return analyticsQuery(db, all, f, table, mode).Where("EXISTS (?)", rankingBusinessPromotes(db, scope, f.Sources).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id")) + // 同一渠道会出现在多级商务的范围中,先去重并一次性展开可见渠道。 + // 避免对每一条报表身份记录重复扫描整棵商务关系树。 + visible := rankingBusinessPromotes(db, scope, f.Sources).Select("DISTINCT p.source_db,p.id") + return analyticsQuery(db, all, f, table, mode).Joins("JOIN (?) visible_promotes ON visible_promotes.source_db=r.source_db AND visible_promotes.id=r.promote_id", visible) } func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, scope business.AnalyticsScope, f business.RankingRange) (*business.RankingData, error) { db := r.data.DB().WithContext(ctx) @@ -109,60 +114,72 @@ func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, sc return out, nil } } + group, queryContext := errgroup.WithContext(ctx) + group.SetLimit(2) + queryDB := db.WithContext(queryContext) for _, period := range []struct { begin, end string target map[business.AnalyticsKey]*business.RankingMetrics }{{f.Begin, f.End, out.Current}, {f.PreviousBegin, f.PreviousEnd, out.Previous}} { - query := f.AnalyticsFilter - query.Begin, query.End = period.begin, period.end - rowFor := func(po analyticsAggPO) *business.RankingMetrics { - key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID} - if period.target[key] == nil { - period.target[key] = &business.RankingMetrics{} - } - return period.target[key] - } - for _, pay := range []bool{true, false} { - table, mode := "tab_sw_report_day", 0 - if !pay { - table = "tab_sw_report_day_key" - if scope.IDCardDedup { - mode = 1 + group.Go(func() error { + query := f.AnalyticsFilter + query.Begin, query.End = period.begin, period.end + rowFor := func(po analyticsAggPO) *business.RankingMetrics { + key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID} + if period.target[key] == nil { + period.target[key] = &business.RankingMetrics{} } + return period.target[key] } - q := rankingReportQuery(db, kind, scope, query, table, mode) - id := "r.promote_id" - if kind == business.GameRanking { - id = "r.game_id" - } - if kind == business.ChannelRanking { - q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id") - id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)" - } - selects := "r.source_db," + id + " AS entity_id" - group := "r.source_db," + id - if pay { - selects += ",COALESCE(SUM(r.total_pay),0) AS amount" - } else { - selects += ",r.metric,COUNT(DISTINCT r.identity_key) AS count" - group += ",r.metric" - } - var rows []analyticsAggPO - if err := q.Select(selects).Group(group).Scan(&rows).Error; err != nil { - return nil, business.ErrStorage - } - for _, po := range rows { - if po.ID == nil || (pay && *po.ID < 0) { - continue + for _, pay := range []bool{true, false} { + table, mode := "tab_sw_report_day", 0 + if !pay { + table = "tab_sw_report_day_key" + if scope.IDCardDedup { + mode = 1 + } } - row := rowFor(po) + q := rankingReportQuery(queryDB, kind, scope, query, table, mode) + id := "r.promote_id" + if kind == business.GameRanking { + id = "r.game_id" + } + if kind == business.ChannelRanking { + if pay { + q = analyticsPayLeaves(queryDB, q) + } + q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id") + id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)" + } + selects := "r.source_db," + id + " AS entity_id" + group := "r.source_db," + id if pay { - row.Pay = po.Amount + selects += ",COALESCE(SUM(r.total_pay),0) AS amount" } else { - putAnalyticsCount(&row.Counts, po.Metric, po.Count) + selects += ",r.metric,COUNT(DISTINCT r.identity_key) AS count" + group += ",r.metric" + } + var rows []analyticsAggPO + if err := q.Select(selects).Group(group).Scan(&rows).Error; err != nil { + return business.ErrStorage + } + for _, po := range rows { + if po.ID == nil || (pay && *po.ID < 0) { + continue + } + row := rowFor(po) + if pay { + row.Pay = po.Amount + } else { + putAnalyticsCount(&row.Counts, po.Metric, po.Count) + } } } - } + return nil + }) + } + if err := group.Wait(); err != nil { + return nil, err } if kind != business.BusinessRanking && len(out.Current) > 0 { table, column := "tab_promote", "account" diff --git a/internal/data/business/registration_index.go b/internal/data/business/registration_index.go new file mode 100644 index 0000000..eb10733 --- /dev/null +++ b/internal/data/business/registration_index.go @@ -0,0 +1,70 @@ +package business + +import ( + "errors" + "fmt" + "strings" + + "gorm.io/gorm" +) + +// 保留旧 register_time 字符串及其 CAST 语义,用虚拟列索引避免逐行扫描主账号。 +// MySQL 可将查询中相同的 CAST 表达式匹配到该索引,既有查询无需更改统计口径。 +func migrateRegistrationIndex(db *gorm.DB) error { + if db.Dialector.Name() != "mysql" || !db.Migrator().HasTable("tab_user") { + return nil + } + const column = "sw_register_time_unix" + const index = "idx_sw_user_registration" + if !db.Migrator().HasColumn("tab_user", column) { + if err := db.Exec("ALTER TABLE tab_user ADD COLUMN sw_register_time_unix BIGINT UNSIGNED GENERATED ALWAYS AS (CAST(register_time AS UNSIGNED)) VIRTUAL, ALGORITHM=INPLACE, LOCK=NONE").Error; err != nil { + return err + } + } else { + var expression string + if err := db.Raw("SELECT generation_expression FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='tab_user' AND column_name=?", column).Scan(&expression).Error; err != nil { + return err + } + normalized := strings.NewReplacer("`", "", " ", "", "\n", "", "\t", "").Replace(strings.ToLower(expression)) + if normalized != "cast(register_timeasunsigned)" { + return fmt.Errorf("tab_user.%s 的现有定义与注册时间转换不符", column) + } + } + if !db.Migrator().HasIndex("tab_user", index) { + return addRegistrationIndex(db) + } + var columns []string + if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user' AND index_name=? ORDER BY seq_in_index", index).Scan(&columns).Error; err != nil { + return err + } + if strings.Join(columns, ",") != "source_db,puid,sw_register_time_unix,id" { + return fmt.Errorf("tab_user.%s 的现有索引列不符", index) + } + return nil +} + +func addRegistrationIndex(db *gorm.DB) error { + // 旧 SELECT CAST 会以警告方式接受尾部空格等历史值。建索引属于 DDL, + // 严格模式会把同一警告升级为错误;仅在固定连接的索引构建期间保持旧转换行为。 + return db.Connection(func(conn *gorm.DB) (result error) { + var previous string + if err := conn.Raw("SELECT @@session.sql_mode").Scan(&previous).Error; err != nil { + return err + } + modes := []string{} + for _, mode := range strings.Split(previous, ",") { + if mode != "STRICT_TRANS_TABLES" && mode != "STRICT_ALL_TABLES" { + modes = append(modes, mode) + } + } + if err := conn.Exec("SET SESSION sql_mode=?", strings.Join(modes, ",")).Error; err != nil { + return err + } + defer func() { + if err := conn.Exec("SET SESSION sql_mode=?", previous).Error; err != nil { + result = errors.Join(result, err) + } + }() + return conn.Exec("ALTER TABLE tab_user ADD INDEX idx_sw_user_registration(source_db,puid,sw_register_time_unix,id), ALGORITHM=INPLACE, LOCK=NONE").Error + }) +} diff --git a/internal/data/business/report_build.go b/internal/data/business/report_build.go index 6195021..85cbdce 100644 --- a/internal/data/business/report_build.go +++ b/internal/data/business/report_build.go @@ -41,14 +41,12 @@ func (r *reportBuildRepo) Rebuild(ctx context.Context, kind business.ReportBuild 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 - } + available, err := businessTablesAvailable(tx, reportTable, keyTable, rawTable, "tab_user", "tab_spend", "tab_game") + if err != nil { + return err + } + if !available { + return business.ErrReportBuildUnavailable } statDate := day.Format("2006-01-02") lock := reportBuildLockPO{Kind: string(kind), StatDate: statDate} @@ -133,7 +131,11 @@ func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time) 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 { + registerQuery, err := reportRegistrationQuery(db) + if err != nil { + return nil, err + } + if err := registerQuery.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 { @@ -204,3 +206,37 @@ func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time) } return input, nil } + +func reportRegistrationQuery(db *gorm.DB) (*gorm.DB, error) { + q := db.Table("tab_user") + if db.Dialector.Name() != "mysql" || !db.Migrator().HasIndex("tab_user", "idx_sw_user_registration") { + return q, nil + } + // 跨平台任务也必须提供组合索引的首列,避免又回到数百万主账号扫描。 + // 平台值取同一事务中的实际数据,不写死平台名单,也保留历史 NULL。 + var rows []struct { + SourceDB *string `gorm:"column:source_db"` + } + if err := db.Table("tab_user").Distinct("source_db").Scan(&rows).Error; err != nil { + return nil, business.ErrStorage + } + sources := []string{} + hasNull := false + for _, row := range rows { + if row.SourceDB == nil { + hasNull = true + } else { + sources = append(sources, *row.SourceDB) + } + } + if len(sources) == 0 { + if hasNull { + return q.Where("source_db IS NULL"), nil + } + return q.Where("1=0"), nil + } + if hasNull { + return q.Where("(source_db IN ? OR source_db IS NULL)", sources), nil + } + return q.Where("source_db IN ?", sources), nil +} diff --git a/internal/data/business/schema.go b/internal/data/business/schema.go index fbdbc07..385105c 100644 --- a/internal/data/business/schema.go +++ b/internal/data/business/schema.go @@ -19,14 +19,36 @@ func storageError(err error) error { } func businessTableAvailable(db *gorm.DB, name string) (bool, error) { - tables, err := db.Migrator().GetTables() + return businessTablesAvailable(db, name) +} + +// 一次检查请求所需的表,不为每个依赖重复枚举整个业务库。 +// 不缓存结果,保留迁移后新建表和运行时连接异常的可见性。 +func businessTablesAvailable(db *gorm.DB, names ...string) (bool, error) { + if len(names) == 0 { + return true, nil + } + var tables []string + var err error + switch db.Dialector.Name() { + case "mysql": + err = db.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ?", names).Scan(&tables).Error + case "sqlite": + err = db.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name IN ?", names).Scan(&tables).Error + default: + tables, err = db.Migrator().GetTables() + } if err != nil { return false, business.ErrStorage } + found := make(map[string]bool, len(tables)) for _, table := range tables { - if table == name { - return true, nil + found[table] = true + } + for _, name := range names { + if !found[name] { + return false, nil } } - return false, nil + return true, nil } diff --git a/internal/data/business/schema_test.go b/internal/data/business/schema_test.go new file mode 100644 index 0000000..d5e88c1 --- /dev/null +++ b/internal/data/business/schema_test.go @@ -0,0 +1,49 @@ +package business + +import ( + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "kra/internal/biz/business" +) + +func TestBusinessTablesAvailabilityIsFreshAndPropagatesFailures(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "schema.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = pool.Close() }) + check := func(want bool, names ...string) { + t.Helper() + got, err := businessTablesAvailable(db, names...) + if err != nil || got != want { + t.Fatalf("tables %v: got %v, %v; want %v", names, got, err, want) + } + } + check(true) + check(false, "first", "second") + for _, table := range []string{"first", "second"} { + if err := db.Exec("CREATE TABLE " + table + " (id INTEGER)").Error; err != nil { + t.Fatal(err) + } + } + check(true, "first", "second", "first") + check(false, "first", "missing") + check(false, "first') OR 1=1 --") + if err := db.Exec("DROP TABLE second").Error; err != nil { + t.Fatal(err) + } + check(false, "first", "second") + if err := pool.Close(); err != nil { + t.Fatal(err) + } + if available, err := businessTablesAvailable(db, "first"); available || err != business.ErrStorage { + t.Fatalf("connection error must not become a missing-table result: %v, %v", available, err) + } +} diff --git a/internal/data/business/server_report_index.go b/internal/data/business/server_report_index.go new file mode 100644 index 0000000..5e1d062 --- /dev/null +++ b/internal/data/business/server_report_index.go @@ -0,0 +1,18 @@ +package business + +import "gorm.io/gorm" + +// 区服汇总按创角时间读取原始记录;旧表只有 play_time 索引,导致每日创角扫描全表。 +func migrateServerReportIndex(db *gorm.DB) error { + if db.Dialector.Name() != "mysql" || !db.Migrator().HasTable("tab_user_play_info") { + return nil + } + var existing int64 + if err := db.Raw("SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user_play_info' AND column_name='create_time' AND seq_in_index=1").Scan(&existing).Error; err != nil { + return err + } + if existing > 0 { + return nil + } + return db.Exec("ALTER TABLE tab_user_play_info ADD INDEX idx_sw_play_create_time(create_time), ALGORITHM=INPLACE, LOCK=NONE").Error +} diff --git a/internal/data/business/workbench.go b/internal/data/business/workbench.go index a792e7e..dd8f224 100644 --- a/internal/data/business/workbench.go +++ b/internal/data/business/workbench.go @@ -4,6 +4,8 @@ import ( "context" "time" + "golang.org/x/sync/errgroup" + "gorm.io/gorm" "kra/internal/biz/business" ) @@ -23,13 +25,8 @@ func workbenchAvailable(db *gorm.DB, scope business.AnalyticsScope, sources []st if kind == "" || kind == business.WorkbenchRegister { tables = append(tables, "tab_sw_report_day_key") } - for _, table := range tables { - available, err := businessTableAvailable(db, table) - if err != nil || !available { - return false, available, err - } - } - return false, true, nil + available, err = businessTablesAvailable(db, tables...) + return false, available, err } func (r *workbenchRepo) Summary(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.WorkbenchWindow) (*business.WorkbenchTotals, error) { db := r.data.DB().WithContext(ctx) @@ -46,47 +43,83 @@ func (r *workbenchRepo) Summary(ctx context.Context, scope business.AnalyticsSco if scope.IDCardDedup { mode = 1 } - for _, period := range []struct { - day time.Time - target *business.WorkbenchDay - }{{w.Today, &out.Today}, {w.Yesterday, &out.Yesterday}} { - f := business.AnalyticsFilter{Sources: sources, Begin: period.day.Format("2006-01-02"), End: period.day.Format("2006-01-02")} - var counts []portraitCountPO - if err := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode).Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Where("r.metric IN (1,2)").Group("r.metric").Scan(&counts).Error; err != nil { - return nil, business.ErrStorage - } - period.target.Counts = toPortraitCounts(counts) - if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&period.target.Pay).Error; err != nil { - return nil, business.ErrStorage - } - // 活跃渠道按自身注册或正充值统计,不能归并一级渠道后再计数。 - active := map[business.AnalyticsKey]bool{} - var registrations, pays []analyticsAggPO - if err := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode).Where("r.metric=1").Select("r.source_db,r.promote_id AS entity_id").Group("r.source_db,r.promote_id").Having("COUNT(DISTINCT r.identity_key)>0").Scan(®istrations).Error; err != nil { - return nil, business.ErrStorage - } - if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("r.source_db,r.promote_id AS entity_id").Group("r.source_db,r.promote_id").Having("SUM(r.total_pay)>0").Scan(&pays).Error; err != nil { - return nil, business.ErrStorage - } - for _, po := range append(registrations, pays...) { - if po.ID != nil { - active[business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}] = true - } - } - period.target.ActiveChannels = int64(len(active)) - if err := analyticsBasePromotes(db, scope, f, "p").Where("p.create_time BETWEEN ? AND ?", period.day.Unix(), period.day.AddDate(0, 0, 1).Unix()-1).Count(&period.target.NewChannels).Error; err != nil { - return nil, business.ErrStorage + date := func(value time.Time) string { return value.Format("2006-01-02") } + today, yesterday := date(w.Today), date(w.Yesterday) + monthStart, previousStart, previousEnd := date(w.MonthStart), date(w.PreviousMonthStart), date(w.PreviousMonthEnd) + dayFilter := business.AnalyticsFilter{Sources: sources, Begin: yesterday, End: today} + type datedCount struct { + StatDate string + Metric int + Count int64 + } + type datedChannel struct { + StatDate string + SourceDB string `gorm:"column:source_db"` + ID *int64 `gorm:"column:entity_id"` + } + var counts []datedCount + var registrations, pays []datedChannel + var amounts struct{ TodayPay, YesterdayPay, MonthPay, PreviousMonthPay string } + var created struct{ TodayCount, YesterdayCount int64 } + group, queryContext := errgroup.WithContext(ctx) + group.SetLimit(2) + queryDB := db.WithContext(queryContext) + group.Go(func() error { + return storageError(analyticsQuery(queryDB, scope, dayFilter, "tab_sw_report_day_key", mode). + Select("CAST(r.stat_date AS CHAR) AS stat_date,r.metric,COUNT(DISTINCT " + portraitIdentity(queryDB, scope.IDCardDedup) + ") AS count"). + Where("r.metric IN (1,2)").Group("r.stat_date,r.metric").Scan(&counts).Error) + }) + group.Go(func() error { + f := business.AnalyticsFilter{Sources: sources, Begin: previousStart, End: today} + // 同时取得两日和两个自然月区间的金额;月初的昨日须额外纳入扫描范围。 + return storageError(analyticsQuery(queryDB, scope, f, "tab_sw_report_day", 0). + Where("((r.stat_date BETWEEN ? AND ?) OR (r.stat_date BETWEEN ? AND ?) OR r.stat_date=?)", monthStart, today, previousStart, previousEnd, yesterday). + Select("COALESCE(SUM(CASE WHEN r.stat_date=? THEN r.total_pay ELSE 0 END),0) AS today_pay,"+ + "COALESCE(SUM(CASE WHEN r.stat_date=? THEN r.total_pay ELSE 0 END),0) AS yesterday_pay,"+ + "COALESCE(SUM(CASE WHEN r.stat_date BETWEEN ? AND ? THEN r.total_pay ELSE 0 END),0) AS month_pay,"+ + "COALESCE(SUM(CASE WHEN r.stat_date BETWEEN ? AND ? THEN r.total_pay ELSE 0 END),0) AS previous_month_pay", + today, yesterday, monthStart, today, previousStart, previousEnd).Scan(&amounts).Error) + }) + group.Go(func() error { + // COUNT(DISTINCT identity_key)>0 等价于存在非 NULL 的注册键;保留空字符串的旧语义。 + return storageError(analyticsQuery(queryDB, scope, dayFilter, "tab_sw_report_day_key", mode). + Where("r.metric=1 AND r.identity_key IS NOT NULL").Distinct(). + Select("CAST(r.stat_date AS CHAR) AS stat_date,r.source_db,r.promote_id AS entity_id").Scan(®istrations).Error) + }) + group.Go(func() error { + return storageError(analyticsQuery(queryDB, scope, dayFilter, "tab_sw_report_day", 0). + Select("CAST(r.stat_date AS CHAR) AS stat_date,r.source_db,r.promote_id AS entity_id"). + Group("r.stat_date,r.source_db,r.promote_id").Having("SUM(r.total_pay)>0").Scan(&pays).Error) + }) + group.Go(func() error { + todayEnd := w.Today.AddDate(0, 0, 1).Unix() - 1 + yesterdayEnd := w.Yesterday.AddDate(0, 0, 1).Unix() - 1 + return storageError(analyticsBasePromotes(queryDB, scope, dayFilter, "p"). + Where("p.create_time BETWEEN ? AND ?", w.Yesterday.Unix(), todayEnd). + Select("COALESCE(SUM(CASE WHEN p.create_time BETWEEN ? AND ? THEN 1 ELSE 0 END),0) AS today_count,"+ + "COALESCE(SUM(CASE WHEN p.create_time BETWEEN ? AND ? THEN 1 ELSE 0 END),0) AS yesterday_count", + w.Today.Unix(), todayEnd, w.Yesterday.Unix(), yesterdayEnd).Scan(&created).Error) + }) + if err := group.Wait(); err != nil { + return nil, err + } + days := map[string]*business.WorkbenchDay{today: &out.Today, yesterday: &out.Yesterday} + for _, row := range counts { + if day := days[row.StatDate]; day != nil { + putAnalyticsCount(&day.Counts, row.Metric, row.Count) } } - for _, period := range []struct { - begin, end time.Time - target *string - }{{w.MonthStart, w.Today, &out.MonthPay}, {w.PreviousMonthStart, w.PreviousMonthEnd, &out.PreviousMonthPay}} { - f := business.AnalyticsFilter{Sources: sources, Begin: period.begin.Format("2006-01-02"), End: period.end.Format("2006-01-02")} - if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(period.target).Error; err != nil { - return nil, business.ErrStorage + active := map[string]map[business.AnalyticsKey]bool{today: {}, yesterday: {}} + for _, row := range append(registrations, pays...) { + if keys := active[row.StatDate]; keys != nil && row.ID != nil { + keys[business.AnalyticsKey{SourceDB: row.SourceDB, ID: *row.ID}] = true } } + out.Today.ActiveChannels, out.Yesterday.ActiveChannels = int64(len(active[today])), int64(len(active[yesterday])) + out.Today.Pay, out.Yesterday.Pay = amounts.TodayPay, amounts.YesterdayPay + out.Today.NewChannels, out.Yesterday.NewChannels = created.TodayCount, created.YesterdayCount + out.MonthPay, out.PreviousMonthPay = amounts.MonthPay, amounts.PreviousMonthPay + return out, nil } @@ -118,12 +151,12 @@ func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope } if kind == business.WorkbenchPayLow || kind == business.WorkbenchUnlogin { var channels []workbenchPromotePO - if err := analyticsBasePromotes(db, scope, f, "p").Select("p.id,p.source_db,p.account,p.create_time,p.last_login_time").Where("p.promote_level=1").Scan(&channels).Error; err != nil { + if err := analyticsBasePromotes(db, scope, f, "p").Select("p.id,p.source_db,p.create_time,p.last_login_time").Where("p.promote_level=1").Scan(&channels).Error; err != nil { return nil, business.ErrStorage } for _, po := range channels { row := rowFor(po.SourceDB, po.ID) - row.Name, row.CreatedUnix, row.LastLoginUnix = po.Account, po.CreateTime, po.LastLoginTime + row.CreatedUnix, row.LastLoginUnix = po.CreateTime, po.LastLoginTime } } if kind != business.WorkbenchUnlogin { @@ -144,6 +177,7 @@ func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE root_p.id END" } } else { + q = analyticsPayLeaves(db, q) q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id") id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE COALESCE(root_p.id,r.promote_id) END" } @@ -160,22 +194,32 @@ func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope rowFor(po.SourceDB, *po.ID).Value = po.Amount } } - keys := make([]business.AnalyticsKey, 0, len(rows)) - for key := range rows { - keys = append(keys, key) - } - for start := 0; start < len(keys); start += 300 { - var names []workbenchPromotePO - if err := db.Table("tab_promote").Select("id,source_db,account").Where("(source_db,id) IN ?", analyticsPairs(keys[start:min(start+300, len(keys))])).Scan(&names).Error; err != nil { - return nil, business.ErrStorage - } - for _, po := range names { - rowFor(po.SourceDB, po.ID).Name = po.Account - } - } + } for _, row := range rows { out.Rows = append(out.Rows, row) } return out, nil } + +func (r *workbenchRepo) FillRankNames(ctx context.Context, rows []*business.WorkbenchRankRow) error { + if len(rows) == 0 { + return nil + } + keys := make([]business.AnalyticsKey, 0, len(rows)) + for _, row := range rows { + keys = append(keys, row.AnalyticsKey) + } + var names []workbenchPromotePO + if err := r.data.DB().WithContext(ctx).Table("tab_promote").Select("id,source_db,account").Where("(source_db,id) IN ?", analyticsPairs(keys)).Scan(&names).Error; err != nil { + return business.ErrStorage + } + byKey := make(map[business.AnalyticsKey]string, len(names)) + for _, po := range names { + byKey[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}] = po.Account + } + for _, row := range rows { + row.Name = byKey[row.AnalyticsKey] + } + return nil +} diff --git a/internal/data/business/workbench_test.go b/internal/data/business/workbench_test.go index e43b396..18c3681 100644 --- a/internal/data/business/workbench_test.go +++ b/internal/data/business/workbench_test.go @@ -85,3 +85,32 @@ func TestWorkbenchStorageSummaryAndRankRules(t *testing.T) { t.Fatal("六卡缺表应明确未接入") } } + +func TestWorkbenchSummaryMonthStartAndNullableRegistrationKeys(t *testing.T) { + db, _ := prepareChannels(t) + for _, statement := range []string{ + "DELETE FROM tab_sw_report_day", + "DELETE FROM tab_sw_report_day_key", + "INSERT INTO tab_sw_report_day VALUES('lx',100,1,0,'2026-10-01',10),('lx',100,1,0,'2026-10-02',999),('lx',100,1,0,'2026-10-31',30),('lx',100,1,0,'2026-11-01',20),('lx',103,1,0,'2026-11-01',-1)", + "INSERT INTO tab_sw_report_day_key VALUES('lx',103,1,1,'2026-11-01',1,NULL),('lx',999,1,1,'2026-11-01',1,''),('lx',100,1,1,'2026-10-31',1,'A')", + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + day := time.Date(2026, 11, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + if err := db.Exec("UPDATE tab_promote SET create_time=CASE WHEN id=100 THEN ? ELSE ? END WHERE source_db='lx' AND id IN(100,101)", day.Unix(), day.AddDate(0, 0, -1).Unix()).Error; err != nil { + t.Fatal(err) + } + window := business.WorkbenchWindow{Today: day, Yesterday: day.AddDate(0, 0, -1), MonthStart: day, PreviousMonthStart: day.AddDate(0, -1, 0), PreviousMonthEnd: day.AddDate(0, -1, 0)} + out, err := NewWorkbenchRepo(testData{db}).Summary(context.Background(), business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true, IDCardDedup: true}, []string{"lx"}, window) + if err != nil { + t.Fatal(err) + } + if portraitTestMoney(out.Today.Pay) != "19.00" || portraitTestMoney(out.Yesterday.Pay) != "30.00" || portraitTestMoney(out.MonthPay) != "19.00" || portraitTestMoney(out.PreviousMonthPay) != "10.00" { + t.Fatalf("month-first comparison mixed yesterday with the previous-month window: %+v", out) + } + if out.Today.ActiveChannels != 2 || out.Today.Counts.Register != 1 || out.Yesterday.ActiveChannels != 1 || out.Today.NewChannels != 1 || out.Yesterday.NewChannels != 1 { + t.Fatalf("daily groups or NULL/empty registration-key behavior changed: %+v", out) + } +} diff --git a/internal/data/data.go b/internal/data/data.go index e4d33c1..e5a7587 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -47,6 +47,7 @@ var ProviderSet = wire.NewSet( databusiness.NewWorkbenchRepo, databusiness.NewRankingRepo, databusiness.NewReportBuildRepo, + databusiness.NewAlertTriggerRepo, NewIntegrationRuntime, wire.Bind(new(datasystem.Provider), new(*Data)), wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), diff --git a/internal/data/data_scope_audit.go b/internal/data/data_scope_audit.go index b838221..13de87e 100644 --- a/internal/data/data_scope_audit.go +++ b/internal/data/data_scope_audit.go @@ -10,9 +10,11 @@ import ( ) const ( - dataScopeAuditQueueSize = 1024 - dataScopeAuditBatchSize = 100 - dataScopeAuditInterval = 2 * time.Second + dataScopeAuditQueueSize = 1024 + dataScopeAuditBatchSize = 100 + dataScopeAuditInterval = 2 * time.Second + dataScopeAuditWriteTimeout = 2 * time.Second + dataScopeAuditCloseTimeout = 5 * time.Second ) // dataScopeAuditWriter keeps row-scope auditing best-effort: callbacks only @@ -26,6 +28,8 @@ type dataScopeAuditWriter struct { stop chan struct{} done chan struct{} closeOnce sync.Once + enqueueMu sync.RWMutex + closed bool } func newDataScopeAuditWriter(data *Data, logger *slog.Logger) *dataScopeAuditWriter { @@ -57,6 +61,11 @@ func (w *dataScopeAuditWriter) Enqueue(record dataAccessLogPO) { if w == nil { return } + w.enqueueMu.RLock() + defer w.enqueueMu.RUnlock() + if w.closed { + return + } select { case w.queue <- record: default: @@ -68,8 +77,19 @@ func (w *dataScopeAuditWriter) Close() { if w == nil { return } - w.closeOnce.Do(func() { close(w.stop) }) - <-w.done + w.closeOnce.Do(func() { + w.enqueueMu.Lock() + w.closed = true + close(w.stop) + w.enqueueMu.Unlock() + }) + timer := time.NewTimer(dataScopeAuditCloseTimeout) + defer timer.Stop() + select { + case <-w.done: + case <-timer.C: + w.log().Warn("等待数据权限审计写入超时", "mod", "data-scope") + } } func (w *dataScopeAuditWriter) run() { @@ -78,6 +98,7 @@ func (w *dataScopeAuditWriter) run() { defer ticker.Stop() batch := make([]dataAccessLogPO, 0, w.batchSize) flush := func() { + defer func() { batch = batch[:0] }() if len(batch) == 0 || w.data == nil || w.data.gormDB == nil { return } @@ -85,10 +106,11 @@ func (w *dataScopeAuditWriter) run() { if db == nil { return } - if err := db.WithContext(context.Background()).Session(&gorm.Session{NewDB: true, SkipHooks: true}).Create(&batch).Error; err != nil { + ctx, cancel := context.WithTimeout(context.Background(), dataScopeAuditWriteTimeout) + defer cancel() + if err := db.WithContext(ctx).Session(&gorm.Session{NewDB: true, SkipHooks: true}).Create(&batch).Error; err != nil { w.log().Warn("数据权限审计批量写入失败", "mod", "data-scope", "error", err) } - batch = batch[:0] } for { select { diff --git a/internal/data/data_scope_audit_test.go b/internal/data/data_scope_audit_test.go index 61d1d31..8a30026 100644 --- a/internal/data/data_scope_audit_test.go +++ b/internal/data/data_scope_audit_test.go @@ -76,3 +76,32 @@ func TestDataScopeAuditWriterUsesReloadedDatabase(t *testing.T) { t.Fatalf("audit log was written to retired database: %d", firstCount) } } + +func TestDataScopeAuditCloseCancelsBlockedWrite(t *testing.T) { + db := openDataScopeAuditTestDB(t, "data-scope-audit-blocked") + entered := make(chan struct{}) + if err := db.Callback().Create().Before("gorm:create").Register("test:block", func(tx *gorm.DB) { + close(entered) + <-tx.Statement.Context.Done() + tx.AddError(tx.Statement.Context.Err()) + }); err != nil { + t.Fatal(err) + } + w := newDataScopeAuditWriterWithOptions(&Data{gormDB: newReloadableDB(db)}, slog.New(slog.NewTextHandler(io.Discard, nil)), 8, 1, time.Hour) + w.Enqueue(dataAccessLogPO{EventType: "blocked_write"}) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("writer did not start") + } + w.Close() + select { + case <-w.done: + default: + t.Fatal("database write did not honor its deadline") + } + w.Enqueue(dataAccessLogPO{EventType: "after_close"}) + if len(w.queue) != 0 { + t.Fatal("closed writer accepted a new event") + } +} diff --git a/internal/data/integration/integration_config_runtime_test.go b/internal/data/integration/integration_config_runtime_test.go index 49727e1..336c265 100644 --- a/internal/data/integration/integration_config_runtime_test.go +++ b/internal/data/integration/integration_config_runtime_test.go @@ -52,6 +52,26 @@ func TestIntegrationConfigSavePublishesUnmaskedRuntimeValues(t *testing.T) { } } +func TestCertificateAliasesMaskAndRoundTrip(t *testing.T) { + old := map[string]any{"app_cert_content": "cert", "key_content": "key", "pkcs12_file": "bundle", "template_content": "keep"} + values := map[string]any{} + for key, value := range old { + values[key] = value + } + maskIntegrationSecrets("payment", "alipay", values) + for _, key := range []string{"app_cert_content", "key_content", "pkcs12_file"} { + if values[key] == old[key] { + t.Fatalf("unmasked alias: %s", key) + } + } + mergeIntegrationSecrets("payment", "alipay", values, old) + for key, value := range old { + if values[key] != value { + t.Fatalf("masked edit changed %s", key) + } + } +} + func TestCommunicationDefaultsIncludeKafka(t *testing.T) { db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") if err != nil { diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 6d5a833..2048b40 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 != 23 { - t.Fatalf("migration versions = %d, want 23", versions) + if versions != 27 { + t.Fatalf("migration versions = %d, want 27", 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/data/system/media.go b/internal/data/system/media.go index 9a34559..4a8b7e1 100644 --- a/internal/data/system/media.go +++ b/internal/data/system/media.go @@ -125,6 +125,9 @@ func (r *mediaRepo) UpdateMediaName(ctx context.Context, id uint, name string) e // not-found error instead of silently succeeding with RowsAffected=0. var po mediaPO if err := r.data.DB().WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return system.ErrMediaNotFound + } return err } return r.data.DB().WithContext(ctx).Model(&po).Update("name", name).Error @@ -147,7 +150,7 @@ func (r *mediaRepo) CreateMediaBatch(ctx context.Context, items []*system.MediaF func (r *mediaRepo) SaveCategory(ctx context.Context, v *system.AttachmentCategory) error { db := r.data.DB().WithContext(ctx) var count int64 - query := db.Model(&categoryPO{}).Where("name = ? AND pid = ?", v.Name, v.ParentID) + query := db.Model(&categoryPO{}).Where("name = ? AND pid = ? AND id <> ?", v.Name, v.ParentID, v.ID) if err := query.Count(&count).Error; err != nil { return err } diff --git a/internal/data/system/media_edit_test.go b/internal/data/system/media_edit_test.go new file mode 100644 index 0000000..0a1eaf9 --- /dev/null +++ b/internal/data/system/media_edit_test.go @@ -0,0 +1,40 @@ +package system + +import ( + "context" + "kra/internal/biz/system" + "path/filepath" + "testing" +) + +func TestMediaEditsKeepUnchangedCategoryNameAndMapMissingFile(t *testing.T) { + db, err := openWithDriver("sqlite", filepath.Join(t.TempDir(), "media.db")) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + t.Cleanup(func() { _ = pool.Close() }) + if err := db.AutoMigrate(&mediaPO{}, &categoryPO{}); err != nil { + t.Fatal(err) + } + r := NewMediaRepo(&Data{gormDB: newReloadableDB(db, nil)}) + ctx := context.Background() + category := &system.AttachmentCategory{Name: "screenshots"} + if err := r.SaveCategory(ctx, category); err != nil { + t.Fatal(err) + } + var row categoryPO + if err := db.First(&row).Error; err != nil { + t.Fatal(err) + } + category.ID = row.ID + if err := r.SaveCategory(ctx, category); err != nil { + t.Fatalf("unchanged category rejected: %v", err) + } + if err := r.SaveCategory(ctx, &system.AttachmentCategory{Name: "screenshots"}); err == nil { + t.Fatal("duplicate category accepted") + } + if err := r.UpdateMediaName(ctx, 999, "missing"); err != system.ErrMediaNotFound { + t.Fatalf("missing file: %v", err) + } +} diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index ba555a4..b319bde 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) != 23 { - t.Fatalf("module migrations = %d, want 23", len(got)) + if got := catalog.MigrationSteps(); len(got) != 27 { + t.Fatalf("module migrations = %d, want 27", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { diff --git a/internal/server/gin.go b/internal/server/gin.go index 6e902a3..71a54e1 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -113,11 +113,7 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access logger.Info("websocket endpoint initialized", "mod", "websocket", "enabled", ws != nil && ws.Enabled(), "path", websocketRoutePath(prefix, ws)) } - env := "" - if snapshot != nil && snapshot.Admin != nil && snapshot.Admin.App != nil { - env = strings.ToLower(strings.TrimSpace(snapshot.Admin.App.Env)) - } - if env == "" || env == "development" || env == "dev" || env == "test" || env == "local" { + if snapshot != nil && config.SwaggerEnabled(snapshot.Admin) { registerSwagger(engine, prefix, version, logger) } staticfiles.Register(engine, runtime) diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index de7f7ff..47c4b1b 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -51,7 +51,7 @@ func emptyHandlers() *handler.Set { } func TestGinRouteContract(t *testing.T) { - engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") routes := engine.Routes() actual := make([]string, 0, len(routes)) seen := make(map[string]struct{}, len(routes)) @@ -85,7 +85,7 @@ func TestGinRouteContract(t *testing.T) { } func TestGinRoutesMatchSharedCatalog(t *testing.T) { - engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") registered := make(map[string]struct{}, len(engine.Routes())) for _, route := range engine.Routes() { key := route.Method + " " + route.Path @@ -102,10 +102,36 @@ func TestGinRoutesMatchSharedCatalog(t *testing.T) { } } +func TestEveryPrivateRouteRequiresAuthentication(t *testing.T) { + for _, prefix := range []string{"", "/api"} { + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: prefix}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") + for _, descriptor := range routecatalog.Descriptors() { + if descriptor.Public { + continue + } + path := descriptor.Path + parts := strings.Split(path, "/") + for i, part := range parts { + if strings.HasPrefix(part, ":") { + parts[i] = "1" + } + } + path = prefix + strings.Join(parts, "/") + t.Run(descriptor.Method+" "+path, func(t *testing.T) { + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(descriptor.Method, path, nil)) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("anonymous private request returned %d: %s", recorder.Code, recorder.Body.String()) + } + }) + } + } +} + func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) { var output bytes.Buffer logger := slog.New(slog.NewJSONHandler(&output, nil)) - engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, logger, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, logger, "test") text := output.String() if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want { t.Fatalf("registered route log count = %d, want %d", got, want) @@ -116,7 +142,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) { } func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) { - engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil) engine.ServeHTTP(response, request) @@ -132,7 +158,7 @@ func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) { } func TestSwaggerSupportsRouterPrefix(t *testing.T) { - engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin", App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") for _, path := range []string{"/admin/swagger/index.html", "/admin/swagger/doc.json"} { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) @@ -158,7 +184,7 @@ func TestHealthEndpointKeepsRootProbePathWithRouterPrefix(t *testing.T) { } func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) { - engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0") response := httptest.NewRecorder() engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"basePath":"/"`) { @@ -167,7 +193,7 @@ func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) { } func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) { - engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") engine.GET("/client-ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) }) request := httptest.NewRequest(http.MethodGet, "/client-ip", nil) request.RemoteAddr = "203.0.113.10:4321" @@ -181,7 +207,7 @@ func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) { } func TestAnnouncementDataSourceRequiresAuthentication(t *testing.T) { - engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") + engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "test") response := httptest.NewRecorder() engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/info/getInfoDataSource", nil)) if response.Code != http.StatusUnauthorized { diff --git a/internal/server/middleware/payment_audit_test.go b/internal/server/middleware/payment_audit_test.go index f4199f3..23cd657 100644 --- a/internal/server/middleware/payment_audit_test.go +++ b/internal/server/middleware/payment_audit_test.go @@ -59,3 +59,16 @@ func TestPaymentIntegrationConfigUsesRouteLevelSummary(t *testing.T) { t.Fatal("payment test route was not recognized as sensitive") } } + +func TestCertificateAliasesAreRedactedInNestedPayloads(t *testing.T) { + raw := []byte(`{"config":{"app_cert_content":"secret-cert","keys":[{"key_content":"secret-key"}],"cert_path":"private-file","template_content":"keep"}}`) + got := redactJSON(raw, "application/json", 4096) + for _, secret := range []string{"secret-cert", "secret-key", "private-file"} { + if strings.Contains(got, secret) { + t.Fatalf("certificate alias leaked: %s", got) + } + } + if !strings.Contains(got, `"template_content":"keep"`) { + t.Fatal("ordinary content was masked") + } +} diff --git a/internal/server/middleware/redact.go b/internal/server/middleware/redact.go index dbe4b57..d1c65b9 100644 --- a/internal/server/middleware/redact.go +++ b/internal/server/middleware/redact.go @@ -1,6 +1,9 @@ package middleware -import "strings" +import ( + "kra/internal/biz/integration" + "strings" +) // redactedValue replaces a sensitive value in every log line and audit record. const redactedValue = "***" @@ -36,6 +39,9 @@ func isSensitiveHeader(key string) bool { // isSensitivePayloadKey also matches every key ending in "token" so provider // specific token names stay masked without being enumerated. func isSensitivePayloadKey(key string) bool { + if integration.IsIntegrationSecretKey(key) { + return true + } normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", "")) if _, sensitive := sensitivePayloadKeys[normalized]; sensitive { return true diff --git a/internal/service/business/account.go b/internal/service/business/account.go index 666eb44..d490ac6 100644 --- a/internal/service/business/account.go +++ b/internal/service/business/account.go @@ -59,6 +59,9 @@ func (s *AccountService) List(ctx context.Context, req *dto.BusinessAccountQuery } *field.target = &n } + if (req.BeginTime == "") != (req.EndTime == "") { + return nil, business.ErrAccountInput + } if req.BeginTime != "" && req.EndTime != "" { begin, err := time.ParseInLocation("2006-01-02 15:04:05", req.BeginTime, time.Local) if err != nil { diff --git a/internal/service/business/analytics.go b/internal/service/business/analytics.go index 9c70ebd..766d208 100644 --- a/internal/service/business/analytics.go +++ b/internal/service/business/analytics.go @@ -26,6 +26,9 @@ func analyticsRequest(req *dto.AnalyticsQuery) (business.AnalyticsFilter, error) } f := business.AnalyticsFilter{Sources: sources, GameNames: splitQueryValues(req.GameNames), PromoteName: strings.TrimSpace(req.PromoteName), PromoteType: req.PromoteType, SortField: strings.TrimSpace(req.SortField), Ascending: strings.EqualFold(strings.TrimSpace(req.SortOrder), "asc")} begin, end := strings.TrimSpace(req.BeginTime), strings.TrimSpace(req.EndTime) + if (begin == "") != (end == "") { + return f, business.ErrAnalyticsDate + } if begin != "" && end != "" { location := time.FixedZone("Asia/Shanghai", 8*3600) b, err := time.ParseInLocation("2006-01-02", begin, location) @@ -33,7 +36,7 @@ func analyticsRequest(req *dto.AnalyticsQuery) (business.AnalyticsFilter, error) return f, business.ErrAnalyticsDate } e, err := time.ParseInLocation("2006-01-02", end, location) - if err != nil { + if err != nil || e.Before(b) { return f, business.ErrAnalyticsDate } f.StartDate = &b diff --git a/internal/service/business/date_range_test.go b/internal/service/business/date_range_test.go new file mode 100644 index 0000000..e550709 --- /dev/null +++ b/internal/service/business/date_range_test.go @@ -0,0 +1,33 @@ +package business + +import ( + "context" + "kra/internal/service/dto" + "testing" +) + +func TestRejectIncompleteAndReversedDateRanges(t *testing.T) { + for _, dates := range [][2]string{{"2026-11-01", ""}, {"", "2026-11-01"}, {"2026-11-03", "2026-11-01"}, {"bad", "2026-11-01"}} { + if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); err == nil { + t.Errorf("analytics accepted %v", dates) + } + for _, spend := range []bool{false, true} { + _, _, err := ledgerRequest(&dto.LedgerQuery{BeginRegisterTime: dates[0], EndRegisterTime: dates[1], BeginPayTime: dates[0], EndPayTime: dates[1]}, spend) + if err == nil { + t.Errorf("ledger accepted %v (spend=%v)", dates, spend) + } + } + } + for _, dates := range [][2]string{{"", ""}, {"2026-11-01", "2026-11-01"}} { + if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); err != nil { + t.Fatal(err) + } + if _, _, err := ledgerRequest(&dto.LedgerQuery{BeginPayTime: dates[0], EndPayTime: dates[1]}, true); err != nil { + t.Fatal(err) + } + } + s := &AccountService{} + if _, err := s.List(context.Background(), &dto.BusinessAccountQuery{BeginTime: "2026-11-01 00:00:00"}); err == nil { + t.Fatal("account query silently ignored incomplete date range") + } +} diff --git a/internal/service/business/ledger.go b/internal/service/business/ledger.go index 929abc9..5a74fde 100644 --- a/internal/service/business/ledger.go +++ b/internal/service/business/ledger.go @@ -31,6 +31,9 @@ func ledgerRequest(req *dto.LedgerQuery, spend bool) (business.LedgerFilter, bus if spend { begin, end = req.BeginPayTime, req.EndPayTime } + if (strings.TrimSpace(begin) == "") != (strings.TrimSpace(end) == "") { + return f, business.LedgerListOptions{}, business.ErrLedgerInput + } if strings.TrimSpace(begin) != "" && strings.TrimSpace(end) != "" { location := time.FixedZone("Asia/Shanghai", 8*3600) b, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(begin), location) @@ -38,7 +41,7 @@ func ledgerRequest(req *dto.LedgerQuery, spend bool) (business.LedgerFilter, bus return f, business.LedgerListOptions{}, business.ErrLedgerInput } e, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(end), location) - if err != nil { + if err != nil || e.Before(b) { return f, business.LedgerListOptions{}, business.ErrLedgerInput } f.StartDate = &b diff --git a/internal/service/business/ledger_excel.go b/internal/service/business/ledger_excel.go index f232ce7..68fe101 100644 --- a/internal/service/business/ledger_excel.go +++ b/internal/service/business/ledger_excel.go @@ -55,6 +55,10 @@ func ledgerWorkbook(sheet string, headers []string, rows [][]ledgerCell) ([]byte } } last, _ := excelize.ColumnNumberToName(len(headers)) + // 流式读取器依赖 dimension;Excelize 的初始 A1 范围不会随写入单元格自动扩展。 + if err := file.SetSheetDimension(sheet, "A1:"+last+strconv.Itoa(len(rows)+1)); err != nil { + return nil, err + } if err := file.SetColWidth(sheet, "A", last, 20); err != nil { return nil, err } diff --git a/internal/service/business/ledger_excel_test.go b/internal/service/business/ledger_excel_test.go index ebee431..0300f3d 100644 --- a/internal/service/business/ledger_excel_test.go +++ b/internal/service/business/ledger_excel_test.go @@ -32,6 +32,9 @@ func TestLedgerWorkbookPreservesNumbersAndText(t *testing.T) { t.Fatal(err) } defer f.Close() + if dimension, err := f.GetSheetDimension("注册明细"); err != nil || dimension != "A1:M2" { + t.Fatalf("注册导出范围不完整: %s %v", dimension, err) + } amount, _ := f.GetCellValue("注册明细", "H2", excelize.Options{RawCellValue: true}) formula, _ := f.GetCellFormula("注册明细", "K2") text, _ := f.GetCellValue("注册明细", "K2") @@ -51,6 +54,9 @@ func TestLedgerWorkbookPreservesNumbersAndText(t *testing.T) { t.Fatal(err) } defer pay.Close() + if dimension, err := pay.GetSheetDimension("充值明细"); err != nil || dimension != "A1:R2" { + t.Fatalf("充值导出范围不完整: %s %v", dimension, err) + } order, _ := pay.GetCellValue("充值明细", "E2") if order != "000000000000000001" { t.Fatal("订单号前导零丢失") diff --git a/internal/service/payment/payment.go b/internal/service/payment/payment.go index 0f290b6..8b38084 100644 --- a/internal/service/payment/payment.go +++ b/internal/service/payment/payment.go @@ -222,12 +222,21 @@ func (s *PaymentService) Refund(ctx context.Context, req *dto.PaymentRefundReque func (s *PaymentService) TestProvider(ctx context.Context, provider string) (*dto.PaymentTestResult, error) { result, err := s.uc.TestProvider(ctx, provider) + // The usecase records provider diagnostics in server logs. Arbitrary adapter + // errors can contain URLs, credentials or SQL; never forward them over HTTP. + if err != nil { + err = paymentbiz.ErrPaymentTestFailed + } if result == nil { return nil, err } response := &dto.PaymentTestResult{Provider: result.Provider, TradeNo: result.TradeNo, Passed: result.Passed, FullFlow: result.FullFlow, Mode: result.Mode, Stages: make([]*dto.PaymentTestStage, 0, len(result.Stages))} for _, stage := range result.Stages { - response.Stages = append(response.Stages, &dto.PaymentTestStage{Name: stage.Name, Status: stage.Status, Message: stage.Message, TradeNo: stage.TradeNo, Duration: stage.Duration}) + message := stage.Message + if stage.Status == "failed" { + message = "该步骤失败,请检查配置和服务端日志" + } + response.Stages = append(response.Stages, &dto.PaymentTestStage{Name: stage.Name, Status: stage.Status, Message: message, TradeNo: stage.TradeNo, Duration: stage.Duration}) } response.Result = paymentResultResponse(result.Result) return response, err diff --git a/internal/service/payment/payment_test.go b/internal/service/payment/payment_test.go new file mode 100644 index 0000000..72b9ca3 --- /dev/null +++ b/internal/service/payment/payment_test.go @@ -0,0 +1,28 @@ +package payment + +import ( + "context" + "errors" + paymentbiz "kra/internal/biz/payment" + "testing" +) + +type diagnosticRepo struct{ paymentbiz.PaymentRepo } + +func (diagnosticRepo) TestProvider(context.Context, string) (*paymentbiz.PaymentTestResult, error) { + return &paymentbiz.PaymentTestResult{Provider: "alipay", Stages: []paymentbiz.PaymentTestStage{ + {Name: "config", Status: "passed", Message: "配置已检查"}, + {Name: "create", Status: "failed", Message: "https://merchant:secret@example.test/private?token=secret"}, + }}, errors.New("provider response contains a secret") +} + +func TestProviderDiagnosticsDoNotExposeRawErrors(t *testing.T) { + s := NewPaymentService(paymentbiz.NewPaymentUsecase(diagnosticRepo{}, nil, nil, nil, nil, nil)) + result, err := s.TestProvider(context.Background(), "alipay") + if err != paymentbiz.ErrPaymentTestFailed { + t.Fatalf("unsafe diagnostic error: %v", err) + } + if result == nil || len(result.Stages) != 2 || result.Stages[0].Message != "配置已检查" || result.Stages[1].Message != "该步骤失败,请检查配置和服务端日志" { + t.Fatalf("unexpected diagnostic response: %+v", result) + } +} diff --git a/internal/worker/task_executor.go b/internal/worker/task_executor.go index 104b7bd..cd0c287 100644 --- a/internal/worker/task_executor.go +++ b/internal/worker/task_executor.go @@ -211,7 +211,34 @@ func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) e if e.methodOrphaned(task.ID) { return errTaskOrphaned } - runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + options, err := taskbiz.MethodExecutionOptions(task.Params) + if err != nil { + return err + } + for attempt := 0; attempt <= options.MaxRetryTimes; attempt++ { + err = e.runMethodAttempt(ctx, task, method, options.Timeout) + if err == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || e.methodOrphaned(task.ID) { + return err + } + if attempt < options.MaxRetryTimes { + slog.WarnContext(ctx, "任务执行失败,准备重试", "task_id", task.ID, "method", task.MethodName, "attempt", attempt+1, "max_retry_times", options.MaxRetryTimes, "error", err) + timer := time.NewTimer(options.RetryInterval) + select { + case <-ctx.Done(): + timer.Stop() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return errTaskTimeout + } + return ctx.Err() + case <-timer.C: + } + } + } + return err +} + +func (e *TaskExecutor) runMethodAttempt(ctx context.Context, task *taskbiz.TimedTask, method platformtask.MethodFunc, timeout time.Duration) error { + runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() done := make(chan error, 1) go func() { @@ -224,9 +251,12 @@ func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) e }() select { case err := <-done: - if errors.Is(err, context.DeadlineExceeded) { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(runCtx.Err(), context.DeadlineExceeded) { return errTaskTimeout } + if runCtx.Err() != nil { + return runCtx.Err() + } return err case <-runCtx.Done(): e.markMethodOrphan(task.ID, done) diff --git a/internal/worker/task_executor_test.go b/internal/worker/task_executor_test.go index bd2f898..f72966d 100644 --- a/internal/worker/task_executor_test.go +++ b/internal/worker/task_executor_test.go @@ -52,6 +52,49 @@ func TestRunMethodConvertsPanicToError(t *testing.T) { } } +func TestRunMethodUsesImportedDeadlineAndRetries(t *testing.T) { + registry := platformtask.NewRegistry() + attempts := 0 + registry.Register(platformtask.Method{Name: "migrated-retry", Run: func(ctx context.Context, _ json.RawMessage) error { + attempts++ + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) < 1590*time.Second || time.Until(deadline) > 1600*time.Second { + return errors.New("迁移的 1600 秒超时未生效") + } + if attempts < 3 { + return errors.New("temporary") + } + return nil + }}) + executor := &TaskExecutor{methods: registry} + task := &taskbiz.TimedTask{MethodName: "migrated-retry", Params: []byte(`{"_execution":{"timeoutSeconds":1600,"maxRetryTimes":3,"retryIntervalSeconds":0}}`)} + if err := executor.runMethod(context.Background(), task); err != nil || attempts != 3 { + t.Fatalf("迁移重试未生效: attempts=%d err=%v", attempts, err) + } +} + +func TestRunMethodCancellationStopsRetryDelay(t *testing.T) { + registry := platformtask.NewRegistry() + started := make(chan struct{}) + registry.Register(platformtask.Method{Name: "cancel-retry", Run: func(context.Context, json.RawMessage) error { close(started); return errors.New("temporary") }}) + executor := &TaskExecutor{methods: registry} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- executor.runMethod(ctx, &taskbiz.TimedTask{MethodName: "cancel-retry", Params: []byte(`{"_execution":{"maxRetryTimes":3,"retryIntervalSeconds":60}}`)}) + }() + <-started + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("取消重试错误: %v", err) + } + case <-time.After(time.Second): + t.Fatal("重试等待没有响应取消") + } +} + func TestRunMethodUsesParentContext(t *testing.T) { const methodName = "worker-test-parent-context" started := make(chan struct{}) diff --git a/internal/worker/task_registry.go b/internal/worker/task_registry.go index 1e6f703..4a7c7c5 100644 --- a/internal/worker/task_registry.go +++ b/internal/worker/task_registry.go @@ -20,10 +20,11 @@ type TaskMethods struct { media *system.MediaUsecase runtime *config.Store reports *business.ReportBuildUsecase + alerts *business.AlertTriggerUsecase } -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 NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store, reports *business.ReportBuildUsecase, alerts *business.AlertTriggerUsecase) *TaskMethods { + return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime, reports: reports, alerts: alerts} } func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) { @@ -31,6 +32,15 @@ func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) { return } methods.registerBusinessReports(registry) + registry.Register(platformtask.Method{Name: "alertTriggerTask", Description: "按商务范围检测并生成预警", Run: func(ctx context.Context, _ json.RawMessage) error { + if methods.alerts == nil { + return errors.New("预警任务依赖未就绪") + } + if ctx == nil { + ctx = context.Background() + } + return methods.alerts.Run(ctx) + }}) descriptions := map[string]string{} for _, task := range taskmodule.TimedTasks() { descriptions[task.MethodName] = task.Description diff --git a/pkg/database/gormkit/logger.go b/pkg/database/gormkit/logger.go index ef59ee8..330a1fc 100644 --- a/pkg/database/gormkit/logger.go +++ b/pkg/database/gormkit/logger.go @@ -91,15 +91,31 @@ func (g *Logger) Trace(ctx context.Context, begin time.Time, query func() (strin return } elapsed := time.Since(begin) + logError := queryErr != nil && g.level >= logger.Error && !errors.Is(queryErr, logger.ErrRecordNotFound) + logSlow := elapsed > g.slowThreshold && g.level >= logger.Warn + logInfo := g.level >= logger.Info + if !logError && !logSlow && !logInfo { + // query 会展开全部 SQL 参数;未启用的日志不应承担大 IN 查询的格式化成本。 + return + } + level := slog.LevelInfo + if logError { + level = slog.LevelError + } else if logSlow { + level = slog.LevelWarn + } + if !g.logger.Enabled(ctx, level) { + return + } sql, rows := query() fields := []any{"mod", "sql", "gorm_logger", true, "sql", sql, "rows", rows, "elapsed_ms", elapsed.Milliseconds()} switch { - case queryErr != nil && g.level >= logger.Error && !errors.Is(queryErr, logger.ErrRecordNotFound): + case logError: fields = append(fields, "error", queryErr) g.logger.ErrorContext(ctx, "SQL 执行错误", fields...) - case elapsed > g.slowThreshold && g.level >= logger.Warn: + case logSlow: g.logger.WarnContext(ctx, "SQL 慢查询", fields...) - case g.level >= logger.Info: + case logInfo: g.logger.InfoContext(ctx, "SQL", fields...) } } diff --git a/pkg/database/gormkit/logger_test.go b/pkg/database/gormkit/logger_test.go new file mode 100644 index 0000000..1e6d813 --- /dev/null +++ b/pkg/database/gormkit/logger_test.go @@ -0,0 +1,45 @@ +package gormkit + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + "time" + + "gorm.io/gorm/logger" +) + +func TestTraceOnlyFormatsQueriesThatWillBeLogged(t *testing.T) { + for _, tc := range []struct { + name string + gorm logger.LogLevel + app slog.Level + duration time.Duration + err error + want bool + }{ + {"silent", logger.Silent, slog.LevelInfo, time.Second, errors.New("failure"), false}, + {"successful error-only", logger.Error, slog.LevelInfo, time.Second, nil, false}, + {"fast warning-only", logger.Warn, slog.LevelInfo, time.Millisecond, nil, false}, + {"not-found warning-only", logger.Warn, slog.LevelInfo, time.Millisecond, logger.ErrRecordNotFound, false}, + {"application filter", logger.Info, slog.LevelError, time.Millisecond, nil, false}, + {"slow", logger.Warn, slog.LevelInfo, time.Second, nil, true}, + {"error", logger.Error, slog.LevelInfo, time.Millisecond, errors.New("failure"), true}, + {"info", logger.Info, slog.LevelInfo, time.Millisecond, nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + var buffer bytes.Buffer + log := NewLogger(slog.New(slog.NewJSONHandler(&buffer, &slog.HandlerOptions{Level: tc.app})), tc.gorm) + calls := 0 + log.Trace(context.Background(), time.Now().Add(-tc.duration), func() (string, int64) { + calls++ + return "SELECT 1", 1 + }, tc.err) + if (calls == 1) != tc.want || (buffer.Len() > 0) != tc.want { + t.Fatalf("format calls=%d, output=%q, want logging=%v", calls, buffer.String(), tc.want) + } + }) + } +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..c29fa35 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,49 @@ +# 商务验证工具 + +仅保留仍用于当前 sw8/sw9 验证的工具。旧 sw7、固定端口 18000、重复页面夹具脚本已归档;接口边界回归优先运行相应 Go 测试。 + +## 环境 + +- Python 3.10+;数据库工具需要 PyMySQL、PyYAML,Excel 对比需要 openpyxl。 +- 页面检查使用 Node 和 Playwright;通过 `SW_PLAYWRIGHT_MODULE` 指定已有模块路径。 +- `--runtime` 必须指向仓库外的私有目录。配置、令牌、实际响应、导出文件和 SQL 日志不得提交。 +- 旧 Java 连接 sw8,新 Go 连接 sw9。运行命令时明确传入端口,避免误连日常服务。任务保持停用。 + +通用输入:`legacy-.token`、`new-.token`、`legacy-client.json`、`id-maps.json`;写入测试还需要专门创建的三级测试身份及对应凭据。不要把业务正常账号当作可随意清理的测试身份。 + +## 工具用途 + +| 工具 | 用途与写入范围 | +|---|---| +| start-sw-rehearsal.ps1 | 从私有配置启动本机 Go 服务,临时注入 SDK 兼容参数,日志留在 runtime | +| sw_database_rehearsal.py | 数据库盘点、备份、迁移及验证;同时提供其他脚本依赖的 connect/query | +| sw_api_compare.py | 新旧读取接口逐字段对比;输入 case JSON 明确路径、参数、允许的协议转换 | +| sw_write_verify.py | 规则与预警状态流程测试;提供其他写入工具的 Rehearsal 支持 | +| sw_account_create_verify.py | 创建临时下级、验证层级/角色并清理;不删除预先存在的同名账号 | +| sw_account_write_verify.py | 测试账号资料/状态并恢复原值 | +| sw_preference_compare.py | 四种包含下级/身份证去重组合,结束恢复偏好 | +| sw_export_compare.py | 新旧注册和充值 Excel 逐单元格对比 | +| sw_sdk_write_verify.py | 独立平台夹具上的绑定、重提、解绑与渠道创建;受控 OA 审核 | +| sw_report_rehearsal.py | 指定日报分区备份、比较和恢复 | +| sw_run_report_tasks.py | 要求已有日期匹配的备份;创建停用的临时计划、手工执行并清理计划 | +| sw_endpoint_benchmark.py | 接口耗时、完整响应摘要及 SQL 采集,只读 | +| sw_endpoint_parity.py | 优化前后接口及偏好矩阵对比;即使 HTTP 恢复失败也尝试还原数据库偏好 | +| sw_dashboard_layout_smoke.cjs | 激活分页、偏好和榜单布局检查,截图留在指定私有目录 | + +先通过各 Python 工具的 `--help` 查看必需参数。日报、预警及写入工具会改动测试数据;使用其备份/恢复流程,并核对最终表计数与任务状态。 + +## 读取对比示例 + +```powershell +python scripts/sw_api_compare.py --runtime D:/private/sw-review --cases D:/private/sw-review/cases.json --old-url http://127.0.0.1:18080 --new-url http://127.0.0.1:19004 --output D:/private/sw-review/result.json +``` + +`--recompare` 只比较已保存响应;`--refresh-target` 使用已保存的同参数旧响应、重新请求新服务。报告会标记 `source_capture_reused`,不能把快照对比描述为双方重新实测。 + +本地 MySQL 回归只接受 `SW_TEST_MYSQL_DSN` 指向 `127.0.0.1` 的隔离实例: + +```powershell +go test ./internal/data/business -run MySQL -count=1 +``` + +这些测试创建并清理自己的随机数据库,不在 sw8/sw9 上运行测试建表和删除。 diff --git a/scripts/start-sw-rehearsal.ps1 b/scripts/start-sw-rehearsal.ps1 new file mode 100644 index 0000000..5356e59 --- /dev/null +++ b/scripts/start-sw-rehearsal.ps1 @@ -0,0 +1,37 @@ +param( + [Parameter(Mandatory = $true)][string]$RuntimePath, + [string]$ConfigName = 'config-next.yaml', + [string]$BinaryPath = '.cache/sw89/sw-new-complete.exe' +) + +$ErrorActionPreference = 'Stop' +$workspaceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$runtimeRoot = (Resolve-Path -LiteralPath $RuntimePath).Path +$configPath = (Resolve-Path -LiteralPath (Join-Path $runtimeRoot $ConfigName)).Path +if (-not [System.IO.Path]::IsPathRooted($BinaryPath)) { + $BinaryPath = Join-Path $workspaceRoot $BinaryPath +} +$binary = (Resolve-Path -LiteralPath $BinaryPath).Path +$compat = Get-Content -LiteralPath (Join-Path $runtimeRoot 'sdk-compatibility.json') -Raw | ConvertFrom-Json +$previous = @{} +$settings = @{ + SW_SDK_AUTH_CODE = $compat.AUTH_CODE + SW_SDK_MIGRATE_AUTH_CODE = $compat.MIGRATE_AUTH_CODE + SW_SDK_TABLE_PREFIX = $compat.TABLE_PREFIX +} +try { + foreach ($name in $settings.Keys) { + $previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + [Environment]::SetEnvironmentVariable($name, $settings[$name], 'Process') + } + $process = Start-Process -FilePath $binary -ArgumentList @('-conf', ('"' + $configPath + '"')) ` + -WorkingDirectory $workspaceRoot -WindowStyle Hidden ` + -RedirectStandardOutput (Join-Path $runtimeRoot 'rehearsal.stdout.log') ` + -RedirectStandardError (Join-Path $runtimeRoot 'rehearsal.stderr.log') -PassThru + $process.Id | Set-Content -LiteralPath (Join-Path $runtimeRoot 'rehearsal.pid') + Write-Output "Rehearsal process: $($process.Id)" +} finally { + foreach ($name in $previous.Keys) { + [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') + } +} diff --git a/scripts/sw_account_create_verify.py b/scripts/sw_account_create_verify.py new file mode 100644 index 0000000..74f5655 --- /dev/null +++ b/scripts/sw_account_create_verify.py @@ -0,0 +1,73 @@ +"""在 sw8/sw9 用专用二级账号创建临时三级账号,核对层级和角色后清理。""" + +import argparse +from pathlib import Path + +from sw_write_verify import Rehearsal + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + parser.add_argument("--old-url", default="http://127.0.0.1:18080") + parser.add_argument("--new-url", default="http://127.0.0.1:19002") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]): + raise ValueError("测试凭据必须位于仓库外") + run = Rehearsal(args) + username = "mig_recheck_" + run.suffix + password = (args.runtime / "synthetic-test-password.txt").read_text(encoding="utf-8").strip() + source_roles = run.query("SELECT role_id FROM sw8.sys_user_role WHERE user_id=%s", (run.owner("legacy", 3),)) + expected_roles = run.query("SELECT sys_authority_authority_id id FROM sw9.sys_user_authority WHERE sys_user_id=%s", (run.owner("new", 3),)) + created_sides = set() + try: + run.check("测试角色存在", len(source_roles) == 1 and len(expected_roles) == 1) + for side, database, table, column in (("legacy", "sw8", "sys_user", "user_name"), ("new", "sw9", "sys_users", "username")): + run.check("临时账号不存在 " + side, not run.query(f"SELECT 1 FROM {database}.{table} WHERE {column}=%s", (username,))) + payload = {"userName": username, "password": password, "nickName": "迁移复核临时账号", "realName": "迁移复核临时账号"} + run.call("legacy", "testlevel2", "POST", "/system/user", {**payload, "roleIds": [str(source_roles[0]["role_id"])]}) + created_sides.add("sw8") + run.call("new", "testlevel2", "POST", "/sw/business", {**payload, "parentId": 0, "level": 1, "authorityId": 888}) + created_sides.add("sw9") + old = run.query("SELECT user_id,level,parent_id,id_path,status FROM sw8.sys_user WHERE user_name=%s", (username,))[0] + new = run.query("SELECT id,level,parent_id,id_path,business_status,authority_id,enable FROM sw9.sys_users WHERE username=%s", (username,))[0] + run.check("新旧创建三级且挂靠当前二级", old["level"] == new["level"] == 3 and str(old["parent_id"]) == str(run.owner("legacy", 2)) and new["parent_id"] == run.owner("new", 2)) + path = ",".join(run.mapping.get(part, part) for part in str(old["id_path"]).split(",")) + run.check("祖先路径按用户映射一致", path == new["id_path"]) + run.check("默认状态正常且角色不能由请求提权", str(old["status"]) == str(new["business_status"]) == "0" and new["enable"] == 1 and new["authority_id"] == expected_roles[0]["id"] and new["authority_id"] != 888) + old_roles = run.query("SELECT role_id FROM sw8.sys_user_role WHERE user_id=%s", (old["user_id"],)) + new_roles = run.query("SELECT sys_authority_authority_id id FROM sw9.sys_user_authority WHERE sys_user_id=%s", (new["id"],)) + run.check("关联角色正确", old_roles == source_roles and new_roles == expected_roles) + values = [] + for database, user_id in (("sw8", old["user_id"]), ("sw9", new["id"])): + values.append(run.query(f"SELECT include_sub_promote,idcard_dedup FROM {database}.sys_user_preference WHERE user_id=%s", (user_id,))) + run.check("默认统计偏好一致", values[0] == values[1] == [{"include_sub_promote": 1, "idcard_dedup": 1}]) + finally: + # 只清理本次唯一名称创建的临时账号及其角色/偏好,不触碰原有测试链。 + for database, table, id_col, name_col in (("sw8", "sys_user", "user_id", "user_name"), ("sw9", "sys_users", "id", "username")): + if database not in created_sides: + continue + rows = run.query(f"SELECT {id_col} id FROM {database}.{table} WHERE {name_col}=%s", (username,)) + for row in rows: + user_id = row["id"] + run.db.begin() + try: + run.query(f"DELETE FROM {database}.sys_user_preference WHERE user_id=%s", (user_id,)) + if database == "sw8": + for relation in ("sys_user_role", "sys_user_post"): + run.query(f"DELETE FROM sw8.{relation} WHERE user_id=%s", (user_id,)) + else: + run.query("DELETE FROM sw9.sys_user_authority WHERE sys_user_id=%s", (user_id,)) + run.query(f"DELETE FROM {database}.{table} WHERE {id_col}=%s AND {name_col}=%s", (user_id, username)) + run.db.commit() + except Exception: + run.db.rollback() + raise + run.check("临时账号清理完成 " + database, not run.query(f"SELECT 1 FROM {database}.{table} WHERE {name_col}=%s", (username,))) + run.db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_account_write_verify.py b/scripts/sw_account_write_verify.py new file mode 100644 index 0000000..2223507 --- /dev/null +++ b/scripts/sw_account_write_verify.py @@ -0,0 +1,75 @@ +"""核对专用二级测试账号的资料更新、四种商务状态及恢复,不修改真实商务账号。""" + +import argparse +import json +import time +from pathlib import Path + +from sw_write_verify import Rehearsal + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--config", type=Path, default=Path("configs/config.yaml")) + parser.add_argument("--old-url", default="http://127.0.0.1:18080") + parser.add_argument("--new-url", default="http://127.0.0.1:19001") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + run = Rehearsal(args) + account = run.users[1] + if not account["username"].startswith("mig_verify_"): + raise ValueError("仅允许修改本轮创建的专用测试账号") + originals = { + "legacy": run.query("SELECT real_name,status,reject_reason,update_time FROM sw8.sys_user WHERE user_id=%s AND user_name=%s", (account["source_id"], account["username"]))[0], + "new": run.query("SELECT real_name,business_status,enable,reject_reason,updated_at FROM sw9.sys_users WHERE id=%s AND username=%s", (account["target_id"], account["username"]))[0], + } + (args.runtime / "test-account-before-write.json").write_text(json.dumps(originals, default=str, indent=2), encoding="utf-8") + + def update(side, status, name, reason): + payload = {"userId": account["source_id" if side == "legacy" else "target_id"], "userName": account["username"], "realName": name, + "status": str(status), "rejectReason": reason or "", "roleIds": ["2087435436573081601"]} + return run.call(side, "admin", "PUT", "/system/user" if side == "legacy" else "/sw/business", payload) + + try: + started = time.monotonic() + for index, status in enumerate((0, 3, 2, 1, 0)): + if index == 4: + # 旧控制器对相同请求体有五秒重复提交限制。 + time.sleep(max(0, 5.2 - (time.monotonic() - started))) + name = "迁移核对二级资料更新" + reason = "迁移核对驳回" if status == 2 else "" + for side in ("legacy", "new"): + update(side, status, name, reason) + path = "/system/user/" + str(account["source_id"]) if side == "legacy" else "/sw/business/" + str(account["target_id"]) + detail = run.call(side, "admin", "GET", path)["data"] + if side == "legacy": + detail = detail["user"] + run.check(f"资料及状态 {status} {side}", detail["realName"] == name and str(detail["status"]) == str(status) and detail["level"] == 2) + enabled = run.query("SELECT enable FROM sw9.sys_users WHERE id=%s", (account["target_id"],))[0]["enable"] + run.check(f"商务状态 {status} 与启停一致", enabled == (1 if status == 0 else 0)) + replies = {side: run.call(side, "testlevel2", "GET", run.prefix(side) + "/user/preference", success=False) for side in ("legacy", "new")} + if status != 0: + run.check(f"状态 {status} 拒绝旧会话", replies["new"]["code"] != 0, + legacy_session_allowed=replies["legacy"]["code"] == 200, accepted_change="新系统每次请求核对商务状态,阻止停用/待审/驳回账号沿用旧会话") + else: + run.check("正常账号会话可用", replies["new"]["code"] == 0 and replies["legacy"]["code"] == 200) + finally: + for side in ("legacy", "new"): + original = originals[side] + update(side, original["status" if side == "legacy" else "business_status"], original["real_name"], original["reject_reason"]) + old, new = originals["legacy"], originals["new"] + run.query("UPDATE sw8.sys_user SET real_name=%s,status=%s,reject_reason=%s,update_time=%s WHERE user_id=%s AND user_name=%s", + (old["real_name"], old["status"], old["reject_reason"], old["update_time"], account["source_id"], account["username"])) + run.query("UPDATE sw9.sys_users SET real_name=%s,business_status=%s,enable=%s,reject_reason=%s,updated_at=%s WHERE id=%s AND username=%s", + (new["real_name"], new["business_status"], new["enable"], new["reject_reason"], new["updated_at"], account["target_id"], account["username"])) + actual = { + "legacy": run.query("SELECT real_name,status,reject_reason,update_time FROM sw8.sys_user WHERE user_id=%s", (account["source_id"],))[0], + "new": run.query("SELECT real_name,business_status,enable,reject_reason,updated_at FROM sw9.sys_users WHERE id=%s", (account["target_id"],))[0], + } + run.check("测试账号原始资料和状态已恢复", actual == originals) + run.db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_activation_http_smoke.py b/scripts/sw_activation_http_smoke.py deleted file mode 100644 index 1c6b4d7..0000000 --- a/scripts/sw_activation_http_smoke.py +++ /dev/null @@ -1,42 +0,0 @@ -"""查询运行实例的渠道激活接口,仅执行只读请求。""" - -import argparse -import json -import urllib.parse -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() - checks = [] - paths = [ - ("/sw/promoteActivePool/bindList?pageNum=1&pageSize=10", 0), - ("/sw/promoteActivePool/bindList?platformNames=lx,hw&statuses=reached", 0), - ("/sw/promoteActivePool/bindList?" + urllib.parse.urlencode({"statuses": "已达标", "busierAccount": "does-not-exist"}), 0), - ("/sw/promoteActivePool/bindSpendDetail?id=0", 7), - ] - for path, expected in paths: - request = urllib.request.Request(args.base_url + path, headers={"x-token": token}) - with urllib.request.urlopen(request, timeout=30) as response: - body = json.load(response) - item = {"method": "GET", "path": path, "http": response.status, "code": body.get("code"), - "msg": body.get("msg"), "passed": response.status == 200 and body.get("code") == expected} - if isinstance(body.get("data"), dict) and "rows" in body["data"]: - item.update(total=body["data"]["total"], available=body["data"].get("available")) - checks.append(item) - print(json.dumps(item, ensure_ascii=False)) - report = {"scope": "当前 sw7 运行实例,只读请求;OA 未接入场景明确返回 available=false。完整 OA 与流水数据通过两个隔离数据库的 TCP HTTP 测试。", - "checks": checks, "passed": all(item["passed"] for item in checks)} - args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - if not report["passed"]: - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/sw_activation_smoke.cjs b/scripts/sw_activation_smoke.cjs deleted file mode 100644 index 6b770e0..0000000 --- a/scripts/sw_activation_smoke.cjs +++ /dev/null @@ -1,75 +0,0 @@ -// 核对渠道激活页面的真实渲染;OA 数据与流水查询使用受控响应。 -const fs = require('node:fs/promises') -const path = require('node:path') -const assert = require('node:assert/strict') -const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright') - -;(async () => { - const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim() - const browser = await chromium.launch({ channel: 'chrome', headless: true }) - try { - const context = await browser.newContext({ viewport: { width: 1500, height: 1000 } }) - await context.addInitScript(value => localStorage.setItem('token', value), token) - const page = await context.newPage() - const errors = [] - const queries = [] - let unavailable = false - const rows = ['pending', 'in_progress', 'reached', 'rejected', 'unbound'].map((status, i) => ({ - id: String(i + 1), promoteAccount: `测试渠道-${i + 1}`, busierAccount: '测试商务', platformName: 'lx', platformTitle: '龙翔', - bindAt: ['pending', 'rejected'].includes(status) ? null : '2026-11-01 00:00:00', - unbindAt: status === 'unbound' ? '2026-11-03 12:00:00' : null, - endAt: ['reached', 'unbound'].includes(status) ? '2026-11-03 12:00:00' : null, - accumAmount: '30.30', status, statusText: { pending: '待审核', in_progress: '进行中', reached: '已达标', rejected: '已驳回', unbound: '已解绑' }[status] - })) - page.on('pageerror', error => errors.push(error.message)) - await page.route('http://127.0.0.1:8080/api/**', async route => { - const request = route.request() - const url = new URL(request.url()) - const api = url.pathname.slice(4) - if (api === '/sw/promoteActivePool/bindList') { - queries.push(url.search) - return route.fulfill({ json: { code: 0, data: { rows: unavailable ? [] : rows, total: unavailable ? 0 : rows.length, available: !unavailable } } }) - } - if (api === '/sw/promoteActivePool/bindSpendDetail') { - if (url.searchParams.get('id') === '1') return route.fulfill({ json: { code: 7, msg: '审核通过后可查看', data: {} } }) - const row = rows.find(item => item.id === url.searchParams.get('id')) - return route.fulfill({ json: { code: 0, data: { promoteAccount: row.promoteAccount, bindAt: row.bindAt, endAt: row.endAt, list: [{ platformTitle: '龙翔', gameName: '测试游戏', totalAmount: '9007199254740993.10' }], totalAmount: '9007199254740993.10' } } }) - } - const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } }) - return route.fulfill({ response }) - }) - await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/ChannelActivationList') - await page.getByText('测试渠道-1', { exact: true }).waitFor() - const row = id => page.locator('.el-table__body-wrapper tr').filter({ hasText: `测试渠道-${id}` }) - assert.equal(await row(4).getByRole('button', { name: '流水明细', exact: true }).count(), 0) - for (const id of [1, 2, 3, 5]) assert.equal(await row(id).getByRole('button', { name: '流水明细', exact: true }).count(), 1) - const dialog = page.getByRole('dialog', { name: '流水明细', exact: true }) - await row(3).getByRole('button', { name: '流水明细', exact: true }).click() - await dialog.getByText('测试游戏', { exact: true }).waitFor() - await dialog.getByText('2026-11-01 至 2026-11-03', { exact: true }).waitFor() - assert.equal(await dialog.getByText('¥9,007,199,254,740,993.10', { exact: true }).count(), 2) - await dialog.locator('.el-loading-mask').waitFor({ state: 'hidden' }) - await page.screenshot({ path: path.join(process.env.TEMP, 'sw-activation-flow.png'), animations: 'disabled' }) - await dialog.getByRole('button', { name: '关闭', exact: true }).click() - await dialog.waitFor({ state: 'hidden' }) - await row(1).getByRole('button', { name: '流水明细', exact: true }).click() - await dialog.getByText('审核通过后可查看', { exact: true }).waitFor() - assert.equal(await dialog.getByText('绑定期间累计流水:', { exact: false }).count(), 0) - await dialog.getByRole('button', { name: '关闭', exact: true }).click() - await dialog.waitFor({ state: 'hidden' }) - const form = page.locator('.kra-search-box') - await form.getByPlaceholder('渠道账号', { exact: true }).fill(' 渠道 ') - await form.getByRole('button', { name: '查询', exact: true }).click() - await page.waitForResponse(response => response.url().includes('/sw/promoteActivePool/bindList') && response.url().includes('promoteAccount=')) - assert.equal(new URLSearchParams(queries.at(-1)).get('promoteAccount'), '渠道') - unavailable = true - await form.getByRole('button', { name: '重置', exact: true }).click() - await page.getByText('OA 渠道激活数据源尚未接入或暂不可用。', { exact: true }).waitFor() - assert.equal(new URLSearchParams(queries.at(-1)).get('promoteAccount'), '') - assert.deepEqual(errors, []) - const result = { passed: true, rejectedHidesFlow: true, pendingShowsReasonWithoutFakeTotal: true, reachedCutoffDisplayed: true, exactLargeAmount: true, filtersAndReset: true, unavailableSourceNotice: true, pageErrors: errors } - await fs.writeFile('docs/sw6-to-kra/activation-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome,受控 OA 和流水响应,无写入', ...result }, null, 2) + '\n') - console.log(JSON.stringify(result)) - await context.close() - } finally { await browser.close() } -})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/sw_alert_http_smoke.py b/scripts/sw_alert_http_smoke.py deleted file mode 100644 index 09a73a0..0000000 --- a/scripts/sw_alert_http_smoke.py +++ /dev/null @@ -1,58 +0,0 @@ -"""实测运行实例的预警查询和无效写请求,不修改真实预警或上传文件。""" - -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", type=Path, required=True) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - token = args.token_file.read_text(encoding="utf-8").strip() - checks = [] - - def call(method, path, body=None, expected=0): - request = urllib.request.Request( - args.base_url + path, method=method, - headers={"x-token": token, "Content-Type": "application/json"}, - data=json.dumps(body).encode() if body is not None else None, - ) - with urllib.request.urlopen(request, timeout=30) as response: - value = json.load(response) - result = {"method": method, "path": path, "http": response.status, - "code": value.get("code"), "msg": value.get("msg"), - "passed": response.status == 200 and value.get("code") == expected} - data = value.get("data", {}) - if isinstance(data, dict) and "pageData" in data: - result.update(total=data["pageData"]["total"], available=data.get("available"), - status_count=data["statusCount"]) - checks.append(result) - print(json.dumps(result, ensure_ascii=False), flush=True) - return data - - call("GET", "/sw/alertRecord/list?pageNum=1&pageSize=10") - call("GET", "/sw/alertRecord/list?platformCodes=lx,hw&status=5") - call("GET", "/sw/alertRule/ruleTypes") - accounts = call("GET", "/sw/business/list?pageNum=1&pageSize=500") - bound = next((row for row in accounts.get("rows", []) if row["bindCount"] > 0), None) - unbound = next((row for row in accounts.get("rows", []) if row["bindCount"] == 0), None) - for account in [bound, unbound]: - if account: - call("GET", f"/sw/alertRecord/list?businessId={account['userId']}") - call("GET", "/sw/alertRecord/detail/0", expected=7) - for action in ["startProcess", "submitAudit", "approve", "reject", "reProcess", "addNote"]: - call("POST", f"/sw/alertRecord/{action}", {"alertId": 0}, expected=7) - report = {"scope": "既有 sw7,只读查询及必然拒绝的无效写请求;正向流转另用隔离 SQLite 和 TCP HTTP 测试", - "base_url": args.base_url, "checks": checks, - "passed": all(row["passed"] for row in checks)} - args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - if not report["passed"]: - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/sw_alert_records_smoke.cjs b/scripts/sw_alert_records_smoke.cjs deleted file mode 100644 index cee19e7..0000000 --- a/scripts/sw_alert_records_smoke.cjs +++ /dev/null @@ -1,156 +0,0 @@ -// 独立 Chrome 核对预警页面;预警写入和附件上传使用受控接口,不修改真实业务数据。 -const fs = require('node:fs/promises') -const path = require('node:path') -const assert = require('node:assert/strict') -const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright') - -;(async () => { - const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim() - const browser = await chromium.launch({ channel: 'chrome', headless: true }) - const results = [] - const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=', 'base64') - try { - for (const level of [0, 1, 2, 3]) { - const context = await browser.newContext({ viewport: { width: 1500, height: 1080 } }) - await context.addInitScript(value => localStorage.setItem('token', value), token) - const page = await context.newPage() - const pageErrors = [] - const writes = [] - const queries = [] - let uploads = 0 - let unavailable = false - const rows = [1, 2, 3, 4, 5].map(status => ({ - id: String(100 + status), platformCode: 'lx', platformName: '龙翔', ruleName: `测试规则-${status}`, - ruleType: '测试分类', targetPromoteName: `渠道-${status}`, businessName: '测试一级商务', - alertContent: '测试预警内容', status, createdAt: '2026-11-03 10:20:30', updatedAt: '2026-11-03 10:20:30' - })) - const logs = new Map(rows.map(row => [row.id, []])) - const log = (id, action, note, attachments = []) => logs.get(id).unshift({ - id: String(writes.length), operatorName: '页面测试账号', logType: action ? 1 : 2, - logTypeLabel: action ? '状态变更操作' : '添加独立备注', actionDetail: action || '', - noteContent: note || '', attachments, createdAt: '2026-11-03 10:30:00' - }) - page.on('pageerror', error => pageErrors.push(error.message)) - await page.route('http://127.0.0.1:8080/api/**', async route => { - const request = route.request() - const url = new URL(request.url()) - const api = url.pathname.slice(4) - const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } }) - if (api === '/sw/alertRule/ruleTypes') return ok(['测试分类']) - if (api === '/sw/alertRecord/list') { - queries.push(url.search) - let filtered = rows.filter(row => !url.searchParams.get('status') || row.status === Number(url.searchParams.get('status'))) - if (url.searchParams.get('ruleName')) filtered = filtered.filter(row => row.ruleName.includes(url.searchParams.get('ruleName'))) - return ok({ pageData: { rows: unavailable ? [] : filtered, total: unavailable ? 0 : filtered.length }, statusCount: unavailable ? {} : { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1 }, available: !unavailable }) - } - if (api.startsWith('/sw/alertRecord/detail/')) { - const id = api.split('/').at(-1) - return ok({ alertRecord: rows.find(row => row.id === id), operationLogs: logs.get(id), canAudit: level === 1 }) - } - if (api === '/fileUploadAndDownload/upload') { - uploads++ - return ok({ file: { ID: 800 + uploads, url: `/files/verify-${uploads}.png`, name: 'test.png' } }) - } - if (api.startsWith('/sw/alertRecord/') && request.method() === 'POST') { - const payload = request.postDataJSON() - const id = String(payload.alertId) - writes.push({ path: api, body: payload }) - const action = api.split('/').at(-1) - if (action === 'addNote') log(id, '', payload.noteContent, ['/files/verify-1.png']) - else { - rows.find(row => row.id === id).status = { startProcess: 2, submitAudit: 3, reject: 4, reProcess: 2, approve: 5 }[action] - log(id, { startProcess: 'start', submitAudit: 'submit', reject: 'reject', reProcess: 'reprocess', approve: 'pass' }[action], payload.note) - } - return ok({}) - } - const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } }) - if (api === '/user/getUserInfo') { - const body = await response.json() - Object.assign(body.data.userInfo, { ID: 70000 + level, level, nickName: '页面测试账号', phone: '', email: '', authorityId: level === 0 ? 888 : 900 + level }) - return route.fulfill({ response, json: body }) - } - return route.fulfill({ response }) - }) - await page.route('**/files/verify-*.png', route => route.fulfill({ contentType: 'image/png', body: png })) - await page.goto('http://127.0.0.1:8080/#/layout/sw-AlertCenter/AlertList') - await page.getByText('测试规则-1', { exact: true }).waitFor() - const result = { level, stateButtons: [], passed: true } - const tableRow = status => page.locator('.el-table__body-wrapper tr').filter({ hasText: `测试规则-${status}` }) - const dialog = page.getByRole('dialog', { name: '预警处理 / 详情', exact: true }) - const close = async () => { await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }) } - for (const status of [1, 2, 3, 4, 5]) { - const expected = { 1: '处理', 2: '继续处理', 3: level === 1 ? '审核' : '查看', 4: '重新处理', 5: '查看' }[status] - await tableRow(status).getByRole('button', { name: expected, exact: true }).click() - await dialog.getByText('测试预警内容', { exact: true }).waitFor() - assert.equal(await dialog.getByRole('button', { name: '审核通过', exact: true }).count(), status === 3 && level === 1 ? 1 : 0) - assert.equal(await dialog.getByPlaceholder('填写处理备注...').count(), status === 5 || (status === 3 && level !== 1) ? 0 : 1) - result.stateButtons.push({ status, label: expected }) - await close() - } - if (level === 1) { - await tableRow(1).getByRole('button', { name: '处理', exact: true }).click() - await dialog.getByPlaceholder('填写处理备注...').waitFor() - await dialog.locator('input[type="file"]').setInputFiles({ name: 'test.png', mimeType: 'image/png', buffer: png }) - await dialog.locator('textarea').evaluate((element, bytes) => { - const transfer = new DataTransfer() - transfer.items.add(new File([new Uint8Array(bytes)], 'paste.png', { type: 'image/png' })) - element.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: transfer })) - }, [...png]) - await dialog.locator('.alert-upload-zone').evaluate((element, bytes) => { - const transfer = new DataTransfer() - transfer.items.add(new File([new Uint8Array(bytes)], 'drop.png', { type: 'image/png' })) - element.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer })) - }, [...png]) - assert.equal(await dialog.locator('.pending-image').count(), 3) - await dialog.locator('.pending-image').first().getByRole('button', { name: '移除', exact: true }).click() - await dialog.locator('.pending-image').first().getByRole('button', { name: '移除', exact: true }).click() - assert.equal(await dialog.locator('.pending-image').count(), 1) - assert.equal(uploads, 0) - await dialog.getByRole('button', { name: '添加备注', exact: true }).click() - await page.getByText('请填写处理备注后再提交附件', { exact: true }).waitFor() - assert.equal(uploads, 0) - await dialog.getByPlaceholder('填写处理备注...').fill('测试备注和图片') - await dialog.getByRole('button', { name: '开始处理', exact: true }).click() - await page.getByText('请先点击「添加备注」上传图片,或移除暂存图片后再操作', { exact: true }).waitFor() - assert.equal(writes.length, 0) - await dialog.getByRole('button', { name: '添加备注', exact: true }).click() - await dialog.getByText('备注:测试备注和图片', { exact: true }).waitFor() - assert.equal(uploads, 1) - assert.deepEqual(writes[0].body.attachmentIds, ['801']) - for (const [button, next, confirmation] of [ - ['开始处理', '提交审核', true], ['提交审核', '审核通过', true], ['审核驳回', '重新处理', true], - ['重新处理', '提交审核', true], ['提交审核', '审核通过', true], ['审核通过', null, false] - ]) { - await dialog.getByRole('button', { name: button, exact: true }).click() - if (confirmation) await page.getByRole('dialog', { name: '操作确认', exact: true }).getByRole('button', { name: '确定', exact: true }).click() - if (next) await dialog.getByRole('button', { name: next, exact: true }).waitFor() - else await dialog.getByPlaceholder('填写处理备注...').waitFor({ state: 'hidden' }) - } - assert.equal(writes.length, 7) - await page.screenshot({ path: path.join(process.env.TEMP, 'sw-alert-record-workflow.png'), animations: 'disabled' }) - await close() - result.workflowAndAttachments = true - result.pasteDropAndRemove = true - } - if (level === 2) { - await page.getByRole('button', { name: '未处理 1', exact: true }).click() - await tableRow(2).waitFor({ state: 'hidden' }) - assert.ok(queries.at(-1).includes('status=1')) - await page.getByRole('button', { name: '未处理 1', exact: true }).click() - await tableRow(2).waitFor() - assert.ok(!queries.at(-1).includes('status=1')) - unavailable = true - await page.locator('.kra-search-box').getByRole('button', { name: '搜索', exact: true }).click() - await page.getByText('渠道数据尚未接入,暂时无法核对预警可见范围。', { exact: true }).waitFor() - assert.equal(await page.getByText('未接入', { exact: true }).count(), 5) - result.statusCardsAndUnavailable = true - } - assert.deepEqual(pageErrors, []) - result.pageErrors = pageErrors - results.push(result) - console.log(JSON.stringify(result)) - await context.close() - } - await fs.writeFile('docs/sw6-to-kra/alert-records-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome 渲染;受控身份、预警数据、写接口和图片上传;未修改真实业务数据', results }, null, 2) + '\n') - } finally { await browser.close() } -})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/sw_alert_rules_smoke.cjs b/scripts/sw_alert_rules_smoke.cjs deleted file mode 100644 index e90d9f6..0000000 --- a/scripts/sw_alert_rules_smoke.cjs +++ /dev/null @@ -1,36 +0,0 @@ -// 在独立 Chrome 上核对规则入口和指标联动,接口写入使用测试替身。 -const fs=require('node:fs/promises');const path=require('node:path'); -const {chromium}=require(process.env.SW_PLAYWRIGHT_MODULE||'playwright'); -(async()=>{ - const token=(await fs.readFile(path.join(process.env.TEMP,'sw-kra-http-test-token'),'utf8')).trim(); - const browser=await chromium.launch({channel:'chrome',headless:true});const results=[]; - try{ - for(const level of [0,1,2,3]){ - const context=await browser.newContext({viewport:{width:1440,height:1000}});await context.addInitScript(t=>localStorage.setItem('token',t),token); - const page=await context.newPage();const errors=[];const writes=[];page.on('pageerror',e=>errors.push(e.message)); - await page.route('http://127.0.0.1:8080/api/**',async route=>{ - const req=route.request();const url=new URL(req.url());const api=url.pathname.slice(4); - if(api==='/sw/alertRule/list'){await route.fulfill({json:{code:0,msg:'成功',data:{total:1,rows:[{id:1,ruleName:'测试规则',ruleType:'测试分类',ruleTypeLabel:'测试分类',monitorMetric:1,statPeriodDays:7,momDirection:2,momThreshold:20,warningColor:'#f56c6c',creatorId:70001,creatorName:'测试一级商务',monitorScope:1,enabled:1,ruleDescription:'渠道近 7 天注册量较上一同期环比下降 20% 时触发'}]}}});return;} - if(api.startsWith('/sw/alertRule/')&&req.method()!=='GET'){writes.push({path:api,body:req.postDataJSON()});await route.fulfill({json:{code:0,msg:'成功',data:{id:2}}});return;} - const response=await route.fetch({url:'http://127.0.0.1:18000'+api+url.search,headers:{...req.headers(),'x-token':token}}); - if(api==='/user/getUserInfo'){const body=await response.json();Object.assign(body.data.userInfo,{ID:70000+level,level,nickName:'页面核对账号',phone:'',email:'',authorityId:level===0?888:900+level});await route.fulfill({response,json:body});} - else await route.fulfill({response}); - }); - await page.goto('http://127.0.0.1:8080/#/layout/sw-AlertCenter/AlertRules');await page.getByText('测试规则',{exact:true}).waitFor(); - const create=await page.getByRole('button',{name:'新建规则',exact:true}).count();const edit=await page.getByRole('button',{name:'编辑',exact:true}).count(); - const allowed=level===0||level===1;if(create!==(allowed?1:0)||edit!==(allowed?1:0))throw new Error('规则写入口权限错误 level='+level); - const result={level,createButton:create,editButton:edit,passed:true}; - if(level===1){ - await page.getByRole('button',{name:'新建规则',exact:true}).click();const dialog=page.getByRole('dialog',{name:'新建规则',exact:true}); - await dialog.getByPlaceholder('请输入规则名称').fill('登录测试');await dialog.getByPlaceholder('如:注册异常 / 充值异常 / 登录异常').fill('自定义类型'); - await dialog.locator('.el-form-item').filter({hasText:'监控指标'}).locator('.el-select__wrapper').click();await page.getByRole('option',{name:'登录天数',exact:true}).click(); - if(await dialog.getByText('统计周期(天)',{exact:true}).count())throw new Error('登录指标仍显示环比字段'); - await dialog.getByText('未登录天数',{exact:true}).waitFor();await dialog.getByRole('button',{name:'保存',exact:true}).click();await dialog.waitFor({state:'hidden'}); - const saved=writes.find(x=>x.path==='/sw/alertRule/add');if(!saved||saved.body.monitorMetric!==3||saved.body.unloginDays!==7)throw new Error('规则联动表单参数错误');result.metricFields=true; - } - result.pageErrors=errors;if(errors.length)throw new Error(errors.join(';')); - results.push(result);console.log(JSON.stringify(result));await context.close(); - } - await fs.writeFile('docs/sw6-to-kra/alert-rules-page-2026-11-03.json',JSON.stringify({method:'真实 Chrome 渲染,受控角色及规则数据;不写真实规则',results},null,2)); - }finally{await browser.close();} -})().catch(e=>{console.error(e.message);process.exitCode=1}); diff --git a/scripts/sw_analytics_http_smoke.py b/scripts/sw_analytics_http_smoke.py deleted file mode 100644 index dc7a52e..0000000 --- a/scripts/sw_analytics_http_smoke.py +++ /dev/null @@ -1,37 +0,0 @@ -"""只读检查渠道/游戏统计接口,区分空结果与业务表未接入。""" -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() - checks = [] - for kind in ["channelData", "gameData"]: - for query, expected in [("sourceDb=lx,hw,yq&pageNum=1&pageSize=10", 0), ("beginTime=bad&endTime=2026-11-04", 7), ("beginTime=2026-11-05&endTime=2026-11-04", 7)]: - path = f"/sw/{kind}/list?{query}" - req = urllib.request.Request(args.base_url + path, headers={"x-token": token}) - with urllib.request.urlopen(req, timeout=30) as response: - payload = json.load(response) - data = payload.get("data", {}) - item = {"path": path, "http": response.status, "code": payload.get("code"), "msg": payload.get("msg"), "passed": payload.get("code") == expected} - if "available" in data: - item.update(available=data["available"], total=data.get("total"), begin=data.get("beginTime"), end=data.get("endTime")) - if data["available"] is False: - item["passed"] &= data.get("summary") is None - checks.append(item) - report = {"scope": "真实 sw7 会话,只读请求;完整统计及权限口径另由隔离 TCP HTTP 测试验证。", "checks": checks, "passed": all(item["passed"] for item in checks)} - 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_analytics_page_smoke.cjs b/scripts/sw_analytics_page_smoke.cjs deleted file mode 100644 index 91dcaf4..0000000 --- a/scripts/sw_analytics_page_smoke.cjs +++ /dev/null @@ -1,62 +0,0 @@ -// 真实 Chrome 核对统计筛选、排序、分页和全量汇总;业务接口使用受控数据。 -const fs = require('node:fs/promises') -const path = require('node:path') -const assert = require('node:assert/strict') -const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright') - -;(async () => { - const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim() - const browser = await chromium.launch({ channel: 'chrome', headless: true }) - const reports = [] - try { - for (const kind of ['channel', 'game']) { - const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } }) - await context.addInitScript(value => localStorage.setItem('token', value), token) - const page = await context.newPage(), queries = [], errors = [] - let unavailable = false - const routeName = kind === 'channel' ? 'channelData' : 'gameData' - page.on('pageerror', error => errors.push(error.message)) - await page.route('http://127.0.0.1:8080/api/**', async route => { - const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4) - if (api === `/sw/${routeName}/list`) { - queries.push(Object.fromEntries(url.searchParams)) - const pageNum = Number(url.searchParams.get('pageNum') || 1) - const metrics = { newRegisterCount: 2, activeCount: 4, payUserCount: 1, newPayUserCount: 1, totalPay: '10.00', payRate: '25.00', arpu: '2.50', arppu: '10.00' } - const row = { ...metrics, sourceDb: 'lx', sourceDbLabel: '龙翔', promoteId: pageNum, promoteName: `测试渠道${pageNum}`, promoteType: 1, busierName: '游戏商务', gameId: pageNum, gameName: `测试游戏${pageNum}` } - const summary = { ...metrics, gameName: '全部游戏', promoteName: '全部渠道', newRegisterCount: 60, activeCount: 100, payUserCount: 20, newPayUserCount: 10, totalPay: '1000.00', payRate: '20.00', arpu: '10.00', arppu: '50.00' } - return route.fulfill({ json: { code: 0, data: { rows: unavailable ? [] : [row], total: unavailable ? 0 : 21, channelCount: unavailable ? null : 21, totalRegisterCount: unavailable ? null : 60, totalPayAmount: unavailable ? null : '1000.00', avgPayRate: unavailable ? null : '20.00', summary: unavailable ? null : summary, beginTime: url.searchParams.get('beginTime'), endTime: url.searchParams.get('endTime'), available: !unavailable } } }) - } - if (api === '/sw/serverAnalysis/games') return route.fulfill({ json: { code: 0, data: [{ sourceDb: 'lx', gameId: 1, gameName: '筛选游戏' }] } }) - const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } }) - return route.fulfill({ response }) - }) - await page.goto(`http://127.0.0.1:8080/#/layout/sw-DataAnalysis/${kind === 'channel' ? 'ChannelDataStatistics' : 'GameAnalytics'}`) - await page.getByText(kind === 'channel' ? '测试渠道1' : '测试游戏1', { exact: true }).waitFor() - assert.equal((Date.parse(queries[0].endTime) - Date.parse(queries[0].beginTime)) / 86400000, 29) - const totals = kind === 'channel' ? page.locator('.report-cards') : page.locator('.el-table__footer-wrapper') - await totals.getByText('¥1,000.00', { exact: true }).waitFor() - assert.equal(await page.getByRole('button', { name: '导出', exact: true }).count(), 0) - await page.locator('.el-pagination .btn-next').click() - await page.getByText(kind === 'channel' ? '测试渠道2' : '测试游戏2', { exact: true }).waitFor() - assert.equal(queries.at(-1).pageNum, '2') - await totals.getByText('¥1,000.00', { exact: true }).waitFor() - const form = page.locator('.kra-search-box') - await form.locator('.el-form-item').filter({ has: page.locator('label').getByText('游戏', { exact: true }) }).locator('.el-select__wrapper').click() - await page.getByRole('option', { name: '筛选游戏', exact: true }).click(); await page.keyboard.press('Escape') - if (kind === 'channel') await form.getByPlaceholder('渠道账号(模糊匹配)', { exact: true }).fill('测试') - await Promise.all([page.waitForResponse(response => response.url().includes(`/sw/${routeName}/list`) && response.url().includes('gameName=')), form.getByRole('button', { name: kind === 'channel' ? '搜索' : '查询', exact: true }).click()]) - assert.equal(queries.at(-1).pageNum, '1'); assert.equal(queries.at(-1).gameName, '筛选游戏') - if (kind === 'channel') assert.equal(queries.at(-1).promoteName, '测试') - await page.locator('.el-loading-mask').first().waitFor({ state: 'hidden' }) - await page.screenshot({ path: path.join(process.env.TEMP, `sw-${kind}-analytics.png`), animations: 'disabled' }) - unavailable = true - await form.getByRole('button', { name: kind === 'channel' ? '搜索' : '查询', exact: true }).click() - await page.getByText('统计所需业务汇总尚未接入。', { exact: true }).waitFor() - assert.equal(await totals.getByText('¥1,000.00', { exact: true }).count(), 0) - assert.deepEqual(errors, []) - const report = { kind, passed: true, fullSummaryAcrossPages: true, filtersResetPage: true, platformAndGameFilters: true, noInventedExport: true, missingDataNotZero: true, pageErrors: errors } - reports.push(report); console.log(JSON.stringify(report)); await context.close() - } - await fs.writeFile('docs/sw6-to-kra/analytics-page-2026-11-04.json', JSON.stringify({ method: '真实 Chrome 渲染,受控业务数据;未写入真实数据', reports }, null, 2) + '\n') - } finally { await browser.close() } -})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/sw_api_compare.py b/scripts/sw_api_compare.py new file mode 100644 index 0000000..a851503 --- /dev/null +++ b/scripts/sw_api_compare.py @@ -0,0 +1,201 @@ +"""请求隔离的新旧商务实例;原始响应和令牌留在仓库外,报告只记录校验结果。""" + +import argparse +import concurrent.futures +import decimal +import hashlib +import json +import re +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +def fetch(base, token, path, params, legacy, client_id="", timeout=120): + url = base.rstrip("/") + path + if params: + url += "?" + urllib.parse.urlencode(params, doseq=True) + headers = {"Authorization": "Bearer " + token} if legacy else {"x-token": token} + if legacy: + headers["clientid"] = client_id + request = urllib.request.Request(url, headers=headers) + started = time.monotonic() + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = json.load(response) + return {"http": response.status, "elapsed_ms": round((time.monotonic() - started) * 1000), "body": body} + except (urllib.error.URLError, TimeoutError, ValueError) as error: + return {"http": None, "elapsed_ms": round((time.monotonic() - started) * 1000), "error": type(error).__name__} + + +def data_of(reply): + body = reply.get("body", {}) + if "rows" in body: + return {key: value for key, value in body.items() if key not in ("code", "msg")} + return body.get("data") + + +def shape(value): + if isinstance(value, dict): + return {key: (len(item) if isinstance(item, list) else type(item).__name__) for key, item in value.items()} + return {"type": type(value).__name__, "length": len(value) if isinstance(value, list) else None} + + +def expected_relative_pay_rate(row): + """用户确认的新口径:由旧的本期付费率及百分点差推导相对环比。""" + if row.get("momValue") is None: + return row + current = decimal.Decimal(str(row["metricValue"])) + difference = decimal.Decimal(str(row["momValue"])) + previous = current - difference + result = dict(row) + if previous <= 0: + result.update(momValue=None, momStatus=None) + else: + change = (difference / previous * 100).quantize(decimal.Decimal("0.01"), rounding=decimal.ROUND_HALF_UP) + result.update(momValue=format(abs(change) if change == 0 else change, ".2f"), momStatus=0 if change == 0 else 1 if change > 0 else -1) + return result + + +def compare_data(source, target, case, users): + differences = [] + omitted = {"source": case.get("source_omit_fields", []), "target": case.get("target_omit_fields", [])} + # 两边通过 HTTP 分别创建的专用测试账号,其创建时刻天然不同。 + # 只忽略显式列出的夹具账号时间,真实迁移账号的所有时间字段仍须相等。 + generated = {str(value) for value in case.get("generated_user_ids", [])} + generated_ids = {"source": generated, "target": {users.get(value, value) for value in generated}} + user_fields = set(case.get("user_fields", ["userId", "parentId", "creatorId", "businessId", "operatorId", "parentUserId"])) + + def normalize(value, side, field="", path=""): + if isinstance(value, dict): + if side == "source" and field == "payRateRankList" and case.get("relative_pay_rate"): + value = expected_relative_pay_rate(value) + if side == "target" and value.get("available") is False: + differences.append({"path": path + ".available", "kind": "unavailable"}) + if side == "source" and case.get("normalize_root_path") and value.get("level") in (0, 1, "0", "1") and value.get("idPath") is None: + value = {**value, "idPath": "0"} + excluded = set(omitted[side]) + if str(value.get("userId")) in generated_ids[side]: + excluded.add("createTime") + return {key: normalize(item, side, key, path + "." + key) for key, item in value.items() if key not in excluded} + if isinstance(value, list): + rows = [normalize(item, side, field, path) for item in value] + keys = case.get("row_key") + if keys and rows and all(isinstance(row, dict) and all(key in row for key in keys) for row in rows): + rows.sort(key=lambda row: tuple(str(row[key]) for key in keys)) + return rows + if value is None and field in case.get("null_empty_fields", []): + return "" + if side == "source" and case.get("map_users", True) and value is not None: + if field in user_fields: + value = users.get(str(value), str(value)) + elif field == "idPath": + value = ",".join(users.get(item, item) for item in str(value).split(",")) + return value + + def walk(left, right, path="data"): + # 只兼容数值与 JSON 数字字符串;账号、手机号、区服编号等字符串保留前导零。 + numeric_field = path.rsplit(".", 1)[-1] in case.get("numeric_fields", []) + if not isinstance(left, bool) and not isinstance(right, bool) and (numeric_field or isinstance(left, (int, float)) or isinstance(right, (int, float))): + try: + if decimal.Decimal(str(left)) == decimal.Decimal(str(right)): + return + except decimal.InvalidOperation: + pass + if type(left) is not type(right): + differences.append({"path": path, "kind": "type", "source": type(left).__name__, "target": type(right).__name__}) + elif isinstance(left, dict): + for key in sorted(left.keys() | right.keys()): + if key not in left or key not in right: + differences.append({"path": path + "." + key, "kind": "missing_field", "side": "source" if key not in left else "target"}) + else: + walk(left[key], right[key], path + "." + key) + elif isinstance(left, list): + if len(left) != len(right): + differences.append({"path": path, "kind": "length", "source": len(left), "target": len(right)}) + for i, (a, b) in enumerate(zip(left, right)): + walk(a, b, path + f"[{i}]") + elif left != right: + # 数据值只留在仓库外;公开报告保留字段路径和摘要供复核。 + differences.append({"path": path, "kind": "value", "source_sha256": hashlib.sha256(str(left).encode()).hexdigest(), "target_sha256": hashlib.sha256(str(right).encode()).hexdigest()}) + + walk(normalize(source, "source"), normalize(target, "target")) + return {"semantic_equal": not differences, "difference_count": len(differences), "differences": differences[:30], + "normalization": {"user_ids_mapped": case.get("map_users", True), "numeric_representation": True, + "user_fields": sorted(user_fields), "numeric_string_fields": case.get("numeric_fields", []), + "normalize_root_path": case.get("normalize_root_path", False), + "expected_relative_pay_rate": case.get("relative_pay_rate", False), + "generated_user_creation_times": len(generated), + "row_key": case.get("row_key"), "omitted_fields": omitted, "null_empty_fields": case.get("null_empty_fields", [])}} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--cases", type=Path, required=True) + parser.add_argument("--identity", default="admin") + parser.add_argument("--old-url", default="http://127.0.0.1:18080") + parser.add_argument("--new-url", default="http://127.0.0.1:19000") + parser.add_argument("--output", type=Path, required=True) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--recompare", action="store_true", help="使用已保存的同参数 HTTP 响应重新核对,不重复访问数据库") + mode.add_argument("--refresh-target", action="store_true", help="保留已采集的同参数旧响应,重新请求优化后的新实现;新增用例仍请求两边") + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + if args.runtime.resolve().is_relative_to(root): + raise ValueError("原始业务响应必须保存在仓库外") + tokens = [(args.runtime / (prefix + "-" + args.identity + ".token")).read_text(encoding="utf-8").strip() for prefix in ("legacy", "new")] + client_id = json.loads((args.runtime / "legacy-client.json").read_text(encoding="utf-8"))["client_id"] + users = {str(row["old_id"]): str(row["new_id"]) for row in json.loads((args.runtime / "id-maps.json").read_text(encoding="utf-8")) if row["entity"] == "user"} + cases = json.loads(args.cases.read_text(encoding="utf-8")) + reports = [] + folder = args.runtime / ("responses-" + args.identity) + folder.mkdir(exist_ok=True) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + for case in cases: + capture = folder / (case["name"] + ".json") + reused_source = args.refresh_target and capture.exists() + if args.recompare or reused_source: + saved = json.loads(capture.read_text(encoding="utf-8")) + if any(saved["case"].get(key) != case.get(key) for key in ("source", "target", "params", "source_params", "target_params")): + raise ValueError("已有 HTTP 响应的请求条件不符:" + case["name"]) + replies = [saved["source"], saved["target"]] + if args.refresh_target: + replies[1] = fetch(args.new_url, tokens[1], case["target"], case.get("target_params", case.get("params", {})), False, timeout=case.get("timeout_seconds", 120)) + capture.write_text(json.dumps({"case": case, "source": replies[0], "target": replies[1]}, ensure_ascii=False, indent=2), encoding="utf-8") + else: + pairs = [pool.submit(fetch, base, tokens[i], case[side], case.get(side + "_params", case.get("params", {})), i == 0, client_id, case.get("timeout_seconds", 120)) + for i, (base, side) in enumerate(((args.old_url, "source"), (args.new_url, "target")))] + replies = [job.result() for job in pairs] + capture.write_text(json.dumps({"case": case, "source": replies[0], "target": replies[1]}, ensure_ascii=False, indent=2), encoding="utf-8") + values = [data_of(reply) for reply in replies] + for i, side in enumerate(("source", "target")): + for key in case.get(side + "_data_path", []): + values[i] = values[i][key] + codes = [reply.get("body", {}).get("code") for reply in replies] + report = {"case": case["name"], "source": case["source"], "target": case["target"], "codes": codes, + "source_capture_reused": reused_source, + "http_passed": codes == [200, 0], "elapsed_ms": [reply["elapsed_ms"] for reply in replies], + "shapes": [shape(value) for value in values], + "exact_data_equal": values[0] == values[1]} + if all(isinstance(value, dict) and "total" in value for value in values): + report["totals"] = [value["total"] for value in values] + report["totals_equal"] = str(values[0]["total"]) == str(values[1]["total"]) + reports.append(report) + report.update(compare_data(values[0], values[1], case, users)) + report["passed"] = report["http_passed"] and report["semantic_equal"] + if case.get("expected_error_codes"): + messages = [reply.get("body", {}).get("msg") for reply in replies] + report["rejection_equal"] = codes == case["expected_error_codes"] and messages[0] == messages[1] + report["expected_error_codes"] = case["expected_error_codes"] + report["passed"] = report["rejection_equal"] + print(json.dumps(report, ensure_ascii=False), flush=True) + args.output.write_text(json.dumps({"identity": args.identity, "checks": reports}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if not all(report["passed"] for report in reports): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_channel_http_smoke.py b/scripts/sw_channel_http_smoke.py deleted file mode 100644 index de789d1..0000000 --- a/scripts/sw_channel_http_smoke.py +++ /dev/null @@ -1,52 +0,0 @@ -"""实测渠道只读接口及必然拒绝的无效创建请求,不写入实际平台渠道。""" - -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() - checks = [] - - def call(method, path, expected=0, body=None): - request = urllib.request.Request(args.base_url + path, method=method, - headers={"x-token": token, "Content-Type": "application/json"}, - data=json.dumps(body).encode() if body is not None else None) - with urllib.request.urlopen(request, timeout=30) as response: - payload = json.load(response) - item = {"method": method, "path": path, "http": response.status, "code": payload.get("code"), - "msg": payload.get("msg"), "passed": response.status == 200 and payload.get("code") == expected} - data = payload.get("data", {}) - if isinstance(data, dict) and "rows" in data: - item.update(total=data["total"], available=data.get("available")) - if isinstance(data, list): - item["rows"] = len(data) - checks.append(item) - print(json.dumps(item, ensure_ascii=False)) - return data - - data = call("GET", "/sw/promote/list?pageNum=1&pageSize=10") - call("GET", "/sw/promote/list?sourceDb=lx,hw&lockStatus=-1&promoteLevel=1") - call("GET", "/sw/promote/list?busierAccount=does-not-exist") - for source in ["lx", "hw", "yq"]: - call("GET", "/sw/promote/bindOptions?sourceDb=" + source) - call("GET", "/sw/promote/game-distribution?sourceDb=lx&promoteId=0", expected=7) - if isinstance(data, dict) and data.get("available") is False: - call("GET", "/sw/promote/game-distribution?sourceDb=lx&promoteId=1", expected=7) - call("POST", "/sw/promote", expected=7, body={}) - report = {"scope": "当前 sw7 实际登录会话;只读查询和无效创建。完整查询、偏好、游戏分布及源库创建已在隔离数据库通过 TCP HTTP 验证。", - "checks": checks, "passed": all(item["passed"] for item in checks)} - args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - if not report["passed"]: - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/sw_channel_page_smoke.cjs b/scripts/sw_channel_page_smoke.cjs deleted file mode 100644 index e2b3700..0000000 --- a/scripts/sw_channel_page_smoke.cjs +++ /dev/null @@ -1,94 +0,0 @@ -// 独立 Chrome 对照渠道列表、添加抽屉和游戏分布;平台创建接口使用测试替身。 -const fs = require('node:fs/promises') -const path = require('node:path') -const assert = require('node:assert/strict') -const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright') - -;(async () => { - const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim() - const browser = await chromium.launch({ channel: 'chrome', headless: true }) - try { - const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } }) - await context.addInitScript(value => localStorage.setItem('token', value), token) - const page = await context.newPage() - const errors = [], queries = [], gameQueries = [], writes = [] - let available = true - const rows = [ - { id: 100, sourceDb: 'lx', platformName: '龙翔', account: 'lx-channel', nickname: '测试渠道', promoteLevel: 1, busierDisplayName: 'SDK-LX(测试商务)', parentName: '', realName: '负责人', registerType: 0, gameCount: 2, playerCount: 3, totalPayAmount: '30.30', lockStatus: 0 }, - { id: 100, sourceDb: 'hw', platformName: '汇玩', account: 'hw-channel', promoteLevel: 1, registerType: 1, gameCount: 0, playerCount: null, totalPayAmount: null, lockStatus: -1 } - ] - page.on('pageerror', error => errors.push(error.message)) - await page.route('http://127.0.0.1:8080/api/**', async route => { - const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4) - const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } }) - if (api === '/sw/promote/list') { queries.push(url.search); return ok({ rows: available ? rows : [], total: available ? 2 : 0, available }) } - if (api === '/sw/promote/bindOptions') { - const source = url.searchParams.get('sourceDb') - const option = { sourceDb: source, platformBusierId: source === 'lx' ? 14 : 18, platformBusierAccount: source === 'lx' ? 'SDK-LX' : 'SDK-HW', userId: 70009 } - return ok([option, option]) - } - if (api === '/sw/promote/game-distribution') { - gameQueries.push({ sourceDb: url.searchParams.get('sourceDb'), promoteId: url.searchParams.get('promoteId') }) - return ok(url.searchParams.get('sourceDb') === 'lx' ? [{ gameId: 1, gameName: '测试游戏', registerCount: 2, payAmount: '70.00' }] : []) - } - if (api === '/sw/promote' && request.method() === 'POST') { writes.push(request.postDataJSON()); return ok({ id: 200, sourceDb: 'lx' }) } - const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } }) - if (api === '/user/getUserInfo') { - const body = await response.json() - Object.assign(body.data.userInfo, { ID: 70001, level: 1, authorityId: 901, nickName: '页面核对账号', phone: '', email: '' }) - return route.fulfill({ response, json: body }) - } - return route.fulfill({ response }) - }) - await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/ChannelList') - await page.getByText('lx-channel', { exact: true }).waitFor() - const lx = page.locator('.el-table__body-wrapper tr').filter({ hasText: 'lx-channel' }) - const hw = page.locator('.el-table__body-wrapper tr').filter({ hasText: 'hw-channel' }) - assert.equal(await page.getByRole('button', { name: '编辑', exact: true }).count(), 0) - assert.equal(await hw.getByText('未接入', { exact: true }).count(), 2) - await lx.getByRole('button', { name: '2 款', exact: true }).click() - const games = page.getByRole('dialog', { name: '推广游戏数据 — 测试渠道', exact: true }) - await games.getByText('测试游戏', { exact: true }).waitFor() - await games.locator('.el-loading-mask').waitFor({ state: 'hidden' }) - await games.getByText('¥70.00', { exact: true }).waitFor() - assert.deepEqual(gameQueries.at(-1), { sourceDb: 'lx', promoteId: '100' }) - await page.screenshot({ path: path.join(process.env.TEMP, 'sw-channel-games.png'), animations: 'disabled' }) - await games.locator('.el-drawer__close-btn').click(); await games.waitFor({ state: 'hidden' }) - await hw.getByRole('button', { name: '0 款', exact: true }).click() - const emptyGames = page.getByRole('dialog', { name: '推广游戏数据 — hw-channel', exact: true }) - await emptyGames.waitFor() - await emptyGames.locator('.el-loading-mask').waitFor({ state: 'hidden' }) - assert.deepEqual(gameQueries.at(-1), { sourceDb: 'hw', promoteId: '100' }) - await emptyGames.locator('.el-drawer__close-btn').click(); await emptyGames.waitFor({ state: 'hidden' }) - await page.getByRole('button', { name: '添加渠道', exact: true }).click() - const drawer = page.getByRole('dialog', { name: '添加渠道', exact: true }) - const select = async (label, option) => { - await drawer.locator('.el-form-item').filter({ hasText: label }).locator('.el-select__wrapper').click() - await page.getByRole('option', { name: option, exact: true }).click() - } - await select('所属平台', '龙翔') - await select('游戏商务', 'SDK-LX') - await select('所属平台', '汇玩') - assert.equal(await drawer.getByText('SDK-LX', { exact: true }).count(), 0) - await select('游戏商务', 'SDK-HW') - await select('所属平台', '龙翔') - await select('游戏商务', 'SDK-LX') - await select('推广类型', '企业') - await drawer.getByPlaceholder('中文/英文/数字,4-30位', { exact: true }).fill('中文渠道') - await drawer.getByPlaceholder('6-15位', { exact: true }).fill('demo123') - await drawer.getByRole('button', { name: '确认添加', exact: true }).click() - await drawer.waitFor({ state: 'hidden' }) - assert.equal(writes.length, 1) - assert.equal(writes[0].platformBusierId, 14) - assert.equal(writes[0].sourceDb, 'lx') - assert.equal(writes[0].registerType, 1) - available = false - await page.locator('.kra-search-box').getByRole('button', { name: '搜索', exact: true }).click() - await page.getByText('渠道数据尚未接入。', { exact: true }).waitFor() - assert.deepEqual(errors, []) - const result = { passed: true, level: 1, noEditEntry: true, createAllowedForUpperBusiness: true, platformSwitchClearsBinding: true, crossPlatformSameID: true, zeroGameCountClickable: true, missingMetricsVisible: true, sourceDatabaseCreatePayload: true, portraitPending: false, pageErrors: errors } - await fs.writeFile('docs/sw6-to-kra/channel-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome 渲染;受控页面数据、身份及平台创建响应,不创建真实平台渠道', ...result }, null, 2) + '\n') - console.log(JSON.stringify(result)) - await context.close() - } finally { await browser.close() } -})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/sw_channel_portrait_http_smoke.py b/scripts/sw_channel_portrait_http_smoke.py deleted file mode 100644 index 2f149b1..0000000 --- a/scripts/sw_channel_portrait_http_smoke.py +++ /dev/null @@ -1,47 +0,0 @@ -"""只读验证渠道画像路由及现有数据的缺表处理,不修改业务记录。""" -import argparse -import json -import urllib.parse -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() - checks = [] - - def get(path, expected=0): - req = urllib.request.Request(args.base_url + path, headers={"x-token": token}) - with urllib.request.urlopen(req, timeout=30) as response: - result = json.load(response) - item = {"path": path, "http": response.status, "code": result.get("code"), "msg": result.get("msg"), - "passed": response.status == 200 and result.get("code") == expected} - data = result.get("data", {}) - if isinstance(data, dict) and "available" in data: - item["available"] = data["available"] - checks.append(item) - return data - - channels = get("/sw/promote/list?pageNum=1&pageSize=1") - if channels.get("available") is False: - get("/sw/promote/portrait?sourceDb=lx&promoteId=1&period=total", expected=7) - elif channels.get("rows"): - row = channels["rows"][0] - get("/sw/promote/portrait?" + urllib.parse.urlencode({"sourceDb": row["sourceDb"], "promoteId": row["id"], "period": "today"})) - get("/sw/promote/portrait?sourceDb=lx&promoteId=0", expected=7) - get("/sw/promote/portrait?sourceDb=invalid&promoteId=1", expected=7) - report = {"scope": "当前 sw7 登录会话,只读检查;完整累计、分层、日期与权限在隔离 TCP HTTP 中验证。", - "checks": checks, "passed": all(item["passed"] for item in checks)} - 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_channel_portrait_page_smoke.cjs b/scripts/sw_channel_portrait_page_smoke.cjs deleted file mode 100644 index fdd3a26..0000000 --- a/scripts/sw_channel_portrait_page_smoke.cjs +++ /dev/null @@ -1,75 +0,0 @@ -// 真实 Chrome 对照渠道画像;接口数据受控,验证累计、分层及关联预警参数。 -const fs = require('node:fs/promises') -const path = require('node:path') -const assert = require('node:assert/strict') -const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright') - -;(async () => { - const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim() - const browser = await chromium.launch({ channel: 'chrome', headless: true }) - try { - const context = await browser.newContext({ viewport: { width: 1500, height: 1050 } }) - await context.addInitScript(value => localStorage.setItem('token', value), token) - const page = await context.newPage() - const errors = [], requests = [], alerts = [], games = [] - let unavailable = false - page.on('pageerror', error => errors.push(error.message)) - await page.route('http://127.0.0.1:8080/api/**', async route => { - const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4) - const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } }) - if (api === '/sw/promote/list') return ok({ available: true, total: 2, rows: ['lx', 'hw'].map(sourceDb => ({ id: 100, sourceDb, platformName: sourceDb === 'lx' ? '龙翔' : '汇玩', account: `${sourceDb}-channel`, nickname: `${sourceDb}测试渠道`, promoteLevel: 1, gameCount: 2, playerCount: 4, totalPayAmount: '15.00', lockStatus: 0 })) }) - if (api === '/sw/promote/portrait') { - requests.push(Object.fromEntries(url.searchParams)) - const period = url.searchParams.get('period'), source = url.searchParams.get('sourceDb') - const length = period === 'total' ? 21 : period === 'last7' ? 7 : 24 - const trend = Array.from({ length }, (_, i) => ({ label: length === 24 ? `${String(i).padStart(2, '0')}:00` : new Date(Date.UTC(2026, 10, 3 - length + 1 + i)).toISOString().slice(5, 10), value: i === 1 ? '4' : '0' })) - const metric = value => ({ value, compareValue: period === 'total' ? null : '1', changeRate: period === 'total' ? null : '100.0', changeDiff: null, compareLabel: period === 'total' ? null : '较上一周期' }) - return ok({ available: !unavailable, unavailableReason: unavailable ? '付费分层所需数据尚未接入' : '', period, - basic: { promoteId: 100, nickname: `${source}测试渠道`, account: `${source}-channel`, busierDisplayName: 'SDK(商务)', promoteLevelLabel: '一级渠道', registerTypeLabel: '公会', status: -3, statusLabel: '商务新增', gameCount: 2, createTime: '2026-11-01 08:00:00', lastLoginTime: '2026-11-03 08:00:00' }, - metrics: unavailable ? null : { register: metric('4'), payAmount: metric('15.00'), payRate: metric('25.0'), active: metric('4') }, - registerTrend: unavailable ? [] : trend, payTrend: unavailable ? [] : trend.map(point => ({ ...point, value: point.value === '4' ? '15.00' : '0.00' })), - payTiers: unavailable ? [] : ['whale', 'mid', 'small', 'free'].map((tier, index) => ({ tier, tierLabel: ['大R', '中R', '小R', '免费'][index], count: 1, ratio: '25.0' })), gameDistribution: [] }) - } - if (api === '/sw/alertRecord/list') { alerts.push(Object.fromEntries(url.searchParams)); return ok({ available: true, pageData: { rows: [{ id: '1', createdAt: '2026-11-03 10:00:00', ruleType: 'register_anomaly', alertContent: '对应渠道预警', status: 1 }], total: 1 }, statusCount: { 1: 1 } }) } - if (api === '/sw/promote/game-distribution') { games.push(Object.fromEntries(url.searchParams)); return ok([{ gameId: 1, gameName: '累计推广游戏', registerCount: 4, payAmount: '31.00' }]) } - const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } }) - return route.fulfill({ response }) - }) - await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/ChannelList') - const row = source => page.locator('.el-table__body-wrapper tr').filter({ hasText: `${source}-channel` }) - await row('lx').getByRole('button', { name: '画像', exact: true }).click() - const dialog = page.getByRole('dialog', { name: '渠道画像 — lx测试渠道', exact: true }) - await dialog.getByText('活跃玩家', { exact: true }).waitFor() - await dialog.locator('.el-loading-mask').first().waitFor({ state: 'hidden' }) - assert.equal(await dialog.getByText('人均充值', { exact: true }).count(), 0) - assert.equal(await dialog.locator('.pay-tier-item').count(), 4) - await dialog.getByText('对应渠道预警', { exact: true }).waitFor() - assert.equal(alerts.at(-1).targetPromoteId, '100'); assert.equal(alerts.at(-1).platformCodes, 'lx'); assert.equal(alerts.at(-1).businessId, undefined) - await Promise.all([page.waitForResponse(response => response.url().includes('/sw/promote/portrait') && response.url().includes('period=last7')), dialog.getByText('近7天', { exact: true }).click()]) - assert.equal(requests.at(-1).period, 'last7') - await dialog.locator('.el-loading-mask').first().waitFor({ state: 'hidden' }) - await Promise.all([page.waitForResponse(response => response.url().includes('/sw/promote/portrait') && response.url().includes('period=total')), dialog.getByText('累计', { exact: true }).click()]) - await dialog.locator('.el-loading-mask').first().waitFor({ state: 'hidden' }) - assert.equal(await dialog.locator('.metric-compare').count(), 0) - await page.screenshot({ path: path.join(process.env.TEMP, 'sw-channel-portrait.png'), animations: 'disabled' }) - await dialog.locator('.profile-info').getByRole('button', { name: '2 款', exact: true }).click() - const gameDialog = page.getByRole('dialog', { name: '推广游戏数据 — lx测试渠道', exact: true }) - await gameDialog.getByText('累计推广游戏', { exact: true }).waitFor() - assert.equal(games.at(-1).sourceDb, 'lx'); assert.equal(games.at(-1).promoteId, '100') - await gameDialog.locator('.el-drawer__close-btn').click(); await gameDialog.waitFor({ state: 'hidden' }) - await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }) - await row('hw').getByRole('button', { name: '画像', exact: true }).click() - const other = page.getByRole('dialog', { name: '渠道画像 — hw测试渠道', exact: true }) - await other.getByText('对应渠道预警', { exact: true }).waitFor() - assert.equal(requests.at(-1).sourceDb, 'hw'); assert.equal(alerts.at(-1).platformCodes, 'hw') - await other.getByRole('button', { name: '关闭', exact: true }).click(); await other.waitFor({ state: 'hidden' }) - unavailable = true - await row('lx').getByRole('button', { name: '画像', exact: true }).click() - await dialog.getByText('付费分层所需数据尚未接入', { exact: true }).waitFor() - assert.equal(await dialog.locator('.pay-tier-item').count(), 0) - assert.deepEqual(errors, []) - const report = { method: '真实 Chrome,画像、游戏与预警数据为受控响应,不改业务数据', passed: true, activeMetric: true, totalNoComparison: true, canonicalLast7: true, fourPayTiers: true, gameDrawer: true, platformAndChannelAlertFilter: true, crossPlatformSameID: true, unavailableIsNotZero: true, pageErrors: errors } - await fs.writeFile('docs/sw6-to-kra/channel-portrait-page-2026-11-03.json', JSON.stringify(report, null, 2) + '\n') - console.log(JSON.stringify(report)); await context.close() - } finally { await browser.close() } -})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/sw_dashboard_layout_smoke.cjs b/scripts/sw_dashboard_layout_smoke.cjs new file mode 100644 index 0000000..287d050 --- /dev/null +++ b/scripts/sw_dashboard_layout_smoke.cjs @@ -0,0 +1,218 @@ +// 真实开发服务:检查 OA 数据、统计口径持久化与响应式布局。 +// 临时修改当前测试账号的统计口径,结束时恢复;截图只写入指定的私有目录。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') +const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright') + +;(async () => { + const base = process.env.SW_UI_BASE_URL || 'http://127.0.0.1:8080' + const output = process.env.SW_UI_OUTPUT_DIR + assert.ok(output && process.env.SW_TEST_TOKEN_FILE, 'Set SW_UI_OUTPUT_DIR and SW_TEST_TOKEN_FILE') + const token = (await fs.readFile(process.env.SW_TEST_TOKEN_FILE, 'utf8')).trim() + const browser = await chromium.launch({ channel: 'chrome', headless: true }) + const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } }) + const headers = { 'x-token': token } + const api = async (method, endpoint, data) => { + const response = await context.request.fetch(`${base}/api${endpoint}`, { method, headers, data }) + assert.ok(response.ok()) + const body = await response.json() + assert.equal(body.code, 0) + return body.data + } + let original + const report = { checks: [], pageErrors: [] } + try { + await fs.mkdir(output, { recursive: true }) + original = await api('GET', '/sw/user/preference') + await fs.writeFile(path.join(output, 'original-preference.json'), JSON.stringify(original)) + await context.addInitScript(value => localStorage.setItem('token', value), token) + const page = await context.newPage() + page.on('pageerror', e => report.pageErrors.push(e.message)) + const activationPath = '/sw/promoteActivePool/bindList' + await page.goto(`${base}/#/layout/sw-BusMgmt/ChannelActivationList`) + await page.locator('.el-table__body-wrapper tr').first().waitFor() + const activation = await api('GET', `${activationPath}?pageNum=1&pageSize=10&platformNames=lx,hw,yq`) + assert.equal(activation.available, true) + assert.ok(activation.total > 0) + assert.equal(await page.locator('.el-table__body-wrapper tr').count(), activation.rows.length) + assert.equal(await page.locator('.el-alert--warning').count(), 0) + report.checks.push({ name: 'actual OA activation rows', total: activation.total }) + await page.screenshot({ path: path.join(output, 'activation.png'), animations: 'disabled' }) + + const pagination = page.locator('.kra-pagination') + const pageRequests = [] + const trackPage = request => { + if (request.url().includes(activationPath)) pageRequests.push(Number(new URL(request.url()).searchParams.get('pageNum'))) + } + page.on('request', trackPage) + const movePage = async (target, action) => { + pageRequests.length = 0 + const [response] = await Promise.all([ + page.waitForResponse(r => r.url().includes(activationPath) && Number(new URL(r.url()).searchParams.get('pageNum')) === target), + action() + ]) + const expected = (await response.json()).data + await page.waitForFunction(() => !document.querySelector('.el-loading-mask')) + assert.equal((await pagination.locator('.el-pager .is-active').innerText()).trim(), String(target)) + assert.equal(await page.locator('.el-table__body-wrapper tr').count(), expected.rows.length) + const names = await page.locator('.el-table__body-wrapper tr td:nth-child(2) .cell').allTextContents() + assert.deepEqual(names.map(name => name.trim()), expected.rows.map(row => row.promoteAccount)) + assert.deepEqual(pageRequests, [target], 'pagination must not trigger a second request back to page 1') + } + assert.ok(activation.total > 10, 'Pagination check needs more than one page') + await movePage(2, () => pagination.locator('.btn-next').click()) + const lastPage = Math.ceil(activation.total / 10) + if (lastPage > 2) await movePage(lastPage, () => pagination.locator('.el-pager .number').filter({ hasText: new RegExp(`^${lastPage}$`) }).click()) + await page.screenshot({ path: path.join(output, 'activation-last-page.png'), animations: 'disabled' }) + await movePage(lastPage - 1, () => pagination.locator('.btn-prev').click()) + if (lastPage - 1 !== 1) await movePage(1, () => pagination.locator('.el-pager .number').filter({ hasText: /^\s*1\s*$/ }).click()) + page.off('request', trackPage) + report.checks.push({ name: 'activation pagination: next, last, previous, first; response rows match and no page reset', total: activation.total, lastPage }) + + const preference = page.getByRole('group', { name: '统计口径', exact: true }) + for (const [key, label] of [['includeSubPromote', '包含下级渠道'], ['idcardDedup', '身份证去重']]) { + const checkbox = preference.getByRole('checkbox', { name: label, exact: true }) + await page.waitForFunction(label => [...document.querySelectorAll('.business-preferences input')].some(el => el.closest('label')?.textContent.includes(label) && !el.disabled), label) + assert.equal(await checkbox.isChecked(), original[key]) + const [saved] = await Promise.all([ + page.waitForResponse(r => r.url().includes('/sw/user/preference') && r.request().method() === 'PUT'), + page.waitForResponse(r => r.url().includes(activationPath)), + preference.getByText(label, { exact: true }).click() + ]) + assert.equal((await saved.json()).code, 0) + assert.equal((await api('GET', '/sw/user/preference'))[key], !original[key]) + await page.reload() + await preference.waitFor() + await page.waitForFunction(() => [...document.querySelectorAll('.business-preferences input')].every(el => !el.disabled)) + assert.equal(await preference.getByRole('checkbox', { name: label, exact: true }).isChecked(), !original[key]) + report.checks.push({ name: `${key}: visible, persisted, page refreshed` }) + } + await api('PUT', '/sw/user/preference', original) + + // 保存失败不能让勾选状态与服务端失去同步。 + await page.reload() + await page.waitForFunction(() => document.querySelectorAll('.business-preferences input').length === 2 && [...document.querySelectorAll('.business-preferences input')].every(el => !el.disabled)) + const rejectSave = route => route.request().method() === 'PUT' + ? route.fulfill({ json: { code: 7, msg: '测试保存失败', data: {} } }) : route.continue() + await page.route('**/api/sw/user/preference', rejectSave) + await Promise.all([ + page.waitForResponse(r => r.url().includes('/sw/user/preference') && r.request().method() === 'PUT'), + preference.getByText('包含下级渠道', { exact: true }).click() + ]) + await page.waitForFunction(() => [...document.querySelectorAll('.business-preferences input')].every(el => !el.disabled)) + assert.equal(await preference.getByRole('checkbox', { name: '包含下级渠道', exact: true }).isChecked(), original.includeSubPromote) + await page.unroute('**/api/sw/user/preference', rejectSave) + report.checks.push({ name: 'failed preference save preserves previous value' }) + + const search = () => page.locator('.kra-search-box').getByRole('button', { name: '查询', exact: true }).click() + const activationPattern = '**/api/sw/promoteActivePool/bindList?*' + await page.route(activationPattern, route => route.fulfill({ json: { code: 7, msg: '测试查询失败', data: {} } })) + await search() + await page.getByText('查询失败,请重试', { exact: true }).waitFor() + assert.equal(await page.locator('.el-table__body-wrapper tr').count(), 0) + await page.unroute(activationPattern) + await page.route(activationPattern, route => route.fulfill({ json: { code: 0, data: { available: false, rows: [], total: 0 } } })) + await search() + await page.getByText('OA 数据源暂不可用', { exact: true }).waitFor() + await page.unroute(activationPattern) + await search() + await page.locator('.el-table__body-wrapper tr').first().waitFor() + report.checks.push({ name: 'activation error and unavailable states differ from empty results; retry recovers' }) + + for (const [name, route, grid] of [ + ['ranking', 'sw-DataAnalysis/RankingAnalysis', '.ranking-panels'], + ['dashboard', 'dashboard', '.business-ranks'] + ]) { + await page.goto(`${base}/#/layout/${route}`) + await page.locator(grid).waitFor() + await page.waitForFunction(() => !document.querySelector('.el-loading-mask')) + await page.locator('.el-message').evaluateAll(nodes => nodes.forEach(node => node.querySelector('.el-message__closeBtn')?.click())) + if (name === 'ranking') { + for (const panel of await page.locator('.ranking-panel').all()) { + assert.match(await panel.innerText(), /上期环比/) + assert.doesNotMatch(await panel.innerText(), /百分点|\bpp\b/) + } + } else { + assert.equal(await page.getByText('快捷功能', { exact: true }).count(), 0) + assert.equal(await page.getByText('公告', { exact: true }).count(), 0) + assert.equal(await page.getByText('商务数据', { exact: true }).count(), 0) + assert.ok(await page.getByText('数据平台', { exact: true }).isVisible()) + for (const index of [0, 1]) { + const panel = page.locator('.business-ranks > .el-card').nth(index) + await panel.locator('.business-rank-period').click() + const optionsId = await panel.getByRole('combobox').getAttribute('aria-controls') + await Promise.all([ + page.waitForResponse(r => /\/sw\/workbench\/(registerTop|payRank)/.test(r.url()) && r.url().includes('period=last7')), + page.locator(`[id="${optionsId}"]`).getByRole('option', { name: '近7天', exact: true }).click() + ]) + await panel.locator('.ranking-table tbody tr').first().waitFor() + assert.equal(await panel.locator('.ranking-empty').count(), 0) + } + report.checks.push({ name: 'dashboard period filters refresh real data in compact tables' }) + } + for (const [width, columns] of [[1920, 4], [1600, 4], [1366, 4], [1024, 2], [390, 1]]) { + await page.setViewportSize({ width, height: 1000 }) + if (width === 390) await page.getByRole('button', { name: '打开菜单', exact: true }).waitFor() + await page.waitForFunction(({grid, columns}) => getComputedStyle(document.querySelector(grid)).gridTemplateColumns.split(' ').length === columns, { grid, columns }) + const boxes = await page.locator(`${grid} > *`).evaluateAll(nodes => nodes.map(n => { const r = n.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right } })) + if (columns === 4) assert.ok(boxes.every(box => Math.abs(box.y - boxes[0].y) < 1)) + assert.ok(boxes.every(box => box.x >= 0 && box.right <= width + 1), `panel overflow: ${name}, ${width}`) + const overflow = await page.locator(`${grid} .el-table__body-wrapper, ${grid} .ranking-table-wrap`).evaluateAll(nodes => nodes.some(n => n.querySelector('table')?.clientWidth > n.clientWidth + 1)) + assert.equal(overflow, false, `table horizontal overflow: ${name}, ${width}`) + if (name === 'ranking') { + const tallRows = await page.locator('.ranking-table tr').evaluateAll(nodes => nodes.some(n => n.getBoundingClientRect().height > 40)) + assert.equal(tallRows, false, `ranking rows should stay compact: ${width}`) + } + const clippedNumbers = await page.locator(`${grid} tbody .ranking-numeric, ${grid} .ranking-number`).evaluateAll(nodes => nodes.some(n => n.scrollWidth > n.clientWidth + 1)) + assert.equal(clippedNumbers, false, `numeric values must be fully visible: ${name}, ${width}`) + const prefBox = await preference.boundingBox() + assert.ok(prefBox && prefBox.x >= 0 && prefBox.x + prefBox.width <= width, `preference overflow at ${width}`) + const toolbarOverflow = await page.getByRole('button', { name: '打开菜单', exact: true }).count() ? await page.locator('.business-preferences').evaluate(el => { + const header = el.closest('.fixed') + return header.scrollWidth > header.clientWidth + 1 + }) : false + assert.equal(toolbarOverflow, false, `mobile header overflow: ${width}`) + await page.screenshot({ path: path.join(output, `${name}-${width}.png`), animations: 'disabled' }) + report.checks.push({ name: `${name} layout`, width, columns, noOverflow: true }) + } + await page.setViewportSize({ width: 1600, height: 1000 }) + } + await page.goto(`${base}/#/layout/sw-BusMgmt/BusinessList`) + await page.getByRole('button', { name: '添加子商务', exact: true }).waitFor() + assert.equal(await page.locator('.business-view-switch').count(), 0) + assert.equal(await page.getByRole('tab', { name: '商务列表', exact: true }).count(), 0) + await page.screenshot({ path: path.join(output, 'business-list-admin.png'), animations: 'disabled' }) + // 只替换展示身份核对三级专属切换器;读取接口仍用授权测试身份,不做业务写入。 + await page.route('**/api/user/getUserInfo', async route => { + const response = await route.fetch() + const body = await response.json() + Object.assign(body.data.userInfo, { level: 3, authorityId: 903 }) + await route.fulfill({ response, json: body }) + }) + await page.reload() + await page.getByRole('radiogroup', { name: '商务管理视图', exact: true }).waitFor() + assert.equal(await page.getByRole('button', { name: '添加子商务', exact: true }).count(), 0) + const viewSwitch = page.getByRole('radiogroup', { name: '商务管理视图', exact: true }) + await Promise.all([ + page.waitForResponse(r => r.url().includes('/sw/business/bindPlatform')), + viewSwitch.getByText('绑定列表', { exact: true }).click() + ]) + assert.equal(await page.getByPlaceholder('请输入账号', { exact: true }).isVisible(), false) + await viewSwitch.getByText('商务列表', { exact: true }).click() + assert.ok(await page.getByPlaceholder('请输入账号', { exact: true }).isVisible()) + await page.screenshot({ path: path.join(output, 'business-list-level3.png'), animations: 'disabled' }) + report.checks.push({ name: 'business list: no redundant single tab; controlled level-3 switch works' }) + assert.deepEqual(report.pageErrors, []) + report.passed = true + } catch (e) { + const page = context.pages()[0] + if (page) await page.screenshot({ path: path.join(output, 'failure.png'), animations: 'disabled' }).catch(() => {}) + throw e + } finally { + if (original) await api('PUT', '/sw/user/preference', original) + await fs.writeFile(path.join(output, 'ui-checks.json'), JSON.stringify(report, null, 2) + '\n') + await browser.close() + } + console.log(JSON.stringify(report)) +})().catch(e => { console.error(e.stack); process.exitCode = 1 }) diff --git a/scripts/sw_database_rehearsal.py b/scripts/sw_database_rehearsal.py new file mode 100644 index 0000000..a1f1526 --- /dev/null +++ b/scripts/sw_database_rehearsal.py @@ -0,0 +1,188 @@ +"""在同一测试服务器核对 SW 源副本、备份目标库并执行迁移;凭据只读取本地配置。""" + +import argparse +import datetime as dt +import gzip +import hashlib +import json +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pymysql +import yaml + + +def identifier(value): + if not re.fullmatch(r"[A-Za-z0-9_]+", value): + raise ValueError("数据库和表名只能包含字母、数字、下划线") + return "`" + value + "`" + + +def write_json(path, data): + path.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8") + + +def connect(config): + return pymysql.connect(host=config["host"], port=int(config["port"]), user=config["user"], + password=config["password"], charset="utf8mb4", connect_timeout=10, + read_timeout=180, autocommit=True, cursorclass=pymysql.cursors.DictCursor) + + +def query(conn, sql, args=None): + with conn.cursor() as cursor: + cursor.execute(sql, args) + return cursor.fetchall() + + +def source_tables(sql): + tables = re.findall(r"CREATE TABLE `bak_sw2kra_20261103_([^`]+)` LIKE", sql) + if len(tables) != 26 or len(set(tables)) != 26: + raise ValueError("迁移源表清单发生变化,请先复核备份范围") + return tables + + +def fingerprint(conn, database, table): + columns = query(conn, "SELECT column_name,data_type FROM information_schema.columns WHERE table_schema=%s AND table_name=%s ORDER BY ordinal_position", (database, table)) + keys = query(conn, "SELECT column_name FROM information_schema.statistics WHERE table_schema=%s AND table_name=%s AND index_name='PRIMARY' ORDER BY seq_in_index", (database, table)) + if not keys: + raise ValueError("备份核对要求稳定主键:" + table) + order = ",".join(identifier(item["column_name"]) for item in keys) + digest = hashlib.sha256() + rows = query(conn, f"SELECT * FROM {identifier(database)}.{identifier(table)} ORDER BY {order}") + json_columns = [item["column_name"] for item in columns if item["data_type"] == "json"] + for row in rows: + # JSON 的内部二进制布局在导入后可能不同,比较实际业务值而非 CHECKSUM 的存储字节。 + for name in json_columns: + if row[name] is not None: + row[name] = json.loads(row[name]) + digest.update(json.dumps(row, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":")).encode()) + digest.update(b"\n") + return {"rows": len(rows), "sha256": digest.hexdigest()} + + +def client_options(config, folder): + # 使用独立选项文件,避免密码出现在进程命令行或输出中。 + def escape(value): + return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r") + handle = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".cnf", dir=folder, delete=False) + with handle: + handle.write("[client]\n") + for key in ("host", "port", "user", "password"): + handle.write(f'{key}="{escape(config[key])}"\n') + handle.write("default-character-set=utf8mb4\n") + return Path(handle.name) + + +def prepare(args, config, sql): + if (args.state / (args.target + "-migration-source.sql.gz")).exists(): + raise ValueError("目标备份已存在;请使用新的状态目录,保留原始备份") + tables = source_tables(sql) + manifest = {"source": args.source, "target": args.target, "created_at": dt.datetime.now(dt.timezone.utc), + "sql_sha256": hashlib.sha256(sql.encode()).hexdigest(), "checks": []} + with connect(config) as conn: + manifest["server"] = query(conn, "SELECT @@hostname hostname,@@version version")[0] + if query(conn, "SELECT 1 FROM information_schema.tables WHERE table_schema=%s AND table_name='sys_users'", (args.target,)): + raise ValueError("目标已迁移,不能覆盖原始基线") + for table in tables: + checksums = query(conn, f"CHECKSUM TABLE {identifier(args.source)}.{identifier(table)}, {identifier(args.target)}.{identifier(table)} EXTENDED") + values = [row["Checksum"] for row in checksums] + fingerprints = [fingerprint(conn, db, table) for db in (args.source, args.target)] + item = {"table": table, "rows": [item["rows"] for item in fingerprints], "checksums": values, + "fingerprints": fingerprints, "passed": fingerprints[0] == fingerprints[1]} + manifest["checks"].append(item) + print(json.dumps(item, ensure_ascii=False), flush=True) + if not all(item["passed"] for item in manifest["checks"]): + write_json(args.state / "baseline.json", manifest) + raise RuntimeError("源库与目标库框架数据不一致,未执行迁移") + manifest["tables_before"] = query(conn, "SELECT table_name,engine FROM information_schema.tables WHERE table_schema=%s ORDER BY table_name", (args.target,)) + schemas = {} + for row in manifest["tables_before"]: + table = row["table_name"] + schemas[table] = query(conn, f"SHOW CREATE TABLE {identifier(args.target)}.{identifier(table)}")[0]["Create Table"] + write_json(args.state / "schema-before.json", schemas) + dump = Path(shutil.which("mysqldump") or Path(shutil.which("mysql")).with_name("mysqldump.exe")) + options = client_options(config, args.state) + backup = args.state / (args.target + "-migration-source.sql.gz") + try: + command = [str(dump), "--defaults-extra-file=" + str(options), "--single-transaction", "--quick", "--hex-blob", + "--set-gtid-purged=OFF", "--skip-lock-tables", args.target, *tables] + with (args.state / "backup.stderr.log").open("wb") as errors: + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=errors) + with gzip.open(backup, "wb") as output: + shutil.copyfileobj(process.stdout, output) + if process.wait() != 0: + raise RuntimeError("备份失败,详情仅保存在本地日志") + # 完整解压检查,避免把未写完的压缩文件当作成功备份。 + with gzip.open(backup, "rb") as stream: + while stream.read(1024 * 1024): + pass + manifest["backup"] = {"path": str(backup.resolve()), "bytes": backup.stat().st_size, + "sha256": hashlib.sha256(backup.read_bytes()).hexdigest()} + write_json(args.state / "baseline.json", manifest) + print(json.dumps({"backup": manifest["backup"], "passed": True}), flush=True) + finally: + options.unlink(missing_ok=True) + + +def migrate(args, config, sql): + manifest = json.loads((args.state / "baseline.json").read_text(encoding="utf-8")) + if (manifest["source"], manifest["target"]) != (args.source, args.target): + raise ValueError("备份对应的数据库与本次选择不符") + backup = Path(manifest["backup"]["path"]) + if hashlib.sha256(backup.read_bytes()).hexdigest() != manifest["backup"]["sha256"]: + raise ValueError("备份文件校验失败") + statements = re.findall(r"(?m)^USE `[^`]+`;", sql) + if len(statements) != 1: + raise ValueError("SQL 必须只有一个显式 USE 入口") + prepared = sql.replace(statements[0], "USE " + identifier(args.target) + ";") + (args.state / "executed-migration.sql").write_text(prepared, encoding="utf-8") + with connect(config) as conn: + actual_server = query(conn, "SELECT @@hostname hostname,@@version version")[0] + if actual_server != manifest["server"]: + raise ValueError("连接服务器与备份时不同") + # 仅首次执行需要核对原始基线;重复执行由迁移 SQL 的完成账本保护。 + migrated = query(conn, "SELECT 1 FROM information_schema.tables WHERE table_schema=%s AND table_name='sys_users'", (args.target,)) + if not migrated: + for item in manifest["checks"]: + if fingerprint(conn, args.target, item["table"]) != item["fingerprints"][1]: + raise ValueError("目标数据在备份后变化:" + item["table"]) + options = client_options(config, args.state) + try: + with (args.state / "migration.stdout.log").open("wb") as output, (args.state / "migration.stderr.log").open("wb") as errors: + result = subprocess.run([shutil.which("mysql"), "--defaults-extra-file=" + str(options), "--batch", "--binary-mode"], + input=prepared.encode("utf-8"), stdout=output, stderr=errors) + print(json.dumps({"target": args.target, "exit_code": result.returncode, + "sql_sha256": hashlib.sha256(prepared.encode()).hexdigest()}), flush=True) + if result.returncode: + raise RuntimeError("迁移 SQL 失败,请检查本地日志;不会自动覆盖部分迁移状态") + finally: + options.unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("prepare", "migrate")) + parser.add_argument("--config", type=Path, default=Path("configs/config.yaml")) + parser.add_argument("--sql", type=Path, default=Path("docs/sw6-to-kra/sw_to_kra_migrate.sql")) + parser.add_argument("--source", default="sw8") + parser.add_argument("--target", default="sw9") + parser.add_argument("--state", type=Path, required=True) + args = parser.parse_args() + identifier(args.source) + identifier(args.target) + if args.source == args.target or args.target in ("sw", "sw6"): + raise ValueError("演练目标必须为独立测试副本") + args.state = args.state.resolve() + if args.state.is_relative_to(Path(__file__).resolve().parents[1]): + raise ValueError("含账号数据的备份必须保存在仓库外") + args.state.mkdir(parents=True, exist_ok=True) + config = yaml.safe_load(args.config.read_text(encoding="utf-8"))["data"]["database"] + sql = args.sql.read_text(encoding="utf-8") + {"prepare": prepare, "migrate": migrate}[args.action](args, config, sql) + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_endpoint_benchmark.py b/scripts/sw_endpoint_benchmark.py new file mode 100644 index 0000000..a0f7f60 --- /dev/null +++ b/scripts/sw_endpoint_benchmark.py @@ -0,0 +1,76 @@ +"""只读请求商务接口,记录耗时、完整数据摘要及 SQL;敏感内容必须存放仓库外。""" + +import argparse +import hashlib +import json +import re +import statistics +import time +import urllib.parse +import urllib.request +from pathlib import Path + + +def log_offsets(directory): + return {p: p.stat().st_size for p in directory.rglob("*.log")} + + +def read_queries(directory, offsets): + queries = [] + files = list(directory.rglob("*.log")) + files = [file for file in files if file.parent.name == "sql"] or files + for file in files: + with file.open("rb") as stream: + stream.seek(offsets.get(file, 0)) + for line in stream: + try: + row = json.loads(line) + except (ValueError, UnicodeDecodeError): + continue + if row.get("gorm_logger") and "sql" in row: + queries.append(row) + return queries + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--base-url", default="http://127.0.0.1:19004") + parser.add_argument("--phase", required=True) + parser.add_argument("--repeat", type=int, default=2) + parser.add_argument("--cases", type=Path) + args = parser.parse_args() + if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]): + raise ValueError("完整接口响应和 SQL 必须留在仓库外") + output = args.runtime / args.phase + output.mkdir(exist_ok=True) + cases = json.loads((args.cases or args.runtime / "cases.json").read_text(encoding="utf-8")) + token = (args.runtime / "new-admin.token").read_text(encoding="utf-8").strip() + report = [] + for case in cases: + elapsed = [] + for round_number in range(args.repeat): + offsets = log_offsets(args.runtime / "sql-logs") + url = args.base_url + case["path"] + "?" + urllib.parse.urlencode(case["params"], doseq=True) + request = urllib.request.Request(url, headers={"x-token": token, "X-Request-Id": f"perf-{args.phase}-{case['name']}-{round_number}"}) + started = time.monotonic() + with urllib.request.urlopen(request, timeout=300) as response: + body = json.load(response) + elapsed.append(round((time.monotonic() - started) * 1000, 2)) + if body.get("code") != 0: + raise RuntimeError("请求失败:" + case["name"]) + queries = read_queries(args.runtime / "sql-logs", offsets) + data = body["data"] + capture = {"case": case, "data": data, "elapsedMs": elapsed[-1], "queries": queries} + (output / f"{case['name']}-{round_number}.json").write_text(json.dumps(capture, ensure_ascii=False, indent=2), encoding="utf-8") + digest = hashlib.sha256(json.dumps(data, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + slow = sorted(queries, key=lambda row: row.get("elapsed_ms", 0), reverse=True)[:5] + item = {"case": case["name"], "round": round_number, "elapsedMs": elapsed[-1], "dataSha256": digest, "queryCount": len(queries), "slowQueries": [{"elapsedMs": q.get("elapsed_ms"), "rows": q.get("rows"), "tables": list(dict.fromkeys(re.findall(r"(?:FROM|JOIN)\s+`?([\w.]+)", q["sql"], re.I)))} for q in slow]} + report.append(item) + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(json.dumps(item), flush=True) + print(json.dumps({"case": case["name"], "medianMs": statistics.median(elapsed)}), flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_endpoint_parity.py b/scripts/sw_endpoint_parity.py new file mode 100644 index 0000000..ed8abe7 --- /dev/null +++ b/scripts/sw_endpoint_parity.py @@ -0,0 +1,79 @@ +"""在同一个 sw9 快照上核对优化前后接口,涵盖分页/排序及统计偏好。""" + +import argparse +import json +import urllib.parse +import urllib.request +from pathlib import Path + +import yaml + +from sw_database_rehearsal import connect, query + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--before-url", default="http://127.0.0.1:19002") + parser.add_argument("--after-url", default="http://127.0.0.1:19004") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]): + raise ValueError("原始响应必须保存在仓库外") + config = yaml.safe_load((args.runtime / "config.yaml").read_text(encoding="utf-8"))["data"]["database"] + if config["name"] != "sw9": + raise ValueError("统计偏好测试仅允许 sw9") + folder = args.runtime / "parity" + folder.mkdir(exist_ok=True) + reports = [] + + def api(base, token, path, params=None, payload=None): + url = base + path + ("?" + urllib.parse.urlencode(params, doseq=True) if params else "") + request = urllib.request.Request(url, headers={"x-token": token, "Content-Type": "application/json"}, data=json.dumps(payload).encode() if payload is not None else None, method="PUT" if payload is not None else "GET") + with urllib.request.urlopen(request, timeout=300) as response: + data = json.load(response) + if data.get("code") != 0: + raise RuntimeError("接口请求失败:" + path) + return data["data"] + + def compare(case, token, group): + values = [api(base, token, case["path"], case["params"]) for base in (args.before_url, args.after_url)] + name = group + "-" + case["name"] + (folder / (name + ".json")).write_text(json.dumps({"case": case, "before": values[0], "after": values[1]}, ensure_ascii=False, indent=2), encoding="utf-8") + row = {"case": name, "passed": values[0] == values[1]} + reports.append(row) + args.output.write_text(json.dumps({"checks": reports, "passed": all(row["passed"] for row in reports)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(row), flush=True) + if not row["passed"]: + raise AssertionError("接口数据不一致:" + name) + + admin = (args.runtime / "new-admin.token").read_text(encoding="utf-8").strip() + for case in json.loads((args.runtime / "parity-cases.json").read_text(encoding="utf-8")): + compare(case, admin, "admin") + + token = (args.runtime / "new-level3.token").read_text(encoding="utf-8").strip() + user = api(args.before_url, token, "/user/getUserInfo")["userInfo"]["ID"] + original_preference = api(args.before_url, token, "/sw/user/preference") + cases = json.loads((args.runtime / "cases.json").read_text(encoding="utf-8")) + with connect(config) as db: + original = query(db, "SELECT * FROM sw9.sys_user_preference WHERE user_id=%s", (user,)) + try: + for include, dedup in ((True, True), (True, False), (False, True), (False, False)): + api(args.before_url, token, "/sw/user/preference", payload={"includeSubPromote": include, "idcardDedup": dedup}) + for case in cases: + compare(case, token, f"level3-sub{int(include)}-dedup{int(dedup)}") + finally: + try: + api(args.before_url, token, "/sw/user/preference", payload=original_preference) + finally: + # Restore the row even if the service stopped during verification. + if original: + row = original[0] + query(db, "UPDATE sw9.sys_user_preference SET include_sub_promote=%s,idcard_dedup=%s,update_time=%s WHERE user_id=%s", (row["include_sub_promote"], row["idcard_dedup"], row["update_time"], user)) + else: + query(db, "DELETE FROM sw9.sys_user_preference WHERE user_id=%s", (user,)) + assert query(db, "SELECT * FROM sw9.sys_user_preference WHERE user_id=%s", (user,)) == original + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_export_compare.py b/scripts/sw_export_compare.py new file mode 100644 index 0000000..2ed5eee --- /dev/null +++ b/scripts/sw_export_compare.py @@ -0,0 +1,76 @@ +"""以同一主账号的实际数据请求新旧明细导出,并逐单元格核对,文件保留在仓库外。""" + +import argparse +import decimal +import io +import json +import time +import urllib.parse +import urllib.request +from pathlib import Path + +import openpyxl + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--old-url", default="http://127.0.0.1:18080") + parser.add_argument("--new-url", default="http://127.0.0.1:19001") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]): + raise ValueError("导出文件必须保存在仓库外") + client = json.loads((args.runtime / "legacy-client.json").read_text(encoding="utf-8"))["client_id"] + headers = [{"Authorization": "Bearer " + (args.runtime / "legacy-admin.token").read_text().strip(), "clientid": client}, + {"x-token": (args.runtime / "new-admin.token").read_text().strip()}] + reports = [] + for kind, time_key in (("register", "Register"), ("spend", "Pay")): + params = {"sourceDb": "lx", "pageSize": 10, "begin" + time_key + "Time": "2026-11-03", "end" + time_key + "Time": "2026-11-03"} + request = urllib.request.Request(args.new_url + "/sw/" + kind + "/list?" + urllib.parse.urlencode(params), headers=headers[1]) + with urllib.request.urlopen(request, timeout=60) as response: + body = json.load(response) + if body.get("code") != 0: + raise RuntimeError("读取导出样本失败:" + kind) + sample = next(row for row in body["data"]["rows"] if row.get("userId") is not None) + params["userId"] = sample["userId"] + sheets, durations = [], [] + for i, base in enumerate((args.old_url, args.new_url)): + payload = urllib.parse.urlencode(params).encode() + request = urllib.request.Request(base + ("/system/" if i == 0 else "/sw/") + kind + "/export", data=payload, + headers={**headers[i], "Content-Type": "application/x-www-form-urlencoded"}, method="POST") + started = time.monotonic() + with urllib.request.urlopen(request, timeout=120) as response: + raw = response.read() + durations.append(round((time.monotonic() - started) * 1000)) + if not raw.startswith(b"PK"): + raise RuntimeError("导出未返回工作簿:" + kind + "/" + str(i)) + (args.runtime / (("legacy-" if i == 0 else "new-") + kind + "-export.xlsx")).write_bytes(raw) + workbook = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True) + sheets.append(list(workbook.worksheets[0].values)) + workbook.close() + differences = [] + for row_number, (old, new) in enumerate(zip(*sheets), 1): + if len(old) != len(new): + differences.append({"row": row_number, "kind": "columns"}) + for col, (a, b) in enumerate(zip(old, new), 1): + if a == b or (a in (None, "") and b in (None, "")): + continue + if isinstance(a, (int, float)) or isinstance(b, (int, float)): + try: + if decimal.Decimal(str(a)) == decimal.Decimal(str(b)): + continue + except decimal.InvalidOperation: + pass + differences.append({"row": row_number, "column": col, "kind": "value"}) + rows = [len(sheet) - 1 for sheet in sheets] + reports.append({"kind": kind, "rows": rows, "columns": [len(sheet[0]) for sheet in sheets], "elapsed_ms": durations, + "differences": differences, "passed": rows[0] > 0 and rows[0] == rows[1] and not differences}) + print(json.dumps(reports[-1], ensure_ascii=False), flush=True) + args.output.write_text(json.dumps({"checks": reports, "passed": all(row["passed"] for row in reports)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if not all(row["passed"] for row in reports): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_http_smoke.py b/scripts/sw_http_smoke.py deleted file mode 100644 index 27a284e..0000000 --- a/scripts/sw_http_smoke.py +++ /dev/null @@ -1,72 +0,0 @@ -"""请求运行中的商务接口并生成脱敏结果;令牌从仓库外文件读取。""" - -import argparse -import json -import time -import urllib.error -import urllib.parse -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() - results = [] - - def request(method, path, payload=None, expected=0): - started = time.monotonic() - req = urllib.request.Request( - args.base_url.rstrip("/") + path, - data=json.dumps(payload).encode() if payload is not None else None, - headers={"x-token": token, "Content-Type": "application/json"}, method=method) - try: - with urllib.request.urlopen(req, timeout=30) as response: - body = json.load(response) - item = {"method": method, "path": path, "http": response.status, - "code": body.get("code"), "msg": body.get("msg"), - "elapsed_ms": round((time.monotonic() - started) * 1000)} - item["passed"] = response.status == 200 and body.get("code") == expected - data = body.get("data") - if isinstance(data, dict) and "rows" in data: - item.update(total=data.get("total"), rows=len(data["rows"])) - if "available" in data: - item["available"] = data["available"] - results.append(item) - print(json.dumps(item, ensure_ascii=False), flush=True) - return data if item["passed"] else None - except (urllib.error.URLError, TimeoutError, ValueError) as error: - item = {"method": method, "path": path, "passed": False, "error": str(error)} - results.append(item) - print(json.dumps(item, ensure_ascii=False), flush=True) - return None - - first = request("GET", "/sw/business/list?pageNum=1&pageSize=500") - request("GET", "/sw/business/list?pageNum=2&pageSize=10") - if first and first["rows"]: - account = first["rows"][0] - request("GET", f"/sw/business/{account['userId']}") - request("GET", "/sw/business/list?" + urllib.parse.urlencode({"userName": account["userName"]})) - bound = next((row for row in first["rows"] if row["bindCount"] > 0), account) - request("GET", f"/sw/business/{bound['userId']}/bindPlatform") - request("GET", f"/sw/business/{bound['userId']}/promotes") - request("GET", "/sw/user/preference") - request("GET", "/sw/business/bindPlatform?pageNum=1&pageSize=10") - request("GET", "/sw/business/bindPlatform?sourceDb=lx,hw&status=0&pageSize=2") - # 使用校验必然拒绝的请求体检查写接口,不新增或修改测试库业务记录。 - for method, path in [("PUT", "/sw/user/preference"), ("POST", "/sw/business"), - ("PUT", "/sw/business"), ("POST", "/sw/business/bindPlatform"), - ("DELETE", "/sw/business/bindPlatform")]: - request(method, path, {}, expected=7) - args.output.write_text(json.dumps({"base_url": args.base_url, "checks": results, - "passed": all(row["passed"] for row in results)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - if not all(row["passed"] for row in results): - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/sw_ledger_http_smoke.py b/scripts/sw_ledger_http_smoke.py deleted file mode 100644 index 9b8fdad..0000000 --- a/scripts/sw_ledger_http_smoke.py +++ /dev/null @@ -1,59 +0,0 @@ -"""只读检查明细接口,并用确定为空的商务筛选验证真实 XLSX 导出。""" -import argparse -import io -import json -import urllib.parse -import urllib.request -import zipfile -import xml.etree.ElementTree as ET -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() - checks = [] - keyword = "__kra_empty_export_probe_20261104__" - - def call(method, path, payload=None, expected=0, excel=False): - body = urllib.parse.urlencode(payload).encode() if payload is not None else None - request = urllib.request.Request(args.base_url + path, method=method, data=body, - headers={"x-token": token, "Content-Type": "application/x-www-form-urlencoded"}) - with urllib.request.urlopen(request, timeout=30) as response: - raw = response.read() - item = {"method": method, "path": path, "http": response.status} - if excel: - with zipfile.ZipFile(io.BytesIO(raw)) as archive: - sheet = ET.fromstring(archive.read("xl/worksheets/sheet1.xml")) - rows = sheet.findall(".//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}row") - item.update(xlsx=True, worksheet_rows=len(rows), passed=response.headers.get("success") == "true" and len(rows) == 1) - else: - value = json.loads(raw) - data = value.get("data", {}) - item.update(code=value.get("code"), msg=value.get("msg"), passed=value.get("code") == expected) - if isinstance(data, dict) and "rows" in data: - item.update(total=data.get("total"), available=data.get("available")) - checks.append(item) - return item - - for kind in ["register", "spend"]: - item = call("GET", f"/sw/{kind}/list?sourceDb=lx,hw,yq") - empty = call("GET", f"/sw/{kind}/list?" + urllib.parse.urlencode({"busierAccount": keyword})) - empty["passed"] &= empty.get("total") == 0 and empty.get("available") is True - call("POST", f"/sw/{kind}/export", {"busierAccount": keyword}, excel=True) - if item.get("available") is False: - call("POST", f"/sw/{kind}/export", {"sourceDb": "lx"}, expected=7) - call("GET", "/sw/register/list?beginRegisterTime=bad&endRegisterTime=2026-11-04", expected=7) - report = {"scope": "真实 sw7 会话,只读查询;导出使用确定为空的商务筛选,仅校验表头工作簿,不导出真实业务行。", "checks": checks, "passed": all(item["passed"] for item in checks)} - 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_ledger_page_smoke.cjs b/scripts/sw_ledger_page_smoke.cjs deleted file mode 100644 index 0b6bc29..0000000 --- a/scripts/sw_ledger_page_smoke.cjs +++ /dev/null @@ -1,72 +0,0 @@ -// 明细页面采用受控列表数据;下载通过真实服务的确定空查询生成 XLSX,不读取实际业务行。 -const fs = require('node:fs/promises') -const path = require('node:path') -const assert = require('node:assert/strict') -const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright') - -;(async () => { - const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim() - const browser = await chromium.launch({ channel: 'chrome', headless: true }) - const reports = [] - try { - for (const kind of ['register', 'spend']) { - const context = await browser.newContext({ viewport: { width: 1650, height: 1000 }, acceptDownloads: true }) - await context.addInitScript(value => localStorage.setItem('token', value), token) - const page = await context.newPage() - const errors = [], queries = [], exports = [] - let failed = false - page.on('pageerror', error => errors.push(error.message)) - await page.route('http://127.0.0.1:8080/api/**', async route => { - const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4) - if (api === `/sw/${kind}/list`) { - queries.push(Object.fromEntries(url.searchParams)) - if (failed) return route.fulfill({ json: { code: 7, msg: '测试查询失败', data: {} } }) - const row = kind === 'register' - ? { id: 7, userId: 7, sourceDb: 'lx', sourceDbLabel: '龙翔', registerTime: 1793664000, fgameName: '测试游戏', totalPayAmount: '9007199254740993.10', registerIp: '192.0.2.1', deviceName: '测试机型', promoteAccount: '测试渠道', busierName: 'SDK(测试商务)' } - : { id: 71, userId: 7, sourceDb: 'lx', sourceDbLabel: '龙翔', payOrderNumber: '00000000000071', gameName: '测试游戏', serverName: '测试区服', gamePlayerName: '测试角色', payAmount: '5000.00', payTime: 1793664000, payWay: 3, payStatus: 0, spendIp: '192.0.2.2', promoteAccount: '测试渠道', busierName: 'SDK(测试商务)' } - return route.fulfill({ json: { code: 0, msg: '成功', data: { rows: [row], total: 1, totalPayAmount: '2300.00', available: true } } }) - } - if (api === '/sw/serverAnalysis/games') return route.fulfill({ json: { code: 0, data: [{ sourceDb: 'lx', gameId: 1, gameName: '可选游戏' }, { sourceDb: 'hw', gameId: 2, gameName: '可选游戏' }] } }) - if (api === `/sw/${kind}/export`) { - exports.push(Object.fromEntries(new URLSearchParams(request.postData()))) - const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api, method: 'POST', postData: 'busierAccount=__kra_empty_export_probe_20261104__', headers: { ...request.headers(), 'content-type': 'application/x-www-form-urlencoded', 'x-token': token } }) - return route.fulfill({ response }) - } - const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } }) - return route.fulfill({ response }) - }) - await page.goto(`http://127.0.0.1:8080/#/layout/sw-DataAnalysis/${kind === 'register' ? 'RegistrationDetails' : 'RechargeDetails'}`) - await page.getByText('测试渠道', { exact: true }).waitFor() - const first = queries[0], begin = first[kind === 'register' ? 'beginRegisterTime' : 'beginPayTime'], end = first[kind === 'register' ? 'endRegisterTime' : 'endPayTime'] - assert.equal((Date.parse(end) - Date.parse(begin)) / 86400000, 29) - if (kind === 'register') { - assert.equal(await page.getByRole('columnheader', { name: 'ID', exact: true }).count(), 0) - await page.getByText('¥9,007,199,254,740,993.10', { exact: true }).waitFor() - } else { - assert.equal(await page.locator('.large-recharge-row').count(), 1) - await page.getByText('¥2,300.00', { exact: true }).waitFor() - } - const form = page.locator('.kra-search-box') - await form.locator('.el-form-item').filter({ has: page.locator('label').getByText('游戏', { exact: true }) }).locator('.el-select__wrapper').click() - await page.getByRole('option', { name: '可选游戏', exact: true }).click() - await page.keyboard.press('Escape') - await form.getByPlaceholder('玩家ID', { exact: true }).fill('42') - await Promise.all([page.waitForResponse(response => response.url().includes(`/sw/${kind}/list`) && response.url().includes('userId=42')), form.getByRole('button', { name: '搜索', exact: true }).click()]) - assert.equal(queries.at(-1).gameName, '可选游戏') - const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: '导出', exact: true }).click()]) - const bytes = await fs.readFile(await download.path()) - assert.equal(bytes.subarray(0, 2).toString(), 'PK') - assert.equal(exports.at(-1).userId, '42') - assert.equal(exports.at(-1).gameName, '可选游戏') - await page.screenshot({ path: path.join(process.env.TEMP, `sw-${kind}-details.png`), animations: 'disabled' }) - failed = true - await form.getByRole('button', { name: '搜索', exact: true }).click() - await page.locator('.el-alert').getByText('测试查询失败', { exact: true }).waitFor() - if (kind === 'spend') assert.equal(await page.getByText('¥2,300.00', { exact: true }).count(), 0) - assert.deepEqual(errors, []) - const report = { kind, passed: true, defaultThirtyDays: true, gameNameSelection: true, exactAmountOrSuccessSummary: true, xlsxDownload: true, exportKeepsFilters: true, failedQueryClearsOldData: true, pageErrors: errors } - reports.push(report); console.log(JSON.stringify(report)); await context.close() - } - await fs.writeFile('docs/sw6-to-kra/ledger-page-2026-11-04.json', JSON.stringify({ method: '真实 Chrome,列表数据受控;导出使用真实服务的空筛选生成 XLSX,不读取业务数据。', reports }, null, 2) + '\n') - } finally { await browser.close() } -})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/sw_login_smoke.cjs b/scripts/sw_login_smoke.cjs deleted file mode 100644 index 8e00256..0000000 --- a/scripts/sw_login_smoke.cjs +++ /dev/null @@ -1,52 +0,0 @@ -// 验证旧商务登录外观和三步找回密码;短信接口始终使用测试响应。 -const fs=require('node:fs/promises'); -const path=require('node:path'); -const {chromium}=require(process.env.SW_PLAYWRIGHT_MODULE||'playwright'); -(async()=>{ - const browser=await chromium.launch({channel:'chrome',headless:true}); - const calls=[]; - try { - const context=await browser.newContext({viewport:{width:1440,height:1000}}); - const page=await context.newPage(); - const errors=[];page.on('pageerror',e=>errors.push(e.message)); - await page.route('http://127.0.0.1:8080/api/**',async route=>{ - const request=route.request();const uri=new URL(request.url());const apiPath=uri.pathname.slice(4); - if(apiPath.startsWith('/sw/auth/forgot-password/')){ - const action=apiPath.split('/').pop();const payload=request.postDataJSON();calls.push({action,payload}); - const data=action==='verify-code'?{resetToken:'a'.repeat(64)}:action==='reset'?{}:{maskedPhone:'138****8000'}; - await route.fulfill({json:{code:0,msg:'成功',data}});return; - } - if(apiPath==='/base/captcha'){ - const response=await route.fetch({url:'http://127.0.0.1:18000/base/captcha'});const body=await response.json();body.data.openCaptcha=true;await route.fulfill({response,json:body});return; - } - await route.continue(); - }); - await page.goto('http://127.0.0.1:8080/#/login'); - await page.getByText('欢迎登录',{exact:true}).waitFor(); - await page.getByPlaceholder('请输入验证码',{exact:true}).waitFor(); - if(await page.title()!=='遨游龙翔游戏平台')throw new Error('页面标题没有使用旧商务名称'); - if(!await page.getByAltText('遨游龙翔游戏平台',{exact:true}).isVisible())throw new Error('旧品牌图片未显示'); - await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-login-desktop.png'),fullPage:true,animations:'disabled'}); - await page.getByRole('button',{name:'忘记密码?'}).click(); - const dialog=page.getByRole('dialog',{name:'找回密码'}); - await dialog.getByPlaceholder('请输入您注册的商务账号').fill('demo_biz'); - await dialog.getByRole('button',{name:'下一步',exact:true}).click(); - await dialog.getByText('138****8000',{exact:true}).waitFor(); - await dialog.getByPlaceholder('请输入 6 位验证码').fill('123456'); - await dialog.getByRole('button',{name:'下一步',exact:true}).click(); - await dialog.getByPlaceholder('6-15 位字符').fill('abc123'); - await dialog.getByPlaceholder('请再次输入新密码').fill('abc123'); - await dialog.getByRole('button',{name:'完成',exact:true}).click(); - await dialog.waitFor({state:'hidden'}); - const actions=calls.map(c=>c.action); - if(JSON.stringify(actions)!==JSON.stringify(['check','send-code','verify-code','reset']))throw new Error('三步流程请求顺序不正确'); - const reset=calls[3].payload; - if(reset.resetToken!=='a'.repeat(64)||reset.newPassword!=='abc123'||reset.confirmPassword!=='abc123')throw new Error('重置请求参数未对齐'); - if(errors.length)throw new Error('页面运行时错误:'+errors.join(';')); - await page.setViewportSize({width:390,height:844}); - await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-login-mobile.png'),fullPage:true,animations:'disabled'}); - const report={method:'真实 Chrome 渲染;找回密码接口使用受控响应,不发送真实短信',title:await page.title(),actions,pageErrors:errors,passed:true}; - await fs.writeFile('docs/sw6-to-kra/login-page-verification-2026-11-03.json',JSON.stringify(report,null,2)); - console.log(JSON.stringify(report)); - }finally{await browser.close();} -})().catch(e=>{console.error(e.message);process.exitCode=1}); diff --git a/scripts/sw_menu_icons_http_smoke.py b/scripts/sw_menu_icons_http_smoke.py deleted file mode 100644 index 7a951c2..0000000 --- a/scripts/sw_menu_icons_http_smoke.py +++ /dev/null @@ -1,42 +0,0 @@ -"""通过真实菜单接口检查商务图标映射,不进行页面交互。""" -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 deleted file mode 100644 index d155c68..0000000 --- a/scripts/sw_menu_icons_verify.cjs +++ /dev/null @@ -1,32 +0,0 @@ -// 核验实际安装的图标组件及 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_migration_inventory.py b/scripts/sw_migration_inventory.py deleted file mode 100644 index 5e98953..0000000 --- a/scripts/sw_migration_inventory.py +++ /dev/null @@ -1,48 +0,0 @@ -"""从指定旧仓库和现有迁移 SQL 生成可复核的迁移清单,不访问数据库。""" - -import argparse -import hashlib -import json -import re -import subprocess -from pathlib import Path - - -def git(repo, *args): - return subprocess.check_output(["git", "-C", str(repo), *args], text=True, encoding="utf-8").strip() - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--source", required=True, type=Path) - parser.add_argument("--baseline", default="d31082f4a7816d4c83544b8f54e6f42a3b2132b1") - args = parser.parse_args() - root = Path(__file__).resolve().parents[1] - source = args.source.resolve() - sql = (root / "docs/sw6-to-kra/sw_to_kra_migrate.sql").read_text(encoding="utf-8") - contracts = [dict(zip(("method", "source_path", "target_path", "permission", "controller"), row)) - for row in re.findall(r"\('(GET|POST|PUT|DELETE)','([^']+)','([^']+)','([^']+)','([^']+)'\)", sql)] - changes = [] - for path in git(source, "diff", "--name-only", args.baseline, "main").splitlines(): - # 只登记路径与摘要,不把旧配置中的凭据写入新仓库。 - if not (path.endswith(".java") or "/mapper/" in path and path.endswith(".xml")): - continue - file = source / path - changes.append({"path": path, "exists": file.is_file(), - "sha256": hashlib.sha256(file.read_bytes()).hexdigest() if file.is_file() else None}) - custom = [] - for file in sorted((source / "ruoyi-admin/src/main/java").rglob("*Controller.java")): - text = file.read_text(encoding="utf-8") - if file.name == "ForgotPasswordController.java": - custom.append({"path": file.relative_to(source).as_posix(), "source": text}) - result = {"baseline": args.baseline, "backend_head": git(source, "rev-parse", "main"), - "frontend_head": git(source / "web", "rev-parse", "dev/dev1.0"), - "contracts": contracts, "changed_sources": changes, - "additional_controllers": [item["path"] for item in custom]} - target = root / "docs/sw6-to-kra/source-inventory.json" - target.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - print(f"已登记 {len(contracts)} 个 SQL 接口契约、{len(changes)} 个变更源文件。") - - -if __name__ == "__main__": - main() diff --git a/scripts/sw_page_smoke.cjs b/scripts/sw_page_smoke.cjs deleted file mode 100644 index 10191bd..0000000 --- a/scripts/sw_page_smoke.cjs +++ /dev/null @@ -1,86 +0,0 @@ -// 使用受控身份和接口数据检查实际 Vue 页面,不修改业务库。 -const fs = require('node:fs/promises'); -const path = require('node:path'); -const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright'); -(async () => { - const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim(); - const browser = await chromium.launch({channel:'chrome', headless:true}); - const results = []; - try { - for (const level of [0,1,2,3]) { - const context = await browser.newContext({viewport:{width:1440,height:1000}}); - await context.addInitScript(value => localStorage.setItem('token',value),token); - const page = await context.newPage(); - const pageErrors=[]; - page.on('pageerror',e=>pageErrors.push(e.message)); - let bindingCalls=0; const channelCalls=[]; - await page.route('http://127.0.0.1:8080/api/**', async route => { - const request=route.request(); - const uri=new URL(request.url()); - const apiPath=uri.pathname.slice(4); - let body; - if(apiPath==='/sw/business/list') body={code:0,msg:'成功',data:{rows:[{userId:79999,userName:'展示测试商务',level:2,parentUserName:'上级商务',realName:'测试姓名',phoneNumber:'',bindCount:0,promoteCount:0,createTime:'2026-11-03 10:00:00',status:'0',authorityId:902}],total:1}}; - else if(/^\/sw\/business\/\d+\/promotes$/.test(apiPath)) { channelCalls.push(Object.fromEntries(uri.searchParams)); body={code:0,msg:'成功',data:{rows:[{promoteId:1,promoteName:'渠道测试',sourceDb:'lx',platformName:'龙翔'}],total:1,available:true}}; } - else if(apiPath==='/sw/business/bindPlatform') { - bindingCalls++; - body={code:0,msg:'成功',data:{total:3,rows:['0','2','3'].map((status,i)=>({userId:70000+level,sourceDb:'lx',platformName:'龙翔',platformBusierId:10+i,platformBusierAccount:['正常账号','驳回账号','待审账号'][i],bindUserName:'测试商务',promoteCount:status==='0'?2:0,status,createTime:'2026-11-03 10:00:00',reviewTime:'2026-11-03 11:00:00',reviewAccount:'测试审核人',rejectReason:status==='2'?'测试驳回原因':null}))}}; - } else if(/^\/sw\/business\/\d+\/bindPlatform$/.test(apiPath)) body={code:0,msg:'成功',data:[]}; - if(body) {await route.fulfill({json:body});return;} - const response=await route.fetch({url:'http://127.0.0.1:18000'+apiPath+uri.search,headers:{...request.headers(),'x-token':token}}); - if(apiPath==='/user/getUserInfo') { - body=await response.json(); - const info=body.data.userInfo; - Object.assign(info,{ID:70000+level,level,nickName:'页面核对账号',phone:'',email:'',authorityId:level===0?888:900+level}); - await route.fulfill({response,json:body}); - } else await route.fulfill({response}); - }); - await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/BusinessList'); - try { await page.getByRole('tab',{name:'商务列表',exact:true}).waitFor({timeout:15000}); } catch (error) { console.log(JSON.stringify({url:page.url(),errors:pageErrors,text:(await page.locator('body').innerText()).slice(0,1800)})); await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-browser-failure.png')}); throw error; } - await page.getByText('展示测试商务',{exact:true}).waitFor(); - const tabs=await page.getByRole('tab',{name:'绑定列表',exact:true}).count(); - const editButtons=await page.getByRole('button',{name:'编辑',exact:true}).count(); - if(tabs!==(level===3?1:0)||editButtons!==0) throw new Error('页面权限或编辑入口不一致 level='+level); - const result={level,bindingTab:tabs===1,editButtons,passed:true}; - if(level===1 || level===2) { - await page.getByRole('row').filter({hasText:'展示测试商务'}).getByRole('button',{name:'查看SDK商务绑定详情',exact:true}).click(); - await page.getByRole('dialog',{name:'SDK商务绑定详情'}).waitFor(); - result.sdkDetailReadonly=(await page.getByRole('dialog',{name:'SDK商务绑定详情'}).getByRole('button',{name:'解绑',exact:true}).count())===0; - if(!result.sdkDetailReadonly)throw new Error('SDK 详情出现写操作'); - await page.getByRole('dialog',{name:'SDK商务绑定详情'}).getByRole('button',{name:'关闭此对话框',exact:true}).click(); - } - if(level===3) { - await page.getByRole('tab',{name:'绑定列表',exact:true}).click(); - await page.getByText('正常账号',{exact:true}).waitFor(); - const normal=page.getByRole('row').filter({hasText:'正常账号'}); - const rejected=page.getByRole('row').filter({hasText:'驳回账号'}); - const pending=page.getByRole('row').filter({hasText:'待审账号'}); - result.normalUnbind=await normal.getByRole('button',{name:'解绑',exact:true}).count(); - result.rejectedUnbind=await rejected.getByRole('button',{name:'解绑',exact:true}).count(); - result.pendingUnbind=await pending.getByRole('button',{name:'解绑',exact:true}).count(); - result.rejectedDetail=await rejected.getByRole('button',{name:'查看驳回',exact:true}).count(); - if(result.normalUnbind!==1||result.rejectedUnbind||result.pendingUnbind||result.rejectedDetail!==1)throw new Error('绑定状态操作不一致'); - result.rejectedChannelsDisabled=await rejected.getByRole('button',{name:'查看旗下渠道',exact:true}).isDisabled(); - result.pendingChannelsDisabled=await pending.getByRole('button',{name:'查看旗下渠道',exact:true}).isDisabled(); - if(!result.rejectedChannelsDisabled || !result.pendingChannelsDisabled)throw new Error('未通过绑定仍可查看渠道'); - await normal.getByRole('button',{name:'查看旗下渠道',exact:true}).click(); - await page.getByText('渠道测试',{exact:true}).waitFor(); - const channelQuery=channelCalls.at(-1); - if(channelQuery.sourceDb!=='lx'||channelQuery.platformBusierId!=='10')throw new Error('旗下渠道未携带平台和商务筛选'); - result.channelFilters=true; - await page.getByRole('dialog',{name:'旗下渠道',exact:true}).getByRole('button',{name:'关闭此对话框',exact:true}).click(); - await rejected.getByRole('button',{name:'查看驳回',exact:true}).click(); - await page.getByText('测试驳回原因',{exact:true}).waitFor(); - const panel=await page.getByRole('dialog',{name:'审核详情',exact:true}).elementHandle(); - await page.waitForFunction(el=>{const box=el.getBoundingClientRect();return box.x>=0 && box.right<=innerWidth+1},panel,{timeout:5000}); - result.rejectDrawerInViewport=true; - } else if(bindingCalls!==0) throw new Error('非三级加载了绑定列表'); - result.bindingCalls=bindingCalls; - result.pageErrors=pageErrors; - await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-business-level-'+level+'.png'),fullPage:true,animations:'disabled'}); - results.push(result); - console.log(JSON.stringify(result)); - await context.close(); - } - await fs.writeFile('docs/sw6-to-kra/business-page-parity-2026-11-03.json',JSON.stringify({method:'真实 Chrome 渲染;受控身份和业务数据,权限页签与状态操作核对',results},null,2)); - } finally {await browser.close();} -})().catch(error=>{console.error(error.message);process.exitCode=1}); diff --git a/scripts/sw_portrait_http_smoke.py b/scripts/sw_portrait_http_smoke.py deleted file mode 100644 index 5bf0b55..0000000 --- a/scripts/sw_portrait_http_smoke.py +++ /dev/null @@ -1,68 +0,0 @@ -"""只读验证运行实例的商务画像、缺数据处理与参数校验。""" - -import argparse -import json -import urllib.parse -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() - checks = [] - - def get(path, expected=0): - request = urllib.request.Request(args.base_url + path, headers={"x-token": token}) - with urllib.request.urlopen(request, timeout=30) as response: - payload = json.load(response) - check = {"path": path, "http": response.status, "code": payload.get("code"), - "msg": payload.get("msg"), "passed": response.status == 200 and payload.get("code") == expected} - data = payload.get("data", {}) - if isinstance(data, dict) and "basic" in data: - check.update(available=data.get("available"), promote_count=data["basic"].get("promoteCount"), - register_points=len(data.get("registerTrend", [])), pay_points=len(data.get("payTrend", []))) - checks.append(check) - return data - - accounts = get("/sw/business/list?pageNum=1&pageSize=500")["rows"] - - def bound_subtree(account): - prefix = f"{account.get('idPath') or '0'},{account['userId']}" - ids = [account] - if int(account.get("level") or 0) < 3: - ids += [row for row in accounts if row["userId"] != account["userId"] and - (row.get("parentId") == account["userId"] or row.get("idPath") == prefix or - str(row.get("idPath") or "").startswith(prefix + ","))] - return sum(row.get("bindCount") or 0 for row in ids) - - empty = next((row for row in accounts if bound_subtree(row) == 0), None) - bound = next((row for row in accounts if bound_subtree(row) > 0), None) - if empty: - data = get(f"/sw/business/{empty['userId']}/portrait?sourceDbList=lx,hw,yq&period=today") - checks[-1]["passed"] &= data.get("available") is True and data.get("metrics", {}).get("register", {}).get("value") == "0" and len(data.get("registerTrend", [])) == 24 - data = get(f"/sw/business/{empty['userId']}/portrait?sourceDbList=lx&period=thisMonth") - checks[-1]["passed"] &= data.get("period") == "thismonth" and 1 <= len(data.get("registerTrend", [])) <= 31 - if bound: - data = get(f"/sw/business/{bound['userId']}/portrait?sourceDbList=lx,hw,yq&period=last7") - if data.get("available") is False: - checks[-1]["passed"] &= data.get("metrics") is None and bool(data.get("unavailableReason")) - else: - checks[-1]["passed"] &= len(data.get("registerTrend", [])) == 7 - if accounts: - get(f"/sw/business/{accounts[0]['userId']}/portrait", expected=7) - get("/sw/business/bindPlatform/portrait?sourceDb=lx&platformBusierId=0", expected=7) - report = {"scope": "真实 sw7 登录会话,只读请求;有绑定但业务表未接入时不伪造零,无绑定时验证真实空画像。完整指标和 SDK 基本信息通过隔离双库 TCP HTTP 测试。", - "checks": checks, "passed": all(check["passed"] for check in checks)} - 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_portrait_page_smoke.cjs b/scripts/sw_portrait_page_smoke.cjs deleted file mode 100644 index affed88..0000000 --- a/scripts/sw_portrait_page_smoke.cjs +++ /dev/null @@ -1,101 +0,0 @@ -// 真实 Chrome 核对三个画像入口、统计切换及嵌套抽屉;所有业务数据使用受控响应。 -const fs = require('node:fs/promises') -const path = require('node:path') -const assert = require('node:assert/strict') -const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright') - -;(async () => { - const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim() - const browser = await chromium.launch({ channel: 'chrome', headless: true }) - const results = [] - try { - for (const level of [1, 3]) { - const context = await browser.newContext({ viewport: { width: 1600, height: 1080 } }) - await context.addInitScript(value => localStorage.setItem('token', value), token) - const page = await context.newPage() - const errors = [], portraits = [], alertQueries = [], channelQueries = [] - let unavailable = false - const normal = { userId: 70009, sourceDb: 'lx', platformName: '龙翔', platformBusierId: 14, platformBusierAccount: 'SDK测试账号', bindUserName: '测试商务', status: '0', createTime: '2026-11-01 10:00:00', promoteCount: 1 } - page.on('pageerror', error => errors.push(error.message)) - await page.route('http://127.0.0.1:8080/api/**', async route => { - const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4) - const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } }) - if (api === '/sw/business/list') return ok({ rows: level === 3 ? [] : [{ userId: 70009, userName: '测试商务账号', realName: '测试商务', level: 3, status: '0', bindCount: 1, promoteCount: 1, createTime: '2026-11-01 10:00:00' }], total: level === 3 ? 0 : 1 }) - if (api === '/sw/business/70009/bindPlatform') return ok([normal]) - if (api === '/sw/business/bindPlatform') return ok({ rows: [normal, { ...normal, platformBusierId: 15, platformBusierAccount: '驳回账号', status: '2' }, { ...normal, platformBusierId: 16, platformBusierAccount: '待审账号', status: '3' }], total: 3 }) - if (api === '/sw/business/70009/portrait' || api === '/sw/business/bindPlatform/portrait') { - const sdk = api.includes('bindPlatform') - const period = url.searchParams.get('period') || 'today' - portraits.push({ api, period, source: url.searchParams.get(sdk ? 'sourceDb' : 'sourceDbList'), platformBusierId: url.searchParams.get('platformBusierId') }) - const length = ['today', 'yesterday'].includes(period) ? 24 : 7 - const trends = Array.from({ length }, (_, i) => ({ label: length === 24 ? String(i).padStart(2, '0') + ':00' : new Date(Date.UTC(2026, 9, 28 + i)).toISOString().slice(5, 10), value: i === 1 ? '2' : '0' })) - const compareLabel = period === 'today' ? '较昨日' : period === 'yesterday' ? '较前日' : period === 'thismonth' ? '较上月同期' : '较上一周期' - const metric = value => ({ value, compareValue: '1', changeRate: '100.0', changeDiff: null, compareLabel }) - return ok({ basic: { userId: sdk ? 14 : 70009, userName: sdk ? 'SDK测试账号' : '测试商务账号', realName: '画像姓名', level: 3, levelLabel: '三级商务', parentUserName: '上级账号', status: '0', statusLabel: sdk ? '待审核' : '正常', promoteCount: unavailable ? null : 1, monthNewPromote: unavailable ? null : 1, createTime: '2026-11-01 10:00:00' }, period, - available: !unavailable, unavailableReason: unavailable ? '渠道数据尚未接入,画像指标暂不可用' : '', metrics: unavailable ? null : { register: metric('2'), payAmount: metric('12.00'), payRate: { ...metric('50.0'), changeRate: null, changeDiff: '25.0', compareValue: '25.0' }, arpu: metric('6.00') }, - registerTrend: unavailable ? [] : trends, payTrend: unavailable ? [] : trends.map(point => ({ ...point, value: point.value === '2' ? '12.00' : '0.00' })), registerTop5: unavailable ? [] : [{ rank: 1, sourceDbLabel: '龙翔', promoteId: 100, promoteName: '测试主渠道', value: '2.00' }], payTop5: unavailable ? [] : [{ rank: 1, sourceDbLabel: '龙翔', promoteId: 100, promoteName: '测试主渠道', value: '12.00' }] }) - } - if (api === '/sw/alertRecord/list') { alertQueries.push(Object.fromEntries(url.searchParams)); return ok({ available: true, pageData: { rows: [{ id: '1', createdAt: '2026-11-03 10:00:00', targetPromoteName: '测试预警渠道', ruleType: 'register_anomaly', alertContent: '测试关联预警', status: 1, statusLabel: '未处理' }], total: 1 }, statusCount: { 1: 1 } }) } - if (api === '/sw/business/70009/promotes') { channelQueries.push({ api, ...Object.fromEntries(url.searchParams) }); return ok({ rows: [{ promoteId: 100, promoteName: '测试直属渠道', sourceDb: 'lx', platformName: '龙翔' }], total: 1, available: true }) } - const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } }) - if (api === '/user/getUserInfo') { const body = await response.json(); Object.assign(body.data.userInfo, { ID: level === 3 ? 70009 : 70001, level, authorityId: 900 + level, nickName: '画像核对账号', phone: '', email: '' }); return route.fulfill({ response, json: body }) } - return route.fulfill({ response }) - }) - await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/BusinessList') - const close = async dialog => { await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }) } - if (level === 1) { - await page.getByRole('button', { name: '画像', exact: true }).click() - const portrait = page.getByRole('dialog', { name: '商务画像 — 测试商务', exact: true }) - await portrait.getByText('画像姓名', { exact: true }).waitFor() - await portrait.locator('.el-loading-mask').first().waitFor({ state: 'hidden' }) - await portrait.getByText('测试关联预警', { exact: true }).waitFor() - assert.equal(portraits.at(-1).source, 'lx,hw,yq') - await portrait.getByRole('img', { name: '注册量趋势', exact: true }).focus() - await portrait.getByRole('img', { name: '注册量趋势', exact: true }).press('ArrowRight') - await portrait.locator('.trend-tooltip').getByText('01:00', { exact: true }).waitFor() - await portrait.getByText('近7天', { exact: true }).click() - await page.waitForFunction(() => [...document.querySelectorAll('.profile-two-columns .el-card__header')].some(node => node.textContent.includes('近7天注册量'))) - assert.equal(portraits.at(-1).period, 'last7') - await portrait.locator('.el-loading-mask').first().waitFor({ state: 'hidden' }) - await page.screenshot({ path: path.join(process.env.TEMP, 'sw-business-portrait.png'), animations: 'disabled' }) - await close(portrait) - await page.getByRole('button', { name: '查看SDK商务绑定详情', exact: true }).click() - const bindings = page.getByRole('dialog', { name: 'SDK商务绑定详情', exact: true }) - await bindings.getByRole('button', { name: '龙翔', exact: true }).click() - const sdk = page.getByRole('dialog', { name: '游戏商务画像 — SDK测试账号', exact: true }) - await sdk.getByText('画像姓名', { exact: true }).waitFor() - assert.equal(portraits.at(-1).platformBusierId, '14') - assert.equal(alertQueries.at(-1).businessId, '70009') - assert.equal(alertQueries.at(-1).platformCodes, 'lx') - await sdk.locator('.profile-info').getByText('待审核', { exact: true }).waitFor() - assert.equal(await sdk.locator('.profile-info .el-tag--success').count(), 0) - await sdk.locator('.profile-info').getByRole('button', { name: '1', exact: true }).click() - const channels = page.getByRole('dialog', { name: '旗下渠道', exact: true }) - await channels.waitFor(); await channels.locator('.el-loading-mask').waitFor({ state: 'hidden' }) - assert.equal(channelQueries.at(-1).api, '/sw/business/70009/promotes') - assert.equal(channelQueries.at(-1).platformBusierId, '14') - await channels.locator('.el-drawer__close-btn').click(); await channels.waitFor({ state: 'hidden' }) - await close(sdk) - await bindings.locator('.el-drawer__close-btn').click(); await bindings.waitFor({ state: 'hidden' }) - unavailable = true - await page.getByRole('button', { name: '画像', exact: true }).click() - await portrait.getByText('渠道数据尚未接入,画像指标暂不可用', { exact: true }).waitFor() - assert.equal(await portrait.getByRole('img', { name: '注册量趋势', exact: true }).count(), 0) - await close(portrait) - } else { - await page.getByRole('tab', { name: '绑定列表', exact: true }).click() - const normalRow = page.locator('.el-table__body-wrapper tr').filter({ hasText: 'SDK测试账号' }) - await normalRow.getByRole('button', { name: '画像', exact: true }).click() - const sdk = page.getByRole('dialog', { name: '游戏商务画像 — SDK测试账号', exact: true }) - await sdk.getByText('画像姓名', { exact: true }).waitFor() - await close(sdk) - assert.equal(await page.locator('.el-table__body-wrapper tr').filter({ hasText: '驳回账号' }).getByRole('button', { name: '画像', exact: true }).count(), 0) - assert.equal(await page.locator('.el-table__body-wrapper tr').filter({ hasText: '待审账号' }).getByRole('button', { name: '画像', exact: true }).count(), 0) - } - assert.deepEqual(errors, []) - const result = { level, passed: true, entry: level === 1 ? '商务列表与只读 SDK 详情' : '三级绑定列表正常行', pageErrors: errors } - results.push(result); console.log(JSON.stringify(result)); await context.close() - } - await fs.writeFile('docs/sw6-to-kra/portrait-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome 渲染;业务接口使用受控数据。验证三个入口、时间切换、交互图表、SDK 状态、SW 用户 ID 与平台 ID 分离、缺数据提示。', results }, null, 2) + '\n') - } finally { await browser.close() } -})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/sw_preference_compare.py b/scripts/sw_preference_compare.py new file mode 100644 index 0000000..4993e72 --- /dev/null +++ b/scripts/sw_preference_compare.py @@ -0,0 +1,61 @@ +"""用已有真实绑定的三级账号逐项切换统计偏好,对比报表后恢复原始偏好行。""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from sw_write_verify import Rehearsal + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--config", type=Path, default=Path("configs/config.yaml")) + parser.add_argument("--old-url", default="http://127.0.0.1:18080") + parser.add_argument("--new-url", default="http://127.0.0.1:19001") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + run = Rehearsal(args) + identity = json.loads((args.runtime / "test-business-identities.json").read_text())["level3"] + ids = {"legacy": identity["user_id"], "new": run.mapping[str(identity["user_id"])]} + originals, preferences = {}, {} + for side, db in (("legacy", "sw8"), ("new", "sw9")): + originals[side] = run.query(f"SELECT * FROM {db}.sys_user_preference WHERE user_id=%s", (ids[side],)) + preferences[side] = run.call(side, "level3", "GET", run.prefix(side) + "/user/preference")["data"] + (args.runtime / "preferences-before-matrix.json").write_text(json.dumps(originals, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + templates = json.loads((args.runtime / "level3-business-cases.json").read_text()) + templates = [row for row in templates if row["name"] in ("channelData-list", "gameData-list", "rank-game", "workbench-summary")] + try: + for include, dedup in ((True, True), (True, False), (False, True), (False, False)): + mode = f"sub{int(include)}-dedup{int(dedup)}" + value = {"includeSubPromote": include, "idcardDedup": dedup} + for side in ("legacy", "new"): + run.call(side, "level3", "PUT", run.prefix(side) + "/user/preference", value) + actual = run.call(side, "level3", "GET", run.prefix(side) + "/user/preference")["data"] + run.check("偏好写入 " + mode + " " + side, actual == value) + cases = [{**row, "name": mode + "-" + row["name"]} for row in templates] + cases_file = args.runtime / ("preference-cases-" + mode + ".json") + cases_file.write_text(json.dumps(cases), encoding="utf-8") + output = args.output.with_name(args.output.stem + "-" + mode + args.output.suffix) + with (args.runtime / ("preference-" + mode + ".log")).open("wb") as log: + completed = subprocess.run([sys.executable, str(Path(__file__).with_name("sw_api_compare.py")), "--runtime", str(args.runtime), "--cases", str(cases_file), + "--identity", "level3", "--old-url", args.old_url, "--new-url", args.new_url, "--output", str(output)], stdout=log, stderr=subprocess.STDOUT) + run.check("偏好模式报表一致 " + mode, completed.returncode == 0, report=output.name) + finally: + for side, db in (("legacy", "sw8"), ("new", "sw9")): + run.call(side, "level3", "PUT", run.prefix(side) + "/user/preference", preferences[side]) + if not originals[side]: + run.query(f"DELETE FROM {db}.sys_user_preference WHERE user_id=%s", (ids[side],)) + else: + row = originals[side][0] + run.query(f"UPDATE {db}.sys_user_preference SET include_sub_promote=%s,idcard_dedup=%s,update_time=%s WHERE user_id=%s", + (row["include_sub_promote"], row["idcard_dedup"], row["update_time"], ids[side])) + actual = run.query(f"SELECT * FROM {db}.sys_user_preference WHERE user_id=%s", (ids[side],)) + run.check("偏好原始行已恢复 " + side, actual == originals[side]) + run.db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_ranking_http_smoke.py b/scripts/sw_ranking_http_smoke.py deleted file mode 100644 index c78bda2..0000000 --- a/scripts/sw_ranking_http_smoke.py +++ /dev/null @@ -1,36 +0,0 @@ -"""真实会话只读验证独立排行榜接口,不创建业务数据。""" -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() - checks = [] - for kind in ['channel', 'business', 'game']: - for query, expected in [('sourceDb=lx,hw,yq', 0), ('beginTime=bad&endTime=2026-11-04', 7), ('beginTime=2026-11-05&endTime=2026-11-04', 7)]: - path = '/sw/rank/' + kind + '?' + query - req = urllib.request.Request(args.base_url + path, headers={'x-token': token}) - with urllib.request.urlopen(req, timeout=30) as response: - payload = json.load(response) - item = {'path': path, 'http': response.status, 'code': payload.get('code'), 'msg': payload.get('msg'), 'passed': payload.get('code') == expected} - if expected == 0: - data = payload.get('data', {}) - item['available'] = data.get('available') - item['passed'] &= data.get('available') is False and all(data.get(key) == [] for key in ['registerRankList', 'rechargeRankList', 'payRateRankList', 'activeRankList']) - checks.append(item) - report = {'scope': '真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向排行和权限通过隔离 TCP HTTP 测试。', 'checks': checks, 'passed': all(item['passed'] for item in checks)} - 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_report_build_http_smoke.py b/scripts/sw_report_build_http_smoke.py deleted file mode 100644 index d8df518..0000000 --- a/scripts/sw_report_build_http_smoke.py +++ /dev/null @@ -1,34 +0,0 @@ -"""只读核验汇总任务方法已注册、导入计划保持禁用,不触发实际数据重建。""" -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() diff --git a/scripts/sw_report_rehearsal.py b/scripts/sw_report_rehearsal.py new file mode 100644 index 0000000..9562811 --- /dev/null +++ b/scripts/sw_report_rehearsal.py @@ -0,0 +1,119 @@ +"""备份/比较/恢复 sw8 与 sw9 指定自然日的四张报表,不备份或改写原始业务流水。""" + +import argparse +import datetime as dt +import gzip +import hashlib +import json +import shutil +import subprocess +from pathlib import Path + +import yaml + +from sw_database_rehearsal import client_options, connect, query, write_json + +TABLES = ("tab_sw_report_day", "tab_sw_report_day_key", "tab_sw_report_server_day", "tab_sw_report_server_day_key") + + +def snapshot(conn, database, day, logical=False): + result = {} + for table in TABLES: + rows = query(conn, f"SELECT * FROM `{database}`.`{table}` WHERE stat_date=%s ORDER BY id", (day,)) + if logical: + rows = [{key: value for key, value in row.items() if key not in ("id", "created_at", "updated_at")} for row in rows] + serialized = [json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str) for row in rows] + if logical: + serialized.sort() + result[table] = {"rows": len(rows), "sha256": hashlib.sha256("\n".join(serialized).encode()).hexdigest()} + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("backup", "verify", "restore")) + parser.add_argument("--config", type=Path, default=Path("configs/config.yaml")) + parser.add_argument("--date", default="2026-11-03") + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + dt.date.fromisoformat(args.date) + args.state = args.state.resolve() + if args.state.is_relative_to(Path(__file__).resolve().parents[1]): + raise ValueError("报表备份和身份键必须保存在仓库外") + args.state.mkdir(parents=True, exist_ok=True) + config = yaml.safe_load(args.config.read_text(encoding="utf-8"))["data"]["database"] + manifest_path = args.state / "report-baseline.json" + if args.action == "backup": + if manifest_path.exists(): + raise ValueError("已有基线备份,禁止覆盖") + manifest = {"date": args.date, "databases": {}} + with connect(config) as conn: + manifest["server"] = query(conn, "SELECT @@hostname hostname,@@version version")[0] + for database in ("sw8", "sw9"): + baseline = snapshot(conn, database, args.date) + options = client_options(config, args.state) + file = args.state / (database + "-report-partition.sql.gz") + try: + with (args.state / (database + "-dump.stderr.log")).open("wb") as log: + process = subprocess.Popen([shutil.which("mysqldump"), "--defaults-extra-file=" + str(options), "--single-transaction", "--quick", "--hex-blob", + "--no-create-info", "--skip-triggers", "--skip-add-locks", "--skip-disable-keys", "--complete-insert", "--set-gtid-purged=OFF", + "--where=stat_date='" + args.date + "'", database, *TABLES], stdout=subprocess.PIPE, stderr=log) + with gzip.open(file, "wb") as target: + shutil.copyfileobj(process.stdout, target) + if process.wait() != 0: + raise RuntimeError("报表备份失败") + with gzip.open(file, "rb") as stream: + while stream.read(1024 * 1024): + pass + manifest["databases"][database] = {"tables": baseline, "backup": str(file), "sha256": hashlib.sha256(file.read_bytes()).hexdigest()} + finally: + options.unlink(missing_ok=True) + write_json(manifest_path, manifest) + print(json.dumps({"date": args.date, "tables": {db: value["tables"] for db, value in manifest["databases"].items()}}, ensure_ascii=False), flush=True) + return + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest["date"] != args.date: + raise ValueError("日期与备份不符") + with connect(config) as conn: + if query(conn, "SELECT @@hostname hostname,@@version version")[0] != manifest["server"]: + raise ValueError("数据库服务器与备份不符") + if args.action == "restore": + for database, value in manifest["databases"].items(): + if database not in ("sw8", "sw9"): + raise ValueError("未知数据库") + file = Path(value["backup"]) + if hashlib.sha256(file.read_bytes()).hexdigest() != value["sha256"]: + raise ValueError("备份校验失败") + content = gzip.decompress(file.read_bytes()) + if b"LOCK TABLES" in content or b"ALTER TABLE" in content or b"CREATE TABLE" in content: + raise ValueError("分区恢复文件不允许包含 DDL 或隐式提交") + deletes = "\n".join(f"DELETE FROM `{table}` WHERE stat_date='{args.date}';" for table in TABLES) + script = ("START TRANSACTION;\n" + deletes + "\n").encode() + content + b"\nCOMMIT;\n" + options = client_options(config, args.state) + try: + completed = subprocess.run([shutil.which("mysql"), "--defaults-extra-file=" + str(options), "--batch", database], input=script, capture_output=True) + (args.state / (database + "-restore.stderr.log")).write_bytes(completed.stderr) + if completed.returncode: + raise RuntimeError("分区恢复失败") + finally: + options.unlink(missing_ok=True) + with connect(config) as conn: + checks = [{"database": db, "passed": snapshot(conn, db, args.date) == value["tables"]} for db, value in manifest["databases"].items()] + else: + with connect(config) as conn: + source, target = [snapshot(conn, db, args.date, logical=True) for db in ("sw8", "sw9")] + checks = [{"table": table, "source": source[table], "target": target[table], "passed": source[table] == target[table]} for table in TABLES] + for db in ("sw8", "sw9"): + for table in TABLES: + rows = query(conn, f"SELECT * FROM `{db}`.`{table}` WHERE stat_date=%s ORDER BY id", (args.date,)) + write_json(args.state / (db + "-" + table + "-rebuilt.json"), rows) + result = {"action": args.action, "date": args.date, "checks": checks, "passed": all(item["passed"] for item in checks)} + write_json(args.output or args.state / (args.action + ".json"), result) + print(json.dumps(result, ensure_ascii=False), flush=True) + if not result["passed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_run_report_tasks.py b/scripts/sw_run_report_tasks.py new file mode 100644 index 0000000..44ba3f2 --- /dev/null +++ b/scripts/sw_run_report_tasks.py @@ -0,0 +1,58 @@ +"""通过新商务原生任务 HTTP 重跑指定日期,要求已有分区备份并保持任务禁用。""" + +import argparse +import json +import time +from pathlib import Path + +from sw_write_verify import Rehearsal + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--config", type=Path, default=Path("configs/config.yaml")) + parser.add_argument("--old-url", default="http://127.0.0.1:18080") + parser.add_argument("--new-url", default="http://127.0.0.1:19001") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--date", default="2026-11-03") + parser.add_argument("--backup", type=Path, required=True) + parser.add_argument("--methods", nargs="+", choices=("swReportDayBackfillTask", "swReportServerDayBackfillTask", "alertTriggerTask"), + default=["swReportDayBackfillTask", "swReportServerDayBackfillTask"]) + args = parser.parse_args() + backup = json.loads(args.backup.read_text(encoding="utf-8")) + if backup["date"] != args.date: + raise ValueError("已有备份的日期与任务不符") + run = Rehearsal(args) + ids = [] + try: + for method in args.methods: + name = "迁移核对-" + method + "-" + str(int(time.time())) + run.call("new", "admin", "POST", "/timedTask/createTimedTask", {"name": name, "spec": "@daily", "executorType": "method", "methodName": method, + "params": {"startDate": args.date, "endDate": args.date, "_execution": {"timeoutSeconds": 1800}}, "enabled": False}) + task = run.query("SELECT id,enabled FROM sw9.sys_timed_tasks WHERE name=%s AND deleted_at IS NULL", (name,))[0] + ids.append(task["id"]) + (args.runtime / "report-rehearsal-task-ids.json").write_text(json.dumps(ids), encoding="utf-8") + run.check(method + " 创建且保持禁用", task["enabled"] == 0) + started = time.monotonic() + run.call("new", "admin", "POST", "/timedTask/triggerTimedTask", {"id": task["id"]}) + while time.monotonic() - started < 1830: + value = run.call("new", "admin", "GET", f"/timedTask/getTimedTaskLogList?taskId={task['id']}&page=1&pageSize=1")["data"] + logs = value.get("list", []) + if logs and logs[0].get("status") not in ("running", "pending"): + (args.runtime / (method + "-execution.json")).write_text(json.dumps(logs[0], ensure_ascii=False, indent=2), encoding="utf-8") + run.check(method + " 真实执行完成", logs[0]["status"] == "success", status=logs[0]["status"], elapsed_seconds=round(time.monotonic() - started, 2)) + break + time.sleep(2) + else: + run.check(method + " 等待执行完成", False, reason="timeout") + except Exception as error: + if all(check["passed"] for check in run.checks): + run.check("报表重跑执行异常", False, error_type=type(error).__name__) + raise + finally: + run.db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_sdk_write_verify.py b/scripts/sw_sdk_write_verify.py new file mode 100644 index 0000000..b9070f0 --- /dev/null +++ b/scripts/sw_sdk_write_verify.py @@ -0,0 +1,118 @@ +"""主库使用 sw8/sw9,平台写入使用独立本地 SDK 夹具;核对验密、重提、解绑与新增渠道。""" + +import argparse +import json +import time +from pathlib import Path + +import pymysql + +from sw_api_compare import compare_data +from sw_write_verify import Rehearsal + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--config", type=Path, default=Path("configs/config.yaml")) + parser.add_argument("--old-url", default="http://127.0.0.1:18081") + parser.add_argument("--new-url", default="http://127.0.0.1:19002") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + run = Rehearsal(args) + fixture = json.loads((args.runtime / "sdk-fixture/fixture.json").read_text()) + owner = fixture["owner_id"] + local = pymysql.connect(host="127.0.0.1", port=13309, user="root", autocommit=True, cursorclass=pymysql.cursors.DictCursor) + channel = "migchannel" + str(int(time.time())) + + def query(sql, params=()): + with local.cursor() as cursor: + cursor.execute(sql, params) + return cursor.fetchall() + + def api_path(side, path): + return ("/system/user" if side == "legacy" else "/sw/business") + path + + def bind(side, password=None, success=True, identity="testlevel3"): + return run.call(side, identity, "POST", api_path(side, "/bindPlatform"), + {"sourceDb": "lx", "account": fixture["account"], "password": password or fixture["password"]}, success=success) + + def snapshot(label, status): + values = [] + for side, database in (("legacy", "sw8"), ("new", "sw9")): + rows = run.query(f"SELECT user_id AS userId,source_db AS sourceDb,platform_busier_id AS platformBusierId,platform_busier_account AS platformBusierAccount,status,reject_reason AS rejectReason,review_account AS reviewAccount FROM {database}.sys_user_bind_platform WHERE source_db='lx' AND platform_busier_id=%s", (owner,)) + run.check(label + " 行数和状态 " + side, len(rows) == 1 and rows[0]["status"] == status) + values.append(rows) + compared = compare_data(*values, {}, run.mapping) + run.check(label + " 字段一致", compared["semantic_equal"], differences=compared["differences"]) + + try: + for database in ("sw_test_sdk_old", "sw_test_sdk_new"): + for table in ("tab_promote", "tab_promote_settlement_time"): + run.check("本地平台写入夹具为空 " + database + "." + table, query(f"SELECT COUNT(*) n FROM {database}.{table}")[0]["n"] == 0) + query(f"ALTER TABLE {database}.{table} AUTO_INCREMENT=1") + for side, database in (("legacy", "sw8"), ("new", "sw9")): + run.check("夹具绑定键未占用 " + side, not run.query(f"SELECT user_id FROM {database}.sys_user_bind_platform WHERE source_db='lx' AND platform_busier_id=%s", (owner,))) + denied = bind(side, password="invalid-password", success=False) + run.check("错误密码不允许绑定 " + side, denied["code"] != (200 if side == "legacy" else 0)) + denied = bind(side, success=False, identity="testlevel1") + run.check("仅三级允许绑定 " + side, denied["code"] != (200 if side == "legacy" else 0)) + started = time.monotonic() + for side in ("legacy", "new"): + bind(side) + snapshot("首次绑定待审核", "3") + for side in ("legacy", "new"): + denied = bind(side, success=False) + run.check("待审核重复提交被拒绝 " + side, denied["code"] != (200 if side == "legacy" else 0)) + # 仅在主库夹具模拟 OA 的审核结果,不调用外部 OA 写接口。 + for database in ("sw8", "sw9"): + run.query(f"UPDATE {database}.sys_user_bind_platform SET status='2',reject_reason='测试驳回',review_account='migration-test',review_time=NOW() WHERE source_db='lx' AND platform_busier_id=%s", (owner,)) + time.sleep(max(0, 5.2 - (time.monotonic() - started))) + for side in ("legacy", "new"): + bind(side) + snapshot("驳回后重提并清空旧审核信息", "3") + for database in ("sw8", "sw9"): + run.query(f"UPDATE {database}.sys_user_bind_platform SET status='0',review_account='migration-test',review_time=NOW() WHERE source_db='lx' AND platform_busier_id=%s", (owner,)) + snapshot("审核通过绑定", "0") + payload = {"sourceDb": "lx", "platformBusierId": owner, "account": channel, "password": "Channel168", "registerType": 0} + for side in ("legacy", "new"): + denied = run.call(side, "testlevel1", "POST", run.prefix(side) + "/promote", payload, success=False) + run.check("保留原一级角色的新增渠道权限限制 " + side, denied["code"] != (200 if side == "legacy" else 0)) + run.call(side, "admin", "POST", run.prefix(side) + "/promote", payload) + saved, settlements = [], [] + for side, database in (("legacy", "sw_test_sdk_old"), ("new", "sw_test_sdk_new")): + rows = query(f"SELECT id,account,password,status,busier_id,register_type,promote_level,parent_id,top_promote_id,game_ids2 FROM {database}.tab_promote WHERE account=%s", (channel,)) + run.check("源平台渠道只创建一条 " + side, len(rows) == 1) + saved.append(rows[0]) + settlement = query(f"SELECT * FROM {database}.tab_promote_settlement_time WHERE promote_id=%s", (rows[0]["id"],)) + run.check("七日结算及时间关系 " + side, len(settlement) == 1 and settlement[0]["day_period"] == 7 and settlement[0]["time_type"] == 0 and settlement[0]["next_count_time"] - settlement[0]["create_time"] == 7 * 86400) + relation = query(f"SELECT promote_ids FROM {database}.tab_promote_business WHERE id=%s", (owner,))[0]["promote_ids"] + run.check("平台商务渠道关系已追加 " + side, str(rows[0]["id"]) in (relation or "").split(",")) + run.check("新渠道属性及兼容密码完全一致", saved[0] == saved[1] and saved[0]["status"] == -3 and saved[0]["promote_level"] == 1) + for side, database in (("legacy", "sw8"), ("new", "sw9")): + denied = run.call(side, "admin", "POST", run.prefix(side) + "/promote", payload, success=False) + run.check("重复渠道账号被拒绝 " + side, denied["code"] != (200 if side == "legacy" else 0)) + run.call(side, "testlevel3", "DELETE", api_path(side, "/bindPlatform"), {"sourceDb": "lx", "platformBusierId": owner}) + run.check("解绑只删除当前绑定 " + side, not run.query(f"SELECT user_id FROM {database}.sys_user_bind_platform WHERE user_id=%s AND source_db='lx' AND platform_busier_id=%s", (run.owner(side, 3), owner))) + except Exception as error: + if all(check["passed"] for check in run.checks): + run.check("SDK 验证执行异常", False, error_type=type(error).__name__) + raise + finally: + for side, database in (("legacy", "sw8"), ("new", "sw9")): + run.query(f"DELETE FROM {database}.sys_user_bind_platform WHERE user_id=%s AND source_db='lx' AND platform_busier_id=%s AND platform_busier_account=%s", (run.owner(side, 3), owner, fixture["account"])) + run.query("DELETE FROM sw9.sys_business_binding_locks WHERE source_db='lx' AND platform_busier_id=%s", (owner,)) + for database in ("sw_test_sdk_old", "sw_test_sdk_new"): + rows = query(f"SELECT id FROM {database}.tab_promote WHERE account=%s", (channel,)) + for row in rows: + query(f"DELETE FROM {database}.tab_promote_settlement_time WHERE promote_id=%s", (row["id"],)) + query(f"DELETE FROM {database}.tab_promote WHERE account=%s", (channel,)) + query(f"UPDATE {database}.tab_promote_business SET promote_ids='' WHERE id=%s AND account=%s", (owner, fixture["account"])) + remaining = sum(run.query(f"SELECT COUNT(*) n FROM {db}.sys_user_bind_platform WHERE source_db='lx' AND platform_busier_id=%s", (owner,))[0]["n"] for db in ("sw8", "sw9")) + run.check("主库 SDK 绑定夹具已清理", remaining == 0) + local.close() + run.db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_server_analysis_http_smoke.py b/scripts/sw_server_analysis_http_smoke.py deleted file mode 100644 index 75c9254..0000000 --- a/scripts/sw_server_analysis_http_smoke.py +++ /dev/null @@ -1,44 +0,0 @@ -"""只读验证区服分析接口;完整统计由隔离数据库的真实 TCP HTTP 测试覆盖。""" -import argparse -import json -import urllib.parse -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() - checks = [] - cases = [ - ('list', {}, 7), ('servers', {}, 7), - ('list', {'gameName': '隔离核验不存在游戏', 'sourceDb': 'lx,hw,yq'}, 0), - ('list', {'gameName': 'x', 'beginTime': 'bad', 'endTime': '2026-11-04'}, 7), - ('list', {'gameName': 'x', 'beginTime': '2026-11-05', 'endTime': '2026-11-04'}, 7), - ('servers', {'gameName': '隔离核验不存在游戏', 'sourceDb': 'lx'}, 7), - ] - for endpoint, query, expected in cases: - path = '/sw/serverAnalysis/' + endpoint + '?' + urllib.parse.urlencode(query) - req = urllib.request.Request(args.base_url + path, headers={'x-token': token}) - with urllib.request.urlopen(req, timeout=30) as response: - payload = json.load(response) - data = payload.get('data') - item = {'endpoint': endpoint, 'query': query, 'http': response.status, 'code': payload.get('code'), 'msg': payload.get('msg'), 'passed': payload.get('code') == expected} - if isinstance(data, dict) and 'available' in data: - item.update(available=data['available'], total=data.get('total')) - if data['available'] is False: - item['passed'] &= data.get('summary') is None - checks.append(item) - report = {'scope': '真实 sw7 会话只读检查,当前缺少业务报表和游戏区服表;未创建实际业务数据。', 'checks': checks, 'passed': all(item['passed'] for item in checks)} - 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_user_status_http_smoke.py b/scripts/sw_user_status_http_smoke.py deleted file mode 100644 index 351a134..0000000 --- a/scripts/sw_user_status_http_smoke.py +++ /dev/null @@ -1,48 +0,0 @@ -"""只读核对原生账号与商务状态;写接口只发送缺失 ID 的无效请求。""" - -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() - checks = [] - - def request(method, path, body=None, expected=0): - req = urllib.request.Request(args.base_url + path, method=method, - headers={"x-token": token, "Content-Type": "application/json"}, - data=json.dumps(body).encode() if body is not None else None) - with urllib.request.urlopen(req, timeout=30) as response: - result = json.load(response) - checks.append({"method": method, "path": path, "http": response.status, - "code": result.get("code"), "msg": result.get("msg"), - "passed": response.status == 200 and result.get("code") == expected}) - return result.get("data", {}) - - native = request("POST", "/user/getUserList", {"page": 1, "pageSize": 500}) - business = request("GET", "/sw/business/list?pageNum=1&pageSize=500") - native_rows = native.get("list", []) - by_id = {row["userId"]: row for row in business.get("rows", [])} - conflicts = sum((row.get("enable") == 1) != (row.get("businessStatus") == 0) for row in native_rows) - mismatches = sum(str(row.get("businessStatus")) != by_id.get(row["ID"], {}).get("status") for row in native_rows) - request("PUT", "/user/setUserInfo", {"ID": 0, "enable": 1}, expected=7) - report = {"scope": "真实 sw7 登录会话,只读检查;未启停任何真实账号。正向启停和审核状态保留由隔离 TCP HTTP 测试覆盖。", - "checks": checks, "native_total": native.get("total"), "business_total": business.get("total"), - "checked_users": len(native_rows), "inconsistent_states": conflicts, "response_mismatches": mismatches, - "passed": all(item["passed"] for item in checks) and conflicts == 0 and mismatches == 0 - and len(native_rows) == native.get("total") == business.get("total")} - 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_workbench_http_smoke.py b/scripts/sw_workbench_http_smoke.py deleted file mode 100644 index 9a4df6b..0000000 --- a/scripts/sw_workbench_http_smoke.py +++ /dev/null @@ -1,41 +0,0 @@ -"""真实会话只读验证工作台接口与缺表提示,不制造实际商务数据。""" -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() - checks = [] - for endpoint in ['summary', 'registerTop', 'payRank', 'unloginTop']: - cases = [('', 7), ('sourceDbList=lx,hw,yq', 0 if endpoint == 'summary' else 7)] - if endpoint in ['registerTop', 'payRank']: - cases.append(('sourceDbList=lx&period=invalid', 7)) - if endpoint == 'payRank': - cases.append(('sourceDbList=lx,hw,yq&order=asc&period=last30', 7)) - for query, expected in cases: - path = '/sw/workbench/' + endpoint + '?' + query - req = urllib.request.Request(args.base_url + path, headers={'x-token': token}) - with urllib.request.urlopen(req, timeout=30) as response: - payload = json.load(response) - item = {'path': path, 'http': response.status, 'code': payload.get('code'), 'msg': payload.get('msg'), 'passed': payload.get('code') == expected} - if endpoint == 'summary' and expected == 0: - data = payload.get('data', {}) - item['available'] = data.get('available') - item['passed'] &= data.get('available') is False and all(data.get(key, {}).get('value') is None for key in ['todayRegister', 'todayActive', 'todayPay', 'monthPay', 'activePromote', 'newPromote']) - checks.append(item) - report = {'scope': '真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向六卡、榜单与权限通过隔离 TCP HTTP 测试。', 'checks': checks, 'passed': all(item['passed'] for item in checks)} - 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_write_verify.py b/scripts/sw_write_verify.py new file mode 100644 index 0000000..57395e3 --- /dev/null +++ b/scripts/sw_write_verify.py @@ -0,0 +1,207 @@ +"""在 sw8/sw9 专用测试账号下核对写流程;临时渠道、绑定、预警及规则在 finally 中清理。""" + +import argparse +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +import pymysql +import yaml + +from sw_api_compare import compare_data + + +class Rehearsal: + def __init__(self, args): + self.args = args + self.checks = [] + args.output.write_text(json.dumps({"checks": [], "passed": False, "status": "running"}) + "\n", encoding="utf-8") + self.root = args.runtime + self.users = json.loads((self.root / "synthetic-test-users.json").read_text(encoding="utf-8")) + self.tokens = {(side, identity): (self.root / f"{side}-{identity}.token").read_text().strip() + for side in ("legacy", "new") for identity in ("admin", "level3", "testlevel1", "testlevel2", "testlevel3")} + self.client = json.loads((self.root / "legacy-client.json").read_text())["client_id"] + maps = json.loads((self.root / "id-maps.json").read_text()) + self.mapping = {str(row["old_id"]): str(row["new_id"]) for row in maps if row["entity"] == "user"} + config = yaml.safe_load(args.config.read_text(encoding="utf-8"))["data"]["database"] + self.db = pymysql.connect(host=config["host"], port=int(config["port"]), user=config["user"], password=config["password"], + autocommit=True, charset="utf8mb4", read_timeout=60, cursorclass=pymysql.cursors.DictCursor) + self.rule_ids = {} + self.suffix = str(int(time.time())) + self.rule_type = "mig_verify_" + self.suffix + self.account = "mig_verify_" + self.suffix + self.promote_id = 2000000000 + int(self.suffix[-7:]) + self.alert_id = 9007199254740993 + int(self.suffix[-7:]) + self.fixtures = [] + + def query(self, sql, params=()): + with self.db.cursor() as cursor: + cursor.execute(sql, params) + return cursor.fetchall() + + def check(self, name, passed, **details): + self.checks.append({"check": name, "passed": bool(passed), **details}) + self.args.output.write_text(json.dumps({"checks": self.checks, "passed": all(item["passed"] for item in self.checks)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(self.checks[-1], ensure_ascii=False), flush=True) + if not passed: + raise AssertionError(name) + + def call(self, side, identity, method, path, payload=None, success=True): + old = side == "legacy" + headers = {"Content-Type": "application/json"} + headers.update({"Authorization": "Bearer " + self.tokens[side, identity], "clientid": self.client} if old else {"x-token": self.tokens[side, identity]}) + request = urllib.request.Request((self.args.old_url if old else self.args.new_url) + path, + data=json.dumps(payload).encode() if payload is not None else None, method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=60) as response: + raw = response.read() + status = response.status + except urllib.error.HTTPError as response: + raw = response.read() + status = response.code + try: + reply = json.loads(raw) + except (ValueError, UnicodeDecodeError): + self.check(path + " " + side, False, http=status, response_bytes=len(raw), error="响应不是 JSON") + if success and reply.get("code") != (200 if old else 0): + self.check(path + " " + side, False, code=reply.get("code"), message=reply.get("msg")) + return reply + + def owner(self, side, level): + return self.users[level - 1]["source_id" if side == "legacy" else "target_id"] + + def prefix(self, side): + return "/system" if side == "legacy" else "/sw" + + def rule_snapshot(self, name): + values = [self.call(side, "testlevel1", "GET", self.prefix(side) + "/alertRule/" + str(self.rule_ids[side]))["data"] for side in ("legacy", "new")] + options = {"source_omit_fields": ["id", "createdAt", "updatedAt"], "target_omit_fields": ["id", "createdAt", "updatedAt"], + "null_empty_fields": ["momDirectionLabel"]} + result = compare_data(*values, options, self.mapping) + self.check(name, result["semantic_equal"], differences=result["differences"]) + return values + + def rules(self): + self.rule = {"ruleName": "迁移核对临时规则", "ruleType": self.rule_type, "monitorMetric": 3, "unloginDays": 2, "monitorScope": 1, "warningColor": "#f56c6c"} + for side, database in (("legacy", "sw8"), ("new", "sw9")): + self.call(side, "testlevel1", "POST", self.prefix(side) + "/alertRule/add", self.rule) + rows = self.query(f"SELECT id FROM {database}.sys_alert_rule WHERE creator_id=%s AND rule_type=%s", (self.owner(side, 1), self.rule_type)) + self.check("新增规则 " + side, len(rows) == 1) + self.rule_ids[side] = rows[0]["id"] + self.rule_snapshot("新增规则字段一致") + for side in ("legacy", "new"): + self.call(side, "testlevel1", "PUT", self.prefix(side) + f"/alertRule/toggle/{self.rule_ids[side]}?enabled=0") + values = self.rule_snapshot("规则禁用一致") + self.check("禁用状态生效", all(row["enabled"] == 0 for row in values)) + self.rule.update(monitorMetric=1, statPeriodDays=3, momDirection=2, momThreshold=12.5) + self.rule.pop("unloginDays") + for side in ("legacy", "new"): + self.call(side, "testlevel1", "PUT", self.prefix(side) + "/alertRule/edit", {**self.rule, "id": self.rule_ids[side]}) + values = self.rule_snapshot("编辑保留启停和可选历史字段") + self.check("编辑未重启规则且保留未登录天数", all(row["enabled"] == 0 and row["unloginDays"] == 2 for row in values)) + for side in ("legacy", "new"): + rejected = self.call(side, "testlevel2", "PUT", self.prefix(side) + f"/alertRule/toggle/{self.rule_ids[side]}?enabled=1", success=False) + self.check("二级不能修改一级规则 " + side, rejected.get("code") != (200 if side == "legacy" else 0)) + self.call(side, "testlevel1", "PUT", self.prefix(side) + f"/alertRule/toggle/{self.rule_ids[side]}?enabled=1") + self.rule_snapshot("重新启用规则一致") + + def prepare_alert(self): + for side, database in (("legacy", "sw8"), ("new", "sw9")): + existing = self.query(f"SELECT id FROM {database}.tab_promote WHERE id=%s AND source_db='lx'", (self.promote_id,)) + self.check("临时渠道 ID 未占用 " + side, not existing) + existing = self.query(f"SELECT id FROM {database}.sys_alert_record WHERE id=%s", (self.alert_id,)) + self.check("临时预警 ID 未占用 " + side, not existing) + self.fixtures.append({"side": side, "database": database, "promote_id": self.promote_id, "alert_id": self.alert_id, "account": self.account, + "owner": self.owner(side, 1), "user": self.owner(side, 3)}) + (self.root / "write-fixtures.json").write_text(json.dumps(self.fixtures, indent=2), encoding="utf-8") + self.query(f"INSERT INTO {database}.tab_promote(id,source_db,account,busier_id,promote_level,status) VALUES(%s,'lx',%s,%s,1,1)", (self.promote_id, self.account, self.promote_id)) + self.query(f"INSERT INTO {database}.sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status,create_time) VALUES(%s,'lx',%s,%s,'0',NOW())", (self.owner(side, 3), self.promote_id, self.account)) + self.query(f"INSERT INTO {database}.sys_alert_record(id,platform_code,platform_name,rule_id,rule_name,rule_type,monitor_metric,target_promote_id,target_promote_name,business_id,business_name,alert_content,status,alert_date,created_at,updated_at) VALUES(%s,'lx','龙翔',%s,%s,%s,1,%s,%s,%s,%s,'迁移核对临时预警',1,CURDATE(),NOW(),NOW())", + (self.alert_id, self.rule_ids[side], self.rule["ruleName"], self.rule_type, self.promote_id, self.account, self.owner(side, 1), self.users[0]["username"])) + + def alert_snapshot(self, label, expected_status): + values = [self.call(side, "testlevel1", "GET", self.prefix(side) + f"/alertRecord/detail/{self.alert_id}")["data"] for side in ("legacy", "new")] + for row in values: + row["alertRecord"].pop("ruleId", None) + options = {"source_omit_fields": ["id", "createdAt", "updatedAt"], "target_omit_fields": ["id", "createdAt", "updatedAt"], + "null_empty_fields": ["actionDetail", "noteContent"]} + # 日志主键独立生成;按原操作序列比较业务字段及操作人映射。 + result = compare_data(*values, options, self.mapping) + self.check(label, result["semantic_equal"] and all(row["alertRecord"]["status"] == expected_status for row in values), + status=expected_status, log_counts=[len(row["operationLogs"]) for row in values], differences=result["differences"]) + + def workflow(self): + self.prepare_alert() + self.alert_snapshot("临时预警初始状态一致", 1) + for side in ("legacy", "new"): + rejected = self.call(side, "level3", "GET", self.prefix(side) + f"/alertRecord/detail/{self.alert_id}", success=False) + self.check("其他商务不可查看临时预警 " + side, rejected.get("code") != (200 if side == "legacy" else 0)) + steps = [("startProcess", "testlevel3", 2), ("submitAudit", "testlevel3", 3), ("reject", "testlevel1", 4), + ("reProcess", "testlevel3", 2), ("submitAudit", "testlevel3", 3), ("approve", "testlevel1", 5)] + for index, (action, identity, status) in enumerate(steps): + for side in ("legacy", "new"): + self.call(side, identity, "POST", self.prefix(side) + "/alertRecord/" + action, {"alertId": str(self.alert_id), "note": "迁移核对步骤" + str(index)}) + self.alert_snapshot(action + " 状态及日志一致", status) + if index == 1: + for side in ("legacy", "new"): + rejected = self.call(side, "testlevel3", "POST", self.prefix(side) + "/alertRecord/approve", {"alertId": str(self.alert_id)}, success=False) + self.check("三级商务不能审核 " + side, rejected.get("code") != (200 if side == "legacy" else 0)) + for side in ("legacy", "new"): + self.call(side, "testlevel3", "POST", self.prefix(side) + "/alertRecord/addNote", {"alertId": str(self.alert_id), "noteContent": "迁移核对独立备注", "attachmentIds": []}) + self.alert_snapshot("已处理记录追加备注不改变状态", 5) + + def remove_rule(self): + for side, database in (("legacy", "sw8"), ("new", "sw9")): + self.call(side, "testlevel1", "DELETE", self.prefix(side) + "/alertRule/" + str(self.rule_ids[side])) + row = self.query(f"SELECT is_deleted FROM {database}.sys_alert_rule WHERE id=%s", (self.rule_ids[side],))[0] + self.check("删除规则保留历史行 " + side, row["is_deleted"] == 1) + + def cleanup(self): + for fixture in reversed(self.fixtures): + database = fixture["database"] + self.query(f"DELETE FROM {database}.sys_alert_operation_log WHERE alert_id=%s", (fixture["alert_id"],)) + self.query(f"DELETE FROM {database}.sys_alert_record WHERE id=%s AND target_promote_name=%s", (fixture["alert_id"], fixture["account"])) + self.query(f"DELETE FROM {database}.sys_user_bind_platform WHERE user_id=%s AND source_db='lx' AND platform_busier_id=%s AND platform_busier_account=%s", (fixture["user"], fixture["promote_id"], fixture["account"])) + self.query(f"DELETE FROM {database}.tab_promote WHERE id=%s AND source_db='lx' AND account=%s", (fixture["promote_id"], fixture["account"])) + for side, database in (("legacy", "sw8"), ("new", "sw9")): + self.query(f"DELETE FROM {database}.sys_alert_rule WHERE creator_id=%s AND rule_type=%s", (self.owner(side, 1), self.rule_type)) + remaining = 0 + for fixture in self.fixtures: + database = fixture["database"] + for table, condition, params in ( + ("sys_alert_record", "id=%s", (fixture["alert_id"],)), + ("sys_alert_operation_log", "alert_id=%s", (fixture["alert_id"],)), + ("tab_promote", "id=%s AND source_db='lx'", (fixture["promote_id"],)), + ("sys_user_bind_platform", "user_id=%s AND source_db='lx' AND platform_busier_id=%s", (fixture["user"], fixture["promote_id"])), + ): + remaining += self.query(f"SELECT COUNT(*) n FROM {database}.{table} WHERE {condition}", params)[0]["n"] + for side, database in (("legacy", "sw8"), ("new", "sw9")): + remaining += self.query(f"SELECT COUNT(*) n FROM {database}.sys_alert_rule WHERE creator_id=%s AND rule_type=%s", (self.owner(side, 1), self.rule_type))[0]["n"] + self.check("临时预警渠道绑定和规则已清理", remaining == 0, remaining=remaining) + self.db.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runtime", type=Path, required=True) + parser.add_argument("--config", type=Path, default=Path("configs/config.yaml")) + parser.add_argument("--old-url", default="http://127.0.0.1:18080") + parser.add_argument("--new-url", default="http://127.0.0.1:19001") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]): + raise ValueError("测试状态必须保存在仓库外") + run = Rehearsal(args) + try: + run.rules() + run.workflow() + run.remove_rule() + finally: + run.cleanup() + + +if __name__ == "__main__": + main() diff --git a/web/src/components/businessPreference/index.vue b/web/src/components/businessPreference/index.vue index 3c3ab5b..3275402 100644 --- a/web/src/components/businessPreference/index.vue +++ b/web/src/components/businessPreference/index.vue @@ -1,27 +1,16 @@ + + diff --git a/web/src/utils/request.js b/web/src/utils/request.js index de4a0e6..cc0a625 100644 --- a/web/src/utils/request.js +++ b/web/src/utils/request.js @@ -195,7 +195,7 @@ service.interceptors.response.use( closeLoading(response.config.loadingOption) } - if (response.headers['new-token']) { + if (response.headers['new-token'] && userStore.token && userStore.token === response.config.headers?.['x-token']) { userStore.setToken(response.headers['new-token']) } @@ -229,14 +229,19 @@ service.interceptors.response.use( } if (error.response.status === 401) { + // Session expiry must take effect even if another error dialog is open or dismissed. + const userStore = useUserStore() + const requestToken = error.config?.headers?.['x-token'] + if (requestToken && userStore.token && requestToken !== userStore.token) { + return Promise.reject(error) + } + userStore.ClearStorage() + if (router.currentRoute.value.name !== 'Login') { + router.replace({ name: 'Login' }) + } emitter.emit('show-error', { code: '401', - message: getErrorMessage(error), - fn: () => { - const userStore = useUserStore() - userStore.ClearStorage() - router.push({ name: 'Login', replace: true }) - } + message: getErrorMessage(error) }) return Promise.reject(error) } diff --git a/web/src/view/dashboard/components/BusinessDashboard.vue b/web/src/view/dashboard/components/BusinessDashboard.vue index 2bb6de3..0525f20 100644 --- a/web/src/view/dashboard/components/BusinessDashboard.vue +++ b/web/src/view/dashboard/components/BusinessDashboard.vue @@ -1,7 +1,10 @@