# 冒烟测试执行脚本(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, '
.*?免登录') $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 '今天') -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)客户\s*(\d+)').Groups[1].Value $kpiDue = [regex]::Match($screen, '(?s)今日到期\s*(\d+)').Groups[1].Value $kpi7 = [regex]::Match($screen, '(?s)7 天内到期\s*(\d+)').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 '')) "" 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 = '' } $xssID = ($r.Path -split '/')[-1] $xss = (Send $admin 'GET' "/customers/$xssID").Content Check 'N-03' 'XSS 内容被转义' ((-not (Has $xss '')) -and (Has $xss '<script>')) "" $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