Files
gym-manage/test-full-flow.js

627 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全面端到端测试脚本
*
* 流程:
* 1. 后台管理系统:admin 创建团课(无封面、张教练)→ 保存二维码
* 2. 会员端:预约团课(开课前30分钟以上)
* 3. 调整时间 → 扫码签到(开课前2小时内)
* 4. 调整时间 → 教练端:手动开课 → 手动结课
*
* API Base: http://192.168.110.64:8084
* 认证: HMAC-SHA256 + JWT
*
* 业务规则(来自后端代码):
* - 预约: 距开课 >= 30分钟
* - 签到: 开课前2小时 ~ 课程结束
* - 开课(>=60min课程): 开课时间后10分钟内正常, 10-30分钟迟到, >30分钟拒绝
* - 结课: 结束时间后10分钟内
*/
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const API_BASE = 'http://192.168.110.64:8084';
const SIGNATURE_SECRET = 'NovalonManageSystemSecretKey2026';
const QRCODE_DIR = path.resolve(__dirname, 'QRCODE');
// 测试账号
const ADMIN = { username: 'admin', password: 'Test@123' };
const MEMBER_CODE = 'dev-test-fullflow-' + Date.now();
const COACH = { username: 'coach_zhang', password: 'Test@123' };
// 结果记录
const results = {
steps: [],
startTime: new Date().toISOString(),
endTime: null,
courseId: null,
courseName: null,
memberId: null,
bookingId: null,
qrPath: null,
passed: 0,
failed: 0
};
function record(step, status, detail) {
const entry = { step, status, detail, time: new Date().toISOString() };
results.steps.push(entry);
const icon = status === 'PASS' ? '✓' : status === 'SKIP' ? '○' : '✗';
console.log(` ${icon} [${status}] ${step}${detail ? ': ' + detail : ''}`);
if (status === 'PASS') results.passed++;
else if (status === 'FAIL') results.failed++;
}
// ── 本地时间格式化(发送给服务端 LocalDateTime,不带时区)──
function formatLocalTime(d) {
const pad = (n) => String(n).padStart(2, '0');
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}
// ── HTTP 请求工具 ──
function apiRequest(method, urlPath, body, token) {
return new Promise((resolve, reject) => {
const u = new URL(API_BASE + urlPath);
const timestamp = Date.now();
const nonce = timestamp.toString(36) + '-' + Math.random().toString(36).substring(2, 8);
const bodyStr = body ? JSON.stringify(body) : '';
const stringToSign = [method.toUpperCase(), u.pathname + u.search, '', bodyStr, String(timestamp), nonce].join('\n');
const sig = crypto.createHmac('sha256', SIGNATURE_SECRET).update(stringToSign).digest('base64');
const headers = {
'Content-Type': 'application/json',
'X-Signature': sig,
'X-Timestamp': String(timestamp),
'X-Nonce': nonce
};
if (token) headers['Authorization'] = 'Bearer ' + token;
const req = http.request({
hostname: u.hostname, port: u.port, path: u.pathname + u.search,
method, headers, timeout: 15000
}, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
let parsed;
try { parsed = JSON.parse(data); } catch (e) { parsed = data; }
resolve({ status: res.statusCode, data: parsed });
});
});
req.on('error', e => reject(e));
if (bodyStr) req.write(bodyStr);
req.end();
});
}
// 下载文件到本地
function downloadFile(urlPath, token, destPath) {
return new Promise((resolve, reject) => {
const u = new URL(API_BASE + urlPath);
const timestamp = Date.now();
const nonce = timestamp.toString(36) + '-' + Math.random().toString(36).substring(2, 8);
const stringToSign = ['GET', u.pathname + u.search, '', '', String(timestamp), nonce].join('\n');
const sig = crypto.createHmac('sha256', SIGNATURE_SECRET).update(stringToSign).digest('base64');
const headers = {
'X-Signature': sig, 'X-Timestamp': String(timestamp), 'X-Nonce': nonce
};
if (token) headers['Authorization'] = 'Bearer ' + token;
const req = http.request({
hostname: u.hostname, port: u.port, path: u.pathname + u.search,
method: 'GET', headers, timeout: 15000
}, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
fs.writeFileSync(destPath, buf);
resolve(destPath);
});
});
req.on('error', e => reject(e));
req.end();
});
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
// ── 主流程 ──
async function main() {
console.log('═══════════════════════════════════════════');
console.log(' 全面端到端测试 - 全流程');
console.log(' 包含: 后台管理 / 会员端 / 教练端');
console.log(` 时间: ${results.startTime}`);
console.log(` 本地时间: ${formatLocalTime(new Date())}`);
console.log('═══════════════════════════════════════════\n');
// 确保 QRCODE 目录存在
if (!fs.existsSync(QRCODE_DIR)) fs.mkdirSync(QRCODE_DIR, { recursive: true });
let adminToken, coachToken, memberToken;
// ═══════════════════════════════════════════
// 阶段1:后台管理系统
// ═══════════════════════════════════════════
console.log('── 阶段1:后台管理系统 ──');
// 1a. 管理员登录
try {
const loginRes = await apiRequest('POST', '/api/auth/login', ADMIN);
if (loginRes.status === 200 && loginRes.data.token) {
adminToken = loginRes.data.token;
record('1a. 管理员登录', 'PASS', `${ADMIN.username} / userId=${loginRes.data.userId}`);
} else {
record('1a. 管理员登录', 'FAIL', `status=${loginRes.status}, body=${JSON.stringify(loginRes.data).substring(0, 200)}`);
throw new Error('Admin login failed, cannot continue');
}
} catch (e) {
record('1a. 管理员登录', 'FAIL', e.message);
if (!adminToken) { generateMarkdownReport(); return; }
}
// 1b. 获取教练列表
let coachId = null;
if (adminToken) {
try {
const coachRes = await apiRequest('GET', '/api/coach/list', null, adminToken);
if (coachRes.status === 200) {
const coaches = Array.isArray(coachRes.data) ? coachRes.data : (coachRes.data?.data || []);
const zhang = coaches.find(c => c.username === 'coach_zhang' || (c.nickname && c.nickname.includes('张')));
if (zhang) {
coachId = zhang.id;
record('1b. 获取教练列表', 'PASS', `找到 ${COACH.username}, id=${coachId}, nickname=${zhang.nickname || '-'}`);
} else {
record('1b. 获取教练列表', 'FAIL', `未找到 ${COACH.username}, 列表: ${coaches.map(c => c.username || c.nickname).join(', ')}`);
}
} else {
record('1b. 获取教练列表', 'FAIL', `status=${coachRes.status}`);
}
} catch (e) {
record('1b. 获取教练列表', 'FAIL', e.message);
}
}
// 1c. 创建团课(无封面、张教练负责)
let courseId = null;
const courseName = '全流程测试-' + Date.now().toString(36);
// 初始时间:5小时后开课,留足预约时间(>=30分钟)
const createStartTime = new Date(Date.now() + 5 * 60 * 60 * 1000);
const createEndTime = new Date(createStartTime.getTime() + 60 * 60 * 1000);
if (adminToken && coachId) {
try {
const createBody = {
courseName: courseName,
coachId: parseInt(coachId),
courseType: 1,
startTime: formatLocalTime(createStartTime),
endTime: formatLocalTime(createEndTime),
maxMembers: 20,
location: '测试场地-全流程',
coverImage: '', // 无封面
description: '自动化全流程端到端测试团课'
};
console.log(' 创建请求:', JSON.stringify(createBody));
const createRes = await apiRequest('POST', '/api/groupCourse', createBody, adminToken);
console.log(' 创建响应: status=' + createRes.status + ', body=' + JSON.stringify(createRes.data).substring(0, 300));
const createdCourse = createRes.data?.data || createRes.data;
if (createRes.status === 200 && createdCourse?.id) {
courseId = createdCourse.id;
results.courseId = courseId;
results.courseName = courseName;
record('1c. 创建团课', 'PASS',
`id=${courseId}, name="${courseName}", coachId=${coachId}, 无封面, startTime=${formatLocalTime(createStartTime)}`);
} else {
// 尝试从message中提取信息
const msg = createRes.data?.message || '';
record('1c. 创建团课', 'FAIL',
`status=${createRes.status}, msg=${msg}, body=${JSON.stringify(createRes.data).substring(0, 300)}`);
}
} catch (e) {
record('1c. 创建团课', 'FAIL', e.message);
}
}
// 1d. 获取并保存二维码
if (adminToken && courseId) {
try {
const detailRes = await apiRequest('GET', `/api/groupCourse/${courseId}/detail`, null, adminToken);
const course = detailRes.data;
if (course.qrCodePath) {
const destFile = path.join(QRCODE_DIR, courseName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_') + '.png');
try {
await downloadFile(course.qrCodePath, adminToken, destFile);
results.qrPath = destFile;
const size = fs.statSync(destFile).size;
record('1d. 保存二维码', 'PASS', `已保存: ${destFile} (${size} bytes)`);
} catch (e) {
record('1d. 保存二维码', 'FAIL', `下载失败: ${e.message}`);
}
} else {
record('1d. 保存二维码', 'FAIL', 'qrCodePath 为空(可能异步生成中)');
}
} catch (e) {
record('1d. 保存二维码', 'FAIL', e.message);
}
}
// ═══════════════════════════════════════════
// 阶段2:会员端 - 预约 + 签到
// ═══════════════════════════════════════════
console.log('\n── 阶段2:会员端 ──');
// 2a. 会员登录
if (courseId) {
try {
const memberLoginRes = await apiRequest('POST', '/api/member/auth/miniapp/login', { code: MEMBER_CODE });
if (memberLoginRes.status === 200 && memberLoginRes.data.accessToken) {
memberToken = memberLoginRes.data.accessToken;
results.memberId = memberLoginRes.data.memberId;
record('2a. 会员登录', 'PASS', `memberId=${results.memberId}`);
} else {
record('2a. 会员登录', 'FAIL', `status=${memberLoginRes.status}, ${JSON.stringify(memberLoginRes.data).substring(0, 200)}`);
}
} catch (e) {
record('2a. 会员登录', 'FAIL', e.message);
}
}
// 2b. 预约团课(现在距开课5小时 > 30分钟,满足预约条件)
if (memberToken && courseId) {
try {
const bookBody = { courseId: parseInt(courseId), memberId: parseInt(results.memberId) };
console.log(' 预约请求:', JSON.stringify(bookBody));
const bookRes = await apiRequest('POST', '/api/groupCourse/book', bookBody, memberToken);
console.log(' 预约响应: status=' + bookRes.status + ', body=' + JSON.stringify(bookRes.data).substring(0, 300));
if (bookRes.status === 200) {
results.bookingId = bookRes.data?.data?.id || bookRes.data?.id;
record('2b. 预约团课', 'PASS',
`courseId=${courseId}, bookingId=${results.bookingId}, 距开课约5h>=30min要求)`);
} else if (bookRes.status === 400) {
const msg = bookRes.data?.message || '';
if (msg.includes('已预约')) {
record('2b. 预约团课', 'SKIP', '已预约过该课程(可能是重复测试)');
} else {
record('2b. 预约团课', 'FAIL', `status=400, msg=${msg}`);
}
} else {
record('2b. 预约团课', 'FAIL', `status=${bookRes.status}, ${JSON.stringify(bookRes.data).substring(0, 200)}`);
}
} catch (e) {
record('2b. 预约团课', 'FAIL', e.message);
}
}
// 2c. 调整团课时间:签到需要 startTime 在 2 小时内(规则: now >= startTime - 2h
const signInStart = new Date(Date.now() + 60 * 60 * 1000); // 1小时后开课(在2h窗口内)
if (adminToken && courseId) {
try {
const updateBody = {
startTime: formatLocalTime(signInStart),
endTime: formatLocalTime(new Date(signInStart.getTime() + 60 * 60 * 1000))
};
const updateRes = await apiRequest('PUT', `/api/groupCourse/${courseId}`, updateBody, adminToken);
console.log(' 调整时间响应: status=' + updateRes.status);
if (updateRes.status === 200) {
record('2c. 调整课程时间(签到用)', 'PASS',
`startTime→${formatLocalTime(signInStart)}1h后→满足签到2h窗口)`);
} else {
record('2c. 调整课程时间(签到用)', 'FAIL',
`status=${updateRes.status}, ${JSON.stringify(updateRes.data).substring(0, 200)}`);
}
} catch (e) {
record('2c. 调整课程时间(签到用)', 'FAIL', e.message);
}
}
// 2d. 签到(扫码签到 = 调用 groupCourse/signin API
if (memberToken && courseId) {
try {
const signinBody = { courseId: parseInt(courseId) };
const signinRes = await apiRequest('POST', `/api/groupCourse/signin/${results.memberId}`, signinBody, memberToken);
console.log(' 签到响应: status=' + signinRes.status + ', body=' + JSON.stringify(signinRes.data).substring(0, 300));
if (signinRes.status === 200) {
record('2d. 扫码签到', 'PASS',
`courseId=${courseId}, memberId=${results.memberId}, 签到时间距开课约1h(满足2h窗口)`);
} else if (signinRes.status === 400) {
const msg = signinRes.data?.message || '';
if (msg.includes('已出席') || msg.includes('已签到')) {
record('2d. 扫码签到', 'SKIP', '已签到过');
} else {
record('2d. 扫码签到', 'FAIL', `status=400, msg=${msg}`);
}
} else {
record('2d. 扫码签到', 'FAIL',
`status=${signinRes.status}, ${JSON.stringify(signinRes.data).substring(0, 200)}`);
}
} catch (e) {
record('2d. 扫码签到', 'FAIL', e.message);
}
}
// ═══════════════════════════════════════════
// 阶段3:教练端 - 开课 + 结课
// ═══════════════════════════════════════════
console.log('\n── 阶段3:教练端 ──');
// 3a. 教练登录
try {
const coachLoginRes = await apiRequest('POST', '/api/auth/login', COACH);
if (coachLoginRes.status === 200 && coachLoginRes.data.token) {
coachToken = coachLoginRes.data.token;
record('3a. 教练登录', 'PASS', `${COACH.username}, userId=${coachLoginRes.data.userId}`);
} else {
record('3a. 教练登录', 'FAIL', `status=${coachLoginRes.status}, ${JSON.stringify(coachLoginRes.data).substring(0, 200)}`);
}
} catch (e) {
record('3a. 教练登录', 'FAIL', e.message);
}
// 3b. 调整课程时间: 开课需要已过 startTime(规则: 10分钟内正常开课)
const coachStartTime = new Date(Date.now() - 3 * 60 * 1000); // 3分钟前
if (adminToken && courseId) {
try {
const updateBody = {
startTime: formatLocalTime(coachStartTime),
endTime: formatLocalTime(new Date(coachStartTime.getTime() + 60 * 60 * 1000))
};
const updateRes = await apiRequest('PUT', `/api/groupCourse/${courseId}`, updateBody, adminToken);
console.log(' 调整时间为过去响应: status=' + updateRes.status);
if (updateRes.status === 200) {
record('3b. 调整课程时间(开课用)', 'PASS',
`startTime→${formatLocalTime(coachStartTime)}3分钟前→教练可正常开课)`);
} else {
record('3b. 调整课程时间(开课用)', 'FAIL',
`status=${updateRes.status}, ${JSON.stringify(updateRes.data).substring(0, 200)}`);
}
} catch (e) {
record('3b. 调整课程时间(开课用)', 'FAIL', e.message);
}
}
// 3c. 教练手动开课
if (coachToken && courseId) {
try {
const startRes = await apiRequest('POST', `/api/coach/courses/${courseId}/start`, {}, coachToken);
console.log(' 开课响应: status=' + startRes.status + ', body=' + JSON.stringify(startRes.data).substring(0, 300));
if (startRes.status === 200) {
const respData = startRes.data?.data || startRes.data;
record('3c. 手动开课', 'PASS',
`courseId=${courseId}, status=${respData?.status}, msg=${startRes.data?.message || ''}`);
} else if (startRes.status === 400) {
const msg = startRes.data?.message || '';
record('3c. 手动开课', 'FAIL', `status=400, msg=${msg}`);
} else {
record('3c. 手动开课', 'FAIL',
`status=${startRes.status}, ${JSON.stringify(startRes.data).substring(0, 200)}`);
}
} catch (e) {
record('3c. 手动开课', 'FAIL', e.message);
}
}
// 3d. 调整课程结束时间:结课需要 endTime 在10分钟内(规则: now <= endTime + 10min
// 把 endTime 设为2分钟前
const coachEndTime = new Date(Date.now() - 2 * 60 * 1000);
if (adminToken && courseId) {
try {
const updateBody = {
endTime: formatLocalTime(coachEndTime)
};
const updateRes = await apiRequest('PUT', `/api/groupCourse/${courseId}`, updateBody, adminToken);
console.log(' 调整结束时间响应: status=' + updateRes.status);
if (updateRes.status === 200) {
record('3d. 调整结束时间(结课用)', 'PASS',
`endTime→${formatLocalTime(coachEndTime)}2分钟前→教练可结课)`);
} else {
record('3d. 调整结束时间(结课用)', 'FAIL',
`status=${updateRes.status}, ${JSON.stringify(updateRes.data).substring(0, 200)}`);
}
} catch (e) {
record('3d. 调整结束时间(结课用)', 'FAIL', e.message);
}
}
// 3e. 教练手动结课
if (coachToken && courseId) {
try {
const endRes = await apiRequest('POST', `/api/coach/courses/${courseId}/end`, {}, coachToken);
console.log(' 结课响应: status=' + endRes.status + ', body=' + JSON.stringify(endRes.data).substring(0, 300));
if (endRes.status === 200) {
const respData = endRes.data?.data || endRes.data;
record('3e. 手动结课', 'PASS',
`courseId=${courseId}, status=${respData?.status}, msg=${endRes.data?.message || ''}`);
} else if (endRes.status === 400) {
const msg = endRes.data?.message || '';
record('3e. 手动结课', 'FAIL', `status=400, msg=${msg}`);
} else {
record('3e. 手动结课', 'FAIL',
`status=${endRes.status}, ${JSON.stringify(endRes.data).substring(0, 200)}`);
}
} catch (e) {
record('3e. 手动结课', 'FAIL', e.message);
}
}
// ═══════════════════════════════════════════
// 阶段4:验证与总结
// ═══════════════════════════════════════════
console.log('\n── 阶段4:最终验证 ──');
if (adminToken && courseId) {
try {
const finalRes = await apiRequest('GET', `/api/groupCourse/${courseId}/detail`, null, adminToken);
const fc = finalRes.data;
record('4. 最终课程状态', 'PASS',
`name="${fc.courseName}", status=${fc.status}, members=${fc.currentMembers}, startTime=${fc.startTime}`);
} catch (e) {
record('4. 最终课程状态', 'FAIL', e.message);
}
}
// ── 输出汇总 ──
results.endTime = new Date().toISOString();
console.log('\n═══════════════════════════════════════════');
console.log(' 测试结果汇总');
console.log('═══════════════════════════════════════════');
console.log(` 通过: ${results.passed} | 失败: ${results.failed} | 总计: ${results.steps.length}`);
console.log(` 课程ID: ${courseId || 'N/A'} 课程名: ${courseName}`);
console.log(` 二维码: ${results.qrPath || 'N/A'}`);
console.log(` 开始时间: ${results.startTime}`);
console.log(` 结束时间: ${results.endTime}`);
console.log('═══════════════════════════════════════════\n');
// 生成 Markdown 报告
generateMarkdownReport();
}
// ── 生成 Markdown 报告 ──
function generateMarkdownReport() {
const reportPath = path.join(__dirname, 'TEST_REPORT_full_flow.md');
const lines = [];
lines.push('# 全面端到端测试报告');
lines.push('');
lines.push('## 测试概要');
lines.push('');
lines.push('| 项目 | 值 |');
lines.push('|------|-----|');
lines.push('| 测试时间 | ' + results.startTime + ' ~ ' + results.endTime + ' |');
lines.push('| 总步骤数 | ' + results.steps.length + ' |');
lines.push('| 通过 | ' + results.passed + ' |');
lines.push('| 失败 | ' + results.failed + ' |');
lines.push('| 课程ID | ' + (results.courseId || 'N/A') + ' |');
lines.push('| 课程名称 | ' + (results.courseName || 'N/A') + ' |');
lines.push('| 二维码路径 | ' + (results.qrPath || 'N/A') + ' |');
lines.push('| API地址 | ' + API_BASE + ' |');
lines.push('');
lines.push('## 测试范围');
lines.push('');
lines.push('| 模块 | 项目 | 测试内容 |');
lines.push('|------|------|----------|');
lines.push('| 后台管理系统 | `gym-manage-web` | 管理员登录、创建团课(无封面、张教练)、修改团课时间、保存二维码 |');
lines.push('| 会员端 | `gym-manage-uniapp` | 会员登录、预约团课、扫码签到 |');
lines.push('| 教练端 | `gym-manage-coach-uniapp` | 教练登录、手动开课、手动结课 |');
lines.push('| 后端API | `gym-manage-api` | 所有操作通过REST API完成 |');
lines.push('');
lines.push('## 业务规则验证');
lines.push('');
lines.push('| 规则 | 条件 | 测试策略 |');
lines.push('|------|------|----------|');
lines.push('| 预约时间限制 | 需在开课前 >= 30分钟 | 创建课程startTime为5小时后,预约成功 |');
lines.push('| 签到时间窗口 | 开课前2小时 ~ 课程结束 | 调整startTime为1小时后,签到成功 |');
lines.push('| 教练开课 | 开课时间后10分钟内正常开课 | 调整startTime为3分钟前,开课成功 |');
lines.push('| 教练结课 | 结束时间后10分钟内结课 | 调整endTime为2分钟前,结课 |');
lines.push('');
lines.push('## 测试流程');
lines.push('');
lines.push('```');
lines.push('1. Admin: Login -> Get coach list -> Create course -> Save QR code');
lines.push('2. Member: Login -> Book course');
lines.push('3. Admin: Adjust startTime (for sign-in window)');
lines.push('4. Member: Sign in (scan QR)');
lines.push('5. Admin: Adjust startTime to past (for coach start)');
lines.push('6. Coach: Login -> Start course');
lines.push('7. Admin: Adjust endTime to past (for coach end)');
lines.push('8. Coach: End course');
lines.push('```');
lines.push('');
lines.push('## 详细步骤结果');
lines.push('');
lines.push('| # | 步骤 | 状态 | 详情 | 时间 |');
lines.push('|---|------|------|------|------|');
results.steps.forEach((s, i) => {
const icon = s.status === 'PASS' ? 'PASS' : (s.status === 'SKIP' ? 'SKIP' : 'FAIL');
const detail = (s.detail || '-').replace(/\|/g, '\\|');
lines.push('| ' + (i + 1) + ' | ' + s.step + ' | ' + icon + ' | ' + detail + ' | ' + (s.time || '-') + ' |');
});
lines.push('');
const failSteps = results.steps.filter(s => s.status === 'FAIL');
if (failSteps.length > 0) {
lines.push('## 失败步骤分析');
lines.push('');
failSteps.forEach(s => {
lines.push('- **' + s.step + '**: ' + (s.detail || '无详情'));
});
lines.push('');
}
lines.push('## 使用的API端点');
lines.push('');
lines.push('| 端点 | 方法 | 用途 | 认证 |');
lines.push('|------|------|------|------|');
lines.push('| `/api/auth/login` | POST | 管理员/教练登录 | HMAC签名 |');
lines.push('| `/api/coach/list` | GET | 获取教练列表 | JWT (Admin) |');
lines.push('| `/api/groupCourse` | POST | 创建团课 | JWT (Admin) |');
lines.push('| `/api/groupCourse/{id}` | PUT | 修改团课时间 | JWT (Admin) |');
lines.push('| `/api/groupCourse/{id}/detail` | GET | 获取课程详情(含二维码) | JWT (Admin) |');
lines.push('| `/api/member/auth/miniapp/login` | POST | 会员登录 | HMAC签名 |');
lines.push('| `/api/groupCourse/book` | POST | 预约团课 | JWT (Member) |');
lines.push('| `/api/groupCourse/signin/{memberId}` | POST | 扫码签到 | JWT (Member) |');
lines.push('| `/api/coach/courses/{courseId}/start` | POST | 教练手动开课 | JWT (Coach) |');
lines.push('| `/api/coach/courses/{courseId}/end` | POST | 教练手动结课 | JWT (Coach) |');
lines.push('');
lines.push('## 认证机制');
lines.push('');
lines.push('- **JWT Token**: `Authorization: Bearer {token}`');
lines.push('- **HMAC-SHA256**: `X-Signature`, `X-Timestamp`, `X-Nonce`');
lines.push('- **Secret Key**: `NovalonManageSystemSecretKey2026`');
lines.push('');
lines.push('## 测试账号');
lines.push('');
lines.push('| 角色 | 用户名 | 密码 |');
lines.push('|------|--------|------|');
lines.push('| 管理员 | admin | Test@123 |');
lines.push('| 会员 | (小程序code登录) | ' + MEMBER_CODE + ' |');
lines.push('| 教练 | coach_zhang | Test@123 |');
lines.push('');
lines.push('## 时间约束处理策略');
lines.push('');
lines.push('本测试通过后台API动态调整课程时间,绕过各步骤的时间限制:');
lines.push('');
lines.push('| 步骤 | 时间约束 | 处理方式 |');
lines.push('|------|----------|----------|');
lines.push('| 预约 | 需 >= 30分钟前 | 创建课程startTime=当前+5h |');
lines.push('| 签到 | 开课前2h ~ 课程结束 | PUT修改startTime=当前+1h |');
lines.push('| 开课 | 开课时间后10分钟内 | PUT修改startTime=当前-3min |');
lines.push('| 结课 | 结束时间后10分钟内 | PUT修改endTime=当前-2min |');
lines.push('');
lines.push('> **注意**: 使用 `formatLocalTime()` 发送本地时间(无时区),确保与服务器 LocalDateTime 一致。');
fs.writeFileSync(reportPath, lines.join('\n'), 'utf-8');
console.log('报告已保存: ' + reportPath);
}
// ── 运行 ──
main().catch(e => {
console.error('测试脚本异常:', e);
record('脚本异常', 'FAIL', e.message);
generateMarkdownReport();
});