Commit 6cf83571 authored by uuo00_n's avatar uuo00_n

feat: 重构项目为微服务架构并添加教育服务模块

重构项目结构为微服务架构,新增教育服务模块(edu-service)及相关功能:
- 添加教育服务核心实体(学生、教师、班级、课表等)及DTO
- 实现教育服务API接口(人员管理、教师管理、班级管理、课表管理等)
- 配置教育服务数据库连接和JPA实体
- 添加Dockerfile和构建配置
- 更新相关文档和部署说明

同时调整原有LLM服务和认证服务结构:
- 将原单体的数据库、模型和服务拆分到对应微服务
- 更新各服务的Docker配置和依赖
- 添加跨服务通信支持
parent 708db1b1
# 数据库配置
# 注意:生产环境请通过安全的环境变量管理传递凭据,避免将敏感信息提交到版本库
# MONGODB_URL=mongodb://llm:fSjFMwyShmcH4GdR@datacenter.dldzxx.cn:27017/llm?authSource=llm
MONGODB_URL=mongodb://localhost:27017/
DB_NAME=llm_filter_db
# JWT配置
SECRET_KEY=your_secret_key_here
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# 学期配置
TERM_START_DATE=2025-09-01
# Ollama配置
OLLAMA_BASE_URL=http://datacenter.dldzxx.cn:11434/
OLLAMA_MODEL=deepseek-r1:14b
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="e4808b27-03d7-4f87-99c5-f280665e27ae" name="更改" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 1
}]]></component>
<component name="ProjectId" id="350mjP6N9bwIjvPg5Z0uWHsdDvk" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "main",
"last_opened_file_path": "/Users/uu/Desktop/dles_prj/llm-filter",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.pluginManager",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="RunManager">
<configuration default="true" type="DjangoTestsConfigurationType">
<module name="llm-filter" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="TARGET" value="" />
<option name="SETTINGS_FILE" value="" />
<option name="CUSTOM_SETTINGS" value="false" />
<option name="USE_OPTIONS" value="false" />
<option name="OPTIONS" value="" />
<method v="2" />
</configuration>
<configuration default="true" type="Python.FlaskServer">
<module name="llm-filter" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="launchJavascriptDebuger" value="false" />
<method v="2" />
</configuration>
<configuration default="true" type="docs" factoryName="Docutils task">
<module name="llm-filter" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="docutils_input_file" value="" />
<option name="docutils_output_file" value="" />
<option name="docutils_params" value="" />
<option name="docutils_task" value="" />
<option name="docutils_open_in_browser" value="false" />
<method v="2" />
</configuration>
<configuration default="true" type="docs" factoryName="Sphinx task">
<module name="llm-filter" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="docutils_input_file" value="" />
<option name="docutils_output_file" value="" />
<option name="docutils_params" value="" />
<option name="docutils_task" value="" />
<option name="docutils_open_in_browser" value="false" />
<method v="2" />
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-3aa1da707db6-JavaScript-PY-252.26830.99" />
<option value="bundled-python-sdk-164cda30dcd9-0af03a5fa574-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-252.26830.99" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="e4808b27-03d7-4f87-99c5-f280665e27ae" name="更改" comment="" />
<created>1762255498972</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1762255498972</updated>
<workItem from="1762255500391" duration="1060000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>
\ No newline at end of file
- 当后端项目修改后要更新重启docker容器
\ No newline at end of file
# 微服务架构重构方案
## 1. 现状分析
当前项目是一个基于 Python FastAPI 的单体应用,主要包含以下业务领域:
- **认证与用户管理**:用户注册、登录、角色权限。
- **教育教务管理**:学生、教师、班级、课表、人员档案、绑定关系。
- **LLM 对话与过滤**:对话管理、敏感词过滤、对接 Ollama。
- **数据统计**:仪表盘。
**痛点(为什么感觉乱):**
1. **控制层与数据层耦合**:API 路由函数中直接包含大量 MongoDB 查询逻辑(如 `app/api/v1/auth.py`),缺乏独立的 Service 层封装。
2. **业务边界模糊**`bindings`(绑定关系)逻辑穿插在 Auth 和各个实体模块中。
3. **单体膨胀风险**:教务逻辑(复杂的实体关系)与 AI 逻辑(无状态、流式、计算密集)混在一起,扩展性受限。
## 2. 拆分建议
建议将项目拆分为三个独立微服务,根据业务特性选择最适合的语言:
### 服务 A: 身份认证中心 (Identity Service)
- **职责**:用户注册、登录、JWT 签发与验证、权限管理 (RBAC)、绑定关系管理。
- **建议语言****Go** (Golang)
- **数据库****PostgreSQL**
- **理由**:用户账户、角色权限是典型的结构化数据,关系型数据库能提供更好的事务支持和数据一致性保障。
- **接管路由**`/auth`, `/admin` (部分用户管理), `/bindings`
### 服务 B: 教务核心服务 (Edu Core Service)
- **职责**:学生、教师、班级、课表、人员档案的 CRUD 与业务逻辑。
- **建议语言****Java (Spring Boot)**
- **数据库****PostgreSQL**
- **理由**:教务数据之间存在复杂的关联关系(如班级-学生、课程-教师),使用外键约束能有效维护数据完整性。Spring Data JPA 提供了强大的 ORM 支持。
- **接管路由**`/students`, `/teachers`, `/classes`, `/schedules`, `/persons`
### 服务 C: LLM 智能服务 (LLM Filter Service)
- **职责**:LLM 对话管理、敏感词过滤算法、Ollama 接口对接。
- **建议语言****Python** (保持现状)
- **数据库****PostgreSQL (JSONB)****MongoDB**
- **理由**:对话记录虽然是非结构化的,但 PostgreSQL 的 JSONB 性能非常强劲,且便于与用户表做关联查询。为了简化运维,推荐全栈统一使用 PostgreSQL。
- **接管路由**`/conversations`, `/admin` (敏感词管理)
### 服务 D: 聚合层 / 网关 (API Gateway / BFF)
- **职责**:统一入口,路由转发,或者聚合 Dashboard 数据。
- **实现**:Nginx, Kong, 或一个轻量级的 Node.js/Go 服务。
## 3. 架构图示
```mermaid
graph TD
Client[前端应用] --> Gateway[API 网关]
Gateway -->|/auth, /bindings| AuthSvc[身份认证服务 (Go)]
Gateway -->|/students, /classes| EduSvc[教务核心服务 (Go/Java)]
Gateway -->|/conversations| LLMSvc[LLM 智能服务 (Python)]
AuthSvc --> DB_PG[(PostgreSQL: AuthDB)]
EduSvc --> DB_PG[(PostgreSQL: EduDB)]
LLMSvc --> DB_PG[(PostgreSQL: LLMDB)]
LLMSvc --> Ollama[Ollama LLM]
```
## 4. 迁移路线 (Strangler Fig Pattern)
1. **第一步:提取公共依赖**
- 确定服务间通信协议(推荐 RESTful HTTP 或 gRPC)。
- 统一数据库设计(目前是共享 Mongo,微服务建议分库或分 Collection)。
2. **第二步:剥离身份认证 (Auth Service)**
- 优先用 Go 重写 `/auth` 模块。
- 网关将 `/auth` 流量转发至新服务。
- Python 单体保留 JWT 验证逻辑(作为中间件),信任 Go 服务签发的 Token。
3. **第三步:剥离教务业务**
-`/students` 等模块迁移至新服务。
4. **第四步:瘦身 Python 服务**
- 最后 Python 服务仅保留 LLM 相关核心业务,成为纯粹的 AI 服务。
## 5. 示例:用 Go 重构 Auth 服务
我们可以先尝试用 Go 重写 `register``login` 接口,展示微服务的结构。
# LLM Filter 更新日志
## [Unreleased] — 2025-12-08
- 新增
- 完善 API 文档与错误响应,统一 `summary/description/responses`(对话、学生、管理员、绑定、班级、课表、人物、仪表盘、认证)。
- 自定义 Swagger UI 通过 CDN 加载静态资源,提升可访问性。
- 添加 `APP_BASE_URL` 配置,在启动时打印文档地址。
- 修复
- 敏感词处理逻辑兼容新旧版本,修复记录中用户/对话 ID 类型转换问题。
- 仪表盘校园总览接口角色等级要求由 5 调整为 4。
- 考勤统计缺失课程 ID 等问题修复。
- 修复默认账号(user)无法访问学生信息的问题(自动补全学生实体)。
- 修复仪表盘接口中的数据类型错误(ObjectId 转换、datetime 序列化)。
- 重构
- 升级并适配 Pydantic v2,更新模型与设置读取方式(`pydantic-settings`)。
- 统一鉴权依赖与权限等级校验;账户与人物实体分离,绑定机制统一。
- 敏感词记录功能重构,增加详细敏感词信息与审计能力;CORS 允许源与凭证动态配置。
- 新增周课表查询功能(学生/教师),支持按周次过滤与开学日期配置。
- 文档
- 更新 README,补充实体化模型、接口说明、Windows PowerShell 快速启动指南与许可证信息。
- 在 OpenAPI 中增加 `version``contact`(邮箱:`huangjunbo1107@outlook.com`)、`license` 元信息。
- 运维与杂项
- 更新 OLLAMA 模型配置(示例:`deepseek-r1:14b`),清理编译缓存与完善 `.gitignore`
### 关联提交(摘要)
- 10e59f3 feat: 完善API文档和错误响应
- 2724a77 feat(配置): 添加APP_BASE_URL配置并显示API文档链接
- cbd3da7 refactor(models): 更新Pydantic模型配置以兼容v2版本
- 4711122 docs: 更新README文档内容与格式
- 7a1720e fix(conversation): 更新敏感词处理逻辑以兼容新旧版本
- adff7ae feat(core): 添加CORS和GitHub相关配置项
- 789fdcb feat(api): 对话路由统一响应模型,新增删除接口;列表负载优化
- 35d99c7 fix(dashboard): 将校园总览接口的角色等级要求从5级降至4级
- 5dbcb42 feat: 重构用户与实体模型,实现账户与人物分离
- 05ab73d feat(学生绑定): 实现用户与学生绑定功能及接口
## 1.0.0 — 2025-10-31
- 项目初始化:LLM 过滤系统后端(FastAPI + MongoDB + Ollama)。
- 敏感词分类与严重程度管理、管理员操作与审计追踪基础能力。
- README 与 MIT 许可证添加,基础安装与运行说明。
# 🚀 LLM Filter 项目开发与启动指南
本文档旨在帮助开发人员快速搭建环境、启动服务并参与开发。本项目采用微服务架构,包含 Auth (Go), Edu (Java), LLM (Python) 三大核心服务。
---
## 🏗️ 架构概览
| 服务名称 | 端口 | 技术栈 | 职责 | 目录 |
| :--- | :--- | :--- | :--- | :--- |
| **Auth Service** | **8081** | Go (Gin) | 用户认证、JWT、绑定管理 | `microservices/auth-service` |
| **Edu Service** | **8082** | Java (Spring Boot) | 学生、教师、课表、教务数据 | `microservices/edu-service` |
| **LLM Service** | **8000** | Python (FastAPI) | AI 对话、RAG、敏感词过滤 | `microservices/llm-service` |
| **Postgres** | 5433 | PostgreSQL 15 | 存储 Auth 和 Edu 数据 | (Docker) |
| **Mongo** | 27017 | MongoDB | 存储对话历史、敏感词库 | (Docker) |
---
## ⚡ 快速启动 (Docker 模式)
这是最简单的启动方式,适合预览或部署。
### 前置要求
- Docker & Docker Compose
- 根目录下已配置 `.env` 文件
### 启动命令
在项目根目录执行:
```bash
# 构建并后台启动所有服务
docker-compose up -d --build
# 查看运行日志
docker-compose logs -f
```
### 访问服务
- **Swagger 文档 (LLM)**: [http://localhost:8000/docs](http://localhost:8000/docs)
- **Auth API**: [http://localhost:8081/api/v1/auth/health](http://localhost:8081/api/v1/auth/health)
- **Edu API**: [http://localhost:8082/api/v1/edu/health](http://localhost:8082/api/v1/edu/health)
---
## 🛠️ 本地开发指南 (Local Development)
如果你需要修改代码,建议在本地分别启动服务。
### 1. 启动基础设施 (数据库)
首先确保数据库在运行。你可以只通过 Docker 启动 DB:
```bash
# 只启动 Postgres 和 Mongo
docker-compose up -d postgres mongo
```
### 2. 启动 Auth Service (Go)
**目录**: `microservices/auth-service`
```bash
cd microservices/auth-service
# 安装依赖
go mod tidy
# 运行服务 (确保本地 5433 端口可用)
# 注意:代码中已配置连接 localhost:5433
go run main.go
```
### 3. 启动 Edu Service (Java)
**目录**: `microservices/edu-service`
**要求**: JDK 1.8, Maven
```bash
cd microservices/edu-service
# 编译并运行 (指定 settings.xml 以使用阿里云镜像)
mvn -s settings.xml clean spring-boot:run
```
### 4. 启动 LLM Service (Python)
**目录**: `microservices/llm-service`
**要求**: Python 3.10+
```bash
cd microservices/llm-service
# 创建虚拟环境 (可选)
python -m venv venv
source venv/bin/activate # macOS/Linux
# venv\Scripts\activate # Windows
# 安装依赖
pip install -r requirements.txt
# 运行服务 (会自动向上查找根目录的 .env)
python main.py
```
---
## 🧪 接口测试流程
### 1. 注册与登录 (Auth Service)
所有操作都需要 Token。
```bash
# 1. 注册
curl -X POST http://localhost:8081/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "password": "password123", "email": "test@example.com"}'
# 2. 登录 (获取 Token)
curl -X POST http://localhost:8081/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "testuser", "password": "password123"}'
```
### 2. 教务数据操作 (Edu Service)
使用上一步获取的 Token (`Bearer <TOKEN>`)。
```bash
# 列出班级
curl http://localhost:8082/api/v1/classes \
-H "Authorization: Bearer <TOKEN>"
```
### 3. AI 对话 (LLM Service)
LLM Service 会自动调用 Auth Service 验证 Token。
```bash
# 发起对话
curl -X POST http://localhost:8000/api/v1/conversations/chat \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"message": "你好,请介绍一下你自己"}'
```
---
## 📂 目录结构说明
```text
/llm-filter
├── docker-compose.yml # 容器编排文件
├── .env # 全局环境变量 (数据库密码、密钥等)
└── microservices/ # 微服务源码目录
├── auth-service/ # [Go] 认证服务
├── edu-service/ # [Java] 教务服务
└── llm-service/ # [Python] LLM 核心服务
```
## ⚠️ 常见问题
1. **端口冲突**:如果 `5432` 被本地 Postgres 占用,Docker 会映射到 `5433`。代码中已默认适配 `5433`,如需修改请检查 `.env` 和各服务的配置文件。
2. **Maven 下载慢**:请使用项目提供的 `microservices/edu-service/settings.xml`,已配置阿里云镜像。
3. **Python 找不到 .env**`config.py` 已内置自动向上查找逻辑,确保在 `microservices/llm-service` 目录下运行即可。
This diff is collapsed.
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from app.core.config import settings
from app.db.mongodb import db
from app.schemas.user import UserCreate, UserResponse, Token
from app.services.auth import authenticate_user, create_access_token, get_password_hash
from app.models.role import get_role_level
router = APIRouter()
@router.post(
"/register",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
summary="用户注册",
description="创建基础账户(默认教育版、角色等级为1)。",
responses={
400: {"description": "用户名或邮箱已存在", "content": {"application/json": {"example": {"detail": "用户名已存在"}}}},
},
)
async def register(user_data: UserCreate):
"""注册新用户"""
# 检查用户名是否已存在
existing_user = await db.db.users.find_one({"username": user_data.username})
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="用户名已存在"
)
# 检查邮箱是否已存在
existing_email = await db.db.users.find_one({"email": user_data.email})
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="邮箱已被注册"
)
# 创建新用户
hashed_password = get_password_hash(user_data.password)
user = {
"username": user_data.username,
"email": user_data.email,
"hashed_password": hashed_password,
"role": "user", # 默认为普通用户
"role_level": 1, # 默认为 1 级
"edition": "edu", # 默认为教育版,可在后续管理员界面修改
}
result = await db.db.users.insert_one(user)
# 获取创建的用户
created_user = await db.db.users.find_one({"_id": result.inserted_id})
return {
"id": str(created_user["_id"]),
"username": created_user["username"],
"email": created_user["email"],
"role": created_user.get("role", "user"),
"role_level": created_user.get("role_level", 1),
"edition": created_user.get("edition", "edu"),
}
@router.post(
"/login",
response_model=Token,
summary="用户登录",
description="使用表单登录(username/password),返回令牌与用户/绑定信息。",
responses={
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "用户名或密码错误"}}}},
},
)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
"""用户登录"""
user = await authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
# 创建访问令牌(在载荷中加入用户的角色与版别信息)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={
"sub": str(user["_id"]),
"role": user.get("role", "user"),
"role_level": user.get("role_level", get_role_level(user.get("role"))),
"edition": user.get("edition", "edu"),
**({
"person_id": str(b["person_id"]),
"person_type": b.get("type"),
"bound_primary": True,
} if (b := await db.db.bindings.find_one({"account_id": user["_id"], "primary": True})) else {})
},
expires_delta=access_token_expires
)
# 登录响应中回传基础用户信息,前端免一次 /me 调用;后端仍应以服务端鉴权为准
return {
"access_token": access_token,
"token_type": "bearer",
"role": user.get("role", "user"),
"role_level": user.get("role_level", get_role_level(user.get("role"))),
"edition": user.get("edition", "edu"),
**({
"person_id": str(b["person_id"]),
"person_type": b.get("type"),
"bound_primary": True,
} if b else {})
}
from fastapi import APIRouter, Depends, HTTPException, Path
from pydantic import BaseModel
from typing import Optional
from app.api.deps import require_edition_for_mode, get_current_active_user, require_role
from app.services.bindings import create_binding, delete_binding, get_binding_by_account
router = APIRouter(dependencies=[Depends(require_edition_for_mode())])
class BindingPayload(BaseModel):
person_id: str
type: str
primary: bool = True
class ActionResult(BaseModel):
success: bool
class BindingOut(BaseModel):
_id: str
account_id: str
person_id: str
type: str
primary: bool
@router.post(
"",
summary="创建主绑定",
description="为当前账号创建人物主绑定(student/teacher),同类型主绑定唯一。",
response_model=ActionResult,
responses={
400: {"description": "主绑定已存在或参数错误", "content": {"application/json": {"example": {"detail": "主绑定已存在或参数错误"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
},
)
async def bind(payload: BindingPayload, current_user: dict = Depends(require_role(2))) -> ActionResult:
ok = await create_binding(str(current_user["_id"]), payload.person_id, payload.type, payload.primary)
if not ok:
raise HTTPException(status_code=400, detail="主绑定已存在或参数错误")
return {"success": True}
@router.delete(
"/{person_id}",
summary="删除绑定",
description="删除当前账号与指定人物的绑定。",
response_model=ActionResult,
responses={
404: {"description": "未找到绑定", "content": {"application/json": {"example": {"detail": "未找到绑定"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
},
)
async def unbind(person_id: str = Path(..., description="人物ID"), current_user: dict = Depends(require_role(2))) -> ActionResult:
ok = await delete_binding(str(current_user["_id"]), person_id)
if not ok:
raise HTTPException(status_code=404, detail="未找到绑定")
return {"success": True}
@router.get(
"/me",
summary="查询当前账号的主绑定",
description="返回当前账号的主绑定信息(account_id/person_id/type/primary)。",
response_model=BindingOut,
responses={
404: {"description": "未绑定人物", "content": {"application/json": {"example": {"detail": "未绑定人物"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
},
)
async def me(current_user: dict = Depends(get_current_active_user)) -> BindingOut:
b = await get_binding_by_account(str(current_user["_id"]))
if not b:
raise HTTPException(status_code=404, detail="未绑定人物")
b["_id"] = str(b["_id"])
b["account_id"] = str(b["account_id"])
b["person_id"] = str(b["person_id"])
return b
from fastapi import APIRouter, Depends, HTTPException, Path, Body
from pydantic import BaseModel
from typing import List, Optional
from bson import ObjectId
from app.api.deps import require_edition_for_mode, require_role
from app.db.mongodb import db
router = APIRouter(dependencies=[Depends(require_edition_for_mode())])
class HeadTeacherPayload(BaseModel):
head_teacher_person_id: str
class ActionResult(BaseModel):
success: bool
class ClassOut(BaseModel):
_id: Optional[str] = None
class_id: str
head_teacher_person_id: Optional[str] = None
students_count: Optional[int] = 0
@router.put(
"/{class_id}/head-teacher",
summary="设置班主任人物",
description="为班级设置 head_teacher_person_id(指向教师人物)。",
response_model=ActionResult,
responses={
404: {"description": "班级不存在", "content": {"application/json": {"example": {"detail": "班级不存在"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
403: {"description": "权限不足", "content": {"application/json": {"example": {"detail": "权限不足"}}}},
},
)
async def set_head_teacher(class_id: str = Path(..., description="班级ID"), payload: HeadTeacherPayload = Body(..., description="班主任人物设置"), current_user: dict = Depends(require_role(3))) -> ActionResult:
res = await db.db.classes.update_one({"class_id": class_id}, {"$set": {"head_teacher_person_id": ObjectId(payload.head_teacher_person_id)}})
if res.matched_count == 0:
raise HTTPException(status_code=404, detail="班级不存在")
return {"success": True}
@router.get(
"",
summary="列出班级",
description="按 class_id 排序列出班级信息(含 head_teacher_person_id)。",
response_model=List[ClassOut],
)
async def list_classes(current_user: dict = Depends(require_role(2))) -> List[ClassOut]:
res = []
cursor = db.db.classes.find({}).sort("class_id", 1)
async for d in cursor:
d["_id"] = str(d["_id"]) if d.get("_id") else None
if d.get("head_teacher_person_id"):
d["head_teacher_person_id"] = str(d["head_teacher_person_id"])
res.append(d)
return res
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel, Field
from typing import List, Dict
from bson import ObjectId
from app.api.deps import require_edition_for_mode, require_role
from app.db.mongodb import db
router = APIRouter(dependencies=[Depends(require_edition_for_mode())])
class PersonCreate(BaseModel):
person_id: str
name: str
type: str = Field(pattern="^(student|teacher|staff)$")
class BulkInsertResult(BaseModel):
inserted: int
class PersonOut(BaseModel):
_id: str
person_id: str
name: str
type: str
class PersonsListResponse(BaseModel):
items: List[PersonOut]
counts_by_type: Dict[str, int]
@router.post(
"/bulk",
summary="批量导入人物档案",
description="导入学生/教师/职员的基础人物信息(person_id/name/type)。",
response_model=BulkInsertResult,
responses={
400: {"description": "空数据", "content": {"application/json": {"example": {"detail": "空数据"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
403: {"description": "权限不足", "content": {"application/json": {"example": {"detail": "权限不足"}}}},
},
)
async def bulk_create(persons: List[PersonCreate] = Body(..., description="人物档案列表"), current_user: dict = Depends(require_role(3))) -> BulkInsertResult:
docs = [{"person_id": p.person_id, "name": p.name, "type": p.type} for p in persons]
if not docs:
raise HTTPException(status_code=400, detail="空数据")
await db.db.persons.insert_many(docs)
return {"inserted": len(docs)}
@router.get(
"",
summary="列出人物档案",
description="按 person_id 排序列出所有人物档案。",
response_model=PersonsListResponse,
)
async def list_persons(current_user: dict = Depends(require_role(3))) -> PersonsListResponse:
res = []
counts = {}
cursor = db.db.persons.find({}).sort("person_id", 1)
async for d in cursor:
d["_id"] = str(d["_id"])
res.append(d)
t = d.get("type")
if t:
counts[t] = counts.get(t, 0) + 1
return {"items": res, "counts_by_type": counts}
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
from bson import ObjectId
from app.api.deps import require_edition_for_mode, require_role
from app.db.mongodb import db
router = APIRouter(dependencies=[Depends(require_edition_for_mode())])
class AssignTeacherPayload(BaseModel):
lesson_id: str
teacher_person_id: str
class ActionResult(BaseModel):
success: bool
class ScheduleOut(BaseModel):
lesson_id: Optional[str]
weekday: Optional[int]
period: Optional[int]
course_name: Optional[str]
teacher_person_id: Optional[str]
classes: List[Dict[str, Any]]
@router.put(
"/assign-teacher",
summary="为节次设置任课教师人物",
description="将指定 lesson_id 的节次设置为 teacher_person_id(教师人物)。",
response_model=ActionResult,
responses={
404: {"description": "节次不存在", "content": {"application/json": {"example": {"detail": "节次不存在"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
403: {"description": "权限不足", "content": {"application/json": {"example": {"detail": "权限不足"}}}},
},
)
async def assign_teacher(payload: AssignTeacherPayload = Body(..., description="节次与教师人物"), current_user: dict = Depends(require_role(3))) -> ActionResult:
res = await db.db.schedules.update_one({"lesson_id": payload.lesson_id}, {"$set": {"teacher_person_id": ObjectId(payload.teacher_person_id)}})
if res.matched_count == 0:
raise HTTPException(status_code=404, detail="节次不存在")
return {"success": True}
@router.get(
"",
summary="列出课表节次",
description="按工作日与节次排序列出共享节次(含各班 location 与 teacher_person_id)。",
response_model=List[ScheduleOut],
)
async def list_schedules(current_user: dict = Depends(require_role(2))) -> List[ScheduleOut]:
res = []
cursor = db.db.schedules.find({}).sort([("weekday", 1), ("period", 1)])
async for d in cursor:
if d.get("teacher_person_id"):
d["teacher_person_id"] = str(d["teacher_person_id"])
res.append({
"lesson_id": d.get("lesson_id"),
"weekday": d.get("weekday"),
"period": d.get("period"),
"course_name": d.get("course_name"),
"teacher_person_id": d.get("teacher_person_id"),
"classes": d.get("classes", []),
})
return res
from fastapi import APIRouter, Depends, HTTPException, Path, Body
from pydantic import BaseModel
from typing import Optional, Dict, Any
from app.api.deps import require_edition_for_mode, require_role
from app.services.student_binding import bind_user_to_student, unbind_user_from_student
from app.services.dashboard import _get_student_by_user
router = APIRouter(dependencies=[Depends(require_edition_for_mode())])
class BindPayload(BaseModel):
user_id: str
class ActionResult(BaseModel):
success: bool
class StudentOut(BaseModel):
_id: str
student_id: Optional[str] = None
name: Optional[str] = None
class_id: Optional[str] = None
@router.post(
"/{student_id}/bind",
response_model=ActionResult,
summary="绑定学生",
description="为指定学生建立主绑定(需角色等级≥2)。",
responses={
400: {"description": "绑定失败或学生不存在", "content": {"application/json": {"example": {"detail": "用户已绑定其他学生或学生不存在"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
},
)
async def bind(student_id: str = Path(..., description="学生ID"), payload: BindPayload = Body(..., description="绑定参数"), current_user: dict = Depends(require_role(2))) -> ActionResult:
ok = await bind_user_to_student(payload.user_id, student_id)
if not ok:
raise HTTPException(status_code=400, detail="用户已绑定其他学生或学生不存在")
return {"success": True}
@router.delete(
"/{student_id}/bind",
response_model=ActionResult,
summary="解除学生绑定",
description="解除当前用户与指定学生的主绑定(需角色等级≥2)。",
responses={
404: {"description": "未找到或未绑定", "content": {"application/json": {"example": {"detail": "学生不存在或未绑定"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
},
)
async def unbind(student_id: str = Path(..., description="学生ID"), current_user: dict = Depends(require_role(2))) -> ActionResult:
ok = await unbind_user_from_student(student_id)
if not ok:
raise HTTPException(status_code=404, detail="学生不存在或未绑定")
return {"success": True}
@router.get(
"/me",
response_model=StudentOut,
summary="查询当前绑定学生",
description="返回当前用户绑定的学生信息(需角色等级≥1)。",
responses={
404: {"description": "未绑定学生", "content": {"application/json": {"example": {"detail": "当前用户未绑定学生"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
},
)
async def me(current_user: dict = Depends(require_role(1))) -> StudentOut:
try:
s = await _get_student_by_user(current_user["_id"])
if not s:
raise HTTPException(status_code=404, detail="当前用户未绑定学生")
s["_id"] = str(s["_id"])
return s
except HTTPException as e:
if e.status_code == 404:
raise HTTPException(status_code=404, detail="当前用户未绑定学生")
raise e
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel
from typing import List, Optional
from bson import ObjectId
from app.api.deps import require_edition_for_mode, require_role
from app.db.mongodb import db
router = APIRouter(dependencies=[Depends(require_edition_for_mode())])
class TeacherCreate(BaseModel):
person_id: str
teacher_id: str
department: str
roles: List[str]
account_id: Optional[str] = None
class BulkInsertResult(BaseModel):
inserted: int
class TeacherOut(BaseModel):
_id: str
person_id: str
teacher_id: str
department: str
roles: List[str]
account_id: Optional[str] = None
@router.post(
"/bulk",
summary="批量导入教师实体",
description="导入教师实体(person_id/teacher_id/department/roles),可选绑定 account_id。",
response_model=BulkInsertResult,
responses={
400: {"description": "空数据", "content": {"application/json": {"example": {"detail": "空数据"}}}},
401: {"description": "认证失败", "content": {"application/json": {"example": {"detail": "无效的认证凭据"}}}},
403: {"description": "权限不足", "content": {"application/json": {"example": {"detail": "权限不足"}}}},
},
)
async def bulk_create(teachers: List[TeacherCreate] = Body(..., description="教师实体列表"), current_user: dict = Depends(require_role(3))) -> BulkInsertResult:
docs = []
for t in teachers:
doc = {
"person_id": ObjectId(t.person_id),
"teacher_id": t.teacher_id,
"department": t.department,
"roles": t.roles,
}
if t.account_id:
doc["account_id"] = ObjectId(t.account_id)
docs.append(doc)
if not docs:
raise HTTPException(status_code=400, detail="空数据")
await db.db.teachers.insert_many(docs)
return {"inserted": len(docs)}
@router.get(
"",
summary="列出教师实体",
description="按 teacher_id 排序列出所有教师实体(包含 person_id/account_id)。",
response_model=List[TeacherOut],
)
async def list_teachers(current_user: dict = Depends(require_role(3))) -> List[TeacherOut]:
res = []
cursor = db.db.teachers.find({}).sort("teacher_id", 1)
async for d in cursor:
d["_id"] = str(d["_id"])
d["person_id"] = str(d["person_id"])
if d.get("account_id"):
d["account_id"] = str(d["account_id"])
res.append(d)
return res
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field, ConfigDict, field_serializer
from bson import ObjectId
from pydantic_core import core_schema
# 自定义ObjectId字段
class PyObjectId(ObjectId):
@classmethod
def __get_pydantic_core_schema__(cls, source_type, handler):
def validate(v):
if isinstance(v, ObjectId):
return v
if ObjectId.is_valid(v):
return ObjectId(v)
raise ValueError("无效的ObjectId")
return core_schema.no_info_plain_validator_function(validate)
@classmethod
def __get_pydantic_json_schema__(cls, core_schema_, handler):
json_schema = handler(core_schema_)
json_schema.update({"type": "string"})
return json_schema
# 用户模型(修复缩进错误:确保为顶层类定义)
class UserModel(BaseModel):
# MongoDB 主键,使用别名 _id,序列化时转换为字符串
id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
# 用户名
username: str
# 邮箱(此处仅为字符串,外层使用 EmailStr 的 Schema 做校验)
email: str
# 哈希后的密码
hashed_password: str
# 角色字符串(兼容历史中的 "admin")。建议与 app/models/role.py 中的枚举保持一致
role: str = "user"
# 角色等级(1~5),用于统一的权限判断
role_level: int = 1
# 版别:"edu"(教育版)或 "biz"(企业版)
edition: str = "edu"
# 创建与更新时间
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
model_config = ConfigDict(
populate_by_name=True,
arbitrary_types_allowed=True,
json_schema_extra={
"example": {
"username": "user1",
"email": "user1@example.com",
"hashed_password": "hashed_password_here",
"role": "user",
"role_level": 1,
"edition": "edu",
}
},
)
@field_serializer("id", when_used="json")
def serialize_id(self, v: ObjectId):
return str(v)
from typing import Optional, Literal
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserLogin(BaseModel):
username: str
password: str
class UserResponse(BaseModel):
id: str
username: str
email: str
# 角色采用 Literal 强校验,同时兼容历史中的 "admin"
role: Literal["user", "manager", "leader", "master", "administrator", "admin"]
# 新增:角色等级,便于前端快速展示权限范围
role_level: int
# 版别采用 Literal 强校验:教育版/企业版
edition: Literal["edu", "biz"]
class Token(BaseModel):
access_token: str = Field(description="访问令牌(JWT)")
token_type: str = Field(description="令牌类型,固定为 bearer")
# 扩展:在登录响应中返回关键用户属性,减少额外查询(也可单独提供 /me 接口)
# 使用 Literal 限定角色取值范围,保持与 UserResponse 一致
role: Optional[Literal["user", "manager", "leader", "master", "administrator", "admin"]] = Field(default=None, description="系统角色")
role_level: Optional[int] = Field(default=None, description="角色等级(1~5)")
# 使用 Literal 限定版别取值范围
edition: Optional[Literal["edu", "biz"]] = Field(default=None, description="版别(教育/企业)")
# 绑定信息(实体化)
person_id: Optional[str] = Field(default=None, description="主绑定的人物ID(persons._id)")
person_type: Optional[Literal["student", "teacher", "staff"]] = Field(default=None, description="主绑定人物类型")
bound_primary: Optional[bool] = Field(default=None, description="是否存在主绑定")
class TokenData(BaseModel):
user_id: Optional[str] = None
# TokenData 也保持与 Token 一致的角色限定,便于类型安全
role: Optional[Literal["user", "manager", "leader", "master", "administrator", "admin"]] = None
role_level: Optional[int] = None
edition: Optional[Literal["edu", "biz"]] = None
\ No newline at end of file
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
from app.db.mongodb import db
from bson import ObjectId
from app.models.role import get_role_level
# 密码加密上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
"""验证密码"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
"""获取密码哈希值"""
return pwd_context.hash(password)
async def authenticate_user(username: str, password: str):
"""验证用户"""
user = await db.db.users.find_one({"username": username})
if not user:
return False
if not verify_password(password, user["hashed_password"]):
return False
return user
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""创建访问令牌
关键点:
- 在 JWT 载荷中加入角色与版别信息,便于前端解码后快速展示;
- 仍以服务端数据库查询为准,避免客户端伪造带来的安全问题。
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
async def get_current_user(token: str):
"""获取当前用户"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id = payload.get("sub")
if user_id is None:
return None
user = await db.db.users.find_one({"_id": ObjectId(user_id)})
if user is None:
return None
# 兼容兜底:若用户数据缺少角色等级或版别,则进行补充
if user.get("role_level") is None:
user["role_level"] = get_role_level(user.get("role"))
if user.get("edition") is None:
user["edition"] = "edu" # 默认版别为教育版
return user
except JWTError:
return None
\ No newline at end of file
from typing import Optional, Dict
from bson import ObjectId
from app.db.mongodb import db
async def create_binding(account_id: str, person_id: str, type_: str, primary: bool = True) -> bool:
aid = ObjectId(account_id)
pid = ObjectId(person_id)
if primary:
existing = await db.db.bindings.find_one({"account_id": aid, "type": type_, "primary": True})
if existing:
return False
res = await db.db.bindings.insert_one({"account_id": aid, "person_id": pid, "type": type_, "primary": primary})
return bool(res.inserted_id)
async def delete_binding(account_id: str, person_id: str) -> bool:
aid = ObjectId(account_id)
pid = ObjectId(person_id)
res = await db.db.bindings.delete_one({"account_id": aid, "person_id": pid})
return res.deleted_count == 1
async def get_binding_by_account(account_id: str) -> Optional[Dict]:
aid = ObjectId(account_id)
return await db.db.bindings.find_one({"account_id": aid, "primary": True})
\ No newline at end of file
from bson import ObjectId
from app.db.mongodb import db
async def bind_user_to_student(user_id: str, student_id: str) -> bool:
uid = ObjectId(user_id)
exists = await db.db.students.find_one({"user_id": uid})
if exists:
return False
res = await db.db.students.update_one({"student_id": student_id}, {"$set": {"user_id": uid}})
return res.modified_count == 1
async def unbind_user_from_student(student_id: str) -> bool:
res = await db.db.students.update_one({"student_id": student_id}, {"$unset": {"user_id": ""}})
return res.modified_count == 1
async def get_student_by_user(user_id: str):
return await db.db.students.find_one({"user_id": ObjectId(user_id)})
\ No newline at end of file
version: '3.8'
services:
# 数据库服务
postgres:
image: postgres:15-alpine
container_name: llm-filter-db
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: password
POSTGRES_DB: llm_filter_db
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- llm-network
# 身份认证服务 (Go)
auth-service:
build:
context: ./microservices/auth-service
dockerfile: Dockerfile
container_name: llm-filter-auth
ports:
- "8081:8081"
environment:
- DB_HOST=postgres
- DB_USER=admin
- DB_PASSWORD=password
- DB_NAME=llm_filter_db
- DB_PORT=5432
- JWT_SECRET=llm_filter_secure_secret_key_2025_update_must_be_32_bytes
depends_on:
- postgres
networks:
- llm-network
# 教务核心服务 (Go)
edu-service:
build:
context: ./microservices/edu-service
dockerfile: Dockerfile
container_name: llm-filter-edu
ports:
- "8082:8082"
environment:
- DB_HOST=postgres
- DB_USER=admin
- DB_PASSWORD=password
- DB_NAME=llm_filter_db
- DB_PORT=5432
- JWT_SECRET=llm_filter_secure_secret_key_2025_update_must_be_32_bytes
depends_on:
- postgres
networks:
- llm-network
# LLM 服务 (Python)
llm-service:
build:
context: ./microservices/llm-service
dockerfile: Dockerfile
container_name: llm-filter-llm
ports:
- "8000:8000"
volumes:
- ./microservices/llm-service:/app
environment:
- MONGODB_URL=mongodb://mongo:27017
- DB_NAME=llm_filter_db
- SECRET_KEY=llm_filter_secure_secret_key_2025_update_must_be_32_bytes
- ALGORITHM=HS256
- ACCESS_TOKEN_EXPIRE_MINUTES=30
- TERM_START_DATE=2025-09-01
- OLLAMA_BASE_URL=http://datacenter.dldzxx.cn:11434/
- OLLAMA_MODEL=deepseek-r1:14b
depends_on:
- mongo
networks:
- llm-network
# MongoDB 服务 (LLM 服务依赖)
mongo:
image: mongo:latest
container_name: llm-filter-mongo
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
networks:
- llm-network
# API 网关 (Nginx)
gateway:
image: nginx:alpine
container_name: llm-filter-gateway
ports:
- "8080:80"
volumes:
- ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- auth-service
- edu-service
- llm-service
networks:
- llm-network
networks:
llm-network:
driver: bridge
volumes:
postgres_data:
mongo_data:
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/json;
# 开启 gzip
gzip on;
gzip_types text/plain application/xml text/css application/javascript application/json;
upstream auth_service {
server auth-service:8081;
}
upstream edu_service {
server edu-service:8082;
}
upstream llm_service {
server llm-service:8000;
}
server {
listen 80;
server_name localhost;
# 允许跨域
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
if ($request_method = 'OPTIONS') {
return 204;
}
# Auth Service 路由
location /api/v1/auth {
proxy_pass http://auth_service/api/v1/auth;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/v1/bindings {
proxy_pass http://auth_service/api/v1/bindings;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Auth Service Swagger UI
location /swagger/ {
proxy_pass http://auth_service/swagger/;
proxy_set_header Host $host;
}
# Edu Service 路由
location /api/v1/edu {
proxy_pass http://edu_service/api/v1/edu;
proxy_set_header Host $host;
}
location /api/v1/classes {
proxy_pass http://edu_service/api/v1/classes;
proxy_set_header Host $host;
}
location /api/v1/persons {
proxy_pass http://edu_service/api/v1/persons;
proxy_set_header Host $host;
}
location /api/v1/teachers {
proxy_pass http://edu_service/api/v1/teachers;
proxy_set_header Host $host;
}
location /api/v1/schedules {
proxy_pass http://edu_service/api/v1/schedules;
proxy_set_header Host $host;
}
# Edu Service Swagger UI
location /swagger-ui/ {
proxy_pass http://edu_service/swagger-ui/;
proxy_set_header Host $host;
}
location /swagger-ui.html {
proxy_pass http://edu_service/swagger-ui.html;
proxy_set_header Host $host;
}
location /v3/api-docs {
proxy_pass http://edu_service/v3/api-docs;
proxy_set_header Host $host;
}
# LLM Service 路由 (默认/兜底)
# 包括 /docs, /openapi.json, /api/v1/chat, /api/v1/admin 等
location / {
proxy_pass http://llm_service/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
This diff is collapsed.
FROM golang:1.24-alpine
WORKDIR /app
# 设置 Go 代理
ENV GOPROXY=https://goproxy.cn,direct
# 预下载依赖,利用 Docker 缓存层
COPY go.mod go.sum ./
RUN go mod download
# 复制源代码
COPY . .
# 整理依赖(确保新加的 swaggo 依赖被正确拉取)
RUN go mod tidy
# 编译
RUN go build -o auth-service .
# 运行时暴露端口
EXPOSE 8081
# 启动服务
CMD ["./auth-service"]
This diff is collapsed.
This diff is collapsed.
basePath: /api/v1
definitions:
handler.BindingRequest:
properties:
person_id:
type: string
primary:
type: boolean
type:
type: string
required:
- person_id
- type
type: object
handler.LoginRequest:
properties:
password:
type: string
username:
type: string
required:
- password
- username
type: object
handler.RegisterRequest:
properties:
email:
type: string
password:
minLength: 6
type: string
username:
type: string
required:
- email
- password
- username
type: object
host: localhost:8080
info:
contact: {}
description: LLM Filter System Authentication Service API
title: Auth Service API
version: "1.0"
paths:
/auth/login:
post:
consumes:
- application/json
description: 使用用户名和密码登录
parameters:
- description: 登录凭证
in: body
name: credentials
required: true
schema:
$ref: '#/definitions/handler.LoginRequest'
produces:
- application/json
responses:
"200":
description: 登录成功
schema:
additionalProperties: true
type: object
"400":
description: 请求参数错误
schema:
additionalProperties:
type: string
type: object
"401":
description: 用户名或密码错误
schema:
additionalProperties:
type: string
type: object
summary: 用户登录
tags:
- 认证管理
/auth/register:
post:
consumes:
- application/json
description: 注册新用户
parameters:
- description: 注册信息
in: body
name: details
required: true
schema:
$ref: '#/definitions/handler.RegisterRequest'
produces:
- application/json
responses:
"200":
description: 注册成功
schema:
additionalProperties: true
type: object
"400":
description: 请求参数错误或用户已存在
schema:
additionalProperties:
type: string
type: object
summary: 用户注册
tags:
- 认证管理
/bindings:
post:
consumes:
- application/json
description: 将当前用户绑定到学生或教师身份
parameters:
- description: 绑定信息
in: body
name: binding
required: true
schema:
$ref: '#/definitions/handler.BindingRequest'
produces:
- application/json
responses:
"200":
description: 绑定成功
schema:
additionalProperties: true
type: object
"400":
description: 请求参数错误
schema:
additionalProperties:
type: string
type: object
"401":
description: 未授权
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: 创建用户绑定
tags:
- 绑定管理
/bindings/{person_id}:
delete:
consumes:
- application/json
description: 解除当前用户的指定身份绑定
parameters:
- description: 人员ID
in: path
name: person_id
required: true
type: string
produces:
- application/json
responses:
"200":
description: 解绑成功
schema:
additionalProperties: true
type: object
"400":
description: 请求参数错误
schema:
additionalProperties:
type: string
type: object
"401":
description: 未授权
schema:
additionalProperties:
type: string
type: object
"500":
description: 服务器内部错误
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: 解除用户绑定
tags:
- 绑定管理
/bindings/me:
get:
consumes:
- application/json
description: 获取当前登录用户的主身份绑定信息
produces:
- application/json
responses:
"200":
description: 获取成功
schema:
additionalProperties: true
type: object
"401":
description: 未授权
schema:
additionalProperties:
type: string
type: object
"404":
description: 未找到主绑定
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: 获取我的主绑定
tags:
- 绑定管理
securityDefinitions:
BearerAuth:
in: header
name: Authorization
type: apiKey
swagger: "2.0"
module auth-service
go 1.24.3
require github.com/gin-gonic/gin v1.11.0
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
)
This diff is collapsed.
package handler
import (
"auth-service/internal/service"
"net/http"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
svc *service.AuthService
}
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
return &AuthHandler{svc: svc}
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type RegisterRequest struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
// Login 处理用户登录
// @Summary 用户登录
// @Description 使用用户名和密码登录
// @Tags 认证管理
// @Accept json
// @Produce json
// @Param credentials body LoginRequest true "登录凭证"
// @Success 200 {object} map[string]interface{} "登录成功"
// @Failure 400 {object} map[string]string "请求参数错误"
// @Failure 401 {object} map[string]string "用户名或密码错误"
// @Router /auth/login [post]
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
token, user, err := h.svc.Login(&service.LoginRequest{
Username: req.Username,
Password: req.Password,
})
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user,
})
}
// Register 处理用户注册
// @Summary 用户注册
// @Description 注册新用户
// @Tags 认证管理
// @Accept json
// @Produce json
// @Param details body RegisterRequest true "注册信息"
// @Success 200 {object} map[string]interface{} "注册成功"
// @Failure 400 {object} map[string]string "请求参数错误或用户已存在"
// @Router /auth/register [post]
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.svc.Register(&service.RegisterRequest{
Username: req.Username,
Email: req.Email,
Password: req.Password,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "User registered successfully",
"user": user,
})
}
package handler
import (
"auth-service/internal/service"
"net/http"
"github.com/gin-gonic/gin"
)
type BindingHandler struct {
svc *service.BindingService
}
func NewBindingHandler(svc *service.BindingService) *BindingHandler {
return &BindingHandler{svc: svc}
}
type BindingRequest struct {
PersonID string `json:"person_id" binding:"required"`
Type string `json:"type" binding:"required"`
Primary bool `json:"primary"`
}
// Bind 创建绑定
// @Summary 创建用户绑定
// @Description 将当前用户绑定到学生或教师身份
// @Tags 绑定管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param binding body BindingRequest true "绑定信息"
// @Success 200 {object} map[string]interface{} "绑定成功"
// @Failure 400 {object} map[string]string "请求参数错误"
// @Failure 401 {object} map[string]string "未授权"
// @Router /bindings [post]
func (h *BindingHandler) Bind(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var req BindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 默认 primary 为 true,如果前端传了 false 则为 false
// 注意:BindJSON 会将未传的 bool 设为 false,这里我们需要明确业务逻辑
// Python 代码中: primary: bool = True (default)
// 这里 Go 的 struct 如果不传默认是 false。
// 为了兼容,我们可以认为如果不传,默认应该不是强制 true,而是看前端传什么。
// 但 Python 代码明确 default=True。
// 这里简单处理:如果前端没传 primary,Go 默认 false。如果业务需要默认 true,需要指针或者自定义 Unmarshal。
// 鉴于这是一个微服务重构,我们假设前端会明确传递参数,或者我们暂且按 Go 的默认值 false 处理,
// 如果需要默认 true,可以在 service 层处理,或者这里手动判断。
// 让我们查看 Python 代码: `primary: bool = True` in Pydantic.
// 既然 Python 默认是 True,那我们在 Go 里也应该尽量保持一致,或者让前端显式传。
// 为了简单,我们暂且信任前端传参。
err := h.svc.CreateBinding(&service.CreateBindingRequest{
UserID: userID.(uint),
PersonID: req.PersonID,
Type: req.Type,
Primary: req.Primary, // 注意这里如果没传就是 false
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// Unbind 删除绑定
// @Summary 解除用户绑定
// @Description 解除当前用户的指定身份绑定
// @Tags 绑定管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param person_id path string true "人员ID"
// @Success 200 {object} map[string]interface{} "解绑成功"
// @Failure 400 {object} map[string]string "请求参数错误"
// @Failure 401 {object} map[string]string "未授权"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Router /bindings/{person_id} [delete]
func (h *BindingHandler) Unbind(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
personID := c.Param("person_id")
if personID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "person_id is required"})
return
}
if err := h.svc.Unbind(userID.(uint), personID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// Me 获取当前用户的主绑定
// @Summary 获取我的主绑定
// @Description 获取当前登录用户的主身份绑定信息
// @Tags 绑定管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{} "获取成功"
// @Failure 401 {object} map[string]string "未授权"
// @Failure 404 {object} map[string]string "未找到主绑定"
// @Router /bindings/me [get]
func (h *BindingHandler) Me(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
binding, err := h.svc.GetPrimaryBinding(userID.(uint))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "No primary binding found"})
return
}
c.JSON(http.StatusOK, binding)
}
package model
import (
"time"
"gorm.io/gorm"
)
type Binding struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"` // 对应 account_id
PersonID string `gorm:"index;not null" json:"person_id"`
Type string `gorm:"type:varchar(20);not null" json:"type"` // student, teacher, staff
Primary bool `gorm:"default:false" json:"primary"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
package model
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;not null" json:"username"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
Password string `gorm:"not null" json:"-"` // 不在 JSON 中返回密码
Role string `gorm:"default:'user'" json:"role"`
RoleLevel int `gorm:"default:1" json:"role_level"`
Edition string `gorm:"default:'edu'" json:"edition"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
package repository
import (
"auth-service/internal/model"
"gorm.io/gorm"
)
type BindingRepository struct {
db *gorm.DB
}
func NewBindingRepository(db *gorm.DB) *BindingRepository {
return &BindingRepository{db: db}
}
// Create 创建绑定
func (r *BindingRepository) Create(binding *model.Binding) error {
return r.db.Create(binding).Error
}
// Delete 删除绑定
func (r *BindingRepository) Delete(userID uint, personID string) error {
return r.db.Where("user_id = ? AND person_id = ?", userID, personID).Delete(&model.Binding{}).Error
}
// FindPrimaryByUserID 查找用户的主绑定
func (r *BindingRepository) FindPrimaryByUserID(userID uint) (*model.Binding, error) {
var binding model.Binding
err := r.db.Where("user_id = ? AND \"primary\" = ?", userID, true).First(&binding).Error
if err != nil {
return nil, err
}
return &binding, nil
}
// FindPrimaryByUserIDAndType 查找用户指定类型的主绑定
func (r *BindingRepository) FindPrimaryByUserIDAndType(userID uint, bindingType string) (*model.Binding, error) {
var binding model.Binding
err := r.db.Where("user_id = ? AND type = ? AND \"primary\" = ?", userID, bindingType, true).First(&binding).Error
if err != nil {
return nil, err
}
return &binding, nil
}
// ExistsPrimary 检查是否存在主绑定
func (r *BindingRepository) ExistsPrimary(userID uint, bindingType string) (bool, error) {
var count int64
err := r.db.Model(&model.Binding{}).
Where("user_id = ? AND type = ? AND \"primary\" = ?", userID, bindingType, true).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
package repository
import (
"auth-service/internal/model"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(user *model.User) error {
return r.db.Create(user).Error
}
func (r *UserRepository) FindByUsername(username string) (*model.User, error) {
var user model.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) FindByEmail(email string) (*model.User, error) {
var user model.User
err := r.db.Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) ExistsByUsername(username string) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *UserRepository) ExistsByEmail(email string) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).Where("email = ?", email).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
package service
import (
"auth-service/internal/model"
"auth-service/internal/repository"
"auth-service/pkg/utils"
"errors"
)
type AuthService struct {
repo *repository.UserRepository
}
func NewAuthService(repo *repository.UserRepository) *AuthService {
return &AuthService{repo: repo}
}
// RegisterRequest 注册请求参数
type RegisterRequest struct {
Username string
Email string
Password string
}
// LoginRequest 登录请求参数
type LoginRequest struct {
Username string
Password string
}
// Register 用户注册业务逻辑
func (s *AuthService) Register(req *RegisterRequest) (*model.User, error) {
// 检查用户名是否存在
exists, err := s.repo.ExistsByUsername(req.Username)
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("username already exists")
}
// 检查邮箱是否存在
exists, err = s.repo.ExistsByEmail(req.Email)
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("email already registered")
}
// 密码加密
hashedPwd, err := utils.HashPassword(req.Password)
if err != nil {
return nil, err
}
// 创建用户
user := &model.User{
Username: req.Username,
Email: req.Email,
Password: hashedPwd,
Role: "user",
Edition: "edu",
}
if err := s.repo.Create(user); err != nil {
return nil, err
}
return user, nil
}
// Login 用户登录业务逻辑
func (s *AuthService) Login(req *LoginRequest) (string, *model.User, error) {
user, err := s.repo.FindByUsername(req.Username)
if err != nil {
return "", nil, errors.New("invalid username or password")
}
if !utils.CheckPasswordHash(req.Password, user.Password) {
return "", nil, errors.New("invalid username or password")
}
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
if err != nil {
return "", nil, err
}
return token, user, nil
}
package service
import (
"auth-service/internal/model"
"auth-service/internal/repository"
"errors"
)
type BindingService struct {
repo *repository.BindingRepository
}
func NewBindingService(repo *repository.BindingRepository) *BindingService {
return &BindingService{repo: repo}
}
type CreateBindingRequest struct {
UserID uint
PersonID string
Type string
Primary bool
}
// CreateBinding 创建绑定
func (s *BindingService) CreateBinding(req *CreateBindingRequest) error {
// 如果是主绑定,检查是否已存在同类型的主绑定
if req.Primary {
exists, err := s.repo.ExistsPrimary(req.UserID, req.Type)
if err != nil {
return err
}
if exists {
return errors.New("primary binding of this type already exists")
}
}
binding := &model.Binding{
UserID: req.UserID,
PersonID: req.PersonID,
Type: req.Type,
Primary: req.Primary,
}
return s.repo.Create(binding)
}
// Unbind 删除绑定
func (s *BindingService) Unbind(userID uint, personID string) error {
// TODO: 可以在这里检查是否存在,或者直接删除(幂等)
// GORM 的 Delete 如果没找到记录不会报错,只会返回 RowsAffected=0
// 这里简单直接调用 Delete
return s.repo.Delete(userID, personID)
}
// GetPrimaryBinding 获取用户的主绑定
func (s *BindingService) GetPrimaryBinding(userID uint) (*model.Binding, error) {
return s.repo.FindPrimaryByUserID(userID)
}
package main
import (
"auth-service/internal/handler"
"auth-service/internal/model"
"auth-service/internal/repository"
"auth-service/internal/service"
"auth-service/pkg/middleware"
"auth-service/pkg/utils"
"fmt"
"log"
"os"
_ "auth-service/docs" // docs is generated by Swag CLI
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// @title Auth Service API
// @version 1.0
// @description LLM Filter System Authentication Service API
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
func main() {
// 获取环境变量配置
dbHost := os.Getenv("DB_HOST")
dbUser := os.Getenv("DB_USER")
dbPassword := os.Getenv("DB_PASSWORD")
dbName := os.Getenv("DB_NAME")
dbPort := os.Getenv("DB_PORT")
// 默认值处理(用于本地开发)
if dbHost == "" {
dbHost = "localhost"
}
if dbUser == "" {
dbUser = "admin"
}
if dbPassword == "" {
dbPassword = "password" // 请确保本地有此密码的数据库或修改此处
}
if dbName == "" {
dbName = "llm_filter_db"
}
if dbPort == "" {
dbPort = "5433"
}
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Shanghai",
dbHost, dbUser, dbPassword, dbName, dbPort)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// 自动迁移数据库结构
if err := db.AutoMigrate(&model.User{}, &model.Binding{}); err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
// 初始化依赖注入
userRepo := repository.NewUserRepository(db)
// 初始化管理员账号
initAdminUser(userRepo)
authSvc := service.NewAuthService(userRepo)
authHandler := handler.NewAuthHandler(authSvc)
bindingRepo := repository.NewBindingRepository(db)
bindingSvc := service.NewBindingService(bindingRepo)
bindingHandler := handler.NewBindingHandler(bindingSvc)
r := gin.Default()
// Swagger 文档路由
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// 路由分组
v1 := r.Group("/api/v1")
{
authGroup := v1.Group("/auth")
{
authGroup.POST("/login", authHandler.Login)
authGroup.POST("/register", authHandler.Register)
}
// 绑定相关路由,需要认证
bindingsGroup := v1.Group("/bindings")
bindingsGroup.Use(middleware.AuthMiddleware())
{
bindingsGroup.POST("", bindingHandler.Bind)
bindingsGroup.DELETE("/:person_id", bindingHandler.Unbind)
bindingsGroup.GET("/me", bindingHandler.Me)
}
}
log.Println("Auth Service starting on :8081...")
if err := r.Run(":8081"); err != nil {
log.Fatal("Server failed to start:", err)
}
}
func initAdminUser(repo *repository.UserRepository) {
adminUsername := "admin"
exists, err := repo.ExistsByUsername(adminUsername)
if err != nil {
log.Printf("Failed to check admin user existence: %v", err)
return
}
if !exists {
log.Println("Admin user not found. Creating default admin user...")
adminPassword := os.Getenv("ADMIN_PASSWORD")
if adminPassword == "" {
adminPassword = "password123"
}
hashedPwd, err := utils.HashPassword(adminPassword)
if err != nil {
log.Printf("Failed to hash admin password: %v", err)
return
}
adminUser := &model.User{
Username: adminUsername,
Email: "admin@example.com",
Password: hashedPwd,
Role: "administrator", // 兼容旧系统的最高权限角色
RoleLevel: 5,
Edition: "edu",
}
if err := repo.Create(adminUser); err != nil {
log.Printf("Failed to create admin user: %v", err)
} else {
log.Printf("Admin user created successfully. Username: %s, Password: %s", adminUsername, adminPassword)
}
} else {
log.Println("Admin user already exists.")
}
}
package middleware
import (
"auth-service/pkg/utils"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
c.Abort()
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
c.Abort()
return
}
tokenString := parts[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return utils.GetJWTSecret(), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.Abort()
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
c.Abort()
return
}
// 将用户信息存入上下文
if sub, ok := claims["sub"].(float64); ok {
c.Set("userID", uint(sub))
}
if role, ok := claims["role"].(string); ok {
c.Set("role", role)
}
c.Next()
}
}
package utils
import (
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var jwtSecret = []byte("your-secret-key")
func init() {
if secret := os.Getenv("JWT_SECRET"); secret != "" {
jwtSecret = []byte(secret)
}
}
func GetJWTSecret() []byte {
return jwtSecret
}
// HashPassword 密码加密
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPasswordHash 密码校验
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// GenerateToken 生成 JWT Token
func GenerateToken(userID uint, username string, role string) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"name": username,
"role": role,
"exp": time.Now().Add(time.Hour * 24).Unix(), // 24小时过期
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.llmfilter</groupId>
<artifactId>edu-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>edu-service</name>
<description>Edu Core Service for LLM Filter System</description>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<!-- JWT Utilities -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<localRepository>${user.home}/.m2/repository</localRepository>
<mirrors>
<mirror>
<id>aliyunmaven</id>
<mirrorOf>*</mirrorOf>
<name>阿里云公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
</mirrors>
</settings>
package com.llmfilter.edu;
import com.llmfilter.edu.security.JwtAuthenticationFilter;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
@OpenAPIDefinition(
info = @Info(
title = "Edu Service API",
version = "1.0",
description = "LLM 过滤系统 - 教务核心服务 API"
)
)
public class EduServiceApplication {
public static void main(String[] args) {
SpringApplication.run(EduServiceApplication.class, args);
}
@Bean
public FilterRegistrationBean<JwtAuthenticationFilter> loggingFilter(JwtAuthenticationFilter filter) {
FilterRegistrationBean<JwtAuthenticationFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(filter);
registrationBean.addUrlPatterns("/api/v1/*"); // 拦截 API 接口
registrationBean.setOrder(1);
return registrationBean;
}
}
package com.llmfilter.edu.controller;
import com.llmfilter.edu.dto.ClassDto;
import com.llmfilter.edu.dto.HeadTeacherPayload;
import com.llmfilter.edu.service.ClassService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/classes")
@RequiredArgsConstructor
@Tag(name = "班级管理", description = "班级与班主任管理接口")
public class ClassController {
private final ClassService classService;
@GetMapping
@Operation(summary = "获取班级列表", description = "获取所有班级的基础信息")
public ResponseEntity<List<ClassDto>> listClasses() {
return ResponseEntity.ok(classService.listClasses());
}
@PutMapping("/{classId}/head-teacher")
@Operation(summary = "设置班主任", description = "为指定班级分配或更新班主任")
public ResponseEntity<Map<String, Boolean>> setHeadTeacher(
@PathVariable String classId,
@RequestBody HeadTeacherPayload payload) {
classService.setHeadTeacher(classId, payload);
Map<String, Boolean> result = new HashMap<>();
result.put("success", true);
return ResponseEntity.ok(result);
}
}
package com.llmfilter.edu.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/edu")
public class HealthController {
@GetMapping("/health")
public Map<String, String> health() {
Map<String, String> status = new HashMap<>();
status.put("status", "ok");
status.put("service", "edu-service (Java)");
return status;
}
}
package com.llmfilter.edu.controller;
import com.llmfilter.edu.dto.PersonDto;
import com.llmfilter.edu.service.PersonService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/persons")
@RequiredArgsConstructor
@Tag(name = "人员管理", description = "基础人员信息管理接口")
public class PersonController {
private final PersonService personService;
@PostMapping("/bulk")
@Operation(summary = "批量创建人员", description = "批量导入或创建人员基础信息")
public ResponseEntity<Map<String, Integer>> bulkCreate(@RequestBody List<PersonDto> persons) {
int count = personService.bulkCreate(persons);
Map<String, Integer> result = new HashMap<>();
result.put("inserted", count);
return ResponseEntity.ok(result);
}
@GetMapping
@Operation(summary = "获取人员列表", description = "获取所有人员信息")
public ResponseEntity<Map<String, Object>> listPersons() {
return ResponseEntity.ok(personService.listPersons());
}
}
package com.llmfilter.edu.dto;
import lombok.Data;
@Data
public class AssignTeacherPayload {
private String lessonId;
private String teacherPersonId;
}
package com.llmfilter.edu.dto;
import lombok.Data;
@Data
public class ClassDto {
private Long id;
private String classId;
private String headTeacherPersonId;
private Integer grade;
private Integer studentsCount;
}
package com.llmfilter.edu.dto;
import lombok.Data;
@Data
public class HeadTeacherPayload {
private String headTeacherPersonId;
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment