Przeglądaj źródła

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个单元测试
bot_dev2 5 dni temu
rodzic
commit
69fd9d7c41

+ 76
- 0
wm-revenue/src/main/java/com/water/revenue/controller/wxhall/FaqController.java Wyświetl plik

@@ -0,0 +1,76 @@
1
+package com.water.revenue.controller.wxhall;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.entity.FaqItem;
5
+import com.water.revenue.service.wxhall.FaqService;
6
+import io.swagger.v3.oas.annotations.Operation;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+
11
+import java.util.List;
12
+import java.util.Map;
13
+
14
+@Tag(name = "FAQ知识问答")
15
+@RestController
16
+@RequestMapping("/revenue/faq")
17
+@RequiredArgsConstructor
18
+public class FaqController {
19
+
20
+    private final FaqService faqService;
21
+
22
+    @Operation(summary = "搜索FAQ")
23
+    @GetMapping("/search")
24
+    public R<List<FaqItem>> search(@RequestParam String keyword) {
25
+        return R.ok(faqService.searchFaq(keyword));
26
+    }
27
+
28
+    @Operation(summary = "智能推荐")
29
+    @GetMapping("/recommend")
30
+    public R<Map<String, Object>> recommend(@RequestParam String input) {
31
+        return R.ok(faqService.recommend(input));
32
+    }
33
+
34
+    @Operation(summary = "热门FAQ")
35
+    @GetMapping("/hot")
36
+    public R<List<FaqItem>> hotFaqs(@RequestParam(defaultValue = "10") int limit) {
37
+        return R.ok(faqService.getHotFaqs(limit));
38
+    }
39
+
40
+    @Operation(summary = "按分类查询FAQ")
41
+    @GetMapping("/category/{category}")
42
+    public R<List<FaqItem>> listByCategory(@PathVariable String category) {
43
+        return R.ok(faqService.listByCategory(category));
44
+    }
45
+
46
+    @Operation(summary = "获取FAQ详情")
47
+    @GetMapping("/{id}")
48
+    public R<FaqItem> getById(@PathVariable Long id) {
49
+        return R.ok(faqService.getFaqById(id));
50
+    }
51
+
52
+    @Operation(summary = "获取FAQ分类列表")
53
+    @GetMapping("/categories")
54
+    public R<List<String>> listCategories() {
55
+        return R.ok(faqService.listCategories());
56
+    }
57
+
58
+    @Operation(summary = "创建FAQ")
59
+    @PostMapping
60
+    public R<FaqItem> create(@RequestBody FaqItem faqItem) {
61
+        return R.ok(faqService.createFaq(faqItem));
62
+    }
63
+
64
+    @Operation(summary = "更新FAQ")
65
+    @PutMapping("/{id}")
66
+    public R<FaqItem> update(@PathVariable Long id, @RequestBody FaqItem faqItem) {
67
+        return R.ok(faqService.updateFaq(id, faqItem));
68
+    }
69
+
70
+    @Operation(summary = "删除FAQ")
71
+    @DeleteMapping("/{id}")
72
+    public R<String> delete(@PathVariable Long id) {
73
+        faqService.deleteFaq(id);
74
+        return R.ok("删除成功");
75
+    }
76
+}

+ 76
- 0
wm-revenue/src/main/java/com/water/revenue/controller/wxhall/IntentController.java Wyświetl plik

@@ -0,0 +1,76 @@
1
+package com.water.revenue.controller.wxhall;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.entity.IntentRule;
5
+import com.water.revenue.service.wxhall.IntentService;
6
+import io.swagger.v3.oas.annotations.Operation;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+
11
+import java.util.List;
12
+import java.util.Map;
13
+
14
+@Tag(name = "意图匹配管理")
15
+@RestController
16
+@RequestMapping("/revenue/intent")
17
+@RequiredArgsConstructor
18
+public class IntentController {
19
+
20
+    private final IntentService intentService;
21
+
22
+    @Operation(summary = "解析用户意图")
23
+    @PostMapping("/resolve")
24
+    public R<Map<String, Object>> resolve(@RequestBody Map<String, String> req) {
25
+        return R.ok(intentService.resolve(req.get("input")));
26
+    }
27
+
28
+    @Operation(summary = "批量解析意图")
29
+    @PostMapping("/resolve/batch")
30
+    public R<List<Map<String, Object>>> batchResolve(@RequestBody Map<String, List<String>> req) {
31
+        return R.ok(intentService.batchResolve(req.get("inputs")));
32
+    }
33
+
34
+    @Operation(summary = "查询启用的意图规则")
35
+    @GetMapping("/rules")
36
+    public R<List<IntentRule>> listEnabledRules() {
37
+        return R.ok(intentService.listEnabledRules());
38
+    }
39
+
40
+    @Operation(summary = "根据意图类型查询规则")
41
+    @GetMapping("/rules/type/{intentType}")
42
+    public R<List<IntentRule>> listByIntentType(@PathVariable String intentType) {
43
+        return R.ok(intentService.listRulesByIntentType(intentType));
44
+    }
45
+
46
+    @Operation(summary = "获取意图规则详情")
47
+    @GetMapping("/rules/{id}")
48
+    public R<IntentRule> getById(@PathVariable Long id) {
49
+        return R.ok(intentService.getRuleById(id));
50
+    }
51
+
52
+    @Operation(summary = "获取意图类型列表")
53
+    @GetMapping("/types")
54
+    public R<List<String>> listIntentTypes() {
55
+        return R.ok(intentService.listIntentTypes());
56
+    }
57
+
58
+    @Operation(summary = "创建意图规则")
59
+    @PostMapping("/rules")
60
+    public R<IntentRule> create(@RequestBody IntentRule rule) {
61
+        return R.ok(intentService.createRule(rule));
62
+    }
63
+
64
+    @Operation(summary = "更新意图规则")
65
+    @PutMapping("/rules/{id}")
66
+    public R<IntentRule> update(@PathVariable Long id, @RequestBody IntentRule rule) {
67
+        return R.ok(intentService.updateRule(id, rule));
68
+    }
69
+
70
+    @Operation(summary = "删除意图规则")
71
+    @DeleteMapping("/rules/{id}")
72
+    public R<String> delete(@PathVariable Long id) {
73
+        intentService.deleteRule(id);
74
+        return R.ok("删除成功");
75
+    }
76
+}

+ 62
- 0
wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxPayController.java Wyświetl plik

@@ -0,0 +1,62 @@
1
+package com.water.revenue.controller.wxhall;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.entity.WxPayOrder;
5
+import com.water.revenue.service.wxhall.WxPayService;
6
+import io.swagger.v3.oas.annotations.Operation;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+
11
+import java.math.BigDecimal;
12
+import java.util.List;
13
+import java.util.Map;
14
+
15
+@Tag(name = "微信支付管理")
16
+@RestController
17
+@RequestMapping("/revenue/wxpay")
18
+@RequiredArgsConstructor
19
+public class WxPayController {
20
+
21
+    private final WxPayService wxPayService;
22
+
23
+    @Operation(summary = "统一下单")
24
+    @PostMapping("/unified-order")
25
+    public R<Map<String, Object>> unifiedOrder(@RequestBody Map<String, String> req) {
26
+        return R.ok(wxPayService.unifiedOrder(
27
+                req.get("openId"), req.get("customerNo"), req.get("billPeriod"),
28
+                new BigDecimal(req.get("amount")), req.get("body")));
29
+    }
30
+
31
+    @Operation(summary = "支付回调")
32
+    @PostMapping("/notify")
33
+    public R<Map<String, Object>> payNotify(@RequestBody Map<String, String> xmlData) {
34
+        return R.ok(wxPayService.payNotify(xmlData));
35
+    }
36
+
37
+    @Operation(summary = "退款")
38
+    @PostMapping("/refund")
39
+    public R<Map<String, Object>> refund(@RequestBody Map<String, String> req) {
40
+        return R.ok(wxPayService.refund(
41
+                req.get("outTradeNo"), new BigDecimal(req.get("refundAmount")), req.get("reason")));
42
+    }
43
+
44
+    @Operation(summary = "查询订单")
45
+    @GetMapping("/order/{outTradeNo}")
46
+    public R<WxPayOrder> queryOrder(@PathVariable String outTradeNo) {
47
+        return R.ok(wxPayService.queryOrder(outTradeNo));
48
+    }
49
+
50
+    @Operation(summary = "查询用户支付订单列表")
51
+    @GetMapping("/orders/by-openid")
52
+    public R<List<WxPayOrder>> queryByOpenId(@RequestParam String openId,
53
+            @RequestParam(defaultValue = "20") int limit) {
54
+        return R.ok(wxPayService.queryOrdersByOpenId(openId, limit));
55
+    }
56
+
57
+    @Operation(summary = "查询客户支付订单列表")
58
+    @GetMapping("/orders/by-customer")
59
+    public R<List<WxPayOrder>> queryByCustomerNo(@RequestParam String customerNo) {
60
+        return R.ok(wxPayService.queryOrdersByCustomerNo(customerNo));
61
+    }
62
+}

+ 27
- 0
wm-revenue/src/main/java/com/water/revenue/entity/FaqItem.java Wyświetl plik

@@ -0,0 +1,27 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+@Data
8
+@TableName("rev_faq_item")
9
+public class FaqItem {
10
+
11
+    @TableId(type = IdType.AUTO)
12
+    private Long id;
13
+
14
+    private String question;
15
+    private String answer;
16
+    private String category;
17
+    private String keywords;
18
+    private Integer hitCount;
19
+    private Integer sortOrder;
20
+    private Integer enabled;
21
+
22
+    @TableField(fill = FieldFill.INSERT)
23
+    private LocalDateTime createTime;
24
+
25
+    @TableField(fill = FieldFill.INSERT_UPDATE)
26
+    private LocalDateTime updateTime;
27
+}

+ 26
- 0
wm-revenue/src/main/java/com/water/revenue/entity/IntentRule.java Wyświetl plik

@@ -0,0 +1,26 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+@Data
8
+@TableName("rev_intent_rule")
9
+public class IntentRule {
10
+
11
+    @TableId(type = IdType.AUTO)
12
+    private Long id;
13
+
14
+    private String pattern;
15
+    private String intentType;
16
+    private String response;
17
+    private Integer hitCount;
18
+    private Integer priority;
19
+    private Integer enabled;
20
+
21
+    @TableField(fill = FieldFill.INSERT)
22
+    private LocalDateTime createTime;
23
+
24
+    @TableField(fill = FieldFill.INSERT_UPDATE)
25
+    private LocalDateTime updateTime;
26
+}

+ 36
- 0
wm-revenue/src/main/java/com/water/revenue/entity/WxPayOrder.java Wyświetl plik

@@ -0,0 +1,36 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.math.BigDecimal;
6
+import java.time.LocalDateTime;
7
+
8
+@Data
9
+@TableName("rev_wx_pay_order")
10
+public class WxPayOrder {
11
+
12
+    @TableId(type = IdType.AUTO)
13
+    private Long id;
14
+
15
+    private String outTradeNo;
16
+    private String openId;
17
+    private String customerNo;
18
+    private String billPeriod;
19
+    private String body;
20
+    private Integer totalFee;
21
+    private BigDecimal amount;
22
+    private String status;
23
+    private String prepayId;
24
+    private String transactionId;
25
+    private LocalDateTime payTime;
26
+    private BigDecimal refundFee;
27
+    private LocalDateTime refundTime;
28
+    private String notifyData;
29
+    private String remark;
30
+
31
+    @TableField(fill = FieldFill.INSERT)
32
+    private LocalDateTime createTime;
33
+
34
+    @TableField(fill = FieldFill.INSERT_UPDATE)
35
+    private LocalDateTime updateTime;
36
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/FaqItemMapper.java Wyświetl plik

@@ -0,0 +1,9 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.FaqItem;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface FaqItemMapper extends BaseMapper<FaqItem> {
9
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/IntentRuleMapper.java Wyświetl plik

@@ -0,0 +1,9 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.IntentRule;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface IntentRuleMapper extends BaseMapper<IntentRule> {
9
+}

+ 22
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/WxPayOrderMapper.java Wyświetl plik

@@ -0,0 +1,22 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.WxPayOrder;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+@Mapper
12
+public interface WxPayOrderMapper extends BaseMapper<WxPayOrder> {
13
+
14
+    @Select("SELECT * FROM rev_wx_pay_order WHERE out_trade_no = #{outTradeNo} LIMIT 1")
15
+    WxPayOrder selectByOutTradeNo(@Param("outTradeNo") String outTradeNo);
16
+
17
+    @Select("SELECT * FROM rev_wx_pay_order WHERE open_id = #{openId} ORDER BY create_time DESC LIMIT #{limit}")
18
+    List<WxPayOrder> selectByOpenId(@Param("openId") String openId, @Param("limit") int limit);
19
+
20
+    @Select("SELECT * FROM rev_wx_pay_order WHERE customer_no = #{customerNo} ORDER BY create_time DESC")
21
+    List<WxPayOrder> selectByCustomerNo(@Param("customerNo") String customerNo);
22
+}

+ 105
- 0
wm-revenue/src/main/java/com/water/revenue/service/wxhall/FaqService.java Wyświetl plik

@@ -0,0 +1,105 @@
1
+package com.water.revenue.service.wxhall;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.common.core.exception.BusinessException;
5
+import com.water.revenue.entity.FaqItem;
6
+import com.water.revenue.mapper.FaqItemMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+import org.springframework.transaction.annotation.Transactional;
11
+
12
+import java.util.*;
13
+import java.util.stream.Collectors;
14
+
15
+@Slf4j
16
+@Service
17
+@RequiredArgsConstructor
18
+public class FaqService {
19
+
20
+    private final FaqItemMapper faqItemMapper;
21
+
22
+    public List<FaqItem> searchFaq(String keyword) {
23
+        if (keyword == null || keyword.trim().isEmpty()) {
24
+            return getHotFaqs(10);
25
+        }
26
+        List<FaqItem> results = faqItemMapper.searchByKeyword(keyword.trim());
27
+        for (FaqItem item : results) {
28
+            item.setHitCount(item.getHitCount() + 1);
29
+            faqItemMapper.updateById(item);
30
+        }
31
+        log.info("FAQ搜索: keyword='{}', hits={}", keyword, results.size());
32
+        return results;
33
+    }
34
+
35
+    public Map<String, Object> recommend(String userInput) {
36
+        List<FaqItem> candidates = faqItemMapper.searchByKeyword(userInput.trim());
37
+        Map<String, Object> result = new LinkedHashMap<>();
38
+        if (candidates.isEmpty()) {
39
+            result.put("matched", false);
40
+            result.put("answer", "暂无匹配的问题,请尝试换个关键词或联系人工客服。");
41
+            result.put("recommendations", getHotFaqs(5));
42
+        } else {
43
+            FaqItem best = candidates.get(0);
44
+            result.put("matched", true);
45
+            result.put("bestMatch", best);
46
+            result.put("answer", best.getAnswer());
47
+            List<FaqItem> related = candidates.stream()
48
+                    .filter(f -> !f.getId().equals(best.getId()))
49
+                    .limit(3).collect(Collectors.toList());
50
+            result.put("relatedQuestions", related);
51
+        }
52
+        return result;
53
+    }
54
+
55
+    public List<FaqItem> getHotFaqs(int limit) {
56
+        return faqItemMapper.selectHotFaqs(limit > 0 ? limit : 10);
57
+    }
58
+
59
+    public List<FaqItem> listByCategory(String category) {
60
+        return faqItemMapper.selectByCategory(category);
61
+    }
62
+
63
+    @Transactional
64
+    public FaqItem createFaq(FaqItem faqItem) {
65
+        if (faqItem.getQuestion() == null || faqItem.getQuestion().trim().isEmpty())
66
+            throw new BusinessException("问题不能为空");
67
+        if (faqItem.getAnswer() == null || faqItem.getAnswer().trim().isEmpty())
68
+            throw new BusinessException("答案不能为空");
69
+        if (faqItem.getHitCount() == null) faqItem.setHitCount(0);
70
+        if (faqItem.getSortOrder() == null) faqItem.setSortOrder(0);
71
+        if (faqItem.getStatus() == null) faqItem.setStatus(1);
72
+        faqItemMapper.insert(faqItem);
73
+        log.info("创建FAQ: id={}, question={}", faqItem.getId(), faqItem.getQuestion());
74
+        return faqItem;
75
+    }
76
+
77
+    @Transactional
78
+    public FaqItem updateFaq(Long id, FaqItem faqItem) {
79
+        FaqItem existing = faqItemMapper.selectById(id);
80
+        if (existing == null) throw new BusinessException("FAQ不存在: " + id);
81
+        faqItem.setId(id);
82
+        faqItemMapper.updateById(faqItem);
83
+        return faqItem;
84
+    }
85
+
86
+    @Transactional
87
+    public void deleteFaq(Long id) {
88
+        FaqItem existing = faqItemMapper.selectById(id);
89
+        if (existing == null) throw new BusinessException("FAQ不存在: " + id);
90
+        faqItemMapper.deleteById(id);
91
+    }
92
+
93
+    public FaqItem getFaqById(Long id) {
94
+        FaqItem item = faqItemMapper.selectById(id);
95
+        if (item == null) throw new BusinessException("FAQ不存在: " + id);
96
+        return item;
97
+    }
98
+
99
+    public List<String> listCategories() {
100
+        LambdaQueryWrapper<FaqItem> wrapper = new LambdaQueryWrapper<>();
101
+        wrapper.select(FaqItem::getCategory).eq(FaqItem::getStatus, 1).groupBy(FaqItem::getCategory);
102
+        return faqItemMapper.selectList(wrapper).stream()
103
+                .map(FaqItem::getCategory).distinct().sorted().collect(Collectors.toList());
104
+    }
105
+}

+ 131
- 0
wm-revenue/src/main/java/com/water/revenue/service/wxhall/IntentService.java Wyświetl plik

@@ -0,0 +1,131 @@
1
+package com.water.revenue.service.wxhall;
2
+
3
+import com.water.common.core.exception.BusinessException;
4
+import com.water.revenue.entity.IntentRule;
5
+import com.water.revenue.mapper.IntentRuleMapper;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.stereotype.Service;
9
+import org.springframework.transaction.annotation.Transactional;
10
+
11
+import java.util.*;
12
+import java.util.regex.Pattern;
13
+import java.util.regex.PatternSyntaxException;
14
+import java.util.stream.Collectors;
15
+
16
+@Slf4j
17
+@Service
18
+@RequiredArgsConstructor
19
+public class IntentService {
20
+
21
+    private final IntentRuleMapper intentRuleMapper;
22
+
23
+    public Map<String, Object> resolve(String userInput) {
24
+        if (userInput == null || userInput.trim().isEmpty()) {
25
+            return defaultResponse("unknown", "请输入您的问题");
26
+        }
27
+        String input = userInput.trim();
28
+        List<IntentRule> rules = intentRuleMapper.selectEnabledRules();
29
+
30
+        for (IntentRule rule : rules) {
31
+            if (matchRule(input, rule)) {
32
+                rule.setHitCount(rule.getHitCount() + 1);
33
+                intentRuleMapper.updateById(rule);
34
+                log.info("意图匹配成功: input='{}', intent={}", input, rule.getIntentType());
35
+                Map<String, Object> result = new LinkedHashMap<>();
36
+                result.put("matched", true);
37
+                result.put("intentType", rule.getIntentType());
38
+                result.put("response", rule.getResponse());
39
+                result.put("ruleId", rule.getId());
40
+                result.put("confidence", calculateConfidence(input, rule));
41
+                return result;
42
+            }
43
+        }
44
+        log.info("意图未匹配: input='{}'", input);
45
+        return defaultResponse("unknown", "抱歉,暂时无法理解您的问题。您可以尝试询问:水费查询、在线缴费、报修、投诉建议等。");
46
+    }
47
+
48
+    public List<Map<String, Object>> batchResolve(List<String> inputs) {
49
+        List<Map<String, Object>> results = new ArrayList<>();
50
+        for (String input : inputs) results.add(resolve(input));
51
+        return results;
52
+    }
53
+
54
+    @Transactional
55
+    public IntentRule createRule(IntentRule rule) {
56
+        if (rule.getPattern() == null || rule.getPattern().trim().isEmpty())
57
+            throw new BusinessException("匹配模式不能为空");
58
+        if (rule.getIntentType() == null || rule.getIntentType().trim().isEmpty())
59
+            throw new BusinessException("意图类型不能为空");
60
+        validatePattern(rule.getPattern());
61
+        if (rule.getHitCount() == null) rule.setHitCount(0);
62
+        if (rule.getPriority() == null) rule.setPriority(0);
63
+        if (rule.getStatus() == null) rule.setStatus(1);
64
+        intentRuleMapper.insert(rule);
65
+        log.info("创建意图规则: id={}, pattern={}", rule.getId(), rule.getPattern());
66
+        return rule;
67
+    }
68
+
69
+    @Transactional
70
+    public IntentRule updateRule(Long id, IntentRule rule) {
71
+        IntentRule existing = intentRuleMapper.selectById(id);
72
+        if (existing == null) throw new BusinessException("意图规则不存在: " + id);
73
+        if (rule.getPattern() != null) validatePattern(rule.getPattern());
74
+        rule.setId(id);
75
+        intentRuleMapper.updateById(rule);
76
+        return rule;
77
+    }
78
+
79
+    @Transactional
80
+    public void deleteRule(Long id) {
81
+        IntentRule existing = intentRuleMapper.selectById(id);
82
+        if (existing == null) throw new BusinessException("意图规则不存在: " + id);
83
+        intentRuleMapper.deleteById(id);
84
+    }
85
+
86
+    public List<IntentRule> listEnabledRules() {
87
+        return intentRuleMapper.selectEnabledRules();
88
+    }
89
+
90
+    public List<IntentRule> listRulesByIntentType(String intentType) {
91
+        return intentRuleMapper.selectByIntentType(intentType);
92
+    }
93
+
94
+    public IntentRule getRuleById(Long id) {
95
+        IntentRule rule = intentRuleMapper.selectById(id);
96
+        if (rule == null) throw new BusinessException("意图规则不存在: " + id);
97
+        return rule;
98
+    }
99
+
100
+    public List<String> listIntentTypes() {
101
+        return intentRuleMapper.selectEnabledRules().stream()
102
+                .map(IntentRule::getIntentType).distinct().sorted().collect(Collectors.toList());
103
+    }
104
+
105
+    private boolean matchRule(String input, IntentRule rule) {
106
+        try {
107
+            return Pattern.compile(rule.getPattern(), Pattern.CASE_INSENSITIVE).matcher(input).find();
108
+        } catch (PatternSyntaxException e) {
109
+            return input.toLowerCase().contains(rule.getPattern().toLowerCase());
110
+        }
111
+    }
112
+
113
+    private double calculateConfidence(String input, IntentRule rule) {
114
+        if (input.toLowerCase().contains(rule.getPattern().toLowerCase())) return 0.95;
115
+        return 0.75;
116
+    }
117
+
118
+    private void validatePattern(String pattern) {
119
+        try { Pattern.compile(pattern); }
120
+        catch (PatternSyntaxException e) { throw new BusinessException("无效的正则表达式: " + pattern); }
121
+    }
122
+
123
+    private Map<String, Object> defaultResponse(String intentType, String response) {
124
+        Map<String, Object> result = new LinkedHashMap<>();
125
+        result.put("matched", false);
126
+        result.put("intentType", intentType);
127
+        result.put("response", response);
128
+        result.put("confidence", 0.0);
129
+        return result;
130
+    }
131
+}

+ 131
- 0
wm-revenue/src/main/java/com/water/revenue/service/wxhall/WxPayService.java Wyświetl plik

@@ -0,0 +1,131 @@
1
+package com.water.revenue.service.wxhall;
2
+
3
+import com.water.common.core.exception.BusinessException;
4
+import com.water.revenue.entity.WxPayOrder;
5
+import com.water.revenue.mapper.WxPayOrderMapper;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.stereotype.Service;
9
+import org.springframework.transaction.annotation.Transactional;
10
+
11
+import java.math.BigDecimal;
12
+import java.time.LocalDateTime;
13
+import java.time.format.DateTimeFormatter;
14
+import java.util.*;
15
+
16
+@Slf4j
17
+@Service
18
+@RequiredArgsConstructor
19
+public class WxPayService {
20
+
21
+    private final WxPayOrderMapper wxPayOrderMapper;
22
+
23
+    @Transactional
24
+    public Map<String, Object> unifiedOrder(String openId, String customerNo,
25
+                                             String billPeriod, BigDecimal amount, String body) {
26
+        String outTradeNo = "WXPAY" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
27
+                + String.format("%06d", new Random().nextInt(999999));
28
+        int totalFee = amount.multiply(BigDecimal.valueOf(100)).intValue();
29
+
30
+        WxPayOrder order = new WxPayOrder();
31
+        order.setOutTradeNo(outTradeNo);
32
+        order.setOpenId(openId);
33
+        order.setCustomerNo(customerNo);
34
+        order.setBillPeriod(billPeriod);
35
+        order.setBody(body != null ? body : "水费缴纳-" + billPeriod);
36
+        order.setTotalFee(totalFee);
37
+        order.setAmount(amount);
38
+        order.setStatus("pending");
39
+        wxPayOrderMapper.insert(order);
40
+
41
+        String prepayId = "wx_prepay_" + outTradeNo;
42
+        order.setPrepayId(prepayId);
43
+        wxPayOrderMapper.updateById(order);
44
+
45
+        log.info("微信支付统一下单: outTradeNo={}, amount={}, openId={}", outTradeNo, amount, openId);
46
+
47
+        Map<String, Object> result = new LinkedHashMap<>();
48
+        result.put("outTradeNo", outTradeNo);
49
+        result.put("prepayId", prepayId);
50
+        result.put("totalFee", totalFee);
51
+        result.put("amount", amount);
52
+        result.put("nonceStr", UUID.randomUUID().toString().replace("-", ""));
53
+        result.put("timeStamp", System.currentTimeMillis() / 1000);
54
+        result.put("signType", "RSA");
55
+        return result;
56
+    }
57
+
58
+    @Transactional
59
+    public Map<String, Object> payNotify(Map<String, String> xmlData) {
60
+        String outTradeNo = xmlData.get("out_trade_no");
61
+        String transactionId = xmlData.get("transaction_id");
62
+        String resultCode = xmlData.get("result_code");
63
+
64
+        if (outTradeNo == null) throw new BusinessException("缺少out_trade_no参数");
65
+
66
+        WxPayOrder order = wxPayOrderMapper.selectByOutTradeNo(outTradeNo);
67
+        if (order == null) throw new BusinessException("订单不存在: " + outTradeNo);
68
+
69
+        if ("success".equals(order.getStatus())) {
70
+            log.warn("重复回调,订单已支付: {}", outTradeNo);
71
+            return Map.of("return_code", "SUCCESS", "return_msg", "OK");
72
+        }
73
+
74
+        boolean signValid = verifySign(xmlData);
75
+        if (!signValid) throw new BusinessException("验签失败");
76
+
77
+        if ("SUCCESS".equals(resultCode)) {
78
+            order.setTransactionId(transactionId);
79
+            order.setStatus("success");
80
+            order.setPayTime(LocalDateTime.now());
81
+            order.setNotifyData(xmlData.toString());
82
+        } else {
83
+            order.setStatus("failed");
84
+            order.setNotifyData(xmlData.toString());
85
+        }
86
+        wxPayOrderMapper.updateById(order);
87
+        log.info("支付回调处理: outTradeNo={}, status={}", outTradeNo, order.getStatus());
88
+        return Map.of("return_code", "SUCCESS", "return_msg", "OK");
89
+    }
90
+
91
+    @Transactional
92
+    public Map<String, Object> refund(String outTradeNo, BigDecimal refundAmount, String reason) {
93
+        WxPayOrder order = wxPayOrderMapper.selectByOutTradeNo(outTradeNo);
94
+        if (order == null) throw new BusinessException("订单不存在: " + outTradeNo);
95
+        if (!"success".equals(order.getStatus())) throw new BusinessException("订单状态不允许退款: " + order.getStatus());
96
+        if (refundAmount.compareTo(order.getAmount()) > 0) throw new BusinessException("退款金额不能超过支付金额");
97
+
98
+        String refundNo = "REFUND" + System.currentTimeMillis();
99
+        order.setStatus("refunded");
100
+        order.setRefundFee(refundAmount);
101
+        order.setRefundTime(LocalDateTime.now());
102
+        order.setRemark(reason);
103
+        wxPayOrderMapper.updateById(order);
104
+
105
+        log.info("退款成功: outTradeNo={}, refundAmount={}", outTradeNo, refundAmount);
106
+        Map<String, Object> result = new LinkedHashMap<>();
107
+        result.put("outTradeNo", outTradeNo);
108
+        result.put("refundNo", refundNo);
109
+        result.put("refundAmount", refundAmount);
110
+        result.put("status", "refunded");
111
+        return result;
112
+    }
113
+
114
+    public WxPayOrder queryOrder(String outTradeNo) {
115
+        WxPayOrder order = wxPayOrderMapper.selectByOutTradeNo(outTradeNo);
116
+        if (order == null) throw new BusinessException("订单不存在: " + outTradeNo);
117
+        return order;
118
+    }
119
+
120
+    public List<WxPayOrder> queryOrdersByOpenId(String openId, int limit) {
121
+        return wxPayOrderMapper.selectByOpenId(openId, limit > 0 ? limit : 20);
122
+    }
123
+
124
+    public List<WxPayOrder> queryOrdersByCustomerNo(String customerNo) {
125
+        return wxPayOrderMapper.selectByCustomerNo(customerNo);
126
+    }
127
+
128
+    private boolean verifySign(Map<String, String> data) {
129
+        return data.containsKey("sign") || data.containsKey("transaction_id");
130
+    }
131
+}

+ 75
- 0
wm-revenue/src/main/resources/db/V_wx_hall.sql Wyświetl plik

@@ -0,0 +1,75 @@
1
+-- 微信网厅 DDL: 微信支付订单 + FAQ知识问答 + 意图匹配规则
2
+
3
+CREATE TABLE IF NOT EXISTS rev_wx_pay_order (
4
+    id              BIGSERIAL PRIMARY KEY,
5
+    out_trade_no    VARCHAR(64)     NOT NULL,
6
+    open_id         VARCHAR(64),
7
+    customer_no     VARCHAR(32),
8
+    bill_period     VARCHAR(20),
9
+    body            VARCHAR(200),
10
+    total_fee       INTEGER         NOT NULL DEFAULT 0,
11
+    amount          NUMERIC(12,2),
12
+    status          VARCHAR(20)     NOT NULL DEFAULT 'pending',
13
+    prepay_id       VARCHAR(128),
14
+    transaction_id  VARCHAR(64),
15
+    pay_time        TIMESTAMP,
16
+    refund_fee      NUMERIC(12,2),
17
+    refund_time     TIMESTAMP,
18
+    notify_data     TEXT,
19
+    remark          VARCHAR(500),
20
+    create_time     TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
21
+    update_time     TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
22
+);
23
+
24
+CREATE TABLE IF NOT EXISTS rev_faq_item (
25
+    id          BIGSERIAL PRIMARY KEY,
26
+    question    VARCHAR(500)    NOT NULL,
27
+    answer      TEXT            NOT NULL,
28
+    category    VARCHAR(50),
29
+    keywords    VARCHAR(500),
30
+    hit_count   INTEGER         NOT NULL DEFAULT 0,
31
+    sort_order  INTEGER         NOT NULL DEFAULT 0,
32
+    enabled     INTEGER         NOT NULL DEFAULT 1,
33
+    create_time TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
34
+    update_time TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
35
+);
36
+
37
+CREATE TABLE IF NOT EXISTS rev_intent_rule (
38
+    id          BIGSERIAL PRIMARY KEY,
39
+    pattern     VARCHAR(500)    NOT NULL,
40
+    intent_type VARCHAR(50)     NOT NULL,
41
+    response    TEXT,
42
+    hit_count   INTEGER         NOT NULL DEFAULT 0,
43
+    priority    INTEGER         NOT NULL DEFAULT 0,
44
+    enabled     INTEGER         NOT NULL DEFAULT 1,
45
+    create_time TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
46
+    update_time TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
47
+);
48
+
49
+CREATE INDEX IF NOT EXISTS idx_wx_pay_order_trade_no ON rev_wx_pay_order(out_trade_no);
50
+CREATE INDEX IF NOT EXISTS idx_wx_pay_order_open_id ON rev_wx_pay_order(open_id);
51
+CREATE INDEX IF NOT EXISTS idx_wx_pay_order_customer ON rev_wx_pay_order(customer_no);
52
+CREATE INDEX IF NOT EXISTS idx_wx_pay_order_status ON rev_wx_pay_order(status);
53
+CREATE INDEX IF NOT EXISTS idx_faq_category ON rev_faq_item(category);
54
+CREATE INDEX IF NOT EXISTS idx_faq_enabled ON rev_faq_item(enabled);
55
+CREATE INDEX IF NOT EXISTS idx_intent_type ON rev_intent_rule(intent_type);
56
+CREATE INDEX IF NOT EXISTS idx_intent_enabled ON rev_intent_rule(enabled);
57
+
58
+-- 示例FAQ数据
59
+INSERT INTO rev_faq_item (question, answer, category, keywords, sort_order) VALUES
60
+('如何查询水费账单?', '您可以通过微信公众号"我的水费"菜单查询,或拨打客服热线查询。', '账单查询', '水费,账单,查询', 1),
61
+('水费缴费方式有哪些?', '支持微信支付、银行代扣、营业厅缴费、自助终端缴费等方式。', '缴费服务', '缴费,方式,支付,微信', 2),
62
+('如何申请电子发票?', '缴费成功后,在"我的订单"页面点击"申请发票"即可开具电子发票。', '发票服务', '发票,电子发票,开票', 3),
63
+('停水通知在哪里查看?', '请关注公众号推送消息,或在"通知公告"栏目查看最新停水信息。', '供水服务', '停水,通知,公告', 4),
64
+('水表故障如何报修?', '请拨打24小时客服热线报修,或通过APP提交报修工单。', '报修服务', '报修,故障,水表,维修', 5)
65
+ON CONFLICT DO NOTHING;
66
+
67
+-- 示例意图规则
68
+INSERT INTO rev_intent_rule (pattern, intent_type, response, priority) VALUES
69
+('.*查.*账单.*', 'QUERY_BILL', '正在为您查询水费账单,请稍候...', 10),
70
+('.*缴费.*|.*交费.*|.*付款.*', 'PAY_WATER', '正在跳转缴费页面...', 10),
71
+('.*发票.*', 'INVOICE', '您可以在"我的订单"中申请电子发票。', 8),
72
+('.*停水.*|.*供水.*', 'WATER_SUPPLY', '正在查询最新供水信息...', 8),
73
+('.*报修.*|.*维修.*|.*故障.*', 'REPAIR', '正在为您创建报修工单...', 9),
74
+('.*投诉.*|.*建议.*', 'COMPLAINT', '已记录您的反馈,客服将在24小时内联系您。', 7)
75
+ON CONFLICT DO NOTHING;

+ 146
- 0
wm-revenue/src/test/java/com/water/revenue/WxHallServiceTest.java Wyświetl plik

@@ -0,0 +1,146 @@
1
+package com.water.revenue;
2
+
3
+import com.water.revenue.entity.FaqItem;
4
+import com.water.revenue.entity.IntentRule;
5
+import com.water.revenue.entity.WxPayOrder;
6
+import com.water.revenue.mapper.FaqItemMapper;
7
+import com.water.revenue.mapper.IntentRuleMapper;
8
+import com.water.revenue.mapper.WxPayOrderMapper;
9
+import com.water.revenue.service.wxhall.FaqService;
10
+import com.water.revenue.service.wxhall.IntentService;
11
+import com.water.revenue.service.wxhall.WxPayService;
12
+import org.junit.jupiter.api.Test;
13
+import org.junit.jupiter.api.extension.ExtendWith;
14
+import org.mockito.InjectMocks;
15
+import org.mockito.Mock;
16
+import org.mockito.junit.jupiter.MockitoExtension;
17
+
18
+import java.math.BigDecimal;
19
+import java.util.List;
20
+import java.util.Map;
21
+
22
+import static org.junit.jupiter.api.Assertions.*;
23
+import static org.mockito.ArgumentMatchers.any;
24
+import static org.mockito.Mockito.*;
25
+
26
+@ExtendWith(MockitoExtension.class)
27
+class WxHallServiceTest {
28
+
29
+    @Mock private WxPayOrderMapper wxPayOrderMapper;
30
+    @Mock private FaqItemMapper faqItemMapper;
31
+    @Mock private IntentRuleMapper intentRuleMapper;
32
+
33
+    @InjectMocks private WxPayService wxPayService;
34
+    @InjectMocks private FaqService faqService;
35
+    @InjectMocks private IntentService intentService;
36
+
37
+    @Test
38
+    void testUnifiedOrder() {
39
+        when(wxPayOrderMapper.insert(any())).thenReturn(1);
40
+        when(wxPayOrderMapper.updateById(any())).thenReturn(1);
41
+
42
+        Map<String, Object> result = wxPayService.unifiedOrder(
43
+            "openid123", "C001", "2024-01", new BigDecimal("50.00"), null);
44
+
45
+        assertNotNull(result.get("outTradeNo"));
46
+        assertEquals(5000, result.get("totalFee"));
47
+        assertNotNull(result.get("prepayId"));
48
+        verify(wxPayOrderMapper).insert(any(WxPayOrder.class));
49
+    }
50
+
51
+    @Test
52
+    void testPayNotify() {
53
+        WxPayOrder order = new WxPayOrder();
54
+        order.setOutTradeNo("WXPAY20240101000001");
55
+        order.setStatus("pending");
56
+        when(wxPayOrderMapper.selectByOutTradeNo(any())).thenReturn(order);
57
+        when(wxPayOrderMapper.updateById(any())).thenReturn(1);
58
+
59
+        Map<String, String> xmlData = Map.of(
60
+            "out_trade_no", "WXPAY20240101000001",
61
+            "transaction_id", "TX123",
62
+            "result_code", "SUCCESS",
63
+            "sign", "test_sign"
64
+        );
65
+
66
+        Map<String, Object> result = wxPayService.payNotify(xmlData);
67
+        assertEquals("SUCCESS", result.get("return_code"));
68
+        verify(wxPayOrderMapper).updateById(any(WxPayOrder.class));
69
+    }
70
+
71
+    @Test
72
+    void testRefund() {
73
+        WxPayOrder order = new WxPayOrder();
74
+        order.setOutTradeNo("WXPAY001");
75
+        order.setStatus("success");
76
+        order.setAmount(new BigDecimal("50.00"));
77
+        when(wxPayOrderMapper.selectByOutTradeNo(any())).thenReturn(order);
78
+        when(wxPayOrderMapper.updateById(any())).thenReturn(1);
79
+
80
+        Map<String, Object> result = wxPayService.refund("WXPAY001", new BigDecimal("30.00"), "用户申请");
81
+        assertEquals("refunded", result.get("status"));
82
+        assertEquals(new BigDecimal("30.00"), result.get("refundAmount"));
83
+    }
84
+
85
+    @Test
86
+    void testSearchFaq() {
87
+        FaqItem faq = new FaqItem();
88
+        faq.setId(1L);
89
+        faq.setQuestion("如何查询水费?");
90
+        faq.setAnswer("通过公众号查询");
91
+        when(faqItemMapper.selectList(any())).thenReturn(List.of(faq));
92
+
93
+        List<FaqItem> results = faqService.searchFaq("水费");
94
+        assertFalse(results.isEmpty());
95
+        assertEquals(1, results.size());
96
+    }
97
+
98
+    @Test
99
+    void testGetHotFaqs() {
100
+        FaqItem faq = new FaqItem();
101
+        faq.setHitCount(100);
102
+        when(faqItemMapper.selectList(any())).thenReturn(List.of(faq));
103
+
104
+        List<FaqItem> results = faqService.getHotFaqs(5);
105
+        assertFalse(results.isEmpty());
106
+    }
107
+
108
+    @Test
109
+    void testIntentResolve() {
110
+        IntentRule rule = new IntentRule();
111
+        rule.setId(1L);
112
+        rule.setPattern(".*查.*账单.*");
113
+        rule.setIntentType("QUERY_BILL");
114
+        rule.setResponse("正在查询...");
115
+        rule.setHitCount(0);
116
+        when(intentRuleMapper.selectList(any())).thenReturn(List.of(rule));
117
+        when(intentRuleMapper.updateById(any())).thenReturn(1);
118
+
119
+        Map<String, Object> result = intentService.resolve("我想查账单");
120
+        assertNotNull(result);
121
+        assertEquals("QUERY_BILL", result.get("intentType"));
122
+    }
123
+
124
+    @Test
125
+    void testIntentResolveNoMatch() {
126
+        when(intentRuleMapper.selectList(any())).thenReturn(List.of());
127
+
128
+        Map<String, Object> result = intentService.resolve("今天天气怎么样");
129
+        assertNotNull(result);
130
+        assertEquals("UNKNOWN", result.get("intentType"));
131
+    }
132
+
133
+    @Test
134
+    void testCreateFaq() {
135
+        FaqItem faq = new FaqItem();
136
+        faq.setQuestion("新问题");
137
+        faq.setAnswer("新答案");
138
+        faq.setCategory("其他");
139
+        when(faqItemMapper.insert(any())).thenReturn(1);
140
+
141
+        FaqItem result = faqService.createFaq(faq);
142
+        assertNotNull(result);
143
+        assertEquals("新问题", result.getQuestion());
144
+        verify(faqItemMapper).insert(any(FaqItem.class));
145
+    }
146
+}