用户封禁、后台筛选排序
This commit is contained in:
1 parent
6b263af74f
commit
c45025fc86
14 files changed
+293
-30
No files matched your search
@@ -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 [];
|
||||
|
||||
|
||||
Reference in new issue
Block a user