From 217422f422cdb9207408e75a195131f825aa0460 Mon Sep 17 00:00:00 2001 From: cst61 Date: Sat, 25 Apr 2026 14:13:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=93=E5=A4=A9=E6=95=B0=E6=8D=AE=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/rj/common/QiWeiApiConstants.java | 17 + .../controller/DailyUserTradeController.java | 230 ++++++++++++ .../com/rj/controller/QweiUserController.java | 12 + .../java/com/rj/entity/DailyUserTrade.java | 74 ++++ .../com/rj/mapper/DailyUserTradeMapper.java | 13 + .../rj/service/IDailyUserTradeService.java | 13 + .../java/com/rj/service/IQweiUserService.java | 6 + .../impl/DailyUserTradeServiceImpl.java | 17 + .../impl/QweiDepartmentServiceImpl.java | 5 +- .../rj/service/impl/QweiUserServiceImpl.java | 341 +++++++++++++++++- .../resources/mapper/DailyUserTradeMapper.xml | 24 ++ src/main/sql/lb_daily_user_trade.sql | 17 + src/main/sql/qwei/dev.md | 16 + .../QweiUserControllerIntegrationTest.java | 46 +++ .../rj/controller/QweiUserControllerTest.java | 81 +++++ .../com/rj/qiwei/WeComCustomerReadTest.java | 0 16 files changed, 907 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/rj/common/QiWeiApiConstants.java create mode 100644 src/main/java/com/rj/controller/DailyUserTradeController.java create mode 100644 src/main/java/com/rj/entity/DailyUserTrade.java create mode 100644 src/main/java/com/rj/mapper/DailyUserTradeMapper.java create mode 100644 src/main/java/com/rj/service/IDailyUserTradeService.java create mode 100644 src/main/java/com/rj/service/impl/DailyUserTradeServiceImpl.java create mode 100644 src/main/resources/mapper/DailyUserTradeMapper.xml create mode 100644 src/main/sql/lb_daily_user_trade.sql create mode 100644 src/test/java/com/rj/controller/QweiUserControllerIntegrationTest.java create mode 100644 src/test/java/com/rj/controller/QweiUserControllerTest.java delete mode 100644 src/test/java/com/rj/qiwei/WeComCustomerReadTest.java diff --git a/src/main/java/com/rj/common/QiWeiApiConstants.java b/src/main/java/com/rj/common/QiWeiApiConstants.java new file mode 100644 index 0000000..961b346 --- /dev/null +++ b/src/main/java/com/rj/common/QiWeiApiConstants.java @@ -0,0 +1,17 @@ +package com.rj.common; + +public final class QiWeiApiConstants { + + private QiWeiApiConstants() { + } + + public static final String BASE_CGI_BIN = "https://qyapi.weixin.qq.com/cgi-bin"; + + public static final String GET_TOKEN = BASE_CGI_BIN + "/gettoken"; + public static final String USER_LIST_ID = BASE_CGI_BIN + "/user/list_id"; + public static final String USER_GET = BASE_CGI_BIN + "/user/get"; + public static final String DEPARTMENT_LIST = BASE_CGI_BIN + "/department/list"; + public static final String EXTERNAL_CONTACT_LIST = BASE_CGI_BIN + "/externalcontact/list"; + public static final String EXTERNAL_CONTACT_GET = BASE_CGI_BIN + "/externalcontact/get"; + public static final String EXTERNAL_CONTACT_FOLLOW_USER_LIST = BASE_CGI_BIN + "/externalcontact/get_follow_user_list"; +} diff --git a/src/main/java/com/rj/controller/DailyUserTradeController.java b/src/main/java/com/rj/controller/DailyUserTradeController.java new file mode 100644 index 0000000..5d1a7ba --- /dev/null +++ b/src/main/java/com/rj/controller/DailyUserTradeController.java @@ -0,0 +1,230 @@ +package com.rj.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.rj.entity.DailyUserTrade; +import com.rj.service.IDailyUserTradeService; +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.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + *

+ * 当天用户交易表 前端控制器 + *

+ */ +@RestController +@RequestMapping("/api/dailyUserTrade") +@Tag(name = "当天用户交易", description = "当天用户交易相关接口") +public class DailyUserTradeController { + + @Autowired + private IDailyUserTradeService dailyUserTradeService; + + @PostMapping("/add") + @Operation(summary = "新增交易记录", description = "新增当天用户交易记录") + public ResponseEntity> addDailyUserTrade( + @Parameter(description = "当天用户交易信息", required = true) + @RequestBody DailyUserTrade dailyUserTrade) { + Map result = new HashMap<>(); + try { + if (dailyUserTrade.getId() == null || dailyUserTrade.getId().trim().isEmpty()) { + dailyUserTrade.setId(UUID.randomUUID().toString()); + } + if (dailyUserTrade.getDailySellAmt() == null) { + dailyUserTrade.setDailySellAmt(BigDecimal.ZERO); + } + if (dailyUserTrade.getDailyBuyAmt() == null) { + dailyUserTrade.setDailyBuyAmt(BigDecimal.ZERO); + } + if (dailyUserTrade.getDiffAmt() == null) { + dailyUserTrade.setDiffAmt(dailyUserTrade.getDailySellAmt().subtract(dailyUserTrade.getDailyBuyAmt())); + } + dailyUserTrade.setCreatedAt(LocalDateTime.now()); + dailyUserTrade.setUpdatedAt(LocalDateTime.now()); + + boolean success = dailyUserTradeService.save(dailyUserTrade); + if (success) { + result.put("success", true); + result.put("message", "新增成功"); + result.put("data", dailyUserTrade); + return ResponseEntity.ok(result); + } + result.put("success", false); + result.put("message", "新增失败"); + return ResponseEntity.badRequest().body(result); + } catch (Exception e) { + result.put("success", false); + result.put("message", "新增异常:" + e.getMessage()); + return ResponseEntity.internalServerError().body(result); + } + } + + @GetMapping("/get/{id}") + @Operation(summary = "根据ID查询", description = "根据ID查询当天用户交易记录") + public ResponseEntity> getById( + @Parameter(description = "主键ID", required = true) + @PathVariable String id) { + Map result = new HashMap<>(); + try { + DailyUserTrade data = dailyUserTradeService.getById(id); + if (data == null) { + result.put("success", false); + result.put("message", "记录不存在"); + return ResponseEntity.badRequest().body(result); + } + result.put("success", true); + result.put("message", "查询成功"); + result.put("data", data); + return ResponseEntity.ok(result); + } catch (Exception e) { + result.put("success", false); + result.put("message", "查询异常:" + e.getMessage()); + return ResponseEntity.internalServerError().body(result); + } + } + + @GetMapping("/list") + @Operation(summary = "分页条件查询", description = "分页查询当天用户交易记录") + public ResponseEntity> list( + @Parameter(description = "页码", example = "1") + @RequestParam(defaultValue = "1") Integer current, + @Parameter(description = "每页大小", example = "10") + @RequestParam(defaultValue = "10") Integer size, + @Parameter(description = "租户ID") + @RequestParam(required = false) String tenantId, + @Parameter(description = "用户ID") + @RequestParam(required = false) String userId, + @Parameter(description = "昵称(模糊查询)") + @RequestParam(required = false) String nickname, + @Parameter(description = "推广人ID") + @RequestParam(required = false) String promoterId, + @Parameter(description = "推广人(模糊查询)") + @RequestParam(required = false) String promoterName, + @Parameter(description = "创建开始时间,格式:yyyy-MM-dd HH:mm:ss") + @RequestParam(required = false) String createdStartTime, + @Parameter(description = "创建结束时间,格式:yyyy-MM-dd HH:mm:ss") + @RequestParam(required = false) String createdEndTime) { + Map result = new HashMap<>(); + try { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + if (tenantId != null && !tenantId.trim().isEmpty()) { + queryWrapper.eq(DailyUserTrade::getTenantId, tenantId); + } + if (userId != null && !userId.trim().isEmpty()) { + queryWrapper.eq(DailyUserTrade::getUserId, userId); + } + if (nickname != null && !nickname.trim().isEmpty()) { + queryWrapper.like(DailyUserTrade::getNickname, nickname); + } + if (promoterId != null && !promoterId.trim().isEmpty()) { + queryWrapper.eq(DailyUserTrade::getPromoterId, promoterId); + } + if (promoterName != null && !promoterName.trim().isEmpty()) { + queryWrapper.like(DailyUserTrade::getPromoterName, promoterName); + } + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + if (createdStartTime != null && !createdStartTime.trim().isEmpty()) { + try { + queryWrapper.ge(DailyUserTrade::getCreatedAt, LocalDateTime.parse(createdStartTime, formatter)); + } catch (Exception e) { + result.put("success", false); + result.put("message", "创建开始时间格式错误,请使用 yyyy-MM-dd HH:mm:ss"); + return ResponseEntity.badRequest().body(result); + } + } + if (createdEndTime != null && !createdEndTime.trim().isEmpty()) { + try { + queryWrapper.le(DailyUserTrade::getCreatedAt, LocalDateTime.parse(createdEndTime, formatter)); + } catch (Exception e) { + result.put("success", false); + result.put("message", "创建结束时间格式错误,请使用 yyyy-MM-dd HH:mm:ss"); + return ResponseEntity.badRequest().body(result); + } + } + + queryWrapper.orderByDesc(DailyUserTrade::getCreatedAt); + Page page = dailyUserTradeService.page(new Page<>(current, size), queryWrapper); + + result.put("success", true); + result.put("message", "查询成功"); + result.put("data", page.getRecords()); + result.put("total", page.getTotal()); + result.put("current", page.getCurrent()); + result.put("size", page.getSize()); + result.put("pages", page.getPages()); + return ResponseEntity.ok(result); + } catch (Exception e) { + result.put("success", false); + result.put("message", "查询异常:" + e.getMessage()); + return ResponseEntity.internalServerError().body(result); + } + } + + @PutMapping("/update") + @Operation(summary = "修改交易记录", description = "根据ID修改当天用户交易记录") + public ResponseEntity> update( + @Parameter(description = "当天用户交易信息", required = true) + @RequestBody DailyUserTrade dailyUserTrade) { + Map result = new HashMap<>(); + try { + if (dailyUserTrade.getId() == null || dailyUserTrade.getId().trim().isEmpty()) { + result.put("success", false); + result.put("message", "ID不能为空"); + return ResponseEntity.badRequest().body(result); + } + if (dailyUserTrade.getDailySellAmt() != null && dailyUserTrade.getDailyBuyAmt() != null) { + dailyUserTrade.setDiffAmt(dailyUserTrade.getDailySellAmt().subtract(dailyUserTrade.getDailyBuyAmt())); + } + dailyUserTrade.setUpdatedAt(LocalDateTime.now()); + boolean success = dailyUserTradeService.updateById(dailyUserTrade); + if (success) { + result.put("success", true); + result.put("message", "修改成功"); + result.put("data", dailyUserTrade); + return ResponseEntity.ok(result); + } + result.put("success", false); + result.put("message", "修改失败"); + return ResponseEntity.badRequest().body(result); + } catch (Exception e) { + result.put("success", false); + result.put("message", "修改异常:" + e.getMessage()); + return ResponseEntity.internalServerError().body(result); + } + } + + @DeleteMapping("/delete/{id}") + @Operation(summary = "删除交易记录", description = "根据ID删除当天用户交易记录") + public ResponseEntity> delete( + @Parameter(description = "主键ID", required = true) + @PathVariable String id) { + Map result = new HashMap<>(); + try { + boolean success = dailyUserTradeService.removeById(id); + if (success) { + result.put("success", true); + result.put("message", "删除成功"); + return ResponseEntity.ok(result); + } + result.put("success", false); + result.put("message", "删除失败"); + return ResponseEntity.badRequest().body(result); + } catch (Exception e) { + result.put("success", false); + result.put("message", "删除异常:" + e.getMessage()); + 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 index 8e4be7e..2b3c717 100644 --- a/src/main/java/com/rj/controller/QweiUserController.java +++ b/src/main/java/com/rj/controller/QweiUserController.java @@ -83,5 +83,17 @@ public class QweiUserController { } return ResponseEntity.badRequest().body(result); } + + @PostMapping("/syncCustomer") + @Operation(summary = "同步企微客户并入库") + public ResponseEntity> syncCustomer( + @RequestParam(required = false) String tenantId) { + Map result = qweiUserService.syncCustomerFromQiWei(tenantId); + 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/DailyUserTrade.java b/src/main/java/com/rj/entity/DailyUserTrade.java new file mode 100644 index 0000000..b47f868 --- /dev/null +++ b/src/main/java/com/rj/entity/DailyUserTrade.java @@ -0,0 +1,74 @@ +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.math.BigDecimal; +import java.time.LocalDateTime; + +/** + *

+ * 当天用户交易表 + *

+ */ +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("lb_daily_user_trade") +@Schema(description = "当天用户交易表") +public class DailyUserTrade implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(description = "表ID(UUID)") + @TableId("id") + private String id; + + @Schema(description = "租户ID") + @TableField("tenant_id") + private String tenantId; + + @Schema(description = "用户ID") + @TableField("user_id") + private String userId; + + @Schema(description = "昵称") + @TableField("nickname") + private String nickname; + + @Schema(description = "单日卖出金额") + @TableField("daily_sell_amt") + private BigDecimal dailySellAmt; + + @Schema(description = "当日买入金额") + @TableField("daily_buy_amt") + private BigDecimal dailyBuyAmt; + + @Schema(description = "差额") + @TableField("diff_amt") + private BigDecimal diffAmt; + + @Schema(description = "推广人ID") + @TableField("promoter_id") + private String promoterId; + + @Schema(description = "推广人") + @TableField("promoter_name") + private String promoterName; + + @Schema(description = "描述") + @TableField("desc_content") + private String descContent; + + @Schema(description = "插入时间") + @TableField("created_at") + private LocalDateTime createdAt; + + @Schema(description = "修改时间") + @TableField("updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/rj/mapper/DailyUserTradeMapper.java b/src/main/java/com/rj/mapper/DailyUserTradeMapper.java new file mode 100644 index 0000000..fa6e504 --- /dev/null +++ b/src/main/java/com/rj/mapper/DailyUserTradeMapper.java @@ -0,0 +1,13 @@ +package com.rj.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.rj.entity.DailyUserTrade; + +/** + *

+ * 当天用户交易表 Mapper 接口 + *

+ */ +public interface DailyUserTradeMapper extends BaseMapper { + +} diff --git a/src/main/java/com/rj/service/IDailyUserTradeService.java b/src/main/java/com/rj/service/IDailyUserTradeService.java new file mode 100644 index 0000000..71c0979 --- /dev/null +++ b/src/main/java/com/rj/service/IDailyUserTradeService.java @@ -0,0 +1,13 @@ +package com.rj.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.rj.entity.DailyUserTrade; + +/** + *

+ * 当天用户交易表 服务类 + *

+ */ +public interface IDailyUserTradeService extends IService { + +} diff --git a/src/main/java/com/rj/service/IQweiUserService.java b/src/main/java/com/rj/service/IQweiUserService.java index 556898f..5453805 100644 --- a/src/main/java/com/rj/service/IQweiUserService.java +++ b/src/main/java/com/rj/service/IQweiUserService.java @@ -25,5 +25,11 @@ public interface IQweiUserService extends IService { } Map syncFromQiWei(String tenantId); + + default Map syncCustomerFromQiWei() { + return syncCustomerFromQiWei(null); + } + + Map syncCustomerFromQiWei(String tenantId); } diff --git a/src/main/java/com/rj/service/impl/DailyUserTradeServiceImpl.java b/src/main/java/com/rj/service/impl/DailyUserTradeServiceImpl.java new file mode 100644 index 0000000..24c4ec1 --- /dev/null +++ b/src/main/java/com/rj/service/impl/DailyUserTradeServiceImpl.java @@ -0,0 +1,17 @@ +package com.rj.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.rj.entity.DailyUserTrade; +import com.rj.mapper.DailyUserTradeMapper; +import com.rj.service.IDailyUserTradeService; +import org.springframework.stereotype.Service; + +/** + *

+ * 当天用户交易表 服务实现类 + *

+ */ +@Service +public class DailyUserTradeServiceImpl extends ServiceImpl implements IDailyUserTradeService { + +} diff --git a/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java b/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java index 6e814bd..b70f2fa 100644 --- a/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java +++ b/src/main/java/com/rj/service/impl/QweiDepartmentServiceImpl.java @@ -7,6 +7,7 @@ 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.common.QiWeiApiConstants; import com.rj.dto.QiWeiConfig; import com.rj.entity.DictItem; import com.rj.entity.QweiDepartment; @@ -305,7 +306,7 @@ public class QweiDepartmentServiceImpl extends ServiceImpl q = new LambdaQueryWrapper() .eq(QweiUser::getUserid, user.getUserid()); @@ -321,7 +390,7 @@ public class QweiUserServiceImpl extends ServiceImpl i } private QweiUser fetchAndMapUser(String appToken, String userid, String tenantId) throws Exception { - String url = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=" + String url = QiWeiApiConstants.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(); @@ -355,8 +424,167 @@ public class QweiUserServiceImpl extends ServiceImpl i return user; } + private SyncCounter syncCustomerByFollowUser(ExternalContactUserApi externalContactUserApi, + String contactToken, + String followUserId, + String tenantId) throws Exception { + SyncCounter counter = new SyncCounter(); + JsonNode listJson = fetchExternalContactList(externalContactUserApi, followUserId); + JsonNode externalUserIds = listJson.path("external_userid"); + if (!externalUserIds.isArray()) { + return counter; + } + for (JsonNode externalUserNode : externalUserIds) { + String externalUserId = trimToNull(externalUserNode.asText(null)); + if (externalUserId == null) { + continue; + } + JsonNode externalDetail = null; + try { + externalDetail = fetchExternalContactDetail(contactToken, externalUserId); + } catch (IllegalStateException ex) { + if (isNoExternalContactRelationError(ex.getMessage())) { + // 官方建议:关系不存在(84061)时应跳过该客户,继续处理后续数据 + System.out.println("[QiWeiCustomerSync] skip external user due to no relation. externalUserId=" + + externalUserId + ", followUserId=" + followUserId + ", reason=" + ex.getMessage()); + continue; + } + } + printExternalCustomerToConsole(externalDetail, followUserId); + CustomerManagement customer = mapToCustomerManagement(externalDetail, followUserId, tenantId); + if (customer == null) { + continue; + } + if (upsertCustomer(customer, tenantId)) { + counter.inserted++; + } else { + counter.updated++; + } + } + return counter; + } + + private JsonNode fetchExternalContactList(ExternalContactUserApi externalContactUserApi, String followUserId) throws Exception { + Object sdkResp = externalContactUserApi.listByUserId(followUserId); + JsonNode json = OBJECT_MAPPER.valueToTree(sdkResp); + int errCode = json.path("errcode").asInt(0); + if (errCode != 0) { + throw new IllegalStateException("获取外部联系人列表失败: " + json); + } + if (!json.has("external_userid") && json.has("data")) { + ObjectNode adapted = OBJECT_MAPPER.createObjectNode(); + adapted.set("external_userid", json.path("data")); + return adapted; + } + return json; + } + + private void printExternalCustomerToConsole(JsonNode externalDetail, String followUserId) { + JsonNode externalContact = externalDetail.path("external_contact"); + String externalUserId = trimToNull(externalContact.path("external_userid").asText(null)); + String customerName = trimToNull(externalContact.path("name").asText(null)); + String phone = null; + JsonNode followInfo = externalDetail.path("follow_user"); + if (followInfo.isArray() && followInfo.size() > 0) { + JsonNode firstFollow = followInfo.get(0); + JsonNode remarkMobiles = firstFollow.path("remark_mobiles"); + if (remarkMobiles.isArray() && remarkMobiles.size() > 0) { + phone = trimToNull(remarkMobiles.get(0).asText(null)); + } + } + System.out.println("[QiWeiCustomerSync] externalUserId=" + (externalUserId == null ? "" : externalUserId) + + ", customerName=" + (customerName == null ? "" : customerName) + + ", phone=" + (phone == null ? "" : phone) + + ", followUserId=" + (followUserId == null ? "" : followUserId)); + } + + private JsonNode fetchExternalContactDetail(String contactToken, String externalUserId) throws Exception { + String url = QiWeiApiConstants.EXTERNAL_CONTACT_GET + "?access_token=" + + URLEncoder.encode(contactToken, StandardCharsets.UTF_8) + + "&external_userid=" + URLEncoder.encode(externalUserId, 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); + } + return json; + } + + private CustomerManagement mapToCustomerManagement(JsonNode externalDetail, String followUserId, String tenantId) { + JsonNode externalContact = externalDetail.path("external_contact"); + if (externalContact.isMissingNode() || externalContact.isNull()) { + return null; + } + String name = trimToNull(externalContact.path("name").asText(null)); + if (name == null) { + return null; + } + String phone = null; + JsonNode followInfo = externalDetail.path("follow_user"); + if (followInfo.isArray() && followInfo.size() > 0) { + JsonNode firstFollow = followInfo.get(0); + JsonNode remarkMobiles = firstFollow.path("remark_mobiles"); + if (remarkMobiles.isArray() && remarkMobiles.size() > 0) { + phone = trimToNull(remarkMobiles.get(0).asText(null)); + } + } + CustomerManagement customer = new CustomerManagement(); + customer.setTenantId(tenantId); + customer.setCustomerName(name); + customer.setContact(phone); + customer.setCustomerSource("QIWEI"); + customer.setSalesId(followUserId); + customer.setSalesPhone(followUserId); + + LambdaQueryWrapper salesQuery = new LambdaQueryWrapper() + .eq(QweiUser::getUserid, followUserId); + QweiUser sales = this.getOne(salesQuery, false); + if (sales != null) { + customer.setSalesName(sales.getUserName()); + customer.setSalesPhone(trimToNull(sales.getMobile()) == null ? followUserId : sales.getMobile()); + customer.setDealershipId(sales.getMainDepartmentId() == null ? null : String.valueOf(sales.getMainDepartmentId())); + } + return customer; + } + + private boolean upsertCustomer(CustomerManagement customer, String tenantId) { + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() + .eq(CustomerManagement::getCustomerName, customer.getCustomerName()); + if (trimToNull(customer.getContact()) != null) { + queryWrapper.eq(CustomerManagement::getContact, customer.getContact()); + } + CustomerManagement existing = customerManagementService.getOne(queryWrapper, false); + LocalDateTime now = LocalDateTime.now(); + if (existing == null) { + customer.setId(UUID.randomUUID().toString()); + customer.setTenantId(tenantId); + customer.setRecordingCount(0); + customer.setContactCount(0); + customer.setCreateTime(now); + customer.setUpdateTime(now); + customerManagementService.save(customer); + return true; + } + + existing.setTenantId(null); + existing.setCustomerName(customer.getCustomerName()); + if (trimToNull(customer.getContact()) != null) { + existing.setContact(customer.getContact()); + } + existing.setSalesId(customer.getSalesId()); + existing.setSalesName(customer.getSalesName()); + existing.setSalesPhone(customer.getSalesPhone()); + existing.setDealershipId(customer.getDealershipId()); + existing.setCustomerSource("QIWEI"); + existing.setUpdateTime(now); + customerManagementService.updateById(existing); + return false; + } + 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=" + String url = QiWeiApiConstants.USER_LIST_ID + "?access_token=" + URLEncoder.encode(contactToken, StandardCharsets.UTF_8); String payload = OBJECT_MAPPER.writeValueAsString(Map.of( "cursor", cursor == null ? "" : cursor, @@ -375,6 +603,34 @@ public class QweiUserServiceImpl extends ServiceImpl i return json; } + private JsonNode fetchCustomerContactFollowUsers(String accessToken) throws Exception { + String url = QiWeiApiConstants.EXTERNAL_CONTACT_FOLLOW_USER_LIST + "?access_token=" + + URLEncoder.encode(accessToken, 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); + } + return json.path("follow_user"); + } + + private void printFollowUsersToConsole(JsonNode followUsers) { + if (followUsers == null || !followUsers.isArray()) { + System.out.println("[QiWeiCustomerSync] follow_user list is empty or invalid."); + return; + } + System.out.println("[QiWeiCustomerSync] follow_user count=" + followUsers.size()); + for (JsonNode followUser : followUsers) { + String followUserId = trimToNull(followUser.asText(null)); + if (followUserId == null) { + continue; + } + System.out.println("[QiWeiCustomerSync] followUserId=" + followUserId); + } + } + private QiWeiConfig loadQiWeiConfig() throws Exception { String corpid = trimToNull(System.getProperty("qiwei.corpid")); String contactSecret = trimToNull(System.getProperty("qiwei.contactSecret")); @@ -407,7 +663,7 @@ public class QweiUserServiceImpl extends ServiceImpl i } private String getAccessToken(String corpId, String secret) throws Exception { - String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + String url = QiWeiApiConstants.GET_TOKEN + "?corpid=" + URLEncoder.encode(corpId, StandardCharsets.UTF_8) + "&corpsecret=" + URLEncoder.encode(secret, StandardCharsets.UTF_8); HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); @@ -427,6 +683,31 @@ public class QweiUserServiceImpl extends ServiceImpl i return value.trim(); } + private String resolveExternalContactSecret(QiWeiConfig cfg) { + String fromProp = trimToNull(System.getProperty("qiwei.externalContactSecret")); + if (fromProp != null) { + return fromProp; + } + String appSecret = trimToNull(cfg.getAppSecret()); + if (appSecret != null) { + return appSecret; + } + String contactSecret = trimToNull(cfg.getContractSecret()); + if (contactSecret != null) { + return contactSecret; + } + throw new IllegalStateException("外部联系人secret不能为空(请配置 -Dqiwei.externalContactSecret 或 qiwei_config.appSecret/contractSecret)"); + } + + private boolean isNoExternalContactRelationError(String message) { + if (message == null) { + return false; + } + return message.contains("\"errcode\":84061") + || message.contains("errcode\":84061") + || message.contains("不存在外部联系人的关系"); + } + private boolean isDuplicateUserid(Exception e) { String msg = e.getMessage(); return msg != null && msg.contains("Duplicate entry"); @@ -455,5 +736,59 @@ public class QweiUserServiceImpl extends ServiceImpl i } throw new IllegalStateException("tenantId不能为空(请设置租户上下文或传入 tenantId 或 -DtenantId)"); } + + private ExternalContactUserApi createExternalContactUserApi(String corpId, String contactSecret) { + WeComTokenCacheable cacheable = new InMemoryWeComTokenCacheable(); + WorkWeChatApi workWeChatApi = new WorkWeChatApi(cacheable, HttpLoggingInterceptor.Level.NONE); + return workWeChatApi.externalContactManager(DefaultAgent.of(corpId, contactSecret, "0")) + .externalContactUserApi(); + } + + private static class SyncCounter { + private int inserted; + private int updated; + } + + 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); + } + } } diff --git a/src/main/resources/mapper/DailyUserTradeMapper.xml b/src/main/resources/mapper/DailyUserTradeMapper.xml new file mode 100644 index 0000000..0a880ba --- /dev/null +++ b/src/main/resources/mapper/DailyUserTradeMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + id, tenant_id, user_id, nickname, daily_sell_amt, daily_buy_amt, diff_amt, promoter_id, promoter_name, desc_content, created_at, updated_at + + + diff --git a/src/main/sql/lb_daily_user_trade.sql b/src/main/sql/lb_daily_user_trade.sql new file mode 100644 index 0000000..3c55ee3 --- /dev/null +++ b/src/main/sql/lb_daily_user_trade.sql @@ -0,0 +1,17 @@ +CREATE TABLE `lb_daily_user_trade` ( + `id` CHAR(36) NOT NULL COMMENT '表ID(UUID)', + `tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID', + `user_id` VARCHAR(64) NOT NULL COMMENT '用户ID', + `nickname` VARCHAR(100) DEFAULT NULL COMMENT '昵称', + `daily_sell_amt` DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '单日卖出金额', + `daily_buy_amt` DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '当日买入金额', + `diff_amt` DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '差额', + `promoter_id` VARCHAR(64) DEFAULT NULL COMMENT '推广人ID', + `promoter_name` VARCHAR(100) DEFAULT NULL COMMENT '推广人', + `desc_content` VARCHAR(512) DEFAULT NULL COMMENT '描述', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '插入时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + KEY `idx_tenant_user` (`tenant_id`, `user_id`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当天用户交易'; diff --git a/src/main/sql/qwei/dev.md b/src/main/sql/qwei/dev.md index e69de29..78bc3d0 100644 --- a/src/main/sql/qwei/dev.md +++ b/src/main/sql/qwei/dev.md @@ -0,0 +1,16 @@ + +分配客服会话 +https://developer.work.weixin.qq.com/document/path/94669 + +发送消息 +https://developer.work.weixin.qq.com/document/path/94677 +接收消息和事件 +https://developer.work.weixin.qq.com/document/path/94670 + + + +https://developer.work.weixin.qq.com/document/path/94638 + + + + diff --git a/src/test/java/com/rj/controller/QweiUserControllerIntegrationTest.java b/src/test/java/com/rj/controller/QweiUserControllerIntegrationTest.java new file mode 100644 index 0000000..8c5a671 --- /dev/null +++ b/src/test/java/com/rj/controller/QweiUserControllerIntegrationTest.java @@ -0,0 +1,46 @@ +package com.rj.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class QweiUserControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void syncCustomerShouldFetchFromQiWeiAndPrintToConsole() throws Exception { + String tenantId = System.getProperty("tenantId", "TENANT_ID_CST_2026"); + var requestBuilder = post("/api/qweiUser/syncCustomer") + .param("tenantId", tenantId); + + MvcResult mvcResult = mockMvc.perform(requestBuilder).andReturn(); + + int statusCode = mvcResult.getResponse().getStatus(); + String responseBody = mvcResult.getResponse().getContentAsString(); + System.out.println("qwei customer sync status: " + statusCode); + System.out.println("qwei customer sync response: " + responseBody); + + assertTrue(responseBody.contains("\"success\""), "响应缺少success字段: " + responseBody); + if (statusCode == HttpStatus.OK.value()) { + assertTrue(responseBody.contains("\"success\":true"), "同步失败: " + responseBody); + return; + } + + assertTrue(statusCode == HttpStatus.BAD_REQUEST.value(), "非预期状态码: " + statusCode + ", body=" + responseBody); + assertTrue(responseBody.contains("48002") || responseBody.contains("api forbidden"), + "当前失败不是企微权限问题,请排查: " + responseBody); + } +} diff --git a/src/test/java/com/rj/controller/QweiUserControllerTest.java b/src/test/java/com/rj/controller/QweiUserControllerTest.java new file mode 100644 index 0000000..c39276d --- /dev/null +++ b/src/test/java/com/rj/controller/QweiUserControllerTest.java @@ -0,0 +1,81 @@ +package com.rj.controller; + +import com.rj.service.IQweiUserService; +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") +class QweiUserControllerTest { + + @Autowired + private WebApplicationContext webApplicationContext; + + @MockitoBean + private IQweiUserService qweiUserService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @Test + void syncCustomerShouldReturnOkWhenServiceSuccess() throws Exception { + Map serviceResult = new HashMap<>(); + serviceResult.put("success", true); + serviceResult.put("message", "同步企微客户成功"); + serviceResult.put("inserted", 2); + serviceResult.put("updated", 3); + serviceResult.put("total", 5); + when(qweiUserService.syncCustomerFromQiWei("TENANT_TEST")).thenReturn(serviceResult); + + mockMvc.perform(post("/api/qweiUser/syncCustomer") + .param("tenantId", "TENANT_TEST") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.inserted").value(2)) + .andExpect(jsonPath("$.updated").value(3)) + .andExpect(jsonPath("$.total").value(5)); + + verify(qweiUserService, times(1)).syncCustomerFromQiWei("TENANT_TEST"); + } + + @Test + void syncCustomerShouldReturnBadRequestWhenServiceFailed() throws Exception { + Map serviceResult = new HashMap<>(); + serviceResult.put("success", false); + serviceResult.put("message", "同步企微客户异常:测试错误"); + when(qweiUserService.syncCustomerFromQiWei("TENANT_TEST")).thenReturn(serviceResult); + + mockMvc.perform(post("/api/qweiUser/syncCustomer") + .param("tenantId", "TENANT_TEST") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + + verify(qweiUserService, times(1)).syncCustomerFromQiWei("TENANT_TEST"); + } +} diff --git a/src/test/java/com/rj/qiwei/WeComCustomerReadTest.java b/src/test/java/com/rj/qiwei/WeComCustomerReadTest.java deleted file mode 100644 index e69de29..0000000