Blog posts and course content are currently in Chinese. Translation is in progress.
Back to Blog

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

2026年8月1日27 min read
用户要注销账号你敢直接删?怡途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 计费、可能存客户数据的平台,这个取舍合理。