增加测试

This commit is contained in:
chfei committed 2026-10-28 11:35:48 +08:00
1 parent 6d4629e4ac
commit 497e9f1c34
14 files changed
+700 -74

No files matched your search

+4
View File
@@ -4,6 +4,10 @@
规则文件使用 `.md` 格式,按以下入口要求加载。 规则文件使用 `.md` 格式,按以下入口要求加载。
## 主要编码规范
入口只校验一次,拒绝重复判空和滥用try/catch;单次逻辑不乱抽函数,低概率防御先征求同意。
## 常驻规则 ## 常驻规则
开始任务前,读取并遵循: 开始任务前,读取并遵循:
+41
View File
@@ -0,0 +1,41 @@
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const html = fs.readFileSync(__dirname + '/index.html', 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
const elements = new Map();
function element(id) {
if (!elements.has(id)) elements.set(id, { value: id === 'pageSize' ? '50' : '', innerHTML: '', textContent: '', classList: { toggle() { return true; } }, setAttribute() {}, showModal() {}, close() {} });
return elements.get(id);
}
const context = vm.createContext({ document: { getElementById: element, querySelectorAll: () => [] }, console: { assert: assert.ok }, setTimeout: () => 1, clearTimeout() {}, FormData, assert });
vm.runInContext(script, context);
vm.runInContext(`
assert.equal(visibleRows().length, 50);
page=2; assert.equal(visibleRows().length, 22);
query='乐乐'; assert.equal(filtered().length, 3);
query=''; group='服饰鞋包'; assert.equal(filtered().length, 24);
group=''; tab='已过期'; assert.ok(filtered().every(r=>r.status==='已过期'));
for (const key of Object.keys(configs)) {
switchPage(key);
assert.ok($('tbody').innerHTML.includes('data-edit'));
openEditor(data[key][0].id);
assert.ok($('editorFields').innerHTML.includes('name="name"'));
hiddenColumns.add('group'); renderTable();
assert.ok(!$('thead').innerHTML.includes('所属分组'));
}
switchPage('stores');
assert.equal(configs.stores.columns.length, 11);
assert.equal(configs.users.columns.length, 13);
assert.ok($('tbody').innerHTML.includes('data-store-action'));
selected.add(data.stores[3].id); $('login').onclick();
assert.equal(data.stores[3].login, '登录成功');
$('batch').onclick();
assert.equal(data.stores[3].status, '正常');
assert.equal(selected.size, 0);
query='no-matching-record'; renderTable();
assert.ok($('tbody').innerHTML.includes('没有匹配的记录'));
assert.equal($('next').disabled, true);
assert.equal(escapeHtml('<img onerror="bad">'), '&lt;img onerror=&quot;bad&quot;&gt;');
`, context);
console.log('PASS: script syntax, five page renders, pagination, filters, editors, columns, batch action, empty state and HTML escaping.');
+19
View File
@@ -0,0 +1,19 @@
# 设计检查记录
final result: blocked
参考:D:/Desktop/上货截图 下的五张原始软件截图,已逐一查看。
设计范围:店铺管理、子账号管理、超级店铺管理、超级用户管理、OEM 贴牌管理。保留 #98638f 主色,重新组织导航、筛选、表格及编辑面板。采用演示数据,不复制真实账号。
阻碍:内置浏览器安全策略拒绝 file:// 协议,未进行替代浏览器或协议绕过。没有完成浏览器截图、视觉对比与真实浏览器交互验收,不能声称视觉 QA 通过。
代码检查:运行 node DesignDemo/check.cjs,覆盖脚本语法、五页渲染、筛选、分页、编辑表单构建、列设置、批量操作、空状态及 HTML 转义。此检查使用 DOM 桩,不代替浏览器验证。
使用:双击 index.html,以现代 Edge 或 Chrome 打开。所有资源内联,无网络依赖。数据仅保留在页面内存,刷新页面恢复初始样例;新增、编辑、续期及权限均为本地交互演示。
WPF 映射:侧栏与主区域采用 Grid,列表采用 DataGrid,筛选采用 TextBox/ComboBox,编辑采用项目对话框链路。颜色可迁入 ResourceDictionary;行高提供 36px 舒适与 28px 紧凑两档,默认紧凑;每页默认 50 条。实际 WPF 控件尺寸、DPI、性能和键盘行为需要后续实现时验证。
第二版调整:移除统计卡片与大标题说明,采用 44px 顶栏、156px 侧栏、行内筛选和表格网格线。店铺列表显示 11 个业务列,超级用户显示 13 个业务列;增加序号、模拟进入/续期和批量检测/登录操作。编辑改为居中双列表单。样例店铺扩展至 72 条以验证分页。视觉 QA 仍因前述浏览器限制未完成。
第三版调整:普通按钮、输入框、面板及表格边框使用中性灰;主按钮保留 #98638f 填充并取消彩色描边;行内次要操作改为灰色文字,悬停采用灰底,选中行保留浅品牌色。维持原有紧凑布局和业务交互。视觉验收状态仍为 blocked。
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>29 供应链 · 店铺管理设计演示</title>
<style>
:root{--brand:#98638f;--brand-dark:#784b70;--tint:#f4edf3;--ink:#292731;--muted:#85828e;--line:#e5e7eb;--canvas:#f5f6f7;--green:#31856a;--red:#c35c64;--amber:#a77725}
*{box-sizing:border-box}body{margin:0;font:14px/1.5 "Segoe UI","Microsoft YaHei",sans-serif;color:var(--ink);background:var(--canvas)}button,input,select{font:inherit}button{cursor:pointer;white-space:nowrap}button,input,select{border:1px solid #d1d5db;border-radius:6px;background:white;color:inherit}button{padding:8px 15px;transition:background .15s}button:hover:not(:disabled){background:#f0f2f4;border-color:#b9c0c8}button:disabled{background:#f5f6f7;color:#a0a6ad;border-color:#e2e5e9;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid #64748b;outline-offset:2px}input,select{height:36px;padding:0 11px;min-width:0}input[type=checkbox]{width:15px;height:15px;accent-color:var(--brand);vertical-align:middle;cursor:pointer}button.primary{background:var(--brand);color:#fff;border-color:transparent}button.primary:hover:not(:disabled){background:var(--brand-dark);border-color:transparent}button.text{border:0;background:none;color:#626970;padding:5px 8px}button.text:hover:not(:disabled){background:#e9edf0;color:#30353b}.muted{color:var(--muted)}.mono{font-family:Consolas,monospace;font-size:12px;color:#777482}.app{display:grid;grid-template-columns:218px minmax(0,1fr);height:100vh;min-height:680px}.sidebar{background:#fff;border-right:1px solid var(--line);display:flex;flex-direction:column;padding:28px 16px 16px}.brand{padding:0 12px 30px;display:flex;align-items:center;gap:10px}.brand b{font-size:29px;color:var(--brand);letter-spacing:-2px}.brand strong{font-size:17px;letter-spacing:1px}.brand small{display:block;color:var(--muted);font-size:10px;letter-spacing:2px}.section-label{padding:15px 14px 8px;color:#9a97a1;font-size:11px;letter-spacing:1.4px}.nav{display:flex;align-items:center;gap:13px;width:100%;border:0;text-align:left;padding:12px 14px;margin:3px 0;background:none;color:#65616e;border-radius:7px}.nav .nav-index{font:11px Consolas;color:#a6a0aa}.nav.active{background:var(--tint);color:var(--brand-dark);font-weight:600}.nav.active .nav-index{color:var(--brand)}.nav.active:after{content:"";width:5px;height:5px;border-radius:50%;background:var(--brand);margin-left:auto}.sidebar-bottom{margin-top:auto;padding:18px 12px 0;border-top:1px solid var(--line)}.demo-badge{font-size:11px;background:var(--tint);color:var(--brand-dark);padding:3px 7px;border-radius:4px}.sidebar-bottom p{font-size:11px;color:var(--muted);line-height:1.8}.shell{min-width:0;display:flex;flex-direction:column;overflow:hidden}.topbar{height:66px;flex-shrink:0;background:white;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 30px}.breadcrumb{font-size:12px;color:var(--muted)}.breadcrumb span{color:var(--ink);padding-left:10px}.profile{display:flex;align-items:center;gap:20px;font-size:12px}.profile b{color:var(--brand-dark);font-weight:600}.avatar{background:var(--tint);color:var(--brand-dark);padding:8px 10px;border-radius:8px;font-weight:600}.content{padding:26px 30px 18px;flex:1;min-height:0;display:flex;flex-direction:column;gap:20px}.page-heading{display:flex;justify-content:space-between;align-items:center;gap:20px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--brand);font-weight:700;margin-bottom:4px}h1{font-size:25px;letter-spacing:.5px;margin:0;font-weight:650}.page-heading p{font-size:12px;color:var(--muted);margin:6px 0 0}.heading-actions{display:flex;gap:10px}.stats{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--line);background:white;border-radius:9px;padding:19px 0}.stat{padding:0 25px;border-right:1px solid var(--line);position:relative}.stat:last-child{border:0}.stat-label{color:#77727f;font-size:12px}.stat-number{font-size:27px;font-weight:650;margin-top:5px;font-variant-numeric:tabular-nums;letter-spacing:-.5px}.stat-note{position:absolute;right:23px;bottom:6px;font-size:11px;color:var(--muted)}.stat:nth-child(2) .stat-number{color:var(--green)}.stat:nth-child(3) .stat-number{color:var(--amber)}.stat:nth-child(4) .stat-number{color:var(--brand)}.workspace{background:#fff;border:1px solid var(--line);border-radius:9px;display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden}.tabs{display:flex;gap:26px;margin:0 22px;border-bottom:1px solid var(--line);min-height:53px;align-items:stretch}.tab{border:0;border-radius:0;padding:0 2px;background:transparent;font-size:13px;color:#898390;position:relative}.tab.active{color:var(--brand-dark);font-weight:600}.tab.active:after{content:"";height:3px;background:var(--brand);position:absolute;bottom:0;left:0;right:0;border-radius:3px 3px 0 0}.tab small{margin-left:7px;font-size:11px;color:inherit;background:#f5f3f6;padding:2px 6px;border-radius:4px}.filters{display:flex;align-items:end;gap:12px;padding:18px 22px}.field{display:flex;flex-direction:column;gap:7px}.field label{font-size:11px;color:#77717f}.search-field{flex:1;max-width:330px}.field select{width:150px}.toolbar{display:flex;justify-content:space-between;align-Line truncated
/* Desktop workbench: controls stay compact; wide datasets scroll horizontally. */
body{font-size:12px;background:#f3f4f5;color:#30353b;min-width:1000px}
.app{grid-template-columns:156px minmax(0,1fr);min-height:560px}
.sidebar{padding:0;background:#f7f8f9;border-right:1px solid #d6d9de}
.brand{height:44px;padding:0 12px;gap:8px;background:var(--brand);color:white;border-bottom:1px solid transparent}
.brand b{color:white;font-size:24px}.brand strong{font-size:15px}.brand small{display:none}
.section-label{padding:12px 12px 5px;font-size:11px;letter-spacing:0;color:#808791}
.nav{border-radius:0;margin:0;padding:8px 12px;gap:8px;font-size:12px;height:34px}
.nav.active{background:#eee5ec;color:var(--brand-dark);border-left:0;padding-left:12px}
.nav.active:after{display:none}.nav .nav-index{font-size:10px}.sidebar-bottom{padding:10px 8px}.sidebar-bottom p{display:none}.demo-badge{font-size:10px;padding:2px 4px}.sidebar-bottom button{font-size:11px}
.topbar{height:44px;padding:0 14px;background:var(--brand);color:white;border-bottom:1px solid transparent}.breadcrumb,.breadcrumb span,.profile b{color:white}.breadcrumb{font-size:12px}.profile{gap:16px;font-size:11px}.avatar{display:none}
.content{padding:0 7px 0;gap:0}.page-heading{height:39px;min-height:39px;padding:0 4px;border-bottom:1px solid #dce0e4;gap:10px}.page-heading p,.eyebrow{display:none}h1{font-size:13px;letter-spacing:0;font-weight:600}.heading-actions{gap:6px}
button,input,select{border-radius:2px;font-size:12px}button{padding:3px 10px;height:27px}input,select{height:26px;padding:0 7px}input[type=checkbox]{height:13px;width:13px;margin:0}button.text{height:24px;padding:1px 6px}
.workspace{border:1px solid #d6d9de;border-top:0;border-radius:0}.tabs{min-height:31px;height:31px;flex-shrink:0;margin:0;padding:0 9px;gap:3px;background:#f2f3f5;border-bottom:1px solid #dce0e4}.tab{height:31px;padding:0 13px;font-size:12px;color:#746b78;border-right:1px solid #e3e5e8}.tab.active{background:white}.tab.active:after{height:2px}.tab small{font-size:10px;padding:0 4px;background:none}
.filters{padding:7px 9px;gap:9px;align-items:center;flex-wrap:wrap;background:#ffffff}.filters .field{flex-direction:row;align-items:center;gap:5px}.filters .field label{font-size:12px;color:#626970;white-space:nowrap}.filters .field select{width:112px}.search-field{flex:none;max-width:none}.search-field input{width:235px}
.toolbar{padding:5px 9px;border-top:1px solid #e5e7eb;gap:7px;min-height:36px;background:#fafafa;flex-wrap:wrap}.toolbar-left,.toolbar-right{gap:6px}.toolbar button{font-size:12px;padding:2px 9px;height:25px}.toolbar-left>span{font-size:11px;border-right:1px solid #dce0e4;padding-right:9px;margin-right:3px}
.table-scroll{border-top:1px solid #d6d9de}table{font-size:12px;width:max-content;min-width:100%}th{height:29px;font-size:12px;padding:0 9px;background:#f0f2f4;color:#59616b;font-weight:500;border-right:1px solid #e0e3e7;border-bottom:1px solid #d4d8dd}td{height:36px;padding:2px 9px;border-right:1px solid #e9ebee;border-bottom:1px solid #e9ebee;color:#343a40}.compact td{height:28px;padding:1px 9px}
th:first-child,td:first-child{width:30px;min-width:30px;padding:0 8px;position:sticky;left:0;background:white;z-index:1}th:first-child{background:#f0f2f4;z-index:3}.row-number{color:#9299a1;text-align:right;font-size:11px;min-width:34px}thead th{z-index:2}th:last-child{z-index:3;background:#f0f2f4}td:last-child,th:last-child{box-shadow:-3px 0 4px #0000000a;border-left:1px solid #dce0e4}
tbody tr:nth-child(even) td{background:#fafbfc}tbody tr:hover td{background:#f0f2f4}tbody tr.selected td{background:#f3eaf1}.entity{gap:0}.entity-mark,.entity small,.compact .entity-mark{display:none}.entity-name{margin:0;font-size:12px;font-weight:500}.pill{font-size:11px;padding:0 5px;border-radius:2px;line-height:19px}.tag{background:none;padding:0;font-size:12px;color:#60666e}.date{font-size:12px}.subline{display:inline;font-size:10px;margin-left:6px}.mono{color:#69727d;font-size:11px}.muted{color:#7c838c}.row-action{padding:0 5px!important;height:23px!important}.pagination{height:35px;min-height:35px;flex-shrink:0;padding:3px 9px;background:#f7f8f9;font-size:11px}.pagination button,.pagination select{height:24px}.pagination-controls{gap:6px}.current-page{padding:2px 8px}.statusbar{height:23px;min-height:23px;align-items:center;padding:0 5px;font-size:10px}
.drawer{margin:auto;width:620px;max-width:90vw;height:auto;max-height:90vh;border-radius:3px}.dialog-head{padding:11px 15px;background:#f5f6f7}.dialog-head h2{font-size:14px}.dialog-head p{font-size:10px;margin-top:2px}.dialog-body{padding:15px;gap:13px}.drawer .dialog-body{display:grid;grid-template-columns:1fr 1fr}.drawer .dialog-body>.help{grid-column:1/-1}.dialog-body input:not([type=checkbox]),.dialog-body select{height:29px}.dialog-body .field{gap:5px}.dialog-footer{padding:9px 15px}.permissions{grid-template-columns:1fr;gap:6px}.columns-dialog{border-radius:3px}.help{border-radius:2px;font-size:11px}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar"><div class="brand"><b>29</b><div><strong>供应链</strong><small>COMMERCE WORKSPACE</small></div></div><div class="section-label">店铺工作台</div><nav aria-label="店铺工作台"><button class="nav active" data-page="stores"><span class="nav-index">01</span>店铺管理</button><button class="nav" data-page="accounts"><span class="nav-index">02</span>子账号管理</button></nav><div class="section-label">超级管理</div><nav aria-label="超级管理"><button class="nav" data-page="superstores"><span class="nav-index">03</span>超级店铺管理</button><button class="nav" data-page="users"><span class="nav-index">04</span>超级用户管理</button><button class="nav" data-page="oem"><span class="nav-index">05</span>OEM 贴牌管理</button></nav><div class="sidebar-bottom"><span class="demo-badge">桌面工作台 · 演示</span><p>专注每一家店铺的日常运营<br>本地交互演示 · 数据均为模拟</p><button class="text" id="designNotes">界面设计说明</button></div></aside>
<div class="shell"><header class="topbar"><div class="breadcrumb">管理工作空间 <span>/</span><span id="crumb">店铺管理</span></div><div class="profile"><span>账户积分 <b>9,759</b></span><span class="avatar">管</span><span>管理员</span></div></header>
<main class="content"><section class="page-heading"><div><div class="eyebrow" id="eyebrow">STORE MANAGEMENT</div><h1 id="pageTitle">店铺管理</h1><p id="pageDescription"></p></div><div class="heading-actions"><button id="refresh">刷新列表</button><button class="primary" id="add">添加店铺</button></div></section>
<section class="workspace" aria-label="管理列表"><div class="tabs" id="tabs"></div><form class="filters" id="filterForm"><div class="field search-field"><label for="keyword">关键词</label><input id="keyword" maxlength="100" placeholder="搜索店铺名称 / 店铺 ID"></div><div class="field"><label for="groupFilter">所属分组</label><select id="groupFilter"></select></div><div class="field"><label for="statusFilter">状态</label><select id="statusFilter"><option value="">全部状态</option><option>正常</option><option>即将到期</option><option>已过期</option></select></div><button class="primary" type="submit">查询</button><button type="button" id="reset">重置</button></form>
<div class="toolbar"><div class="toolbar-left"><span id="selection" class="muted">选择记录后可批量操作</span><button id="batch" disabled>批量续期</button><button id="detect" disabled>检测选中</button><button id="login" disabled>登录选中</button></div><div class="toolbar-right"><span class="muted" id="resultCount"></span><button id="density" aria-pressed="true">舒适视图</button><button id="columnSettings">列设置</button></div></div>
<div class="table-scroll"><table id="table" class="compact"><thead id="thead"></thead><tbody id="tbody"></tbody></table></div><footer class="pagination"><span id="range"></span><div class="pagination-controls"><label for="pageSize">每页</label><select id="pageSize"><option>20</option><option selected>50</option><option>100</option></select><button id="prev" aria-label="上一页">上一页</button><span class="current-page" id="pageNumber">1</span><button id="next" aria-label="下一页">下一页</button></div></footer></section>
<footer class="statusbar"><span id="loadStatus">数据已就绪 · 本地演示</span><span>29 供应链 / 管理工作台</span></footer></main></div></div>
<dialog class="drawer" id="editor"><form id="editForm"><header class="dialog-head"><div><h2 id="editorTitle"></h2><p>修改仅在本次演示中生效</p></div><button type="button" class="text" data-close="editor">关闭</button></header><div class="dialog-body" id="editorFields"></div><footer class="dialog-footer"><button type="button" data-close="editor">取消</button><button class="primary" type="submit">保存</button></footer></form></dialog>
<dialog class="columns-dialog" id="columns"><header class="dialog-head"><h2>显示的列</h2><button class="text" data-close="columns">完成</button></header><div class="dialog-body" id="columnOptions"></div></dialog>
<dialog id="details" style="width:520px;max-width:95vw"><header class="dialog-head"><h2 id="detailTitle"></h2><button class="text" data-close="details">关闭</button></header><div class="dialog-body" id="detailBody"></div></dialog><div class="toast" role="status" id="toast" hidden></div>
<script>
'use strict';
const $=id=>document.getElementById(id);
const escapeHtml=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const names=['乐乐生活馆','清麦日用百货','栖木家居旗舰店','佐薇服饰小铺','青禾优选','云朵母婴生活','拾光好物集合店','橙意食品专营店','木子家居日用','浅湾服饰店','山野户外生活','简物生活旗舰店','暖晨家纺店','知味食品小铺','白露日用百货','云间服饰集合店','小满优选','春序家居店','鹿鸣母婴店','青山好物','星野百货','暮色生活馆','沐光服饰','初禾日用品'];
const stores=Array.from({length:72},(_,i)=>({name:names[i%names.length]+(i>=24?" · "+(Math.floor(i/24)+1)+"店":""),i})).map(({name,i})=>({id:1001+i,name,code:'wx'+(316129135100+i*74031).toString(16)+'a'+i,group:['精选店铺','服饰鞋包','生活百货'][i%3],account:['运营一组','运营二组','未分配'][i%3],owner:'demo_owner_'+(i%4+1),phone:'138****'+String(1200+i),status:i%7===3?'已过期':i%5===2?'即将到期':'正常',expires:i%7===3?'2026-10-20':i%5===2?'2026-11-02':'2027-05-09',note:i%3===0?'重点运营店铺':'',permissions:['店铺列表','订单管理'],created:'2026-08-14 15:16',login:i%7===3?'未登录':'登录成功',api:i%4===0?'未购买':'已开通',permanent:i%9===0?'是':'否',secondary:i%6===0?'已启用':'未启用'}));
const data={stores,superstores:stores.map(r=>({...r,id:r.id+3000})),accounts:['林小夏','陈晓','周宁','许安','李可','宋悦'].map((name,i)=>({id:2001+i,name,code:'operator_'+(i+1),phone:'139****'+(2300+i),group:['运营','客服','仓储'][i%3],status:i===4?'已停用':'正常',account:'主账号 · 演示企业',note:'',permissions:['店铺列表','订单管理']})),users:Array.from({length:18},(_,i)=>({id:3001+i,name:['青禾供应链','云飞商贸','开兴科技','花下海','众店联源','微擎科技'][i%6]+(i>5?' '+(Math.floor(i/6)+1):''),code:'user_'+(801+i),phone:'137****'+(5600+i),group:i%4===0?'代理商':'主账户',status:i===5?'已停用':'正常',company:['青禾商贸有限公司','云飞供应链有限公司','开兴网络科技'][i%3],points:300+i*20,balance:(140+i*37.5).toFixed(2),fee:'2.50',monthly:30,returnFee:'3.00',agent:['云飞','大牛','花下海'][i%3],type:'普通用户',permissions:['商品采集','在线寄件'],note:''})),oem:['桃花源供应链','店速通','开茂科技','枫谷拍单','洋总代理','臻和网络','贝塔一体仓','佳俊商贸'].map((name,i)=>({id:5001+i,name,code:'26'+(701+i)+'TW'+i,group:'品牌合作',status:'正常',account:'demo_brand_'+(i+1),phone:'136****'+(7400+i),note:'',permissions:[]}))};
const configs={stores:{title:'店铺管理',en:'STORE MANAGEMENT',desc:'集中管理店铺、账号与授权,让日常运营井然有序。',add:'添加店铺',batch:'批量续期',search:'搜索店铺名称 / 店铺 ID',columns:[['name','店铺信息'],['group','所属分组'],['account','子账号'],['status','授权状态'],['expires','到期时间'],['note','备注']],groups:['精选店铺','服饰鞋包','生活百货']},superstores:{title:'超级店铺管理',en:'STORE ADMINISTRATION',desc:'跨账号查看全部店铺,统一管理店铺归属与授权状态。',add:'添加店铺',batch:'批量检测',search:'搜索店铺 / 所属账号 / 店铺 ID',columns:[['name','店铺信息'],['owner','所属账号'],['group','店铺分组'],['account','子账号'],['status','授权状态'],['expires','到期时间']],groups:['精选店铺','服饰鞋包','生活百货']},accounts:{title:'子账号管理',en:'TEAM & ACCESS',desc:'为团队分配独立账号,以清晰的访问权限协同管理店铺。',add:'添加子账号',batch:'批量启用',search:'搜索昵称 / 登录账号',columns:[['name','用户信息'],['phone','登录手机号'],['group','角色分组'],['status','账号状态'],['permissions','可访问页面'],['account','所属主账号']],groups:['运营','客服','仓储']},users:{title:'超级用户管理',en:'USER ADMINISTRATION',desc:'统一管理用户、功能权限与账户信息。',add:'添加用户',batch:'批量启用',search:'搜索用户 / 手机号 / 公司',columns:[['name','用户信息'],['group','用户分组'],['company','公司'],['points','积分'],['balance','快递余额'],['fee','每单快递费'],['status','账号状态']],groups:['主账户','代理商']},oem:{title:'OEM 贴牌管理',en:'BRAND PARTNERS',desc:'管理合作品牌标识与主体账号,建立统一的品牌工作空间。',add:'添加贴牌',batch:'批量启用',search:'搜索贴牌名称 / 贴牌编码',columns:[['name','贴牌名称'],['code','贴牌编码'],['account','主体账号'],['status','状态'],['assets','品牌素材']],groups:['品牌合作']}};
configs.stores.columns=[['name','店铺名称'],['code','店铺 ID'],['created','添加时间'],['expires','到期时间'],['status','授权状态'],['group','分组'],['account','子账号'],['phone','子账号手机号'],['login','登录状态'],['api','API 购买'],['note','备注']];
configs.superstores.columns=[['name','店铺名称'],['code','店铺 ID / 登录号码'],['owner','所属账号'],['account','子账号'],['group','店铺分组'],['login','登录状态'],['status','授权状态'],['api','API 购买'],['secondary','二级超管'],['permanent','永久'],['note','备注']];
configs.accounts.columns=[['name','用户昵称'],['code','登录账号'],['phone','手机号'],['group','分组'],['account','所属主账号'],['status','状态'],['permissions','可访问页面'],['note','备注']];
configs.users.columns=[['name','用户昵称'],['phone','手机 / 登录账号'],['group','分组'],['company','公司'],['type','账户类型'],['permissions','功能权限'],['agent','代理名称'],['points','积分'],['monthly','月费耗点'],['balance','快递余额'],['fee','每单快递费'],['returnFee','每单退货费'],['status','状态']];
configs.oem.columns=[['code','贴牌编码'],['name','贴牌名称'],['account','主体账号'],['assets','图标'],['assetsTitle','题图'],['status','状态'],['note','备注']];
let current='stores',tab='',page=1,query='',group='',status='',sortAsc=true,editingId=null,selected=new Set(),hiddenColumns=new Set(),toastTimer;
function isStore(){return current==='stores'||current==='superstores'}
function filtered(){return data[current].filter(r=>(!query||Object.values(r).join(' ').toLowerCase().includes(query))&&(!group||r.group===group)&&(!status||r.status===status)&&(!tab||r.status===tab)).sort((a,b)=>sortAsc?a.name.localeCompare(b.name,'zh-CN'):b.name.localeCompare(a.name,'zh-CN'))}
function visibleRows(){const rows=filtered();return rows.slice((page-1)*Number($('pageSize').value),page*Number($('pageSize').value))}
function showToast(message){$('toast').textContent=message;$('toast').hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').hidden=true,3200)}
function renderTabs(){const tabs=isStore()?[['','全部店铺'],['正常','授权正常'],['即将到期','即将到期'],['已过期','已过期']]:[['','全部'+(current==='oem'?'品牌':'账号')],['正常','正常'],['已停用','已停用']];$('tabs').innerHTML=tabs.map(([value,label])=>`<button class="tab ${tab===value?'active':''}" data-tab="${value}" aria-pressed="${tab===value}">${label}<small>${data[current].filter(r=>!value||r.status===value).length}</small></button>`).join('')}
function cell(r,key){const v=escapeHtml(r[key]);switch(key){case'name':return `<div class="entity"><span class="entity-mark ${r.id%3===0?'alt':''}">${escapeHtml(r.name.slice(0,1))}</span><div><div class="entity-name">${v}</div><small>${escapeHtml(r.code)}</small></div></div>`;case'login':return `<span style="color:${r.login==='登录成功'?'var(--green)':'var(--muted)'}">${v}</span>`;case'status':return `<span class="pill ${r.status==='即将到期'?'warn':r.status==='正常'?'':'error'}">${v==='正常'&&isStore()?'授权正常':v}</span>`;case'group':return `<span class="tag">${v}</span>`;case'expires':return `<span class="date">${v}</span>${r.status==='即将到期'?'<div class="subline" style="color:var(--amber)">5 天后到期</div>':''}`;case'permissions':return `<span class="muted">${escapeHtml(r.permissions.join('、'))||'尚未分配'}</span>`;case'assetsTitle':case'assets':return '<span class="muted">暂未上传</span>';case'balance':case'fee':case'returnFee':return `<span class="date">¥ ${v}</span>`;case'code':case'owner':return `<span class="mono">${v}</span>`;case'note':return `<span class="muted">${v||'—'}</span>`;default:return v||'<span class="muted">—</span>'}}
function renderTable(){const cfg=configs[current],rows=filtered(),size=Number($('pageSize').value);page=Math.max(1,Math.min(page,Math.ceil(rows.length/size)||1));const visible=visibleRows(),cols=cfg.columns.filter(([key])=>!hiddenColumns.has(key));$('thead').innerHTML=`<tr><th><input type="checkbox" id="selectAll" aria-label="选择当前页全部记录" ${visible.length&&visible.every(r=>selected.has(r.id))?'checked':''}></th><th class="row-number">序号</th>${cols.map(([key,label])=>`<th>${key==='name'?`<button class="sort" id="sortName">${label} ${sortAsc?'↑':'↓'}</button>`:label}</th>`).join('')}<th>操作</th></tr>`;$('tbody').innerHTML=visible.length?visible.map((r,index)=>`<tr class="${selected.has(r.id)?'selected':''}"><td><input type="checkbox" data-select="${r.id}" aria-label="选择${escapeHtml(r.name)}" ${selected.has(r.id)?'checked':''}></td><td class="row-number">${(page-1)*size+index+1}</td>${cols.map(([key])=>`<td>${cell(r,key)}</td>`).join('')}<td>${isStore()?`<button class="text row-action" data-store-action="enter" data-id="${r.id}">进入</button><button class="text row-action" data-store-action="renew" data-id="${r.id}">续期</button>`:""}<button class="text row-action" data-detail="${r.id}">${isStore()?'查看':'详情'}</button><button class="text row-action" data-edit="${r.id}">编辑</button></td></tr>`).join(''):`<tr><td class="empty" colspan="${cols.length+3}">没有匹配的记录,请调整筛选条件或重置。</td></tr>`;const all=$('selectAll');all.indeterminate=visible.some(r=>selected.has(r.id))&&!visible.every(r=>selected.has(r.id));$('selection').textContent=selected.size?`已选择 ${selected.size} 项`:'选择记录后可批量操作';$('selection').className=selected.size?'selection':'muted';$('batch').disabled=!selected.size;$('detect').disabled=$('login').disabled=!selected.size;$('resultCount').textContent=`共 ${rows.length} 条记录`;$('range').textContent=rows.length?`显示 ${(page-1)*size+1}–${Math.min(page*size,rows.length)} 条,共 ${rows.length} 条`:'共 0 条记录';$('pageNumber').textContent=`${page} / ${Math.ceil(rows.length/size)||1}`;$('prev').disabled=page===1;$('next').disabled=page*size>=rows.length}
function render(){renderTabs();renderTable()}
function switchPage(key){current=key;tab='';page=1;query='';group='';status='';selected.clear();hiddenColumns.clear();const cfg=configs[key];$('detect').hidden=$('login').hidden=!isStore();document.querySelectorAll('[data-page]').forEach(b=>{b.classList.toggle('active',b.dataset.page===key);b.setAttribute('aria-current',b.dataset.page===key?'page':'false')});$('crumb').textContent=$('pageTitle').textContent=cfg.title;$('eyebrow').textContent=cfg.en;$('pageDescription').textContent=cfg.desc;$('add').textContent=cfg.add;$('batch').textContent=cfg.batch;$('keyword').value='';$('keyword').placeholder=cfg.search;$('groupFilter').innerHTML='<option value="">全部分组</option>'+cfg.groups.map(g=>`<option>${g}</option>`).join('');$('statusFilter').innerHTML='<option value="">全部状态</option>'+(isStore()?['正常','即将到期','已过期']:['正常','已停用']).map(s=>`<option>${s}</option>`).join('');render()}
function field(label,name,value='',required=false,type='text'){return `<div class="field"><label for="edit_${name}">${label}${required?' <span style="color:var(--brand)">*</span>':''}</label><input id="edit_${name}" name="${name}" type="${type}" value="${escapeHtml(value)}" maxlength="100" ${required?'required':''}></div>`}
function openEditor(id=null){editingId=id;const r=data[current].find(r=>r.id===id)||{},cfg=configs[current];$('editorTitle').textContent=id?'编辑'+(isStore()?'店铺':current==='oem'?'贴牌':'用户'):cfg.add;let fields=field(isStore()?'店铺名称':current==='oem'?'贴牌名称':'用户昵称','name',r.name,true)+field(isStore()?'店铺 ID':current==='oem'?'贴牌编码':'登录账号','code',r.code,true);if(current==='accounts'||current==='users')fields+=field('手机号','phone',r.phone,true)+field(id?'新密码(留空则不修改)':'密码','password','',!id,'password');fields+=`<div class="field"><label for="edit_group">所属分组</label><select name="group" id="edit_group">${cfg.groups.map(g=>`<option ${r.group===g?'selected':''}>${g}</option>`).join('')}</select></div>`;if(isStore()||current==='oem')fields+=field(current==='oem'?'主体账号':'子账号','account',r.account,current==='oem');if(current==='users')fields+=field('公司','company',r.company);if(!isStore())fields+=`<div class="field"><label for="edit_status">账号状态</label><select name="status" id="edit_status"><option ${r.status!=='已停用'?'selected':''}>正常</option><option ${r.status==='已停用'?'selected':''}>已停用</option></select></div>`;if(current==='accounts'||current==='users')fields+=`<div class="field"><label>可访问页面</label><div class="permissions">${['店铺列表','订单管理','商品采集','在线寄件','评分考核','税务申报'].map(p=>`<label><input type="checkbox" name="permission" value="${p}" ${(r.permissions||[]).includes(p)?'checked':''}>${p}</label>`).join('')}</div></div>`;fields+=field('备注','note',r.note);if(isStore()&&!id)fields+='<div class="help">此处演示手动添加流程。正式系统可在此接入扫码授权;新记录默认使用演示授权。</div>';$('editorFields').innerHTML=fields;$('editor').showModal()}
document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>switchPage(b.dataset.page));
$('filterForm').onsubmit=e=>{e.preventDefault();query=$('keyword').value.trim().toLowerCase();group=$('groupFilter').value;status=$('statusFilter').value;page=1;selected.clear();renderTable()};
$('reset').onclick=()=>{query=group=status=tab='';page=1;selected.clear();$('keyword').value=$('groupFilter').value=$('statusFilter').value='';render()};
$('tabs').onclick=e=>{const b=e.target.closest('[data-tab]');if(b){tab=b.dataset.tab;page=1;selected.clear();renderTabs();renderTable()}};
$('prev').onclick=()=>{page--;renderTable()};$('next').onclick=()=>{page++;renderTable()};$('pageSize').onchange=()=>{page=1;renderTable()};
$('table').onchange=e=>{if(e.target.id==='selectAll')visibleRows().forEach(r=>e.target.checked?selected.add(r.id):selected.delete(r.id));else if(e.target.dataset.select){const id=Number(e.target.dataset.select);e.target.checked?selected.add(id):selected.delete(id)}renderTable()};
$('table').onclick=e=>{const action=e.target.closest('[data-store-action]');if(action){const r=data[current].find(r=>r.id===Number(action.dataset.id));if(action.dataset.storeAction==='renew'){r.status='正常';r.expires='2027-05-09';render();showToast('已模拟续期:'+r.name)}else{showToast('模拟进入店铺:'+r.name+'(未连接真实店铺)')}return}const edit=e.target.closest('[data-edit]'),detail=e.target.closest('[data-detail]');if(edit)openEditor(Number(edit.dataset.edit));if(detail){const r=data[current].find(r=>r.id===Number(detail.dataset.detail));$('detailTitle').textContent=r.name;$('detailBody').innerHTML=configs[current].columns.map(([key,label])=>`<div><div class="muted" style="font-size:11px;margin-bottom:6px">${label}</div>${cell(r,key)}</div>`).join('')+'<div class="notice">此为本地演示记录,不会连接店铺或读取真实账号。</div>';$('details').showModal()}if(e.target.id==='sortName'){sortAsc=!sortAsc;renderTable()}};
$('density').onclick=()=>{const compact=$('table').classList.toggle('compact');$('density').textContent=compact?'舒适视图':'紧凑视图';$('density').setAttribute('aria-pressed',compact)};
$('columnSettings').onclick=()=>{$('columnOptions').innerHTML=configs[current].columns.map(([key,label])=>`<label><input type="checkbox" value="${key}" ${hiddenColumns.has(key)?'':'checked'} ${key==='name'?'disabled':''}>${label}${key==='name'?'(固定)':''}</label>`).join('');$('columns').showModal()};
$('columnOptions').onchange=e=>{e.target.checked?hiddenColumns.delete(e.target.value):hiddenColumns.add(e.target.value);renderTable()};
$('add').onclick=()=>openEditor();document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('editForm').onsubmit=e=>{e.preventDefault();const values=new FormData(e.target),name=String(values.get('name')).trim(),code=String(values.get('code')).trim();if(!name||!code){showToast('名称和编号不能为空');return}if(data[current].some(r=>r.code===code&&r.id!==editingId)){showToast('该编号已存在,请使用其他编号');return}const existing=data[current].find(r=>r.id===editingId);const record={...(existing||{id:Date.now(),status:'正常',expires:'2027-05-09',owner:'demo_owner_1',points:0,balance:'0.00',fee:'2.50'}),name,code,group:values.get('group'),note:values.get('note'),permissions:values.getAll('permission')};for(const key of ['phone','account','company','status'])if(values.has(key))record[key]=values.get(key);if(existing)Object.assign(existing,record);else data[current].unshift(record);$('editor').close();render();showToast(editingId?'已保存修改(演示)':'已添加记录(演示)')};
$('batch').onclick=()=>{let count=selected.size;data[current].filter(r=>selected.has(r.id)).forEach(r=>{if(current!=='superstores'){r.status='正常';if(current==='stores')r.expires='2027-05-09'}});selected.clear();render();showToast(current==='superstores'?`已完成 ${count} 家店铺的模拟检测`:`已更新 ${count} 条演示记录`)};
$('detect').onclick=()=>{showToast('已模拟检测 '+selected.size+' 家店铺;登录状态已显示在表格中')};
$('login').onclick=()=>{data[current].filter(r=>selected.has(r.id)).forEach(r=>r.login='登录成功');renderTable();showToast('已模拟登录 '+selected.size+' 家店铺')};
$('refresh').onclick=()=>{render();$('loadStatus').textContent='已刷新 · '+new Date().toLocaleTimeString('zh-CN')+' · 本地演示';showToast('列表已刷新')};
$('designNotes').onclick=()=>{$('detailTitle').textContent='为桌面工作流而设计';$('detailBody').innerHTML='<div class="notice">品牌主色 #98638f · 梅紫</div><p>五个页面共享导航、筛选栏、表格和分页布局,降低切换页面的学习成本。</p><p>店铺名称、ID、归属、授权和登录状态分别列示,常用操作直接放在工具栏与固定操作列;编辑使用居中对话框。</p><p>默认紧凑行高 28px,舒适行高 36px;默认每页 50 条。表头与操作列固定,适合长列表与横向浏览。</p><p>后续 WPF 可对应 Grid、DataGrid、ResourceDictionary 和 Dialog。此 HTML 仅演示交互,不包含权限校验、真实登录、支付续期或 API 接入。</p>';$('details').showModal()};
switchPage('stores');
console.assert(escapeHtml('<script>')==='&lt;script&gt;','HTML escaping');
console.assert(filtered().length===72,'Initial store count');
query='不存在的店铺';console.assert(filtered().length===0,'Empty search');query='';
status='已过期';console.assert(filtered().every(r=>r.status==='已过期'),'Status filter');status='';
console.assert(new Set(stores.map(r=>r.id)).size===stores.length,'Unique record IDs');
</script>
</body>
</html>
@@ -7,6 +7,7 @@ namespace Store.Management.Api.Authentication;
public sealed class LoginRequest public sealed class LoginRequest
{ {
[Required] public string UserName { get; set; } = ""; [Required] public string UserName { get; set; } = "";
/// <summary>服务端要求的 UTF-8 密码的 32 位大写 MD5,由调用方计算。</summary>
[Required] public string Password { get; set; } = ""; [Required] public string Password { get; set; } = "";
[StringLength(100)] public string? DeviceId { get; set; } [StringLength(100)] public string? DeviceId { get; set; }
[StringLength(100)] public string? DeviceName { get; set; } [StringLength(100)] public string? DeviceName { get; set; }
@@ -39,27 +40,38 @@ internal sealed class AuthApi(ApiTransport transport, TokenSession session) : IA
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
var result = await transport.GetRequiredAsync<LoginResult>("Auth/Login", Method.Post, request, LoginResult result = await transport.GetRequiredAsync<LoginResult>("Auth/Login", Method.Post, request,
authenticated: false, cancellationToken: cancellationToken).ConfigureAwait(false); authenticated: false, cancellationToken: cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(result.Token) || result.Token.Contains('\r') || result.Token.Contains('\n') || if (string.IsNullOrWhiteSpace(result.Token) || result.Token.Contains('\r') || result.Token.Contains('\n') ||
!string.Equals(result.TokenType, "Bearer", StringComparison.OrdinalIgnoreCase)) !string.Equals(result.TokenType, "Bearer", StringComparison.OrdinalIgnoreCase))
{
throw new ApiException(ApiErrorKind.InvalidResponse, "登录响应缺少有效的 Bearer Token。"); throw new ApiException(ApiErrorKind.InvalidResponse, "登录响应缺少有效的 Bearer Token。");
}
session.Set(result.Token); session.Set(result.Token);
return result; return result;
} }
finally { _gate.Release(); } finally
{
_gate.Release();
}
} }
public async Task LogoutAsync(CancellationToken cancellationToken = default) public async Task LogoutAsync(CancellationToken cancellationToken = default)
{ {
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
var token = session.Token; string? token = session.Token;
try try
{ {
if (token is not null) if (token is not null)
{
await transport.SendAsync<object>("Auth/Logout", Method.Post, cancellationToken: cancellationToken).ConfigureAwait(false); await transport.SendAsync<object>("Auth/Logout", Method.Post, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
finally
{
session.ClearIfCurrent(token);
_gate.Release();
} }
finally { session.ClearIfCurrent(token); _gate.Release(); }
} }
public Task<CurrentUser> MeAsync(CancellationToken cancellationToken = default) public Task<CurrentUser> MeAsync(CancellationToken cancellationToken = default)
+6 -6
View File
@@ -1,6 +1,6 @@
# Store Management API 客户端 # Store Management API 客户端
独立的 net10.0 类库,使用 RestSharp 112.1.0 连接 StoreWeb。WPF 项目已引用该类库。每个服务地址及用户会话创建一个 StoreApiClient,建议在应用容器注册为单例,退出应用时释放。 独立的 API 类库(当前继承仓库的 net8.0-windows 目标框架),使用 RestSharp 112.1.0 连接 StoreWeb。WPF 项目已引用该类库。每个服务地址及用户会话创建一个 StoreApiClient,建议在应用容器注册为单例,退出应用时释放。
## 调用 ## 调用
@@ -21,7 +21,7 @@ try
await api.Auth.LoginAsync(new LoginRequest await api.Auth.LoginAsync(new LoginRequest
{ {
UserName = userName, UserName = userName,
Password = password, Password = passwordMd5, // UTF-8 密码的 32 位大写 MD5,保留原密码首尾空格
DeviceId = deviceId, // 可选;稳定设备标识由宿主应用管理 DeviceId = deviceId, // 可选;稳定设备标识由宿主应用管理
DeviceName = Environment.MachineName DeviceName = Environment.MachineName
}); });
@@ -44,7 +44,7 @@ catch (ApiException ex)
} }
``` ```
示例中的账号、密码、设备标识来自调用方输入。异步方法均支持 CancellationToken;主动取消抛出 OperationCanceledException,本地输入校验抛出 ValidationException。 示例中的账号、密码摘要、设备标识由调用方提供;登录窗口已负责计算密码摘要,API 层不重复计算。异步方法均支持 CancellationToken;主动取消抛出 OperationCanceledException,本地输入校验抛出 ValidationException。
## 文件与职责 ## 文件与职责
@@ -58,13 +58,13 @@ catch (ApiException ex)
## 协议与扩展 ## 协议与扩展
对齐 StoreWeb 的 AuthController、TokenResult、UnifyResultFilter 和用户管理控制器。成功码为 200;HTTP 200 内的业务失败同样抛出 ApiException。登录读取 data.token,认证采用 Bearer opaque Token,不解析 JWT,不根据首次登录的有效期自行判定续期。HTTP 或业务 401 清除当前请求所用的会话;旧请求的 401 不清除新 Token。 对齐 StoreWeb 的 AuthController、TokenResult、UnifyResultFilter 和用户管理控制器。成功码为 200;HTTP 200 内的业务失败同样抛出 ApiException。先读取响应状态与业务码,再解析成功 data,避免错误响应的数据结构掩盖业务错误。登录读取 data.token,认证采用 Bearer opaque Token,不解析 JWT,不根据首次登录的有效期自行判定续期。HTTP 或业务 401 清除当前请求所用的会话;旧请求的 401 不清除新 Token。
后续商品模块按 Users 的方式新增独立 DTO、接口和实现,由 StoreApiClient 暴露模块入口。模块通过 Transport.GetRequiredAsync<T> 发送有数据请求,通过 SendAsync<object> 发送无数据操作。路由采用 user/GetAllUser 这样的相对路径,传输层自动添加版本。简单参数使用 query,复杂参数使用 body,HTTP 方法与服务端契约一致。避免按常规 REST 习惯猜测当前服务端的动作路由。 后续商品模块按 Users 的方式新增独立 DTO、接口和实现,由 StoreApiClient 暴露模块入口。模块通过 Transport.GetRequiredAsync<T> 发送有数据请求,通过 SendAsync<object> 发送无数据操作。路由采用 user/GetAllUser 这样的相对路径,传输层自动添加版本。简单参数使用 query,复杂参数使用 body,HTTP 方法与服务端契约一致。避免按常规 REST 习惯猜测当前服务端的动作路由。
请求不自动重试,避免添加、修改操作重复执行。禁止自动重定向;服务地址应直接指向最终 API 地址。退出时即使服务端请求失败也清理本地 Token,并将错误交给宿主;此时服务端会话可能仍有效。Session.Clear 可用于本地退出,不撤销服务器会话。 请求不自动重试,避免添加、修改操作重复执行。禁止自动重定向;服务地址应直接指向最终 API 地址。退出时即使服务端请求失败也清理本地 Token,并将错误交给宿主;此时服务端会话可能仍有效。Session.Clear 可用于本地退出,不撤销服务器会话。
当前不包含登录界面、Token 磁盘持久化、自动刷新或商品接口。实际地址由宿主提供,尚未进行真实服务联调。 登录界面由宿主项目提供;API 层不包含 Token 磁盘持久化、自动刷新或商品接口。实际地址由宿主提供,尚未进行真实服务联调。
## 验证 ## 验证
@@ -75,4 +75,4 @@ dotnet build Store.Management.slnx
dotnet run --project Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj dotnet run --project Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj
``` ```
模拟 HTTP 测试覆盖登录路由、Bearer、分页序列化与解析、输入边界、业务错误、无效 JSON、403、HTTP 与业务 401、退出及主动取消,不会调用真实服务。 模拟 HTTP 测试覆盖登录路由、Bearer、分页序列化与解析、输入边界、业务错误、无效 JSON、403、HTTP 与业务 401、无效 Token/data、旧请求 401 会话隔离、登录退出串行、退出失败清理及主动取消,不会调用真实服务。
@@ -20,7 +20,7 @@ public sealed class ApiTransport : IDisposable
options.Validate(); options.Validate();
_session = session; _session = session;
_version = options.Version; _version = options.Version;
var settings = new RestClientOptions(new Uri(options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/")) RestClientOptions settings = new RestClientOptions(new Uri(options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/"))
{ {
Timeout = options.Timeout, Timeout = options.Timeout,
FollowRedirects = false FollowRedirects = false
@@ -32,30 +32,62 @@ public sealed class ApiTransport : IDisposable
IReadOnlyDictionary<string, string?>? query = null, bool authenticated = true, IReadOnlyDictionary<string, string?>? query = null, bool authenticated = true,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(route) || route.StartsWith('/') || route.Contains("..") || if (string.IsNullOrWhiteSpace(route) || route.StartsWith('/') || route.Contains("..") ||
route.Contains(':') || route.Contains('?') || route.Contains('#') || route.Contains('\\')) route.Contains(':') || route.Contains('?') || route.Contains('#') || route.Contains('\\'))
{
throw new ArgumentException("接口必须使用相对路由,例如 user/GetAllUser。", nameof(route)); throw new ArgumentException("接口必须使用相对路由,例如 user/GetAllUser。", nameof(route));
if (body is not null) Validator.ValidateObject(body, new ValidationContext(body), true); }
cancellationToken.ThrowIfCancellationRequested(); if (body is not null)
var token = authenticated ? _session.Token : null; {
if (authenticated && token is null) throw new ApiException(ApiErrorKind.Unauthorized, "请先登录。", code: 401); Validator.ValidateObject(body, new ValidationContext(body), true);
var request = new RestRequest($"{_version}/{route}", method); }
if (token is not null) request.AddHeader("Authorization", $"Bearer {token}"); string? token = authenticated ? _session.Token : null;
if (body is not null) request.AddJsonBody(body); if (authenticated && token is null)
{
throw new ApiException(ApiErrorKind.Unauthorized, "请先登录。", code: 401);
}
RestRequest request = new RestRequest($"{_version}/{route}", method);
if (token is not null)
{
request.AddHeader("Authorization", $"Bearer {token}");
}
if (body is not null)
{
request.AddJsonBody(body);
}
if (query is not null) if (query is not null)
foreach (var pair in query) {
if (pair.Value is not null) request.AddQueryParameter(pair.Key, pair.Value); foreach (KeyValuePair<string, string?> pair in query)
var response = await _client.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); {
if (pair.Value is not null)
{
request.AddQueryParameter(pair.Key, pair.Value);
}
}
}
RestResponse response = await _client.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (response.ResponseStatus == ResponseStatus.TimedOut) if (response.ResponseStatus == ResponseStatus.TimedOut)
{
throw new ApiException(ApiErrorKind.Timeout, "请求超时,请稍后重试。", innerException: response.ErrorException); throw new ApiException(ApiErrorKind.Timeout, "请求超时,请稍后重试。", innerException: response.ErrorException);
}
if ((int)response.StatusCode == 0) if ((int)response.StatusCode == 0)
{
throw new ApiException(ApiErrorKind.Network, "无法连接服务器,请检查网络与服务地址。", innerException: response.ErrorException); throw new ApiException(ApiErrorKind.Network, "无法连接服务器,请检查网络与服务地址。", innerException: response.ErrorException);
}
ApiResult<T>? result = null; // 先判断状态,再解析 data;错误响应的数据结构可能与成功响应不同。
ApiResult<JsonElement>? result = null;
JsonException? parseError = null; JsonException? parseError = null;
try { result = JsonSerializer.Deserialize<ApiResult<T>>(response.Content ?? "", Json); } try
catch (JsonException ex) { parseError = ex; } {
result = JsonSerializer.Deserialize<ApiResult<JsonElement>>(response.Content ?? "", Json);
}
catch (JsonException ex)
{
parseError = ex;
}
if (response.StatusCode == HttpStatusCode.Unauthorized || result?.Code == 401) if (response.StatusCode == HttpStatusCode.Unauthorized || result?.Code == 401)
{ {
_session.ClearIfCurrent(token); _session.ClearIfCurrent(token);
@@ -63,13 +95,31 @@ public sealed class ApiTransport : IDisposable
response.StatusCode, 401, result?.TraceId); response.StatusCode, 401, result?.TraceId);
} }
if (!response.IsSuccessful) if (!response.IsSuccessful)
{
throw new ApiException(ApiErrorKind.Http, result?.Msg ?? $"服务器请求失败({(int)response.StatusCode})。", throw new ApiException(ApiErrorKind.Http, result?.Msg ?? $"服务器请求失败({(int)response.StatusCode})。",
response.StatusCode, result?.Code, result?.TraceId); response.StatusCode, result?.Code, result?.TraceId);
}
if (result is null) if (result is null)
{
throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应格式无效。", response.StatusCode, innerException: parseError); throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应格式无效。", response.StatusCode, innerException: parseError);
}
if (result.Code != 200) if (result.Code != 200)
{
throw new ApiException(ApiErrorKind.Business, result.Msg ?? "操作失败。", response.StatusCode, result.Code, result.TraceId); throw new ApiException(ApiErrorKind.Business, result.Msg ?? "操作失败。", response.StatusCode, result.Code, result.TraceId);
return result.Data; }
if (result.Data.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return default;
}
try
{
return result.Data.Deserialize<T>(Json);
}
catch (JsonException ex)
{
throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应 data 格式无效。",
response.StatusCode, result.Code, result.TraceId, ex);
}
} }
public async Task<T> GetRequiredAsync<T>(string route, Method method, object? body = null, public async Task<T> GetRequiredAsync<T>(string route, Method method, object? body = null,
@@ -0,0 +1,289 @@
using MAX.Core.Commons;
using Prism.Commands;
using Prism.Mvvm;
using Prism.Services.Dialogs;
using System;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
namespace Store.Management.Framework.Controls;
public static class IDialogServiceExtension
{
public static void ShowDialog<TParameter>(this IDialogService dialogService, string name, TParameter parameter, Action<IDialogResult> callback) where TParameter : class
{
dialogService.ShowDialog(name, new DialogParameters
{
{"parameter", parameter},
}, callback);
}
public static void Show<TParameter>(this IDialogService dialogService, string name, TParameter parameter, Action<IDialogResult> callback) where TParameter : class
{
dialogService.Show(name, new DialogParameters
{
{"parameter", parameter},
}, callback);
}
}
/// <summary>
/// 确定取消对话框 ViewModel
/// </summary>
public class ConfirmDialogViewModel : BindableBase, IDialogAware
{
public ConfirmDialogViewModel()
{
}
private string title = "";
private bool _enableConfirm = true;
private bool _ShowConfirm = true;
private bool _ShowCancel = true;
private bool _ShowErrorMsg = true;
private string _ErrorMsg = "";
private string confirmText = "确定";
private string cancelText = "取消";
private string backText = "返回";
private bool allowEscape = true;
private bool allowEnter = true;
private bool isLoading = false;
public string Title
{
get => title;
protected set => SetProperty(ref title, value);
}
public bool AllowEscape
{
get => allowEscape;
set => SetProperty(ref allowEscape, value);
}
public bool AllowEnter
{
get => allowEnter;
set => SetProperty(ref allowEnter, value);
}
public bool IsLoading
{
get => isLoading;
set => SetProperty(ref isLoading, value, () =>
{
AllowEscape = !value;
AllowEnter = !value;
EnableConfirm = !value;
});
}
//<UserControl.Resources>
// <SolidColorBrush x:Key="RegionBrush" Color="Red"/>
// </UserControl.Resources>
private SolidColorBrush _Underpaint = new SolidColorBrush(Color.FromRgb(238, 238, 238));
/// <summary>
/// 背景底色
/// </summary>
public SolidColorBrush Underpaint
{
get => _Underpaint;
set => SetProperty(ref _Underpaint, value);
}
public event Action<IDialogResult> RequestClose;
protected void OnRequestClose(IDialogResult result)
{
RequestClose?.Invoke(result);
}
public virtual bool CanCloseDialog()
{
return true;
}
public virtual void OnDialogClosed()
{
}
public virtual void OnDialogOpened(IDialogParameters parameters)
{
}
public bool EnableConfirm
{
get => _enableConfirm;
set => SetProperty(ref _enableConfirm, value);
}
public bool ShowConfirm
{
get => _ShowConfirm;
set => SetProperty(ref _ShowConfirm, value);
}
public bool ShowCancel
{
get => _ShowCancel;
set => SetProperty(ref _ShowCancel, value);
}
public bool ShowErrorMsg
{
get => _ShowErrorMsg;
set => SetProperty(ref _ShowErrorMsg, value);
}
public string ConfirmText
{
get => confirmText;
set => SetProperty(ref confirmText, value);
}
public string CancelText
{
get => cancelText;
set => SetProperty(ref cancelText, value);
}
public string BackText
{
get => backText;
set => SetProperty(ref backText, value);
}
/// <summary>
/// 错误提示
/// </summary>
public string ErrorMsg
{
get => _ErrorMsg;
set => SetProperty(ref _ErrorMsg, value);
}
public virtual ICommand ConfirmCommand => new DelegateCommand(_Confirm);
protected virtual void _Confirm()
{
DialogResult ret = Confirm();
if (ret != null)
{
RequestClose?.Invoke(ret);
}
}
protected virtual DialogResult Confirm()
{
return new DialogResult(ButtonResult.OK);
}
public virtual ICommand CancelCommand => new DelegateCommand(_Cancel);
protected virtual void _Cancel()
{
DialogResult ret = Cancel();
if (ret != null)
{
RequestClose?.Invoke(ret);
}
else
{
RequestClose?.Invoke(new DialogResult(ButtonResult.Cancel));
}
}
protected virtual DialogResult Cancel()
{
return new DialogResult(ButtonResult.Cancel);
}
public virtual void Loaded(object content)
{
var bb = Application.Current.TryFindResource("BackgroundBrush") as SolidColorBrush;
if (bb != null)
{
Underpaint = bb;
}
}
public class AsyncConfirmDialogViewModel : ConfirmDialogViewModel
{
public override ICommand ConfirmCommand => new AsyncDelegateCommand(_Confirm);
protected new async Task _Confirm()
{
DialogResult ret = await Confirm();
if (ret != null)
{
OnRequestClose(ret);
}
}
protected new virtual async Task<DialogResult> Confirm()
{
return await Task.FromResult(new DialogResult(ButtonResult.OK));
}
}
public interface IDialogClassParameters
{
}
}
/// 确定取消对话框
/// </summary>
/// <typeparam name="TParameter">参数Model</typeparam>
public class ConfirmDialogViewModel<TParameter> : ConfirmDialogViewModel where TParameter : class
{
protected TParameter Parameter { get; set; }
public override void OnDialogOpened(IDialogParameters parameters)
{
base.OnDialogOpened(parameters);
if (parameters.TryGetValue("parameter", out TParameter parameter))
{
Parameter = parameter;
}
}
}
public abstract class ConfirmDialogViewModel<TParameter, TResult> : ConfirmDialogViewModel where TParameter : class where TResult : DialogResult/*, new()*/
{
protected TParameter Parameter { get; set; }
public override void OnDialogOpened(IDialogParameters parameters)
{
base.OnDialogOpened(parameters);
if (parameters.TryGetValue("parameter", out TParameter parameter))
{
Parameter = parameter;
}
}
protected override void _Confirm()
{
TResult ret = Confirm();
if (ret != null)
{
base.OnRequestClose(ret);
}
}
protected new abstract TResult Confirm();
protected override void _Cancel()
{
TResult ret = Cancel();
if (ret != null)
{
base.OnRequestClose(ret);
}
else
{
base.OnRequestClose(new DialogResult(ButtonResult.Cancel));
}
}
protected new abstract TResult Cancel();
}
//public abstract class ConfirmDialogParameterViewModel : ConfirmDialogViewModel<ConfirmDialogParameterViewModel.Parameter>
//{
// public abstract class Parameter;
//}
+51 -40
View File
@@ -1,13 +1,14 @@
using Prism.Ioc;
using Prism.Services.Dialogs;
using MAX.Notification; using MAX.Notification;
using Store.Management.Notifications; using Prism.Ioc;
using Prism.Mvvm;
using Prism.Services.Dialogs;
using Store.Management.Api;
using Store.Management.Framework; using Store.Management.Framework;
using Store.Management.Notifications;
using Store.Management.Services; using Store.Management.Services;
using Store.Management.ViewModels; using Store.Management.ViewModels;
using Store.Management.Views; using Store.Management.Views;
using System.Windows; using System.Windows;
using Store.Management.Api;
namespace Store.Management; namespace Store.Management;
public partial class App : FrameworkApplication public partial class App : FrameworkApplication
{ {
@@ -22,51 +23,61 @@ public partial class App : FrameworkApplication
BaseUrl = new Uri("http://localhost:14312/api/") BaseUrl = new Uri("http://localhost:14312/api/")
}); });
protected override Window CreateShell() => null!; protected override Window CreateShell()
{
return Container.Resolve<MainWindow>();
}
protected override void OnInitialized() protected override void OnInitialized()
{ {
base.OnInitialized(); base.OnInitialized();
// 登录弹窗关闭与主窗口创建之间不触发 WPF 的最后窗口退出。 //base.OnInitialized();
ShutdownMode = ShutdownMode.OnExplicitShutdown; //// 登录弹窗关闭与主窗口创建之间不触发 WPF 的最后窗口退出。
Container.Resolve<IDialogService>().ShowDialog( //ShutdownMode = ShutdownMode.OnExplicitShutdown;
nameof(LoginWindow), new DialogParameters(), result => //Container.Resolve<IDialogService>().ShowDialog(
{ // nameof(LoginWindow), new DialogParameters(), result =>
if (result.Result != ButtonResult.OK) // {
{ // if (result.Result != ButtonResult.OK)
Shutdown(); // {
return; // Shutdown();
} // return;
// }
MainWindow = Container.Resolve<MainWindow>(); // MainWindow = Container.Resolve<MainWindow>();
ShutdownMode = ShutdownMode.OnMainWindowClose; // ShutdownMode = ShutdownMode.OnMainWindowClose;
MainWindow.Show(); // MainWindow.Show();
}); // });
} }
protected override void RegisterTypes(IContainerRegistry registry) protected override void RegisterTypes(IContainerRegistry containerRegistry)
{ {
registry.RegisterInstance(CreateApiClient());
registry.Register<LoginViewModel>(provider => new LoginViewModel(provider.Resolve<StoreApiClient>().Auth)); containerRegistry.RegisterDialogWindow<Framework.Controls.DialogWindow>();
registry.RegisterSingleton<SingletonProbe>();
registry.Register<TransientProbe>(); ViewModelLocationProvider.Register<MainWindow, MainWindowViewModel>();
registry.RegisterSingleton<NotificationExampleState>();
registry.Register<IPublisher>(provider => new NotificationPublisher(
new PrismNotificationServiceProvider(provider))); //registry.RegisterInstance(CreateApiClient());
// 显式提供处理器集合,展示一条通知分发给多个业务模块。 // registry.Register<LoginViewModel>(provider => new LoginViewModel(provider.Resolve<StoreApiClient>().Auth));
registry.Register<IEnumerable<INotificationHandler<ProductSavedNotification>>>(provider => // registry.RegisterSingleton<SingletonProbe>();
new INotificationHandler<ProductSavedNotification>[] // registry.Register<TransientProbe>();
{ // registry.RegisterSingleton<NotificationExampleState>();
new ProductCacheHandler(provider.Resolve<NotificationExampleState>()), // registry.Register<IPublisher>(provider => new NotificationPublisher(
new ProductAuditHandler(provider.Resolve<NotificationExampleState>()) // new PrismNotificationServiceProvider(provider)));
}); // // 显式提供处理器集合,展示一条通知分发给多个业务模块。
registry.RegisterDialog<LoginWindow, LoginViewModel>(); // registry.Register<IEnumerable<INotificationHandler<ProductSavedNotification>>>(provider =>
registry.RegisterDialog<FrameworkTestDialog, FrameworkTestDialogViewModel>(); // new INotificationHandler<ProductSavedNotification>[]
// {
// new ProductCacheHandler(provider.Resolve<NotificationExampleState>()),
// new ProductAuditHandler(provider.Resolve<NotificationExampleState>())
// });
// registry.RegisterDialog<LoginWindow, LoginViewModel>();
// registry.RegisterDialog<FrameworkTestDialog, FrameworkTestDialogViewModel>();
} }
protected override void OnExit(ExitEventArgs e) protected override void OnExit(ExitEventArgs e)
{ {
try { Container?.Resolve<StoreApiClient>().Dispose(); } //try { Container?.Resolve<StoreApiClient>().Dispose(); }
finally { base.OnExit(e); } //finally { base.OnExit(e); }
} }
} }
+4 -3
View File
@@ -1,13 +1,14 @@
<Window <f:WindowBase
x:Class="Store.Management.MainWindow" x:Class="Store.Management.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:f="Store.Management.Framework"
xmlns:views="clr-namespace:Store.Management.Views" xmlns:views="clr-namespace:Store.Management.Views"
Title="Store.Management · 框架能力测试" Title="{Binding Title}"
Width="960" Width="960"
Height="720" Height="720"
MinWidth="720" MinWidth="720"
MinHeight="560" MinHeight="560"
WindowStartupLocation="CenterScreen"> WindowStartupLocation="CenterScreen">
<views:FrameworkTestPage /> <views:FrameworkTestPage />
</Window> </f:WindowBase>
+15 -2
View File
@@ -1,6 +1,19 @@
using Prism.Mvvm;
using Store.Management.Framework.Controls;
using System.Windows; using System.Windows;
namespace Store.Management; namespace Store.Management;
public partial class MainWindow : Window
public partial class MainWindow : WindowBase
{ {
public MainWindow() => InitializeComponent(); public MainWindow() => InitializeComponent();
} }
public class MainWindowViewModel : BindableBase
{
private string _title="TTTT";
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
}
@@ -2,7 +2,6 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
</PropertyGroup> </PropertyGroup>
@@ -59,7 +59,93 @@ using var cancelled = new CancellationTokenSource();
cancelled.Cancel(); cancelled.Cancel();
try { await api.Auth.LoginAsync(new LoginRequest(), cancelled.Token); throw new Exception("取消未生效"); } try { await api.Auth.LoginAsync(new LoginRequest(), cancelled.Token); throw new Exception("取消未生效"); }
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
Console.WriteLine("PASS: 登录、路由、Bearer、分页、校验、业务错误、格式错误、403、HTTP/业务401、退出、取消。"); // 错误信封的 data 不必符合成功 DTO,仍须保留错误类型与追踪信息。
handler.Json = """{"code":200,"data":{"token":"session-four"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
handler.Json = """{"code":400,"msg":"账号错误","traceId":"error-data","data":[]} """;
try
{
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "wrong" });
throw new Exception("错误登录应失败");
}
catch (ApiException ex)
{
Check(ex.Kind == ApiErrorKind.Business && ex.Code == 400 && ex.TraceId == "error-data"
&& ex.Message == "账号错误", "错误 data 不应掩盖业务错误");
}
Check(api.Session.IsAuthenticated, "失败登录不应替换原会话");
handler.Json = """{"code":200,"data":{"token":"","tokenType":"Bearer"}}""";
await Error(async () => await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }), ApiErrorKind.InvalidResponse);
Check(api.Session.IsAuthenticated, "无效 Token 不应替换原会话");
handler.Json = """{"code":200,"traceId":"bad-data","data":[]} """;
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.InvalidResponse);
handler.Json = """{"code":200,"data":null}""";
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.InvalidResponse);
handler.Json = """{"code":401,"traceId":"expired","data":[]} """;
await Error(async () => await api.Auth.MeAsync(), ApiErrorKind.Unauthorized);
Check(!api.Session.IsAuthenticated, "data 类型不匹配时业务 401 仍应清理会话");
handler.Json = """{"code":200,"data":{"token":"old-token"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
TaskCompletionSource arrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
handler.BeforeResponse = async cancellationToken =>
{
arrived.SetResult();
await release.Task.WaitAsync(cancellationToken);
};
handler.Json = """{"code":401,"data":[]} """;
Task<CurrentUser> oldRequest = api.Auth.MeAsync();
await arrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
handler.BeforeResponse = null;
handler.Json = """{"code":200,"data":{"token":"new-token"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
release.SetResult();
await Error(async () => await oldRequest, ApiErrorKind.Unauthorized);
Check(api.Session.IsAuthenticated, "旧请求 401 不得清理新会话");
handler.Json = """{"code":200,"data":{"userName":"admin"}}""";
await api.Auth.MeAsync();
Check(handler.Authorization == "Bearer new-token", "重新登录应使用新 Token");
arrived = new(TaskCreationOptions.RunContinuationsAsynchronously);
release = new(TaskCreationOptions.RunContinuationsAsynchronously);
handler.BeforeResponse = async cancellationToken =>
{
arrived.SetResult();
await release.Task.WaitAsync(cancellationToken);
};
handler.Json = """{"code":200,"data":{"token":"last-token"}}""";
Task<LoginResult> pendingLogin = api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
await arrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
int beforeLogout = handler.Count;
Task pendingLogout = api.Auth.LogoutAsync();
Check(handler.Count == beforeLogout && !pendingLogout.IsCompleted, "退出必须等待登录完成");
handler.BeforeResponse = null;
handler.Json = """{"code":200,"data":null}""";
release.SetResult();
await Task.WhenAll(pendingLogin, pendingLogout).WaitAsync(TimeSpan.FromSeconds(5));
Check(!api.Session.IsAuthenticated && handler.Authorization == "Bearer last-token", "退出应撤销刚完成的登录");
handler.Json = """{"code":200,"data":{"token":"logout-token"}}""";
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" });
handler.Status = HttpStatusCode.InternalServerError;
handler.Json = "server-error";
await Error(() => api.Auth.LogoutAsync(), ApiErrorKind.Http);
Check(!api.Session.IsAuthenticated, "退出失败也必须清理本地会话");
handler.Status = HttpStatusCode.OK;
handler.BeforeResponse = cancellationToken => Task.Delay(Timeout.Infinite, cancellationToken);
using CancellationTokenSource inFlight = new(TimeSpan.FromMilliseconds(100));
try
{
await api.Auth.LoginAsync(new LoginRequest { UserName = "admin", Password = "secret" }, inFlight.Token);
throw new Exception("请求中取消未生效");
}
catch (OperationCanceledException)
{
Check(!api.Session.IsAuthenticated, "取消登录不得保存会话");
}
handler.BeforeResponse = null;
Console.WriteLine("PASS: 登录、路由、Bearer、分页、校验、错误分类、无效 Token/data、401 会话隔离、登录退出串行、退出失败清理、取消。");
sealed class StubHandler : HttpMessageHandler sealed class StubHandler : HttpMessageHandler
{ {
@@ -69,12 +155,19 @@ sealed class StubHandler : HttpMessageHandler
public string Path { get; private set; } = ""; public string Path { get; private set; } = "";
public string? Authorization { get; private set; } public string? Authorization { get; private set; }
public string? Body { get; private set; } public string? Body { get; private set; }
public Func<CancellationToken, Task>? BeforeResponse { get; set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
Count++; Count++;
Path = request.RequestUri!.AbsolutePath; Path = request.RequestUri!.AbsolutePath;
Authorization = request.Headers.Authorization?.ToString(); Authorization = request.Headers.Authorization?.ToString();
Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); Body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(Status) { Content = new StringContent(Json, Encoding.UTF8, "application/json") }; HttpStatusCode status = Status;
string json = Json;
if (BeforeResponse is not null)
{
await BeforeResponse(cancellationToken);
}
return new HttpResponseMessage(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
} }
} }
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup> <PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup>
<ItemGroup><ProjectReference Include="..\..\Source\Store.Management.Api\Store.Management.Api.csproj" /></ItemGroup> <ItemGroup><ProjectReference Include="..\..\Source\Store.Management.Api\Store.Management.Api.csproj" /></ItemGroup>
</Project> </Project>