This commit is contained in:
2026-06-06 23:54:11 +08:00
commit 33639129b1
58 changed files with 10309 additions and 0 deletions
+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,
}