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 @@
+35
View File
@@ -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
+84
View File
@@ -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"}
+32
View File
@@ -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
+161
View File
@@ -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"}
+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)
+96
View File
@@ -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"}
+379
View File
@@ -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
+662
View File
@@ -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,
}