feat(wm-revenue): #57 微信网厅(微信支付+AI客服+意图匹配)

- WxPayService: 统一下单/支付回调/退款/订单查询
- FaqService: FAQ CRUD/关键词搜索/热门推荐/分类查询
- IntentService: 意图识别(正则匹配)/批量解析/规则管理
- 3个Controller + 15+ API端点 (/api/revenue/wxpay|faq|intent/*)
- Entity: WxPayOrder, FaqItem, IntentRule
- DDL: rev_wx_pay_order/rev_faq_item/rev_intent_rule + 8个索引
- 8个单元测试
This commit is contained in:
2026-06-14 16:43:00 +08:00
parent 8e1b9d8dd2
commit 69fd9d7c41
14 changed files with 931 additions and 0 deletions
@@ -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<List<FaqItem>> search(@RequestParam String keyword) {
return R.ok(faqService.searchFaq(keyword));
}
@Operation(summary = "智能推荐")
@GetMapping("/recommend")
public R<Map<String, Object>> recommend(@RequestParam String input) {
return R.ok(faqService.recommend(input));
}
@Operation(summary = "热门FAQ")
@GetMapping("/hot")
public R<List<FaqItem>> hotFaqs(@RequestParam(defaultValue = "10") int limit) {
return R.ok(faqService.getHotFaqs(limit));
}
@Operation(summary = "按分类查询FAQ")
@GetMapping("/category/{category}")
public R<List<FaqItem>> listByCategory(@PathVariable String category) {
return R.ok(faqService.listByCategory(category));
}
@Operation(summary = "获取FAQ详情")
@GetMapping("/{id}")
public R<FaqItem> getById(@PathVariable Long id) {
return R.ok(faqService.getFaqById(id));
}
@Operation(summary = "获取FAQ分类列表")
@GetMapping("/categories")
public R<List<String>> listCategories() {
return R.ok(faqService.listCategories());
}
@Operation(summary = "创建FAQ")
@PostMapping
public R<FaqItem> create(@RequestBody FaqItem faqItem) {
return R.ok(faqService.createFaq(faqItem));
}
@Operation(summary = "更新FAQ")
@PutMapping("/{id}")
public R<FaqItem> update(@PathVariable Long id, @RequestBody FaqItem faqItem) {
return R.ok(faqService.updateFaq(id, faqItem));
}
@Operation(summary = "删除FAQ")
@DeleteMapping("/{id}")
public R<String> delete(@PathVariable Long id) {
faqService.deleteFaq(id);
return R.ok("删除成功");
}
}
@@ -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<Map<String, Object>> resolve(@RequestBody Map<String, String> req) {
return R.ok(intentService.resolve(req.get("input")));
}
@Operation(summary = "批量解析意图")
@PostMapping("/resolve/batch")
public R<List<Map<String, Object>>> batchResolve(@RequestBody Map<String, List<String>> req) {
return R.ok(intentService.batchResolve(req.get("inputs")));
}
@Operation(summary = "查询启用的意图规则")
@GetMapping("/rules")
public R<List<IntentRule>> listEnabledRules() {
return R.ok(intentService.listEnabledRules());
}
@Operation(summary = "根据意图类型查询规则")
@GetMapping("/rules/type/{intentType}")
public R<List<IntentRule>> listByIntentType(@PathVariable String intentType) {
return R.ok(intentService.listRulesByIntentType(intentType));
}
@Operation(summary = "获取意图规则详情")
@GetMapping("/rules/{id}")
public R<IntentRule> getById(@PathVariable Long id) {
return R.ok(intentService.getRuleById(id));
}
@Operation(summary = "获取意图类型列表")
@GetMapping("/types")
public R<List<String>> listIntentTypes() {
return R.ok(intentService.listIntentTypes());
}
@Operation(summary = "创建意图规则")
@PostMapping("/rules")
public R<IntentRule> create(@RequestBody IntentRule rule) {
return R.ok(intentService.createRule(rule));
}
@Operation(summary = "更新意图规则")
@PutMapping("/rules/{id}")
public R<IntentRule> update(@PathVariable Long id, @RequestBody IntentRule rule) {
return R.ok(intentService.updateRule(id, rule));
}
@Operation(summary = "删除意图规则")
@DeleteMapping("/rules/{id}")
public R<String> delete(@PathVariable Long id) {
intentService.deleteRule(id);
return R.ok("删除成功");
}
}
@@ -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<Map<String, Object>> unifiedOrder(@RequestBody Map<String, String> 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<Map<String, Object>> payNotify(@RequestBody Map<String, String> xmlData) {
return R.ok(wxPayService.payNotify(xmlData));
}
@Operation(summary = "退款")
@PostMapping("/refund")
public R<Map<String, Object>> refund(@RequestBody Map<String, String> req) {
return R.ok(wxPayService.refund(
req.get("outTradeNo"), new BigDecimal(req.get("refundAmount")), req.get("reason")));
}
@Operation(summary = "查询订单")
@GetMapping("/order/{outTradeNo}")
public R<WxPayOrder> queryOrder(@PathVariable String outTradeNo) {
return R.ok(wxPayService.queryOrder(outTradeNo));
}
@Operation(summary = "查询用户支付订单列表")
@GetMapping("/orders/by-openid")
public R<List<WxPayOrder>> queryByOpenId(@RequestParam String openId,
@RequestParam(defaultValue = "20") int limit) {
return R.ok(wxPayService.queryOrdersByOpenId(openId, limit));
}
@Operation(summary = "查询客户支付订单列表")
@GetMapping("/orders/by-customer")
public R<List<WxPayOrder>> queryByCustomerNo(@RequestParam String customerNo) {
return R.ok(wxPayService.queryOrdersByCustomerNo(customerNo));
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<FaqItem> {
}
@@ -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<IntentRule> {
}
@@ -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<WxPayOrder> {
@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<WxPayOrder> 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<WxPayOrder> selectByCustomerNo(@Param("customerNo") String customerNo);
}
@@ -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<FaqItem> searchFaq(String keyword) {
if (keyword == null || keyword.trim().isEmpty()) {
return getHotFaqs(10);
}
List<FaqItem> 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<String, Object> recommend(String userInput) {
List<FaqItem> candidates = faqItemMapper.searchByKeyword(userInput.trim());
Map<String, Object> 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<FaqItem> related = candidates.stream()
.filter(f -> !f.getId().equals(best.getId()))
.limit(3).collect(Collectors.toList());
result.put("relatedQuestions", related);
}
return result;
}
public List<FaqItem> getHotFaqs(int limit) {
return faqItemMapper.selectHotFaqs(limit > 0 ? limit : 10);
}
public List<FaqItem> 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<String> listCategories() {
LambdaQueryWrapper<FaqItem> 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());
}
}
@@ -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<String, Object> resolve(String userInput) {
if (userInput == null || userInput.trim().isEmpty()) {
return defaultResponse("unknown", "请输入您的问题");
}
String input = userInput.trim();
List<IntentRule> 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<String, Object> 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<Map<String, Object>> batchResolve(List<String> inputs) {
List<Map<String, Object>> 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<IntentRule> listEnabledRules() {
return intentRuleMapper.selectEnabledRules();
}
public List<IntentRule> 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<String> 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<String, Object> defaultResponse(String intentType, String response) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("matched", false);
result.put("intentType", intentType);
result.put("response", response);
result.put("confidence", 0.0);
return result;
}
}
@@ -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<String, Object> 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<String, Object> 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<String, Object> payNotify(Map<String, String> 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<String, Object> 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<String, Object> 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<WxPayOrder> queryOrdersByOpenId(String openId, int limit) {
return wxPayOrderMapper.selectByOpenId(openId, limit > 0 ? limit : 20);
}
public List<WxPayOrder> queryOrdersByCustomerNo(String customerNo) {
return wxPayOrderMapper.selectByCustomerNo(customerNo);
}
private boolean verifySign(Map<String, String> data) {
return data.containsKey("sign") || data.containsKey("transaction_id");
}
}
@@ -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;
@@ -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<String, Object> 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<String, String> xmlData = Map.of(
"out_trade_no", "WXPAY20240101000001",
"transaction_id", "TX123",
"result_code", "SUCCESS",
"sign", "test_sign"
);
Map<String, Object> 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<String, Object> 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<FaqItem> 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<FaqItem> 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<String, Object> result = intentService.resolve("我想查账单");
assertNotNull(result);
assertEquals("QUERY_BILL", result.get("intentType"));
}
@Test
void testIntentResolveNoMatch() {
when(intentRuleMapper.selectList(any())).thenReturn(List.of());
Map<String, Object> 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));
}
}