You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
137 lines
4.1 KiB
137 lines
4.1 KiB
from __future__ import annotations
|
|
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
import secrets
|
|
|
|
from app.extensions import db
|
|
from app.models import ShareLink
|
|
|
|
DEFAULT_SHARE_LINK_EXPIRE_DAYS = 30
|
|
MIN_SHARE_LINK_EXPIRE_DAYS = 1
|
|
MAX_SHARE_LINK_EXPIRE_DAYS = 365
|
|
SHARE_LINK_TOKEN_BYTES = 16
|
|
SHARE_EDITABLE_FIELDS = (
|
|
"phone",
|
|
"attendance_status",
|
|
"actual_attendee_count",
|
|
"child_count",
|
|
"red_packet_child_count",
|
|
"total_gift_amount",
|
|
"gift_method_option_id",
|
|
"gift_scene_option_id",
|
|
"favor_status",
|
|
"candy_status",
|
|
"child_red_packet_status",
|
|
"note",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ShareTokenCheckResult:
|
|
ok: bool
|
|
reason: str
|
|
link: ShareLink | None = None
|
|
|
|
|
|
def list_share_links() -> Sequence[ShareLink]:
|
|
result = db.session.execute(
|
|
db.select(ShareLink).order_by(ShareLink.created_at.desc(), ShareLink.id.desc()),
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
def get_share_link_by_token(token: str) -> ShareLink | None:
|
|
normalized = token.strip()
|
|
if not normalized:
|
|
return None
|
|
result = db.session.execute(db.select(ShareLink).where(ShareLink.token == normalized).limit(1))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
def get_share_link_or_none(link_id: int) -> ShareLink | None:
|
|
return db.session.get(ShareLink, link_id)
|
|
|
|
|
|
def normalize_expire_days(raw_days: int) -> int:
|
|
if raw_days < MIN_SHARE_LINK_EXPIRE_DAYS:
|
|
return MIN_SHARE_LINK_EXPIRE_DAYS
|
|
if raw_days > MAX_SHARE_LINK_EXPIRE_DAYS:
|
|
return MAX_SHARE_LINK_EXPIRE_DAYS
|
|
return raw_days
|
|
|
|
|
|
def parse_share_link_create_form(form: dict[str, str]) -> tuple[dict[str, object], list[str]]:
|
|
errors: list[str] = []
|
|
label = form.get("label", "").strip() or None
|
|
raw_expire_days = form.get("expire_days", "").strip() or str(DEFAULT_SHARE_LINK_EXPIRE_DAYS)
|
|
|
|
try:
|
|
expire_days = int(raw_expire_days)
|
|
except ValueError:
|
|
return {}, ["有效期天数必须是整数。"]
|
|
|
|
if expire_days < MIN_SHARE_LINK_EXPIRE_DAYS or expire_days > MAX_SHARE_LINK_EXPIRE_DAYS:
|
|
errors.append(f"有效期天数必须在 {MIN_SHARE_LINK_EXPIRE_DAYS}-{MAX_SHARE_LINK_EXPIRE_DAYS} 之间。")
|
|
|
|
if errors:
|
|
return {}, errors
|
|
|
|
return {"label": label, "expire_days": expire_days}, []
|
|
|
|
|
|
def create_share_link(*, created_by: int | None, label: str | None, expire_days: int) -> ShareLink:
|
|
normalized_days = normalize_expire_days(expire_days)
|
|
token = _generate_unique_share_token()
|
|
link = ShareLink(
|
|
token=token,
|
|
label=(label or "").strip() or None,
|
|
expires_at=_build_expire_at(normalized_days),
|
|
revoked_at=None,
|
|
created_by=created_by,
|
|
)
|
|
db.session.add(link)
|
|
db.session.flush()
|
|
return link
|
|
|
|
|
|
def revoke_share_link(link: ShareLink, *, now: datetime | None = None) -> bool:
|
|
if link.revoked_at is not None:
|
|
return False
|
|
|
|
link.revoked_at = (now or datetime.now(UTC)).replace(tzinfo=None)
|
|
return True
|
|
|
|
|
|
def check_share_token(token: str, *, now: datetime | None = None) -> ShareTokenCheckResult:
|
|
link = get_share_link_by_token(token)
|
|
if link is None:
|
|
return ShareTokenCheckResult(ok=False, reason="not_found")
|
|
|
|
current = (now or datetime.now(UTC)).replace(tzinfo=None)
|
|
if link.revoked_at is not None:
|
|
return ShareTokenCheckResult(ok=False, reason="revoked", link=link)
|
|
if link.expires_at <= current:
|
|
return ShareTokenCheckResult(ok=False, reason="expired", link=link)
|
|
return ShareTokenCheckResult(ok=True, reason="ok", link=link)
|
|
|
|
|
|
def build_share_link_url(token: str) -> str:
|
|
return f"/s/{token}"
|
|
|
|
|
|
def _build_expire_at(expire_days: int) -> datetime:
|
|
return (datetime.now(UTC) + timedelta(days=expire_days)).replace(tzinfo=None)
|
|
|
|
|
|
def _generate_share_token() -> str:
|
|
return secrets.token_urlsafe(SHARE_LINK_TOKEN_BYTES)
|
|
|
|
|
|
def _generate_unique_share_token() -> str:
|
|
while True:
|
|
token = _generate_share_token()
|
|
exists = db.session.execute(db.select(ShareLink.id).where(ShareLink.token == token).limit(1)).scalar_one_or_none()
|
|
if exists is None:
|
|
return token
|
|
|