From 624ec1b7c4a116b05b9ba2c83a376703140d16f6 Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Mon, 14 Sep 2026 20:26:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E8=B4=A6=E6=94=AF=E6=8C=81=E4=BC=81?= =?UTF-8?q?=E5=BE=AE=E9=80=9A=E8=AE=AF=E5=BD=95=E5=90=8C=E6=AD=A5=E4=BA=BA?= =?UTF-8?q?=E5=91=98=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E4=B8=8E=E5=90=8E=E5=8F=B0=E4=BA=BA=E5=91=98?= =?UTF-8?q?/=E5=B7=A5=E5=8D=95=E9=A1=B5=E8=81=94=E8=B0=83=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../InquiryStaffRoleController.java | 48 ++- .../inquiry/dto/StaffWecomSyncResult.java | 28 ++ .../service/IInquiryStaffRoleService.java | 11 + .../impl/InquiryStaffRoleServiceImpl.java | 82 ++++- .../impl/InquiryWorkOrderServiceImpl.java | 60 +++- .../support/InquiryTicketDisplaySupport.java | 222 ++++++++++++++ .../inquiry/wecom/WeComAddressBookClient.java | 287 ++++++++++++++++++ .../inquiry/wecom/WeComDepartment.java | 36 +++ .../modules/inquiry/wecom/WeComMember.java | 48 +++ .../InquiryTicketDisplaySupportTest.java | 64 ++++ .../src/views/inquiry/staffRole/index.vue | 134 +++++++- .../views/inquiry/staffRole/staffRole.api.ts | 8 +- .../src/views/inquiry/workOrder/index.vue | 187 ++++++++++-- 13 files changed, 1173 insertions(+), 42 deletions(-) create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/StaffWecomSyncResult.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComAddressBookClient.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComDepartment.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComMember.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java index afcc884..31207ac 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryStaffRoleController.java @@ -11,21 +11,28 @@ import org.apache.shiro.authz.annotation.RequiresPermissions; import org.jeecg.common.api.vo.Result; import org.jeecg.common.aspect.annotation.AutoLog; import org.jeecg.common.system.base.controller.JeecgController; +import org.apache.shiro.SecurityUtils; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult; import org.jeecg.modules.inquiry.entity.InquiryStaffRole; import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService; +import org.jeecg.modules.inquiry.wecom.WeComAddressBookClient; +import org.jeecg.modules.inquiry.wecom.WeComDepartment; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; /** * 员工岗位管理 API。 *

* 路径前缀 /inquiry/staffRole;权限码 inquiry:staff:*。 - * 不做企微换号,wecom_id 由运营手工绑定。 + * wecom_id 可手工维护,也可经 /syncWecom 从企微通讯录 upsert(姓名/手机/部门)。 + * 浏览器禁止直连企微;同步只服务展示,不挡询价主路径。 */ @Slf4j @Api(tags = "询价-员工岗位") @@ -36,6 +43,9 @@ public class InquiryStaffRoleController extends JeecgController ROLE_LABEL = new HashMap<>(); static { @@ -122,6 +132,42 @@ public class InquiryStaffRoleController extends JeecgController syncWecom() { + String operator = currentUsername(); + StaffWecomSyncResult result = staffRoleService.syncFromWeCom(operator); + return Result.OK(result.getMessage(), result); + } + + /** + * 企微部门下拉选项。浏览器禁止直连企微,只打本接口。 + */ + @ApiOperation("企微部门列表") + @RequiresPermissions("inquiry:staff:list") + @GetMapping("/wecomDepartments") + public Result> wecomDepartments() { + return Result.OK(weComAddressBookClient.listDepartments()); + } + + private String currentUsername() { + try { + Object principal = SecurityUtils.getSubject().getPrincipal(); + if (principal instanceof LoginUser) { + return ((LoginUser) principal).getUsername(); + } + } catch (Exception ignored) { + // 无登录主体时用系统标记 + } + return "system"; + } + private void normalize(InquiryStaffRole body) { if (StringUtils.isBlank(body.getName()) || StringUtils.isBlank(body.getWecomId())) { throw new IllegalArgumentException("姓名与企微 userid 不能为空"); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/StaffWecomSyncResult.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/StaffWecomSyncResult.java new file mode 100644 index 0000000..ad9f278 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/StaffWecomSyncResult.java @@ -0,0 +1,28 @@ +package org.jeecg.modules.inquiry.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +/** + * 企微通讯录同步结果(给后台 toast / 日志)。 + */ +@Data +@ApiModel("员工企微同步结果") +public class StaffWecomSyncResult { + + @ApiModelProperty("企微侧已激活成员数") + private int fetched; + + @ApiModelProperty("新建条数") + private int created; + + @ApiModelProperty("更新条数") + private int updated; + + @ApiModelProperty("跳过条数(无 userid 等)") + private int skipped; + + @ApiModelProperty("摘要文案") + private String message; +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java index 52d3542..6794c80 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java @@ -1,6 +1,7 @@ package org.jeecg.modules.inquiry.service; import com.baomidou.mybatisplus.extension.service.IService; +import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult; import org.jeecg.modules.inquiry.entity.InquiryStaffRole; import java.util.Map; @@ -19,4 +20,14 @@ public interface IInquiryStaffRoleService extends IService { * 停用员工(status=disabled),不物理删除。 */ void disable(String id); + + /** + * 从企微通讯录全量 upsert:按 wecom_id 更新姓名/手机/部门,缺失则新建(默认销售岗)。 + *

+ * 不删、不停用企微侧消失的人;不覆盖已有 roleCode / routes / 提醒开关。 + * + * @param operator 操作人(写入 createBy/updateBy) + * @return 同步计数 + */ + StaffWecomSyncResult syncFromWeCom(String operator); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java index 3186faa..ad49692 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java @@ -2,9 +2,15 @@ package org.jeecg.modules.inquiry.service.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult; import org.jeecg.modules.inquiry.entity.InquiryStaffRole; import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper; import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService; +import org.jeecg.modules.inquiry.wecom.WeComAddressBookClient; +import org.jeecg.modules.inquiry.wecom.WeComMember; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,15 +20,25 @@ import java.util.List; import java.util.Map; /** - * 员工岗位 CRUD 实现。 + * 员工岗位 CRUD + 企微通讯录 upsert。 *

- * 线程:跟随 HTTP 请求线程;无全局锁。wecom_id 唯一约束由库保证。 + * 线程:跟随 HTTP 请求线程;同步方法串行化,避免并发双写同一 wecom_id。 + * 无全局工单大锁;wecom_id 唯一约束由库保证。 */ +@Slf4j @Service public class InquiryStaffRoleServiceImpl extends ServiceImpl implements IInquiryStaffRoleService { private static final String[] ROLE_CODES = {"sales", "air", "sea", "land"}; + private static final String DEFAULT_ROLE = "sales"; + private static final String DEFAULT_ROLE_LABEL = "销售"; + + @Autowired + private WeComAddressBookClient weComAddressBookClient; + + /** 同步互斥:同一进程内禁止两路并发全量 upsert。 */ + private final Object syncLock = new Object(); @Override public Map countByRole() { @@ -54,4 +70,66 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl().eq("wecom_id", wecomId).last("LIMIT 1"), false); } + + @Override + @Transactional(rollbackFor = Exception.class) + public StaffWecomSyncResult syncFromWeCom(String operator) { + synchronized (syncLock) { + List members = weComAddressBookClient.listActiveMembers(); + int created = 0; + int updated = 0; + int skipped = 0; + Date now = new Date(); + String op = StringUtils.defaultIfBlank(operator, "wecom-sync"); + + for (WeComMember m : members) { + if (m == null || StringUtils.isBlank(m.getUserid())) { + skipped++; + continue; + } + InquiryStaffRole existing = findByWecomId(m.getUserid()); + if (existing == null) { + InquiryStaffRole row = new InquiryStaffRole(); + row.setWecomId(m.getUserid()); + row.setName(StringUtils.defaultIfBlank(m.getName(), m.getUserid())); + row.setMobile(StringUtils.trimToNull(m.getMobile())); + row.setDepartName(StringUtils.trimToNull(m.getDepartmentName())); + row.setRoleCode(DEFAULT_ROLE); + row.setRoleLabel(DEFAULT_ROLE_LABEL); + row.setNotifyEventException(0); + row.setStatus("active"); + row.setCreateBy(op); + row.setCreateTime(now); + row.setUpdateBy(op); + row.setUpdateTime(now); + save(row); + created++; + } else { + // 只刷新展示字段;岗位/线路/提醒开关保留运营手工配置 + existing.setName(StringUtils.defaultIfBlank(m.getName(), existing.getName())); + if (StringUtils.isNotBlank(m.getMobile())) { + existing.setMobile(m.getMobile()); + } + if (StringUtils.isNotBlank(m.getDepartmentName())) { + existing.setDepartName(m.getDepartmentName()); + } + existing.setUpdateBy(op); + existing.setUpdateTime(now); + updateById(existing); + updated++; + } + } + + StaffWecomSyncResult result = new StaffWecomSyncResult(); + result.setFetched(members.size()); + result.setCreated(created); + result.setUpdated(updated); + result.setSkipped(skipped); + result.setMessage(String.format( + "同步完成:企微成员 %d,新建 %d,更新 %d,跳过 %d", + members.size(), created, updated, skipped)); + log.info("企微通讯录同步 operator={} {}", op, result.getMessage()); + return result; + } + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java index 585d85e..a0ad7f9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java @@ -11,27 +11,35 @@ import org.jeecg.modules.inquiry.dto.TicketTransitionRequest; import org.jeecg.modules.inquiry.entity.InquiryAttachment; import org.jeecg.modules.inquiry.entity.InquiryExceptionCase; import org.jeecg.modules.inquiry.entity.InquiryQuote; +import org.jeecg.modules.inquiry.entity.InquiryStaffRole; import org.jeecg.modules.inquiry.entity.InquiryTicket; import org.jeecg.modules.inquiry.entity.InquiryTicketStatusLog; import org.jeecg.modules.inquiry.mapper.InquiryAttachmentMapper; import org.jeecg.modules.inquiry.mapper.InquiryExceptionCaseMapper; import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper; +import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper; import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper; import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper; import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService; +import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * 询价工单主账实现。 *

* 副作用:改 inq_ticket.status、写 inq_ticket_status_log、改 inq_exception_case。 + * 列表/详情会就地补展示字段(salesName、demandTitle/Detail),只影响响应,不写回库。 * 线程:单请求事务;只锁当前工单行(DB 行级),不串行其它工单。 * 为何:Graph/前端成功不能冒充主账六态成功,所有 transition 必须过本服务。 */ @@ -47,6 +55,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl pageView(Page page, Map filters) { @@ -55,6 +65,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl w.like(InquiryTicket::getWorkOrderNo, keyword) .or().like(InquiryTicket::getSalesName, keyword) + .or().like(InquiryTicket::getSalesWecomId, keyword) .or().like(InquiryTicket::getDemandTitle, keyword) .or().like(InquiryTicket::getOrigin, keyword) .or().like(InquiryTicket::getDestination, keyword)); @@ -91,7 +102,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl result = page(page, qw); + enrichDisplayFields(result.getRecords()); + return result; } @Override @@ -100,6 +113,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quotes = quoteMapper.selectList(new LambdaQueryWrapper() .eq(InquiryQuote::getWorkOrderNo, workOrderNo) .orderByAsc(InquiryQuote::getCreateTime)); @@ -122,6 +136,50 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl tickets) { + if (tickets == null || tickets.isEmpty()) { + return; + } + Map staffNameByWecom = loadStaffNames(tickets); + for (InquiryTicket ticket : tickets) { + if (ticket == null) { + continue; + } + String staffName = staffNameByWecom.get(StringUtils.trimToEmpty(ticket.getSalesWecomId())); + ticket.setSalesName(InquiryTicketDisplaySupport.resolveSalesDisplayName( + ticket.getSalesName(), ticket.getSalesWecomId(), staffName)); + InquiryTicketDisplaySupport.applyRequirementSummary(ticket); + } + } + + /** 批量按企微 userid 取员工姓名,避免列表 N+1。 */ + private Map loadStaffNames(List tickets) { + Set wecomIds = new HashSet<>(); + for (InquiryTicket ticket : tickets) { + if (ticket != null && StringUtils.isNotBlank(ticket.getSalesWecomId())) { + wecomIds.add(ticket.getSalesWecomId().trim()); + } + } + if (wecomIds.isEmpty()) { + return Collections.emptyMap(); + } + List staffRows = staffRoleMapper.selectList(new LambdaQueryWrapper() + .in(InquiryStaffRole::getWecomId, new ArrayList<>(wecomIds)) + .eq(InquiryStaffRole::getStatus, "active")); + Map result = new HashMap<>(); + for (InquiryStaffRole row : staffRows) { + if (row == null || StringUtils.isBlank(row.getWecomId()) || StringUtils.isBlank(row.getName())) { + continue; + } + result.put(row.getWecomId().trim(), row.getName().trim()); + } + return result; + } + @Override @Transactional(rollbackFor = Exception.class) public InquiryTicket transition(TicketTransitionRequest req, String operator) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java new file mode 100644 index 0000000..b90f448 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java @@ -0,0 +1,222 @@ +package org.jeecg.modules.inquiry.support; + +import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.inquiry.entity.InquiryTicket; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * 工单列表/详情的展示口径,对齐现网 InquiryAggregateService.requirementSummaryLines。 + *

+ * 职责:把结构化字段拼成「发起人可读名 + 询价需求多行摘要」,禁止把 FP:AIR|… 原文甩到后台。 + * 副作用:无写库;仅生成展示用字符串。 + * 为何:inq_ticket.sales_name / demand_* 可能为空或被写入管道原文,后台必须以员工表与字段快照为准。 + */ +public final class InquiryTicketDisplaySupport { + + private InquiryTicketDisplaySupport() { + } + + /** + * 询价需求多行摘要:首行标题(业务线 · 路线),后续为货量/品名。 + * 与旧后台 requirementSummaryLines 同口径,便于对照验收。 + */ + public static List requirementSummaryLines(InquiryTicket ticket) { + List lines = new ArrayList<>(); + if (ticket == null) { + return lines; + } + String businessLine = upper(ticket.getBusinessLine()); + + List heading = new ArrayList<>(); + String lineLabel = firstText(ticket.getBizTypeLabel(), businessLineLabel(ticket.getBusinessLine())); + if (StringUtils.isNotBlank(lineLabel)) { + heading.add(lineLabel); + } + String route = route(ticket.getOrigin(), ticket.getDestination()); + if (StringUtils.isNotBlank(route)) { + heading.add(route); + } + addLine(lines, join(heading, " · ")); + + List quantities = new ArrayList<>(); + if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) { + addLabel(quantities, "件数", ticket.getPieces()); + addUnit(quantities, "毛重", ticket.getWeightKg(), "kg"); + addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM"); + } else if ("SEA".equals(businessLine) || containsChinese(lineLabel, "海运")) { + addLabel(quantities, "件数", ticket.getPieces()); + addUnit(quantities, "重量", ticket.getWeightKg(), "kg"); + addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM"); + } else if ("LAND".equals(businessLine) || containsChinese(lineLabel, "陆运")) { + addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM"); + addUnit(quantities, "重量", ticket.getWeightKg(), "kg"); + addLabel(quantities, "件数", ticket.getPieces()); + } else { + addLabel(quantities, "件数", ticket.getPieces()); + addUnit(quantities, "重量", ticket.getWeightKg(), "kg"); + addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM"); + } + addLine(lines, join(quantities, " · ")); + + List cargo = new ArrayList<>(); + boolean land = "LAND".equals(businessLine) || containsChinese(lineLabel, "陆运"); + addLabel(cargo, land ? "货物品名" : "品名", ticket.getGoodsName()); + if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) { + addLabel(cargo, "客户", ticket.getCustomerName()); + } + addLine(lines, join(cargo, " · ")); + return lines; + } + + /** + * 把摘要写回 ticket 的 demandTitle/demandDetail,仅用于 API 响应,不落库。 + */ + public static void applyRequirementSummary(InquiryTicket ticket) { + if (ticket == null) { + return; + } + List lines = requirementSummaryLines(ticket); + if (lines.isEmpty()) { + // 结构化字段全空时,仍禁止把 FP: 管道原文当需求展示 + if (looksLikeRawFp(ticket.getDemandTitle())) { + ticket.setDemandTitle(null); + } + if (looksLikeRawFp(ticket.getDemandDetail())) { + ticket.setDemandDetail(null); + } + return; + } + ticket.setDemandTitle(lines.get(0)); + if (lines.size() == 1) { + ticket.setDemandDetail(""); + } else { + ticket.setDemandDetail(join(lines.subList(1, lines.size()), "\n")); + } + } + + /** + * 发起人展示名:优先已有中文名,其次员工表姓名;企微 userid 不能冒充人名。 + */ + public static String resolveSalesDisplayName(String salesName, String salesWecomId, String staffName) { + if (StringUtils.isNotBlank(salesName) && !looksLikeWecomUserId(salesName)) { + return salesName.trim(); + } + if (StringUtils.isNotBlank(staffName)) { + return staffName.trim(); + } + if (StringUtils.isNotBlank(salesName)) { + return salesName.trim(); + } + return StringUtils.trimToEmpty(salesWecomId); + } + + public static boolean looksLikeRawFp(String value) { + String raw = StringUtils.trimToEmpty(value); + return raw.startsWith("FP:") || raw.contains("|") && raw.contains(":"); + } + + /** 纯拼音/企微 userid(无中文)不当作「发起人名称」。 */ + public static boolean looksLikeWecomUserId(String value) { + String raw = StringUtils.trimToEmpty(value); + if (raw.isEmpty()) { + return true; + } + if (raw.chars().anyMatch(ch -> Character.UnicodeScript.of(ch) == Character.UnicodeScript.HAN)) { + return false; + } + return raw.matches("^[A-Za-z][A-Za-z0-9._-]{1,64}$"); + } + + private static String businessLineLabel(String value) { + if ("AIR".equalsIgnoreCase(value)) { + return "空运"; + } + if ("SEA".equalsIgnoreCase(value)) { + return "海运"; + } + if ("LAND".equalsIgnoreCase(value)) { + return "陆运"; + } + if ("MULTI".equalsIgnoreCase(value)) { + return "多段联运"; + } + return value; + } + + private static String route(String origin, String destination) { + if (StringUtils.isBlank(origin) && StringUtils.isBlank(destination)) { + return null; + } + return (StringUtils.isNotBlank(origin) ? origin.trim() : "-") + + " → " + + (StringUtils.isNotBlank(destination) ? destination.trim() : "-"); + } + + private static void addLine(List lines, String value) { + if (StringUtils.isNotBlank(value)) { + lines.add(value); + } + } + + private static void addLabel(List values, String label, Object value) { + if (value == null || !StringUtils.isNotBlank(String.valueOf(value))) { + return; + } + values.add(label + " " + plain(value)); + } + + private static void addUnit(List values, String label, Object value, String unit) { + if (value == null || !StringUtils.isNotBlank(String.valueOf(value))) { + return; + } + values.add(label + " " + plain(value) + unit); + } + + private static String plain(Object value) { + if (value instanceof BigDecimal) { + return ((BigDecimal) value).stripTrailingZeros().toPlainString(); + } + return String.valueOf(value).trim(); + } + + private static String join(List values, String sep) { + if (values == null || values.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (String v : values) { + if (!StringUtils.isNotBlank(v)) { + continue; + } + if (sb.length() > 0) { + sb.append(sep); + } + sb.append(v.trim()); + } + return sb.length() == 0 ? null : sb.toString(); + } + + private static String firstText(String... values) { + if (values == null) { + return null; + } + for (String v : values) { + if (StringUtils.isNotBlank(v)) { + return v.trim(); + } + } + return null; + } + + private static String upper(String value) { + return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + } + + private static boolean containsChinese(String value, String token) { + return StringUtils.isNotBlank(value) && value.contains(token); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComAddressBookClient.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComAddressBookClient.java new file mode 100644 index 0000000..7e7ce9b --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComAddressBookClient.java @@ -0,0 +1,287 @@ +package org.jeecg.modules.inquiry.wecom; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.annotation.PostConstruct; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 企微通讯录只读客户端(主账侧)。 + *

+ * 职责:gettoken + department/list + user/list,给员工页同步姓名/手机/部门。 + * 密钥:环境变量 WECOM_CORP_ID / WECOM_SECRET(与询价应用 1000010 同一套,见 prompt/03)。 + * 本机开发可设 WECOM_SOCKS_HOST/PORT,经测试机 SOCKS 出口,避免本机公网 IP 不在企微白名单(errcode=60020)。 + * 禁止:浏览器直连;禁止在同步失败时挡询价主路径;禁止把 Secret 写入日志。 + * 线程:HTTP 请求线程调用;token 缓存带锁;connect/read 超时,禁止无限阻塞。 + */ +@Slf4j +@Component +public class WeComAddressBookClient { + + private static final String DEFAULT_API_BASE = "https://qyapi.weixin.qq.com"; + /** 根部门 id;fetch_child=1 拉全树成员。 */ + private static final int ROOT_DEPARTMENT_ID = 1; + + @Value("${WECOM_CORP_ID:}") + private String corpId; + + @Value("${WECOM_SECRET:}") + private String secret; + + @Value("${WECOM_API_BASE:" + DEFAULT_API_BASE + "}") + private String apiBase; + + /** 可选:本机开发经 SOCKS 出站(如 127.0.0.1,配合 ssh -D)。 */ + @Value("${WECOM_SOCKS_HOST:}") + private String socksHost; + + @Value("${WECOM_SOCKS_PORT:1080}") + private int socksPort; + + private RestTemplate restTemplate; + private final Object tokenLock = new Object(); + private String cachedToken; + private long tokenExpireAtMs; + + @PostConstruct + public void initHttpClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5_000); + factory.setReadTimeout(15_000); + if (StringUtils.isNotBlank(socksHost)) { + factory.setProxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(socksHost.trim(), socksPort))); + log.info("企微 HTTP 经 SOCKS {}:{} 出站(绕过本机 IP 白名单限制)", socksHost, socksPort); + } + RestTemplate rt = new RestTemplate(factory); + rt.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8)); + this.restTemplate = rt; + } + + /** + * 配置是否齐全(不访问外网)。 + */ + public boolean isConfigured() { + return StringUtils.isNotBlank(corpId) && StringUtils.isNotBlank(secret); + } + + /** + * 拉取应用可见范围内的已激活成员(去重 userid)。 + *

+ * 副作用:出站 HTTP 调企微;失败抛 IllegalStateException,由上层转 Result。 + */ + public List listActiveMembers() { + if (!isConfigured()) { + throw new IllegalStateException("未配置 WECOM_CORP_ID / WECOM_SECRET,无法同步通讯录"); + } + String token = getAccessToken(); + Map deptNames = loadDepartmentNames(token); + String url = UriComponentsBuilder + .fromHttpUrl(trimBase(apiBase) + "/cgi-bin/user/list") + .queryParam("access_token", token) + .queryParam("department_id", ROOT_DEPARTMENT_ID) + .queryParam("fetch_child", 1) + .toUriString(); + JSONObject body = getJson(url); + assertOk(body, "user/list"); + JSONArray userlist = body.getJSONArray("userlist"); + Map byUserid = new LinkedHashMap<>(); + if (userlist == null) { + return new ArrayList<>(); + } + for (int i = 0; i < userlist.size(); i++) { + JSONObject u = userlist.getJSONObject(i); + if (u == null) { + continue; + } + String userid = StringUtils.trimToEmpty(u.getString("userid")); + if (userid.isEmpty()) { + continue; + } + int status = u.getIntValue("status"); + // 1=已激活;其它状态不同步进业务表,避免半离职账号入主账 + if (status != 1) { + continue; + } + String name = StringUtils.defaultIfBlank(u.getString("name"), userid); + String mobile = StringUtils.trimToEmpty(u.getString("mobile")); + String deptName = resolveDepartmentName(u.getJSONArray("department"), deptNames); + byUserid.put(userid, new WeComMember(userid, name, mobile, deptName, status)); + } + return new ArrayList<>(byUserid.values()); + } + + /** + * 拉取企微部门树(扁平列表,带 path 便于下拉展示)。 + *

+ * 副作用:出站 HTTP;失败抛 IllegalStateException。 + */ + public List listDepartments() { + if (!isConfigured()) { + throw new IllegalStateException("未配置 WECOM_CORP_ID / WECOM_SECRET,无法拉取部门"); + } + String token = getAccessToken(); + String url = UriComponentsBuilder + .fromHttpUrl(trimBase(apiBase) + "/cgi-bin/department/list") + .queryParam("access_token", token) + .toUriString(); + JSONObject body = getJson(url); + assertOk(body, "department/list"); + JSONArray list = body.getJSONArray("department"); + if (list == null || list.isEmpty()) { + return new ArrayList<>(); + } + + Map byId = new LinkedHashMap<>(); + for (int i = 0; i < list.size(); i++) { + JSONObject d = list.getJSONObject(i); + if (d != null) { + byId.put(d.getIntValue("id"), d); + } + } + + List result = new ArrayList<>(); + for (JSONObject d : byId.values()) { + int id = d.getIntValue("id"); + String name = StringUtils.defaultIfBlank(d.getString("name"), String.valueOf(id)); + int parentId = d.getIntValue("parentid"); + String path = buildDepartmentPath(id, byId); + result.add(new WeComDepartment(id, name, parentId, path)); + } + // 按路径排序,下拉更易扫 + result.sort((a, b) -> StringUtils.defaultString(a.getPath()).compareTo(StringUtils.defaultString(b.getPath()))); + return result; + } + + private String buildDepartmentPath(int id, Map byId) { + List parts = new ArrayList<>(); + int cursor = id; + // 防环:最多 32 层 + for (int depth = 0; depth < 32; depth++) { + JSONObject d = byId.get(cursor); + if (d == null) { + break; + } + parts.add(0, StringUtils.defaultIfBlank(d.getString("name"), String.valueOf(cursor))); + int parentId = d.getIntValue("parentid"); + if (parentId <= 0 || parentId == cursor || !byId.containsKey(parentId)) { + break; + } + cursor = parentId; + } + return String.join(" / ", parts); + } + + private Map loadDepartmentNames(String token) { + String url = UriComponentsBuilder + .fromHttpUrl(trimBase(apiBase) + "/cgi-bin/department/list") + .queryParam("access_token", token) + .toUriString(); + JSONObject body = getJson(url); + assertOk(body, "department/list"); + Map map = new HashMap<>(); + JSONArray list = body.getJSONArray("department"); + if (list == null) { + return map; + } + for (int i = 0; i < list.size(); i++) { + JSONObject d = list.getJSONObject(i); + if (d == null) { + continue; + } + map.put(d.getIntValue("id"), StringUtils.defaultString(d.getString("name"))); + } + return map; + } + + private String resolveDepartmentName(JSONArray departmentIds, Map deptNames) { + if (departmentIds == null || departmentIds.isEmpty()) { + return ""; + } + List names = new ArrayList<>(); + for (int i = 0; i < departmentIds.size(); i++) { + int id = departmentIds.getIntValue(i); + String n = deptNames.get(id); + if (StringUtils.isNotBlank(n)) { + names.add(n); + } + } + return String.join("/", names); + } + + private String getAccessToken() { + synchronized (tokenLock) { + long now = System.currentTimeMillis(); + if (StringUtils.isNotBlank(cachedToken) && now < tokenExpireAtMs) { + return cachedToken; + } + String url = UriComponentsBuilder + .fromHttpUrl(trimBase(apiBase) + "/cgi-bin/gettoken") + .queryParam("corpid", corpId) + .queryParam("corpsecret", secret) + .toUriString(); + JSONObject body = getJson(url); + assertOk(body, "gettoken"); + String token = body.getString("access_token"); + if (StringUtils.isBlank(token)) { + throw new IllegalStateException("企微 gettoken 未返回 access_token"); + } + int expiresIn = body.getIntValue("expires_in"); + if (expiresIn <= 0) { + expiresIn = 7200; + } + // 提前 5 分钟失效,避免边界过期 + cachedToken = token; + tokenExpireAtMs = now + Math.max(60, expiresIn - 300) * 1000L; + log.info("企微 access_token 已刷新,expiresIn={}s", expiresIn); + return cachedToken; + } + } + + private JSONObject getJson(String url) { + try { + String raw = restTemplate.getForObject(url, String.class); + if (StringUtils.isBlank(raw)) { + throw new IllegalStateException("企微接口返回空响应"); + } + return JSONObject.parseObject(raw); + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("调用企微失败: " + e.getMessage(), e); + } + } + + private void assertOk(JSONObject body, String api) { + if (body == null) { + throw new IllegalStateException("企微 " + api + " 响应为空"); + } + int errcode = body.getIntValue("errcode"); + if (errcode != 0) { + String errmsg = body.getString("errmsg"); + throw new IllegalStateException("企微 " + api + " 失败 errcode=" + errcode + " errmsg=" + errmsg); + } + } + + private static String trimBase(String base) { + if (StringUtils.isBlank(base)) { + return DEFAULT_API_BASE; + } + return base.endsWith("/") ? base.substring(0, base.length() - 1) : base; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComDepartment.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComDepartment.java new file mode 100644 index 0000000..bb6cabb --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComDepartment.java @@ -0,0 +1,36 @@ +package org.jeecg.modules.inquiry.wecom; + +/** + * 企微部门投影,供员工表单下拉。 + */ +public class WeComDepartment { + + private final int id; + private final String name; + private final int parentId; + /** 自根到本节点的展示路径,如「友通达 / 销售部」。 */ + private final String path; + + public WeComDepartment(int id, String name, int parentId, String path) { + this.id = id; + this.name = name; + this.parentId = parentId; + this.path = path; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public int getParentId() { + return parentId; + } + + public String getPath() { + return path; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComMember.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComMember.java new file mode 100644 index 0000000..51f84ea --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/wecom/WeComMember.java @@ -0,0 +1,48 @@ +package org.jeecg.modules.inquiry.wecom; + +/** + * 企微通讯录成员投影(仅姓名展示所需字段)。 + *

+ * 来源:qyapi user/list;不做岗位推断,岗位仍由运营在后台维护。 + */ +public class WeComMember { + + private final String userid; + private final String name; + private final String mobile; + private final String departmentName; + private final int status; + + public WeComMember(String userid, String name, String mobile, String departmentName, int status) { + this.userid = userid; + this.name = name; + this.mobile = mobile; + this.departmentName = departmentName; + this.status = status; + } + + public String getUserid() { + return userid; + } + + public String getName() { + return name; + } + + public String getMobile() { + return mobile; + } + + public String getDepartmentName() { + return departmentName; + } + + public int getStatus() { + return status; + } + + /** 企微 status=1 表示已激活,可入员工表。 */ + public boolean isActive() { + return status == 1; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java new file mode 100644 index 0000000..29a34d6 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupportTest.java @@ -0,0 +1,64 @@ +package org.jeecg.modules.inquiry.support; + +import org.jeecg.modules.inquiry.entity.InquiryTicket; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 工单列表展示口径:发起人名称 + 询价需求摘要,对齐旧后台。 + */ +public class InquiryTicketDisplaySupportTest { + + @Test + public void airRequirementSummaryMatchesLegacyShape() { + InquiryTicket ticket = new InquiryTicket(); + ticket.setBusinessLine("AIR"); + ticket.setBizTypeLabel("空运"); + ticket.setOrigin("ZUH"); + ticket.setDestination("CRK"); + ticket.setPieces(10); + ticket.setWeightKg(new BigDecimal("1")); + ticket.setVolumeCbm(new BigDecimal("8")); + ticket.setGoodsName("茶叶"); + ticket.setDemandDetail("FP:AIR|ZUH|CRK|茶叶|10件|1KGS|8CBM|2026-09-11"); + + List lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket); + assertEquals("空运 · ZUH → CRK", lines.get(0)); + assertEquals("件数 10 · 毛重 1kg · 体积 8CBM", lines.get(1)); + assertEquals("品名 茶叶", lines.get(2)); + + InquiryTicketDisplaySupport.applyRequirementSummary(ticket); + assertEquals("空运 · ZUH → CRK", ticket.getDemandTitle()); + assertEquals("件数 10 · 毛重 1kg · 体积 8CBM\n品名 茶叶", ticket.getDemandDetail()); + } + + @Test + public void landUsesGoodsNameLabel() { + InquiryTicket ticket = new InquiryTicket(); + ticket.setBusinessLine("LAND"); + ticket.setBizTypeLabel("陆运"); + ticket.setOrigin("上海"); + ticket.setDestination("北宁Bac Ninh"); + ticket.setGoodsName("设备"); + + List lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket); + assertEquals("陆运 · 上海 → 北宁Bac Ninh", lines.get(0)); + assertEquals("货物品名 设备", lines.get(1)); + } + + @Test + public void salesNamePrefersStaffChineseName() { + assertEquals("吴吉林", + InquiryTicketDisplaySupport.resolveSalesDisplayName(null, "WuJiLin", "吴吉林")); + assertEquals("陈柏成", + InquiryTicketDisplaySupport.resolveSalesDisplayName("WuJiLin", "WuJiLin", "陈柏成")); + assertFalse(InquiryTicketDisplaySupport.looksLikeWecomUserId("陈柏成")); + assertTrue(InquiryTicketDisplaySupport.looksLikeWecomUserId("WuJiLin")); + } +} diff --git a/inquiry-backend/src/views/inquiry/staffRole/index.vue b/inquiry-backend/src/views/inquiry/staffRole/index.vue index 4a84201..2263a58 100644 --- a/inquiry-backend/src/views/inquiry/staffRole/index.vue +++ b/inquiry-backend/src/views/inquiry/staffRole/index.vue @@ -1,7 +1,8 @@