首次推送
build / build-and-test (push) Failing after 1m24s

This commit is contained in:
2026-10-18 19:44:38 +08:00
commit f047662129
64 changed files with 10454 additions and 0 deletions
+560
View File
@@ -0,0 +1,560 @@
# 冒烟测试执行脚本(HTTP 层逐条断言)
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\acceptance.ps1 -Base http://127.0.0.1:18090 -Db <测试库路径>
param(
[string]$Base = 'http://127.0.0.1:18090',
[string]$Db = 'F:\hupeE的代肝平台\testrun\daili.db',
[string]$Python = 'E:\Miniconda3\python.exe',
[string]$Tools = 'F:\hupeE的代肝平台\scripts\dbtool.py',
[string]$LogFile = 'F:\hupeE的代肝平台\testrun\acceptance-result.txt'
)
try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { }
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONIOENCODING = 'utf-8'
$script:results = @()
$script:fail = 0
$T = (Get-Date).Date
$today = $T.ToString('yyyy-MM-dd')
function D([int]$n) { $T.AddDays($n).ToString('yyyy-MM-dd') }
function New-Sess {
$handler = New-Object System.Net.Http.HttpClientHandler
$handler.AllowAutoRedirect = $true
$handler.UseCookies = $true
$handler.CookieContainer = New-Object System.Net.CookieContainer
$client = New-Object System.Net.Http.HttpClient($handler)
$client.Timeout = [TimeSpan]::FromSeconds(30)
return $client
}
function Send {
param($Sess, [string]$Method, [string]$Path, $Body = $null, $Headers = $null)
$req = New-Object System.Net.Http.HttpRequestMessage
$req.RequestUri = [Uri]("$Base$Path")
if ($Method -eq 'POST') { $req.Method = [System.Net.Http.HttpMethod]::Post }
else { $req.Method = [System.Net.Http.HttpMethod]::Get }
if ($null -ne $Body) {
$pairs = New-Object 'System.Collections.Generic.List[System.Collections.Generic.KeyValuePair[string,string]]'
foreach ($k in $Body.Keys) {
$kv = New-Object 'System.Collections.Generic.KeyValuePair[string,string]'($k, [string]$Body[$k])
$pairs.Add($kv)
}
$req.Content = [System.Net.Http.FormUrlEncodedContent]::new([System.Collections.Generic.IEnumerable[System.Collections.Generic.KeyValuePair[string,string]]]$pairs)
}
if ($null -ne $Headers) {
foreach ($k in $Headers.Keys) { [void]$req.Headers.TryAddWithoutValidation($k, [string]$Headers[$k]) }
}
try {
$resp = $Sess.SendAsync($req).Result
$text = $resp.Content.ReadAsStringAsync().Result
$final = $resp.RequestMessage.RequestUri.AbsolutePath
$resp.Dispose()
return @{ Status = [int]$resp.StatusCode; Content = $text; Path = $final }
} catch {
return @{ Status = 0; Content = ''; Path = ''; Err = $_.Exception.Message }
}
}
function Check {
param([string]$Id, [string]$Name, $Cond, [string]$Detail = '')
$ok = ($Cond -eq $true)
if ($ok) {
Write-Host (" [PASS] {0} {1}" -f $Id, $Name) -ForegroundColor Green
$script:results += [pscustomobject]@{ ID = $Id; Name = $Name; Result = 'PASS'; Detail = $Detail }
} else {
Write-Host (" [FAIL] {0} {1} {2}" -f $Id, $Name, $Detail) -ForegroundColor Red
$script:results += [pscustomobject]@{ ID = $Id; Name = $Name; Result = 'FAIL'; Detail = $Detail }
$script:fail++
}
}
function Has { param($Text, [string]$Pattern) return ([string]$Text) -match $Pattern }
function Flash { param($Text) $m = [regex]::Match([string]$Text, '<div class="flash[^>]*>([^<]*)</div>'); return $m.Groups[1].Value }
function SubIDs { param($Text) return @([regex]::Matches([string]$Text, 'id="sub-(\d+)"') | ForEach-Object { [int]$_.Groups[1].Value }) }
function RowCount { param($Text) return [regex]::Matches([string]$Text, 'class="tail"').Count }
function Dbtool { param([string[]]$ToolArgs) return (& $Python $Tools @ToolArgs) -join ' ' }
$admin = New-Sess
$anon = New-Sess
Write-Host "`n=== 1. 启动与部署(S) ===" -ForegroundColor Cyan
$r = Send $anon 'GET' '/healthz'
Check 'S-02' '健康检查 /healthz 返回 ok' ($r.Status -eq 200 -and $r.Content -match 'ok')
$r = Send $anon 'GET' '/'
Check 'S-03' '首访跳 /setup' ($r.Path -eq '/setup' -and (Has $r.Content '创建第一个管理员')) "path=$($r.Path)"
$okStatic = $true; $detail = @()
foreach ($p in '/static/app.css', '/static/screen.css', '/static/app.js', '/static/screen.js', '/static/favicon.svg') {
$r = Send $anon 'GET' $p
$okStatic = $okStatic -and ($r.Status -eq 200) -and ($r.Content.Length -gt 100)
$detail += "$p=$($r.Status)/$($r.Content.Length)"
}
Check 'S-04' '静态资源可访问' $okStatic ($detail -join ' ')
Write-Host "`n=== 2. 账户与权限(A) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/setup' @{ username = 'ab'; display_name = 'x'; password = 'daili123'; password2 = 'daili123' }
Check 'A-01' '用户名过短被拒' ($r.Status -eq 400 -and (Has $r.Content '用户名至少 3 个字符')) "status=$($r.Status)"
$r = Send $admin 'POST' '/setup' @{ username = 'admin'; display_name = '老板'; password = '123'; password2 = '123' }
Check 'A-02' '密码过短被拒' ($r.Status -eq 400 -and (Has $r.Content '密码至少 6 位')) "status=$($r.Status)"
$r = Send $admin 'POST' '/setup' @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili999' }
Check 'A-03' '两次密码不一致被拒' ($r.Status -eq 400 -and (Has $r.Content '两次输入的密码不一致')) "status=$($r.Status)"
$r = Send $admin 'POST' '/setup' @{ username = 'admin'; display_name = '老板'; password = 'daili123'; password2 = 'daili123' }
Check 'A-04' '初始化管理员成功并自动登录' ($r.Status -eq 200 -and $r.Path -eq '/packages') "path=$($r.Path)"
Write-Host "`n=== 3. 套餐(P) ===" -ForegroundColor Cyan
$r = Send $admin 'GET' '/packages'
Check 'P-01' '套餐空态提示' ((Has $r.Content '还没有套餐'))
$r = Send $admin 'POST' '/packages' @{ name = '包月日常'; unit = 'day'; default_amount = '30'; price = '300'; color = '#2F5D50'; note = '每天日常 + 周常'; sort_order = '1' }
Check 'P-02' '新增按日套餐' ((Has $r.Content '套餐已创建')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' '/packages' @{ name = '深渊十次'; unit = 'count'; default_amount = '10'; price = '180'; color = '#6B4E9B'; note = '按次结算'; sort_order = '2' }
Check 'P-03' '新增按次套餐' ((Has $r.Content '套餐已创建')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' '/packages' @{ name = ''; unit = 'day'; default_amount = '0'; color = ''; sort_order = '0' }
Check 'P-04' '套餐必填校验' ($r.Status -eq 400 -and (Has $r.Content '套餐名称不能为空')) "status=$($r.Status)"
Write-Host "`n=== 4. 登录与会话(A) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/logout'
$r = Send $admin 'GET' '/calendar'
Check 'A-05' '登出后受保护页面跳登录' ($r.Path -like '/login*')
$r = Send $admin 'POST' '/login' @{ username = 'admin'; password = 'wrongpass'; next = '/calendar' }
Check 'A-06' '错误口令被拒' ($r.Status -eq 400 -and (Has $r.Content '用户名或密码不正确')) "status=$($r.Status)"
$r = Send $admin 'POST' '/login' @{ username = 'admin'; password = 'daili123'; next = '/calendar' }
Check 'A-04b' '重新登录成功' ($r.Path -eq '/calendar' -and (Has $r.Content '欢迎回来')) "path=$($r.Path)"
$screenPublic = ((Send $admin 'GET' '/settings').Content -match '(?s)大屏</dt>.*?免登录')
$r = Send $anon 'GET' '/customers'
$p1 = $r.Path
$r = Send $anon 'GET' '/screen'
$p2 = $r.Path
$screenOk = if ($screenPublic) { $p2 -eq '/screen' } else { $p2 -like '/login*' }
Check 'A-07' '未登录拦截后台页(大屏按配置)' (($p1 -like '/login*') -and $screenOk) "customers=$p1 screen=$p2 public=$screenPublic"
$r = Send $admin 'POST' '/packages' @{ name = '恶意套餐'; unit = 'day'; default_amount = '1'; color = '#2F5D50' } @{ Origin = 'http://evil.example' }
Check 'A-12' '跨站 POST 被拒绝' ($r.Status -eq 403) "status=$($r.Status)"
Write-Host "`n=== 5. 客户(C) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/customers' @{ tail = '1001'; name = '张三'; game_name = '王者荣耀'; game_server = '微信区'; contact = 'wx:1001'; tags = '老客户,包月 老客户'; source = '抖音'; note = '主用例' }
$custPath = $r.Path
$custID = ($custPath -split '/')[-1]
Check 'C-01' '新建客户成功' ($custPath -like '/customers/*') "path=$custPath"
$r = Send $admin 'POST' '/customers' @{ tail = '1001'; name = '张三' }
Check 'C-02' '尾号+姓名重复被拒' ($r.Status -eq 400 -and (Has $r.Content '已存在')) "status=$($r.Status)"
$r = Send $admin 'POST' '/customers' @{ tail = '1001'; name = '李四'; game_name = '王者荣耀' }
$custLiID = ($r.Path -split '/')[-1]
Check 'C-03' '同尾号不同姓名允许' ($r.Path -like '/customers/*') "path=$($r.Path)"
$r = Send $admin 'POST' '/customers' @{ tail = ''; name = '无尾号' }
Check 'C-04' '尾号为空被拒' ($r.Status -eq 400 -and (Has $r.Content '手机尾号不能为空')) "status=$($r.Status)"
$r = Send $admin 'POST' '/customers' @{ tail = '123456789'; name = '超长尾号' }
Check 'C-05' '尾号超长被拒' ($r.Status -eq 400 -and (Has $r.Content '最长 8 位')) "status=$($r.Status)"
$r = Send $admin 'GET' '/customers?q=1001'
Check 'C-06' '尾号搜索命中两人' ((Has $r.Content '张三') -and (Has $r.Content '李四') -and (RowCount $r.Content) -eq 2) "rows=$(RowCount $r.Content)"
$r = Send $admin 'GET' '/customers?q=%E7%8E%8B%E8%80%85'
Check 'C-07a' '按游戏名搜索' ((RowCount $r.Content) -ge 2) "rows=$(RowCount $r.Content)"
$r = Send $admin 'GET' '/customers?q=wx%3A1001'
Check 'C-07b' '按联系方式搜索' ((Has $r.Content '张三')) "rows=$(RowCount $r.Content)"
$r = Send $admin 'GET' '/customers?q=%E6%9D%8E%E5%9B%9B'
Check 'C-07c' '按姓名搜索' ((RowCount $r.Content) -eq 1) "rows=$(RowCount $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
$tagVal = Dbtool @('scalar', $Db, "SELECT tags FROM customers WHERE id=$custID")
Check 'C-08' '标签归一化(逗号/空格/去重)' ($tagVal.Trim() -eq '老客户,包月') "tags=$tagVal"
$r = Send $admin 'POST' "/customers/$custID/edit" @{ tail = '1001'; name = '张三'; game_name = '王者荣耀'; game_server = '安卓微信区'; contact = 'wx:1001'; tags = '老客户,包月'; source = '抖音'; note = '已编辑' }
$r = Send $admin 'GET' "/customers/$custID"
Check 'C-09' '编辑客户生效' ((Has $r.Content '安卓微信区') -and (Has $r.Content '已编辑')) "flash=$(Flash $r.Content)"
# 批量客户:用于分页
for ($i = 1; $i -le 22; $i++) {
$tail = 'P' + $i.ToString('000')
Send $admin 'POST' '/customers' @{ tail = $tail; name = "分页客户$i" } | Out-Null
}
$r = Send $admin 'GET' '/customers'
$total = [int][regex]::Match($r.Content, '共 (\d+) 位').Groups[1].Value
$r2 = Send $admin 'GET' '/customers?page=2'
$expect = [Math]::Min(20, $total - 20)
Check 'C-10' '客户分页正常' (($r2.Status -eq 200) -and (RowCount $r2.Content) -eq $expect -and $total -ge 24) "total=$total page2=$(RowCount $r2.Content) expect=$expect"
Write-Host "`n=== 6. 订阅(U) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30'; note = '首单' }
Check 'U-01' '开通按日订阅(结束日=T+29)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
$p1ID = (SubIDs $r.Content)[0]
$detail = (Send $admin 'GET' "/customers/$custID").Content
Check 'U-01b' '按日剩余 30 天' ((Has $detail '剩 30 天')) ""
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '2'; start_date = $today; total_count = '10'; end_date = (D 10) }
Check 'U-02' '开通按次订阅(10 次 / 有效期 T+10)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
$p2ID = (SubIDs $r.Content)[1]
Check 'U-02b' '按次剩余 10/10 次' ((Has $r.Content '剩 10 / 10 次')) ""
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' }
Check 'U-03' '重复订阅被拦截' ((Has $r.Content '已订阅此套餐')) "flash=$(Flash $r.Content)"
# P-05:新增套餐 D(id=3),验证既有订阅不受影响
$before = (Send $admin 'GET' "/customers/$custID").Content
$r = Send $admin 'POST' '/packages' @{ name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; note = '无有效期'; sort_order = '3' }
Check 'P-05' '新增套餐 D 成功' ((Has $r.Content '套餐已创建')) ""
$after = (Send $admin 'GET' "/customers/$custID").Content
Check 'P-05b' '新增套餐后既有订阅剩余量不变' ((Has $after '剩 30 天') -and (Has $after '剩 10 / 10 次')) ""
$r = Send $admin 'POST' "/customers/$custID/subscriptions" @{ package_id = '3'; start_date = $today; total_count = '5' }
Check 'U-04' '开通第三个套餐(无有效期按次)' ((Has $r.Content '已开通')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
$ids = SubIDs $r.Content
$p3ID = $ids[2]
Check 'U-04b' '三条订阅并存' ($ids.Count -eq 3) "count=$($ids.Count)"
$r = Send $admin 'GET' '/subscriptions?state=active'
Check 'U-05a' '筛选:进行中' ((Has $r.Content '张三') -and (Has $r.Content '进行中')) ""
$r = Send $admin 'GET' '/subscriptions?state=due7'
Check 'U-05b' '筛选:7 天内到期' ($r.Status -eq 200) "status=$($r.Status)"
$r = Send $admin 'GET' '/subscriptions?state=closed'
Check 'U-05c' '筛选:已结束(应为空)' ($r.Status -eq 200 -and (Has $r.Content '没有订阅')) "flash=$(Has $r.Content '没有订阅')"
$r = Send $admin 'GET' '/subscriptions?package_id=2'
Check 'U-06' '按套餐筛选' ((Has $r.Content '张三') -and -not (Has $r.Content '阿豪')) ""
# 过期与未开始
$r = Send $admin 'POST' '/customers' @{ tail = '9001'; name = '阿豪' }
$custHaoID = ($r.Path -split '/')[-1]
Send $admin 'POST' "/customers/$custHaoID/subscriptions" @{ package_id = '1'; start_date = (D -40); days = '30' } | Out-Null
$r = Send $admin 'GET' "/customers/$custHaoID"
$haoSubID = (SubIDs $r.Content)[0]
Check 'U-07a' '已过期订阅显示「已到期」' ((Has $r.Content '已到期')) ""
$r = Send $admin 'POST' "/customers/$custLiID/subscriptions" @{ package_id = '1'; start_date = (D 5); days = '30' } | Out-Null
$r = Send $admin 'GET' "/customers/$custLiID"
$liSubID = (SubIDs $r.Content)[0]
Check 'U-07b' '未开始订阅显示「未开始」' ((Has $r.Content '未开始')) ""
Write-Host "`n=== 7. 套餐编辑与归档(P) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/packages/3/edit' @{ name = '副本五次(改)'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; sort_order = '3' }
$r = Send $admin 'GET' "/customers/$custID"
Check 'P-06' '编辑套餐后订阅同步显示新名称' ((Has $r.Content '副本五次\(改\)')) "flash=$(Flash $r.Content)"
Send $admin 'POST' '/packages/3/edit' @{ name = '副本五次'; unit = 'count'; default_amount = '5'; price = '120'; color = '#41556B'; sort_order = '3' } | Out-Null
$r = Send $admin 'POST' '/packages/2/archive'
Check 'P-07a' '归档套餐' ((Has $r.Content '已归档')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/customers/$custLiID/subscriptions" @{ package_id = '2'; start_date = $today; total_count = '10' }
Check 'P-07b' '归档后不能新建该套餐订阅' ((Has $r.Content '已归档,不能新建')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
Check 'P-08' '归档后已有订阅照常显示' ((Has $r.Content '深渊十次')) ""
Send $admin 'POST' '/packages/2/archive' | Out-Null
Write-Host "`n=== 8. 追加时长(E) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' "/subscriptions/$p1ID/extend" @{ new_end_date = (D 39); note = '未到期续费'; back = "/customers/$custID" }
Check 'E-01' '未到期追加:起点=T+30,追加 10 天' ($r -ne $null -and (Has $r.Content '已追加 10 天')) "flash=$(Flash $r.Content)"
$detail = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-01b' '追加后剩余 40 天' ((Has $detail '剩 40 天')) ""
$r = Send $admin 'POST' "/subscriptions/$haoSubID/extend" @{ new_end_date = (D 29); note = '过期续费'; back = "/customers/$custHaoID" }
Check 'E-02' '已到期追加:从今天起算 30 天' ((Has $r.Content '已追加 30 天')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/subscriptions/$p1ID/extend" @{ new_end_date = (D 10); back = "/customers/$custID" }
$d2 = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-03' '早于计算起点被拒且数据不变' ((Has $r.Content '追加失败') -and (Has $r.Content '早于计算起点') -and (Has $d2 '剩 40 天')) "flash=$(Flash $r.Content)"
Check 'E-04' '追加留痕(起点/旧→新/天数/备注/操作人)' ((Has $detail '未到期续费') -and (Has $detail '追加天数') -and (Has $detail (D 30)) -and (Has $detail '老板')) ""
Check 'E-05' '新建订阅有初始「开通」记录' ((Has $detail '开通') -and (Has $detail '\+30 天')) ""
$r = Send $admin 'POST' "/subscriptions/$p1ID/pause" @{ back = "/customers/$custID" }
$r = Send $admin 'POST' "/subscriptions/$p1ID/extend" @{ new_end_date = (D 49); note = '冻结期续费'; back = "/customers/$custID" }
$d3 = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-06' '暂停中追加后回到进行中' ((Has $r.Content '已追加 10 天') -and -not (Has $d3 '暂停中')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/subscriptions/$liSubID/close"
$r = Send $admin 'POST' "/subscriptions/$liSubID/extend" @{ new_end_date = (D 40); back = "/customers/$custLiID" }
Check 'E-07' '已结束订阅不可追加' ((Has $r.Content '请先「重新启用」')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; note = '加购 5 次'; back = "/customers/$custID" }
$d4 = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-08' '按次追加次数' ((Has $r.Content '已追加 5 次') -and (Has $d4 '剩 15 / 15 次')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' "/subscriptions/$p2ID/extend" @{ add_count = '5'; new_end_opt = (D 30); note = '加次并延期'; back = "/customers/$custID" }
$d5 = (Send $admin 'GET' "/customers/$custID").Content
Check 'E-09' '按次追加可同时改有效期' ((Has $d5 ('有效期至 ' + (D 30)))) ""
$r = Send $admin 'GET' '/logs?q=%E8%BF%BD%E5%8A%A0'
Check 'E-10' '操作日志可查追加记录' ((Has $r.Content '追加时长') -or (Has $r.Content '追加次数')) ""
Write-Host "`n=== 9. 暂停 / 恢复(G) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' "/subscriptions/$haoSubID/pause" @{ back = "/customers/$custHaoID" }
$d = (Send $admin 'GET' "/customers/$custHaoID").Content
Check 'G-01' '暂停后状态为暂停中' ((Has $r.Content '已暂停') -and (Has $d '暂停中')) "flash=$(Flash $r.Content)"
Dbtool @('backdate-pause', $Db, "$haoSubID", '5') | Out-Null
$r = Send $admin 'POST' "/subscriptions/$haoSubID/resume" @{ back = "/customers/$custHaoID" }
$d = (Send $admin 'GET' "/customers/$custHaoID").Content
Check 'G-02' '恢复后结束日顺延 5 天' ((Has $r.Content '顺延 5 天')) "flash=$(Flash $r.Content)"
Check 'G-03' '暂停期间的 5 天被补回(剩余 35 天)' ((Has $d '剩 35 天')) ""
$r = Send $admin 'POST' "/subscriptions/$haoSubID/pause" @{ back = "/customers/$custHaoID" }
$r = Send $admin 'POST' "/subscriptions/$haoSubID/pause" @{ back = "/customers/$custHaoID" }
Check 'G-04' '重复暂停被拒' ((Has $r.Content '已处于暂停状态')) "flash=$(Flash $r.Content)"
Send $admin 'POST' "/subscriptions/$haoSubID/resume" @{ back = "/customers/$custHaoID" } | Out-Null
$r = Send $admin 'POST' "/subscriptions/$liSubID/pause" @{ back = "/customers/$custLiID" }
Check 'G-05' '已结束订阅不可暂停' ((Has $r.Content '无法暂停')) "flash=$(Flash $r.Content)"
# 专用客户:验证暂停后不出现在日历
$r = Send $admin 'POST' '/customers' @{ tail = '7001'; name = '单测' }
$custCalID = ($r.Path -split '/')[-1]
Send $admin 'POST' "/customers/$custCalID/subscriptions" @{ package_id = '1'; start_date = $today; days = '30' } | Out-Null
$r = Send $admin 'GET' "/customers/$custCalID"
$calSubID = (SubIDs $r.Content)[0]
$before = (Send $admin 'GET' "/calendar?d=$today").Content
Send $admin 'POST' "/subscriptions/$calSubID/pause" @{ back = "/customers/$custCalID" } | Out-Null
$after = (Send $admin 'GET' "/calendar?d=$today").Content
$link = "/subscriptions/$calSubID/checkin"
Check 'G-06' '暂停后不出现在日历待打清单' ((Has $before $link) -and (-not (Has $after $link))) "before=$(Has $before $link) after=$(Has $after $link)"
Send $admin 'POST' "/subscriptions/$calSubID/resume" @{ back = "/customers/$custCalID" } | Out-Null
Write-Host "`n=== 10. 打卡(K) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' "/subscriptions/$calSubID/checkin" @{ date = $today; status = 'done'; note = '今日已打'; back = "/calendar?d=$today" }
$cal = (Send $admin 'GET' "/calendar?d=$today").Content
Check 'K-01' '按日打卡生成记录并标记完成' ((Has $r.Content '已记录') -and (Has $cal '已完成') -and (-not (Has $cal ("/subscriptions/$calSubID/checkin")))) "flash=$(Flash $r.Content)"
$d = (Send $admin 'GET' "/customers/$custCalID").Content
Check 'K-02' '按日打卡不扣天数' ((Has $d '剩 30 天')) ""
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-03' '按次打卡扣 1 次' ((Has $d '剩 19 / 20 次')) ""
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-04' '同日重复打卡不重复扣次' ((Has $d '剩 19 / 20 次')) ""
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'skipped'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-05' '改为跳过返还 1 次' ((Has $d '剩 20 / 20 次')) ""
Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" } | Out-Null
$d = (Send $admin 'GET' "/customers/$custID").Content
$ciID = (Dbtool @('scalar', $Db, "SELECT id FROM checkins WHERE sub_id=$p2ID AND service_date='$today'")).Trim()
$r = Send $admin 'POST' "/checkins/$ciID/delete" @{ back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-06' '撤销打卡返还次数' ((Has $r.Content '已撤销') -and (Has $d '剩 20 / 20 次')) "flash=$(Flash $r.Content)"
Dbtool @('exec', $Db, "UPDATE subscriptions SET used_count=total_count WHERE id=$p2ID") | Out-Null
$r = Send $admin 'POST' "/subscriptions/$p2ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
Check 'K-07' '次数用尽时打卡被拒' ((Has $r.Content '剩余次数不足')) "flash=$(Flash $r.Content)"
Dbtool @('exec', $Db, "UPDATE subscriptions SET used_count=0 WHERE id=$p2ID") | Out-Null
$r = Send $admin 'POST' "/subscriptions/$liSubID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custLiID" }
Check 'K-08' '已结束订阅不可打卡' ((Has $r.Content '订阅已结束,无法打卡')) "flash=$(Flash $r.Content)"
$r = Send $admin 'GET' "/customers/$custID"
Check 'K-11' '无有效期订阅卡片有「今日打卡」按钮' ((Has $r.Content "/subscriptions/$p3ID/checkin") -and (Has $r.Content '今日打卡')) ""
$r = Send $admin 'POST' "/subscriptions/$p3ID/checkin" @{ date = $today; status = 'done'; back = "/customers/$custID" }
$d = (Send $admin 'GET' "/customers/$custID").Content
Check 'K-09' '无有效期按次订阅可打卡扣次' ((Has $d '剩 4 / 5 次')) "flash=$(Flash $r.Content)"
Check 'K-10' '打卡历史含日期/备注/操作人' ((Has $d '今日已打') -or (Has $d '完成')) ""
Write-Host "`n=== 11. 日历(L) ===" -ForegroundColor Cyan
$cal = (Send $admin 'GET' '/calendar').Content
Check 'L-01' '默认当月且今天选中' ((Has $cal '今天</span>') -and (Has $cal ($T.ToString('M 月 d 日')))) ""
$r = Send $admin 'GET' '/calendar?y=2026&m=12'
Check 'L-02a' '月份切换到 2026-12' ((Has $r.Content '2026 年 12 月')) ""
$r = Send $admin 'GET' '/calendar?y=2027&m=1'
Check 'L-02b' '跨年切换到 2027-01' ((Has $r.Content '2027 年 1 月')) ""
$r = Send $admin 'GET' '/calendar?y=2020&m=1'
Check 'L-03' '「回到今天」入口存在' ((Has $r.Content '回到今天')) ""
$cal = (Send $admin 'GET' "/calendar?d=$today").Content
Check 'L-04' '格子显示色签与负荷' ((Has $cal '单测') -and (Has $cal '包月') -and (Has $cal 'heat-')) ""
Check 'L-05' '点日期切换抽屉' ((Has $cal ($T.ToString('M 月 d 日'))) -and (Has $cal '当日待打')) ""
$r = Send $admin 'GET' "/calendar?d=$(D -1)"
Check 'L-06' '过去日期标记已过去' ((Has $r.Content '已过去')) ""
Check 'L-07' '近 7 天到期区块' ((Has $cal '近 7 天到期')) ""
Check 'L-08' '无有效期按次订阅不进日历' (-not (Has $cal ("/subscriptions/$p3ID/checkin"))) ""
$css = (Send $admin 'GET' '/static/app.css').Content
Check 'L-09' '移动端样式规则存在' ((Has $css '@media \(max-width: 560px\)') -and (Has $cal 'viewport')) ""
Write-Host "`n=== 12. 大屏(D) ===" -ForegroundColor Cyan
$r = Send $anon 'GET' '/screen'
$d1Ok = if ($screenPublic) { $r.Status -eq 200 -and $r.Path -eq '/screen' } else { $r.Path -like '/login*' }
Check 'D-01' '大屏访问符合 screen_public 配置' $d1Ok "path=$($r.Path) public=$screenPublic"
$screen = (Send $admin 'GET' '/screen').Content
$customers = (Send $admin 'GET' '/customers').Content
$custN = [regex]::Match($customers, '共 (\d+) 位').Groups[1].Value
$dueToday = [regex]::Match((Send $admin 'GET' '/subscriptions?state=due_today').Content, '共 (\d+) 条').Groups[1].Value
$due7 = [regex]::Match((Send $admin 'GET' '/subscriptions?state=due7').Content, '共 (\d+) 条').Groups[1].Value
$kpiCust = [regex]::Match($screen, '(?s)客户</span>\s*<b>(\d+)</b>').Groups[1].Value
$kpiDue = [regex]::Match($screen, '(?s)今日到期</span>\s*<b>(\d+)</b>').Groups[1].Value
$kpi7 = [regex]::Match($screen, '(?s)7 天内到期</span>\s*<b>(\d+)</b>').Groups[1].Value
Check 'D-02' '四张 KPI 与后台一致' (($kpiCust -eq $custN) -and ($kpiDue -eq $dueToday) -and ($kpi7 -eq $due7)) "cust=$kpiCust/$custN due=$kpiDue/$dueToday 7=$kpi7/$due7"
Check 'D-03' '热力月历渲染' ((Has $screen '到期热力') -and (Has $screen 'heat-cell')) ""
$topN = [regex]::Matches($screen, 'class="top-item').Count
Check 'D-04' 'TOP 榜不超过 10 条' ((Has $screen '即将到期 TOP 10') -and ($topN -ge 1) -and ($topN -le 10)) "count=$topN"
$todayTasks = [regex]::Match($cal, '(\d+) 待打').Groups[1].Value
$screenTasks = [regex]::Match($screen, '<span>(\d+) / (\d+) 已完成</span>').Groups[2].Value
Check 'D-05' '今日待打数与日历一致' ($todayTasks -eq $screenTasks -and $todayTasks -ne '') "calendar=$todayTasks screen=$screenTasks"
Check 'D-06' '最近追加流水渲染' ((Has $screen '最近追加')) ""
Check 'D-07' '自动刷新秒数取自设置' ((Has $screen '<meta http-equiv="refresh" content="30">')) ""
Write-Host "`n=== 13. 日志 / 导出 / 备份(R) ===" -ForegroundColor Cyan
$r = Send $admin 'GET' '/logs'
Check 'R-01' '日志列表倒序分页' ((Has $r.Content '共 ') -and (Has $r.Content '操作人')) ""
$r = Send $admin 'GET' '/logs?q=%E6%89%93%E5%8D%A1'
Check 'R-02' '日志搜索命中' ((Has $r.Content '打卡')) ""
$r = Send $admin 'GET' '/logs'
Check 'R-03' '关键动作均入库' ((Has $r.Content '追加时长') -and (Has $r.Content '暂停订阅') -and (Has $r.Content '打卡') -and (Has $r.Content '新建客户')) ""
$respCsv = $admin.GetAsync("$Base/export/customers.csv").Result
$bytes = $respCsv.Content.ReadAsByteArrayAsync().Result
$csv = [System.Text.Encoding]::UTF8.GetString($bytes)
$bom = ($bytes.Length -gt 2) -and ($bytes[0] -eq 0xEF) -and ($bytes[1] -eq 0xBB) -and ($bytes[2] -eq 0xBF)
$lines = ($csv -split "`n").Count
$custNow = [int][regex]::Match((Send $admin 'GET' '/customers').Content, '共 (\d+) 位').Groups[1].Value
Check 'R-04' '客户 CSV:BOM + 表头 + 行数匹配' ($bom -and (Has $csv '手机尾号') -and ($lines -ge ($custNow + 1))) "bom=$bom lines=$lines customers=$custNow"
$csv2 = (Send $admin 'GET' '/export/subscriptions.csv').Content
Check 'R-05' '订阅 CSV 含剩余/类型/状态' ((Has $csv2 '剩余') -and (Has $csv2 '类型') -and (Has $csv2 '原文状态')) ""
$csv3 = (Send $admin 'GET' '/export/logs.csv').Content
Check 'R-06' '日志 CSV 含操作人/动作' ((Has $csv3 '操作人') -and (Has $csv3 '动作')) ""
$r = Send $admin 'POST' '/settings/backup'
Check 'R-07' '手动备份生成文件' ((Has $r.Content '已备份')) "flash=$(Flash $r.Content)"
$verify = (& $Python 'F:\hupeE的代肝平台\scripts\verify-backup.py' 'F:\hupeE的代肝平台\testrun\backup') -join ' '
Check 'R-07b' '备份文件可被标准 SQLite 打开且有数据' (($verify -match 'OK') -and ($verify -match 'customers\s+(\d+)')) $verify
Send $admin 'POST' '/settings' @{ warn_days = '3'; screen_refresh = '30' } | Out-Null
$keepShown = [int][regex]::Match((Send $admin 'GET' '/settings').Content, '保留 (\d+) 份').Groups[1].Value
for ($i = 0; $i -lt 3; $i++) {
Start-Sleep -Milliseconds 1200
Send $admin 'POST' '/settings/backup' | Out-Null
}
$bk = @(Get-ChildItem 'F:\hupeE的代肝平台\testrun\backup' -Filter 'daili-*.db')
$pruneOk = ($keepShown -ge 1) -and ($bk.Count -le $keepShown) -and (($keepShown -gt 3) -or ($bk.Count -eq $keepShown))
Check 'R-08' "备份保留份数生效(配置 $keepShown 份)" $pruneOk "count=$($bk.Count) keep=$keepShown"
Write-Host "`n=== 14. 非功能 / 安全(N) ===" -ForegroundColor Cyan
function TimeOf([string]$path) {
$sw = [Diagnostics.Stopwatch]::StartNew()
Send $admin 'GET' $path | Out-Null
$sw.Stop()
return $sw.ElapsedMilliseconds
}
$tCal = @(1..3 | ForEach-Object { TimeOf "/calendar?d=$today" } | Measure-Object -Minimum).Minimum
$tCust = @(1..3 | ForEach-Object { TimeOf '/customers' } | Measure-Object -Minimum).Minimum
$tScr = @(1..3 | ForEach-Object { TimeOf '/screen' } | Measure-Object -Minimum).Minimum
Check 'N-01' '页面响应时间达标' (($tCal -lt 150) -and ($tCust -lt 100) -and ($tScr -lt 100)) "calendar=${tCal}ms customers=${tCust}ms screen=${tScr}ms"
Write-Host " (造 300 条订阅后复测日历)" -ForegroundColor DarkGray
Dbtool @('bulk', $Db, '300') | Out-Null
$tBulk = @(1..3 | ForEach-Object { TimeOf "/calendar?d=$today" } | Measure-Object -Minimum).Minimum
Check 'N-02' '300 条订阅下日历仍快' ($tBulk -lt 500) "calendar=${tBulk}ms"
$r = Send $admin 'POST' '/customers' @{ tail = '0002'; name = '<script>alert(1)</script>' }
$xssID = ($r.Path -split '/')[-1]
$xss = (Send $admin 'GET' "/customers/$xssID").Content
Check 'N-03' 'XSS 内容被转义' ((-not (Has $xss '<script>alert\(1\)</script>')) -and (Has $xss '&lt;script&gt;')) ""
$r = Send $admin 'GET' '/customers/999999'
Check 'N-04' '不存在的客户返回 404' ($r.Status -eq 404) "status=$($r.Status)"
$r = Send $admin 'GET' '/nope'
Check 'N-07' '未知路径返回 404' ($r.Status -eq 404) "status=$($r.Status)"
$hdr = (& curl.exe -s -i -X POST -d 'username=admin&password=daili123&next=/calendar' "$Base/login") -join "`n"
$hasHttpOnly = $hdr -match 'Set-Cookie: daili_sid=[^;]+;[^\r\n]*HttpOnly'
$hasSameSite = $hdr -match 'SameSite=Lax'
Check 'N-05' '会话 Cookie 含 HttpOnly + SameSite' ($hasHttpOnly -and $hasSameSite) "HttpOnly=$hasHttpOnly SameSite=$hasSameSite"
Write-Host "`n=== 15. 账户收尾(A) ===" -ForegroundColor Cyan
$r = Send $admin 'POST' '/settings/users' @{ username = 'helper'; display_name = '小工'; password = 'helper123' }
Check 'A-08a' '新增第二个管理员' ((Has $r.Content '已创建')) "flash=$(Flash $r.Content)"
$helper = New-Sess
$r = Send $helper 'POST' '/login' @{ username = 'helper'; password = 'helper123'; next = '/customers' }
$helperView = (Send $helper 'GET' '/customers').Content
$helperN = [regex]::Match($helperView, '共 (\d+) 位').Groups[1].Value
$adminView = (Send $admin 'GET' '/customers').Content
$adminN = [regex]::Match($adminView, '共 (\d+) 位').Groups[1].Value
Check 'A-08b' '多管理员共享同一套数据' ($helperN -eq $adminN -and $helperN -ne '') "helper=$helperN admin=$adminN"
$settingsHtml = (Send $admin 'GET' '/settings').Content
$toggles = [regex]::Matches($settingsHtml, '/settings/users/\d+/toggle').Count
Check 'A-10' '不能停用自己的账户' ((Has $settingsHtml '当前账户') -and $toggles -eq 1) "toggles=$toggles"
$helperID = [regex]::Match($settingsHtml, '/settings/users/(\d+)/toggle').Groups[1].Value
$r = Send $admin 'POST' "/settings/users/$helperID/toggle"
Check 'A-09a' '停用账户' ((Has $r.Content '停用账户成功')) "flash=$(Flash $r.Content)"
$helper2 = New-Sess
$r = Send $helper2 'POST' '/login' @{ username = 'helper'; password = 'helper123'; next = '/calendar' }
Check 'A-09b' '停用后无法登录' ($r.Status -eq 400 -and (Has $r.Content '用户名或密码不正确')) "status=$($r.Status)"
Send $admin 'POST' "/settings/users/$helperID/toggle" | Out-Null
$r = Send $admin 'POST' '/settings/password' @{ old_password = 'nope'; password = 'daili456'; password2 = 'daili456' }
Check 'A-11a' '原密码错误被拒' ((Has $r.Content '原密码不正确')) "flash=$(Flash $r.Content)"
$r = Send $admin 'POST' '/settings/password' @{ old_password = 'daili123'; password = 'daili456'; password2 = 'daili456' }
Check 'A-11b' '修改密码成功' ((Has $r.Content '密码已更新')) "flash=$(Flash $r.Content)"
$old = New-Sess
$r = Send $old 'POST' '/login' @{ username = 'admin'; password = 'daili123'; next = '/calendar' }
$oldFail = ($r.Status -eq 400 -and (Has $r.Content '用户名或密码不正确'))
$new = New-Sess
$r = Send $new 'POST' '/login' @{ username = 'admin'; password = 'daili456'; next = '/calendar' }
Check 'A-11c' '旧密码失效、新密码可用' ($oldFail -and $r.Path -eq '/calendar') "oldFail=$oldFail path=$($r.Path)"
# 回滚口令,避免影响后续人工验证
Send $admin 'POST' '/settings/password' @{ old_password = 'daili456'; password = 'daili123'; password2 = 'daili123' } | Out-Null
Write-Host "`n=== 16. 配置文件(CF) ===" -ForegroundColor Cyan
$settingsHtml = (Send $admin 'GET' '/settings').Content
Check 'CF-01' '设置页展示配置文件路径' ((Has $settingsHtml 'daili.ini')) ""
Check 'CF-02' '设置页展示备份目录与保留份数' ((Has $settingsHtml '备份') -and (Has $settingsHtml '保留')) ""
$anon2 = New-Sess
$r = Send $anon2 'GET' '/screen'
Check 'CF-03' '大屏免登录(screen_public = true)' ($r.Status -eq 200 -and $r.Path -eq '/screen') "status=$($r.Status) path=$($r.Path)"
$logs = @(Get-ChildItem 'F:\hupeE的代肝平台\testrun\logs' -Filter '*.log' -ErrorAction SilentlyContinue)
$logText = ''
if ($logs.Count -ge 1) { $logText = Get-Content $logs[0].FullName -Raw -Encoding UTF8 }
Check 'CF-04' '运行日志落盘' ($logs.Count -ge 1 -and $logText.Length -gt 20) "files=$($logs.Count) bytes=$($logText.Length)"
Write-Host "`n=== 汇总 ===" -ForegroundColor Cyan
$pass = @($script:results | Where-Object { $_.Result -eq 'PASS' }).Count
$fail = @($script:results | Where-Object { $_.Result -eq 'FAIL' }).Count
Write-Host (" 通过 {0} 项,失败 {1} 项,共 {2} 项" -f $pass, $fail, $script:results.Count) -ForegroundColor ($(if ($fail -eq 0) { 'Green' } else { 'Red' }))
$script:results | ForEach-Object { "{0}|{1}|{2}|{3}" -f $_.ID, $_.Result, $_.Name, $_.Detail } |
Set-Content -Path $LogFile -Encoding UTF8
Write-Host (" 结果已写入 {0}" -f $LogFile) -ForegroundColor DarkGray
if ($fail -gt 0) {
Write-Host "`n失败明细:" -ForegroundColor Red
$script:results | Where-Object { $_.Result -eq 'FAIL' } | ForEach-Object {
Write-Host (" {0} {1} {2}" -f $_.ID, $_.Name, $_.Detail) -ForegroundColor Red
}
exit 1
}
exit 0