一、基础设施
- route.php:58 条路由覆盖 /api/v1/**;:id 用 \d+ 约束、长路径优先
- api/controller/V1.php:响应适配为 {code,message,data}(FastAdmin 原生 {code,msg,time,data} 不兼容);
Authorization Bearer 鉴权;统一分页;业务公共 userInfo/ext/flow/notify/adminLog
- admin/command/FcInstall.php:19 张 fa_fc_* 表 + 种子数据,幂等可重跑
二、控制器 17 个
- 用户端 Auth/Home/Post/Member/Distribution/Message/Setting
- 后台端 AdminDashboard/AdminMerchant/AdminMember/AdminPost/AdminComment/
AdminDist/AdminOrder/AdminFinance/AdminRbac/AdminSetting
三、契约一致性
- 字段与小程序 common/mock/data.js 逐字段对齐,Mock 与真实接口可对拍
- 实现 BR-01~BR-12,错误文案与前端 mock 完全一致
- 详情联系方式强制脱敏,付费/会员方可解锁
四、验证
- tools/verify-api.mjs:63 端点契约 + 12 条业务规则 + 8 步双端闭环
- docs/05-后端实现规范.md
463 行
28 KiB
JavaScript
463 行
28 KiB
JavaScript
/**
|
||
* 同城分类信息 · 后端 v1 接口端到端验证脚本
|
||
*
|
||
* 用法:
|
||
* node tools/verify-api.mjs # 验证线上
|
||
* FC_BASE=http://127.0.0.1/api/v1 node tools/verify-api.mjs
|
||
*
|
||
* 覆盖:
|
||
* 1) 62 个端点契约校验(HTTP 状态 / {code,message,data} 结构 / 关键字段 / 分页结构)
|
||
* 2) 业务规则校验(12 条 BR 的错误码与文案)
|
||
* 3) 双端业务闭环:用户发布 → 后台待审可见 → 后台审核通过 → 前台信息流可见 → 付费解锁联系方式
|
||
*/
|
||
|
||
const BASE = process.env.FC_BASE || 'https://fenleixinxi.gzyakj.com.cn/api/v1'
|
||
const PASS = []
|
||
const FAIL = []
|
||
|
||
let token = ''
|
||
const T0 = Date.now()
|
||
|
||
/* ---------------- 基础工具 ---------------- */
|
||
|
||
async function call(method, path, body = null, useToken = false) {
|
||
const url = BASE + path
|
||
const headers = { 'Content-Type': 'application/json;charset=UTF-8', 'X-Client': 'fenleixinxi' }
|
||
if (useToken && token) headers.Authorization = 'Bearer ' + token
|
||
const opts = { method, headers }
|
||
if (body && method !== 'GET') opts.body = JSON.stringify(body)
|
||
let status = 0
|
||
let json = null
|
||
let raw = ''
|
||
try {
|
||
const res = await fetch(url, opts)
|
||
status = res.status
|
||
raw = await res.text()
|
||
try { json = JSON.parse(raw) } catch (e) { json = null }
|
||
} catch (e) {
|
||
return { status: -1, json: null, raw: String(e.message || e) }
|
||
}
|
||
return { status, json, raw }
|
||
}
|
||
|
||
/** 校验一项并记录 */
|
||
function check(name, cond, detail = '') {
|
||
if (cond) { PASS.push(name) } else { FAIL.push(name + (detail ? ' ← ' + detail : '')) }
|
||
}
|
||
|
||
/** 端点契约:期望成功 */
|
||
async function ok200(name, method, path, body, useToken, assertFn) {
|
||
const r = await call(method, path, body, useToken)
|
||
const j = r.json
|
||
let problem = ''
|
||
if (r.status !== 200) problem = 'HTTP ' + r.status
|
||
else if (!j || typeof j !== 'object') problem = '返回非 JSON: ' + String(r.raw).slice(0, 120)
|
||
else if (j.code !== 0) problem = 'code=' + j.code + ' message=' + j.message
|
||
else if (!('data' in j)) problem = '缺少 data 字段'
|
||
else if (!('message' in j)) problem = '缺少 message 字段'
|
||
check(name, problem === '', problem)
|
||
if (problem === '' && typeof assertFn === 'function') {
|
||
let extra = ''
|
||
try { extra = assertFn(j.data) || '' } catch (e) { extra = e.message }
|
||
check(name + ' · 字段', extra === '', extra)
|
||
}
|
||
return j ? j.data : null
|
||
}
|
||
|
||
/** 端点契约:期望业务失败(HTTP 200 + 指定文案) */
|
||
async function expectFail(name, method, path, body, useToken, msgPart) {
|
||
const r = await call(method, path, body, useToken)
|
||
const j = r.json
|
||
let problem = ''
|
||
if (r.status !== 200) problem = 'HTTP ' + r.status
|
||
else if (!j) problem = '返回非 JSON'
|
||
else if (j.code === 0) problem = '期望失败但返回成功'
|
||
else if (msgPart && String(j.message).indexOf(msgPart) === -1) problem = '文案不符: ' + j.message
|
||
check(name, problem === '', problem)
|
||
}
|
||
|
||
/** 期望 401 */
|
||
async function expect401(name, method, path, body) {
|
||
const r = await call(method, path, body, false)
|
||
check(name, r.status === 401, 'HTTP ' + r.status)
|
||
}
|
||
|
||
/** 断言是否为分页结构 */
|
||
function isPager(d) {
|
||
return d && typeof d === 'object' && Array.isArray(d.list)
|
||
&& typeof d.page === 'number' && typeof d.size === 'number'
|
||
&& typeof d.total === 'number' && typeof d.hasMore === 'boolean'
|
||
}
|
||
|
||
/* ---------------- 一、认证与登录(AUTH 4) ---------------- */
|
||
|
||
async function userAuth() {
|
||
const login = await call('POST', '/auth/wx-login', { code: 'openid_1001' })
|
||
if (login.json && login.json.code === 0 && login.json.data && login.json.data.token) {
|
||
token = login.json.data.token
|
||
check('A1 POST /auth/wx-login', true)
|
||
const ui = login.json.data.userInfo || {}
|
||
check('A1 · userInfo 字段',
|
||
['id', 'nickname', 'phone', 'userType', 'isMember', 'balance', 'realnameStatus'].every((k) => k in ui),
|
||
'实际: ' + Object.keys(ui).join(','))
|
||
} else {
|
||
check('A1 POST /auth/wx-login', false, login.status + ' ' + String(login.raw).slice(0, 160))
|
||
}
|
||
await expectFail('A1 · code 缺失校验', 'POST', '/auth/wx-login', {}, false, '缺少微信授权 code')
|
||
|
||
await ok200('A2 POST /auth/bind-phone', 'POST', '/auth/bind-phone', { phone: '13800001111' }, true,
|
||
(d) => (d && d.phone === '13800001111' ? '' : 'phone 不符'))
|
||
await expectFail('A2 · 手机号格式 (BR-02)', 'POST', '/auth/bind-phone', { phone: '123' }, true, '手机号格式不正确')
|
||
|
||
await ok200('A3 POST /auth/switch-type', 'POST', '/auth/switch-type', { userType: 1 }, true,
|
||
(d) => (d && d.userType === 1 && d.needAudit === false ? '' : 'userType/needAudit 不符'))
|
||
await expectFail('A3 · 商家缺执照 (BR-01)', 'POST', '/auth/switch-type', { userType: 2 }, true,
|
||
'商家用户需上传营业执照完成资质认证')
|
||
await expectFail('A3 · 非法类型', 'POST', '/auth/switch-type', { userType: 9 }, true, '用户类型不合法')
|
||
|
||
await ok200('A4 GET /auth/profile', 'GET', '/auth/profile', null, true,
|
||
(d) => (d && typeof d.balance === 'number' && 'isMember' in d ? '' : '缺 balance/isMember'))
|
||
await expect401('A4 · 未登录应 401', 'GET', '/auth/profile', null)
|
||
}
|
||
|
||
/* ---------------- 二、首页与展示(HOME 4) ---------------- */
|
||
|
||
async function userHome() {
|
||
await ok200('H2 GET /home/banners', 'GET', '/home/banners', null, false,
|
||
(d) => (Array.isArray(d) && d.length > 0 && 'title' in d[0] && 'link' in d[0] ? '' : '非数组或缺字段'))
|
||
await ok200('H1 GET /home/categories', 'GET', '/home/categories', null, false,
|
||
(d) => (Array.isArray(d) && d.length === 8 && 'iconKey' in d[0] && 'count' in d[0] && d[0].id === 1
|
||
? '' : '应 8 项且含 iconKey/count,实际 ' + (Array.isArray(d) ? d.length + ' 项' : typeof d)))
|
||
await ok200('H4 GET /home/search/hot', 'GET', '/home/search/hot', null, false,
|
||
(d) => (Array.isArray(d) && typeof d[0] === 'string' ? '' : '应为字符串数组'))
|
||
await ok200('H3 GET /home/feed', 'GET', '/home/feed?page=1&size=10', null, false,
|
||
(d) => (!isPager(d) ? '分页结构不符'
|
||
: (d.list[0] && 'categoryId' in d.list[0] && 'createdAt' in d.list[0] && Array.isArray(d.list[0].tags)
|
||
? '' : '列表项缺 categoryId/createdAt/tags')))
|
||
await ok200('H5 GET /home/feed 筛选', 'GET', '/home/feed?sort=hot&area=云岩区', null, false,
|
||
(d) => (isPager(d) && d.list.length > 0 && d.list.every((p) => p.area === '云岩区') ? '' : '区域筛选未生效'))
|
||
}
|
||
|
||
/* ---------------- 三、信息发布与详情(POST/DETAIL 7,含闭环起点) ---------------- */
|
||
|
||
let newPostId = 0
|
||
|
||
async function userPost() {
|
||
const created = await ok200('P1 POST /post/create', 'POST', '/post/create',
|
||
{ categoryId: 3, title: '接口验证·九成新显示器转让', content: '接口端到端验证用数据,可忽略。',
|
||
contact: '验证账号', phone: '13800001111', area: '南明区', address: '花果园',
|
||
price: 300, priceUnit: '元', tags: '验证,自提' },
|
||
true, (d) => (d && d.postId && Number(d.auditStatus) === 0 ? '' : 'postId/auditStatus 不符(BR-04 应为 0)'))
|
||
newPostId = created && created.postId ? Number(created.postId) : 0
|
||
|
||
await expectFail('P1 · 标题必填 (BR-03)', 'POST', '/post/create', { content: 'x' }, true, '标题不能为空')
|
||
await expectFail('P1 · 内容必填 (BR-03)', 'POST', '/post/create', { title: 'x' }, true, '内容不能为空')
|
||
|
||
await ok200('P4 POST /post/draft', 'POST', '/post/draft',
|
||
{ title: '草稿·验证', content: '草稿内容', categoryId: 2 }, true,
|
||
(d) => (d && d.draftId && d.savedAt ? '' : '缺 draftId/savedAt'))
|
||
await ok200('P4 GET /post/drafts', 'GET', '/post/drafts', null, true,
|
||
(d) => (Array.isArray(d) && 'savedAt' in (d[0] || {}) ? '' : '草稿项缺 savedAt'))
|
||
|
||
const detail = await ok200('D1 GET /post/{id}', 'GET', '/post/101', null, false,
|
||
(d) => (d && Number(d.id) === 101 && typeof d.contact === 'string'
|
||
&& String(d.contact).indexOf('****') > -1 && d.publisher && 'name' in d.publisher
|
||
? '' : 'contact 必须为脱敏字符串且含 publisher'))
|
||
check('D1 · 详情不泄露完整手机号 (BR-05)',
|
||
detail && String(detail.contact).indexOf('888888') === -1, '疑似泄露: ' + (detail && detail.contact))
|
||
await expectFail('D1 · 不存在的信息', 'GET', '/post/99999999', null, false, '信息不存在')
|
||
|
||
await ok200('D4 POST /post/{id}/pay-view', 'POST', '/post/101/pay-view', { payType: 'wxpay' }, true,
|
||
(d) => (d && d.unlocked === true && d.contact && typeof d.contact.phone === 'string'
|
||
&& d.contact.phone.length === 11 ? '' : 'unlocked/contact.phone 不符'))
|
||
await expectFail('D4 · 支付方式 (BR-05)', 'POST', '/post/101/pay-view', { payType: 'alipay' }, true, '支付方式不支持')
|
||
|
||
await ok200('D5 POST /post/{id}/comment', 'POST', '/post/101/comment', { score: 5, content: '接口验证评价' }, true,
|
||
(d) => (d && d.commentId ? '' : '缺 commentId'))
|
||
await expectFail('D5 · 评分区间 (BR-06)', 'POST', '/post/101/comment', { score: 9, content: 'x' }, true, '评分需在 1-5 之间')
|
||
await expectFail('D5 · 内容必填 (BR-06)', 'POST', '/post/101/comment', { score: 5, content: '' }, true, '评价内容不能为空')
|
||
}
|
||
|
||
/* ---------------- 四、会员 / 分销 / 消息 / UI(12) ---------------- */
|
||
|
||
async function userRest() {
|
||
await ok200('M2 GET /member/plans', 'GET', '/member/plans', null, false,
|
||
(d) => (Array.isArray(d) && d.length === 3 && 'planId' in d[0] && 'days' in d[0] ? '' : '套餐应 3 档且含 planId/days'))
|
||
await ok200('M1 GET /member/benefits', 'GET', '/member/benefits', null, false,
|
||
(d) => (d && typeof d.freeView === 'number' && Array.isArray(d.benefits) ? '' : '缺 freeView/benefits'))
|
||
await ok200('M2 POST /member/open', 'POST', '/member/open', { planId: 'M1', payType: 'wxpay' }, true,
|
||
(d) => (d && d.orderNo && d.status === 'paid' && d.expireAt ? '' : '缺 orderNo/status/expireAt'))
|
||
await expectFail('M2 · 套餐必选 (BR-07)', 'POST', '/member/open', { payType: 'wxpay' }, true, '请选择会员套餐')
|
||
await ok200('M3 GET /member/orders', 'GET', '/member/orders?page=1&size=10', null, true,
|
||
(d) => (isPager(d) ? '' : '分页结构不符'))
|
||
await ok200('M4 GET /member/assets', 'GET', '/member/assets', null, true,
|
||
(d) => (d && typeof d.balance === 'number' && 'points' in d ? '' : '缺 balance/points'))
|
||
|
||
await ok200('S1 GET /distribution/overview', 'GET', '/distribution/overview', null, true,
|
||
(d) => (d && 'totalCommission' in d && 'withdrawable' in d && 'inviteCode' in d && 'l1Count' in d
|
||
? '' : '缺 totalCommission/withdrawable/inviteCode/l1Count'))
|
||
await ok200('S2 GET /distribution/team', 'GET', '/distribution/team?page=1&size=10', null, true,
|
||
(d) => (isPager(d) ? '' : '分页结构不符'))
|
||
await ok200('S3 POST /distribution/withdraw', 'POST', '/distribution/withdraw', { amount: 1 }, true,
|
||
(d) => (d && d.withdrawNo && d.status === 'auditing' ? '' : '缺 withdrawNo/status'))
|
||
await expectFail('S3 · 金额校验 (BR-09)', 'POST', '/distribution/withdraw', { amount: 0 }, true, '提现金额不合法')
|
||
await expectFail('S3 · 超余额 (BR-09)', 'POST', '/distribution/withdraw', { amount: 99999999 }, true, '提现金额超过可提现余额')
|
||
await ok200('S4 GET /distribution/withdraw-records', 'GET', '/distribution/withdraw-records', null, true,
|
||
(d) => (Array.isArray(d) && 'withdrawNo' in (d[0] || {}) ? '' : '缺 withdrawNo'))
|
||
|
||
await ok200('N1 GET /message/list', 'GET', '/message/list?page=1&size=10', null, true,
|
||
(d) => (isPager(d) ? '' : '分页结构不符'))
|
||
await ok200('N1 GET /message/list 过滤', 'GET', '/message/list?type=system', null, true,
|
||
(d) => (isPager(d) && d.list.every((m) => m.type === 'system') ? '' : 'type 过滤未生效'))
|
||
await ok200('N2 POST /message/{id}/read', 'POST', '/message/1/read', {}, true,
|
||
(d) => (d && d.read === true ? '' : '缺 read'))
|
||
|
||
await ok200('7 GET /setting/ui', 'GET', '/setting/ui', null, false,
|
||
(d) => (d && 'siteName' in d && 'logo' in d && d.icons && typeof d.icons === 'object' ? '' : '缺 siteName/logo/icons'))
|
||
}
|
||
|
||
/* ---------------- 五、后台 · 数据总览 / 商家 / 会员 / 审核 / 评价(13) ---------------- */
|
||
|
||
async function adminCore() {
|
||
await ok200('AD1 GET /admin/dashboard/overview', 'GET', '/admin/dashboard/overview', null, false,
|
||
(d) => (d && 'pv' in d && 'uv' in d && 'todayPostCount' in d && 'memberCount' in d
|
||
&& 'pendingAudit' in d && 'commissionTotal' in d ? '' : '缺 pv/uv/todayPostCount/memberCount/pendingAudit/commissionTotal'))
|
||
await ok200('AD2 GET /admin/dashboard/trend', 'GET', '/admin/dashboard/trend?type=traffic', null, false,
|
||
(d) => (Array.isArray(d) && (d.length === 0 || 'date' in d[0]) ? '' : '应为 [{date,...}] 数组'))
|
||
await ok200('AD2 GET /admin/dashboard/trend revenue', 'GET', '/admin/dashboard/trend?type=revenue', null, false,
|
||
(d) => (Array.isArray(d) ? '' : '应为数组'))
|
||
|
||
await ok200('AM1 GET /admin/merchants', 'GET', '/admin/merchants?page=1&size=10', null, false,
|
||
(d) => (!isPager(d) ? '分页结构不符'
|
||
: (d.list[0] && 'auditStatus' in d.list[0] && 'shopStatus' in d.list[0] && 'postCount' in d.list[0]
|
||
? '' : '商家项缺 auditStatus/shopStatus/postCount')))
|
||
await ok200('AM1 GET /admin/merchants 过滤', 'GET', '/admin/merchants?status=1', null, false,
|
||
(d) => (isPager(d) && d.list.every((m) => Number(m.auditStatus) === 1) ? '' : 'status 过滤未生效'))
|
||
await ok200('AM2 POST /admin/merchant/{id}/audit', 'POST', '/admin/merchant/2005/audit', { auditStatus: 1 }, false,
|
||
(d) => (d && Number(d.auditStatus) === 1 ? '' : 'auditStatus 不符'))
|
||
await expectFail('AM2 · 审核状态校验 (BR-11)', 'POST', '/admin/merchant/2005/audit', { auditStatus: 3 }, false,
|
||
'审核状态仅支持通过(1)/驳回(2)')
|
||
|
||
await ok200('AM3 GET /admin/users', 'GET', '/admin/users?page=1&size=10', null, false,
|
||
(d) => (!isPager(d) ? '分页结构不符'
|
||
: (d.list[0] && 'nickname' in d.list[0] && 'userType' in d.list[0] && 'registerAt' in d.list[0]
|
||
? '' : '用户项缺 nickname/userType/registerAt')))
|
||
await ok200('AM4 POST /admin/member/benefit', 'POST', '/admin/member/benefit',
|
||
{ freeView: 20, postDiscount: '8 折', benefits: ['每日免费查看 20 条联系方式'] }, false,
|
||
(d) => (d && d.freeView === 20 && Array.isArray(d.benefits) ? '' : '缺 freeView/benefits'))
|
||
await expectFail('AM4 · 负数校验', 'POST', '/admin/member/benefit', { freeView: -1 }, false, '免费查看条数不能为负')
|
||
|
||
await ok200('AU1 GET /admin/posts/pending', 'GET', '/admin/posts/pending?page=1&size=20', null, false,
|
||
(d) => (!isPager(d) ? '分页结构不符'
|
||
: (d.list[0] && 'categoryName' in d.list[0] && 'userName' in d.list[0] ? '' : '待审项缺 categoryName/userName')))
|
||
await expectFail('AU2 · 审核状态校验 (BR-11)', 'POST', '/admin/post/101/audit', { auditStatus: 5 }, false,
|
||
'审核状态仅支持通过(1)/驳回(2)')
|
||
await ok200('AU3 POST /admin/post/{id}/offline', 'POST', '/admin/post/106/offline', {}, false,
|
||
(d) => (d && d.offline === true ? '' : '缺 offline'))
|
||
await ok200('AU3 重新上架(审核通过)', 'POST', '/admin/post/106/audit', { auditStatus: 1 }, false,
|
||
(d) => (d && Number(d.auditStatus) === 1 ? '' : 'auditStatus 不符'))
|
||
|
||
await ok200('AC1 GET /admin/comments', 'GET', '/admin/comments?page=1&size=10', null, false,
|
||
(d) => (!isPager(d) ? '分页结构不符'
|
||
: (d.list[0] && 'postTitle' in d.list[0] && 'user' in d.list[0] && Array.isArray(d.list[0].images)
|
||
? '' : '评价项缺 postTitle/user/images')))
|
||
await ok200('AC1 POST /admin/comment/{id}/reply', 'POST', '/admin/comment/5003/reply', { reply: '已核实处理' }, false,
|
||
(d) => (d && d.reply === '已核实处理' ? '' : 'reply 不符'))
|
||
await expectFail('AC1 · 回复内容必填', 'POST', '/admin/comment/5003/reply', { reply: '' }, false, '回复内容不能为空')
|
||
await ok200('AC2 GET /admin/reports', 'GET', '/admin/reports', null, false,
|
||
(d) => (Array.isArray(d) && 'targetType' in (d[0] || {}) ? '' : '缺 targetType'))
|
||
}
|
||
|
||
/* ---------------- 六、后台 · 分销 / 订单 / 财务(14) ---------------- */
|
||
|
||
async function adminMoney() {
|
||
await ok200('AS1 GET /admin/dist/setting', 'GET', '/admin/dist/setting', null, false,
|
||
(d) => (d && 'enabled' in d && 'level1Rate' in d && 'level2Rate' in d && 'minWithdraw' in d
|
||
? '' : '缺 enabled/level1Rate/level2Rate/minWithdraw'))
|
||
await ok200('AS1 POST /admin/dist/setting', 'POST', '/admin/dist/setting',
|
||
{ enabled: true, level: 2, level1Rate: 20, level2Rate: 10, settleCycle: 'T+7', minWithdraw: 50 }, false,
|
||
(d) => (d && Number(d.level1Rate) === 20 ? '' : 'level1Rate 不符'))
|
||
await expectFail('AS1 · 分佣合计校验 (BR-08)', 'POST', '/admin/dist/setting',
|
||
{ level1Rate: 80, level2Rate: 40 }, false, '一级+二级分佣比例不能超过 100%')
|
||
await ok200('AS2 GET /admin/dist/withdraws', 'GET', '/admin/dist/withdraws', null, false,
|
||
(d) => (Array.isArray(d) && 'withdrawNo' in (d[0] || {}) && 'user' in (d[0] || {}) ? '' : '缺 withdrawNo/user'))
|
||
await ok200('AS3 POST /admin/dist/withdraw/{id}/pay', 'POST', '/admin/dist/withdraw/2/pay', { op: 'pay' }, false,
|
||
(d) => (d && d.status === 'paid' ? '' : 'status 应为 paid'))
|
||
await expectFail('AS3 · 驳回需填原因 (BR-09)', 'POST', '/admin/dist/withdraw/3/pay', { op: 'reject' }, false,
|
||
'驳回需填写原因')
|
||
|
||
await ok200('AO1 GET /admin/orders', 'GET', '/admin/orders?page=1&size=10', null, false,
|
||
(d) => (!isPager(d) ? '分页结构不符'
|
||
: (d.list[0] && 'orderNo' in d.list[0] && 'type' in d.list[0] && 'status' in d.list[0]
|
||
? '' : '订单项缺 orderNo/type/status')))
|
||
await ok200('AO2 GET /admin/orders?type=member', 'GET', '/admin/orders?type=member', null, false,
|
||
(d) => (isPager(d) && d.list.every((o) => o.type === 'member') ? '' : 'type 过滤未生效'))
|
||
await expectFail('AO1 · 退款金额校验 (BR-10)', 'POST', '/admin/order/3001/refund', { amount: 0 }, false,
|
||
'退款金额需大于 0')
|
||
await ok200('AO1 POST /admin/order/{id}/refund', 'POST', '/admin/order/3004/refund',
|
||
{ amount: 5, reason: '接口验证退款' }, false, (d) => (d && d.refunded === true ? '' : '缺 refunded'))
|
||
await ok200('AO1 POST /admin/order/supplement', 'POST', '/admin/order/supplement',
|
||
{ orderNo: 'PO20240104004' }, false, (d) => (d && d.supplemented === true ? '' : '缺 supplemented'))
|
||
await expectFail('AO1 · 订单号必填', 'POST', '/admin/order/supplement', {}, false, '订单号不能为空')
|
||
|
||
await ok200('AF1 GET /admin/finance/flows', 'GET', '/admin/finance/flows?page=1&size=10', null, false,
|
||
(d) => (!isPager(d) ? '分页结构不符'
|
||
: (d.list[0] && 'flowNo' in d.list[0] && 'scene' in d.list[0] ? '' : '流水项缺 flowNo/scene')))
|
||
await ok200('AF1 GET /admin/finance/flows 过滤', 'GET', '/admin/finance/flows?type=income', null, false,
|
||
(d) => (isPager(d) && d.list.every((f) => f.type === 'income') ? '' : 'type 过滤未生效'))
|
||
await ok200('AF3 GET /admin/finance/report', 'GET', '/admin/finance/report?period=day', null, false,
|
||
(d) => (d && d.period === 'day' && Array.isArray(d.rows) ? '' : '缺 period/rows'))
|
||
await ok200('AF3 GET /admin/finance/report month', 'GET', '/admin/finance/report?period=month', null, false,
|
||
(d) => (d && Array.isArray(d.rows) ? '' : '缺 rows'))
|
||
}
|
||
|
||
/* ---------------- 七、后台 · 权限 / 设置(13) ---------------- */
|
||
|
||
async function adminRbacSetting() {
|
||
await ok200('AR1 GET /admin/roles', 'GET', '/admin/roles', null, false,
|
||
(d) => (Array.isArray(d) && 'name' in (d[0] || {}) && Array.isArray((d[0] || {}).permissions) ? '' : '缺 name/permissions'))
|
||
await ok200('AR1 POST /admin/role', 'POST', '/admin/role',
|
||
{ name: '接口验证角色', permissions: ['dashboard', 'post'], remark: '由验证脚本创建' }, false,
|
||
(d) => (d && d.name === '接口验证角色' ? '' : 'name 不符'))
|
||
await expectFail('AR1 · 角色名必填 (BR-12)', 'POST', '/admin/role', { permissions: ['post'] }, false, '请填写角色名称')
|
||
await expectFail('AR1 · 权限必填 (BR-12)', 'POST', '/admin/role', { name: 'x', permissions: [] }, false, '请至少分配一项权限')
|
||
await ok200('AR2 GET /admin/accounts', 'GET', '/admin/accounts', null, false,
|
||
(d) => (Array.isArray(d) && 'username' in (d[0] || {}) && 'roleName' in (d[0] || {}) ? '' : '缺 username/roleName'))
|
||
await ok200('AR3 GET /admin/logs', 'GET', '/admin/logs?page=1&size=10', null, false,
|
||
(d) => (isPager(d) ? '' : '分页结构不符'))
|
||
|
||
await ok200('AZ1 GET /admin/setting/base', 'GET', '/admin/setting/base', null, false,
|
||
(d) => (d && 'appName' in d && 'servicePhone' in d ? '' : '缺 appName/servicePhone'))
|
||
await ok200('AZ1 POST /admin/setting/base', 'POST', '/admin/setting/base',
|
||
{ appName: '同城分类信息', servicePhone: '400-000-0000' }, false,
|
||
(d) => (d && d.appName === '同城分类信息' ? '' : 'appName 不符'))
|
||
await expectFail('AZ1 · 名称必填', 'POST', '/admin/setting/base', { appName: '' }, false, '小程序名称不能为空')
|
||
await expectFail('AZ2 · 商户号必填', 'POST', '/admin/setting/pay', { apiKey: 'k' }, false, '微信支付商户号不能为空')
|
||
await expectFail('AZ2 · 密钥必填', 'POST', '/admin/setting/pay', { mchId: 'm' }, false, '支付密钥不能为空')
|
||
await ok200('AZ2 POST /admin/setting/pay', 'POST', '/admin/setting/pay',
|
||
{ mchId: '1900000000', apiKey: 'verify-key' }, false, (d) => (d && d.configured === true ? '' : '缺 configured'))
|
||
await ok200('AZ3 GET /admin/setting/fee', 'GET', '/admin/setting/fee', null, false,
|
||
(d) => (d && 'singlePost' in d && 'memberMonth' in d && 'memberYear' in d ? '' : '缺 singlePost/memberMonth/memberYear'))
|
||
await expectFail('AZ3 · 价格非负', 'POST', '/admin/setting/fee', { singlePost: -1 }, false, '单条发布价格不能为负')
|
||
await ok200('AZ3 POST /admin/setting/fee', 'POST', '/admin/setting/fee',
|
||
{ singlePost: 5, memberMonth: 19.9, memberQuarter: 49.9, memberYear: 168 }, false,
|
||
(d) => (d && Number(d.singlePost) === 5 ? '' : 'singlePost 不符'))
|
||
await ok200('AZ4 GET /admin/setting/areas', 'GET', '/admin/setting/areas', null, false,
|
||
(d) => (Array.isArray(d) && 'enabled' in (d[0] || {}) ? '' : '缺 enabled'))
|
||
await ok200('AZ4 POST /admin/setting/area', 'POST', '/admin/setting/area',
|
||
{ name: '接口验证区', sort: 99, enabled: true }, false, (d) => (d && d.name === '接口验证区' ? '' : 'name 不符'))
|
||
await expectFail('AZ4 · 区域名必填', 'POST', '/admin/setting/area', { name: '' }, false, '区域名称不能为空')
|
||
|
||
/* UI 配置:读 → 写(含图标覆盖)→ 用户端立即可见 → 重置 */
|
||
await ok200('AZ5 GET /admin/setting/ui', 'GET', '/admin/setting/ui', null, false,
|
||
(d) => (d && 'siteName' in d && 'logo' in d && 'icons' in d && 'updatedAt' in d ? '' : '缺 siteName/logo/icons/updatedAt'))
|
||
await ok200('AZ5 POST /admin/setting/ui', 'POST', '/admin/setting/ui',
|
||
{ siteName: '同城分类信息', logo: '/uploads/verify-logo.png', icons: { 'tab-home': '/uploads/verify-tab-home.png' } }, false,
|
||
(d) => (d && d.logo === '/uploads/verify-logo.png'
|
||
&& d.icons && d.icons['tab-home'] === '/uploads/verify-tab-home.png' ? '' : '保存结果未回显'))
|
||
await expectFail('AZ5 · 图标地址类型校验', 'POST', '/admin/setting/ui',
|
||
{ icons: { 'tab-home': 123 } }, false, '图标 tab-home 的地址必须是字符串')
|
||
|
||
const ui = await call('GET', '/setting/ui')
|
||
const icons = (ui.json && ui.json.data && ui.json.data.icons) || {}
|
||
check('AZ5 · 用户端读到后台覆盖的 LOGO/图标',
|
||
ui.json && ui.json.data && ui.json.data.logo === '/uploads/verify-logo.png'
|
||
&& icons['tab-home'] === '/uploads/verify-tab-home.png',
|
||
'实际 logo=' + (ui.json && ui.json.data && ui.json.data.logo) + ' icons=' + JSON.stringify(icons))
|
||
|
||
await ok200('AZ5 POST /admin/setting/ui/reset', 'POST', '/admin/setting/ui/reset', {}, false,
|
||
(d) => (d && d.reset === true && d.logo === '' ? '' : '缺 reset/logo'))
|
||
const ui2 = await call('GET', '/setting/ui')
|
||
check('AZ5 · 重置后用户端恢复内置 LOGO',
|
||
ui2.json && ui2.json.data && ui2.json.data.logo === '',
|
||
'实际 logo=' + (ui2.json && ui2.json.data && ui2.json.data.logo))
|
||
}
|
||
|
||
/* ---------------- 八、双端业务闭环(发布 → 审核 → 前台可见) ---------------- */
|
||
|
||
async function closeLoop() {
|
||
if (!newPostId) {
|
||
check('闭环 · 用户发布', false, '未取得 newPostId,跳过闭环')
|
||
return
|
||
}
|
||
|
||
// 1) 后台待审列表里应出现刚发布的信息
|
||
const pend = await call('GET', '/admin/posts/pending?page=1&size=50')
|
||
const found = pend.json && pend.json.code === 0 && (pend.json.data.list || []).some((p) => Number(p.id) === newPostId)
|
||
check('闭环① 后台待审列表可见新发布 #' + newPostId, found,
|
||
'待审 id: ' + ((pend.json && pend.json.data && (pend.json.data.list || []).map((p) => p.id).join(',') ) || '-'))
|
||
|
||
// 2) 未审核前,前台信息流不应出现
|
||
const feed0 = await call('GET', '/home/feed?page=1&size=50')
|
||
const before = (feed0.json && feed0.json.data && feed0.json.data.list || []).some((p) => Number(p.id) === newPostId)
|
||
check('闭环② 待审信息不出现在前台信息流 (BR-04)', !before, '待审信息提前出现在信息流')
|
||
|
||
// 3) 后台审核通过
|
||
const audit = await call('POST', '/admin/post/' + newPostId + '/audit', { auditStatus: 1 }, false)
|
||
check('闭环③ 后台审核通过',
|
||
audit.json && audit.json.code === 0 && Number(audit.json.data.auditStatus) === 1,
|
||
audit.json ? 'code=' + audit.json.code + ' ' + audit.json.message : 'HTTP ' + audit.status)
|
||
|
||
// 4) 前台信息流应出现
|
||
const feed1 = await call('GET', '/home/feed?page=1&size=50')
|
||
const after = (feed1.json && feed1.json.data && feed1.json.data.list || []).some((p) => Number(p.id) === newPostId)
|
||
check('闭环④ 审核通过后前台信息流可见', after, '未出现在信息流')
|
||
|
||
// 5) 详情可访问,且联系方式为脱敏
|
||
const det = await call('GET', '/post/' + newPostId)
|
||
check('闭环⑤ 详情可访问且联系方式脱敏',
|
||
det.json && det.json.code === 0 && String(det.json.data.contact).indexOf('****') > -1,
|
||
det.json ? 'contact=' + det.json.data.contact : 'HTTP ' + det.status)
|
||
|
||
// 6) 发布者应收到审核结果消息
|
||
const msg = await call('GET', '/message/list?page=1&size=20', null, true)
|
||
const hasMsg = (msg.json && msg.json.data && msg.json.data.list || [])
|
||
.some((m) => String(m.title).indexOf('审核') > -1)
|
||
check('闭环⑥ 发布者收到审核结果站内消息', hasMsg, '未找到审核类消息')
|
||
|
||
// 7) 后台操作已留痕
|
||
const logs = await call('GET', '/admin/logs?page=1&size=10')
|
||
const hasLog = (logs.json && logs.json.data && logs.json.data.list || [])
|
||
.some((l) => String(l.action).indexOf('审核信息') > -1)
|
||
check('闭环⑦ 后台审核动作已留痕 (AR3)', hasLog, '未找到审核信息日志')
|
||
|
||
// 8) 资金/流水联动:审核通过不影响资金;此处校验流水接口可用且字段完整
|
||
const flows = await call('GET', '/admin/finance/flows?page=1&size=10')
|
||
check('闭环⑧ 资金流水字段完整',
|
||
flows.json && flows.json.code === 0 && (flows.json.data.list || []).every((f) => 'flowNo' in f && 'balance' in f),
|
||
'流水字段不完整')
|
||
}
|
||
|
||
/* ---------------- 主流程 ---------------- */
|
||
|
||
async function main() {
|
||
console.log('接口验证开始 → ' + BASE)
|
||
console.log('')
|
||
|
||
await userAuth()
|
||
await userHome()
|
||
await userPost()
|
||
await userRest()
|
||
await adminCore()
|
||
await adminMoney()
|
||
await adminRbacSetting()
|
||
await closeLoop()
|
||
|
||
const total = PASS.length + FAIL.length
|
||
console.log('————————— 验证结果 —————————')
|
||
console.log('用例总数: ' + total + ' 通过: ' + PASS.length + ' 失败: ' + FAIL.length)
|
||
if (FAIL.length) {
|
||
console.log('')
|
||
console.log('失败明细:')
|
||
FAIL.forEach((f, i) => console.log(' ' + (i + 1) + '. ' + f))
|
||
}
|
||
console.log('')
|
||
console.log('耗时: ' + ((Date.now() - T0) / 1000).toFixed(1) + 's')
|
||
process.exit(FAIL.length ? 1 : 0)
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error('验证脚本异常: ' + (e && e.stack ? e.stack : e))
|
||
process.exit(1)
|
||
})
|