diff --git a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoPermissionCode.java b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoPermissionCode.java index 06456a4..9015b38 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/bean/GaoPermissionCode.java +++ b/src/main/java/com/imyeyu/api/modules/gao/bean/GaoPermissionCode.java @@ -49,6 +49,22 @@ public enum GaoPermissionCode implements BuiltinPermissionCode { EVENT_RECORD_DELETE("EVENT_RECORD:DELETE", "登记事件记录删除", false, false), + POINT_ACCOUNT_READ("POINT_ACCOUNT:READ", "客户积分账户读取", true, true), + + POINT_LEDGER_READ("POINT_LEDGER:READ", "客户积分流水读取", true, true), + + POINT_RULE_CREATE("POINT_RULE:CREATE", "客户积分规则创建", true, true), + + POINT_RULE_READ("POINT_RULE:READ", "客户积分规则读取", true, true), + + POINT_RULE_UPDATE("POINT_RULE:UPDATE", "客户积分规则修改", true, true), + + POINT_RULE_DELETE("POINT_RULE:DELETE", "客户积分规则删除", true, true), + + POINT_ADJUST("POINT:ADJUST", "客户积分人工调整", true, true), + + POINT_SOURCE_VALUE("POINT:SOURCE_VALUE", "登记提交自定义积分值", true, true), + STAT_READ("STAT:READ", "统计读取", false, false), QR_CODE_CREATE("QR_CODE:CREATE", "二维码创建", false, false), @@ -103,5 +119,4 @@ public enum GaoPermissionCode implements BuiltinPermissionCode { public boolean isGrantToAdmin() { return false; } - } diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointAccountController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointAccountController.java new file mode 100644 index 0000000..5d37691 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointAccountController.java @@ -0,0 +1,50 @@ +package com.imyeyu.api.modules.gao.controller; + +import com.imyeyu.api.bean.ModuleCode; +import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission; +import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; +import com.imyeyu.api.modules.gao.bean.GaoRoleCode; +import com.imyeyu.api.modules.gao.entity.GaoPointAccount; +import com.imyeyu.api.modules.gao.service.GaoPointAccountService; +import com.imyeyu.api.modules.gao.service.GaoStoreService; +import com.imyeyu.api.modules.user.service.RoleChecker; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.annotation.AOPLog; +import com.imyeyu.spring.annotation.RequestRateLimit; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/// GAO 客户积分账户接口 +/// +/// @author Codex +/// @since 2026-08-11 +@RestController +@RequiredArgsConstructor +@RequestMapping("/gao/customer/point") +public class GaoPointAccountController { + + private final RoleChecker roleChecker; + private final GaoStoreService storeService; + private final GaoPointAccountService accountService; + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_ACCOUNT_READ) + @PostMapping("/account/detail") + public GaoPointAccount detail(@RequestParam String customerId, @RequestParam(required = false) String storeId) { + storeId = resolveStoreId(storeId); + GaoPointAccount account = accountService.getByCustomerId(storeId, customerId); + TimiException.required(account, "not found customer point account"); + return account; + } + + private String resolveStoreId(String requestedStoreId) { + if (roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name())) { + return requestedStoreId; + } + return storeService.getBelongIdByRequiredLoginUserId(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointLedgerController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointLedgerController.java new file mode 100644 index 0000000..412c761 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointLedgerController.java @@ -0,0 +1,63 @@ +package com.imyeyu.api.modules.gao.controller; + +import com.imyeyu.api.bean.ModuleCode; +import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission; +import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; +import com.imyeyu.api.modules.gao.bean.GaoRoleCode; +import com.imyeyu.api.modules.gao.entity.GaoPointLedger; +import com.imyeyu.api.modules.gao.service.GaoPointLedgerService; +import com.imyeyu.api.modules.gao.service.GaoPointTriggerService; +import com.imyeyu.api.modules.gao.service.GaoStoreService; +import com.imyeyu.api.modules.gao.vo.GaoPointAdjustRequest; +import com.imyeyu.api.modules.gao.vo.GaoPointLedgerPage; +import com.imyeyu.api.modules.user.service.RoleChecker; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.annotation.AOPLog; +import com.imyeyu.spring.annotation.RequestRateLimit; +import com.imyeyu.spring.bean.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/// GAO 客户积分流水接口 +/// +/// @author Codex +/// @since 2026-08-11 +@RestController +@RequiredArgsConstructor +@RequestMapping("/gao/customer/point") +public class GaoPointLedgerController { + + private final RoleChecker roleChecker; + private final GaoStoreService storeService; + private final GaoPointLedgerService ledgerService; + private final GaoPointTriggerService triggerService; + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_LEDGER_READ) + @PostMapping("/ledger/list") + public PageResult list(@RequestBody GaoPointLedgerPage page) { + TimiException.required(page, "not found page"); + page.setStoreId(resolveStoreId(page.getStoreId())); + return ledgerService.pageByQuery(page); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_ADJUST) + @PostMapping("/adjust") + public void adjust(@RequestBody GaoPointAdjustRequest request) { + request.setStoreId(resolveStoreId(request.getStoreId())); + triggerService.adjust(request); + } + + private String resolveStoreId(String requestedStoreId) { + if (roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name())) { + return requestedStoreId; + } + return storeService.getBelongIdByRequiredLoginUserId(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointRuleController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointRuleController.java new file mode 100644 index 0000000..02c6e9d --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoPointRuleController.java @@ -0,0 +1,108 @@ +package com.imyeyu.api.modules.gao.controller; + +import com.imyeyu.api.bean.ModuleCode; +import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission; +import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; +import com.imyeyu.api.modules.gao.bean.GaoRoleCode; +import com.imyeyu.api.modules.gao.entity.GaoPointRule; +import com.imyeyu.api.modules.gao.service.GaoPointRuleService; +import com.imyeyu.api.modules.gao.service.GaoStoreService; +import com.imyeyu.api.modules.user.service.RoleChecker; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.annotation.AOPLog; +import com.imyeyu.spring.annotation.RequestRateLimit; +import com.imyeyu.spring.bean.Page; +import com.imyeyu.spring.bean.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/// GAO 客户积分规则接口 +/// +/// @author Codex +/// @since 2026-08-11 +@RestController +@RequiredArgsConstructor +@RequestMapping("/gao/customer/point") +public class GaoPointRuleController { + + private final RoleChecker roleChecker; + private final GaoStoreService storeService; + private final GaoPointRuleService ruleService; + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_RULE_READ) + @PostMapping("/rule/list") + public PageResult list(@RequestBody Page page) { + TimiException.required(page, "not found page"); + if (!isGlobalManager()) { + GaoPointRule example = TimiJava.defaultIfNull(page.getEqualsExample(), new GaoPointRule()); + example.setStoreId(resolveStoreId(null)); + page.setEqualsExample(example); + } + return ruleService.page(page); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_RULE_READ) + @PostMapping("/rule/detail") + public GaoPointRule detail(@RequestParam String id) { + GaoPointRule rule = ruleService.get(id); + TimiException.required(rule, "not found point rule"); + checkStore(rule.getStoreId()); + return rule; + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_RULE_CREATE) + @PostMapping("/rule/create") + public String create(@RequestBody GaoPointRule rule) { + rule.setStoreId(resolveStoreId(rule.getStoreId())); + ruleService.create(rule); + return rule.getId(); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_RULE_UPDATE) + @PostMapping("/rule/update") + public void update(@RequestBody GaoPointRule rule) { + rule.setStoreId(resolveStoreId(rule.getStoreId())); + ruleService.update(rule); + } + + @AOPLog + @RequestRateLimit + @RequireGaoPermission(GaoPermissionCode.POINT_RULE_DELETE) + @PostMapping("/rule/delete") + public void delete(@RequestParam String id) { + GaoPointRule rule = ruleService.get(id); + TimiException.required(rule, "not found point rule"); + checkStore(rule.getStoreId()); + ruleService.delete(id); + } + + private String resolveStoreId(String requestedStoreId) { + if (isGlobalManager()) { + return requestedStoreId; + } + return storeService.getBelongIdByRequiredLoginUserId(); + } + + private void checkStore(String storeId) { + if (!isGlobalManager()) { + TimiException.requiredTrue(resolveStoreId(null).equals(storeId), "无权操作其他门店数据"); + } + } + + private boolean isGlobalManager() { + return roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name()); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoEventRecord.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoEventRecord.java index 9a2a53b..39f4739 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoEventRecord.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoEventRecord.java @@ -52,6 +52,10 @@ public class GaoEventRecord extends UUIDEntity { /// 营业日递增序号 private Long businessDayNo; + /// 本次登记提交的积分值,只参与积分触发,不落库 + @Transient + private Long pointValue; + @Transient private GaoCustomer customer; diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointAccount.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointAccount.java new file mode 100644 index 0000000..87c06eb --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointAccount.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.entity; + +import com.imyeyu.spring.entity.UUIDEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/// GAO 客户积分账户 +/// +/// @author Codex +/// @since 2026-08-11 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoPointAccount extends UUIDEntity { + + /// 门店 ID + private String storeId; + + /// 客户 ID + private String customerId; + + /// 当前可用积分余额 + private Long balance; + + /// 累计获得积分 + private Long totalEarned; + + /// 累计撤销积分 + private Long totalRevoked; + + /// 累计人工调整积分,可正可负 + private Long totalAdjusted; + + /// 乐观锁版本 + private Long version; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java new file mode 100644 index 0000000..550c558 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java @@ -0,0 +1,86 @@ +package com.imyeyu.api.modules.gao.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import com.imyeyu.spring.entity.UUIDEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/// GAO 客户积分流水 +/// +/// @author Codex +/// @since 2026-08-11 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoPointLedger extends UUIDEntity { + + /// 流水变化类型 + public enum ChangeType { + + /// 登记事件获得积分 + EARN, + + /// 删除登记记录撤销积分 + REVOKE, + + /// 人工调整积分 + ADJUST + } + + /// 流水方向 + public enum Direction { + + /// 增加积分 + INCREASE, + + /// 减少积分 + DECREASE + } + + /// 客户积分账户 ID + private String accountId; + + /// 门店 ID + private String storeId; + + /// 客户 ID + private String customerId; + + /// 变化类型 + private ChangeType changeType; + + /// 方向 + private Direction direction; + + /// 本次变化积分,增加为正,减少为负 + private Long pointDelta; + + /// 变化前余额 + private Long balanceBefore; + + /// 变化后余额 + private Long balanceAfter; + + /// 命中的积分规则 ID + private String ruleId; + + /// 登记事件 ID + private String eventId; + + /// 登记记录 ID + private String eventRecordId; + + /// 来源快照 + private JsonNode sourcePayload; + + /// 幂等键 + private String idempotencyKey; + + /// 备注 + private String remark; + + /// 操作用户 ID + private String operatorUserId; + + /// 发生时间 + private Long occurredAt; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRule.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRule.java new file mode 100644 index 0000000..b37d46c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRule.java @@ -0,0 +1,96 @@ +package com.imyeyu.api.modules.gao.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import com.imyeyu.spring.entity.UUIDEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/// GAO 客户积分规则 +/// +/// @author Codex +/// @since 2026-08-11 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoPointRule extends UUIDEntity { + + /// 规则状态 + public enum Status { + + /// 草稿,不参与触发 + DRAFT, + + /// 启用,参与触发 + ACTIVE, + + /// 停用,不参与触发 + INACTIVE, + + /// 已删除,不参与触发 + DELETED + } + + /// 触发类型 + public enum TriggerType { + + /// 每登记一次触发一次 + EVERY_TIME, + + /// 客户首次登记该事件触发一次 + FIRST_TIME, + + /// 客户累计登记达到 N 次后每次触发 + AFTER_COUNT_EVERY_TIME, + + /// 使用登记接口提交的积分值触发 + SOURCE_VALUE + } + + /// 积分值模式 + public enum PointMode { + + /// 使用规则固定积分值 + FIXED, + + /// 使用登记接口提交的积分值 + SOURCE_VALUE + } + + /// 门店 ID + private String storeId; + + /// 登记事件 ID + private String eventId; + + /// 规则名称 + private String name; + + /// 规则状态 + private Status status; + + /// 触发类型 + private TriggerType triggerType; + + /// 积分值模式 + private PointMode pointMode; + + /// 固定积分值 + private Long pointValue; + + /// 条件配置 + private JsonNode conditionJson; + + /// 生效开始时间戳,毫秒,空为不限制 + private Long beginAt; + + /// 生效结束时间戳,毫秒,空为不限制 + private Long endAt; + + /// 优先级,数值小优先 + private Integer priority; + + /// true 为命中后停止继续匹配后续规则 + private Boolean exclusive; + + /// 备注 + private String remark; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointAccountMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointAccountMapper.java new file mode 100644 index 0000000..4392dc3 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointAccountMapper.java @@ -0,0 +1,41 @@ +package com.imyeyu.api.modules.gao.mapper; + +import com.imyeyu.api.modules.gao.entity.GaoPointAccount; +import com.imyeyu.spring.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; + +/// GAO 客户积分账户 Mapper +/// +/// @author Codex +/// @since 2026-08-11 +public interface GaoPointAccountMapper extends BaseMapper { + + /// 查询客户当前有效积分账户 + /// + /// @param storeId 门店 ID + /// @param customerId 客户 ID + /// @return 积分账户 + GaoPointAccount selectByStoreIdAndCustomerId(@Param("storeId") String storeId, @Param("customerId") String customerId); + + /// 创建客户积分账户,已存在时保持原账户不变 + /// + /// @param account 积分账户 + void insertIfAbsent(@Param("account") GaoPointAccount account); + + /// 锁定积分账户 + /// + /// @param id 账户 ID + /// @return 积分账户 + GaoPointAccount selectForUpdate(@Param("id") String id); + + /// 原子更新积分账户余额和统计值 + /// + /// @param id 账户 ID + /// @param delta 本次余额变化 + /// @param totalEarnedDelta 累计获得积分变化 + /// @param totalRevokedDelta 累计撤销积分变化 + /// @param totalAdjustedDelta 累计人工调整积分变化 + /// @param updatedAt 更新时间 + /// @return 更新行数 + int updateBalance(@Param("id") String id, @Param("delta") long delta, @Param("totalEarnedDelta") long totalEarnedDelta, @Param("totalRevokedDelta") long totalRevokedDelta, @Param("totalAdjustedDelta") long totalAdjustedDelta, @Param("updatedAt") long updatedAt); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerMapper.java new file mode 100644 index 0000000..e1934fd --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerMapper.java @@ -0,0 +1,45 @@ +package com.imyeyu.api.modules.gao.mapper; + +import com.imyeyu.api.modules.gao.entity.GaoPointLedger; +import com.imyeyu.api.modules.gao.vo.GaoPointLedgerPage; +import com.imyeyu.spring.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/// GAO 客户积分流水 Mapper +/// +/// @author Codex +/// @since 2026-08-11 +public interface GaoPointLedgerMapper extends BaseMapper { + + /// 按幂等键查询流水 + /// + /// @param idempotencyKey 幂等键 + /// @return 流水 + GaoPointLedger selectByIdempotencyKey(@Param("idempotencyKey") String idempotencyKey); + + /// 查询登记记录产生的原始获得积分流水 + /// + /// @param eventRecordId 登记记录 ID + /// @return 获得积分流水 + List selectEarnByEventRecordId(@Param("eventRecordId") String eventRecordId); + + /// 批量查询幂等键对应的流水 + /// + /// @param idempotencyKeyList 幂等键列表 + /// @return 已存在流水 + List selectByIdempotencyKeyList(@Param("idempotencyKeyList") List idempotencyKeyList); + + /// 查询积分流水总数 + /// + /// @param page 查询条件 + /// @return 流水总数 + long countByQuery(GaoPointLedgerPage page); + + /// 分页查询积分流水 + /// + /// @param page 查询条件 + /// @return 流水列表 + List selectByQuery(GaoPointLedgerPage page); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRuleMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRuleMapper.java new file mode 100644 index 0000000..15a5807 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRuleMapper.java @@ -0,0 +1,22 @@ +package com.imyeyu.api.modules.gao.mapper; + +import com.imyeyu.api.modules.gao.entity.GaoPointRule; +import com.imyeyu.spring.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/// GAO 客户积分规则 Mapper +/// +/// @author Codex +/// @since 2026-08-11 +public interface GaoPointRuleMapper extends BaseMapper { + + /// 查询当前时间可匹配的启用规则 + /// + /// @param storeId 门店 ID + /// @param eventId 登记事件 ID + /// @param now 当前时间戳 + /// @return 按优先级排序的规则列表 + List selectActiveByStoreIdAndEventId(@Param("storeId") String storeId, @Param("eventId") String eventId, @Param("now") long now); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointAccountService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointAccountService.java new file mode 100644 index 0000000..c824b8a --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointAccountService.java @@ -0,0 +1,35 @@ +package com.imyeyu.api.modules.gao.service; + +import com.imyeyu.api.modules.gao.entity.GaoPointAccount; +import com.imyeyu.spring.service.BaseService; + +/// GAO 客户积分账户服务 +/// +/// @author Codex +/// @since 2026-08-11 +public interface GaoPointAccountService extends BaseService { + + /// 查询客户当前有效积分账户 + /// + /// @param storeId 门店 ID + /// @param customerId 客户 ID + /// @return 积分账户,不存在时返回 null + GaoPointAccount getByCustomerId(String storeId, String customerId); + + /// 获取并锁定客户积分账户,不存在时创建 + /// + /// @param storeId 门店 ID + /// @param customerId 客户 ID + /// @return 已锁定的积分账户 + GaoPointAccount getOrCreateForUpdate(String storeId, String customerId); + + /// 原子更新账户余额 + /// + /// @param accountId 账户 ID + /// @param delta 本次余额变化 + /// @param totalEarnedDelta 累计获得积分变化 + /// @param totalRevokedDelta 累计撤销积分变化 + /// @param totalAdjustedDelta 累计人工调整积分变化 + /// @return 更新行数 + int updateBalance(String accountId, long delta, long totalEarnedDelta, long totalRevokedDelta, long totalAdjustedDelta); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointLedgerService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointLedgerService.java new file mode 100644 index 0000000..e544369 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointLedgerService.java @@ -0,0 +1,46 @@ +package com.imyeyu.api.modules.gao.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.imyeyu.api.modules.gao.entity.GaoPointLedger; +import com.imyeyu.api.modules.gao.entity.GaoPointRule; +import com.imyeyu.api.modules.gao.entity.GaoEventRecord; +import com.imyeyu.api.modules.gao.vo.GaoPointAdjustRequest; +import com.imyeyu.api.modules.gao.vo.GaoPointLedgerPage; +import com.imyeyu.spring.bean.PageResult; +import com.imyeyu.spring.service.BaseService; + +/// GAO 客户积分流水服务 +/// +/// @author Codex +/// @since 2026-08-11 +public interface GaoPointLedgerService extends BaseService { + + /// 写入登记获得积分流水 + /// + /// @param record 登记记录 + /// @param rule 命中规则 + /// @param pointDelta 积分变化 + /// @param sourcePayload 来源快照 + /// @return 积分流水 + GaoPointLedger earn(GaoEventRecord record, GaoPointRule rule, long pointDelta, JsonNode sourcePayload); + + /// 写入删除登记记录的撤销流水 + /// + /// @param originLedger 原始获得积分流水 + /// @param record 登记记录 + /// @return 撤销流水 + GaoPointLedger revoke(GaoPointLedger originLedger, GaoEventRecord record); + + /// 写入人工调整流水 + /// + /// @param request 人工调整请求 + /// @param operatorUserId 操作用户 ID + /// @return 调整流水 + GaoPointLedger adjust(GaoPointAdjustRequest request, String operatorUserId); + + /// 分页查询积分流水 + /// + /// @param page 查询条件 + /// @return 分页结果 + PageResult pageByQuery(GaoPointLedgerPage page); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRuleService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRuleService.java new file mode 100644 index 0000000..5dbc7ff --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRuleService.java @@ -0,0 +1,36 @@ +package com.imyeyu.api.modules.gao.service; + +import com.imyeyu.api.modules.gao.entity.GaoPointRule; +import com.imyeyu.spring.service.BaseService; + +import java.util.List; + +/// GAO 客户积分规则服务 +/// +/// @author Codex +/// @since 2026-08-11 +public interface GaoPointRuleService extends BaseService { + + /// 查询当前时间可匹配的启用规则 + /// + /// @param storeId 门店 ID + /// @param eventId 登记事件 ID + /// @param now 当前时间戳 + /// @return 规则列表 + List listActive(String storeId, String eventId, long now); + + /// 创建积分规则 + /// + /// @param rule 积分规则 + void create(GaoPointRule rule); + + /// 修改积分规则 + /// + /// @param rule 积分规则 + void update(GaoPointRule rule); + + /// 删除积分规则 + /// + /// @param id 规则 ID + void delete(String id); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointTriggerService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointTriggerService.java new file mode 100644 index 0000000..b2e47db --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointTriggerService.java @@ -0,0 +1,28 @@ +package com.imyeyu.api.modules.gao.service; + +import com.imyeyu.api.modules.gao.entity.GaoPointLedger; +import com.imyeyu.api.modules.gao.entity.GaoEventRecord; +import com.imyeyu.api.modules.gao.vo.GaoPointAdjustRequest; + +/// GAO 客户积分触发服务 +/// +/// @author Codex +/// @since 2026-08-11 +public interface GaoPointTriggerService { + + /// 在登记记录创建成功后触发积分 + /// + /// @param record 登记记录 + void onEventRecordCreated(GaoEventRecord record); + + /// 撤销登记记录产生的全部获得积分 + /// + /// @param record 登记记录 + void revokeByEventRecord(GaoEventRecord record); + + /// 执行人工调整 + /// + /// @param request 人工调整请求 + /// @return 调整流水 + GaoPointLedger adjust(GaoPointAdjustRequest request); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoEventRecordServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoEventRecordServiceImplement.java index 1e3e670..6202982 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoEventRecordServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoEventRecordServiceImplement.java @@ -10,6 +10,7 @@ import com.imyeyu.api.modules.gao.entity.GaoEvent; import com.imyeyu.api.modules.gao.entity.GaoEventRecord; import com.imyeyu.api.modules.gao.mapper.GaoEventRecordMapper; import com.imyeyu.api.modules.gao.service.GaoCustomerService; +import com.imyeyu.api.modules.gao.service.GaoPointTriggerService; import com.imyeyu.api.modules.gao.service.GaoEventRecordService; import com.imyeyu.api.modules.gao.service.GaoEventService; import com.imyeyu.api.modules.gao.service.GaoStoreBusinessDayService; @@ -63,6 +64,7 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService recordList) { + for (GaoEventRecord record : TimiJava.safeIterable(recordList)) { + create(record); + } + } + @Transactional(TimiServerDBConfig.ROLLBACKER) @Override public void update(GaoEventRecord record) { @@ -193,6 +205,33 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService implements GaoPointAccountService { + + private final GaoPointAccountMapper mapper; + + @Override + protected BaseMapper mapper() { + return mapper; + } + + @Override + public GaoPointAccount getByCustomerId(String storeId, String customerId) { + TimiException.required(storeId, "not found storeId"); + TimiException.required(customerId, "not found customerId"); + return mapper.selectByStoreIdAndCustomerId(storeId, customerId); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoPointAccount getOrCreateForUpdate(String storeId, String customerId) { + TimiException.required(storeId, "not found storeId"); + TimiException.required(customerId, "not found customerId"); + + GaoPointAccount account = mapper.selectByStoreIdAndCustomerId(storeId, customerId); + if (account == null) { + account = new GaoPointAccount(); + account.setId(UUID.randomUUID().toString()); + account.setStoreId(storeId); + account.setCustomerId(customerId); + account.setBalance(0L); + account.setTotalEarned(0L); + account.setTotalRevoked(0L); + account.setTotalAdjusted(0L); + account.setVersion(0L); + account.setCreatedAt(Time.now()); + mapper.insertIfAbsent(account); + account = mapper.selectByStoreIdAndCustomerId(storeId, customerId); + } + TimiException.required(account, "not found customer point account"); + GaoPointAccount lockedAccount = mapper.selectForUpdate(account.getId()); + TimiException.required(lockedAccount, "not found customer point account"); + return lockedAccount; + } + + @Override + public int updateBalance(String accountId, long delta, long totalEarnedDelta, long totalRevokedDelta, long totalAdjustedDelta) { + TimiException.required(accountId, "not found accountId"); + return mapper.updateBalance(accountId, delta, totalEarnedDelta, totalRevokedDelta, totalAdjustedDelta, Time.now()); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java new file mode 100644 index 0000000..8b55c00 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java @@ -0,0 +1,142 @@ +package com.imyeyu.api.modules.gao.service.implement; + +import com.fasterxml.jackson.databind.JsonNode; +import com.imyeyu.api.config.dbsource.TimiServerDBConfig; +import com.imyeyu.api.modules.gao.entity.GaoPointAccount; +import com.imyeyu.api.modules.gao.entity.GaoPointLedger; +import com.imyeyu.api.modules.gao.entity.GaoPointRule; +import com.imyeyu.api.modules.gao.entity.GaoEventRecord; +import com.imyeyu.api.modules.gao.mapper.GaoPointLedgerMapper; +import com.imyeyu.api.modules.gao.service.GaoPointAccountService; +import com.imyeyu.api.modules.gao.service.GaoPointLedgerService; +import com.imyeyu.api.modules.gao.vo.GaoPointAdjustRequest; +import com.imyeyu.api.modules.gao.vo.GaoPointLedgerPage; +import com.imyeyu.api.modules.user.service.UserLoginService; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiCode; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.bean.PageResult; +import com.imyeyu.spring.mapper.BaseMapper; +import com.imyeyu.spring.service.AbstractEntityService; +import com.imyeyu.utils.Time; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/// GAO 客户积分流水服务实现 +/// +/// @author Codex +/// @since 2026-08-11 +@Service +@RequiredArgsConstructor +public class GaoPointLedgerServiceImplement extends AbstractEntityService implements GaoPointLedgerService { + + private final GaoPointLedgerMapper mapper; + private final GaoPointAccountService accountService; + private final UserLoginService userLoginService; + + @Override + protected BaseMapper mapper() { + return mapper; + } + + @Override + public void update(GaoPointLedger ledger) { + throw new TimiException(TimiCode.ERROR_NOT_SUPPORT, "积分流水只允许追加,禁止修改"); + } + + @Override + public void delete(String id) { + throw new TimiException(TimiCode.ERROR_NOT_SUPPORT, "积分流水只允许追加,禁止删除"); + } + + @Override + public void destroy(String id) { + throw new TimiException(TimiCode.ERROR_NOT_SUPPORT, "积分流水只允许追加,禁止物理删除"); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoPointLedger earn(GaoEventRecord record, GaoPointRule rule, long pointDelta, JsonNode sourcePayload) { + TimiException.required(record, "not found event record"); + TimiException.required(rule, "not found point rule"); + TimiException.requiredTrue(0 < pointDelta, "pointDelta invalid"); + String idempotencyKey = "GAO_EVENT_RECORD:%s:RULE:%s:EARN".formatted(record.getId(), rule.getId()); + return apply(record.getStoreId(), record.getCustomerId(), GaoPointLedger.ChangeType.EARN, pointDelta, rule.getId(), record.getEventId(), record.getId(), sourcePayload, idempotencyKey, rule.getRemark(), record.getOperatorUserId(), record.getRegisteredAt()); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoPointLedger revoke(GaoPointLedger originLedger, GaoEventRecord record) { + TimiException.required(originLedger, "not found origin ledger"); + TimiException.required(record, "not found event record"); + TimiException.requiredTrue(0 < originLedger.getPointDelta(), "origin ledger pointDelta invalid"); + String idempotencyKey = "GAO_EVENT_RECORD:%s:LEDGER:%s:REVOKE".formatted(record.getId(), originLedger.getId()); + return apply(originLedger.getStoreId(), originLedger.getCustomerId(), GaoPointLedger.ChangeType.REVOKE, -originLedger.getPointDelta(), originLedger.getRuleId(), originLedger.getEventId(), originLedger.getEventRecordId(), originLedger.getSourcePayload(), idempotencyKey, "撤销登记记录积分", userLoginService.getRequireLoginUserId(), Time.now()); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public GaoPointLedger adjust(GaoPointAdjustRequest request, String operatorUserId) { + TimiException.required(request, "not found adjust request"); + TimiException.requiredTrue(TimiJava.isNotEmpty(request.getAdjustRequestId()), "not found adjustRequestId"); + TimiException.required(request.getStoreId(), "not found storeId"); + TimiException.required(request.getCustomerId(), "not found customerId"); + TimiException.required(request.getPointDelta(), "not found pointDelta"); + TimiException.requiredTrue(request.getPointDelta() != 0, "pointDelta invalid"); + TimiException.requiredTrue(TimiJava.isNotEmpty(request.getRemark()), "not found adjust remark"); + TimiException.required(operatorUserId, "not found operatorUserId"); + String idempotencyKey = "MANUAL_ADJUST:%s".formatted(request.getAdjustRequestId()); + return apply(request.getStoreId(), request.getCustomerId(), GaoPointLedger.ChangeType.ADJUST, request.getPointDelta(), null, null, null, null, idempotencyKey, request.getRemark(), operatorUserId, Time.now()); + } + + @Override + public PageResult pageByQuery(GaoPointLedgerPage page) { + TimiException.required(page, "not found page"); + PageResult result = new PageResult<>(); + result.setTotal(mapper.countByQuery(page)); + result.setList(mapper.selectByQuery(page)); + return result; + } + + private GaoPointLedger apply(String storeId, String customerId, GaoPointLedger.ChangeType changeType, long pointDelta, String ruleId, String eventId, String eventRecordId, JsonNode sourcePayload, String idempotencyKey, String remark, String operatorUserId, Long occurredAt) { + GaoPointLedger existing = mapper.selectByIdempotencyKey(idempotencyKey); + if (existing != null) { + return existing; + } + + GaoPointAccount account = accountService.getOrCreateForUpdate(storeId, customerId); + existing = mapper.selectByIdempotencyKey(idempotencyKey); + if (existing != null) { + return existing; + } + + long balanceBefore = account.getBalance(); + long balanceAfter = Math.addExact(balanceBefore, pointDelta); + long totalEarnedDelta = changeType == GaoPointLedger.ChangeType.EARN ? pointDelta : 0L; + long totalRevokedDelta = changeType == GaoPointLedger.ChangeType.REVOKE ? -pointDelta : 0L; + long totalAdjustedDelta = changeType == GaoPointLedger.ChangeType.ADJUST ? pointDelta : 0L; + + GaoPointLedger ledger = new GaoPointLedger(); + ledger.setAccountId(account.getId()); + ledger.setStoreId(storeId); + ledger.setCustomerId(customerId); + ledger.setChangeType(changeType); + ledger.setDirection(0 < pointDelta ? GaoPointLedger.Direction.INCREASE : GaoPointLedger.Direction.DECREASE); + ledger.setPointDelta(pointDelta); + ledger.setBalanceBefore(balanceBefore); + ledger.setBalanceAfter(balanceAfter); + ledger.setRuleId(ruleId); + ledger.setEventId(eventId); + ledger.setEventRecordId(eventRecordId); + ledger.setSourcePayload(sourcePayload); + ledger.setIdempotencyKey(idempotencyKey); + ledger.setRemark(remark); + ledger.setOperatorUserId(operatorUserId); + ledger.setOccurredAt(occurredAt); + create(ledger); + int updated = accountService.updateBalance(account.getId(), pointDelta, totalEarnedDelta, totalRevokedDelta, totalAdjustedDelta); + TimiException.requiredTrue(0 < updated, "积分余额不足"); + return ledger; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointRuleServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointRuleServiceImplement.java new file mode 100644 index 0000000..f8c93b6 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointRuleServiceImplement.java @@ -0,0 +1,154 @@ +package com.imyeyu.api.modules.gao.service.implement; + +import com.fasterxml.jackson.databind.JsonNode; +import com.imyeyu.api.config.dbsource.TimiServerDBConfig; +import com.imyeyu.api.modules.gao.entity.GaoPointRule; +import com.imyeyu.api.modules.gao.entity.GaoEvent; +import com.imyeyu.api.modules.gao.mapper.GaoPointRuleMapper; +import com.imyeyu.api.modules.gao.service.GaoPointRuleService; +import com.imyeyu.api.modules.gao.service.GaoEventService; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.mapper.BaseMapper; +import com.imyeyu.spring.service.AbstractEntityService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/// GAO 客户积分规则服务实现 +/// +/// @author Codex +/// @since 2026-08-11 +@Service +@RequiredArgsConstructor +public class GaoPointRuleServiceImplement extends AbstractEntityService implements GaoPointRuleService { + + private final GaoPointRuleMapper mapper; + private final GaoEventService eventService; + + @Override + protected BaseMapper mapper() { + return mapper; + } + + @Override + public List listActive(String storeId, String eventId, long now) { + TimiException.required(storeId, "not found storeId"); + TimiException.required(eventId, "not found eventId"); + return mapper.selectActiveByStoreIdAndEventId(storeId, eventId, now); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void create(GaoPointRule rule) { + TimiException.required(rule, "not found rule"); + TimiException.required(rule.getStoreId(), "not found rule.storeId"); + TimiException.required(rule.getEventId(), "not found rule.eventId"); + TimiException.required(rule.getName(), "not found rule.name"); + checkEvent(rule.getStoreId(), rule.getEventId()); + rule.setStatus(TimiJava.defaultIfNull(rule.getStatus(), GaoPointRule.Status.DRAFT)); + rule.setPriority(TimiJava.defaultIfNull(rule.getPriority(), 0)); + rule.setExclusive(TimiJava.defaultIfNull(rule.getExclusive(), false)); + verifyRule(rule); + super.create(rule); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void update(GaoPointRule rule) { + TimiException.required(rule, "not found rule"); + TimiException.required(rule.getId(), "not found rule.id"); + TimiException.required(rule.getStoreId(), "not found rule.storeId"); + TimiException.required(rule.getEventId(), "not found rule.eventId"); + TimiException.required(rule.getName(), "not found rule.name"); + GaoPointRule dbRule = get(rule.getId()); + TimiException.required(dbRule, "not found rule"); + TimiException.requiredTrue(dbRule.getStoreId().equals(rule.getStoreId()), "rule.storeId invalid"); + checkEvent(rule.getStoreId(), rule.getEventId()); + rule.setPriority(TimiJava.defaultIfNull(rule.getPriority(), 0)); + rule.setExclusive(TimiJava.defaultIfNull(rule.getExclusive(), false)); + verifyRule(rule); + dbRule.setEventId(rule.getEventId()); + dbRule.setName(rule.getName()); + dbRule.setStatus(rule.getStatus()); + dbRule.setTriggerType(rule.getTriggerType()); + dbRule.setPointMode(rule.getPointMode()); + dbRule.setPointValue(rule.getPointValue()); + dbRule.setConditionJson(rule.getConditionJson()); + dbRule.setBeginAt(rule.getBeginAt()); + dbRule.setEndAt(rule.getEndAt()); + dbRule.setPriority(rule.getPriority()); + dbRule.setExclusive(rule.getExclusive()); + dbRule.setRemark(rule.getRemark()); + mapper.update(dbRule); + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void delete(String id) { + TimiException.required(id, "not found rule.id"); + GaoPointRule rule = get(id); + TimiException.required(rule, "not found rule"); + rule.setStatus(GaoPointRule.Status.DELETED); + super.update(rule); + super.delete(id); + } + + private void checkEvent(String storeId, String eventId) { + GaoEvent event = eventService.get(eventId); + TimiException.required(event, "not found event"); + TimiException.requiredTrue(storeId.equals(event.getStoreId()), "规则和登记事件不属于同一门店"); + } + + private void verifyRule(GaoPointRule rule) { + TimiException.required(rule.getStatus(), "not found rule.status"); + TimiException.required(rule.getTriggerType(), "not found rule.triggerType"); + TimiException.required(rule.getPointMode(), "not found rule.pointMode"); + TimiException.requiredTrue(rule.getPriority() != null && 0 <= rule.getPriority(), "rule.priority invalid"); + TimiException.requiredTrue(rule.getBeginAt() == null || rule.getEndAt() == null || rule.getBeginAt() <= rule.getEndAt(), "rule.beginAt or rule.endAt invalid"); + + if (rule.getTriggerType() == GaoPointRule.TriggerType.SOURCE_VALUE) { + TimiException.requiredTrue(rule.getPointMode() == GaoPointRule.PointMode.SOURCE_VALUE, "SOURCE_VALUE 规则必须使用 SOURCE_VALUE 积分值模式"); + TimiException.requiredNull(rule.getPointValue(), "SOURCE_VALUE 规则不能填写固定积分值"); + verifySourceCondition(rule.getConditionJson()); + return; + } + + TimiException.requiredTrue(rule.getPointMode() == GaoPointRule.PointMode.FIXED, "固定触发规则必须使用 FIXED 积分值模式"); + TimiException.required(rule.getPointValue(), "not found rule.pointValue"); + TimiException.requiredTrue(0 < rule.getPointValue(), "rule.pointValue invalid"); + if (rule.getTriggerType() == GaoPointRule.TriggerType.AFTER_COUNT_EVERY_TIME) { + JsonNode condition = requireObjectCondition(rule.getConditionJson()); + JsonNode threshold = condition.get("threshold"); + TimiException.requiredTrue(threshold != null && threshold.isIntegralNumber() && 0 < threshold.longValue(), "AFTER_COUNT_EVERY_TIME.threshold invalid"); + JsonNode includeCurrent = condition.get("includeCurrent"); + TimiException.requiredTrue(includeCurrent == null || includeCurrent.isNull() || includeCurrent.isBoolean(), "AFTER_COUNT_EVERY_TIME.includeCurrent invalid"); + verifyScope(condition); + } else if (rule.getTriggerType() == GaoPointRule.TriggerType.FIRST_TIME) { + verifyScope(requireObjectCondition(rule.getConditionJson())); + } + } + + private void verifySourceCondition(JsonNode conditionJson) { + JsonNode condition = conditionJson == null ? null : requireObjectCondition(conditionJson); + Long min = condition == null || condition.get("min") == null || condition.get("min").isNull() ? null : condition.get("min").isIntegralNumber() ? condition.get("min").longValue() : null; + Long max = condition == null || condition.get("max") == null || condition.get("max").isNull() ? null : condition.get("max").isIntegralNumber() ? condition.get("max").longValue() : null; + TimiException.requiredTrue(condition == null || min != null || condition.get("min") == null || condition.get("min").isNull(), "SOURCE_VALUE.min invalid"); + TimiException.requiredTrue(condition == null || max != null || condition.get("max") == null || condition.get("max").isNull(), "SOURCE_VALUE.max invalid"); + TimiException.requiredTrue(min == null || 0 < min, "SOURCE_VALUE.min invalid"); + TimiException.requiredTrue(max == null || 0 < max, "SOURCE_VALUE.max invalid"); + TimiException.requiredTrue(min == null || max == null || min <= max, "SOURCE_VALUE.min or SOURCE_VALUE.max invalid"); + } + + private JsonNode requireObjectCondition(JsonNode conditionJson) { + TimiException.requiredTrue(conditionJson != null && conditionJson.isObject(), "rule.conditionJson must be object"); + return conditionJson; + } + + private void verifyScope(JsonNode condition) { + JsonNode scope = condition.get("scope"); + TimiException.requiredTrue(scope == null || scope.isNull() || "CUSTOMER_EVENT".equals(scope.asText()), "rule.conditionJson.scope invalid"); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointTriggerServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointTriggerServiceImplement.java new file mode 100644 index 0000000..fef6dea --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointTriggerServiceImplement.java @@ -0,0 +1,159 @@ +package com.imyeyu.api.modules.gao.service.implement; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.imyeyu.api.bean.ModuleCode; +import com.imyeyu.api.config.dbsource.TimiServerDBConfig; +import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; +import com.imyeyu.api.modules.gao.entity.GaoPointLedger; +import com.imyeyu.api.modules.gao.entity.GaoPointRule; +import com.imyeyu.api.modules.gao.entity.GaoCustomer; +import com.imyeyu.api.modules.gao.entity.GaoEventRecord; +import com.imyeyu.api.modules.gao.mapper.GaoPointLedgerMapper; +import com.imyeyu.api.modules.gao.mapper.GaoEventRecordMapper; +import com.imyeyu.api.modules.gao.service.GaoPointLedgerService; +import com.imyeyu.api.modules.gao.service.GaoPointRuleService; +import com.imyeyu.api.modules.gao.service.GaoPointTriggerService; +import com.imyeyu.api.modules.gao.service.GaoCustomerService; +import com.imyeyu.api.modules.gao.vo.GaoPointAdjustRequest; +import com.imyeyu.api.modules.user.service.PermissionChecker; +import com.imyeyu.api.modules.user.service.UserLoginService; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.utils.Time; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// GAO 客户积分触发服务实现 +/// +/// @author Codex +/// @since 2026-08-11 +@Service +@RequiredArgsConstructor +public class GaoPointTriggerServiceImplement implements GaoPointTriggerService { + + private final ObjectMapper jackson; + private final PermissionChecker permissionChecker; + private final UserLoginService userLoginService; + private final GaoPointRuleService ruleService; + private final GaoPointLedgerService ledgerService; + private final GaoCustomerService customerService; + private final GaoPointLedgerMapper ledgerMapper; + private final GaoEventRecordMapper eventRecordMapper; + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void onEventRecordCreated(GaoEventRecord record) { + TimiException.required(record, "not found event record"); + TimiException.required(record.getStoreId(), "not found record.storeId"); + TimiException.required(record.getCustomerId(), "not found record.customerId"); + TimiException.required(record.getEventId(), "not found record.eventId"); + + List ruleList = ruleService.listActive(record.getStoreId(), record.getEventId(), Time.now()); + Long count = null; + for (GaoPointRule rule : ruleList) { + Long pointDelta = resolvePointDelta(record, rule, count); + if (pointDelta == null) { + continue; + } + if (count == null) { + count = eventRecordMapper.count(record.getStoreId(), record.getCustomerId(), record.getEventId(), null, null); + } + JsonNode sourcePayload = sourcePayload(record, rule, pointDelta); + ledgerService.earn(record, rule, pointDelta, sourcePayload); + if (Boolean.TRUE.equals(rule.getExclusive())) { + break; + } + } + } + + @Transactional(TimiServerDBConfig.ROLLBACKER) + @Override + public void revokeByEventRecord(GaoEventRecord record) { + TimiException.required(record, "not found event record"); + List originLedgerList = ledgerMapper.selectEarnByEventRecordId(record.getId()); + if (originLedgerList.isEmpty()) { + return; + } + Set revokedKeySet = new HashSet<>(); + List revokeKeyList = originLedgerList.stream().map(origin -> revokeKey(record, origin)).toList(); + for (GaoPointLedger ledger : ledgerMapper.selectByIdempotencyKeyList(revokeKeyList)) { + revokedKeySet.add(ledger.getIdempotencyKey()); + } + for (GaoPointLedger originLedger : originLedgerList) { + if (!revokedKeySet.contains(revokeKey(record, originLedger))) { + ledgerService.revoke(originLedger, record); + } + } + } + + @Override + public GaoPointLedger adjust(GaoPointAdjustRequest request) { + TimiException.required(request, "not found adjust request"); + TimiException.required(request.getStoreId(), "not found storeId"); + TimiException.required(request.getCustomerId(), "not found customerId"); + GaoCustomer customer = customerService.get(request.getCustomerId()); + TimiException.required(customer, "not found customer"); + TimiException.requiredTrue(request.getStoreId().equals(customer.getStoreId()), "客户和积分调整门店不一致"); + return ledgerService.adjust(request, userLoginService.getRequireLoginUserId()); + } + + private Long resolvePointDelta(GaoEventRecord record, GaoPointRule rule, Long count) { + return switch (rule.getTriggerType()) { + case EVERY_TIME -> rule.getPointValue(); + case FIRST_TIME -> { + long currentCount = count == null ? count(record) : count; + yield currentCount == 1 ? rule.getPointValue() : null; + } + case AFTER_COUNT_EVERY_TIME -> { + JsonNode condition = rule.getConditionJson(); + long threshold = condition.get("threshold").longValue(); + boolean includeCurrent = condition.get("includeCurrent") == null || condition.get("includeCurrent").isNull() || condition.get("includeCurrent").asBoolean(); + long currentCount = count == null ? count(record) : count; + yield includeCurrent ? threshold <= currentCount ? rule.getPointValue() : null : threshold < currentCount ? rule.getPointValue() : null; + } + case SOURCE_VALUE -> resolveSourceValue(record, rule); + }; + } + + private Long resolveSourceValue(GaoEventRecord record, GaoPointRule rule) { + if (record.getPointValue() == null) { + return null; + } + permissionChecker.checkAny(ModuleCode.GAO, GaoPermissionCode.POINT_SOURCE_VALUE.getValue()); + long pointValue = record.getPointValue(); + TimiException.requiredTrue(0 < pointValue, "pointValue invalid"); + JsonNode condition = rule.getConditionJson(); + if (condition != null) { + JsonNode min = condition.get("min"); + JsonNode max = condition.get("max"); + TimiException.requiredTrue(min == null || min.isNull() || min.longValue() <= pointValue, "pointValue 小于规则最小值"); + TimiException.requiredTrue(max == null || max.isNull() || pointValue <= max.longValue(), "pointValue 大于规则最大值"); + } + return pointValue; + } + + private JsonNode sourcePayload(GaoEventRecord record, GaoPointRule rule, long pointDelta) { + if (rule.getTriggerType() != GaoPointRule.TriggerType.SOURCE_VALUE) { + return null; + } + ObjectNode payload = jackson.createObjectNode(); + payload.put("submittedPointValue", record.getPointValue()); + payload.put("validatedPointValue", pointDelta); + return payload; + } + + private long count(GaoEventRecord record) { + return TimiJava.defaultIfNull(eventRecordMapper.count(record.getStoreId(), record.getCustomerId(), record.getEventId(), null, null), 0L); + } + + private String revokeKey(GaoEventRecord record, GaoPointLedger originLedger) { + return "GAO_EVENT_RECORD:%s:LEDGER:%s:REVOKE".formatted(record.getId(), originLedger.getId()); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointAdjustRequest.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointAdjustRequest.java new file mode 100644 index 0000000..f3f0775 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointAdjustRequest.java @@ -0,0 +1,26 @@ +package com.imyeyu.api.modules.gao.vo; + +import lombok.Data; + +/// GAO 客户积分人工调整请求 +/// +/// @author Codex +/// @since 2026-08-11 +@Data +public class GaoPointAdjustRequest { + + /// 人工调整请求 ID,用于生成幂等键 + private String adjustRequestId; + + /// 门店 ID + private String storeId; + + /// 客户 ID + private String customerId; + + /// 调整积分,正数增加,负数减少 + private Long pointDelta; + + /// 调整原因 + private String remark; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointLedgerPage.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointLedgerPage.java new file mode 100644 index 0000000..7e30f57 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPointLedgerPage.java @@ -0,0 +1,33 @@ +package com.imyeyu.api.modules.gao.vo; + +import com.imyeyu.api.modules.gao.entity.GaoPointLedger; +import com.imyeyu.java.bean.BasePage; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/// GAO 客户积分流水查询请求 +/// +/// @author Codex +/// @since 2026-08-11 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoPointLedgerPage extends BasePage { + + /// 门店 ID + private String storeId; + + /// 客户 ID + private String customerId; + + /// 客户积分账户 ID + private String accountId; + + /// 流水变化类型 + private GaoPointLedger.ChangeType changeType; + + /// 开始发生时间 + private Long beginAt; + + /// 结束发生时间 + private Long endAt; +} diff --git a/src/main/resources/db/migration/timiserver/V29__create_gao_customer_point_tables.sql b/src/main/resources/db/migration/timiserver/V29__create_gao_customer_point_tables.sql new file mode 100644 index 0000000..0e2f43e --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V29__create_gao_customer_point_tables.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS `gao_point_account` ( + `id` VARCHAR(36) NOT NULL, + `store_id` VARCHAR(36) NOT NULL COMMENT '门店 ID', + `customer_id` VARCHAR(36) NOT NULL COMMENT '客户 ID', + `active_customer_id` VARCHAR(36) GENERATED ALWAYS AS ( + CASE WHEN `deleted_at` IS NULL THEN `customer_id` END + ) STORED COMMENT '未删除客户 ID' INVISIBLE, + `balance` BIGINT NOT NULL DEFAULT 0 COMMENT '当前可用积分余额', + `total_earned` BIGINT NOT NULL DEFAULT 0 COMMENT '累计获得积分', + `total_revoked` BIGINT NOT NULL DEFAULT 0 COMMENT '累计撤销积分', + `total_adjusted` BIGINT NOT NULL DEFAULT 0 COMMENT '累计人工调整积分', + `version` BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + `created_at` BIGINT NOT NULL, + `updated_at` BIGINT NULL, + `deleted_at` BIGINT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_gao_point_account_customer` (`store_id`, `active_customer_id`), + KEY `idx_gao_point_account_customer_id` (`customer_id`), + KEY `idx_gao_point_account_store_balance` (`store_id`, `deleted_at`, `balance`) +) COMMENT='GAO 客户积分账户'; + +CREATE TABLE IF NOT EXISTS `gao_point_rule` ( + `id` VARCHAR(36) NOT NULL, + `store_id` VARCHAR(36) NOT NULL COMMENT '门店 ID', + `event_id` VARCHAR(36) NOT NULL COMMENT '登记事件 ID', + `name` VARCHAR(64) NOT NULL COMMENT '规则名称', + `status` VARCHAR(32) NOT NULL COMMENT '规则状态', + `trigger_type` VARCHAR(32) NOT NULL COMMENT '触发类型', + `point_mode` VARCHAR(32) NOT NULL COMMENT '积分值模式', + `point_value` BIGINT NULL COMMENT '固定积分值', + `condition_json` JSON NULL COMMENT '条件配置', + `begin_at` BIGINT NULL COMMENT '生效开始时间戳,毫秒', + `end_at` BIGINT NULL COMMENT '生效结束时间戳,毫秒', + `priority` INT NOT NULL DEFAULT 0 COMMENT '优先级,数值小优先', + `exclusive` BIT(1) NOT NULL DEFAULT b'0' COMMENT 'true 为命中后停止继续匹配后续规则', + `remark` TEXT NULL COMMENT '备注', + `created_at` BIGINT NOT NULL, + `updated_at` BIGINT NULL, + `deleted_at` BIGINT NULL, + PRIMARY KEY (`id`), + KEY `idx_gao_point_rule_match` (`store_id`, `event_id`, `status`, `deleted_at`, `priority`), + KEY `idx_gao_point_rule_type` (`trigger_type`, `point_mode`) +) COMMENT='GAO 客户积分规则'; + +CREATE TABLE IF NOT EXISTS `gao_point_ledger` ( + `id` VARCHAR(36) NOT NULL, + `account_id` VARCHAR(36) NOT NULL COMMENT '客户积分账户 ID', + `store_id` VARCHAR(36) NOT NULL COMMENT '门店 ID', + `customer_id` VARCHAR(36) NOT NULL COMMENT '客户 ID', + `change_type` VARCHAR(32) NOT NULL COMMENT '变化类型', + `direction` VARCHAR(16) NOT NULL COMMENT '方向', + `point_delta` BIGINT NOT NULL COMMENT '本次变化积分,增加为正,减少为负', + `balance_before` BIGINT NOT NULL COMMENT '变化前余额', + `balance_after` BIGINT NOT NULL COMMENT '变化后余额', + `rule_id` VARCHAR(36) NULL COMMENT '积分规则 ID', + `event_id` VARCHAR(36) NULL COMMENT '登记事件 ID', + `event_record_id` VARCHAR(36) NULL COMMENT '登记记录 ID', + `source_payload` JSON NULL COMMENT '来源快照', + `idempotency_key` VARCHAR(128) NOT NULL COMMENT '幂等键', + `remark` TEXT NULL COMMENT '备注', + `operator_user_id` VARCHAR(36) NULL COMMENT '操作用户 ID', + `occurred_at` BIGINT NOT NULL COMMENT '发生时间', + `created_at` BIGINT NOT NULL, + `updated_at` BIGINT NULL, + `deleted_at` BIGINT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_gao_point_ledger_idempotency` (`idempotency_key`), + KEY `idx_gao_point_ledger_account_time` (`account_id`, `deleted_at`, `occurred_at`), + KEY `idx_gao_point_ledger_customer_time` (`store_id`, `customer_id`, `deleted_at`, `occurred_at`), + KEY `idx_gao_point_ledger_event_record` (`event_record_id`, `change_type`), + KEY `idx_gao_point_ledger_rule_time` (`rule_id`, `occurred_at`) +) COMMENT='GAO 客户积分流水'; diff --git a/src/main/resources/mapper/timi-server/GaoPointAccountMapper.xml b/src/main/resources/mapper/timi-server/GaoPointAccountMapper.xml new file mode 100644 index 0000000..c2000b0 --- /dev/null +++ b/src/main/resources/mapper/timi-server/GaoPointAccountMapper.xml @@ -0,0 +1,44 @@ + + + + + + + INSERT INTO `gao_point_account` ( + `id`, `store_id`, `customer_id`, `balance`, `total_earned`, `total_revoked`, `total_adjusted`, `version`, `created_at`, `updated_at`, `deleted_at` + ) VALUES ( + #{account.id}, #{account.storeId}, #{account.customerId}, #{account.balance}, #{account.totalEarned}, #{account.totalRevoked}, #{account.totalAdjusted}, #{account.version}, #{account.createdAt}, #{account.updatedAt}, #{account.deletedAt} + ) + ON DUPLICATE KEY UPDATE `id` = `id` + + + + + + UPDATE `gao_point_account` + SET + `balance` = `balance` + #{delta}, + `total_earned` = `total_earned` + #{totalEarnedDelta}, + `total_revoked` = `total_revoked` + #{totalRevokedDelta}, + `total_adjusted` = `total_adjusted` + #{totalAdjustedDelta}, + `version` = `version` + 1, + `updated_at` = #{updatedAt} + WHERE `id` = #{id} + AND `balance` + #{delta} >= 0 + AND `deleted_at` IS NULL + + diff --git a/src/main/resources/mapper/timi-server/GaoPointLedgerMapper.xml b/src/main/resources/mapper/timi-server/GaoPointLedgerMapper.xml new file mode 100644 index 0000000..3a4d6ac --- /dev/null +++ b/src/main/resources/mapper/timi-server/GaoPointLedgerMapper.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + `deleted_at` IS NULL + + AND `store_id` = #{storeId} + + + AND `customer_id` = #{customerId} + + + AND `account_id` = #{accountId} + + + AND `change_type` = #{changeType} + + + AND `occurred_at` >= #{beginAt} + + + AND `occurred_at` <= #{endAt} + + + + + + + diff --git a/src/main/resources/mapper/timi-server/GaoPointRuleMapper.xml b/src/main/resources/mapper/timi-server/GaoPointRuleMapper.xml new file mode 100644 index 0000000..c5434d5 --- /dev/null +++ b/src/main/resources/mapper/timi-server/GaoPointRuleMapper.xml @@ -0,0 +1,15 @@ + + + + +