Commit 785bbfc4 by zhangxingmin

Merge remote-tracking branch 'origin/dev' into prod

parents 03839f6b 82141c7e
package com.yd.notice.api.controller;
import com.yd.common.result.Result;
import com.yd.notice.api.service.ApiSubscribeRecordService;
import com.yd.notice.feign.request.SubscribeBatchRequest;
import com.yd.notice.feign.request.SubscribeRemainingRequest;
import com.yd.notice.feign.response.SubscribeRemainingResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
/**
* 小程序订阅记录管理
*
* @author zxm
* @since 2026-04-02
*/
@Slf4j
@RestController
@RequestMapping("/subscribe")
@Validated
public class SubscribeRecordController {
@Autowired
private ApiSubscribeRecordService apiSubscribeRecordService;
/**
* 批量保存订阅记录(用户授权后调用)
*/
@PostMapping("/record/batch")
public Result<Void> batchSave(@Valid @RequestBody SubscribeBatchRequest request) {
return apiSubscribeRecordService.batchSave(request);
}
/**
* 查询用户对某个微信模板的剩余有效订阅次数
*/
@GetMapping("/remaining")
public Result<SubscribeRemainingResponse> getRemaining(@Valid SubscribeRemainingRequest request) {
return apiSubscribeRecordService.getRemaining(request);
}
}
\ No newline at end of file
package com.yd.notice.api.service;
import com.yd.common.result.Result;
import com.yd.notice.feign.request.SubscribeBatchRequest;
import com.yd.notice.feign.request.SubscribeRemainingRequest;
import com.yd.notice.feign.response.SubscribeRemainingResponse;
/**
* 小程序订阅记录 API 服务接口
*
* @author zxm
* @since 2026-04-02
*/
public interface ApiSubscribeRecordService {
/**
* 批量保存订阅记录
*/
Result<Void> batchSave(SubscribeBatchRequest request);
/**
* 查询用户对某个微信模板的剩余有效订阅次数
*/
Result<SubscribeRemainingResponse> getRemaining(SubscribeRemainingRequest request);
}
\ No newline at end of file
package com.yd.notice.api.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.yd.common.result.Result;
import com.yd.common.result.JsonResult;
import com.yd.notice.api.service.ApiSubscribeRecordService;
import com.yd.notice.feign.dto.SubscribeRecordDTO;
import com.yd.notice.feign.request.SubscribeBatchRequest;
import com.yd.notice.feign.request.SubscribeRemainingRequest;
import com.yd.notice.feign.response.QuerySfpUserResponse;
import com.yd.notice.feign.response.SubscribeRemainingResponse;
import com.yd.notice.service.model.NotificationTemplate;
import com.yd.notice.service.model.SubscribeRecord;
import com.yd.notice.service.service.INotificationTemplateService;
import com.yd.notice.service.service.ISubscribeRecordService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 小程序订阅记录 API 服务实现类
*
* @author zxm
* @since 2026-04-02
*/
@Slf4j
@Service
public class ApiSubscribeRecordServiceImpl implements ApiSubscribeRecordService {
@Autowired
private ISubscribeRecordService subscribeRecordService;
@Autowired
private INotificationTemplateService templateService;
@Autowired
private RestTemplate restTemplate;
@Resource
private Environment env;
/**
* 根据当前环境获取 ajb 服务基础地址
*/
private String getAjbBaseUrl() {
String activeProfile = env.getProperty("spring.profiles.active", "dev");
log.info("当前环境: {}, 获取 ajb 服务地址", activeProfile);
if ("prod".equalsIgnoreCase(activeProfile) || "production".equalsIgnoreCase(activeProfile)) {
// 生产环境 ajb 服务地址(请根据实际部署确认)
return "https://hoservice.ydhomeoffice.cn/hoserviceApi";
} else {
// 测试/开发环境
return "https://mdev.anjibao.cn/sfpApi";
}
}
/**
* 从请求头中获取 Token,通过远程解析获取用户的完整信息(包含 openid 和 userId)
*
* @return QuerySfpUserResponse 用户信息
* @throws RuntimeException 如果获取失败
*/
private QuerySfpUserResponse getUserInfoFromToken() {
// 获取当前请求上下文
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
log.error("无法获取当前请求上下文");
throw new RuntimeException("无法获取当前请求上下文");
}
HttpServletRequest request = attributes.getRequest();
String authorization = request.getHeader("X-Authorization");
if (!StringUtils.hasText(authorization)) {
log.error("请求头 X-Authorization 为空");
throw new RuntimeException("未提供认证 Token");
}
// 直接使用原始 authorization 值,不做任何处理,远程接口会自动剥离前缀
String token = authorization;
log.info("调用远程解析 Token 接口,原始 authorization: {}", authorization);
// 根据环境获取 ajb 服务地址
String ajbBaseUrl = getAjbBaseUrl();
String parseUrl = ajbBaseUrl + "/user/parse/token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> requestBody = new HashMap<>();
requestBody.put("token", token);
HttpEntity<Map<String, String>> entity = new HttpEntity<>(requestBody, headers);
log.info("调用远程解析 Token 接口, url={}, token前50字符: {}", parseUrl,
token.substring(0, Math.min(50, token.length())) + "...");
try {
ResponseEntity<JsonResult> responseEntity = restTemplate.postForEntity(parseUrl, entity, JsonResult.class);
if (responseEntity.getStatusCode().is2xxSuccessful()) {
JsonResult body = responseEntity.getBody();
if (body != null && body.isSuccess()) {
Object data = body.getData();
if (data != null) {
QuerySfpUserResponse userInfo = JSON.parseObject(JSON.toJSONString(data), QuerySfpUserResponse.class);
if (userInfo.getId() == null) {
log.error("远程解析返回的用户ID为空");
throw new RuntimeException("用户信息不完整");
}
log.info("远程解析 Token 成功,用户ID: {}, openid: {}", userInfo.getId(), userInfo.getWxOpenId());
return userInfo;
}
}
}
log.error("远程解析 Token 失败, response={}", responseEntity);
throw new RuntimeException("获取用户信息失败");
} catch (Exception e) {
log.error("调用远程解析 Token 接口异常", e);
throw new RuntimeException("获取用户信息异常: " + e.getMessage());
}
}
@Override
public Result<Void> batchSave(SubscribeBatchRequest request) {
log.info("批量保存订阅记录, request={}", JSON.toJSONString(request));
List<SubscribeRecordDTO> dtoList = request.getRecords();
if (dtoList == null || dtoList.isEmpty()) {
return Result.fail("订阅记录列表为空");
}
// 从 Token 中获取当前用户的完整信息(所有记录共用同一个用户)
QuerySfpUserResponse userInfo;
try {
userInfo = getUserInfoFromToken();
} catch (Exception e) {
log.error("获取用户信息失败", e);
return Result.fail("获取用户信息失败:" + e.getMessage());
}
String openid = userInfo.getWxOpenId();
String userId = userInfo.getId().toString(); // Long -> String
List<SubscribeRecord> records = new ArrayList<>();
for (SubscribeRecordDTO dto : dtoList) {
// 1. 根据 wxTemplateId 查询模板信息
NotificationTemplate template = templateService.getOne(
new LambdaQueryWrapper<NotificationTemplate>()
.eq(NotificationTemplate::getExtraTemplate, dto.getWxTemplateId())
.eq(NotificationTemplate::getStatus, 1)
.orderByDesc(NotificationTemplate::getCreateTime)
.last("LIMIT 1")
);
if (template == null) {
log.error("模板不存在, wxTemplateId={}", dto.getWxTemplateId());
return Result.fail("模板不存在: " + dto.getWxTemplateId());
}
// 2. 构建实体对象
SubscribeRecord record = new SubscribeRecord();
record.setProjectType(dto.getProjectType());
record.setWxTemplateId(dto.getWxTemplateId());
record.setOpenid(openid);
record.setUserId(userId); // 设置用户ID
record.setTemplateBizId(template.getTemplateBizId());
record.setChannelBizId(template.getChannelBizId());
record.setStatus(0);
record.setSubscribeTime(LocalDateTime.now());
record.setExpireTime(LocalDateTime.now().plusDays(7));
records.add(record);
}
boolean success = subscribeRecordService.batchSave(records);
if (success) {
log.info("批量保存订阅记录成功, 数量={}, 用户ID={}", records.size(), userId);
return Result.success();
} else {
log.error("批量保存订阅记录失败");
return Result.fail("批量保存失败");
}
}
@Override
public Result<SubscribeRemainingResponse> getRemaining(SubscribeRemainingRequest request) {
log.info("查询剩余次数, request={}", JSON.toJSONString(request));
// 从 Token 中获取当前用户的 openid
String openid;
try {
QuerySfpUserResponse userInfo = getUserInfoFromToken();
openid = userInfo.getWxOpenId();
} catch (Exception e) {
log.error("获取用户 openid 失败", e);
return Result.fail("获取用户信息失败:" + e.getMessage());
}
String wxTemplateId = request.getWxTemplateId();
int count = subscribeRecordService.countRemainingByWxTemplateId(openid, wxTemplateId);
SubscribeRemainingResponse response = new SubscribeRemainingResponse();
response.setOpenid(openid);
response.setWxTemplateId(wxTemplateId);
response.setRemainingCount(count);
log.info("查询剩余次数结果, count={}", count);
return Result.success(response);
}
}
\ No newline at end of file
package com.yd.notice.feign.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 小程序订阅记录 DTO
*
* @author zxm
* @since 2026-04-02
*/
@Data
public class SubscribeRecordDTO {
/**
* 项目类型(SFP、CFPP等)
*/
@NotBlank(message = "项目类型不能为空")
private String projectType;
/**
* 微信侧实际模板ID(必填,用于查询模板表)
*/
@NotBlank(message = "wxTemplateId模板ID不能为空")
private String wxTemplateId;
// /**
// * 用户小程序openid(必填)
// */
// @NotBlank(message = "用户小程序openid不能为空")
// private String openid;
}
\ No newline at end of file
package com.yd.notice.feign.request;
import com.yd.notice.feign.dto.SubscribeRecordDTO;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.util.List;
@Data
public class SubscribeBatchRequest {
/**
* 批量订阅记录列表
*/
@NotEmpty(message = "订阅记录列表不能为空")
@Valid
private List<SubscribeRecordDTO> records;
}
\ No newline at end of file
package com.yd.notice.feign.request;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 查询剩余订阅次数请求
*
* @author zxm
* @since 2026-04-02
*/
@Data
public class SubscribeRemainingRequest {
// @NotBlank(message = "openid不能为空")
// private String openid;
@NotBlank(message = "wxTemplateId模板ID不能为空")
private String wxTemplateId;
}
\ No newline at end of file
package com.yd.notice.feign.response;
import lombok.Data;
import java.util.Date;
@Data
public class QuerySfpUserResponse {
private Long id;//serial idPRIauto_increment
private String name;//userName
private String mobile;//手机号
private Integer gender;//性别,1,男,2,女
private String age;//年龄
private String manageMobile;//管理经纪人手机号(保单托管)
private Date loginDate;//登录时间(用于判断该用户是否登录过期 30天, 登录成功更新,退出或者切换账号更新)
private Integer loginState;//是否登录(0=No, 1=Yes)
private String customerSource;//客户来源
private String level;//用户等级
private String levelName;//用户等级名称
private Integer isVip;//是否vip
private Date vipExpireDate;//vip到期时间
private Date inactivateDate;//激活到期时间
private Integer remainingTimes;//剩余次数
private Integer isMax;//是否达到最大购买次数 0-否 1-是
private Integer userType;//用户类型
private Long roleId;//用户角色ID
private String systemType; //系统类型(tvm、sfp、policyManage)
private String tenantProjectRoleIds;//租户项目角色ID列表
private String flag;//标识
private String remark;//备注
private String wxNickname;//微信昵称
private String wxOpenId;//用户小程序登录的openid
private String wxOpenIdGzh;//用户公众号登录的openid
private Integer isBlacklist;//是否黑名单 0-否 1-是
private Integer isActive;//是否启用(0=No, 1=Yes)
private Date createdAt;//创建时间
private String createdBy;//创建人
private Date updatedAt;//修改时间
private String updatedBy;//修改人
/**
* 律家保-法税卡激活接口,请求⽅用户唯⼀识别号 (用于跳转到律家保页面的挂参)
*/
private String ljbRefId;
}
package com.yd.notice.feign.response;
import lombok.Data;
/**
* 查询剩余订阅次数响应
*
* @author zxm
* @since 2026-04-02
*/
@Data
public class SubscribeRemainingResponse {
/**
* 微信用户openid
*/
private String openid;
/**
* 模板ID
*/
private String wxTemplateId;
/**
* 用户对某个微信模板的剩余有效订阅次数
*/
private Integer remainingCount;
}
\ No newline at end of file
...@@ -125,6 +125,5 @@ ...@@ -125,6 +125,5 @@
<artifactId>weixin-java-miniapp</artifactId> <artifactId>weixin-java-miniapp</artifactId>
<version>4.6.0</version> <version>4.6.0</version>
</dependency> </dependency>
</dependencies> </dependencies>
</project> </project>
\ No newline at end of file
package com.yd.notice.service.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yd.notice.service.model.SubscribeRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 订阅记录 Mapper
*
* @author zxm
* @since 2026-04-02
*/
@Mapper
public interface SubscribeRecordMapper extends BaseMapper<SubscribeRecord> {
/**
* 统计剩余有效次数(未过期、未消耗)
*/
@Select("SELECT COUNT(*) FROM subscribe_record " +
"WHERE openid = #{openid} " +
"AND wx_template_id = #{wxTemplateId} " +
"AND status = 0 " +
"AND expire_time > NOW() " +
"AND is_deleted = 0")
int countRemainingByWxTemplateId(@Param("openid") String openid,
@Param("wxTemplateId") String wxTemplateId);
/**
* 消耗一条有效记录(取最早的一条)
*/
@Update("UPDATE subscribe_record SET status = 1, consume_time = NOW() " +
"WHERE id = ( " +
" SELECT id FROM ( " +
" SELECT id FROM subscribe_record " +
" WHERE openid = #{openid} " +
" AND wx_template_id = #{wxTemplateId} " +
" AND status = 0 " +
" AND expire_time > NOW() " +
" AND is_deleted = 0 " +
" ORDER BY subscribe_time ASC LIMIT 1 " +
" ) AS tmp " +
")")
int consumeOneByWxTemplateId(@Param("openid") String openid,
@Param("wxTemplateId") String wxTemplateId);
}
\ No newline at end of file
package com.yd.notice.service.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
/**
* <p>
* 小程序订阅记录表
* </p>
*
* @author zxm
* @since 2026-08-27
*/
@Getter
@Setter
@TableName("subscribe_record")
public class SubscribeRecord implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 项目类型(SFP、CFPP等)
*/
@TableField("project_type")
private String projectType;
/**
* 项目唯一业务ID
*/
@TableField("project_biz_id")
private String projectBizId;
/**
* 渠道业务ID(关联channel_config)
*/
@TableField("channel_biz_id")
private String channelBizId;
/**
* 模板业务ID(关联notification_template)
*/
@TableField("template_biz_id")
private String templateBizId;
/**
* 微信侧实际模板ID(extra_template冗余,便于查询)
*/
@TableField("wx_template_id")
private String wxTemplateId;
/**
* 用户openid
*/
@TableField("openid")
private String openid;
/**
* 用户ID
*/
@TableField("user_id")
private String userId;
/**
* 0-有效(未消耗) 1-已消耗
*/
@TableField("status")
private Integer status;
/**
* 授权时间
*/
@TableField("subscribe_time")
private LocalDateTime subscribeTime;
/**
* 过期时间(订阅时间+7天)
*/
@TableField("expire_time")
private LocalDateTime expireTime;
/**
* 消耗时间
*/
@TableField("consume_time")
private LocalDateTime consumeTime;
/**
* 通用备注
*/
@TableField("remark")
private String remark;
/**
* 删除标识: 0-正常, 1-删除
*/
@TableField("is_deleted")
private Integer isDeleted;
/**
* 创建人ID
*/
@TableField("creator_id")
private String creatorId;
/**
* 更新人ID
*/
@TableField("updater_id")
private String updaterId;
/**
* 创建时间
*/
@TableField("create_time")
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField("update_time")
private LocalDateTime updateTime;
}
...@@ -11,6 +11,7 @@ import com.yd.notice.service.model.NotificationTask; ...@@ -11,6 +11,7 @@ import com.yd.notice.service.model.NotificationTask;
import com.yd.notice.service.model.NotificationTemplate; import com.yd.notice.service.model.NotificationTemplate;
import com.yd.notice.service.service.IChannelConfigService; import com.yd.notice.service.service.IChannelConfigService;
import com.yd.notice.service.service.INotificationTemplateService; import com.yd.notice.service.service.INotificationTemplateService;
import com.yd.notice.service.service.ISubscribeRecordService; // 新增导入
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.error.WxErrorException; import me.chanjar.weixin.common.error.WxErrorException;
...@@ -27,6 +28,7 @@ public class MiniprogramMessageSender implements MessageSender { ...@@ -27,6 +28,7 @@ public class MiniprogramMessageSender implements MessageSender {
private final IChannelConfigService channelConfigService; private final IChannelConfigService channelConfigService;
private final INotificationTemplateService templateService; private final INotificationTemplateService templateService;
private final ISubscribeRecordService subscribeRecordService; // 新增注入
// 不可重试的错误码(永久失败) // 不可重试的错误码(永久失败)
private static final String USER_REFUSE_ERROR = "43101"; // 用户拒绝接收 private static final String USER_REFUSE_ERROR = "43101"; // 用户拒绝接收
...@@ -60,19 +62,17 @@ public class MiniprogramMessageSender implements MessageSender { ...@@ -60,19 +62,17 @@ public class MiniprogramMessageSender implements MessageSender {
} }
// 3. 解析 content_template 并渲染占位符 // 3. 解析 content_template 并渲染占位符
// task.getContent() 已经是渲染后的完整 JSON 字符串(由 TemplateRenderUtil 提前处理)
// 但为了标准化,我们在这里再次解析,顺便提取 page 跳转路径
String renderedContent = task.getContent(); String renderedContent = task.getContent();
JSONObject contentJson = JSONObject.parseObject(renderedContent); JSONObject contentJson = JSONObject.parseObject(renderedContent);
log.info("进入小程序消息发送器=>消息内容JSON:{}", JSON.toJSONString(contentJson)); log.info("进入小程序消息发送器=>消息内容JSON:{}", JSON.toJSONString(contentJson));
// 提取 page(跳转路径),并从 data 中移除 // 提取 page(跳转路径),并从 data 中移除
String page = contentJson.getString("page"); String page = contentJson.getString("page");
log.info("进入小程序消息发送器=>page:{}", page); log.info("进入小程序消息发送器=>page:{}", page);
contentJson.remove("page"); contentJson.remove("page");
log.info("进入小程序消息发送器=>移除page:{}", JSON.toJSONString(contentJson)); log.info("进入小程序消息发送器=>移除page:{}", JSON.toJSONString(contentJson));
// 4. 构建微信订阅消息数据结构(新版本使用 List<MsgData>) // 4. 构建微信订阅消息数据结构
List<WxMaSubscribeMessage.MsgData> dataList = new ArrayList<>(); List<WxMaSubscribeMessage.MsgData> dataList = new ArrayList<>();
for (Map.Entry<String, Object> entry : contentJson.entrySet()) { for (Map.Entry<String, Object> entry : contentJson.entrySet()) {
String key = entry.getKey(); String key = entry.getKey();
...@@ -92,25 +92,38 @@ public class MiniprogramMessageSender implements MessageSender { ...@@ -92,25 +92,38 @@ public class MiniprogramMessageSender implements MessageSender {
.toUser(task.getReceiver()) .toUser(task.getReceiver())
.templateId(wxTemplateId) .templateId(wxTemplateId)
.data(dataList) .data(dataList)
.page(page) // 如果 page 为空,builder 也会处理 .page(page)
.build(); .build();
// log.info("进入小程序消息发送器=>构建消息体WxMaSubscribeMessage:{}", JSON.toJSONString(message));
// 6. 调用微信 SDK 发送 // 6. 调用微信 SDK 发送
WxMaService wxMaService = buildWxMaService(appid, secret); WxMaService wxMaService = buildWxMaService(appid, secret);
wxMaService.getMsgService().sendSubscribeMsg(message); wxMaService.getMsgService().sendSubscribeMsg(message);
log.info("微信小程序订阅消息发送成功, taskBizId={}, openid={}", task.getTaskBizId(), task.getReceiver()); log.info("微信小程序订阅消息发送成功, taskBizId={}, openid={}", task.getTaskBizId(), task.getReceiver());
// ========== 新增:发送成功后消耗一条订阅记录 ==========
try {
String openid = task.getReceiver();
// 使用从模板中获取的 wxTemplateId
boolean consumed = subscribeRecordService.consumeOneByWxTemplateId(openid, wxTemplateId);
if (consumed) {
log.info("消耗订阅记录成功, openid={}, wxTemplateId={}", openid, wxTemplateId);
} else {
log.warn("发送成功但无可用订阅记录可消耗, openid={}, wxTemplateId={}", openid, wxTemplateId);
}
} catch (Exception e) {
// 消耗失败不影响发送结果,仅记录日志
log.error("消耗订阅记录异常, openid={}, wxTemplateId={}", task.getReceiver(), wxTemplateId, e);
}
return SendResult.success(); return SendResult.success();
} catch (WxErrorException e) { } catch (WxErrorException e) {
// 微信 API 抛出的具体异常(含错误码) Integer errorCode = e.getError().getErrorCode();
Integer errorCode = e.getError().getErrorCode(); // 正确获取错误码
String errCode = errorCode != null ? errorCode.toString() : "UNKNOWN"; String errCode = errorCode != null ? errorCode.toString() : "UNKNOWN";
String errMsg = e.getMessage(); String errMsg = e.getMessage();
log.info("微信小程序发送失败, taskBizId={}, errCode={}, errMsg={}", task.getTaskBizId(), errCode, errMsg); log.info("微信小程序发送失败, taskBizId={}, errCode={}, errMsg={}", task.getTaskBizId(), errCode, errMsg);
// 处理不可重试错误
if (USER_REFUSE_ERROR.equals(errCode) || TEMPLATE_INVALID.equals(errCode)) { if (USER_REFUSE_ERROR.equals(errCode) || TEMPLATE_INVALID.equals(errCode)) {
return SendResult.failNonRetryable(errCode, errMsg); return SendResult.failNonRetryable(errCode, errMsg);
} }
...@@ -133,6 +146,6 @@ public class MiniprogramMessageSender implements MessageSender { ...@@ -133,6 +146,6 @@ public class MiniprogramMessageSender implements MessageSender {
@Override @Override
public String getSupportedChannelType() { public String getSupportedChannelType() {
return "miniprogram"; // 必须与 channel_config.channel_type 一致 return "miniprogram";
} }
} }
\ No newline at end of file
package com.yd.notice.service.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yd.notice.service.model.SubscribeRecord;
import java.util.List;
/**
* 订阅记录服务接口
*
* @author zxm
* @since 2026-04-02
*/
public interface ISubscribeRecordService extends IService<SubscribeRecord> {
/**
* 批量保存订阅记录
*/
boolean batchSave(List<SubscribeRecord> records);
/**
* 统计剩余有效次数(通过 wxTemplateId)
*/
int countRemainingByWxTemplateId(String openid, String wxTemplateId);
/**
* 消耗一条有效记录(通过 wxTemplateId)
*/
boolean consumeOneByWxTemplateId(String openid, String wxTemplateId);
}
\ No newline at end of file
package com.yd.notice.service.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yd.notice.service.dao.SubscribeRecordMapper;
import com.yd.notice.service.model.SubscribeRecord;
import com.yd.notice.service.service.ISubscribeRecordService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* 订阅记录服务实现类
*
* @author zxm
* @since 2026-04-02
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SubscribeRecordServiceImpl extends ServiceImpl<SubscribeRecordMapper, SubscribeRecord> implements ISubscribeRecordService {
@Override
@Transactional(rollbackFor = Exception.class)
public boolean batchSave(List<SubscribeRecord> records) {
if (records == null || records.isEmpty()) {
return false;
}
return saveBatch(records);
}
@Override
public int countRemainingByWxTemplateId(String openid, String wxTemplateId) {
if (!StringUtils.hasText(openid) || !StringUtils.hasText(wxTemplateId)) {
return 0;
}
return baseMapper.countRemainingByWxTemplateId(openid, wxTemplateId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean consumeOneByWxTemplateId(String openid, String wxTemplateId) {
if (!StringUtils.hasText(openid) || !StringUtils.hasText(wxTemplateId)) {
return false;
}
int affected = baseMapper.consumeOneByWxTemplateId(openid, wxTemplateId);
if (affected > 0) {
log.info("消耗订阅记录成功, openid={}, wxTemplateId={}", openid, wxTemplateId);
} else {
log.warn("无可消耗的有效订阅记录, openid={}, wxTemplateId={}", openid, wxTemplateId);
}
return affected > 0;
}
}
\ No newline at end of file
...@@ -21,7 +21,7 @@ public class MyBatisPlusCodeGenerator { ...@@ -21,7 +21,7 @@ public class MyBatisPlusCodeGenerator {
}) })
.strategyConfig(builder -> { .strategyConfig(builder -> {
builder.addInclude( builder.addInclude(
"channel_config","notification_record","notification_task","notification_template" "subscribe_record"
) )
.entityBuilder() .entityBuilder()
.enableLombok() .enableLombok()
......
package com.yd.notice.service.utils;
import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* SFP 项目专用 JWT Token 工具类
* 完全在代码内部获取环境标识(System.getProperty),硬编码不同环境的配置
* 支持生成 Token 和解析 Token(获取 userId、customerId 等)
*
* @author yourname
*/
@Slf4j
@Service
public class SfpTokenUtil {
// JWT 标准声明 Key
private static final String CLAIM_KEY_SUBJECT = "sub";
private static final String CLAIM_KEY_CREATED = "created";
// 自定义声明 Key(与 JwtTokenUtil 保持一致)
private static final String CLAIM_KEY_USERID = "UserId";
private static final String CLAIM_KEY_CUSTOMERID = "CustomerId";
// 静态配置字段
private static String seal;
private static String issuer;
private static Long expiration;
/**
* 初始化:通过 System.getProperty 获取环境,硬编码对应的配置
* 该方法在 Spring 容器启动完成后自动执行
*/
@PostConstruct
public void init() {
// 从系统属性中获取 spring.profiles.active,如果获取不到则默认为 "default"
String activeProfile = System.getProperty("spring.profiles.active", "default");
// 判断是否为生产环境(可自定义判断规则,如包含 "prod" 或 "production")
boolean isProd = "prod".equalsIgnoreCase(activeProfile)
|| "production".equalsIgnoreCase(activeProfile)
|| activeProfile.toLowerCase().contains("prod");
if (isProd) {
// 生产环境配置(请替换为实际的生产密钥)
seal = "SFP2023!@#";
issuer = "sfpfamilyfinancialplanning";
expiration = 2592000L; // 24小时
log.info("SfpTokenUtil 加载生产环境配置, seal: {}, issuer: {}, expiration: {} 秒", seal, issuer, expiration);
} else {
// 测试/开发环境配置(请替换为实际的测试密钥)
seal = "zhb123!@#";
issuer = "zuihuibi";
expiration = 2592000L;
log.info("SfpTokenUtil 加载测试环境配置, seal: {},issuer: {}, expiration: {} 秒", seal, issuer, expiration);
}
}
// ======================== 生成 Token ========================
/**
* 生成 JWT Token(自动添加 issuer 前缀,如 "zuihuibi {token}")
*
* @param ticket 用户标识(通常为登录名或唯一标识)
* @param userId 用户 ID(字符串)
* @param customerId 客户 ID(可选)
* @return 完整 Token 字符串(含前缀)
*/
public static String generateToken(String ticket, String userId, String customerId) {
Map<String, Object> claims = new HashMap<>();
claims.put(CLAIM_KEY_SUBJECT, ticket);
claims.put(CLAIM_KEY_USERID, userId);
if (customerId != null) {
claims.put(CLAIM_KEY_CUSTOMERID, customerId);
}
claims.put(CLAIM_KEY_CREATED, new Date());
String token = Jwts.builder()
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(SignatureAlgorithm.HS512, seal)
.compact();
return issuer + " " + token;
}
// ======================== 解析 Token ========================
/**
* 从 Token 中解析 Claims(包含所有声明信息)
*
* @param token 原始 Token(可能包含 issuer 前缀)
* @return Map, key 包含 "resultCode"(SUCCESS/EXPIRED/INVALID)和 "claims"(解析成功时为 Claims 对象)
*/
public static Map<String, Object> getClaimsFromToken(String token) {
Map<String, Object> map = new HashMap<>();
String resultCode = "SUCCESS";
Claims claims = null;
log.info("SfpTokenUtil.getClaimsFromToken - 原始 token (前50字符): {}",
token != null ? token.substring(0, Math.min(50, token.length())) + "..." : "null");
try {
String realToken = token;
if (issuer != null && token.startsWith(issuer + " ")) {
realToken = token.substring(issuer.length() + 1);
log.info("SfpTokenUtil.getClaimsFromToken - 已剥离前缀 '{} ',realToken 前50字符: {}",
issuer, realToken.substring(0, Math.min(50, realToken.length())) + "...");
} else {
log.info("SfpTokenUtil.getClaimsFromToken - token 不以 '{} ' 开头,未剥离", issuer);
}
log.info("SfpTokenUtil.getClaimsFromToken - 当前使用的 seal 前缀: {}",
seal != null ? seal.substring(0, Math.min(4, seal.length())) + "..." : "null");
Jws<Claims> jwsClaims = Jwts.parser()
.setSigningKey(seal)
.parseClaimsJws(realToken);
claims = jwsClaims.getBody();
log.info("SfpTokenUtil.getClaimsFromToken - 解析成功,claims: {}", claims);
} catch (ExpiredJwtException e) {
resultCode = "EXPIRED";
log.warn("SfpTokenUtil.getClaimsFromToken - Token 已过期: {}", e.getMessage());
} catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
resultCode = "INVALID";
log.warn("SfpTokenUtil.getClaimsFromToken - Token 无效, 异常类型: {}, 消息: {}",
e.getClass().getSimpleName(), e.getMessage());
} catch (Exception e) {
resultCode = "INVALID";
log.error("SfpTokenUtil.getClaimsFromToken - 未知异常: ", e);
}
map.put("resultCode", resultCode);
map.put("claims", claims);
return map;
}
/**
* 从 Token 中获取用户 ID(String 形式)
*
* @param token 原始 Token
* @return 用户 ID 字符串,解析失败返回 null
*/
public static String getUserIdFromToken(String token) {
Map<String, Object> map = getClaimsFromToken(token);
String resultCode = (String) map.get("resultCode");
if ("SUCCESS".equals(resultCode)) {
Claims claims = (Claims) map.get("claims");
if (claims != null) {
Object userId = claims.get(CLAIM_KEY_USERID);
if (userId != null) {
log.info("SfpTokenUtil.getUserIdFromToken - 成功获取 UserId: {}", userId);
return userId.toString();
} else {
log.warn("SfpTokenUtil.getUserIdFromToken - Claims 中缺少 '{}' 字段", CLAIM_KEY_USERID);
}
} else {
log.warn("SfpTokenUtil.getUserIdFromToken - Claims 为空");
}
} else {
log.warn("SfpTokenUtil.getUserIdFromToken - 解析失败,resultCode: {}", resultCode);
}
return null;
}
/**
* 从 Token 中获取客户 ID
*
* @param token 原始 Token
* @return 客户 ID 字符串,解析失败返回 null
*/
public static String getCustomerIdFromToken(String token) {
Map<String, Object> map = getClaimsFromToken(token);
if ("SUCCESS".equals(map.get("resultCode"))) {
Claims claims = (Claims) map.get("claims");
if (claims != null) {
Object customerId = claims.get(CLAIM_KEY_CUSTOMERID);
return customerId != null ? customerId.toString() : null;
}
}
return null;
}
/**
* 验证 Token 是否有效(未过期、格式正确)
*
* @param token 原始 Token
* @return true 表示有效,false 表示无效或过期
*/
public static boolean validateToken(String token) {
Map<String, Object> map = getClaimsFromToken(token);
return "SUCCESS".equals(map.get("resultCode"));
}
/**
* 检查 Token 是否过期
*
* @param token 原始 Token
* @return true 表示已过期,false 表示未过期或解析失败
*/
public static boolean isTokenExpired(String token) {
Map<String, Object> map = getClaimsFromToken(token);
String resultCode = (String) map.get("resultCode");
if ("EXPIRED".equals(resultCode)) {
return true;
}
if ("SUCCESS".equals(resultCode)) {
Claims claims = (Claims) map.get("claims");
if (claims != null) {
Date expirationDate = claims.getExpiration();
return expirationDate != null && expirationDate.before(new Date());
}
}
return false;
}
}
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yd.notice.service.dao.SubscribeRecordMapper">
</mapper>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment