Skip to content
Open
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
10 changes: 9 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ ARG MODULE_PATH=mate-auth

RUN mvn clean package -pl ${MODULE_PATH} -am -DskipTests -B --no-transfer-progress

# Normalize the runnable jar to a fixed name. Dual-role modules (auth/system/notice)
# emit a thin <name>.jar (library) plus a fat <name>-exec.jar (runnable) — prefer the
# exec jar; single-jar modules (gateway/ai) fall back to the plain *.jar.
RUN set -e; \
JAR="$(ls ${MODULE_PATH}/target/*-exec.jar 2>/dev/null | head -n1)"; \
[ -z "$JAR" ] && JAR="$(ls ${MODULE_PATH}/target/*.jar | head -n1)"; \
cp "$JAR" /build/app.jar

# ============================================================
# Stage 2: Runtime
# ============================================================
Expand All @@ -32,7 +40,7 @@ WORKDIR /app

ARG MODULE_PATH=mate-auth

COPY --from=builder /build/${MODULE_PATH}/target/*.jar app.jar
COPY --from=builder /build/app.jar app.jar
# 非 root 用户跑, 需可写 HOME 供 npx/npm 缓存 (~/.npm)
RUN mkdir -p /home/mate/.npm && chown -R mate:mate /app /home/mate
ENV HOME=/home/mate
Expand Down
21 changes: 16 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# MateCloud Makefile
# Convenience targets for common dev / ops operations.

.PHONY: help build build-module up down restart logs clean test docs monolith run-monolith
.PHONY: help build build-module up down restart logs clean test docs monolith run-monolith monolith-up monolith-down

help:
@echo "MateCloud Makefile"
Expand All @@ -23,6 +23,11 @@ help:
@echo ""
@echo " docker-build Build all service docker images"
@echo " docker-push Push all service images to registry (REGISTRY=...)"
@echo ""
@echo " monolith Build the single-JVM monolith JAR (-Pmonolith)"
@echo " run-monolith Run the monolith JAR locally (needs MySQL + Redis)"
@echo " monolith-up Build + run the monolith in docker (infra + :8080)"
@echo " monolith-down Stop the monolith container"

build:
mvn clean install -DskipTests -B
Expand Down Expand Up @@ -72,8 +77,14 @@ docker-push:
docs: ## Generate API documentation (Smart-Doc)
mvn smart-doc:html -pl mate-biz/mate-system -q

monolith: ## Build monolith JAR
mvn clean package -pl mate-monolith -am -DskipTests -Pmonolith
monolith: ## Build monolith JAR (mvn -Pmonolith)
mvn -Pmonolith clean package -pl mate-monolith -am -DskipTests -B

run-monolith: ## Run monolith locally (mode/Nacos come from mate-infra-local.yml)
java -jar $$(ls mate-monolith/target/mate-monolith-*.jar | grep -v -- '-exec' | head -n1)

monolith-up: ## Build + run monolith in docker (infra + single JVM on :8080)
docker-compose --profile monolith up -d --build mysql redis mate-monolith

run-monolith: ## Run monolith mode (single JAR, no Dubbo)
MATE_RPC_MODE=local java -jar mate-monolith/target/mate-monolith-1.0.0.jar
monolith-down: ## Stop the monolith container
docker-compose --profile monolith stop mate-monolith
27 changes: 27 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,33 @@ services:
networks:
- mate-net

# ---- Monolith mode (opt-in alternative to the microservice stack above) ----
# auth + system + notice in ONE JVM on port 8080; no Nacos, no Dubbo, no broker.
# Start with: docker-compose --profile monolith up -d mysql redis mate-monolith
# It coexists with the microservice DB: Flyway adopts the existing schema instead
# of re-running migrations (see DataSourceAutoConfiguration#autoSeedPerServiceHistory).
mate-monolith:
profiles: ["monolith"]
build:
context: .
dockerfile: mate-monolith/Dockerfile
container_name: mate-monolith
restart: unless-stopped
depends_on:
- mysql
- redis
environment:
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod}
# Infra hosts = compose service names (placeholders default to 127.0.0.1,
# which inside a container would be the container itself).
MYSQL_HOST: mysql
REDIS_HOST: redis
MINIO_ENDPOINT: http://minio:9000
ports:
- "${MATE_MONOLITH_PORT:-8080}:8080"
networks:
- mate-net

# ---- Frontend (Vue 3 admin, served by nginx; proxies /api → mate-gateway) ----
mate-ui:
build:
Expand Down
53 changes: 52 additions & 1 deletion docs/rfcs/045-monolith-microservice-dual-mode.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
# RFC-045: 微服务 + 单体双模架构 — 一套代码,两种部署

- **Status**: Draft
- **Status**: Implemented (2026-06-28) — 见文末「实施记录」
- **Created**: 2026-04-12
- **Author**: MateCloud Team
- **Wave**: 10
- **Dependencies**: RFC-038, RFC-039

> ⚠️ 本文 Part 1–3 是最初设计草案,部分已过时(端口为 `8080` 非 `9000`;`mate-admin` 已并入
> `mate-system`;本地适配器为 4 个非 2 个)。**落地的真实方案与若干草案未预见的坑,见文末
> [实施记录](#实施记录-2026-06-28)。**

> "同一个代码库,`mate.rpc.mode=local` 启动一个 JAR 就是单体,`dubbo` 启动五个进程就是微服务。"

## 背景
Expand Down Expand Up @@ -567,3 +571,50 @@ File: `pom.xml`(root)
- **只有 2 个 Local 适配器**:当前只有 auth→system 有 RPC 调用。如果未来新增跨服务 RPC,只需加对应的 Local 适配器
- **前端零改动**:API 路径完全一致,只改 base URL
- **这不是"微服务降级"**:单体模式是一种正式的部署形态,适合中小团队、开发环境、快速验证,不是临时方案

## 实施记录 (2026-06-28)

落地时发现草案漏掉了几个让单体「根本无法启动」的关键问题。最终方案如下,**全部不影响微服务模式**(共享 starter 的改动都用 `matchIfMissing=true` 保留 dubbo 默认行为,或仅在目标历史表为空时触发)。

### 1. 致命前提:业务模块必须能被当作「库」依赖(classifier)
`mate-auth/system/notice` 原本被 `spring-boot-maven-plugin` 打成可执行胖 JAR(类在 `BOOT-INF/classes/`),**无法作为 Maven 编译依赖** → 单体连编译都过不了。
- 方案:三个模块的 `spring-boot-maven-plugin` 加 `<classifier>exec</classifier>`。主 JAR 退回瘦库(单体可依赖),`*-exec.jar` 才是可运行胖包(微服务部署用)。
- 配套:根 `Dockerfile` 与三个服务 `Dockerfile` 改为优先取 `*-exec.jar`。

### 2. 配置优先级陷阱(`spring.config.import`)
被 `import` 进来的文件**优先级高于** importer 本身(这正是微服务里 Nacos `mate-infra` 能覆盖 `mate-defaults` 的机制)。所以单体写在 `application.yml` 顶层的覆盖项(`mate.rpc.mode=local`、关 Nacos)**全部被 `mate-defaults` 盖掉**,导致单体竟以 dubbo 模式启动、注册 Nacos、自调 Dubbo。
- 方案:把所有「覆盖 mate-defaults」的项放进**最后 import** 的 `mate-monolith/src/main/resources/mate-infra-local.yml`(它即单体版的「classpath mate-infra」),`application.yml` 只留入口与不冲突项。

### 3. 组合根:排除嵌套的 `@SpringBootApplication`
`scanBasePackages="vip.mate"` 会把三个服务的 `@SpringBootApplication` 当作 `@Configuration` 扫进来,重新激活它们的 `@EnableDiscoveryClient`/`@EnableAsync`。
- 方案:`MateMonolithApplication` 用显式 `@ComponentScan` + `excludeFilters` 排除这三个类(并保留 Boot 默认的两个 TypeExclude/AutoConfigurationExclude 过滤器)。

### 4. 单体专有的两处 Bean 冲突(多模块合一才暴露)
- **Mapper Bean 名冲突**:auth 与 system 各有一个 `LoginLogDao`,简单类名都叫 `loginLogDao` → 冲突。`DataSourceAutoConfiguration` 的 `@MapperScan` 改用 `FullyQualifiedAnnotationBeanNameGenerator`(按类型注入,对微服务透明)。
- **MyBatis TypeAlias 冲突**:两个 `LoginLogPO` 简单名相同 → 别名重复抛错。项目无任何 XML mapper,`setTypeAliasesPackage(...)` 是死配置 → 直接移除。

### 5. 消除重复:`RolePermissionResolverPort`
草案的「Local 适配器」会让 `LocalTokenIssuer` 与 `SaTokenIssuer` 90% 重复。改为抽出唯一随模式变化的「按用户查角色/权限」为端口:
- `SaTokenIssuer` 变为**两模式共用**的唯一 `TokenIssuerPort` 实现,只依赖该端口;
- `DubboRolePermissionResolver`(auth,dubbo)走 RPC + Redis 兜底;`LocalRolePermissionResolver`(monolith,local)直调 `IPermissionDomainService`。
- 本地适配器现共 4 个:`UserQuery` / `UserRegistration` / `NoticeDispatcher` / `RolePermissionResolver`。

### 6. 单体无需消息中间件(域事件走进程内)
真实域事件流本就是 Spring `ApplicationEventPublisher` + `@TransactionalEventListener`,RabbitMQ 那套(`DomainEventAutoConfiguration` 等)是零消费者的跨服务脚手架。
- 方案:`RabbitMqAutoConfiguration` / `DomainEventAutoConfiguration` 加 `@ConditionalOnProperty(mate.rpc.mode=dubbo, matchIfMissing=true)`;单体再 `spring.autoconfigure.exclude` 掉 Boot 的 `RabbitAutoConfiguration` → 不连 broker、`health: UP`。

### 7. 单体彻底关闭 Dubbo
`mate-defaults` 的 `dubbo.scan.base-packages` 会让 dubbo-spring-boot-autoconfigure 在无 `@EnableDubbo` 时仍扫描并导出 `@DubboService`。
- 方案:单体 `spring.autoconfigure.exclude` 掉 `DubboAutoConfiguration` / `DubboRelaxedBindingAutoConfiguration` / `DubboListenerAutoConfiguration`。

### 8. Flyway:一张历史表 + 「领养」既有 schema
单体所有迁移进单表 `flyway_history_monolith`(`mate.module.code=monolith`,各版本号全局唯一)。难点是**与微服务共用同一个库**时不能重复建表。
- 修复潜在 bug:`repairThenMigrate` 策略的 `@ConditionalOnClass(name="Flyway")` 用了非全限定名 → 条件永远 false、自愈逻辑从未生效。改为 `@ConditionalOnClass(Flyway.class)`。
- 扩展 `autoSeedPerServiceHistory`:除遗留单表外,还从**兄弟 `flyway_history_*` 表**把已应用记录播种进目标表(重排 `installed_rank`、跳过 baseline 伪记录、清理 `success=0` 残留)。于是单体首启会「领养」微服务已迁移的 schema → `No migration necessary`;全新库则照常跑全部迁移;二次启动幂等。

### 9. 构建 / 运行
- `mate-monolith/Dockerfile`(多阶段,`-Pmonolith` 构建);`docker-compose.yml` 增加 `mate-monolith` 服务并置于 compose `profiles: [monolith]`(默认不随微服务栈启动)。
- `make monolith` / `run-monolith` / `monolith-up` / `monolith-down`。

### 部署注意
单体与微服务可共用同一个库(Flyway 自动领养),但**不要同时运行**两套写同一份数据。全新部署直接起单体即可;从微服务库切单体时,首启自动领养,无需手工 SQL。
3 changes: 2 additions & 1 deletion mate-auth/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ FROM eclipse-temurin:21-jre-alpine
LABEL maintainer="MateCloud Team"

WORKDIR /app
COPY target/*.jar app.jar
# Runnable fat jar carries the 'exec' classifier (the plain jar is the thin library).
COPY target/*-exec.jar app.jar

ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseZGC"
ENV SPRING_PROFILES_ACTIVE=prod
Expand Down
6 changes: 6 additions & 0 deletions mate-auth/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- Dual-role module: runnable service AND a library consumed by mate-monolith.
classifier keeps the thin mate-auth.jar as the main (library) artifact and
emits mate-auth-exec.jar as the executable fat jar for microservice deploy. -->
<classifier>exec</classifier>
</configuration>
</plugin>
</plugins>
</build>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2024-2026 Beijing Daotiandi Technology Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package vip.mate.auth.domain.adapter.port;

import vip.mate.auth.domain.model.aggregate.AuthUser;

import java.util.List;

/**
* Outbound port: resolve a user's RBAC role keys and permission codes.
* <p>
* This is the ONLY part of token issuance that differs between deployment modes:
* in microservice mode it goes to mate-system over Dubbo
* ({@code DubboRolePermissionResolver}); in monolith mode it calls
* mate-system's permission domain service in-process
* ({@code LocalRolePermissionResolver}). {@code SaTokenIssuer} stays mode-agnostic
* and depends only on this port.
*
* @author mateaix
*/
public interface RolePermissionResolverPort {

/**
* Role keys for the user. Implementations should prefer roles already carried
* on the {@link AuthUser} and only look them up when absent. Never returns
* {@code null} — an empty list means "no roles resolved".
*/
List<String> resolveRoleKeys(AuthUser user);

/**
* Permission codes for the user, with the same contract as
* {@link #resolveRoleKeys(AuthUser)}.
*/
List<String> resolvePermissions(AuthUser user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2024-2026 Beijing Daotiandi Technology Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package vip.mate.auth.infrastructure.adapter.token;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.apache.dubbo.config.annotation.DubboReference;
import vip.mate.api.admin.service.IRpcPermissionService;
import vip.mate.api.rpc.RpcConstants;
import vip.mate.auth.domain.adapter.port.RolePermissionResolverPort;
import vip.mate.auth.domain.model.aggregate.AuthUser;
import vip.mate.base.result.Result;

import java.util.List;

/**
* Microservice-mode {@link RolePermissionResolverPort}: fetches roles/permissions
* from mate-system over Dubbo.
*
* <p><b>Fail-closed:</b> on RPC failure it falls back to the Redis set that
* {@code SaTokenIssuer} cached on the user's previous successful login, so a
* transient mate-system outage does not silently strip a user's authority.
*
* @author mateaix
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "mate.rpc.mode", havingValue = "dubbo", matchIfMissing = true)
public class DubboRolePermissionResolver implements RolePermissionResolverPort {

private final StringRedisTemplate stringRedisTemplate;

@DubboReference(check = false, timeout = 5000, retries = 1,
version = RpcConstants.VERSION, group = RpcConstants.GROUP_SYSTEM)
private IRpcPermissionService rpcPermissionService;

public DubboRolePermissionResolver(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}

@Override
public List<String> resolveRoleKeys(AuthUser user) {
List<String> roles = user.getRoleCodes();
if (roles != null && !roles.isEmpty()) {
return roles;
}
try {
Result<List<String>> result = rpcPermissionService.getRoleKeysByUsername(user.getUsername());
if (result != null && Boolean.TRUE.equals(result.getSuccess())
&& result.getData() != null && !result.getData().isEmpty()) {
log.debug("[auth] Fetched roles from mate-system for username={}: {}", user.getUsername(), result.getData());
return result.getData();
}
} catch (Exception e) {
log.warn("[auth] Failed to fetch roles from mate-system for username={}: {}", user.getUsername(), e.getMessage());
return fallbackFromCache(SessionCacheKeys.ROLE_KEY_PREFIX + user.getUserId(),
"roles", user.getUsername());
}
return List.of();
}

@Override
public List<String> resolvePermissions(AuthUser user) {
List<String> upstream = user.getPermissions();
if (upstream != null && !upstream.isEmpty()) {
return upstream;
}
try {
Result<List<String>> result = rpcPermissionService.getPermissionsByUsername(user.getUsername());
if (result != null && Boolean.TRUE.equals(result.getSuccess())
&& result.getData() != null && !result.getData().isEmpty()) {
log.debug("[auth] Fetched permissions from mate-system for username={}: {}", user.getUsername(), result.getData());
return result.getData();
}
} catch (Exception e) {
log.warn("[auth] Failed to fetch permissions from mate-system for username={}: {}", user.getUsername(), e.getMessage());
return fallbackFromCache(SessionCacheKeys.PERM_KEY_PREFIX + user.getUserId(),
"permissions", user.getUsername());
}
return List.of();
}

/**
* Attempt to read a previously-cached role/permission set from Redis. If the
* cache is also empty, log the degraded state and return empty rather than
* throwing — the user can still log in but will have no authorised actions
* until mate-system recovers.
*/
private List<String> fallbackFromCache(String redisKey, String label, String username) {
try {
var cached = stringRedisTemplate.opsForSet().members(redisKey);
if (cached != null && !cached.isEmpty()) {
log.info("[auth] Using cached {} for username={} (mate-system unavailable)", label, username);
return List.copyOf(cached);
}
} catch (Exception ex) {
log.error("[auth] Redis fallback also failed for {} of username={}: {}", label, username, ex.getMessage());
}
log.error("[auth] No {} available for username={} — mate-system RPC failed and no Redis cache exists. "
+ "User will have zero {}.", label, username, label);
return List.of();
}
}
Loading