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 可能意外带出 passwordHashtokenVersion 或失败次数。

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");
}

必要要求:

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. 验收清单

15. 练习题

  1. 修改密码后为什么要递增 tokenVersion
  2. logout-all 为什么不能保证浏览器本地立刻删除 Token?
  3. 为什么 Argon2 Hash 最好不要在持有数据库行锁时计算?
  4. “先查询用户名不存在,再创建”为什么仍可能重复?
  5. 禁用最后一个管理员为什么存在并发窗口?
  6. 禁用后再启用管理员时,为什么旧 Token 不应恢复?
  7. 账号状态重复设置为相同值,返回成功还是错误各有什么取舍?
  8. 审计日志与普通应用日志有什么区别?