From 2476655b289ca23300388ae43d446eb9575eb839 Mon Sep 17 00:00:00 2001 From: wangys <3401275564@qq.com> Date: Fri, 17 Apr 2026 16:31:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/k12-frontend-import-alias/SKILL.md | 21 + .gitignore | 6 + AGENT.md | 119 +++ app/src/api/auth.js | 28 +- app/src/api/upms.js | 2 +- app/src/app.json | 1 + app/src/pages/home/index.js | 62 +- app/src/pages/home/index.wxml | 13 +- app/src/pages/home/index.wxss | 24 +- app/src/pages/login/index.js | 50 ++ app/src/pages/login/index.json | 3 + app/src/pages/login/index.wxml | 12 + app/src/pages/login/index.wxss | 27 + app/src/pages/profile/index.js | 7 + app/src/utils/request.js | 73 +- app/src/utils/session.js | 27 + backend/apis/api-auth/pom.xml | 15 + .../api/auth/dto/CurrentUserResponse.java | 5 +- .../k12study/api/auth/dto/LoginRequest.java | 5 +- .../api/auth/remote/AuthApiPaths.java | 18 + .../api/auth/remote/AuthRemoteApi.java | 43 + .../RemoteAuthServiceFallbackFactory.java | 49 ++ backend/apis/api-upms/pom.xml | 15 + .../api/upms/dto/FileMetadataDto.java | 19 + .../api/upms/dto/FileUploadRequestDto.java | 12 + .../api/upms/dto/InboxMessageDto.java | 16 + .../api/upms/dto/MessageReadResultDto.java | 10 + .../api/upms/dto/SchoolClassCourseDto.java | 8 + .../k12study/api/upms/dto/SchoolClassDto.java | 12 + .../api/upms/dto/SchoolClassMemberDto.java | 15 + .../api/upms/remote/UpmsApiPaths.java | 7 + .../api/upms/remote/UpmsRemoteApi.java | 82 +- .../RemoteUpmsServiceFallbackFactory.java | 103 +++ backend/auth/pom.xml | 12 + .../auth/controller/AuthController.java | 11 +- .../k12study/auth/service/AuthService.java | 423 ++++++++- .../auth/src/main/resources/application.yml | 4 + .../src/main/resources/application.yml | 9 +- .../core/constants/SecurityConstants.java | 6 + backend/common/common-feign/pom.xml | 45 + .../feign/config/FeignAutoConfiguration.java | 76 ++ .../feign/config/FeignClientProperties.java | 58 ++ .../contract/FeignContractValidator.java | 203 +++++ .../FeignAuthRelayInterceptor.java | 37 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../security/context/RequestUserContext.java | 9 +- .../common/security/jwt/JwtTokenProvider.java | 31 +- .../common/security/jwt/JwtUserPrincipal.java | 9 +- .../web/config/CommonWebMvcConfiguration.java | 20 +- backend/common/pom.xml | 1 + .../gateway/filter/JwtRelayFilter.java | 36 +- backend/pom.xml | 8 + .../upms/controller/UpmsController.java | 90 +- .../upms/service/UpmsQueryService.java | 31 +- .../service/impl/UpmsQueryServiceImpl.java | 830 +++++++++++++++--- .../upms/src/main/resources/application.yml | 9 +- docs/architecture/数据流图(多角色).drawio | 19 +- ...ms-auth-frontend-backend-implementation.md | 38 + docs/plan/modules/auth.md | 38 + docs/plan/modules/course.md | 42 + docs/plan/modules/gateway.md | 32 + docs/plan/modules/question.md | 38 + docs/plan/modules/upms.md | 39 + docs/plan/modules/向量知识库.md | 39 + docs/plan/modules/知识图谱.md | 36 + docs/plan/upms.md | 5 + frontend/package.json | 2 + frontend/src/App.tsx | 2 +- frontend/src/api/auth.ts | 21 +- frontend/src/api/index.ts | 2 + frontend/src/api/upms.ts | 74 +- frontend/src/components/AppCard/index.scss | 18 + .../{AppCard.tsx => AppCard/index.tsx} | 1 + .../src/components/LoadingView/index.scss | 6 + .../index.tsx} | 2 + frontend/src/components/index.ts | 2 + frontend/src/layouts/DefaultLayout/index.scss | 10 + .../index.tsx} | 1 + frontend/src/layouts/SidebarLayout/index.scss | 44 + .../index.tsx} | 1 + frontend/src/layouts/index.ts | 2 + frontend/src/main.tsx | 4 +- frontend/src/pages/DashboardPage.tsx | 9 - frontend/src/pages/DashboardPage/index.scss | 32 + frontend/src/pages/DashboardPage/index.tsx | 113 +++ frontend/src/pages/LoginPage.tsx | 42 - frontend/src/pages/LoginPage/index.scss | 41 + frontend/src/pages/LoginPage/index.tsx | 62 ++ frontend/src/pages/NotFoundPage/index.scss | 6 + .../index.tsx} | 1 + frontend/src/pages/RoutePlaceholderPage.tsx | 13 - .../src/pages/RoutePlaceholderPage/index.scss | 7 + .../src/pages/RoutePlaceholderPage/index.tsx | 16 + frontend/src/pages/index.ts | 4 + frontend/src/remote/upmsRemote.ts | 23 - frontend/src/router/AppRouter.tsx | 94 +- frontend/src/router/dynamic-router.ts | 48 + frontend/src/router/index.ts | 1 + frontend/src/router/router-renderer.tsx | 60 ++ frontend/src/router/static-router.ts | 23 + frontend/src/store/index.ts | 1 + frontend/src/store/session.ts | 4 +- frontend/src/styles/app.css | 120 --- frontend/src/styles/index.scss | 17 + frontend/src/types/index.ts | 3 + frontend/src/types/route.ts | 2 +- frontend/src/types/upms.ts | 70 ++ frontend/src/utils/http.ts | 71 +- frontend/src/utils/index.ts | 2 + frontend/src/utils/storage.ts | 21 + frontend/tsconfig.json | 6 +- frontend/vite.config.ts | 6 + global.code-snippets | 65 -- init/pg/auth/10_create_auth_tables.sql | 14 + init/pg/upms/10_create_upms_tables.sql | 27 + init/pg/upms/20_init_upms_seed.sql | 48 +- 116 files changed, 3875 insertions(+), 583 deletions(-) create mode 100644 .agents/skills/k12-frontend-import-alias/SKILL.md create mode 100644 AGENT.md create mode 100644 app/src/pages/login/index.js create mode 100644 app/src/pages/login/index.json create mode 100644 app/src/pages/login/index.wxml create mode 100644 app/src/pages/login/index.wxss create mode 100644 app/src/utils/session.js create mode 100644 backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/AuthApiPaths.java create mode 100644 backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/AuthRemoteApi.java create mode 100644 backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/factory/RemoteAuthServiceFallbackFactory.java create mode 100644 backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/FileMetadataDto.java create mode 100644 backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/FileUploadRequestDto.java create mode 100644 backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/InboxMessageDto.java create mode 100644 backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/MessageReadResultDto.java create mode 100644 backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassCourseDto.java create mode 100644 backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassDto.java create mode 100644 backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassMemberDto.java create mode 100644 backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/factory/RemoteUpmsServiceFallbackFactory.java create mode 100644 backend/common/common-feign/pom.xml create mode 100644 backend/common/common-feign/src/main/java/com/k12study/common/feign/config/FeignAutoConfiguration.java create mode 100644 backend/common/common-feign/src/main/java/com/k12study/common/feign/config/FeignClientProperties.java create mode 100644 backend/common/common-feign/src/main/java/com/k12study/common/feign/contract/FeignContractValidator.java create mode 100644 backend/common/common-feign/src/main/java/com/k12study/common/feign/interceptor/FeignAuthRelayInterceptor.java create mode 100644 backend/common/common-feign/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 docs/plan/modules-todo/upms-auth-frontend-backend-implementation.md create mode 100644 docs/plan/modules/auth.md create mode 100644 docs/plan/modules/course.md create mode 100644 docs/plan/modules/gateway.md create mode 100644 docs/plan/modules/question.md create mode 100644 docs/plan/modules/upms.md create mode 100644 docs/plan/modules/向量知识库.md create mode 100644 docs/plan/modules/知识图谱.md create mode 100644 docs/plan/upms.md create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/components/AppCard/index.scss rename frontend/src/components/{AppCard.tsx => AppCard/index.tsx} (95%) create mode 100644 frontend/src/components/LoadingView/index.scss rename frontend/src/components/{LoadingView.tsx => LoadingView/index.tsx} (85%) create mode 100644 frontend/src/components/index.ts create mode 100644 frontend/src/layouts/DefaultLayout/index.scss rename frontend/src/layouts/{DefaultLayout.tsx => DefaultLayout/index.tsx} (88%) create mode 100644 frontend/src/layouts/SidebarLayout/index.scss rename frontend/src/layouts/{SidebarLayout.tsx => SidebarLayout/index.tsx} (96%) create mode 100644 frontend/src/layouts/index.ts delete mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/DashboardPage/index.scss create mode 100644 frontend/src/pages/DashboardPage/index.tsx delete mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/LoginPage/index.scss create mode 100644 frontend/src/pages/LoginPage/index.tsx create mode 100644 frontend/src/pages/NotFoundPage/index.scss rename frontend/src/pages/{NotFoundPage.tsx => NotFoundPage/index.tsx} (90%) delete mode 100644 frontend/src/pages/RoutePlaceholderPage.tsx create mode 100644 frontend/src/pages/RoutePlaceholderPage/index.scss create mode 100644 frontend/src/pages/RoutePlaceholderPage/index.tsx create mode 100644 frontend/src/pages/index.ts delete mode 100644 frontend/src/remote/upmsRemote.ts create mode 100644 frontend/src/router/dynamic-router.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/router/router-renderer.tsx create mode 100644 frontend/src/router/static-router.ts create mode 100644 frontend/src/store/index.ts delete mode 100644 frontend/src/styles/app.css create mode 100644 frontend/src/styles/index.scss create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/utils/index.ts delete mode 100644 global.code-snippets diff --git a/.agents/skills/k12-frontend-import-alias/SKILL.md b/.agents/skills/k12-frontend-import-alias/SKILL.md new file mode 100644 index 0000000..80e3afe --- /dev/null +++ b/.agents/skills/k12-frontend-import-alias/SKILL.md @@ -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` 的别名配置一致。 diff --git a/.gitignore b/.gitignore index c03ff10..8bf09b0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..1719217 --- /dev/null +++ b/AGENT.md @@ -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..url` 走直连 URL 兜底 +- 熔断降级统一返回 `ApiResponse.failure(503, reason)`,前端按标准响应体处理 + +--- + +## 6. Agent 协作约定 + +- 任务拆解与进度跟踪优先使用 `TaskCreate / TaskUpdate`,而不是把进度写进 memory +- 任何对本文件、`.agents/skills/*`、`docs/architecture/*` 的改动都必须在同一次提交中同步 +- 本文件若与 `.agents/skills/*/SKILL.md` 冲突,以 SKILL.md 为准(SKILL.md 是细则源头) diff --git a/app/src/api/auth.js b/app/src/api/auth.js index 787f83b..a0cb12a 100644 --- a/app/src/api/auth.js +++ b/app/src/api/auth.js @@ -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 }; diff --git a/app/src/api/upms.js b/app/src/api/upms.js index 7a4a54d..72d1f29 100644 --- a/app/src/api/upms.js +++ b/app/src/api/upms.js @@ -1,4 +1,4 @@ -const { request } = require("../utils/request"); +const { request } = require("/utils/request"); function getRouteMeta() { return request({ diff --git a/app/src/app.json b/app/src/app.json index 6617587..6f7cf5d 100644 --- a/app/src/app.json +++ b/app/src/app.json @@ -1,5 +1,6 @@ { "pages": [ + "pages/login/index", "pages/home/index", "pages/profile/index" ], diff --git a/app/src/pages/home/index.js b/app/src/pages/home/index.js index 2a5bc31..9b3ea88 100644 --- a/app/src/pages/home/index.js +++ b/app/src/pages/home/index.js @@ -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" }); } }); diff --git a/app/src/pages/home/index.wxml b/app/src/pages/home/index.wxml index 5c40f7d..4523c6e 100644 --- a/app/src/pages/home/index.wxml +++ b/app/src/pages/home/index.wxml @@ -1,6 +1,15 @@ - {{title}} - {{description}} + 学生端首页 + 加载用户信息中... + {{error}} + + 账号:{{currentUser.username}} + 姓名:{{currentUser.displayName}} + 租户:{{currentUser.tenantId}} + 部门:{{currentUser.deptId}} + 角色:{{currentUser.roleCodesText}} + + diff --git a/app/src/pages/home/index.wxss b/app/src/pages/home/index.wxss index 8f51b84..7c8d29d 100644 --- a/app/src/pages/home/index.wxss +++ b/app/src/pages/home/index.wxss @@ -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; } diff --git a/app/src/pages/login/index.js b/app/src/pages/login/index.js new file mode 100644 index 0000000..f7017ef --- /dev/null +++ b/app/src/pages/login/index.js @@ -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 }); + } + } +}); diff --git a/app/src/pages/login/index.json b/app/src/pages/login/index.json new file mode 100644 index 0000000..8ac8e4b --- /dev/null +++ b/app/src/pages/login/index.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "学生登录" +} diff --git a/app/src/pages/login/index.wxml b/app/src/pages/login/index.wxml new file mode 100644 index 0000000..d8e5cca --- /dev/null +++ b/app/src/pages/login/index.wxml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/pages/login/index.wxss b/app/src/pages/login/index.wxss new file mode 100644 index 0000000..4a0cf0a --- /dev/null +++ b/app/src/pages/login/index.wxss @@ -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; +} diff --git a/app/src/pages/profile/index.js b/app/src/pages/profile/index.js index 97f5c77..0110396 100644 --- a/app/src/pages/profile/index.js +++ b/app/src/pages/profile/index.js @@ -1,6 +1,13 @@ +const { getAccessToken } = require("/utils/session"); + Page({ data: { title: "我的", description: "这里预留账号中心、学校切换、消息入口等能力。" + }, + onShow() { + if (!getAccessToken()) { + wx.reLaunch({ url: "/pages/login/index" }); + } } }); diff --git a/app/src/utils/request.js b/app/src/utils/request.js index b864835..576ec9a 100644 --- a/app/src/utils/request.js +++ b/app/src/utils/request.js @@ -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 }; diff --git a/app/src/utils/session.js b/app/src/utils/session.js new file mode 100644 index 0000000..cb37c05 --- /dev/null +++ b/app/src/utils/session.js @@ -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 +}; diff --git a/backend/apis/api-auth/pom.xml b/backend/apis/api-auth/pom.xml index 62b9de5..16270cc 100644 --- a/backend/apis/api-auth/pom.xml +++ b/backend/apis/api-auth/pom.xml @@ -16,6 +16,21 @@ common-api ${project.version} + + org.springframework.cloud + spring-cloud-starter-openfeign + true + + + org.springframework + spring-web + true + + + org.slf4j + slf4j-api + true + org.projectlombok lombok diff --git a/backend/apis/api-auth/src/main/java/com/k12study/api/auth/dto/CurrentUserResponse.java b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/dto/CurrentUserResponse.java index 5c53c3c..81333f5 100644 --- a/backend/apis/api-auth/src/main/java/com/k12study/api/auth/dto/CurrentUserResponse.java +++ b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/dto/CurrentUserResponse.java @@ -9,7 +9,10 @@ public record CurrentUserResponse( String provinceCode, String areaCode, String tenantId, + String tenantPath, String deptId, - List roles + String deptPath, + List roles, + String clientType ) { } diff --git a/backend/apis/api-auth/src/main/java/com/k12study/api/auth/dto/LoginRequest.java b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/dto/LoginRequest.java index f152cb8..ccb53f8 100644 --- a/backend/apis/api-auth/src/main/java/com/k12study/api/auth/dto/LoginRequest.java +++ b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/dto/LoginRequest.java @@ -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 ) { } diff --git a/backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/AuthApiPaths.java b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/AuthApiPaths.java new file mode 100644 index 0000000..4e77ee1 --- /dev/null +++ b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/AuthApiPaths.java @@ -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"; +} diff --git a/backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/AuthRemoteApi.java b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/AuthRemoteApi.java new file mode 100644 index 0000000..0570719 --- /dev/null +++ b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/AuthRemoteApi.java @@ -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 login(@RequestBody LoginRequest request); + + /** 一次一换:撤销旧 refresh、签发新 access+refresh;失败返回 401 */ + @PostMapping("/tokens/refresh") + ApiResponse refresh(@RequestBody RefreshTokenRequest request); + + /** 解析 Authorization 中的 access token 返回当前用户画像(含 roleCodes/clientType) */ + @GetMapping("/users/current") + ApiResponse currentUser( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization); +} diff --git a/backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/factory/RemoteAuthServiceFallbackFactory.java b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/factory/RemoteAuthServiceFallbackFactory.java new file mode 100644 index 0000000..e5be0a2 --- /dev/null +++ b/backend/apis/api-auth/src/main/java/com/k12study/api/auth/remote/factory/RemoteAuthServiceFallbackFactory.java @@ -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 { + + 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 login(LoginRequest request) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse refresh(RefreshTokenRequest request) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse currentUser(String authorization) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + }; + } +} diff --git a/backend/apis/api-upms/pom.xml b/backend/apis/api-upms/pom.xml index 7deda92..49071bc 100644 --- a/backend/apis/api-upms/pom.xml +++ b/backend/apis/api-upms/pom.xml @@ -16,6 +16,21 @@ common-api ${project.version} + + org.springframework.cloud + spring-cloud-starter-openfeign + true + + + org.springframework + spring-web + true + + + org.slf4j + slf4j-api + true + org.projectlombok lombok diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/FileMetadataDto.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/FileMetadataDto.java new file mode 100644 index 0000000..7f04071 --- /dev/null +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/FileMetadataDto.java @@ -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 +) { +} diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/FileUploadRequestDto.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/FileUploadRequestDto.java new file mode 100644 index 0000000..5fc2e27 --- /dev/null +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/FileUploadRequestDto.java @@ -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 +) { +} diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/InboxMessageDto.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/InboxMessageDto.java new file mode 100644 index 0000000..4fd1f47 --- /dev/null +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/InboxMessageDto.java @@ -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 +) { +} diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/MessageReadResultDto.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/MessageReadResultDto.java new file mode 100644 index 0000000..5d81424 --- /dev/null +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/MessageReadResultDto.java @@ -0,0 +1,10 @@ +package com.k12study.api.upms.dto; + +import java.time.Instant; + +public record MessageReadResultDto( + String messageId, + String readStatus, + Instant readAt +) { +} diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassCourseDto.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassCourseDto.java new file mode 100644 index 0000000..79a05bb --- /dev/null +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassCourseDto.java @@ -0,0 +1,8 @@ +package com.k12study.api.upms.dto; + +public record SchoolClassCourseDto( + String classId, + String courseId, + String relationStatus +) { +} diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassDto.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassDto.java new file mode 100644 index 0000000..50f750f --- /dev/null +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassDto.java @@ -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 +) { +} diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassMemberDto.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassMemberDto.java new file mode 100644 index 0000000..6651d1f --- /dev/null +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/dto/SchoolClassMemberDto.java @@ -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 +) { +} diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/UpmsApiPaths.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/UpmsApiPaths.java index 34709bd..99735c9 100644 --- a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/UpmsApiPaths.java +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/UpmsApiPaths.java @@ -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"; } diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/UpmsRemoteApi.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/UpmsRemoteApi.java index 756eb08..798c681 100644 --- a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/UpmsRemoteApi.java +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/UpmsRemoteApi.java @@ -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> routes(); - ApiResponse currentUser(); + @GetMapping("/routes") + ApiResponse> routes( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization); - ApiResponse> areas(); + @GetMapping("/users/current") + ApiResponse currentUser( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization); - ApiResponse> tenants(); + @GetMapping("/areas") + ApiResponse> areas( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization); - ApiResponse> departments(); + @GetMapping("/tenants") + ApiResponse> tenants( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization); + + @GetMapping("/departments") + ApiResponse> departments( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization); + + @GetMapping("/classes") + ApiResponse> classes( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization); + + @GetMapping("/classes/{classId}/members") + ApiResponse> classMembers( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @PathVariable("classId") String classId); + + @GetMapping("/classes/{classId}/courses") + ApiResponse> classCourses( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @PathVariable("classId") String classId); + + @PostMapping("/files/upload") + ApiResponse uploadFile( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @RequestBody FileUploadRequestDto request); + + @GetMapping("/files/{fileId}") + ApiResponse fileById( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @PathVariable("fileId") String fileId); + + @GetMapping("/messages/inbox") + ApiResponse> inboxMessages( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization); + + @PostMapping("/messages/{messageId}/read") + ApiResponse readMessage( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, + @PathVariable("messageId") String messageId); } diff --git a/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/factory/RemoteUpmsServiceFallbackFactory.java b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/factory/RemoteUpmsServiceFallbackFactory.java new file mode 100644 index 0000000..88154f5 --- /dev/null +++ b/backend/apis/api-upms/src/main/java/com/k12study/api/upms/remote/factory/RemoteUpmsServiceFallbackFactory.java @@ -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 { + + 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> routes(String authorization) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse currentUser(String authorization) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse> areas(String authorization) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse> tenants(String authorization) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse> departments(String authorization) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse> classes(String authorization) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse> classMembers(String authorization, String classId) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse> classCourses(String authorization, String classId) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse uploadFile(String authorization, FileUploadRequestDto request) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse fileById(String authorization, String fileId) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse> inboxMessages(String authorization) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + + @Override + public ApiResponse readMessage(String authorization, String messageId) { + return ApiResponse.failure(FALLBACK_CODE, message); + } + }; + } +} diff --git a/backend/auth/pom.xml b/backend/auth/pom.xml index 8aa4f9f..f1543dc 100644 --- a/backend/auth/pom.xml +++ b/backend/auth/pom.xml @@ -30,6 +30,18 @@ api-auth ${project.version} + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.security + spring-security-crypto + + + org.postgresql + postgresql + org.springframework.boot spring-boot-starter-actuator diff --git a/backend/auth/src/main/java/com/k12study/auth/controller/AuthController.java b/backend/auth/src/main/java/com/k12study/auth/controller/AuthController.java index 9e4fbce..d9d3da8 100644 --- a/backend/auth/src/main/java/com/k12study/auth/controller/AuthController.java +++ b/backend/auth/src/main/java/com/k12study/auth/controller/AuthController.java @@ -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 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)); } } diff --git a/backend/auth/src/main/java/com/k12study/auth/service/AuthService.java b/backend/auth/src/main/java/com/k12study/auth/service/AuthService.java index f797383..b02ba51 100644 --- a/backend/auth/src/main/java/com/k12study/auth/service/AuthService.java +++ b/backend/auth/src/main/java/com/k12study/auth/service/AuthService.java @@ -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 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 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 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(); + 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() ); } - var context = RequestUserContextHolder.get(); + + UserRecord user = findUserById(principal.userId()) + .orElseThrow(() -> new BizException(401, "用户不存在或已禁用")); + List roleCodes = findRoleCodes(user.userId()); + + String areaCode = safeAdcode(user.adcode()); + String provinceCode = areaCode.length() >= 2 ? areaCode.substring(0, 2) + "0000" : areaCode; 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") + 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 roleCodes) { + if ("MINI".equals(clientType) && roleCodes.stream().noneMatch("STUDENT"::equalsIgnoreCase)) { + throw new BizException(403, "小程序端仅允许学生账号登录"); + } + } + + private void ensureRoleAssigned(List 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 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 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 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 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 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 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 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 sessions = jdbcTemplate.queryForList(sessionSql, Map.of("userId", userId), String.class); + if (sessions.size() <= 3) { + return; + } + List 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 + ) { + } } diff --git a/backend/auth/src/main/resources/application.yml b/backend/auth/src/main/resources/application.yml index 7382309..48377cf 100644 --- a/backend/auth/src/main/resources/application.yml +++ b/backend/auth/src/main/resources/application.yml @@ -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} diff --git a/backend/boot-dev/src/main/resources/application.yml b/backend/boot-dev/src/main/resources/application.yml index bfc9c7a..9df497c 100644 --- a/backend/boot-dev/src/main/resources/application.yml +++ b/backend/boot-dev/src/main/resources/application.yml @@ -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} diff --git a/backend/common/common-core/src/main/java/com/k12study/common/core/constants/SecurityConstants.java b/backend/common/common-core/src/main/java/com/k12study/common/core/constants/SecurityConstants.java index 440757c..bd64fdf 100644 --- a/backend/common/common-core/src/main/java/com/k12study/common/core/constants/SecurityConstants.java +++ b/backend/common/common-core/src/main/java/com/k12study/common/core/constants/SecurityConstants.java @@ -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() { } diff --git a/backend/common/common-feign/pom.xml b/backend/common/common-feign/pom.xml new file mode 100644 index 0000000..6354f59 --- /dev/null +++ b/backend/common/common-feign/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + com.k12study + k12study-backend + 0.1.0-SNAPSHOT + ../../pom.xml + + common-feign + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + io.github.openfeign + feign-okhttp + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + org.springframework + spring-webmvc + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + true + + + diff --git a/backend/common/common-feign/src/main/java/com/k12study/common/feign/config/FeignAutoConfiguration.java b/backend/common/common-feign/src/main/java/com/k12study/common/feign/config/FeignAutoConfiguration.java new file mode 100644 index 0000000..29117aa --- /dev/null +++ b/backend/common/common-feign/src/main/java/com/k12study/common/feign/config/FeignAutoConfiguration.java @@ -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"); + } +} diff --git a/backend/common/common-feign/src/main/java/com/k12study/common/feign/config/FeignClientProperties.java b/backend/common/common-feign/src/main/java/com/k12study/common/feign/config/FeignClientProperties.java new file mode 100644 index 0000000..a618e13 --- /dev/null +++ b/backend/common/common-feign/src/main/java/com/k12study/common/feign/config/FeignClientProperties.java @@ -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; } +} diff --git a/backend/common/common-feign/src/main/java/com/k12study/common/feign/contract/FeignContractValidator.java b/backend/common/common-feign/src/main/java/com/k12study/common/feign/contract/FeignContractValidator.java new file mode 100644 index 0000000..91e0d38 --- /dev/null +++ b/backend/common/common-feign/src/main/java/com/k12study/common/feign/contract/FeignContractValidator.java @@ -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, 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 provider = + applicationContext.getBeanProvider(RequestMappingHandlerMapping.class); + RequestMappingHandlerMapping handlerMapping = provider.getIfAvailable(); + if (handlerMapping == null) { + return; + } + + Set mvcEndpoints = collectMvcEndpoints(handlerMapping); + if (mvcEndpoints.isEmpty()) { + return; + } + + Set> 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 collectMvcEndpoints(RequestMappingHandlerMapping handlerMapping) { + Set endpoints = new LinkedHashSet<>(); + for (Map.Entry entry : handlerMapping.getHandlerMethods().entrySet()) { + RequestMappingInfo info = entry.getKey(); + Set methods = info.getMethodsCondition().getMethods(); + Set 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> 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> 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) {} +} diff --git a/backend/common/common-feign/src/main/java/com/k12study/common/feign/interceptor/FeignAuthRelayInterceptor.java b/backend/common/common-feign/src/main/java/com/k12study/common/feign/interceptor/FeignAuthRelayInterceptor.java new file mode 100644 index 0000000..c948b90 --- /dev/null +++ b/backend/common/common-feign/src/main/java/com/k12study/common/feign/interceptor/FeignAuthRelayInterceptor.java @@ -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); + } + } +} diff --git a/backend/common/common-feign/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/backend/common/common-feign/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f2acdf2 --- /dev/null +++ b/backend/common/common-feign/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.k12study.common.feign.config.FeignAutoConfiguration diff --git a/backend/common/common-security/src/main/java/com/k12study/common/security/context/RequestUserContext.java b/backend/common/common-security/src/main/java/com/k12study/common/security/context/RequestUserContext.java index c273995..cded222 100644 --- a/backend/common/common-security/src/main/java/com/k12study/common/security/context/RequestUserContext.java +++ b/backend/common/common-security/src/main/java/com/k12study/common/security/context/RequestUserContext.java @@ -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 roleCodes, + String clientType, + String sessionId ) { } diff --git a/backend/common/common-security/src/main/java/com/k12study/common/security/jwt/JwtTokenProvider.java b/backend/common/common-security/src/main/java/com/k12study/common/security/jwt/JwtTokenProvider.java index 2484c76..c227dde 100644 --- a/backend/common/common-security/src/main/java/com/k12study/common/security/jwt/JwtTokenProvider.java +++ b/backend/common/common-security/src/main/java/com/k12study/common/security/jwt/JwtTokenProvider.java @@ -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 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) ); } } diff --git a/backend/common/common-security/src/main/java/com/k12study/common/security/jwt/JwtUserPrincipal.java b/backend/common/common-security/src/main/java/com/k12study/common/security/jwt/JwtUserPrincipal.java index ed4e8bf..ce705aa 100644 --- a/backend/common/common-security/src/main/java/com/k12study/common/security/jwt/JwtUserPrincipal.java +++ b/backend/common/common-security/src/main/java/com/k12study/common/security/jwt/JwtUserPrincipal.java @@ -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 roleCodes, + String clientType, + String sessionId ) { } diff --git a/backend/common/common-web/src/main/java/com/k12study/common/web/config/CommonWebMvcConfiguration.java b/backend/common/common-web/src/main/java/com/k12study/common/web/config/CommonWebMvcConfiguration.java index 1b0a2ad..837c732 100644 --- a/backend/common/common-web/src/main/java/com/k12study/common/web/config/CommonWebMvcConfiguration.java +++ b/backend/common/common-web/src/main/java/com/k12study/common/web/config/CommonWebMvcConfiguration.java @@ -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 parseRoleCodes(String roleCodesHeader) { + if (roleCodesHeader == null || roleCodesHeader.isBlank()) { + return List.of(); + } + return Arrays.stream(roleCodesHeader.split(",")) + .map(String::trim) + .filter(code -> !code.isBlank()) + .toList(); + } } diff --git a/backend/common/pom.xml b/backend/common/pom.xml index 922648f..49c9f14 100644 --- a/backend/common/pom.xml +++ b/backend/common/pom.xml @@ -20,5 +20,6 @@ common-security common-mybatis common-redis + common-feign diff --git a/backend/gateway/src/main/java/com/k12study/gateway/filter/JwtRelayFilter.java b/backend/gateway/src/main/java/com/k12study/gateway/filter/JwtRelayFilter.java index 88b4ebf..c92bdaf 100644 --- a/backend/gateway/src/main/java/com/k12study/gateway/filter/JwtRelayFilter.java +++ b/backend/gateway/src/main/java/com/k12study/gateway/filter/JwtRelayFilter.java @@ -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 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 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; + } } diff --git a/backend/pom.xml b/backend/pom.xml index 5809c6c..121213b 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -27,6 +27,7 @@ 3.3.5 2023.0.3 + 2023.0.3.2 3.5.7 42.7.4 0.12.6 @@ -49,6 +50,13 @@ pom import + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + ${spring.cloud.alibaba.version} + pom + import + com.baomidou diff --git a/backend/upms/src/main/java/com/k12study/upms/controller/UpmsController.java b/backend/upms/src/main/java/com/k12study/upms/controller/UpmsController.java index 54498d3..45b5f89 100644 --- a/backend/upms/src/main/java/com/k12study/upms/controller/UpmsController.java +++ b/backend/upms/src/main/java/com/k12study/upms/controller/UpmsController.java @@ -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> routes() { - return ApiResponse.success(upmsQueryService.routes()); + public ApiResponse> routes( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { + return ApiResponse.success(upmsQueryService.routes(authorizationHeader)); } @GetMapping("/areas") - public ApiResponse> areas() { - return ApiResponse.success(upmsQueryService.areas()); + public ApiResponse> areas( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { + return ApiResponse.success(upmsQueryService.areas(authorizationHeader)); } @GetMapping("/tenants") - public ApiResponse> tenants() { - return ApiResponse.success(upmsQueryService.tenants()); + public ApiResponse> tenants( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { + return ApiResponse.success(upmsQueryService.tenants(authorizationHeader)); } @GetMapping("/departments") - public ApiResponse> departments() { - return ApiResponse.success(upmsQueryService.departments()); + public ApiResponse> departments( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { + return ApiResponse.success(upmsQueryService.departments(authorizationHeader)); } @GetMapping("/users/current") - public ApiResponse currentUser() { - return ApiResponse.success(upmsQueryService.currentUser()); + public ApiResponse currentUser( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { + return ApiResponse.success(upmsQueryService.currentUser(authorizationHeader)); + } + + @GetMapping("/classes") + public ApiResponse> classes( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { + return ApiResponse.success(upmsQueryService.classes(authorizationHeader)); + } + + @GetMapping("/classes/{classId}/members") + public ApiResponse> classMembers( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @PathVariable String classId) { + return ApiResponse.success(upmsQueryService.classMembers(authorizationHeader, classId)); + } + + @GetMapping("/classes/{classId}/courses") + public ApiResponse> classCourses( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @PathVariable String classId) { + return ApiResponse.success(upmsQueryService.classCourses(authorizationHeader, classId)); + } + + @PostMapping("/files/upload") + public ApiResponse uploadFile( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @RequestBody FileUploadRequestDto request) { + return ApiResponse.success("上传登记成功", upmsQueryService.uploadFile(authorizationHeader, request)); + } + + @GetMapping("/files/{fileId}") + public ApiResponse fileById( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @PathVariable String fileId) { + return ApiResponse.success(upmsQueryService.fileById(authorizationHeader, fileId)); + } + + @GetMapping("/messages/inbox") + public ApiResponse> inboxMessages( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { + return ApiResponse.success(upmsQueryService.inboxMessages(authorizationHeader)); + } + + @PostMapping("/messages/{messageId}/read") + public ApiResponse readMessage( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @PathVariable String messageId) { + return ApiResponse.success("已标记已读", upmsQueryService.readMessage(authorizationHeader, messageId)); } } diff --git a/backend/upms/src/main/java/com/k12study/upms/service/UpmsQueryService.java b/backend/upms/src/main/java/com/k12study/upms/service/UpmsQueryService.java index 438f9d5..92578f1 100644 --- a/backend/upms/src/main/java/com/k12study/upms/service/UpmsQueryService.java +++ b/backend/upms/src/main/java/com/k12study/upms/service/UpmsQueryService.java @@ -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 routes(); + List routes(String authorizationHeader); - List areas(); + List areas(String authorizationHeader); - List tenants(); + List tenants(String authorizationHeader); - List departments(); + List departments(String authorizationHeader); - CurrentRouteUserDto currentUser(); + CurrentRouteUserDto currentUser(String authorizationHeader); + + List classes(String authorizationHeader); + + List classMembers(String authorizationHeader, String classId); + + List classCourses(String authorizationHeader, String classId); + + FileMetadataDto uploadFile(String authorizationHeader, FileUploadRequestDto request); + + FileMetadataDto fileById(String authorizationHeader, String fileId); + + List inboxMessages(String authorizationHeader); + + MessageReadResultDto readMessage(String authorizationHeader, String messageId); } diff --git a/backend/upms/src/main/java/com/k12study/upms/service/impl/UpmsQueryServiceImpl.java b/backend/upms/src/main/java/com/k12study/upms/service/impl/UpmsQueryServiceImpl.java index c8ec429..e0d2b82 100644 --- a/backend/upms/src/main/java/com/k12study/upms/service/impl/UpmsQueryServiceImpl.java +++ b/backend/upms/src/main/java/com/k12study/upms/service/impl/UpmsQueryServiceImpl.java @@ -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 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 areas() { - return List.of( - new AreaNodeDto( - "330000", - "100000", - "浙江省", - "PROVINCE", - List.of( - new AreaNodeDto( - "330100", - "330000", - "杭州市", - "CITY", - List.of() - ) - ) - ) - ); + public List 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 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 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 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 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 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 tenants(String authorizationHeader) { + AuthContext context = requireAuth(authorizationHeader); + List 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 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 departments(String authorizationHeader) { + AuthContext context = requireAuth(authorizationHeader); + List 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 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 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 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 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 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 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 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 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 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 buildRouteTree(List rows) { + if (rows.isEmpty()) { + return List.of(); + } + Map rowMap = new LinkedHashMap<>(); + Map> 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 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 rowMap, + Map> childrenMap) { + RouteRow row = rowMap.get(routeId); + List 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 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 buildAreaTree(List rows) { + Map rowMap = rows.stream() + .collect(Collectors.toMap(AreaRow::id, row -> row, (a, b) -> a, LinkedHashMap::new)); + Map> 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 rowMap, Map> childMap) { + AreaRow row = rowMap.get(id); + List 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 buildTenantTree(List rows) { + Map rowMap = rows.stream() + .collect(Collectors.toMap(TenantRow::tenantId, row -> row, (a, b) -> a, LinkedHashMap::new)); + Map> 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 rowMap, + Map> childMap) { + TenantRow row = rowMap.get(tenantId); + List 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 buildDeptTree(List rows) { + Map rowMap = rows.stream() + .collect(Collectors.toMap(DeptRow::deptId, row -> row, (a, b) -> a, LinkedHashMap::new)); + Map> 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 rowMap, + Map> childMap) { + DeptRow row = rowMap.get(deptId); + List 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 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 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 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 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 + ) { + } } diff --git a/backend/upms/src/main/resources/application.yml b/backend/upms/src/main/resources/application.yml index 0b32ebb..705e741 100644 --- a/backend/upms/src/main/resources/application.yml +++ b/backend/upms/src/main/resources/application.yml @@ -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: diff --git a/docs/architecture/数据流图(多角色).drawio b/docs/architecture/数据流图(多角色).drawio index f620364..9c398aa 100644 --- a/docs/architecture/数据流图(多角色).drawio +++ b/docs/architecture/数据流图(多角色).drawio @@ -1,6 +1,6 @@ - + @@ -10,16 +10,16 @@ - + - - + + - + - + @@ -148,8 +148,11 @@ - - + + + + + diff --git a/docs/plan/modules-todo/upms-auth-frontend-backend-implementation.md b/docs/plan/modules-todo/upms-auth-frontend-backend-implementation.md new file mode 100644 index 0000000..e5b22c5 --- /dev/null +++ b/docs/plan/modules-todo/upms-auth-frontend-backend-implementation.md @@ -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 前端 -> 双模式联调”执行,避免前端先行导致接口反复改动。 diff --git a/docs/plan/modules/auth.md b/docs/plan/modules/auth.md new file mode 100644 index 0000000..7b5dec5 --- /dev/null +++ b/docs/plan/modules/auth.md @@ -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` 角色,老师与机构角色不开放小程序登录。 diff --git a/docs/plan/modules/course.md b/docs/plan/modules/course.md new file mode 100644 index 0000000..dbd1194 --- /dev/null +++ b/docs/plan/modules/course.md @@ -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)。 diff --git a/docs/plan/modules/gateway.md b/docs/plan/modules/gateway.md new file mode 100644 index 0000000..af5c092 --- /dev/null +++ b/docs/plan/modules/gateway.md @@ -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)统一口径。 diff --git a/docs/plan/modules/question.md b/docs/plan/modules/question.md new file mode 100644 index 0000000..a7f374d --- /dev/null +++ b/docs/plan/modules/question.md @@ -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 服务,或先以规则模板生成保障可交付。 +* 费曼评估通过阈值与回流规则(与系统文档口径)是否纳入一期强约束。 diff --git a/docs/plan/modules/upms.md b/docs/plan/modules/upms.md new file mode 100644 index 0000000..6695c5f --- /dev/null +++ b/docs/plan/modules/upms.md @@ -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 端路由资源管理;小程序端当前不做动态路由。 +* 学生手机号换绑不启用“原手机号确认 + 班级归属校验 + 冷静期”三段式流程,采用手机号验证或线下联系老师处理。 diff --git a/docs/plan/modules/向量知识库.md b/docs/plan/modules/向量知识库.md new file mode 100644 index 0000000..72024d6 --- /dev/null +++ b/docs/plan/modules/向量知识库.md @@ -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/教师内容边界容易在实现时被混用。 +## 需确认的设计决策 +* 首期是否先做“单向量模型 + 单索引集合”简化方案,再演进多模型路由。 +* 向量检索与图检索的融合策略首期采用串行还是并行(推荐串行,便于控制复杂度)。 +* 检索日志保留周期与脱敏策略(涉及学生查询内容合规)。 diff --git a/docs/plan/modules/知识图谱.md b/docs/plan/modules/知识图谱.md new file mode 100644 index 0000000..fff3b6c --- /dev/null +++ b/docs/plan/modules/知识图谱.md @@ -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(推荐模板化)。 +* 总部知识对分校是否默认可见,或需要租户级授权开关。 +* 图谱实体冲突(同名知识点跨租户)采用“租户隔离命名空间”还是“全局实体+租户可见边”模型。 diff --git a/docs/plan/upms.md b/docs/plan/upms.md new file mode 100644 index 0000000..aebd06d --- /dev/null +++ b/docs/plan/upms.md @@ -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日志 diff --git a/frontend/package.json b/frontend/package.json index 35aa3ff..cbe0c76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec1e335..8c99654 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { AppRouter } from "./router/AppRouter"; +import { AppRouter } from "@/router"; export default function App() { return ; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index fe48925..863a601 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -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>("/auth/tokens", input); + return http.post>("/auth/tokens", input); } export async function refreshToken(refreshToken: string) { - return http.post>("/auth/tokens/refresh", { + return http.post>("/auth/tokens/refresh", { refreshToken }); } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..c6dbbde --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,2 @@ +export * from "./auth"; +export * from "./upms"; diff --git a/frontend/src/api/upms.ts b/frontend/src/api/upms.ts index 05d4022..7c6f6e5 100644 --- a/frontend/src/api/upms.ts +++ b/frontend/src/api/upms.ts @@ -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 { - const response = await getUpmsRoutesRemote(); + const response = await http.get>("/upms/routes"); return response.data as RouteNode[]; } export async function fetchCurrentUser(): Promise { - const response = await getUpmsCurrentUserRemote(); + const response = await http.get>("/upms/users/current"); return response.data as CurrentRouteUser; } export async function fetchAreas(): Promise { - const response = await getUpmsAreasRemote(); + const response = await http.get>("/upms/areas"); return normalizeAreaNodes(response.data as UpmsAreaNode[]); } export async function fetchTenants(): Promise { - const response = await getUpmsTenantsRemote(); + const response = await http.get>("/upms/tenants"); return normalizeTenantNodes(response.data as UpmsTenantNode[]); } export async function fetchDepartments(): Promise { - const response = await getUpmsDepartmentsRemote(); + const response = await http.get>("/upms/departments"); return normalizeDeptNodes(response.data as UpmsDeptNode[]); } + +export async function fetchClasses(): Promise { + const response = await http.get>("/upms/classes"); + return response.data as UpmsClass[]; +} + +export async function fetchClassMembers(classId: string): Promise { + const response = await http.get>( + `/upms/classes/${encodeURIComponent(classId)}/members` + ); + return response.data as UpmsClassMember[]; +} + +export async function fetchClassCourses(classId: string): Promise { + const response = await http.get>( + `/upms/classes/${encodeURIComponent(classId)}/courses` + ); + return response.data as UpmsClassCourse[]; +} + +export async function uploadFileMetadata(request: UpmsFileUploadRequest): Promise { + const response = await http.post>("/upms/files/upload", request); + return response.data as UpmsFileMetadata; +} + +export async function fetchFileMetadata(fileId: string): Promise { + const response = await http.get>(`/upms/files/${encodeURIComponent(fileId)}`); + return response.data as UpmsFileMetadata; +} + +export async function fetchInboxMessages(): Promise { + const response = await http.get>("/upms/messages/inbox"); + return response.data as UpmsInboxMessage[]; +} + +export async function markInboxMessageRead(messageId: string): Promise { + const response = await http.post>( + `/upms/messages/${encodeURIComponent(messageId)}/read` + ); + return response.data as UpmsMessageReadResult; +} diff --git a/frontend/src/components/AppCard/index.scss b/frontend/src/components/AppCard/index.scss new file mode 100644 index 0000000..b115f5e --- /dev/null +++ b/frontend/src/components/AppCard/index.scss @@ -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; +} diff --git a/frontend/src/components/AppCard.tsx b/frontend/src/components/AppCard/index.tsx similarity index 95% rename from frontend/src/components/AppCard.tsx rename to frontend/src/components/AppCard/index.tsx index 1bd0d25..232b002 100644 --- a/frontend/src/components/AppCard.tsx +++ b/frontend/src/components/AppCard/index.tsx @@ -1,4 +1,5 @@ import type { PropsWithChildren, ReactNode } from "react"; +import "./index.scss"; type AppCardProps = PropsWithChildren<{ title: string; diff --git a/frontend/src/components/LoadingView/index.scss b/frontend/src/components/LoadingView/index.scss new file mode 100644 index 0000000..ec50c52 --- /dev/null +++ b/frontend/src/components/LoadingView/index.scss @@ -0,0 +1,6 @@ +.loading-view { + display: grid; + place-items: center; + min-height: 100vh; + color: #5b6475; +} diff --git a/frontend/src/components/LoadingView.tsx b/frontend/src/components/LoadingView/index.tsx similarity index 85% rename from frontend/src/components/LoadingView.tsx rename to frontend/src/components/LoadingView/index.tsx index 5f7c82a..05ddc81 100644 --- a/frontend/src/components/LoadingView.tsx +++ b/frontend/src/components/LoadingView/index.tsx @@ -1,3 +1,5 @@ +import "./index.scss"; + export function LoadingView({ message = "Loading..." }: { message?: string }) { return
{message}
; } diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 0000000..92f03bd --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,2 @@ +export * from "./AppCard"; +export * from "./LoadingView"; diff --git a/frontend/src/layouts/DefaultLayout/index.scss b/frontend/src/layouts/DefaultLayout/index.scss new file mode 100644 index 0000000..b317cf1 --- /dev/null +++ b/frontend/src/layouts/DefaultLayout/index.scss @@ -0,0 +1,10 @@ +.default-layout { + min-height: 100vh; + padding: 24px; +} + +@media (max-width: 768px) { + .default-layout { + padding: 16px; + } +} diff --git a/frontend/src/layouts/DefaultLayout.tsx b/frontend/src/layouts/DefaultLayout/index.tsx similarity index 88% rename from frontend/src/layouts/DefaultLayout.tsx rename to frontend/src/layouts/DefaultLayout/index.tsx index ee99d42..9e54116 100644 --- a/frontend/src/layouts/DefaultLayout.tsx +++ b/frontend/src/layouts/DefaultLayout/index.tsx @@ -1,4 +1,5 @@ import { Outlet } from "react-router-dom"; +import "./index.scss"; export function DefaultLayout() { return ( diff --git a/frontend/src/layouts/SidebarLayout/index.scss b/frontend/src/layouts/SidebarLayout/index.scss new file mode 100644 index 0000000..ffce8d4 --- /dev/null +++ b/frontend/src/layouts/SidebarLayout/index.scss @@ -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; + } +} diff --git a/frontend/src/layouts/SidebarLayout.tsx b/frontend/src/layouts/SidebarLayout/index.tsx similarity index 96% rename from frontend/src/layouts/SidebarLayout.tsx rename to frontend/src/layouts/SidebarLayout/index.tsx index daef904..424e8bb 100644 --- a/frontend/src/layouts/SidebarLayout.tsx +++ b/frontend/src/layouts/SidebarLayout/index.tsx @@ -1,4 +1,5 @@ import { Outlet, useLocation } from "react-router-dom"; +import "./index.scss"; export function SidebarLayout() { const location = useLocation(); diff --git a/frontend/src/layouts/index.ts b/frontend/src/layouts/index.ts new file mode 100644 index 0000000..a2de4d1 --- /dev/null +++ b/frontend/src/layouts/index.ts @@ -0,0 +1,2 @@ +export * from "./DefaultLayout"; +export * from "./SidebarLayout"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index aa0700f..dab932e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx deleted file mode 100644 index 591bd1a..0000000 --- a/frontend/src/pages/DashboardPage.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { AppCard } from "../components/AppCard"; - -export function DashboardPage() { - return ( - -

当前页面用于承接首版后台管理端骨架,后续可继续补充机构端、教师端等业务模块。

-
- ); -} diff --git a/frontend/src/pages/DashboardPage/index.scss b/frontend/src/pages/DashboardPage/index.scss new file mode 100644 index 0000000..9643c22 --- /dev/null +++ b/frontend/src/pages/DashboardPage/index.scss @@ -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; +} diff --git a/frontend/src/pages/DashboardPage/index.tsx b/frontend/src/pages/DashboardPage/index.tsx new file mode 100644 index 0000000..db847dd --- /dev/null +++ b/frontend/src/pages/DashboardPage/index.tsx @@ -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(null); + const [currentUser, setCurrentUser] = useState(null); + const [classes, setClasses] = useState([]); + const [messages, setMessages] = useState([]); + + 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 ( +
+ + {loading ?

正在加载用户信息...

: null} + {currentUser ? ( +
    +
  • 用户:{currentUser.displayName}({currentUser.username})
  • +
  • 租户:{currentUser.tenantId}
  • +
  • 部门:{currentUser.deptId}
  • +
  • 权限数:{currentUser.permissionCodes.length}
  • +
+ ) : null} +
+ + + {loading ?

正在加载班级...

: null} + {classes.length > 0 ? ( +
    + {classes.slice(0, 5).map((item) => ( +
  • + {item.className}({item.classCode || "未编码"}) +
  • + ))} +
+ ) : !loading ? ( +

暂无班级数据

+ ) : null} +
+ + + {loading ?

正在加载站内信...

: null} + {messages.length > 0 ? ( +
    + {messages.slice(0, 5).map((message) => ( +
  • + + [{message.readStatus}] {message.title} + + {message.readStatus === "UNREAD" ? ( + + ) : null} +
  • + ))} +
+ ) : !loading ? ( +

暂无站内信

+ ) : null} + {error ?

{error}

: null} +
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx deleted file mode 100644 index fa764b9..0000000 --- a/frontend/src/pages/LoginPage.tsx +++ /dev/null @@ -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 ( -
-
-

K12Study Admin

- setForm({ ...form, username: event.target.value })} - placeholder="用户名" - /> - setForm({ ...form, password: event.target.value })} - placeholder="密码" - /> - -
-
- ); -} diff --git a/frontend/src/pages/LoginPage/index.scss b/frontend/src/pages/LoginPage/index.scss new file mode 100644 index 0000000..d447045 --- /dev/null +++ b/frontend/src/pages/LoginPage/index.scss @@ -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; +} diff --git a/frontend/src/pages/LoginPage/index.tsx b/frontend/src/pages/LoginPage/index.tsx new file mode 100644 index 0000000..7ed084c --- /dev/null +++ b/frontend/src/pages/LoginPage/index.tsx @@ -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(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 ( +
+
+

K12Study Admin

+ setForm({ ...form, username: event.target.value })} + placeholder="用户名" + /> + setForm({ ...form, password: event.target.value })} + placeholder="密码" + /> + {error ?

{error}

: null} + +
+
+ ); +} diff --git a/frontend/src/pages/NotFoundPage/index.scss b/frontend/src/pages/NotFoundPage/index.scss new file mode 100644 index 0000000..3fc2952 --- /dev/null +++ b/frontend/src/pages/NotFoundPage/index.scss @@ -0,0 +1,6 @@ +.not-found { + display: grid; + place-items: center; + min-height: 100vh; + color: #5b6475; +} diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage/index.tsx similarity index 90% rename from frontend/src/pages/NotFoundPage.tsx rename to frontend/src/pages/NotFoundPage/index.tsx index be6a6cf..8796638 100644 --- a/frontend/src/pages/NotFoundPage.tsx +++ b/frontend/src/pages/NotFoundPage/index.tsx @@ -1,4 +1,5 @@ import { Link } from "react-router-dom"; +import "./index.scss"; export function NotFoundPage() { return ( diff --git a/frontend/src/pages/RoutePlaceholderPage.tsx b/frontend/src/pages/RoutePlaceholderPage.tsx deleted file mode 100644 index 4c9a486..0000000 --- a/frontend/src/pages/RoutePlaceholderPage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useLocation } from "react-router-dom"; -import { AppCard } from "../components/AppCard"; - -export function RoutePlaceholderPage() { - const location = useLocation(); - - return ( - -

当前路由:{location.pathname}

-

这里先用于承接动态菜单和页面挂载,后续再补真实业务实现。

-
- ); -} diff --git a/frontend/src/pages/RoutePlaceholderPage/index.scss b/frontend/src/pages/RoutePlaceholderPage/index.scss new file mode 100644 index 0000000..d9b24bd --- /dev/null +++ b/frontend/src/pages/RoutePlaceholderPage/index.scss @@ -0,0 +1,7 @@ +.route-placeholder p { + margin: 0 0 8px; +} + +.route-placeholder p:last-child { + margin-bottom: 0; +} diff --git a/frontend/src/pages/RoutePlaceholderPage/index.tsx b/frontend/src/pages/RoutePlaceholderPage/index.tsx new file mode 100644 index 0000000..277b22e --- /dev/null +++ b/frontend/src/pages/RoutePlaceholderPage/index.tsx @@ -0,0 +1,16 @@ +import { AppCard } from "@/components"; +import { useLocation } from "react-router-dom"; +import "./index.scss"; + +export function RoutePlaceholderPage() { + const location = useLocation(); + + return ( +
+ +

当前路由:{location.pathname}

+

这里先用于承接动态菜单和页面挂载,后续再补真实业务实现。

+
+
+ ); +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts new file mode 100644 index 0000000..0933708 --- /dev/null +++ b/frontend/src/pages/index.ts @@ -0,0 +1,4 @@ +export * from "./DashboardPage"; +export * from "./LoginPage"; +export * from "./NotFoundPage"; +export * from "./RoutePlaceholderPage"; diff --git a/frontend/src/remote/upmsRemote.ts b/frontend/src/remote/upmsRemote.ts deleted file mode 100644 index 183f5af..0000000 --- a/frontend/src/remote/upmsRemote.ts +++ /dev/null @@ -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>("/upms/routes"); -} - -export function getUpmsCurrentUserRemote() { - return http.get>("/upms/users/current"); -} - -export function getUpmsAreasRemote() { - return http.get>("/upms/areas"); -} - -export function getUpmsTenantsRemote() { - return http.get>("/upms/tenants"); -} - -export function getUpmsDepartmentsRemote() { - return http.get>("/upms/departments"); -} diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index 8d4cb11..2b134b0 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -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: - }; -} - -function buildRoutes(dynamicRoutes: RouteNode[]): RouteObject[] { - const grouped = dynamicRoutes.reduce>((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: , - children: routes.map(toChildRoute) - } satisfies RouteObject; - }); - - return [ - { path: "/login", element: }, - ...layoutRoutes, - { path: "*", element: } - ]; -} +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([]); - 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: }]; - } - - const nextRoutes = buildRoutes(dynamicRoutes); - if (dynamicRoutes.length === 0) { - nextRoutes.unshift({ path: "/", element: }); - } - return nextRoutes; - }, [dynamicRoutes, loading]); + const routerData = useDynamicRouterData(); + const routes = useMemo( + () => renderAppRoutes(routerData), + [routerData.authed, routerData.loading, routerData.loadError, routerData.dynamicRoutes] + ); return useRoutes(routes); } diff --git a/frontend/src/router/dynamic-router.ts b/frontend/src/router/dynamic-router.ts new file mode 100644 index 0000000..41ae389 --- /dev/null +++ b/frontend/src/router/dynamic-router.ts @@ -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([]); + 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 + }; +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..6b6c0df --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1 @@ +export * from "./AppRouter"; diff --git a/frontend/src/router/router-renderer.tsx b/frontend/src/router/router-renderer.tsx new file mode 100644 index 0000000..92b3721 --- /dev/null +++ b/frontend/src/router/router-renderer.tsx @@ -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: + }; +} + +function buildLayoutRoutes(dynamicRoutes: RouteNode[]): RouteObject[] { + const grouped = dynamicRoutes.reduce>((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: , + children: routes.map(toChildRoute) + } satisfies RouteObject; + }); +} + +export function renderAppRoutes(state: RouterStateInput): RouteObject[] { + const scene = resolveRouterScene(state); + + if (scene === "UNAUTHED") { + return [ + { path: "/login", element: }, + { path: "*", element: } + ]; + } + if (scene === "LOADING") { + return [{ path: "*", element: }]; + } + if (scene === "LOAD_ERROR") { + return [{ path: "*", element: }]; + } + + return [ + { path: "/login", element: }, + ...buildLayoutRoutes(state.dynamicRoutes), + { path: "*", element: } + ]; +} diff --git a/frontend/src/router/static-router.ts b/frontend/src/router/static-router.ts new file mode 100644 index 0000000..cf4a690 --- /dev/null +++ b/frontend/src/router/static-router.ts @@ -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"; +} diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts new file mode 100644 index 0000000..3a46e9e --- /dev/null +++ b/frontend/src/store/index.ts @@ -0,0 +1 @@ +export * from "./session"; diff --git a/frontend/src/store/session.ts b/frontend/src/store/session.ts index 82f0d6f..e3a684f 100644 --- a/frontend/src/store/session.ts +++ b/frontend/src/store/session.ts @@ -1,9 +1,9 @@ -import { clearAccessToken, getAccessToken } from "../utils/storage"; +import { clearTokens, getAccessToken } from "@/utils"; export function isAuthenticated() { return Boolean(getAccessToken()); } export function signOut() { - clearAccessToken(); + clearTokens(); } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css deleted file mode 100644 index 91e16a7..0000000 --- a/frontend/src/styles/app.css +++ /dev/null @@ -1,120 +0,0 @@ -:root { - color: #1a1f36; - background: linear-gradient(180deg, #eef2ff 0%, #f8fafc 100%); - font-family: "Segoe UI", "PingFang SC", sans-serif; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; -} - -a { - color: #2563eb; -} - -.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; -} - -.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; -} - -.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; -} - -.loading-view, -.not-found { - display: grid; - place-items: center; - min-height: 100vh; - color: #5b6475; -} - -@media (max-width: 768px) { - .shell { - grid-template-columns: 1fr; - } - - .shell__sidebar { - padding: 16px; - } - - .shell__content { - padding: 16px; - } -} diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss new file mode 100644 index 0000000..ae70842 --- /dev/null +++ b/frontend/src/styles/index.scss @@ -0,0 +1,17 @@ +:root { + color: #1a1f36; + background: linear-gradient(180deg, #eef2ff 0%, #f8fafc 100%); + font-family: "Segoe UI", "PingFang SC", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +a { + color: #2563eb; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..07ab9af --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,3 @@ +export * from "./api"; +export * from "./route"; +export * from "./upms"; diff --git a/frontend/src/types/route.ts b/frontend/src/types/route.ts index 5071e7d..6523914 100644 --- a/frontend/src/types/route.ts +++ b/frontend/src/types/route.ts @@ -3,7 +3,7 @@ import type { UpmsLayoutType, UpmsRouteMeta, UpmsRouteNode -} from "./upms"; +} from "@/types/upms"; export type LayoutType = UpmsLayoutType; export type RouteMeta = UpmsRouteMeta; diff --git a/frontend/src/types/upms.ts b/frontend/src/types/upms.ts index 49d1527..a4c8f6a 100644 --- a/frontend/src/types/upms.ts +++ b/frontend/src/types/upms.ts @@ -58,3 +58,73 @@ export interface UpmsDeptNode { deptPath: string; children: UpmsDeptNode[]; } + +export interface UpmsClass { + classId: string; + classCode: string; + className: string; + gradeCode: string; + status: string; + tenantId: string; + deptId: string; +} + +export interface UpmsClassMember { + classId: string; + userId: string; + username: string; + displayName: string; + memberRole: string; + memberStatus: string; + joinedAt: string; + leftAt: string | null; +} + +export interface UpmsClassCourse { + classId: string; + courseId: string; + relationStatus: string; +} + +export interface UpmsFileUploadRequest { + mediaType: string; + objectKey: string; + fileName?: string; + mimeType?: string; + fileSize?: number; + fileHash?: string; + durationMs?: number; +} + +export interface UpmsFileMetadata { + fileId: string; + mediaType: string; + objectKey: string; + fileName: string | null; + mimeType: string | null; + fileSize: number | null; + fileHash: string | null; + durationMs: number | null; + uploadedBy: string | null; + tenantId: string; + tenantPath: string | null; + createdAt: string; +} + +export interface UpmsInboxMessage { + messageId: string; + messageType: string; + bizType: string; + title: string; + content: string; + webJumpUrl: string; + readStatus: "UNREAD" | "READ" | "ARCHIVED"; + readAt: string | null; + sendAt: string; +} + +export interface UpmsMessageReadResult { + messageId: string; + readStatus: "READ" | "UNREAD" | "ARCHIVED"; + readAt: string | null; +} diff --git a/frontend/src/utils/http.ts b/frontend/src/utils/http.ts index 953d56d..ee2e67f 100644 --- a/frontend/src/utils/http.ts +++ b/frontend/src/utils/http.ts @@ -1,5 +1,12 @@ -import type { ApiResponse } from "../types/api"; -import { getAccessToken } from "./storage"; +/** + * @description Web 端统一 HTTP 客户端;自动注入 Authorization、401 触发 refresh token 轮换、并发刷新合并、刷新失败跳登录 + * @filename http.ts + * @author wangys + * @copyright xyzh + * @since 2026-04-17 + */ +import type { ApiResponse } from "@/types"; +import { clearTokens, getAccessToken, getRefreshToken, setTokens } from "@/utils/storage"; const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"; const DEFAULT_TIMEOUT = 10000; @@ -11,8 +18,11 @@ interface RequestOptions { body?: unknown; headers?: Record; timeout?: number; + skipAuthRefresh?: boolean; } +let refreshPromise: Promise | null = null; + function isApiResponse(payload: unknown): payload is ApiResponse { if (typeof payload !== "object" || payload === null) { return false; @@ -28,6 +38,10 @@ function buildUrl(path: string) { return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`; } +function isAuthTokenPath(path: string) { + return path.startsWith("/auth/tokens"); +} + async function parseResponse(response: Response) { if (response.status === 204) { return null; @@ -59,6 +73,17 @@ async function request(path: string, options: RequestOptions = {}): Promise(path, { ...options, skipAuthRefresh: true }); + } + clearTokens(); + if (typeof window !== "undefined" && window.location.pathname !== "/login") { + window.location.replace("/login"); + } + throw new Error("登录已过期,请重新登录"); + } if (!response.ok) { const message = typeof payload === "object" && payload !== null && "message" in payload @@ -76,6 +101,48 @@ async function request(path: string, options: RequestOptions = {}): Promise { + if (refreshPromise) { + return refreshPromise; + } + + const refreshToken = getRefreshToken(); + if (!refreshToken) { + return null; + } + + refreshPromise = (async () => { + try { + const response = await fetch(buildUrl("/auth/tokens/refresh"), { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ refreshToken }) + }); + const payload = await parseResponse(response); + if (!response.ok) { + return null; + } + if (!isApiResponse(payload) || payload.code !== 0 || !payload.data) { + return null; + } + const tokenData = payload.data as { accessToken?: string; refreshToken?: string }; + if (!tokenData.accessToken || !tokenData.refreshToken) { + return null; + } + setTokens(tokenData.accessToken, tokenData.refreshToken); + return tokenData.accessToken; + } catch (error) { + return null; + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; +} + export const http = { get(path: string, options?: Omit) { return request(path, { ...options, method: "GET" }); diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts new file mode 100644 index 0000000..fb3faca --- /dev/null +++ b/frontend/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./http"; +export * from "./storage"; diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts index f75abe6..da50494 100644 --- a/frontend/src/utils/storage.ts +++ b/frontend/src/utils/storage.ts @@ -1,13 +1,34 @@ const ACCESS_TOKEN_KEY = "k12study.access-token"; +const REFRESH_TOKEN_KEY = "k12study.refresh-token"; export function getAccessToken() { return localStorage.getItem(ACCESS_TOKEN_KEY); } +export function getRefreshToken() { + return localStorage.getItem(REFRESH_TOKEN_KEY); +} export function setAccessToken(token: string) { localStorage.setItem(ACCESS_TOKEN_KEY, token); } +export function setRefreshToken(token: string) { + localStorage.setItem(REFRESH_TOKEN_KEY, token); +} + +export function setTokens(accessToken: string, refreshToken: string) { + setAccessToken(accessToken); + setRefreshToken(refreshToken); +} export function clearAccessToken() { localStorage.removeItem(ACCESS_TOKEN_KEY); } + +export function clearRefreshToken() { + localStorage.removeItem(REFRESH_TOKEN_KEY); +} + +export function clearTokens() { + clearAccessToken(); + clearRefreshToken(); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c809e72..a022ebf 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -15,8 +15,10 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "baseUrl": ".", - "types": ["vite/client"] + "paths": { + "@/*": ["./src/*"] + }, + "types": ["vite/client", "node"] }, "include": ["src", "vite.config.ts"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7bb596c..b6d4c96 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,8 +1,14 @@ +import path from "node:path"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src") + } + }, server: { port: 5173, proxy: { diff --git a/global.code-snippets b/global.code-snippets deleted file mode 100644 index 8530207..0000000 --- a/global.code-snippets +++ /dev/null @@ -1,65 +0,0 @@ -{ - // VS Code 原生 snippet 不能直接读取 git config user.name。 - // 团队统一文件头请使用 turbo-file-header 插件,配置见 .fileheader/fileheader.config.yaml。 - // Place your global snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and - // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope - // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is - // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: - // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. - // Placeholders with the same ids are connected. - // Example: - // "Print to console": { - // "scope": "javascript,typescript", - // "prefix": "log", - // "body": [ - // "console.log('$1');", - // "$2" - // ], - // "description": "Log output to console" - // } - "FileHeader":{ - "prefix": ".fileheader", - "body": [ - "/**", - " * @description ${TM_FILENAME}文件描述", - " * @filename ${TM_FILENAME}", - " * @author ${1:wangys}", - " * @copyright xyzh", - " * @since $CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE", - " */" - ], - "description": "文件头描述(静态备用,author 默认值同步 git user.name)" - }, - - "Method":{ - "prefix": ".func", - "body": [ - "/**", - " * @description 函数描述", - " * @param ", - " * @return 返回值描述", - " * @author ${1:wangys}", - " * @since $CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE", - " */" - ], - "description": "函数描述(静态备用,author 默认值同步 git user.name)" - }, - "Property":{ - "prefix": ".prop", - "body": [ - "/**", - " *", - " */" - ], - "description": "属性描述" - }, - "Variables":{ - "prefix": ".var", - "body": [ - "/**", - " *", - " */" - ], - "description": "变量描述" - } -} diff --git a/init/pg/auth/10_create_auth_tables.sql b/init/pg/auth/10_create_auth_tables.sql index 69b4cf7..66718c1 100644 --- a/init/pg/auth/10_create_auth_tables.sql +++ b/init/pg/auth/10_create_auth_tables.sql @@ -4,6 +4,8 @@ CREATE SCHEMA IF NOT EXISTS auth; DROP TABLE IF EXISTS auth.tb_auth_refresh_token CASCADE; CREATE TABLE IF NOT EXISTS auth.tb_auth_refresh_token ( token_id VARCHAR(64) PRIMARY KEY, + session_id VARCHAR(64) NOT NULL, + client_type VARCHAR(32) NOT NULL DEFAULT 'WEB', user_id VARCHAR(64) NOT NULL, username VARCHAR(64) NOT NULL, adcode VARCHAR(12), @@ -14,10 +16,14 @@ CREATE TABLE IF NOT EXISTS auth.tb_auth_refresh_token ( refresh_token TEXT NOT NULL, expire_at TIMESTAMP NOT NULL, revoked BOOLEAN NOT NULL DEFAULT FALSE, + revoked_at TIMESTAMP, + last_active_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); COMMENT ON TABLE auth.tb_auth_refresh_token IS '认证刷新令牌表'; COMMENT ON COLUMN auth.tb_auth_refresh_token.token_id IS '令牌ID'; +COMMENT ON COLUMN auth.tb_auth_refresh_token.session_id IS '会话ID'; +COMMENT ON COLUMN auth.tb_auth_refresh_token.client_type IS '客户端类型(WEB/MINI)'; COMMENT ON COLUMN auth.tb_auth_refresh_token.user_id IS '用户ID'; COMMENT ON COLUMN auth.tb_auth_refresh_token.username IS '用户名'; COMMENT ON COLUMN auth.tb_auth_refresh_token.adcode IS '行政区划编码'; @@ -28,6 +34,8 @@ COMMENT ON COLUMN auth.tb_auth_refresh_token.dept_path IS '部门路径'; COMMENT ON COLUMN auth.tb_auth_refresh_token.refresh_token IS '刷新令牌'; COMMENT ON COLUMN auth.tb_auth_refresh_token.expire_at IS '过期时间'; COMMENT ON COLUMN auth.tb_auth_refresh_token.revoked IS '是否撤销'; +COMMENT ON COLUMN auth.tb_auth_refresh_token.revoked_at IS '撤销时间'; +COMMENT ON COLUMN auth.tb_auth_refresh_token.last_active_at IS '最后活跃时间'; COMMENT ON COLUMN auth.tb_auth_refresh_token.created_at IS '创建时间'; DROP TABLE IF EXISTS auth.tb_auth_login_audit CASCADE; @@ -35,6 +43,7 @@ CREATE TABLE IF NOT EXISTS auth.tb_auth_login_audit ( audit_id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64), username VARCHAR(64) NOT NULL, + client_type VARCHAR(32) NOT NULL DEFAULT 'WEB', adcode VARCHAR(12), tenant_id VARCHAR(64), tenant_path VARCHAR(255), @@ -42,12 +51,14 @@ CREATE TABLE IF NOT EXISTS auth.tb_auth_login_audit ( dept_path VARCHAR(255), login_ip VARCHAR(64), login_status VARCHAR(32) NOT NULL, + failure_reason VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); COMMENT ON TABLE auth.tb_auth_login_audit IS '登录审计日志表'; COMMENT ON COLUMN auth.tb_auth_login_audit.audit_id IS '审计ID'; COMMENT ON COLUMN auth.tb_auth_login_audit.user_id IS '用户ID'; COMMENT ON COLUMN auth.tb_auth_login_audit.username IS '用户名'; +COMMENT ON COLUMN auth.tb_auth_login_audit.client_type IS '客户端类型'; COMMENT ON COLUMN auth.tb_auth_login_audit.adcode IS '行政区划编码'; COMMENT ON COLUMN auth.tb_auth_login_audit.tenant_id IS '租户ID'; COMMENT ON COLUMN auth.tb_auth_login_audit.tenant_path IS '租户路径'; @@ -55,7 +66,10 @@ COMMENT ON COLUMN auth.tb_auth_login_audit.dept_id IS '部门ID'; COMMENT ON COLUMN auth.tb_auth_login_audit.dept_path IS '部门路径'; COMMENT ON COLUMN auth.tb_auth_login_audit.login_ip IS '登录IP'; COMMENT ON COLUMN auth.tb_auth_login_audit.login_status IS '登录状态'; +COMMENT ON COLUMN auth.tb_auth_login_audit.failure_reason IS '失败原因'; COMMENT ON COLUMN auth.tb_auth_login_audit.created_at IS '创建时间'; CREATE INDEX IF NOT EXISTS idx_auth_refresh_token_user ON auth.tb_auth_refresh_token(user_id); +CREATE INDEX IF NOT EXISTS idx_auth_refresh_token_user_client ON auth.tb_auth_refresh_token(user_id, client_type, session_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_refresh_token_value ON auth.tb_auth_refresh_token(refresh_token); CREATE INDEX IF NOT EXISTS idx_auth_login_audit_tenant ON auth.tb_auth_login_audit(tenant_id, created_at DESC); diff --git a/init/pg/upms/10_create_upms_tables.sql b/init/pg/upms/10_create_upms_tables.sql index 504a774..5bc889e 100644 --- a/init/pg/upms/10_create_upms_tables.sql +++ b/init/pg/upms/10_create_upms_tables.sql @@ -46,12 +46,16 @@ COMMENT ON COLUMN upms.tb_sys_dept.created_at IS '创建时间'; DROP TABLE IF EXISTS upms.tb_school_class_course_rel CASCADE; DROP TABLE IF EXISTS upms.tb_school_class_member CASCADE; DROP TABLE IF EXISTS upms.tb_school_class CASCADE; +DROP TABLE IF EXISTS upms.tb_sys_user_role CASCADE; DROP TABLE IF EXISTS upms.tb_sys_user CASCADE; CREATE TABLE IF NOT EXISTS upms.tb_sys_user ( user_id VARCHAR(64) PRIMARY KEY, username VARCHAR(64) UNIQUE NOT NULL, display_name VARCHAR(128) NOT NULL, password_hash VARCHAR(255) NOT NULL, + mobile_phone VARCHAR(20), + mobile_bind_status VARCHAR(16) NOT NULL DEFAULT 'UNBOUND', + mobile_verified_at TIMESTAMP, adcode VARCHAR(12) NOT NULL, tenant_id VARCHAR(64) NOT NULL, tenant_path VARCHAR(255) NOT NULL, @@ -65,6 +69,9 @@ COMMENT ON COLUMN upms.tb_sys_user.user_id IS '用户ID'; COMMENT ON COLUMN upms.tb_sys_user.username IS '用户名'; COMMENT ON COLUMN upms.tb_sys_user.display_name IS '显示名称'; COMMENT ON COLUMN upms.tb_sys_user.password_hash IS '密码哈希'; +COMMENT ON COLUMN upms.tb_sys_user.mobile_phone IS '手机号'; +COMMENT ON COLUMN upms.tb_sys_user.mobile_bind_status IS '手机号绑定状态'; +COMMENT ON COLUMN upms.tb_sys_user.mobile_verified_at IS '手机号验证时间'; COMMENT ON COLUMN upms.tb_sys_user.adcode IS '行政区划编码'; COMMENT ON COLUMN upms.tb_sys_user.tenant_id IS '租户ID'; COMMENT ON COLUMN upms.tb_sys_user.tenant_path IS '租户路径'; @@ -219,6 +226,23 @@ COMMENT ON COLUMN upms.tb_sys_role.tenant_path IS '租户路径'; COMMENT ON COLUMN upms.tb_sys_role.dept_id IS '部门ID'; COMMENT ON COLUMN upms.tb_sys_role.dept_path IS '部门路径'; COMMENT ON COLUMN upms.tb_sys_role.created_at IS '创建时间'; +DROP TABLE IF EXISTS upms.tb_sys_user_role CASCADE; +CREATE TABLE IF NOT EXISTS upms.tb_sys_user_role ( + user_id VARCHAR(64) NOT NULL, + role_id VARCHAR(64) NOT NULL, + tenant_id VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, role_id), + CONSTRAINT fk_sys_user_role_user + FOREIGN KEY (user_id) REFERENCES upms.tb_sys_user(user_id) ON DELETE CASCADE, + CONSTRAINT fk_sys_user_role_role + FOREIGN KEY (role_id) REFERENCES upms.tb_sys_role(role_id) ON DELETE CASCADE +); +COMMENT ON TABLE upms.tb_sys_user_role IS '用户角色关系表'; +COMMENT ON COLUMN upms.tb_sys_user_role.user_id IS '用户ID'; +COMMENT ON COLUMN upms.tb_sys_user_role.role_id IS '角色ID'; +COMMENT ON COLUMN upms.tb_sys_user_role.tenant_id IS '租户ID'; +COMMENT ON COLUMN upms.tb_sys_user_role.created_at IS '创建时间'; DROP TABLE IF EXISTS upms.tb_sys_menu CASCADE; @@ -420,6 +444,9 @@ COMMENT ON COLUMN upms.tb_sys_message_recipient.updated_at IS '更新时间'; CREATE INDEX IF NOT EXISTS idx_sys_tenant_adcode ON upms.tb_sys_tenant(adcode); CREATE INDEX IF NOT EXISTS idx_dept_tenant ON upms.tb_sys_dept(tenant_id, dept_path); CREATE INDEX IF NOT EXISTS idx_user_tenant ON upms.tb_sys_user(tenant_id, dept_id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_user_tenant_mobile ON upms.tb_sys_user(tenant_id, mobile_phone) WHERE mobile_phone IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_sys_user_role_user ON upms.tb_sys_user_role(user_id, tenant_id); +CREATE INDEX IF NOT EXISTS idx_sys_user_role_role ON upms.tb_sys_user_role(role_id, tenant_id); CREATE INDEX IF NOT EXISTS idx_school_class_tenant_dept ON upms.tb_school_class(tenant_id, dept_id, grade_code); CREATE INDEX IF NOT EXISTS idx_school_class_member_tenant_user ON upms.tb_school_class_member(tenant_id, user_id, member_status); CREATE INDEX IF NOT EXISTS idx_school_class_course_rel_tenant_course ON upms.tb_school_class_course_rel(tenant_id, course_id, relation_status); diff --git a/init/pg/upms/20_init_upms_seed.sql b/init/pg/upms/20_init_upms_seed.sql index f01af60..6dfd8f3 100644 --- a/init/pg/upms/20_init_upms_seed.sql +++ b/init/pg/upms/20_init_upms_seed.sql @@ -14,17 +14,27 @@ INSERT INTO upms.tb_sys_dept ( ON CONFLICT (dept_id) DO NOTHING; INSERT INTO upms.tb_sys_user ( - user_id, username, display_name, password_hash, adcode, tenant_id, tenant_path, dept_id, dept_path + user_id, username, display_name, password_hash, mobile_phone, mobile_bind_status, mobile_verified_at, + adcode, tenant_id, tenant_path, dept_id, dept_path ) VALUES - ('U10001', 'admin', 'K12Study 管理员', '$2a$10$bootstrap', '330100', 'SCH-HQ', '/SCH-HQ/', 'DEPT-HQ-ADMIN', '/DEPT-HQ/DEPT-HQ-ADMIN/') + ('U10001', 'admin', 'K12Study 管理员', 'admin123', NULL, 'UNBOUND', NULL, '330100', 'SCH-HQ', '/SCH-HQ/', 'DEPT-HQ-ADMIN', '/DEPT-HQ/DEPT-HQ-ADMIN/'), + ('U20001', 'student01', '张同学', 'stud123', '13800000001', 'BOUND', CURRENT_TIMESTAMP, '330100', 'SCH-HQ', '/SCH-HQ/', 'DEPT-HQ', '/DEPT-HQ/') ON CONFLICT (user_id) DO NOTHING; INSERT INTO upms.tb_sys_role ( role_id, role_code, role_name, adcode, tenant_id, tenant_path, dept_id, dept_path ) VALUES - ('ROLE-ORG-ADMIN', 'ORG_ADMIN', '机构管理员', '330100', 'SCH-HQ', '/SCH-HQ/', 'DEPT-HQ-ADMIN', '/DEPT-HQ/DEPT-HQ-ADMIN/') + ('ROLE-ORG-ADMIN', 'ORG_ADMIN', '机构管理员', '330100', 'SCH-HQ', '/SCH-HQ/', 'DEPT-HQ-ADMIN', '/DEPT-HQ/DEPT-HQ-ADMIN/'), + ('ROLE-STUDENT', 'STUDENT', '学生', '330100', 'SCH-HQ', '/SCH-HQ/', 'DEPT-HQ', '/DEPT-HQ/') ON CONFLICT (role_id) DO NOTHING; +INSERT INTO upms.tb_sys_user_role ( + user_id, role_id, tenant_id +) VALUES + ('U10001', 'ROLE-ORG-ADMIN', 'SCH-HQ'), + ('U20001', 'ROLE-STUDENT', 'SCH-HQ') +ON CONFLICT (user_id, role_id) DO NOTHING; + INSERT INTO upms.tb_sys_menu ( route_id, parent_route_id, route_path, route_name, component_key, layout_type, title, icon, permission_code, @@ -43,6 +53,25 @@ INSERT INTO upms.tb_sys_role_menu ( ('ROLE-ORG-ADMIN', 'ROUTE-TENANT', '330100', 'SCH-HQ', '/SCH-HQ/', 'DEPT-HQ-ADMIN', '/DEPT-HQ/DEPT-HQ-ADMIN/') ON CONFLICT (role_id, route_id) DO NOTHING; +INSERT INTO upms.tb_school_class ( + class_id, tenant_id, dept_id, class_code, class_name, grade_code, status, adcode, tenant_path, dept_path, created_by +) VALUES + ('CLS-2026-01', 'SCH-HQ', 'DEPT-HQ', 'G1-CLASS-1', '高一(1)班', 'G1', 'ACTIVE', '330100', '/SCH-HQ/', '/DEPT-HQ/', 'U10001') +ON CONFLICT (class_id) DO NOTHING; + +INSERT INTO upms.tb_school_class_member ( + class_id, user_id, member_role, member_status, tenant_id +) VALUES + ('CLS-2026-01', 'U20001', 'STUDENT', 'ACTIVE', 'SCH-HQ'), + ('CLS-2026-01', 'U10001', 'HEAD_TEACHER', 'ACTIVE', 'SCH-HQ') +ON CONFLICT (class_id, user_id) DO NOTHING; + +INSERT INTO upms.tb_school_class_course_rel ( + class_id, course_id, relation_status, tenant_id +) VALUES + ('CLS-2026-01', 'COURSE-MATH-G1', 'ACTIVE', 'SCH-HQ') +ON CONFLICT (class_id, course_id) DO NOTHING; + INSERT INTO upms.tb_sys_message ( message_id, message_type, biz_type, title, content, content_object_type, content_object_id, web_jump_url, send_channel, sender_user_id, adcode, tenant_id, tenant_path, message_status, send_at, ext_json @@ -114,5 +143,18 @@ INSERT INTO upms.tb_sys_message_recipient ( '/SCH-HQ/', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ), + ( + 'MSG-20260415001', + 'U20001', + 'DELIVERED', + 'UNREAD', + NULL, + NULL, + NULL, + 'SCH-HQ', + '/SCH-HQ/', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP ) ON CONFLICT (message_id, recipient_user_id) DO NOTHING;