feat(api): add public RESTful API with API key auth and documentation page

This commit is contained in:
2026-07-07 09:22:11 +08:00
parent 14c226d082
commit 6a49fedfbb
9 changed files with 910 additions and 5 deletions

View File

@@ -6,6 +6,7 @@ _translations: dict[str, dict[str, str]] = {
"nav.settings": {"zh": "设置", "en": "Settings"}, "nav.settings": {"zh": "设置", "en": "Settings"},
"nav.invites": {"zh": "邀请码管理", "en": "Invites"}, "nav.invites": {"zh": "邀请码管理", "en": "Invites"},
"nav.logout": {"zh": "退出", "en": "Logout"}, "nav.logout": {"zh": "退出", "en": "Logout"},
"nav.api_docs": {"zh": "API 文档", "en": "API Docs"},
# ── Auth ── # ── Auth ──
"login.title": {"zh": "登录", "en": "Login"}, "login.title": {"zh": "登录", "en": "Login"},
@@ -59,6 +60,7 @@ _translations: dict[str, dict[str, str]] = {
# ── Postcards list ── # ── Postcards list ──
"pc_list.title": {"zh": " 的明信片", "en": "'s postcards"}, "pc_list.title": {"zh": " 的明信片", "en": "'s postcards"},
"pc_list.new": {"zh": "+ 新明信片", "en": "+ New postcard"}, "pc_list.new": {"zh": "+ 新明信片", "en": "+ New postcard"},
"pc_list.duplicate": {"zh": "编号已存在", "en": "Card number already exists"},
"pc_list.all_countries": {"zh": "全部国家", "en": "All countries"}, "pc_list.all_countries": {"zh": "全部国家", "en": "All countries"},
"pc_list.filter": {"zh": "筛选", "en": "Filter"}, "pc_list.filter": {"zh": "筛选", "en": "Filter"},
"pc_list.tab_sent": {"zh": "寄出", "en": "Sent"}, "pc_list.tab_sent": {"zh": "寄出", "en": "Sent"},
@@ -208,6 +210,47 @@ _translations: dict[str, dict[str, str]] = {
"flash.password_short": {"zh": "新密码长度至少6位", "en": "Password must be at least 6 characters"}, "flash.password_short": {"zh": "新密码长度至少6位", "en": "Password must be at least 6 characters"},
"flash.password_ok": {"zh": "密码修改成功", "en": "Password changed"}, "flash.password_ok": {"zh": "密码修改成功", "en": "Password changed"},
# ── API Keys ──
"settings.tab_apikeys": {"zh": "API Keys", "en": "API Keys"},
"apikey.title": {"zh": "API Key 管理", "en": "API Key Management"},
"apikey.hint": {"zh": "使用 API Key 通过 HTTP 请求访问星笺的公共 API。请妥善保管你的 Key它仅在创建时显示一次。", "en": "Use API Keys to access Mailova's public API via HTTP requests. Keep your key safe — it is only shown once upon creation."},
"apikey.created": {"zh": "✅ 新 Key 已创建:", "en": "✅ New key created:"},
"apikey.copy_now": {"zh": "⚠️ 请立即复制保存,关闭后将无法再次查看完整 Key。", "en": "⚠️ Copy it now. You won't be able to see the full key again after leaving this page."},
"apikey.name_ph": {"zh": "Key 名称(可选)", "en": "Key name (optional)"},
"apikey.create": {"zh": "创建 Key", "en": "Create Key"},
"apikey.name": {"zh": "名称", "en": "Name"},
"apikey.key": {"zh": "Key", "en": "Key"},
"apikey.created_at": {"zh": "创建时间", "en": "Created"},
"apikey.last_used": {"zh": "最后使用", "en": "Last used"},
"apikey.delete": {"zh": "删除", "en": "Delete"},
"apikey.confirm_delete": {"zh": "确定删除此 API Key", "en": "Delete this API key?"},
"apikey.empty": {"zh": "暂无 API Key", "en": "No API keys yet"},
# ── API Docs ──
"api_docs.title": {"zh": "API 文档", "en": "API Documentation"},
"api_docs.intro": {"zh": "星笺提供 RESTful API供第三方应用集成使用。所有需要鉴权的请求需在 Header 中携带 X-API-Key。", "en": "Mailova provides a RESTful API for third-party integrations. All authenticated requests must include the X-API-Key header."},
"api_docs.base_url": {"zh": "基础 URL", "en": "Base URL"},
"api_docs.auth": {"zh": "鉴权方式", "en": "Authentication"},
"api_docs.auth_desc": {"zh": "在请求头中携带你的 API Key", "en": "Include your API Key in the request header:"},
"api_docs.profile_ops": {"zh": "Profile 操作", "en": "Profile Operations"},
"api_docs.postcard_ops": {"zh": "明信片操作", "en": "Postcard Operations"},
"api_docs.upload_op": {"zh": "图片上传", "en": "Image Upload"},
"api_docs.key_ops": {"zh": "API Key 管理", "en": "API Key Management"},
"api_docs.showcase_ops": {"zh": "公开展示(无需鉴权)", "en": "Public Showcase (no auth)"},
"api_docs.method": {"zh": "方法", "en": "Method"},
"api_docs.path": {"zh": "路径", "en": "Path"},
"api_docs.desc": {"zh": "说明", "en": "Description"},
"api_docs.params": {"zh": "参数", "en": "Parameters"},
"api_docs.body": {"zh": "请求体 (JSON)", "en": "Request Body (JSON)"},
"api_docs.response": {"zh": "响应", "en": "Response"},
"api_docs.status": {"zh": "状态码", "en": "Status"},
"api_docs.go_settings": {"zh": "去设置页创建 API Key →", "en": "Go to Settings to create an API Key →"},
"api_docs.try_it": {"zh": "试试看", "en": "Try it"},
"api_docs.api_key_ph": {"zh": "输入你的 API Key", "en": "Enter your API Key"},
"api_docs.send": {"zh": "发送", "en": "Send"},
"api_docs.response_label": {"zh": "响应结果", "en": "Response"},
"api_docs.no_auth": {"zh": "此接口无需鉴权", "en": "No authentication required"},
# ── Misc ── # ── Misc ──
"misc.required": {"zh": " *", "en": " *"}, "misc.required": {"zh": " *", "en": " *"},
} }

View File

@@ -5,15 +5,16 @@ from fastapi.staticfiles import StaticFiles
from app.config import UPLOAD_DIR from app.config import UPLOAD_DIR
from app.database import init_db from app.database import init_db
from app.routers import web from app.routers import web, api
app = FastAPI(title="Postcard Manager") app = FastAPI(title="Mailova 星笺")
UPLOAD_DIR.mkdir(exist_ok=True) UPLOAD_DIR.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static") app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
app.include_router(web.router) app.include_router(web.router)
app.include_router(api.router)
init_db() init_db()

View File

@@ -26,6 +26,7 @@ class User(Base):
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", foreign_keys="InviteCode.created_by", cascade="all, delete-orphan") invite_codes = relationship("InviteCode", back_populates="creator", foreign_keys="InviteCode.created_by", cascade="all, delete-orphan")
invited_by_code = relationship("InviteCode", foreign_keys=[invited_by_code_id]) invited_by_code = relationship("InviteCode", foreign_keys=[invited_by_code_id])
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
class InviteCode(Base): class InviteCode(Base):
@@ -74,7 +75,7 @@ class Postcard(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False) profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
card_number = Column(String(64), nullable=False) card_number = Column(String(64), nullable=False, unique=True)
status = Column(String(16), nullable=False) status = Column(String(16), nullable=False)
# sent & delivered # sent & delivered
recipient_name = Column(String(64)) recipient_name = Column(String(64))
@@ -98,3 +99,17 @@ class Postcard(Base):
@validates("country") @validates("country")
def _upper_country(self, key, value): def _upper_country(self, key, value):
return value.strip().upper() if value else None return value.strip().upper() if value else None
class ApiKey(Base):
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(64), unique=True, nullable=False, index=True)
name = Column(String(64), nullable=False, default="")
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, default=_utcnow)
last_used_at = Column(DateTime, nullable=True)
user = relationship("User", back_populates="api_keys")

407
app/routers/api.py Normal file
View File

@@ -0,0 +1,407 @@
from datetime import datetime, timedelta, timezone
from pathlib import Path
from secrets import token_hex
from fastapi import APIRouter, Depends, Header, HTTPException, Query, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.orm import Session as DbSession
from app.database import get_db
from app.models import ApiKey, Profile, Postcard, User
from app.config import UPLOAD_DIR
router = APIRouter(prefix="/api/v1", tags=["api"])
# ─── Auth Dependency ──────────────────────────────────────────────
def get_api_user(
x_api_key: str = Header(..., alias="X-API-Key"),
db: DbSession = Depends(get_db),
) -> User:
"""Validate X-API-Key header and return the associated user."""
api_key = db.query(ApiKey).filter(ApiKey.key == x_api_key).first()
if not api_key or not api_key.is_active:
raise HTTPException(status_code=401, detail="Invalid or inactive API key")
# Update last_used_at
api_key.last_used_at = datetime.now(timezone.utc)
db.commit()
return api_key.user
def get_api_user_optional(
x_api_key: str | None = Header(None, alias="X-API-Key"),
db: DbSession = Depends(get_db),
) -> User | None:
"""Like get_api_user but returns None if no key provided (for public endpoints)."""
if not x_api_key:
return None
return get_api_user(x_api_key, db)
# ─── Pydantic Schemas ────────────────────────────────────────────
class ProfileCreate(BaseModel):
nickname: str
class ProfileUpdate(BaseModel):
nickname: str | None = None
class ProfileOut(BaseModel):
id: int
nickname: str
showcase_enabled: bool
showcase_visible: str
notes: str
created_at: datetime
model_config = {"from_attributes": True}
class PostcardCreate(BaseModel):
card_number: str
status: str
recipient_name: str | None = None
country: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
receive_time: str | None = None
notes: str | None = None
class PostcardUpdate(BaseModel):
card_number: str | None = None
status: str | None = None
recipient_name: str | None = None
country: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
receive_time: str | None = None
notes: str | None = None
class PostcardOut(BaseModel):
id: int
profile_id: int
card_number: str
status: str
recipient_name: str | None
country: str | None
send_time: datetime | None
arrival_time: datetime | None
sender_name: str | None
receive_time: datetime | None
notes: str
image_front: str | None
image_back: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ApiKeyCreate(BaseModel):
name: str = ""
class ApiKeyOut(BaseModel):
id: int
key: str
name: str
is_active: bool
created_at: datetime
last_used_at: datetime | None
model_config = {"from_attributes": True}
# ─── Helpers ──────────────────────────────────────────────────────
def _parse_date(s: str | None) -> datetime | None:
"""Parse a date or datetime string to datetime."""
if not s:
return None
s = s.strip()
for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
raise HTTPException(status_code=400, detail=f"Invalid date format: {s}")
def _profile_or_404(user: User, profile_id: int, db: DbSession) -> Profile:
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if not p:
raise HTTPException(status_code=404, detail="Profile not found")
return p
def _postcard_or_404(user: User, profile_id: int, postcard_id: int, db: DbSession) -> Postcard:
_profile_or_404(user, profile_id, db)
pc = db.query(Postcard).filter(
Postcard.id == postcard_id, Postcard.profile_id == profile_id
).first()
if not pc:
raise HTTPException(status_code=404, detail="Postcard not found")
return pc
# ═══════════════════════════════════════════════════════════════════
# Profiles
# ═══════════════════════════════════════════════════════════════════
@router.get("/profiles", response_model=list[ProfileOut])
def list_profiles(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
return db.query(Profile).filter(Profile.user_id == user.id).order_by(Profile.id).all()
@router.post("/profiles", response_model=ProfileOut, status_code=201)
def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
p = Profile(nickname=body.nickname.strip(), user_id=user.id)
db.add(p)
db.commit()
db.refresh(p)
return p
@router.get("/profiles/{profile_id}", response_model=ProfileOut)
def get_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
return _profile_or_404(user, profile_id, db)
@router.put("/profiles/{profile_id}", response_model=ProfileOut)
def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
p = _profile_or_404(user, profile_id, db)
if body.nickname is not None:
p.nickname = body.nickname.strip()
db.commit()
db.refresh(p)
return p
@router.delete("/profiles/{profile_id}")
def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
p = _profile_or_404(user, profile_id, db)
db.delete(p)
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════════════════════
# Postcards
# ═══════════════════════════════════════════════════════════════════
@router.get("/profiles/{profile_id}/postcards", response_model=list[PostcardOut])
def list_postcards(
profile_id: int,
status: str | None = Query(None),
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
_profile_or_404(user, profile_id, db)
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
if status:
q = q.filter(Postcard.status == status)
return q.order_by(Postcard.id.desc()).all()
@router.post("/profiles/{profile_id}/postcards", response_model=PostcardOut, status_code=201)
def create_postcard(
profile_id: int,
body: PostcardCreate,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
_profile_or_404(user, profile_id, db)
cn = body.card_number.strip()
if db.query(Postcard).filter(Postcard.card_number == cn).first():
raise HTTPException(status_code=409, detail="Card number already exists")
pc = Postcard(
profile_id=profile_id,
card_number=cn,
status=body.status,
recipient_name=body.recipient_name,
country=body.country.strip().upper() if body.country else None,
send_time=_parse_date(body.send_time),
arrival_time=_parse_date(body.arrival_time),
sender_name=body.sender_name,
receive_time=_parse_date(body.receive_time),
notes=body.notes or "",
)
db.add(pc)
db.commit()
db.refresh(pc)
return pc
@router.get("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
def get_postcard(
profile_id: int,
postcard_id: int,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
return _postcard_or_404(user, profile_id, postcard_id, db)
@router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
def update_postcard(
profile_id: int,
postcard_id: int,
body: PostcardUpdate,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
pc = _postcard_or_404(user, profile_id, postcard_id, db)
for field in ("status", "recipient_name", "sender_name", "notes"):
val = getattr(body, field)
if val is not None:
setattr(pc, field, val.strip() if isinstance(val, str) else val)
if body.card_number is not None:
cn = body.card_number.strip()
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
if dup:
raise HTTPException(status_code=409, detail="Card number already exists")
pc.card_number = cn
if body.country is not None:
pc.country = body.country.strip().upper() if body.country else None
for field in ("send_time", "arrival_time", "receive_time"):
val = getattr(body, field)
if val is not None:
setattr(pc, field, _parse_date(val))
db.commit()
db.refresh(pc)
return pc
@router.delete("/profiles/{profile_id}/postcards/{postcard_id}")
def delete_postcard(
profile_id: int,
postcard_id: int,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
pc = _postcard_or_404(user, profile_id, postcard_id, db)
db.delete(pc)
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════════════════════
# Image Upload
# ═══════════════════════════════════════════════════════════════════
@router.post("/profiles/{profile_id}/postcards/{postcard_id}/upload", response_model=PostcardOut)
async def upload_image(
profile_id: int,
postcard_id: int,
side: str = Query("front", pattern="^(front|back)$"),
file: UploadFile = File(...),
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
pc = _postcard_or_404(user, profile_id, postcard_id, db)
ext = Path(file.filename).suffix if file.filename else ".jpg"
save_name = f"pc_{pc.id}_{side}{ext}"
save_path = UPLOAD_DIR / save_name
content = await file.read()
save_path.write_bytes(content)
if side == "front":
pc.image_front = f"/uploads/{save_name}"
else:
pc.image_back = f"/uploads/{save_name}"
db.commit()
db.refresh(pc)
return pc
# ═══════════════════════════════════════════════════════════════════
# API Key Management
# ═══════════════════════════════════════════════════════════════════
@router.get("/keys", response_model=list[ApiKeyOut])
def list_api_keys(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
return db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
@router.post("/keys", response_model=ApiKeyOut, status_code=201)
def create_api_key(body: ApiKeyCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
key = f"mv_{token_hex(24)}"
ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id)
db.add(ak)
db.commit()
db.refresh(ak)
return ak
@router.delete("/keys/{key_id}")
def delete_api_key(key_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if not ak:
raise HTTPException(status_code=404, detail="API key not found")
db.delete(ak)
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════════════════════
# Showcase (public, no auth required)
# ═══════════════════════════════════════════════════════════════════
class ShowcaseProfileOut(BaseModel):
id: int
nickname: str
showcase_visible: str
sent: list[PostcardOut]
received: list[PostcardOut]
model_config = {"from_attributes": True}
class ShowcaseUserOut(BaseModel):
username: str
showcase_mode: str
profiles: list[ShowcaseProfileOut]
@router.get("/showcase/{username}", response_model=ShowcaseUserOut)
def showcase_user(username: str, db: DbSession = Depends(get_db)):
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
result_profiles = []
for p in profiles:
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
if p.showcase_visible in ("sent", "both"):
sent = [c for c in cards if c.status in ("sent", "delivered")]
else:
sent = []
if p.showcase_visible in ("received", "both"):
received = [c for c in cards if c.status == "received"]
else:
received = []
if sent or received:
sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
result_profiles.append(ShowcaseProfileOut(
id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible,
sent=sent, received=received,
))
return ShowcaseUserOut(username=user.username, showcase_mode=user.showcase_mode, profiles=result_profiles)
@router.get("/showcase/{username}/profile/{profile_id}", response_model=ShowcaseProfileOut)
def showcase_profile(username: str, profile_id: int, db: DbSession = Depends(get_db)):
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id, Profile.showcase_enabled == True).first()
if not p:
raise HTTPException(status_code=404, detail="Profile not found or showcase disabled")
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
sent = [c for c in cards if c.status in ("sent", "delivered")] if p.showcase_visible in ("sent", "both") else []
received = [c for c in cards if c.status == "received"] if p.showcase_visible in ("received", "both") else []
sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
return ShowcaseProfileOut(id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible, sent=sent, received=received)

View File

@@ -305,13 +305,16 @@ def settings_page(
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
from app.models import ApiKey
profiles = db.query(Profile).filter(Profile.user_id == user.id).all() profiles = db.query(Profile).filter(Profile.user_id == user.id).all()
profile_cards = {} profile_cards = {}
for p in profiles: for p in profiles:
profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all() profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return templates.TemplateResponse("settings.html", { return templates.TemplateResponse("settings.html", {
"request": request, "user": user, "profiles": profiles, "request": request, "user": user, "profiles": profiles,
"profile_cards": profile_cards, "active_tab": active_tab, "profile_cards": profile_cards, "active_tab": active_tab,
"api_keys": api_keys,
}) })
@@ -558,6 +561,13 @@ def postcard_create(
if not profile: if not profile:
return _redirect("/profiles") return _redirect("/profiles")
cn = card_number.strip()
if db.query(Postcard).filter(Postcard.card_number == cn).first():
return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": profile, "postcard": None,
"error": _t("pc_list.duplicate", user.language),
})
def _parse_dt(s: str) -> datetime | None: def _parse_dt(s: str) -> datetime | None:
s = s.strip() s = s.strip()
if not s: if not s:
@@ -638,6 +648,14 @@ def postcard_update(
if not pc: if not pc:
return _redirect("/profiles") return _redirect("/profiles")
cn = card_number.strip()
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
if dup:
return templates.TemplateResponse("postcard_form.html", {
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
"error": _t("pc_list.duplicate", user.language),
})
def _parse_dt(s: str) -> datetime | None: def _parse_dt(s: str) -> datetime | None:
s = s.strip() s = s.strip()
if not s: if not s:
@@ -821,3 +839,54 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
"profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all, "profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all,
"lang": user.language, "lang": user.language,
}) })
# ---------- API Keys ----------
@router.post("/settings/apikeys/create")
def apikey_create(
request: Request,
name: str = Form(""),
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
from app.models import ApiKey
from secrets import token_hex
key = f"mv_{token_hex(24)}"
ak = ApiKey(key=key, name=name.strip(), user_id=user.id)
db.add(ak)
db.commit()
db.refresh(ak)
# Re-render settings with apikeys tab active and the new key shown
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return templates.TemplateResponse("settings.html", {
"request": request, "user": user, "active_tab": "apikeys",
"api_keys": api_keys, "new_key": ak.key, "success": "API Key created",
})
@router.post("/settings/apikeys/{key_id}/delete")
def apikey_delete(
key_id: int,
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
from app.models import ApiKey
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if ak:
db.delete(ak)
db.commit()
return _redirect("/settings?tab=apikeys")
# ---------- API Docs ----------
@router.get("/api/docs", response_class=HTMLResponse)
def api_docs_page(
request: Request,
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
return templates.TemplateResponse("api_docs.html", {
"request": request, "user": user,
})

306
app/templates/api_docs.html Normal file
View File

@@ -0,0 +1,306 @@
{% extends "base.html" %}
{% block title %}{{ 'api_docs.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %}
{% block content %}
<style>
.doc-section { margin-bottom:2rem; }
.doc-section h2 { font-size:1.15rem; margin-bottom:.75rem; padding-bottom:.5rem; border-bottom:1px solid var(--border); }
.doc-section h3 { font-size:1rem; margin:1rem 0 .5rem; }
.doc-endpoint { background:var(--card-bg); border:1px solid var(--border); border-radius:8px; padding:1rem 1.25rem; margin-bottom:1rem; }
.doc-endpoint .ep-header { display:flex; align-items:center; gap:.6rem; margin-bottom:.5rem; flex-wrap:wrap; }
.doc-method { display:inline-block; padding:2px 8px; border-radius:4px; font-size:.75rem; font-weight:700; color:#fff; white-space:nowrap; }
.method-get { background:#22c55e; }
.method-post { background:#3b82f6; }
.method-put { background:#f59e0b; }
.method-delete { background:#ef4444; }
.doc-path { font-family:monospace; font-size:.9rem; color:var(--text); word-break:break-all; }
.doc-desc { color:var(--text-muted); font-size:.85rem; margin-bottom:.5rem; }
.doc-table { width:100%; border-collapse:collapse; font-size:.82rem; margin-top:.5rem; }
.doc-table th, .doc-table td { text-align:left; padding:4px 8px; border-bottom:1px solid var(--border); }
.doc-table th { color:var(--text-muted); font-weight:600; white-space:nowrap; }
.doc-table td:first-child { font-family:monospace; white-space:nowrap; }
.doc-note { color:var(--text-muted); font-size:.82rem; margin-top:.25rem; }
.doc-json { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:.5rem .75rem; font-family:monospace; font-size:.8rem; margin-top:.5rem; overflow-x:auto; white-space:pre; }
.doc-auth-box { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:1rem; margin:.75rem 0; }
.doc-auth-box code { background:var(--card-bg); padding:2px 6px; border-radius:3px; font-size:.82rem; }
.try-box { margin-top:.5rem; display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
.try-box input { flex:1; min-width:200px; font-family:monospace; font-size:.82rem; }
.try-box button { flex-shrink:0; }
.try-result { margin-top:.5rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:.5rem .75rem; font-family:monospace; font-size:.78rem; max-height:300px; overflow:auto; white-space:pre-wrap; word-break:break-all; display:none; }
.try-result.show { display:block; }
.tag-required { color:var(--danger); font-size:.75rem; }
</style>
<div class="section-header">
<h1>{{ 'api_docs.title'|t(user.language) }}</h1>
</div>
<div class="doc-section">
<p style="color:var(--text-muted);font-size:.9rem">{{ 'api_docs.intro'|t(user.language) }}</p>
<p style="margin-top:.5rem"><a href="/settings?tab=apikeys" class="btn btn-sm" style="display:inline-block">{{ 'api_docs.go_settings'|t(user.language) }}</a></p>
</div>
<div class="doc-section">
<h2>{{ 'api_docs.base_url'|t(user.language) }}</h2>
<div class="doc-json">{{ request.base_url }}api/v1</div>
</div>
<div class="doc-section">
<h2>{{ 'api_docs.auth'|t(user.language) }}</h2>
<p class="doc-desc">{{ 'api_docs.auth_desc'|t(user.language) }}</p>
<div class="doc-auth-box">
<code>X-API-Key: mv_your_api_key_here</code>
</div>
</div>
<!-- ═══════ Profiles ═══════ -->
<div class="doc-section">
<h2>{{ 'api_docs.profile_ops'|t(user.language) }}</h2>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-get">GET</span>
<span class="doc-path">/profiles</span>
</div>
<div class="doc-desc">获取当前用户的所有 Profile 列表。</div>
<div class="doc-json">[
{"id": 1, "nickname": "Japan", "showcase_enabled": false, "showcase_visible": "both", "notes": "", "created_at": "2025-01-01T00:00:00"},
...
]</div>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-post">POST</span>
<span class="doc-path">/profiles</span>
</div>
<div class="doc-desc">创建新 Profile。</div>
<table class="doc-table">
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
<tr><td>nickname</td><td>string <span class="tag-required">{{ 'misc.required'|t(user.language) }}</span></td></tr>
</table>
<div class="doc-json">{"id": 2, "nickname": "Europe", ...}</div>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-get">GET</span>
<span class="doc-path">/profiles/{id}</span>
</div>
<div class="doc-desc">获取单个 Profile 详情。</div>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-put">PUT</span>
<span class="doc-path">/profiles/{id}</span>
</div>
<div class="doc-desc">更新 Profile。</div>
<table class="doc-table">
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
<tr><td>nickname</td><td>string可选</td></tr>
</table>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-delete">DELETE</span>
<span class="doc-path">/profiles/{id}</span>
</div>
<div class="doc-desc">删除 Profile 及其下所有明信片。</div>
<div class="doc-json">{"ok": true}</div>
</div>
</div>
<!-- ═══════ Postcards ═══════ -->
<div class="doc-section">
<h2>{{ 'api_docs.postcard_ops'|t(user.language) }}</h2>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-get">GET</span>
<span class="doc-path">/profiles/{profile_id}/postcards</span>
</div>
<div class="doc-desc">获取指定 Profile 下的明信片列表。</div>
<table class="doc-table">
<tr><th>{{ 'api_docs.params'|t(user.language) }}</th><th></th></tr>
<tr><td>status</td><td>string可选— 按状态筛选sent / delivered / received</td></tr>
</table>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-post">POST</span>
<span class="doc-path">/profiles/{profile_id}/postcards</span>
</div>
<div class="doc-desc">创建新明信片。</div>
<table class="doc-table">
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
<tr><td>card_number</td><td>string <span class="tag-required">* {{ 'misc.required'|t(user.language) }}</span></td></tr>
<tr><td>status</td><td>string <span class="tag-required">* {{ 'misc.required'|t(user.language) }}</span> — sent / delivered / received</td></tr>
<tr><td>recipient_name</td><td>string可选</td></tr>
<tr><td>country</td><td>string可选— 两个大写字母,如 JP</td></tr>
<tr><td>send_time</td><td>string可选— 格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM</td></tr>
<tr><td>arrival_time</td><td>string可选</td></tr>
<tr><td>sender_name</td><td>string可选</td></tr>
<tr><td>receive_time</td><td>string可选</td></tr>
<tr><td>notes</td><td>string可选</td></tr>
</table>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-get">GET</span>
<span class="doc-path">/profiles/{profile_id}/postcards/{id}</span>
</div>
<div class="doc-desc">获取单张明信片详情。</div>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-put">PUT</span>
<span class="doc-path">/profiles/{profile_id}/postcards/{id}</span>
</div>
<div class="doc-desc">更新明信片。仅传入需要修改的字段。</div>
<table class="doc-table">
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
<tr><td>card_number</td><td>string可选</td></tr>
<tr><td>status</td><td>string可选</td></tr>
<tr><td>recipient_name</td><td>string可选</td></tr>
<tr><td>country</td><td>string可选</td></tr>
<tr><td>send_time</td><td>string可选</td></tr>
<tr><td>arrival_time</td><td>string可选</td></tr>
<tr><td>sender_name</td><td>string可选</td></tr>
<tr><td>receive_time</td><td>string可选</td></tr>
<tr><td>notes</td><td>string可选</td></tr>
</table>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-delete">DELETE</span>
<span class="doc-path">/profiles/{profile_id}/postcards/{id}</span>
</div>
<div class="doc-desc">删除单张明信片。</div>
<div class="doc-json">{"ok": true}</div>
</div>
</div>
<!-- ═══════ Image Upload ═══════ -->
<div class="doc-section">
<h2>{{ 'api_docs.upload_op'|t(user.language) }}</h2>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-post">POST</span>
<span class="doc-path">/profiles/{profile_id}/postcards/{id}/upload</span>
</div>
<div class="doc-desc">上传明信片图片(正面或背面),使用 <code>multipart/form-data</code></div>
<table class="doc-table">
<tr><th>Form Field</th><th></th></tr>
<tr><td>file</td><td>File <span class="tag-required">* {{ 'misc.required'|t(user.language) }}</span> — 图片文件</td></tr>
<tr><td>side</td><td>string可选— front 或 back默认 front</td></tr>
</table>
</div>
</div>
<!-- ═══════ API Keys ═══════ -->
<div class="doc-section">
<h2>{{ 'api_docs.key_ops'|t(user.language) }}</h2>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-get">GET</span>
<span class="doc-path">/keys</span>
</div>
<div class="doc-desc">获取当前用户的所有 API Key。</div>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-post">POST</span>
<span class="doc-path">/keys</span>
</div>
<div class="doc-desc">创建新的 API Key。创建后完整 Key 仅在响应中显示一次。</div>
<table class="doc-table">
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
<tr><td>name</td><td>string可选— Key 的备注名称</td></tr>
</table>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-delete">DELETE</span>
<span class="doc-path">/keys/{id}</span>
</div>
<div class="doc-desc">删除指定 API Key。</div>
<div class="doc-json">{"ok": true}</div>
</div>
</div>
<!-- ═══════ Showcase ═══════ -->
<div class="doc-section">
<h2>{{ 'api_docs.showcase_ops'|t(user.language) }}</h2>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-get">GET</span>
<span class="doc-path">/showcase/{username}</span>
</div>
<div class="doc-desc">{{ 'api_docs.no_auth'|t(user.language) }} — 获取用户的公开展示数据。</div>
</div>
<div class="doc-endpoint">
<div class="ep-header">
<span class="doc-method method-get">GET</span>
<span class="doc-path">/showcase/{username}/profile/{id}</span>
</div>
<div class="doc-desc">{{ 'api_docs.no_auth'|t(user.language) }} — 获取单个 Profile 的展示数据。</div>
</div>
</div>
<!-- ═══════ Try It ═══════ -->
<div class="doc-section">
<h2>{{ 'api_docs.try_it'|t(user.language) }}</h2>
<div class="try-box">
<input type="text" id="try-key" placeholder="{{ 'api_docs.api_key_ph'|t(user.language) }}">
<select id="try-method" style="width:auto;flex-shrink:0">
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option>
</select>
<input type="text" id="try-url" placeholder="/profiles" style="flex:1;min-width:150px;font-family:monospace">
<button class="btn btn-primary btn-sm" onclick="tryApi()">{{ 'api_docs.send'|t(user.language) }}</button>
</div>
<textarea id="try-body" rows="3" placeholder="Request body (JSON, for POST/PUT)" style="width:100%;margin-top:.5rem;font-family:monospace;font-size:.82rem;resize:vertical;display:none"></textarea>
<div class="try-result" id="try-result"></div>
</div>
<script>
document.getElementById('try-method').addEventListener('change', function() {
document.getElementById('try-body').style.display = (this.value === 'POST' || this.value === 'PUT') ? '' : 'none';
});
async function tryApi() {
var key = document.getElementById('try-key').value.trim();
var method = document.getElementById('try-method').value;
var path = document.getElementById('try-url').value.trim();
var body = document.getElementById('try-body').value.trim();
var result = document.getElementById('try-result');
result.className = 'try-result show';
result.textContent = 'Loading...';
try {
var baseUrl = '{{ request.base_url }}api/v1';
var opts = { method: method, headers: {} };
if (key) opts.headers['X-API-Key'] = key;
if (body && (method === 'POST' || method === 'PUT')) {
opts.headers['Content-Type'] = 'application/json';
opts.body = body;
}
var resp = await fetch(baseUrl + path, opts);
var text = await resp.text();
try { text = JSON.stringify(JSON.parse(text), null, 2); } catch(e) {}
result.textContent = resp.status + ' ' + resp.statusText + '\n\n' + text;
} catch(e) {
result.textContent = 'Error: ' + e.message;
}
}
</script>
{% endblock %}

View File

@@ -17,6 +17,7 @@
{% if user.is_admin %} {% if user.is_admin %}
<a href="/admin/invites">{{ 'nav.invites'|t(user.language) }}</a> <a href="/admin/invites">{{ 'nav.invites'|t(user.language) }}</a>
{% endif %} {% endif %}
<a href="/api/docs">{{ 'nav.api_docs'|t(user.language) }}</a>
<form method="post" action="/settings/language" class="inline" style="display:inline-flex;align-items:center;gap:0"> <form method="post" action="/settings/language" class="inline" style="display:inline-flex;align-items:center;gap:0">
<input type="hidden" name="language" value="{{ 'en' if user.language == 'zh' else 'zh' }}"> <input type="hidden" name="language" value="{{ 'en' if user.language == 'zh' else 'zh' }}">
<button type="submit" class="nav-lang-btn">{{ 'EN' if user.language == 'zh' else '中' }}</button> <button type="submit" class="nav-lang-btn">{{ 'EN' if user.language == 'zh' else '中' }}</button>

View File

@@ -6,8 +6,9 @@
</div> </div>
<div class="tabs" id="tabs"> <div class="tabs" id="tabs">
<button class="tab {% if active_tab != 'showcase' %}active{% endif %}" onclick="switchTab('account')">{{ 'settings.tab_account'|t(user.language) }}</button> <button class="tab {% if active_tab != 'showcase' and active_tab != 'apikeys' %}active{% endif %}" onclick="switchTab('account')">{{ 'settings.tab_account'|t(user.language) }}</button>
<button class="tab {% if active_tab == 'showcase' %}active{% endif %}" onclick="switchTab('showcase')">{{ 'settings.tab_showcase'|t(user.language) }}</button> <button class="tab {% if active_tab == 'showcase' %}active{% endif %}" onclick="switchTab('showcase')">{{ 'settings.tab_showcase'|t(user.language) }}</button>
<button class="tab {% if active_tab == 'apikeys' %}active{% endif %}" onclick="switchTab('apikeys')">{{ 'settings.tab_apikeys'|t(user.language) }}</button>
</div> </div>
{% if success %} {% if success %}
@@ -17,7 +18,7 @@
<div class="alert alert-error">{{ error }}</div> <div class="alert alert-error">{{ error }}</div>
{% endif %} {% endif %}
<div id="tab-account" class="tab-panel" {% if active_tab == 'showcase' %}style="display:none"{% endif %}> <div id="tab-account" class="tab-panel" {% if active_tab == 'showcase' or active_tab == 'apikeys' %}style="display:none"{% endif %}>
<div class="form-card"> <div class="form-card">
<h3 style="margin-bottom:.75rem">{{ 'settings.change_username'|t(user.language) }}</h3> <h3 style="margin-bottom:.75rem">{{ 'settings.change_username'|t(user.language) }}</h3>
<form method="post" action="/settings/change-username"> <form method="post" action="/settings/change-username">
@@ -155,6 +156,61 @@
</div> </div>
</div> </div>
<div id="tab-apikeys" class="tab-panel" {% if active_tab != 'apikeys' %}style="display:none"{% endif %}>
<div class="form-card">
<h3 style="margin-bottom:.75rem">{{ 'apikey.title'|t(user.language) }}</h3>
<p style="color:var(--text-muted);font-size:.85rem;margin-bottom:1rem">{{ 'apikey.hint'|t(user.language) }}</p>
{% if new_key %}
<div class="alert alert-success" style="margin-bottom:1rem">
<strong>{{ 'apikey.created'|t(user.language) }}</strong><br>
<code style="word-break:break-all;font-size:.85rem">{{ new_key }}</code><br>
<small style="color:var(--text-muted)">{{ 'apikey.copy_now'|t(user.language) }}</small>
</div>
{% endif %}
<form method="post" action="/settings/apikeys/create" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem">
<input type="text" name="name" placeholder="{{ 'apikey.name_ph'|t(user.language) }}" style="max-width:200px">
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.create'|t(user.language) }}</button>
</form>
{% if api_keys %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>{{ 'apikey.name'|t(user.language) }}</th>
<th>{{ 'apikey.key'|t(user.language) }}</th>
<th>{{ 'apikey.created_at'|t(user.language) }}</th>
<th>{{ 'apikey.last_used'|t(user.language) }}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ak in api_keys %}
<tr>
<td>{{ ak.name or '—' }}</td>
<td><code style="font-size:.8rem">{{ ak.key[:8] }}****</code></td>
<td>{{ ak.created_at|to_local|strftime('%Y-%m-%d') }}</td>
<td>{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d') }}{% else %}—{% endif %}</td>
<td>
<form method="post" action="/settings/apikeys/{{ ak.id }}/delete" style="display:inline" onsubmit="return confirm('{{ 'apikey.confirm_delete'|t(user.language) }}')">
<button type="submit" class="btn btn-danger btn-sm">{{ 'apikey.delete'|t(user.language) }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<p>{{ 'apikey.empty'|t(user.language) }}</p>
</div>
{% endif %}
</div>
</div>
<script> <script>
function switchTab(name) { function switchTab(name) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); }); document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });

View File

@@ -10,3 +10,10 @@
## 用户偏好 ## 用户偏好
- 用户明确表示不需要我做浏览器测试,他自己测试即可。以后完成功能后直接提交,不再打开浏览器验证。 - 用户明确表示不需要我做浏览器测试,他自己测试即可。以后完成功能后直接提交,不再打开浏览器验证。
- 做功能时不要改动布局,只做最小改动。特别是做 i18n 时,只替换字符串,保持 HTML 结构完全一致。
## 后续功能
- 字体/颜色调整commit e875e90
- dt 标签不换行commit e178b93
- 英语本地化 i18ncommit 7e99ce9app/i18n.py + User.language 字段 + 导航栏切换 + 所有模板翻译键
- 去掉明信片编号格式限制commit 69a2ab3