构建多通道通知中心:模板化设计、异步削峰与可靠重试实践

先交代一个背景。做电商的都知道,通知服务是最容易被忽略、又最容易出事故的模块。订单挂了还能找日志,短信发晚了用户直接开骂。我在公司里看到的场景很典型:订单服务里直接封装了阿里云短信 SDK,注册服务自己写 JavaMail 配置,运营后台又单独维护一套站内信逻辑。等短信供应商涨价,或者你想换一家到达率更高的通道,你会发现需要改动的服务比想象中多得多,那种感觉特别崩溃。

后来我们下决心搞了一个"通知中台"。这里我用"中台"这个词其实心里有点虚,本质上就是一个对业务方屏蔽渠道差异、统一负责发送逻辑的服务。它的目标很简单:业务方只负责说"给谁发什么",至于怎么发、走哪家供应商、失败了怎么办,全部扔给通知中心。

一、先看整体流程

先别管复杂的抽象,你可以把通知中心想象成一条流水线:

复制代码
业务方请求 → 接入层做校验/幂等/转化任务 → 落库(状态为 INIT)
    → 异步抓取任务 → 模板渲染 → 路由选择 Channel/Provider
    → 调用发送 → 结果回写 → 失败则按策略重试/死信

我见过不少团队把通知服务做成了一个非常薄的 API:Controller 里直接调短信 SDK,没入库没状态。当时觉得没问题,上线后才发现一旦用户说"我没收到",根本没有任何记录可以查。所以第一条原则:发送必须落库,任务要有一个生命周期,状态从 INIT 开始,一直记录到 SENDING、SUCCESS、FAILED、DEAD。异步为什么要先落库?因为通知任务最好是一次持久化的"意图",之后无论是线程池、MQ 消费者还是定时任务去执行,都只是一次状态推进。

架构上我们并没有一开始就上很大的集群,而是把一个 Spring Boot 应用拆成了比较清晰的模块:

  • API 层:NotificationService 接口,负责接收业务方请求
  • 核心域模型:NotificationTask、Channel、Provider、Template
  • 基础设施:ChannelRouter、TemplateRenderService、重试 Job、Redis 等等

大概的分层见下面这张简化的图:

复制代码
┌──────────────────────────────────────────────┐
│        业务方(只关心"发什么通知")             │
└──────────────────────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│   通知中心接入层:校验、幂等、创建任务、落库     │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│  异步任务队列(线程池 / MQ)                  │
│    → 模板渲染 → 路由选择 → 发送               │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│  具体渠道适配器                              │
│  EmailChannel → SMTP / SES                  │
│  SmsChannel  → 阿里云 / 腾讯云              │
│  StationChannel → 写站内信表                 │
└──────────────────────────────────────────────┘

业务方调用体验是这样的:

java 复制代码
NotificationRequest request = NotificationRequest.builder()
        .type(NotifyType.ORDER_TIMEOUT)
        .bizId(orderId.toString())
        .channelTypeList(Arrays.asList(ChannelType.EMAIL, ChannelType.SMS))
        .receiver("tom@example.com")
        .params(ImmutableMap.of("orderId", order.getOrderNo(),
                                 "timeout", "10分钟"))
        .build();

notificationService.send(request);

bizId 是幂等用的,params 里是模板渲染需要的业务参数。模板编码、渠道选择、重试策略在后台配置好即可。

二、模板化:用 Thymeleaf 把内容渲染整明白

最早做通知,大家习惯用 String.format 拼内容,或者直接把 HTML 嵌在 Java 常量里。如果只是翻译几个订单变量,好说;但运营想要改一个字都要提需求,那就太累了。我们把内容和格式拆开,每个 notifyType 可以看作一套模板组,同一种通知在不同渠道有不同的模板。

用 Thymeleaf 渲染模板的好处是它既能输出 HTML,又能输出纯文本,语法上也顺手。我把模板放在数据库里,也可以通过 Git 管理,发布时不用动应用。模板示例:

邮件模板 order_timeout_email.html:

html 复制代码
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"/></head>
<body>
  <h2>订单超时提醒</h2>
  <p>亲爱的<b th:text="${userName}">用户</b>,您好:</p>
  <p>
    订单 <span th:text="${orderId}"></span> 已超过
    <span th:text="${timeout}"></span> 未支付,系统已自动关闭该订单。
  </p>
  <a th:href="${detailUrl}">查看订单详情</a>
</body>
</html>

短信模板 order_timeout_sms.txt:

复制代码
【XX商城】您的订单${orderId}已超过${timeout}未支付,系统已自动关闭。如有疑问请联系客服,退订请回复TD。

渲染组件封装得很薄:

java 复制代码
@Component
public class TemplateRenderService {

    private final ThymeleafTemplateEngine engine;

    public String render(String templateContent,
                         Map<String, Object> params,
                         TemplateMode mode) {
        // 用 StringTemplateResolver 处理存储在数据库的模板内容
        StringTemplateResolver resolver = new StringTemplateResolver();
        resolver.setTemplateMode(mode); // HTML / TEXT
        engine.setTemplateResolver(resolver);

        Context context = new Context();
        context.setVariables(params);
        return engine.process(templateContent, context);
    }
}

仔细看代码,你会发现每次渲染前都在给 engine 设置 resolver。理论上 StringTemplateResolver 无状态,你可以在初始化时只指定一次模板模式,但那段代码只是演示思路,实际项目里我建议直接创建一个独立的 TemplateEngine,并为每个渠道准备 resolver。Thymeleaf 引擎本身是线程安全的,process 方法可以放心调用。

模板按渠道编码区分。比如订单超时通知,邮件模板编码是 ORDER_TIMEOUT_EMAIL,短信模板编码是 ORDER_TIMEOUT_SMS。路由阶段根据通知类型和选择的渠道去找对应模板。说白了,业务参数只有一份,渠道差异全部用模板自己消化。产品改文案只需去后台改模板,不用再找你发版。

有时候短信内容因为 70 字限制,只取了 orderId 和 timeout;邮件排版复杂一些,可以引用十几个参数,这些都让模板自己搞。模板渲染是纯函数,好测试也容易追踪------查一条通知时,把模板和参数回放一遍就能还原当时的内容。

三、核心抽象:Channel 和 Provider

真正让我敢在公司里重构这堆逻辑的底气,是抽出了两个接口:Channel 和 Provider。

  • Channel:表示一种消息触达通道,比如异步邮件、短信、站内信。一个 Channel 内可能包含多个具体服务商。
  • Provider:具体服务商适配器,比如阿里云短信、腾讯云短信。

很多人一开始容易把它们搞混。我理解是:Channel 定义的是"以什么方式触达",Provider 定义的是"实际由谁发送"。Channel 内部可以根据路由规则选择 Provider。

Channel 接口长得像这样:

java 复制代码
public interface Channel {
    ChannelType type();
    boolean support(Notification notification);
    SendResult send(Notification notification);
}

为什么要一个 support 方法?因为有些通道有前置条件。站内信 Channel 必须要求当前用户存在,短信 Channel 要求接收人有手机号。support 把判定逻辑交还给各自 Channel,避免业务方传参时还要做一堆判断。

EmailChannel 走 Spring 的 JavaMailSender,代码并不复杂。为了发 HTML 内容,需要构建 MimeMessage:

java 复制代码
@Component
public class EmailChannel implements Channel {

    private final JavaMailSender mailSender;
    private final EmailProvider emailProvider;  // 作为默认邮件服务器配置

    @Override
    public ChannelType type() { return ChannelType.EMAIL; }

    @Override
    public SendResult send(Notification notification) {
        try {
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(emailProvider.getFromAddress());
            helper.setTo(notification.getReceiver());
            helper.setSubject(notification.getSubject());
            // content 在上一渲染阶段已经放入 Notification 对象
            helper.setText(notification.getContent(), true);
            mailSender.send(message);
            return SendResult.success();
        } catch (Exception e) {
            return SendResult.fail(e.getMessage());
        }
    }
}

我特意没有在 EmailChannel 中写模板渲染逻辑,渲染应该在进入 Channel 之前做好,渲染结果放进 Notification 的 content 字段中。这样多个 Channel 可以共享同一份渲染后的内容,你也可以在日志里直接看到实际发出去的消息全文。

短信的 Channel 会有多 Provider。假设你接了阿里云、腾讯云,在实际使用中你可能把阿里云作为主通道,腾讯云作为备选。先看 Provider 定义:

java 复制代码
public interface SmsProvider {
    String name();
    SmsSendResult send(SmsRequest request);
}

阿里云实现里会调用对应的 SDK,代码很简单:

java 复制代码
@Component
public class AliyunSmsProvider implements SmsProvider {

    @Override
    public String name() { return "aliyun"; }

    @Override
    public SmsSendResult send(SmsRequest request) {
        try {
            CommonRequest req = new CommonRequest();
            // ... 组装参数、签名、模板参数等
            SendSmsResponse resp = client.getAcsResponse(sendReq);
            if ("OK".equals(resp.getCode())) {
                // 保留供应商返回的 requestId,后续状态查询要用
                return SmsSendResult.success(resp.getRequestId());
            }
            return SmsSendResult.fail(resp.getCode(), resp.getMessage());
        } catch (Exception e) {
            return SmsSendResult.fail(e.getMessage());
        }
    }
}

SmsChannel 要做的事就是根据路由策略挑选一个 Provider 并发送。这里我用了一个 SmsRouter,可以简单理解成一个按规则返回 Provider 的组件。

java 复制代码
@Component
public class SmsChannel implements Channel {

    private final List<SmsProvider> providers;
    private final SmsRouter smsRouter;

    @Override
    public ChannelType type() { return ChannelType.SMS; }

    @Override
    public SendResult send(Notification notification) {
        SmsProvider provider = smsRouter.choose(notification, providers);
        if (provider == null) {
            return SendResult.fail("no available sms provider");
        }

        SmsRequest smsRequest = SmsRequest.builder()
                .mobile(notification.getReceiver())
                .signName(notification.getSignName())
                .templateCode(notification.getSmsTemplateCode())
                .templateParam(notification.getSmsParamJson())
                .build();

        SmsSendResult result = provider.send(smsRequest);
        if (result.isSuccess()) {
            // 把供应商的 requestId 记录下来,为将来查询状态留了线索
            return SendResult.success(result.getVendorReqId());
        }
        return SendResult.fail(result.getErrorMsg())
                .withRecoverable(true)
                .setProviderName(provider.name());
    }
}

站内信是最朴素的实现。不需要调用任何外部服务,直接插入一张表。不同的是,用户可能并没有注册 userId,这需要业务方在请求中显式传 userId,而不是用 receiver 字符串去匹配。

java 复制代码
@Component
public class StationChannel implements Channel {

    private final StationMessageMapper stationMessageMapper;

    @Override
    public SendResult send(Notification notification) {
        StationMessage msg = StationMessage.builder()
                .userId(notification.getUserId())
                .title(notification.getTitle())
                .content(notification.getContent())
                .read(false)
                .createdAt(LocalDateTime.now())
                .build();
        stationMessageMapper.insert(msg);
        return SendResult.success(msg.getId().toString());
    }
}

把所有 Channel 收集到一个 Map 里,方便路由查找:

java 复制代码
@Configuration
public class ChannelRegistry {
    @Bean
    public ChannelRouter channelRouter(List<Channel> channels) {
        Map<ChannelType, Channel> channelMap = channels.stream()
                .collect(Collectors.toMap(Channel::type, Function.identity()));
        return new ChannelRouter(channelMap);
    }
}

四、异步化:线程池和 MQ 不是二选一

同步发送通知最坑人的地方就是:外部服务一旦慢,你的业务接口也一起慢。短信通道平均 200ms,如果超时设置 5 秒,那核心下单接口就可能被短信拖死。所以我们把发送动作从主流程中剥离。

如果你的系统是单体,流量也不是特别夸张,先用一个线程池最合适。

java 复制代码
@Bean("notificationExecutor")
public ThreadPoolTaskExecutor notificationExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(16);
    executor.setQueueCapacity(10000);
    executor.setThreadNamePrefix("notify-exec-");
    executor.setRejectedExecutionHandler(new CallerRunsPolicy());
    executor.initialize();
    return executor;
}

调用方调了 send 接口后,代码内部只做"生成 NotificationTask 并落库",然后丢给线程池执行。只要 executor 的队列没满,主流程几乎是无延迟的响应。

后来随着微服务拆分,业务方各自部署,我们把通知中心独立成一个服务,通过 MQ 接收任务。每个业务方发送时,把通知任务内容作为消息发到 RocketMQ,通知服务消费并执行发送。这个演进也自然:

java 复制代码
@PostMapping(value = "/notifications/send")
public void send(@RequestBody NotificationRequest request) {
    NotificationTask task = convertAndSave(request);
    rabbitTemplate.convertAndSend("notification.exchange", "notify.route", task);
}

用了 MQ 后,如果你只是把任务发进队列然后让消费者发短信,依然有风险:消费者拉到消息后应用突然宕机,消息丢了。那怎么办?我们的做法是,任务表里已经存在 INIT 状态,MQ 只是一个触发信号,真正的事实来源还是数据库。消费端拿到 taskId,然后加载 task 执行。如果消费挂了,任务表里还是 INIT,定时扫描的 Job 会把这些任务捞出来继续执行。所以说 MQ 和数据库不是替代关系,而是配合:MQ 用来快速触发,DB 用来兜底。

即便有线程池或 MQ,通知中心还应该对自己做限流。不能等到第三方供应商 qps 限制打回来了你才想到。我常用 Guava RateLimiter,非常轻:

java 复制代码
private final RateLimiter smsRateLimiter = RateLimiter.create(50); // 每秒最多 50 条

public void sendSms(NotificationTask task) {
    boolean acquire = smsRateLimiter.tryAcquire(Duration.ofSeconds(1));
    if (!acquire) {
        // 稍后重试
        throw new RetryableException("sms channel qps over limit");
    }
    realSend(task);
}

限流被击中时不要立刻打日志报警,那是正常业务情况。把任务状态置回 WAITING,设置 nextRetryTime = now + 1s,让重试 Job 下一轮处理就行。

五、可靠性与重试

一条通知从接收到最终结果,我们通常要记录以下几类信息:

  • id,主键
  • biz_id 和 biz_type,业务方幂等键,比如订单超时通知里 biz_type = ORDER_TIMEOUT,biz_id = orderNo
  • channel_type,目前是 EMAIL / SMS / STATION
  • notify_type,通知业务类型,对应一组模板和策略
  • template_code,实际渲染使用的模板编码
  • receiver,接收人,数据库里建议加密存储,日志里必须脱敏
  • params_digest,业务参数 JSON,也建议加密
  • content,渲染后的完整内容,方便事后查看"用户到底收到了什么"
  • status,用 INIT、SENDING、SUCCESS、FAILED、DEAD 这几个枚举
  • retry_count、max_retry_count,已经尝试了几次,最多计划尝试几次。
  • next_retry_time,用来定时重试的字段
  • last_error,最后一次错误信息
  • create_time、update_time,不多解释

这部分设计没有太多技巧,重要的是想清楚重试流程。看图:

复制代码
save(task=INIT)
  ↓ 放入异步执行
  置为 SENDING
    ↓ channel.send()
   成功 → SUCCESS,结束
   失败 → 判断是否可重试
     是 → retry_count + 1,计算 next_retry_time,置回 INIT/PENDING
     否 → 置为 FAILED(再往后可能是 DEAD)

重试时间基本都按指数退避来算:第一次失败 1 分钟后试,第二次 5 分钟,第三次 30 分钟......上限看业务需要,验证码最好 3 次以内,营销邮件可以多一点。

重试 Job 用 Spring @Scheduled 就先够用:

java 复制代码
@Scheduled(fixedDelay = 30000)
public void retryFailedTask() {
    List<NotificationTask> tasks = taskMapper.selectRetryable(
            LocalDateTime.now(), 100);

    for (NotificationTask task : tasks) {
        if (task.getRetryCount() >= task.getMaxRetryCount()) {
            handleDead(task);
            continue;
        }
        task.setStatus(TaskStatus.SENDING);
        taskMapper.updateById(task);
        // 再丢回异步队列去执行
        sendProcess(task.getId());
    }
}

有一个细节很多人会忽略:如果是外部渠道超时,但你不知道短信有没有被渠道成功发出,直接盲重发就可能造成用户收到多条同内容短信。这种情况下不要立刻把任务标记为 FAILED,而是先调用供应商的查询接口,看看这条消息到底有没有发出去。我们在设计 SmsChannel 时保留了 vendor_req_id,就是为了支持这个查询动作。

到了最大重试次数,任务进入 DEAD 状态。死信必须有人处理,不能光写在日志里。我们会发一个钉钉告警给运维:

java 复制代码
public void handleDead(NotificationTask task) {
    task.setStatus(TaskStatus.DEAD);
    taskMapper.updateById(task);

    alertService.sendToOps("通知死信", String.format(
            "任务[%s],渠道=%s,接收人=%s,错误=%s",
            task.getId(), task.getChannelType(),
            DesensUtil.mask(task.getReceiver()), task.getLastError()));
}

验证码这种强时效业务,一般用户会点击"重新发送"。此时我们查到旧任务已经是 DEAD,就允许创建一个新任务,同时把旧验证码置为无效。其他类型死信还是要运营每天关注去重发或者手动处理。

六、路由降级和频控

邮件、短信、站内信,这几种渠道的可达性完全不一样。短信一般都会接至少两个供应商,天然要做主备切换。路由策略不能写死在代码里,而是要根据通道健康状态、发送成本、业务紧急程度来实时决策。

我最简单也最实用的做法是:在 Provider 层维护一个内存健康状态,由定时 Job 去 mock 发送或者检查供应商的熔断器。当路由被调用时,如果发现主供应商不可用,就切换到备用供应商。这里的"不可用"不只有返回失败,还包括连续超时比例过高,可能是网络问题,此时主通道也应该暂时破产。

跨渠道降级则要更谨慎。比如用户收到验证码短信,短信通道挂了,你不能降级成邮件或者站内信,因为用户可能不会去站点看。降级要业务方明确允许才开放。我会在通知任务上带一个 allowChannels 集合,如果业务方只允许短信,那短信失败就只能重试,不能再跳到别的方式。

另外很多通知不只是一种发送需求。订单催付、营销活动、验证码的频控规则差异巨大。没有频控,你的通知中心可能被人薅成短信轰炸机。频控主要按几个维度:

  • 同一用户同一类型,频率限制,比如验证码 1 分钟内最多 3 次
  • 同一手机号一天的营销总条数限制
  • 渠道总 QPS 限制

实现用 Redis 做计数器很自然:

java 复制代码
public boolean canSend(String phone, NotifyType type) {
    String key = "notify:limit:" + type.getCode() + ":" + phone;
    Long count = redisTemplate.opsForValue().increment(key);
    if (count == 1) {
        redisTemplate.expire(key, Duration.ofMinutes(1));
    }
    return count <= maxPermits(type, phone);
}

当频控触发,除了直接拒绝,还可以根据场景决定是延迟重试还是降级为低打扰渠道。比如营销活动短信被限流了,可以自动转为站内信;验证码被限流了,你可以提示用户 60 秒后再获取。

七、幂等、脱敏和审计

幂等

用户连续点了两次"获取验证码",或者前端超时重试导致同一个订单催付通知被发送两次。这是通知中心必须处理的。最简单的办法是在任务表上建联合唯一索引:

sql 复制代码
unique key uk_biz_type (biz_type, biz_id, channel_type)

插入前先查一遍,或者干脆 INSERT IGNORE(具体根据你用的数据库选策略)。如果遇到高并发,MySQL 的唯一索引就足够扛了。如果业务场景极端,可以用 Redis SETNX 加锁:

java 复制代码
public void createTaskIfAbsent(NotificationRequest req) {
    String lockKey = String.format("notify:%s:%s:%s",
            req.getType(), req.getBizId(), req.getFirstChannel());
    Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
    if (success) {
        try {
            int rows = taskMapper.insert(task);
            if (rows > 0) {
                // 创建成功,继续后续流程
            }
        } finally {
            // 释放锁
        }
    }
}

如果任务已经存在,代码可以查询已有任务并返回它的当前状态。这样业务方不必拿"已存在"当异常,幂等语义就是"同一个业务请求最终只会产生一条消息"。

敏感信息处理

用户的手机号、邮箱、地址,一定要加密存储。数据库落的是密文,只有发送时在内存中解密。日志里严禁打印原始信息,用脱敏工具统一处理:

java 复制代码
public class DesensUtil {
    public static String maskMobile(String mobile) {
        return mobile.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
    }

    public static String maskEmail(String email) {
        int idx = email.indexOf('@');
        if (idx <= 1) return email;
        return email.substring(0, 1) + "***" + email.substring(idx);
    }
}

千万别小看这一步。很多信息泄露就是通过日志泄露出去的,光是脱敏还不够,日志文件前要加上权限控制,过期要删除。

审计为什么要做

好的通知中心必须能回答一个问题:用户说他没收到短信,到底发生了什么。你不能只回一句"我们查一下"然后干瞪眼。我会把每一次发送的关键步骤都记录成一条审计日志,至少包含:

  • 业务方是谁,哪个系统发的,来源 IP
  • 请求参数(已经脱敏)
  • 最终选择了哪个 Channel,哪个 Provider
  • 发送耗时,状态,重试次数
  • 第三方返回的结果或错误信息

用 Spring AOP 也可以,直接在服务层打点更自然。我个人的习惯是在 NotificationService 的方法里埋一个简单的日志,异步发送到日志系统。关键点是日志中永远不能出现完整手机号、邮箱和明文密码类的信息。

AOP 大概长这样:

java 复制代码
@Aspect
@Component
public class AuditLogAspect {

    @Around("@annotation(audit)")
    public Object around(ProceedingJoinPoint pjp, Audit audit) throws Throwable {
        Object[] args = pjp.getArgs();
        String req = DesensUtil.serialize(args);

        long begin = System.currentTimeMillis();
        try {
            Object result = pjp.proceed();
            auditLogService.syncLog(req, result.toString(), 0);
            return result;
        } catch (Throwable e) {
            auditLogService.syncLog(req, e.getMessage(), 1);
            throw e;
        }
    }
}

这样从接入到最终发送,每个环节都留了记录。后面排查客诉时,不像从前那样全靠猜。

八、最后说点自己的体会

通知中心看起来很基础,其实特别考验抽象能力和边界把握能力。一开始不用设计得太复杂,但落库、模板渲染、异步、重试这四件事一定要有。如果你正在改造一个乱七八糟的通知模块,我建议这样排优先级:第一,先把发送记录落库;第二,把模板引擎替换掉手工拼接;第三,统一 Channel 和 Provider 接口;第四,再考虑 MQ 和复杂的路由降级。等这些都完成了,你的通知模块就不再是散落的补丁,而是一个真正能稳定支撑你业务的基础设施。

上面这些内容,基本是我前后重构两个系统总结出来的路数。代码里的细节未必适合每一个团队,但大方向应该没错。最关键的不是选什么技术,而是关注消息可靠性和用户体验。通知这东西,出问题通常是隐性的,用户可能只是默默流失。别让一条短信毁掉一次交易。


相关推荐
vipxieliang3 小时前
ValidX 验证器缓存机制:如何提升验证性能
java·spring boot
Thomas.Sir4 小时前
第1课:SpringBoot 4.x 快速上手:新特性、环境准备与第一个HelloWorld程序
java·spring boot
程序员小孔.4 小时前
基于 Spring Cloud + Vue 的 JEEWMS 开源仓储管理平台设计与实现
前端·javascript·vue.js·spring boot·spring cloud·开源
RuoyiOffice4 小时前
SpringBoot+Vue3 业财协同架构:预算、费用分摊、发票、往来与凭证怎样避免五套数字
spring boot·vue3·财务管理·spring boot 3·预算管理·ruoyi office·业财协同
她的男孩4 小时前
页面已发布用户仍进不去:Forge Admin 门户入口与系统菜单挂载解耦
java·spring boot·低代码
专业程序开发源5 小时前
django视听资源推荐系统16690-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·django·php·课程设计
Java爱好狂.13 小时前
Java初学者如何一周速通SpringBoot!
java·spring boot·后端·spring·高并发·架构师·java面试
顽疲18 小时前
java springboot vue 养老系统源码详解:请假 care_leave 销假与床位停费日扣实践
java·vue.js·spring boot
Java_AI工程师19 小时前
Spring4 / SpringBoot1.x 老项目想接入 AI?不用升级框架!3 套无痛落地方案
java·人工智能·spring boot