shinnosuke
2026-08-25 aabd3fb155475168427f1c14966beb873bff6763
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
package com.talentcard.server.service;
 
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.entity.BenefitVerifyRecord;
import com.talentcard.server.entity.TransportCarWashCredential;
import com.talentcard.server.entity.TransportCarWashService;
import com.talentcard.server.mapper.BenefitVerifyRecordMapper;
import com.talentcard.server.mapper.TransportCarWashCredentialMapper;
import com.talentcard.server.mapper.TransportCarWashServiceMapper;
import com.talentcard.server.request.CarWashBenefitRuleRequest;
import com.talentcard.server.request.CarWashCredentialAuditRequest;
import com.talentcard.server.request.CarWashCredentialRollbackRequest;
import com.talentcard.server.request.CarWashCredentialSubmitRequest;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
 
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
 
@Service
public class CarWashBenefitManagementService {
    private final TransportCarWashCredentialMapper credentialMapper;
    private final TransportCarWashServiceMapper configMapper;
    private final BenefitVerifyRecordMapper verifyRecordMapper;
    private final ObjectMapper objectMapper;
 
    public CarWashBenefitManagementService(TransportCarWashCredentialMapper credentialMapper,
                                           TransportCarWashServiceMapper configMapper,
                                           BenefitVerifyRecordMapper verifyRecordMapper,
                                           ObjectMapper objectMapper) {
        this.credentialMapper = credentialMapper;
        this.configMapper = configMapper;
        this.verifyRecordMapper = verifyRecordMapper;
        this.objectMapper = objectMapper;
    }
 
    @Transactional
    public Map<String, Object> saveRules(Long merchantId, CarWashBenefitRuleRequest request) {
        requireMerchant(merchantId);
        List<Map<String, Object>> rules = new ArrayList<>();
        for (CarWashBenefitRuleRequest.Rule item : request.rules()) {
            String cycle = normalizeCycle(item.resetCycle());
            Map<String, Object> rule = new LinkedHashMap<>();
            rule.put("cardLevel", item.cardLevel().trim());
            rule.put("totalCount", item.totalCount());
            rule.put("resetCycle", cycle);
            rule.put("familyCardShared", Boolean.TRUE.equals(item.familyCardShared()));
            rules.add(rule);
        }
        String json;
        try {
            json = objectMapper.writeValueAsString(rules);
        } catch (Exception exception) {
            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车权益次数配置保存失败");
        }
        TransportCarWashService config = configMapper.selectOne(new QueryWrapper<TransportCarWashService>()
                .eq("merchant_id", merchantId).eq("deleted", 0).last("limit 1 for update"));
        if (config == null) {
            config = new TransportCarWashService();
            config.setMerchantId(merchantId);
            config.setBenefitRules(json);
            config.setStatus(1);
            config.setDeleted(0);
            if (configMapper.insert(config) != 1) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车权益次数配置保存失败");
        } else if (configMapper.update(null, new UpdateWrapper<TransportCarWashService>()
                .set("benefit_rules", json).eq("id", config.getId()).eq("deleted", 0)) != 1) {
            throw new BusinessException(ErrorCode.CONFLICT, "洗车权益配置已变化,请刷新后重试");
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("merchantId", merchantId);
        result.put("rules", rules);
        return result;
    }
 
    @Transactional(readOnly = true)
    public Map<String, Object> rules(Long merchantId) {
        requireMerchant(merchantId);
        TransportCarWashService config = configMapper.selectOne(new QueryWrapper<TransportCarWashService>()
                .eq("merchant_id", merchantId).eq("deleted", 0).last("limit 1"));
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("merchantId", merchantId);
        result.put("rules", config == null ? List.of() : parseRules(config.getBenefitRules()));
        return result;
    }
 
    @Transactional
    public Map<String, Object> submit(Long merchantId, CarWashCredentialSubmitRequest request, String headerIdempotencyKey) {
        requireMerchant(merchantId);
        String key = first(request.idempotencyKey(), headerIdempotencyKey);
        if (key.isBlank() || key.length() > 128) throw new BusinessException(ErrorCode.BAD_REQUEST, "提交凭证不能为空且长度不能超过128");
        TransportCarWashCredential duplicate = credentialMapper.selectOne(new QueryWrapper<TransportCarWashCredential>()
                .eq("idempotency_key", key).eq("deleted", 0).last("limit 1"));
        if (duplicate != null) return detail(duplicate.getId());
        Map<String, Object> source = DatabaseRows.normalize(credentialMapper.selectActiveCard(request.talentId(), request.cardId()));
        if (source.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "人才卡不存在或当前不可用");
        Long primaryTalentId = request.talentId();
        Long primaryCardId = request.cardId();
        String cardLevel = text(source, "cardLevel");
        String cardType = text(source, "cardType");
        if (!"PERSONAL".equalsIgnoreCase(cardType)) {
            Map<String, Object> primary = DatabaseRows.normalize(credentialMapper.selectPrimaryCard(request.cardId()));
            if (primary.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡未找到有效主卡");
            primaryTalentId = longValue(primary, "primaryTalentId");
            primaryCardId = longValue(primary, "primaryCardId");
            cardLevel = text(primary, "cardLevel");
        }
        List<String> files = request.proofFiles() == null ? List.of() : request.proofFiles().stream().map(this::clean).filter(v -> !v.isBlank()).distinct().toList();
        for (String file : files) {
            if (!(file.startsWith("/uploads/") || file.startsWith("https://"))) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "凭证文件地址不正确");
            }
        }
        TransportCarWashCredential credential = new TransportCarWashCredential();
        credential.setCredentialNo(BusinessIdGenerator.next("CWC"));
        credential.setMerchantId(merchantId);
        credential.setTalentId(request.talentId());
        credential.setCardId(request.cardId());
        credential.setPrimaryTalentId(primaryTalentId);
        credential.setPrimaryCardId(primaryCardId);
        credential.setCardLevel(cardLevel);
        credential.setProofFiles(writeJson(files));
        credential.setDescription(trimToNull(request.description()));
        credential.setConsumeTime(request.consumeTime());
        credential.setStatus(0);
        credential.setDeductedCount(0);
        credential.setIdempotencyKey(key);
        credential.setSubmittedAt(LocalDateTime.now());
        credential.setDeleted(0);
        try {
            if (credentialMapper.insert(credential) != 1) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车凭证提交失败");
        } catch (DuplicateKeyException exception) {
            TransportCarWashCredential existing = credentialMapper.selectOne(new QueryWrapper<TransportCarWashCredential>()
                    .eq("idempotency_key", key).eq("deleted", 0).last("limit 1"));
            if (existing != null) return detail(existing.getId());
            throw exception;
        }
        return detail(credential.getId());
    }
 
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> page(Long merchantId, Integer status, String keyword,
                                                 LocalDate startDate, LocalDate endDate, int page, int size) {
        validateStatus(status);
        if (startDate != null && endDate != null && startDate.isAfter(endDate)) throw new BusinessException(ErrorCode.BAD_REQUEST, "开始日期不能晚于结束日期");
        int safePage = Math.max(page, 1);
        int safeSize = Math.min(Math.max(size, 1), 100);
        LocalDateTime start = startDate == null ? null : startDate.atStartOfDay();
        LocalDateTime end = endDate == null ? null : endDate.plusDays(1).atStartOfDay();
        List<Map<String, Object>> records = DatabaseRows.normalize(credentialMapper.selectPage(
                merchantId, status, clean(keyword), start, end, safeSize, (safePage - 1) * safeSize)).stream()
                .map(this::view).toList();
        Long total = credentialMapper.countPage(merchantId, status, clean(keyword), start, end);
        return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total);
    }
 
    @Transactional(readOnly = true)
    public Map<String, Object> detail(Long id) {
        Map<String, Object> row = credentialMapper.selectDetail(id);
        if (row == null || row.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "洗车凭证记录不存在");
        return view(DatabaseRows.normalize(row));
    }
 
    @Transactional
    public Map<String, Object> audit(Long id, CarWashCredentialAuditRequest request, Long operatorId) {
        if (request.status() == null || (request.status() != 1 && request.status() != 2)) throw new BusinessException(ErrorCode.BAD_REQUEST, "审核状态只能为1通过或2驳回");
        String remark = trimToNull(request.auditRemark());
        if (request.status() == 2 && remark == null) throw new BusinessException(ErrorCode.BAD_REQUEST, "驳回时必须填写审核备注");
        TransportCarWashCredential credential = credentialMapper.selectByIdForUpdate(id);
        if (credential == null) throw new BusinessException(ErrorCode.NOT_FOUND, "洗车凭证记录不存在");
        if (!Integer.valueOf(0).equals(credential.getStatus())) throw new BusinessException(ErrorCode.CONFLICT, "该洗车凭证已经审核");
        int quantity = request.quantity() == null ? 1 : request.quantity();
        if (quantity < 1 || quantity > 100) throw new BusinessException(ErrorCode.BAD_REQUEST, "扣减次数必须在1到100之间");
        Long verifyId = null;
        if (request.status() == 1) {
            Rule rule = rule(credential.getMerchantId(), credential.getCardLevel(), !credential.getCardId().equals(credential.getPrimaryCardId()));
            LocalDateTime windowStart = windowStart(rule.resetCycle());
            long used = number(credentialMapper.countUsedQuota(credential.getPrimaryCardId(), windowStart)).longValue();
            if (used + quantity > rule.totalCount()) throw new BusinessException(ErrorCode.CONFLICT, "洗车权益剩余次数不足");
            BenefitVerifyRecord record = new BenefitVerifyRecord();
            record.setVerifyNo(BusinessIdGenerator.next("CWV"));
            record.setTalentId(credential.getPrimaryTalentId());
            record.setCardId(credential.getPrimaryCardId());
            record.setMerchantId(credential.getMerchantId());
            record.setStaffId(operatorId);
            record.setVerifyType("CAR_WASH");
            record.setVerifyAmount((long) quantity);
            record.setSubsidyAmount(0L);
            record.setVerifyTime(LocalDateTime.now());
            record.setRemark("洗车凭证人工审核通过:" + credential.getCredentialNo());
            record.setStatus(1);
            record.setIdempotencyKey("CAR_WASH_CREDENTIAL:" + credential.getId());
            if (verifyRecordMapper.insert(record) != 1) throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车权益扣减流水保存失败");
            verifyId = record.getId();
        } else {
            quantity = 0;
        }
        credential.setStatus(request.status());
        credential.setAuditBy(operatorId);
        credential.setAuditTime(LocalDateTime.now());
        credential.setAuditRemark(remark);
        credential.setVerifyRecordId(verifyId);
        credential.setDeductedCount(quantity);
        if (credentialMapper.updateById(credential) != 1) throw new BusinessException(ErrorCode.CONFLICT, "洗车凭证状态已变化,请刷新后重试");
        return detail(id);
    }
 
    @Transactional
    public Map<String, Object> rollback(Long id, CarWashCredentialRollbackRequest request, Long operatorId) {
        TransportCarWashCredential credential = credentialMapper.selectByIdForUpdate(id);
        if (credential == null) throw new BusinessException(ErrorCode.NOT_FOUND, "洗车凭证记录不存在");
        if (!Integer.valueOf(1).equals(credential.getStatus()) || credential.getVerifyRecordId() == null) throw new BusinessException(ErrorCode.CONFLICT, "仅审核通过且已扣减的凭证可以回滚");
        int updated = verifyRecordMapper.update(null, new UpdateWrapper<BenefitVerifyRecord>()
                .set("status", 0).set("revoked_at", LocalDateTime.now()).set("revoked_by", operatorId)
                .set("revoke_reason", request.reason().trim())
                .eq("id", credential.getVerifyRecordId()).eq("status", 1));
        if (updated != 1) throw new BusinessException(ErrorCode.CONFLICT, "洗车权益流水已回滚,请刷新后重试");
        credential.setStatus(3);
        credential.setRollbackBy(operatorId);
        credential.setRollbackTime(LocalDateTime.now());
        credential.setRollbackReason(request.reason().trim());
        if (credentialMapper.updateById(credential) != 1) throw new BusinessException(ErrorCode.CONFLICT, "洗车凭证状态已变化,请刷新后重试");
        return detail(id);
    }
 
    private Rule rule(Long merchantId, String cardLevel, boolean familyCard) {
        TransportCarWashService config = configMapper.selectOne(new QueryWrapper<TransportCarWashService>()
                .eq("merchant_id", merchantId).eq("deleted", 0).eq("status", 1).last("limit 1"));
        if (config == null) throw new BusinessException(ErrorCode.CONFLICT, "洗车服务未启用");
        for (Map<String, Object> item : parseRules(config.getBenefitRules())) {
            if (levelMatches(text(item, "cardLevel"), cardLevel)) {
                Rule rule = new Rule(number(item.get("totalCount")).intValue(), normalizeCycle(text(item, "resetCycle")), Boolean.TRUE.equals(item.get("familyCardShared")));
                if (familyCard && !rule.familyCardShared()) throw new BusinessException(ErrorCode.CONFLICT, "亲属附属卡不可共享该洗车权益");
                return rule;
            }
        }
        throw new BusinessException(ErrorCode.CONFLICT, "当前人才卡等级未配置洗车权益次数");
    }
 
    private List<Map<String, Object>> parseRules(String json) {
        if (json == null || json.isBlank()) return List.of();
        try { return objectMapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {}); }
        catch (Exception exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车权益次数配置格式不正确"); }
    }
 
    private String writeJson(Object value) {
        try { return objectMapper.writeValueAsString(value); }
        catch (Exception exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "洗车凭证文件保存失败"); }
    }
 
    private Map<String, Object> view(Map<String, Object> row) {
        Map<String, Object> result = new LinkedHashMap<>(row);
        Object files = row.get("proofFiles");
        if (files != null) {
            try { result.put("proofFiles", objectMapper.readValue(String.valueOf(files), new TypeReference<List<String>>() {})); }
            catch (Exception ignored) { result.put("proofFiles", List.of()); }
        }
        int status = number(row.get("status")).intValue();
        result.put("statusText", switch (status) { case 0 -> "待审核"; case 1 -> "已通过"; case 2 -> "已驳回"; case 3 -> "已回滚"; default -> "未知"; });
        return result;
    }
 
    private LocalDateTime windowStart(String cycle) {
        LocalDate today = LocalDate.now();
        return switch (normalizeCycle(cycle)) {
            case "DAY" -> today.atStartOfDay();
            case "WEEK" -> today.with(DayOfWeek.MONDAY).atStartOfDay();
            case "MONTH" -> today.withDayOfMonth(1).atStartOfDay();
            case "YEAR" -> today.with(TemporalAdjusters.firstDayOfYear()).atStartOfDay();
            default -> LocalDate.of(1970, 1, 1).atStartOfDay();
        };
    }
 
    private String normalizeCycle(String raw) {
        String value = clean(raw).toUpperCase(Locale.ROOT);
        return switch (value) {
            case "DAY", "DAILY", "每天", "每日" -> "DAY";
            case "WEEK", "WEEKLY", "每周" -> "WEEK";
            case "MONTH", "MONTHLY", "每月" -> "MONTH";
            case "YEAR", "YEARLY", "ANNUAL", "每年" -> "YEAR";
            case "NONE", "不重置" -> "NONE";
            default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "洗车权益重置周期不正确");
        };
    }
 
    private boolean levelMatches(String configured, String actual) {
        String a = clean(configured).replace("人才卡", "");
        String b = clean(actual).replace("人才卡", "");
        return !a.isBlank() && (a.equalsIgnoreCase(b) || a.contains(b) || b.contains(a));
    }
 
    private void requireMerchant(Long merchantId) {
        if (merchantId == null || merchantId <= 0 || number(configMapper.countCarWashMerchant(merchantId)).longValue() == 0)
            throw new BusinessException(ErrorCode.NOT_FOUND, "洗车商家不存在");
    }
    private void validateStatus(Integer status) { if (status != null && (status < 0 || status > 3)) throw new BusinessException(ErrorCode.BAD_REQUEST, "洗车凭证状态不正确"); }
    private Number number(Object value) { return value instanceof Number number ? number : 0L; }
    private Long longValue(Map<String, Object> row, String key) { Object value = row.get(key); return value instanceof Number n ? n.longValue() : null; }
    private String text(Map<String, Object> row, String key) { Object value = row.get(key); return value == null ? "" : String.valueOf(value).trim(); }
    private String first(String... values) { for (String value : values) if (!clean(value).isBlank()) return clean(value); return ""; }
    private String clean(String value) { return value == null ? "" : value.trim(); }
    private String trimToNull(String value) { return clean(value).isBlank() ? null : clean(value); }
    private record Rule(int totalCount, String resetCycle, boolean familyCardShared) {}
}