fix(withdraw): resolve remote 404 route, db reason column patch and avatar tmp path fallback

这个提交包含在:
xcbyy
2026-10-13 09:33:29 +08:00
父节点 1fb0cc4273
当前提交 35a5a6670c
共有 8 个文件被更改,包括 2648 次插入 和 64 次删除

查看文件

@@ -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');
});
});
});