feat(invite): add invite code registration system

This commit is contained in:
2026-07-06 01:13:07 +08:00
parent a7c5082ff6
commit b58956937e
6 changed files with 335 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
from datetime import datetime, timezone 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 sqlalchemy.orm import relationship, validates
from app.database import Base from app.database import Base
@@ -16,11 +16,27 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
username = Column(String(64), unique=True, nullable=False, index=True) username = Column(String(64), unique=True, nullable=False, index=True)
password_hash = Column(String(128), nullable=False) password_hash = Column(String(128), nullable=False)
is_admin = Column(Boolean, nullable=False, default=False)
showcase_mode = Column(String(16), nullable=False, default="profile") showcase_mode = Column(String(16), nullable=False, default="profile")
created_at = Column(DateTime, default=_utcnow) created_at = Column(DateTime, default=_utcnow)
profiles = relationship("Profile", back_populates="user", cascade="all, delete-orphan") profiles = relationship("Profile", back_populates="user", cascade="all, delete-orphan")
sessions = relationship("UserSession", 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): class UserSession(Base):

View File

@@ -17,7 +17,11 @@ from app.auth import (
) )
from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR
from app.database import get_db 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"]) router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
@@ -71,6 +75,189 @@ def login_submit(
return resp 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 ---------- # ---------- Settings ----------
@router.get("/settings", response_class=HTMLResponse) @router.get("/settings", response_class=HTMLResponse)

View File

@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}邀请码管理 - 星笺{% endblock %}
{% block content %}
<div class="section-header">
<h1>邀请码管理</h1>
</div>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<!-- Add new code -->
<div style="margin-bottom:2rem;padding:1.5rem;background:var(--card-bg);border-radius:var(--radius);border:1px solid var(--border)">
<h3 style="margin-bottom:1rem;font-size:1rem">创建邀请码</h3>
<form method="post" action="/admin/invites/add" style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:end">
<div style="flex:1;min-width:200px">
<label style="display:block;font-size:.8rem;color:var(--text-muted);margin-bottom:.25rem">邀请码(留空自动生成)</label>
<input type="text" name="code" maxlength="8" placeholder="自动生成" style="text-transform:uppercase">
</div>
<div style="min-width:140px">
<label style="display:block;font-size:.8rem;color:var(--text-muted);margin-bottom:.25rem">限制类型</label>
<select name="limit_type" id="limit_type" onchange="toggleLimitFields()">
<option value="unlimited">无限制</option>
<option value="uses">使用次数</option>
<option value="expires">有效期</option>
</select>
</div>
<div id="max_uses_field" style="display:none;min-width:120px">
<label style="display:block;font-size:.8rem;color:var(--text-muted);margin-bottom:.25rem">最大使用次数</label>
<input type="number" name="max_uses" min="1" value="1">
</div>
<div id="expires_field" style="display:none;min-width:180px">
<label style="display:block;font-size:.8rem;color:var(--text-muted);margin-bottom:.25rem">过期时间</label>
<input type="datetime-local" name="expires_at">
</div>
<button type="submit" class="btn btn-primary">创建</button>
</form>
</div>
<!-- Invite code list -->
{% if codes %}
<div class="postcard-list">
{% for c in codes %}
<div class="postcard-row">
<div style="flex:1;display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<strong style="font-family:monospace;font-size:1.1rem">{{ c.code }}</strong>
<span style="font-size:.8rem;color:var(--text-muted)">创建者: {{ c.creator.username if c.creator else '-' }}</span>
<span style="font-size:.8rem;color:var(--text-muted)">{{ c.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
{% if c.max_uses is not none %}
<span class="badge">已用 {{ c.use_count }} / {{ c.max_uses }}</span>
{% else %}
<span class="badge">已用 {{ c.use_count }} / ∞</span>
{% endif %}
{% if c.expires_at %}
<span class="badge">过期: {{ c.expires_at.strftime('%Y-%m-%d %H:%M') }}</span>
{% else %}
<span class="badge">永不过期</span>
{% endif %}
<form method="post" action="/admin/invites/{{ c.id }}/edit" style="display:inline-flex;align-items:center;gap:.3rem;flex-wrap:wrap" id="edit-form-{{ c.id }}">
<select name="limit_type" style="width:auto;padding:2px 4px;font-size:.75rem" onchange="toggleEditFields({{ c.id }}, this.value)">
<option value="unlimited"{% if c.max_uses is none and not c.expires_at %} selected{% endif %}>无限制</option>
<option value="uses"{% if c.max_uses is not none %} selected{% endif %}>次数限制</option>
<option value="expires"{% if c.expires_at %} selected{% endif %}>有效期</option>
</select>
<input type="number" name="max_uses" value="{{ c.max_uses or 1 }}" min="1" style="width:60px;padding:2px 4px;font-size:.75rem;display:{% if c.max_uses is not none %}inline{% else %}none{% endif %}">
<input type="datetime-local" name="expires_at" value="{{ c.expires_at.strftime('%Y-%m-%dT%H:%M') if c.expires_at else '' }}" style="font-size:.75rem;display:{% if c.expires_at %}inline{% else %}none{% endif %}">
<button type="submit" class="btn btn-sm">保存</button>
</form>
<form method="post" action="/admin/invites/{{ c.id }}/delete" style="display:inline" onsubmit="return confirm('确定删除该邀请码?')">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🔑</div>
<p>暂无邀请码</p>
</div>
{% endif %}
<script>
function toggleLimitFields() {
var t = document.getElementById('limit_type').value;
document.getElementById('max_uses_field').style.display = t === 'uses' ? '' : 'none';
document.getElementById('expires_field').style.display = t === 'expires' ? '' : 'none';
}
function toggleEditFields(id, val) {
var form = document.getElementById('edit-form-' + id);
var inputs = form.querySelectorAll('input[name="max_uses"], input[name="expires_at"]');
inputs.forEach(function(inp) {
if (inp.name === 'max_uses') inp.style.display = val === 'uses' ? 'inline' : 'none';
if (inp.name === 'expires_at') inp.style.display = val === 'expires' ? 'inline' : 'none';
});
}
</script>
{% endblock %}

View File

@@ -14,6 +14,9 @@
{% if user is defined and user %} {% if user is defined and user %}
<a href="/profiles">Profile</a> <a href="/profiles">Profile</a>
<a href="/settings">设置</a> <a href="/settings">设置</a>
{% if user.is_admin %}
<a href="/admin/invites">邀请码管理</a>
{% endif %}
<a href="/logout" class="nav-logout">退出</a> <a href="/logout" class="nav-logout">退出</a>
{% endif %} {% endif %}
</div> </div>

View File

@@ -16,5 +16,6 @@
</label> </label>
<button type="submit" class="btn btn-primary">登录</button> <button type="submit" class="btn btn-primary">登录</button>
</form> </form>
<p style="margin-top:1rem;text-align:center;font-size:.85rem">没有账号?<a href="/register">去注册</a></p>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}注册 - 星笺{% endblock %}
{% block content %}
<div class="auth-form">
<h2>注册</h2>
<p style="color:var(--text-muted);font-size:.85rem;margin-bottom:1rem">邀请码由已有用户或管理员提供</p>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/register">
<label>邀请码</label>
<input type="text" name="invite_code" required maxlength="8" placeholder="8位字母数字" style="text-transform:none" autofocus>
<label>用户名</label>
<input type="text" name="username" required maxlength="64">
<label>密码</label>
<input type="password" name="password" required minlength="6">
<label>确认密码</label>
<input type="password" name="confirm_password" required>
<button type="submit" class="btn btn-primary">注册</button>
</form>
<p style="margin-top:1rem;text-align:center;font-size:.85rem">已有账号?<a href="/login">去登录</a></p>
</div>
{% endblock %}