用户封禁、后台筛选排序

This commit is contained in:
Yeomai committed 2026-10-01 09:12:39 +08:00
1 parent 6b263af74f
commit c45025fc86
14 files changed
+293 -30

No files matched your search

+6
View File
@@ -19,6 +19,8 @@ export const ErrorCode = {
UNAUTHORIZED: 2000,
TOKEN_EXPIRED: 2001,
FORBIDDEN: 2002,
/** 账号被封禁。和「未登录」分开:小程序要提示「用户状态异常」,而不是把人踢去登录 */
ACCOUNT_DISABLED: 2003,
NO_ENTITLEMENT: 3000,
COURSE_OFFLINE: 3001,
@@ -59,6 +61,10 @@ export class AppError extends HttpException {
return new AppError(ErrorCode.FORBIDDEN, message, 403);
}
static accountDisabled(message = '用户状态异常'): AppError {
return new AppError(ErrorCode.ACCOUNT_DISABLED, message, 403);
}
static noEntitlement(message = '尚未拥有该课程'): AppError {
return new AppError(ErrorCode.NO_ENTITLEMENT, message, 403);
}
@@ -93,8 +93,18 @@ export class AdminController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('entitlement') entitlement?: string,
@Query('sort') sort?: string,
@Query('order') order?: string,
) {
return this.admin.listUsers(env, Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
return this.admin.listUsers(env, Number(page) || 1, Number(pageSize) || 20, {
keyword,
status,
entitlement,
sort,
order,
});
}
@Patch('users/:id')
@@ -1,6 +1,6 @@
import { randomInt } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { PrismaClient } from '@prisma/client';
import type { Prisma, PrismaClient } from '@prisma/client';
import { z } from 'zod';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import type { DataSourceEnv } from '../../common/admin-env';
@@ -37,6 +37,68 @@ function notifyEventLabel(event: string | null): string {
/** 后台主动发放 / 回收产生的订单。不是支付单,列表里订单号显示为空。 */
const MANUAL_ORDER_STATUSES: number[] = [OrderStatus.ADMIN_GRANT, OrderStatus.ADMIN_REVOKE];
/** 用户列表允许的排序字段。只认这几个 key,避免把任意字符串透给 Prisma。 */
export const USER_SORT_KEYS = ['id', 'registered', 'lastLogin', 'entitlements'] as const;
export type UserSortKey = (typeof USER_SORT_KEYS)[number];
/** 用户列表的筛选与排序参数,直接来自 HTTP query,都是字符串。 */
export interface UserListQuery {
keyword?: string | null;
/** 账号状态:1 正常 / 0 封禁,空表示全部 */
status?: string | number | null;
/** 按有无已购课程筛:has 有 / none 没有,空表示全部 */
entitlement?: string | null;
sort?: string | null;
order?: string | null;
}
function isSortKey(value: string | null | undefined): value is UserSortKey {
return value !== null && value !== undefined && (USER_SORT_KEYS as readonly string[]).includes(value);
}
/**
* 用户列表的筛选与排序条件。
*
* 参数直接来自 HTTP query,所以这里负责把字符串收敛成合法值:认不出来的一律当没传。
* 抽成纯函数是为了能单测——字段名写错(比如把 last_login_at 拼错)只有跑起来才会暴露。
*/
export function buildUserListQuery(input: UserListQuery = {}): {
where: Prisma.UserWhereInput;
orderBy: Prisma.UserOrderByWithRelationInput;
} {
const keyword = (input.keyword ?? '').trim();
// 注意:不能用 Number(''),那是 0,会被误当成「封禁」
const statusText = input.status === null || input.status === undefined ? '' : String(input.status).trim();
const status = statusText === '0' || statusText === '1' ? Number(statusText) : undefined;
const entitlement = input.entitlement === 'has' || input.entitlement === 'none' ? input.entitlement : undefined;
const sort: UserSortKey = isSortKey(input.sort) ? input.sort : 'id';
const order: 'asc' | 'desc' = input.order === 'asc' ? 'asc' : 'desc';
const where: Prisma.UserWhereInput = {};
if (keyword) {
where.OR = [{ nickname: { contains: keyword } }, { openid: { contains: keyword } }];
}
if (status !== undefined) where.status = status;
if (entitlement === 'has') where.entitlements = { some: {} };
if (entitlement === 'none') where.entitlements = { none: {} };
let orderBy: Prisma.UserOrderByWithRelationInput;
switch (sort) {
case 'registered':
orderBy = { registered_at: order };
break;
case 'lastLogin':
orderBy = { last_login_at: order };
break;
case 'entitlements':
orderBy = { entitlements: { _count: order } };
break;
default:
orderBy = { id: order };
}
return { where, orderBy };
}
/** 通用委托接口。用最小结构约束代替 any,保持 strict 校验通过。 */
interface Delegate {
findMany: (args?: unknown) => Promise<unknown[]>;
@@ -149,18 +211,16 @@ export class AdminService {
// ── 用户 ───────────────────────────────────────────────────────────────
async listUsers(env: DataSourceEnv, page = 1, pageSize = 20, keyword = '') {
async listUsers(env: DataSourceEnv, page = 1, pageSize = 20, query: UserListQuery = {}) {
const db = this.prisma.clientFor(env);
const size = Math.min(Math.max(pageSize, 1), 100);
const current = Math.max(page, 1);
const where = keyword
? { OR: [{ nickname: { contains: keyword } }, { openid: { contains: keyword } }] }
: {};
const { where, orderBy } = buildUserListQuery(query);
const [items, total] = await db.$transaction([
db.user.findMany({
where,
orderBy: { id: 'desc' },
orderBy,
skip: (current - 1) * size,
take: size,
include: { _count: { select: { entitlements: true } } },
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { buildUserListQuery } from './admin.service';
describe('用户列表的筛选与排序', () => {
it('什么都不传时按 ID 倒序,且没有任何筛选条件', () => {
expect(buildUserListQuery()).toEqual({ where: {}, orderBy: { id: 'desc' } });
});
it('关键词同时匹配昵称与 openid,并去掉首尾空格', () => {
expect(buildUserListQuery({ keyword: ' Yeomai ' }).where).toEqual({
OR: [{ nickname: { contains: 'Yeomai' } }, { openid: { contains: 'Yeomai' } }],
});
});
it('状态筛选:空串不能当成「封禁」', () => {
// Number('') 是 0,一不小心就会把「全部」当成「封禁」
expect(buildUserListQuery({ status: '' }).where).toEqual({});
expect(buildUserListQuery({ status: '0' }).where).toEqual({ status: 0 });
expect(buildUserListQuery({ status: 1 }).where).toEqual({ status: 1 });
expect(buildUserListQuery({ status: '9' }).where).toEqual({});
});
it('按有无已购课程筛,认不出来的值当没传', () => {
expect(buildUserListQuery({ entitlement: 'has' }).where).toEqual({ entitlements: { some: {} } });
expect(buildUserListQuery({ entitlement: 'none' }).where).toEqual({ entitlements: { none: {} } });
expect(buildUserListQuery({ entitlement: '乱填的' }).where).toEqual({});
});
it('排序只认白名单,认不出来退回 ID', () => {
expect(buildUserListQuery({ sort: 'lastLogin' }).orderBy).toEqual({ last_login_at: 'desc' });
expect(buildUserListQuery({ sort: 'registered', order: 'asc' }).orderBy).toEqual({ registered_at: 'asc' });
expect(buildUserListQuery({ sort: 'entitlements' }).orderBy).toEqual({
entitlements: { _count: 'desc' },
});
expect(buildUserListQuery({ sort: 'rm -rf' }).orderBy).toEqual({ id: 'desc' });
expect(buildUserListQuery({ order: '乱填的' }).orderBy).toEqual({ id: 'desc' });
});
it('筛选与排序可以叠加', () => {
expect(buildUserListQuery({ keyword: 'a', status: '1', entitlement: 'has', sort: 'lastLogin', order: 'asc' }))
.toEqual({
where: {
OR: [{ nickname: { contains: 'a' } }, { openid: { contains: 'a' } }],
status: 1,
entitlements: { some: {} },
},
orderBy: { last_login_at: 'asc' },
});
});
});
@@ -66,8 +66,6 @@ export class AuthService {
},
});
if (user.status !== 1) throw AppError.forbidden('账号已被封禁');
return {
accessToken: await this.sign(user.id, 'user', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET),
refreshToken: await this.sign(user.id, 'user', REFRESH_TTL, this.cfg.JWT_REFRESH_SECRET),
@@ -97,7 +95,7 @@ export class AuthService {
throw AppError.unauthorized('登录已过期,请重新进入小程序');
}
const user = await this.prisma.active.user.findUnique({ where: { id: sub } });
if (!user || user.status !== 1) throw AppError.unauthorized();
if (!user) throw AppError.unauthorized();
return { accessToken: await this.sign(user.id, 'user', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET) };
}
@@ -14,7 +14,7 @@ export class UsersService {
async me(userId: number) {
const user = await this.prisma.active.user.findUnique({ where: { id: userId } });
if (!user || user.status !== 1) throw AppError.unauthorized();
if (!user) throw AppError.unauthorized();
const base = this.cfg.STATIC_BASE_URL;
return {
@@ -60,6 +60,15 @@ export class UsersService {
/** 我的课程。 */
async entitlements(userId: number) {
// 被封禁的账号不返回任何权益:知识页据此显示「用户状态异常」,
// 而不是继续把已购课程列出来(token 在有效期内时,光靠鉴权拦不住)
const user = await this.prisma.active.user.findUnique({
where: { id: userId },
select: { status: true },
});
if (!user) throw AppError.unauthorized();
if (user.status !== 1) throw AppError.accountDisabled();
// 小程序读开发库且开关关闭时,已购课程列表直接返回空
if (!(await showOwnedCourses(this.prisma))) return [];