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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user