feat: initial postcard manager - FastAPI + Jinja2 + SQLite

This commit is contained in:
2026-07-05 16:06:03 +08:00
commit 0eca60300f
24 changed files with 1304 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
uploads/*
!uploads/.gitkeep
*.db
.env

1
activate.bat Normal file
View File

@@ -0,0 +1 @@
start "Python Venv" /D "%~dp0" cmd /k "%~dp0.venv\Scripts\activate.bat"

1
activate.ps1 Normal file
View File

@@ -0,0 +1 @@
Start-Process -WindowStyle Normal -FilePath "powershell" -ArgumentList "-NoExit", "-Command", "& '.\.venv\Scripts\Activate.ps1'"

0
app/__init__.py Normal file
View File

86
app/auth.py Normal file
View File

@@ -0,0 +1,86 @@
from datetime import datetime, timezone
from uuid import uuid4
import bcrypt
from fastapi import Cookie, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.config import SECRET_KEY, SESSION_COOKIE_NAME
from app.database import get_db
from app.models import ApiKey, User
# ---------- Password ----------
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode(), password_hash.encode())
# ---------- Session ----------
# In-memory session store: token -> user_id
_sessions: dict[str, int] = {}
def create_session(user_id: int) -> str:
token = uuid4().hex
_sessions[token] = user_id
return token
def destroy_session(token: str) -> None:
_sessions.pop(token, None)
def get_session_user_id(token: str | None) -> int | None:
if token is None:
return None
return _sessions.get(token)
# ---------- Web Dependencies ----------
def get_current_user_web(
request: Request,
db: Session = Depends(get_db),
) -> User:
token = request.cookies.get(SESSION_COOKIE_NAME)
uid = get_session_user_id(token)
if uid is None:
raise HTTPException(status_code=303, headers={"Location": "/login"})
user = db.query(User).filter(User.id == uid).first()
if user is None:
raise HTTPException(status_code=303, headers={"Location": "/login"})
return user
def redirect_if_not_logged_in(request: Request, db: Session) -> User | None:
"""Return user if logged in, else None — caller decides redirect."""
token = request.cookies.get(SESSION_COOKIE_NAME)
uid = get_session_user_id(token)
if uid is None:
return None
return db.query(User).filter(User.id == uid).first()
# ---------- API Dependencies ----------
def get_current_user_api(
request: Request,
db: Session = Depends(get_db),
) -> User:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
token = auth_header[7:]
api_key = db.query(ApiKey).filter(ApiKey.key == token).first()
if api_key is None:
raise HTTPException(status_code=401, detail="Invalid API key")
user = db.query(User).filter(User.id == api_key.user_id).first()
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user

7
app/config.py Normal file
View File

@@ -0,0 +1,7 @@
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DATABASE_URL = f"sqlite:///{BASE_DIR / 'postcard.db'}"
UPLOAD_DIR = BASE_DIR / "uploads"
SECRET_KEY = "postcard-manager-secret-change-in-production"
SESSION_COOKIE_NAME = "pc_session"

25
app/database.py Normal file
View File

@@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import DATABASE_URL
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
from app import models # noqa: F401
Base.metadata.create_all(bind=engine)

23
app/main.py Normal file
View File

@@ -0,0 +1,23 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.config import UPLOAD_DIR
from app.database import init_db
from app.routers import api, web
app = FastAPI(title="Postcard Manager")
UPLOAD_DIR.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
app.include_router(web.router)
app.include_router(api.router)
init_db()
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

74
app/models.py Normal file
View File

@@ -0,0 +1,74 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.database import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
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)
created_at = Column(DateTime, default=_utcnow)
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
profiles = relationship("Profile", back_populates="user", cascade="all, delete-orphan")
class ApiKey(Base):
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
key = Column(String(64), unique=True, nullable=False, index=True)
created_at = Column(DateTime, default=_utcnow)
user = relationship("User", back_populates="api_keys")
@staticmethod
def generate_key() -> str:
return uuid.uuid4().hex
class Profile(Base):
__tablename__ = "profiles"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
nickname = Column(String(64), nullable=False)
created_at = Column(DateTime, default=_utcnow)
user = relationship("User", back_populates="profiles")
postcards = relationship("Postcard", back_populates="profile", cascade="all, delete-orphan")
class Postcard(Base):
__tablename__ = "postcards"
id = Column(Integer, primary_key=True, index=True)
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
card_number = Column(String(64), nullable=False)
status = Column(String(16), nullable=False) # sent / delivered / received
# sent & delivered
recipient_name = Column(String(64))
country = Column(String(64))
send_time = Column(DateTime)
arrival_time = Column(DateTime)
# received
sender_name = Column(String(64))
receive_time = Column(DateTime)
# images
image_front = Column(String(256))
image_back = Column(String(256))
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
profile = relationship("Profile", back_populates="postcards")

0
app/routers/__init__.py Normal file
View File

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

@@ -0,0 +1,209 @@
from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy.orm import Session
from app.auth import get_current_user_api, hash_password, verify_password
from app.config import UPLOAD_DIR
from app.database import get_db
from app.models import ApiKey, Postcard, Profile, User
from app.schemas import (
ApiKeyOut,
LoginRequest,
PostcardCreate,
PostcardOut,
PostcardUpdate,
ProfileCreate,
ProfileOut,
RegisterRequest,
)
router = APIRouter(prefix="/api", tags=["api"])
# ---------- Auth ----------
@router.post("/register", response_model=dict)
def api_register(body: RegisterRequest, db: Session = Depends(get_db)):
if db.query(User).filter(User.username == body.username).first():
raise HTTPException(400, "Username already exists")
user = User(username=body.username, password_hash=hash_password(body.password))
db.add(user)
db.commit()
return {"message": "ok"}
@router.post("/login", response_model=dict)
def api_login(body: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == body.username).first()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(401, "Invalid credentials")
api_key = ApiKey(user_id=user.id, key=ApiKey.generate_key())
db.add(api_key)
db.commit()
return {"api_key": api_key.key}
# ---------- Profile ----------
def _get_profile(profile_id: int, user: User, db: Session) -> Profile:
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if not p:
raise HTTPException(404, "Profile not found")
return p
@router.get("/profiles", response_model=list[ProfileOut])
def api_list_profiles(user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
return db.query(Profile).filter(Profile.user_id == user.id).all()
@router.post("/profiles", response_model=ProfileOut, status_code=201)
def api_create_profile(body: ProfileCreate, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
p = Profile(user_id=user.id, nickname=body.nickname)
db.add(p)
db.commit()
db.refresh(p)
return p
@router.put("/profiles/{profile_id}", response_model=ProfileOut)
def api_update_profile(profile_id: int, body: ProfileCreate, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
p = _get_profile(profile_id, user, db)
p.nickname = body.nickname
db.commit()
db.refresh(p)
return p
@router.delete("/profiles/{profile_id}")
def api_delete_profile(profile_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
p = _get_profile(profile_id, user, db)
db.delete(p)
db.commit()
return {"message": "ok"}
# ---------- Postcard ----------
def _get_postcard(postcard_id: int, user: User, db: Session) -> Postcard:
pc = (
db.query(Postcard)
.join(Profile)
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
.first()
)
if not pc:
raise HTTPException(404, "Postcard not found")
return pc
@router.get("/profiles/{profile_id}/postcards", response_model=list[PostcardOut])
def api_list_postcards(
profile_id: int,
status: str | None = None,
country: str | None = None,
user: User = Depends(get_current_user_api),
db: Session = Depends(get_db),
):
_get_profile(profile_id, user, db)
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
if status:
q = q.filter(Postcard.status == status)
if country:
q = q.filter(Postcard.country.ilike(f"%{country}%"))
return q.order_by(Postcard.created_at.desc()).all()
@router.post("/profiles/{profile_id}/postcards", response_model=PostcardOut, status_code=201)
def api_create_postcard(
profile_id: int,
body: PostcardCreate,
user: User = Depends(get_current_user_api),
db: Session = Depends(get_db),
):
_get_profile(profile_id, user, db)
pc = Postcard(profile_id=profile_id, **body.model_dump())
db.add(pc)
db.commit()
db.refresh(pc)
return pc
@router.get("/postcards/{postcard_id}", response_model=PostcardOut)
def api_get_postcard(postcard_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
return _get_postcard(postcard_id, user, db)
@router.put("/postcards/{postcard_id}", response_model=PostcardOut)
def api_update_postcard(
postcard_id: int,
body: PostcardUpdate,
user: User = Depends(get_current_user_api),
db: Session = Depends(get_db),
):
pc = _get_postcard(postcard_id, user, db)
for k, v in body.model_dump(exclude_unset=True).items():
setattr(pc, k, v)
db.commit()
db.refresh(pc)
return pc
@router.delete("/postcards/{postcard_id}")
def api_delete_postcard(postcard_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
pc = _get_postcard(postcard_id, user, db)
db.delete(pc)
db.commit()
return {"message": "ok"}
# ---------- Image Upload ----------
@router.post("/postcards/{postcard_id}/image/{side}", response_model=PostcardOut)
async def api_upload_image(
postcard_id: int,
side: str,
file: UploadFile = File(...),
user: User = Depends(get_current_user_api),
db: Session = Depends(get_db),
):
if side not in ("front", "back"):
raise HTTPException(400, "side must be 'front' or 'back'")
pc = _get_postcard(postcard_id, user, db)
ext = Path(file.filename or "upload.jpg").suffix or ".jpg"
filename = f"{uuid4().hex}{ext}"
dest = UPLOAD_DIR / filename
content = await file.read()
dest.write_bytes(content)
setattr(pc, f"image_{side}", f"/uploads/{filename}")
db.commit()
db.refresh(pc)
return pc
# ---------- API Key ----------
@router.get("/api-keys", response_model=list[ApiKeyOut])
def api_list_keys(user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
return db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
@router.post("/api-keys", response_model=ApiKeyOut, status_code=201)
def api_create_key(user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
ak = ApiKey(user_id=user.id, key=ApiKey.generate_key())
db.add(ak)
db.commit()
db.refresh(ak)
return ak
@router.delete("/api-keys/{key_id}")
def api_delete_key(key_id: int, user: User = Depends(get_current_user_api), db: Session = Depends(get_db)):
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if not ak:
raise HTTPException(404, "API key not found")
db.delete(ak)
db.commit()
return {"message": "ok"}

359
app/routers/web.py Normal file
View File

@@ -0,0 +1,359 @@
from datetime import datetime
from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.auth import (
create_session,
destroy_session,
get_current_user_web,
hash_password,
redirect_if_not_logged_in,
verify_password,
)
from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR
from app.database import get_db
from app.models import ApiKey, Postcard, Profile, User
router = APIRouter(tags=["web"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303)
# ---------- Auth ----------
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db)
if user:
return _redirect("/dashboard")
return templates.TemplateResponse("login.html", {"request": request})
@router.post("/login")
def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash):
return templates.TemplateResponse(
"login.html", {"request": request, "error": "用户名或密码错误"}, status_code=401
)
resp = _redirect("/dashboard")
resp.set_cookie(SESSION_COOKIE_NAME, create_session(user.id), httponly=True)
return resp
@router.get("/register", response_class=HTMLResponse)
def register_page(request: Request):
return templates.TemplateResponse("register.html", {"request": request})
@router.post("/register")
def register_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
password2: str = Form(...),
db: Session = Depends(get_db),
):
if password != password2:
return templates.TemplateResponse(
"register.html", {"request": request, "error": "两次密码不一致"}, status_code=400
)
if db.query(User).filter(User.username == username).first():
return templates.TemplateResponse(
"register.html", {"request": request, "error": "用户名已存在"}, status_code=400
)
user = User(username=username, password_hash=hash_password(password))
db.add(user)
db.commit()
resp = _redirect("/dashboard")
resp.set_cookie(SESSION_COOKIE_NAME, create_session(user.id), httponly=True)
return resp
@router.get("/logout")
def logout(request: Request):
token = request.cookies.get(SESSION_COOKIE_NAME)
destroy_session(token or "")
resp = _redirect("/login")
resp.delete_cookie(SESSION_COOKIE_NAME)
return resp
# ---------- Dashboard ----------
@router.get("/", response_class=HTMLResponse)
def root(user: User = Depends(get_current_user_web)):
return _redirect("/dashboard")
@router.get("/dashboard", response_class=HTMLResponse)
def dashboard(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
profiles = db.query(Profile).filter(Profile.user_id == user.id).all()
# stats per profile
stats = {}
for p in profiles:
cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
stats[p.id] = {
"total": len(cards),
"sent": sum(1 for c in cards if c.status == "sent"),
"delivered": sum(1 for c in cards if c.status == "delivered"),
"received": sum(1 for c in cards if c.status == "received"),
}
return templates.TemplateResponse("dashboard.html", {"request": request, "user": user, "profiles": profiles, "stats": stats})
# ---------- Profiles ----------
@router.get("/profiles", response_class=HTMLResponse)
def profile_list(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
profiles = db.query(Profile).filter(Profile.user_id == user.id).all()
return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles})
@router.post("/profiles")
def profile_create(request: Request, nickname: str = Form(...), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
db.add(Profile(user_id=user.id, nickname=nickname))
db.commit()
return _redirect("/profiles")
@router.post("/profiles/{profile_id}/delete")
def profile_delete(profile_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if p:
db.delete(p)
db.commit()
return _redirect("/profiles")
# ---------- Postcards ----------
@router.get("/profiles/{profile_id}/postcards", response_class=HTMLResponse)
def postcard_list(
request: Request,
profile_id: int,
status: str = "",
country: str = "",
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if not profile:
return _redirect("/profiles")
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
if status:
q = q.filter(Postcard.status == status)
if country:
q = q.filter(Postcard.country.ilike(f"%{country}%"))
postcards = q.order_by(Postcard.created_at.desc()).all()
return templates.TemplateResponse(
"postcards.html",
{"request": request, "user": user, "profile": profile, "postcards": postcards, "status": status, "country": country},
)
@router.get("/profiles/{profile_id}/postcards/new", response_class=HTMLResponse)
def postcard_new(request: Request, profile_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if not profile:
return _redirect("/profiles")
return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": profile, "postcard": None})
@router.post("/profiles/{profile_id}/postcards")
def postcard_create(
request: Request,
profile_id: int,
card_number: str = Form(...),
status: str = Form(...),
recipient_name: str = Form(""),
country: str = Form(""),
send_time: str = Form(""),
arrival_time: str = Form(""),
sender_name: str = Form(""),
receive_time: str = Form(""),
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
profile = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if not profile:
return _redirect("/profiles")
def _parse_dt(s: str) -> datetime | None:
s = s.strip()
if not s:
return None
try:
return datetime.fromisoformat(s)
except ValueError:
return None
pc = Postcard(
profile_id=profile_id,
card_number=card_number,
status=status,
recipient_name=recipient_name or None,
country=country or None,
send_time=_parse_dt(send_time),
arrival_time=_parse_dt(arrival_time),
sender_name=sender_name or None,
receive_time=_parse_dt(receive_time),
)
db.add(pc)
db.commit()
return _redirect(f"/profiles/{profile_id}/postcards")
@router.get("/postcards/{postcard_id}", response_class=HTMLResponse)
def postcard_detail(request: Request, postcard_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
pc = (
db.query(Postcard)
.join(Profile)
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
.first()
)
if not pc:
return _redirect("/profiles")
return templates.TemplateResponse("postcard_detail.html", {"request": request, "user": user, "postcard": pc})
@router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse)
def postcard_edit(request: Request, postcard_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
pc = (
db.query(Postcard)
.join(Profile)
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
.first()
)
if not pc:
return _redirect("/profiles")
return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": pc.profile, "postcard": pc})
@router.post("/postcards/{postcard_id}/edit")
def postcard_update(
request: Request,
postcard_id: int,
card_number: str = Form(...),
status: str = Form(...),
recipient_name: str = Form(""),
country: str = Form(""),
send_time: str = Form(""),
arrival_time: str = Form(""),
sender_name: str = Form(""),
receive_time: str = Form(""),
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
pc = (
db.query(Postcard)
.join(Profile)
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
.first()
)
if not pc:
return _redirect("/profiles")
def _parse_dt(s: str) -> datetime | None:
s = s.strip()
if not s:
return None
try:
return datetime.fromisoformat(s)
except ValueError:
return None
pc.card_number = card_number
pc.status = status
pc.recipient_name = recipient_name or None
pc.country = country or None
pc.send_time = _parse_dt(send_time)
pc.arrival_time = _parse_dt(arrival_time)
pc.sender_name = sender_name or None
pc.receive_time = _parse_dt(receive_time)
db.commit()
return _redirect(f"/postcards/{postcard_id}")
@router.post("/postcards/{postcard_id}/delete")
def postcard_delete(postcard_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
pc = (
db.query(Postcard)
.join(Profile)
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
.first()
)
if not pc:
return _redirect("/profiles")
pid = pc.profile_id
db.delete(pc)
db.commit()
return _redirect(f"/profiles/{pid}/postcards")
# ---------- Image Upload ----------
@router.post("/postcards/{postcard_id}/image/{side}")
async def upload_image(
postcard_id: int,
side: str,
file: UploadFile = File(...),
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
if side not in ("front", "back"):
return _redirect("/profiles")
pc = (
db.query(Postcard)
.join(Profile)
.filter(Postcard.id == postcard_id, Profile.user_id == user.id)
.first()
)
if not pc:
return _redirect("/profiles")
ext = Path(file.filename or "upload.jpg").suffix or ".jpg"
filename = f"{uuid4().hex}{ext}"
dest = UPLOAD_DIR / filename
content = await file.read()
dest.write_bytes(content)
setattr(pc, f"image_{side}", f"/uploads/{filename}")
db.commit()
return _redirect(f"/postcards/{postcard_id}")
# ---------- API Keys ----------
@router.get("/api-keys", response_class=HTMLResponse)
def api_keys_page(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
return templates.TemplateResponse("api_keys.html", {"request": request, "user": user, "keys": keys})
@router.post("/api-keys")
def api_key_create(user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
ak = ApiKey(user_id=user.id, key=ApiKey.generate_key())
db.add(ak)
db.commit()
return _redirect("/api-keys")
@router.post("/api-keys/{key_id}/delete")
def api_key_delete(key_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if ak:
db.delete(ak)
db.commit()
return _redirect("/api-keys")

82
app/schemas.py Normal file
View File

@@ -0,0 +1,82 @@
from datetime import datetime
from pydantic import BaseModel
# ---------- Auth ----------
class RegisterRequest(BaseModel):
username: str
password: str
class LoginRequest(BaseModel):
username: str
password: str
# ---------- Profile ----------
class ProfileCreate(BaseModel):
nickname: str
class ProfileOut(BaseModel):
id: int
nickname: str
created_at: datetime
model_config = {"from_attributes": True}
# ---------- Postcard ----------
class PostcardCreate(BaseModel):
card_number: str
status: str # sent / delivered / received
recipient_name: str | None = None
country: str | None = None
send_time: datetime | None = None
arrival_time: datetime | None = None
sender_name: str | None = None
receive_time: datetime | 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: datetime | None = None
arrival_time: datetime | None = None
sender_name: str | None = None
receive_time: datetime | 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
image_front: str | None
image_back: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ---------- ApiKey ----------
class ApiKeyOut(BaseModel):
id: int
key: str
created_at: datetime
model_config = {"from_attributes": True}

118
app/static/style.css Normal file
View File

@@ -0,0 +1,118 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--danger: #dc2626;
--danger-hover: #b91c1c;
--bg: #f8fafc;
--card-bg: #fff;
--border: #e2e8f0;
--text: #1e293b;
--text-muted: #64748b;
--sent: #f59e0b;
--delivered: #10b981;
--received: #8b5cf6;
}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Navbar */
.navbar { display: flex; align-items: center; justify-content: space-between; padding: .75rem 1.5rem; background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
.nav-brand { font-size: 1.15rem; font-weight: 700; color: var(--text); }
.nav-brand:hover { text-decoration: none; }
.nav-links { display: flex; gap: 1.25rem; align-items: center; }
.nav-links a { color: var(--text-muted); font-size: .9rem; }
.nav-links a:hover { color: var(--primary); text-decoration: none; }
.nav-logout { color: var(--danger) !important; }
/* Container */
.container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; }
/* Auth forms */
.auth-form { max-width: 360px; margin: 4rem auto; background: var(--card-bg); padding: 2rem; border-radius: 8px; border: 1px solid var(--border); }
.auth-form h2 { margin-bottom: 1.25rem; }
.auth-link { margin-top: 1rem; text-align: center; font-size: .9rem; }
/* Forms */
label { display: block; margin-top: .75rem; font-size: .85rem; font-weight: 500; color: var(--text-muted); }
input[type="text"], input[type="password"], input[type="datetime-local"], select, textarea {
width: 100%; padding: .5rem .75rem; margin-top: .25rem; border: 1px solid var(--border); border-radius: 6px; font-size: .95rem; background: #fff;
}
input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
.form-card { background: var(--card-bg); padding: 1.5rem; border-radius: 8px; border: 1px solid var(--border); }
.form-actions { display: flex; gap: .75rem; margin-top: 1.25rem; }
.form-row { display: flex; gap: .5rem; align-items: center; }
.inline-form { margin-bottom: 1.25rem; }
/* Buttons */
.btn { display: inline-block; padding: .5rem 1rem; border: 1px solid var(--border); border-radius: 6px; background: #fff; cursor: pointer; font-size: .9rem; text-align: center; }
.btn:hover { background: var(--bg); text-decoration: none; }
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-danger:hover { background: var(--danger-hover); }
.btn-sm { padding: .25rem .6rem; font-size: .8rem; }
/* Alert */
.alert { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .9rem; }
.alert-error { background: #fef2f2; color: var(--danger); border: 1px solid #fecaca; }
/* Table */
table { width: 100%; border-collapse: collapse; background: var(--card-bg); border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
th, td { padding: .65rem 1rem; text-align: left; border-bottom: 1px solid var(--border); font-size: .9rem; }
th { background: var(--bg); font-weight: 600; color: var(--text-muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
tr:last-child td { border-bottom: none; }
code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-size: .85rem; word-break: break-all; }
.inline { display: inline; }
/* Section */
.section { margin-bottom: 2rem; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
/* Card grid */
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
.card { display: block; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; transition: box-shadow .15s; }
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.06); text-decoration: none; }
.card h3 { margin-bottom: .5rem; }
.stats { display: flex; gap: .75rem; font-size: .85rem; color: var(--text-muted); flex-wrap: wrap; }
.stat { white-space: nowrap; }
/* Postcard grid */
.postcard-card { display: flex; gap: 1rem; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: .75rem; transition: box-shadow .15s; }
.postcard-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.06); text-decoration: none; }
.thumb { width: 80px; height: 60px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
.thumb.placeholder { display: flex; align-items: center; justify-content: center; background: var(--bg); font-size: 1.5rem; }
.postcard-info { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; }
/* Badge */
.badge { display: inline-block; padding: .2rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 600; color: #fff; }
.badge-sent { background: var(--sent); }
.badge-delivered { background: var(--delivered); }
.badge-received { background: var(--received); }
/* Detail page */
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 1.5rem; }
.detail-images { display: flex; flex-direction: column; gap: 1.5rem; }
.image-block h3 { margin-bottom: .5rem; font-size: .95rem; }
.detail-img { width: 100%; max-width: 400px; border-radius: 8px; border: 1px solid var(--border); }
.upload-form { margin-top: .5rem; display: flex; gap: .5rem; align-items: center; }
.upload-form input[type="file"] { flex: 1; font-size: .85rem; }
.detail-meta dl { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; }
.detail-meta dt { font-weight: 600; font-size: .85rem; color: var(--text-muted); }
.detail-meta dd { font-size: .95rem; }
/* Filter */
.filter-bar { margin-bottom: 1.25rem; }
/* Empty state */
.empty { color: var(--text-muted); padding: 2rem; text-align: center; }
/* Responsive */
@media (max-width: 640px) {
.detail-grid { grid-template-columns: 1fr; }
.card-grid { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}API Keys - Postcard Manager{% endblock %}
{% block content %}
<div class="section-header">
<h1>API Keys</h1>
<form method="post" action="/api-keys">
<button type="submit" class="btn btn-primary">+ 创建新 Key</button>
</form>
</div>
{% if keys %}
<table>
<thead><tr><th>Key</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody>
{% for k in keys %}
<tr>
<td><code>{{ k.key }}</code></td>
<td>{{ k.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<form method="post" action="/api-keys/{{ k.id }}/delete" class="inline">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('确定删除该 Key')">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">还没有 API Key点击上方按钮创建一个。</p>
{% endif %}
<section class="section" style="margin-top:2rem">
<h2>使用说明</h2>
<pre>curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:8000/api/profiles</pre>
</section>
{% endblock %}

24
app/templates/base.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Postcard Manager{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="navbar">
<a href="/dashboard" class="nav-brand">📮 Postcard Manager</a>
<div class="nav-links">
{% if user is defined and user %}
<a href="/profiles">Profiles</a>
<a href="/api-keys">API Keys</a>
<a href="/logout" class="nav-logout">Logout</a>
{% endif %}
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Dashboard - Postcard Manager{% endblock %}
{% block content %}
<h1>👋 欢迎,{{ user.username }}</h1>
<section class="section">
<div class="section-header">
<h2>Profiles</h2>
</div>
{% if profiles %}
<div class="card-grid">
{% for p in profiles %}
<a href="/profiles/{{ p.id }}/postcards" class="card">
<h3>{{ p.nickname }}</h3>
<div class="stats">
<span class="stat">📬 {{ stats[p.id].total }}</span>
<span class="stat sent">寄出 {{ stats[p.id].sent }}</span>
<span class="stat delivered">送达 {{ stats[p.id].delivered }}</span>
<span class="stat received">收到 {{ stats[p.id].received }}</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="empty">还没有 Profile<a href="/profiles">去创建一个</a></p>
{% endif %}
</section>
{% endblock %}

18
app/templates/login.html Normal file
View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}登录 - Postcard Manager{% endblock %}
{% block content %}
<div class="auth-form">
<h2>登录</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/login">
<label>用户名</label>
<input type="text" name="username" required autofocus>
<label>密码</label>
<input type="password" name="password" required>
<button type="submit" class="btn btn-primary">登录</button>
</form>
<p class="auth-link">没有账号?<a href="/register">注册</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}明信片详情 - Postcard Manager{% endblock %}
{% block content %}
<div class="section-header">
<h1>{{ postcard.card_number }}</h1>
<div>
<a href="/postcards/{{ postcard.id }}/edit" class="btn">编辑</a>
<form method="post" action="/postcards/{{ postcard.id }}/delete" class="inline">
<button type="submit" class="btn btn-danger" onclick="return confirm('确定删除?')">删除</button>
</form>
</div>
</div>
<span class="badge badge-{{ postcard.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) }}</span>
<div class="detail-grid">
<div class="detail-images">
<div class="image-block">
<h3>正面</h3>
{% if postcard.image_front %}
<img src="{{ postcard.image_front }}" alt="正面" class="detail-img">
{% endif %}
<form method="post" action="/postcards/{{ postcard.id }}/image/front" enctype="multipart/form-data" class="inline upload-form">
<input type="file" name="file" accept="image/*" required>
<button type="submit" class="btn btn-sm">上传正面</button>
</form>
</div>
<div class="image-block">
<h3>反面</h3>
{% if postcard.image_back %}
<img src="{{ postcard.image_back }}" alt="反面" class="detail-img">
{% endif %}
<form method="post" action="/postcards/{{ postcard.id }}/image/back" enctype="multipart/form-data" class="inline upload-form">
<input type="file" name="file" accept="image/*" required>
<button type="submit" class="btn btn-sm">上传反面</button>
</form>
</div>
</div>
<div class="detail-meta">
<dl>
<dt>编号</dt><dd>{{ postcard.card_number }}</dd>
<dt>状态</dt><dd>{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(postcard.status, postcard.status) }}</dd>
{% if postcard.status in ('sent','delivered') %}
<dt>收件人</dt><dd>{{ postcard.recipient_name or '-' }}</dd>
<dt>国家/地区</dt><dd>{{ postcard.country or '-' }}</dd>
<dt>寄出时间</dt><dd>{{ postcard.send_time.strftime('%Y-%m-%d %H:%M') if postcard.send_time else '-' }}</dd>
<dt>到达时间</dt><dd>{{ postcard.arrival_time.strftime('%Y-%m-%d %H:%M') if postcard.arrival_time else '-' }}</dd>
{% else %}
<dt>发件人</dt><dd>{{ postcard.sender_name or '-' }}</dd>
<dt>收到时间</dt><dd>{{ postcard.receive_time.strftime('%Y-%m-%d %H:%M') if postcard.receive_time else '-' }}</dd>
{% endif %}
<dt>所属 Profile</dt><dd>{{ postcard.profile.nickname }}</dd>
</dl>
<a href="/profiles/{{ postcard.profile_id }}/postcards">← 返回列表</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}{{ '编辑' if postcard else '新建' }}明信片 - Postcard Manager{% endblock %}
{% block content %}
<div class="section-header">
<h1>{{ '编辑' if postcard else '新建' }}明信片</h1>
</div>
<form method="post" action="{{ '/postcards/%d/edit' % postcard.id if postcard else '/profiles/%d/postcards' % profile.id }}" class="form-card">
<label>编号 *</label>
<input type="text" name="card_number" value="{{ postcard.card_number if postcard else '' }}" required>
<label>状态 *</label>
<select name="status" id="status-select" onchange="toggleFields()">
<option value="sent" {% if postcard and postcard.status=='sent' %}selected{% endif %}>已寄出</option>
<option value="delivered" {% if postcard and postcard.status=='delivered' %}selected{% endif %}>已送达</option>
<option value="received" {% if postcard and postcard.status=='received' %}selected{% endif %}>已收到</option>
</select>
<div id="fields-sent-delivered">
<label>收件人</label>
<input type="text" name="recipient_name" value="{{ postcard.recipient_name if postcard else '' }}">
<label>国家/地区</label>
<input type="text" name="country" value="{{ postcard.country if postcard else '' }}">
<label>寄出时间</label>
<input type="datetime-local" name="send_time" value="{{ postcard.send_time.strftime('%Y-%m-%dT%H:%M') if postcard and postcard.send_time else '' }}">
<label>到达时间</label>
<input type="datetime-local" name="arrival_time" value="{{ postcard.arrival_time.strftime('%Y-%m-%dT%H:%M') if postcard and postcard.arrival_time else '' }}">
</div>
<div id="fields-received" style="display:none">
<label>发件人</label>
<input type="text" name="sender_name" value="{{ postcard.sender_name if postcard else '' }}">
<label>收到时间</label>
<input type="datetime-local" name="receive_time" value="{{ postcard.receive_time.strftime('%Y-%m-%dT%H:%M') if postcard and postcard.receive_time else '' }}">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存</button>
<a href="/profiles/{{ profile.id }}/postcards" class="btn">取消</a>
</div>
</form>
<script>
function toggleFields() {
var s = document.getElementById('status-select').value;
document.getElementById('fields-sent-delivered').style.display = (s === 'received') ? 'none' : '';
document.getElementById('fields-received').style.display = (s === 'received') ? '' : 'none';
}
toggleFields();
</script>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ profile.nickname }} - Postcard Manager{% endblock %}
{% block content %}
<div class="section-header">
<h1>{{ profile.nickname }} 的明信片</h1>
<a href="/profiles/{{ profile.id }}/postcards/new" class="btn btn-primary">+ 新明信片</a>
</div>
<div class="filter-bar">
<form method="get" class="form-row">
<select name="status">
<option value="">全部状态</option>
<option value="sent" {% if status == 'sent' %}selected{% endif %}>已寄出</option>
<option value="delivered" {% if status == 'delivered' %}selected{% endif %}>已送达</option>
<option value="received" {% if status == 'received' %}selected{% endif %}>已收到</option>
</select>
<input type="text" name="country" placeholder="国家/地区" value="{{ country }}">
<button type="submit" class="btn">筛选</button>
</form>
</div>
{% if postcards %}
<div class="card-grid">
{% for pc in postcards %}
<a href="/postcards/{{ pc.id }}" class="postcard-card">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="正面" class="thumb">
{% else %}
<div class="thumb placeholder">📷</div>
{% endif %}
<div class="postcard-info">
<span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status, pc.status) }}</span>
<strong>{{ pc.card_number }}</strong>
{% if pc.status in ('sent','delivered') %}
<span>→ {{ pc.recipient_name or '-' }} ({{ pc.country or '-' }})</span>
{% else %}
<span>← {{ pc.sender_name or '-' }}</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="empty">没有找到明信片。</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Profiles - Postcard Manager{% endblock %}
{% block content %}
<div class="section-header">
<h1>Profiles</h1>
</div>
<div class="inline-form">
<form method="post" action="/profiles" class="form-row">
<input type="text" name="nickname" placeholder="新 Profile 昵称" required>
<button type="submit" class="btn btn-primary">添加</button>
</form>
</div>
{% if profiles %}
<table>
<thead><tr><th>昵称</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody>
{% for p in profiles %}
<tr>
<td><a href="/profiles/{{ p.id }}/postcards">{{ p.nickname }}</a></td>
<td>{{ p.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<form method="post" action="/profiles/{{ p.id }}/delete" class="inline">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('确定删除该 Profile 及其所有明信片?')">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">还没有 Profile。</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}注册 - Postcard Manager{% endblock %}
{% block content %}
<div class="auth-form">
<h2>注册</h2>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/register">
<label>用户名</label>
<input type="text" name="username" required autofocus>
<label>密码</label>
<input type="password" name="password" required>
<label>确认密码</label>
<input type="password" name="password2" required>
<button type="submit" class="btn btn-primary">注册</button>
</form>
<p class="auth-link">已有账号?<a href="/login">登录</a></p>
</div>
{% endblock %}

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
sqlalchemy
python-multipart
jinja2
bcrypt
python-jose[cryptography]
aiofiles