323 lines
11 KiB
Python
323 lines
11 KiB
Python
from datetime import date, datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import DateTime, Integer, String, Text, func, select
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from app.db import Base
|
|
|
|
|
|
def _mysql_args() -> dict:
|
|
return {"mysql_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}
|
|
|
|
|
|
QUESTION_STATUSES = ("pending", "replied")
|
|
DEFAULT_REPLY_WORKDAYS = 3
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
class ExpertQuestion(Base):
|
|
__tablename__ = "expert_question"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
member_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
title: Mapped[str] = mapped_column(String(200), nullable=False, default="")
|
|
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=_now, index=True)
|
|
replied_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
|
|
|
|
class ExpertQuestionAttachment(Base):
|
|
__tablename__ = "expert_question_attachment"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
question_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
file_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
class ExpertReply(Base):
|
|
__tablename__ = "expert_reply"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
question_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
|
|
body_html: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
staff_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=_now)
|
|
|
|
|
|
class ExpertReplyAttachment(Base):
|
|
__tablename__ = "expert_reply_attachment"
|
|
__table_args__ = _mysql_args()
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
reply_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
|
file_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
|
|
def reply_workdays(db) -> int:
|
|
from app.models.content import SiteSetting
|
|
|
|
row = db.scalar(select(SiteSetting).where(SiteSetting.key == "expert_reply_workdays"))
|
|
raw = (row.value if row else "").strip()
|
|
if not raw:
|
|
return DEFAULT_REPLY_WORKDAYS
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
return DEFAULT_REPLY_WORKDAYS
|
|
return value if value > 0 else DEFAULT_REPLY_WORKDAYS
|
|
|
|
|
|
def workday_deadline(start: datetime | date, days: int) -> date:
|
|
current = start.date() if isinstance(start, datetime) else start
|
|
remaining = max(int(days), 0)
|
|
while remaining > 0:
|
|
current = current + timedelta(days=1)
|
|
if current.weekday() < 5:
|
|
remaining -= 1
|
|
return current
|
|
|
|
|
|
def is_overdue(created_at: datetime, days: int, *, now: datetime | None = None) -> bool:
|
|
if created_at is None:
|
|
return False
|
|
current = now or _now()
|
|
return current.date() > workday_deadline(created_at, days)
|
|
|
|
|
|
def _private_file(db, file_id: int):
|
|
from app.models.content import AdminFile
|
|
|
|
if file_id is None:
|
|
return None
|
|
row = db.get(AdminFile, file_id)
|
|
if row is None or row.bucket != "private":
|
|
return None
|
|
return row
|
|
|
|
|
|
def _attachment_payload(db, file_id: int) -> dict | None:
|
|
from app.services.storage import private_object_url
|
|
|
|
row = _private_file(db, file_id)
|
|
if row is None:
|
|
return None
|
|
url, expires_at = private_object_url(row.object_key)
|
|
return {
|
|
"id": row.id,
|
|
"filename": row.filename,
|
|
"contentType": row.content_type or "",
|
|
"url": url,
|
|
"expiresAt": expires_at.isoformat(),
|
|
}
|
|
|
|
|
|
def _question_attachments(db, question_id: int) -> list[dict]:
|
|
links = db.scalars(
|
|
select(ExpertQuestionAttachment)
|
|
.where(ExpertQuestionAttachment.question_id == question_id)
|
|
.order_by(ExpertQuestionAttachment.position.asc(), ExpertQuestionAttachment.id.asc())
|
|
).all()
|
|
items = []
|
|
for link in links:
|
|
payload = _attachment_payload(db, link.file_id)
|
|
if payload is not None:
|
|
items.append(payload)
|
|
return items
|
|
|
|
|
|
def _reply_attachments(db, reply_id: int) -> list[dict]:
|
|
links = db.scalars(
|
|
select(ExpertReplyAttachment)
|
|
.where(ExpertReplyAttachment.reply_id == reply_id)
|
|
.order_by(ExpertReplyAttachment.position.asc(), ExpertReplyAttachment.id.asc())
|
|
).all()
|
|
items = []
|
|
for link in links:
|
|
payload = _attachment_payload(db, link.file_id)
|
|
if payload is not None:
|
|
items.append(payload)
|
|
return items
|
|
|
|
|
|
def _question_list_item(db, row: ExpertQuestion, *, days: int, include_member: bool = False) -> dict:
|
|
from app.models.member import get_member_by_id
|
|
|
|
item = {
|
|
"id": row.id,
|
|
"title": row.title,
|
|
"status": row.status,
|
|
"statusLabel": "已回复" if row.status == "replied" else "待回复",
|
|
"createdAt": row.created_at.isoformat(timespec="seconds") if row.created_at else "",
|
|
"repliedAt": row.replied_at.isoformat(timespec="seconds") if row.replied_at else "",
|
|
"overdue": bool(row.status == "pending" and is_overdue(row.created_at, days)),
|
|
}
|
|
if include_member:
|
|
member = get_member_by_id(db, row.member_id)
|
|
item["memberId"] = row.member_id
|
|
item["memberEmail"] = member.email if member else ""
|
|
return item
|
|
|
|
|
|
def create_question(db, *, member_id: int, title: str, body: str, attachment_file_ids: list[int]) -> int:
|
|
cleaned_title = (title or "").strip()
|
|
cleaned_body = (body or "").strip()
|
|
if not cleaned_title:
|
|
raise ValueError("标题不能为空")
|
|
if not cleaned_body:
|
|
raise ValueError("正文不能为空")
|
|
if len(cleaned_title) > 200:
|
|
raise ValueError("标题过长")
|
|
files = []
|
|
for file_id in attachment_file_ids or []:
|
|
row = _private_file(db, file_id)
|
|
if row is None:
|
|
raise ValueError("附件必须是已上传的私有文件")
|
|
files.append(row)
|
|
question = ExpertQuestion(
|
|
member_id=member_id,
|
|
title=cleaned_title,
|
|
body=cleaned_body,
|
|
status="pending",
|
|
created_at=_now(),
|
|
)
|
|
db.add(question)
|
|
db.flush()
|
|
for index, file_row in enumerate(files):
|
|
db.add(
|
|
ExpertQuestionAttachment(
|
|
question_id=question.id,
|
|
file_id=file_row.id,
|
|
position=index,
|
|
)
|
|
)
|
|
db.commit()
|
|
db.refresh(question)
|
|
return question.id
|
|
|
|
|
|
def member_questions(db, member_id: int) -> dict:
|
|
days = reply_workdays(db)
|
|
rows = db.scalars(
|
|
select(ExpertQuestion)
|
|
.where(ExpertQuestion.member_id == member_id)
|
|
.order_by(ExpertQuestion.created_at.desc(), ExpertQuestion.id.desc())
|
|
).all()
|
|
return {"items": [_question_list_item(db, row, days=days) for row in rows]}
|
|
|
|
|
|
def member_question(db, member_id: int, question_id: int) -> dict | None:
|
|
row = db.get(ExpertQuestion, question_id)
|
|
if row is None or row.member_id != member_id:
|
|
return None
|
|
days = reply_workdays(db)
|
|
body = _question_list_item(db, row, days=days)
|
|
body["body"] = row.body
|
|
body["attachments"] = _question_attachments(db, row.id)
|
|
reply = db.scalar(select(ExpertReply).where(ExpertReply.question_id == row.id))
|
|
if reply is not None:
|
|
body["reply"] = {
|
|
"bodyHtml": reply.body_html,
|
|
"createdAt": reply.created_at.isoformat(timespec="seconds") if reply.created_at else "",
|
|
"attachments": _reply_attachments(db, reply.id),
|
|
}
|
|
return body
|
|
|
|
|
|
def pending_count(db) -> int:
|
|
return int(
|
|
db.scalar(
|
|
select(func.count())
|
|
.select_from(ExpertQuestion)
|
|
.where(ExpertQuestion.status == "pending")
|
|
)
|
|
or 0
|
|
)
|
|
|
|
|
|
def admin_questions(db, *, status: str | None = None) -> dict:
|
|
days = reply_workdays(db)
|
|
stmt = select(ExpertQuestion).order_by(ExpertQuestion.created_at.desc(), ExpertQuestion.id.desc())
|
|
if status in QUESTION_STATUSES:
|
|
stmt = stmt.where(ExpertQuestion.status == status)
|
|
rows = db.scalars(stmt).all()
|
|
return {
|
|
"items": [_question_list_item(db, row, days=days, include_member=True) for row in rows],
|
|
"pendingCount": pending_count(db),
|
|
"replyWorkdays": days,
|
|
}
|
|
|
|
|
|
def admin_question(db, question_id: int) -> dict | None:
|
|
row = db.get(ExpertQuestion, question_id)
|
|
if row is None:
|
|
return None
|
|
days = reply_workdays(db)
|
|
body = _question_list_item(db, row, days=days, include_member=True)
|
|
body["body"] = row.body
|
|
body["attachments"] = _question_attachments(db, row.id)
|
|
reply = db.scalar(select(ExpertReply).where(ExpertReply.question_id == row.id))
|
|
if reply is not None:
|
|
body["reply"] = {
|
|
"bodyHtml": reply.body_html,
|
|
"createdAt": reply.created_at.isoformat(timespec="seconds") if reply.created_at else "",
|
|
"attachments": _reply_attachments(db, reply.id),
|
|
}
|
|
return body
|
|
|
|
|
|
def reply_question(
|
|
db,
|
|
*,
|
|
question_id: int,
|
|
staff_id: int,
|
|
body_html: str,
|
|
attachment_file_ids: list[int],
|
|
) -> None:
|
|
from app.core.config import get_settings
|
|
from app.models.content import AdminFile
|
|
from app.services.html_filter import filter_html
|
|
|
|
question = db.get(ExpertQuestion, question_id)
|
|
if question is None:
|
|
raise ValueError("提问不存在")
|
|
if question.status != "pending":
|
|
raise ValueError("该提问已回复")
|
|
files = []
|
|
for file_id in attachment_file_ids or []:
|
|
row = _private_file(db, file_id)
|
|
if row is None:
|
|
raise ValueError("附件必须是已上传的私有文件")
|
|
files.append(row)
|
|
keys = list(db.scalars(select(AdminFile.object_key).where(AdminFile.bucket == "public")))
|
|
cleaned = filter_html(body_html or "", list(keys), get_settings().minio_base_url)
|
|
if not cleaned.strip():
|
|
raise ValueError("回复正文不能为空")
|
|
reply = ExpertReply(
|
|
question_id=question.id,
|
|
body_html=cleaned,
|
|
staff_id=staff_id,
|
|
created_at=_now(),
|
|
)
|
|
db.add(reply)
|
|
db.flush()
|
|
for index, file_row in enumerate(files):
|
|
db.add(
|
|
ExpertReplyAttachment(
|
|
reply_id=reply.id,
|
|
file_id=file_row.id,
|
|
position=index,
|
|
)
|
|
)
|
|
question.status = "replied"
|
|
question.replied_at = _now()
|
|
db.commit()
|