From b58956937e9ea9eaf484c3caa99d8adbab6615d9 Mon Sep 17 00:00:00 2001 From: Zichao Lin Date: Mon, 6 Jul 2026 01:13:07 +0800 Subject: [PATCH] feat(invite): add invite code registration system --- app/models.py | 18 ++- app/routers/web.py | 189 ++++++++++++++++++++++++++++++- app/templates/admin_invites.html | 103 +++++++++++++++++ app/templates/base.html | 3 + app/templates/login.html | 1 + app/templates/register.html | 23 ++++ 6 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 app/templates/admin_invites.html create mode 100644 app/templates/register.html diff --git a/app/models.py b/app/models.py index f11466d..e76aa32 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy.orm import relationship, validates from app.database import Base @@ -16,11 +16,27 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) username = Column(String(64), unique=True, nullable=False, index=True) password_hash = Column(String(128), nullable=False) + is_admin = Column(Boolean, nullable=False, default=False) showcase_mode = Column(String(16), nullable=False, default="profile") created_at = Column(DateTime, default=_utcnow) profiles = relationship("Profile", back_populates="user", cascade="all, delete-orphan") sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan") + invite_codes = relationship("InviteCode", back_populates="creator", cascade="all, delete-orphan") + + +class InviteCode(Base): + __tablename__ = "invite_codes" + + id = Column(Integer, primary_key=True, index=True) + code = Column(String(8), unique=True, nullable=False, index=True) + max_uses = Column(Integer, nullable=True) # None = unlimited + expires_at = Column(DateTime, nullable=True) # None = no expiration + use_count = Column(Integer, nullable=False, default=0) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=_utcnow) + + creator = relationship("User", back_populates="invite_codes") class UserSession(Base): diff --git a/app/routers/web.py b/app/routers/web.py index 542e73a..cb0ba92 100644 --- a/app/routers/web.py +++ b/app/routers/web.py @@ -17,7 +17,11 @@ from app.auth import ( ) from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR from app.database import get_db -from app.models import Postcard, Profile, User +from datetime import datetime +from random import choice +from string import ascii_letters, digits + +from app.models import InviteCode, Postcard, Profile, User router = APIRouter(tags=["web"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) @@ -71,6 +75,189 @@ def login_submit( return resp +# ---------- Register ---------- + +@router.get("/register", response_class=HTMLResponse) +def register_page(request: Request, db: Session = Depends(get_db)): + user = redirect_if_not_logged_in(request, db) + if user: + return _redirect("/dashboard") + return templates.TemplateResponse("register.html", {"request": request}) + + +@router.post("/register") +def register_submit( + request: Request, + invite_code: str = Form(...), + username: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), + db: Session = Depends(get_db), +): + # Validate invite code + code_obj = db.query(InviteCode).filter(InviteCode.code == invite_code).first() + if not code_obj: + return templates.TemplateResponse( + "register.html", {"request": request, "error": "邀请码无效"}, status_code=400 + ) + if code_obj.expires_at and code_obj.expires_at < datetime.utcnow(): + return templates.TemplateResponse( + "register.html", {"request": request, "error": "邀请码已过期"}, status_code=400 + ) + if code_obj.max_uses is not None and code_obj.use_count >= code_obj.max_uses: + return templates.TemplateResponse( + "register.html", {"request": request, "error": "邀请码使用次数已用尽"}, status_code=400 + ) + # Validate passwords + if password != confirm_password: + return templates.TemplateResponse( + "register.html", {"request": request, "error": "两次输入的密码不一致"}, status_code=400 + ) + if len(password) < 6: + return templates.TemplateResponse( + "register.html", {"request": request, "error": "密码至少6个字符"}, status_code=400 + ) + # Check username + if db.query(User).filter(User.username == username).first(): + return templates.TemplateResponse( + "register.html", {"request": request, "error": "用户名已存在"}, status_code=400 + ) + # Create user + new_user = User(username=username, password_hash=hash_password(password)) + db.add(new_user) + db.flush() + # Update invite code usage + code_obj.use_count += 1 + db.commit() + # Auto-login + token = create_session(new_user.id, db) + resp = _redirect("/dashboard") + resp.set_cookie(SESSION_COOKIE_NAME, token, httponly=True, samesite="lax") + return resp + + +# ---------- Admin ---------- + +def _generate_code() -> str: + """Generate a random 8-character alphanumeric code.""" + return "".join(choice(ascii_letters + digits) for _ in range(8)) + + +def _require_admin(user: User) -> bool: + return user.is_admin + + +@router.get("/admin/invites", response_class=HTMLResponse) +def admin_invites_page( + request: Request, + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + if not _require_admin(user): + return _redirect("/dashboard") + codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all() + return templates.TemplateResponse("admin_invites.html", { + "request": request, "user": user, "codes": codes, + }) + + +@router.post("/admin/invites/add") +def admin_add_invite( + request: Request, + code: str = Form(""), + limit_type: str = Form("unlimited"), + max_uses: int = Form(None), + expires_at: str = Form(None), + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + if not _require_admin(user): + return _redirect("/dashboard") + # Generate or validate code + if not code.strip(): + code = _generate_code() + else: + code = code.strip() + if len(code) != 8: + codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all() + return templates.TemplateResponse("admin_invites.html", { + "request": request, "user": user, "codes": codes, + "error": "邀请码必须为8个字符", + }) + if db.query(InviteCode).filter(InviteCode.code == code).first(): + codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all() + return templates.TemplateResponse("admin_invites.html", { + "request": request, "user": user, "codes": codes, + "error": "邀请码已存在", + }) + # Parse limits + m_uses = None + exp_at = None + if limit_type == "uses" and max_uses: + m_uses = max_uses + elif limit_type == "expires" and expires_at: + exp_at = datetime.fromisoformat(expires_at) + new_code = InviteCode( + code=code, max_uses=m_uses, expires_at=exp_at, + use_count=0, created_by=user.id, + ) + db.add(new_code) + db.commit() + codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all() + return templates.TemplateResponse("admin_invites.html", { + "request": request, "user": user, "codes": codes, + "success": f"邀请码 {code} 已创建", + }) + + +@router.post("/admin/invites/{code_id}/edit") +def admin_edit_invite( + request: Request, + code_id: int, + limit_type: str = Form("unlimited"), + max_uses: int = Form(None), + expires_at: str = Form(None), + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + if not _require_admin(user): + return _redirect("/dashboard") + code_obj = db.query(InviteCode).filter(InviteCode.id == code_id).first() + if not code_obj: + return _redirect("/admin/invites") + if limit_type == "uses" and max_uses: + code_obj.max_uses = max_uses + code_obj.expires_at = None + elif limit_type == "expires" and expires_at: + code_obj.expires_at = datetime.fromisoformat(expires_at) + code_obj.max_uses = None + else: + code_obj.max_uses = None + code_obj.expires_at = None + db.commit() + codes = db.query(InviteCode).order_by(InviteCode.created_at.desc()).all() + return templates.TemplateResponse("admin_invites.html", { + "request": request, "user": user, "codes": codes, + "success": f"邀请码 {code_obj.code} 已更新", + }) + + +@router.post("/admin/invites/{code_id}/delete") +def admin_delete_invite( + request: Request, + code_id: int, + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + if not _require_admin(user): + return _redirect("/dashboard") + code_obj = db.query(InviteCode).filter(InviteCode.id == code_id).first() + if code_obj: + db.delete(code_obj) + db.commit() + return _redirect("/admin/invites") + + # ---------- Settings ---------- @router.get("/settings", response_class=HTMLResponse) diff --git a/app/templates/admin_invites.html b/app/templates/admin_invites.html new file mode 100644 index 0000000..1ad79a6 --- /dev/null +++ b/app/templates/admin_invites.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} +{% block title %}邀请码管理 - 星笺{% endblock %} +{% block content %} +
+

邀请码管理

+
+ +{% if success %} +
{{ success }}
+{% endif %} +{% if error %} +
{{ error }}
+{% endif %} + + +
+

创建邀请码

+
+
+ + +
+
+ + +
+ + + +
+
+ + +{% if codes %} +
+{% for c in codes %} +
+
+ {{ c.code }} + 创建者: {{ c.creator.username if c.creator else '-' }} + {{ c.created_at.strftime('%Y-%m-%d %H:%M') }} +
+
+ {% if c.max_uses is not none %} + 已用 {{ c.use_count }} / {{ c.max_uses }} + {% else %} + 已用 {{ c.use_count }} / ∞ + {% endif %} + {% if c.expires_at %} + 过期: {{ c.expires_at.strftime('%Y-%m-%d %H:%M') }} + {% else %} + 永不过期 + {% endif %} +
+ + + + +
+
+ +
+
+
+{% endfor %} +
+{% else %} +
+
🔑
+

暂无邀请码

+
+{% endif %} + + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 5d09319..2d740e5 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,6 +14,9 @@ {% if user is defined and user %} Profile 设置 + {% if user.is_admin %} + 邀请码管理 + {% endif %} 退出 {% endif %} diff --git a/app/templates/login.html b/app/templates/login.html index c4c40eb..ef5ce6f 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -16,5 +16,6 @@ +

没有账号?去注册

{% endblock %} diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..81b7e61 --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}注册 - 星笺{% endblock %} +{% block content %} +
+

注册

+

邀请码由已有用户或管理员提供

+ {% if error %} +
{{ error }}
+ {% endif %} +
+ + + + + + + + + +
+

已有账号?去登录

+
+{% endblock %}