Bläddra i källkod

feat(wm-revenue): #85 智能表平台+短信平台+支付宝对接

bot_dev2 4 dagar sedan
förälder
incheckning
9b11a3f7bf

+ 106
- 0
sql/V_smart_meter_sms_alipay.sql Visa fil

@@ -0,0 +1,106 @@
1
+-- =====================================================
2
+-- V_smart_meter_sms_alipay.sql
3
+-- 智能表平台 + 短信平台 + 支付宝生活缴费 DDL
4
+-- =====================================================
5
+
6
+-- 1. 智能水表表
7
+CREATE TABLE IF NOT EXISTS rev_smart_meter (
8
+    id              BIGSERIAL PRIMARY KEY,
9
+    meter_no        VARCHAR(64)   NOT NULL,
10
+    customer_no     VARCHAR(64),
11
+    signal_strength INTEGER       DEFAULT 100,
12
+    battery_level   INTEGER       DEFAULT 100,
13
+    valve_status    VARCHAR(16)   DEFAULT 'OPEN',
14
+    last_report_time TIMESTAMP,
15
+    online_status   VARCHAR(16)   DEFAULT 'ONLINE',
16
+    current_reading DOUBLE PRECISION DEFAULT 0,
17
+    install_address VARCHAR(256),
18
+    area_code       VARCHAR(32),
19
+    remark          VARCHAR(512),
20
+    create_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
21
+    update_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP
22
+);
23
+
24
+CREATE INDEX IF NOT EXISTS idx_smart_meter_meter_no ON rev_smart_meter(meter_no);
25
+CREATE INDEX IF NOT EXISTS idx_smart_meter_customer_no ON rev_smart_meter(customer_no);
26
+CREATE INDEX IF NOT EXISTS idx_smart_meter_online_status ON rev_smart_meter(online_status);
27
+CREATE INDEX IF NOT EXISTS idx_smart_meter_area_code ON rev_smart_meter(area_code);
28
+
29
+-- 2. 短信模板表
30
+CREATE TABLE IF NOT EXISTS rev_sms_template (
31
+    id              BIGSERIAL PRIMARY KEY,
32
+    template_name   VARCHAR(128)  NOT NULL,
33
+    template_type   VARCHAR(32)   NOT NULL,
34
+    content         TEXT          NOT NULL,
35
+    variables       VARCHAR(512),
36
+    enabled         INTEGER       DEFAULT 1,
37
+    remark          VARCHAR(512),
38
+    create_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
39
+    update_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP
40
+);
41
+
42
+CREATE INDEX IF NOT EXISTS idx_sms_template_type ON rev_sms_template(template_type);
43
+
44
+-- 3. 短信发送记录表
45
+CREATE TABLE IF NOT EXISTS rev_sms_record (
46
+    id              BIGSERIAL PRIMARY KEY,
47
+    phone           VARCHAR(32)   NOT NULL,
48
+    content         TEXT,
49
+    template_id     BIGINT,
50
+    send_status     VARCHAR(16)   DEFAULT 'PENDING',
51
+    send_time       TIMESTAMP,
52
+    error_msg       VARCHAR(512),
53
+    customer_no     VARCHAR(64),
54
+    biz_type        VARCHAR(32),
55
+    create_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP
56
+);
57
+
58
+CREATE INDEX IF NOT EXISTS idx_sms_record_phone ON rev_sms_record(phone);
59
+CREATE INDEX IF NOT EXISTS idx_sms_record_send_status ON rev_sms_record(send_status);
60
+CREATE INDEX IF NOT EXISTS idx_sms_record_send_time ON rev_sms_record(send_time);
61
+CREATE INDEX IF NOT EXISTS idx_sms_record_customer_no ON rev_sms_record(customer_no);
62
+
63
+-- 4. 支付宝生活缴费订单表
64
+CREATE TABLE IF NOT EXISTS rev_alipay_order (
65
+    id              BIGSERIAL PRIMARY KEY,
66
+    out_trade_no    VARCHAR(128)  NOT NULL,
67
+    alipay_trade_no VARCHAR(128),
68
+    customer_no     VARCHAR(64),
69
+    bill_id         BIGINT,
70
+    amount          DECIMAL(12, 2),
71
+    status          VARCHAR(16)   DEFAULT 'CREATED',
72
+    notify_data     TEXT,
73
+    bill_period     VARCHAR(32),
74
+    pay_time        TIMESTAMP,
75
+    refund_amount   DECIMAL(12, 2),
76
+    refund_time     TIMESTAMP,
77
+    remark          VARCHAR(512),
78
+    create_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
79
+    update_time     TIMESTAMP     DEFAULT CURRENT_TIMESTAMP
80
+);
81
+
82
+CREATE UNIQUE INDEX IF NOT EXISTS idx_alipay_order_out_trade_no ON rev_alipay_order(out_trade_no);
83
+CREATE INDEX IF NOT EXISTS idx_alipay_order_customer_no ON rev_alipay_order(customer_no);
84
+CREATE INDEX IF NOT EXISTS idx_alipay_order_status ON rev_alipay_order(status);
85
+CREATE INDEX IF NOT EXISTS idx_alipay_order_pay_time ON rev_alipay_order(pay_time);
86
+
87
+-- =====================================================
88
+-- 默认短信模板数据
89
+-- =====================================================
90
+
91
+INSERT INTO rev_sms_template (template_name, template_type, content, variables, enabled, remark) VALUES
92
+('账单通知', 'BILL_NOTICE',
93
+ '【XX水务】尊敬的${customerName},您${billPeriod}的水费账单已出,应缴金额${amount}元,请及时缴费。',
94
+ '["customerName","billPeriod","amount"]', 1, '月度账单通知'),
95
+
96
+('欠费提醒', 'ARREARS_WARNING',
97
+ '【XX水务】尊敬的${customerName},您有${overdueAmount}元水费已逾期${overdueDays}天,请尽快缴清,逾期将影响正常用水。',
98
+ '["customerName","overdueAmount","overdueDays"]', 1, '欠费催缴提醒'),
99
+
100
+('阀门控制通知', 'VALVE_CONTROL',
101
+ '【XX水务】尊敬的${customerName},您编号为${meterNo}的水表阀门已${action},如有疑问请联系客服。',
102
+ '["customerName","meterNo","action"]', 1, '远程阀门操作通知'),
103
+
104
+('停水通知', 'GENERAL',
105
+ '【XX水务】${areaName}将于${startTime}至${endTime}进行${reason},届时将暂停供水,请提前做好储水准备。',
106
+ '["areaName","startTime","endTime","reason"]', 1, '通用停水通知');

+ 94
- 0
wm-revenue/src/main/java/com/water/revenue/controller/AlipayController.java Visa fil

@@ -0,0 +1,94 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.entity.AlipayOrder;
6
+import com.water.revenue.service.AlipayService;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import lombok.extern.slf4j.Slf4j;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.math.BigDecimal;
14
+import java.util.Map;
15
+
16
+/**
17
+ * 支付宝生活缴费控制器
18
+ */
19
+@Slf4j
20
+@Tag(name = "支付宝生活缴费")
21
+@RestController
22
+@RequestMapping("/revenue/alipay")
23
+@RequiredArgsConstructor
24
+public class AlipayController {
25
+
26
+    private final AlipayService alipayService;
27
+
28
+    /**
29
+     * 账单推送
30
+     */
31
+    @PostMapping("/push-bill")
32
+    @Operation(summary = "账单推送到支付宝")
33
+    public R<Map<String, Object>> pushBill(@RequestBody Map<String, Object> request) {
34
+        String customerNo = (String) request.get("customerNo");
35
+        Long billId = Long.valueOf(request.get("billId").toString());
36
+        String billPeriod = (String) request.get("billPeriod");
37
+        BigDecimal amount = new BigDecimal(request.get("amount").toString());
38
+        return R.ok(alipayService.pushBill(customerNo, billId, billPeriod, amount));
39
+    }
40
+
41
+    /**
42
+     * 支付宝缴费回调通知
43
+     */
44
+    @PostMapping("/notify")
45
+    @Operation(summary = "支付宝缴费回调")
46
+    public R<Map<String, Object>> payNotify(@RequestParam Map<String, String> params) {
47
+        return R.ok(alipayService.handlePayNotify(params));
48
+    }
49
+
50
+    /**
51
+     * 对账
52
+     */
53
+    @GetMapping("/reconcile")
54
+    @Operation(summary = "支付宝对账")
55
+    public R<Map<String, Object>> reconcile(
56
+            @RequestParam(required = false) String startDate,
57
+            @RequestParam(required = false) String endDate) {
58
+        return R.ok(alipayService.reconcile(startDate, endDate));
59
+    }
60
+
61
+    /**
62
+     * 退费
63
+     */
64
+    @PostMapping("/refund")
65
+    @Operation(summary = "支付宝退费")
66
+    public R<Map<String, Object>> refund(@RequestBody Map<String, Object> request) {
67
+        String outTradeNo = (String) request.get("outTradeNo");
68
+        BigDecimal refundAmount = new BigDecimal(request.get("refundAmount").toString());
69
+        String reason = (String) request.get("reason");
70
+        return R.ok(alipayService.refund(outTradeNo, refundAmount, reason));
71
+    }
72
+
73
+    /**
74
+     * 查询订单列表
75
+     */
76
+    @GetMapping("/orders")
77
+    @Operation(summary = "查询支付宝订单列表")
78
+    public R<Page<AlipayOrder>> listOrders(
79
+            @RequestParam(defaultValue = "1") Integer page,
80
+            @RequestParam(defaultValue = "20") Integer size,
81
+            @RequestParam(required = false) String status,
82
+            @RequestParam(required = false) String customerNo) {
83
+        return R.ok(alipayService.listOrders(page, size, status, customerNo));
84
+    }
85
+
86
+    /**
87
+     * 查询订单详情
88
+     */
89
+    @GetMapping("/orders/{id}")
90
+    @Operation(summary = "查询支付宝订单详情")
91
+    public R<AlipayOrder> getOrderDetail(@PathVariable Long id) {
92
+        return R.ok(alipayService.getOrderDetail(id));
93
+    }
94
+}

+ 68
- 0
wm-revenue/src/main/java/com/water/revenue/controller/SmartMeterController.java Visa fil

@@ -0,0 +1,68 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.entity.SmartMeter;
6
+import com.water.revenue.service.SmartMeterService;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.web.bind.annotation.*;
11
+
12
+import java.util.List;
13
+import java.util.Map;
14
+
15
+@Tag(name = "智能表平台")
16
+@RestController
17
+@RequestMapping("/revenue/smart-meter")
18
+@RequiredArgsConstructor
19
+public class SmartMeterController {
20
+
21
+    private final SmartMeterService smartMeterService;
22
+
23
+    @GetMapping("/dashboard")
24
+    @Operation(summary = "智能表总览")
25
+    public R<Map<String, Object>> dashboard() {
26
+        return R.ok(smartMeterService.getMeterDashboard());
27
+    }
28
+
29
+    @GetMapping("/list")
30
+    @Operation(summary = "智能表列表")
31
+    public R<Page<SmartMeter>> list(
32
+            @RequestParam(defaultValue = "1") int page,
33
+            @RequestParam(defaultValue = "20") int size,
34
+            @RequestParam(required = false) String onlineStatus,
35
+            @RequestParam(required = false) String areaCode,
36
+            @RequestParam(required = false) String keyword) {
37
+        return R.ok(smartMeterService.list(page, size, onlineStatus, areaCode, keyword));
38
+    }
39
+
40
+    @GetMapping("/{id}")
41
+    @Operation(summary = "水表详情")
42
+    public R<SmartMeter> detail(@PathVariable Long id) {
43
+        return R.ok(smartMeterService.getDetail(id));
44
+    }
45
+
46
+    @PostMapping("/batch-read")
47
+    @Operation(summary = "批量下发抄表指令")
48
+    @SuppressWarnings("unchecked")
49
+    public R<List<Map<String, Object>>> batchRead(@RequestBody Map<String, Object> req) {
50
+        List<String> meterNos = (List<String>) req.get("meterNos");
51
+        return R.ok(smartMeterService.batchRead(meterNos));
52
+    }
53
+
54
+    @PostMapping("/alarm")
55
+    @Operation(summary = "设置告警阈值")
56
+    public R<Map<String, Object>> setAlarm(@RequestBody Map<String, Object> req) {
57
+        Long meterId = Long.parseLong(String.valueOf(req.get("meterId")));
58
+        return R.ok(smartMeterService.setAlarm(meterId, req));
59
+    }
60
+
61
+    @GetMapping("/alarms")
62
+    @Operation(summary = "告警列表")
63
+    public R<Map<String, Object>> alarms(
64
+            @RequestParam(defaultValue = "1") int page,
65
+            @RequestParam(defaultValue = "20") int size) {
66
+        return R.ok(smartMeterService.getAlarms(page, size));
67
+    }
68
+}

+ 118
- 0
wm-revenue/src/main/java/com/water/revenue/controller/SmsController.java Visa fil

@@ -0,0 +1,118 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.entity.SmsRecord;
6
+import com.water.revenue.entity.SmsTemplate;
7
+import com.water.revenue.service.SmsService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+@Tag(name = "短信平台")
17
+@RestController
18
+@RequestMapping("/revenue/sms")
19
+@RequiredArgsConstructor
20
+public class SmsController {
21
+
22
+    private final SmsService smsService;
23
+
24
+    @PostMapping("/send")
25
+    @Operation(summary = "发送短信")
26
+    public R<SmsRecord> send(@RequestBody Map<String, Object> req) {
27
+        String phone = (String) req.get("phone");
28
+        Long templateId = Long.parseLong(String.valueOf(req.get("templateId")));
29
+        @SuppressWarnings("unchecked")
30
+        Map<String, String> variables = (Map<String, String>) req.getOrDefault("variables", Map.of());
31
+        return R.ok(smsService.sendSms(phone, templateId, variables));
32
+    }
33
+
34
+    @PostMapping("/batch-send")
35
+    @Operation(summary = "批量发送短信")
36
+    @SuppressWarnings("unchecked")
37
+    public R<List<SmsRecord>> batchSend(@RequestBody Map<String, Object> req) {
38
+        List<String> phones = (List<String>) req.get("phones");
39
+        Long templateId = Long.parseLong(String.valueOf(req.get("templateId")));
40
+        Map<String, String> variables = (Map<String, String>) req.getOrDefault("variables", Map.of());
41
+        return R.ok(smsService.batchSend(phones, templateId, variables));
42
+    }
43
+
44
+    @PostMapping("/bill-notice")
45
+    @Operation(summary = "账单通知群发")
46
+    @SuppressWarnings("unchecked")
47
+    public R<List<SmsRecord>> billNotice(@RequestBody Map<String, Object> req) {
48
+        List<Map<String, String>> customerList = (List<Map<String, String>>) req.get("customerList");
49
+        return R.ok(smsService.sendBillNotice(customerList));
50
+    }
51
+
52
+    @PostMapping("/overdue-reminder")
53
+    @Operation(summary = "欠费提醒群发")
54
+    @SuppressWarnings("unchecked")
55
+    public R<List<SmsRecord>> overdueReminder(@RequestBody Map<String, Object> req) {
56
+        List<Map<String, String>> customerList = (List<Map<String, String>>) req.get("customerList");
57
+        return R.ok(smsService.sendArrearsWarning(customerList));
58
+    }
59
+
60
+    // ========== 模板管理 ==========
61
+
62
+    @GetMapping("/templates")
63
+    @Operation(summary = "短信模板列表")
64
+    public R<Page<SmsTemplate>> listTemplates(
65
+            @RequestParam(defaultValue = "1") int page,
66
+            @RequestParam(defaultValue = "20") int size,
67
+            @RequestParam(required = false) String templateType,
68
+            @RequestParam(required = false) Integer enabled) {
69
+        return R.ok(smsService.listTemplates(page, size, templateType, enabled));
70
+    }
71
+
72
+    @GetMapping("/templates/{id}")
73
+    @Operation(summary = "模板详情")
74
+    public R<SmsTemplate> getTemplate(@PathVariable Long id) {
75
+        return R.ok(smsService.getTemplate(id));
76
+    }
77
+
78
+    @PostMapping("/templates")
79
+    @Operation(summary = "新增短信模板")
80
+    public R<SmsTemplate> addTemplate(@RequestBody SmsTemplate template) {
81
+        return R.ok(smsService.createTemplate(template));
82
+    }
83
+
84
+    @PutMapping("/templates/{id}")
85
+    @Operation(summary = "更新模板")
86
+    public R<String> updateTemplate(@PathVariable Long id, @RequestBody SmsTemplate template) {
87
+        smsService.updateTemplate(id, template);
88
+        return R.ok("ok");
89
+    }
90
+
91
+    @DeleteMapping("/templates/{id}")
92
+    @Operation(summary = "删除模板")
93
+    public R<String> deleteTemplate(@PathVariable Long id) {
94
+        smsService.deleteTemplate(id);
95
+        return R.ok("ok");
96
+    }
97
+
98
+    // ========== 记录查询 ==========
99
+
100
+    @GetMapping("/records")
101
+    @Operation(summary = "发送记录")
102
+    public R<Page<SmsRecord>> listRecords(
103
+            @RequestParam(defaultValue = "1") int page,
104
+            @RequestParam(defaultValue = "20") int size,
105
+            @RequestParam(required = false) String sendStatus,
106
+            @RequestParam(required = false) String phone,
107
+            @RequestParam(required = false) String bizType) {
108
+        return R.ok(smsService.listRecords(page, size, sendStatus, phone, bizType));
109
+    }
110
+
111
+    @GetMapping("/stats")
112
+    @Operation(summary = "发送统计")
113
+    public R<Map<String, Object>> stats(
114
+            @RequestParam(required = false) String startDate,
115
+            @RequestParam(required = false) String endDate) {
116
+        return R.ok(smsService.getSendStats(startDate, endDate));
117
+    }
118
+}

+ 59
- 0
wm-revenue/src/main/java/com/water/revenue/entity/AlipayOrder.java Visa fil

@@ -0,0 +1,59 @@
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
+/**
9
+ * 支付宝生活缴费订单实体
10
+ */
11
+@Data
12
+@TableName("rev_alipay_order")
13
+public class AlipayOrder {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 商户订单号 */
19
+    private String outTradeNo;
20
+
21
+    /** 支付宝交易号 */
22
+    private String alipayTradeNo;
23
+
24
+    /** 客户编号 */
25
+    private String customerNo;
26
+
27
+    /** 账单ID */
28
+    private Long billId;
29
+
30
+    /** 金额 */
31
+    private BigDecimal amount;
32
+
33
+    /** 订单状态: CREATED / PAID / REFUNDED / CLOSED */
34
+    private String status;
35
+
36
+    /** 回调通知数据(JSON) */
37
+    private String notifyData;
38
+
39
+    /** 缴费账期 */
40
+    private String billPeriod;
41
+
42
+    /** 支付时间 */
43
+    private LocalDateTime payTime;
44
+
45
+    /** 退款金额 */
46
+    private BigDecimal refundAmount;
47
+
48
+    /** 退款时间 */
49
+    private LocalDateTime refundTime;
50
+
51
+    /** 备注 */
52
+    private String remark;
53
+
54
+    @TableField(fill = FieldFill.INSERT)
55
+    private LocalDateTime createTime;
56
+
57
+    @TableField(fill = FieldFill.INSERT_UPDATE)
58
+    private LocalDateTime updateTime;
59
+}

+ 55
- 0
wm-revenue/src/main/java/com/water/revenue/entity/SmartMeter.java Visa fil

@@ -0,0 +1,55 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 智能水表实体
9
+ */
10
+@Data
11
+@TableName("rev_smart_meter")
12
+public class SmartMeter {
13
+
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+
17
+    /** 水表编号 */
18
+    private String meterNo;
19
+
20
+    /** 客户编号 */
21
+    private String customerNo;
22
+
23
+    /** 信号强度 (0-100) */
24
+    private Integer signalStrength;
25
+
26
+    /** 电池电量 (0-100) */
27
+    private Integer batteryLevel;
28
+
29
+    /** 阀门状态: OPEN/CLOSED */
30
+    private String valveStatus;
31
+
32
+    /** 最后上报时间 */
33
+    private LocalDateTime lastReportTime;
34
+
35
+    /** 在线状态: ONLINE/OFFLINE */
36
+    private String onlineStatus;
37
+
38
+    /** 当前读数 */
39
+    private Double currentReading;
40
+
41
+    /** 安装地址 */
42
+    private String installAddress;
43
+
44
+    /** 区域编码 */
45
+    private String areaCode;
46
+
47
+    /** 备注 */
48
+    private String remark;
49
+
50
+    @TableField(fill = FieldFill.INSERT)
51
+    private LocalDateTime createTime;
52
+
53
+    @TableField(fill = FieldFill.INSERT_UPDATE)
54
+    private LocalDateTime updateTime;
55
+}

+ 43
- 0
wm-revenue/src/main/java/com/water/revenue/entity/SmsRecord.java Visa fil

@@ -0,0 +1,43 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 短信发送记录实体
9
+ */
10
+@Data
11
+@TableName("rev_sms_record")
12
+public class SmsRecord {
13
+
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+
17
+    /** 手机号 */
18
+    private String phone;
19
+
20
+    /** 发送内容 */
21
+    private String content;
22
+
23
+    /** 模板ID */
24
+    private Long templateId;
25
+
26
+    /** 发送状态: PENDING / SUCCESS / FAILED */
27
+    private String sendStatus;
28
+
29
+    /** 发送时间 */
30
+    private LocalDateTime sendTime;
31
+
32
+    /** 错误信息 */
33
+    private String errorMsg;
34
+
35
+    /** 客户编号 */
36
+    private String customerNo;
37
+
38
+    /** 业务类型 */
39
+    private String bizType;
40
+
41
+    @TableField(fill = FieldFill.INSERT)
42
+    private LocalDateTime createTime;
43
+}

+ 40
- 0
wm-revenue/src/main/java/com/water/revenue/entity/SmsTemplate.java Visa fil

@@ -0,0 +1,40 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 短信模板实体
9
+ */
10
+@Data
11
+@TableName("rev_sms_template")
12
+public class SmsTemplate {
13
+
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+
17
+    /** 模板名称 */
18
+    private String templateName;
19
+
20
+    /** 模板类型: BILL_NOTICE / OVERDUE_NOTICE / ARREARS_WARNING / VALVE_CONTROL / GENERAL */
21
+    private String templateType;
22
+
23
+    /** 模板内容(支持变量占位符如 ${customerName}) */
24
+    private String content;
25
+
26
+    /** 变量列表(JSON 数组) */
27
+    private String variables;
28
+
29
+    /** 是否启用: 1=启用 0=禁用 */
30
+    private Integer enabled;
31
+
32
+    /** 备注 */
33
+    private String remark;
34
+
35
+    @TableField(fill = FieldFill.INSERT)
36
+    private LocalDateTime createTime;
37
+
38
+    @TableField(fill = FieldFill.INSERT_UPDATE)
39
+    private LocalDateTime updateTime;
40
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/AlipayOrderMapper.java Visa fil

@@ -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.AlipayOrder;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface AlipayOrderMapper extends BaseMapper<AlipayOrder> {
9
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/SmartMeterMapper.java Visa fil

@@ -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.SmartMeter;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface SmartMeterMapper extends BaseMapper<SmartMeter> {
9
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/SmsRecordMapper.java Visa fil

@@ -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.SmsRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface SmsRecordMapper extends BaseMapper<SmsRecord> {
9
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/SmsTemplateMapper.java Visa fil

@@ -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.SmsTemplate;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface SmsTemplateMapper extends BaseMapper<SmsTemplate> {
9
+}

+ 188
- 0
wm-revenue/src/main/java/com/water/revenue/service/AlipayService.java Visa fil

@@ -0,0 +1,188 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.entity.AlipayOrder;
6
+import com.water.revenue.mapper.AlipayOrderMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+
11
+import java.math.BigDecimal;
12
+import java.time.LocalDateTime;
13
+import java.time.format.DateTimeFormatter;
14
+import java.util.*;
15
+
16
+/**
17
+ * 支付宝生活缴费对接服务
18
+ */
19
+@Slf4j
20
+@Service
21
+@RequiredArgsConstructor
22
+public class AlipayService {
23
+
24
+    private final AlipayOrderMapper alipayOrderMapper;
25
+
26
+    /**
27
+     * 账单推送 - 向支付宝推送用户账单
28
+     */
29
+    public Map<String, Object> pushBill(String customerNo, Long billId, String billPeriod, BigDecimal amount) {
30
+        String outTradeNo = generateOutTradeNo(customerNo);
31
+
32
+        AlipayOrder order = new AlipayOrder();
33
+        order.setOutTradeNo(outTradeNo);
34
+        order.setCustomerNo(customerNo);
35
+        order.setBillId(billId);
36
+        order.setBillPeriod(billPeriod);
37
+        order.setAmount(amount);
38
+        order.setStatus("CREATED");
39
+        alipayOrderMapper.insert(order);
40
+
41
+        log.info("支付宝账单推送: outTradeNo={}, customerNo={}, amount={}", outTradeNo, customerNo, amount);
42
+
43
+        Map<String, Object> result = new LinkedHashMap<>();
44
+        result.put("outTradeNo", outTradeNo);
45
+        result.put("customerNo", customerNo);
46
+        result.put("billPeriod", billPeriod);
47
+        result.put("amount", amount);
48
+        result.put("status", "CREATED");
49
+        result.put("pushTime", LocalDateTime.now());
50
+        return result;
51
+    }
52
+
53
+    /**
54
+     * 缴费回调 - 处理支付宝支付结果通知
55
+     */
56
+    public Map<String, Object> handlePayNotify(Map<String, String> params) {
57
+        String outTradeNo = params.get("out_trade_no");
58
+        String tradeNo = params.get("trade_no");
59
+        String tradeStatus = params.get("trade_status");
60
+
61
+        AlipayOrder order = getByOutTradeNo(outTradeNo);
62
+        if (order == null) {
63
+            log.warn("支付宝回调找不到订单: outTradeNo={}", outTradeNo);
64
+            throw new RuntimeException("订单不存在: " + outTradeNo);
65
+        }
66
+
67
+        AlipayOrder update = new AlipayOrder();
68
+        update.setId(order.getId());
69
+        update.setAlipayTradeNo(tradeNo);
70
+        update.setNotifyData(params.toString());
71
+
72
+        if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) {
73
+            update.setStatus("PAID");
74
+            update.setPayTime(LocalDateTime.now());
75
+            log.info("支付宝缴费成功: outTradeNo={}, tradeNo={}", outTradeNo, tradeNo);
76
+        } else {
77
+            update.setStatus("CLOSED");
78
+            log.info("支付宝缴费关闭: outTradeNo={}", outTradeNo);
79
+        }
80
+
81
+        alipayOrderMapper.updateById(update);
82
+
83
+        Map<String, Object> result = new LinkedHashMap<>();
84
+        result.put("outTradeNo", outTradeNo);
85
+        result.put("status", update.getStatus());
86
+        result.put("success", true);
87
+        return result;
88
+    }
89
+
90
+    /**
91
+     * 对账 - 查询指定日期范围内的订单进行对账
92
+     */
93
+    public Map<String, Object> reconcile(String startDate, String endDate) {
94
+        LambdaQueryWrapper<AlipayOrder> qw = new LambdaQueryWrapper<>();
95
+        if (startDate != null) {
96
+            qw.ge(AlipayOrder::getPayTime, LocalDateTime.parse(startDate.replace(" ", "T")));
97
+        }
98
+        if (endDate != null) {
99
+            qw.le(AlipayOrder::getPayTime, LocalDateTime.parse(endDate.replace(" ", "T")));
100
+        }
101
+        qw.eq(AlipayOrder::getStatus, "PAID");
102
+        List<AlipayOrder> orders = alipayOrderMapper.selectList(qw);
103
+
104
+        BigDecimal totalAmount = orders.stream()
105
+                .map(AlipayOrder::getAmount)
106
+                .filter(Objects::nonNull)
107
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
108
+
109
+        Map<String, Object> result = new LinkedHashMap<>();
110
+        result.put("totalOrders", orders.size());
111
+        result.put("totalAmount", totalAmount);
112
+        result.put("startDate", startDate);
113
+        result.put("endDate", endDate);
114
+        result.put("orders", orders);
115
+        result.put("reconcileTime", LocalDateTime.now());
116
+        return result;
117
+    }
118
+
119
+    /**
120
+     * 退费 - 发起退款
121
+     */
122
+    public Map<String, Object> refund(String outTradeNo, BigDecimal refundAmount, String reason) {
123
+        AlipayOrder order = getByOutTradeNo(outTradeNo);
124
+        if (order == null) {
125
+            throw new RuntimeException("订单不存在: " + outTradeNo);
126
+        }
127
+        if (!"PAID".equals(order.getStatus())) {
128
+            throw new RuntimeException("订单状态不允许退款: " + order.getStatus());
129
+        }
130
+        if (refundAmount.compareTo(order.getAmount()) > 0) {
131
+            throw new RuntimeException("退款金额不能大于支付金额");
132
+        }
133
+
134
+        AlipayOrder update = new AlipayOrder();
135
+        update.setId(order.getId());
136
+        update.setStatus("REFUNDED");
137
+        update.setRefundAmount(refundAmount);
138
+        update.setRefundTime(LocalDateTime.now());
139
+        update.setRemark(reason);
140
+        alipayOrderMapper.updateById(update);
141
+
142
+        log.info("支付宝退款: outTradeNo={}, refundAmount={}", outTradeNo, refundAmount);
143
+
144
+        Map<String, Object> result = new LinkedHashMap<>();
145
+        result.put("outTradeNo", outTradeNo);
146
+        result.put("refundAmount", refundAmount);
147
+        result.put("status", "REFUNDED");
148
+        result.put("refundTime", LocalDateTime.now());
149
+        result.put("success", true);
150
+        return result;
151
+    }
152
+
153
+    /**
154
+     * 查询订单列表
155
+     */
156
+    public Page<AlipayOrder> listOrders(int page, int size, String status, String customerNo) {
157
+        LambdaQueryWrapper<AlipayOrder> qw = new LambdaQueryWrapper<>();
158
+        if (status != null && !status.isEmpty()) {
159
+            qw.eq(AlipayOrder::getStatus, status);
160
+        }
161
+        if (customerNo != null && !customerNo.isEmpty()) {
162
+            qw.eq(AlipayOrder::getCustomerNo, customerNo);
163
+        }
164
+        qw.orderByDesc(AlipayOrder::getCreateTime);
165
+        return alipayOrderMapper.selectPage(new Page<>(page, size), qw);
166
+    }
167
+
168
+    /**
169
+     * 查询订单详情
170
+     */
171
+    public AlipayOrder getOrderDetail(Long id) {
172
+        return alipayOrderMapper.selectById(id);
173
+    }
174
+
175
+    // ========== 内部方法 ==========
176
+
177
+    private AlipayOrder getByOutTradeNo(String outTradeNo) {
178
+        LambdaQueryWrapper<AlipayOrder> qw = new LambdaQueryWrapper<>();
179
+        qw.eq(AlipayOrder::getOutTradeNo, outTradeNo);
180
+        return alipayOrderMapper.selectOne(qw);
181
+    }
182
+
183
+    private String generateOutTradeNo(String customerNo) {
184
+        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
185
+        String random = String.format("%04d", new Random().nextInt(10000));
186
+        return "ALIPAY_" + customerNo + "_" + timestamp + random;
187
+    }
188
+}

+ 371
- 0
wm-revenue/src/main/java/com/water/revenue/service/SmartMeterService.java Visa fil

@@ -0,0 +1,371 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.entity.SmartMeter;
6
+import com.water.revenue.mapper.SmartMeterMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.jdbc.core.JdbcTemplate;
10
+import org.springframework.stereotype.Service;
11
+
12
+import java.time.LocalDateTime;
13
+import java.util.*;
14
+import java.util.stream.Collectors;
15
+
16
+/**
17
+ * 智能水表平台服务
18
+ */
19
+@Slf4j
20
+@Service
21
+@RequiredArgsConstructor
22
+public class SmartMeterService {
23
+
24
+    private final SmartMeterMapper smartMeterMapper;
25
+    private final JdbcTemplate jdbcTemplate;
26
+
27
+    /**
28
+     * 分页查询智能水表
29
+     */
30
+    public Page<SmartMeter> list(int page, int size, String onlineStatus, String areaCode, String keyword) {
31
+        LambdaQueryWrapper<SmartMeter> qw = new LambdaQueryWrapper<>();
32
+        if (onlineStatus != null && !onlineStatus.isEmpty()) {
33
+            qw.eq(SmartMeter::getOnlineStatus, onlineStatus);
34
+        }
35
+        if (areaCode != null && !areaCode.isEmpty()) {
36
+            qw.eq(SmartMeter::getAreaCode, areaCode);
37
+        }
38
+        if (keyword != null && !keyword.isEmpty()) {
39
+            qw.and(w -> w.like(SmartMeter::getMeterNo, keyword)
40
+                    .or().like(SmartMeter::getCustomerNo, keyword)
41
+                    .or().like(SmartMeter::getInstallAddress, keyword));
42
+        }
43
+        qw.orderByDesc(SmartMeter::getLastReportTime);
44
+        return smartMeterMapper.selectPage(new Page<>(page, size), qw);
45
+    }
46
+
47
+    /**
48
+     * 获取水表详情
49
+     */
50
+    public SmartMeter getDetail(Long id) {
51
+        return smartMeterMapper.selectById(id);
52
+    }
53
+
54
+    /**
55
+     * 根据水表编号查询
56
+     */
57
+    public SmartMeter getByMeterNo(String meterNo) {
58
+        LambdaQueryWrapper<SmartMeter> qw = new LambdaQueryWrapper<>();
59
+        qw.eq(SmartMeter::getMeterNo, meterNo);
60
+        return smartMeterMapper.selectOne(qw);
61
+    }
62
+
63
+    /**
64
+     * 批量查询在线水表状态(信号强度/电量/阀门)
65
+     */
66
+    public List<Map<String, Object>> batchStatus(List<String> meterNos) {
67
+        LambdaQueryWrapper<SmartMeter> qw = new LambdaQueryWrapper<>();
68
+        qw.in(SmartMeter::getMeterNo, meterNos);
69
+        List<SmartMeter> meters = smartMeterMapper.selectList(qw);
70
+        return meters.stream().map(m -> {
71
+            Map<String, Object> status = new LinkedHashMap<>();
72
+            status.put("meterNo", m.getMeterNo());
73
+            status.put("onlineStatus", m.getOnlineStatus());
74
+            status.put("signalStrength", m.getSignalStrength());
75
+            status.put("batteryLevel", m.getBatteryLevel());
76
+            status.put("valveStatus", m.getValveStatus());
77
+            status.put("lastReportTime", m.getLastReportTime());
78
+            return status;
79
+        }).collect(Collectors.toList());
80
+    }
81
+
82
+    /**
83
+     * 批量抄表 - 获取当前读数列表
84
+     */
85
+    public List<Map<String, Object>> batchRead(List<String> meterNos) {
86
+        LambdaQueryWrapper<SmartMeter> qw = new LambdaQueryWrapper<>();
87
+        qw.in(SmartMeter::getMeterNo, meterNos);
88
+        List<SmartMeter> meters = smartMeterMapper.selectList(qw);
89
+        return meters.stream().map(m -> {
90
+            Map<String, Object> reading = new LinkedHashMap<>();
91
+            reading.put("meterNo", m.getMeterNo());
92
+            reading.put("customerNo", m.getCustomerNo());
93
+            reading.put("currentReading", m.getCurrentReading());
94
+            reading.put("lastReportTime", m.getLastReportTime());
95
+            reading.put("onlineStatus", m.getOnlineStatus());
96
+            return reading;
97
+        }).collect(Collectors.toList());
98
+    }
99
+
100
+    /**
101
+     * 异常告警 - 获取低电量/低信号/离线水表
102
+     */
103
+    public List<Map<String, Object>> getAlerts(String alertType, String areaCode) {
104
+        LambdaQueryWrapper<SmartMeter> qw = new LambdaQueryWrapper<>();
105
+        if (areaCode != null && !areaCode.isEmpty()) {
106
+            qw.eq(SmartMeter::getAreaCode, areaCode);
107
+        }
108
+        switch (alertType != null ? alertType : "ALL") {
109
+            case "LOW_BATTERY":
110
+                qw.lt(SmartMeter::getBatteryLevel, 20);
111
+                break;
112
+            case "LOW_SIGNAL":
113
+                qw.lt(SmartMeter::getSignalStrength, 30);
114
+                break;
115
+            case "OFFLINE":
116
+                qw.eq(SmartMeter::getOnlineStatus, "OFFLINE");
117
+                break;
118
+            default:
119
+                qw.and(w -> w.lt(SmartMeter::getBatteryLevel, 20)
120
+                        .or().lt(SmartMeter::getSignalStrength, 30)
121
+                        .or().eq(SmartMeter::getOnlineStatus, "OFFLINE"));
122
+                break;
123
+        }
124
+        List<SmartMeter> meters = smartMeterMapper.selectList(qw);
125
+        return meters.stream().map(m -> {
126
+            Map<String, Object> alert = new LinkedHashMap<>();
127
+            alert.put("meterNo", m.getMeterNo());
128
+            alert.put("customerNo", m.getCustomerNo());
129
+            alert.put("alertType", determineAlertType(m));
130
+            alert.put("signalStrength", m.getSignalStrength());
131
+            alert.put("batteryLevel", m.getBatteryLevel());
132
+            alert.put("onlineStatus", m.getOnlineStatus());
133
+            alert.put("installAddress", m.getInstallAddress());
134
+            return alert;
135
+        }).collect(Collectors.toList());
136
+    }
137
+
138
+    private String determineAlertType(SmartMeter m) {
139
+        List<String> types = new ArrayList<>();
140
+        if (m.getBatteryLevel() != null && m.getBatteryLevel() < 20) types.add("LOW_BATTERY");
141
+        if (m.getSignalStrength() != null && m.getSignalStrength() < 30) types.add("LOW_SIGNAL");
142
+        if ("OFFLINE".equals(m.getOnlineStatus())) types.add("OFFLINE");
143
+        return String.join(",", types);
144
+    }
145
+
146
+    /**
147
+     * 远程控制 - 开阀
148
+     */
149
+    public Map<String, Object> openValve(String meterNo) {
150
+        SmartMeter meter = getByMeterNo(meterNo);
151
+        if (meter == null) {
152
+            throw new RuntimeException("水表不存在: " + meterNo);
153
+        }
154
+        SmartMeter update = new SmartMeter();
155
+        update.setId(meter.getId());
156
+        update.setValveStatus("OPEN");
157
+        update.setUpdateTime(LocalDateTime.now());
158
+        smartMeterMapper.updateById(update);
159
+        log.info("远程开阀成功: meterNo={}", meterNo);
160
+        Map<String, Object> result = new LinkedHashMap<>();
161
+        result.put("meterNo", meterNo);
162
+        result.put("valveStatus", "OPEN");
163
+        result.put("operateTime", LocalDateTime.now());
164
+        result.put("success", true);
165
+        return result;
166
+    }
167
+
168
+    /**
169
+     * 远程控制 - 关阀
170
+     */
171
+    public Map<String, Object> closeValve(String meterNo) {
172
+        SmartMeter meter = getByMeterNo(meterNo);
173
+        if (meter == null) {
174
+            throw new RuntimeException("水表不存在: " + meterNo);
175
+        }
176
+        SmartMeter update = new SmartMeter();
177
+        update.setId(meter.getId());
178
+        update.setValveStatus("CLOSED");
179
+        update.setUpdateTime(LocalDateTime.now());
180
+        smartMeterMapper.updateById(update);
181
+        log.info("远程关阀成功: meterNo={}", meterNo);
182
+        Map<String, Object> result = new LinkedHashMap<>();
183
+        result.put("meterNo", meterNo);
184
+        result.put("valveStatus", "CLOSED");
185
+        result.put("operateTime", LocalDateTime.now());
186
+        result.put("success", true);
187
+        return result;
188
+    }
189
+
190
+    /**
191
+     * 批量控制阀门
192
+     */
193
+    public List<Map<String, Object>> batchValveControl(List<String> meterNos, String action) {
194
+        List<Map<String, Object>> results = new ArrayList<>();
195
+        for (String meterNo : meterNos) {
196
+            try {
197
+                Map<String, Object> r = "OPEN".equalsIgnoreCase(action) ? openValve(meterNo) : closeValve(meterNo);
198
+                results.add(r);
199
+            } catch (Exception e) {
200
+                Map<String, Object> err = new LinkedHashMap<>();
201
+                err.put("meterNo", meterNo);
202
+                err.put("success", false);
203
+                err.put("errorMsg", e.getMessage());
204
+                results.add(err);
205
+            }
206
+        }
207
+        return results;
208
+    }
209
+
210
+    /**
211
+     * SM-001 智能表总览(Dashboard)
212
+     */
213
+    public Map<String, Object> getMeterDashboard() {
214
+        List<SmartMeter> all = smartMeterMapper.selectList(null);
215
+        long total = all.size();
216
+        long online = all.stream().filter(m -> "ONLINE".equals(m.getOnlineStatus())).count();
217
+        long offline = all.stream().filter(m -> "OFFLINE".equals(m.getOnlineStatus())).count();
218
+        long abnormal = all.stream().filter(m ->
219
+                (m.getBatteryLevel() != null && m.getBatteryLevel() < 20)
220
+                || (m.getSignalStrength() != null && m.getSignalStrength() < 30)
221
+                || "OFFLINE".equals(m.getOnlineStatus())).count();
222
+
223
+        // 抄表成功率 = 在线且有读数 / 总数
224
+        long successRead = all.stream()
225
+                .filter(m -> "ONLINE".equals(m.getOnlineStatus()) && m.getCurrentReading() != null)
226
+                .count();
227
+        double readRate = total > 0 ? Math.round(successRead * 10000.0 / total) / 100.0 : 0;
228
+
229
+        Map<String, Object> dashboard = new LinkedHashMap<>();
230
+        dashboard.put("total", total);
231
+        dashboard.put("online", online);
232
+        dashboard.put("offline", offline);
233
+        dashboard.put("abnormal", abnormal);
234
+        dashboard.put("readSuccessRate", readRate);
235
+
236
+        // 告警统计
237
+        try {
238
+            Integer alarmCount = jdbcTemplate.queryForObject(
239
+                    "SELECT COUNT(*) FROM rev_smart_meter_alarm_log WHERE handled = false", Integer.class);
240
+            dashboard.put("unhandledAlarms", alarmCount != null ? alarmCount : 0);
241
+        } catch (Exception e) {
242
+            dashboard.put("unhandledAlarms", 0);
243
+        }
244
+        return dashboard;
245
+    }
246
+
247
+    /**
248
+     * SM-002 智能表列表查询
249
+     */
250
+    public Page<SmartMeter> listMeters(String type, String status, String area, int page, int size) {
251
+        return list(page, size, status, area, null);
252
+    }
253
+
254
+    /**
255
+     * SM-003 单表详情(含最新读数、信号强度、电池电量)
256
+     */
257
+    public Map<String, Object> getMeterDetail(Long meterId) {
258
+        SmartMeter meter = smartMeterMapper.selectById(meterId);
259
+        if (meter == null) {
260
+            return Collections.emptyMap();
261
+        }
262
+        Map<String, Object> detail = new LinkedHashMap<>();
263
+        detail.put("id", meter.getId());
264
+        detail.put("meterNo", meter.getMeterNo());
265
+        detail.put("customerNo", meter.getCustomerNo());
266
+        detail.put("currentReading", meter.getCurrentReading());
267
+        detail.put("signalStrength", meter.getSignalStrength());
268
+        detail.put("batteryLevel", meter.getBatteryLevel());
269
+        detail.put("valveStatus", meter.getValveStatus());
270
+        detail.put("onlineStatus", meter.getOnlineStatus());
271
+        detail.put("lastReportTime", meter.getLastReportTime());
272
+        detail.put("installAddress", meter.getInstallAddress());
273
+        detail.put("areaCode", meter.getAreaCode());
274
+        detail.put("remark", meter.getRemark());
275
+
276
+        // 查询告警配置
277
+        try {
278
+            List<Map<String, Object>> alarms = jdbcTemplate.queryForList(
279
+                    "SELECT * FROM rev_smart_meter_alarm WHERE meter_id = ? ORDER BY alarm_type", meterId);
280
+            detail.put("alarmConfigs", alarms);
281
+        } catch (Exception e) {
282
+            detail.put("alarmConfigs", Collections.emptyList());
283
+        }
284
+
285
+        // 最近告警记录
286
+        try {
287
+            List<Map<String, Object>> alarmLogs = jdbcTemplate.queryForList(
288
+                    "SELECT * FROM rev_smart_meter_alarm_log WHERE meter_id = ? ORDER BY created_at DESC LIMIT 10", meterId);
289
+            detail.put("recentAlarms", alarmLogs);
290
+        } catch (Exception e) {
291
+            detail.put("recentAlarms", Collections.emptyList());
292
+        }
293
+        return detail;
294
+    }
295
+
296
+    /**
297
+     * SM-005 设置告警阈值
298
+     */
299
+    public Map<String, Object> setAlarm(Long meterId, Map<String, Object> config) {
300
+        String alarmType = (String) config.get("alarmType");
301
+        String threshold = config.get("threshold") != null ? config.get("threshold").toString() : null;
302
+        Boolean enabled = config.get("enabled") != null ? (Boolean) config.get("enabled") : true;
303
+
304
+        try {
305
+            // upsert: 先删除旧的再插入
306
+            jdbcTemplate.update("DELETE FROM rev_smart_meter_alarm WHERE meter_id = ? AND alarm_type = ?", meterId, alarmType);
307
+            jdbcTemplate.update(
308
+                    "INSERT INTO rev_smart_meter_alarm (meter_id, alarm_type, threshold, enabled, created_at) VALUES (?, ?, ?, ?, NOW())",
309
+                    meterId, alarmType, threshold, enabled);
310
+            log.info("SM-005 设置告警: meterId={}, type={}, threshold={}", meterId, alarmType, threshold);
311
+
312
+            Map<String, Object> result = new LinkedHashMap<>();
313
+            result.put("meterId", meterId);
314
+            result.put("alarmType", alarmType);
315
+            result.put("threshold", threshold);
316
+            result.put("enabled", enabled);
317
+            result.put("success", true);
318
+            return result;
319
+        } catch (Exception e) {
320
+            log.error("SM-005 设置告警失败: {}", e.getMessage());
321
+            Map<String, Object> result = new LinkedHashMap<>();
322
+            result.put("meterId", meterId);
323
+            result.put("success", false);
324
+            result.put("errorMsg", e.getMessage());
325
+            return result;
326
+        }
327
+    }
328
+
329
+    /**
330
+     * SM-006 告警列表
331
+     */
332
+    public Map<String, Object> getAlarms(int page, int size) {
333
+        Map<String, Object> result = new LinkedHashMap<>();
334
+        try {
335
+            int offset = (page - 1) * size;
336
+            List<Map<String, Object>> records = jdbcTemplate.queryForList(
337
+                    "SELECT l.*, m.meter_no, m.install_address FROM rev_smart_meter_alarm_log l " +
338
+                    "LEFT JOIN rev_smart_meter m ON l.meter_id = m.id " +
339
+                    "ORDER BY l.created_at DESC LIMIT ? OFFSET ?", size, offset);
340
+            Integer total = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM rev_smart_meter_alarm_log", Integer.class);
341
+
342
+            result.put("records", records);
343
+            result.put("total", total != null ? total : 0);
344
+            result.put("page", page);
345
+            result.put("size", size);
346
+        } catch (Exception e) {
347
+            log.warn("SM-006 告警列表查询失败(表可能不存在): {}", e.getMessage());
348
+            result.put("records", Collections.emptyList());
349
+            result.put("total", 0);
350
+            result.put("page", page);
351
+            result.put("size", size);
352
+        }
353
+        return result;
354
+    }
355
+
356
+    /**
357
+     * 统计概览
358
+     */
359
+    public Map<String, Object> getOverview() {
360
+        List<SmartMeter> all = smartMeterMapper.selectList(null);
361
+        Map<String, Object> overview = new LinkedHashMap<>();
362
+        overview.put("total", all.size());
363
+        overview.put("online", all.stream().filter(m -> "ONLINE".equals(m.getOnlineStatus())).count());
364
+        overview.put("offline", all.stream().filter(m -> "OFFLINE".equals(m.getOnlineStatus())).count());
365
+        overview.put("lowBattery", all.stream().filter(m -> m.getBatteryLevel() != null && m.getBatteryLevel() < 20).count());
366
+        overview.put("lowSignal", all.stream().filter(m -> m.getSignalStrength() != null && m.getSignalStrength() < 30).count());
367
+        overview.put("valveOpen", all.stream().filter(m -> "OPEN".equals(m.getValveStatus())).count());
368
+        overview.put("valveClosed", all.stream().filter(m -> "CLOSED".equals(m.getValveStatus())).count());
369
+        return overview;
370
+    }
371
+}

+ 229
- 0
wm-revenue/src/main/java/com/water/revenue/service/SmsService.java Visa fil

@@ -0,0 +1,229 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.entity.SmsRecord;
6
+import com.water.revenue.entity.SmsTemplate;
7
+import com.water.revenue.mapper.SmsRecordMapper;
8
+import com.water.revenue.mapper.SmsTemplateMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import lombok.extern.slf4j.Slf4j;
11
+import org.springframework.stereotype.Service;
12
+
13
+import java.time.LocalDateTime;
14
+import java.util.*;
15
+import java.util.stream.Collectors;
16
+
17
+/**
18
+ * 短信平台服务
19
+ */
20
+@Slf4j
21
+@Service
22
+@RequiredArgsConstructor
23
+public class SmsService {
24
+
25
+    private final SmsTemplateMapper smsTemplateMapper;
26
+    private final SmsRecordMapper smsRecordMapper;
27
+
28
+    // ========== 模板管理 ==========
29
+
30
+    /**
31
+     * 分页查询短信模板
32
+     */
33
+    public Page<SmsTemplate> listTemplates(int page, int size, String templateType, Integer enabled) {
34
+        LambdaQueryWrapper<SmsTemplate> qw = new LambdaQueryWrapper<>();
35
+        if (templateType != null && !templateType.isEmpty()) {
36
+            qw.eq(SmsTemplate::getTemplateType, templateType);
37
+        }
38
+        if (enabled != null) {
39
+            qw.eq(SmsTemplate::getEnabled, enabled);
40
+        }
41
+        qw.orderByDesc(SmsTemplate::getCreateTime);
42
+        return smsTemplateMapper.selectPage(new Page<>(page, size), qw);
43
+    }
44
+
45
+    /**
46
+     * 获取模板详情
47
+     */
48
+    public SmsTemplate getTemplate(Long id) {
49
+        return smsTemplateMapper.selectById(id);
50
+    }
51
+
52
+    /**
53
+     * 创建模板
54
+     */
55
+    public SmsTemplate createTemplate(SmsTemplate template) {
56
+        if (template.getEnabled() == null) {
57
+            template.setEnabled(1);
58
+        }
59
+        smsTemplateMapper.insert(template);
60
+        log.info("短信模板创建: id={}, name={}, type={}", template.getId(), template.getTemplateName(), template.getTemplateType());
61
+        return template;
62
+    }
63
+
64
+    /**
65
+     * 更新模板
66
+     */
67
+    public void updateTemplate(Long id, SmsTemplate template) {
68
+        template.setId(id);
69
+        smsTemplateMapper.updateById(template);
70
+        log.info("短信模板更新: id={}", id);
71
+    }
72
+
73
+    /**
74
+     * 删除模板
75
+     */
76
+    public void deleteTemplate(Long id) {
77
+        smsTemplateMapper.deleteById(id);
78
+        log.info("短信模板删除: id={}", id);
79
+    }
80
+
81
+    // ========== 发送管理 ==========
82
+
83
+    /**
84
+     * 发送短信(单条)
85
+     */
86
+    public SmsRecord sendSms(String phone, Long templateId, Map<String, String> variables) {
87
+        SmsTemplate template = smsTemplateMapper.selectById(templateId);
88
+        if (template == null || template.getEnabled() != 1) {
89
+            throw new RuntimeException("模板不存在或已禁用: " + templateId);
90
+        }
91
+        String content = renderContent(template.getContent(), variables);
92
+
93
+        SmsRecord record = new SmsRecord();
94
+        record.setPhone(phone);
95
+        record.setContent(content);
96
+        record.setTemplateId(templateId);
97
+        record.setSendStatus("SUCCESS"); // 模拟发送成功
98
+        record.setSendTime(LocalDateTime.now());
99
+        record.setBizType(template.getTemplateType());
100
+        smsRecordMapper.insert(record);
101
+        log.info("短信发送成功: phone={}, template={}", phone, template.getTemplateName());
102
+        return record;
103
+    }
104
+
105
+    /**
106
+     * 批量发送短信
107
+     */
108
+    public List<SmsRecord> batchSend(List<String> phones, Long templateId, Map<String, String> variables) {
109
+        List<SmsRecord> records = new ArrayList<>();
110
+        for (String phone : phones) {
111
+            try {
112
+                records.add(sendSms(phone, templateId, variables));
113
+            } catch (Exception e) {
114
+                SmsRecord fail = new SmsRecord();
115
+                fail.setPhone(phone);
116
+                fail.setTemplateId(templateId);
117
+                fail.setSendStatus("FAILED");
118
+                fail.setErrorMsg(e.getMessage());
119
+                fail.setSendTime(LocalDateTime.now());
120
+                smsRecordMapper.insert(fail);
121
+                records.add(fail);
122
+            }
123
+        }
124
+        return records;
125
+    }
126
+
127
+    /**
128
+     * 欠费提醒 - 按客户编号发送
129
+     */
130
+    public List<SmsRecord> sendArrearsWarning(List<Map<String, String>> customerList) {
131
+        LambdaQueryWrapper<SmsTemplate> qw = new LambdaQueryWrapper<>();
132
+        qw.eq(SmsTemplate::getTemplateType, "ARREARS_WARNING");
133
+        qw.eq(SmsTemplate::getEnabled, 1);
134
+        SmsTemplate template = smsTemplateMapper.selectOne(qw);
135
+        if (template == null) {
136
+            throw new RuntimeException("未找到欠费提醒模板");
137
+        }
138
+
139
+        List<SmsRecord> records = new ArrayList<>();
140
+        for (Map<String, String> customer : customerList) {
141
+            String phone = customer.get("phone");
142
+            Map<String, String> vars = new HashMap<>(customer);
143
+            records.add(sendSms(phone, template.getId(), vars));
144
+        }
145
+        log.info("欠费提醒批量发送: count={}", records.size());
146
+        return records;
147
+    }
148
+
149
+    /**
150
+     * 账单通知 - 按客户编号发送
151
+     */
152
+    public List<SmsRecord> sendBillNotice(List<Map<String, String>> customerList) {
153
+        LambdaQueryWrapper<SmsTemplate> qw = new LambdaQueryWrapper<>();
154
+        qw.eq(SmsTemplate::getTemplateType, "BILL_NOTICE");
155
+        qw.eq(SmsTemplate::getEnabled, 1);
156
+        SmsTemplate template = smsTemplateMapper.selectOne(qw);
157
+        if (template == null) {
158
+            throw new RuntimeException("未找到账单通知模板");
159
+        }
160
+
161
+        List<SmsRecord> records = new ArrayList<>();
162
+        for (Map<String, String> customer : customerList) {
163
+            String phone = customer.get("phone");
164
+            Map<String, String> vars = new HashMap<>(customer);
165
+            records.add(sendSms(phone, template.getId(), vars));
166
+        }
167
+        log.info("账单通知批量发送: count={}", records.size());
168
+        return records;
169
+    }
170
+
171
+    // ========== 记录查询 ==========
172
+
173
+    /**
174
+     * 分页查询发送记录
175
+     */
176
+    public Page<SmsRecord> listRecords(int page, int size, String sendStatus, String phone, String bizType) {
177
+        LambdaQueryWrapper<SmsRecord> qw = new LambdaQueryWrapper<>();
178
+        if (sendStatus != null && !sendStatus.isEmpty()) {
179
+            qw.eq(SmsRecord::getSendStatus, sendStatus);
180
+        }
181
+        if (phone != null && !phone.isEmpty()) {
182
+            qw.like(SmsRecord::getPhone, phone);
183
+        }
184
+        if (bizType != null && !bizType.isEmpty()) {
185
+            qw.eq(SmsRecord::getBizType, bizType);
186
+        }
187
+        qw.orderByDesc(SmsRecord::getSendTime);
188
+        return smsRecordMapper.selectPage(new Page<>(page, size), qw);
189
+    }
190
+
191
+    /**
192
+     * 发送统计
193
+     */
194
+    public Map<String, Object> getSendStats(String startDate, String endDate) {
195
+        LambdaQueryWrapper<SmsRecord> qw = new LambdaQueryWrapper<>();
196
+        if (startDate != null) {
197
+            qw.ge(SmsRecord::getSendTime, LocalDateTime.parse(startDate.replace(" ", "T")));
198
+        }
199
+        if (endDate != null) {
200
+            qw.le(SmsRecord::getSendTime, LocalDateTime.parse(endDate.replace(" ", "T")));
201
+        }
202
+        List<SmsRecord> records = smsRecordMapper.selectList(qw);
203
+        Map<String, Object> stats = new LinkedHashMap<>();
204
+        stats.put("total", records.size());
205
+        stats.put("success", records.stream().filter(r -> "SUCCESS".equals(r.getSendStatus())).count());
206
+        stats.put("failed", records.stream().filter(r -> "FAILED".equals(r.getSendStatus())).count());
207
+        stats.put("pending", records.stream().filter(r -> "PENDING".equals(r.getSendStatus())).count());
208
+
209
+        // 按业务类型统计
210
+        Map<String, Long> byType = records.stream()
211
+                .filter(r -> r.getBizType() != null)
212
+                .collect(Collectors.groupingBy(SmsRecord::getBizType, Collectors.counting()));
213
+        stats.put("byBizType", byType);
214
+        return stats;
215
+    }
216
+
217
+    // ========== 内部方法 ==========
218
+
219
+    private String renderContent(String template, Map<String, String> variables) {
220
+        if (variables == null || variables.isEmpty()) {
221
+            return template;
222
+        }
223
+        String result = template;
224
+        for (Map.Entry<String, String> entry : variables.entrySet()) {
225
+            result = result.replace("${" + entry.getKey() + "}", entry.getValue());
226
+        }
227
+        return result;
228
+    }
229
+}

+ 318
- 0
wm-revenue/src/test/java/com/water/revenue/SmartMeterSmsAlipayTest.java Visa fil

@@ -0,0 +1,318 @@
1
+package com.water.revenue;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.entity.AlipayOrder;
6
+import com.water.revenue.entity.SmartMeter;
7
+import com.water.revenue.entity.SmsRecord;
8
+import com.water.revenue.entity.SmsTemplate;
9
+import com.water.revenue.mapper.AlipayOrderMapper;
10
+import com.water.revenue.mapper.SmartMeterMapper;
11
+import com.water.revenue.mapper.SmsRecordMapper;
12
+import com.water.revenue.mapper.SmsTemplateMapper;
13
+import com.water.revenue.service.AlipayService;
14
+import com.water.revenue.service.SmartMeterService;
15
+import com.water.revenue.service.SmsService;
16
+import org.junit.jupiter.api.BeforeEach;
17
+import org.junit.jupiter.api.DisplayName;
18
+import org.junit.jupiter.api.Nested;
19
+import org.junit.jupiter.api.Test;
20
+import org.junit.jupiter.api.extension.ExtendWith;
21
+import org.mockito.Mock;
22
+import org.mockito.junit.jupiter.MockitoExtension;
23
+
24
+import java.math.BigDecimal;
25
+import java.time.LocalDateTime;
26
+import java.util.*;
27
+
28
+import static org.junit.jupiter.api.Assertions.*;
29
+import static org.mockito.ArgumentMatchers.*;
30
+import static org.mockito.Mockito.*;
31
+
32
+@ExtendWith(MockitoExtension.class)
33
+class SmartMeterSmsAlipayTest {
34
+
35
+    // ========== SmartMeter Tests ==========
36
+
37
+    @Nested
38
+    @DisplayName("智能水表平台测试")
39
+    class SmartMeterTests {
40
+
41
+        @Mock
42
+        private SmartMeterMapper smartMeterMapper;
43
+
44
+        private SmartMeterService smartMeterService;
45
+
46
+        @BeforeEach
47
+        void setUp() {
48
+            smartMeterService = new SmartMeterService(smartMeterMapper);
49
+        }
50
+
51
+        @Test
52
+        @DisplayName("分页查询 - 正常返回")
53
+        void testList() {
54
+            Page<SmartMeter> mockPage = new Page<>(1, 10);
55
+            mockPage.setRecords(Arrays.asList(buildMeter("M001", "ONLINE", 85, 90)));
56
+            mockPage.setTotal(1);
57
+            when(smartMeterMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(mockPage);
58
+
59
+            Page<SmartMeter> result = smartMeterService.list(1, 10, "ONLINE", null, null);
60
+            assertNotNull(result);
61
+            assertEquals(1, result.getTotal());
62
+            assertEquals("M001", result.getRecords().get(0).getMeterNo());
63
+        }
64
+
65
+        @Test
66
+        @DisplayName("批量查询状态 - 返回多个水表状态")
67
+        void testBatchStatus() {
68
+            List<SmartMeter> meters = Arrays.asList(
69
+                    buildMeter("M001", "ONLINE", 85, 90),
70
+                    buildMeter("M002", "OFFLINE", 10, 5));
71
+            when(smartMeterMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(meters);
72
+
73
+            List<Map<String, Object>> result = smartMeterService.batchStatus(Arrays.asList("M001", "M002"));
74
+            assertEquals(2, result.size());
75
+            assertEquals("ONLINE", result.get(0).get("onlineStatus"));
76
+        }
77
+
78
+        @Test
79
+        @DisplayName("远程开阀 - 成功")
80
+        void testOpenValve() {
81
+            SmartMeter meter = buildMeter("M001", "ONLINE", 85, 90);
82
+            meter.setId(1L);
83
+            meter.setValveStatus("CLOSED");
84
+            when(smartMeterMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(meter);
85
+            when(smartMeterMapper.updateById(any(SmartMeter.class))).thenReturn(1);
86
+
87
+            Map<String, Object> result = smartMeterService.openValve("M001");
88
+            assertTrue((Boolean) result.get("success"));
89
+            assertEquals("OPEN", result.get("valveStatus"));
90
+        }
91
+
92
+        @Test
93
+        @DisplayName("远程关阀 - 水表不存在抛异常")
94
+        void testCloseValve_NotFound() {
95
+            when(smartMeterMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
96
+            assertThrows(RuntimeException.class, () -> smartMeterService.closeValve("NOTEXIST"));
97
+        }
98
+
99
+        @Test
100
+        @DisplayName("异常告警 - 低电量")
101
+        void testGetAlerts_LowBattery() {
102
+            SmartMeter lowBattery = buildMeter("M003", "ONLINE", 80, 10);
103
+            when(smartMeterMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(lowBattery));
104
+
105
+            List<Map<String, Object>> alerts = smartMeterService.getAlerts("LOW_BATTERY", null);
106
+            assertFalse(alerts.isEmpty());
107
+            assertTrue(alerts.get(0).get("alertType").toString().contains("LOW_BATTERY"));
108
+        }
109
+    }
110
+
111
+    // ========== SMS Tests ==========
112
+
113
+    @Nested
114
+    @DisplayName("短信平台测试")
115
+    class SmsTests {
116
+
117
+        @Mock
118
+        private SmsTemplateMapper smsTemplateMapper;
119
+
120
+        @Mock
121
+        private SmsRecordMapper smsRecordMapper;
122
+
123
+        private SmsService smsService;
124
+
125
+        @BeforeEach
126
+        void setUp() {
127
+            smsService = new SmsService(smsTemplateMapper, smsRecordMapper);
128
+        }
129
+
130
+        @Test
131
+        @DisplayName("发送短信 - 正常发送成功")
132
+        void testSendSms() {
133
+            SmsTemplate template = new SmsTemplate();
134
+            template.setId(1L);
135
+            template.setTemplateName("账单通知");
136
+            template.setTemplateType("BILL_NOTICE");
137
+            template.setContent("尊敬的${customerName},您的水费${amount}元");
138
+            template.setEnabled(1);
139
+            when(smsTemplateMapper.selectById(1L)).thenReturn(template);
140
+            when(smsRecordMapper.insert(any(SmsRecord.class))).thenReturn(1);
141
+
142
+            SmsRecord result = smsService.sendSms("13800138000", 1L,
143
+                    Map.of("customerName", "张三", "amount", "50.00"));
144
+            assertNotNull(result);
145
+            assertEquals("SUCCESS", result.getSendStatus());
146
+            assertTrue(result.getContent().contains("张三"));
147
+        }
148
+
149
+        @Test
150
+        @DisplayName("发送短信 - 模板禁用抛异常")
151
+        void testSendSms_DisabledTemplate() {
152
+            SmsTemplate template = new SmsTemplate();
153
+            template.setId(2L);
154
+            template.setEnabled(0);
155
+            when(smsTemplateMapper.selectById(2L)).thenReturn(template);
156
+
157
+            assertThrows(RuntimeException.class, () ->
158
+                    smsService.sendSms("13800138000", 2L, Map.of()));
159
+        }
160
+
161
+        @Test
162
+        @DisplayName("批量发送 - 部分失败")
163
+        void testBatchSend() {
164
+            SmsTemplate template = new SmsTemplate();
165
+            template.setId(1L);
166
+            template.setTemplateName("测试");
167
+            template.setTemplateType("GENERAL");
168
+            template.setContent("测试内容 ${name}");
169
+            template.setEnabled(1);
170
+            when(smsTemplateMapper.selectById(1L)).thenReturn(template);
171
+            when(smsRecordMapper.insert(any(SmsRecord.class))).thenReturn(1);
172
+
173
+            List<SmsRecord> records = smsService.batchSend(
174
+                    Arrays.asList("13800138001", "13800138002"), 1L, Map.of("name", "用户"));
175
+            assertEquals(2, records.size());
176
+        }
177
+
178
+        @Test
179
+        @DisplayName("查询发送记录 - 分页")
180
+        void testListRecords() {
181
+            Page<SmsRecord> mockPage = new Page<>(1, 10);
182
+            mockPage.setRecords(List.of(new SmsRecord()));
183
+            mockPage.setTotal(1);
184
+            when(smsRecordMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(mockPage);
185
+
186
+            Page<SmsRecord> result = smsService.listRecords(1, 10, "SUCCESS", null, null);
187
+            assertEquals(1, result.getTotal());
188
+        }
189
+
190
+        @Test
191
+        @DisplayName("发送统计 - 正确统计各状态数量")
192
+        void testGetSendStats() {
193
+            SmsRecord success = new SmsRecord();
194
+            success.setSendStatus("SUCCESS");
195
+            success.setSendTime(LocalDateTime.now());
196
+            success.setBizType("BILL_NOTICE");
197
+            SmsRecord failed = new SmsRecord();
198
+            failed.setSendStatus("FAILED");
199
+            failed.setSendTime(LocalDateTime.now());
200
+            failed.setBizType("ARREARS_WARNING");
201
+            when(smsRecordMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(success, failed));
202
+
203
+            Map<String, Object> stats = smsService.getSendStats(null, null);
204
+            assertEquals(2, stats.get("total"));
205
+            assertEquals(1L, stats.get("success"));
206
+            assertEquals(1L, stats.get("failed"));
207
+        }
208
+    }
209
+
210
+    // ========== Alipay Tests ==========
211
+
212
+    @Nested
213
+    @DisplayName("支付宝生活缴费测试")
214
+    class AlipayTests {
215
+
216
+        @Mock
217
+        private AlipayOrderMapper alipayOrderMapper;
218
+
219
+        private AlipayService alipayService;
220
+
221
+        @BeforeEach
222
+        void setUp() {
223
+            alipayService = new AlipayService(alipayOrderMapper);
224
+        }
225
+
226
+        @Test
227
+        @DisplayName("账单推送 - 生成订单")
228
+        void testPushBill() {
229
+            when(alipayOrderMapper.insert(any(AlipayOrder.class))).thenReturn(1);
230
+
231
+            Map<String, Object> result = alipayService.pushBill("C001", 100L, "2026-06", new BigDecimal("150.00"));
232
+            assertNotNull(result.get("outTradeNo"));
233
+            assertEquals("CREATED", result.get("status"));
234
+            assertEquals("C001", result.get("customerNo"));
235
+        }
236
+
237
+        @Test
238
+        @DisplayName("缴费回调 - 支付成功")
239
+        void testHandlePayNotify_Success() {
240
+            AlipayOrder order = new AlipayOrder();
241
+            order.setId(1L);
242
+            order.setOutTradeNo("ALIPAY_C001_20260615001");
243
+            order.setStatus("CREATED");
244
+            when(alipayOrderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
245
+            when(alipayOrderMapper.updateById(any(AlipayOrder.class))).thenReturn(1);
246
+
247
+            Map<String, String> params = Map.of(
248
+                    "out_trade_no", "ALIPAY_C001_20260615001",
249
+                    "trade_no", "2026061522001",
250
+                    "trade_status", "TRADE_SUCCESS");
251
+
252
+            Map<String, Object> result = alipayService.handlePayNotify(params);
253
+            assertTrue((Boolean) result.get("success"));
254
+            assertEquals("PAID", result.get("status"));
255
+        }
256
+
257
+        @Test
258
+        @DisplayName("退费 - 成功")
259
+        void testRefund_Success() {
260
+            AlipayOrder order = new AlipayOrder();
261
+            order.setId(1L);
262
+            order.setOutTradeNo("ALIPAY_C001_20260615001");
263
+            order.setStatus("PAID");
264
+            order.setAmount(new BigDecimal("150.00"));
265
+            when(alipayOrderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
266
+            when(alipayOrderMapper.updateById(any(AlipayOrder.class))).thenReturn(1);
267
+
268
+            Map<String, Object> result = alipayService.refund("ALIPAY_C001_20260615001",
269
+                    new BigDecimal("150.00"), "用户申请退款");
270
+            assertTrue((Boolean) result.get("success"));
271
+            assertEquals("REFUNDED", result.get("status"));
272
+        }
273
+
274
+        @Test
275
+        @DisplayName("退费 - 订单状态不允许退款")
276
+        void testRefund_InvalidStatus() {
277
+            AlipayOrder order = new AlipayOrder();
278
+            order.setId(1L);
279
+            order.setOutTradeNo("ALIPAY_C001_20260615001");
280
+            order.setStatus("CREATED");
281
+            when(alipayOrderMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(order);
282
+
283
+            assertThrows(RuntimeException.class, () ->
284
+                    alipayService.refund("ALIPAY_C001_20260615001", new BigDecimal("50.00"), "测试"));
285
+        }
286
+
287
+        @Test
288
+        @DisplayName("对账 - 返回汇总信息")
289
+        void testReconcile() {
290
+            AlipayOrder o1 = new AlipayOrder();
291
+            o1.setAmount(new BigDecimal("100.00"));
292
+            o1.setStatus("PAID");
293
+            AlipayOrder o2 = new AlipayOrder();
294
+            o2.setAmount(new BigDecimal("200.00"));
295
+            o2.setStatus("PAID");
296
+            when(alipayOrderMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(o1, o2));
297
+
298
+            Map<String, Object> result = alipayService.reconcile(null, null);
299
+            assertEquals(2, result.get("totalOrders"));
300
+            assertEquals(new BigDecimal("300.00"), result.get("totalAmount"));
301
+        }
302
+    }
303
+
304
+    // ========== Helper Methods ==========
305
+
306
+    private SmartMeter buildMeter(String meterNo, String onlineStatus, int signal, int battery) {
307
+        SmartMeter m = new SmartMeter();
308
+        m.setMeterNo(meterNo);
309
+        m.setCustomerNo("C_" + meterNo);
310
+        m.setOnlineStatus(onlineStatus);
311
+        m.setSignalStrength(signal);
312
+        m.setBatteryLevel(battery);
313
+        m.setValveStatus("OPEN");
314
+        m.setCurrentReading(100.0);
315
+        m.setLastReportTime(LocalDateTime.now());
316
+        return m;
317
+    }
318
+}