fix: 完成商务迁移复核与接口优化并清理历史资料

This commit is contained in:
Yvan
2026-11-04 14:45:02 +08:00
parent 5a96be6a79
commit 297e80827d
176 changed files with 6803 additions and 6002 deletions
+49
View File
@@ -0,0 +1,49 @@
# 商务验证工具
仅保留仍用于当前 sw8/sw9 验证的工具。旧 sw7、固定端口 18000、重复页面夹具脚本已归档;接口边界回归优先运行相应 Go 测试。
## 环境
- Python 3.10+;数据库工具需要 PyMySQL、PyYAML,Excel 对比需要 openpyxl。
- 页面检查使用 Node 和 Playwright;通过 `SW_PLAYWRIGHT_MODULE` 指定已有模块路径。
- `--runtime` 必须指向仓库外的私有目录。配置、令牌、实际响应、导出文件和 SQL 日志不得提交。
- 旧 Java 连接 sw8,新 Go 连接 sw9。运行命令时明确传入端口,避免误连日常服务。任务保持停用。
通用输入:`legacy-<identity>.token`、`new-<identity>.token`、`legacy-client.json`、`id-maps.json`;写入测试还需要专门创建的三级测试身份及对应凭据。不要把业务正常账号当作可随意清理的测试身份。
## 工具用途
| 工具 | 用途与写入范围 |
|---|---|
| start-sw-rehearsal.ps1 | 从私有配置启动本机 Go 服务,临时注入 SDK 兼容参数,日志留在 runtime |
| sw_database_rehearsal.py | 数据库盘点、备份、迁移及验证;同时提供其他脚本依赖的 connect/query |
| sw_api_compare.py | 新旧读取接口逐字段对比;输入 case JSON 明确路径、参数、允许的协议转换 |
| sw_write_verify.py | 规则与预警状态流程测试;提供其他写入工具的 Rehearsal 支持 |
| sw_account_create_verify.py | 创建临时下级、验证层级/角色并清理;不删除预先存在的同名账号 |
| sw_account_write_verify.py | 测试账号资料/状态并恢复原值 |
| sw_preference_compare.py | 四种包含下级/身份证去重组合,结束恢复偏好 |
| sw_export_compare.py | 新旧注册和充值 Excel 逐单元格对比 |
| sw_sdk_write_verify.py | 独立平台夹具上的绑定、重提、解绑与渠道创建;受控 OA 审核 |
| sw_report_rehearsal.py | 指定日报分区备份、比较和恢复 |
| sw_run_report_tasks.py | 要求已有日期匹配的备份;创建停用的临时计划、手工执行并清理计划 |
| sw_endpoint_benchmark.py | 接口耗时、完整响应摘要及 SQL 采集,只读 |
| sw_endpoint_parity.py | 优化前后接口及偏好矩阵对比;即使 HTTP 恢复失败也尝试还原数据库偏好 |
| sw_dashboard_layout_smoke.cjs | 激活分页、偏好和榜单布局检查,截图留在指定私有目录 |
先通过各 Python 工具的 `--help` 查看必需参数。日报、预警及写入工具会改动测试数据;使用其备份/恢复流程,并核对最终表计数与任务状态。
## 读取对比示例
```powershell
python scripts/sw_api_compare.py --runtime D:/private/sw-review --cases D:/private/sw-review/cases.json --old-url http://127.0.0.1:18080 --new-url http://127.0.0.1:19004 --output D:/private/sw-review/result.json
```
`--recompare` 只比较已保存响应;`--refresh-target` 使用已保存的同参数旧响应、重新请求新服务。报告会标记 `source_capture_reused`,不能把快照对比描述为双方重新实测。
本地 MySQL 回归只接受 `SW_TEST_MYSQL_DSN` 指向 `127.0.0.1` 的隔离实例:
```powershell
go test ./internal/data/business -run MySQL -count=1
```
这些测试创建并清理自己的随机数据库,不在 sw8/sw9 上运行测试建表和删除。
+37
View File
@@ -0,0 +1,37 @@
param(
[Parameter(Mandatory = $true)][string]$RuntimePath,
[string]$ConfigName = 'config-next.yaml',
[string]$BinaryPath = '.cache/sw89/sw-new-complete.exe'
)
$ErrorActionPreference = 'Stop'
$workspaceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$runtimeRoot = (Resolve-Path -LiteralPath $RuntimePath).Path
$configPath = (Resolve-Path -LiteralPath (Join-Path $runtimeRoot $ConfigName)).Path
if (-not [System.IO.Path]::IsPathRooted($BinaryPath)) {
$BinaryPath = Join-Path $workspaceRoot $BinaryPath
}
$binary = (Resolve-Path -LiteralPath $BinaryPath).Path
$compat = Get-Content -LiteralPath (Join-Path $runtimeRoot 'sdk-compatibility.json') -Raw | ConvertFrom-Json
$previous = @{}
$settings = @{
SW_SDK_AUTH_CODE = $compat.AUTH_CODE
SW_SDK_MIGRATE_AUTH_CODE = $compat.MIGRATE_AUTH_CODE
SW_SDK_TABLE_PREFIX = $compat.TABLE_PREFIX
}
try {
foreach ($name in $settings.Keys) {
$previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
[Environment]::SetEnvironmentVariable($name, $settings[$name], 'Process')
}
$process = Start-Process -FilePath $binary -ArgumentList @('-conf', ('"' + $configPath + '"')) `
-WorkingDirectory $workspaceRoot -WindowStyle Hidden `
-RedirectStandardOutput (Join-Path $runtimeRoot 'rehearsal.stdout.log') `
-RedirectStandardError (Join-Path $runtimeRoot 'rehearsal.stderr.log') -PassThru
$process.Id | Set-Content -LiteralPath (Join-Path $runtimeRoot 'rehearsal.pid')
Write-Output "Rehearsal process: $($process.Id)"
} finally {
foreach ($name in $previous.Keys) {
[Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process')
}
}
+73
View File
@@ -0,0 +1,73 @@
"""在 sw8/sw9 用专用二级账号创建临时三级账号,核对层级和角色后清理。"""
import argparse
from pathlib import Path
from sw_write_verify import Rehearsal
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--old-url", default="http://127.0.0.1:18080")
parser.add_argument("--new-url", default="http://127.0.0.1:19002")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]):
raise ValueError("测试凭据必须位于仓库外")
run = Rehearsal(args)
username = "mig_recheck_" + run.suffix
password = (args.runtime / "synthetic-test-password.txt").read_text(encoding="utf-8").strip()
source_roles = run.query("SELECT role_id FROM sw8.sys_user_role WHERE user_id=%s", (run.owner("legacy", 3),))
expected_roles = run.query("SELECT sys_authority_authority_id id FROM sw9.sys_user_authority WHERE sys_user_id=%s", (run.owner("new", 3),))
created_sides = set()
try:
run.check("测试角色存在", len(source_roles) == 1 and len(expected_roles) == 1)
for side, database, table, column in (("legacy", "sw8", "sys_user", "user_name"), ("new", "sw9", "sys_users", "username")):
run.check("临时账号不存在 " + side, not run.query(f"SELECT 1 FROM {database}.{table} WHERE {column}=%s", (username,)))
payload = {"userName": username, "password": password, "nickName": "迁移复核临时账号", "realName": "迁移复核临时账号"}
run.call("legacy", "testlevel2", "POST", "/system/user", {**payload, "roleIds": [str(source_roles[0]["role_id"])]})
created_sides.add("sw8")
run.call("new", "testlevel2", "POST", "/sw/business", {**payload, "parentId": 0, "level": 1, "authorityId": 888})
created_sides.add("sw9")
old = run.query("SELECT user_id,level,parent_id,id_path,status FROM sw8.sys_user WHERE user_name=%s", (username,))[0]
new = run.query("SELECT id,level,parent_id,id_path,business_status,authority_id,enable FROM sw9.sys_users WHERE username=%s", (username,))[0]
run.check("新旧创建三级且挂靠当前二级", old["level"] == new["level"] == 3 and str(old["parent_id"]) == str(run.owner("legacy", 2)) and new["parent_id"] == run.owner("new", 2))
path = ",".join(run.mapping.get(part, part) for part in str(old["id_path"]).split(","))
run.check("祖先路径按用户映射一致", path == new["id_path"])
run.check("默认状态正常且角色不能由请求提权", str(old["status"]) == str(new["business_status"]) == "0" and new["enable"] == 1 and new["authority_id"] == expected_roles[0]["id"] and new["authority_id"] != 888)
old_roles = run.query("SELECT role_id FROM sw8.sys_user_role WHERE user_id=%s", (old["user_id"],))
new_roles = run.query("SELECT sys_authority_authority_id id FROM sw9.sys_user_authority WHERE sys_user_id=%s", (new["id"],))
run.check("关联角色正确", old_roles == source_roles and new_roles == expected_roles)
values = []
for database, user_id in (("sw8", old["user_id"]), ("sw9", new["id"])):
values.append(run.query(f"SELECT include_sub_promote,idcard_dedup FROM {database}.sys_user_preference WHERE user_id=%s", (user_id,)))
run.check("默认统计偏好一致", values[0] == values[1] == [{"include_sub_promote": 1, "idcard_dedup": 1}])
finally:
# 只清理本次唯一名称创建的临时账号及其角色/偏好,不触碰原有测试链。
for database, table, id_col, name_col in (("sw8", "sys_user", "user_id", "user_name"), ("sw9", "sys_users", "id", "username")):
if database not in created_sides:
continue
rows = run.query(f"SELECT {id_col} id FROM {database}.{table} WHERE {name_col}=%s", (username,))
for row in rows:
user_id = row["id"]
run.db.begin()
try:
run.query(f"DELETE FROM {database}.sys_user_preference WHERE user_id=%s", (user_id,))
if database == "sw8":
for relation in ("sys_user_role", "sys_user_post"):
run.query(f"DELETE FROM sw8.{relation} WHERE user_id=%s", (user_id,))
else:
run.query("DELETE FROM sw9.sys_user_authority WHERE sys_user_id=%s", (user_id,))
run.query(f"DELETE FROM {database}.{table} WHERE {id_col}=%s AND {name_col}=%s", (user_id, username))
run.db.commit()
except Exception:
run.db.rollback()
raise
run.check("临时账号清理完成 " + database, not run.query(f"SELECT 1 FROM {database}.{table} WHERE {name_col}=%s", (username,)))
run.db.close()
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
"""核对专用二级测试账号的资料更新、四种商务状态及恢复,不修改真实商务账号。"""
import argparse
import json
import time
from pathlib import Path
from sw_write_verify import Rehearsal
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--config", type=Path, default=Path("configs/config.yaml"))
parser.add_argument("--old-url", default="http://127.0.0.1:18080")
parser.add_argument("--new-url", default="http://127.0.0.1:19001")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
run = Rehearsal(args)
account = run.users[1]
if not account["username"].startswith("mig_verify_"):
raise ValueError("仅允许修改本轮创建的专用测试账号")
originals = {
"legacy": run.query("SELECT real_name,status,reject_reason,update_time FROM sw8.sys_user WHERE user_id=%s AND user_name=%s", (account["source_id"], account["username"]))[0],
"new": run.query("SELECT real_name,business_status,enable,reject_reason,updated_at FROM sw9.sys_users WHERE id=%s AND username=%s", (account["target_id"], account["username"]))[0],
}
(args.runtime / "test-account-before-write.json").write_text(json.dumps(originals, default=str, indent=2), encoding="utf-8")
def update(side, status, name, reason):
payload = {"userId": account["source_id" if side == "legacy" else "target_id"], "userName": account["username"], "realName": name,
"status": str(status), "rejectReason": reason or "", "roleIds": ["2087435436573081601"]}
return run.call(side, "admin", "PUT", "/system/user" if side == "legacy" else "/sw/business", payload)
try:
started = time.monotonic()
for index, status in enumerate((0, 3, 2, 1, 0)):
if index == 4:
# 旧控制器对相同请求体有五秒重复提交限制。
time.sleep(max(0, 5.2 - (time.monotonic() - started)))
name = "迁移核对二级资料更新"
reason = "迁移核对驳回" if status == 2 else ""
for side in ("legacy", "new"):
update(side, status, name, reason)
path = "/system/user/" + str(account["source_id"]) if side == "legacy" else "/sw/business/" + str(account["target_id"])
detail = run.call(side, "admin", "GET", path)["data"]
if side == "legacy":
detail = detail["user"]
run.check(f"资料及状态 {status} {side}", detail["realName"] == name and str(detail["status"]) == str(status) and detail["level"] == 2)
enabled = run.query("SELECT enable FROM sw9.sys_users WHERE id=%s", (account["target_id"],))[0]["enable"]
run.check(f"商务状态 {status} 与启停一致", enabled == (1 if status == 0 else 0))
replies = {side: run.call(side, "testlevel2", "GET", run.prefix(side) + "/user/preference", success=False) for side in ("legacy", "new")}
if status != 0:
run.check(f"状态 {status} 拒绝旧会话", replies["new"]["code"] != 0,
legacy_session_allowed=replies["legacy"]["code"] == 200, accepted_change="新系统每次请求核对商务状态,阻止停用/待审/驳回账号沿用旧会话")
else:
run.check("正常账号会话可用", replies["new"]["code"] == 0 and replies["legacy"]["code"] == 200)
finally:
for side in ("legacy", "new"):
original = originals[side]
update(side, original["status" if side == "legacy" else "business_status"], original["real_name"], original["reject_reason"])
old, new = originals["legacy"], originals["new"]
run.query("UPDATE sw8.sys_user SET real_name=%s,status=%s,reject_reason=%s,update_time=%s WHERE user_id=%s AND user_name=%s",
(old["real_name"], old["status"], old["reject_reason"], old["update_time"], account["source_id"], account["username"]))
run.query("UPDATE sw9.sys_users SET real_name=%s,business_status=%s,enable=%s,reject_reason=%s,updated_at=%s WHERE id=%s AND username=%s",
(new["real_name"], new["business_status"], new["enable"], new["reject_reason"], new["updated_at"], account["target_id"], account["username"]))
actual = {
"legacy": run.query("SELECT real_name,status,reject_reason,update_time FROM sw8.sys_user WHERE user_id=%s", (account["source_id"],))[0],
"new": run.query("SELECT real_name,business_status,enable,reject_reason,updated_at FROM sw9.sys_users WHERE id=%s", (account["target_id"],))[0],
}
run.check("测试账号原始资料和状态已恢复", actual == originals)
run.db.close()
if __name__ == "__main__":
main()
-42
View File
@@ -1,42 +0,0 @@
"""查询运行实例的渠道激活接口,仅执行只读请求。"""
import argparse
import json
import urllib.parse
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
checks = []
paths = [
("/sw/promoteActivePool/bindList?pageNum=1&pageSize=10", 0),
("/sw/promoteActivePool/bindList?platformNames=lx,hw&statuses=reached", 0),
("/sw/promoteActivePool/bindList?" + urllib.parse.urlencode({"statuses": "已达标", "busierAccount": "does-not-exist"}), 0),
("/sw/promoteActivePool/bindSpendDetail?id=0", 7),
]
for path, expected in paths:
request = urllib.request.Request(args.base_url + path, headers={"x-token": token})
with urllib.request.urlopen(request, timeout=30) as response:
body = json.load(response)
item = {"method": "GET", "path": path, "http": response.status, "code": body.get("code"),
"msg": body.get("msg"), "passed": response.status == 200 and body.get("code") == expected}
if isinstance(body.get("data"), dict) and "rows" in body["data"]:
item.update(total=body["data"]["total"], available=body["data"].get("available"))
checks.append(item)
print(json.dumps(item, ensure_ascii=False))
report = {"scope": "当前 sw7 运行实例,只读请求;OA 未接入场景明确返回 available=false。完整 OA 与流水数据通过两个隔离数据库的 TCP HTTP 测试。",
"checks": checks, "passed": all(item["passed"] for item in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if not report["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
-75
View File
@@ -1,75 +0,0 @@
// 核对渠道激活页面的真实渲染;OA 数据与流水查询使用受控响应。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
;(async () => {
const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim()
const browser = await chromium.launch({ channel: 'chrome', headless: true })
try {
const context = await browser.newContext({ viewport: { width: 1500, height: 1000 } })
await context.addInitScript(value => localStorage.setItem('token', value), token)
const page = await context.newPage()
const errors = []
const queries = []
let unavailable = false
const rows = ['pending', 'in_progress', 'reached', 'rejected', 'unbound'].map((status, i) => ({
id: String(i + 1), promoteAccount: `测试渠道-${i + 1}`, busierAccount: '测试商务', platformName: 'lx', platformTitle: '龙翔',
bindAt: ['pending', 'rejected'].includes(status) ? null : '2026-11-01 00:00:00',
unbindAt: status === 'unbound' ? '2026-11-03 12:00:00' : null,
endAt: ['reached', 'unbound'].includes(status) ? '2026-11-03 12:00:00' : null,
accumAmount: '30.30', status, statusText: { pending: '待审核', in_progress: '进行中', reached: '已达标', rejected: '已驳回', unbound: '已解绑' }[status]
}))
page.on('pageerror', error => errors.push(error.message))
await page.route('http://127.0.0.1:8080/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
const api = url.pathname.slice(4)
if (api === '/sw/promoteActivePool/bindList') {
queries.push(url.search)
return route.fulfill({ json: { code: 0, data: { rows: unavailable ? [] : rows, total: unavailable ? 0 : rows.length, available: !unavailable } } })
}
if (api === '/sw/promoteActivePool/bindSpendDetail') {
if (url.searchParams.get('id') === '1') return route.fulfill({ json: { code: 7, msg: '审核通过后可查看', data: {} } })
const row = rows.find(item => item.id === url.searchParams.get('id'))
return route.fulfill({ json: { code: 0, data: { promoteAccount: row.promoteAccount, bindAt: row.bindAt, endAt: row.endAt, list: [{ platformTitle: '龙翔', gameName: '测试游戏', totalAmount: '9007199254740993.10' }], totalAmount: '9007199254740993.10' } } })
}
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
return route.fulfill({ response })
})
await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/ChannelActivationList')
await page.getByText('测试渠道-1', { exact: true }).waitFor()
const row = id => page.locator('.el-table__body-wrapper tr').filter({ hasText: `测试渠道-${id}` })
assert.equal(await row(4).getByRole('button', { name: '流水明细', exact: true }).count(), 0)
for (const id of [1, 2, 3, 5]) assert.equal(await row(id).getByRole('button', { name: '流水明细', exact: true }).count(), 1)
const dialog = page.getByRole('dialog', { name: '流水明细', exact: true })
await row(3).getByRole('button', { name: '流水明细', exact: true }).click()
await dialog.getByText('测试游戏', { exact: true }).waitFor()
await dialog.getByText('2026-11-01 至 2026-11-03', { exact: true }).waitFor()
assert.equal(await dialog.getByText('¥9,007,199,254,740,993.10', { exact: true }).count(), 2)
await dialog.locator('.el-loading-mask').waitFor({ state: 'hidden' })
await page.screenshot({ path: path.join(process.env.TEMP, 'sw-activation-flow.png'), animations: 'disabled' })
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
await dialog.waitFor({ state: 'hidden' })
await row(1).getByRole('button', { name: '流水明细', exact: true }).click()
await dialog.getByText('审核通过后可查看', { exact: true }).waitFor()
assert.equal(await dialog.getByText('绑定期间累计流水:', { exact: false }).count(), 0)
await dialog.getByRole('button', { name: '关闭', exact: true }).click()
await dialog.waitFor({ state: 'hidden' })
const form = page.locator('.kra-search-box')
await form.getByPlaceholder('渠道账号', { exact: true }).fill(' 渠道 ')
await form.getByRole('button', { name: '查询', exact: true }).click()
await page.waitForResponse(response => response.url().includes('/sw/promoteActivePool/bindList') && response.url().includes('promoteAccount='))
assert.equal(new URLSearchParams(queries.at(-1)).get('promoteAccount'), '渠道')
unavailable = true
await form.getByRole('button', { name: '重置', exact: true }).click()
await page.getByText('OA 渠道激活数据源尚未接入或暂不可用。', { exact: true }).waitFor()
assert.equal(new URLSearchParams(queries.at(-1)).get('promoteAccount'), '')
assert.deepEqual(errors, [])
const result = { passed: true, rejectedHidesFlow: true, pendingShowsReasonWithoutFakeTotal: true, reachedCutoffDisplayed: true, exactLargeAmount: true, filtersAndReset: true, unavailableSourceNotice: true, pageErrors: errors }
await fs.writeFile('docs/sw6-to-kra/activation-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome,受控 OA 和流水响应,无写入', ...result }, null, 2) + '\n')
console.log(JSON.stringify(result))
await context.close()
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
-58
View File
@@ -1,58 +0,0 @@
"""实测运行实例的预警查询和无效写请求,不修改真实预警或上传文件。"""
import argparse
import json
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
checks = []
def call(method, path, body=None, expected=0):
request = urllib.request.Request(
args.base_url + path, method=method,
headers={"x-token": token, "Content-Type": "application/json"},
data=json.dumps(body).encode() if body is not None else None,
)
with urllib.request.urlopen(request, timeout=30) as response:
value = json.load(response)
result = {"method": method, "path": path, "http": response.status,
"code": value.get("code"), "msg": value.get("msg"),
"passed": response.status == 200 and value.get("code") == expected}
data = value.get("data", {})
if isinstance(data, dict) and "pageData" in data:
result.update(total=data["pageData"]["total"], available=data.get("available"),
status_count=data["statusCount"])
checks.append(result)
print(json.dumps(result, ensure_ascii=False), flush=True)
return data
call("GET", "/sw/alertRecord/list?pageNum=1&pageSize=10")
call("GET", "/sw/alertRecord/list?platformCodes=lx,hw&status=5")
call("GET", "/sw/alertRule/ruleTypes")
accounts = call("GET", "/sw/business/list?pageNum=1&pageSize=500")
bound = next((row for row in accounts.get("rows", []) if row["bindCount"] > 0), None)
unbound = next((row for row in accounts.get("rows", []) if row["bindCount"] == 0), None)
for account in [bound, unbound]:
if account:
call("GET", f"/sw/alertRecord/list?businessId={account['userId']}")
call("GET", "/sw/alertRecord/detail/0", expected=7)
for action in ["startProcess", "submitAudit", "approve", "reject", "reProcess", "addNote"]:
call("POST", f"/sw/alertRecord/{action}", {"alertId": 0}, expected=7)
report = {"scope": "既有 sw7,只读查询及必然拒绝的无效写请求;正向流转另用隔离 SQLite 和 TCP HTTP 测试",
"base_url": args.base_url, "checks": checks,
"passed": all(row["passed"] for row in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if not report["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
-156
View File
@@ -1,156 +0,0 @@
// 独立 Chrome 核对预警页面;预警写入和附件上传使用受控接口,不修改真实业务数据。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
;(async () => {
const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim()
const browser = await chromium.launch({ channel: 'chrome', headless: true })
const results = []
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=', 'base64')
try {
for (const level of [0, 1, 2, 3]) {
const context = await browser.newContext({ viewport: { width: 1500, height: 1080 } })
await context.addInitScript(value => localStorage.setItem('token', value), token)
const page = await context.newPage()
const pageErrors = []
const writes = []
const queries = []
let uploads = 0
let unavailable = false
const rows = [1, 2, 3, 4, 5].map(status => ({
id: String(100 + status), platformCode: 'lx', platformName: '龙翔', ruleName: `测试规则-${status}`,
ruleType: '测试分类', targetPromoteName: `渠道-${status}`, businessName: '测试一级商务',
alertContent: '测试预警内容', status, createdAt: '2026-11-03 10:20:30', updatedAt: '2026-11-03 10:20:30'
}))
const logs = new Map(rows.map(row => [row.id, []]))
const log = (id, action, note, attachments = []) => logs.get(id).unshift({
id: String(writes.length), operatorName: '页面测试账号', logType: action ? 1 : 2,
logTypeLabel: action ? '状态变更操作' : '添加独立备注', actionDetail: action || '',
noteContent: note || '', attachments, createdAt: '2026-11-03 10:30:00'
})
page.on('pageerror', error => pageErrors.push(error.message))
await page.route('http://127.0.0.1:8080/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
const api = url.pathname.slice(4)
const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } })
if (api === '/sw/alertRule/ruleTypes') return ok(['测试分类'])
if (api === '/sw/alertRecord/list') {
queries.push(url.search)
let filtered = rows.filter(row => !url.searchParams.get('status') || row.status === Number(url.searchParams.get('status')))
if (url.searchParams.get('ruleName')) filtered = filtered.filter(row => row.ruleName.includes(url.searchParams.get('ruleName')))
return ok({ pageData: { rows: unavailable ? [] : filtered, total: unavailable ? 0 : filtered.length }, statusCount: unavailable ? {} : { 1: 1, 2: 1, 3: 1, 4: 1, 5: 1 }, available: !unavailable })
}
if (api.startsWith('/sw/alertRecord/detail/')) {
const id = api.split('/').at(-1)
return ok({ alertRecord: rows.find(row => row.id === id), operationLogs: logs.get(id), canAudit: level === 1 })
}
if (api === '/fileUploadAndDownload/upload') {
uploads++
return ok({ file: { ID: 800 + uploads, url: `/files/verify-${uploads}.png`, name: 'test.png' } })
}
if (api.startsWith('/sw/alertRecord/') && request.method() === 'POST') {
const payload = request.postDataJSON()
const id = String(payload.alertId)
writes.push({ path: api, body: payload })
const action = api.split('/').at(-1)
if (action === 'addNote') log(id, '', payload.noteContent, ['/files/verify-1.png'])
else {
rows.find(row => row.id === id).status = { startProcess: 2, submitAudit: 3, reject: 4, reProcess: 2, approve: 5 }[action]
log(id, { startProcess: 'start', submitAudit: 'submit', reject: 'reject', reProcess: 'reprocess', approve: 'pass' }[action], payload.note)
}
return ok({})
}
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
if (api === '/user/getUserInfo') {
const body = await response.json()
Object.assign(body.data.userInfo, { ID: 70000 + level, level, nickName: '页面测试账号', phone: '', email: '', authorityId: level === 0 ? 888 : 900 + level })
return route.fulfill({ response, json: body })
}
return route.fulfill({ response })
})
await page.route('**/files/verify-*.png', route => route.fulfill({ contentType: 'image/png', body: png }))
await page.goto('http://127.0.0.1:8080/#/layout/sw-AlertCenter/AlertList')
await page.getByText('测试规则-1', { exact: true }).waitFor()
const result = { level, stateButtons: [], passed: true }
const tableRow = status => page.locator('.el-table__body-wrapper tr').filter({ hasText: `测试规则-${status}` })
const dialog = page.getByRole('dialog', { name: '预警处理 / 详情', exact: true })
const close = async () => { await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }) }
for (const status of [1, 2, 3, 4, 5]) {
const expected = { 1: '处理', 2: '继续处理', 3: level === 1 ? '审核' : '查看', 4: '重新处理', 5: '查看' }[status]
await tableRow(status).getByRole('button', { name: expected, exact: true }).click()
await dialog.getByText('测试预警内容', { exact: true }).waitFor()
assert.equal(await dialog.getByRole('button', { name: '审核通过', exact: true }).count(), status === 3 && level === 1 ? 1 : 0)
assert.equal(await dialog.getByPlaceholder('填写处理备注...').count(), status === 5 || (status === 3 && level !== 1) ? 0 : 1)
result.stateButtons.push({ status, label: expected })
await close()
}
if (level === 1) {
await tableRow(1).getByRole('button', { name: '处理', exact: true }).click()
await dialog.getByPlaceholder('填写处理备注...').waitFor()
await dialog.locator('input[type="file"]').setInputFiles({ name: 'test.png', mimeType: 'image/png', buffer: png })
await dialog.locator('textarea').evaluate((element, bytes) => {
const transfer = new DataTransfer()
transfer.items.add(new File([new Uint8Array(bytes)], 'paste.png', { type: 'image/png' }))
element.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, clipboardData: transfer }))
}, [...png])
await dialog.locator('.alert-upload-zone').evaluate((element, bytes) => {
const transfer = new DataTransfer()
transfer.items.add(new File([new Uint8Array(bytes)], 'drop.png', { type: 'image/png' }))
element.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer }))
}, [...png])
assert.equal(await dialog.locator('.pending-image').count(), 3)
await dialog.locator('.pending-image').first().getByRole('button', { name: '移除', exact: true }).click()
await dialog.locator('.pending-image').first().getByRole('button', { name: '移除', exact: true }).click()
assert.equal(await dialog.locator('.pending-image').count(), 1)
assert.equal(uploads, 0)
await dialog.getByRole('button', { name: '添加备注', exact: true }).click()
await page.getByText('请填写处理备注后再提交附件', { exact: true }).waitFor()
assert.equal(uploads, 0)
await dialog.getByPlaceholder('填写处理备注...').fill('测试备注和图片')
await dialog.getByRole('button', { name: '开始处理', exact: true }).click()
await page.getByText('请先点击「添加备注」上传图片,或移除暂存图片后再操作', { exact: true }).waitFor()
assert.equal(writes.length, 0)
await dialog.getByRole('button', { name: '添加备注', exact: true }).click()
await dialog.getByText('备注:测试备注和图片', { exact: true }).waitFor()
assert.equal(uploads, 1)
assert.deepEqual(writes[0].body.attachmentIds, ['801'])
for (const [button, next, confirmation] of [
['开始处理', '提交审核', true], ['提交审核', '审核通过', true], ['审核驳回', '重新处理', true],
['重新处理', '提交审核', true], ['提交审核', '审核通过', true], ['审核通过', null, false]
]) {
await dialog.getByRole('button', { name: button, exact: true }).click()
if (confirmation) await page.getByRole('dialog', { name: '操作确认', exact: true }).getByRole('button', { name: '确定', exact: true }).click()
if (next) await dialog.getByRole('button', { name: next, exact: true }).waitFor()
else await dialog.getByPlaceholder('填写处理备注...').waitFor({ state: 'hidden' })
}
assert.equal(writes.length, 7)
await page.screenshot({ path: path.join(process.env.TEMP, 'sw-alert-record-workflow.png'), animations: 'disabled' })
await close()
result.workflowAndAttachments = true
result.pasteDropAndRemove = true
}
if (level === 2) {
await page.getByRole('button', { name: '未处理 1', exact: true }).click()
await tableRow(2).waitFor({ state: 'hidden' })
assert.ok(queries.at(-1).includes('status=1'))
await page.getByRole('button', { name: '未处理 1', exact: true }).click()
await tableRow(2).waitFor()
assert.ok(!queries.at(-1).includes('status=1'))
unavailable = true
await page.locator('.kra-search-box').getByRole('button', { name: '搜索', exact: true }).click()
await page.getByText('渠道数据尚未接入,暂时无法核对预警可见范围。', { exact: true }).waitFor()
assert.equal(await page.getByText('未接入', { exact: true }).count(), 5)
result.statusCardsAndUnavailable = true
}
assert.deepEqual(pageErrors, [])
result.pageErrors = pageErrors
results.push(result)
console.log(JSON.stringify(result))
await context.close()
}
await fs.writeFile('docs/sw6-to-kra/alert-records-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome 渲染;受控身份、预警数据、写接口和图片上传;未修改真实业务数据', results }, null, 2) + '\n')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
-36
View File
@@ -1,36 +0,0 @@
// 在独立 Chrome 上核对规则入口和指标联动,接口写入使用测试替身。
const fs=require('node:fs/promises');const path=require('node:path');
const {chromium}=require(process.env.SW_PLAYWRIGHT_MODULE||'playwright');
(async()=>{
const token=(await fs.readFile(path.join(process.env.TEMP,'sw-kra-http-test-token'),'utf8')).trim();
const browser=await chromium.launch({channel:'chrome',headless:true});const results=[];
try{
for(const level of [0,1,2,3]){
const context=await browser.newContext({viewport:{width:1440,height:1000}});await context.addInitScript(t=>localStorage.setItem('token',t),token);
const page=await context.newPage();const errors=[];const writes=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('http://127.0.0.1:8080/api/**',async route=>{
const req=route.request();const url=new URL(req.url());const api=url.pathname.slice(4);
if(api==='/sw/alertRule/list'){await route.fulfill({json:{code:0,msg:'成功',data:{total:1,rows:[{id:1,ruleName:'测试规则',ruleType:'测试分类',ruleTypeLabel:'测试分类',monitorMetric:1,statPeriodDays:7,momDirection:2,momThreshold:20,warningColor:'#f56c6c',creatorId:70001,creatorName:'测试一级商务',monitorScope:1,enabled:1,ruleDescription:'渠道近 7 天注册量较上一同期环比下降 20% 时触发'}]}}});return;}
if(api.startsWith('/sw/alertRule/')&&req.method()!=='GET'){writes.push({path:api,body:req.postDataJSON()});await route.fulfill({json:{code:0,msg:'成功',data:{id:2}}});return;}
const response=await route.fetch({url:'http://127.0.0.1:18000'+api+url.search,headers:{...req.headers(),'x-token':token}});
if(api==='/user/getUserInfo'){const body=await response.json();Object.assign(body.data.userInfo,{ID:70000+level,level,nickName:'页面核对账号',phone:'',email:'',authorityId:level===0?888:900+level});await route.fulfill({response,json:body});}
else await route.fulfill({response});
});
await page.goto('http://127.0.0.1:8080/#/layout/sw-AlertCenter/AlertRules');await page.getByText('测试规则',{exact:true}).waitFor();
const create=await page.getByRole('button',{name:'新建规则',exact:true}).count();const edit=await page.getByRole('button',{name:'编辑',exact:true}).count();
const allowed=level===0||level===1;if(create!==(allowed?1:0)||edit!==(allowed?1:0))throw new Error('规则写入口权限错误 level='+level);
const result={level,createButton:create,editButton:edit,passed:true};
if(level===1){
await page.getByRole('button',{name:'新建规则',exact:true}).click();const dialog=page.getByRole('dialog',{name:'新建规则',exact:true});
await dialog.getByPlaceholder('请输入规则名称').fill('登录测试');await dialog.getByPlaceholder('如:注册异常 / 充值异常 / 登录异常').fill('自定义类型');
await dialog.locator('.el-form-item').filter({hasText:'监控指标'}).locator('.el-select__wrapper').click();await page.getByRole('option',{name:'登录天数',exact:true}).click();
if(await dialog.getByText('统计周期(天)',{exact:true}).count())throw new Error('登录指标仍显示环比字段');
await dialog.getByText('未登录天数',{exact:true}).waitFor();await dialog.getByRole('button',{name:'保存',exact:true}).click();await dialog.waitFor({state:'hidden'});
const saved=writes.find(x=>x.path==='/sw/alertRule/add');if(!saved||saved.body.monitorMetric!==3||saved.body.unloginDays!==7)throw new Error('规则联动表单参数错误');result.metricFields=true;
}
result.pageErrors=errors;if(errors.length)throw new Error(errors.join(';'));
results.push(result);console.log(JSON.stringify(result));await context.close();
}
await fs.writeFile('docs/sw6-to-kra/alert-rules-page-2026-11-03.json',JSON.stringify({method:'真实 Chrome 渲染,受控角色及规则数据;不写真实规则',results},null,2));
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exitCode=1});
-37
View File
@@ -1,37 +0,0 @@
"""只读检查渠道/游戏统计接口,区分空结果与业务表未接入。"""
import argparse
import json
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
checks = []
for kind in ["channelData", "gameData"]:
for query, expected in [("sourceDb=lx,hw,yq&pageNum=1&pageSize=10", 0), ("beginTime=bad&endTime=2026-11-04", 7), ("beginTime=2026-11-05&endTime=2026-11-04", 7)]:
path = f"/sw/{kind}/list?{query}"
req = urllib.request.Request(args.base_url + path, headers={"x-token": token})
with urllib.request.urlopen(req, timeout=30) as response:
payload = json.load(response)
data = payload.get("data", {})
item = {"path": path, "http": response.status, "code": payload.get("code"), "msg": payload.get("msg"), "passed": payload.get("code") == expected}
if "available" in data:
item.update(available=data["available"], total=data.get("total"), begin=data.get("beginTime"), end=data.get("endTime"))
if data["available"] is False:
item["passed"] &= data.get("summary") is None
checks.append(item)
report = {"scope": "真实 sw7 会话,只读请求;完整统计及权限口径另由隔离 TCP HTTP 测试验证。", "checks": checks, "passed": all(item["passed"] for item in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if not report["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
-62
View File
@@ -1,62 +0,0 @@
// 真实 Chrome 核对统计筛选、排序、分页和全量汇总;业务接口使用受控数据。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
;(async () => {
const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim()
const browser = await chromium.launch({ channel: 'chrome', headless: true })
const reports = []
try {
for (const kind of ['channel', 'game']) {
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } })
await context.addInitScript(value => localStorage.setItem('token', value), token)
const page = await context.newPage(), queries = [], errors = []
let unavailable = false
const routeName = kind === 'channel' ? 'channelData' : 'gameData'
page.on('pageerror', error => errors.push(error.message))
await page.route('http://127.0.0.1:8080/api/**', async route => {
const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4)
if (api === `/sw/${routeName}/list`) {
queries.push(Object.fromEntries(url.searchParams))
const pageNum = Number(url.searchParams.get('pageNum') || 1)
const metrics = { newRegisterCount: 2, activeCount: 4, payUserCount: 1, newPayUserCount: 1, totalPay: '10.00', payRate: '25.00', arpu: '2.50', arppu: '10.00' }
const row = { ...metrics, sourceDb: 'lx', sourceDbLabel: '龙翔', promoteId: pageNum, promoteName: `测试渠道${pageNum}`, promoteType: 1, busierName: '游戏商务', gameId: pageNum, gameName: `测试游戏${pageNum}` }
const summary = { ...metrics, gameName: '全部游戏', promoteName: '全部渠道', newRegisterCount: 60, activeCount: 100, payUserCount: 20, newPayUserCount: 10, totalPay: '1000.00', payRate: '20.00', arpu: '10.00', arppu: '50.00' }
return route.fulfill({ json: { code: 0, data: { rows: unavailable ? [] : [row], total: unavailable ? 0 : 21, channelCount: unavailable ? null : 21, totalRegisterCount: unavailable ? null : 60, totalPayAmount: unavailable ? null : '1000.00', avgPayRate: unavailable ? null : '20.00', summary: unavailable ? null : summary, beginTime: url.searchParams.get('beginTime'), endTime: url.searchParams.get('endTime'), available: !unavailable } } })
}
if (api === '/sw/serverAnalysis/games') return route.fulfill({ json: { code: 0, data: [{ sourceDb: 'lx', gameId: 1, gameName: '筛选游戏' }] } })
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
return route.fulfill({ response })
})
await page.goto(`http://127.0.0.1:8080/#/layout/sw-DataAnalysis/${kind === 'channel' ? 'ChannelDataStatistics' : 'GameAnalytics'}`)
await page.getByText(kind === 'channel' ? '测试渠道1' : '测试游戏1', { exact: true }).waitFor()
assert.equal((Date.parse(queries[0].endTime) - Date.parse(queries[0].beginTime)) / 86400000, 29)
const totals = kind === 'channel' ? page.locator('.report-cards') : page.locator('.el-table__footer-wrapper')
await totals.getByText('¥1,000.00', { exact: true }).waitFor()
assert.equal(await page.getByRole('button', { name: '导出', exact: true }).count(), 0)
await page.locator('.el-pagination .btn-next').click()
await page.getByText(kind === 'channel' ? '测试渠道2' : '测试游戏2', { exact: true }).waitFor()
assert.equal(queries.at(-1).pageNum, '2')
await totals.getByText('¥1,000.00', { exact: true }).waitFor()
const form = page.locator('.kra-search-box')
await form.locator('.el-form-item').filter({ has: page.locator('label').getByText('游戏', { exact: true }) }).locator('.el-select__wrapper').click()
await page.getByRole('option', { name: '筛选游戏', exact: true }).click(); await page.keyboard.press('Escape')
if (kind === 'channel') await form.getByPlaceholder('渠道账号(模糊匹配)', { exact: true }).fill('测试')
await Promise.all([page.waitForResponse(response => response.url().includes(`/sw/${routeName}/list`) && response.url().includes('gameName=')), form.getByRole('button', { name: kind === 'channel' ? '搜索' : '查询', exact: true }).click()])
assert.equal(queries.at(-1).pageNum, '1'); assert.equal(queries.at(-1).gameName, '筛选游戏')
if (kind === 'channel') assert.equal(queries.at(-1).promoteName, '测试')
await page.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
await page.screenshot({ path: path.join(process.env.TEMP, `sw-${kind}-analytics.png`), animations: 'disabled' })
unavailable = true
await form.getByRole('button', { name: kind === 'channel' ? '搜索' : '查询', exact: true }).click()
await page.getByText('统计所需业务汇总尚未接入。', { exact: true }).waitFor()
assert.equal(await totals.getByText('¥1,000.00', { exact: true }).count(), 0)
assert.deepEqual(errors, [])
const report = { kind, passed: true, fullSummaryAcrossPages: true, filtersResetPage: true, platformAndGameFilters: true, noInventedExport: true, missingDataNotZero: true, pageErrors: errors }
reports.push(report); console.log(JSON.stringify(report)); await context.close()
}
await fs.writeFile('docs/sw6-to-kra/analytics-page-2026-11-04.json', JSON.stringify({ method: '真实 Chrome 渲染,受控业务数据;未写入真实数据', reports }, null, 2) + '\n')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
+201
View File
@@ -0,0 +1,201 @@
"""请求隔离的新旧商务实例;原始响应和令牌留在仓库外,报告只记录校验结果。"""
import argparse
import concurrent.futures
import decimal
import hashlib
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def fetch(base, token, path, params, legacy, client_id="", timeout=120):
url = base.rstrip("/") + path
if params:
url += "?" + urllib.parse.urlencode(params, doseq=True)
headers = {"Authorization": "Bearer " + token} if legacy else {"x-token": token}
if legacy:
headers["clientid"] = client_id
request = urllib.request.Request(url, headers=headers)
started = time.monotonic()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = json.load(response)
return {"http": response.status, "elapsed_ms": round((time.monotonic() - started) * 1000), "body": body}
except (urllib.error.URLError, TimeoutError, ValueError) as error:
return {"http": None, "elapsed_ms": round((time.monotonic() - started) * 1000), "error": type(error).__name__}
def data_of(reply):
body = reply.get("body", {})
if "rows" in body:
return {key: value for key, value in body.items() if key not in ("code", "msg")}
return body.get("data")
def shape(value):
if isinstance(value, dict):
return {key: (len(item) if isinstance(item, list) else type(item).__name__) for key, item in value.items()}
return {"type": type(value).__name__, "length": len(value) if isinstance(value, list) else None}
def expected_relative_pay_rate(row):
"""用户确认的新口径:由旧的本期付费率及百分点差推导相对环比。"""
if row.get("momValue") is None:
return row
current = decimal.Decimal(str(row["metricValue"]))
difference = decimal.Decimal(str(row["momValue"]))
previous = current - difference
result = dict(row)
if previous <= 0:
result.update(momValue=None, momStatus=None)
else:
change = (difference / previous * 100).quantize(decimal.Decimal("0.01"), rounding=decimal.ROUND_HALF_UP)
result.update(momValue=format(abs(change) if change == 0 else change, ".2f"), momStatus=0 if change == 0 else 1 if change > 0 else -1)
return result
def compare_data(source, target, case, users):
differences = []
omitted = {"source": case.get("source_omit_fields", []), "target": case.get("target_omit_fields", [])}
# 两边通过 HTTP 分别创建的专用测试账号,其创建时刻天然不同。
# 只忽略显式列出的夹具账号时间,真实迁移账号的所有时间字段仍须相等。
generated = {str(value) for value in case.get("generated_user_ids", [])}
generated_ids = {"source": generated, "target": {users.get(value, value) for value in generated}}
user_fields = set(case.get("user_fields", ["userId", "parentId", "creatorId", "businessId", "operatorId", "parentUserId"]))
def normalize(value, side, field="", path=""):
if isinstance(value, dict):
if side == "source" and field == "payRateRankList" and case.get("relative_pay_rate"):
value = expected_relative_pay_rate(value)
if side == "target" and value.get("available") is False:
differences.append({"path": path + ".available", "kind": "unavailable"})
if side == "source" and case.get("normalize_root_path") and value.get("level") in (0, 1, "0", "1") and value.get("idPath") is None:
value = {**value, "idPath": "0"}
excluded = set(omitted[side])
if str(value.get("userId")) in generated_ids[side]:
excluded.add("createTime")
return {key: normalize(item, side, key, path + "." + key) for key, item in value.items() if key not in excluded}
if isinstance(value, list):
rows = [normalize(item, side, field, path) for item in value]
keys = case.get("row_key")
if keys and rows and all(isinstance(row, dict) and all(key in row for key in keys) for row in rows):
rows.sort(key=lambda row: tuple(str(row[key]) for key in keys))
return rows
if value is None and field in case.get("null_empty_fields", []):
return ""
if side == "source" and case.get("map_users", True) and value is not None:
if field in user_fields:
value = users.get(str(value), str(value))
elif field == "idPath":
value = ",".join(users.get(item, item) for item in str(value).split(","))
return value
def walk(left, right, path="data"):
# 只兼容数值与 JSON 数字字符串;账号、手机号、区服编号等字符串保留前导零。
numeric_field = path.rsplit(".", 1)[-1] in case.get("numeric_fields", [])
if not isinstance(left, bool) and not isinstance(right, bool) and (numeric_field or isinstance(left, (int, float)) or isinstance(right, (int, float))):
try:
if decimal.Decimal(str(left)) == decimal.Decimal(str(right)):
return
except decimal.InvalidOperation:
pass
if type(left) is not type(right):
differences.append({"path": path, "kind": "type", "source": type(left).__name__, "target": type(right).__name__})
elif isinstance(left, dict):
for key in sorted(left.keys() | right.keys()):
if key not in left or key not in right:
differences.append({"path": path + "." + key, "kind": "missing_field", "side": "source" if key not in left else "target"})
else:
walk(left[key], right[key], path + "." + key)
elif isinstance(left, list):
if len(left) != len(right):
differences.append({"path": path, "kind": "length", "source": len(left), "target": len(right)})
for i, (a, b) in enumerate(zip(left, right)):
walk(a, b, path + f"[{i}]")
elif left != right:
# 数据值只留在仓库外;公开报告保留字段路径和摘要供复核。
differences.append({"path": path, "kind": "value", "source_sha256": hashlib.sha256(str(left).encode()).hexdigest(), "target_sha256": hashlib.sha256(str(right).encode()).hexdigest()})
walk(normalize(source, "source"), normalize(target, "target"))
return {"semantic_equal": not differences, "difference_count": len(differences), "differences": differences[:30],
"normalization": {"user_ids_mapped": case.get("map_users", True), "numeric_representation": True,
"user_fields": sorted(user_fields), "numeric_string_fields": case.get("numeric_fields", []),
"normalize_root_path": case.get("normalize_root_path", False),
"expected_relative_pay_rate": case.get("relative_pay_rate", False),
"generated_user_creation_times": len(generated),
"row_key": case.get("row_key"), "omitted_fields": omitted, "null_empty_fields": case.get("null_empty_fields", [])}}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--cases", type=Path, required=True)
parser.add_argument("--identity", default="admin")
parser.add_argument("--old-url", default="http://127.0.0.1:18080")
parser.add_argument("--new-url", default="http://127.0.0.1:19000")
parser.add_argument("--output", type=Path, required=True)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--recompare", action="store_true", help="使用已保存的同参数 HTTP 响应重新核对,不重复访问数据库")
mode.add_argument("--refresh-target", action="store_true", help="保留已采集的同参数旧响应,重新请求优化后的新实现;新增用例仍请求两边")
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
if args.runtime.resolve().is_relative_to(root):
raise ValueError("原始业务响应必须保存在仓库外")
tokens = [(args.runtime / (prefix + "-" + args.identity + ".token")).read_text(encoding="utf-8").strip() for prefix in ("legacy", "new")]
client_id = json.loads((args.runtime / "legacy-client.json").read_text(encoding="utf-8"))["client_id"]
users = {str(row["old_id"]): str(row["new_id"]) for row in json.loads((args.runtime / "id-maps.json").read_text(encoding="utf-8")) if row["entity"] == "user"}
cases = json.loads(args.cases.read_text(encoding="utf-8"))
reports = []
folder = args.runtime / ("responses-" + args.identity)
folder.mkdir(exist_ok=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
for case in cases:
capture = folder / (case["name"] + ".json")
reused_source = args.refresh_target and capture.exists()
if args.recompare or reused_source:
saved = json.loads(capture.read_text(encoding="utf-8"))
if any(saved["case"].get(key) != case.get(key) for key in ("source", "target", "params", "source_params", "target_params")):
raise ValueError("已有 HTTP 响应的请求条件不符:" + case["name"])
replies = [saved["source"], saved["target"]]
if args.refresh_target:
replies[1] = fetch(args.new_url, tokens[1], case["target"], case.get("target_params", case.get("params", {})), False, timeout=case.get("timeout_seconds", 120))
capture.write_text(json.dumps({"case": case, "source": replies[0], "target": replies[1]}, ensure_ascii=False, indent=2), encoding="utf-8")
else:
pairs = [pool.submit(fetch, base, tokens[i], case[side], case.get(side + "_params", case.get("params", {})), i == 0, client_id, case.get("timeout_seconds", 120))
for i, (base, side) in enumerate(((args.old_url, "source"), (args.new_url, "target")))]
replies = [job.result() for job in pairs]
capture.write_text(json.dumps({"case": case, "source": replies[0], "target": replies[1]}, ensure_ascii=False, indent=2), encoding="utf-8")
values = [data_of(reply) for reply in replies]
for i, side in enumerate(("source", "target")):
for key in case.get(side + "_data_path", []):
values[i] = values[i][key]
codes = [reply.get("body", {}).get("code") for reply in replies]
report = {"case": case["name"], "source": case["source"], "target": case["target"], "codes": codes,
"source_capture_reused": reused_source,
"http_passed": codes == [200, 0], "elapsed_ms": [reply["elapsed_ms"] for reply in replies],
"shapes": [shape(value) for value in values],
"exact_data_equal": values[0] == values[1]}
if all(isinstance(value, dict) and "total" in value for value in values):
report["totals"] = [value["total"] for value in values]
report["totals_equal"] = str(values[0]["total"]) == str(values[1]["total"])
reports.append(report)
report.update(compare_data(values[0], values[1], case, users))
report["passed"] = report["http_passed"] and report["semantic_equal"]
if case.get("expected_error_codes"):
messages = [reply.get("body", {}).get("msg") for reply in replies]
report["rejection_equal"] = codes == case["expected_error_codes"] and messages[0] == messages[1]
report["expected_error_codes"] = case["expected_error_codes"]
report["passed"] = report["rejection_equal"]
print(json.dumps(report, ensure_ascii=False), flush=True)
args.output.write_text(json.dumps({"identity": args.identity, "checks": reports}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if not all(report["passed"] for report in reports):
raise SystemExit(1)
if __name__ == "__main__":
main()
-52
View File
@@ -1,52 +0,0 @@
"""实测渠道只读接口及必然拒绝的无效创建请求,不写入实际平台渠道。"""
import argparse
import json
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
checks = []
def call(method, path, expected=0, body=None):
request = urllib.request.Request(args.base_url + path, method=method,
headers={"x-token": token, "Content-Type": "application/json"},
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(request, timeout=30) as response:
payload = json.load(response)
item = {"method": method, "path": path, "http": response.status, "code": payload.get("code"),
"msg": payload.get("msg"), "passed": response.status == 200 and payload.get("code") == expected}
data = payload.get("data", {})
if isinstance(data, dict) and "rows" in data:
item.update(total=data["total"], available=data.get("available"))
if isinstance(data, list):
item["rows"] = len(data)
checks.append(item)
print(json.dumps(item, ensure_ascii=False))
return data
data = call("GET", "/sw/promote/list?pageNum=1&pageSize=10")
call("GET", "/sw/promote/list?sourceDb=lx,hw&lockStatus=-1&promoteLevel=1")
call("GET", "/sw/promote/list?busierAccount=does-not-exist")
for source in ["lx", "hw", "yq"]:
call("GET", "/sw/promote/bindOptions?sourceDb=" + source)
call("GET", "/sw/promote/game-distribution?sourceDb=lx&promoteId=0", expected=7)
if isinstance(data, dict) and data.get("available") is False:
call("GET", "/sw/promote/game-distribution?sourceDb=lx&promoteId=1", expected=7)
call("POST", "/sw/promote", expected=7, body={})
report = {"scope": "当前 sw7 实际登录会话;只读查询和无效创建。完整查询、偏好、游戏分布及源库创建已在隔离数据库通过 TCP HTTP 验证。",
"checks": checks, "passed": all(item["passed"] for item in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if not report["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
-94
View File
@@ -1,94 +0,0 @@
// 独立 Chrome 对照渠道列表、添加抽屉和游戏分布;平台创建接口使用测试替身。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
;(async () => {
const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim()
const browser = await chromium.launch({ channel: 'chrome', headless: true })
try {
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } })
await context.addInitScript(value => localStorage.setItem('token', value), token)
const page = await context.newPage()
const errors = [], queries = [], gameQueries = [], writes = []
let available = true
const rows = [
{ id: 100, sourceDb: 'lx', platformName: '龙翔', account: 'lx-channel', nickname: '测试渠道', promoteLevel: 1, busierDisplayName: 'SDK-LX(测试商务)', parentName: '', realName: '负责人', registerType: 0, gameCount: 2, playerCount: 3, totalPayAmount: '30.30', lockStatus: 0 },
{ id: 100, sourceDb: 'hw', platformName: '汇玩', account: 'hw-channel', promoteLevel: 1, registerType: 1, gameCount: 0, playerCount: null, totalPayAmount: null, lockStatus: -1 }
]
page.on('pageerror', error => errors.push(error.message))
await page.route('http://127.0.0.1:8080/api/**', async route => {
const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4)
const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } })
if (api === '/sw/promote/list') { queries.push(url.search); return ok({ rows: available ? rows : [], total: available ? 2 : 0, available }) }
if (api === '/sw/promote/bindOptions') {
const source = url.searchParams.get('sourceDb')
const option = { sourceDb: source, platformBusierId: source === 'lx' ? 14 : 18, platformBusierAccount: source === 'lx' ? 'SDK-LX' : 'SDK-HW', userId: 70009 }
return ok([option, option])
}
if (api === '/sw/promote/game-distribution') {
gameQueries.push({ sourceDb: url.searchParams.get('sourceDb'), promoteId: url.searchParams.get('promoteId') })
return ok(url.searchParams.get('sourceDb') === 'lx' ? [{ gameId: 1, gameName: '测试游戏', registerCount: 2, payAmount: '70.00' }] : [])
}
if (api === '/sw/promote' && request.method() === 'POST') { writes.push(request.postDataJSON()); return ok({ id: 200, sourceDb: 'lx' }) }
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
if (api === '/user/getUserInfo') {
const body = await response.json()
Object.assign(body.data.userInfo, { ID: 70001, level: 1, authorityId: 901, nickName: '页面核对账号', phone: '', email: '' })
return route.fulfill({ response, json: body })
}
return route.fulfill({ response })
})
await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/ChannelList')
await page.getByText('lx-channel', { exact: true }).waitFor()
const lx = page.locator('.el-table__body-wrapper tr').filter({ hasText: 'lx-channel' })
const hw = page.locator('.el-table__body-wrapper tr').filter({ hasText: 'hw-channel' })
assert.equal(await page.getByRole('button', { name: '编辑', exact: true }).count(), 0)
assert.equal(await hw.getByText('未接入', { exact: true }).count(), 2)
await lx.getByRole('button', { name: '2 款', exact: true }).click()
const games = page.getByRole('dialog', { name: '推广游戏数据 — 测试渠道', exact: true })
await games.getByText('测试游戏', { exact: true }).waitFor()
await games.locator('.el-loading-mask').waitFor({ state: 'hidden' })
await games.getByText('¥70.00', { exact: true }).waitFor()
assert.deepEqual(gameQueries.at(-1), { sourceDb: 'lx', promoteId: '100' })
await page.screenshot({ path: path.join(process.env.TEMP, 'sw-channel-games.png'), animations: 'disabled' })
await games.locator('.el-drawer__close-btn').click(); await games.waitFor({ state: 'hidden' })
await hw.getByRole('button', { name: '0 款', exact: true }).click()
const emptyGames = page.getByRole('dialog', { name: '推广游戏数据 — hw-channel', exact: true })
await emptyGames.waitFor()
await emptyGames.locator('.el-loading-mask').waitFor({ state: 'hidden' })
assert.deepEqual(gameQueries.at(-1), { sourceDb: 'hw', promoteId: '100' })
await emptyGames.locator('.el-drawer__close-btn').click(); await emptyGames.waitFor({ state: 'hidden' })
await page.getByRole('button', { name: '添加渠道', exact: true }).click()
const drawer = page.getByRole('dialog', { name: '添加渠道', exact: true })
const select = async (label, option) => {
await drawer.locator('.el-form-item').filter({ hasText: label }).locator('.el-select__wrapper').click()
await page.getByRole('option', { name: option, exact: true }).click()
}
await select('所属平台', '龙翔')
await select('游戏商务', 'SDK-LX')
await select('所属平台', '汇玩')
assert.equal(await drawer.getByText('SDK-LX', { exact: true }).count(), 0)
await select('游戏商务', 'SDK-HW')
await select('所属平台', '龙翔')
await select('游戏商务', 'SDK-LX')
await select('推广类型', '企业')
await drawer.getByPlaceholder('中文/英文/数字,4-30位', { exact: true }).fill('中文渠道')
await drawer.getByPlaceholder('6-15位', { exact: true }).fill('demo123')
await drawer.getByRole('button', { name: '确认添加', exact: true }).click()
await drawer.waitFor({ state: 'hidden' })
assert.equal(writes.length, 1)
assert.equal(writes[0].platformBusierId, 14)
assert.equal(writes[0].sourceDb, 'lx')
assert.equal(writes[0].registerType, 1)
available = false
await page.locator('.kra-search-box').getByRole('button', { name: '搜索', exact: true }).click()
await page.getByText('渠道数据尚未接入。', { exact: true }).waitFor()
assert.deepEqual(errors, [])
const result = { passed: true, level: 1, noEditEntry: true, createAllowedForUpperBusiness: true, platformSwitchClearsBinding: true, crossPlatformSameID: true, zeroGameCountClickable: true, missingMetricsVisible: true, sourceDatabaseCreatePayload: true, portraitPending: false, pageErrors: errors }
await fs.writeFile('docs/sw6-to-kra/channel-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome 渲染;受控页面数据、身份及平台创建响应,不创建真实平台渠道', ...result }, null, 2) + '\n')
console.log(JSON.stringify(result))
await context.close()
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
-47
View File
@@ -1,47 +0,0 @@
"""只读验证渠道画像路由及现有数据的缺表处理,不修改业务记录。"""
import argparse
import json
import urllib.parse
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
checks = []
def get(path, expected=0):
req = urllib.request.Request(args.base_url + path, headers={"x-token": token})
with urllib.request.urlopen(req, timeout=30) as response:
result = json.load(response)
item = {"path": path, "http": response.status, "code": result.get("code"), "msg": result.get("msg"),
"passed": response.status == 200 and result.get("code") == expected}
data = result.get("data", {})
if isinstance(data, dict) and "available" in data:
item["available"] = data["available"]
checks.append(item)
return data
channels = get("/sw/promote/list?pageNum=1&pageSize=1")
if channels.get("available") is False:
get("/sw/promote/portrait?sourceDb=lx&promoteId=1&period=total", expected=7)
elif channels.get("rows"):
row = channels["rows"][0]
get("/sw/promote/portrait?" + urllib.parse.urlencode({"sourceDb": row["sourceDb"], "promoteId": row["id"], "period": "today"}))
get("/sw/promote/portrait?sourceDb=lx&promoteId=0", expected=7)
get("/sw/promote/portrait?sourceDb=invalid&promoteId=1", expected=7)
report = {"scope": "当前 sw7 登录会话,只读检查;完整累计、分层、日期与权限在隔离 TCP HTTP 中验证。",
"checks": checks, "passed": all(item["passed"] for item in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if not report["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
@@ -1,75 +0,0 @@
// 真实 Chrome 对照渠道画像;接口数据受控,验证累计、分层及关联预警参数。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
;(async () => {
const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim()
const browser = await chromium.launch({ channel: 'chrome', headless: true })
try {
const context = await browser.newContext({ viewport: { width: 1500, height: 1050 } })
await context.addInitScript(value => localStorage.setItem('token', value), token)
const page = await context.newPage()
const errors = [], requests = [], alerts = [], games = []
let unavailable = false
page.on('pageerror', error => errors.push(error.message))
await page.route('http://127.0.0.1:8080/api/**', async route => {
const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4)
const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } })
if (api === '/sw/promote/list') return ok({ available: true, total: 2, rows: ['lx', 'hw'].map(sourceDb => ({ id: 100, sourceDb, platformName: sourceDb === 'lx' ? '龙翔' : '汇玩', account: `${sourceDb}-channel`, nickname: `${sourceDb}测试渠道`, promoteLevel: 1, gameCount: 2, playerCount: 4, totalPayAmount: '15.00', lockStatus: 0 })) })
if (api === '/sw/promote/portrait') {
requests.push(Object.fromEntries(url.searchParams))
const period = url.searchParams.get('period'), source = url.searchParams.get('sourceDb')
const length = period === 'total' ? 21 : period === 'last7' ? 7 : 24
const trend = Array.from({ length }, (_, i) => ({ label: length === 24 ? `${String(i).padStart(2, '0')}:00` : new Date(Date.UTC(2026, 10, 3 - length + 1 + i)).toISOString().slice(5, 10), value: i === 1 ? '4' : '0' }))
const metric = value => ({ value, compareValue: period === 'total' ? null : '1', changeRate: period === 'total' ? null : '100.0', changeDiff: null, compareLabel: period === 'total' ? null : '较上一周期' })
return ok({ available: !unavailable, unavailableReason: unavailable ? '付费分层所需数据尚未接入' : '', period,
basic: { promoteId: 100, nickname: `${source}测试渠道`, account: `${source}-channel`, busierDisplayName: 'SDK(商务)', promoteLevelLabel: '一级渠道', registerTypeLabel: '公会', status: -3, statusLabel: '商务新增', gameCount: 2, createTime: '2026-11-01 08:00:00', lastLoginTime: '2026-11-03 08:00:00' },
metrics: unavailable ? null : { register: metric('4'), payAmount: metric('15.00'), payRate: metric('25.0'), active: metric('4') },
registerTrend: unavailable ? [] : trend, payTrend: unavailable ? [] : trend.map(point => ({ ...point, value: point.value === '4' ? '15.00' : '0.00' })),
payTiers: unavailable ? [] : ['whale', 'mid', 'small', 'free'].map((tier, index) => ({ tier, tierLabel: ['大R', '中R', '小R', '免费'][index], count: 1, ratio: '25.0' })), gameDistribution: [] })
}
if (api === '/sw/alertRecord/list') { alerts.push(Object.fromEntries(url.searchParams)); return ok({ available: true, pageData: { rows: [{ id: '1', createdAt: '2026-11-03 10:00:00', ruleType: 'register_anomaly', alertContent: '对应渠道预警', status: 1 }], total: 1 }, statusCount: { 1: 1 } }) }
if (api === '/sw/promote/game-distribution') { games.push(Object.fromEntries(url.searchParams)); return ok([{ gameId: 1, gameName: '累计推广游戏', registerCount: 4, payAmount: '31.00' }]) }
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
return route.fulfill({ response })
})
await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/ChannelList')
const row = source => page.locator('.el-table__body-wrapper tr').filter({ hasText: `${source}-channel` })
await row('lx').getByRole('button', { name: '画像', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '渠道画像 — lx测试渠道', exact: true })
await dialog.getByText('活跃玩家', { exact: true }).waitFor()
await dialog.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
assert.equal(await dialog.getByText('人均充值', { exact: true }).count(), 0)
assert.equal(await dialog.locator('.pay-tier-item').count(), 4)
await dialog.getByText('对应渠道预警', { exact: true }).waitFor()
assert.equal(alerts.at(-1).targetPromoteId, '100'); assert.equal(alerts.at(-1).platformCodes, 'lx'); assert.equal(alerts.at(-1).businessId, undefined)
await Promise.all([page.waitForResponse(response => response.url().includes('/sw/promote/portrait') && response.url().includes('period=last7')), dialog.getByText('近7天', { exact: true }).click()])
assert.equal(requests.at(-1).period, 'last7')
await dialog.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
await Promise.all([page.waitForResponse(response => response.url().includes('/sw/promote/portrait') && response.url().includes('period=total')), dialog.getByText('累计', { exact: true }).click()])
await dialog.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
assert.equal(await dialog.locator('.metric-compare').count(), 0)
await page.screenshot({ path: path.join(process.env.TEMP, 'sw-channel-portrait.png'), animations: 'disabled' })
await dialog.locator('.profile-info').getByRole('button', { name: '2 款', exact: true }).click()
const gameDialog = page.getByRole('dialog', { name: '推广游戏数据 — lx测试渠道', exact: true })
await gameDialog.getByText('累计推广游戏', { exact: true }).waitFor()
assert.equal(games.at(-1).sourceDb, 'lx'); assert.equal(games.at(-1).promoteId, '100')
await gameDialog.locator('.el-drawer__close-btn').click(); await gameDialog.waitFor({ state: 'hidden' })
await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await dialog.waitFor({ state: 'hidden' })
await row('hw').getByRole('button', { name: '画像', exact: true }).click()
const other = page.getByRole('dialog', { name: '渠道画像 — hw测试渠道', exact: true })
await other.getByText('对应渠道预警', { exact: true }).waitFor()
assert.equal(requests.at(-1).sourceDb, 'hw'); assert.equal(alerts.at(-1).platformCodes, 'hw')
await other.getByRole('button', { name: '关闭', exact: true }).click(); await other.waitFor({ state: 'hidden' })
unavailable = true
await row('lx').getByRole('button', { name: '画像', exact: true }).click()
await dialog.getByText('付费分层所需数据尚未接入', { exact: true }).waitFor()
assert.equal(await dialog.locator('.pay-tier-item').count(), 0)
assert.deepEqual(errors, [])
const report = { method: '真实 Chrome,画像、游戏与预警数据为受控响应,不改业务数据', passed: true, activeMetric: true, totalNoComparison: true, canonicalLast7: true, fourPayTiers: true, gameDrawer: true, platformAndChannelAlertFilter: true, crossPlatformSameID: true, unavailableIsNotZero: true, pageErrors: errors }
await fs.writeFile('docs/sw6-to-kra/channel-portrait-page-2026-11-03.json', JSON.stringify(report, null, 2) + '\n')
console.log(JSON.stringify(report)); await context.close()
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
+218
View File
@@ -0,0 +1,218 @@
// 真实开发服务:检查 OA 数据、统计口径持久化与响应式布局。
// 临时修改当前测试账号的统计口径,结束时恢复;截图只写入指定的私有目录。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
;(async () => {
const base = process.env.SW_UI_BASE_URL || 'http://127.0.0.1:8080'
const output = process.env.SW_UI_OUTPUT_DIR
assert.ok(output && process.env.SW_TEST_TOKEN_FILE, 'Set SW_UI_OUTPUT_DIR and SW_TEST_TOKEN_FILE')
const token = (await fs.readFile(process.env.SW_TEST_TOKEN_FILE, 'utf8')).trim()
const browser = await chromium.launch({ channel: 'chrome', headless: true })
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } })
const headers = { 'x-token': token }
const api = async (method, endpoint, data) => {
const response = await context.request.fetch(`${base}/api${endpoint}`, { method, headers, data })
assert.ok(response.ok())
const body = await response.json()
assert.equal(body.code, 0)
return body.data
}
let original
const report = { checks: [], pageErrors: [] }
try {
await fs.mkdir(output, { recursive: true })
original = await api('GET', '/sw/user/preference')
await fs.writeFile(path.join(output, 'original-preference.json'), JSON.stringify(original))
await context.addInitScript(value => localStorage.setItem('token', value), token)
const page = await context.newPage()
page.on('pageerror', e => report.pageErrors.push(e.message))
const activationPath = '/sw/promoteActivePool/bindList'
await page.goto(`${base}/#/layout/sw-BusMgmt/ChannelActivationList`)
await page.locator('.el-table__body-wrapper tr').first().waitFor()
const activation = await api('GET', `${activationPath}?pageNum=1&pageSize=10&platformNames=lx,hw,yq`)
assert.equal(activation.available, true)
assert.ok(activation.total > 0)
assert.equal(await page.locator('.el-table__body-wrapper tr').count(), activation.rows.length)
assert.equal(await page.locator('.el-alert--warning').count(), 0)
report.checks.push({ name: 'actual OA activation rows', total: activation.total })
await page.screenshot({ path: path.join(output, 'activation.png'), animations: 'disabled' })
const pagination = page.locator('.kra-pagination')
const pageRequests = []
const trackPage = request => {
if (request.url().includes(activationPath)) pageRequests.push(Number(new URL(request.url()).searchParams.get('pageNum')))
}
page.on('request', trackPage)
const movePage = async (target, action) => {
pageRequests.length = 0
const [response] = await Promise.all([
page.waitForResponse(r => r.url().includes(activationPath) && Number(new URL(r.url()).searchParams.get('pageNum')) === target),
action()
])
const expected = (await response.json()).data
await page.waitForFunction(() => !document.querySelector('.el-loading-mask'))
assert.equal((await pagination.locator('.el-pager .is-active').innerText()).trim(), String(target))
assert.equal(await page.locator('.el-table__body-wrapper tr').count(), expected.rows.length)
const names = await page.locator('.el-table__body-wrapper tr td:nth-child(2) .cell').allTextContents()
assert.deepEqual(names.map(name => name.trim()), expected.rows.map(row => row.promoteAccount))
assert.deepEqual(pageRequests, [target], 'pagination must not trigger a second request back to page 1')
}
assert.ok(activation.total > 10, 'Pagination check needs more than one page')
await movePage(2, () => pagination.locator('.btn-next').click())
const lastPage = Math.ceil(activation.total / 10)
if (lastPage > 2) await movePage(lastPage, () => pagination.locator('.el-pager .number').filter({ hasText: new RegExp(`^${lastPage}$`) }).click())
await page.screenshot({ path: path.join(output, 'activation-last-page.png'), animations: 'disabled' })
await movePage(lastPage - 1, () => pagination.locator('.btn-prev').click())
if (lastPage - 1 !== 1) await movePage(1, () => pagination.locator('.el-pager .number').filter({ hasText: /^\s*1\s*$/ }).click())
page.off('request', trackPage)
report.checks.push({ name: 'activation pagination: next, last, previous, first; response rows match and no page reset', total: activation.total, lastPage })
const preference = page.getByRole('group', { name: '统计口径', exact: true })
for (const [key, label] of [['includeSubPromote', '包含下级渠道'], ['idcardDedup', '身份证去重']]) {
const checkbox = preference.getByRole('checkbox', { name: label, exact: true })
await page.waitForFunction(label => [...document.querySelectorAll('.business-preferences input')].some(el => el.closest('label')?.textContent.includes(label) && !el.disabled), label)
assert.equal(await checkbox.isChecked(), original[key])
const [saved] = await Promise.all([
page.waitForResponse(r => r.url().includes('/sw/user/preference') && r.request().method() === 'PUT'),
page.waitForResponse(r => r.url().includes(activationPath)),
preference.getByText(label, { exact: true }).click()
])
assert.equal((await saved.json()).code, 0)
assert.equal((await api('GET', '/sw/user/preference'))[key], !original[key])
await page.reload()
await preference.waitFor()
await page.waitForFunction(() => [...document.querySelectorAll('.business-preferences input')].every(el => !el.disabled))
assert.equal(await preference.getByRole('checkbox', { name: label, exact: true }).isChecked(), !original[key])
report.checks.push({ name: `${key}: visible, persisted, page refreshed` })
}
await api('PUT', '/sw/user/preference', original)
// 保存失败不能让勾选状态与服务端失去同步。
await page.reload()
await page.waitForFunction(() => document.querySelectorAll('.business-preferences input').length === 2 && [...document.querySelectorAll('.business-preferences input')].every(el => !el.disabled))
const rejectSave = route => route.request().method() === 'PUT'
? route.fulfill({ json: { code: 7, msg: '测试保存失败', data: {} } }) : route.continue()
await page.route('**/api/sw/user/preference', rejectSave)
await Promise.all([
page.waitForResponse(r => r.url().includes('/sw/user/preference') && r.request().method() === 'PUT'),
preference.getByText('包含下级渠道', { exact: true }).click()
])
await page.waitForFunction(() => [...document.querySelectorAll('.business-preferences input')].every(el => !el.disabled))
assert.equal(await preference.getByRole('checkbox', { name: '包含下级渠道', exact: true }).isChecked(), original.includeSubPromote)
await page.unroute('**/api/sw/user/preference', rejectSave)
report.checks.push({ name: 'failed preference save preserves previous value' })
const search = () => page.locator('.kra-search-box').getByRole('button', { name: '查询', exact: true }).click()
const activationPattern = '**/api/sw/promoteActivePool/bindList?*'
await page.route(activationPattern, route => route.fulfill({ json: { code: 7, msg: '测试查询失败', data: {} } }))
await search()
await page.getByText('查询失败,请重试', { exact: true }).waitFor()
assert.equal(await page.locator('.el-table__body-wrapper tr').count(), 0)
await page.unroute(activationPattern)
await page.route(activationPattern, route => route.fulfill({ json: { code: 0, data: { available: false, rows: [], total: 0 } } }))
await search()
await page.getByText('OA 数据源暂不可用', { exact: true }).waitFor()
await page.unroute(activationPattern)
await search()
await page.locator('.el-table__body-wrapper tr').first().waitFor()
report.checks.push({ name: 'activation error and unavailable states differ from empty results; retry recovers' })
for (const [name, route, grid] of [
['ranking', 'sw-DataAnalysis/RankingAnalysis', '.ranking-panels'],
['dashboard', 'dashboard', '.business-ranks']
]) {
await page.goto(`${base}/#/layout/${route}`)
await page.locator(grid).waitFor()
await page.waitForFunction(() => !document.querySelector('.el-loading-mask'))
await page.locator('.el-message').evaluateAll(nodes => nodes.forEach(node => node.querySelector('.el-message__closeBtn')?.click()))
if (name === 'ranking') {
for (const panel of await page.locator('.ranking-panel').all()) {
assert.match(await panel.innerText(), /上期环比/)
assert.doesNotMatch(await panel.innerText(), /百分点|\bpp\b/)
}
} else {
assert.equal(await page.getByText('快捷功能', { exact: true }).count(), 0)
assert.equal(await page.getByText('公告', { exact: true }).count(), 0)
assert.equal(await page.getByText('商务数据', { exact: true }).count(), 0)
assert.ok(await page.getByText('数据平台', { exact: true }).isVisible())
for (const index of [0, 1]) {
const panel = page.locator('.business-ranks > .el-card').nth(index)
await panel.locator('.business-rank-period').click()
const optionsId = await panel.getByRole('combobox').getAttribute('aria-controls')
await Promise.all([
page.waitForResponse(r => /\/sw\/workbench\/(registerTop|payRank)/.test(r.url()) && r.url().includes('period=last7')),
page.locator(`[id="${optionsId}"]`).getByRole('option', { name: '近7天', exact: true }).click()
])
await panel.locator('.ranking-table tbody tr').first().waitFor()
assert.equal(await panel.locator('.ranking-empty').count(), 0)
}
report.checks.push({ name: 'dashboard period filters refresh real data in compact tables' })
}
for (const [width, columns] of [[1920, 4], [1600, 4], [1366, 4], [1024, 2], [390, 1]]) {
await page.setViewportSize({ width, height: 1000 })
if (width === 390) await page.getByRole('button', { name: '打开菜单', exact: true }).waitFor()
await page.waitForFunction(({grid, columns}) => getComputedStyle(document.querySelector(grid)).gridTemplateColumns.split(' ').length === columns, { grid, columns })
const boxes = await page.locator(`${grid} > *`).evaluateAll(nodes => nodes.map(n => { const r = n.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right } }))
if (columns === 4) assert.ok(boxes.every(box => Math.abs(box.y - boxes[0].y) < 1))
assert.ok(boxes.every(box => box.x >= 0 && box.right <= width + 1), `panel overflow: ${name}, ${width}`)
const overflow = await page.locator(`${grid} .el-table__body-wrapper, ${grid} .ranking-table-wrap`).evaluateAll(nodes => nodes.some(n => n.querySelector('table')?.clientWidth > n.clientWidth + 1))
assert.equal(overflow, false, `table horizontal overflow: ${name}, ${width}`)
if (name === 'ranking') {
const tallRows = await page.locator('.ranking-table tr').evaluateAll(nodes => nodes.some(n => n.getBoundingClientRect().height > 40))
assert.equal(tallRows, false, `ranking rows should stay compact: ${width}`)
}
const clippedNumbers = await page.locator(`${grid} tbody .ranking-numeric, ${grid} .ranking-number`).evaluateAll(nodes => nodes.some(n => n.scrollWidth > n.clientWidth + 1))
assert.equal(clippedNumbers, false, `numeric values must be fully visible: ${name}, ${width}`)
const prefBox = await preference.boundingBox()
assert.ok(prefBox && prefBox.x >= 0 && prefBox.x + prefBox.width <= width, `preference overflow at ${width}`)
const toolbarOverflow = await page.getByRole('button', { name: '打开菜单', exact: true }).count() ? await page.locator('.business-preferences').evaluate(el => {
const header = el.closest('.fixed')
return header.scrollWidth > header.clientWidth + 1
}) : false
assert.equal(toolbarOverflow, false, `mobile header overflow: ${width}`)
await page.screenshot({ path: path.join(output, `${name}-${width}.png`), animations: 'disabled' })
report.checks.push({ name: `${name} layout`, width, columns, noOverflow: true })
}
await page.setViewportSize({ width: 1600, height: 1000 })
}
await page.goto(`${base}/#/layout/sw-BusMgmt/BusinessList`)
await page.getByRole('button', { name: '添加子商务', exact: true }).waitFor()
assert.equal(await page.locator('.business-view-switch').count(), 0)
assert.equal(await page.getByRole('tab', { name: '商务列表', exact: true }).count(), 0)
await page.screenshot({ path: path.join(output, 'business-list-admin.png'), animations: 'disabled' })
// 只替换展示身份核对三级专属切换器;读取接口仍用授权测试身份,不做业务写入。
await page.route('**/api/user/getUserInfo', async route => {
const response = await route.fetch()
const body = await response.json()
Object.assign(body.data.userInfo, { level: 3, authorityId: 903 })
await route.fulfill({ response, json: body })
})
await page.reload()
await page.getByRole('radiogroup', { name: '商务管理视图', exact: true }).waitFor()
assert.equal(await page.getByRole('button', { name: '添加子商务', exact: true }).count(), 0)
const viewSwitch = page.getByRole('radiogroup', { name: '商务管理视图', exact: true })
await Promise.all([
page.waitForResponse(r => r.url().includes('/sw/business/bindPlatform')),
viewSwitch.getByText('绑定列表', { exact: true }).click()
])
assert.equal(await page.getByPlaceholder('请输入账号', { exact: true }).isVisible(), false)
await viewSwitch.getByText('商务列表', { exact: true }).click()
assert.ok(await page.getByPlaceholder('请输入账号', { exact: true }).isVisible())
await page.screenshot({ path: path.join(output, 'business-list-level3.png'), animations: 'disabled' })
report.checks.push({ name: 'business list: no redundant single tab; controlled level-3 switch works' })
assert.deepEqual(report.pageErrors, [])
report.passed = true
} catch (e) {
const page = context.pages()[0]
if (page) await page.screenshot({ path: path.join(output, 'failure.png'), animations: 'disabled' }).catch(() => {})
throw e
} finally {
if (original) await api('PUT', '/sw/user/preference', original)
await fs.writeFile(path.join(output, 'ui-checks.json'), JSON.stringify(report, null, 2) + '\n')
await browser.close()
}
console.log(JSON.stringify(report))
})().catch(e => { console.error(e.stack); process.exitCode = 1 })
+188
View File
@@ -0,0 +1,188 @@
"""在同一测试服务器核对 SW 源副本、备份目标库并执行迁移;凭据只读取本地配置。"""
import argparse
import datetime as dt
import gzip
import hashlib
import json
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
import pymysql
import yaml
def identifier(value):
if not re.fullmatch(r"[A-Za-z0-9_]+", value):
raise ValueError("数据库和表名只能包含字母、数字、下划线")
return "`" + value + "`"
def write_json(path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8")
def connect(config):
return pymysql.connect(host=config["host"], port=int(config["port"]), user=config["user"],
password=config["password"], charset="utf8mb4", connect_timeout=10,
read_timeout=180, autocommit=True, cursorclass=pymysql.cursors.DictCursor)
def query(conn, sql, args=None):
with conn.cursor() as cursor:
cursor.execute(sql, args)
return cursor.fetchall()
def source_tables(sql):
tables = re.findall(r"CREATE TABLE `bak_sw2kra_20261103_([^`]+)` LIKE", sql)
if len(tables) != 26 or len(set(tables)) != 26:
raise ValueError("迁移源表清单发生变化,请先复核备份范围")
return tables
def fingerprint(conn, database, table):
columns = query(conn, "SELECT column_name,data_type FROM information_schema.columns WHERE table_schema=%s AND table_name=%s ORDER BY ordinal_position", (database, table))
keys = query(conn, "SELECT column_name FROM information_schema.statistics WHERE table_schema=%s AND table_name=%s AND index_name='PRIMARY' ORDER BY seq_in_index", (database, table))
if not keys:
raise ValueError("备份核对要求稳定主键:" + table)
order = ",".join(identifier(item["column_name"]) for item in keys)
digest = hashlib.sha256()
rows = query(conn, f"SELECT * FROM {identifier(database)}.{identifier(table)} ORDER BY {order}")
json_columns = [item["column_name"] for item in columns if item["data_type"] == "json"]
for row in rows:
# JSON 的内部二进制布局在导入后可能不同,比较实际业务值而非 CHECKSUM 的存储字节。
for name in json_columns:
if row[name] is not None:
row[name] = json.loads(row[name])
digest.update(json.dumps(row, ensure_ascii=False, sort_keys=True, default=str, separators=(",", ":")).encode())
digest.update(b"\n")
return {"rows": len(rows), "sha256": digest.hexdigest()}
def client_options(config, folder):
# 使用独立选项文件,避免密码出现在进程命令行或输出中。
def escape(value):
return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r")
handle = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".cnf", dir=folder, delete=False)
with handle:
handle.write("[client]\n")
for key in ("host", "port", "user", "password"):
handle.write(f'{key}="{escape(config[key])}"\n')
handle.write("default-character-set=utf8mb4\n")
return Path(handle.name)
def prepare(args, config, sql):
if (args.state / (args.target + "-migration-source.sql.gz")).exists():
raise ValueError("目标备份已存在;请使用新的状态目录,保留原始备份")
tables = source_tables(sql)
manifest = {"source": args.source, "target": args.target, "created_at": dt.datetime.now(dt.timezone.utc),
"sql_sha256": hashlib.sha256(sql.encode()).hexdigest(), "checks": []}
with connect(config) as conn:
manifest["server"] = query(conn, "SELECT @@hostname hostname,@@version version")[0]
if query(conn, "SELECT 1 FROM information_schema.tables WHERE table_schema=%s AND table_name='sys_users'", (args.target,)):
raise ValueError("目标已迁移,不能覆盖原始基线")
for table in tables:
checksums = query(conn, f"CHECKSUM TABLE {identifier(args.source)}.{identifier(table)}, {identifier(args.target)}.{identifier(table)} EXTENDED")
values = [row["Checksum"] for row in checksums]
fingerprints = [fingerprint(conn, db, table) for db in (args.source, args.target)]
item = {"table": table, "rows": [item["rows"] for item in fingerprints], "checksums": values,
"fingerprints": fingerprints, "passed": fingerprints[0] == fingerprints[1]}
manifest["checks"].append(item)
print(json.dumps(item, ensure_ascii=False), flush=True)
if not all(item["passed"] for item in manifest["checks"]):
write_json(args.state / "baseline.json", manifest)
raise RuntimeError("源库与目标库框架数据不一致,未执行迁移")
manifest["tables_before"] = query(conn, "SELECT table_name,engine FROM information_schema.tables WHERE table_schema=%s ORDER BY table_name", (args.target,))
schemas = {}
for row in manifest["tables_before"]:
table = row["table_name"]
schemas[table] = query(conn, f"SHOW CREATE TABLE {identifier(args.target)}.{identifier(table)}")[0]["Create Table"]
write_json(args.state / "schema-before.json", schemas)
dump = Path(shutil.which("mysqldump") or Path(shutil.which("mysql")).with_name("mysqldump.exe"))
options = client_options(config, args.state)
backup = args.state / (args.target + "-migration-source.sql.gz")
try:
command = [str(dump), "--defaults-extra-file=" + str(options), "--single-transaction", "--quick", "--hex-blob",
"--set-gtid-purged=OFF", "--skip-lock-tables", args.target, *tables]
with (args.state / "backup.stderr.log").open("wb") as errors:
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=errors)
with gzip.open(backup, "wb") as output:
shutil.copyfileobj(process.stdout, output)
if process.wait() != 0:
raise RuntimeError("备份失败,详情仅保存在本地日志")
# 完整解压检查,避免把未写完的压缩文件当作成功备份。
with gzip.open(backup, "rb") as stream:
while stream.read(1024 * 1024):
pass
manifest["backup"] = {"path": str(backup.resolve()), "bytes": backup.stat().st_size,
"sha256": hashlib.sha256(backup.read_bytes()).hexdigest()}
write_json(args.state / "baseline.json", manifest)
print(json.dumps({"backup": manifest["backup"], "passed": True}), flush=True)
finally:
options.unlink(missing_ok=True)
def migrate(args, config, sql):
manifest = json.loads((args.state / "baseline.json").read_text(encoding="utf-8"))
if (manifest["source"], manifest["target"]) != (args.source, args.target):
raise ValueError("备份对应的数据库与本次选择不符")
backup = Path(manifest["backup"]["path"])
if hashlib.sha256(backup.read_bytes()).hexdigest() != manifest["backup"]["sha256"]:
raise ValueError("备份文件校验失败")
statements = re.findall(r"(?m)^USE `[^`]+`;", sql)
if len(statements) != 1:
raise ValueError("SQL 必须只有一个显式 USE 入口")
prepared = sql.replace(statements[0], "USE " + identifier(args.target) + ";")
(args.state / "executed-migration.sql").write_text(prepared, encoding="utf-8")
with connect(config) as conn:
actual_server = query(conn, "SELECT @@hostname hostname,@@version version")[0]
if actual_server != manifest["server"]:
raise ValueError("连接服务器与备份时不同")
# 仅首次执行需要核对原始基线;重复执行由迁移 SQL 的完成账本保护。
migrated = query(conn, "SELECT 1 FROM information_schema.tables WHERE table_schema=%s AND table_name='sys_users'", (args.target,))
if not migrated:
for item in manifest["checks"]:
if fingerprint(conn, args.target, item["table"]) != item["fingerprints"][1]:
raise ValueError("目标数据在备份后变化:" + item["table"])
options = client_options(config, args.state)
try:
with (args.state / "migration.stdout.log").open("wb") as output, (args.state / "migration.stderr.log").open("wb") as errors:
result = subprocess.run([shutil.which("mysql"), "--defaults-extra-file=" + str(options), "--batch", "--binary-mode"],
input=prepared.encode("utf-8"), stdout=output, stderr=errors)
print(json.dumps({"target": args.target, "exit_code": result.returncode,
"sql_sha256": hashlib.sha256(prepared.encode()).hexdigest()}), flush=True)
if result.returncode:
raise RuntimeError("迁移 SQL 失败,请检查本地日志;不会自动覆盖部分迁移状态")
finally:
options.unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("prepare", "migrate"))
parser.add_argument("--config", type=Path, default=Path("configs/config.yaml"))
parser.add_argument("--sql", type=Path, default=Path("docs/sw6-to-kra/sw_to_kra_migrate.sql"))
parser.add_argument("--source", default="sw8")
parser.add_argument("--target", default="sw9")
parser.add_argument("--state", type=Path, required=True)
args = parser.parse_args()
identifier(args.source)
identifier(args.target)
if args.source == args.target or args.target in ("sw", "sw6"):
raise ValueError("演练目标必须为独立测试副本")
args.state = args.state.resolve()
if args.state.is_relative_to(Path(__file__).resolve().parents[1]):
raise ValueError("含账号数据的备份必须保存在仓库外")
args.state.mkdir(parents=True, exist_ok=True)
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))["data"]["database"]
sql = args.sql.read_text(encoding="utf-8")
{"prepare": prepare, "migrate": migrate}[args.action](args, config, sql)
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
"""只读请求商务接口,记录耗时、完整数据摘要及 SQL;敏感内容必须存放仓库外。"""
import argparse
import hashlib
import json
import re
import statistics
import time
import urllib.parse
import urllib.request
from pathlib import Path
def log_offsets(directory):
return {p: p.stat().st_size for p in directory.rglob("*.log")}
def read_queries(directory, offsets):
queries = []
files = list(directory.rglob("*.log"))
files = [file for file in files if file.parent.name == "sql"] or files
for file in files:
with file.open("rb") as stream:
stream.seek(offsets.get(file, 0))
for line in stream:
try:
row = json.loads(line)
except (ValueError, UnicodeDecodeError):
continue
if row.get("gorm_logger") and "sql" in row:
queries.append(row)
return queries
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--base-url", default="http://127.0.0.1:19004")
parser.add_argument("--phase", required=True)
parser.add_argument("--repeat", type=int, default=2)
parser.add_argument("--cases", type=Path)
args = parser.parse_args()
if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]):
raise ValueError("完整接口响应和 SQL 必须留在仓库外")
output = args.runtime / args.phase
output.mkdir(exist_ok=True)
cases = json.loads((args.cases or args.runtime / "cases.json").read_text(encoding="utf-8"))
token = (args.runtime / "new-admin.token").read_text(encoding="utf-8").strip()
report = []
for case in cases:
elapsed = []
for round_number in range(args.repeat):
offsets = log_offsets(args.runtime / "sql-logs")
url = args.base_url + case["path"] + "?" + urllib.parse.urlencode(case["params"], doseq=True)
request = urllib.request.Request(url, headers={"x-token": token, "X-Request-Id": f"perf-{args.phase}-{case['name']}-{round_number}"})
started = time.monotonic()
with urllib.request.urlopen(request, timeout=300) as response:
body = json.load(response)
elapsed.append(round((time.monotonic() - started) * 1000, 2))
if body.get("code") != 0:
raise RuntimeError("请求失败:" + case["name"])
queries = read_queries(args.runtime / "sql-logs", offsets)
data = body["data"]
capture = {"case": case, "data": data, "elapsedMs": elapsed[-1], "queries": queries}
(output / f"{case['name']}-{round_number}.json").write_text(json.dumps(capture, ensure_ascii=False, indent=2), encoding="utf-8")
digest = hashlib.sha256(json.dumps(data, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
slow = sorted(queries, key=lambda row: row.get("elapsed_ms", 0), reverse=True)[:5]
item = {"case": case["name"], "round": round_number, "elapsedMs": elapsed[-1], "dataSha256": digest, "queryCount": len(queries), "slowQueries": [{"elapsedMs": q.get("elapsed_ms"), "rows": q.get("rows"), "tables": list(dict.fromkeys(re.findall(r"(?:FROM|JOIN)\s+`?([\w.]+)", q["sql"], re.I)))} for q in slow]}
report.append(item)
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(json.dumps(item), flush=True)
print(json.dumps({"case": case["name"], "medianMs": statistics.median(elapsed)}), flush=True)
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
"""在同一个 sw9 快照上核对优化前后接口,涵盖分页/排序及统计偏好。"""
import argparse
import json
import urllib.parse
import urllib.request
from pathlib import Path
import yaml
from sw_database_rehearsal import connect, query
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--before-url", default="http://127.0.0.1:19002")
parser.add_argument("--after-url", default="http://127.0.0.1:19004")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]):
raise ValueError("原始响应必须保存在仓库外")
config = yaml.safe_load((args.runtime / "config.yaml").read_text(encoding="utf-8"))["data"]["database"]
if config["name"] != "sw9":
raise ValueError("统计偏好测试仅允许 sw9")
folder = args.runtime / "parity"
folder.mkdir(exist_ok=True)
reports = []
def api(base, token, path, params=None, payload=None):
url = base + path + ("?" + urllib.parse.urlencode(params, doseq=True) if params else "")
request = urllib.request.Request(url, headers={"x-token": token, "Content-Type": "application/json"}, data=json.dumps(payload).encode() if payload is not None else None, method="PUT" if payload is not None else "GET")
with urllib.request.urlopen(request, timeout=300) as response:
data = json.load(response)
if data.get("code") != 0:
raise RuntimeError("接口请求失败:" + path)
return data["data"]
def compare(case, token, group):
values = [api(base, token, case["path"], case["params"]) for base in (args.before_url, args.after_url)]
name = group + "-" + case["name"]
(folder / (name + ".json")).write_text(json.dumps({"case": case, "before": values[0], "after": values[1]}, ensure_ascii=False, indent=2), encoding="utf-8")
row = {"case": name, "passed": values[0] == values[1]}
reports.append(row)
args.output.write_text(json.dumps({"checks": reports, "passed": all(row["passed"] for row in reports)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(row), flush=True)
if not row["passed"]:
raise AssertionError("接口数据不一致:" + name)
admin = (args.runtime / "new-admin.token").read_text(encoding="utf-8").strip()
for case in json.loads((args.runtime / "parity-cases.json").read_text(encoding="utf-8")):
compare(case, admin, "admin")
token = (args.runtime / "new-level3.token").read_text(encoding="utf-8").strip()
user = api(args.before_url, token, "/user/getUserInfo")["userInfo"]["ID"]
original_preference = api(args.before_url, token, "/sw/user/preference")
cases = json.loads((args.runtime / "cases.json").read_text(encoding="utf-8"))
with connect(config) as db:
original = query(db, "SELECT * FROM sw9.sys_user_preference WHERE user_id=%s", (user,))
try:
for include, dedup in ((True, True), (True, False), (False, True), (False, False)):
api(args.before_url, token, "/sw/user/preference", payload={"includeSubPromote": include, "idcardDedup": dedup})
for case in cases:
compare(case, token, f"level3-sub{int(include)}-dedup{int(dedup)}")
finally:
try:
api(args.before_url, token, "/sw/user/preference", payload=original_preference)
finally:
# Restore the row even if the service stopped during verification.
if original:
row = original[0]
query(db, "UPDATE sw9.sys_user_preference SET include_sub_promote=%s,idcard_dedup=%s,update_time=%s WHERE user_id=%s", (row["include_sub_promote"], row["idcard_dedup"], row["update_time"], user))
else:
query(db, "DELETE FROM sw9.sys_user_preference WHERE user_id=%s", (user,))
assert query(db, "SELECT * FROM sw9.sys_user_preference WHERE user_id=%s", (user,)) == original
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
"""以同一主账号的实际数据请求新旧明细导出,并逐单元格核对,文件保留在仓库外。"""
import argparse
import decimal
import io
import json
import time
import urllib.parse
import urllib.request
from pathlib import Path
import openpyxl
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--old-url", default="http://127.0.0.1:18080")
parser.add_argument("--new-url", default="http://127.0.0.1:19001")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]):
raise ValueError("导出文件必须保存在仓库外")
client = json.loads((args.runtime / "legacy-client.json").read_text(encoding="utf-8"))["client_id"]
headers = [{"Authorization": "Bearer " + (args.runtime / "legacy-admin.token").read_text().strip(), "clientid": client},
{"x-token": (args.runtime / "new-admin.token").read_text().strip()}]
reports = []
for kind, time_key in (("register", "Register"), ("spend", "Pay")):
params = {"sourceDb": "lx", "pageSize": 10, "begin" + time_key + "Time": "2026-11-03", "end" + time_key + "Time": "2026-11-03"}
request = urllib.request.Request(args.new_url + "/sw/" + kind + "/list?" + urllib.parse.urlencode(params), headers=headers[1])
with urllib.request.urlopen(request, timeout=60) as response:
body = json.load(response)
if body.get("code") != 0:
raise RuntimeError("读取导出样本失败:" + kind)
sample = next(row for row in body["data"]["rows"] if row.get("userId") is not None)
params["userId"] = sample["userId"]
sheets, durations = [], []
for i, base in enumerate((args.old_url, args.new_url)):
payload = urllib.parse.urlencode(params).encode()
request = urllib.request.Request(base + ("/system/" if i == 0 else "/sw/") + kind + "/export", data=payload,
headers={**headers[i], "Content-Type": "application/x-www-form-urlencoded"}, method="POST")
started = time.monotonic()
with urllib.request.urlopen(request, timeout=120) as response:
raw = response.read()
durations.append(round((time.monotonic() - started) * 1000))
if not raw.startswith(b"PK"):
raise RuntimeError("导出未返回工作簿:" + kind + "/" + str(i))
(args.runtime / (("legacy-" if i == 0 else "new-") + kind + "-export.xlsx")).write_bytes(raw)
workbook = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
sheets.append(list(workbook.worksheets[0].values))
workbook.close()
differences = []
for row_number, (old, new) in enumerate(zip(*sheets), 1):
if len(old) != len(new):
differences.append({"row": row_number, "kind": "columns"})
for col, (a, b) in enumerate(zip(old, new), 1):
if a == b or (a in (None, "") and b in (None, "")):
continue
if isinstance(a, (int, float)) or isinstance(b, (int, float)):
try:
if decimal.Decimal(str(a)) == decimal.Decimal(str(b)):
continue
except decimal.InvalidOperation:
pass
differences.append({"row": row_number, "column": col, "kind": "value"})
rows = [len(sheet) - 1 for sheet in sheets]
reports.append({"kind": kind, "rows": rows, "columns": [len(sheet[0]) for sheet in sheets], "elapsed_ms": durations,
"differences": differences, "passed": rows[0] > 0 and rows[0] == rows[1] and not differences})
print(json.dumps(reports[-1], ensure_ascii=False), flush=True)
args.output.write_text(json.dumps({"checks": reports, "passed": all(row["passed"] for row in reports)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if not all(row["passed"] for row in reports):
raise SystemExit(1)
if __name__ == "__main__":
main()
-72
View File
@@ -1,72 +0,0 @@
"""请求运行中的商务接口并生成脱敏结果;令牌从仓库外文件读取。"""
import argparse
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
results = []
def request(method, path, payload=None, expected=0):
started = time.monotonic()
req = urllib.request.Request(
args.base_url.rstrip("/") + path,
data=json.dumps(payload).encode() if payload is not None else None,
headers={"x-token": token, "Content-Type": "application/json"}, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as response:
body = json.load(response)
item = {"method": method, "path": path, "http": response.status,
"code": body.get("code"), "msg": body.get("msg"),
"elapsed_ms": round((time.monotonic() - started) * 1000)}
item["passed"] = response.status == 200 and body.get("code") == expected
data = body.get("data")
if isinstance(data, dict) and "rows" in data:
item.update(total=data.get("total"), rows=len(data["rows"]))
if "available" in data:
item["available"] = data["available"]
results.append(item)
print(json.dumps(item, ensure_ascii=False), flush=True)
return data if item["passed"] else None
except (urllib.error.URLError, TimeoutError, ValueError) as error:
item = {"method": method, "path": path, "passed": False, "error": str(error)}
results.append(item)
print(json.dumps(item, ensure_ascii=False), flush=True)
return None
first = request("GET", "/sw/business/list?pageNum=1&pageSize=500")
request("GET", "/sw/business/list?pageNum=2&pageSize=10")
if first and first["rows"]:
account = first["rows"][0]
request("GET", f"/sw/business/{account['userId']}")
request("GET", "/sw/business/list?" + urllib.parse.urlencode({"userName": account["userName"]}))
bound = next((row for row in first["rows"] if row["bindCount"] > 0), account)
request("GET", f"/sw/business/{bound['userId']}/bindPlatform")
request("GET", f"/sw/business/{bound['userId']}/promotes")
request("GET", "/sw/user/preference")
request("GET", "/sw/business/bindPlatform?pageNum=1&pageSize=10")
request("GET", "/sw/business/bindPlatform?sourceDb=lx,hw&status=0&pageSize=2")
# 使用校验必然拒绝的请求体检查写接口,不新增或修改测试库业务记录。
for method, path in [("PUT", "/sw/user/preference"), ("POST", "/sw/business"),
("PUT", "/sw/business"), ("POST", "/sw/business/bindPlatform"),
("DELETE", "/sw/business/bindPlatform")]:
request(method, path, {}, expected=7)
args.output.write_text(json.dumps({"base_url": args.base_url, "checks": results,
"passed": all(row["passed"] for row in results)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if not all(row["passed"] for row in results):
raise SystemExit(1)
if __name__ == "__main__":
main()
-59
View File
@@ -1,59 +0,0 @@
"""只读检查明细接口,并用确定为空的商务筛选验证真实 XLSX 导出。"""
import argparse
import io
import json
import urllib.parse
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
checks = []
keyword = "__kra_empty_export_probe_20261104__"
def call(method, path, payload=None, expected=0, excel=False):
body = urllib.parse.urlencode(payload).encode() if payload is not None else None
request = urllib.request.Request(args.base_url + path, method=method, data=body,
headers={"x-token": token, "Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
item = {"method": method, "path": path, "http": response.status}
if excel:
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
sheet = ET.fromstring(archive.read("xl/worksheets/sheet1.xml"))
rows = sheet.findall(".//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}row")
item.update(xlsx=True, worksheet_rows=len(rows), passed=response.headers.get("success") == "true" and len(rows) == 1)
else:
value = json.loads(raw)
data = value.get("data", {})
item.update(code=value.get("code"), msg=value.get("msg"), passed=value.get("code") == expected)
if isinstance(data, dict) and "rows" in data:
item.update(total=data.get("total"), available=data.get("available"))
checks.append(item)
return item
for kind in ["register", "spend"]:
item = call("GET", f"/sw/{kind}/list?sourceDb=lx,hw,yq")
empty = call("GET", f"/sw/{kind}/list?" + urllib.parse.urlencode({"busierAccount": keyword}))
empty["passed"] &= empty.get("total") == 0 and empty.get("available") is True
call("POST", f"/sw/{kind}/export", {"busierAccount": keyword}, excel=True)
if item.get("available") is False:
call("POST", f"/sw/{kind}/export", {"sourceDb": "lx"}, expected=7)
call("GET", "/sw/register/list?beginRegisterTime=bad&endRegisterTime=2026-11-04", expected=7)
report = {"scope": "真实 sw7 会话,只读查询;导出使用确定为空的商务筛选,仅校验表头工作簿,不导出真实业务行。", "checks": checks, "passed": all(item["passed"] for item in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if not report["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
-72
View File
@@ -1,72 +0,0 @@
// 明细页面采用受控列表数据;下载通过真实服务的确定空查询生成 XLSX,不读取实际业务行。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
;(async () => {
const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim()
const browser = await chromium.launch({ channel: 'chrome', headless: true })
const reports = []
try {
for (const kind of ['register', 'spend']) {
const context = await browser.newContext({ viewport: { width: 1650, height: 1000 }, acceptDownloads: true })
await context.addInitScript(value => localStorage.setItem('token', value), token)
const page = await context.newPage()
const errors = [], queries = [], exports = []
let failed = false
page.on('pageerror', error => errors.push(error.message))
await page.route('http://127.0.0.1:8080/api/**', async route => {
const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4)
if (api === `/sw/${kind}/list`) {
queries.push(Object.fromEntries(url.searchParams))
if (failed) return route.fulfill({ json: { code: 7, msg: '测试查询失败', data: {} } })
const row = kind === 'register'
? { id: 7, userId: 7, sourceDb: 'lx', sourceDbLabel: '龙翔', registerTime: 1793664000, fgameName: '测试游戏', totalPayAmount: '9007199254740993.10', registerIp: '192.0.2.1', deviceName: '测试机型', promoteAccount: '测试渠道', busierName: 'SDK(测试商务)' }
: { id: 71, userId: 7, sourceDb: 'lx', sourceDbLabel: '龙翔', payOrderNumber: '00000000000071', gameName: '测试游戏', serverName: '测试区服', gamePlayerName: '测试角色', payAmount: '5000.00', payTime: 1793664000, payWay: 3, payStatus: 0, spendIp: '192.0.2.2', promoteAccount: '测试渠道', busierName: 'SDK(测试商务)' }
return route.fulfill({ json: { code: 0, msg: '成功', data: { rows: [row], total: 1, totalPayAmount: '2300.00', available: true } } })
}
if (api === '/sw/serverAnalysis/games') return route.fulfill({ json: { code: 0, data: [{ sourceDb: 'lx', gameId: 1, gameName: '可选游戏' }, { sourceDb: 'hw', gameId: 2, gameName: '可选游戏' }] } })
if (api === `/sw/${kind}/export`) {
exports.push(Object.fromEntries(new URLSearchParams(request.postData())))
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api, method: 'POST', postData: 'busierAccount=__kra_empty_export_probe_20261104__', headers: { ...request.headers(), 'content-type': 'application/x-www-form-urlencoded', 'x-token': token } })
return route.fulfill({ response })
}
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
return route.fulfill({ response })
})
await page.goto(`http://127.0.0.1:8080/#/layout/sw-DataAnalysis/${kind === 'register' ? 'RegistrationDetails' : 'RechargeDetails'}`)
await page.getByText('测试渠道', { exact: true }).waitFor()
const first = queries[0], begin = first[kind === 'register' ? 'beginRegisterTime' : 'beginPayTime'], end = first[kind === 'register' ? 'endRegisterTime' : 'endPayTime']
assert.equal((Date.parse(end) - Date.parse(begin)) / 86400000, 29)
if (kind === 'register') {
assert.equal(await page.getByRole('columnheader', { name: 'ID', exact: true }).count(), 0)
await page.getByText('¥9,007,199,254,740,993.10', { exact: true }).waitFor()
} else {
assert.equal(await page.locator('.large-recharge-row').count(), 1)
await page.getByText('¥2,300.00', { exact: true }).waitFor()
}
const form = page.locator('.kra-search-box')
await form.locator('.el-form-item').filter({ has: page.locator('label').getByText('游戏', { exact: true }) }).locator('.el-select__wrapper').click()
await page.getByRole('option', { name: '可选游戏', exact: true }).click()
await page.keyboard.press('Escape')
await form.getByPlaceholder('玩家ID', { exact: true }).fill('42')
await Promise.all([page.waitForResponse(response => response.url().includes(`/sw/${kind}/list`) && response.url().includes('userId=42')), form.getByRole('button', { name: '搜索', exact: true }).click()])
assert.equal(queries.at(-1).gameName, '可选游戏')
const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: '导出', exact: true }).click()])
const bytes = await fs.readFile(await download.path())
assert.equal(bytes.subarray(0, 2).toString(), 'PK')
assert.equal(exports.at(-1).userId, '42')
assert.equal(exports.at(-1).gameName, '可选游戏')
await page.screenshot({ path: path.join(process.env.TEMP, `sw-${kind}-details.png`), animations: 'disabled' })
failed = true
await form.getByRole('button', { name: '搜索', exact: true }).click()
await page.locator('.el-alert').getByText('测试查询失败', { exact: true }).waitFor()
if (kind === 'spend') assert.equal(await page.getByText('¥2,300.00', { exact: true }).count(), 0)
assert.deepEqual(errors, [])
const report = { kind, passed: true, defaultThirtyDays: true, gameNameSelection: true, exactAmountOrSuccessSummary: true, xlsxDownload: true, exportKeepsFilters: true, failedQueryClearsOldData: true, pageErrors: errors }
reports.push(report); console.log(JSON.stringify(report)); await context.close()
}
await fs.writeFile('docs/sw6-to-kra/ledger-page-2026-11-04.json', JSON.stringify({ method: '真实 Chrome,列表数据受控;导出使用真实服务的空筛选生成 XLSX,不读取业务数据。', reports }, null, 2) + '\n')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
-52
View File
@@ -1,52 +0,0 @@
// 验证旧商务登录外观和三步找回密码;短信接口始终使用测试响应。
const fs=require('node:fs/promises');
const path=require('node:path');
const {chromium}=require(process.env.SW_PLAYWRIGHT_MODULE||'playwright');
(async()=>{
const browser=await chromium.launch({channel:'chrome',headless:true});
const calls=[];
try {
const context=await browser.newContext({viewport:{width:1440,height:1000}});
const page=await context.newPage();
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('http://127.0.0.1:8080/api/**',async route=>{
const request=route.request();const uri=new URL(request.url());const apiPath=uri.pathname.slice(4);
if(apiPath.startsWith('/sw/auth/forgot-password/')){
const action=apiPath.split('/').pop();const payload=request.postDataJSON();calls.push({action,payload});
const data=action==='verify-code'?{resetToken:'a'.repeat(64)}:action==='reset'?{}:{maskedPhone:'138****8000'};
await route.fulfill({json:{code:0,msg:'成功',data}});return;
}
if(apiPath==='/base/captcha'){
const response=await route.fetch({url:'http://127.0.0.1:18000/base/captcha'});const body=await response.json();body.data.openCaptcha=true;await route.fulfill({response,json:body});return;
}
await route.continue();
});
await page.goto('http://127.0.0.1:8080/#/login');
await page.getByText('欢迎登录',{exact:true}).waitFor();
await page.getByPlaceholder('请输入验证码',{exact:true}).waitFor();
if(await page.title()!=='遨游龙翔游戏平台')throw new Error('页面标题没有使用旧商务名称');
if(!await page.getByAltText('遨游龙翔游戏平台',{exact:true}).isVisible())throw new Error('旧品牌图片未显示');
await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-login-desktop.png'),fullPage:true,animations:'disabled'});
await page.getByRole('button',{name:'忘记密码?'}).click();
const dialog=page.getByRole('dialog',{name:'找回密码'});
await dialog.getByPlaceholder('请输入您注册的商务账号').fill('demo_biz');
await dialog.getByRole('button',{name:'下一步',exact:true}).click();
await dialog.getByText('138****8000',{exact:true}).waitFor();
await dialog.getByPlaceholder('请输入 6 位验证码').fill('123456');
await dialog.getByRole('button',{name:'下一步',exact:true}).click();
await dialog.getByPlaceholder('6-15 位字符').fill('abc123');
await dialog.getByPlaceholder('请再次输入新密码').fill('abc123');
await dialog.getByRole('button',{name:'完成',exact:true}).click();
await dialog.waitFor({state:'hidden'});
const actions=calls.map(c=>c.action);
if(JSON.stringify(actions)!==JSON.stringify(['check','send-code','verify-code','reset']))throw new Error('三步流程请求顺序不正确');
const reset=calls[3].payload;
if(reset.resetToken!=='a'.repeat(64)||reset.newPassword!=='abc123'||reset.confirmPassword!=='abc123')throw new Error('重置请求参数未对齐');
if(errors.length)throw new Error('页面运行时错误:'+errors.join(';'));
await page.setViewportSize({width:390,height:844});
await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-login-mobile.png'),fullPage:true,animations:'disabled'});
const report={method:'真实 Chrome 渲染;找回密码接口使用受控响应,不发送真实短信',title:await page.title(),actions,pageErrors:errors,passed:true};
await fs.writeFile('docs/sw6-to-kra/login-page-verification-2026-11-03.json',JSON.stringify(report,null,2));
console.log(JSON.stringify(report));
}finally{await browser.close();}
})().catch(e=>{console.error(e.message);process.exitCode=1});
-42
View File
@@ -1,42 +0,0 @@
"""通过真实菜单接口检查商务图标映射,不进行页面交互。"""
import argparse
import json
import re
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base-url', default='http://127.0.0.1:18000')
parser.add_argument('--token-file', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding='utf-8').strip()
icons = dict(re.findall(r'"(sw_[^"]+)":\s*"([^"]+)"', Path('internal/data/business/menu_icons.go').read_text(encoding='utf-8')))
req = urllib.request.Request(args.base_url + '/menu/getMenu', data=b'{}', headers={'x-token': token, 'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=30) as response:
data = json.load(response)
assert data['code'] == 0, data.get('msg')
rows = []
def walk(value):
if isinstance(value, dict):
name = value.get('name')
if name in icons and 'component' in value:
actual = value.get('meta', {}).get('icon')
rows.append({'name': name, 'icon': actual, 'expected': icons[name], 'passed': actual == icons[name]})
for item in value.values():
walk(item)
elif isinstance(value, list):
for item in value:
walk(item)
walk(data)
report = {'baseURL': args.base_url, 'menuCount': len(rows), 'checks': rows, 'passed': len(rows) == len(icons) and all(row['passed'] for row in rows)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False))
if not report['passed']:
raise SystemExit(1)
if __name__ == '__main__':
main()
-32
View File
@@ -1,32 +0,0 @@
// 核验实际安装的图标组件及 SVG 输出,不启动浏览器、不检查页面交互。
const fs = require('node:fs')
const path = require('node:path')
const assert = require('node:assert/strict')
const { createRequire } = require('node:module')
const root = path.resolve(__dirname, '..')
const localRequire = createRequire(path.join(root, 'web', 'package.json'))
const icons = localRequire('@element-plus/icons-vue')
const { createSSRApp, h } = localRequire('vue')
const { renderToString } = localRequire('@vue/server-renderer')
;(async () => {
const source = fs.readFileSync(path.join(root, 'internal/data/business/menu_icons.go'), 'utf8')
const entries = [...source.matchAll(/"(sw_[^"]+)":\s*"([^"]+)"/g)].map(match => ({ name: match[1], icon: match[2] }))
assert.equal(entries.length, 14)
const sql = fs.readFileSync(path.join(root, 'docs/sw6-to-kra/sw_to_kra_migrate.sql'), 'utf8')
const moduleSource = fs.readFileSync(path.join(root, 'internal/modules/business/definition.go'), 'utf8')
for (const row of entries) {
assert.ok(icons[row.icon], `${row.icon} 未注册`)
assert.ok(sql.includes(`WHEN '${row.name}' THEN '${row.icon}'`), `${row.name} 缺少重复执行修复`)
assert.ok(sql.includes(`WHEN '${row.name.slice(3)}' THEN '${row.icon}'`), `${row.name} 缺少首次迁移映射`)
assert.ok(moduleSource.includes(`Icon: databusiness.MenuIcon("${row.name}")`))
const rendered = await renderToString(createSSRApp({ render: () => h(icons[row.icon]) }))
assert.match(rendered, /<svg[\s>]/)
assert.match(rendered, /<(path|circle|rect|polygon|line)[\s>]/)
row.svgRendered = true
}
assert.ok(icons.Menu, 'SQL 默认图标必须已注册')
const result = { method: '本地已安装组件的 SSR SVG 验证,包含首次/重复迁移 SQL 与模块定义一致性检查', icons: entries, passed: true }
fs.writeFileSync(path.join(root, 'docs/sw6-to-kra/menu-icons-components-2026-11-04.json'), JSON.stringify(result, null, 2) + '\n')
console.log(JSON.stringify(result))
})().catch(error => { console.error(error); process.exitCode = 1 })
-48
View File
@@ -1,48 +0,0 @@
"""从指定旧仓库和现有迁移 SQL 生成可复核的迁移清单,不访问数据库。"""
import argparse
import hashlib
import json
import re
import subprocess
from pathlib import Path
def git(repo, *args):
return subprocess.check_output(["git", "-C", str(repo), *args], text=True, encoding="utf-8").strip()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--baseline", default="d31082f4a7816d4c83544b8f54e6f42a3b2132b1")
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
source = args.source.resolve()
sql = (root / "docs/sw6-to-kra/sw_to_kra_migrate.sql").read_text(encoding="utf-8")
contracts = [dict(zip(("method", "source_path", "target_path", "permission", "controller"), row))
for row in re.findall(r"\('(GET|POST|PUT|DELETE)','([^']+)','([^']+)','([^']+)','([^']+)'\)", sql)]
changes = []
for path in git(source, "diff", "--name-only", args.baseline, "main").splitlines():
# 只登记路径与摘要,不把旧配置中的凭据写入新仓库。
if not (path.endswith(".java") or "/mapper/" in path and path.endswith(".xml")):
continue
file = source / path
changes.append({"path": path, "exists": file.is_file(),
"sha256": hashlib.sha256(file.read_bytes()).hexdigest() if file.is_file() else None})
custom = []
for file in sorted((source / "ruoyi-admin/src/main/java").rglob("*Controller.java")):
text = file.read_text(encoding="utf-8")
if file.name == "ForgotPasswordController.java":
custom.append({"path": file.relative_to(source).as_posix(), "source": text})
result = {"baseline": args.baseline, "backend_head": git(source, "rev-parse", "main"),
"frontend_head": git(source / "web", "rev-parse", "dev/dev1.0"),
"contracts": contracts, "changed_sources": changes,
"additional_controllers": [item["path"] for item in custom]}
target = root / "docs/sw6-to-kra/source-inventory.json"
target.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"已登记 {len(contracts)} 个 SQL 接口契约、{len(changes)} 个变更源文件。")
if __name__ == "__main__":
main()
-86
View File
@@ -1,86 +0,0 @@
// 使用受控身份和接口数据检查实际 Vue 页面,不修改业务库。
const fs = require('node:fs/promises');
const path = require('node:path');
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright');
(async () => {
const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim();
const browser = await chromium.launch({channel:'chrome', headless:true});
const results = [];
try {
for (const level of [0,1,2,3]) {
const context = await browser.newContext({viewport:{width:1440,height:1000}});
await context.addInitScript(value => localStorage.setItem('token',value),token);
const page = await context.newPage();
const pageErrors=[];
page.on('pageerror',e=>pageErrors.push(e.message));
let bindingCalls=0; const channelCalls=[];
await page.route('http://127.0.0.1:8080/api/**', async route => {
const request=route.request();
const uri=new URL(request.url());
const apiPath=uri.pathname.slice(4);
let body;
if(apiPath==='/sw/business/list') body={code:0,msg:'成功',data:{rows:[{userId:79999,userName:'展示测试商务',level:2,parentUserName:'上级商务',realName:'测试姓名',phoneNumber:'',bindCount:0,promoteCount:0,createTime:'2026-11-03 10:00:00',status:'0',authorityId:902}],total:1}};
else if(/^\/sw\/business\/\d+\/promotes$/.test(apiPath)) { channelCalls.push(Object.fromEntries(uri.searchParams)); body={code:0,msg:'成功',data:{rows:[{promoteId:1,promoteName:'渠道测试',sourceDb:'lx',platformName:'龙翔'}],total:1,available:true}}; }
else if(apiPath==='/sw/business/bindPlatform') {
bindingCalls++;
body={code:0,msg:'成功',data:{total:3,rows:['0','2','3'].map((status,i)=>({userId:70000+level,sourceDb:'lx',platformName:'龙翔',platformBusierId:10+i,platformBusierAccount:['正常账号','驳回账号','待审账号'][i],bindUserName:'测试商务',promoteCount:status==='0'?2:0,status,createTime:'2026-11-03 10:00:00',reviewTime:'2026-11-03 11:00:00',reviewAccount:'测试审核人',rejectReason:status==='2'?'测试驳回原因':null}))}};
} else if(/^\/sw\/business\/\d+\/bindPlatform$/.test(apiPath)) body={code:0,msg:'成功',data:[]};
if(body) {await route.fulfill({json:body});return;}
const response=await route.fetch({url:'http://127.0.0.1:18000'+apiPath+uri.search,headers:{...request.headers(),'x-token':token}});
if(apiPath==='/user/getUserInfo') {
body=await response.json();
const info=body.data.userInfo;
Object.assign(info,{ID:70000+level,level,nickName:'页面核对账号',phone:'',email:'',authorityId:level===0?888:900+level});
await route.fulfill({response,json:body});
} else await route.fulfill({response});
});
await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/BusinessList');
try { await page.getByRole('tab',{name:'商务列表',exact:true}).waitFor({timeout:15000}); } catch (error) { console.log(JSON.stringify({url:page.url(),errors:pageErrors,text:(await page.locator('body').innerText()).slice(0,1800)})); await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-browser-failure.png')}); throw error; }
await page.getByText('展示测试商务',{exact:true}).waitFor();
const tabs=await page.getByRole('tab',{name:'绑定列表',exact:true}).count();
const editButtons=await page.getByRole('button',{name:'编辑',exact:true}).count();
if(tabs!==(level===3?1:0)||editButtons!==0) throw new Error('页面权限或编辑入口不一致 level='+level);
const result={level,bindingTab:tabs===1,editButtons,passed:true};
if(level===1 || level===2) {
await page.getByRole('row').filter({hasText:'展示测试商务'}).getByRole('button',{name:'查看SDK商务绑定详情',exact:true}).click();
await page.getByRole('dialog',{name:'SDK商务绑定详情'}).waitFor();
result.sdkDetailReadonly=(await page.getByRole('dialog',{name:'SDK商务绑定详情'}).getByRole('button',{name:'解绑',exact:true}).count())===0;
if(!result.sdkDetailReadonly)throw new Error('SDK 详情出现写操作');
await page.getByRole('dialog',{name:'SDK商务绑定详情'}).getByRole('button',{name:'关闭此对话框',exact:true}).click();
}
if(level===3) {
await page.getByRole('tab',{name:'绑定列表',exact:true}).click();
await page.getByText('正常账号',{exact:true}).waitFor();
const normal=page.getByRole('row').filter({hasText:'正常账号'});
const rejected=page.getByRole('row').filter({hasText:'驳回账号'});
const pending=page.getByRole('row').filter({hasText:'待审账号'});
result.normalUnbind=await normal.getByRole('button',{name:'解绑',exact:true}).count();
result.rejectedUnbind=await rejected.getByRole('button',{name:'解绑',exact:true}).count();
result.pendingUnbind=await pending.getByRole('button',{name:'解绑',exact:true}).count();
result.rejectedDetail=await rejected.getByRole('button',{name:'查看驳回',exact:true}).count();
if(result.normalUnbind!==1||result.rejectedUnbind||result.pendingUnbind||result.rejectedDetail!==1)throw new Error('绑定状态操作不一致');
result.rejectedChannelsDisabled=await rejected.getByRole('button',{name:'查看旗下渠道',exact:true}).isDisabled();
result.pendingChannelsDisabled=await pending.getByRole('button',{name:'查看旗下渠道',exact:true}).isDisabled();
if(!result.rejectedChannelsDisabled || !result.pendingChannelsDisabled)throw new Error('未通过绑定仍可查看渠道');
await normal.getByRole('button',{name:'查看旗下渠道',exact:true}).click();
await page.getByText('渠道测试',{exact:true}).waitFor();
const channelQuery=channelCalls.at(-1);
if(channelQuery.sourceDb!=='lx'||channelQuery.platformBusierId!=='10')throw new Error('旗下渠道未携带平台和商务筛选');
result.channelFilters=true;
await page.getByRole('dialog',{name:'旗下渠道',exact:true}).getByRole('button',{name:'关闭此对话框',exact:true}).click();
await rejected.getByRole('button',{name:'查看驳回',exact:true}).click();
await page.getByText('测试驳回原因',{exact:true}).waitFor();
const panel=await page.getByRole('dialog',{name:'审核详情',exact:true}).elementHandle();
await page.waitForFunction(el=>{const box=el.getBoundingClientRect();return box.x>=0 && box.right<=innerWidth+1},panel,{timeout:5000});
result.rejectDrawerInViewport=true;
} else if(bindingCalls!==0) throw new Error('非三级加载了绑定列表');
result.bindingCalls=bindingCalls;
result.pageErrors=pageErrors;
await page.screenshot({path:path.join(process.env.TEMP,'sw-kra-business-level-'+level+'.png'),fullPage:true,animations:'disabled'});
results.push(result);
console.log(JSON.stringify(result));
await context.close();
}
await fs.writeFile('docs/sw6-to-kra/business-page-parity-2026-11-03.json',JSON.stringify({method:'真实 Chrome 渲染;受控身份和业务数据,权限页签与状态操作核对',results},null,2));
} finally {await browser.close();}
})().catch(error=>{console.error(error.message);process.exitCode=1});
-68
View File
@@ -1,68 +0,0 @@
"""只读验证运行实例的商务画像、缺数据处理与参数校验。"""
import argparse
import json
import urllib.parse
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
checks = []
def get(path, expected=0):
request = urllib.request.Request(args.base_url + path, headers={"x-token": token})
with urllib.request.urlopen(request, timeout=30) as response:
payload = json.load(response)
check = {"path": path, "http": response.status, "code": payload.get("code"),
"msg": payload.get("msg"), "passed": response.status == 200 and payload.get("code") == expected}
data = payload.get("data", {})
if isinstance(data, dict) and "basic" in data:
check.update(available=data.get("available"), promote_count=data["basic"].get("promoteCount"),
register_points=len(data.get("registerTrend", [])), pay_points=len(data.get("payTrend", [])))
checks.append(check)
return data
accounts = get("/sw/business/list?pageNum=1&pageSize=500")["rows"]
def bound_subtree(account):
prefix = f"{account.get('idPath') or '0'},{account['userId']}"
ids = [account]
if int(account.get("level") or 0) < 3:
ids += [row for row in accounts if row["userId"] != account["userId"] and
(row.get("parentId") == account["userId"] or row.get("idPath") == prefix or
str(row.get("idPath") or "").startswith(prefix + ","))]
return sum(row.get("bindCount") or 0 for row in ids)
empty = next((row for row in accounts if bound_subtree(row) == 0), None)
bound = next((row for row in accounts if bound_subtree(row) > 0), None)
if empty:
data = get(f"/sw/business/{empty['userId']}/portrait?sourceDbList=lx,hw,yq&period=today")
checks[-1]["passed"] &= data.get("available") is True and data.get("metrics", {}).get("register", {}).get("value") == "0" and len(data.get("registerTrend", [])) == 24
data = get(f"/sw/business/{empty['userId']}/portrait?sourceDbList=lx&period=thisMonth")
checks[-1]["passed"] &= data.get("period") == "thismonth" and 1 <= len(data.get("registerTrend", [])) <= 31
if bound:
data = get(f"/sw/business/{bound['userId']}/portrait?sourceDbList=lx,hw,yq&period=last7")
if data.get("available") is False:
checks[-1]["passed"] &= data.get("metrics") is None and bool(data.get("unavailableReason"))
else:
checks[-1]["passed"] &= len(data.get("registerTrend", [])) == 7
if accounts:
get(f"/sw/business/{accounts[0]['userId']}/portrait", expected=7)
get("/sw/business/bindPlatform/portrait?sourceDb=lx&platformBusierId=0", expected=7)
report = {"scope": "真实 sw7 登录会话,只读请求;有绑定但业务表未接入时不伪造零,无绑定时验证真实空画像。完整指标和 SDK 基本信息通过隔离双库 TCP HTTP 测试。",
"checks": checks, "passed": all(check["passed"] for check in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if not report["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
-101
View File
@@ -1,101 +0,0 @@
// 真实 Chrome 核对三个画像入口、统计切换及嵌套抽屉;所有业务数据使用受控响应。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
;(async () => {
const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim()
const browser = await chromium.launch({ channel: 'chrome', headless: true })
const results = []
try {
for (const level of [1, 3]) {
const context = await browser.newContext({ viewport: { width: 1600, height: 1080 } })
await context.addInitScript(value => localStorage.setItem('token', value), token)
const page = await context.newPage()
const errors = [], portraits = [], alertQueries = [], channelQueries = []
let unavailable = false
const normal = { userId: 70009, sourceDb: 'lx', platformName: '龙翔', platformBusierId: 14, platformBusierAccount: 'SDK测试账号', bindUserName: '测试商务', status: '0', createTime: '2026-11-01 10:00:00', promoteCount: 1 }
page.on('pageerror', error => errors.push(error.message))
await page.route('http://127.0.0.1:8080/api/**', async route => {
const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4)
const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } })
if (api === '/sw/business/list') return ok({ rows: level === 3 ? [] : [{ userId: 70009, userName: '测试商务账号', realName: '测试商务', level: 3, status: '0', bindCount: 1, promoteCount: 1, createTime: '2026-11-01 10:00:00' }], total: level === 3 ? 0 : 1 })
if (api === '/sw/business/70009/bindPlatform') return ok([normal])
if (api === '/sw/business/bindPlatform') return ok({ rows: [normal, { ...normal, platformBusierId: 15, platformBusierAccount: '驳回账号', status: '2' }, { ...normal, platformBusierId: 16, platformBusierAccount: '待审账号', status: '3' }], total: 3 })
if (api === '/sw/business/70009/portrait' || api === '/sw/business/bindPlatform/portrait') {
const sdk = api.includes('bindPlatform')
const period = url.searchParams.get('period') || 'today'
portraits.push({ api, period, source: url.searchParams.get(sdk ? 'sourceDb' : 'sourceDbList'), platformBusierId: url.searchParams.get('platformBusierId') })
const length = ['today', 'yesterday'].includes(period) ? 24 : 7
const trends = Array.from({ length }, (_, i) => ({ label: length === 24 ? String(i).padStart(2, '0') + ':00' : new Date(Date.UTC(2026, 9, 28 + i)).toISOString().slice(5, 10), value: i === 1 ? '2' : '0' }))
const compareLabel = period === 'today' ? '较昨日' : period === 'yesterday' ? '较前日' : period === 'thismonth' ? '较上月同期' : '较上一周期'
const metric = value => ({ value, compareValue: '1', changeRate: '100.0', changeDiff: null, compareLabel })
return ok({ basic: { userId: sdk ? 14 : 70009, userName: sdk ? 'SDK测试账号' : '测试商务账号', realName: '画像姓名', level: 3, levelLabel: '三级商务', parentUserName: '上级账号', status: '0', statusLabel: sdk ? '待审核' : '正常', promoteCount: unavailable ? null : 1, monthNewPromote: unavailable ? null : 1, createTime: '2026-11-01 10:00:00' }, period,
available: !unavailable, unavailableReason: unavailable ? '渠道数据尚未接入,画像指标暂不可用' : '', metrics: unavailable ? null : { register: metric('2'), payAmount: metric('12.00'), payRate: { ...metric('50.0'), changeRate: null, changeDiff: '25.0', compareValue: '25.0' }, arpu: metric('6.00') },
registerTrend: unavailable ? [] : trends, payTrend: unavailable ? [] : trends.map(point => ({ ...point, value: point.value === '2' ? '12.00' : '0.00' })), registerTop5: unavailable ? [] : [{ rank: 1, sourceDbLabel: '龙翔', promoteId: 100, promoteName: '测试主渠道', value: '2.00' }], payTop5: unavailable ? [] : [{ rank: 1, sourceDbLabel: '龙翔', promoteId: 100, promoteName: '测试主渠道', value: '12.00' }] })
}
if (api === '/sw/alertRecord/list') { alertQueries.push(Object.fromEntries(url.searchParams)); return ok({ available: true, pageData: { rows: [{ id: '1', createdAt: '2026-11-03 10:00:00', targetPromoteName: '测试预警渠道', ruleType: 'register_anomaly', alertContent: '测试关联预警', status: 1, statusLabel: '未处理' }], total: 1 }, statusCount: { 1: 1 } }) }
if (api === '/sw/business/70009/promotes') { channelQueries.push({ api, ...Object.fromEntries(url.searchParams) }); return ok({ rows: [{ promoteId: 100, promoteName: '测试直属渠道', sourceDb: 'lx', platformName: '龙翔' }], total: 1, available: true }) }
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
if (api === '/user/getUserInfo') { const body = await response.json(); Object.assign(body.data.userInfo, { ID: level === 3 ? 70009 : 70001, level, authorityId: 900 + level, nickName: '画像核对账号', phone: '', email: '' }); return route.fulfill({ response, json: body }) }
return route.fulfill({ response })
})
await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/BusinessList')
const close = async dialog => { await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }) }
if (level === 1) {
await page.getByRole('button', { name: '画像', exact: true }).click()
const portrait = page.getByRole('dialog', { name: '商务画像 — 测试商务', exact: true })
await portrait.getByText('画像姓名', { exact: true }).waitFor()
await portrait.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
await portrait.getByText('测试关联预警', { exact: true }).waitFor()
assert.equal(portraits.at(-1).source, 'lx,hw,yq')
await portrait.getByRole('img', { name: '注册量趋势', exact: true }).focus()
await portrait.getByRole('img', { name: '注册量趋势', exact: true }).press('ArrowRight')
await portrait.locator('.trend-tooltip').getByText('01:00', { exact: true }).waitFor()
await portrait.getByText('近7天', { exact: true }).click()
await page.waitForFunction(() => [...document.querySelectorAll('.profile-two-columns .el-card__header')].some(node => node.textContent.includes('近7天注册量')))
assert.equal(portraits.at(-1).period, 'last7')
await portrait.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
await page.screenshot({ path: path.join(process.env.TEMP, 'sw-business-portrait.png'), animations: 'disabled' })
await close(portrait)
await page.getByRole('button', { name: '查看SDK商务绑定详情', exact: true }).click()
const bindings = page.getByRole('dialog', { name: 'SDK商务绑定详情', exact: true })
await bindings.getByRole('button', { name: '龙翔', exact: true }).click()
const sdk = page.getByRole('dialog', { name: '游戏商务画像 — SDK测试账号', exact: true })
await sdk.getByText('画像姓名', { exact: true }).waitFor()
assert.equal(portraits.at(-1).platformBusierId, '14')
assert.equal(alertQueries.at(-1).businessId, '70009')
assert.equal(alertQueries.at(-1).platformCodes, 'lx')
await sdk.locator('.profile-info').getByText('待审核', { exact: true }).waitFor()
assert.equal(await sdk.locator('.profile-info .el-tag--success').count(), 0)
await sdk.locator('.profile-info').getByRole('button', { name: '1', exact: true }).click()
const channels = page.getByRole('dialog', { name: '旗下渠道', exact: true })
await channels.waitFor(); await channels.locator('.el-loading-mask').waitFor({ state: 'hidden' })
assert.equal(channelQueries.at(-1).api, '/sw/business/70009/promotes')
assert.equal(channelQueries.at(-1).platformBusierId, '14')
await channels.locator('.el-drawer__close-btn').click(); await channels.waitFor({ state: 'hidden' })
await close(sdk)
await bindings.locator('.el-drawer__close-btn').click(); await bindings.waitFor({ state: 'hidden' })
unavailable = true
await page.getByRole('button', { name: '画像', exact: true }).click()
await portrait.getByText('渠道数据尚未接入,画像指标暂不可用', { exact: true }).waitFor()
assert.equal(await portrait.getByRole('img', { name: '注册量趋势', exact: true }).count(), 0)
await close(portrait)
} else {
await page.getByRole('tab', { name: '绑定列表', exact: true }).click()
const normalRow = page.locator('.el-table__body-wrapper tr').filter({ hasText: 'SDK测试账号' })
await normalRow.getByRole('button', { name: '画像', exact: true }).click()
const sdk = page.getByRole('dialog', { name: '游戏商务画像 — SDK测试账号', exact: true })
await sdk.getByText('画像姓名', { exact: true }).waitFor()
await close(sdk)
assert.equal(await page.locator('.el-table__body-wrapper tr').filter({ hasText: '驳回账号' }).getByRole('button', { name: '画像', exact: true }).count(), 0)
assert.equal(await page.locator('.el-table__body-wrapper tr').filter({ hasText: '待审账号' }).getByRole('button', { name: '画像', exact: true }).count(), 0)
}
assert.deepEqual(errors, [])
const result = { level, passed: true, entry: level === 1 ? '商务列表与只读 SDK 详情' : '三级绑定列表正常行', pageErrors: errors }
results.push(result); console.log(JSON.stringify(result)); await context.close()
}
await fs.writeFile('docs/sw6-to-kra/portrait-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome 渲染;业务接口使用受控数据。验证三个入口、时间切换、交互图表、SDK 状态、SW 用户 ID 与平台 ID 分离、缺数据提示。', results }, null, 2) + '\n')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
+61
View File
@@ -0,0 +1,61 @@
"""用已有真实绑定的三级账号逐项切换统计偏好,对比报表后恢复原始偏好行。"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
from sw_write_verify import Rehearsal
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--config", type=Path, default=Path("configs/config.yaml"))
parser.add_argument("--old-url", default="http://127.0.0.1:18080")
parser.add_argument("--new-url", default="http://127.0.0.1:19001")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
run = Rehearsal(args)
identity = json.loads((args.runtime / "test-business-identities.json").read_text())["level3"]
ids = {"legacy": identity["user_id"], "new": run.mapping[str(identity["user_id"])]}
originals, preferences = {}, {}
for side, db in (("legacy", "sw8"), ("new", "sw9")):
originals[side] = run.query(f"SELECT * FROM {db}.sys_user_preference WHERE user_id=%s", (ids[side],))
preferences[side] = run.call(side, "level3", "GET", run.prefix(side) + "/user/preference")["data"]
(args.runtime / "preferences-before-matrix.json").write_text(json.dumps(originals, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
templates = json.loads((args.runtime / "level3-business-cases.json").read_text())
templates = [row for row in templates if row["name"] in ("channelData-list", "gameData-list", "rank-game", "workbench-summary")]
try:
for include, dedup in ((True, True), (True, False), (False, True), (False, False)):
mode = f"sub{int(include)}-dedup{int(dedup)}"
value = {"includeSubPromote": include, "idcardDedup": dedup}
for side in ("legacy", "new"):
run.call(side, "level3", "PUT", run.prefix(side) + "/user/preference", value)
actual = run.call(side, "level3", "GET", run.prefix(side) + "/user/preference")["data"]
run.check("偏好写入 " + mode + " " + side, actual == value)
cases = [{**row, "name": mode + "-" + row["name"]} for row in templates]
cases_file = args.runtime / ("preference-cases-" + mode + ".json")
cases_file.write_text(json.dumps(cases), encoding="utf-8")
output = args.output.with_name(args.output.stem + "-" + mode + args.output.suffix)
with (args.runtime / ("preference-" + mode + ".log")).open("wb") as log:
completed = subprocess.run([sys.executable, str(Path(__file__).with_name("sw_api_compare.py")), "--runtime", str(args.runtime), "--cases", str(cases_file),
"--identity", "level3", "--old-url", args.old_url, "--new-url", args.new_url, "--output", str(output)], stdout=log, stderr=subprocess.STDOUT)
run.check("偏好模式报表一致 " + mode, completed.returncode == 0, report=output.name)
finally:
for side, db in (("legacy", "sw8"), ("new", "sw9")):
run.call(side, "level3", "PUT", run.prefix(side) + "/user/preference", preferences[side])
if not originals[side]:
run.query(f"DELETE FROM {db}.sys_user_preference WHERE user_id=%s", (ids[side],))
else:
row = originals[side][0]
run.query(f"UPDATE {db}.sys_user_preference SET include_sub_promote=%s,idcard_dedup=%s,update_time=%s WHERE user_id=%s",
(row["include_sub_promote"], row["idcard_dedup"], row["update_time"], ids[side]))
actual = run.query(f"SELECT * FROM {db}.sys_user_preference WHERE user_id=%s", (ids[side],))
run.check("偏好原始行已恢复 " + side, actual == originals[side])
run.db.close()
if __name__ == "__main__":
main()
-36
View File
@@ -1,36 +0,0 @@
"""真实会话只读验证独立排行榜接口,不创建业务数据。"""
import argparse
import json
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base-url', default='http://127.0.0.1:18000')
parser.add_argument('--token-file', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding='utf-8').strip()
checks = []
for kind in ['channel', 'business', 'game']:
for query, expected in [('sourceDb=lx,hw,yq', 0), ('beginTime=bad&endTime=2026-11-04', 7), ('beginTime=2026-11-05&endTime=2026-11-04', 7)]:
path = '/sw/rank/' + kind + '?' + query
req = urllib.request.Request(args.base_url + path, headers={'x-token': token})
with urllib.request.urlopen(req, timeout=30) as response:
payload = json.load(response)
item = {'path': path, 'http': response.status, 'code': payload.get('code'), 'msg': payload.get('msg'), 'passed': payload.get('code') == expected}
if expected == 0:
data = payload.get('data', {})
item['available'] = data.get('available')
item['passed'] &= data.get('available') is False and all(data.get(key) == [] for key in ['registerRankList', 'rechargeRankList', 'payRateRankList', 'activeRankList'])
checks.append(item)
report = {'scope': '真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向排行和权限通过隔离 TCP HTTP 测试。', 'checks': checks, 'passed': all(item['passed'] for item in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False))
if not report['passed']:
raise SystemExit(1)
if __name__ == '__main__':
main()
-34
View File
@@ -1,34 +0,0 @@
"""只读核验汇总任务方法已注册、导入计划保持禁用,不触发实际数据重建。"""
import argparse
import json
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base-url', default='http://127.0.0.1:18000')
parser.add_argument('--token-file', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding='utf-8').strip()
def get(path):
req = urllib.request.Request(args.base_url + path, headers={'x-token': token})
with urllib.request.urlopen(req, timeout=30) as response:
data = json.load(response)
assert data['code'] == 0, data.get('msg')
return data['data']
methods = get('/timedTask/getRegisteredMethods')['methods']
names = {row['name'] for row in methods}
expected = {f'swReport{kind}{period}Task' for kind in ['Day', 'ServerDay'] for period in ['Today', 'Yesterday', 'Backfill']}
tasks = get('/timedTask/getTimedTaskList?page=1&pageSize=1000')
imported = [row for row in tasks['list'] if row.get('name', '').startswith('SW/')]
report = {'scope': '真实 sw7 会话,只读任务接口;正向创建和触发通过隔离数据库原生任务 HTTP 测试。', 'registeredMethods': sorted(names & expected), 'importedTasks': len(imported), 'enabledImportedTasks': sum(bool(row['enabled']) for row in imported), 'passed': expected <= names and not any(row['enabled'] for row in imported)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False))
if not report['passed']:
raise SystemExit(1)
if __name__ == '__main__':
main()
+119
View File
@@ -0,0 +1,119 @@
"""备份/比较/恢复 sw8 与 sw9 指定自然日的四张报表,不备份或改写原始业务流水。"""
import argparse
import datetime as dt
import gzip
import hashlib
import json
import shutil
import subprocess
from pathlib import Path
import yaml
from sw_database_rehearsal import client_options, connect, query, write_json
TABLES = ("tab_sw_report_day", "tab_sw_report_day_key", "tab_sw_report_server_day", "tab_sw_report_server_day_key")
def snapshot(conn, database, day, logical=False):
result = {}
for table in TABLES:
rows = query(conn, f"SELECT * FROM `{database}`.`{table}` WHERE stat_date=%s ORDER BY id", (day,))
if logical:
rows = [{key: value for key, value in row.items() if key not in ("id", "created_at", "updated_at")} for row in rows]
serialized = [json.dumps(row, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str) for row in rows]
if logical:
serialized.sort()
result[table] = {"rows": len(rows), "sha256": hashlib.sha256("\n".join(serialized).encode()).hexdigest()}
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("backup", "verify", "restore"))
parser.add_argument("--config", type=Path, default=Path("configs/config.yaml"))
parser.add_argument("--date", default="2026-11-03")
parser.add_argument("--state", type=Path, required=True)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
dt.date.fromisoformat(args.date)
args.state = args.state.resolve()
if args.state.is_relative_to(Path(__file__).resolve().parents[1]):
raise ValueError("报表备份和身份键必须保存在仓库外")
args.state.mkdir(parents=True, exist_ok=True)
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))["data"]["database"]
manifest_path = args.state / "report-baseline.json"
if args.action == "backup":
if manifest_path.exists():
raise ValueError("已有基线备份,禁止覆盖")
manifest = {"date": args.date, "databases": {}}
with connect(config) as conn:
manifest["server"] = query(conn, "SELECT @@hostname hostname,@@version version")[0]
for database in ("sw8", "sw9"):
baseline = snapshot(conn, database, args.date)
options = client_options(config, args.state)
file = args.state / (database + "-report-partition.sql.gz")
try:
with (args.state / (database + "-dump.stderr.log")).open("wb") as log:
process = subprocess.Popen([shutil.which("mysqldump"), "--defaults-extra-file=" + str(options), "--single-transaction", "--quick", "--hex-blob",
"--no-create-info", "--skip-triggers", "--skip-add-locks", "--skip-disable-keys", "--complete-insert", "--set-gtid-purged=OFF",
"--where=stat_date='" + args.date + "'", database, *TABLES], stdout=subprocess.PIPE, stderr=log)
with gzip.open(file, "wb") as target:
shutil.copyfileobj(process.stdout, target)
if process.wait() != 0:
raise RuntimeError("报表备份失败")
with gzip.open(file, "rb") as stream:
while stream.read(1024 * 1024):
pass
manifest["databases"][database] = {"tables": baseline, "backup": str(file), "sha256": hashlib.sha256(file.read_bytes()).hexdigest()}
finally:
options.unlink(missing_ok=True)
write_json(manifest_path, manifest)
print(json.dumps({"date": args.date, "tables": {db: value["tables"] for db, value in manifest["databases"].items()}}, ensure_ascii=False), flush=True)
return
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if manifest["date"] != args.date:
raise ValueError("日期与备份不符")
with connect(config) as conn:
if query(conn, "SELECT @@hostname hostname,@@version version")[0] != manifest["server"]:
raise ValueError("数据库服务器与备份不符")
if args.action == "restore":
for database, value in manifest["databases"].items():
if database not in ("sw8", "sw9"):
raise ValueError("未知数据库")
file = Path(value["backup"])
if hashlib.sha256(file.read_bytes()).hexdigest() != value["sha256"]:
raise ValueError("备份校验失败")
content = gzip.decompress(file.read_bytes())
if b"LOCK TABLES" in content or b"ALTER TABLE" in content or b"CREATE TABLE" in content:
raise ValueError("分区恢复文件不允许包含 DDL 或隐式提交")
deletes = "\n".join(f"DELETE FROM `{table}` WHERE stat_date='{args.date}';" for table in TABLES)
script = ("START TRANSACTION;\n" + deletes + "\n").encode() + content + b"\nCOMMIT;\n"
options = client_options(config, args.state)
try:
completed = subprocess.run([shutil.which("mysql"), "--defaults-extra-file=" + str(options), "--batch", database], input=script, capture_output=True)
(args.state / (database + "-restore.stderr.log")).write_bytes(completed.stderr)
if completed.returncode:
raise RuntimeError("分区恢复失败")
finally:
options.unlink(missing_ok=True)
with connect(config) as conn:
checks = [{"database": db, "passed": snapshot(conn, db, args.date) == value["tables"]} for db, value in manifest["databases"].items()]
else:
with connect(config) as conn:
source, target = [snapshot(conn, db, args.date, logical=True) for db in ("sw8", "sw9")]
checks = [{"table": table, "source": source[table], "target": target[table], "passed": source[table] == target[table]} for table in TABLES]
for db in ("sw8", "sw9"):
for table in TABLES:
rows = query(conn, f"SELECT * FROM `{db}`.`{table}` WHERE stat_date=%s ORDER BY id", (args.date,))
write_json(args.state / (db + "-" + table + "-rebuilt.json"), rows)
result = {"action": args.action, "date": args.date, "checks": checks, "passed": all(item["passed"] for item in checks)}
write_json(args.output or args.state / (args.action + ".json"), result)
print(json.dumps(result, ensure_ascii=False), flush=True)
if not result["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
"""通过新商务原生任务 HTTP 重跑指定日期,要求已有分区备份并保持任务禁用。"""
import argparse
import json
import time
from pathlib import Path
from sw_write_verify import Rehearsal
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--config", type=Path, default=Path("configs/config.yaml"))
parser.add_argument("--old-url", default="http://127.0.0.1:18080")
parser.add_argument("--new-url", default="http://127.0.0.1:19001")
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--date", default="2026-11-03")
parser.add_argument("--backup", type=Path, required=True)
parser.add_argument("--methods", nargs="+", choices=("swReportDayBackfillTask", "swReportServerDayBackfillTask", "alertTriggerTask"),
default=["swReportDayBackfillTask", "swReportServerDayBackfillTask"])
args = parser.parse_args()
backup = json.loads(args.backup.read_text(encoding="utf-8"))
if backup["date"] != args.date:
raise ValueError("已有备份的日期与任务不符")
run = Rehearsal(args)
ids = []
try:
for method in args.methods:
name = "迁移核对-" + method + "-" + str(int(time.time()))
run.call("new", "admin", "POST", "/timedTask/createTimedTask", {"name": name, "spec": "@daily", "executorType": "method", "methodName": method,
"params": {"startDate": args.date, "endDate": args.date, "_execution": {"timeoutSeconds": 1800}}, "enabled": False})
task = run.query("SELECT id,enabled FROM sw9.sys_timed_tasks WHERE name=%s AND deleted_at IS NULL", (name,))[0]
ids.append(task["id"])
(args.runtime / "report-rehearsal-task-ids.json").write_text(json.dumps(ids), encoding="utf-8")
run.check(method + " 创建且保持禁用", task["enabled"] == 0)
started = time.monotonic()
run.call("new", "admin", "POST", "/timedTask/triggerTimedTask", {"id": task["id"]})
while time.monotonic() - started < 1830:
value = run.call("new", "admin", "GET", f"/timedTask/getTimedTaskLogList?taskId={task['id']}&page=1&pageSize=1")["data"]
logs = value.get("list", [])
if logs and logs[0].get("status") not in ("running", "pending"):
(args.runtime / (method + "-execution.json")).write_text(json.dumps(logs[0], ensure_ascii=False, indent=2), encoding="utf-8")
run.check(method + " 真实执行完成", logs[0]["status"] == "success", status=logs[0]["status"], elapsed_seconds=round(time.monotonic() - started, 2))
break
time.sleep(2)
else:
run.check(method + " 等待执行完成", False, reason="timeout")
except Exception as error:
if all(check["passed"] for check in run.checks):
run.check("报表重跑执行异常", False, error_type=type(error).__name__)
raise
finally:
run.db.close()
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
"""主库使用 sw8/sw9,平台写入使用独立本地 SDK 夹具;核对验密、重提、解绑与新增渠道。"""
import argparse
import json
import time
from pathlib import Path
import pymysql
from sw_api_compare import compare_data
from sw_write_verify import Rehearsal
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--config", type=Path, default=Path("configs/config.yaml"))
parser.add_argument("--old-url", default="http://127.0.0.1:18081")
parser.add_argument("--new-url", default="http://127.0.0.1:19002")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
run = Rehearsal(args)
fixture = json.loads((args.runtime / "sdk-fixture/fixture.json").read_text())
owner = fixture["owner_id"]
local = pymysql.connect(host="127.0.0.1", port=13309, user="root", autocommit=True, cursorclass=pymysql.cursors.DictCursor)
channel = "migchannel" + str(int(time.time()))
def query(sql, params=()):
with local.cursor() as cursor:
cursor.execute(sql, params)
return cursor.fetchall()
def api_path(side, path):
return ("/system/user" if side == "legacy" else "/sw/business") + path
def bind(side, password=None, success=True, identity="testlevel3"):
return run.call(side, identity, "POST", api_path(side, "/bindPlatform"),
{"sourceDb": "lx", "account": fixture["account"], "password": password or fixture["password"]}, success=success)
def snapshot(label, status):
values = []
for side, database in (("legacy", "sw8"), ("new", "sw9")):
rows = run.query(f"SELECT user_id AS userId,source_db AS sourceDb,platform_busier_id AS platformBusierId,platform_busier_account AS platformBusierAccount,status,reject_reason AS rejectReason,review_account AS reviewAccount FROM {database}.sys_user_bind_platform WHERE source_db='lx' AND platform_busier_id=%s", (owner,))
run.check(label + " 行数和状态 " + side, len(rows) == 1 and rows[0]["status"] == status)
values.append(rows)
compared = compare_data(*values, {}, run.mapping)
run.check(label + " 字段一致", compared["semantic_equal"], differences=compared["differences"])
try:
for database in ("sw_test_sdk_old", "sw_test_sdk_new"):
for table in ("tab_promote", "tab_promote_settlement_time"):
run.check("本地平台写入夹具为空 " + database + "." + table, query(f"SELECT COUNT(*) n FROM {database}.{table}")[0]["n"] == 0)
query(f"ALTER TABLE {database}.{table} AUTO_INCREMENT=1")
for side, database in (("legacy", "sw8"), ("new", "sw9")):
run.check("夹具绑定键未占用 " + side, not run.query(f"SELECT user_id FROM {database}.sys_user_bind_platform WHERE source_db='lx' AND platform_busier_id=%s", (owner,)))
denied = bind(side, password="invalid-password", success=False)
run.check("错误密码不允许绑定 " + side, denied["code"] != (200 if side == "legacy" else 0))
denied = bind(side, success=False, identity="testlevel1")
run.check("仅三级允许绑定 " + side, denied["code"] != (200 if side == "legacy" else 0))
started = time.monotonic()
for side in ("legacy", "new"):
bind(side)
snapshot("首次绑定待审核", "3")
for side in ("legacy", "new"):
denied = bind(side, success=False)
run.check("待审核重复提交被拒绝 " + side, denied["code"] != (200 if side == "legacy" else 0))
# 仅在主库夹具模拟 OA 的审核结果,不调用外部 OA 写接口。
for database in ("sw8", "sw9"):
run.query(f"UPDATE {database}.sys_user_bind_platform SET status='2',reject_reason='测试驳回',review_account='migration-test',review_time=NOW() WHERE source_db='lx' AND platform_busier_id=%s", (owner,))
time.sleep(max(0, 5.2 - (time.monotonic() - started)))
for side in ("legacy", "new"):
bind(side)
snapshot("驳回后重提并清空旧审核信息", "3")
for database in ("sw8", "sw9"):
run.query(f"UPDATE {database}.sys_user_bind_platform SET status='0',review_account='migration-test',review_time=NOW() WHERE source_db='lx' AND platform_busier_id=%s", (owner,))
snapshot("审核通过绑定", "0")
payload = {"sourceDb": "lx", "platformBusierId": owner, "account": channel, "password": "Channel168", "registerType": 0}
for side in ("legacy", "new"):
denied = run.call(side, "testlevel1", "POST", run.prefix(side) + "/promote", payload, success=False)
run.check("保留原一级角色的新增渠道权限限制 " + side, denied["code"] != (200 if side == "legacy" else 0))
run.call(side, "admin", "POST", run.prefix(side) + "/promote", payload)
saved, settlements = [], []
for side, database in (("legacy", "sw_test_sdk_old"), ("new", "sw_test_sdk_new")):
rows = query(f"SELECT id,account,password,status,busier_id,register_type,promote_level,parent_id,top_promote_id,game_ids2 FROM {database}.tab_promote WHERE account=%s", (channel,))
run.check("源平台渠道只创建一条 " + side, len(rows) == 1)
saved.append(rows[0])
settlement = query(f"SELECT * FROM {database}.tab_promote_settlement_time WHERE promote_id=%s", (rows[0]["id"],))
run.check("七日结算及时间关系 " + side, len(settlement) == 1 and settlement[0]["day_period"] == 7 and settlement[0]["time_type"] == 0 and settlement[0]["next_count_time"] - settlement[0]["create_time"] == 7 * 86400)
relation = query(f"SELECT promote_ids FROM {database}.tab_promote_business WHERE id=%s", (owner,))[0]["promote_ids"]
run.check("平台商务渠道关系已追加 " + side, str(rows[0]["id"]) in (relation or "").split(","))
run.check("新渠道属性及兼容密码完全一致", saved[0] == saved[1] and saved[0]["status"] == -3 and saved[0]["promote_level"] == 1)
for side, database in (("legacy", "sw8"), ("new", "sw9")):
denied = run.call(side, "admin", "POST", run.prefix(side) + "/promote", payload, success=False)
run.check("重复渠道账号被拒绝 " + side, denied["code"] != (200 if side == "legacy" else 0))
run.call(side, "testlevel3", "DELETE", api_path(side, "/bindPlatform"), {"sourceDb": "lx", "platformBusierId": owner})
run.check("解绑只删除当前绑定 " + side, not run.query(f"SELECT user_id FROM {database}.sys_user_bind_platform WHERE user_id=%s AND source_db='lx' AND platform_busier_id=%s", (run.owner(side, 3), owner)))
except Exception as error:
if all(check["passed"] for check in run.checks):
run.check("SDK 验证执行异常", False, error_type=type(error).__name__)
raise
finally:
for side, database in (("legacy", "sw8"), ("new", "sw9")):
run.query(f"DELETE FROM {database}.sys_user_bind_platform WHERE user_id=%s AND source_db='lx' AND platform_busier_id=%s AND platform_busier_account=%s", (run.owner(side, 3), owner, fixture["account"]))
run.query("DELETE FROM sw9.sys_business_binding_locks WHERE source_db='lx' AND platform_busier_id=%s", (owner,))
for database in ("sw_test_sdk_old", "sw_test_sdk_new"):
rows = query(f"SELECT id FROM {database}.tab_promote WHERE account=%s", (channel,))
for row in rows:
query(f"DELETE FROM {database}.tab_promote_settlement_time WHERE promote_id=%s", (row["id"],))
query(f"DELETE FROM {database}.tab_promote WHERE account=%s", (channel,))
query(f"UPDATE {database}.tab_promote_business SET promote_ids='' WHERE id=%s AND account=%s", (owner, fixture["account"]))
remaining = sum(run.query(f"SELECT COUNT(*) n FROM {db}.sys_user_bind_platform WHERE source_db='lx' AND platform_busier_id=%s", (owner,))[0]["n"] for db in ("sw8", "sw9"))
run.check("主库 SDK 绑定夹具已清理", remaining == 0)
local.close()
run.db.close()
if __name__ == "__main__":
main()
-44
View File
@@ -1,44 +0,0 @@
"""只读验证区服分析接口;完整统计由隔离数据库的真实 TCP HTTP 测试覆盖。"""
import argparse
import json
import urllib.parse
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base-url', default='http://127.0.0.1:18000')
parser.add_argument('--token-file', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding='utf-8').strip()
checks = []
cases = [
('list', {}, 7), ('servers', {}, 7),
('list', {'gameName': '隔离核验不存在游戏', 'sourceDb': 'lx,hw,yq'}, 0),
('list', {'gameName': 'x', 'beginTime': 'bad', 'endTime': '2026-11-04'}, 7),
('list', {'gameName': 'x', 'beginTime': '2026-11-05', 'endTime': '2026-11-04'}, 7),
('servers', {'gameName': '隔离核验不存在游戏', 'sourceDb': 'lx'}, 7),
]
for endpoint, query, expected in cases:
path = '/sw/serverAnalysis/' + endpoint + '?' + urllib.parse.urlencode(query)
req = urllib.request.Request(args.base_url + path, headers={'x-token': token})
with urllib.request.urlopen(req, timeout=30) as response:
payload = json.load(response)
data = payload.get('data')
item = {'endpoint': endpoint, 'query': query, 'http': response.status, 'code': payload.get('code'), 'msg': payload.get('msg'), 'passed': payload.get('code') == expected}
if isinstance(data, dict) and 'available' in data:
item.update(available=data['available'], total=data.get('total'))
if data['available'] is False:
item['passed'] &= data.get('summary') is None
checks.append(item)
report = {'scope': '真实 sw7 会话只读检查,当前缺少业务报表和游戏区服表;未创建实际业务数据。', 'checks': checks, 'passed': all(item['passed'] for item in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False))
if not report['passed']:
raise SystemExit(1)
if __name__ == '__main__':
main()
-48
View File
@@ -1,48 +0,0 @@
"""只读核对原生账号与商务状态;写接口只发送缺失 ID 的无效请求。"""
import argparse
import json
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
checks = []
def request(method, path, body=None, expected=0):
req = urllib.request.Request(args.base_url + path, method=method,
headers={"x-token": token, "Content-Type": "application/json"},
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req, timeout=30) as response:
result = json.load(response)
checks.append({"method": method, "path": path, "http": response.status,
"code": result.get("code"), "msg": result.get("msg"),
"passed": response.status == 200 and result.get("code") == expected})
return result.get("data", {})
native = request("POST", "/user/getUserList", {"page": 1, "pageSize": 500})
business = request("GET", "/sw/business/list?pageNum=1&pageSize=500")
native_rows = native.get("list", [])
by_id = {row["userId"]: row for row in business.get("rows", [])}
conflicts = sum((row.get("enable") == 1) != (row.get("businessStatus") == 0) for row in native_rows)
mismatches = sum(str(row.get("businessStatus")) != by_id.get(row["ID"], {}).get("status") for row in native_rows)
request("PUT", "/user/setUserInfo", {"ID": 0, "enable": 1}, expected=7)
report = {"scope": "真实 sw7 登录会话,只读检查;未启停任何真实账号。正向启停和审核状态保留由隔离 TCP HTTP 测试覆盖。",
"checks": checks, "native_total": native.get("total"), "business_total": business.get("total"),
"checked_users": len(native_rows), "inconsistent_states": conflicts, "response_mismatches": mismatches,
"passed": all(item["passed"] for item in checks) and conflicts == 0 and mismatches == 0
and len(native_rows) == native.get("total") == business.get("total")}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if not report["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
-41
View File
@@ -1,41 +0,0 @@
"""真实会话只读验证工作台接口与缺表提示,不制造实际商务数据。"""
import argparse
import json
import urllib.request
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base-url', default='http://127.0.0.1:18000')
parser.add_argument('--token-file', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding='utf-8').strip()
checks = []
for endpoint in ['summary', 'registerTop', 'payRank', 'unloginTop']:
cases = [('', 7), ('sourceDbList=lx,hw,yq', 0 if endpoint == 'summary' else 7)]
if endpoint in ['registerTop', 'payRank']:
cases.append(('sourceDbList=lx&period=invalid', 7))
if endpoint == 'payRank':
cases.append(('sourceDbList=lx,hw,yq&order=asc&period=last30', 7))
for query, expected in cases:
path = '/sw/workbench/' + endpoint + '?' + query
req = urllib.request.Request(args.base_url + path, headers={'x-token': token})
with urllib.request.urlopen(req, timeout=30) as response:
payload = json.load(response)
item = {'path': path, 'http': response.status, 'code': payload.get('code'), 'msg': payload.get('msg'), 'passed': payload.get('code') == expected}
if endpoint == 'summary' and expected == 0:
data = payload.get('data', {})
item['available'] = data.get('available')
item['passed'] &= data.get('available') is False and all(data.get(key, {}).get('value') is None for key in ['todayRegister', 'todayActive', 'todayPay', 'monthPay', 'activePromote', 'newPromote'])
checks.append(item)
report = {'scope': '真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向六卡、榜单与权限通过隔离 TCP HTTP 测试。', 'checks': checks, 'passed': all(item['passed'] for item in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report, ensure_ascii=False))
if not report['passed']:
raise SystemExit(1)
if __name__ == '__main__':
main()
+207
View File
@@ -0,0 +1,207 @@
"""在 sw8/sw9 专用测试账号下核对写流程;临时渠道、绑定、预警及规则在 finally 中清理。"""
import argparse
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
import pymysql
import yaml
from sw_api_compare import compare_data
class Rehearsal:
def __init__(self, args):
self.args = args
self.checks = []
args.output.write_text(json.dumps({"checks": [], "passed": False, "status": "running"}) + "\n", encoding="utf-8")
self.root = args.runtime
self.users = json.loads((self.root / "synthetic-test-users.json").read_text(encoding="utf-8"))
self.tokens = {(side, identity): (self.root / f"{side}-{identity}.token").read_text().strip()
for side in ("legacy", "new") for identity in ("admin", "level3", "testlevel1", "testlevel2", "testlevel3")}
self.client = json.loads((self.root / "legacy-client.json").read_text())["client_id"]
maps = json.loads((self.root / "id-maps.json").read_text())
self.mapping = {str(row["old_id"]): str(row["new_id"]) for row in maps if row["entity"] == "user"}
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))["data"]["database"]
self.db = pymysql.connect(host=config["host"], port=int(config["port"]), user=config["user"], password=config["password"],
autocommit=True, charset="utf8mb4", read_timeout=60, cursorclass=pymysql.cursors.DictCursor)
self.rule_ids = {}
self.suffix = str(int(time.time()))
self.rule_type = "mig_verify_" + self.suffix
self.account = "mig_verify_" + self.suffix
self.promote_id = 2000000000 + int(self.suffix[-7:])
self.alert_id = 9007199254740993 + int(self.suffix[-7:])
self.fixtures = []
def query(self, sql, params=()):
with self.db.cursor() as cursor:
cursor.execute(sql, params)
return cursor.fetchall()
def check(self, name, passed, **details):
self.checks.append({"check": name, "passed": bool(passed), **details})
self.args.output.write_text(json.dumps({"checks": self.checks, "passed": all(item["passed"] for item in self.checks)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(self.checks[-1], ensure_ascii=False), flush=True)
if not passed:
raise AssertionError(name)
def call(self, side, identity, method, path, payload=None, success=True):
old = side == "legacy"
headers = {"Content-Type": "application/json"}
headers.update({"Authorization": "Bearer " + self.tokens[side, identity], "clientid": self.client} if old else {"x-token": self.tokens[side, identity]})
request = urllib.request.Request((self.args.old_url if old else self.args.new_url) + path,
data=json.dumps(payload).encode() if payload is not None else None, method=method, headers=headers)
try:
with urllib.request.urlopen(request, timeout=60) as response:
raw = response.read()
status = response.status
except urllib.error.HTTPError as response:
raw = response.read()
status = response.code
try:
reply = json.loads(raw)
except (ValueError, UnicodeDecodeError):
self.check(path + " " + side, False, http=status, response_bytes=len(raw), error="响应不是 JSON")
if success and reply.get("code") != (200 if old else 0):
self.check(path + " " + side, False, code=reply.get("code"), message=reply.get("msg"))
return reply
def owner(self, side, level):
return self.users[level - 1]["source_id" if side == "legacy" else "target_id"]
def prefix(self, side):
return "/system" if side == "legacy" else "/sw"
def rule_snapshot(self, name):
values = [self.call(side, "testlevel1", "GET", self.prefix(side) + "/alertRule/" + str(self.rule_ids[side]))["data"] for side in ("legacy", "new")]
options = {"source_omit_fields": ["id", "createdAt", "updatedAt"], "target_omit_fields": ["id", "createdAt", "updatedAt"],
"null_empty_fields": ["momDirectionLabel"]}
result = compare_data(*values, options, self.mapping)
self.check(name, result["semantic_equal"], differences=result["differences"])
return values
def rules(self):
self.rule = {"ruleName": "迁移核对临时规则", "ruleType": self.rule_type, "monitorMetric": 3, "unloginDays": 2, "monitorScope": 1, "warningColor": "#f56c6c"}
for side, database in (("legacy", "sw8"), ("new", "sw9")):
self.call(side, "testlevel1", "POST", self.prefix(side) + "/alertRule/add", self.rule)
rows = self.query(f"SELECT id FROM {database}.sys_alert_rule WHERE creator_id=%s AND rule_type=%s", (self.owner(side, 1), self.rule_type))
self.check("新增规则 " + side, len(rows) == 1)
self.rule_ids[side] = rows[0]["id"]
self.rule_snapshot("新增规则字段一致")
for side in ("legacy", "new"):
self.call(side, "testlevel1", "PUT", self.prefix(side) + f"/alertRule/toggle/{self.rule_ids[side]}?enabled=0")
values = self.rule_snapshot("规则禁用一致")
self.check("禁用状态生效", all(row["enabled"] == 0 for row in values))
self.rule.update(monitorMetric=1, statPeriodDays=3, momDirection=2, momThreshold=12.5)
self.rule.pop("unloginDays")
for side in ("legacy", "new"):
self.call(side, "testlevel1", "PUT", self.prefix(side) + "/alertRule/edit", {**self.rule, "id": self.rule_ids[side]})
values = self.rule_snapshot("编辑保留启停和可选历史字段")
self.check("编辑未重启规则且保留未登录天数", all(row["enabled"] == 0 and row["unloginDays"] == 2 for row in values))
for side in ("legacy", "new"):
rejected = self.call(side, "testlevel2", "PUT", self.prefix(side) + f"/alertRule/toggle/{self.rule_ids[side]}?enabled=1", success=False)
self.check("二级不能修改一级规则 " + side, rejected.get("code") != (200 if side == "legacy" else 0))
self.call(side, "testlevel1", "PUT", self.prefix(side) + f"/alertRule/toggle/{self.rule_ids[side]}?enabled=1")
self.rule_snapshot("重新启用规则一致")
def prepare_alert(self):
for side, database in (("legacy", "sw8"), ("new", "sw9")):
existing = self.query(f"SELECT id FROM {database}.tab_promote WHERE id=%s AND source_db='lx'", (self.promote_id,))
self.check("临时渠道 ID 未占用 " + side, not existing)
existing = self.query(f"SELECT id FROM {database}.sys_alert_record WHERE id=%s", (self.alert_id,))
self.check("临时预警 ID 未占用 " + side, not existing)
self.fixtures.append({"side": side, "database": database, "promote_id": self.promote_id, "alert_id": self.alert_id, "account": self.account,
"owner": self.owner(side, 1), "user": self.owner(side, 3)})
(self.root / "write-fixtures.json").write_text(json.dumps(self.fixtures, indent=2), encoding="utf-8")
self.query(f"INSERT INTO {database}.tab_promote(id,source_db,account,busier_id,promote_level,status) VALUES(%s,'lx',%s,%s,1,1)", (self.promote_id, self.account, self.promote_id))
self.query(f"INSERT INTO {database}.sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status,create_time) VALUES(%s,'lx',%s,%s,'0',NOW())", (self.owner(side, 3), self.promote_id, self.account))
self.query(f"INSERT INTO {database}.sys_alert_record(id,platform_code,platform_name,rule_id,rule_name,rule_type,monitor_metric,target_promote_id,target_promote_name,business_id,business_name,alert_content,status,alert_date,created_at,updated_at) VALUES(%s,'lx','龙翔',%s,%s,%s,1,%s,%s,%s,%s,'迁移核对临时预警',1,CURDATE(),NOW(),NOW())",
(self.alert_id, self.rule_ids[side], self.rule["ruleName"], self.rule_type, self.promote_id, self.account, self.owner(side, 1), self.users[0]["username"]))
def alert_snapshot(self, label, expected_status):
values = [self.call(side, "testlevel1", "GET", self.prefix(side) + f"/alertRecord/detail/{self.alert_id}")["data"] for side in ("legacy", "new")]
for row in values:
row["alertRecord"].pop("ruleId", None)
options = {"source_omit_fields": ["id", "createdAt", "updatedAt"], "target_omit_fields": ["id", "createdAt", "updatedAt"],
"null_empty_fields": ["actionDetail", "noteContent"]}
# 日志主键独立生成;按原操作序列比较业务字段及操作人映射。
result = compare_data(*values, options, self.mapping)
self.check(label, result["semantic_equal"] and all(row["alertRecord"]["status"] == expected_status for row in values),
status=expected_status, log_counts=[len(row["operationLogs"]) for row in values], differences=result["differences"])
def workflow(self):
self.prepare_alert()
self.alert_snapshot("临时预警初始状态一致", 1)
for side in ("legacy", "new"):
rejected = self.call(side, "level3", "GET", self.prefix(side) + f"/alertRecord/detail/{self.alert_id}", success=False)
self.check("其他商务不可查看临时预警 " + side, rejected.get("code") != (200 if side == "legacy" else 0))
steps = [("startProcess", "testlevel3", 2), ("submitAudit", "testlevel3", 3), ("reject", "testlevel1", 4),
("reProcess", "testlevel3", 2), ("submitAudit", "testlevel3", 3), ("approve", "testlevel1", 5)]
for index, (action, identity, status) in enumerate(steps):
for side in ("legacy", "new"):
self.call(side, identity, "POST", self.prefix(side) + "/alertRecord/" + action, {"alertId": str(self.alert_id), "note": "迁移核对步骤" + str(index)})
self.alert_snapshot(action + " 状态及日志一致", status)
if index == 1:
for side in ("legacy", "new"):
rejected = self.call(side, "testlevel3", "POST", self.prefix(side) + "/alertRecord/approve", {"alertId": str(self.alert_id)}, success=False)
self.check("三级商务不能审核 " + side, rejected.get("code") != (200 if side == "legacy" else 0))
for side in ("legacy", "new"):
self.call(side, "testlevel3", "POST", self.prefix(side) + "/alertRecord/addNote", {"alertId": str(self.alert_id), "noteContent": "迁移核对独立备注", "attachmentIds": []})
self.alert_snapshot("已处理记录追加备注不改变状态", 5)
def remove_rule(self):
for side, database in (("legacy", "sw8"), ("new", "sw9")):
self.call(side, "testlevel1", "DELETE", self.prefix(side) + "/alertRule/" + str(self.rule_ids[side]))
row = self.query(f"SELECT is_deleted FROM {database}.sys_alert_rule WHERE id=%s", (self.rule_ids[side],))[0]
self.check("删除规则保留历史行 " + side, row["is_deleted"] == 1)
def cleanup(self):
for fixture in reversed(self.fixtures):
database = fixture["database"]
self.query(f"DELETE FROM {database}.sys_alert_operation_log WHERE alert_id=%s", (fixture["alert_id"],))
self.query(f"DELETE FROM {database}.sys_alert_record WHERE id=%s AND target_promote_name=%s", (fixture["alert_id"], fixture["account"]))
self.query(f"DELETE FROM {database}.sys_user_bind_platform WHERE user_id=%s AND source_db='lx' AND platform_busier_id=%s AND platform_busier_account=%s", (fixture["user"], fixture["promote_id"], fixture["account"]))
self.query(f"DELETE FROM {database}.tab_promote WHERE id=%s AND source_db='lx' AND account=%s", (fixture["promote_id"], fixture["account"]))
for side, database in (("legacy", "sw8"), ("new", "sw9")):
self.query(f"DELETE FROM {database}.sys_alert_rule WHERE creator_id=%s AND rule_type=%s", (self.owner(side, 1), self.rule_type))
remaining = 0
for fixture in self.fixtures:
database = fixture["database"]
for table, condition, params in (
("sys_alert_record", "id=%s", (fixture["alert_id"],)),
("sys_alert_operation_log", "alert_id=%s", (fixture["alert_id"],)),
("tab_promote", "id=%s AND source_db='lx'", (fixture["promote_id"],)),
("sys_user_bind_platform", "user_id=%s AND source_db='lx' AND platform_busier_id=%s", (fixture["user"], fixture["promote_id"])),
):
remaining += self.query(f"SELECT COUNT(*) n FROM {database}.{table} WHERE {condition}", params)[0]["n"]
for side, database in (("legacy", "sw8"), ("new", "sw9")):
remaining += self.query(f"SELECT COUNT(*) n FROM {database}.sys_alert_rule WHERE creator_id=%s AND rule_type=%s", (self.owner(side, 1), self.rule_type))[0]["n"]
self.check("临时预警渠道绑定和规则已清理", remaining == 0, remaining=remaining)
self.db.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--runtime", type=Path, required=True)
parser.add_argument("--config", type=Path, default=Path("configs/config.yaml"))
parser.add_argument("--old-url", default="http://127.0.0.1:18080")
parser.add_argument("--new-url", default="http://127.0.0.1:19001")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.runtime.resolve().is_relative_to(Path(__file__).resolve().parents[1]):
raise ValueError("测试状态必须保存在仓库外")
run = Rehearsal(args)
try:
run.rules()
run.workflow()
run.remove_rule()
finally:
run.cleanup()
if __name__ == "__main__":
main()