From 41f692aad91a1a32e92da64cb265356b713dd8ac Mon Sep 17 00:00:00 2001 From: Timi Date: Fri, 4 Sep 2026 14:55:21 +0800 Subject: [PATCH] add groovy script --- pom.xml | 5 + .../bean/GroovyScriptExecutionResult.java | 9 + .../api/modules/common/entity/Attachment.java | 2 + .../modules/common/entity/GroovyScript.java | 42 +++ .../common/entity/GroovyScriptVersion.java | 26 ++ .../common/mapper/GroovyScriptMapper.java | 11 + .../mapper/GroovyScriptVersionMapper.java | 11 + .../common/service/GroovyScriptService.java | 120 +++++++ .../GroovyScriptServiceImplement.java | 293 +++++++++++++++++ .../gao/controller/GaoStoreController.java | 11 - .../entity/GaoCustomerConsumptionRecord.java | 44 +++ .../api/modules/gao/entity/GaoEvent.java | 5 +- .../modules/gao/entity/GaoPointLedger.java | 33 +- .../api/modules/gao/entity/GaoPointRule.java | 67 ++-- .../api/modules/gao/entity/GaoSalesOrder.java | 3 + .../api/modules/gao/entity/GaoStore.java | 3 - .../GaoCustomerConsumptionRecordMapper.java | 20 ++ .../gao/mapper/GaoEventSQLProvider.java | 2 +- ...GaoPointLedgerConsumptionRecordMapper.java | 38 +++ .../gao/mapper/GaoPointLedgerMapper.java | 13 +- .../gao/mapper/GaoPointRuleMapper.java | 5 +- .../gao/mapper/GaoSalesOrderMapper.java | 5 +- .../modules/gao/script/GaoPointDecision.java | 17 + .../gao/script/GaoPointScriptContext.java | 154 +++++++++ .../GaoCustomerConsumptionRecordService.java | 54 ++++ .../gao/service/GaoEventRecordService.java | 17 - .../gao/service/GaoPointLedgerService.java | 46 ++- .../gao/service/GaoPointRuleService.java | 9 +- .../gao/service/GaoPointTriggerService.java | 16 +- .../modules/gao/service/GaoStoreService.java | 13 - ...omerConsumptionRecordServiceImplement.java | 80 +++++ .../GaoCustomerPackageServiceImplement.java | 52 +-- .../GaoEventRecordServiceImplement.java | 37 +-- .../implement/GaoEventServiceImplement.java | 5 + .../GaoOrderPaymentServiceImplement.java | 36 ++- .../GaoPointLedgerServiceImplement.java | 127 ++++++-- .../GaoPointRuleServiceImplement.java | 161 ++-------- .../GaoPointTriggerServiceImplement.java | 300 +++++++++++------- .../GaoSalesOrderServiceImplement.java | 59 ++-- .../implement/GaoStoreServiceImplement.java | 52 --- .../vo/GaoCustomerPackagePurchaseRequest.java | 4 +- .../gao/vo/GaoPackageOrderCreateData.java | 3 - .../V63__create_groovy_script_tables.sql | 30 ++ ...gao_consumption_and_point_script_model.sql | 62 ++++ ...V65__migrate_gao_point_rules_to_groovy.sql | 23 ++ ..._migrate_gao_legacy_consumption_events.sql | 44 +++ ...7__fix_gao_point_rule_trigger_ref_type.sql | 4 + .../GaoCustomerConsumptionRecordMapper.xml | 20 ++ .../mapper/timi-server/gao/GaoEventMapper.xml | 1 + .../timi-server/gao/GaoEventRecordMapper.xml | 24 ++ .../timi-server/gao/GaoPointLedgerMapper.xml | 15 +- .../timi-server/gao/GaoPointRuleMapper.xml | 9 +- .../timi-server/gao/GaoSalesOrderMapper.xml | 7 +- 53 files changed, 1708 insertions(+), 541 deletions(-) create mode 100644 src/main/java/com/imyeyu/api/modules/common/bean/GroovyScriptExecutionResult.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/entity/GroovyScript.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/entity/GroovyScriptVersion.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/mapper/GroovyScriptMapper.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/mapper/GroovyScriptVersionMapper.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/service/GroovyScriptService.java create mode 100644 src/main/java/com/imyeyu/api/modules/common/service/implement/GroovyScriptServiceImplement.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomerConsumptionRecord.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/mapper/GaoCustomerConsumptionRecordMapper.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerConsumptionRecordMapper.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/script/GaoPointDecision.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/script/GaoPointScriptContext.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/service/GaoCustomerConsumptionRecordService.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerConsumptionRecordServiceImplement.java create mode 100644 src/main/resources/db/migration/timiserver/V63__create_groovy_script_tables.sql create mode 100644 src/main/resources/db/migration/timiserver/V64__add_gao_consumption_and_point_script_model.sql create mode 100644 src/main/resources/db/migration/timiserver/V65__migrate_gao_point_rules_to_groovy.sql create mode 100644 src/main/resources/db/migration/timiserver/V66__migrate_gao_legacy_consumption_events.sql create mode 100644 src/main/resources/db/migration/timiserver/V67__fix_gao_point_rule_trigger_ref_type.sql create mode 100644 src/main/resources/mapper/timi-server/gao/GaoCustomerConsumptionRecordMapper.xml diff --git a/pom.xml b/pom.xml index d7cfc82..73fc325 100644 --- a/pom.xml +++ b/pom.xml @@ -180,6 +180,11 @@ + + org.apache.groovy + groovy + 4.0.29 + com.imyeyu.spring timi-spring diff --git a/src/main/java/com/imyeyu/api/modules/common/bean/GroovyScriptExecutionResult.java b/src/main/java/com/imyeyu/api/modules/common/bean/GroovyScriptExecutionResult.java new file mode 100644 index 0000000..22de318 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/bean/GroovyScriptExecutionResult.java @@ -0,0 +1,9 @@ +package com.imyeyu.api.modules.common.bean; + +/// Groovy 脚本执行结果 +/// +/// @param 脚本返回值类型 +/// @author Codex +/// @since 2026-09-04 +public record GroovyScriptExecutionResult(String scriptId, String versionId, Integer version, String checksum, T result) { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java b/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java index 64f8561..3b586cc 100644 --- a/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java +++ b/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java @@ -48,6 +48,8 @@ public class Attachment extends UUIDEntity { GAO_ORDER_FULFILLMENT, + GAO_SALES_ORDER, + TEMP_FILE, MIRROR diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/GroovyScript.java b/src/main/java/com/imyeyu/api/modules/common/entity/GroovyScript.java new file mode 100644 index 0000000..a6c3d02 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/entity/GroovyScript.java @@ -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; +} diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/GroovyScriptVersion.java b/src/main/java/com/imyeyu/api/modules/common/entity/GroovyScriptVersion.java new file mode 100644 index 0000000..f8c57f1 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/entity/GroovyScriptVersion.java @@ -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; +} diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/GroovyScriptMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/GroovyScriptMapper.java new file mode 100644 index 0000000..b7e16f6 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/GroovyScriptMapper.java @@ -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 { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/GroovyScriptVersionMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/GroovyScriptVersionMapper.java new file mode 100644 index 0000000..f563e21 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/GroovyScriptVersionMapper.java @@ -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 { +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/GroovyScriptService.java b/src/main/java/com/imyeyu/api/modules/common/service/GroovyScriptService.java new file mode 100644 index 0000000..eee5ef4 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/GroovyScriptService.java @@ -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 { + + /// 按业务归属和代码查询脚本 + /// + /// @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 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 bindings); + + /// 执行已发布且启用的脚本并返回实际执行版本 + /// + /// @param bizType 业务归属 + /// @param code 业务内唯一代码 + /// @param bindings 传给脚本的变量 + /// @param resultType 返回值类型 + /// @param 返回值类型 + /// @return 脚本执行结果 + GroovyScriptExecutionResult executePublished(GroovyScript.BizType bizType, String code, Map bindings, Class resultType); + + /// 按指定历史版本执行脚本 + /// + /// 用于按积分发生时的脚本版本复算,脚本当前是否发布或启用不影响执行;脚本返回 null 表示未命中,属合法结果 + /// + /// @param versionId 脚本版本 ID + /// @param bindings 传给脚本的变量 + /// @param resultType 返回值类型 + /// @param 返回值类型 + /// @return 脚本执行结果 + GroovyScriptExecutionResult executeVersion(String versionId, Map bindings, Class resultType); + + /// 执行已发布且启用的脚本并验证返回值类型 + /// + /// @param bizType 业务归属 + /// @param code 业务内唯一代码 + /// @param bindings 传给脚本的变量 + /// @param resultType 返回值类型 + /// @param 返回值类型 + /// @return 脚本返回值 + T execute(GroovyScript.BizType bizType, String code, Map bindings, Class resultType); + + /// 清除脚本的本地编译缓存 + /// + /// 多实例部署时,其他节点在收到脚本变更通知后调用此方法 + /// + /// @param scriptId 脚本 ID + void invalidate(String scriptId); +} diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/GroovyScriptServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/GroovyScriptServiceImplement.java new file mode 100644 index 0000000..890571c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/GroovyScriptServiceImplement.java @@ -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 implements GroovyScriptService { + + private final GroovyScriptMapper mapper; + private final GroovyScriptVersionMapper versionMapper; + private final Map compiledScriptMap = new ConcurrentHashMap<>(); + + @Override + protected BaseMapper 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 listVersion(String scriptId) { + TimiException.required(scriptId, "未找到脚本 ID"); + GroovyScriptVersion example = new GroovyScriptVersion(); + example.setScriptId(scriptId); + List 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 bindings) { + return executePublished(bizType, code, bindings, Object.class).result(); + } + + @Override + public GroovyScriptExecutionResult executePublished(GroovyScript.BizType bizType, String code, Map bindings, Class 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 result = executeVersion(script.getPublishedVersionId(), bindings, resultType); + TimiException.requiredTrue(script.getId().equals(result.scriptId()), "Groovy 脚本已发布版本无效"); + return result; + } + + @Override + public T execute(GroovyScript.BizType bizType, String code, Map bindings, Class resultType) { + return executePublished(bizType, code, bindings, resultType).result(); + } + + @Override + public GroovyScriptExecutionResult executeVersion(String versionId, Map bindings, Class 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 type, GroovyClassLoader classLoader) { + + private Object execute(Map 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) { + } + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoStoreController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoStoreController.java index 667d914..bf1086d 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoStoreController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoStoreController.java @@ -65,16 +65,6 @@ public class GaoStoreController { return store; } - @AOPLog - @RequestRateLimit - @RequireGaoPermission(GaoPermissionCode.STORE_UPDATE) - @PostMapping("/direct-product-sale-event/list") - public List 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); } } diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomerConsumptionRecord.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomerConsumptionRecord.java new file mode 100644 index 0000000..74d3ff0 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomerConsumptionRecord.java @@ -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; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoEvent.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoEvent.java index c48b270..f9c087b 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoEvent.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoEvent.java @@ -25,7 +25,10 @@ public class GaoEvent extends UUIDEntity { GENERAL, /// 购买产品套餐 - PACKAGE_PURCHASE + PACKAGE_PURCHASE, + + /// 已退役的历史消费事件,仅保留归档和溯源 + LEGACY_CONSUMPTION } /// diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java index f0e6826..97a385c 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointLedger.java @@ -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; diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRule.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRule.java index a17a27c..eba636a 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRule.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoPointRule.java @@ -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; diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoSalesOrder.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoSalesOrder.java index b3c43e9..6bb8783 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoSalesOrder.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoSalesOrder.java @@ -96,6 +96,9 @@ public class GaoSalesOrder extends UUIDEntity { /// 有效收款,单位分 private Long paidAmount; + /// 首次全额实收时间 + private Long paidAt; + /// 有效退款,单位分 private Long refundedAmount; diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoStore.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoStore.java index 20ab456..07ea5f7 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoStore.java +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoStore.java @@ -54,9 +54,6 @@ public class GaoStore extends UUIDEntity { /// 排序值 private Integer sort; - /// 直接产品订单使用的消费事件 ID - private String directProductSaleEventId; - public boolean isActivated() { return status == Status.ACTIVE; } diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoCustomerConsumptionRecordMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoCustomerConsumptionRecordMapper.java new file mode 100644 index 0000000..a9c7261 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoCustomerConsumptionRecordMapper.java @@ -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 selectBySource(@Param("sourceType") GaoCustomerConsumptionRecord.SourceType sourceType, @Param("sourceId") String sourceId); + + List 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); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoEventSQLProvider.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoEventSQLProvider.java index 6777a3e..ce14ce9 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoEventSQLProvider.java +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoEventSQLProvider.java @@ -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()) ); } } diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerConsumptionRecordMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerConsumptionRecordMapper.java new file mode 100644 index 0000000..9e486ed --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerConsumptionRecordMapper.java @@ -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 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); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerMapper.java index 197349d..4a8e7c8 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerMapper.java +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointLedgerMapper.java @@ -19,11 +19,18 @@ public interface GaoPointLedgerMapper extends BaseMapper /// @return 流水 GaoPointLedger selectByIdempotencyKey(@Param("idempotencyKey") String idempotencyKey); - /// 查询登记记录产生的原始规则积分流水 + /// 查询触发来源产生的原始规则积分流水 /// - /// @param eventRecordId 登记记录 ID + /// @param triggerSourceType 直接触发来源类型 + /// @param triggerSourceId 直接触发来源 ID /// @return 原始规则积分流水 - List selectRuleChangeByEventRecordId(@Param("eventRecordId") String eventRecordId); + List selectRuleChangeByTriggerSource(@Param("triggerSourceType") GaoPointLedger.TriggerSourceType triggerSourceType, @Param("triggerSourceId") String triggerSourceId); + + /// 查询依赖指定消费事实的原始规则积分流水 + /// + /// @param consumptionRecordId 消费事实 ID + /// @return 原始规则积分流水 + List selectRuleChangeByConsumptionRecordId(@Param("consumptionRecordId") String consumptionRecordId); /// 批量查询幂等键对应的流水 /// diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRuleMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRuleMapper.java index 97a1fe1..af230c4 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRuleMapper.java +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoPointRuleMapper.java @@ -18,8 +18,9 @@ public interface GaoPointRuleMapper extends BaseMapper { /// 查询当前时间可匹配的启用规则 /// /// @param storeId 门店 ID - /// @param eventId 登记事件 ID + /// @param triggerSourceType 直接触发来源类型 + /// @param triggerRefId 触发对象筛选 ID,空为不限 /// @param now 当前时间戳 /// @return 按优先级排序的规则列表 - List selectActiveByStoreIdAndEventId(@Param("storeId") String storeId, @Param("eventId") String eventId, @Param("now") long now); + List selectActiveByTrigger(@Param("storeId") String storeId, @Param("triggerSourceType") GaoPointRule.TriggerSourceType triggerSourceType, @Param("triggerRefId") String triggerRefId, @Param("now") long now); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoSalesOrderMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoSalesOrderMapper.java index f80b86e..7829995 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoSalesOrderMapper.java +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoSalesOrderMapper.java @@ -27,7 +27,7 @@ public interface GaoSalesOrderMapper extends BaseMapper { /// 更新订单付款汇总 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 { @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); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/script/GaoPointDecision.java b/src/main/java/com/imyeyu/api/modules/gao/script/GaoPointDecision.java new file mode 100644 index 0000000..0465bfc --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/script/GaoPointDecision.java @@ -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 conditionSnapshot, List consumptionRecordIdList) { +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/script/GaoPointScriptContext.java b/src/main/java/com/imyeyu/api/modules/gao/script/GaoPointScriptContext.java new file mode 100644 index 0000000..cd98c2c --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/script/GaoPointScriptContext.java @@ -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 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 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 conditionSnapshot, List consumptionRecordIdList) { + TimiException.requiredTrue(pointDelta != 0, "脚本积分变化不能为 0"); + Map snapshot = conditionSnapshot == null ? new LinkedHashMap<>() : new LinkedHashMap<>(conditionSnapshot); + List> consumptionSummary = new ArrayList<>(); + for (Map.Entry entry : consumptionFactMap.entrySet()) { + Map 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 deps = new ArrayList<>(consumptionFactMap.keySet()); + for (String id : consumptionRecordIdList == null ? List.of() : consumptionRecordIdList) { + if (id != null && !deps.contains(id)) { + deps.add(id); + } + } + return new GaoPointDecision(pointDelta, reason, snapshot, deps); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoCustomerConsumptionRecordService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoCustomerConsumptionRecordService.java new file mode 100644 index 0000000..8ec9e2e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoCustomerConsumptionRecordService.java @@ -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 { + + /// 写入销售订单消费事实 + /// + /// 幂等,重复执行返回已有事实 + /// + /// @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 listActiveByWindow(String storeId, String customerId, long beginAt, long endAt); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java index f832fc4..a798883 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoEventRecordService.java @@ -104,23 +104,6 @@ public interface GaoEventRecordService extends BaseService { - /// 写入登记规则积分流水 + /// 写入规则发放或扣减积分流水 + /// + /// 幂等,重复触发返回已有流水;积分流水只追加,不修改不删除 /// - /// @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 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 listRuleChangeByEventRecordId(String eventRecordId); + List listRuleChangeByTriggerSource(GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId); + + /// 查询依赖指定消费事实的原始规则积分流水 + /// + /// @param consumptionRecordId 消费事实 ID + /// @return 原始积分流水 + List listRuleChangeByConsumptionRecordId(String consumptionRecordId); /// 按幂等键批量查询积分流水 /// diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRuleService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRuleService.java index 9bae0b8..8a5d24d 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRuleService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointRuleService.java @@ -13,11 +13,14 @@ public interface GaoPointRuleService extends BaseService { /// 查询当前时间可匹配的启用规则 /// + /// 触发对象筛选 ID 为空时匹配未设置触发对象引用的规则 + /// /// @param storeId 门店 ID - /// @param eventId 登记事件 ID + /// @param triggerSourceType 直接触发来源类型 + /// @param triggerRefId 触发对象筛选 ID,可空 /// @param now 当前时间戳 - /// @return 规则列表 - List listActive(String storeId, String eventId, long now); + /// @return 按优先级排序的规则列表 + List listActive(String storeId, GaoPointRule.TriggerSourceType triggerSourceType, String triggerRefId, long now); /// 查询规则 ID 列表 /// diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointTriggerService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointTriggerService.java index b2e47db..0b7fea9 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointTriggerService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoPointTriggerService.java @@ -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 人工调整请求 diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoStoreService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoStoreService.java index 0c35476..b62d724 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/GaoStoreService.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoStoreService.java @@ -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 { - /// 校验直接产品订单使用的消费事件 - /// - /// @param store 门店 - /// @return 合法的消费事件 - GaoEvent validateDirectProductSaleEvent(GaoStore store); - - /// 查询可绑定到直接产品订单的消费事件 - /// - /// @param storeId 门店 ID - /// @return 当前门店可绑定的消费事件 - List listDirectProductSaleEvents(String storeId); - /// 查询用户所属门店 /// /// @param userId 用户 ID diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerConsumptionRecordServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerConsumptionRecordServiceImplement.java new file mode 100644 index 0000000..73f9917 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerConsumptionRecordServiceImplement.java @@ -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 implements GaoCustomerConsumptionRecordService { + + private final GaoCustomerConsumptionRecordMapper mapper; + + @Override + protected BaseMapper 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 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); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerPackageServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerPackageServiceImplement.java index 71eb264..c2aa17a 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerPackageServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerPackageServiceImplement.java @@ -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 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 defaultPurchaseItems(List configItemList) { return configItemList.stream().map(configItem -> { GaoCustomerPackagePurchaseItem item = new GaoCustomerPackagePurchaseItem(); @@ -350,11 +322,11 @@ public class GaoCustomerPackageServiceImplement extends AbstractEntityService itemList, List configItemList) { - TimiException.requiredTrue(itemList.size() == configItemList.size(), "套餐权益项目必须与事件绑定套餐一致"); + TimiException.requiredTrue(itemList.size() == configItemList.size(), "套餐权益项目必须与套餐配置一致"); Map 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 existingItemList = itemMapper.selectByCustomerPackageId(existingPackage.getId()); diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoEventRecordServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoEventRecordServiceImplement.java index 84670d6..f90b65c 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoEventRecordServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoEventRecordServiceImplement.java @@ -214,30 +214,13 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService 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 canonicalOptionList = null; if (record.getSelectedOptionGroupList() != null) { @@ -428,6 +406,7 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService { 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; diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java index 2ba9ff4..06c77d0 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoPointLedgerServiceImplement.java @@ -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 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 execution, + GaoPointLedger.TriggerSourceType triggerSourceType, String triggerSourceId, String salesOrderId, + String storeId, String customerId, Long occurredAt, String operatorUserId, List 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 listRuleChangeByEventRecordId(String eventRecordId) { - TimiException.required(eventRecordId, "not found eventRecordId"); - return mapper.selectRuleChangeByEventRecordId(eventRecordId); + public List 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 listRuleChangeByConsumptionRecordId(String consumptionRecordId) { + TimiException.required(consumptionRecordId, "not found consumptionRecordId"); + return mapper.selectRuleChangeByConsumptionRecordId(consumptionRecordId); } @Override @@ -145,7 +171,62 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService 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 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 mapper() { @@ -41,10 +41,10 @@ public class GaoPointRuleServiceImplement extends AbstractEntityService listActive(String storeId, String eventId, long now) { + public List 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 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 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 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 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 revokedKeySet = new HashSet<>(); - List 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 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 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 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 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 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 dependentList = ledgerService.listRuleChangeByConsumptionRecordId(consumptionRecordId); + if (dependentList.isEmpty()) { + return; + } + List 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 bindings = new HashMap<>(); + bindings.put("context", context); + try { + GroovyScriptExecutionResult 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 originLedgerList, String reason) { + if (originLedgerList == null || originLedgerList.isEmpty()) { + return; + } + List revokeKeyList = originLedgerList.stream().map(origin -> "GAO_POINT_LEDGER:%s:REVOKE".formatted(origin.getId())).toList(); + Set 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 execution) { + return execution != null && execution.versionId() != null; + } + + private boolean isGroovyLedger(GaoPointLedger origin) { + return TimiJava.isNotEmpty(origin.getGroovyScriptVersionId()); } } diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoSalesOrderServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoSalesOrderServiceImplement.java index 198a006..62322d6 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoSalesOrderServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoSalesOrderServiceImplement.java @@ -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 itemList = resolveDirectItems(storeId, request.getItemList()); long saleAmount = 0L; for (GaoSalesOrderItem item : itemList) { @@ -155,24 +158,24 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService 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(); diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerPackagePurchaseRequest.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerPackagePurchaseRequest.java index c87ceef..b6f7e57 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerPackagePurchaseRequest.java +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerPackagePurchaseRequest.java @@ -15,8 +15,8 @@ public class GaoCustomerPackagePurchaseRequest { /// 客户端幂等请求号 private String requestNo; - /// 购买事件 ID - private String eventId; + /// 套餐配置 ID + private String packageConfigId; /// 客户 ID private String customerId; diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPackageOrderCreateData.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPackageOrderCreateData.java index 0433636..bfcded5 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPackageOrderCreateData.java +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoPackageOrderCreateData.java @@ -27,9 +27,6 @@ public class GaoPackageOrderCreateData { /// 成交价 private Long saleAmount; - /// 来源购买事件记录 ID - private String sourceEventRecordId; - /// 幂等号 private String requestNo; diff --git a/src/main/resources/db/migration/timiserver/V63__create_groovy_script_tables.sql b/src/main/resources/db/migration/timiserver/V63__create_groovy_script_tables.sql new file mode 100644 index 0000000..2309aef --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V63__create_groovy_script_tables.sql @@ -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; diff --git a/src/main/resources/db/migration/timiserver/V64__add_gao_consumption_and_point_script_model.sql b/src/main/resources/db/migration/timiserver/V64__add_gao_consumption_and_point_script_model.sql new file mode 100644 index 0000000..d14f80b --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V64__add_gao_consumption_and_point_script_model.sql @@ -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; diff --git a/src/main/resources/db/migration/timiserver/V65__migrate_gao_point_rules_to_groovy.sql b/src/main/resources/db/migration/timiserver/V65__migrate_gao_point_rules_to_groovy.sql new file mode 100644 index 0000000..ccb4858 --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V65__migrate_gao_point_rules_to_groovy.sql @@ -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'; diff --git a/src/main/resources/db/migration/timiserver/V66__migrate_gao_legacy_consumption_events.sql b/src/main/resources/db/migration/timiserver/V66__migrate_gao_legacy_consumption_events.sql new file mode 100644 index 0000000..4321c74 --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V66__migrate_gao_legacy_consumption_events.sql @@ -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`; diff --git a/src/main/resources/db/migration/timiserver/V67__fix_gao_point_rule_trigger_ref_type.sql b/src/main/resources/db/migration/timiserver/V67__fix_gao_point_rule_trigger_ref_type.sql new file mode 100644 index 0000000..849de79 --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V67__fix_gao_point_rule_trigger_ref_type.sql @@ -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'; diff --git a/src/main/resources/mapper/timi-server/gao/GaoCustomerConsumptionRecordMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoCustomerConsumptionRecordMapper.xml new file mode 100644 index 0000000..79f092c --- /dev/null +++ b/src/main/resources/mapper/timi-server/gao/GaoCustomerConsumptionRecordMapper.xml @@ -0,0 +1,20 @@ + + + + + + + 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 + + diff --git a/src/main/resources/mapper/timi-server/gao/GaoEventMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoEventMapper.xml index 9b08997..9c5283e 100644 --- a/src/main/resources/mapper/timi-server/gao/GaoEventMapper.xml +++ b/src/main/resources/mapper/timi-server/gao/GaoEventMapper.xml @@ -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` <= #{now}) AND (`end_at` IS NULL OR #{now} <= `end_at`) AND `deleted_at` IS NULL diff --git a/src/main/resources/mapper/timi-server/gao/GaoEventRecordMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoEventRecordMapper.xml index 869d9c6..55ee1e1 100644 --- a/src/main/resources/mapper/timi-server/gao/GaoEventRecordMapper.xml +++ b/src/main/resources/mapper/timi-server/gao/GaoEventRecordMapper.xml @@ -119,6 +119,12 @@ #{eventId} + + 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 + ) + AND EXISTS ( @@ -212,6 +218,12 @@ #{eventId} + + 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 + ) + AND r.`number_value` IS NOT NULL @@ -386,6 +398,12 @@ #{eventId} + + 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 + ) + SELECT diff --git a/src/main/resources/mapper/timi-server/gao/GaoPointLedgerMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoPointLedgerMapper.xml index 1589b75..6fc810c 100644 --- a/src/main/resources/mapper/timi-server/gao/GaoPointLedgerMapper.xml +++ b/src/main/resources/mapper/timi-server/gao/GaoPointLedgerMapper.xml @@ -9,15 +9,26 @@ LIMIT 1 - 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 * FROM `gao_point_rule` WHERE `store_id` = #{storeId} - AND `event_id` = #{eventId} + AND `trigger_source_type` = #{triggerSourceType} + AND (`trigger_ref_id` IS NULL + + OR `trigger_ref_id` = #{triggerRefId} + + ) AND `status` = 'ACTIVE' AND (`begin_at` IS NULL OR `begin_at` <= #{now}) AND (`end_at` IS NULL OR #{now} <= `end_at`) diff --git a/src/main/resources/mapper/timi-server/gao/GaoSalesOrderMapper.xml b/src/main/resources/mapper/timi-server/gao/GaoSalesOrderMapper.xml index 09fc2bd..520e968 100644 --- a/src/main/resources/mapper/timi-server/gao/GaoSalesOrderMapper.xml +++ b/src/main/resources/mapper/timi-server/gao/GaoSalesOrderMapper.xml @@ -33,7 +33,7 @@ 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 @@ -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 `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 `gao_sales_order` SET `status` = #{status}, `updated_at` = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000)