# 用户数据同步 / 迁移

本篇文章仅提供架构设计的略微参考，真实场景中每个公司的架构设计都是千差万别的，一套设计理论未必能够适应所有公司的项目。
所以如果你觉着本篇文章的设计理念不能契合你公司的需求，请以你公司的原设计为准。

--- 

### 数据同步需求

在前面的不同架构 SSO 对接示例中，我们均假设了一个前提：

-- _所有的 sso-client 只负责业务操作，不存储 user 数据，user 数据全部来源于 sso-server，包括登录认证也都是基于 sso-server 里的 user 账号进行校验操作。_

这种架构比较简洁、清晰，是一种理想化的 SSO 架构模型。

然而更多时候，我们遇到的实际情况是：

-- _公司已经有了 N 多个系统，每个系统都有自己独立的一套账号认证体系，现在老板要让这 N 个毫无关系的系统集成单点登录。_

要完成这种需求，首先你得考虑两个问题：
1. 问题一：sso-client 需不需要保留 user 数据。
	- sso-client 不涉及 user 信息连表查的业务，就可以不保留 user 信息。
	- sso-client 涉及 user 信息连表查业务，就需要在 sso-client 保留 user 数据。
2. 问题二：如果保留的话，是和 sso-server 强同步，还是弱同步。
	- 强同步就是指 sso-client 的 user 数据和 sso-server 的 user 数据，字段值必须保持一致。比如说：一个用户在server端昵称修改为“张三”，那么在 client 端也要实时同步修改。
	- 弱同步就是指两边可以各改各的。比如说：一个用户 server 端修改了昵称为 “张三”，他在 client 端依然可以昵称为 “李四”。


由此可大致分为三种设计方案：

| 方案序号	| 方案名称	| 简单说明															|  适用系统									|
| :--------	| :--------	| :--------															| :--------									|
| 方案一	 	| 统一迁移	| 统一把用户数据迁移到 sso-server 认证中心再进行对接					| 比较简单的系统，业务上不需要 user 信息连表查	|
| 方案二	 	| 实时同步	| 按照一定的规则，使 sso-client 和 sso-server 保持 user 信息实时同步 	| 一般业务上需要 user 信息连表查的系统			|
| 方案三	 	| 字段关联	| 不同步，但找一个关键字段，将 sso-client 和 sso-server 的 user 账号进行关联起来	| sso-client 不打算过分依赖 sso-server 的 user 数据，只是想借助 sso-server 完成一下统一登录		|

下面逐一拆解三种方案具体实现。


### 1、方案一：统一迁移

对接工作开发前，sso-client 的 user 数据完全迁移到 sso-server 中，且自身不再保留 user 数据，只进行业务数据处理操作。

这种方案其实不必过多讲解，因为数据完成迁移后整个架构就转化为了上述的“理想化SSO模型”，后续对接也比较方便。
迁移方式可以选择数据库同步工具，或者手写代码从 sso-client 库读取数据然后 insert 到 sso-server 库中。
这并非此文探讨的重点，因此不再过多赘述了。

- 方案优点：架构简洁明了，SSO 登录、注销对接起来非常方便
- 方案缺点：sso-client 不存储 user 信息，因此业务上需要连表查询 user 信息的地方会比较麻烦（例如：拉取帖子列表时需要附加显示用户头像和昵称信息）

方案适用范围：适合业务比较简单，不涉及 user资料连表查业务 的子系统。


### 2、方案二：实时同步

首先，对接前，数据还是要迁移的，只不过迁移后 sso-client 的 user 数据不删除掉，依然保留。

然后在项目运行阶段，每当 sso-server 的 user 数据发生变动时（增删改），逐一向每个 sso-client 推送变化信息。使 sso-client 与 sso-server 的 user 数据保持强同步。

你可能会有疑问，那 sso-client 的 user 数据发生变动时，要不要向 sso-server 推送信息，我的建议是：尽量不要让 sso-client 的 user 信息主动发生变化。

举个例子：

> 公司有电商、论坛、短视频 3 个子系统 + 1 个 sso-server 认证中心，无论用户从哪个子系统点击 “修改我的资料” 按钮时，都应该统一跳转到 sso-server 认证中心进行修改，
> 修改完毕后再由 sso-server 将 user 信息推送至 3 个子系统。以此来保证 4 个系统间的 user 信息同步。

- 方案优点：sso-client 存储了 user 信息，可以比较方便的进行 user 连表查操作。
- 方案缺点：sso-server 与 sso-client 的 user 数据同步功能不算简单，开发起来可能要耗费一段不小的工期。

方案适用范围：一般业务上需要 user 信息连表查的子系统都适合。


### 3、方案三：字段关联

如果子系统不需要和 sso-server 做到信息强同步，可以使用字段关联法做到账户关联进行登录。

举个例子：公司有三个子系统，电商、论坛、短视频。同一个用户可以在这三个子系统以及 sso-server 认证中心拥有不同的昵称、头像等信息，互不干扰。

例如，在 sso-server 认证中心里，张三的数据库信息为：

| id		| username	| avatar 	| password 		| age 			| email				|
| :--------	| :--------	| :--------	| :--------		| :--------		| :--------			|
| 10001		| ...		| ...		| ...			| ...			| ...				|
| 10002		| 小明		| cat.jpg	| 123456		| 18 			| `23397@xx.com`		|
| 10003		| ...		| ...		| ...			| ...			| ...				|

在电商系统里中，张三的数据库信息为：

| id			| name		| avatar 		| money			| email				|
| :--------		| :--------	| :--------		| :--------		| :--------			|
| 100334		| ...		| ...			| ...			| ...				|
| 100335		| 二明		| dog.jpg		| 1000 			| `23397@xx.com`		|
| 100336		| ...		| ...			| ...			| ...				|

这里的关键点在于，虽然用户 “张三” 在每个系统里的资料都是不同的，但是程序要想办法将它们识别为同一个用户，
要做到这一点，就需要我们准备一个关键字段将信息打通串联起来。例如表中的 “邮箱” 信息可以作为这个“关联字段”。

（注：此处仅展示使用邮箱作为关联字段的操作，实际上除了邮箱以外，手机号、身份证号等具有唯一性的信息都可以作为关联字段）

首先，在 sso-server 端，我们需要重写一下 `checkTicketAppendData` 函数，使其在 “校验 ticket 返回 loginId” 时，追加返回 email 字段。

```  java 
// 配置SSO相关参数 
@Autowired
private void configSso(SaSsoServerTemplate ssoServerTemplate) {
	
	// 其它配置 ...
	
	// 配置：Ticket校验函数
	ssoServerTemplate.strategy.checkTicketAppendData = (loginId, result) -> {
		System.out.println("-------- 追加返回信息到 sso-client --------");

		// 在校验 ticket 后，给 sso-client 端追加返回信息的函数
		SysUser user = sysUserMapper.getById(loginId);
		result.set("email", user.getEmail());
		// result.set("user", user);  // 你也可以将整个user 对象的信息都返回到 sso-client，自由决定

		return result;
	};
	
}
```

在 sso-client 端，重写 ticketResultHandle 函数，根据 sso-server 返回的信息查询本地 user 信息并登录：

``` java
// 配置SSO相关参数 
@Autowired
private void configSso(SaSsoClientTemplate ssoClientTemplate) {

	// 其它配置 ... 

	// 自定义校验 ticket 返回值的处理逻辑 （每次从认证中心获取校验 ticket 的结果后调用）
	ssoClientTemplate.strategy.ticketResultHandle = (ctr, back) -> {
		System.out.println("--------- 自定义 ticket 校验结果处理函数 ---------");
		System.out.println("此账号在 sso-server 的 userId：" + ctr.loginId);
		System.out.println("此账号在 sso-server 会话剩余有效期：" + ctr.remainSessionTimeout + " 秒");
		System.out.println("此账号返回的 email 信息：" + ctr.result.get("email"));

		// 模拟代码：
		// 根据 email 字段找到此账号在本系统对应的 user 信息
		String email = (String) ctr.result.get("email");
		SysUser user = sysUserMapper.getByEmail(email);

		// 如果找不到，说明是首次登录本系统的新用户，需要自动注册一个新账号给他
		if(user == null) {
			// 涉及到数据库操作，此处仅做模拟代码
			// 1、构建 user 信息
			// 2、插入到数据库
			// 3、查询出最新刚插入的这条 user 信息
			user = sysUserMapper.getByEmail(email);
		}

		// 进行登录
		StpUtil.login(user.getId(), ctr.remainSessionTimeout);
		StpUtil.getSession().set("user", user);

		// 一切工作完毕，重定向回 back 页面
		return SaHolder.getResponse().redirect(back);
	};

}
```

至此完毕。

- 方案优点：
	- 1、sso-client 不需要和 sso-server 保持信息强同步，实现起来不复杂，架构也比较清晰易维护。
	- 2、同一个用户的信息，sso-client 可以和 sso-client 保持不同，各自维护各自的，互不干扰。
- 方案缺点：好像没啥缺点，除非你觉着上述的第2条优点属于缺点。

方案适用范围：在 user 信息方面不打算过分依赖 sso-server 的系统，希望自己维护自己的 user 信息，只是想借助 sso-server 完成一下统一登录。


### 4、扩展：没有关联字段

如果我们的子系统 user 表没有邮箱、手机号等唯一性字段和 sso-server 的 user 表进行关联，该怎么办呢？

没有字段，那就创造个字段，例如：

| id			| name		| avatar 		| age			| center_id			|
| :--------		| :--------	| :--------		| :--------		| :--------			|
| 205421		| ...		| ...			| ...			| ...				|
| 205422		| 小风筝		| dog.jpg		| 21 			| 10002				|
| 205423		| ...		| ...			| ...			| ...				|

如上表所示，我们可以在子系统的 user 表新增一列 `center_id`，记录这个用户在认证中心所属的账号id。然后在登录时根据这个 `center_id` 来查找相应的用户。 

由于 sso-server 端默认就是会返回 loginId 参数的，因此在 sso-server 端不必再重写一下 `checkTicketAppendData` 函数来追加返回信息了，
我们只需要重写 sso-client 端的 `ticketResultHandle` 函数即可：

``` java
// 配置SSO相关参数 
@Autowired
private void configSso(SaSsoClientTemplate ssoClientTemplate) {

	// 其它配置 ... 

	// 自定义校验 ticket 返回值的处理逻辑 （每次从认证中心获取校验 ticket 的结果后调用）
	ssoClientTemplate.strategy.ticketResultHandle = (ctr, back) -> {
		System.out.println("--------- 自定义 ticket 校验结果处理函数 ---------");
		System.out.println("此账号在 sso-server 的 userId：" + ctr.loginId);
		System.out.println("此账号在 sso-server 会话剩余有效期：" + ctr.remainSessionTimeout + " 秒");

		// 模拟代码：
		// 根据 center_id 字段找到此账号在本系统对应的 user 信息
		long centerId = SaFoxUtil.getValueByType(ctr.loginId, long.class);
		SysUser user = sysUserMapper.getByCenterId(centerId);

		// 如果找不到，说明是首次登录本系统的新用户，需要自动注册一个新账号给他
		if(user == null) {
			// 涉及到数据库操作，此处仅做模拟
			// 1、构建 user 信息
			// 2、插入到数据库
			// 3、查询出最新刚插入的这条 user 信息
			user = sysUserMapper.getByCenterId(userId);
		}

		// 进行登录
		//     注意此处需要使用 centerId 进行登录，否则该账号将无法正常完成单点注销功能 
		StpUtil.login(centerId, ctr.remainSessionTimeout);
		StpUtil.getSession().set("user", user);

		// 一切工作完毕，重定向回 back 页面
		return SaHolder.getResponse().redirect(back);
	};

}
```

至此完毕。


> [!INFO| label:提问：按照方案三，一个用户登录过程中，sso-server 和 sso-client 对这个用户账号的完整处理步骤是怎样的？] 
> 1. 用户进入 sso-client 登录页面，点击上面的 [ 使用 xx 认证中心快捷登录 ] 按钮，浏览器跳转至 sso-server 认证中心。
> 2. 如果用户在 sso-server 有账号，则直接登录，如果没有，则注册账号并登录。
> 3. sso-server 重定向回 sso-client 端，并携带 ticket 参数。
> 4. sso-client 获取 ticket 参数，并解析出 center_id 值。
> 5. 根据 center_id 从 user 表查数据：
> 	- 5.1 查的到，证明有账号，直接登录。
> 	- 5.2 查不到，证明无账号，程序自动给他添加一条 user 账号，并登录。
> 6. 登录完成。



### 5、解决模式三下，loginId 与 centerId 不一致的问题

按照字段关联法登录之后，如果一个用户在本地应用端的 userId 和认证中心端的 userId 不一致，则可能发生单点注销失败的情况：

假设，一个用户在认证中心的 userId=10002，在本地应用端的 userId=100335，
则在本地应用端发起单点注销时，其传递的 loginId 值是 100335，在 sso-server 是找不到 userId=100335 用户的，自然无法单点注销成功。

解决方案是在本地应用端重写 loginId 与 centerId 转换策略函数，做到本地应用 userId 与认证中心 userId 的互相映射：

``` java
@RestController
public class SsoClientController {

	// 配置SSO相关参数
	@Autowired
	private void configSso(SaSsoClientTemplate ssoClientTemplate) {
		// 重写 loginId 与 centerId 转换策略函数，做到本地应用 userId 与认证中心 userId 的互相映射
		
		// 将 centerId 转换为 loginId 的函数
		ssoClientTemplate.strategy.convertCenterIdToLoginId = (centerId) -> {
			return "Stu" + centerId;
		};
		// 将 loginId 转换为 centerId 的函数
		ssoClientTemplate.strategy.convertLoginIdToCenterId = (loginId) -> {
			return loginId.toString().substring(3);
		};
	}

}
```

如上代码，演示了应用本地 loginId 与认证中心 centerId 不一致时的转换写法（演示的逻辑为添加和裁剪指定前缀），真实项目中，应该根据用户表存储的映射关系来做查询返回。

值得注意的是，在重写转换策略后，我们在消息推送时也应该严格按照转换写法提交 loginId 参数，例如：

``` java
// 查询我的账号信息：sso-client 前端 -> sso-center 后端 -> sso-server 后端
@RequestMapping("/sso/myInfo")
public Object myInfo() {
	// 如果尚未登录
	if( ! StpUtil.isLogin()) {
		return "尚未登录，无法获取";
	}

	// 原写法：直接调用 StpUtil.getLoginId() 当做 centerId 来提交
	// Object centerId = StpUtil.getLoginId();

	// 新写法：获取本地 loginId 对应的认证中心 centerId
	Object centerId = SaSsoClientUtil.getSsoTemplate().strategy.convertLoginIdToCenterId.run(StpUtil.getLoginId());

	// 推送消息
	SaSsoMessage message = new SaSsoMessage();
	message.setType("userinfo");
	message.set("loginId", centerId);
	SaResult result = SaSsoClientUtil.pushMessageAsSaResult(message);

	// 返回给前端
	return result;
}
```