This commit is contained in:
2026-06-06 23:54:11 +08:00
commit 33639129b1
58 changed files with 10309 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
import sqlite3
from collections.abc import Generator
from contextlib import contextmanager
from .settings import DATABASE_PATH, ensure_runtime_dirs
@contextmanager
def get_connection() -> Generator[sqlite3.Connection, None, None]:
ensure_runtime_dirs()
connection = sqlite3.connect(DATABASE_PATH)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
try:
yield connection
finally:
connection.close()
+42
View File
@@ -0,0 +1,42 @@
"""Simple in-memory rate limiter per user."""
from __future__ import annotations
import time
from collections import defaultdict
from typing import Annotated
from fastapi import Depends, HTTPException, status
from app.api.deps import require_student
from app.core.schemas import UserProfile
class RateLimiter:
def __init__(self, max_requests: int, window_seconds: int) -> None:
self._max = max_requests
self._window = window_seconds
self._buckets: dict[int, list[float]] = defaultdict(list)
def check(self, user_id: int) -> None:
now = time.time()
cutoff = now - self._window
bucket = self._buckets[user_id]
# purge expired entries
while bucket and bucket[0] < cutoff:
bucket.pop(0)
if len(bucket) >= self._max:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"请求过于频繁,请 {self._window // 60} 分钟后重试",
)
bucket.append(now)
_diagnosis_limiter = RateLimiter(max_requests=5, window_seconds=60)
def check_diagnosis_rate_limit(
user: Annotated[UserProfile, Depends(require_student)],
) -> None:
_diagnosis_limiter.check(user.id)
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
Role = Literal["student", "admin"]
OrderStatus = Literal["已提交", "待处理", "处理中", "待上门", "已完成", "已确认", "返工申请中", "已取消"]
Urgency = Literal["", "", "", "紧急"]
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=50)
password: str = Field(min_length=1, max_length=100)
class UserProfile(BaseModel):
id: int
username: str
display_name: str
role: Role
class LoginResponse(BaseModel):
token: str
user: UserProfile
class DiagnosisStartRequest(BaseModel):
message: str = Field(min_length=2, max_length=300)
class DiagnosisQuestionAnswer(BaseModel):
question_id: str
answer: str = Field(min_length=1, max_length=200)
class DiagnosisAnswerRequest(BaseModel):
session_id: str
answers: list[DiagnosisQuestionAnswer]
class DiagnosisQuestion(BaseModel):
id: str
prompt: str
class DiagnosisDraft(BaseModel):
category: str
urgency: Urgency
summary: str
safety_risk: bool
suggested_worker: str
notes: list[str]
class DiagnosisResponse(BaseModel):
session_id: str
stage: Literal["questions", "draft"]
initial_message: str
suggested_categories: list[str]
questions: list[DiagnosisQuestion]
draft: DiagnosisDraft | None = None
class SavedAddressOut(BaseModel):
id: int
campus: str
building: str
room: str
last_used_at: str
class OrderCreateRequest(BaseModel):
campus: str = Field(min_length=1, max_length=50)
building: str = Field(min_length=1, max_length=50)
room: str = Field(min_length=1, max_length=50)
category: str = Field(min_length=1, max_length=50)
raw_description: str = Field(min_length=2, max_length=300)
structured_summary: str = Field(min_length=2, max_length=400)
urgency: Urgency
expected_date: str | None = Field(default=None, max_length=20)
expected_time_segment: str | None = Field(default=None, max_length=50)
diagnosis_session_id: str | None = Field(default=None, max_length=80)
allow_room_entry: bool = False
class FeedbackRequest(BaseModel):
rating: int = Field(ge=1, le=5)
comment: str = Field(min_length=1, max_length=300)
class ReworkRequest(BaseModel):
reason: str = Field(min_length=2, max_length=300)
class AdminOrderUpdateRequest(BaseModel):
status: OrderStatus | None = None
assignee_name: str | None = Field(default=None, max_length=50)
expected_arrival_at: str | None = Field(default=None, max_length=50)
admin_note: str | None = Field(default=None, max_length=300)
class OrderEventOut(BaseModel):
id: int
actor_role: str
actor_name: str
event_type: str
title: str
detail: str | None
from_status: str | None
to_status: str | None
created_at: str
class AttachmentOut(BaseModel):
id: int
file_name: str
file_path: str
mime_type: str
created_at: str
class OrderSummaryOut(BaseModel):
id: int
order_no: str
campus: str
building: str
room: str
category: str
status: str
urgency: str
submission_time: str
expected_repair_time: str | None
assignee_name: str | None
class OrderDetailOut(BaseModel):
id: int
order_no: str
campus: str
building: str
room: str
category: str
status: str
urgency: str
raw_description: str
structured_summary: str
allow_room_entry: bool
expected_date: str | None
expected_time_segment: str | None
assignee_name: str | None
expected_arrival_at: str | None
admin_note: str | None
rework_reason: str | None
created_at: str
updated_at: str
attachments: list[AttachmentOut]
events: list[OrderEventOut]
feedback: dict[str, Any] | None
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import secrets
import bcrypt
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode())
def generate_token() -> str:
return secrets.token_urlsafe(32)
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2]
load_dotenv(BASE_DIR / ".env")
DATA_DIR = BASE_DIR / "data"
UPLOADS_DIR = BASE_DIR / "uploads"
DATABASE_PATH = DATA_DIR / os.getenv("DORM_REPAIR_DB", "dorm_repair.sqlite3")
FRONTEND_ORIGIN = os.getenv("FRONTEND_ORIGIN", "http://localhost:5173")
FRONTEND_ORIGINS = [
origin.strip()
for origin in os.getenv("FRONTEND_ORIGINS", f"{FRONTEND_ORIGIN},http://127.0.0.1:5173").split(",")
if origin.strip()
]
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "")
DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
DEEPSEEK_TIMEOUT = float(os.getenv("DEEPSEEK_TIMEOUT", "30.0"))
def ensure_runtime_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)