21
.agents/skills/k12-frontend-import-alias/SKILL.md
Normal file
21
.agents/skills/k12-frontend-import-alias/SKILL.md
Normal 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
6
.gitignore
vendored
@@ -1,12 +1,18 @@
|
||||
urbanLifeline
|
||||
Tik
|
||||
schoolNewsServ
|
||||
pigx
|
||||
.claude
|
||||
referenceCode
|
||||
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/extensions.json
|
||||
.DS_Store
|
||||
.tmp-run/
|
||||
.gitnexus
|
||||
|
||||
|
||||
**/target/
|
||||
**/node_modules/
|
||||
|
||||
119
AGENT.md
Normal file
119
AGENT.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# K12Study Agent 工程规范
|
||||
|
||||
本文件是所有自动化 agent(Claude 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 是细则源头)
|
||||
@@ -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) {
|
||||
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 = {
|
||||
login
|
||||
login,
|
||||
refreshToken,
|
||||
getAuthCurrentUser
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { request } = require("../utils/request");
|
||||
const { request } = require("/utils/request");
|
||||
|
||||
function getRouteMeta() {
|
||||
return request({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/login/index",
|
||||
"pages/home/index",
|
||||
"pages/profile/index"
|
||||
],
|
||||
|
||||
@@ -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({
|
||||
data: {
|
||||
title: "K12Study 小程序骨架",
|
||||
description: "这里先放首页占位,后续可扩展为家长端或学生端入口。"
|
||||
loading: false,
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<view class="page">
|
||||
<view class="card">
|
||||
<view>{{title}}</view>
|
||||
<view style="margin-top: 16rpx; color: #64748b;">{{description}}</view>
|
||||
<view class="title">学生端首页</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
50
app/src/pages/login/index.js
Normal file
50
app/src/pages/login/index.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
3
app/src/pages/login/index.json
Normal file
3
app/src/pages/login/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "学生登录"
|
||||
}
|
||||
12
app/src/pages/login/index.wxml
Normal file
12
app/src/pages/login/index.wxml
Normal 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>
|
||||
27
app/src/pages/login/index.wxss
Normal file
27
app/src/pages/login/index.wxss
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
const { getAccessToken } = require("/utils/session");
|
||||
|
||||
Page({
|
||||
data: {
|
||||
title: "我的",
|
||||
description: "这里预留账号中心、学校切换、消息入口等能力。"
|
||||
},
|
||||
onShow() {
|
||||
if (!getAccessToken()) {
|
||||
wx.reLaunch({ url: "/pages/login/index" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const BASE_URL = "http://localhost:8088";
|
||||
const { clearTokens, getAccessToken, getRefreshToken, setTokens } = require("./session");
|
||||
|
||||
let refreshPromise = null;
|
||||
|
||||
function isApiResponse(payload) {
|
||||
return (
|
||||
@@ -12,16 +15,29 @@ function isApiResponse(payload) {
|
||||
|
||||
function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const accessToken = getAccessToken();
|
||||
wx.request({
|
||||
url: `${BASE_URL}${options.url}`,
|
||||
method: options.method || "GET",
|
||||
data: options.data,
|
||||
header: {
|
||||
"Content-Type": "application/json",
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
...(options.header || {})
|
||||
},
|
||||
success: (response) => {
|
||||
success: async (response) => {
|
||||
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) {
|
||||
const 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 = {
|
||||
request
|
||||
};
|
||||
|
||||
27
app/src/utils/session.js
Normal file
27
app/src/utils/session.js
Normal 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
|
||||
};
|
||||
@@ -16,6 +16,21 @@
|
||||
<artifactId>common-api</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</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>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
@@ -9,7 +9,10 @@ public record CurrentUserResponse(
|
||||
String provinceCode,
|
||||
String areaCode,
|
||||
String tenantId,
|
||||
String tenantPath,
|
||||
String deptId,
|
||||
List<String> roles
|
||||
String deptPath,
|
||||
List<String> roles,
|
||||
String clientType
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.k12study.api.auth.dto;
|
||||
public record LoginRequest(
|
||||
String username,
|
||||
String password,
|
||||
String mobile,
|
||||
String smsCode,
|
||||
String provinceCode,
|
||||
String areaCode,
|
||||
String tenantId
|
||||
String tenantId,
|
||||
String clientType
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,21 @@
|
||||
<artifactId>common-api</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</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>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.k12study.api.upms.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record MessageReadResultDto(
|
||||
String messageId,
|
||||
String readStatus,
|
||||
Instant readAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.k12study.api.upms.dto;
|
||||
|
||||
public record SchoolClassCourseDto(
|
||||
String classId,
|
||||
String courseId,
|
||||
String relationStatus
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -10,4 +10,11 @@ public final class UpmsApiPaths {
|
||||
public static final String AREAS = BASE + "/areas";
|
||||
public static final String TENANTS = BASE + "/tenants";
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -3,19 +3,91 @@ package com.k12study.api.upms.remote;
|
||||
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.factory.RemoteUpmsServiceFallbackFactory;
|
||||
import com.k12study.common.api.response.ApiResponse;
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,18 @@
|
||||
<artifactId>api-auth</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</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>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
|
||||
@@ -14,6 +14,13 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* @description 认证 HTTP 入口;统一登录、刷新、当前用户查询,响应体由 AuthService 组装后通过 ApiResponse 包装
|
||||
* @filename AuthController.java
|
||||
* @author wangys
|
||||
* @copyright xyzh
|
||||
* @since 2026-04-17
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
public class AuthController {
|
||||
@@ -39,7 +46,7 @@ public class AuthController {
|
||||
|
||||
@GetMapping("/users/current")
|
||||
public ApiResponse<CurrentUserResponse> currentUser(
|
||||
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
|
||||
return ApiResponse.success(authService.currentUser(authorizationHeader));
|
||||
@RequestHeader(value = "Authorization", required = false) String authorization) {
|
||||
return ApiResponse.success(authService.currentUser(authorization));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,64 +3,423 @@ package com.k12study.auth.service;
|
||||
import com.k12study.api.auth.dto.CurrentUserResponse;
|
||||
import com.k12study.api.auth.dto.LoginRequest;
|
||||
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.jwt.JwtTokenProvider;
|
||||
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.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.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
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.jdbcTemplate = jdbcTemplate;
|
||||
this.authProperties = authProperties;
|
||||
}
|
||||
|
||||
public TokenResponse login(LoginRequest request) {
|
||||
String username = request.username() == null || request.username().isBlank() ? "admin" : request.username();
|
||||
JwtUserPrincipal principal = new JwtUserPrincipal(
|
||||
"U10001",
|
||||
username,
|
||||
"K12Study 管理员",
|
||||
request.tenantId() == null || request.tenantId().isBlank() ? "SCH-HQ" : request.tenantId(),
|
||||
"DEPT-HQ-ADMIN"
|
||||
);
|
||||
String clientType = normalizeClientType(request == null ? null : request.clientType());
|
||||
String tenantId = request == null || request.tenantId() == null ? "" : request.tenantId().trim();
|
||||
UserRecord user = resolveLoginUser(request, tenantId);
|
||||
verifyCredential(request, user);
|
||||
|
||||
List<String> roleCodes = findRoleCodes(user.userId());
|
||||
ensureRoleAssigned(roleCodes);
|
||||
validateClientRole(clientType, roleCodes);
|
||||
|
||||
String sessionId = UUID.randomUUID().toString().replace("-", "");
|
||||
JwtUserPrincipal principal = toPrincipal(user, roleCodes, clientType, sessionId);
|
||||
String accessToken = jwtTokenProvider.createAccessToken(principal);
|
||||
String refreshToken = jwtTokenProvider.createAccessToken(principal);
|
||||
return new TokenResponse(accessToken, refreshToken, "Bearer", 12 * 60 * 60);
|
||||
String refreshToken = jwtTokenProvider.createRefreshToken(principal);
|
||||
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) {
|
||||
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);
|
||||
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) {
|
||||
JwtUserPrincipal principal;
|
||||
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
|
||||
JwtUserPrincipal principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length()));
|
||||
return new CurrentUserResponse(
|
||||
principal.userId(),
|
||||
principal.username(),
|
||||
principal.displayName(),
|
||||
"330000",
|
||||
"330100",
|
||||
principal.tenantId(),
|
||||
principal.deptId(),
|
||||
List.of("SUPER_ADMIN", "ORG_ADMIN")
|
||||
);
|
||||
}
|
||||
principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length()));
|
||||
} else {
|
||||
var context = RequestUserContextHolder.get();
|
||||
return new CurrentUserResponse(
|
||||
context == null ? "U10001" : context.userId(),
|
||||
context == null ? "admin" : context.username(),
|
||||
context == null ? "K12Study 管理员" : context.displayName(),
|
||||
"330000",
|
||||
"330100",
|
||||
context == null ? "SCH-HQ" : context.tenantId(),
|
||||
context == null ? "DEPT-HQ-ADMIN" : context.deptId(),
|
||||
List.of("SUPER_ADMIN", "ORG_ADMIN")
|
||||
if (context == null || !StringUtils.hasText(context.userId())) {
|
||||
throw new BizException(401, "未登录或登录已失效");
|
||||
}
|
||||
principal = new JwtUserPrincipal(
|
||||
context.userId(),
|
||||
context.username(),
|
||||
context.displayName(),
|
||||
context.adcode(),
|
||||
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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ server:
|
||||
spring:
|
||||
application:
|
||||
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:
|
||||
redis:
|
||||
host: ${K12STUDY_REDIS_HOST:localhost}
|
||||
|
||||
@@ -6,11 +6,10 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: k12study-boot-dev
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
||||
- org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
|
||||
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
|
||||
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:
|
||||
redis:
|
||||
host: ${K12STUDY_REDIS_HOST:localhost}
|
||||
|
||||
@@ -5,8 +5,14 @@ public final class SecurityConstants {
|
||||
public static final String HEADER_USER_ID = "X-User-Id";
|
||||
public static final String HEADER_USERNAME = "X-Username";
|
||||
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_PATH = "X-Tenant-Path";
|
||||
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() {
|
||||
}
|
||||
|
||||
45
backend/common/common-feign/pom.xml
Normal file
45
backend/common/common-feign/pom.xml
Normal 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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.k12study.common.feign.config.FeignAutoConfiguration
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.k12study.common.security.context;
|
||||
import java.util.List;
|
||||
|
||||
public record RequestUserContext(
|
||||
String userId,
|
||||
String username,
|
||||
String displayName,
|
||||
String adcode,
|
||||
String tenantId,
|
||||
String deptId
|
||||
String tenantPath,
|
||||
String deptId,
|
||||
String deptPath,
|
||||
List<String> roleCodes,
|
||||
String clientType,
|
||||
String sessionId
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
public class JwtTokenProvider {
|
||||
@@ -19,15 +21,29 @@ public class JwtTokenProvider {
|
||||
}
|
||||
|
||||
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();
|
||||
return Jwts.builder()
|
||||
.subject(principal.userId())
|
||||
.claim("username", principal.username())
|
||||
.claim("displayName", principal.displayName())
|
||||
.claim("adcode", principal.adcode())
|
||||
.claim("tenantId", principal.tenantId())
|
||||
.claim("tenantPath", principal.tenantPath())
|
||||
.claim("deptId", principal.deptId())
|
||||
.claim("deptPath", principal.deptPath())
|
||||
.claim("roleCodes", principal.roleCodes())
|
||||
.claim("clientType", principal.clientType())
|
||||
.claim("sessionId", principal.sessionId())
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plus(authProperties.getAccessTokenTtl())))
|
||||
.expiration(Date.from(now.plus(ttl)))
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
}
|
||||
@@ -38,12 +54,23 @@ public class JwtTokenProvider {
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> roleCodes = claims.get("roleCodes", List.class);
|
||||
if (roleCodes == null) {
|
||||
roleCodes = List.of();
|
||||
}
|
||||
return new JwtUserPrincipal(
|
||||
claims.getSubject(),
|
||||
claims.get("username", String.class),
|
||||
claims.get("displayName", String.class),
|
||||
claims.get("adcode", 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.k12study.common.security.jwt;
|
||||
import java.util.List;
|
||||
|
||||
public record JwtUserPrincipal(
|
||||
String userId,
|
||||
String username,
|
||||
String displayName,
|
||||
String adcode,
|
||||
String tenantId,
|
||||
String deptId
|
||||
String tenantPath,
|
||||
String deptId,
|
||||
String deptPath,
|
||||
List<String> roleCodes,
|
||||
String clientType,
|
||||
String sessionId
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
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_USERNAME),
|
||||
request.getHeader(SecurityConstants.HEADER_DISPLAY_NAME),
|
||||
request.getHeader(SecurityConstants.HEADER_ADCODE),
|
||||
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());
|
||||
filterChain.doFilter(request, response);
|
||||
@@ -37,4 +45,14 @@ public class CommonWebMvcConfiguration extends OncePerRequestFilter {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
<module>common-security</module>
|
||||
<module>common-mybatis</module>
|
||||
<module>common-redis</module>
|
||||
<module>common-feign</module>
|
||||
</modules>
|
||||
</project>
|
||||
|
||||
@@ -42,12 +42,21 @@ public class JwtRelayFilter implements GlobalFilter, Ordered {
|
||||
try {
|
||||
String token = authorization.substring(authProperties.getTokenPrefix().length());
|
||||
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()
|
||||
.header(SecurityConstants.HEADER_USER_ID, principal.userId())
|
||||
.header(SecurityConstants.HEADER_USERNAME, principal.username())
|
||||
.header(SecurityConstants.HEADER_DISPLAY_NAME, principal.displayName())
|
||||
.header(SecurityConstants.HEADER_TENANT_ID, principal.tenantId())
|
||||
.header(SecurityConstants.HEADER_DEPT_ID, principal.deptId())
|
||||
.header(SecurityConstants.HEADER_USER_ID, safe(principal.userId()))
|
||||
.header(SecurityConstants.HEADER_USERNAME, safe(principal.username()))
|
||||
.header(SecurityConstants.HEADER_DISPLAY_NAME, safe(principal.displayName()))
|
||||
.header(SecurityConstants.HEADER_ADCODE, safe(principal.adcode()))
|
||||
.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();
|
||||
return chain.filter(exchange.mutate().request(mutatedRequest).build());
|
||||
} catch (Exception exception) {
|
||||
@@ -67,9 +76,24 @@ public class JwtRelayFilter implements GlobalFilter, Ordered {
|
||||
private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
|
||||
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||
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()
|
||||
.bufferFactory()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<spring.boot.version>3.3.5</spring.boot.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>
|
||||
<postgresql.version>42.7.4</postgresql.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
@@ -49,6 +50,13 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</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>
|
||||
<groupId>com.baomidou</groupId>
|
||||
|
||||
@@ -3,16 +3,34 @@ package com.k12study.upms.controller;
|
||||
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.UpmsApiPaths;
|
||||
import com.k12study.common.api.response.ApiResponse;
|
||||
import com.k12study.upms.service.UpmsQueryService;
|
||||
import java.util.List;
|
||||
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.RestController;
|
||||
|
||||
/**
|
||||
* @description UPMS HTTP 入口;路由/组织/班级/文件/站内信等聚合查询,全部经 UpmsQueryService 执行租户隔离后返回
|
||||
* @filename UpmsController.java
|
||||
* @author wangys
|
||||
* @copyright xyzh
|
||||
* @since 2026-04-17
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(UpmsApiPaths.BASE)
|
||||
public class UpmsController {
|
||||
@@ -23,27 +41,79 @@ public class UpmsController {
|
||||
}
|
||||
|
||||
@GetMapping("/routes")
|
||||
public ApiResponse<List<RouteNodeDto>> routes() {
|
||||
return ApiResponse.success(upmsQueryService.routes());
|
||||
public ApiResponse<List<RouteNodeDto>> routes(
|
||||
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
|
||||
return ApiResponse.success(upmsQueryService.routes(authorizationHeader));
|
||||
}
|
||||
|
||||
@GetMapping("/areas")
|
||||
public ApiResponse<List<AreaNodeDto>> areas() {
|
||||
return ApiResponse.success(upmsQueryService.areas());
|
||||
public ApiResponse<List<AreaNodeDto>> areas(
|
||||
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
|
||||
return ApiResponse.success(upmsQueryService.areas(authorizationHeader));
|
||||
}
|
||||
|
||||
@GetMapping("/tenants")
|
||||
public ApiResponse<List<TenantNodeDto>> tenants() {
|
||||
return ApiResponse.success(upmsQueryService.tenants());
|
||||
public ApiResponse<List<TenantNodeDto>> tenants(
|
||||
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
|
||||
return ApiResponse.success(upmsQueryService.tenants(authorizationHeader));
|
||||
}
|
||||
|
||||
@GetMapping("/departments")
|
||||
public ApiResponse<List<DeptNodeDto>> departments() {
|
||||
return ApiResponse.success(upmsQueryService.departments());
|
||||
public ApiResponse<List<DeptNodeDto>> departments(
|
||||
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
|
||||
return ApiResponse.success(upmsQueryService.departments(authorizationHeader));
|
||||
}
|
||||
|
||||
@GetMapping("/users/current")
|
||||
public ApiResponse<CurrentRouteUserDto> currentUser() {
|
||||
return ApiResponse.success(upmsQueryService.currentUser());
|
||||
public ApiResponse<CurrentRouteUserDto> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,38 @@ package com.k12study.upms.service;
|
||||
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 java.util.List;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,130 +3,762 @@ package com.k12study.upms.service.impl;
|
||||
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.LayoutType;
|
||||
import com.k12study.api.upms.dto.MessageReadResultDto;
|
||||
import com.k12study.api.upms.dto.RouteMetaDto;
|
||||
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.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 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.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.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class UpmsQueryServiceImpl implements UpmsQueryService {
|
||||
private final NamedParameterJdbcTemplate jdbcTemplate;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Override
|
||||
public List<RouteNodeDto> routes() {
|
||||
return List.of(
|
||||
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()
|
||||
)
|
||||
);
|
||||
public UpmsQueryServiceImpl(NamedParameterJdbcTemplate jdbcTemplate, JwtTokenProvider jwtTokenProvider) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AreaNodeDto> areas() {
|
||||
return List.of(
|
||||
new AreaNodeDto(
|
||||
"330000",
|
||||
"100000",
|
||||
"浙江省",
|
||||
"PROVINCE",
|
||||
List.of(
|
||||
new AreaNodeDto(
|
||||
"330100",
|
||||
"330000",
|
||||
"杭州市",
|
||||
"CITY",
|
||||
List.of()
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
public List<RouteNodeDto> routes(String authorizationHeader) {
|
||||
AuthContext context = requireAuth(authorizationHeader);
|
||||
String sql = """
|
||||
SELECT DISTINCT
|
||||
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
|
||||
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
|
||||
ORDER BY m.created_at, m.route_id
|
||||
""";
|
||||
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
|
||||
public List<TenantNodeDto> tenants() {
|
||||
return List.of(
|
||||
new TenantNodeDto(
|
||||
"SCH-HQ",
|
||||
null,
|
||||
"K12Study 总校",
|
||||
"HEAD_SCHOOL",
|
||||
"330100",
|
||||
"/SCH-HQ/",
|
||||
List.of(
|
||||
new TenantNodeDto(
|
||||
"SCH-ZJ-HZ-01",
|
||||
"SCH-HQ",
|
||||
"杭州分校",
|
||||
"CITY_SCHOOL",
|
||||
"330100",
|
||||
"/SCH-HQ/SCH-ZJ-HZ-01/",
|
||||
List.of()
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
public List<AreaNodeDto> areas(String authorizationHeader) {
|
||||
requireAuth(authorizationHeader);
|
||||
String sql = """
|
||||
SELECT id, pid, adcode, name, area_type
|
||||
FROM upms.tb_sys_area
|
||||
WHERE del_flag = '0'
|
||||
ORDER BY area_sort NULLS LAST, id
|
||||
""";
|
||||
List<AreaRow> rows = jdbcTemplate.query(
|
||||
sql,
|
||||
Map.of(),
|
||||
(rs, rowNum) -> new AreaRow(
|
||||
rs.getLong("id"),
|
||||
rs.getLong("pid"),
|
||||
String.valueOf(rs.getLong("adcode")),
|
||||
rs.getString("name"),
|
||||
rs.getString("area_type")
|
||||
));
|
||||
return buildAreaTree(rows);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<DeptNodeDto> departments() {
|
||||
return List.of(
|
||||
new DeptNodeDto(
|
||||
"DEPT-HQ",
|
||||
null,
|
||||
"总校教学部",
|
||||
"GRADE",
|
||||
"SCH-HQ",
|
||||
"330100",
|
||||
"/SCH-HQ/",
|
||||
"/DEPT-HQ/",
|
||||
List.of(
|
||||
new DeptNodeDto(
|
||||
"DEPT-HQ-MATH",
|
||||
"DEPT-HQ",
|
||||
"数学学科组",
|
||||
"SUBJECT",
|
||||
"SCH-HQ",
|
||||
"330100",
|
||||
"/SCH-HQ/",
|
||||
"/DEPT-HQ/DEPT-HQ-MATH/",
|
||||
List.of()
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
public List<TenantNodeDto> tenants(String authorizationHeader) {
|
||||
AuthContext context = requireAuth(authorizationHeader);
|
||||
List<String> roleCodes = findRoleCodes(context.userId());
|
||||
boolean superAdmin = roleCodes.stream().anyMatch("SUPER_ADMIN"::equalsIgnoreCase);
|
||||
|
||||
StringBuilder sql = new StringBuilder("""
|
||||
SELECT tenant_id, parent_tenant_id, tenant_name, tenant_type, adcode, tenant_path
|
||||
FROM upms.tb_sys_tenant
|
||||
WHERE status = 'ACTIVE'
|
||||
""");
|
||||
MapSqlParameterSource params = new MapSqlParameterSource();
|
||||
if (!superAdmin) {
|
||||
if (StringUtils.hasText(context.tenantPath())) {
|
||||
sql.append(" AND tenant_path LIKE :tenantPathPrefix ");
|
||||
params.addValue("tenantPathPrefix", context.tenantPath() + "%");
|
||||
} else {
|
||||
sql.append(" AND tenant_id = :tenantId ");
|
||||
params.addValue("tenantId", context.tenantId());
|
||||
}
|
||||
}
|
||||
sql.append(" ORDER BY tenant_path ");
|
||||
|
||||
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
|
||||
public CurrentRouteUserDto currentUser() {
|
||||
var context = RequestUserContextHolder.get();
|
||||
public List<DeptNodeDto> departments(String authorizationHeader) {
|
||||
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(
|
||||
context == null ? "U10001" : context.userId(),
|
||||
context == null ? "admin" : context.username(),
|
||||
context == null ? "K12Study 管理员" : context.displayName(),
|
||||
"330100",
|
||||
context == null ? "SCH-HQ" : context.tenantId(),
|
||||
"/SCH-HQ/",
|
||||
context == null ? "DEPT-HQ-ADMIN" : context.deptId(),
|
||||
"/DEPT-HQ/DEPT-HQ-ADMIN/",
|
||||
List.of("dashboard:view", "tenant:view", "dept:view")
|
||||
user.userId(),
|
||||
user.username(),
|
||||
user.displayName(),
|
||||
user.adcode(),
|
||||
user.tenantId(),
|
||||
user.tenantPath(),
|
||||
user.deptId(),
|
||||
user.deptPath(),
|
||||
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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ server:
|
||||
spring:
|
||||
application:
|
||||
name: k12study-upms
|
||||
autoconfigure:
|
||||
exclude:
|
||||
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
||||
- org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
|
||||
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
|
||||
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}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<mxfile host="65bd71144e">
|
||||
<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>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
@@ -10,16 +10,16 @@
|
||||
<mxCell id="3" value="入口:微信小程序/React 后台;主链路:Java服务编排 -> Python AI子服务(OCR/LLM/ASR) -> 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"/>
|
||||
</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"/>
|
||||
</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">
|
||||
<mxGeometry x="90" y="100" width="790" height="1070" as="geometry"/>
|
||||
<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="70" y="100" width="790" height="1070" as="geometry"/>
|
||||
</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"/>
|
||||
</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"/>
|
||||
</mxCell>
|
||||
<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">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</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">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
<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">
|
||||
<mxPoint x="580" y="322" as="sourcePoint"/>
|
||||
<mxPoint x="1360" y="305" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</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">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
|
||||
@@ -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
38
docs/plan/modules/auth.md
Normal 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`:输出标准 claims(userId/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` 角色,老师与机构角色不开放小程序登录。
|
||||
42
docs/plan/modules/course.md
Normal file
42
docs/plan/modules/course.md
Normal 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)。
|
||||
32
docs/plan/modules/gateway.md
Normal file
32
docs/plan/modules/gateway.md
Normal 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)统一口径。
|
||||
38
docs/plan/modules/question.md
Normal file
38
docs/plan/modules/question.md
Normal 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
39
docs/plan/modules/upms.md
Normal 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 端路由资源管理;小程序端当前不做动态路由。
|
||||
* 学生手机号换绑不启用“原手机号确认 + 班级归属校验 + 冷静期”三段式流程,采用手机号验证或线下联系老师处理。
|
||||
39
docs/plan/modules/向量知识库.md
Normal file
39
docs/plan/modules/向量知识库.md
Normal 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/教师内容边界容易在实现时被混用。
|
||||
## 需确认的设计决策
|
||||
* 首期是否先做“单向量模型 + 单索引集合”简化方案,再演进多模型路由。
|
||||
* 向量检索与图检索的融合策略首期采用串行还是并行(推荐串行,便于控制复杂度)。
|
||||
* 检索日志保留周期与脱敏策略(涉及学生查询内容合规)。
|
||||
36
docs/plan/modules/知识图谱.md
Normal file
36
docs/plan/modules/知识图谱.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 问题陈述
|
||||
按“图谱知识库”独立模块设计知识抽取与图检索链路,明确总部与教师内容入库策略,并把租户隔离作为图检索默认约束。
|
||||
## 当前状态(已确认)
|
||||
* 架构层已定义图数据库主选 NebulaGraph,AI 链路为业务服务 -> 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
5
docs/plan/upms.md
Normal 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日志
|
||||
@@ -15,9 +15,11 @@
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"sass-embedded": "^1.99.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppRouter } from "./router/AppRouter";
|
||||
import { AppRouter } from "@/router";
|
||||
|
||||
export default function App() {
|
||||
return <AppRouter />;
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import type { ApiResponse } from "../types/api";
|
||||
import { http } from "../utils/http";
|
||||
|
||||
interface LoginInput {
|
||||
import type { ApiResponse } from "@/types";
|
||||
import { http } from "@/utils";
|
||||
export interface LoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
mobile?: string;
|
||||
smsCode?: string;
|
||||
provinceCode: string;
|
||||
areaCode: string;
|
||||
tenantId: string;
|
||||
clientType?: "WEB" | "MINI";
|
||||
}
|
||||
|
||||
export interface TokenPayload {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenType: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
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) {
|
||||
return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/tokens/refresh", {
|
||||
return http.post<ApiResponse<TokenPayload>>("/auth/tokens/refresh", {
|
||||
refreshToken
|
||||
});
|
||||
}
|
||||
|
||||
2
frontend/src/api/index.ts
Normal file
2
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./auth";
|
||||
export * from "./upms";
|
||||
@@ -1,12 +1,17 @@
|
||||
import {
|
||||
getUpmsAreasRemote,
|
||||
getUpmsCurrentUserRemote,
|
||||
getUpmsDepartmentsRemote,
|
||||
getUpmsRoutesRemote,
|
||||
getUpmsTenantsRemote
|
||||
} from "../remote/upmsRemote";
|
||||
import type { CurrentRouteUser, RouteNode } from "../types/route";
|
||||
import type { UpmsAreaNode, UpmsDeptNode, UpmsTenantNode } from "../types/upms";
|
||||
import type { ApiResponse, CurrentRouteUser, RouteNode } from "@/types";
|
||||
import type {
|
||||
UpmsAreaNode,
|
||||
UpmsClass,
|
||||
UpmsClassCourse,
|
||||
UpmsClassMember,
|
||||
UpmsDeptNode,
|
||||
UpmsFileMetadata,
|
||||
UpmsFileUploadRequest,
|
||||
UpmsInboxMessage,
|
||||
UpmsMessageReadResult,
|
||||
UpmsTenantNode
|
||||
} from "@/types/upms";
|
||||
import { http } from "@/utils";
|
||||
|
||||
function normalizeAreaNodes(nodes: UpmsAreaNode[]): UpmsAreaNode[] {
|
||||
return nodes.map((node) => ({
|
||||
@@ -30,26 +35,67 @@ function normalizeDeptNodes(nodes: UpmsDeptNode[]): UpmsDeptNode[] {
|
||||
}
|
||||
|
||||
export async function fetchDynamicRoutes(): Promise<RouteNode[]> {
|
||||
const response = await getUpmsRoutesRemote();
|
||||
const response = await http.get<ApiResponse<RouteNode[]>>("/upms/routes");
|
||||
return response.data as RouteNode[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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[]);
|
||||
}
|
||||
|
||||
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[]);
|
||||
}
|
||||
|
||||
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[]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
18
frontend/src/components/AppCard/index.scss
Normal file
18
frontend/src/components/AppCard/index.scss
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import "./index.scss";
|
||||
|
||||
type AppCardProps = PropsWithChildren<{
|
||||
title: string;
|
||||
6
frontend/src/components/LoadingView/index.scss
Normal file
6
frontend/src/components/LoadingView/index.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.loading-view {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
color: #5b6475;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "./index.scss";
|
||||
|
||||
export function LoadingView({ message = "Loading..." }: { message?: string }) {
|
||||
return <div className="loading-view">{message}</div>;
|
||||
}
|
||||
2
frontend/src/components/index.ts
Normal file
2
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./AppCard";
|
||||
export * from "./LoadingView";
|
||||
10
frontend/src/layouts/DefaultLayout/index.scss
Normal file
10
frontend/src/layouts/DefaultLayout/index.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.default-layout {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.default-layout {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import "./index.scss";
|
||||
|
||||
export function DefaultLayout() {
|
||||
return (
|
||||
44
frontend/src/layouts/SidebarLayout/index.scss
Normal file
44
frontend/src/layouts/SidebarLayout/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import "./index.scss";
|
||||
|
||||
export function SidebarLayout() {
|
||||
const location = useLocation();
|
||||
2
frontend/src/layouts/index.ts
Normal file
2
frontend/src/layouts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./DefaultLayout";
|
||||
export * from "./SidebarLayout";
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles/app.css";
|
||||
import App from "@/App";
|
||||
import "@/styles/index.scss";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { AppCard } from "../components/AppCard";
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<AppCard title="项目骨架已就绪">
|
||||
<p>当前页面用于承接首版后台管理端骨架,后续可继续补充机构端、教师端等业务模块。</p>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
32
frontend/src/pages/DashboardPage/index.scss
Normal file
32
frontend/src/pages/DashboardPage/index.scss
Normal 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;
|
||||
}
|
||||
113
frontend/src/pages/DashboardPage/index.tsx
Normal file
113
frontend/src/pages/DashboardPage/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/pages/LoginPage/index.scss
Normal file
41
frontend/src/pages/LoginPage/index.scss
Normal 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;
|
||||
}
|
||||
62
frontend/src/pages/LoginPage/index.tsx
Normal file
62
frontend/src/pages/LoginPage/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
frontend/src/pages/NotFoundPage/index.scss
Normal file
6
frontend/src/pages/NotFoundPage/index.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.not-found {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
color: #5b6475;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "./index.scss";
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
7
frontend/src/pages/RoutePlaceholderPage/index.scss
Normal file
7
frontend/src/pages/RoutePlaceholderPage/index.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.route-placeholder p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.route-placeholder p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
16
frontend/src/pages/RoutePlaceholderPage/index.tsx
Normal file
16
frontend/src/pages/RoutePlaceholderPage/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
frontend/src/pages/index.ts
Normal file
4
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./DashboardPage";
|
||||
export * from "./LoginPage";
|
||||
export * from "./NotFoundPage";
|
||||
export * from "./RoutePlaceholderPage";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,90 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, type RouteObject, useRoutes } from "react-router-dom";
|
||||
import { fetchDynamicRoutes } from "../api/upms";
|
||||
import { LoadingView } from "../components/LoadingView";
|
||||
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 /> }
|
||||
];
|
||||
}
|
||||
import { useMemo } from "react";
|
||||
import { useRoutes } from "react-router-dom";
|
||||
import { useDynamicRouterData } from "./dynamic-router";
|
||||
import { renderAppRoutes } from "./router-renderer";
|
||||
|
||||
export function AppRouter() {
|
||||
const [dynamicRoutes, setDynamicRoutes] = useState<RouteNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
const routerData = useDynamicRouterData();
|
||||
const routes = useMemo(
|
||||
() => renderAppRoutes(routerData),
|
||||
[routerData.authed, routerData.loading, routerData.loadError, routerData.dynamicRoutes]
|
||||
);
|
||||
|
||||
return useRoutes(routes);
|
||||
}
|
||||
|
||||
48
frontend/src/router/dynamic-router.ts
Normal file
48
frontend/src/router/dynamic-router.ts
Normal 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
|
||||
};
|
||||
}
|
||||
1
frontend/src/router/index.ts
Normal file
1
frontend/src/router/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./AppRouter";
|
||||
60
frontend/src/router/router-renderer.tsx
Normal file
60
frontend/src/router/router-renderer.tsx
Normal 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 /> }
|
||||
];
|
||||
}
|
||||
23
frontend/src/router/static-router.ts
Normal file
23
frontend/src/router/static-router.ts
Normal 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
Reference in New Issue
Block a user