1 Commits
main ... main

Author SHA1 Message Date
5020bd1532 feat: Hair Keeper v1.1.0 版本更新
本次更新包含以下主要改进:

## 新功能
- 添加quickstart.sh脚本帮助用户快速使用模板项目
- 添加simple_deploy.sh便于部署
- 新增院系管理功能(DeptAdmin),支持增删改查院系管理员信息
- 用户可以在header中切换管理的院系
- 添加zustand全局状态管理
- 添加DEFAULT_USER_PASSWORD环境变量,作为创建用户时的默认密码
- 添加p-limit库和DB_PARALLEL_LIMIT环境变量控制数据库批次操作并发数

## 安全修复
- 修复Next.js CVE-2025-66478漏洞
- 限制只有超级管理员才能创建超级管理员用户

## 开发环境优化
- 开发终端兼容云端环境
- MinIO客户端直传兼容云端环境
- 开发容器增加vim和Claude Code插件
- 编程代理改用Claude
- docker-compose.yml添加全局name属性

## Bug修复与代码优化
- 删除用户时级联删除SelectionLog
- 手机端关闭侧边栏后刷新页面延迟调整(300ms=>350ms)
- instrumentation.ts移至src内部以适配生产环境
- 删除部分引发类型错误的无用代码
- 优化quickstart.sh远程仓库推送相关配置

## 文件变更
- 新增49个文件,修改多个配置和源代码文件
- 重构用户管理模块目录结构

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-23 16:58:55 +08:00
34 changed files with 454 additions and 2067 deletions

View File

@@ -1,85 +1,12 @@
{
"permissions": {
"allow": [
"Read(./.env.example)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(less:*)",
"Bash(wc:*)",
"Bash(ls:*)",
"Bash(tree:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(fd:*)",
"Bash(file:*)",
"Bash(stat:*)",
"Bash(du:*)",
"Bash(diff:*)",
"Bash(which:*)",
"Bash(whoami:*)",
"Bash(echo:*)",
"Bash(pwd:*)",
"Bash(date:*)",
"Bash(uname:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git branch:*)",
"Bash(git show:*)",
"Bash(git remote:*)",
"Bash(git tag:*)",
"Bash(git stash list:*)",
"Bash(git blame:*)",
"Bash(git shortlog:*)",
"Bash(git rev-parse:*)",
"Task",
"Edit",
"Write",
"MultiEdit",
"Bash(pnpm install:*)",
"Bash(pnpm add:*)",
"Bash(pnpm run build:*)",
"Bash(pnpm prisma generate:*)",
"Bash(pnpm run lint:*)",
"Bash(pip install:*)",
"Bash(pip3 install:*)",
"Bash(mkdir:*)",
"Bash(cp:*)",
"Bash(mv:*)",
"Bash(rm:*)",
"Bash(touch:*)",
"Bash(chmod:*)",
"Bash(chown:*)",
"Bash(ln:*)",
"Bash(tar:*)",
"Bash(unzip:*)",
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(sed:*)",
"Bash(awk:*)",
"Bash(sort:*)",
"Bash(uniq:*)",
"Bash(cut:*)",
"Bash(xargs:*)",
"Bash(tee:*)",
"Bash(jq:*)",
"mcp__ide__getDiagnostics"
"Read(./.env.example)"
],
"deny": [
"Read(./.env)",
"Read(./.env.development)",
"Read(./.env.production)",
"Read(./.git/config)"
"Read(./.env.production)"
]
}
}

View File

@@ -1,415 +0,0 @@
---
name: pku-iaaa
description: |
Integrate Peking University (PKU) IAAA unified authentication system into web applications.
Use this skill whenever the user wants to implement PKU IAAA SSO login, PKU unified identity
authentication, or connect their app to iaaa.pku.edu.cn. Also trigger when the user mentions
"北大统一认证", "IAAA认证", "北大SSO", "PKU login", or wants to add campus authentication
for a Peking University application. Covers login redirect, token validation, proxy SSO,
and account verification — for any web framework (Django, Spring Boot, Express.js, Flask, etc).
---
# 北京大学 IAAA 统一身份认证集成
本 skill 指导你将北京大学 IAAA统一安全系统身份认证集成到任意 Web 应用中。
## 前置条件
集成前需在 IAAA 注册应用系统,准备以下信息:
- **应用系统 ID**`appId`):英文字母开头,可含数字
- **Key**:由 IAAA 提供,用于生成消息摘要
- **服务器 IP 地址**:应用系统所在服务器的 IP
- **回调地址 URL**`redirectUrl`):用于接收 token 的回调端点
申请方式:管理部门用户登录校内门户 → "信息服务"或"办事大厅" → 搜索"统一身份认证应用备案申请"。
注册后可在 `https://i3a.pku.edu.cn/iaaa/index.jsp` 自行管理服务器 IP 和回调 URL。
## 开始集成前,先确认需求
在动手写代码之前,先向用户确认:
1. **使用什么框架?** Django / Spring Boot / Express.js / Flask / 其他)
2. **需要哪种级别的集成?**
- 基本认证(登录跳转 + Token 验证)— 绝大多数场景只需这个
- 代理 SSO中心应用 + 分支应用架构)— 参见 `references/proxy-sso.md`
- 账号验证(仅校验某个学号/职工号是否存在)— 参见 `references/account-verify.md`
3. **系统是否还有非校内账号?** 如果是,需要同时保留自有登录入口(影响前端跳转参数)
## 认证流程概览
```
用户点击登录 ──POST──> iaaa.pku.edu.cn/iaaa/oauth.jsp
用户输入账号密码认证
认证成功,生成 token
302 重定向到 redirectUrl?token=xxx
应用服务端收到 token
服务端调用 validate.do 验证 token
验证成功获取用户信息identityId 等)
匹配本地用户,创建会话
```
## 第一步:登录跳转(前端)
用户点击登录时,通过 POST 表单跳转到 IAAA 统一登录页面。
### 关键参数
| 参数 | 说明 |
|------|------|
| `appID` | 注册的应用系统 ID |
| `redirectUrl` | 回调地址(验证 token 的端点),若含参数需 URLEncode |
| `redirectLogonUrl` | (可选)应用自有登录地址,当系统还有非校内账号时使用 |
### 实现要点
- 必须使用 POST 方法提交表单到 `https://iaaa.pku.edu.cn/iaaa/oauth.jsp`
- `redirectUrl` 的协议必须与实际部署一致(从当前请求动态获取,不要硬编码 `http://`
- 回调 URL 的尾部斜杠很重要——IAAA 可能因缺少尾部斜杠而拒绝回调
### 参考实现JavaScript 前端通用)
```javascript
function redirectToIAAALogin(appID, callbackPath, loginPath) {
const form = document.createElement('form');
form.action = 'https://iaaa.pku.edu.cn/iaaa/oauth.jsp';
form.method = 'POST';
form.style.display = 'none';
function addField(name, value) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
}
// 使用当前页面协议,不要硬编码 http://
const baseUrl = `${location.protocol}//${location.host}`;
addField('appID', appID);
addField('redirectUrl', `${baseUrl}${callbackPath}`);
if (loginPath) {
addField('redirectLogonUrl', `${baseUrl}${loginPath}`);
}
document.body.appendChild(form);
form.submit();
}
```
## 第二步Token 验证(服务端)
IAAA 认证成功后,会重定向浏览器到 `redirectUrl?token=<TOKEN>`。服务端需要验证这个 token。
### 验证端点
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do
?remoteAddr={REMOTE_ADDR}
&appId={APP_ID}
&token={TOKEN}
&msgAbs={MSG_ABS}
```
如果不需要返回用户详细信息(只需 identityId可使用简单验证端点
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validateSimple.do?...(参数相同)
```
### 参数说明
| 参数 | 说明 |
|------|------|
| `remoteAddr` | 访问应用系统的用户**真实 IP 地址** |
| `appId` | 应用系统 ID |
| `token` | IAAA 传回的票据 |
| `msgAbs` | MD5 消息摘要(见下方计算方法) |
### msgAbs 计算方法(极其重要)
1. 取除 `msgAbs` 以外的所有请求参数
2. 按**参数名升序**排列
3. 拼接为查询字符串格式:`appId={APP_ID}&remoteAddr={REMOTE_ADDR}&token={TOKEN}`
4. 将此字符串与 Key 直接拼接(不加分隔符):`PARA_STR + Key`
5. 对拼接结果计算 MD5UTF-8 编码32 位十六进制,大小写不敏感)
**Python 示例:**
```python
import hashlib
para_str = f"appId={app_id}&remoteAddr={remote_addr}&token={token}"
raw = para_str + key
msg_abs = hashlib.md5(raw.encode('utf-8')).hexdigest()
```
**Java 示例:**
```java
String paraStr = "appId=" + appId + "&remoteAddr=" + remoteAddr + "&token=" + token;
String raw = paraStr + key;
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8));
String msgAbs = String.format("%032x", new BigInteger(1, digest));
```
**Node.js 示例:**
```javascript
const crypto = require('crypto');
const paraStr = `appId=${appId}&remoteAddr=${remoteAddr}&token=${token}`;
const msgAbs = crypto.createHash('md5').update(paraStr + key).digest('hex');
```
### 获取客户端真实 IP
反向代理Nginx 等)环境下,客户端 IP 通常在 `X-Forwarded-For` 头中。取第一个值(最左侧)作为真实 IP
```python
# Python / Django
remote_addr = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() \
or request.META.get('REMOTE_ADDR', '')
```
```java
// Java / Spring Boot
String remoteAddr = request.getHeader("X-Forwarded-For");
if (remoteAddr != null && !remoteAddr.isEmpty()) {
remoteAddr = remoteAddr.split(",")[0].trim();
} else {
remoteAddr = request.getRemoteAddr();
}
```
```javascript
// Node.js / Express
const remoteAddr = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket.remoteAddress;
```
### 返回结果
**成功:**
```json
{
"success": true,
"errCode": "0",
"errMsg": "认证成功",
"userInfo": {
"name": "用户姓名",
"status": "账号状态(开通/禁止)",
"identityId": "身份账号(学号/职工号)",
"deptId": "所在院系码",
"dept": "所在院系",
"identityType": "身份类别(学生/职工)",
"detailType": "身份细类",
"identityStatus": "身份状态(在校/减离)",
"campus": "校区"
}
}
```
**失败:**
```json
{
"success": false,
"errCode": "错误码",
"errMsg": "错误信息"
}
```
简单验证端点(`validateSimple.do`)成功时 `userInfo` 中只包含 `identityId`
### 服务端验证的完整参考实现
#### Django
```python
import hashlib
import logging
import requests
from django.contrib.auth import login, get_user_model
from django.http import HttpResponseRedirect
from django.shortcuts import render, reverse
logger = logging.getLogger(__name__)
User = get_user_model()
IAAA_APP_ID = "your_app_id"
IAAA_KEY = "your_key"
IAAA_USERID_FIELD = "username" # User 模型中与 identityId 对应的字段
def iaaa_callback(request):
"""IAAA 认证回调视图"""
token = request.GET.get('token')
if not token:
return render(request, 'login_failed.html', {'msg': '未收到认证票据'})
# 获取客户端真实 IP
remote_addr = (
request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip()
or request.META.get('REMOTE_ADDR', '')
)
# 计算消息摘要(参数按字母序排列)
para_str = f"appId={IAAA_APP_ID}&remoteAddr={remote_addr}&token={token}"
msg_abs = hashlib.md5((para_str + IAAA_KEY).encode('utf-8')).hexdigest()
# 调用 IAAA 验证服务
validate_url = (
f"https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do"
f"?{para_str}&msgAbs={msg_abs}"
)
try:
resp = requests.get(validate_url, timeout=10)
resp.raise_for_status()
result = resp.json()
except Exception as e:
logger.error(f"IAAA token 验证请求失败: {e}")
return render(request, 'login_failed.html', {'msg': '身份认证服务暂不可用,请稍后重试'})
if not result.get('success'):
err_code = result.get('errCode', '未知')
err_msg = result.get('errMsg', '未知错误')
logger.warning(f"IAAA 认证失败: errCode={err_code}, errMsg={err_msg}")
return render(request, 'login_failed.html', {'msg': f'认证失败:{err_msg}'})
# 认证成功,提取用户身份
user_info = result.get('userInfo', {})
identity_id = user_info.get('identityId', '')
if not identity_id:
logger.warning(f"IAAA 认证成功但未返回 identityId: {result}")
return render(request, 'login_failed.html', {'msg': '无法获取您的身份账号'})
# 匹配本地用户
user = User.objects.filter(**{IAAA_USERID_FIELD: identity_id}).first()
if user is None:
logger.info(f"IAAA 用户 {identity_id} 在系统中不存在")
return render(request, 'login_failed.html', {'msg': '系统中不存在与您北大账号对应的用户'})
# 登录
login(request, user)
logger.info(f"IAAA 登录成功: {identity_id} ({user_info.get('name', '')})")
return HttpResponseRedirect(reverse('index'))
```
#### Spring Boot
```java
@GetMapping("/iaaa/callback")
public String iaaaCallback(
@RequestParam String token,
HttpServletRequest request,
HttpSession session) throws Exception {
// 获取客户端真实 IP
String remoteAddr = request.getHeader("X-Forwarded-For");
if (remoteAddr != null && !remoteAddr.isEmpty()) {
remoteAddr = remoteAddr.split(",")[0].trim();
} else {
remoteAddr = request.getRemoteAddr();
}
// 计算消息摘要
String paraStr = "appId=" + appId + "&remoteAddr=" + remoteAddr + "&token=" + token;
String msgAbs = md5(paraStr + key);
// 调用验证服务
String url = "https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do?"
+ paraStr + "&msgAbs=" + msgAbs;
RestTemplate rest = new RestTemplate();
Map<String, Object> result = rest.getForObject(url, Map.class);
if (!(Boolean) result.get("success")) {
String errMsg = (String) result.getOrDefault("errMsg", "认证失败");
// 处理失败...
return "redirect:/login?error=" + URLEncoder.encode(errMsg, "UTF-8");
}
Map<String, String> userInfo = (Map<String, String>) result.get("userInfo");
String identityId = userInfo.get("identityId");
// 查找本地用户,创建会话...
return "redirect:/";
}
private String md5(String input) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
return String.format("%032x", new java.math.BigInteger(1, digest));
}
```
#### Express.js
```javascript
const crypto = require('crypto');
const axios = require('axios');
app.get('/iaaa/callback', async (req, res) => {
const { token } = req.query;
if (!token) return res.redirect('/login?error=no_token');
const remoteAddr = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket.remoteAddress;
const paraStr = `appId=${APP_ID}&remoteAddr=${remoteAddr}&token=${token}`;
const msgAbs = crypto.createHash('md5').update(paraStr + KEY).digest('hex');
const url = `https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do?${paraStr}&msgAbs=${msgAbs}`;
try {
const { data } = await axios.get(url);
if (!data.success) {
console.warn(`IAAA 认证失败: ${data.errCode} - ${data.errMsg}`);
return res.redirect(`/login?error=${encodeURIComponent(data.errMsg || '认证失败')}`);
}
const { identityId, name } = data.userInfo;
if (!identityId) {
return res.redirect('/login?error=no_identity');
}
// 查找本地用户,创建 session...
const user = await User.findOne({ where: { username: identityId } });
if (!user) {
return res.redirect('/login?error=user_not_found');
}
req.session.userId = user.id;
console.log(`IAAA 登录成功: ${identityId} (${name})`);
res.redirect('/');
} catch (err) {
console.error('IAAA 验证请求失败:', err.message);
res.redirect('/login?error=service_unavailable');
}
});
```
## 常见易错点
实现时特别注意以下几点,这些是实际集成中最常见的 bug 来源:
1. **协议硬编码**`redirectUrl` 中不要写死 `http://`,应从当前请求或配置动态获取。生产环境通常是 HTTPS协议不匹配会导致回调失败。
2. **msgAbs 参数排序**:参数必须严格按参数名字母升序拼接。当前 `validate.do` 只有三个参数(`appId`, `remoteAddr`, `token`),恰好已是字母序。但如果使用代理认证等其他端点(参数更多),一定要排序。
3. **错误响应处理**IAAA 返回 `success: false` 时,务必提取 `errCode``errMsg` 记录到日志并展示给用户,不要只返回一个笼统的"认证失败"。
4. **客户端 IP**`remoteAddr` 必须是用户的真实 IP。如果应用在反向代理之后使用 `X-Forwarded-For` 头的第一个值。IP 不匹配会导致 token 验证失败。
5. **回调 URL 尾部斜杠**IAAA 在注册时绑定了精确的回调 URL运行时传入的 `redirectUrl` 必须完全匹配(包括尾部斜杠)。
6. **服务端可达性**:应用服务器必须能访问 `iaaa.pku.edu.cn` 的 443 端口HTTPS
## 高级功能
对于需要代理 SSO中心应用 + 分支应用)或账号验证的场景,参阅:
- `references/proxy-sso.md` — 代理身份认证(单点登录多个应用)
- `references/account-verify.md` — 验证北京大学账号是否存在

View File

@@ -1,78 +0,0 @@
# 北京大学账号验证
用于验证某个学号/职工号是否为有效的北京大学账号,不涉及登录流程。
## API
```
GET https://iaaa.pku.edu.cn/iaaa/svc/pub/validate.do
?userId={USERID}
&userName={USERNAME}
&appId={APPID}
&msgAbs={MD5MSG}
```
## 参数
| 参数 | 说明 |
|------|------|
| `userId` | 学号或职工号 |
| `userName` | 账号姓名(传输时需 URLEncode但计算 MD5 摘要时使用原始值,不做 URLEncode |
| `appId` | 应用 ID |
| `msgAbs` | MD5 消息摘要 |
## msgAbs 计算
与其他端点规则一致:除 `msgAbs` 外所有参数按参数名升序排列拼接,再拼接 Key取 MD5。
PARA_STR 排序后为:`appId={APPID}&userId={USERID}&userName={USERNAME}`
注意:`userName` 在 PARA_STR 中使用**原始值**(不做 URLEncode只有在 URL 传输时才 URLEncode。
## 返回
**正常:**
```json
{
"success": true,
"valid": true,
"userType": "用户身份类别"
}
```
`valid``true` 表示账号存在,`false` 表示不存在。
**异常:**
```json
{
"success": false,
"errCode": "错误代码",
"errMsg": "错误信息"
}
```
## Python 示例
```python
import hashlib
import urllib.parse
import requests
def verify_pku_account(user_id: str, user_name: str, app_id: str, key: str) -> dict:
"""验证北大账号是否存在"""
# msgAbs 中 userName 使用原始值
para_str = f"appId={app_id}&userId={user_id}&userName={user_name}"
msg_abs = hashlib.md5((para_str + key).encode('utf-8')).hexdigest()
# URL 中 userName 需要 URLEncode
url = (
f"https://iaaa.pku.edu.cn/iaaa/svc/pub/validate.do"
f"?appId={app_id}"
f"&userId={user_id}"
f"&userName={urllib.parse.quote(user_name)}"
f"&msgAbs={msg_abs}"
)
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()
```

View File

@@ -1,120 +0,0 @@
# 代理身份认证Proxy SSO
适用场景:应用 O中心应用登录后用户点击进入应用 A、B、C分支应用时无需重新登录。
## 流程
```
用户登录中心应用 O
├─ O 使用 validateWithProxy.do 验证 token → 获得 grantToken
├─ 用户点击分支应用 A
│ ├─ O 调用 validateProxyGrantToken.do → 获得分支应用 A 的 token
│ └─ 将 token 传给分支应用 A → A 用 validate.do 验证
└─ 用户退出中心应用 O
└─ O 调用 expireProxy.do 注销 grantToken
```
## API 详情
### 1. 中心应用验证(获取 grantToken
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validateWithProxy.do
?remoteAddr={REMOTE_ADDR}
&appId={APP_ID}
&token={TOKEN}
&msgAbs={MSG_ABS}
```
参数和 msgAbs 计算方式与标准 `validate.do` 相同。
**成功返回:**
```json
{
"success": true,
"errCode": "0",
"errMsg": "认证成功",
"userInfo": {
"name": "用户姓名",
"status": "账号状态",
"identityId": "身份账号",
"deptId": "所在院系码",
"dept": "所在院系",
"identityType": "身份类别",
"detailType": "身份细类",
"identityStatus": "身份状态"
},
"grantToken": "代理票据"
}
```
### 2. 获取分支应用 Token
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/validateProxyGrantToken.do
?remoteAddr={REMOTE_ADDR}
&appId={APP_ID}
&targetAppId={TARGET_APP_ID}
&grantToken={GRANT_TOKEN}
&timestamp={TIMESTAMP}
&msgAbs={MSG_ABS}
```
**参数:**
| 参数 | 说明 |
|------|------|
| `remoteAddr` | 客户端 IP |
| `appId` | 中心应用 ID |
| `targetAppId` | 分支应用 ID |
| `grantToken` | 上一步获得的代理票据 |
| `timestamp` | 当前时间戳long 型整数) |
| `msgAbs` | MD5 摘要PARA_STR 为除 msgAbs 外所有参数按参数名升序拼接 |
注意此端点参数较多PARA_STR 排序后为:
`appId={}&grantToken={}&remoteAddr={}&targetAppId={}&timestamp={}`
**成功返回:**
```json
{
"success": true,
"errCode": "0",
"errMsg": "验证成功",
"token": "分支应用访问票据"
}
```
**错误码 12需要手机验证** 重定向用户到:
```
https://iaaa.pku.edu.cn/iaaa/ma4proxy.jsp
?proxyAppId={中心应用ID}
&appId={分支应用ID}
&userId={用户账号}
&grantToken={GRANT_TOKEN}
&redirectUrl={分支应用回调地址需URLEncode}
```
### 3. 分支应用验证 Token
分支应用收到 token 后,使用标准 `validate.do``validateSimple.do` 验证,与普通认证流程相同。
### 4. 注销代理 Token
中心应用退出时或必要时调用:
```
GET https://iaaa.pku.edu.cn/iaaa/svc/token/expireProxy.do
?appId={APP_ID}
&grantToken={GRANT_TOKEN}
&msgAbs={MSG_ABS}
```
msgAbs 的 PARA_STR`appId={}&grantToken={}`(按参数名升序)
**返回:**
```json
{"success": true, "errCode": "0", "errMsg": "注销成功"}
```

View File

@@ -45,13 +45,10 @@ RUN apt-get update && apt-get install -y \
telnet \
redis-tools \
iputils-ping \
dnsutils \
potrace \
imagemagick \
zsh \
vim \
rsync \
openjdk-17-jdk \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 3.12
@@ -98,12 +95,8 @@ RUN curl -fsSL https://code-server.dev/install.sh | sh -s -- --version=${CODE_SE
# 安装 npm 全局包
RUN npm install -g \
@musistudio/claude-code-router \
pm2
# 安装 claude code 编程代理
RUN curl -fsSL https://claude.ai/install.sh | bash && \
echo 'eval "$(ccr activate)"' >> /root/.zshrc
@anthropic-ai/claude-code \
@musistudio/claude-code-router
# 创建工作目录
RUN mkdir -p /workspace /root/.local/share/code-server/User
@@ -120,7 +113,7 @@ RUN code-server --install-extension ms-ceintl.vscode-language-pack-zh-hans \
&& code-server --install-extension dbaeumer.vscode-eslint \
&& code-server --install-extension prisma.prisma \
&& code-server --install-extension ecmel.vscode-html-css \
&& code-server --install-extension cweijan.vscode-database-client2 \
&& code-server --install-extension cweijan.vscode-redis-client \
&& code-server --install-extension anthropic.claude-code
# 配置 code-server (密码将在启动时设置)

View File

@@ -23,8 +23,6 @@ services:
- claude-code-router:/root/.claude-code-router
# SSH 配置
- ssh:/root/.ssh
# 零碎文件持久化
- persist:/root/.hair-keeper-persist
environment:
- NODE_ENV=development
- TZ=Asia/Shanghai
@@ -42,12 +40,6 @@ services:
memory: 4G
volumes:
code-server-config:
code-server-data:
claude:
claude-code-router:
ssh:
persist:
# node_modules 卷,避免主机和容器之间的文件系统差异
node_modules:
# pnpm store 卷,加速依赖安装

View File

@@ -1,40 +1,5 @@
#!/bin/bash
# ============================================
# 文件持久化:启动时恢复 + 后台定期同步到命名卷
# ============================================
PERSIST_DIR="/root/.hair-keeper-persist"
PERSIST_FILES=(
"/root/.claude.json"
"/root/.zsh_history"
"/root/.bash_history"
)
mkdir -p "$PERSIST_DIR"
# 启动时恢复:持久化目录中的文件优先覆盖到原路径
for filepath in "${PERSIST_FILES[@]}"; do
filename=$(basename "$filepath")
persist_path="$PERSIST_DIR/$filename"
if [ -f "$persist_path" ] && [ -s "$persist_path" ]; then
cp "$persist_path" "$filepath"
fi
done
# 后台定期同步将原路径文件同步回持久化目录每60秒
(
while true; do
sleep 60
for filepath in "${PERSIST_FILES[@]}"; do
filename=$(basename "$filepath")
persist_path="$PERSIST_DIR/$filename"
if [ -f "$filepath" ]; then
rsync -a "$filepath" "$persist_path"
fi
done
done
) &
# 设置默认密码
DEV_PASSWORD=${DEV_PASSWORD:-clouddev}
@@ -77,20 +42,5 @@ echo ""
echo "提示: 可通过环境变量 DEV_PASSWORD 自定义密码"
echo ""
# 容器停止时执行最终同步
cleanup() {
echo "Syncing persistent files before shutdown..."
for filepath in "${PERSIST_FILES[@]}"; do
filename=$(basename "$filepath")
persist_path="$PERSIST_DIR/$filename"
if [ -f "$filepath" ]; then
rsync -a "$filepath" "$persist_path"
fi
done
exit 0
}
trap cleanup SIGTERM SIGINT
# 保持容器运行
tail -f /dev/null &
wait $!
tail -f /dev/null

View File

@@ -51,14 +51,6 @@ NEXTAUTH_SECRET=
PKUAI_API_KEY=
PKUAI_API_BASE=
# 北京大学 IAAA 统一认证(可选,不配置则不启用)
## 在 IAAA 注册的应用系统 ID
IAAA_APP_ID=
## IAAA 提供的密钥,用于生成消息摘要
IAAA_KEY=
## 设为 true 则进入登录页后自动跳转到统一认证(默认 false
IAAA_AUTO_REDIRECT=

4
.gitignore vendored
View File

@@ -15,7 +15,6 @@
# next.js
/.next/
/.next-prod/
/out/
# production
@@ -52,6 +51,3 @@ tasks.md
# lock 一般的项目需要用git管理但这个是模板项目就不管理
package-lock.json
pnpm-lock.yaml
# claude
.claude/*.local.*

View File

@@ -3,7 +3,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目说明
本项目模板Hair Keeper v1.3.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证支持SSO单点登录、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
本项目模板Hair Keeper v1.1.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
@@ -50,45 +50,43 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
## 重要目录和文件
### 前端
- `src/components/common/`进一步封装的高级通用控件例如下拉菜单、对话框表单、响应式tabs等控件
- `src/components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
- `src/components/ai-elements/`ai对话相关的组件
- `src/components/data-details/`专注于数据展示的可复用控件例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
- `src/components/data-table/`专注于数据表格的可复用控件本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
- `src/components/icons/`:项目的自定义图标可以写在这个文件夹
- `src/components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
- `src/components/ui/`高度可定制可复用的基础UI组件通常源自第三方库
- `src/app/(main)/`:开发者在这里自行实现的所有业务的页面
- `src/app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
- `src/app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
- `src/app/(main)/users/`用户管理模块提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
- `src/hooks/`可复用React Hooks库部分复杂的组件也通过hook实现Headless UI逻辑与样式分离组件中可复用的逻辑都可以放在这
- `src/lib/trpc.ts`创建并导出tRPC React客户端实例用于前端与后端API通信
- `src/lib/stores/`通过zustand管理的全局的状态
- `components/common/`进一步封装的高级通用控件例如下拉菜单、对话框表单、响应式tabs等控件
- `components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
- `components/ai-elements/`ai对话相关的组件
- `components/data-details/`专注于数据展示的可复用控件例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
- `components/data-table/`专注于数据表格的可复用控件本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
- `components/icons/`:项目的自定义图标可以写在这个文件夹
- `components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
- `components/ui/`高度可定制可复用的基础UI组件通常源自第三方库
- `app/(main)/`:开发者在这里自行实现的所有业务的页面
- `app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
- `app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
- `app/(main)/users/`用户管理模块提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
- `hooks/`可复用React Hooks库部分复杂的组件也通过hook实现Headless UI逻辑与样式分离组件中可复用的逻辑都可以放在这
- `lib/trpc.ts`创建并导出tRPC React客户端实例用于前端与后端API通信
- `lib/stores/`通过zustand管理的全局的状态
### 后端
- `src/server/routers/`项目trpc api定义文件开发者主要在这里定义和实现业务的后端API
- `src/server/routers/_app.ts``appRouter`根路由定义,需要添加子路由时在此处注册
- `src/server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
- `src/server/routers/jobs.ts`tRPC任务进度订阅路由
- `src/server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
- `src/server/routers/global.ts`系统全局和特定业务关联不大的一些api
- `src/server/routers/dev/`开发模式下的辅助功能需要的trpc api
- `src/server/queues/`消息队列和worker通过其中的index.ts统一导出任务状态更新采用trpc SSE subscription接口定义在`server/routers/jobs.ts`
- `src/server/agents`LLM的对接和使用
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
- `src/server/service/session.ts`会话管理服务基于Redis实现权限变更后强制重新登录
- `src/server/service/iaaa.ts`:北京大学统一认证(IAAA)对接服务,通过环境变量配置即可启用
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
- `src/api/dev/`开发模式下的辅助功能需要的api
- `server/routers/`项目trpc api定义文件开发者主要在这里定义和实现业务的后端API
- `server/routers/_app.ts``appRouter`根路由定义,需要添加子路由时在此处注册
- `server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
- `server/routers/jobs.ts`tRPC任务进度订阅路由
- `server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
- `server/routers/global.ts`系统全局和特定业务关联不大的一些api
- `server/routers/dev/`开发模式下的辅助功能需要的trpc api
- `server/queues/`消息队列和worker通过其中的index.ts统一导出任务状态更新采用trpc SSE subscription接口定义在`server/routers/jobs.ts`
- `server/agents`LLM的对接和使用
- `server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
- `server/service/dev/`:开发模式下的辅助功能需要的后台服务
- `server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
- `api/dev/`:开发模式下的辅助功能需要的api
### 其他
- `src/constants/`:项目全局常量管理
- `src/constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)"
- `src/lib/schema/`集中管理数据验证schema定义前后端统一的数据结构和验证规则前端对默认值等其他要求写在表单组件中后端对默认值等其他要求写在接口文件中使用z.input而不是z.infer来获取Schema的输入类型
- `src/lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
- `src/lib/format.ts`:数据格式化工具函数库
- `constants/`:项目全局常量管理
- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)"
- `lib/schema/`集中管理数据验证schema定义前后端统一的数据结构和验证规则前端对默认值等其他要求写在表单组件中后端对默认值等其他要求写在接口文件中使用z.input而不是z.infer来获取Schema的输入类型
- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
- `lib/format.ts`:数据格式化工具函数库
## 非标准命令
- `pnpm run dev:attach`这会使用tmux在名为nextdev的session中启动pnpm run dev便于在开发页面或其他地方与开发服务器交互

View File

@@ -1,16 +1,11 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目说明
本项目模板Hair Keeper v1.3.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证支持SSO单点登录、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
本项目模板Hair Keeper v1.1.0是一个高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑。
Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
开发者直接在本项目模板的基础上进行开发,本项目源代码完全对开发者可见并可以随时修改、扩展功能、增加新的组件和模块,开发者尽量遵从如下文表述的约定和项目文件组织规则。
## 主要依赖库
本项目使用pnpm作为包管理器
- 基础next + react + trpc + prisma
- UI基础框架tailwindcss + radix-ui(基于shadcn/ui库添加组件) + lucide-react + sonner(toast)
- 图表等高级UIrecharts(图表) + xyflow/react(节点图 dagre自动布局) + embla-carousel-react + dnd-kit/sortable
@@ -50,45 +45,44 @@ Hair Keeper是个诙谐有趣的名称和项目内容毫无关系。
## 重要目录和文件
### 前端
- `src/components/common/`进一步封装的高级通用控件例如下拉菜单、对话框表单、响应式tabs等控件
- `src/components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
- `src/components/ai-elements/`ai对话相关的组件
- `src/components/data-details/`专注于数据展示的可复用控件例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
- `src/components/data-table/`专注于数据表格的可复用控件本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
- `src/components/icons/`:项目的自定义图标可以写在这个文件夹
- `src/components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
- `src/components/ui/`高度可定制可复用的基础UI组件通常源自第三方库
- `src/app/(main)/`:开发者在这里自行实现的所有业务的页面
- `src/app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
- `src/app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
- `src/app/(main)/users/`用户管理模块提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
- `src/hooks/`可复用React Hooks库部分复杂的组件也通过hook实现Headless UI逻辑与样式分离组件中可复用的逻辑都可以放在这
- `src/lib/trpc.ts`创建并导出tRPC React客户端实例用于前端与后端API通信
- `src/lib/stores/`通过zustand管理的全局的状态
- `components/common/`进一步封装的高级通用控件例如下拉菜单、对话框表单、响应式tabs等控件
- `components/features/`:进一步封装的控件,通常更重或者与业务关联强需要在不同的页面中复用
- `components/ai-elements/`ai对话相关的组件
- `components/data-details/`专注于数据展示的可复用控件例如detail-badge-list、detail-copyable、detail-list、detail-timeline等控件
- `components/data-table/`专注于数据表格的可复用控件本项目模板自带了基础的data-table、过滤器、排序、分页、列可见性切换等功能
- `components/icons/`:项目的自定义图标可以写在这个文件夹
- `components/layout/`:应用的完整布局框架和导航系统以及可复用的布局容器
- `components/ui/`高度可定制可复用的基础UI组件通常源自第三方库
- `app/(main)/`:开发者在这里自行实现的所有业务的页面
- `app/(main)/dev/`:辅助开发的页面,本项目模板在其中实现了许多功能,代码在实现业务时也可以借鉴参考
- `app/(main)/settings/`:全局设置,由开发者根据业务需求进行补充和实现
- `app/(main)/users/`用户管理模块提供用户CRUD、角色管理、批量授权等完整的用户管理功能的页面和组件实现
- `hooks/`可复用React Hooks库部分复杂的组件也通过hook实现Headless UI逻辑与样式分离组件中可复用的逻辑都可以放在这
- `lib/trpc.ts`创建并导出tRPC React客户端实例用于前端与后端API通信
- `lib/stores/`通过zustand管理的全局的状态
### 后端
- `src/server/routers/`项目trpc api定义文件开发者主要在这里定义和实现业务的后端API
- `src/server/routers/_app.ts``appRouter`根路由定义,需要添加子路由时在此处注册
- `src/server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
- `src/server/routers/jobs.ts`tRPC任务进度订阅路由
- `src/server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
- `src/server/routers/global.ts`系统全局和特定业务关联不大的一些api
- `src/server/routers/dev/`开发模式下的辅助功能需要的trpc api
- `src/server/queues/`消息队列和worker通过其中的index.ts统一导出任务状态更新采用trpc SSE subscription接口定义在`server/routers/jobs.ts`
- `src/server/agents`LLM的对接和使用
- `src/server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
- `src/server/service/session.ts`会话管理服务基于Redis实现权限变更后强制重新登录
- `src/server/service/iaaa.ts`:北京大学统一认证(IAAA)对接服务,通过环境变量配置即可启用
- `src/server/service/dev/`:开发模式下的辅助功能需要的后台服务
- `src/server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
- `src/api/dev/`开发模式下的辅助功能需要的api
- `server/routers/`项目trpc api定义文件开发者主要在这里定义和实现业务的后端API
- `server/routers/_app.ts``appRouter`根路由定义,需要添加子路由时在此处注册
- `server/routers/common.ts`:定义需要在多个模块中复用的通用业务接口路由
- `server/routers/jobs.ts`tRPC任务进度订阅路由
- `server/routers/selection.ts`:用于记录用户选择的选项或者输入的内容,优化用户的输入体验
- `server/routers/global.ts`系统全局和特定业务关联不大的一些api
- `server/routers/dev/`开发模式下的辅助功能需要的trpc api
- `server/queues/`消息队列和worker通过其中的index.ts统一导出任务状态更新采用trpc SSE subscription接口定义在`server/routers/jobs.ts`
- `server/agents`LLM的对接和使用
- `server/service/`:服务层模块集合,封装后端业务逻辑和系统服务
- `server/service/dev/`:开发模式下的辅助功能需要的后台服务
- `server/utils/`:服务端专用工具函数库,为后端业务逻辑提供基础设施支持
- `api/dev/`:开发模式下的辅助功能需要的api
### 其他
- `src/constants/`:项目全局常量管理
- `src/constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)"
- `src/lib/schema/`集中管理数据验证schema定义前后端统一的数据结构和验证规则前端对默认值等其他要求写在表单组件中后端对默认值等其他要求写在接口文件中使用z.input而不是z.infer来获取Schema的输入类型
- `src/lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
- `src/lib/format.ts`:数据格式化工具函数库
- `constants/`:项目全局常量管理
- `constants/permissions.ts`:权限定义,支持前后端一致的权限控制,支持解析复杂的权限表达式(如"A&B|(C&D)"
- `constants/menu.ts`: 菜单管理工具库,提供菜单项定义、查询、权限过滤等功能
- `lib/schema/`集中管理数据验证schema定义前后端统一的数据结构和验证规则前端对默认值等其他要求写在表单组件中后端对默认值等其他要求写在接口文件中使用z.input而不是z.infer来获取Schema的输入类型
- `lib/algorithom.ts`:通用计算机算法实现,例如拓扑排序
- `lib/format.ts`:数据格式化工具函数库
## 非标准命令
- `pnpm run dev:attach`这会使用tmux在名为nextdev的session中启动pnpm run dev便于在开发页面或其他地方与开发服务器交互

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
distDir: process.env.NODE_ENV === "production" ? ".next-prod" : '.next', // 开发和生产环境输出到不同的目录,这样可以同时运行开发服务器和生产服务器
};

View File

@@ -1,12 +1,12 @@
{
"name": "hair-keeper",
"version": "1.3.0",
"version": "1.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000 --turbo",
"dev:attach": "DEV_TERMINAL=nextdev;tmux new-session -A -s $DEV_TERMINAL\\; send-keys \"pnpm run dev\" ^M",
"build": "next build",
"start": "next start",
"start": "next start -p 3000",
"lint": "next lint && tsc --noEmit",
"db:seed": "tsx prisma/seed.ts",
"build:analyze": "ANALYZE=true next build"
@@ -74,7 +74,7 @@
"minio": "^8.0.6",
"motion": "^12.23.22",
"nanoid": "^5.1.6",
"next": "~15.4.10",
"next": "~15.4.8",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"nuqs": "^2.6.0",

View File

@@ -1,45 +0,0 @@
# Hair Keeper开发容器使用帮助
## AI编程代理用法
命令行中:
* `ccr code` 打开一个新对话
* `ccr code --resume` 回到之前某个对话
* `ccr model` 切换编程代理使用的模型,也可以添加新模型
对话中:
* `ESC` 连按2次可以查看和回溯到之前的某一轮对话
* `alt+tab` 切换模型是否打开思维链,默认是开启的,请关闭,因为思考模式很慢,只在极度复杂的任务时才打开
* `\` 如果您的提示词过长,一行输入不下,可以在行末输入这个字符来换行
* `@` 如果您需要引用项目中的文件,可以用这个符号
* `/ide` 输入这个命令AI会和您的IDE进行联动这样您在IDE中选中的代码会被自动发送给AIAI在修改代码时也会在IDE中打开代码预览。
其他:
* `CLAUDE.md` 文件中的内容每次对话都会发送给AI如果您有什么要强调的可以写在这里面您也可以输入`/init` 让AI自动扫描项目并编写CLAUDE.md一般这里面写的都是项目的约定、编码习惯和开发目标。
## 项目与开发环境
##### 常见命令:
* `pnpm run dev` 打开开发服务器
* `pnpm run lint` 检查代码中是否存在明显错误建议每次AI进行了一次大修改先用这个命令排查错误有错误就粘贴给AI让它解决
* `pnpm run build` 构建和打包项目耗时很长如果AI尝试执行这个命令阻止它并告诉它只需要执行`pnpm run lint` 排查错误
* `pnpm prisma migrate dev --name add_some_tables` 如果您对`schema.prisma` 进行了修改并希望修改能同步到数据库,执行这条命令,`add_some_tables` 请替换成能够描述您实际修改的标识符
##### 访问容器内部服务SSH转发
如果您是通过浏览器访问云开发容器有时候可能需要访问云开发容器内部的本地服务您可以通过SSH转发来实现。
例如我们可以通过这种方法来使用Prisma Studioprisma提供的数据库管理工具
```bash
pnpm prisma studio --port 5555 # 在容器中执行
ssh -N -L 5555:127.0.0.1:5555 root@cloud.liuyh.com -p <Hair Keeper容器的SSH服务映射的外部端口> # 在本地主机中执行,然后输入您开发环节的密码
```
然后您可以在本地输入 [http://localhost:5555/](http://localhost:5555/) 访问容器内部运行的Prisma Studio。
虽然您访问的是本地地址但是Prisma Studio服务是运行在远程服务器的容器中的SSH则是连接两者的桥梁。
## 代码仓库快速入门
* `git push origin main` 推送本地代码到远程仓库推送完成后您可以访问这个链接查看您的代码也就是说您的代码在服务器上有了个备份避免意外删除或丢失通过远程仓库您还可以与其他人合作开发一个项目git能够解决代码的版本问题和不同成员修改的合并问题。
* `git add -A && git commit -m "修改了xxx文件、新增了xxx功能"` 在您对代码进行了一定的修改后,可以执行这条命令,相当于一个存档点,也便于您后续查看开发历史。在您进行了几次修改,准备结束今天的工作时,您可以执行`git push origin main` 将代码同步到远程仓库,避免代码丢失。

View File

@@ -316,11 +316,7 @@ EOF
## 项目说明
本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md目前尚未实现业务功能
## 重要提示
- 完成用户的任务后,务必运行`pnpm run lint`检查代码错误并修复不要运行build
- 尽量使用pnpm执行npm相关的命令
本项目基于 Hair Keeper 模板构建(详见 @TEMPLATE_README.md目前尚未实现业务功能
EOF
print_success "新的 CLAUDE.md 已创建"
@@ -350,8 +346,6 @@ EOF
"api_base_url": "${PKUAI_API_BASE}api/anthropic/v1/messages",
"api_key": "${PKUAI_API_KEY}",
"models": [
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-sonnet-4-5-20250929",
"claude-opus-4-5-20251101",
"claude-haiku-4-5-20251001"
@@ -374,13 +368,13 @@ EOF
}
},
"Router": {
"default": "pku-anthropic,claude-opus-4-6",
"default": "pku-anthropic,claude-opus-4-5-20251101",
"background": "pku-anthropic,claude-haiku-4-5-20251001",
"think": "pku-anthropic,-claude-opus4-6",
"longContext": "pku-anthropic,claude-opus-4-6",
"think": "pku-anthropic,claude-opus-4-5-20251101",
"longContext": "pku-anthropic,claude-opus-4-5-20251101",
"longContextThreshold": 80000,
"webSearch": "",
"image": "pku-anthropic,claude-opus-4-6"
"image": "pku-anthropic,claude-opus-4-5-20251101"
},
"CUSTOM_ROUTER_PATH": ""
}
@@ -407,7 +401,6 @@ EOF
echo ""
git init
git config init.defaultBranch main
git config core.pager "less -F -X"
print_info "设置 Git 用户配置..."
git config user.email "$GIT_USER_EMAIL"
git config user.name "$GIT_USER_NAME"

4
simple_deploy.sh Executable file → Normal file
View File

@@ -1,6 +1,6 @@
#!/bin/bash
# 此脚本用来一键部署到生产服务器
# 需事先配置好ssh免密登录目标服务器需要安装好node、pnpm、tsx、pm2来运行程序
# 需事先配置好ssh免密登录目标服务器需要安装好node、pnpm、tsx、pm2来运行容器
set -e
# 配置
@@ -20,7 +20,7 @@ TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
# 复制必要文件
cp -r .next-prod $TEMP_DIR/
cp -r .next $TEMP_DIR/
cp -r public $TEMP_DIR/
cp -r prisma $TEMP_DIR/
cp package.json $TEMP_DIR/

View File

@@ -1,28 +0,0 @@
#!/bin/bash
# 此脚本用来直接在当前目录下一键部署生产服务器
# 需要安装好node、pnpm、pm2来运行程序
set -e
# 配置
PROJECT_NAME="hair-keeper" # 可自由修改,默认使用本项目模板的名称
PORT="8000"
pnpm run lint
echo "🔨 开始构建项目..."
pnpm run build
echo "🗄️ 运行数据库迁移..."
npx prisma migrate deploy
npx prisma generate
echo "🔄 使用PM2重启服务..."
pm2 describe hair-keeper > /dev/null 2>&1 && pm2 delete hair-keeper
pm2 start pnpm --name $PROJECT_NAME -- start -p ${PORT}
# 保存当前 PM2 进程列表的快照,使其在系统重启后能自动恢复
pm2 save
echo "✅ 部署完成!服务运行在端口 ${PORT}"
echo "📊 查看服务状态: pm2 status"
echo "📝 查看日志: pm2 logs $PROJECT_NAME"
echo "❌ 关闭服务: pm2 delete $PROJECT_NAME"
echo "🎉 部署成功!"

View File

@@ -1,243 +0,0 @@
'use client'
import { useEffect, useRef, useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { signIn } from "next-auth/react"
import { useRouter, useSearchParams } from "next/navigation"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { AlertTriangle, ExternalLink } from "lucide-react"
import type { IaaaClientConfig } from "./page"
// 登录表单验证 schema
const loginSchema = z.object({
id: z.string().min(1, "请输入用户ID"),
password: z.string().min(1, "请输入密码"),
})
type LoginFormData = z.infer<typeof loginSchema>
/** IAAA 回调错误码到用户提示的映射 */
const IAAA_ERROR_MESSAGES: Record<string, string> = {
iaaa_no_token: '未收到认证票据,请重试',
iaaa_validate_failed: '统一认证验证失败,请重试',
iaaa_user_not_found: '系统中不存在与您北大账号对应的用户,请联系管理员',
iaaa_server_error: '服务器内部错误,请稍后重试',
}
/** 创建隐藏表单 POST 跳转到 IAAA 统一认证页面 */
function redirectToIaaa(appId: string, callbackPath: string) {
const form = document.createElement('form')
form.action = 'https://iaaa.pku.edu.cn/iaaa/oauth.jsp'
form.method = 'POST'
form.style.display = 'none'
function addField(name: string, value: string) {
const input = document.createElement('input')
input.type = 'hidden'
input.name = name
input.value = value
form.appendChild(input)
}
const baseUrl = `${location.protocol}//${location.host}`
addField('appID', appId)
addField('redirectUrl', `${baseUrl}${callbackPath}`)
// 保留应用自有登录入口,以便用户可以选择回到密码登录
addField('redirectLogonUrl', `${baseUrl}/login`)
document.body.appendChild(form)
form.submit()
}
export function LoginForm({ iaaaConfig }: { iaaaConfig: IaaaClientConfig }) {
const router = useRouter()
const searchParams = useSearchParams()
const autoRedirected = useRef(false)
const [showDevHint, setShowDevHint] = useState(false)
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
id: "",
password: "",
},
})
// IAAA 回调错误提示(使用 iaaa_error 参数名,避免与 next-auth 的 error 参数冲突)
const iaaaErrorCode = searchParams.get('iaaa_error')
const iaaaError = iaaaErrorCode ? IAAA_ERROR_MESSAGES[iaaaErrorCode] : null
// 自动跳转:配置开启且 IAAA 已启用时,进入登录页自动跳转到统一认证
// 仅在非错误回调场景下触发(避免验证失败后死循环)
useEffect(() => {
if (
iaaaConfig.enabled &&
iaaaConfig.autoRedirect &&
!iaaaErrorCode &&
!autoRedirected.current
) {
autoRedirected.current = true
redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath)
}
}, [iaaaConfig, iaaaErrorCode])
const onSubmit = async (data: LoginFormData) => {
try {
const result = await signIn("credentials", {
id: data.id,
password: data.password,
redirect: false,
})
if (result?.error) {
form.setError("root", {
type: "manual",
message: "用户ID或密码错误"
})
} else if (result?.ok) {
router.push("/")
router.refresh()
}
} catch (error) {
form.setError("root", {
type: "manual",
message: "登录失败,请重试"
})
console.error("Login error:", error)
}
}
// IAAA 是否可见:已启用,或开发模式下需要显示配置提醒
const showIaaa = iaaaConfig.enabled || iaaaConfig.showInDev
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* IAAA 回调错误提示 */}
{iaaaError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{iaaaError}</AlertDescription>
</Alert>
)}
{/* 密码登录表单 */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>ID</FormLabel>
<FormControl>
<Input
type="text"
placeholder="请输入用户ID"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入密码"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<div className="text-sm text-red-600 text-center">
{form.formState.errors.root.message}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "登录中..." : "登录"}
</Button>
</form>
</Form>
{/* IAAA 统一认证登录 */}
{showIaaa && (
<>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
if (iaaaConfig.enabled) {
redirectToIaaa(iaaaConfig.appId, iaaaConfig.callbackPath)
} else {
setShowDevHint(true)
}
}}
>
<ExternalLink className="mr-2 h-4 w-4" />
</Button>
</>
)}
</CardContent>
</Card>
{/* 开发模式下未配置 IAAA 的提示对话框(仅开发模式渲染,避免泄露内部架构) */}
{iaaaConfig.showInDev && <Dialog open={showDevHint} onOpenChange={setShowDevHint}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
IAAA 使
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<p className="font-medium">1. IAAA </p>
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
<div>URL<span className="text-muted-foreground">https://你的域名</span></div>
<div>IP<span className="text-muted-foreground">IP</span></div>
<div>URL<span className="text-muted-foreground">https://你的域名/api/auth/iaaa/callback</span></div>
</div>
<p className="font-medium">2. <code className="bg-muted px-1 py-0.5 rounded">.env</code> </p>
<div className="rounded-md bg-muted p-3 font-mono text-xs space-y-1">
<div>IAAA_APP_ID=ID</div>
<div>IAAA_KEY=</div>
<div className="text-muted-foreground"># </div>
<div>IAAA_AUTO_REDIRECT=true</div>
</div>
<p className="text-muted-foreground">
</p>
</div>
</DialogContent>
</Dialog>}
</div>
)
}

View File

@@ -1,23 +1,122 @@
import { getIaaaClientConfig } from "@/server/service/iaaa"
import { LoginForm } from "./login-form"
'use client'
export interface IaaaClientConfig {
enabled: boolean
appId: string
callbackPath: string
autoRedirect: boolean
/** 开发模式下未配置 IAAA需要显示配置提醒 */
showInDev: boolean
}
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { signIn } from "next-auth/react"
import { useRouter } from "next/navigation"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
// 登录表单验证 schema
const loginSchema = z.object({
id: z.string().min(1, "请输入用户ID"),
password: z.string().min(1, "请输入密码"),
})
type LoginFormData = z.infer<typeof loginSchema>
export default function LoginPage() {
const config = getIaaaClientConfig()
const isDev = process.env.NODE_ENV === 'development'
const router = useRouter()
const iaaaConfig: IaaaClientConfig = {
...config,
showInDev: isDev && !config.enabled,
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
id: "",
password: "",
},
})
const onSubmit = async (data: LoginFormData) => {
try {
const result = await signIn("credentials", {
id: data.id,
password: data.password,
redirect: false,
})
if (result?.error) {
form.setError("root", {
type: "manual",
message: "用户ID或密码错误"
})
} else if (result?.ok) {
// 登录成功,重定向到首页
router.push("/")
router.refresh()
}
} catch (error) {
form.setError("root", {
type: "manual",
message: "登录失败,请重试"
})
console.error("Login error:", error)
}
}
return <LoginForm iaaaConfig={iaaaConfig} />
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-center"></CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="id"
render={({ field }) => (
<FormItem>
<FormLabel>ID</FormLabel>
<FormControl>
<Input
type="text"
placeholder="请输入用户ID"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type="password"
placeholder="请输入密码"
disabled={form.formState.isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<div className="text-sm text-red-600 text-center">
{form.formState.errors.root.message}
</div>
)}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "登录中..." : "登录"}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit, Upload } from 'lucide-react'
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { trpc } from '@/lib/trpc'
@@ -73,7 +73,7 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
const [commitType, setCommitType] = React.useState<'normal' | 'amend' | null>(null)
const [confirmAction, setConfirmAction] = React.useState<{
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset' | 'push'
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset'
commitId?: string
message?: string
title?: string
@@ -191,17 +191,6 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
},
})
// 推送远程仓库mutation
const pushToRemoteMutation = trpc.devPanel!.pushToRemote.useMutation({
onSuccess: (data) => {
toast.success(data.message)
setConfirmAction(null)
},
onError: (error) => {
toast.error(error.message)
},
})
// 处理分支选择(仅用于查看历史,不切换实际分支)
const handleBranchChange = (branchName: string | null) => {
if (!branchName) return
@@ -317,21 +306,9 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
resetToCommitMutation.mutate({ commitId: confirmAction.commitId })
}
break
case 'push':
pushToRemoteMutation.mutate({ branchName: selectedBranch })
break
}
}
// 处理推送远程仓库
const handlePushToRemote = () => {
setConfirmAction({
type: 'push',
title: '推送到远程仓库',
description: `确定要将分支 "${selectedBranch}" 的最新提交推送到远程仓库吗?`,
})
}
// 手动刷新所有数据
const handleRefresh = () => {
refetchBranches()
@@ -380,7 +357,7 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
</div>
</div>
{/* 右半部分Commit按钮、推送按钮和刷新按钮 */}
{/* 右半部分Commit按钮和刷新按钮 */}
<div className="flex items-center gap-2 flex-1 justify-end">
<Button
onClick={() => setShowCommitDialog(true)}
@@ -390,16 +367,6 @@ export function VersionControl({ isOpen }: { isOpen: boolean }) {
<GitCommit className="mr-2 h-4 w-4" />
</Button>
{selectedBranch && !selectedBranch.startsWith('origin/') && (
<Button
variant="outline"
onClick={handlePushToRemote}
disabled={pushToRemoteMutation.isPending}
title="推送到远程仓库"
>
<Upload className={cn("h-4 w-4", pushToRemoteMutation.isPending && "animate-pulse")} />
</Button>
)}
<Button
variant="outline"
onClick={handleRefresh}

View File

@@ -1,93 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { encode } from 'next-auth/jwt'
import { db } from '@/server/db'
import { authOptions, buildUserJwtPayload, userAuthInclude } from '@/server/auth'
import { validateIaaaToken } from '@/server/service/iaaa'
import { clearSessionInvalidation } from '@/server/service/session'
/**
* IAAA 统一认证回调路由
*
* IAAA 认证成功后会 302 重定向到此路由,携带 token 参数。
* 本路由验证 token、查找本地用户、生成 JWT session cookie然后重定向到首页。
*/
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get('token')
const loginUrl = new URL('/login', req.url)
if (!token) {
loginUrl.searchParams.set('iaaa_error', 'iaaa_no_token')
return NextResponse.redirect(loginUrl)
}
// 获取客户端真实 IP反向代理场景取 X-Forwarded-For 第一个值)
const remoteAddr =
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
req.headers.get('x-real-ip') ||
'127.0.0.1'
// 调用 IAAA 验证
const userInfo = await validateIaaaToken(token, remoteAddr)
if (!userInfo) {
loginUrl.searchParams.set('iaaa_error', 'iaaa_validate_failed')
return NextResponse.redirect(loginUrl)
}
// 用 identityId学号/职工号)匹配本地用户
const user = await db.user.findUnique({
where: { id: userInfo.identityId },
include: userAuthInclude,
})
if (!user) {
console.warn(`[IAAA] 用户 ${userInfo.identityId}${userInfo.name})在系统中不存在`)
loginUrl.searchParams.set('iaaa_error', 'iaaa_user_not_found')
return NextResponse.redirect(loginUrl)
}
// 更新最近登录时间
await db.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
})
// 清除会话失效标记
await clearSessionInvalidation(user.id)
// 构建 JWT payload与密码登录完全一致
const payload = buildUserJwtPayload(user)
// 生成 next-auth 兼容的 JWT
const secret = authOptions.secret || process.env.NEXTAUTH_SECRET
if (!secret) {
console.error('[IAAA] NEXTAUTH_SECRET 未配置')
loginUrl.searchParams.set('iaaa_error', 'iaaa_server_error')
return NextResponse.redirect(loginUrl)
}
const maxAge = authOptions.session?.maxAge ?? 30 * 24 * 60 * 60
const encodedToken = await encode({
token: { ...payload, sub: payload.id },
secret,
maxAge,
})
// 设置 session cookie 并重定向到首页
// next-auth 在 HTTPS 环境下使用 __Secure- 前缀
const useSecureCookie = req.nextUrl.protocol === 'https:'
const cookieName = useSecureCookie
? '__Secure-next-auth.session-token'
: 'next-auth.session-token'
const response = NextResponse.redirect(new URL('/', req.url))
response.cookies.set(cookieName, encodedToken, {
httpOnly: true,
secure: useSecureCookie,
sameSite: 'lax',
path: '/',
maxAge,
})
console.info(`[IAAA] 登录成功: ${userInfo.identityId}${userInfo.name}`)
return response
}

View File

@@ -267,7 +267,7 @@ export function FormDialog({
if (formRef.current) {
// 查找第一个可聚焦的输入元素
const firstInput = formRef.current.querySelector<HTMLElement>(
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
)
if (firstInput) {
firstInput.focus()

View File

@@ -1,6 +1,6 @@
'use client'
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
import { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
@@ -19,43 +19,20 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import { Loader2, ChevronLeft, ChevronRight, Check } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
// 字段配置类型定义
export interface StepFieldConfig {
name: string
label: string
required?: boolean
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode
className?: string
}
// 步骤配置类型定义
export interface StepConfig {
name: string
title: string
description?: string
fields: StepFieldConfig[]
className?: string // 步骤内容区域的网格样式
}
// MultiStepFormDialog Context
export interface MultiStepFormDialogContextValue {
form: UseFormReturn<any>
close: () => void
steps: StepConfig[]
currentStep: number
totalSteps: number
onCancel: () => void
onPrevious: () => void
onNext: () => void
isSubmitting: boolean
isValidating: boolean
submitButtonText: string
isFirstStep: boolean
isLastStep: boolean
goToStep: (step: number) => void
goNext: () => void
goPrev: () => void
isLoading?: boolean
canGoNext: () => Promise<boolean>
}
const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null)
@@ -68,343 +45,91 @@ export function useMultiStepFormDialogContext() {
return context
}
// 步骤指示器组件
export interface StepIndicatorProps {
className?: string
showLabels?: boolean
clickable?: boolean
// 字段配置类型定义
export interface FormFieldConfig {
name: string
label: string
required?: boolean
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
className?: string // 允许为单个字段指定自定义样式
}
export function StepIndicator({ className, showLabels = false, clickable = false }: StepIndicatorProps) {
const { steps, currentStep, goToStep, isLoading } = useMultiStepFormDialogContext()
// 步骤配置类型定义
export interface StepConfig {
title: string
description?: string
fields: FormFieldConfig[]
}
// 多步骤表单操作按钮栏组件
export function MultiStepFormActionBar() {
const {
onCancel,
onPrevious,
onNext,
isSubmitting,
isValidating,
submitButtonText,
isFirstStep,
isLastStep
} = useMultiStepFormDialogContext()
return (
<div className={cn("flex items-center justify-center gap-2", className)}>
{steps.map((step, index) => {
const isCompleted = index < currentStep
const isCurrent = index === currentStep
const isClickable = clickable && !isLoading && index <= currentStep
return (
<React.Fragment key={step.name}>
{index > 0 && (
<div
className={cn(
"h-0.5 w-8 transition-colors",
index <= currentStep ? "bg-primary" : "bg-muted"
)}
/>
)}
<button
type="button"
disabled={!isClickable}
onClick={() => isClickable && goToStep(index)}
className={cn(
"flex items-center gap-2 transition-colors",
isClickable && "cursor-pointer hover:opacity-80",
!isClickable && "cursor-default"
)}
>
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full border-2 text-sm font-medium transition-colors",
isCompleted && "border-primary bg-primary text-primary-foreground",
isCurrent && "border-primary text-primary",
!isCompleted && !isCurrent && "border-muted text-muted-foreground"
)}
>
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
</div>
{showLabels && (
<span
className={cn(
"text-sm font-medium hidden sm:inline",
isCurrent && "text-primary",
!isCurrent && "text-muted-foreground"
)}
>
{step.title}
</span>
)}
</button>
</React.Fragment>
)
})}
</div>
)
}
// 步骤内容组件
export interface StepContentProps {
className?: string
}
export function StepContent({ className = 'grid grid-cols-1 gap-4' }: StepContentProps) {
const { form, steps, currentStep, isLoading } = useMultiStepFormDialogContext()
const currentStepConfig = steps[currentStep]
if (!currentStepConfig) return null
// 如果正在加载,显示骨架屏
if (isLoading) {
return (
<div className={cn("p-1", className, currentStepConfig.className)}>
{currentStepConfig.fields.map((fieldConfig) => (
<div key={fieldConfig.name} className={cn("space-y-2", fieldConfig.className || '')}>
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
<div className="flex justify-between pt-6 border-t">
<div className="flex space-x-2">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
</div>
<div className="flex space-x-2">
{!isFirstStep && (
<Button
type="button"
variant="outline"
onClick={onPrevious}
disabled={isSubmitting}
>
<ChevronLeft className="w-4 h-4 mr-1" />
</Button>
)}
{!isLastStep && (
<Button
type="button"
onClick={onNext}
disabled={isSubmitting || isValidating}
>
{isValidating ? '验证中...' : '下一步'}
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
)}
<Button
type="submit"
disabled={isSubmitting}
className={!isLastStep ? 'hidden' : ''}
>
{isSubmitting ? `${submitButtonText}中...` : submitButtonText}
</Button>
</div>
)
}
return (
<div className={cn("p-1", className, currentStepConfig.className)}>
{currentStepConfig.fields.map((fieldConfig) => (
<FormField
key={fieldConfig.name}
control={form.control}
name={fieldConfig.name}
render={({ field }) => (
<FormItem className={fieldConfig.className || ''}>
<FormLabel className="flex items-center gap-1">
{fieldConfig.label}
{fieldConfig.required && <span className="text-red-500">*</span>}
</FormLabel>
<FormControl>
{fieldConfig.render({ field })}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
)
}
// 步骤标题组件(显示当前步骤的标题和描述)
export interface StepHeaderProps {
className?: string
}
export function StepHeader({ className }: StepHeaderProps) {
const { steps, currentStep } = useMultiStepFormDialogContext()
const currentStepConfig = steps[currentStep]
if (!currentStepConfig) return null
return (
<div className={cn("space-y-1", className)}>
<h4 className="font-medium">{currentStepConfig.title}</h4>
{currentStepConfig.description && (
<p className="text-sm text-muted-foreground">{currentStepConfig.description}</p>
)}
</div>
)
}
// 上一步按钮组件
export interface PrevStepActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
children?: React.ReactNode
onPrev?: () => void
}
export function PrevStepAction({
children,
variant = 'outline',
onPrev,
...props
}: PrevStepActionProps) {
const { goPrev, isFirstStep, isLoading } = useMultiStepFormDialogContext()
const handleClick = () => {
if (onPrev) {
onPrev()
} else {
goPrev()
}
}
return (
<Button
type="button"
variant={variant}
onClick={handleClick}
disabled={isFirstStep || isLoading || props.disabled}
{...props}
>
{children ?? (
<>
<ChevronLeft className="mr-1 h-4 w-4" />
</>
)}
</Button>
)
}
// 下一步按钮组件
export interface NextStepActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
children?: React.ReactNode
onNext?: () => Promise<boolean> | boolean
validateCurrentStep?: boolean
}
export function NextStepAction({
children,
variant = 'default',
onNext,
validateCurrentStep = true,
...props
}: NextStepActionProps) {
const { goNext, isLastStep, isLoading, canGoNext } = useMultiStepFormDialogContext()
const [isValidating, setIsValidating] = useState(false)
const handleClick = async () => {
setIsValidating(true)
try {
if (validateCurrentStep) {
const canProceed = await canGoNext()
if (!canProceed) {
return
}
}
if (onNext) {
const shouldProceed = await onNext()
if (!shouldProceed) {
return
}
}
goNext()
} finally {
setIsValidating(false)
}
}
if (isLastStep) return null
return (
<Button
type="button"
variant={variant}
onClick={handleClick}
disabled={isLoading || isValidating || props.disabled}
{...props}
>
{children ?? (
<>
<ChevronRight className="ml-1 h-4 w-4" />
</>
)}
{isValidating && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
</Button>
)
}
// 取消按钮组件
export interface StepCancelActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
children?: React.ReactNode
onCancel?: () => void
}
export function StepCancelAction({ children = '取消', variant = 'outline', onCancel, ...props }: StepCancelActionProps) {
const { close } = useMultiStepFormDialogContext()
const handleClick = () => {
if (onCancel) {
onCancel()
} else {
close()
}
}
return (
<Button type="button" variant={variant} onClick={handleClick} disabled={props.disabled} {...props}>
{children}
</Button>
)
}
// 提交按钮组件(仅在最后一步显示)
export interface StepSubmitActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
onSubmit: (data: any) => Promise<void> | void
children?: React.ReactNode
disabled?: boolean
isSubmitting?: boolean
showSpinningLoader?: boolean
showOnlyOnLastStep?: boolean
}
export function StepSubmitAction({
onSubmit,
children = '提交',
disabled = false,
isSubmitting = false,
showSpinningLoader = true,
showOnlyOnLastStep = true,
variant = 'default',
...props
}: StepSubmitActionProps) {
const { form, isLastStep, isLoading } = useMultiStepFormDialogContext()
if (showOnlyOnLastStep && !isLastStep) return null
return (
<Button
type="button"
variant={variant}
onClick={form.handleSubmit(onSubmit)}
disabled={isSubmitting || disabled || isLoading}
{...props}
>
{children}
{isSubmitting && showSpinningLoader && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
</Button>
)
}
// 操作按钮栏组件
export interface StepActionBarProps {
children?: React.ReactNode
className?: string
}
export function StepActionBar({ children, className }: StepActionBarProps) {
return (
<div className={cn("flex justify-between", className)}>
{children}
</div>
)
}
// 左侧操作区
export function StepActionBarLeft({ children, className }: { children?: React.ReactNode; className?: string }) {
return <div className={cn("flex gap-2", className)}>{children}</div>
}
// 右侧操作区
export function StepActionBarRight({ children, className }: { children?: React.ReactNode; className?: string }) {
return <div className={cn("flex gap-2", className)}>{children}</div>
}
// 主对话框组件
export interface MultiStepFormDialogProps {
isOpen: boolean
title: string
description: string
form: UseFormReturn<any>
onSubmit: (data: any) => Promise<void> | void
steps: StepConfig[]
contentClassName?: string
gridClassName?: string
/* action */
onClose: () => void
className?: string
formClassName?: string
children: React.ReactNode
isLoading?: boolean
initialStep?: number
isSubmitting: boolean
submitButtonText: string
}
export function MultiStepFormDialog({
@@ -412,33 +137,30 @@ export function MultiStepFormDialog({
title,
description,
form,
onSubmit,
steps,
contentClassName = 'max-w-4xl',
gridClassName = 'grid grid-cols-1 md:grid-cols-2 gap-4',
onClose,
className = 'max-w-lg',
formClassName,
children,
isLoading = false,
initialStep = 0,
isSubmitting,
submitButtonText,
}: MultiStepFormDialogProps) {
const isMobile = useIsMobile()
const [currentStep, setCurrentStep] = useState(0)
const [isValidating, setIsValidating] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const [currentStep, setCurrentStep] = useState(initialStep)
// 重置步骤当对话框关闭或重新打开时
// 当对话框打开或步骤改变时,自动聚焦到第一个表单输入控件
useEffect(() => {
if (isOpen) {
setCurrentStep(initialStep)
}
}, [isOpen, initialStep])
// 当对话框打开或步骤变化时,自动聚焦到第一个表单输入控件
useEffect(() => {
if (isOpen && !isLoading) {
// 使当前拥有焦点的元素通常是用来触发打开这个drawer的控件失去焦点不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
(document.activeElement as HTMLElement)?.blur()
// 使用 setTimeout 确保 DOM 已完全渲染
const timer = setTimeout(() => {
if (formRef.current) {
// 查找第一个可聚焦的输入元素
const firstInput = formRef.current.querySelector<HTMLElement>(
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button[role="combobox"]:not([disabled])'
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
)
if (firstInput) {
firstInput.focus()
@@ -448,78 +170,138 @@ export function MultiStepFormDialog({
return () => clearTimeout(timer)
}
}, [isOpen, isLoading, currentStep])
}, [isOpen, currentStep])
const close = () => {
const handleSubmit = async (data: any) => {
await onSubmit(data)
}
const handleClose = () => {
setCurrentStep(0)
onClose()
}
const totalSteps = steps.length
const handleNext = async () => {
if (currentStep < steps.length - 1) {
setIsValidating(true)
try {
// 验证当前步骤的字段,只有验证通过才能跳转到下一步
const currentStepFields = currentStepConfig.fields.map(field => field.name)
const isValid = await form.trigger(currentStepFields)
if (isValid) {
setCurrentStep(currentStep + 1)
}
} finally {
setIsValidating(false)
}
}
}
const handlePrevious = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const isFirstStep = currentStep === 0
const isLastStep = currentStep === totalSteps - 1
const isLastStep = currentStep === steps.length - 1
const currentStepConfig = steps[currentStep]
const goToStep = (step: number) => {
if (step >= 0 && step < totalSteps) {
setCurrentStep(step)
}
}
const goNext = () => {
if (!isLastStep) {
setCurrentStep((prev) => prev + 1)
}
}
const goPrev = () => {
if (!isFirstStep) {
setCurrentStep((prev) => prev - 1)
}
}
// 验证当前步骤的字段
const canGoNext = async (): Promise<boolean> => {
const currentStepConfig = steps[currentStep]
if (!currentStepConfig) return true
const fieldNames = currentStepConfig.fields.map((f) => f.name)
const result = await form.trigger(fieldNames as any)
return result
}
// 步骤指示器组件
const stepIndicator = (
<div className="flex items-center justify-between mb-6">
{steps.map((step, index) => (
<div key={index} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
index <= currentStep
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{index + 1}
</div>
<div className="ml-2 text-sm">
<div className={index <= currentStep ? 'text-primary font-medium' : 'text-muted-foreground'}>
{step.title}
</div>
</div>
{index < steps.length - 1 && (
<div className={`w-12 h-0.5 mx-4 ${index < currentStep ? 'bg-primary' : 'bg-muted'}`} />
)}
</div>
))}
</div>
)
// Context 值
const contextValue: MultiStepFormDialogContextValue = {
form,
close,
steps,
currentStep,
totalSteps,
onCancel: handleClose,
onPrevious: handlePrevious,
onNext: handleNext,
isSubmitting,
isValidating,
submitButtonText,
isFirstStep,
isLastStep,
goToStep,
goNext,
goPrev,
isLoading,
canGoNext,
isLastStep
}
// 表单内容组件
const formContent = (
<MultiStepFormDialogContext.Provider value={contextValue}>
<Form {...form}>
<form ref={formRef} className={cn("space-y-6", formClassName)}>
{children}
</form>
</Form>
<div className="space-y-4">
<Form {...form}>
<form ref={formRef} onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
{/* 当前步骤标题和描述 */}
<div className="border-b pb-4">
<h3 className="text-lg font-medium">{currentStepConfig.title}</h3>
{currentStepConfig.description && (
<p className="text-sm text-muted-foreground mt-1">{currentStepConfig.description}</p>
)}
</div>
{/* 当前步骤的字段 */}
<div className={cn("p-1", gridClassName)}>
{currentStepConfig.fields.map((fieldConfig) => (
<FormField
key={fieldConfig.name}
control={form.control}
name={fieldConfig.name}
render={({ field }) => (
<FormItem className={fieldConfig.className || ''}>
<FormLabel className="flex items-center gap-1">
{fieldConfig.label}
{fieldConfig.required && <span className="text-red-500">*</span>}
</FormLabel>
<FormControl>
{fieldConfig.render({ field })}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
{/* 操作按钮 */}
<MultiStepFormActionBar />
</form>
</Form>
</div>
</MultiStepFormDialogContext.Provider>
)
// 根据设备类型渲染不同的组件
if (isMobile) {
return (
<Drawer open={isOpen} onOpenChange={(open) => !open && close()}>
<DrawerContent className={className}>
<Drawer open={isOpen} onOpenChange={handleClose}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
{stepIndicator}
{formContent}
</div>
</DrawerContent>
@@ -528,16 +310,17 @@ export function MultiStepFormDialog({
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
<DialogContent className={className} showCloseButton={false}>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className={contentClassName}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto max-h-[70vh]">
{stepIndicator}
<div className='overflow-y-auto max-h-[70vh]'>
{formContent}
</div>
</DialogContent>
</Dialog>
)
}
}

View File

@@ -1,32 +1,7 @@
'use client'
import { SessionProvider as NextAuthSessionProvider, signOut, useSession } from "next-auth/react"
import { useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
/** 检测会话失效并自动登出 */
function SessionInvalidationGuard({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession()
const pathname = usePathname()
const signingOut = useRef(false)
useEffect(() => {
// 已认证但 session 中没有 user会话被标记失效自动登出清除 cookie
if (status === 'authenticated' && !(session as any)?.user?.id && !signingOut.current && pathname !== '/login') {
signingOut.current = true
signOut({ callbackUrl: '/login' })
}
}, [status, session, pathname])
return <>{children}</>
}
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react"
export function SessionProvider({ children }: { children: React.ReactNode }) {
return (
<NextAuthSessionProvider>
<SessionInvalidationGuard>
{children}
</SessionInvalidationGuard>
</NextAuthSessionProvider>
)
}
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
}

View File

@@ -3,4 +3,4 @@
*/
export const SITE_NAME = 'Hair Keeper'
export const SITE_DESCRIPTION = '高度集成、深度定制、约定优于配置的全栈Web应用模板旨在保持灵活性的同时提供一套基于成熟架构的开发底座自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能提供AI开发辅助免于纠结功能如何实现可快速上手专注于业务逻辑'
export const SITE_VERSION = 'v1.3.0'
export const SITE_VERSION = 'v1.1.0'

View File

@@ -9,8 +9,8 @@ export default withAuth(
const token = req.nextauth.token
const pathname = req.nextUrl.pathname
// 如果用户已登录且访问登录页面,重定向到首页(会话失效的用户除外,需留在登录页)
if (pathname === "/login" && token && !token.sessionInvalid) {
// 如果用户已登录且访问登录页面,重定向到首页
if (pathname === "/login" && token) {
return NextResponse.redirect(new URL("/", req.url))
}
@@ -49,12 +49,7 @@ export default withAuth(
if (req.nextUrl.pathname === "/login") {
return true
}
// 会话已被标记失效,强制重新登录
if (token?.sessionInvalid) {
return false
}
// 其他路由需要有效的 token
return !!token
},

View File

@@ -4,37 +4,6 @@ import type { User as NextAuthUser } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"
import { db } from "./db"
import { clearSessionInvalidation, isSessionInvalidated } from "./service/session"
/** 用户查询时需要 include 的关联 */
export const userAuthInclude = {
roles: { include: { permissions: true } },
dept: true,
} as const
/** 从数据库用户对象构建 JWT payload供密码登录和 IAAA 登录共用) */
export function buildUserJwtPayload(user: {
id: string
name: string | null
status: string | null
deptCode: string | null
isSuperAdmin: boolean
roles: Array<{ name: string; permissions: Array<{ name: string }> }>
}) {
const roles = user.roles.map((r) => r.name)
const permissions = Array.from(
new Set(user.roles.flatMap((r) => r.permissions.map((p) => p.name)))
)
return {
id: user.id,
name: user.name,
status: user.status,
deptCode: user.deptCode,
roles,
permissions,
isSuperAdmin: user.isSuperAdmin,
}
}
export const authOptions: NextAuthOptions = {
providers: [
@@ -52,8 +21,15 @@ export const authOptions: NextAuthOptions = {
try {
// 查找用户
const user = await db.user.findUnique({
where: { id: credentials.id },
include: userAuthInclude,
where: {
id: credentials.id
},
include: {
roles: {
include: { permissions: true }
},
dept: true
}
})
if (!user) {
@@ -62,6 +38,7 @@ export const authOptions: NextAuthOptions = {
// 验证密码
const isPasswordValid = await bcrypt.compare(credentials.password, user.password)
if (!isPasswordValid) {
return null
@@ -72,11 +49,23 @@ export const authOptions: NextAuthOptions = {
where: { id: user.id },
data: { lastLoginAt: new Date() }
})
// 清除会话失效标记(用户已重新登录,获得最新权限
await clearSessionInvalidation(user.id)
return buildUserJwtPayload(user) as any
// 返回用户信息、角色和权限
const roles = user.roles.map((r) => r.name)
const permissions = Array.from(
new Set(user.roles.flatMap((r) =>
r.permissions.map((p) => p.name)
))
)
return {
id: user.id,
name: user.name,
status: user.status,
deptCode: user.deptCode,
roles,
permissions,
isSuperAdmin: user.isSuperAdmin
} as any
} catch (error) {
console.error("Auth error:", error)
return null
@@ -109,20 +98,10 @@ export const authOptions: NextAuthOptions = {
permissions: u.permissions,
isSuperAdmin: u.isSuperAdmin,
}
} else if (token.id) {
// 后续请求:检查会话是否已被标记失效
const invalidated = await isSessionInvalidated(token.id as string)
if (invalidated) {
token.sessionInvalid = true
}
}
return token
},
async session({ session, token }) {
// 会话已被标记失效返回不含用户信息的session
if (token.sessionInvalid) {
return { expires: session.expires } as any
}
// 将JWT token中的信息传递给session
if (session.user) {
const t = token as any

View File

@@ -1,7 +1,7 @@
import 'server-only'
import { Client } from 'minio';
// 内部客户端:用于 statObject、removeObject 等服务端直连操作,以及 presignedPostPolicy
// 初始化 MinIO 客户端
export const minioClient = new Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_API_PORT || '9000'),
@@ -10,32 +10,6 @@ export const minioClient = new Client({
secretKey: process.env.MINIO_ROOT_PASSWORD || '',
});
/**
* 公网客户端:用于生成 presigned GET/PUT URL
*
* AWS Signature V4 会将 Host 头纳入签名计算,因此 presigned GET/PUT URL
* 必须使用与浏览器访问一致的公网地址生成,否则签名校验失败。
* 而 POST Policy 不含 Host 签名,只需替换返回的 URL 地址即可。
*/
function createPresignClient(): Client {
const serverUrl = process.env.MINIO_SERVER_URL;
if (!serverUrl) return minioClient;
const parsed = new URL(serverUrl);
const useSSL = parsed.protocol === 'https:';
const port = parsed.port ? parseInt(parsed.port) : (useSSL ? 443 : 80);
return new Client({
endPoint: parsed.hostname,
port,
useSSL,
accessKey: process.env.MINIO_ROOT_USER || '',
secretKey: process.env.MINIO_ROOT_PASSWORD || '',
});
}
const presignClient = createPresignClient();
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files';
/**
@@ -192,9 +166,6 @@ export async function generatePresignedPostPolicy(
// 精确匹配
policy.setContentType(allowedContentType);
}
} else {
// 未指定类型限制时,允许任意 Content-Type客户端上传时会设置此字段Policy 中必须声明)
policy.policy.conditions.push(['starts-with', '$Content-Type', '']);
}
if (allowOriginalFilename) {
@@ -284,8 +255,8 @@ export async function generatePresignedGetObject(
}
}
// 使用公网客户端生成预签名 URL,确保 V4 签名中的 Host 与浏览器访问地址一致
const url = await presignClient.presignedGetObject(
// 生成预签名 URL
const url = await minioClient.presignedGetObject(
BUCKET_NAME,
objectName,
expirySeconds,
@@ -293,7 +264,7 @@ export async function generatePresignedGetObject(
);
return {
url,
url: replaceUrlBase(url),
expiresIn: expirySeconds,
};
} catch (error) {

View File

@@ -12,7 +12,6 @@ import {
resetToCommit,
getCurrentBranch,
hasUncommittedChanges,
pushToRemote,
} from '@/server/utils/git-helper'
@@ -225,25 +224,6 @@ export const devPanelRouter = createTRPCRouter({
})
}
}),
/**
* 推送分支到远程仓库
*/
pushToRemote: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
.input(z.object({
branchName: z.string().min(1, '分支名称不能为空'),
}))
.mutation(async ({ input }) => {
try {
await pushToRemote(input.branchName)
return { success: true, message: `已推送分支 ${input.branchName} 到远程仓库` }
} catch (error: any) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `推送失败: ${error.message}`,
})
}
}),
})
export type DevPanelRouter = typeof devPanelRouter

View File

@@ -7,7 +7,6 @@ import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { inferProcedureOutput, TRPCError } from '@trpc/server'
import pLimit from 'p-limit'
import { invalidateUserSessions, invalidateSessionsByRoleId } from '@/server/service/session'
// 从环境变量获取并发限制默认为16
const dbParallelLimit = pLimit(parseInt(process.env.DB_PARALLEL_LIMIT || '16', 10))
@@ -117,7 +116,7 @@ export const usersRouter = createTRPCRouter({
throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已被其他角色使用' })
}
const updatedRole = await ctx.db.role.update({
return ctx.db.role.update({
where: { id },
data: {
name,
@@ -130,11 +129,6 @@ export const usersRouter = createTRPCRouter({
}
}
})
// 标记持有该角色的所有用户会话失效
await invalidateSessionsByRoleId(id)
return updatedRole
}),
deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
@@ -211,10 +205,7 @@ export const usersRouter = createTRPCRouter({
processedCount += batch.length
}
// 标记所有受影响用户的会话失效
await invalidateUserSessions(users.map(u => u.id))
return { count: processedCount }
}),
@@ -274,7 +265,7 @@ export const usersRouter = createTRPCRouter({
updateData.password = await bcrypt.hash(password, 12)
}
const updatedUser = await ctx.db.user.update({
return ctx.db.user.update({
where: { id },
data: updateData,
include: {
@@ -282,11 +273,6 @@ export const usersRouter = createTRPCRouter({
dept: true
}
})
// 标记用户会话失效,强制重新登录以获取最新权限
await invalidateUserSessions([id])
return updatedUser
}),
getById: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
@@ -314,9 +300,6 @@ export const usersRouter = createTRPCRouter({
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能删除自己的账户' })
}
// 标记用户会话失效
await invalidateUserSessions([id])
// 删除用户
return ctx.db.user.delete({
where: { id },

View File

@@ -1,89 +0,0 @@
import 'server-only'
import crypto from 'crypto'
const IAAA_VALIDATE_URL = 'https://iaaa.pku.edu.cn/iaaa/svc/token/validate.do'
export interface IaaaUserInfo {
name: string
status: string
identityId: string
deptId: string
dept: string
identityType: string
detailType: string
identityStatus: string
campus: string
}
interface IaaaValidateResult {
success: boolean
errCode: string
errMsg: string
userInfo?: IaaaUserInfo
}
/** 检查 IAAA 统一认证是否已配置启用 */
export function isIaaaEnabled(): boolean {
return !!(process.env.IAAA_APP_ID && process.env.IAAA_KEY)
}
/** 获取 IAAA 配置(仅返回前端需要的非敏感信息) */
export function getIaaaClientConfig() {
return {
enabled: isIaaaEnabled(),
appId: process.env.IAAA_APP_ID || '',
callbackPath: '/api/auth/iaaa/callback',
autoRedirect: process.env.IAAA_AUTO_REDIRECT === 'true',
}
}
/**
* 验证 IAAA Token
* @param token IAAA 回调携带的 token
* @param remoteAddr 用户真实 IP 地址
* @returns 验证成功返回用户信息,失败返回 null 并记录日志
*/
export async function validateIaaaToken(
token: string,
remoteAddr: string
): Promise<IaaaUserInfo | null> {
const appId = process.env.IAAA_APP_ID
const key = process.env.IAAA_KEY
if (!appId || !key) {
console.error('[IAAA] 环境变量 IAAA_APP_ID 或 IAAA_KEY 未配置')
return null
}
// 参数按字母序拼接appId, remoteAddr, token
const paraStr = `appId=${appId}&remoteAddr=${remoteAddr}&token=${token}`
const msgAbs = crypto.createHash('md5').update(paraStr + key).digest('hex')
const url = `${IAAA_VALIDATE_URL}?${paraStr}&msgAbs=${msgAbs}`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(10000) })
if (!resp.ok) {
console.error(`[IAAA] 验证请求失败: HTTP ${resp.status}`)
return null
}
const result: IaaaValidateResult = await resp.json()
if (!result.success) {
console.warn(`[IAAA] 认证失败: errCode=${result.errCode}, errMsg=${result.errMsg}`)
return null
}
const userInfo = result.userInfo
if (!userInfo?.identityId) {
console.warn('[IAAA] 认证成功但未返回 identityId')
return null
}
return userInfo
} catch (error) {
console.error('[IAAA] Token 验证请求异常:', error)
return null
}
}

View File

@@ -1,39 +0,0 @@
import 'server-only'
import { getRedisClient } from '@/server/redis'
import { db } from '@/server/db'
const SESSION_INVALID_PREFIX = 'session:invalid:'
const SESSION_INVALID_TTL = 30 * 24 * 60 * 60 // 30天与JWT maxAge一致
/** 标记指定用户的会话失效 */
export async function invalidateUserSessions(userIds: string[]) {
if (userIds.length === 0) return
const redis = getRedisClient()
const pipeline = redis.pipeline()
for (const id of userIds) {
pipeline.set(`${SESSION_INVALID_PREFIX}${id}`, '1', 'EX', SESSION_INVALID_TTL)
}
await pipeline.exec()
}
/** 检查用户会话是否已被标记失效 */
export async function isSessionInvalidated(userId: string): Promise<boolean> {
const redis = getRedisClient()
const result = await redis.exists(`${SESSION_INVALID_PREFIX}${userId}`)
return result === 1
}
/** 用户重新登录后清除失效标记 */
export async function clearSessionInvalidation(userId: string) {
const redis = getRedisClient()
await redis.del(`${SESSION_INVALID_PREFIX}${userId}`)
}
/** 标记某角色下所有用户的会话失效 */
export async function invalidateSessionsByRoleId(roleId: number) {
const users = await db.user.findMany({
where: { roles: { some: { id: roleId } } },
select: { id: true },
})
await invalidateUserSessions(users.map(u => u.id))
}

View File

@@ -632,19 +632,3 @@ export async function hasUncommittedChanges(): Promise<boolean> {
return false
}
}
/**
* 推送当前分支到远程仓库
* @param branchName 分支名称
*/
export async function pushToRemote(branchName: string): Promise<void> {
try {
await execAsync(`git push origin "${branchName}"`, {
cwd: process.cwd(),
encoding: 'utf-8',
})
} catch (error) {
console.error('推送到远程仓库失败:', error)
throw new Error(`无法推送到远程仓库: ${error instanceof Error ? error.message : String(error)}`)
}
}

View File

@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -23,19 +19,9 @@
}
],
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
".next-prod/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}