feat:昵称重复、显示分销人分销tree
This commit is contained in:
1 parent
4518856ccd
commit
074307b07b
50 files changed
+3386
-116
No files matched your search
@@ -0,0 +1,309 @@
|
||||
# 聊天用户同步修复发布与数据处理
|
||||
|
||||
## 本次修复与发布范围
|
||||
|
||||
业务会员 ID 成为聊天身份,普通会员 IM 用户名固定为 `u<ID>`,昵称独立展示。同昵称会员按各自原 ID 保留历史。已删除、禁用、孤立账号通过会员状态管理,IM 人工封禁由原字段独立管理。
|
||||
|
||||
业务事务保存同步任务,提交后通过专用执行队列尝试同步。每分钟定时对账和补偿覆盖逻辑删除及绕过业务入口的资料变化。接口通过版本防止旧请求覆盖新资料。状态变更版本随最新任务保留,覆盖 IM 故障期间先禁用再恢复的情况。
|
||||
|
||||
IM 统一检查会员状态、人工封禁和 JWT 撤销版本。Redis 保存连接可用状态,在线节点登记心跳,停用控制消息广播至所有在线连接节点。WebSocket 登录与心跳均检查版本。
|
||||
|
||||
本次同时修正中文正文读取依赖 JVM 默认字符集的问题。请求声明编码优先,JSON 无编码声明时使用 UTF-8。
|
||||
|
||||
发布包包含以下内容。
|
||||
|
||||
| 位置 | 内容 |
|
||||
| --- | --- |
|
||||
| `jars/yudao-server.jar` | 业务 API,使用 Java 8 |
|
||||
| `jars/im-platform.jar` | IM HTTP 接口,使用 Java 17 |
|
||||
| `jars/im-server.jar` | IM 连接节点,使用 Java 17 |
|
||||
| `migrations/business.sql` | 业务同步任务、持久化编号水位、停用状态的定时任务配置 |
|
||||
| `migrations/im.sql` | IM 会员状态、同步版本、撤销版本及有效限制字段 |
|
||||
| `repair-im-users/` | 数据修复工具及锁定依赖 |
|
||||
| `client-h5/` | 当前 UniApp 源码配置构建的 H5 产物 |
|
||||
| `docs/` | 发布操作、核验报告 |
|
||||
| `SHA256SUMS` | 发布文件校验值 |
|
||||
|
||||
业务 JAR 使用当前工作区构建,包含工作区同期已有的会员分销关系相关改动。发布前将这部分内容一起纳入版本审核。UniApp 原生 App 应由现有 HBuilder/打包流程发布包含本次客户端改动的版本。
|
||||
|
||||
## 1. 确认环境并准备备份
|
||||
|
||||
本次只读核验的业务库为 `chat_v1`,IM 库为 `im_platform`,地址为 `139.224.25.207`。两库位于同一 MySQL 5.7.43-log 实例。正式操作时重新确认连接与线上服务对应。
|
||||
|
||||
在有数据库访问权限的运维机器解压发布包,检查文件校验。
|
||||
|
||||
```bash
|
||||
cd /实际解压目录/chat-sync-release-20261109
|
||||
sha256sum -c SHA256SUMS
|
||||
# macOS 可使用 shasum -a 256 -c SHA256SUMS
|
||||
```
|
||||
|
||||
使用 MySQL 自带凭据文件保存连接信息,命令会交互输入密码。
|
||||
|
||||
```bash
|
||||
mysql_config_editor set --login-path=chat-business --host=139.224.25.207 --port=3306 --user=chat_v1 --password
|
||||
mysql_config_editor set --login-path=chat-im --host=139.224.25.207 --port=3306 --user=im_platform --password
|
||||
|
||||
mysql --login-path=chat-business -D chat_v1 -e 'SELECT DATABASE(),@@server_uuid,@@hostname,VERSION();'
|
||||
mysql --login-path=chat-im -D im_platform -e 'SELECT DATABASE(),@@server_uuid,@@hostname,VERSION();'
|
||||
```
|
||||
|
||||
预先配置修复工具,安装依赖并生成只读预检查清单。密码输入方法见工具 README。
|
||||
|
||||
```bash
|
||||
cd repair-im-users
|
||||
npm ci --omit=dev
|
||||
cp config.example.json config.local.json
|
||||
chmod 600 config.local.json
|
||||
# 编辑 config.local.json,填写两个实际数据库连接
|
||||
node repair-im-users.mjs plan --config config.local.json --out runs/preflight.plan.json
|
||||
cd ..
|
||||
```
|
||||
|
||||
结构迁移前 `schemaReady=false` 符合当前线上状态。正式执行清单须在结构迁移完成后重新生成。
|
||||
|
||||
## 2. 进入维护窗口并停写
|
||||
|
||||
在网关启用维护页面,暂停 App、管理后台、第三方入口和运营脚本的会员及聊天写入。停止业务同步任务、所有旧业务实例、所有旧 IM 接口实例和所有聊天连接节点。同步暂停守护程序的自动拉起和容器编排自动恢复。
|
||||
|
||||
先检查现有服务和启动配置。下面提供 systemd 部署示例,服务名和 JAR 路径按当前服务器实际配置填写。采用容器或其他进程管理方式时执行相应的停止、替换、启动操作。
|
||||
|
||||
```bash
|
||||
systemctl list-units --type=service --all | grep -Ei 'yudao|im-platform|im-server|chat'
|
||||
pgrep -af 'yudao-server|im-platform|im-server'
|
||||
|
||||
BUSINESS_SERVICE='实际业务服务名'
|
||||
IM_PLATFORM_SERVICE='实际IM接口服务名'
|
||||
IM_SERVER_SERVICE='实际聊天连接服务名'
|
||||
BUSINESS_JAR='/实际业务目录/yudao-server.jar'
|
||||
IM_PLATFORM_JAR='/实际IM接口目录/im-platform.jar'
|
||||
IM_SERVER_JAR='/实际聊天连接目录/im-server.jar'
|
||||
|
||||
systemctl cat "$BUSINESS_SERVICE" "$IM_PLATFORM_SERVICE" "$IM_SERVER_SERVICE"
|
||||
sudo systemctl stop "$BUSINESS_SERVICE" "$IM_PLATFORM_SERVICE" "$IM_SERVER_SERVICE"
|
||||
```
|
||||
|
||||
多机、多实例环境对每个实例执行停止。确认全部旧进程停止后保存完整备份。
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
CHAT_BACKUP_DIR="/实际备份目录/chat-sync-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$CHAT_BACKUP_DIR"
|
||||
|
||||
mysqldump --login-path=chat-business --default-character-set=utf8mb4 --single-transaction \
|
||||
--routines --triggers --events --hex-blob --no-tablespaces --set-gtid-purged=OFF \
|
||||
chat_v1 > "$CHAT_BACKUP_DIR/chat_v1.sql"
|
||||
mysqldump --login-path=chat-im --default-character-set=utf8mb4 --single-transaction \
|
||||
--routines --triggers --events --hex-blob --no-tablespaces --set-gtid-purged=OFF \
|
||||
im_platform > "$CHAT_BACKUP_DIR/im_platform.sql"
|
||||
|
||||
cp -p "$BUSINESS_JAR" "$CHAT_BACKUP_DIR/yudao-server.jar"
|
||||
cp -p "$IM_PLATFORM_JAR" "$CHAT_BACKUP_DIR/im-platform.jar"
|
||||
cp -p "$IM_SERVER_JAR" "$CHAT_BACKUP_DIR/im-server.jar"
|
||||
sha256sum "$CHAT_BACKUP_DIR"/*.sql "$CHAT_BACKUP_DIR"/*.jar > "$CHAT_BACKUP_DIR/SHA256SUMS"
|
||||
```
|
||||
|
||||
同时保存外置配置、JVM 参数、服务定义及客户端旧版本。核对两份数据库备份命令成功退出,文件大小与备份内容符合预期。
|
||||
|
||||
## 3. 执行两侧结构迁移
|
||||
|
||||
```bash
|
||||
mysql --login-path=chat-business --default-character-set=utf8mb4 -D chat_v1 < migrations/business.sql
|
||||
mysql --login-path=chat-im --default-character-set=utf8mb4 -D im_platform < migrations/im.sql
|
||||
```
|
||||
|
||||
IM 的 ALTER 脚本按一次迁移执行。若运行中断,先检查列是否存在,依据实际完成情况处理后再继续。
|
||||
|
||||
```sql
|
||||
SELECT TABLE_NAME,COLUMN_NAME,COLUMN_TYPE
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='im_platform' AND TABLE_NAME='im_user'
|
||||
AND COLUMN_NAME IN ('member_state','sync_version','auth_version','access_disabled');
|
||||
|
||||
SELECT id,name,status,handler_name,cron_expression
|
||||
FROM chat_v1.infra_job
|
||||
WHERE handler_name='memberChatSyncJob' AND deleted=b'0';
|
||||
```
|
||||
|
||||
新任务配置初始 `status=2`,保持停用,待服务启动后注册和启用。
|
||||
|
||||
## 4. 重新生成正式清单并修复历史资料
|
||||
|
||||
```bash
|
||||
cd repair-im-users
|
||||
node repair-im-users.mjs plan --config config.local.json --out runs/maintenance.plan.json
|
||||
```
|
||||
|
||||
检查输出 `schemaReady=true`、两库身份、用户数量、状态分类、同昵称清单和安全编号起点。归属异常、字段长度、保留用户名占用或结构异常会直接终止,输出对应明细。
|
||||
|
||||
保持停写并执行。
|
||||
|
||||
```bash
|
||||
node repair-im-users.mjs apply --config config.local.json --plan runs/maintenance.plan.json --maintenance
|
||||
node repair-im-users.mjs verify --config config.local.json --plan runs/maintenance.plan.json
|
||||
```
|
||||
|
||||
`phase=complete` 且核验 `verified=true` 后继续。原用户 ID、密码哈希、好友关系、群关系、群备注与历史消息数量均参与核验。
|
||||
|
||||
出现中断时保留清单与 journal,在停写状态使用同一条 `apply` 命令重跑。两库分别提交,每个阶段保存断点,重跑会校验实际快照并补齐后续阶段。
|
||||
|
||||
将 `runs/maintenance.plan.json*` 复制到本批次备份目录。
|
||||
|
||||
## 5. 替换发布包和核对配置
|
||||
|
||||
三个服务仍处于停止状态时替换 JAR。
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
sudo cp jars/yudao-server.jar "$BUSINESS_JAR"
|
||||
sudo cp jars/im-platform.jar "$IM_PLATFORM_JAR"
|
||||
sudo cp jars/im-server.jar "$IM_SERVER_JAR"
|
||||
```
|
||||
|
||||
沿用现场服务定义中的用户、目录、权限、端口、数据库连接和有效密钥配置。IM 数据库密码使用当前有效凭据。业务服务的 IM 签名配置须与 IM 接口一致,两个 IM 服务的 accessToken 密钥须一致。
|
||||
|
||||
IM 接口的有效外置配置应包含以下选项。
|
||||
|
||||
```yaml
|
||||
im:
|
||||
auth:
|
||||
business-only: true
|
||||
```
|
||||
|
||||
核对业务与 IM 的 Redis 连接。IM 接口与全部连接节点使用相同 Redis 实例和库号。新增状态采用字符串 Hash,控制消息沿用原 Redis 队列配置。保留 IM 连接节点心跳注册所需的 ZSET 权限。
|
||||
|
||||
## 6. 按顺序启动服务和同步任务
|
||||
|
||||
1. 启动新版 IM 接口,等待启动完成及账号状态全量预热成功。
|
||||
2. 启动全部新版 IM 连接节点,确认节点初始化成功并注册在线心跳。
|
||||
3. 启动新版业务服务,专用初始化会核验消息通知和客服账号。
|
||||
|
||||
systemd 部署可按顺序执行,并在每一步检查日志。
|
||||
|
||||
```bash
|
||||
sudo systemctl start "$IM_PLATFORM_SERVICE"
|
||||
sudo journalctl -u "$IM_PLATFORM_SERVICE" -n 100 --no-pager
|
||||
|
||||
sudo systemctl start "$IM_SERVER_SERVICE"
|
||||
sudo journalctl -u "$IM_SERVER_SERVICE" -n 100 --no-pager
|
||||
|
||||
sudo systemctl start "$BUSINESS_SERVICE"
|
||||
sudo journalctl -u "$BUSINESS_SERVICE" -n 100 --no-pager
|
||||
```
|
||||
|
||||
每个 IM 用户状态预热失败会记录 `刷新聊天账号状态失败`。在启动异常得到修复并完成预热后继续。保留网关维护页,仅开放运维管理与验收访问。
|
||||
|
||||
在管理后台进入定时任务页面,先执行“同步”,将数据库中的任务配置注册到 Quartz,再启用处理器 `memberChatSyncJob`,确认每分钟执行。可手动执行一次以加快首次补偿。
|
||||
|
||||
现有“同步”操作会重建全部任务的 Quartz 注册,并按数据库状态恢复各任务。按现场维护范围控制其他任务的启用状态。
|
||||
|
||||
对应接口与参数如下,沿用后台既有登录与租户请求头。
|
||||
|
||||
```text
|
||||
POST /admin-api/infra/job/sync
|
||||
PUT /admin-api/infra/job/update-status?id=<任务ID>&status=1
|
||||
PUT /admin-api/infra/job/trigger?id=<任务ID>
|
||||
```
|
||||
|
||||
每轮最多发送 200 个待补偿任务,后台发送时间预算约 45 秒。失败按 1、5、15、30 分钟退避,后续保持 30 分钟重试。全部任务可能需要多轮完成。
|
||||
|
||||
业务库检查待补偿及错误明细。
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) AS pending_profiles
|
||||
FROM member_chat_sync
|
||||
WHERE deleted=b'0' AND synced_version<version;
|
||||
|
||||
SELECT COUNT(*) AS pending_initialization
|
||||
FROM member_chat_sync
|
||||
WHERE deleted=b'0' AND member_state=0 AND initialized=b'0';
|
||||
|
||||
SELECT member_id,member_state,version,synced_version,retry_count,next_retry_time,last_error
|
||||
FROM member_chat_sync
|
||||
WHERE deleted=b'0' AND (synced_version<version OR (member_state=0 AND initialized=b'0'))
|
||||
ORDER BY next_retry_time,member_id LIMIT 100;
|
||||
```
|
||||
|
||||
修复实际故障后,如需提前重试已退避任务,可在业务库执行下面的定向操作,再手动触发任务。
|
||||
|
||||
```sql
|
||||
UPDATE member_chat_sync SET next_retry_time=CURRENT_TIMESTAMP
|
||||
WHERE deleted=b'0' AND (synced_version<version OR (member_state=0 AND initialized=b'0'));
|
||||
```
|
||||
|
||||
## 7. 完成数据核验和真实账号验收
|
||||
|
||||
首次补偿完成后,在开放用户写入前执行。
|
||||
|
||||
```bash
|
||||
cd repair-im-users
|
||||
node repair-im-users.mjs verify --config config.local.json --plan runs/maintenance.plan.json --ready
|
||||
cd ..
|
||||
```
|
||||
|
||||
要求输出 `verified=true`、`ready=true`、两个 pending 计数均为 0。保存核验报告后进行真实账号操作,避免验收操作影响快照比对。
|
||||
|
||||
IM 库检查关键样本与全量用户名。
|
||||
|
||||
```sql
|
||||
SELECT id,user_name,nick_name,member_state,is_banned,sync_version,auth_version
|
||||
FROM im_user WHERE id IN (-200,-100,100,131) ORDER BY id;
|
||||
|
||||
SELECT id,user_name FROM im_user
|
||||
WHERE id>0 AND BINARY user_name<>BINARY CONCAT('u',id);
|
||||
|
||||
SELECT user_name,COUNT(*) FROM im_user GROUP BY user_name HAVING COUNT(*)>1;
|
||||
```
|
||||
|
||||
100 应为 `u100`、昵称“老芋头”、会员状态 2。131 应为 `u131`、昵称“老芋头”、会员状态 0。后两个全量查询应返回 0 行。
|
||||
|
||||
真实客户端按以下顺序验收。
|
||||
|
||||
- 使用 131 的业务账号登录,调用 `POST /app-api/member/auth/chat-token`,确认业务返回 `code=0` 并带有完整聊天凭证,验证收发消息。
|
||||
- 按正式清单中的同昵称用户逐一核验,展示昵称保持业务资料,聊天身份分别对应原 ID。
|
||||
- 对专用测试会员执行禁用,确认在线连接关闭,客户端展示服务端原因,旧 accessToken、refreshToken 和 WebSocket 重连均失败。
|
||||
- 恢复该会员,从业务入口取得新凭证。新凭证可用,旧凭证持续失效。
|
||||
- 对测试会员设置独立 IM 封禁,确认业务恢复资格时该封禁仍生效。保留其封禁原因和原密码。
|
||||
- 修改测试会员昵称头像,确认好友和群成员资料更新,个人群备注符合既有语义。
|
||||
- 确认独立 IM 登录、注册、改密码和改资料入口返回统一业务入口提示,聊天凭证刷新正常服务业务客户端。
|
||||
|
||||
发布包含本次变更的 H5 和原生 App 客户端,完成停用原因展示及停止重连验收,再按现场发布流程恢复流量。
|
||||
|
||||
## 8. 发布后观察
|
||||
|
||||
持续查看任务积压、重试错误、用户名冲突和聊天凭证签发日志。对账每分钟分页扫描会员资料,支持逻辑删除、物理缺失与手工改库后的当前状态修正。
|
||||
|
||||
本次新增永久编号水位表。会员注册事务在全局行锁下预留编号,跨租户共享编号空间。数据库重启后该水位仍有效。
|
||||
|
||||
IM 返回的账号不可用码为 `10005`,业务聊天凭证接口对应 `1004018005`,保留服务端原因。同步失败或临时故障沿用 `1004018002`。客户端收到账号不可用后停止该业务登录上下文的聊天刷新和重连,新业务登录后可重新获取资格。
|
||||
|
||||
## 9. 回滚与异常恢复
|
||||
|
||||
### 尚未完成迁移
|
||||
|
||||
保持维护状态。先检查 journal,优先使用同一清单重跑 `apply`。源数据发生变化时工具会拒绝继续,应先依据差异与备份确定修复方式。
|
||||
|
||||
### 回滚本批次资料
|
||||
|
||||
重新停写,暂停同步任务及三类服务,备份当前数据库后执行。
|
||||
|
||||
```bash
|
||||
cd repair-im-users
|
||||
node repair-im-users.mjs rollback --config config.local.json --plan runs/maintenance.plan.json --maintenance
|
||||
```
|
||||
|
||||
`phase=rolledBack` 表示本批次资料字段恢复完成。工具保留当前会员停用状态和人工封禁,递增撤销版本及同步版本,保留安全编号水位,并重新生成待补偿任务。
|
||||
|
||||
回滚后保持维护页,核验原用户名、昵称头像、关系与消息。使用新版 IM 接口重新建立 Redis 状态,再启动新版连接节点。业务任务运行后会重新采用 `u<ID>`,因此需要先确定重新迁移方案,再开放流量。
|
||||
|
||||
已经发生后续资料编辑、默认好友初始化、新消息或群变动时,自动回滚会终止并打印差异。使用本批次完整备份与差异记录制定定向恢复步骤。
|
||||
|
||||
### 恢复旧发布版本
|
||||
|
||||
旧 IM 发布包缺少本次账号状态与撤销版本校验。恢复旧版本需要在维护状态完成两库与服务的成套恢复,并单独处理已经撤销的凭证及已停用账号。数据库全量恢复可能覆盖维护窗口后产生的数据,须根据实际备份时间和恢复目标执行。
|
||||
|
||||
当前自动化回滚工具适用于本批次资料恢复,安全状态和编号水位由新版服务持续解释。表结构保留便于继续修复与审计。
|
||||
|
||||
## 当前验证范围
|
||||
|
||||
本地完成两版本 MySQL 集成测试、Redis 状态校验、真实签名 HTTP 与 WebSocket 联调、客户端逻辑测试及三个服务构建。生产数据库已只读预检查,正式迁移、线上进程发布、真实账号及手机端验收由现场按本文执行。
|
||||
@@ -0,0 +1,118 @@
|
||||
# 聊天用户同步修复核验报告
|
||||
|
||||
## 问题定位
|
||||
|
||||
原业务同步将会员昵称同时写入 IM 的 `user_name` 和 `nick_name`。`im_user.user_name` 存在唯一索引,会员昵称允许重复。
|
||||
|
||||
本次两库只读核对发现,100 在业务库已逻辑删除,昵称为“老芋头”,IM 中仍保留用户名“老芋头”。131 是正常业务会员,昵称同为“老芋头”,IM 旧用户名为“芋头”。131 再次同步昵称时发生用户名唯一键冲突,IM 返回 `10003`,业务聊天凭证接口随后返回 `1004018002`。
|
||||
|
||||
修复后 100、131 分别使用 `u100`、`u131`,展示昵称均为“老芋头”。100 会员状态为已删除,131 为正常,各自原 ID 及历史关系继续保留。
|
||||
|
||||
## 线上只读预检查
|
||||
|
||||
扫描时间为北京时间 2026-11-09 11:27:50。连接业务库 `chat_v1` 与 IM 库 `im_platform`,两库服务器 UUID 一致,MySQL 版本为 5.7.43-log。用户名排序规则为 `utf8mb4_general_ci`。
|
||||
|
||||
| 项目 | 扫描结果 |
|
||||
| --- | --- |
|
||||
| 业务会员,包含逻辑删除 | 1013 |
|
||||
| IM 用户,包含两个系统账号 | 1030 |
|
||||
| 正常会员对应的现有 IM 用户 | 945 |
|
||||
| 禁用会员 | 2 |
|
||||
| 已删除会员 | 66 |
|
||||
| IM 独有正数用户 | 15 |
|
||||
| 业务会员缺少 IM 用户 | 0 |
|
||||
| 同昵称会员,包含逻辑删除 | 13 组,29 条 |
|
||||
| 同昵称会员,排除逻辑删除 | 11 组,24 条 |
|
||||
| 本次扫描的安全编号起点 | 1076 |
|
||||
| 好友记录 | 5652 |
|
||||
| 群成员记录 | 3 |
|
||||
| 群记录 | 1 |
|
||||
| 私聊消息 | 2795 |
|
||||
| 群聊消息 | 7 |
|
||||
|
||||
扫描时业务同步任务表、持久化编号水位表,以及 IM 的四个新增字段尚待执行结构迁移,预检查输出 `schemaReady=false`。这些数量记录本次在线快照,正式维护窗口停写后重新生成清单。
|
||||
|
||||
当前全量同昵称明细如下。
|
||||
|
||||
| 昵称 | 会员 ID |
|
||||
| --- | --- |
|
||||
| 逝水如丝 | 75、130、154 |
|
||||
| 喵喵酱 | 76、155 |
|
||||
| 老芋头 | 100、131 |
|
||||
| 小丸子 | 159、1041 |
|
||||
| 静静 | 190、729 |
|
||||
| 甜甜 | 226、261、993、1049 |
|
||||
| 小颜 | 241、254 |
|
||||
| 菜菜 | 257、466 |
|
||||
| 洋洋 | 304、684 |
|
||||
| 婉儿 | 362、945 |
|
||||
| 小雅 | 373、394 |
|
||||
| 糖糖 | 410、804 |
|
||||
| 媛媛 | 502、616 |
|
||||
|
||||
完整原值清单保存在工作区工具的私有 `runs` 目录,Git 已忽略该目录。正式执行时使用维护窗口的新清单和执行记录。
|
||||
|
||||
## 本地验证结果
|
||||
|
||||
| 验证项 | 环境与结果 |
|
||||
| --- | --- |
|
||||
| 业务相关回归 | MySQL 5.7.44,32 例通过 |
|
||||
| 业务相关回归 | MySQL 8.0.46,32 例通过 |
|
||||
| IM 接口全量测试 | MySQL 8.0.46,153 例通过,1 例外部存储 smoke 测试按条件跳过 |
|
||||
| IM 连接服务测试 | 4 例通过 |
|
||||
| IM 本次专项测试 | MySQL 5.7.44,接口 28 例、连接 4 例通过 |
|
||||
| 数据修复工具 | MySQL 5.7、8.0 合计 20 例通过,2 个条件入口按环境跳过 |
|
||||
| 真实 HTTP 与 WebSocket 联调 | 独立本地 IM 接口及两个连接节点,1 个完整流程通过 |
|
||||
| 客户端凭证管理 | 5 例通过 |
|
||||
| 客户端 H5 | 构建通过 |
|
||||
| 业务 JAR | Java 8 构建通过 |
|
||||
| IM 接口、连接 JAR | Java 17 构建通过 |
|
||||
|
||||
IM 测试和真实联调额外使用 `file.encoding=US-ASCII`,验证中文 JSON 正文读取与验签依赖的编码已经明确。
|
||||
|
||||
### 数据和业务事务
|
||||
|
||||
- 原 100、131 冲突、同昵称普通会员、改昵称、30 字昵称、空手机号和最大 Long 用户名。
|
||||
- 业务事务回滚时同步任务回滚,逻辑删除与物理缺失能够被对账识别。
|
||||
- 持久化编号水位、降低自增值后的新注册、同手机号并发注册。
|
||||
- 同 ID 并发 IM 创建、重复请求、旧版本覆盖拦截、同版本不同资料冲突。
|
||||
- 用户、好友及群成员资料更新的同事务提交与故障回滚,群备注和原密码保留。
|
||||
- 欢迎消息并发重试去重,Redis 更新失败后的同版本补偿,禁用恢复及独立封禁的版本校验。
|
||||
|
||||
### 真实双节点连接
|
||||
|
||||
使用实际启动的新版 `im-platform.jar` 和两个 `im-server.jar` 进程连接隔离 MySQL、Redis,通过真实第三方签名请求执行。
|
||||
|
||||
1. 同步中文昵称,获得第三方聊天凭证。
|
||||
2. 同一账号连接两个 IM 节点,确认两条连接登录成功。
|
||||
3. 同步禁用状态,两个节点均收到控制消息并关闭连接。
|
||||
4. 旧 HTTP 凭证、刷新请求均被账号状态拦截。
|
||||
5. 恢复会员后领取新凭证,新连接及接口访问正常,原凭证继续失效。
|
||||
6. 独立用户名密码登录返回业务入口提示。
|
||||
7. 模拟 IM 未接收中间禁用状态、随后直接收到恢复后的最新任务,通过状态变更版本撤销旧凭证,心跳关闭旧连接。
|
||||
|
||||
### 修复工具演练
|
||||
|
||||
在两种 MySQL 上分别演练互相占用的用户名循环、两阶段改名、重复 apply、提交确认丢失后的重跑、维护期 verify、缺失用户补建后的 verify --ready,以及重复 rollback。
|
||||
|
||||
额外检查数据库实际排序规则下的全角用户名占用、字段容量不足、非事务表、异常历史用户编号、超过 JavaScript 安全整数范围的 ID 和版本。后续编辑触发回滚保护时会返回字段路径和差异。
|
||||
|
||||
### 客户端
|
||||
|
||||
验证账号不可用后停止轮询和重试、旧登录上下文的迟到失败、强制下线期间的迟到刷新结果、业务补签返回 IM 封禁原因,以及旧业务登录的补签结果不会覆盖新业务登录凭证。
|
||||
|
||||
## 本次工程决策
|
||||
|
||||
增加持久化编号水位,覆盖 MySQL 5.7 重启重建 AUTO_INCREMENT 的行为,并将历史消息、好友和群关系中保留的用户编号纳入预留范围。
|
||||
|
||||
最新同步资料中保存状态变更版本,覆盖禁用快照被后续恢复快照替代的情况。IM 通过已应用同步版本判断这次撤销是否已处理,重复请求保持幂等。
|
||||
|
||||
业务提交后的发送使用私有有界执行队列,队列繁忙时任务保留在数据库中,避免影响项目原有异步执行器。注册及业务登录的响应结构沿用现有定义。
|
||||
|
||||
## 交付与现场验收
|
||||
|
||||
三个服务发布包、客户端 H5 产物、两侧结构迁移、四命令修复工具、SHA256 校验值及发布回滚说明随交付包提供。
|
||||
|
||||
业务 JAR 包含当前工作区同期已有的会员分销关系改动,构建范围按当前工作区记录。生产迁移、线上进程发布、131 等真实会员凭证与消息收发、原生手机客户端强制下线展示由现场执行验收。本地联调使用隔离测试账号与服务。
|
||||
|
||||
线上执行后,保存正式清单的 `journal.json`、`verification.json` 和完整备份,并补记维护时间、发布实例、真实账号验收结果与观察期错误数量,形成正式修复执行记录。
|
||||
@@ -0,0 +1,68 @@
|
||||
# 聊天用户同步修复简版上线步骤
|
||||
|
||||
本次上线需要执行两份 SQL、修复一次历史账号,再替换三个 JAR。按下面四步操作。
|
||||
|
||||
## 1. 备份并停服
|
||||
|
||||
备份 `chat_v1`、`im_platform` 两个库和原来的三个 JAR。暂停用户访问,停止业务服务、IM 接口服务和全部聊天连接节点,暂停自动拉起。
|
||||
|
||||
## 2. 在两个库分别执行 SQL
|
||||
|
||||
用现有数据库管理软件打开发布包里的文件,在对应数据库执行。
|
||||
|
||||
| 数据库 | SQL 文件 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `chat_v1` | `migrations/business.sql` | 创建同步任务表、编号水位表,添加定时任务配置 |
|
||||
| `im_platform` | `migrations/im.sql` | 为 IM 用户增加会员状态和版本字段 |
|
||||
|
||||
IM 结构脚本执行一次。每个脚本成功后继续下一步。
|
||||
|
||||
## 3. 修复历史账号
|
||||
|
||||
在能连接两个数据库、装有 Node.js 20.19 及以上版本的电脑操作。发布包已提供本次线上地址、库名和账号配置,密码在运行时输入。
|
||||
|
||||
以下命令使用 Bash。在解压后的发布包目录打开终端,依次执行。
|
||||
|
||||
```bash
|
||||
cd repair-im-users
|
||||
npm ci --omit=dev
|
||||
cp config.production.example.json config.local.json
|
||||
|
||||
read -r -s -p '业务数据库密码 ' CHAT_REPAIR_BUSINESS_PASSWORD
|
||||
printf '\n'
|
||||
read -r -s -p 'IM 数据库密码 ' CHAT_REPAIR_IM_PASSWORD
|
||||
printf '\n'
|
||||
export CHAT_REPAIR_BUSINESS_PASSWORD CHAT_REPAIR_IM_PASSWORD
|
||||
|
||||
node repair-im-users.mjs plan --config config.local.json --out runs/go.plan.json
|
||||
node repair-im-users.mjs apply --config config.local.json --plan runs/go.plan.json --maintenance
|
||||
node repair-im-users.mjs verify --config config.local.json --plan runs/go.plan.json
|
||||
```
|
||||
|
||||
`plan` 应输出 `schemaReady=true`,`apply` 应输出 `phase=complete`,`verify` 应输出 `verified=true`。任一步报错,保持停服并处理报错后继续。保存 `runs/go.plan.json*`,用于本批次核验和回滚。
|
||||
|
||||
这一步将历史用户名改为 `u<ID>`,同步昵称头像,停用已删除及孤立账号,并初始化同步任务和安全编号水位。
|
||||
|
||||
## 4. 替换 JAR、启动并验收
|
||||
|
||||
替换发布包 `jars` 目录里的三个 JAR,沿用现场有效配置。IM 接口确认 `im.auth.business-only=true`。
|
||||
|
||||
按以下顺序启动,每个服务启动成功后再启动下一个。
|
||||
|
||||
1. `im-platform.jar`
|
||||
2. `im-server.jar` 的全部节点
|
||||
3. `yudao-server.jar`
|
||||
|
||||
进入管理后台定时任务页面,点“同步”,启用并执行一次 `memberChatSyncJob`。后台“同步”会重新注册全部任务,其他任务按现场维护安排控制。
|
||||
|
||||
等待补偿完成,在刚才的 `repair-im-users` 目录执行。
|
||||
|
||||
```bash
|
||||
node repair-im-users.mjs verify --config config.local.json --plan runs/go.plan.json --ready
|
||||
```
|
||||
|
||||
输出 `verified=true`、`ready=true`,且 `pending`、`pendingInitialization` 均为 0 后,使用 131 账号登录,确认聊天凭证签发和消息收发正常,再恢复用户访问。需要多轮补偿时等待定时任务继续执行。
|
||||
|
||||
有独立发布客户端的部署,同时发布本次 H5 或对应 App 版本,验收停用提示和停止重连。
|
||||
|
||||
业务 JAR 包含当前工作区已有的会员分销关系改动,发布时一并核对。详细异常处理和回滚步骤见同目录《聊天用户同步修复_发布与数据处理》。
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
runs/
|
||||
config.local.json
|
||||
@@ -0,0 +1,121 @@
|
||||
# 聊天历史用户修复工具
|
||||
|
||||
按会员 ID 修正 IM 身份,保留同昵称会员的各自账号与历史关系。支持 MySQL 5.7、8.0 和 Node.js 20.19 及以上版本。工具可以在能连接两库的运维电脑运行。
|
||||
|
||||
## 准备连接
|
||||
|
||||
```bash
|
||||
cd repair-im-users
|
||||
npm ci --omit=dev
|
||||
cp config.example.json config.local.json
|
||||
chmod 600 config.local.json
|
||||
```
|
||||
|
||||
在 `config.local.json` 分别填写业务库和 IM 库的地址、端口、库名、账号。`passwordEnv` 指向密码环境变量。按现有数据库要求设置可选的 `ssl` 参数。
|
||||
|
||||
下面的密码输入示例使用 Bash。
|
||||
|
||||
```bash
|
||||
read -r -s -p '业务数据库密码 ' CHAT_REPAIR_BUSINESS_PASSWORD
|
||||
printf '\n'
|
||||
read -r -s -p 'IM 数据库密码 ' CHAT_REPAIR_IM_PASSWORD
|
||||
printf '\n'
|
||||
export CHAT_REPAIR_BUSINESS_PASSWORD CHAT_REPAIR_IM_PASSWORD
|
||||
umask 077
|
||||
```
|
||||
|
||||
`plan`、`verify` 使用只读事务。`apply`、`rollback` 需要相应表的查询、更新、插入权限,`apply` 还需要业务会员表的 ALTER 权限。结构迁移通过独立 SQL 文件执行。
|
||||
|
||||
## 四个命令
|
||||
|
||||
```bash
|
||||
# 提前只读检查,允许新结构尚未安装
|
||||
node repair-im-users.mjs plan --config config.local.json --out runs/preflight.plan.json
|
||||
|
||||
# 维护窗口内,执行结构迁移后,重新生成正式清单
|
||||
node repair-im-users.mjs plan --config config.local.json --out runs/maintenance.plan.json
|
||||
|
||||
# 保持两库写入、旧服务和同步任务暂停
|
||||
node repair-im-users.mjs apply --config config.local.json --plan runs/maintenance.plan.json --maintenance
|
||||
|
||||
# 服务启动前核验资料、关系、消息数量和编号水位
|
||||
node repair-im-users.mjs verify --config config.local.json --plan runs/maintenance.plan.json
|
||||
|
||||
# 启动新版服务并完成后台补偿后,开放流量前再次核验
|
||||
node repair-im-users.mjs verify --config config.local.json --plan runs/maintenance.plan.json --ready
|
||||
|
||||
# 需要回滚时,重新进入维护窗口并暂停服务
|
||||
node repair-im-users.mjs rollback --config config.local.json --plan runs/maintenance.plan.json --maintenance
|
||||
```
|
||||
|
||||
每条命令成功退出码为 0,异常退出码为 1。`--maintenance` 表示操作者已经完成停写,工具依据数据库快照再次检查数据。
|
||||
|
||||
## 清单和执行记录
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `*.plan.json` | 原字段值、目标字段值、数据库身份、批次号和 SHA256 |
|
||||
| `*.plan.json.summary.json` | 状态数量、缺失用户、同昵称清单、结构预检查 |
|
||||
| `*.plan.json.journal.json` | IM 提交、业务任务提交、自增起点调整和回滚断点 |
|
||||
| `*.plan.json.verification.json` | 最近一次成功核验结果 |
|
||||
|
||||
清单包含会员手机号、头像地址等资料,以及用于核验密码未变化的哈希指纹。请与两库完整备份一起保存至受控目录。生成文件采用私有权限和原子替换。清单、记录、配置文件须保持成套保存。
|
||||
|
||||
## 处理规则
|
||||
|
||||
| 数据分类 | 用户名 | 会员状态 |
|
||||
| --- | --- | --- |
|
||||
| 正常业务会员 | `u<ID>` | 0 |
|
||||
| 禁用业务会员 | `u<ID>` | 1 |
|
||||
| 逻辑删除会员 | `u<ID>` | 2 |
|
||||
| IM 独有正数用户 | `u<ID>` | 3 |
|
||||
| 业务会员缺少 IM 用户 | 创建同步任务,启动新版接口后补建 | 按业务当前状态 |
|
||||
| 系统账号 -100、-200 | 保留专用初始化规则 | 保留并核验 |
|
||||
|
||||
工具使用精确十进制字符串和 BigInt 处理 ID、同步版本和撤销版本。头像、昵称同步至用户、好友和群成员的资料副本。群备注沿用原值。
|
||||
|
||||
正式执行前检查唯一索引、字段容量、InnoDB 引擎、异常 ID、保留账号占用,并通过数据库实际排序规则检查用户名冲突。两库身份包含库名、服务器 UUID、服务器名称和版本。
|
||||
|
||||
## 提交和重跑
|
||||
|
||||
1. 对原始业务资料和 IM 快照重新核对。
|
||||
2. 在一个 IM 事务内,将所有待迁移用户改为批次临时用户名,再统一改为 `u<ID>`,更新资料副本和账号状态。
|
||||
3. 核对用户、关系和消息快照后提交 IM 事务。
|
||||
4. 在业务事务内写入同步任务和持久化编号水位。
|
||||
5. 单独校正会员表 AUTO_INCREMENT,保存完成断点。
|
||||
|
||||
编号水位覆盖业务用户、IM 用户,以及好友、群和历史消息中的用户编号。新版业务注册使用持久化水位分配 ID,可覆盖 MySQL 5.7 重启后重建自增值的情况。
|
||||
|
||||
进程中断或提交响应丢失后,保持停写,使用同一份清单重新运行 `apply`。工具识别已提交快照并补齐后续阶段。完成后的重复执行保持幂等。`imApplying`、`imCommitted`、`businessCommitted` 是执行过程中的正常断点名称。
|
||||
|
||||
## 核验边界
|
||||
|
||||
`verify` 用于服务启动前,要求原有消息数量与快照完全一致。`verify --ready` 允许补建账号、默认好友和欢迎消息,要求原有关系仍存在、消息总量未减少、任务已确认且正常会员已完成初始化。
|
||||
|
||||
核验期间继续暂停用户业务写入。若已经开放流量,会员改资料、新消息或群变动会影响快照核验,应依据新清单及业务记录检查。
|
||||
|
||||
`--ready` 同时要求系统账号存在且可用。该步骤在业务服务运行专用系统账号初始化后执行。
|
||||
|
||||
## 回滚规则
|
||||
|
||||
回滚前再次备份当前两库,暂停同步、写入和所有 IM 服务。
|
||||
|
||||
工具先检查迁移后字段、好友、群成员和消息是否发生后续变化。发现差异会打印字段路径及原值、现值并终止覆盖。回滚采用两阶段改名,恢复原用户名、昵称和头像副本。
|
||||
|
||||
会员停用状态、人工封禁、登录撤销版本和持久化编号水位继续保持当前有效约束。同步版本和登录撤销版本递增,业务任务重新排入补偿队列。数据回滚完成后应保持维护状态,再生成正式清单执行修复,或依据差异进行人工处理。新版同步任务随后执行时会再次采用 `u<ID>`。
|
||||
|
||||
服务启动产生的新好友、欢迎消息,以及恢复流量后的资料和消息变化,都可能触发回滚保护。此时依据完整数据库备份和差异记录制定定向恢复步骤。
|
||||
|
||||
回滚命令只负责本批次资料字段及同步任务,表结构与历史账号继续保留。恢复后启动新版 IM 接口重建 Redis 状态,再启动连接节点和业务服务,按当前账号状态重新领取凭证。
|
||||
|
||||
## 本地测试
|
||||
|
||||
```bash
|
||||
# 算法和清单测试
|
||||
npm test
|
||||
|
||||
# 指向本机隔离 MySQL 测试端口,每个测试创建并清理独立库
|
||||
CHAT_REPAIR_TEST_PORTS=33907,33909 npm test
|
||||
```
|
||||
|
||||
MySQL 测试夹具使用本地专用凭据,限制为显式指定的 `127.0.0.1` 端口。HTTP、WebSocket 测试依赖完整源码树和独立本地服务,运行方式见发布核验报告。
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"business": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"user": "chat_v1",
|
||||
"database": "chat_v1",
|
||||
"passwordEnv": "CHAT_REPAIR_BUSINESS_PASSWORD"
|
||||
},
|
||||
"im": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"user": "im_platform",
|
||||
"database": "im_platform",
|
||||
"passwordEnv": "CHAT_REPAIR_IM_PASSWORD"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"business": {
|
||||
"host": "139.224.25.207",
|
||||
"port": 3306,
|
||||
"user": "chat_v1",
|
||||
"database": "chat_v1",
|
||||
"passwordEnv": "CHAT_REPAIR_BUSINESS_PASSWORD"
|
||||
},
|
||||
"im": {
|
||||
"host": "139.224.25.207",
|
||||
"port": 3306,
|
||||
"user": "im_platform",
|
||||
"database": "im_platform",
|
||||
"passwordEnv": "CHAT_REPAIR_IM_PASSWORD"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const MAX_MEMBER_ID = 9223372036854775807n;
|
||||
export const USER_COLUMNS = ['user_name', 'nick_name', 'head_image', 'head_image_thumb',
|
||||
'member_state', 'sync_version', 'auth_version', 'access_disabled'];
|
||||
export const FRIEND_COLUMNS = ['friend_nick_name', 'friend_head_image'];
|
||||
export const GROUP_MEMBER_COLUMNS = ['user_nick_name', 'head_image'];
|
||||
|
||||
export function stable(value) {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function fingerprint(value) {
|
||||
return createHash('sha256').update(JSON.stringify(stable(value)) ?? 'undefined').digest('hex');
|
||||
}
|
||||
|
||||
export function assertSame(actual, expected, label) {
|
||||
if (fingerprint(actual) !== fingerprint(expected)) {
|
||||
const differences = [];
|
||||
const inspect = (left, right, path) => {
|
||||
if (differences.length >= 10 || fingerprint(left) === fingerprint(right)) return;
|
||||
if (left != null && right != null && typeof left === 'object' && typeof right === 'object') {
|
||||
for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) inspect(left[key], right[key], `${path}.${key}`);
|
||||
} else {
|
||||
differences.push({ path, expected: right ?? null, actual: left ?? null });
|
||||
}
|
||||
};
|
||||
inspect(actual, expected, label);
|
||||
throw new Error(`${label}与清单不一致,请保持维护状态并检查差异 ${JSON.stringify(differences)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function memberId(value) {
|
||||
const text = String(value);
|
||||
if (!/^-?[0-9]+$/.test(text)) throw new Error('用户编号须为精确的十进制整数');
|
||||
const id = BigInt(text);
|
||||
if (id > MAX_MEMBER_ID || id < -MAX_MEMBER_ID) throw new Error('用户编号超出 Long 范围');
|
||||
if (id <= 0n && id !== -100n && id !== -200n) throw new Error(`异常用户编号 ${text}`);
|
||||
return id.toString();
|
||||
}
|
||||
|
||||
export function profile(member) {
|
||||
const id = memberId(member.id);
|
||||
if (BigInt(id) <= 0n) throw new Error(`业务会员编号须为正数 ${id}`);
|
||||
const nickname = member.nickname?.trim() ? member.nickname : `u${id}`;
|
||||
if (nickname.length > 30) throw new Error(`会员 ${id} 的昵称超过接口支持的30字符`);
|
||||
return {
|
||||
userId: id,
|
||||
userName: `u${id}`,
|
||||
nickName: nickname,
|
||||
avatar: member.avatar ?? '',
|
||||
phone: member.mobile ?? null,
|
||||
memberState: member.deleted ? 2 : Number(member.status) === 0 ? 0 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function temporaryName(id, runId) {
|
||||
return `~${runId.slice(0, 4)}_${BigInt(id).toString(36)}`;
|
||||
}
|
||||
|
||||
export function createPlan(before, identities, runId) {
|
||||
const members = new Map(before.members.map(row => [memberId(row.id), row]));
|
||||
if (members.size !== before.members.length) throw new Error('业务会员编号重复');
|
||||
const users = new Map(before.users.map(row => [memberId(row.id), row]));
|
||||
if (users.size !== before.users.length) throw new Error('IM 用户编号重复');
|
||||
const tasks = new Map(before.tasks.map(row => [memberId(row.member_id), row]));
|
||||
const profiles = new Map([...members].map(([id, member]) => [id, profile(member)]));
|
||||
for (const [id, task] of tasks) {
|
||||
if (!profiles.has(id)) {
|
||||
memberId(id);
|
||||
const previous = JSON.parse(task.profile_json);
|
||||
const user = users.get(id);
|
||||
profiles.set(id, { ...previous, userId: id, userName: `u${id}`,
|
||||
nickName: user?.nick_name ?? previous.nickName,
|
||||
avatar: user?.head_image ?? previous.avatar, memberState: 3 });
|
||||
}
|
||||
}
|
||||
const targetVersions = new Map();
|
||||
const taskChanges = [];
|
||||
let maximum = 0n;
|
||||
const referenced = [
|
||||
...before.friends.flatMap(row => [row.user_id, row.friend_id]),
|
||||
...before.groupMembers.map(row => row.user_id),
|
||||
...before.groups.map(row => row.owner_id),
|
||||
...Object.values(before.messages).flatMap(row => [row.maximumSender, row.maximumReceiver]),
|
||||
].filter(value => value != null);
|
||||
for (const id of [...members.keys(), ...users.keys(), ...tasks.keys(), ...referenced.map(memberId)]) {
|
||||
if (BigInt(id) > maximum) maximum = BigInt(id);
|
||||
}
|
||||
if (maximum === MAX_MEMBER_ID) throw new Error('会员编号已经耗尽,无法安全提高自增起点');
|
||||
for (const [id, desired] of profiles) {
|
||||
const userVersion = BigInt(users.get(id)?.sync_version ?? 0);
|
||||
const taskVersion = BigInt(tasks.get(id)?.version ?? 0);
|
||||
const version = (userVersion > taskVersion ? userVersion : taskVersion) + 1n;
|
||||
if (version > MAX_MEMBER_ID) throw new Error(`会员 ${id} 的同步版本已耗尽`);
|
||||
targetVersions.set(id, version.toString());
|
||||
taskChanges.push({
|
||||
member_id: id,
|
||||
tenant_id: String(members.get(id)?.tenant_id ?? tasks.get(id).tenant_id),
|
||||
profile_json: JSON.stringify(desired),
|
||||
member_state: desired.memberState,
|
||||
version: version.toString(),
|
||||
synced_version: '0',
|
||||
initialized: Boolean(tasks.get(id)?.initialized),
|
||||
retry_count: 0,
|
||||
last_error: '',
|
||||
});
|
||||
}
|
||||
const userChanges = [];
|
||||
for (const user of before.users) {
|
||||
const id = memberId(user.id);
|
||||
if (BigInt(id) < 0n) continue;
|
||||
const desired = profiles.get(id);
|
||||
const state = desired?.memberState ?? 3;
|
||||
const disabled = state !== 0 || Boolean(user.is_banned);
|
||||
let authVersion = BigInt(user.auth_version ?? 0);
|
||||
if (Number(user.member_state ?? 0) !== state || Boolean(user.access_disabled) !== disabled) authVersion++;
|
||||
if (disabled && authVersion === 0n) authVersion = 1n;
|
||||
const version = targetVersions.get(id) ?? (BigInt(user.sync_version ?? 0) + 1n).toString();
|
||||
userChanges.push({
|
||||
id,
|
||||
user_name: `u${id}`,
|
||||
nick_name: desired?.nickName ?? user.nick_name,
|
||||
head_image: desired?.avatar ?? user.head_image,
|
||||
head_image_thumb: desired?.avatar ?? user.head_image_thumb,
|
||||
member_state: state,
|
||||
sync_version: version,
|
||||
auth_version: authVersion.toString(),
|
||||
access_disabled: disabled,
|
||||
});
|
||||
}
|
||||
const changes = new Map(userChanges.map(row => [row.id, row]));
|
||||
const friendChanges = before.friends.filter(row => changes.has(String(row.friend_id))).map(row => {
|
||||
const user = changes.get(String(row.friend_id));
|
||||
return { id: row.id, friend_nick_name: user.nick_name, friend_head_image: user.head_image_thumb };
|
||||
});
|
||||
const groupMemberChanges = before.groupMembers.filter(row => changes.has(String(row.user_id))).map(row => {
|
||||
const user = changes.get(String(row.user_id));
|
||||
return { id: row.id, user_nick_name: user.nick_name, head_image: user.head_image_thumb };
|
||||
});
|
||||
const reserved = new Set(before.users.filter(row => BigInt(row.id) < 0n).map(row => row.user_name.toLowerCase()));
|
||||
for (const task of taskChanges) {
|
||||
if (reserved.has(`u${task.member_id}`)) throw new Error(`会员 ${task.member_id} 的目标用户名与保留账号冲突`);
|
||||
}
|
||||
const temporary = new Set();
|
||||
for (const user of userChanges) {
|
||||
const name = temporaryName(user.id, runId);
|
||||
if (reserved.has(user.user_name.toLowerCase()) || reserved.has(name.toLowerCase()) || temporary.has(name)) {
|
||||
throw new Error(`会员 ${user.id} 的目标用户名与保留账号冲突`);
|
||||
}
|
||||
temporary.add(name);
|
||||
}
|
||||
const plan = {
|
||||
formatVersion: 1,
|
||||
runId,
|
||||
identities,
|
||||
createdAt: new Date().toISOString(),
|
||||
before,
|
||||
userChanges,
|
||||
friendChanges,
|
||||
groupMemberChanges,
|
||||
taskChanges,
|
||||
nextMemberId: (maximum + 1n).toString(),
|
||||
};
|
||||
plan.hash = fingerprint(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function validatePlan(plan) {
|
||||
const { hash, ...content } = plan;
|
||||
if (plan.formatVersion !== 1 || fingerprint(content) !== hash) throw new Error('修复清单格式或校验值错误');
|
||||
memberId(plan.nextMemberId);
|
||||
}
|
||||
|
||||
function applyChanges(rows, changes) {
|
||||
const byId = new Map(changes.map(row => [String(row.id), row]));
|
||||
return rows.map(row => ({ ...row, ...byId.get(String(row.id)) }));
|
||||
}
|
||||
|
||||
export function expectedIm(plan) {
|
||||
return {
|
||||
users: applyChanges(plan.before.users, plan.userChanges),
|
||||
friends: applyChanges(plan.before.friends, plan.friendChanges),
|
||||
groupMembers: applyChanges(plan.before.groupMembers, plan.groupMemberChanges),
|
||||
groups: plan.before.groups,
|
||||
messages: plan.before.messages,
|
||||
};
|
||||
}
|
||||
|
||||
export function imPart(snapshot) {
|
||||
const { users, friends, groupMembers, groups, messages } = snapshot;
|
||||
return { users, friends, groupMembers, groups, messages };
|
||||
}
|
||||
|
||||
export function rollbackChanges(plan, current) {
|
||||
const expected = expectedIm(plan);
|
||||
const liveUsers = new Map(current.users.map(row => [String(row.id), row]));
|
||||
const expectedUsers = new Map(expected.users.map(row => [String(row.id), row]));
|
||||
const users = [];
|
||||
for (const before of plan.before.users) {
|
||||
if (BigInt(before.id) < 0n) continue;
|
||||
const live = liveUsers.get(String(before.id));
|
||||
if (!live) throw new Error(`会员 ${before.id} 已不存在,停止回滚`);
|
||||
const after = expectedUsers.get(String(before.id));
|
||||
const comparable = { ...live, auth_version: after.auth_version };
|
||||
assertSame(comparable, after, `会员 ${before.id}`);
|
||||
const authVersion = BigInt(live.auth_version) + 1n;
|
||||
const syncVersion = BigInt(live.sync_version) + 1n;
|
||||
users.push({ ...live, user_name: before.user_name, nick_name: before.nick_name,
|
||||
head_image: before.head_image, head_image_thumb: before.head_image_thumb,
|
||||
auth_version: authVersion.toString(), sync_version: syncVersion.toString() });
|
||||
}
|
||||
assertSame(current.friends, expected.friends, '好友资料');
|
||||
assertSame(current.groupMembers, expected.groupMembers, '群成员资料');
|
||||
assertSame(current.groups, expected.groups, '群关系');
|
||||
assertSame(current.messages, expected.messages, '消息数量');
|
||||
return { users, friends: plan.before.friends, groupMembers: plan.before.groupMembers };
|
||||
}
|
||||
|
||||
export function summary(plan) {
|
||||
const states = { active: 0, disabled: 0, deleted: 0, orphan: 0 };
|
||||
const names = ['active', 'disabled', 'deleted', 'orphan'];
|
||||
for (const row of plan.userChanges) states[names[row.member_state]]++;
|
||||
const nicknames = new Map();
|
||||
for (const member of plan.before.members) {
|
||||
const same = nicknames.get(member.nickname) ?? [];
|
||||
same.push(member.id);
|
||||
nicknames.set(member.nickname, same);
|
||||
}
|
||||
const duplicateNicknames = [...nicknames].filter(([, ids]) => ids.length > 1)
|
||||
.map(([nickname, memberIds]) => ({ nickname, memberIds }));
|
||||
return {
|
||||
runId: plan.runId,
|
||||
businessMembers: plan.before.members.length,
|
||||
imUsers: plan.before.users.length,
|
||||
...states,
|
||||
missingImUsers: plan.taskChanges.filter(row => !plan.before.users.some(user => user.id === row.member_id)).length,
|
||||
nextMemberId: plan.nextMemberId,
|
||||
duplicateNicknameGroups: duplicateNicknames.length,
|
||||
duplicateNicknameMembers: duplicateNicknames.reduce((count, group) => count + group.memberIds.length, 0),
|
||||
duplicateNicknames,
|
||||
};
|
||||
}
|
||||
Generated
+138
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"name": "friendship-repair-im-users",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "friendship-repair-im-users",
|
||||
"dependencies": {
|
||||
"mysql2": "3.15.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/is-property": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru.min": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.5.tgz",
|
||||
"integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=1.30.0",
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.15.3",
|
||||
"resolved": "https://registry.npmmirror.com/mysql2/-/mysql2-3.15.3.tgz",
|
||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.1",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"long": "^5.2.1",
|
||||
"lru.min": "^1.0.0",
|
||||
"named-placeholders": "^1.1.3",
|
||||
"seq-queue": "^0.0.5",
|
||||
"sqlstring": "^2.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lru.min": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/seq-queue": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"node_modules/sqlstring": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "friendship-repair-im-users",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": { "node": ">=20.19" },
|
||||
"scripts": { "test": "node --test test/*.test.mjs" },
|
||||
"dependencies": { "mysql2": "3.15.3" }
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, open, readFile, rename } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
USER_COLUMNS, FRIEND_COLUMNS, GROUP_MEMBER_COLUMNS, assertSame, createPlan,
|
||||
expectedIm, fingerprint, imPart, rollbackChanges, summary, temporaryName, validatePlan,
|
||||
} from './core.mjs';
|
||||
|
||||
const TASK_COLUMNS = ['tenant_id', 'profile_json', 'member_state', 'version', 'synced_version',
|
||||
'initialized', 'retry_count', 'last_error'];
|
||||
const NEW_COLUMNS = ['member_state', 'sync_version', 'auth_version', 'access_disabled'];
|
||||
|
||||
function argumentsFor(argv) {
|
||||
const [command, ...args] = argv;
|
||||
const options = {};
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const key = args[index];
|
||||
if (key === '--maintenance' || key === '--ready') {
|
||||
options[key.slice(2)] = true;
|
||||
} else if (['--config', '--plan', '--out'].includes(key) && args[index + 1]) {
|
||||
options[key.slice(2)] = args[++index];
|
||||
} else {
|
||||
throw new Error(`无法识别的参数 ${key}`);
|
||||
}
|
||||
}
|
||||
if (!['plan', 'apply', 'verify', 'rollback'].includes(command) || !options.config) {
|
||||
throw new Error('用法为 node repair-im-users.mjs plan|apply|verify|rollback --config <配置文件> [--plan <清单>] [--out <清单>] [--maintenance] [--ready]');
|
||||
}
|
||||
if (command !== 'plan' && !options.plan) throw new Error('请使用 --plan 指定已生成的修复清单');
|
||||
if (['apply', 'rollback'].includes(command) && !options.maintenance) {
|
||||
throw new Error('执行前须暂停业务写入、所有同步任务和 IM 服务,再指定 --maintenance');
|
||||
}
|
||||
return { command, options };
|
||||
}
|
||||
|
||||
async function atomicJson(path, value) {
|
||||
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${path}.tmp-${process.pid}`;
|
||||
const handle = await open(temporary, 'w', 0o600);
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify(value, null, 2) + '\n');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temporary, path);
|
||||
}
|
||||
|
||||
async function loadJson(path, fallback) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT' && fallback !== undefined) return fallback;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(config) {
|
||||
if (!config?.host || !config.user || !config.database || !config.passwordEnv) {
|
||||
throw new Error('每个数据库连接均须明确填写 host、user、database、passwordEnv');
|
||||
}
|
||||
const password = process.env[config.passwordEnv];
|
||||
if (password === undefined) throw new Error(`缺少密码环境变量 ${config.passwordEnv}`);
|
||||
return mysql.createConnection({
|
||||
host: config.host, port: config.port ?? 3306, user: config.user,
|
||||
database: config.database, password, ssl: config.ssl,
|
||||
connectTimeout: 10000, supportBigNumbers: true, bigNumberStrings: true,
|
||||
dateStrings: true, charset: 'utf8mb4', multipleStatements: false,
|
||||
});
|
||||
}
|
||||
|
||||
function normalize(row) {
|
||||
const output = { ...row };
|
||||
for (const key of ['id', 'member_id', 'tenant_id', 'friend_id', 'user_id', 'group_id', 'owner_id',
|
||||
'version', 'synced_version', 'sync_version', 'auth_version']) {
|
||||
if (output[key] != null) output[key] = String(output[key]);
|
||||
}
|
||||
for (const key of ['deleted', 'is_banned', 'access_disabled', 'initialized']) {
|
||||
if (key in output) {
|
||||
const value = output[key];
|
||||
output[key] = Buffer.isBuffer(value) ? value[0] !== 0 : Boolean(Number(value));
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function rows(connection, sql, parameters = []) {
|
||||
const [result] = await connection.query(sql, parameters);
|
||||
return result.map(normalize);
|
||||
}
|
||||
|
||||
async function identity(connection) {
|
||||
const [row] = await rows(connection, 'SELECT DATABASE() AS db, @@server_uuid AS serverUuid, @@hostname AS serverName, VERSION() AS version');
|
||||
return row;
|
||||
}
|
||||
|
||||
async function inspectSchema(business, im) {
|
||||
const columns = await rows(im, 'SELECT COLUMN_NAME AS name, CHARACTER_MAXIMUM_LENGTH AS maximum, COLLATION_NAME AS collation FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', ['im_user']);
|
||||
const names = new Set(columns.map(row => row.name));
|
||||
const [indexes] = await im.query('SHOW INDEX FROM im_user');
|
||||
const usernameIndexes = indexes.filter(row => Number(row.Non_unique) === 0 && row.Column_name === 'user_name' && row.Sub_part == null);
|
||||
const hasUniqueUsername = usernameIndexes.some(index => indexes.filter(row => row.Key_name === index.Key_name).length === 1);
|
||||
if (!hasUniqueUsername) throw new Error('IM 用户表须具备完整 user_name 唯一索引');
|
||||
const username = columns.find(row => row.name === 'user_name');
|
||||
if (Number(username?.maximum) < 20) throw new Error('IM 用户名字段长度须至少为20字符');
|
||||
const [tables] = await business.query('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', ['member_chat_sync']);
|
||||
const [sequences] = await business.query('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', ['member_chat_sync_sequence']);
|
||||
for (const [connection, tables] of [[business, ['member_user', 'member_chat_sync', 'member_chat_sync_sequence']],
|
||||
[im, ['im_user', 'im_friend', 'im_group_member']]]) {
|
||||
const definitions = await rows(connection, 'SELECT TABLE_NAME AS name,ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)', [tables]);
|
||||
for (const table of definitions) {
|
||||
if (table.engine !== 'InnoDB') throw new Error(`表 ${table.name} 必须使用 InnoDB 才能保证修复事务完整性`);
|
||||
}
|
||||
}
|
||||
const capacities = await rows(im, 'SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName,CHARACTER_MAXIMUM_LENGTH AS maximum FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)',
|
||||
[['im_user', 'im_friend', 'im_group_member']]);
|
||||
return { imColumns: [...names], usernameCollation: username.collation,
|
||||
missingImColumns: NEW_COLUMNS.filter(name => !names.has(name)), taskTableExists: tables.length === 1,
|
||||
sequenceTableExists: sequences.length === 1, capacities };
|
||||
}
|
||||
|
||||
async function validateTargets(im, schema, plan) {
|
||||
const tables = { im_user: plan.userChanges, im_friend: plan.friendChanges, im_group_member: plan.groupMemberChanges };
|
||||
for (const definition of schema.capacities) {
|
||||
if (definition.maximum == null) continue;
|
||||
for (const row of tables[definition.tableName]) {
|
||||
const value = row[definition.columnName];
|
||||
if (value != null && Array.from(String(value)).length > Number(definition.maximum)) {
|
||||
throw new Error(`表 ${definition.tableName} 编号 ${row.id} 的 ${definition.columnName} 超出字段长度 ${definition.maximum}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const temporary = plan.userChanges.map(user => temporaryName(user.id, plan.runId));
|
||||
const finalNames = [...new Set([...plan.userChanges.map(user => user.user_name), ...plan.taskChanges.map(task => `u${task.member_id}`)])];
|
||||
for (const [names, condition] of [[temporary, ''], [finalNames, 'id < 0 AND ']]) {
|
||||
for (let offset = 0; offset < names.length; offset += 500) {
|
||||
const conflicts = await rows(im, `SELECT id,user_name FROM im_user WHERE ${condition}user_name IN (?)`, [names.slice(offset, offset + 500)]);
|
||||
if (conflicts.length) throw new Error(`数据库排序规则下存在保留用户名或临时用户名占用 ${JSON.stringify(conflicts)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readMembers(connection, lock = false) {
|
||||
return rows(connection, 'SELECT id,tenant_id,nickname,avatar,mobile,status,deleted FROM member_user ORDER BY id' + (lock ? ' FOR UPDATE' : ''));
|
||||
}
|
||||
|
||||
async function readTasks(connection, exists, lock = false) {
|
||||
if (!exists) return [];
|
||||
return rows(connection, `SELECT member_id,${TASK_COLUMNS.join(',')},deleted FROM member_chat_sync ORDER BY member_id` + (lock ? ' FOR UPDATE' : ''));
|
||||
}
|
||||
|
||||
async function readIm(connection, schema, lock = false) {
|
||||
const defaults = NEW_COLUMNS.map(name => schema.imColumns.includes(name) ? name : `0 AS ${name}`);
|
||||
const suffix = lock ? ' FOR UPDATE' : '';
|
||||
const users = await rows(connection, 'SELECT id,user_name,nick_name,head_image,head_image_thumb,is_banned,'
|
||||
+ defaults.join(',') + ',SHA2(password,256) AS password_hash FROM im_user ORDER BY id' + suffix);
|
||||
const friends = await rows(connection, 'SELECT id,user_id,friend_id,friend_nick_name,friend_head_image FROM im_friend ORDER BY id' + suffix);
|
||||
const groupMembers = await rows(connection, 'SELECT id,group_id,user_id,user_nick_name,head_image,remark_nick_name,remark_group_name,quit FROM im_group_member ORDER BY id' + suffix);
|
||||
const groups = await rows(connection, 'SELECT id,owner_id,dissolve FROM im_group ORDER BY id' + suffix);
|
||||
const messages = {};
|
||||
for (const table of ['im_private_message', 'im_group_message']) {
|
||||
const receiver = table === 'im_private_message' ? ',MAX(recv_id) AS maximumReceiver' : '';
|
||||
const [count] = await rows(connection, `SELECT COUNT(*) AS count,MAX(id) AS maximumId,MAX(send_id) AS maximumSender${receiver} FROM ${table}`);
|
||||
messages[table] = { count: String(count.count), maximumId: count.maximumId == null ? null : String(count.maximumId),
|
||||
maximumSender: count.maximumSender == null ? null : String(count.maximumSender),
|
||||
maximumReceiver: count.maximumReceiver == null ? null : String(count.maximumReceiver) };
|
||||
}
|
||||
return { users, friends, groupMembers, groups, messages };
|
||||
}
|
||||
|
||||
async function readSnapshot(business, im, schema) {
|
||||
return { members: await readMembers(business), tasks: await readTasks(business, schema.taskTableExists),
|
||||
...await readIm(im, schema) };
|
||||
}
|
||||
|
||||
async function updateRows(connection, table, columns, changes) {
|
||||
for (let offset = 0; offset < changes.length; offset += 100) {
|
||||
const batch = changes.slice(offset, offset + 100);
|
||||
const parameters = [];
|
||||
const assignments = columns.map(column => {
|
||||
const cases = batch.map(row => {
|
||||
parameters.push(row.id, row[column]);
|
||||
return 'WHEN ? THEN ?';
|
||||
});
|
||||
return `\`${column}\` = CASE id ${cases.join(' ')} ELSE \`${column}\` END`;
|
||||
});
|
||||
parameters.push(...batch.map(row => row.id));
|
||||
const placeholders = batch.map(() => '?').join(',');
|
||||
await connection.query(`UPDATE ${table} SET ${assignments.join(',')} WHERE id IN (${placeholders})`, parameters);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameUsers(connection, users, runId) {
|
||||
await updateRows(connection, 'im_user', ['user_name'], users.map(user => ({
|
||||
id: user.id, user_name: temporaryName(user.id, runId),
|
||||
})));
|
||||
await updateRows(connection, 'im_user', USER_COLUMNS, users);
|
||||
}
|
||||
|
||||
async function seedTasks(connection, changes) {
|
||||
for (const task of changes) {
|
||||
const columns = ['member_id', ...TASK_COLUMNS];
|
||||
const values = columns.map(column => task[column]);
|
||||
const assignments = TASK_COLUMNS.map(column => `${column} = VALUES(${column})`);
|
||||
await connection.query(`INSERT INTO member_chat_sync (${columns.join(',')},next_retry_time,deleted)
|
||||
VALUES (${columns.map(() => '?').join(',')},CURRENT_TIMESTAMP,b'0')
|
||||
ON DUPLICATE KEY UPDATE ${assignments.join(',')},next_retry_time=CURRENT_TIMESTAMP,deleted=b'0'`, values);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireSchemas(schema) {
|
||||
if (schema.missingImColumns.length || !schema.taskTableExists || !schema.sequenceTableExists) {
|
||||
throw new Error('请先在维护窗口执行两侧结构迁移,再重新生成清单');
|
||||
}
|
||||
}
|
||||
|
||||
async function executeApply(context, plan, journal, saveJournal) {
|
||||
const { business, im, schema } = context;
|
||||
await requireSchemas(schema);
|
||||
if (journal.phase === 'rolledBack') throw new Error('该批次已回滚,请重新生成修复清单');
|
||||
if (journal.phase === 'complete') return;
|
||||
assertSame(await readMembers(business), plan.before.members, '业务会员资料');
|
||||
const current = await readIm(im, schema);
|
||||
const after = expectedIm(plan);
|
||||
if (fingerprint(current) !== fingerprint(after)) {
|
||||
assertSame(current, imPart(plan.before), 'IM 原始资料');
|
||||
journal.phase = 'imApplying';
|
||||
await saveJournal();
|
||||
await im.beginTransaction();
|
||||
try {
|
||||
assertSame(await readIm(im, schema, true), imPart(plan.before), 'IM 锁定资料');
|
||||
await renameUsers(im, plan.userChanges, plan.runId);
|
||||
await updateRows(im, 'im_friend', FRIEND_COLUMNS, plan.friendChanges);
|
||||
await updateRows(im, 'im_group_member', GROUP_MEMBER_COLUMNS, plan.groupMemberChanges);
|
||||
assertSame(await readIm(im, schema), after, 'IM 修复结果');
|
||||
await im.commit();
|
||||
} catch (error) {
|
||||
await im.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
journal.phase = 'imCommitted';
|
||||
await saveJournal();
|
||||
await business.beginTransaction();
|
||||
try {
|
||||
assertSame(await readMembers(business, true), plan.before.members, '业务锁定资料');
|
||||
const existing = await readTasks(business, true, true);
|
||||
const desired = plan.taskChanges.map(task => ({ ...task, deleted: false }));
|
||||
const expectedTasks = new Map(plan.before.tasks.map(task => [task.member_id, task]));
|
||||
for (const task of desired) expectedTasks.set(task.member_id, task);
|
||||
const expected = [...expectedTasks.values()].sort((left, right) => BigInt(left.member_id) < BigInt(right.member_id) ? -1 : 1);
|
||||
if (fingerprint(existing) !== fingerprint(expected)) {
|
||||
assertSame(existing, plan.before.tasks, '业务同步任务');
|
||||
await seedTasks(business, plan.taskChanges);
|
||||
}
|
||||
await business.query('UPDATE member_chat_sync_sequence SET last_id = GREATEST(last_id, ?) WHERE id = 1',
|
||||
[(BigInt(plan.nextMemberId) - 1n).toString()]);
|
||||
await business.commit();
|
||||
} catch (error) {
|
||||
await business.rollback();
|
||||
throw error;
|
||||
}
|
||||
journal.phase = 'businessCommitted';
|
||||
await saveJournal();
|
||||
// AUTO_INCREMENT is DDL and is deliberately a separately resumable phase.
|
||||
const [definition] = await rows(business, 'SHOW CREATE TABLE member_user');
|
||||
const nextId = definition['Create Table'].match(/\bAUTO_INCREMENT=(\d+)/)?.[1] ?? '1';
|
||||
if (BigInt(nextId) < BigInt(plan.nextMemberId)) {
|
||||
await business.query(`ALTER TABLE member_user AUTO_INCREMENT = ${plan.nextMemberId}`);
|
||||
}
|
||||
journal.phase = 'complete';
|
||||
await saveJournal();
|
||||
}
|
||||
|
||||
async function executeRollback(context, plan, journal, saveJournal) {
|
||||
const { business, im, schema } = context;
|
||||
await requireSchemas(schema);
|
||||
if (journal.phase === 'rolledBack') return;
|
||||
if (!['imApplying', 'imCommitted', 'businessCommitted', 'complete', 'rollingBack', 'imRolledBack'].includes(journal.phase)) {
|
||||
throw new Error('该批次尚未开始执行');
|
||||
}
|
||||
assertSame(await readMembers(business), plan.before.members, '业务会员资料');
|
||||
if (journal.phase !== 'imRolledBack') {
|
||||
await im.beginTransaction();
|
||||
try {
|
||||
const current = await readIm(im, schema, true);
|
||||
if (journal.rollbackUsers && fingerprint(current.users) === fingerprint(journal.rollbackUsers)) {
|
||||
// A preceding commit may have succeeded before its acknowledgement was written.
|
||||
} else {
|
||||
const changes = rollbackChanges(plan, current);
|
||||
journal.phase = 'rollingBack';
|
||||
const byId = new Map(changes.users.map(user => [String(user.id), user]));
|
||||
journal.rollbackUsers = current.users.map(user => byId.get(String(user.id)) ?? user);
|
||||
await saveJournal();
|
||||
await renameUsers(im, changes.users, randomUUID().replaceAll('-', ''));
|
||||
await updateRows(im, 'im_friend', FRIEND_COLUMNS, changes.friends);
|
||||
await updateRows(im, 'im_group_member', GROUP_MEMBER_COLUMNS, changes.groupMembers);
|
||||
}
|
||||
await im.commit();
|
||||
} catch (error) {
|
||||
await im.rollback();
|
||||
throw error;
|
||||
}
|
||||
journal.phase = 'imRolledBack';
|
||||
await saveJournal();
|
||||
}
|
||||
await business.beginTransaction();
|
||||
try {
|
||||
// Keep versions monotonic while restoring the pre-repair desired profiles.
|
||||
const currentTasks = new Map((await readTasks(business, true, true)).map(task => [task.member_id, task]));
|
||||
const restored = [];
|
||||
const previousTasks = new Map(plan.before.tasks.map(task => [task.member_id, task]));
|
||||
const users = new Map(journal.rollbackUsers.map(user => [String(user.id), user]));
|
||||
for (const task of plan.taskChanges) {
|
||||
const current = currentTasks.get(task.member_id);
|
||||
if (current && current.version !== task.version && current.version !== previousTasks.get(task.member_id)?.version
|
||||
&& current.version !== journal.restoredVersions?.[task.member_id]) {
|
||||
throw new Error(`会员 ${task.member_id} 的同步任务已变化,停止回滚`);
|
||||
}
|
||||
const base = BigInt(users.get(task.member_id)?.sync_version ?? task.version);
|
||||
const version = journal.restoredVersions?.[task.member_id] ?? (base + 1n).toString();
|
||||
restored.push({ ...task, member_id: task.member_id, version, synced_version: '0', retry_count: 0, last_error: '' });
|
||||
}
|
||||
journal.restoredVersions = Object.fromEntries(restored.map(task => [task.member_id, task.version]));
|
||||
await saveJournal();
|
||||
await seedTasks(business, restored);
|
||||
await business.commit();
|
||||
} catch (error) {
|
||||
await business.rollback();
|
||||
throw error;
|
||||
}
|
||||
journal.phase = 'rolledBack';
|
||||
await saveJournal();
|
||||
}
|
||||
|
||||
async function verify(context, plan, ready) {
|
||||
const { business, im, schema } = context;
|
||||
await requireSchemas(schema);
|
||||
const live = await readIm(im, schema);
|
||||
const users = new Map(live.users.map(user => [user.id, user]));
|
||||
const tasks = new Map((await readTasks(business, true)).map(task => [task.member_id, task]));
|
||||
assertSame(await readMembers(business), plan.before.members, '业务会员资料');
|
||||
for (const expected of plan.userChanges) {
|
||||
const actual = users.get(expected.id);
|
||||
if (!actual) throw new Error(`IM 会员 ${expected.id} 缺失`);
|
||||
const original = plan.before.users.find(user => user.id === expected.id);
|
||||
assertSame(actual.password_hash, original.password_hash, `会员 ${expected.id} 的原密码校验值`);
|
||||
for (const column of USER_COLUMNS) {
|
||||
if (column === 'auth_version') {
|
||||
if (BigInt(actual[column]) < BigInt(expected[column])) throw new Error('登录撤销版本发生倒退');
|
||||
} else {
|
||||
assertSame(actual[column], expected[column], `会员 ${expected.id} 的 ${column}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
let pending = 0;
|
||||
let pendingInitialization = 0;
|
||||
for (const expected of plan.taskChanges) {
|
||||
const task = tasks.get(expected.member_id);
|
||||
if (!task || task.version !== expected.version) throw new Error(`会员 ${expected.member_id} 的任务版本不匹配`);
|
||||
if (BigInt(task.synced_version) < BigInt(task.version)) pending++;
|
||||
if (task.member_state === 0 && !task.initialized) pendingInitialization++;
|
||||
if (ready && !users.has(expected.member_id)) throw new Error(`会员 ${expected.member_id} 尚未完成 IM 补建`);
|
||||
if (ready) {
|
||||
const desired = JSON.parse(expected.profile_json);
|
||||
const user = users.get(expected.member_id);
|
||||
assertSame(user.user_name, desired.userName, `会员 ${expected.member_id} 的用户名`);
|
||||
assertSame(user.member_state, desired.memberState, `会员 ${expected.member_id} 的可用状态`);
|
||||
assertSame(user.sync_version, expected.version, `会员 ${expected.member_id} 的应用版本`);
|
||||
}
|
||||
}
|
||||
const expected = expectedIm(plan);
|
||||
const liveFriends = new Map(live.friends.map(row => [row.id, row]));
|
||||
const liveGroupMembers = new Map(live.groupMembers.map(row => [row.id, row]));
|
||||
for (const friend of expected.friends) assertSame(liveFriends.get(friend.id), friend, `好友关系 ${friend.id}`);
|
||||
for (const member of expected.groupMembers) assertSame(liveGroupMembers.get(member.id), member, `群成员关系 ${member.id}`);
|
||||
assertSame(live.groups, plan.before.groups, '群关系');
|
||||
if (!ready) assertSame(live.messages, plan.before.messages, '消息数量');
|
||||
const [counter] = await rows(business, 'SELECT last_id FROM member_chat_sync_sequence WHERE id = 1');
|
||||
if (BigInt(counter?.last_id ?? 0) < BigInt(plan.nextMemberId) - 1n) throw new Error('业务持久化编号水位未提升至安全起点');
|
||||
if (ready && (pending || pendingInitialization)) throw new Error(`仍有 ${pending} 个待同步账号、${pendingInitialization} 个待初始化账号`);
|
||||
if (ready) {
|
||||
for (const [table, before] of Object.entries(plan.before.messages)) {
|
||||
if (BigInt(live.messages[table].count) < BigInt(before.count)) throw new Error(`表 ${table} 的消息数量减少`);
|
||||
}
|
||||
for (const id of ['-100', '-200']) {
|
||||
const user = users.get(id);
|
||||
if (!user || user.member_state !== 0 || user.is_banned) throw new Error(`系统账号 ${id} 尚未正常初始化`);
|
||||
}
|
||||
}
|
||||
return { ...summary(plan), verified: true, pending, pendingInitialization, ready: Boolean(ready) };
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const { command, options } = argumentsFor(argv);
|
||||
const config = await loadJson(resolve(options.config));
|
||||
const business = await connect(config.business);
|
||||
let im;
|
||||
try {
|
||||
im = await connect(config.im);
|
||||
const identities = { business: await identity(business), im: await identity(im) };
|
||||
const schema = await inspectSchema(business, im);
|
||||
const context = { business, im, schema };
|
||||
if (command === 'plan' || command === 'verify') {
|
||||
await business.query('SET SESSION TRANSACTION READ ONLY');
|
||||
await im.query('SET SESSION TRANSACTION READ ONLY');
|
||||
await business.query('START TRANSACTION READ ONLY');
|
||||
await im.query('START TRANSACTION READ ONLY');
|
||||
}
|
||||
if (command === 'plan') {
|
||||
const before = await readSnapshot(business, im, schema);
|
||||
const plan = createPlan(before, identities, randomUUID().replaceAll('-', ''));
|
||||
await validateTargets(im, schema, plan);
|
||||
const path = resolve(options.out ?? `runs/${plan.runId}.plan.json`);
|
||||
await atomicJson(path, plan);
|
||||
const report = { ...summary(plan), createdAt: plan.createdAt,
|
||||
schemaReady: !schema.missingImColumns.length && schema.taskTableExists && schema.sequenceTableExists,
|
||||
missingBusinessTables: [!schema.taskTableExists ? 'member_chat_sync' : null,
|
||||
!schema.sequenceTableExists ? 'member_chat_sync_sequence' : null].filter(Boolean),
|
||||
missingImColumns: schema.missingImColumns, usernameCollation: schema.usernameCollation, plan: path };
|
||||
await atomicJson(`${path}.summary.json`, report);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
return;
|
||||
}
|
||||
const path = resolve(options.plan);
|
||||
const plan = await loadJson(path);
|
||||
validatePlan(plan);
|
||||
assertSame(identities, plan.identities, '数据库身份');
|
||||
if (command === 'verify') {
|
||||
const report = await verify(context, plan, options.ready);
|
||||
await atomicJson(`${path}.verification.json`, report);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
return;
|
||||
}
|
||||
const journalPath = `${path}.journal.json`;
|
||||
const journal = await loadJson(journalPath, { runId: plan.runId, planHash: plan.hash, phase: 'created' });
|
||||
if (journal.planHash !== plan.hash) throw new Error('执行记录与清单不匹配');
|
||||
const saveJournal = () => atomicJson(journalPath, { ...journal, updatedAt: new Date().toISOString() });
|
||||
if (command === 'apply') await executeApply(context, plan, journal, saveJournal);
|
||||
if (command === 'rollback') await executeRollback(context, plan, journal, saveJournal);
|
||||
console.log(JSON.stringify({ ...summary(plan), phase: journal.phase, journal: journalPath }, null, 2));
|
||||
} finally {
|
||||
await business.rollback().catch(() => {});
|
||||
await im?.rollback().catch(() => {});
|
||||
await business.end();
|
||||
await im?.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch(error => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertSame, createPlan, expectedIm, memberId, profile, rollbackChanges, temporaryName, validatePlan } from '../core.mjs';
|
||||
|
||||
function user(id, name) {
|
||||
return { id: String(id), user_name: name, nick_name: name, head_image: '', head_image_thumb: '',
|
||||
member_state: 0, sync_version: '0', auth_version: '0', access_disabled: false, is_banned: false, password_hash: 'preserved' };
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
return {
|
||||
members: [
|
||||
{ id: '100', tenant_id: '1', nickname: '老芋头', avatar: '', mobile: null, status: 1, deleted: true },
|
||||
{ id: '131', tenant_id: '1', nickname: '老芋头', avatar: 'new-avatar', mobile: null, status: 0, deleted: false },
|
||||
],
|
||||
tasks: [],
|
||||
users: [user('-200', '客服'), user('-100', '消息'), user('100', 'u131'), user('131', 'u100'), user('900', '孤立账号')],
|
||||
friends: [{ id: '1', user_id: '1', friend_id: '131', friend_nick_name: '旧昵称', friend_head_image: '' }],
|
||||
groupMembers: [{ id: '1', group_id: '1', user_id: '131', user_nick_name: '旧昵称', head_image: '', remark_nick_name: '我的备注' }],
|
||||
groups: [{ id: '1', owner_id: '100', dissolve: 0 }],
|
||||
messages: { private: { count: '10', maximumId: '10' } },
|
||||
};
|
||||
}
|
||||
|
||||
test('same nicknames remain distinct and deleted/orphan accounts are archived', () => {
|
||||
const plan = createPlan(fixture(), {}, 'abcdef');
|
||||
assert.deepEqual(plan.userChanges.map(row => [row.id, row.user_name, row.member_state]),
|
||||
[['100', 'u100', 2], ['131', 'u131', 0], ['900', 'u900', 3]]);
|
||||
assert.equal(plan.nextMemberId, '901');
|
||||
assert.equal(plan.userChanges[0].auth_version, '1');
|
||||
assert.equal(plan.userChanges[1].auth_version, '0');
|
||||
assert.equal(expectedIm(plan).groupMembers[0].remark_nick_name, '我的备注');
|
||||
assert.equal(expectedIm(plan).users[3].password_hash, 'preserved');
|
||||
});
|
||||
|
||||
test('IDs and versions preserve precision beyond the JavaScript safe-integer range', () => {
|
||||
const before = fixture();
|
||||
before.members[1].id = '9007199254740993';
|
||||
before.users[3].id = '9007199254740993';
|
||||
before.users[3].sync_version = '9007199254740994';
|
||||
const plan = createPlan(before, {}, 'abcdef');
|
||||
assert.equal(plan.nextMemberId, '9007199254740994');
|
||||
assert.equal(plan.userChanges[1].user_name, 'u9007199254740993');
|
||||
assert.equal(plan.userChanges[1].sync_version, '9007199254740995');
|
||||
});
|
||||
|
||||
test('historical message references reserve IDs even when the old IM user row is absent', () => {
|
||||
const before = fixture();
|
||||
before.messages.private.maximumSender = '9007199254740993';
|
||||
assert.equal(createPlan(before, {}, 'abcdef').nextMemberId, '9007199254740994');
|
||||
});
|
||||
|
||||
test('precheck rejects invalid historical identities and differences identify missing fields', () => {
|
||||
const before = fixture();
|
||||
before.messages.private.maximumSender = '9223372036854775808';
|
||||
assert.throws(() => createPlan(before, {}, 'abcdef'), /Long/);
|
||||
assert.throws(() => assertSame({ id: '131' }, { id: '131', nickname: '原昵称' }, '会员'), /会员.nickname/);
|
||||
});
|
||||
|
||||
test('maximum Long fits protocol and temporary usernames', () => {
|
||||
const member = fixture().members[1];
|
||||
member.id = '9223372036854775807';
|
||||
assert.equal(profile(member).userName.length, 20);
|
||||
assert.ok(temporaryName(member.id, 'abcdef').length <= 20);
|
||||
assert.throws(() => memberId('9223372036854775808'));
|
||||
assert.throws(() => memberId(0));
|
||||
assert.throws(() => memberId(-1));
|
||||
});
|
||||
|
||||
test('unknown accounts in the durable queue receive orphan snapshots', () => {
|
||||
const before = fixture();
|
||||
before.tasks.push({ member_id: '900', tenant_id: '1', version: '9',
|
||||
profile_json: JSON.stringify({ userId: '900', nickName: 'old' }) });
|
||||
const plan = createPlan(before, {}, 'abcdef');
|
||||
assert.equal(plan.taskChanges[2].member_state, 3);
|
||||
assert.equal(plan.taskChanges[2].version, '10');
|
||||
assert.equal(plan.userChanges[2].sync_version, '10');
|
||||
});
|
||||
|
||||
test('precheck catches system namespace conflicts and nickname limits', () => {
|
||||
const before = fixture();
|
||||
before.users[0].user_name = 'U131';
|
||||
assert.throws(() => createPlan(before, {}, 'abcdef'), /冲突/);
|
||||
before.users[0].user_name = '客服';
|
||||
before.members[1].nickname = '字'.repeat(31);
|
||||
assert.throws(() => createPlan(before, {}, 'abcdef'), /30字符/);
|
||||
});
|
||||
|
||||
test('plan checksum detects edits', () => {
|
||||
const plan = createPlan(fixture(), {}, 'abcdef');
|
||||
validatePlan(plan);
|
||||
plan.userChanges[0].user_name = 'other';
|
||||
assert.throws(() => validatePlan(plan), /校验值/);
|
||||
});
|
||||
|
||||
test('rollback restores profiles and advances both versions', () => {
|
||||
const plan = createPlan(fixture(), {}, 'abcdef');
|
||||
const current = expectedIm(plan);
|
||||
const restored = rollbackChanges(plan, current);
|
||||
assert.equal(restored.users[0].user_name, 'u131');
|
||||
assert.equal(restored.users[0].auth_version, '2');
|
||||
assert.equal(restored.users[0].sync_version, '2');
|
||||
assert.equal(restored.users[0].member_state, 2);
|
||||
assert.equal(restored.users[0].access_disabled, true);
|
||||
current.friends[0].friend_nick_name = 'later edit';
|
||||
assert.throws(() => rollbackChanges(plan, current), /清单不一致/);
|
||||
});
|
||||
|
||||
test('rollback rejects later profile, relation and message changes', () => {
|
||||
const plan = createPlan(fixture(), {}, 'abcdef');
|
||||
const current = expectedIm(plan);
|
||||
current.users[3].nick_name = 'later edit';
|
||||
assert.throws(() => rollbackChanges(plan, current), /清单不一致/);
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { main } from '../repair-im-users.mjs';
|
||||
|
||||
const ports = (process.env.CHAT_REPAIR_TEST_PORTS ?? '').split(',').filter(Boolean).map(Number);
|
||||
const apiRoot = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const imRoot = resolve(apiRoot, '../Friendship-and-chat-im/box-im-master');
|
||||
process.env.CHAT_REPAIR_FIXTURE_PASSWORD = 'chat_sync_test';
|
||||
|
||||
async function fixture(port) {
|
||||
const token = randomUUID().replaceAll('-', '');
|
||||
const businessName = `codex_chat_business_${token}`;
|
||||
const imName = `codex_chat_im_${token}`;
|
||||
const base = { host: '127.0.0.1', port, user: 'root', password: 'chat_sync_test',
|
||||
supportBigNumbers: true, bigNumberStrings: true, multipleStatements: true };
|
||||
const admin = await mysql.createConnection(base);
|
||||
const directory = await mkdtemp(resolve(tmpdir(), 'chat-repair-test-'));
|
||||
const config = resolve(directory, 'config.json');
|
||||
const plan = resolve(directory, 'plan.json');
|
||||
await admin.query(`CREATE DATABASE ${businessName} CHARACTER SET utf8mb4; CREATE DATABASE ${imName} CHARACTER SET utf8mb4`);
|
||||
const business = await mysql.createConnection({ ...base, database: businessName });
|
||||
const im = await mysql.createConnection({ ...base, database: imName });
|
||||
try {
|
||||
await business.query(`CREATE TABLE member_user (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT NOT NULL DEFAULT 1,
|
||||
nickname VARCHAR(30) NOT NULL DEFAULT '', avatar VARCHAR(255), mobile VARCHAR(11),
|
||||
status TINYINT NOT NULL DEFAULT 0, deleted BIT(1) NOT NULL DEFAULT b'0') ENGINE=InnoDB;
|
||||
CREATE TABLE infra_job (id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(32), status TINYINT,
|
||||
handler_name VARCHAR(64),handler_param VARCHAR(255),cron_expression VARCHAR(32),retry_count INT,
|
||||
retry_interval INT,monitor_timeout INT,deleted BIT(1) DEFAULT b'0');`);
|
||||
await business.query(await readFile(resolve(apiRoot, 'sql/mysql/member/member_chat_sync.sql'), 'utf8'));
|
||||
await im.query(await readFile(resolve(imRoot, 'db/im-platform.sql'), 'utf8'));
|
||||
await business.query(`INSERT INTO member_user (id,nickname,status,deleted) VALUES
|
||||
(100,'老芋头',1,b'1'),(131,'老芋头',0,b'0'),(501,'新会员',0,b'0')`);
|
||||
await im.query(`INSERT INTO im_user (id,user_name,nick_name,password) VALUES
|
||||
(-100,'消息通知','消息通知','retained'),(-200,'客服','客服','retained'),
|
||||
(100,'u131','旧100','retained'),(131,'u100','旧131','retained'),(900,'孤立账号','孤立账号','retained');
|
||||
INSERT INTO im_friend (id,user_id,friend_id,friend_nick_name,friend_head_image) VALUES (1,100,131,'旧131','');
|
||||
INSERT INTO im_group (id,owner_id,name) VALUES (1,100,'原群');
|
||||
INSERT INTO im_group_member (id,group_id,user_id,user_nick_name,remark_nick_name,head_image)
|
||||
VALUES (1,1,131,'旧131','原备注','');
|
||||
INSERT INTO im_private_message (send_id,recv_id,content,type,status) VALUES (100,131,'原消息',0,3);`);
|
||||
const connection = database => ({ host: '127.0.0.1', port, user: 'root', database,
|
||||
passwordEnv: 'CHAT_REPAIR_FIXTURE_PASSWORD' });
|
||||
await writeFile(config, JSON.stringify({ business: connection(businessName), im: connection(imName) }));
|
||||
return {
|
||||
business, im, config, plan,
|
||||
run: (command, ...extra) => main([command, '--config', config, '--plan', plan, ...extra]),
|
||||
async close() {
|
||||
await business.end();
|
||||
await im.end();
|
||||
await admin.query(`DROP DATABASE ${businessName}; DROP DATABASE ${imName}`);
|
||||
await admin.end();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await business.end();
|
||||
await im.end();
|
||||
await admin.query(`DROP DATABASE IF EXISTS ${businessName}; DROP DATABASE IF EXISTS ${imName}`);
|
||||
await admin.end();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test('real MySQL repair integration requires explicit isolated localhost ports', { skip: ports.length !== 0 }, () => {});
|
||||
|
||||
for (const port of ports) {
|
||||
test(`MySQL ${port} prechecks field capacity, actual collation and transactional storage`, async () => {
|
||||
const context = await fixture(port);
|
||||
try {
|
||||
await context.im.query('ALTER TABLE im_user MODIFY head_image VARCHAR(4)');
|
||||
await context.business.query("UPDATE member_user SET avatar='too-long' WHERE id=131");
|
||||
await assert.rejects(context.run('plan', '--out', context.plan), /head_image.*长度/);
|
||||
await context.business.query("UPDATE member_user SET avatar='' WHERE id=131");
|
||||
await context.im.query('ALTER TABLE im_user MODIFY user_name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL');
|
||||
await context.im.query("UPDATE im_user SET user_name='u501' WHERE id=-100");
|
||||
await assert.rejects(context.run('plan', '--out', context.plan), /排序规则/);
|
||||
await context.im.query("UPDATE im_user SET user_name='消息通知' WHERE id=-100");
|
||||
await context.im.query('ALTER TABLE im_friend ENGINE=MyISAM');
|
||||
await assert.rejects(context.run('plan', '--out', context.plan), /InnoDB/);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(`MySQL ${port} repairs cyclic names, repeats safely and rolls back`, async () => {
|
||||
const context = await fixture(port);
|
||||
try {
|
||||
await context.run('plan', '--out', context.plan);
|
||||
await context.run('apply', '--maintenance');
|
||||
await context.run('apply', '--maintenance');
|
||||
await context.run('verify');
|
||||
const [users] = await context.im.query('SELECT id,user_name,member_state,auth_version FROM im_user WHERE id > 0 ORDER BY id');
|
||||
assert.deepEqual(users.map(user => [user.id, user.user_name, user.member_state]),
|
||||
[['100', 'u100', 2], ['131', 'u131', 0], ['900', 'u900', 3]]);
|
||||
const [[counter]] = await context.business.query('SELECT last_id FROM member_chat_sync_sequence WHERE id=1');
|
||||
assert.equal(String(counter.last_id), '900');
|
||||
await assert.rejects(context.run('verify', '--ready'), /尚未完成|待同步/);
|
||||
await context.im.query("UPDATE im_user SET nick_name='后续修改' WHERE id=131");
|
||||
await assert.rejects(context.run('rollback', '--maintenance'), /清单不一致/);
|
||||
await context.im.query("UPDATE im_user SET nick_name='老芋头' WHERE id=131");
|
||||
await context.run('rollback', '--maintenance');
|
||||
await context.run('rollback', '--maintenance');
|
||||
const [[restored]] = await context.im.query('SELECT user_name,auth_version FROM im_user WHERE id=100');
|
||||
assert.equal(restored.user_name, 'u131');
|
||||
assert.ok(BigInt(restored.auth_version) > BigInt(users[0].auth_version));
|
||||
const [[message]] = await context.im.query('SELECT COUNT(*) AS n FROM im_private_message');
|
||||
assert.equal(Number(message.n), 1);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(`MySQL ${port} resumes after lost commit acknowledgement`, async () => {
|
||||
const context = await fixture(port);
|
||||
try {
|
||||
await context.run('plan', '--out', context.plan);
|
||||
await context.run('apply', '--maintenance');
|
||||
const journalPath = `${context.plan}.journal.json`;
|
||||
const journal = JSON.parse(await readFile(journalPath, 'utf8'));
|
||||
journal.phase = 'imApplying';
|
||||
await writeFile(journalPath, JSON.stringify(journal));
|
||||
await context.run('apply', '--maintenance');
|
||||
await context.run('verify');
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(`MySQL ${port} verifies readiness after missing users and initialization complete`, async () => {
|
||||
const context = await fixture(port);
|
||||
try {
|
||||
await context.run('plan', '--out', context.plan);
|
||||
await context.run('apply', '--maintenance');
|
||||
const plan = JSON.parse(await readFile(context.plan, 'utf8'));
|
||||
const missing = plan.taskChanges.find(task => task.member_id === '501');
|
||||
const desired = JSON.parse(missing.profile_json);
|
||||
await context.im.query('INSERT INTO im_user (id,user_name,nick_name,password,head_image,head_image_thumb,member_state,sync_version) VALUES (?,?,?,?,?,?,?,?)',
|
||||
['501', desired.userName, desired.nickName, 'new-random-password-hash', desired.avatar, desired.avatar, desired.memberState, missing.version]);
|
||||
await context.business.query("UPDATE member_chat_sync SET synced_version=version,initialized=b'1'");
|
||||
await context.im.query("INSERT INTO im_friend (id,user_id,friend_id,friend_nick_name) VALUES (2,501,-200,'客服')");
|
||||
await context.im.query("INSERT INTO im_private_message (send_id,recv_id,content,type,status) VALUES (-200,501,'欢迎',0,0)");
|
||||
await context.run('verify', '--ready');
|
||||
const report = JSON.parse(await readFile(`${context.plan}.verification.json`, 'utf8'));
|
||||
assert.equal(report.verified, true);
|
||||
assert.equal(report.pending, 0);
|
||||
assert.equal(report.pendingInitialization, 0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(`MySQL ${port} rejects database identity and concurrent source changes`, async () => {
|
||||
const context = await fixture(port);
|
||||
try {
|
||||
await context.run('plan', '--out', context.plan);
|
||||
await context.business.query("UPDATE member_user SET nickname='后续修改' WHERE id=131");
|
||||
await assert.rejects(context.run('apply', '--maintenance'), /清单不一致/);
|
||||
const [[user]] = await context.im.query('SELECT user_name FROM im_user WHERE id=100');
|
||||
assert.equal(user.user_name, 'u131');
|
||||
const config = JSON.parse(await readFile(context.config, 'utf8'));
|
||||
[config.business.database, config.im.database] = [config.im.database, config.business.database];
|
||||
await writeFile(context.config, JSON.stringify(config));
|
||||
await assert.rejects(context.run('apply', '--maintenance'));
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const endpoint = process.env.CHAT_SYNC_RUNTIME_HTTP;
|
||||
const socketEndpoint = process.env.CHAT_SYNC_RUNTIME_SOCKET;
|
||||
const secondSocketEndpoint = process.env.CHAT_SYNC_RUNTIME_SOCKET_SECOND;
|
||||
|
||||
test('local HTTP and WebSocket enforce disable, restore and credential revocation', {
|
||||
skip: !endpoint || !socketEndpoint,
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
assert.match(endpoint, /^http:\/\/(127\.0\.0\.1|localhost):[0-9]+$/);
|
||||
assert.match(socketEndpoint, /^ws:\/\/(127\.0\.0\.1|localhost):[0-9]+$/);
|
||||
assert.equal(typeof WebSocket, 'function', 'Node 20 requires --experimental-websocket');
|
||||
const source = await readFile(new URL('../../../../Friendship-and-chat-im/box-im-master/im-platform/src/main/java/com/bx/implatform/enums/third/ThirdKeyTypeEnum.java', import.meta.url), 'utf8');
|
||||
const match = source.match(/\(\s*"10"\s*,\s*"[^"]*"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)/);
|
||||
assert.ok(match, 'Fixture signing configuration must be available locally');
|
||||
const [, key, secret] = match;
|
||||
|
||||
async function third(path, data) {
|
||||
const body = JSON.stringify(data);
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
const sign = createHash('md5').update(key + secret + '10' + timestamp + body).digest('hex');
|
||||
const response = await fetch(endpoint + path, { method: 'POST', body,
|
||||
headers: { 'Content-Type': 'application/json', 'APP-KEY': key, 'BIZ-TYPE': '10', TIMESTAMP: timestamp, SIGN: sign } });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function connect(token, target = socketEndpoint) {
|
||||
assert.match(target, /^ws:\/\/(127\.0\.0\.1|localhost):[0-9]+$/);
|
||||
const socket = new WebSocket(new URL('/im', target));
|
||||
const packets = [];
|
||||
let notify;
|
||||
const first = new Promise(resolve => { notify = resolve; });
|
||||
socket.addEventListener('message', message => {
|
||||
const packet = JSON.parse(message.data);
|
||||
packets.push(packet);
|
||||
notify(packet);
|
||||
});
|
||||
const closed = new Promise(resolve => socket.addEventListener('close', resolve));
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true });
|
||||
socket.addEventListener('error', reject, { once: true });
|
||||
});
|
||||
socket.send(JSON.stringify({ cmd: 0, data: { accessToken: token } }));
|
||||
return { socket, packets, first, closed };
|
||||
}
|
||||
|
||||
const current = await third('/third/user/getUserByUserId', { userId: 131 });
|
||||
const version = Number(current.data?.syncVersion ?? 0) + 1;
|
||||
const profile = { userId: 131, userName: '老芋头', nickName: '老芋头', avatar: '', memberState: 0, syncVersion: version };
|
||||
const synchronized = await third('/third/user/createOrUpdate', profile);
|
||||
assert.equal(synchronized.code, 200, synchronized.message);
|
||||
assert.equal(synchronized.data.appliedVersion, version);
|
||||
const initial = await third('/third/user/login', { userId: 131, terminal: 1 });
|
||||
assert.equal(initial.code, 200);
|
||||
const old = await connect(initial.data.accessToken);
|
||||
const otherNode = secondSocketEndpoint ? await connect(initial.data.accessToken, secondSocketEndpoint) : null;
|
||||
try {
|
||||
assert.equal((await old.first).cmd, 0);
|
||||
if (otherNode) assert.equal((await otherNode.first).cmd, 0);
|
||||
assert.equal((await third('/third/user/createOrUpdate', { ...profile, memberState: 1, syncVersion: version + 1 })).code, 200);
|
||||
await old.closed;
|
||||
if (otherNode) await otherNode.closed;
|
||||
assert.ok(old.packets.some(packet => packet.cmd === 2 && packet.data.includes('禁用')));
|
||||
const blocked = await fetch(endpoint + '/user/self', { headers: { accessToken: initial.data.accessToken } }).then(response => response.json());
|
||||
assert.equal(blocked.code, 10005);
|
||||
const refresh = await fetch(endpoint + '/refreshToken', { method: 'PUT', headers: { refreshToken: initial.data.refreshToken } }).then(response => response.json());
|
||||
assert.equal(refresh.code, 10005);
|
||||
assert.equal((await third('/third/user/createOrUpdate', { ...profile, syncVersion: version + 2 })).code, 200);
|
||||
const restored = await third('/third/user/login', { userId: 131, terminal: 1 });
|
||||
assert.equal(restored.code, 200);
|
||||
assert.notEqual(restored.data.accessToken, initial.data.accessToken);
|
||||
const stale = await connect(initial.data.accessToken);
|
||||
assert.equal((await stale.first).cmd, 2);
|
||||
await stale.closed;
|
||||
const fresh = await connect(restored.data.accessToken);
|
||||
try {
|
||||
assert.equal((await fresh.first).cmd, 0);
|
||||
const revoked = await fetch(endpoint + '/user/self', { headers: { accessToken: initial.data.accessToken } }).then(response => response.json());
|
||||
assert.equal(revoked.code, 401);
|
||||
const active = await fetch(endpoint + '/user/self', { headers: { accessToken: restored.data.accessToken } }).then(response => response.json());
|
||||
assert.equal(active.code, 200);
|
||||
assert.equal(active.data.userName, 'u131');
|
||||
const independent = await fetch(endpoint + '/login', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userName: 'testuser', password: 'Password123', terminal: 1 }) }).then(response => response.json());
|
||||
assert.equal(independent.code, 10007);
|
||||
const missedTransition = await third('/third/user/createOrUpdate', {
|
||||
...profile, syncVersion: version + 4, accessChangeVersion: version + 4,
|
||||
});
|
||||
assert.equal(missedTransition.code, 200);
|
||||
const revokedAfterMissedDisable = await fetch(endpoint + '/user/self', {
|
||||
headers: { accessToken: restored.data.accessToken },
|
||||
}).then(response => response.json());
|
||||
assert.equal(revokedAfterMissedDisable.code, 401);
|
||||
fresh.socket.send(JSON.stringify({ cmd: 1, data: {} }));
|
||||
await fresh.closed;
|
||||
assert.ok(fresh.packets.some(packet => packet.cmd === 2));
|
||||
} finally {
|
||||
fresh.socket.close();
|
||||
}
|
||||
} finally {
|
||||
old.socket.close();
|
||||
otherNode?.socket.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE IF NOT EXISTS member_chat_sync (
|
||||
member_id BIGINT NOT NULL,
|
||||
profile_json LONGTEXT NOT NULL,
|
||||
member_state TINYINT NOT NULL DEFAULT 0,
|
||||
version BIGINT NOT NULL DEFAULT 1,
|
||||
synced_version BIGINT NOT NULL DEFAULT 0,
|
||||
initialized BIT(1) NOT NULL DEFAULT b'0',
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
next_retry_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_error VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
creator VARCHAR(64) DEFAULT '',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) DEFAULT '',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted BIT(1) NOT NULL DEFAULT b'0',
|
||||
tenant_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (member_id),
|
||||
KEY idx_chat_sync_retry (tenant_id, next_retry_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- MySQL 5.7 can reconstruct AUTO_INCREMENT after restart. Persist the reserved IM ID range.
|
||||
CREATE TABLE IF NOT EXISTS member_chat_sync_sequence (
|
||||
id TINYINT NOT NULL PRIMARY KEY,
|
||||
last_id BIGINT NOT NULL
|
||||
) ENGINE=InnoDB;
|
||||
INSERT INTO member_chat_sync_sequence (id, last_id)
|
||||
SELECT 1, COALESCE(MAX(id), 0) FROM member_user
|
||||
ON DUPLICATE KEY UPDATE last_id = GREATEST(last_id, VALUES(last_id));
|
||||
|
||||
-- Create the handler in a stopped state. Start it after data repair and service verification.
|
||||
INSERT INTO infra_job (name, status, handler_name, handler_param, cron_expression,
|
||||
retry_count, retry_interval, monitor_timeout)
|
||||
SELECT '会员聊天同步与对账', 2, 'memberChatSyncJob', '', '0 * * * * ?', 0, 0, 120000
|
||||
WHERE NOT EXISTS (SELECT 1 FROM infra_job WHERE handler_name = 'memberChatSyncJob' AND deleted = b'0');
|
||||
+14
@@ -1,12 +1,26 @@
|
||||
package cn.iocoder.yudao.module.trade.api.brokerage;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分销 API 接口
|
||||
*/
|
||||
public interface TradeBrokerageUserApi {
|
||||
|
||||
/** 批量查询已建立的分销关系。 */
|
||||
List<BrokerageUserRespDTO> getBrokerageUserList(@NotNull Collection<Long> userIds);
|
||||
|
||||
/** 分页查询直属下级,并批量统计本页下级各自的直属人数。 */
|
||||
PageResult<BrokerageUserRespDTO> getBrokerageUserChildrenPage(@NotNull Long bindUserId,
|
||||
@NotNull @Valid PageParam pageParam);
|
||||
|
||||
/**
|
||||
* 绑定分销员
|
||||
*/
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.trade.api.brokerage.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 分销关系,只包含查询展示所需的字段。
|
||||
*/
|
||||
@Data
|
||||
public class BrokerageUserRespDTO {
|
||||
|
||||
private Long id;
|
||||
|
||||
private Long bindUserId;
|
||||
|
||||
private LocalDateTime bindUserTime;
|
||||
|
||||
/** 直属下级人数,仅在下级分页查询中填充。 */
|
||||
private Long directChildCount;
|
||||
|
||||
}
|
||||
+15
@@ -1,6 +1,9 @@
|
||||
package cn.iocoder.yudao.module.trade.api.brokerage;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO;
|
||||
import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageCodeDO;
|
||||
import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageCodeService;
|
||||
import cn.iocoder.yudao.module.trade.service.brokerage.BrokerageUserService;
|
||||
@@ -9,6 +12,8 @@ import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
@Service
|
||||
@@ -20,6 +25,16 @@ public class TradeBrokerageUserApiImpl implements TradeBrokerageUserApi {
|
||||
@Resource
|
||||
private BrokerageCodeService brokerageCodeService;
|
||||
|
||||
@Override
|
||||
public List<BrokerageUserRespDTO> getBrokerageUserList(Collection<Long> userIds) {
|
||||
return brokerageUserService.getBrokerageUserList(userIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<BrokerageUserRespDTO> getBrokerageUserChildrenPage(Long bindUserId, PageParam pageParam) {
|
||||
return brokerageUserService.getBrokerageUserChildrenPage(bindUserId, pageParam);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean bindBrokerageUser(@NotNull Long userId, @NotNull Long bindUserId) {
|
||||
return brokerageUserService.bindBrokerageUser(userId, bindUserId);
|
||||
|
||||
+23
@@ -2,6 +2,8 @@ package cn.iocoder.yudao.module.trade.dal.mysql.brokerage;
|
||||
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.SortingField;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
@@ -11,6 +13,7 @@ import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokera
|
||||
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankByUserCountRespVO;
|
||||
import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@@ -21,6 +24,8 @@ import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 分销用户 Mapper
|
||||
@@ -30,6 +35,24 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface BrokerageUserMapper extends BaseMapperX<BrokerageUserDO> {
|
||||
|
||||
default PageResult<BrokerageUserDO> selectChildrenPage(Long bindUserId, PageParam pageParam) {
|
||||
return selectPage(pageParam, new LambdaQueryWrapperX<BrokerageUserDO>()
|
||||
.eq(BrokerageUserDO::getBindUserId, bindUserId)
|
||||
.orderByDesc(BrokerageUserDO::getId));
|
||||
}
|
||||
|
||||
default Map<Long, Long> selectDirectChildCountMap(Collection<Long> userIds) {
|
||||
if (userIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<Map<String, Object>> counts = selectMaps(new QueryWrapper<BrokerageUserDO>()
|
||||
.select("bind_user_id AS userId", "COUNT(*) AS childCount")
|
||||
.in("bind_user_id", userIds)
|
||||
.groupBy("bind_user_id"));
|
||||
return counts.stream().collect(Collectors.toMap(
|
||||
row -> MapUtil.getLong(row, "userId"), row -> MapUtil.getLong(row, "childCount")));
|
||||
}
|
||||
|
||||
default PageResult<BrokerageUserDO> selectPage(BrokerageUserPageReqVO reqVO, List<Long> ids) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<BrokerageUserDO>()
|
||||
.inIfPresent(BrokerageUserDO::getId, ids)
|
||||
|
||||
+8
@@ -1,6 +1,8 @@
|
||||
package cn.iocoder.yudao.module.trade.service.brokerage;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO;
|
||||
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserCreateReqVO;
|
||||
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserPageReqVO;
|
||||
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryPageReqVO;
|
||||
@@ -11,6 +13,8 @@ import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分销用户 Service 接口
|
||||
@@ -19,6 +23,10 @@ import javax.validation.constraints.NotNull;
|
||||
*/
|
||||
public interface BrokerageUserService {
|
||||
|
||||
List<BrokerageUserRespDTO> getBrokerageUserList(Collection<Long> userIds);
|
||||
|
||||
PageResult<BrokerageUserRespDTO> getBrokerageUserChildrenPage(Long bindUserId, PageParam pageParam);
|
||||
|
||||
/**
|
||||
* 获得分销用户
|
||||
*
|
||||
|
||||
+24
@@ -6,12 +6,14 @@ import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
|
||||
import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
|
||||
import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO;
|
||||
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserCreateReqVO;
|
||||
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.user.BrokerageUserPageReqVO;
|
||||
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserChildSummaryPageReqVO;
|
||||
@@ -38,6 +40,7 @@ import java.util.*;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMapByFilter;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
||||
import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
@@ -58,6 +61,27 @@ public class BrokerageUserServiceImpl implements BrokerageUserService {
|
||||
@Resource
|
||||
private MemberUserApi memberUserApi;
|
||||
|
||||
@Override
|
||||
public List<BrokerageUserRespDTO> getBrokerageUserList(Collection<Long> userIds) {
|
||||
if (CollUtil.isEmpty(userIds)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return BeanUtils.toBean(brokerageUserMapper.selectBatchIds(userIds), BrokerageUserRespDTO.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<BrokerageUserRespDTO> getBrokerageUserChildrenPage(Long bindUserId, PageParam pageParam) {
|
||||
PageResult<BrokerageUserRespDTO> page = BeanUtils.toBean(
|
||||
brokerageUserMapper.selectChildrenPage(bindUserId, pageParam), BrokerageUserRespDTO.class);
|
||||
if (CollUtil.isEmpty(page.getList())) {
|
||||
return page;
|
||||
}
|
||||
Map<Long, Long> counts = brokerageUserMapper.selectDirectChildCountMap(
|
||||
convertSet(page.getList(), BrokerageUserRespDTO::getId));
|
||||
page.getList().forEach(user -> user.setDirectChildCount(counts.getOrDefault(user.getId(), 0L)));
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BrokerageUserDO getBrokerageUser(Long id) {
|
||||
return brokerageUserMapper.selectById(id);
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package cn.iocoder.yudao.module.trade.service.brokerage;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO;
|
||||
import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageUserMapper;
|
||||
import cn.iocoder.yudao.module.trade.service.config.TradeConfigService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
||||
@Import(BrokerageUserServiceImpl.class)
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:brokerage_relationship;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value;",
|
||||
"spring.sql.init.schema-locations=classpath:/sql/brokerage_relationship_schema.sql"
|
||||
})
|
||||
@Sql(statements = "DELETE FROM trade_brokerage_user", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
|
||||
class BrokerageUserRelationshipTest extends BaseDbUnitTest {
|
||||
|
||||
@Resource private BrokerageUserServiceImpl service;
|
||||
@Resource private BrokerageUserMapper mapper;
|
||||
@Resource private DataSource dataSource;
|
||||
@MockBean private TradeConfigService tradeConfigService;
|
||||
@MockBean private MemberUserApi memberUserApi;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.update("INSERT INTO trade_brokerage_user (id) VALUES (1)");
|
||||
for (long id = 2; id <= 25; id++) {
|
||||
jdbc.update("INSERT INTO trade_brokerage_user (id, bind_user_id) VALUES (?, 1)", id);
|
||||
}
|
||||
jdbc.update("INSERT INTO trade_brokerage_user (id, bind_user_id, deleted) VALUES (30, 1, TRUE)");
|
||||
jdbc.update("INSERT INTO trade_brokerage_user (id, bind_user_id) VALUES (100, 25), (101, 25), (102, 100)");
|
||||
jdbc.update("INSERT INTO trade_brokerage_user (id, bind_user_id, deleted) VALUES (103, 25, TRUE)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void directPagesAreDescendingBoundedAndCountOnlyActiveDirectChildren() {
|
||||
PageParam query = new PageParam();
|
||||
query.setPageSize(20);
|
||||
PageResult<BrokerageUserRespDTO> page = service.getBrokerageUserChildrenPage(1L, query);
|
||||
assertThat(page.getTotal()).isEqualTo(24);
|
||||
assertThat(page.getList()).hasSize(20);
|
||||
assertThat(page.getList()).extracting(BrokerageUserRespDTO::getId).startsWith(25L, 24L).endsWith(6L);
|
||||
assertThat(page.getList().get(0).getDirectChildCount()).isEqualTo(2);
|
||||
assertThat(page.getList().get(1).getDirectChildCount()).isZero();
|
||||
|
||||
query.setPageNo(2);
|
||||
assertThat(service.getBrokerageUserChildrenPage(1L, query).getList())
|
||||
.extracting(BrokerageUserRespDTO::getId).containsExactly(5L, 4L, 3L, 2L);
|
||||
query.setPageNo(3);
|
||||
PageResult<BrokerageUserRespDTO> beyond = service.getBrokerageUserChildrenPage(1L, query);
|
||||
assertThat(beyond.getTotal()).isEqualTo(24);
|
||||
assertThat(beyond.getList()).isEmpty();
|
||||
verifyNoInteractions(tradeConfigService, memberUserApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsFurtherLevelsAndMissingBrokerageRootWithoutCreatingRecords() {
|
||||
PageParam query = new PageParam();
|
||||
assertThat(service.getBrokerageUserChildrenPage(25L, query).getList())
|
||||
.extracting(BrokerageUserRespDTO::getId).containsExactly(101L, 100L);
|
||||
assertThat(service.getBrokerageUserChildrenPage(100L, query).getList())
|
||||
.extracting(BrokerageUserRespDTO::getId).containsExactly(102L);
|
||||
assertThat(service.getBrokerageUserChildrenPage(999L, query).getList()).isEmpty();
|
||||
assertThat(mapper.selectById(999L)).isNull();
|
||||
verifyNoInteractions(tradeConfigService, memberUserApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchLookupExcludesDeletedAndUnknownUsersAndHandlesEmptyInput() {
|
||||
assertThat(service.getBrokerageUserList(Arrays.asList(1L, 25L, 30L, 999L)))
|
||||
.extracting(BrokerageUserRespDTO::getId).containsExactlyInAnyOrder(1L, 25L);
|
||||
assertThat(service.getBrokerageUserList(Collections.emptyList())).isEmpty();
|
||||
assertThat(mapper.selectDirectChildCountMap(Collections.emptyList())).isEmpty();
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS "trade_brokerage_user" (
|
||||
"id" bigint NOT NULL PRIMARY KEY,
|
||||
"bind_user_id" bigint,
|
||||
"bind_user_time" datetime,
|
||||
"brokerage_enabled" bit DEFAULT TRUE,
|
||||
"brokerage_time" datetime,
|
||||
"brokerage_price" int DEFAULT 0,
|
||||
"frozen_price" int DEFAULT 0,
|
||||
"creator" varchar DEFAULT '',
|
||||
"create_time" datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" varchar DEFAULT '',
|
||||
"update_time" datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted" bit DEFAULT FALSE,
|
||||
"tenant_id" bigint DEFAULT 0
|
||||
);
|
||||
+1
@@ -93,6 +93,7 @@ public interface MemberErrorCode {
|
||||
ErrorCode CHAT_TOKEN_CREATE_FAILED = new ErrorCode(1_004_018_002, "聊天登录凭证签发失败,请稍后重试");
|
||||
ErrorCode CHAT_HISTORY_QUERY_FAILED = new ErrorCode(1_004_018_003, "聊天记录加载失败,请稍后重试");
|
||||
ErrorCode CHAT_MESSAGE_MODERATION_FAILED = new ErrorCode(1_004_018_004, "消息屏蔽失败,请稍后重试");
|
||||
ErrorCode CHAT_ACCOUNT_UNAVAILABLE = new ErrorCode(1_004_018_005, "聊天账号已停用,请通过业务系统重新登录");
|
||||
|
||||
//========== 航空账号 1-004-019-000 ==========
|
||||
ErrorCode AIRLINE_ACCOUNT_NOT_EXISTS = new ErrorCode(1_004_019_000, "航空账号不存在");
|
||||
|
||||
+2
@@ -29,6 +29,8 @@ public interface ChatApi {
|
||||
*/
|
||||
boolean createOrUpdate(@Valid ChatCreateOrUpdateUserReqVO dto);
|
||||
|
||||
ChatUserSyncRespVO syncUser(@Valid ChatCreateOrUpdateUserReqVO dto);
|
||||
|
||||
/**
|
||||
* 封禁聊天用户并推送下线
|
||||
*/
|
||||
|
||||
+47
-27
@@ -29,6 +29,7 @@ import java.util.*;
|
||||
|
||||
import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.CHAT_HISTORY_QUERY_FAILED;
|
||||
import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.CHAT_MESSAGE_MODERATION_FAILED;
|
||||
import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.CHAT_ACCOUNT_UNAVAILABLE;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -47,13 +48,16 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.USER_LOGIN);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(2000).execute().body();
|
||||
log.debug("聊天用户登录,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("聊天用户登录,res为空");
|
||||
return null;
|
||||
}
|
||||
ChatBaseRespVO baseRes = JSONUtil.toBean(res, ChatBaseRespVO.class);
|
||||
if (baseRes != null && Integer.valueOf(10005).equals(baseRes.getCode())) {
|
||||
throw new ServiceException(CHAT_ACCOUNT_UNAVAILABLE.getCode(), baseRes.getMessage());
|
||||
}
|
||||
if (ObjectUtil.isEmpty(baseRes) || !baseRes.isSuccess()) {
|
||||
log.error("聊天用户登录,返回错误");
|
||||
return null;
|
||||
@@ -64,6 +68,8 @@ public class ChatApiImpl implements ChatApi {
|
||||
return null;
|
||||
}
|
||||
return vo;
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
log.error("聊天用户登录,意外的错误", e);
|
||||
@@ -79,7 +85,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.GET_USER_ONLINE_STATUS_BY_ID);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("查询用户在线状态,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("查询用户在线状态,res为空");
|
||||
@@ -108,6 +114,11 @@ public class ChatApiImpl implements ChatApi {
|
||||
*/
|
||||
@Override
|
||||
public boolean createOrUpdate(ChatCreateOrUpdateUserReqVO dto) {
|
||||
return syncUser(dto).isSuccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatUserSyncRespVO syncUser(ChatCreateOrUpdateUserReqVO dto) {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.USER_SAVE_OR_UPDATE);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
@@ -122,7 +133,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
log.error("创建或者更新聊天用户,接口响应异常,url={},userId={},httpStatus={},contentType={},response={}",
|
||||
api.getFullPath(), dto.getUserId(), response.getStatus(),
|
||||
response.header("Content-Type"), responseSummary);
|
||||
return false;
|
||||
return ChatUserSyncRespVO.failure(-1, "IM 请求或响应异常,请查看同步日志");
|
||||
}
|
||||
ChatBaseRespVO baseRes;
|
||||
try {
|
||||
@@ -131,18 +142,29 @@ public class ChatApiImpl implements ChatApi {
|
||||
log.error("创建或者更新聊天用户,接口响应解析失败,url={},userId={},httpStatus={},contentType={},response={}",
|
||||
api.getFullPath(), dto.getUserId(), response.getStatus(),
|
||||
response.header("Content-Type"), responseSummary, e);
|
||||
return false;
|
||||
return ChatUserSyncRespVO.failure(-1, "IM 请求或响应异常,请查看同步日志");
|
||||
}
|
||||
if (baseRes == null || !baseRes.isSuccess()) {
|
||||
log.error("创建或者更新聊天用户,接口业务失败,url={},userId={},httpStatus={},response={}",
|
||||
api.getFullPath(), dto.getUserId(), response.getStatus(), responseSummary);
|
||||
return false;
|
||||
return ChatUserSyncRespVO.failure(baseRes == null ? -1 : baseRes.getCode(),
|
||||
baseRes == null ? "IM 响应为空" : baseRes.getMessage());
|
||||
}
|
||||
return true;
|
||||
ChatUserSyncRespVO result = JSONUtil.toBean(baseRes.getData(), ChatUserSyncRespVO.class);
|
||||
if (result == null) {
|
||||
result = new ChatUserSyncRespVO();
|
||||
}
|
||||
result.setCode(200);
|
||||
result.setMessage(baseRes.getMessage());
|
||||
if (dto.getSyncVersion() != null && (result.getAppliedVersion() == null
|
||||
|| !Objects.equals(dto.getUserId(), result.getUserId()))) {
|
||||
return ChatUserSyncRespVO.failure(-1, "IM 响应缺少匹配用户的同步版本,请核对部署版本");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("创建或者更新聊天用户,请求失败,url={},userId={}", api.getFullPath(), dto.getUserId(), e);
|
||||
return false;
|
||||
return ChatUserSyncRespVO.failure(-1, "IM 请求或响应异常,请查看同步日志");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +188,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
try {
|
||||
ChatBanUserReqVO dto = new ChatBanUserReqVO(userId, reason);
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("封禁聊天用户,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("封禁聊天用户,res为空");
|
||||
@@ -192,7 +214,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.USER_GET);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("根据userId查询用户详情,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("根据userId查询用户详情,res为空");
|
||||
@@ -224,7 +246,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.FRIEND_ADD);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("添加好友,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("添加好友,res为空");
|
||||
@@ -291,7 +313,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.SENSITIVE_PAGE);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("敏感词分页,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("敏感词分页,res为空");
|
||||
@@ -328,7 +350,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.SENSITIVE_INFO);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("敏感词详情,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("敏感词详情,res为空");
|
||||
@@ -360,7 +382,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.SENSITIVE_CREATE_OR_UPDATE);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("创建或者更新敏感词,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("创建或者更新敏感词,res为空");
|
||||
@@ -391,7 +413,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.SENSITIVE_DELETAE);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("删除敏感词,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("删除敏感词,res为空");
|
||||
@@ -427,7 +449,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.PRIVATE_MSG_SEND);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("发私聊送消息,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("发私聊送消息,res为空");
|
||||
@@ -486,15 +508,13 @@ public class ChatApiImpl implements ChatApi {
|
||||
*/
|
||||
@Override
|
||||
public ChatPrivateMessageSendResVO serviceUserSendUniqueText(Long targetUserId, String text) {
|
||||
ChatPrivateTotalMessageTotalReqVO dto = new ChatPrivateTotalMessageTotalReqVO();
|
||||
dto.setSendId(ChatConstant.COM_SERVICE_USER_ID);
|
||||
dto.setReceiveId(targetUserId);
|
||||
List<ChatPrivateTotalMessageTotalResVO> total = privateTotal(dto);
|
||||
if (ObjectUtil.isEmpty(total)) {
|
||||
// 发送消息
|
||||
return serviceUserSendText(targetUserId, text);
|
||||
}
|
||||
return null;
|
||||
ChatPrivateMessageSendReqVO request = new ChatPrivateMessageSendReqVO();
|
||||
request.setSendId(ChatConstant.COM_SERVICE_USER_ID);
|
||||
request.setRecvId(targetUserId);
|
||||
request.setTmpId("member-welcome:" + targetUserId);
|
||||
request.setType(0);
|
||||
request.setContent(text);
|
||||
return send(request);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,7 +629,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.PRIVATE_MSG_INFO);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("私聊消息详情,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("私聊消息详情,res为空");
|
||||
@@ -641,7 +661,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.PRIVATE_MSG_COUNT);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("私聊消息统计,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("私聊消息统计,res为空");
|
||||
@@ -673,7 +693,7 @@ public class ChatApiImpl implements ChatApi {
|
||||
ChatApiTool.API api = setApiConfig(ChatApiTool.PRIVATE_MSG_TOTAL);
|
||||
try {
|
||||
String body = JSONUtil.toJsonStr(dto);
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body();
|
||||
String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).timeout(10000).execute().body();
|
||||
log.debug("私聊消息发送消息的统计,res:{}", res);
|
||||
if (ObjectUtil.isEmpty(res)) {
|
||||
log.error("私聊消息发送消息的统计,res为空");
|
||||
|
||||
+5
-2
@@ -20,15 +20,18 @@ public class ChatCreateOrUpdateUserReqVO {
|
||||
@Schema(description = "用户名")
|
||||
private String userName;
|
||||
|
||||
@NotEmpty(message = "用户手机号不可为空")
|
||||
@Schema(description = "用户手机号")
|
||||
private String phone;
|
||||
|
||||
@Length(max = 20, message = "昵称不能大于20字符")
|
||||
@Length(max = 30, message = "昵称不能大于30字符")
|
||||
@Schema(description = "用户昵称")
|
||||
private String nickName;
|
||||
|
||||
@Schema(description = "用户头像")
|
||||
private String avatar;
|
||||
|
||||
private Integer memberState;
|
||||
private Long syncVersion;
|
||||
private Long accessChangeVersion;
|
||||
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ public class ChatGetUserByUserIdRespVO {
|
||||
private String userName;
|
||||
|
||||
@NotEmpty(message = "用户昵称不能为空")
|
||||
@Length(max = 20, message = "昵称不能大于20字符")
|
||||
@Length(max = 30, message = "昵称不能大于30字符")
|
||||
@Schema(description = "用户昵称")
|
||||
private String nickName;
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.member.client.vo.chat.user;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ChatUserSyncRespVO {
|
||||
private Integer code;
|
||||
private String message;
|
||||
private Long userId;
|
||||
private Long appliedVersion;
|
||||
|
||||
public boolean isSuccess() {
|
||||
return Integer.valueOf(200).equals(code);
|
||||
}
|
||||
|
||||
public static ChatUserSyncRespVO failure(Integer code, String message) {
|
||||
ChatUserSyncRespVO result = new ChatUserSyncRespVO();
|
||||
result.setCode(code);
|
||||
result.setMessage(message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+68
-1
@@ -23,6 +23,9 @@ import cn.iocoder.yudao.module.member.service.tag.MemberTagService;
|
||||
import cn.iocoder.yudao.module.member.service.user.MemberRecentCfgService;
|
||||
import cn.iocoder.yudao.module.member.service.user.MemberUserExportService;
|
||||
import cn.iocoder.yudao.module.member.service.user.MemberUserService;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageUserApi;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -44,13 +47,16 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
|
||||
import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
|
||||
|
||||
@Tag(name = "管理后台 - 会员用户")
|
||||
@@ -78,6 +84,8 @@ public class MemberUserController {
|
||||
private MemberRecentCfgService memberRecentCfgService;
|
||||
@Resource
|
||||
private AddressService addressService;
|
||||
@Resource
|
||||
private TradeBrokerageUserApi tradeBrokerageUserApi;
|
||||
|
||||
@PutMapping("/update")
|
||||
@Operation(summary = "更新会员用户")
|
||||
@@ -129,6 +137,7 @@ public class MemberUserController {
|
||||
MemberUserRespVO result = MemberUserConvert.INSTANCE.convert03(user);
|
||||
// 查询用户tag
|
||||
if (ObjectUtil.isNotEmpty(result)) {
|
||||
fillInviters(Collections.singletonList(result));
|
||||
List<MemberRecentTagDO> memberRecentTags = memberRecentTagMapper.getByMemberId(result.getId());
|
||||
if (ObjectUtil.isNotEmpty(memberRecentTags)) {
|
||||
List<MemberUserRespVO.MemberTag> tags = memberRecentTags.stream().map(v -> {
|
||||
@@ -168,7 +177,65 @@ public class MemberUserController {
|
||||
List<MemberRecentCfgDO> memberCfgList = memberRecentCfgService.getByMemberIds(convertSet(pageResult.getList(), MemberUserDO::getId));
|
||||
// 按本页用户批量查询收货地址,避免逐个用户查询。
|
||||
List<MemberAddressDO> addresses = addressService.getAddressListByUserIds(convertSet(pageResult.getList(), MemberUserDO::getId));
|
||||
return success(MemberUserConvert.INSTANCE.convertPage(pageResult, tags, memberRecentTags, levels, groups, memberCfgList, addresses));
|
||||
PageResult<MemberUserRespVO> result = MemberUserConvert.INSTANCE.convertPage(
|
||||
pageResult, tags, memberRecentTags, levels, groups, memberCfgList, addresses);
|
||||
fillInviters(result.getList());
|
||||
return success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/brokerage-children-page")
|
||||
@Operation(summary = "获得会员直属分销下级分页")
|
||||
@PreAuthorize("@ss.hasPermission('member:user:query')")
|
||||
public CommonResult<PageResult<MemberUserBrokerageChildRespVO>> getBrokerageChildrenPage(
|
||||
@Valid MemberUserBrokerageChildrenPageReqVO reqVO) {
|
||||
PageResult<BrokerageUserRespDTO> page = tradeBrokerageUserApi.getBrokerageUserChildrenPage(
|
||||
reqVO.getBindUserId(), reqVO);
|
||||
if (CollUtil.isEmpty(page.getList())) {
|
||||
return success(new PageResult<>(Collections.emptyList(), page.getTotal()));
|
||||
}
|
||||
Map<Long, MemberUserDO> users = convertMap(memberUserService.getUserList(
|
||||
convertSet(page.getList(), BrokerageUserRespDTO::getId)), MemberUserDO::getId);
|
||||
PageResult<MemberUserBrokerageChildRespVO> result = BeanUtils.toBean(page, MemberUserBrokerageChildRespVO.class);
|
||||
result.getList().forEach(child -> {
|
||||
MemberUserDO user = users.get(child.getId());
|
||||
child.setMemberExists(user != null);
|
||||
if (user != null) {
|
||||
child.setUserCode(user.getUserCode());
|
||||
child.setNickname(user.getNickname());
|
||||
child.setAvatar(user.getAvatar());
|
||||
child.setStatus(user.getStatus());
|
||||
}
|
||||
});
|
||||
return success(result);
|
||||
}
|
||||
|
||||
private void fillInviters(List<MemberUserRespVO> users) {
|
||||
List<BrokerageUserRespDTO> relations = tradeBrokerageUserApi.getBrokerageUserList(
|
||||
convertSet(users, MemberUserRespVO::getId));
|
||||
Set<Long> inviterIds = relations.stream().map(BrokerageUserRespDTO::getBindUserId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
if (inviterIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, MemberUserDO> inviters = convertMap(memberUserService.getUserList(inviterIds), MemberUserDO::getId);
|
||||
Map<Long, BrokerageUserRespDTO> relationMap = convertMap(relations, BrokerageUserRespDTO::getId);
|
||||
users.forEach(user -> {
|
||||
BrokerageUserRespDTO relation = relationMap.get(user.getId());
|
||||
if (relation == null || relation.getBindUserId() == null) {
|
||||
return;
|
||||
}
|
||||
MemberUserDO inviter = inviters.get(relation.getBindUserId());
|
||||
MemberUserInviterRespVO summary = new MemberUserInviterRespVO();
|
||||
summary.setId(relation.getBindUserId());
|
||||
summary.setBindUserTime(relation.getBindUserTime());
|
||||
summary.setMemberExists(inviter != null);
|
||||
if (inviter != null) {
|
||||
summary.setUserCode(inviter.getUserCode());
|
||||
summary.setNickname(inviter.getNickname());
|
||||
summary.setAvatar(inviter.getAvatar());
|
||||
}
|
||||
user.setInviter(summary);
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/export-async")
|
||||
|
||||
+2
@@ -28,6 +28,8 @@ public class MemberUserBaseVO {
|
||||
|
||||
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
|
||||
@NotNull(message = "用户昵称不能为空")
|
||||
@org.hibernate.validator.constraints.Length(max = 30, message = "昵称不能大于30字符")
|
||||
@javax.validation.constraints.Pattern(regexp = "(?s).*\\S.*", message = "昵称不可为空白")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/x.png")
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.member.controller.admin.user.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "管理后台 - 分销关系树直属下级")
|
||||
@Data
|
||||
public class MemberUserBrokerageChildRespVO {
|
||||
|
||||
private Long id;
|
||||
|
||||
private String userCode;
|
||||
|
||||
private String nickname;
|
||||
|
||||
private String avatar;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private Long bindUserId;
|
||||
|
||||
private LocalDateTime bindUserTime;
|
||||
|
||||
@Schema(description = "直属下级人数")
|
||||
private Long directChildCount;
|
||||
|
||||
@Schema(description = "会员资料是否存在")
|
||||
private Boolean memberExists;
|
||||
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.member.controller.admin.user.vo;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Positive;
|
||||
|
||||
@Schema(description = "管理后台 - 会员直属下级分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class MemberUserBrokerageChildrenPageReqVO extends PageParam {
|
||||
|
||||
@NotNull(message = "邀请人编号不能为空")
|
||||
@Positive(message = "邀请人编号必须为正数")
|
||||
private Long bindUserId;
|
||||
|
||||
public MemberUserBrokerageChildrenPageReqVO() {
|
||||
setPageSize(20);
|
||||
}
|
||||
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.member.controller.admin.user.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "管理后台 - 会员邀请人")
|
||||
@Data
|
||||
public class MemberUserInviterRespVO {
|
||||
|
||||
@Schema(description = "邀请人的会员编号")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "邀请人的用户ID")
|
||||
private String userCode;
|
||||
|
||||
private String nickname;
|
||||
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "当前会员与邀请人的绑定时间")
|
||||
private LocalDateTime bindUserTime;
|
||||
|
||||
@Schema(description = "邀请人的会员资料是否存在")
|
||||
private Boolean memberExists;
|
||||
|
||||
}
|
||||
+3
@@ -22,6 +22,9 @@ public class MemberUserRespVO extends MemberUserBaseVO {
|
||||
@Schema(description = "用户 Code,6 位数字和大小写字母组合", example = "aB3xY9")
|
||||
private String userCode;
|
||||
|
||||
@Schema(description = "直接邀请人,未绑定时为空")
|
||||
private MemberUserInviterRespVO inviter;
|
||||
|
||||
@Schema(description = "微信号", example = "wx_user")
|
||||
private String wechatNum;
|
||||
|
||||
|
||||
+2
@@ -19,6 +19,8 @@ public class AppMemberUserUpdateReqVO {
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "用户昵称")
|
||||
@org.hibernate.validator.constraints.Length(max = 30, message = "昵称不能大于30字符")
|
||||
@javax.validation.constraints.Pattern(regexp = "(?s).*\\S.*", message = "昵称不可为空白")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "出生日期")
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package cn.iocoder.yudao.module.member.dal.dataobject.user;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("member_chat_sync")
|
||||
public class MemberChatSyncDO extends TenantBaseDO {
|
||||
@TableId
|
||||
private Long memberId;
|
||||
private String profileJson;
|
||||
private Integer memberState;
|
||||
private Long version;
|
||||
private Long syncedVersion;
|
||||
private Boolean initialized;
|
||||
private Integer retryCount;
|
||||
private LocalDateTime nextRetryTime;
|
||||
private String lastError;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.member.dal.mysql.user;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberChatSyncDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface MemberChatSyncMapper extends BaseMapperX<MemberChatSyncDO> {
|
||||
@Select("SELECT * FROM member_chat_sync WHERE member_id = #{id} AND deleted = 0 FOR UPDATE")
|
||||
MemberChatSyncDO selectForUpdate(@Param("id") Long id);
|
||||
|
||||
@Select("SELECT * FROM member_chat_sync WHERE deleted = 0 "
|
||||
+ "AND (synced_version < version OR (member_state = 0 AND initialized = 0)) "
|
||||
+ "AND next_retry_time <= CURRENT_TIMESTAMP ORDER BY member_state DESC, next_retry_time, member_id LIMIT 200")
|
||||
List<MemberChatSyncDO> selectPending();
|
||||
|
||||
@Select("SELECT * FROM member_chat_sync WHERE deleted = 0 AND member_state <> 3 "
|
||||
+ "AND NOT EXISTS (SELECT 1 FROM member_user WHERE member_user.id = member_chat_sync.member_id) LIMIT 200")
|
||||
List<MemberChatSyncDO> selectOrphans();
|
||||
}
|
||||
+23
@@ -13,6 +13,8 @@ import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
@@ -28,6 +30,27 @@ import java.util.stream.IntStream;
|
||||
@Mapper
|
||||
public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
|
||||
|
||||
@com.baomidou.mybatisplus.annotation.InterceptorIgnore(tenantLine = "true")
|
||||
@Select("SELECT last_id FROM member_chat_sync_sequence WHERE id = 1 FOR UPDATE")
|
||||
Long lockChatMemberSequence();
|
||||
|
||||
@com.baomidou.mybatisplus.annotation.InterceptorIgnore(tenantLine = "true")
|
||||
@Select("SELECT COALESCE(MAX(id), 0) FROM member_user")
|
||||
Long selectGlobalMaximumMemberId();
|
||||
|
||||
@com.baomidou.mybatisplus.annotation.InterceptorIgnore(tenantLine = "true")
|
||||
@org.apache.ibatis.annotations.Update("UPDATE member_chat_sync_sequence SET last_id = #{id} WHERE id = 1")
|
||||
int updateChatMemberSequence(@Param("id") Long id);
|
||||
|
||||
@Select("SELECT * FROM member_user WHERE mobile = #{mobile} AND deleted = 0 FOR UPDATE")
|
||||
MemberUserDO selectMobileForCreate(@Param("mobile") String mobile);
|
||||
|
||||
@Select("SELECT * FROM member_user WHERE id = #{id} FOR UPDATE")
|
||||
MemberUserDO selectForChatSync(@Param("id") Long id);
|
||||
|
||||
@Select("SELECT * FROM member_user WHERE id > #{cursor} ORDER BY id LIMIT 200")
|
||||
List<MemberUserDO> selectChatReconcileBatch(@Param("cursor") Long cursor);
|
||||
|
||||
default MemberUserDO selectByMobile(String mobile) {
|
||||
return selectOne(MemberUserDO::getMobile, mobile);
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package cn.iocoder.yudao.module.member.job.member;
|
||||
|
||||
import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
|
||||
import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
|
||||
import cn.iocoder.yudao.module.member.service.user.MemberChatSyncService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
@Component
|
||||
public class MemberChatSyncJob implements JobHandler {
|
||||
@Resource
|
||||
private MemberChatSyncService syncService;
|
||||
|
||||
@Override
|
||||
@TenantJob
|
||||
public String execute(String param) {
|
||||
return "已对账聊天会员 " + syncService.reconcileAndRetry() + " 个";
|
||||
}
|
||||
}
|
||||
+15
-51
@@ -195,7 +195,21 @@ public class MemberAuthServiceImpl implements MemberAuthService {
|
||||
.setUserId(user.getId()).setUserType(getUserType().getValue())
|
||||
.setClientId(OAuth2ClientConstants.CLIENT_ID_DEFAULT));
|
||||
// 构建返回结果
|
||||
return AuthConvert.INSTANCE.convert(accessTokenRespDTO, openid);
|
||||
AppAuthLoginRespVO result = AuthConvert.INSTANCE.convert(accessTokenRespDTO, openid);
|
||||
try {
|
||||
if (userService.prepareChatUser(user.getId())) {
|
||||
ChatLoginReqVO request = new ChatLoginReqVO();
|
||||
request.setUserId(user.getId());
|
||||
request.setTerminal(1);
|
||||
ChatLoginRespVO chatToken = chatApi.login(request);
|
||||
if (chatToken != null) {
|
||||
result.setChatToken(BeanUtils.toBean(chatToken, AppAuthLoginRespVO.ChatToken.class));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("业务登录成功,聊天凭证待补发,userId={}", user.getId(), e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -260,61 +274,11 @@ public class MemberAuthServiceImpl implements MemberAuthService {
|
||||
smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.MEMBER_LOGIN.getScene(), userIp));
|
||||
}
|
||||
|
||||
// 当前用户是否已注册
|
||||
if (userService.getUserByMobile(reqVO.getMobile()) != null) {
|
||||
// 已经注册就登录
|
||||
MemberUserDO user = userService.createUserIfAbsent(reqVO.getMobile(), userIp, getTerminal());
|
||||
Assert.notNull(user, "获取用户失败,结果为空");
|
||||
AppAuthLoginRespVO serviceToken = createTokenAfterLoginSuccess(user, reqVO.getMobile(), LoginLogTypeEnum.LOGIN_SMS, null);
|
||||
// 同步聊天的用户体系
|
||||
boolean isSync = userService.syncChatUser(user.getId());
|
||||
if (isSync) {
|
||||
ChatLoginReqVO loginObj = new ChatLoginReqVO();
|
||||
loginObj.setTerminal(1);
|
||||
loginObj.setUserId(user.getId());
|
||||
ChatLoginRespVO login = chatApi.login(loginObj);
|
||||
if (ObjectUtil.isNotEmpty(login)) {
|
||||
serviceToken.setChatToken(BeanUtils.toBean(login, AppAuthLoginRespVO.ChatToken.class));
|
||||
}
|
||||
}
|
||||
// 更新朋友圈用户新人tag
|
||||
memberRecentTagService.createOrUpdateNewPersonTag(user.getId());
|
||||
// 初始化用户朋友圈配置
|
||||
memberRecentCfgService.getOrCreate(user.getId());
|
||||
// 为用户添加`消息通知`和`客服`
|
||||
chatApi.addMessageFriend(user.getId());
|
||||
chatApi.addServiceFriend(user.getId());
|
||||
chatApi.serviceUserSendUniqueText(user.getId(), "您好,您遇到什么问题了?可直接输入问题,我帮您解答");
|
||||
return serviceToken;
|
||||
}
|
||||
|
||||
// 获得获得注册用户
|
||||
MemberUserDO user = userService.createUserIfAbsent(reqVO.getMobile(), userIp, getTerminal());
|
||||
Assert.notNull(user, "获取用户失败,结果为空");
|
||||
|
||||
// 创建 Token 令牌,记录登录日志
|
||||
AppAuthLoginRespVO serviceToken = createTokenAfterLoginSuccess(user, reqVO.getMobile(), LoginLogTypeEnum.LOGIN_SMS, null);
|
||||
|
||||
// 同步聊天的用户体系
|
||||
boolean isSync = userService.syncChatUser(user.getId());
|
||||
if (isSync) {
|
||||
ChatLoginReqVO loginObj = new ChatLoginReqVO();
|
||||
loginObj.setTerminal(1);
|
||||
loginObj.setUserId(user.getId());
|
||||
ChatLoginRespVO login = chatApi.login(loginObj);
|
||||
if (ObjectUtil.isNotEmpty(login)) {
|
||||
serviceToken.setChatToken(BeanUtils.toBean(login, AppAuthLoginRespVO.ChatToken.class));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新朋友圈用户新人tag
|
||||
memberRecentTagService.createOrUpdateNewPersonTag(user.getId());
|
||||
// 初始化用户朋友圈配置
|
||||
memberRecentCfgService.getOrCreate(user.getId());
|
||||
// 为用户添加`消息通知`和`客服`
|
||||
chatApi.addMessageFriend(user.getId());
|
||||
chatApi.addServiceFriend(user.getId());
|
||||
chatApi.serviceUserSendUniqueText(user.getId(), "您好,您遇到什么问题了?可直接输入问题,我帮您解答");
|
||||
return serviceToken;
|
||||
}
|
||||
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
package cn.iocoder.yudao.module.member.service.user;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.iocoder.yudao.module.member.client.service.ChatApi;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatCreateOrUpdateUserReqVO;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatUserSyncRespVO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberChatSyncDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberChatSyncMapper;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MemberChatSyncService {
|
||||
private static final int[] RETRY_MINUTES = {1, 5, 15, 30};
|
||||
private static final String WELCOME = "您好,您遇到什么问题了?可直接输入问题,我帮您解答";
|
||||
|
||||
private final MemberUserMapper userMapper;
|
||||
private final MemberChatSyncMapper syncMapper;
|
||||
private final ChatApi chatApi;
|
||||
private final RedissonClient redissonClient;
|
||||
private final TaskExecutor dispatcher;
|
||||
private final TransactionTemplate transactions;
|
||||
private final TransactionTemplate independentTransactions;
|
||||
|
||||
@Autowired
|
||||
public MemberChatSyncService(MemberUserMapper userMapper, MemberChatSyncMapper syncMapper,
|
||||
ChatApi chatApi, RedissonClient redissonClient,
|
||||
PlatformTransactionManager transactionManager) {
|
||||
this(userMapper, syncMapper, chatApi, redissonClient, transactionManager, createDispatcher());
|
||||
}
|
||||
|
||||
MemberChatSyncService(MemberUserMapper userMapper, MemberChatSyncMapper syncMapper,
|
||||
ChatApi chatApi, RedissonClient redissonClient,
|
||||
PlatformTransactionManager transactionManager,
|
||||
TaskExecutor dispatcher) {
|
||||
this.userMapper = userMapper;
|
||||
this.syncMapper = syncMapper;
|
||||
this.chatApi = chatApi;
|
||||
this.redissonClient = redissonClient;
|
||||
this.dispatcher = dispatcher;
|
||||
transactions = new TransactionTemplate(transactionManager);
|
||||
independentTransactions = new TransactionTemplate(transactionManager);
|
||||
independentTransactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
private static ThreadPoolTaskExecutor createDispatcher() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2);
|
||||
executor.setMaxPoolSize(4);
|
||||
executor.setQueueCapacity(200);
|
||||
executor.setThreadNamePrefix("member-chat-sync-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
|
||||
@javax.annotation.PreDestroy
|
||||
public void shutdown() {
|
||||
if (dispatcher instanceof ThreadPoolTaskExecutor) {
|
||||
((ThreadPoolTaskExecutor) dispatcher).shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/** Joins the caller's transaction, so rolled-back profiles never reach IM. */
|
||||
public void enqueue(Long memberId) {
|
||||
enqueue(memberId, true);
|
||||
}
|
||||
|
||||
private void enqueue(Long memberId, boolean dispatchAfterCommit) {
|
||||
transactions.executeWithoutResult(status -> {
|
||||
MemberUserDO member = userMapper.selectForChatSync(memberId);
|
||||
MemberChatSyncDO task = syncMapper.selectForUpdate(memberId);
|
||||
if (member == null && task == null) {
|
||||
return;
|
||||
}
|
||||
ChatCreateOrUpdateUserReqVO profile;
|
||||
if (member == null) {
|
||||
profile = JSONUtil.toBean(task.getProfileJson(), ChatCreateOrUpdateUserReqVO.class);
|
||||
profile.setMemberState(3);
|
||||
} else {
|
||||
profile = profile(member);
|
||||
}
|
||||
ChatCreateOrUpdateUserReqVO previous = task == null ? null
|
||||
: JSONUtil.toBean(task.getProfileJson(), ChatCreateOrUpdateUserReqVO.class);
|
||||
if (previous != null) {
|
||||
profile.setAccessChangeVersion(previous.getAccessChangeVersion());
|
||||
}
|
||||
if (Objects.equals(profile, previous)) {
|
||||
return;
|
||||
}
|
||||
long version = task == null ? 1 : Math.addExact(task.getVersion(), 1);
|
||||
if (previous != null && !Objects.equals(previous.getMemberState(), profile.getMemberState())) {
|
||||
// A later restore must retain revocation even if the disabled snapshot was never delivered.
|
||||
profile.setAccessChangeVersion(version);
|
||||
}
|
||||
String json = JSONUtil.toJsonStr(profile);
|
||||
if (task == null) {
|
||||
task = new MemberChatSyncDO();
|
||||
task.setMemberId(memberId);
|
||||
task.setTenantId(member.getTenantId());
|
||||
task.setVersion(version);
|
||||
task.setSyncedVersion(0L);
|
||||
task.setInitialized(false);
|
||||
task.setProfileJson(json);
|
||||
task.setMemberState(profile.getMemberState());
|
||||
task.setRetryCount(0);
|
||||
task.setNextRetryTime(LocalDateTime.now());
|
||||
task.setLastError("");
|
||||
syncMapper.insert(task);
|
||||
} else {
|
||||
task.setVersion(version);
|
||||
task.setProfileJson(json);
|
||||
task.setMemberState(profile.getMemberState());
|
||||
task.setRetryCount(0);
|
||||
task.setNextRetryTime(LocalDateTime.now());
|
||||
task.setLastError("");
|
||||
task.setUpdateTime(null);
|
||||
syncMapper.updateById(task);
|
||||
}
|
||||
if (dispatchAfterCommit) {
|
||||
Long tenantId = task.getTenantId();
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
try {
|
||||
dispatcher.execute(() -> TenantUtils.execute(tenantId, () -> dispatch(memberId)));
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("聊天同步执行队列繁忙,任务保留待补偿,userId={}", memberId, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public boolean ensureSynced(Long memberId) {
|
||||
boolean awaitingCommit = TransactionSynchronizationManager.isActualTransactionActive();
|
||||
enqueue(memberId);
|
||||
if (awaitingCommit) {
|
||||
return false;
|
||||
}
|
||||
dispatch(memberId, 1000);
|
||||
return isCurrentVersionReady(memberId);
|
||||
}
|
||||
|
||||
public boolean prepareForLogin(Long memberId) {
|
||||
boolean awaitingCommit = TransactionSynchronizationManager.isActualTransactionActive();
|
||||
enqueue(memberId);
|
||||
return !awaitingCommit && isCurrentVersionReady(memberId);
|
||||
}
|
||||
|
||||
private boolean isCurrentVersionReady(Long memberId) {
|
||||
MemberChatSyncDO current = independentTransactions.execute(status -> syncMapper.selectById(memberId));
|
||||
return current != null && current.getMemberState() == 0
|
||||
&& current.getSyncedVersion() >= current.getVersion();
|
||||
}
|
||||
|
||||
public void dispatch(Long memberId) {
|
||||
dispatch(memberId, 0);
|
||||
}
|
||||
|
||||
private void dispatch(Long memberId, long waitMillis) {
|
||||
RLock lock = null;
|
||||
try {
|
||||
lock = redissonClient.getLock("member:chat:sync:" + memberId);
|
||||
if (!lock.tryLock(waitMillis, java.util.concurrent.TimeUnit.MILLISECONDS)) {
|
||||
return;
|
||||
}
|
||||
for (int pass = 0; pass < 5; pass++) {
|
||||
MemberChatSyncDO task = independentTransactions.execute(status -> syncMapper.selectById(memberId));
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
long sentVersion = task.getVersion();
|
||||
dispatchSnapshot(task);
|
||||
MemberChatSyncDO latest = independentTransactions.execute(status -> syncMapper.selectById(memberId));
|
||||
if (latest == null || latest.getVersion() == sentVersion) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// The committed task remains pending when Redis or the local DB is unavailable.
|
||||
log.error("执行聊天同步任务失败,userId={}", memberId, e);
|
||||
} finally {
|
||||
try {
|
||||
if (lock != null && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("释放聊天同步锁失败,userId={}", memberId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchSnapshot(MemberChatSyncDO task) {
|
||||
Long memberId = task.getMemberId();
|
||||
try {
|
||||
if (task.getSyncedVersion() < task.getVersion()) {
|
||||
ChatCreateOrUpdateUserReqVO request = JSONUtil.toBean(task.getProfileJson(), ChatCreateOrUpdateUserReqVO.class);
|
||||
request.setSyncVersion(task.getVersion());
|
||||
ChatUserSyncRespVO response = chatApi.syncUser(request);
|
||||
if (response == null || !response.isSuccess() || response.getAppliedVersion() == null
|
||||
|| !Objects.equals(response.getAppliedVersion(), task.getVersion())) {
|
||||
String error = response == null ? "IM 未返回同步结果"
|
||||
: response.getCode() + " " + response.getMessage() + ", appliedVersion=" + response.getAppliedVersion();
|
||||
recordFailure(task, error);
|
||||
return;
|
||||
}
|
||||
independentTransactions.executeWithoutResult(status -> syncMapper.update(null, taskVersion(task)
|
||||
.set(MemberChatSyncDO::getSyncedVersion, task.getVersion())
|
||||
.set(MemberChatSyncDO::getRetryCount, 0).set(MemberChatSyncDO::getLastError, "")));
|
||||
}
|
||||
MemberChatSyncDO latest = independentTransactions.execute(status -> syncMapper.selectById(memberId));
|
||||
if (latest == null || !Objects.equals(latest.getVersion(), task.getVersion())) {
|
||||
return;
|
||||
}
|
||||
if (task.getMemberState() == 0 && !Boolean.TRUE.equals(task.getInitialized())) {
|
||||
if (!chatApi.addMessageFriend(memberId) || !chatApi.addServiceFriend(memberId)
|
||||
|| chatApi.serviceUserSendUniqueText(memberId, WELCOME) == null) {
|
||||
recordFailure(task, "聊天用户已同步,默认好友或欢迎消息待补偿");
|
||||
return;
|
||||
}
|
||||
independentTransactions.executeWithoutResult(status -> syncMapper.update(null,
|
||||
taskVersion(task).set(MemberChatSyncDO::getInitialized, true)
|
||||
.set(MemberChatSyncDO::getRetryCount, 0).set(MemberChatSyncDO::getLastError, "")));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
recordFailure(task, e.getClass().getSimpleName() + " " + e.getMessage());
|
||||
log.warn("同步聊天会员失败,userId={},version={}", memberId, task.getVersion(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public int reconcileAndRetry() {
|
||||
long cursor = 0;
|
||||
int inspected = 0;
|
||||
while (true) {
|
||||
List<MemberUserDO> batch = userMapper.selectChatReconcileBatch(cursor);
|
||||
if (batch.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
for (MemberUserDO member : batch) {
|
||||
enqueue(member.getId(), false);
|
||||
cursor = member.getId();
|
||||
inspected++;
|
||||
}
|
||||
}
|
||||
for (MemberChatSyncDO task : syncMapper.selectOrphans()) {
|
||||
enqueue(task.getMemberId(), false);
|
||||
}
|
||||
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(45);
|
||||
for (MemberChatSyncDO task : syncMapper.selectPending()) {
|
||||
dispatch(task.getMemberId());
|
||||
if (System.nanoTime() >= deadline) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return inspected;
|
||||
}
|
||||
|
||||
private void recordFailure(MemberChatSyncDO task, String error) {
|
||||
int attempt = Math.min(task.getRetryCount(), Integer.MAX_VALUE - 2) + 1;
|
||||
int minutes = RETRY_MINUTES[Math.min(attempt - 1, RETRY_MINUTES.length - 1)];
|
||||
independentTransactions.executeWithoutResult(status -> syncMapper.update(null, taskVersion(task)
|
||||
.set(MemberChatSyncDO::getRetryCount, attempt)
|
||||
.set(MemberChatSyncDO::getNextRetryTime, LocalDateTime.now().plusMinutes(minutes))
|
||||
.set(MemberChatSyncDO::getLastError, StrUtil.sub(error, 0, 1000))));
|
||||
log.warn("聊天会员待补偿,userId={},version={},error={}", task.getMemberId(), task.getVersion(), error);
|
||||
}
|
||||
|
||||
private LambdaUpdateWrapper<MemberChatSyncDO> taskVersion(MemberChatSyncDO task) {
|
||||
return new LambdaUpdateWrapper<MemberChatSyncDO>()
|
||||
.eq(MemberChatSyncDO::getMemberId, task.getMemberId()).eq(MemberChatSyncDO::getVersion, task.getVersion());
|
||||
}
|
||||
|
||||
private ChatCreateOrUpdateUserReqVO profile(MemberUserDO member) {
|
||||
ChatCreateOrUpdateUserReqVO profile = new ChatCreateOrUpdateUserReqVO();
|
||||
profile.setUserId(member.getId());
|
||||
profile.setUserName("u" + member.getId());
|
||||
profile.setNickName(StrUtil.isBlank(member.getNickname()) ? profile.getUserName() : member.getNickname());
|
||||
profile.setAvatar(member.getAvatar() == null ? "" : member.getAvatar());
|
||||
profile.setPhone(member.getMobile());
|
||||
int state = Integer.valueOf(0).equals(member.getStatus()) ? 0 : 1;
|
||||
profile.setMemberState(Boolean.TRUE.equals(member.getDeleted()) ? 2 : state);
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
+3
@@ -218,6 +218,9 @@ public interface MemberUserService {
|
||||
*/
|
||||
boolean syncChatUser(Long id);
|
||||
|
||||
/** Queues current IM data and reports already-confirmed readiness without waiting for synchronization. */
|
||||
boolean prepareChatUser(Long id);
|
||||
|
||||
/**
|
||||
* 获取用户的微信信息
|
||||
*/
|
||||
|
||||
+31
-30
@@ -11,8 +11,6 @@ import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
|
||||
import cn.iocoder.yudao.module.member.client.service.ChatApi;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatCreateOrUpdateUserReqVO;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendResVO;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatUserOnLineStatusReqVo;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatUserOnLineStatusRespVo;
|
||||
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO;
|
||||
@@ -101,6 +99,8 @@ public class MemberUserServiceImpl implements MemberUserService {
|
||||
@Resource
|
||||
private ChatApi chatApi;
|
||||
@Resource
|
||||
private MemberChatSyncService memberChatSyncService;
|
||||
@Resource
|
||||
private TradeBrokerageUserApi tradeBrokerageUserApi;
|
||||
@Resource
|
||||
private OAuth2TokenApi oauth2TokenApi;
|
||||
@@ -148,8 +148,23 @@ public class MemberUserServiceImpl implements MemberUserService {
|
||||
// 昵称为空时,随机一个名字,避免一些依赖 nickname 的逻辑报错,或者有点丑。例如说,短信发送有昵称时~
|
||||
user.setNickname("用户" + RandomUtil.randomNumbers(6));
|
||||
}
|
||||
Long reservedId = memberUserMapper.lockChatMemberSequence();
|
||||
if (reservedId == null) {
|
||||
throw new IllegalStateException("会员编号水位未初始化,请先执行聊天同步迁移");
|
||||
}
|
||||
if (StrUtil.isNotBlank(mobile)) {
|
||||
// A locking read sees a registration that committed while this request waited for the sequence.
|
||||
MemberUserDO concurrentUser = memberUserMapper.selectMobileForCreate(mobile);
|
||||
if (concurrentUser != null) {
|
||||
return concurrentUser;
|
||||
}
|
||||
}
|
||||
long nextId = Math.addExact(Math.max(reservedId, memberUserMapper.selectGlobalMaximumMemberId()), 1);
|
||||
user.setId(nextId);
|
||||
memberUserMapper.updateChatMemberSequence(nextId);
|
||||
memberUserMapper.insert(user);
|
||||
assignUserCode(user);
|
||||
memberChatSyncService.enqueue(user.getId());
|
||||
|
||||
// 发送 MQ 消息:用户创建
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@@ -323,7 +338,7 @@ public class MemberUserServiceImpl implements MemberUserService {
|
||||
photoMapper.insert(photoList);
|
||||
}
|
||||
// 修改用户信息推送到聊天系统
|
||||
syncChatUser(updateObj.getId());
|
||||
memberChatSyncService.enqueue(updateObj.getId());
|
||||
// 绑定分销关系
|
||||
if (ObjectUtil.isNotEmpty(reqVO.getInvitationCode())) {
|
||||
// 查询推广人
|
||||
@@ -351,6 +366,7 @@ public class MemberUserServiceImpl implements MemberUserService {
|
||||
|
||||
// 3. 更新用户手机
|
||||
memberUserMapper.updateById(MemberUserDO.builder().id(userId).mobile(reqVO.getMobile()).build());
|
||||
memberChatSyncService.enqueue(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -364,6 +380,7 @@ public class MemberUserServiceImpl implements MemberUserService {
|
||||
|
||||
// 2. 更新用户手机
|
||||
memberUserMapper.updateById(MemberUserDO.builder().id(userId).mobile(phoneNumberInfo.getPhoneNumber()).build());
|
||||
memberChatSyncService.enqueue(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -458,6 +475,7 @@ public class MemberUserServiceImpl implements MemberUserService {
|
||||
// 更新用户新人tag
|
||||
memberRecentTagService.createOrUpdateNewPersonTag(updateReqVO.getId());
|
||||
memberUserMapper.updateById(updateObj);
|
||||
memberChatSyncService.enqueue(updateObj.getId());
|
||||
|
||||
// 变为禁用:踢出全部 App 登录态
|
||||
Integer newStatus = updateObj.getStatus();
|
||||
@@ -474,6 +492,7 @@ public class MemberUserServiceImpl implements MemberUserService {
|
||||
public void updateUserStatus(Long id, Integer status) {
|
||||
MemberUserDO dbUser = validateUserExists(id);
|
||||
if (ObjectUtil.equal(dbUser.getStatus(), status)) {
|
||||
memberChatSyncService.enqueue(id);
|
||||
// 已禁用时再点一次,仍强制踢一次,避免漏踢
|
||||
if (CommonStatusEnum.isDisable(status)) {
|
||||
kickMemberLogin(id);
|
||||
@@ -481,29 +500,17 @@ public class MemberUserServiceImpl implements MemberUserService {
|
||||
return;
|
||||
}
|
||||
memberUserMapper.updateById(new MemberUserDO().setId(id).setStatus(status));
|
||||
memberChatSyncService.enqueue(id);
|
||||
if (CommonStatusEnum.isDisable(status)) {
|
||||
kickMemberLogin(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理会员全部 OAuth2 access + refresh,并通过 IM 私聊通知 App 踢登录(不封禁 IM 账号)。
|
||||
* 撤销会员 OAuth2 登录态,IM 会员状态由持久化同步任务单独管理。
|
||||
*/
|
||||
private void kickMemberLogin(Long userId) {
|
||||
// 先发 IM 消息(WS 仍在线时更容易送达),再清业务 token
|
||||
try {
|
||||
syncChatUser(userId);
|
||||
// 约定前缀:Flutter 收到后先跳登录页,再清理本地态(不走 /third/user/ban)
|
||||
ChatPrivateMessageSendResVO sent = chatApi.messageUserSendText(
|
||||
userId, "__APP_KICK_SESSION__:账号已被禁用");
|
||||
if (sent == null) {
|
||||
log.warn("禁用踢登录:IM 私聊通知未发出, userId={}", userId);
|
||||
} else {
|
||||
log.info("禁用踢登录:已发 IM 私聊通知, userId={}", userId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("禁用踢登录:IM 私聊通知失败, userId={}", userId, e);
|
||||
}
|
||||
// IM 停用由已落库的同步任务在事务提交后执行。
|
||||
try {
|
||||
oauth2TokenApi.removeAccessTokenByUserId(userId, UserTypeEnum.MEMBER.getValue());
|
||||
log.info("已清理会员登录 token, userId={}", userId);
|
||||
@@ -585,18 +592,12 @@ public class MemberUserServiceImpl implements MemberUserService {
|
||||
|
||||
@Override
|
||||
public boolean syncChatUser(Long id) {
|
||||
// 查询系统用户
|
||||
MemberUserDO memberUser = memberUserMapper.selectById(id);
|
||||
if (ObjectUtil.isEmpty(memberUser)) {
|
||||
return false;
|
||||
}
|
||||
ChatCreateOrUpdateUserReqVO chatUser = new ChatCreateOrUpdateUserReqVO();
|
||||
chatUser.setUserId(memberUser.getId());
|
||||
chatUser.setUserName(memberUser.getNickname());
|
||||
chatUser.setPhone(memberUser.getMobile());
|
||||
chatUser.setNickName(memberUser.getNickname());
|
||||
chatUser.setAvatar(memberUser.getAvatar());
|
||||
return chatApi.createOrUpdate(chatUser);
|
||||
return memberChatSyncService.ensureSynced(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean prepareChatUser(Long id) {
|
||||
return memberChatSyncService.prepareForLogin(id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+26
@@ -38,6 +38,7 @@ class ChatApiImplCreateOrUpdateTest {
|
||||
void setUp() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/third/user/createOrUpdate", this::respond);
|
||||
server.createContext("/third/user/login", this::respond);
|
||||
server.start();
|
||||
|
||||
ChatProperties properties = new ChatProperties();
|
||||
@@ -60,6 +61,19 @@ class ChatApiImplCreateOrUpdateTest {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableLoginPreservesServerReasonForBusinessClient() {
|
||||
responseBody = "{\"code\":10005,\"message\":\"聊天账号已封禁,请联系客服\",\"data\":null}";
|
||||
cn.iocoder.yudao.module.member.client.vo.chat.user.ChatLoginReqVO request =
|
||||
new cn.iocoder.yudao.module.member.client.vo.chat.user.ChatLoginReqVO();
|
||||
request.setUserId(131L);
|
||||
request.setTerminal(1);
|
||||
cn.iocoder.yudao.framework.common.exception.ServiceException error = assertThrows(
|
||||
cn.iocoder.yudao.framework.common.exception.ServiceException.class, () -> chatApi.login(request));
|
||||
assertEquals(1004018005, error.getCode());
|
||||
assertEquals("聊天账号已封禁,请联系客服", error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulResponseIsAcceptedAndRequestUsesJson() {
|
||||
assertTrue(chatApi.createOrUpdate(request()));
|
||||
@@ -152,6 +166,18 @@ class ChatApiImplCreateOrUpdateTest {
|
||||
assertTrue(message.length() < 1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void versionedSynchronizationRequiresMatchingUserAndAppliedVersion() {
|
||||
ChatCreateOrUpdateUserReqVO request = request();
|
||||
request.setUserId(131L);
|
||||
request.setSyncVersion(3L);
|
||||
assertFalse(chatApi.syncUser(request).isSuccess());
|
||||
responseBody = "{\"code\":200,\"message\":\"成功\",\"data\":{\"userId\":131,\"appliedVersion\":3}}";
|
||||
assertEquals(Long.valueOf(3), chatApi.syncUser(request).getAppliedVersion());
|
||||
responseBody = "{\"code\":200,\"data\":{\"userId\":100,\"appliedVersion\":3}}";
|
||||
assertFalse(chatApi.syncUser(request).isSuccess());
|
||||
}
|
||||
|
||||
private void respond(HttpExchange exchange) throws IOException {
|
||||
try {
|
||||
requestAccept.set(exchange.getRequestHeaders().getFirst("Accept"));
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package cn.iocoder.yudao.module.member.controller.admin.user;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.yudao.module.member.controller.admin.user.vo.*;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberRecentTagMapper;
|
||||
import cn.iocoder.yudao.module.member.service.address.AddressService;
|
||||
import cn.iocoder.yudao.module.member.service.group.MemberGroupService;
|
||||
import cn.iocoder.yudao.module.member.service.level.MemberLevelService;
|
||||
import cn.iocoder.yudao.module.member.service.point.MemberPointRecordService;
|
||||
import cn.iocoder.yudao.module.member.service.tag.MemberTagService;
|
||||
import cn.iocoder.yudao.module.member.service.user.MemberRecentCfgService;
|
||||
import cn.iocoder.yudao.module.member.service.user.MemberUserExportService;
|
||||
import cn.iocoder.yudao.module.member.service.user.MemberUserService;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageUserApi;
|
||||
import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageUserRespDTO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class MemberUserBrokerageControllerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks private MemberUserController controller;
|
||||
@Mock private MemberUserService memberUserService;
|
||||
@Mock private MemberUserExportService memberUserExportService;
|
||||
@Mock private MemberTagService memberTagService;
|
||||
@Mock private MemberLevelService memberLevelService;
|
||||
@Mock private MemberGroupService memberGroupService;
|
||||
@Mock private MemberPointRecordService memberPointRecordService;
|
||||
@Mock private MemberRecentTagMapper memberRecentTagMapper;
|
||||
@Mock private MemberRecentCfgService memberRecentCfgService;
|
||||
@Mock private AddressService addressService;
|
||||
@Mock private TradeBrokerageUserApi tradeBrokerageUserApi;
|
||||
|
||||
@Test
|
||||
void pageLoadsRelationshipsAndInvitersInBatchesAndPreservesMissingMembers() {
|
||||
MemberUserDO first = user(1L, "会员一");
|
||||
MemberUserDO second = user(2L, "会员二");
|
||||
MemberUserDO third = user(3L, "会员三");
|
||||
MemberUserDO fourth = user(4L, "会员四");
|
||||
when(memberUserService.getUserPage(any())).thenReturn(new PageResult<>(Arrays.asList(first, second, third, fourth), 4L));
|
||||
when(tradeBrokerageUserApi.getBrokerageUserList(anyCollection())).thenReturn(Arrays.asList(
|
||||
relation(1L, 10L), relation(2L, 10L), relation(3L, 99L)));
|
||||
when(memberUserService.getUserList(anyCollection())).thenReturn(Collections.singletonList(user(10L, "邀请人")));
|
||||
|
||||
List<MemberUserRespVO> result = controller.getUserPage(new MemberUserPageReqVO()).getData().getList();
|
||||
|
||||
assertThat(result.get(0).getInviter().getId()).isEqualTo(10L);
|
||||
assertThat(result.get(0).getInviter().getUserCode()).isEqualTo("U10");
|
||||
assertThat(result.get(0).getInviter().getNickname()).isEqualTo("邀请人");
|
||||
assertThat(result.get(0).getInviter().getAvatar()).isEqualTo("/avatar/10");
|
||||
assertThat(result.get(0).getInviter().getBindUserTime()).isEqualTo(relation(1L, 10L).getBindUserTime());
|
||||
assertThat(result.get(1).getInviter().getMemberExists()).isTrue();
|
||||
assertThat(result.get(2).getInviter().getId()).isEqualTo(99L);
|
||||
assertThat(result.get(2).getInviter().getMemberExists()).isFalse();
|
||||
assertThat(result.get(3).getInviter()).isNull();
|
||||
verify(tradeBrokerageUserApi).getBrokerageUserList(new HashSet<>(Arrays.asList(1L, 2L, 3L, 4L)));
|
||||
verify(memberUserService).getUserList(new HashSet<>(Arrays.asList(10L, 99L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailIncludesInviterAndUnboundMembersDoNotLoadInviterProfiles() {
|
||||
when(memberUserService.getUser(1L)).thenReturn(user(1L, "会员"));
|
||||
when(tradeBrokerageUserApi.getBrokerageUserList(anyCollection())).thenReturn(Collections.singletonList(relation(1L, 10L)));
|
||||
when(memberUserService.getUserList(anyCollection())).thenReturn(Collections.singletonList(user(10L, "邀请人")));
|
||||
assertThat(controller.getUser(1L).getData().getInviter().getNickname()).isEqualTo("邀请人");
|
||||
|
||||
when(tradeBrokerageUserApi.getBrokerageUserList(anyCollection())).thenReturn(Collections.emptyList());
|
||||
assertThat(controller.getUser(1L).getData().getInviter()).isNull();
|
||||
verify(memberUserService, times(1)).getUserList(anyCollection());
|
||||
assertThat(controller.getUser(999L).getData()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyMemberPageDoesNotQueryRelationships() {
|
||||
when(memberUserService.getUserPage(any())).thenReturn(PageResult.empty());
|
||||
assertThat(controller.getUserPage(new MemberUserPageReqVO()).getData().getList()).isEmpty();
|
||||
verifyNoInteractions(tradeBrokerageUserApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void childPageEnrichesMembersAndRetainsRelationshipWhenProfileIsMissing() {
|
||||
BrokerageUserRespDTO first = relation(20L, 1L);
|
||||
first.setDirectChildCount(3L);
|
||||
BrokerageUserRespDTO missing = relation(19L, 1L);
|
||||
missing.setDirectChildCount(0L);
|
||||
when(tradeBrokerageUserApi.getBrokerageUserChildrenPage(eq(1L), any())).thenReturn(
|
||||
new PageResult<>(Arrays.asList(first, missing), 25L));
|
||||
MemberUserDO child = user(20L, "下级");
|
||||
child.setStatus(1);
|
||||
when(memberUserService.getUserList(anyCollection())).thenReturn(Collections.singletonList(child));
|
||||
MemberUserBrokerageChildrenPageReqVO query = new MemberUserBrokerageChildrenPageReqVO();
|
||||
query.setBindUserId(1L);
|
||||
|
||||
PageResult<MemberUserBrokerageChildRespVO> result = controller.getBrokerageChildrenPage(query).getData();
|
||||
assertThat(result.getTotal()).isEqualTo(25L);
|
||||
assertThat(result.getList().get(0).getDirectChildCount()).isEqualTo(3L);
|
||||
assertThat(result.getList().get(0).getStatus()).isEqualTo(1);
|
||||
assertThat(result.getList().get(0).getMemberExists()).isTrue();
|
||||
assertThat(result.getList().get(1).getId()).isEqualTo(19L);
|
||||
assertThat(result.getList().get(1).getMemberExists()).isFalse();
|
||||
verify(memberUserService).getUserList(new HashSet<>(Arrays.asList(20L, 19L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void httpValidatesParentAndPaginationAndUsesTwentyAsDefault() throws Exception {
|
||||
when(tradeBrokerageUserApi.getBrokerageUserChildrenPage(eq(1L), any())).thenReturn(new PageResult<>(Collections.emptyList(), 25L));
|
||||
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
mvc.perform(get("/member/user/brokerage-children-page").param("bindUserId", "1"))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.total").value(25))
|
||||
.andExpect(jsonPath("$.data.list").isEmpty());
|
||||
verify(tradeBrokerageUserApi).getBrokerageUserChildrenPage(eq(1L), argThat(page -> page.getPageSize() == 20 && page.getPageNo() == 1));
|
||||
for (String size : Arrays.asList("-1", "0", "101")) {
|
||||
mvc.perform(get("/member/user/brokerage-children-page").param("bindUserId", "1").param("pageSize", size))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
mvc.perform(get("/member/user/brokerage-children-page")).andExpect(status().isBadRequest());
|
||||
mvc.perform(get("/member/user/brokerage-children-page").param("bindUserId", "0")).andExpect(status().isBadRequest());
|
||||
mvc.perform(get("/member/user/brokerage-children-page").param("bindUserId", "1").param("pageNo", "0"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberQueryPermissionIsRequiredBeforeReadingRelationships() throws Exception {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(SecurityConfiguration.class);
|
||||
PermissionCheck permission = new PermissionCheck();
|
||||
context.getBeanFactory().registerSingleton("ss", permission);
|
||||
for (Field field : MemberUserController.class.getDeclaredFields()) {
|
||||
if (field.isAnnotationPresent(Resource.class)) {
|
||||
field.setAccessible(true);
|
||||
context.getBeanFactory().registerSingleton(field.getName(), field.get(controller));
|
||||
}
|
||||
}
|
||||
context.registerBean(MemberUserController.class, () -> controller);
|
||||
context.refresh();
|
||||
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("admin", "", "ROLE_ADMIN"));
|
||||
MemberUserController secured = context.getBean(MemberUserController.class);
|
||||
MemberUserBrokerageChildrenPageReqVO query = new MemberUserBrokerageChildrenPageReqVO();
|
||||
query.setBindUserId(1L);
|
||||
assertThatThrownBy(() -> secured.getBrokerageChildrenPage(query)).isInstanceOf(AccessDeniedException.class);
|
||||
verifyNoInteractions(tradeBrokerageUserApi);
|
||||
permission.allowed = true;
|
||||
when(tradeBrokerageUserApi.getBrokerageUserChildrenPage(eq(1L), any())).thenReturn(PageResult.empty());
|
||||
assertThat(secured.getBrokerageChildrenPage(query).getData().getList()).isEmpty();
|
||||
} finally {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||
static class SecurityConfiguration { }
|
||||
|
||||
public static class PermissionCheck {
|
||||
boolean allowed;
|
||||
public boolean hasPermission(String permission) {
|
||||
return allowed && "member:user:query".equals(permission);
|
||||
}
|
||||
}
|
||||
|
||||
private static MemberUserDO user(Long id, String nickname) {
|
||||
MemberUserDO result = new MemberUserDO();
|
||||
result.setId(id);
|
||||
result.setUserCode("U" + id);
|
||||
result.setNickname(nickname);
|
||||
result.setAvatar("/avatar/" + id);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static BrokerageUserRespDTO relation(Long id, Long parent) {
|
||||
BrokerageUserRespDTO result = new BrokerageUserRespDTO();
|
||||
result.setId(id);
|
||||
result.setBindUserId(parent);
|
||||
result.setBindUserTime(LocalDateTime.of(2026, 11, 9, 12, 0));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -120,7 +120,7 @@ class MemberAuthServiceImplSmsRegisterDbTest extends BaseDbUnitTest {
|
||||
smsCodeApi = mock(SmsCodeApi.class);
|
||||
when(userService.getUserByMobile(MOBILE)).thenReturn(user);
|
||||
when(userService.createUserIfAbsent(eq(MOBILE), anyString(), anyInt())).thenReturn(user);
|
||||
when(userService.syncChatUser(USER_ID)).thenReturn(true);
|
||||
when(userService.prepareChatUser(USER_ID)).thenReturn(true);
|
||||
when(oauth2TokenApi.createAccessToken(any(OAuth2AccessTokenCreateReqDTO.class)))
|
||||
.thenReturn(new OAuth2AccessTokenRespDTO().setUserId(USER_ID)
|
||||
.setAccessToken("access-token").setRefreshToken("refresh-token")
|
||||
@@ -180,9 +180,10 @@ class MemberAuthServiceImplSmsRegisterDbTest extends BaseDbUnitTest {
|
||||
Integer.class)).isEqualTo(1);
|
||||
assertThat(memberRecentCfgService.getOrCreate(USER_ID).getPrivacySecure()).isFalse();
|
||||
verify(smsCodeApi, times(2)).useSmsCode(any());
|
||||
verify(chatApi, times(2)).addMessageFriend(USER_ID);
|
||||
verify(chatApi, times(2)).addServiceFriend(USER_ID);
|
||||
verify(chatApi, times(2)).serviceUserSendUniqueText(eq(USER_ID), anyString());
|
||||
verify(userService, times(2)).prepareChatUser(USER_ID);
|
||||
// Default friends and welcome messages are now owned by the durable synchronization task.
|
||||
verify(chatApi, org.mockito.Mockito.never()).addMessageFriend(USER_ID);
|
||||
verify(chatApi, org.mockito.Mockito.never()).addServiceFriend(USER_ID);
|
||||
}
|
||||
|
||||
private void assertSuccessfulLogin(AppAuthLoginRespVO result) {
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package cn.iocoder.yudao.module.member.service.user;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.handler.DefaultDBFieldHandler;
|
||||
import cn.iocoder.yudao.module.member.client.service.ChatApi;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberChatSyncMapper;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import cn.iocoder.yudao.module.member.mq.producer.user.MemberUserProducer;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import com.baomidou.mybatisplus.core.config.GlobalConfig;
|
||||
import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.LongValue;
|
||||
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Opt-in tests create and drop only a unique schema on a disposable localhost MySQL. */
|
||||
class MemberChatSyncMySqlTest {
|
||||
private JdbcTemplate admin;
|
||||
private JdbcTemplate jdbc;
|
||||
private String database;
|
||||
private MemberChatSyncService sync;
|
||||
private MemberUserService userService;
|
||||
private TransactionTemplate transactions;
|
||||
private MemberChatSyncMapper tasks;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
String url = System.getenv("CHAT_SYNC_TEST_MYSQL_URL");
|
||||
assumeTrue(url != null && !url.isEmpty(), "Set CHAT_SYNC_TEST_MYSQL_URL for isolated MySQL tests");
|
||||
assertTrue(url.matches("jdbc:mysql://(localhost|127\\.0\\.0\\.1):[0-9]+/[a-zA-Z0-9_]+(?:\\?.*)?"));
|
||||
String password = System.getenv().getOrDefault("CHAT_SYNC_TEST_MYSQL_PASSWORD", "");
|
||||
admin = new JdbcTemplate(new UnpooledDataSource("com.mysql.cj.jdbc.Driver", url, "root", password));
|
||||
database = "codex_chat_sync_" + UUID.randomUUID().toString().replace("-", "");
|
||||
admin.execute("CREATE DATABASE " + database + " CHARACTER SET utf8mb4");
|
||||
int query = url.indexOf('?');
|
||||
String base = query < 0 ? url : url.substring(0, query);
|
||||
String databaseUrl = base.substring(0, base.lastIndexOf('/') + 1) + database + (query < 0 ? "" : url.substring(query));
|
||||
UnpooledDataSource dataSource = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", databaseUrl, "root", password);
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("CREATE TABLE member_user (id BIGINT AUTO_INCREMENT PRIMARY KEY, tenant_id BIGINT DEFAULT 1, "
|
||||
+ "nickname VARCHAR(30) DEFAULT '',avatar VARCHAR(255),mobile VARCHAR(11),password VARCHAR(100),"
|
||||
+ "status INT DEFAULT 0,deleted BIT DEFAULT 0,user_code VARCHAR(6) UNIQUE,register_ip VARCHAR(64),register_terminal INT,"
|
||||
+ "creator VARCHAR(64),updater VARCHAR(64),create_time DATETIME,update_time DATETIME)");
|
||||
String ddl = new String(Files.readAllBytes(Paths.get("../../sql/mysql/member/member_chat_sync.sql")), StandardCharsets.UTF_8);
|
||||
ddl = ddl.substring(0, ddl.indexOf("-- Create the handler"));
|
||||
for (String statement : ddl.split(";")) {
|
||||
if (!statement.trim().isEmpty()) jdbc.execute(statement);
|
||||
}
|
||||
DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource);
|
||||
transactions = new TransactionTemplate(manager);
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.setEnvironment(new Environment("chat-test", new SpringManagedTransactionFactory(), dataSource));
|
||||
GlobalConfigUtils.setGlobalConfig(configuration, new GlobalConfig().setDbConfig(new GlobalConfig.DbConfig()).setMetaObjectHandler(new DefaultDBFieldHandler()));
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||||
@Override
|
||||
public Expression getTenantId() {
|
||||
return new LongValue(1);
|
||||
}
|
||||
}));
|
||||
configuration.addInterceptor(interceptor);
|
||||
configuration.addMapper(MemberUserMapper.class);
|
||||
configuration.addMapper(MemberChatSyncMapper.class);
|
||||
SqlSessionTemplate sessions = new SqlSessionTemplate(new MybatisSqlSessionFactoryBuilder().build(configuration));
|
||||
java.util.Set<String> columns = new java.util.HashSet<>(jdbc.queryForList(
|
||||
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='member_user'", String.class));
|
||||
for (com.baomidou.mybatisplus.core.metadata.TableFieldInfo field : com.baomidou.mybatisplus.core.metadata.TableInfoHelper
|
||||
.getTableInfo(MemberUserDO.class).getFieldList()) {
|
||||
if (!columns.contains(field.getColumn())) {
|
||||
String type = "VARCHAR(1000)";
|
||||
if (field.getPropertyType() == java.time.LocalDateTime.class) {
|
||||
type = "DATETIME";
|
||||
} else if (Number.class.isAssignableFrom(field.getPropertyType())) {
|
||||
type = "DECIMAL(30,6)";
|
||||
}
|
||||
jdbc.execute("ALTER TABLE member_user ADD COLUMN " + field.getColumn() + " " + type);
|
||||
}
|
||||
}
|
||||
MemberUserMapper users = sessions.getMapper(MemberUserMapper.class);
|
||||
tasks = sessions.getMapper(MemberChatSyncMapper.class);
|
||||
RedissonClient redis = mock(RedissonClient.class);
|
||||
RLock lock = mock(RLock.class);
|
||||
when(redis.getLock(anyString())).thenReturn(lock);
|
||||
// Keep all outbound IM requests disabled in database tests.
|
||||
sync = new MemberChatSyncService(users, tasks, mock(ChatApi.class), redis, manager, Runnable::run);
|
||||
MemberUserServiceImpl target = new MemberUserServiceImpl();
|
||||
ReflectionTestUtils.setField(target, "memberUserMapper", users);
|
||||
ReflectionTestUtils.setField(target, "memberChatSyncService", sync);
|
||||
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
||||
when(encoder.encode(anyString())).thenReturn("encoded-password");
|
||||
ReflectionTestUtils.setField(target, "passwordEncoder", encoder);
|
||||
ReflectionTestUtils.setField(target, "memberUserProducer", mock(MemberUserProducer.class));
|
||||
ProxyFactory proxy = new ProxyFactory(target);
|
||||
proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource()));
|
||||
userService = (MemberUserService) proxy.getProxy();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (admin != null && database != null) admin.execute("DROP DATABASE " + database);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolledBackProfileRollsBackItsTaskAndDeletedRowsAreReconciled() {
|
||||
jdbc.update("INSERT INTO member_user (id,nickname,tenant_id) VALUES (131,'原昵称',1)");
|
||||
transactions.executeWithoutResult(status -> {
|
||||
jdbc.update("UPDATE member_user SET nickname='新昵称' WHERE id=131");
|
||||
sync.enqueue(131L);
|
||||
status.setRollbackOnly();
|
||||
});
|
||||
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM member_chat_sync", Integer.class));
|
||||
assertEquals("原昵称", jdbc.queryForObject("SELECT nickname FROM member_user WHERE id=131", String.class));
|
||||
sync.enqueue(131L);
|
||||
jdbc.update("UPDATE member_user SET deleted=1 WHERE id=131");
|
||||
sync.reconcileAndRetry();
|
||||
assertEquals(2, tasks.selectById(131L).getMemberState());
|
||||
assertEquals(2L, tasks.selectById(131L).getVersion());
|
||||
jdbc.update("DELETE FROM member_user WHERE id=131");
|
||||
sync.reconcileAndRetry();
|
||||
assertEquals(3, tasks.selectById(131L).getMemberState());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalUsernameAndThirtyCharacterNicknamePersistWithOptionalPhone() {
|
||||
jdbc.update("INSERT INTO member_user (id,nickname,tenant_id) VALUES (131,?,1)", String.join("", java.util.Collections.nCopies(30, "字")));
|
||||
sync.enqueue(131L);
|
||||
assertTrue(tasks.selectById(131L).getProfileJson().contains("u131"));
|
||||
sync.enqueue(131L);
|
||||
assertEquals(1L, tasks.selectById(131L).getVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistentWatermarkSurvivesLowerAutoincrementAndSerializesConcurrentRegistration() throws Exception {
|
||||
jdbc.update("UPDATE member_chat_sync_sequence SET last_id=900 WHERE id=1");
|
||||
jdbc.execute("ALTER TABLE member_user AUTO_INCREMENT=1");
|
||||
java.util.concurrent.ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<MemberUserDO> first = executor.submit(() -> userService.createUserIfAbsent("13900000131", "127.0.0.1", 1));
|
||||
Future<MemberUserDO> second = executor.submit(() -> userService.createUserIfAbsent("13900000131", "127.0.0.1", 1));
|
||||
assertEquals(901L, first.get().getId());
|
||||
assertEquals(901L, second.get().getId());
|
||||
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM member_user", Integer.class));
|
||||
assertEquals(901L, jdbc.queryForObject("SELECT last_id FROM member_chat_sync_sequence WHERE id=1", Long.class));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package cn.iocoder.yudao.module.member.service.user;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.iocoder.yudao.module.member.client.service.ChatApi;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatCreateOrUpdateUserReqVO;
|
||||
import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatUserSyncRespVO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberChatSyncDO;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberChatSyncMapper;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class MemberChatSyncServiceTest {
|
||||
private final MemberUserMapper users = mock(MemberUserMapper.class);
|
||||
private final MemberChatSyncMapper tasks = mock(MemberChatSyncMapper.class);
|
||||
private final ChatApi chat = mock(ChatApi.class);
|
||||
private final RedissonClient redis = mock(RedissonClient.class);
|
||||
private final RLock lock = mock(RLock.class);
|
||||
private final AtomicReference<MemberChatSyncDO> saved = new AtomicReference<>();
|
||||
private MemberChatSyncService service;
|
||||
private TransactionTemplate transactions;
|
||||
private MemberUserDO member;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "sync-test"), MemberChatSyncDO.class);
|
||||
JdbcDataSource dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:chat_sync_test;DB_CLOSE_DELAY=-1");
|
||||
DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource);
|
||||
transactions = new TransactionTemplate(manager);
|
||||
service = new MemberChatSyncService(users, tasks, chat, redis, manager, Runnable::run);
|
||||
member = new MemberUserDO().setId(131L).setNickname("老芋头").setStatus(0);
|
||||
member.setTenantId(1L);
|
||||
when(users.selectForChatSync(131L)).thenReturn(member);
|
||||
when(tasks.selectForUpdate(131L)).thenAnswer(call -> saved.get());
|
||||
when(tasks.selectById(131L)).thenAnswer(call -> saved.get());
|
||||
when(tasks.insert(any(MemberChatSyncDO.class))).thenAnswer(call -> {
|
||||
saved.set(call.getArgument(0));
|
||||
return 1;
|
||||
});
|
||||
when(redis.getLock(anyString())).thenReturn(lock);
|
||||
when(lock.tryLock(anyLong(), any(java.util.concurrent.TimeUnit.class))).thenReturn(true);
|
||||
when(lock.isHeldByCurrentThread()).thenReturn(true);
|
||||
when(chat.syncUser(any())).thenAnswer(call -> {
|
||||
ChatCreateOrUpdateUserReqVO request = call.getArgument(0);
|
||||
ChatUserSyncRespVO response = new ChatUserSyncRespVO();
|
||||
response.setCode(200);
|
||||
response.setAppliedVersion(request.getSyncVersion());
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void transactionCommitDispatchesCanonicalProfileAndRollbackDispatchesNothing() {
|
||||
transactions.executeWithoutResult(status -> {
|
||||
service.enqueue(131L);
|
||||
verifyNoInteractions(chat);
|
||||
});
|
||||
ArgumentCaptor<ChatCreateOrUpdateUserReqVO> request = ArgumentCaptor.forClass(ChatCreateOrUpdateUserReqVO.class);
|
||||
verify(chat).syncUser(request.capture());
|
||||
assertEquals("u131", request.getValue().getUserName());
|
||||
assertEquals("老芋头", request.getValue().getNickName());
|
||||
clearInvocations(chat);
|
||||
member.setNickname("更新昵称");
|
||||
transactions.executeWithoutResult(status -> {
|
||||
service.enqueue(131L);
|
||||
status.setRollbackOnly();
|
||||
});
|
||||
verifyNoInteractions(chat);
|
||||
}
|
||||
|
||||
@Test
|
||||
void identicalSnapshotDoesNotAdvanceVersion() {
|
||||
service.enqueue(131L);
|
||||
clearInvocations(tasks);
|
||||
service.enqueue(131L);
|
||||
assertEquals(1L, saved.get().getVersion());
|
||||
verify(tasks, never()).updateById(any(MemberChatSyncDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRestoreAndLogicalDeleteProduceMonotonicStateSnapshots() {
|
||||
service.enqueue(131L);
|
||||
member.setStatus(1);
|
||||
service.enqueue(131L);
|
||||
assertEquals(2L, saved.get().getVersion());
|
||||
assertEquals(1, saved.get().getMemberState());
|
||||
member.setStatus(0);
|
||||
service.enqueue(131L);
|
||||
assertEquals(3L, saved.get().getVersion());
|
||||
assertEquals(3L, JSONUtil.toBean(saved.get().getProfileJson(), ChatCreateOrUpdateUserReqVO.class).getAccessChangeVersion());
|
||||
member.setNickname("资料变更");
|
||||
service.enqueue(131L);
|
||||
assertEquals(3L, JSONUtil.toBean(saved.get().getProfileJson(), ChatCreateOrUpdateUserReqVO.class).getAccessChangeVersion());
|
||||
member.setDeleted(true);
|
||||
service.enqueue(131L);
|
||||
assertEquals(5L, saved.get().getVersion());
|
||||
assertEquals(2, saved.get().getMemberState());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingBusinessRecordArchivesExistingQueuedAccount() {
|
||||
service.enqueue(131L);
|
||||
when(users.selectForChatSync(131L)).thenReturn(null);
|
||||
service.enqueue(131L);
|
||||
assertEquals(3, saved.get().getMemberState());
|
||||
assertEquals(2L, saved.get().getVersion());
|
||||
assertEquals(3, JSONUtil.toBean(saved.get().getProfileJson(), ChatCreateOrUpdateUserReqVO.class).getMemberState());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void remoteFailureAndWelcomeFailureUseVersionGuardedUpdates() {
|
||||
doReturn(ChatUserSyncRespVO.failure(10006, "conflict")).when(chat).syncUser(any());
|
||||
service.enqueue(131L);
|
||||
ArgumentCaptor<Wrapper> updates = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(tasks).update(isNull(), updates.capture());
|
||||
String set = ((com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<?>) updates.getValue()).getSqlSet();
|
||||
assertTrue(set.contains("retry_count"));
|
||||
assertTrue(set.contains("next_retry_time"));
|
||||
assertTrue(updates.getValue().getCustomSqlSegment().contains("version"));
|
||||
}
|
||||
}
|
||||
+2
@@ -33,6 +33,8 @@ class MemberUserServiceImplRecentTagTest extends BaseMockitoUnitTest {
|
||||
private MemberRecentTagMapper memberRecentTagMapper;
|
||||
@Mock
|
||||
private MemberRecentTagService memberRecentTagService;
|
||||
@Mock
|
||||
private MemberChatSyncService memberChatSyncService;
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
|
||||
Reference in new issue
Block a user