diff --git a/docs/聊天用户同步修复_发布与数据处理.md b/docs/聊天用户同步修复_发布与数据处理.md new file mode 100644 index 0000000..014fdfe --- /dev/null +++ b/docs/聊天用户同步修复_发布与数据处理.md @@ -0,0 +1,309 @@ +# 聊天用户同步修复发布与数据处理 + +## 本次修复与发布范围 + +业务会员 ID 成为聊天身份,普通会员 IM 用户名固定为 `u`,昵称独立展示。同昵称会员按各自原 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_version0 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`,因此需要先确定重新迁移方案,再开放流量。 + +已经发生后续资料编辑、默认好友初始化、新消息或群变动时,自动回滚会终止并打印差异。使用本批次完整备份与差异记录制定定向恢复步骤。 + +### 恢复旧发布版本 + +旧 IM 发布包缺少本次账号状态与撤销版本校验。恢复旧版本需要在维护状态完成两库与服务的成套恢复,并单独处理已经撤销的凭证及已停用账号。数据库全量恢复可能覆盖维护窗口后产生的数据,须根据实际备份时间和恢复目标执行。 + +当前自动化回滚工具适用于本批次资料恢复,安全状态和编号水位由新版服务持续解释。表结构保留便于继续修复与审计。 + +## 当前验证范围 + +本地完成两版本 MySQL 集成测试、Redis 状态校验、真实签名 HTTP 与 WebSocket 联调、客户端逻辑测试及三个服务构建。生产数据库已只读预检查,正式迁移、线上进程发布、真实账号及手机端验收由现场按本文执行。 diff --git a/docs/聊天用户同步修复_核验报告.md b/docs/聊天用户同步修复_核验报告.md new file mode 100644 index 0000000..2216991 --- /dev/null +++ b/docs/聊天用户同步修复_核验报告.md @@ -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` 和完整备份,并补记维护时间、发布实例、真实账号验收结果与观察期错误数量,形成正式修复执行记录。 diff --git a/docs/聊天用户同步修复_简版上线步骤.md b/docs/聊天用户同步修复_简版上线步骤.md new file mode 100644 index 0000000..61b2b37 --- /dev/null +++ b/docs/聊天用户同步修复_简版上线步骤.md @@ -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`,同步昵称头像,停用已删除及孤立账号,并初始化同步任务和安全编号水位。 + +## 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 包含当前工作区已有的会员分销关系改动,发布时一并核对。详细异常处理和回滚步骤见同目录《聊天用户同步修复_发布与数据处理》。 diff --git a/script/repair-im-users/.gitignore b/script/repair-im-users/.gitignore new file mode 100644 index 0000000..f0f4135 --- /dev/null +++ b/script/repair-im-users/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +runs/ +config.local.json diff --git a/script/repair-im-users/README.md b/script/repair-im-users/README.md new file mode 100644 index 0000000..a9b02a2 --- /dev/null +++ b/script/repair-im-users/README.md @@ -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` | 0 | +| 禁用业务会员 | `u` | 1 | +| 逻辑删除会员 | `u` | 2 | +| IM 独有正数用户 | `u` | 3 | +| 业务会员缺少 IM 用户 | 创建同步任务,启动新版接口后补建 | 按业务当前状态 | +| 系统账号 -100、-200 | 保留专用初始化规则 | 保留并核验 | + +工具使用精确十进制字符串和 BigInt 处理 ID、同步版本和撤销版本。头像、昵称同步至用户、好友和群成员的资料副本。群备注沿用原值。 + +正式执行前检查唯一索引、字段容量、InnoDB 引擎、异常 ID、保留账号占用,并通过数据库实际排序规则检查用户名冲突。两库身份包含库名、服务器 UUID、服务器名称和版本。 + +## 提交和重跑 + +1. 对原始业务资料和 IM 快照重新核对。 +2. 在一个 IM 事务内,将所有待迁移用户改为批次临时用户名,再统一改为 `u`,更新资料副本和账号状态。 +3. 核对用户、关系和消息快照后提交 IM 事务。 +4. 在业务事务内写入同步任务和持久化编号水位。 +5. 单独校正会员表 AUTO_INCREMENT,保存完成断点。 + +编号水位覆盖业务用户、IM 用户,以及好友、群和历史消息中的用户编号。新版业务注册使用持久化水位分配 ID,可覆盖 MySQL 5.7 重启后重建自增值的情况。 + +进程中断或提交响应丢失后,保持停写,使用同一份清单重新运行 `apply`。工具识别已提交快照并补齐后续阶段。完成后的重复执行保持幂等。`imApplying`、`imCommitted`、`businessCommitted` 是执行过程中的正常断点名称。 + +## 核验边界 + +`verify` 用于服务启动前,要求原有消息数量与快照完全一致。`verify --ready` 允许补建账号、默认好友和欢迎消息,要求原有关系仍存在、消息总量未减少、任务已确认且正常会员已完成初始化。 + +核验期间继续暂停用户业务写入。若已经开放流量,会员改资料、新消息或群变动会影响快照核验,应依据新清单及业务记录检查。 + +`--ready` 同时要求系统账号存在且可用。该步骤在业务服务运行专用系统账号初始化后执行。 + +## 回滚规则 + +回滚前再次备份当前两库,暂停同步、写入和所有 IM 服务。 + +工具先检查迁移后字段、好友、群成员和消息是否发生后续变化。发现差异会打印字段路径及原值、现值并终止覆盖。回滚采用两阶段改名,恢复原用户名、昵称和头像副本。 + +会员停用状态、人工封禁、登录撤销版本和持久化编号水位继续保持当前有效约束。同步版本和登录撤销版本递增,业务任务重新排入补偿队列。数据回滚完成后应保持维护状态,再生成正式清单执行修复,或依据差异进行人工处理。新版同步任务随后执行时会再次采用 `u`。 + +服务启动产生的新好友、欢迎消息,以及恢复流量后的资料和消息变化,都可能触发回滚保护。此时依据完整数据库备份和差异记录制定定向恢复步骤。 + +回滚命令只负责本批次资料字段及同步任务,表结构与历史账号继续保留。恢复后启动新版 IM 接口重建 Redis 状态,再启动连接节点和业务服务,按当前账号状态重新领取凭证。 + +## 本地测试 + +```bash +# 算法和清单测试 +npm test + +# 指向本机隔离 MySQL 测试端口,每个测试创建并清理独立库 +CHAT_REPAIR_TEST_PORTS=33907,33909 npm test +``` + +MySQL 测试夹具使用本地专用凭据,限制为显式指定的 `127.0.0.1` 端口。HTTP、WebSocket 测试依赖完整源码树和独立本地服务,运行方式见发布核验报告。 diff --git a/script/repair-im-users/config.example.json b/script/repair-im-users/config.example.json new file mode 100644 index 0000000..19a45a8 --- /dev/null +++ b/script/repair-im-users/config.example.json @@ -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" + } +} diff --git a/script/repair-im-users/config.production.example.json b/script/repair-im-users/config.production.example.json new file mode 100644 index 0000000..3cc9840 --- /dev/null +++ b/script/repair-im-users/config.production.example.json @@ -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" + } +} diff --git a/script/repair-im-users/core.mjs b/script/repair-im-users/core.mjs new file mode 100644 index 0000000..94c14a4 --- /dev/null +++ b/script/repair-im-users/core.mjs @@ -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, + }; +} diff --git a/script/repair-im-users/package-lock.json b/script/repair-im-users/package-lock.json new file mode 100644 index 0000000..550949e --- /dev/null +++ b/script/repair-im-users/package-lock.json @@ -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" + } + } + } +} diff --git a/script/repair-im-users/package.json b/script/repair-im-users/package.json new file mode 100644 index 0000000..ae95909 --- /dev/null +++ b/script/repair-im-users/package.json @@ -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" } +} diff --git a/script/repair-im-users/repair-im-users.mjs b/script/repair-im-users/repair-im-users.mjs new file mode 100644 index 0000000..70b7d06 --- /dev/null +++ b/script/repair-im-users/repair-im-users.mjs @@ -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; + }); +} diff --git a/script/repair-im-users/test/core.test.mjs b/script/repair-im-users/test/core.test.mjs new file mode 100644 index 0000000..1252ab1 --- /dev/null +++ b/script/repair-im-users/test/core.test.mjs @@ -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), /清单不一致/); +}); diff --git a/script/repair-im-users/test/mysql.test.mjs b/script/repair-im-users/test/mysql.test.mjs new file mode 100644 index 0000000..5ee66ab --- /dev/null +++ b/script/repair-im-users/test/mysql.test.mjs @@ -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(); + } + }); +} diff --git a/script/repair-im-users/test/runtime.test.mjs b/script/repair-im-users/test/runtime.test.mjs new file mode 100644 index 0000000..b2b9157 --- /dev/null +++ b/script/repair-im-users/test/runtime.test.mjs @@ -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(); + } +}); diff --git a/sql/mysql/member/member_chat_sync.sql b/sql/mysql/member/member_chat_sync.sql new file mode 100644 index 0000000..c10bd3e --- /dev/null +++ b/sql/mysql/member/member_chat_sync.sql @@ -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'); diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageUserApi.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageUserApi.java index 088fc9b..58d05d4 100644 --- a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageUserApi.java +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageUserApi.java @@ -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 getBrokerageUserList(@NotNull Collection userIds); + + /** 分页查询直属下级,并批量统计本页下级各自的直属人数。 */ + PageResult getBrokerageUserChildrenPage(@NotNull Long bindUserId, + @NotNull @Valid PageParam pageParam); + /** * 绑定分销员 */ diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/dto/BrokerageUserRespDTO.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/dto/BrokerageUserRespDTO.java new file mode 100644 index 0000000..3b46b4a --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/dto/BrokerageUserRespDTO.java @@ -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; + +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageUserApiImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageUserApiImpl.java index be1be19..c8485fb 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageUserApiImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageUserApiImpl.java @@ -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 getBrokerageUserList(Collection userIds) { + return brokerageUserService.getBrokerageUserList(userIds); + } + + @Override + public PageResult 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); diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageUserMapper.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageUserMapper.java index 6c24cac..6d6951f 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageUserMapper.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/dal/mysql/brokerage/BrokerageUserMapper.java @@ -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 { + default PageResult selectChildrenPage(Long bindUserId, PageParam pageParam) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .eq(BrokerageUserDO::getBindUserId, bindUserId) + .orderByDesc(BrokerageUserDO::getId)); + } + + default Map selectDirectChildCountMap(Collection userIds) { + if (userIds.isEmpty()) { + return Collections.emptyMap(); + } + List> counts = selectMaps(new QueryWrapper() + .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 selectPage(BrokerageUserPageReqVO reqVO, List ids) { return selectPage(reqVO, new LambdaQueryWrapperX() .inIfPresent(BrokerageUserDO::getId, ids) diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java index 3518c6f..f642023 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java @@ -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 getBrokerageUserList(Collection userIds); + + PageResult getBrokerageUserChildrenPage(Long bindUserId, PageParam pageParam); + /** * 获得分销用户 * diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java index a55ae55..d494061 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java @@ -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 getBrokerageUserList(Collection userIds) { + if (CollUtil.isEmpty(userIds)) { + return Collections.emptyList(); + } + return BeanUtils.toBean(brokerageUserMapper.selectBatchIds(userIds), BrokerageUserRespDTO.class); + } + + @Override + public PageResult getBrokerageUserChildrenPage(Long bindUserId, PageParam pageParam) { + PageResult page = BeanUtils.toBean( + brokerageUserMapper.selectChildrenPage(bindUserId, pageParam), BrokerageUserRespDTO.class); + if (CollUtil.isEmpty(page.getList())) { + return page; + } + Map 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); diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserRelationshipTest.java b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserRelationshipTest.java new file mode 100644 index 0000000..430acfe --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserRelationshipTest.java @@ -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 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 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(); + } +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_relationship_schema.sql b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_relationship_schema.sql new file mode 100644 index 0000000..764cded --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/test/resources/sql/brokerage_relationship_schema.sql @@ -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 +); diff --git a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java index 91f2126..2708a28 100644 --- a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java +++ b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java @@ -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, "航空账号不存在"); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java index f5537b5..1614059 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java @@ -29,6 +29,8 @@ public interface ChatApi { */ boolean createOrUpdate(@Valid ChatCreateOrUpdateUserReqVO dto); + ChatUserSyncRespVO syncUser(@Valid ChatCreateOrUpdateUserReqVO dto); + /** * 封禁聊天用户并推送下线 */ diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java index 7b74f16..1f800ca 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java @@ -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 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为空"); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatCreateOrUpdateUserReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatCreateOrUpdateUserReqVO.java index 459b4be..aa07f3c 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatCreateOrUpdateUserReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatCreateOrUpdateUserReqVO.java @@ -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; + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatGetUserByUserIdRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatGetUserByUserIdRespVO.java index 2cbaf52..27c6a6f 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatGetUserByUserIdRespVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatGetUserByUserIdRespVO.java @@ -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; diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatUserSyncRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatUserSyncRespVO.java new file mode 100644 index 0000000..11f1b8d --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatUserSyncRespVO.java @@ -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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java index 24d58e4..4a9e0d8 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java @@ -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 memberRecentTags = memberRecentTagMapper.getByMemberId(result.getId()); if (ObjectUtil.isNotEmpty(memberRecentTags)) { List tags = memberRecentTags.stream().map(v -> { @@ -168,7 +177,65 @@ public class MemberUserController { List memberCfgList = memberRecentCfgService.getByMemberIds(convertSet(pageResult.getList(), MemberUserDO::getId)); // 按本页用户批量查询收货地址,避免逐个用户查询。 List addresses = addressService.getAddressListByUserIds(convertSet(pageResult.getList(), MemberUserDO::getId)); - return success(MemberUserConvert.INSTANCE.convertPage(pageResult, tags, memberRecentTags, levels, groups, memberCfgList, addresses)); + PageResult 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> getBrokerageChildrenPage( + @Valid MemberUserBrokerageChildrenPageReqVO reqVO) { + PageResult page = tradeBrokerageUserApi.getBrokerageUserChildrenPage( + reqVO.getBindUserId(), reqVO); + if (CollUtil.isEmpty(page.getList())) { + return success(new PageResult<>(Collections.emptyList(), page.getTotal())); + } + Map users = convertMap(memberUserService.getUserList( + convertSet(page.getList(), BrokerageUserRespDTO::getId)), MemberUserDO::getId); + PageResult 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 users) { + List relations = tradeBrokerageUserApi.getBrokerageUserList( + convertSet(users, MemberUserRespVO::getId)); + Set inviterIds = relations.stream().map(BrokerageUserRespDTO::getBindUserId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (inviterIds.isEmpty()) { + return; + } + Map inviters = convertMap(memberUserService.getUserList(inviterIds), MemberUserDO::getId); + Map 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") diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBaseVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBaseVO.java index 075f3ad..8809d48 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBaseVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBaseVO.java @@ -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") diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildRespVO.java new file mode 100644 index 0000000..6d77f5e --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildRespVO.java @@ -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; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildrenPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildrenPageReqVO.java new file mode 100644 index 0000000..8f36771 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBrokerageChildrenPageReqVO.java @@ -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); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserInviterRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserInviterRespVO.java new file mode 100644 index 0000000..505340a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserInviterRespVO.java @@ -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; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java index c3b4d09..75d8a0a 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserRespVO.java @@ -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; diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/vo/AppMemberUserUpdateReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/vo/AppMemberUserUpdateReqVO.java index a9c99bb..e93f079 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/vo/AppMemberUserUpdateReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/vo/AppMemberUserUpdateReqVO.java @@ -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 = "出生日期") diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/user/MemberChatSyncDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/user/MemberChatSyncDO.java new file mode 100644 index 0000000..09246d1 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/user/MemberChatSyncDO.java @@ -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; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberChatSyncMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberChatSyncMapper.java new file mode 100644 index 0000000..ffdddfe --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberChatSyncMapper.java @@ -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 { + @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 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 selectOrphans(); +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java index 48e1db7..8c7d9b4 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java @@ -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 { + @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 selectChatReconcileBatch(@Param("cursor") Long cursor); + default MemberUserDO selectByMobile(String mobile) { return selectOne(MemberUserDO::getMobile, mobile); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/job/member/MemberChatSyncJob.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/job/member/MemberChatSyncJob.java new file mode 100644 index 0000000..d5bafe2 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/job/member/MemberChatSyncJob.java @@ -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() + " 个"; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java index c06220f..6a4f1aa 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java @@ -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; } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberChatSyncService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberChatSyncService.java new file mode 100644 index 0000000..02e70f9 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberChatSyncService.java @@ -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 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 taskVersion(MemberChatSyncDO task) { + return new LambdaUpdateWrapper() + .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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java index da86c9c..676be61 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java @@ -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); + /** * 获取用户的微信信息 */ diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java index 2a82233..c6c33b2 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java @@ -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); } /** diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplCreateOrUpdateTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplCreateOrUpdateTest.java index 8418aa7..28300da 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplCreateOrUpdateTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/ChatApiImplCreateOrUpdateTest.java @@ -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")); diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserBrokerageControllerTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserBrokerageControllerTest.java new file mode 100644 index 0000000..07f0618 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserBrokerageControllerTest.java @@ -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 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 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; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java index 1ff9a2e..86119d5 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImplSmsRegisterDbTest.java @@ -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) { diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberChatSyncMySqlTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberChatSyncMySqlTest.java new file mode 100644 index 0000000..74e8a76 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberChatSyncMySqlTest.java @@ -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 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 first = executor.submit(() -> userService.createUserIfAbsent("13900000131", "127.0.0.1", 1)); + Future 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(); + } + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberChatSyncServiceTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberChatSyncServiceTest.java new file mode 100644 index 0000000..5f96ba6 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberChatSyncServiceTest.java @@ -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 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 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 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")); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImplRecentTagTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImplRecentTagTest.java index 60a4546..f95ad71 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImplRecentTagTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImplRecentTagTest.java @@ -33,6 +33,8 @@ class MemberUserServiceImplRecentTagTest extends BaseMockitoUnitTest { private MemberRecentTagMapper memberRecentTagMapper; @Mock private MemberRecentTagService memberRecentTagService; + @Mock + private MemberChatSyncService memberChatSyncService; @Test @SuppressWarnings({"unchecked", "rawtypes"})