返回博客

用户要注销账号你敢直接删?怡途Claw 资料修改与账户注销 API 安全设计实战

2026年8月1日阅读约 27 分钟
用户要注销账号你敢直接删?怡途Claw 资料修改与账户注销 API 安全设计实战

怡途Claw 用户资料、密码、头像修改与账户注销 API 实战:注销不是一刀切 DELETE,而是软删除混合策略--余额、订阅、未完成支付先拦截,隐私数据硬删、财务数据留审计、手机号释放可重注册。

前面讲了登录拿 token。拿到 token 之后,/api/app/* 这组接口里有一类是专门管"我自己"的:看资料、改资料、改密码、换头像,以及最麻烦的--注销账号。

实际接进来会碰到几个问题:

  • 改昵称到底调哪个接口?PUT /profile 里的 namenickname 填哪个?
  • 改密码要带旧密码吗,新密码有什么限制?
  • 用户要注销账号,直接 DELETE 就完了?余额和订阅怎么办?
  • 注销之后手机号还能不能重新注册?聊天记录真的删干净了吗?
  • 注销时为什么要输密码,还让我手打"注销"两个字?

这组接口挂在 app/users 下,全部要带 Authorization: Bearer <token>。本文从 app-users.controller.tsaccount-deletion.service.ts 的代码来看每个接口到底做了什么。

看资料:GET /api/app/users/profile

最简单的一个,返回当前登录用户的资料:

curl https://www.yitukeji.cn/api/app/users/profile \
  -H "Authorization: Bearer <你的token>"

返回:

{
  "success": true,
  "data": {
    "id": 101,
    "phone": "13800138000",
    "name": "张三",
    "nickname": "张三",
    "email": "zhangsan@example.com",
    "avatar": "https://...",
    "role": "user",
    "createdAt": "2026-07-01T10:00:00.000Z",
    "subscription": { "...": "当前订阅" },
    "package": { "...": "套餐信息" },
    "agent": { "...": "该用户的 agent 概要" }
  }
}

几个点:

  • namenickname 返回的是同一个值(都是 user.name),后端没单独存 nickname,nickname 字段是为了兼容老前端保留的。
  • subscriptionpackageagent 是三个聚合字段,findByIdWithSubscription 一次查出来带回来。其中 package 兜底取 subscription,所以没订阅时 package 也可能是 null
  • GET /api/app/auth/me 返回内容基本一致,区别只是语义上一个偏"我是谁"一个偏"我的资料",按需选一个调就行。

改资料:PUT /api/app/users/profile

请求体:

{
  "nickname": "新昵称",
  "email": "new@example.com",
  "avatar": "https://..."
}

三个字段都是可选的,传哪个改哪个。email@IsEmail 校验,格式不对直接 400 邮箱格式不正确

一个容易踩的细节:DTO 里同时有 namenickname 两个字段,后端取值是 dto.nickname ?? dto.name--nickname 优先,nickname 没传才回退到 name。所以你想改昵称,传 nickname 就行,别两个都传搞混。

改完返回更新后的资料,结构和 GET /profile 一样。

换头像:PUT /api/app/users/avatar

单独拎出来一个接口,因为头像经常单独传。请求体就一个字段:

{ "avatar": "https://cdn.example.com/avatar.png" }

avatar 是字符串 URL,非空,最长 500 字符,超了返回 头像URL长度不能超过500字符

注意这个接口只存 URL,不接文件上传。图片本身要先走 /api/app/files/*/api/agent/og-save 传到七牛拿到外链,再把 URL 提交到这里。和 304 那篇讲的文件上传是两段链路。

改密码:PUT /api/app/users/password

请求体:

{
  "currentPassword": "旧密码",
  "newPassword": "新密码"
}

两个字段都必填,都至少 6 位(DTO 层 @MinLength(6)),少了返回 当前密码至少6位 / 新密码至少6位

后端会先 bcrypt.compare 验旧密码对不对,错了返回 密码错误。新密码会和旧密码做同样的 hash 存回去。返回就一句 密码修改成功,不带数据。

改完密码不需要重新登录--token 还在 24 小时有效期内,不会因为改密码失效。这点和下面注销不一样。

注销前置检查:GET /api/app/users/account/deletion-check

注销账号不是 DELETE 一刀切。先调这个接口预览一下当前账户能不能注销、有什么拦着:

curl https://www.yitukeji.cn/api/app/users/account/deletion-check \
  -H "Authorization: Bearer <你的token>"

返回:

{
  "canDelete": false,
  "blockers": [
    {
      "code": "BALANCE_NOT_ZERO",
      "message": "账户余额剩余 12.50 元,请先联系客服处理或消费完再注销",
      "detail": { "balance": 12.5 }
    },
    {
      "code": "ACTIVE_SUBSCRIPTION",
      "message": "有 1 个未到期订阅,请先取消订阅或等待订阅到期",
      "detail": { "count": 1 }
    }
  ],
  "summary": {
    "balance": 12.5,
    "pendingPaymentCount": 0,
    "activeSubscriptionCount": 1,
    "activeInstanceCount": 2
  }
}

四类拦截(任一命中就 canDelete: false):

  1. 余额大于 0BALANCE_NOT_ZERO):账户里还有钱,得先消费完或联系客服退,不能带着钱销户。
  2. 有未完成支付PENDING_PAYMENT):payment_orders 里有 pending 状态的订单,得先取消或等它完成。
  3. 有未到期订阅ACTIVE_SUBSCRIPTION):activetrialcurrentPeriodEnd > now 的订阅。注意这里特意排除了"active 但已过期"的,过期的不拦。
  4. 运行中实例不拦截,只是统计在 summary.activeInstanceCount 里--注销时会自动 forceDelete 掉,不用你手动停。

blockers 数组把所有拦路项一次列全,summary 给数量概览。前端可以直接拿 blockers 渲染提示,告诉用户先处理哪几样。

执行注销:DELETE /api/app/users/account

确认 canDelete: true 之后才能调。这个接口有两道确认:

curl -X DELETE https://www.yitukeji.cn/api/app/users/account \
  -H "Authorization: Bearer <你的token>" \
  -H "Content-Type: application/json" \
  -d '{ "password": "你的密码", "reason": "不用了", "confirmText": "注销" }'

两道保险:

  • password 必填,后端 bcrypt.compare 验密码对不对,错了返回 密码错误。这是防止 token 泄露后被人恶意注销。
  • confirmText 如果传了,必须正好等于字符串 注销(两个字),否则返回 确认文本不匹配,请输入"注销"两个字。这是防误操作的第二道关,前端通常做成让用户手打"注销"才能点确认按钮。
  • reason 可选,注销原因,会写进审计记录。

deletedBy 固定是 self(用户自助注销)。后台 admin 也能强制注销,那时 deletedByadmin:<adminId>,可以跳过前置检查和密码确认。

注销到底删了什么

这是整个接口里最值得讲的部分。注销不是一条 DELETE FROM users,而是"软删除混合"策略,分三类数据区别对待:

用户主表 users:软删

  • isActivefalse--这就是 307 那篇讲的"注销即失效"的开关,老 token 下次请求查到 isActive=false 直接拒绝。
  • phone 改名成 deleted_<8位uuid>_<原手机号>,截断到 250 字符。这样原手机号的 unique 约束被释放,将来同号能重新注册。
  • emailnull,同样释放 unique。
  • password 打乱成 DELETED_<uuid>,防止 admin 冒登。
  • name 保留原值,方便 admin 后台展示"这个号以前是谁"。

隐私类数据:硬删 14 张表按 user_id 物理删除,包括 messages(聊天记录)、api_keysuser_devicesinstance_filestoken_usage_recordsuser_balancesrecharge_recordslogin_logschat_run_events 等。这些是真删,不留。

财务类数据:保留 payment_ordersapple_iap_purchasesbilling_records 不动,userId 原样保留。这是给税务和合规审计留的,展示层靠 isActive=false 过滤掉注销用户即可。

运行中的实例:先 forceDelete 再删库 Docker 容器和 DB 记录在事务外先 forceDelete 掉(涉及 docker 副作用,不可回滚),单个实例删失败不阻塞注销,只 warn 记审计。然后才进事务做上面三类数据清理。

整个过程在 account_deletion_records 表留一条审计快照:手机号、邮箱、注销前余额、实例数、订阅快照、操作人、客户端 IP、User-Agent、注销原因。admin 后台能回溯每一次注销。

一条串起来的链路

把 307 和这篇放一起看,注销的安全设计是闭环的:

  1. 注销时 isActive=false + password 打乱
  2. 老 token 还没过期,但下次调任意 /api/app/* 接口,JwtAuthGuard 校验时查库发现 isActive=false,直接返回 账户已注销
  3. 不用等 token 自然过期,注销即时生效
  4. 手机号释放,将来能重新注册新账号

代价是每个鉴权请求多一次 DB 查询。代码注释写得很直白:注销能即时生效比省这一次查询重要。对一个按 token 计费、可能存客户数据的平台,这个取舍合理。