Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ public class EmailSendRequest implements Serializable {
private String subject;
private String body;

/**
* 幂等键(业务方生成,如 UUID)。provider 据此去重:
* 同一幂等键的请求无论被重试/重放多少次,只发送一次。
* 可空 —— 兼容旧调用方(无幂等键时按原逻辑直接发送)。
*/
private String idempotencyKey;

public EmailSendRequest() {}

public EmailSendRequest(String recipient, String subject, String body) {
Expand All @@ -26,4 +33,7 @@ public EmailSendRequest(String recipient, String subject, String body) {

public String getBody() { return body; }
public void setBody(String body) { this.body = body; }

public String getIdempotencyKey() { return idempotencyKey; }
public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; }
}
13 changes: 13 additions & 0 deletions OpenBlog-business/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>com.yqz</groupId>
<artifactId>OpenBlog-common</artifactId>
<version>${project.version}</version>
</dependency>

<!-- Dubbo + Nacos (consumer) -->
<dependency>
<groupId>org.apache.dubbo</groupId>
Expand All @@ -79,6 +85,13 @@
<version>2.4.3</version>
</dependency>

<!-- RocketMQ (通知异步投递,P1)。配 rocketmq.name-server;RocketMQ 为阿里系,与 Dubbo/Nacos 同生态 -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.3.2</version>
</dependency>

<dependency>
<groupId>com.yqz</groupId>
<artifactId>OpenBlog-framework-audit</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,43 @@
import com.yqz.openblog.config.AuthSecurityProperties;
import com.yqz.openblog.config.CorsProperties;
import com.yqz.openblog.config.SiteProperties;
import com.yqz.openblog.notification.NotificationProperties;
import com.yqz.openblog.seo.SeoProperties;
import org.apache.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.TypeExcludeFilter;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.scheduling.annotation.EnableScheduling;

/**
* 显式声明组件扫描(覆盖 @SpringBootApplication 的默认扫描):
* - basePackages 与默认一致;额外排除 com.yqz.openblog.common 包——
* 该包的异常处理器改由 OpenBlog-common 的自动装配(CommonAutoConfiguration)注册。
* - 显式 @ComponentScan 不会自动带上 @SpringBootApplication 默认的两个过滤,
* 因此手动补回 TypeExcludeFilter / AutoConfigurationExcludeFilter,保证
* framework 模块下的 @AutoConfiguration 类不被组件扫描重复注册。
*/
@SpringBootApplication
@EnableDubbo
@EnableScheduling
@EnableConfigurationProperties({
SiteProperties.class,
CorsProperties.class,
AuthSecurityProperties.class,
SeoProperties.class
SeoProperties.class,
NotificationProperties.class
})
@ComponentScan(
basePackages = "com.yqz.openblog",
excludeFilters = {
@ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class),
@ComponentScan.Filter(type = FilterType.REGEX, pattern = "com\\.yqz\\.openblog\\.common\\..*")
})
public class OpenBlogApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public class AuthSecurityProperties {

private Slider slider = new Slider();
private LoginLockout loginLockout = new LoginLockout();
private EmailCode emailCode = new EmailCode();

public Slider getSlider() {
return slider;
Expand All @@ -27,6 +28,14 @@ public void setLoginLockout(LoginLockout loginLockout) {
this.loginLockout = loginLockout;
}

public EmailCode getEmailCode() {
return emailCode;
}

public void setEmailCode(EmailCode emailCode) {
this.emailCode = emailCode;
}

public static class Slider {
/**
* 是否要求登录/注册前先完成「滑到尽头」验证(Redis 记录一次性凭证)。
Expand Down Expand Up @@ -101,4 +110,43 @@ public void setLockoutSeconds(int lockoutSeconds) {
this.lockoutSeconds = lockoutSeconds;
}
}

public static class EmailCode {
/**
* 验证码有效时间(秒),默认 5 分钟。
*/
private int codeTtlSeconds = 300;
/**
* 重新发送冷却时间(秒),默认 60 秒。
*/
private int resendCooldownSeconds = 60;
/**
* 校验失败允许的最大次数,超过后验证码作废,默认 5 次。
*/
private int maxVerifyAttempts = 5;

public int getCodeTtlSeconds() {
return codeTtlSeconds;
}

public void setCodeTtlSeconds(int codeTtlSeconds) {
this.codeTtlSeconds = codeTtlSeconds;
}

public int getResendCooldownSeconds() {
return resendCooldownSeconds;
}

public void setResendCooldownSeconds(int resendCooldownSeconds) {
this.resendCooldownSeconds = resendCooldownSeconds;
}

public int getMaxVerifyAttempts() {
return maxVerifyAttempts;
}

public void setMaxVerifyAttempts(int maxVerifyAttempts) {
this.maxVerifyAttempts = maxVerifyAttempts;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.yqz.openblog.notification;

/**
* 通知渠道模板方法基类。
* <p>
* 固定投递骨架:模板渲染 → doSend(子类实现渠道差异)。子类只需实现 {@link #type()} 与
* {@link #doSend(NotificationMessage, String)},统一处理模板渲染,降低渠道实现与调用方的耦合。
* 新增渠道(SMS / 飞书)只需继承本类,主链路零改动。
*/
public abstract class AbstractNotificationChannel implements NotificationChannel {

private final NotificationTemplateService templateService;

protected AbstractNotificationChannel(NotificationTemplateService templateService) {
this.templateService = templateService;
}

/**
* 模板方法(final,子类不可覆写骨架):渲染内容后交给子类真正投递。
* 渲染失败抛 {@link com.yqz.openblog.common.BizException}(4000),投递失败由 doSend 抛(5002 等)。
*/
@Override
public final void send(NotificationMessage message) {
String content = templateService.render(message.getTemplateCode(), message.getParams());
doSend(message, content);
}

/**
* 子类实现:真正投递。入参为完整消息(含 {@code messageId} 幂等键)与已渲染内容。
* Email → Dubbo RPC 调 email 模块;未来 SMS → 阿里云短信;Feishu → webhook。
* 失败必须抛 {@link com.yqz.openblog.common.BizException},由调用方决定是否回滚。
*/
protected abstract void doSend(NotificationMessage message, String content);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.yqz.openblog.notification;

import com.yqz.openblog.common.BizException;
import org.springframework.stereotype.Component;

import java.util.EnumMap;
import java.util.List;
import java.util.Map;

/**
* 渠道路由表:把容器里所有启用的 {@link NotificationChannel} 按 {@code type()} 收敛成 Map,
* {@link NotificationService} 据此分发。新增渠道 = 新增实现类(自动被 Spring 收集),主链路零改动。
*/
@Component
public class ChannelRegistry {

private final Map<NotificationChannelType, NotificationChannel> channels =
new EnumMap<>(NotificationChannelType.class);

public ChannelRegistry(List<NotificationChannel> channelList) {
for (NotificationChannel channel : channelList) {
NotificationChannelType type = channel.type();
if (channels.put(type, channel) != null) {
throw new IllegalStateException("通知渠道重复注册: " + type);
}
}
}

/** 按类型取渠道;未配置/被禁用时抛 4000(fail-closed)。 */
public NotificationChannel resolve(NotificationChannelType type) {
NotificationChannel channel = channels.get(type);
if (channel == null) {
throw new BizException(4000, "未配置的通知渠道: " + type);
}
return channel;
}

public Map<NotificationChannelType, NotificationChannel> all() {
return channels;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.yqz.openblog.notification;

/**
* 通知渠道策略接口。
* <p>
* 每个渠道一个实现(当前仅 {@code EmailNotificationChannel};未来 SMS / 飞书各加一个实现),
* 由 {@link ChannelRegistry} 按 {@link #type()} 路由。新增渠道只需新增实现类,主链路零改动。
*/
public interface NotificationChannel {

/** 本渠道对应的类型(用于路由)。 */
NotificationChannelType type();

/** 投递一条通知。失败抛 {@link com.yqz.openblog.common.BizException}(由调用方决定是否回滚)。 */
void send(NotificationMessage message);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.yqz.openblog.notification;

/**
* 通知渠道类型。
* <p>
* 当前只实现 EMAIL(经 Dubbo 调 email 模块直发)。SMS / FEISHU 为未来扩展位,
* 接入时新增枚举值 + 对应 {@link NotificationChannel} 策略 + 配置即可,主链路零改动。
*/
public enum NotificationChannelType {

/** 邮件 */
EMAIL;

// 未来扩展位:
// SMS —— 阿里云短信(新增 SmsNotificationChannel)
// FEISHU —— 飞书机器人 webhook(新增 FeishuNotificationChannel)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.yqz.openblog.notification;

import java.util.Map;

/**
* 统一通知消息模型。
* <p>
* 渠道无关:channel 决定由哪个策略投递,recipient/subject 为投递目标,templateCode + params
* 经 {@link NotificationTemplateService} 渲染成各渠道内容。未来接 SMS/飞书时扩展 templateCode
* 与模板即可,调用方无需感知渠道实现差异。
*/
public class NotificationMessage {

/**
* 全局幂等键(业务方生成,如 UUID):贯穿 outbox / MQ / 消费端投递。
* 异步链路下由 submitAsync 生成;通道据此传给下游(如 EmailSendRequest.idempotencyKey),
* 保证同一条消息被 MQ 重投 N 次也只真正投递一次。同步链路可空(通道自行生成)。
*/
private String messageId;
private NotificationChannelType channel;
private String recipient;
private String subject;
private String templateCode;
private Map<String, Object> params;

public NotificationMessage() {}

public NotificationMessage(String messageId, NotificationChannelType channel, String recipient, String subject,
String templateCode, Map<String, Object> params) {
this.messageId = messageId;
this.channel = channel;
this.recipient = recipient;
this.subject = subject;
this.templateCode = templateCode;
this.params = params;
}

public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }

public NotificationChannelType getChannel() { return channel; }
public void setChannel(NotificationChannelType channel) { this.channel = channel; }

public String getRecipient() { return recipient; }
public void setRecipient(String recipient) { this.recipient = recipient; }

public String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = subject; }

public String getTemplateCode() { return templateCode; }
public void setTemplateCode(String templateCode) { this.templateCode = templateCode; }

public Map<String, Object> getParams() { return params; }
public void setParams(Map<String, Object> params) { this.params = params; }

public static Builder builder() { return new Builder(); }

public static class Builder {
private final NotificationMessage msg = new NotificationMessage();

public Builder messageId(String messageId) { msg.messageId = messageId; return this; }
public Builder channel(NotificationChannelType channel) { msg.channel = channel; return this; }
public Builder recipient(String recipient) { msg.recipient = recipient; return this; }
public Builder subject(String subject) { msg.subject = subject; return this; }
public Builder templateCode(String templateCode) { msg.templateCode = templateCode; return this; }
public Builder params(Map<String, Object> params) { msg.params = params; return this; }

public NotificationMessage build() { return msg; }
}
}
Loading