init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user