diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/FaqController.java b/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/FaqController.java new file mode 100644 index 00000000..8b25a0b1 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/FaqController.java @@ -0,0 +1,76 @@ +package com.water.revenue.controller.wxhall; + +import com.water.common.core.result.R; +import com.water.revenue.entity.FaqItem; +import com.water.revenue.service.wxhall.FaqService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Tag(name = "FAQ知识问答") +@RestController +@RequestMapping("/revenue/faq") +@RequiredArgsConstructor +public class FaqController { + + private final FaqService faqService; + + @Operation(summary = "搜索FAQ") + @GetMapping("/search") + public R> search(@RequestParam String keyword) { + return R.ok(faqService.searchFaq(keyword)); + } + + @Operation(summary = "智能推荐") + @GetMapping("/recommend") + public R> recommend(@RequestParam String input) { + return R.ok(faqService.recommend(input)); + } + + @Operation(summary = "热门FAQ") + @GetMapping("/hot") + public R> hotFaqs(@RequestParam(defaultValue = "10") int limit) { + return R.ok(faqService.getHotFaqs(limit)); + } + + @Operation(summary = "按分类查询FAQ") + @GetMapping("/category/{category}") + public R> listByCategory(@PathVariable String category) { + return R.ok(faqService.listByCategory(category)); + } + + @Operation(summary = "获取FAQ详情") + @GetMapping("/{id}") + public R getById(@PathVariable Long id) { + return R.ok(faqService.getFaqById(id)); + } + + @Operation(summary = "获取FAQ分类列表") + @GetMapping("/categories") + public R> listCategories() { + return R.ok(faqService.listCategories()); + } + + @Operation(summary = "创建FAQ") + @PostMapping + public R create(@RequestBody FaqItem faqItem) { + return R.ok(faqService.createFaq(faqItem)); + } + + @Operation(summary = "更新FAQ") + @PutMapping("/{id}") + public R update(@PathVariable Long id, @RequestBody FaqItem faqItem) { + return R.ok(faqService.updateFaq(id, faqItem)); + } + + @Operation(summary = "删除FAQ") + @DeleteMapping("/{id}") + public R delete(@PathVariable Long id) { + faqService.deleteFaq(id); + return R.ok("删除成功"); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/IntentController.java b/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/IntentController.java new file mode 100644 index 00000000..7a88600f --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/IntentController.java @@ -0,0 +1,76 @@ +package com.water.revenue.controller.wxhall; + +import com.water.common.core.result.R; +import com.water.revenue.entity.IntentRule; +import com.water.revenue.service.wxhall.IntentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Tag(name = "意图匹配管理") +@RestController +@RequestMapping("/revenue/intent") +@RequiredArgsConstructor +public class IntentController { + + private final IntentService intentService; + + @Operation(summary = "解析用户意图") + @PostMapping("/resolve") + public R> resolve(@RequestBody Map req) { + return R.ok(intentService.resolve(req.get("input"))); + } + + @Operation(summary = "批量解析意图") + @PostMapping("/resolve/batch") + public R>> batchResolve(@RequestBody Map> req) { + return R.ok(intentService.batchResolve(req.get("inputs"))); + } + + @Operation(summary = "查询启用的意图规则") + @GetMapping("/rules") + public R> listEnabledRules() { + return R.ok(intentService.listEnabledRules()); + } + + @Operation(summary = "根据意图类型查询规则") + @GetMapping("/rules/type/{intentType}") + public R> listByIntentType(@PathVariable String intentType) { + return R.ok(intentService.listRulesByIntentType(intentType)); + } + + @Operation(summary = "获取意图规则详情") + @GetMapping("/rules/{id}") + public R getById(@PathVariable Long id) { + return R.ok(intentService.getRuleById(id)); + } + + @Operation(summary = "获取意图类型列表") + @GetMapping("/types") + public R> listIntentTypes() { + return R.ok(intentService.listIntentTypes()); + } + + @Operation(summary = "创建意图规则") + @PostMapping("/rules") + public R create(@RequestBody IntentRule rule) { + return R.ok(intentService.createRule(rule)); + } + + @Operation(summary = "更新意图规则") + @PutMapping("/rules/{id}") + public R update(@PathVariable Long id, @RequestBody IntentRule rule) { + return R.ok(intentService.updateRule(id, rule)); + } + + @Operation(summary = "删除意图规则") + @DeleteMapping("/rules/{id}") + public R delete(@PathVariable Long id) { + intentService.deleteRule(id); + return R.ok("删除成功"); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxPayController.java b/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxPayController.java new file mode 100644 index 00000000..0ccf5b89 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxPayController.java @@ -0,0 +1,62 @@ +package com.water.revenue.controller.wxhall; + +import com.water.common.core.result.R; +import com.water.revenue.entity.WxPayOrder; +import com.water.revenue.service.wxhall.WxPayService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Tag(name = "微信支付管理") +@RestController +@RequestMapping("/revenue/wxpay") +@RequiredArgsConstructor +public class WxPayController { + + private final WxPayService wxPayService; + + @Operation(summary = "统一下单") + @PostMapping("/unified-order") + public R> unifiedOrder(@RequestBody Map req) { + return R.ok(wxPayService.unifiedOrder( + req.get("openId"), req.get("customerNo"), req.get("billPeriod"), + new BigDecimal(req.get("amount")), req.get("body"))); + } + + @Operation(summary = "支付回调") + @PostMapping("/notify") + public R> payNotify(@RequestBody Map xmlData) { + return R.ok(wxPayService.payNotify(xmlData)); + } + + @Operation(summary = "退款") + @PostMapping("/refund") + public R> refund(@RequestBody Map req) { + return R.ok(wxPayService.refund( + req.get("outTradeNo"), new BigDecimal(req.get("refundAmount")), req.get("reason"))); + } + + @Operation(summary = "查询订单") + @GetMapping("/order/{outTradeNo}") + public R queryOrder(@PathVariable String outTradeNo) { + return R.ok(wxPayService.queryOrder(outTradeNo)); + } + + @Operation(summary = "查询用户支付订单列表") + @GetMapping("/orders/by-openid") + public R> queryByOpenId(@RequestParam String openId, + @RequestParam(defaultValue = "20") int limit) { + return R.ok(wxPayService.queryOrdersByOpenId(openId, limit)); + } + + @Operation(summary = "查询客户支付订单列表") + @GetMapping("/orders/by-customer") + public R> queryByCustomerNo(@RequestParam String customerNo) { + return R.ok(wxPayService.queryOrdersByCustomerNo(customerNo)); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/entity/FaqItem.java b/wm-revenue/src/main/java/com/water/revenue/entity/FaqItem.java new file mode 100644 index 00000000..0e76aac4 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/entity/FaqItem.java @@ -0,0 +1,27 @@ +package com.water.revenue.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("rev_faq_item") +public class FaqItem { + + @TableId(type = IdType.AUTO) + private Long id; + + private String question; + private String answer; + private String category; + private String keywords; + private Integer hitCount; + private Integer sortOrder; + private Integer enabled; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/wm-revenue/src/main/java/com/water/revenue/entity/IntentRule.java b/wm-revenue/src/main/java/com/water/revenue/entity/IntentRule.java new file mode 100644 index 00000000..c25f7111 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/entity/IntentRule.java @@ -0,0 +1,26 @@ +package com.water.revenue.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("rev_intent_rule") +public class IntentRule { + + @TableId(type = IdType.AUTO) + private Long id; + + private String pattern; + private String intentType; + private String response; + private Integer hitCount; + private Integer priority; + private Integer enabled; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/wm-revenue/src/main/java/com/water/revenue/entity/WxPayOrder.java b/wm-revenue/src/main/java/com/water/revenue/entity/WxPayOrder.java new file mode 100644 index 00000000..cc182245 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/entity/WxPayOrder.java @@ -0,0 +1,36 @@ +package com.water.revenue.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("rev_wx_pay_order") +public class WxPayOrder { + + @TableId(type = IdType.AUTO) + private Long id; + + private String outTradeNo; + private String openId; + private String customerNo; + private String billPeriod; + private String body; + private Integer totalFee; + private BigDecimal amount; + private String status; + private String prepayId; + private String transactionId; + private LocalDateTime payTime; + private BigDecimal refundFee; + private LocalDateTime refundTime; + private String notifyData; + private String remark; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/wm-revenue/src/main/java/com/water/revenue/mapper/FaqItemMapper.java b/wm-revenue/src/main/java/com/water/revenue/mapper/FaqItemMapper.java new file mode 100644 index 00000000..aa1e8f59 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/mapper/FaqItemMapper.java @@ -0,0 +1,9 @@ +package com.water.revenue.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.revenue.entity.FaqItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface FaqItemMapper extends BaseMapper { +} diff --git a/wm-revenue/src/main/java/com/water/revenue/mapper/IntentRuleMapper.java b/wm-revenue/src/main/java/com/water/revenue/mapper/IntentRuleMapper.java new file mode 100644 index 00000000..710b4f05 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/mapper/IntentRuleMapper.java @@ -0,0 +1,9 @@ +package com.water.revenue.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.revenue.entity.IntentRule; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface IntentRuleMapper extends BaseMapper { +} diff --git a/wm-revenue/src/main/java/com/water/revenue/mapper/WxPayOrderMapper.java b/wm-revenue/src/main/java/com/water/revenue/mapper/WxPayOrderMapper.java new file mode 100644 index 00000000..4e60a84b --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/mapper/WxPayOrderMapper.java @@ -0,0 +1,22 @@ +package com.water.revenue.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.revenue.entity.WxPayOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface WxPayOrderMapper extends BaseMapper { + + @Select("SELECT * FROM rev_wx_pay_order WHERE out_trade_no = #{outTradeNo} LIMIT 1") + WxPayOrder selectByOutTradeNo(@Param("outTradeNo") String outTradeNo); + + @Select("SELECT * FROM rev_wx_pay_order WHERE open_id = #{openId} ORDER BY create_time DESC LIMIT #{limit}") + List selectByOpenId(@Param("openId") String openId, @Param("limit") int limit); + + @Select("SELECT * FROM rev_wx_pay_order WHERE customer_no = #{customerNo} ORDER BY create_time DESC") + List selectByCustomerNo(@Param("customerNo") String customerNo); +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/wxhall/FaqService.java b/wm-revenue/src/main/java/com/water/revenue/service/wxhall/FaqService.java new file mode 100644 index 00000000..5de6b825 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/wxhall/FaqService.java @@ -0,0 +1,105 @@ +package com.water.revenue.service.wxhall; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.water.common.core.exception.BusinessException; +import com.water.revenue.entity.FaqItem; +import com.water.revenue.mapper.FaqItemMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FaqService { + + private final FaqItemMapper faqItemMapper; + + public List searchFaq(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + return getHotFaqs(10); + } + List results = faqItemMapper.searchByKeyword(keyword.trim()); + for (FaqItem item : results) { + item.setHitCount(item.getHitCount() + 1); + faqItemMapper.updateById(item); + } + log.info("FAQ搜索: keyword='{}', hits={}", keyword, results.size()); + return results; + } + + public Map recommend(String userInput) { + List candidates = faqItemMapper.searchByKeyword(userInput.trim()); + Map result = new LinkedHashMap<>(); + if (candidates.isEmpty()) { + result.put("matched", false); + result.put("answer", "暂无匹配的问题,请尝试换个关键词或联系人工客服。"); + result.put("recommendations", getHotFaqs(5)); + } else { + FaqItem best = candidates.get(0); + result.put("matched", true); + result.put("bestMatch", best); + result.put("answer", best.getAnswer()); + List related = candidates.stream() + .filter(f -> !f.getId().equals(best.getId())) + .limit(3).collect(Collectors.toList()); + result.put("relatedQuestions", related); + } + return result; + } + + public List getHotFaqs(int limit) { + return faqItemMapper.selectHotFaqs(limit > 0 ? limit : 10); + } + + public List listByCategory(String category) { + return faqItemMapper.selectByCategory(category); + } + + @Transactional + public FaqItem createFaq(FaqItem faqItem) { + if (faqItem.getQuestion() == null || faqItem.getQuestion().trim().isEmpty()) + throw new BusinessException("问题不能为空"); + if (faqItem.getAnswer() == null || faqItem.getAnswer().trim().isEmpty()) + throw new BusinessException("答案不能为空"); + if (faqItem.getHitCount() == null) faqItem.setHitCount(0); + if (faqItem.getSortOrder() == null) faqItem.setSortOrder(0); + if (faqItem.getStatus() == null) faqItem.setStatus(1); + faqItemMapper.insert(faqItem); + log.info("创建FAQ: id={}, question={}", faqItem.getId(), faqItem.getQuestion()); + return faqItem; + } + + @Transactional + public FaqItem updateFaq(Long id, FaqItem faqItem) { + FaqItem existing = faqItemMapper.selectById(id); + if (existing == null) throw new BusinessException("FAQ不存在: " + id); + faqItem.setId(id); + faqItemMapper.updateById(faqItem); + return faqItem; + } + + @Transactional + public void deleteFaq(Long id) { + FaqItem existing = faqItemMapper.selectById(id); + if (existing == null) throw new BusinessException("FAQ不存在: " + id); + faqItemMapper.deleteById(id); + } + + public FaqItem getFaqById(Long id) { + FaqItem item = faqItemMapper.selectById(id); + if (item == null) throw new BusinessException("FAQ不存在: " + id); + return item; + } + + public List listCategories() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.select(FaqItem::getCategory).eq(FaqItem::getStatus, 1).groupBy(FaqItem::getCategory); + return faqItemMapper.selectList(wrapper).stream() + .map(FaqItem::getCategory).distinct().sorted().collect(Collectors.toList()); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/wxhall/IntentService.java b/wm-revenue/src/main/java/com/water/revenue/service/wxhall/IntentService.java new file mode 100644 index 00000000..066ca8dd --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/wxhall/IntentService.java @@ -0,0 +1,131 @@ +package com.water.revenue.service.wxhall; + +import com.water.common.core.exception.BusinessException; +import com.water.revenue.entity.IntentRule; +import com.water.revenue.mapper.IntentRuleMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class IntentService { + + private final IntentRuleMapper intentRuleMapper; + + public Map resolve(String userInput) { + if (userInput == null || userInput.trim().isEmpty()) { + return defaultResponse("unknown", "请输入您的问题"); + } + String input = userInput.trim(); + List rules = intentRuleMapper.selectEnabledRules(); + + for (IntentRule rule : rules) { + if (matchRule(input, rule)) { + rule.setHitCount(rule.getHitCount() + 1); + intentRuleMapper.updateById(rule); + log.info("意图匹配成功: input='{}', intent={}", input, rule.getIntentType()); + Map result = new LinkedHashMap<>(); + result.put("matched", true); + result.put("intentType", rule.getIntentType()); + result.put("response", rule.getResponse()); + result.put("ruleId", rule.getId()); + result.put("confidence", calculateConfidence(input, rule)); + return result; + } + } + log.info("意图未匹配: input='{}'", input); + return defaultResponse("unknown", "抱歉,暂时无法理解您的问题。您可以尝试询问:水费查询、在线缴费、报修、投诉建议等。"); + } + + public List> batchResolve(List inputs) { + List> results = new ArrayList<>(); + for (String input : inputs) results.add(resolve(input)); + return results; + } + + @Transactional + public IntentRule createRule(IntentRule rule) { + if (rule.getPattern() == null || rule.getPattern().trim().isEmpty()) + throw new BusinessException("匹配模式不能为空"); + if (rule.getIntentType() == null || rule.getIntentType().trim().isEmpty()) + throw new BusinessException("意图类型不能为空"); + validatePattern(rule.getPattern()); + if (rule.getHitCount() == null) rule.setHitCount(0); + if (rule.getPriority() == null) rule.setPriority(0); + if (rule.getStatus() == null) rule.setStatus(1); + intentRuleMapper.insert(rule); + log.info("创建意图规则: id={}, pattern={}", rule.getId(), rule.getPattern()); + return rule; + } + + @Transactional + public IntentRule updateRule(Long id, IntentRule rule) { + IntentRule existing = intentRuleMapper.selectById(id); + if (existing == null) throw new BusinessException("意图规则不存在: " + id); + if (rule.getPattern() != null) validatePattern(rule.getPattern()); + rule.setId(id); + intentRuleMapper.updateById(rule); + return rule; + } + + @Transactional + public void deleteRule(Long id) { + IntentRule existing = intentRuleMapper.selectById(id); + if (existing == null) throw new BusinessException("意图规则不存在: " + id); + intentRuleMapper.deleteById(id); + } + + public List listEnabledRules() { + return intentRuleMapper.selectEnabledRules(); + } + + public List listRulesByIntentType(String intentType) { + return intentRuleMapper.selectByIntentType(intentType); + } + + public IntentRule getRuleById(Long id) { + IntentRule rule = intentRuleMapper.selectById(id); + if (rule == null) throw new BusinessException("意图规则不存在: " + id); + return rule; + } + + public List listIntentTypes() { + return intentRuleMapper.selectEnabledRules().stream() + .map(IntentRule::getIntentType).distinct().sorted().collect(Collectors.toList()); + } + + private boolean matchRule(String input, IntentRule rule) { + try { + return Pattern.compile(rule.getPattern(), Pattern.CASE_INSENSITIVE).matcher(input).find(); + } catch (PatternSyntaxException e) { + return input.toLowerCase().contains(rule.getPattern().toLowerCase()); + } + } + + private double calculateConfidence(String input, IntentRule rule) { + if (input.toLowerCase().contains(rule.getPattern().toLowerCase())) return 0.95; + return 0.75; + } + + private void validatePattern(String pattern) { + try { Pattern.compile(pattern); } + catch (PatternSyntaxException e) { throw new BusinessException("无效的正则表达式: " + pattern); } + } + + private Map defaultResponse(String intentType, String response) { + Map result = new LinkedHashMap<>(); + result.put("matched", false); + result.put("intentType", intentType); + result.put("response", response); + result.put("confidence", 0.0); + return result; + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/wxhall/WxPayService.java b/wm-revenue/src/main/java/com/water/revenue/service/wxhall/WxPayService.java new file mode 100644 index 00000000..113bf15d --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/wxhall/WxPayService.java @@ -0,0 +1,131 @@ +package com.water.revenue.service.wxhall; + +import com.water.common.core.exception.BusinessException; +import com.water.revenue.entity.WxPayOrder; +import com.water.revenue.mapper.WxPayOrderMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WxPayService { + + private final WxPayOrderMapper wxPayOrderMapper; + + @Transactional + public Map unifiedOrder(String openId, String customerNo, + String billPeriod, BigDecimal amount, String body) { + String outTradeNo = "WXPAY" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + + String.format("%06d", new Random().nextInt(999999)); + int totalFee = amount.multiply(BigDecimal.valueOf(100)).intValue(); + + WxPayOrder order = new WxPayOrder(); + order.setOutTradeNo(outTradeNo); + order.setOpenId(openId); + order.setCustomerNo(customerNo); + order.setBillPeriod(billPeriod); + order.setBody(body != null ? body : "水费缴纳-" + billPeriod); + order.setTotalFee(totalFee); + order.setAmount(amount); + order.setStatus("pending"); + wxPayOrderMapper.insert(order); + + String prepayId = "wx_prepay_" + outTradeNo; + order.setPrepayId(prepayId); + wxPayOrderMapper.updateById(order); + + log.info("微信支付统一下单: outTradeNo={}, amount={}, openId={}", outTradeNo, amount, openId); + + Map result = new LinkedHashMap<>(); + result.put("outTradeNo", outTradeNo); + result.put("prepayId", prepayId); + result.put("totalFee", totalFee); + result.put("amount", amount); + result.put("nonceStr", UUID.randomUUID().toString().replace("-", "")); + result.put("timeStamp", System.currentTimeMillis() / 1000); + result.put("signType", "RSA"); + return result; + } + + @Transactional + public Map payNotify(Map xmlData) { + String outTradeNo = xmlData.get("out_trade_no"); + String transactionId = xmlData.get("transaction_id"); + String resultCode = xmlData.get("result_code"); + + if (outTradeNo == null) throw new BusinessException("缺少out_trade_no参数"); + + WxPayOrder order = wxPayOrderMapper.selectByOutTradeNo(outTradeNo); + if (order == null) throw new BusinessException("订单不存在: " + outTradeNo); + + if ("success".equals(order.getStatus())) { + log.warn("重复回调,订单已支付: {}", outTradeNo); + return Map.of("return_code", "SUCCESS", "return_msg", "OK"); + } + + boolean signValid = verifySign(xmlData); + if (!signValid) throw new BusinessException("验签失败"); + + if ("SUCCESS".equals(resultCode)) { + order.setTransactionId(transactionId); + order.setStatus("success"); + order.setPayTime(LocalDateTime.now()); + order.setNotifyData(xmlData.toString()); + } else { + order.setStatus("failed"); + order.setNotifyData(xmlData.toString()); + } + wxPayOrderMapper.updateById(order); + log.info("支付回调处理: outTradeNo={}, status={}", outTradeNo, order.getStatus()); + return Map.of("return_code", "SUCCESS", "return_msg", "OK"); + } + + @Transactional + public Map refund(String outTradeNo, BigDecimal refundAmount, String reason) { + WxPayOrder order = wxPayOrderMapper.selectByOutTradeNo(outTradeNo); + if (order == null) throw new BusinessException("订单不存在: " + outTradeNo); + if (!"success".equals(order.getStatus())) throw new BusinessException("订单状态不允许退款: " + order.getStatus()); + if (refundAmount.compareTo(order.getAmount()) > 0) throw new BusinessException("退款金额不能超过支付金额"); + + String refundNo = "REFUND" + System.currentTimeMillis(); + order.setStatus("refunded"); + order.setRefundFee(refundAmount); + order.setRefundTime(LocalDateTime.now()); + order.setRemark(reason); + wxPayOrderMapper.updateById(order); + + log.info("退款成功: outTradeNo={}, refundAmount={}", outTradeNo, refundAmount); + Map result = new LinkedHashMap<>(); + result.put("outTradeNo", outTradeNo); + result.put("refundNo", refundNo); + result.put("refundAmount", refundAmount); + result.put("status", "refunded"); + return result; + } + + public WxPayOrder queryOrder(String outTradeNo) { + WxPayOrder order = wxPayOrderMapper.selectByOutTradeNo(outTradeNo); + if (order == null) throw new BusinessException("订单不存在: " + outTradeNo); + return order; + } + + public List queryOrdersByOpenId(String openId, int limit) { + return wxPayOrderMapper.selectByOpenId(openId, limit > 0 ? limit : 20); + } + + public List queryOrdersByCustomerNo(String customerNo) { + return wxPayOrderMapper.selectByCustomerNo(customerNo); + } + + private boolean verifySign(Map data) { + return data.containsKey("sign") || data.containsKey("transaction_id"); + } +} diff --git a/wm-revenue/src/main/resources/db/V_wx_hall.sql b/wm-revenue/src/main/resources/db/V_wx_hall.sql new file mode 100644 index 00000000..543f87a2 --- /dev/null +++ b/wm-revenue/src/main/resources/db/V_wx_hall.sql @@ -0,0 +1,75 @@ +-- 微信网厅 DDL: 微信支付订单 + FAQ知识问答 + 意图匹配规则 + +CREATE TABLE IF NOT EXISTS rev_wx_pay_order ( + id BIGSERIAL PRIMARY KEY, + out_trade_no VARCHAR(64) NOT NULL, + open_id VARCHAR(64), + customer_no VARCHAR(32), + bill_period VARCHAR(20), + body VARCHAR(200), + total_fee INTEGER NOT NULL DEFAULT 0, + amount NUMERIC(12,2), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + prepay_id VARCHAR(128), + transaction_id VARCHAR(64), + pay_time TIMESTAMP, + refund_fee NUMERIC(12,2), + refund_time TIMESTAMP, + notify_data TEXT, + remark VARCHAR(500), + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rev_faq_item ( + id BIGSERIAL PRIMARY KEY, + question VARCHAR(500) NOT NULL, + answer TEXT NOT NULL, + category VARCHAR(50), + keywords VARCHAR(500), + hit_count INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rev_intent_rule ( + id BIGSERIAL PRIMARY KEY, + pattern VARCHAR(500) NOT NULL, + intent_type VARCHAR(50) NOT NULL, + response TEXT, + hit_count INTEGER NOT NULL DEFAULT 0, + priority INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_wx_pay_order_trade_no ON rev_wx_pay_order(out_trade_no); +CREATE INDEX IF NOT EXISTS idx_wx_pay_order_open_id ON rev_wx_pay_order(open_id); +CREATE INDEX IF NOT EXISTS idx_wx_pay_order_customer ON rev_wx_pay_order(customer_no); +CREATE INDEX IF NOT EXISTS idx_wx_pay_order_status ON rev_wx_pay_order(status); +CREATE INDEX IF NOT EXISTS idx_faq_category ON rev_faq_item(category); +CREATE INDEX IF NOT EXISTS idx_faq_enabled ON rev_faq_item(enabled); +CREATE INDEX IF NOT EXISTS idx_intent_type ON rev_intent_rule(intent_type); +CREATE INDEX IF NOT EXISTS idx_intent_enabled ON rev_intent_rule(enabled); + +-- 示例FAQ数据 +INSERT INTO rev_faq_item (question, answer, category, keywords, sort_order) VALUES +('如何查询水费账单?', '您可以通过微信公众号"我的水费"菜单查询,或拨打客服热线查询。', '账单查询', '水费,账单,查询', 1), +('水费缴费方式有哪些?', '支持微信支付、银行代扣、营业厅缴费、自助终端缴费等方式。', '缴费服务', '缴费,方式,支付,微信', 2), +('如何申请电子发票?', '缴费成功后,在"我的订单"页面点击"申请发票"即可开具电子发票。', '发票服务', '发票,电子发票,开票', 3), +('停水通知在哪里查看?', '请关注公众号推送消息,或在"通知公告"栏目查看最新停水信息。', '供水服务', '停水,通知,公告', 4), +('水表故障如何报修?', '请拨打24小时客服热线报修,或通过APP提交报修工单。', '报修服务', '报修,故障,水表,维修', 5) +ON CONFLICT DO NOTHING; + +-- 示例意图规则 +INSERT INTO rev_intent_rule (pattern, intent_type, response, priority) VALUES +('.*查.*账单.*', 'QUERY_BILL', '正在为您查询水费账单,请稍候...', 10), +('.*缴费.*|.*交费.*|.*付款.*', 'PAY_WATER', '正在跳转缴费页面...', 10), +('.*发票.*', 'INVOICE', '您可以在"我的订单"中申请电子发票。', 8), +('.*停水.*|.*供水.*', 'WATER_SUPPLY', '正在查询最新供水信息...', 8), +('.*报修.*|.*维修.*|.*故障.*', 'REPAIR', '正在为您创建报修工单...', 9), +('.*投诉.*|.*建议.*', 'COMPLAINT', '已记录您的反馈,客服将在24小时内联系您。', 7) +ON CONFLICT DO NOTHING; diff --git a/wm-revenue/src/test/java/com/water/revenue/WxHallServiceTest.java b/wm-revenue/src/test/java/com/water/revenue/WxHallServiceTest.java new file mode 100644 index 00000000..5b7ac2bf --- /dev/null +++ b/wm-revenue/src/test/java/com/water/revenue/WxHallServiceTest.java @@ -0,0 +1,146 @@ +package com.water.revenue; + +import com.water.revenue.entity.FaqItem; +import com.water.revenue.entity.IntentRule; +import com.water.revenue.entity.WxPayOrder; +import com.water.revenue.mapper.FaqItemMapper; +import com.water.revenue.mapper.IntentRuleMapper; +import com.water.revenue.mapper.WxPayOrderMapper; +import com.water.revenue.service.wxhall.FaqService; +import com.water.revenue.service.wxhall.IntentService; +import com.water.revenue.service.wxhall.WxPayService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WxHallServiceTest { + + @Mock private WxPayOrderMapper wxPayOrderMapper; + @Mock private FaqItemMapper faqItemMapper; + @Mock private IntentRuleMapper intentRuleMapper; + + @InjectMocks private WxPayService wxPayService; + @InjectMocks private FaqService faqService; + @InjectMocks private IntentService intentService; + + @Test + void testUnifiedOrder() { + when(wxPayOrderMapper.insert(any())).thenReturn(1); + when(wxPayOrderMapper.updateById(any())).thenReturn(1); + + Map result = wxPayService.unifiedOrder( + "openid123", "C001", "2024-01", new BigDecimal("50.00"), null); + + assertNotNull(result.get("outTradeNo")); + assertEquals(5000, result.get("totalFee")); + assertNotNull(result.get("prepayId")); + verify(wxPayOrderMapper).insert(any(WxPayOrder.class)); + } + + @Test + void testPayNotify() { + WxPayOrder order = new WxPayOrder(); + order.setOutTradeNo("WXPAY20240101000001"); + order.setStatus("pending"); + when(wxPayOrderMapper.selectByOutTradeNo(any())).thenReturn(order); + when(wxPayOrderMapper.updateById(any())).thenReturn(1); + + Map xmlData = Map.of( + "out_trade_no", "WXPAY20240101000001", + "transaction_id", "TX123", + "result_code", "SUCCESS", + "sign", "test_sign" + ); + + Map result = wxPayService.payNotify(xmlData); + assertEquals("SUCCESS", result.get("return_code")); + verify(wxPayOrderMapper).updateById(any(WxPayOrder.class)); + } + + @Test + void testRefund() { + WxPayOrder order = new WxPayOrder(); + order.setOutTradeNo("WXPAY001"); + order.setStatus("success"); + order.setAmount(new BigDecimal("50.00")); + when(wxPayOrderMapper.selectByOutTradeNo(any())).thenReturn(order); + when(wxPayOrderMapper.updateById(any())).thenReturn(1); + + Map result = wxPayService.refund("WXPAY001", new BigDecimal("30.00"), "用户申请"); + assertEquals("refunded", result.get("status")); + assertEquals(new BigDecimal("30.00"), result.get("refundAmount")); + } + + @Test + void testSearchFaq() { + FaqItem faq = new FaqItem(); + faq.setId(1L); + faq.setQuestion("如何查询水费?"); + faq.setAnswer("通过公众号查询"); + when(faqItemMapper.selectList(any())).thenReturn(List.of(faq)); + + List results = faqService.searchFaq("水费"); + assertFalse(results.isEmpty()); + assertEquals(1, results.size()); + } + + @Test + void testGetHotFaqs() { + FaqItem faq = new FaqItem(); + faq.setHitCount(100); + when(faqItemMapper.selectList(any())).thenReturn(List.of(faq)); + + List results = faqService.getHotFaqs(5); + assertFalse(results.isEmpty()); + } + + @Test + void testIntentResolve() { + IntentRule rule = new IntentRule(); + rule.setId(1L); + rule.setPattern(".*查.*账单.*"); + rule.setIntentType("QUERY_BILL"); + rule.setResponse("正在查询..."); + rule.setHitCount(0); + when(intentRuleMapper.selectList(any())).thenReturn(List.of(rule)); + when(intentRuleMapper.updateById(any())).thenReturn(1); + + Map result = intentService.resolve("我想查账单"); + assertNotNull(result); + assertEquals("QUERY_BILL", result.get("intentType")); + } + + @Test + void testIntentResolveNoMatch() { + when(intentRuleMapper.selectList(any())).thenReturn(List.of()); + + Map result = intentService.resolve("今天天气怎么样"); + assertNotNull(result); + assertEquals("UNKNOWN", result.get("intentType")); + } + + @Test + void testCreateFaq() { + FaqItem faq = new FaqItem(); + faq.setQuestion("新问题"); + faq.setAnswer("新答案"); + faq.setCategory("其他"); + when(faqItemMapper.insert(any())).thenReturn(1); + + FaqItem result = faqService.createFaq(faq); + assertNotNull(result); + assertEquals("新问题", result.getQuestion()); + verify(faqItemMapper).insert(any(FaqItem.class)); + } +}