Compare commits

...
15 Commits
Author SHA1 Message Date
timi 99b44faf09 Merge pull request 'v1.0.15' (#23) from dev into master
Reviewed-on: #23
2026-08-15 17:45:26 +00:00
Timi 7f6cebd4e2 v1.0.15
CI / build-deploy (pull_request) Failing after 6s
CI / notify-on-failure (pull_request) Successful in 0s
2026-08-16 01:44:45 +08:00
Timi 75fd4bed18 fix event save fail for limit == 1 2026-08-15 15:01:08 +08:00
timi b3e3cbf84b Merge pull request 'v1.0.14' (#22) from dev into master
Reviewed-on: #22
2026-08-14 17:31:11 +00:00
Timi 8db6f962c9 v1.0.14
CI / build-deploy (pull_request) Successful in 22s
CI / notify-on-failure (pull_request) Skipped
2026-08-15 01:30:51 +08:00
timi 0aee11d824 Merge pull request 'v1.0.14' (#21) from dev into master
Reviewed-on: #21
2026-08-14 17:26:10 +00:00
Timi e35cf3aa59 1.0.14
CI / build-deploy (pull_request) Failing after 10s
CI / notify-on-failure (pull_request) Successful in 0s
2026-08-15 01:25:08 +08:00
Timi 457e716b38 add totalNumberValue for rank 2026-08-14 18:58:04 +08:00
Timi ab4a164680 return NumberValueTotal for list 2026-08-14 18:44:53 +08:00
Timi 9dfca25f8c update point tier rule 2026-08-14 18:26:38 +08:00
Timi e369a38e2e add Gao user invite 2026-08-14 11:57:10 +08:00
Timi f1e10df6a7 rename Gao GlobalManager role name 2026-08-14 11:31:25 +08:00
timi fcf358a768 Merge pull request 'v1.0.13' (#20) from dev into master
Reviewed-on: #20
2026-08-13 09:52:26 +00:00
Timi 36e2a37af2 v1.0.13
CI / build-deploy (pull_request) Successful in 21s
CI / notify-on-failure (pull_request) Skipped
2026-08-13 17:52:03 +08:00
Timi 348f7396a1 add point log 2026-08-13 14:04:07 +08:00
52 changed files with 1597 additions and 247 deletions
+16 -1
View File
@@ -11,7 +11,7 @@
<groupId>com.imyeyu.timiserverapi</groupId>
<artifactId>TimiServerAPI</artifactId>
<version>1.0.12</version>
<version>1.0.15</version>
<packaging>jar</packaging>
<name>TimiServerAPI</name>
<description>imyeyu.com API</description>
@@ -167,6 +167,16 @@
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>e2ins_nexus</id>
<url>https://nexus.e2ins.com/repository/maven-public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<dependencies>
@@ -225,6 +235,11 @@
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>com.e2ins.ms.notify</groupId>
<artifactId>e2ins-msg-notify-service-sdk</artifactId>
<version>0.0.9</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
@@ -1,5 +1,6 @@
package com.imyeyu.api.config;
import com.e2ins.ms.notify.SmsNotifyService;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
@@ -31,6 +32,12 @@ public class BeanConfig {
.build();
}
@Bean
public SmsNotifyService smsNotifyService() {
// TODO
return new SmsNotifyService(null, null);
}
@Bean
public Yaml yaml() {
return new Yaml();
@@ -2,6 +2,7 @@ package com.imyeyu.api.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imyeyu.api.modules.common.entity.Multilingual;
import com.imyeyu.api.modules.gao.bean.GaoUserInviteCache;
import com.imyeyu.spring.bean.RedisConfigParams;
import com.imyeyu.spring.config.AbstractRedisConfig;
import com.imyeyu.spring.util.Redis;
@@ -202,4 +203,10 @@ public class RedisConfig extends AbstractRedisConfig {
public Redis<String, String> getClipboardRedisTemplate() {
return getRedis(database, RedisSerializers.STRING, RedisSerializers.STRING);
}
/// @return GAO 用户邀请,code: 邀请缓存
@Bean("redisGaoUserInvite")
public Redis<String, GaoUserInviteCache> getGaoUserInviteRedisTemplate() {
return getRedis(database, RedisSerializers.STRING, RedisSerializers.jacksonSerializer(jackson, GaoUserInviteCache.class));
}
}
@@ -32,6 +32,8 @@ public enum SettingModule {
TEMP_FILE(Setting.Module.TempFile.class),
USER(Setting.Module.User.class),
SYSTEM(Setting.Module.System.class),
SYSTEM_STATUS(Setting.Module.SystemStatus.class);
@@ -14,6 +14,21 @@ import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
public class Tag extends UUIDEntity {
///
///
/// @author 夜雨
/// @since 2026-08-12 16:49
public enum OwnerType {
GAO_STORE
}
/// 归属类型
protected OwnerType ownerType;
/// 归属 ID
protected String ownerId;
/// 名称多语言 ID
protected String nameLangId;
@@ -2,6 +2,7 @@ package com.imyeyu.api.modules.common.mapper;
import com.imyeyu.api.modules.common.entity.Tag;
import com.imyeyu.spring.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import java.util.Collection;
import java.util.List;
@@ -13,4 +14,6 @@ import java.util.List;
public interface TagMapper extends BaseMapper<Tag, String> {
List<Tag> selectByIdList(Collection<String> idList);
Tag selectByOwnerAndZhCN(@Param("ownerType") Tag.OwnerType ownerType, @Param("ownerId") String ownerId, @Param("zhCN") String zhCN);
}
@@ -1,10 +1,10 @@
package com.imyeyu.api.modules.common.service;
import com.imyeyu.api.modules.common.bean.SettingModule;
import com.imyeyu.api.modules.common.bean.attachment.SettingSupport;
import com.imyeyu.api.modules.common.entity.Setting;
import com.imyeyu.java.TimiJava;
import com.imyeyu.spring.service.BaseService;
import com.imyeyu.utils.Text;
import java.util.List;
@@ -19,7 +19,7 @@ public interface SettingService extends BaseService<Setting, String> {
Setting getByCode(String bizType, String bizId, String code);
default Setting getSystem(Enum<?> bizType) {
return getByCode(Text.bizClassName(bizType.getClass()), TimiJava.ZERO_UUID, bizType.toString());
return getByCode(SettingModule.fromClass(bizType.getClass()).name(), TimiJava.ZERO_UUID, bizType.toString());
}
List<Setting> listByBizId(String bizType, String bizId);
@@ -40,6 +40,9 @@ public class TagServiceImplement extends AbstractEntityService<Tag, String> impl
public void create(Tag tag) {
TimiException.required(tag, "not found tag");
TimiException.required(tag.getZhCN(), "not found tag.zhCN");
TimiException.required(tag.getOwnerType(), "not found ownerType");
TimiException.required(tag.getOwnerId(), "not found ownerId");
checkZhCNUniqueInOwner(tag, null);
tag.setNameLangId(multilingualService.create(tag.getZhCN()));
super.create(tag);
}
@@ -48,12 +51,15 @@ public class TagServiceImplement extends AbstractEntityService<Tag, String> impl
public void update(Tag tag) {
TimiException.required(tag, "not found tag");
TimiException.required(tag.getId(), "not found tag.id");
if (tag.getZhCN() != null) {
if (tag.getNameLangId() == null) {
Tag dbTag = get(tag.getId());
TimiException.required(dbTag, "not found tag");
tag.setOwnerType(dbTag.getOwnerType());
tag.setOwnerId(dbTag.getOwnerId());
if (tag.getZhCN() != null) {
if (tag.getNameLangId() == null) {
tag.setNameLangId(dbTag.getNameLangId());
}
checkZhCNUniqueInOwner(tag, tag.getId());
tag.setNameLangId(multilingualService.createIfDifferent(tag.getNameLangId(), tag.getZhCN()));
}
super.update(tag);
@@ -97,4 +103,9 @@ public class TagServiceImplement extends AbstractEntityService<Tag, String> impl
example.setBizId(bizId);
return applyMapper.selectAllByExample(example);
}
private void checkZhCNUniqueInOwner(Tag tag, String allowedId) {
Tag existed = mapper.selectByOwnerAndZhCN(tag.getOwnerType(), tag.getOwnerId(), tag.getZhCN());
TimiException.requiredTrue(existed == null || existed.getId().equals(allowedId), "该标签名称已存在");
}
}
@@ -84,7 +84,7 @@ public enum GaoPermissionCode implements BuiltinPermissionCode {
@Getter
final boolean grantToStoreManager;
/// `true` 为初始化时授权给全局管理角色
/// `true` 为初始化时授权给门店管理角色
@Getter
final boolean grantToGlobalManager;
@@ -12,8 +12,8 @@ import lombok.AllArgsConstructor;
@AllArgsConstructor
public enum GaoRoleCode implements BuiltinRoleCode {
/// 全局管理
GLOBAL_MANAGER("全局管理", null),
/// 门店管理
GLOBAL_MANAGER("门店管理", null),
/// 店长
STORE_MANAGER("店长", GLOBAL_MANAGER.name());
@@ -0,0 +1,29 @@
package com.imyeyu.api.modules.gao.bean;
import lombok.Data;
/// GAO 用户邀请缓存
///
/// @author Codex
/// @since 2026-08-13
@Data
public class GaoUserInviteCache {
/// 发起邀请的用户 ID
private String inviterUserId;
/// 门店 IDGLOBAL_MANAGER 可为空
private String storeId;
/// 角色 ID
private String roleId;
/// 邀请来源或场景
private String scene;
/// 邀请创建时间,毫秒时间戳
private Long createdAt;
/// 邀请过期时间,毫秒时间戳
private Long expireAt;
}
@@ -12,6 +12,7 @@ import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoStoreService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerTrendStateReq;
import com.imyeyu.api.modules.user.service.RoleChecker;
import com.imyeyu.api.modules.user.service.UserLoginService;
@@ -60,8 +61,8 @@ public class GaoCustomerController {
@RequireGaoPermission(GaoPermissionCode.CUSTOMER_READ)
@PostMapping("/list")
@JsonView(ResponseView.Public.class)
public PageResult<GaoCustomer> list(@RequestBody Page<GaoCustomer> page) {
PageResult<GaoCustomer> result = service.page(page);
public PageResult<GaoCustomer> list(@RequestBody GaoCustomerPage page) {
PageResult<GaoCustomer> result = service.pageByQuery(page);
List<String> idList = result.getList().stream().map(GaoCustomer::getId).distinct().toList();
Map<String, List<Attachment>> attachmentMap = attachmentService.mapByBizIdList(Attachment.BizType.GAO_CUSTOMER, idList);
@@ -62,7 +62,9 @@ public class GaoEventController {
@RequireGaoPermission(GaoPermissionCode.EVENT_READ)
@PostMapping("/detail")
public GaoEvent detail(@RequestParam String id) {
return service.get(id);
GaoEvent event = service.get(id);
event.setRoleCodeList(service.listRoleCode(event.getId()));
return event;
}
@AOPLog
@@ -16,6 +16,7 @@ import com.imyeyu.api.modules.gao.service.GaoEventService;
import com.imyeyu.api.modules.gao.service.GaoStoreService;
import com.imyeyu.api.modules.gao.vo.GaoCustomerEventRankView;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordPage;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordPageResult;
import com.imyeyu.api.modules.gao.vo.GaoEventRecordRankPage;
import com.imyeyu.api.modules.user.service.RoleChecker;
import com.imyeyu.api.modules.user.service.UserLoginService;
@@ -64,8 +65,12 @@ public class GaoEventRecordController {
@RequireGaoPermission(GaoPermissionCode.EVENT_RECORD_READ)
@PostMapping("/list")
@JsonView(ResponseView.Public.class)
public PageResult<GaoEventRecord> list(@RequestBody GaoEventRecordPage page) {
PageResult<GaoEventRecord> result = service.pageByRange(page);
public GaoEventRecordPageResult list(@RequestBody GaoEventRecordPage page) {
PageResult<GaoEventRecord> pageResult = service.pageByRange(page);
GaoEventRecordPageResult result = new GaoEventRecordPageResult();
result.setTotal(pageResult.getTotal());
result.setList(pageResult.getList());
result.setNumberValueTotal(resolveNumberValueTotal(page));
{
List<String> customerIdList = result.getList().stream().map(GaoEventRecord::getCustomerId).toList();
Map<String, GaoEvent> eventMap = eventService.mapByIdList(result.getList().stream().map(GaoEventRecord::getEventId).toList());
@@ -80,6 +85,22 @@ public class GaoEventRecordController {
return result;
}
/// 根据列表筛选条件返回单个 NUMBER 事件的数值合计
///
/// @param page 登记记录范围查询请求
/// @return 数值合计,不满足单个 NUMBER 事件条件时为空
private Long resolveNumberValueTotal(GaoEventRecordPage page) {
List<String> eventIdList = page.getEventIdList();
if (eventIdList == null || eventIdList.size() != 1) {
return null;
}
GaoEvent event = eventService.mapByIdList(eventIdList).get(eventIdList.get(0));
if (event == null || event.getValueType() != GaoEvent.ValueType.NUMBER) {
return null;
}
return service.sumNumberValueByRange(page);
}
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.EVENT_RECORD_READ)
@@ -0,0 +1,139 @@
package com.imyeyu.api.modules.gao.controller;
import com.fasterxml.jackson.annotation.JsonView;
import com.imyeyu.api.annotation.RequireCorePermission;
import com.imyeyu.api.bean.CorePermissionCode;
import com.imyeyu.api.bean.ModuleCode;
import com.imyeyu.api.modules.common.entity.Tag;
import com.imyeyu.api.modules.common.entity.TagApply;
import com.imyeyu.api.modules.common.service.TagService;
import com.imyeyu.api.modules.gao.bean.GaoRoleCode;
import com.imyeyu.api.modules.gao.service.GaoStoreService;
import com.imyeyu.api.modules.user.service.RoleChecker;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.annotation.AOPLog;
import com.imyeyu.spring.annotation.RequestRateLimit;
import com.imyeyu.spring.bean.Page;
import com.imyeyu.spring.bean.PageResult;
import com.imyeyu.spring.util.ResponseView;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/// GAO 标签接口
///
/// @author Codex
/// @since 2026-08-13
@RestController
@RequiredArgsConstructor
@RequestMapping("/gao/tag")
public class GaoTagController {
private final TagService service;
private final RoleChecker roleChecker;
private final GaoStoreService storeService;
/// 查询标签列表
///
/// @param page 分页参数
/// @return 标签分页
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.TAG_READ)
@PostMapping("/list")
public PageResult<Tag> list(@RequestBody Page<Tag> page) {
TimiException.required(page, "未找到分页参数");
page.setEqualsExample(TimiJava.defaultIfNull(page.getEqualsExample(), new Tag()));
page.getEqualsExample().setOwnerType(Tag.OwnerType.GAO_STORE);
if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name())) {
page.getEqualsExample().setOwnerId(storeService.getBelongIdByRequiredLoginUserId());
}
return service.page(page);
}
/// 查询标签详情
///
/// @param id 标签 ID
/// @return 标签详情
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.TAG_READ)
@PostMapping("/detail")
public Tag detail(@RequestParam String id) {
return service.get(id);
}
/// 创建标签
///
/// @param tag 标签
/// @return 标签
@AOPLog
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.TAG_CREATE)
@PostMapping("/create")
public Tag create(@RequestBody Tag tag) {
tag.setOwnerType(Tag.OwnerType.GAO_STORE);
if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name())) {
tag.setOwnerId(storeService.getBelongIdByRequiredLoginUserId());
}
service.create(tag);
return tag;
}
/// 更新标签
///
/// @param tag 标签
@AOPLog
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.TAG_UPDATE)
@PostMapping("/update")
public void update(@RequestBody Tag tag) {
Tag dbTag = service.get(tag.getId());
tag.setOwnerType(dbTag.getOwnerType());
tag.setOwnerId(dbTag.getOwnerId());
service.update(tag);
}
/// 删除标签
///
/// @param id 标签 ID
@AOPLog
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.TAG_DELETE)
@PostMapping("/delete")
public void delete(@RequestParam String id) {
service.delete(id);
}
/// 按业务查询标签
///
/// @param bizType 业务类型
/// @param bizId 业务 ID
/// @return 标签列表
@JsonView(ResponseView.Public.class)
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.TAG_READ)
@GetMapping("/list/biz/id")
public List<Tag> listByBizType(@RequestParam TagApply.BizType bizType, @RequestParam String bizId) {
return service.listByBizType(bizType, bizId);
}
/// 差分保存业务标签
///
/// @param apply 标签请求
@AOPLog
@RequestRateLimit
@RequireCorePermission(CorePermissionCode.TAG_APPLY)
@PostMapping("/apply")
public void apply(@RequestBody TagApply apply) {
service.apply(apply.getBizType(), apply.getBizId(), apply.getTagIdList());
}
}
@@ -9,7 +9,14 @@ import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.bean.GaoRoleCode;
import com.imyeyu.api.modules.gao.entity.GaoUser;
import com.imyeyu.api.modules.gao.service.GaoStoreService;
import com.imyeyu.api.modules.gao.service.GaoUserInviteService;
import com.imyeyu.api.modules.gao.service.GaoUserService;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteCreateRequest;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteCreateResponse;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteRegisterData;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteRegisterRequest;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteValidateRequest;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteValidateResponse;
import com.imyeyu.api.modules.user.entity.Role;
import com.imyeyu.api.modules.user.entity.User;
import com.imyeyu.api.modules.user.service.RoleChecker;
@@ -17,12 +24,15 @@ import com.imyeyu.api.modules.user.service.RoleService;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.api.modules.user.service.UserRoleService;
import com.imyeyu.api.modules.user.service.UserService;
import com.imyeyu.api.modules.user.vo.LoginResponse;
import com.imyeyu.java.TimiJava;
import com.imyeyu.spring.annotation.AOPLog;
import com.imyeyu.spring.annotation.CaptchaValid;
import com.imyeyu.spring.annotation.RequestRateLimit;
import com.imyeyu.spring.bean.Page;
import com.imyeyu.spring.bean.PageResult;
import com.imyeyu.spring.util.ResponseView;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -32,6 +42,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/// GAO 用户接口
///
@@ -46,11 +57,38 @@ public class GaoUserController {
private final RoleService roleService;
private final RoleChecker roleChecker;
private final GaoUserService service;
private final GaoUserInviteService inviteService;
private final UserRoleService userRoleService;
private final GaoStoreService gaoStoreService;
private final UserLoginService userLoginService;
private final AttachmentService attachmentService;
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.USER_CREATE)
@PostMapping("/invite")
public GaoUserInviteCreateResponse invite(@RequestBody @Valid GaoUserInviteCreateRequest req) {
return inviteService.create(req);
}
@AOPLog
@RequestRateLimit(20)
@PostMapping("/invite/validate")
public GaoUserInviteValidateResponse validateInvite(@RequestBody @Valid GaoUserInviteValidateRequest req) {
return inviteService.validate(req.getCode());
}
@AOPLog
@JsonView(ResponseView.Public.class)
@CaptchaValid
@RequestRateLimit(value = 5, inSeconds = 60)
@PostMapping("/invite/register")
public LoginResponse registerByInvite(@RequestBody @Valid GaoUserInviteRegisterRequest req) {
GaoUserInviteRegisterData data = req.getData();
String userId = inviteService.register(data);
return userLoginService.login(UUID.randomUUID().toString(), userId);
}
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.USER_READ)
@@ -137,6 +175,7 @@ public class GaoUserController {
@RequireGaoPermission(GaoPermissionCode.USER_READ)
@PostMapping("/role/list")
public List<Role> roleList() {
// TODO 此接口获取本模块所有角色,需要限制角色
return roleService.listByModuleCode(ModuleCode.GAO);
}
}
@@ -38,6 +38,26 @@ public class GaoEvent extends UUIDEntity {
DELETED
}
/// 事件数值类型
public enum ValueType {
/// 不携带数值
NONE,
/// 携带整数数值
NUMBER
}
/// 事件数值单位
public enum ValueUnit {
/// 金额分
CENT,
/// 次数
COUNT
}
/// 登记限制类型
///
/// @author 夜雨
@@ -70,6 +90,9 @@ public class GaoEvent extends UUIDEntity {
final Long min;
}
/// 统计图表颜色
private String color;
/// 登记事件名称
private String name;
@@ -79,8 +102,14 @@ public class GaoEvent extends UUIDEntity {
/// 说明
private String description;
/// 统计图表颜色
private String color;
/// 数值类型
private ValueType valueType;
/// 数值单位
private ValueUnit valueUnit;
/// true 为登记时必须提交 numberValue
private Boolean requireValue;
/// 状态
private Status status;
@@ -40,6 +40,15 @@ public class GaoEventRecord extends UUIDEntity {
/// 操作管理员用户 ID
private String operatorUserId;
/// 本次登记提交的积分值
private Long pointValue;
/// 本次登记通过积分规则获得的积分总和
private Long earnedPointValue;
/// 本次登记提交的整数业务数值
private Long numberValue;
/// 备注
private String remark;
@@ -52,10 +61,6 @@ public class GaoEventRecord extends UUIDEntity {
/// 营业日递增序号
private Long businessDayNo;
/// 本次登记提交的积分值,只参与积分触发,不落库
@Transient
private Long pointValue;
@Transient
private GaoCustomer customer;
@@ -42,7 +42,10 @@ public class GaoPointRule extends UUIDEntity {
AFTER_COUNT_EVERY_TIME,
/// 使用登记接口提交的积分值触发
SOURCE_VALUE
SOURCE_VALUE,
/// 按来源事件数值累计分档触发
CUSTOMER_EVENT_VALUE_TIER
}
/// 积分值模式
@@ -52,7 +55,10 @@ public class GaoPointRule extends UUIDEntity {
FIXED,
/// 使用登记接口提交的积分值
SOURCE_VALUE
SOURCE_VALUE,
/// 使用条件配置中的分档积分值
TIER
}
/// 门店 ID
@@ -3,6 +3,7 @@ package com.imyeyu.api.modules.gao.mapper;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
import com.imyeyu.spring.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
@@ -35,4 +36,16 @@ public interface GaoCustomerMapper extends BaseMapper<GaoCustomer, String> {
///
/// @return 性别统计列表
List<GaoCustomerGenderStatView> stateGender(@Param("storeId") String storeId);
/// 查询客户总数
///
/// @param page 查询条件
/// @return 客户总数
long countByQuery(GaoCustomerPage page);
/// 分页查询客户
///
/// @param page 查询条件
/// @return 客户列表
List<GaoCustomer> selectByQuery(GaoCustomerPage page);
}
@@ -21,6 +21,28 @@ public interface GaoEventRecordMapper extends BaseMapper<GaoEventRecord, String>
Long count(String storeId, String customerId, String eventId, Long beginAt, Long endAt);
/// 查询事件中已携带数值的登记记录数量
///
/// @param eventId 事件 ID
/// @return 携带数值的登记记录数量
Long countByEventIdAndNumberValue(String eventId);
/// 汇总客户指定来源事件在时间范围内的数值
///
/// @param storeId 门店 ID
/// @param customerId 客户 ID
/// @param eventId 来源事件 ID
/// @param beginAt 起始时间,包含
/// @param endAt 结束时间,不包含
/// @return 数值总和
Long sumNumberValue(@Param("storeId") String storeId, @Param("customerId") String customerId, @Param("eventId") String eventId, @Param("beginAt") Long beginAt, @Param("endAt") Long endAt);
/// 回写登记记录最终获得的积分快照
///
/// @param id 登记记录 ID
/// @param earnedPointValue 获得积分总和
void updateEarnedPointValue(@Param("id") String id, @Param("earnedPointValue") Long earnedPointValue);
/// 按门店和登记日期统计登记记录数
///
/// @param storeId 门店 ID
@@ -42,6 +64,12 @@ public interface GaoEventRecordMapper extends BaseMapper<GaoEventRecord, String>
/// @return 登记事件记录列表
List<GaoEventRecord> selectByRange(GaoEventRecordPage page);
/// 按登记记录列表条件汇总数值
///
/// @param page 登记记录范围查询请求
/// @return 登记数值合计
Long sumNumberValueByRange(GaoEventRecordPage page);
/// 统计客户登记排行榜总数
///
/// @param page 排行榜分页请求
@@ -3,8 +3,10 @@ package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerTrendStateReq;
import com.imyeyu.spring.bean.Page;
import com.imyeyu.spring.bean.PageResult;
import com.imyeyu.spring.service.BaseService;
import java.io.IOException;
@@ -19,6 +21,12 @@ import java.util.stream.Collectors;
/// @since 2026-07-27 14:12
public interface GaoCustomerService extends BaseService<GaoCustomer, String> {
/// 按查询条件分页查询客户
///
/// @param page 查询条件
/// @return 客户分页
PageResult<GaoCustomer> pageByQuery(GaoCustomerPage page);
void updateWithAttachment(GaoCustomer customer);
/// 按编码查询客户
@@ -32,6 +32,12 @@ public interface GaoEventRecordService extends BaseService<GaoEventRecord, Strin
/// @return 登记事件记录列表
PageResult<GaoEventRecord> pageByRange(GaoEventRecordPage req);
/// 按登记记录列表条件汇总数值
///
/// @param req 登记记录范围查询请求
/// @return 登记数值合计
Long sumNumberValueByRange(GaoEventRecordPage req);
/// 按时间范围查询客户登记排行榜
///
/// @param page 范围查询请求,不传时间为全部时间
@@ -0,0 +1,31 @@
package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteCreateRequest;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteCreateResponse;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteRegisterData;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteValidateResponse;
/// GAO 用户邀请注册服务
///
/// @author Codex
/// @since 2026-08-13
public interface GaoUserInviteService {
/// 创建 GAO 用户邀请
///
/// @param req 邀请创建请求
/// @return 邀请 code 和过期时间
GaoUserInviteCreateResponse create(GaoUserInviteCreateRequest req);
/// 校验 GAO 用户邀请是否可用
///
/// @param code 邀请 code
/// @return 校验结果
GaoUserInviteValidateResponse validate(String code);
/// 使用邀请注册 GAO 用户
///
/// @param data 注册数据
/// @return 注册成功的用户 ID
String register(GaoUserInviteRegisterData data);
}
@@ -10,6 +10,7 @@ import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.util.CustomerXSSFBuilder;
import com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerGenderStatView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerPage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerTrendStateReq;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
@@ -18,6 +19,7 @@ import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiCode;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.bean.Page;
import com.imyeyu.spring.bean.PageResult;
import com.imyeyu.spring.mapper.BaseMapper;
import com.imyeyu.spring.service.AbstractEntityService;
import lombok.RequiredArgsConstructor;
@@ -65,6 +67,15 @@ public class GaoCustomerServiceImplement extends AbstractEntityService<GaoCustom
return mapper;
}
@Override
public PageResult<GaoCustomer> pageByQuery(GaoCustomerPage page) {
TimiException.required(page, "not found page");
PageResult<GaoCustomer> result = new PageResult<>();
result.setTotal(mapper.countByQuery(page));
result.setList(mapper.selectByQuery(page));
return result;
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void create(GaoCustomer customer) {
@@ -87,7 +87,9 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
String loginUserId = userLoginService.getRequireLoginUserId();
Long registeredAt = TimiJava.defaultIfNull(record.getRegisteredAt(), Time.now());
GaoEvent event = eventService.get(record.getEventId());
TimiException.required(event, "not found event");
TimiException.required(event.getStoreId(), "not found event.storeId");
verifyRecordValue(event, record.getNumberValue());
// 检查权限
checkEventPermission(event);
int registeredDateValue = dateValue(registeredAt);
@@ -119,6 +121,8 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
dbRecord.setRegisteredDateValue(registeredDateValue);
dbRecord.setBusinessDayNo(businessDayNo);
dbRecord.setPointValue(record.getPointValue());
dbRecord.setNumberValue(record.getNumberValue());
dbRecord.setEarnedPointValue(0L);
super.create(dbRecord);
// 附件
if (i == 0) {
@@ -170,24 +174,17 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
Logger logger = new Logger(Logger.Module.GAO, "GAO_EVENT_RECORD_UPDATE");
try {
logger.setContent(jackson.writeValueAsString(record));
TimiException.required(record.getCustomerId(), "not found record.customerId");
TimiException.required(record.getEventId(), "not found record.eventId");
GaoEventRecord dbRecord = get(record.getId());
TimiException.required(dbRecord, "not found event record");
TimiException.required(record.getStoreId() == null || record.getStoreId().equals(dbRecord.getStoreId()), "record.storeId invalid");
verifyImmutableRecordFields(record, dbRecord);
record.setRegisteredAt(TimiJava.defaultIfNull(record.getRegisteredAt(), Time.now()));
GaoEvent event = eventService.get(record.getEventId());
GaoEvent event = eventService.get(dbRecord.getEventId());
TimiException.required(event, "not found event");
TimiException.required(event.getStoreId(), "not found event.storeId");
checkEventPermission(event);
checkEventCreatable(record.getCustomerId(), event, record.getRegisteredAt());
GaoCustomer customer = customerService.get(record.getCustomerId());
TimiException.required(customer, "not found customer");
TimiException.requiredTrue(event.getStoreId().equals(customer.getStoreId()), "客户和登记事件不属于同一门店");
int registeredDateValue = dateValue(record.getRegisteredAt());
record.setStoreId(event.getStoreId());
record.setRegisteredDateValue(registeredDateValue);
record.setBusinessDayNo(businessDayService.resolveOpenBusinessDay(event.getStoreId(), registeredDateValue).getBusinessDayNo());
super.update(record);
dbRecord.setRemark(record.getRemark());
super.update(dbRecord);
attachmentService.updateByBizId(Attachment.BizType.GAO_EVENT_RECORD, record.getId(), record.getAttachmentList());
logger.setLevel(Logger.Level.INFO);
logger.setResult(record.getId());
@@ -269,6 +266,12 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
return result;
}
@Override
public Long sumNumberValueByRange(GaoEventRecordPage page) {
TimiException.required(page.getStoreId(), "not found page.storeId");
return mapper.sumNumberValueByRange(page);
}
@Override
public byte[] export(GaoEventRecordPage page) throws IOException {
TimiException.required(page.getStoreId(), "not found page.storeId");
@@ -279,13 +282,29 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
@Override
public PageResult<GaoCustomerEventRankView> pageRankByRange(GaoEventRecordRankPage page) {
TimiException.required(page.getStoreId(), "not found page.storeId");
validateRankPage(page);
if (page.getRankType() == GaoEventRecordRankPage.RankType.CONTINUOUS_DAYS) {
page.setTodayDateValue(dateValue(Time.now()));
}
PageResult<GaoCustomerEventRankView> result = new PageResult<>();
result.setTotal(mapper.countRankByRange(page));
result.setList(mapper.selectRankByRange(page));
return result;
}
/// 校验登记数值排行的事件范围
///
/// @param page 排行榜查询请求
private void validateRankPage(GaoEventRecordRankPage page) {
if (page.getRankType() != GaoEventRecordRankPage.RankType.TOTAL_NUMBER_VALUE) {
return;
}
List<String> eventIdList = page.getEventIdList();
TimiException.requiredTrue(eventIdList != null && eventIdList.size() == 1, "按登记数值排行必须选择单个事件");
GaoEvent event = eventService.mapByIdList(eventIdList).get(eventIdList.get(0));
TimiException.requiredTrue(event != null && event.getValueType() == GaoEvent.ValueType.NUMBER, "按登记数值排行只能选择 NUMBER 事件");
}
private void checkEventPermission(GaoEvent event) {
List<String> roleCodeList = eventService.listRoleCode(event.getId());
TimiException.requiredTrue(roleCodeList != null && !roleCodeList.isEmpty(), "登记事件未配置可登记角色");
@@ -343,6 +362,26 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
}
}
private void verifyRecordValue(GaoEvent event, Long numberValue) {
GaoEvent.ValueType valueType = TimiJava.defaultIfNull(event.getValueType(), GaoEvent.ValueType.NONE);
if (numberValue != null) {
TimiException.requiredTrue(0 <= numberValue, "numberValue 不能为负数");
}
if (valueType == GaoEvent.ValueType.NONE) {
TimiException.requiredNull(numberValue, "NONE 事件不能提交 numberValue");
} else if (Boolean.TRUE.equals(event.getRequireValue())) {
TimiException.required(numberValue, "该事件必须提交 numberValue");
}
}
private void verifyImmutableRecordFields(GaoEventRecord request, GaoEventRecord dbRecord) {
TimiException.requiredTrue(request.getCustomerId() == null || request.getCustomerId().equals(dbRecord.getCustomerId()), "登记记录 customerId 创建后不可修改");
TimiException.requiredTrue(request.getEventId() == null || request.getEventId().equals(dbRecord.getEventId()), "登记记录 eventId 创建后不可修改");
TimiException.requiredTrue(request.getRegisteredAt() == null || request.getRegisteredAt().equals(dbRecord.getRegisteredAt()), "登记记录 registeredAt 创建后不可修改");
TimiException.requiredTrue(request.getPointValue() == null || request.getPointValue().equals(dbRecord.getPointValue()), "登记记录 pointValue 创建后不可修改");
TimiException.requiredTrue(request.getNumberValue() == null || request.getNumberValue().equals(dbRecord.getNumberValue()), "登记记录 numberValue 创建后不可修改");
}
private int dateValue(long time) {
LocalDate date = Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()).toLocalDate();
return Integer.parseInt(DAY_FORMATTER.format(date));
@@ -5,6 +5,7 @@ import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.gao.entity.GaoEvent;
import com.imyeyu.api.modules.gao.entity.GaoEventRoleRelation;
import com.imyeyu.api.modules.gao.mapper.GaoEventMapper;
import com.imyeyu.api.modules.gao.mapper.GaoEventRecordMapper;
import com.imyeyu.api.modules.gao.mapper.GaoEventRoleRelationMapper;
import com.imyeyu.api.modules.gao.service.GaoEventService;
import com.imyeyu.api.modules.system.entity.Logger;
@@ -45,6 +46,7 @@ public class GaoEventServiceImplement extends AbstractEntityService<GaoEvent, St
private final LoggerService loggerService;
private final GaoEventMapper mapper;
private final GaoEventRecordMapper eventRecordMapper;
private final GaoEventRoleRelationMapper roleRelationMapper;
@Override
@@ -64,6 +66,8 @@ public class GaoEventServiceImplement extends AbstractEntityService<GaoEvent, St
logger.setContent(jackson.writeValueAsString(event));
event.setStatus(TimiJava.defaultIfNull(event.getStatus(), GaoEvent.Status.DRAFT));
event.setValueType(TimiJava.defaultIfNull(event.getValueType(), GaoEvent.ValueType.NONE));
event.setRequireValue(TimiJava.defaultIfNull(event.getRequireValue(), false));
event.setRequirePhoto(TimiJava.defaultIfNull(event.getRequirePhoto(), false));
event.setLimitType(TimiJava.defaultIfNull(event.getLimitType(), GaoEvent.LimitType.NONE));
event.setSort(TimiJava.defaultIfNull(event.getSort(), 0));
@@ -94,17 +98,36 @@ public class GaoEventServiceImplement extends AbstractEntityService<GaoEvent, St
try {
logger.setContent(jackson.writeValueAsString(event));
GaoEvent dbEvent = get(event.getId());
TimiException.required(dbEvent, "not found event");
TimiException.requiredTrue(dbEvent.getStoreId().equals(event.getStoreId()), "event.storeId invalid");
GaoEvent.ValueType oldValueType = dbEvent.getValueType();
GaoEvent.ValueUnit oldValueUnit = dbEvent.getValueUnit();
dbEvent.setName(event.getName());
dbEvent.setDescription(event.getDescription());
dbEvent.setColor(event.getColor());
dbEvent.setStatus(event.getStatus());
dbEvent.setValueType(TimiJava.defaultIfNull(event.getValueType(), oldValueType));
if (event.getValueUnit() != null) {
dbEvent.setValueUnit(event.getValueUnit());
} else if (event.getValueType() != null && event.getValueType() != oldValueType) {
dbEvent.setValueUnit(null);
} else {
dbEvent.setValueUnit(oldValueUnit);
}
if (event.getRequireValue() != null) {
dbEvent.setRequireValue(event.getRequireValue());
} else if (event.getValueType() != null && event.getValueType() != oldValueType) {
dbEvent.setRequireValue(false);
} else {
dbEvent.setRequireValue(dbEvent.getRequireValue());
}
dbEvent.setRequirePhoto(event.getRequirePhoto());
dbEvent.setLimitType(event.getLimitType());
dbEvent.setLimitValue(event.getLimitValue());
dbEvent.setBeginAt(event.getBeginAt());
dbEvent.setEndAt(event.getEndAt());
verifyValueDefinitionChange(dbEvent, oldValueType, oldValueUnit);
verifyEvent(dbEvent);
mapper.update(dbEvent);
saveRoleRelation(event.getId(), event.getRoleCodeList());
@@ -190,14 +213,32 @@ public class GaoEventServiceImplement extends AbstractEntityService<GaoEvent, St
}
private void verifyEvent(GaoEvent event) {
GaoEvent.ValueType valueType = TimiJava.defaultIfNull(event.getValueType(), GaoEvent.ValueType.NONE);
if (valueType == GaoEvent.ValueType.NONE) {
TimiException.requiredNull(event.getValueUnit(), "NONE 事件不能配置数值单位");
TimiException.requiredTrue(!Boolean.TRUE.equals(event.getRequireValue()), "NONE 事件不能要求提交数值");
} else {
TimiException.required(event.getValueUnit(), "NUMBER 事件必须配置数值单位");
}
if (TimiJava.isNotEmpty(event.getLimitType()) && event.getLimitType() != GaoEvent.LimitType.NONE) {
TimiException.requiredTrue(event.getLimitType().getMin() < event.getLimitValue(), "约束值最低 %s".formatted(event.getLimitType().getMin()));
TimiException.requiredTrue(event.getLimitType().getMin() <= event.getLimitValue(), "约束值最低 %s".formatted(event.getLimitType().getMin()));
}
if (event.getBeginAt() != null || event.getEndAt() != null) {
TimiException.requiredTrue(event.getBeginAt() == null || event.getEndAt() == null || event.getBeginAt() <= event.getEndAt(), "invalid beginAt or endAt");
}
}
private void verifyValueDefinitionChange(GaoEvent event, GaoEvent.ValueType oldValueType, GaoEvent.ValueUnit oldValueUnit) {
if (oldValueType == event.getValueType() && oldValueUnit == event.getValueUnit()) {
return;
}
Long count = eventRecordMapper.countByEventIdAndNumberValue(event.getId());
if (0 < TimiJava.defaultIfNull(count, 0L)) {
TimiException.requiredTrue(oldValueUnit == event.getValueUnit(), "事件已有数值登记记录,禁止修改 valueUnit");
TimiException.requiredTrue(event.getValueType() == GaoEvent.ValueType.NUMBER, "事件已有数值登记记录,不能改为 NONE");
}
}
private void saveRoleRelation(String eventId, List<String> roleCodeList) {
List<GaoEventRoleRelation> dbRoleRelationList = roleRelationMapper.selectAllByEventIdList(List.of(eventId));
Set<String> roleCodeSet = TimiJava.defaultIfEmpty(roleCodeList, List.<String>of()).stream().filter(TimiJava::isNotEmpty).collect(Collectors.toSet());
@@ -15,6 +15,8 @@ import com.imyeyu.api.modules.gao.service.GaoPointAccountService;
import com.imyeyu.api.modules.gao.service.GaoPointLedgerService;
import com.imyeyu.api.modules.gao.vo.GaoPointAdjustRequest;
import com.imyeyu.api.modules.gao.vo.GaoPointLedgerPage;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiCode;
@@ -24,6 +26,7 @@ import com.imyeyu.spring.mapper.BaseMapper;
import com.imyeyu.spring.service.AbstractEntityService;
import com.imyeyu.utils.Time;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,6 +37,7 @@ import java.util.Map;
///
/// @author Codex
/// @since 2026-08-11
@Slf4j
@Service
@RequiredArgsConstructor
public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPointLedger, String> implements GaoPointLedgerService {
@@ -42,6 +46,7 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPoi
private final GaoPointAccountService accountService;
private final GaoCustomerService customerService;
private final AttachmentService attachmentService;
private final LoggerService loggerService;
private final UserLoginService userLoginService;
@Override
@@ -128,14 +133,21 @@ 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) {
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 {
GaoPointLedger existing = mapper.selectByIdempotencyKey(idempotencyKey);
if (existing != null) {
logger.setLevel(Logger.Level.INFO);
logger.setResult(existing.getId());
return existing;
}
GaoPointAccount account = accountService.getOrCreateForUpdate(storeId, customerId);
existing = mapper.selectByIdempotencyKey(idempotencyKey);
if (existing != null) {
logger.setLevel(Logger.Level.INFO);
logger.setResult(existing.getId());
return existing;
}
@@ -165,6 +177,20 @@ public class GaoPointLedgerServiceImplement extends AbstractEntityService<GaoPoi
create(ledger);
int updated = accountService.updateBalance(account.getId(), pointDelta, totalEarnedDelta, totalRevokedDelta, totalAdjustedDelta);
TimiException.requiredTrue(0 < updated, "积分余额不足");
logger.setLevel(Logger.Level.INFO);
logger.setResult(ledger.getId());
return ledger;
} catch (TimiException e) {
logger.setLevel(Logger.Level.WARN);
logger.setException(e.getMessage());
throw e;
} catch (Exception e) {
logger.setLevel(Logger.Level.ERROR);
logger.setException(TimiJava.serializeThrowable(e));
log.error("应用 GAO 积分流水失败", e);
throw new TimiException(TimiCode.ERROR, "应用 GAO 积分流水失败", e);
} finally {
loggerService.create(logger);
}
}
}
@@ -25,6 +25,8 @@ import java.util.List;
@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;
@@ -116,6 +118,13 @@ public class GaoPointRuleServiceImplement extends AbstractEntityService<GaoPoint
return;
}
if (rule.getTriggerType() == GaoPointRule.TriggerType.CUSTOMER_EVENT_VALUE_TIER) {
TimiException.requiredTrue(rule.getPointMode() == GaoPointRule.PointMode.TIER, "CUSTOMER_EVENT_VALUE_TIER 规则必须使用 TIER 积分值模式");
TimiException.requiredNull(rule.getPointValue(), "TIER 规则不能填写规则级积分值");
verifyTierCondition(rule.getStoreId(), rule.getConditionJson());
return;
}
TimiException.requiredTrue(rule.getPointMode() == GaoPointRule.PointMode.FIXED, "固定触发规则必须使用 FIXED 积分值模式");
TimiException.required(rule.getPointValue(), "not found rule.pointValue");
TimiException.requiredTrue(0 < rule.getPointValue(), "rule.pointValue invalid");
@@ -131,6 +140,63 @@ public class GaoPointRuleServiceImplement extends AbstractEntityService<GaoPoint
}
}
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 类型");
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 (i == 0) {
TimiException.requiredTrue(min == 0, "TIER 第一档 min 必须为 0");
} else {
TimiException.requiredTrue(previousMax != null && previousMax < Long.MAX_VALUE && 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() && 0 <= pointValue.longValue(), "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;
@@ -6,16 +6,16 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.imyeyu.api.bean.ModuleCode;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.entity.GaoPointLedger;
import com.imyeyu.api.modules.gao.entity.GaoPointRule;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.entity.GaoEventRecord;
import com.imyeyu.api.modules.gao.mapper.GaoPointLedgerMapper;
import com.imyeyu.api.modules.gao.entity.GaoPointLedger;
import com.imyeyu.api.modules.gao.entity.GaoPointRule;
import com.imyeyu.api.modules.gao.mapper.GaoEventRecordMapper;
import com.imyeyu.api.modules.gao.mapper.GaoPointLedgerMapper;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoPointLedgerService;
import com.imyeyu.api.modules.gao.service.GaoPointRuleService;
import com.imyeyu.api.modules.gao.service.GaoPointTriggerService;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.vo.GaoPointAdjustRequest;
import com.imyeyu.api.modules.user.service.PermissionChecker;
import com.imyeyu.api.modules.user.service.UserLoginService;
@@ -26,6 +26,9 @@ import lombok.RequiredArgsConstructor;
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.HashSet;
import java.util.List;
import java.util.Set;
@@ -51,26 +54,34 @@ public class GaoPointTriggerServiceImplement implements GaoPointTriggerService {
@Override
public void onEventRecordCreated(GaoEventRecord record) {
TimiException.required(record, "not found event record");
TimiException.required(record.getId(), "not found event record.id");
TimiException.required(record.getStoreId(), "not found record.storeId");
TimiException.required(record.getCustomerId(), "not found record.customerId");
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 earnedPointValue = 0L;
for (GaoPointRule rule : ruleList) {
Long pointDelta = resolvePointDelta(record, rule, count);
if (pointDelta == null) {
RuleResolution resolution = resolveRule(record, rule, count);
if (resolution == null) {
continue;
}
if (count == null) {
count = eventRecordMapper.count(record.getStoreId(), record.getCustomerId(), record.getEventId(), null, null);
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 (0 < pointDelta) {
ledgerService.earn(record, rule, pointDelta, resolution.sourcePayload());
earnedPointValue = Math.addExact(earnedPointValue, pointDelta);
}
JsonNode sourcePayload = sourcePayload(record, rule, pointDelta);
ledgerService.earn(record, rule, pointDelta, sourcePayload);
if (Boolean.TRUE.equals(rule.getExclusive())) {
break;
}
}
record.setEarnedPointValue(earnedPointValue);
eventRecordMapper.updateEarnedPointValue(record.getId(), earnedPointValue);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@@ -104,25 +115,26 @@ public class GaoPointTriggerServiceImplement implements GaoPointTriggerService {
return ledgerService.adjust(request, userLoginService.getRequireLoginUserId());
}
private Long resolvePointDelta(GaoEventRecord record, GaoPointRule rule, Long count) {
private RuleResolution resolveRule(GaoEventRecord record, GaoPointRule rule, Long count) {
return switch (rule.getTriggerType()) {
case EVERY_TIME -> rule.getPointValue();
case EVERY_TIME -> new RuleResolution(rule.getPointValue(), null);
case FIRST_TIME -> {
long currentCount = count == null ? count(record) : count;
yield currentCount == 1 ? rule.getPointValue() : null;
yield currentCount == 1 ? new RuleResolution(rule.getPointValue(), null) : null;
}
case AFTER_COUNT_EVERY_TIME -> {
JsonNode condition = rule.getConditionJson();
long threshold = condition.get("threshold").longValue();
boolean includeCurrent = condition.get("includeCurrent") == null || condition.get("includeCurrent").isNull() || condition.get("includeCurrent").asBoolean();
long currentCount = count == null ? count(record) : count;
yield includeCurrent ? threshold <= currentCount ? rule.getPointValue() : null : threshold < currentCount ? rule.getPointValue() : null;
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);
};
}
private Long resolveSourceValue(GaoEventRecord record, GaoPointRule rule) {
private RuleResolution resolveSourceValue(GaoEventRecord record, GaoPointRule rule) {
if (record.getPointValue() == null) {
return null;
}
@@ -136,17 +148,49 @@ public class GaoPointTriggerServiceImplement implements GaoPointTriggerService {
TimiException.requiredTrue(min == null || min.isNull() || min.longValue() <= pointValue, "pointValue 小于规则最小值");
TimiException.requiredTrue(max == null || max.isNull() || pointValue <= max.longValue(), "pointValue 大于规则最大值");
}
return pointValue;
ObjectNode payload = jackson.createObjectNode();
payload.put("submittedPointValue", pointValue);
payload.put("validatedPointValue", pointValue);
return new RuleResolution(pointValue, payload);
}
private JsonNode sourcePayload(GaoEventRecord record, GaoPointRule rule, long pointDelta) {
if (rule.getTriggerType() != GaoPointRule.TriggerType.SOURCE_VALUE) {
return null;
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 = TimiJava.defaultIfNull(eventRecordMapper.sumNumberValue(record.getStoreId(), record.getCustomerId(), sourceEventId, windowBeginAt, windowEndAt), 0L);
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;
}
}
TimiException.required(matchedTier, "TIER 没有匹配的分档");
long pointDelta = matchedTier.get("pointValue").longValue();
ObjectNode payload = jackson.createObjectNode();
payload.put("submittedPointValue", record.getPointValue());
payload.put("validatedPointValue", pointDelta);
return payload;
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 long count(GaoEventRecord record) {
@@ -156,4 +200,7 @@ public class GaoPointTriggerServiceImplement implements GaoPointTriggerService {
private String revokeKey(GaoEventRecord record, GaoPointLedger originLedger) {
return "GAO_EVENT_RECORD:%s:LEDGER:%s:REVOKE".formatted(record.getId(), originLedger.getId());
}
private record RuleResolution(Long pointDelta, JsonNode sourcePayload) {
}
}
@@ -0,0 +1,349 @@
package com.imyeyu.api.modules.gao.service.implement;
import com.imyeyu.api.bean.CoreRoleCode;
import com.imyeyu.api.bean.ModuleCode;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.gao.bean.GaoUserInviteCache;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.bean.GaoRoleCode;
import com.imyeyu.api.modules.gao.entity.GaoStore;
import com.imyeyu.api.modules.gao.entity.GaoUser;
import com.imyeyu.api.modules.gao.service.GaoStoreService;
import com.imyeyu.api.modules.gao.service.GaoUserInviteService;
import com.imyeyu.api.modules.gao.service.GaoUserService;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteCreateRequest;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteCreateResponse;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteRegisterData;
import com.imyeyu.api.modules.gao.vo.GaoUserInviteValidateResponse;
import com.imyeyu.api.modules.system.entity.Logger;
import com.imyeyu.api.modules.system.service.LoggerService;
import com.imyeyu.api.modules.user.entity.Role;
import com.imyeyu.api.modules.user.entity.User;
import com.imyeyu.api.modules.user.entity.UserRole;
import com.imyeyu.api.modules.user.service.AuthorizationScopeService;
import com.imyeyu.api.modules.user.service.PermissionChecker;
import com.imyeyu.api.modules.user.service.RoleChecker;
import com.imyeyu.api.modules.user.service.RoleService;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.api.modules.user.service.UserRoleService;
import com.imyeyu.api.modules.user.service.UserService;
import com.imyeyu.api.modules.user.bean.AuthOwnerType;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiCode;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.util.Redis;
import com.imyeyu.utils.Digest;
import com.imyeyu.utils.Time;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.List;
import java.util.UUID;
/// GAO 用户邀请注册服务实现
///
/// @author Codex
/// @since 2026-08-13
@Slf4j
@Service
@RequiredArgsConstructor
public class GaoUserInviteServiceImplement implements GaoUserInviteService {
private static final String INVITE_KEY_PREFIX = "GAO:USER:INVITE:";
private static final String CONSUME_LOCK_KEY_PREFIX = "GAO:USER:INVITE:CONSUME:";
private static final String CREATE_CODE_LOCK_KEY = "GAO:USER:INVITE:CREATE:LOCK";
private static final String CODE_CHARACTERS = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
private static final int CODE_LENGTH = 10;
private static final long INVITE_TTL = Time.D * 7;
private static final long CODE_LOCK_TTL = Time.S * 5;
private static final long CONSUME_LOCK_TTL = Time.M * 2;
private static final long REGISTER_RATE_LIMIT_TTL = Time.M * 10;
private static final int CODE_RATE_LIMIT = 30;
private static final int REGISTER_VALUE_RATE_LIMIT = 10;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final GaoStoreService gaoStoreService;
private final GaoUserService gaoUserService;
private final UserLoginService userLoginService;
private final UserRoleService userRoleService;
private final UserService userService;
private final RoleService roleService;
private final PermissionChecker permissionChecker;
private final RoleChecker roleChecker;
private final AuthorizationScopeService authorizationScopeService;
private final LoggerService loggerService;
@Qualifier("redisGaoUserInvite")
private final Redis<String, GaoUserInviteCache> redisInvite;
@Qualifier("redisLocker")
private final Redis<String, Integer> redisLocker;
@Qualifier("redisRateLimit")
private final Redis<String, Integer> redisRateLimit;
@Override
public GaoUserInviteCreateResponse create(GaoUserInviteCreateRequest req) {
Logger logger = new Logger(Logger.Module.GAO, Logger.Level.INFO, "GAO_USER_INVITE_CREATE");
try {
TimiException.required(req, "not found invite request");
TimiException.required(req.getRoleId(), "not found roleId");
String inviterUserId = userLoginService.getRequireLoginUserId();
logger.setUserId(inviterUserId);
permissionChecker.checkAny(ModuleCode.GAO, GaoPermissionCode.USER_CREATE.getValue());
Role requestedRole = roleService.get(req.getRoleId());
boolean adminInviteGlobalManager = roleChecker.hasAny(ModuleCode.CORE, CoreRoleCode.ADMIN.name())
&& isGlobalManagerRole(requestedRole);
boolean globalManager = authorizationScopeService.isSystemAdmin(inviterUserId)
|| roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name());
boolean storeManager = !globalManager;
GaoStore loginStore = storeManager ? gaoStoreService.getByUserId(inviterUserId) : null;
String storeId = adminInviteGlobalManager ? null : storeManager ? loginStore == null ? null : loginStore.getId() : req.getStoreId();
if (!adminInviteGlobalManager) {
TimiException.required(storeId, "not found storeId");
checkStoreScope(inviterUserId, storeId);
}
if (storeId != null) {
requireActiveStore(storeId);
}
Role role = requireInvitableRole(inviterUserId, req.getRoleId(), storeId);
long createdAt = Time.now();
long expireAt = createdAt + INVITE_TTL;
GaoUserInviteCache cache = new GaoUserInviteCache();
cache.setInviterUserId(inviterUserId);
cache.setStoreId(storeId);
cache.setRoleId(role.getId());
cache.setScene(adminInviteGlobalManager ? "CORE_ADMIN" : storeManager ? "STORE_MANAGER" : "STORE_MANAGEMENT");
cache.setCreatedAt(createdAt);
cache.setExpireAt(expireAt);
String code = saveInvite(cache);
logger.setContent("storeId=%s;roleId=%s;scene=%s;createdAt=%d;expireAt=%d".formatted(
storeId, role.getId(), cache.getScene(), createdAt, expireAt));
logger.setResult("created");
return new GaoUserInviteCreateResponse(code, expireAt);
} catch (TimiException e) {
logger.setLevel(Logger.Level.WARN);
logger.setException(e.getMessage());
throw e;
} catch (Exception e) {
logger.setLevel(Logger.Level.ERROR);
logger.setException(TimiJava.serializeThrowable(e));
log.error("GAO 邀请创建失败", e);
throw new TimiException(TimiCode.ERROR, "邀请创建失败", e);
} finally {
loggerService.create(logger);
}
}
@Override
public GaoUserInviteValidateResponse validate(String code) {
TimiException.required(code, "not found code");
checkCodeRateLimit(code);
GaoUserInviteCache cache = requireValidInvite(code);
validateInviteTarget(cache);
return new GaoUserInviteValidateResponse(true, cache.getExpireAt());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public String register(GaoUserInviteRegisterData data) {
TimiException.required(data, "not found register data");
String code = data.getCode();
TimiException.required(code, "not found code");
checkCodeRateLimit(code);
checkRegisterValueRateLimit("NAME", data.getName());
checkRegisterValueRateLimit("PHONE", data.getPhoneNo());
String lockKey = getConsumeLockKey(code);
TimiException.requiredTrue(redisLocker.lock(lockKey, 1, CONSUME_LOCK_TTL), "邀请码无效或正在使用");
GaoUserInviteCache cache = null;
Logger logger = new Logger(Logger.Module.GAO, Logger.Level.WARN, "GAO_USER_INVITE_REGISTER");
try {
cache = requireValidInvite(code);
logger.setContent(buildLogContent(cache));
validateInviteTarget(cache);
TimiException.requiredNull(userService.getByName(data.getName()), "existed user name");
TimiException.requiredNull(userService.getByPhoneNo(data.getPhoneNo()), "existed phone number");
if (!redisInvite.destroy(getInviteKey(code))) {
throw invalidInvite();
}
User user = new User();
user.setName(data.getName());
user.setPhoneNo(data.getPhoneNo());
user.setPassword(data.getPassword());
if (cache.getStoreId() == null) {
// TODO 用更好的方式
userService.create(user);
userService.verifyPhoneNo(user.getId());
} else {
GaoUser gaoUser = new GaoUser();
gaoUser.setStoreId(cache.getStoreId());
gaoUser.setUser(user);
gaoUserService.create(gaoUser);
}
UserRole userRole = new UserRole();
userRole.setId(UUID.randomUUID().toString());
userRole.setUserId(user.getId());
userRole.setRoleId(cache.getRoleId());
userRole.setCreatedAt(Time.now());
userRoleService.create(userRole);
logger.setLevel(Logger.Level.INFO);
logger.setUserId(user.getId());
logger.setResult(user.getId());
return user.getId();
} catch (TimiException e) {
logger.setException(e.getMessage());
throw e;
} catch (Exception e) {
logger.setLevel(Logger.Level.ERROR);
logger.setException(TimiJava.serializeThrowable(e));
log.error("GAO 用户邀请注册失败", e);
throw new TimiException(TimiCode.ERROR, "邀请注册失败", e);
} finally {
loggerService.create(logger);
redisLocker.releaseLock(lockKey);
}
}
private String saveInvite(GaoUserInviteCache cache) {
TimiException.requiredTrue(redisLocker.lock(CREATE_CODE_LOCK_KEY, 1, CODE_LOCK_TTL), "服务繁忙,请稍后重试");
try {
for (int attempt = 0; attempt < 3; attempt++) {
String code = randomCode();
if (!redisInvite.has(getInviteKey(code))) {
redisInvite.set(getInviteKey(code), cache, INVITE_TTL);
return code;
}
}
throw new TimiException(TimiCode.ERROR_SERVICE_BUSY, "邀请码生成失败");
} finally {
redisLocker.releaseLock(CREATE_CODE_LOCK_KEY);
}
}
private String randomCode() {
StringBuilder code = new StringBuilder(CODE_LENGTH);
for (int i = 0; i < CODE_LENGTH; i++) {
code.append(CODE_CHARACTERS.charAt(SECURE_RANDOM.nextInt(CODE_CHARACTERS.length())));
}
return code.toString();
}
private GaoUserInviteCache requireValidInvite(String code) {
TimiException.required(code, "not found code");
GaoUserInviteCache cache = redisInvite.get(getInviteKey(code));
if (cache == null || cache.getExpireAt() == null || cache.getExpireAt() <= Time.now()) {
throw invalidInvite();
}
return cache;
}
private GaoStore requireActiveStore(String storeId) {
GaoStore store = gaoStoreService.get(storeId);
if (store == null || !store.isActivated()) {
throw invalidInvite();
}
return store;
}
private Role requireInvitableRole(String inviterUserId, String roleId, String storeId) {
Role role = requireValidRole(roleId, storeId);
List<String> directRoleIdList = userRoleService.listRoleByUserId(ModuleCode.GAO, inviterUserId)
.stream()
.map(Role::getId)
.toList();
boolean descendant = authorizationScopeService.listManageableRole(inviterUserId, ModuleCode.GAO)
.stream()
.anyMatch(item -> role.getId().equals(item.getId()))
&& !directRoleIdList.contains(role.getId());
if (!descendant) {
throw new TimiException(TimiCode.PERMISSION_ERROR, "角色不在可邀请范围内");
}
return role;
}
private Role requireValidRole(String roleId, String storeId) {
Role role = roleService.get(roleId);
if (role == null || role.getModuleCode() != ModuleCode.GAO || (storeId == null && !isGlobalManagerRole(role)) || !isRoleUsableForStore(role, storeId)) {
throw invalidInvite();
}
return role;
}
private boolean isRoleUsableForStore(Role role, String storeId) {
return role.getOwnerType() == null
|| role.getOwnerType() == AuthOwnerType.MODULE && (TimiJava.isEmpty(role.getOwnerId()) || ModuleCode.GAO.name().equals(role.getOwnerId()))
|| role.getOwnerType() == AuthOwnerType.STORE && storeId != null && storeId.equals(role.getOwnerId());
}
private boolean isGlobalManagerRole(Role role) {
return role != null
&& role.getModuleCode() == ModuleCode.GAO
&& GaoRoleCode.GLOBAL_MANAGER.name().equals(role.getCode());
}
private void validateInviteTarget(GaoUserInviteCache cache) {
if (cache.getStoreId() != null) {
requireActiveStore(cache.getStoreId());
}
requireValidRole(cache.getRoleId(), cache.getStoreId());
}
private void checkStoreScope(String userId, String storeId) {
if (authorizationScopeService.isSystemAdmin(userId) || roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.GLOBAL_MANAGER.name())) {
return;
}
GaoStore userStore = gaoStoreService.getByUserId(userId);
TimiException.requiredTrue(userStore != null && storeId.equals(userStore.getId()), "无权操作其他门店");
}
private String buildLogContent(GaoUserInviteCache cache) {
return "inviterUserId=%s;storeId=%s;roleId=%s;scene=%s".formatted(cache.getInviterUserId(), cache.getStoreId(), cache.getRoleId(), cache.getScene());
}
private void checkCodeRateLimit(String code) {
checkRateLimit("GAO:USER:INVITE:LIMIT:CODE:" + Digest.sha256(code), CODE_RATE_LIMIT, REGISTER_RATE_LIMIT_TTL);
}
private void checkRegisterValueRateLimit(String type, String value) {
checkRateLimit("GAO:USER:INVITE:LIMIT:" + type + ":" + Digest.sha256(value), REGISTER_VALUE_RATE_LIMIT, REGISTER_RATE_LIMIT_TTL);
}
private void checkRateLimit(String key, int limit, long ttl) {
String lockKey = key + ":LOCK";
TimiException.requiredTrue(redisLocker.lock(lockKey, 1, Time.S * 5), "服务繁忙,请稍后重试");
try {
Integer count = redisRateLimit.get(key);
if (count != null && limit <= count) {
throw new TimiException(TimiCode.REQUEST_BAD, "request_rate_limit");
}
redisRateLimit.set(key, count == null ? 1 : count + 1, ttl);
} finally {
redisLocker.releaseLock(lockKey);
}
}
private String getInviteKey(String code) {
return INVITE_KEY_PREFIX + code;
}
private String getConsumeLockKey(String code) {
return CONSUME_LOCK_KEY_PREFIX + code;
}
private TimiException invalidInvite() {
return new TimiException(TimiCode.ARG_BAD, "邀请码无效或已过期");
}
}
@@ -1,6 +1,5 @@
package com.imyeyu.api.modules.gao.service.implement;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.gao.entity.GaoStore;
import com.imyeyu.api.modules.gao.entity.GaoUser;
@@ -30,7 +29,6 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class GaoUserServiceImplement extends AbstractEntityService<GaoUser, String> implements GaoUserService {
private final ObjectMapper jackson;
private final UserService userService;
private final LoggerService loggerService;
private final GaoStoreService gaoStoreService;
@@ -52,7 +50,8 @@ public class GaoUserServiceImplement extends AbstractEntityService<GaoUser, Stri
Logger logger = new Logger(Logger.Module.GAO, "GAO_USER_CREATE");
try {
logger.setContent(jackson.writeValueAsString(gaoUser));
logger.setContent("storeId=%s;userName=%s;phoneNo=%s".formatted(
gaoUser.getStoreId(), gaoUser.getUser().getName(), gaoUser.getUser().getPhoneNo()));
GaoStore store = gaoStoreService.get(gaoUser.getStoreId());
TimiException.required(store, "not found store");
@@ -88,7 +87,8 @@ public class GaoUserServiceImplement extends AbstractEntityService<GaoUser, Stri
Logger logger = new Logger(Logger.Module.GAO, "GAO_USER_UPDATE");
try {
logger.setContent(jackson.writeValueAsString(gaoUser));
logger.setContent("gaoUserId=%s;enabled=%s;remark=%s".formatted(
gaoUser.getId(), gaoUser.getEnabled(), gaoUser.getRemark()));
GaoUser dbGaoUser = super.get(gaoUser.getId());
TimiException.required(dbGaoUser, "not found gao user");
@@ -122,7 +122,7 @@ public class GaoUserServiceImplement extends AbstractEntityService<GaoUser, Stri
Logger logger = new Logger(Logger.Module.GAO, "GAO_USER_TRANSFER_STORE");
try {
logger.setContent(jackson.writeValueAsString(gaoUser));
logger.setContent("gaoUserId=%s;storeId=%s".formatted(gaoUser.getId(), gaoUser.getStoreId()));
GaoStore store = gaoStoreService.get(gaoUser.getStoreId());
TimiException.required(store, "not found store");
@@ -29,6 +29,9 @@ public class GaoCustomerEventRankView {
/// 最近连续登记天数
private Long recentContinuousEventDays;
/// 登记数值合计
private Long totalNumberValue;
/// 最后登记时间
private Long latestRegisteredAt;
}
@@ -0,0 +1,25 @@
package com.imyeyu.api.modules.gao.vo;
import com.imyeyu.java.bean.BasePage;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/// GAO 客户分页查询请求
///
/// @author Codex
/// @since 2026-08-13
@Data
@EqualsAndHashCode(callSuper = true)
public class GaoCustomerPage extends BasePage {
/// 门店 ID
private String storeId;
/// 关键字,匹配编码、姓名、电话
private String keyword;
/// 标签 ID 列表,客户必须同时拥有列表内全部标签
private List<String> tagIdList;
}
@@ -0,0 +1,22 @@
package com.imyeyu.api.modules.gao.vo;
import com.fasterxml.jackson.annotation.JsonView;
import com.imyeyu.api.modules.gao.entity.GaoEventRecord;
import com.imyeyu.spring.bean.PageResult;
import com.imyeyu.spring.util.ResponseView;
import lombok.Data;
import lombok.EqualsAndHashCode;
///
/// 登记事件记录分页查询结果
///
/// @author Codex
/// @since 2026-08-14
@Data
@EqualsAndHashCode(callSuper = true)
public class GaoEventRecordPageResult extends PageResult<GaoEventRecord> {
/// 指定单个数值事件的登记数值合计,未指定单个 NUMBER 事件时为空
@JsonView(ResponseView.Public.class)
private Long numberValueTotal;
}
@@ -41,6 +41,8 @@ public class GaoEventRecordRankPage extends BasePage {
/// 按累计登记次数
TOTAL_COUNT,
/// 按最近连续登记天数
CONTINUOUS_DAYS
CONTINUOUS_DAYS,
/// 按登记数值合计
TOTAL_NUMBER_VALUE
}
}
@@ -0,0 +1,19 @@
package com.imyeyu.api.modules.gao.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/// GAO 用户邀请创建请求
///
/// @author Codex
/// @since 2026-08-13
@Data
public class GaoUserInviteCreateRequest {
/// 门店管理场景的目标门店 ID,店长场景及 ADMIN 邀请 GLOBAL_MANAGER 时不使用
private String storeId;
/// 目标角色 ID
@NotBlank
private String roleId;
}
@@ -0,0 +1,19 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
/// GAO 用户邀请创建响应
///
/// @author Codex
/// @since 2026-08-13
@Data
@AllArgsConstructor
public class GaoUserInviteCreateResponse {
/// 邀请 code
private String code;
/// 邀请过期时间,毫秒时间戳
private Long expireAt;
}
@@ -0,0 +1,34 @@
package com.imyeyu.api.modules.gao.vo;
import com.imyeyu.api.modules.user.validation.UserPassword;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/// GAO 用户邀请注册数据
///
/// @author Codex
/// @since 2026-08-13
@Data
public class GaoUserInviteRegisterData {
/// 邀请 code
@NotBlank
@Pattern(regexp = "[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{10}")
private String code;
/// 手机号
@NotBlank
@Pattern(regexp = "^1\\d{10}$")
private String phoneNo;
/// 用户名
@NotBlank
@Size(max = 191)
private String name;
/// 明文密码
@UserPassword
private String password;
}
@@ -0,0 +1,20 @@
package com.imyeyu.api.modules.gao.vo;
import com.imyeyu.spring.bean.CaptchaData;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
/// GAO 用户邀请注册请求
///
/// @author Codex
/// @since 2026-08-13
public class GaoUserInviteRegisterRequest extends CaptchaData<GaoUserInviteRegisterData> {
/// 级联校验注册数据
@Override
@NotNull
@Valid
public GaoUserInviteRegisterData getData() {
return super.getData();
}
}
@@ -0,0 +1,18 @@
package com.imyeyu.api.modules.gao.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/// GAO 用户邀请校验请求
///
/// @author Codex
/// @since 2026-08-13
@Data
public class GaoUserInviteValidateRequest {
/// 邀请 code
@NotBlank
@Pattern(regexp = "[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{10}")
private String code;
}
@@ -0,0 +1,19 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
/// GAO 用户邀请校验响应
///
/// @author Codex
/// @since 2026-08-13
@Data
@AllArgsConstructor
public class GaoUserInviteValidateResponse {
/// true 为邀请码可用
private boolean valid;
/// 邀请过期时间,毫秒时间戳
private Long expireAt;
}
@@ -143,7 +143,7 @@ public class FileController implements TimiJava, OS.FileSystem {
public void read(HttpServletRequest req, HttpServletResponse resp) {
try {
String path = req.getServletPath().substring("/system/file/read".length());
path = "E:\\IDEAProject\\TimiServerAPI\\data\\file" + path;
path = "/root/bin/data/mnt" + path;
if (TimiJava.isEmpty(path)) {
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
@@ -1,5 +1,6 @@
package com.imyeyu.api.modules.user.service;
import com.e2ins.ms.notify.vo.sms.CaptchaVerifyReq;
import com.imyeyu.api.modules.user.entity.User;
import com.imyeyu.api.modules.user.vo.LoginRequest;
import com.imyeyu.api.modules.user.vo.LoginResponse;
@@ -23,6 +24,8 @@ public interface UserLoginService {
LoginResponse login(String token, String userId);
LoginResponse loginBySms(CaptchaVerifyReq req);
/**
* 根据令牌获取用户
*
@@ -1,5 +1,8 @@
package com.imyeyu.api.modules.user.service.implement;
import com.e2ins.ms.notify.SmsNotifyService;
import com.e2ins.ms.notify.bean.NotifyDetail;
import com.e2ins.ms.notify.vo.sms.CaptchaVerifyReq;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.entity.Setting;
@@ -7,17 +10,18 @@ import com.imyeyu.api.modules.common.service.AttachmentService;
import com.imyeyu.api.modules.common.service.SettingService;
import com.imyeyu.api.modules.user.bean.BootstrapData;
import com.imyeyu.api.modules.user.entity.User;
import com.imyeyu.api.modules.user.service.RoleService;
import com.imyeyu.api.modules.user.service.UserLoginService;
import com.imyeyu.api.modules.user.service.UserRoleService;
import com.imyeyu.api.modules.user.service.UserService;
import com.imyeyu.api.modules.user.vo.LoginRequest;
import com.imyeyu.api.modules.user.vo.LoginResponse;
import com.imyeyu.java.TimiJava;
import com.imyeyu.java.bean.timi.TimiCode;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.TimiSpring;
import com.imyeyu.spring.util.Redis;
import com.imyeyu.utils.Digest;
import com.imyeyu.utils.Regex;
import com.imyeyu.utils.Time;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -26,6 +30,7 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
@@ -38,15 +43,14 @@ import java.util.concurrent.ThreadLocalRandom;
@RequiredArgsConstructor(onConstructor_ = {@Lazy})
public class UserLoginServiceImplement implements UserLoginService, TimiJava {
private static final String REGEX_PHONE_NO = "^1\\d{10}$";
private static final String USER_TOKEN_KEY_PREFIX = "USER:TOKEN:";
private static final String PHONE_VERIFY_KEY_PREFIX = "USER:PHONE:VERIFY:";
private static final long PHONE_VERIFY_CODE_TTL = Time.M * 5;
private final UserService userService;
private final RoleService roleService;
private final SettingService settingService;
private final UserRoleService userRoleService;
private final SmsNotifyService smsNotifyService;
private final AttachmentService attachmentService;
private final Redis<String, String> redisUserToken;
@@ -61,7 +65,7 @@ public class UserLoginServiceImplement implements UserLoginService, TimiJava {
User dbUser = userService.get(req.getUser());
if (dbUser == null && req.getUser().contains("@")) {
dbUser = userService.getByVerifiedEmail(req.getUser());
} else if (dbUser == null && isPhoneNo(req.getUser())) {
} else if (dbUser == null && Regex.isMatch(Regex.MOBILE_PHONE, req.getUser())) {
dbUser = userService.getByVerifiedPhoneNo(req.getUser());
} else if (dbUser == null) {
dbUser = userService.getByName(req.getUser());
@@ -98,6 +102,24 @@ public class UserLoginServiceImplement implements UserLoginService, TimiJava {
return result;
}
@Override
public LoginResponse loginBySms(CaptchaVerifyReq req) {
try {
NotifyDetail detail = smsNotifyService.captchaVerify(req);
// 无报错即成功
User user = userService.getByPhoneNo(detail.getSendTo());
if (!user.isValidPhone()) {
user.setPhoneNoVerifyAt(Time.now());
userService.update(user);
}
return login(UUID.randomUUID().toString(), user.getId());
} catch (IOException e) {
log.error("login by sms error", e);
throw new TimiException(TimiCode.ERROR, "验证短信失败");
}
}
@Override
public User getLoginUser(String token) {
if (TimiJava.isEmpty(token)) {
@@ -143,8 +165,8 @@ public class UserLoginServiceImplement implements UserLoginService, TimiJava {
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void sendPhoneVerifyCode(String phoneNo) {
TimiException.requiredTrue(isPhoneNo(phoneNo), "invalid phone number");
User verifiedUser = userService.getByVerifiedPhoneNo(phoneNo);
TimiException.requiredTrue(Regex.isMatch(Regex.MOBILE_PHONE, phoneNo), "invalid phone number");
User verifiedUser = userService.getByPhoneNo(phoneNo);
User loginUser = getRequireLoginUser();
TimiException.requiredTrue(verifiedUser == null || verifiedUser.getId().equals(loginUser.getId()), "existed phone number");
@@ -156,11 +178,11 @@ public class UserLoginServiceImplement implements UserLoginService, TimiJava {
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void verifyPhoneNo(String phoneNo, String verifyCode) {
TimiException.requiredTrue(isPhoneNo(phoneNo), "invalid phone number");
TimiException.requiredTrue(Regex.isMatch(Regex.MOBILE_PHONE, phoneNo), "invalid phone number");
TimiException.required(verifyCode, "not found verify code");
User user = getRequireLoginUser();
User verifiedUser = userService.getByVerifiedPhoneNo(phoneNo);
User verifiedUser = userService.getByPhoneNo(phoneNo);
TimiException.requiredTrue(verifiedUser == null || verifiedUser.getId().equals(user.getId()), "existed phone number");
String cacheCode = redisUserPhoneVerify.get(getPhoneVerifyKey(phoneNo));
@@ -188,10 +210,6 @@ public class UserLoginServiceImplement implements UserLoginService, TimiJava {
userService.delete(user.getId());
}
private boolean isPhoneNo(String value) {
return TimiJava.isNotEmpty(value) && value.matches(REGEX_PHONE_NO);
}
private String getPhoneVerifyKey(String phoneNo) {
return "%s%s".formatted(PHONE_VERIFY_KEY_PREFIX, phoneNo);
}
@@ -57,7 +57,7 @@ public class UserServiceImplement extends AbstractEntityService<User, String> im
{
if (TimiJava.isNotEmpty(user.getPhoneNo())) {
TimiException.requiredTrue(user.isValidPhone(), "invalid phone");
User byPhoneNo = getByVerifiedPhoneNo(user.getPhoneNo());
User byPhoneNo = getByPhoneNo(user.getPhoneNo());
TimiException.requiredNull(byPhoneNo, "existed phone number");
}
}
@@ -94,7 +94,7 @@ public class UserServiceImplement extends AbstractEntityService<User, String> im
if (TimiJava.isNotEmpty(phoneNo)) {
TimiException.requiredTrue(user.isValidPhone(), "invalid phone");
if (!phoneNo.equals(dbUser.getPhoneNo())) {
User byPhoneNo = getByVerifiedPhoneNo(phoneNo);
User byPhoneNo = getByPhoneNo(phoneNo);
TimiException.requiredTrue(byPhoneNo == null || byPhoneNo.getId().equals(dbUser.getId()), "existed phone number");
}
}
@@ -0,0 +1,18 @@
-- 历史标签统一归入迁移时创建的默认门店。
SET @gao_default_store_id = '00000000-0000-0000-0000-000000000001';
ALTER TABLE `tag`
ADD COLUMN `owner_type` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '归属类型' AFTER `id`,
ADD COLUMN `owner_id` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '归属门店 ID' AFTER `owner_type`;
UPDATE `tag`
SET
`owner_type` = 'GAO_STORE',
`owner_id` = @gao_default_store_id
WHERE `owner_type` IS NULL
OR `owner_id` IS NULL;
ALTER TABLE `tag`
MODIFY COLUMN `owner_type` VARCHAR(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '归属类型',
MODIFY COLUMN `owner_id` VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '归属门店 ID',
ADD KEY `idx_tag_owner` (`owner_type`, `owner_id`, `deleted_at`);
@@ -0,0 +1,10 @@
ALTER TABLE `gao_event`
ADD COLUMN `value_type` VARCHAR(16) NOT NULL DEFAULT 'NONE' COMMENT '数值类型,NONE 不携带数值,NUMBER 携带整数数值' AFTER `description`,
ADD COLUMN `value_unit` VARCHAR(16) NULL COMMENT '数值单位,CENT 金额分,COUNT 次数' AFTER `value_type`,
ADD COLUMN `require_value` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'true 为登记时必须提交 numberValue' AFTER `value_unit`;
ALTER TABLE `gao_event_record`
ADD COLUMN `point_value` BIGINT NULL COMMENT '本次登记提交的积分值' AFTER `operator_user_id`,
ADD COLUMN `earned_point_value` BIGINT NOT NULL DEFAULT 0 COMMENT '本次登记通过积分规则获得的积分总和' AFTER `point_value`,
ADD COLUMN `number_value` BIGINT NULL COMMENT '本次登记提交的整数业务数值' AFTER `earned_point_value`,
ADD KEY `idx_gao_event_record_value_sum` (`store_id`, `customer_id`, `event_id`, `deleted_at`, `registered_at`);
@@ -18,4 +18,18 @@
</if>
AND `deleted_at` IS NULL
</select>
<select id="selectByOwnerAndZhCN" resultType="com.imyeyu.api.modules.common.entity.Tag">
SELECT
`t`.*
FROM `tag` `t`
INNER JOIN `multilingual` `m`
ON `m`.`id` = `t`.`name_lang_id`
AND `m`.`deleted_at` IS NULL
WHERE
`t`.`owner_type` = #{ownerType}
AND `t`.`owner_id` = #{ownerId}
AND `m`.`zh_cn` = #{zhCN}
AND `t`.`deleted_at` IS NULL
LIMIT 1
</select>
</mapper>
@@ -19,6 +19,52 @@
AND `deleted_at` IS NULL
</select>
<sql id="queryWhere">
`c`.`deleted_at` IS NULL
<if test="storeId != null and storeId != ''">
AND `c`.`store_id` = #{storeId}
</if>
<if test="keyword != null and keyword != ''">
AND (
`c`.`code` LIKE CONCAT('%', #{keyword}, '%')
OR `c`.`name` LIKE CONCAT('%', #{keyword}, '%')
OR `c`.`telephone` LIKE CONCAT('%', #{keyword}, '%')
)
</if>
<if test="tagIdList != null and !tagIdList.isEmpty()">
<bind name="tagCount" value="tagIdList.size()"/>
AND `c`.`id` IN (
SELECT `ta`.`biz_id`
FROM `tag_apply` `ta`
WHERE
`ta`.`biz_type` = 'GAO_CUSTOMER'
AND `ta`.`tag_id` IN
<foreach collection="tagIdList" item="tagId" separator="," open="(" close=")">
#{tagId}
</foreach>
AND `ta`.`deleted_at` IS NULL
GROUP BY `ta`.`biz_id`
HAVING COUNT(DISTINCT `ta`.`tag_id`) = #{tagCount}
)
</if>
</sql>
<select id="countByQuery" resultType="long">
SELECT COUNT(1)
FROM `gao_customer` `c`
WHERE
<include refid="queryWhere"/>
</select>
<select id="selectByQuery" resultType="com.imyeyu.api.modules.gao.entity.GaoCustomer">
SELECT `c`.*
FROM `gao_customer` `c`
WHERE
<include refid="queryWhere"/>
ORDER BY `c`.`created_at` DESC, `c`.`id` DESC
LIMIT #{offset}, #{limit}
</select>
<select id="stateDailyNewCustomer" resultType="com.imyeyu.api.modules.gao.vo.GaoCustomerDailyStateView">
SELECT
CAST(DATE_FORMAT(FROM_UNIXTIME(`created_at` / 1000), '%Y%m%d') AS UNSIGNED) AS `dateValue`,
@@ -26,6 +26,39 @@
AND `deleted_at` IS NULL
</select>
<select id="countByEventIdAndNumberValue" resultType="java.lang.Long">
SELECT
COUNT(1)
FROM `gao_event_record`
WHERE
`event_id` = #{eventId}
AND `number_value` IS NOT NULL
AND `deleted_at` IS NULL
</select>
<select id="sumNumberValue" resultType="java.lang.Long">
SELECT
COALESCE(SUM(`number_value`), 0)
FROM `gao_event_record`
WHERE
`store_id` = #{storeId}
AND `customer_id` = #{customerId}
AND `event_id` = #{eventId}
AND `registered_at` &gt;= #{beginAt}
AND `registered_at` &lt; #{endAt}
AND `deleted_at` IS NULL
</select>
<update id="updateEarnedPointValue">
UPDATE `gao_event_record`
SET
`earned_point_value` = #{earnedPointValue},
`updated_at` = ROUND(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000)
WHERE
`id` = #{id}
AND `deleted_at` IS NULL
</update>
<select id="countByStoreIdAndRegisteredDateValue" resultType="java.lang.Long">
SELECT
COUNT(1)
@@ -97,6 +130,14 @@
ORDER BY r.`registered_at` DESC, r.`created_at` DESC
LIMIT #{offset}, #{limit}
</select>
<select id="sumNumberValueByRange" resultType="java.lang.Long">
SELECT
COALESCE(SUM(r.`number_value`), 0)
<include refid="rangeFrom"/>
WHERE
<include refid="rangeWhere"/>
AND r.`number_value` IS NOT NULL
</select>
<sql id="rankWhere">
r.`deleted_at` IS NULL
@@ -119,6 +160,9 @@
#{eventId}
</foreach>
</if>
<if test="rankType != null and rankType.name() == 'TOTAL_NUMBER_VALUE'">
AND r.`number_value` IS NOT NULL
</if>
</sql>
<select id="countRankByRange" resultType="long">
SELECT
@@ -129,12 +173,69 @@
<include refid="rankWhere"/>
</select>
<select id="selectRankByRange" resultType="com.imyeyu.api.modules.gao.vo.GaoCustomerEventRankView">
<choose>
<when test="rankType == null or rankType.name() == 'TOTAL_DAYS'">
SELECT
ROW_NUMBER() OVER (
ORDER BY COUNT(DISTINCT r.`registered_date_value`) DESC, r.`customer_id`
) AS `rank`,
r.`customer_id` AS `customerId`,
COUNT(DISTINCT r.`registered_date_value`) AS `totalEventDays`,
NULL AS `totalEventCount`,
NULL AS `recentContinuousEventDays`,
NULL AS `totalNumberValue`,
NULL AS `latestRegisteredAt`
FROM `gao_event_record` r
LEFT JOIN `gao_customer` c ON r.`customer_id` = c.`id`
WHERE
<include refid="rankWhere"/>
GROUP BY r.`customer_id`
ORDER BY `rank`
LIMIT #{offset}, #{limit}
</when>
<when test="rankType.name() == 'TOTAL_COUNT'">
SELECT
ROW_NUMBER() OVER (
ORDER BY COUNT(1) DESC, r.`customer_id`
) AS `rank`,
r.`customer_id` AS `customerId`,
NULL AS `totalEventDays`,
COUNT(1) AS `totalEventCount`,
NULL AS `recentContinuousEventDays`,
NULL AS `totalNumberValue`,
NULL AS `latestRegisteredAt`
FROM `gao_event_record` r
LEFT JOIN `gao_customer` c ON r.`customer_id` = c.`id`
WHERE
<include refid="rankWhere"/>
GROUP BY r.`customer_id`
ORDER BY `rank`
LIMIT #{offset}, #{limit}
</when>
<when test="rankType.name() == 'TOTAL_NUMBER_VALUE'">
SELECT
ROW_NUMBER() OVER (
ORDER BY SUM(r.`number_value`) DESC, r.`customer_id`
) AS `rank`,
r.`customer_id` AS `customerId`,
NULL AS `totalEventDays`,
NULL AS `totalEventCount`,
NULL AS `recentContinuousEventDays`,
SUM(r.`number_value`) AS `totalNumberValue`,
NULL AS `latestRegisteredAt`
FROM `gao_event_record` r
LEFT JOIN `gao_customer` c ON r.`customer_id` = c.`id`
WHERE
<include refid="rankWhere"/>
GROUP BY r.`customer_id`
ORDER BY `rank`
LIMIT #{offset}, #{limit}
</when>
<otherwise>
WITH RECURSIVE `daily_stat` AS (
SELECT
r.`customer_id` AS `customerId`,
r.`registered_date_value` AS `registeredDateValue`,
COUNT(1) AS `dailyCount`,
MAX(r.`registered_at`) AS `latestRegisteredAt`
r.`registered_date_value` AS `registeredDateValue`
FROM `gao_event_record` r
LEFT JOIN `gao_customer` c ON r.`customer_id` = c.`id`
WHERE
@@ -175,15 +276,6 @@
AND b.`deleted_at` IS NULL
WHERE COALESCE(b.`status`, 'OPEN') != 'CLOSED'
),
`rank_stat` AS (
SELECT
`customerId`,
COUNT(1) AS `totalEventDays`,
SUM(`dailyCount`) AS `totalEventCount`,
MAX(`latestRegisteredAt`) AS `latestRegisteredAt`
FROM `daily_stat`
GROUP BY `customerId`
),
`continuous_ordered` AS (
SELECT
d.`customerId`,
@@ -212,51 +304,23 @@
AND 1 = 0
</if>
GROUP BY o.`customerId`
),
`ranked` AS (
SELECT
ROW_NUMBER() OVER (
ORDER BY
<choose>
<when test="rankType != null and rankType.name() == 'TOTAL_COUNT'">
r.`totalEventCount` DESC,
r.`totalEventDays` DESC,
r.`latestRegisteredAt` DESC,
r.`customerId`
</when>
<when test="rankType != null and rankType.name() == 'CONTINUOUS_DAYS'">
COALESCE(c.`recentContinuousEventDays`, 0) DESC,
r.`totalEventDays` DESC,
r.`totalEventCount` DESC,
r.`latestRegisteredAt` DESC,
r.`customerId`
</when>
<otherwise>
r.`totalEventDays` DESC,
r.`totalEventCount` DESC,
r.`latestRegisteredAt` DESC,
r.`customerId`
</otherwise>
</choose>
) AS `rank`,
r.`customerId`,
r.`totalEventDays`,
r.`totalEventCount`,
COALESCE(c.`recentContinuousEventDays`, 0) AS `recentContinuousEventDays`,
r.`latestRegisteredAt`
FROM `rank_stat` r
LEFT JOIN `continuous_stat` c ON r.`customerId` = c.`customerId`
)
SELECT
`rank`,
`customerId`,
`totalEventDays`,
`totalEventCount`,
`recentContinuousEventDays`,
`latestRegisteredAt`
FROM `ranked`
ROW_NUMBER() OVER (
ORDER BY COALESCE(c.`recentContinuousEventDays`, 0) DESC, r.`customerId`
) AS `rank`,
r.`customerId`,
NULL AS `totalEventDays`,
NULL AS `totalEventCount`,
COALESCE(c.`recentContinuousEventDays`, 0) AS `recentContinuousEventDays`,
NULL AS `totalNumberValue`,
NULL AS `latestRegisteredAt`
FROM (SELECT DISTINCT `customerId` FROM `daily_stat`) r
LEFT JOIN `continuous_stat` c ON r.`customerId` = c.`customerId`
ORDER BY `rank`
LIMIT #{offset}, #{limit}
</otherwise>
</choose>
</select>
<sql id="storeChartRangeWhere">
@@ -10,6 +10,6 @@
AND (`begin_at` IS NULL OR `begin_at` &lt;= #{now})
AND (`end_at` IS NULL OR #{now} &lt;= `end_at`)
AND `deleted_at` IS NULL
ORDER BY `priority` ASC, `created_at` ASC, `id` ASC
ORDER BY `priority`, `created_at`, `id`
</select>
</mapper>