diff --git a/src/main/java/com/rj/common/DictItemConstants.java b/src/main/java/com/rj/common/DictItemConstants.java new file mode 100644 index 0000000..cce92a3 --- /dev/null +++ b/src/main/java/com/rj/common/DictItemConstants.java @@ -0,0 +1,13 @@ +package com.rj.common; + +/** + * dict_item 相关常量 + */ +public final class DictItemConstants { + + private DictItemConstants() { + } + + public static final String QIWEI_CONFIG = "qiwei_config"; +} + diff --git a/src/main/java/com/rj/common/PasswordUtil.java b/src/main/java/com/rj/common/PasswordUtil.java index 3f110a4..a22a839 100644 --- a/src/main/java/com/rj/common/PasswordUtil.java +++ b/src/main/java/com/rj/common/PasswordUtil.java @@ -2,6 +2,15 @@ package com.rj.common; import org.springframework.util.DigestUtils; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + /** * 密码工具类 * @@ -15,6 +24,13 @@ public class PasswordUtil { */ public static final String DEFAULT_SALT = "rj_system_2025"; + /** + * 可逆加密标记,避免重复加密 + */ + private static final String ENC_PREFIX = "ENC$"; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + /** * MD5加密密码 * @@ -47,6 +63,79 @@ public class PasswordUtil { return encryptPassword(password, DEFAULT_SALT); } + /** + * 可逆加密(AES-GCM),输出格式:ENC$base64(iv).base64(cipherText) + *

+ * 说明:此方法用于需要可解密的配置值(例如企微配置),不要用于用户密码。 + */ + public static String encryptReversibleWithDefaultSalt(String plainText) { + if (plainText == null) { + plainText = ""; + } + if (isEncryptedReversible(plainText)) { + return plainText; + } + try { + byte[] iv = new byte[12]; // GCM recommended IV length + SECURE_RANDOM.nextBytes(iv); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + cipher.init(Cipher.ENCRYPT_MODE, deriveAesKey(DEFAULT_SALT), spec); + + byte[] cipherBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + String ivB64 = Base64.getEncoder().encodeToString(iv); + String ctB64 = Base64.getEncoder().encodeToString(cipherBytes); + return ENC_PREFIX + ivB64 + "." + ctB64; + } catch (Exception e) { + throw new IllegalStateException("encrypt reversible failed: " + e.getMessage(), e); + } + } + + /** + * 可逆解密(AES-GCM),支持未加密值直接原样返回。 + */ + public static String decryptReversibleWithDefaultSalt(String encryptedOrPlain) { + if (encryptedOrPlain == null) { + return null; + } + if (!isEncryptedReversible(encryptedOrPlain)) { + return encryptedOrPlain; + } + try { + String payload = encryptedOrPlain.substring(ENC_PREFIX.length()); + int dot = payload.indexOf('.'); + if (dot <= 0 || dot >= payload.length() - 1) { + throw new IllegalArgumentException("bad encrypted format"); + } + byte[] iv = Base64.getDecoder().decode(payload.substring(0, dot)); + byte[] cipherBytes = Base64.getDecoder().decode(payload.substring(dot + 1)); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + cipher.init(Cipher.DECRYPT_MODE, deriveAesKey(DEFAULT_SALT), spec); + byte[] plain = cipher.doFinal(cipherBytes); + return new String(plain, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("decrypt reversible failed: " + e.getMessage(), e); + } + } + + public static boolean isEncryptedReversible(String value) { + return value != null && value.startsWith(ENC_PREFIX); + } + + private static SecretKey deriveAesKey(String salt) { + try { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + byte[] keyBytes = sha256.digest(String.valueOf(salt).getBytes(StandardCharsets.UTF_8)); + // 256-bit AES key + return new SecretKeySpec(keyBytes, "AES"); + } catch (Exception e) { + throw new IllegalStateException("derive key failed: " + e.getMessage(), e); + } + } + /** * 验证密码 * diff --git a/src/main/java/com/rj/controller/DictItemController.java b/src/main/java/com/rj/controller/DictItemController.java index 79b4d2d..a32a773 100644 --- a/src/main/java/com/rj/controller/DictItemController.java +++ b/src/main/java/com/rj/controller/DictItemController.java @@ -1,5 +1,7 @@ package com.rj.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rj.common.DictItemConstants; import com.rj.dto.QiWeiConfig; import com.rj.entity.DictItem; import com.rj.service.IDictItemService; @@ -26,6 +28,8 @@ public class DictItemController { @Autowired private IDictItemService dictItemService; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @PostMapping("/add") @Operation(summary = "新增") public ResponseEntity> add( @@ -90,10 +94,18 @@ public class DictItemController { try { DictItem data = dictItemService.getById(id); if (data == null) { - if ("qiwei_config".equals(name)) { + if (DictItemConstants.QIWEI_CONFIG.equals(name)) { result.put("success", true); result.put("message", "查询成功"); - result.put("data", new QiWeiConfig()); + data= new DictItem(); + QiWeiConfig cfg = new QiWeiConfig(); + cfg.setCorpid("-"); + cfg.setAppSecret("-"); + cfg.setContractSecret("-"); + data.setValue(OBJECT_MAPPER.writeValueAsString(cfg)); + data.setName(DictItemConstants.QIWEI_CONFIG); + data.setDescription("企微的默认配置项"); + result.put("data", data); return ResponseEntity.ok(result); } diff --git a/src/main/java/com/rj/controller/QweiDepartmentController.java b/src/main/java/com/rj/controller/QweiDepartmentController.java new file mode 100644 index 0000000..6dfb613 --- /dev/null +++ b/src/main/java/com/rj/controller/QweiDepartmentController.java @@ -0,0 +1,74 @@ +package com.rj.controller; + +import com.rj.entity.QweiDepartment; +import com.rj.service.IQweiDepartmentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/qweiDepartment") +@Tag(name = "企微部门", description = "qwei_department增删改查与分页") +public class QweiDepartmentController { + + @Autowired + private IQweiDepartmentService qweiDepartmentService; + + @PostMapping("/add") + @Operation(summary = "新增") + public ResponseEntity> add( + @Parameter(description = "实体", required = true) @RequestBody QweiDepartment entity) { + Map result = qweiDepartmentService.add(entity); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } + + @PutMapping("/update") + @Operation(summary = "更新") + public ResponseEntity> update( + @Parameter(description = "实体", required = true) @RequestBody QweiDepartment entity) { + Map result = qweiDepartmentService.update(entity); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } + + @DeleteMapping("/delete/{id}") + @Operation(summary = "删除") + public ResponseEntity> delete( + @Parameter(description = "主键ID(UUID)", required = true) @PathVariable String id) { + Map result = qweiDepartmentService.deleteById(id); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } + + @GetMapping("/list") + @Operation(summary = "分页查询") + public ResponseEntity> list( + @RequestParam(defaultValue = "1") Integer current, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) String deptName, + @RequestParam(required = false) Long parentDeptId) { + Map result = qweiDepartmentService.pageQuery(current, size, deptId, deptName, parentDeptId); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.internalServerError().body(result); + } +} + diff --git a/src/main/java/com/rj/controller/QweiUserController.java b/src/main/java/com/rj/controller/QweiUserController.java new file mode 100644 index 0000000..5da1a5c --- /dev/null +++ b/src/main/java/com/rj/controller/QweiUserController.java @@ -0,0 +1,75 @@ +package com.rj.controller; + +import com.rj.entity.QweiUser; +import com.rj.service.IQweiUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/qweiUser") +@Tag(name = "企微用户", description = "qwei_user增删改查与分页") +public class QweiUserController { + + @Autowired + private IQweiUserService qweiUserService; + + @PostMapping("/add") + @Operation(summary = "新增") + public ResponseEntity> add( + @Parameter(description = "实体", required = true) @RequestBody QweiUser entity) { + Map result = qweiUserService.add(entity); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } + + @PutMapping("/update") + @Operation(summary = "更新") + public ResponseEntity> update( + @Parameter(description = "实体", required = true) @RequestBody QweiUser entity) { + Map result = qweiUserService.update(entity); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } + + @DeleteMapping("/delete/{id}") + @Operation(summary = "删除") + public ResponseEntity> delete( + @Parameter(description = "主键ID(UUID)", required = true) @PathVariable String id) { + Map result = qweiUserService.deleteById(id); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.badRequest().body(result); + } + + @GetMapping("/list") + @Operation(summary = "分页查询") + public ResponseEntity> list( + @RequestParam(defaultValue = "1") Integer current, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) String userid, + @RequestParam(required = false) String userName, + @RequestParam(required = false) Long mainDepartmentId, + @RequestParam(required = false) Integer status) { + Map result = qweiUserService.pageQuery(current, size, userid, userName, mainDepartmentId, status); + Boolean success = (Boolean) result.get("success"); + if (success != null && success) { + return ResponseEntity.ok(result); + } + return ResponseEntity.internalServerError().body(result); + } +} + diff --git a/src/main/java/com/rj/entity/QweiDepartment.java b/src/main/java/com/rj/entity/QweiDepartment.java new file mode 100644 index 0000000..fe693a2 --- /dev/null +++ b/src/main/java/com/rj/entity/QweiDepartment.java @@ -0,0 +1,53 @@ +package com.rj.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("qwei_department") +@Schema(description = "企微部门表") +public class QweiDepartment implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId("id") + @Schema(description = "主键UUID") + private String id; + + @TableField("dept_id") + @Schema(description = "企微部门ID") + private Long deptId; + + @TableField("dept_name") + @Schema(description = "部门名称") + private String deptName; + + @TableField("parent_dept_id") + @Schema(description = "上级部门ID") + private Long parentDeptId; + + @TableField("order_num") + @Schema(description = "部门排序") + private Integer orderNum; + + @TableField("is_deleted") + @Schema(description = "是否删除(0否1是)") + private Integer isDeleted; + + @TableField("created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @TableField("updated_at") + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/rj/entity/QweiUser.java b/src/main/java/com/rj/entity/QweiUser.java new file mode 100644 index 0000000..0f76859 --- /dev/null +++ b/src/main/java/com/rj/entity/QweiUser.java @@ -0,0 +1,97 @@ +package com.rj.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("qwei_user") +@Schema(description = "企微用户表") +public class QweiUser implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId("id") + @Schema(description = "主键UUID") + private String id; + + @TableField("userid") + @Schema(description = "企微用户ID") + private String userid; + + @TableField("user_name") + @Schema(description = "姓名") + private String userName; + + @TableField("mobile") + @Schema(description = "手机号") + private String mobile; + + @TableField("email") + @Schema(description = "邮箱") + private String email; + + @TableField("biz_email") + @Schema(description = "企业邮箱") + private String bizEmail; + + @TableField("telephone") + @Schema(description = "座机") + private String telephone; + + @TableField("position") + @Schema(description = "职位") + private String position; + + @TableField("gender") + @Schema(description = "性别") + private Integer gender; + + @TableField("status") + @Schema(description = "激活状态") + private Integer status; + + @TableField("main_department_id") + @Schema(description = "主部门ID") + private Long mainDepartmentId; + + @TableField("department_ids_json") + @Schema(description = "所属部门ID列表(JSON)") + private String departmentIdsJson; + + @TableField("alias_name") + @Schema(description = "别名") + private String aliasName; + + @TableField("avatar") + @Schema(description = "头像URL") + private String avatar; + + @TableField("thumb_avatar") + @Schema(description = "头像缩略图URL") + private String thumbAvatar; + + @TableField("address") + @Schema(description = "地址") + private String address; + + @TableField("is_deleted") + @Schema(description = "是否删除(0否1是)") + private Integer isDeleted; + + @TableField("created_at") + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @TableField("updated_at") + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/rj/mapper/QweiDepartmentMapper.java b/src/main/java/com/rj/mapper/QweiDepartmentMapper.java new file mode 100644 index 0000000..1a1420f --- /dev/null +++ b/src/main/java/com/rj/mapper/QweiDepartmentMapper.java @@ -0,0 +1,10 @@ +package com.rj.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rj.entity.QweiDepartment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface QweiDepartmentMapper extends BaseMapper { +} + diff --git a/src/main/java/com/rj/mapper/QweiUserMapper.java b/src/main/java/com/rj/mapper/QweiUserMapper.java new file mode 100644 index 0000000..66f37a4 --- /dev/null +++ b/src/main/java/com/rj/mapper/QweiUserMapper.java @@ -0,0 +1,10 @@ +package com.rj.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rj.entity.QweiUser; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface QweiUserMapper extends BaseMapper { +} + diff --git a/src/main/java/com/rj/service/IQweiDepartmentService.java b/src/main/java/com/rj/service/IQweiDepartmentService.java new file mode 100644 index 0000000..d8c08c4 --- /dev/null +++ b/src/main/java/com/rj/service/IQweiDepartmentService.java @@ -0,0 +1,22 @@ +package com.rj.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rj.entity.QweiDepartment; + +import java.util.Map; + +public interface IQweiDepartmentService extends IService { + + Map add(QweiDepartment entity); + + Map update(QweiDepartment entity); + + Map deleteById(String id); + + Map pageQuery(Integer current, + Integer size, + Long deptId, + String deptName, + Long parentDeptId); +} + diff --git a/src/main/java/com/rj/service/IQweiUserService.java b/src/main/java/com/rj/service/IQweiUserService.java new file mode 100644 index 0000000..deabfdc --- /dev/null +++ b/src/main/java/com/rj/service/IQweiUserService.java @@ -0,0 +1,23 @@ +package com.rj.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rj.entity.QweiUser; + +import java.util.Map; + +public interface IQweiUserService extends IService { + + Map add(QweiUser entity); + + Map update(QweiUser entity); + + Map deleteById(String id); + + Map pageQuery(Integer current, + Integer size, + String userid, + String userName, + Long mainDepartmentId, + Integer status); +} + diff --git a/src/main/java/com/rj/service/impl/DictItemServiceImpl.java b/src/main/java/com/rj/service/impl/DictItemServiceImpl.java index 775a481..4097c73 100644 --- a/src/main/java/com/rj/service/impl/DictItemServiceImpl.java +++ b/src/main/java/com/rj/service/impl/DictItemServiceImpl.java @@ -3,6 +3,8 @@ 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.rj.common.DictItemConstants; +import com.rj.common.PasswordUtil; import com.rj.entity.DictItem; import com.rj.mapper.DictItemMapper; import com.rj.service.IDictItemService; @@ -33,6 +35,26 @@ public class DictItemServiceImpl extends ServiceImpl i result.put("message", "name不能为空"); return result; } + + String tenantId = entity.getTenantId().trim(); + String name = entity.getName().trim(); + LambdaQueryWrapper dupQ = new LambdaQueryWrapper() + .eq(DictItem::getName, name); + if (this.count(dupQ) > 0) { + result.put("success", false); + result.put("message", "该租户下name已存在,不能重复添加"); + return result; + } + if (DictItemConstants.QIWEI_CONFIG.equals(name)){ + String rawValue = entity.getValue(); + if (rawValue == null) { + rawValue = ""; + } + entity.setValue(PasswordUtil.encryptReversibleWithDefaultSalt(rawValue)); + } + + entity.setTenantId(tenantId); + entity.setName(name); entity.setId(UUID.randomUUID().toString().replace("-", "")); LocalDateTime now = LocalDateTime.now(); entity.setCreatedTime(now); @@ -60,6 +82,13 @@ public class DictItemServiceImpl extends ServiceImpl i result.put("message", "ID不能为空"); return result; } + if (DictItemConstants.QIWEI_CONFIG.equals(entity.getName())) { + String rawValue = entity.getValue(); + if (rawValue == null) { + rawValue = ""; + } + entity.setValue(PasswordUtil.encryptReversibleWithDefaultSalt(rawValue)); + } entity.setUpdatedTime(LocalDateTime.now()); boolean ok = this.updateById(entity); result.put("success", ok); diff --git a/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java b/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java new file mode 100644 index 0000000..0abf94d --- /dev/null +++ b/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java @@ -0,0 +1,142 @@ +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.rj.entity.QweiDepartment; +import com.rj.mapper.QweiDepartmentMapper; +import com.rj.service.IQweiDepartmentService; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Service +public class QweiDepartmentServiceImpl extends ServiceImpl + implements IQweiDepartmentService { + + @Override + public Map add(QweiDepartment entity) { + Map result = new HashMap<>(); + try { + if (entity.getDeptId() == null) { + result.put("success", false); + result.put("message", "deptId不能为空"); + return result; + } + if (entity.getDeptName() == null || entity.getDeptName().trim().isEmpty()) { + result.put("success", false); + result.put("message", "deptName不能为空"); + return result; + } + LambdaQueryWrapper dup = new LambdaQueryWrapper() + .eq(QweiDepartment::getDeptId, entity.getDeptId()); + if (this.count(dup) > 0) { + result.put("success", false); + result.put("message", "deptId已存在"); + return result; + } + + entity.setId(UUID.randomUUID().toString()); + if (entity.getIsDeleted() == null) { + entity.setIsDeleted(0); + } + LocalDateTime now = LocalDateTime.now(); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + boolean ok = this.save(entity); + result.put("success", ok); + result.put("message", ok ? "添加成功" : "添加失败"); + if (ok) { + result.put("data", entity); + } + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "添加异常:" + e.getMessage()); + return result; + } + } + + @Override + public Map update(QweiDepartment entity) { + Map result = new HashMap<>(); + try { + if (entity.getId() == null || entity.getId().trim().isEmpty()) { + result.put("success", false); + result.put("message", "id不能为空"); + return result; + } + entity.setUpdatedAt(LocalDateTime.now()); + boolean ok = this.updateById(entity); + result.put("success", ok); + result.put("message", ok ? "更新成功" : "更新失败"); + if (ok) { + result.put("data", this.getById(entity.getId())); + } + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "更新异常:" + e.getMessage()); + return result; + } + } + + @Override + public Map deleteById(String id) { + Map result = new HashMap<>(); + try { + boolean ok = this.removeById(id); + result.put("success", ok); + result.put("message", ok ? "删除成功" : "删除失败"); + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "删除异常:" + e.getMessage()); + return result; + } + } + + @Override + public Map pageQuery(Integer current, Integer size, Long deptId, String deptName, Long parentDeptId) { + Map result = new HashMap<>(); + try { + if (current == null || current < 1) { + current = 1; + } + if (size == null || size < 1) { + size = 10; + } + + Page page = new Page<>(current, size); + LambdaQueryWrapper q = new LambdaQueryWrapper<>(); + if (deptId != null) { + q.eq(QweiDepartment::getDeptId, deptId); + } + if (deptName != null && !deptName.trim().isEmpty()) { + q.like(QweiDepartment::getDeptName, deptName.trim()); + } + if (parentDeptId != null) { + q.eq(QweiDepartment::getParentDeptId, parentDeptId); + } + q.orderByAsc(QweiDepartment::getOrderNum).orderByDesc(QweiDepartment::getUpdatedAt); + + Page data = this.page(page, q); + result.put("success", true); + result.put("message", "查询成功"); + result.put("data", data.getRecords()); + result.put("total", data.getTotal()); + result.put("current", data.getCurrent()); + result.put("size", data.getSize()); + result.put("pages", data.getPages()); + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "查询异常:" + e.getMessage()); + return result; + } + } +} + diff --git a/src/main/java/com/rj/service/impl/QweiUserServiceImpl.java b/src/main/java/com/rj/service/impl/QweiUserServiceImpl.java new file mode 100644 index 0000000..fc3a5d2 --- /dev/null +++ b/src/main/java/com/rj/service/impl/QweiUserServiceImpl.java @@ -0,0 +1,148 @@ +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.rj.entity.QweiUser; +import com.rj.mapper.QweiUserMapper; +import com.rj.service.IQweiUserService; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Service +public class QweiUserServiceImpl extends ServiceImpl implements IQweiUserService { + + @Override + public Map add(QweiUser entity) { + Map result = new HashMap<>(); + try { + if (entity.getUserid() == null || entity.getUserid().trim().isEmpty()) { + result.put("success", false); + result.put("message", "userid不能为空"); + return result; + } + LambdaQueryWrapper dup = new LambdaQueryWrapper() + .eq(QweiUser::getUserid, entity.getUserid().trim()); + if (this.count(dup) > 0) { + result.put("success", false); + result.put("message", "userid已存在"); + return result; + } + + entity.setId(UUID.randomUUID().toString()); + entity.setUserid(entity.getUserid().trim()); + if (entity.getIsDeleted() == null) { + entity.setIsDeleted(0); + } + LocalDateTime now = LocalDateTime.now(); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + boolean ok = this.save(entity); + result.put("success", ok); + result.put("message", ok ? "添加成功" : "添加失败"); + if (ok) { + result.put("data", entity); + } + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "添加异常:" + e.getMessage()); + return result; + } + } + + @Override + public Map update(QweiUser entity) { + Map result = new HashMap<>(); + try { + if (entity.getId() == null || entity.getId().trim().isEmpty()) { + result.put("success", false); + result.put("message", "id不能为空"); + return result; + } + if (entity.getUserid() != null) { + entity.setUserid(entity.getUserid().trim()); + } + entity.setUpdatedAt(LocalDateTime.now()); + boolean ok = this.updateById(entity); + result.put("success", ok); + result.put("message", ok ? "更新成功" : "更新失败"); + if (ok) { + result.put("data", this.getById(entity.getId())); + } + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "更新异常:" + e.getMessage()); + return result; + } + } + + @Override + public Map deleteById(String id) { + Map result = new HashMap<>(); + try { + boolean ok = this.removeById(id); + result.put("success", ok); + result.put("message", ok ? "删除成功" : "删除失败"); + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "删除异常:" + e.getMessage()); + return result; + } + } + + @Override + public Map pageQuery(Integer current, + Integer size, + String userid, + String userName, + Long mainDepartmentId, + Integer status) { + Map result = new HashMap<>(); + try { + if (current == null || current < 1) { + current = 1; + } + if (size == null || size < 1) { + size = 10; + } + + Page page = new Page<>(current, size); + LambdaQueryWrapper q = new LambdaQueryWrapper<>(); + if (userid != null && !userid.trim().isEmpty()) { + q.eq(QweiUser::getUserid, userid.trim()); + } + if (userName != null && !userName.trim().isEmpty()) { + q.like(QweiUser::getUserName, userName.trim()); + } + if (mainDepartmentId != null) { + q.eq(QweiUser::getMainDepartmentId, mainDepartmentId); + } + if (status != null) { + q.eq(QweiUser::getStatus, status); + } + q.orderByDesc(QweiUser::getUpdatedAt); + + Page data = this.page(page, q); + result.put("success", true); + result.put("message", "查询成功"); + result.put("data", data.getRecords()); + result.put("total", data.getTotal()); + result.put("current", data.getCurrent()); + result.put("size", data.getSize()); + result.put("pages", data.getPages()); + return result; + } catch (Exception e) { + result.put("success", false); + result.put("message", "查询异常:" + e.getMessage()); + return result; + } + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9cd1d10..e0cb018 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -285,4 +285,4 @@ siliconflow: #qiwei: # corpid: wwf863be906c659801 # app-secret: 5fiJNav_GrLBN70aBjJK3khRpzDPKoj-b9JhXFwNZOk -# contract-secret: KQbOvEW8SQORCkvArJ6aF-voarpveVHN5Jj1p4DnWto \ No newline at end of file +# contract-secret: KQbOvEW8SQORCkvArJ6aF-voarpveVHN5Jj1p4DnWto KQbOvEW8SQORCkvArJ6aF8T_h4ObCgn9uuDRtot_T_c \ No newline at end of file diff --git a/src/test/java/com/rj/qiwei/QiWeiContactsReadTest.java b/src/test/java/com/rj/qiwei/QiWeiContactsReadTest.java new file mode 100644 index 0000000..e8ce569 --- /dev/null +++ b/src/test/java/com/rj/qiwei/QiWeiContactsReadTest.java @@ -0,0 +1,297 @@ +package com.rj.qiwei; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.mapper.DictItemMapper; +import com.rj.tenant.TenantContextHolder; +import cn.felord.DefaultAgent; +import cn.felord.WeComException; +import cn.felord.WeComTokenCacheable; +import cn.felord.api.UserApi; +import cn.felord.domain.contactbook.user.DeptUserListResponse; +import cn.felord.domain.contactbook.user.DeptUser; +import cn.felord.domain.contactbook.user.UserInfoResponse; +import cn.felord.domain.contactbook.user.SimpleUser; +import cn.felord.domain.GenericResponse; +import cn.felord.retrofit.AccessTokenApi; +import cn.felord.retrofit.WorkWechatRetrofitFactory; +import okhttp3.ConnectionPool; +import okhttp3.logging.HttpLoggingInterceptor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +public class QiWeiContactsReadTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Autowired + private DictItemMapper dictItemMapper; + + @Test + public void readContactsFromWeCom() throws Exception { + QiWeiConfig cfg = loadQiWeiConfigFromDbOrSystemProps(); + + // 支持通过 JVM 参数覆盖 DB 配置,便于快速联调: + // -Dqiwei.corpid=xxx -Dqiwei.contactSecret=xxx -Dqiwei.appSecret=xxx + String corpId = requireNonBlank(firstNonBlank(System.getProperty("qiwei.corpid"), cfg.getCorpid()), + "qiwei_config.corpid 不能为空"); + String appSecret = trimToNull(firstNonBlank(System.getProperty("qiwei.appSecret"), cfg.getAppSecret())); + String contactSecret = trimToNull(firstNonBlank(System.getProperty("qiwei.contactSecret"), cfg.getContractSecret())); + assertTrue(appSecret != null || contactSecret != null, "qiwei_config.appSecret/contractSecret 至少一个不能为空"); + + // 通讯录读取优先使用通讯录 secret,若失败再尝试 app secret,便于自动命中有权限的一组凭证。 + List candidates = buildSecretCandidates(contactSecret, appSecret); + List failures = new ArrayList<>(); + UserApi userApi = null; + DeptUserListResponse idListResp = null; + String selectedSecretType = null; + + // 1) 拉取成员ID列表(适用于通讯录同步类 secret) + for (SecretCandidate candidate : candidates) { + System.out.println("qiwei test trying secretType=" + candidate.type + + ", corpId=" + corpId + + ", secretTail=" + maskSecretTail(candidate.secret)); + userApi = createUserApi(corpId, candidate.secret); + try { + idListResp = userApi.userList("", 100); + selectedSecretType = candidate.type; + break; + } catch (WeComException e) { + failures.add("secretType=" + candidate.type + ", error=" + e.getMessage()); + } + } + + if (idListResp == null) { + fail("读取通讯录失败:所有可用 secret 均不可用。corpId=" + corpId + + ";排查建议:1) 若报 e=60020 请把出口 IP 36.143.223.91 加入该 secret 的可信 IP;" + + "2) 若报 e=48002 请给对应应用开通通讯录读取权限并放开可见范围;" + + "3) 确认优先使用的是通讯录 secret。失败明细=" + failures); + } + assertNotNull(userApi, "UserApi 初始化失败"); + assertNotNull(idListResp, "企微 user/list_id 响应不应为空"); + System.out.println("qiwei test selected secretType=" + selectedSecretType); + System.out.println("userList resp: " + OBJECT_MAPPER.writeValueAsString(idListResp)); + + List deptUsersFromList = idListResp.getDeptUser(); + List userIds = deptUsersFromList == null ? null : deptUsersFromList.stream() + .map(DeptUser::getUserid) + .toList(); + if (userIds != null && !userIds.isEmpty()) { + String firstUserId = userIds.get(0); + UserInfoResponse user = tryGetUserWithFallback(userApi, firstUserId, corpId, appSecret); + if (user != null) { + System.out.println("first user(" + firstUserId + "): " + OBJECT_MAPPER.writeValueAsString(user)); + } + } + + // 2) 拉取部门 1 的成员简要信息(需要应用对该部门有查看权限) + GenericResponse> deptUsers = tryGetDeptUsersWithFallback(userApi, corpId, appSecret); + if (deptUsers != null) { + System.out.println("dept(1) simple users: " + OBJECT_MAPPER.writeValueAsString(deptUsers)); + } + } + + private QiWeiConfig loadQiWeiConfigFromDbOrSystemProps() throws Exception { + // 可选:通过 -DtenantId=xxx 指定租户;不传则使用默认测试租户 + String tenantId = System.getProperty("tenantId", "TENANT_ID_CST_2026"); + + if (tenantId != null && !tenantId.trim().isEmpty()) { + TenantContextHolder.setTenantId(tenantId.trim()); + 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()) { + String json = PasswordUtil.decryptReversibleWithDefaultSalt(item.getValue()); + QiWeiConfig cfg = OBJECT_MAPPER.readValue(json, QiWeiConfig.class); + if (cfg != null) { + return cfg; + } + } + } finally { + TenantContextHolder.clear(); + } + } + Assumptions.assumeTrue(false, + "未找到 qiwei_config(且未提供 -Dqiwei.corpid/-Dqiwei.contractSecret 或 -Dqiwei.appSecret),跳过该测试"); + return null; // never reached + } + + private static String requireNonBlank(String v, String message) { + if (v == null || v.trim().isEmpty()) { + throw new IllegalStateException(message); + } + return v.trim(); + } + + private static UserApi createUserApi(String corpId, String secret) { + String agentId = "0"; + WeComTokenCacheable cacheable = new InMemoryWeComTokenCacheable(); + AccessTokenApi tokenApi = new AccessTokenApi(cacheable, DefaultAgent.of(corpId, secret, agentId)); + var retrofit = WorkWechatRetrofitFactory.create( + tokenApi, + new ConnectionPool(), + HttpLoggingInterceptor.Level.NONE + ); + return retrofit.create(UserApi.class); + } + + private static String firstNonBlank(String a, String b) { + if (a != null && !a.trim().isEmpty()) { + return a.trim(); + } + if (b != null && !b.trim().isEmpty()) { + return b.trim(); + } + return null; + } + + private static List buildSecretCandidates(String contactSecret, String appSecret) { + List candidates = new ArrayList<>(2); + if (contactSecret != null) { + candidates.add(new SecretCandidate("contact", contactSecret)); + } + if (appSecret != null && (contactSecret == null || !appSecret.equals(contactSecret))) { + candidates.add(new SecretCandidate("app", appSecret)); + } + return candidates; + } + + private static String trimToNull(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static UserInfoResponse tryGetUserWithFallback(UserApi currentApi, String userId, String corpId, String appSecret) { + try { + UserInfoResponse user = currentApi.getUser(userId); + assertNotNull(user, "读取成员信息响应不应为空"); + return user; + } catch (WeComException e) { + if (!isContactAssistantForbidden(e)) { + throw e; + } + System.out.println("[WARN] 当前 secret 调用 user/get 被限制(48009),尝试使用 appSecret 重试"+e.getMessage()); + if (appSecret == null) { + System.out.println("[WARN] appSecret 为空,跳过 user/get"); + return null; + } + UserApi appApi = createUserApi(corpId, appSecret); + try { + UserInfoResponse user = appApi.getUser(userId); + assertNotNull(user, "读取成员信息响应不应为空"); + return user; + } catch (WeComException ex) { + System.out.println("[WARN] appSecret 调用 user/get 仍失败,跳过。error=" + ex.getMessage()); + return null; + } + } + } + + private static GenericResponse> tryGetDeptUsersWithFallback(UserApi currentApi, String corpId, String appSecret) { + try { + GenericResponse> deptUsers = currentApi.getDeptUsers(1L); + assertNotNull(deptUsers, "企微 user/simplelist 响应不应为空"); + return deptUsers; + } catch (WeComException e) { + if (!isContactAssistantForbidden(e)) { + throw e; + } + System.out.println("[WARN] 当前 secret 调用 user/simplelist 被限制(48009),尝试使用 appSecret 重试"); + if (appSecret == null) { + System.out.println("[WARN] appSecret 为空,跳过 user/simplelist"); + return null; + } + UserApi appApi = createUserApi(corpId, appSecret); + try { + GenericResponse> deptUsers = appApi.getDeptUsers(1L); + assertNotNull(deptUsers, "企微 user/simplelist 响应不应为空"); + return deptUsers; + } catch (WeComException ex) { + System.out.println("[WARN] appSecret 调用 user/simplelist 仍失败,跳过。error=" + ex.getMessage()); + return null; + } + } + } + + private static boolean isContactAssistantForbidden(WeComException e) { + String msg = e.getMessage(); + return msg != null && (msg.contains("e=48009") || msg.contains("api forbidden for contact assistant")); + } + + private static String maskSecretTail(String secret) { + int n = Math.min(6, secret.length()); + return "***" + secret.substring(secret.length() - n); + } + + private record SecretCandidate(String type, String secret) { + } + + /** + * 测试用内存缓存:避免引入 Redis 依赖/配置。 + */ + private static final class InMemoryWeComTokenCacheable implements WeComTokenCacheable { + private final ConcurrentHashMap map = new ConcurrentHashMap<>(); + + @Override + public String putCorpTicket(String corpId, String agentId, String corpTicket) { + map.put("ticket:corp:" + corpId + ":" + agentId, corpTicket); + return corpTicket; + } + + @Override + public String getCorpTicket(String corpId, String agentId) { + return map.get("ticket:corp:" + corpId + ":" + agentId); + } + + @Override + public String putAgentTicket(String corpId, String agentId, String agentTicket) { + map.put("ticket:agent:" + corpId + ":" + agentId, agentTicket); + return agentTicket; + } + + @Override + public String getAgentTicket(String corpId, String agentId) { + return map.get("ticket:agent:" + corpId + ":" + agentId); + } + + @Override + public String putAccessToken(String corpId, String agentId, String accessToken) { + map.put("token:" + corpId + ":" + agentId, accessToken); + return accessToken; + } + + @Override + public String getAccessToken(String corpId, String agentId) { + return map.get("token:" + corpId + ":" + agentId); + } + + @Override + public void clearAccessToken(String corpId, String agentId) { + map.remove("token:" + corpId + ":" + agentId); + } + } +} +