用户封禁、后台筛选排序

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

+20 -2
View File
@@ -11,6 +11,10 @@ Page({
hasCourse: false,
ownedCourses: [],
tipsHtml: '',
/** 账号被封禁:不显示已购权益,改为显示提示。文字全部来自后台配置 */
blocked: false,
blockedTitle: '',
blockedDesc: '',
},
onLoad() {
@@ -31,9 +35,23 @@ Page({
.getEntitlements()
.then((list) => {
store.setEntitlements(list);
this.setData({ ownedCourses: list || [], hasCourse: (list || []).length > 0 });
this.setData({ ownedCourses: list || [], hasCourse: (list || []).length > 0, blocked: false });
})
.catch((err) => console.warn('[kecheng] 已购课程加载失败', err));
.catch((err) => {
if (err && err.code === api.ACCOUNT_DISABLED) {
// 服务端不返回权益了,本地缓存也一并清掉,免得露出上一次的课程
store.setEntitlements([]);
this.setData({
ownedCourses: [],
hasCourse: false,
blocked: true,
blockedTitle: store.cfg('account.blocked_title'),
blockedDesc: store.cfg('account.blocked_desc'),
});
return;
}
console.warn('[kecheng] 已购课程加载失败', err);
});
},
loadTips() {
+9 -2
View File
@@ -1,5 +1,12 @@
<scroll-view class="container" scroll-y enable-flex type="custom">
<block wx:if="{{!hasCourse}}">
<block wx:if="{{blocked}}">
<view class="blocked-tip">
<text wx:if="{{blockedTitle}}" class="blocked-title">{{blockedTitle}}</text>
<text wx:if="{{blockedDesc}}" class="blocked-desc">{{blockedDesc}}</text>
</view>
</block>
<block wx:elif="{{!hasCourse}}">
<view style="width: 100%;height: 400rpx;">
<image style="margin-top: 30rpx; margin-left: 20rpx; height: 350rpx; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); width: 710rpx; border-radius: 20rpx; background-color: rgb(0, 255, 255);"
src="{{staticBase}}/Images/Banner/BGbanner.png" mode="aspectFill" />
@@ -7,7 +14,7 @@
<Parser html="{{tipsHtml}}" />
</block>
<block wx:if="{{hasCourse}}">
<block wx:elif="{{hasCourse}}">
<view wx:for="{{ownedCourses}}" wx:for-item="item" wx:for-index="index" wx:key="courseId"
class="kecheng" bind:tap="toMyCourse" data-code="{{item.code}}">
<view class="kecheng_video_up">
+26 -1
View File
@@ -46,4 +46,29 @@
font-size: 25rpx;
margin-top: 15rpx;
margin-left: 15rpx;
}
}
/* 账号异常提示的容器:整页替换成这一块,文字由后台配置下发 */
.blocked-tip {
box-sizing: border-box;
width: 710rpx;
margin-top: 120rpx;
padding: 60rpx 40rpx;
border: 1rpx solid #ffd9d9;
border-radius: 20rpx;
background-color: #fff7f7;
}
.blocked-title {
display: block;
font-size: 36rpx;
font-weight: bold;
text-align: center;
color: #e60012;
}
.blocked-desc {
display: block;
margin-top: 20rpx;
font-size: 26rpx;
text-align: center;
color: #888;
}
+12 -2
View File
@@ -14,6 +14,13 @@ const store = require('./store');
/** 进行中的会话恢复,避免并发 401 触发多次恢复流程 */
let recovering = null;
/**
* 服务端错误码:账号被封禁。
* 要和「登录过期」分开:页面据此走自己的提示分支,而不是把人赶去登录。
* 具体提示什么文字由后台配置决定,这里只管把错误码原样带出去。
*/
const ACCOUNT_DISABLED = 2003;
function staticUrl(path) {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
@@ -74,7 +81,10 @@ function request(options) {
if (needLogin && options.auth !== false && options.retried !== true) {
return recoverSession()
.then(() => request(Object.assign({}, options, { retried: true })))
.catch(() => {
.catch((err) => {
// 账号异常:把原因原样抛给页面,并且不清本地用户——
// 清掉的话知识页会把它当成未登录,只剩一片空白,看不出发生了什么
if (err && err.code === ACCOUNT_DISABLED) throw err;
store.reset();
throw { code: 2000, message: '登录已过期,请重新进入小程序' };
});
@@ -236,4 +246,4 @@ function requestVirtualPayment(payData) {
});
}
module.exports = Object.assign(api, { requestVirtualPayment, devicePlatform: platform });
module.exports = Object.assign(api, { requestVirtualPayment, devicePlatform: platform, ACCOUNT_DISABLED });
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { Button, Card, Empty, Input, Modal, Notice, Select } from '@/components/ui';
import { formatDate } from '@/lib/datetime';
import { formatDateTime } from '@/lib/datetime';
interface UserRow {
id: number;
@@ -44,6 +44,11 @@ export default function UsersPage() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
/** 筛选与排序:'-1' / '' 表示全部 */
const [statusFilter, setStatusFilter] = useState('-1');
const [entitlementFilter, setEntitlementFilter] = useState('');
const [sortKey, setSortKey] = useState('id');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [error, setError] = useState('');
const [editing, setEditing] = useState<UserRow | null>(null);
const [form, setForm] = useState({ nickname: '', headicon_id: '0', status: '1' });
@@ -54,22 +59,36 @@ export default function UsersPage() {
const load = useCallback((nextPage: number, nextKeyword: string) => {
setError('');
api
.listUsers(nextPage, PAGE_SIZE, nextKeyword)
.listUsers({
page: nextPage,
pageSize: PAGE_SIZE,
keyword: nextKeyword,
status: statusFilter === '-1' ? undefined : Number(statusFilter),
entitlement: entitlementFilter === 'has' || entitlementFilter === 'none' ? entitlementFilter : undefined,
sort: sortKey,
order: sortOrder,
})
.then((res) => {
setItems(res.items as unknown as UserRow[]);
setTotal(res.total);
setPage(res.page);
})
.catch((err: { message: string }) => setError(err.message));
}, []);
}, [statusFilter, entitlementFilter, sortKey, sortOrder]);
// 筛选 / 排序一变,load 的引用就变,这里会带着当前关键词回到第 1 页重拉
useEffect(() => {
load(1, keyword);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load]);
// 发放权益时用的课程下拉,只在首次进页面拉一次
useEffect(() => {
load(1, '');
api
.listResource('courses', { page: 1, pageSize: 100 })
.then((res) => setCourses(res.items as unknown as CourseRow[]))
.catch(() => setCourses([]));
}, [load]);
}, []);
const loadEntitlements = (userId: number) => {
api
@@ -141,8 +160,48 @@ export default function UsersPage() {
<Card
title={`共 ${total} 位用户`}
action={
<div className="flex gap-2">
<Input value={keyword} onChange={setKeyword} placeholder="昵称或 openid…" />
/* 筛选、排序、搜索排在一行;改任意一项都会从第 1 页重新拉 */
<div className="flex flex-nowrap items-center gap-2">
<div className="w-32">
<Select
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: -1, label: '全部状态' },
{ value: 1, label: '正常' },
{ value: 0, label: '封禁' },
]}
/>
</div>
<div className="w-36">
<Select
value={entitlementFilter}
onChange={setEntitlementFilter}
options={[
{ value: '', label: '全部用户' },
{ value: 'has', label: '有已购课程' },
{ value: 'none', label: '无已购课程' },
]}
/>
</div>
<div className="w-40">
<Select
value={sortKey}
onChange={setSortKey}
options={[
{ value: 'id', label: '按 ID' },
{ value: 'lastLogin', label: '按最后登录时间' },
{ value: 'registered', label: '按注册时间' },
{ value: 'entitlements', label: '按已购课程数' },
]}
/>
</div>
<Button variant="ghost" onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}>
{sortOrder === 'desc' ? '降序 ↓' : '升序 ↑'}
</Button>
<div className="w-52">
<Input value={keyword} onChange={setKeyword} placeholder="昵称或 openid…" />
</div>
<Button variant="ghost" onClick={() => load(1, keyword)}>
搜索
</Button>
@@ -176,7 +235,7 @@ export default function UsersPage() {
{u.status === 1 ? <span className="text-green-600">正常</span> : <span className="text-red-600">封禁</span>}
</td>
<td className="px-2 py-2 text-gray-500">
{formatDate(u.last_login_at)}
{formatDateTime(u.last_login_at)}
</td>
<td className="px-2 py-2 text-right">
<Button variant="ghost" onClick={() => openEdit(u)}>
+1 -1
View File
@@ -73,7 +73,7 @@ export function Select({
}: {
value: string;
onChange: (v: string) => void;
options: Array<{ value: number; label: string }>;
options: Array<{ value: number | string; label: string }>;
}) {
return (
<select
+13 -2
View File
@@ -121,8 +121,19 @@ export const api = {
body: JSON.stringify({ env }),
}),
listUsers: (page: number, pageSize: number, keyword: string) =>
request<Paged<Record<string, unknown>>>(`/admin/v1/users${qs({ page, pageSize, keyword })}`),
/** 用户列表。筛选和排序参数都会拼进 query,空值会被 qs 丢掉。 */
listUsers: (params: {
page: number;
pageSize: number;
keyword: string;
/** 1 正常 / 0 封禁 */
status?: number;
/** has 有已购课程 / none 没有 */
entitlement?: 'has' | 'none';
/** id / registered / lastLogin / entitlements */
sort?: string;
order?: 'asc' | 'desc';
}) => request<Paged<Record<string, unknown>>>(`/admin/v1/users${qs({ ...params })}`),
updateUser: (id: number, payload: Record<string, unknown>) =>
request<unknown>(`/admin/v1/users/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
+1 -1
View File
@@ -1 +1 @@
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
+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 [];