init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from app.core.database import get_connection
|
||||
from app.core.schemas import UserProfile
|
||||
from app.services import repository
|
||||
|
||||
|
||||
def get_bearer_token(authorization: Annotated[str | None, Header()] = None) -> str:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_token")
|
||||
return authorization.replace("Bearer ", "", 1).strip()
|
||||
|
||||
|
||||
def get_current_user(token: Annotated[str, Depends(get_bearer_token)]) -> UserProfile:
|
||||
with get_connection() as connection:
|
||||
row = repository.get_user_by_token(connection, token)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_token")
|
||||
return repository.profile_from_row(row)
|
||||
|
||||
|
||||
def require_student(user: Annotated[UserProfile, Depends(get_current_user)]) -> UserProfile:
|
||||
if user.role != "student":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="student_only")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user: Annotated[UserProfile, Depends(get_current_user)]) -> UserProfile:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin_only")
|
||||
return user
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.api.deps import require_admin
|
||||
from app.core.database import get_connection
|
||||
from app.core.schemas import AdminOrderUpdateRequest, OrderDetailOut, OrderSummaryOut, UserProfile
|
||||
from app.services import repository
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/orders", response_model=list[OrderSummaryOut])
|
||||
def list_orders(
|
||||
_: Annotated[UserProfile, Depends(require_admin)],
|
||||
status_text: str | None = Query(default=None, alias="status"),
|
||||
category: str | None = Query(default=None),
|
||||
urgency: str | None = Query(default=None),
|
||||
) -> list[dict[str, str]]:
|
||||
with get_connection() as connection:
|
||||
return repository.list_orders(connection, status=status_text, category=category, urgency=urgency)
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}", response_model=OrderDetailOut)
|
||||
def get_order(order_id: int, _: Annotated[UserProfile, Depends(require_admin)]) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
detail = repository.get_order_detail(connection, order_id)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="order_not_found")
|
||||
return detail
|
||||
|
||||
|
||||
@router.patch("/orders/{order_id}")
|
||||
def update_order(
|
||||
order_id: int,
|
||||
payload: AdminOrderUpdateRequest,
|
||||
admin: Annotated[UserProfile, Depends(require_admin)],
|
||||
) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
try:
|
||||
repository.update_order(connection, order_id, admin, payload)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats(_: Annotated[UserProfile, Depends(require_admin)]) -> dict[str, object]:
|
||||
with get_connection() as connection:
|
||||
return repository.get_stats(connection)
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/accept-rework")
|
||||
def accept_rework(
|
||||
order_id: int,
|
||||
admin: Annotated[UserProfile, Depends(require_admin)],
|
||||
) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
try:
|
||||
repository.accept_rework(connection, order_id, admin)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/reject-rework")
|
||||
def reject_rework(
|
||||
order_id: int,
|
||||
admin: Annotated[UserProfile, Depends(require_admin)],
|
||||
) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
try:
|
||||
repository.reject_rework(connection, order_id, admin)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.database import get_connection
|
||||
from app.core.schemas import LoginRequest, LoginResponse, UserProfile
|
||||
from app.core.security import verify_password
|
||||
from app.services import repository
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest) -> LoginResponse:
|
||||
with get_connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT id, username, password_hash, role, display_name FROM users WHERE username = ?",
|
||||
(payload.username,),
|
||||
).fetchone()
|
||||
if row is None or not verify_password(payload.password, row["password_hash"]):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_credentials")
|
||||
token = repository.create_session(connection, row["id"])
|
||||
user = repository.profile_from_row(row)
|
||||
return LoginResponse(token=token, user=user)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserProfile)
|
||||
def me(user: Annotated[UserProfile, Depends(get_current_user)]) -> UserProfile:
|
||||
return user
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
|
||||
from app.api.deps import require_student
|
||||
from app.core.database import get_connection
|
||||
from app.core.ratelimit import check_diagnosis_rate_limit
|
||||
from app.core.schemas import (
|
||||
DiagnosisAnswerRequest,
|
||||
DiagnosisResponse,
|
||||
DiagnosisStartRequest,
|
||||
FeedbackRequest,
|
||||
OrderCreateRequest,
|
||||
OrderDetailOut,
|
||||
OrderSummaryOut,
|
||||
ReworkRequest,
|
||||
SavedAddressOut,
|
||||
UserProfile,
|
||||
)
|
||||
from app.services import repository
|
||||
from app.services.diagnosis import get_diagnosis_provider
|
||||
|
||||
router = APIRouter(prefix="/api/student", tags=["student"])
|
||||
|
||||
|
||||
@router.post("/diagnosis/start", response_model=DiagnosisResponse)
|
||||
async def start_diagnosis(
|
||||
payload: DiagnosisStartRequest,
|
||||
_: Annotated[UserProfile, Depends(require_student)],
|
||||
_rl: None = Depends(check_diagnosis_rate_limit),
|
||||
) -> DiagnosisResponse:
|
||||
provider = get_diagnosis_provider()
|
||||
return await provider.start(payload.message)
|
||||
|
||||
|
||||
@router.post("/diagnosis/answer", response_model=DiagnosisResponse)
|
||||
async def answer_diagnosis(
|
||||
payload: DiagnosisAnswerRequest,
|
||||
_: Annotated[UserProfile, Depends(require_student)],
|
||||
_rl: None = Depends(check_diagnosis_rate_limit),
|
||||
) -> DiagnosisResponse:
|
||||
try:
|
||||
provider = get_diagnosis_provider()
|
||||
return await provider.answer(payload.session_id, payload.answers)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
|
||||
|
||||
|
||||
@router.get("/addresses", response_model=list[SavedAddressOut])
|
||||
def list_addresses(user: Annotated[UserProfile, Depends(require_student)]) -> list[dict[str, object]]:
|
||||
with get_connection() as connection:
|
||||
return repository.list_addresses(connection, user.id)
|
||||
|
||||
|
||||
@router.delete("/addresses/{address_id}")
|
||||
def delete_address(address_id: int, user: Annotated[UserProfile, Depends(require_student)]) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
if not repository.delete_address(connection, address_id, user.id):
|
||||
raise HTTPException(status_code=404, detail="地址不存在")
|
||||
return {"message": "已删除"}
|
||||
|
||||
|
||||
@router.post("/orders")
|
||||
def create_order(payload: OrderCreateRequest, user: Annotated[UserProfile, Depends(require_student)]) -> dict[str, int]:
|
||||
with get_connection() as connection:
|
||||
order_id = repository.create_order(connection, user, payload)
|
||||
return {"order_id": order_id}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/attachments")
|
||||
def upload_attachments(
|
||||
order_id: int,
|
||||
user: Annotated[UserProfile, Depends(require_student)],
|
||||
files: Annotated[list[UploadFile], File(...)],
|
||||
) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
order = repository.get_order_detail(connection, order_id, student_id=user.id)
|
||||
if order is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="order_not_found")
|
||||
for file in files:
|
||||
try:
|
||||
repository.save_attachment(connection, order_id, file)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/cancel")
|
||||
def cancel_order(
|
||||
order_id: int,
|
||||
user: Annotated[UserProfile, Depends(require_student)],
|
||||
) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
try:
|
||||
repository.cancel_order(connection, order_id, user)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error.args[0]) from error
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/orders", response_model=list[OrderSummaryOut])
|
||||
def list_orders(user: Annotated[UserProfile, Depends(require_student)]) -> list[dict[str, str]]:
|
||||
with get_connection() as connection:
|
||||
return repository.list_orders(connection, student_id=user.id)
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}", response_model=OrderDetailOut)
|
||||
def get_order(order_id: int, user: Annotated[UserProfile, Depends(require_student)]) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
detail = repository.get_order_detail(connection, order_id, student_id=user.id)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="order_not_found")
|
||||
return detail
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/confirm")
|
||||
def confirm_order(order_id: int, user: Annotated[UserProfile, Depends(require_student)]) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
try:
|
||||
repository.confirm_order(connection, order_id, user)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error.args[0]) from error
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/feedback")
|
||||
def submit_feedback(
|
||||
order_id: int,
|
||||
payload: FeedbackRequest,
|
||||
user: Annotated[UserProfile, Depends(require_student)],
|
||||
) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
try:
|
||||
repository.add_feedback(connection, order_id, user, payload)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error.args[0]) from error
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/rework")
|
||||
def submit_rework(
|
||||
order_id: int,
|
||||
payload: ReworkRequest,
|
||||
user: Annotated[UserProfile, Depends(require_student)],
|
||||
) -> dict[str, str]:
|
||||
with get_connection() as connection:
|
||||
try:
|
||||
repository.request_rework(connection, order_id, user, payload)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error.args[0]) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error.args[0]) from error
|
||||
return {"status": "ok"}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.routes_admin import router as admin_router
|
||||
from app.api.routes_auth import router as auth_router
|
||||
from app.api.routes_student import router as student_router
|
||||
from app.core.database import get_connection
|
||||
from app.core.settings import FRONTEND_ORIGINS, UPLOADS_DIR, ensure_runtime_dirs
|
||||
from app.services.repository import init_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ERROR_DETAIL_MAP: dict[str, str] = {
|
||||
"missing_token": "缺少认证令牌",
|
||||
"invalid_token": "认证已过期,请重新登录",
|
||||
"invalid_credentials": "用户名或密码错误",
|
||||
"student_only": "此功能仅限学生使用",
|
||||
"admin_only": "此功能仅限管理员使用",
|
||||
"order_not_found": "工单不存在或已被删除",
|
||||
"session_not_found": "诊断会话已过期,请重新描述故障",
|
||||
"order_not_completed": "维修尚未完成,无法执行此操作",
|
||||
"order_not_ready_for_feedback": "当前状态不可评价",
|
||||
"order_cannot_cancel": "当前状态无法取消,仅已提交或待处理的工单可取消",
|
||||
"order_not_in_rework": "当前工单不在返工申请状态",
|
||||
"unsupported_file_type": "仅支持上传图片文件(jpg, png, gif, webp)",
|
||||
"file_too_large": "文件大小超过限制(最大10MB)",
|
||||
"validation_error": "请求参数有误",
|
||||
}
|
||||
|
||||
ensure_runtime_dirs()
|
||||
|
||||
with get_connection() as conn:
|
||||
init_db(conn)
|
||||
conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')")
|
||||
conn.commit()
|
||||
|
||||
app = FastAPI(title="Dorm Repair API", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=FRONTEND_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(student_router)
|
||||
app.include_router(admin_router)
|
||||
app.mount("/uploads", StaticFiles(directory=UPLOADS_DIR), name="uploads")
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
error_code = str(exc.detail) if not isinstance(exc.detail, dict) else exc.detail.get("error_code", str(exc.detail))
|
||||
detail = ERROR_DETAIL_MAP.get(error_code, str(exc.detail))
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": detail, "error_code": error_code},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": "请求参数有误", "error_code": "validation_error"},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.exception("Unhandled exception: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "服务器内部错误", "error_code": "internal_error"},
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown() -> None:
|
||||
from app.services.diagnosis import close_diagnosis_provider
|
||||
|
||||
await close_diagnosis_provider()
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,379 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.schemas import DiagnosisDraft, DiagnosisQuestion, DiagnosisQuestionAnswer, DiagnosisResponse
|
||||
from app.core.settings import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, DEEPSEEK_TIMEOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SESSION_TTL = 3600 # 1 hour
|
||||
|
||||
CLASSIFY_SYSTEM_PROMPT = """你是宿舍维修故障诊断助手。根据学生故障描述,输出 JSON 对象。
|
||||
|
||||
分类规则:
|
||||
- 电路照明: 灯、电、跳闸、插座、焦味、漏电、火花
|
||||
- 给排水: 漏水、水龙头、下水、厕所、马桶、管道、洗手池
|
||||
- 门窗锁具: 门、锁、窗、把手、柜门
|
||||
- 空调设备: 空调、制冷、制热
|
||||
- 网络设备: 网络、wifi、网速、断网、网口
|
||||
- 家具设施: 床、桌椅、衣柜、抽屉、护栏
|
||||
- 其他: 不属于以上类别
|
||||
|
||||
紧急度规则(urgency):
|
||||
- 紧急: 火花、漏电、冒烟、大面积漏水、安全威胁
|
||||
- 高: 影响基本生活(照明/用水/用电/门禁)
|
||||
- 中: 普通故障影响使用
|
||||
- 低: 无立即影响
|
||||
|
||||
safety_risk: 火花、漏电、冒烟、焦味 设为 true
|
||||
suggested_worker: 电工(电路照明)、水暖维修(给排水)、门窗维修(门窗锁具)、空调维修(空调设备)、
|
||||
网络维护(网络设备)、综合维修(其他)
|
||||
questions: 2-3个有针对性、非模板化的追问,每个含 id(q1/q2/q3) 和 prompt 字段
|
||||
|
||||
输出格式:
|
||||
{"category":"...","urgency":"...","safety_risk":false,"suggested_worker":"...","notes":["..."],"questions":[{"id":"q1","prompt":"..."}]}"""
|
||||
|
||||
SUMMARY_SYSTEM_PROMPT = """根据学生原始描述和补充回答,生成维修工单摘要。
|
||||
格式:"[category]问题,建议[suggested_worker]处理。[现象];[位置];[影响范围]"
|
||||
不超过200字,使用中文标点。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiagnosisSession:
|
||||
session_id: str
|
||||
category: str
|
||||
urgency: Literal["低", "中", "高", "紧急"]
|
||||
suggested_worker: str
|
||||
safety_risk: bool
|
||||
initial_message: str
|
||||
suggested_categories: list[str]
|
||||
questions: list[DiagnosisQuestion]
|
||||
notes: list[str] = field(default_factory=list)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class AIDiagnosisProvider:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str = DEEPSEEK_BASE_URL,
|
||||
api_key: str = DEEPSEEK_API_KEY,
|
||||
model: str = DEEPSEEK_MODEL,
|
||||
timeout: float = DEEPSEEK_TIMEOUT,
|
||||
) -> None:
|
||||
self._api_key = api_key
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=base_url.rstrip("/"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=httpx.Timeout(timeout),
|
||||
)
|
||||
self._model = model
|
||||
self._sessions: dict[str, DiagnosisSession] = {}
|
||||
self._last_sweep = time.time()
|
||||
|
||||
def _sweep_expired_sessions(self) -> None:
|
||||
now = time.time()
|
||||
if now - self._last_sweep < 300: # sweep every 5 minutes
|
||||
return
|
||||
self._last_sweep = now
|
||||
expired = [sid for sid, s in self._sessions.items() if now - s.created_at > SESSION_TTL]
|
||||
for sid in expired:
|
||||
del self._sessions[sid]
|
||||
if expired:
|
||||
logger.info("Swept %d expired diagnosis sessions", len(expired))
|
||||
|
||||
async def start(self, message: str) -> DiagnosisResponse:
|
||||
self._sweep_expired_sessions()
|
||||
if not self._api_key:
|
||||
return self._start_local(message)
|
||||
try:
|
||||
result = await self._call_classify(message)
|
||||
category = result["category"]
|
||||
urgency = result["urgency"]
|
||||
worker = result["suggested_worker"]
|
||||
safety_risk = result["safety_risk"]
|
||||
ai_notes = result.get("notes", [])
|
||||
questions = [DiagnosisQuestion(id=q["id"], prompt=q["prompt"]) for q in result.get("questions", [])]
|
||||
if not questions:
|
||||
questions = [
|
||||
DiagnosisQuestion(id="location", prompt="问题具体出现在宿舍的什么位置或设备上?"),
|
||||
DiagnosisQuestion(id="symptom", prompt="请补充最明显的故障现象,方便维修人员带对工具。"),
|
||||
]
|
||||
except (httpx.HTTPError, json.JSONDecodeError, KeyError):
|
||||
return self._start_local(message)
|
||||
|
||||
session_id = uuid4().hex
|
||||
suggested_categories = [category]
|
||||
if category != "其他":
|
||||
suggested_categories.append("其他")
|
||||
session = DiagnosisSession(
|
||||
session_id=session_id,
|
||||
category=category,
|
||||
urgency=urgency,
|
||||
suggested_worker=worker,
|
||||
safety_risk=safety_risk,
|
||||
initial_message=message,
|
||||
suggested_categories=suggested_categories,
|
||||
questions=questions,
|
||||
notes=ai_notes,
|
||||
)
|
||||
self._sessions[session_id] = session
|
||||
return DiagnosisResponse(
|
||||
session_id=session_id,
|
||||
stage="questions",
|
||||
initial_message=message,
|
||||
suggested_categories=suggested_categories,
|
||||
questions=questions,
|
||||
draft=None,
|
||||
)
|
||||
|
||||
async def answer(self, session_id: str, answers: list[DiagnosisQuestionAnswer]) -> DiagnosisResponse:
|
||||
session = self._sessions.get(session_id)
|
||||
if session is None:
|
||||
raise KeyError("session_not_found")
|
||||
|
||||
if time.time() - session.created_at > SESSION_TTL:
|
||||
del self._sessions[session_id]
|
||||
raise KeyError("session_not_found")
|
||||
|
||||
session.created_at = time.time()
|
||||
|
||||
if not self._api_key:
|
||||
return self._answer_local(session, answers)
|
||||
try:
|
||||
summary = await self._call_summary(session, answers)
|
||||
except (httpx.HTTPError, json.JSONDecodeError):
|
||||
return self._answer_local(session, answers)
|
||||
|
||||
notes = list(session.notes)
|
||||
if session.safety_risk:
|
||||
notes.append("建议尽快断电或停止继续使用相关设备。")
|
||||
answer_map = {item.question_id: item.answer.strip() for item in answers}
|
||||
for question in session.questions:
|
||||
value = answer_map.get(question.id)
|
||||
if value:
|
||||
notes.append(f"{question.prompt}:{value}")
|
||||
|
||||
draft = DiagnosisDraft(
|
||||
category=session.category,
|
||||
urgency=session.urgency,
|
||||
summary=summary,
|
||||
safety_risk=session.safety_risk,
|
||||
suggested_worker=session.suggested_worker,
|
||||
notes=notes,
|
||||
)
|
||||
return DiagnosisResponse(
|
||||
session_id=session.session_id,
|
||||
stage="draft",
|
||||
initial_message=session.initial_message,
|
||||
suggested_categories=session.suggested_categories,
|
||||
questions=[],
|
||||
draft=draft,
|
||||
)
|
||||
|
||||
def _start_local(self, message: str) -> DiagnosisResponse:
|
||||
category, urgency, worker, safety_risk, questions = self._local_classify(message)
|
||||
session_id = uuid4().hex
|
||||
suggested_categories = [category]
|
||||
if category != "其他":
|
||||
suggested_categories.append("其他")
|
||||
session = DiagnosisSession(
|
||||
session_id=session_id,
|
||||
category=category,
|
||||
urgency=urgency,
|
||||
suggested_worker=worker,
|
||||
safety_risk=safety_risk,
|
||||
initial_message=message,
|
||||
suggested_categories=suggested_categories,
|
||||
questions=questions,
|
||||
)
|
||||
self._sessions[session_id] = session
|
||||
return DiagnosisResponse(
|
||||
session_id=session_id,
|
||||
stage="questions",
|
||||
initial_message=message,
|
||||
suggested_categories=suggested_categories,
|
||||
questions=questions,
|
||||
draft=None,
|
||||
)
|
||||
|
||||
def _answer_local(self, session: DiagnosisSession, answers: list[DiagnosisQuestionAnswer]) -> DiagnosisResponse:
|
||||
answer_map = {item.question_id: item.answer.strip() for item in answers}
|
||||
notes = []
|
||||
if session.safety_risk:
|
||||
notes.append("建议尽快断电或停止继续使用相关设备。")
|
||||
if "location" in answer_map:
|
||||
notes.append(f"具体位置:{answer_map['location']}")
|
||||
if "impact" in answer_map:
|
||||
notes.append(f"影响范围:{answer_map['impact']}")
|
||||
if "symptom" in answer_map:
|
||||
notes.append(f"补充现象:{answer_map['symptom']}")
|
||||
|
||||
summary = self._local_build_summary(session, answer_map)
|
||||
draft = DiagnosisDraft(
|
||||
category=session.category,
|
||||
urgency=session.urgency,
|
||||
summary=summary,
|
||||
safety_risk=session.safety_risk,
|
||||
suggested_worker=session.suggested_worker,
|
||||
notes=notes,
|
||||
)
|
||||
return DiagnosisResponse(
|
||||
session_id=session.session_id,
|
||||
stage="draft",
|
||||
initial_message=session.initial_message,
|
||||
suggested_categories=session.suggested_categories,
|
||||
questions=[],
|
||||
draft=draft,
|
||||
)
|
||||
|
||||
def _local_classify(
|
||||
self, message: str
|
||||
) -> tuple[str, Literal["低", "中", "高", "紧急"], str, bool, list[DiagnosisQuestion]]:
|
||||
text = message.lower()
|
||||
category = "其他"
|
||||
urgency: Literal["低", "中", "高", "紧急"] = "中"
|
||||
worker = "综合维修"
|
||||
safety_risk = False
|
||||
|
||||
if any(keyword in text for keyword in ["灯", "电", "跳闸", "插座", "焦味"]):
|
||||
category = "电路照明"
|
||||
worker = "电工"
|
||||
urgency = "高"
|
||||
if any(keyword in text for keyword in ["漏水", "水龙头", "下水", "厕所", "马桶"]):
|
||||
category = "给排水"
|
||||
worker = "水暖维修"
|
||||
urgency = "高"
|
||||
if any(keyword in text for keyword in ["门", "锁", "窗", "把手"]):
|
||||
category = "门窗锁具"
|
||||
worker = "门窗维修"
|
||||
urgency = "中"
|
||||
if any(keyword in text for keyword in ["空调", "制冷", "制热"]):
|
||||
category = "空调设备"
|
||||
worker = "空调维修"
|
||||
urgency = "中"
|
||||
if any(keyword in text for keyword in ["网络", "wifi", "网速", "断网"]):
|
||||
category = "网络设备"
|
||||
worker = "网络维护"
|
||||
urgency = "中"
|
||||
if any(keyword in text for keyword in ["烟", "火花", "焦味", "漏电"]):
|
||||
urgency = "紧急"
|
||||
safety_risk = True
|
||||
|
||||
questions = [
|
||||
DiagnosisQuestion(id="location", prompt="问题具体出现在宿舍的什么位置或设备上?"),
|
||||
DiagnosisQuestion(id="impact", prompt="这个问题目前影响范围有多大?比如是否影响整间宿舍使用。"),
|
||||
DiagnosisQuestion(id="symptom", prompt="请补充最明显的故障现象,方便维修人员带对工具。"),
|
||||
]
|
||||
return category, urgency, worker, safety_risk, questions
|
||||
|
||||
def _local_build_summary(self, session: DiagnosisSession, answers: dict[str, str]) -> str:
|
||||
parts = [session.initial_message.strip()]
|
||||
for key in ("location", "impact", "symptom"):
|
||||
value = answers.get(key)
|
||||
if value:
|
||||
parts.append(value)
|
||||
joined = ";".join(parts)
|
||||
return f"{session.category}问题,建议{session.suggested_worker}处理。{joined}"
|
||||
|
||||
async def _call_classify(self, message: str) -> dict[str, Any]:
|
||||
response = await self._client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
{"role": "system", "content": CLASSIFY_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0.1,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
content = body["choices"][0]["message"]["content"]
|
||||
result = json.loads(content)
|
||||
_validate_classify_result(result)
|
||||
return result
|
||||
|
||||
async def _call_summary(self, session: DiagnosisSession, answers: list[DiagnosisQuestionAnswer]) -> str:
|
||||
answer_texts = "\n".join(f"- {item.question_id}: {item.answer.strip()}" for item in answers)
|
||||
response = await self._client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": self._model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"原始描述:{session.initial_message}\n"
|
||||
f"故障类别:{session.category}\n"
|
||||
f"负责工种:{session.suggested_worker}\n"
|
||||
f"补充回答:\n{answer_texts}"
|
||||
),
|
||||
},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
return body["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
||||
def _validate_classify_result(result: dict[str, Any]) -> None:
|
||||
valid_categories = {"电路照明", "给排水", "门窗锁具", "空调设备", "网络设备", "家具设施", "其他"}
|
||||
valid_urgencies = {"低", "中", "高", "紧急"}
|
||||
|
||||
category = result.get("category", "")
|
||||
if category not in valid_categories:
|
||||
result["category"] = "其他"
|
||||
|
||||
urgency = result.get("urgency", "")
|
||||
if urgency not in valid_urgencies:
|
||||
result["urgency"] = "中"
|
||||
|
||||
if not isinstance(result.get("safety_risk"), bool):
|
||||
result["safety_risk"] = False
|
||||
|
||||
worker = result.get("suggested_worker", "")
|
||||
if not worker:
|
||||
result["suggested_worker"] = "综合维修"
|
||||
|
||||
questions = result.get("questions")
|
||||
if not isinstance(questions, list):
|
||||
result["questions"] = []
|
||||
else:
|
||||
result["questions"] = [q for q in questions if isinstance(q, dict) and "id" in q and "prompt" in q]
|
||||
|
||||
notes = result.get("notes")
|
||||
if not isinstance(notes, list):
|
||||
result["notes"] = []
|
||||
|
||||
|
||||
_dp: AIDiagnosisProvider | None = None
|
||||
|
||||
|
||||
def get_diagnosis_provider() -> AIDiagnosisProvider:
|
||||
global _dp
|
||||
if _dp is None:
|
||||
_dp = AIDiagnosisProvider()
|
||||
return _dp
|
||||
|
||||
|
||||
async def close_diagnosis_provider() -> None:
|
||||
global _dp
|
||||
if _dp is not None:
|
||||
await _dp._client.aclose()
|
||||
_dp = None
|
||||
@@ -0,0 +1,662 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.core.schemas import AdminOrderUpdateRequest, FeedbackRequest, OrderCreateRequest, ReworkRequest, UserProfile
|
||||
from app.core.security import generate_token
|
||||
from app.core.settings import UPLOADS_DIR
|
||||
|
||||
STATUS_NEW = "已提交"
|
||||
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
def now_text() -> str:
|
||||
return datetime.now().strftime(DATETIME_FORMAT)
|
||||
|
||||
|
||||
def init_db(connection: sqlite3.Connection) -> None:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repair_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_no TEXT UNIQUE NOT NULL,
|
||||
student_id INTEGER NOT NULL,
|
||||
campus TEXT NOT NULL,
|
||||
building TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
raw_description TEXT NOT NULL,
|
||||
structured_summary TEXT NOT NULL,
|
||||
urgency TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
allow_room_entry INTEGER NOT NULL DEFAULT 0,
|
||||
assignee_name TEXT,
|
||||
expected_date TEXT,
|
||||
expected_time_segment TEXT,
|
||||
expected_arrival_at TEXT,
|
||||
admin_note TEXT,
|
||||
rework_reason TEXT,
|
||||
diagnosis_session_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(student_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
campus TEXT NOT NULL,
|
||||
building TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL,
|
||||
UNIQUE(student_id, campus, building, room),
|
||||
FOREIGN KEY(student_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(order_id) REFERENCES repair_orders(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
actor_role TEXT NOT NULL,
|
||||
actor_name TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
from_status TEXT,
|
||||
to_status TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(order_id) REFERENCES repair_orders(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER UNIQUE NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(order_id) REFERENCES repair_orders(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def create_session(connection: sqlite3.Connection, user_id: int) -> str:
|
||||
token = generate_token()
|
||||
created_at = now_text()
|
||||
expires_at = (datetime.now() + timedelta(days=7)).strftime(DATETIME_FORMAT)
|
||||
connection.execute(
|
||||
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
(token, user_id, created_at, expires_at),
|
||||
)
|
||||
connection.commit()
|
||||
return token
|
||||
|
||||
|
||||
def get_user_by_token(connection: sqlite3.Connection, token: str) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT users.id, users.username, users.display_name, users.role
|
||||
FROM sessions
|
||||
JOIN users ON users.id = sessions.user_id
|
||||
WHERE sessions.token = ? AND sessions.expires_at >= ?
|
||||
""",
|
||||
(token, now_text()),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def profile_from_row(row: sqlite3.Row) -> UserProfile:
|
||||
return UserProfile(id=row["id"], username=row["username"], display_name=row["display_name"], role=row["role"])
|
||||
|
||||
|
||||
def build_order_number(connection: sqlite3.Connection) -> str:
|
||||
count = connection.execute("SELECT COUNT(*) AS count FROM repair_orders").fetchone()["count"] + 1
|
||||
return f"DR{datetime.now().strftime('%Y%m%d')}{count:03d}"
|
||||
|
||||
|
||||
def create_event(
|
||||
connection: sqlite3.Connection,
|
||||
order_id: int,
|
||||
actor_role: str,
|
||||
actor_name: str,
|
||||
event_type: str,
|
||||
title: str,
|
||||
detail: str | None,
|
||||
from_status: str | None,
|
||||
to_status: str | None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO order_events (
|
||||
order_id, actor_role, actor_name, event_type, title, detail, from_status, to_status, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
order_id,
|
||||
actor_role,
|
||||
actor_name,
|
||||
event_type,
|
||||
title,
|
||||
detail,
|
||||
from_status,
|
||||
to_status,
|
||||
now_text(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def upsert_saved_address(
|
||||
connection: sqlite3.Connection,
|
||||
student_id: int,
|
||||
campus: str,
|
||||
building: str,
|
||||
room: str,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO saved_addresses (student_id, campus, building, room, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(student_id, campus, building, room)
|
||||
DO UPDATE SET last_used_at = excluded.last_used_at
|
||||
""",
|
||||
(student_id, campus, building, room, now_text()),
|
||||
)
|
||||
|
||||
|
||||
def create_order(connection: sqlite3.Connection, student: UserProfile, payload: OrderCreateRequest) -> int:
|
||||
order_no = build_order_number(connection)
|
||||
created_at = now_text()
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
INSERT INTO repair_orders (
|
||||
order_no, student_id, campus, building, room, category, raw_description, structured_summary,
|
||||
urgency, status, allow_room_entry, assignee_name, expected_date, expected_time_segment,
|
||||
expected_arrival_at, admin_note, rework_reason, diagnosis_session_id, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL, NULL, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
order_no,
|
||||
student.id,
|
||||
payload.campus,
|
||||
payload.building,
|
||||
payload.room,
|
||||
payload.category,
|
||||
payload.raw_description,
|
||||
payload.structured_summary,
|
||||
payload.urgency,
|
||||
STATUS_NEW,
|
||||
int(payload.allow_room_entry),
|
||||
payload.expected_date,
|
||||
payload.expected_time_segment,
|
||||
payload.diagnosis_session_id,
|
||||
created_at,
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
order_id = cursor.lastrowid
|
||||
create_event(
|
||||
connection,
|
||||
order_id,
|
||||
"student",
|
||||
student.display_name,
|
||||
"created",
|
||||
"学生提交报修",
|
||||
payload.raw_description,
|
||||
None,
|
||||
STATUS_NEW,
|
||||
)
|
||||
upsert_saved_address(connection, student.id, payload.campus, payload.building, payload.room)
|
||||
connection.commit()
|
||||
return order_id
|
||||
|
||||
|
||||
def list_addresses(connection: sqlite3.Connection, student_id: int) -> list[dict[str, Any]]:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id, campus, building, room, last_used_at
|
||||
FROM saved_addresses
|
||||
WHERE student_id = ?
|
||||
ORDER BY last_used_at DESC
|
||||
""",
|
||||
(student_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def delete_address(connection: sqlite3.Connection, address_id: int, student_id: int) -> bool:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM saved_addresses WHERE id = ? AND student_id = ?",
|
||||
(address_id, student_id),
|
||||
)
|
||||
connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def list_orders(
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
student_id: int | None = None,
|
||||
status: str | None = None,
|
||||
category: str | None = None,
|
||||
urgency: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
order_no,
|
||||
campus,
|
||||
building,
|
||||
room,
|
||||
category,
|
||||
status,
|
||||
urgency,
|
||||
created_at AS submission_time,
|
||||
COALESCE(
|
||||
expected_arrival_at,
|
||||
expected_date || ' ' || COALESCE(expected_time_segment, '')
|
||||
) AS expected_repair_time,
|
||||
assignee_name
|
||||
FROM repair_orders
|
||||
WHERE 1 = 1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
if student_id is not None:
|
||||
query += " AND student_id = ?"
|
||||
params.append(student_id)
|
||||
if status:
|
||||
query += " AND status = ?"
|
||||
params.append(status)
|
||||
if category:
|
||||
query += " AND category = ?"
|
||||
params.append(category)
|
||||
if urgency:
|
||||
query += " AND urgency = ?"
|
||||
params.append(urgency)
|
||||
query += " ORDER BY updated_at DESC"
|
||||
rows = connection.execute(query, params).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _get_order_row(connection: sqlite3.Connection, order_id: int) -> sqlite3.Row | None:
|
||||
return connection.execute("SELECT * FROM repair_orders WHERE id = ?", (order_id,)).fetchone()
|
||||
|
||||
|
||||
def get_order_detail(
|
||||
connection: sqlite3.Connection,
|
||||
order_id: int,
|
||||
*,
|
||||
student_id: int | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
row = _get_order_row(connection, order_id)
|
||||
if row is None:
|
||||
return None
|
||||
if student_id is not None and row["student_id"] != student_id:
|
||||
return None
|
||||
|
||||
attachments = connection.execute(
|
||||
"SELECT id, file_name, file_path, mime_type, created_at FROM attachments WHERE order_id = ? ORDER BY id ASC",
|
||||
(order_id,),
|
||||
).fetchall()
|
||||
events = connection.execute(
|
||||
"""
|
||||
SELECT id, actor_role, actor_name, event_type, title, detail, from_status, to_status, created_at
|
||||
FROM order_events
|
||||
WHERE order_id = ?
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchall()
|
||||
feedback_row = connection.execute(
|
||||
"SELECT rating, comment, created_at FROM feedback WHERE order_id = ?",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
detail = dict(row)
|
||||
detail["attachments"] = [dict(item) for item in attachments]
|
||||
detail["events"] = [dict(item) for item in events]
|
||||
detail["feedback"] = dict(feedback_row) if feedback_row else None
|
||||
return detail
|
||||
|
||||
|
||||
_ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
_MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
_VALID_TRANSITIONS: dict[str, set[str]] = {
|
||||
"已提交": {"待处理", "已取消"},
|
||||
"待处理": {"处理中", "已取消"},
|
||||
"处理中": {"待上门", "已完成"},
|
||||
"待上门": {"处理中", "已完成"},
|
||||
"已完成": {"已确认", "返工申请中"},
|
||||
"已确认": set(),
|
||||
"返工申请中": {"处理中"},
|
||||
"已取消": set(),
|
||||
}
|
||||
|
||||
|
||||
def _detect_image_type(header: bytes) -> str | None:
|
||||
if header[:3] == b"\xff\xd8\xff":
|
||||
return ".jpg"
|
||||
if header[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return ".png"
|
||||
if header[:4] == b"GIF8":
|
||||
return ".gif"
|
||||
if header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP":
|
||||
return ".webp"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_status_transition(from_status: str, to_status: str) -> None:
|
||||
allowed = _VALID_TRANSITIONS.get(from_status, set())
|
||||
if to_status not in allowed:
|
||||
raise ValueError(f"不允许的状态转换:{from_status} → {to_status}")
|
||||
|
||||
|
||||
def save_attachment(connection: sqlite3.Connection, order_id: int, file: UploadFile) -> None:
|
||||
ext = Path(file.filename or "upload").suffix.lower() or ".jpg"
|
||||
if ext not in _ALLOWED_EXTENSIONS:
|
||||
raise ValueError("unsupported_file_type")
|
||||
|
||||
raw = file.file.read()
|
||||
if len(raw) > _MAX_UPLOAD_SIZE:
|
||||
raise ValueError("file_too_large")
|
||||
|
||||
detected = _detect_image_type(raw[:12])
|
||||
if detected is None:
|
||||
raise ValueError("unsupported_file_type")
|
||||
|
||||
relative_dir = Path(str(order_id))
|
||||
target_dir = UPLOADS_DIR / relative_dir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_name = f"{uuid4().hex}{detected}"
|
||||
target_path = target_dir / file_name
|
||||
target_path.write_bytes(raw)
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO attachments (order_id, file_name, file_path, mime_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(order_id, file.filename or file_name, str(relative_dir / file_name), f"image/{detected[1:]}", now_text()),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def confirm_order(connection: sqlite3.Connection, order_id: int, student: UserProfile) -> None:
|
||||
row = _get_order_row(connection, order_id)
|
||||
if row is None or row["student_id"] != student.id:
|
||||
raise KeyError("order_not_found")
|
||||
if row["status"] != "已完成":
|
||||
raise ValueError("order_not_completed")
|
||||
previous = row["status"]
|
||||
connection.execute(
|
||||
"UPDATE repair_orders SET status = ?, updated_at = ? WHERE id = ?",
|
||||
("已确认", now_text(), order_id),
|
||||
)
|
||||
create_event(
|
||||
connection,
|
||||
order_id,
|
||||
"student",
|
||||
student.display_name,
|
||||
"confirmed",
|
||||
"学生确认维修完成",
|
||||
None,
|
||||
previous,
|
||||
"已确认",
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def request_rework(connection: sqlite3.Connection, order_id: int, student: UserProfile, payload: ReworkRequest) -> None:
|
||||
row = _get_order_row(connection, order_id)
|
||||
if row is None or row["student_id"] != student.id:
|
||||
raise KeyError("order_not_found")
|
||||
if row["status"] != "已完成":
|
||||
raise ValueError("order_not_completed")
|
||||
previous = row["status"]
|
||||
connection.execute(
|
||||
"UPDATE repair_orders SET status = ?, rework_reason = ?, updated_at = ? WHERE id = ?",
|
||||
("返工申请中", payload.reason, now_text(), order_id),
|
||||
)
|
||||
create_event(
|
||||
connection,
|
||||
order_id,
|
||||
"student",
|
||||
student.display_name,
|
||||
"rework_requested",
|
||||
"学生申请返工",
|
||||
payload.reason,
|
||||
previous,
|
||||
"返工申请中",
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def add_feedback(connection: sqlite3.Connection, order_id: int, student: UserProfile, payload: FeedbackRequest) -> None:
|
||||
row = _get_order_row(connection, order_id)
|
||||
if row is None or row["student_id"] != student.id:
|
||||
raise KeyError("order_not_found")
|
||||
if row["status"] not in {"已完成", "已确认"}:
|
||||
raise ValueError("order_not_ready_for_feedback")
|
||||
connection.execute(
|
||||
"INSERT OR REPLACE INTO feedback (order_id, rating, comment, created_at) VALUES (?, ?, ?, ?)",
|
||||
(order_id, payload.rating, payload.comment, now_text()),
|
||||
)
|
||||
create_event(
|
||||
connection,
|
||||
order_id,
|
||||
"student",
|
||||
student.display_name,
|
||||
"feedback_added",
|
||||
"学生提交评价",
|
||||
payload.comment,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def update_order(
|
||||
connection: sqlite3.Connection, order_id: int, admin: UserProfile, payload: AdminOrderUpdateRequest
|
||||
) -> None:
|
||||
row = _get_order_row(connection, order_id)
|
||||
if row is None:
|
||||
raise KeyError("order_not_found")
|
||||
|
||||
previous_status = row["status"]
|
||||
assignee_name = payload.assignee_name if payload.assignee_name is not None else row["assignee_name"]
|
||||
expected_arrival_at = (
|
||||
payload.expected_arrival_at if payload.expected_arrival_at is not None else row["expected_arrival_at"]
|
||||
)
|
||||
admin_note = payload.admin_note if payload.admin_note is not None else row["admin_note"]
|
||||
status = payload.status if payload.status is not None else row["status"]
|
||||
|
||||
if payload.status is not None and payload.status != row["status"]:
|
||||
_validate_status_transition(row["status"], payload.status)
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE repair_orders
|
||||
SET status = ?, assignee_name = ?, expected_arrival_at = ?, admin_note = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, assignee_name, expected_arrival_at, admin_note, now_text(), order_id),
|
||||
)
|
||||
detail_parts = []
|
||||
if assignee_name:
|
||||
detail_parts.append(f"负责人:{assignee_name}")
|
||||
if expected_arrival_at:
|
||||
detail_parts.append(f"预计上门:{expected_arrival_at}")
|
||||
if admin_note:
|
||||
detail_parts.append(f"备注:{admin_note}")
|
||||
detail = ";".join(detail_parts) or None
|
||||
create_event(
|
||||
connection,
|
||||
order_id,
|
||||
"admin",
|
||||
admin.display_name,
|
||||
"admin_updated",
|
||||
"管理员更新工单",
|
||||
detail,
|
||||
previous_status,
|
||||
status,
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def accept_rework(connection: sqlite3.Connection, order_id: int, admin: UserProfile) -> None:
|
||||
row = _get_order_row(connection, order_id)
|
||||
if row is None:
|
||||
raise KeyError("order_not_found")
|
||||
if row["status"] != "返工申请中":
|
||||
raise ValueError("order_not_in_rework")
|
||||
previous = row["status"]
|
||||
connection.execute(
|
||||
"UPDATE repair_orders SET status = ?, rework_reason = NULL, updated_at = ? WHERE id = ?",
|
||||
("处理中", now_text(), order_id),
|
||||
)
|
||||
create_event(
|
||||
connection,
|
||||
order_id,
|
||||
"admin",
|
||||
admin.display_name,
|
||||
"rework_accepted",
|
||||
"管理员接受返工申请,重新安排处理",
|
||||
None,
|
||||
previous,
|
||||
"处理中",
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def reject_rework(connection: sqlite3.Connection, order_id: int, admin: UserProfile) -> None:
|
||||
row = _get_order_row(connection, order_id)
|
||||
if row is None:
|
||||
raise KeyError("order_not_found")
|
||||
if row["status"] != "返工申请中":
|
||||
raise ValueError("order_not_in_rework")
|
||||
previous = row["status"]
|
||||
connection.execute(
|
||||
"UPDATE repair_orders SET status = ?, rework_reason = NULL, updated_at = ? WHERE id = ?",
|
||||
("已完成", now_text(), order_id),
|
||||
)
|
||||
create_event(
|
||||
connection,
|
||||
order_id,
|
||||
"admin",
|
||||
admin.display_name,
|
||||
"rework_rejected",
|
||||
"管理员拒绝返工申请,维持已完成状态",
|
||||
None,
|
||||
previous,
|
||||
"已完成",
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def cancel_order(connection: sqlite3.Connection, order_id: int, student: UserProfile) -> None:
|
||||
row = _get_order_row(connection, order_id)
|
||||
if row is None or row["student_id"] != student.id:
|
||||
raise KeyError("order_not_found")
|
||||
if row["status"] not in {"已提交", "待处理"}:
|
||||
raise ValueError("order_cannot_cancel")
|
||||
connection.execute(
|
||||
"UPDATE repair_orders SET status = ?, updated_at = ? WHERE id = ?",
|
||||
("已取消", now_text(), order_id),
|
||||
)
|
||||
create_event(
|
||||
connection,
|
||||
order_id,
|
||||
"student",
|
||||
student.display_name,
|
||||
"status_updated",
|
||||
"学生取消了报修工单",
|
||||
None,
|
||||
row["status"],
|
||||
"已取消",
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def get_stats(connection: sqlite3.Connection) -> dict[str, object]:
|
||||
cursor = connection.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT category, COUNT(*) AS cnt
|
||||
FROM repair_orders
|
||||
GROUP BY category
|
||||
ORDER BY cnt DESC
|
||||
"""
|
||||
)
|
||||
category_distribution = [{"category": row["category"], "count": row["cnt"]} for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT building, COUNT(*) AS cnt
|
||||
FROM repair_orders
|
||||
GROUP BY building
|
||||
ORDER BY cnt DESC
|
||||
"""
|
||||
)
|
||||
building_distribution = [{"building": row["building"], "count": row["cnt"]} for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT AVG(
|
||||
(julianday(events.first_done_at) - julianday(orders.created_at)) * 24
|
||||
) AS avg_hours
|
||||
FROM repair_orders orders
|
||||
JOIN (
|
||||
SELECT order_id, MIN(created_at) AS first_done_at
|
||||
FROM order_events
|
||||
WHERE to_status = '已完成'
|
||||
GROUP BY order_id
|
||||
) events ON events.order_id = orders.id
|
||||
"""
|
||||
)
|
||||
avg_row = cursor.fetchone()
|
||||
avg_hours = round(avg_row["avg_hours"], 1) if avg_row and avg_row["avg_hours"] is not None else 0
|
||||
|
||||
cursor.execute("SELECT AVG(rating) AS avg_rating FROM feedback")
|
||||
rating_row = cursor.fetchone()
|
||||
avg_rating = round(rating_row["avg_rating"], 1) if rating_row and rating_row["avg_rating"] is not None else 0
|
||||
|
||||
return {
|
||||
"category_distribution": category_distribution,
|
||||
"building_distribution": building_distribution,
|
||||
"avg_processing_hours": avg_hours,
|
||||
"avg_rating": avg_rating,
|
||||
}
|
||||
Reference in New Issue
Block a user