diff --git a/src/main/java/com/rj/controller/QweiDepartmentController.java b/src/main/java/com/rj/controller/QweiDepartmentController.java index 6dfb613..64ed8d3 100644 --- a/src/main/java/com/rj/controller/QweiDepartmentController.java +++ b/src/main/java/com/rj/controller/QweiDepartmentController.java @@ -70,5 +70,16 @@ public class QweiDepartmentController { } return ResponseEntity.internalServerError().body(result); } + + @PostMapping("/sync") + @Operation(summary = "同步企微部门并入库") + public ResponseEntity> sync() { + Map result = qweiDepartmentService.syncFromQiWei(); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } } diff --git a/src/main/java/com/rj/controller/QweiUserController.java b/src/main/java/com/rj/controller/QweiUserController.java index 5da1a5c..268117f 100644 --- a/src/main/java/com/rj/controller/QweiUserController.java +++ b/src/main/java/com/rj/controller/QweiUserController.java @@ -71,5 +71,16 @@ public class QweiUserController { } return ResponseEntity.internalServerError().body(result); } + + @PostMapping("/sync") + @Operation(summary = "同步企微用户并入库") + public ResponseEntity> sync() { + Map result = qweiUserService.syncFromQiWei(); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } } diff --git a/src/main/java/com/rj/entity/QweiDepartment.java b/src/main/java/com/rj/entity/QweiDepartment.java index fe693a2..b501c85 100644 --- a/src/main/java/com/rj/entity/QweiDepartment.java +++ b/src/main/java/com/rj/entity/QweiDepartment.java @@ -22,6 +22,10 @@ public class QweiDepartment implements Serializable { @Schema(description = "主键UUID") private String id; + @TableField("tenant_id") + @Schema(description = "租户ID(UUID)") + private String tenantId; + @TableField("dept_id") @Schema(description = "企微部门ID") private Long deptId; diff --git a/src/main/java/com/rj/entity/QweiUser.java b/src/main/java/com/rj/entity/QweiUser.java index 0f76859..42ed974 100644 --- a/src/main/java/com/rj/entity/QweiUser.java +++ b/src/main/java/com/rj/entity/QweiUser.java @@ -22,6 +22,10 @@ public class QweiUser implements Serializable { @Schema(description = "主键UUID") private String id; + @TableField("tenant_id") + @Schema(description = "租户ID(UUID)") + private String tenantId; + @TableField("userid") @Schema(description = "企微用户ID") private String userid; diff --git a/src/main/java/com/rj/mapper/QweiDepartmentMapper.java b/src/main/java/com/rj/mapper/QweiDepartmentMapper.java index 1a1420f..db21087 100644 --- a/src/main/java/com/rj/mapper/QweiDepartmentMapper.java +++ b/src/main/java/com/rj/mapper/QweiDepartmentMapper.java @@ -1,10 +1,42 @@ package com.rj.mapper; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.rj.entity.QweiDepartment; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; @Mapper public interface QweiDepartmentMapper extends BaseMapper { + + @InterceptorIgnore(tenantLine = "1") + @Select(""" + SELECT id, tenant_id, dept_id, dept_name, parent_dept_id, order_num, is_deleted, created_at, updated_at + FROM qwei_department + WHERE dept_id = #{deptId} + LIMIT 1 + """) + QweiDepartment selectByDeptIdIgnoreTenant(@Param("deptId") Long deptId); + + @InterceptorIgnore(tenantLine = "1") + @Update(""" + UPDATE qwei_department + SET tenant_id = #{tenantId}, + dept_name = #{deptName}, + parent_dept_id = #{parentDeptId}, + order_num = #{orderNum}, + is_deleted = #{isDeleted}, + updated_at = #{updatedAt} + WHERE id = #{id} + """) + int updateLegacyTenantAndFieldsByIdIgnoreTenant(@Param("id") String id, + @Param("tenantId") String tenantId, + @Param("deptName") String deptName, + @Param("parentDeptId") Long parentDeptId, + @Param("orderNum") Integer orderNum, + @Param("isDeleted") Integer isDeleted, + @Param("updatedAt") java.time.LocalDateTime updatedAt); } diff --git a/src/main/java/com/rj/mapper/QweiUserMapper.java b/src/main/java/com/rj/mapper/QweiUserMapper.java index 66f37a4..aa7da31 100644 --- a/src/main/java/com/rj/mapper/QweiUserMapper.java +++ b/src/main/java/com/rj/mapper/QweiUserMapper.java @@ -1,10 +1,66 @@ package com.rj.mapper; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.rj.entity.QweiUser; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; @Mapper public interface QweiUserMapper extends BaseMapper { + + @InterceptorIgnore(tenantLine = "1") + @Select(""" + SELECT id, tenant_id, userid, user_name, mobile, email, biz_email, telephone, position, gender, status, + main_department_id, department_ids_json, alias_name, avatar, thumb_avatar, address, + is_deleted, created_at, updated_at + FROM qwei_user + WHERE userid = #{userid} + LIMIT 1 + """) + QweiUser selectByUseridIgnoreTenant(@Param("userid") String userid); + + @InterceptorIgnore(tenantLine = "1") + @Update(""" + UPDATE qwei_user + SET tenant_id = #{tenantId}, + user_name = #{userName}, + mobile = #{mobile}, + email = #{email}, + biz_email = #{bizEmail}, + telephone = #{telephone}, + position = #{position}, + gender = #{gender}, + status = #{status}, + main_department_id = #{mainDepartmentId}, + department_ids_json = #{departmentIdsJson}, + alias_name = #{aliasName}, + avatar = #{avatar}, + thumb_avatar = #{thumbAvatar}, + address = #{address}, + is_deleted = #{isDeleted}, + updated_at = #{updatedAt} + WHERE id = #{id} + """) + int updateLegacyTenantAndFieldsByIdIgnoreTenant(@Param("id") String id, + @Param("tenantId") String tenantId, + @Param("userName") String userName, + @Param("mobile") String mobile, + @Param("email") String email, + @Param("bizEmail") String bizEmail, + @Param("telephone") String telephone, + @Param("position") String position, + @Param("gender") Integer gender, + @Param("status") Integer status, + @Param("mainDepartmentId") Long mainDepartmentId, + @Param("departmentIdsJson") String departmentIdsJson, + @Param("aliasName") String aliasName, + @Param("avatar") String avatar, + @Param("thumbAvatar") String thumbAvatar, + @Param("address") String address, + @Param("isDeleted") Integer isDeleted, + @Param("updatedAt") java.time.LocalDateTime updatedAt); } diff --git a/src/main/java/com/rj/service/IQweiDepartmentService.java b/src/main/java/com/rj/service/IQweiDepartmentService.java index d8c08c4..d268ce1 100644 --- a/src/main/java/com/rj/service/IQweiDepartmentService.java +++ b/src/main/java/com/rj/service/IQweiDepartmentService.java @@ -18,5 +18,7 @@ public interface IQweiDepartmentService extends IService { Long deptId, String deptName, Long parentDeptId); + + Map syncFromQiWei(); } diff --git a/src/main/java/com/rj/service/IQweiUserService.java b/src/main/java/com/rj/service/IQweiUserService.java index deabfdc..cbda6da 100644 --- a/src/main/java/com/rj/service/IQweiUserService.java +++ b/src/main/java/com/rj/service/IQweiUserService.java @@ -19,5 +19,7 @@ public interface IQweiUserService extends IService { String userName, Long mainDepartmentId, Integer status); + + Map syncFromQiWei(); } diff --git a/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java b/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java index 0abf94d..f4c40a9 100644 --- a/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java +++ b/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java @@ -3,13 +3,31 @@ package com.rj.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rj.common.DictItemConstants; +import com.rj.common.PasswordUtil; +import com.rj.dto.QiWeiConfig; +import com.rj.entity.DictItem; import com.rj.entity.QweiDepartment; +import com.rj.mapper.DictItemMapper; import com.rj.mapper.QweiDepartmentMapper; import com.rj.service.IQweiDepartmentService; +import com.rj.tenant.TenantContextHolder; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -17,10 +35,17 @@ import java.util.UUID; public class QweiDepartmentServiceImpl extends ServiceImpl implements IQweiDepartmentService { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + + @Autowired + private DictItemMapper dictItemMapper; + @Override public Map add(QweiDepartment entity) { Map result = new HashMap<>(); try { + String tenantId = resolveTenantId(entity.getTenantId()); if (entity.getDeptId() == null) { result.put("success", false); result.put("message", "deptId不能为空"); @@ -40,6 +65,7 @@ public class QweiDepartmentServiceImpl extends ServiceImpl syncFromQiWei() { + Map result = new HashMap<>(); + try { + QiWeiConfig cfg = loadQiWeiConfig(); + String tenantId = resolveTenantId(null); + // 确保后续对 qwei_department 的查询/写入都带 tenant_id 条件 + TenantContextHolder.setTenantId(tenantId); + String corpId = requireNonBlank(cfg.getCorpid(), "qiwei_config.corpid不能为空"); + String contactSecret = requireNonBlank(cfg.getContractSecret(), "qiwei_config.contractSecret不能为空"); + String appSecret = trimToNull(cfg.getAppSecret()); + + List remoteDepartments; + try { + String token = getAccessToken(corpId, contactSecret); + remoteDepartments = fetchDepartments(token); + } catch (Exception e) { + String msg = e.getMessage(); + boolean contactForbidden = msg != null && (msg.contains("e=48009") + || msg.contains("api forbidden for contact assistant")); + if (!contactForbidden || appSecret == null) { + throw e; + } + String appToken = getAccessToken(corpId, appSecret); + remoteDepartments = fetchDepartments(appToken); + } + + int inserted = 0; + int updated = 0; + for (QweiDepartment dept : remoteDepartments) { + dept.setTenantId(tenantId); + LambdaQueryWrapper q = new LambdaQueryWrapper() + .eq(QweiDepartment::getDeptId, dept.getDeptId()); + QweiDepartment exists = this.getOne(q, false); + LocalDateTime now = LocalDateTime.now(); + if (exists == null) { + dept.setId(UUID.randomUUID().toString()); + dept.setIsDeleted(0); + dept.setCreatedAt(now); + dept.setUpdatedAt(now); + try { + this.save(dept); + inserted++; + } catch (Exception insertEx) { + // 若数据库唯一索引仍是 dept_id 单列,且历史数据 tenant_id 为空,会导致不同租户插入时撞唯一键。 + // 这里做一次“修复脏数据”:找到 dept_id 对应的旧记录,如果 tenant_id 为空则补齐为当前租户并更新。 + if (!isDuplicateDeptId(insertEx)) { + throw insertEx; + } + QweiDepartment legacy = findByDeptIdIgnoreTenant(dept.getDeptId()); + if (legacy == null) { + throw insertEx; + } + String legacyTenant = trimToNull(legacy.getTenantId()); + if (legacyTenant == null) { + this.baseMapper.updateLegacyTenantAndFieldsByIdIgnoreTenant( + legacy.getId(), + tenantId, + dept.getDeptName(), + dept.getParentDeptId(), + dept.getOrderNum(), + 0, + now + ); + updated++; + } else if (!tenantId.equals(legacyTenant)) { + throw new IllegalStateException("部门dept_id=" + dept.getDeptId() + + " 已存在且属于其他租户(" + legacyTenant + ")。请把唯一索引改为(tenant_id, dept_id)。"); + } else { + // 同租户但 exists 查不到(理论上不该发生),走更新 + exists = legacy; + exists.setDeptName(dept.getDeptName()); + exists.setParentDeptId(dept.getParentDeptId()); + exists.setOrderNum(dept.getOrderNum()); + exists.setIsDeleted(0); + exists.setUpdatedAt(now); + this.updateById(exists); + updated++; + } + } + } else { + exists.setDeptName(dept.getDeptName()); + exists.setParentDeptId(dept.getParentDeptId()); + exists.setOrderNum(dept.getOrderNum()); + exists.setIsDeleted(0); + exists.setUpdatedAt(now); + this.updateById(exists); + updated++; + } + } + + result.put("success", true); + result.put("message", "同步成功"); + result.put("inserted", inserted); + result.put("updated", updated); + result.put("total", remoteDepartments.size()); + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "同步异常:" + e.getMessage()); + return result; + } finally { + TenantContextHolder.clear(); + } + } + + private QiWeiConfig loadQiWeiConfig() throws Exception { + // 优先使用 JVM 参数,便于联调/脚本执行: + // -Dqiwei.corpid=xxx -Dqiwei.contactSecret=xxx + String corpid = trimToNull(System.getProperty("qiwei.corpid")); + String contactSecret = trimToNull(System.getProperty("qiwei.contactSecret")); + if (corpid != null && contactSecret != null) { + QiWeiConfig cfg = new QiWeiConfig(); + cfg.setCorpid(corpid); + cfg.setContractSecret(contactSecret); + cfg.setAppSecret(trimToNull(System.getProperty("qiwei.appSecret"))); + return cfg; + } + + String tenantId = System.getProperty("tenantId", "TENANT_ID_CST_2026"); + TenantContextHolder.setTenantId(tenantId); + try { + LambdaQueryWrapper q = new LambdaQueryWrapper() + .eq(DictItem::getName, DictItemConstants.QIWEI_CONFIG) + .orderByDesc(DictItem::getUpdatedTime) + .orderByDesc(DictItem::getCreatedTime) + .last("limit 1"); + DictItem item = dictItemMapper.selectOne(q); + if (item == null || item.getValue() == null || item.getValue().trim().isEmpty()) { + throw new IllegalStateException("未找到 qiwei_config"); + } + String json = PasswordUtil.decryptReversibleWithDefaultSalt(item.getValue()); + return OBJECT_MAPPER.readValue(json, QiWeiConfig.class); + } finally { + TenantContextHolder.clear(); + } + } + + private String getAccessToken(String corpId, String secret) throws Exception { + String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + + URLEncoder.encode(corpId, StandardCharsets.UTF_8) + + "&corpsecret=" + URLEncoder.encode(secret, StandardCharsets.UTF_8); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + String body = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); + JsonNode json = OBJECT_MAPPER.readTree(body); + int errCode = json.path("errcode").asInt(-1); + if (errCode != 0) { + throw new IllegalStateException("获取access_token失败: " + body); + } + return json.path("access_token").asText(); + } + + private List fetchDepartments(String token) throws Exception { + String url = "https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=" + + URLEncoder.encode(token, StandardCharsets.UTF_8); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + String body = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); + JsonNode json = OBJECT_MAPPER.readTree(body); + int errCode = json.path("errcode").asInt(-1); + if (errCode != 0) { + throw new IllegalStateException("获取部门列表失败: " + body); + } + + List list = new ArrayList<>(); + JsonNode departmentNode = json.path("department"); + if (departmentNode.isArray()) { + for (JsonNode n : departmentNode) { + QweiDepartment dept = new QweiDepartment(); + dept.setDeptId(n.path("id").asLong()); + dept.setDeptName(n.path("name").asText(null)); + dept.setParentDeptId(n.path("parentid").asLong()); + dept.setOrderNum(n.path("order").asInt(0)); + list.add(dept); + } + } + return list; + } + + private String requireNonBlank(String value, String msg) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalStateException(msg); + } + return value.trim(); + } + + private boolean isDuplicateDeptId(Exception e) { + String msg = e.getMessage(); + return msg != null && (msg.contains("Duplicate entry") && msg.contains("uk_qwei_department_dept_id")); + } + + private QweiDepartment findByDeptIdIgnoreTenant(Long deptId) { + return this.baseMapper.selectByDeptIdIgnoreTenant(deptId); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String t = value.trim(); + return t.isEmpty() ? null : t; + } + + private String resolveTenantId(String tenantIdFromEntity) { + String current = trimToNull(TenantContextHolder.getTenantId()); + if (current != null) { + return current; + } + String t = trimToNull(tenantIdFromEntity); + if (t != null) { + return t; + } + String fromProp = trimToNull(System.getProperty("tenantId")); + if (fromProp != null) { + return fromProp; + } + throw new IllegalStateException("tenantId不能为空(请设置租户上下文或传入 tenantId 或 -DtenantId)"); + } } diff --git a/src/main/java/com/rj/service/impl/QweiUserServiceImpl.java b/src/main/java/com/rj/service/impl/QweiUserServiceImpl.java index fc3a5d2..6f51ad2 100644 --- a/src/main/java/com/rj/service/impl/QweiUserServiceImpl.java +++ b/src/main/java/com/rj/service/impl/QweiUserServiceImpl.java @@ -3,11 +3,27 @@ package com.rj.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rj.common.DictItemConstants; +import com.rj.common.PasswordUtil; +import com.rj.dto.QiWeiConfig; +import com.rj.entity.DictItem; import com.rj.entity.QweiUser; +import com.rj.mapper.DictItemMapper; import com.rj.mapper.QweiUserMapper; import com.rj.service.IQweiUserService; +import com.rj.tenant.TenantContextHolder; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; @@ -16,10 +32,17 @@ import java.util.UUID; @Service public class QweiUserServiceImpl extends ServiceImpl implements IQweiUserService { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + + @Autowired + private DictItemMapper dictItemMapper; + @Override public Map add(QweiUser entity) { Map result = new HashMap<>(); try { + String tenantId = resolveTenantId(entity.getTenantId()); if (entity.getUserid() == null || entity.getUserid().trim().isEmpty()) { result.put("success", false); result.put("message", "userid不能为空"); @@ -34,6 +57,7 @@ public class QweiUserServiceImpl extends ServiceImpl i } entity.setId(UUID.randomUUID().toString()); + entity.setTenantId(tenantId); entity.setUserid(entity.getUserid().trim()); if (entity.getIsDeleted() == null) { entity.setIsDeleted(0); @@ -64,6 +88,8 @@ public class QweiUserServiceImpl extends ServiceImpl i result.put("message", "id不能为空"); return result; } + // 更新时不允许把 tenantId 改掉;若传了就忽略 + entity.setTenantId(null); if (entity.getUserid() != null) { entity.setUserid(entity.getUserid().trim()); } @@ -144,5 +170,290 @@ public class QweiUserServiceImpl extends ServiceImpl i return result; } } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map syncFromQiWei() { + Map result = new HashMap<>(); + String tenantId = null; + try { + tenantId = resolveTenantId(null); + TenantContextHolder.setTenantId(tenantId); + + QiWeiConfig cfg = loadQiWeiConfig(); + String corpId = requireNonBlank(cfg.getCorpid(), "qiwei_config.corpid不能为空"); + String contactSecret = requireNonBlank(cfg.getContractSecret(), "qiwei_config.contractSecret不能为空"); + String appSecret = trimToNull(cfg.getAppSecret()); + if (appSecret == null) { + throw new IllegalStateException("qiwei_config.appSecret不能为空(user/get 需要 appSecret)"); + } + + String contactToken = getAccessToken(corpId, contactSecret); + String appToken = getAccessToken(corpId, appSecret); + + int inserted = 0; + int updated = 0; + + String cursor = ""; + int limit = 100; + while (true) { + JsonNode listId = fetchUserListId(contactToken, cursor, limit); + JsonNode deptUser = listId.path("dept_user"); + if (deptUser.isArray()) { + for (JsonNode du : deptUser) { + String userid = du.path("userid").asText(null); + if (userid == null || userid.isBlank()) { + continue; + } + QweiUser mapped = fetchAndMapUser(appToken, userid.trim(), tenantId); + if (upsertUser(mapped, tenantId)) { + inserted++; + } else { + updated++; + } + } + } + + JsonNode nextCursor = listId.get("next_cursor"); + if (nextCursor == null || nextCursor.isNull()) { + break; + } + String nc = nextCursor.asText(""); + if (nc.isBlank()) { + break; + } + cursor = nc; + } + + result.put("success", true); + result.put("message", "同步成功"); + result.put("inserted", inserted); + result.put("updated", updated); + result.put("tenantId", tenantId); + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "同步异常:" + e.getMessage()); + if (tenantId != null) { + result.put("tenantId", tenantId); + } + return result; + } finally { + TenantContextHolder.clear(); + } + } + + private boolean upsertUser(QweiUser user, String tenantId) { + LambdaQueryWrapper q = new LambdaQueryWrapper() + .eq(QweiUser::getUserid, user.getUserid()); + QweiUser exists = this.getOne(q, false); + LocalDateTime now = LocalDateTime.now(); + if (exists == null) { + user.setId(UUID.randomUUID().toString()); + user.setTenantId(tenantId); + user.setIsDeleted(0); + user.setCreatedAt(now); + user.setUpdatedAt(now); + try { + this.save(user); + return true; + } catch (Exception insertEx) { + // 若唯一索引仍是 userid 单列,且历史数据 tenant_id 为空,会导致撞唯一键 + if (!isDuplicateUserid(insertEx)) { + throw insertEx; + } + QweiUser legacy = this.baseMapper.selectByUseridIgnoreTenant(user.getUserid()); + if (legacy == null) { + throw insertEx; + } + String legacyTenant = trimToNull(legacy.getTenantId()); + if (legacyTenant == null) { + this.baseMapper.updateLegacyTenantAndFieldsByIdIgnoreTenant( + legacy.getId(), + tenantId, + user.getUserName(), + user.getMobile(), + user.getEmail(), + user.getBizEmail(), + user.getTelephone(), + user.getPosition(), + user.getGender(), + user.getStatus(), + user.getMainDepartmentId(), + user.getDepartmentIdsJson(), + user.getAliasName(), + user.getAvatar(), + user.getThumbAvatar(), + user.getAddress(), + 0, + now + ); + return false; + } + if (!tenantId.equals(legacyTenant)) { + throw new IllegalStateException("用户userid=" + user.getUserid() + + " 已存在且属于其他租户(" + legacyTenant + ")。请把唯一索引改为(tenant_id, userid)。"); + } + // 同租户兜底更新 + exists = legacy; + } + } + + exists.setTenantId(null); // 不允许改租户 + exists.setUserName(user.getUserName()); + exists.setMobile(user.getMobile()); + exists.setEmail(user.getEmail()); + exists.setBizEmail(user.getBizEmail()); + exists.setTelephone(user.getTelephone()); + exists.setPosition(user.getPosition()); + exists.setGender(user.getGender()); + exists.setStatus(user.getStatus()); + exists.setMainDepartmentId(user.getMainDepartmentId()); + exists.setDepartmentIdsJson(user.getDepartmentIdsJson()); + exists.setAliasName(user.getAliasName()); + exists.setAvatar(user.getAvatar()); + exists.setThumbAvatar(user.getThumbAvatar()); + exists.setAddress(user.getAddress()); + exists.setIsDeleted(0); + exists.setUpdatedAt(now); + this.updateById(exists); + return false; + } + + private QweiUser fetchAndMapUser(String appToken, String userid, String tenantId) throws Exception { + String url = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=" + + URLEncoder.encode(appToken, StandardCharsets.UTF_8) + + "&userid=" + URLEncoder.encode(userid, StandardCharsets.UTF_8); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + String body = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); + JsonNode json = OBJECT_MAPPER.readTree(body); + int errCode = json.path("errcode").asInt(-1); + if (errCode != 0) { + throw new IllegalStateException("获取用户信息失败: " + body); + } + + QweiUser user = new QweiUser(); + user.setTenantId(tenantId); + user.setUserid(userid); + user.setUserName(json.path("name").asText(null)); + user.setMobile(json.path("mobile").asText(null)); + user.setEmail(json.path("email").asText(null)); + user.setBizEmail(json.path("biz_email").asText(null)); + user.setTelephone(json.path("telephone").asText(null)); + user.setPosition(json.path("position").asText(null)); + user.setGender(json.hasNonNull("gender") ? json.path("gender").asInt() : null); + user.setStatus(json.hasNonNull("status") ? json.path("status").asInt() : null); + user.setAvatar(json.path("avatar").asText(null)); + user.setThumbAvatar(json.path("thumb_avatar").asText(null)); + user.setAliasName(json.path("alias").asText(null)); + user.setAddress(json.path("address").asText(null)); + user.setMainDepartmentId(json.hasNonNull("main_department") ? json.path("main_department").asLong() : null); + JsonNode dep = json.path("department"); + if (dep.isArray()) { + user.setDepartmentIdsJson(OBJECT_MAPPER.writeValueAsString(dep)); + } + return user; + } + + private JsonNode fetchUserListId(String contactToken, String cursor, int limit) throws Exception { + String url = "https://qyapi.weixin.qq.com/cgi-bin/user/list_id?access_token=" + + URLEncoder.encode(contactToken, StandardCharsets.UTF_8); + String payload = OBJECT_MAPPER.writeValueAsString(Map.of( + "cursor", cursor == null ? "" : cursor, + "limit", limit + )); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + String body = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); + JsonNode json = OBJECT_MAPPER.readTree(body); + int errCode = json.path("errcode").asInt(-1); + if (errCode != 0) { + throw new IllegalStateException("获取成员ID列表失败: " + body); + } + return json; + } + + private QiWeiConfig loadQiWeiConfig() throws Exception { + String corpid = trimToNull(System.getProperty("qiwei.corpid")); + String contactSecret = trimToNull(System.getProperty("qiwei.contactSecret")); + String appSecret = trimToNull(System.getProperty("qiwei.appSecret")); + if (corpid != null && contactSecret != null && appSecret != null) { + QiWeiConfig cfg = new QiWeiConfig(); + cfg.setCorpid(corpid); + cfg.setContractSecret(contactSecret); + cfg.setAppSecret(appSecret); + return cfg; + } + + String tenantId = System.getProperty("tenantId", "TENANT_ID_CST_2026"); + TenantContextHolder.setTenantId(tenantId); + try { + LambdaQueryWrapper q = new LambdaQueryWrapper() + .eq(DictItem::getName, DictItemConstants.QIWEI_CONFIG) + .orderByDesc(DictItem::getUpdatedTime) + .orderByDesc(DictItem::getCreatedTime) + .last("limit 1"); + DictItem item = dictItemMapper.selectOne(q); + if (item == null || item.getValue() == null || item.getValue().trim().isEmpty()) { + throw new IllegalStateException("未找到 qiwei_config"); + } + String json = PasswordUtil.decryptReversibleWithDefaultSalt(item.getValue()); + return OBJECT_MAPPER.readValue(json, QiWeiConfig.class); + } finally { + TenantContextHolder.clear(); + } + } + + private String getAccessToken(String corpId, String secret) throws Exception { + String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + + URLEncoder.encode(corpId, StandardCharsets.UTF_8) + + "&corpsecret=" + URLEncoder.encode(secret, StandardCharsets.UTF_8); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + String body = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()).body(); + JsonNode json = OBJECT_MAPPER.readTree(body); + int errCode = json.path("errcode").asInt(-1); + if (errCode != 0) { + throw new IllegalStateException("获取access_token失败: " + body); + } + return json.path("access_token").asText(); + } + + private String requireNonBlank(String value, String msg) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalStateException(msg); + } + return value.trim(); + } + + private boolean isDuplicateUserid(Exception e) { + String msg = e.getMessage(); + return msg != null && msg.contains("Duplicate entry"); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String t = value.trim(); + return t.isEmpty() ? null : t; + } + + private String resolveTenantId(String tenantIdFromEntity) { + String current = trimToNull(TenantContextHolder.getTenantId()); + if (current != null) { + return current; + } + String t = trimToNull(tenantIdFromEntity); + if (t != null) { + return t; + } + String fromProp = trimToNull(System.getProperty("tenantId")); + if (fromProp != null) { + return fromProp; + } + throw new IllegalStateException("tenantId不能为空(请设置租户上下文或传入 tenantId 或 -DtenantId)"); + } } diff --git a/src/test/java/com/rj/controller/QweiDepartmentControllerTest.java b/src/test/java/com/rj/controller/QweiDepartmentControllerTest.java new file mode 100644 index 0000000..f958506 --- /dev/null +++ b/src/test/java/com/rj/controller/QweiDepartmentControllerTest.java @@ -0,0 +1,80 @@ +package com.rj.controller; + +import com.rj.service.IQweiDepartmentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureWebMvc +@ActiveProfiles("test") +public class QweiDepartmentControllerTest { + + @Autowired + private WebApplicationContext webApplicationContext; + + @MockitoBean + private IQweiDepartmentService qweiDepartmentService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @Test + void syncEndpointShouldReturnOkWhenServiceSuccess() throws Exception { + Map serviceResult = new HashMap<>(); + serviceResult.put("success", true); + serviceResult.put("message", "同步成功"); + serviceResult.put("inserted", 3); + serviceResult.put("updated", 5); + serviceResult.put("total", 8); + when(qweiDepartmentService.syncFromQiWei()).thenReturn(serviceResult); + + mockMvc.perform(post("/api/qweiDepartment/sync") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.inserted").value(3)) + .andExpect(jsonPath("$.updated").value(5)) + .andExpect(jsonPath("$.total").value(8)); + + verify(qweiDepartmentService, times(1)).syncFromQiWei(); + } + + @Test + void syncEndpointShouldReturnBadRequestWhenServiceFailed() throws Exception { + Map serviceResult = new HashMap<>(); + serviceResult.put("success", false); + serviceResult.put("message", "同步异常:测试错误"); + when(qweiDepartmentService.syncFromQiWei()).thenReturn(serviceResult); + + mockMvc.perform(post("/api/qweiDepartment/sync") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + + verify(qweiDepartmentService, times(1)).syncFromQiWei(); + } +} + diff --git a/src/test/java/com/rj/qiwei/QweiDepartmentSyncIntegrationTest.java b/src/test/java/com/rj/qiwei/QweiDepartmentSyncIntegrationTest.java new file mode 100644 index 0000000..395d8a2 --- /dev/null +++ b/src/test/java/com/rj/qiwei/QweiDepartmentSyncIntegrationTest.java @@ -0,0 +1,28 @@ +package com.rj.qiwei; + +import com.rj.service.IQweiDepartmentService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ActiveProfiles("test") +public class QweiDepartmentSyncIntegrationTest { + + @Autowired + private IQweiDepartmentService qweiDepartmentService; + + @Test + void syncDepartmentsToDb() { + Map result = qweiDepartmentService.syncFromQiWei(); + Object success = result.get("success"); + assertTrue(Boolean.TRUE.equals(success), "同步失败: " + result); + System.out.println("qwei department sync result: " + result); + } +} + diff --git a/src/test/java/com/rj/qiwei/QweiUserSyncIntegrationTest.java b/src/test/java/com/rj/qiwei/QweiUserSyncIntegrationTest.java new file mode 100644 index 0000000..929c411 --- /dev/null +++ b/src/test/java/com/rj/qiwei/QweiUserSyncIntegrationTest.java @@ -0,0 +1,28 @@ +package com.rj.qiwei; + +import com.rj.service.IQweiUserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ActiveProfiles("test") +public class QweiUserSyncIntegrationTest { + + @Autowired + private IQweiUserService qweiUserService; + + @Test + void syncUsersToDb() { + Map result = qweiUserService.syncFromQiWei(); + Object success = result.get("success"); + assertTrue(Boolean.TRUE.equals(success), "同步失败: " + result); + System.out.println("qwei user sync result: " + result); + } +} +