From 9fccd804a370dc4af2718b7684fb789c96bd0652 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Fri, 6 Nov 2026 15:48:35 +0800 Subject: [PATCH] feat: automate business upgrades and sync reviewed frontend --- .gitignore | 3 + cmd/business_http_test.go | 2 +- cmd/ledger_http_test.go | 15 +- cmd/main.go | 6 +- deploy/docker/README.md | 33 +- deploy/docker/build-package.ps1 | 6 +- deploy/docker/deploy.sh | 15 +- deploy/migrations/assets.go | 79 + deploy/migrations/cleanup-legacy.sql | 401 ++++ deploy/migrations/cleanup_test.go | 297 +++ deploy/migrations/legacy-framework.sql | 8 +- internal/biz/business/account.go | 2 +- internal/biz/business/ledger.go | 4 - internal/biz/system/authority.go | 11 + internal/config/types.go | 8 +- internal/data/business/account.go | 19 +- internal/data/business/account_test.go | 7 +- internal/data/business/ledger.go | 37 +- internal/data/business/ledger_test.go | 2 +- internal/data/business/migration_schema.go | 8 + internal/data/business/migrations.go | 3 +- internal/data/config_store.go | 12 +- internal/data/data.go | 17 +- internal/data/migrate.go | 50 +- internal/data/swmigration/backup.go | 317 ++++ internal/data/swmigration/backup_test.go | 155 ++ internal/data/swmigration/framework.go | 119 ++ internal/data/swmigration/runner.go | 426 +++++ internal/data/swmigration/runner_test.go | 609 ++++++ internal/data/swmigration/state.go | 38 + .../swmigration/testdata/legacy-schema.sql | 1672 +++++++++++++++++ .../swmigration/testdata/legacy-surface.json | 544 ++++++ internal/data/system/authority.go | 3 + internal/data/system/business_roles.go | 119 ++ internal/data/system/business_roles_test.go | 72 + internal/data/system/migrations.go | 1 + internal/data/system/models.go | 1 + internal/data/system/user.go | 2 +- internal/modules/catalog_test.go | 4 +- web/src/utils/blobError.js | 26 + web/src/utils/blobError.test.js | 28 + web/src/utils/request.js | 26 +- .../components/BusinessDashboard.vue | 70 +- .../dashboard/components/dashboard-metrics.js | 34 +- .../components/dashboard-metrics.test.js | 4 +- web/src/view/layout/header/tools.vue | 4 +- web/src/view/superAdmin/user/user.vue | 6 +- web/src/view/sw/BusinessPerformance.vue | 197 +- web/src/view/sw/RankingAnalysis.vue | 2 +- .../view/sw/components/BusinessBindings.vue | 2 +- .../view/sw/components/BusinessManagement.vue | 145 +- web/src/view/sw/components/LedgerDetails.vue | 8 +- .../sw/components/PerformanceDetailDrawer.vue | 23 +- .../view/sw/components/PerformanceScroll.vue | 2 +- .../view/sw/components/ReportAnalytics.vue | 8 +- web/src/view/sw/components/TeamSettings.vue | 229 ++- web/src/view/systemTools/timedTask/index.vue | 4 +- web/src/view/systemTools/version/version.vue | 3 +- 58 files changed, 5689 insertions(+), 259 deletions(-) create mode 100644 deploy/migrations/assets.go create mode 100644 deploy/migrations/cleanup-legacy.sql create mode 100644 deploy/migrations/cleanup_test.go create mode 100644 internal/data/swmigration/backup.go create mode 100644 internal/data/swmigration/backup_test.go create mode 100644 internal/data/swmigration/framework.go create mode 100644 internal/data/swmigration/runner.go create mode 100644 internal/data/swmigration/runner_test.go create mode 100644 internal/data/swmigration/state.go create mode 100644 internal/data/swmigration/testdata/legacy-schema.sql create mode 100644 internal/data/swmigration/testdata/legacy-surface.json create mode 100644 internal/data/system/business_roles.go create mode 100644 internal/data/system/business_roles_test.go create mode 100644 web/src/utils/blobError.js create mode 100644 web/src/utils/blobError.test.js diff --git a/.gitignore b/.gitignore index 5725b71..0be8257 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ __pycache__/ /scripts/ /test-results/ /playwright-report/ + +# Private automatic migration archives (never serve or commit these). +migration-backups/ diff --git a/cmd/business_http_test.go b/cmd/business_http_test.go index bd7c0b8..465eb4d 100644 --- a/cmd/business_http_test.go +++ b/cmd/business_http_test.go @@ -52,7 +52,7 @@ func TestBusinessHTTPListWithSystemOnlyAndCompleteDatabase(t *testing.T) { } for _, statement := range []string{ "INSERT INTO sys_users(id,uuid,username,authority_id,level,id_path) VALUES (1,'admin','admin',888,0,'0'),(2,'child','child',901,1,'0'),(3,'empty','empty',903,3,'0,2')", - "INSERT INTO sys_authorities(authority_id,authority_name) VALUES (901,'一级商务')", + "INSERT INTO sys_authorities(authority_id,authority_name,business_level) VALUES (901,'一级商务',1)", "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES (2,'lx',9,'0')", } { if err := db.Exec(statement).Error; err != nil { diff --git a/cmd/ledger_http_test.go b/cmd/ledger_http_test.go index ca264a5..586b551 100644 --- a/cmd/ledger_http_test.go +++ b/cmd/ledger_http_test.go @@ -40,6 +40,7 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) { } for _, sql := range []string{ "INSERT INTO sys_users(id,uuid,username,level,parent_id,id_path,enable) VALUES(1,'root','root',0,0,'0',1),(7,'owner','owner',1,0,'0',1),(9,'leaf','leaf',3,7,'0,7',1),(99,'other','other',1,0,'0',1)", + "UPDATE sys_users SET nick_name='商务昵称' WHERE id=9", "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'sdk','0'),(99,'hw',20,'other','0')", "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER)", "INSERT INTO tab_promote VALUES(100,'lx','root-channel',1,0,14),(101,'lx','child-channel',2,100,14),(100,'hw','outside',1,0,20)", @@ -110,7 +111,7 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) { data := call(7, "GET", "/sw/register/list?sourceDb=lx&sortField=totalPayAmount", "", 0).(map[string]any) rows := data["rows"].([]any) first := rows[0].(map[string]any) - if data["total"] != float64(2) || first["userId"] != "1" || first["totalPayAmount"] != "1012.34" || first["fgameName"] != "历史游戏" || first["busierName"] != "owner→leaf" { + if data["total"] != float64(2) || first["userId"] != "1" || first["totalPayAmount"] != "1012.34" || first["fgameName"] != "历史游戏" || first["busierName"] != "sdk(leaf)" { t.Fatalf("注册明细口径或字段错误: %+v", data) } games := call(7, "GET", "/sw/serverAnalysis/games?sourceDb=lx&limit=50", "", 0).([]any) @@ -125,6 +126,9 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) { if data["total"] != float64(1) || data["totalPayAmount"] != "0.00" { t.Fatal("合计不得包含筛选条件外的成功订单") } + if data["rows"].([]any)[0].(map[string]any)["busierName"] != "sdk(leaf)" { + t.Fatal("充值明细归属商务应显示平台账号和商务登录名") + } data = call(1, "GET", "/sw/spend/list?sourceDb=lx", "", 0).(map[string]any) if data["total"] != float64(4) || data["totalPayAmount"] != "20.34" { t.Fatal("超管全平台遗漏官方订单") @@ -154,6 +158,11 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) { if err != nil || len(xlsRows) != 3 || xlsRows[0][7] != "累计充值" { t.Fatal("注册 XLSX 字段或行数错误") } + for _, row := range xlsRows[1:] { + if len(row) != 13 || row[12] != "sdk(leaf)" { + t.Fatalf("注册导出归属商务格式错误: %+v", row) + } + } raw, header = request(7, "POST", "/sw/spend/export", "sourceDb=lx&payStatus=1&sortField=payAmount") if header.Get("success") != "true" { t.Fatalf("充值导出失败: %s", raw) @@ -163,10 +172,14 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) { t.Fatal(err) } order, _ := f.GetCellValue("充值明细", "E2") + busierName, busierErr := f.GetCellValue("充值明细", "R2") f.Close() if order != "000000000001" { t.Fatal("订单号导出丢失前导零") } + if busierErr != nil || busierName != "sdk(leaf)" { + t.Fatalf("充值导出归属商务格式错误: %q %v", busierName, busierErr) + } call(7, "GET", "/sw/register/list?beginRegisterTime=bad&endRegisterTime=2026-11-04", "", 7) if err := db.Exec("WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<10001) INSERT INTO tab_user(id,source_db,register_time,fgame_id,fgame_name,promote_id,puid) SELECT 1000+n,'lx',?,1,'游戏',100,0 FROM seq", strconv.FormatInt(stamp, 10)).Error; err != nil { t.Fatal(err) diff --git a/cmd/main.go b/cmd/main.go index 70aa1a2..25e987e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,7 +6,9 @@ import ( "log/slog" "net" "os" + "os/signal" "strings" + "syscall" "kra/internal/config" "kra/internal/data" @@ -173,7 +175,9 @@ func main() { if bc.Data != nil { database = bc.Data.Database } - if err := data.MigrateDatabase(context.Background(), database, modules.Catalog(), logger, flagCheckSchema); err != nil { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + if err := data.MigrateDatabase(ctx, database, modules.Catalog(), logger, flagCheckSchema, runtime.ConfigPath()); err != nil { panic(err) } logger.Info("database migration verification completed", "mod", "system", "check_only", flagCheckSchema) diff --git a/deploy/docker/README.md b/deploy/docker/README.md index 6909113..3735b5d 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -15,23 +15,30 @@ - 后端仅绑定 `127.0.0.1:9602`,前端仅绑定 `127.0.0.1:9601`。 - 宝塔 `sw2.fuhua91.com` 代理至 9601,前端容器将 `/api/` 转发至后端并去除前缀,支持 WebSocket、SSE 和文件上传。 -数据库沿用已迁移并验证的 `sw10`,设置 `admin.system.disable_auto_migrate: true`,由部署前的独立迁移命令更新结构。平台别名为 `lx/hw/yq/oa`,其中三个 SDK 别名连接 `game_lx/game_hw/game_yq` 测试库,Redis 沿用当前关闭状态。生产配置使用 `admin.app.env: production`,关闭开发 Swagger。七条常规任务启用(四条商务/区服日汇总、预警检测、两条有效端刷新),两个历史回填停用。**使用同一 sw10 时只运行一个调度实例,切换到服务器前停止本机的后端服务。** +测试数据库为 `sw10`。程序和部署入口会自动识别旧商务库并完成整套升级;已有 `disable_auto_migrate` 配置不会跳过商务升级。平台别名为 `lx/hw/yq/oa`,其中三个 SDK 别名连接 `game_lx/game_hw/game_yq` 测试库,Redis 沿用当前关闭状态。生产配置使用 `admin.app.env: production`,关闭开发 Swagger。七条常规任务启用(四条商务/区服日汇总、预警检测、两条有效端刷新),两个历史回填停用。**使用同一 sw10 时只运行一个调度实例,切换到服务器前停止本机的后端服务。** -## 数据库迁移 +## 全自动数据库升级 -新副本必须先完整复制旧商务数据库,再执行 [旧框架转换 SQL](../migrations/legacy-framework.sql),最后使用本次发布的后端执行全部增量迁移: +将主数据库配置为准备接管的旧商务库,然后正常部署或启动程序即可。程序不创建空库替代旧数据,不需要手动运行 SQL、建立索引、回填角色映射或执行旧表清理。部署脚本中的 `-migrate` 与直接启动后端使用同一条自动流程。 + +执行顺序:识别旧库并取得迁移锁 → 备份受影响的数据 → 转换旧框架 → 执行全部增量迁移和索引 → 校验结构、数据记录和商务角色 → 备份待清理表 → 删除明确名单中的旧表 → 再次校验 → 开放业务接口及调度任务。首次接管前,旧程序应处于正常发布停写状态;检测到迁移期间账号发生变化时会停止清理。 + +阶段进度、失败原因、备份摘要和完成时间记录在 `sys_business_upgrade_state`,迁移版本仍记录在 `sys_schema_migrations`。同库的多个新实例会等待同一把锁。建索引不设置总超时,日志每 10 秒报告当前阶段;在全部步骤完成前,业务服务不会就绪。 + +备份自动写入配置文件旁的 `migration-backups/`,在当前 Docker 部署中对应 `/www/wwwroot/sw2.fuhua91.com/config/migration-backups/`。目录权限为 700、文件为 600,不对外提供下载。备份包含受转换影响的系统数据、待删除旧表及其结构;只新增索引的原始业务大表保留在原库,其旧结构以注释写入备份。渠道主键扩容涉及的 `tab_promote` 同时备份数据。备份完整写入并校验 SHA-256 后才允许清理,历史备份不会随容器替换删除。 + +旧角色映射会一次性回填到 `sys_authorities.business_level`,之后创建商务账号只按稳定的等级标识查角色。角色可改名,复制角色不复制该标识,内置商务角色禁止删除。旧用户 ID、角色 ID、权限关联及主键自增高水位保持迁移约定,不因移除映射表再次编号。 + +当前自动清理名单为 79 张:19 张旧框架归档、26 张迁移前备份、23 张旧 SnailJob 表、4 张旧代码生成/演示表、2 张旧预警去重备份,以及 5 张已归档的 `sw_kra_*` 迁移辅助表。正常业务表和两个必要的迁移状态表保留;原 156 表的已迁移库升级后为 78 表。初次迁移时沿用旧任务启停状态,已运行的新商务库则保持现有任务设置。 + +发生中断后,重新启动或重新部署会根据持久化状态继续:尚未提交数据的旧框架转换可重新创建空目标结构,已提交的数据不会再次映射 ID,已有索引会校验后跳过,部分完成的清理使用原来的完整备份继续。验证失败、备份损坏或发现外键/视图等依赖时保留原表并报告原因。数据库已改变时,部署脚本只在旧版本通过结构检查后才允许回退到旧镜像。 + +[框架转换 SQL](../migrations/legacy-framework.sql) 与 [清理 SQL](../migrations/cleanup-legacy.sql) 是程序内嵌的执行内容,供审阅和应急排查,正常部署无需手工调用。可选的只读诊断命令: ```bash -/app/kratos-admin -conf /data/conf/config.yaml -migrate /app/kratos-admin -conf /data/conf/config.yaml -check-schema ``` -框架转换 SQL 的 `USE` 默认指向 sw10,其他目标库需修改该行。`-migrate` 使用与服务相同的模块迁移目录,包含新表、字段、索引、权限及任务定义,完成检查后退出;不会启动 HTTP、定时任务或 SDK 连接。`-check-schema` 只读检查版本、必需表和字段、主键与索引。旧业务表缺失时应重新完成复制,不能用空表替代历史数据。 - -转换 SQL 和增量迁移必须全部执行。服务关闭自动迁移后,会在连接失败、版本未完成或结构缺失时明确报错,不进入初始化向导。新增迁移需同时更新模块结构检查与相邻回归测试。部署脚本在停止旧调度实例后自动运行 `-migrate`,全部检查通过才启动新服务。 - -旧库 `tab_promote.id` 的 INT 自增容量由增量迁移扩大为同符号 BIGINT,保留现存 ID、自增高水位、关联数据与注释。渠道、玩家、流水等可能变大的旧业务 ID 在 JSON 和 Excel 中使用文本,请求兼容十进制字符串;不降低自增值或重新编号。 - ## 构建与发布 Windows 本机安装 Go、Node、pnpm、tar,先提交源代码,再执行: @@ -42,7 +49,7 @@ Windows 本机安装 Go、Node、pnpm、tar,先提交源代码,再执行: 输出位于 `.cache/deploy/sw2-.tar.gz` 及对应 SHA-256 文件。后端交叉编译为 Linux amd64,前端执行生产构建;包内只含二进制、前端产物、Dockerfile 和部署配置,真实运行配置及环境变量不进入镜像或 Git。 -通过 SSH 将包上传到 226,校验 SHA-256 后解压到 `releases//`。首次部署另行准备上述私有配置文件,保持 `admin.router_prefix` 为空,并确认主库为 sw10。SDK 环境变量名称为 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE`、`SW_SDK_TABLE_PREFIX`,值从已有私有运行配置取得。 +通过 SSH 将包上传到 226,校验 SHA-256 后解压到 `releases//`。首次部署准备上述私有配置文件,保持 `admin.router_prefix` 为空,主库填写待接管的旧商务数据库。SDK 环境变量名称为 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE`、`SW_SDK_TABLE_PREFIX`,值从已有私有运行配置取得。 ```bash bash /www/wwwroot/sw2.fuhua91.com/releases//deploy.sh --build-only @@ -50,9 +57,9 @@ bash /www/wwwroot/sw2.fuhua91.com/releases//deploy.sh --build-o bash /www/wwwroot/sw2.fuhua91.com/releases//deploy.sh ``` -脚本先构建和检查镜像,再停止当前受管容器,保留一份停止的 `-previous` 容器。迁移或健康检查失败时恢复前一组容器。配置、上传文件和数据库不随镜像替换;索引在独立迁移容器中按版本执行。 +脚本先构建和检查镜像,再停止当前受管容器,保留一份停止的 `-previous` 容器。迁移或健康检查失败时,先检查旧版本是否兼容当前数据库,再决定是否恢复前一组容器。配置、备份和上传文件不随镜像替换;完整数据库升级在独立迁移容器中自动执行。 -迁移不设置总等待时限,日志记录每一步的名称、结果和耗时;耗时索引完成前不会启动业务服务。业务容器退出或重启时立即回退。切换数据库前需备份运行配置;镜像回退不会自动还原数据库连接配置或撤销已执行的数据库迁移,回退版本需兼容当前结构和迁移版本。 +迁移不设置总等待时限,日志记录每一步的名称、结果和耗时;耗时索引完成前不会启动业务服务。业务容器退出或重启时进入兼容性检查后再处理回退。切换数据库前需备份运行配置;镜像回退不会自动还原数据库连接配置或撤销已执行的数据库迁移,回退版本需兼容当前结构和迁移版本。 首次接入域名时,备份宝塔原站点配置,再安装 `bt-nginx-sw2.fuhua91.com.conf` 至 `/www/server/panel/vhost/nginx/`,执行 `/www/server/nginx/sbin/nginx -t` 通过后重载。模板保留证书验证目录,当前监听 HTTP;后续由宝塔为 sw2 单独配置匹配域名的证书。 diff --git a/deploy/docker/build-package.ps1 b/deploy/docker/build-package.ps1 index 2b6a52a..dece1b0 100644 --- a/deploy/docker/build-package.ps1 +++ b/deploy/docker/build-package.ps1 @@ -47,7 +47,11 @@ try { foreach ($name in @('deploy.sh', 'wait-backend.sh', 'bt-nginx-sw2.fuhua91.com.conf')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $stage $name) } - Copy-Item -LiteralPath (Join-Path $PSScriptRoot '..\migrations') -Destination (Join-Path $stage 'migrations') -Recurse + $migrationOutput = Join-Path $stage 'migrations' + New-Item -ItemType Directory -Force -Path $migrationOutput | Out-Null + Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot '..\migrations') -Filter '*.sql' -File | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $migrationOutput + } & tar -czf $archive -C $stage . if ($LASTEXITCODE -ne 0) { throw 'Release archive failed' } $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh index 68e227b..2bf842a 100644 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -73,6 +73,17 @@ rollback() { result=$? trap - ERR set +eu + # A successful schema/data upgrade cannot be undone by switching images. + # Probe the old binary before letting it touch the upgraded database. + if exists sw2-backend-previous; then + previous_image=$(docker inspect --format '{{.Config.Image}}' sw2-backend-previous) + if ! docker run --rm --network "$NETWORK" --env-file "$BASE_DIR/runtime.env" \ + -v "$BASE_DIR/config:/data/conf:ro" -v "$BASE_DIR/log:/app/logs" \ + "$previous_image" /app/kratos-admin -conf /data/conf/config.yaml -check-schema; then + echo 'Deployment failed after a database change. Upgrade state/backups and current containers are retained; the incompatible previous backend stays stopped. Redeploying resumes the database upgrade.' >&2 + exit "$result" + fi + fi for name in "${created[@]}"; do docker rm -f "$name" >/dev/null 2>&1; done for name in "${moved[@]}"; do if exists "$name-previous"; then docker rename "$name-previous" "$name"; fi @@ -91,10 +102,10 @@ for name in "${NAMES[@]}"; do fi done -echo 'Applying and verifying database migrations before starting the service.' +echo 'Automatically converting, migrating, verifying and cleaning the business database before starting the service.' docker run --rm --name sw2-migrate --label com.sw2.application=sw2 \ --network "$NETWORK" --env-file "$BASE_DIR/runtime.env" \ - -v "$BASE_DIR/config:/data/conf:ro" -v "$BASE_DIR/log:/app/logs" \ + -v "$BASE_DIR/config:/data/conf" -v "$BASE_DIR/log:/app/logs" \ "$BACKEND_IMAGE" /app/kratos-admin -conf /data/conf/config.yaml -migrate created+=(sw2-backend) diff --git a/deploy/migrations/assets.go b/deploy/migrations/assets.go new file mode 100644 index 0000000..babc4e9 --- /dev/null +++ b/deploy/migrations/assets.go @@ -0,0 +1,79 @@ +// Package migrations contains the reviewed SQL bundled with the application. +// SQL execution and database lifecycle remain in internal/data. +package migrations + +import ( + _ "embed" + "fmt" + "regexp" + "strings" +) + +//go:embed legacy-framework.sql +var frameworkSQL string + +//go:embed cleanup-legacy.sql +var cleanupSQL string + +func FrameworkSQL() string { return frameworkSQL } +func CleanupSQL() string { return cleanupSQL } + +// Statements understands the DELIMITER directives in the bundled, trusted +// scripts. It does not split stored-procedure bodies on their semicolons. +func Statements(source string) ([]string, error) { + delimiter := ";" + var buffer strings.Builder + var statements []string + for _, line := range strings.Split(source, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "DELIMITER ") { + delimiter = strings.TrimSpace(strings.TrimPrefix(trimmed, "DELIMITER ")) + if delimiter == "" { + return nil, fmt.Errorf("empty SQL delimiter") + } + continue + } + if buffer.Len() == 0 && (trimmed == "" || strings.HasPrefix(trimmed, "--")) { + continue + } + buffer.WriteString(line) + buffer.WriteByte('\n') + if strings.HasSuffix(trimmed, delimiter) { + statement := strings.TrimSuffix(strings.TrimSpace(buffer.String()), delimiter) + statements = append(statements, statement) + buffer.Reset() + } + } + if strings.TrimSpace(buffer.String()) != "" { + return nil, fmt.Errorf("unterminated SQL script") + } + return statements, nil +} + +func CleanupTables() []string { + block := regexp.MustCompile(`(?s)INSERT INTO _sw_cleanup_targets_20261106\([^\n]+\) VALUES\s*(.*?);`).FindStringSubmatch(cleanupSQL) + if len(block) != 2 { + panic("cleanup table manifest is missing") + } + var names []string + for _, match := range regexp.MustCompile(`\('([a-z0-9_]+)'`).FindAllStringSubmatch(block[1], -1) { + names = append(names, match[1]) + } + return names +} + +func LegacyArchives() map[string]string { + archives := map[string]string{} + for _, match := range regexp.MustCompile("`([a-z0-9_]+)` TO `(legacy_sw_20261103_[a-z0-9_]+)`").FindAllStringSubmatch(frameworkSQL, -1) { + archives[match[1]] = match[2] + } + return archives +} + +func FrameworkCreatedTables() []string { + var names []string + for _, match := range regexp.MustCompile("(?m)^CREATE TABLE `?([a-z0-9_]+)`? ").FindAllStringSubmatch(frameworkSQL, -1) { + names = append(names, match[1]) + } + return names +} diff --git a/deploy/migrations/cleanup-legacy.sql b/deploy/migrations/cleanup-legacy.sql new file mode 100644 index 0000000..a43f43f --- /dev/null +++ b/deploy/migrations/cleanup-legacy.sql @@ -0,0 +1,401 @@ +-- SW → Kra 自动升级后的旧表清理,兼容 MySQL 5.7。 +-- 程序会自动调用本脚本;手工执行仅用于预览或恢复已到清理阶段的升级。 +-- 自动流程已完成私有备份、结构/索引及业务角色校验后,才允许进入删除阶段。 +-- 使用独立 MySQL 会话完整执行本文件;客户端需支持 DELIMITER,遇错停止,不使用 --force。 +-- 默认仅预览。确认输出名单和目标库后,将下方 0 改为 1,再完整执行。 +-- DROP TABLE 隐式提交,不能用 ROLLBACK 恢复;中途失败可在排除原因后重跑。 +-- 删除名单为明确表名,绝不按 legacy_、bak_、sj_ 等前缀批量匹配。 +-- 保留业务表、sys_schema_migrations 和 sys_business_upgrade_state;旧映射与核验明细已自动归档。 +USE `sw10`; +SET @sw_cleanup_execute = 0; +SET NAMES utf8mb4; + +DELIMITER $$ +DROP PROCEDURE IF EXISTS sw_kra_cleanup_20261106$$ +CREATE PROCEDURE sw_kra_cleanup_20261106() +cleanup_run: BEGIN + DECLARE v_db VARCHAR(64); + DECLARE v_lock_name VARCHAR(64); + DECLARE v_locked INT DEFAULT 0; + DECLARE v_prepared INT DEFAULT 0; + DECLARE v_timeout_changed INT DEFAULT 0; + DECLARE v_old_lock_wait BIGINT UNSIGNED; + DECLARE v_name VARCHAR(64); + DECLARE v_last VARCHAR(64) DEFAULT ''; + DECLARE v_message VARCHAR(128); + DECLARE v_present INT DEFAULT 0; + DECLARE v_dropped INT DEFAULT 0; + DECLARE v_started DATETIME(6); + DECLARE v_step_started DATETIME(6); + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + IF v_prepared=1 THEN DEALLOCATE PREPARE sw_cleanup_drop; END IF; + IF v_timeout_changed=1 THEN SET SESSION lock_wait_timeout=v_old_lock_wait; END IF; + DROP TEMPORARY TABLE IF EXISTS _sw_cleanup_targets_20261106; + DROP TEMPORARY TABLE IF EXISTS _sw_cleanup_required_tables_20261106; + DROP TEMPORARY TABLE IF EXISTS _sw_cleanup_required_versions_20261106; + IF v_locked=1 THEN DO RELEASE_LOCK(v_lock_name); END IF; + RESIGNAL; + END; + + SET v_started=SYSDATE(6), v_db=DATABASE(); + IF v_db IS NULL OR LOWER(v_db) IN ('oa','game_lx','game_hw','game_yq','mysql','sys','information_schema','performance_schema') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Refusing cleanup of source/platform/system database'; + END IF; + IF @sw_cleanup_execute IS NULL OR @sw_cleanup_execute NOT IN (0,1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sw_cleanup_execute must be 0 (preview) or 1 (delete)'; + END IF; + IF @@SESSION.foreign_key_checks<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Enable foreign_key_checks before cleanup'; + END IF; + + -- 与旧框架转换和 -migrate 使用同一把库级锁。 + SET v_lock_name=CONCAT('sw_to_kra:',MD5(v_db)); + SELECT GET_LOCK(v_lock_name,0) INTO v_locked; + IF COALESCE(v_locked,0)<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Migration/cleanup already running for this database'; + END IF; + + CREATE TEMPORARY TABLE _sw_cleanup_targets_20261106 ( + table_name VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin PRIMARY KEY, + category VARCHAR(80) NOT NULL + ); + INSERT INTO _sw_cleanup_targets_20261106(table_name,category) VALUES + ('legacy_sw_20261103_sys_client','旧框架归档'), + ('legacy_sw_20261103_sys_config','旧框架归档'), + ('legacy_sw_20261103_sys_dept','旧框架归档'), + ('legacy_sw_20261103_sys_dict_data','旧框架归档'), + ('legacy_sw_20261103_sys_dict_type','旧框架归档'), + ('legacy_sw_20261103_sys_login_info','旧框架归档'), + ('legacy_sw_20261103_sys_menu','旧框架归档'), + ('legacy_sw_20261103_sys_notice','旧框架归档'), + ('legacy_sw_20261103_sys_oper_log','旧框架归档'), + ('legacy_sw_20261103_sys_oss','旧框架归档'), + ('legacy_sw_20261103_sys_oss_config','旧框架归档'), + ('legacy_sw_20261103_sys_post','旧框架归档'), + ('legacy_sw_20261103_sys_role','旧框架归档'), + ('legacy_sw_20261103_sys_role_dept','旧框架归档'), + ('legacy_sw_20261103_sys_role_menu','旧框架归档'), + ('legacy_sw_20261103_sys_social','旧框架归档'), + ('legacy_sw_20261103_sys_user','旧框架归档'), + ('legacy_sw_20261103_sys_user_post','旧框架归档'), + ('legacy_sw_20261103_sys_user_role','旧框架归档'), + ('bak_sw2kra_20261103_sj_job','迁移前备份'), + ('bak_sw2kra_20261103_sys_alert_operation_log','迁移前备份'), + ('bak_sw2kra_20261103_sys_alert_record','迁移前备份'), + ('bak_sw2kra_20261103_sys_alert_rule','迁移前备份'), + ('bak_sw2kra_20261103_sys_client','迁移前备份'), + ('bak_sw2kra_20261103_sys_config','迁移前备份'), + ('bak_sw2kra_20261103_sys_dept','迁移前备份'), + ('bak_sw2kra_20261103_sys_dict_data','迁移前备份'), + ('bak_sw2kra_20261103_sys_dict_type','迁移前备份'), + ('bak_sw2kra_20261103_sys_login_info','迁移前备份'), + ('bak_sw2kra_20261103_sys_menu','迁移前备份'), + ('bak_sw2kra_20261103_sys_message','迁移前备份'), + ('bak_sw2kra_20261103_sys_notice','迁移前备份'), + ('bak_sw2kra_20261103_sys_oper_log','迁移前备份'), + ('bak_sw2kra_20261103_sys_oss','迁移前备份'), + ('bak_sw2kra_20261103_sys_oss_config','迁移前备份'), + ('bak_sw2kra_20261103_sys_post','迁移前备份'), + ('bak_sw2kra_20261103_sys_role','迁移前备份'), + ('bak_sw2kra_20261103_sys_role_dept','迁移前备份'), + ('bak_sw2kra_20261103_sys_role_menu','迁移前备份'), + ('bak_sw2kra_20261103_sys_social','迁移前备份'), + ('bak_sw2kra_20261103_sys_user','迁移前备份'), + ('bak_sw2kra_20261103_sys_user_bind_platform','迁移前备份'), + ('bak_sw2kra_20261103_sys_user_post','迁移前备份'), + ('bak_sw2kra_20261103_sys_user_preference','迁移前备份'), + ('bak_sw2kra_20261103_sys_user_role','迁移前备份'), + ('sj_distributed_lock','旧 SnailJob 框架'), + ('sj_group_config','旧 SnailJob 框架'), + ('sj_job','旧 SnailJob 框架'), + ('sj_job_executor','旧 SnailJob 框架'), + ('sj_job_log_message','旧 SnailJob 框架'), + ('sj_job_summary','旧 SnailJob 框架'), + ('sj_job_task','旧 SnailJob 框架'), + ('sj_job_task_batch','旧 SnailJob 框架'), + ('sj_namespace','旧 SnailJob 框架'), + ('sj_notify_config','旧 SnailJob 框架'), + ('sj_notify_recipient','旧 SnailJob 框架'), + ('sj_retry','旧 SnailJob 框架'), + ('sj_retry_dead_letter','旧 SnailJob 框架'), + ('sj_retry_scene_config','旧 SnailJob 框架'), + ('sj_retry_summary','旧 SnailJob 框架'), + ('sj_retry_task','旧 SnailJob 框架'), + ('sj_retry_task_log_message','旧 SnailJob 框架'), + ('sj_server_node','旧 SnailJob 框架'), + ('sj_system_user','旧 SnailJob 框架'), + ('sj_system_user_permission','旧 SnailJob 框架'), + ('sj_workflow','旧 SnailJob 框架'), + ('sj_workflow_node','旧 SnailJob 框架'), + ('sj_workflow_task_batch','旧 SnailJob 框架'), + ('gen_table','旧代码生成与演示'), + ('gen_table_column','旧代码生成与演示'), + ('test_demo','旧代码生成与演示'), + ('test_tree','旧代码生成与演示'), + ('sys_alert_dedup_bak_20261020_105142_128391_5084329','旧预警去重备份'), + ('sys_alert_dedup_bak_20261020_111958_265207_5085137','旧预警去重备份'), + ('sw_kra_api_contract','已归档的迁移辅助表'), + ('sw_kra_checks','已归档的迁移辅助表'), + ('sw_kra_id_map','已归档的迁移辅助表'), + ('sw_kra_menu_permissions','已归档的迁移辅助表'), + ('sw_kra_state','已归档的迁移辅助表') + ; + CREATE TEMPORARY TABLE _sw_cleanup_required_tables_20261106 ( + table_name VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin PRIMARY KEY + ); + INSERT INTO _sw_cleanup_required_tables_20261106(table_name) VALUES + ('casbin_rule'), + ('jwt_blacklists'), + ('media_attachment_category'), + ('media_file_upload_and_downloads'), + ('media_uploads'), + ('media_upload_chunks'), + ('pay_events'), + ('pay_orders'), + ('sys_alert_channel_lock'), + ('sys_alert_exemption'), + ('sys_alert_gap'), + ('sys_alert_operation_log'), + ('sys_alert_record'), + ('sys_alert_rule'), + ('sys_announcements'), + ('sys_apis'), + ('sys_api_tokens'), + ('sys_authorities'), + ('sys_authority_apis'), + ('sys_authority_btns'), + ('sys_authority_departments'), + ('sys_authority_menus'), + ('sys_base_menus'), + ('sys_base_menu_btns'), + ('sys_base_menu_parameters'), + ('sys_business_alert_locks'), + ('sys_business_binding_locks'), + ('sys_business_channel_effectiveness'), + ('sys_business_effectiveness_state'), + ('sys_business_groups'), + ('sys_business_group_members'), + ('sys_business_group_state'), + ('sys_business_member_profiles'), + ('sys_business_password_codes'), + ('sys_business_password_resets'), + ('sys_business_performance_goals'), + ('sys_business_report_build_locks'), + ('sys_data_access_logs'), + ('sys_departments'), + ('sys_dictionaries'), + ('sys_dictionary_details'), + ('sys_error'), + ('sys_export_templates'), + ('sys_export_template_condition'), + ('sys_export_template_join'), + ('sys_ignore_apis'), + ('sys_integration_configs'), + ('sys_login_logs'), + ('sys_message'), + ('sys_operation_records'), + ('sys_params'), + ('sys_positions'), + ('sys_schema_migrations'), + ('sys_security_config'), + ('sys_timed_tasks'), + ('sys_timed_task_logs'), + ('sys_users'), + ('sys_user_authority'), + ('sys_user_bind_platform'), + ('sys_user_departments'), + ('sys_user_positions'), + ('sys_user_preference'), + ('sys_versions'), + ('tab_game'), + ('tab_game_server'), + ('tab_promote'), + ('tab_promote_apply'), + ('tab_spend'), + ('tab_sw_report_day'), + ('tab_sw_report_day_key'), + ('tab_sw_report_server_day'), + ('tab_sw_report_server_day_key'), + ('tab_user'), + ('tab_user_day_login'), + ('tab_user_play_info'), + ('sys_business_upgrade_state') + ; + CREATE TEMPORARY TABLE _sw_cleanup_required_versions_20261106 ( + id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin PRIMARY KEY + ); + INSERT INTO _sw_cleanup_required_versions_20261106(id) VALUES + ('202608200001_data_infrastructure'), + ('202608200002_system_schema'), + ('202608200003_payment_schema'), + ('202608200004_payment_defaults'), + ('202608210001_communication_integration_defaults'), + ('202608210002_communication_surface'), + ('202608210003_communication_test_surface'), + ('202608220001_task_schema'), + ('202608290001_payment_event_schema'), + ('202608290002_payment_admin_surface'), + ('202608290003_payment_order_read_permissions'), + ('202608290004_kafka_integration_default'), + ('202608290005_communication_integration_default_upgrade'), + ('202609290001_sms_admin_surface'), + ('202611030001_business_preference'), + ('202611030002_business_user_fields'), + ('202611030003_business_bindings'), + ('202611030004_business_binding_locks'), + ('202611030005_business_password_reset'), + ('202611030006_business_alert_schema'), + ('202611030007_business_alert_attachments'), + ('202611040008_business_report_build_locks'), + ('202611040009_business_menu_icons'), + ('202611040010_business_alert_trigger_locks'), + ('202611040011_business_registration_index'), + ('202611040012_business_server_report_index'), + ('202611040013_business_query_indexes'), + ('202611040014_business_report_build_indexes'), + ('202611040015_business_approved_game_index'), + ('202611040016_business_other_settings_surface'), + ('202611040017_business_groups_goals_effectiveness'), + ('202611040018_business_effectiveness_query_index'), + ('202611040019_business_performance_surface'), + ('202611050020_business_alert_v2'), + ('202611050021_business_alert_surface'), + ('202611060022_business_promote_id_capacity'), + ('202611060023_business_binding_status_index'), + ('202611060024_business_role_identity'), + ('202611060025_automatic_upgrade_state') + ; + + -- 缺任意必需表时,在执行第一条 DROP 前停止。 + SET v_name=(SELECT MIN(r.table_name) FROM _sw_cleanup_required_tables_20261106 r + WHERE NOT EXISTS (SELECT 1 FROM information_schema.tables t + WHERE t.table_schema=v_db AND BINARY t.table_name=BINARY r.table_name AND t.table_type='BASE TABLE')); + IF v_name IS NOT NULL THEN + SET v_message=CONCAT('Required current table is missing: ',v_name); + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT=v_message; + END IF; + IF NOT EXISTS (SELECT 1 FROM sys_business_upgrade_state WHERE name='legacy-sw' + AND version='20261106_automatic_v1' AND phase IN ('cleanup','complete') + AND verified_at IS NOT NULL AND LENGTH(verified_migrations)=64 + AND cleanup_backup_path<>'' AND LENGTH(cleanup_sha256)=64) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Automatic upgrade and backup verification must finish before cleanup'; + END IF; + -- 初次删除复核旧记录;中断后旧记录可能已删除,改由持久化的自动升级状态证明已校验。 + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=v_db AND table_name='sw_kra_state') + AND EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=v_db AND table_name='sw_kra_checks') THEN + IF NOT EXISTS (SELECT 1 FROM sw_kra_state WHERE id=1 + AND version='20261103_framework_migration_v5' AND phase='completed' AND completed_at IS NOT NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Legacy framework migration has not completed'; + END IF; + IF (SELECT COUNT(DISTINCT check_name) FROM sw_kra_checks WHERE stage='backup')<>52 + OR (SELECT COUNT(DISTINCT check_name) FROM sw_kra_checks WHERE stage='migration')<>24 + OR EXISTS (SELECT 1 FROM sw_kra_checks WHERE passed<>1 OR expected_value IS NULL + OR actual_value IS NULL OR expected_value<>actual_value) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Migration verification records are incomplete or failed'; + END IF; + END IF; + IF (SELECT COUNT(*) FROM sys_authorities WHERE deleted_at IS NULL AND business_level BETWEEN 1 AND 3 AND authority_id<>888)<>3 + OR (SELECT COUNT(DISTINCT business_level) FROM sys_authorities WHERE deleted_at IS NULL AND business_level BETWEEN 1 AND 3 AND authority_id<>888)<>3 + OR EXISTS(SELECT 1 FROM sys_authorities WHERE business_level IS NOT NULL AND (business_level NOT BETWEEN 1 AND 3 OR authority_id=888)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Stable business role bindings are incomplete'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=v_db AND table_name='sys_authorities' + AND index_name='uk_authority_business_level')<>1 + OR NOT EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=v_db AND table_name='sys_authorities' + AND index_name='uk_authority_business_level' AND column_name='business_level' AND seq_in_index=1 AND non_unique=0 AND sub_part IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Business role unique index is missing or incompatible'; + END IF; + IF EXISTS (SELECT 1 FROM _sw_cleanup_required_versions_20261106 r + WHERE NOT EXISTS (SELECT 1 FROM sys_schema_migrations m WHERE BINARY m.id=BINARY r.id)) THEN + SELECT r.id AS missing_migration FROM _sw_cleanup_required_versions_20261106 r + WHERE NOT EXISTS (SELECT 1 FROM sys_schema_migrations m WHERE BINARY m.id=BINARY r.id) ORDER BY r.id; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Run all incremental migrations before cleanup'; + END IF; + -- 不能仅凭手工填写 completed 状态删除尚未归档的旧框架。 + IF EXISTS (SELECT 1 FROM information_schema.tables t WHERE t.table_schema=v_db AND t.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_oss','sys_oss_config','sys_client')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Unarchived legacy framework tables remain; cleanup stopped'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.tables t JOIN _sw_cleanup_targets_20261106 c + ON BINARY c.table_name=BINARY t.table_name WHERE t.table_schema=v_db AND t.table_type<>'BASE TABLE') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='A cleanup target is not a base table'; + END IF; + + -- 保留外键检查;任何引用(包括其他库)都需先处理,避免删除一半才遇到约束。 + IF EXISTS (SELECT 1 FROM information_schema.key_column_usage k JOIN _sw_cleanup_targets_20261106 c + ON BINARY k.referenced_table_name=BINARY c.table_name WHERE k.referenced_table_schema=v_db) THEN + SELECT DISTINCT k.table_schema,k.table_name,k.constraint_name,k.referenced_table_name + FROM information_schema.key_column_usage k JOIN _sw_cleanup_targets_20261106 c + ON BINARY k.referenced_table_name=BINARY c.table_name WHERE k.referenced_table_schema=v_db; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Foreign keys reference cleanup targets'; + END IF; + -- 检查当前库的视图、触发器、事件和存储程序;定义不可见时也停止。 + IF EXISTS (SELECT 1 FROM information_schema.views v WHERE v.table_schema=v_db AND + (v.view_definition IS NULL OR EXISTS (SELECT 1 FROM _sw_cleanup_targets_20261106 c + WHERE LOWER(v.view_definition) REGEXP CONCAT('(^|[^0-9a-z_])',c.table_name,'([^0-9a-z_]|$)')))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Review views referencing cleanup targets before deletion'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.triggers t WHERE t.trigger_schema=v_db AND + (t.action_statement IS NULL OR EXISTS (SELECT 1 FROM _sw_cleanup_targets_20261106 c + WHERE BINARY t.event_object_table=BINARY c.table_name + OR LOWER(t.action_statement) REGEXP CONCAT('(^|[^0-9a-z_])',c.table_name,'([^0-9a-z_]|$)')))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Review triggers referencing cleanup targets before deletion'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.events e WHERE e.event_schema=v_db AND + (e.event_definition IS NULL OR EXISTS (SELECT 1 FROM _sw_cleanup_targets_20261106 c + WHERE LOWER(e.event_definition) REGEXP CONCAT('(^|[^0-9a-z_])',c.table_name,'([^0-9a-z_]|$)')))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Review events referencing cleanup targets before deletion'; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.routines r WHERE r.routine_schema=v_db + AND r.routine_name<>'sw_kra_cleanup_20261106' AND + (r.routine_definition IS NULL OR EXISTS (SELECT 1 FROM _sw_cleanup_targets_20261106 c + WHERE LOWER(r.routine_definition) REGEXP CONCAT('(^|[^0-9a-z_])',c.table_name,'([^0-9a-z_]|$)')))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Review routines referencing cleanup targets before deletion'; + END IF; + + SELECT COUNT(*) INTO v_present FROM information_schema.tables t JOIN _sw_cleanup_targets_20261106 c + ON BINARY t.table_name=BINARY c.table_name WHERE t.table_schema=v_db; + SELECT v_db AS target_database,IF(@sw_cleanup_execute=0,'PREVIEW','DELETE') AS mode, + v_present AS existing_target_tables; + SELECT c.table_name,c.category,IF(t.table_name IS NULL,'already absent','to delete') AS status, + t.table_rows AS estimated_rows,COALESCE(t.data_length,0)+COALESCE(t.index_length,0) AS estimated_bytes + FROM _sw_cleanup_targets_20261106 c LEFT JOIN information_schema.tables t + ON t.table_schema=v_db AND BINARY t.table_name=BINARY c.table_name ORDER BY c.category,c.table_name; + + IF @sw_cleanup_execute=1 THEN + SET v_old_lock_wait=@@SESSION.lock_wait_timeout, v_timeout_changed=1; + -- 只限制等待元数据锁的时间,避免遗留连接占表后一直卡住;实际删除不设总超时。 + SET SESSION lock_wait_timeout=15; + drop_loop: LOOP + SET v_name=(SELECT MIN(table_name) FROM _sw_cleanup_targets_20261106 WHERE table_name>v_last); + IF v_name IS NULL THEN LEAVE drop_loop; END IF; + SET v_last=v_name; + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=v_db AND BINARY table_name=BINARY v_name) THEN + SET v_step_started=SYSDATE(6); + SET @sw_cleanup_drop_sql=CONCAT('DROP TABLE IF EXISTS `',REPLACE(v_db,'`','``'),'`.`',REPLACE(v_name,'`','``'),'`'); + PREPARE sw_cleanup_drop FROM @sw_cleanup_drop_sql; + SET v_prepared=1; + EXECUTE sw_cleanup_drop; + DEALLOCATE PREPARE sw_cleanup_drop; + SET v_prepared=0, v_dropped=v_dropped+1; + SELECT v_name AS dropped_table,TIMESTAMPDIFF(MICROSECOND,v_step_started,SYSDATE(6))/1000 AS elapsed_ms; + END IF; + END LOOP; + SET SESSION lock_wait_timeout=v_old_lock_wait; + SET v_timeout_changed=0; + END IF; + + DROP TEMPORARY TABLE _sw_cleanup_targets_20261106; + DROP TEMPORARY TABLE _sw_cleanup_required_tables_20261106; + DROP TEMPORARY TABLE _sw_cleanup_required_versions_20261106; + DO RELEASE_LOCK(v_lock_name); + SET v_locked=0, @sw_cleanup_drop_sql=NULL; + SELECT IF(@sw_cleanup_execute=0,'Preview completed; no tables deleted','Cleanup completed') AS result, + v_dropped AS dropped_tables,TIMESTAMPDIFF(MICROSECOND,v_started,SYSDATE(6))/1000 AS elapsed_ms; +END cleanup_run$$ +CALL sw_kra_cleanup_20261106()$$ +DROP PROCEDURE sw_kra_cleanup_20261106$$ +DELIMITER ; diff --git a/deploy/migrations/cleanup_test.go b/deploy/migrations/cleanup_test.go new file mode 100644 index 0000000..76696c1 --- /dev/null +++ b/deploy/migrations/cleanup_test.go @@ -0,0 +1,297 @@ +package migrations_test + +import ( + "context" + "database/sql" + "fmt" + "os" + "regexp" + "strings" + "testing" + "time" + + mysql "github.com/go-sql-driver/mysql" + "kra/internal/modules" +) + +func cleanupSQL(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile("cleanup-legacy.sql") + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func cleanupManifest(t *testing.T, source, name string) []string { + t.Helper() + block := regexp.MustCompile(`(?s)INSERT INTO ` + regexp.QuoteMeta(name) + `\([^\n]+\) VALUES\s*(.*?);`).FindStringSubmatch(source) + if len(block) != 2 { + t.Fatalf("missing manifest %s", name) + } + matches := regexp.MustCompile(`\('([a-z0-9_]+)'`).FindAllStringSubmatch(block[1], -1) + var values []string + seen := map[string]bool{} + for _, match := range matches { + if seen[match[1]] { + t.Fatalf("duplicate %s entry %s", name, match[1]) + } + seen[match[1]] = true + values = append(values, match[1]) + } + return values +} + +func TestCleanupManifestMatchesMigrationAndRetainsCurrentTables(t *testing.T) { + source := cleanupSQL(t) + targets := cleanupManifest(t, source, "_sw_cleanup_targets_20261106") + required := cleanupManifest(t, source, "_sw_cleanup_required_tables_20261106") + versions := cleanupManifest(t, source, "_sw_cleanup_required_versions_20261106") + if len(targets) != 79 || len(required) != 76 { + t.Fatalf("cleanup manifest changed: %d targets, %d required", len(targets), len(required)) + } + set := map[string]bool{} + for _, name := range targets { + if strings.HasPrefix(name, "tab_") || name == "sys_business_upgrade_state" { + t.Fatalf("current data included: %s", name) + } + set[name] = true + } + for _, name := range required { + if set[name] { + t.Fatalf("required table selected for deletion: %s", name) + } + } + legacy, err := os.ReadFile("legacy-framework.sql") + if err != nil { + t.Fatal(err) + } + for _, pattern := range []string{"TO `(legacy_sw_20261103_[a-z0-9_]+)`", "CREATE TABLE `(bak_sw2kra_20261103_[a-z0-9_]+)`"} { + for _, match := range regexp.MustCompile(pattern).FindAllStringSubmatch(string(legacy), -1) { + if !set[match[1]] { + t.Fatalf("migration archive omitted: %s", match[1]) + } + } + } + known := map[string]bool{} + for _, id := range versions { + known[id] = true + } + steps := modules.Catalog().MigrationSteps() + if len(steps) != len(versions) { + t.Fatal("refresh the cleanup version gate for the current catalog") + } + for _, step := range steps { + if !known[step.ID] { + t.Fatalf("missing required migration %s", step.ID) + } + } + if !strings.Contains(source, "SET @sw_cleanup_execute = 0;") { + t.Fatal("cleanup must default to preview") + } +} + +// DELIMITER belongs to the mysql client. Keep one connection for the routine, +// session variables, advisory lock and temporary manifests. +func runCleanupSQL(ctx context.Context, conn *sql.Conn, source, database string, execute bool) error { + source = strings.Replace(source, "USE `sw10`;", "USE `"+database+"`;", 1) + if execute { + source = strings.Replace(source, "SET @sw_cleanup_execute = 0;", "SET @sw_cleanup_execute = 1;", 1) + } + delimiter, buffer := ";", "" + for _, line := range strings.Split(source, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "DELIMITER ") { + delimiter = strings.TrimSpace(strings.TrimPrefix(trimmed, "DELIMITER ")) + continue + } + buffer += line + "\n" + if strings.HasSuffix(trimmed, delimiter) { + statement := strings.TrimSuffix(strings.TrimSpace(buffer), delimiter) + if _, err := conn.ExecContext(ctx, statement); err != nil { + return err + } + buffer = "" + } + } + return nil +} + +func TestMySQLCleanupPreflightPreviewDeletionAndRetry(t *testing.T) { + dsn := os.Getenv("SW_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set SW_TEST_MYSQL_DSN to an isolated local MySQL") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") { + t.Fatal("cleanup tests require a local isolated MySQL") + } + cfg.DBName = "" + admin, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + name := fmt.Sprintf("sw_test_cleanup_%d", time.Now().UnixNano()) + if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4"); err != nil { + admin.Close() + t.Fatal(err) + } + t.Cleanup(func() { + _, err := admin.Exec("DROP DATABASE `" + name + "`") + if err != nil { + t.Error(err) + } + admin.Close() + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + conn, err := admin.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + exec := func(query string, args ...any) { + t.Helper() + if _, err := conn.ExecContext(ctx, query, args...); err != nil { + t.Fatal(err) + } + } + exec("USE `" + name + "`") + source := cleanupSQL(t) + targets := cleanupManifest(t, source, "_sw_cleanup_targets_20261106") + required := cleanupManifest(t, source, "_sw_cleanup_required_tables_20261106") + versions := cleanupManifest(t, source, "_sw_cleanup_required_versions_20261106") + for _, table := range append(targets, required...) { + switch table { + case "sw_kra_state", "sw_kra_checks", "sys_schema_migrations", "sys_business_upgrade_state", "sys_authorities": + continue + } + exec("CREATE TABLE `" + table + "` (id BIGINT PRIMARY KEY) ENGINE=InnoDB") + exec("INSERT INTO `" + table + "` VALUES (1)") + } + exec("CREATE TABLE sys_authorities(authority_id BIGINT PRIMARY KEY,business_level TINYINT UNSIGNED,deleted_at DATETIME,UNIQUE KEY uk_authority_business_level(business_level))") + exec("INSERT INTO sys_authorities VALUES(101,1,NULL),(102,2,NULL),(103,3,NULL)") + exec("CREATE TABLE sys_business_upgrade_state(name VARCHAR(64) PRIMARY KEY,version VARCHAR(64),phase VARCHAR(32),verified_at DATETIME,verified_migrations VARCHAR(64),cleanup_backup_path TEXT,cleanup_sha256 VARCHAR(64))") + exec("INSERT INTO sys_business_upgrade_state VALUES('legacy-sw','20261106_automatic_v1','cleanup',NOW(),REPEAT('a',64),'verified-backup.sql.gz',REPEAT('b',64))") + exec("CREATE TABLE sw_kra_state(id INT PRIMARY KEY,version VARCHAR(80),phase VARCHAR(40),completed_at DATETIME)") + exec("INSERT INTO sw_kra_state VALUES(1,'20261103_framework_migration_v5','completed',NOW())") + exec("CREATE TABLE sw_kra_checks(stage VARCHAR(32),check_name VARCHAR(160),expected_value BIGINT,actual_value BIGINT,passed TINYINT)") + for stage, count := range map[string]int{"backup": 52, "migration": 24} { + for i := 0; i < count; i++ { + exec("INSERT INTO sw_kra_checks VALUES(?,?,?,?,?)", stage, fmt.Sprintf("check_%d", i), 1, 1, 1) + } + } + exec("CREATE TABLE sys_schema_migrations(id VARCHAR(255) PRIMARY KEY)") + for _, id := range versions { + exec("INSERT INTO sys_schema_migrations VALUES(?)", id) + } + exec("CREATE TABLE bak_sw2kra_20261103_not_owned(id INT)") + count := func(query string, args ...any) int { + t.Helper() + var n int + if err := conn.QueryRowContext(ctx, query, args...).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + has := func(table string) bool { + return count("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=? AND table_name=?", name, table) == 1 + } + assertTargets := func() { + t.Helper() + for _, table := range targets { + if !has(table) { + t.Fatalf("table deleted before all checks passed: %s", table) + } + } + } + fails := func(want string) { + t.Helper() + err := runCleanupSQL(ctx, conn, source, name, true) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("want rejection %q, got %v", want, err) + } + assertTargets() + } + if err := runCleanupSQL(ctx, conn, source, name, false); err != nil { + t.Fatal(err) + } + assertTargets() + exec("UPDATE sw_kra_state SET phase='data_validated'") + fails("has not completed") + exec("UPDATE sw_kra_state SET phase='completed'") + exec("UPDATE sw_kra_checks SET passed=0 WHERE stage='backup' AND check_name='check_0'") + fails("verification records") + exec("UPDATE sw_kra_checks SET passed=1") + const attachments = "202611030007_business_alert_attachments" + exec("DELETE FROM sys_schema_migrations WHERE id=?", attachments) + fails("incremental migrations") + exec("INSERT INTO sys_schema_migrations VALUES(?)", attachments) + exec("RENAME TABLE sys_alert_rule TO user_saved_rule") + fails("Required current table") + exec("RENAME TABLE user_saved_rule TO sys_alert_rule") + exec("CREATE TABLE sys_user(id INT)") + fails("Unarchived legacy") + exec("DROP TABLE sys_user") + exec("CREATE TABLE retained_reference(id BIGINT,FOREIGN KEY(id) REFERENCES legacy_sw_20261103_sys_user(id))") + fails("Foreign keys") + exec("DROP TABLE retained_reference") + exec("CREATE VIEW retained_view AS SELECT * FROM legacy_sw_20261103_sys_user") + fails("views referencing") + exec("DROP VIEW retained_view") + exec("CREATE TRIGGER retained_trigger AFTER INSERT ON sys_users FOR EACH ROW INSERT INTO sj_job(id) VALUES(NEW.id)") + fails("triggers referencing") + exec("DROP TRIGGER retained_trigger") + exec("CREATE PROCEDURE retained_routine() SELECT COUNT(*) FROM sj_job") + fails("routines referencing") + exec("DROP PROCEDURE retained_routine") + exec("CREATE EVENT retained_event ON SCHEDULE EVERY 1 YEAR STARTS CURRENT_TIMESTAMP + INTERVAL 1 YEAR DISABLE DO INSERT INTO sj_job(id) VALUES(2)") + fails("events referencing") + exec("DROP EVENT retained_event") + exec("SET SESSION foreign_key_checks=0") + fails("foreign_key_checks") + exec("SET SESSION foreign_key_checks=1") + other, err := admin.Conn(ctx) + if err != nil { + t.Fatal(err) + } + var lock string + if err := conn.QueryRowContext(ctx, "SELECT CONCAT('sw_to_kra:',MD5(DATABASE()))").Scan(&lock); err != nil { + t.Fatal(err) + } + var locked int + if err := other.QueryRowContext(ctx, "SELECT GET_LOCK(?,0)", lock).Scan(&locked); err != nil || locked != 1 { + t.Fatalf("lock: %d %v", locked, err) + } + fails("already running") + if _, err := other.ExecContext(ctx, "DO RELEASE_LOCK(?)", lock); err != nil { + t.Fatal(err) + } + other.Close() + oldTimeout := count("SELECT @@SESSION.lock_wait_timeout") + if err := runCleanupSQL(ctx, conn, source, name, true); err != nil { + t.Fatal(err) + } + for _, table := range targets { + if has(table) { + t.Fatalf("old table retained: %s", table) + } + } + for _, table := range required { + if !has(table) { + t.Fatalf("current table deleted: %s", table) + } + } + if !has("bak_sw2kra_20261103_not_owned") || count("SELECT COUNT(*) FROM sys_business_upgrade_state") != 1 || count("SELECT COUNT(*) FROM sys_users") != 1 || count("SELECT COUNT(*) FROM tab_user") != 1 { + t.Fatal("non-target data changed") + } + if count("SELECT @@SESSION.lock_wait_timeout") != oldTimeout { + t.Fatal("session lock timeout not restored") + } + if err := runCleanupSQL(ctx, conn, source, name, true); err != nil { + t.Fatalf("retry after deletion: %v", err) + } +} diff --git a/deploy/migrations/legacy-framework.sql b/deploy/migrations/legacy-framework.sql index 749766a..fcb7ea3 100644 --- a/deploy/migrations/legacy-framework.sql +++ b/deploy/migrations/legacy-framework.sql @@ -1,4 +1,4 @@ --- SW → Kra 旧框架转换:完整迁移必须继续执行当前后端的 -migrate 和 -check-schema。 +-- SW → Kra 旧框架转换:由程序首次连接旧库时自动执行,随后自动完成增量迁移、校验和清理。 -- 服务设置 admin.system.disable_auto_migrate: true;目标库由下方 USE 配置。 -- 新副本:备份源表、创建 Kra 表、转换框架数据及关联,并归档旧表。 -- 已迁移副本:自动执行菜单调整,不重新导入账号,不重建已存在的表。 @@ -42,7 +42,7 @@ IF COALESCE(v_lock,0)<>1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Migratio -- 一、已迁移库:确认目标表完整、迁移账本已完成,再调整菜单。 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 + 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') AND engine='InnoDB')<>48 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 @@ -79,8 +79,10 @@ IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() 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; + IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='sw_kra_id_map') THEN 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); + END IF; DELETE FROM sys_base_menu_btns WHERE sys_base_menu_id=v_workbench; DELETE FROM sys_base_menus WHERE id=v_workbench; @@ -689,6 +691,7 @@ INSERT INTO sw_kra_checks(stage,check_name,expected_value,actual_value,passed) V IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_alert_operation_log backup bytes'; END IF; COMMIT; +SELECT '旧框架备份已校验' AS result; -- 四、按 Kra 当前定义创建目标表,并增加商务账号和岗位扩展字段。 @@ -1731,6 +1734,7 @@ IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='positio UPDATE sw_kra_state SET phase='data_validated' WHERE id=1; COMMIT; +SELECT '框架数据已验证' AS result; -- 十、数据核验通过后归档旧框架表,登记完成状态并释放迁移锁。 diff --git a/internal/biz/business/account.go b/internal/biz/business/account.go index e862db3..805b5de 100644 --- a/internal/biz/business/account.go +++ b/internal/biz/business/account.go @@ -19,7 +19,7 @@ var ( ErrAccountExists = errors.BadRequest("SW_ACCOUNT_EXISTS", "登录账号已存在") ErrPhoneExists = errors.BadRequest("SW_PHONE_EXISTS", "手机号码已存在") ErrEmailExists = errors.BadRequest("SW_EMAIL_EXISTS", "邮箱账号已存在") - ErrBusinessRole = errors.BadRequest("SW_BUSINESS_ROLE", "未找到对应层级的商务角色,请核对角色迁移映射") + ErrBusinessRole = errors.BadRequest("SW_BUSINESS_ROLE", "商务等级对应的角色配置不完整") ) type Account struct { diff --git a/internal/biz/business/ledger.go b/internal/biz/business/ledger.go index 1317659..329130d 100644 --- a/internal/biz/business/ledger.go +++ b/internal/biz/business/ledger.go @@ -37,7 +37,6 @@ type LedgerListOptions struct { type LedgerNames struct { PromoteID *int64 PromoteAccount, BindingAccount, BindingUserName string - BusinessPath []string } func (n *LedgerNames) DisplayPromote() string { @@ -53,9 +52,6 @@ func (n *LedgerNames) DisplayPromote() string { return "未知渠道(ID:" + strconv.FormatInt(*n.PromoteID, 10) + ")" } func (n *LedgerNames) DisplayBusiness() string { - if len(n.BusinessPath) > 0 { - return strings.Join(n.BusinessPath, "→") - } return (&Channel{BindingAccount: n.BindingAccount, BindingUserName: n.BindingUserName}).BusinessDisplayName() } diff --git a/internal/biz/system/authority.go b/internal/biz/system/authority.go index 70abc11..fa13d98 100644 --- a/internal/biz/system/authority.go +++ b/internal/biz/system/authority.go @@ -2,6 +2,7 @@ package system import ( "context" + "github.com/go-kratos/kratos/v3/errors" "time" ) @@ -32,6 +33,7 @@ type Authority struct { DeletedAt *time.Time AuthorityID uint AuthorityName string + BusinessLevel *int ParentID *uint DataScope int DefaultRouter string @@ -39,6 +41,15 @@ type Authority struct { Menus []*Menu } +var ErrBusinessAuthorityProtected = errors.BadRequest("BUSINESS_AUTHORITY_PROTECTED", "商务内置角色不能删除") + +func ValidateAuthorityDeletion(authority *Authority) error { + if authority != nil && authority.BusinessLevel != nil { + return ErrBusinessAuthorityProtected + } + return nil +} + type AuthorityUsecase struct{ AuthorityAccessRepo } func NewAuthorityUsecase(repo AuthorityAccessRepo) *AuthorityUsecase { diff --git a/internal/config/types.go b/internal/config/types.go index 3969442..2dc43b9 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -143,9 +143,11 @@ type Disk struct { } type System struct { - UseRedis bool `mapstructure:"use_redis" yaml:"use_redis" json:"use_redis"` - UseMultipoint bool `mapstructure:"use_multipoint" yaml:"use_multipoint" json:"use_multipoint"` - UseStrictAuth bool `mapstructure:"use_strict_auth" yaml:"use_strict_auth" json:"use_strict_auth"` + UseRedis bool `mapstructure:"use_redis" yaml:"use_redis" json:"use_redis"` + UseMultipoint bool `mapstructure:"use_multipoint" yaml:"use_multipoint" json:"use_multipoint"` + UseStrictAuth bool `mapstructure:"use_strict_auth" yaml:"use_strict_auth" json:"use_strict_auth"` + // Controls generic template migrations. A recognized legacy SW database + // always completes its automatic upgrade before the application is ready. DisableAutoMigrate bool `mapstructure:"disable_auto_migrate" yaml:"disable_auto_migrate" json:"disable_auto_migrate"` UseMongo bool `mapstructure:"use_mongo" yaml:"use_mongo" json:"use_mongo"` Addr int `mapstructure:"addr" yaml:"addr" json:"addr"` diff --git a/internal/data/business/account.go b/internal/data/business/account.go index cb732cb..74094dd 100644 --- a/internal/data/business/account.go +++ b/internal/data/business/account.go @@ -204,26 +204,11 @@ func (r *accountRepo) Update(ctx context.Context, a *business.Account) error { } func (r *accountRepo) AuthorityForLevel(ctx context.Context, level int) (uint, error) { - // 原角色 ID 仅用于查迁移映射,不作为 Kra 角色 ID 使用。 - legacy := map[int]string{1: "2083497078545207297", 2: "2087435436573081601", 3: "2087361690139152385"} - oldID, ok := legacy[level] - if !ok { + if level < 1 || level > 3 { return 0, business.ErrBusinessRole } - db := r.data.DB().WithContext(ctx) - var id uint - if db.Migrator().HasTable("sw_kra_id_map") { - if err := db.Table("sw_kra_id_map m").Select("m.new_id").Joins("JOIN sys_authorities a ON a.authority_id=m.new_id AND a.deleted_at IS NULL").Where("m.entity = ? AND m.old_id = ?", "role", oldID).Scan(&id).Error; err != nil { - return 0, business.ErrStorage - } - if id != 0 && id != 888 { - return id, nil - } - } - // 新安装可使用对应中文角色;匹配不唯一时停止创建,避免错授权限。 - names := map[int]string{1: "一级商务", 2: "二级商务", 3: "三级商务"} var ids []uint - if err := db.Table("sys_authorities").Where("authority_name = ? AND deleted_at IS NULL AND authority_id <> ?", names[level], 888).Pluck("authority_id", &ids).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Table("sys_authorities").Where("business_level=? AND deleted_at IS NULL AND authority_id<>888", level).Pluck("authority_id", &ids).Error; err != nil { return 0, business.ErrStorage } if len(ids) != 1 { diff --git a/internal/data/business/account_test.go b/internal/data/business/account_test.go index e368242..e99572a 100644 --- a/internal/data/business/account_test.go +++ b/internal/data/business/account_test.go @@ -15,10 +15,10 @@ func prepareAccounts(t *testing.T) *gorm.DB { } for _, sql := range []string{ "CREATE TABLE sys_user_authority (sys_user_id INTEGER,sys_authority_authority_id INTEGER)", - "CREATE TABLE sys_authorities (authority_id INTEGER,authority_name TEXT,deleted_at DATETIME)", + "CREATE TABLE sys_authorities (authority_id INTEGER,authority_name TEXT,deleted_at DATETIME,business_level INTEGER UNIQUE)", "CREATE TABLE sw_kra_id_map (entity TEXT,old_id TEXT,new_id INTEGER)", "CREATE TABLE tab_promote (id INTEGER,source_db TEXT,busier_id INTEGER,promote_level INTEGER,status INTEGER)", - "INSERT INTO sys_authorities VALUES (901,'一级商务',NULL),(902,'已改名二级角色',NULL),(903,'三级商务',NULL)", + "INSERT INTO sys_authorities VALUES (901,'一级商务',NULL,1),(902,'已改名二级角色',NULL,2),(903,'三级商务',NULL,3)", "INSERT INTO sw_kra_id_map VALUES ('role','2087435436573081601',902)", } { if err := db.Exec(sql).Error; err != nil { @@ -32,6 +32,9 @@ func TestAccountCreateIsAtomicAndPreferencesDefaultOn(t *testing.T) { db := prepareAccounts(t) r := NewAccountRepo(testData{db}) ctx := context.Background() + if err := db.Exec("DROP TABLE sw_kra_id_map").Error; err != nil { + t.Fatal(err) + } role, err := r.AuthorityForLevel(ctx, 2) if err != nil || role != 902 { t.Fatalf("角色映射不正确: %d %v", role, err) diff --git a/internal/data/business/ledger.go b/internal/data/business/ledger.go index d3789ec..a48140a 100644 --- a/internal/data/business/ledger.go +++ b/internal/data/business/ledger.go @@ -394,13 +394,11 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le SourceDB string `gorm:"column:source_db"` PlatformBusierID uint64 PlatformBusierAccount, Username string - UserID uint - IDPath string `gorm:"column:id_path"` } names := map[business.PlatformKey]bindName{} for start := 0; start < len(pairs); start += 300 { var rows []bindName - q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,COALESCE(NULLIF(u.nick_name,''),u.username) AS username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id") + q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,u.username").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id") if err := q.Scan(&rows).Error; err != nil { return business.ErrStorage } @@ -411,31 +409,6 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le } } } - ancestorIDs := map[uint]bool{} - paths := map[business.PlatformKey][]uint{} - for key, binding := range names { - for _, part := range strings.Split(binding.IDPath, ",") { - id, err := strconv.ParseUint(strings.TrimSpace(part), 10, strconv.IntSize) - if err == nil && id > 0 && uint(id) != binding.UserID { - paths[key] = append(paths[key], uint(id)) - ancestorIDs[uint(id)] = true - } - } - } - ids := make([]uint, 0, len(ancestorIDs)) - for id := range ancestorIDs { - ids = append(ids, id) - } - ancestors := map[uint]string{} - for start := 0; start < len(ids); start += 300 { - var rows []accountPO - if err := db.Select("id,COALESCE(NULLIF(nick_name,''),username) AS username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil { - return business.ErrStorage - } - for _, row := range rows { - ancestors[row.ID] = row.Username - } - } for _, target := range targets { n := target.Names if n.PromoteID == nil || *n.PromoteID < 0 { @@ -448,14 +421,6 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le if binding, ok := names[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}]; ok { n.BindingAccount = binding.PlatformBusierAccount n.BindingUserName = binding.Username - for _, id := range paths[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}] { - if name := strings.TrimSpace(ancestors[id]); name != "" { - n.BusinessPath = append(n.BusinessPath, name) - } - } - if name := strings.TrimSpace(binding.Username); name != "" { - n.BusinessPath = append(n.BusinessPath, name) - } } } } diff --git a/internal/data/business/ledger_test.go b/internal/data/business/ledger_test.go index 6660f23..bd56ce7 100644 --- a/internal/data/business/ledger_test.go +++ b/internal/data/business/ledger_test.go @@ -82,7 +82,7 @@ func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) { first = row } } - if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "parent→branch→leaf" { + if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "lx-business(leaf)" { t.Fatalf("累计充值不能受本次时间/渠道限制,且需保留游戏快照: %+v", first) } filter.SortField = "totalPayAmount" diff --git a/internal/data/business/migration_schema.go b/internal/data/business/migration_schema.go index 634b134..7c58b03 100644 --- a/internal/data/business/migration_schema.go +++ b/internal/data/business/migration_schema.go @@ -5,6 +5,8 @@ import ( "strings" "gorm.io/gorm" + "kra/internal/data/swmigration" + datasystem "kra/internal/data/system" "kra/pkg/database/migration" ) @@ -36,6 +38,9 @@ func CheckPrerequisites(db *gorm.DB) error { } func CheckSchema(db *gorm.DB) error { + if err := swmigration.CheckSchema(db); err != nil { + return err + } if err := CheckPrerequisites(db); err != nil { return err } @@ -50,6 +55,9 @@ func CheckSchema(db *gorm.DB) error { if db.Dialector.Name() != "mysql" { return nil } + if err := datasystem.CheckBusinessRoles(db); err != nil { + return err + } indexes := append([]businessQueryIndex{}, businessQueryIndexes...) var idType string if err := db.Raw("SELECT data_type FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='tab_promote' AND column_name='id'").Scan(&idType).Error; err != nil { diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index 29121fc..2083e9f 100644 --- a/internal/data/business/migrations.go +++ b/internal/data/business/migrations.go @@ -2,11 +2,12 @@ package business import ( "gorm.io/gorm" + "kra/internal/data/swmigration" "kra/pkg/database/migration" ) func Migrations() []migration.Step { - return []migration.Step{{ID: "202611060023_business_binding_status_index", Migrate: migrateBindingStatusIndex}, {ID: "202611060022_business_promote_id_capacity", Migrate: migratePromoteIDCapacity}, {ID: "202611050020_business_alert_v2", Migrate: migrateAlertV2}, {ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {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.Step{swmigration.StateMigration(), {ID: "202611060023_business_binding_status_index", Migrate: migrateBindingStatusIndex}, {ID: "202611060022_business_promote_id_capacity", Migrate: migratePromoteIDCapacity}, {ID: "202611050020_business_alert_v2", Migrate: migrateAlertV2}, {ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {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/config_store.go b/internal/data/config_store.go index 675d12f..7c7be7d 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -168,14 +168,10 @@ func (d *Data) reloadConfig(ctx context.Context) error { return fmt.Errorf("reload database: %w", err) } } - if databaseReady && (next.Admin.System == nil || !next.Admin.System.DisableAutoMigrate) { - if err = migrateAll(candidateDB.WithContext(ctx), d.catalog); err != nil { - return fmt.Errorf("reload database migrations: %w", err) - } - } - if databaseReady && next.Admin.System != nil && next.Admin.System.DisableAutoMigrate { - if err = checkMigratedSchema(candidateDB.WithContext(ctx), d.catalog); err != nil { - return fmt.Errorf("reload database schema: %w", err) + if databaseReady { + disable := next.Admin.System != nil && next.Admin.System.DisableAutoMigrate + if err = prepareConfiguredDatabase(ctx, candidateDB, next.Data.Database, d.catalog, d.logger(), configPath, disable); err != nil { + return fmt.Errorf("reload database upgrade: %w", err) } } storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), next.Admin.Storage) diff --git a/internal/data/data.go b/internal/data/data.go index 67ea3d9..2199d3d 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -271,8 +271,13 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor } usingFallback = true } - d.databaseReady.Store(!usingFallback) d.gormDB = newReloadableDB(db) + if !usingFallback { + if err = prepareConfiguredDatabase(context.Background(), db, c.Database, catalog, appLogger, runtime.ConfigPath(), disableAutoMigrate); err != nil { + return nil, nil, err + } + } + d.databaseReady.Store(!usingFallback) d.auditLog = newDataScopeAuditWriter(d, appLogger) registerDataScopeCallbacks(db, d.enqueueDataScopeAudit) d.dbList, err = openDatabaseList(c.DatabaseList, appLogger) @@ -282,16 +287,6 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor for _, item := range d.dbList { registerDataScopeCallbacks(item, d.enqueueDataScopeAudit) } - if !usingFallback && !disableAutoMigrate { - if err = migrateAll(db, catalog); err != nil { - return nil, nil, fmt.Errorf("migrate tables: %w", err) - } - } - if !usingFallback && disableAutoMigrate { - if err = checkMigratedSchema(db, catalog); err != nil { - return nil, nil, err - } - } if !usingFallback { storageConfig, storageErr := dataintegration.ResolveStorageConfig(db, admin.Storage) if storageErr != nil { diff --git a/internal/data/migrate.go b/internal/data/migrate.go index 4d653c7..7ef2cbf 100644 --- a/internal/data/migrate.go +++ b/internal/data/migrate.go @@ -4,17 +4,20 @@ import ( "context" "fmt" "log/slog" + "path/filepath" "time" + drivermysql "github.com/go-sql-driver/mysql" "gorm.io/gorm" "kra/internal/config" + "kra/internal/data/swmigration" "kra/pkg/database/migration" "kra/pkg/module" ) // MigrateDatabase opens only the configured primary database. It does not // create a database, start a scheduler, open SDK clients, or initialize users. -func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Catalog, logger *slog.Logger, checkOnly bool) error { +func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Catalog, logger *slog.Logger, checkOnly bool, configPath ...string) error { if !databaseConnectionConfigured(c) { return fmt.Errorf("migration requires an existing configured database") } @@ -23,7 +26,23 @@ func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Cat if !checkOnly && migrationConfig.MaxOpenConns > 0 && migrationConfig.MaxOpenConns < 2 { migrationConfig.MaxOpenConns = 2 } - db, err := openDatabase(&migrationConfig, false, "", logger) + var db *gorm.DB + var err error + if normalizedDriver(c.Driver) == "mysql" && !checkOnly { + var dsn string + dsn, err = databaseDSN(&migrationConfig, "") + if err == nil { + var options *drivermysql.Config + options, err = drivermysql.ParseDSN(dsn) + if err == nil { + options.ReadTimeout = 0 + options.WriteTimeout = 0 + db, err = openWithDriverConfig("mysql", options.FormatDSN(), &migrationConfig, logger) + } + } + } else { + db, err = openDatabase(&migrationConfig, false, "", logger) + } if err != nil { return fmt.Errorf("open migration database: %w", err) } @@ -32,6 +51,17 @@ func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Cat if checkOnly { return checkMigratedSchema(db, catalog) } + automatic, err := swmigration.Recognized(db) + if err != nil { + return err + } + if automatic { + root := "" + if len(configPath) > 0 && configPath[0] != "" { + root = filepath.Join(filepath.Dir(configPath[0]), "migration-backups") + } + return swmigration.Run(ctx, db, catalog, root, logger) + } // A dedicated connection owns the lock even when a migration uses a // transaction or temporarily changes connection-local SQL compatibility. run := func() error { @@ -67,6 +97,22 @@ func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Cat }) } +// SW upgrades are automatic even for installations that previously disabled +// generic table auto-migration. Other template databases keep that setting. +func prepareConfiguredDatabase(ctx context.Context, db *gorm.DB, c *config.Database, catalog module.Catalog, logger *slog.Logger, configPath string, disable bool) error { + automatic, err := swmigration.Recognized(db.Session(&gorm.Session{NewDB: true}).WithContext(ctx)) + if err != nil { + return err + } + if automatic { + return MigrateDatabase(ctx, c, catalog, logger, false, configPath) + } + if disable { + return checkMigratedSchema(db, catalog) + } + return migrateAll(db, catalog) +} + func checkMigratedSchema(db *gorm.DB, catalog module.Catalog) error { db = db.Session(&gorm.Session{NewDB: true}) if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil { diff --git a/internal/data/swmigration/backup.go b/internal/data/swmigration/backup.go new file mode 100644 index 0000000..b8e41a3 --- /dev/null +++ b/internal/data/swmigration/backup.go @@ -0,0 +1,317 @@ +package swmigration + +import ( + "bufio" + "compress/gzip" + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "log/slog" + "math/big" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "gorm.io/gorm" +) + +func quoteName(value string) string { return "`" + strings.ReplaceAll(value, "`", "``") + "`" } + +// backupDatabase writes affected data to a private, durable SQL archive. +// Large raw-data tables are not rewritten by the upgrade; their original +// schemas are included as comments. tab_promote is included with data because +// its primary-key width changes. Cleanup snapshots include every drop target. +func backupDatabase(ctx context.Context, db *gorm.DB, root, identity, phase string, cleanupTargets map[string]bool, logger *slog.Logger) (path, digest string, err error) { + if root == "" { + return "", "", fmt.Errorf("a persistent configuration directory is required for migration backups") + } + if err = os.MkdirAll(root, 0700); err != nil { + return "", "", err + } + info, err := os.Lstat(root) + if err != nil { + return "", "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", "", fmt.Errorf("migration backup location must be a private directory") + } + if err = os.Chmod(root, 0700); err != nil { + return "", "", err + } + key := sha256.Sum256([]byte(identity)) + name := fmt.Sprintf("sw-%x-%s-%d.sql.gz", key[:8], phase, time.Now().UnixNano()) + path = filepath.Join(root, name) + partial := path + ".partial" + file, err := os.OpenFile(partial, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return "", "", err + } + defer func() { + _ = file.Close() + if err != nil { + _ = os.Remove(partial) + } + }() + hash := sha256.New() + compressed, err := gzip.NewWriterLevel(io.MultiWriter(file, hash), gzip.BestSpeed) + if err != nil { + return "", "", err + } + defer compressed.Close() + writer := bufio.NewWriterSize(compressed, 128*1024) + write := func(value string) error { _, err := writer.WriteString(value); return err } + if err = write("-- Automatic SW upgrade backup. Restore only in a maintenance session.\n-- Contains affected/retired data; untouched raw-data schemas are comments.\nSET @sw_restore_mode=@@SESSION.sql_mode;\nSET @sw_restore_fk=@@SESSION.foreign_key_checks;\nSET @sw_restore_tz=@@SESSION.time_zone;\nSET SESSION sql_mode='NO_AUTO_VALUE_ON_ZERO';\nSET SESSION foreign_key_checks=0;\nSET SESSION time_zone='+00:00';\nSET NAMES utf8mb4;\n"); err != nil { + return "", "", err + } + pool, err := db.DB() + if err != nil { + return "", "", err + } + conn, err := pool.Conn(ctx) + if err != nil { + return "", "", err + } + defer conn.Close() + var oldTimeZone, oldSQLMode, oldClientCharset, oldCollation string + var oldResultCharset sql.NullString + if err = conn.QueryRowContext(ctx, "SELECT @@SESSION.time_zone,@@SESSION.sql_mode,@@SESSION.character_set_client,@@SESSION.character_set_results,@@SESSION.collation_connection").Scan(&oldTimeZone, &oldSQLMode, &oldClientCharset, &oldResultCharset, &oldCollation); err != nil { + return "", "", err + } + if _, err = conn.ExecContext(ctx, "SET SESSION time_zone='+00:00',sql_mode='NO_AUTO_VALUE_ON_ZERO'"); err != nil { + return "", "", err + } + defer func() { + clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + var resultCharset any + if oldResultCharset.Valid { + resultCharset = oldResultCharset.String + } + _, restoreErr := conn.ExecContext(clean, "SET SESSION time_zone=?,sql_mode=?,character_set_client=?,character_set_results=?,collation_connection=?", oldTimeZone, oldSQLMode, oldClientCharset, resultCharset, oldCollation) + if err == nil { + err = restoreErr + } + }() + if _, err = conn.ExecContext(ctx, "SET NAMES utf8mb4"); err != nil { + return "", "", err + } + tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + if err != nil { + return "", "", err + } + defer tx.Rollback() + var schema string + if err = tx.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&schema); err != nil { + return "", "", err + } + rows, err := tx.QueryContext(ctx, "SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() AND table_type='BASE TABLE' ORDER BY table_name") + if err != nil { + return "", "", err + } + var tables []string + for rows.Next() { + var table string + if err = rows.Scan(&table); err != nil { + rows.Close() + return "", "", err + } + if table != TableName { + tables = append(tables, table) + } + } + err = rows.Err() + rows.Close() + if err != nil { + return "", "", err + } + for _, table := range tables { + if err = ctx.Err(); err != nil { + return "", "", err + } + if cleanupTargets != nil && !cleanupTargets[table] { + continue + } + var unused, ddl string + if err = tx.QueryRowContext(ctx, "SHOW CREATE TABLE "+quoteName(table)).Scan(&unused, &ddl); err != nil { + return "", "", err + } + includeData := cleanupTargets != nil || !strings.HasPrefix(table, "tab_") || table == "tab_promote" + if !includeData { + if err = write("-- Retained raw table; schema reference only:\n-- " + strings.ReplaceAll(ddl, "\n", "\n-- ") + ";\n"); err != nil { + return "", "", err + } + continue + } + if err = write("DROP TABLE IF EXISTS " + quoteName(table) + ";\n" + ddl + ";\n"); err != nil { + return "", "", err + } + columns, types, columnErr := backupColumns(ctx, tx, schema, table) + if columnErr != nil { + return "", "", columnErr + } + quoted := make([]string, len(columns)) + for i, col := range columns { + quoted[i] = quoteName(col) + } + prefix := "INSERT INTO " + quoteName(table) + " (" + strings.Join(quoted, ",") + ") VALUES (" + dataRows, queryErr := tx.QueryContext(ctx, "SELECT "+strings.Join(quoted, ",")+" FROM "+quoteName(table)) + if queryErr != nil { + return "", "", queryErr + } + var count int64 + for dataRows.Next() { + values := make([]any, len(columns)) + dest := make([]any, len(columns)) + for i := range values { + dest[i] = &values[i] + } + if err = dataRows.Scan(dest...); err != nil { + dataRows.Close() + return "", "", err + } + literals := make([]string, len(values)) + for i, value := range values { + literals[i], err = backupLiteral(value, types[i]) + if err != nil { + dataRows.Close() + return "", "", fmt.Errorf("backup %s.%s: %w", table, columns[i], err) + } + } + if err = write(prefix + strings.Join(literals, ",") + ");\n"); err != nil { + dataRows.Close() + return "", "", err + } + count++ + } + err = dataRows.Err() + dataRows.Close() + if err != nil { + return "", "", err + } + logger.Info("migration backup table completed", "table", table, "rows", count, "phase", phase) + } + if err = tx.Commit(); err != nil { + return "", "", err + } + if err = write("SET SESSION foreign_key_checks=@sw_restore_fk;\nSET SESSION sql_mode=@sw_restore_mode;\nSET SESSION time_zone=@sw_restore_tz;\n-- SW BACKUP COMPLETE\n"); err != nil { + return "", "", err + } + if err = writer.Flush(); err != nil { + return "", "", err + } + if err = compressed.Close(); err != nil { + return "", "", err + } + if err = file.Sync(); err != nil { + return "", "", err + } + if err = file.Close(); err != nil { + return "", "", err + } + if err = os.Rename(partial, path); err != nil { + return "", "", err + } + digest = hex.EncodeToString(hash.Sum(nil)) + if err = verifyBackup(path, digest); err != nil { + return "", "", err + } + logger.Info("migration backup verified", "phase", phase, "path", path, "sha256", digest) + return path, digest, nil +} + +func backupColumns(ctx context.Context, tx *sql.Tx, schema, table string) ([]string, []string, error) { + rows, err := tx.QueryContext(ctx, "SELECT column_name,data_type,extra FROM information_schema.columns WHERE table_schema=? AND table_name=? ORDER BY ordinal_position", schema, table) + if err != nil { + return nil, nil, err + } + defer rows.Close() + var names, types []string + for rows.Next() { + var name, kind, extra string + if err := rows.Scan(&name, &kind, &extra); err != nil { + return nil, nil, err + } + if strings.Contains(extra, "VIRTUAL GENERATED") || strings.Contains(extra, "STORED GENERATED") { + continue + } + names = append(names, name) + types = append(types, kind) + } + return names, types, rows.Err() +} + +func backupLiteral(value any, kind string) (string, error) { + if value == nil { + return "NULL", nil + } + var raw []byte + switch v := value.(type) { + case []byte: + raw = v + case string: + raw = []byte(v) + case time.Time: + if v.IsZero() { + raw = []byte("0000-00-00 00:00:00") + } else { + raw = []byte(v.Format("2006-01-02 15:04:05.999999")) + } + case int64: + raw = []byte(strconv.FormatInt(v, 10)) + case uint64: + raw = []byte(strconv.FormatUint(v, 10)) + case float64: + raw = []byte(strconv.FormatFloat(v, 'g', -1, 64)) + case bool: + if v { + raw = []byte("1") + } else { + raw = []byte("0") + } + default: + return "", fmt.Errorf("unsupported SQL value %T", value) + } + if kind == "bit" { + if v, ok := value.(int64); ok { + return strconv.FormatInt(v, 10), nil + } + if v, ok := value.(uint64); ok { + return strconv.FormatUint(v, 10), nil + } + return new(big.Int).SetBytes(raw).String(), nil + } + encoded := "FROM_BASE64('" + base64.StdEncoding.EncodeToString(raw) + "')" + switch kind { + case "binary", "varbinary", "blob", "tinyblob", "mediumblob", "longblob": + return encoded, nil + case "geometry", "point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection": + return "", fmt.Errorf("unsupported geometry backup type %s", kind) + default: + return "CONVERT(" + encoded + " USING utf8mb4)", nil + } +} + +func verifyBackup(path, expected string) error { + if path == "" || len(expected) != 64 { + return fmt.Errorf("verified migration backup is missing") + } + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open migration backup: %w", err) + } + defer file.Close() + hash := sha256.New() + if _, err = io.Copy(hash, file); err != nil { + return err + } + if hex.EncodeToString(hash.Sum(nil)) != expected { + return fmt.Errorf("migration backup checksum mismatch: %s", path) + } + return nil +} diff --git a/internal/data/swmigration/backup_test.go b/internal/data/swmigration/backup_test.go new file mode 100644 index 0000000..2d8f901 --- /dev/null +++ b/internal/data/swmigration/backup_test.go @@ -0,0 +1,155 @@ +package swmigration + +import ( + "compress/gzip" + "context" + "database/sql" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + assets "kra/deploy/migrations" +) + +func backupTestDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv("SW_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("isolated MySQL required") + } + cfg, err := driver.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") { + t.Fatal("local MySQL required") + } + cfg.DBName = "" + cfg.ParseTime = true + admin, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + name := fmt.Sprintf("sw_test_backup_%d", time.Now().UnixNano()) + if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4"); err != nil { + t.Fatal(err) + } + cfg.DBName = name + db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(1) + t.Cleanup(func() { + pool.Close() + _, err := admin.Exec("DROP DATABASE `" + name + "`") + if err != nil { + t.Error(err) + } + admin.Close() + }) + return db +} + +func TestMySQLBackupRoundTripPreservesStorageValues(t *testing.T) { + source, target := backupTestDB(t), backupTestDB(t) + for _, statement := range []string{ + "SET SESSION time_zone='+08:00'", + "CREATE TABLE sys_fixture(id BIGINT UNSIGNED PRIMARY KEY,txt LONGTEXT,raw VARBINARY(16),amount DECIMAL(30,6),payload JSON,flags BIT(9),stamp TIMESTAMP(6) NULL,clock DATETIME(6),computed DECIMAL(31,6) GENERATED ALWAYS AS(amount*2) VIRTUAL)", + "INSERT INTO sys_fixture(id,txt,raw,amount,payload,flags,stamp,clock) VALUES(18446744073709551615,'中文😀 quotes '' slash \\\\ line',X'00FF27225C','123456789012345678901234.123456','{\"id\":\"9007199254740993\"}',b'100000001','2026-02-03 04:05:06.123456','2026-02-03 04:05:06.123456'),(1,'',X'',NULL,NULL,b'0',NULL,NULL)", + "CREATE TABLE tab_user(id INT PRIMARY KEY)", "INSERT INTO tab_user VALUES(1)", + "CREATE TABLE tab_promote(id BIGINT PRIMARY KEY)", "INSERT INTO tab_promote VALUES(9007199254740993)", + } { + if err := source.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + if err := target.Exec("CREATE TABLE tab_user(id INT PRIMARY KEY)").Error; err != nil { + t.Fatal(err) + } + target.Exec("INSERT INTO tab_user VALUES(77)") + root := t.TempDir() + path, digest, err := backupDatabase(context.Background(), source, root, "test-db", "source", nil, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + if err := verifyBackup(path, digest); err != nil { + t.Fatal(err) + } + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + reader, err := gzip.NewReader(file) + if err != nil { + t.Fatal(err) + } + raw, err := io.ReadAll(reader) + reader.Close() + file.Close() + if err != nil { + t.Fatal(err) + } + statements, err := assets.Statements(string(raw)) + if err != nil { + t.Fatal(err) + } + for _, statement := range statements { + if err := target.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + query := "SELECT CAST(id AS CHAR) AS id,txt,HEX(raw) AS raw,CAST(amount AS CHAR) AS amount,CAST(payload AS CHAR) AS payload,CAST(flags AS UNSIGNED) AS flags,UNIX_TIMESTAMP(stamp) AS stamp,CAST(clock AS CHAR) AS clock,CAST(computed AS CHAR) AS computed FROM sys_fixture ORDER BY id" + var before, after []map[string]any + if err := source.Raw(query).Scan(&before).Error; err != nil { + t.Fatal(err) + } + if err := target.Raw(query).Scan(&after).Error; err != nil { + t.Fatal(err) + } + if fmt.Sprint(before) != fmt.Sprint(after) { + t.Fatalf("SQL backup round trip changed storage values: before=%v after=%v", before, after) + } + var id uint64 + target.Table("tab_promote").Select("id").Scan(&id) + if id != 9007199254740993 { + t.Fatal("large promote ID changed") + } + target.Table("tab_user").Select("id").Scan(&id) + if id != 77 { + t.Fatal("schema-only raw table was overwritten") + } + var timezone string + source.Raw("SELECT @@SESSION.time_zone").Scan(&timezone) + if timezone != "+08:00" { + t.Fatalf("backup leaked connection time zone: %s", timezone) + } + appendFile, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + appendFile.WriteString("corrupt") + appendFile.Close() + if err := verifyBackup(path, digest); err == nil { + t.Fatal("modified backup checksum accepted") + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, _, err := backupDatabase(cancelled, source, root, "test-db", "cancelled", nil, slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil { + t.Fatal("cancelled backup succeeded") + } + partial, _ := filepath.Glob(filepath.Join(root, "*.partial")) + if len(partial) != 0 { + t.Fatalf("partial backup left behind: %v", partial) + } +} diff --git a/internal/data/swmigration/framework.go b/internal/data/swmigration/framework.go new file mode 100644 index 0000000..312491c --- /dev/null +++ b/internal/data/swmigration/framework.go @@ -0,0 +1,119 @@ +package swmigration + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "sort" + "strings" + + "gorm.io/gorm" + scripts "kra/deploy/migrations" +) + +func migrateFramework(ctx context.Context, db *gorm.DB, conn *sql.Conn, logger *slog.Logger) error { + tables, err := tableSet(db) + if err != nil { + return err + } + if tables["sw_kra_state"] { + var previous struct{ Version, Phase string } + if err := db.Table("sw_kra_state").Where("id=1").Take(&previous).Error; err != nil { + return err + } + if previous.Version != "20261103_framework_migration_v5" { + return fmt.Errorf("unsupported legacy migration version %q", previous.Version) + } + switch previous.Phase { + case "completed": + return nil + case "data_validated": + return finishArchives(ctx, db, conn) + case "backup": + // All framework data changes and the data_validated marker commit + // together. In this phase only empty target DDL may survive a crash. + if err := resetIncompleteFramework(ctx, db, conn, tables); err != nil { + return err + } + default: + return fmt.Errorf("unsupported partial migration phase %q", previous.Phase) + } + } else if !tables["sys_user"] { + return fmt.Errorf("legacy source users and migration evidence are missing") + } + return executeScript(ctx, conn, scripts.FrameworkSQL(), false, logger) +} + +func resetIncompleteFramework(ctx context.Context, db *gorm.DB, conn *sql.Conn, tables map[string]bool) error { + for source, archive := range scripts.LegacyArchives() { + if !tables[source] || tables[archive] { + return fmt.Errorf("incomplete framework sources are not recoverable: %s", source) + } + } + created := scripts.FrameworkCreatedTables() + sort.SliceStable(created, func(i, j int) bool { + priority := func(name string) int { + if name == "sw_kra_state" { + return 2 + } + if name == "sw_kra_checks" { + return 1 + } + return 0 + } + return priority(created[i]) < priority(created[j]) + }) + for _, name := range created { + if !tables[name] || strings.HasPrefix(name, "bak_sw2kra_") || name == "sw_kra_state" || name == "sw_kra_checks" { + continue + } + var count int64 + if err := db.Table(name).Count(&count).Error; err != nil { + return err + } + if count != 0 { + return fmt.Errorf("partial framework target %s contains committed data; automatic reset refused", name) + } + } + for _, name := range created { + if tables[name] { + if _, err := conn.ExecContext(ctx, "DROP TABLE "+quoteName(name)); err != nil { + return err + } + } + } + return nil +} + +func finishArchives(ctx context.Context, db *gorm.DB, conn *sql.Conn) error { + tables, err := tableSet(db) + if err != nil { + return err + } + for _, name := range scripts.FrameworkCreatedTables() { + if !tables[name] { + return fmt.Errorf("validated framework table is missing: %s", name) + } + } + if err := verifyCheckRows(db); err != nil { + return err + } + var moves []string + for source, archive := range scripts.LegacyArchives() { + if tables[source] == tables[archive] { + return fmt.Errorf("ambiguous legacy archive state: %s", source) + } + if tables[source] { + moves = append(moves, quoteName(source)+" TO "+quoteName(archive)) + } + } + sort.Strings(moves) + if len(moves) > 0 { + if _, err := conn.ExecContext(ctx, "RENAME TABLE "+strings.Join(moves, ",")); err != nil { + return err + } + } + _, err = conn.ExecContext(ctx, "UPDATE sw_kra_state SET phase='completed',completed_at=NOW(3) WHERE id=1") + return err +} diff --git a/internal/data/swmigration/runner.go b/internal/data/swmigration/runner.go new file mode 100644 index 0000000..2130282 --- /dev/null +++ b/internal/data/swmigration/runner.go @@ -0,0 +1,426 @@ +package swmigration + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + "time" + + "gorm.io/gorm" + scripts "kra/deploy/migrations" + "kra/pkg/database/migration" + "kra/pkg/module" +) + +// Recognized identifies an old SW database or a previous automatic upgrade. +// Database names are not used to infer ownership of unrelated schemas. +func Recognized(db *gorm.DB) (bool, error) { + if db.Dialector.Name() != "mysql" { + return false, nil + } + tables, err := tableSet(db) + if err != nil { + return false, err + } + if tables["sw_kra_state"] || tables["sys_user"] && tables["tab_sw_report_day"] && tables["tab_sw_report_server_day"] { + return true, nil + } + if !tables[TableName] { + return false, nil + } + // The state table also exists in new template installations. Only a + // recorded legacy workflow identifies a previously upgraded SW database. + var count int64 + if err := db.Table(TableName).Where("name=?", workflowName).Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func tableSet(db *gorm.DB) (map[string]bool, error) { + var names []string + if err := db.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() AND table_type='BASE TABLE'").Scan(&names).Error; err != nil { + return nil, err + } + set := map[string]bool{} + for _, name := range names { + set[name] = true + } + return set, nil +} + +func Run(ctx context.Context, db *gorm.DB, catalog module.Catalog, backupRoot string, logger *slog.Logger) (runErr error) { + if logger == nil { + logger = slog.Default() + } + db = db.Session(&gorm.Session{NewDB: true}).WithContext(ctx) + pool, err := db.DB() + if err != nil { + return err + } + conn, err := pool.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + var schema, serverUUID string + if err = conn.QueryRowContext(ctx, "SELECT DATABASE(),@@server_uuid").Scan(&schema, &serverUUID); err != nil { + return err + } + if schema == "" { + return fmt.Errorf("automatic upgrade requires a selected database") + } + var lockName string + if err = conn.QueryRowContext(ctx, "SELECT CONCAT('sw_to_kra:',MD5(DATABASE()))").Scan(&lockName); err != nil { + return err + } + for { + var locked sql.NullInt64 + if err = conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,10)", lockName).Scan(&locked); err != nil { + return err + } + if locked.Valid && locked.Int64 == 1 { + break + } + if !locked.Valid { + return fmt.Errorf("database migration lock is unavailable") + } + logger.Info("waiting for another database upgrade", "database", schema) + } + defer func() { + clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + _, _ = conn.ExecContext(clean, "DO RELEASE_LOCK(?)", lockName) + }() + if err = StateMigration().Migrate(db); err != nil { + return err + } + state := statePO{Name: workflowName} + err = db.First(&state, "name=?", workflowName).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + state = statePO{Name: workflowName, Version: workflowVersion, Phase: "backup", DatabaseIdentity: serverUUID + ":" + schema, StartedAt: time.Now(), UpdatedAt: time.Now()} + initial, readErr := tableSet(db) + if readErr != nil { + return readErr + } + state.SourceWasLegacy = initial["sys_user"] + if initial["sw_kra_state"] { + var phase string + if err := db.Table("sw_kra_state").Select("phase").Where("id=1").Scan(&phase).Error; err != nil { + return err + } + state.SourceWasLegacy = state.SourceWasLegacy || phase != "completed" + } + if err = db.Create(&state).Error; err != nil { + return err + } + } else if err != nil { + return err + } + if state.Version != workflowVersion || state.DatabaseIdentity != serverUUID+":"+schema { + return fmt.Errorf("automatic upgrade identity/version does not match this database") + } + started := time.Now() + logger.Info("automatic business database upgrade started", "database", schema, "phase", state.Phase) + defer func() { + status := "completed" + if runErr != nil { + status = "failed" + clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + _ = db.Session(&gorm.Session{NewDB: true}).WithContext(clean).Model(&statePO{}).Where("name=?", workflowName).Updates(map[string]any{"last_error": runErr.Error(), "updated_at": time.Now()}).Error + } + logger.Info("automatic business database upgrade finished", "database", schema, "status", status, "elapsed_ms", time.Since(started).Milliseconds()) + }() + save := func() error { state.UpdatedAt = time.Now(); return db.Save(&state).Error } + stage := func(name string, work func() error) error { + state.Phase = name + state.LastError = "" + if err := save(); err != nil { + return err + } + begin := time.Now() + logger.Info("database upgrade stage started", "stage", name) + done := make(chan struct{}) + stopped := make(chan struct{}) + go func() { + defer close(stopped) + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + logger.Info("database upgrade is still running", "stage", name, "elapsed_ms", time.Since(begin).Milliseconds()) + } + } + }() + err := work() + close(done) + <-stopped + if err != nil { + return fmt.Errorf("database upgrade %s: %w", name, err) + } + logger.Info("database upgrade stage completed", "stage", name, "elapsed_ms", time.Since(begin).Milliseconds()) + return nil + } + resumingCleanup := state.CleanupBackupPath != "" && state.VerifiedAt != nil + wasComplete := state.CompletedAt != nil + if !resumingCleanup && !wasComplete { + if state.BackupPath == "" { + if err := stage("backup", func() error { + var e error + state.BackupPath, state.BackupSHA256, e = backupDatabase(ctx, db, backupRoot, state.DatabaseIdentity, "source", nil, logger) + return e + }); err != nil { + return err + } + if err := save(); err != nil { + return err + } + } else if err := verifyBackup(state.BackupPath, state.BackupSHA256); err != nil { + return err + } + if err := stage("framework", func() error { return migrateFramework(ctx, db, conn, logger) }); err != nil { + return err + } + } + if err := stage("migrations", func() error { + if err := catalog.CheckPrerequisites(db); err != nil { + return err + } + return migration.Run(db, catalog.MigrationSteps()) + }); err != nil { + return err + } + if err := stage("verify", func() error { + if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil { + return err + } + return catalog.CheckSchema(db) + }); err != nil { + return err + } + remaining, err := remainingTargets(db) + if err != nil { + return err + } + if len(remaining) > 0 { + if wasComplete { + return fmt.Errorf("retired tables reappeared after a completed upgrade; automatic deletion refused") + } + if !resumingCleanup && !wasComplete { + if err := verifyLegacyChecks(db); err != nil { + return err + } + } + if state.SourceWasLegacy && !state.TasksRestored { + if err := stage("tasks", func() error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("UPDATE sys_timed_tasks t JOIN sw_kra_id_map m ON m.entity='task' AND m.new_id=t.id JOIN sj_job j ON CAST(j.id AS CHAR)=m.old_id SET t.enabled=(j.job_status=1) WHERE j.deleted=0").Error; err != nil { + return err + } + return tx.Model(&statePO{}).Where("name=?", workflowName).Update("tasks_restored", true).Error + }) + }); err != nil { + return err + } + state.TasksRestored = true + } + if state.CleanupBackupPath == "" { + if err := stage("cleanup-backup", func() error { + var e error + state.CleanupBackupPath, state.CleanupSHA256, e = backupDatabase(ctx, db, backupRoot, state.DatabaseIdentity, "cleanup", remaining, logger) + return e + }); err != nil { + return err + } + if err := save(); err != nil { + return err + } + } else if err := verifyBackup(state.CleanupBackupPath, state.CleanupSHA256); err != nil { + return err + } + // The journal survives deletion of all old migration helpers. A restart + // can finish a partially completed DROP list using the verified archive. + now := time.Now() + state.VerifiedAt = &now + state.VerifiedMigrations = migrationHash(catalog.MigrationSteps()) + if err := stage("cleanup", func() error { + // CALL can commit before a killed process reaches DROP PROCEDURE. + // Remove only our own shipped routine before checking dependencies. + if _, err := conn.ExecContext(ctx, "DROP PROCEDURE IF EXISTS sw_kra_migrate_20261103"); err != nil { + return err + } + return executeScript(ctx, conn, scripts.CleanupSQL(), true, logger) + }); err != nil { + return err + } + } + if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil { + return err + } + if err := catalog.CheckSchema(db); err != nil { + return err + } + remaining, err = remainingTargets(db) + if err != nil { + return err + } + if len(remaining) != 0 { + return fmt.Errorf("legacy cleanup left %d tables", len(remaining)) + } + now := time.Now() + state.Phase = "complete" + if state.CompletedAt == nil { + state.CompletedAt = &now + } + state.VerifiedAt = &now + state.LastError = "" + state.VerifiedMigrations = migrationHash(catalog.MigrationSteps()) + return save() +} + +func migrationHash(steps []migration.Step) string { + ids := make([]string, len(steps)) + for i, step := range steps { + ids[i] = step.ID + } + sort.Strings(ids) + sum := sha256.Sum256([]byte(strings.Join(ids, "\n"))) + return hex.EncodeToString(sum[:]) +} + +func remainingTargets(db *gorm.DB) (map[string]bool, error) { + tables, err := tableSet(db) + if err != nil { + return nil, err + } + remaining := map[string]bool{} + for _, name := range scripts.CleanupTables() { + if tables[name] { + remaining[name] = true + } + } + return remaining, nil +} + +func executeScript(ctx context.Context, conn *sql.Conn, source string, cleanup bool, logger *slog.Logger) error { + statements, err := scripts.Statements(source) + if err != nil { + return err + } + for _, statement := range statements { + upper := strings.ToUpper(strings.TrimSpace(statement)) + if strings.HasPrefix(upper, "USE ") { + continue + } + if cleanup && strings.HasPrefix(upper, "SET @SW_CLEANUP_EXECUTE") { + statement = "SET @sw_cleanup_execute=1" + } + rows, err := conn.QueryContext(ctx, statement) + if err != nil { + return err + } + for { + columns, err := rows.Columns() + if err != nil { + rows.Close() + return err + } + for rows.Next() { + values := make([]any, len(columns)) + dest := make([]any, len(columns)) + for i := range values { + dest[i] = &values[i] + } + if err := rows.Scan(dest...); err != nil { + rows.Close() + return err + } + // Result sets contain progress/metadata only, never copied rows. + if len(columns) > 0 && (columns[0] == "dropped_table" || columns[0] == "result") { + attrs := make([]any, 0, len(columns)*2) + for i, name := range columns { + value := values[i] + if b, ok := value.([]byte); ok { + value = string(b) + } + attrs = append(attrs, name, value) + } + logger.Info("database migration SQL result", attrs...) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if !rows.NextResultSet() { + break + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + } + return nil +} + +func verifyLegacyChecks(db *gorm.DB) error { + tables, err := tableSet(db) + if err != nil { + return err + } + if !tables["sw_kra_state"] || !tables["sw_kra_checks"] { + return fmt.Errorf("legacy migration evidence is missing; cleanup refused") + } + var completed int64 + if err := db.Table("sw_kra_state").Where("id=1 AND version=? AND phase='completed' AND completed_at IS NOT NULL", "20261103_framework_migration_v5").Count(&completed).Error; err != nil { + return err + } + if completed != 1 { + return fmt.Errorf("legacy framework migration is incomplete") + } + if err := verifyCheckRows(db); err != nil { + return err + } + if tables["legacy_sw_20261103_sys_user"] && tables["sw_kra_id_map"] { + var archived, mapped int64 + if err := db.Table("legacy_sw_20261103_sys_user").Where("del_flag='0'").Count(&archived).Error; err != nil { + return err + } + if err := db.Table("sw_kra_id_map").Where("entity='user'").Count(&mapped).Error; err != nil { + return err + } + if archived != mapped { + return fmt.Errorf("legacy accounts changed during conversion; cleanup refused") + } + } + return nil +} + +func verifyCheckRows(db *gorm.DB) error { + var invalid, backups, checks int64 + if err := db.Table("sw_kra_checks").Where("passed<>1 OR expected_value IS NULL OR actual_value IS NULL OR expected_value<>actual_value").Count(&invalid).Error; err != nil { + return err + } + if err := db.Table("sw_kra_checks").Where("stage='backup'").Distinct("check_name").Count(&backups).Error; err != nil { + return err + } + if err := db.Table("sw_kra_checks").Where("stage='migration'").Distinct("check_name").Count(&checks).Error; err != nil { + return err + } + if invalid != 0 || backups != 52 || checks != 24 { + return fmt.Errorf("legacy migration validation is incomplete; cleanup refused") + } + return nil +} diff --git a/internal/data/swmigration/runner_test.go b/internal/data/swmigration/runner_test.go new file mode 100644 index 0000000..aea8278 --- /dev/null +++ b/internal/data/swmigration/runner_test.go @@ -0,0 +1,609 @@ +package swmigration_test + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + assets "kra/deploy/migrations" + biz "kra/internal/biz/business" + bizsystem "kra/internal/biz/system" + "kra/internal/config" + rootdata "kra/internal/data" + data "kra/internal/data/business" + "kra/internal/data/swmigration" + "kra/internal/integration/storage" + "kra/internal/modules" + "kra/pkg/database/migration" +) + +func localDatabase(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv("SW_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set SW_TEST_MYSQL_DSN for isolated MySQL upgrade tests") + } + cfg, err := driver.ParseDSN(dsn) + if err != nil { + t.Fatal(err) + } + if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") { + t.Fatal("upgrade tests require an isolated local 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_upgrade_%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) + } + cfg.DBName = name + db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + pool.SetMaxOpenConns(4) + t.Cleanup(func() { + pool.Close() + _, err := admin.Exec("DROP DATABASE `" + name + "`") + if err != nil { + t.Error(err) + } + admin.Close() + }) + return db +} + +func legacyFixture(t *testing.T, db *gorm.DB) string { + t.Helper() + raw, err := os.ReadFile("testdata/legacy-schema.sql") + if err != nil { + t.Fatal(err) + } + statements, err := assets.Statements(string(raw)) + if err != nil { + t.Fatal(err) + } + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + var surface struct{ Menus, Jobs []map[string]any } + file, err := os.Open("testdata/legacy-surface.json") + if err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(file) + decoder.UseNumber() + err = decoder.Decode(&surface) + file.Close() + if err != nil { + t.Fatal(err) + } + password, err := bcrypt.GenerateFromPassword([]byte("Fixture-password-2026"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + roleIDs := []string{"1", "2083497078545207297", "2087435436573081601", "2087361690139152385"} + userIDs := []string{"9007199254740993", "9007199254740995", "9007199254740997", "9007199254740999"} + for level, id := range roleIDs { + key := fmt.Sprintf("business%d", level) + if level == 0 { + key = "superadmin" + } + insertLegacy(t, db, "sys_role", map[string]any{"role_id": id, "role_name": fmt.Sprintf("renamed-role-%d", level), "role_key": key, "role_sort": level, "data_scope": "1", "status": "0", "del_flag": "0", "create_time": "2026-01-01 08:00:00", "update_time": "2026-01-01 08:00:00"}) + parent := "0" + path := "0" + if level > 1 { + parent = userIDs[level-1] + path = "0," + strings.Join(userIDs[1:level], ",") + } + insertLegacy(t, db, "sys_user", map[string]any{"user_id": userIDs[level], "user_name": fmt.Sprintf("fixture-user-%d", level), "nick_name": fmt.Sprintf("fixture-%d", level), "password": string(password), "status": "0", "del_flag": "0", "level": level, "parent_id": parent, "id_path": path, "create_time": "2026-01-01 08:00:00", "update_time": "2026-01-01 08:00:00"}) + insertLegacy(t, db, "sys_user_role", map[string]any{"user_id": userIDs[level], "role_id": id}) + insertLegacy(t, db, "sys_user_preference", map[string]any{"user_id": userIDs[level], "include_sub_promote": 1, "idcard_dedup": 1, "update_time": "2026-01-01 08:00:00"}) + } + for _, menu := range surface.Menus { + insertLegacy(t, db, "sys_menu", menu) + for _, id := range roleIDs { + insertLegacy(t, db, "sys_role_menu", map[string]any{"role_id": id, "menu_id": menu["menu_id"]}) + } + } + for i, job := range surface.Jobs { + job["id"] = i + 1 + job["job_name"] = fmt.Sprintf("fixture-job-%d", i) + job["biz_id"] = fmt.Sprintf("fixture-job-%d", i) + job["namespace_id"] = "fixture" + job["args_str"] = "{}" + job["deleted"] = 0 + insertLegacy(t, db, "sj_job", job) + } + insertLegacy(t, db, "sys_user_bind_platform", map[string]any{"user_id": userIDs[3], "source_db": "lx", "platform_busier_id": 14, "platform_busier_account": "fixture-sdk", "status": "0", "create_time": "2026-01-01 08:00:00"}) + insertLegacy(t, db, "sys_oss", map[string]any{"oss_id": "9007199254741093", "file_name": "fixture.png", "original_name": "fixture.png", "file_suffix": "png", "url": "https://example.invalid/fixture.png", "ext1": "{}", "create_by": userIDs[3], "create_time": "2026-01-01 08:00:00"}) + insertLegacy(t, db, "sys_alert_rule", map[string]any{"id": 1, "rule_name": "fixture-rule", "rule_type": "fixture", "monitor_metric": 1, "monitor_scope": 1, "warning_color": "red", "creator_id": userIDs[3], "enabled": 1, "is_deleted": 0, "created_at": "2026-01-01 08:00:00", "updated_at": "2026-01-01 08:00:00"}) + insertLegacy(t, db, "sys_alert_record", map[string]any{"id": "9007199254740993", "platform_code": "lx", "platform_name": "fixture", "rule_id": 1, "rule_name": "fixture-rule", "rule_type": "fixture", "target_promote_id": 100, "business_id": userIDs[3], "business_name": "fixture", "status": 1, "alert_date": "2026-01-01", "created_at": "2026-01-01 08:00:00", "updated_at": "2026-01-01 08:00:00"}) + insertLegacy(t, db, "sys_alert_operation_log", map[string]any{"id": 1, "alert_id": "9007199254740993", "operator_id": userIDs[3], "log_type": 1, "attachment_ids": "[9007199254741093]", "created_at": "2026-01-01 08:00:00"}) + insertLegacy(t, db, "tab_promote", map[string]any{"id": 100, "source_db": "lx", "account": "fixture-channel", "promote_level": 1, "busier_id": 14}) + insertLegacy(t, db, "tab_user", map[string]any{"id": 1, "source_db": "lx", "account": "fixture-player", "promote_id": 100, "puid": 0, "register_time": "1767225600"}) + insertLegacy(t, db, "tab_spend", map[string]any{"id": 1, "source_db": "lx", "user_id": 1, "promote_id": 100, "pay_status": 1, "pay_amount": "12.34", "pay_time": 1767225600, "pay_order_number": "fixture-order"}) + if err := db.Exec("ALTER TABLE tab_promote AUTO_INCREMENT=2147483640").Error; err != nil { + t.Fatal(err) + } + return string(password) +} + +func insertLegacy(t *testing.T, db *gorm.DB, table string, values map[string]any) { + t.Helper() + var columns []struct { + ColumnName, DataType, Extra, IsNullable string + ColumnDefault *string + } + if err := db.Raw("SELECT column_name,data_type,extra,is_nullable,column_default FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? ORDER BY ordinal_position", table).Scan(&columns).Error; err != nil { + t.Fatal(err) + } + known := map[string]bool{} + for _, column := range columns { + known[column.ColumnName] = true + if _, exists := values[column.ColumnName]; exists || column.ColumnDefault != nil || column.IsNullable == "YES" || strings.Contains(column.Extra, "auto_increment") || strings.Contains(column.Extra, "GENERATED") { + continue + } + var value any = "fixture" + switch column.DataType { + case "tinyint", "smallint", "mediumint", "int", "bigint", "decimal", "double", "float", "bit": + value = 0 + case "datetime", "timestamp": + value = "2026-01-01 08:00:00" + case "date": + value = "2026-01-01" + case "time": + value = "00:00:00" + case "json": + value = "{}" + } + values[column.ColumnName] = value + } + var names []string + for name := range values { + if !known[name] { + t.Fatalf("unknown fixture column %s.%s", table, name) + } + names = append(names, name) + } + sort.Strings(names) + args := make([]any, len(names)) + marks := make([]string, len(names)) + quoted := make([]string, len(names)) + for i, name := range names { + args[i] = values[name] + marks[i] = "?" + quoted[i] = "`" + name + "`" + } + if err := db.Exec("INSERT INTO `"+table+"` ("+strings.Join(quoted, ",")+") VALUES ("+strings.Join(marks, ",")+")", args...).Error; err != nil { + t.Fatalf("seed %s: %v", table, err) + } +} + +type provider struct{ db *gorm.DB } + +func (p provider) DB() *gorm.DB { return p.db } + +func assertComplete(t *testing.T, db *gorm.DB, password string) { + t.Helper() + if err := modules.Catalog().CheckSchema(db); err != nil { + t.Fatal(err) + } + for _, name := range assets.CleanupTables() { + if db.Migrator().HasTable(name) { + t.Fatalf("retired table remains: %s", name) + } + } + var phase string + db.Table(swmigration.TableName).Select("phase").Where("name='legacy-sw'").Scan(&phase) + if phase != "complete" { + t.Fatalf("phase=%q", phase) + } + var users []struct { + ID, AuthorityID uint + Password string + } + if err := db.Table("sys_users").Order("id").Find(&users).Error; err != nil { + t.Fatal(err) + } + if len(users) != 4 { + t.Fatalf("migrated users=%d", len(users)) + } + for _, user := range users { + if user.Password != password { + t.Fatal("password bytes changed") + } + } + var count int64 + db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_type='BASE TABLE'").Scan(&count) + if count != 78 { + t.Fatalf("final table count=%d, want 78", count) + } + db.Table("tab_user").Count(&count) + if count != 1 { + t.Fatal("raw user data changed") + } + var next uint64 + db.Raw("SELECT auto_increment FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='tab_promote'").Scan(&next) + if next != 2147483640 { + t.Fatalf("auto-increment changed: %d", next) + } + var surface struct{ Jobs []map[string]any } + fixture, err := os.Open("testdata/legacy-surface.json") + if err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(fixture) + decoder.UseNumber() + err = decoder.Decode(&surface) + fixture.Close() + if err != nil { + t.Fatal(err) + } + expectedEnabled := int64(2) // new incremental and full effectiveness jobs + for _, job := range surface.Jobs { + if fmt.Sprint(job["job_status"]) == "1" { + expectedEnabled++ + } + } + db.Table("sys_timed_tasks").Where("deleted_at IS NULL AND enabled=1").Count(&count) + if count != expectedEnabled { + t.Fatalf("legacy task enable states not restored: got %d want %d", count, expectedEnabled) + } + var attachments string + db.Table("sys_alert_operation_log").Select("attachment_ids").Where("id=1").Scan(&attachments) + if attachments == "[9007199254741093]" || attachments == "[]" { + t.Fatalf("attachment migration did not finish before cleanup: %s", attachments) + } + var id uint + uc := biz.NewAccountUsecase(data.NewAccountRepo(provider{db})) + for level := 1; level <= 3; level++ { + actor := bizsystem.Actor{UserID: users[0].ID, AuthorityID: 888} + if level > 1 { + var aid uint + db.Table("sys_users").Select("authority_id").Where("id=?", id).Scan(&aid) + actor = bizsystem.Actor{UserID: id, AuthorityID: aid} + } + created, err := uc.CreateChild(bizsystem.NewActorContext(context.Background(), actor), &biz.Account{Username: fmt.Sprintf("after-cleanup-%d", level), Password: "fixture-password", RealName: "fixture"}) + if err != nil { + t.Fatalf("create business level %d after map removal: %v", level, err) + } + id = created.ID + if created.Level != level { + t.Fatalf("created level %d instead of %d", created.Level, level) + } + } +} + +func TestMySQLAutomaticUpgradeAndRestart(t *testing.T) { + db := localDatabase(t) + password := legacyFixture(t, db) + root := t.TempDir() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err != nil { + t.Fatal(err) + } + assertComplete(t, db, password) + archives, err := filepath.Glob(filepath.Join(root, "*.sql.gz")) + if err != nil || len(archives) != 2 { + t.Fatalf("backup archives=%v %v", archives, err) + } + if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err != nil { + t.Fatalf("restart after cleanup: %v", err) + } + var count int64 + db.Table("sys_users").Count(&count) + if count != 7 { + t.Fatal("restart reset created users") + } +} + +func TestMySQLUpgradeRecognitionRequiresLegacyEvidence(t *testing.T) { + db := localDatabase(t) + if err := swmigration.StateMigration().Migrate(db); err != nil { + t.Fatal(err) + } + assertRecognized := func(want bool) { + t.Helper() + got, err := swmigration.Recognized(db) + if err != nil || got != want { + t.Fatalf("legacy recognition = %v, want %v: %v", got, want, err) + } + } + // Every fresh template installation creates this table, even when it has + // never contained legacy SW data or started the legacy upgrade workflow. + assertRecognized(false) + if err := db.Exec("INSERT INTO sys_business_upgrade_state(name,version,phase,database_identity) VALUES('another-workflow','v1','complete','fixture')").Error; err != nil { + t.Fatal(err) + } + assertRecognized(false) + if err := db.Exec("UPDATE sys_business_upgrade_state SET name='legacy-sw'").Error; err != nil { + t.Fatal(err) + } + assertRecognized(true) +} + +func TestMySQLUpgradeResumesAfterIndexFailure(t *testing.T) { + db := localDatabase(t) + password := legacyFixture(t, db) + root := t.TempDir() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := db.Exec("CREATE INDEX idx_sw_user_registration ON tab_user(id)").Error; err != nil { + t.Fatal(err) + } + if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err == nil { + t.Fatal("incompatible existing index accepted") + } + if !db.Migrator().HasTable("sw_kra_id_map") || !db.Migrator().HasTable("legacy_sw_20261103_sys_user") { + t.Fatal("failed index migration deleted recovery data") + } + if err := db.Exec("DROP INDEX idx_sw_user_registration ON tab_user").Error; err != nil { + t.Fatal(err) + } + if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err != nil { + t.Fatalf("resume indexes: %v", err) + } + assertComplete(t, db, password) +} + +func TestMySQLUpgradeResumesPartialCleanupOnlyWithVerifiedBackup(t *testing.T) { + db := localDatabase(t) + password := legacyFixture(t, db) + root := t.TempDir() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + if err := db.Exec("CREATE VIEW retained_job_view AS SELECT id FROM sj_job").Error; err != nil { + t.Fatal(err) + } + if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err == nil { + t.Fatal("cleanup ignored a dependent view") + } + var path string + if err := db.Table(swmigration.TableName).Select("cleanup_backup_path").Where("name='legacy-sw'").Scan(&path).Error; err != nil { + t.Fatal(err) + } + original, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // Simulate a crash after an earlier table was dropped. Its data is already + // in the completed cleanup archive, so restarting must not replace it with + // a new snapshot of only the tables that remain. + if err := db.Exec("DROP TABLE bak_sw2kra_20261103_sys_user").Error; err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(original, byte(1)), 0600); err != nil { + t.Fatal(err) + } + if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err == nil || !strings.Contains(err.Error(), "checksum") { + t.Fatalf("corrupt archive allowed deletion: %v", err) + } + if !db.Migrator().HasTable("sw_kra_id_map") { + t.Fatal("mapping deleted without verified backup") + } + if err := os.WriteFile(path, original, 0600); err != nil { + t.Fatal(err) + } + if err := db.Exec("DROP VIEW retained_job_view").Error; err != nil { + t.Fatal(err) + } + if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err != nil { + t.Fatalf("resume cleanup: %v", err) + } + assertComplete(t, db, password) +} + +func TestMySQLConcurrentAutomaticUpgrades(t *testing.T) { + db := localDatabase(t) + password := legacyFixture(t, db) + root := t.TempDir() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + errors := make(chan error, 2) + var group sync.WaitGroup + for i := 0; i < 2; i++ { + group.Add(1) + go func() { + defer group.Done() + errors <- swmigration.Run(context.Background(), db, modules.Catalog(), root, log) + }() + } + group.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + assertComplete(t, db, password) + archives, _ := filepath.Glob(filepath.Join(root, "*.sql.gz")) + if len(archives) != 2 { + t.Fatalf("concurrent instances duplicated backups: %v", archives) + } +} + +type cancelOnProgress struct { + slog.Handler + cancel context.CancelFunc + once *sync.Once +} + +func (h cancelOnProgress) Handle(ctx context.Context, r slog.Record) error { + r.Attrs(func(a slog.Attr) bool { + if a.Key == "result" && a.Value.String() == "旧框架备份已校验" { + h.once.Do(h.cancel) + } + return true + }) + return h.Handler.Handle(ctx, r) +} +func (h cancelOnProgress) WithAttrs(attrs []slog.Attr) slog.Handler { + return cancelOnProgress{Handler: h.Handler.WithAttrs(attrs), cancel: h.cancel, once: h.once} +} +func (h cancelOnProgress) WithGroup(name string) slog.Handler { + return cancelOnProgress{Handler: h.Handler.WithGroup(name), cancel: h.cancel, once: h.once} +} + +func TestMySQLUpgradeResumesInterruptedFrameworkDDL(t *testing.T) { + db := localDatabase(t) + password := legacyFixture(t, db) + root := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + log := slog.New(cancelOnProgress{Handler: slog.NewTextHandler(io.Discard, nil), cancel: cancel, once: &sync.Once{}}) + if err := swmigration.Run(ctx, db, modules.Catalog(), root, log); err == nil { + t.Fatal("interrupted framework migration unexpectedly completed") + } + if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("recover interrupted DDL: %v", err) + } + assertComplete(t, db, password) +} + +func TestMySQLUpgradeFinishesCommittedArchiveWithoutRemapping(t *testing.T) { + db := localDatabase(t) + password := legacyFixture(t, db) + pool, _ := db.DB() + conn, err := pool.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + statements, err := assets.Statements(assets.FrameworkSQL()) + if err != nil { + t.Fatal(err) + } + for _, statement := range statements { + if strings.HasPrefix(strings.ToUpper(statement), "USE ") { + continue + } + if _, err := conn.ExecContext(context.Background(), statement); err != nil { + conn.Close() + t.Fatal(err) + } + } + conn.Close() + if err := db.Exec("UPDATE sw_kra_state SET phase='data_validated',completed_at=NULL WHERE id=1").Error; err != nil { + t.Fatal(err) + } + if err := swmigration.Run(context.Background(), db, modules.Catalog(), t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("resume committed conversion: %v", err) + } + assertComplete(t, db, password) +} + +func TestMySQLNativeStartupAutomaticallyUpgradesWithLegacyDisableFlag(t *testing.T) { + db := localDatabase(t) + password := legacyFixture(t, db) + cfg, err := driver.ParseDSN(os.Getenv("SW_TEST_MYSQL_DSN")) + if err != nil { + t.Fatal(err) + } + if err := db.Raw("SELECT DATABASE()").Scan(&cfg.DBName).Error; err != nil { + t.Fatal(err) + } + cfg.ParseTime = true + cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600) + cfg.ReadTimeout = time.Second + root := t.TempDir() + path := filepath.Join(root, "config.yaml") + if err := os.WriteFile(path, []byte("admin: {}\n"), 0600); err != nil { + t.Fatal(err) + } + admin := &config.Admin{ConfigPath: path, System: &config.System{DisableAutoMigrate: true}, Local: &config.Local{StorePath: filepath.Join(root, "uploads")}} + runtime := config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "mysql", Source: cfg.FormatDSN(), LogMode: "silent", Engine: "InnoDB", MaxOpenConns: 1}}, Admin: admin}) + defer runtime.Close() + store, err := storage.NewReloadable(admin) + if err != nil { + t.Fatal(err) + } + d, closeData, err := rootdata.NewData(runtime, slog.New(slog.NewTextHandler(io.Discard, nil)), store, modules.Catalog()) + if err != nil { + t.Fatal(err) + } + defer closeData() + if !d.DatabaseReady() { + t.Fatal("database was not ready after automatic startup") + } + assertComplete(t, db, password) + files, _ := filepath.Glob(filepath.Join(root, "migration-backups", "*.sql.gz")) + if len(files) != 2 { + t.Fatalf("automatic private backups not created: %v", files) + } +} + +func TestMySQLExistingMigratedInstallationPreservesTaskChoices(t *testing.T) { + db := localDatabase(t) + legacyFixture(t, db) + pool, _ := db.DB() + conn, err := pool.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + statements, err := assets.Statements(assets.FrameworkSQL()) + if err != nil { + t.Fatal(err) + } + for _, statement := range statements { + if strings.HasPrefix(strings.ToUpper(statement), "USE ") { + continue + } + if _, err := conn.ExecContext(context.Background(), statement); err != nil { + conn.Close() + t.Fatal(err) + } + } + conn.Close() + var oldSteps []migration.Step + for _, step := range modules.Catalog().MigrationSteps() { + if !strings.HasPrefix(step.ID, "202611060024_") && !strings.HasPrefix(step.ID, "202611060025_") { + oldSteps = append(oldSteps, step) + } + } + if err := migration.Run(db, oldSteps); err != nil { + t.Fatal(err) + } + if err := db.Exec("UPDATE sys_timed_tasks SET enabled=(id=1)").Error; err != nil { + t.Fatal(err) + } + var before, after []map[string]any + if err := db.Table("sys_timed_tasks").Select("id,name,method_name,spec,params,enabled").Order("id").Find(&before).Error; err != nil { + t.Fatal(err) + } + if err := swmigration.Run(context.Background(), db, modules.Catalog(), t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatal(err) + } + if err := db.Table("sys_timed_tasks").Select("id,name,method_name,spec,params,enabled").Order("id").Find(&after).Error; err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("automatic cleanup changed existing task configuration") + } + for _, name := range assets.CleanupTables() { + if db.Migrator().HasTable(name) { + t.Fatalf("cleanup omitted %s", name) + } + } +} diff --git a/internal/data/swmigration/state.go b/internal/data/swmigration/state.go new file mode 100644 index 0000000..3def936 --- /dev/null +++ b/internal/data/swmigration/state.go @@ -0,0 +1,38 @@ +package swmigration + +import ( + "gorm.io/gorm" + "kra/pkg/database/migration" + "time" +) + +const TableName = "sys_business_upgrade_state" +const workflowName = "legacy-sw" +const workflowVersion = "20261106_automatic_v1" + +type statePO struct { + Name string `gorm:"primaryKey;size:64"` + Version string `gorm:"size:64;not null"` + Phase string `gorm:"size:32;not null"` + DatabaseIdentity string `gorm:"size:128;not null"` + SourceWasLegacy bool + TasksRestored bool + BackupPath string `gorm:"type:text"` + BackupSHA256 string `gorm:"size:64"` + CleanupBackupPath string `gorm:"type:text"` + CleanupSHA256 string `gorm:"size:64"` + VerifiedMigrations string `gorm:"size:64"` + StartedAt time.Time + UpdatedAt time.Time + VerifiedAt *time.Time + CompletedAt *time.Time + LastError string `gorm:"type:text"` +} + +func (statePO) TableName() string { return TableName } + +func StateMigration() migration.Step { + return migration.Step{ID: "202611060025_automatic_upgrade_state", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &statePO{}) }} +} + +func CheckSchema(db *gorm.DB) error { return migration.CheckModels(db, &statePO{}) } diff --git a/internal/data/swmigration/testdata/legacy-schema.sql b/internal/data/swmigration/testdata/legacy-schema.sql new file mode 100644 index 0000000..62cfa0b --- /dev/null +++ b/internal/data/swmigration/testdata/legacy-schema.sql @@ -0,0 +1,1672 @@ +-- Sanitized legacy storage contract. Contains no production rows or credentials. +CREATE TABLE `gen_table` ( + `table_id` bigint(20) NOT NULL, + `data_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT '', + `table_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT '', + `table_comment` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '', + `class_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `tpl_category` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT 'crud', + `frontend_type` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT 'vue', + `package_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `module_name` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `business_name` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `function_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `function_author` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `gen_type` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `gen_path` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT '/', + `options` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`table_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `gen_table_column` ( + `column_id` bigint(20) NOT NULL, + `table_id` bigint(20) DEFAULT NULL, + `column_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `column_comment` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `column_type` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `java_type` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `java_field` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_pk` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_increment` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_required` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_insert` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_edit` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_list` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_query` char(1) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `query_type` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT 'EQ', + `html_type` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `dict_type` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT '', + `sort` int(11) DEFAULT NULL, + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`column_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sj_distributed_lock` ( + `name` varchar(64) NOT NULL, + `lock_until` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `locked_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `locked_by` varchar(255) NOT NULL, + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_group_config` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL DEFAULT '', + `description` varchar(256) NOT NULL DEFAULT '', + `token` varchar(64) NOT NULL DEFAULT 'fixture-secret', + `group_status` tinyint(4) NOT NULL DEFAULT '0', + `version` int(11) NOT NULL, + `group_partition` int(11) NOT NULL, + `id_generator_mode` tinyint(4) NOT NULL DEFAULT '1', + `init_scene` tinyint(4) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_namespace_id_group_name` (`namespace_id`,`group_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_job` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `biz_id` varchar(64) NOT NULL, + `group_name` varchar(64) NOT NULL, + `job_name` varchar(64) NOT NULL, + `args_str` text, + `args_type` tinyint(4) NOT NULL DEFAULT '1', + `next_trigger_at` bigint(13) NOT NULL, + `job_status` tinyint(4) NOT NULL DEFAULT '1', + `task_type` tinyint(4) NOT NULL DEFAULT '1', + `route_key` tinyint(4) NOT NULL DEFAULT '4', + `executor_type` tinyint(4) NOT NULL DEFAULT '1', + `executor_info` varchar(255) DEFAULT NULL, + `trigger_type` tinyint(4) NOT NULL, + `trigger_interval` varchar(255) NOT NULL, + `block_strategy` tinyint(4) NOT NULL DEFAULT '1', + `executor_timeout` int(11) NOT NULL DEFAULT '0', + `max_retry_times` int(11) NOT NULL DEFAULT '0', + `parallel_num` int(11) NOT NULL DEFAULT '1', + `retry_interval` int(11) NOT NULL DEFAULT '0', + `bucket_index` int(11) NOT NULL DEFAULT '0', + `resident` tinyint(4) NOT NULL DEFAULT '0', + `notify_ids` varchar(128) NOT NULL DEFAULT '', + `owner_id` bigint(20) DEFAULT NULL, + `labels` varchar(512) DEFAULT '', + `description` varchar(256) NOT NULL DEFAULT '', + `ext_attrs` varchar(256) DEFAULT '', + `deleted` tinyint(4) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sj_job_01` (`namespace_id`,`biz_id`), + KEY `idx_namespace_id_group_name` (`namespace_id`,`group_name`), + KEY `idx_job_status_bucket_index` (`job_status`,`bucket_index`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_job_executor` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `executor_info` varchar(256) NOT NULL, + `executor_type` varchar(3) NOT NULL, + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name` (`namespace_id`,`group_name`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_job_log_message` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `job_id` bigint(20) NOT NULL, + `task_batch_id` bigint(20) NOT NULL, + `task_id` bigint(20) NOT NULL, + `message` longtext NOT NULL, + `log_num` int(11) NOT NULL DEFAULT '1', + `real_time` bigint(13) NOT NULL DEFAULT '0', + `ext_attrs` varchar(256) DEFAULT '', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_task_batch_id_task_id` (`task_batch_id`,`task_id`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`,`group_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_job_summary` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL DEFAULT '', + `business_id` bigint(20) NOT NULL, + `system_task_type` tinyint(4) NOT NULL DEFAULT '3', + `trigger_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `success_num` int(11) NOT NULL DEFAULT '0', + `fail_num` int(11) NOT NULL DEFAULT '0', + `fail_reason` varchar(512) NOT NULL DEFAULT '', + `stop_num` int(11) NOT NULL DEFAULT '0', + `stop_reason` varchar(512) NOT NULL DEFAULT '', + `cancel_num` int(11) NOT NULL DEFAULT '0', + `cancel_reason` varchar(512) NOT NULL DEFAULT '', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_trigger_at_system_task_type_business_id` (`trigger_at`,`system_task_type`,`business_id`) USING BTREE, + KEY `idx_namespace_id_group_name_business_id` (`namespace_id`,`group_name`,`business_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_job_task` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `job_id` bigint(20) NOT NULL, + `task_batch_id` bigint(20) NOT NULL, + `parent_id` bigint(20) NOT NULL DEFAULT '0', + `task_status` tinyint(4) NOT NULL DEFAULT '0', + `retry_count` int(11) NOT NULL DEFAULT '0', + `mr_stage` tinyint(4) DEFAULT NULL, + `leaf` tinyint(4) NOT NULL DEFAULT '1', + `task_name` varchar(255) NOT NULL DEFAULT '', + `client_info` varchar(128) DEFAULT NULL, + `wf_context` text, + `result_message` text NOT NULL, + `args_str` text, + `args_type` tinyint(4) NOT NULL DEFAULT '1', + `ext_attrs` varchar(256) DEFAULT '', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_task_batch_id_task_status` (`task_batch_id`,`task_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`,`group_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_job_task_batch` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `job_id` bigint(20) NOT NULL, + `workflow_node_id` bigint(20) NOT NULL DEFAULT '0', + `parent_workflow_node_id` bigint(20) NOT NULL DEFAULT '0', + `workflow_task_batch_id` bigint(20) NOT NULL DEFAULT '0', + `task_batch_status` tinyint(4) NOT NULL DEFAULT '0', + `operation_reason` tinyint(4) NOT NULL DEFAULT '0', + `execution_at` bigint(13) NOT NULL DEFAULT '0', + `system_task_type` tinyint(4) NOT NULL DEFAULT '3', + `parent_id` varchar(64) NOT NULL DEFAULT '', + `ext_attrs` varchar(256) DEFAULT '', + `deleted` tinyint(4) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_job_id_task_batch_status` (`job_id`,`task_batch_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`,`group_name`), + KEY `idx_workflow_task_batch_id_workflow_node_id` (`workflow_task_batch_id`,`workflow_node_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_namespace` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(64) NOT NULL, + `unique_id` varchar(64) NOT NULL, + `description` varchar(256) NOT NULL DEFAULT '', + `deleted` tinyint(4) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_unique_id` (`unique_id`), + KEY `idx_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_notify_config` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `notify_name` varchar(64) NOT NULL DEFAULT '', + `system_task_type` tinyint(4) NOT NULL DEFAULT '3', + `notify_status` tinyint(4) NOT NULL DEFAULT '0', + `recipient_ids` varchar(128) NOT NULL, + `notify_threshold` int(11) NOT NULL DEFAULT '0', + `notify_scene` tinyint(4) NOT NULL DEFAULT '0', + `rate_limiter_status` tinyint(4) NOT NULL DEFAULT '0', + `rate_limiter_threshold` int(11) NOT NULL DEFAULT '0', + `description` varchar(256) NOT NULL DEFAULT '', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_scene_name` (`namespace_id`,`group_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_notify_recipient` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `recipient_name` varchar(64) NOT NULL, + `notify_type` tinyint(4) NOT NULL DEFAULT '0', + `notify_attribute` varchar(512) NOT NULL, + `description` varchar(256) NOT NULL DEFAULT '', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_namespace_id` (`namespace_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_retry` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `group_id` bigint(20) NOT NULL, + `scene_name` varchar(64) NOT NULL, + `scene_id` bigint(20) NOT NULL, + `idempotent_id` varchar(64) NOT NULL, + `biz_no` varchar(64) NOT NULL DEFAULT '', + `executor_name` varchar(512) NOT NULL DEFAULT '', + `args_str` text NOT NULL, + `ext_attrs` text NOT NULL, + `serializer_name` varchar(32) NOT NULL DEFAULT 'jackson', + `next_trigger_at` bigint(13) NOT NULL, + `retry_count` int(11) NOT NULL DEFAULT '0', + `retry_status` tinyint(4) NOT NULL DEFAULT '0', + `task_type` tinyint(4) NOT NULL DEFAULT '1', + `bucket_index` int(11) NOT NULL DEFAULT '0', + `parent_id` bigint(20) NOT NULL DEFAULT '0', + `deleted` bigint(20) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_scene_tasktype_idempotentid_deleted` (`scene_id`,`task_type`,`idempotent_id`,`deleted`), + KEY `idx_biz_no` (`biz_no`), + KEY `idx_idempotent_id` (`idempotent_id`), + KEY `idx_retry_status_bucket_index` (`retry_status`,`bucket_index`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_retry_dead_letter` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `group_id` bigint(20) NOT NULL, + `scene_name` varchar(64) NOT NULL, + `scene_id` bigint(20) NOT NULL, + `idempotent_id` varchar(64) NOT NULL, + `biz_no` varchar(64) NOT NULL DEFAULT '', + `executor_name` varchar(512) NOT NULL DEFAULT '', + `serializer_name` varchar(32) NOT NULL DEFAULT 'jackson', + `args_str` text NOT NULL, + `ext_attrs` text NOT NULL, + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_scene_name` (`namespace_id`,`group_name`,`scene_name`), + KEY `idx_idempotent_id` (`idempotent_id`), + KEY `idx_biz_no` (`biz_no`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_retry_scene_config` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `scene_name` varchar(64) NOT NULL, + `group_name` varchar(64) NOT NULL, + `scene_status` tinyint(4) NOT NULL DEFAULT '0', + `max_retry_count` int(11) NOT NULL DEFAULT '5', + `back_off` tinyint(4) NOT NULL DEFAULT '1', + `trigger_interval` varchar(16) NOT NULL DEFAULT '', + `notify_ids` varchar(128) NOT NULL DEFAULT '', + `deadline_request` bigint(20) unsigned NOT NULL DEFAULT '60000', + `executor_timeout` int(11) unsigned NOT NULL DEFAULT '5', + `route_key` tinyint(4) NOT NULL DEFAULT '4', + `block_strategy` tinyint(4) NOT NULL DEFAULT '1', + `cb_status` tinyint(4) NOT NULL DEFAULT '0', + `cb_trigger_type` tinyint(4) NOT NULL DEFAULT '1', + `cb_max_count` int(11) NOT NULL DEFAULT '16', + `cb_trigger_interval` varchar(16) NOT NULL DEFAULT '', + `owner_id` bigint(20) DEFAULT NULL, + `labels` varchar(512) DEFAULT '', + `description` varchar(256) NOT NULL DEFAULT '', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_namespace_id_group_name_scene_name` (`namespace_id`,`group_name`,`scene_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_retry_summary` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL DEFAULT '', + `scene_name` varchar(64) NOT NULL DEFAULT '', + `trigger_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `running_num` int(11) NOT NULL DEFAULT '0', + `finish_num` int(11) NOT NULL DEFAULT '0', + `max_count_num` int(11) NOT NULL DEFAULT '0', + `suspend_num` int(11) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_scene_name_trigger_at` (`namespace_id`,`group_name`,`scene_name`,`trigger_at`) USING BTREE, + KEY `idx_trigger_at` (`trigger_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_retry_task` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `scene_name` varchar(64) NOT NULL, + `retry_id` bigint(20) NOT NULL, + `ext_attrs` text NOT NULL, + `task_status` tinyint(4) NOT NULL DEFAULT '1', + `task_type` tinyint(4) NOT NULL DEFAULT '1', + `operation_reason` tinyint(4) NOT NULL DEFAULT '0', + `client_info` varchar(128) DEFAULT NULL, + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_group_name_scene_name` (`namespace_id`,`group_name`,`scene_name`), + KEY `task_status` (`task_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_retry_id` (`retry_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_retry_task_log_message` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `retry_id` bigint(20) NOT NULL, + `retry_task_id` bigint(20) NOT NULL, + `message` longtext NOT NULL, + `log_num` int(11) NOT NULL DEFAULT '1', + `real_time` bigint(13) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_namespace_id_group_name_retry_task_id` (`namespace_id`,`group_name`,`retry_task_id`), + KEY `idx_create_dt` (`create_dt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_server_node` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `host_id` varchar(64) NOT NULL, + `host_ip` varchar(64) NOT NULL, + `host_port` int(16) NOT NULL, + `expire_at` datetime NOT NULL, + `node_type` tinyint(4) NOT NULL, + `ext_attrs` varchar(256) DEFAULT '', + `labels` varchar(512) DEFAULT '', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_host_id_host_ip` (`host_id`,`host_ip`), + KEY `idx_namespace_id_group_name` (`namespace_id`,`group_name`), + KEY `idx_expire_at_node_type` (`expire_at`,`node_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_system_user` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `username` varchar(64) NOT NULL, + `password` varchar(128) NOT NULL, + `role` tinyint(4) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_username` (`username`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_system_user_permission` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `group_name` varchar(64) NOT NULL, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `system_user_id` bigint(20) NOT NULL, + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_namespace_id_group_name_system_user_id` (`namespace_id`,`group_name`,`system_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_workflow` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `workflow_name` varchar(64) NOT NULL, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `biz_id` varchar(64) NOT NULL, + `group_name` varchar(64) NOT NULL, + `workflow_status` tinyint(4) NOT NULL DEFAULT '1', + `trigger_type` tinyint(4) NOT NULL, + `trigger_interval` varchar(255) NOT NULL, + `next_trigger_at` bigint(20) NOT NULL, + `block_strategy` tinyint(4) NOT NULL DEFAULT '1', + `executor_timeout` int(11) NOT NULL DEFAULT '0', + `description` varchar(256) NOT NULL DEFAULT '', + `flow_info` text, + `wf_context` text, + `notify_ids` varchar(128) NOT NULL DEFAULT '', + `bucket_index` int(11) NOT NULL DEFAULT '0', + `version` int(11) NOT NULL, + `owner_id` bigint(20) DEFAULT NULL, + `ext_attrs` varchar(256) DEFAULT '', + `deleted` tinyint(4) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sj_workflow_01` (`namespace_id`,`biz_id`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`,`group_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_workflow_node` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `node_name` varchar(64) NOT NULL, + `group_name` varchar(64) NOT NULL, + `job_id` bigint(20) NOT NULL, + `workflow_id` bigint(20) NOT NULL, + `node_type` tinyint(4) NOT NULL DEFAULT '1', + `expression_type` tinyint(4) NOT NULL DEFAULT '0', + `fail_strategy` tinyint(4) NOT NULL DEFAULT '1', + `workflow_node_status` tinyint(4) NOT NULL DEFAULT '1', + `priority_level` int(11) NOT NULL DEFAULT '1', + `node_info` text, + `version` int(11) NOT NULL, + `ext_attrs` varchar(256) DEFAULT '', + `deleted` tinyint(4) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`,`group_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sj_workflow_task_batch` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `namespace_id` varchar(64) NOT NULL DEFAULT '764d604ec6fc45f68cd92514c40e9e1a', + `group_name` varchar(64) NOT NULL, + `workflow_id` bigint(20) NOT NULL, + `task_batch_status` tinyint(4) NOT NULL DEFAULT '0', + `operation_reason` tinyint(4) NOT NULL DEFAULT '0', + `flow_info` text, + `wf_context` text, + `execution_at` bigint(13) NOT NULL DEFAULT '0', + `ext_attrs` varchar(256) DEFAULT '', + `version` int(11) NOT NULL DEFAULT '1', + `deleted` tinyint(4) NOT NULL DEFAULT '0', + `create_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_dt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_job_id_task_batch_status` (`workflow_id`,`task_batch_status`), + KEY `idx_create_dt` (`create_dt`), + KEY `idx_namespace_id_group_name` (`namespace_id`,`group_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sys_alert_dedup_bak_20261020_105142_128391_5084329` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `platform_code` varchar(50) DEFAULT NULL, + `platform_name` varchar(50) NOT NULL, + `rule_id` bigint(20) NOT NULL, + `rule_name` varchar(100) NOT NULL, + `rule_type` varchar(50) DEFAULT NULL, + `monitor_metric` tinyint(4) DEFAULT NULL, + `target_promote_id` bigint(20) NOT NULL, + `target_promote_name` varchar(100) NOT NULL, + `business_id` bigint(20) NOT NULL, + `business_name` varchar(100) NOT NULL, + `alert_content` text NOT NULL, + `status` tinyint(4) NOT NULL DEFAULT '1', + `alert_date` date NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_alert_record_daily` (`platform_code`,`target_promote_id`,`rule_id`,`alert_date`) USING BTREE, + KEY `idx_business_id` (`business_id`) USING BTREE, + KEY `idx_platform_id` (`platform_code`) USING BTREE, + KEY `idx_status` (`status`) USING BTREE, + KEY `idx_created_at` (`created_at`) USING BTREE, + KEY `idx_rule_promote_created` (`rule_id`,`target_promote_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_alert_dedup_bak_20261020_111958_265207_5085137` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `platform_code` varchar(50) DEFAULT NULL, + `platform_name` varchar(50) NOT NULL, + `rule_id` bigint(20) NOT NULL, + `rule_name` varchar(100) NOT NULL, + `rule_type` varchar(50) DEFAULT NULL, + `monitor_metric` tinyint(4) DEFAULT NULL, + `target_promote_id` bigint(20) NOT NULL, + `target_promote_name` varchar(100) NOT NULL, + `business_id` bigint(20) NOT NULL, + `business_name` varchar(100) NOT NULL, + `alert_content` text NOT NULL, + `status` tinyint(4) NOT NULL DEFAULT '1', + `alert_date` date NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_alert_record_daily` (`platform_code`,`target_promote_id`,`rule_id`,`alert_date`) USING BTREE, + KEY `idx_business_id` (`business_id`) USING BTREE, + KEY `idx_platform_id` (`platform_code`) USING BTREE, + KEY `idx_status` (`status`) USING BTREE, + KEY `idx_created_at` (`created_at`) USING BTREE, + KEY `idx_rule_promote_created` (`rule_id`,`target_promote_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_alert_operation_log` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `alert_id` bigint(20) unsigned NOT NULL, + `operator_id` bigint(20) NOT NULL, + `operator_name` varchar(50) NOT NULL, + `log_type` tinyint(4) NOT NULL, + `action_detail` varchar(100) DEFAULT NULL, + `note_content` text, + `attachment_ids` json DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_alert_id` (`alert_id`) USING BTREE, + KEY `idx_created_at` (`created_at`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_alert_record` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `platform_code` varchar(50) DEFAULT NULL, + `platform_name` varchar(50) NOT NULL, + `rule_id` bigint(20) NOT NULL, + `rule_name` varchar(100) NOT NULL, + `rule_type` varchar(50) DEFAULT NULL, + `monitor_metric` tinyint(4) DEFAULT NULL, + `target_promote_id` bigint(20) NOT NULL, + `target_promote_name` varchar(100) NOT NULL, + `business_id` bigint(20) NOT NULL, + `business_name` varchar(100) NOT NULL, + `alert_content` text NOT NULL, + `status` tinyint(4) NOT NULL DEFAULT '1', + `alert_date` date NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `unclosed_flag` tinyint(4) GENERATED ALWAYS AS ((case when (`status` = 5) then NULL else 1 end)) STORED, + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_alert_record_unclosed` (`platform_code`,`target_promote_id`,`rule_type`,`unclosed_flag`), + KEY `idx_business_id` (`business_id`) USING BTREE, + KEY `idx_platform_id` (`platform_code`) USING BTREE, + KEY `idx_status` (`status`) USING BTREE, + KEY `idx_created_at` (`created_at`) USING BTREE, + KEY `idx_rule_promote_created` (`rule_id`,`target_promote_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_alert_rule` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `rule_name` varchar(100) NOT NULL, + `rule_type` varchar(50) NOT NULL, + `monitor_metric` tinyint(4) NOT NULL, + `stat_period_days` int(11) DEFAULT NULL, + `mom_direction` tinyint(4) DEFAULT NULL, + `mom_threshold` decimal(5,2) DEFAULT NULL, + `unlogin_days` int(11) DEFAULT NULL, + `monitor_scope` tinyint(4) NOT NULL DEFAULT '1', + `warning_color` varchar(20) NOT NULL, + `creator_id` bigint(20) NOT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT '1', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_creator_id` (`creator_id`) USING BTREE, + KEY `idx_rule_type` (`rule_type`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_client` ( + `id` bigint(20) NOT NULL, + `client_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `client_key` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `client_secret` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `grant_type` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `device_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `access_path` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `ip_whitelist` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `active_timeout` int(11) DEFAULT '1800', + `timeout` int(11) DEFAULT '604800', + `status` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `del_flag` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_config` ( + `config_id` bigint(20) NOT NULL, + `config_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `config_key` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `config_value` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '', + `config_type` char(1) COLLATE utf8mb4_unicode_ci DEFAULT 'N', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`config_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_dept` ( + `dept_id` bigint(20) NOT NULL, + `parent_id` bigint(20) DEFAULT '0', + `ancestors` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '', + `dept_name` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT '', + `dept_category` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `order_num` int(4) DEFAULT '0', + `leader` bigint(20) DEFAULT NULL, + `phone` varchar(11) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `del_flag` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`dept_id`), + KEY `idx_sys_dept_parent_id` (`parent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_dict_data` ( + `dict_code` bigint(20) NOT NULL, + `dict_sort` int(4) DEFAULT '0', + `dict_label` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `dict_value` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `dict_type` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `css_class` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `list_class` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_default` char(1) COLLATE utf8mb4_unicode_ci DEFAULT 'N', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`dict_code`), + KEY `idx_sys_dict_data_type` (`dict_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_dict_type` ( + `dict_id` bigint(20) NOT NULL, + `dict_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `dict_type` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`dict_id`), + UNIQUE KEY `dict_type` (`dict_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_login_info` ( + `info_id` bigint(20) NOT NULL, + `user_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '', + `client_key` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT '', + `device_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT '', + `ipaddr` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT '', + `login_location` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `browser` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '', + `os` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '', + `status` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `msg` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `login_time` datetime DEFAULT NULL, + PRIMARY KEY (`info_id`), + KEY `idx_sys_login_info_s` (`status`), + KEY `idx_sys_login_info_lt` (`login_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_menu` ( + `menu_id` bigint(20) NOT NULL, + `menu_name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `parent_id` bigint(20) DEFAULT '0', + `order_num` int(4) DEFAULT '0', + `path` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT '', + `component` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `query_param` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `is_frame` char(1) COLLATE utf8mb4_unicode_ci DEFAULT 'N', + `is_cache` char(1) COLLATE utf8mb4_unicode_ci DEFAULT 'Y', + `menu_type` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '', + `visible` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `status` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `perms` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `icon` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '#', + `active_menu` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `ext` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT '', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '', + PRIMARY KEY (`menu_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_message` ( + `message_id` bigint(20) NOT NULL, + `category` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `source` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, + `title` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `message` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '', + `content` longtext COLLATE utf8mb4_unicode_ci, + `data_json` longtext COLLATE utf8mb4_unicode_ci, + `path` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `send_user_ids` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '0', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`message_id`), + KEY `idx_sys_message_category_time` (`category`,`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_notice` ( + `notice_id` bigint(20) NOT NULL, + `notice_title` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `notice_type` char(1) COLLATE utf8mb4_unicode_ci NOT NULL, + `notice_content` longblob, + `status` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`notice_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_oper_log` ( + `oper_id` bigint(20) NOT NULL, + `title` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '', + `business_type` int(2) DEFAULT '0', + `method` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `request_method` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT '', + `operator_type` int(1) DEFAULT '0', + `oper_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '', + `user_id` bigint(20) DEFAULT NULL, + `dept_id` bigint(20) DEFAULT NULL, + `dept_name` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '', + `client_key` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT '', + `device_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT '', + `browser` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '', + `os` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '', + `oper_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `oper_ip` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT '', + `oper_location` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `oper_param` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT '', + `json_result` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT '', + `status` int(1) DEFAULT '0', + `error_msg` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT '', + `oper_time` datetime DEFAULT NULL, + `cost_time` bigint(20) DEFAULT '0', + PRIMARY KEY (`oper_id`), + KEY `idx_sys_oper_log_bt` (`business_type`), + KEY `idx_sys_oper_log_uid` (`user_id`), + KEY `idx_sys_oper_log_s` (`status`), + KEY `idx_sys_oper_log_ot` (`oper_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_oss` ( + `oss_id` bigint(20) NOT NULL, + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `original_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `file_suffix` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL, + `ext1` text COLLATE utf8mb4_unicode_ci, + `create_dept` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `service` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'minio', + PRIMARY KEY (`oss_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_oss_config` ( + `oss_config_id` bigint(20) NOT NULL, + `config_key` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `access_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `secret_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `bucket_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `prefix` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `endpoint` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `domain_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `is_https` char(1) COLLATE utf8mb4_unicode_ci DEFAULT 'N', + `region` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `access_policy` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '1', + `status` char(1) COLLATE utf8mb4_unicode_ci DEFAULT 'N', + `ext1` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`oss_config_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_post` ( + `post_id` bigint(20) NOT NULL, + `dept_id` bigint(20) NOT NULL, + `post_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `post_category` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `post_name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL, + `post_sort` int(4) NOT NULL, + `status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL, + `del_flag` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`post_id`), + KEY `idx_sys_post_dept_id` (`dept_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_role` ( + `role_id` bigint(20) NOT NULL, + `role_name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL, + `role_key` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `role_sort` int(4) NOT NULL, + `data_scope` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '1', + `menu_check_strictly` tinyint(1) DEFAULT '1', + `dept_check_strictly` tinyint(1) DEFAULT '1', + `status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL, + `del_flag` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`role_id`), + KEY `idx_sys_role_create_dept` (`create_dept`), + KEY `idx_sys_role_create_by` (`create_by`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_role_dept` ( + `role_id` bigint(20) NOT NULL, + `dept_id` bigint(20) NOT NULL, + PRIMARY KEY (`role_id`,`dept_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_role_menu` ( + `role_id` bigint(20) NOT NULL, + `menu_id` bigint(20) NOT NULL, + PRIMARY KEY (`role_id`,`menu_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_social` ( + `id` bigint(20) NOT NULL, + `user_id` bigint(20) NOT NULL, + `auth_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `source` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `open_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `user_name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL, + `nick_name` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT '', + `email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + `avatar` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '', + `access_token` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL, + `expire_in` int(11) DEFAULT NULL, + `refresh_token` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `access_code` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `union_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `scope` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `token_type` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `id_token` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `mac_algorithm` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `mac_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `code` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `oauth_token` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `oauth_token_secret` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `del_flag` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_user` ( + `user_id` bigint(20) NOT NULL, + `dept_id` bigint(20) DEFAULT NULL, + `user_name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL, + `nick_name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL, + `user_type` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT 'sys_user', + `email` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT '', + `phone_number` varchar(11) COLLATE utf8mb4_unicode_ci DEFAULT '', + `gender` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `avatar` bigint(20) DEFAULT NULL, + `password` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '', + `status` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `del_flag` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '0', + `login_ip` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT '', + `login_date` datetime DEFAULT NULL, + `create_dept` bigint(20) DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `level` tinyint(2) unsigned NOT NULL DEFAULT '1', + `parent_id` bigint(20) DEFAULT '0', + `id_path` text CHARACTER SET utf8mb4, + `real_name` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `qq_number` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT '', + `reject_reason` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`user_id`), + KEY `idx_sys_user_dept_id` (`dept_id`), + KEY `idx_sys_user_create_by` (`create_by`), + KEY `idx_sys_user_user_name` (`user_name`), + KEY `idx_sys_user_phone` (`phone_number`), + KEY `idx_id_path_level` (`id_path`(100),`level`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_user_bind_platform` ( + `user_id` bigint(20) NOT NULL, + `source_db` varchar(10) CHARACTER SET utf8 NOT NULL DEFAULT '', + `platform_busier_id` bigint(20) NOT NULL, + `platform_busier_account` varchar(30) CHARACTER SET utf8 NOT NULL DEFAULT '', + `status` char(1) COLLATE utf8mb4_unicode_ci DEFAULT '3', + `reject_reason` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `review_time` datetime DEFAULT NULL, + `review_account` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`user_id`,`source_db`,`platform_busier_id`) USING BTREE, + KEY `idx_user_status` (`user_id`,`status`), + KEY `idx_platform_busier_status` (`platform_busier_id`,`status`,`user_id`), + KEY `idx_user_status_platform` (`user_id`,`status`,`platform_busier_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +CREATE TABLE `sys_user_post` ( + `user_id` bigint(20) NOT NULL, + `post_id` bigint(20) NOT NULL, + PRIMARY KEY (`user_id`,`post_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `sys_user_preference` ( + `user_id` bigint(20) NOT NULL, + `include_sub_promote` tinyint(1) NOT NULL DEFAULT '1', + `idcard_dedup` tinyint(4) NOT NULL DEFAULT '1', + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `sys_user_role` ( + `user_id` bigint(20) NOT NULL, + `role_id` bigint(20) NOT NULL, + PRIMARY KEY (`user_id`,`role_id`), + KEY `idx_sys_user_role_rid` (`role_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `tab_datareport_every_pid` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `time` varchar(20) NOT NULL DEFAULT '', + `promote_id` int(11) NOT NULL DEFAULT '0', + `game_id` int(11) NOT NULL DEFAULT '0', + `new_register_user` text NOT NULL, + `new_register_device` text NOT NULL, + `new_register_device_user` text NOT NULL, + `active_user` text NOT NULL, + `active_user7` text NOT NULL, + `active_user30` text NOT NULL, + `pay_user` text NOT NULL, + `total_order` text NOT NULL, + `total_pay` float(10,2) NOT NULL DEFAULT '0.00', + `new_pay_user` text NOT NULL, + `new_total_order` text NOT NULL, + `new_total_pay` float(10,2) NOT NULL DEFAULT '0.00', + `fire_device` text NOT NULL, + `active_device` text NOT NULL, + `create_time` int(11) NOT NULL DEFAULT '0', + `update_time` int(11) NOT NULL DEFAULT '0', + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`) USING BTREE, + KEY `time` (`time`) USING BTREE, + KEY `p+t` (`promote_id`,`time`) USING BTREE, + KEY `promote_id` (`time`,`promote_id`,`game_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `tab_datareport_top_pid` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `time` varchar(20) NOT NULL DEFAULT '', + `promote_id` int(11) NOT NULL DEFAULT '0', + `promote_level` tinyint(1) NOT NULL DEFAULT '1', + `game_id` int(11) NOT NULL DEFAULT '0', + `new_register_user` text NOT NULL, + `new_register_device` text NOT NULL, + `new_register_device_user` text NOT NULL, + `active_user` text NOT NULL, + `active_user7` text NOT NULL, + `active_user30` text NOT NULL, + `pay_user` text NOT NULL, + `total_order` text NOT NULL, + `total_pay` float(10,2) NOT NULL DEFAULT '0.00', + `new_pay_user` text NOT NULL, + `new_total_order` text NOT NULL, + `new_total_pay` float(10,2) NOT NULL DEFAULT '0.00', + `fire_device` text NOT NULL, + `active_device` text NOT NULL, + `create_time` int(11) NOT NULL DEFAULT '0', + `update_time` int(11) NOT NULL DEFAULT '0', + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`) USING BTREE, + KEY `time` (`time`) USING BTREE, + KEY `p+t` (`promote_id`,`time`) USING BTREE, + KEY `promote_id` (`time`,`promote_id`,`game_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `tab_game` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `game_name` varchar(30) NOT NULL DEFAULT '', + `sort` int(10) unsigned NOT NULL DEFAULT '0', + `short` varchar(20) NOT NULL DEFAULT '', + `game_type_id` varchar(50) NOT NULL DEFAULT '0', + `game_type_name` varchar(50) NOT NULL DEFAULT '', + `game_score` double(4,1) NOT NULL DEFAULT '0.0', + `tag_name` varchar(100) NOT NULL DEFAULT '', + `features` varchar(100) NOT NULL DEFAULT '', + `introduction` varchar(1100) NOT NULL DEFAULT '', + `recommend_level` double(3,0) NOT NULL DEFAULT '0', + `apply_status` int(11) NOT NULL DEFAULT '1', + `icon` varchar(100) NOT NULL DEFAULT '', + `cover` varchar(100) NOT NULL DEFAULT '', + `hot_cover` varchar(100) NOT NULL DEFAULT '', + `screenshot` varchar(1000) NOT NULL DEFAULT '', + `groom` varchar(100) NOT NULL DEFAULT '', + `and_dow_address` varchar(100) NOT NULL DEFAULT '', + `ios_dow_plist` varchar(100) NOT NULL DEFAULT '', + `ios_dow_address` varchar(100) NOT NULL DEFAULT '', + `add_game_address` varchar(255) NOT NULL DEFAULT '', + `ios_game_address` varchar(255) NOT NULL DEFAULT '', + `dow_num` int(10) NOT NULL DEFAULT '0', + `recommend_status` varchar(10) NOT NULL DEFAULT '1', + `pay_status` tinyint(2) NOT NULL DEFAULT '1', + `dow_status` tinyint(2) NOT NULL DEFAULT '1', + `create_time` int(11) NOT NULL DEFAULT '0', + `marking` varchar(50) NOT NULL DEFAULT '', + `language` varchar(10) NOT NULL DEFAULT '', + `ratio` decimal(10,2) NOT NULL DEFAULT '0.00', + `money` decimal(10,2) NOT NULL DEFAULT '0.00', + `game_appid` varchar(32) NOT NULL DEFAULT '', + `game_coin_name` varchar(10) NOT NULL DEFAULT '', + `game_coin_ration` varchar(10) NOT NULL DEFAULT '', + `category` tinyint(2) NOT NULL DEFAULT '0', + `sdk_version` tinyint(2) NOT NULL DEFAULT '-1', + `game_status` tinyint(3) unsigned NOT NULL DEFAULT '1', + `relation_game_id` int(11) unsigned NOT NULL DEFAULT '0', + `relation_game_name` varchar(30) NOT NULL DEFAULT '', + `game_size` varchar(10) NOT NULL DEFAULT '0MB', + `material_url` varchar(255) NOT NULL DEFAULT '', + `appstatus` tinyint(2) NOT NULL DEFAULT '0', + `accredit_img` varchar(100) NOT NULL DEFAULT '0', + `back_describe` varchar(50) NOT NULL DEFAULT '', + `dow_icon` varchar(100) NOT NULL DEFAULT '', + `back_map` varchar(100) NOT NULL DEFAULT '0', + `game_address_size` varchar(10) NOT NULL DEFAULT '0', + `down_port` tinyint(1) NOT NULL DEFAULT '1', + `ccustom_service_qq` varchar(11) NOT NULL DEFAULT '', + `is_force_update` tinyint(1) NOT NULL DEFAULT '0', + `display_site` varchar(20) NOT NULL DEFAULT '1,2,3', + `dev_name` varchar(30) NOT NULL DEFAULT '', + `promote_ids` text, + `third_party_url` varchar(800) NOT NULL DEFAULT '', + `screen_type` tinyint(1) NOT NULL DEFAULT '0', + `weiduan_pay_status` tinyint(1) NOT NULL DEFAULT '0', + `fullscreen` tinyint(1) NOT NULL DEFAULT '0', + `is_https` tinyint(1) NOT NULL DEFAULT '0', + `video_cover` varchar(100) NOT NULL DEFAULT '', + `video` text NOT NULL, + `video_url` text NOT NULL, + `load_cover` varchar(100) NOT NULL DEFAULT '', + `is_interflow` tinyint(1) NOT NULL DEFAULT '0', + `is_force_real` tinyint(1) NOT NULL DEFAULT '0', + `first_support_num` int(10) NOT NULL DEFAULT '0', + `following_support_rate` int(10) NOT NULL DEFAULT '0', + `interface_id` int(6) NOT NULL DEFAULT '0', + `cp_game_id` varchar(30) NOT NULL DEFAULT '', + `currency_name` varchar(10) NOT NULL DEFAULT '', + `currency_ratio` int(5) NOT NULL DEFAULT '0', + `cp_id` int(11) unsigned NOT NULL DEFAULT '0', + `vip_table_pic` varchar(100) NOT NULL DEFAULT '', + `sdk_verify` tinyint(1) NOT NULL DEFAULT '0', + `super_version` int(10) NOT NULL DEFAULT '0', + `super_remark` varchar(1000) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `update_time` int(10) NOT NULL DEFAULT '0', + `super_version_name` varchar(255) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `sdk_scheme` varchar(50) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `age_type` tinyint(11) NOT NULL DEFAULT '1', + `age_appid` varchar(255) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `age_bizid` varchar(255) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `age_key` varchar(255) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `age_num` tinyint(4) unsigned DEFAULT '0', + `age_tip` varchar(255) CHARACTER SET utf8mb4 DEFAULT '', + `apple_in_app_set` longtext CHARACTER SET utf8mb4 NOT NULL, + `sdk_area` tinyint(2) NOT NULL DEFAULT '0', + `test_game_status` tinyint(3) unsigned NOT NULL DEFAULT '0', + `simulator_official_status` tinyint(2) NOT NULL DEFAULT '0', + `simulator_channel_status` tinyint(2) NOT NULL DEFAULT '0', + `promote_ids2` text NOT NULL, + `only_for_promote` tinyint(3) unsigned NOT NULL DEFAULT '0', + `yidun_business_id` varchar(100) NOT NULL DEFAULT '', + `platform_id` int(11) NOT NULL DEFAULT '0', + `pc_sdk_game_switch` tinyint(3) unsigned NOT NULL DEFAULT '0', + `pc_sdk_game_config` text NOT NULL, + `pc_repackage_temp_id` bigint(20) NOT NULL DEFAULT '0', + `pc_repackage_game_name` varchar(100) NOT NULL DEFAULT '', + `game_promote_link` varchar(255) NOT NULL DEFAULT '', + `is_open_gm_module_entrance` tinyint(3) unsigned NOT NULL DEFAULT '0', + `opus_ratio` decimal(10,2) unsigned NOT NULL DEFAULT '0.00', + `is_yh` tinyint(3) unsigned NOT NULL DEFAULT '0', + `support_beishu` decimal(10,2) unsigned NOT NULL DEFAULT '0.00', + `support_server` tinyint(3) unsigned NOT NULL DEFAULT '0', + `hzb_pay_status` tinyint(3) unsigned NOT NULL DEFAULT '0', + `auto_check_support` tinyint(1) unsigned NOT NULL DEFAULT '0', + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`) USING BTREE, + KEY `game_name` (`game_name`) USING BTREE, + KEY `game_appid` (`game_appid`) USING BTREE, + KEY `recommend_status` (`recommend_status`) USING BTREE, + KEY `relation_game_id` (`relation_game_id`) USING BTREE, + KEY `game_type_id` (`game_type_id`) USING BTREE, + KEY `sdk_version` (`sdk_version`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `tab_game_server` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `game_id` int(11) NOT NULL DEFAULT '0', + `server_name` varchar(30) NOT NULL DEFAULT '', + `server_num` varchar(30) NOT NULL DEFAULT '0', + `status` tinyint(2) NOT NULL DEFAULT '1', + `start_time` int(11) NOT NULL DEFAULT '0', + `desride` varchar(300) NOT NULL DEFAULT '', + `create_time` int(11) NOT NULL DEFAULT '0', + `sdk_version` tinyint(2) NOT NULL DEFAULT '1', + `third_server_id` int(11) NOT NULL DEFAULT '0', + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`) USING BTREE, + KEY `game_id` (`game_id`) USING BTREE, + KEY `show_status` (`status`) USING BTREE, + KEY `start_time` (`start_time`) USING BTREE, + KEY `create_time` (`create_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `tab_promote` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `account` varchar(30) NOT NULL DEFAULT '', + `password` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '', + `is_edit_password` tinyint(1) NOT NULL DEFAULT '0', + `second_pwd` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '', + `id_card_no` varchar(18) NOT NULL DEFAULT '', + `company_name` varchar(100) NOT NULL DEFAULT '', + `company_id_no` varchar(100) NOT NULL DEFAULT '', + `nickname` varchar(30) CHARACTER SET utf8 NOT NULL DEFAULT '', + `mobile_phone` varchar(11) CHARACTER SET utf8 NOT NULL DEFAULT '', + `email` varchar(50) CHARACTER SET utf8 NOT NULL DEFAULT '', + `real_name` varchar(30) CHARACTER SET utf8 NOT NULL DEFAULT '', + `register_type` tinyint(2) NOT NULL DEFAULT '0', + `pattern` tinyint(2) NOT NULL DEFAULT '0', + `status` int(11) NOT NULL DEFAULT '1', + `lock_status` tinyint(4) NOT NULL DEFAULT '0', + `settment_type` varchar(10) NOT NULL DEFAULT '0', + `bank_name` varchar(50) CHARACTER SET utf8 NOT NULL DEFAULT '', + `bank_card` varchar(50) CHARACTER SET utf8 NOT NULL DEFAULT '', + `bank_account` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '', + `bank_phone` varchar(11) CHARACTER SET utf8 NOT NULL DEFAULT '', + `bank_area` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '', + `account_openin` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '', + `bank_name_company` varchar(50) CHARACTER SET utf8 NOT NULL DEFAULT '', + `bank_card_company` varchar(50) CHARACTER SET utf8 NOT NULL DEFAULT '', + `bank_account_company` varchar(100) CHARACTER SET utf8 NOT NULL DEFAULT '', + `alipay_account` varchar(30) CHARACTER SET utf8 NOT NULL DEFAULT '', + `alipay_name` varchar(30) CHARACTER SET utf8 NOT NULL DEFAULT '', + `money` decimal(10,2) NOT NULL DEFAULT '0.00', + `balance_coin` decimal(10,2) NOT NULL DEFAULT '0.00', + `balance_profit` decimal(10,2) NOT NULL DEFAULT '0.00', + `balance_opus` decimal(10,2) NOT NULL DEFAULT '0.00', + `advance_profit` decimal(10,2) NOT NULL DEFAULT '0.00', + `promote_type` int(2) NOT NULL DEFAULT '1', + `parent_id` int(11) NOT NULL DEFAULT '0', + `parent_name` varchar(30) CHARACTER SET utf8 NOT NULL DEFAULT '', + `promote_level` tinyint(2) NOT NULL DEFAULT '1', + `top_promote_id` int(11) NOT NULL DEFAULT '0', + `last_login_time` int(11) NOT NULL DEFAULT '0', + `create_time` int(11) NOT NULL DEFAULT '0', + `admin_id` int(11) NOT NULL DEFAULT '0', + `mark1` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '', + `mark2` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '', + `busier_id` int(10) NOT NULL DEFAULT '0', + `game_ids` varchar(800) CHARACTER SET utf8 NOT NULL DEFAULT '', + `bind_user_id` int(10) NOT NULL DEFAULT '0', + `bind_openid` varchar(100) NOT NULL DEFAULT '', + `bt_welfare_register_auto` tinyint(3) unsigned NOT NULL DEFAULT '0', + `bt_welfare_recharge_auto` tinyint(3) unsigned NOT NULL DEFAULT '0', + `bt_welfare_total_auto` tinyint(3) unsigned NOT NULL DEFAULT '0', + `bt_welfare_month_auto` tinyint(3) unsigned NOT NULL DEFAULT '0', + `bt_welfare_week_auto` tinyint(3) unsigned NOT NULL DEFAULT '0', + `bt_welfare_clock_auto` tinyint(3) unsigned NOT NULL DEFAULT '0', + `bt_welfare_first_recharge_auto` tinyint(3) NOT NULL DEFAULT '0', + `bt_welfare_daily_total_recharge_auto` tinyint(3) NOT NULL DEFAULT '0', + `bt_welfare_transfer_game_auto` tinyint(3) NOT NULL DEFAULT '0', + `bt_welfare_role_level_auto` tinyint(3) NOT NULL DEFAULT '0', + `zfb` varchar(1000) NOT NULL DEFAULT '', + `wxscan` varchar(1000) NOT NULL DEFAULT '', + `wxapp` varchar(500) NOT NULL DEFAULT '', + `is_custom_pay` tinyint(3) NOT NULL DEFAULT '0', + `prepayment` decimal(10,2) NOT NULL DEFAULT '0.00', + `allow_check_subbox` tinyint(3) unsigned NOT NULL DEFAULT '0', + `update_time` int(10) NOT NULL DEFAULT '0', + `oa_studio_id` int(10) unsigned NOT NULL DEFAULT '0', + `game_ids2` text CHARACTER SET utf8 NOT NULL, + `latest_session_id` varchar(255) CHARACTER SET utf8 NOT NULL DEFAULT '', + `settlement_day_period` int(8) unsigned NOT NULL DEFAULT '7', + `sms_notice_switch` tinyint(2) NOT NULL DEFAULT '0', + `allow_simulator` tinyint(2) NOT NULL DEFAULT '0', + `cash_money` decimal(10,2) DEFAULT '0.00', + `other_identity` varchar(200) CHARACTER SET utf8 DEFAULT '', + `dy_identifier` varchar(200) CHARACTER SET utf8 DEFAULT '', + `agreement_auth` varchar(255) DEFAULT NULL, + `remarks` varchar(500) DEFAULT NULL, + `is_invitation` tinyint(2) NOT NULL DEFAULT '0', + `id_card_front` varchar(255) NOT NULL DEFAULT '', + `id_card_back` varchar(255) NOT NULL DEFAULT '', + `business_img` varchar(255) NOT NULL DEFAULT '', + `is_risk` tinyint(1) NOT NULL DEFAULT '0', + `ban_wxpay` tinyint(1) NOT NULL DEFAULT '0', + `ban_alipay` tinyint(1) NOT NULL DEFAULT '0', + `work_video` varchar(200) NOT NULL DEFAULT '', + `address` varchar(200) NOT NULL DEFAULT '', + `sign_type` tinyint(4) NOT NULL DEFAULT '0', + `trust_book` varchar(200) NOT NULL DEFAULT '', + `trust_name` varchar(100) NOT NULL DEFAULT '', + `trust_id_card` varchar(18) NOT NULL DEFAULT '', + `trust_job` varchar(30) NOT NULL DEFAULT '', + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`) USING BTREE, + KEY `account` (`account`) USING BTREE, + KEY `parent_id` (`parent_id`) USING BTREE, + KEY `busier_id` (`busier_id`) USING BTREE, + KEY `top_promote_id` (`top_promote_id`) USING BTREE, + KEY `idx_busier_id` (`busier_id`), + KEY `idx_busier_status` (`busier_id`,`status`,`id`), + KEY `idx_promote_source_busier` (`source_db`,`busier_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `tab_promote_apply` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `game_id` int(11) NOT NULL DEFAULT '0', + `promote_id` int(11) NOT NULL DEFAULT '0', + `apply_time` int(11) NOT NULL DEFAULT '0', + `status` tinyint(2) NOT NULL DEFAULT '0', + `enable_status` tinyint(2) NOT NULL DEFAULT '0', + `pack_url` varchar(500) NOT NULL DEFAULT '', + `plist_url` varchar(500) NOT NULL DEFAULT '', + `dow_url` varchar(500) NOT NULL DEFAULT '', + `dispose_id` int(11) NOT NULL DEFAULT '1', + `dispose_time` int(11) NOT NULL DEFAULT '0', + `sdk_version` tinyint(2) NOT NULL DEFAULT '1', + `promote_ratio` double(5,2) NOT NULL DEFAULT '0.00', + `promote_money` double(5,2) NOT NULL DEFAULT '0.00', + `pack_type` tinyint(1) NOT NULL DEFAULT '0', + `pack_mark` varchar(100) NOT NULL DEFAULT '', + `is_upload` tinyint(1) NOT NULL DEFAULT '0', + `pack_id` int(11) NOT NULL DEFAULT '1', + `pack_time` int(11) NOT NULL DEFAULT '0', + `and_url` varchar(100) NOT NULL DEFAULT '', + `ios_url` varchar(100) NOT NULL DEFAULT '', + `and_status` tinyint(1) NOT NULL DEFAULT '0', + `ios_status` tinyint(1) NOT NULL DEFAULT '0', + `and_upload` tinyint(1) NOT NULL DEFAULT '0', + `ios_upload` tinyint(1) NOT NULL DEFAULT '0', + `cps_alone_show` tinyint(3) unsigned NOT NULL DEFAULT '0', + `add_cps_alone_time` int(11) unsigned NOT NULL DEFAULT '0', + `cpa_alone_show` tinyint(3) unsigned NOT NULL DEFAULT '0', + `add_cpa_alone_time` int(11) unsigned NOT NULL DEFAULT '0', + `is_h5_share_show` tinyint(3) unsigned NOT NULL DEFAULT '0', + `top_is_h5_share_show` tinyint(3) unsigned NOT NULL DEFAULT '0', + `update_time` int(10) NOT NULL DEFAULT '0', + `ksdy_set` text NOT NULL, + `game_is_open` tinyint(2) NOT NULL DEFAULT '1', + `user_limit_discount` decimal(10,2) NOT NULL DEFAULT '10.00', + `magic_power_show` tinyint(3) unsigned NOT NULL DEFAULT '0', + `magic_power_link` varchar(255) NOT NULL, + `promote_opus_ratio` double(5,2) NOT NULL DEFAULT '0.00', + `promote_invite_ratio` double(5,2) NOT NULL DEFAULT '0.00', + `pc_enable_status` tinyint(2) NOT NULL DEFAULT '0', + `output_pc_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`) USING BTREE, + KEY `promote_id` (`promote_id`) USING BTREE, + KEY `apply_search` (`promote_id`,`game_id`) USING BTREE, + KEY `game_id` (`game_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `tab_spend` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL DEFAULT '0', + `user_account` varchar(200) NOT NULL DEFAULT '', + `game_id` int(11) NOT NULL DEFAULT '0', + `game_name` varchar(30) NOT NULL DEFAULT '', + `server_id` varchar(30) NOT NULL DEFAULT '0', + `server_name` varchar(30) NOT NULL DEFAULT '', + `game_player_id` varchar(30) NOT NULL DEFAULT '0', + `game_player_name` varchar(100) NOT NULL DEFAULT '', + `promote_id` int(11) NOT NULL DEFAULT '0', + `promote_account` varchar(30) NOT NULL DEFAULT '', + `order_number` varchar(100) NOT NULL DEFAULT '', + `pay_order_number` varchar(100) NOT NULL DEFAULT '', + `props_name` varchar(30) NOT NULL DEFAULT '', + `pay_amount` decimal(10,2) NOT NULL DEFAULT '0.00', + `advance_pay_amount` decimal(10,2) NOT NULL DEFAULT '0.00', + `discount` decimal(5,2) NOT NULL DEFAULT '10.00', + `discount_type` tinyint(2) NOT NULL DEFAULT '0', + `cost` decimal(10,2) NOT NULL DEFAULT '0.00', + `pay_time` int(11) NOT NULL DEFAULT '0', + `pay_status` tinyint(2) NOT NULL DEFAULT '0', + `pay_game_status` tinyint(2) NOT NULL DEFAULT '0', + `extra_param` varchar(255) NOT NULL DEFAULT '', + `extend` varchar(255) NOT NULL DEFAULT '', + `pay_way` tinyint(2) NOT NULL DEFAULT '0', + `spend_ip` varchar(20) NOT NULL DEFAULT '', + `is_check` tinyint(2) NOT NULL DEFAULT '0', + `sdk_version` tinyint(2) NOT NULL DEFAULT '1', + `receipt` varchar(1000) NOT NULL DEFAULT '', + `role_level` int(8) NOT NULL DEFAULT '0', + `small_id` int(11) NOT NULL DEFAULT '0', + `small_nickname` varchar(30) DEFAULT '', + `coupon_record_id` int(11) NOT NULL DEFAULT '0', + `is_weiduan` tinyint(1) NOT NULL DEFAULT '0', + `update_time` int(10) NOT NULL DEFAULT '0', + `pay_promote_id` int(10) NOT NULL DEFAULT '0', + `pay_config_id` int(10) NOT NULL DEFAULT '0', + `pay_config_sub` varchar(50) NOT NULL DEFAULT '', + `game_notify_info` varchar(1000) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `product_id` varchar(100) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `currency_code` varchar(10) CHARACTER SET utf8mb4 NOT NULL DEFAULT 'CNY', + `us_cost` decimal(10,2) NOT NULL DEFAULT '0.00', + `currency_cost` decimal(10,2) NOT NULL DEFAULT '0.00', + `area` tinyint(1) unsigned NOT NULL DEFAULT '0', + `goods_reserve` varchar(100) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `auto_repair_times` tinyint(2) NOT NULL, + `platform_id` int(11) NOT NULL DEFAULT '0', + `webplatform_user_id` int(11) NOT NULL DEFAULT '0', + `platform_pay_type` tinyint(11) NOT NULL DEFAULT '0', + `cp_id` int(11) NOT NULL DEFAULT '0', + `cp_settlement_period_id` int(11) NOT NULL DEFAULT '0', + `super_order_number` varchar(100) NOT NULL DEFAULT '', + `is_bind_recycle` tinyint(1) NOT NULL DEFAULT '0', + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`), + UNIQUE KEY `pay_order_number` (`pay_order_number`) USING BTREE, + KEY `promote_id` (`promote_id`) USING BTREE, + KEY `game_id` (`game_id`) USING BTREE, + KEY `spend_search` (`game_id`,`promote_id`,`user_id`,`pay_time`) USING BTREE, + KEY `user_id` (`user_id`) USING BTREE, + KEY `pay_status` (`pay_status`) USING BTREE, + KEY `pay_time` (`pay_time`) USING BTREE, + KEY `idx_spend_small_role_pay` (`small_id`,`server_id`,`game_player_id`,`pay_status`) USING BTREE, + KEY `idx_promote_pay_status` (`promote_id`,`pay_status`), + KEY `idx_promote_paytime_status` (`promote_id`,`pay_time`,`pay_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `tab_sw_report_day` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `stat_date` varchar(10) NOT NULL, + `source_db` varchar(10) NOT NULL DEFAULT '', + `promote_id` int(11) NOT NULL DEFAULT '0', + `game_id` int(11) NOT NULL DEFAULT '0', + `game_name` varchar(64) NOT NULL DEFAULT '', + `dedup_mode` tinyint(4) NOT NULL DEFAULT '0', + `new_register_cnt` int(11) NOT NULL DEFAULT '0', + `active_cnt` int(11) NOT NULL DEFAULT '0', + `pay_cnt` int(11) NOT NULL DEFAULT '0', + `new_pay_cnt` int(11) NOT NULL DEFAULT '0', + `total_pay` decimal(14,2) NOT NULL DEFAULT '0.00', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sw_report_day` (`stat_date`,`source_db`,`promote_id`,`game_id`,`dedup_mode`), + KEY `idx_sw_report_day_query` (`stat_date`,`source_db`,`dedup_mode`,`promote_id`), + KEY `idx_sw_report_day_game_name` (`stat_date`,`dedup_mode`,`source_db`,`game_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `tab_sw_report_day_key` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `stat_date` varchar(10) NOT NULL, + `source_db` varchar(10) NOT NULL DEFAULT '', + `promote_id` int(11) NOT NULL DEFAULT '0', + `game_id` int(11) NOT NULL DEFAULT '0', + `game_name` varchar(64) NOT NULL DEFAULT '', + `dedup_mode` tinyint(4) NOT NULL DEFAULT '0', + `metric` tinyint(4) NOT NULL, + `identity_key` varchar(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sw_report_day_key` (`stat_date`,`source_db`,`promote_id`,`game_id`,`dedup_mode`,`metric`,`identity_key`), + KEY `idx_key_channel` (`stat_date`,`dedup_mode`,`source_db`,`promote_id`,`metric`), + KEY `idx_key_game` (`stat_date`,`dedup_mode`,`source_db`,`game_id`,`metric`), + KEY `idx_key_game_name` (`stat_date`,`dedup_mode`,`source_db`,`game_name`,`metric`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `tab_sw_report_server_day` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `stat_date` varchar(10) NOT NULL, + `source_db` varchar(10) NOT NULL DEFAULT '', + `promote_id` int(11) NOT NULL DEFAULT '0', + `game_id` int(11) NOT NULL DEFAULT '0', + `game_name` varchar(64) NOT NULL DEFAULT '', + `server_num` varchar(32) NOT NULL DEFAULT '', + `dedup_mode` tinyint(4) NOT NULL DEFAULT '0', + `new_register_cnt` int(11) NOT NULL DEFAULT '0', + `active_cnt` int(11) NOT NULL DEFAULT '0', + `pay_cnt` int(11) NOT NULL DEFAULT '0', + `total_pay` decimal(14,2) NOT NULL DEFAULT '0.00', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sw_report_server_day` (`stat_date`,`source_db`,`promote_id`,`game_id`,`server_num`,`dedup_mode`), + KEY `idx_server_day_query` (`stat_date`,`source_db`,`game_id`,`dedup_mode`,`server_num`), + KEY `idx_server_day_promote` (`stat_date`,`source_db`,`dedup_mode`,`promote_id`), + KEY `idx_server_day_game_name` (`stat_date`,`source_db`,`dedup_mode`,`game_name`,`server_num`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `tab_sw_report_server_day_key` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `stat_date` varchar(10) NOT NULL, + `source_db` varchar(10) NOT NULL DEFAULT '', + `promote_id` int(11) NOT NULL DEFAULT '0', + `game_id` int(11) NOT NULL DEFAULT '0', + `game_name` varchar(64) NOT NULL DEFAULT '', + `server_num` varchar(32) NOT NULL DEFAULT '', + `dedup_mode` tinyint(4) NOT NULL DEFAULT '0', + `metric` tinyint(4) NOT NULL, + `identity_key` varchar(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sw_report_server_day_key` (`stat_date`,`source_db`,`promote_id`,`game_id`,`server_num`,`dedup_mode`,`metric`,`identity_key`), + KEY `idx_server_key_query` (`stat_date`,`dedup_mode`,`source_db`,`game_id`,`server_num`,`metric`), + KEY `idx_server_key_game_name` (`stat_date`,`dedup_mode`,`source_db`,`game_name`,`server_num`,`metric`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `tab_user` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `account` varchar(200) NOT NULL DEFAULT '', + `password` varchar(100) NOT NULL DEFAULT '', + `promote_id` int(11) NOT NULL DEFAULT '0', + `promote_account` varchar(30) NOT NULL DEFAULT '', + `parent_id` int(11) NOT NULL DEFAULT '0', + `parent_name` varchar(30) NOT NULL DEFAULT '', + `fgame_id` int(11) NOT NULL DEFAULT '0', + `fgame_name` varchar(30) NOT NULL DEFAULT '', + `nickname` varchar(30) NOT NULL DEFAULT '', + `sex` tinyint(1) NOT NULL DEFAULT '0', + `email` varchar(50) NOT NULL DEFAULT '', + `qq` varchar(20) NOT NULL DEFAULT '', + `phone` varchar(15) NOT NULL DEFAULT '', + `receive_address` varchar(255) NOT NULL DEFAULT '', + `real_name` varchar(20) NOT NULL DEFAULT '', + `idcard` varchar(20) NOT NULL DEFAULT '', + `vip_level` tinyint(2) NOT NULL DEFAULT '0', + `cumulative` decimal(10,2) NOT NULL DEFAULT '0.00', + `balance` decimal(10,2) NOT NULL DEFAULT '0.00', + `hzb_balance` decimal(10,2) NOT NULL DEFAULT '0.00', + `prize_pool` decimal(10,2) NOT NULL DEFAULT '0.00', + `lottery_num` int(11) NOT NULL DEFAULT '0', + `anti_addiction` tinyint(2) NOT NULL DEFAULT '0', + `lock_status` tinyint(2) NOT NULL DEFAULT '1', + `age_status` tinyint(2) NOT NULL DEFAULT '0', + `register_way` tinyint(2) NOT NULL DEFAULT '0', + `register_type` tinyint(2) NOT NULL DEFAULT '1', + `register_time` varchar(11) NOT NULL DEFAULT '0', + `login_time` int(11) NOT NULL DEFAULT '0', + `register_ip` varchar(16) NOT NULL DEFAULT '', + `login_ip` varchar(16) NOT NULL DEFAULT '', + `is_check` tinyint(2) NOT NULL DEFAULT '1', + `settle_check` tinyint(2) NOT NULL DEFAULT '0', + `sub_status` tinyint(2) NOT NULL DEFAULT '0', + `token` varchar(255) NOT NULL DEFAULT '', + `pkey` varchar(150) NOT NULL DEFAULT '', + `unionid` varchar(200) NOT NULL DEFAULT '', + `head_img` varchar(500) NOT NULL DEFAULT '', + `point` int(11) NOT NULL DEFAULT '0', + `gold_coin` double(11,2) NOT NULL DEFAULT '0.00', + `alipay` varchar(20) NOT NULL DEFAULT '', + `alipay_real_name` varchar(50) NOT NULL DEFAULT '', + `old_promote_id` int(11) NOT NULL DEFAULT '0', + `third_authentication` tinyint(1) NOT NULL DEFAULT '0', + `equipment_num` varchar(100) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `is_bind` tinyint(2) NOT NULL DEFAULT '0', + `puid` int(11) NOT NULL DEFAULT '0', + `invitation_id` int(11) NOT NULL DEFAULT '0', + `is_platform` tinyint(1) NOT NULL DEFAULT '0', + `is_prompt` tinyint(1) NOT NULL DEFAULT '0', + `is_sell_prompt` tinyint(1) NOT NULL DEFAULT '0', + `member_days` int(6) NOT NULL DEFAULT '0', + `end_time` int(11) NOT NULL DEFAULT '0', + `wx_nickname` varchar(60) NOT NULL DEFAULT '', + `tplay_cash` decimal(11,2) unsigned NOT NULL DEFAULT '0.00', + `openid` varchar(200) NOT NULL DEFAULT '', + `pay_password` varchar(100) NOT NULL DEFAULT '', + `device_name` varchar(100) NOT NULL DEFAULT '', + `is_unsubscribe` tinyint(2) NOT NULL DEFAULT '0', + `wechat_cdkey` varchar(255) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `sso` tinyint(2) NOT NULL DEFAULT '0', + `is_simulator` tinyint(2) NOT NULL DEFAULT '0', + `is_batch_create` tinyint(2) NOT NULL DEFAULT '0', + `user_score` decimal(8,1) NOT NULL DEFAULT '0.0', + `user_stage_id` int(11) NOT NULL DEFAULT '0', + `user_stage_remark` varchar(255) NOT NULL DEFAULT '', + `login_equipment_num` varchar(200) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `is_new_real_name` tinyint(2) NOT NULL DEFAULT '0', + `ban_wxpay` tinyint(1) NOT NULL DEFAULT '0', + `ban_alipay` tinyint(1) NOT NULL DEFAULT '0', + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`), + KEY `register_time` (`register_time`) USING BTREE, + KEY `promote_id` (`promote_id`) USING BTREE, + KEY `fgame_id` (`fgame_id`) USING BTREE, + KEY `deposite_search` (`promote_id`,`fgame_id`,`lock_status`,`register_way`,`register_time`) USING BTREE, + KEY `puid` (`puid`) USING BTREE, + KEY `phone` (`phone`) USING BTREE, + KEY `login_time` (`login_time`) USING BTREE, + KEY `idx_promote_puid` (`promote_id`,`puid`), + KEY `idx_promote_register` (`promote_id`,`register_time`), + KEY `idx_user_promote_logintime` (`promote_id`,`login_time`) USING BTREE, + KEY `account` (`account`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `tab_user_day_login` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `login_record_id` int(11) NOT NULL DEFAULT '0', + `user_id` int(11) NOT NULL DEFAULT '0', + `promote_id` int(11) NOT NULL DEFAULT '0', + `game_id` int(11) NOT NULL DEFAULT '0', + `is_new` tinyint(1) NOT NULL DEFAULT '0', + `equipment_num` varchar(255) NOT NULL DEFAULT '', + `sdk_version` tinyint(1) NOT NULL DEFAULT '0', + `login_time` int(11) NOT NULL DEFAULT '0', + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`), + KEY `login_time` (`login_time`) USING BTREE, + KEY `game_id` (`game_id`) USING BTREE, + KEY `promote_id` (`promote_id`) USING BTREE, + KEY `seach` (`game_id`,`promote_id`,`user_id`) USING BTREE, + KEY `seach1` (`user_id`,`login_time`,`game_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE `tab_user_play_info` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL DEFAULT '0', + `user_account` varchar(200) NOT NULL DEFAULT '', + `game_id` int(11) NOT NULL DEFAULT '0', + `game_name` varchar(30) NOT NULL DEFAULT '', + `server_id` varchar(30) NOT NULL DEFAULT '', + `server_name` varchar(30) NOT NULL DEFAULT '', + `role_id` varchar(30) NOT NULL DEFAULT '', + `role_name` varchar(30) NOT NULL DEFAULT '', + `role_level` int(8) NOT NULL DEFAULT '0', + `combat_number` varchar(30) NOT NULL DEFAULT '', + `promote_id` int(11) NOT NULL DEFAULT '0', + `promote_account` varchar(30) NOT NULL DEFAULT '', + `play_time` int(11) NOT NULL DEFAULT '0', + `down_time` int(11) NOT NULL DEFAULT '0', + `play_ip` varchar(30) NOT NULL DEFAULT '', + `update_time` int(11) NOT NULL DEFAULT '0', + `puid` int(11) NOT NULL DEFAULT '0', + `nickname` varchar(30) NOT NULL DEFAULT '', + `player_reserve` varchar(100) CHARACTER SET utf8mb4 NOT NULL DEFAULT '', + `create_time` int(11) NOT NULL DEFAULT '0', + `source_db` varchar(10) NOT NULL DEFAULT '', + PRIMARY KEY (`id`,`source_db`) USING BTREE, + KEY `user_id` (`user_id`) USING BTREE, + KEY `game_id` (`game_id`) USING BTREE, + KEY `promote_id` (`promote_id`) USING BTREE, + KEY `search` (`user_id`,`game_id`,`server_id`,`server_name`,`role_id`) USING BTREE, + KEY `puid` (`puid`) USING BTREE, + KEY `play_time` (`play_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC; + +CREATE TABLE `test_demo` ( + `id` bigint(20) NOT NULL, + `dept_id` bigint(20) DEFAULT NULL, + `user_id` bigint(20) DEFAULT NULL, + `order_num` int(11) DEFAULT '0', + `test_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `value` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` int(11) DEFAULT '0', + `create_dept` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `del_flag` int(11) DEFAULT '0', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE `test_tree` ( + `id` bigint(20) NOT NULL, + `parent_id` bigint(20) DEFAULT '0', + `dept_id` bigint(20) DEFAULT NULL, + `user_id` bigint(20) DEFAULT NULL, + `tree_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` int(11) DEFAULT '0', + `create_dept` bigint(20) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `create_by` bigint(20) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + `update_by` bigint(20) DEFAULT NULL, + `del_flag` int(11) DEFAULT '0', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/internal/data/swmigration/testdata/legacy-surface.json b/internal/data/swmigration/testdata/legacy-surface.json new file mode 100644 index 0000000..df7ec6f --- /dev/null +++ b/internal/data/swmigration/testdata/legacy-surface.json @@ -0,0 +1,544 @@ +{ + "menus": [ + { + "menu_id": "2084194096229949441", + "menu_name": "商务管理", + "parent_id": "0", + "order_num": 1, + "path": "BusMgmt", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "M", + "visible": "0", + "status": "0", + "perms": null, + "icon": "category" + }, + { + "menu_id": "2084194830778408961", + "menu_name": "商务列表", + "parent_id": "2084194096229949441", + "order_num": 1, + "path": "BusinessList", + "component": "BusMgmt/BusinessList", + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:user:list", + "icon": "email" + }, + { + "menu_id": "2084202636386541569", + "menu_name": "渠道列表", + "parent_id": "2084194096229949441", + "order_num": 2, + "path": "ChannelList", + "component": "BusMgmt/ChannelList", + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:promote:list", + "icon": "chart" + }, + { + "menu_id": "2084224284976246785", + "menu_name": "数据分析", + "parent_id": "0", + "order_num": 2, + "path": "DataAnalysis", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "M", + "visible": "0", + "status": "0", + "perms": null, + "icon": "international" + }, + { + "menu_id": "2084224583841378306", + "menu_name": "渠道数据统计", + "parent_id": "2084224284976246785", + "order_num": 1, + "path": "ChannelDataStatistics", + "component": "DataAnalysis/ChannelDataStatistics", + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:channelData:list", + "icon": "color" + }, + { + "menu_id": "2084449385185308674", + "menu_name": "充值明细", + "parent_id": "2084224284976246785", + "order_num": 2, + "path": "RechargeDetails", + "component": "DataAnalysis/RechargeDetails", + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:spend:list", + "icon": "dict" + }, + { + "menu_id": "2084453258847350785", + "menu_name": "注册明细", + "parent_id": "2084224284976246785", + "order_num": 3, + "path": "RegistrationDetails", + "component": "DataAnalysis/RegistrationDetails", + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:register:list", + "icon": "cascader" + }, + { + "menu_id": "2084458436631752706", + "menu_name": "游戏数据", + "parent_id": "2084224284976246785", + "order_num": 4, + "path": "GameAnalytics", + "component": "DataAnalysis/GameAnalytics", + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:gameData:list", + "icon": "education" + }, + { + "menu_id": "2084466605093318657", + "menu_name": "区服分析", + "parent_id": "2084224284976246785", + "order_num": 5, + "path": "ServerAnalysis", + "component": "DataAnalysis/ServerAnalysis", + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:serverAnalysis:list", + "icon": "excel" + }, + { + "menu_id": "2084475309624684546", + "menu_name": "排行榜", + "parent_id": "2084224284976246785", + "order_num": 5, + "path": "RankingAnalysis", + "component": "DataAnalysis/RankingAnalysis", + "query_param": null, + "is_frame": "N", + "is_cache": "N", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:rank:channel", + "icon": "star" + }, + { + "menu_id": "2084478005203202049", + "menu_name": "预警中心", + "parent_id": "0", + "order_num": 3, + "path": "AlertCenter", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "M", + "visible": "0", + "status": "0", + "perms": null, + "icon": "process-definition" + }, + { + "menu_id": "2084480429515108353", + "menu_name": "预警列表", + "parent_id": "2084478005203202049", + "order_num": 1, + "path": "AlertList", + "component": "AlertCenter/AlertList", + "query_param": null, + "is_frame": "N", + "is_cache": "N", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:alertRecord:list", + "icon": "documentation" + }, + { + "menu_id": "2084480593562726401", + "menu_name": "预警规则", + "parent_id": "2084478005203202049", + "order_num": 2, + "path": "AlertRules", + "component": "AlertCenter/AlertRules", + "query_param": null, + "is_frame": "N", + "is_cache": "N", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": "system:alertRule:list", + "icon": "example" + }, + { + "menu_id": "2086033702105710593", + "menu_name": "审核", + "parent_id": "2084480429515108353", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:alertRecord:audit", + "icon": "" + }, + { + "menu_id": "2086034409621880833", + "menu_name": "详情接口", + "parent_id": "2084480429515108353", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:alertRecord:query", + "icon": "" + }, + { + "menu_id": "2086621845875916801", + "menu_name": "添加备注", + "parent_id": "2084480429515108353", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:alertRecord:process", + "icon": "" + }, + { + "menu_id": "2086623544338350082", + "menu_name": "上传图片", + "parent_id": "2084480429515108353", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:oss:upload", + "icon": "" + }, + { + "menu_id": "2086643768701812738", + "menu_name": "查询", + "parent_id": "2084480593562726401", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:alertRule:query", + "icon": "" + }, + { + "menu_id": "2086643813702500354", + "menu_name": "新增", + "parent_id": "2084480593562726401", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:alertRule:add", + "icon": "" + }, + { + "menu_id": "2086643981961199618", + "menu_name": "编辑", + "parent_id": "2084480593562726401", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:alertRule:edit", + "icon": "" + }, + { + "menu_id": "2086644092393029633", + "menu_name": "删除", + "parent_id": "2084480593562726401", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:alertRule:remove", + "icon": "" + }, + { + "menu_id": "2087069357514440706", + "menu_name": "新增渠道", + "parent_id": "2084202636386541569", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:promote:add", + "icon": "" + }, + { + "menu_id": "2087385085920727041", + "menu_name": "添加子商务", + "parent_id": "2084194830778408961", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:user:add", + "icon": "" + }, + { + "menu_id": "2087385679095975937", + "menu_name": "查询", + "parent_id": "2084194830778408961", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:user:query", + "icon": "" + }, + { + "menu_id": "2087385949666332673", + "menu_name": "编辑商务", + "parent_id": "2084194830778408961", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:user:edit", + "icon": "" + }, + { + "menu_id": "2087386325354336258", + "menu_name": "导出", + "parent_id": "2084194830778408961", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:user:export", + "icon": "" + }, + { + "menu_id": "2087444380922167298", + "menu_name": "绑定解绑游戏商务", + "parent_id": "2084194830778408961", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:user:bind", + "icon": "" + }, + { + "menu_id": "2087447465111326721", + "menu_name": "查询", + "parent_id": "2084202636386541569", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:promote:list", + "icon": "" + }, + { + "menu_id": "2087831493110956033", + "menu_name": "导出", + "parent_id": "2084453258847350785", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:spend:export", + "icon": "" + }, + { + "menu_id": "2095702540414996481", + "menu_name": "渠道激活", + "parent_id": "2084194096229949441", + "order_num": 3, + "path": "ChannelActivationList", + "component": "BusMgmt/ChannelActivationList", + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "C", + "visible": "0", + "status": "0", + "perms": null, + "icon": "company" + }, + { + "menu_id": "2095702652168032258", + "menu_name": "列表", + "parent_id": "2095702540414996481", + "order_num": 1, + "path": "", + "component": null, + "query_param": null, + "is_frame": "N", + "is_cache": "Y", + "menu_type": "F", + "visible": "0", + "status": "0", + "perms": "system:promoteActivePool:list", + "icon": "" + } + ], + "jobs": [ + { + "executor_info": "swReportDayYesterdayTask", + "trigger_type": 3, + "trigger_interval": "0 0 1 * * ? ", + "job_status": 1 + }, + { + "executor_info": "swReportServerDayYesterdayTask", + "trigger_type": 3, + "trigger_interval": "0 5 1 * * ? ", + "job_status": 1 + }, + { + "executor_info": "swReportDayTodayTask", + "trigger_type": 3, + "trigger_interval": "40 */5 * * * ? ", + "job_status": 1 + }, + { + "executor_info": "swReportServerDayTodayTask", + "trigger_type": 3, + "trigger_interval": "0 */5 * * * ?", + "job_status": 1 + }, + { + "executor_info": "swReportServerDayBackfillTask", + "trigger_type": 2, + "trigger_interval": "60", + "job_status": 0 + }, + { + "executor_info": "swReportDayBackfillTask", + "trigger_type": 2, + "trigger_interval": "60", + "job_status": 0 + }, + { + "executor_info": "alertTriggerTask", + "trigger_type": 3, + "trigger_interval": "0 */30 * * * ? ", + "job_status": 1 + } + ] +} \ No newline at end of file diff --git a/internal/data/system/authority.go b/internal/data/system/authority.go index c5f3f3c..d52ec2f 100644 --- a/internal/data/system/authority.go +++ b/internal/data/system/authority.go @@ -412,6 +412,9 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro return err } var users, children int64 + if err := system.ValidateAuthorityDeletion(&system.Authority{AuthorityID: authority.AuthorityID, BusinessLevel: authority.BusinessLevel}); err != nil { + return err + } // Associated-user checks only block on users that still // exist (and are not soft-deleted). Counting raw join rows would make an // orphaned association impossible to clean up, which is observably diff --git a/internal/data/system/business_roles.go b/internal/data/system/business_roles.go new file mode 100644 index 0000000..1aa0e7d --- /dev/null +++ b/internal/data/system/business_roles.go @@ -0,0 +1,119 @@ +package system + +import ( + "fmt" + "gorm.io/gorm" +) + +// The migration resolves historical IDs once. Runtime role assignment uses +// business_level and is independent of editable names and migration helpers. +func migrateBusinessRoles(db *gorm.DB) error { + if !db.Migrator().HasColumn(&authorityPO{}, "BusinessLevel") { + if err := db.Migrator().AddColumn(&authorityPO{}, "BusinessLevel"); err != nil { + return err + } + } + var roles []authorityPO + if err := db.Find(&roles).Error; err != nil { + return err + } + if len(roles) == 0 { + return ensureBusinessRoleIndex(db) + } + legacy := map[int]string{1: "2083497078545207297", 2: "2087435436573081601", 3: "2087361690139152385"} + names := map[int]string{1: "一级商务", 2: "二级商务", 3: "三级商务"} + hasMap := db.Migrator().HasTable("sw_kra_id_map") + applicable := hasMap + for _, role := range roles { + if role.BusinessLevel != nil { + applicable = true + } + for _, name := range names { + if role.AuthorityName == name { + applicable = true + } + } + } + if !applicable { + return ensureBusinessRoleIndex(db) + } + bindings := map[int]uint{} + for _, role := range roles { + if role.BusinessLevel == nil { + continue + } + level := *role.BusinessLevel + if level < 1 || level > 3 || role.AuthorityID == 888 || bindings[level] != 0 { + return fmt.Errorf("invalid or duplicate business role level %d", level) + } + bindings[level] = role.AuthorityID + } + for level := 1; level <= 3; level++ { + if bindings[level] != 0 { + continue + } + var ids []uint + if hasMap { + if err := db.Table("sw_kra_id_map m").Select("m.new_id").Joins("JOIN sys_authorities a ON a.authority_id=m.new_id AND a.deleted_at IS NULL").Where("m.entity='role' AND m.old_id=? AND m.new_id<>888", legacy[level]).Scan(&ids).Error; err != nil { + return err + } + } else { + for _, role := range roles { + if role.AuthorityID != 888 && role.AuthorityName == names[level] { + ids = append(ids, role.AuthorityID) + } + } + } + if len(ids) != 1 { + // A generic first installation can have only the administrator. + // Migrated SW databases must supply all three mapped roles. + if !hasMap && len(bindings) == 0 && len(roles) == 1 && roles[0].AuthorityID == 888 { + return ensureBusinessRoleIndex(db) + } + return fmt.Errorf("business level %d requires exactly one migration role", level) + } + for _, id := range bindings { + if id == ids[0] { + return fmt.Errorf("one role cannot represent multiple business levels") + } + } + bindings[level] = ids[0] + } + if err := db.Transaction(func(tx *gorm.DB) error { + for level, id := range bindings { + if err := tx.Model(&authorityPO{}).Where("authority_id=?", id).UpdateColumn("business_level", level).Error; err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + return ensureBusinessRoleIndex(db) +} + +func ensureBusinessRoleIndex(db *gorm.DB) error { + const name = "uk_authority_business_level" + if db.Migrator().HasIndex(&authorityPO{}, name) { + return nil + } + return db.Migrator().CreateIndex(&authorityPO{}, name) +} + +func CheckBusinessRoles(db *gorm.DB) error { + var roles []authorityPO + if err := db.Where("business_level IS NOT NULL").Find(&roles).Error; err != nil { + return err + } + seen := map[int]bool{} + for _, role := range roles { + if *role.BusinessLevel < 1 || *role.BusinessLevel > 3 || role.AuthorityID == 888 || seen[*role.BusinessLevel] { + return fmt.Errorf("invalid business role binding") + } + seen[*role.BusinessLevel] = true + } + if len(seen) != 3 { + return fmt.Errorf("three business role bindings are required") + } + return nil +} diff --git a/internal/data/system/business_roles_test.go b/internal/data/system/business_roles_test.go new file mode 100644 index 0000000..6cea72b --- /dev/null +++ b/internal/data/system/business_roles_test.go @@ -0,0 +1,72 @@ +package system + +import ( + "context" + biz "kra/internal/biz/system" + "testing" +) + +func TestBusinessRoleIdentitySurvivesRenameAndMappingCleanup(t *testing.T) { + d := newTransactionTestData(t) + db := d.DB() + for _, statement := range []string{ + "INSERT INTO sys_authorities(authority_id,authority_name) VALUES(888,'admin'),(11,'一级已改名'),(12,'二级已改名'),(13,'三级已改名')", + "CREATE TABLE sw_kra_id_map(entity TEXT,old_id TEXT,new_id INTEGER)", + "INSERT INTO sw_kra_id_map VALUES('role','2083497078545207297',11),('role','2087435436573081601',12),('role','2087361690139152385',13)", + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + if err := migrateBusinessRoles(db); err != nil { + t.Fatal(err) + } + if err := db.Exec("DROP TABLE sw_kra_id_map").Error; err != nil { + t.Fatal(err) + } + if err := migrateBusinessRoles(db); err != nil { + t.Fatalf("migration retry after map removal: %v", err) + } + repo := &authorityAccessRepo{data: d} + if err := repo.UpdateAuthority(context.Background(), &biz.Authority{AuthorityID: 12, AuthorityName: "客服主管"}); err != nil { + t.Fatal(err) + } + var role authorityPO + if err := db.First(&role, "authority_id=12").Error; err != nil { + t.Fatal(err) + } + if role.BusinessLevel == nil || *role.BusinessLevel != 2 { + t.Fatal("rename changed the stable business role identity") + } + if err := repo.DeleteAuthority(context.Background(), 12); err != biz.ErrBusinessAuthorityProtected { + t.Fatalf("reserved role deletion: %v", err) + } + if err := repo.CopyAuthority(context.Background(), 12, &biz.Authority{AuthorityID: 14, AuthorityName: "普通副本", DataScope: 1}); err != nil { + t.Fatal(err) + } + role = authorityPO{} + if err := db.First(&role, "authority_id=14").Error; err != nil { + t.Fatal(err) + } + if role.BusinessLevel != nil { + t.Fatal("copy inherited the reserved business level") + } + if err := CheckBusinessRoles(db); err != nil { + t.Fatal(err) + } +} + +func TestBusinessRoleMigrationRejectsAmbiguousFallback(t *testing.T) { + d := newTransactionTestData(t) + if err := d.DB().Exec("INSERT INTO sys_authorities(authority_id,authority_name) VALUES(11,'一级商务'),(12,'一级商务'),(13,'二级商务'),(14,'三级商务')").Error; err != nil { + t.Fatal(err) + } + if err := migrateBusinessRoles(d.DB()); err == nil { + t.Fatal("ambiguous role names were accepted") + } + var count int64 + d.DB().Model(&authorityPO{}).Where("business_level IS NOT NULL").Count(&count) + if count != 0 { + t.Fatal("failed role migration partially changed bindings") + } +} diff --git a/internal/data/system/migrations.go b/internal/data/system/migrations.go index 3b6762b..679bab5 100644 --- a/internal/data/system/migrations.go +++ b/internal/data/system/migrations.go @@ -22,6 +22,7 @@ func Migrations() []migration.Step { {ID: "202608210002_communication_surface", Migrate: ensureCommunicationSurface}, {ID: "202608210003_communication_test_surface", Migrate: ensureCommunicationTestSurface}, {ID: "202611030002_business_user_fields", Migrate: ensureBusinessUserFields}, + {ID: "202611060024_business_role_identity", Migrate: migrateBusinessRoles}, } } diff --git a/internal/data/system/models.go b/internal/data/system/models.go index 1ba4365..2452c99 100644 --- a/internal/data/system/models.go +++ b/internal/data/system/models.go @@ -42,6 +42,7 @@ type authorityPO struct { DeletedAt gorm.DeletedAt `gorm:"index"` AuthorityID uint `gorm:"primaryKey;column:authority_id"` AuthorityName string + BusinessLevel *int `gorm:"type:tinyint unsigned;uniqueIndex:uk_authority_business_level"` ParentID *uint DataScope int `gorm:"default:1"` DefaultRouter string `gorm:"default:dashboard"` diff --git a/internal/data/system/user.go b/internal/data/system/user.go index 379c4d7..d49a3de 100644 --- a/internal/data/system/user.go +++ b/internal/data/system/user.go @@ -97,7 +97,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro } func toBizAuthority(po authorityPO) system.Authority { - return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter} + return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, BusinessLevel: po.BusinessLevel, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter} } func parseOriginSetting(raw string) (map[string]any, error) { diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index a3bdb48..a161e98 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) != 37 { - t.Fatalf("module migrations = %d, want 37", len(got)) + if got := catalog.MigrationSteps(); len(got) != 39 { + t.Fatalf("module migrations = %d, want 39", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { diff --git a/web/src/utils/blobError.js b/web/src/utils/blobError.js new file mode 100644 index 0000000..5170a7f --- /dev/null +++ b/web/src/utils/blobError.js @@ -0,0 +1,26 @@ +const textOf = async blob => (await blob.text()).replace(/^\uFEFF/, '').trim() + +const looksLikeMessage = text => text.startsWith('{') || text.startsWith('[') || text.startsWith('导出') + +export async function readBlobBusinessMessage(blob) { + if (!(blob instanceof Blob)) return '' + const type = (blob.type || '').toLowerCase() + const declaredText = type.includes('json') || type.startsWith('text/') + if (!declaredText) { + const head = (await textOf(blob.slice(0, 64))) + if (!looksLikeMessage(head)) return '' + } + const text = await textOf(blob) + if (!text) return '' + if (!text.startsWith('{') && !text.startsWith('[')) return declaredText || text.startsWith('导出') ? text : '' + try { + const data = JSON.parse(text) + if (!data || typeof data !== 'object' || Array.isArray(data)) return '' + const message = data.msg || data.message || '' + if ('code' in data && Number(data.code) !== 0) return message || '导出失败' + if (typeof message === 'string' && (message.includes('导出') || message.includes('10000'))) return message + return '' + } catch { + return '' + } +} diff --git a/web/src/utils/blobError.test.js b/web/src/utils/blobError.test.js new file mode 100644 index 0000000..7e6997e --- /dev/null +++ b/web/src/utils/blobError.test.js @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readBlobBusinessMessage } from './blobError.js' + +const jsonBlob = (value, type = 'application/json') => new Blob([JSON.stringify(value)], { type }) + +test('reads the export limit message from a json blob', async () => { + const message = '导出数据超过10000条,请缩小时间范围或筛选条件' + assert.equal(await readBlobBusinessMessage(jsonBlob({ code: 500, msg: message })), message) +}) + +test('reads the same message when the file type is not json', async () => { + const message = '导出数据超过10000条,请缩小时间范围或筛选条件' + assert.equal(await readBlobBusinessMessage(jsonBlob({ code: 500, msg: message }, 'application/octet-stream')), message) +}) + +test('reads a plain-text export error', async () => { + const message = '导出数据超过10000条,请缩小时间范围或筛选条件' + assert.equal(await readBlobBusinessMessage(new Blob([message], { type: 'text/plain' })), message) +}) + +test('leaves spreadsheet and unrelated json files alone', async () => { + const sheet = new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00])], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) + assert.equal(await readBlobBusinessMessage(sheet), '') + assert.equal(await readBlobBusinessMessage(jsonBlob({ versionName: '1.0', versionCode: '1', message: '发版说明' })), '') + assert.equal(await readBlobBusinessMessage(jsonBlob({ code: 0, msg: 'ok' })), '') + assert.equal(await readBlobBusinessMessage(jsonBlob({ msg: '导出数据超过10000条,请缩小时间范围或筛选条件' })), '导出数据超过10000条,请缩小时间范围或筛选条件') +}) diff --git a/web/src/utils/request.js b/web/src/utils/request.js index ece979e..1bfd63c 100644 --- a/web/src/utils/request.js +++ b/web/src/utils/request.js @@ -3,6 +3,7 @@ import { useUserStore } from '@/pinia/modules/user' import { ElLoading, ElMessage } from 'element-plus' import { emitter } from '@/utils/bus' import { isPasswordChangeRequiredError } from '@/utils/requestError' +import { readBlobBusinessMessage } from '@/utils/blobError' import router from '@/router/index' const DEFAULT_REQUEST_TIMEOUT = 1000 * 60 * 10 @@ -189,8 +190,20 @@ const showErrorMessage = (rawMessage) => { activeErrorMessages.set(message, instance) } +const rejectBlobBusinessError = async (response, error) => { + const message = await readBlobBusinessMessage(response?.data) + if (!message) return null + showErrorMessage(message) + const notified = error || new Error(message) + notified.businessMessage = message + notified.exportNotified = true + notified.config = notified.config || response?.config + notified.response = notified.response || response + return notified +} + service.interceptors.response.use( - (response) => { + async (response) => { const userStore = useUserStore() if (!response.config.donNotShowLoading) { @@ -201,6 +214,12 @@ service.interceptors.response.use( userStore.setToken(response.headers['new-token']) } + if (response.data instanceof Blob) { + const notified = await rejectBlobBusinessError(response) + if (notified) return Promise.reject(notified) + return response + } + if (typeof response.data.code === 'undefined') { return response } @@ -216,7 +235,7 @@ service.interceptors.response.use( return response.data.msg ? response.data : response }, - (error) => { + async (error) => { if (!error.config?.donNotShowLoading) { closeLoading(error.config?.loadingOption) } @@ -257,6 +276,9 @@ service.interceptors.response.use( return Promise.reject(error) } + const notified = await rejectBlobBusinessError(error.response, error) + if (notified) return Promise.reject(notified) + emitter.emit('show-error', { code: error.response.status, message: getErrorMessage(error) diff --git a/web/src/view/dashboard/components/BusinessDashboard.vue b/web/src/view/dashboard/components/BusinessDashboard.vue index d67eaae..df88a57 100644 --- a/web/src/view/dashboard/components/BusinessDashboard.vue +++ b/web/src/view/dashboard/components/BusinessDashboard.vue @@ -27,7 +27,7 @@
-

{{ item.label }}

+

{{ item.label }}

{{ item.value }}

{{ item.change }}

@@ -188,6 +188,9 @@
+ +
{{ fieldTip.text }}
+
@@ -252,6 +255,35 @@ const overviewPeriod = ref('today') const overviewDates = ref([]) const kpiError = ref('') const kpiList = ref(overviewCards(null)) +const fieldTip = ref({ open: false, ready: false, text: '', x: 0, y: 0 }) +const fieldTipRef = ref() +let fieldTipToken = 0 + +const placeFieldTip = anchor => { + const el = fieldTipRef.value + if (!el) return + const margin = 8 + const left = Math.max(margin, Math.min(anchor.left + anchor.width / 2 - el.offsetWidth / 2, window.innerWidth - el.offsetWidth - margin)) + const above = anchor.top - el.offsetHeight - margin + fieldTip.value = { ...fieldTip.value, ready: true, x: left, y: above >= margin ? above : anchor.bottom + margin } +} + +const showFieldTip = async (event, text) => { + const token = ++fieldTipToken + const anchor = event.currentTarget.getBoundingClientRect() + fieldTip.value = { open: true, ready: false, text, x: -9999, y: 0 } + await nextTick() + if (token === fieldTipToken) placeFieldTip(anchor) +} + +const hideFieldTip = () => { + fieldTipToken += 1 + if (fieldTip.value.open) fieldTip.value = { ...fieldTip.value, open: false, ready: false } +} + +const hideFieldTipOnScroll = () => { + if (fieldTip.value.open) hideFieldTip() +} const requestVersions = { summary: 0, register: 0, recharge: 0, offline: 0, low: 0, alerts: 0 } const kpiLoading = ref(false) const alertLoading = ref(false) @@ -759,6 +791,7 @@ onMounted(() => { fetchLowRechargeRank() }) window.addEventListener('resize', handleResize) + window.addEventListener('scroll', hideFieldTipOnScroll, true) themeObserver = new MutationObserver(() => { renderRegisterChart(); renderRechargeChart() }) themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) }) @@ -769,6 +802,7 @@ onBeforeUnmount(() => { stopAlertTimer() if (alertAnimTimer) clearTimeout(alertAnimTimer) window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', hideFieldTipOnScroll, true) registerChart?.dispose() rechargeChart?.dispose() registerChart = undefined @@ -861,6 +895,16 @@ onBeforeUnmount(() => { line-height: 1.4; } +.has-tip { + cursor: help; + border-bottom: 1px dashed transparent; +} + +.has-tip:hover { + color: var(--dash-blue); + border-bottom-color: var(--dash-blue); +} + .kpi-value { margin: 0; color: var(--dash-title); @@ -1244,3 +1288,27 @@ html.dark .alert-body { } } + + diff --git a/web/src/view/dashboard/components/dashboard-metrics.js b/web/src/view/dashboard/components/dashboard-metrics.js index 1577bed..b5f3b15 100644 --- a/web/src/view/dashboard/components/dashboard-metrics.js +++ b/web/src/view/dashboard/components/dashboard-metrics.js @@ -1,14 +1,30 @@ import { channelCount, channelMoney } from '../../sw/channel-display.js' +const formatTipAmount = amount => { + const value = Number(amount) + return Number.isFinite(value) ? value.toLocaleString('en-US') : String(amount) +} + +const effectiveTip = data => { + const days = data?.settings?.days ?? 30 + const amount = formatTipAmount(data?.settings?.amount ?? 3000) + return [ + '所选区间内达成有效端的渠道数。', + `判定:渠道注册后 ${days} 天内累计充值流水满 ¥${amount} 即达成,按达标当天计入;`, + `注册满 ${days} 天仍未达标的不算,之后流水上来也不补记;达标后标记保持。`, + '阈值与天数由超管在「设置」中配置。' + ].join('\n') +} + const definitions = [ - { key: 'todayRegister', label: '玩家注册' }, - { key: 'todayActive', label: '活跃玩家' }, - { key: 'todayPay', label: '充值金额', money: true }, + { key: 'todayRegister', label: '玩家注册', tip: '所选区间内新增注册玩家数。\n勾选右上角「身份证去重」后按实名身份证去重:同一身份证多账号计 1 人,无实名信息不计入。' }, + { key: 'todayActive', label: '活跃玩家', tip: '所选区间内登录游戏的去重玩家数。\n勾选「身份证去重」后按实名身份证去重,无实名信息不计入。' }, + { key: 'todayPay', label: '充值金额', money: true, tip: '所选区间内所有渠道的充值成功金额汇总,已扣除失败 / 取消订单。' }, { key: 'effectivePromote', label: '有效端' }, - { key: 'activePromote', label: '活跃渠道数' }, - { key: 'newPromote', label: '新增渠道数' }, - { key: 'lockedPromote', label: '锁定渠道', event: true }, - { key: 'terminatedPromote', label: '解除合作渠道', event: true } + { key: 'activePromote', label: '活跃渠道数', tip: '所选区间内至少产生 1 个注册或 1 笔充值的渠道数。' }, + { key: 'newPromote', label: '新增渠道数', tip: '所选区间内新建立合作关系的渠道数,以渠道在系统中首次创建 / 启用时间为准。' }, + { key: 'lockedPromote', label: '锁定渠道', event: true, tip: '所选区间内被锁定的渠道数,按锁定时间落在区间内统计。\n统计的是「这段时间内发生了多少」,与渠道当前状态无关:例如 1 月锁定、2 月恢复正常,在 1 月区间里仍算锁定渠道。' }, + { key: 'terminatedPromote', label: '解除合作渠道', event: true, tip: '所选区间内被解除合作的渠道数,按解除合作时间落在区间内统计。\n解除合作与锁定分开展示,二者都属于被锁定的范畴。' } ] export const overviewCards = (data) => definitions.map(definition => { @@ -23,9 +39,7 @@ export const overviewCards = (data) => definitions.map(definition => { change, up: known && changeRate > 0, down: known && changeRate < 0, - tip: definition.key === 'effectivePromote' - ? `渠道注册后 ${data?.settings?.days ?? 30} 天内充值满 ¥${data?.settings?.amount ?? 3000},按达标日期统计。` - : definition.event ? '按所选区间内的状态变更事件统计。' : '所选时间区间内的数据,与上一等长周期比较。' + tip: definition.key === 'effectivePromote' ? effectiveTip(data) : definition.tip } }) diff --git a/web/src/view/dashboard/components/dashboard-metrics.test.js b/web/src/view/dashboard/components/dashboard-metrics.test.js index cc1ff4c..942ee83 100644 --- a/web/src/view/dashboard/components/dashboard-metrics.test.js +++ b/web/src/view/dashboard/components/dashboard-metrics.test.js @@ -14,7 +14,9 @@ test('overview preserves exact money, eight cards, and unavailable event data', assert.equal(cards.find(c => c.key === 'todayRegister').value, '0') assert.equal(cards.find(c => c.key === 'todayActive').value, '5,998') assert.equal(cards.find(c => c.key === 'lockedPromote').value, '未接入') - assert.match(cards.find(c => c.key === 'effectivePromote').tip, /15 天内充值满 ¥500/) + assert.equal(cards.find(c => c.key === 'todayRegister').tip, '所选区间内新增注册玩家数。\n勾选右上角「身份证去重」后按实名身份证去重:同一身份证多账号计 1 人,无实名信息不计入。') + assert.match(cards.find(c => c.key === 'effectivePromote').tip, /注册后 15 天内累计充值流水满 ¥500/) + assert.match(cards.find(c => c.key === 'lockedPromote').tip, /1 月锁定、2 月恢复正常/) assert.ok(overviewCards(null).every(c => c.value === '—')) assert.ok(overviewCards({ available: false }).every(c => c.value === '未接入')) }) diff --git a/web/src/view/layout/header/tools.vue b/web/src/view/layout/header/tools.vue index 7e4a3e2..139995f 100644 --- a/web/src/view/layout/header/tools.vue +++ b/web/src/view/layout/header/tools.vue @@ -10,11 +10,11 @@ @click="handleCommand" /> - + /> --> - + - +