fix: 完成商务迁移复核与接口优化并清理历史资料
This commit is contained in:
@@ -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 上运行测试建表和删除。
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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});
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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 })
|
||||
@@ -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 })
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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});
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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()
|
||||
@@ -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});
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user