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