feat(invite): add invite code registration system
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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 %}
|
{% 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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
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