账号换绑迭代三技术方案

来源文档:【日常】账号换绑迭代三-技术方案

技术方案评审准则

  • 大型与超大型项目:技术 TL 参与需求调研、内审估时以及技术评审阶段,深度参与,确保信息对齐。
  • 中型项目:发群里内审且至少 2 个同意,组长或 TL 至少一人参与实际技术评审。
  • 小型项目:组内发群里,有伴 CR 即可。
  • 现场技术方案评审人员透明。

补充要求:

  • 新增 AI 友好,体现联动 AI 落具体技术设计。

基本信息

基本信息 内容
PRD 【prd】C端账号换绑优化
效率平台 PROJ-16335
项目群 -
需求评审时间 -
技术方案评审时间 + 参会人 王爷(王野)、悠柒(石家豪)、谢涛涛、满熊

一. 概述

1.1 术语

术语 说明
账号合并 用户更换手机号时,原手机号对应的账号(fromUserId)合并到新手机号对应的账号(toUserId
客户合并 店铺级别的账号隔离,商家操作换绑客户手机号时,原账号的客户身份及资产迁移到新账号
kdtId 店铺 ID,可以是总店 ID 或门店 ID
rootKdtId 连锁总店 ID

1.2 需求背景

商家经常反馈,消费者在换绑手机号时遇到流程复杂的问题,希望能简化换绑手机号流程,或支持商家帮消费者进行手机号换绑。

换绑流程麻烦的本质原因是:有赞的账号体系不是店铺隔离的,一个账号关联了 N 个店铺,涉及多店铺资产,因此需要层层校验防止出现用户资损,同时也导致无法支持单个商家操作。彻底解决的办法是实现账号的店铺级别隔离。

项目迭代如下:

  • 迭代一:平台账号隔离,双写阶段,已改造完成(待切流)
  • 迭代二:平台账号隔离,下双写
  • 迭代三:C 端账号换绑优化(本次需求)

账号域接口变更如下:

  • 原接口:com.youzan.uic.api.user.service.UserBaseInfoService#getLatestUserIdByUserId
  • 新接口:com.youzan.uic.api.user.service.UserBaseInfoService#getLatestUserIdByUserIdAndKdtId

变更原因:

  • 客户换绑手机号时,原账号依然保留,可以再次成为店铺的客户,会有两个不同的业务身份。
  • 新接口需要传入 kdtId 来区分当前查询的是哪个客户身份。

getLatestUserIdByUserId 新接口添加支持:

  1. 业务方查询最新 userId 时,需要区分当前应该返回 u1 还是 u2,因此入参需要添加 kdtId
  2. account_customer_merger 这个 topic 在迁移工作完成以后,需要发回调消息到 account_customer_merger_ack
  3. account_customer_merger_ack topic 地址:https://ops.qima-inc.com/v3/bizevent/#/topic/4279

1.3 本期目标

序号 内容 说明
1 retail-trade-core 账号合并,在订单创建前修复因用户合并导致的用户 ID 不一致问题
2 retail-trade-misc 线上下单页三方券兑换、刷新券码,校验用户是否存在
3 retail-trade-cart 查询购物车人员时,多人购物车匹配不到人员信息,可能是账号合并导致
4 升级 user-api 版本 将涉及的三个应用升级到 1.8.21-RELEASE
5 改造 getLatestUserIdByUserId 接口调用 使用新接口 getLatestUserIdByUserIdAndKdtId,传入 kdtId 参数
6 接入客户资产迁移回执消息 业务处理完成后发送回执消息,通知账号域迁移完成

1.4 AI 需求理解与 SDD 开发

目前在摸索 SDD 开发,大家也可以多尝试,找出后端的 SDD 范式。当前已知多工程联动会有问题,还在摸索中,鼓励大家一起参与,可以分别用接下来的需求做一次实战。

  • AI监控大师-Cursor+Spec-kit实战 spec-kit
  • 【WIP】AI JIRA大师-Cursor+OpenSpec实战 open-spec
  • OpenSpec实践 - 【项目】门店高频 jira 修复 open-spec

二. 业务分析

2.1 业务用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
flowchart TD
A[商家操作客户换绑手机号] --> B[账号域发送合并消息]
B --> C[业务方接收消息]
C --> D[迁移用户资产]
D --> E[发送回执消息]
E --> F[账号域确认迁移完成]

subgraph 本期新增改动
G[调用新接口传入kdtId] --> H[获取最新userId]
I[接入回执消息机制]
end

C --> G
H --> D
D --> I
I --> E

classDef default fill:#f9f9f9,stroke:#333,stroke-width:1px;
classDef new fill:#ff9800,stroke:#e65100,stroke-width:2px,color:white;

class A,B,C,D,E,F default;
class G,H,I new;

2.2 业务流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sequenceDiagram
participant 商家 as 商家
participant 账号域 as 账号域(UIC)
participant 门店交易 as 门店交易
participant 业务系统 as 其他业务系统

商家->>账号域: 发起客户手机号换绑
账号域->>账号域: 执行账号/客户合并
账号域->>门店交易: 发送合并消息(account_customer_merger)
账号域->>业务系统: 发送合并消息

门店交易->>门店交易: 迁移用户寄存商品
门店交易->>门店交易: 迁移第三方优惠券
门店交易->>账号域: 发送回执消息(迁移完成)

Note over 门店交易,账号域: 后续查询场景
门店交易->>账号域: getLatestUserIdByUserIdAndKdtId(param)
账号域-->>门店交易: 返回最新userId

三. 系统设计

3.1 应用容器架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
flowchart TD
subgraph 账号域
UIC[UserBaseInfoService]
end

subgraph 门店交易应用
MISC[retail-trade-misc<br>AccountClient]
CORE[retail-trade-core<br>SCRMClient]
CART[retail-trade-cart<br>UicClient]
end

subgraph 消息队列
NSQ1[account_customer_merger]
NSQ2[account_user_fansbind]
NSQ3[回执消息Topic]
end

UIC --> MISC
UIC --> CORE
UIC --> CART

NSQ1 --> MISC
NSQ2 --> MISC
MISC --> NSQ3

classDef default fill:#f9f9f9,stroke:#333,stroke-width:1px;
classDef new fill:#ff9800,stroke:#e65100,stroke-width:2px,color:white;

class NSQ3 new;

3.2 应用间交互时序图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sequenceDiagram
participant Client as 业务调用方
participant AccountClient as AccountClient<br>(retail-trade-misc)
participant SCRMClient as SCRMClient<br>(retail-trade-core)
participant UicClient as UicClient<br>(retail-trade-cart)
participant UIC as UserBaseInfoService<br>(UIC)

Note over Client,UIC: 改造前:不传kdtId
Client->>AccountClient: getLatestUserIdByUserId(param)
AccountClient->>UIC: getLatestUserIdByUserId(param)
UIC-->>AccountClient: 返回最新userId

Note over Client,UIC: 改造后:传入kdtId
Client->>AccountClient: getLatestUserIdByUserId(param with kdtId)
AccountClient->>UIC: getLatestUserIdByUserIdAndKdtId(param with kdtId)
UIC-->>AccountClient: 返回最新userId(基于kdtId区分)

3.3 数据模型

本次改动不涉及数据模型变更,仅涉及接口调用参数变更。

依赖版本升级如下:

应用 当前版本 目标版本
retail-trade-misc 1.8.12-RELEASE 1.8.21-RELEASE
retail-trade-core 1.8.15-RELEASE 1.8.21-RELEASE
retail-trade-cart 1.7.6-RELEASE 1.8.21-RELEASE
1
2
3
<groupId>com.youzan.uic</groupId>
<artifactId>user-api</artifactId>
<version>1.8.21-RELEASE</version>

四. 详细设计

4.1 组件间交互图

在原有组件间交互图的基础上,需要补充新的调用关系,并在图中用不同颜色标记出来,下图中红色部分为本期项目新增。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
flowchart LR
classDef subgraphStyle fill:#fff3cd,stroke:#856404,stroke-width:1px;
classDef coreNode fill:#f44336,stroke:#d32f2f,stroke-width:2px,color:white;

subgraph retail-trade-misc
A[AccountClient] --> B[getLatestUserIdByUserId]
B --> C[UserBaseInfoService.getLatestUserIdByUserIdAndKdtId]
end
class retail-trade-misc subgraphStyle;

subgraph retail-trade-core
D[SCRMClient] --> E[getLatestUserIdByUserId]
E --> F[UserBaseInfoService.getLatestUserIdByUserIdAndKdtId]
end
class retail-trade-core subgraphStyle;

subgraph retail-trade-cart
G[UicClient] --> H[getUserInfoByOldUserId]
H --> I[UserBaseInfoService.getLatestUserIdByUserIdAndKdtId]
end
class retail-trade-cart subgraphStyle;

retail-trade-misc -.->| | retail-trade-core
retail-trade-core -.->| | retail-trade-cart

class C,F,I coreNode;

linkStyle 6,7 stroke:none,fill:none;

4.2 具体改动点设计

接口名称 接口 类型 影响端 备注
AccountClient#getLatestUserIdByUserId 获取最新的账号 ID 修改 后端 传入 kdtId 参数
SCRMClient#getLatestUserIdByUserId 获取最新的账号 ID 修改 后端 传入 kdtId 参数
UicClient#getUserInfoByOldUserId 获取最新的账号 ID 修改 后端 方法签名已有 kdtId,需使用新接口
回执消息 retail-trade-misc 新增 后端 处理完成后发送回执

4.2.1 retail-trade-misc 改动

文件:trade-misc-dependency/src/main/java/com/youzan/retail/trade/misc/dependency/account/AccountClient.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 改造前
public Long getLatestUserIdByUserId(GetLatestUserIdByUserIdParam param) {
PlainResult<Long> result = RpcExceptionUtils.processPlainResult("获取最新的账号id",
"com.youzan.uic.api.user.service.UserBaseInfoService.getLatestUserIdByUserId",
param, ErrorCodeEnum.BUSSINESS_ERROR_CODE,
() -> userBaseInfoService.getLatestUserIdByUserId(param), new RpcOption());
// ...
}

// 改造后
// param set 进去 kdtId
public Long getLatestUserIdByUserId(GetLatestUserIdByUserIdParam param) {
PlainResult<Long> result = RpcExceptionUtils.processPlainResult("获取最新的账号id",
"com.youzan.uic.api.user.service.UserBaseInfoService.getLatestUserIdByUserIdAndKdtId",
param, ErrorCodeEnum.BUSSINESS_ERROR_CODE,
() -> userBaseInfoService.getLatestUserIdByUserIdAndKdtId(param), new RpcOption());
// ...
}

4.2.2 retail-trade-core 改动

文件:trade-core-dependency/src/main/java/com/youzan/retail/trade/core/dependency/scrm/SCRMClient.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 改造前
public Long getLatestUserIdByUserId(Long userId) {
GetLatestUserIdByUserIdParam getLatestUserIdByUserIdParam = new GetLatestUserIdByUserIdParam();
getLatestUserIdByUserIdParam.setUserId(userId);
PlainResult<Long> result = ExceptionHandler.processRpcInvoke(
"com.youzan.uic.api.user.service.UserBaseInfoService.getLatestUserIdByUserId",
ErrorCodeEnum.RPC_ERROR_CODE,
() -> userBaseInfoService.getLatestUserIdByUserId(getLatestUserIdByUserIdParam),
getLatestUserIdByUserIdParam);
// ...
}

// 改造后
public Long getLatestUserIdByUserId(Long userId, Long kdtId) {
GetLatestUserIdByUserIdParam getLatestUserIdByUserIdParam = new GetLatestUserIdByUserIdParam();
getLatestUserIdByUserIdParam.setUserId(userId);
getLatestUserIdByUserIdParam.setKdtId(kdtId);
PlainResult<Long> result = ExceptionHandler.processRpcInvoke(
"com.youzan.uic.api.user.service.UserBaseInfoService.getLatestUserIdByUserIdAndKdtId",
ErrorCodeEnum.RPC_ERROR_CODE,
() -> userBaseInfoService.getLatestUserIdByUserIdAndKdtId(getLatestUserIdByUserIdParam),
getLatestUserIdByUserIdParam);
// ...
}

4.2.3 retail-trade-cart 改动

文件:trade-cart-infrastructure/src/main/java/com/youzan/retail/trade/cart/infrastructure/adpater/client/UicClient.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 改造前
public Long getUserInfoByOldUserId(Long oldUserId, Long kdtId) {
GetLatestUserIdByUserIdParam param = new GetLatestUserIdByUserIdParam();
param.setUserId(oldUserId);
PlainResult<Long> result = RpcUtil.processPlainRpcInvoke("getLatestUserIdByUserId",
"com.youzan.uic.api.user.service.UserBaseInfoService", param,
ErrorCodeEnum.OTHER_ERROR_CODE, () -> userBaseInfoService.getLatestUserIdByUserId(param),
new RpcOption());
// ...
}

// 改造后
public Long getUserInfoByOldUserId(Long oldUserId, Long kdtId) {
GetLatestUserIdByUserIdParam param = new GetLatestUserIdByUserIdParam();
param.setUserId(oldUserId);
param.setKdtId(kdtId);
PlainResult<Long> result = RpcUtil.processPlainRpcInvoke("getLatestUserIdByUserId",
"com.youzan.uic.api.user.service.UserBaseInfoService", param,
ErrorCodeEnum.OTHER_ERROR_CODE, () -> userBaseInfoService.getLatestUserIdByUserIdAndKdtId(param),
new RpcOption());
// ...
}

4.2.4 回执消息接入(retail-trade-misc

回执消息 Topic:account_customer_merger_ack(需与账号域确认具体 topic 名称)

AccountMergerBindProcessor 处理完成后发送回执消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package com.youzan.retail.trade.misc.biz.nsq.processor.account;

/**
* 账号合并绑定消息处理器
* 触发条件:商家换绑客户手机号,迁移对应的所有门店的用户资产信息
*/
@Slf4j
@Component
@NsqConsumerBean(lookupAddresses = "${nsq.host}",
topic = "account_customer_merger",
channel = "${retail.trade.misc.nsq.channel}")
public class AccountMergerBindProcessor extends NsqCommonJsonConsumerTemplate<AccountCustomerMergerBindMessage> {

@Resource
private GoodsStorageMigrationService goodsStorageMigrationService;

@Resource
private ThirdCouponUserMigrationComponent thirdCouponUserMigrationComponent;

@Resource
private MessageProducer messageProducer;

@Override
public boolean processMessage(NSQMessage nsqMessage, AccountCustomerMergerBindMessage messageContent) {
log.info("account_customer_merger messageContent:{}", JSONObject.toJSONString(messageContent));
if (Objects.isNull(messageContent) || Objects.isNull(messageContent.getFromUserId())
|| Objects.isNull(messageContent.getToUserId())) {
log.warn("account_customer_merger messageContent is empty");
return true;
}

Long fromUserId = messageContent.getFromUserId();
Long toUserId = messageContent.getToUserId();
Long rootKdtId = messageContent.getRootKdtId();
String scene = messageContent.getScene();

// 以总部维度,触发所有门店用户寄存商品信息的迁移
goodsStorageMigrationService.migrateCustomerByRootKdtId(fromUserId, toUserId, rootKdtId);

// 迁移第三方优惠券
thirdCouponUserMigrationComponent.replaceThirdCouponUserNoCheckVoucherValid(fromUserId, toUserId, rootKdtId);

// 发送第三方优惠券迁移完成回执
sendMigrationAck(scene, "account_customer_merger_ack", rootKdtId, fromUserId, toUserId);

return true;
}

/**
* 发送迁移完成回执消息
*/
private void sendMigrationAck(String scene, String assetType, Long rootKdtId, Long fromUserId, Long toUserId) {
try {
AccountCustomerMergerAckMessage ackMessage = new AccountCustomerMergerAckMessage();
ackMessage.setScene(scene);
ackMessage.setAssetType(assetType);
ackMessage.setRootKdtId(rootKdtId != null ? String.valueOf(rootKdtId) : null);
ackMessage.setFromUserId(fromUserId);
ackMessage.setToUserId(toUserId);

messageProducer.send_account_customer_merger_ack(ackMessage);
log.info("发送账号合并回执成功, scene:{}, assetType:{}, rootKdtId:{}, fromUserId:{}, toUserId:{}",
scene, assetType, rootKdtId, fromUserId, toUserId);
} catch (Exception e) {
log.error("发送账号合并回执失败, scene:{}, assetType:{}, rootKdtId:{}, fromUserId:{}, toUserId:{}",
scene, assetType, rootKdtId, fromUserId, toUserId, e);
}
}
}

五. 非功能性需求设计

非功能性需求是指软件产品为满足用户业务需求而必须具有,且除功能需求以外的特性,包括系统的性能、可靠性、可维护性、可扩充性,以及对技术和业务的适应性等。

自检场景 场景描述 是否涉及 若有-应对方案
发布切流设计(强制) 灰度设计、业务动态开关设计、平滑发布 ✅ 是 1. 先升级依赖版本发布
2. 新接口已兼容老逻辑,可平滑切换
3. 分应用灰度发布
资损场景(强制) 涉及资损场景,必须考虑 ❌ 否 不涉及资金相关操作
大流量应对(强制) 是否添加必要限流、优先级队列等 ❌ 否 非高频接口,无需特殊限流
依赖降级(强制) 依赖方服务不可用,是否需要降级方案 ✅ 是 保持原有降级逻辑:接口调用失败时返回原 userId
三方依赖变更应对(强制) 不强依赖三方编码位数、域名等易变信息 ❌ 否 仅接口参数变更
告警监控设计(强制) 核心新增场景要求有监控项设计 ✅ 是 1. 监控新接口调用成功率
2. 监控回执消息发送成功率
数据清洗/迁移(强制) 涉及历史数据刷入的,需要严格执行刷入规范 ❌ 否 不涉及历史数据迁移

六. 接口设计

参考示例:成本调价一期-接口文档

6.1 新接口定义(UIC 提供)

1
2
3
4
5
6
7
8
/**
* 获取最新userId(支持kdtId)
*
* @param userId 用户ID
* @param kdtId 店铺ID(总店或门店均可)
* @return 最新的userId
*/
PlainResult<Long> getLatestUserIdByUserIdAndKdtId(Long userId, Long kdtId);

接口逻辑说明:

  • 叠加账号合并和客户合并获取最新 userId
  • 取账号合并和客户合并最新的一条作为最新的 userId
  • 传入 kdtId 用于区分店铺级别的客户身份

6.2 回执消息结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 账号客户合并回执消息
* 在迁移完成后发送到 account_customer_merger_ack topic
*/
@Data
public class AccountCustomerMergerAckMessage implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 场景类型
*/
private String scene;

/**
* 资产类型
*/
private String assetType;

/**
* 总部kdtId
*/
private String rootKdtId;

/**
* 待合并的userId
*/
private Long fromUserId;

/**
* 合并到的userId
*/
private Long toUserId;
}

七. 影响面分析

影响面评估 是否有影响 若有-应对方案
本项目对 HD、APP、小程序、PC 收银历史版本兼容(强制) ❌ 否 纯后端改动,不涉及端上变更
本项目对开放平台接口、扩展点有何影响(强制) ❌ 否 不涉及开放平台接口
本项目对数据报表有何影响(强制) ❌ 否 不涉及报表数据变更
对现有核心链路 RT 的影响(强制) ❌ 否 接口调用方式变更,RT 无明显变化
对外部依赖方有何影响 ❌ 否 仅内部接口升级

7.1 调用方影响分析

需要排查并同步修改以下调用方:

retail-trade-misc

  • AccountClient#getLatestUserIdByUserId 的所有调用方需要确保传入 kdtId

retail-trade-core

  • SCRMClient#getLatestUserIdByUserId 的所有调用方需要确保传入 kdtId

retail-trade-cart

  • UicClient#getUserInfoByOldUserId 已有 kdtId 参数,仅需切换到新接口

八. 测试验证方案

8.1 验证方式

  1. 接口层验证:通过 dubbo invoke 验证 kdtId 正确传递。
  2. 核心场景回归
    • 寄存商品场景
    • 第三方优惠券场景
    • 购物车场景
  3. 回执消息验证:验证迁移完成后回执消息正确发送。

8.2 测试数据

账号域在 QA 环境提供测试店铺和测试数据(uid),数据覆盖:

  • 账号合并场景
  • 客户合并场景

附录

参考文档