feat(list): 订单/通知/组局列表分页,生产 NODE_ENV 切换 production

- 仓储层 getOrders/getOrdersForUser/getActivities/getUserNotifications 支持 LIMIT/OFFSET,新增 countOrders/countOrdersForUser/countActivities/getUserNotificationTotal

- 接口支持 page/pageSize/status/keyword/payType/creatorId/category,返回 list/total/hasMore;未传 page 保持旧数组格式,兼容后台与工作台

- 小程序订单/通知/组局三页触底加载,筛选与搜索下推服务端,避免只过滤当前页

- 新增 116 套件/758 用例,全绿;线上实测分页与筛选接口正常
这个提交包含在:
xcbyy
2026-10-22 10:52:40 +08:00
父节点 082ec1c385
当前提交 d876288394
共有 9 个文件被更改,包括 580 次插入 和 108 次删除

查看文件

@@ -55,8 +55,88 @@ export interface ActivityRow {
}
export class MysqlRepository {
/** 分页参数归一化:page 从 1 开始,pageSize 默认 20(最大 100) */
private normalizePage(page: any, pageSize: any, defaultSize = 20): { page: number; pageSize: number; offset: number } {
const p = Math.max(1, Math.floor(Number(page) || 1));
const s = Math.min(100, Math.max(1, Math.floor(Number(pageSize) || defaultSize)));
return { page: p, pageSize: s, offset: (p - 1) * s };
}
/** 活动列表筛选条件(列表与计数共用,保证 total 与 list 口径一致) */
private buildActivityFilters(opts: {
keyword?: string;
payType?: string;
creatorId?: number;
category?: string;
categoryName?: string;
} = {}): { where: string; params: any[] } {
const conds: string[] = ["(u.status IS NULL OR u.status != '已封禁')"];
const params: any[] = [];
const keyword = String(opts.keyword || '').trim();
if (keyword) {
const like = `%${keyword}%`;
conds.push('(a.title LIKE ? OR a.location LIKE ? OR a.game_category LIKE ? OR u.nickname LIKE ?)');
params.push(like, like, like, like);
}
const payType = String(opts.payType || '').toUpperCase();
if (payType === 'AA') {
conds.push('a.pay_type = ?');
params.push(1);
} else if (payType === 'PROJECT') {
conds.push('a.pay_type = ?');
params.push(2);
}
const creatorId = Number(opts.creatorId) || 0;
if (creatorId > 0) {
conds.push('a.creator_id = ?');
params.push(creatorId);
}
const category = String(opts.category || '').toUpperCase();
if (category && category !== 'ALL') {
if (category === 'MEAL') {
conds.push('(a.game_category LIKE ? OR a.title LIKE ? OR a.game_category LIKE ?)');
params.push('%美食%', '%美食%', '%MEAL%');
} else if (category === 'SING') {
conds.push('(a.game_category LIKE ? OR a.title LIKE ? OR a.game_category LIKE ?)');
params.push('%陪唱%', '%陪唱%', '%SING%');
} else if (category === 'DRINK' || category === 'BAR') {
conds.push('(a.game_category LIKE ? OR a.game_category LIKE ? OR a.title LIKE ? OR a.game_category LIKE ? OR a.game_category LIKE ?)');
params.push('%下午茶%', '%微醺%', '%下午茶%', '%DRINK%', '%BAR%');
} else if (category === 'ESPORTS') {
conds.push('(a.game_category LIKE ? OR a.game_category LIKE ? OR a.title LIKE ? OR a.game_category LIKE ?)');
params.push('%电竞%', '%游戏%', '%电竞%', '%ESPORTS%');
} else {
const name = String(opts.categoryName || '').trim() || category;
const like = `%${name}%`;
conds.push('(a.game_category LIKE ? OR a.title LIKE ?)');
params.push(like, like);
}
}
return { where: conds.join(' AND '), params };
}
// 活动相关
async getActivities(): Promise<ActivityRow[]> {
async getActivities(opts: {
page?: number;
pageSize?: number;
keyword?: string;
payType?: string;
creatorId?: number;
category?: string;
categoryName?: string;
} = {}): Promise<ActivityRow[]> {
const { where, params } = this.buildActivityFilters(opts);
const paged = Number(opts.page) > 0;
let limitSql = '';
if (paged) {
const { pageSize, offset } = this.normalizePage(opts.page, opts.pageSize);
limitSql = ` LIMIT ${pageSize} OFFSET ${offset}`;
}
const [actRows]: any = await pool.query(`
SELECT
a.id, a.creator_id as creatorId, a.title, a.game_category as gameCategory,
@@ -69,9 +149,9 @@ export class MysqlRepository {
u.avatar as creatorAvatar
FROM activities a
LEFT JOIN users u ON a.creator_id = u.id
WHERE (u.status IS NULL OR u.status != '已封禁')
ORDER BY a.created_at DESC
`);
WHERE ${where}
ORDER BY a.created_at DESC${limitSql}
`, params);
const [ptRows]: any = await pool.query(`
SELECT
@@ -103,6 +183,23 @@ export class MysqlRepository {
});
}
async countActivities(opts: {
keyword?: string;
payType?: string;
creatorId?: number;
category?: string;
categoryName?: string;
} = {}): Promise<number> {
const { where, params } = this.buildActivityFilters(opts);
const [rows]: any = await pool.query(`
SELECT COUNT(*) as c
FROM activities a
LEFT JOIN users u ON a.creator_id = u.id
WHERE ${where}
`, params);
return Number((rows && rows[0] && rows[0].c) || 0);
}
async getActivityById(id: string): Promise<any | null> {
const [actRows]: any = await pool.query(`
SELECT
@@ -1320,8 +1417,34 @@ export class MysqlRepository {
}
// 订单相关
async getOrders(): Promise<any[]> {
/** 订单状态分组(列表 tab 与计数共用;状态值为库内中文枚举) */
private orderStatusFilter(group?: string): string {
switch (String(group || '').toUpperCase()) {
case 'PENDING_PAY':
return "status = '待付款'";
case 'PENDING_ACCEPT':
return "status = '待接单'";
case 'IN_SERVICE':
return "status IN ('已接单','履约中','服务中','已到场核销','待确认')";
case 'COMPLETED':
return "status = '已完成'";
case 'REFUNDING':
return "status IN ('争议仲裁中','退款中','退款审核中','已退款','已全额退款','已部分退款','售后处理中')";
case 'CANCELLED':
return "status = '已取消'";
default:
return '1=1';
}
}
async getOrders(opts: { page?: number; pageSize?: number; status?: string } = {}): Promise<any[]> {
// 订单超时自动流转统一由后台定时任务处理(见 index.ts startOrderTimeoutScheduler)
const statusWhere = this.orderStatusFilter(opts.status);
let limitSql = 'LIMIT 500';
if (Number(opts.page) > 0) {
const { pageSize, offset } = this.normalizePage(opts.page, opts.pageSize);
limitSql = `LIMIT ${pageSize} OFFSET ${offset}`;
}
const [rows]: any = await pool.query(`
SELECT
id,
@@ -1348,18 +1471,34 @@ export class MysqlRepository {
finish_applied_at as finishAppliedAt,
created_at as createdAt
FROM orders
WHERE ${statusWhere}
ORDER BY created_at DESC
LIMIT 500
${limitSql}
`);
return rows;
}
async countOrders(opts: { status?: string } = {}): Promise<number> {
const statusWhere = this.orderStatusFilter(opts.status);
const [rows]: any = await pool.query(`SELECT COUNT(*) as c FROM orders WHERE ${statusWhere}`);
return Number((rows && rows[0] && rows[0].c) || 0);
}
/**
* 仅返回与指定用户相关的订单(买家 / 承接大神 / 已认领 / 其大神档案对应订单)。
* 用于替代把全平台订单下发给任意客户端,避免订单隐私与核销码泄露。
*/
async getOrdersForUser(userId: number): Promise<any[]> {
async getOrdersForUser(
userId: number,
opts: { page?: number; pageSize?: number; status?: string } = {}
): Promise<any[]> {
const uid = Number(userId) || 0;
const statusWhere = this.orderStatusFilter(opts.status);
let limitSql = 'LIMIT 500';
if (Number(opts.page) > 0) {
const { pageSize, offset } = this.normalizePage(opts.page, opts.pageSize);
limitSql = `LIMIT ${pageSize} OFFSET ${offset}`;
}
// 订单超时自动流转统一由后台定时任务处理(见 index.ts startOrderTimeoutScheduler)
const [rows]: any = await pool.query(`
SELECT
@@ -1387,16 +1526,37 @@ export class MysqlRepository {
finish_applied_at as finishAppliedAt,
created_at as createdAt
FROM orders
WHERE buyer_id = ?
OR expert_id = ?
OR claimed_by = ?
OR companion_name IN (SELECT name FROM companions WHERE user_id = ? OR id = ?)
WHERE (
buyer_id = ?
OR expert_id = ?
OR claimed_by = ?
OR companion_name IN (SELECT name FROM companions WHERE user_id = ? OR id = ?)
)
AND ${statusWhere}
ORDER BY created_at DESC
LIMIT 500
${limitSql}
`, [uid, uid, uid, uid, uid]);
return rows;
}
async countOrdersForUser(userId: number, opts: { status?: string } = {}): Promise<number> {
const uid = Number(userId) || 0;
if (uid <= 0) return 0;
const statusWhere = this.orderStatusFilter(opts.status);
const [rows]: any = await pool.query(`
SELECT COUNT(*) as c
FROM orders
WHERE (
buyer_id = ?
OR expert_id = ?
OR claimed_by = ?
OR companion_name IN (SELECT name FROM companions WHERE user_id = ? OR id = ?)
)
AND ${statusWhere}
`, [uid, uid, uid, uid, uid]);
return Number((rows && rows[0] && rows[0].c) || 0);
}
async createOrder(data: {
buyerId?: number;
clientName?: string;
@@ -3444,21 +3604,32 @@ export class MysqlRepository {
return { id };
}
async getUserNotifications(userId: number, limit = 50): Promise<any[]> {
async getUserNotifications(userId: number, limit = 50, offset = 0): Promise<any[]> {
const uid = Number(userId) || 0;
if (uid <= 0) return [];
const take = Math.min(200, Math.max(1, Number(limit) || 50));
const skip = Math.max(0, Math.floor(Number(offset) || 0));
const [rows]: any = await pool.query(
`SELECT id, type, title, content, link, is_read as isRead, created_at as createdAt
FROM user_notifications
WHERE user_id = ?
ORDER BY created_at DESC, id DESC
LIMIT ${take}`,
LIMIT ${take} OFFSET ${skip}`,
[uid]
);
return rows;
}
async getUserNotificationTotal(userId: number): Promise<number> {
const uid = Number(userId) || 0;
if (uid <= 0) return 0;
const [rows]: any = await pool.query(
'SELECT COUNT(*) as c FROM user_notifications WHERE user_id = ?',
[uid]
);
return Number((rows && rows[0] && rows[0].c) || 0);
}
async getUserUnreadNotificationCount(userId: number): Promise<number> {
const uid = Number(userId) || 0;
if (uid <= 0) return 0;

查看文件

@@ -898,8 +898,25 @@ app.get('/api/notifications', async (req: Request, res: Response) => {
Number(req.query.userId || req.headers['x-user-id'] || 0),
'查看消息通知'
);
const list = await repo.getUserNotifications(uid, Number(req.query.limit) || 50);
const unread = await repo.getUserUnreadNotificationCount(uid);
if (Number(req.query.page) > 0) {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(req.query.pageSize) || 20)));
const total = await repo.getUserNotificationTotal(uid);
const list = await repo.getUserNotifications(uid, pageSize, (page - 1) * pageSize);
return res.json({
code: 20000,
data: {
list,
unread,
total,
page,
pageSize,
hasMore: (page - 1) * pageSize + list.length < total,
},
});
}
const list = await repo.getUserNotifications(uid, Number(req.query.limit) || 50);
res.json({ code: 20000, data: { list, unread } });
} catch (err: any) {
const statusCode = err.status || 400;
@@ -952,12 +969,33 @@ app.get('/api/dashboard/todos', async (_req: Request, res: Response) => {
});
// 3. 活动与4位核销码相关接口 (MySQL 持久化)
app.get('/api/activities', async (_req: Request, res: Response) => {
app.get('/api/activities', async (req: Request, res: Response) => {
try {
const acts = await repo.getActivities();
const filters = {
keyword: String(req.query.keyword || '').trim(),
payType: String(req.query.payType || '').trim(),
creatorId: Number(req.query.creatorId) || 0,
category: String(req.query.category || '').trim(),
categoryName: String(req.query.categoryName || '').trim(),
};
const paged = Number(req.query.page) > 0;
if (!paged) {
const acts = await repo.getActivities(filters);
return res.json({ code: 20000, data: acts });
}
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(req.query.pageSize) || 20)));
const acts = await repo.getActivities({ ...filters, page, pageSize });
const total = await repo.countActivities(filters);
res.json({
code: 20000,
data: acts
data: {
list: acts,
total,
page,
pageSize,
hasMore: (page - 1) * pageSize + acts.length < total,
},
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
@@ -1458,12 +1496,31 @@ app.put('/api/companions/:id/profile', async (req: Request, res: Response) => {
// 5. 订单与仲裁接口 (落盘 MySQL)
app.get('/api/orders', async (req: Request, res: Response) => {
try {
const status = String(req.query.status || '').trim();
const paged = Number(req.query.page) > 0;
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(req.query.pageSize) || 20)));
const queryOpts = paged ? { page, pageSize, status } : { status };
// 管理端(服务端签名 admin token)可取全量;普通用户仅能取与自己相关的订单
if (isVerifiedAdminRequest(req)) {
const orders = await repo.getOrders();
const orders = await repo.getOrders(queryOpts);
if (!paged) {
return res.json({
code: 20000,
data: orders
});
}
const total = await repo.countOrders({ status });
return res.json({
code: 20000,
data: orders
data: {
list: orders,
total,
page,
pageSize,
hasMore: (page - 1) * pageSize + orders.length < total,
}
});
}
const uid = resolveSecureUserId(
@@ -1471,10 +1528,23 @@ app.get('/api/orders', async (req: Request, res: Response) => {
Number(req.query.userId || req.headers['x-user-id'] || 0),
'查看订单列表'
);
const orders = await repo.getOrdersForUser(uid);
const orders = await repo.getOrdersForUser(uid, queryOpts);
if (!paged) {
return res.json({
code: 20000,
data: orders
});
}
const total = await repo.countOrdersForUser(uid, { status });
res.json({
code: 20000,
data: orders
data: {
list: orders,
total,
page,
pageSize,
hasMore: (page - 1) * pageSize + orders.length < total,
}
});
} catch (err: any) {
const statusCode = err.status || 500;

查看文件

@@ -515,7 +515,7 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
const repoContent = fs.readFileSync(repoPath, 'utf-8');
expect(repoContent).toContain("WHERE (u.status IS NULL OR u.status != '已封禁')");
expect(repoContent).toContain("(u.status IS NULL OR u.status != '已封禁')");
expect(repoContent).toContain("UPDATE activities SET status = 'CANCELLED' WHERE creator_id = ? AND status = 'RECRUITING'");
const rolePath = path.join(rootDir, 'pwuniapp', 'common', 'role.js');

查看文件

@@ -0,0 +1,60 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 列表分页闸门:订单 / 消息通知 / 组局
* 要求:服务端支持 page/pageSize 并返回 total/hasMore;不传 page 时保持旧数组格式兼容;
* 小程序三页必须支持触底加载更多。
*/
describe('列表分页(订单 / 通知 / 组局)', () => {
const root = path.resolve(__dirname, '../../');
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
const repo = read('server/src/db/repository.ts');
const indexCode = read('server/src/index.ts');
const api = read('pwuniapp/common/api.js');
const orderList = read('pwuniapp/pages/order/list.vue');
const notifications = read('pwuniapp/pages/user/notifications.vue');
const activityList = read('pwuniapp/pages/activity/list.vue');
it('仓储层必须使用 LIMIT/OFFSET 分页,而不是一次拉全量', () => {
expect(repo).toContain('LIMIT ${pageSize} OFFSET ${offset}');
expect(repo).toContain('LIMIT ${take} OFFSET ${skip}');
expect(repo).toContain('async countOrders(');
expect(repo).toContain('async countOrdersForUser(');
expect(repo).toContain('async countActivities(');
expect(repo).toContain('async getUserNotificationTotal(');
});
it('接口必须返回 total/hasMore,未传 page 时保持旧数组格式', () => {
expect(indexCode).toContain("if (Number(req.query.page) > 0)");
expect(indexCode).toContain('hasMore: (page - 1) * pageSize');
// 旧调用(管理后台 / 工作台)不传 page 仍拿数组
expect(indexCode).toContain('const paged = Number(req.query.page) > 0;');
expect(indexCode).toContain('data: orders');
expect(indexCode).toContain('data: acts');
});
it('小程序 API 层传递分页参数并保持旧调用兼容', () => {
expect(api).toContain('page=${Math.floor(Number(options.page))}');
expect(api).toContain('pageSize=${Math.floor(Number(options.pageSize) || 20)}');
expect(api).toContain("return paged ? { list: [], total: 0, page: 1, pageSize: 20, hasMore: false } : [];");
expect(api).toContain('if (Number(extra.page) > 0)');
});
it('订单 / 通知 / 组局页面必须支持触底加载更多', () => {
expect(orderList).toContain('onReachBottom');
expect(orderList).toContain('loadLiveOrders(false)');
expect(notifications).toContain('@scrolltolower="loadMore"');
expect(notifications).toContain('loadData(false)');
expect(activityList).toContain('onReachBottom');
expect(activityList).toContain('fetchLiveActivities(false)');
});
it('筛选与搜索必须下推到服务端,避免只过滤当前页造成漏数据', () => {
expect(orderList).toContain('status: currentTab.value');
expect(activityList).toContain('keyword: searchKeyword.value.trim()');
expect(activityList).toContain('payType: currentTab.value');
expect(activityList).toContain('creatorId: currentTab.value');
});
});

查看文件

@@ -21,16 +21,16 @@ describe('第十二批读接口数据隔离闸门 (订单 / 我的组局 / 分
it('仓储层提供按用户隔离的 getOrdersForUser', () => {
expect(repoCode).toContain('async getOrdersForUser(');
const start = repoCode.indexOf('async getOrdersForUser(');
const block = repoCode.slice(start, start + 1400);
const block = repoCode.slice(start, start + 2200);
expect(block).toContain('buyer_id = ?');
expect(block).toContain('claimed_by = ?');
expect(block).toContain('companion_name IN (SELECT name FROM companions');
});
it('订单列表:管理端全量,普通用户仅本人相关', () => {
const block = routeBlock("app.get('/api/orders',");
const block = routeBlock("app.get('/api/orders',", 1800);
expect(block).toContain('isVerifiedAdminRequest(req)');
expect(block).toContain('repo.getOrdersForUser(uid)');
expect(block).toContain('repo.getOrdersForUser(uid,');
expect(block).toContain("'查看订单列表'");
});
});
@@ -50,7 +50,7 @@ describe('第十二批读接口数据隔离闸门 (订单 / 我的组局 / 分
describe('3. 小程序端订单列表携带当前用户身份', () => {
it('getOrders 必须带上当前用户 id', () => {
const start = apiCode.indexOf('export async function getOrders(');
const block = apiCode.slice(start, start + 300);
const block = apiCode.slice(start, start + 900);
expect(block).toContain('/orders?userId=${normalizeUserId()}');
});
});