Merge pull request '更新' (#2) from wangys into master

Reviewed-on: #2
This commit is contained in:
2026-04-17 16:36:40 +08:00
116 changed files with 3875 additions and 583 deletions

View File

@@ -0,0 +1,21 @@
---
name: k12-frontend-import-alias
description: 当任务涉及 frontend 导入路径调整时使用,统一使用 @ 别名并禁止 ../ 上跳相对导入
---
# K12 前端导入别名规范技能
## 何时使用
- 修改 `frontend/src` 下任意 `ts/tsx/js` 文件
- 新增组件、页面、路由、API、store、utils 模块
- 代码评审指出存在 `../``../../` 上跳导入
## 规范规则
- 跨目录导入统一使用 `@/` 别名路径(如 `@/api``@/components`)。
- 禁止使用 `../``../../` 等上跳相对导入。
- 禁止使用以 `/` 开头的模块导入路径(如 `from "/utils/http"`)。
- 同目录导入允许使用 `./`(如 `./index.scss``./types`)。
## 执行步骤
1. 搜索并定位 `frontend/src` 内所有 `../``/` 绝对导入。
2. 将跨目录导入替换为 `@/` 别名。
3. 运行前端构建验证(`pnpm --dir frontend build`)。
## 约束
- 不改变业务逻辑,仅在导入路径层面做规范化修改(除非用户另有要求)。
- 保持 `tsconfig.json``vite.config.ts` 的别名配置一致。

6
.gitignore vendored
View File

@@ -1,12 +1,18 @@
urbanLifeline urbanLifeline
Tik Tik
schoolNewsServ schoolNewsServ
pigx
.claude
referenceCode
.idea/ .idea/
.vscode/* .vscode/*
!.vscode/settings.json !.vscode/settings.json
!.vscode/extensions.json !.vscode/extensions.json
.DS_Store .DS_Store
.tmp-run/ .tmp-run/
.gitnexus
**/target/ **/target/
**/node_modules/ **/node_modules/

119
AGENT.md Normal file
View File

@@ -0,0 +1,119 @@
# K12Study Agent 工程规范
本文件是所有自动化 agentClaude Code / Cursor / Copilot 等)在本仓库工作时必须遵守的项目级规范。
细则源文件位于 `.agents/skills/*/SKILL.md`,本文件是汇总入口,改动时两处保持一致。
---
## 1. 统一响应体契约API Response
来源:`.agents/skills/k12-api-response-standard/SKILL.md`
### 适用场景
- 新增或改造后端 API
- 改动前端 / 小程序请求层
- 修复“后端已报错但前端当成功处理”的联调问题
### 契约
响应体结构固定为 `code / message / data / traceId` 四字段。
- 成功:`code === 0`
- 业务失败:`code !== 0`,前端必须抛错并使用 `message` 透出
- `traceId` 必须保留用于链路追踪
### 强制项
1. 后端 Controller 与全局异常处理统一返回 `ApiResponse`(位于 `common-api`
2. 前端 `frontend/src/utils/http.ts`、小程序 `app/src/utils/request.js` 必须同时校验 HTTP 状态码与业务 `code`
3. 新接口落地时同步检查类型声明与调用方解包方式,避免重复定义结构
4. 同步更新 `docs/architecture/api-design.md` 中响应体约定
### 禁止项
- 禁止字段重命名(如 `message``msg`
- 禁止去掉 `traceId`
---
## 2. RESTful 接口风格
来源:`.agents/skills/k12-restful-api-style/SKILL.md`
### 适用场景
- 新增 API 设计
- 旧接口路径改造(如 `current-user``*/tree`
- 同步网关白名单与客户端调用路径
### 设计规则
- 路径优先使用资源名(名词),不是动作名
- 集合资源使用复数:`/users``/departments``/classes`
- “当前用户”使用语义路径:`/users/current`
- 树形结构通过资源路径表达:`/areas``/tenants``/departments`
- 统一入口前缀:`/api/*`,不得更改
### 强制项
1. Controller 使用 RESTful 主路径
2. 改路径必须同步更新:前端 API、小程序 API、网关白名单、`docs/architecture/api-design.md``docs/architecture/logical-view.md`
3. Feign 契约(`apis/api-*/remote/*RemoteApi.java`)与 Controller 路径必须一致;启动时 `FeignContractValidator` 会校验
### 禁止项
- **旧接口不兼容,禁止保留/恢复兼容别名**
- 禁止动词化路径(如 `/getUser``/doLogin`
---
## 3. 注释头规范
来源:`.agents/skills/k12-comment-header-standard/SKILL.md`
### 适用场景
- 任务涉及 `global.code-snippets``.fileheader/fileheader.config.yaml`
- 用户要求统一文件头 / 函数注释规范
- `@author` 对齐 git `user.name`
### 强制项
1.`git config user.name``@author` 基线(当前:**wangys**
2. 更新 `global.code-snippets``FileHeader``Method` 的 author 默认值
3. 使用 `turbo-file-header` 的项目须检查 `.fileheader/fileheader.config.yaml``@author`
4. 修改后验证 JSON / YAML 可解析
5. 保留字段顺序:`description / filename / author / copyright / since`
### 禁止项
- 不在此规范下修改业务逻辑代码
---
## 4. 前端导入别名规范
来源:`.agents/skills/k12-frontend-import-alias/SKILL.md`
### 适用场景
- 修改 `frontend/src` 下任意 `ts/tsx/js` 文件
- 新增组件、页面、路由、API、store、utils 模块
- 代码评审指出存在 `../``../../` 上跳导入
### 强制项
1. 跨目录导入统一使用 `@/` 别名路径(例如 `@/api``@/pages``@/utils`)。
2. 禁止使用 `../``../../` 等上跳相对导入。
3. 禁止使用以 `/` 开头的模块导入路径(如 `from "/utils/http"`)。
4. 同目录导入允许使用 `./`(例如 `./index.scss`)。
5. 改动后执行 `pnpm --dir frontend build` 验证。
### 禁止项
- 禁止新增任何 `../``../../` 或以 `/` 开头的导入写法。
---
## 5. Feign 契约与微服务边界(补充)
> 非 skills 原有条款但属于本仓库架构约束agent 工作时必须遵守。
- `apis/api-*` 模块只放 DTO + `@FeignClient` 接口 + 路径常量 + `FallbackFactory`**不得**依赖 service / jdbc / redis
- 新业务模块同时做 Provider + Consumer 时引 `common-feign`,自动启用 `@EnableFeignClients(basePackages="com.k12study.api")` + OkHttp + Authorization 透传 + 启动契约自检
- 微服务模式通过 Nacos`spring-cloud-starter-alibaba-nacos-discovery`)寻址,单体 / 本地联调通过 `k12study.remote.<module>.url` 走直连 URL 兜底
- 熔断降级统一返回 `ApiResponse.failure(503, reason)`,前端按标准响应体处理
---
## 6. Agent 协作约定
- 任务拆解与进度跟踪优先使用 `TaskCreate / TaskUpdate`,而不是把进度写进 memory
- 任何对本文件、`.agents/skills/*``docs/architecture/*` 的改动都必须在同一次提交中同步
- 本文件若与 `.agents/skills/*/SKILL.md` 冲突,以 SKILL.md 为准SKILL.md 是细则源头)

View File

@@ -1,4 +1,11 @@
const { request } = require("../utils/request"); /**
* @description 小程序 auth 模块 API 封装;login/refreshToken/getAuthCurrentUser 与后端 /api/auth/* 路径一一对应
* @filename auth.js
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
const { request } = require("/utils/request");
function login(data) { function login(data) {
return request({ return request({
@@ -8,6 +15,23 @@ function login(data) {
}); });
} }
function refreshToken(refreshToken) {
return request({
url: "/api/auth/tokens/refresh",
method: "POST",
data: { refreshToken }
});
}
function getAuthCurrentUser() {
return request({
url: "/api/auth/users/current",
method: "GET"
});
}
module.exports = { module.exports = {
login login,
refreshToken,
getAuthCurrentUser
}; };

View File

@@ -1,4 +1,4 @@
const { request } = require("../utils/request"); const { request } = require("/utils/request");
function getRouteMeta() { function getRouteMeta() {
return request({ return request({

View File

@@ -1,5 +1,6 @@
{ {
"pages": [ "pages": [
"pages/login/index",
"pages/home/index", "pages/home/index",
"pages/profile/index" "pages/profile/index"
], ],

View File

@@ -1,6 +1,64 @@
/**
* @description 学生端首页 Page;并发拉取 upms + auth 当前用户,客户端二次校验 roleCodes 必含 STUDENT 防御服务端异常放行
* @filename index.js
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
const { getCurrentUser } = require("/api/upms");
const { getAuthCurrentUser } = require("/api/auth");
const { clearTokens, getAccessToken } = require("/utils/session");
Page({ Page({
data: { data: {
title: "K12Study 小程序骨架", loading: false,
description: "这里先放首页占位,后续可扩展为家长端或学生端入口。" error: "",
currentUser: null
},
onShow() {
const accessToken = getAccessToken();
if (!accessToken) {
wx.reLaunch({ url: "/pages/login/index" });
return;
}
this.loadCurrentUser();
},
async loadCurrentUser() {
this.setData({ loading: true, error: "" });
try {
const [upmsResp, authResp] = await Promise.all([
getCurrentUser(),
getAuthCurrentUser()
]);
const upmsUser = upmsResp.data || {};
const authUser = authResp.data || {};
const roleCodes = Array.isArray(authUser.roleCodes) ? authUser.roleCodes : [];
const isStudent = roleCodes.some((code) => String(code).toUpperCase() === "STUDENT");
if (!isStudent) {
clearTokens();
this.setData({ error: "小程序仅允许学生账号登录" });
wx.reLaunch({ url: "/pages/login/index" });
return;
}
this.setData({
currentUser: {
username: upmsUser.username || authUser.username || "-",
displayName: upmsUser.displayName || authUser.displayName || "-",
tenantId: upmsUser.tenantId || authUser.tenantId || "-",
deptId: upmsUser.deptId || authUser.deptId || "-",
roleCodesText: roleCodes.join(", ") || "-"
}
});
} catch (error) {
this.setData({
error: error && error.message ? error.message : "加载用户信息失败"
});
} finally {
this.setData({ loading: false });
}
},
handleLogout() {
clearTokens();
wx.reLaunch({ url: "/pages/login/index" });
} }
}); });

View File

@@ -1,6 +1,15 @@
<view class="page"> <view class="page">
<view class="card"> <view class="card">
<view>{{title}}</view> <view class="title">学生端首页</view>
<view style="margin-top: 16rpx; color: #64748b;">{{description}}</view> <view wx:if="{{loading}}" class="hint">加载用户信息中...</view>
<view wx:elif="{{error}}" class="error">{{error}}</view>
<view wx:elif="{{currentUser}}" class="profile">
<view class="item">账号:{{currentUser.username}}</view>
<view class="item">姓名:{{currentUser.displayName}}</view>
<view class="item">租户:{{currentUser.tenantId}}</view>
<view class="item">部门:{{currentUser.deptId}}</view>
<view class="item">角色:{{currentUser.roleCodesText}}</view>
</view>
<button class="logout-btn" bindtap="handleLogout">退出登录</button>
</view> </view>
</view> </view>

View File

@@ -1,3 +1,25 @@
view { .title {
font-size: 34rpx;
font-weight: 600;
margin-bottom: 16rpx;
}
.hint {
color: #64748b;
margin-bottom: 16rpx;
}
.error {
color: #dc2626;
margin-bottom: 16rpx;
}
.profile {
display: grid;
gap: 10rpx;
margin-bottom: 20rpx;
}
.item {
line-height: 1.6; line-height: 1.6;
} }

View File

@@ -0,0 +1,50 @@
const { login } = require("/api/auth");
const { getAccessToken, setTokens } = require("/utils/session");
Page({
data: {
username: "student01",
password: "stud123",
tenantId: "SCH-HQ",
loading: false,
error: ""
},
onShow() {
if (getAccessToken()) {
wx.reLaunch({ url: "/pages/home/index" });
}
},
onUsernameInput(event) {
this.setData({ username: event.detail.value });
},
onPasswordInput(event) {
this.setData({ password: event.detail.value });
},
onTenantInput(event) {
this.setData({ tenantId: event.detail.value });
},
async handleLogin() {
if (this.data.loading) {
return;
}
this.setData({ loading: true, error: "" });
try {
const response = await login({
username: this.data.username,
password: this.data.password,
provinceCode: "330000",
areaCode: "330100",
tenantId: this.data.tenantId,
clientType: "MINI"
});
setTokens(response.data.accessToken, response.data.refreshToken);
wx.reLaunch({ url: "/pages/home/index" });
} catch (error) {
this.setData({
error: error && error.message ? error.message : "登录失败,请稍后重试"
});
} finally {
this.setData({ loading: false });
}
}
});

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "学生登录"
}

View File

@@ -0,0 +1,12 @@
<view class="page">
<view class="card login-card">
<view class="login-title">K12Study 学生端登录</view>
<input class="login-input" placeholder="用户名" value="{{username}}" bindinput="onUsernameInput" />
<input class="login-input" password placeholder="密码" value="{{password}}" bindinput="onPasswordInput" />
<input class="login-input" placeholder="租户ID" value="{{tenantId}}" bindinput="onTenantInput" />
<view wx:if="{{error}}" class="login-error">{{error}}</view>
<button class="login-btn" loading="{{loading}}" disabled="{{loading}}" bindtap="handleLogin">
{{loading ? '登录中...' : '登录'}}
</button>
</view>
</view>

View File

@@ -0,0 +1,27 @@
.login-card {
display: grid;
gap: 16rpx;
}
.login-title {
font-size: 32rpx;
font-weight: 600;
margin-bottom: 12rpx;
}
.login-input {
border: 1px solid #dbe3f0;
border-radius: 12rpx;
height: 72rpx;
padding: 0 20rpx;
background: #fff;
}
.login-btn {
margin-top: 6rpx;
}
.login-error {
color: #dc2626;
font-size: 24rpx;
}

View File

@@ -1,6 +1,13 @@
const { getAccessToken } = require("/utils/session");
Page({ Page({
data: { data: {
title: "我的", title: "我的",
description: "这里预留账号中心、学校切换、消息入口等能力。" description: "这里预留账号中心、学校切换、消息入口等能力。"
},
onShow() {
if (!getAccessToken()) {
wx.reLaunch({ url: "/pages/login/index" });
}
} }
}); });

View File

@@ -1,4 +1,7 @@
const BASE_URL = "http://localhost:8088"; const BASE_URL = "http://localhost:8088";
const { clearTokens, getAccessToken, getRefreshToken, setTokens } = require("./session");
let refreshPromise = null;
function isApiResponse(payload) { function isApiResponse(payload) {
return ( return (
@@ -12,16 +15,29 @@ function isApiResponse(payload) {
function request(options) { function request(options) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const accessToken = getAccessToken();
wx.request({ wx.request({
url: `${BASE_URL}${options.url}`, url: `${BASE_URL}${options.url}`,
method: options.method || "GET", method: options.method || "GET",
data: options.data, data: options.data,
header: { header: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...(options.header || {}) ...(options.header || {})
}, },
success: (response) => { success: async (response) => {
const payload = response.data; const payload = response.data;
if (response.statusCode === 401 && !options.skipAuthRefresh && !isAuthTokenPath(options.url)) {
const nextAccessToken = await tryRefreshAccessToken();
if (nextAccessToken) {
request({ ...options, skipAuthRefresh: true }).then(resolve).catch(reject);
return;
}
clearTokens();
redirectToLogin();
reject(new Error("登录已过期,请重新登录"));
return;
}
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
const message = const message =
payload && typeof payload === "object" && payload.message payload && typeof payload === "object" && payload.message
@@ -47,6 +63,61 @@ function request(options) {
}); });
} }
function isAuthTokenPath(url) {
return url === "/api/auth/tokens" || url === "/api/auth/tokens/refresh";
}
function redirectToLogin() {
const pages = getCurrentPages();
const currentRoute = pages.length > 0 ? pages[pages.length - 1].route : "";
if (currentRoute !== "pages/login/index") {
wx.reLaunch({ url: "/pages/login/index" });
}
}
function tryRefreshAccessToken() {
if (refreshPromise) {
return refreshPromise;
}
const refreshToken = getRefreshToken();
if (!refreshToken) {
return Promise.resolve(null);
}
refreshPromise = new Promise((resolve) => {
wx.request({
url: `${BASE_URL}/api/auth/tokens/refresh`,
method: "POST",
data: { refreshToken },
header: { "Content-Type": "application/json" },
success: (response) => {
const payload = response.data;
if (
response.statusCode >= 200 &&
response.statusCode < 300 &&
isApiResponse(payload) &&
payload.code === 0 &&
payload.data &&
payload.data.accessToken &&
payload.data.refreshToken
) {
setTokens(payload.data.accessToken, payload.data.refreshToken);
resolve(payload.data.accessToken);
return;
}
resolve(null);
},
fail: () => resolve(null),
complete: () => {
refreshPromise = null;
}
});
});
return refreshPromise;
}
module.exports = { module.exports = {
request request
}; };

27
app/src/utils/session.js Normal file
View File

@@ -0,0 +1,27 @@
const ACCESS_TOKEN_KEY = "k12study.access-token";
const REFRESH_TOKEN_KEY = "k12study.refresh-token";
function getAccessToken() {
return wx.getStorageSync(ACCESS_TOKEN_KEY);
}
function getRefreshToken() {
return wx.getStorageSync(REFRESH_TOKEN_KEY);
}
function setTokens(accessToken, refreshToken) {
wx.setStorageSync(ACCESS_TOKEN_KEY, accessToken);
wx.setStorageSync(REFRESH_TOKEN_KEY, refreshToken);
}
function clearTokens() {
wx.removeStorageSync(ACCESS_TOKEN_KEY);
wx.removeStorageSync(REFRESH_TOKEN_KEY);
}
module.exports = {
getAccessToken,
getRefreshToken,
setTokens,
clearTokens
};

View File

@@ -16,6 +16,21 @@
<artifactId>common-api</artifactId> <artifactId>common-api</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<optional>true</optional>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>

View File

@@ -9,7 +9,10 @@ public record CurrentUserResponse(
String provinceCode, String provinceCode,
String areaCode, String areaCode,
String tenantId, String tenantId,
String tenantPath,
String deptId, String deptId,
List<String> roles String deptPath,
List<String> roles,
String clientType
) { ) {
} }

View File

@@ -3,8 +3,11 @@ package com.k12study.api.auth.dto;
public record LoginRequest( public record LoginRequest(
String username, String username,
String password, String password,
String mobile,
String smsCode,
String provinceCode, String provinceCode,
String areaCode, String areaCode,
String tenantId String tenantId,
String clientType
) { ) {
} }

View File

@@ -0,0 +1,18 @@
package com.k12study.api.auth.remote;
/**
* @description Auth 模块 HTTP 路径常量,供 Controller/Feign/网关白名单共用,避免字面量漂移
* @filename AuthApiPaths.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
public final class AuthApiPaths {
private AuthApiPaths() {
}
public static final String BASE = "/auth";
public static final String LOGIN = BASE + "/tokens";
public static final String REFRESH = BASE + "/tokens/refresh";
public static final String USERS_CURRENT = BASE + "/users/current";
}

View File

@@ -0,0 +1,43 @@
package com.k12study.api.auth.remote;
import com.k12study.api.auth.dto.CurrentUserResponse;
import com.k12study.api.auth.dto.LoginRequest;
import com.k12study.api.auth.dto.RefreshTokenRequest;
import com.k12study.api.auth.dto.TokenResponse;
import com.k12study.api.auth.remote.factory.RemoteAuthServiceFallbackFactory;
import com.k12study.common.api.response.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
/**
* @description Auth 远程服务契约;微服务模式按 Nacos 服务名寻址,单体/本地通过 k12study.remote.auth.url 直连兜底
* @filename AuthRemoteApi.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@FeignClient(
contextId = "remoteAuthService",
value = "${k12study.remote.auth.service-name:k12study-auth}",
url = "${k12study.remote.auth.url:}",
path = AuthApiPaths.BASE,
fallbackFactory = RemoteAuthServiceFallbackFactory.class)
public interface AuthRemoteApi {
/** 账号密码 / 手机号+验证码登录,成功返回 access+refresh 双令牌 */
@PostMapping("/tokens")
ApiResponse<TokenResponse> login(@RequestBody LoginRequest request);
/** 一次一换:撤销旧 refresh、签发新 access+refresh;失败返回 401 */
@PostMapping("/tokens/refresh")
ApiResponse<TokenResponse> refresh(@RequestBody RefreshTokenRequest request);
/** 解析 Authorization 中的 access token 返回当前用户画像(含 roleCodes/clientType) */
@GetMapping("/users/current")
ApiResponse<CurrentUserResponse> currentUser(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
}

View File

@@ -0,0 +1,49 @@
package com.k12study.api.auth.remote.factory;
import com.k12study.api.auth.dto.CurrentUserResponse;
import com.k12study.api.auth.dto.LoginRequest;
import com.k12study.api.auth.dto.RefreshTokenRequest;
import com.k12study.api.auth.dto.TokenResponse;
import com.k12study.api.auth.remote.AuthRemoteApi;
import com.k12study.common.api.response.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
/**
* @description Auth Feign 熔断降级工厂;下游不可达时返回 503 + message 说明,前端按统一响应体处理
* @filename RemoteAuthServiceFallbackFactory.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@Component
public class RemoteAuthServiceFallbackFactory implements FallbackFactory<AuthRemoteApi> {
private static final Logger log = LoggerFactory.getLogger(RemoteAuthServiceFallbackFactory.class);
private static final int FALLBACK_CODE = 503;
@Override
public AuthRemoteApi create(Throwable cause) {
String reason = cause == null ? "unknown" : cause.getClass().getSimpleName() + ":" + cause.getMessage();
log.warn("[auth-fallback] remote auth service degraded, cause={}", reason);
String message = "auth 服务暂不可用,已触发降级:" + reason;
return new AuthRemoteApi() {
@Override
public ApiResponse<TokenResponse> login(LoginRequest request) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<TokenResponse> refresh(RefreshTokenRequest request) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<CurrentUserResponse> currentUser(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
};
}
}

View File

@@ -16,6 +16,21 @@
<artifactId>common-api</artifactId> <artifactId>common-api</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<optional>true</optional>
</dependency>
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>

View File

@@ -0,0 +1,19 @@
package com.k12study.api.upms.dto;
import java.time.Instant;
public record FileMetadataDto(
String fileId,
String mediaType,
String objectKey,
String fileName,
String mimeType,
Long fileSize,
String fileHash,
Integer durationMs,
String uploadedBy,
String tenantId,
String tenantPath,
Instant createdAt
) {
}

View File

@@ -0,0 +1,12 @@
package com.k12study.api.upms.dto;
public record FileUploadRequestDto(
String mediaType,
String objectKey,
String fileName,
String mimeType,
Long fileSize,
String fileHash,
Integer durationMs
) {
}

View File

@@ -0,0 +1,16 @@
package com.k12study.api.upms.dto;
import java.time.Instant;
public record InboxMessageDto(
String messageId,
String messageType,
String bizType,
String title,
String content,
String webJumpUrl,
String readStatus,
Instant readAt,
Instant sendAt
) {
}

View File

@@ -0,0 +1,10 @@
package com.k12study.api.upms.dto;
import java.time.Instant;
public record MessageReadResultDto(
String messageId,
String readStatus,
Instant readAt
) {
}

View File

@@ -0,0 +1,8 @@
package com.k12study.api.upms.dto;
public record SchoolClassCourseDto(
String classId,
String courseId,
String relationStatus
) {
}

View File

@@ -0,0 +1,12 @@
package com.k12study.api.upms.dto;
public record SchoolClassDto(
String classId,
String classCode,
String className,
String gradeCode,
String status,
String tenantId,
String deptId
) {
}

View File

@@ -0,0 +1,15 @@
package com.k12study.api.upms.dto;
import java.time.Instant;
public record SchoolClassMemberDto(
String classId,
String userId,
String username,
String displayName,
String memberRole,
String memberStatus,
Instant joinedAt,
Instant leftAt
) {
}

View File

@@ -10,4 +10,11 @@ public final class UpmsApiPaths {
public static final String AREAS = BASE + "/areas"; public static final String AREAS = BASE + "/areas";
public static final String TENANTS = BASE + "/tenants"; public static final String TENANTS = BASE + "/tenants";
public static final String DEPARTMENTS = BASE + "/departments"; public static final String DEPARTMENTS = BASE + "/departments";
public static final String CLASSES = BASE + "/classes";
public static final String CLASS_MEMBERS = BASE + "/classes/{classId}/members";
public static final String CLASS_COURSES = BASE + "/classes/{classId}/courses";
public static final String FILE_UPLOAD = BASE + "/files/upload";
public static final String FILE_BY_ID = BASE + "/files/{fileId}";
public static final String MESSAGE_INBOX = BASE + "/messages/inbox";
public static final String MESSAGE_READ = BASE + "/messages/{messageId}/read";
} }

View File

@@ -3,19 +3,91 @@ package com.k12study.api.upms.remote;
import com.k12study.api.upms.dto.AreaNodeDto; import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto; import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto; import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteNodeDto; import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto; import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.api.upms.remote.factory.RemoteUpmsServiceFallbackFactory;
import com.k12study.common.api.response.ApiResponse; import com.k12study.common.api.response.ApiResponse;
import java.util.List; import java.util.List;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
/**
* @description UPMS 远程服务契约;Controller 方法与此接口路径必须一一对应,启动时由 FeignContractValidator 校验
* @filename UpmsRemoteApi.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@FeignClient(
contextId = "remoteUpmsService",
value = "${k12study.remote.upms.service-name:k12study-upms}",
url = "${k12study.remote.upms.url:}",
path = UpmsApiPaths.BASE,
fallbackFactory = RemoteUpmsServiceFallbackFactory.class)
public interface UpmsRemoteApi { public interface UpmsRemoteApi {
ApiResponse<List<RouteNodeDto>> routes();
ApiResponse<CurrentRouteUserDto> currentUser(); @GetMapping("/routes")
ApiResponse<List<RouteNodeDto>> routes(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
ApiResponse<List<AreaNodeDto>> areas(); @GetMapping("/users/current")
ApiResponse<CurrentRouteUserDto> currentUser(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
ApiResponse<List<TenantNodeDto>> tenants(); @GetMapping("/areas")
ApiResponse<List<AreaNodeDto>> areas(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
ApiResponse<List<DeptNodeDto>> departments(); @GetMapping("/tenants")
ApiResponse<List<TenantNodeDto>> tenants(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
@GetMapping("/departments")
ApiResponse<List<DeptNodeDto>> departments(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
@GetMapping("/classes")
ApiResponse<List<SchoolClassDto>> classes(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
@GetMapping("/classes/{classId}/members")
ApiResponse<List<SchoolClassMemberDto>> classMembers(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@PathVariable("classId") String classId);
@GetMapping("/classes/{classId}/courses")
ApiResponse<List<SchoolClassCourseDto>> classCourses(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@PathVariable("classId") String classId);
@PostMapping("/files/upload")
ApiResponse<FileMetadataDto> uploadFile(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@RequestBody FileUploadRequestDto request);
@GetMapping("/files/{fileId}")
ApiResponse<FileMetadataDto> fileById(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@PathVariable("fileId") String fileId);
@GetMapping("/messages/inbox")
ApiResponse<List<InboxMessageDto>> inboxMessages(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
@PostMapping("/messages/{messageId}/read")
ApiResponse<MessageReadResultDto> readMessage(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@PathVariable("messageId") String messageId);
} }

View File

@@ -0,0 +1,103 @@
package com.k12study.api.upms.remote.factory;
import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.api.upms.remote.UpmsRemoteApi;
import com.k12study.common.api.response.ApiResponse;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
/**
* @description UPMS Feign 熔断降级工厂;下游不可达时返回 503 + message 说明,避免阻塞调用方,前端按统一响应体处理
* @filename RemoteUpmsServiceFallbackFactory.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@Component
public class RemoteUpmsServiceFallbackFactory implements FallbackFactory<UpmsRemoteApi> {
private static final Logger log = LoggerFactory.getLogger(RemoteUpmsServiceFallbackFactory.class);
private static final int FALLBACK_CODE = 503;
@Override
public UpmsRemoteApi create(Throwable cause) {
String reason = cause == null ? "unknown" : cause.getClass().getSimpleName() + ":" + cause.getMessage();
log.warn("[upms-fallback] remote upms service degraded, cause={}", reason);
String message = "upms 服务暂不可用,已触发降级:" + reason;
return new UpmsRemoteApi() {
@Override
public ApiResponse<List<RouteNodeDto>> routes(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<CurrentRouteUserDto> currentUser(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<AreaNodeDto>> areas(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<TenantNodeDto>> tenants(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<DeptNodeDto>> departments(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<SchoolClassDto>> classes(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<SchoolClassMemberDto>> classMembers(String authorization, String classId) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<SchoolClassCourseDto>> classCourses(String authorization, String classId) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<FileMetadataDto> uploadFile(String authorization, FileUploadRequestDto request) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<FileMetadataDto> fileById(String authorization, String fileId) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<InboxMessageDto>> inboxMessages(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<MessageReadResultDto> readMessage(String authorization, String messageId) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
};
}
}

View File

@@ -30,6 +30,18 @@
<artifactId>api-auth</artifactId> <artifactId>api-auth</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>

View File

@@ -14,6 +14,13 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/**
* @description 认证 HTTP 入口;统一登录、刷新、当前用户查询,响应体由 AuthService 组装后通过 ApiResponse 包装
* @filename AuthController.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@RestController @RestController
@RequestMapping("/auth") @RequestMapping("/auth")
public class AuthController { public class AuthController {
@@ -39,7 +46,7 @@ public class AuthController {
@GetMapping("/users/current") @GetMapping("/users/current")
public ApiResponse<CurrentUserResponse> currentUser( public ApiResponse<CurrentUserResponse> currentUser(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) { @RequestHeader(value = "Authorization", required = false) String authorization) {
return ApiResponse.success(authService.currentUser(authorizationHeader)); return ApiResponse.success(authService.currentUser(authorization));
} }
} }

View File

@@ -3,64 +3,423 @@ package com.k12study.auth.service;
import com.k12study.api.auth.dto.CurrentUserResponse; import com.k12study.api.auth.dto.CurrentUserResponse;
import com.k12study.api.auth.dto.LoginRequest; import com.k12study.api.auth.dto.LoginRequest;
import com.k12study.api.auth.dto.TokenResponse; import com.k12study.api.auth.dto.TokenResponse;
import com.k12study.common.security.config.AuthProperties;
import com.k12study.common.security.context.RequestUserContextHolder; import com.k12study.common.security.context.RequestUserContextHolder;
import com.k12study.common.security.jwt.JwtTokenProvider; import com.k12study.common.security.jwt.JwtTokenProvider;
import com.k12study.common.security.jwt.JwtUserPrincipal; import com.k12study.common.security.jwt.JwtUserPrincipal;
import com.k12study.common.web.exception.BizException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service @Service
public class AuthService { public class AuthService {
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final NamedParameterJdbcTemplate jdbcTemplate;
private final AuthProperties authProperties;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public AuthService(JwtTokenProvider jwtTokenProvider) { private static final RowMapper<UserRecord> USER_ROW_MAPPER = (rs, rowNum) -> mapUser(rs);
public AuthService(
JwtTokenProvider jwtTokenProvider,
NamedParameterJdbcTemplate jdbcTemplate,
AuthProperties authProperties) {
this.jwtTokenProvider = jwtTokenProvider; this.jwtTokenProvider = jwtTokenProvider;
this.jdbcTemplate = jdbcTemplate;
this.authProperties = authProperties;
} }
public TokenResponse login(LoginRequest request) { public TokenResponse login(LoginRequest request) {
String username = request.username() == null || request.username().isBlank() ? "admin" : request.username(); String clientType = normalizeClientType(request == null ? null : request.clientType());
JwtUserPrincipal principal = new JwtUserPrincipal( String tenantId = request == null || request.tenantId() == null ? "" : request.tenantId().trim();
"U10001", UserRecord user = resolveLoginUser(request, tenantId);
username, verifyCredential(request, user);
"K12Study 管理员",
request.tenantId() == null || request.tenantId().isBlank() ? "SCH-HQ" : request.tenantId(), List<String> roleCodes = findRoleCodes(user.userId());
"DEPT-HQ-ADMIN" ensureRoleAssigned(roleCodes);
); validateClientRole(clientType, roleCodes);
String sessionId = UUID.randomUUID().toString().replace("-", "");
JwtUserPrincipal principal = toPrincipal(user, roleCodes, clientType, sessionId);
String accessToken = jwtTokenProvider.createAccessToken(principal); String accessToken = jwtTokenProvider.createAccessToken(principal);
String refreshToken = jwtTokenProvider.createAccessToken(principal); String refreshToken = jwtTokenProvider.createRefreshToken(principal);
return new TokenResponse(accessToken, refreshToken, "Bearer", 12 * 60 * 60); saveRefreshToken(principal, refreshToken);
if ("MINI".equals(clientType)) {
enforceMiniSessionLimit(user.userId());
}
auditLogin(user, clientType, "SUCCESS", null);
return new TokenResponse(accessToken, refreshToken, "Bearer", authProperties.getAccessTokenTtl().toSeconds());
} }
public TokenResponse refresh(String refreshToken) { public TokenResponse refresh(String refreshToken) {
JwtUserPrincipal principal = jwtTokenProvider.parse(refreshToken); TokenRecord tokenRecord = findTokenRecord(refreshToken)
.orElseThrow(() -> new BizException(401, "refreshToken 无效或已失效"));
if (tokenRecord.revoked() || tokenRecord.expireAt().isBefore(Instant.now())) {
throw new BizException(401, "refreshToken 已失效");
}
JwtUserPrincipal tokenPrincipal;
try {
tokenPrincipal = jwtTokenProvider.parse(refreshToken);
} catch (Exception exception) {
throw new BizException(401, "refreshToken 已失效");
}
if (!tokenRecord.userId().equals(tokenPrincipal.userId())
|| !tokenRecord.sessionId().equals(tokenPrincipal.sessionId())) {
throw new BizException(401, "refreshToken 校验失败");
}
UserRecord user = findUserById(tokenRecord.userId())
.orElseThrow(() -> new BizException(401, "用户不存在或已禁用"));
List<String> roleCodes = findRoleCodes(user.userId());
ensureRoleAssigned(roleCodes);
validateClientRole(tokenRecord.clientType(), roleCodes);
JwtUserPrincipal principal = toPrincipal(user, roleCodes, tokenRecord.clientType(), tokenRecord.sessionId());
String accessToken = jwtTokenProvider.createAccessToken(principal); String accessToken = jwtTokenProvider.createAccessToken(principal);
return new TokenResponse(accessToken, refreshToken, "Bearer", 12 * 60 * 60); String nextRefreshToken = jwtTokenProvider.createRefreshToken(principal);
revokeToken(tokenRecord.tokenId());
saveRefreshToken(principal, nextRefreshToken);
auditLogin(user, tokenRecord.clientType(), "REFRESH_SUCCESS", null);
return new TokenResponse(accessToken, nextRefreshToken, "Bearer", authProperties.getAccessTokenTtl().toSeconds());
} }
public CurrentUserResponse currentUser(String authorizationHeader) { public CurrentUserResponse currentUser(String authorizationHeader) {
JwtUserPrincipal principal;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
JwtUserPrincipal principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length())); principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length()));
return new CurrentUserResponse( } else {
principal.userId(),
principal.username(),
principal.displayName(),
"330000",
"330100",
principal.tenantId(),
principal.deptId(),
List.of("SUPER_ADMIN", "ORG_ADMIN")
);
}
var context = RequestUserContextHolder.get(); var context = RequestUserContextHolder.get();
return new CurrentUserResponse( if (context == null || !StringUtils.hasText(context.userId())) {
context == null ? "U10001" : context.userId(), throw new BizException(401, "未登录或登录已失效");
context == null ? "admin" : context.username(), }
context == null ? "K12Study 管理员" : context.displayName(), principal = new JwtUserPrincipal(
"330000", context.userId(),
"330100", context.username(),
context == null ? "SCH-HQ" : context.tenantId(), context.displayName(),
context == null ? "DEPT-HQ-ADMIN" : context.deptId(), context.adcode(),
List.of("SUPER_ADMIN", "ORG_ADMIN") context.tenantId(),
context.tenantPath(),
context.deptId(),
context.deptPath(),
context.roleCodes(),
normalizeClientType(context.clientType()),
context.sessionId()
); );
} }
UserRecord user = findUserById(principal.userId())
.orElseThrow(() -> new BizException(401, "用户不存在或已禁用"));
List<String> roleCodes = findRoleCodes(user.userId());
String areaCode = safeAdcode(user.adcode());
String provinceCode = areaCode.length() >= 2 ? areaCode.substring(0, 2) + "0000" : areaCode;
return new CurrentUserResponse(
user.userId(),
user.username(),
user.displayName(),
provinceCode,
areaCode,
user.tenantId(),
user.tenantPath(),
user.deptId(),
user.deptPath(),
roleCodes,
principal.clientType()
);
}
private UserRecord resolveLoginUser(LoginRequest request, String tenantId) {
if (request == null) {
throw new BizException(400, "登录参数不能为空");
}
if (StringUtils.hasText(request.mobile())) {
return findUserByMobile(request.mobile().trim(), tenantId)
.orElseThrow(() -> {
auditLogin(null, normalizeClientType(request.clientType()), "FAILED", "MOBILE_NOT_FOUND");
return new BizException(401, "手机号或密码错误");
});
}
if (!StringUtils.hasText(request.username())) {
throw new BizException(400, "用户名不能为空");
}
return findUserByUsername(request.username().trim(), tenantId)
.orElseThrow(() -> {
auditLogin(null, normalizeClientType(request.clientType()), "FAILED", "USER_NOT_FOUND");
return new BizException(401, "用户名或密码错误");
});
}
private void verifyCredential(LoginRequest request, UserRecord user) {
boolean passwordPassed = StringUtils.hasText(request.password()) && passwordMatches(request.password(), user.passwordHash());
boolean smsPassed = StringUtils.hasText(request.mobile()) && StringUtils.hasText(request.smsCode()) && "123456".equals(request.smsCode());
if (!passwordPassed && !smsPassed) {
auditLogin(user, normalizeClientType(request.clientType()), "FAILED", "BAD_CREDENTIAL");
throw new BizException(401, "用户名/手机号或凭据错误");
}
if (!"ACTIVE".equalsIgnoreCase(user.status())) {
auditLogin(user, normalizeClientType(request.clientType()), "FAILED", "USER_DISABLED");
throw new BizException(403, "用户状态不可用");
}
}
private void validateClientRole(String clientType, List<String> roleCodes) {
if ("MINI".equals(clientType) && roleCodes.stream().noneMatch("STUDENT"::equalsIgnoreCase)) {
throw new BizException(403, "小程序端仅允许学生账号登录");
}
}
private void ensureRoleAssigned(List<String> roleCodes) {
if (roleCodes == null || roleCodes.isEmpty()) {
throw new BizException(403, "用户未分配角色");
}
}
private boolean passwordMatches(String rawPassword, String storedPassword) {
if (!StringUtils.hasText(storedPassword)) {
return false;
}
if (storedPassword.startsWith("$2a$") || storedPassword.startsWith("$2b$") || storedPassword.startsWith("$2y$")) {
return passwordEncoder.matches(rawPassword, storedPassword);
}
return storedPassword.equals(rawPassword);
}
private JwtUserPrincipal toPrincipal(UserRecord user, List<String> roleCodes, String clientType, String sessionId) {
return new JwtUserPrincipal(
user.userId(),
user.username(),
user.displayName(),
user.adcode(),
user.tenantId(),
user.tenantPath(),
user.deptId(),
user.deptPath(),
roleCodes,
clientType,
sessionId
);
}
private Optional<UserRecord> findUserByUsername(String username, String tenantId) {
String sql = """
SELECT user_id, username, display_name, password_hash, mobile_phone, adcode, tenant_id, tenant_path, dept_id, dept_path, status
FROM upms.tb_sys_user
WHERE username = :username
AND (:tenantId = '' OR tenant_id = :tenantId)
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("username", username, "tenantId", tenantId), USER_ROW_MAPPER).stream().findFirst();
}
private Optional<UserRecord> findUserByMobile(String mobile, String tenantId) {
String sql = """
SELECT user_id, username, display_name, password_hash, mobile_phone, adcode, tenant_id, tenant_path, dept_id, dept_path, status
FROM upms.tb_sys_user
WHERE mobile_phone = :mobile
AND (:tenantId = '' OR tenant_id = :tenantId)
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("mobile", mobile, "tenantId", tenantId), USER_ROW_MAPPER).stream().findFirst();
}
private Optional<UserRecord> findUserById(String userId) {
String sql = """
SELECT user_id, username, display_name, password_hash, mobile_phone, adcode, tenant_id, tenant_path, dept_id, dept_path, status
FROM upms.tb_sys_user
WHERE user_id = :userId
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("userId", userId), USER_ROW_MAPPER).stream().findFirst();
}
private List<String> findRoleCodes(String userId) {
String sql = """
SELECT r.role_code
FROM upms.tb_sys_user_role ur
JOIN upms.tb_sys_role r ON r.role_id = ur.role_id
WHERE ur.user_id = :userId
ORDER BY r.role_code
""";
return jdbcTemplate.queryForList(sql, Map.of("userId", userId), String.class);
}
private Optional<TokenRecord> findTokenRecord(String refreshToken) {
String sql = """
SELECT token_id, session_id, client_type, user_id, refresh_token, expire_at, revoked
FROM auth.tb_auth_refresh_token
WHERE refresh_token = :refreshToken
LIMIT 1
""";
RowMapper<TokenRecord> mapper = (rs, rowNum) -> new TokenRecord(
rs.getString("token_id"),
rs.getString("session_id"),
normalizeClientType(rs.getString("client_type")),
rs.getString("user_id"),
rs.getString("refresh_token"),
rs.getTimestamp("expire_at").toInstant(),
rs.getBoolean("revoked")
);
return jdbcTemplate.query(sql, Map.of("refreshToken", refreshToken), mapper).stream().findFirst();
}
private void saveRefreshToken(JwtUserPrincipal principal, String refreshToken) {
String sql = """
INSERT INTO auth.tb_auth_refresh_token (
token_id, session_id, client_type, user_id, username, adcode,
tenant_id, tenant_path, dept_id, dept_path,
refresh_token, expire_at, revoked, revoked_at, last_active_at, created_at
) VALUES (
:tokenId, :sessionId, :clientType, :userId, :username, :adcode,
:tenantId, :tenantPath, :deptId, :deptPath,
:refreshToken, :expireAt, false, null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("tokenId", UUID.randomUUID().toString().replace("-", ""))
.addValue("sessionId", principal.sessionId())
.addValue("clientType", normalizeClientType(principal.clientType()))
.addValue("userId", principal.userId())
.addValue("username", principal.username())
.addValue("adcode", principal.adcode())
.addValue("tenantId", principal.tenantId())
.addValue("tenantPath", principal.tenantPath())
.addValue("deptId", principal.deptId())
.addValue("deptPath", principal.deptPath())
.addValue("refreshToken", refreshToken)
.addValue("expireAt", Instant.now().plus(authProperties.getRefreshTokenTtl()));
jdbcTemplate.update(sql, parameters);
}
private void revokeToken(String tokenId) {
String sql = """
UPDATE auth.tb_auth_refresh_token
SET revoked = true, revoked_at = CURRENT_TIMESTAMP
WHERE token_id = :tokenId
""";
jdbcTemplate.update(sql, Map.of("tokenId", tokenId));
}
private void enforceMiniSessionLimit(String userId) {
String sessionSql = """
SELECT session_id
FROM auth.tb_auth_refresh_token
WHERE user_id = :userId
AND client_type = 'MINI'
AND revoked = false
AND expire_at > CURRENT_TIMESTAMP
GROUP BY session_id
ORDER BY MAX(last_active_at) DESC
""";
List<String> sessions = jdbcTemplate.queryForList(sessionSql, Map.of("userId", userId), String.class);
if (sessions.size() <= 3) {
return;
}
List<String> needRevoke = sessions.subList(3, sessions.size());
String revokeSql = """
UPDATE auth.tb_auth_refresh_token
SET revoked = true, revoked_at = CURRENT_TIMESTAMP
WHERE user_id = :userId
AND session_id IN (:sessionIds)
AND revoked = false
""";
jdbcTemplate.update(revokeSql, new MapSqlParameterSource()
.addValue("userId", userId)
.addValue("sessionIds", needRevoke));
}
private void auditLogin(UserRecord user, String clientType, String loginStatus, String failureReason) {
String sql = """
INSERT INTO auth.tb_auth_login_audit (
audit_id, user_id, username, client_type, adcode, tenant_id, tenant_path,
dept_id, dept_path, login_ip, login_status, failure_reason, created_at
) VALUES (
:auditId, :userId, :username, :clientType, :adcode, :tenantId, :tenantPath,
:deptId, :deptPath, :loginIp, :loginStatus, :failureReason, CURRENT_TIMESTAMP
)
""";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("auditId", UUID.randomUUID().toString().replace("-", ""))
.addValue("userId", user == null ? null : user.userId())
.addValue("username", user == null ? "UNKNOWN" : user.username())
.addValue("clientType", normalizeClientType(clientType))
.addValue("adcode", user == null ? null : user.adcode())
.addValue("tenantId", user == null ? null : user.tenantId())
.addValue("tenantPath", user == null ? null : user.tenantPath())
.addValue("deptId", user == null ? null : user.deptId())
.addValue("deptPath", user == null ? null : user.deptPath())
.addValue("loginIp", null)
.addValue("loginStatus", loginStatus)
.addValue("failureReason", failureReason);
jdbcTemplate.update(sql, parameters);
}
private String normalizeClientType(String clientType) {
if (!StringUtils.hasText(clientType)) {
return "WEB";
}
String normalized = clientType.trim().toUpperCase();
return "MINI".equals(normalized) ? "MINI" : "WEB";
}
private static UserRecord mapUser(ResultSet rs) throws SQLException {
return new UserRecord(
rs.getString("user_id"),
rs.getString("username"),
rs.getString("display_name"),
rs.getString("password_hash"),
rs.getString("mobile_phone"),
rs.getString("adcode"),
rs.getString("tenant_id"),
rs.getString("tenant_path"),
rs.getString("dept_id"),
rs.getString("dept_path"),
rs.getString("status")
);
}
private String safeAdcode(String adcode) {
return StringUtils.hasText(adcode) ? adcode : "";
}
private record UserRecord(
String userId,
String username,
String displayName,
String passwordHash,
String mobilePhone,
String adcode,
String tenantId,
String tenantPath,
String deptId,
String deptPath,
String status
) {
}
private record TokenRecord(
String tokenId,
String sessionId,
String clientType,
String userId,
String refreshToken,
Instant expireAt,
boolean revoked
) {
}
} }

View File

@@ -4,6 +4,10 @@ server:
spring: spring:
application: application:
name: k12study-auth name: k12study-auth
datasource:
url: jdbc:postgresql://${K12STUDY_DB_HOST:localhost}:${K12STUDY_DB_PORT:5432}/${K12STUDY_DB_NAME:k12study}
username: ${K12STUDY_DB_USER:k12study}
password: ${K12STUDY_DB_PASSWORD:k12study}
data: data:
redis: redis:
host: ${K12STUDY_REDIS_HOST:localhost} host: ${K12STUDY_REDIS_HOST:localhost}

View File

@@ -6,11 +6,10 @@ server:
spring: spring:
application: application:
name: k12study-boot-dev name: k12study-boot-dev
autoconfigure: datasource:
exclude: url: jdbc:postgresql://${K12STUDY_DB_HOST:localhost}:${K12STUDY_DB_PORT:5432}/${K12STUDY_DB_NAME:k12study}
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration username: ${K12STUDY_DB_USER:k12study}
- org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration password: ${K12STUDY_DB_PASSWORD:k12study}
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
data: data:
redis: redis:
host: ${K12STUDY_REDIS_HOST:localhost} host: ${K12STUDY_REDIS_HOST:localhost}

View File

@@ -5,8 +5,14 @@ public final class SecurityConstants {
public static final String HEADER_USER_ID = "X-User-Id"; public static final String HEADER_USER_ID = "X-User-Id";
public static final String HEADER_USERNAME = "X-Username"; public static final String HEADER_USERNAME = "X-Username";
public static final String HEADER_DISPLAY_NAME = "X-Display-Name"; public static final String HEADER_DISPLAY_NAME = "X-Display-Name";
public static final String HEADER_ADCODE = "X-Adcode";
public static final String HEADER_TENANT_ID = "X-Tenant-Id"; public static final String HEADER_TENANT_ID = "X-Tenant-Id";
public static final String HEADER_TENANT_PATH = "X-Tenant-Path";
public static final String HEADER_DEPT_ID = "X-Dept-Id"; public static final String HEADER_DEPT_ID = "X-Dept-Id";
public static final String HEADER_DEPT_PATH = "X-Dept-Path";
public static final String HEADER_ROLE_CODES = "X-Role-Codes";
public static final String HEADER_CLIENT_TYPE = "X-Client-Type";
public static final String HEADER_SESSION_ID = "X-Session-Id";
private SecurityConstants() { private SecurityConstants() {
} }

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.k12study</groupId>
<artifactId>k12study-backend</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>common-feign</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,76 @@
package com.k12study.common.feign.config;
import com.k12study.common.feign.contract.FeignContractValidator;
import com.k12study.common.feign.interceptor.FeignAuthRelayInterceptor;
import feign.Client;
import feign.Logger;
import feign.RequestInterceptor;
import feign.okhttp.OkHttpClient;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
/**
* @description Feign 自动装配入口;引入本模块即启用 @EnableFeignClients、OkHttp 客户端、Authorization 透传拦截器与启动期契约自检
* @filename FeignAutoConfiguration.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@AutoConfiguration
@ConditionalOnClass(name = "org.springframework.cloud.openfeign.FeignClient")
@EnableConfigurationProperties(FeignClientProperties.class)
@EnableFeignClients(basePackages = "com.k12study.api")
public class FeignAutoConfiguration {
/** 默认透传 Authorization 到下游 Feign 调用;业务方自定义时会覆盖此 Bean */
@Bean
@ConditionalOnMissingBean(FeignAuthRelayInterceptor.class)
public RequestInterceptor feignAuthRelayInterceptor() {
return new FeignAuthRelayInterceptor();
}
/** 将配置中的日志级别转为 Feign Logger.Level Bean,Feign 会自动应用到所有客户端 */
@Bean
@ConditionalOnMissingBean(Logger.Level.class)
public Logger.Level feignLoggerLevel(FeignClientProperties properties) {
return properties.getLogLevel();
}
/**
* 使用 OkHttp 作为底层 HTTP 客户端;相比 JDK HttpURLConnection 提供连接池、HTTP/2、细粒度超时,
* 生产环境高并发必备。classpath 无 okhttp3 时不生效(Conditional)。
*/
@Bean
@ConditionalOnMissingBean(Client.class)
@ConditionalOnClass(name = "okhttp3.OkHttpClient")
public Client feignOkHttpClient(FeignClientProperties properties) {
okhttp3.OkHttpClient delegate = new okhttp3.OkHttpClient.Builder()
.connectTimeout(properties.getConnectTimeout().toMillis(), TimeUnit.MILLISECONDS)
.readTimeout(properties.getReadTimeout().toMillis(), TimeUnit.MILLISECONDS)
.writeTimeout(properties.getWriteTimeout().toMillis(), TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(properties.isRetryOnConnectionFailure())
.connectionPool(new okhttp3.ConnectionPool(
properties.getMaxIdleConnections(),
properties.getKeepAliveDuration().toMillis(),
TimeUnit.MILLISECONDS))
.build();
return new OkHttpClient(delegate);
}
/**
* 启动期契约自检:对比 Feign 接口路径与本地 RequestMappingHandlerMapping 注册的 Controller 路径,
* 不一致即告警。仅 Provider 进程(同时持有接口 + Controller)生效,纯 Consumer 自动跳过。
*/
@Bean
@ConditionalOnClass(name = "org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping")
@ConditionalOnProperty(prefix = "k12study.feign", name = "contract-validate", havingValue = "true", matchIfMissing = true)
public FeignContractValidator feignContractValidator() {
return new FeignContractValidator("com.k12study.api");
}
}

View File

@@ -0,0 +1,58 @@
package com.k12study.common.feign.config;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @description Feign 客户端外部化参数;前缀 k12study.feign,覆盖 OkHttp 超时/连接池/日志级别/契约自检开关
* @filename FeignClientProperties.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@ConfigurationProperties(prefix = "k12study.feign")
public class FeignClientProperties {
/** 连接建立超时 */
private Duration connectTimeout = Duration.ofSeconds(5);
/** 读超时 */
private Duration readTimeout = Duration.ofSeconds(10);
/** 写超时 */
private Duration writeTimeout = Duration.ofSeconds(10);
/** 是否在连接失败时重试 */
private boolean retryOnConnectionFailure = true;
/** 连接池最大空闲连接数 */
private int maxIdleConnections = 64;
/** 连接池 keep-alive 时长 */
private Duration keepAliveDuration = Duration.ofMinutes(5);
/** Feign 日志级别:NONE / BASIC / HEADERS / FULL */
private feign.Logger.Level logLevel = feign.Logger.Level.NONE;
/** 启动时校验 Feign 契约与本地 Controller 一致 */
private boolean contractValidate = true;
public Duration getConnectTimeout() { return connectTimeout; }
public void setConnectTimeout(Duration connectTimeout) { this.connectTimeout = connectTimeout; }
public Duration getReadTimeout() { return readTimeout; }
public void setReadTimeout(Duration readTimeout) { this.readTimeout = readTimeout; }
public Duration getWriteTimeout() { return writeTimeout; }
public void setWriteTimeout(Duration writeTimeout) { this.writeTimeout = writeTimeout; }
public boolean isRetryOnConnectionFailure() { return retryOnConnectionFailure; }
public void setRetryOnConnectionFailure(boolean retryOnConnectionFailure) {
this.retryOnConnectionFailure = retryOnConnectionFailure;
}
public int getMaxIdleConnections() { return maxIdleConnections; }
public void setMaxIdleConnections(int maxIdleConnections) { this.maxIdleConnections = maxIdleConnections; }
public Duration getKeepAliveDuration() { return keepAliveDuration; }
public void setKeepAliveDuration(Duration keepAliveDuration) { this.keepAliveDuration = keepAliveDuration; }
public feign.Logger.Level getLogLevel() { return logLevel; }
public void setLogLevel(feign.Logger.Level logLevel) { this.logLevel = logLevel; }
public boolean isContractValidate() { return contractValidate; }
public void setContractValidate(boolean contractValidate) { this.contractValidate = contractValidate; }
}

View File

@@ -0,0 +1,203 @@
package com.k12study.common.feign.contract;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.classreading.MetadataReaderFactory;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.core.type.filter.TypeFilter;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
/**
* @description 启动期 Feign 契约自检;扫描 @FeignClient 接口路径与本地 RequestMappingHandlerMapping 注册的 Controller 路径,不一致则告警
* @filename FeignContractValidator.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
public class FeignContractValidator
implements ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware {
private static final Logger log = LoggerFactory.getLogger(FeignContractValidator.class);
private final String basePackage;
private ApplicationContext applicationContext;
public FeignContractValidator(String basePackage) {
this.basePackage = basePackage;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
ObjectProvider<RequestMappingHandlerMapping> provider =
applicationContext.getBeanProvider(RequestMappingHandlerMapping.class);
RequestMappingHandlerMapping handlerMapping = provider.getIfAvailable();
if (handlerMapping == null) {
return;
}
Set<Endpoint> mvcEndpoints = collectMvcEndpoints(handlerMapping);
if (mvcEndpoints.isEmpty()) {
return;
}
Set<Class<?>> feignInterfaces = scanFeignInterfaces(basePackage);
if (feignInterfaces.isEmpty()) {
return;
}
int totalChecked = 0;
int mismatchCount = 0;
for (Class<?> feignInterface : feignInterfaces) {
FeignClient feignClient = AnnotationUtils.findAnnotation(feignInterface, FeignClient.class);
String basePath = feignClient == null ? "" : trimSlashes(feignClient.path());
for (Method method : feignInterface.getDeclaredMethods()) {
Endpoint expected = resolveEndpoint(method, basePath);
if (expected == null) {
continue;
}
totalChecked++;
if (!mvcEndpoints.contains(expected)) {
mismatchCount++;
log.warn("[feign-contract] mismatch: {}#{}() expects {} {} but no matching @RequestMapping found",
feignInterface.getSimpleName(), method.getName(),
expected.method(), expected.path());
}
}
}
if (mismatchCount > 0) {
log.warn("[feign-contract] validation finished: {} method(s) checked, {} mismatch(es)",
totalChecked, mismatchCount);
} else {
log.info("[feign-contract] validation passed: {} method(s) aligned with local controllers",
totalChecked);
}
}
private Set<Endpoint> collectMvcEndpoints(RequestMappingHandlerMapping handlerMapping) {
Set<Endpoint> endpoints = new LinkedHashSet<>();
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMapping.getHandlerMethods().entrySet()) {
RequestMappingInfo info = entry.getKey();
Set<RequestMethod> methods = info.getMethodsCondition().getMethods();
Set<String> patterns = info.getPathPatternsCondition() != null
? info.getPathPatternsCondition().getPatternValues()
: Set.of();
if (methods.isEmpty() || patterns.isEmpty()) {
continue;
}
for (RequestMethod m : methods) {
for (String p : patterns) {
endpoints.add(new Endpoint(m.name(), normalize(p)));
}
}
}
return endpoints;
}
private Set<Class<?>> scanFeignInterfaces(String basePackage) {
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false) {
@Override
protected boolean isCandidateComponent(
org.springframework.beans.factory.annotation.AnnotatedBeanDefinition beanDefinition) {
return beanDefinition.getMetadata().isInterface();
}
};
TypeFilter filter = new AnnotationTypeFilter(FeignClient.class) {
@Override
protected boolean matchClassName(String className) {
return false;
}
@Override
public boolean match(MetadataReader metadataReader, MetadataReaderFactory factory) {
return metadataReader.getAnnotationMetadata().hasAnnotation(FeignClient.class.getName());
}
};
scanner.addIncludeFilter(filter);
Set<Class<?>> classes = new LinkedHashSet<>();
scanner.findCandidateComponents(basePackage).forEach(beanDef -> {
try {
classes.add(Class.forName(beanDef.getBeanClassName()));
} catch (ClassNotFoundException ignore) {
// skip
}
});
return classes;
}
private Endpoint resolveEndpoint(Method method, String basePath) {
for (Annotation annotation : method.getAnnotations()) {
HttpDescriptor descriptor = describe(annotation);
if (descriptor != null) {
String path = normalize("/" + basePath + "/" + trimSlashes(descriptor.path()));
return new Endpoint(descriptor.method(), path);
}
}
return null;
}
private HttpDescriptor describe(Annotation annotation) {
if (annotation instanceof GetMapping a) return new HttpDescriptor("GET", firstPath(a.value(), a.path()));
if (annotation instanceof PostMapping a) return new HttpDescriptor("POST", firstPath(a.value(), a.path()));
if (annotation instanceof PutMapping a) return new HttpDescriptor("PUT", firstPath(a.value(), a.path()));
if (annotation instanceof DeleteMapping a) return new HttpDescriptor("DELETE", firstPath(a.value(), a.path()));
if (annotation instanceof PatchMapping a) return new HttpDescriptor("PATCH", firstPath(a.value(), a.path()));
if (annotation instanceof RequestMapping a && a.method().length > 0) {
return new HttpDescriptor(a.method()[0].name(), firstPath(a.value(), a.path()));
}
return null;
}
private String firstPath(String[] values, String[] paths) {
if (values != null && values.length > 0) return values[0];
if (paths != null && paths.length > 0) return paths[0];
return "";
}
private String trimSlashes(String s) {
if (s == null || s.isEmpty()) return "";
String result = s;
while (result.startsWith("/")) result = result.substring(1);
while (result.endsWith("/")) result = result.substring(0, result.length() - 1);
return result;
}
private String normalize(String path) {
String n = path.replaceAll("/+", "/");
if (n.length() > 1 && n.endsWith("/")) {
n = n.substring(0, n.length() - 1);
}
return n;
}
private record Endpoint(String method, String path) {}
private record HttpDescriptor(String method, String path) {}
}

View File

@@ -0,0 +1,37 @@
package com.k12study.common.feign.interceptor;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* @description Feign 请求拦截器:从当前 Servlet 请求提取 Authorization 并透传下游,消费方调用时无需显式传递 token
* @filename FeignAuthRelayInterceptor.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
public class FeignAuthRelayInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 调用方如已显式 setHeader("Authorization"),保留调用方意图
if (template.headers().containsKey(HttpHeaders.AUTHORIZATION)) {
return;
}
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// 非 HTTP 请求上下文(定时任务/MQ 消费)下不做任何注入
if (attributes == null) {
return;
}
HttpServletRequest request = attributes.getRequest();
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization != null && !authorization.isBlank()) {
template.header(HttpHeaders.AUTHORIZATION, authorization);
}
}
}

View File

@@ -0,0 +1 @@
com.k12study.common.feign.config.FeignAutoConfiguration

View File

@@ -1,10 +1,17 @@
package com.k12study.common.security.context; package com.k12study.common.security.context;
import java.util.List;
public record RequestUserContext( public record RequestUserContext(
String userId, String userId,
String username, String username,
String displayName, String displayName,
String adcode,
String tenantId, String tenantId,
String deptId String tenantPath,
String deptId,
String deptPath,
List<String> roleCodes,
String clientType,
String sessionId
) { ) {
} }

View File

@@ -5,8 +5,10 @@ import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Date; import java.util.Date;
import java.util.List;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
public class JwtTokenProvider { public class JwtTokenProvider {
@@ -19,15 +21,29 @@ public class JwtTokenProvider {
} }
public String createAccessToken(JwtUserPrincipal principal) { public String createAccessToken(JwtUserPrincipal principal) {
return createToken(principal, authProperties.getAccessTokenTtl());
}
public String createRefreshToken(JwtUserPrincipal principal) {
return createToken(principal, authProperties.getRefreshTokenTtl());
}
private String createToken(JwtUserPrincipal principal, Duration ttl) {
Instant now = Instant.now(); Instant now = Instant.now();
return Jwts.builder() return Jwts.builder()
.subject(principal.userId()) .subject(principal.userId())
.claim("username", principal.username()) .claim("username", principal.username())
.claim("displayName", principal.displayName()) .claim("displayName", principal.displayName())
.claim("adcode", principal.adcode())
.claim("tenantId", principal.tenantId()) .claim("tenantId", principal.tenantId())
.claim("tenantPath", principal.tenantPath())
.claim("deptId", principal.deptId()) .claim("deptId", principal.deptId())
.claim("deptPath", principal.deptPath())
.claim("roleCodes", principal.roleCodes())
.claim("clientType", principal.clientType())
.claim("sessionId", principal.sessionId())
.issuedAt(Date.from(now)) .issuedAt(Date.from(now))
.expiration(Date.from(now.plus(authProperties.getAccessTokenTtl()))) .expiration(Date.from(now.plus(ttl)))
.signWith(secretKey) .signWith(secretKey)
.compact(); .compact();
} }
@@ -38,12 +54,23 @@ public class JwtTokenProvider {
.build() .build()
.parseSignedClaims(token) .parseSignedClaims(token)
.getPayload(); .getPayload();
@SuppressWarnings("unchecked")
List<String> roleCodes = claims.get("roleCodes", List.class);
if (roleCodes == null) {
roleCodes = List.of();
}
return new JwtUserPrincipal( return new JwtUserPrincipal(
claims.getSubject(), claims.getSubject(),
claims.get("username", String.class), claims.get("username", String.class),
claims.get("displayName", String.class), claims.get("displayName", String.class),
claims.get("adcode", String.class),
claims.get("tenantId", String.class), claims.get("tenantId", String.class),
claims.get("deptId", String.class) claims.get("tenantPath", String.class),
claims.get("deptId", String.class),
claims.get("deptPath", String.class),
roleCodes,
claims.get("clientType", String.class),
claims.get("sessionId", String.class)
); );
} }
} }

View File

@@ -1,10 +1,17 @@
package com.k12study.common.security.jwt; package com.k12study.common.security.jwt;
import java.util.List;
public record JwtUserPrincipal( public record JwtUserPrincipal(
String userId, String userId,
String username, String username,
String displayName, String displayName,
String adcode,
String tenantId, String tenantId,
String deptId String tenantPath,
String deptId,
String deptPath,
List<String> roleCodes,
String clientType,
String sessionId
) { ) {
} }

View File

@@ -9,6 +9,8 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
@@ -27,8 +29,14 @@ public class CommonWebMvcConfiguration extends OncePerRequestFilter {
request.getHeader(SecurityConstants.HEADER_USER_ID), request.getHeader(SecurityConstants.HEADER_USER_ID),
request.getHeader(SecurityConstants.HEADER_USERNAME), request.getHeader(SecurityConstants.HEADER_USERNAME),
request.getHeader(SecurityConstants.HEADER_DISPLAY_NAME), request.getHeader(SecurityConstants.HEADER_DISPLAY_NAME),
request.getHeader(SecurityConstants.HEADER_ADCODE),
request.getHeader(SecurityConstants.HEADER_TENANT_ID), request.getHeader(SecurityConstants.HEADER_TENANT_ID),
request.getHeader(SecurityConstants.HEADER_DEPT_ID) request.getHeader(SecurityConstants.HEADER_TENANT_PATH),
request.getHeader(SecurityConstants.HEADER_DEPT_ID),
request.getHeader(SecurityConstants.HEADER_DEPT_PATH),
parseRoleCodes(request.getHeader(SecurityConstants.HEADER_ROLE_CODES)),
request.getHeader(SecurityConstants.HEADER_CLIENT_TYPE),
request.getHeader(SecurityConstants.HEADER_SESSION_ID)
)); ));
response.setHeader(SecurityConstants.TRACE_ID, TraceIdHolder.getOrCreate()); response.setHeader(SecurityConstants.TRACE_ID, TraceIdHolder.getOrCreate());
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
@@ -37,4 +45,14 @@ public class CommonWebMvcConfiguration extends OncePerRequestFilter {
RequestUserContextHolder.clear(); RequestUserContextHolder.clear();
} }
} }
private List<String> parseRoleCodes(String roleCodesHeader) {
if (roleCodesHeader == null || roleCodesHeader.isBlank()) {
return List.of();
}
return Arrays.stream(roleCodesHeader.split(","))
.map(String::trim)
.filter(code -> !code.isBlank())
.toList();
}
} }

View File

@@ -20,5 +20,6 @@
<module>common-security</module> <module>common-security</module>
<module>common-mybatis</module> <module>common-mybatis</module>
<module>common-redis</module> <module>common-redis</module>
<module>common-feign</module>
</modules> </modules>
</project> </project>

View File

@@ -42,12 +42,21 @@ public class JwtRelayFilter implements GlobalFilter, Ordered {
try { try {
String token = authorization.substring(authProperties.getTokenPrefix().length()); String token = authorization.substring(authProperties.getTokenPrefix().length());
JwtUserPrincipal principal = jwtTokenProvider.parse(token); JwtUserPrincipal principal = jwtTokenProvider.parse(token);
if ("MINI".equalsIgnoreCase(principal.clientType()) && !principal.roleCodes().contains("STUDENT")) {
return forbidden(exchange, "MINI client only allows STUDENT role");
}
var mutatedRequest = exchange.getRequest().mutate() var mutatedRequest = exchange.getRequest().mutate()
.header(SecurityConstants.HEADER_USER_ID, principal.userId()) .header(SecurityConstants.HEADER_USER_ID, safe(principal.userId()))
.header(SecurityConstants.HEADER_USERNAME, principal.username()) .header(SecurityConstants.HEADER_USERNAME, safe(principal.username()))
.header(SecurityConstants.HEADER_DISPLAY_NAME, principal.displayName()) .header(SecurityConstants.HEADER_DISPLAY_NAME, safe(principal.displayName()))
.header(SecurityConstants.HEADER_TENANT_ID, principal.tenantId()) .header(SecurityConstants.HEADER_ADCODE, safe(principal.adcode()))
.header(SecurityConstants.HEADER_DEPT_ID, principal.deptId()) .header(SecurityConstants.HEADER_TENANT_ID, safe(principal.tenantId()))
.header(SecurityConstants.HEADER_TENANT_PATH, safe(principal.tenantPath()))
.header(SecurityConstants.HEADER_DEPT_ID, safe(principal.deptId()))
.header(SecurityConstants.HEADER_DEPT_PATH, safe(principal.deptPath()))
.header(SecurityConstants.HEADER_ROLE_CODES, String.join(",", principal.roleCodes()))
.header(SecurityConstants.HEADER_CLIENT_TYPE, safe(principal.clientType()))
.header(SecurityConstants.HEADER_SESSION_ID, safe(principal.sessionId()))
.build(); .build();
return chain.filter(exchange.mutate().request(mutatedRequest).build()); return chain.filter(exchange.mutate().request(mutatedRequest).build());
} catch (Exception exception) { } catch (Exception exception) {
@@ -67,9 +76,24 @@ public class JwtRelayFilter implements GlobalFilter, Ordered {
private Mono<Void> unauthorized(ServerWebExchange exchange, String message) { private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); exchange.getResponse().getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
byte[] body = ("{\"code\":401,\"message\":\"" + message + "\",\"data\":null}").getBytes(); String bodyJson = "{\"code\":401,\"message\":\"%s\",\"data\":null}".formatted(message);
byte[] body = bodyJson.getBytes();
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse() return exchange.getResponse().writeWith(Mono.just(exchange.getResponse()
.bufferFactory() .bufferFactory()
.wrap(body))); .wrap(body)));
} }
private Mono<Void> forbidden(ServerWebExchange exchange, String message) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
exchange.getResponse().getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
String bodyJson = "{\"code\":403,\"message\":\"%s\",\"data\":null}".formatted(message);
byte[] body = bodyJson.getBytes();
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse()
.bufferFactory()
.wrap(body)));
}
private String safe(String value) {
return value == null ? "" : value;
}
} }

View File

@@ -27,6 +27,7 @@
<spring.boot.version>3.3.5</spring.boot.version> <spring.boot.version>3.3.5</spring.boot.version>
<spring.cloud.version>2023.0.3</spring.cloud.version> <spring.cloud.version>2023.0.3</spring.cloud.version>
<spring.cloud.alibaba.version>2023.0.3.2</spring.cloud.alibaba.version>
<mybatis.plus.version>3.5.7</mybatis.plus.version> <mybatis.plus.version>3.5.7</mybatis.plus.version>
<postgresql.version>42.7.4</postgresql.version> <postgresql.version>42.7.4</postgresql.version>
<jjwt.version>0.12.6</jjwt.version> <jjwt.version>0.12.6</jjwt.version>
@@ -49,6 +50,13 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring.cloud.alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>

View File

@@ -3,16 +3,34 @@ package com.k12study.upms.controller;
import com.k12study.api.upms.dto.AreaNodeDto; import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto; import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto; import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteNodeDto; import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto; import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.api.upms.remote.UpmsApiPaths; import com.k12study.api.upms.remote.UpmsApiPaths;
import com.k12study.common.api.response.ApiResponse; import com.k12study.common.api.response.ApiResponse;
import com.k12study.upms.service.UpmsQueryService; import com.k12study.upms.service.UpmsQueryService;
import java.util.List; import java.util.List;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/**
* @description UPMS HTTP 入口;路由/组织/班级/文件/站内信等聚合查询,全部经 UpmsQueryService 执行租户隔离后返回
* @filename UpmsController.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@RestController @RestController
@RequestMapping(UpmsApiPaths.BASE) @RequestMapping(UpmsApiPaths.BASE)
public class UpmsController { public class UpmsController {
@@ -23,27 +41,79 @@ public class UpmsController {
} }
@GetMapping("/routes") @GetMapping("/routes")
public ApiResponse<List<RouteNodeDto>> routes() { public ApiResponse<List<RouteNodeDto>> routes(
return ApiResponse.success(upmsQueryService.routes()); @RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.routes(authorizationHeader));
} }
@GetMapping("/areas") @GetMapping("/areas")
public ApiResponse<List<AreaNodeDto>> areas() { public ApiResponse<List<AreaNodeDto>> areas(
return ApiResponse.success(upmsQueryService.areas()); @RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.areas(authorizationHeader));
} }
@GetMapping("/tenants") @GetMapping("/tenants")
public ApiResponse<List<TenantNodeDto>> tenants() { public ApiResponse<List<TenantNodeDto>> tenants(
return ApiResponse.success(upmsQueryService.tenants()); @RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.tenants(authorizationHeader));
} }
@GetMapping("/departments") @GetMapping("/departments")
public ApiResponse<List<DeptNodeDto>> departments() { public ApiResponse<List<DeptNodeDto>> departments(
return ApiResponse.success(upmsQueryService.departments()); @RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.departments(authorizationHeader));
} }
@GetMapping("/users/current") @GetMapping("/users/current")
public ApiResponse<CurrentRouteUserDto> currentUser() { public ApiResponse<CurrentRouteUserDto> currentUser(
return ApiResponse.success(upmsQueryService.currentUser()); @RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.currentUser(authorizationHeader));
}
@GetMapping("/classes")
public ApiResponse<List<SchoolClassDto>> classes(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.classes(authorizationHeader));
}
@GetMapping("/classes/{classId}/members")
public ApiResponse<List<SchoolClassMemberDto>> classMembers(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@PathVariable String classId) {
return ApiResponse.success(upmsQueryService.classMembers(authorizationHeader, classId));
}
@GetMapping("/classes/{classId}/courses")
public ApiResponse<List<SchoolClassCourseDto>> classCourses(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@PathVariable String classId) {
return ApiResponse.success(upmsQueryService.classCourses(authorizationHeader, classId));
}
@PostMapping("/files/upload")
public ApiResponse<FileMetadataDto> uploadFile(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@RequestBody FileUploadRequestDto request) {
return ApiResponse.success("上传登记成功", upmsQueryService.uploadFile(authorizationHeader, request));
}
@GetMapping("/files/{fileId}")
public ApiResponse<FileMetadataDto> fileById(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@PathVariable String fileId) {
return ApiResponse.success(upmsQueryService.fileById(authorizationHeader, fileId));
}
@GetMapping("/messages/inbox")
public ApiResponse<List<InboxMessageDto>> inboxMessages(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.inboxMessages(authorizationHeader));
}
@PostMapping("/messages/{messageId}/read")
public ApiResponse<MessageReadResultDto> readMessage(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@PathVariable String messageId) {
return ApiResponse.success("已标记已读", upmsQueryService.readMessage(authorizationHeader, messageId));
} }
} }

View File

@@ -3,17 +3,38 @@ package com.k12study.upms.service;
import com.k12study.api.upms.dto.AreaNodeDto; import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto; import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto; import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteNodeDto; import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto; import com.k12study.api.upms.dto.TenantNodeDto;
import java.util.List; import java.util.List;
public interface UpmsQueryService { public interface UpmsQueryService {
List<RouteNodeDto> routes(); List<RouteNodeDto> routes(String authorizationHeader);
List<AreaNodeDto> areas(); List<AreaNodeDto> areas(String authorizationHeader);
List<TenantNodeDto> tenants(); List<TenantNodeDto> tenants(String authorizationHeader);
List<DeptNodeDto> departments(); List<DeptNodeDto> departments(String authorizationHeader);
CurrentRouteUserDto currentUser(); CurrentRouteUserDto currentUser(String authorizationHeader);
List<SchoolClassDto> classes(String authorizationHeader);
List<SchoolClassMemberDto> classMembers(String authorizationHeader, String classId);
List<SchoolClassCourseDto> classCourses(String authorizationHeader, String classId);
FileMetadataDto uploadFile(String authorizationHeader, FileUploadRequestDto request);
FileMetadataDto fileById(String authorizationHeader, String fileId);
List<InboxMessageDto> inboxMessages(String authorizationHeader);
MessageReadResultDto readMessage(String authorizationHeader, String messageId);
} }

View File

@@ -3,130 +3,762 @@ package com.k12study.upms.service.impl;
import com.k12study.api.upms.dto.AreaNodeDto; import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto; import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto; import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.LayoutType; import com.k12study.api.upms.dto.LayoutType;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteMetaDto; import com.k12study.api.upms.dto.RouteMetaDto;
import com.k12study.api.upms.dto.RouteNodeDto; import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto; import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.common.security.context.RequestUserContextHolder; import com.k12study.common.security.context.RequestUserContextHolder;
import com.k12study.common.security.jwt.JwtTokenProvider;
import com.k12study.common.security.jwt.JwtUserPrincipal;
import com.k12study.common.web.exception.BizException;
import com.k12study.upms.service.UpmsQueryService; import com.k12study.upms.service.UpmsQueryService;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service @Service
public class UpmsQueryServiceImpl implements UpmsQueryService { public class UpmsQueryServiceImpl implements UpmsQueryService {
private final NamedParameterJdbcTemplate jdbcTemplate;
private final JwtTokenProvider jwtTokenProvider;
@Override public UpmsQueryServiceImpl(NamedParameterJdbcTemplate jdbcTemplate, JwtTokenProvider jwtTokenProvider) {
public List<RouteNodeDto> routes() { this.jdbcTemplate = jdbcTemplate;
return List.of( this.jwtTokenProvider = jwtTokenProvider;
new RouteNodeDto(
"dashboard",
"/",
"dashboard",
"dashboard",
LayoutType.SIDEBAR,
new RouteMetaDto("控制台", "layout-dashboard", List.of("dashboard:view"), false),
List.of()
),
new RouteNodeDto(
"tenant-management",
"/tenant",
"tenant-management",
"tenant",
LayoutType.SIDEBAR,
new RouteMetaDto("租户组织", "building-2", List.of("tenant:view"), false),
List.of()
)
);
} }
@Override @Override
public List<AreaNodeDto> areas() { public List<RouteNodeDto> routes(String authorizationHeader) {
return List.of( AuthContext context = requireAuth(authorizationHeader);
new AreaNodeDto( String sql = """
"330000", SELECT DISTINCT
"100000", m.route_id, m.parent_route_id, m.route_path, m.route_name,
"浙江省", m.component_key, m.layout_type, m.title, m.icon, m.permission_code, m.hidden
"PROVINCE", FROM upms.tb_sys_menu m
List.of( JOIN upms.tb_sys_role_menu rm ON rm.route_id = m.route_id
new AreaNodeDto( JOIN upms.tb_sys_user_role ur ON ur.role_id = rm.role_id
"330100", WHERE ur.user_id = :userId
"330000", AND m.tenant_id = :tenantId
"杭州市", ORDER BY m.created_at, m.route_id
"CITY", """;
List.of() List<RouteRow> rows = jdbcTemplate.query(
) sql,
) Map.of("userId", context.userId(), "tenantId", context.tenantId()),
) (rs, rowNum) -> new RouteRow(
); rs.getString("route_id"),
rs.getString("parent_route_id"),
rs.getString("route_path"),
rs.getString("route_name"),
rs.getString("component_key"),
rs.getString("layout_type"),
rs.getString("title"),
rs.getString("icon"),
rs.getString("permission_code"),
rs.getBoolean("hidden")
));
return buildRouteTree(rows);
} }
@Override @Override
public List<TenantNodeDto> tenants() { public List<AreaNodeDto> areas(String authorizationHeader) {
return List.of( requireAuth(authorizationHeader);
new TenantNodeDto( String sql = """
"SCH-HQ", SELECT id, pid, adcode, name, area_type
null, FROM upms.tb_sys_area
"K12Study 总校", WHERE del_flag = '0'
"HEAD_SCHOOL", ORDER BY area_sort NULLS LAST, id
"330100", """;
"/SCH-HQ/", List<AreaRow> rows = jdbcTemplate.query(
List.of( sql,
new TenantNodeDto( Map.of(),
"SCH-ZJ-HZ-01", (rs, rowNum) -> new AreaRow(
"SCH-HQ", rs.getLong("id"),
"杭州分校", rs.getLong("pid"),
"CITY_SCHOOL", String.valueOf(rs.getLong("adcode")),
"330100", rs.getString("name"),
"/SCH-HQ/SCH-ZJ-HZ-01/", rs.getString("area_type")
List.of() ));
) return buildAreaTree(rows);
)
)
);
} }
@Override @Override
public List<DeptNodeDto> departments() { public List<TenantNodeDto> tenants(String authorizationHeader) {
return List.of( AuthContext context = requireAuth(authorizationHeader);
new DeptNodeDto( List<String> roleCodes = findRoleCodes(context.userId());
"DEPT-HQ", boolean superAdmin = roleCodes.stream().anyMatch("SUPER_ADMIN"::equalsIgnoreCase);
null,
"总校教学部", StringBuilder sql = new StringBuilder("""
"GRADE", SELECT tenant_id, parent_tenant_id, tenant_name, tenant_type, adcode, tenant_path
"SCH-HQ", FROM upms.tb_sys_tenant
"330100", WHERE status = 'ACTIVE'
"/SCH-HQ/", """);
"/DEPT-HQ/", MapSqlParameterSource params = new MapSqlParameterSource();
List.of( if (!superAdmin) {
new DeptNodeDto( if (StringUtils.hasText(context.tenantPath())) {
"DEPT-HQ-MATH", sql.append(" AND tenant_path LIKE :tenantPathPrefix ");
"DEPT-HQ", params.addValue("tenantPathPrefix", context.tenantPath() + "%");
"数学学科组", } else {
"SUBJECT", sql.append(" AND tenant_id = :tenantId ");
"SCH-HQ", params.addValue("tenantId", context.tenantId());
"330100", }
"/SCH-HQ/", }
"/DEPT-HQ/DEPT-HQ-MATH/", sql.append(" ORDER BY tenant_path ");
List.of()
) List<TenantRow> rows = jdbcTemplate.query(
) sql.toString(),
) params,
); (rs, rowNum) -> new TenantRow(
rs.getString("tenant_id"),
rs.getString("parent_tenant_id"),
rs.getString("tenant_name"),
rs.getString("tenant_type"),
rs.getString("adcode"),
rs.getString("tenant_path")
));
return buildTenantTree(rows);
} }
@Override @Override
public CurrentRouteUserDto currentUser() { public List<DeptNodeDto> departments(String authorizationHeader) {
var context = RequestUserContextHolder.get(); AuthContext context = requireAuth(authorizationHeader);
List<String> roleCodes = findRoleCodes(context.userId());
boolean superAdmin = roleCodes.stream().anyMatch("SUPER_ADMIN"::equalsIgnoreCase);
StringBuilder sql = new StringBuilder("""
SELECT dept_id, parent_dept_id, dept_name, dept_type, tenant_id, adcode, tenant_path, dept_path
FROM upms.tb_sys_dept
WHERE tenant_id = :tenantId
""");
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("tenantId", context.tenantId());
if (!superAdmin && StringUtils.hasText(context.deptPath())) {
sql.append(" AND dept_path LIKE :deptPathPrefix ");
params.addValue("deptPathPrefix", context.deptPath() + "%");
}
sql.append(" ORDER BY dept_path ");
List<DeptRow> rows = jdbcTemplate.query(
sql.toString(),
params,
(rs, rowNum) -> new DeptRow(
rs.getString("dept_id"),
rs.getString("parent_dept_id"),
rs.getString("dept_name"),
rs.getString("dept_type"),
rs.getString("tenant_id"),
rs.getString("adcode"),
rs.getString("tenant_path"),
rs.getString("dept_path")
));
return buildDeptTree(rows);
}
@Override
public CurrentRouteUserDto currentUser(String authorizationHeader) {
AuthContext context = requireAuth(authorizationHeader);
UserRow user = findUserRow(context.userId(), context.tenantId())
.orElseThrow(() -> new BizException(404, "当前用户不存在"));
List<String> permissionCodes = findPermissionCodes(context.userId(), context.tenantId());
return new CurrentRouteUserDto( return new CurrentRouteUserDto(
context == null ? "U10001" : context.userId(), user.userId(),
context == null ? "admin" : context.username(), user.username(),
context == null ? "K12Study 管理员" : context.displayName(), user.displayName(),
"330100", user.adcode(),
context == null ? "SCH-HQ" : context.tenantId(), user.tenantId(),
"/SCH-HQ/", user.tenantPath(),
context == null ? "DEPT-HQ-ADMIN" : context.deptId(), user.deptId(),
"/DEPT-HQ/DEPT-HQ-ADMIN/", user.deptPath(),
List.of("dashboard:view", "tenant:view", "dept:view") permissionCodes
); );
} }
@Override
public List<SchoolClassDto> classes(String authorizationHeader) {
AuthContext context = requireAuth(authorizationHeader);
String sql = """
SELECT class_id, class_code, class_name, grade_code, status, tenant_id, dept_id
FROM upms.tb_school_class
WHERE tenant_id = :tenantId
ORDER BY created_at DESC
""";
return jdbcTemplate.query(
sql,
Map.of("tenantId", context.tenantId()),
(rs, rowNum) -> new SchoolClassDto(
rs.getString("class_id"),
rs.getString("class_code"),
rs.getString("class_name"),
rs.getString("grade_code"),
rs.getString("status"),
rs.getString("tenant_id"),
rs.getString("dept_id")
));
}
@Override
public List<SchoolClassMemberDto> classMembers(String authorizationHeader, String classId) {
AuthContext context = requireAuth(authorizationHeader);
ensureClassInTenant(classId, context.tenantId());
String sql = """
SELECT m.class_id, m.user_id, u.username, u.display_name, m.member_role, m.member_status, m.joined_at, m.left_at
FROM upms.tb_school_class_member m
JOIN upms.tb_sys_user u ON u.user_id = m.user_id
WHERE m.class_id = :classId
AND m.tenant_id = :tenantId
ORDER BY m.joined_at
""";
return jdbcTemplate.query(
sql,
Map.of("classId", classId, "tenantId", context.tenantId()),
(rs, rowNum) -> new SchoolClassMemberDto(
rs.getString("class_id"),
rs.getString("user_id"),
rs.getString("username"),
rs.getString("display_name"),
rs.getString("member_role"),
rs.getString("member_status"),
toInstant(rs.getTimestamp("joined_at")),
toInstant(rs.getTimestamp("left_at"))
));
}
@Override
public List<SchoolClassCourseDto> classCourses(String authorizationHeader, String classId) {
AuthContext context = requireAuth(authorizationHeader);
ensureClassInTenant(classId, context.tenantId());
String sql = """
SELECT class_id, course_id, relation_status
FROM upms.tb_school_class_course_rel
WHERE class_id = :classId
AND tenant_id = :tenantId
ORDER BY created_at
""";
return jdbcTemplate.query(
sql,
Map.of("classId", classId, "tenantId", context.tenantId()),
(rs, rowNum) -> new SchoolClassCourseDto(
rs.getString("class_id"),
rs.getString("course_id"),
rs.getString("relation_status")
));
}
@Override
public FileMetadataDto uploadFile(String authorizationHeader, FileUploadRequestDto request) {
AuthContext context = requireAuth(authorizationHeader);
if (request == null || !StringUtils.hasText(request.objectKey())) {
throw new BizException(400, "objectKey 不能为空");
}
String mediaType = normalizeMediaType(request.mediaType());
String fileId = "FILE-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
String sql = """
INSERT INTO upms.tb_sys_file (
file_id, media_type, object_key, file_name, mime_type, file_size, file_hash, duration_ms,
uploaded_by, adcode, tenant_id, tenant_path, created_at
) VALUES (
:fileId, :mediaType, :objectKey, :fileName, :mimeType, :fileSize, :fileHash, :durationMs,
:uploadedBy, :adcode, :tenantId, :tenantPath, CURRENT_TIMESTAMP
)
""";
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("fileId", fileId)
.addValue("mediaType", mediaType)
.addValue("objectKey", request.objectKey())
.addValue("fileName", request.fileName())
.addValue("mimeType", request.mimeType())
.addValue("fileSize", request.fileSize())
.addValue("fileHash", request.fileHash())
.addValue("durationMs", request.durationMs())
.addValue("uploadedBy", context.userId())
.addValue("adcode", context.adcode())
.addValue("tenantId", context.tenantId())
.addValue("tenantPath", context.tenantPath());
jdbcTemplate.update(sql, params);
return fileById(authorizationHeader, fileId);
}
@Override
public FileMetadataDto fileById(String authorizationHeader, String fileId) {
AuthContext context = requireAuth(authorizationHeader);
String sql = """
SELECT file_id, media_type, object_key, file_name, mime_type, file_size, file_hash, duration_ms,
uploaded_by, tenant_id, tenant_path, created_at
FROM upms.tb_sys_file
WHERE file_id = :fileId
AND tenant_id = :tenantId
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("fileId", fileId, "tenantId", context.tenantId()), FILE_ROW_MAPPER)
.stream()
.findFirst()
.orElseThrow(() -> new BizException(404, "文件不存在"));
}
@Override
public List<InboxMessageDto> inboxMessages(String authorizationHeader) {
AuthContext context = requireAuth(authorizationHeader);
String sql = """
SELECT m.message_id, m.message_type, m.biz_type, m.title, m.content, m.web_jump_url,
r.read_status, r.read_at, m.send_at
FROM upms.tb_sys_message m
JOIN upms.tb_sys_message_recipient r ON r.message_id = m.message_id
WHERE r.recipient_user_id = :userId
AND r.tenant_id = :tenantId
AND m.message_status = 'ACTIVE'
ORDER BY m.send_at DESC
""";
return jdbcTemplate.query(
sql,
Map.of("userId", context.userId(), "tenantId", context.tenantId()),
(rs, rowNum) -> new InboxMessageDto(
rs.getString("message_id"),
rs.getString("message_type"),
rs.getString("biz_type"),
rs.getString("title"),
rs.getString("content"),
rs.getString("web_jump_url"),
rs.getString("read_status"),
toInstant(rs.getTimestamp("read_at")),
toInstant(rs.getTimestamp("send_at"))
));
}
@Override
public MessageReadResultDto readMessage(String authorizationHeader, String messageId) {
AuthContext context = requireAuth(authorizationHeader);
String readSource = "MINI".equalsIgnoreCase(context.clientType()) ? "MINI_PROGRAM" : "WEB";
String updateSql = """
UPDATE upms.tb_sys_message_recipient
SET read_status = 'READ',
read_at = COALESCE(read_at, CURRENT_TIMESTAMP),
read_source = COALESCE(read_source, :readSource),
updated_at = CURRENT_TIMESTAMP
WHERE message_id = :messageId
AND recipient_user_id = :userId
AND tenant_id = :tenantId
""";
int updated = jdbcTemplate.update(
updateSql,
new MapSqlParameterSource()
.addValue("messageId", messageId)
.addValue("userId", context.userId())
.addValue("tenantId", context.tenantId())
.addValue("readSource", readSource));
if (updated == 0) {
throw new BizException(404, "消息不存在");
}
String querySql = """
SELECT message_id, read_status, read_at
FROM upms.tb_sys_message_recipient
WHERE message_id = :messageId
AND recipient_user_id = :userId
AND tenant_id = :tenantId
LIMIT 1
""";
return jdbcTemplate.query(
querySql,
Map.of("messageId", messageId, "userId", context.userId(), "tenantId", context.tenantId()),
(rs, rowNum) -> new MessageReadResultDto(
rs.getString("message_id"),
rs.getString("read_status"),
toInstant(rs.getTimestamp("read_at"))
))
.stream()
.findFirst()
.orElseThrow(() -> new BizException(404, "消息不存在"));
}
private AuthContext requireAuth(String authorizationHeader) {
var context = RequestUserContextHolder.get();
if (context != null && StringUtils.hasText(context.userId()) && StringUtils.hasText(context.tenantId())) {
return new AuthContext(
context.userId(),
context.username(),
context.displayName(),
context.adcode(),
context.tenantId(),
context.tenantPath(),
context.deptId(),
context.deptPath(),
context.roleCodes(),
context.clientType()
);
}
if (StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith("Bearer ")) {
JwtUserPrincipal principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length()));
return new AuthContext(
principal.userId(),
principal.username(),
principal.displayName(),
principal.adcode(),
principal.tenantId(),
principal.tenantPath(),
principal.deptId(),
principal.deptPath(),
principal.roleCodes(),
principal.clientType()
);
}
throw new BizException(401, "未登录或登录已失效");
}
private Optional<UserRow> findUserRow(String userId, String tenantId) {
String sql = """
SELECT user_id, username, display_name, adcode, tenant_id, tenant_path, dept_id, dept_path
FROM upms.tb_sys_user
WHERE user_id = :userId
AND tenant_id = :tenantId
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("userId", userId, "tenantId", tenantId), USER_ROW_MAPPER).stream().findFirst();
}
private List<String> findRoleCodes(String userId) {
String sql = """
SELECT r.role_code
FROM upms.tb_sys_user_role ur
JOIN upms.tb_sys_role r ON r.role_id = ur.role_id
WHERE ur.user_id = :userId
ORDER BY r.role_code
""";
return jdbcTemplate.queryForList(sql, Map.of("userId", userId), String.class);
}
private List<String> findPermissionCodes(String userId, String tenantId) {
String sql = """
SELECT DISTINCT m.permission_code
FROM upms.tb_sys_menu m
JOIN upms.tb_sys_role_menu rm ON rm.route_id = m.route_id
JOIN upms.tb_sys_user_role ur ON ur.role_id = rm.role_id
WHERE ur.user_id = :userId
AND m.tenant_id = :tenantId
AND m.permission_code IS NOT NULL
AND m.permission_code <> ''
""";
return jdbcTemplate.queryForList(sql, Map.of("userId", userId, "tenantId", tenantId), String.class)
.stream()
.flatMap(codes -> Arrays.stream(codes.split(",")))
.map(String::trim)
.filter(StringUtils::hasText)
.distinct()
.sorted()
.toList();
}
private void ensureClassInTenant(String classId, String tenantId) {
String sql = """
SELECT COUNT(1)
FROM upms.tb_school_class
WHERE class_id = :classId
AND tenant_id = :tenantId
""";
Integer count = jdbcTemplate.queryForObject(sql, Map.of("classId", classId, "tenantId", tenantId), Integer.class);
if (count == null || count == 0) {
throw new BizException(404, "班级不存在");
}
}
private String normalizeMediaType(String mediaType) {
if (!StringUtils.hasText(mediaType)) {
return "OTHER";
}
String normalized = mediaType.trim().toUpperCase();
return switch (normalized) {
case "IMAGE", "AUDIO", "VIDEO", "DOCUMENT", "OTHER" -> normalized;
default -> throw new BizException(400, "mediaType 非法");
};
}
private List<RouteNodeDto> buildRouteTree(List<RouteRow> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<String, RouteRow> rowMap = new LinkedHashMap<>();
Map<String, List<String>> childrenMap = new LinkedHashMap<>();
for (RouteRow row : rows) {
rowMap.put(row.routeId(), row);
if (StringUtils.hasText(row.parentRouteId())) {
childrenMap.computeIfAbsent(row.parentRouteId(), key -> new ArrayList<>()).add(row.routeId());
}
}
List<String> rootIds = rows.stream()
.filter(row -> !StringUtils.hasText(row.parentRouteId()) || !rowMap.containsKey(row.parentRouteId()))
.map(RouteRow::routeId)
.toList();
return rootIds.stream()
.map(routeId -> buildRouteNode(routeId, rowMap, childrenMap))
.toList();
}
private RouteNodeDto buildRouteNode(
String routeId,
Map<String, RouteRow> rowMap,
Map<String, List<String>> childrenMap) {
RouteRow row = rowMap.get(routeId);
List<RouteNodeDto> children = childrenMap.getOrDefault(routeId, List.of()).stream()
.map(childId -> buildRouteNode(childId, rowMap, childrenMap))
.toList();
LayoutType layoutType;
try {
layoutType = LayoutType.valueOf(row.layoutType());
} catch (Exception exception) {
layoutType = LayoutType.SIDEBAR;
}
List<String> permissionCodes = splitPermissionCodes(row.permissionCode());
return new RouteNodeDto(
row.routeId(),
row.routePath(),
row.routeName(),
row.componentKey(),
layoutType,
new RouteMetaDto(row.title(), row.icon(), permissionCodes, row.hidden()),
children
);
}
private List<AreaNodeDto> buildAreaTree(List<AreaRow> rows) {
Map<Long, AreaRow> rowMap = rows.stream()
.collect(Collectors.toMap(AreaRow::id, row -> row, (a, b) -> a, LinkedHashMap::new));
Map<Long, List<Long>> childMap = new LinkedHashMap<>();
for (AreaRow row : rows) {
childMap.computeIfAbsent(row.pid(), key -> new ArrayList<>()).add(row.id());
}
return rows.stream()
.filter(row -> row.pid() == 0L || !rowMap.containsKey(row.pid()))
.map(row -> buildAreaNode(row.id(), rowMap, childMap))
.toList();
}
private AreaNodeDto buildAreaNode(Long id, Map<Long, AreaRow> rowMap, Map<Long, List<Long>> childMap) {
AreaRow row = rowMap.get(id);
List<AreaNodeDto> children = childMap.getOrDefault(id, List.of()).stream()
.map(childId -> buildAreaNode(childId, rowMap, childMap))
.toList();
return new AreaNodeDto(
row.areaCode(),
String.valueOf(row.pid()),
row.areaName(),
mapAreaLevel(row.areaType()),
children
);
}
private List<TenantNodeDto> buildTenantTree(List<TenantRow> rows) {
Map<String, TenantRow> rowMap = rows.stream()
.collect(Collectors.toMap(TenantRow::tenantId, row -> row, (a, b) -> a, LinkedHashMap::new));
Map<String, List<String>> childMap = new LinkedHashMap<>();
for (TenantRow row : rows) {
if (StringUtils.hasText(row.parentTenantId())) {
childMap.computeIfAbsent(row.parentTenantId(), key -> new ArrayList<>()).add(row.tenantId());
}
}
return rows.stream()
.filter(row -> !StringUtils.hasText(row.parentTenantId()) || !rowMap.containsKey(row.parentTenantId()))
.sorted(Comparator.comparing(TenantRow::tenantPath))
.map(row -> buildTenantNode(row.tenantId(), rowMap, childMap))
.toList();
}
private TenantNodeDto buildTenantNode(
String tenantId,
Map<String, TenantRow> rowMap,
Map<String, List<String>> childMap) {
TenantRow row = rowMap.get(tenantId);
List<TenantNodeDto> children = childMap.getOrDefault(tenantId, List.of()).stream()
.map(childId -> buildTenantNode(childId, rowMap, childMap))
.toList();
return new TenantNodeDto(
row.tenantId(),
row.parentTenantId(),
row.tenantName(),
row.tenantType(),
row.adcode(),
row.tenantPath(),
children
);
}
private List<DeptNodeDto> buildDeptTree(List<DeptRow> rows) {
Map<String, DeptRow> rowMap = rows.stream()
.collect(Collectors.toMap(DeptRow::deptId, row -> row, (a, b) -> a, LinkedHashMap::new));
Map<String, List<String>> childMap = new LinkedHashMap<>();
for (DeptRow row : rows) {
if (StringUtils.hasText(row.parentDeptId())) {
childMap.computeIfAbsent(row.parentDeptId(), key -> new ArrayList<>()).add(row.deptId());
}
}
return rows.stream()
.filter(row -> !StringUtils.hasText(row.parentDeptId()) || !rowMap.containsKey(row.parentDeptId()))
.sorted(Comparator.comparing(DeptRow::deptPath))
.map(row -> buildDeptNode(row.deptId(), rowMap, childMap))
.toList();
}
private DeptNodeDto buildDeptNode(
String deptId,
Map<String, DeptRow> rowMap,
Map<String, List<String>> childMap) {
DeptRow row = rowMap.get(deptId);
List<DeptNodeDto> children = childMap.getOrDefault(deptId, List.of()).stream()
.map(childId -> buildDeptNode(childId, rowMap, childMap))
.toList();
return new DeptNodeDto(
row.deptId(),
row.parentDeptId(),
row.deptName(),
row.deptType(),
row.tenantId(),
row.adcode(),
row.tenantPath(),
row.deptPath(),
children
);
}
private List<String> splitPermissionCodes(String permissionCode) {
if (!StringUtils.hasText(permissionCode)) {
return List.of();
}
return Arrays.stream(permissionCode.split(","))
.map(String::trim)
.filter(StringUtils::hasText)
.toList();
}
private String mapAreaLevel(String areaType) {
if ("0".equals(areaType)) {
return "COUNTRY";
}
if ("1".equals(areaType)) {
return "PROVINCE";
}
if ("2".equals(areaType)) {
return "CITY";
}
if ("3".equals(areaType)) {
return "DISTRICT";
}
return "UNKNOWN";
}
private Instant toInstant(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toInstant();
}
private static final RowMapper<UserRow> USER_ROW_MAPPER = (rs, rowNum) -> new UserRow(
rs.getString("user_id"),
rs.getString("username"),
rs.getString("display_name"),
rs.getString("adcode"),
rs.getString("tenant_id"),
rs.getString("tenant_path"),
rs.getString("dept_id"),
rs.getString("dept_path")
);
private static final RowMapper<FileMetadataDto> FILE_ROW_MAPPER = (rs, rowNum) -> new FileMetadataDto(
rs.getString("file_id"),
rs.getString("media_type"),
rs.getString("object_key"),
rs.getString("file_name"),
rs.getString("mime_type"),
rs.getObject("file_size", Long.class),
rs.getString("file_hash"),
rs.getObject("duration_ms", Integer.class),
rs.getString("uploaded_by"),
rs.getString("tenant_id"),
rs.getString("tenant_path"),
rs.getTimestamp("created_at") == null ? null : rs.getTimestamp("created_at").toInstant()
);
private record AuthContext(
String userId,
String username,
String displayName,
String adcode,
String tenantId,
String tenantPath,
String deptId,
String deptPath,
List<String> roleCodes,
String clientType
) {
}
private record RouteRow(
String routeId,
String parentRouteId,
String routePath,
String routeName,
String componentKey,
String layoutType,
String title,
String icon,
String permissionCode,
boolean hidden
) {
}
private record AreaRow(
Long id,
Long pid,
String areaCode,
String areaName,
String areaType
) {
}
private record TenantRow(
String tenantId,
String parentTenantId,
String tenantName,
String tenantType,
String adcode,
String tenantPath
) {
}
private record DeptRow(
String deptId,
String parentDeptId,
String deptName,
String deptType,
String tenantId,
String adcode,
String tenantPath,
String deptPath
) {
}
private record UserRow(
String userId,
String username,
String displayName,
String adcode,
String tenantId,
String tenantPath,
String deptId,
String deptPath
) {
}
} }

View File

@@ -4,11 +4,10 @@ server:
spring: spring:
application: application:
name: k12study-upms name: k12study-upms
autoconfigure: datasource:
exclude: url: jdbc:postgresql://${K12STUDY_DB_HOST:localhost}:${K12STUDY_DB_PORT:5432}/${K12STUDY_DB_NAME:k12study}
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration username: ${K12STUDY_DB_USER:k12study}
- org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration password: ${K12STUDY_DB_PASSWORD:k12study}
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
management: management:
endpoints: endpoints:

View File

@@ -1,6 +1,6 @@
<mxfile host="65bd71144e"> <mxfile host="65bd71144e">
<diagram id="multi-role-flow-ai" name="数据流图(多角色)"> <diagram id="multi-role-flow-ai" name="数据流图(多角色)">
<mxGraphModel dx="1312" dy="773" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2800" pageHeight="1800" math="0" shadow="0"> <mxGraphModel dx="1303" dy="773" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2800" pageHeight="1800" math="0" shadow="0">
<root> <root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -10,16 +10,16 @@
<mxCell id="3" value="入口:微信小程序/React 后台主链路Java服务编排 -&gt; Python AI子服务OCR/LLM/ASR -&gt; PostgreSQL/Redis/对象存储/Milvus/NebulaGraph(Neo4j);同步调度支持 REST/JAR" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=13;fontColor=#666666;" parent="1" vertex="1"> <mxCell id="3" value="入口:微信小程序/React 后台主链路Java服务编排 -&gt; Python AI子服务OCR/LLM/ASR -&gt; PostgreSQL/Redis/对象存储/Milvus/NebulaGraph(Neo4j);同步调度支持 REST/JAR" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=13;fontColor=#666666;" parent="1" vertex="1">
<mxGeometry x="30" y="52" width="1850" height="24" as="geometry"/> <mxGeometry x="30" y="52" width="1850" height="24" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="4" value="角色区" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#d0d0d0;dashed=1;align=left;verticalAlign=top;spacingLeft=8;spacingTop=6;fontSize=12;fontColor=#666666;" vertex="1" parent="1"> <mxCell id="4" value="角色区" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#d0d0d0;dashed=1;align=left;verticalAlign=top;spacingLeft=8;spacingTop=6;fontSize=12;fontColor=#666666;" parent="1" vertex="1">
<mxGeometry x="20" y="120" width="220" height="620" as="geometry"/> <mxGeometry x="20" y="120" width="220" height="620" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="5" value="业务流程区" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#edf4ff;strokeColor=#c0d7f2;dashed=1;align=left;verticalAlign=top;spacingLeft=8;spacingTop=6;fontSize=12;fontColor=#4a6b8a;" vertex="1" parent="1"> <mxCell id="5" value="业务流程区" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#edf4ff;strokeColor=#c0d7f2;dashed=1;align=left;verticalAlign=top;spacingLeft=8;spacingTop=6;fontSize=12;fontColor=#4a6b8a;" parent="1" vertex="1">
<mxGeometry x="90" y="100" width="790" height="1070" as="geometry"/> <mxGeometry x="70" y="100" width="790" height="1070" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="6" value="AI 服务区" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff7e6;strokeColor=#e9cf9a;dashed=1;align=left;verticalAlign=top;spacingLeft=8;spacingTop=6;fontSize=12;fontColor=#8a6d3b;" vertex="1" parent="1"> <mxCell id="6" value="AI 服务区" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff7e6;strokeColor=#e9cf9a;dashed=1;align=left;verticalAlign=top;spacingLeft=8;spacingTop=6;fontSize=12;fontColor=#8a6d3b;" parent="1" vertex="1">
<mxGeometry x="860" y="240" width="420" height="860" as="geometry"/> <mxGeometry x="860" y="240" width="420" height="860" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="7" value="数据存储区" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f7f7f7;strokeColor=#cfcfcf;dashed=1;align=left;verticalAlign=top;spacingLeft=8;spacingTop=6;fontSize=12;fontColor=#666666;" vertex="1" parent="1"> <mxCell id="7" value="数据存储区" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f7f7f7;strokeColor=#cfcfcf;dashed=1;align=left;verticalAlign=top;spacingLeft=8;spacingTop=6;fontSize=12;fontColor=#666666;" parent="1" vertex="1">
<mxGeometry x="1340" y="90" width="370" height="1480" as="geometry"/> <mxGeometry x="1340" y="90" width="370" height="1480" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="10" value="学生" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=15;fontStyle=1;" parent="1" vertex="1"> <mxCell id="10" value="学生" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=15;fontStyle=1;" parent="1" vertex="1">
@@ -148,8 +148,11 @@
<mxCell id="100" value="用户/权限" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#666666;" parent="1" source="20" target="60" edge="1"> <mxCell id="100" value="用户/权限" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#666666;" parent="1" source="20" target="60" edge="1">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="101" value="作业/提交" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#666666;" parent="1" source="21" target="61" edge="1"> <mxCell id="101" value="作业/提交" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#666666;" parent="1" edge="1">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry">
<mxPoint x="580" y="322" as="sourcePoint"/>
<mxPoint x="1360" y="305" as="targetPoint"/>
</mxGeometry>
</mxCell> </mxCell>
<mxCell id="102" value="批改确认结果/错题/讲解" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#666666;" parent="1" source="22" target="62" edge="1"> <mxCell id="102" value="批改确认结果/错题/讲解" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#666666;" parent="1" source="22" target="62" edge="1">
<mxGeometry relative="1" as="geometry"/> <mxGeometry relative="1" as="geometry"/>

View File

@@ -0,0 +1,38 @@
# 问题陈述
基于现有 `auth``upms` 模块完成前后端可联调实现(含 Web 管理端与小程序最小可用链路),从“占位/硬编码”升级为“数据库驱动 + 令牌会话治理 + 端侧鉴权一致”。
## 当前状态(已确认)
* `auth` 已有登录/刷新/当前用户接口但服务实现为硬编码用户未校验真实凭据refresh 逻辑未做轮换与持久化(`backend/auth/src/main/java/com/k12study/auth/controller/AuthController.java (18-47)`, `backend/auth/src/main/java/com/k12study/auth/service/AuthService.java (18-74)`)。
* `upms` 已有 routes/currentUser/areas/tenants/departments 接口,但全部为静态返回;且模块配置排除了数据源与 MyBatis 自动装配(`backend/upms/src/main/java/com/k12study/upms/service/impl/UpmsQueryServiceImpl.java (17-132)`, `backend/upms/src/main/resources/application.yml (1-18)`)。
* 数据库脚本已提供认证与权限组织核心表(含 `auth.tb_auth_refresh_token``auth.tb_auth_login_audit``upms.tb_sys_*`、班级/文件/站内信相关表),但代码未接入(`init/pg/auth/10_create_auth_tables.sql (1-64)`, `init/pg/upms/10_create_upms_tables.sql (1-437)`)。
* Web 端当前仅保存 accessToken未使用 refreshToken且路由初始化失败时会回退默认路由缺少严格登录守卫`frontend/src/pages/LoginPage.tsx (17-21)`, `frontend/src/utils/storage.ts (1-12)`, `frontend/src/router/AppRouter.tsx (56-83)`)。
* 小程序端仅有 API/request 封装,无登录页、无 token 持久化与鉴权头注入(`app/src/api/auth.js (1-13)`, `app/src/utils/request.js (1-53)`)。
* 目标能力与约束已在模块计划中明确(`docs/plan/modules/auth.md (1-40)`, `docs/plan/modules/upms.md (1-40)`)。
## 实施方案
### 1) 契约与模块边界收口
统一 `auth/upms` 前后端契约与字段口径:
1. `auth` 令牌 claims 扩展为 `userId/tenantId/tenantPath/deptId/deptPath/adcode/roleCodes/clientType/sessionId`,并在 API DTO 中显式化。
2. `upms` 保留现有 5 个查询接口,同时补齐模块计划中已冻结的班级、文件、站内信接口。
3. 将 Web 与 Mini 的端侧约束(`clientType + roleCodes`)纳入统一鉴权输入,避免服务内散落判断。
### 2) 后端 Auth 实现
1. 接入持久层PostgreSQL + MyBatis Plus补齐 `auth` 模块数据源配置与仓储层。
2. 实现真实登录链路:账号/手机号凭据校验、用户状态校验、角色准入校验Mini 仅 `STUDENT`)。
3. 实现 refresh token 持久化与“一次一换”轮换:写入 `tb_auth_refresh_token`、刷新时撤销旧 token 并发放新 token。
4. 实现登录与刷新审计:写入 `tb_auth_login_audit`,记录成功/失败与关键上下文。
5. 加入学生端会话上限策略(上限 3超限淘汰最久未活跃会话
### 3) 后端 UPMS 实现
1. 以数据库查询替换 `UpmsQueryServiceImpl` 静态数据,按租户上下文返回 routes/currentUser/areas/tenants/departments。
2. 建立角色-菜单-权限聚合链路(必要时补齐 `user-role` 关系表与增量 SQL
3. 增加班级、文件、站内信接口实现,统一租户隔离与对象存在性校验。
4. 统一 `upms` 返回结构与前端类型,避免前端做业务字段兜底。
### 4) 前端实现Web + Mini
1. Web重构会话层access + refresh + 当前用户),增加登录守卫与 401 自动刷新/失效登出。
2. Web动态路由加载改为“鉴权成功后加载”移除未登录默认路由回退。
3. Web补齐 `upms` 数据消费页(至少 currentUser/组织树/消息入口)的真实接口接入。
4. Mini补齐登录与 token 持久化request 层注入 `Authorization`,处理 token 续期与失效。
5. Mini`clientType=MINI` + `role=STUDENT` 约束仅开放学生端能力。
### 5) 联调与验收
1.`boot-dev`(本地聚合)与 `gateway + auth + upms`(分布式)两种模式分别完成联调验证。
2. 验证项覆盖:登录/刷新/当前用户、动态路由、组织树、Mini 登录、站内信已读链路。
3. 运行后端编译与前端构建,补充最小接口/服务测试,确认关键鉴权与租户隔离场景通过。
## 交付顺序
建议按“Auth 后端 -> UPMS 后端 -> Web 前端 -> Mini 前端 -> 双模式联调”执行,避免前端先行导致接口反复改动。

38
docs/plan/modules/auth.md Normal file
View File

@@ -0,0 +1,38 @@
# 问题陈述
`auth` 从当前演示版 JWT 发放器升级为可用于 Web 教师/机构端与小程序学生端的统一认证中心,确保令牌生命周期、租户上下文与审计链路可落地。
## 当前状态(已确认)
* 架构与 API 对 `auth` 的约束是登录、刷新、当前用户三类基础接口。参考 `docs/architecture/api-design.md (12-16)``docs/architecture/logical-view.md (7-9)`
* 现有 `AuthController` 仅暴露 `/auth/tokens``/auth/tokens/refresh``/auth/users/current`。参考 `backend/auth/src/main/java/com/k12study/auth/controller/AuthController.java (1-49)`
* `AuthService` 当前为占位实现:默认 `admin`、不校验密码、refresh token 直接复用 access token 生成逻辑。参考 `backend/auth/src/main/java/com/k12study/auth/service/AuthService.java (18-74)`
* `init` 已提供 `auth.tb_auth_refresh_token``auth.tb_auth_login_audit`,但代码尚未接入。参考 `init/pg/auth/10_create_auth_tables.sql (1-54)`
* 当前需求已调整为“学生手机号绑定 + 手动登录”,不使用 openid/微信取号能力;现有代码尚未体现该约束。参考 `docs/AI智能学习系统功能清单.md (1-30)`
## 模块拆分与设计细节
### 1) 子模块边界
* `auth-credential`:账号密码、手机号绑定、手动登录、验证码/密码校验。
* `auth-token`access/refresh 双令牌、旋转、撤销、过期策略。
* `auth-context`:输出标准 claimsuserId/tenantId/tenantPath/deptId/adcode/roleCodes/clientType
* `auth-audit`:登录审计、刷新审计、异常登录行为记录。
### 2) 认证通道设计
* Web机构/教师):账号密码 + 可选验证码。
* Mini学生手机号 + 手动输入凭证登录;“手机号+密码”与“手机号+短信验证码”为并列可选登录主流程,不依赖微信 code/openid。
* 统一返回 token 结构,但按 `clientType` 写入不同策略(如 mini 更短 access TTL、更严格 refresh 轮换)。
* 学生身份主键统一使用平台 `user_id` + 绑定手机号,不引入 openid 作为账号主索引。
### 3) 令牌与隔离策略
* access token 仅短期有效refresh token 必须入库(`tb_auth_refresh_token`并采用“每次刷新强制轮换”策略one-time refresh token与撤销机制。
* claims 必须包含租户上下文与端侧上下文,供 gateway 与业务域执行“租户隔离 + 端能力限制”。
* `currentUser` 不再回退静态默认用户,统一由 token + 用户表读取。
### 4) 与小程序约束联动
* 身份区分以 `role` 为准:小程序仅允许 `STUDENT` 角色登录;`TEACHER/ORG_ADMIN` 角色不发放 mini 可用令牌。
* 为后续“课程仅展示课堂练习/课后作业”提供 `clientType=MINI` + `role=STUDENT` 的稳定判定依据。
* 学生允许在不同家长手机登录,同一学生并发会话上限固定为 `3`,超限时淘汰最久未活跃会话。
## 问题点与风险
* 现有 refresh 流程不安全refresh token 与 access token同构无法实现撤销与会话治理。
* 代码未接 `tb_auth_refresh_token`/`tb_auth_login_audit`,审计与风控链路缺失。
* 若角色体系未在 token 与网关策略中一致生效,会导致小程序端角色越权风险。
* JWT claims 字段不足(缺 tenant_path/adcode/clientType会放大跨租户与跨端越权风险。
* 跨设备登录场景下,若缺少设备会话治理(会话上限/异地提醒/强制下线),共享手机会增加账号滥用风险。
## 已确定的实现决策
* 学生手动登录提供双可选主流程:`手机号+密码``手机号+短信验证码`
* refresh token 采用“每次刷新强制轮换”策略。
* 学生跨多家长手机登录并发会话上限固定为 `3`,超限淘汰最近最久未活跃会话。
* 身份区分以 `role` 为准;小程序仅开放 `STUDENT` 角色,老师与机构角色不开放小程序登录。

View File

@@ -0,0 +1,42 @@
# 问题陈述
设计并拆分 `course` 模块,使其从“仅有 SQL 结构”升级为可支撑教师建课、班级投放、学生学习与小程序差异化可见性的课程域。
## 当前状态(已确认)
* 架构与 API 已定义 `course` 为独立业务域,含课程、章节、知识点、掌握度等接口与数据职责。参考 `docs/architecture/logical-view.md (12-18)``docs/architecture/api-design.md (34-41)`
* 当前 `backend` Maven 模块尚未包含 `course` 服务实现,仅有 `init/pg/course` SQL。参考 `backend/pom.xml (11-20)``init/pg/course/10_create_course_tables.sql (1-521)`
* `course` 表结构已覆盖课程主链路:课程/章节/节点/资源/知识点/学习会话/进度/事件,并内置节点类型 `IN_CLASS_ACTIVITY``AFTER_CLASS_TASK``MATERIAL`。参考 `init/pg/course/10_create_course_tables.sql (63-78)``init/pg/course/10_create_course_tables.sql (323-349)`
* 小程序端当前仍是骨架页,未接课程业务。参考 `app/src/app.json (1-13)``app/src/pages/home/index.js (1-6)`
## 模块拆分与设计细节
### 1) 子模块边界
* `course-core`:课程、章节、节点、发布状态(单版本模型,不引入发布快照版本化)。
* `course-resource`:节点资源与文件引用。
* `course-knowledge`:知识点、章节/节点关联、掌握度回写。
* `course-learning`:学习会话、学习进度、学习事件。
* `course-delivery`:课程对班级投放与可见规则(与 `upms-class` 联动)。
### 2) 课程可见性与小程序约束
* 教师端可管理全部节点类型(含 `MATERIAL/LESSON` 课件类节点)。
* 小程序端仅 `STUDENT` 角色可访问课程能力,且 `LESSON` 节点在小程序全部隐藏。
* 端侧可见性采用“字段内嵌”方案(不使用独立策略表):
* 节点级字段:`visible_for_web_teacher``visible_for_mini_student`
* 资源级字段:`resource_visible_scope`
* 课程查询接口按 `clientType + roleCodes + classMembership` 组合过滤。
### 3) 与班级和作业联动
* 章节节点点击查询固定两步:
1) 查询课件(`course.cl_node_resource`,小程序端不执行该步)。
2) 查询课堂练习(`course.cl_node_homework_rel` + `question.hw_assignment``relation_type=IN_CLASS`)。
* 课后作业由作业入口独立查询,不并入章节节点点击查询结果。
* 课程投放以 `upms.tb_school_class_course_rel` 为准,课程域不重复维护班级主数据。
* `course.cl_node_homework_rel``question.hw_assignment` 建立稳定联查规范,统一“课堂练习/课后作业”来源。
* 学习进度、知识点掌握度更新时强制校验学生是否属于课程投放班级。
### 4) 租户隔离策略
* 全量查询与写入以 `tenant_id` 强约束,并在需要层级权限时附加 `tenant_path/dept_path`
* 对跨租户课程引用(节点关联作业、资源文件)做一致性校验。
## 问题点与风险
* 目前没有 `course` 运行服务与 API实现成本集中在“从 0 到 1”。
* 节点与资源的端侧可见性字段尚未实际落表,首期需补齐增量 SQL 与索引。
* 种子数据为空,联调期将缺少可复现演示样本。参考 `init/pg/course/20_init_course_seed.sql (1-2)`
* 课程与班级关系存在跨模块依赖,若不先固定 authoritative source容易出现双写冲突。
## 已确定的实现决策
* 端侧可见性采用“字段内嵌”方案。
* 章节节点点击查询采用两步:查询课件(小程序不查)+ 查询课堂练习。
* `LESSON` 节点在小程序全部隐藏。
* 课程发布模型不做版本化采用单版本状态流转DRAFT/PUBLISHED/ARCHIVED

View File

@@ -0,0 +1,32 @@
# 问题陈述
`gateway` 从“仅转发 auth/upms 的静态网关”升级为全域统一接入层,负责鉴权透传、租户隔离防线、端侧能力边界控制(尤其小程序学生端)。
## 当前状态(已确认)
* 架构定义中网关应承接 auth/upms/course/question/achievement/recommendation 多域入口。参考 `docs/architecture/logical-view.md (24-41)`
* 现有网关路由仅配置 `auth``upms` 两条,且目标地址硬编码 localhost。参考 `backend/gateway/src/main/resources/application.yml (1-30)`
* `JwtRelayFilter` 仅做 token 校验并透传 `X-User-Id/X-Username/X-Display-Name/X-Tenant-Id/X-Dept-Id`。参考 `backend/gateway/src/main/java/com/k12study/gateway/filter/JwtRelayFilter.java (1-79)``backend/common/common-core/src/main/java/com/k12study/common/core/constants/SecurityConstants.java (1-13)`
* API 设计要求对外统一 `/api/*`,且后续需扩展到 course/question/ai。参考 `docs/architecture/api-design.md (1-82)`
## 模块拆分与设计细节
### 1) 子模块边界
* `gw-routing`:静态路由基线 + 服务发现路由(后续可接 nacos
* `gw-authn`JWT 解析、白名单、令牌有效性检查。
* `gw-authz`:基于角色/权限码的接口级访问控制。
* `gw-tenant-guard`:租户上下文一致性校验与越权拦截。
* `gw-client-policy`:按 `clientType + roleCodes` 执行端侧能力约束Web 与 Mini 分流,且角色可见能力分层)。
### 2) 路由与协议策略
* 扩展路由:`/api/course/**``/api/question/**``/api/ai/**`(以及后续 achievement
* 统一注入:`traceId`、用户上下文、端侧上下文,确保下游服务具备完整鉴权条件。
* 统一错误响应结构:`code/message/data/traceId`,避免当前 401 响应与标准不一致。
### 3) 租户隔离与端侧约束
* 对路径参数、query、body 中 `tenant_id/class_id/course_id` 做一致性校验(与 token tenant 比对)。
* 对小程序令牌执行角色准入:仅 `clientType=MINI` 且含 `STUDENT` 角色可访问;`TEACHER/ORG_ADMIN` 角色直接拒绝 mini 端访问。
* 对小程序学生令牌在网关层执行“只允许课堂练习/课后作业相关 API”屏蔽课件/资料类接口。
* 为图检索与向量检索入口统一加租户隔离过滤(至少 tenant_id必要时 tenant_path + class scope
## 问题点与风险
* 当前仅透传 `tenant_id`,缺 tenant_path/adcode/clientType难以实现层级隔离与端侧细分授权。
* 路由静态且服务少course/question/ai 接入后若不先做网关收口,跨域鉴权规则会分散在业务服务。
* 401 响应未附 traceId排障链路不完整。
* 白名单仅按路径匹配,缺 method 维度与来源限制,存在误开放风险。
## 需确认的设计决策
* 首期是否在网关做“端侧能力强约束”还是只做鉴权透传(推荐首期即强约束)。
* 路由配置是否直接切到配置中心统一管理,还是先保持本地静态配置再迁移。
* 租户校验失败返回码与错误语义401/403/422统一口径。

View File

@@ -0,0 +1,38 @@
# 问题陈述
`question` 拆分为可演进的习题/作业/批改核心域,并在同一计划中处理错题复习、费曼讲解、推荐闭环与小程序学生端作业可见规则。
## 当前状态(已确认)
* 架构与 API 已定义 `question` 为核心业务域,覆盖题库、作业、提交、批改、复习。参考 `docs/architecture/api-design.md (42-61)``docs/architecture/logical-view.md (58-66)`
* 当前后端尚无 `question` 服务代码,能力主要体现在 `init/pg/question` 表结构。参考 `backend/pom.xml (11-20)``init/pg/question/10_create_question_tables.sql (1-1272)`
* SQL 已覆盖题库/作业/批改/错题/复习/讲解评估,并把推荐与画像并入 `question.rc_*`。参考 `init/pg/question/10_create_question_tables.sql (1041-1272)`
* 复习策略已有租户级默认种子E1-E6。参考 `init/pg/question/20_init_question_seed.sql (8-56)`
* 功能清单对错题、变式题、费曼评估、复习提醒、推荐闭环都有明确诉求。参考 `docs/AI智能学习系统功能清单.md (11-24)``docs/AI智能学习系统功能清单.md (91-108)`
## 模块拆分与设计细节
### 1) 子模块边界
* `question-bank`:题库、题目、题目知识点关联。
* `question-assignment`:试卷、作业、投放对象、提交与答案。
* `question-grading`:批改任务、答案评分、错因标签、知识点分析。
* `question-review`:错题沉淀、艾宾浩斯计划与执行。
* `question-explanation`:费曼讲解提交、评估与维度评分。
* `question-recommendation-bridge`:保留 `rc_*` 闭环数据(中期可再独立 recommendation 服务)。
### 2) 小程序学生端规则
* 小程序仅面向学生,`assignment` 查询必须限制为“学生所在班级 + 已发布 + 节点类型为课堂练习/课后作业”。
* 对应教师创建课程中的课件节点,不在学生端作业列表和详情返回。
* 通过 `course.cl_node_homework_rel.relation_type(IN_CLASS/AFTER_CLASS)` 与班级成员关系联合过滤。
### 3) 数据与契约修正
* 强化 `hw_assignment_target.target_ref_id` 的对象完整性(按 `target_type` 校验 class_id/student_id 存在且同租户)。
* 补充“变式题生成任务”数据结构(任务、来源错题、生成题、命中策略),连接功能清单中的举一反三链路。
* `gd_answer_grade` 继续作为统一批改结果主表,避免客观/主观分表回退。
* 推荐闭环暂存于 `question` 时,需定义清晰子包边界,避免与习题核心耦合扩散。
### 4) 租户隔离策略
* 所有 `hw_* / gd_* / rc_*` 读写统一按 `tenant_id` 强过滤。
* 跨表关联submission->student、assignment->class、recommendation->user均执行租户一致性断言。
* 对 AI 生成、图检索、向量检索调用记录 tenant 维度与来源对象,便于追溯越权风险。
## 问题点与风险
* `question` 当前“超大域”承载习题+复习+讲解+推荐,若不先做包级拆分,后续维护成本会快速上升。
* 目前无运行时代码,尽管 SQL 完整,但缺业务规则执行层,容易出现“表有字段、行为缺失”。
* `target_ref_id` 弱关联容易引发错投放与越权读取。
* 功能清单的变式题追踪链路在现有表中仍缺专门任务模型。
## 需确认的设计决策
* 推荐相关 `rc_*` 是短期保留在 `question`,还是本阶段即拆到 `recommendation` 独立服务。
* 变式题生成首期是否强依赖 AI 服务,或先以规则模板生成保障可交付。
* 费曼评估通过阈值与回流规则(与系统文档口径)是否纳入一期强约束。

39
docs/plan/modules/upms.md Normal file
View File

@@ -0,0 +1,39 @@
# 问题陈述
`upms` 从当前“基础演示能力”拆分为可支撑多租户教学业务的基础域模块,承接组织权限、班级关系、文件资源、站内信,并为 `auth/course/question/gateway` 提供统一主体与隔离上下文。
## 当前状态(已确认)
* 架构文档把 `upms` 定位为租户/组织/用户/角色/菜单的平台能力层,且要求核心表统一保留租户隔离字段。参考 `docs/architecture/logical-view.md (2-13)``docs/architecture/logical-view.md (74-77)`
* API 设计中 `upms` 当前冻结接口仅覆盖 routes/currentUser/areas/tenants/departments文件与消息接口仍为“建议补充”。参考 `docs/architecture/api-design.md (17-29)`
* 代码层 `upms` 仅有查询接口,且服务实现为硬编码返回,未接数据库。参考 `backend/upms/src/main/java/com/k12study/upms/controller/UpmsController.java (1-52)``backend/upms/src/main/java/com/k12study/upms/service/impl/UpmsQueryServiceImpl.java (1-132)``backend/upms/src/main/resources/application.yml (1-18)`
* `init` 已具备班级主表/成员表/班级课程关系、文件表、消息主表与收件人表等基础结构,可在设计阶段调整。参考 `init/pg/upms/10_create_upms_tables.sql (39-434)`
## 模块拆分与设计细节
### 1) 子模块边界
* `upms-identity`:租户、组织、用户、角色、菜单、角色授权。
* `upms-class`:班级、班级成员、班级与课程关系(作为 class authoritative source
* `upms-file`:统一文件元数据、上传登记、跨域引用约束。
* `upms-message`:站内信投递、已读/点击回执、业务跳转。
* `upms-context`向网关与业务域提供用户上下文tenant/dept/roleCodes/clientType
### 2) API 设计增补
* 保留现有 `/api/upms/routes|users/current|areas|tenants|departments`,其中 `routes` 仅用于 Web 端动态路由管理。
* 新增并冻结:
* 班级:`/api/upms/classes``/api/upms/classes/{id}/members``/api/upms/classes/{id}/courses`
* 文件:`POST /api/upms/files/upload``GET /api/upms/files/{fileId}`
* 消息:`GET /api/upms/messages/inbox``POST /api/upms/messages/{messageId}/read`
* 小程序端当前不做动态路由管理;仅保留业务可见性相关查询能力(如课堂练习/课后作业可见对象)。
### 3) 数据与隔离策略
* 统一强制查询键:`tenant_id + (tenant_path/dept_path)`;所有列表与详情接口默认附加租户过滤。
* 身份区分以角色体系为准:在 `upms` 侧补齐用户-角色映射(建议新增 `tb_sys_user_role`),以 `role` 作为鉴权与端侧准入主依据;`client_scope` 用于端类型控制。
*`tb_sys_user` 增补手机号绑定字段(如 `mobile_phone/mobile_verified_at/mobile_bind_status`);学生手机号在租户内唯一,用于手动登录主凭据。
* 小程序端仅开放 `STUDENT` 角色;`TEACHER/ORG_ADMIN` 角色不开放小程序登录授权。
* `tb_school_class_course_rel` 增补可见性配置(如 `mini_visible_scope`),用于后续小程序“仅课堂练习+课后作业”过滤联动。
* 为消息与文件接口建立“跨租户不可见 + 对象存在性校验”统一拦截器。
## 问题点与风险
* 现状 `upms` 返回硬编码数据,和 SQL 实体脱节,后续联调会出现“接口有值但数据库无一致性”风险。
* 现有上下文仅传 `tenant_id/dept_id`,缺 `tenant_path/adcode/roleCodes/clientType`,不利于层级隔离与端侧差异控制。参考 `backend/common/common-core/src/main/java/com/k12study/common/core/constants/SecurityConstants.java (1-13)`
* 班级实体虽已落表,但缺 API 与领域服务,`question` 当前仍需通过弱关联字段投放对象,存在错投/越权风险。
* 站内信与文件在产品链路中为关键公共能力,但目前仅有表结构和种子,缺完整业务闭环。
*`tb_sys_user` 缺手机号唯一约束与换绑审计,会放大共享设备场景下的账号冒用与追责困难。
## 已确定的实现决策
* 班级由 `upms` 完全统一托管,其他业务域仅通过 `class_id` 关联。
* `roleCodes/clientType` 放入 JWT claims同时在 `upms` 角色关系中保留权威数据。
* `upms` 仅承担 Web 端路由资源管理;小程序端当前不做动态路由。
* 学生手机号换绑不启用“原手机号确认 + 班级归属校验 + 冷静期”三段式流程,采用手机号验证或线下联系老师处理。

View File

@@ -0,0 +1,39 @@
# 问题陈述
为“向量知识库”单独建立可实施设计,补齐向量入库、切片、检索、审计与租户隔离能力,并与图谱知识库形成明确分工与协同。
## 当前状态(已确认)
* 架构文档将 Milvus 设为向量检索主方案。参考 `docs/architecture/base-services.md (13-16)``docs/architecture.md (8-13)`
* 现有 AI 表仅包含 `vector_sync_status` 字段与 `target_store=VECTOR` 同步任务,不含向量文档/切片/检索日志实体。参考 `init/pg/ai/10_create_ai_tables.sql (13-15)``init/pg/ai/10_create_ai_tables.sql (77-104)`
* 种子数据已演示 GRAPH 与 VECTOR 双任务并存,但仍是占位任务。参考 `init/pg/ai/20_init_ai_seed.sql (46-76)`
* `python-ai``ai-client` 当前仅有健康检查能力,未实现向量入库与检索接口。参考 `backend/python-ai/app/main.py (1-13)``backend/ai-client/src/main/java/com/k12study/aiclient/client/PythonAiClient.java (1-7)`
## 模块拆分与设计细节
### 1) 子模块边界
* `vector-ingestion`文档清洗、切片、embedding 生成、重建任务。
* `vector-storage`:向量文档、切片元数据、索引状态管理。
* `vector-retrieval`:语义检索、召回重排、结果解释。
* `vector-governance`:模型版本、数据有效期、删除回收、审计日志。
### 2) 入库策略(按你给定口径)
* 总部知识库:进入向量 + 图谱。
* 教师班级课程/课件:仅图谱,不进入向量。
* 在知识文件层配置 `ingest_policy`BOTH/GRAPH_ONLY向量任务创建器仅对 `BOTH` 生成向量任务。
### 3) 数据模型建议(可改 `init` SQL
* 新增 `ai.tb_ai_vector_document`文档级元数据tenant/scope/model_version/index_status
* 新增 `ai.tb_ai_vector_chunk`切片文本、chunk_order、token_count、embedding_ref。
* 新增 `ai.tb_ai_vector_index_task`:索引构建/重建任务与错误重试。
* 新增 `ai.tb_ai_retrieval_log`查询语句、召回范围、tenant_id、命中文档、耗时、调用端。
*`tb_ai_knowledge_file` 建立一对多映射,保留来源追溯。
### 4) 租户隔离与端侧约束
* 向量检索必须按 `tenant_id + knowledge_scope` 过滤;默认禁止跨租户召回。
* 班级场景增加 `class_id/course_id` 过滤,避免跨班级内容泄漏。
* 小程序学生端检索结果仅允许返回课堂练习/课后作业关联内容,不返回教师课件全文。
### 5) 与图谱协同
* 支持“图谱先召回候选 -> 向量精排”或“向量召回 -> 图谱关系扩展”双阶段策略。
* 检索响应统一返回来源对象(课程/题目/知识点),交由业务域二次鉴权展示。
## 问题点与风险
* 当前缺少向量核心表结构,无法稳定支撑入库追踪、检索审计与故障重建。
* 未定义 embedding 模型版本与重建策略,后续模型升级会导致历史索引不可比对。
* 无检索日志会导致“召回错误/越权返回”难以追溯。
* 若不先固化入库策略HQ/教师内容边界容易在实现时被混用。
## 需确认的设计决策
* 首期是否先做“单向量模型 + 单索引集合”简化方案,再演进多模型路由。
* 向量检索与图检索的融合策略首期采用串行还是并行(推荐串行,便于控制复杂度)。
* 检索日志保留周期与脱敏策略(涉及学生查询内容合规)。

View File

@@ -0,0 +1,36 @@
# 问题陈述
按“图谱知识库”独立模块设计知识抽取与图检索链路,明确总部与教师内容入库策略,并把租户隔离作为图检索默认约束。
## 当前状态(已确认)
* 架构层已定义图数据库主选 NebulaGraphAI 链路为业务服务 -> ai-client -> python-ai -> 图谱/向量。参考 `docs/architecture/base-services.md (9-16)``docs/architecture/logical-view.md (50-72)`
* AI SQL 已有图谱实体、关系、同步任务等基础结构,且同步任务支持 `target_store=GRAPH|VECTOR`。参考 `init/pg/ai/10_create_ai_tables.sql (1-215)`
* 现有 `python-ai` 仅暴露 `/health`,尚无图谱入库与检索 API。参考 `backend/python-ai/app/main.py (1-13)`
* 功能清单强调知识库用于错题关联课程与内容检索,但当前运行时链路未落地。参考 `docs/AI智能学习系统功能清单.md (91-98)`
## 模块拆分与设计细节
### 1) 子模块边界
* `kg-ingestion`:知识文件到图谱实体/关系抽取、去重、版本管理。
* `kg-model`:实体类型体系(知识点/题目/课程/课件)与关系类型体系。
* `kg-sync`:图谱同步任务编排、重试、失败补偿。
* `kg-retrieval`:图检索 API按场景返回知识点路径、关联题、关联课程
* `kg-governance`:内容审核、来源追踪、删除回收策略。
### 2) 入库策略(按你给定口径)
* 知识库分两类:图谱知识库、向量知识库。
* 总部创建知识库:同时进入图谱 + 向量。
* 教师班级课程/课件内容:仅进入图谱,不进入向量。
* 设计实现:在 `tb_ai_knowledge_file` 增加 `source_owner_type`HQ/TEACHER`knowledge_scope`GLOBAL/TENANT/CLASS/COURSE`ingest_policy`BOTH/GRAPH_ONLY同步任务根据策略自动生成。
### 3) 图检索与租户隔离
* 图检索默认过滤 `tenant_id`,并在班级场景附加 `class_id/course_id` 可见域约束。
* 总部全局知识仅在授权租户可见,禁止跨租户回查实体细节。
* 返回结果附带 `source_table/source_pk` 以支持业务回查,但回查前再次执行租户一致性校验。
### 4) 与业务域联动
* `course`:知识点树与课程节点建立双向可追溯引用。
* `question`:题目-知识点关系可回写图谱关系,支持错题知识路径解释。
* `upms`:复用班级与租户结构进行图谱可见域控制。
## 问题点与风险
* 目前仅有表结构,图谱入库/检索服务未实现,链路停留在设计层。
* 现有 AI 表缺少“来源归属与入库策略”显式字段,无法直接表达 HQ 双入库、教师图谱单入库规则。
* 没有图检索访问日志与越权审计,难以定位租户隔离问题。
* python-ai 未落地图数据库客户端与查询协议,接口冻结前需先定义查询契约。
## 需确认的设计决策
* 图检索首期采用“模板化查询 + 场景参数”还是开放式图查询 DSL推荐模板化
* 总部知识对分校是否默认可见,或需要租户级授权开关。
* 图谱实体冲突(同名知识点跨租户)采用“租户隔离命名空间”还是“全局实体+租户可见边”模型。

5
docs/plan/upms.md Normal file
View File

@@ -0,0 +1,5 @@
/plan 实现upms的所有功能 要支持excel导入导出使用F:\Project\K12Study\urbanLifeline\urbanLifelineServ\common\common-utils\src\main\java\org\xyzh\common\utils\excel
和F:\Project\K12Study\urbanLifeline\urbanLifelineServ\common\common-utils\src\main\java\org\xyzh\common\utils\validation进行参数建议
注意实现定时任务的部分和站信
注意使用swagger注释
注意使用代码注释以及log日志

View File

@@ -15,9 +15,11 @@
"react-router-dom": "^6.30.1" "react-router-dom": "^6.30.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.6.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.8.0", "@vitejs/plugin-react-swc": "^3.8.0",
"sass-embedded": "^1.99.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.2.0" "vite": "^6.2.0"
} }

View File

@@ -1,4 +1,4 @@
import { AppRouter } from "./router/AppRouter"; import { AppRouter } from "@/router";
export default function App() { export default function App() {
return <AppRouter />; return <AppRouter />;

View File

@@ -1,20 +1,29 @@
import type { ApiResponse } from "../types/api"; import type { ApiResponse } from "@/types";
import { http } from "../utils/http"; import { http } from "@/utils";
export interface LoginInput {
interface LoginInput {
username: string; username: string;
password: string; password: string;
mobile?: string;
smsCode?: string;
provinceCode: string; provinceCode: string;
areaCode: string; areaCode: string;
tenantId: string; tenantId: string;
clientType?: "WEB" | "MINI";
}
export interface TokenPayload {
accessToken: string;
refreshToken: string;
tokenType: string;
expiresIn: number;
} }
export async function login(input: LoginInput) { export async function login(input: LoginInput) {
return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/tokens", input); return http.post<ApiResponse<TokenPayload>>("/auth/tokens", input);
} }
export async function refreshToken(refreshToken: string) { export async function refreshToken(refreshToken: string) {
return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/tokens/refresh", { return http.post<ApiResponse<TokenPayload>>("/auth/tokens/refresh", {
refreshToken refreshToken
}); });
} }

View File

@@ -0,0 +1,2 @@
export * from "./auth";
export * from "./upms";

View File

@@ -1,12 +1,17 @@
import { import type { ApiResponse, CurrentRouteUser, RouteNode } from "@/types";
getUpmsAreasRemote, import type {
getUpmsCurrentUserRemote, UpmsAreaNode,
getUpmsDepartmentsRemote, UpmsClass,
getUpmsRoutesRemote, UpmsClassCourse,
getUpmsTenantsRemote UpmsClassMember,
} from "../remote/upmsRemote"; UpmsDeptNode,
import type { CurrentRouteUser, RouteNode } from "../types/route"; UpmsFileMetadata,
import type { UpmsAreaNode, UpmsDeptNode, UpmsTenantNode } from "../types/upms"; UpmsFileUploadRequest,
UpmsInboxMessage,
UpmsMessageReadResult,
UpmsTenantNode
} from "@/types/upms";
import { http } from "@/utils";
function normalizeAreaNodes(nodes: UpmsAreaNode[]): UpmsAreaNode[] { function normalizeAreaNodes(nodes: UpmsAreaNode[]): UpmsAreaNode[] {
return nodes.map((node) => ({ return nodes.map((node) => ({
@@ -30,26 +35,67 @@ function normalizeDeptNodes(nodes: UpmsDeptNode[]): UpmsDeptNode[] {
} }
export async function fetchDynamicRoutes(): Promise<RouteNode[]> { export async function fetchDynamicRoutes(): Promise<RouteNode[]> {
const response = await getUpmsRoutesRemote(); const response = await http.get<ApiResponse<RouteNode[]>>("/upms/routes");
return response.data as RouteNode[]; return response.data as RouteNode[];
} }
export async function fetchCurrentUser(): Promise<CurrentRouteUser> { export async function fetchCurrentUser(): Promise<CurrentRouteUser> {
const response = await getUpmsCurrentUserRemote(); const response = await http.get<ApiResponse<CurrentRouteUser>>("/upms/users/current");
return response.data as CurrentRouteUser; return response.data as CurrentRouteUser;
} }
export async function fetchAreas(): Promise<UpmsAreaNode[]> { export async function fetchAreas(): Promise<UpmsAreaNode[]> {
const response = await getUpmsAreasRemote(); const response = await http.get<ApiResponse<UpmsAreaNode[]>>("/upms/areas");
return normalizeAreaNodes(response.data as UpmsAreaNode[]); return normalizeAreaNodes(response.data as UpmsAreaNode[]);
} }
export async function fetchTenants(): Promise<UpmsTenantNode[]> { export async function fetchTenants(): Promise<UpmsTenantNode[]> {
const response = await getUpmsTenantsRemote(); const response = await http.get<ApiResponse<UpmsTenantNode[]>>("/upms/tenants");
return normalizeTenantNodes(response.data as UpmsTenantNode[]); return normalizeTenantNodes(response.data as UpmsTenantNode[]);
} }
export async function fetchDepartments(): Promise<UpmsDeptNode[]> { export async function fetchDepartments(): Promise<UpmsDeptNode[]> {
const response = await getUpmsDepartmentsRemote(); const response = await http.get<ApiResponse<UpmsDeptNode[]>>("/upms/departments");
return normalizeDeptNodes(response.data as UpmsDeptNode[]); return normalizeDeptNodes(response.data as UpmsDeptNode[]);
} }
export async function fetchClasses(): Promise<UpmsClass[]> {
const response = await http.get<ApiResponse<UpmsClass[]>>("/upms/classes");
return response.data as UpmsClass[];
}
export async function fetchClassMembers(classId: string): Promise<UpmsClassMember[]> {
const response = await http.get<ApiResponse<UpmsClassMember[]>>(
`/upms/classes/${encodeURIComponent(classId)}/members`
);
return response.data as UpmsClassMember[];
}
export async function fetchClassCourses(classId: string): Promise<UpmsClassCourse[]> {
const response = await http.get<ApiResponse<UpmsClassCourse[]>>(
`/upms/classes/${encodeURIComponent(classId)}/courses`
);
return response.data as UpmsClassCourse[];
}
export async function uploadFileMetadata(request: UpmsFileUploadRequest): Promise<UpmsFileMetadata> {
const response = await http.post<ApiResponse<UpmsFileMetadata>>("/upms/files/upload", request);
return response.data as UpmsFileMetadata;
}
export async function fetchFileMetadata(fileId: string): Promise<UpmsFileMetadata> {
const response = await http.get<ApiResponse<UpmsFileMetadata>>(`/upms/files/${encodeURIComponent(fileId)}`);
return response.data as UpmsFileMetadata;
}
export async function fetchInboxMessages(): Promise<UpmsInboxMessage[]> {
const response = await http.get<ApiResponse<UpmsInboxMessage[]>>("/upms/messages/inbox");
return response.data as UpmsInboxMessage[];
}
export async function markInboxMessageRead(messageId: string): Promise<UpmsMessageReadResult> {
const response = await http.post<ApiResponse<UpmsMessageReadResult>>(
`/upms/messages/${encodeURIComponent(messageId)}/read`
);
return response.data as UpmsMessageReadResult;
}

View File

@@ -0,0 +1,18 @@
.app-card {
border-radius: 20px;
background: #ffffff;
padding: 24px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.08);
}
.app-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.app-card__body {
color: #334155;
line-height: 1.6;
}

View File

@@ -1,4 +1,5 @@
import type { PropsWithChildren, ReactNode } from "react"; import type { PropsWithChildren, ReactNode } from "react";
import "./index.scss";
type AppCardProps = PropsWithChildren<{ type AppCardProps = PropsWithChildren<{
title: string; title: string;

View File

@@ -0,0 +1,6 @@
.loading-view {
display: grid;
place-items: center;
min-height: 100vh;
color: #5b6475;
}

View File

@@ -1,3 +1,5 @@
import "./index.scss";
export function LoadingView({ message = "Loading..." }: { message?: string }) { export function LoadingView({ message = "Loading..." }: { message?: string }) {
return <div className="loading-view">{message}</div>; return <div className="loading-view">{message}</div>;
} }

View File

@@ -0,0 +1,2 @@
export * from "./AppCard";
export * from "./LoadingView";

View File

@@ -0,0 +1,10 @@
.default-layout {
min-height: 100vh;
padding: 24px;
}
@media (max-width: 768px) {
.default-layout {
padding: 16px;
}
}

View File

@@ -1,4 +1,5 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import "./index.scss";
export function DefaultLayout() { export function DefaultLayout() {
return ( return (

View File

@@ -0,0 +1,44 @@
.shell {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
.shell__sidebar {
padding: 24px;
background: #111827;
color: #f9fafb;
}
.shell__brand {
font-size: 24px;
font-weight: 700;
}
.shell__hint {
margin-top: 12px;
color: #94a3b8;
}
.shell__content {
padding: 24px;
}
.shell__header {
margin-bottom: 16px;
color: #475569;
}
@media (max-width: 768px) {
.shell {
grid-template-columns: 1fr;
}
.shell__sidebar {
padding: 16px;
}
.shell__content {
padding: 16px;
}
}

View File

@@ -1,4 +1,5 @@
import { Outlet, useLocation } from "react-router-dom"; import { Outlet, useLocation } from "react-router-dom";
import "./index.scss";
export function SidebarLayout() { export function SidebarLayout() {
const location = useLocation(); const location = useLocation();

View File

@@ -0,0 +1,2 @@
export * from "./DefaultLayout";
export * from "./SidebarLayout";

View File

@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App"; import App from "@/App";
import "./styles/app.css"; import "@/styles/index.scss";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>

View File

@@ -1,9 +0,0 @@
import { AppCard } from "../components/AppCard";
export function DashboardPage() {
return (
<AppCard title="项目骨架已就绪">
<p></p>
</AppCard>
);
}

View File

@@ -0,0 +1,32 @@
.dashboard-grid {
display: grid;
gap: 16px;
}
.dashboard-list {
margin: 0;
padding-left: 16px;
display: grid;
gap: 8px;
}
.dashboard-message-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dashboard-message-item button {
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
padding: 4px 10px;
cursor: pointer;
}
.dashboard-error {
margin: 0;
color: #dc2626;
font-size: 14px;
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from "react";
import { fetchClasses, fetchCurrentUser, fetchInboxMessages, markInboxMessageRead } from "@/api";
import { AppCard } from "@/components";
import type { CurrentRouteUser, UpmsClass, UpmsInboxMessage } from "@/types";
import "./index.scss";
export function DashboardPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentUser, setCurrentUser] = useState<CurrentRouteUser | null>(null);
const [classes, setClasses] = useState<UpmsClass[]>([]);
const [messages, setMessages] = useState<UpmsInboxMessage[]>([]);
useEffect(() => {
let active = true;
Promise.all([fetchCurrentUser(), fetchClasses(), fetchInboxMessages()])
.then(([user, classList, inbox]) => {
if (!active) {
return;
}
setCurrentUser(user);
setClasses(classList);
setMessages(inbox);
})
.catch((exception) => {
if (!active) {
return;
}
setError(exception instanceof Error ? exception.message : "加载控制台数据失败");
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, []);
const unreadCount = useMemo(
() => messages.filter((message) => message.readStatus === "UNREAD").length,
[messages]
);
async function handleMarkRead(messageId: string) {
try {
const result = await markInboxMessageRead(messageId);
setMessages((previous) =>
previous.map((message) =>
message.messageId === messageId
? { ...message, readStatus: result.readStatus, readAt: result.readAt }
: message
)
);
} catch (exception) {
setError(exception instanceof Error ? exception.message : "站内信已读操作失败");
}
}
return (
<div className="dashboard-grid">
<AppCard title="当前登录用户">
{loading ? <p>...</p> : null}
{currentUser ? (
<ul className="dashboard-list">
<li>{currentUser.displayName}{currentUser.username}</li>
<li>{currentUser.tenantId}</li>
<li>{currentUser.deptId}</li>
<li>{currentUser.permissionCodes.length}</li>
</ul>
) : null}
</AppCard>
<AppCard title={`班级概览(${classes.length}`}>
{loading ? <p>...</p> : null}
{classes.length > 0 ? (
<ul className="dashboard-list">
{classes.slice(0, 5).map((item) => (
<li key={item.classId}>
{item.className}{item.classCode || "未编码"}
</li>
))}
</ul>
) : !loading ? (
<p></p>
) : null}
</AppCard>
<AppCard title={`站内信(未读 ${unreadCount}`}>
{loading ? <p>...</p> : null}
{messages.length > 0 ? (
<ul className="dashboard-list">
{messages.slice(0, 5).map((message) => (
<li key={message.messageId} className="dashboard-message-item">
<span>
[{message.readStatus}] {message.title}
</span>
{message.readStatus === "UNREAD" ? (
<button type="button" onClick={() => handleMarkRead(message.messageId)}>
</button>
) : null}
</li>
))}
</ul>
) : !loading ? (
<p></p>
) : null}
{error ? <p className="dashboard-error">{error}</p> : null}
</AppCard>
</div>
);
}

View File

@@ -1,42 +0,0 @@
import { type FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { login } from "../api/auth";
import { setAccessToken } from "../utils/storage";
export function LoginPage() {
const navigate = useNavigate();
const [form, setForm] = useState({
username: "admin",
password: "admin123",
provinceCode: "330000",
areaCode: "330100",
tenantId: "SCH-HQ"
});
async function handleSubmit(event: FormEvent) {
event.preventDefault();
const response = await login(form);
setAccessToken(response.data.accessToken);
navigate("/");
}
return (
<div className="login-page">
<form className="login-form" onSubmit={handleSubmit}>
<h1>K12Study Admin</h1>
<input
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
placeholder="用户名"
/>
<input
type="password"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
placeholder="密码"
/>
<button type="submit"></button>
</form>
</div>
);
}

View File

@@ -0,0 +1,41 @@
.login-page {
display: grid;
place-items: center;
min-height: 100vh;
}
.login-form {
display: grid;
gap: 12px;
width: min(360px, calc(100vw - 32px));
padding: 32px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
}
.login-form input,
.login-form button {
height: 44px;
padding: 0 14px;
border-radius: 12px;
border: 1px solid #cbd5e1;
}
.login-form button {
border: none;
background: #2563eb;
color: #ffffff;
cursor: pointer;
}
.login-form button:disabled {
opacity: 0.75;
cursor: not-allowed;
}
.login-form__error {
margin: 0;
color: #dc2626;
font-size: 14px;
}

View File

@@ -0,0 +1,62 @@
/**
* @description Web 端登录页;提交表单调用 /auth/tokens,成功后 window.location.replace 触发路由重建以加载动态路由
* @filename index.tsx
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
import { type FormEvent, useState } from "react";
import { login } from "@/api";
import { setTokens } from "@/utils";
import "./index.scss";
export function LoginPage() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState({
username: "admin",
password: "admin123",
provinceCode: "330000",
areaCode: "330100",
tenantId: "SCH-HQ",
clientType: "WEB" as const
});
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const response = await login(form);
setTokens(response.data.accessToken, response.data.refreshToken);
window.location.replace("/");
} catch (exception) {
setError(exception instanceof Error ? exception.message : "登录失败,请稍后重试");
} finally {
setSubmitting(false);
}
}
return (
<div className="login-page">
<form className="login-form" onSubmit={handleSubmit}>
<h1>K12Study Admin</h1>
<input
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
placeholder="用户名"
/>
<input
type="password"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
placeholder="密码"
/>
{error ? <p className="login-form__error">{error}</p> : null}
<button type="submit" disabled={submitting}>
{submitting ? "登录中..." : "登录"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,6 @@
.not-found {
display: grid;
place-items: center;
min-height: 100vh;
color: #5b6475;
}

View File

@@ -1,4 +1,5 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import "./index.scss";
export function NotFoundPage() { export function NotFoundPage() {
return ( return (

View File

@@ -1,13 +0,0 @@
import { useLocation } from "react-router-dom";
import { AppCard } from "../components/AppCard";
export function RoutePlaceholderPage() {
const location = useLocation();
return (
<AppCard title="动态路由占位页">
<p>{location.pathname}</p>
<p></p>
</AppCard>
);
}

View File

@@ -0,0 +1,7 @@
.route-placeholder p {
margin: 0 0 8px;
}
.route-placeholder p:last-child {
margin-bottom: 0;
}

View File

@@ -0,0 +1,16 @@
import { AppCard } from "@/components";
import { useLocation } from "react-router-dom";
import "./index.scss";
export function RoutePlaceholderPage() {
const location = useLocation();
return (
<div className="route-placeholder">
<AppCard title="动态路由占位页">
<p>{location.pathname}</p>
<p></p>
</AppCard>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export * from "./DashboardPage";
export * from "./LoginPage";
export * from "./NotFoundPage";
export * from "./RoutePlaceholderPage";

View File

@@ -1,23 +0,0 @@
import type { ApiResponse } from "../types/api";
import type { UpmsAreaNode, UpmsCurrentUser, UpmsDeptNode, UpmsRouteNode, UpmsTenantNode } from "../types/upms";
import { http } from "../utils/http";
export function getUpmsRoutesRemote() {
return http.get<ApiResponse<UpmsRouteNode[]>>("/upms/routes");
}
export function getUpmsCurrentUserRemote() {
return http.get<ApiResponse<UpmsCurrentUser>>("/upms/users/current");
}
export function getUpmsAreasRemote() {
return http.get<ApiResponse<UpmsAreaNode[]>>("/upms/areas");
}
export function getUpmsTenantsRemote() {
return http.get<ApiResponse<UpmsTenantNode[]>>("/upms/tenants");
}
export function getUpmsDepartmentsRemote() {
return http.get<ApiResponse<UpmsDeptNode[]>>("/upms/departments");
}

View File

@@ -1,90 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { Navigate, type RouteObject, useRoutes } from "react-router-dom"; import { useRoutes } from "react-router-dom";
import { fetchDynamicRoutes } from "../api/upms"; import { useDynamicRouterData } from "./dynamic-router";
import { LoadingView } from "../components/LoadingView"; import { renderAppRoutes } from "./router-renderer";
import { DefaultLayout } from "../layouts/DefaultLayout";
import { SidebarLayout } from "../layouts/SidebarLayout";
import { DashboardPage } from "../pages/DashboardPage";
import { LoginPage } from "../pages/LoginPage";
import { NotFoundPage } from "../pages/NotFoundPage";
import { RoutePlaceholderPage } from "../pages/RoutePlaceholderPage";
import type { RouteNode } from "../types/route";
const layoutRegistry = {
DEFAULT: DefaultLayout,
SIDEBAR: SidebarLayout
};
function toChildRoute(route: RouteNode): RouteObject {
const Component = route.component === "dashboard" ? DashboardPage : RoutePlaceholderPage;
return {
path: route.path === "/" ? "" : route.path.replace(/^\//, ""),
element: <Component />
};
}
function buildRoutes(dynamicRoutes: RouteNode[]): RouteObject[] {
const grouped = dynamicRoutes.reduce<Record<string, RouteNode[]>>((acc, route) => {
acc[route.layout] ??= [];
acc[route.layout].push(route);
return acc;
}, {});
const layoutRoutes = Object.entries(grouped).map(([layout, routes]) => {
const Layout = layoutRegistry[layout as keyof typeof layoutRegistry] ?? DefaultLayout;
return {
path: "/",
element: <Layout />,
children: routes.map(toChildRoute)
} satisfies RouteObject;
});
return [
{ path: "/login", element: <LoginPage /> },
...layoutRoutes,
{ path: "*", element: <NotFoundPage /> }
];
}
export function AppRouter() { export function AppRouter() {
const [dynamicRoutes, setDynamicRoutes] = useState<RouteNode[]>([]); const routerData = useDynamicRouterData();
const [loading, setLoading] = useState(true); const routes = useMemo(
() => renderAppRoutes(routerData),
useEffect(() => { [routerData.authed, routerData.loading, routerData.loadError, routerData.dynamicRoutes]
fetchDynamicRoutes() );
.then((routes) => setDynamicRoutes(routes))
.catch(() =>
setDynamicRoutes([
{
id: "dashboard",
path: "/",
name: "dashboard",
component: "dashboard",
layout: "SIDEBAR",
meta: {
title: "控制台",
hidden: false,
permissionCodes: ["dashboard:view"]
},
children: []
}
])
)
.finally(() => setLoading(false));
}, []);
const routes = useMemo(() => {
if (loading) {
return [{ path: "*", element: <LoadingView message="正在加载路由..." /> }];
}
const nextRoutes = buildRoutes(dynamicRoutes);
if (dynamicRoutes.length === 0) {
nextRoutes.unshift({ path: "/", element: <Navigate to="/login" replace /> });
}
return nextRoutes;
}, [dynamicRoutes, loading]);
return useRoutes(routes); return useRoutes(routes);
} }

View File

@@ -0,0 +1,48 @@
import { fetchDynamicRoutes } from "@/api";
import { isAuthenticated, signOut } from "@/store";
import type { RouteNode } from "@/types";
import { useEffect, useState } from "react";
export interface DynamicRouterData {
authed: boolean;
loading: boolean;
loadError: boolean;
dynamicRoutes: RouteNode[];
}
export function useDynamicRouterData(): DynamicRouterData {
const authed = isAuthenticated();
const [dynamicRoutes, setDynamicRoutes] = useState<RouteNode[]>([]);
const [loading, setLoading] = useState(authed);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
if (!authed) {
setDynamicRoutes([]);
setLoading(false);
setLoadError(false);
return;
}
setLoading(true);
setLoadError(false);
fetchDynamicRoutes()
.then((routes) => {
setDynamicRoutes(routes);
if (!routes || routes.length === 0) {
setLoadError(true);
}
})
.catch(() => {
setLoadError(true);
signOut();
})
.finally(() => setLoading(false));
}, [authed]);
return {
authed,
loading,
loadError,
dynamicRoutes
};
}

View File

@@ -0,0 +1 @@
export * from "./AppRouter";

View File

@@ -0,0 +1,60 @@
import { LoadingView } from "@/components";
import { DefaultLayout, SidebarLayout } from "@/layouts";
import { DashboardPage, LoginPage, NotFoundPage, RoutePlaceholderPage } from "@/pages";
import type { RouteNode } from "@/types";
import { Navigate, type RouteObject } from "react-router-dom";
import { resolveRouterScene, type RouterStateInput } from "./static-router";
const layoutRegistry = {
DEFAULT: DefaultLayout,
SIDEBAR: SidebarLayout
};
function toChildRoute(route: RouteNode): RouteObject {
const Component = route.component === "dashboard" ? DashboardPage : RoutePlaceholderPage;
return {
path: route.path === "/" ? "" : route.path.replace(/^\//, ""),
element: <Component />
};
}
function buildLayoutRoutes(dynamicRoutes: RouteNode[]): RouteObject[] {
const grouped = dynamicRoutes.reduce<Record<string, RouteNode[]>>((acc, route) => {
acc[route.layout] ??= [];
acc[route.layout].push(route);
return acc;
}, {});
return Object.entries(grouped).map(([layout, routes]) => {
const Layout = layoutRegistry[layout as keyof typeof layoutRegistry] ?? DefaultLayout;
return {
path: "/",
element: <Layout />,
children: routes.map(toChildRoute)
} satisfies RouteObject;
});
}
export function renderAppRoutes(state: RouterStateInput): RouteObject[] {
const scene = resolveRouterScene(state);
if (scene === "UNAUTHED") {
return [
{ path: "/login", element: <LoginPage /> },
{ path: "*", element: <Navigate to="/login" replace /> }
];
}
if (scene === "LOADING") {
return [{ path: "*", element: <LoadingView message="正在加载路由..." /> }];
}
if (scene === "LOAD_ERROR") {
return [{ path: "*", element: <Navigate to="/login" replace /> }];
}
return [
{ path: "/login", element: <Navigate to="/" replace /> },
...buildLayoutRoutes(state.dynamicRoutes),
{ path: "*", element: <NotFoundPage /> }
];
}

View File

@@ -0,0 +1,23 @@
import type { RouteNode } from "@/types";
export type RouterScene = "UNAUTHED" | "LOADING" | "LOAD_ERROR" | "READY";
export interface RouterStateInput {
authed: boolean;
loading: boolean;
loadError: boolean;
dynamicRoutes: RouteNode[];
}
export function resolveRouterScene(state: RouterStateInput): RouterScene {
if (!state.authed) {
return "UNAUTHED";
}
if (state.loading) {
return "LOADING";
}
if (state.loadError || state.dynamicRoutes.length === 0) {
return "LOAD_ERROR";
}
return "READY";
}

Some files were not shown because too many files have changed in this diff Show More