账号换绑迭代三技术方案
来源文档:【日常】账号换绑迭代三-技术方案
技术方案评审准则
大型与超大型项目:技术 TL 参与需求调研、内审估时以及技术评审阶段,深度参与,确保信息对齐。
中型项目:发群里内审且至少 2 个同意,组长或 TL 至少一人参与实际技术评审。
小型项目:组内发群里,有伴 CR 即可。
现场技术方案评审人员透明。
补充要求:
新增 AI 友好,体现联动 AI 落具体技术设计。
基本信息
一. 概述
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 新接口添加支持:
业务方查询最新 userId 时,需要区分当前应该返回 u1 还是 u2,因此入参需要添加 kdtId。
account_customer_merger 这个 topic 在迁移工作完成以后,需要发回调消息到 account_customer_merger_ack。
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 ()); } 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 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 @Data public class AccountCustomerMergerAckMessage implements Serializable { private static final long serialVersionUID = 1L ; private String scene; private String assetType; private String rootKdtId; private Long fromUserId; 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 验证方式
接口层验证 :通过 dubbo invoke 验证 kdtId 正确传递。
核心场景回归 :
回执消息验证 :验证迁移完成后回执消息正确发送。
8.2 测试数据
账号域在 QA 环境提供测试店铺和测试数据(uid),数据覆盖:
附录
参考文档