add gao point

This commit is contained in:
Timi
2026-08-11 23:57:59 +08:00
parent 39ac73dc66
commit c6157db0de
26 changed files with 1534 additions and 1 deletions
@@ -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;
}
}
@@ -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();
}
}
@@ -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<GaoPointLedger> 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();
}
}
@@ -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<GaoPointRule> list(@RequestBody Page<GaoPointRule> 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());
}
}
@@ -52,6 +52,10 @@ public class GaoEventRecord extends UUIDEntity {
/// 营业日递增序号
private Long businessDayNo;
/// 本次登记提交的积分值,只参与积分触发,不落库
@Transient
private Long pointValue;
@Transient
private GaoCustomer customer;
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<GaoPointAccount, String> {
/// 查询客户当前有效积分账户
///
/// @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);
}
@@ -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<GaoPointLedger, String> {
/// 按幂等键查询流水
///
/// @param idempotencyKey 幂等键
/// @return 流水
GaoPointLedger selectByIdempotencyKey(@Param("idempotencyKey") String idempotencyKey);
/// 查询登记记录产生的原始获得积分流水
///
/// @param eventRecordId 登记记录 ID
/// @return 获得积分流水
List<GaoPointLedger> selectEarnByEventRecordId(@Param("eventRecordId") String eventRecordId);
/// 批量查询幂等键对应的流水
///
/// @param idempotencyKeyList 幂等键列表
/// @return 已存在流水
List<GaoPointLedger> selectByIdempotencyKeyList(@Param("idempotencyKeyList") List<String> idempotencyKeyList);
/// 查询积分流水总数
///
/// @param page 查询条件
/// @return 流水总数
long countByQuery(GaoPointLedgerPage page);
/// 分页查询积分流水
///
/// @param page 查询条件
/// @return 流水列表
List<GaoPointLedger> selectByQuery(GaoPointLedgerPage page);
}
@@ -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<GaoPointRule, String> {
/// 查询当前时间可匹配的启用规则
///
/// @param storeId 门店 ID
/// @param eventId 登记事件 ID
/// @param now 当前时间戳
/// @return 按优先级排序的规则列表
List<GaoPointRule> selectActiveByStoreIdAndEventId(@Param("storeId") String storeId, @Param("eventId") String eventId, @Param("now") long now);
}
@@ -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<GaoPointAccount, String> {
/// 查询客户当前有效积分账户
///
/// @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);
}
@@ -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<GaoPointLedger, String> {
/// 写入登记获得积分流水
///
/// @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<GaoPointLedger> pageByQuery(GaoPointLedgerPage page);
}
@@ -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<GaoPointRule, String> {
/// 查询当前时间可匹配的启用规则
///
/// @param storeId 门店 ID
/// @param eventId 登记事件 ID
/// @param now 当前时间戳
/// @return 规则列表
List<GaoPointRule> 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);
}
@@ -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);
}
@@ -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<GaoEve
private final AttachmentService attachmentService;
private final GaoCustomerService customerService;
private final GaoStoreBusinessDayService businessDayService;
private final GaoPointTriggerService pointTriggerService;
private final GaoEventRecordMapper mapper;
@@ -116,6 +118,7 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
dbRecord.setRegisteredAt(registeredAt);
dbRecord.setRegisteredDateValue(registeredDateValue);
dbRecord.setBusinessDayNo(businessDayNo);
dbRecord.setPointValue(record.getPointValue());
super.create(dbRecord);
// 附件
if (i == 0) {
@@ -131,6 +134,7 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
attachmentService.update(clone);
}
}
pointTriggerService.onEventRecordCreated(dbRecord);
logIdList.add(dbRecord.getId());
}
logger.setLevel(Logger.Level.INFO);
@@ -149,6 +153,14 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
}
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void createBatch(List<GaoEventRecord> 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<GaoEve
}
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void delete(String id) {
TimiException.required(id, "not found record.id");
Logger logger = new Logger(Logger.Module.GAO, "GAO_EVENT_RECORD_DELETE");
try {
logger.setContent(id);
GaoEventRecord record = get(id);
TimiException.required(record, "not found event record");
pointTriggerService.revokeByEventRecord(record);
super.delete(id);
logger.setLevel(Logger.Level.INFO);
logger.setResult(id);
} catch (TimiException e) {
logger.setLevel(Logger.Level.WARN);
logger.setException(e.getMessage());
throw e;
} catch (Exception e) {
logger.setLevel(Logger.Level.ERROR);
logger.setException(TimiJava.serializeThrowable(e));
log.error("删除 GAO 登记记录失败", e);
throw new TimiException(TimiCode.ERROR, "删除 GAO 登记记录失败", e);
} finally {
loggerService.create(logger);
}
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void createWithCustomer(GaoCustomer customer) {
@@ -0,0 +1,71 @@
package com.imyeyu.api.modules.gao.service.implement;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.gao.entity.GaoPointAccount;
import com.imyeyu.api.modules.gao.mapper.GaoPointAccountMapper;
import com.imyeyu.api.modules.gao.service.GaoPointAccountService;
import com.imyeyu.java.bean.timi.TimiException;
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;
import java.util.UUID;
/// GAO 客户积分账户服务实现
///
/// @author Codex
/// @since 2026-08-11
@Service
@RequiredArgsConstructor
public class GaoPointAccountServiceImplement extends AbstractEntityService<GaoPointAccount, String> implements GaoPointAccountService {
private final GaoPointAccountMapper mapper;
@Override
protected BaseMapper<GaoPointAccount, String> 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());
}
}
@@ -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<GaoPointLedger, String> implements GaoPointLedgerService {
private final GaoPointLedgerMapper mapper;
private final GaoPointAccountService accountService;
private final UserLoginService userLoginService;
@Override
protected BaseMapper<GaoPointLedger, String> 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<GaoPointLedger> pageByQuery(GaoPointLedgerPage page) {
TimiException.required(page, "not found page");
PageResult<GaoPointLedger> 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;
}
}
@@ -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<GaoPointRule, String> implements GaoPointRuleService {
private final GaoPointRuleMapper mapper;
private final GaoEventService eventService;
@Override
protected BaseMapper<GaoPointRule, String> mapper() {
return mapper;
}
@Override
public List<GaoPointRule> 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");
}
}
@@ -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<GaoPointRule> 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<GaoPointLedger> originLedgerList = ledgerMapper.selectEarnByEventRecordId(record.getId());
if (originLedgerList.isEmpty()) {
return;
}
Set<String> revokedKeySet = new HashSet<>();
List<String> 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());
}
}
@@ -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;
}
@@ -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;
}
@@ -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 客户积分流水';
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.imyeyu.api.modules.gao.mapper.GaoPointAccountMapper">
<select id="selectByStoreIdAndCustomerId" resultType="com.imyeyu.api.modules.gao.entity.GaoPointAccount">
SELECT *
FROM `gao_point_account`
WHERE `store_id` = #{storeId}
AND `customer_id` = #{customerId}
AND `deleted_at` IS NULL
LIMIT 1
</select>
<insert id="insertIfAbsent">
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`
</insert>
<select id="selectForUpdate" resultType="com.imyeyu.api.modules.gao.entity.GaoPointAccount">
SELECT *
FROM `gao_point_account`
WHERE `id` = #{id}
AND `deleted_at` IS NULL
LIMIT 1
FOR UPDATE
</select>
<update id="updateBalance">
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} &gt;= 0
AND `deleted_at` IS NULL
</update>
</mapper>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.imyeyu.api.modules.gao.mapper.GaoPointLedgerMapper">
<select id="selectByIdempotencyKey" resultType="com.imyeyu.api.modules.gao.entity.GaoPointLedger">
SELECT *
FROM `gao_point_ledger`
WHERE `idempotency_key` = #{idempotencyKey}
AND `deleted_at` IS NULL
LIMIT 1
</select>
<select id="selectEarnByEventRecordId" resultType="com.imyeyu.api.modules.gao.entity.GaoPointLedger">
SELECT *
FROM `gao_point_ledger`
WHERE `event_record_id` = #{eventRecordId}
AND `change_type` = 'EARN'
AND `deleted_at` IS NULL
ORDER BY `created_at` ASC, `id` ASC
</select>
<select id="selectByIdempotencyKeyList" resultType="com.imyeyu.api.modules.gao.entity.GaoPointLedger">
SELECT *
FROM `gao_point_ledger`
WHERE `deleted_at` IS NULL
AND `idempotency_key` IN
<foreach collection="idempotencyKeyList" item="idempotencyKey" open="(" separator="," close=")">
#{idempotencyKey}
</foreach>
</select>
<sql id="queryWhere">
`deleted_at` IS NULL
<if test="storeId != null and storeId != ''">
AND `store_id` = #{storeId}
</if>
<if test="customerId != null and customerId != ''">
AND `customer_id` = #{customerId}
</if>
<if test="accountId != null and accountId != ''">
AND `account_id` = #{accountId}
</if>
<if test="changeType != null">
AND `change_type` = #{changeType}
</if>
<if test="beginAt != null">
AND `occurred_at` &gt;= #{beginAt}
</if>
<if test="endAt != null">
AND `occurred_at` &lt;= #{endAt}
</if>
</sql>
<select id="countByQuery" resultType="long">
SELECT COUNT(1)
FROM `gao_point_ledger`
WHERE
<include refid="queryWhere"/>
</select>
<select id="selectByQuery" resultType="com.imyeyu.api.modules.gao.entity.GaoPointLedger">
SELECT *
FROM `gao_point_ledger`
WHERE
<include refid="queryWhere"/>
ORDER BY `occurred_at` DESC, `created_at` DESC
LIMIT #{offset}, #{limit}
</select>
</mapper>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.imyeyu.api.modules.gao.mapper.GaoPointRuleMapper">
<select id="selectActiveByStoreIdAndEventId" resultType="com.imyeyu.api.modules.gao.entity.GaoPointRule">
SELECT *
FROM `gao_point_rule`
WHERE `store_id` = #{storeId}
AND `event_id` = #{eventId}
AND `status` = 'ACTIVE'
AND (`begin_at` IS NULL OR `begin_at` &lt;= #{now})
AND (`end_at` IS NULL OR #{now} &lt;= `end_at`)
AND `deleted_at` IS NULL
ORDER BY `priority` ASC, `created_at` ASC, `id` ASC
</select>
</mapper>