通知与消息系统 / 文件存储系统
一、通知系统
消息分类
通知系统根据业务用途将消息划分为以下三大类:
| 分类 | 说明 | 示例 |
|---|---|---|
| 系统通知 | 与用户操作无关的平台级通知 | 系统维护公告、版本更新、安全提醒 |
| 业务通知 | 与用户具体业务行为相关的通知 | 订单发货、退款成功、工单回复 |
| 营销通知 | 运营推广类通知 | 优惠券发放、活动预告、会员权益提醒 |
消息实体设计
public enum MessageCategory {
SYSTEM,
BUSINESS,
MARKETING
}
@Data
public class MessageTemplate {
private Long id;
private String templateCode; // 模板编码,如 ORDER_SHIPPED
private MessageCategory category; // 消息分类
private String titleTemplate; // 标题模板,支持占位符
private String contentTemplate; // 内容模板,支持占位符
private Integer priority; // 优先级,数值越大优先级越高
private List<NotifyChannel> supportedChannels; // 支持的推送渠道
private Boolean allowMerge; // 是否允许合并
private Integer maxMergeInterval; // 合并窗口(秒)
private Boolean requireConsent; // 是否需要用户同意
}通知渠道
| 渠道 | 特点 | 适用场景 | 到达率 | 成本 |
|---|---|---|---|---|
| 站内信 | 应用内消息中心展示 | 所有类型通知 | 高(用户在线时) | 低 |
| 短信 | 手机号直推,强触达 | 验证码、支付结果、安全告警 | 极高 | 中 |
| 邮件 | 富文本支持,可归档 | 报表、周报、详细通知 | 中 | 低 |
| App Push | 推送至手机通知栏 | 即时消息、活动推广 | 中高 | 中 |
| 微信模板消息 | 通过微信公众号/小程序下发 | 服务通知、订单状态 | 高 | 低 |
渠道分发策略
@Component
public class ChannelDispatcher {
@Autowired
private List<MessageSender> senders;
public void dispatch(Notification notification) {
// 1. 查询用户通知偏好
UserNotificationPreference preference = userPreferenceService
.getByUserId(notification.getUserId());
// 2. 根据偏好过滤可用渠道
List<NotifyChannel> enabledChannels = preference.getEnabledChannels();
// 3. 按优先级依次尝试发送
for (NotifyChannel channel : enabledChannels) {
if (!channel.supports(notification)) {
continue;
}
try {
MessageSender sender = findSender(channel);
sender.send(notification);
// 记录发送日志
notificationLogService.record(notification, channel, SendStatus.SUCCESS);
} catch (Exception e) {
log.error("Channel {} send failed, fallback", channel, e);
notificationLogService.record(notification, channel, SendStatus.FAILED);
// 降级:尝试下一个渠道
}
}
}
private MessageSender findSender(NotifyChannel channel) {
return senders.stream()
.filter(s -> s.supports(channel))
.findFirst()
.orElseThrow(() -> new UnsupportedOperationException(
"No sender for channel: " + channel));
}
}通知去重与合并
相同内容合并
当同一用户收到多条内容相同的通知时,合并为一条并累加计数。
@Service
public class MergeStrategy {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String MERGE_KEY_PREFIX = "notify:merge:";
public NotificationResult tryMerge(Notification notification) {
if (!notification.getAllowMerge()) {
return NotificationResult.noMerge(notification);
}
String mergeKey = MERGE_KEY_PREFIX
+ notification.getUserId() + ":"
+ notification.getTemplateCode();
// 原子递增合并计数
Long count = redisTemplate.opsForValue().increment(mergeKey);
if (count == 1) {
// 首次到达,设置过期时间(合并窗口)
redisTemplate.expire(mergeKey,
Duration.ofSeconds(notification.getMaxMergeInterval()));
return NotificationResult.noMerge(notification);
}
// 非首次到达,不生成新消息,仅更新原消息的合并计数
return NotificationResult.merged(notification.getUserId(),
notification.getTemplateCode(), count.intValue());
}
}频率控制
对同一渠道的通知做发送频率限制,避免对用户造成骚扰。
@Component
public class FrequencyLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String FREQ_KEY_PREFIX = "notify:freq:";
public boolean allowSend(Long userId, NotifyChannel channel, MessageCategory category) {
String key = FREQ_KEY_PREFIX + userId + ":" + channel.name();
// 根据分类获取不同限流策略
RateLimitRule rule = getRateLimitRule(category);
// 滑动窗口计数
Long count = redisTemplate.opsForZset().count(key,
System.currentTimeMillis() - rule.getWindowMs(),
System.currentTimeMillis());
if (count >= rule.getMaxCount()) {
log.warn("Frequency limit exceeded. userId={}, channel={}, category={}",
userId, channel, category);
return false;
}
// 记录本次发送
redisTemplate.opsForZset().add(key,
String.valueOf(System.nanoTime()),
(double) System.currentTimeMillis());
redisTemplate.expire(key, Duration.ofMillis(rule.getWindowMs() * 2));
return true;
}
private RateLimitRule getRateLimitRule(MessageCategory category) {
switch (category) {
case SYSTEM:
return new RateLimitRule(60_000, 5); // 每分钟最多5条
case BUSINESS:
return new RateLimitRule(60_000, 10); // 每分钟最多10条
case MARKETING:
return new RateLimitRule(86_400_000, 3); // 每天最多3条
default:
return new RateLimitRule(60_000, 10);
}
}
@Data
@AllArgsConstructor
private static class RateLimitRule {
private long windowMs;
private int maxCount;
}
}优先级队列
使用消息队列根据优先级进行分级处理,确保高优先级的通知(如支付结果、安全告警)被优先送达。
@Component
public class PriorityMessageQueue {
@Autowired
private RabbitTemplate rabbitTemplate;
private static final String EXCHANGE = "notification.exchange";
public void enqueue(Notification notification) {
MessageCategory category = notification.getCategory();
String routingKey;
// 根据优先级决定路由
switch (category) {
case SYSTEM:
routingKey = "notification.priority.high";
break;
case BUSINESS:
routingKey = "notification.priority.normal";
break;
case MARKETING:
routingKey = "notification.priority.low";
break;
default:
routingKey = "notification.priority.normal";
}
rabbitTemplate.convertAndSend(EXCHANGE, routingKey, notification,
message -> {
// 设置消息优先级(0-9,越大优先级越高)
message.getMessageProperties().setPriority(
notification.getPriority());
return message;
});
}
}RabbitMQ 队列配置:
@Configuration
public class NotificationQueueConfig {
@Bean
public Queue highPriorityQueue() {
return QueueBuilder.durable("notification.queue.high")
.maxPriority(9)
.build();
}
@Bean
public Queue normalPriorityQueue() {
return QueueBuilder.durable("notification.queue.normal")
.build();
}
@Bean
public Queue lowPriorityQueue() {
return QueueBuilder.durable("notification.queue.low")
.build();
}
@Bean
public DirectExchange notificationExchange() {
return new DirectExchange("notification.exchange");
}
@Bean
public Binding highBinding() {
return BindingBuilder.bind(highPriorityQueue())
.to(notificationExchange())
.with("notification.priority.high");
}
@Bean
public Binding normalBinding() {
return BindingBuilder.bind(normalPriorityQueue())
.to(notificationExchange())
.with("notification.priority.normal");
}
@Bean
public Binding lowBinding() {
return BindingBuilder.bind(lowPriorityQueue())
.to(notificationExchange())
.with("notification.priority.low");
}
}用户通知偏好设置
用户可自主控制订阅哪些类型的通知以及通过哪些渠道接收。
数据库设计
CREATE TABLE `user_notification_preference` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`channel` VARCHAR(20) NOT NULL COMMENT '通知渠道:INBOX/SMS/EMAIL/PUSH/WECHAT',
`category` VARCHAR(20) NOT NULL COMMENT '消息分类:SYSTEM/BUSINESS/MARKETING',
`enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`quiet_start` TIME DEFAULT NULL COMMENT '免打扰开始时间',
`quiet_end` TIME DEFAULT NULL COMMENT '免打扰结束时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_channel_category` (`user_id`, `channel`, `category`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户通知偏好设置';偏好查询服务
@Service
public class PreferenceService {
@Autowired
private UserNotificationPreferenceMapper preferenceMapper;
@Cacheable(value = "user:notification:preference", key = "#userId")
public UserNotificationPreference getByUserId(Long userId) {
List<UserNotificationPreference> list = preferenceMapper
.selectList(new LambdaQueryWrapper<UserNotificationPreference>()
.eq(UserNotificationPreference::getUserId, userId)
.eq(UserNotificationPreference::getEnabled, true));
UserNotificationPreference result = new UserNotificationPreference();
result.setUserId(userId);
for (UserNotificationPreference pref : list) {
result.getEnabledChannels()
.add(NotifyChannel.valueOf(pref.getChannel()));
}
return result;
}
@Transactional
public void updatePreference(Long userId, NotifyChannel channel,
MessageCategory category, boolean enabled) {
UserNotificationPreference preference = preferenceMapper
.selectOne(new LambdaQueryWrapper<UserNotificationPreference>()
.eq(UserNotificationPreference::getUserId, userId)
.eq(UserNotificationPreference::getChannel, channel.name())
.eq(UserNotificationPreference::getCategory, category.name()));
if (preference == null) {
preference = new UserNotificationPreference();
preference.setUserId(userId);
preference.setChannel(channel.name());
preference.setCategory(category.name());
preference.setEnabled(enabled);
preferenceMapper.insert(preference);
} else {
preference.setEnabled(enabled);
preferenceMapper.updateById(preference);
}
}
public boolean isQuietHours(Long userId, NotifyChannel channel) {
// 检查当前时间是否在用户设置的免打扰时段内
UserNotificationPreference pref = preferenceMapper
.selectOne(new LambdaQueryWrapper<UserNotificationPreference>()
.eq(UserNotificationPreference::getUserId, userId)
.eq(UserNotificationPreference::getChannel, channel.name())
.last("LIMIT 1"));
if (pref == null || pref.getQuietStart() == null) {
return false;
}
LocalTime now = LocalTime.now();
LocalTime start = pref.getQuietStart().toLocalTime();
LocalTime end = pref.getQuietEnd().toLocalTime();
if (start.isBefore(end)) {
return !now.isBefore(start) && !now.isAfter(end);
} else {
// 跨天免打扰时段,如 22:00 - 08:00
return !now.isBefore(start) || !now.isAfter(end);
}
}
}二、站内信设计
数据库表设计
站内信体系包含三张核心表:message(消息主体)、notification(通知记录)、inbox(用户信箱)。
message 表
存储消息的原始内容,与具体接收人解耦。
CREATE TABLE `message` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`biz_id` VARCHAR(64) DEFAULT NULL COMMENT '业务ID,用于去重',
`template_code` VARCHAR(50) DEFAULT NULL COMMENT '模板编码',
`title` VARCHAR(200) NOT NULL COMMENT '消息标题',
`content` TEXT NOT NULL COMMENT '消息内容(富文本)',
`category` VARCHAR(20) NOT NULL COMMENT '消息分类',
`priority` INT NOT NULL DEFAULT 0 COMMENT '优先级',
`sender_type` VARCHAR(20) DEFAULT 'SYSTEM' COMMENT '发送者类型:SYSTEM/ADMIN/AUTO',
`sender_id` BIGINT DEFAULT NULL COMMENT '发送者ID',
`allow_merge` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许合并',
`merge_interval` INT DEFAULT 300 COMMENT '合并窗口(秒)',
`status` VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/WITHDRAWN',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_id` (`biz_id`),
KEY `idx_category_created` (`category`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息主体';notification 表
记录每条消息向每个用户的推送记录,支持多渠道发送追踪。
CREATE TABLE `notification` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`message_id` BIGINT NOT NULL COMMENT '关联消息ID',
`user_id` BIGINT NOT NULL COMMENT '接收用户ID',
`channel` VARCHAR(20) NOT NULL COMMENT '通知渠道',
`channel_msg_id` VARCHAR(100) DEFAULT NULL COMMENT '渠道返回的消息ID(如短信回执ID、Push任务ID)',
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/SENT/FAILED/READ',
`error_msg` VARCHAR(500) DEFAULT NULL COMMENT '发送失败原因',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数',
`sent_at` DATETIME DEFAULT NULL COMMENT '发送时间',
`read_at` DATETIME DEFAULT NULL COMMENT '阅读时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_message_user` (`message_id`, `user_id`),
KEY `idx_user_status` (`user_id`, `status`),
KEY `idx_channel_msg` (`channel`, `channel_msg_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知发送记录';inbox 表
用户收件箱,记录每条消息在用户端的阅读状态。
CREATE TABLE `inbox` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`message_id` BIGINT NOT NULL COMMENT '消息ID',
`folder` VARCHAR(20) NOT NULL DEFAULT 'INBOX' COMMENT '文件夹:INBOX/ARCHIVE/TRASH',
`is_read` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已读',
`is_starred` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否标星',
`merge_count` INT NOT NULL DEFAULT 1 COMMENT '合并计数',
`read_at` DATETIME DEFAULT NULL COMMENT '阅读时间',
`received_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '接收时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_message` (`user_id`, `message_id`),
KEY `idx_user_folder` (`user_id`, `folder`, `received_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户收件箱';已读/未读设计
站内信的已读未读有三种主流实现方式,根据业务场景选择或结合使用。
读扩散(Pull 模式)
用户读取消息后,仅标记 inbox 表对应记录的 is_read 字段。
@Service
public class ReadDiffuseService {
@Autowired
private InboxMapper inboxMapper;
@Transactional
public void markAsRead(Long userId, Long messageId) {
inboxMapper.update(null,
new LambdaUpdateWrapper<Inbox>()
.eq(Inbox::getUserId, userId)
.eq(Inbox::getMessageId, messageId)
.set(Inbox::getIsRead, true)
.set(Inbox::getReadAt, LocalDateTime.now()));
}
public PageResult<InboxVO> queryInbox(Long userId, int page, int size) {
// 查询用户收件箱,实时判断已读状态
Page<Inbox> inboxPage = inboxMapper.selectPage(
new Page<>(page, size),
new LambdaQueryWrapper<Inbox>()
.eq(Inbox::getUserId, userId)
.eq(Inbox::getFolder, "INBOX")
.orderByDesc(Inbox::getReceivedAt));
return PageResult.of(inboxPage.getRecords(), inboxPage.getTotal());
}
}优点:写操作轻量,仅更新单行记录。 缺点:每次查询都要扫描大量记录,用户量增大后查询性能下降。
写扩散(Push 模式)
消息发布时,为每个目标用户在 inbox 表预创建一条未读记录。读取时直接查询 inbox。
优点:读取性能好,可直接 COUNT 未读数。 缺点:发送大量消息时(如营销通知),写入放大严重。
推拉结合(推荐)
结合两种模式的优势:系统通知和营销通知使用拉模式(读扩散),高优先级的业务通知使用推模式(写扩散)。
@Service
public class HybridInboxService {
@Autowired
private InboxMapper inboxMapper;
@Autowired
private NotificationMapper notificationMapper;
public void dispatchToInbox(Message message, List<Long> targetUserIds) {
if (MessageCategory.MARKETING.equals(message.getCategory())
|| MessageCategory.SYSTEM.equals(message.getCategory())) {
// 读扩散:不预写入 inbox,仅记录 notification
for (Long userId : targetUserIds) {
Notification notification = new Notification();
notification.setMessageId(message.getId());
notification.setUserId(userId);
notification.setChannel("INBOX");
notification.setStatus("SENT");
notification.setSentAt(LocalDateTime.now());
notificationMapper.insert(notification);
}
} else {
// 写扩散:预写入 inbox
for (Long userId : targetUserIds) {
Inbox inbox = new Inbox();
inbox.setUserId(userId);
inbox.setMessageId(message.getId());
inbox.setIsRead(false);
inboxMapper.insert(inbox);
Notification notification = new Notification();
notification.setMessageId(message.getId());
notification.setUserId(userId);
notification.setChannel("INBOX");
notification.setStatus("SENT");
notification.setSentAt(LocalDateTime.now());
notificationMapper.insert(notification);
}
}
}
public long countUnread(Long userId) {
// 推模式消息:直接 COUNT inbox
long pushUnread = inboxMapper.selectCount(
new LambdaQueryWrapper<Inbox>()
.eq(Inbox::getUserId, userId)
.eq(Inbox::getIsRead, false));
// 拉模式消息:从 notification 表计算未读数
long pullUnread = notificationMapper.selectCount(
new LambdaQueryWrapper<Notification>()
.eq(Notification::getUserId, userId)
.eq(Notification::getChannel, "INBOX")
.eq(Notification::getStatus, "SENT")
.notIn(Notification::getMessageId,
// 排除已读的消息
new LambdaQueryWrapper<Inbox>()
.select(Inbox::getMessageId)
.eq(Inbox::getUserId, userId)
.eq(Inbox::getIsRead, true))
);
return pushUnread + pullUnread;
}
}消息撤回能力
已发送的消息支持在有效期内撤回。撤回后用户侧将看到"消息已被撤回"的提示。
@Service
public class MessageRecallService {
@Autowired
private MessageMapper messageMapper;
@Autowired
private InboxMapper inboxMapper;
@Autowired
private RabbitTemplate rabbitTemplate;
private static final long RECALL_TIMEOUT_SECONDS = 600; // 发送后10分钟内可撤回
@Transactional
public boolean recallMessage(Long messageId, Long operatorId) {
Message message = messageMapper.selectById(messageId);
if (message == null) {
throw new BusinessException("消息不存在");
}
// 检查撤回时效
long elapsed = Duration.between(message.getCreatedAt(), LocalDateTime.now()).getSeconds();
if (elapsed > RECALL_TIMEOUT_SECONDS) {
throw new BusinessException("已超过撤回时限");
}
// 将消息标记为已撤回
message.setStatus("WITHDRAWN");
messageMapper.updateById(message);
// 删除用户收件箱中的记录
inboxMapper.delete(new LambdaQueryWrapper<Inbox>()
.eq(Inbox::getMessageId, messageId));
// 发送撤回通知到消息队列,触发实时更新
rabbitTemplate.convertAndSend("notification.exchange",
"notification.recall", messageId);
return true;
}
}通知中心前后端实现
后端 API
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
@Autowired
private NotificationService notificationService;
/**
* 查询用户收件箱(分页)
*/
@GetMapping("/inbox")
public Result<PageResult<InboxVO>> queryInbox(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
Long userId = SecurityUtils.getCurrentUserId();
return Result.success(notificationService.queryInbox(userId, page, size));
}
/**
* 获取未读消息数
*/
@GetMapping("/unread-count")
public Result<Long> getUnreadCount() {
Long userId = SecurityUtils.getCurrentUserId();
return Result.success(notificationService.countUnread(userId));
}
/**
* 标记消息为已读
*/
@PutMapping("/{messageId}/read")
public Result<Void> markAsRead(@PathVariable Long messageId) {
Long userId = SecurityUtils.getCurrentUserId();
notificationService.markAsRead(userId, messageId);
return Result.success();
}
/**
* 标记全部已读
*/
@PutMapping("/read-all")
public Result<Void> markAllAsRead() {
Long userId = SecurityUtils.getCurrentUserId();
notificationService.markAllAsRead(userId);
return Result.success();
}
/**
* 撤回消息(管理员)
*/
@PostMapping("/{messageId}/recall")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> recallMessage(@PathVariable Long messageId) {
Long userId = SecurityUtils.getCurrentUserId();
notificationService.recallMessage(messageId, userId);
return Result.success();
}
/**
* 删除站内信
*/
@DeleteMapping("/{messageId}")
public Result<Void> deleteMessage(@PathVariable Long messageId) {
Long userId = SecurityUtils.getCurrentUserId();
notificationService.deleteMessage(userId, messageId);
return Result.success();
}
/**
* 更新通知偏好
*/
@PutMapping("/preferences")
public Result<Void> updatePreferences(
@RequestBody @Valid UpdatePreferenceRequest request) {
Long userId = SecurityUtils.getCurrentUserId();
notificationService.updatePreference(
userId, request.getChannel(),
request.getCategory(), request.getEnabled());
return Result.success();
}
}前端组件(Vue 示例)
<template>
<div class="notification-center">
<!-- 未读数量徽标 -->
<el-badge :value="unreadCount" :hidden="unreadCount === 0">
<el-icon :size="24"><Bell /></el-icon>
</el-badge>
<!-- 通知下拉面板 -->
<el-popover placement="bottom-end" width="380" trigger="click"
@show="fetchNotifications">
<template #reference>
<el-button :icon="Bell" circle />
</template>
<div class="notification-panel">
<div class="panel-header">
<span>消息中心</span>
<el-button text size="small" @click="markAllRead">
全部已读
</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="站内信" name="inbox">
<div class="notification-list">
<div v-for="item in list" :key="item.messageId"
class="notification-item"
:class="{ unread: !item.isRead }"
@click="handleClick(item)">
<div class="item-title">{{ item.title }}</div>
<div class="item-content">{{ item.content }}</div>
<div class="item-time">{{ formatTime(item.receivedAt) }}</div>
</div>
<el-empty v-if="list.length === 0" description="暂无消息" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-popover>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { Bell } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import axios from 'axios';
const unreadCount = ref(0);
const activeTab = ref('inbox');
const list = ref([]);
const fetchUnreadCount = async () => {
const res = await axios.get('/api/notifications/unread-count');
unreadCount.value = res.data.data;
};
const fetchNotifications = async () => {
const res = await axios.get('/api/notifications/inbox', {
params: { page: 1, size: 20 }
});
list.value = res.data.data.records;
};
const markAllRead = async () => {
await axios.put('/api/notifications/read-all');
unreadCount.value = 0;
ElMessage.success('已全部标记为已读');
};
const handleClick = async (item) => {
if (!item.isRead) {
await axios.put(`/api/notifications/${item.messageId}/read`);
item.isRead = true;
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
};
const formatTime = (time) => {
// 格式化时间显示逻辑
return new Date(time).toLocaleString('zh-CN');
};
onMounted(() => {
fetchUnreadCount();
// 轮询未读数
setInterval(fetchUnreadCount, 30000);
});
</script>
<style scoped>
.notification-panel {
max-height: 480px;
overflow-y: auto;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #eee;
}
.notification-item {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
}
.notification-item:hover {
background: #f5f7fa;
}
.notification-item.unread {
background: #ecf5ff;
}
.item-title {
font-weight: 600;
margin-bottom: 4px;
}
.item-content {
font-size: 13px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-time {
font-size: 12px;
color: #999;
margin-top: 4px;
}
</style>三、消息推送
第三方 Push SDK
市面上主流的第三方推送服务对比:
| 服务商 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 极光推送(JPush) | 集成简单、文档完善、厂商通道全 | 海外覆盖一般 | 国内通用场景 |
| 个推(Getui) | 长连接稳定、高并发能力强 | 配置稍复杂 | 对到达率要求高的场景 |
| 友盟(Umeng) | 阿里系产品、数据分析能力强 | 推送能力相对基础 | 需数据分析的运营场景 |
JPush 集成示例
<!-- pom.xml -->
<dependency>
<groupId>cn.jpush.api</groupId>
<artifactId>jpush-client</artifactId>
<version>3.6.6</version>
</dependency>@Service
public class JPushService implements MessageSender {
@Value("${jpush.app-key}")
private String appKey;
@Value("${jpush.master-secret}")
private String masterSecret;
private static final int MAX_RETRY = 3;
@Override
public boolean supports(NotifyChannel channel) {
return channel == NotifyChannel.PUSH;
}
@Override
@Retryable(value = PushSendException.class, maxAttempts = MAX_RETRY)
public void send(Notification notification) {
JPushClient jpushClient = new JPushClient(masterSecret, appKey, MAX_RETRY);
PushPayload payload = buildPushPayload(notification);
try {
PushResult result = jpushClient.sendPush(payload);
if (result.getResponseCode() != 200) {
throw new PushSendException("JPush send failed: " +
result.getOriginalContent());
}
log.info("Push sent, msgId={}", result.getMsgId());
} catch (APIConnectionException | APIRequestException e) {
log.error("JPush connection error", e);
throw new PushSendException(e);
}
}
private PushPayload buildPushPayload(Notification notification) {
return PushPayload.newBuilder()
.setPlatform(Platform.all())
.setAudience(Audience.alias(
String.valueOf(notification.getUserId())))
.setNotification(Notification.newBuilder()
.setAlert(notification.getContent())
.addPlatformNotification(
AndroidNotification.newBuilder()
.setTitle(notification.getTitle())
.build())
.addPlatformNotification(
IosNotification.newBuilder()
.setAlert(notification.getTitle())
.setSound("default")
.build())
.build())
.setOptions(Options.newBuilder()
.setTimeToLive(86400) // 离线消息保留1天
.setApnsProduction(true) // 生产环境
.build())
.build();
}
}APNs / FCM 推送原理
APNs(Apple Push Notification Service)
应用服务器 → 建立 TLS 连接 → 发送 HTTP/2 请求到 APNs → APNs 下发到设备- 使用 HTTP/2 协议,支持多路复用
- 每个推送请求包含 device token、payload、priority、expiration
- 支持的优先级:
10(立即显示)、5(可延迟节能) - 通知内容限制 4KB(iOS 13+ 对大图片有特殊处理)
FCM(Firebase Cloud Messaging)
应用服务器 → 发送 POST 请求到 fcm.googleapis.com → FCM 下发到 Android 设备- 使用 JSON 格式的 HTTP 请求
- 区分
notification messages(系统自动处理展示)和data messages(应用自主处理) - 支持条件推送(topic-based)、设备组推送
@Service
public class FcmService {
@Value("${fcm.server-key}")
private String serverKey;
private static final String FCM_URL = "https://fcm.googleapis.com/fcm/send";
public void sendPush(Long userId, String title, String body) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "key=" + serverKey);
Map<String, Object> payload = new HashMap<>();
payload.put("to", getDeviceToken(userId));
payload.put("priority", "high");
Map<String, String> notification = new HashMap<>();
notification.put("title", title);
notification.put("body", body);
notification.put("sound", "default");
payload.put("notification", notification);
HttpEntity<Map<String, Object>> request =
new HttpEntity<>(payload, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(
FCM_URL, request, Map.class);
if (response.getStatusCode() != HttpStatus.OK) {
log.error("FCM send failed: {}", response.getBody());
}
}
private String getDeviceToken(Long userId) {
// 从数据库获取用户最新的 FCM device token
return deviceTokenMapper.selectOne(
new LambdaQueryWrapper<DeviceToken>()
.eq(DeviceToken::getUserId, userId)
.eq(DeviceToken::getPlatform, "ANDROID")
.orderByDesc(DeviceToken::getUpdatedAt)
.last("LIMIT 1"))
.getToken();
}
}推送通道保活
Android 端推送通道保活常用策略:
| 策略 | 说明 | 效果 |
|---|---|---|
| 前台 Service | 绑定前台通知栏的 Service | 高,但通知栏常驻 |
| JobScheduler | Android 5.0+ 的周期性任务 | 中 |
| WorkManager | Jetpack 组件,兼容性好 | 中高 |
| 双进程守护 | 两个进程互相拉起 | 高,但耗电 |
| 厂商通道 | 接入厂商系统级通道 | 最高 |
厂商通道保活策略
public class KeepAliveManager {
/**
* 根据设备品牌选择最优保活方案
*/
public static void optimizeKeepAlive(Context context) {
String brand = Build.BRAND.toLowerCase();
if (brand.contains("huawei") || brand.contains("honor")) {
// 华为:优先使用厂商推送,配合后台弹窗权限
HuaweiPushManager.requestPermission(context);
} else if (brand.contains("xiaomi")) {
// 小米:使用 MIUI 推送 + 自启动权限引导
XiaomiPushManager.register(context);
requestAutoStartPermission(context);
} else if (brand.contains("oppo") || brand.contains("oneplus")) {
// OPPO:使用 OPush + 通知权限引导
OppoPushManager.register(context);
} else if (brand.contains("vivo")) {
// vivo:使用 vivo 推送 + 后台高耗电权限
VivoPushManager.register(context);
} else {
// 其他品牌:使用 JPush/个推 SDK 自带保活
JPushInterface.setDebugMode(true);
JPushInterface.init(context);
}
}
private static void requestAutoStartPermission(Context context) {
// 引导用户开启自启动权限
Intent intent = new Intent();
intent.setComponent(new ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity"));
if (intent.resolveActivity(context.getPackageManager()) != null) {
context.startActivity(intent);
}
}
}厂商通道
各厂商提供系统级推送通道,应用被杀后仍能收到推送。
| 厂商 | SDK 名称 | 特点 | 适配建议 |
|---|---|---|---|
| 华为 | HMS Push Kit | 系统级长连接,到达率最高 | 必须 HMS Core 5.0+ |
| 小米 | Mipush | MIUI 系统级通道 | 非 MIUI 设备需降级 |
| OPPO | OPush | ColorOS 系统级推送 | 需要审核 |
| vivo | vivo Push | Funtouch OS 系统级 | 需要审核 |
厂商通道路由策略
@Component
public class VendorChannelRouter {
@Autowired
private DeviceInfoService deviceInfoService;
public void routeByVendor(Notification notification) {
DeviceInfo device = deviceInfoService
.getLatestDevice(notification.getUserId());
if (device == null) {
log.warn("No device info for userId={}", notification.getUserId());
return;
}
String brand = device.getBrand().toLowerCase();
try {
if (brand.contains("huawei") || brand.contains("honor")) {
sendHuaweiPush(notification, device);
} else if (brand.contains("xiaomi")) {
sendXiaomiPush(notification, device);
} else if (brand.contains("oppo")) {
sendOppoPush(notification, device);
} else if (brand.contains("vivo")) {
sendVivoPush(notification, device);
} else {
// 降级到第三方 SDK
sendViaThirdPartySdk(notification, device);
}
} catch (Exception e) {
log.error("Vendor push failed, fallback to third-party SDK", e);
sendViaThirdPartySdk(notification, device);
}
}
private void sendHuaweiPush(Notification notification, DeviceInfo device) {
// 华为推送使用 OAuth 2.0 鉴权
// 请求 URL: https://push-api.cloud.huawei.com/v1/{appId}/messages:send
// 参考华为 HUAWEI Push Kit 文档
}
private void sendViaThirdPartySdk(Notification notification, DeviceInfo device) {
// 降级到 JPush 等第三方 SDK
jPushService.send(notification);
}
}厂商通道统一推送接口
public interface VendorPushService {
/**
* 向指定设备推送消息
* @param deviceToken 厂商通道的设备标识
* @param title 通知标题
* @param body 通知内容
* @param extras 扩展参数(JSON)
* @return 渠道返回的消息ID
*/
String push(String deviceToken, String title, String body, String extras);
}
@Component
public class HuaweiPushService implements VendorPushService {
@Override
public String push(String deviceToken, String title, String body, String extras) {
// 华为推送实现
return null;
}
}四、文件存储系统
文件存储分类
| 文件类型 | 常见格式 | 存储策略 | 典型用途 |
|---|---|---|---|
| 图片 | jpg/png/webp/gif | 标准存储 + CDN | 商品图、头像、内容图片 |
| 文档 | pdf/doc/xlsx | 标准存储 | 合同、报表、导入模板 |
| 音视频 | mp4/mp3/m3u8 | 低频存储 | 课程视频、录音 |
| 临时文件 | 各类 | 临时存储 + TTL | 上传中文件、导出缓存、分片上传的碎片 |
文件元数据表
CREATE TABLE `file_metadata` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`file_key` VARCHAR(200) NOT NULL COMMENT 'OSS 文件路径/对象名',
`original_name` VARCHAR(255) NOT NULL COMMENT '原文件名',
`file_type` VARCHAR(20) NOT NULL COMMENT '分类:IMAGE/DOCUMENT/VIDEO/TEMP',
`mime_type` VARCHAR(100) NOT NULL COMMENT 'MIME 类型',
`file_size` BIGINT NOT NULL COMMENT '文件大小(字节)',
`storage_class` VARCHAR(20) NOT NULL DEFAULT 'STANDARD' COMMENT '存储类型:STANDARD/IA/ARCHIVE',
`md5` VARCHAR(32) NOT NULL COMMENT '文件 MD5',
`width` INT DEFAULT NULL COMMENT '图片宽度(像素)',
`height` INT DEFAULT NULL COMMENT '图片高度(像素)',
`duration` INT DEFAULT NULL COMMENT '视频时长(秒)',
`upload_id` VARCHAR(100) DEFAULT NULL COMMENT '分片上传 ID',
`upload_status` VARCHAR(20) NOT NULL DEFAULT 'COMPLETED' COMMENT 'PENDING/UPLOADING/COMPLETED/FAILED',
`access_level` VARCHAR(20) NOT NULL DEFAULT 'PRIVATE' COMMENT 'PUBLIC/PRIVATE',
`uploaded_by` BIGINT DEFAULT NULL COMMENT '上传者',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_file_key` (`file_key`),
KEY `idx_md5` (`md5`),
KEY `idx_uploaded_by` (`uploaded_by`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件元数据';存储策略(OSS 分层)
对象存储(OSS)标准分层模型:
| 存储类型 | 访问频率 | 存储成本 | 访问成本 | 最短存储期限 | 数据恢复时间 |
|---|---|---|---|---|---|
| 标准(Standard) | 频繁 | 高 | 低 | 无 | 实时 |
| 低频(Infrequent Access) | 每月1-2次 | 中 | 中 | 30天 | 实时 |
| 归档(Archive) | 季度/年度 | 低 | 高 | 60/90天 | 分钟级恢复 |
文件存储策略选择器
@Component
public class StorageStrategySelector {
public StorageClass selectStorageClass(FileUploadRequest request) {
FileCategory category = request.getCategory();
Long fileSize = request.getFileSize();
// 根据文件类型选择默认存储策略
switch (category) {
case IMAGE:
return StorageClass.STANDARD; // 图片访问频繁,标准存储
case DOCUMENT:
return StorageClass.IA; // 文档低频访问,低频存储
case VIDEO:
return StorageClass.IA; // 视频体积大,低频存储
case TEMP:
return StorageClass.STANDARD; // 临时文件标准存储,需快速清理
default:
return StorageClass.STANDARD;
}
}
/**
* 根据文件生命周期自动降级
*/
public StorageClass demoteStorageClass(FileMetadata file) {
LocalDateTime createdAt = file.getCreatedAt();
long daysSinceCreated = Duration.between(createdAt, LocalDateTime.now()).toDays();
if (daysSinceCreated > 365) {
return StorageClass.ARCHIVE; // 超过1年,归档
}
if (daysSinceCreated > 90) {
return StorageClass.IA; // 超过90天,降为低频
}
return file.getStorageClass();
}
}文件上传流程
客户端直传
客户端直接向 OSS 上传文件,服务端仅签发上传凭证,减少服务端带宽压力。
@Service
public class DirectUploadService {
@Autowired
private OSSClient ossClient;
@Value("${oss.bucket-name}")
private String bucketName;
@Value("${oss.access-key-id}")
private String accessKeyId;
/**
* 生成 OSS 直传凭证(基于 STS 临时令牌)
*/
public StsCredentials generateUploadCredentials(Long userId, FileCategory category) {
// 构建上传策略
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(
PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 104857600); // 限制100MB
policyConds.addConditionItem(
PolicyConditions.COND_START_WITH,
"key", getUploadPathPrefix(userId, category));
String policy = ossClient.generatePostPolicy(
LocalDateTime.now().plusHours(1), policyConds);
String signature = ossClient.calculatePostSignature(policy);
StsCredentials credentials = new StsCredentials();
credentials.setAccessKeyId(accessKeyId);
credentials.setPolicy(policy);
credentials.setSignature(signature);
credentials.setUploadUrl("https://" + bucketName + ".oss-cn-hangzhou.aliyuncs.com");
credentials.setKeyPrefix(getUploadPathPrefix(userId, category));
return credentials;
}
/**
* 上传完成回调处理
*/
@Transactional
public FileMetadata handleUploadCallback(UploadCallbackRequest request) {
FileMetadata metadata = new FileMetadata();
metadata.setFileKey(request.getFileKey());
metadata.setOriginalName(request.getOriginalName());
metadata.setFileType(request.getCategory());
metadata.setMimeType(request.getMimeType());
metadata.setFileSize(request.getFileSize());
metadata.setMd5(request.getMd5());
metadata.setAccessLevel("PRIVATE");
metadata.setUploadedBy(request.getUserId());
metadata.setUploadStatus(UploadStatus.COMPLETED);
metadata.setStorageClass(StorageClass.STANDARD);
// 如果是图片,解析宽高
if ("IMAGE".equals(request.getCategory())) {
ImageInfo imageInfo = readImageInfo(request.getFileKey());
metadata.setWidth(imageInfo.getWidth());
metadata.setHeight(imageInfo.getHeight());
}
fileMetadataMapper.insert(metadata);
return metadata;
}
private String getUploadPathPrefix(Long userId, FileCategory category) {
return String.format("%s/%d/%s/",
category.name().toLowerCase(),
userId,
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")));
}
}服务端上传
文件先传到应用服务器,再由服务器转发到 OSS。适合小文件或需要对文件做前置处理的场景。
@Service
public class ServerUploadService {
@Autowired
private OSSClient ossClient;
@Value("${oss.bucket-name}")
private String bucketName;
public FileMetadata uploadFile(MultipartFile file, FileCategory category,
Long userId) throws IOException {
String fileKey = generateFileKey(file, category, userId);
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
// 上传到 OSS
PutObjectRequest putRequest = new PutObjectRequest(
bucketName, fileKey, file.getInputStream(), metadata);
ossClient.putObject(putRequest);
// 计算 MD5
String md5 = DigestUtils.md5DigestAsHex(file.getInputStream());
// 保存文件元数据
FileMetadata fileMeta = new FileMetadata();
fileMeta.setFileKey(fileKey);
fileMeta.setOriginalName(file.getOriginalFilename());
fileMeta.setFileType(category.name());
fileMeta.setMimeType(file.getContentType());
fileMeta.setFileSize(file.getSize());
fileMeta.setMd5(md5);
fileMeta.setAccessLevel("PRIVATE");
fileMeta.setUploadedBy(userId);
fileMetadataMapper.insert(fileMeta);
return fileMeta;
}
private String generateFileKey(MultipartFile file, FileCategory category,
Long userId) {
String suffix = getFileSuffix(file.getOriginalFilename());
String uuid = UUID.randomUUID().toString().replace("-", "");
return String.format("%s/%d/%s/%s%s",
category.name().toLowerCase(),
userId,
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")),
uuid, suffix);
}
}分片上传
大文件(超过 100MB)使用分片上传,支持断点续传。
@Service
public class MultipartUploadService {
@Autowired
private OSSClient ossClient;
@Value("${oss.bucket-name}")
private String bucketName;
/**
* 1. 初始化分片上传
*/
public InitMultipartUploadResult initUpload(String fileName, Long userId) {
String fileKey = generateFileKey(fileName, userId);
InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(
bucketName, fileKey);
InitiateMultipartUploadResult result = ossClient.initiateMultipartUpload(request);
// 保存上传记录
FileMetadata metadata = new FileMetadata();
metadata.setFileKey(fileKey);
metadata.setUploadId(result.getUploadId());
metadata.setUploadStatus(UploadStatus.UPLOADING);
metadata.setUploadedBy(userId);
fileMetadataMapper.insert(metadata);
return result;
}
/**
* 2. 上传分片
*/
public UploadPartResult uploadPart(String uploadId, String fileKey,
int partNumber, InputStream partStream,
long partSize) {
UploadPartRequest request = new UploadPartRequest();
request.setBucketName(bucketName);
request.setKey(fileKey);
request.setUploadId(uploadId);
request.setPartNumber(partNumber);
request.setInputStream(partStream);
request.setPartSize(partSize);
return ossClient.uploadPart(request);
}
/**
* 3. 完成分片上传
*/
@Transactional
public FileMetadata completeUpload(String uploadId, String fileKey,
List<PartETag> partETags) {
CompleteMultipartUploadRequest request = new CompleteMultipartUploadRequest(
bucketName, fileKey, uploadId, partETags);
CompleteMultipartUploadResult result = ossClient.completeMultipartUpload(request);
// 更新文件元数据状态
FileMetadata metadata = fileMetadataMapper.selectOne(
new LambdaQueryWrapper<FileMetadata>()
.eq(FileMetadata::getFileKey, fileKey));
metadata.setUploadStatus(UploadStatus.COMPLETED);
fileMetadataMapper.updateById(metadata);
return metadata;
}
/**
* 4. 取消分片上传
*/
public void abortUpload(String uploadId, String fileKey) {
AbortMultipartUploadRequest request = new AbortMultipartUploadRequest(
bucketName, fileKey, uploadId);
ossClient.abortMultipartUpload(request);
}
}文件处理
图片处理
OSS 提供图片处理服务,支持缩略图、水印、格式转换等。
@Component
public class ImageProcessService {
@Autowired
private OSSClient ossClient;
@Value("${oss.bucket-name}")
private String bucketName;
/**
* 生成缩略图 URL(OSS 图片处理)
*/
public String generateThumbnail(String fileKey, int width, int height) {
// OSS 图片处理参数:缩放 + 裁剪
StringBuilder style = new StringBuilder();
style.append("image/resize,m_fill,w_")
.append(width).append(",h_").append(height)
.append("/auto-orient,1");
return generateProcessedUrl(fileKey, style.toString());
}
/**
* 添加图片水印
*/
public String addWatermark(String fileKey, String watermarkText,
String position, int opacity) {
// 水印文字需进行 Base64 编码
String encodedText = Base64.getUrlEncoder()
.encodeToString(watermarkText.getBytes(StandardCharsets.UTF_8));
StringBuilder style = new StringBuilder();
style.append("image/watermark,text_")
.append(encodedText)
.append(",type_ZHJvaWRzYW5zZm9ucw")
.append(",size_30")
.append(",color_FFFFFF")
.append(",t_").append(opacity)
.append(",g_").append(position != null ? position : "se")
.append(",x_10,y_10");
return generateProcessedUrl(fileKey, style.toString());
}
/**
* 格式转换(WebP 节省带宽)
*/
public String convertFormat(String fileKey, String targetFormat) {
return generateProcessedUrl(fileKey,
"image/format," + targetFormat);
}
/**
* 获取处理的图片信息
*/
public ImageInfo getImageInfo(String fileKey) {
String style = "image/info";
GetObjectRequest request = new GetObjectRequest(bucketName, fileKey);
request.setProcess(style);
OSSObject ossObject = ossClient.getObject(request);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(ossObject.getObjectContent()))) {
String json = reader.lines().collect(Collectors.joining());
return JSON.parseObject(json, ImageInfo.class);
} catch (IOException e) {
throw new RuntimeException("Failed to read image info", e);
}
}
private String generateProcessedUrl(String fileKey, String style) {
return ossClient.generatePresignedUrl(
bucketName, fileKey,
Date.from(LocalDateTime.now().plusHours(1)
.atZone(ZoneId.systemDefault()).toInstant()))
+ "?x-oss-process=" + URLEncoder.encode(style, StandardCharsets.UTF_8);
}
}视频转码
@Service
public class VideoTranscodeService {
@Autowired
private OSSClient ossClient;
@Value("${oss.bucket-name}")
private String bucketName;
@Value("${mps.pipeline-id}")
private String pipelineId;
/**
* 提交视频转码任务(使用阿里云媒体处理服务)
*/
public String submitTranscodeJob(String fileKey, TranscodePreset preset) {
String outputKey = generateOutputKey(fileKey, preset);
// 构建转码作业请求
SubmitJobsRequest request = new SubmitJobsRequest();
request.setPipelineId(pipelineId);
request.setInput(buildInput(fileKey));
request.setOutput(buildOutput(outputKey, preset));
SubmitJobsResponse response = ossClient.submitJobs(request);
if (response.getJobResultList() != null
&& !response.getJobResultList().isEmpty()) {
String jobId = response.getJobResultList().get(0).getJob().getJobId();
log.info("Transcode job submitted, jobId={}, fileKey={}", jobId, fileKey);
return jobId;
}
throw new RuntimeException("Failed to submit transcode job");
}
/**
* 查询转码进度
*/
public TranscodeStatus queryTranscodeStatus(String jobId) {
QueryJobListRequest request = new QueryJobListRequest();
request.setJobIds(jobId);
QueryJobListResponse response = ossClient.queryJobList(request);
if (response.getJobList() != null && !response.getJobList().isEmpty()) {
Job job = response.getJobList().get(0);
return new TranscodeStatus(jobId, job.getJobStatus());
}
return new TranscodeStatus(jobId, "UNKNOWN");
}
private JobInput buildInput(String fileKey) {
JobInput input = new JobInput();
input.setBucket(bucketName);
input.setLocation("oss-cn-hangzhou");
input.setObject(fileKey);
return input;
}
private JobOutput buildOutput(String outputKey, TranscodePreset preset) {
JobOutput output = new JobOutput();
output.setBucket(bucketName);
output.setLocation("oss-cn-hangzhou");
output.setObject(outputKey);
// M3U8 输出配置(HLS 格式)
M3U8NonStandardSupport m3u8 = new M3U8NonStandardSupport();
m3u8.setTS(true);
output.setM3U8NonStandardSupport(m3u8);
// 视频参数
Video video = new Video();
video.setCodec("H.264");
video.setBitrate(preset.getVideoBitrate());
video.setWidth(preset.getWidth());
video.setHeight(preset.getHeight());
video.setFps(preset.getFps());
output.setVideo(video);
// 音频参数
Audio audio = new Audio();
audio.setCodec("AAC");
audio.setBitrate(preset.getAudioBitrate());
audio.setChannels("2");
output.setAudio(audio);
// 封装格式
output.setContainer("m3u8");
return output;
}
@Data
public static class TranscodePreset {
private String videoBitrate = "2000";
private String audioBitrate = "128";
private int width = 1280;
private int height = 720;
private String fps = "30";
private String format = "m3u8";
}
@Data
@AllArgsConstructor
public static class TranscodeStatus {
private String jobId;
private String status;
}
}文件访问控制
公开/私有/临时签名 URL
@Service
public class FileAccessService {
@Autowired
private OSSClient ossClient;
@Value("${oss.bucket-name}")
private String bucketName;
@Value("${oss.public-bucket}")
private String publicBucketName;
/**
* 获取公开文件的访问 URL
*/
public String getPublicUrl(String fileKey) {
return String.format("https://%s.oss-cn-hangzhou.aliyuncs.com/%s",
publicBucketName, fileKey);
}
/**
* 生成私有文件的临时签名 URL(带有效期)
*/
public String generatePresignedUrl(String fileKey, long expirationSeconds) {
Date expiration = Date.from(LocalDateTime.now()
.plusSeconds(expirationSeconds)
.atZone(ZoneId.systemDefault()).toInstant());
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(
bucketName, fileKey);
request.setExpiration(expiration);
request.setMethod(HttpMethod.GET);
// 如果需要限制下载速度或附加处理
ResponseHeaderOverrides headers = new ResponseHeaderOverrides();
headers.setContentDisposition("attachment; filename=\"" +
getOriginalFileName(fileKey) + "\"");
request.setResponseHeaders(headers);
URL url = ossClient.generatePresignedUrl(request);
return url.toString();
}
/**
* 生成图片处理后的临时访问 URL
*/
public String generateProcessedPresignedUrl(String fileKey,
String processStyle,
long expirationSeconds) {
Date expiration = Date.from(LocalDateTime.now()
.plusSeconds(expirationSeconds)
.atZone(ZoneId.systemDefault()).toInstant());
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(
bucketName, fileKey);
request.setExpiration(expiration);
request.setMethod(HttpMethod.GET);
request.setProcess(processStyle);
URL url = ossClient.generatePresignedUrl(request);
return url.toString();
}
/**
* 验证文件访问权限
*/
public boolean checkAccessPermission(Long userId, String fileKey) {
FileMetadata metadata = fileMetadataMapper.selectOne(
new LambdaQueryWrapper<FileMetadata>()
.eq(FileMetadata::getFileKey, fileKey));
if (metadata == null) {
return false;
}
// 公开文件所有人可访问
if ("PUBLIC".equals(metadata.getAccessLevel())) {
return true;
}
// 私有文件仅上传者可访问
return userId != null && userId.equals(metadata.getUploadedBy());
}
}文件清理
定期清理与 TTL 过期策略
@Component
@Slf4j
public class FileCleanupService {
@Autowired
private FileMetadataMapper fileMetadataMapper;
@Autowired
private OSSClient ossClient;
@Value("${oss.bucket-name}")
private String bucketName;
/**
* 清理临时文件(上传未完成、超过 TTL)
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
@Transactional
public void cleanupTempFiles() {
// 查找上传状态为 UPLOADING 且超过2小时未完成的文件
List<FileMetadata> expiredTempFiles = fileMetadataMapper.selectList(
new LambdaQueryWrapper<FileMetadata>()
.eq(FileMetadata::getUploadStatus, UploadStatus.UPLOADING)
.or(eq -> eq.eq(FileMetadata::getFileType, "TEMP"))
.lt(FileMetadata::getCreatedAt,
LocalDateTime.now().minusHours(2)));
for (FileMetadata file : expiredTempFiles) {
try {
// 如果是分片上传,先取消
if (file.getUploadId() != null) {
AbortMultipartUploadRequest abortRequest =
new AbortMultipartUploadRequest(
bucketName, file.getFileKey(), file.getUploadId());
ossClient.abortMultipartUpload(abortRequest);
}
// 删除 OSS 文件
ossClient.deleteObject(bucketName, file.getFileKey());
// 更新数据库状态
file.setUploadStatus(UploadStatus.FAILED);
fileMetadataMapper.updateById(file);
log.info("Cleaned up temp file: fileKey={}", file.getFileKey());
} catch (Exception e) {
log.error("Failed to cleanup temp file: fileKey={}",
file.getFileKey(), e);
}
}
}
/**
* 清理逻辑删除的文件
*/
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点执行
@Transactional
public void cleanupDeletedFiles() {
// 查找标记为删除超过30天的文件
List<FileMetadata> expiredFiles = fileMetadataMapper.selectDeletedFiles(
LocalDateTime.now().minusDays(30));
for (FileMetadata file : expiredFiles) {
try {
// 删除 OSS 文件
ossClient.deleteObject(bucketName, file.getFileKey());
// 彻底删除数据库记录
fileMetadataMapper.deleteById(file.getId());
log.info("Permanently deleted file: fileKey={}", file.getFileKey());
} catch (Exception e) {
log.error("Failed to permanently delete file: fileKey={}",
file.getFileKey(), e);
}
}
}
/**
* 文件生命周期管理(OSS 生命周期规则配置)
*/
public void configureLifecycleRules() {
// 通过 OSS SDK 设置生命周期规则
SetBucketLifecycleRequest request = new SetBucketLifecycleRequest(bucketName);
// 规则:临时文件目录 1天后自动删除
LifecycleRule tempRule = new LifecycleRule();
tempRule.setId("temp-file-cleanup");
tempRule.setPrefix("temp/");
tempRule.setStatus(LifecycleRule.RuleStatus.Enabled);
tempRule.setExpirationDays(1);
request.addLifecycleRule(tempRule);
// 规则:归档存储目录 3年后自动删除
LifecycleRule archiveRule = new LifecycleRule();
archiveRule.setId("archive-file-cleanup");
archiveRule.setPrefix("archive/");
archiveRule.setStatus(LifecycleRule.RuleStatus.Enabled);
archiveRule.setExpirationDays(1095);
request.addLifecycleRule(archiveRule);
ossClient.setBucketLifecycle(request);
}
}OSS 生命周期规则(JSON 配置示例)
阿里云 OSS 也支持通过 JSON 配置生命周期规则,可直接在控制台配置或通过 SDK 创建:
{
"rules": [
{
"id": "temp-cleanup",
"status": "Enabled",
"prefix": "temp/",
"expiration": {
"days": 1
}
},
{
"id": "ia-transition",
"status": "Enabled",
"prefix": "documents/",
"transition": [
{
"days": 90,
"storageClass": "IA"
},
{
"days": 365,
"storageClass": "Archive"
}
]
},
{
"id": "archive-expire",
"status": "Enabled",
"prefix": "archive/",
"expiration": {
"days": 1095
}
}
]
}五、总结
| 子系统 | 核心要点 | 关键设计决策 |
|---|---|---|
| 通知系统 | 消息分类、多渠道分发、去重合并、频率控制、优先级队列 | 推拉结合、滑动窗口限流、RabbitMQ 优先级队列 |
| 站内信 | message/notification/inbox 三表设计、推拉结合的已读未读、消息撤回 | 业务消息写扩散 + 系统/营销消息读扩散 |
| 消息推送 | 第三方 SDK 集成、APNs/FCM 原理、厂商通道适配 | 统一接口 + 按品牌路由 + 第三方降级 |
| 文件存储 | OSS 分层存储、客户端直传/服务端上传/分片上传,图片处理与视频转码 | 临时签名 URL 鉴权、生命周期规则自动清理 |