Files
subtrack/scripts/acceptance.ps1
T
subtrack bcce6bb830 fix(v1.8.1): 按次显示已完成/总次数、隐藏已开通套餐、已结束订阅置灰置底
1) 按次计费显示口径
- 订阅卡片、列表、大屏 TOP 都从「剩 x / y 次」改为「已完成 x / y 次」
- 进度条改为按已用次数计算

2) 开通订阅下拉过滤
- 客户详情「开通订阅」下拉自动隐藏该客户已开通的套餐,延期只能走「追加」入口,避免新开叠加
- 若客户已开通全部套餐,下拉只剩占位项

3) 已结束订阅置灰置底
- 客户详情的订阅卡片按「进行中 → 已结束」排序,已结束排到最后
- 已结束卡片降低透明度、降低饱和度,鼠标悬停恢复部分可见

数据库:无变更
测试:
- 功能验证 5 项:按次文案、已开通套餐不在下拉、未开通仍可选、已结束卡片置灰、已结束排在最后
- 119 项验收回归全绿(8 条按次断言随之更新为新口径)
- 无头 Chrome 实拍客户详情页;版本号 1.8.1
2026-10-19 19:53:50 +08:00

583 lines
36 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 冒烟测试执行脚本(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]$Root = '',
[string]$Db = '',
[string]$Python = '',
[string]$Tools = '',
[string]$LogFile = ''
)
try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop } catch { }
$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONIOENCODING = 'utf-8'
# ---- 路径与外部程序:全部基于脚本所在目录,换机器/换目录都不用改 ----
function Find-Python {
foreach ($c in @($env:PYTHON, 'python', 'py')) {
if (-not $c) { continue }
$cmd = Get-Command $c -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
}
foreach ($p in @('E:\Miniconda3\python.exe', 'C:\Python312\python.exe', 'C:\Python311\python.exe')) {
if (Test-Path $p) { return $p }
}
throw '找不到 Python 解释器,请用 -Python 指定路径'
}
if (-not $Root) { $Root = Split-Path -Parent $PSScriptRoot }
if (-not $Db) { $Db = Join-Path $Root 'testrun\daili.db' }
if (-not $Tools) { $Tools = Join-Path $Root 'scripts\dbtool.py' }
if (-not $LogFile) { $LogFile = Join-Path $Root 'testrun\acceptance-result.txt' }
if (-not $Python) { $Python = Find-Python }
$BackupDir = Join-Path $Root 'testrun\backup'
$LogDir = Join-Path $Root 'testrun\logs'
$VerifyScript = Join-Path $Root 'scripts\verify-backup.py'
$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' '按次已完成 0/10 次' ((Has $r.Content '已完成 0 / 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 '已完成 0 / 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 '已完成 0 / 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 '已完成 1 / 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 '已完成 1 / 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 '已完成 0 / 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 '已完成 0 / 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 '已完成 1 / 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"
$screenDue = (Send $admin 'GET' '/screen?heat=due').Content
Check 'D-03' '热力月历渲染(默认待完成,可切到期)' ((Has $screen '每日待完成') -and (Has $screen 'heat-cell') -and (Has $screenDue '到期热力')) ""
$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, '(\d+) / (\d+) 已完成').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 $VerifyScript $BackupDir) -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 $BackupDir -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 $LogDir -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