更新+mock部分

This commit is contained in:
2026-04-16 18:12:09 +08:00
parent d5c06eca28
commit adadb3bf1d
40 changed files with 884 additions and 174 deletions

View File

@@ -0,0 +1,21 @@
---
name: k12-api-response-standard
description: 当任务涉及前后端接口联调、请求封装、异常处理时使用,统一 code/message/data/traceId 响应体契约
---
# K12 统一响应体技能
## 何时使用
- 新增或改造后端 API
- 改动前端/小程序请求层
- 修复“后端已报错但前端当成功处理”的联调问题
## 统一契约
- 响应体结构固定为:`code/message/data/traceId`
- 成功条件:`code === 0`
- 业务失败:`code !== 0`,前端应抛错并使用 `message` 透出
## 执行步骤
1. 后端控制器与异常处理统一返回 `ApiResponse`
2. 前端与小程序请求封装统一校验 `code` 字段,不仅依赖 HTTP 状态码。
3. 新接口落地时同步检查类型声明与调用方数据解包方式,避免重复定义结构。
4. 同步更新 `docs/architecture/api-design.md` 中响应体约定,保持文档与代码一致。
## 约束
- 不引入破坏性字段重命名(如把 `message` 改为 `msg`)。
- 保留 `traceId` 以支持链路追踪。

View File

@@ -0,0 +1,17 @@
---
name: k12-comment-header-standard
description: 当任务涉及注释模板、global.code-snippets 或 .fileheader 配置时使用,确保 author 与当前仓库 git user.name 对齐
---
# K12 注释头规范技能
## 何时使用
- 用户要求统一文件头/函数注释规范
- 用户要求 `@author` 与 git 用户名一致
- 任务涉及 `global.code-snippets``.fileheader/fileheader.config.yaml`
## 执行步骤
1. 读取仓库 `git config user.name`,以此作为 `author` 基线。
2. 更新 `global.code-snippets``FileHeader``Method` 的 author 默认值。
3. 若项目使用 `turbo-file-header`,检查 `.fileheader/fileheader.config.yaml``@author` 字段,确保与团队约定一致。
4. 修改后验证 JSON/YAML 可解析,避免因注释模板格式错误导致编辑器失效。
## 约束
- 不修改业务逻辑代码,只处理注释与模板规范。
- 需要保留已有注释字段顺序description/filename/author/copyright/since

View File

@@ -0,0 +1,22 @@
---
name: k12-restful-api-style
description: 当任务涉及接口命名、路径规划或网关转发时使用,统一 K12Study 的 RESTful 风格并强制旧接口不兼容
---
# K12 RESTful 接口风格技能
## 何时使用
- 新增 API 设计
- 旧接口路径改造(如 `current-user``* /tree`
- 需要同步网关白名单与客户端调用路径
## 设计规则
- 路径优先使用资源名(名词)而不是动作名。
- 集合资源使用复数:`/users``/departments`
- “当前用户”使用语义路径:`/users/current`
- 树形结构优先通过资源路径表达:`/areas``/tenants``/departments`
## 执行步骤
1. 在控制器中提供 RESTful 主路径。
2. 删除旧路径映射,不保留兼容别名。
3. 同步更新前端、小程序 API 调用路径。
4. 同步更新鉴权白名单与文档(`docs/architecture/api-design.md``docs/architecture/logical-view.md`)。
## 约束
- 保持网关 `/api/*` 统一入口不变。
- 旧接口不兼容,禁止新增或恢复旧路径别名。

View File

@@ -0,0 +1,19 @@
dateFormat: YYYY-MM-DD
autoInsertOnCreateFile: true
autoUpdateOnSave: false
useJSDocStyle: false
customVariables:
- name: description
value: ""
fileheader:
- label: " * @description"
value: "{{description}}"
usePrevious: true
- label: " * @filename"
value: "{{fileName}}"
- label: " * @author"
value: "{{userName}}"
- label: " * @copyright"
value: "xyzh"
- label: " * @since"
value: "{{birthtime}}"

4
.gitignore vendored
View File

@@ -2,7 +2,9 @@ urbanLifeline
Tik Tik
schoolNewsServ schoolNewsServ
.idea/ .idea/
.vscode/ .vscode/*
!.vscode/settings.json
!.vscode/extensions.json
.DS_Store .DS_Store
.tmp-run/ .tmp-run/

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"ygqygq2.turbo-file-header"
]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"java.compile.nullAnalysis.mode": "automatic"
}

View File

@@ -2,7 +2,7 @@ const { request } = require("../utils/request");
function login(data) { function login(data) {
return request({ return request({
url: "/api/auth/login", url: "/api/auth/tokens",
method: "POST", method: "POST",
data data
}); });

View File

@@ -6,7 +6,38 @@ function getRouteMeta() {
method: "GET" method: "GET"
}); });
} }
function getCurrentUser() {
return request({
url: "/api/upms/users/current",
method: "GET"
});
}
function getAreas() {
return request({
url: "/api/upms/areas",
method: "GET"
});
}
function getTenants() {
return request({
url: "/api/upms/tenants",
method: "GET"
});
}
function getDepartments() {
return request({
url: "/api/upms/departments",
method: "GET"
});
}
module.exports = { module.exports = {
getRouteMeta getRouteMeta,
getCurrentUser,
getAreas,
getTenants,
getDepartments
}; };

View File

@@ -1,5 +1,15 @@
const BASE_URL = "http://localhost:8088"; const BASE_URL = "http://localhost:8088";
function isApiResponse(payload) {
return (
payload &&
typeof payload === "object" &&
Object.prototype.hasOwnProperty.call(payload, "code") &&
Object.prototype.hasOwnProperty.call(payload, "message") &&
Object.prototype.hasOwnProperty.call(payload, "data")
);
}
function request(options) { function request(options) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
wx.request({ wx.request({
@@ -10,7 +20,28 @@ function request(options) {
"Content-Type": "application/json", "Content-Type": "application/json",
...(options.header || {}) ...(options.header || {})
}, },
success: (response) => resolve(response.data), success: (response) => {
const payload = response.data;
if (response.statusCode < 200 || response.statusCode >= 300) {
const message =
payload && typeof payload === "object" && payload.message
? String(payload.message)
: `Request failed with status ${response.statusCode}`;
reject(new Error(message));
return;
}
if (isApiResponse(payload)) {
if (payload.code !== 0) {
reject(new Error(payload.message || `Business request failed with code ${payload.code}`));
return;
}
resolve(payload);
return;
}
reject(new Error("响应体不符合统一规范:缺少 code/message/data 字段"));
},
fail: reject fail: reject
}); });
}); });

View File

@@ -0,0 +1,4 @@
package com.k12study.api.auth.dto;
public record RefreshTokenRequest(String refreshToken) {
}

View File

@@ -4,9 +4,9 @@ import java.util.List;
public record AreaNodeDto( public record AreaNodeDto(
String areaCode, String areaCode,
String parentCode,
String areaName, String areaName,
String areaLevel, String areaLevel,
String provinceCode,
List<AreaNodeDto> children List<AreaNodeDto> children
) { ) {
} }

View File

@@ -6,8 +6,11 @@ public record CurrentRouteUserDto(
String userId, String userId,
String username, String username,
String displayName, String displayName,
String adcode,
String tenantId, String tenantId,
String tenantPath,
String deptId, String deptId,
String deptPath,
List<String> permissionCodes List<String> permissionCodes
) { ) {
} }

View File

@@ -4,9 +4,12 @@ import java.util.List;
public record DeptNodeDto( public record DeptNodeDto(
String deptId, String deptId,
String parentDeptId,
String deptName, String deptName,
String deptType, String deptType,
String tenantId, String tenantId,
String adcode,
String tenantPath,
String deptPath, String deptPath,
List<DeptNodeDto> children List<DeptNodeDto> children
) { ) {

View File

@@ -4,10 +4,10 @@ import java.util.List;
public record TenantNodeDto( public record TenantNodeDto(
String tenantId, String tenantId,
String parentTenantId,
String tenantName, String tenantName,
String tenantType, String tenantType,
String provinceCode, String adcode,
String areaCode,
String tenantPath, String tenantPath,
List<TenantNodeDto> children List<TenantNodeDto> children
) { ) {

View File

@@ -0,0 +1,13 @@
package com.k12study.api.upms.remote;
public final class UpmsApiPaths {
private UpmsApiPaths() {
}
public static final String BASE = "/upms";
public static final String ROUTES = BASE + "/routes";
public static final String USERS_CURRENT = BASE + "/users/current";
public static final String AREAS = BASE + "/areas";
public static final String TENANTS = BASE + "/tenants";
public static final String DEPARTMENTS = BASE + "/departments";
}

View File

@@ -0,0 +1,21 @@
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.RouteNodeDto;
import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.common.api.response.ApiResponse;
import java.util.List;
public interface UpmsRemoteApi {
ApiResponse<List<RouteNodeDto>> routes();
ApiResponse<CurrentRouteUserDto> currentUser();
ApiResponse<List<AreaNodeDto>> areas();
ApiResponse<List<TenantNodeDto>> tenants();
ApiResponse<List<DeptNodeDto>> departments();
}

View File

@@ -2,15 +2,16 @@ package com.k12study.auth.controller;
import com.k12study.api.auth.dto.CurrentUserResponse; import com.k12study.api.auth.dto.CurrentUserResponse;
import com.k12study.api.auth.dto.LoginRequest; import com.k12study.api.auth.dto.LoginRequest;
import com.k12study.api.auth.dto.RefreshTokenRequest;
import com.k12study.api.auth.dto.TokenResponse; import com.k12study.api.auth.dto.TokenResponse;
import com.k12study.auth.service.AuthService; import com.k12study.auth.service.AuthService;
import com.k12study.common.api.response.ApiResponse; import com.k12study.common.api.response.ApiResponse;
import com.k12study.common.web.exception.BizException;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@@ -22,17 +23,21 @@ public class AuthController {
this.authService = authService; this.authService = authService;
} }
@PostMapping("/login") @PostMapping("/tokens")
public ApiResponse<TokenResponse> login(@RequestBody LoginRequest request) { public ApiResponse<TokenResponse> login(@RequestBody LoginRequest request) {
return ApiResponse.success("登录成功", authService.login(request)); return ApiResponse.success("登录成功", authService.login(request));
} }
@PostMapping("/refresh") @PostMapping("/tokens/refresh")
public ApiResponse<TokenResponse> refresh(@RequestParam("refreshToken") String refreshToken) { public ApiResponse<TokenResponse> refresh(@RequestBody RefreshTokenRequest request) {
String refreshToken = request == null ? null : request.refreshToken();
if (refreshToken == null || refreshToken.isBlank()) {
throw new BizException(400, "refreshToken 不能为空");
}
return ApiResponse.success("刷新成功", authService.refresh(refreshToken)); return ApiResponse.success("刷新成功", authService.refresh(refreshToken));
} }
@GetMapping("/current-user") @GetMapping("/users/current")
public ApiResponse<CurrentUserResponse> currentUser( public ApiResponse<CurrentUserResponse> currentUser(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) { @RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(authService.currentUser(authorizationHeader)); return ApiResponse.success(authService.currentUser(authorizationHeader));

View File

@@ -23,6 +23,6 @@ auth:
enabled: true enabled: true
gateway-mode: true gateway-mode: true
whitelist: whitelist:
- /auth/login - /auth/tokens
- /auth/refresh - /auth/tokens/refresh
- /actuator/** - /actuator/**

View File

@@ -30,8 +30,8 @@ auth:
enabled: true enabled: true
gateway-mode: false gateway-mode: false
whitelist: whitelist:
- /auth/login - /auth/tokens
- /auth/refresh - /auth/tokens/refresh
- /actuator/** - /actuator/**
ai: ai:

View File

@@ -14,7 +14,11 @@ public class AuthProperties {
private String secret = "k12study-dev-secret-k12study-dev-secret"; private String secret = "k12study-dev-secret-k12study-dev-secret";
private Duration accessTokenTtl = Duration.ofHours(12); private Duration accessTokenTtl = Duration.ofHours(12);
private Duration refreshTokenTtl = Duration.ofDays(7); private Duration refreshTokenTtl = Duration.ofDays(7);
private List<String> whitelist = new ArrayList<>(List.of("/actuator/**", "/auth/login", "/auth/refresh")); private List<String> whitelist = new ArrayList<>(List.of(
"/actuator/**",
"/auth/tokens",
"/auth/tokens/refresh"
));
public boolean isEnabled() { public boolean isEnabled() {
return enabled; return enabled;

View File

@@ -29,6 +29,6 @@ management:
auth: auth:
enabled: true enabled: true
whitelist: whitelist:
- /api/auth/login - /api/auth/tokens
- /api/auth/refresh - /api/auth/tokens/refresh
- /actuator/** - /actuator/**

View File

@@ -5,6 +5,7 @@ import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto; import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.RouteNodeDto; import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.TenantNodeDto; import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.api.upms.remote.UpmsApiPaths;
import com.k12study.common.api.response.ApiResponse; import com.k12study.common.api.response.ApiResponse;
import com.k12study.upms.service.UpmsQueryService; import com.k12study.upms.service.UpmsQueryService;
import java.util.List; import java.util.List;
@@ -13,7 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("/upms") @RequestMapping(UpmsApiPaths.BASE)
public class UpmsController { public class UpmsController {
private final UpmsQueryService upmsQueryService; private final UpmsQueryService upmsQueryService;
@@ -26,22 +27,22 @@ public class UpmsController {
return ApiResponse.success(upmsQueryService.routes()); return ApiResponse.success(upmsQueryService.routes());
} }
@GetMapping("/areas/tree") @GetMapping("/areas")
public ApiResponse<List<AreaNodeDto>> areas() { public ApiResponse<List<AreaNodeDto>> areas() {
return ApiResponse.success(upmsQueryService.areas()); return ApiResponse.success(upmsQueryService.areas());
} }
@GetMapping("/tenants/tree") @GetMapping("/tenants")
public ApiResponse<List<TenantNodeDto>> tenants() { public ApiResponse<List<TenantNodeDto>> tenants() {
return ApiResponse.success(upmsQueryService.tenants()); return ApiResponse.success(upmsQueryService.tenants());
} }
@GetMapping("/depts/tree") @GetMapping("/departments")
public ApiResponse<List<DeptNodeDto>> departments() { public ApiResponse<List<DeptNodeDto>> departments() {
return ApiResponse.success(upmsQueryService.departments()); return ApiResponse.success(upmsQueryService.departments());
} }
@GetMapping("/current-user") @GetMapping("/users/current")
public ApiResponse<CurrentRouteUserDto> currentUser() { public ApiResponse<CurrentRouteUserDto> currentUser() {
return ApiResponse.success(upmsQueryService.currentUser()); return ApiResponse.success(upmsQueryService.currentUser());
} }

View File

@@ -1,10 +1,12 @@
package com.k12study.upms.domain; package com.k12study.upms.domain;
public record SysArea( public record SysArea(
String areaCode, long id,
String parentCode, long pid,
long adcode,
String areaName, String areaName,
String areaLevel, String areaType,
String provinceCode String areaStatus,
String delFlag
) { ) {
} }

View File

@@ -6,6 +6,8 @@ public record SysDept(
String tenantId, String tenantId,
String deptName, String deptName,
String deptType, String deptType,
String adcode,
String tenantPath,
String deptPath String deptPath
) { ) {
} }

View File

@@ -5,8 +5,8 @@ public record SysTenant(
String parentTenantId, String parentTenantId,
String tenantName, String tenantName,
String tenantType, String tenantType,
String provinceCode, String adcode,
String areaCode, String tenantPath,
String tenantPath String status
) { ) {
} }

View File

@@ -3,109 +3,17 @@ package com.k12study.upms.service;
import com.k12study.api.upms.dto.AreaNodeDto; import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto; import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto; import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.LayoutType;
import com.k12study.api.upms.dto.RouteMetaDto;
import com.k12study.api.upms.dto.RouteNodeDto; import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.TenantNodeDto; import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.common.security.context.RequestUserContextHolder;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Service; public interface UpmsQueryService {
List<RouteNodeDto> routes();
@Service List<AreaNodeDto> areas();
public class UpmsQueryService {
public List<RouteNodeDto> routes() { List<TenantNodeDto> tenants();
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 List<AreaNodeDto> areas() { List<DeptNodeDto> departments();
return List.of(
new AreaNodeDto(
"330000",
"浙江省",
"province",
"330000",
List.of(
new AreaNodeDto("330100", "杭州市", "city", "330000", List.of())
)
)
);
}
public List<TenantNodeDto> tenants() { CurrentRouteUserDto currentUser();
return List.of(
new TenantNodeDto(
"SCH-HQ",
"K12Study 总校",
"head_school",
"330000",
"330100",
"/SCH-HQ/",
List.of(
new TenantNodeDto(
"SCH-ZJ-HZ-01",
"杭州分校",
"city_school",
"330000",
"330100",
"/SCH-HQ/SCH-ZJ-HZ-01/",
List.of()
)
)
)
);
}
public List<DeptNodeDto> departments() {
return List.of(
new DeptNodeDto(
"DEPT-HQ",
"总校教学部",
"grade",
"SCH-HQ",
"/DEPT-HQ/",
List.of(
new DeptNodeDto(
"DEPT-HQ-MATH",
"数学学科组",
"subject",
"SCH-HQ",
"/DEPT-HQ/DEPT-HQ-MATH/",
List.of()
)
)
)
);
}
public CurrentRouteUserDto currentUser() {
var context = RequestUserContextHolder.get();
return new CurrentRouteUserDto(
context == null ? "U10001" : context.userId(),
context == null ? "admin" : context.username(),
context == null ? "K12Study 管理员" : context.displayName(),
context == null ? "SCH-HQ" : context.tenantId(),
context == null ? "DEPT-HQ-ADMIN" : context.deptId(),
List.of("dashboard:view", "tenant:view", "dept:view")
);
}
} }

View File

@@ -0,0 +1,132 @@
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.LayoutType;
import com.k12study.api.upms.dto.RouteMetaDto;
import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.common.security.context.RequestUserContextHolder;
import com.k12study.upms.service.UpmsQueryService;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class UpmsQueryServiceImpl implements UpmsQueryService {
@Override
public List<RouteNodeDto> routes() {
return List.of(
new RouteNodeDto(
"dashboard",
"/",
"dashboard",
"dashboard",
LayoutType.SIDEBAR,
new RouteMetaDto("控制台", "layout-dashboard", List.of("dashboard:view"), false),
List.of()
),
new RouteNodeDto(
"tenant-management",
"/tenant",
"tenant-management",
"tenant",
LayoutType.SIDEBAR,
new RouteMetaDto("租户组织", "building-2", List.of("tenant:view"), false),
List.of()
)
);
}
@Override
public List<AreaNodeDto> areas() {
return List.of(
new AreaNodeDto(
"330000",
"100000",
"浙江省",
"PROVINCE",
List.of(
new AreaNodeDto(
"330100",
"330000",
"杭州市",
"CITY",
List.of()
)
)
)
);
}
@Override
public List<TenantNodeDto> tenants() {
return List.of(
new TenantNodeDto(
"SCH-HQ",
null,
"K12Study 总校",
"HEAD_SCHOOL",
"330100",
"/SCH-HQ/",
List.of(
new TenantNodeDto(
"SCH-ZJ-HZ-01",
"SCH-HQ",
"杭州分校",
"CITY_SCHOOL",
"330100",
"/SCH-HQ/SCH-ZJ-HZ-01/",
List.of()
)
)
)
);
}
@Override
public List<DeptNodeDto> departments() {
return List.of(
new DeptNodeDto(
"DEPT-HQ",
null,
"总校教学部",
"GRADE",
"SCH-HQ",
"330100",
"/SCH-HQ/",
"/DEPT-HQ/",
List.of(
new DeptNodeDto(
"DEPT-HQ-MATH",
"DEPT-HQ",
"数学学科组",
"SUBJECT",
"SCH-HQ",
"330100",
"/SCH-HQ/",
"/DEPT-HQ/DEPT-HQ-MATH/",
List.of()
)
)
)
);
}
@Override
public CurrentRouteUserDto currentUser() {
var context = RequestUserContextHolder.get();
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")
);
}
}

Binary file not shown.

269
docs/apijson/upms.api.json Normal file
View File

@@ -0,0 +1,269 @@
{
"_meta": {
"module": "upms",
"prefix": "/api/upms",
"responseEnvelope": {
"code": "int, 0=成功, 非0=业务失败",
"message": "string, 业务描述",
"data": "T, 业务数据",
"traceId": "string, 链路追踪ID"
},
"errorExample": {
"code": 500,
"message": "系统异常",
"data": null,
"traceId": "abc123"
}
},
"apis": [
{
"id": "upms-routes",
"method": "GET",
"path": "/api/upms/routes",
"summary": "获取当前用户的动态路由/菜单树",
"sqlSource": "upms.tb_sys_menu + upms.tb_sys_role_menu",
"request": {
"headers": { "Authorization": "Bearer {accessToken}" },
"params": null,
"body": null
},
"response": {
"code": 0,
"message": "OK",
"data": [
{
"id": "dashboard",
"path": "/",
"name": "dashboard",
"component": "dashboard",
"layout": "SIDEBAR",
"meta": {
"title": "控制台",
"icon": "layout-dashboard",
"permissionCodes": ["dashboard:view"],
"hidden": false
},
"children": []
},
{
"id": "tenant-management",
"path": "/tenant",
"name": "tenant-management",
"component": "tenant",
"layout": "SIDEBAR",
"meta": {
"title": "租户组织",
"icon": "building-2",
"permissionCodes": ["tenant:view"],
"hidden": false
},
"children": []
}
],
"traceId": "trace-001"
},
"fieldMapping": {
"id": "tb_sys_menu.route_id",
"path": "tb_sys_menu.route_path",
"name": "tb_sys_menu.route_name",
"component": "tb_sys_menu.component_key",
"layout": "tb_sys_menu.layout_type",
"meta.title": "tb_sys_menu.title",
"meta.icon": "tb_sys_menu.icon",
"meta.permissionCodes": "tb_sys_menu.permission_code (拆分为数组)",
"meta.hidden": "tb_sys_menu.hidden",
"children": "通过 parent_route_id 递归组装"
},
"backendDto": "com.k12study.api.upms.dto.RouteNodeDto",
"frontendType": "UpmsRouteNode (types/upms.ts)"
},
{
"id": "upms-users-current",
"method": "GET",
"path": "/api/upms/users/current",
"summary": "获取当前登录用户信息(含权限编码)",
"sqlSource": "upms.tb_sys_user + upms.tb_sys_role + upms.tb_sys_role_menu",
"request": {
"headers": { "Authorization": "Bearer {accessToken}" },
"params": null,
"body": null
},
"response": {
"code": 0,
"message": "OK",
"data": {
"userId": "U10001",
"username": "admin",
"displayName": "K12Study 管理员",
"adcode": "330100",
"tenantId": "SCH-HQ",
"tenantPath": "/SCH-HQ/",
"deptId": "DEPT-HQ-ADMIN",
"deptPath": "/DEPT-HQ/DEPT-HQ-ADMIN/",
"permissionCodes": ["dashboard:view", "tenant:view", "dept:view"]
},
"traceId": "trace-002"
},
"fieldMapping": {
"userId": "tb_sys_user.user_id",
"username": "tb_sys_user.username",
"displayName": "tb_sys_user.display_name",
"adcode": "tb_sys_user.adcode",
"tenantId": "tb_sys_user.tenant_id",
"tenantPath": "tb_sys_user.tenant_path",
"deptId": "tb_sys_user.dept_id",
"deptPath": "tb_sys_user.dept_path",
"permissionCodes": "通过 role->role_menu->menu.permission_code 聚合"
},
"backendDto": "com.k12study.api.upms.dto.CurrentRouteUserDto",
"frontendType": "UpmsCurrentUser (types/upms.ts)"
},
{
"id": "upms-areas",
"method": "GET",
"path": "/api/upms/areas",
"summary": "获取行政区划树",
"sqlSource": "upms.tb_sys_area",
"request": {
"headers": { "Authorization": "Bearer {accessToken}" },
"params": null,
"body": null
},
"response": {
"code": 0,
"message": "OK",
"data": [
{
"areaCode": "330000",
"parentCode": "100000",
"areaName": "浙江省",
"areaLevel": "PROVINCE",
"children": [
{
"areaCode": "330100",
"parentCode": "330000",
"areaName": "杭州市",
"areaLevel": "CITY",
"children": []
}
]
}
],
"traceId": "trace-003"
},
"fieldMapping": {
"areaCode": "tb_sys_area.adcode (转为字符串)",
"parentCode": "tb_sys_area.pid (转为字符串)",
"areaName": "tb_sys_area.name",
"areaLevel": "tb_sys_area.area_type (映射: 0->COUNTRY, 1->PROVINCE, 2->CITY)",
"children": "通过 pid 递归组装"
},
"backendDto": "com.k12study.api.upms.dto.AreaNodeDto",
"frontendType": "UpmsAreaNode (types/upms.ts)"
},
{
"id": "upms-tenants",
"method": "GET",
"path": "/api/upms/tenants",
"summary": "获取租户树",
"sqlSource": "upms.tb_sys_tenant",
"request": {
"headers": { "Authorization": "Bearer {accessToken}" },
"params": null,
"body": null
},
"response": {
"code": 0,
"message": "OK",
"data": [
{
"tenantId": "SCH-HQ",
"parentTenantId": null,
"tenantName": "K12Study 总校",
"tenantType": "HEAD_SCHOOL",
"adcode": "330100",
"tenantPath": "/SCH-HQ/",
"children": [
{
"tenantId": "SCH-ZJ-HZ-01",
"parentTenantId": "SCH-HQ",
"tenantName": "杭州分校",
"tenantType": "CITY_SCHOOL",
"adcode": "330100",
"tenantPath": "/SCH-HQ/SCH-ZJ-HZ-01/",
"children": []
}
]
}
],
"traceId": "trace-004"
},
"fieldMapping": {
"tenantId": "tb_sys_tenant.tenant_id",
"parentTenantId": "tb_sys_tenant.parent_tenant_id",
"tenantName": "tb_sys_tenant.tenant_name",
"tenantType": "tb_sys_tenant.tenant_type",
"adcode": "tb_sys_tenant.adcode",
"tenantPath": "tb_sys_tenant.tenant_path",
"children": "通过 parent_tenant_id 递归组装"
},
"backendDto": "com.k12study.api.upms.dto.TenantNodeDto",
"frontendType": "UpmsTenantNode (types/upms.ts)"
},
{
"id": "upms-departments",
"method": "GET",
"path": "/api/upms/departments",
"summary": "获取部门/组织树",
"sqlSource": "upms.tb_sys_dept",
"request": {
"headers": { "Authorization": "Bearer {accessToken}" },
"params": null,
"body": null
},
"response": {
"code": 0,
"message": "OK",
"data": [
{
"deptId": "DEPT-HQ",
"parentDeptId": null,
"deptName": "总校教学部",
"deptType": "GRADE",
"tenantId": "SCH-HQ",
"adcode": "330100",
"tenantPath": "/SCH-HQ/",
"deptPath": "/DEPT-HQ/",
"children": [
{
"deptId": "DEPT-HQ-MATH",
"parentDeptId": "DEPT-HQ",
"deptName": "数学学科组",
"deptType": "SUBJECT",
"tenantId": "SCH-HQ",
"adcode": "330100",
"tenantPath": "/SCH-HQ/",
"deptPath": "/DEPT-HQ/DEPT-HQ-MATH/",
"children": []
}
]
}
],
"traceId": "trace-005"
},
"fieldMapping": {
"deptId": "tb_sys_dept.dept_id",
"parentDeptId": "tb_sys_dept.parent_dept_id",
"deptName": "tb_sys_dept.dept_name",
"deptType": "tb_sys_dept.dept_type",
"tenantId": "tb_sys_dept.tenant_id",
"adcode": "tb_sys_dept.adcode",
"tenantPath": "tb_sys_dept.tenant_path",
"deptPath": "tb_sys_dept.dept_path",
"children": "通过 parent_dept_id 递归组装"
},
"backendDto": "com.k12study.api.upms.dto.DeptNodeDto",
"frontendType": "UpmsDeptNode (types/upms.ts)"
}
]
}

View File

@@ -1,7 +1,7 @@
# API 设计(基础架构 + 业务功能) # API 设计(基础架构 + 业务功能)
## 1. API 设计原则 ## 1. API 设计原则
- 对外统一前缀:`/api/*` - 对外统一前缀:`/api/*`
- 统一响应结构:`code/message/data/traceId` - 统一响应结构:`code/message/data/traceId`,其中 `code=0` 表示成功,非 0 表示业务失败
- 认证策略JWT + RBAC网关做统一鉴权透传。 - 认证策略JWT + RBAC网关做统一鉴权透传。
- API 冻结点: - API 冻结点:
- 基础架构 API 在 M3 冻结。 - 基础架构 API 在 M3 冻结。
@@ -9,16 +9,16 @@
## 2. 基础架构 APIM3 ## 2. 基础架构 APIM3
### 2.1 认证域auth ### 2.1 认证域auth
- `POST /api/auth/login` - `POST /api/auth/tokens`(登录)
- `POST /api/auth/refresh` - `POST /api/auth/tokens/refresh`(刷新)
- `GET /api/auth/current-user` - `GET /api/auth/users/current`(当前用户)
### 2.2 权限与组织域upms ### 2.2 权限与组织域upms
- `GET /api/upms/routes` - `GET /api/upms/routes`
- `GET /api/upms/current-user` - `GET /api/upms/users/current`
- `GET /api/upms/areas/tree` - `GET /api/upms/areas`
- `GET /api/upms/tenants/tree` - `GET /api/upms/tenants`
- `GET /api/upms/depts/tree` - `GET /api/upms/departments`
### 2.3 基础扩展 API建议补充 ### 2.3 基础扩展 API建议补充
- 文件域 - 文件域

View File

@@ -55,9 +55,9 @@ graph TD
## 3. 主链路视角 ## 3. 主链路视角
### 3.1 基础架构链路 ### 3.1 基础架构链路
- 登录:`frontend/app -> /api/auth/login -> auth -> token` - 登录:`frontend/app -> /api/auth/tokens -> auth -> token`
- 权限与路由:`frontend/app -> /api/upms/routes -> upms` - 权限与路由:`frontend/app -> /api/upms/routes -> upms`
- 组织数据:`/api/upms/areas/tree|tenants/tree|depts/tree` - 组织数据:`/api/upms/areas|tenants|departments`
### 3.2 教学业务链路 ### 3.2 教学业务链路
- 教师发作业:题库/试卷/作业配置 -> 投放班级。 - 教师发作业:题库/试卷/作业配置 -> 投放班级。

View File

@@ -1,6 +1,6 @@
<mxfile host="65bd71144e"> <mxfile host="65bd71144e">
<diagram id="full-business-flow-v3" name="完整业务流程图"> <diagram id="full-business-flow-v3" name="完整业务流程图">
<mxGraphModel dx="1312" dy="773" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="5600" pageHeight="2300" math="0" shadow="0"> <mxGraphModel dx="1658" dy="703" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="5600" pageHeight="2300" math="0" shadow="0">
<root> <root>
<mxCell id="0"/> <mxCell id="0"/>
<mxCell id="1" parent="0"/> <mxCell id="1" parent="0"/>
@@ -35,13 +35,13 @@
<mxGeometry x="30" y="110" width="5400" height="220" as="geometry"/> <mxGeometry x="30" y="110" width="5400" height="220" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="11" value="学生主线" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fa;strokeColor=#b7c3d0;fontSize=13;fontStyle=1;align=left;spacingLeft=10;" parent="1" vertex="1"> <mxCell id="11" value="学生主线" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fa;strokeColor=#b7c3d0;fontSize=13;fontStyle=1;align=left;spacingLeft=10;" parent="1" vertex="1">
<mxGeometry x="30" y="360" width="5400" height="260" as="geometry"/> <mxGeometry x="20" y="370" width="5400" height="260" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="12" value="AI/系统主线" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fa;strokeColor=#b7c3d0;fontSize=13;fontStyle=1;align=left;spacingLeft=10;" parent="1" vertex="1"> <mxCell id="12" value="AI/系统主线" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fa;strokeColor=#b7c3d0;fontSize=13;fontStyle=1;align=left;spacingLeft=10;" parent="1" vertex="1">
<mxGeometry x="30" y="660" width="5400" height="620" as="geometry"/> <mxGeometry x="50" y="670" width="5400" height="620" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="13" value="管理运营主线" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fa;strokeColor=#b7c3d0;fontSize=13;fontStyle=1;align=left;spacingLeft=10;" parent="1" vertex="1"> <mxCell id="13" value="管理运营主线" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8f9fa;strokeColor=#b7c3d0;fontSize=13;fontStyle=1;align=left;spacingLeft=10;" parent="1" vertex="1">
<mxGeometry x="30" y="1320" width="5400" height="300" as="geometry"/> <mxGeometry y="1330" width="5400" height="300" as="geometry"/>
</mxCell> </mxCell>
<mxCell id="20" value="开始" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" parent="1" vertex="1"> <mxCell id="20" value="开始" style="ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=12;" parent="1" vertex="1">
<mxGeometry x="70" y="470" width="110" height="64" as="geometry"/> <mxGeometry x="70" y="470" width="110" height="64" as="geometry"/>

View File

@@ -10,5 +10,11 @@ interface LoginInput {
} }
export async function login(input: LoginInput) { export async function login(input: LoginInput) {
return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/login", input); return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/tokens", input);
}
export async function refreshToken(refreshToken: string) {
return http.post<ApiResponse<{ accessToken: string; refreshToken: string }>>("/auth/tokens/refresh", {
refreshToken
});
} }

View File

@@ -1,13 +1,55 @@
import type { ApiResponse } from "../types/api"; import {
getUpmsAreasRemote,
getUpmsCurrentUserRemote,
getUpmsDepartmentsRemote,
getUpmsRoutesRemote,
getUpmsTenantsRemote
} from "../remote/upmsRemote";
import type { CurrentRouteUser, RouteNode } from "../types/route"; import type { CurrentRouteUser, RouteNode } from "../types/route";
import { http } from "../utils/http"; import type { UpmsAreaNode, UpmsDeptNode, UpmsTenantNode } from "../types/upms";
function normalizeAreaNodes(nodes: UpmsAreaNode[]): UpmsAreaNode[] {
return nodes.map((node) => ({
...node,
children: normalizeAreaNodes(node.children ?? [])
}));
}
function normalizeTenantNodes(nodes: UpmsTenantNode[]): UpmsTenantNode[] {
return nodes.map((node) => ({
...node,
children: normalizeTenantNodes(node.children ?? [])
}));
}
function normalizeDeptNodes(nodes: UpmsDeptNode[]): UpmsDeptNode[] {
return nodes.map((node) => ({
...node,
children: normalizeDeptNodes(node.children ?? [])
}));
}
export async function fetchDynamicRoutes(): Promise<RouteNode[]> { export async function fetchDynamicRoutes(): Promise<RouteNode[]> {
const response = await http.get<ApiResponse<RouteNode[]>>("/upms/routes"); const response = await getUpmsRoutesRemote();
return response.data as RouteNode[]; return response.data as RouteNode[];
} }
export async function fetchCurrentUser(): Promise<CurrentRouteUser> { export async function fetchCurrentUser(): Promise<CurrentRouteUser> {
const response = await http.get<ApiResponse<CurrentRouteUser>>("/upms/current-user"); const response = await getUpmsCurrentUserRemote();
return response.data as CurrentRouteUser; return response.data as CurrentRouteUser;
} }
export async function fetchAreas(): Promise<UpmsAreaNode[]> {
const response = await getUpmsAreasRemote();
return normalizeAreaNodes(response.data as UpmsAreaNode[]);
}
export async function fetchTenants(): Promise<UpmsTenantNode[]> {
const response = await getUpmsTenantsRemote();
return normalizeTenantNodes(response.data as UpmsTenantNode[]);
}
export async function fetchDepartments(): Promise<UpmsDeptNode[]> {
const response = await getUpmsDepartmentsRemote();
return normalizeDeptNodes(response.data as UpmsDeptNode[]);
}

View File

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

View File

@@ -1,27 +1,11 @@
export type LayoutType = "DEFAULT" | "SIDEBAR"; import type {
UpmsCurrentUser,
UpmsLayoutType,
UpmsRouteMeta,
UpmsRouteNode
} from "./upms";
export interface RouteMeta { export type LayoutType = UpmsLayoutType;
title: string; export type RouteMeta = UpmsRouteMeta;
icon?: string; export type RouteNode = UpmsRouteNode;
permissionCodes?: string[]; export type CurrentRouteUser = UpmsCurrentUser;
hidden?: boolean;
}
export interface RouteNode {
id: string;
path: string;
name: string;
component: string;
layout: LayoutType;
meta: RouteMeta;
children: RouteNode[];
}
export interface CurrentRouteUser {
userId: string;
username: string;
displayName: string;
tenantId: string;
deptId: string;
permissionCodes: string[];
}

View File

@@ -0,0 +1,60 @@
export type UpmsLayoutType = "DEFAULT" | "SIDEBAR";
export interface UpmsRouteMeta {
title: string;
icon?: string;
permissionCodes?: string[];
hidden?: boolean;
}
export interface UpmsRouteNode {
id: string;
path: string;
name: string;
component: string;
layout: UpmsLayoutType;
meta: UpmsRouteMeta;
children: UpmsRouteNode[];
}
export interface UpmsCurrentUser {
userId: string;
username: string;
displayName: string;
adcode: string;
tenantId: string;
tenantPath: string;
deptId: string;
deptPath: string;
permissionCodes: string[];
}
export interface UpmsAreaNode {
areaCode: string;
parentCode: string;
areaName: string;
areaLevel: string;
children: UpmsAreaNode[];
}
export interface UpmsTenantNode {
tenantId: string;
parentTenantId: string | null;
tenantName: string;
tenantType: string;
adcode: string;
tenantPath: string;
children: UpmsTenantNode[];
}
export interface UpmsDeptNode {
deptId: string;
parentDeptId: string | null;
deptName: string;
deptType: string;
tenantId: string;
adcode: string;
tenantPath: string;
deptPath: string;
children: UpmsDeptNode[];
}

View File

@@ -1,3 +1,4 @@
import type { ApiResponse } from "../types/api";
import { getAccessToken } from "./storage"; import { getAccessToken } from "./storage";
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"; const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
@@ -12,6 +13,14 @@ interface RequestOptions {
timeout?: number; timeout?: number;
} }
function isApiResponse(payload: unknown): payload is ApiResponse<unknown> {
if (typeof payload !== "object" || payload === null) {
return false;
}
return "code" in payload && "message" in payload && "data" in payload;
}
function buildUrl(path: string) { function buildUrl(path: string) {
if (/^https?:\/\//.test(path)) { if (/^https?:\/\//.test(path)) {
return path; return path;
@@ -57,6 +66,9 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
: `Request failed with status ${response.status}`; : `Request failed with status ${response.status}`;
throw new Error(message); throw new Error(message);
} }
if (isApiResponse(payload) && payload.code !== 0) {
throw new Error(payload.message || `Business request failed with code ${payload.code}`);
}
return payload as T; return payload as T;
} finally { } finally {

65
global.code-snippets Normal file
View File

@@ -0,0 +1,65 @@
{
// 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": "变量描述"
}
}