feat: 增加用户认证系统
This commit is contained in:
parent
55e28b8d3b
commit
39fb220cf2
22
README.md
22
README.md
|
|
@ -31,14 +31,16 @@ pip install -e .
|
||||||
backend_port: 3000
|
backend_port: 3000
|
||||||
frontend_port: 4000
|
frontend_port: 4000
|
||||||
|
|
||||||
# LLM API
|
# LLM API (global defaults, can be overridden per model)
|
||||||
api_key: {{your-api-key}}
|
default_api_key: {{your-api-key}}
|
||||||
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
default_api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
||||||
|
|
||||||
# Available models
|
# Available models (each model can optionally specify its own api_key and api_url)
|
||||||
models:
|
models:
|
||||||
- id: glm-5
|
- id: glm-5
|
||||||
name: GLM-5
|
name: GLM-5
|
||||||
|
# api_key: xxx # Optional, falls back to default_api_key
|
||||||
|
# api_url: xxx # Optional, falls back to default_api_url
|
||||||
- id: glm-4-plus
|
- id: glm-4-plus
|
||||||
name: GLM-4 Plus
|
name: GLM-4 Plus
|
||||||
|
|
||||||
|
|
@ -47,6 +49,14 @@ default_model: glm-5
|
||||||
# Workspace root directory
|
# Workspace root directory
|
||||||
workspace_root: ./workspaces
|
workspace_root: ./workspaces
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
# "single": Single-user mode - no login required, auto-creates default user
|
||||||
|
# "multi": Multi-user mode - requires JWT, users must register/login
|
||||||
|
auth_mode: single
|
||||||
|
|
||||||
|
# JWT secret (only used in multi-user mode, change for production!)
|
||||||
|
jwt_secret: nano-claw-default-secret-change-in-production
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
db_type: sqlite
|
db_type: sqlite
|
||||||
db_sqlite_file: nano_claw.db
|
db_sqlite_file: nano_claw.db
|
||||||
|
|
@ -72,6 +82,7 @@ npm run dev
|
||||||
backend/
|
backend/
|
||||||
├── models.py # SQLAlchemy 数据模型
|
├── models.py # SQLAlchemy 数据模型
|
||||||
├── routes/ # API 路由
|
├── routes/ # API 路由
|
||||||
|
│ ├── auth.py # 认证(登录/注册/JWT)
|
||||||
│ ├── conversations.py
|
│ ├── conversations.py
|
||||||
│ ├── messages.py
|
│ ├── messages.py
|
||||||
│ ├── projects.py # 项目管理
|
│ ├── projects.py # 项目管理
|
||||||
|
|
@ -117,7 +128,8 @@ frontend/
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `POST` | `/api/conversations` | 创建会话 |
|
| `POST` | `/api/auth/login` | 用户登录 |
|
||||||
|
| `POST` | `/api/auth/register` | 用户注册 |
|
||||||
| `GET` | `/api/conversations` | 会话列表 |
|
| `GET` | `/api/conversations` | 会话列表 |
|
||||||
| `GET` | `/api/conversations/:id/messages` | 消息列表 |
|
| `GET` | `/api/conversations/:id/messages` | 消息列表 |
|
||||||
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) |
|
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) |
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,49 @@ class User(db.Model):
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||||
username = db.Column(db.String(50), unique=True, nullable=False)
|
username = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
password = db.Column(db.String(255), nullable=True) # Allow NULL for third-party login
|
password_hash = db.Column(db.String(255), nullable=True) # NULL for API-key-only auth
|
||||||
phone = db.Column(db.String(20))
|
email = db.Column(db.String(120), unique=True, nullable=True)
|
||||||
|
avatar = db.Column(db.String(512), nullable=True)
|
||||||
|
role = db.Column(db.String(20), nullable=False, default="user") # user, admin
|
||||||
|
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
conversations = db.relationship("Conversation", backref="user", lazy="dynamic",
|
conversations = db.relationship("Conversation", backref="user", lazy="dynamic",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
order_by="Conversation.updated_at.desc()")
|
order_by="Conversation.updated_at.desc()")
|
||||||
|
projects = db.relationship("Project", backref="user", lazy="dynamic",
|
||||||
|
cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def password(self):
|
||||||
|
raise AttributeError("password is not readable")
|
||||||
|
|
||||||
|
@password.setter
|
||||||
|
def password(self, plain):
|
||||||
|
if plain:
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
self.password_hash = generate_password_hash(plain)
|
||||||
|
else:
|
||||||
|
self.password_hash = None
|
||||||
|
|
||||||
|
def check_password(self, plain):
|
||||||
|
if not self.password_hash:
|
||||||
|
return False
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
return check_password_hash(self.password_hash, plain)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"username": self.username,
|
||||||
|
"email": self.email,
|
||||||
|
"avatar": self.avatar,
|
||||||
|
"role": self.role,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"last_login_at": self.last_login_at.isoformat() if self.last_login_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Conversation(db.Model):
|
class Conversation(db.Model):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from backend.routes.models import bp as models_bp
|
||||||
from backend.routes.tools import bp as tools_bp
|
from backend.routes.tools import bp as tools_bp
|
||||||
from backend.routes.stats import bp as stats_bp
|
from backend.routes.stats import bp as stats_bp
|
||||||
from backend.routes.projects import bp as projects_bp
|
from backend.routes.projects import bp as projects_bp
|
||||||
|
from backend.routes.auth import bp as auth_bp, init_auth
|
||||||
from backend.services.glm_client import GLMClient
|
from backend.services.glm_client import GLMClient
|
||||||
from backend.config import MODEL_CONFIG
|
from backend.config import MODEL_CONFIG
|
||||||
|
|
||||||
|
|
@ -15,8 +16,12 @@ def register_routes(app: Flask):
|
||||||
# Initialize GLM client with per-model config
|
# Initialize GLM client with per-model config
|
||||||
glm_client = GLMClient(MODEL_CONFIG)
|
glm_client = GLMClient(MODEL_CONFIG)
|
||||||
init_chat_service(glm_client)
|
init_chat_service(glm_client)
|
||||||
|
|
||||||
|
# Initialize authentication system (reads auth_mode from config.yml)
|
||||||
|
init_auth(app)
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(conversations_bp)
|
app.register_blueprint(conversations_bp)
|
||||||
app.register_blueprint(messages_bp)
|
app.register_blueprint(messages_bp)
|
||||||
app.register_blueprint(models_bp)
|
app.register_blueprint(models_bp)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""Authentication module - supports both single-user and multi-user modes.
|
||||||
|
|
||||||
|
Single-user mode (auth_mode: "single"):
|
||||||
|
- Auto-creates a default user on first startup
|
||||||
|
- All requests are authenticated as the default user (no token needed)
|
||||||
|
- Login endpoint returns a convenience token but it's optional
|
||||||
|
|
||||||
|
Multi-user mode (auth_mode: "multi"):
|
||||||
|
- Requires JWT token for all API requests (except login/register)
|
||||||
|
- Users must register and login to get a token
|
||||||
|
- Supports admin/user roles
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import jwt
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import wraps
|
||||||
|
from flask import Blueprint, request, g, current_app
|
||||||
|
from backend import db
|
||||||
|
from backend.models import User
|
||||||
|
from backend.utils.helpers import ok, err
|
||||||
|
|
||||||
|
bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
# Routes that don't require authentication
|
||||||
|
PUBLIC_ROUTES = {
|
||||||
|
"POST:/api/auth/login",
|
||||||
|
"POST:/api/auth/register",
|
||||||
|
"GET:/api/models",
|
||||||
|
"GET:/api/tools",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_config():
|
||||||
|
"""Get auth configuration from app config."""
|
||||||
|
return current_app.config.get("AUTH_CONFIG", {
|
||||||
|
"mode": "single", # "single" or "multi"
|
||||||
|
"jwt_secret": "nano-claw-default-secret-change-in-production",
|
||||||
|
"jwt_expiry": 7 * 24 * 3600, # 7 days in seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token(user):
|
||||||
|
"""Generate a JWT token for a user."""
|
||||||
|
cfg = get_auth_config()
|
||||||
|
payload = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"exp": int(time.time()) + cfg["jwt_expiry"],
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, cfg["jwt_secret"], algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user():
|
||||||
|
"""Resolve the current user from request context.
|
||||||
|
|
||||||
|
In single-user mode: auto-creates and returns the default user.
|
||||||
|
In multi-user mode: validates the JWT token from Authorization header.
|
||||||
|
Returns None if authentication fails in multi-user mode.
|
||||||
|
"""
|
||||||
|
cfg = get_auth_config()
|
||||||
|
|
||||||
|
if cfg["mode"] == "single":
|
||||||
|
user = User.query.filter_by(username="default").first()
|
||||||
|
if not user:
|
||||||
|
user = User(username="default", role="admin")
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Multi-user mode: validate JWT
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = auth_header[7:]
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, cfg["jwt_secret"], algorithms=["HS256"])
|
||||||
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = db.session.get(User, payload.get("user_id"))
|
||||||
|
if not user or not user.is_active:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def init_auth(app):
|
||||||
|
"""Register authentication hooks on the Flask app."""
|
||||||
|
cfg_path = app.config.get("AUTH_CONFIG_PATH")
|
||||||
|
if cfg_path:
|
||||||
|
from backend import load_config
|
||||||
|
full_cfg = load_config()
|
||||||
|
auth_mode = full_cfg.get("auth_mode", "single")
|
||||||
|
jwt_secret = full_cfg.get("jwt_secret", "nano-claw-default-secret-change-in-production")
|
||||||
|
else:
|
||||||
|
auth_mode = "single"
|
||||||
|
jwt_secret = "nano-claw-default-secret-change-in-production"
|
||||||
|
|
||||||
|
app.config["AUTH_CONFIG"] = {
|
||||||
|
"mode": auth_mode,
|
||||||
|
"jwt_secret": jwt_secret,
|
||||||
|
"jwt_expiry": 7 * 24 * 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def before_request_auth():
|
||||||
|
"""Authenticate user before each request."""
|
||||||
|
method_path = f"{request.method}:{request.path}"
|
||||||
|
|
||||||
|
# Skip auth for public routes
|
||||||
|
if method_path in PUBLIC_ROUTES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Skip auth for static files
|
||||||
|
if request.path.startswith("/static"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# In single-user mode, always set the default user
|
||||||
|
cfg = get_auth_config()
|
||||||
|
if cfg["mode"] == "single":
|
||||||
|
g.current_user = _resolve_user()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Multi-user mode: validate token
|
||||||
|
user = _resolve_user()
|
||||||
|
if not user:
|
||||||
|
return err(401, "Unauthorized - please login")
|
||||||
|
g.current_user = user
|
||||||
|
|
||||||
|
# Update last_login_at (debounced: at most once per hour)
|
||||||
|
if (not user.last_login_at or
|
||||||
|
(datetime.now(timezone.utc) - user.last_login_at).total_seconds() > 3600):
|
||||||
|
user.last_login_at = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auth API Routes ---
|
||||||
|
|
||||||
|
@bp.route("/api/auth/login", methods=["POST"])
|
||||||
|
def login():
|
||||||
|
"""User login - returns JWT token."""
|
||||||
|
cfg = get_auth_config()
|
||||||
|
|
||||||
|
# Single-user mode: just return the default user's token
|
||||||
|
if cfg["mode"] == "single":
|
||||||
|
user = User.query.filter_by(username="default").first()
|
||||||
|
if not user:
|
||||||
|
return err(500, "Default user not initialized")
|
||||||
|
return ok({
|
||||||
|
"token": generate_token(user),
|
||||||
|
"user": user.to_dict(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Multi-user mode: validate credentials
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = data.get("username", "").strip()
|
||||||
|
password = data.get("password", "")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return err(400, "Username and password are required")
|
||||||
|
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if not user or not user.check_password(password):
|
||||||
|
return err(401, "Invalid username or password")
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
return err(403, "Account is disabled")
|
||||||
|
|
||||||
|
user.last_login_at = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"token": generate_token(user),
|
||||||
|
"user": user.to_dict(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/auth/register", methods=["POST"])
|
||||||
|
def register():
|
||||||
|
"""User registration - only available in multi-user mode."""
|
||||||
|
cfg = get_auth_config()
|
||||||
|
if cfg["mode"] == "single":
|
||||||
|
return err(403, "Registration is disabled in single-user mode")
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = data.get("username", "").strip()
|
||||||
|
password = data.get("password", "")
|
||||||
|
email = data.get("email", "").strip() or None
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return err(400, "Username and password are required")
|
||||||
|
if len(username) < 2 or len(username) > 50:
|
||||||
|
return err(400, "Username must be 2-50 characters")
|
||||||
|
if len(password) < 4:
|
||||||
|
return err(400, "Password must be at least 4 characters")
|
||||||
|
if email and "@" not in email:
|
||||||
|
return err(400, "Invalid email format")
|
||||||
|
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
return err(409, f"Username '{username}' already exists")
|
||||||
|
if email and User.query.filter_by(email=email).first():
|
||||||
|
return err(409, "Email already registered")
|
||||||
|
|
||||||
|
user = User(username=username, password=password, email=email)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"token": generate_token(user),
|
||||||
|
"user": user.to_dict(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/auth/profile", methods=["GET"])
|
||||||
|
def get_profile():
|
||||||
|
"""Get current user profile."""
|
||||||
|
user = getattr(g, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return err(401, "Not authenticated")
|
||||||
|
return ok(user.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/auth/profile", methods=["PATCH"])
|
||||||
|
def update_profile():
|
||||||
|
"""Update current user profile."""
|
||||||
|
user = getattr(g, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return err(401, "Not authenticated")
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
if "email" in data:
|
||||||
|
new_email = data["email"].strip() or None
|
||||||
|
if new_email and new_email != user.email:
|
||||||
|
if User.query.filter_by(email=new_email).first():
|
||||||
|
return err(409, "Email already registered")
|
||||||
|
user.email = new_email
|
||||||
|
|
||||||
|
if "avatar" in data:
|
||||||
|
user.avatar = data["avatar"]
|
||||||
|
|
||||||
|
if "password" in data:
|
||||||
|
new_password = data["password"]
|
||||||
|
if len(new_password) < 4:
|
||||||
|
return err(400, "Password must be at least 4 characters")
|
||||||
|
user.password = new_password
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return ok(user.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/auth/mode", methods=["GET"])
|
||||||
|
def get_auth_mode():
|
||||||
|
"""Get current authentication mode (public endpoint)."""
|
||||||
|
cfg = get_auth_config()
|
||||||
|
return ok({"mode": cfg["mode"]})
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
"""Conversation API routes"""
|
"""Conversation API routes"""
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request, g
|
||||||
from backend import db
|
from backend import db
|
||||||
from backend.models import Conversation, Project
|
from backend.models import Conversation, Project
|
||||||
from backend.utils.helpers import ok, err, to_dict, get_or_create_default_user
|
from backend.utils.helpers import ok, err, to_dict
|
||||||
from backend.config import DEFAULT_MODEL
|
from backend.config import DEFAULT_MODEL
|
||||||
|
|
||||||
bp = Blueprint("conversations", __name__)
|
bp = Blueprint("conversations", __name__)
|
||||||
|
|
@ -23,15 +23,16 @@ def _conv_to_dict(conv, **extra):
|
||||||
@bp.route("/api/conversations", methods=["GET", "POST"])
|
@bp.route("/api/conversations", methods=["GET", "POST"])
|
||||||
def conversation_list():
|
def conversation_list():
|
||||||
"""List or create conversations"""
|
"""List or create conversations"""
|
||||||
|
user = g.current_user
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
d = request.json or {}
|
d = request.json or {}
|
||||||
user = get_or_create_default_user()
|
|
||||||
|
|
||||||
# Validate project_id if provided
|
# Validate project_id if provided
|
||||||
project_id = d.get("project_id")
|
project_id = d.get("project_id")
|
||||||
if project_id:
|
if project_id:
|
||||||
project = db.session.get(Project, project_id)
|
project = db.session.get(Project, project_id)
|
||||||
if not project:
|
if not project or project.user_id != user.id:
|
||||||
return err(404, "Project not found")
|
return err(404, "Project not found")
|
||||||
|
|
||||||
conv = Conversation(
|
conv = Conversation(
|
||||||
|
|
@ -48,23 +49,22 @@ def conversation_list():
|
||||||
db.session.add(conv)
|
db.session.add(conv)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok(_conv_to_dict(conv))
|
return ok(_conv_to_dict(conv))
|
||||||
|
|
||||||
# GET - list conversations
|
# GET - list conversations
|
||||||
cursor = request.args.get("cursor")
|
cursor = request.args.get("cursor")
|
||||||
limit = min(int(request.args.get("limit", 20)), 100)
|
limit = min(int(request.args.get("limit", 20)), 100)
|
||||||
project_id = request.args.get("project_id")
|
project_id = request.args.get("project_id")
|
||||||
user = get_or_create_default_user()
|
|
||||||
q = Conversation.query.filter_by(user_id=user.id)
|
q = Conversation.query.filter_by(user_id=user.id)
|
||||||
|
|
||||||
# Filter by project if specified
|
# Filter by project if specified
|
||||||
if project_id:
|
if project_id:
|
||||||
q = q.filter_by(project_id=project_id)
|
q = q.filter_by(project_id=project_id)
|
||||||
|
|
||||||
if cursor:
|
if cursor:
|
||||||
q = q.filter(Conversation.updated_at < (
|
q = q.filter(Conversation.updated_at < (
|
||||||
db.session.query(Conversation.updated_at).filter_by(id=cursor).scalar() or datetime.now(timezone.utc)))
|
db.session.query(Conversation.updated_at).filter_by(id=cursor).scalar() or datetime.now(timezone.utc)))
|
||||||
rows = q.order_by(Conversation.updated_at.desc()).limit(limit + 1).all()
|
rows = q.order_by(Conversation.updated_at.desc()).limit(limit + 1).all()
|
||||||
|
|
||||||
items = [_conv_to_dict(r, message_count=r.messages.count()) for r in rows[:limit]]
|
items = [_conv_to_dict(r, message_count=r.messages.count()) for r in rows[:limit]]
|
||||||
return ok({
|
return ok({
|
||||||
"items": items,
|
"items": items,
|
||||||
|
|
@ -76,32 +76,33 @@ def conversation_list():
|
||||||
@bp.route("/api/conversations/<conv_id>", methods=["GET", "PATCH", "DELETE"])
|
@bp.route("/api/conversations/<conv_id>", methods=["GET", "PATCH", "DELETE"])
|
||||||
def conversation_detail(conv_id):
|
def conversation_detail(conv_id):
|
||||||
"""Get, update or delete a conversation"""
|
"""Get, update or delete a conversation"""
|
||||||
|
user = g.current_user
|
||||||
conv = db.session.get(Conversation, conv_id)
|
conv = db.session.get(Conversation, conv_id)
|
||||||
if not conv:
|
if not conv or conv.user_id != user.id:
|
||||||
return err(404, "conversation not found")
|
return err(404, "conversation not found")
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
return ok(_conv_to_dict(conv))
|
return ok(_conv_to_dict(conv))
|
||||||
|
|
||||||
if request.method == "DELETE":
|
if request.method == "DELETE":
|
||||||
db.session.delete(conv)
|
db.session.delete(conv)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok(message="deleted")
|
return ok(message="deleted")
|
||||||
|
|
||||||
# PATCH - update conversation
|
# PATCH - update conversation
|
||||||
d = request.json or {}
|
d = request.json or {}
|
||||||
for k in ("title", "model", "system_prompt", "temperature", "max_tokens", "thinking_enabled"):
|
for k in ("title", "model", "system_prompt", "temperature", "max_tokens", "thinking_enabled"):
|
||||||
if k in d:
|
if k in d:
|
||||||
setattr(conv, k, d[k])
|
setattr(conv, k, d[k])
|
||||||
|
|
||||||
# Support updating project_id
|
# Support updating project_id
|
||||||
if "project_id" in d:
|
if "project_id" in d:
|
||||||
project_id = d["project_id"]
|
project_id = d["project_id"]
|
||||||
if project_id:
|
if project_id:
|
||||||
project = db.session.get(Project, project_id)
|
project = db.session.get(Project, project_id)
|
||||||
if not project:
|
if not project or project.user_id != user.id:
|
||||||
return err(404, "Project not found")
|
return err(404, "Project not found")
|
||||||
conv.project_id = project_id or None
|
conv.project_id = project_id or None
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok(_conv_to_dict(conv))
|
return ok(_conv_to_dict(conv))
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request, g
|
||||||
from backend import db
|
from backend import db
|
||||||
from backend.models import Conversation, Message
|
from backend.models import Conversation, Message
|
||||||
from backend.utils.helpers import ok, err, message_to_dict
|
from backend.utils.helpers import ok, err, message_to_dict
|
||||||
from backend.services.chat import ChatService
|
from backend.services.chat import ChatService
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("messages", __name__)
|
bp = Blueprint("messages", __name__)
|
||||||
|
|
||||||
# ChatService will be injected during registration
|
# ChatService will be injected during registration
|
||||||
|
|
@ -21,13 +20,21 @@ def init_chat_service(glm_client):
|
||||||
_chat_service = ChatService(glm_client)
|
_chat_service = ChatService(glm_client)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conv(conv_id):
|
||||||
|
"""Get conversation with ownership check."""
|
||||||
|
conv = db.session.get(Conversation, conv_id)
|
||||||
|
if not conv or conv.user_id != g.current_user.id:
|
||||||
|
return None
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/conversations/<conv_id>/messages", methods=["GET", "POST"])
|
@bp.route("/api/conversations/<conv_id>/messages", methods=["GET", "POST"])
|
||||||
def message_list(conv_id):
|
def message_list(conv_id):
|
||||||
"""List or create messages"""
|
"""List or create messages"""
|
||||||
conv = db.session.get(Conversation, conv_id)
|
conv = _get_conv(conv_id)
|
||||||
if not conv:
|
if not conv:
|
||||||
return err(404, "conversation not found")
|
return err(404, "conversation not found")
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
cursor = request.args.get("cursor")
|
cursor = request.args.get("cursor")
|
||||||
limit = min(int(request.args.get("limit", 50)), 100)
|
limit = min(int(request.args.get("limit", 50)), 100)
|
||||||
|
|
@ -36,14 +43,14 @@ def message_list(conv_id):
|
||||||
q = q.filter(Message.created_at < (
|
q = q.filter(Message.created_at < (
|
||||||
db.session.query(Message.created_at).filter_by(id=cursor).scalar() or datetime.now(timezone.utc)))
|
db.session.query(Message.created_at).filter_by(id=cursor).scalar() or datetime.now(timezone.utc)))
|
||||||
rows = q.order_by(Message.created_at.asc()).limit(limit + 1).all()
|
rows = q.order_by(Message.created_at.asc()).limit(limit + 1).all()
|
||||||
|
|
||||||
items = [message_to_dict(r) for r in rows[:limit]]
|
items = [message_to_dict(r) for r in rows[:limit]]
|
||||||
return ok({
|
return ok({
|
||||||
"items": items,
|
"items": items,
|
||||||
"next_cursor": items[-1]["id"] if len(rows) > limit else None,
|
"next_cursor": items[-1]["id"] if len(rows) > limit else None,
|
||||||
"has_more": len(rows) > limit,
|
"has_more": len(rows) > limit,
|
||||||
})
|
})
|
||||||
|
|
||||||
# POST - create message and get AI response
|
# POST - create message and get AI response
|
||||||
d = request.json or {}
|
d = request.json or {}
|
||||||
text = (d.get("text") or "").strip()
|
text = (d.get("text") or "").strip()
|
||||||
|
|
@ -68,14 +75,14 @@ def message_list(conv_id):
|
||||||
|
|
||||||
tools_enabled = d.get("tools_enabled", True)
|
tools_enabled = d.get("tools_enabled", True)
|
||||||
project_id = d.get("project_id") or conv.project_id
|
project_id = d.get("project_id") or conv.project_id
|
||||||
|
|
||||||
return _chat_service.stream_response(conv, tools_enabled, project_id)
|
return _chat_service.stream_response(conv, tools_enabled, project_id)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"])
|
@bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"])
|
||||||
def delete_message(conv_id, msg_id):
|
def delete_message(conv_id, msg_id):
|
||||||
"""Delete a message"""
|
"""Delete a message"""
|
||||||
conv = db.session.get(Conversation, conv_id)
|
conv = _get_conv(conv_id)
|
||||||
if not conv:
|
if not conv:
|
||||||
return err(404, "conversation not found")
|
return err(404, "conversation not found")
|
||||||
msg = db.session.get(Message, msg_id)
|
msg = db.session.get(Message, msg_id)
|
||||||
|
|
@ -89,7 +96,7 @@ def delete_message(conv_id, msg_id):
|
||||||
@bp.route("/api/conversations/<conv_id>/regenerate/<msg_id>", methods=["POST"])
|
@bp.route("/api/conversations/<conv_id>/regenerate/<msg_id>", methods=["POST"])
|
||||||
def regenerate_message(conv_id, msg_id):
|
def regenerate_message(conv_id, msg_id):
|
||||||
"""Regenerate an assistant message"""
|
"""Regenerate an assistant message"""
|
||||||
conv = db.session.get(Conversation, conv_id)
|
conv = _get_conv(conv_id)
|
||||||
if not conv:
|
if not conv:
|
||||||
return err(404, "conversation not found")
|
return err(404, "conversation not found")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import shutil
|
import shutil
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request, g
|
||||||
|
|
||||||
from backend import db
|
from backend import db
|
||||||
from backend.models import Project, User
|
from backend.models import Project
|
||||||
from backend.utils.helpers import ok, err
|
from backend.utils.helpers import ok, err
|
||||||
from backend.utils.workspace import (
|
from backend.utils.workspace import (
|
||||||
create_project_directory,
|
create_project_directory,
|
||||||
|
|
@ -20,13 +20,9 @@ bp = Blueprint("projects", __name__)
|
||||||
|
|
||||||
@bp.route("/api/projects", methods=["GET"])
|
@bp.route("/api/projects", methods=["GET"])
|
||||||
def list_projects():
|
def list_projects():
|
||||||
"""List all projects for a user"""
|
"""List all projects for current user"""
|
||||||
user_id = request.args.get("user_id", type=int)
|
user = g.current_user
|
||||||
|
projects = Project.query.filter_by(user_id=user.id).order_by(Project.updated_at.desc()).all()
|
||||||
if not user_id:
|
|
||||||
return err(400, "Missing user_id parameter")
|
|
||||||
|
|
||||||
projects = Project.query.filter_by(user_id=user_id).order_by(Project.updated_at.desc()).all()
|
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
"projects": [
|
"projects": [
|
||||||
|
|
@ -48,49 +44,41 @@ def list_projects():
|
||||||
@bp.route("/api/projects", methods=["POST"])
|
@bp.route("/api/projects", methods=["POST"])
|
||||||
def create_project():
|
def create_project():
|
||||||
"""Create a new project"""
|
"""Create a new project"""
|
||||||
|
user = g.current_user
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return err(400, "No data provided")
|
return err(400, "No data provided")
|
||||||
|
|
||||||
user_id = data.get("user_id")
|
|
||||||
name = data.get("name", "").strip()
|
name = data.get("name", "").strip()
|
||||||
description = data.get("description", "")
|
description = data.get("description", "")
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
return err(400, "Missing user_id")
|
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
return err(400, "Project name is required")
|
return err(400, "Project name is required")
|
||||||
|
|
||||||
# Check if user exists
|
|
||||||
user = User.query.get(user_id)
|
|
||||||
if not user:
|
|
||||||
return err(404, "User not found")
|
|
||||||
|
|
||||||
# Check if project name already exists for this user
|
# Check if project name already exists for this user
|
||||||
existing = Project.query.filter_by(user_id=user_id, name=name).first()
|
existing = Project.query.filter_by(user_id=user.id, name=name).first()
|
||||||
if existing:
|
if existing:
|
||||||
return err(400, f"Project '{name}' already exists")
|
return err(400, f"Project '{name}' already exists")
|
||||||
|
|
||||||
# Create project directory
|
# Create project directory
|
||||||
try:
|
try:
|
||||||
relative_path, absolute_path = create_project_directory(name, user_id)
|
relative_path, absolute_path = create_project_directory(name, user.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return err(500, f"Failed to create project directory: {str(e)}")
|
return err(500, f"Failed to create project directory: {str(e)}")
|
||||||
|
|
||||||
# Create project record
|
# Create project record
|
||||||
project = Project(
|
project = Project(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
user_id=user_id,
|
user_id=user.id,
|
||||||
name=name,
|
name=name,
|
||||||
path=relative_path,
|
path=relative_path,
|
||||||
description=description
|
description=description
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.add(project)
|
db.session.add(project)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
"id": project.id,
|
"id": project.id,
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
|
|
@ -202,15 +190,12 @@ def delete_project(project_id):
|
||||||
@bp.route("/api/projects/upload", methods=["POST"])
|
@bp.route("/api/projects/upload", methods=["POST"])
|
||||||
def upload_project_folder():
|
def upload_project_folder():
|
||||||
"""Upload a folder as a new project via file upload"""
|
"""Upload a folder as a new project via file upload"""
|
||||||
user_id = request.form.get("user_id", type=int)
|
user = g.current_user
|
||||||
project_name = request.form.get("name", "").strip()
|
project_name = request.form.get("name", "").strip()
|
||||||
description = request.form.get("description", "")
|
description = request.form.get("description", "")
|
||||||
|
|
||||||
files = request.files.getlist("files")
|
files = request.files.getlist("files")
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
return err(400, "Missing user_id")
|
|
||||||
|
|
||||||
if not files:
|
if not files:
|
||||||
return err(400, "No files uploaded")
|
return err(400, "No files uploaded")
|
||||||
|
|
||||||
|
|
@ -218,19 +203,14 @@ def upload_project_folder():
|
||||||
# Use first file's top-level folder name
|
# Use first file's top-level folder name
|
||||||
project_name = files[0].filename.split("/")[0] if files[0].filename else "untitled"
|
project_name = files[0].filename.split("/")[0] if files[0].filename else "untitled"
|
||||||
|
|
||||||
# Check if user exists
|
|
||||||
user = User.query.get(user_id)
|
|
||||||
if not user:
|
|
||||||
return err(404, "User not found")
|
|
||||||
|
|
||||||
# Check if project name already exists
|
# Check if project name already exists
|
||||||
existing = Project.query.filter_by(user_id=user_id, name=project_name).first()
|
existing = Project.query.filter_by(user_id=user.id, name=project_name).first()
|
||||||
if existing:
|
if existing:
|
||||||
return err(400, f"Project '{project_name}' already exists")
|
return err(400, f"Project '{project_name}' already exists")
|
||||||
|
|
||||||
# Create project directory first
|
# Create project directory first
|
||||||
try:
|
try:
|
||||||
relative_path, absolute_path = create_project_directory(project_name, user_id)
|
relative_path, absolute_path = create_project_directory(project_name, user.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return err(500, f"Failed to create project directory: {str(e)}")
|
return err(500, f"Failed to create project directory: {str(e)}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"""Token statistics API routes"""
|
"""Token statistics API routes"""
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request, g
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from backend.models import TokenUsage
|
from backend.models import TokenUsage
|
||||||
from backend.utils.helpers import ok, err, get_or_create_default_user
|
from backend.utils.helpers import ok, err
|
||||||
|
|
||||||
bp = Blueprint("stats", __name__)
|
bp = Blueprint("stats", __name__)
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ bp = Blueprint("stats", __name__)
|
||||||
@bp.route("/api/stats/tokens", methods=["GET"])
|
@bp.route("/api/stats/tokens", methods=["GET"])
|
||||||
def token_stats():
|
def token_stats():
|
||||||
"""Get token usage statistics"""
|
"""Get token usage statistics"""
|
||||||
user = get_or_create_default_user()
|
user = g.current_user
|
||||||
period = request.args.get("period", "daily")
|
period = request.args.get("period", "daily")
|
||||||
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
"""Chat completion service"""
|
"""Chat completion service"""
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from flask import current_app, Response
|
from flask import current_app, g, Response
|
||||||
from backend import db
|
from backend import db
|
||||||
from backend.models import Conversation, Message
|
from backend.models import Conversation, Message
|
||||||
from backend.tools import registry, ToolExecutor
|
from backend.tools import registry, ToolExecutor
|
||||||
from backend.utils.helpers import (
|
from backend.utils.helpers import (
|
||||||
get_or_create_default_user,
|
|
||||||
record_token_usage,
|
record_token_usage,
|
||||||
build_messages,
|
build_messages,
|
||||||
)
|
)
|
||||||
|
|
@ -205,8 +204,9 @@ class ChatService:
|
||||||
db.session.add(msg)
|
db.session.add(msg)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
user = get_or_create_default_user()
|
user = g.get("current_user")
|
||||||
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
|
if user:
|
||||||
|
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
|
||||||
|
|
||||||
# Check if we need to set title (first message in conversation)
|
# Check if we need to set title (first message in conversation)
|
||||||
conv = db.session.get(Conversation, conv_id)
|
conv = db.session.get(Conversation, conv_id)
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,24 @@ from backend import db
|
||||||
from backend.models import Message, TokenUsage, User
|
from backend.models import Message, TokenUsage, User
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user():
|
||||||
|
"""Get the current authenticated user from request context (g.current_user)."""
|
||||||
|
from flask import g
|
||||||
|
return getattr(g, "current_user", None)
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_default_user() -> User:
|
def get_or_create_default_user() -> User:
|
||||||
"""Get or create default user"""
|
"""Get or create default user.
|
||||||
|
|
||||||
|
.. deprecated::
|
||||||
|
Use g.current_user instead. This is kept for backward compatibility
|
||||||
|
and will be removed in a future version.
|
||||||
|
"""
|
||||||
|
from flask import g
|
||||||
|
user = getattr(g, "current_user", None)
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
# Fallback: look up or create default user (should not happen with auth middleware)
|
||||||
user = User.query.filter_by(username="default").first()
|
user = User.query.filter_by(username="default").first()
|
||||||
if not user:
|
if not user:
|
||||||
user = User(username="default", password=None)
|
user = User(username="default", password=None)
|
||||||
|
|
|
||||||
102
docs/Design.md
102
docs/Design.md
|
|
@ -42,6 +42,7 @@ backend/
|
||||||
│
|
│
|
||||||
├── routes/ # API 路由
|
├── routes/ # API 路由
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
|
│ ├── auth.py # 认证(登录/注册/JWT)
|
||||||
│ ├── conversations.py # 会话 CRUD
|
│ ├── conversations.py # 会话 CRUD
|
||||||
│ ├── messages.py # 消息 CRUD + 聊天
|
│ ├── messages.py # 消息 CRUD + 聊天
|
||||||
│ ├── models.py # 模型列表
|
│ ├── models.py # 模型列表
|
||||||
|
|
@ -89,10 +90,18 @@ classDiagram
|
||||||
class User {
|
class User {
|
||||||
+Integer id
|
+Integer id
|
||||||
+String username
|
+String username
|
||||||
+String password
|
+String password_hash
|
||||||
+String phone
|
+String email
|
||||||
|
+String avatar
|
||||||
|
+String role
|
||||||
|
+Boolean is_active
|
||||||
|
+DateTime created_at
|
||||||
|
+DateTime last_login_at
|
||||||
+relationship conversations
|
+relationship conversations
|
||||||
+relationship projects
|
+relationship projects
|
||||||
|
+to_dict() dict
|
||||||
|
+check_password(str) bool
|
||||||
|
+password(str)$ # property setter, 自动 hash
|
||||||
}
|
}
|
||||||
|
|
||||||
class Project {
|
class Project {
|
||||||
|
|
@ -352,6 +361,16 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
|
|
||||||
## API 总览
|
## API 总览
|
||||||
|
|
||||||
|
### 认证
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/auth/mode` | 获取当前认证模式(公开端点) |
|
||||||
|
| `POST` | `/api/auth/login` | 用户登录,返回 JWT token |
|
||||||
|
| `POST` | `/api/auth/register` | 用户注册(仅多用户模式可用) |
|
||||||
|
| `GET` | `/api/auth/profile` | 获取当前用户信息 |
|
||||||
|
| `PATCH` | `/api/auth/profile` | 更新当前用户信息 |
|
||||||
|
|
||||||
### 会话管理
|
### 会话管理
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|
|
@ -441,12 +460,19 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
|
|
||||||
### User(用户)
|
### User(用户)
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 默认值 | 说明 |
|
||||||
|------|------|------|
|
|------|------|--------|------|
|
||||||
| `id` | Integer | 自增主键 |
|
| `id` | Integer | - | 自增主键 |
|
||||||
| `username` | String(50) | 用户名(唯一) |
|
| `username` | String(50) | - | 用户名(唯一) |
|
||||||
| `password` | String(255) | 密码(可为空,支持第三方登录) |
|
| `password_hash` | String(255) | null | 密码哈希(可为空,支持 API-key-only 认证) |
|
||||||
| `phone` | String(20) | 手机号 |
|
| `email` | String(120) | null | 邮箱(唯一) |
|
||||||
|
| `avatar` | String(512) | null | 头像 URL |
|
||||||
|
| `role` | String(20) | "user" | 角色:`user` / `admin` |
|
||||||
|
| `is_active` | Boolean | true | 是否激活 |
|
||||||
|
| `created_at` | DateTime | now | 创建时间 |
|
||||||
|
| `last_login_at` | DateTime | null | 最后登录时间 |
|
||||||
|
|
||||||
|
`password` 通过 property setter 自动调用 `werkzeug` 的 `generate_password_hash` 存储,通过 `check_password()` 方法验证。
|
||||||
|
|
||||||
### Project(项目)
|
### Project(项目)
|
||||||
|
|
||||||
|
|
@ -527,13 +553,66 @@ GET /api/conversations?limit=20&cursor=conv_abc123
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 错误码
|
## 认证机制
|
||||||
|
|
||||||
|
### 概述
|
||||||
|
|
||||||
|
系统支持**单用户模式**和**多用户模式**,通过 `config.yml` 中的 `auth_mode` 切换。
|
||||||
|
|
||||||
|
### 单用户模式(`auth_mode: single`,默认)
|
||||||
|
|
||||||
|
- **无需登录**,前端不需要传 token
|
||||||
|
- 后端自动创建一个 `username="default"`、`role="admin"` 的用户
|
||||||
|
- 每次请求通过 `before_request` 钩子自动将 `g.current_user` 设为该默认用户
|
||||||
|
- 所有路由从 `g.current_user` 获取当前用户,无需前端传递 `user_id`
|
||||||
|
|
||||||
|
### 多用户模式(`auth_mode: multi`)
|
||||||
|
|
||||||
|
- 除公开端点外,所有请求必须在 `Authorization` 头中携带 JWT token
|
||||||
|
- 用户通过 `/api/auth/register` 注册、`/api/auth/login` 登录获取 token
|
||||||
|
- Token 有效期 7 天,过期需重新登录
|
||||||
|
- 用户只能访问自己的数据(对话、项目、统计等)
|
||||||
|
|
||||||
|
### 认证流程
|
||||||
|
|
||||||
|
```
|
||||||
|
单用户模式:
|
||||||
|
请求 → before_request → 查找/创建 default 用户 → g.current_user → 路由处理
|
||||||
|
|
||||||
|
多用户模式:
|
||||||
|
请求 → before_request → 提取 Authorization header → 验证 JWT → 查找用户 → g.current_user → 路由处理
|
||||||
|
↓ 失败
|
||||||
|
返回 401
|
||||||
|
```
|
||||||
|
|
||||||
|
### 公开端点(无需认证)
|
||||||
|
|
||||||
|
| 端点 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `POST /api/auth/login` | 登录 |
|
||||||
|
| `POST /api/auth/register` | 注册 |
|
||||||
|
| `GET /api/models` | 模型列表 |
|
||||||
|
| `GET /api/tools` | 工具列表 |
|
||||||
|
|
||||||
|
### 前端适配
|
||||||
|
|
||||||
|
前端 API 层(`frontend/src/api/index.js`)已预留 token 管理:
|
||||||
|
- `getToken()` / `setToken(token)` / `clearToken()`
|
||||||
|
- 所有请求自动附带 `Authorization: Bearer <token>`(token 为空时不发送)
|
||||||
|
- 收到 401 时自动清除 token
|
||||||
|
|
||||||
|
切换到多用户模式时,只需补充登录/注册页面 UI。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
| Code | 说明 |
|
| Code | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `0` | 成功 |
|
| `0` | 成功 |
|
||||||
| `400` | 请求参数错误 |
|
| `400` | 请求参数错误 |
|
||||||
|
| `401` | 未认证(多用户模式下缺少或无效 token) |
|
||||||
|
| `403` | 禁止访问(账户禁用、单用户模式下注册等) |
|
||||||
| `404` | 资源不存在 |
|
| `404` | 资源不存在 |
|
||||||
|
| `409` | 资源冲突(用户名/邮箱已存在) |
|
||||||
| `500` | 服务器错误 |
|
| `500` | 服务器错误 |
|
||||||
|
|
||||||
错误响应:
|
错误响应:
|
||||||
|
|
@ -749,6 +828,11 @@ default_model: glm-5
|
||||||
# 工作区根目录
|
# 工作区根目录
|
||||||
workspace_root: ./workspaces
|
workspace_root: ./workspaces
|
||||||
|
|
||||||
|
# 认证模式:single(单用户,无需登录) / multi(多用户,需要 JWT)
|
||||||
|
auth_mode: single
|
||||||
|
# JWT 密钥(仅多用户模式使用,生产环境请替换为随机值)
|
||||||
|
jwt_secret: nano-claw-default-secret-change-in-production
|
||||||
|
|
||||||
# 数据库
|
# 数据库
|
||||||
db_type: mysql # mysql, sqlite, postgresql
|
db_type: mysql # mysql, sqlite, postgresql
|
||||||
db_host: localhost
|
db_host: localhost
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ dependencies = [
|
||||||
"beautifulsoup4>=4.12",
|
"beautifulsoup4>=4.12",
|
||||||
"httpx>=0.25",
|
"httpx>=0.25",
|
||||||
"lxml>=5.0",
|
"lxml>=5.0",
|
||||||
|
"PyJWT>=2.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue