提交
This commit is contained in:
1 parent
a88d87047d
commit
c0de1b9bef
19 files changed
+17083
-202
No files matched your search
@@ -41,11 +41,11 @@ import java.nio.charset.StandardCharsets;
|
||||
@RequiredArgsConstructor
|
||||
public class CasController {
|
||||
|
||||
// 固定 CAS 地址与 client 配置(按需求写死)
|
||||
private static final String CAS_BASE = "http://172.16.0.82";
|
||||
// 固定 CAS 地址与 client 配置(后端走内网地址:19081,前端跳转仍用公网 td.hzdyjsxy.cn:19081)
|
||||
private static final String CAS_BASE = "http://td.hzdyjsxy.cn:19081";
|
||||
private static final String CLIENT_ID = "0i7tcT5tULqvmmbbV8";
|
||||
private static final String CLIENT_SECRET = "68c4f7641523452aaf5ef6d0432869f7";
|
||||
private static final String REDIRECT_URI_ENCODED = "http%3A%2F%2F172.16.0.67%3A8882";
|
||||
private static final String REDIRECT_URI_ENCODED = "http%3A%2F%2Fweb.hzdyjsxy.cn%3A18081";
|
||||
|
||||
private final SysUserMapper userMapper;
|
||||
private final SysLoginService loginService;
|
||||
@@ -139,7 +139,8 @@ public class CasController {
|
||||
SysUserVo user = userMapper
|
||||
.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, finalUsername));
|
||||
if (user == null) {
|
||||
log.warn("CAS auto-login: user not found, username={}, tenantId={}", finalUsername, finalTenantId);
|
||||
log.warn("CAS auto-login: user not found, username={}, tenantId={}", finalUsername,
|
||||
finalTenantId);
|
||||
throw new UserException("user.not.exists", finalUsername);
|
||||
} else {
|
||||
log.info("CAS auto-login: found user: userId={}, userName={}, status={}, tenantId={}",
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ import org.dromara.xg.domain.vo.XgDormBedDetailVo;
|
||||
import org.dromara.xg.domain.XgDormBed;
|
||||
import org.dromara.xg.domain.XgDormRoom;
|
||||
import org.dromara.xg.domain.XgDormBedStudent;
|
||||
import org.dromara.xg.domain.XgSysDept;
|
||||
import org.dromara.xg.domain.vo.XgStudentLeaveVo;
|
||||
import org.dromara.xg.mapper.XgDormBedMapper;
|
||||
import org.dromara.xg.mapper.XgDormRoomMapper;
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
|
||||
[用户认证登录API](./用户认证登录API.md) - 包含用户登录、获取用户信息、修改密码等接口。
|
||||
|
||||
[统一身份认证接入说明](../统一身份认证接入说明.md) - CAS OAuth2.0 统一认证登录完整流程、前后端协作与用户映射规则(2026-06-10 新增)。
|
||||
|
||||
### 2. 心理咨询预约
|
||||
|
||||
#### 2.1 预约管理
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
**依赖文档**: 请先阅读 [API 文档基础规范.md](./API文档基础规范.md) 了解通用规范。
|
||||
|
||||
**统一身份认证**: 前端「统一认证登录」按钮的完整业务流程、CAS OAuth2.0 接入说明及排查指南,请参阅 [统一身份认证接入说明.md](../统一身份认证接入说明.md)。
|
||||
|
||||
---
|
||||
|
||||
## 🔐 用户认证
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,383 @@
|
||||
# 统一身份认证接入说明
|
||||
|
||||
## 文档说明
|
||||
|
||||
本文档描述校园管理系统(hzdyjs_xg)前端 **「统一认证登录」** 按钮的完整业务逻辑,涵盖 CAS OAuth2.0 授权流程、前后端协作方式、用户映射规则及常见问题排查。
|
||||
|
||||
**相关文档**:
|
||||
|
||||
- [用户认证登录API](./API/用户认证登录API.md) — 普通账号密码登录接口
|
||||
- [API文档基础规范](./API/API文档基础规范.md) — 通用 API 规范与 Token 认证方式
|
||||
|
||||
**涉及源码**:
|
||||
|
||||
| 模块 | 文件路径 |
|
||||
|------|----------|
|
||||
| 前端登录页 | `web/src/views/login.vue` |
|
||||
| 前端路由守卫 | `web/src/permission.ts` |
|
||||
| 后端 CAS 代理 | `backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CasController.java` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 什么是统一身份认证
|
||||
|
||||
统一身份认证(CAS,Central Authentication Service)是学校提供的集中式登录平台。用户在校内 CAS 完成身份验证后,业务系统无需再校验密码,只需根据 CAS 返回的用户标识匹配本地账号,即可签发本系统的登录 Token。
|
||||
|
||||
### 1.2 与普通登录的区别
|
||||
|
||||
| 维度 | 普通登录 | 统一认证登录 |
|
||||
|------|----------|--------------|
|
||||
| 认证方式 | 用户名 + 密码(AES+RSA 加密) | CAS OAuth2.0 授权码模式 |
|
||||
| 密码校验 | 后端校验 | 无,由 CAS 平台完成 |
|
||||
| 用户识别 | 表单输入 `username` | CAS profile 中的 `CODE` 字段 |
|
||||
| Token 来源 | `POST /auth/login` | `GET /cas/accessToken` |
|
||||
| 租户 | 表单 `tenantId` | 固定 `000000`(忽略 CAS 返回的 TENANTID) |
|
||||
| 登录后行为 | 路由守卫拉取用户信息 + 动态路由 | 相同 |
|
||||
|
||||
### 1.3 协议与模式
|
||||
|
||||
- **协议**:OAuth 2.0 授权码模式(Authorization Code)
|
||||
- **CAS 服务地址**:`http://td.hzdyjsxy.cn:19081`
|
||||
- **前端回调地址**:`http://web.hzdyjsxy.cn:18081`
|
||||
|
||||
---
|
||||
|
||||
## 2. 整体流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户浏览器
|
||||
participant Web as 前端 (web)
|
||||
participant CAS as 统一认证 CAS<br/>td.hzdyjsxy.cn:19081
|
||||
participant Backend as 后端 (ruoyi-admin)
|
||||
participant DB as 本地用户库 sys_user
|
||||
|
||||
User->>Web: 点击「统一认证登录」
|
||||
Web->>CAS: 跳转 /cas/oauth2.0/authorize<br/>?client_id=...&redirect_uri=...
|
||||
User->>CAS: 输入校内统一账号密码
|
||||
CAS->>Web: 302 回调 redirect_uri?code=ST-xxx
|
||||
Web->>Web: login.vue 检测到 code
|
||||
Web->>Backend: GET /cas/accessToken?code=ST-xxx
|
||||
Backend->>CAS: 用 code 换 access_token
|
||||
Backend->>CAS: 用 access_token 拉 profile
|
||||
CAS-->>Backend: 返回 attributes(CODE, TENANTID...)
|
||||
Backend->>DB: 按 CODE 查 sys_user.user_name
|
||||
Backend->>Backend: Sa-Token 生成本系统 token
|
||||
Backend-->>Web: { access_token: "..." }
|
||||
Web->>Web: setToken + 跳转首页
|
||||
Web->>Backend: 路由守卫拉 getInfo + 动态路由
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端实现
|
||||
|
||||
### 3.1 入口:统一认证登录按钮
|
||||
|
||||
登录页 `web/src/views/login.vue` 提供两个并列按钮:
|
||||
|
||||
- **登录** — 普通账号密码登录
|
||||
- **统一认证登录** — 跳转 CAS 授权页
|
||||
|
||||
**从单点登录门户自动认证**(2026-06-25 新增):
|
||||
|
||||
当用户从学校单点登录门户跳转到本系统登录页时,**无需再点击「统一认证登录」**,页面会自动跳转 CAS 完成认证。直接打开 `/login`(无 `redirect`)仍可使用账号密码登录。
|
||||
|
||||
单点门户典型入口 URL(**无需** `sso`、`from` 等额外参数):
|
||||
|
||||
```
|
||||
http://web.hzdyjsxy.cn:18081/login?redirect=/index
|
||||
```
|
||||
|
||||
识别「来自 SSO / 外部入口」的条件(满足任一即可):
|
||||
|
||||
| 条件 | 说明 |
|
||||
|------|------|
|
||||
| URL 带 `redirect=/index` 且 Referer **不是**本站 | 单点门户固定链接;本地未登录为 `redirect=/` 不会触发 |
|
||||
| Referer 来自 `td.hzdyjsxy.cn`、`hzdyjsxy.cn` | 从统一认证门户点击链接触发 |
|
||||
| URL 参数 `sso=1` 或 `from=sso` / `from=cas` | 可选,门户显式携带时同样生效 |
|
||||
|
||||
**仅生产域名 `web.hzdyjsxy.cn` 启用自动跳转**;本地 `localhost` 开发不会误触发 CAS 跳转到生产环境。
|
||||
|
||||
**不自动跳转**的场景:用户在本系统内操作导致会话过期,被重定向到 `login?redirect=...` 且 Referer 为 `web.hzdyjsxy.cn` 时,保留账号密码登录。
|
||||
|
||||
处理顺序:`onMounted` 时若 URL 已有 `code` 则直接换 Token;否则若判定为外部/SSO 入口则自动 `handleUnifiedLogin()`。
|
||||
|
||||
### 3.2 跳转 CAS 授权页
|
||||
|
||||
点击「统一认证登录」后,浏览器整页跳转到 CAS 授权地址:
|
||||
|
||||
```
|
||||
GET http://td.hzdyjsxy.cn:19081/cas/oauth2.0/authorize
|
||||
?response_type=code
|
||||
&client_id=0i7tcT5tULqvmmbbV8
|
||||
&redirect_uri=http%3A%2F%2Fweb.hzdyjsxy.cn%3A18081
|
||||
```
|
||||
|
||||
**当前写死的配置项**(`login.vue`):
|
||||
|
||||
| 配置项 | 值 | 说明 |
|
||||
|--------|-----|------|
|
||||
| `CAS_BASE` | `http://td.hzdyjsxy.cn:19081` | CAS 服务器地址 |
|
||||
| `CAS_CLIENT_ID` | `0i7tcT5tULqvmmbbV8` | OAuth2 客户端 ID |
|
||||
| `CAS_CLIENT_SECRET` | `68c4f7641523452aaf5ef6d0432869f7` | 客户端密钥(仅后端使用,前端不应暴露) |
|
||||
| `FRONTEND_REDIRECT_URI` | `http://web.hzdyjsxy.cn:18081` | CAS 授权成功后的回调地址 |
|
||||
|
||||
> **注意**:`client_secret` 在前端代码中有定义但未在跳转 URL 中使用;实际换 token 时由后端携带。
|
||||
|
||||
### 3.3 CAS 回调与 code 提取
|
||||
|
||||
用户在 CAS 登录成功后,浏览器被重定向到:
|
||||
|
||||
```
|
||||
http://web.hzdyjsxy.cn:18081?code=ST-xxxxxxxx
|
||||
```
|
||||
|
||||
`login.vue` 在 `onMounted` 时自动检测 URL 中的 `code`,支持三种提取方式:
|
||||
|
||||
1. 直接从 `route.query.code` 获取
|
||||
2. 从 `redirect` 参数中解析(如 `redirect=/?code=ST-xxx`)
|
||||
3. 从 `route.fullPath` 中正则匹配
|
||||
|
||||
**回调路径说明**:若用户先落到 `/` 且无 Token,`permission.ts` 路由守卫会将其重定向到 `/login?redirect=...`,`code` 会保留在 `redirect` 参数中,登录页仍能正确解析。
|
||||
|
||||
### 3.4 通过后端代理换取 Token
|
||||
|
||||
前端**不直接**请求 CAS(避免浏览器 CORS 限制),而是调用后端代理接口:
|
||||
|
||||
```
|
||||
GET {VITE_APP_BASE_API}/cas/accessToken?code={code}
|
||||
```
|
||||
|
||||
收到响应后,从以下字段之一提取 Token:
|
||||
|
||||
- `body.data.access_token`
|
||||
- `body.access_token`
|
||||
- `body.data.accessToken`
|
||||
- `body.accessToken`
|
||||
|
||||
提取成功后:
|
||||
|
||||
1. 调用 `setToken()` 持久化 Token
|
||||
2. 更新 `userStore.token`
|
||||
3. 跳转到 `redirect` 参数指定页面或首页 `/`
|
||||
|
||||
### 3.5 登录后的权限加载
|
||||
|
||||
CAS 登录只写入 Token,**不会**调用 `userStore.login()`。进入系统后,`permission.ts` 路由守卫检测到「有 Token 但 roles 为空」时,会自动:
|
||||
|
||||
1. 调用 `getInfo()` 拉取用户信息与权限
|
||||
2. 调用 `generateRoutes()` 生成动态菜单路由
|
||||
3. 进入目标页面
|
||||
|
||||
因此,CAS 登录与普通登录在**进入系统之后的行为完全一致**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端实现
|
||||
|
||||
### 4.1 接口信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 接口地址 | `GET /cas/accessToken` |
|
||||
| 权限要求 | 无需认证(`@SaIgnore`) |
|
||||
| Controller | `CasController.java` |
|
||||
| 请求参数 | `code`(必填,CAS 授权码) |
|
||||
|
||||
### 4.2 处理步骤
|
||||
|
||||
#### 步骤 1:用 code 换取 CAS access_token
|
||||
|
||||
```
|
||||
GET http://td.hzdyjsxy.cn:19081/cas/oauth2.0/accessToken
|
||||
?client_id=0i7tcT5tULqvmmbbV8
|
||||
&client_secret=68c4f7641523452aaf5ef6d0432869f7
|
||||
&code={code}
|
||||
&redirect_uri=http%3A%2F%2Fweb.hzdyjsxy.cn%3A18081
|
||||
```
|
||||
|
||||
CAS 可能返回 JSON 或 query-string 格式,后端兼容两种解析方式:
|
||||
|
||||
- JSON:`{"access_token":"xxx","expires_in":3600}`
|
||||
- Query-string:`access_token=xxx&expires_in=3600`
|
||||
|
||||
#### 步骤 2:用 access_token 获取用户信息
|
||||
|
||||
```
|
||||
GET http://td.hzdyjsxy.cn:19081/cas/oauth2.0/profile?access_token={access_token}
|
||||
```
|
||||
|
||||
从返回 JSON 的 `attributes` 数组中解析:
|
||||
|
||||
| CAS 字段 | 用途 | 本系统映射 |
|
||||
|----------|------|------------|
|
||||
| `CODE` | 用户唯一标识 | → `sys_user.user_name` |
|
||||
| `TENANTID` | 租户标识 | **当前被忽略**,强制使用 `000000` |
|
||||
|
||||
#### 步骤 3:匹配本地用户并签发 Token
|
||||
|
||||
1. 在租户 `000000` 上下文中,按 `user_name = CODE` 查询 `sys_user`
|
||||
2. 校验用户存在且状态非禁用
|
||||
3. 调用 `loginService.buildLoginUser(user)` 构建 `LoginUser`
|
||||
4. 使用默认客户端 `e5cd7e4891bf95d1d19206ce24a7b32e` 配置超时等参数
|
||||
5. 调用 `LoginHelper.login()` 通过 Sa-Token 签发本系统 Token
|
||||
|
||||
#### 步骤 4:返回登录结果
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiJ9...",
|
||||
"expire_in": 7200,
|
||||
"client_id": "e5cd7e4891bf95d1d19206ce24a7b32e"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 错误处理
|
||||
|
||||
| 场景 | 返回信息 |
|
||||
|------|----------|
|
||||
| code 为空 | `code 不能为空` |
|
||||
| 无法解析 access_token | `无法解析 CAS access_token,原始响应: ...` |
|
||||
| profile 无用户信息 | `CAS profile 未包含用户信息` |
|
||||
| 本地用户不存在 | `user.not.exists` |
|
||||
| 用户已禁用 | `user.blocked` |
|
||||
| CAS 请求异常 | `请求 CAS accessToken 失败: ...` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置清单
|
||||
|
||||
### 5.1 CAS 平台侧(需运维/信息中心配置)
|
||||
|
||||
- [ ] 注册 OAuth2 应用,获取 `client_id` 和 `client_secret`
|
||||
- [ ] 配置授权回调地址 `redirect_uri` 与代码一致
|
||||
- [ ] 确认 profile 接口返回 `attributes` 中包含 `CODE` 字段
|
||||
- [ ] 确认 CAS 服务器 `td.hzdyjsxy.cn:19081` 对后端服务器网络可达
|
||||
|
||||
### 5.2 本系统侧
|
||||
|
||||
- [ ] `sys_user` 表中存在与 CAS `CODE` 一致的 `user_name` 记录
|
||||
- [ ] 用户状态为正常(非禁用)
|
||||
- [ ] 前端 `redirect_uri` 与 CAS 注册地址一致
|
||||
- [ ] 后端 `CasController` 中 CAS 地址、client 配置与 CAS 平台一致
|
||||
- [ ] 生产环境部署时,将硬编码 IP 改为实际域名/地址
|
||||
|
||||
### 5.3 当前硬编码配置汇总
|
||||
|
||||
**前端**(`web/src/views/login.vue`):
|
||||
|
||||
```
|
||||
CAS_BASE = http://td.hzdyjsxy.cn:19081
|
||||
CAS_CLIENT_ID = 0i7tcT5tULqvmmbbV8
|
||||
FRONTEND_REDIRECT_URI = http://web.hzdyjsxy.cn:18081
|
||||
```
|
||||
|
||||
**后端**(`CasController.java`):
|
||||
|
||||
```
|
||||
CAS_BASE = http://td.hzdyjsxy.cn:19081
|
||||
CLIENT_ID = 0i7tcT5tULqvmmbbV8
|
||||
CLIENT_SECRET = 68c4f7641523452aaf5ef6d0432869f7
|
||||
REDIRECT_URI = http://web.hzdyjsxy.cn:18081
|
||||
DEFAULT_CLIENT_ID = e5cd7e4891bf95d1d19206ce24a7b32e
|
||||
DEFAULT_TENANT_ID = 000000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见问题排查
|
||||
|
||||
### 6.1 点击按钮后无法跳转 CAS
|
||||
|
||||
- 检查浏览器能否访问 `http://td.hzdyjsxy.cn:19081`
|
||||
- 检查 CAS 平台是否正常运行
|
||||
- 检查 `client_id` 是否已在 CAS 注册
|
||||
|
||||
### 6.2 回调后停留在登录页 / 登录失败
|
||||
|
||||
- 检查 URL 是否携带 `code` 参数
|
||||
- 检查 `code` 是否已被使用(授权码一次性有效,刷新页面会失败)
|
||||
- 打开浏览器控制台查看 `/cas/accessToken` 请求响应
|
||||
|
||||
### 6.3 提示「用户不存在」
|
||||
|
||||
- CAS profile 中 `CODE` 值与 `sys_user.user_name` 不一致
|
||||
- 需在系统管理中预先创建对应用户,或建立 CAS 用户与本地用户的同步机制
|
||||
|
||||
### 6.4 提示「用户已禁用」
|
||||
|
||||
- 检查 `sys_user.status` 字段是否为正常状态
|
||||
|
||||
### 6.5 登录成功但无菜单 / 权限
|
||||
|
||||
- Token 已写入但 `getInfo()` 失败,检查后端 `/auth/getInfo` 接口
|
||||
- 检查用户是否已分配角色和菜单权限
|
||||
|
||||
### 6.6 CORS 相关
|
||||
|
||||
- 前端不应直接请求 CAS,必须通过后端 `/cas/accessToken` 代理
|
||||
- 若后端也报连接失败,检查后端服务器到 CAS 的网络连通性
|
||||
|
||||
---
|
||||
|
||||
## 7. 后续优化建议
|
||||
|
||||
1. **配置外置化**:将 CAS 地址、client_id、client_secret、redirect_uri 移至 `application.yml`,避免硬编码
|
||||
2. **密钥安全**:前端不应保留 `CAS_CLIENT_SECRET`(当前未使用但存在代码中,建议删除)
|
||||
3. **租户映射**:如需支持多租户,可恢复对 CAS `TENANTID` 的解析逻辑
|
||||
4. **用户自动注册**:CAS 用户首次登录时自动创建本地账号(需评估权限与安全策略)
|
||||
5. **移动端接入**:mobile 工程可参考相同 OAuth2 流程实现统一认证登录
|
||||
6. **退出登录**:考虑对接 CAS 单点登出(SLO)接口
|
||||
|
||||
---
|
||||
|
||||
## 8. 附录:关键代码索引
|
||||
|
||||
### 8.1 前端 — 跳转 CAS
|
||||
|
||||
```typescript
|
||||
// web/src/views/login.vue
|
||||
const handleUnifiedLogin = () => {
|
||||
const authorizeUrl = `${CAS_BASE}/cas/oauth2.0/authorize?response_type=code&client_id=${CAS_CLIENT_ID}&redirect_uri=${FRONTEND_REDIRECT_URI_ENCODED}`;
|
||||
window.location.href = authorizeUrl;
|
||||
};
|
||||
```
|
||||
|
||||
### 8.2 前端 — 回调处理
|
||||
|
||||
```typescript
|
||||
// web/src/views/login.vue — onMounted
|
||||
const code = extractCodeFromRoute(router.currentRoute.value);
|
||||
if (code) {
|
||||
fetchAccessToken(code);
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 后端 — 用户映射与登录
|
||||
|
||||
```java
|
||||
// CasController.java
|
||||
LoginUser loginUser = TenantHelper.dynamic(finalTenantId, () -> {
|
||||
SysUserVo user = userMapper.selectVoOne(
|
||||
new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, finalUsername));
|
||||
// 校验存在性、状态...
|
||||
return loginService.buildLoginUser(user);
|
||||
});
|
||||
LoginHelper.login(loginUser, model);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.1
|
||||
**创建日期**:2026-06-10
|
||||
**更新日期**:2026-06-25 — 新增从 SSO 门户进入登录页时自动统一认证
|
||||
**维护说明**:CAS 配置变更或登录逻辑调整时,请同步更新本文档及 [用户认证登录API](./API/用户认证登录API.md)。
|
||||
@@ -1,25 +1,25 @@
|
||||
-- =============================================================================
|
||||
-- 学生学籍异动 + 换班级 → 教务对接宽表视图(验证用)
|
||||
-- 学生学籍异动 + 换班级 → 教务对接宽表视图
|
||||
-- 说明见:学生学籍异动同步教务系统改造方案.md
|
||||
--
|
||||
-- 约定:
|
||||
-- * 异动前/后「校区」列为 NULL
|
||||
-- * 「异动生效学期」列保留,值为 NULL
|
||||
-- * 「异动生效时间」:学籍异动 = effective_date;换班级 = effective_time
|
||||
-- * 异动前/后「校区」列为 NULL(本系统无校区字段)
|
||||
-- * 「异动生效学期」列保留,值为 NULL(教务可接受空值)
|
||||
-- * 「异动生效时间」:学籍异动 = effective_date;换班级 = effective_time(API 按年筛选)
|
||||
-- * 「异动类型」:输出教务系统中文名称(见 §异动类型映射)
|
||||
-- * 仅导出已处理:学籍 process_status = 1;换班级 status = '1'
|
||||
-- * 换班级异动类型占位:HUAN_BAN_JI(请按教务附件码表替换)
|
||||
-- * 学籍异动「异动前/后」组织与学制:取自当前 xg_student_msg 冗余字段(与「当时」快照
|
||||
-- 可能不一致,正式投产前请按方案评估是否改读 xg_student_msg_his 或落库快照)
|
||||
-- * 末尾「数据来源」「来源记录id」便于联调,对接教务时可从视图中去掉这两列
|
||||
-- * 学籍异动「异动前」:优先 xg_student_msg_his(处理脚本落库快照),否则回退 xg_student_msg
|
||||
-- * 学籍异动「异动后」:取自当前 xg_student_msg(纯学籍异动组织维度通常不变)
|
||||
-- * 末尾「数据来源」「来源记录id」供联调,API 层不对外返回
|
||||
--
|
||||
-- 异动类型映射(本库 → 教务附件1):
|
||||
-- TUI_XUE → 退学
|
||||
-- XIU_XUE → 休学
|
||||
-- FU_XUE → 复学
|
||||
-- BI_YE → 不同步(本库「毕业」记录不传给教务)
|
||||
-- 换班级记录 → 转班级
|
||||
--
|
||||
-- 执行前请确认:本库 sys_user 是否含 user_no;若无则把 COALESCE 改为仅用 user_name。
|
||||
--
|
||||
-- 排序规则:若出现 1267 Illegal mix of collations,说明各表 tenant_id 等字段默认校对不一致;
|
||||
-- 以下 JOIN/WHERE 已对字符串比较统一为 utf8mb4_unicode_ci(可按库内多数表实际改为 general_ci)。
|
||||
--
|
||||
-- 异动类型名称:与教务系统用语对齐(教务清单含实习、肄业、结业、退学、转班级、休学、复学等;
|
||||
-- 本库仅产生 TUI_XUE/XIU_XUE/FU_XUE/BI_YE/HUAN_BAN_JI)。BI_YE 在清单中无「毕业」,暂映射为「结业」,
|
||||
-- 若贵校教务将毕业单独编码请修改下方 CASE。
|
||||
-- =============================================================================
|
||||
|
||||
DROP VIEW IF EXISTS `vw_jw_student_change_export`;
|
||||
@@ -29,30 +29,28 @@ SELECT
|
||||
DATE(COALESCE(sc.apply_date, sc.create_time)) AS `提交时间`,
|
||||
(CAST(NULL AS CHAR(32) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动生效学期`,
|
||||
CAST(sc.effective_date AS DATETIME) AS `异动生效时间`,
|
||||
(sc.change_type COLLATE utf8mb4_unicode_ci) AS `异动类型`,
|
||||
(CASE sc.change_type
|
||||
WHEN 'TUI_XUE' THEN '退学'
|
||||
WHEN 'XIU_XUE' THEN '休学'
|
||||
WHEN 'FU_XUE' THEN '复学'
|
||||
WHEN 'BI_YE' THEN '结业'
|
||||
ELSE COALESCE(sc.change_type, '')
|
||||
END COLLATE utf8mb4_unicode_ci) AS `异动类型名称`,
|
||||
END COLLATE utf8mb4_unicode_ci) AS `异动类型`,
|
||||
(COALESCE(sc.student_no, m.student_no) COLLATE utf8mb4_unicode_ci) AS `申请人学号`,
|
||||
(m.`name` COLLATE utf8mb4_unicode_ci) AS `姓名`,
|
||||
(m.grade COLLATE utf8mb4_unicode_ci) AS `异动前所在年级`,
|
||||
(COALESCE(mh.`name`, m.`name`) COLLATE utf8mb4_unicode_ci) AS `姓名`,
|
||||
(COALESCE(mh.grade, m.grade, cm_before.grade) COLLATE utf8mb4_unicode_ci) AS `异动前所在年级`,
|
||||
(CAST(NULL AS CHAR(64) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动前所在校区`,
|
||||
(m.college_name COLLATE utf8mb4_unicode_ci) AS `异动前所在院系`,
|
||||
(m.major_name COLLATE utf8mb4_unicode_ci) AS `异动前所在专业`,
|
||||
(m.class_name COLLATE utf8mb4_unicode_ci) AS `异动前所在班级`,
|
||||
m.school_system AS `异动前学制`,
|
||||
(COALESCE(mh.college_name, m.college_name, cm_before.college_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在院系`,
|
||||
(COALESCE(mh.major_name, m.major_name, cm_before.major_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在专业`,
|
||||
(COALESCE(mh.class_name, m.class_name, cm_before.class_name) COLLATE utf8mb4_unicode_ci) AS `异动前所在班级`,
|
||||
COALESCE(cm_before.school_system, mh.school_system, m.school_system) AS `异动前学制`,
|
||||
(CAST(NULL AS CHAR(64) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动后所在校区`,
|
||||
(m.grade COLLATE utf8mb4_unicode_ci) AS `异动后所在年级`,
|
||||
(m.class_name COLLATE utf8mb4_unicode_ci) AS `异动后所在班级`,
|
||||
(m.college_name COLLATE utf8mb4_unicode_ci) AS `异动后所在院系`,
|
||||
(m.major_name COLLATE utf8mb4_unicode_ci) AS `异动后所在专业`,
|
||||
m.school_system AS `异动后学制`,
|
||||
(COALESCE(m.grade, cm_after.grade, mh.grade) COLLATE utf8mb4_unicode_ci) AS `异动后所在年级`,
|
||||
(COALESCE(m.class_name, cm_after.class_name, mh.class_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在班级`,
|
||||
(COALESCE(m.college_name, cm_after.college_name, mh.college_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在院系`,
|
||||
(COALESCE(m.major_name, cm_after.major_name, mh.major_name) COLLATE utf8mb4_unicode_ci) AS `异动后所在专业`,
|
||||
COALESCE(cm_after.school_system, m.school_system, cm_before.school_system, mh.school_system) AS `异动后学制`,
|
||||
(COALESCE(NULLIF(TRIM(su.user_no), ''), su.user_name) COLLATE utf8mb4_unicode_ci) AS `异动后班主任工号`,
|
||||
(m.class_teacher_name COLLATE utf8mb4_unicode_ci) AS `异动后班主任姓名`,
|
||||
(COALESCE(m.class_teacher_name, cm_after.class_teacher_name, mh.class_teacher_name) COLLATE utf8mb4_unicode_ci) AS `异动后班主任姓名`,
|
||||
(CAST(NULL AS CHAR(50) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `新名字`,
|
||||
('已通过' COLLATE utf8mb4_unicode_ci) AS `审核状态`,
|
||||
('学籍异动' COLLATE utf8mb4_unicode_ci) AS `数据来源`,
|
||||
@@ -62,12 +60,25 @@ LEFT JOIN xg_student_msg m
|
||||
ON m.id = sc.student_id
|
||||
AND m.deleted = 0
|
||||
AND (m.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
|
||||
LEFT JOIN xg_student_msg_his mh
|
||||
ON mh.id = sc.student_id
|
||||
AND mh.deleted = 0
|
||||
AND (mh.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
|
||||
LEFT JOIN xg_class_msg cm_before
|
||||
ON cm_before.deleted = 0
|
||||
AND (cm_before.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
|
||||
AND cm_before.id = CAST(NULLIF(TRIM(COALESCE(mh.class_id, m.class_id)), '') AS UNSIGNED)
|
||||
LEFT JOIN xg_class_msg cm_after
|
||||
ON cm_after.deleted = 0
|
||||
AND (cm_after.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
|
||||
AND cm_after.id = CAST(NULLIF(TRIM(m.class_id), '') AS UNSIGNED)
|
||||
LEFT JOIN sys_user su
|
||||
ON su.user_id = m.class_teacher_id
|
||||
ON su.user_id = COALESCE(m.class_teacher_id, cm_after.class_teacher_id)
|
||||
AND (su.del_flag COLLATE utf8mb4_unicode_ci) = ('0' COLLATE utf8mb4_unicode_ci)
|
||||
AND (su.tenant_id COLLATE utf8mb4_unicode_ci) = (sc.tenant_id COLLATE utf8mb4_unicode_ci)
|
||||
WHERE sc.deleted = 0
|
||||
AND sc.process_status = 1
|
||||
AND (sc.change_type COLLATE utf8mb4_unicode_ci) <> ('BI_YE' COLLATE utf8mb4_unicode_ci)
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -75,8 +86,7 @@ SELECT
|
||||
DATE(COALESCE(c.change_time, c.create_time)) AS `提交时间`,
|
||||
(CAST(NULL AS CHAR(32) CHARACTER SET utf8mb4) COLLATE utf8mb4_unicode_ci) AS `异动生效学期`,
|
||||
CAST(c.effective_time AS DATETIME) AS `异动生效时间`,
|
||||
('HUAN_BAN_JI' COLLATE utf8mb4_unicode_ci) AS `异动类型`,
|
||||
('转班级' COLLATE utf8mb4_unicode_ci) AS `异动类型名称`,
|
||||
('转班级' COLLATE utf8mb4_unicode_ci) AS `异动类型`,
|
||||
(c.student_no COLLATE utf8mb4_unicode_ci) AS `申请人学号`,
|
||||
(c.student_name COLLATE utf8mb4_unicode_ci) AS `姓名`,
|
||||
(oc.grade COLLATE utf8mb4_unicode_ci) AS `异动前所在年级`,
|
||||
@@ -115,10 +125,6 @@ WHERE c.deleted = 0
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 授权:用户 jw_cx 仅查询本视图(需使用具备 GRANT 权限的账号执行,如 root)
|
||||
-- 库名默认 hzdyjs_xg(与 application-dev 一致);若库名或主机不同请修改后再执行。
|
||||
-- 主机 `%` 表示任意来源连接;若仅本机可改为 `'jw_cx'@'localhost'`。
|
||||
-- 若库中尚无 jw_cx,可先执行(按需设密码与认证插件):
|
||||
-- CREATE USER IF NOT EXISTS 'jw_cx'@'%' IDENTIFIED BY '请设置强密码';
|
||||
-- -----------------------------------------------------------------------------
|
||||
GRANT SELECT ON `hzdyjs_xg`.`vw_jw_student_change_export` TO 'jw_cx'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
@@ -16,7 +16,7 @@
|
||||
| 1 | 提交时间 | 有则格式化为日期 `YYYY-MM-DD`(与教务示例一致);口径建议统一为「登记/创建侧」:`学籍异动` 用 `apply_date` 或 `create_time`;`换班级` 用 `change_time` 或 `create_time`(实施时二选一写死) | `xg_student_status_change` / `xg_student_class_change` |
|
||||
| 2 | 异动生效学期 | **保留列,值为空**(`NULL` 或 `''`,与对方接口约定一致即可) | 常量列,不推算 |
|
||||
| 3 | 异动生效时间 | **必填语义由本库生效字段承担**:学籍异动用 `effective_date`;换班级用 `effective_time`(类型统一为 `DATETIME` 便于对接) | 同上两表 |
|
||||
| 4 | 异动类型 | 代码值需与教务「附件1」枚举对齐;本库:`TUI_XUE`/`XIU_XUE`/`FU_XUE`/`BI_YE`;换班级单独一类(见 §4) | `change_type` + 来源标识 |
|
||||
| 4 | 异动类型 | 输出教务附件1 **中文名称**(退学/休学/复学/转班级);见 §4 映射表;**毕业(BI_YE)不同步** | 视图内 `CASE` + WHERE 过滤 |
|
||||
| 5 | 申请人学号 | 直接输出 | `student_no` |
|
||||
| 6 | 姓名 | 输出异动当时姓名:换班级表有 `student_name`;学籍异动可 `JOIN xg_student_msg` 取当前 `name`(若需「当时」姓名需后续增强,见 §6) | 业务表 + `xg_student_msg` |
|
||||
| 7 | 异动前所在年级 | 有则输出 | 换班级:`original_`* 无年级时 JOIN 原班级 `xg_class_msg.grade`;学籍异动:见 §6 |
|
||||
@@ -52,10 +52,20 @@
|
||||
|
||||
## 4. 异动类型(与教务对齐)
|
||||
|
||||
- **学籍四类**:将 `TUI_XUE`、`XIU_XUE`、`FU_XUE`、`BI_YE` 映射为教务附件中的编码(映射表建议单独维护:字典表或视图内 `CASE`,以教务最终码表为准)。
|
||||
- **换班级**:单独一种教务类型码;来源为 `xg_student_class_change`(勿与表中 `change_type` 1–4「申请类型」混淆,视图层用常量或别名列区分「业务异动类型」)。
|
||||
本库 → 教务附件1 中文名称映射(视图 `异动类型` 列直接输出教务名称):
|
||||
|
||||
「复学并换班级」会同时存在学籍记录与换班级记录:导出时需约定 **两条都出** 或 **合并为一条**(建议与教务约定后,在视图或同步 SQL 中写死规则)。
|
||||
| 本库来源 | 内部标识 | 教务异动类型名称 |
|
||||
| -------- | -------- | ---------------- |
|
||||
| 学籍异动 | `TUI_XUE` | 退学 |
|
||||
| 学籍异动 | `XIU_XUE` | 休学 |
|
||||
| 学籍异动 | `FU_XUE` | 复学 |
|
||||
| 换班级 | — | 转班级 |
|
||||
|
||||
**不同步**:`BI_YE`(毕业)记录不进入视图、不传给教务。
|
||||
|
||||
详细说明见:`跟教务系统同步学生异动信息/异动类型映射说明.md`。
|
||||
|
||||
「复学并换班级」会同时存在学籍记录与换班级记录:当前策略为 **两条都出**。
|
||||
|
||||
---
|
||||
|
||||
@@ -97,7 +107,7 @@
|
||||
|
||||
| 项 | 说明 |
|
||||
| ------------- | ----------------------------------------------------------------------------- |
|
||||
| 纯学籍异动的「异动前」组织 | 若业务未保证每次写 `xg_student_msg_his`,视图可能缺「前」;建议长期在异动落表时**冗余写入前后快照**或依赖可靠 his 关联规则。 |
|
||||
| 纯学籍异动的「异动前」组织 | 视图优先读 `xg_student_msg_his`(处理脚本落库快照),无快照时回退 `xg_student_msg`;同一学生多次异动时 his 仅保留首次快照,需长期可考虑落库冗余字段。 |
|
||||
| 校区 | 当前方案恒空;将来可在 `xg_class_msg` 或学院维度补 `campus_id/name` 后改视图即可。 |
|
||||
| 异动生效学期 | 当前恒空;将来有校历维表后,可只改视图计算逻辑,不必改业务表。 |
|
||||
| 改名 | 无流水则「新名字」保持空;需要时增加改名记录表或审计。 |
|
||||
@@ -126,5 +136,7 @@
|
||||
| ---------- | ------------------------------------------------------------ |
|
||||
| 2026-05-07 | 初稿:汇聚视图方案;校区、生效学期为空;生效时间取自 `effective_date`/`effective_time` |
|
||||
| 2026-05-07 | 补充:`vw_jw_student_change_export.sql` 可执行 DDL |
|
||||
| 2026-06-22 | API 改为按「异动生效时间」自然年筛选;视图输出教务中文异动类型;学籍异动「前」优先读 `xg_student_msg_his` |
|
||||
| 2026-06-22 | 毕业(`BI_YE`)记录不同步给教务,视图 WHERE 排除 |
|
||||
|
||||
|
||||
+16356
File diff suppressed because it is too large.
Load diff
+16
-1
@@ -17,6 +17,20 @@ const isWhiteList = (path: string) => {
|
||||
return whiteList.some((pattern) => isPathMatch(pattern, path));
|
||||
};
|
||||
|
||||
/** 单点登录门户域名,用于未登录重定向到登录页时附带 sso 参数以触发自动统一认证 */
|
||||
const SSO_REFERRER_HOSTS = ['td.hzdyjsxy.cn', 'hzdyjsxy.cn'];
|
||||
|
||||
const isReferrerFromSsoPortal = () => {
|
||||
const referrer = document.referrer;
|
||||
if (!referrer) return false;
|
||||
try {
|
||||
const host = new URL(referrer).hostname;
|
||||
return SSO_REFERRER_HOSTS.some((h) => host === h || host.endsWith('.' + h));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start();
|
||||
if (getToken()) {
|
||||
@@ -59,7 +73,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
next();
|
||||
} else {
|
||||
const redirect = encodeURIComponent(to.fullPath || '/');
|
||||
next(`/login?redirect=${redirect}`); // 否则全部重定向到登录页
|
||||
const ssoFlag = isReferrerFromSsoPortal() ? '&sso=1' : '';
|
||||
next(`/login?redirect=${redirect}${ssoFlag}`); // 否则全部重定向到登录页
|
||||
NProgress.done();
|
||||
}
|
||||
}
|
||||
|
||||
+115
-8
@@ -5,6 +5,10 @@
|
||||
<el-progress :percentage="loadingProgress" :show-text="false" />
|
||||
<span class="loading-text">背景加载中...</span>
|
||||
</div>
|
||||
<div class="loading-indicator" v-if="autoCasRedirecting">
|
||||
<el-progress :percentage="100" :show-text="false" status="success" />
|
||||
<span class="loading-text">正在跳转统一认证...</span>
|
||||
</div>
|
||||
<el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form">
|
||||
<div class="title-box">
|
||||
<h3 class="title">{{ title }}</h3>
|
||||
@@ -114,6 +118,8 @@ const loginRules: ElFormRules = {
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
/** 从单点登录门户进入时自动跳转 CAS,展示过渡提示 */
|
||||
const autoCasRedirecting = ref(false);
|
||||
// 租户开关
|
||||
const tenantEnabled = ref(true);
|
||||
|
||||
@@ -237,15 +243,104 @@ const doSocialLogin = (type: string) => {
|
||||
|
||||
// ======= 统一认证(CAS)相关 =======
|
||||
// 固定的 CAS 地址与生产前端地址(按需求写死)
|
||||
const CAS_BASE = 'http://172.16.0.82';
|
||||
const CAS_BASE = 'http://td.hzdyjsxy.cn:19081';
|
||||
const CAS_CLIENT_ID = '0i7tcT5tULqvmmbbV8';
|
||||
const CAS_CLIENT_SECRET = '68c4f7641523452aaf5ef6d0432869f7';
|
||||
const FRONTEND_REDIRECT_URI_ENCODED = encodeURIComponent('http://172.16.0.67:8882');
|
||||
const FRONTEND_REDIRECT_URI_ENCODED = encodeURIComponent('http://web.hzdyjsxy.cn:18081');
|
||||
const CAS_REDIRECT_KEY = 'cas_login_redirect';
|
||||
/** 单点登录门户域名(Referer 匹配) */
|
||||
const SSO_REFERRER_HOSTS = ['td.hzdyjsxy.cn', 'hzdyjsxy.cn'];
|
||||
/** 本系统前端域名(Referer 来自本站时表示会话过期等内部跳转,保留账号密码登录) */
|
||||
const APP_HOSTS = ['web.hzdyjsxy.cn', 'localhost', '127.0.0.1'];
|
||||
/** 单点门户固定入口 redirect 目标(与门户配置 login?redirect=/index 一致) */
|
||||
const SSO_ENTRY_REDIRECT = '/index';
|
||||
|
||||
/** 仅生产域名启用 SSO 自动跳转,避免本地 localhost 误触发 CAS 并跳到生产环境 */
|
||||
const isCasAutoLoginEnabled = (): boolean => {
|
||||
return window.location.hostname === 'web.hzdyjsxy.cn';
|
||||
};
|
||||
|
||||
const isSsoRedirectEntry = (route: any): boolean => {
|
||||
const redirectTarget = String(route?.query?.redirect || '').split('?')[0];
|
||||
return redirectTarget === SSO_ENTRY_REDIRECT;
|
||||
};
|
||||
|
||||
const isTruthyQueryParam = (value: unknown) => {
|
||||
if (value === undefined || value === null || value === '') return false;
|
||||
const s = String(value).toLowerCase();
|
||||
return s === '1' || s === 'true' || s === 'yes';
|
||||
};
|
||||
|
||||
const isReferrerFromOurApp = (): boolean => {
|
||||
const referrer = document.referrer;
|
||||
if (!referrer) return false;
|
||||
try {
|
||||
const host = new URL(referrer).hostname;
|
||||
return APP_HOSTS.some((h) => host === h || host.endsWith('.' + h));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 点击“统一认证登录”按钮,直接跳转到 CAS 授权页面
|
||||
* 判断是否从单点登录门户进入(需自动走统一认证,无需用户再点按钮)
|
||||
* - URL 参数 sso=1 / from=sso|cas(可选,门户未配置时也能识别)
|
||||
* - Referer 来自学校统一认证/门户域名
|
||||
* - 典型 SSO 入口:/login?redirect=/index(无额外参数、常不带 Referer),且非本系统内跳转
|
||||
*/
|
||||
const isFromSsoPortal = (route: any): boolean => {
|
||||
if (!isCasAutoLoginEnabled()) return false;
|
||||
|
||||
const query = route?.query || {};
|
||||
if (isTruthyQueryParam(query.sso)) return true;
|
||||
|
||||
const from = String(query.from || '').toLowerCase();
|
||||
if (from === 'sso' || from === 'cas' || from === 'udap') return true;
|
||||
|
||||
const referrer = document.referrer;
|
||||
if (referrer) {
|
||||
try {
|
||||
const host = new URL(referrer).hostname;
|
||||
if (SSO_REFERRER_HOSTS.some((h) => host === h || host.endsWith('.' + h))) return true;
|
||||
} catch {
|
||||
/* ignore invalid referrer */
|
||||
}
|
||||
}
|
||||
|
||||
// 单点门户固定链接 /login?redirect=/index;本地未登录访问 / 时为 redirect=/,不应误判
|
||||
if (isSsoRedirectEntry(route) && !isReferrerFromOurApp()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getPostLoginRedirect = () => {
|
||||
const fromStorage = sessionStorage.getItem(CAS_REDIRECT_KEY);
|
||||
if (fromStorage) {
|
||||
sessionStorage.removeItem(CAS_REDIRECT_KEY);
|
||||
return fromStorage;
|
||||
}
|
||||
const target = redirect.value || '/';
|
||||
// CAS 回调经路由守卫带入的 redirect 可能含 code,登录后应去掉
|
||||
if (target.includes('code=')) {
|
||||
return target.split('?')[0] || '/';
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
const persistCasRedirectTarget = () => {
|
||||
const target = redirect.value;
|
||||
if (target && !target.includes('code=')) {
|
||||
sessionStorage.setItem(CAS_REDIRECT_KEY, target);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 跳转 CAS 授权页(手动点击或 SSO 自动触发)
|
||||
*/
|
||||
const handleUnifiedLogin = () => {
|
||||
persistCasRedirectTarget();
|
||||
const authorizeUrl = `${CAS_BASE}/cas/oauth2.0/authorize?response_type=code&client_id=${CAS_CLIENT_ID}&redirect_uri=${FRONTEND_REDIRECT_URI_ENCODED}`;
|
||||
window.location.href = authorizeUrl;
|
||||
};
|
||||
@@ -316,12 +411,16 @@ const fetchAccessToken = async (code: string) => {
|
||||
// 持久化 token,更新 store,并跳转到重定向或首页
|
||||
setToken(tokenStr);
|
||||
userStore.token = tokenStr;
|
||||
const redirectUrl = redirect.value || '/';
|
||||
await router.push(redirectUrl);
|
||||
await router.push(getPostLoginRedirect());
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessage.error((body && body.msg) || '统一认证登录失败,请使用账号密码登录');
|
||||
autoCasRedirecting.value = false;
|
||||
} catch (err) {
|
||||
console.error('fetchAccessToken error', err);
|
||||
ElMessage.error('统一认证登录失败,请使用账号密码登录');
|
||||
autoCasRedirecting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -332,11 +431,19 @@ onMounted(() => {
|
||||
// 开始预加载背景图片
|
||||
preloadBackgroundImage();
|
||||
|
||||
// 如果 URL 中携带 CAS 的 code,则使用该 code 请求 accessToken 并打印返回信息
|
||||
const code = extractCodeFromRoute(router.currentRoute.value);
|
||||
const route = router.currentRoute.value;
|
||||
|
||||
// 如果 URL 中携带 CAS 的 code,则使用该 code 换取本系统 Token
|
||||
const code = extractCodeFromRoute(route);
|
||||
if (code) {
|
||||
// 发起请求(异步,不阻塞页面加载)
|
||||
fetchAccessToken(code);
|
||||
return;
|
||||
}
|
||||
|
||||
// 从单点登录门户进入:自动执行统一认证流程(直接打开登录页不走此分支)
|
||||
if (isFromSsoPortal(route)) {
|
||||
autoCasRedirecting.value = true;
|
||||
handleUnifiedLogin();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
- **Python**:开发与生产建议 **3.7.9**(与现网一致)。
|
||||
- **依赖**:见 `requirements.txt`(Flask 2.0.x + PyMySQL)。
|
||||
- **异动类型映射**:见上级目录 [`异动类型映射说明.md`](../异动类型映射说明.md)
|
||||
|
||||
## 配置文件与环境对应关系
|
||||
|
||||
@@ -25,55 +26,23 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Linux 生产环境:`pip3:未找到命令` / `No module named pymysql`
|
||||
|
||||
很多最小化系统只有 `python3`,没有独立的 **`pip3`** 命令。请用 **当前解释器自带的 pip 模块** 安装依赖,例如:
|
||||
### Linux 生产环境
|
||||
|
||||
```bash
|
||||
cd /path/to/api_service
|
||||
|
||||
# 1)优先尝试(与 python3 一一对应)
|
||||
python3 -m pip install -r requirements.txt
|
||||
|
||||
# 若 `python3 -m pip` 报 No module named pip,先装系统级 pip(需 root):
|
||||
# CentOS 7 / 部分 RHEL:
|
||||
# yum install -y python3-pip
|
||||
# CentOS 8+ / RHEL 8+ / Fedora:
|
||||
# dnf install -y python3-pip
|
||||
# 装完再执行:
|
||||
python3 -m pip install -r requirements.txt
|
||||
|
||||
# 若无 yum/dnf 或仓库无 python3-pip,可用官方引导脚本(任选其一 Python 版本需为 3.7+):
|
||||
# curl -sS https://bootstrap.pypa.io/pip/3.7/get-pip.py -o get-pip.py
|
||||
# python3 get-pip.py
|
||||
# python3 -m pip install -r requirements.txt
|
||||
# (3.7 专用 get-pip 见:https://bootstrap.pypa.io/pip/3.7/get-pip.py )
|
||||
```
|
||||
|
||||
确认:
|
||||
|
||||
```bash
|
||||
python3 -c "import pymysql, flask; print('ok')"
|
||||
```
|
||||
|
||||
启动时请用 **`python3 app.py`**(或与安装依赖时相同的 `python3`),避免混用 `python` 指向旧版 2.x。
|
||||
|
||||
## 选用配置并启动
|
||||
|
||||
**测试/开发**(与 dev 库一致):
|
||||
**测试/开发**:
|
||||
|
||||
```bash
|
||||
cp config.dev.ini config.ini
|
||||
python app.py
|
||||
```
|
||||
|
||||
或直接:
|
||||
|
||||
```bash
|
||||
export JW_CX_CONFIG="$(pwd)/config.dev.ini"
|
||||
python app.py
|
||||
```
|
||||
|
||||
**生产**:
|
||||
|
||||
```bash
|
||||
@@ -81,14 +50,6 @@ export JW_CX_CONFIG=/opt/jw-cx/config.prod.ini
|
||||
python app.py
|
||||
```
|
||||
|
||||
### 配置文件说明
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
|----------|------|
|
||||
| `JW_CX_CONFIG` | 配置文件路径;不设则使用当前工作目录下的 `config.ini`(相对路径相对于**进程启动时的工作目录**) |
|
||||
|
||||
模板见 **`config.ini.example`**。**`config.ini`** 已在 `.gitignore` 中忽略,避免把本地覆盖提交上去。
|
||||
|
||||
### INI 节说明
|
||||
|
||||
**`[database]`**:`host`、`port`、`user`、`password`、`database`
|
||||
@@ -98,8 +59,7 @@ python app.py
|
||||
|----|------|
|
||||
| `api_key` | 与请求头 `X-Jw-Cx-Key` 一致 |
|
||||
| `bind_host` / `bind_port` | HTTP 监听(仅直接运行 `python app.py` 时生效) |
|
||||
| `page_size` | **默认每页条数**;请求可带 `page_size` Query 覆盖,程序内限制 1~5000 |
|
||||
| `recent_days` | **默认近 N 天**;请求可带 `recent_days` / `days` 覆盖,程序内限制 1~3660 |
|
||||
| `default_year` | **默认筛选年份**(按视图列「异动生效时间」);未配置则使用当前自然年;请求可带 `year` 覆盖 |
|
||||
|
||||
## 密钥校验
|
||||
|
||||
@@ -119,8 +79,6 @@ export JW_CX_CONFIG=/path/to/config.prod.ini
|
||||
gunicorn -w 2 -b 0.0.0.0:5050 app:app
|
||||
```
|
||||
|
||||
监听地址/端口以 gunicorn 的 `-b` 为准;`bind_port` 仅对直接运行 `python app.py` 有效。
|
||||
|
||||
## 接口说明
|
||||
|
||||
### GET `/health`
|
||||
@@ -129,39 +87,58 @@ gunicorn -w 2 -b 0.0.0.0:5050 app:app
|
||||
|
||||
### GET `/api/v1/student-changes`
|
||||
|
||||
**数据范围**:仅 **`提交时间` 在最近 N 个自然日内**的记录;N 由 **`recent_days`**(或 `days`)指定,**未传则使用配置** `[api] recent_days`(默认 30)。
|
||||
**分页**:每页条数 **`page_size`** 可传 Query 覆盖,**未传则使用** `[api] page_size`(默认 500)。
|
||||
**数据范围**:**`异动生效时间` 落在指定自然年**内的已办结记录,**一次返回全部**(不分页);年份由 **`year`** 指定,**未传则使用当前自然年**(或配置 `default_year`)。
|
||||
|
||||
| Query | 说明 | 未传时的默认值 |
|
||||
|--------|------|------------------|
|
||||
| `page` | 页码,从 1 开始 | `1` |
|
||||
| `page_size` | 每页条数,有效范围 1~5000 | `[api] page_size` |
|
||||
| `recent_days` | 查询近 N 天(与视图列「提交时间」比较),有效范围 1~3660 | `[api] recent_days` |
|
||||
| `days` | 与 `recent_days` 同义,二选一即可 | (同上) |
|
||||
| `year` | 按「异动生效时间」所在自然年筛选,有效范围 2000~2100 | 当前自然年 |
|
||||
|
||||
成功响应中除 `data`、`page`、`page_size`、`total` 外,还包括:
|
||||
**响应 `data` 每条记录字段**(与教务对接清单一致):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 提交时间 | `YYYY-MM-DD` |
|
||||
| 异动生效学期 | 本系统无数据,为 `null` |
|
||||
| 异动类型 | **教务中文名称**(退学、休学、复学、转班级) |
|
||||
| 申请人学号 / 姓名 | |
|
||||
| 异动前/后 年级、院系、专业、班级、学制 | 学籍异动「前」优先取处理前快照 |
|
||||
| 异动前/后 所在校区 | 本系统无数据,为 `null` |
|
||||
| 异动后班主任工号 / 姓名 | |
|
||||
| 新名字 | 本系统无改名流水,为 `null` |
|
||||
| 审核状态 | 固定「已通过」 |
|
||||
|
||||
成功响应元数据:
|
||||
|
||||
| JSON 字段 | 说明 |
|
||||
|-----------|------|
|
||||
| `filter_recent_days` | **本次请求实际生效**的近 N 天(含 URL 覆盖后的值) |
|
||||
| `filter_by_column` | 固定为 `提交时间`(筛选字段名) |
|
||||
| `total` | 本次返回的记录总数(等于 `data` 数组长度) |
|
||||
| `filter_year` | 本次请求实际生效的筛选年份 |
|
||||
| `filter_by_column` | 固定为 `异动生效时间` |
|
||||
|
||||
**未授权**(HTTP 401):未带或错误的 `X-Jw-Cx-Key`。
|
||||
|
||||
## 调用示例
|
||||
|
||||
```bash
|
||||
# 使用配置中的 page_size、recent_days
|
||||
# 拉取当前自然年(如 2026)全部异动
|
||||
curl -s -H "X-Jw-Cx-Key: JWCX001" \
|
||||
"http://127.0.0.1:5050/api/v1/student-changes?page=1"
|
||||
"http://127.0.0.1:5050/api/v1/student-changes"
|
||||
|
||||
# 指定每页 50 条、近 7 天
|
||||
# 指定 2026 年
|
||||
curl -s -H "X-Jw-Cx-Key: JWCX001" \
|
||||
"http://127.0.0.1:5050/api/v1/student-changes?page=1&page_size=50&recent_days=7"
|
||||
"http://127.0.0.1:5050/api/v1/student-changes?year=2026"
|
||||
```
|
||||
|
||||
## 部署前:重建视图
|
||||
|
||||
代码更新后,请在目标库执行:
|
||||
|
||||
```bash
|
||||
mysql -h <host> -u root -p hzdyjs_xg < docs/设计文档/database/vw_jw_student_change_export.sql
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
1. `config.dev.ini` / `config.prod.ini` 含数据库凭据,与后端 YAML 同源;请按公司规范控制仓库可见性与部署权限。
|
||||
1. `config.dev.ini` / `config.prod.ini` 含数据库凭据,请按公司规范控制仓库可见性与部署权限。
|
||||
2. 建议数据库侧使用**仅对视图** `vw_jw_student_change_export` 有 `SELECT` 的专用账号,与后端 `root` 分离。
|
||||
3. 生产将 **`api_key`** 换为强随机串,并走 HTTPS;必要时加网络白名单。
|
||||
@@ -22,8 +22,33 @@ HEADER_API_KEY = "X-Jw-Cx-Key"
|
||||
|
||||
VIEW_NAME = "vw_jw_student_change_export"
|
||||
|
||||
# 视图列名(与 vw_jw_student_change_export 一致;筛选近 N 天时使用)
|
||||
COL_SUBMIT_DATE = "提交时间"
|
||||
# 视图列名:API 按「异动生效时间」所在自然年筛选
|
||||
COL_EFFECTIVE_TIME = "异动生效时间"
|
||||
|
||||
# 对外返回字段(与教务对接清单一致;不含视图内部列)
|
||||
EXPORT_COLUMNS = (
|
||||
"提交时间",
|
||||
"异动生效学期",
|
||||
"异动类型",
|
||||
"申请人学号",
|
||||
"姓名",
|
||||
"异动前所在年级",
|
||||
"异动前所在校区",
|
||||
"异动前所在院系",
|
||||
"异动前所在专业",
|
||||
"异动前所在班级",
|
||||
"异动前学制",
|
||||
"异动后所在校区",
|
||||
"异动后所在年级",
|
||||
"异动后所在班级",
|
||||
"异动后所在院系",
|
||||
"异动后所在专业",
|
||||
"异动后学制",
|
||||
"异动后班主任工号",
|
||||
"异动后班主任姓名",
|
||||
"新名字",
|
||||
"审核状态",
|
||||
)
|
||||
|
||||
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_DEFAULT_CONFIG_PATH = os.path.join(_BASE_DIR, "config.ini")
|
||||
@@ -99,60 +124,37 @@ def server_bind():
|
||||
}
|
||||
|
||||
|
||||
def _api_defaults_from_config():
|
||||
"""从 [api] 读取 page_size、recent_days 默认值(INI 见 config*.ini)。"""
|
||||
page_size = _get_int("api", "page_size", 500)
|
||||
recent_days = _get_int("api", "recent_days", 30)
|
||||
if page_size < 1:
|
||||
page_size = 1
|
||||
if page_size > 5000:
|
||||
page_size = 5000
|
||||
if recent_days < 1:
|
||||
recent_days = 1
|
||||
if recent_days > 3660:
|
||||
recent_days = 3660
|
||||
return page_size, recent_days
|
||||
def _default_filter_year():
|
||||
"""配置文件 default_year;未配置或无效时使用当前自然年。"""
|
||||
raw = _get("api", "default_year", "")
|
||||
if raw:
|
||||
try:
|
||||
year = int(raw)
|
||||
if 2000 <= year <= 2100:
|
||||
return year
|
||||
except ValueError:
|
||||
pass
|
||||
return date.today().year
|
||||
|
||||
|
||||
def _clamp_int(value, lo, hi):
|
||||
v = int(value)
|
||||
if v < lo:
|
||||
return lo
|
||||
if v > hi:
|
||||
return hi
|
||||
return v
|
||||
|
||||
|
||||
def _resolve_query_paging():
|
||||
def _resolve_filter_year():
|
||||
"""
|
||||
合并 URL 参数与配置文件:
|
||||
- page_size:Query `page_size`,未传则用 [api] page_size
|
||||
- recent_days:Query `recent_days` 或 `days`(别名),未传则用 [api] recent_days
|
||||
范围:page_size 1~5000,recent_days 1~3660
|
||||
- year:Query `year`,未传则用 [api] default_year 或当前自然年
|
||||
"""
|
||||
cfg_ps, cfg_rd = _api_defaults_from_config()
|
||||
|
||||
raw_ps = request.args.get("page_size")
|
||||
if raw_ps is not None and str(raw_ps).strip() != "":
|
||||
default_year = _default_filter_year()
|
||||
raw_year = request.args.get("year")
|
||||
if raw_year is not None and str(raw_year).strip() != "":
|
||||
try:
|
||||
page_size = _clamp_int(raw_ps, 1, 5000)
|
||||
year = int(raw_year)
|
||||
except (ValueError, TypeError):
|
||||
return None, None, "page_size 必须为整数"
|
||||
return None, "year 必须为整数"
|
||||
if year < 2000 or year > 2100:
|
||||
return None, "year 有效范围为 2000~2100"
|
||||
else:
|
||||
page_size = cfg_ps
|
||||
year = default_year
|
||||
|
||||
raw_rd = request.args.get("recent_days")
|
||||
if raw_rd is None or str(raw_rd).strip() == "":
|
||||
raw_rd = request.args.get("days")
|
||||
if raw_rd is not None and str(raw_rd).strip() != "":
|
||||
try:
|
||||
recent_days = _clamp_int(raw_rd, 1, 3660)
|
||||
except (ValueError, TypeError):
|
||||
return None, None, "recent_days / days 必须为整数"
|
||||
else:
|
||||
recent_days = cfg_rd
|
||||
|
||||
return page_size, recent_days, None
|
||||
return year, None
|
||||
|
||||
|
||||
def _check_api_key():
|
||||
@@ -178,8 +180,8 @@ def _serialize_value(value):
|
||||
return value
|
||||
|
||||
|
||||
def _row_to_dict(row):
|
||||
return {k: _serialize_value(v) for k, v in row.items()}
|
||||
def _row_to_export_dict(row):
|
||||
return {col: _serialize_value(row.get(col)) for col in EXPORT_COLUMNS}
|
||||
|
||||
|
||||
def _db_connect():
|
||||
@@ -207,41 +209,26 @@ def list_student_changes():
|
||||
if not _check_api_key():
|
||||
return jsonify({"code": 401, "msg": "无效的密钥或未提供请求头 " + HEADER_API_KEY}), 401
|
||||
|
||||
try:
|
||||
page = int(request.args.get("page", "1"))
|
||||
except ValueError:
|
||||
return jsonify({"code": 400, "msg": "page 必须为整数"}), 400
|
||||
|
||||
if page < 1:
|
||||
page = 1
|
||||
|
||||
page_size, recent_days, param_err = _resolve_query_paging()
|
||||
filter_year, param_err = _resolve_filter_year()
|
||||
if param_err:
|
||||
return jsonify({"code": 400, "msg": param_err}), 400
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 近 recent_days 天:按视图中的「提交时间」筛选(登记/提交日期)
|
||||
where_recent = (
|
||||
"WHERE `{col}` >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)".format(
|
||||
col=COL_SUBMIT_DATE, days=recent_days
|
||||
select_cols = ", ".join("`%s`" % col for col in EXPORT_COLUMNS)
|
||||
where_year = (
|
||||
"WHERE `{col}` IS NOT NULL AND YEAR(`{col}`) = %s".format(
|
||||
col=COL_EFFECTIVE_TIME
|
||||
)
|
||||
)
|
||||
sql_count = "SELECT COUNT(*) AS cnt FROM `{view}` {where_clause}".format(
|
||||
view=VIEW_NAME, where_clause=where_recent
|
||||
)
|
||||
sql_list = (
|
||||
"SELECT * FROM `{view}` {where_clause} "
|
||||
"ORDER BY `来源记录id` ASC LIMIT %s OFFSET %s"
|
||||
).format(view=VIEW_NAME, where_clause=where_recent)
|
||||
"SELECT {cols} FROM `{view}` {where_clause} "
|
||||
"ORDER BY `来源记录id` ASC"
|
||||
).format(cols=select_cols, view=VIEW_NAME, where_clause=where_year)
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = _db_connect()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql_count)
|
||||
total = int(cur.fetchone()["cnt"])
|
||||
cur.execute(sql_list, (page_size, offset))
|
||||
cur.execute(sql_list, (filter_year,))
|
||||
rows = cur.fetchall()
|
||||
except pymysql.Error as e:
|
||||
return jsonify({"code": 500, "msg": "数据库查询失败", "detail": str(e)}), 500
|
||||
@@ -249,17 +236,15 @@ def list_student_changes():
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
data = [_row_to_dict(r) for r in rows]
|
||||
data = [_row_to_export_dict(r) for r in rows]
|
||||
return jsonify(
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"filter_recent_days": recent_days,
|
||||
"filter_by_column": COL_SUBMIT_DATE,
|
||||
"total": len(data),
|
||||
"filter_year": filter_year,
|
||||
"filter_by_column": COL_EFFECTIVE_TIME,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -18,5 +18,4 @@ database = hzdyjs_xg
|
||||
api_key = JWCX001
|
||||
bind_host = 0.0.0.0
|
||||
bind_port = 5050
|
||||
page_size = 500
|
||||
recent_days = 30
|
||||
# default_year =
|
||||
@@ -17,7 +17,5 @@ database = hzdyjs_xg
|
||||
api_key = JWCX001
|
||||
bind_host = 0.0.0.0
|
||||
bind_port = 5050
|
||||
# 列表接口:默认每页条数;请求可带 page_size 覆盖,范围 1~5000
|
||||
page_size = 500
|
||||
# 列表接口:默认近 N 天;请求可带 recent_days 或 days 覆盖,范围 1~3660
|
||||
recent_days = 30
|
||||
# 列表接口:默认筛选年份(异动生效时间);请求可带 year 覆盖;留空则使用当前自然年
|
||||
# default_year =
|
||||
@@ -19,5 +19,4 @@ database = hzdyjs_xg
|
||||
api_key = JWCX001
|
||||
bind_host = 0.0.0.0
|
||||
bind_port = 5050
|
||||
page_size = 500
|
||||
recent_days = 30
|
||||
# default_year =
|
||||
@@ -0,0 +1,38 @@
|
||||
# 学工系统 → 教务系统 异动类型映射
|
||||
|
||||
依据教务《异动类型管理(附件1)》与本系统实际业务数据整理。
|
||||
|
||||
## 本系统产生的异动类型
|
||||
|
||||
| 本库来源 | 本库内部编码 / 标识 | 本库业务含义 | 教务系统异动类型名称 | 说明 |
|
||||
|----------|---------------------|--------------|----------------------|------|
|
||||
| `xg_student_status_change.change_type` | `TUI_XUE` | 退学 | **退学** | 名称一致 |
|
||||
| `xg_student_status_change.change_type` | `XIU_XUE` | 休学 | **休学** | 名称一致 |
|
||||
| `xg_student_status_change.change_type` | `FU_XUE` | 复学 | **复学** | 名称一致 |
|
||||
| `xg_student_class_change`(已处理) | — | 换班级 | **转班级** | 非 `change_type` 字段,视图层固定输出 |
|
||||
|
||||
## 本系统有、但不同步给教务的类型
|
||||
|
||||
| 本库来源 | 内部编码 | 本库业务含义 | 说明 |
|
||||
|----------|----------|--------------|------|
|
||||
| `xg_student_status_change.change_type` | `BI_YE` | 毕业 | **不传给教务**(按对接约定排除) |
|
||||
|
||||
## 教务码表有、本系统暂不产生的类型
|
||||
|
||||
以下类型在教务附件中存在,**本学工系统当前无对应业务流水**,接口不会返回:
|
||||
|
||||
实习、转录失败+实习、肄业、提前毕业、死亡、开除学籍、转系、转专业、转学(转出)、保留学籍、取消学籍、恢复学籍、取消入学资格、恢复入学资格、保留入学资格、停学、跳级、降级、留级、改名
|
||||
|
||||
## 接口约定
|
||||
|
||||
- 对外字段名:**异动类型**
|
||||
- 字段值:上表「教务系统异动类型名称」列的中文名称(非本库内部编码)
|
||||
- **异动生效学期**、**异动前/后所在校区**:本系统无数据,固定返回 `null`
|
||||
- **新名字**:本系统无改名流水,固定返回 `null`
|
||||
- **审核状态**:固定返回「已通过」
|
||||
|
||||
## 视图与 API
|
||||
|
||||
- 视图 DDL:`docs/设计文档/database/vw_jw_student_change_export.sql`
|
||||
- 只读 API:`跟教务系统同步学生异动信息/api_service/app.py`
|
||||
- 数据范围:按 **`异动生效时间` 所在自然年** 筛选(Query `year`,默认当前年);**一次返回该年全部记录,不分页**
|
||||
Reference in new issue
Block a user