feat(notify): 添加批量查询用户授权余额功能

- 新增批量查询用户授权余额接口 /api/mobile/notify/auth/batch-remaining
- 实现批量检查用户对多个模板的授权记录功能
- 添加景区所有场景及模板列表查询接口并支持缓存
- 优化授权记录查询性能,使用批量查询替代逐个查询
- 新增批量查询请求对象 BatchRemainingCountReq 和响应对象 WechatSubscribeAllScenesResp
- 在数据层添加批量查询用户授权记录的 SQL 映射
- 实现缓存管理机制,支持所有场景模板配置的缓存读写与清理
This commit is contained in:
2026-01-10 17:30:48 +08:00
parent 02f1392355
commit c9cc90c842
9 changed files with 316 additions and 15 deletions

View File

@@ -1,8 +1,10 @@
package com.ycwl.basic.controller.mobile.notify; package com.ycwl.basic.controller.mobile.notify;
import com.ycwl.basic.model.mobile.notify.req.BatchRemainingCountReq;
import com.ycwl.basic.model.mobile.notify.req.NotificationAuthRecordReq; import com.ycwl.basic.model.mobile.notify.req.NotificationAuthRecordReq;
import com.ycwl.basic.model.mobile.notify.resp.NotificationAuthRecordResp; import com.ycwl.basic.model.mobile.notify.resp.NotificationAuthRecordResp;
import com.ycwl.basic.model.mobile.notify.resp.ScenicTemplateAuthResp; import com.ycwl.basic.model.mobile.notify.resp.ScenicTemplateAuthResp;
import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationEntity;
import com.ycwl.basic.service.UserNotificationAuthorizationService; import com.ycwl.basic.service.UserNotificationAuthorizationService;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil; import com.ycwl.basic.utils.JwtTokenUtil;
@@ -14,7 +16,9 @@ import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 用户通知授权记录Controller (移动端API) * 用户通知授权记录Controller (移动端API)
@@ -92,4 +96,44 @@ public class UserNotificationAuthController {
return ApiResponse.fail("记录授权失败: " + e.getMessage()); return ApiResponse.fail("记录授权失败: " + e.getMessage());
} }
} }
/**
* 批量查询用户授权余额
* 返回 Map<wechatTemplateId, remainingCount>
*/
@PostMapping("/batch-remaining")
public ApiResponse<Map<String, Integer>> batchGetRemainingCount(
@RequestBody BatchRemainingCountReq req) {
log.debug("批量查询用户授权余额: templateIds={}, scenicId={}",
req.getTemplateIds(), req.getScenicId());
try {
Long memberId = JwtTokenUtil.getWorker().getUserId();
if (memberId == null) {
return ApiResponse.fail("用户未登录");
}
if (CollectionUtils.isEmpty(req.getTemplateIds())) {
return ApiResponse.success(new HashMap<>());
}
Map<String, UserNotificationAuthorizationEntity> authMap =
userNotificationAuthorizationService.batchCheckAuthorization(
memberId, req.getTemplateIds(), req.getScenicId());
// 转换为 templateId -> remainingCount
Map<String, Integer> result = new HashMap<>();
for (String templateId : req.getTemplateIds()) {
UserNotificationAuthorizationEntity entity = authMap.get(templateId);
int remaining = (entity != null && entity.getRemainingCount() != null)
? entity.getRemainingCount() : 0;
result.put(templateId, remaining);
}
return ApiResponse.success(result);
} catch (Exception e) {
log.error("批量查询用户授权余额失败", e);
return ApiResponse.fail("查询失败: " + e.getMessage());
}
}
} }

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.controller.mobile.notify; package com.ycwl.basic.controller.mobile.notify;
import com.ycwl.basic.annotation.IgnoreToken; import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeAllScenesResp;
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeSceneTemplatesResp; import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeSceneTemplatesResp;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
import com.ycwl.basic.service.notify.WechatSubscribeNotifyConfigService; import com.ycwl.basic.service.notify.WechatSubscribeNotifyConfigService;
@@ -87,5 +88,22 @@ public class WechatSubscribeNotifyController {
scenicId, sceneKey, memberId, Objects.requireNonNullElse(templates.size(), 0)); scenicId, sceneKey, memberId, Objects.requireNonNullElse(templates.size(), 0));
return ApiResponse.success(resp); return ApiResponse.success(resp);
} }
/**
* 获取景区下所有场景及其模板列表(静态配置,带缓存)
* 不含用户授权信息,用户授权信息通过 /api/mobile/notify/auth/batch-remaining 接口获取
*/
@GetMapping("/scenic/{scenicId}/scenes")
@IgnoreToken
public ApiResponse<WechatSubscribeAllScenesResp> listAllSceneTemplates(@PathVariable("scenicId") Long scenicId) {
if (scenicId == null) {
return ApiResponse.fail("scenicId不能为空");
}
WechatSubscribeAllScenesResp resp = configService.getAllScenesWithTemplatesCached(scenicId);
log.debug("所有场景模板查询: scenicId={}, sceneCount={}",
scenicId, resp.getScenes() != null ? resp.getScenes().size() : 0);
return ApiResponse.success(resp);
}
} }

View File

@@ -78,7 +78,7 @@ public interface UserNotificationAuthorizationMapper extends BaseMapper<UserNoti
/** /**
* 检查用户是否还有剩余授权次数 * 检查用户是否还有剩余授权次数
* *
* @param memberId 用户ID * @param memberId 用户ID
* @param templateId 模板ID * @param templateId 模板ID
* @param scenicId 景区ID * @param scenicId 景区ID
@@ -89,4 +89,18 @@ public interface UserNotificationAuthorizationMapper extends BaseMapper<UserNoti
@Param("templateId") String templateId, @Param("templateId") String templateId,
@Param("scenicId") Long scenicId @Param("scenicId") Long scenicId
); );
/**
* 批量查询用户对多个模板的授权记录
*
* @param memberId 用户ID
* @param templateIds 模板ID列表
* @param scenicId 景区ID
* @return 授权记录列表
*/
List<UserNotificationAuthorizationEntity> selectBatchByTemplateIds(
@Param("memberId") Long memberId,
@Param("templateIds") List<String> templateIds,
@Param("scenicId") Long scenicId
);
} }

View File

@@ -0,0 +1,29 @@
package com.ycwl.basic.model.mobile.notify.req;
import lombok.Data;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* 批量查询用户授权余额请求
*
* @Author: System
* @Date: 2026/01/10
*/
@Data
public class BatchRemainingCountReq {
/**
* 通知模板ID列表(微信 wechatTemplateId)
*/
@NotEmpty(message = "模板ID列表不能为空")
private List<String> templateIds;
/**
* 景区ID
*/
@NotNull(message = "景区ID不能为空")
private Long scenicId;
}

View File

@@ -0,0 +1,59 @@
package com.ycwl.basic.model.mobile.notify.resp;
import lombok.Data;
import java.util.List;
/**
* 景区所有场景及其订阅消息模板列表(静态配置,不含用户授权信息)
* 用户授权信息通过 /api/mobile/notify/auth/batch-remaining 接口获取
*
* @Author: System
* @Date: 2026/01/10
*/
@Data
public class WechatSubscribeAllScenesResp {
private Long scenicId;
private List<SceneWithTemplates> scenes;
@Data
public static class SceneWithTemplates {
/**
* 场景标识
*/
private String sceneKey;
/**
* 该场景下的模板列表
*/
private List<StaticTemplateInfo> templates;
}
/**
* 静态模板信息(不含用户授权信息,可缓存)
*/
@Data
public static class StaticTemplateInfo {
/**
* 逻辑模板键(业务固定)
*/
private String templateKey;
/**
* 微信订阅消息模板ID(tmplId)
*/
private String wechatTemplateId;
/**
* 前端展示标题
*/
private String title;
/**
* 前端展示描述
*/
private String description;
}
}

View File

@@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.ycwl.basic.mapper.WechatSubscribeEventTemplateMapper; import com.ycwl.basic.mapper.WechatSubscribeEventTemplateMapper;
import com.ycwl.basic.mapper.WechatSubscribeSceneTemplateMapper; import com.ycwl.basic.mapper.WechatSubscribeSceneTemplateMapper;
import com.ycwl.basic.mapper.WechatSubscribeTemplateConfigMapper; import com.ycwl.basic.mapper.WechatSubscribeTemplateConfigMapper;
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeAllScenesResp;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity; import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity; import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
@@ -58,6 +59,11 @@ public class WechatSubscribeNotifyConfigRepository {
*/ */
private static final String EVENT_TEMPLATE_SCENIC_PREFIX = "wechat:subscribe:event:configs:%s:*"; private static final String EVENT_TEMPLATE_SCENIC_PREFIX = "wechat:subscribe:event:configs:%s:*";
/**
* 景区所有场景及模板缓存KEY
*/
private static final String ALL_SCENES_TEMPLATES_KEY = "wechat:subscribe:all-scenes:configs:%s";
/** /**
* 缓存过期时间(小时) * 缓存过期时间(小时)
*/ */
@@ -208,6 +214,98 @@ public class WechatSubscribeNotifyConfigRepository {
return null; return null;
} }
/**
* 获取景区下所有启用的场景Key列表(去重)
* 包含默认配置(scenicId=0)和景区特定配置
*
* @param scenicId 景区ID
* @return 去重后的场景Key列表
*/
public List<String> listAllSceneKeys(Long scenicId) {
Objects.requireNonNull(scenicId, "scenicId is null");
List<Long> scenicIds = List.of(DEFAULT_SCENIC_ID, scenicId);
QueryWrapper<WechatSubscribeSceneTemplateEntity> wrapper = new QueryWrapper<>();
wrapper.in("scenic_id", scenicIds)
.eq("enabled", 1)
.select("DISTINCT scene_key");
List<WechatSubscribeSceneTemplateEntity> rows = sceneTemplateMapper.selectList(wrapper);
return rows.stream()
.map(WechatSubscribeSceneTemplateEntity::getSceneKey)
.filter(Objects::nonNull)
.distinct()
.sorted()
.collect(Collectors.toList());
}
/**
* 获取景区下所有场景及其模板列表(带缓存,不含用户授权信息)
*
* @param scenicId 景区ID
* @return 所有场景及模板配置
*/
public WechatSubscribeAllScenesResp getAllScenesWithTemplatesCached(Long scenicId) {
Objects.requireNonNull(scenicId, "scenicId is null");
String cacheKey = String.format(ALL_SCENES_TEMPLATES_KEY, scenicId);
// 1. 尝试从缓存读取
Boolean hasKey = redisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(hasKey)) {
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
log.debug("从缓存读取所有场景模板配置: scenicId={}", scenicId);
return JacksonUtil.parseObject(cacheValue, WechatSubscribeAllScenesResp.class);
}
}
// 2. 从数据库加载
WechatSubscribeAllScenesResp resp = loadAllScenesWithTemplates(scenicId);
// 3. 写入缓存
String json = JacksonUtil.toJSONString(resp);
redisTemplate.opsForValue().set(cacheKey, json, CACHE_EXPIRE_HOURS, TimeUnit.HOURS);
log.debug("所有场景模板配置缓存写入: scenicId={}, sceneCount={}",
scenicId, resp.getScenes() != null ? resp.getScenes().size() : 0);
return resp;
}
/**
* 加载所有场景及其模板配置(内部方法)
*/
private WechatSubscribeAllScenesResp loadAllScenesWithTemplates(Long scenicId) {
List<String> sceneKeys = listAllSceneKeys(scenicId);
WechatSubscribeAllScenesResp resp = new WechatSubscribeAllScenesResp();
resp.setScenicId(scenicId);
List<WechatSubscribeAllScenesResp.SceneWithTemplates> scenes = new ArrayList<>();
for (String sceneKey : sceneKeys) {
List<WechatSubscribeTemplateConfigEntity> configs = getSceneTemplateConfigsCached(scenicId, sceneKey);
WechatSubscribeAllScenesResp.SceneWithTemplates sceneWithTemplates = new WechatSubscribeAllScenesResp.SceneWithTemplates();
sceneWithTemplates.setSceneKey(sceneKey);
List<WechatSubscribeAllScenesResp.StaticTemplateInfo> templates = new ArrayList<>();
for (WechatSubscribeTemplateConfigEntity cfg : configs) {
if (cfg == null || StringUtils.isBlank(cfg.getWechatTemplateId())) {
continue;
}
WechatSubscribeAllScenesResp.StaticTemplateInfo info = new WechatSubscribeAllScenesResp.StaticTemplateInfo();
info.setTemplateKey(cfg.getTemplateKey());
info.setWechatTemplateId(cfg.getWechatTemplateId());
info.setTitle(StringUtils.isNotBlank(cfg.getTitleTemplate()) ? cfg.getTitleTemplate() : cfg.getTemplateKey());
info.setDescription(cfg.getDescription());
templates.add(info);
}
sceneWithTemplates.setTemplates(templates);
scenes.add(sceneWithTemplates);
}
resp.setScenes(scenes);
return resp;
}
// ==================== 带缓存的配置查询方法 ==================== // ==================== 带缓存的配置查询方法 ====================
/** /**
@@ -386,6 +484,10 @@ public class WechatSubscribeNotifyConfigRepository {
String eventPattern = String.format(EVENT_TEMPLATE_SCENIC_PREFIX, scenicId); String eventPattern = String.format(EVENT_TEMPLATE_SCENIC_PREFIX, scenicId);
deleteByPattern(eventPattern); deleteByPattern(eventPattern);
// 清除所有场景模板缓存
String allScenesKey = String.format(ALL_SCENES_TEMPLATES_KEY, scenicId);
redisTemplate.delete(allScenesKey);
log.info("清除景区所有订阅消息配置缓存: scenicId={}", scenicId); log.info("清除景区所有订阅消息配置缓存: scenicId={}", scenicId);
} }
@@ -396,6 +498,7 @@ public class WechatSubscribeNotifyConfigRepository {
public void clearAllConfigsCache() { public void clearAllConfigsCache() {
deleteByPattern("wechat:subscribe:scene:configs:*"); deleteByPattern("wechat:subscribe:scene:configs:*");
deleteByPattern("wechat:subscribe:event:configs:*"); deleteByPattern("wechat:subscribe:event:configs:*");
deleteByPattern("wechat:subscribe:all-scenes:configs:*");
log.warn("清除所有订阅消息配置缓存"); log.warn("清除所有订阅消息配置缓存");
} }

View File

@@ -228,31 +228,30 @@ public class UserNotificationAuthorizationServiceImpl implements UserNotificatio
public Map<String, UserNotificationAuthorizationEntity> batchCheckAuthorization( public Map<String, UserNotificationAuthorizationEntity> batchCheckAuthorization(
Long memberId, List<String> templateIds, Long scenicId) { Long memberId, List<String> templateIds, Long scenicId) {
log.debug("批量检查用户授权: memberId={}, templateIds={}, scenicId={}", memberId, templateIds, scenicId); log.debug("批量检查用户授权: memberId={}, templateIds={}, scenicId={}", memberId, templateIds, scenicId);
Map<String, UserNotificationAuthorizationEntity> result = new HashMap<>(); Map<String, UserNotificationAuthorizationEntity> result = new HashMap<>();
if (templateIds == null || templateIds.isEmpty()) { if (templateIds == null || templateIds.isEmpty()) {
return result; return result;
} }
// 查询用户在该景区的所有授权记录 // 使用批量查询方法
List<UserNotificationAuthorizationEntity> userRecords = getUserAuthorizations(memberId); List<UserNotificationAuthorizationEntity> records =
userNotificationAuthorizationMapper.selectBatchByTemplateIds(memberId, templateIds, scenicId);
// 过滤出指定景区和模板的记录
Map<String, UserNotificationAuthorizationEntity> recordMap = userRecords.stream() // 转换为Map
.filter(record -> scenicId.equals(record.getScenicId())) Map<String, UserNotificationAuthorizationEntity> recordMap = records.stream()
.filter(record -> templateIds.contains(record.getTemplateId()))
.collect(Collectors.toMap( .collect(Collectors.toMap(
UserNotificationAuthorizationEntity::getTemplateId, UserNotificationAuthorizationEntity::getTemplateId,
record -> record, record -> record,
(existing, replacement) -> existing (existing, replacement) -> existing
)); ));
// 为每个模板ID填充结果 // 为每个模板ID填充结果
for (String templateId : templateIds) { for (String templateId : templateIds) {
result.put(templateId, recordMap.get(templateId)); result.put(templateId, recordMap.get(templateId));
} }
return result; return result;
} }

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.service.notify; package com.ycwl.basic.service.notify;
import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeAllScenesResp;
import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity;
import com.ycwl.basic.repository.WechatSubscribeNotifyConfigRepository; import com.ycwl.basic.repository.WechatSubscribeNotifyConfigRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -33,6 +34,26 @@ public class WechatSubscribeNotifyConfigService {
return configRepository.getSceneTemplateConfigsCached(scenicId, sceneKey); return configRepository.getSceneTemplateConfigsCached(scenicId, sceneKey);
} }
/**
* 获取景区下所有场景Key列表
*
* @param scenicId 景区ID
* @return 场景Key列表
*/
public List<String> listAllSceneKeys(Long scenicId) {
return configRepository.listAllSceneKeys(scenicId);
}
/**
* 获取景区下所有场景及其模板列表(带缓存,不含用户授权信息)
*
* @param scenicId 景区ID
* @return 所有场景及模板配置
*/
public WechatSubscribeAllScenesResp getAllScenesWithTemplatesCached(Long scenicId) {
return configRepository.getAllScenesWithTemplatesCached(scenicId);
}
/** /**
* 获取事件下的模板配置列表(带缓存) * 获取事件下的模板配置列表(带缓存)
* *

View File

@@ -92,12 +92,26 @@
<select id="selectRemainingCount" resultType="java.lang.Integer"> <select id="selectRemainingCount" resultType="java.lang.Integer">
SELECT COALESCE(remaining_count, 0) SELECT COALESCE(remaining_count, 0)
FROM user_notification_authorization FROM user_notification_authorization
WHERE member_id = #{memberId} WHERE member_id = #{memberId}
AND template_id = #{templateId} AND template_id = #{templateId}
AND scenic_id = #{scenicId} AND scenic_id = #{scenicId}
AND status = 1 AND status = 1
AND remaining_count > 0 AND remaining_count > 0
LIMIT 1 LIMIT 1
</select> </select>
<!-- 批量查询用户对多个模板的授权记录 -->
<select id="selectBatchByTemplateIds" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM user_notification_authorization
WHERE member_id = #{memberId}
AND scenic_id = #{scenicId}
AND status = 1
AND template_id IN
<foreach collection="templateIds" item="templateId" open="(" separator="," close=")">
#{templateId}
</foreach>
</select>
</mapper> </mapper>