feat: automate business upgrades and sync reviewed frontend
This commit is contained in:
@@ -55,3 +55,6 @@ __pycache__/
|
|||||||
/scripts/
|
/scripts/
|
||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
|
|
||||||
|
# Private automatic migration archives (never serve or commit these).
|
||||||
|
migration-backups/
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestBusinessHTTPListWithSystemOnlyAndCompleteDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, statement := range []string{
|
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_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')",
|
"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 {
|
if err := db.Exec(statement).Error; err != nil {
|
||||||
|
|||||||
+14
-1
@@ -40,6 +40,7 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, sql := range []string{
|
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)",
|
"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')",
|
"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)",
|
"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)",
|
"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)
|
data := call(7, "GET", "/sw/register/list?sourceDb=lx&sortField=totalPayAmount", "", 0).(map[string]any)
|
||||||
rows := data["rows"].([]any)
|
rows := data["rows"].([]any)
|
||||||
first := rows[0].(map[string]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)
|
t.Fatalf("注册明细口径或字段错误: %+v", data)
|
||||||
}
|
}
|
||||||
games := call(7, "GET", "/sw/serverAnalysis/games?sourceDb=lx&limit=50", "", 0).([]any)
|
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" {
|
if data["total"] != float64(1) || data["totalPayAmount"] != "0.00" {
|
||||||
t.Fatal("合计不得包含筛选条件外的成功订单")
|
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)
|
data = call(1, "GET", "/sw/spend/list?sourceDb=lx", "", 0).(map[string]any)
|
||||||
if data["total"] != float64(4) || data["totalPayAmount"] != "20.34" {
|
if data["total"] != float64(4) || data["totalPayAmount"] != "20.34" {
|
||||||
t.Fatal("超管全平台遗漏官方订单")
|
t.Fatal("超管全平台遗漏官方订单")
|
||||||
@@ -154,6 +158,11 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
|
|||||||
if err != nil || len(xlsRows) != 3 || xlsRows[0][7] != "累计充值" {
|
if err != nil || len(xlsRows) != 3 || xlsRows[0][7] != "累计充值" {
|
||||||
t.Fatal("注册 XLSX 字段或行数错误")
|
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")
|
raw, header = request(7, "POST", "/sw/spend/export", "sourceDb=lx&payStatus=1&sortField=payAmount")
|
||||||
if header.Get("success") != "true" {
|
if header.Get("success") != "true" {
|
||||||
t.Fatalf("充值导出失败: %s", raw)
|
t.Fatalf("充值导出失败: %s", raw)
|
||||||
@@ -163,10 +172,14 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
order, _ := f.GetCellValue("充值明细", "E2")
|
order, _ := f.GetCellValue("充值明细", "E2")
|
||||||
|
busierName, busierErr := f.GetCellValue("充值明细", "R2")
|
||||||
f.Close()
|
f.Close()
|
||||||
if order != "000000000001" {
|
if order != "000000000001" {
|
||||||
t.Fatal("订单号导出丢失前导零")
|
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)
|
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 {
|
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)
|
t.Fatal(err)
|
||||||
|
|||||||
+5
-1
@@ -6,7 +6,9 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"kra/internal/config"
|
"kra/internal/config"
|
||||||
"kra/internal/data"
|
"kra/internal/data"
|
||||||
@@ -173,7 +175,9 @@ func main() {
|
|||||||
if bc.Data != nil {
|
if bc.Data != nil {
|
||||||
database = bc.Data.Database
|
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)
|
panic(err)
|
||||||
}
|
}
|
||||||
logger.Info("database migration verification completed", "mod", "system", "check_only", flagCheckSchema)
|
logger.Info("database migration verification completed", "mod", "system", "check_only", flagCheckSchema)
|
||||||
|
|||||||
+20
-13
@@ -15,23 +15,30 @@
|
|||||||
- 后端仅绑定 `127.0.0.1:9602`,前端仅绑定 `127.0.0.1:9601`。
|
- 后端仅绑定 `127.0.0.1:9602`,前端仅绑定 `127.0.0.1:9601`。
|
||||||
- 宝塔 `sw2.fuhua91.com` 代理至 9601,前端容器将 `/api/` 转发至后端并去除前缀,支持 WebSocket、SSE 和文件上传。
|
- 宝塔 `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
|
```bash
|
||||||
/app/kratos-admin -conf /data/conf/config.yaml -migrate
|
|
||||||
/app/kratos-admin -conf /data/conf/config.yaml -check-schema
|
/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,先提交源代码,再执行:
|
Windows 本机安装 Go、Node、pnpm、tar,先提交源代码,再执行:
|
||||||
@@ -42,7 +49,7 @@ Windows 本机安装 Go、Node、pnpm、tar,先提交源代码,再执行:
|
|||||||
|
|
||||||
输出位于 `.cache/deploy/sw2-<commit>.tar.gz` 及对应 SHA-256 文件。后端交叉编译为 Linux amd64,前端执行生产构建;包内只含二进制、前端产物、Dockerfile 和部署配置,真实运行配置及环境变量不进入镜像或 Git。
|
输出位于 `.cache/deploy/sw2-<commit>.tar.gz` 及对应 SHA-256 文件。后端交叉编译为 Linux amd64,前端执行生产构建;包内只含二进制、前端产物、Dockerfile 和部署配置,真实运行配置及环境变量不进入镜像或 Git。
|
||||||
|
|
||||||
通过 SSH 将包上传到 226,校验 SHA-256 后解压到 `releases/<commit>/`。首次部署另行准备上述私有配置文件,保持 `admin.router_prefix` 为空,并确认主库为 sw10。SDK 环境变量名称为 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE`、`SW_SDK_TABLE_PREFIX`,值从已有私有运行配置取得。
|
通过 SSH 将包上传到 226,校验 SHA-256 后解压到 `releases/<commit>/`。首次部署准备上述私有配置文件,保持 `admin.router_prefix` 为空,主库填写待接管的旧商务数据库。SDK 环境变量名称为 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE`、`SW_SDK_TABLE_PREFIX`,值从已有私有运行配置取得。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash /www/wwwroot/sw2.fuhua91.com/releases/<commit>/deploy.sh <commit> --build-only
|
bash /www/wwwroot/sw2.fuhua91.com/releases/<commit>/deploy.sh <commit> --build-only
|
||||||
@@ -50,9 +57,9 @@ bash /www/wwwroot/sw2.fuhua91.com/releases/<commit>/deploy.sh <commit> --build-o
|
|||||||
bash /www/wwwroot/sw2.fuhua91.com/releases/<commit>/deploy.sh <commit>
|
bash /www/wwwroot/sw2.fuhua91.com/releases/<commit>/deploy.sh <commit>
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本先构建和检查镜像,再停止当前受管容器,保留一份停止的 `-previous` 容器。迁移或健康检查失败时恢复前一组容器。配置、上传文件和数据库不随镜像替换;索引在独立迁移容器中按版本执行。
|
脚本先构建和检查镜像,再停止当前受管容器,保留一份停止的 `-previous` 容器。迁移或健康检查失败时,先检查旧版本是否兼容当前数据库,再决定是否恢复前一组容器。配置、备份和上传文件不随镜像替换;完整数据库升级在独立迁移容器中自动执行。
|
||||||
|
|
||||||
迁移不设置总等待时限,日志记录每一步的名称、结果和耗时;耗时索引完成前不会启动业务服务。业务容器退出或重启时立即回退。切换数据库前需备份运行配置;镜像回退不会自动还原数据库连接配置或撤销已执行的数据库迁移,回退版本需兼容当前结构和迁移版本。
|
迁移不设置总等待时限,日志记录每一步的名称、结果和耗时;耗时索引完成前不会启动业务服务。业务容器退出或重启时进入兼容性检查后再处理回退。切换数据库前需备份运行配置;镜像回退不会自动还原数据库连接配置或撤销已执行的数据库迁移,回退版本需兼容当前结构和迁移版本。
|
||||||
|
|
||||||
首次接入域名时,备份宝塔原站点配置,再安装 `bt-nginx-sw2.fuhua91.com.conf` 至 `/www/server/panel/vhost/nginx/`,执行 `/www/server/nginx/sbin/nginx -t` 通过后重载。模板保留证书验证目录,当前监听 HTTP;后续由宝塔为 sw2 单独配置匹配域名的证书。
|
首次接入域名时,备份宝塔原站点配置,再安装 `bt-nginx-sw2.fuhua91.com.conf` 至 `/www/server/panel/vhost/nginx/`,执行 `/www/server/nginx/sbin/nginx -t` 通过后重载。模板保留证书验证目录,当前监听 HTTP;后续由宝塔为 sw2 单独配置匹配域名的证书。
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ try {
|
|||||||
foreach ($name in @('deploy.sh', 'wait-backend.sh', 'bt-nginx-sw2.fuhua91.com.conf')) {
|
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 $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 .
|
& tar -czf $archive -C $stage .
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Release archive failed' }
|
if ($LASTEXITCODE -ne 0) { throw 'Release archive failed' }
|
||||||
$hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
$hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
|||||||
+13
-2
@@ -73,6 +73,17 @@ rollback() {
|
|||||||
result=$?
|
result=$?
|
||||||
trap - ERR
|
trap - ERR
|
||||||
set +eu
|
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 "${created[@]}"; do docker rm -f "$name" >/dev/null 2>&1; done
|
||||||
for name in "${moved[@]}"; do
|
for name in "${moved[@]}"; do
|
||||||
if exists "$name-previous"; then docker rename "$name-previous" "$name"; fi
|
if exists "$name-previous"; then docker rename "$name-previous" "$name"; fi
|
||||||
@@ -91,10 +102,10 @@ for name in "${NAMES[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
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 \
|
docker run --rm --name sw2-migrate --label com.sw2.application=sw2 \
|
||||||
--network "$NETWORK" --env-file "$BASE_DIR/runtime.env" \
|
--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
|
"$BACKEND_IMAGE" /app/kratos-admin -conf /data/conf/config.yaml -migrate
|
||||||
|
|
||||||
created+=(sw2-backend)
|
created+=(sw2-backend)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
-- SW → Kra 旧框架转换:完整迁移必须继续执行当前后端的 -migrate 和 -check-schema。
|
-- SW → Kra 旧框架转换:由程序首次连接旧库时自动执行,随后自动完成增量迁移、校验和清理。
|
||||||
-- 服务设置 admin.system.disable_auto_migrate: true;目标库由下方 USE 配置。
|
-- 服务设置 admin.system.disable_auto_migrate: true;目标库由下方 USE 配置。
|
||||||
-- 新副本:备份源表、创建 Kra 表、转换框架数据及关联,并归档旧表。
|
-- 新副本:备份源表、创建 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 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()
|
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';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Kra target tables incomplete; refusing partial migration';
|
||||||
END IF;
|
END IF;
|
||||||
IF EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='sw_kra_state') THEN
|
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;
|
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_authority_menus WHERE sys_base_menu_id=v_workbench;
|
||||||
DELETE FROM sys_base_menu_parameters 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
|
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);
|
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_menu_btns WHERE sys_base_menu_id=v_workbench;
|
||||||
DELETE FROM sys_base_menus WHERE 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;
|
IF v_expected <> v_actual THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='sys_alert_operation_log backup bytes'; END IF;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
SELECT '旧框架备份已校验' AS result;
|
||||||
|
|
||||||
-- 四、按 Kra 当前定义创建目标表,并增加商务账号和岗位扩展字段。
|
-- 四、按 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;
|
UPDATE sw_kra_state SET phase='data_validated' WHERE id=1;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
SELECT '框架数据已验证' AS result;
|
||||||
|
|
||||||
-- 十、数据核验通过后归档旧框架表,登记完成状态并释放迁移锁。
|
-- 十、数据核验通过后归档旧框架表,登记完成状态并释放迁移锁。
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var (
|
|||||||
ErrAccountExists = errors.BadRequest("SW_ACCOUNT_EXISTS", "登录账号已存在")
|
ErrAccountExists = errors.BadRequest("SW_ACCOUNT_EXISTS", "登录账号已存在")
|
||||||
ErrPhoneExists = errors.BadRequest("SW_PHONE_EXISTS", "手机号码已存在")
|
ErrPhoneExists = errors.BadRequest("SW_PHONE_EXISTS", "手机号码已存在")
|
||||||
ErrEmailExists = errors.BadRequest("SW_EMAIL_EXISTS", "邮箱账号已存在")
|
ErrEmailExists = errors.BadRequest("SW_EMAIL_EXISTS", "邮箱账号已存在")
|
||||||
ErrBusinessRole = errors.BadRequest("SW_BUSINESS_ROLE", "未找到对应层级的商务角色,请核对角色迁移映射")
|
ErrBusinessRole = errors.BadRequest("SW_BUSINESS_ROLE", "商务等级对应的角色配置不完整")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ type LedgerListOptions struct {
|
|||||||
type LedgerNames struct {
|
type LedgerNames struct {
|
||||||
PromoteID *int64
|
PromoteID *int64
|
||||||
PromoteAccount, BindingAccount, BindingUserName string
|
PromoteAccount, BindingAccount, BindingUserName string
|
||||||
BusinessPath []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *LedgerNames) DisplayPromote() string {
|
func (n *LedgerNames) DisplayPromote() string {
|
||||||
@@ -53,9 +52,6 @@ func (n *LedgerNames) DisplayPromote() string {
|
|||||||
return "未知渠道(ID:" + strconv.FormatInt(*n.PromoteID, 10) + ")"
|
return "未知渠道(ID:" + strconv.FormatInt(*n.PromoteID, 10) + ")"
|
||||||
}
|
}
|
||||||
func (n *LedgerNames) DisplayBusiness() string {
|
func (n *LedgerNames) DisplayBusiness() string {
|
||||||
if len(n.BusinessPath) > 0 {
|
|
||||||
return strings.Join(n.BusinessPath, "→")
|
|
||||||
}
|
|
||||||
return (&Channel{BindingAccount: n.BindingAccount, BindingUserName: n.BindingUserName}).BusinessDisplayName()
|
return (&Channel{BindingAccount: n.BindingAccount, BindingUserName: n.BindingUserName}).BusinessDisplayName()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package system
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"github.com/go-kratos/kratos/v3/errors"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ type Authority struct {
|
|||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
AuthorityID uint
|
AuthorityID uint
|
||||||
AuthorityName string
|
AuthorityName string
|
||||||
|
BusinessLevel *int
|
||||||
ParentID *uint
|
ParentID *uint
|
||||||
DataScope int
|
DataScope int
|
||||||
DefaultRouter string
|
DefaultRouter string
|
||||||
@@ -39,6 +41,15 @@ type Authority struct {
|
|||||||
Menus []*Menu
|
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 }
|
type AuthorityUsecase struct{ AuthorityAccessRepo }
|
||||||
|
|
||||||
func NewAuthorityUsecase(repo AuthorityAccessRepo) *AuthorityUsecase {
|
func NewAuthorityUsecase(repo AuthorityAccessRepo) *AuthorityUsecase {
|
||||||
|
|||||||
@@ -143,9 +143,11 @@ type Disk struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type System struct {
|
type System struct {
|
||||||
UseRedis bool `mapstructure:"use_redis" yaml:"use_redis" json:"use_redis"`
|
UseRedis bool `mapstructure:"use_redis" yaml:"use_redis" json:"use_redis"`
|
||||||
UseMultipoint bool `mapstructure:"use_multipoint" yaml:"use_multipoint" json:"use_multipoint"`
|
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"`
|
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"`
|
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"`
|
UseMongo bool `mapstructure:"use_mongo" yaml:"use_mongo" json:"use_mongo"`
|
||||||
Addr int `mapstructure:"addr" yaml:"addr" json:"addr"`
|
Addr int `mapstructure:"addr" yaml:"addr" json:"addr"`
|
||||||
|
|||||||
@@ -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) {
|
func (r *accountRepo) AuthorityForLevel(ctx context.Context, level int) (uint, error) {
|
||||||
// 原角色 ID 仅用于查迁移映射,不作为 Kra 角色 ID 使用。
|
if level < 1 || level > 3 {
|
||||||
legacy := map[int]string{1: "2083497078545207297", 2: "2087435436573081601", 3: "2087361690139152385"}
|
|
||||||
oldID, ok := legacy[level]
|
|
||||||
if !ok {
|
|
||||||
return 0, business.ErrBusinessRole
|
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
|
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
|
return 0, business.ErrStorage
|
||||||
}
|
}
|
||||||
if len(ids) != 1 {
|
if len(ids) != 1 {
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ func prepareAccounts(t *testing.T) *gorm.DB {
|
|||||||
}
|
}
|
||||||
for _, sql := range []string{
|
for _, sql := range []string{
|
||||||
"CREATE TABLE sys_user_authority (sys_user_id INTEGER,sys_authority_authority_id INTEGER)",
|
"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 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)",
|
"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)",
|
"INSERT INTO sw_kra_id_map VALUES ('role','2087435436573081601',902)",
|
||||||
} {
|
} {
|
||||||
if err := db.Exec(sql).Error; err != nil {
|
if err := db.Exec(sql).Error; err != nil {
|
||||||
@@ -32,6 +32,9 @@ func TestAccountCreateIsAtomicAndPreferencesDefaultOn(t *testing.T) {
|
|||||||
db := prepareAccounts(t)
|
db := prepareAccounts(t)
|
||||||
r := NewAccountRepo(testData{db})
|
r := NewAccountRepo(testData{db})
|
||||||
ctx := context.Background()
|
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)
|
role, err := r.AuthorityForLevel(ctx, 2)
|
||||||
if err != nil || role != 902 {
|
if err != nil || role != 902 {
|
||||||
t.Fatalf("角色映射不正确: %d %v", role, err)
|
t.Fatalf("角色映射不正确: %d %v", role, err)
|
||||||
|
|||||||
@@ -394,13 +394,11 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
|
|||||||
SourceDB string `gorm:"column:source_db"`
|
SourceDB string `gorm:"column:source_db"`
|
||||||
PlatformBusierID uint64
|
PlatformBusierID uint64
|
||||||
PlatformBusierAccount, Username string
|
PlatformBusierAccount, Username string
|
||||||
UserID uint
|
|
||||||
IDPath string `gorm:"column:id_path"`
|
|
||||||
}
|
}
|
||||||
names := map[business.PlatformKey]bindName{}
|
names := map[business.PlatformKey]bindName{}
|
||||||
for start := 0; start < len(pairs); start += 300 {
|
for start := 0; start < len(pairs); start += 300 {
|
||||||
var rows []bindName
|
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 {
|
if err := q.Scan(&rows).Error; err != nil {
|
||||||
return business.ErrStorage
|
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 {
|
for _, target := range targets {
|
||||||
n := target.Names
|
n := target.Names
|
||||||
if n.PromoteID == nil || *n.PromoteID < 0 {
|
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 {
|
if binding, ok := names[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}]; ok {
|
||||||
n.BindingAccount = binding.PlatformBusierAccount
|
n.BindingAccount = binding.PlatformBusierAccount
|
||||||
n.BindingUserName = binding.Username
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) {
|
|||||||
first = row
|
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)
|
t.Fatalf("累计充值不能受本次时间/渠道限制,且需保留游戏快照: %+v", first)
|
||||||
}
|
}
|
||||||
filter.SortField = "totalPayAmount"
|
filter.SortField = "totalPayAmount"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"kra/internal/data/swmigration"
|
||||||
|
datasystem "kra/internal/data/system"
|
||||||
"kra/pkg/database/migration"
|
"kra/pkg/database/migration"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +38,9 @@ func CheckPrerequisites(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CheckSchema(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 {
|
if err := CheckPrerequisites(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,9 @@ func CheckSchema(db *gorm.DB) error {
|
|||||||
if db.Dialector.Name() != "mysql" {
|
if db.Dialector.Name() != "mysql" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := datasystem.CheckBusinessRoles(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
indexes := append([]businessQueryIndex{}, businessQueryIndexes...)
|
indexes := append([]businessQueryIndex{}, businessQueryIndexes...)
|
||||||
var idType string
|
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 {
|
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 {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ package business
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"kra/internal/data/swmigration"
|
||||||
"kra/pkg/database/migration"
|
"kra/pkg/database/migration"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Migrations() []migration.Step {
|
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{})
|
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
|
||||||
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
|
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
|
||||||
return migration.CreateMissingTables(db, &bindingLockPO{})
|
return migration.CreateMissingTables(db, &bindingLockPO{})
|
||||||
|
|||||||
@@ -168,14 +168,10 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
|||||||
return fmt.Errorf("reload database: %w", err)
|
return fmt.Errorf("reload database: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if databaseReady && (next.Admin.System == nil || !next.Admin.System.DisableAutoMigrate) {
|
if databaseReady {
|
||||||
if err = migrateAll(candidateDB.WithContext(ctx), d.catalog); err != nil {
|
disable := next.Admin.System != nil && next.Admin.System.DisableAutoMigrate
|
||||||
return fmt.Errorf("reload database migrations: %w", err)
|
if err = prepareConfiguredDatabase(ctx, candidateDB, next.Data.Database, d.catalog, d.logger(), configPath, disable); err != nil {
|
||||||
}
|
return fmt.Errorf("reload database upgrade: %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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), next.Admin.Storage)
|
storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), next.Admin.Storage)
|
||||||
|
|||||||
+6
-11
@@ -271,8 +271,13 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
|
|||||||
}
|
}
|
||||||
usingFallback = true
|
usingFallback = true
|
||||||
}
|
}
|
||||||
d.databaseReady.Store(!usingFallback)
|
|
||||||
d.gormDB = newReloadableDB(db)
|
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)
|
d.auditLog = newDataScopeAuditWriter(d, appLogger)
|
||||||
registerDataScopeCallbacks(db, d.enqueueDataScopeAudit)
|
registerDataScopeCallbacks(db, d.enqueueDataScopeAudit)
|
||||||
d.dbList, err = openDatabaseList(c.DatabaseList, appLogger)
|
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 {
|
for _, item := range d.dbList {
|
||||||
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
|
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 {
|
if !usingFallback {
|
||||||
storageConfig, storageErr := dataintegration.ResolveStorageConfig(db, admin.Storage)
|
storageConfig, storageErr := dataintegration.ResolveStorageConfig(db, admin.Storage)
|
||||||
if storageErr != nil {
|
if storageErr != nil {
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
drivermysql "github.com/go-sql-driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"kra/internal/config"
|
"kra/internal/config"
|
||||||
|
"kra/internal/data/swmigration"
|
||||||
"kra/pkg/database/migration"
|
"kra/pkg/database/migration"
|
||||||
"kra/pkg/module"
|
"kra/pkg/module"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MigrateDatabase opens only the configured primary database. It does not
|
// MigrateDatabase opens only the configured primary database. It does not
|
||||||
// create a database, start a scheduler, open SDK clients, or initialize users.
|
// 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) {
|
if !databaseConnectionConfigured(c) {
|
||||||
return fmt.Errorf("migration requires an existing configured database")
|
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 {
|
if !checkOnly && migrationConfig.MaxOpenConns > 0 && migrationConfig.MaxOpenConns < 2 {
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("open migration database: %w", err)
|
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 {
|
if checkOnly {
|
||||||
return checkMigratedSchema(db, catalog)
|
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
|
// A dedicated connection owns the lock even when a migration uses a
|
||||||
// transaction or temporarily changes connection-local SQL compatibility.
|
// transaction or temporarily changes connection-local SQL compatibility.
|
||||||
run := func() error {
|
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 {
|
func checkMigratedSchema(db *gorm.DB, catalog module.Catalog) error {
|
||||||
db = db.Session(&gorm.Session{NewDB: true})
|
db = db.Session(&gorm.Session{NewDB: true})
|
||||||
if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil {
|
if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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{}) }
|
||||||
+1672
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -412,6 +412,9 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var users, children int64
|
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
|
// Associated-user checks only block on users that still
|
||||||
// exist (and are not soft-deleted). Counting raw join rows would make an
|
// exist (and are not soft-deleted). Counting raw join rows would make an
|
||||||
// orphaned association impossible to clean up, which is observably
|
// orphaned association impossible to clean up, which is observably
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ func Migrations() []migration.Step {
|
|||||||
{ID: "202608210002_communication_surface", Migrate: ensureCommunicationSurface},
|
{ID: "202608210002_communication_surface", Migrate: ensureCommunicationSurface},
|
||||||
{ID: "202608210003_communication_test_surface", Migrate: ensureCommunicationTestSurface},
|
{ID: "202608210003_communication_test_surface", Migrate: ensureCommunicationTestSurface},
|
||||||
{ID: "202611030002_business_user_fields", Migrate: ensureBusinessUserFields},
|
{ID: "202611030002_business_user_fields", Migrate: ensureBusinessUserFields},
|
||||||
|
{ID: "202611060024_business_role_identity", Migrate: migrateBusinessRoles},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type authorityPO struct {
|
|||||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||||
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
|
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
|
||||||
AuthorityName string
|
AuthorityName string
|
||||||
|
BusinessLevel *int `gorm:"type:tinyint unsigned;uniqueIndex:uk_authority_business_level"`
|
||||||
ParentID *uint
|
ParentID *uint
|
||||||
DataScope int `gorm:"default:1"`
|
DataScope int `gorm:"default:1"`
|
||||||
DefaultRouter string `gorm:"default:dashboard"`
|
DefaultRouter string `gorm:"default:dashboard"`
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toBizAuthority(po authorityPO) system.Authority {
|
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) {
|
func parseOriginSetting(raw string) (map[string]any, error) {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
|||||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := catalog.MigrationSteps(); len(got) != 37 {
|
if got := catalog.MigrationSteps(); len(got) != 39 {
|
||||||
t.Fatalf("module migrations = %d, want 37", len(got))
|
t.Fatalf("module migrations = %d, want 39", len(got))
|
||||||
}
|
}
|
||||||
var menus, apis int
|
var menus, apis int
|
||||||
for _, definition := range catalog.Definitions {
|
for _, definition := range catalog.Definitions {
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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条,请缩小时间范围或筛选条件')
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { useUserStore } from '@/pinia/modules/user'
|
|||||||
import { ElLoading, ElMessage } from 'element-plus'
|
import { ElLoading, ElMessage } from 'element-plus'
|
||||||
import { emitter } from '@/utils/bus'
|
import { emitter } from '@/utils/bus'
|
||||||
import { isPasswordChangeRequiredError } from '@/utils/requestError'
|
import { isPasswordChangeRequiredError } from '@/utils/requestError'
|
||||||
|
import { readBlobBusinessMessage } from '@/utils/blobError'
|
||||||
import router from '@/router/index'
|
import router from '@/router/index'
|
||||||
|
|
||||||
const DEFAULT_REQUEST_TIMEOUT = 1000 * 60 * 10
|
const DEFAULT_REQUEST_TIMEOUT = 1000 * 60 * 10
|
||||||
@@ -189,8 +190,20 @@ const showErrorMessage = (rawMessage) => {
|
|||||||
activeErrorMessages.set(message, instance)
|
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(
|
service.interceptors.response.use(
|
||||||
(response) => {
|
async (response) => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
if (!response.config.donNotShowLoading) {
|
if (!response.config.donNotShowLoading) {
|
||||||
@@ -201,6 +214,12 @@ service.interceptors.response.use(
|
|||||||
userStore.setToken(response.headers['new-token'])
|
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') {
|
if (typeof response.data.code === 'undefined') {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
@@ -216,7 +235,7 @@ service.interceptors.response.use(
|
|||||||
|
|
||||||
return response.data.msg ? response.data : response
|
return response.data.msg ? response.data : response
|
||||||
},
|
},
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (!error.config?.donNotShowLoading) {
|
if (!error.config?.donNotShowLoading) {
|
||||||
closeLoading(error.config?.loadingOption)
|
closeLoading(error.config?.loadingOption)
|
||||||
}
|
}
|
||||||
@@ -257,6 +276,9 @@ service.interceptors.response.use(
|
|||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notified = await rejectBlobBusinessError(error.response, error)
|
||||||
|
if (notified) return Promise.reject(notified)
|
||||||
|
|
||||||
emitter.emit('show-error', {
|
emitter.emit('show-error', {
|
||||||
code: error.response.status,
|
code: error.response.status,
|
||||||
message: getErrorMessage(error)
|
message: getErrorMessage(error)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<el-alert v-if="kpiError" :title="kpiError" type="warning" :closable="false" />
|
<el-alert v-if="kpiError" :title="kpiError" type="warning" :closable="false" />
|
||||||
<div v-loading="kpiLoading" class="kpi-grid">
|
<div v-loading="kpiLoading" class="kpi-grid">
|
||||||
<article v-for="item in kpiList" :key="item.label" class="kpi-card">
|
<article v-for="item in kpiList" :key="item.label" class="kpi-card">
|
||||||
<el-tooltip :content="item.tip"><p class="kpi-label">{{ item.label }}</p></el-tooltip>
|
<p class="kpi-label"><span class="has-tip" @mouseenter="showFieldTip($event, item.tip)" @mouseleave="hideFieldTip">{{ item.label }}</span></p>
|
||||||
<p class="kpi-value">{{ item.value }}</p>
|
<p class="kpi-value">{{ item.value }}</p>
|
||||||
<p class="kpi-change" :class="{ up: item.up, down: item.down }">{{ item.change }}</p>
|
<p class="kpi-change" :class="{ up: item.up, down: item.down }">{{ item.change }}</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -188,6 +188,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<teleport to="body">
|
||||||
|
<div v-if="fieldTip.open" ref="fieldTipRef" class="field-tip" :class="{ ready: fieldTip.ready }" :style="{ left: `${fieldTip.x}px`, top: `${fieldTip.y}px` }">{{ fieldTip.text }}</div>
|
||||||
|
</teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -252,6 +255,35 @@ const overviewPeriod = ref('today')
|
|||||||
const overviewDates = ref([])
|
const overviewDates = ref([])
|
||||||
const kpiError = ref('')
|
const kpiError = ref('')
|
||||||
const kpiList = ref(overviewCards(null))
|
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 requestVersions = { summary: 0, register: 0, recharge: 0, offline: 0, low: 0, alerts: 0 }
|
||||||
const kpiLoading = ref(false)
|
const kpiLoading = ref(false)
|
||||||
const alertLoading = ref(false)
|
const alertLoading = ref(false)
|
||||||
@@ -759,6 +791,7 @@ onMounted(() => {
|
|||||||
fetchLowRechargeRank()
|
fetchLowRechargeRank()
|
||||||
})
|
})
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
|
window.addEventListener('scroll', hideFieldTipOnScroll, true)
|
||||||
themeObserver = new MutationObserver(() => { renderRegisterChart(); renderRechargeChart() })
|
themeObserver = new MutationObserver(() => { renderRegisterChart(); renderRechargeChart() })
|
||||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
})
|
})
|
||||||
@@ -769,6 +802,7 @@ onBeforeUnmount(() => {
|
|||||||
stopAlertTimer()
|
stopAlertTimer()
|
||||||
if (alertAnimTimer) clearTimeout(alertAnimTimer)
|
if (alertAnimTimer) clearTimeout(alertAnimTimer)
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
|
window.removeEventListener('scroll', hideFieldTipOnScroll, true)
|
||||||
registerChart?.dispose()
|
registerChart?.dispose()
|
||||||
rechargeChart?.dispose()
|
rechargeChart?.dispose()
|
||||||
registerChart = undefined
|
registerChart = undefined
|
||||||
@@ -861,6 +895,16 @@ onBeforeUnmount(() => {
|
|||||||
line-height: 1.4;
|
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 {
|
.kpi-value {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--dash-title);
|
color: var(--dash-title);
|
||||||
@@ -1244,3 +1288,27 @@ html.dark .alert-body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.field-tip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3000;
|
||||||
|
visibility: hidden;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1e2433;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-line;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-tip.ready {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { channelCount, channelMoney } from '../../sw/channel-display.js'
|
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 = [
|
const definitions = [
|
||||||
{ key: 'todayRegister', label: '玩家注册' },
|
{ key: 'todayRegister', label: '玩家注册', tip: '所选区间内新增注册玩家数。\n勾选右上角「身份证去重」后按实名身份证去重:同一身份证多账号计 1 人,无实名信息不计入。' },
|
||||||
{ key: 'todayActive', label: '活跃玩家' },
|
{ key: 'todayActive', label: '活跃玩家', tip: '所选区间内登录游戏的去重玩家数。\n勾选「身份证去重」后按实名身份证去重,无实名信息不计入。' },
|
||||||
{ key: 'todayPay', label: '充值金额', money: true },
|
{ key: 'todayPay', label: '充值金额', money: true, tip: '所选区间内所有渠道的充值成功金额汇总,已扣除失败 / 取消订单。' },
|
||||||
{ key: 'effectivePromote', label: '有效端' },
|
{ key: 'effectivePromote', label: '有效端' },
|
||||||
{ key: 'activePromote', label: '活跃渠道数' },
|
{ key: 'activePromote', label: '活跃渠道数', tip: '所选区间内至少产生 1 个注册或 1 笔充值的渠道数。' },
|
||||||
{ key: 'newPromote', label: '新增渠道数' },
|
{ key: 'newPromote', label: '新增渠道数', tip: '所选区间内新建立合作关系的渠道数,以渠道在系统中首次创建 / 启用时间为准。' },
|
||||||
{ key: 'lockedPromote', label: '锁定渠道', event: true },
|
{ key: 'lockedPromote', label: '锁定渠道', event: true, tip: '所选区间内被锁定的渠道数,按锁定时间落在区间内统计。\n统计的是「这段时间内发生了多少」,与渠道当前状态无关:例如 1 月锁定、2 月恢复正常,在 1 月区间里仍算锁定渠道。' },
|
||||||
{ key: 'terminatedPromote', label: '解除合作渠道', event: true }
|
{ key: 'terminatedPromote', label: '解除合作渠道', event: true, tip: '所选区间内被解除合作的渠道数,按解除合作时间落在区间内统计。\n解除合作与锁定分开展示,二者都属于被锁定的范畴。' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const overviewCards = (data) => definitions.map(definition => {
|
export const overviewCards = (data) => definitions.map(definition => {
|
||||||
@@ -23,9 +39,7 @@ export const overviewCards = (data) => definitions.map(definition => {
|
|||||||
change,
|
change,
|
||||||
up: known && changeRate > 0,
|
up: known && changeRate > 0,
|
||||||
down: known && changeRate < 0,
|
down: known && changeRate < 0,
|
||||||
tip: definition.key === 'effectivePromote'
|
tip: definition.key === 'effectivePromote' ? effectiveTip(data) : definition.tip
|
||||||
? `渠道注册后 ${data?.settings?.days ?? 30} 天内充值满 ¥${data?.settings?.amount ?? 3000},按达标日期统计。`
|
|
||||||
: definition.event ? '按所选区间内的状态变更事件统计。' : '所选时间区间内的数据,与上一等长周期比较。'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 === 'todayRegister').value, '0')
|
||||||
assert.equal(cards.find(c => c.key === 'todayActive').value, '5,998')
|
assert.equal(cards.find(c => c.key === 'todayActive').value, '5,998')
|
||||||
assert.equal(cards.find(c => c.key === 'lockedPromote').value, '未接入')
|
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(null).every(c => c.value === '—'))
|
||||||
assert.ok(overviewCards({ available: false }).every(c => c.value === '未接入'))
|
assert.ok(overviewCards({ available: false }).every(c => c.value === '未接入'))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
@click="handleCommand"
|
@click="handleCommand"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<icon-button
|
<!-- <icon-button
|
||||||
icon="lucide:settings"
|
icon="lucide:settings"
|
||||||
label="设置"
|
label="设置"
|
||||||
@click="toggleSetting"
|
@click="toggleSetting"
|
||||||
/>
|
/> -->
|
||||||
|
|
||||||
<icon-button
|
<icon-button
|
||||||
v-if="settings.header.refresh.visible"
|
v-if="settings.header.refresh.visible"
|
||||||
|
|||||||
@@ -35,11 +35,11 @@
|
|||||||
<CustomPic style="margin-top: 8px" :pic-src="scope.row.headerImg" />
|
<CustomPic style="margin-top: 8px" :pic-src="scope.row.headerImg" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column align="left" label="ID" min-width="50" prop="ID" sortable="custom" />
|
<el-table-column align="left" label="ID" min-width="90" prop="ID" sortable="custom" />
|
||||||
<el-table-column
|
<el-table-column
|
||||||
align="left"
|
align="left"
|
||||||
label="用户名"
|
label="用户名"
|
||||||
min-width="150"
|
min-width="200"
|
||||||
prop="userName"
|
prop="userName"
|
||||||
/>
|
/>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column align="left" label="启用" min-width="150">
|
<el-table-column align="left" label="启用" min-width="70">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="scope.row.enable"
|
v-model="scope.row.enable"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="performance-cards"><el-card v-for="card in cards" :key="card.label" shadow="never"><el-tooltip :content="card.tip"><span>{{ card.label }}</span></el-tooltip><strong>{{ card.value }}</strong></el-card></div>
|
<div class="performance-cards"><el-card v-for="card in cards" :key="card.label" shadow="never"><el-tooltip :content="card.tip"><span>{{ card.label }}</span></el-tooltip><strong>{{ card.value }}</strong></el-card></div>
|
||||||
<div class="kra-table-box" v-loading="loading">
|
<div class="kra-table-box" v-loading="loading">
|
||||||
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3" />
|
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3" />
|
||||||
<el-tabs v-model="tab"><el-tab-pane label="商务绩效" name="business" /><el-tab-pane label="趋势图" name="trend" /><el-tab-pane label="小组绩效" name="groups" /></el-tabs>
|
<el-tabs v-model="tab"><el-tab-pane label="商务绩效" name="business" /><el-tab-pane label="小组绩效" name="groups" /><el-tab-pane label="趋势图" name="trend" /></el-tabs>
|
||||||
<div v-if="tab === 'business'" class="matrix-toolbar"><el-select v-model="groupFilter" clearable @change="load" placeholder="全部小组" style="width:190px"><el-option v-for="group in groupOptions" :key="group.id" :label="group.name" :value="group.id" /></el-select><el-text type="info">点数字查看该商务对应周期的渠道明细</el-text></div>
|
<div v-if="tab === 'business'" class="matrix-toolbar"><el-select v-model="groupFilter" clearable @change="load" placeholder="全部小组" style="width:190px"><el-option v-for="group in groupOptions" :key="group.id" :label="group.name" :value="group.id" /></el-select><el-text type="info">点数字查看该商务对应周期的渠道明细</el-text></div>
|
||||||
<performance-scroll v-if="tab === 'business' && data" vertical>
|
<performance-scroll v-if="tab === 'business' && data" vertical>
|
||||||
<table class="matrix" :style="{ width: `${businessWidth}px` }"><colgroup><col v-for="col in businessColumns" :key="col.key" :style="{ width: `${col.width}px` }" /><template v-for="period in descendingPeriods" :key="period.key"><col v-for="metric in metrics" :key="metric.key" style="width:110px" /></template></colgroup>
|
<table class="matrix" :style="{ width: `${businessWidth}px` }"><colgroup><col v-for="col in businessColumns" :key="col.key" :style="{ width: `${col.width}px` }" /><template v-for="period in descendingPeriods" :key="period.key"><col v-for="metric in metrics" :key="metric.key" style="width:110px" /></template></colgroup>
|
||||||
@@ -27,20 +27,83 @@
|
|||||||
</table>
|
</table>
|
||||||
</performance-scroll>
|
</performance-scroll>
|
||||||
<template v-if="tab === 'groups' && data">
|
<template v-if="tab === 'groups' && data">
|
||||||
<el-text tag="p" type="info" class="mb-3">小组目标独立设置,实际为成员绩效之和;目标可能在月中调整。{{ data?.granularity === 'day' ? '当日目标 = 月目标 ÷ 当月天数;左侧展示当月目标和达成。' : '完成率只纳入已设置相应目标的月份。' }}</el-text>
|
<p v-if="isDaily" class="es-grp-note">左侧「小组 / 人数 / 目标 / 达成 / 完成率」与小组信息一起固定:<b>目标 / 达成 / 完成率 = 所选单月的绩效</b>(与月视图同源同数,目标按月设置,完成率只显示百分比不加条);右侧按日倒序展示<b>到今天为止</b>的数据(未来日期不占列),<b>每日「目标 / 实际」两列与月视图同构</b>,格内 = 开端 / 有效端 / 流水三项指标,实际列下方细条横跨两格 = 当日完成率进度条(当日目标 = 月目标 ÷ 当月天数,当日实际为当天数据,各日之和 = 当月实际);未设目标的日显示「—」;点组名前「▸」展开组员当日明细。</p>
|
||||||
|
<p v-else class="es-grp-note">左侧「小组 / 人数 / 达成 / 完成率」与小组信息一起固定:<b>达成 = 区间合计的开端 / 有效端 / 流水</b>(区间内所有月份累计,不依赖目标,未设目标的组也照常显示);完成率 = <b>有目标月份的合计实际 ÷ 合计目标</b>(三指标分行,只显示百分比不加条,达满 100% 变绿);每月「目标 / 实际」两列,格内显示开端 / 有效端 / 充值流水三项指标,<b>实际列下方的完成率条横跨「目标 / 实际」两格</b>;点组名前「▸」展开组员绩效与目标。目标按月设置,<b>当月目标可能中途调整,本表始终显示最新一次保存的值,历史月不变</b>。</p>
|
||||||
<performance-scroll>
|
<performance-scroll>
|
||||||
<table class="matrix group-matrix" :style="{ width: `${groupWidth}px` }"><colgroup><col v-for="col in groupColumns" :key="col.key" :style="{ width: `${col.width}px` }" /><template v-for="period in descendingPeriods" :key="period.key"><col style="width:155px" /><col style="width:170px" /></template></colgroup>
|
<table class="es-grp" :style="{ width: `${groupWidth}px` }">
|
||||||
<thead><tr><th v-for="col in groupColumns" :key="col.key" class="sticky" rowspan="2" :style="{ left: `${col.left}px` }">{{ col.label }}</th><th v-for="period in descendingPeriods" :key="period.key" colspan="2">{{ period.label }}</th></tr><tr><template v-for="period in descendingPeriods" :key="period.key"><th>目标</th><th>实际</th></template></tr></thead>
|
<colgroup>
|
||||||
<tbody><tr v-for="row in teamRows" :key="row.key" :class="{ 'group-heading': !row.member, 'member-row': row.member, departed: row.member?.employment === 'departed' }">
|
<col v-for="(width, index) in groupFixed" :key="index" :style="{ width: `${width}px` }" />
|
||||||
<td class="sticky" style="left:0"><button v-if="!row.member" class="expand" :aria-expanded="expanded.has(row.id)" @click="toggleGroup(row.id)">{{ expanded.has(row.id) ? '▾' : '▸' }}</button><span v-else class="tree-line">└</span><strong>{{ row.name }}</strong><el-tooltip v-if="row.overlapping" content="组员包含上下级,成员绩效相加会发生重叠"><span class="warning"> ⚠</span></el-tooltip></td>
|
<template v-for="period in groupPeriods" :key="period.key"><col style="width:104px" /><col style="width:104px" /></template>
|
||||||
<td class="sticky" :style="{ left: `${groupColumns[1].left}px` }">{{ row.member ? employmentLabels[row.member.employment] : `${row.memberCount} 人` }}</td>
|
</colgroup>
|
||||||
<td v-if="data?.granularity === 'day'" class="sticky" :style="{ left: `${groupColumns[2].left}px` }"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><span>{{ displayTarget(metric.goal, row.goals?.[data.begin.slice(0,7)]?.[metric.goal]) }}</span></div></td>
|
<thead>
|
||||||
<td class="sticky" :style="{ left: `${groupColumns[data?.granularity === 'day' ? 3 : 2].left}px` }"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><span>{{ displayMetric(metric.key, row.total[metric.key]) }}</span></div></td>
|
<tr>
|
||||||
<td class="sticky" :style="{ left: `${groupColumns.at(-1).left}px` }"><div v-for="metric in metrics" :key="metric.key" class="metric-line" :title="groupRate(row, metric) == null ? '未设置目标' : '有目标月份的实际合计 ÷ 目标合计'"><small>{{ metric.label }}</small><span>{{ rateText(groupRate(row, metric)) }}</span></div></td>
|
<th class="es-gf1" rowspan="2">小组</th>
|
||||||
<template v-for="period in descendingPeriods" :key="period.key"><td class="target-cell"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><span>{{ displayTarget(metric.goal, periodTarget(row, period, metric)) }}</span></div></td><td class="actual-cell"><div v-for="metric in metrics" :key="metric.key" class="metric-line"><small>{{ metric.label }}</small><button v-if="hasGoal(row.goals?.[period.key.slice(0,7)])" :disabled="!Number(row.periods[period.key]?.[metric.key])" @click="drill(row, period, metric.key)">{{ displayMetric(metric.key, row.periods[period.key]?.[metric.key] ?? 0) }}</button><span v-else title="未设置目标">—</span></div><div v-if="periodRate(row, period) != null" class="cross-progress"><span><i :class="{ complete: periodRate(row, period) >= 100 }" :style="{ width: `${progressWidth(periodRate(row, period))}%` }" /></span><small>{{ rateText(periodRate(row, period)) }}</small></div></td></template>
|
<th class="es-gf2" rowspan="2">人数</th>
|
||||||
</tr><tr v-if="!teamRows.length"><td :colspan="groupColumns.length + descendingPeriods.length * 2">暂无可见小组</td></tr></tbody>
|
<th v-if="isDaily" class="es-gf3" rowspan="2">目标</th>
|
||||||
|
<th :class="isDaily ? 'es-gf4' : 'es-gf3'" rowspan="2">达成</th>
|
||||||
|
<th :class="isDaily ? 'es-gf5' : 'es-gf4'" rowspan="2">完成率</th>
|
||||||
|
<th v-for="(period, index) in groupPeriods" :key="period.key" colspan="2" :class="{ 'es-wk': index > 0, 'es-today': isToday(period.key) }">{{ period.label }}</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<template v-for="(period, index) in groupPeriods" :key="`${period.key}-sub`">
|
||||||
|
<th :class="{ 'es-wk': index > 0 }">目标</th>
|
||||||
|
<th>实际</th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in teamRows" :key="row.key" :class="row.member ? ['es-grp-mem', { 'es-mem-out': row.member.employment === 'departed' }] : 'es-grp-hd'">
|
||||||
|
<td class="es-gf1" :class="{ 'es-grp-name': !row.member }">
|
||||||
|
<button v-if="!row.member" type="button" class="es-grp-arrow" :aria-expanded="expanded.has(row.id)" title="展开 / 收起组员绩效与目标" @click="toggleGroup(row.id)">{{ expanded.has(row.id) ? '▾' : '▸' }}</button>
|
||||||
|
<span v-if="row.member" class="es-grp-memname">{{ row.name }}</span>
|
||||||
|
<template v-else>{{ row.name }}</template>
|
||||||
|
</td>
|
||||||
|
<td class="es-gf2" :class="{ 'es-grp-lv': !!row.member }">{{ row.member ? levelShort(row.member.level) : row.memberCount }}</td>
|
||||||
|
<td v-if="isDaily" :class="[monthGoal(row) ? 'es-grp-cell es-grp-t' : 'es-grp-none', 'es-gf3']" :title="monthGoal(row) ? undefined : '当月未设置目标'">
|
||||||
|
<template v-if="monthGoal(row)"><div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b>{{ groupFmt(goalNumber(monthGoal(row), metric), metric.key) }}</b></div></template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</td>
|
||||||
|
<td class="es-grp-cell" :class="isDaily ? 'es-gf4' : 'es-gf3'">
|
||||||
|
<div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b :class="isDaily && monthGoal(row) ? achievedClass(row.total?.[metric.key], goalNumber(monthGoal(row), metric)) : ''">{{ groupFmt(row.total?.[metric.key], metric.key) }}</b></div>
|
||||||
|
</td>
|
||||||
|
<td :class="[rateReady(row) ? 'es-grp-cell' : 'es-grp-none', isDaily ? 'es-gf5' : 'es-gf4']" :title="rateReady(row) ? '有目标月份的合计实际 ÷ 合计目标' : (isDaily ? '当月未设置目标' : '未设置目标')">
|
||||||
|
<template v-if="rateReady(row)"><div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b :class="{ 'g-ok': metricRate(row, metric) != null && metricRate(row, metric) >= 100 }">{{ groupRateText(metricRate(row, metric)) }}</b></div></template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</td>
|
||||||
|
<template v-for="(period, index) in groupPeriods" :key="`${row.key}-${period.key}`">
|
||||||
|
<template v-if="isFutureDay(period.key)">
|
||||||
|
<td class="es-grp-none" :class="{ 'es-wk': bodyWeek(period, index) }">—</td>
|
||||||
|
<td class="es-grp-none">—</td>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="periodGoal(row, period)">
|
||||||
|
<td class="es-grp-cell es-grp-t" :class="{ 'es-wk': bodyWeek(period, index), 'es-today': isToday(period.key) }">
|
||||||
|
<div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b>{{ groupFmt(periodGoalValue(row, period, metric), metric.key) }}</b></div>
|
||||||
|
</td>
|
||||||
|
<td class="es-grp-cell" :class="{ 'es-today-r': isToday(period.key) }">
|
||||||
|
<div v-for="metric in groupMetrics" :key="metric.key" class="g3"><i>{{ metric.label }}</i><b :class="achievedClass(row.periods?.[period.key]?.[metric.key], periodGoalValue(row, period, metric))">{{ groupFmt(row.periods?.[period.key]?.[metric.key] ?? 0, metric.key) }}</b></div>
|
||||||
|
<div v-if="roundedRate(amountRate(row, period)) != null" class="es-pbar-wrap" :title="barTitle(row, period)">
|
||||||
|
<div class="es-pbar"><i :data-ok="amountRate(row, period) >= 100 ? '1' : null" :style="{ width: `${progressWidth(amountRate(row, period))}%` }" /></div>
|
||||||
|
<span class="es-pbar-pct" :class="{ 'g-ok': amountRate(row, period) >= 100 }">{{ groupRateText(amountRate(row, period)) }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<td class="es-grp-none" :class="{ 'es-wk': bodyWeek(period, index) }" title="未设置目标">—</td>
|
||||||
|
<td class="es-grp-none" title="未设置目标">—</td>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!teamRows.length"><td class="es-grp-empty" :colspan="groupFixed.length + groupPeriods.length * 2">当前范围内没有小组</td></tr>
|
||||||
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</performance-scroll>
|
</performance-scroll>
|
||||||
|
<div class="es-grp-legend">
|
||||||
|
<span v-if="isDaily">按日显示(未跨月):左侧「目标 / 达成 / 完成率」= 所选当月的绩效(与月视图同源同数,目标按月设置,完成率只显示百分比不加条);每日两列 = 当日目标 / 当日实际,格内三行 = 开端 / 有效(有效端)/ 流水(充值流水),实际列下方细条横跨「目标 / 实际」两格 = 当日完成率进度条;只显示到今天为止的日期(未来日期不占列),今天列 = 最左一列</span>
|
||||||
|
<span v-else>每月两列 = 目标 / 实际,格内三行 = 开端 / 有效(有效端)/ 流水(充值流水),实际列下方细条横跨「目标 / 实际」两格 = 当月完成率进度条;左侧「达成」= 区间合计的开端 / 有效端 / 流水、「完成率」= 有目标月份的合计完成率(只显示百分比不加条),与小组信息一起冻结,月份之间有竖线分段</span>
|
||||||
|
<span>当月({{ currentMonthNumber }} 月)为进行中:实际照常显示,并计入左侧「达成」</span>
|
||||||
|
<span>没设目标显示「—」:未设目标的组「完成率」单独一格、月视图没设目标的月份「目标 / 实际」各自一格;日视图未设目标仍显示当月实际,目标 / 完成率显示「—」</span>
|
||||||
|
<span class="accent"><b>表格可按住左键左右拖动</b>——只拖动表格内容(页面其他部分不动);表格下方常驻一条横向滚动条,点拖皆可;上下看随页面滚动</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="tab === 'trend' && data" class="trend-layout"><performance-trend :periods="data?.periods || []" :trend="data?.trend || {}" /><div><el-text type="info">共 {{ descendingPeriods.length }} {{ data?.granularity === 'day' ? '天,表内可滚动' : '个月' }}</el-text><el-table scrollbar-always-on :data="trendRows" height="421" class="mt-2"><el-table-column prop="label" :label="data?.granularity === 'day' ? '日期' : '月份'" width="90" /><el-table-column v-for="metric in metrics" :key="metric.key" :label="metric.label" min-width="100"><template #default="{ row }">{{ displayMetric(metric.key, row.values[metric.key]) }}</template></el-table-column><el-table-column label="有效率" width="95"><template #default="{ row }">{{ rateText(effectiveRate(row.values)) }}</template></el-table-column><el-table-column v-for="metric in metrics" :key="`${metric.key}-change`" :label="`${metric.label}环比`" min-width="115"><template #default="{ row }">{{ row.changes[metric.key] }}</template></el-table-column></el-table></div></div>
|
<div v-if="tab === 'trend' && data" class="trend-layout"><performance-trend :periods="data?.periods || []" :trend="data?.trend || {}" /><div><el-text type="info">共 {{ descendingPeriods.length }} {{ data?.granularity === 'day' ? '天,表内可滚动' : '个月' }}</el-text><el-table scrollbar-always-on :data="trendRows" height="421" class="mt-2"><el-table-column prop="label" :label="data?.granularity === 'day' ? '日期' : '月份'" width="90" /><el-table-column v-for="metric in metrics" :key="metric.key" :label="metric.label" min-width="100"><template #default="{ row }">{{ displayMetric(metric.key, row.values[metric.key]) }}</template></el-table-column><el-table-column label="有效率" width="95"><template #default="{ row }">{{ rateText(effectiveRate(row.values)) }}</template></el-table-column><el-table-column v-for="metric in metrics" :key="`${metric.key}-change`" :label="`${metric.label}环比`" min-width="115"><template #default="{ row }">{{ row.changes[metric.key] }}</template></el-table-column></el-table></div></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +114,8 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getPerformance, exportPerformance } from '@/api/performance'
|
import { getPerformance, exportPerformance } from '@/api/performance'
|
||||||
import { performanceMetrics as metrics, businessLevels, employmentLabels, displayMember, displayMetric, displayTarget, rateText, completionRate, progressWidth, effectiveRate, hasGoal } from './performance-display'
|
import { readBlobBusinessMessage } from '@/utils/blobError'
|
||||||
|
import { performanceMetrics as metrics, businessLevels, employmentLabels, displayMember, displayMetric, rateText, completionRate, progressWidth, effectiveRate, hasGoal } from './performance-display'
|
||||||
import PerformanceScroll from './components/PerformanceScroll.vue'
|
import PerformanceScroll from './components/PerformanceScroll.vue'
|
||||||
import PerformanceTrend from './components/PerformanceTrend.vue'
|
import PerformanceTrend from './components/PerformanceTrend.vue'
|
||||||
import PerformanceDetailDrawer from './components/PerformanceDetailDrawer.vue'
|
import PerformanceDetailDrawer from './components/PerformanceDetailDrawer.vue'
|
||||||
@@ -71,18 +135,51 @@ const descendingPeriods = computed(() => [...(data.value?.periods || [])].revers
|
|||||||
const withOffsets = columns => { let left = 0; return columns.map(col => { const result = { ...col, left }; left += col.width; return result }) }
|
const withOffsets = columns => { let left = 0; return columns.map(col => { const result = { ...col, left }; left += col.width; return result }) }
|
||||||
const businessColumns = withOffsets([{ key: 'group', label: '小组', width: 125 }, { key: 'member', label: '商务', width: 150 }, { key: 'hire', label: '入职', width: 110 }, { key: 'employment', label: '在职', width: 80 }, { key: 'open', label: '区间开端', width: 105, sort: true }, { key: 'effective', label: '区间有效端', width: 110, sort: true }, { key: 'pay', label: '区间充值流水', width: 150 }, { key: 'rate', label: '有效率', width: 95, sort: true }])
|
const businessColumns = withOffsets([{ key: 'group', label: '小组', width: 125 }, { key: 'member', label: '商务', width: 150 }, { key: 'hire', label: '入职', width: 110 }, { key: 'employment', label: '在职', width: 80 }, { key: 'open', label: '区间开端', width: 105, sort: true }, { key: 'effective', label: '区间有效端', width: 110, sort: true }, { key: 'pay', label: '区间充值流水', width: 150 }, { key: 'rate', label: '有效率', width: 95, sort: true }])
|
||||||
const businessWidth = computed(() => businessColumns.reduce((n, c) => n + c.width, 0) + descendingPeriods.value.length * 330)
|
const businessWidth = computed(() => businessColumns.reduce((n, c) => n + c.width, 0) + descendingPeriods.value.length * 330)
|
||||||
const groupColumns = computed(() => withOffsets([{ key: 'name', label: '小组 / 成员', width: 220 }, { key: 'count', label: '人数 / 在职', width: 105 }, ...(data.value?.granularity === 'day' ? [{ key: 'target', label: '目标', width: 160 }] : []), { key: 'actual', label: '达成', width: 165 }, { key: 'rate', label: '完成率', width: 145 }]))
|
const groupMetrics = [{ key: 'open', label: '开端', goal: 'open' }, { key: 'effective', label: '有效', goal: 'effective' }, { key: 'pay', label: '流水', goal: 'amount' }]
|
||||||
const groupWidth = computed(() => groupColumns.value.reduce((n, c) => n + c.width, 0) + descendingPeriods.value.length * 325)
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
const nowDate = new Date()
|
||||||
|
const isToday = key => { const [year, month, day] = String(key).split('-').map(Number); return year === nowDate.getFullYear() && month === nowDate.getMonth() + 1 && day === nowDate.getDate() }
|
||||||
|
const currentMonthNumber = nowDate.getMonth() + 1
|
||||||
|
const isDaily = computed(() => data.value?.granularity === 'day')
|
||||||
|
const currentMonthKey = `${nowDate.getFullYear()}-${pad(nowDate.getMonth() + 1)}`
|
||||||
|
const isFutureDay = key => { const [year, month, day] = String(key).split('-').map(Number); if (!year || !month || !day) return false; return new Date(year, month - 1, day) > new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate()) }
|
||||||
|
const groupPeriods = computed(() => isDaily.value ? descendingPeriods.value.filter(period => !String(period.key).startsWith(currentMonthKey) || !isFutureDay(period.key)) : descendingPeriods.value)
|
||||||
|
const groupFixed = computed(() => isDaily.value ? [110, 52, 92, 92, 92] : [110, 52, 92, 92])
|
||||||
|
const groupWidth = computed(() => groupFixed.value.reduce((sum, width) => sum + width, 0) + groupPeriods.value.length * 208)
|
||||||
|
const levelShort = level => ['', '一级', '二级', '三级'][level] || '三级'
|
||||||
|
const monthGoal = row => { const goal = row.goals?.[data.value?.begin?.slice(0, 7)]; return hasGoal(goal) ? goal : null }
|
||||||
|
const periodGoal = (row, period) => { const goal = row.goals?.[period.key.slice(0, 7)]; return hasGoal(goal) ? goal : null }
|
||||||
|
const goalNumber = (goal, metric) => goal?.[metric.goal] ?? null
|
||||||
|
const periodGoalValue = (row, period, metric) => { const goal = periodGoal(row, period); if (!goal) return null; const value = goalNumber(goal, metric); if (value == null || !isDaily.value) return value; const [year, month] = period.key.split('-').map(Number); return Number(value) / new Date(year, month, 0).getDate() }
|
||||||
|
const achievedClass = (actual, target) => target == null ? '' : Number(actual || 0) >= Number(target) ? 'g-ok' : 'g-no'
|
||||||
|
const groupFmt = (value, key) => { if (value == null) return '—'; if (key === 'pay') { const wan = Number(value) / 10000; return `¥${wan >= 100 ? Math.round(wan).toLocaleString('en-US') : Math.round(wan * 10) / 10}万` } let n = Number(value); if (n % 1 !== 0) n = Math.round(n * 10) / 10; return n.toLocaleString('en-US') }
|
||||||
|
const roundedRate = rate => rate == null ? null : Math.round(Number(rate) * 10) / 10
|
||||||
|
const groupRateText = rate => { const rounded = roundedRate(rate); return rounded == null ? '—' : `${rounded}%` }
|
||||||
|
const rateReady = row => isDaily.value ? !!monthGoal(row) : (data.value?.periods || []).some(period => hasGoal(row.goals?.[period.key.slice(0, 7)]))
|
||||||
|
const metricRate = (row, metric) => isDaily.value ? completionRate(row.total?.[metric.key], monthGoal(row) ? goalNumber(monthGoal(row), metric) : null) : groupRate(row, metric)
|
||||||
|
const amountRate = (row, period) => completionRate(row.periods?.[period.key]?.pay, periodGoalValue(row, period, groupMetrics[2]))
|
||||||
|
const barTitle = (row, period) => `${isDaily.value ? '当日完成率' : '当月完成率'} ${groupRateText(amountRate(row, period))}:实际流水 ${groupFmt(row.periods?.[period.key]?.pay, 'pay')} ÷ 目标流水 ${groupFmt(periodGoalValue(row, period, groupMetrics[2]), 'pay')}`
|
||||||
|
const bodyWeek = (period, index) => index > 0 && (!isDaily.value || new Date(`${period.key}T00:00:00`).getDay() === 1)
|
||||||
const matrixRows = computed(() => { const result = [], groups = new Map((data.value?.groups || []).map(g => [g.group.id, g])); const blocks = new Map(); for (const row of (data.value?.rows || [])) { const key = row.member.groupId || `none-${row.member.userId}`; if (!blocks.has(key)) blocks.set(key, []); blocks.get(key).push(row) } for (const [id, members] of blocks) { const group = groups.get(id); members.forEach((row, i) => result.push({ ...row, key: `person-${row.member.userId}`, kind: 'person', id: row.member.userId, name: displayMember(row.member), span: group ? i === 0 ? members.length + 1 : 0 : 1, groupName: group?.group.name, memberCount: members.length })); if (group) result.push({ key: `subtotal-${id}`, kind: 'group', id, name: `${group.group.name} 小计`, groupName: group.group.name, subtotal: true, memberIds: members.map(m => m.member.userId), total: group.uniqueTotal, periods: group.uniquePeriods }) } return result })
|
const matrixRows = computed(() => { const result = [], groups = new Map((data.value?.groups || []).map(g => [g.group.id, g])); const blocks = new Map(); for (const row of (data.value?.rows || [])) { const key = row.member.groupId || `none-${row.member.userId}`; if (!blocks.has(key)) blocks.set(key, []); blocks.get(key).push(row) } for (const [id, members] of blocks) { const group = groups.get(id); members.forEach((row, i) => result.push({ ...row, key: `person-${row.member.userId}`, kind: 'person', id: row.member.userId, name: displayMember(row.member), span: group ? i === 0 ? members.length + 1 : 0 : 1, groupName: group?.group.name, memberCount: members.length })); if (group) result.push({ key: `subtotal-${id}`, kind: 'group', id, name: `${group.group.name} 小计`, groupName: group.group.name, subtotal: true, memberIds: members.map(m => m.member.userId), total: group.uniqueTotal, periods: group.uniquePeriods }) } return result })
|
||||||
const toggleGroup = id => { const next = new Set(expanded.value); if (next.has(id)) next.delete(id); else next.add(id); expanded.value = next }
|
const toggleGroup = id => { const next = new Set(expanded.value); if (next.has(id)) next.delete(id); else next.add(id); expanded.value = next }
|
||||||
const teamRows = computed(() => (data.value?.groups || []).flatMap(group => { const row = { key: `group-${group.group.id}`, id: group.group.id, kind: 'team', name: group.group.name, total: group.total, periods: group.periods, goals: group.goals, memberCount: group.group.memberIds.length, overlapping: group.group.overlapping }; return [row, ...(expanded.value.has(group.group.id) ? group.members.map(m => ({ ...m, key: `group-${group.group.id}-person-${m.member.userId}`, id: m.member.userId, kind: 'person', name: displayMember(m.member) })) : [])] }))
|
const teamRows = computed(() => (data.value?.groups || []).flatMap(group => { const row = { key: `group-${group.group.id}`, id: group.group.id, kind: 'team', name: group.group.name, total: group.total, periods: group.periods, goals: group.goals, memberCount: group.group.memberIds.length, overlapping: group.group.overlapping }; return [row, ...(expanded.value.has(group.group.id) ? group.members.map(m => ({ ...m, key: `group-${group.group.id}-person-${m.member.userId}`, id: m.member.userId, kind: 'person', name: displayMember(m.member) })) : [])] }))
|
||||||
const periodTarget = (row, period, metric) => { const value = row.goals?.[period.key.slice(0,7)]?.[metric.goal]; if (value == null) return null; if (data.value?.granularity !== 'day') return value; const [y, m] = period.key.split('-').map(Number); return value / new Date(y, m, 0).getDate() }
|
const groupRate = (row, metric) => { let actual = 0, target = 0; const months = new Set(); for (const p of data.value?.periods || []) { const month = p.key.slice(0,7), value = row.goals?.[month]?.[metric.goal]; if (value == null) continue; actual += Number(row.periods[p.key]?.[metric.key] || 0); if (!months.has(month)) { target += Number(value); months.add(month) } } return completionRate(actual, target) }
|
||||||
const periodRate = (row, period) => completionRate(row.periods[period.key]?.pay, periodTarget(row, period, metrics[2]))
|
|
||||||
const groupRate = (row, metric) => { let actual = 0, target = 0; const months = new Set(); for (const p of data.value?.periods || []) { const month = p.key.slice(0,7), value = row.goals?.[month]?.[metric.goal]; if (value == null) continue; actual += Number(row.periods[p.key]?.[metric.key] || 0); if (!months.has(month)) { target += value; months.add(month) } } return completionRate(actual, target) }
|
|
||||||
const trendRows = computed(() => (data.value?.periods || []).map((p, i, arr) => { const values = data.value.trend[p.key], previous = i ? data.value.trend[arr[i - 1].key] : data.value.prior; return { ...p, values, changes: Object.fromEntries(metrics.map(m => [m.key, previous && Number(previous[m.key]) ? `${((Number(values[m.key]) - Number(previous[m.key])) / Number(previous[m.key]) * 100).toFixed(1)}%` : '—'])) } }).reverse())
|
const trendRows = computed(() => (data.value?.periods || []).map((p, i, arr) => { const values = data.value.trend[p.key], previous = i ? data.value.trend[arr[i - 1].key] : data.value.prior; return { ...p, values, changes: Object.fromEntries(metrics.map(m => [m.key, previous && Number(previous[m.key]) ? `${((Number(values[m.key]) - Number(previous[m.key])) / Number(previous[m.key]) * 100).toFixed(1)}%` : '—'])) } }).reverse())
|
||||||
const detailOpen = ref(false), detailQuery = ref(null), detailContext = ref(null), detailTitle = ref('')
|
const detailOpen = ref(false), detailQuery = ref(null), detailContext = ref(null), detailTitle = ref('')
|
||||||
const drill = (row, period, metric) => { detailQuery.value = { kind: row.kind, targetId: row.id, memberIds: row.memberIds?.join(','), metric, begin: period?.begin || data.value.begin, end: period?.end || data.value.end }; detailContext.value = { name: row.name, groupName: row.groupName || row.member?.groupName || (row.kind === 'group' || row.kind === 'team' ? row.name : ''), periodKey: period?.key, descendantCount: row.descendantCount, metrics: { ...(period ? row.periods[period.key] : row.total) }, settings: data.value.settings }; detailTitle.value = `${row.name} · ${period?.key || '区间'} · ${metrics.find(m => m.key === metric).label}明细`; detailOpen.value = true }
|
const drill = (row, period, metric) => { detailQuery.value = { kind: row.kind, targetId: row.id, memberIds: row.memberIds?.join(','), metric, begin: period?.begin || data.value.begin, end: period?.end || data.value.end }; detailContext.value = { name: row.name, groupName: row.groupName || row.member?.groupName || (row.kind === 'group' || row.kind === 'team' ? row.name : ''), periodKey: period?.key, descendantCount: row.descendantCount, metrics: { ...(period ? row.periods[period.key] : row.total) }, settings: data.value.settings }; detailTitle.value = `${row.name} · ${period?.key || '区间'} · ${metrics.find(m => m.key === metric).label}明细`; detailOpen.value = true }
|
||||||
const download = async () => { if (exporting.value || !data.value || !loadedParams.value || loading.value) return; exporting.value = true; try { const result = await exportPerformance({ ...loadedParams.value, groupId: tab.value === 'business' ? loadedParams.value.groupId : undefined, view: tab.value }); const blob = result instanceof Blob ? result : result.data; if (!(blob instanceof Blob) || blob.type.includes('json')) return; const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = '商务绩效统计.xlsx'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } finally { exporting.value = false } }
|
const download = async () => {
|
||||||
|
if (exporting.value || !data.value || !loadedParams.value || loading.value) return
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const result = await exportPerformance({ ...loadedParams.value, groupId: tab.value === 'business' ? loadedParams.value.groupId : undefined, view: tab.value })
|
||||||
|
const blob = result instanceof Blob ? result : result.data
|
||||||
|
const message = await readBlobBusinessMessage(blob)
|
||||||
|
if (message) { ElMessage.error(message); return }
|
||||||
|
if (!(blob instanceof Blob)) { ElMessage.error('导出失败'); return }
|
||||||
|
const url = URL.createObjectURL(blob), a = document.createElement('a')
|
||||||
|
a.href = url; a.download = '商务绩效统计.xlsx'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
} catch (error) { if (!error?.exportNotified) ElMessage.error(error?.businessMessage || '导出失败,请重试') } finally { exporting.value = false }
|
||||||
|
}
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -90,7 +187,7 @@ onMounted(load)
|
|||||||
.performance-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; margin-bottom: 16px; }
|
.performance-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; margin-bottom: 16px; }
|
||||||
.performance-cards span { color: var(--el-text-color-secondary); font-size: 13px; }
|
.performance-cards span { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
.performance-cards strong { display: block; font-size: 28px; margin-top: 12px; }
|
.performance-cards strong { display: block; font-size: 28px; margin-top: 12px; }
|
||||||
.matrix-toolbar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; flex-wrap: wrap; }
|
.matrix-toolbar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; flex-wrap: wrap;margin-top: 10px; }
|
||||||
.matrix { table-layout: fixed; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
.matrix { table-layout: fixed; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||||
.matrix th, .matrix td { box-sizing: border-box; padding: 10px; border-bottom: 1px solid var(--el-border-color-lighter); border-right: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); overflow-wrap: anywhere; text-align: right; }
|
.matrix th, .matrix td { box-sizing: border-box; padding: 10px; border-bottom: 1px solid var(--el-border-color-lighter); border-right: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); overflow-wrap: anywhere; text-align: right; }
|
||||||
.matrix th { position: sticky; top: 0; z-index: 3; height: 38px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); cursor: pointer; }
|
.matrix th { position: sticky; top: 0; z-index: 3; height: 38px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); cursor: pointer; }
|
||||||
@@ -112,20 +209,52 @@ onMounted(load)
|
|||||||
.subtotal td, tfoot td { font-weight: 600; background: var(--el-fill-color-light) !important; }
|
.subtotal td, tfoot td { font-weight: 600; background: var(--el-fill-color-light) !important; }
|
||||||
.subtotal td { border-top: 1px dashed var(--el-border-color); }
|
.subtotal td { border-top: 1px dashed var(--el-border-color); }
|
||||||
.departed { color: var(--el-text-color-placeholder); }
|
.departed { color: var(--el-text-color-placeholder); }
|
||||||
.group-heading td { background: var(--el-color-primary-light-9); border-top: 2px solid var(--el-border-color); }
|
.es-grp-note { margin: 0 0 10px; font-size: 12px; line-height: 1.7; color: var(--el-text-color-secondary); }
|
||||||
.member-row td { color: var(--el-text-color-secondary); font-size: 11px; }
|
.es-grp-note b { color: var(--el-text-color-regular); }
|
||||||
.member-row td:first-child { border-left: 3px solid var(--el-color-primary); padding-left: 22px; }
|
.es-grp { --grp-hd: #eef3ff; --grp-line: #d5def2; --grp-mem: #f6f8fc; --grp-out: #8b90a3; --grp-track: rgba(0, 0, 0, .08); border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: 12px; }
|
||||||
.tree-line { color: var(--el-color-primary); margin-right: 7px; }
|
.es-grp th, .es-grp td { box-sizing: border-box; padding: 6px; border-bottom: 1px solid var(--el-border-color-lighter); white-space: nowrap; text-align: center; background: var(--el-bg-color); vertical-align: top; }
|
||||||
.expand { width: 22px; height: 22px; border-radius: 4px !important; background: var(--el-color-primary-light-8) !important; color: var(--el-color-primary) !important; margin-right: 8px; }
|
.es-grp thead th { position: sticky; top: 0; z-index: 3; height: 30px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-weight: 600; }
|
||||||
.metric-line { display: flex; align-items: start; justify-content: space-between; gap: 6px; min-height: 27px; white-space: nowrap; }
|
.es-grp thead tr:nth-child(2) th { top: 30px; height: 26px; }
|
||||||
.group-matrix td { vertical-align: top; }
|
.es-grp .es-gf1 { position: sticky; left: 0; z-index: 3; }
|
||||||
.target-cell { padding-bottom: 35px !important; }
|
.es-grp .es-gf2 { position: sticky; left: 110px; z-index: 3; }
|
||||||
.actual-cell { position: relative; padding-bottom: 35px !important; overflow: visible; }
|
.es-grp .es-gf3 { position: sticky; left: 162px; z-index: 3; }
|
||||||
.cross-progress { position: absolute; width: 305px; bottom: 10px; right: 10px; display: flex; align-items: center; gap: 6px; z-index: 1; }
|
.es-grp .es-gf4 { position: sticky; left: 254px; z-index: 4; border-right: 1px solid var(--el-border-color); }
|
||||||
.cross-progress > span { flex: 1; background: var(--el-fill-color-darker); height: 5px; border-radius: 3px; overflow: hidden; }
|
.es-grp .es-gf5 { position: sticky; left: 346px; z-index: 4; border-right: 1px solid var(--el-border-color); }
|
||||||
.cross-progress i { display: block; height: 100%; background: var(--el-color-primary); }
|
.es-grp thead .es-gf1 { z-index: 7; }
|
||||||
.cross-progress i.complete { background: var(--el-color-success); }
|
.es-grp thead .es-gf2, .es-grp thead .es-gf3 { z-index: 6; }
|
||||||
.warning { color: var(--el-color-warning); }
|
.es-grp thead .es-gf4, .es-grp thead .es-gf5 { z-index: 7; }
|
||||||
|
.es-grp tr.es-grp-hd > td { background: var(--grp-hd); border-top: 2px solid var(--grp-line); }
|
||||||
|
.es-grp tr.es-grp-mem td { background: var(--grp-mem); font-size: 11px; }
|
||||||
|
.es-grp tr.es-grp-mem td.es-gf1 { padding-left: 20px; box-shadow: inset 3px 0 0 color-mix(in srgb, var(--el-color-primary) 50%, transparent); }
|
||||||
|
.es-grp tr.es-grp-mem.es-mem-out td { color: var(--grp-out); }
|
||||||
|
.es-grp-name { font-weight: 600; color: var(--el-text-color-primary); }
|
||||||
|
.es-grp-lv { font-size: 11px; color: var(--el-text-color-secondary); }
|
||||||
|
.es-grp-memname { position: relative; display: inline-block; padding-left: 13px; color: var(--el-text-color-regular); }
|
||||||
|
.es-grp-memname::before { content: ''; position: absolute; left: 1px; top: 50%; width: 7px; height: 9px; margin-top: -6px; border-left: 2px solid color-mix(in srgb, var(--el-color-primary) 55%, transparent); border-bottom: 2px solid color-mix(in srgb, var(--el-color-primary) 55%, transparent); border-radius: 0 0 0 3px; }
|
||||||
|
.es-grp-arrow { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; margin-right: 3px; padding: 0; border: 0; border-radius: 5px; appearance: none; background: color-mix(in srgb, var(--el-color-primary) 14%, transparent); color: var(--el-color-primary); font-size: 13px; line-height: 1; vertical-align: -6px; cursor: pointer; }
|
||||||
|
.es-grp-arrow:hover { background: color-mix(in srgb, var(--el-color-primary) 30%, transparent); color: #fff; }
|
||||||
|
.es-grp-cell { padding: 5px 7px !important; text-align: left !important; overflow: visible; }
|
||||||
|
.es-grp-none { color: var(--el-text-color-secondary); }
|
||||||
|
.es-grp-empty { padding: 26px !important; color: var(--el-text-color-secondary); }
|
||||||
|
.g3 { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; line-height: 19px; }
|
||||||
|
.g3 i { font-style: normal; font-size: 11px; color: var(--el-text-color-secondary); }
|
||||||
|
.g3 b { font-weight: 600; font-variant-numeric: tabular-nums; color: var(--el-text-color-primary); }
|
||||||
|
.es-grp-t .g3 b { color: var(--el-text-color-secondary); font-weight: 500; }
|
||||||
|
.es-grp tr.es-grp-mem .g3 b { color: var(--el-text-color-regular); font-weight: 500; }
|
||||||
|
.es-grp .g3 b.g-ok, .es-grp tr.es-grp-mem .g3 b.g-ok, .es-pbar-pct.g-ok { color: var(--el-color-success); font-weight: 600; }
|
||||||
|
.es-grp .g3 b.g-no, .es-grp tr.es-grp-mem .g3 b.g-no { color: var(--el-text-color-secondary); font-weight: 500; }
|
||||||
|
.es-grp .es-wk { border-left: 1px solid var(--el-border-color); }
|
||||||
|
.es-grp td.es-today { box-shadow: inset 1px 0 0 color-mix(in srgb, var(--el-color-primary) 55%, transparent); }
|
||||||
|
.es-grp td.es-today-r { box-shadow: inset -1px 0 0 color-mix(in srgb, var(--el-color-primary) 55%, transparent); }
|
||||||
|
.es-grp thead th.es-today { background: color-mix(in srgb, var(--el-color-primary) 14%, var(--el-fill-color-light)) !important; color: var(--el-color-primary); }
|
||||||
|
.es-pbar-wrap { display: flex; align-items: center; gap: 4px; width: calc(100% + 104px); margin: 4px 0 0 -104px; }
|
||||||
|
.es-pbar { flex: 1; height: 3px; border-radius: 2px; background: var(--grp-track); overflow: hidden; }
|
||||||
|
.es-pbar i { display: block; height: 100%; background: var(--el-color-primary); border-radius: 2px; }
|
||||||
|
.es-pbar i[data-ok="1"] { background: var(--el-color-success); }
|
||||||
|
.es-pbar-pct { flex: none; font-size: 10px; color: var(--el-text-color-secondary); font-variant-numeric: tabular-nums; }
|
||||||
|
.es-grp-legend { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 2px 0; font-size: 12px; line-height: 1.7; color: var(--el-text-color-secondary); }
|
||||||
|
.es-grp-legend .accent { color: var(--el-color-primary); }
|
||||||
|
:global(html.dark) .es-grp { --grp-hd: #202741; --grp-line: #3a4468; --grp-mem: #1b2033; --grp-out: #6b7086; --grp-track: rgba(255, 255, 255, .09); }
|
||||||
.trend-layout { display: grid; grid-template-columns: 1.32fr 1fr; gap: 22px; align-items: start; min-width: 0; }
|
.trend-layout { display: grid; grid-template-columns: 1.32fr 1fr; gap: 22px; align-items: start; min-width: 0; }
|
||||||
.trend-layout > * { min-width: 0; }
|
.trend-layout > * { min-width: 0; }
|
||||||
@media(max-width:1180px) { .trend-layout { grid-template-columns: 1fr; } }
|
@media(max-width:1180px) { .trend-layout { grid-template-columns: 1fr; } }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sw-page">
|
<div class="sw-page">
|
||||||
<div class="kra-search-box">
|
<div class="kra-search-box" style="padding-bottom: 10px;">
|
||||||
<el-form :inline="true" @submit.prevent="load">
|
<el-form :inline="true" @submit.prevent="load">
|
||||||
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="全部平台" style="width: 240px" @change="load"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="全部平台" style="width: 240px" @change="load"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||||
<el-form-item><el-radio-group :model-value="dimension" @change="changeDimension"><el-radio-button v-for="(label, value) in dimensions" :key="value" :value="value">{{ label }}排行</el-radio-button></el-radio-group></el-form-item>
|
<el-form-item><el-radio-group :model-value="dimension" @change="changeDimension"><el-radio-button v-for="(label, value) in dimensions" :key="value" :value="value">{{ label }}排行</el-radio-button></el-radio-group></el-form-item>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="sw-page">
|
<div class="sw-page">
|
||||||
<div v-if="!userId" class="kra-search-box">
|
<div v-if="!userId" class="kra-search-box">
|
||||||
<el-form :inline="true" :model="query" @submit.prevent="search">
|
<el-form :inline="true" :model="query" @submit.prevent="search">
|
||||||
<el-form-item label="所属平台"><el-select v-model="query.sourceDb" multiple clearable placeholder="全部平台" style="width: 220px" @change="search"><el-option v-for="(label, value) in platforms" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
<el-form-item label="所属平台"><el-select v-model="query.sourceDb" multiple clearable placeholder="全部平台" style="width: 220px"><el-option v-for="(label, value) in platforms" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||||
<el-form-item label="SDK商务账号"><el-input v-model="query.platformBusierAccount" clearable /></el-form-item>
|
<el-form-item label="SDK商务账号"><el-input v-model="query.platformBusierAccount" clearable /></el-form-item>
|
||||||
<el-form-item label="审核状态"><el-select v-model="query.status" clearable style="width: 130px"><el-option v-for="(label, value) in statuses" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
<el-form-item label="审核状态"><el-select v-model="query.status" clearable style="width: 130px"><el-option v-for="(label, value) in statuses" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">查询</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
|
<el-form-item><el-button type="primary" icon="search" native-type="submit">查询</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
|
||||||
|
|||||||
@@ -1,40 +1,58 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sw-page">
|
<div class="sw-page biz-mgmt">
|
||||||
<el-tabs v-model="tab"><el-tab-pane label="商务列表" name="accounts" /><el-tab-pane label="小组列表" name="groups" /><el-tab-pane v-if="canSeeBindings" label="绑定列表" name="bindings" /></el-tabs>
|
<div class="ranking-tabs" role="tablist">
|
||||||
<business-bindings v-if="tab === 'bindings'" @changed="refresh" />
|
<button type="button" class="ranking-tab" :class="{ active: tab === 'accounts' }" @click="tab = 'accounts'">商务列表</button>
|
||||||
<template v-else>
|
<button type="button" class="ranking-tab" :class="{ active: tab === 'groups' }" @click="tab = 'groups'">小组列表</button>
|
||||||
<div class="kra-search-box">
|
<button v-if="canSeeBindings" type="button" class="ranking-tab" :class="{ active: tab === 'bindings' }" @click="tab = 'bindings'">绑定列表</button>
|
||||||
<el-form inline @submit.prevent="search">
|
</div>
|
||||||
<template v-if="tab === 'accounts'">
|
<div v-if="bindingsSeen" v-show="tab === 'bindings'" class="bind-panel">
|
||||||
<el-form-item label="商务名称"><el-input v-model="filters.name" clearable placeholder="商务名称 / 负责人" /></el-form-item>
|
<div class="form-hint">注:绑定列表仅三级商务可用(只有三级商务可绑定游戏 SDK 商务);一级 / 二级商务无此入口,查看时列表为空。</div>
|
||||||
<el-form-item label="商务账号"><el-input v-model="filters.account" clearable /></el-form-item>
|
<business-bindings @changed="onBindingsChanged" />
|
||||||
<el-form-item label="商务等级"><el-select v-model="filters.level" clearable style="width:130px"><el-option v-for="(label, value) in businessLevels" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
|
</div>
|
||||||
<el-form-item label="小组"><el-select v-model="filters.group" clearable style="width:180px"><el-option label="未分组" value="0" /><el-option v-for="group in catalog.groups" :key="group.id" :label="`${group.name}(${group.memberIds.length} 人)`" :value="String(group.id)" /></el-select></el-form-item>
|
<div v-show="tab !== 'bindings'" class="biz-roster">
|
||||||
<el-form-item label="在职状态"><el-select v-model="filters.employment" clearable style="width:130px"><el-option v-for="(label, value) in employmentLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
<div class="kra-search-box biz-toolbar">
|
||||||
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
<el-form v-if="tab === 'accounts'" inline @submit.prevent="search">
|
||||||
</template>
|
<el-form-item><el-input v-model="filters.name" clearable placeholder="商务名称" style="width:160px" /></el-form-item>
|
||||||
<template v-else>
|
<el-form-item><el-input v-model="filters.account" clearable placeholder="商务账号" style="width:160px" /></el-form-item>
|
||||||
<el-form-item label="小组或成员"><el-input v-model="groupFilters.keyword" clearable /></el-form-item>
|
<el-form-item><el-select v-model="filters.level" clearable placeholder="全部等级" style="width:130px"><el-option v-for="(label, value) in businessLevels" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
|
||||||
<el-form-item label="与我的关系"><el-select v-model="groupFilters.relation" clearable style="width:160px"><el-option label="我所在的小组" value="mine" /><el-option label="下级的小组" value="sub" /></el-select></el-form-item>
|
<el-form-item><el-select v-model="filters.group" clearable placeholder="全部小组" style="width:180px"><el-option label="未分组" value="0" /><el-option v-for="group in catalog.groups" :key="group.id" :label="`${group.name}(${group.memberIds.length} 人)`" :value="String(group.id)" /></el-select></el-form-item>
|
||||||
<el-form-item label="在职状态"><el-select v-model="groupFilters.employment" clearable style="width:150px"><el-option v-for="(label, value) in employmentLabels" :key="value" :label="`含${label}`" :value="value" /></el-select></el-form-item>
|
<el-form-item><el-select v-model="filters.employment" clearable placeholder="全部在职状态" style="width:140px"><el-option v-for="(label, value) in employmentLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||||
</template>
|
<el-form-item><el-button type="primary" native-type="submit">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
|
||||||
|
<div class="spacer" />
|
||||||
|
<el-form-item>
|
||||||
|
<el-button v-if="catalog.canManage" @click="teamSettings = true">小组设置</el-button>
|
||||||
|
<el-button v-if="canAdd" type="primary" @click="openCreate">+ 添加子商务</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-form v-else inline @submit.prevent>
|
||||||
|
<el-form-item><el-input v-model="groupFilters.keyword" clearable placeholder="小组名称 / 成员姓名" style="width:180px" /></el-form-item>
|
||||||
|
<el-form-item><el-select v-model="groupFilters.relation" clearable placeholder="全部关系" style="width:150px"><el-option label="我所在的小组" value="mine" /><el-option label="下级的小组" value="sub" /></el-select></el-form-item>
|
||||||
|
<el-form-item><el-select v-model="groupFilters.employment" clearable placeholder="全部状态" style="width:130px"><el-option v-for="(label, value) in employmentLabels" :key="value" :label="`含${label}`" :value="value" /></el-select></el-form-item>
|
||||||
|
<el-form-item><el-button type="primary" native-type="submit">搜索</el-button></el-form-item>
|
||||||
|
<div class="spacer" />
|
||||||
|
<el-form-item><el-button v-if="catalog.canManage" @click="teamSettings = true">小组设置</el-button></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="kra-table-box">
|
<div class="kra-table-box">
|
||||||
<div class="kra-btn-list"><el-button v-if="tab === 'accounts' && canAdd" type="primary" @click="openCreate">添加子商务</el-button><el-button v-if="catalog.canManage" plain type="primary" @click="teamSettings = true">小组设置</el-button></div>
|
<p v-if="tab === 'groups'" class="gl-scope">
|
||||||
<el-alert v-if="error || metricError" :title="error || metricError" type="error" :closable="false" class="mb-3" />
|
<span>可见范围:</span><b>{{ meName }}</b><span>及全部下级商务</span>
|
||||||
|
<span class="gl-scope-dot">·</span>
|
||||||
|
<template v-if="myGroupName"><span>你所在小组:</span><b>{{ myGroupName }}</b></template>
|
||||||
|
<template v-else><span>你当前:</span><b>未分组</b></template>
|
||||||
|
</p>
|
||||||
|
<el-alert v-if="error || (tab === 'accounts' && metricError)" :title="error || metricError" type="error" :closable="false" class="mb-3" />
|
||||||
<el-table scrollbar-always-on v-if="tab === 'accounts'" v-loading="loading" :data="pagedMembers" row-key="userId" @sort-change="sortMembers">
|
<el-table scrollbar-always-on v-if="tab === 'accounts'" v-loading="loading" :data="pagedMembers" row-key="userId" @sort-change="sortMembers">
|
||||||
<el-table-column prop="userId" label="ID" width="80" class-name="sw-col-meta" />
|
<el-table-column prop="userId" label="ID" width="80" class-name="sw-col-meta" />
|
||||||
<el-table-column prop="groupName" label="小组" width="125" sortable="custom"><template #default="{ row }">{{ row.groupName || '—' }}</template></el-table-column>
|
|
||||||
<el-table-column prop="userName" label="商务账号" class-name="sw-col-identity" min-width="130" show-overflow-tooltip />
|
<el-table-column prop="userName" label="商务账号" class-name="sw-col-identity" min-width="130" show-overflow-tooltip />
|
||||||
<el-table-column label="商务等级" width="105"><template #default="{ row }"><level-tag :level="row.level" /></template></el-table-column>
|
<el-table-column label="商务等级" width="105"><template #default="{ row }"><level-tag :level="row.level" /></template></el-table-column>
|
||||||
<el-table-column prop="parentUserName" label="上级商务" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="parentUserName" label="上级商务" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column label="商务名称 / 负责人" min-width="150"><template #default="{ row }"><strong class="sw-business-name" :title="displayMember(row)">{{ displayMember(row) }}</strong><div class="muted">{{ row.realName || '—' }}</div></template></el-table-column>
|
<el-table-column label="姓名" min-width="150"><template #default="{ row }"><strong class="sw-business-name bz-name" :title="displayMember(row)">{{ displayMember(row) }}</strong><div class="bz-sub">{{ row.realName || '—' }}</div></template></el-table-column>
|
||||||
<el-table-column label="SDK商务" width="100"><template #default="{ row }"><el-button v-if="row.bindCount" link type="primary" @click="showBindings(row)">{{ row.bindCount }}</el-button><span v-else class="muted">0</span></template></el-table-column>
|
<el-table-column label="SDK商务" width="100"><template #default="{ row }"><el-button v-if="row.bindCount" link type="primary" @click="showBindings(row)">{{ row.bindCount }}</el-button><span v-else class="muted">0</span></template></el-table-column>
|
||||||
<el-table-column label="旗下渠道数" width="115"><template #default="{ row }"><el-button v-if="row.promoteCount != null" link type="primary" @click="showChannels(row)">{{ row.promoteCount }}</el-button><span v-else>未接入</span></template></el-table-column>
|
<el-table-column label="旗下渠道数" width="115"><template #default="{ row }"><el-button v-if="row.promoteCount != null" link type="primary" @click="showChannels(row)">{{ row.promoteCount }}</el-button><span v-else>未接入</span></template></el-table-column>
|
||||||
<el-table-column v-for="metric in ['open', 'effective']" :key="metric" :label="metric === 'open' ? '开端数' : '有效端数'" width="105"><template #header><el-tooltip :content="periodTip"><span>{{ metric === 'open' ? '开端数' : '有效端数' }}</span></el-tooltip></template><template #default="{ row }"><el-button v-if="row.metrics?.[metric]" link :class="metric" @click="details(row, metric)">{{ row.metrics[metric] }}</el-button><span v-else :class="metric">{{ metricLoading ? '…' : row.metrics ? 0 : '—' }}</span></template></el-table-column>
|
<el-table-column v-for="metric in ['open', 'effective']" :key="metric" :label="metric === 'open' ? '开端数' : '有效端数'" width="105"><template #header><el-tooltip :content="periodTip"><span>{{ metric === 'open' ? '开端数' : '有效端数' }}</span></el-tooltip></template><template #default="{ row }"><el-button v-if="row.metrics?.[metric]" link :class="metric" @click="details(row, metric)">{{ row.metrics[metric] }}</el-button><span v-else :class="metric">{{ metricLoading ? '…' : row.metrics ? 0 : '—' }}</span></template></el-table-column>
|
||||||
<el-table-column label="有效率" width="90"><template #default="{ row }"><span :class="row.rate != null && Number(row.rate) < 50 ? 'low-rate' : 'effective'">{{ row.rate == null ? '—' : `${row.rate}%` }}</span></template></el-table-column>
|
<el-table-column label="有效率" width="90"><template #default="{ row }"><span :class="row.rate != null && Number(row.rate) < 50 ? 'low-rate' : 'effective'">{{ row.rate == null ? '—' : `${row.rate}%` }}</span></template></el-table-column>
|
||||||
<el-table-column prop="hireDate" label="入职日期" width="115" class-name="sw-col-meta"><template #default="{ row }">{{ row.hireDate || '—' }}</template></el-table-column>
|
<el-table-column prop="hireDate" label="入职日期" width="115" class-name="sw-col-meta"><template #default="{ row }">{{ row.hireDate || '—' }}</template></el-table-column>
|
||||||
|
<el-table-column prop="groupName" label="小组" width="125" sortable="custom"><template #default="{ row }">{{ row.groupName || '—' }}</template></el-table-column>
|
||||||
<el-table-column label="在职状态" width="100"><template #default="{ row }"><el-tag size="small" :type="employmentTypes[row.employment]">{{ employmentLabels[row.employment] }}</el-tag></template></el-table-column>
|
<el-table-column label="在职状态" width="100"><template #default="{ row }"><el-tag size="small" :type="employmentTypes[row.employment]">{{ employmentLabels[row.employment] }}</el-tag></template></el-table-column>
|
||||||
<el-table-column label="操作" width="265" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="showPortrait(row)">画像</el-button><el-button v-if="row.canEdit" link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-if="row.canSetGoal" :link="hasGoal(row.currentGoal)" :plain="!hasGoal(row.currentGoal)" :type="hasGoal(row.currentGoal) ? 'warning' : 'primary'" @click="openGoal('person', row.userId, `${displayMember(row)}(${businessLevels[row.level]})`)">{{ goalSummary(row.currentGoal) }}</el-button></template></el-table-column>
|
<el-table-column label="操作" width="265" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="showPortrait(row)">画像</el-button><el-button v-if="row.canEdit" link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-if="row.canSetGoal" :link="hasGoal(row.currentGoal)" :plain="!hasGoal(row.currentGoal)" :type="hasGoal(row.currentGoal) ? 'warning' : 'primary'" @click="openGoal('person', row.userId, `${displayMember(row)}(${businessLevels[row.level]})`)">{{ goalSummary(row.currentGoal) }}</el-button></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -49,9 +67,9 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
<div v-if="tab === 'accounts'" class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="filteredMembers.length" :page-sizes="[10,20,50,100]" layout="total, sizes, prev, pager, next" /></div>
|
<div v-if="tab === 'accounts'" class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="filteredMembers.length" :page-sizes="[10,20,50,100]" layout="total, sizes, prev, pager, next" /></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<team-settings-dialog v-model="teamSettings" :catalog="catalog" @saved="teamsSaved" />
|
<team-settings-dialog v-model="teamSettings" :catalog="catalog" @saved="teamsSaved" />
|
||||||
<goal-drawer v-model="goalOpen" :target="goalTarget" @saved="loadMetrics" />
|
<goal-drawer v-model="goalOpen" :target="goalTarget" @saved="() => loadMetrics(true)" />
|
||||||
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :context="detailContext" :title="detailTitle" />
|
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :context="detailContext" :title="detailTitle" />
|
||||||
<el-drawer class="sw-dialog" v-model="channelsOpen" append-to-body title="旗下渠道" size="520px" destroy-on-close><business-channels :user-id="selectedID" /></el-drawer>
|
<el-drawer class="sw-dialog" v-model="channelsOpen" append-to-body title="旗下渠道" size="520px" destroy-on-close><business-channels :user-id="selectedID" /></el-drawer>
|
||||||
<el-drawer v-model="bindingsOpen" append-to-body title="SDK商务绑定详情" size="85%" destroy-on-close><business-bindings :user-id="selectedID" /></el-drawer>
|
<el-drawer v-model="bindingsOpen" append-to-body title="SDK商务绑定详情" size="85%" destroy-on-close><business-bindings :user-id="selectedID" /></el-drawer>
|
||||||
@@ -71,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useUserStore } from '@/pinia/modules/user'
|
import { useUserStore } from '@/pinia/modules/user'
|
||||||
@@ -91,11 +109,24 @@ const user = useUserStore(), buttons = useBtnAuth()
|
|||||||
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) < 3 && !!buttons['sw:business:add'])
|
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) < 3 && !!buttons['sw:business:add'])
|
||||||
const canSeeBindings = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) === 3)
|
const canSeeBindings = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) === 3)
|
||||||
const route = useRoute(), router = useRouter()
|
const route = useRoute(), router = useRouter()
|
||||||
|
const tabNames = ['accounts', 'groups', 'bindings']
|
||||||
|
const tabState = ref((() => { const value = { '#bind': 'bindings', '#grp': 'groups' }[route.hash] || (tabNames.includes(route.query.tab) ? route.query.tab : 'accounts'); return value === 'bindings' && !canSeeBindings.value ? 'accounts' : value })())
|
||||||
const tab = computed({
|
const tab = computed({
|
||||||
get: () => { const value = { '#bind': 'bindings', '#grp': 'groups' }[route.hash] || (['accounts', 'groups', 'bindings'].includes(route.query.tab) ? route.query.tab : 'accounts'); return value === 'bindings' && !canSeeBindings.value ? 'accounts' : value },
|
get: () => tabState.value,
|
||||||
set: value => router.replace({ query: { ...route.query, tab: value }, hash: '' })
|
set: value => {
|
||||||
|
const next = value === 'bindings' && !canSeeBindings.value ? 'accounts' : value
|
||||||
|
if (tabState.value === next) return
|
||||||
|
tabState.value = next
|
||||||
|
const query = { ...route.query, tab: next }
|
||||||
|
const location = router.resolve({ path: route.path, query, hash: '' })
|
||||||
|
const state = window.history.state
|
||||||
|
window.history.replaceState(state ? { ...state, current: location.fullPath } : state, '', location.href)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const catalog = ref({ groups: [], members: [], revision: 0 }), metrics = ref(null), loading = ref(false), metricLoading = ref(false), error = ref(''), metricError = ref('')
|
const catalog = ref({ groups: [], members: [], revision: 0 }), metrics = ref(null), loading = ref(false), metricLoading = ref(false), error = ref(''), metricError = ref('')
|
||||||
|
const rosterReady = ref(false), metricsReady = ref(false), bindingsSeen = ref(false)
|
||||||
|
const meName = computed(() => user.userInfo.nickName || user.userInfo.userName || '当前账号')
|
||||||
|
const myGroupName = computed(() => catalog.value.groups.find(group => group.mine)?.name || catalog.value.members.find(member => member.userId === user.userInfo.ID)?.groupName || '')
|
||||||
const filters = reactive({ name: '', account: '', level: '', group: '', employment: '' }), applied = ref({ ...filters }), groupFilters = reactive({ keyword: '', relation: '', employment: '' })
|
const filters = reactive({ name: '', account: '', level: '', group: '', employment: '' }), applied = ref({ ...filters }), groupFilters = reactive({ keyword: '', relation: '', employment: '' })
|
||||||
const page = ref(1), size = ref(10), groupOrder = ref(null), groupSort = ref({})
|
const page = ref(1), size = ref(10), groupOrder = ref(null), groupSort = ref({})
|
||||||
const metricsByID = computed(() => new Map((metrics.value?.rows || []).map(row => [row.member.userId, row])))
|
const metricsByID = computed(() => new Map((metrics.value?.rows || []).map(row => [row.member.userId, row])))
|
||||||
@@ -115,16 +146,43 @@ const groupRows = computed(() => {
|
|||||||
})
|
})
|
||||||
const periodTip = computed(() => metrics.value && metrics.value.begin <= metrics.value.end ? `${metrics.value.begin} 至 ${metrics.value.end},本人及全部下级商务,按渠道去重统计。` : '本年尚无已结束月份。')
|
const periodTip = computed(() => metrics.value && metrics.value.begin <= metrics.value.end ? `${metrics.value.begin} 至 ${metrics.value.end},本人及全部下级商务,按渠道去重统计。` : '本年尚无已结束月份。')
|
||||||
let teamVersion = 0, metricVersion = 0
|
let teamVersion = 0, metricVersion = 0
|
||||||
const loadTeams = async () => { const v = ++teamVersion; loading.value = true; error.value = ''; try { const res = await getTeams(); if (v !== teamVersion) return; if (res.code === 0) catalog.value = res.data; else error.value = res.msg || '小组及成员读取失败' } catch { if (v === teamVersion) error.value = '小组及成员读取失败,请重试' } finally { if (v === teamVersion) loading.value = false } }
|
const loadTeams = async (force = false) => {
|
||||||
const loadMetrics = async () => { const v = ++metricVersion; metricLoading.value = true; metricError.value = ''; try { const res = await getManagement({}); if (v !== metricVersion) return; if (res.code === 0) metrics.value = res.data; else metricError.value = res.msg || '绩效读取失败' } catch { if (v === metricVersion) metricError.value = '绩效读取失败,请重试' } finally { if (v === metricVersion) metricLoading.value = false } }
|
if (!force && (rosterReady.value || loading.value)) return
|
||||||
const refresh = () => Promise.all([loadTeams(), loadMetrics()])
|
const v = ++teamVersion
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await getTeams()
|
||||||
|
if (v !== teamVersion) return
|
||||||
|
if (res.code === 0) { catalog.value = res.data; rosterReady.value = true } else error.value = res.msg || '小组及成员读取失败'
|
||||||
|
} catch { if (v === teamVersion) error.value = '小组及成员读取失败,请重试' } finally { if (v === teamVersion) loading.value = false }
|
||||||
|
}
|
||||||
|
const loadMetrics = async (force = false) => {
|
||||||
|
if (!force && (metricsReady.value || metricLoading.value)) return
|
||||||
|
const v = ++metricVersion
|
||||||
|
metricLoading.value = true
|
||||||
|
metricError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await getManagement({})
|
||||||
|
if (v !== metricVersion) return
|
||||||
|
if (res.code === 0) { metrics.value = res.data; metricsReady.value = true } else metricError.value = res.msg || '绩效读取失败'
|
||||||
|
} catch { if (v === metricVersion) metricError.value = '绩效读取失败,请重试' } finally { if (v === metricVersion) metricLoading.value = false }
|
||||||
|
}
|
||||||
|
const refresh = () => Promise.all([loadTeams(true), loadMetrics(true)])
|
||||||
|
const onBindingsChanged = () => { if (rosterReady.value) loadTeams(true) }
|
||||||
|
const activate = name => {
|
||||||
|
if (name === 'bindings') { bindingsSeen.value = true; return }
|
||||||
|
loadTeams()
|
||||||
|
if (name === 'accounts') loadMetrics()
|
||||||
|
}
|
||||||
|
watch(tab, activate, { immediate: true })
|
||||||
const search = () => { applied.value = { ...filters }; page.value = 1 }
|
const search = () => { applied.value = { ...filters }; page.value = 1 }
|
||||||
const reset = () => { Object.assign(filters, { name: '', account: '', level: '', group: '', employment: '' }); search() }
|
const reset = () => { Object.assign(filters, { name: '', account: '', level: '', group: '', employment: '' }); search() }
|
||||||
const sortMembers = ({ order }) => { groupOrder.value = order; page.value = 1 }
|
const sortMembers = ({ order }) => { groupOrder.value = order; page.value = 1 }
|
||||||
const sortGroups = value => { groupSort.value = value }
|
const sortGroups = value => { groupSort.value = value }
|
||||||
const viewMembers = group => { reset(); filters.group = String(group.id); search(); tab.value = 'accounts' }
|
const viewMembers = group => { reset(); filters.group = String(group.id); search(); tab.value = 'accounts' }
|
||||||
const teamSettings = ref(false), goalOpen = ref(false), goalTarget = ref(null), detailOpen = ref(false), detailQuery = ref(null), detailContext = ref(null), detailTitle = ref('')
|
const teamSettings = ref(false), goalOpen = ref(false), goalTarget = ref(null), detailOpen = ref(false), detailQuery = ref(null), detailContext = ref(null), detailTitle = ref('')
|
||||||
const teamsSaved = value => { catalog.value = value; if (filters.group && filters.group !== '0' && !value.groups.some(g => String(g.id) === filters.group)) { filters.group = ''; search() } loadMetrics() }
|
const teamsSaved = value => { catalog.value = value; rosterReady.value = true; if (filters.group && filters.group !== '0' && !value.groups.some(g => String(g.id) === filters.group)) { filters.group = ''; search() } loadMetrics(true) }
|
||||||
const openGoal = (kind, id, name) => { goalTarget.value = { kind, id, name }; goalOpen.value = true }
|
const openGoal = (kind, id, name) => { goalTarget.value = { kind, id, name }; goalOpen.value = true }
|
||||||
const details = (row, metric) => { detailQuery.value = { kind: 'person', targetId: row.userId, metric, begin: metrics.value.begin, end: metrics.value.end }; detailContext.value = { name: displayMember(row), groupName: row.groupName, descendantCount: metricsByID.value.get(row.userId)?.descendantCount, metrics: { ...row.metrics }, settings: metrics.value.settings }; detailTitle.value = `${displayMember(row)} · ${metric === 'open' ? '开端' : '有效端'}明细`; detailOpen.value = true }
|
const details = (row, metric) => { detailQuery.value = { kind: 'person', targetId: row.userId, metric, begin: metrics.value.begin, end: metrics.value.end }; detailContext.value = { name: displayMember(row), groupName: row.groupName, descendantCount: metricsByID.value.get(row.userId)?.descendantCount, metrics: { ...row.metrics }, settings: metrics.value.settings }; detailTitle.value = `${displayMember(row)} · ${metric === 'open' ? '开端' : '有效端'}明细`; detailOpen.value = true }
|
||||||
const channelsOpen = ref(false), bindingsOpen = ref(false), portraitOpen = ref(false), selectedID = ref(0), selectedName = ref('')
|
const channelsOpen = ref(false), bindingsOpen = ref(false), portraitOpen = ref(false), selectedID = ref(0), selectedName = ref('')
|
||||||
@@ -136,12 +194,27 @@ const rules = computed(() => ({ userName: [{ required: true, message: '请输入
|
|||||||
const openCreate = () => { const today = new Date(); editing.value = false; form.value = { userName: '', realName: '', phoneNumber: '', hireDate: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`, password: '' }; memberDrawer.value = true }
|
const openCreate = () => { const today = new Date(); editing.value = false; form.value = { userName: '', realName: '', phoneNumber: '', hireDate: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`, password: '' }; memberDrawer.value = true }
|
||||||
const openEdit = row => { editing.value = true; form.value = { ...row, password: '' }; memberDrawer.value = true }
|
const openEdit = row => { editing.value = true; form.value = { ...row, password: '' }; memberDrawer.value = true }
|
||||||
const saveMember = async () => { if (saving.value || !await memberForm.value.validate().catch(() => false)) return; saving.value = true; try { const data = editing.value ? { userId: form.value.userId, realName: form.value.realName, phoneNumber: form.value.phoneNumber, hireDate: form.value.hireDate, employment: form.value.employment, password: form.value.password } : { ...form.value, nickName: form.value.realName, status: '0' }; const res = await (editing.value ? updateMember(data) : createBusiness(data)); if (res.code === 0) { ElMessage.success('保存成功'); memberDrawer.value = false; await refresh() } } finally { saving.value = false } }
|
const saveMember = async () => { if (saving.value || !await memberForm.value.validate().catch(() => false)) return; saving.value = true; try { const data = editing.value ? { userId: form.value.userId, realName: form.value.realName, phoneNumber: form.value.phoneNumber, hireDate: form.value.hireDate, employment: form.value.employment, password: form.value.password } : { ...form.value, nickName: form.value.realName, status: '0' }; const res = await (editing.value ? updateMember(data) : createBusiness(data)); if (res.code === 0) { ElMessage.success('保存成功'); memberDrawer.value = false; await refresh() } } finally { saving.value = false } }
|
||||||
onMounted(refresh)
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sw-business-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ranking-tabs { display: flex; margin: 0 0 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.ranking-tab { margin: 0 0 -1px; padding: 8px 18px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--el-text-color-secondary); font-size: 14px; line-height: 22px; cursor: pointer; }
|
||||||
|
.ranking-tab.active { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
|
||||||
|
.ranking-tab:hover { color: var(--el-color-primary); }
|
||||||
|
.form-hint { margin: 0 0 12px; font-size: 12px; line-height: 1.5; color: #86909c; }
|
||||||
|
.gl-scope { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; font-size: 12px; color: var(--el-text-color-placeholder); }
|
||||||
|
.gl-scope b { color: var(--el-text-color-secondary); font-weight: 600; }
|
||||||
|
.gl-scope-dot { opacity: .45; margin: 0 2px; }
|
||||||
|
.biz-mgmt.sw-page .biz-toolbar { padding-bottom: 16px; }
|
||||||
|
.biz-mgmt.sw-page .biz-toolbar :deep(.el-form) { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; width: 100%; }
|
||||||
|
.biz-mgmt.sw-page .biz-toolbar :deep(.el-form-item) { margin: 0; }
|
||||||
|
.biz-toolbar .spacer { flex: 1 1 12px; }
|
||||||
|
.biz-mgmt.sw-page .biz-roster :deep(.el-table) { font-size: 12px; }
|
||||||
|
.biz-mgmt.sw-page .biz-roster :deep(th.el-table__cell),
|
||||||
|
.biz-mgmt.sw-page .biz-roster :deep(td.el-table__cell) { padding: 8px 0; }
|
||||||
|
.sw-business-name { display: block; overflow: hidden; color: var(--el-text-color-primary); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.bz-sub { color: var(--el-text-color-placeholder); font-size: 11px; line-height: 1.4; }
|
||||||
.muted { color: var(--el-text-color-secondary); font-size: 12px; }
|
.muted { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
.open { color: var(--el-color-primary); }
|
.open { color: var(--el-color-primary); font-weight: 600; }
|
||||||
.effective { color: var(--el-color-success); }
|
.effective { color: var(--el-color-success); font-weight: 600; }
|
||||||
.low-rate { color: var(--el-color-warning); }
|
.low-rate { color: var(--el-color-warning); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { useUserStore } from '@/pinia/modules/user'
|
import { useUserStore } from '@/pinia/modules/user'
|
||||||
import { useBtnAuth } from '@/utils/btnAuth'
|
import { useBtnAuth } from '@/utils/btnAuth'
|
||||||
import { listLedger, exportLedger, listLedgerGames } from '@/api/ledger'
|
import { listLedger, exportLedger, listLedgerGames } from '@/api/ledger'
|
||||||
|
import { readBlobBusinessMessage } from '@/utils/blobError'
|
||||||
import { channelMoney } from '../channel-display'
|
import { channelMoney } from '../channel-display'
|
||||||
import { createGameOptionsLoader } from '../game-options-loader'
|
import { createGameOptionsLoader } from '../game-options-loader'
|
||||||
import { remoteSort, sortDescription } from '../table-display'
|
import { remoteSort, sortDescription } from '../table-display'
|
||||||
@@ -121,10 +122,13 @@ const download = async () => {
|
|||||||
try {
|
try {
|
||||||
const result = await exportLedger(props.kind, params)
|
const result = await exportLedger(props.kind, params)
|
||||||
const blob = result instanceof Blob ? result : result.data
|
const blob = result instanceof Blob ? result : result.data
|
||||||
if (!(blob instanceof Blob)) return
|
const message = await readBlobBusinessMessage(blob)
|
||||||
if (blob.type.includes('json')) return
|
if (message) { ElMessage.error(message); return }
|
||||||
|
if (!(blob instanceof Blob)) { ElMessage.error('导出失败'); return }
|
||||||
const url = URL.createObjectURL(blob), link = document.createElement('a')
|
const url = URL.createObjectURL(blob), link = document.createElement('a')
|
||||||
link.href = url; link.download = spend.value ? '充值明细.xlsx' : '注册明细.xlsx'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
link.href = url; link.download = spend.value ? '充值明细.xlsx' : '注册明细.xlsx'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
} catch (error) {
|
||||||
|
if (!error?.exportNotified) ElMessage.error(error?.businessMessage || '导出失败,请重试')
|
||||||
} finally { exporting.value = false }
|
} finally { exporting.value = false }
|
||||||
}
|
}
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-drawer class="sw-dialog" v-model="visible" append-to-body :title="title" size="min(1100px,96vw)" destroy-on-close>
|
<el-drawer class="sw-dialog performance-detail-drawer" v-model="visible" append-to-body :title="title" size="min(1100px,96vw)" destroy-on-close>
|
||||||
<el-descriptions class="detail-summary" :column="2" border>
|
<el-descriptions class="detail-summary" :column="2" border>
|
||||||
<el-descriptions-item label="统计范围">{{ context?.name || '所选商务' }}<div class="detail-scope">{{ scopeText }}</div></el-descriptions-item>
|
<el-descriptions-item label="统计范围">{{ context?.name || '所选商务' }}<div class="detail-scope">{{ scopeText }}</div></el-descriptions-item>
|
||||||
<el-descriptions-item :label="periodLabel">{{ context?.periodKey || `${query?.begin || '—'} 至 ${query?.end || '—'}` }}</el-descriptions-item>
|
<el-descriptions-item :label="periodLabel">{{ context?.periodKey || `${query?.begin || '—'} 至 ${query?.end || '—'}` }}</el-descriptions-item>
|
||||||
@@ -8,8 +8,9 @@
|
|||||||
<el-descriptions-item label="有效端数 / 有效率">{{ displayMetric('effective', context?.metrics?.effective) }} / {{ rateText(effectiveRate(context?.metrics)) }}</el-descriptions-item>
|
<el-descriptions-item label="有效端数 / 有效率">{{ displayMetric('effective', context?.metrics?.effective) }} / {{ rateText(effectiveRate(context?.metrics)) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="区间充值流水">{{ displayMetric('pay', context?.metrics?.pay) }}</el-descriptions-item>
|
<el-descriptions-item label="区间充值流水">{{ displayMetric('pay', context?.metrics?.pay) }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mt-3" />
|
<el-alert v-if="error" :title="error" type="error" :closable="false" class="detail-alert" />
|
||||||
<el-table scrollbar-always-on v-loading="loading" :data="rows" class="mt-3" :row-key="row => `${row.sourceDb}:${row.id}:${row.contributorId || 0}`">
|
<div class="detail-table-wrap">
|
||||||
|
<el-table scrollbar-always-on height="100%" v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}:${row.contributorId || 0}`">
|
||||||
<el-table-column prop="sourceDbLabel" label="所属平台" width="100" />
|
<el-table-column prop="sourceDbLabel" label="所属平台" width="100" />
|
||||||
<el-table-column v-if="query?.kind === 'team'" prop="contributorName" label="计入成员" min-width="140" />
|
<el-table-column v-if="query?.kind === 'team'" prop="contributorName" label="计入成员" min-width="140" />
|
||||||
<el-table-column prop="account" label="渠道名称" min-width="150"><template #default="{ row }">{{ row.name || row.account || '—' }}</template></el-table-column>
|
<el-table-column prop="account" label="渠道名称" min-width="150"><template #default="{ row }">{{ row.name || row.account || '—' }}</template></el-table-column>
|
||||||
@@ -20,8 +21,9 @@
|
|||||||
<el-table-column label="有效端" width="90"><template #default="{ row }"><el-tag :type="row.qualifiedAt ? 'success' : 'info'">{{ row.qualifiedAt ? '是' : '否' }}</el-tag></template></el-table-column>
|
<el-table-column label="有效端" width="90"><template #default="{ row }"><el-tag :type="row.qualifiedAt ? 'success' : 'info'">{{ row.qualifiedAt ? '是' : '否' }}</el-tag></template></el-table-column>
|
||||||
<el-table-column label="达标日期" width="125"><template #default="{ row }">{{ row.qualifiedAt ? dateText(row.qualifiedAt) : '未达标' }}</template></el-table-column>
|
<el-table-column label="达标日期" width="125"><template #default="{ row }">{{ row.qualifiedAt ? dateText(row.qualifiedAt) : '未达标' }}</template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
</div>
|
||||||
<el-text class="detail-hint" type="info">{{ detailHint }}</el-text>
|
<el-text class="detail-hint" type="info">{{ detailHint }}</el-text>
|
||||||
<el-pagination v-model:current-page="page" v-model:page-size="size" class="mt-4" :total="total" :page-sizes="[20,50,100]" layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" />
|
<el-pagination v-model:current-page="page" v-model:page-size="size" class="detail-pager" :total="total" :page-sizes="[20,50,100]" layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" />
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -46,5 +48,16 @@ onDeactivated(() => { visible.value = false; cancel() })
|
|||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.detail-scope { margin-top: 4px; font-size: 12px; color: var(--el-text-color-secondary); }
|
.detail-scope { margin-top: 4px; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.detail-hint { display: block; margin-top: 12px; line-height: 1.7; }
|
.detail-alert { flex-shrink: 0; margin-top: 12px; }
|
||||||
|
.detail-table-wrap { flex: 1; min-height: 0; margin-top: 12px; }
|
||||||
|
.detail-hint { display: block; flex-shrink: 0; margin-top: 12px; line-height: 1.7; }
|
||||||
|
.detail-pager { flex-shrink: 0; margin-top: 16px; }
|
||||||
|
</style>
|
||||||
|
<!-- 抽屉被 teleport 到 body,scoped 样式到不了抽屉容器 -->
|
||||||
|
<style>
|
||||||
|
.performance-detail-drawer .el-drawer__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const thumbWidth = computed(() => Math.max(36, width.value * width.value / Math.
|
|||||||
const thumbLeft = computed(() => maximum.value ? left.value / maximum.value * Math.max(0, width.value - thumbWidth.value) : 0)
|
const thumbLeft = computed(() => maximum.value ? left.value / maximum.value * Math.max(0, width.value - thumbWidth.value) : 0)
|
||||||
let pointer, trackPointer, observer, suppressClick = false, resetTimer
|
let pointer, trackPointer, observer, suppressClick = false, resetTimer
|
||||||
const measure = () => { if (!viewport.value) return; width.value = viewport.value.clientWidth; scrollWidth.value = viewport.value.scrollWidth; left.value = viewport.value.scrollLeft }
|
const measure = () => { if (!viewport.value) return; width.value = viewport.value.clientWidth; scrollWidth.value = viewport.value.scrollWidth; left.value = viewport.value.scrollLeft }
|
||||||
const start = event => { if (event.button !== 0 || event.target.closest('input,textarea,select,.el-select')) return; pointer = { x: event.clientX, y: event.clientY, left: viewport.value.scrollLeft, top: viewport.value.scrollTop, id: event.pointerId } }
|
const start = event => { if (event.button !== 0 || event.target.closest('input,textarea,select,.el-select,.es-grp-arrow,button,a')) return; pointer = { x: event.clientX, y: event.clientY, left: viewport.value.scrollLeft, top: viewport.value.scrollTop, id: event.pointerId } }
|
||||||
const move = event => { if (!pointer) return; const dx = event.clientX - pointer.x, dy = event.clientY - pointer.y; if (!dragging.value && Math.max(Math.abs(dx), props.vertical ? Math.abs(dy) : 0) <= 5) return; dragging.value = true; suppressClick = true; viewport.value.setPointerCapture(event.pointerId); event.preventDefault(); viewport.value.scrollLeft = pointer.left - dx; if (props.vertical) viewport.value.scrollTop = pointer.top - dy }
|
const move = event => { if (!pointer) return; const dx = event.clientX - pointer.x, dy = event.clientY - pointer.y; if (!dragging.value && Math.max(Math.abs(dx), props.vertical ? Math.abs(dy) : 0) <= 5) return; dragging.value = true; suppressClick = true; viewport.value.setPointerCapture(event.pointerId); event.preventDefault(); viewport.value.scrollLeft = pointer.left - dx; if (props.vertical) viewport.value.scrollTop = pointer.top - dy }
|
||||||
const end = event => { if (viewport.value?.hasPointerCapture(event.pointerId)) viewport.value.releasePointerCapture(event.pointerId); pointer = null; dragging.value = false; clearTimeout(resetTimer); resetTimer = setTimeout(() => { suppressClick = false }, 100) }
|
const end = event => { if (viewport.value?.hasPointerCapture(event.pointerId)) viewport.value.releasePointerCapture(event.pointerId); pointer = null; dragging.value = false; clearTimeout(resetTimer); resetTimer = setTimeout(() => { suppressClick = false }, 100) }
|
||||||
const captureClick = event => { if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false } }
|
const captureClick = event => { if (suppressClick) { event.preventDefault(); event.stopPropagation(); suppressClick = false } }
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { listAnalytics, exportChannelAnalytics } from '@/api/analytics'
|
import { listAnalytics, exportChannelAnalytics } from '@/api/analytics'
|
||||||
|
import { readBlobBusinessMessage } from '@/utils/blobError'
|
||||||
import { listLedgerGames } from '@/api/ledger'
|
import { listLedgerGames } from '@/api/ledger'
|
||||||
import { channelCount } from '../channel-display'
|
import { channelCount } from '../channel-display'
|
||||||
import { createGameOptionsLoader } from '../game-options-loader'
|
import { createGameOptionsLoader } from '../game-options-loader'
|
||||||
@@ -92,11 +93,12 @@ const download = async () => {
|
|||||||
exporting.value = true
|
exporting.value = true
|
||||||
try {
|
try {
|
||||||
const response = await exportChannelAnalytics(loadedQuery.value), blob = response instanceof Blob ? response : response.data
|
const response = await exportChannelAnalytics(loadedQuery.value), blob = response instanceof Blob ? response : response.data
|
||||||
if (!(blob instanceof Blob)) return
|
const message = await readBlobBusinessMessage(blob)
|
||||||
if (blob.type.includes('json')) { const data = JSON.parse(await blob.text()); ElMessage.error(data.msg || '导出失败'); return }
|
if (message) { ElMessage.error(message); return }
|
||||||
|
if (!(blob instanceof Blob)) { ElMessage.error('导出失败'); return }
|
||||||
const url = URL.createObjectURL(blob), link = document.createElement('a')
|
const url = URL.createObjectURL(blob), link = document.createElement('a')
|
||||||
link.href = url; link.download = '渠道数据统计.xlsx'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
link.href = url; link.download = '渠道数据统计.xlsx'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
} catch { ElMessage.error('导出失败,请重试') } finally { exporting.value = false }
|
} catch (error) { if (!error?.exportNotified) ElMessage.error(error?.businessMessage || '导出失败,请重试') } finally { exporting.value = false }
|
||||||
}
|
}
|
||||||
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
|
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
|
||||||
const sortChanged = event => { Object.assign(query, remoteSort(event, '')); return search() }
|
const sortChanged = event => { Object.assign(query, remoteSort(event, '')); return search() }
|
||||||
|
|||||||
@@ -1,65 +1,206 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog class="sw-dialog" v-model="visible" append-to-body title="小组设置" width="min(820px, 95vw)" :close-on-click-modal="false" destroy-on-close>
|
<el-dialog class="sw-dialog team-settings-dialog" v-model="visible" append-to-body align-center title="小组设置" width="760px" :close-on-click-modal="false" destroy-on-close header-class="gm-dialog-head" body-class="gm-dialog-body" footer-class="gm-dialog-foot">
|
||||||
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-3" />
|
<p v-if="error" class="gm-error">{{ error }}</p>
|
||||||
<div class="team-editor" v-loading="saving">
|
<div class="gm-body" v-loading="saving">
|
||||||
<aside>
|
<div class="gm-left">
|
||||||
<div v-for="group in draft" :key="keyOf(group)" class="team-entry" :class="{ active: selected === keyOf(group) }">
|
<div class="gm-head"><span>小组</span><button type="button" class="gm-link" @click="startAdd">+ 新建组</button></div>
|
||||||
<button @click="selected = keyOf(group)">{{ group.name }} <small>{{ group.memberIds.length }} 人</small></button>
|
<div v-if="adding" class="gm-add">
|
||||||
<el-dropdown v-if="group.canEdit" trigger="click"><el-button link aria-label="小组操作">⋯</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item @click="rename(group)">重命名</el-dropdown-item><el-dropdown-item @click="remove(group)">删除小组</el-dropdown-item></el-dropdown-menu></template></el-dropdown>
|
<input ref="addInput" v-model="newName" maxlength="10" placeholder="组名(1-10 字)" @keyup.enter="confirmAdd" @keyup.esc="cancelAdd">
|
||||||
|
<button type="button" class="gm-mini primary" @click="confirmAdd">确定</button>
|
||||||
|
<button type="button" class="gm-mini ghost" @click="cancelAdd">取消</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="ungrouped" :class="{ active: selected === 'none' }" @click="selected = 'none'">未分组 <small>{{ ungrouped.length }} 人</small></button>
|
<div class="gm-tree">
|
||||||
<el-button class="mt-3" plain type="primary" @click="add">新建小组</el-button>
|
<div v-if="!draft.length" class="gm-empty">还没有小组,点右上角「+ 新建组」</div>
|
||||||
</aside>
|
<div v-for="group in draft" :key="keyOf(group)" class="gm-grp-item" :class="{ active: selected === keyOf(group) }" @click="selected = keyOf(group)">
|
||||||
<section>
|
<span class="gm-grp-name">{{ group.name }}</span>
|
||||||
<div class="team-title"><strong>{{ active?.name || '未分组' }}</strong><el-input v-model="keyword" clearable placeholder="搜索商务名称或账号" /></div>
|
<span class="gm-cnt">{{ group.memberIds.length }} 人</span>
|
||||||
<el-alert v-if="overlap" title="组员应当平级,当前选择包含上下级商务。小组绩效按成员相加时会有重叠。" type="warning" :closable="false" class="mb-3" />
|
<span v-if="group.canEdit" class="ops" @click.stop>
|
||||||
<el-alert v-if="active?.partial && !active.canEdit" title="此组包含权限范围外成员,不能重命名或删除。" type="info" :closable="false" class="mb-3" />
|
<button type="button" class="gm-op" @click="rename(group)">重命名</button>
|
||||||
<el-empty v-if="!members.length" description="没有匹配的商务" :image-size="64" />
|
<button type="button" class="gm-op del" @click="remove(group)">删除</button>
|
||||||
<div v-for="member in members" :key="member.userId" class="member-choice">
|
</span>
|
||||||
<el-checkbox :model-value="active?.memberIds.includes(member.userId) || false" :disabled="!active || !active.canEdit" @change="toggle(member.userId, $event)">{{ displayMember(member) }}</el-checkbox>
|
</div>
|
||||||
<span>{{ businessLevels[member.level] }} · {{ member.parentUserName || '无上级' }}</span>
|
<div class="gm-un">
|
||||||
<small>{{ groupOf(member.userId)?.name || '未分组' }}</small>
|
<div class="gm-grp-item" :class="{ active: selected === 'none' }" @click="selected = 'none'">未分组<span class="gm-cnt">{{ ungrouped.length }} 人</span></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
<div class="gm-right">
|
||||||
|
<div class="gm-head"><span>{{ rightTitle }}</span></div>
|
||||||
|
<div class="gm-search"><input v-model="keyword" type="text" placeholder="搜索商务名称"></div>
|
||||||
|
<div class="gm-mem">
|
||||||
|
<div v-if="peerText" class="gm-peer-warn">{{ peerText }}</div>
|
||||||
|
<p v-if="active?.partial && !active.canEdit" class="gm-note">此组包含权限范围外成员,不能重命名或删除。</p>
|
||||||
|
<div v-if="selected !== 'none' && !active" class="gm-empty">请先在左侧选择一个组</div>
|
||||||
|
<div v-else-if="selected === 'none' && !ungrouped.length" class="gm-empty">所有商务都已分组</div>
|
||||||
|
<div v-else-if="!members.length" class="gm-empty">没有匹配的商务</div>
|
||||||
|
<template v-else>
|
||||||
|
<label v-for="member in members" :key="member.userId" class="gm-mem-row" :class="{ disabled: !canCheck }">
|
||||||
|
<input type="checkbox" :checked="!!active?.memberIds.includes(member.userId)" :disabled="!canCheck" @change="toggle(member.userId, $event.target.checked)">
|
||||||
|
<span>{{ displayMember(member) }}</span>
|
||||||
|
<span class="j">{{ member.parentUserName || '一级组织' }}</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer><el-button :disabled="saving" @click="reset">恢复默认</el-button><el-button :disabled="saving" @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
<template #footer>
|
||||||
|
<span class="gm-foot-note">一个商务只能属于一个组;取消勾选后回到「未分组」</span>
|
||||||
|
<el-button :disabled="saving" @click="reset">恢复默认</el-button>
|
||||||
|
<el-button :disabled="saving" @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { saveTeams } from '@/api/performance'
|
import { saveTeams } from '@/api/performance'
|
||||||
import { displayMember, businessLevels } from '../performance-display'
|
import { displayMember } from '../performance-display'
|
||||||
const props = defineProps({ modelValue: Boolean, catalog: { type: Object, default: () => ({ groups: [], members: [], revision: 0 }) } })
|
const props = defineProps({ modelValue: Boolean, catalog: { type: Object, default: () => ({ groups: [], members: [], revision: 0 }) } })
|
||||||
const emit = defineEmits(['update:modelValue', 'saved'])
|
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||||
const visible = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value) })
|
const visible = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value) })
|
||||||
const draft = ref([]), selected = ref('none'), keyword = ref(''), saving = ref(false), error = ref('')
|
const draft = ref([]), selected = ref('none'), keyword = ref(''), saving = ref(false), error = ref('')
|
||||||
|
const adding = ref(false), newName = ref(''), addInput = ref(null)
|
||||||
let temporaryID = 0
|
let temporaryID = 0
|
||||||
const keyOf = group => String(group.id || group.temporaryID)
|
const keyOf = group => String(group.id || group.temporaryID)
|
||||||
const active = computed(() => draft.value.find(group => keyOf(group) === selected.value))
|
const active = computed(() => draft.value.find(group => keyOf(group) === selected.value))
|
||||||
const groupOf = id => draft.value.find(group => group.memberIds.includes(id))
|
const groupOf = id => draft.value.find(group => group.memberIds.includes(id))
|
||||||
const ungrouped = computed(() => props.catalog.members.filter(member => !groupOf(member.userId)))
|
const ungrouped = computed(() => props.catalog.members.filter(member => !groupOf(member.userId)))
|
||||||
const members = computed(() => (active.value ? props.catalog.members : ungrouped.value).filter(member => `${displayMember(member)} ${member.userName}`.toLowerCase().includes(keyword.value.toLowerCase())))
|
const members = computed(() => {
|
||||||
const overlap = computed(() => { const ids = new Set(active.value?.memberIds || []); return props.catalog.members.some(m => ids.has(m.userId) && String(m.idPath || '').split(',').some(id => ids.has(Number(id)))) })
|
const source = selected.value === 'none' ? ungrouped.value : props.catalog.members
|
||||||
watch(visible, value => { if (value) { draft.value = props.catalog.groups.map(g => ({ ...g, memberIds: [...g.memberIds] })); selected.value = draft.value.length ? keyOf(draft.value[0]) : 'none'; keyword.value = ''; error.value = '' } })
|
const kw = keyword.value.trim().toLowerCase()
|
||||||
const namePrompt = async (title, value = '') => { try { const res = await ElMessageBox.prompt('组名为 1 至 10 个字', title, { inputValue: value, inputValidator: text => text?.trim() && [...text.trim()].length <= 10 || '请输入 1 至 10 个字的组名' }); return res.value.trim() } catch { return null } }
|
return source.filter(member => !kw || `${displayMember(member)} ${member.userName || ''}`.toLowerCase().includes(kw))
|
||||||
const add = async () => { const name = await namePrompt('新建小组'); if (!name) return; const group = { id: 0, temporaryID: `new-${++temporaryID}`, name, memberIds: [], canEdit: true }; draft.value.push(group); selected.value = keyOf(group) }
|
})
|
||||||
const rename = async group => { const name = await namePrompt('重命名小组', group.name); if (name) group.name = name }
|
const canCheck = computed(() => !!active.value?.canEdit)
|
||||||
const remove = async group => { try { await ElMessageBox.confirm(`删除后 ${group.memberIds.length} 位成员将回到未分组,成员账号和历史数据保留。`, '删除小组', { type: 'warning' }) } catch { return } draft.value = draft.value.filter(g => g !== group); selected.value = draft.value.length ? keyOf(draft.value[0]) : 'none' }
|
const rightTitle = computed(() => {
|
||||||
const toggle = (id, checked) => { for (const group of draft.value) group.memberIds = group.memberIds.filter(value => value !== id); if (checked && active.value) active.value.memberIds.push(id) }
|
if (selected.value === 'none') return `未分组 · ${ungrouped.value.length} 人`
|
||||||
const reset = async () => { try { await ElMessageBox.confirm('默认不预设小组,将清空当前可管理小组,成员回到未分组。点击保存后生效。', '恢复默认', { type: 'warning' }) } catch { return } draft.value = draft.value.filter(g => !g.canEdit); selected.value = 'none' }
|
return active.value ? `${active.value.name} · ${active.value.memberIds.length} 人` : '选择左侧小组'
|
||||||
const save = async () => { if (saving.value) return; saving.value = true; error.value = ''; try { const res = await saveTeams({ revision: props.catalog.revision, groups: draft.value.map(g => ({ id: g.id, name: g.name, memberIds: g.memberIds })) }); if (res.code !== 0) { error.value = res.msg || '保存失败'; return } ElMessage.success('小组设置已保存'); emit('saved', res.data); visible.value = false } catch { error.value = '小组设置保存失败,请重试' } finally { saving.value = false } }
|
})
|
||||||
|
const peerConflicts = computed(() => {
|
||||||
|
const ids = new Set(active.value?.memberIds || [])
|
||||||
|
if (!ids.size) return []
|
||||||
|
const byId = new Map(props.catalog.members.map(member => [Number(member.userId), member]))
|
||||||
|
const pairs = []
|
||||||
|
for (const member of props.catalog.members) {
|
||||||
|
if (!ids.has(member.userId)) continue
|
||||||
|
const parentId = String(member.idPath || '').split(',').map(Number).filter(id => ids.has(id)).at(-1)
|
||||||
|
const parent = byId.get(parentId)
|
||||||
|
if (parent) pairs.push(`${displayMember(parent)} 与 ${displayMember(member)}`)
|
||||||
|
}
|
||||||
|
return pairs
|
||||||
|
})
|
||||||
|
const peerText = computed(() => {
|
||||||
|
const pairs = peerConflicts.value
|
||||||
|
if (!pairs.length) return ''
|
||||||
|
const more = pairs.length > 3 ? ` 等 ${pairs.length} 组` : ''
|
||||||
|
return `⚠ 组员应当平级:${pairs.slice(0, 3).join('、')}${more} 是上下级关系,放在同一个组会让团队绩效重复计算(只提示,不拦截)。`
|
||||||
|
})
|
||||||
|
watch(visible, value => {
|
||||||
|
if (!value) return
|
||||||
|
draft.value = props.catalog.groups.map(group => ({ ...group, memberIds: [...group.memberIds] }))
|
||||||
|
const first = draft.value.find(group => group.memberIds.length) || draft.value[0]
|
||||||
|
selected.value = first ? keyOf(first) : 'none'
|
||||||
|
keyword.value = ''
|
||||||
|
error.value = ''
|
||||||
|
adding.value = false
|
||||||
|
newName.value = ''
|
||||||
|
})
|
||||||
|
const startAdd = async () => { adding.value = true; newName.value = ''; await nextTick(); addInput.value?.focus() }
|
||||||
|
const cancelAdd = () => { adding.value = false; newName.value = '' }
|
||||||
|
const confirmAdd = () => {
|
||||||
|
const name = newName.value.trim()
|
||||||
|
if (!name) { ElMessage.warning('请输入组名'); return }
|
||||||
|
if ([...name].length > 10) { ElMessage.warning('组名最多 10 个字'); return }
|
||||||
|
const group = { id: 0, temporaryID: `new-${++temporaryID}`, name, memberIds: [], canEdit: true }
|
||||||
|
draft.value.push(group)
|
||||||
|
selected.value = keyOf(group)
|
||||||
|
cancelAdd()
|
||||||
|
ElMessage.success(`已新建组「${name}」`)
|
||||||
|
}
|
||||||
|
const rename = async group => {
|
||||||
|
try {
|
||||||
|
const res = await ElMessageBox.prompt('组名为 1 至 10 个字', '重命名小组', { inputValue: group.name, inputValidator: text => text?.trim() && [...text.trim()].length <= 10 || '请输入 1 至 10 个字的组名' })
|
||||||
|
group.name = res.value.trim()
|
||||||
|
} catch { /* 取消重命名 */ }
|
||||||
|
}
|
||||||
|
const remove = async group => {
|
||||||
|
try { await ElMessageBox.confirm(`删除后组内 ${group.memberIds.length} 人将回到「未分组」,成员账号和历史数据保留。确认删除?`, '删除组', { type: 'warning' }) } catch { return }
|
||||||
|
draft.value = draft.value.filter(item => item !== group)
|
||||||
|
selected.value = draft.value.length ? keyOf(draft.value[0]) : 'none'
|
||||||
|
ElMessage.success('组已删除')
|
||||||
|
}
|
||||||
|
const toggle = (id, checked) => {
|
||||||
|
for (const group of draft.value) group.memberIds = group.memberIds.filter(value => value !== id)
|
||||||
|
if (checked && active.value) active.value.memberIds.push(id)
|
||||||
|
}
|
||||||
|
const reset = async () => {
|
||||||
|
try { await ElMessageBox.confirm('默认不预设小组,将清空当前可管理小组,成员回到未分组。点击保存后生效。', '恢复默认', { type: 'warning' }) } catch { return }
|
||||||
|
draft.value = draft.value.filter(group => !group.canEdit)
|
||||||
|
selected.value = draft.value.length ? keyOf(draft.value[0]) : 'none'
|
||||||
|
ElMessage.success('已恢复默认分组,记得点保存')
|
||||||
|
}
|
||||||
|
const save = async () => {
|
||||||
|
if (saving.value) return
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await saveTeams({ revision: props.catalog.revision, groups: draft.value.map(group => ({ id: group.id, name: group.name, memberIds: group.memberIds })) })
|
||||||
|
if (res.code !== 0) { error.value = res.msg || '保存失败'; return }
|
||||||
|
ElMessage.success('小组设置已保存')
|
||||||
|
emit('saved', res.data)
|
||||||
|
visible.value = false
|
||||||
|
} catch { error.value = '小组设置保存失败,请重试' } finally { saving.value = false }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.team-editor { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 20px; min-height: 340px; }
|
.gm-error { margin: 0; padding: 8px 16px; font-size: 12px; color: var(--el-color-danger); background: var(--el-color-danger-light-9); }
|
||||||
aside { border-right: 1px solid var(--el-border-color-light); padding-right: 16px; }
|
.gm-body { display: flex; height: 430px; }
|
||||||
.team-entry { display: flex; align-items: center; border-radius: 5px; }
|
.gm-left { width: 240px; flex-shrink: 0; border-right: 1px solid var(--el-border-color-lighter); display: flex; flex-direction: column; }
|
||||||
.team-entry button, .ungrouped { background: none; border: none; color: var(--el-text-color-primary); text-align: left; padding: 12px 8px; cursor: pointer; width: 100%; }
|
.gm-right { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.active { background: var(--el-color-primary-light-9) !important; }
|
.gm-head { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; border-bottom: 1px solid var(--el-border-color-lighter); font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
small, .member-choice span { color: var(--el-text-color-secondary); font-size: 12px; }
|
.gm-link { border: 0; background: none; color: var(--el-color-primary); font-size: 12px; padding: 0 2px; cursor: pointer; }
|
||||||
.team-title { display: flex; gap: 12px; align-items: center; margin-bottom: 15px; }
|
.gm-link:hover { opacity: .8; }
|
||||||
.team-title .el-input { flex: 1; }
|
.gm-add { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--el-border-color-lighter); background: var(--el-fill-color-light); }
|
||||||
.member-choice { display: flex; align-items: center; gap: 10px; padding: 7px 0; flex-wrap: wrap; }
|
.gm-add input { flex: 1; min-width: 0; }
|
||||||
.member-choice small { margin-left: auto; }
|
.gm-search { padding: 8px 12px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
@media(max-width:600px) { .team-editor { grid-template-columns: 1fr; } aside { border-right: 0; } }
|
.gm-search input, .gm-add input { width: 100%; height: 32px; padding: 0 10px; border: 1px solid var(--el-border-color); border-radius: 4px; background: var(--el-bg-color); color: var(--el-text-color-primary); font-size: 13px; outline: none; box-sizing: border-box; }
|
||||||
|
.gm-search input:focus, .gm-add input:focus { border-color: var(--el-color-primary); }
|
||||||
|
.gm-mini { height: 28px; padding: 0 10px; border-radius: 4px; border: 1px solid transparent; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
||||||
|
.gm-mini.primary { background: var(--el-color-primary); color: #fff; }
|
||||||
|
.gm-mini.ghost { background: transparent; color: var(--el-text-color-regular); border-color: var(--el-border-color); }
|
||||||
|
.gm-tree, .gm-mem { flex: 1; overflow-y: auto; }
|
||||||
|
.gm-tree { padding: 6px 8px; }
|
||||||
|
.gm-mem { padding: 6px 12px; }
|
||||||
|
.gm-grp-item { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 6px 8px; margin-bottom: 2px; border-radius: 4px; font-size: 13px; color: var(--el-text-color-primary); cursor: pointer; }
|
||||||
|
.gm-grp-item:hover { background: var(--el-fill-color-light); }
|
||||||
|
.gm-grp-item.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); }
|
||||||
|
.gm-grp-item .ops { margin-left: auto; display: flex; gap: 2px; opacity: 0; pointer-events: none; }
|
||||||
|
.gm-grp-item:hover .ops, .gm-grp-item:focus-within .ops { opacity: 1; pointer-events: auto; }
|
||||||
|
.gm-grp-name { min-width: 0; }
|
||||||
|
.gm-op { border: 0; background: none; color: var(--el-text-color-placeholder); font-size: 11px; padding: 0 3px; cursor: pointer; }
|
||||||
|
.gm-op:hover { color: var(--el-color-primary); }
|
||||||
|
.gm-op.del:hover { color: var(--el-color-danger); }
|
||||||
|
.gm-cnt { color: var(--el-text-color-placeholder); font-weight: 400; font-size: 11px; }
|
||||||
|
.gm-un { margin-top: 6px; border-top: 1px dashed var(--el-border-color); padding-top: 6px; }
|
||||||
|
.gm-peer-warn { margin: 8px 0 4px; padding: 8px 10px; border-radius: 4px; font-size: 11px; line-height: 1.6; color: var(--el-color-warning); background: var(--el-color-warning-light-9); border: 1px solid color-mix(in srgb, var(--el-color-warning) 25%, transparent); }
|
||||||
|
.gm-note { margin: 8px 0 4px; font-size: 12px; line-height: 1.5; color: var(--el-text-color-secondary); }
|
||||||
|
.gm-mem-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 4px; font-size: 12px; color: var(--el-text-color-primary); cursor: pointer; }
|
||||||
|
.gm-mem-row:hover { background: var(--el-fill-color-light); }
|
||||||
|
.gm-mem-row.disabled { cursor: default; }
|
||||||
|
.gm-mem-row input { margin: 0; accent-color: var(--el-color-primary); }
|
||||||
|
.gm-mem-row .j { color: var(--el-text-color-placeholder); font-size: 11px; margin-left: auto; white-space: nowrap; }
|
||||||
|
.gm-empty { color: var(--el-text-color-placeholder); font-size: 12px; padding: 26px 0; text-align: center; }
|
||||||
|
.gm-foot-note { margin-right: auto; font-size: 12px; color: var(--el-text-color-placeholder); text-align: left; line-height: 1.5; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.gm-body { flex-direction: column; height: auto; max-height: 70vh; }
|
||||||
|
.gm-left { width: 100%; max-height: 220px; border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
.team-settings-dialog.el-dialog { max-width: 96vw; padding: 0; }
|
||||||
|
.team-settings-dialog .gm-dialog-head.el-dialog__header { margin: 0; padding: 14px 48px 14px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.team-settings-dialog .gm-dialog-head .el-dialog__title { font-size: 16px; font-weight: 600; }
|
||||||
|
.team-settings-dialog .el-dialog__headerbtn { top: 6px; right: 6px; width: 36px; height: 36px; }
|
||||||
|
.team-settings-dialog .gm-dialog-body { padding: 0; }
|
||||||
|
.team-settings-dialog .gm-dialog-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -186,8 +186,8 @@
|
|||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="px-4 py-2">
|
<div class="px-4 py-2">
|
||||||
<p v-if="scope.row.errorMsg" class="text-red-500 break-all">错误:{{ scope.row.errorMsg }}</p>
|
<p v-if="scope.row.errorMsg" class="text-red-500 break-all">错误:{{ scope.row.errorMsg }}</p>
|
||||||
<p v-if="scope.row.output" class="break-all whitespace-pre-wrap mt-1">输出:{{ scope.row.output }}</p>
|
<p v-if="scope.row.output" class="break-all mt-1">输出:{{ scope.row.output }}</p>
|
||||||
<p v-if="!scope.row.errorMsg && !scope.row.output" class="text-gray-400">该次执行未记录详细输出,请结合执行状态、开始时间及耗时判断。</p>
|
<p v-if="!scope.row.errorMsg && !scope.row.output" class="text-gray-400">无详情</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -940,8 +940,9 @@ const downloadJson = async (row) => {
|
|||||||
|
|
||||||
ElMessage.success('下载成功')
|
ElMessage.success('下载成功')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.exportNotified) return
|
||||||
console.error('下载失败:', error)
|
console.error('下载失败:', error)
|
||||||
ElMessage.error('下载失败')
|
ElMessage.error(error?.businessMessage || '下载失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user