feat(invite): add invite code registration system
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
103
app/templates/admin_invites.html
Normal file
103
app/templates/admin_invites.html
Normal 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 %}
|
||||
@@ -14,6 +14,9 @@
|
||||
{% if user is defined and user %}
|
||||
<a href="/profiles">Profile</a>
|
||||
<a href="/settings">设置</a>
|
||||
{% if user.is_admin %}
|
||||
<a href="/admin/invites">邀请码管理</a>
|
||||
{% endif %}
|
||||
<a href="/logout" class="nav-logout">退出</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">登录</button>
|
||||
</form>
|
||||
<p style="margin-top:1rem;text-align:center;font-size:.85rem">没有账号?<a href="/register">去注册</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
23
app/templates/register.html
Normal file
23
app/templates/register.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user