07 管理员账号管理
完成登录和鉴权后,本章实现管理员自己的账号操作,以及管理员之间的基本管理。重点包括密码修改、撤销旧 JWT、分页查询、创建账号、禁用账号和重置密码。
本章仍遵循:业务错误返回 HTTP 200,具体结果由 JSON code 表达。生产监控必须额外统计业务码。
1. 接口总览
| 方法 | 路径 | 作用 |
|---|---|---|
GET |
/api/auth/me |
获取当前管理员 |
PATCH |
/api/auth/password |
修改自己的密码 |
POST |
/api/auth/logout-all |
让自己的全部旧 Token 失效 |
GET |
/api/admin/users |
管理员分页列表 |
POST |
/api/admin/users |
创建管理员 |
PATCH |
/api/admin/users/:adminId/status |
启用或禁用管理员 |
POST |
/api/admin/users/:adminId/reset-password |
重置管理员密码 |
除登录外,本章所有接口都必须经过 authenticateAdmin。
2. 获取当前管理员
HTTP 契约
GET /api/auth/me
Authorization: Bearer <access-token>
{
"code": "OK",
"message": "success",
"data": {
"id": "1",
"username": "admin",
"displayName": "系统管理员",
"email": "admin@example.com",
"accountStatus": 1,
"lastLoginAt": "2026-08-28T02:10:00.000Z",
"createdAt": "2026-08-20T01:00:00.000Z"
}
}
Service 与 Controller 骨架
export async function getAdminProfile(
adminId: string,
): Promise<AdminUserDto> {
// TODO:查询公开字段并转换 DTO
throw new Error("TODO");
}
export async function getMyProfileController(
req: Request<
Record<string, never>,
ApiResponse<AdminUserDto>
>,
res: Response<ApiResponse<AdminUserDto>>,
): Promise<void> {
if (!req.admin) {
throw new AppError("AUTH_REQUIRED", "请先登录");
}
const admin = await getAdminProfile(req.admin.id);
res.json({ code: "OK", message: "success", data: admin });
}
不要直接:
res.json({ code: "OK", data: adminModel });
Model 可能意外带出 passwordHash、tokenVersion 或失败次数。
3. 修改自己的密码
请求类型与契约
export interface ChangePasswordBody {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
PATCH /api/auth/password
Authorization: Bearer <access-token>
Content-Type: application/json
{
"currentPassword": "旧密码",
"newPassword": "新的强密码",
"confirmPassword": "新的强密码"
}
成功:
{
"code": "OK",
"message": "密码修改成功,请重新登录",
"data": null
}
validator 骨架
export const changePasswordValidation = [
body("currentPassword")
.isString()
.withMessage("当前密码格式错误")
.bail()
// TODO:合理长度上限
,
body("newPassword")
.isString()
.withMessage("新密码格式错误")
.bail()
// TODO:长度和强度规则
,
body("confirmPassword")
.custom((value, { req }) => {
// TODO:与 newPassword 比较
return true;
}),
];
校验层负责格式和两次输入一致;Service 负责验证当前密码、比较新旧密码和更新账号。
Service 骨架
export interface ChangePasswordInput {
currentPassword: string;
newPassword: string;
}
export async function changeAdminPassword(
adminId: string,
input: ChangePasswordInput,
): Promise<void> {
// TODO 1:显式查询 passwordHash
// TODO 2:argon2.verify 当前密码
// TODO 3:拒绝新旧密码相同
// TODO 4:在事务外完成较昂贵的新密码 Hash
// TODO 5:在同一个短事务中更新 passwordHash、passwordChangedAt
// TODO 6:在该事务中将 tokenVersion 原子加 1,让全部旧 Token 失效
}
为什么建议先 Hash 再开启短事务:Argon2 是特意设计为耗时、耗内存的密码算法。如果在持有数据库行锁时执行 Hash,会无谓延长锁时间。
密码字段与 tokenVersion 必须原子提交。否则可能出现“密码已经修改,但版本递增失败,旧 JWT 仍有效”的中间状态。
修改成功后,本次请求仍能完成;但下一次携带旧 Token 请求时,数据库 tokenVersion 已不匹配,应要求重新登录。
4. 退出所有设备
JWT 无状态意味着服务器不能像传统 Session 那样简单删除某个内存对象。本练习用 tokenVersion 撤销某管理员之前签发的所有 Token。
HTTP 契约
POST /api/auth/logout-all
Authorization: Bearer <access-token>
{
"code": "OK",
"message": "已退出所有设备",
"data": null
}
Service 骨架
export async function logoutAdminFromAllDevices(
adminId: string,
): Promise<void> {
// TODO:使用原子 increment 更新 tokenVersion
}
logout-all 不会让浏览器自动删除本地 Token,前端仍应删除自己的 Token;数据库版本检查负责拒绝遗漏在其他设备上的旧 Token。
本设计不能只撤销某一个设备的 Token。单设备退出通常由前端删除短期 Access Token 完成;如果要精细撤销,需要引入 Refresh Token 会话表,不属于本阶段必做内容。
5. 管理员列表
查询类型
export interface AdminListQuery {
page?: string;
pageSize?: string;
username?: string;
accountStatus?: string;
sort?: "id" | "username" | "createdAt" | "lastLoginAt";
order?: "asc" | "desc";
}
export interface FindAdminsOptions {
page: number;
pageSize: number;
username?: string;
accountStatus?: AdminAccountStatus;
sort: "id" | "username" | "createdAt" | "lastLoginAt";
order: "asc" | "desc";
}
Service 骨架
export async function findAdmins(
options: FindAdminsOptions,
): Promise<PageResult<AdminUserDto>> {
// TODO:findAndCountAll、limit、offset、稳定排序、字段白名单
throw new Error("TODO");
}
必要要求:
pageSize最大值例如 100;- 排序字段只能来自白名单,不能直接使用任意 query 字符串;
- 返回字段不包含密码摘要和 Token 版本;
- 对同值排序增加
id作为第二排序字段; total来自 count,不是当前页list.length。
6. 创建其他管理员
Body 与响应
export interface CreateAdminBody {
username: string;
password: string;
displayName: string;
email?: string | null;
}
POST /api/admin/users
Authorization: Bearer <access-token>
Content-Type: application/json
{
"username": "store-manager",
"password": "初始强密码",
"displayName": "门店管理员",
"email": "manager@example.com"
}
Service 骨架
export interface CreateAdminInput {
username: string;
password: string;
displayName: string;
email: string | null;
}
export async function createAdmin(
input: CreateAdminInput,
context: { operatedByAdminId: string },
): Promise<AdminUserDto> {
// TODO 1:白名单提取并规范化输入
// TODO 2:Argon2id Hash(不要保存明文)
// TODO 3:创建 AdminUser
// TODO 4:映射唯一约束错误
// TODO 5:返回公开 DTO
throw new Error("TODO");
}
不要只依赖“先查用户名是否存在”。两个并发请求都可能查到不存在,然后同时创建;最终仍需依赖数据库唯一约束并映射错误:
{
"code": "ADMIN_USERNAME_EXISTS",
"message": "用户名已存在",
"data": null
}
不要在响应和日志中返回初始密码。如果业务需要把密码交给新管理员,应通过受控的线下流程或一次性设置密码流程;这部分作为进阶设计。
7. 启用或禁用管理员
类型与契约
export interface AdminIdParams {
adminId: string;
}
export interface UpdateAdminStatusBody {
accountStatus: AdminAccountStatus.Active
| AdminAccountStatus.Disabled;
}
PATCH /api/admin/users/12/status
Authorization: Bearer <access-token>
Content-Type: application/json
{
"accountStatus": 0
}
Service 骨架
export async function updateAdminStatus(
targetAdminId: string,
accountStatus: AdminAccountStatus,
context: { operatedByAdminId: string },
): Promise<AdminUserDto> {
// TODO 1:查询目标账号
// TODO 2:处理不存在、状态未变化
// TODO 3:执行自禁用策略
// TODO 4:防止禁用最后一个 Active 管理员
// TODO 5:在同一个事务中完成状态变更与 tokenVersion + 1
// TODO 6:返回公开 DTO,并记录审计信息
throw new Error("TODO");
}
自己禁用自己
最适合新人阶段的规则是:
不允许管理员通过该接口禁用自己。
返回:
{
"code": "CANNOT_DISABLE_SELF",
"message": "不能禁用当前登录账号",
"data": null
}
这样可以减少误操作。即使未来允许,也要明确当前 Token 立即失效及前端跳转登录页的行为。
最后一个可用管理员
如果唯一的 Active 管理员被禁用,系统将无人能够登录恢复。业务规则应阻止:
{
"code": "LAST_ACTIVE_ADMIN",
"message": "不能禁用最后一个可用管理员",
"data": null
}
“先 count,再 update”存在并发窗口:两个管理员可能同时看到 Active 数量为 2,然后互相禁用。进阶任务是研究事务、行锁或更强的系统恢复机制。不要假装一次普通 count 已经彻底解决并发问题。
禁用时递增 tokenVersion,能让目标管理员已有 Token 在下一次请求时失效。重新启用不应恢复旧 Token。
8. 重置其他管理员密码
HTTP 契约
export interface ResetAdminPasswordBody {
newPassword: string;
confirmPassword: string;
}
POST /api/admin/users/12/reset-password
Authorization: Bearer <access-token>
Content-Type: application/json
{
"newPassword": "新的临时强密码",
"confirmPassword": "新的临时强密码"
}
成功响应不应回显密码:
{
"code": "OK",
"message": "密码已重置",
"data": null
}
Service 骨架
export async function resetAdminPassword(
targetAdminId: string,
newPassword: string,
context: { operatedByAdminId: string },
): Promise<void> {
// TODO 1:确认目标管理员存在
// TODO 2:在短事务外 Hash 新密码
// TODO 3:在同一个短事务中更新 passwordHash/passwordChangedAt
// TODO 4:在该事务中将 tokenVersion 原子加 1
// TODO 5:写审计日志,但绝不记录密码或摘要
}
重置密码同样要求密码字段与 tokenVersion 原子提交;Argon2 Hash 可以在事务外先计算,数据库更新和审计表写入再进入短事务。
进阶设计可以增加:
must_change_password
password_reset_at
要求目标管理员下次登录后修改临时密码。但不要把一次性密码直接写进普通日志或邮件正文。
9. Router 骨架
公开登录 Router 与这里的受保护 Router 应分开。本章示例:
const authProtectedRouter = Router();
authProtectedRouter.use(authenticateAdmin);
authProtectedRouter.get("/me", getMyProfileController);
authProtectedRouter.patch(
"/password",
changePasswordValidation,
validateRequest,
changePasswordController,
);
authProtectedRouter.post(
"/logout-all",
logoutAllController,
);
const adminRouter = Router();
adminRouter.use(authenticateAdmin);
adminRouter.get(
"/users",
adminListValidation,
validateRequest,
listAdminsController,
);
adminRouter.post(
"/users",
createAdminValidation,
validateRequest,
createAdminController,
);
adminRouter.patch(
"/users/:adminId/status",
updateAdminStatusValidation,
validateRequest,
updateAdminStatusController,
);
adminRouter.post(
"/users/:adminId/reset-password",
resetAdminPasswordValidation,
validateRequest,
resetAdminPasswordController,
);
10. Controller 骨架示例
export async function updateAdminStatusController(
req: Request<
AdminIdParams,
ApiResponse<AdminUserDto>,
UpdateAdminStatusBody
>,
res: Response<ApiResponse<AdminUserDto>>,
): Promise<void> {
if (!req.admin) {
throw new AppError("AUTH_REQUIRED", "请先登录");
}
// TODO:将 adminId 校验并转换为项目约定的 ID 类型
const result = await updateAdminStatus(
req.params.adminId,
req.body.accountStatus,
{ operatedByAdminId: req.admin.id },
);
res.json({
code: "OK",
message: "管理员状态已更新",
data: result,
});
}
Controller 不应自己执行 Sequelize update(),否则自禁用、最后管理员、Token 撤销和审计规则会散落在 HTTP 层。
11. 错误码建议
| 场景 | JSON code |
|---|---|
| 当前密码错误 | CURRENT_PASSWORD_INVALID |
| 新旧密码相同 | PASSWORD_UNCHANGED |
| 管理员不存在 | ADMIN_NOT_FOUND |
| 用户名重复 | ADMIN_USERNAME_EXISTS |
| 邮箱重复 | ADMIN_EMAIL_EXISTS |
| 禁用自己 | CANNOT_DISABLE_SELF |
| 禁用最后一个可用管理员 | LAST_ACTIVE_ADMIN |
| 状态未变化 | ADMIN_STATUS_UNCHANGED 或按幂等成功处理 |
“状态未变化”可以设计为成功,这通常更符合幂等性;关键是接口契约保持一致。
12. 审计日志要求
以下操作建议记录:
ADMIN_CREATE
ADMIN_DISABLE
ADMIN_ENABLE
ADMIN_PASSWORD_RESET
PASSWORD_CHANGE
LOGOUT_ALL
至少包含:
requestId
operatedByAdminId
targetAdminId
actionCode
resultCode
createdAt
禁止记录:
明文密码
passwordHash
完整 JWT
JWT Secret
审计日志与普通 Pino 调试日志职责不同。审计记录“谁在什么时候对什么资源做了什么”,调试日志用于定位程序问题。
13. curl 验收顺序
获取当前管理员:
curl -i \
-H 'Authorization: Bearer eyJ...' \
http://127.0.0.1:8080/api/auth/me
创建管理员:
curl -i \
-H 'Authorization: Bearer eyJ...' \
-H 'Content-Type: application/json' \
-d '{"username":"manager2","password":"初始密码","displayName":"管理员2","email":null}' \
http://127.0.0.1:8080/api/admin/users
退出全部设备:
curl -i \
-X POST \
-H 'Authorization: Bearer eyJ...' \
http://127.0.0.1:8080/api/auth/logout-all
然后再次用旧 Token 调用 /api/auth/me,应得到 Token 已失效的业务响应。
14. 验收清单
- [ ]
/me不返回密码摘要、Token 版本和失败次数; - [ ] 修改密码必须验证当前密码;
- [ ] 新旧密码相同会被拒绝;
- [ ] 修改密码和 logout-all 都会原子递增
tokenVersion; - [ ] 旧 Token 在下一次请求时失效;
- [ ] 管理员列表有分页上限和排序白名单;
- [ ] 创建管理员依赖唯一约束处理并发重复;
- [ ] 不允许管理员禁用自己;
- [ ] 不允许禁用最后一个 Active 管理员;
- [ ] 禁用账号后旧 Token 不会因重新启用而恢复;
- [ ] 重置密码不会回显或记录密码;
- [ ] 关键管理操作带有操作者 ID 的审计信息。
15. 练习题
- 修改密码后为什么要递增
tokenVersion? logout-all为什么不能保证浏览器本地立刻删除 Token?- 为什么 Argon2 Hash 最好不要在持有数据库行锁时计算?
- “先查询用户名不存在,再创建”为什么仍可能重复?
- 禁用最后一个管理员为什么存在并发窗口?
- 禁用后再启用管理员时,为什么旧 Token 不应恢复?
- 账号状态重复设置为相同值,返回成功还是错误各有什么取舍?
- 审计日志与普通应用日志有什么区别?