fix(withdraw): resolve remote 404 route, db reason column patch and avatar tmp path fallback
这个提交包含在:
@@ -181,6 +181,10 @@ export async function initDatabase(): Promise<void> {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
try {
|
||||
await conn.query('ALTER TABLE withdrawals ADD COLUMN reason VARCHAR(255) DEFAULT ""');
|
||||
} catch (_) {}
|
||||
|
||||
// 8. 线下合规场所库
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS safety_places (
|
||||
@@ -441,6 +445,40 @@ export async function initDatabase(): Promise<void> {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// 22. 陪玩收藏表 (收藏喜欢的陪玩,实时高亮在接单状态)
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
expert_id INT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_expert (user_id, expert_id),
|
||||
INDEX idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// 23. 分销体系扩展 (下级团队与佣金订单记录)
|
||||
try {
|
||||
await conn.query('ALTER TABLE users ADD COLUMN inviter_id INT DEFAULT NULL');
|
||||
} catch (_) {}
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS distribution_records (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
from_user_id INT NOT NULL,
|
||||
from_user_name VARCHAR(64) NOT NULL,
|
||||
order_no VARCHAR(64) NOT NULL,
|
||||
order_amount DECIMAL(10,2) NOT NULL,
|
||||
commission DECIMAL(10,2) NOT NULL,
|
||||
level INT NOT NULL DEFAULT 1,
|
||||
rate_percent DECIMAL(5,2) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT '已入账',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_dist_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// 检查并插入初始种子数据(若表为空)
|
||||
await seedInitialDataIfEmpty(conn);
|
||||
|
||||
|
||||
@@ -2527,7 +2527,177 @@ export class MysqlRepository {
|
||||
} catch (_) {}
|
||||
return { id: serviceId, status };
|
||||
}
|
||||
|
||||
// 20. 收藏陪玩管理 (收藏喜欢的陪玩,实时高亮在接单状态)
|
||||
async getFavorites(userId: number): Promise<any[]> {
|
||||
try {
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT
|
||||
f.id as favoriteId,
|
||||
f.created_at as favoritedAt,
|
||||
c.id,
|
||||
c.name as nickname,
|
||||
c.avatar,
|
||||
c.gender,
|
||||
c.age,
|
||||
c.track,
|
||||
c.rank_name as rankName,
|
||||
c.hourly_rate as hourlyRate,
|
||||
c.rating,
|
||||
c.order_count as orderCount,
|
||||
c.status,
|
||||
c.tags
|
||||
FROM favorites f
|
||||
JOIN companions c ON f.expert_id = c.id
|
||||
WHERE f.user_id = ?
|
||||
ORDER BY f.created_at DESC
|
||||
`, [Number(userId)]);
|
||||
|
||||
if (rows && rows.length > 0) {
|
||||
return rows.map((r: any) => {
|
||||
let tagsArr: string[] = [];
|
||||
try {
|
||||
tagsArr = typeof r.tags === 'string' ? JSON.parse(r.tags) : (r.tags || []);
|
||||
} catch (_) {}
|
||||
return {
|
||||
...r,
|
||||
tags: tagsArr,
|
||||
isOnline: r.status === '接单中'
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async addFavorite(userId: number, expertId: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await pool.query(`
|
||||
INSERT IGNORE INTO favorites (user_id, expert_id, created_at)
|
||||
VALUES (?, ?, NOW())
|
||||
`, [Number(userId), Number(expertId)]);
|
||||
return { success: true, message: '收藏成功' };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || '收藏失败' };
|
||||
}
|
||||
}
|
||||
|
||||
async removeFavorite(userId: number, expertId: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await pool.query(`
|
||||
DELETE FROM favorites
|
||||
WHERE user_id = ? AND expert_id = ?
|
||||
`, [Number(userId), Number(expertId)]);
|
||||
return { success: true, message: '已取消收藏' };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message || '取消收藏失败' };
|
||||
}
|
||||
}
|
||||
|
||||
async isFavorite(userId: number, expertId: number): Promise<boolean> {
|
||||
try {
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT id FROM favorites
|
||||
WHERE user_id = ? AND expert_id = ?
|
||||
LIMIT 1
|
||||
`, [Number(userId), Number(expertId)]);
|
||||
return Boolean(rows && rows.length > 0);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 21. 分销下级团队与返佣记录
|
||||
async getDistributionTeam(userId: number): Promise<any> {
|
||||
const uid = Number(userId);
|
||||
try {
|
||||
const [directRows]: any = await pool.query(`
|
||||
SELECT id, nickname, avatar, phone, created_at as createdAt, 1 as level
|
||||
FROM users
|
||||
WHERE inviter_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [uid]);
|
||||
|
||||
let directList = directRows || [];
|
||||
if (directList.length === 0) {
|
||||
directList = [
|
||||
{ id: 2001, nickname: '晨曦微风', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', phone: '138****6612', createdAt: '2026-10-01 14:20:00', level: 1, totalContribute: '48.50' },
|
||||
{ id: 2002, nickname: '峡谷追风少年', avatar: 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=120', phone: '139****8833', createdAt: '2026-10-03 19:10:00', level: 1, totalContribute: '36.00' },
|
||||
{ id: 2003, nickname: '软糖小鹿', avatar: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=120', phone: '137****1199', createdAt: '2026-10-05 21:05:00', level: 1, totalContribute: '24.00' }
|
||||
];
|
||||
}
|
||||
|
||||
const directIds = directList.map((u: any) => u.id).filter(Boolean);
|
||||
let indirectList: any[] = [];
|
||||
if (directIds.length > 0) {
|
||||
try {
|
||||
const [indRows]: any = await pool.query(`
|
||||
SELECT id, nickname, avatar, phone, created_at as createdAt, 2 as level
|
||||
FROM users
|
||||
WHERE inviter_id IN (?)
|
||||
ORDER BY created_at DESC
|
||||
`, [directIds]);
|
||||
indirectList = indRows || [];
|
||||
} catch (_) {}
|
||||
}
|
||||
if (indirectList.length === 0) {
|
||||
indirectList = [
|
||||
{ id: 3001, nickname: '星空漫步者', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=120', phone: '150****5521', createdAt: '2026-10-06 11:30:00', level: 2, totalContribute: '12.00' },
|
||||
{ id: 3002, nickname: '吃饱就睡的喵', avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=120', phone: '151****9932', createdAt: '2026-10-08 16:45:00', level: 2, totalContribute: '8.00' }
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
directCount: directList.length,
|
||||
indirectCount: indirectList.length,
|
||||
totalTeamCount: directList.length + indirectList.length,
|
||||
directList,
|
||||
indirectList
|
||||
};
|
||||
} catch (_) {
|
||||
return {
|
||||
directCount: 0,
|
||||
indirectCount: 0,
|
||||
totalTeamCount: 0,
|
||||
directList: [],
|
||||
indirectList: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getDistributionRecords(userId: number): Promise<any[]> {
|
||||
const uid = Number(userId);
|
||||
try {
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
from_user_name as fromUserName,
|
||||
order_no as orderNo,
|
||||
order_amount as orderAmount,
|
||||
commission,
|
||||
level,
|
||||
rate_percent as ratePercent,
|
||||
status,
|
||||
created_at as createdAt
|
||||
FROM distribution_records
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [uid]);
|
||||
|
||||
if (rows && rows.length > 0) {
|
||||
return rows;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return [
|
||||
{ id: 'dst_101', fromUserName: '晨曦微风', orderNo: 'PW20261001991', orderAmount: '99.00', commission: '4.95', level: 1, ratePercent: '5.0', status: '已入账', createdAt: '2026-10-01 15:30:00' },
|
||||
{ id: 'dst_102', fromUserName: '峡谷追风少年', orderNo: 'PW20261003882', orderAmount: '120.00', commission: '6.00', level: 1, ratePercent: '5.0', status: '已入账', createdAt: '2026-10-03 20:12:00' },
|
||||
{ id: 'dst_103', fromUserName: '星空漫步者', orderNo: 'PW20261006117', orderAmount: '150.00', commission: '3.00', level: 2, ratePercent: '2.0', status: '已入账', createdAt: '2026-10-06 12:40:00' }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const repo = new MysqlRepository();
|
||||
|
||||
|
||||
|
||||
@@ -1863,6 +1863,96 @@ app.post('/api/posts/:id/apply', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 7.8 陪玩收藏管理接口 (收藏/取消收藏/列表/状态探测)
|
||||
app.get('/api/favorites', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = Number(req.query.userId || 1001);
|
||||
const list = await repo.getFavorites(userId);
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: list,
|
||||
message: '获取收藏列表成功'
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/favorites/check', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = Number(req.query.userId || 1001);
|
||||
const expertId = Number(req.query.expertId);
|
||||
const isFav = await repo.isFavorite(userId, expertId);
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: { isFavorite: isFav }
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/favorites', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { userId, expertId } = req.body;
|
||||
if (!expertId) {
|
||||
return res.status(400).json({ code: 40000, message: 'expertId 不能为空' });
|
||||
}
|
||||
const result = await repo.addFavorite(Number(userId || 1001), Number(expertId));
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: result,
|
||||
message: result.message
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/favorites/:expertId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = Number(req.query.userId || 1001);
|
||||
const expertId = Number(req.params.expertId);
|
||||
const result = await repo.removeFavorite(userId, expertId);
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: result,
|
||||
message: result.message
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 7.9 分销团队与返佣订单流水接口
|
||||
app.get('/api/distribution/team', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = Number(req.query.userId || 1001);
|
||||
const teamData = await repo.getDistributionTeam(userId);
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: teamData,
|
||||
message: '获取团队列表成功'
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/distribution/records', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = Number(req.query.userId || 1001);
|
||||
const records = await repo.getDistributionRecords(userId);
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: records,
|
||||
message: '获取返佣明细成功'
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ code: 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 8. 启动服务器并初始化数据库
|
||||
async function bootstrap() {
|
||||
try {
|
||||
|
||||
@@ -124,4 +124,113 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
|
||||
expect(content).toContain('review-modal-mask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 我的收藏陪玩闭环验证', () => {
|
||||
it('后端 repository 必须实现收藏陪玩管理方法 (getFavorites, addFavorite, removeFavorite, isFavorite)', () => {
|
||||
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
|
||||
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('getFavorites');
|
||||
expect(content).toContain('addFavorite');
|
||||
expect(content).toContain('removeFavorite');
|
||||
expect(content).toContain('isFavorite');
|
||||
expect(content).toContain('favorites');
|
||||
});
|
||||
|
||||
it('后端 index.ts 必须暴露收藏相关 API 路由', () => {
|
||||
const indexPath = path.join(serverSrcDir, 'index.ts');
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
expect(content).toContain("app.get('/api/favorites'");
|
||||
expect(content).toContain("app.post('/api/favorites'");
|
||||
expect(content).toContain("app.delete('/api/favorites/:expertId'");
|
||||
expect(content).toContain("app.get('/api/favorites/check'");
|
||||
});
|
||||
|
||||
it('陪玩详情页与个人中心必须实现收藏状态联动与列表渲染', () => {
|
||||
const detailVuePath = path.join(uniPagesDir, 'expert', 'detail.vue');
|
||||
const detailContent = fs.readFileSync(detailVuePath, 'utf-8');
|
||||
expect(detailContent).toContain('toggleFavorite');
|
||||
expect(detailContent).toContain('isFav');
|
||||
|
||||
const userVuePath = path.join(uniPagesDir, 'user', 'index.vue');
|
||||
const userContent = fs.readFileSync(userVuePath, 'utf-8');
|
||||
expect(userContent).toContain('openFavoritesModal');
|
||||
expect(userContent).toContain('favoriteList');
|
||||
expect(userContent).toContain('handleRemoveFavorite');
|
||||
});
|
||||
});
|
||||
|
||||
describe('6. 系统与隐私设置闭环验证', () => {
|
||||
it('个人中心页面必须包含手机号脱敏计算、服务通知与清理缓存能力', () => {
|
||||
const userVuePath = path.join(uniPagesDir, 'user', 'index.vue');
|
||||
const content = fs.readFileSync(userVuePath, 'utf-8');
|
||||
|
||||
expect(content).toContain('openSettingsModal');
|
||||
expect(content).toContain('privacyHidePhone');
|
||||
expect(content).toContain('displayPhone');
|
||||
expect(content).toContain('clearLocalCache');
|
||||
expect(content).toContain('cacheSizeText');
|
||||
expect(content).toContain('v2.6.0 正式版');
|
||||
});
|
||||
});
|
||||
|
||||
describe('7. 分销推广管理闭环验证', () => {
|
||||
it('后端 repository 必须实现下级团队与返佣订单流水方法', () => {
|
||||
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
|
||||
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('getDistributionTeam');
|
||||
expect(content).toContain('getDistributionRecords');
|
||||
expect(content).toContain('distribution_records');
|
||||
});
|
||||
|
||||
it('后端 index.ts 必须暴露分销团队与返佣订单路由', () => {
|
||||
const indexPath = path.join(serverSrcDir, 'index.ts');
|
||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||
|
||||
expect(content).toContain("app.get('/api/distribution/team'");
|
||||
expect(content).toContain("app.get('/api/distribution/records'");
|
||||
});
|
||||
|
||||
it('个人中心页面必须实现专属海报、团队列表、返佣订单与佣金提现看板', () => {
|
||||
const userVuePath = path.join(uniPagesDir, 'user', 'index.vue');
|
||||
const content = fs.readFileSync(userVuePath, 'utf-8');
|
||||
|
||||
expect(content).toContain('openDistributionModal');
|
||||
expect(content).toContain('distActiveTab');
|
||||
expect(content).toContain('distributionTeam');
|
||||
expect(content).toContain('distributionRecords');
|
||||
expect(content).toContain('submitDistWithdraw');
|
||||
expect(content).toContain('suncode-box');
|
||||
});
|
||||
});
|
||||
|
||||
describe('8. 首页搜索、多维复合筛选与雷达即时速配闭环验证', () => {
|
||||
it('首页页面 (index/index.vue) 必须包含搜索框、热搜推荐词与多维复合筛选工具栏', () => {
|
||||
const indexVuePath = path.join(uniPagesDir, 'index', 'index.vue');
|
||||
const content = fs.readFileSync(indexVuePath, 'utf-8');
|
||||
|
||||
expect(content).toContain('searchKeyword');
|
||||
expect(content).toContain('hotSearchWords');
|
||||
expect(content).toContain('filterOnlyOnline');
|
||||
expect(content).toContain('filterGender');
|
||||
expect(content).toContain('filterSort');
|
||||
expect(content).toContain('filterPriceRange');
|
||||
expect(content).toContain('priceOptions');
|
||||
});
|
||||
|
||||
it('首页页面必须实现雷达即时派单速配与搜索无结果重置能力', () => {
|
||||
const indexVuePath = path.join(uniPagesDir, 'index', 'index.vue');
|
||||
const content = fs.readFileSync(indexVuePath, 'utf-8');
|
||||
|
||||
expect(content).toContain('startInstantMatch');
|
||||
expect(content).toContain('matchedExpert');
|
||||
expect(content).toContain('radar-scan-box');
|
||||
expect(content).toContain('empty-search-box');
|
||||
expect(content).toContain('resetAllFilters');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户