v1.0.31 #43

Merged
timi merged 10 commits from dev into master 2026-09-07 07:40:03 +00:00
53 changed files with 1708 additions and 541 deletions
Showing only changes of commit 41f692aad9 - Show all commits
+5
View File
@@ -180,6 +180,11 @@
</repositories>
<dependencies>
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy</artifactId>
<version>4.0.29</version>
</dependency>
<dependency>
<groupId>com.imyeyu.spring</groupId>
<artifactId>timi-spring</artifactId>
@@ -0,0 +1,9 @@
package com.imyeyu.api.modules.common.bean;
/// Groovy 脚本执行结果
///
/// @param <T> 脚本返回值类型
/// @author Codex
/// @since 2026-09-04
public record GroovyScriptExecutionResult<T>(String scriptId, String versionId, Integer version, String checksum, T result) {
}
@@ -48,6 +48,8 @@ public class Attachment extends UUIDEntity {
GAO_ORDER_FULFILLMENT,
GAO_SALES_ORDER,
TEMP_FILE,
MIRROR
@@ -0,0 +1,42 @@
package com.imyeyu.api.modules.common.entity;
import com.imyeyu.spring.entity.UUIDEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/// Groovy 脚本
///
/// @author Codex
/// @since 2026-09-04
@Data
@EqualsAndHashCode(callSuper = true)
public class GroovyScript extends UUIDEntity {
///
///
/// @author 夜雨
/// @since 2026/9/4 14:54
public enum BizType {
/// GAO 积分规则
GAO_POINT_RULE
}
/// 业务归属
private BizType bizType;
/// 业务内唯一代码
private String code;
/// 名称
private String name;
/// 说明
private String description;
/// true 为允许执行已发布版本
private Boolean enabled;
/// 当前发布版本 ID
private String publishedVersionId;
}
@@ -0,0 +1,26 @@
package com.imyeyu.api.modules.common.entity;
import com.imyeyu.spring.entity.UUIDEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/// Groovy 脚本版本
///
/// @author Codex
/// @since 2026-09-04
@Data
@EqualsAndHashCode(callSuper = true)
public class GroovyScriptVersion extends UUIDEntity {
/// 脚本 ID
private String scriptId;
/// 版本号
private Integer version;
/// 脚本内容
private String content;
/// 脚本内容 SHA-256
private String checksum;
}
@@ -0,0 +1,11 @@
package com.imyeyu.api.modules.common.mapper;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.spring.mapper.BaseMapper;
/// Groovy 脚本 Mapper
///
/// @author Codex
/// @since 2026-09-04
public interface GroovyScriptMapper extends BaseMapper<GroovyScript, String> {
}
@@ -0,0 +1,11 @@
package com.imyeyu.api.modules.common.mapper;
import com.imyeyu.api.modules.common.entity.GroovyScriptVersion;
import com.imyeyu.spring.mapper.BaseMapper;
/// Groovy 脚本版本 Mapper
///
/// @author Codex
/// @since 2026-09-04
public interface GroovyScriptVersionMapper extends BaseMapper<GroovyScriptVersion, String> {
}
@@ -0,0 +1,120 @@
package com.imyeyu.api.modules.common.service;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.api.modules.common.entity.GroovyScriptVersion;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.spring.service.BaseService;
import java.util.List;
import java.util.Map;
/// Groovy 脚本服务
///
/// 脚本仅限可信管理员维护,不能作为不可信用户代码的执行环境
///
/// @author Codex
/// @since 2026-09-04
public interface GroovyScriptService extends BaseService<GroovyScript, String> {
/// 按业务归属和代码查询脚本
///
/// @param bizType 业务归属
/// @param code 业务内唯一代码
/// @return 脚本,不存在时返回 null
GroovyScript getByCode(GroovyScript.BizType bizType, String code);
/// 创建脚本元数据
///
/// 新建脚本默认停用,且没有已发布版本
///
/// @param script 脚本元数据
void create(GroovyScript script);
/// 更新脚本元数据
///
/// 业务归属和代码是脚本稳定标识,创建后不可变更
///
/// @param script 脚本元数据
void update(GroovyScript script);
/// 设置脚本启用状态
///
/// @param id 脚本 ID
/// @param enabled true 为允许执行已发布版本
void setEnabled(String id, boolean enabled);
/// 创建不可变的脚本版本
///
/// @param scriptId 脚本 ID
/// @param content 脚本内容
/// @return 新版本
GroovyScriptVersion createVersion(String scriptId, String content);
/// 查询脚本的全部版本
///
/// @param scriptId 脚本 ID
/// @return 按版本号升序的版本列表
List<GroovyScriptVersion> listVersion(String scriptId);
/// 发布指定版本
///
/// @param scriptId 脚本 ID
/// @param versionId 脚本版本 ID
void publish(String scriptId, String versionId);
/// 取消当前发布版本
///
/// @param scriptId 脚本 ID
void unpublish(String scriptId);
/// 删除脚本
///
/// @param id 脚本 ID
void delete(String id);
/// 执行已发布且启用的脚本
///
/// @param bizType 业务归属
/// @param code 业务内唯一代码
/// @param bindings 传给脚本的变量
/// @return 脚本返回值
Object execute(GroovyScript.BizType bizType, String code, Map<String, Object> bindings);
/// 执行已发布且启用的脚本并返回实际执行版本
///
/// @param bizType 业务归属
/// @param code 业务内唯一代码
/// @param bindings 传给脚本的变量
/// @param resultType 返回值类型
/// @param <T> 返回值类型
/// @return 脚本执行结果
<T> GroovyScriptExecutionResult<T> executePublished(GroovyScript.BizType bizType, String code, Map<String, Object> bindings, Class<T> resultType);
/// 按指定历史版本执行脚本
///
/// 用于按积分发生时的脚本版本复算,脚本当前是否发布或启用不影响执行;脚本返回 null 表示未命中,属合法结果
///
/// @param versionId 脚本版本 ID
/// @param bindings 传给脚本的变量
/// @param resultType 返回值类型
/// @param <T> 返回值类型
/// @return 脚本执行结果
<T> GroovyScriptExecutionResult<T> executeVersion(String versionId, Map<String, Object> bindings, Class<T> resultType);
/// 执行已发布且启用的脚本并验证返回值类型
///
/// @param bizType 业务归属
/// @param code 业务内唯一代码
/// @param bindings 传给脚本的变量
/// @param resultType 返回值类型
/// @param <T> 返回值类型
/// @return 脚本返回值
<T> T execute(GroovyScript.BizType bizType, String code, Map<String, Object> bindings, Class<T> resultType);
/// 清除脚本的本地编译缓存
///
/// 多实例部署时,其他节点在收到脚本变更通知后调用此方法
///
/// @param scriptId 脚本 ID
void invalidate(String scriptId);
}
@@ -0,0 +1,293 @@
package com.imyeyu.api.modules.common.service.implement;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.api.modules.common.entity.GroovyScriptVersion;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.api.modules.common.mapper.GroovyScriptMapper;
import com.imyeyu.api.modules.common.mapper.GroovyScriptVersionMapper;
import com.imyeyu.api.modules.common.service.GroovyScriptService;
import com.imyeyu.java.bean.timi.TimiCode;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.mapper.BaseMapper;
import com.imyeyu.spring.service.AbstractEntityService;
import groovy.lang.Binding;
import groovy.lang.GroovyClassLoader;
import groovy.lang.Script;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/// Groovy 脚本服务实现
///
/// 每个已编译版本使用独立类加载器,缓存失效后可释放对应类及其元数据
///
/// @author Codex
/// @since 2026-09-04
@Slf4j
@Service
@RequiredArgsConstructor
public class GroovyScriptServiceImplement extends AbstractEntityService<GroovyScript, String> implements GroovyScriptService {
private final GroovyScriptMapper mapper;
private final GroovyScriptVersionMapper versionMapper;
private final Map<String, CompiledScript> compiledScriptMap = new ConcurrentHashMap<>();
@Override
protected BaseMapper<GroovyScript, String> mapper() {
return mapper;
}
@Override
public GroovyScript getByCode(GroovyScript.BizType bizType, String code) {
GroovyScript example = new GroovyScript();
example.setBizType(bizType);
example.setCode(code);
return mapper.selectByExample(example);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void create(GroovyScript script) {
TimiException.required(script, "未找到脚本");
TimiException.required(script.getBizType(), "未找到脚本业务归属");
TimiException.required(script.getCode(), "未找到脚本代码");
TimiException.required(script.getName(), "未找到脚本名称");
TimiException.requiredNull(getByCode(script.getBizType(), script.getCode()), "Groovy 脚本已存在");
script.setEnabled(false);
script.setPublishedVersionId(null);
super.create(script);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void update(GroovyScript script) {
TimiException.required(script, "未找到脚本");
TimiException.required(script.getId(), "未找到脚本 ID");
TimiException.required(script.getName(), "未找到脚本名称");
GroovyScript dbScript = get(script.getId());
TimiException.required(dbScript, "未找到脚本");
dbScript.setName(script.getName());
dbScript.setDescription(script.getDescription());
mapper.update(dbScript);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void setEnabled(String id, boolean enabled) {
GroovyScript script = requireScript(id);
script.setEnabled(enabled);
mapper.update(script);
invalidateAfterCommit(script.getId());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GroovyScriptVersion createVersion(String scriptId, String content) {
requireScript(scriptId);
TimiException.required(content, "未找到脚本内容");
TimiException.requiredTrue(!content.isBlank(), "脚本内容不能为空");
GroovyScriptVersion version = new GroovyScriptVersion();
version.setScriptId(scriptId);
version.setVersion(nextVersion(scriptId));
version.setContent(content);
version.setChecksum(checksum(content));
versionMapper.insert(version);
return version;
}
@Override
public List<GroovyScriptVersion> listVersion(String scriptId) {
TimiException.required(scriptId, "未找到脚本 ID");
GroovyScriptVersion example = new GroovyScriptVersion();
example.setScriptId(scriptId);
List<GroovyScriptVersion> versionList = new ArrayList<>(versionMapper.selectAllByExample(example));
versionList.sort(Comparator.comparing(GroovyScriptVersion::getVersion));
return versionList;
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void publish(String scriptId, String versionId) {
GroovyScript script = requireScript(scriptId);
GroovyScriptVersion version = requireVersion(versionId);
TimiException.requiredTrue(script.getId().equals(version.getScriptId()), "脚本版本不属于当前脚本");
script.setPublishedVersionId(version.getId());
mapper.update(script);
invalidateAfterCommit(script.getId());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void unpublish(String scriptId) {
GroovyScript script = requireScript(scriptId);
script.setPublishedVersionId(null);
mapper.update(script);
invalidateAfterCommit(script.getId());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void delete(String id) {
requireScript(id);
super.delete(id);
invalidateAfterCommit(id);
}
@Override
public Object execute(GroovyScript.BizType bizType, String code, Map<String, Object> bindings) {
return executePublished(bizType, code, bindings, Object.class).result();
}
@Override
public <T> GroovyScriptExecutionResult<T> executePublished(GroovyScript.BizType bizType, String code, Map<String, Object> bindings, Class<T> resultType) {
TimiException.required(bizType, "未找到脚本业务归属");
TimiException.required(code, "未找到脚本代码");
TimiException.required(resultType, "未找到返回值类型");
GroovyScript script = getByCode(bizType, code);
TimiException.required(script, "未找到 Groovy 脚本");
TimiException.requiredTrue(Boolean.TRUE.equals(script.getEnabled()), "Groovy 脚本已停用");
TimiException.required(script.getPublishedVersionId(), "Groovy 脚本尚未发布");
GroovyScriptExecutionResult<T> result = executeVersion(script.getPublishedVersionId(), bindings, resultType);
TimiException.requiredTrue(script.getId().equals(result.scriptId()), "Groovy 脚本已发布版本无效");
return result;
}
@Override
public <T> T execute(GroovyScript.BizType bizType, String code, Map<String, Object> bindings, Class<T> resultType) {
return executePublished(bizType, code, bindings, resultType).result();
}
@Override
public <T> GroovyScriptExecutionResult<T> executeVersion(String versionId, Map<String, Object> bindings, Class<T> resultType) {
TimiException.required(resultType, "未找到返回值类型");
GroovyScriptVersion version = requireVersion(versionId);
CompiledScript compiledScript = compiledScriptMap.computeIfAbsent(cacheKey(version), key -> compile(version.getScriptId(), version));
try {
Object result = compiledScript.execute(bindings);
TimiException.requiredTrue(result == null || resultType.isInstance(result), "Groovy 脚本返回值类型不匹配");
return new GroovyScriptExecutionResult<>(version.getScriptId(), version.getId(), version.getVersion(), version.getChecksum(), resultType.cast(result));
} catch (TimiException e) {
throw e;
} catch (RuntimeException e) {
log.warn("Groovy 脚本执行失败,scriptId={}, versionId={}", version.getScriptId(), version.getId(), e);
throw new TimiException(TimiCode.ERROR, "Groovy 脚本执行失败");
}
}
@Override
public void invalidate(String scriptId) {
if (scriptId == null) {
return;
}
compiledScriptMap.entrySet().removeIf(entry -> {
if (!scriptId.equals(entry.getValue().scriptId())) {
return false;
}
entry.getValue().close();
return true;
});
}
private GroovyScript requireScript(String id) {
TimiException.required(id, "未找到脚本 ID");
GroovyScript script = get(id);
TimiException.required(script, "未找到脚本");
return script;
}
private GroovyScriptVersion requireVersion(String id) {
TimiException.required(id, "未找到脚本版本 ID");
GroovyScriptVersion version = versionMapper.select(id);
TimiException.required(version, "未找到脚本版本");
return version;
}
private int nextVersion(String scriptId) {
return listVersion(scriptId).stream()
.map(GroovyScriptVersion::getVersion)
.max(Integer::compareTo)
.orElse(0) + 1;
}
private String cacheKey(GroovyScriptVersion version) {
return "%s:%s".formatted(version.getId(), version.getChecksum());
}
private CompiledScript compile(String scriptId, GroovyScriptVersion version) {
GroovyClassLoader classLoader = new GroovyClassLoader(getClass().getClassLoader());
try {
Class<?> type = classLoader.parseClass(version.getContent(), "GroovyScript_%s_%d.groovy".formatted(version.getId().replace('-', '_'), version.getVersion()));
return new CompiledScript(scriptId, type.asSubclass(Script.class), classLoader);
} catch (RuntimeException e) {
close(classLoader);
log.warn("Groovy 脚本编译失败,scriptId={}, versionId={}", scriptId, version.getId(), e);
throw new TimiException(TimiCode.ERROR, "Groovy 脚本编译失败");
}
}
private String checksum(String content) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("JVM 缺少 SHA-256 算法", e);
}
}
private void invalidateAfterCommit(String scriptId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
invalidate(scriptId);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
invalidate(scriptId);
}
});
}
private void close(GroovyClassLoader classLoader) {
try {
classLoader.close();
} catch (IOException e) {
log.warn("关闭 Groovy 脚本类加载器失败", e);
}
}
private record CompiledScript(String scriptId, Class<? extends Script> type, GroovyClassLoader classLoader) {
private Object execute(Map<String, Object> bindings) {
try {
Script script = type.getDeclaredConstructor().newInstance();
script.setBinding(new Binding(bindings == null ? Map.of() : new HashMap<>(bindings)));
return script.run();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Groovy 脚本实例化失败", e);
}
}
private void close() {
try {
classLoader.close();
} catch (IOException ignored) {
}
}
}
}
@@ -65,16 +65,6 @@ public class GaoStoreController {
return store;
}
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.STORE_UPDATE)
@PostMapping("/direct-product-sale-event/list")
public List<GaoEvent> listDirectProductSaleEvents(@RequestParam String storeId) {
GaoStore store = service.get(storeId);
storeScopeService.checkStore(store);
return service.listDirectProductSaleEvents(storeId);
}
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.STORE_CREATE)
@@ -101,7 +91,6 @@ public class GaoStoreController {
dbStore.setAddress(store.getAddress());
dbStore.setRemark(store.getRemark());
dbStore.setAutoClosedDay(TimiJava.defaultIfNull(store.getAutoClosedDay(), false));
dbStore.setDirectProductSaleEventId(store.getDirectProductSaleEventId());
service.update(dbStore);
}
}
@@ -0,0 +1,44 @@
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-09-04
@Data
@EqualsAndHashCode(callSuper = true)
public class GaoCustomerConsumptionRecord extends UUIDEntity {
/// 消费来源类型
public enum SourceType {
/// 迁移的旧消费事件
LEGACY_EVENT,
/// 销售订单全额实收
SALES_ORDER
}
/// 消费事实状态
public enum Status {
/// 有效并参与累计
ACTIVE,
/// 已失效,不参与累计
REVOKED
}
private String storeId;
private String customerId;
private SourceType sourceType;
private String sourceId;
private String salesOrderId;
private GaoSalesOrder.OrderType orderType;
private Long amount;
private Long effectiveAt;
private Status status;
private Long revokedAt;
private String revokeReason;
private JsonNode sourcePayload;
}
@@ -25,7 +25,10 @@ public class GaoEvent extends UUIDEntity {
GENERAL,
/// 购买产品套餐
PACKAGE_PURCHASE
PACKAGE_PURCHASE,
/// 已退役的历史消费事件,仅保留归档和溯源
LEGACY_CONSUMPTION
}
///
@@ -17,13 +17,13 @@ public class GaoPointLedger extends UUIDEntity {
/// 流水变化类型
public enum ChangeType {
/// 登记事件发放积分
/// 规则发放积分
EARN,
/// 登记事件消费积分
/// 规则扣减积分
CONSUME,
/// 删除登记记录撤销积分
/// 撤销积分
REVOKE,
/// 人工调整积分
@@ -40,6 +40,16 @@ public class GaoPointLedger extends UUIDEntity {
DECREASE
}
/// 直接触发来源类型
public enum TriggerSourceType {
/// 事件登记
EVENT_RECORD,
/// 订单首次全额实收
SALES_ORDER_PAID
}
/// 客户积分账户 ID
private String accountId;
@@ -67,11 +77,20 @@ public class GaoPointLedger extends UUIDEntity {
/// 命中的积分规则 ID
private String ruleId;
/// 登记事件 ID
private String eventId;
/// 直接触发来源类型
private TriggerSourceType triggerSourceType;
/// 登记记录 ID
private String eventRecordId;
/// 直接触发来源 ID
private String triggerSourceId;
/// 关联销售订单 ID
private String salesOrderId;
/// 实际执行 Groovy 脚本版本 ID,简单规则为空
private String groovyScriptVersionId;
/// 命中时的规则快照
private JsonNode ruleSnapshot;
/// 来源快照
private JsonNode sourcePayload;
@@ -1,6 +1,5 @@
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;
@@ -29,43 +28,47 @@ public class GaoPointRule extends UUIDEntity {
DELETED
}
/// 触发类型
public enum TriggerType {
/// 规则模式
public enum RuleMode {
/// 每登记一次触发一次
EVERY_TIME,
/// 简单规则,固定积分值
SIMPLE,
/// 客户首次登记该事件触发一次
FIRST_TIME,
/// 客户累计登记达到 N 次后每次触发
AFTER_COUNT_EVERY_TIME,
/// 使用登记接口提交的积分值触发
SOURCE_VALUE,
/// 按来源事件数值累计分档触发
CUSTOMER_EVENT_VALUE_TIER
/// Groovy 脚本规则
GROOVY
}
/// 积分值模式
public enum PointMode {
/// 直接触发来源类型
public enum TriggerSourceType {
/// 使用规则固定积分值
FIXED,
/// 事件登记
EVENT_RECORD,
/// 使用登记接口提交的积分值
SOURCE_VALUE,
/// 订单首次全额实收
SALES_ORDER_PAID
}
/// 使用条件配置中的分档积分值
TIER
/// 触发对象筛选类型
public enum TriggerRefType {
/// GAO 登记事件
EVENT
}
/// 门店 ID
private String storeId;
/// 登记事件 ID
private String eventId;
/// 规则模式
private RuleMode ruleMode;
/// 直接触发来源类型
private TriggerSourceType triggerSourceType;
/// 触发对象筛选类型
private TriggerRefType triggerRefType;
/// 触发对象筛选 ID
private String triggerRefId;
/// 规则名称
private String name;
@@ -73,17 +76,11 @@ public class GaoPointRule extends UUIDEntity {
/// 规则状态
private Status status;
/// 触发类型
private TriggerType triggerType;
/// 积分值模式
private PointMode pointMode;
/// 固定积分值
/// 固定积分值,简单规则使用
private Long pointValue;
/// 条件配置
private JsonNode conditionJson;
/// Groovy 脚本 ID,GROOVY 模式使用
private String groovyScriptId;
/// 生效开始时间戳,毫秒,空为不限制
private Long beginAt;
@@ -96,6 +96,9 @@ public class GaoSalesOrder extends UUIDEntity {
/// 有效收款,单位分
private Long paidAmount;
/// 首次全额实收时间
private Long paidAt;
/// 有效退款,单位分
private Long refundedAmount;
@@ -54,9 +54,6 @@ public class GaoStore extends UUIDEntity {
/// 排序值
private Integer sort;
/// 直接产品订单使用的消费事件 ID
private String directProductSaleEventId;
public boolean isActivated() {
return status == Status.ACTIVE;
}
@@ -0,0 +1,20 @@
package com.imyeyu.api.modules.gao.mapper;
import com.imyeyu.api.modules.gao.entity.GaoCustomerConsumptionRecord;
import com.imyeyu.spring.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/// GAO 客户有效消费事实 Mapper
///
/// @author Codex
/// @since 2026-09-04
public interface GaoCustomerConsumptionRecordMapper extends BaseMapper<GaoCustomerConsumptionRecord, String> {
GaoCustomerConsumptionRecord selectBySource(@Param("sourceType") GaoCustomerConsumptionRecord.SourceType sourceType, @Param("sourceId") String sourceId);
List<GaoCustomerConsumptionRecord> selectActiveByWindow(@Param("storeId") String storeId, @Param("customerId") String customerId, @Param("beginAt") long beginAt, @Param("endAt") long endAt);
int revokeBySource(@Param("sourceType") GaoCustomerConsumptionRecord.SourceType sourceType, @Param("sourceId") String sourceId, @Param("revokedAt") long revokedAt, @Param("revokeReason") String revokeReason);
}
@@ -26,7 +26,7 @@ public class GaoEventSQLProvider extends SQLProvider {
private String appendWithoutDeleted(String sql) {
return sql.replace(
"WHERE 1 = 1",
"WHERE 1 = 1 AND (`status` IS NULL OR `status` <> '%s')".formatted(GaoEvent.Status.DELETED.name())
"WHERE 1 = 1 AND (`status` IS NULL OR `status` <> '%s') AND (`biz_type` IS NULL OR `biz_type` <> '%s')".formatted(GaoEvent.Status.DELETED.name(), GaoEvent.BizType.LEGACY_CONSUMPTION.name())
);
}
}
@@ -0,0 +1,38 @@
package com.imyeyu.api.modules.gao.mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/// GAO 积分流水消费事实依赖 Mapper
///
/// @author Codex
/// @since 2026-09-04
public interface GaoPointLedgerConsumptionRecordMapper {
/// 写入积分流水对消费事实的依赖
///
/// @param pointLedgerId 积分流水 ID
/// @param consumptionRecordId 消费事实 ID
/// @param createdAt 创建时间
/// @return 写入行数
@Insert("INSERT IGNORE INTO `gao_point_ledger_consumption_record` (`point_ledger_id`, `consumption_record_id`, `created_at`) VALUES (#{pointLedgerId}, #{consumptionRecordId}, #{createdAt})")
int insert(@Param("pointLedgerId") String pointLedgerId, @Param("consumptionRecordId") String consumptionRecordId, @Param("createdAt") long createdAt);
/// 查询积分流水依赖的消费事实 ID
///
/// @param pointLedgerId 积分流水 ID
/// @return 消费事实 ID 列表
@Select("SELECT `consumption_record_id` FROM `gao_point_ledger_consumption_record` WHERE `point_ledger_id` = #{pointLedgerId}")
List<String> selectConsumptionRecordIdList(@Param("pointLedgerId") String pointLedgerId);
/// 删除积分流水的依赖关系
///
/// @param pointLedgerId 积分流水 ID
/// @return 删除行数
@Delete("DELETE FROM `gao_point_ledger_consumption_record` WHERE `point_ledger_id` = #{pointLedgerId}")
int deleteByPointLedgerId(@Param("pointLedgerId") String pointLedgerId);
}
@@ -19,11 +19,18 @@ public interface GaoPointLedgerMapper extends BaseMapper<GaoPointLedger, String>
/// @return 流水
GaoPointLedger selectByIdempotencyKey(@Param("idempotencyKey") String idempotencyKey);
/// 查询登记记录产生的原始规则积分流水
/// 查询触发来源产生的原始规则积分流水
///
/// @param eventRecordId 登记记录 ID
/// @param triggerSourceType 直接触发来源类型
/// @param triggerSourceId 直接触发来源 ID
/// @return 原始规则积分流水
List<GaoPointLedger> selectRuleChangeByEventRecordId(@Param("eventRecordId") String eventRecordId);
List<GaoPointLedger> selectRuleChangeByTriggerSource(@Param("triggerSourceType") GaoPointLedger.TriggerSourceType triggerSourceType, @Param("triggerSourceId") String triggerSourceId);
/// 查询依赖指定消费事实的原始规则积分流水
///
/// @param consumptionRecordId 消费事实 ID
/// @return 原始规则积分流水
List<GaoPointLedger> selectRuleChangeByConsumptionRecordId(@Param("consumptionRecordId") String consumptionRecordId);
/// 批量查询幂等键对应的流水
///
@@ -18,8 +18,9 @@ public interface GaoPointRuleMapper extends BaseMapper<GaoPointRule, String> {
/// 查询当前时间可匹配的启用规则
///
/// @param storeId 门店 ID
/// @param eventId 登记事件 ID
/// @param triggerSourceType 直接触发来源类型
/// @param triggerRefId 触发对象筛选 ID,空为不限
/// @param now 当前时间戳
/// @return 按优先级排序的规则列表
List<GaoPointRule> selectActiveByStoreIdAndEventId(@Param("storeId") String storeId, @Param("eventId") String eventId, @Param("now") long now);
List<GaoPointRule> selectActiveByTrigger(@Param("storeId") String storeId, @Param("triggerSourceType") GaoPointRule.TriggerSourceType triggerSourceType, @Param("triggerRefId") String triggerRefId, @Param("now") long now);
}
@@ -27,7 +27,7 @@ public interface GaoSalesOrderMapper extends BaseMapper<GaoSalesOrder, String> {
/// 更新订单付款汇总
int updatePayment(@Param("id") String id, @Param("pendingPaidAmount") Long pendingPaidAmount, @Param("paidAmount") Long paidAmount,
@Param("refundedAmount") Long refundedAmount, @Param("paymentStatus") GaoSalesOrder.PaymentStatus paymentStatus,
@Param("paidAt") Long paidAt, @Param("refundedAmount") Long refundedAmount, @Param("paymentStatus") GaoSalesOrder.PaymentStatus paymentStatus,
@Param("refundStatus") GaoSalesOrder.RefundStatus refundStatus);
/// 带状态条件取消订单
@@ -35,9 +35,6 @@ public interface GaoSalesOrderMapper extends BaseMapper<GaoSalesOrder, String> {
@Param("cancelledAt") Long cancelledAt, @Param("cancelOperatorUserId") String cancelOperatorUserId,
@Param("cancelReason") String cancelReason, @Param("refundStatus") GaoSalesOrder.RefundStatus refundStatus);
/// 写入订单来源事件
int updateSourceEventRecordId(@Param("id") String id, @Param("sourceEventRecordId") String sourceEventRecordId);
/// 更新订单履约状态
int updateStatus(@Param("id") String id, @Param("status") GaoSalesOrder.Status status);
}
@@ -0,0 +1,17 @@
package com.imyeyu.api.modules.gao.script;
import java.util.List;
import java.util.Map;
/// GAO 积分规则脚本决策
///
/// 脚本通过上下文构造本对象返回声明式积分决策,不能直接写积分账户或积分流水
///
/// @param pointDelta 积分变化,正为发放,负为扣减,不能为 0
/// @param reason 业务原因
/// @param conditionSnapshot 条件快照
/// @param consumptionRecordIdList 依赖的消费事实 ID 列表
/// @author Codex
/// @since 2026-09-04
public record GaoPointDecision(long pointDelta, String reason, Map<String, Object> conditionSnapshot, List<String> consumptionRecordIdList) {
}
@@ -0,0 +1,154 @@
package com.imyeyu.api.modules.gao.script;
import com.imyeyu.api.modules.gao.entity.GaoCustomerConsumptionRecord;
import com.imyeyu.api.modules.gao.entity.GaoPointRule;
import com.imyeyu.api.modules.gao.service.GaoCustomerConsumptionRecordService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.java.bean.timi.TimiException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/// GAO 积分规则脚本执行上下文
///
/// 只向脚本提供归一化触发信息和只读消费事实查询,滚动窗口固定使用 Asia/Shanghai 时区
///
/// @author Codex
/// @since 2026-09-04
public class GaoPointScriptContext {
/// 滚动窗口固定时区
public static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private final GaoCustomerConsumptionRecordService consumptionService;
private final GaoEventRecordService eventRecordService;
private final GaoPointRule rule;
private final String storeId;
private final String customerId;
private final String triggerSourceType;
private final String triggerRefType;
private final String triggerRefId;
private final long occurredAt;
/// 参与过金额计算的消费事实,ID 到金额摘要
private final Map<String, Long> consumptionFactMap = new LinkedHashMap<>();
public GaoPointScriptContext(GaoCustomerConsumptionRecordService consumptionService, GaoEventRecordService eventRecordService,
GaoPointRule rule, String storeId, String customerId, String triggerRefType, String triggerRefId, long occurredAt) {
this.consumptionService = consumptionService;
this.eventRecordService = eventRecordService;
this.rule = rule;
this.storeId = storeId;
this.customerId = customerId;
this.triggerSourceType = rule.getTriggerSourceType() == null ? null : rule.getTriggerSourceType().name();
this.triggerRefType = triggerRefType;
this.triggerRefId = triggerRefId;
this.occurredAt = occurredAt;
}
/// 触发来源门店 ID
public String getStoreId() {
return storeId;
}
/// 触发来源客户 ID
public String getCustomerId() {
return customerId;
}
/// 直接触发来源类型
public String getTriggerSourceType() {
return triggerSourceType;
}
/// 触发对象筛选类型
public String getTriggerRefType() {
return triggerRefType;
}
/// 触发对象筛选 ID
public String getTriggerRefId() {
return triggerRefId;
}
/// 触发发生时间,毫秒
public long getOccurredAt() {
return occurredAt;
}
/// 查询触发时间往前滚动一年的有效消费金额,窗口按 Asia/Shanghai 自然日对齐
///
/// @return 窗口内 ACTIVE 消费事实金额合计,单位分
public long rollingYearConsumptionAmount() {
LocalDate currentDate = Instant.ofEpochMilli(occurredAt).atZone(ZONE).toLocalDate();
long beginAt = currentDate.minusYears(1).atStartOfDay(ZONE).toInstant().toEpochMilli();
return consumptionAmountBetween(beginAt, occurredAt);
}
/// 查询指定时间窗口内的有效消费金额
///
/// 查询上限不会晚于触发时间,保证历史复算不受后续新增消费影响
///
/// @param beginAt 窗口起始,包含
/// @param endAt 窗口结束,包含
/// @return 窗口内 ACTIVE 消费事实金额合计,单位分
public long consumptionAmountBetween(long beginAt, long endAt) {
long cappedEndAt = Math.min(endAt, occurredAt);
long total = 0L;
for (GaoCustomerConsumptionRecord record : consumptionService.listActiveByWindow(storeId, customerId, beginAt, cappedEndAt)) {
consumptionFactMap.putIfAbsent(record.getId(), record.getAmount());
total = Math.addExact(total, record.getAmount());
}
return total;
}
/// 查询客户在触发对象事件上截至触发时间的登记次数
///
/// @return 登记次数
public long eventCount() {
return eventRecordService.count(storeId, customerId, triggerRefId, null, occurredAt);
}
/// 构造积分决策
///
/// @param pointDelta 积分变化,不能为 0
/// @param reason 业务原因
/// @param conditionSnapshot 条件快照
/// @return 积分决策
public GaoPointDecision decision(long pointDelta, String reason, Map<String, Object> conditionSnapshot) {
return decision(pointDelta, reason, conditionSnapshot, null);
}
/// 构造积分决策并显式追加依赖的消费事实
///
/// @param pointDelta 积分变化,不能为 0
/// @param reason 业务原因
/// @param conditionSnapshot 条件快照
/// @param consumptionRecordIdList 额外依赖的消费事实 ID 列表
/// @return 积分决策
public GaoPointDecision decision(long pointDelta, String reason, Map<String, Object> conditionSnapshot, List<String> consumptionRecordIdList) {
TimiException.requiredTrue(pointDelta != 0, "脚本积分变化不能为 0");
Map<String, Object> snapshot = conditionSnapshot == null ? new LinkedHashMap<>() : new LinkedHashMap<>(conditionSnapshot);
List<Map<String, Object>> consumptionSummary = new ArrayList<>();
for (Map.Entry<String, Long> entry : consumptionFactMap.entrySet()) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", entry.getKey());
item.put("amount", entry.getValue());
consumptionSummary.add(item);
}
snapshot.put("consumptionRecords", consumptionSummary);
snapshot.put("consumptionTotal", consumptionFactMap.values().stream().mapToLong(Long::longValue).sum());
List<String> deps = new ArrayList<>(consumptionFactMap.keySet());
for (String id : consumptionRecordIdList == null ? List.<String>of() : consumptionRecordIdList) {
if (id != null && !deps.contains(id)) {
deps.add(id);
}
}
return new GaoPointDecision(pointDelta, reason, snapshot, deps);
}
}
@@ -0,0 +1,54 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.entity.GaoCustomerConsumptionRecord;
import com.imyeyu.spring.service.BaseService;
import java.util.List;
/// GAO 客户有效消费事实服务
///
/// @author Codex
/// @since 2026-09-04
public interface GaoCustomerConsumptionRecordService extends BaseService<GaoCustomerConsumptionRecord, String> {
/// 写入销售订单消费事实
///
/// 幂等,重复执行返回已有事实
///
/// @param salesOrderId 销售订单 ID
/// @param storeId 门店 ID
/// @param customerId 客户 ID
/// @param amount 有效消费金额,单位分,必须大于 0
/// @param effectiveAt 全额实收时间
/// @param orderType 订单类型
/// @return 消费事实
GaoCustomerConsumptionRecord createSalesOrder(String salesOrderId, String storeId, String customerId, Long amount, Long effectiveAt, com.imyeyu.api.modules.gao.entity.GaoSalesOrder.OrderType orderType);
/// 撤销销售订单消费事实
///
/// @param salesOrderId 销售订单 ID
/// @param reason 失效原因
void revokeSalesOrder(String salesOrderId, String reason);
/// 撤销历史事件迁移出的消费事实
///
/// @param eventRecordId 原事件记录 ID
/// @param reason 失效原因
void revokeLegacyEvent(String eventRecordId, String reason);
/// 按来源查询消费事实
///
/// @param sourceType 来源类型
/// @param sourceId 来源业务对象 ID
/// @return 消费事实,不存在时返回 null
GaoCustomerConsumptionRecord getBySource(GaoCustomerConsumptionRecord.SourceType sourceType, String sourceId);
/// 查询时间窗口内的 ACTIVE 消费事实
///
/// @param storeId 门店 ID
/// @param customerId 客户 ID
/// @param beginAt 起始时间,包含
/// @param endAt 结束时间,包含
/// @return 消费事实列表
List<GaoCustomerConsumptionRecord> listActiveByWindow(String storeId, String customerId, long beginAt, long endAt);
}
@@ -104,23 +104,6 @@ public interface GaoEventRecordService extends BaseService<GaoEventRecord, Strin
/// @return 创建后的事件登记
GaoEventRecord createOne(GaoEventRecord record);
/// 由套餐购买服务创建购买事件登记
///
/// @param record 购买事件登记
/// @return 创建后的事件登记
GaoEventRecord createPackagePurchaseRecord(GaoEventRecord record);
/// 由直接产品订单内部创建消费事件记录
///
/// @param record 订单消费事件记录
/// @return 创建后的事件记录
GaoEventRecord createOrderConsumeRecord(GaoEventRecord record);
/// 撤销订单消费事件产生的全部积分
///
/// @param record 订单消费事件记录
void revokeOrderConsumeRecord(GaoEventRecord record);
/// 拒绝修改或删除已被订单引用的事件记录
///
/// @param recordId 事件记录 ID
@@ -1,9 +1,9 @@
package com.imyeyu.api.modules.gao.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
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.script.GaoPointDecision;
import com.imyeyu.api.modules.gao.vo.GaoPointAdjustRequest;
import com.imyeyu.api.modules.gao.vo.GaoPointLedgerPage;
import com.imyeyu.spring.bean.PageResult;
@@ -17,21 +17,34 @@ import java.util.List;
/// @since 2026-08-11
public interface GaoPointLedgerService extends BaseService<GaoPointLedger, String> {
/// 写入登记规则积分流水
/// 写入规则发放或扣减积分流水
///
/// 幂等,重复触发返回已有流水;积分流水只追加,不修改不删除
///
/// @param record 登记记录
/// @param rule 命中规则
/// @param pointDelta 积分变化
/// @param sourcePayload 来源快照
/// @param decision 积分决策
/// @param execution Groovy 脚本执行结果,简单规则为空
/// @param triggerSourceType 直接触发来源类型
/// @param triggerSourceId 直接触发来源 ID
/// @param salesOrderId 关联销售订单 ID,可空
/// @param storeId 门店 ID
/// @param customerId 客户 ID
/// @param occurredAt 发生时间
/// @param operatorUserId 操作用户 ID
/// @param consumptionRecordIdList 依赖的消费事实 ID 列表
/// @return 积分流水
GaoPointLedger applyRule(GaoEventRecord record, GaoPointRule rule, long pointDelta, JsonNode sourcePayload);
GaoPointLedger applyRule(GaoPointRule rule, GaoPointDecision decision, GroovyScriptExecutionResult<?> execution,
GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId, String salesOrderId,
String storeId, String customerId, Long occurredAt, String operatorUserId, List<String> consumptionRecordIdList);
/// 写入删除登记记录的撤销流水
/// 追加撤销流水
///
/// 撤销流水以原流水为基准反向冲抵,幂等键按原流水设计
///
/// @param originLedger 原始规则积分流水
/// @param record 登记记录
/// @param reason 撤销原因
/// @return 撤销流水
GaoPointLedger revoke(GaoPointLedger originLedger, GaoEventRecord record);
GaoPointLedger revoke(GaoPointLedger originLedger, String reason);
/// 写入人工调整流水
///
@@ -40,11 +53,18 @@ public interface GaoPointLedgerService extends BaseService<GaoPointLedger, Strin
/// @return 调整流水
GaoPointLedger adjust(GaoPointAdjustRequest request, String operatorUserId);
/// 查询登记记录产生的原始规则积分流水
/// 查询触发来源产生的原始规则积分流水
///
/// @param eventRecordId 登记记录 ID
/// @param triggerSourceType 直接触发来源类型
/// @param triggerSourceId 直接触发来源 ID
/// @return 原始积分流水
List<GaoPointLedger> listRuleChangeByEventRecordId(String eventRecordId);
List<GaoPointLedger> listRuleChangeByTriggerSource(GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId);
/// 查询依赖指定消费事实的原始规则积分流水
///
/// @param consumptionRecordId 消费事实 ID
/// @return 原始积分流水
List<GaoPointLedger> listRuleChangeByConsumptionRecordId(String consumptionRecordId);
/// 按幂等键批量查询积分流水
///
@@ -13,11 +13,14 @@ public interface GaoPointRuleService extends BaseService<GaoPointRule, String> {
/// 查询当前时间可匹配的启用规则
///
/// 触发对象筛选 ID 为空时匹配未设置触发对象引用的规则
///
/// @param storeId 门店 ID
/// @param eventId 登记事件 ID
/// @param triggerSourceType 直接触发来源类型
/// @param triggerRefId 触发对象筛选 ID,可空
/// @param now 当前时间戳
/// @return 规则列表
List<GaoPointRule> listActive(String storeId, String eventId, long now);
/// @return 按优先级排序的规则列表
List<GaoPointRule> listActive(String storeId, GaoPointRule.TriggerSourceType triggerSourceType, String triggerRefId, long now);
/// 查询规则 ID 列表
///
@@ -1,7 +1,8 @@
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.entity.GaoPointLedger;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.vo.GaoPointAdjustRequest;
/// GAO 客户积分触发服务
@@ -15,11 +16,22 @@ public interface GaoPointTriggerService {
/// @param record 登记记录
void onEventRecordCreated(GaoEventRecord record);
/// 撤销登记记录产生的全部获得积分
/// 撤销登记记录触发的全部获得积分,并撤销迁移消费事实及其依赖积分
///
/// @param record 登记记录
void revokeByEventRecord(GaoEventRecord record);
/// 在订单首次全额实收时写入消费事实并触发订单积分
///
/// @param order 销售订单
void onSalesOrderPaid(GaoSalesOrder order);
/// 订单取消或全额实收被冲正时撤销消费事实及其依赖积分
///
/// @param order 销售订单
/// @param reason 撤销原因
void revokeBySalesOrder(GaoSalesOrder order, String reason);
/// 执行人工调整
///
/// @param request 人工调整请求
@@ -1,6 +1,5 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.entity.GaoEvent;
import com.imyeyu.api.modules.gao.entity.GaoStore;
import com.imyeyu.spring.bean.Page;
import com.imyeyu.spring.service.BaseService;
@@ -15,18 +14,6 @@ import java.util.List;
/// @since 2026-08-06
public interface GaoStoreService extends BaseService<GaoStore, String> {
/// 校验直接产品订单使用的消费事件
///
/// @param store 门店
/// @return 合法的消费事件
GaoEvent validateDirectProductSaleEvent(GaoStore store);
/// 查询可绑定到直接产品订单的消费事件
///
/// @param storeId 门店 ID
/// @return 当前门店可绑定的消费事件
List<GaoEvent> listDirectProductSaleEvents(String storeId);
/// 查询用户所属门店
///
/// @param userId 用户 ID
@@ -0,0 +1,80 @@
package com.imyeyu.api.modules.gao.service.implement;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.gao.entity.GaoCustomerConsumptionRecord;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.mapper.GaoCustomerConsumptionRecordMapper;
import com.imyeyu.api.modules.gao.service.GaoCustomerConsumptionRecordService;
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.List;
/// GAO 客户有效消费事实服务实现
///
/// @author Codex
/// @since 2026-09-04
@Service
@RequiredArgsConstructor
public class GaoCustomerConsumptionRecordServiceImplement extends AbstractEntityService<GaoCustomerConsumptionRecord, String> implements GaoCustomerConsumptionRecordService {
private final GaoCustomerConsumptionRecordMapper mapper;
@Override
protected BaseMapper<GaoCustomerConsumptionRecord, String> mapper() {
return mapper;
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoCustomerConsumptionRecord createSalesOrder(String salesOrderId, String storeId, String customerId, Long amount, Long effectiveAt, GaoSalesOrder.OrderType orderType) {
TimiException.requiredTrue(amount != null && 0 < amount, "有效消费金额必须大于 0");
GaoCustomerConsumptionRecord existing = mapper.selectBySource(GaoCustomerConsumptionRecord.SourceType.SALES_ORDER, salesOrderId);
if (existing != null) {
return existing;
}
GaoCustomerConsumptionRecord record = new GaoCustomerConsumptionRecord();
record.setStoreId(storeId);
record.setCustomerId(customerId);
record.setSourceType(GaoCustomerConsumptionRecord.SourceType.SALES_ORDER);
record.setSourceId(salesOrderId);
record.setSalesOrderId(salesOrderId);
record.setOrderType(orderType);
record.setAmount(amount);
record.setEffectiveAt(effectiveAt);
record.setStatus(GaoCustomerConsumptionRecord.Status.ACTIVE);
super.create(record);
return record;
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void revokeSalesOrder(String salesOrderId, String reason) {
mapper.revokeBySource(GaoCustomerConsumptionRecord.SourceType.SALES_ORDER, salesOrderId, Time.now(), reason);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void revokeLegacyEvent(String eventRecordId, String reason) {
mapper.revokeBySource(GaoCustomerConsumptionRecord.SourceType.LEGACY_EVENT, eventRecordId, Time.now(), reason);
}
@Override
public GaoCustomerConsumptionRecord getBySource(GaoCustomerConsumptionRecord.SourceType sourceType, String sourceId) {
TimiException.required(sourceType, "not found sourceType");
TimiException.required(sourceId, "not found sourceId");
return mapper.selectBySource(sourceType, sourceId);
}
@Override
public List<GaoCustomerConsumptionRecord> listActiveByWindow(String storeId, String customerId, long beginAt, long endAt) {
TimiException.required(storeId, "not found storeId");
TimiException.required(customerId, "not found customerId");
return mapper.selectActiveByWindow(storeId, customerId, beginAt, endAt);
}
}
@@ -2,11 +2,11 @@ package com.imyeyu.api.modules.gao.service.implement;
import com.imyeyu.api.bean.ModuleCode;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.service.AttachmentService;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.entity.GaoCustomerPackage;
import com.imyeyu.api.modules.gao.entity.GaoCustomerPackageItem;
import com.imyeyu.api.modules.gao.entity.GaoEvent;
import com.imyeyu.api.modules.gao.entity.GaoEventRecord;
import com.imyeyu.api.modules.gao.entity.GaoPackageConfig;
import com.imyeyu.api.modules.gao.entity.GaoPackageConfigItem;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
@@ -15,8 +15,6 @@ import com.imyeyu.api.modules.gao.mapper.GaoCustomerPackageMapper;
import com.imyeyu.api.modules.gao.service.GaoSalesOrderService;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoCustomerPackageService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.api.modules.gao.service.GaoEventService;
import com.imyeyu.api.modules.gao.service.GaoPackageConfigService;
import com.imyeyu.api.modules.gao.service.GaoProductService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
@@ -64,10 +62,9 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
private final GaoPackageConfigService packageConfigService;
private final GaoProductService productService;
private final GaoCustomerService customerService;
private final GaoEventService eventService;
private final GaoEventRecordService eventRecordService;
private final GaoSalesOrderService salesOrderService;
private final GaoStoreScopeService storeScopeService;
private final AttachmentService attachmentService;
private final UserLoginService userLoginService;
private final PermissionChecker permissionChecker;
private final LoggerService loggerService;
@@ -169,14 +166,12 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
TimiException.required(request, "not found purchase request");
TimiException.required(request.getRequestNo(), "not found requestNo");
request.setRequestNo(request.getRequestNo().trim());
TimiException.required(request.getEventId(), "not found eventId");
TimiException.required(request.getPackageConfigId(), "not found packageConfigId");
TimiException.required(request.getCustomerId(), "not found customerId");
GaoEvent event = validatePurchaseEvent(request.getEventId());
storeScopeService.checkStoreId(event.getStoreId());
GaoPackageConfig config = packageConfigService.get(event.getPackageConfigId());
TimiException.required(config, "购买事件绑定的套餐配置不存在");
TimiException.requiredTrue(event.getStoreId().equals(config.getStoreId()), "购买事件和套餐配置不属于同一门店");
GaoPackageConfig config = packageConfigService.get(request.getPackageConfigId());
TimiException.required(config, "套餐配置不存在");
storeScopeService.checkStoreId(config.getStoreId());
List<GaoPackageConfigItem> configItemList = packageConfigService.listItemByPackageConfigId(config.getId());
request.setSaleAmount(TimiJava.defaultIfNull(request.getSaleAmount(), config.getStandardAmount()));
TimiException.requiredTrue(0 <= request.getSaleAmount(), "成交价不能为负数");
@@ -209,16 +204,6 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
verifySameQuantityAsConfig(itemList, configItemList);
}
GaoEventRecord eventRecord = new GaoEventRecord();
eventRecord.setCustomerId(customer.getId());
eventRecord.setStoreId(config.getStoreId());
eventRecord.setEventId(event.getId());
eventRecord.setRegisteredAt(purchasedAt);
eventRecord.setRemark(request.getRemark());
if (event.getValueType() == GaoEvent.ValueType.NUMBER && event.getValueUnit() == GaoEvent.ValueUnit.CENT) {
eventRecord.setNumberValue(request.getSaleAmount());
}
GaoEventRecord createdEventRecord = eventRecordService.createPackagePurchaseRecord(eventRecordWithAttachments(eventRecord, request));
GaoPackageOrderCreateData orderData = new GaoPackageOrderCreateData();
orderData.setStoreId(config.getStoreId());
orderData.setCustomerId(customer.getId());
@@ -226,12 +211,12 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
orderData.setPackageNameSnapshot(config.getName());
orderData.setStandardAmountSnapshot(config.getStandardAmount());
orderData.setSaleAmount(request.getSaleAmount());
orderData.setSourceEventRecordId(createdEventRecord.getId());
orderData.setRequestNo(request.getRequestNo());
orderData.setPurchasedAt(purchasedAt);
orderData.setOperatorUserId(userLoginService.getRequireLoginUserId());
orderData.setRemark(request.getRemark());
GaoSalesOrder salesOrder = salesOrderService.createPackageOrder(orderData);
attachmentService.updateByBizId(Attachment.BizType.GAO_SALES_ORDER, salesOrder.getId(), request.getAttachmentList());
GaoCustomerPackage result = new GaoCustomerPackage();
result.setStoreId(config.getStoreId());
@@ -288,11 +273,6 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
return packageEntity;
}
private GaoEventRecord eventRecordWithAttachments(GaoEventRecord record, GaoCustomerPackagePurchaseRequest request) {
record.setAttachmentList(request.getAttachmentList());
return record;
}
private GaoCustomer validateCustomer(String customerId, String storeId) {
GaoCustomer customer = customerService.get(customerId);
TimiException.required(customer, "not found customer");
@@ -300,14 +280,6 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
return customer;
}
private GaoEvent validatePurchaseEvent(String eventId) {
GaoEvent event = eventService.get(eventId);
TimiException.required(event, "not found purchase event");
TimiException.requiredTrue(event.getBizType() == GaoEvent.BizType.PACKAGE_PURCHASE, "事件不是套餐购买事件");
TimiException.required(event.getPackageConfigId(), "购买事件未绑定套餐配置");
return event;
}
private List<GaoCustomerPackagePurchaseItem> defaultPurchaseItems(List<GaoPackageConfigItem> configItemList) {
return configItemList.stream().map(configItem -> {
GaoCustomerPackagePurchaseItem item = new GaoCustomerPackagePurchaseItem();
@@ -350,11 +322,11 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
}
private void verifySameStructureAsConfig(List<GaoCustomerPackagePurchaseItem> itemList, List<GaoPackageConfigItem> configItemList) {
TimiException.requiredTrue(itemList.size() == configItemList.size(), "套餐权益项目必须与事件绑定套餐一致");
TimiException.requiredTrue(itemList.size() == configItemList.size(), "套餐权益项目必须与套餐配置一致");
Map<String, GaoCustomerPackagePurchaseItem> itemMap = itemList.stream().collect(Collectors.toMap(item -> item.getProductId() + "|" + item.getProductUnitId(), Function.identity()));
for (GaoPackageConfigItem configItem : configItemList) {
GaoCustomerPackagePurchaseItem item = itemMap.get(configItem.getProductId() + "|" + configItem.getProductUnitId());
TimiException.required(item, "套餐权益项目必须与事件绑定套餐一致");
TimiException.required(item, "套餐权益项目必须与套餐配置一致");
item.setProductNameSnapshot(configItem.getProductNameSnapshot());
item.setUnitSnapshot(configItem.getUnitSnapshot());
item.setBaseQuantityPerUnitSnapshot(configItem.getBaseQuantityPerUnitSnapshot());
@@ -376,9 +348,7 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService<Ga
TimiException.requiredTrue(existingOrder.getOrderType() == GaoSalesOrder.OrderType.PACKAGE
&& existingOrder.getCustomerId().equals(request.getCustomerId())
&& existingOrder.getSaleAmount().equals(request.getSaleAmount()), "requestNo 已被其他购买请求使用");
GaoEventRecord eventRecord = eventRecordService.get(existingOrder.getSourceEventRecordId());
TimiException.required(eventRecord, "购买事件登记不存在");
TimiException.requiredTrue(eventRecord.getEventId().equals(request.getEventId())
TimiException.requiredTrue(existingPackage.getPackageConfigId().equals(request.getPackageConfigId())
&& (request.getPurchasedAt() == null || existingOrder.getPurchasedAt().equals(request.getPurchasedAt()))
&& sameText(existingOrder.getRemark(), request.getRemark()), "requestNo 对应购买请求内容不一致");
List<GaoCustomerPackageItem> existingItemList = itemMapper.selectByCustomerPackageId(existingPackage.getId());
@@ -214,30 +214,13 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void create(GaoEventRecord record) {
createInternal(record, false, false);
createInternal(record);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoEventRecord createOne(GaoEventRecord record) {
return createInternal(record, false, false);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoEventRecord createPackagePurchaseRecord(GaoEventRecord record) {
return createInternal(record, true, false);
}
@Override
public GaoEventRecord createOrderConsumeRecord(GaoEventRecord record) {
return createInternal(record, false, true);
}
@Override
public void revokeOrderConsumeRecord(GaoEventRecord record) {
TimiException.required(record, "not found order event record");
pointTriggerService.revokeByEventRecord(record);
return createInternal(record);
}
@Override
@@ -246,7 +229,7 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
TimiException.requiredTrue(mapper.countBySourceEventRecordId(recordId) == 0, "订单关联事件记录不允许修改或删除");
}
private GaoEventRecord createInternal(GaoEventRecord record, boolean packagePurchase, boolean orderConsume) {
private GaoEventRecord createInternal(GaoEventRecord record) {
TimiException.required(record, "not found record");
Logger logger = new Logger(Logger.Module.GAO, "GAO_EVENT_RECORD_BATCH_CREATE");
@@ -258,20 +241,14 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
GaoEvent event = eventService.get(record.getEventId());
TimiException.required(event, "not found event");
TimiException.required(event.getStoreId(), "not found event.storeId");
if (packagePurchase) {
TimiException.requiredTrue(event.getBizType() == GaoEvent.BizType.PACKAGE_PURCHASE, "购买事件类型无效");
TimiException.requiredTrue(!Boolean.TRUE.equals(event.getRequireVerification()), "购买套餐事件不能要求核销");
} else {
TimiException.requiredTrue(event.getBizType() != GaoEvent.BizType.PACKAGE_PURCHASE, "购买套餐事件必须通过套餐购买接口登记");
}
TimiException.requiredTrue(event.getBizType() != GaoEvent.BizType.LEGACY_CONSUMPTION, "退役消费事件不可登记");
TimiException.requiredTrue(event.getBizType() != GaoEvent.BizType.PACKAGE_PURCHASE, "购买套餐事件已退役,不可登记");
eventService.attachOptionGroupList(List.of(event));
List<GaoEventRecordOption> canonicalOptionList = resolveRecordOptionList(event, record.getSelectedOptionGroupList());
verifyRecordValue(event, record.getNumberValue());
verifyRecordAttachment(event, record.getAttachmentList());
// 检查权限
if (!orderConsume) {
checkEventPermission(event);
}
checkEventPermission(event);
int registeredDateValue = dateValue(registeredAt);
long businessDayNo = businessDayService.resolveOpenBusinessDay(event.getStoreId(), registeredDateValue).getBusinessDayNo();
@@ -383,6 +360,7 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
GaoEvent event = eventService.get(dbRecord.getEventId());
TimiException.required(event, "not found event");
TimiException.required(event.getStoreId(), "not found event.storeId");
TimiException.requiredTrue(event.getBizType() != GaoEvent.BizType.LEGACY_CONSUMPTION, "退役消费事件登记不可修改");
TimiException.requiredTrue(event.getBizType() != GaoEvent.BizType.PACKAGE_PURCHASE, "购买套餐事件登记不可通过通用接口修改");
List<GaoEventRecordOption> canonicalOptionList = null;
if (record.getSelectedOptionGroupList() != null) {
@@ -428,6 +406,7 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
GaoEventRecord record = get(id);
TimiException.required(record, "not found event record");
GaoEvent event = eventService.get(record.getEventId());
TimiException.requiredTrue(event == null || event.getBizType() != GaoEvent.BizType.LEGACY_CONSUMPTION, "退役消费事件登记不可删除");
TimiException.requiredTrue(event == null || event.getBizType() != GaoEvent.BizType.PACKAGE_PURCHASE, "购买套餐事件登记不可通过通用接口删除");
pointTriggerService.revokeByEventRecord(record);
GaoEventRecordOption optionExample = new GaoEventRecordOption();
@@ -85,6 +85,7 @@ public class GaoEventServiceImplement extends AbstractEntityService<GaoEvent, St
event.setStatus(TimiJava.defaultIfNull(event.getStatus(), GaoEvent.Status.DRAFT));
event.setBizType(TimiJava.defaultIfNull(event.getBizType(), GaoEvent.BizType.GENERAL));
TimiException.requiredTrue(event.getBizType() != GaoEvent.BizType.LEGACY_CONSUMPTION, "退役消费事件类型只能由迁移流程标记");
event.setValueType(TimiJava.defaultIfNull(event.getValueType(), GaoEvent.ValueType.NONE));
event.setRequireValue(TimiJava.defaultIfNull(event.getRequireValue(), false));
event.setRequirePhoto(TimiJava.defaultIfNull(event.getRequirePhoto(), false));
@@ -122,6 +123,7 @@ public class GaoEventServiceImplement extends AbstractEntityService<GaoEvent, St
TimiException.required(dbEvent, "not found event");
TimiException.requiredTrue(dbEvent.getStoreId().equals(event.getStoreId()), "event.storeId invalid");
TimiException.requiredTrue(dbEvent.getStatus() != GaoEvent.Status.DELETED, "已删除登记事件不可编辑");
TimiException.requiredTrue(dbEvent.getBizType() != GaoEvent.BizType.LEGACY_CONSUMPTION, "退役消费事件不可编辑");
GaoEvent.ValueType oldValueType = dbEvent.getValueType();
GaoEvent.ValueUnit oldValueUnit = dbEvent.getValueUnit();
dbEvent.setName(event.getName());
@@ -192,6 +194,7 @@ public class GaoEventServiceImplement extends AbstractEntityService<GaoEvent, St
GaoEvent event = get(eventId);
TimiException.required(event, "not found event");
TimiException.requiredTrue(event.getStatus() != GaoEvent.Status.DELETED, "已删除登记事件不可编辑");
TimiException.requiredTrue(event.getBizType() != GaoEvent.BizType.LEGACY_CONSUMPTION, "退役消费事件不可编辑");
verifyOptionGroupList(optionGroupList);
saveOptionGroupList(eventId, optionGroupList);
logger.setLevel(Logger.Level.INFO);
@@ -217,6 +220,8 @@ public class GaoEventServiceImplement extends AbstractEntityService<GaoEvent, St
try {
logger.setContent(id);
GaoEvent event = get(id);
TimiException.required(event, "not found event");
TimiException.requiredTrue(event.getBizType() != GaoEvent.BizType.LEGACY_CONSUMPTION, "退役消费事件不可删除");
event.setStatus(GaoEvent.Status.DELETED);
super.update(event);
logger.setLevel(Logger.Level.INFO);
@@ -7,6 +7,7 @@ import com.imyeyu.api.modules.gao.entity.GaoFundRecord;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.service.GaoFundRecordService;
import com.imyeyu.api.modules.gao.service.GaoOrderPaymentService;
import com.imyeyu.api.modules.gao.service.GaoPointTriggerService;
import com.imyeyu.api.modules.gao.service.GaoSalesOrderService;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentActionRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentNoRefundRequest;
@@ -36,6 +37,7 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
private final GaoFundRecordService fundRecordService;
private final GaoSalesOrderService orderService;
private final GaoPointTriggerService pointTriggerService;
private final AttachmentService attachmentService;
private final UserLoginService userLoginService;
private final LoggerService loggerService;
@@ -79,18 +81,23 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
public GaoFundRecord verify(String fundRecordId) {
return loggerService.execute(Logger.Module.GAO, "GAO_SALES_ORDER_PAYMENT_VERIFY", fundRecordId, () -> {
GaoFundRecord record = lockOrderRecord(fundRecordId);
TimiException.requiredTrue(record.getOperationType() == GaoFundRecord.OperationType.RECEIVE && record.getStatus() == GaoFundRecord.Status.PENDING, "收款记录不是待核销状态");
GaoSalesOrder order = orderService.getForUpdate(record.getBizId());
order.setPendingPaidAmount(safe(order.getPendingPaidAmount()) - record.getAmount());
order.setPaidAmount(safe(order.getPaidAmount()) + record.getAmount());
orderService.recomputePaymentStatus(order);
String userId = userLoginService.getRequireLoginUserId();
TimiException.requiredTrue(1 == fundRecordService.updateStatusIf(record.getId(), GaoFundRecord.Status.PENDING, GaoFundRecord.Status.VERIFIED, userId, Time.now(), record.getReason()), "收款记录状态已变化");
orderService.updatePayment(order);
record.setStatus(GaoFundRecord.Status.VERIFIED);
record.setVerifierUserId(userId);
record.setVerifiedAt(Time.now());
return record;
TimiException.requiredTrue(record.getOperationType() == GaoFundRecord.OperationType.RECEIVE && record.getStatus() == GaoFundRecord.Status.PENDING, "收款记录不是待核销状态");
GaoSalesOrder order = orderService.getForUpdate(record.getBizId());
Long paidAtBefore = order.getPaidAt();
order.setPendingPaidAmount(safe(order.getPendingPaidAmount()) - record.getAmount());
order.setPaidAmount(safe(order.getPaidAmount()) + record.getAmount());
orderService.recomputePaymentStatus(order);
String userId = userLoginService.getRequireLoginUserId();
TimiException.requiredTrue(1 == fundRecordService.updateStatusIf(record.getId(), GaoFundRecord.Status.PENDING, GaoFundRecord.Status.VERIFIED, userId, Time.now(), record.getReason()), "收款记录状态已变化");
orderService.updatePayment(order);
record.setStatus(GaoFundRecord.Status.VERIFIED);
record.setVerifierUserId(userId);
record.setVerifiedAt(Time.now());
if (paidAtBefore == null && order.getPaidAt() != null) {
// 订单首次全额实收,写入消费事实并触发订单积分
pointTriggerService.onSalesOrderPaid(order);
}
return record;
});
}
@@ -209,6 +216,7 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
}
TimiException.requiredTrue(source.getStatus() == GaoFundRecord.Status.VERIFIED && source.getOperationType() != GaoFundRecord.OperationType.REVOKE, "只有已生效资金记录可以冲正");
TimiException.requiredNull(fundRecordService.getRevokeBySourceRecordId(source.getId()), "该资金记录已经冲正");
Long paidAtBefore = order.getPaidAt();
if (source.getOperationType() == GaoFundRecord.OperationType.RECEIVE) {
order.setPaidAmount(safe(order.getPaidAmount()) - source.getAmount());
orderService.recomputePaymentStatus(order);
@@ -224,6 +232,10 @@ public class GaoOrderPaymentServiceImplement implements GaoOrderPaymentService {
revoke.setReason(reason);
fundRecordService.createByOrderPayment(revoke);
orderService.updatePayment(order);
if (paidAtBefore != null && order.getPaymentStatus() != GaoSalesOrder.PaymentStatus.PAID) {
// 已全额实收的收款被冲正,撤销消费事实及其依赖积分
pointTriggerService.revokeBySalesOrder(order, reason);
}
TimiException.requiredTrue(hasPhoto(request.getAttachmentList()), "资金冲正必须上传照片");
attachmentService.updateByBizId(Attachment.BizType.GAO_FUND_RECORD, revoke.getId(), request.getAttachmentList());
return revoke;
@@ -1,15 +1,19 @@
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.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.service.AttachmentService;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
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.GaoPointLedgerConsumptionRecordMapper;
import com.imyeyu.api.modules.gao.mapper.GaoPointLedgerMapper;
import com.imyeyu.api.modules.gao.script.GaoPointDecision;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoPointAccountService;
import com.imyeyu.api.modules.gao.service.GaoPointLedgerService;
@@ -42,7 +46,9 @@ import java.util.Map;
@RequiredArgsConstructor
public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPointLedger, String> implements GaoPointLedgerService {
private final ObjectMapper jackson;
private final GaoPointLedgerMapper mapper;
private final GaoPointLedgerConsumptionRecordMapper consumptionRecordMapper;
private final GaoPointAccountService accountService;
private final GaoCustomerService customerService;
private final AttachmentService attachmentService;
@@ -71,23 +77,35 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPoi
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoPointLedger applyRule(GaoEventRecord record, GaoPointRule rule, long pointDelta, JsonNode sourcePayload) {
TimiException.required(record, "not found event record");
public GaoPointLedger applyRule(GaoPointRule rule, GaoPointDecision decision, GroovyScriptExecutionResult<?> execution,
GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId, String salesOrderId,
String storeId, String customerId, Long occurredAt, String operatorUserId, List<String> consumptionRecordIdList) {
TimiException.required(rule, "not found point rule");
TimiException.requiredTrue(pointDelta != 0, "pointDelta invalid");
String idempotencyKey = "GAO_EVENT_RECORD:%s:RULE:%s:EARN".formatted(record.getId(), rule.getId());
TimiException.required(decision, "not found point decision");
TimiException.required(triggerSourceType, "not found triggerSourceType");
TimiException.required(triggerSourceId, "not found triggerSourceId");
TimiException.requiredTrue(decision.pointDelta() != 0, "pointDelta invalid");
long pointDelta = decision.pointDelta();
String idempotencyKey = earnKey(triggerSourceType, triggerSourceId, rule.getId());
GaoPointLedger.ChangeType changeType = pointDelta < 0 ? GaoPointLedger.ChangeType.CONSUME : GaoPointLedger.ChangeType.EARN;
return apply(record.getStoreId(), record.getCustomerId(), changeType, pointDelta, rule.getId(), record.getEventId(), record.getId(), sourcePayload, idempotencyKey, rule.getRemark(), record.getOperatorUserId(), record.getRegisteredAt());
GaoPointLedger ledger = apply(storeId, customerId, changeType, pointDelta, rule.getId(), triggerSourceType, triggerSourceId, salesOrderId,
ruleSnapshot(rule, execution), sourcePayload(decision, execution), idempotencyKey,
TimiJava.defaultIfNull(decision.reason(), rule.getName()), operatorUserId, occurredAt);
saveConsumptionDependencies(ledger.getId(), consumptionRecordIdList);
return ledger;
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoPointLedger revoke(GaoPointLedger originLedger, GaoEventRecord record) {
public GaoPointLedger revoke(GaoPointLedger originLedger, String reason) {
TimiException.required(originLedger, "not found origin ledger");
TimiException.required(record, "not found event record");
TimiException.requiredTrue(originLedger.getPointDelta() != 0, "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());
TimiException.required(originLedger.getId(), "not found origin ledger.id");
TimiException.requiredTrue(originLedger.getPointDelta() != null && originLedger.getPointDelta() != 0, "origin ledger pointDelta invalid");
String idempotencyKey = "GAO_POINT_LEDGER:%s:REVOKE".formatted(originLedger.getId());
return apply(originLedger.getStoreId(), originLedger.getCustomerId(), GaoPointLedger.ChangeType.REVOKE, -originLedger.getPointDelta(),
originLedger.getRuleId(), originLedger.getTriggerSourceType(), originLedger.getTriggerSourceId(), originLedger.getSalesOrderId(),
originLedger.getRuleSnapshot(), originLedger.getSourcePayload(), idempotencyKey, reason,
userLoginService.getRequireLoginUserId(), Time.now());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@@ -102,13 +120,21 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPoi
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());
return apply(request.getStoreId(), request.getCustomerId(), GaoPointLedger.ChangeType.ADJUST, request.getPointDelta(),
null, null, null, null, null, null, idempotencyKey, request.getRemark(), operatorUserId, Time.now());
}
@Override
public List<GaoPointLedger> listRuleChangeByEventRecordId(String eventRecordId) {
TimiException.required(eventRecordId, "not found eventRecordId");
return mapper.selectRuleChangeByEventRecordId(eventRecordId);
public List<GaoPointLedger> listRuleChangeByTriggerSource(GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId) {
TimiException.required(triggerSourceType, "not found triggerSourceType");
TimiException.required(triggerSourceId, "not found triggerSourceId");
return mapper.selectRuleChangeByTriggerSource(triggerSourceType, triggerSourceId);
}
@Override
public List<GaoPointLedger> listRuleChangeByConsumptionRecordId(String consumptionRecordId) {
TimiException.required(consumptionRecordId, "not found consumptionRecordId");
return mapper.selectRuleChangeByConsumptionRecordId(consumptionRecordId);
}
@Override
@@ -145,7 +171,62 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPoi
}
}
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) {
private void saveConsumptionDependencies(String pointLedgerId, List<String> consumptionRecordIdList) {
if (TimiJava.isEmpty(consumptionRecordIdList)) {
return;
}
long now = Time.now();
for (String consumptionRecordId : consumptionRecordIdList) {
TimiException.required(consumptionRecordId, "not found consumptionRecordId");
consumptionRecordMapper.insert(pointLedgerId, consumptionRecordId, now);
}
}
private String earnKey(GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId, String ruleId) {
return switch (triggerSourceType) {
case EVENT_RECORD -> "GAO_EVENT_RECORD:%s:RULE:%s:EARN".formatted(triggerSourceId, ruleId);
case SALES_ORDER_PAID -> "GAO_SALES_ORDER:%s:RULE:%s:EARN".formatted(triggerSourceId, ruleId);
};
}
private JsonNode ruleSnapshot(GaoPointRule rule, GroovyScriptExecutionResult<?> execution) {
ObjectNode snapshot = jackson.createObjectNode();
snapshot.put("ruleId", rule.getId());
snapshot.put("ruleName", rule.getName());
if (rule.getRuleMode() != null) {
snapshot.put("ruleMode", rule.getRuleMode().name());
}
if (rule.getPointValue() != null) {
snapshot.put("pointValue", rule.getPointValue());
}
if (rule.getGroovyScriptId() != null) {
snapshot.put("groovyScriptId", rule.getGroovyScriptId());
}
if (execution != null) {
snapshot.put("groovyScriptVersionId", execution.versionId());
snapshot.put("groovyScriptVersion", execution.version());
snapshot.put("groovyScriptChecksum", execution.checksum());
}
return snapshot;
}
private JsonNode sourcePayload(GaoPointDecision decision, GroovyScriptExecutionResult<?> execution) {
ObjectNode payload = jackson.createObjectNode();
payload.put("reason", decision.reason());
payload.set("consumptionRecordIdList", jackson.valueToTree(decision.consumptionRecordIdList()));
if (decision.conditionSnapshot() != null) {
payload.set("conditionSnapshot", jackson.valueToTree(decision.conditionSnapshot()));
}
if (execution != null) {
payload.put("groovyScriptId", execution.scriptId());
payload.put("groovyScriptVersionId", execution.versionId());
}
return payload;
}
private GaoPointLedger apply(String storeId, String customerId, GaoPointLedger.ChangeType changeType, long pointDelta, String ruleId,
GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId, String salesOrderId, JsonNode ruleSnapshot,
JsonNode sourcePayload, String idempotencyKey, String remark, String operatorUserId, Long occurredAt) {
Logger logger = new Logger(Logger.Module.GAO, "GAO_POINT_LEDGER_APPLY");
logger.setContent("storeId=%s, customerId=%s, changeType=%s, pointDelta=%d, idempotencyKey=%s, remark=%s".formatted(storeId, customerId, changeType, pointDelta, idempotencyKey, remark));
try {
@@ -181,8 +262,11 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPoi
ledger.setBalanceBefore(balanceBefore);
ledger.setBalanceAfter(balanceAfter);
ledger.setRuleId(ruleId);
ledger.setEventId(eventId);
ledger.setEventRecordId(eventRecordId);
ledger.setTriggerSourceType(triggerSourceType);
ledger.setTriggerSourceId(triggerSourceId);
ledger.setSalesOrderId(salesOrderId);
ledger.setGroovyScriptVersionId(executionVersionId(ruleSnapshot));
ledger.setRuleSnapshot(ruleSnapshot);
ledger.setSourcePayload(sourcePayload);
ledger.setIdempotencyKey(idempotencyKey);
ledger.setRemark(remark);
@@ -207,4 +291,11 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPoi
loggerService.create(logger);
}
}
private String executionVersionId(JsonNode ruleSnapshot) {
if (ruleSnapshot == null || ruleSnapshot.get("groovyScriptVersionId") == null || ruleSnapshot.get("groovyScriptVersionId").isNull()) {
return null;
}
return ruleSnapshot.get("groovyScriptVersionId").asText();
}
}
@@ -1,12 +1,13 @@
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.common.entity.GroovyScript;
import com.imyeyu.api.modules.common.service.GroovyScriptService;
import com.imyeyu.api.modules.gao.entity.GaoEvent;
import com.imyeyu.api.modules.gao.entity.GaoPointRule;
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.api.modules.gao.service.GaoPointRuleService;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.mapper.BaseMapper;
@@ -15,9 +16,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -30,10 +31,9 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class GaoPointRuleServiceImplement extends AbstractEntityService<GaoPointRule, String> implements GaoPointRuleService {
private static final int MAX_TIER_COUNT = 100;
private final GaoPointRuleMapper mapper;
private final GaoEventService eventService;
private final GroovyScriptService groovyScriptService;
@Override
protected BaseMapper<GaoPointRule, String> mapper() {
@@ -41,10 +41,10 @@ public class GaoPointRuleServiceImplement extends AbstractEntityService<GaoPoint
}
@Override
public List<GaoPointRule> listActive(String storeId, String eventId, long now) {
public List<GaoPointRule> listActive(String storeId, GaoPointRule.TriggerSourceType triggerSourceType, String triggerRefId, long now) {
TimiException.required(storeId, "not found storeId");
TimiException.required(eventId, "not found eventId");
return mapper.selectActiveByStoreIdAndEventId(storeId, eventId, now);
TimiException.required(triggerSourceType, "not found triggerSourceType");
return mapper.selectActiveByTrigger(storeId, triggerSourceType, triggerRefId, now);
}
@Override
@@ -89,9 +89,7 @@ public class GaoPointRuleServiceImplement extends AbstractEntityService<GaoPoint
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));
@@ -105,22 +103,21 @@ public class GaoPointRuleServiceImplement extends AbstractEntityService<GaoPoint
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.setRuleMode(rule.getRuleMode());
dbRule.setTriggerSourceType(rule.getTriggerSourceType());
dbRule.setTriggerRefType(rule.getTriggerRefType());
dbRule.setTriggerRefId(rule.getTriggerRefId());
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.setGroovyScriptId(rule.getGroovyScriptId());
dbRule.setBeginAt(rule.getBeginAt());
dbRule.setEndAt(rule.getEndAt());
dbRule.setPriority(rule.getPriority());
@@ -140,123 +137,33 @@ public class GaoPointRuleServiceImplement extends AbstractEntityService<GaoPoint
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.required(rule.getRuleMode(), "not found rule.ruleMode");
TimiException.required(rule.getTriggerSourceType(), "not found rule.triggerSourceType");
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");
TimiException.requiredTrue(rule.getTriggerRefType() == null || rule.getTriggerRefType() == GaoPointRule.TriggerRefType.EVENT, "rule.triggerRefType invalid");
TimiException.requiredTrue(rule.getTriggerRefId() == null || rule.getTriggerRefType() != null, "触发对象筛选 ID 必须搭配触发对象筛选类型");
if (rule.getTriggerRefType() == GaoPointRule.TriggerRefType.EVENT) {
TimiException.required(rule.getTriggerRefId(), "not found rule.triggerRefId");
GaoEvent event = eventService.get(rule.getTriggerRefId());
TimiException.required(event, "触发对象事件不存在");
TimiException.requiredTrue(rule.getStoreId().equals(event.getStoreId()), "规则和触发对象事件不属于同一门店");
TimiException.requiredTrue(event.getBizType() != GaoEvent.BizType.LEGACY_CONSUMPTION, "退役消费事件不能作为触发对象");
}
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());
if (rule.getRuleMode() == GaoPointRule.RuleMode.SIMPLE) {
TimiException.requiredNull(rule.getGroovyScriptId(), "简单规则不能绑定 Groovy 脚本");
TimiException.required(rule.getPointValue(), "not found rule.pointValue");
TimiException.requiredTrue(rule.getPointValue() != 0, "rule.pointValue invalid");
return;
}
if (rule.getTriggerType() == GaoPointRule.TriggerType.CUSTOMER_EVENT_VALUE_TIER) {
TimiException.requiredTrue(rule.getPointMode() == GaoPointRule.PointMode.TIER, "CUSTOMER_EVENT_VALUE_TIER 规则必须使用 TIER 积分值模式");
TimiException.requiredNull(rule.getPointValue(), "TIER 规则不能填写规则级积分值");
verifyTierCondition(rule.getStoreId(), rule.getConditionJson());
return;
}
TimiException.requiredTrue(rule.getPointMode() == GaoPointRule.PointMode.FIXED, "固定触发规则必须使用 FIXED 积分值模式");
TimiException.required(rule.getPointValue(), "not found rule.pointValue");
TimiException.requiredTrue(rule.getPointValue() != 0, "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 verifyTierCondition(String storeId, JsonNode conditionJson) {
JsonNode condition = requireObjectCondition(conditionJson);
JsonNode sourceEventIdNode = condition.get("sourceEventId");
TimiException.requiredTrue(sourceEventIdNode != null && sourceEventIdNode.isTextual() && TimiJava.isNotEmpty(sourceEventIdNode.asText().trim()), "TIER.sourceEventId invalid");
String sourceEventId = sourceEventIdNode.asText().trim();
GaoEvent sourceEvent = eventService.get(sourceEventId);
TimiException.required(sourceEvent, "TIER.sourceEventId 对应事件不存在");
TimiException.requiredTrue(storeId.equals(sourceEvent.getStoreId()), "TIER 来源事件和规则不属于同一门店");
TimiException.requiredTrue(sourceEvent.getValueType() == GaoEvent.ValueType.NUMBER, "TIER 来源事件必须配置为 NUMBER 类型");
TimiException.required(sourceEvent.getValueUnit(), "TIER 来源事件必须配置数值单位");
JsonNode sourceValueField = condition.get("sourceValueField");
TimiException.requiredTrue(sourceValueField != null && sourceValueField.isTextual() && "numberValue".equals(sourceValueField.asText()), "TIER.sourceValueField 目前只能是 numberValue");
JsonNode window = condition.get("window");
TimiException.requiredTrue(window != null && window.isObject(), "TIER.window 必须是对象");
JsonNode windowType = window.get("type");
TimiException.requiredTrue(windowType != null && windowType.isTextual() && "ROLLING".equals(windowType.asText()), "TIER.window.type 目前只能是 ROLLING");
JsonNode windowAmount = window.get("amount");
TimiException.requiredTrue(windowAmount != null && windowAmount.isIntegralNumber() && 0 < windowAmount.longValue(), "TIER.window.amount invalid");
JsonNode windowUnit = window.get("unit");
TimiException.requiredTrue(windowUnit != null && windowUnit.isTextual() && "YEAR".equals(windowUnit.asText()), "TIER.window.unit 目前只能是 YEAR");
JsonNode tiers = condition.get("tiers");
TimiException.requiredTrue(tiers != null && tiers.isArray() && !tiers.isEmpty(), "TIER.tiers 不能为空");
TimiException.requiredTrue(tiers.size() <= MAX_TIER_COUNT, "TIER.tiers 数量不能超过 %d".formatted(MAX_TIER_COUNT));
Long previousMax = null;
for (int i = 0; i < tiers.size(); i++) {
JsonNode tier = tiers.get(i);
TimiException.requiredTrue(tier != null && tier.isObject(), "TIER.tiers[%d] 必须是对象".formatted(i));
JsonNode minNode = tier.get("min");
TimiException.requiredTrue(minNode != null && minNode.isIntegralNumber() && 0 <= minNode.longValue(), "TIER.tiers[%d].min invalid".formatted(i));
long min = minNode.longValue();
if (0 < i) {
TimiException.requiredTrue(previousMax != null && min == previousMax + 1, "TIER 分档必须连续且不能重叠");
}
JsonNode maxNode = tier.get("max");
Long max = null;
if (maxNode != null && !maxNode.isNull()) {
TimiException.requiredTrue(maxNode.isIntegralNumber(), "TIER.tiers[%d].max invalid".formatted(i));
max = maxNode.longValue();
TimiException.requiredTrue(min <= max, "TIER.tiers[%d].min 或 max invalid".formatted(i));
}
if (i < tiers.size() - 1) {
TimiException.requiredTrue(max != null, "除最后一档外 TIER.max 不能为空");
TimiException.requiredTrue(max < Long.MAX_VALUE, "TIER 分档上限过大,无法继续配置下一档");
} else {
TimiException.requiredTrue(max == null, "最后一档 TIER.max 必须为空");
}
JsonNode pointValue = tier.get("pointValue");
TimiException.requiredTrue(pointValue != null && pointValue.isIntegralNumber(), "TIER.tiers[%d].pointValue invalid".formatted(i));
previousMax = max;
}
}
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 || min != 0, "SOURCE_VALUE.min invalid");
TimiException.requiredTrue(max == null || max != 0, "SOURCE_VALUE.max invalid");
TimiException.requiredTrue(min == null || max == null || min <= max, "SOURCE_VALUE.min or SOURCE_VALUE.max invalid");
TimiException.requiredTrue(min == null || max == null || (min < 0) == (max < 0), "SOURCE_VALUE.min or SOURCE_VALUE.max direction 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");
TimiException.required(rule.getGroovyScriptId(), "not found rule.groovyScriptId");
TimiException.requiredNull(rule.getPointValue(), "Groovy 规则不能填写固定积分值");
GroovyScript script = groovyScriptService.get(rule.getGroovyScriptId());
TimiException.required(script, "Groovy 脚本不存在");
TimiException.requiredTrue(script.getBizType() == GroovyScript.BizType.GAO_POINT_RULE, "Groovy 脚本业务归属无效");
}
}
@@ -1,22 +1,24 @@
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.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.api.modules.common.service.GroovyScriptService;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.entity.GaoCustomerConsumptionRecord;
import com.imyeyu.api.modules.gao.entity.GaoEventRecord;
import com.imyeyu.api.modules.gao.entity.GaoPointLedger;
import com.imyeyu.api.modules.gao.entity.GaoPointRule;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.script.GaoPointDecision;
import com.imyeyu.api.modules.gao.script.GaoPointScriptContext;
import com.imyeyu.api.modules.gao.service.GaoCustomerConsumptionRecordService;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
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.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;
@@ -26,26 +28,28 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/// GAO 客户积分触发服务实现
///
/// 简单规则直接发放固定积分,复杂规则由版本化 Groovy 脚本返回声明式积分决策;
/// 脚本只接收归一化上下文和只读消费事实查询,积分服务统一负责幂等与写账
///
/// @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 GaoCustomerConsumptionRecordService consumptionService;
private final GroovyScriptService groovyScriptService;
private final GaoCustomerService customerService;
private final ObjectProvider<GaoEventRecordService> eventRecordServiceProvider;
@@ -59,22 +63,16 @@ public class GaoPointTriggerServiceImplement implements GaoPointTriggerService {
TimiException.required(record.getEventId(), "not found record.eventId");
TimiException.required(record.getRegisteredAt(), "not found record.registeredAt");
List<GaoPointRule> ruleList = ruleService.listActive(record.getStoreId(), record.getEventId(), Time.now());
Long count = null;
long pointDeltaTotal = 0L;
for (GaoPointRule rule : ruleList) {
RuleResolution resolution = resolveRule(record, rule, count);
if (resolution == null) {
for (GaoPointRule rule : ruleService.listActive(record.getStoreId(), GaoPointRule.TriggerSourceType.EVENT_RECORD, record.getEventId(), record.getRegisteredAt())) {
GaoPointScriptContext context = eventContext(rule, record);
GroovyScriptExecutionResult<GaoPointDecision> execution = evaluate(rule, record.getStoreId(), record.getCustomerId(), record.getRegisteredAt(),
GaoPointRule.TriggerRefType.EVENT.name(), record.getEventId());
if (execution == null) {
continue;
}
if (rule.getTriggerType() == GaoPointRule.TriggerType.FIRST_TIME || rule.getTriggerType() == GaoPointRule.TriggerType.AFTER_COUNT_EVERY_TIME) {
count = count == null ? count(record) : count;
}
long pointDelta = resolution.pointDelta();
if (pointDelta != 0) {
ledgerService.applyRule(record, rule, pointDelta, resolution.sourcePayload());
pointDeltaTotal = Math.addExact(pointDeltaTotal, pointDelta);
}
pointDeltaTotal += applyDecision(rule, execution, GaoPointLedger.TriggerSourceType.EVENT_RECORD, record.getId(), null,
record.getStoreId(), record.getCustomerId(), record.getRegisteredAt(), record.getOperatorUserId(), context);
if (Boolean.TRUE.equals(rule.getExclusive())) {
break;
}
@@ -87,19 +85,60 @@ public class GaoPointTriggerServiceImplement implements GaoPointTriggerService {
@Override
public void revokeByEventRecord(GaoEventRecord record) {
TimiException.required(record, "not found event record");
List<GaoPointLedger> originLedgerList = ledgerService.listRuleChangeByEventRecordId(record.getId());
if (originLedgerList.isEmpty()) {
return;
TimiException.required(record.getId(), "not found event record.id");
revokeByTriggerSource(GaoPointLedger.TriggerSourceType.EVENT_RECORD, record.getId(), "撤销登记记录积分");
GaoCustomerConsumptionRecord fact = consumptionService.getBySource(GaoCustomerConsumptionRecord.SourceType.LEGACY_EVENT, record.getId());
if (fact != null) {
consumptionService.revokeLegacyEvent(record.getId(), "撤销历史消费事件对应的消费事实");
revokeDependentLedgers(fact.getId());
}
Set<String> revokedKeySet = new HashSet<>();
List<String> revokeKeyList = originLedgerList.stream().map(origin -> revokeKey(record, origin)).toList();
for (GaoPointLedger ledger : ledgerService.listByIdempotencyKeyList(revokeKeyList)) {
revokedKeySet.add(ledger.getIdempotencyKey());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void onSalesOrderPaid(GaoSalesOrder order) {
TimiException.required(order, "not found sales order");
TimiException.required(order.getId(), "not found sales order.id");
TimiException.required(order.getStoreId(), "not found order.storeId");
TimiException.required(order.getCustomerId(), "not found order.customerId");
TimiException.requiredTrue(order.getPaymentStatus() == GaoSalesOrder.PaymentStatus.PAID, "订单尚未全额实收");
TimiException.required(order.getPaidAt(), "not found order.paidAt");
if (order.getSaleAmount() != null && 0 < order.getSaleAmount()) {
consumptionService.createSalesOrder(order.getId(), order.getStoreId(), order.getCustomerId(), order.getSaleAmount(), order.getPaidAt(), order.getOrderType());
}
for (GaoPointLedger originLedger : originLedgerList) {
if (!revokedKeySet.contains(revokeKey(record, originLedger))) {
ledgerService.revoke(originLedger, record);
for (GaoPointRule rule : ruleService.listActive(order.getStoreId(), GaoPointRule.TriggerSourceType.SALES_ORDER_PAID, null, order.getPaidAt())) {
GaoPointScriptContext context = orderContext(rule, order);
GroovyScriptExecutionResult<GaoPointDecision> execution = evaluate(rule, order.getStoreId(), order.getCustomerId(), order.getPaidAt(), null, null);
if (execution == null) {
continue;
}
applyDecision(rule, execution, GaoPointLedger.TriggerSourceType.SALES_ORDER_PAID, order.getId(), order.getId(),
order.getStoreId(), order.getCustomerId(), order.getPaidAt(), order.getOperatorUserId(), context);
if (Boolean.TRUE.equals(rule.getExclusive())) {
break;
}
}
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void revokeBySalesOrder(GaoSalesOrder order, String reason) {
TimiException.required(order, "not found sales order");
TimiException.required(order.getId(), "not found sales order.id");
TimiException.required(reason, "not found revoke reason");
revokeByTriggerSource(GaoPointLedger.TriggerSourceType.SALES_ORDER_PAID, order.getId(), reason);
GaoCustomerConsumptionRecord fact = consumptionService.getBySource(GaoCustomerConsumptionRecord.SourceType.SALES_ORDER, order.getId());
if (fact != null) {
consumptionService.revokeSalesOrder(order.getId(), reason);
} else if (TimiJava.isNotEmpty(order.getSourceEventRecordId())) {
// 切换前的旧订单通过原事件记录映射定位迁移出的消费事实
fact = consumptionService.getBySource(GaoCustomerConsumptionRecord.SourceType.LEGACY_EVENT, order.getSourceEventRecordId());
if (fact != null) {
consumptionService.revokeLegacyEvent(order.getSourceEventRecordId(), reason);
}
}
if (fact != null) {
revokeDependentLedgers(fact.getId());
}
}
@@ -114,94 +153,139 @@ public class GaoPointTriggerServiceImplement implements GaoPointTriggerService {
return ledgerService.adjust(request, userLoginService.getRequireLoginUserId());
}
private RuleResolution resolveRule(GaoEventRecord record, GaoPointRule rule, Long count) {
return switch (rule.getTriggerType()) {
case EVERY_TIME -> new RuleResolution(rule.getPointValue(), null);
case FIRST_TIME -> {
long currentCount = count == null ? count(record) : count;
yield currentCount == 1 ? new RuleResolution(rule.getPointValue(), null) : 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 ? new RuleResolution(rule.getPointValue(), null) : null : threshold < currentCount ? new RuleResolution(rule.getPointValue(), null) : null;
}
case SOURCE_VALUE -> resolveSourceValue(record, rule);
case CUSTOMER_EVENT_VALUE_TIER -> resolveTier(record, rule);
};
/// 按规则评估积分决策
///
/// 简单规则直接构造固定积分决策;Groovy 规则执行发布版本脚本;脚本未返回决策视为未命中
///
/// @param rule 积分规则
/// @param storeId 门店 ID
/// @param customerId 客户 ID
/// @param occurredAt 发生时间
/// @param triggerRefType 触发对象筛选类型,可空
/// @param triggerRefId 触发对象筛选 ID,可空
/// @return 脚本执行结果,未命中返回 null
private GroovyScriptExecutionResult<GaoPointDecision> evaluate(GaoPointRule rule, String storeId, String customerId, long occurredAt, String triggerRefType, String triggerRefId) {
if (rule.getRuleMode() == GaoPointRule.RuleMode.SIMPLE) {
GaoPointDecision decision = new GaoPointDecision(rule.getPointValue(), rule.getName(), Map.of("ruleMode", GaoPointRule.RuleMode.SIMPLE.name()), List.of());
return new GroovyScriptExecutionResult<>(null, null, null, null, decision);
}
GroovyScript script = groovyScriptService.get(rule.getGroovyScriptId());
TimiException.required(script, "规则绑定的 Groovy 脚本不存在");
GaoPointScriptContext context = buildContext(rule, storeId, customerId, occurredAt, triggerRefType, triggerRefId);
Map<String, Object> bindings = new HashMap<>();
bindings.put("context", context);
return groovyScriptService.executePublished(script.getBizType(), script.getCode(), bindings, GaoPointDecision.class);
}
private RuleResolution 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(pointValue != 0, "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 大于规则最大值");
}
ObjectNode payload = jackson.createObjectNode();
payload.put("submittedPointValue", pointValue);
payload.put("validatedPointValue", pointValue);
return new RuleResolution(pointValue, payload);
/// 写入积分流水并累计决策结果
///
/// @return 本次决策的积分变化
private long applyDecision(GaoPointRule rule, GroovyScriptExecutionResult<GaoPointDecision> execution,
GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId, String salesOrderId,
String storeId, String customerId, Long occurredAt, String operatorUserId, GaoPointScriptContext context) {
GaoPointDecision decision = execution.result();
TimiException.required(decision, "规则未返回积分决策");
TimiException.requiredTrue(decision.pointDelta() != 0, "规则积分变化不能为 0");
ledgerService.applyRule(rule, decision, isGroovy(execution) ? execution : null, triggerSourceType, triggerSourceId, salesOrderId,
storeId, customerId, occurredAt, operatorUserId, decision.consumptionRecordIdList());
return decision.pointDelta();
}
private RuleResolution resolveTier(GaoEventRecord record, GaoPointRule rule) {
JsonNode condition = rule.getConditionJson();
String sourceEventId = condition.get("sourceEventId").asText();
long amount = condition.get("window").get("amount").longValue();
ZoneId zone = ZoneId.systemDefault();
LocalDate currentDate = Instant.ofEpochMilli(record.getRegisteredAt()).atZone(zone).toLocalDate();
long windowBeginAt = currentDate.minusYears(amount).atStartOfDay(zone).toInstant().toEpochMilli();
long windowEndAt = Math.addExact(record.getRegisteredAt(), 1L);
long sumValue = eventRecordServiceProvider.getObject().sumNumberValue(record.getStoreId(), record.getCustomerId(), sourceEventId, windowBeginAt, windowEndAt);
/// 撤销指定触发来源产生的全部规则积分
private void revokeByTriggerSource(GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId, String reason) {
List<GaoPointLedger> originLedgerList = ledgerService.listRuleChangeByTriggerSource(triggerSourceType, triggerSourceId);
revokeAll(originLedgerList, reason);
}
JsonNode matchedTier = null;
for (JsonNode tier : condition.get("tiers")) {
long min = tier.get("min").longValue();
JsonNode maxNode = tier.get("max");
if (min <= sumValue && (maxNode == null || maxNode.isNull() || sumValue <= maxNode.longValue())) {
matchedTier = tier;
break;
/// 复算并撤销依赖指定消费事实的条件积分
///
/// 按原积分流水保存的脚本版本和原触发时间重算,只有不再满足原条件的积分才撤销
private void revokeDependentLedgers(String consumptionRecordId) {
List<GaoPointLedger> dependentList = ledgerService.listRuleChangeByConsumptionRecordId(consumptionRecordId);
if (dependentList.isEmpty()) {
return;
}
List<GaoPointLedger> revokeCandidateList = new java.util.ArrayList<>();
for (GaoPointLedger origin : dependentList) {
if (!isGroovyLedger(origin) || stillSatisfied(origin)) {
continue;
}
revokeCandidateList.add(origin);
}
revokeAll(revokeCandidateList, "撤销依赖已失效消费事实的积分");
}
/// 判断条件积分按原脚本版本和原触发时间重算后是否仍满足
private boolean stillSatisfied(GaoPointLedger origin) {
if (TimiJava.isEmpty(origin.getGroovyScriptVersionId()) || origin.getTriggerSourceType() == null) {
// 无脚本版本的条件积分无法复算,保守起见视为不再满足
return false;
}
GaoEventRecord record = null;
if (origin.getTriggerSourceType() == GaoPointLedger.TriggerSourceType.EVENT_RECORD) {
record = eventRecordServiceProvider.getObject().get(origin.getTriggerSourceId());
if (record == null) {
return false;
}
}
if (matchedTier == null) {
return null;
GaoPointRule replayRule = new GaoPointRule();
replayRule.setRuleMode(GaoPointRule.RuleMode.GROOVY);
replayRule.setTriggerSourceType(switch (origin.getTriggerSourceType()) {
case EVENT_RECORD -> GaoPointRule.TriggerSourceType.EVENT_RECORD;
case SALES_ORDER_PAID -> GaoPointRule.TriggerSourceType.SALES_ORDER_PAID;
});
GaoPointScriptContext context = buildContext(replayRule, origin.getStoreId(), origin.getCustomerId(),
origin.getOccurredAt() == null ? Time.now() : origin.getOccurredAt(),
origin.getTriggerSourceType() == GaoPointLedger.TriggerSourceType.EVENT_RECORD ? GaoPointRule.TriggerRefType.EVENT.name() : null,
record == null ? null : record.getEventId());
Map<String, Object> bindings = new HashMap<>();
bindings.put("context", context);
try {
GroovyScriptExecutionResult<GaoPointDecision> execution = groovyScriptService.executeVersion(origin.getGroovyScriptVersionId(), bindings, GaoPointDecision.class);
GaoPointDecision decision = execution.result();
return decision != null && decision.pointDelta() == origin.getPointDelta();
} catch (RuntimeException e) {
// 脚本版本已不可用或执行失败时按不满足处理,交给撤销流程兜底
return false;
}
long pointDelta = matchedTier.get("pointValue").longValue();
ObjectNode payload = jackson.createObjectNode();
payload.put("triggerType", GaoPointRule.TriggerType.CUSTOMER_EVENT_VALUE_TIER.name());
payload.put("sourceEventId", sourceEventId);
payload.put("sourceValueField", "numberValue");
payload.put("windowBeginAt", windowBeginAt);
payload.put("windowEndAt", windowEndAt);
payload.put("sumValue", sumValue);
ObjectNode tierPayload = payload.putObject("tier");
tierPayload.put("min", matchedTier.get("min").longValue());
JsonNode maxNode = matchedTier.get("max");
if (maxNode != null && !maxNode.isNull()) {
tierPayload.put("max", maxNode.longValue());
}
/// 幂等撤销一批原始积分流水
private void revokeAll(List<GaoPointLedger> originLedgerList, String reason) {
if (originLedgerList == null || originLedgerList.isEmpty()) {
return;
}
List<String> revokeKeyList = originLedgerList.stream().map(origin -> "GAO_POINT_LEDGER:%s:REVOKE".formatted(origin.getId())).toList();
Set<String> revokedKeySet = new HashSet<>();
for (GaoPointLedger ledger : ledgerService.listByIdempotencyKeyList(revokeKeyList)) {
revokedKeySet.add(ledger.getIdempotencyKey());
}
for (GaoPointLedger originLedger : originLedgerList) {
String revokeKey = "GAO_POINT_LEDGER:%s:REVOKE".formatted(originLedger.getId());
if (!revokedKeySet.contains(revokeKey)) {
ledgerService.revoke(originLedger, reason);
}
}
tierPayload.put("pointValue", pointDelta);
payload.put("pointDelta", pointDelta);
return new RuleResolution(pointDelta, payload);
}
private long count(GaoEventRecord record) {
return eventRecordServiceProvider.getObject().count(record.getStoreId(), record.getCustomerId(), record.getEventId(), null, null);
private GaoPointScriptContext eventContext(GaoPointRule rule, GaoEventRecord record) {
return buildContext(rule, record.getStoreId(), record.getCustomerId(), record.getRegisteredAt(),
GaoPointRule.TriggerRefType.EVENT.name(), record.getEventId());
}
private String revokeKey(GaoEventRecord record, GaoPointLedger originLedger) {
return "GAO_EVENT_RECORD:%s:LEDGER:%s:REVOKE".formatted(record.getId(), originLedger.getId());
private GaoPointScriptContext orderContext(GaoPointRule rule, GaoSalesOrder order) {
return buildContext(rule, order.getStoreId(), order.getCustomerId(), order.getPaidAt(), null, null);
}
private record RuleResolution(Long pointDelta, JsonNode sourcePayload) {
private GaoPointScriptContext buildContext(GaoPointRule rule, String storeId, String customerId, long occurredAt, String triggerRefType, String triggerRefId) {
return new GaoPointScriptContext(consumptionService, eventRecordServiceProvider.getObject(), rule, storeId, customerId, triggerRefType, triggerRefId, occurredAt);
}
private boolean isGroovy(GroovyScriptExecutionResult<GaoPointDecision> execution) {
return execution != null && execution.versionId() != null;
}
private boolean isGroovyLedger(GaoPointLedger origin) {
return TimiJava.isNotEmpty(origin.getGroovyScriptVersionId());
}
}
@@ -2,11 +2,12 @@ package com.imyeyu.api.modules.gao.service.implement;
import com.imyeyu.api.bean.ModuleCode;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.service.AttachmentService;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.bean.GaoRoleCode;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.entity.GaoCustomerPackage;
import com.imyeyu.api.modules.gao.entity.GaoEvent;
import com.imyeyu.api.modules.gao.entity.GaoEventRecord;
import com.imyeyu.api.modules.gao.entity.GaoFundRecord;
import com.imyeyu.api.modules.gao.entity.GaoProduct;
@@ -21,6 +22,7 @@ import com.imyeyu.api.modules.gao.service.GaoCustomerPackageService;
import com.imyeyu.api.modules.gao.service.GaoEventRecordService;
import com.imyeyu.api.modules.gao.service.GaoFundRecordService;
import com.imyeyu.api.modules.gao.service.GaoOrderFulfillmentService;
import com.imyeyu.api.modules.gao.service.GaoPointTriggerService;
import com.imyeyu.api.modules.gao.service.GaoProductService;
import com.imyeyu.api.modules.gao.service.GaoProductUnitService;
import com.imyeyu.api.modules.gao.service.GaoSalesOrderService;
@@ -79,6 +81,8 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
private final GaoStoreService storeService;
private final GaoStoreScopeService storeScopeService;
private final GaoEventRecordService eventRecordService;
private final GaoPointTriggerService pointTriggerService;
private final AttachmentService attachmentService;
private final PermissionChecker permissionChecker;
private final RoleChecker roleChecker;
private final UserLoginService userLoginService;
@@ -145,7 +149,6 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
GaoStore store = storeService.get(storeId);
TimiException.required(store, "not found store");
storeScopeService.checkStore(store);
GaoEvent consumeEvent = storeService.validateDirectProductSaleEvent(store);
List<GaoSalesOrderItem> itemList = resolveDirectItems(storeId, request.getItemList());
long saleAmount = 0L;
for (GaoSalesOrderItem item : itemList) {
@@ -155,24 +158,24 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
String operatorUserId = userLoginService.getRequireLoginUserId();
GaoSalesOrder order = newOrder(storeId, customer.getId(), GaoSalesOrder.OrderType.DIRECT_PRODUCT, requestNo, purchasedAt, operatorUserId, request.getRemark());
order.setSaleAmount(saleAmount);
order.setPaymentStatus(saleAmount == 0 ? GaoSalesOrder.PaymentStatus.PAID : GaoSalesOrder.PaymentStatus.UNPAID);
// 零元订单创建时即为全额实收
boolean paidAtCreate = saleAmount == 0;
if (paidAtCreate) {
order.setPaymentStatus(GaoSalesOrder.PaymentStatus.PAID);
order.setPaidAt(Time.now());
} else {
order.setPaymentStatus(GaoSalesOrder.PaymentStatus.UNPAID);
}
super.create(order);
for (GaoSalesOrderItem item : itemList) {
item.setSalesOrderId(order.getId());
itemMapper.insert(item);
}
GaoEventRecord record = new GaoEventRecord();
record.setCustomerId(customer.getId());
record.setStoreId(storeId);
record.setEventId(consumeEvent.getId());
record.setNumberValue(saleAmount);
record.setRegisteredAt(purchasedAt);
record.setRemark(request.getRemark());
record.setAttachmentList(request.getAttachmentList());
GaoEventRecord createdRecord = eventRecordService.createOrderConsumeRecord(record);
TimiException.requiredTrue(1 == mapper.updateSourceEventRecordId(order.getId(), createdRecord.getId()), "订单来源事件写入失败");
order.setSourceEventRecordId(createdRecord.getId());
attachmentService.updateByBizId(Attachment.BizType.GAO_SALES_ORDER, order.getId(), request.getAttachmentList());
order.setItemList(itemList);
if (paidAtCreate) {
pointTriggerService.onSalesOrderPaid(order);
}
return order;
}
@@ -194,22 +197,29 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
if (existing != null) {
TimiException.requiredTrue(existing.getOrderType() == GaoSalesOrder.OrderType.PACKAGE
&& data.getCustomerId().equals(existing.getCustomerId())
&& data.getSaleAmount().equals(existing.getSaleAmount())
&& data.getSourceEventRecordId().equals(existing.getSourceEventRecordId()), "requestNo 已被其他订单使用");
&& data.getSaleAmount().equals(existing.getSaleAmount()), "requestNo 已被其他订单使用");
return existing;
}
GaoCustomer customer = customerService.get(data.getCustomerId());
TimiException.required(customer, "not found customer");
TimiException.requiredTrue(storeId.equals(customer.getStoreId()), "客户不属于当前门店");
TimiException.required(data.getPackageConfigId(), "not found packageConfigId");
TimiException.required(data.getSourceEventRecordId(), "not found sourceEventRecordId");
TimiException.requiredTrue(0 <= data.getSaleAmount(), "成交价不能为负数");
GaoSalesOrder order = newOrder(storeId, customer.getId(), GaoSalesOrder.OrderType.PACKAGE, requestNo,
TimiJava.defaultIfNull(data.getPurchasedAt(), Time.now()), data.getOperatorUserId(), data.getRemark());
order.setSaleAmount(data.getSaleAmount());
order.setSourceEventRecordId(data.getSourceEventRecordId());
order.setPaymentStatus(data.getSaleAmount() == 0 ? GaoSalesOrder.PaymentStatus.PAID : GaoSalesOrder.PaymentStatus.UNPAID);
// 零元订单创建时即为全额实收
boolean paidAtCreate = data.getSaleAmount() == 0;
if (paidAtCreate) {
order.setPaymentStatus(GaoSalesOrder.PaymentStatus.PAID);
order.setPaidAt(Time.now());
} else {
order.setPaymentStatus(GaoSalesOrder.PaymentStatus.UNPAID);
}
super.create(order);
if (paidAtCreate) {
pointTriggerService.onSalesOrderPaid(order);
}
return order;
}
@@ -232,14 +242,12 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
TimiException.requiredTrue(order.getStatus() == GaoSalesOrder.Status.ACTIVE, "当前订单不可取消");
TimiException.requiredTrue(fundRecordService.countPendingReceiveByBizId(order.getId()) == 0, "订单存在待核销收款,不能取消");
TimiException.requiredTrue(fulfillmentServiceProvider.getObject().countPendingBySalesOrderId(order.getId()) == 0, "订单存在待履约操作,不能取消");
GaoEventRecord sourceRecord = order.getSourceEventRecordId() == null ? null : eventRecordService.get(order.getSourceEventRecordId());
if (sourceRecord != null) {
eventRecordService.revokeOrderConsumeRecord(sourceRecord);
}
String userId = userLoginService.getRequireLoginUserId();
GaoSalesOrder.RefundStatus refundStatus = safe(order.getPaidAmount()) == 0 ? GaoSalesOrder.RefundStatus.REFUNDED : GaoSalesOrder.RefundStatus.PENDING;
TimiException.requiredTrue(1 == mapper.cancelIf(order.getId(), GaoSalesOrder.Status.ACTIVE, GaoSalesOrder.Status.CANCELLED,
Time.now(), userId, reason, refundStatus), "订单状态已变化");
// 订单取消立即失去有效消费资格,不等待退款完成
pointTriggerService.revokeBySalesOrder(order, reason);
order.setStatus(GaoSalesOrder.Status.CANCELLED);
order.setCancelledAt(Time.now());
order.setCancelOperatorUserId(userId);
@@ -263,7 +271,7 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
public void updatePayment(GaoSalesOrder order) {
TimiException.required(order, "not found sales order");
TimiException.requiredTrue(1 == mapper.updatePayment(order.getId(), safeLong(order.getPendingPaidAmount()), safeLong(order.getPaidAmount()),
safeLong(order.getRefundedAmount()), order.getPaymentStatus(), order.getRefundStatus()), "订单付款汇总更新失败");
order.getPaidAt(), safeLong(order.getRefundedAmount()), order.getPaymentStatus(), order.getRefundStatus()), "订单付款汇总更新失败");
}
@Override
@@ -275,6 +283,9 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
TimiException.requiredTrue(0 <= pendingAmount && pendingAmount + paidAmount <= saleAmount, "待核销收款超出订单金额");
order.setPaymentStatus(saleAmount == 0 || paidAmount == saleAmount
? GaoSalesOrder.PaymentStatus.PAID : (0 < paidAmount ? GaoSalesOrder.PaymentStatus.PARTIALLY_PAID : GaoSalesOrder.PaymentStatus.UNPAID));
if (order.getPaymentStatus() == GaoSalesOrder.PaymentStatus.PAID && order.getPaidAt() == null) {
order.setPaidAt(Time.now());
}
}
@Override
@@ -80,7 +80,6 @@ public class GaoStoreServiceImplement extends AbstractEntityService<GaoStore, St
try {
logger.setContent(jackson.writeValueAsString(store));
checkUnique(store);
validateDirectProductSaleEventIfPresent(store);
store.setStatus(TimiJava.defaultIfNull(store.getStatus(), GaoStore.Status.ACTIVE));
store.setAutoClosedDay(TimiJava.defaultIfNull(store.getAutoClosedDay(), false));
store.setSort(TimiJava.defaultIfNull(store.getSort(), 0));
@@ -113,7 +112,6 @@ public class GaoStoreServiceImplement extends AbstractEntityService<GaoStore, St
try {
logger.setContent(jackson.writeValueAsString(store));
checkUnique(store);
validateDirectProductSaleEventIfPresent(store);
super.update(store);
logger.setResult(store.getId());
} catch (TimiException e) {
@@ -227,56 +225,6 @@ public class GaoStoreServiceImplement extends AbstractEntityService<GaoStore, St
return mapper.selectByUserId(userId);
}
@Override
public GaoEvent validateDirectProductSaleEvent(GaoStore store) {
TimiException.required(store, "not found store");
TimiException.required(store.getDirectProductSaleEventId(), "门店未配置直接产品消费事件");
TimiException.required(store.getId(), "门店尚未生成 ID,不能绑定直接产品消费事件");
GaoEvent event = eventService.get(store.getDirectProductSaleEventId());
TimiException.required(event, "直接产品消费事件不存在");
TimiException.requiredTrue(store.getId().equals(event.getStoreId()), "直接产品消费事件不属于当前门店");
TimiException.requiredTrue(event.getStatus() == GaoEvent.Status.ACTIVE, "直接产品消费事件未启用");
TimiException.requiredTrue(event.getBizType() == GaoEvent.BizType.GENERAL, "直接产品消费事件必须是 GENERAL 类型");
TimiException.requiredTrue(event.getValueType() == GaoEvent.ValueType.NUMBER && event.getValueUnit() == GaoEvent.ValueUnit.CENT, "直接产品消费事件必须使用 NUMBER/CENT 数值");
TimiException.requiredTrue(!Boolean.TRUE.equals(event.getRequireVerification()), "直接产品消费事件不能要求核销");
eventService.attachOptionGroupList(List.of(event));
for (var group : TimiJava.safeIterable(event.getOptionGroupList())) {
TimiException.requiredTrue(!Boolean.TRUE.equals(group.getRequired()), "直接产品消费事件不能配置必填选项");
}
return event;
}
@Override
public List<GaoEvent> listDirectProductSaleEvents(String storeId) {
TimiException.required(storeId, "not found storeId");
return eventService.listValid(storeId).stream()
.filter(this::isValidDirectProductSaleEvent)
.toList();
}
private boolean isValidDirectProductSaleEvent(GaoEvent event) {
if (event == null
|| event.getStatus() != GaoEvent.Status.ACTIVE
|| event.getBizType() != GaoEvent.BizType.GENERAL
|| event.getValueType() != GaoEvent.ValueType.NUMBER
|| event.getValueUnit() != GaoEvent.ValueUnit.CENT
|| Boolean.TRUE.equals(event.getRequireVerification())) { // 消费事件为 true,订单要求为 false,检查是否设计冲突
return false;
}
for (var group : TimiJava.safeIterable(event.getOptionGroupList())) {
if (Boolean.TRUE.equals(group.getRequired())) {
return false;
}
}
return true;
}
private void validateDirectProductSaleEventIfPresent(GaoStore store) {
if (store.getDirectProductSaleEventId() != null && !store.getDirectProductSaleEventId().isBlank()) {
validateDirectProductSaleEvent(store);
}
}
@Override
public String getBelongIdByRequiredLoginUserId() {
return getByUserId(userLoginService.getRequireLoginUserId()).getId();
@@ -15,8 +15,8 @@ public class GaoCustomerPackagePurchaseRequest {
/// 客户端幂等请求号
private String requestNo;
/// 购买事件 ID
private String eventId;
/// 套餐配置 ID
private String packageConfigId;
/// 客户 ID
private String customerId;
@@ -27,9 +27,6 @@ public class GaoPackageOrderCreateData {
/// 成交价
private Long saleAmount;
/// 来源购买事件记录 ID
private String sourceEventRecordId;
/// 幂等号
private String requestNo;
@@ -0,0 +1,30 @@
CREATE TABLE `groovy_script` (
`id` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`biz_type` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务归属',
`code` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务内唯一代码',
`name` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '名称',
`description` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '说明',
`enabled` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'true 为允许执行已发布版本',
`published_version_id` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '当前发布版本 ID',
`created_at` BIGINT(20),
`updated_at` BIGINT(20),
`deleted_at` BIGINT(20),
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_groovy_script_biz_code` (`biz_type`, `code`),
KEY `idx_groovy_script_published_version` (`published_version_id`),
KEY `idx_groovy_script_deleted_created` (`deleted_at`, `created_at`)
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'Groovy 脚本' ROW_FORMAT = Dynamic;
CREATE TABLE `groovy_script_version` (
`id` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`script_id` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '脚本 ID',
`version` INT NOT NULL COMMENT '版本号',
`content` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '脚本内容',
`checksum` CHAR(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL COMMENT '脚本内容 SHA-256',
`created_at` BIGINT(20),
`updated_at` BIGINT(20),
`deleted_at` BIGINT(20),
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_groovy_script_version` (`script_id`, `version`),
KEY `idx_groovy_script_version_script_created` (`script_id`, `created_at`)
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'Groovy 脚本版本' ROW_FORMAT = Dynamic;
@@ -0,0 +1,62 @@
CREATE TABLE `gao_customer_consumption_record` (
`id` VARCHAR(36) NOT NULL,
`store_id` VARCHAR(36) NOT NULL COMMENT '门店 ID',
`customer_id` VARCHAR(36) NOT NULL COMMENT '客户 ID',
`source_type` VARCHAR(32) NOT NULL COMMENT '来源类型',
`source_id` VARCHAR(36) NOT NULL COMMENT '来源业务对象 ID',
`sales_order_id` VARCHAR(36) NULL COMMENT '销售订单 ID',
`order_type` VARCHAR(32) NULL COMMENT '订单类型',
`amount` BIGINT NOT NULL COMMENT '有效消费金额,单位分',
`effective_at` BIGINT NOT NULL COMMENT '成为有效消费时间',
`status` VARCHAR(32) NOT NULL COMMENT '状态',
`revoked_at` BIGINT NULL COMMENT '失效时间',
`revoke_reason` TEXT NULL COMMENT '失效原因',
`source_payload` JSON NULL COMMENT '来源快照',
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NULL,
`deleted_at` BIGINT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_gao_customer_consumption_source` (`source_type`, `source_id`),
KEY `idx_gao_customer_consumption_customer_time` (`store_id`, `customer_id`, `status`, `effective_at`),
KEY `idx_gao_customer_consumption_order` (`sales_order_id`)
) COMMENT='GAO 客户有效消费事实';
CREATE TABLE `gao_point_ledger_consumption_record` (
`point_ledger_id` VARCHAR(36) NOT NULL COMMENT '积分流水 ID',
`consumption_record_id` VARCHAR(36) NOT NULL COMMENT '消费事实 ID',
`created_at` BIGINT NOT NULL,
PRIMARY KEY (`point_ledger_id`, `consumption_record_id`),
KEY `idx_gao_point_ledger_consumption_record_consumption` (`consumption_record_id`)
) COMMENT='GAO 积分流水消费事实依赖';
ALTER TABLE `gao_sales_order`
ADD COLUMN `paid_at` BIGINT NULL COMMENT '首次全额实收时间' AFTER `paid_amount`;
ALTER TABLE `gao_point_rule`
ADD COLUMN `rule_mode` VARCHAR(16) NULL COMMENT '规则模式' AFTER `status`,
ADD COLUMN `trigger_source_type` VARCHAR(32) NULL COMMENT '触发来源类型' AFTER `rule_mode`,
ADD COLUMN `trigger_ref_type` VARCHAR(32) NULL COMMENT '触发对象类型' AFTER `trigger_source_type`,
ADD COLUMN `trigger_ref_id` VARCHAR(36) NULL COMMENT '触发对象 ID' AFTER `trigger_ref_type`,
ADD COLUMN `groovy_script_id` VARCHAR(36) NULL COMMENT 'Groovy 脚本 ID' AFTER `point_value`,
ADD KEY `idx_gao_point_rule_trigger` (`store_id`, `trigger_source_type`, `trigger_ref_id`, `status`, `deleted_at`, `priority`);
ALTER TABLE `gao_point_ledger`
ADD COLUMN `trigger_source_type` VARCHAR(32) NULL COMMENT '直接触发来源类型' AFTER `rule_id`,
ADD COLUMN `trigger_source_id` VARCHAR(36) NULL COMMENT '直接触发来源 ID' AFTER `trigger_source_type`,
ADD COLUMN `groovy_script_version_id` VARCHAR(36) NULL COMMENT '实际执行 Groovy 脚本版本 ID' AFTER `trigger_source_id`,
ADD COLUMN `rule_snapshot` JSON NULL COMMENT '规则快照' AFTER `groovy_script_version_id`,
ADD KEY `idx_gao_point_ledger_trigger_source` (`trigger_source_type`, `trigger_source_id`, `change_type`);
UPDATE `gao_point_rule`
SET `rule_mode` = 'SIMPLE',
`trigger_source_type` = 'EVENT_RECORD',
`trigger_ref_type` = 'GAO_EVENT',
`trigger_ref_id` = `event_id`
WHERE `trigger_type` = 'EVERY_TIME'
AND `point_mode` = 'FIXED'
AND `condition_json` IS NULL;
UPDATE `gao_point_ledger`
SET `trigger_source_type` = 'EVENT_RECORD',
`trigger_source_id` = `event_record_id`
WHERE `event_record_id` IS NOT NULL;
@@ -0,0 +1,23 @@
INSERT INTO `groovy_script` (`id`, `biz_type`, `code`, `name`, `description`, `enabled`, `published_version_id`, `created_at`)
VALUES
('f2511e0e-6e55-4a59-a221-700000000001', 'GAO_POINT_RULE', 'SIGN_IN_CONSUMPTION_TIER', '签到按消费分档积分', '签到时按过去一年有效消费分档积分', TRUE, 'f2511e0e-6e55-4a59-a221-700000000101', 1787248052654),
('f2511e0e-6e55-4a59-a221-700000000002', 'GAO_POINT_RULE', 'SIGN_IN_AFTER_THIRTY', '无消费累计签到积分', '第 30 次签到及以后每次积分', TRUE, 'f2511e0e-6e55-4a59-a221-700000000102', 1787041413269);
INSERT INTO `groovy_script_version` (`id`, `script_id`, `version`, `content`, `checksum`, `created_at`)
VALUES
('f2511e0e-6e55-4a59-a221-700000000101', 'f2511e0e-6e55-4a59-a221-700000000001', 1,
'long amount = context.rollingYearConsumptionAmount()\nif (amount < 1L) return null\nlong point = amount < 200000L ? 20L : amount < 2000000L ? 50L : amount < 5000000L ? 80L : 100L\nreturn context.decision(point, "签到消费分档积分", [amount: amount, window: "ROLLING_1_YEAR"])',
SHA2('long amount = context.rollingYearConsumptionAmount()\nif (amount < 1L) return null\nlong point = amount < 200000L ? 20L : amount < 2000000L ? 50L : amount < 5000000L ? 80L : 100L\nreturn context.decision(point, "签到消费分档积分", [amount: amount, window: "ROLLING_1_YEAR"])', 256), 1787248052654),
('f2511e0e-6e55-4a59-a221-700000000102', 'f2511e0e-6e55-4a59-a221-700000000002', 1,
'if (context.eventCount() < 30L) return null\nreturn context.decision(10L, "第 30 次及以后签到积分", [eventCount: context.eventCount()])',
SHA2('if (context.eventCount() < 30L) return null\nreturn context.decision(10L, "第 30 次及以后签到积分", [eventCount: context.eventCount()])', 256), 1787041413269);
UPDATE `gao_point_rule`
SET `rule_mode` = 'GROOVY', `trigger_source_type` = 'EVENT_RECORD', `trigger_ref_type` = 'EVENT',
`trigger_ref_id` = `event_id`, `groovy_script_id` = 'f2511e0e-6e55-4a59-a221-700000000001', `point_value` = NULL
WHERE `id` = '1746879e-3726-4927-95a6-a3b738cbc134';
UPDATE `gao_point_rule`
SET `rule_mode` = 'GROOVY', `trigger_source_type` = 'EVENT_RECORD', `trigger_ref_type` = 'EVENT',
`trigger_ref_id` = `event_id`, `groovy_script_id` = 'f2511e0e-6e55-4a59-a221-700000000002', `point_value` = NULL
WHERE `id` = '1b71dbb8-be2e-4783-9209-e6a94a8745b4';
@@ -0,0 +1,44 @@
ALTER TABLE `gao_point_ledger`
ADD COLUMN `sales_order_id` VARCHAR(36) NULL COMMENT '关联销售订单 ID' AFTER `trigger_source_id`,
ADD KEY `idx_gao_point_ledger_sales_order` (`sales_order_id`);
-- 将旧规则 sourceEventId 指向的登记事件标记为退役消费事件
UPDATE `gao_event` e
SET e.`biz_type` = 'LEGACY_CONSUMPTION'
WHERE e.`deleted_at` IS NULL
AND e.`biz_type` <> 'PACKAGE_PURCHASE'
AND e.`id` IN (
SELECT DISTINCT JSON_UNQUOTE(JSON_EXTRACT(r.`condition_json`, '$.sourceEventId'))
FROM `gao_point_rule` r
WHERE r.`condition_json` IS NOT NULL
AND JSON_EXTRACT(r.`condition_json`, '$.sourceEventId') IS NOT NULL
);
-- 历史消费事件迁移为 LEGACY_EVENT 消费事实,来源唯一约束保证可重跑
-- 不能使用 INSERT IGNORE,否则数据异常会被静默吞掉,导致消费累计缺失
INSERT INTO `gao_customer_consumption_record`
(`id`, `store_id`, `customer_id`, `source_type`, `source_id`, `sales_order_id`, `order_type`, `amount`, `effective_at`, `status`, `revoked_at`, `revoke_reason`, `source_payload`, `created_at`, `updated_at`, `deleted_at`)
SELECT
UUID(),
r.`store_id`,
r.`customer_id`,
'LEGACY_EVENT',
r.`id`,
NULL,
NULL,
r.`number_value`,
r.`registered_at`,
'ACTIVE',
NULL,
NULL,
JSON_OBJECT('migrationBatch', 'V66', 'eventId', r.`event_id`, 'eventName', e.`name`, 'eventRecordId', r.`id`, 'registeredAt', r.`registered_at`),
FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000),
NULL,
NULL
FROM `gao_event_record` r
INNER JOIN `gao_event` e ON e.`id` = r.`event_id` AND e.`deleted_at` IS NULL
WHERE e.`biz_type` = 'LEGACY_CONSUMPTION'
AND r.`number_value` IS NOT NULL
AND r.`number_value` > 0
AND r.`deleted_at` IS NULL
ON DUPLICATE KEY UPDATE `id` = `id`;
@@ -0,0 +1,4 @@
-- V64 曾把枚举值误写为 GAO_EVENT,MyBatis 读取时会无法映射到 TriggerRefType.EVENT
UPDATE `gao_point_rule`
SET `trigger_ref_type` = 'EVENT'
WHERE `trigger_ref_type` = 'GAO_EVENT';
@@ -0,0 +1,20 @@
<?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.GaoCustomerConsumptionRecordMapper">
<select id="selectBySource" resultType="com.imyeyu.api.modules.gao.entity.GaoCustomerConsumptionRecord">
SELECT * FROM `gao_customer_consumption_record`
WHERE `source_type` = #{sourceType} AND `source_id` = #{sourceId} AND `deleted_at` IS NULL
LIMIT 1
</select>
<select id="selectActiveByWindow" resultType="com.imyeyu.api.modules.gao.entity.GaoCustomerConsumptionRecord">
SELECT * FROM `gao_customer_consumption_record`
WHERE `store_id` = #{storeId} AND `customer_id` = #{customerId} AND `status` = 'ACTIVE'
AND `effective_at` &gt;= #{beginAt} AND `effective_at` &lt;= #{endAt} AND `deleted_at` IS NULL
ORDER BY `effective_at` ASC, `id` ASC
</select>
<update id="revokeBySource">
UPDATE `gao_customer_consumption_record`
SET `status` = 'REVOKED', `revoked_at` = #{revokedAt}, `revoke_reason` = #{revokeReason}, `updated_at` = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000)
WHERE `source_type` = #{sourceType} AND `source_id` = #{sourceId} AND `status` = 'ACTIVE' AND `deleted_at` IS NULL
</update>
</mapper>
@@ -26,6 +26,7 @@
WHERE
`store_id` = #{storeId}
AND `status` = '${@com.imyeyu.api.modules.gao.entity.GaoEvent$Status@ACTIVE.name()}'
AND (`biz_type` IS NULL OR `biz_type` != 'LEGACY_CONSUMPTION')
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
@@ -119,6 +119,12 @@
#{eventId}
</foreach>
</if>
<if test="eventIdList == null or eventIdList.size() == 0">
AND NOT EXISTS (
SELECT 1 FROM `gao_event` re
WHERE re.`id` = r.`event_id` AND re.`biz_type` = 'LEGACY_CONSUMPTION' AND re.`deleted_at` IS NULL
)
</if>
<if test="optionIdList != null and optionIdList.size() > 0">
<foreach collection="optionIdList" item="optionId">
AND EXISTS (
@@ -212,6 +218,12 @@
#{eventId}
</foreach>
</if>
<if test="eventIdList == null or eventIdList.size() == 0">
AND NOT EXISTS (
SELECT 1 FROM `gao_event` re
WHERE re.`id` = r.`event_id` AND re.`biz_type` = 'LEGACY_CONSUMPTION' AND re.`deleted_at` IS NULL
)
</if>
<if test="rankType != null and rankType.name() == 'TOTAL_NUMBER_VALUE'">
AND r.`number_value` IS NOT NULL
</if>
@@ -386,6 +398,12 @@
#{eventId}
</foreach>
</if>
<if test="eventIdList == null or eventIdList.size() == 0">
AND NOT EXISTS (
SELECT 1 FROM `gao_event` re
WHERE re.`id` = r.`event_id` AND re.`biz_type` = 'LEGACY_CONSUMPTION' AND re.`deleted_at` IS NULL
)
</if>
</sql>
<select id="stateDailyByRange" resultType="com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView">
SELECT
@@ -447,6 +465,12 @@
#{eventId}
</foreach>
</if>
<if test="eventIdList == null or eventIdList.size() == 0">
AND NOT EXISTS (
SELECT 1 FROM `gao_event` re
WHERE re.`id` = r.`event_id` AND re.`biz_type` = 'LEGACY_CONSUMPTION' AND re.`deleted_at` IS NULL
)
</if>
</sql>
<select id="stateCustomerDailyByRange" resultType="com.imyeyu.api.modules.gao.vo.GaoEventRecordDailyStatView">
SELECT
@@ -9,15 +9,26 @@
LIMIT 1
</select>
<select id="selectRuleChangeByEventRecordId" resultType="com.imyeyu.api.modules.gao.entity.GaoPointLedger">
<select id="selectRuleChangeByTriggerSource" resultType="com.imyeyu.api.modules.gao.entity.GaoPointLedger">
SELECT *
FROM `gao_point_ledger`
WHERE `event_record_id` = #{eventRecordId}
WHERE `trigger_source_type` = #{triggerSourceType}
AND `trigger_source_id` = #{triggerSourceId}
AND `change_type` IN ('EARN', 'CONSUME')
AND `deleted_at` IS NULL
ORDER BY `created_at` ASC, `id` ASC
</select>
<select id="selectRuleChangeByConsumptionRecordId" resultType="com.imyeyu.api.modules.gao.entity.GaoPointLedger">
SELECT l.*
FROM `gao_point_ledger` l
INNER JOIN `gao_point_ledger_consumption_record` d ON d.`point_ledger_id` = l.`id`
WHERE d.`consumption_record_id` = #{consumptionRecordId}
AND l.`change_type` IN ('EARN', 'CONSUME')
AND l.`deleted_at` IS NULL
ORDER BY l.`created_at` ASC, l.`id` ASC
</select>
<select id="selectByIdempotencyKeyList" resultType="com.imyeyu.api.modules.gao.entity.GaoPointLedger">
SELECT *
FROM `gao_point_ledger`
@@ -11,11 +11,16 @@
</foreach>
</select>
<select id="selectActiveByStoreIdAndEventId" resultType="com.imyeyu.api.modules.gao.entity.GaoPointRule">
<select id="selectActiveByTrigger" resultType="com.imyeyu.api.modules.gao.entity.GaoPointRule">
SELECT *
FROM `gao_point_rule`
WHERE `store_id` = #{storeId}
AND `event_id` = #{eventId}
AND `trigger_source_type` = #{triggerSourceType}
AND (`trigger_ref_id` IS NULL
<if test="triggerRefId != null and triggerRefId != ''">
OR `trigger_ref_id` = #{triggerRefId}
</if>
)
AND `status` = 'ACTIVE'
AND (`begin_at` IS NULL OR `begin_at` &lt;= #{now})
AND (`end_at` IS NULL OR #{now} &lt;= `end_at`)
@@ -33,7 +33,7 @@
</select>
<update id="updatePayment">
UPDATE `gao_sales_order`
SET `pending_paid_amount` = #{pendingPaidAmount}, `paid_amount` = #{paidAmount}, `refunded_amount` = #{refundedAmount},
SET `pending_paid_amount` = #{pendingPaidAmount}, `paid_amount` = #{paidAmount}, `paid_at` = #{paidAt}, `refunded_amount` = #{refundedAmount},
`payment_status` = #{paymentStatus}, `refund_status` = #{refundStatus}, `updated_at` = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000)
WHERE `id` = #{id} AND `deleted_at` IS NULL
</update>
@@ -43,11 +43,6 @@
`cancel_reason` = #{cancelReason}, `refund_status` = #{refundStatus}, `updated_at` = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000)
WHERE `id` = #{id} AND `status` = #{fromStatus} AND `deleted_at` IS NULL
</update>
<update id="updateSourceEventRecordId">
UPDATE `gao_sales_order`
SET `source_event_record_id` = #{sourceEventRecordId}, `updated_at` = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000)
WHERE `id` = #{id} AND `source_event_record_id` IS NULL AND `deleted_at` IS NULL
</update>
<update id="updateStatus">
UPDATE `gao_sales_order`
SET `status` = #{status}, `updated_at` = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000)