feat: automate business upgrades and sync reviewed frontend

This commit is contained in:
Yvan
2026-11-06 15:48:35 +08:00
parent 7df74bc8e8
commit 9fccd804a3
58 changed files with 5689 additions and 259 deletions
+3
View File
@@ -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/
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 单独配置匹配域名的证书。
+5 -1
View File
@@ -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
View File
@@ -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)
+79
View File
@@ -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
}
+401
View File
@@ -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 ;
+297
View File
@@ -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)
}
}
+6 -2
View File
@@ -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;
-- 十、数据核验通过后归档旧框架表,登记完成状态并释放迁移锁。 -- 十、数据核验通过后归档旧框架表,登记完成状态并释放迁移锁。
+1 -1
View File
@@ -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 {
-4
View File
@@ -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()
} }
+11
View File
@@ -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 {
+5 -3
View File
@@ -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"`
+2 -17
View File
@@ -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 {
+5 -2
View File
@@ -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)
+1 -36
View File
@@ -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)
}
} }
} }
} }
+1 -1
View File
@@ -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 -1
View File
@@ -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{})
+4 -8
View File
@@ -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
View File
@@ -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 {
+48 -2
View File
@@ -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 {
+317
View File
@@ -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
}
+155
View File
@@ -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)
}
}
+119
View File
@@ -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
}
+426
View File
@@ -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
}
+609
View File
@@ -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)
}
}
}
+38
View File
@@ -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{}) }
File diff suppressed because it is too large Load Diff
+544
View File
@@ -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
}
]
}
+3
View File
@@ -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
+119
View File
@@ -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")
}
}
+1
View File
@@ -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},
} }
} }
+1
View File
@@ -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"`
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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 {
+26
View File
@@ -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 ''
}
}
+28
View File
@@ -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条,请缩小时间范围或筛选条件')
})
+24 -2
View File
@@ -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 === '未接入'))
}) })
+2 -2
View File
@@ -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"
+3 -3
View File
@@ -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"
+163 -34
View File
@@ -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 -1
View File
@@ -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>
+109 -36
View File
@@ -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>
+6 -2
View File
@@ -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() }
+185 -44
View File
@@ -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>
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -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 || '下载失败')
} }
} }