PersonalWebApplication/docs/superpowers/plans/2026-06-24-photo-wall-server-storage.md
cc 42639856b2 docs(photo-wall): server-storage design spec + implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:55:03 +08:00

46 KiB
Raw Blame History

照片墙服务器存图 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 把照片墙的图片改为按登录用户存到服务器(最多 10 张、严格私有),排版与装饰仍存浏览器。

Architecture: Rust/Axum 新增 4 个受 JWT 鉴权保护的图片接口(列表/上传/取字节/删除),图片以随机文件名存到 PHOTO_DIR/<用户>/;登录改签带用户名的 HS256 JWT。前端用带 Bearer 头的 fetch 上传、并把图片取成 blob objectURL 显示;每张图的位置/旋转/缩放/标注按图片 id 存 localStorage缺失则自动散落排版。

Tech Stack: Rust + Axum 0.8 + jsonwebtoken + base64 + sha2后端React 19 + Vite 8 + TypeScript前端

Global Constraints

  • 设计依据:docs/superpowers/specs/2026-06-24-photo-wall-server-storage-design.md(每条任务要求隐含包含本约束)。
  • 后端框架 axum 0.8:路径参数用 {id} 语法(非 :idFromRequestParts 用原生 async fn不要 #[async_trait]
  • 图片 id = 完整文件名,正则 ^[0-9a-f]+\.(jpg|png|gif|webp)$;非法一律按不存在处理(防目录穿越)。
  • 单图解码后上限 MAX_BYTES = 8 MiB;每用户上限 MAX_PHOTOS = 10
  • 允许类型与扩展名映射:image/jpeg→jpgimage/png→pngimage/gif→gifimage/webp→webp
  • 用户目录名仅允许 [A-Za-z0-9_-]、长度 164不合法拒绝。
  • 鉴权:受保护接口读 Authorization: Bearer <jwt>,校验失败 401JWT_SECRET 来自环境变量,缺省用内置开发密钥并告警。
  • 前端 API 基址:import.meta.env.VITE_API_BASE ?? 'http://localhost:8080'(生产留空走同源相对路径,与 api/auth.ts 一致)。
  • 提交信息结尾加:Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • 当前在 main 分支:开工前先开特性分支(见 Task 0

File Structure

后端(Server/

  • Cargo.toml — 加 base64 依赖。
  • src/auth.rs(改) — JWT 签发/校验;issue_token 改为带用户名。
  • src/photos.rs(建) — PhotoStore + 纯函数:用户名/ID 校验、data URL 解析、list/save/read/delete/count。
  • src/routes/extract.rs(建) — AuthUser 提取器。
  • src/routes/web.rs(改) — 4 个图片接口、登录改签 JWT、CORS 加 DELETE/Authorization、body 上限。
  • src/routes/mod.rs(改) — 声明 mod extract;
  • src/state.rs(改) — AppStatephotos: PhotoStore
  • src/main.rs(改) — 声明 mod photos;

前端(Client/

  • src/hooks/useAuth.ts(改) — Auth 暴露 token
  • src/api/photos.ts(建) — list/upload/delete/取 objectUrl。
  • src/App.tsx(改) — 把 token 传给 PhotoWallPage
  • src/components/PhotoWallPage.tsx(改) — 服务器图片接入、持久化结构变更、右键菜单改进。

文档/部署:

  • docs/photo-wall.md(改)、docs/superpowers/plans/2026-06-18-deploy.md(改:标注本次是后端全量重部署)。

Task 0: 开特性分支

  • Step 1: 建并切到分支
cd /home/cc/InternetProject
git checkout -b feat/photo-wall-server-storage

Expected: Switched to a new branch 'feat/photo-wall-server-storage'


Task 1: 后端 JWT 签发/校验auth.rs

Files:

  • Modify: Server/src/auth.rs
  • Test: Server/src/auth.rs#[cfg(test)] 内联模块)

Interfaces:

  • Produces: pub fn issue_token(username: &str) -> Stringpub fn verify_token(token: &str) -> Option<String>(供 Task 4 的登录与 Task 3 的提取器使用)。

  • Step 1: 写失败测试

Server/src/auth.rs 末尾追加:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn jwt_round_trips_username() {
        let secret = b"test-secret";
        let token = sign(secret, "cc", far_future());
        assert_eq!(verify(secret, &token), Some("cc".to_string()));
    }

    #[test]
    fn jwt_rejects_wrong_secret() {
        let token = sign(b"secret-a", "cc", far_future());
        assert_eq!(verify(b"secret-b", &token), None);
    }

    #[test]
    fn jwt_rejects_garbage() {
        assert_eq!(verify(b"s", "not-a-jwt"), None);
    }

    #[test]
    fn jwt_rejects_expired() {
        let token = sign(b"s", "cc", 1); // 1970 年过期
        assert_eq!(verify(b"s", &token), None);
    }

    fn far_future() -> usize {
        (std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs()
            + 3600) as usize
    }
}
  • Step 2: 跑测试确认失败

Run: cd Server && cargo test auth::tests 2>&1 | tail -20 Expected: 编译失败 —— sign / verify 未定义。

  • Step 3: 实现 JWT

Server/src/auth.rs 顶部 use 区加:

use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};

把现有的 pub fn issue_token() -> String { ... } 整个替换为:

/// JWT 载荷:`sub` 为用户名,`exp` 为到期 Unix 秒。
#[derive(Serialize, Deserialize)]
struct Claims {
    sub: String,
    exp: usize,
}

/// 签发登录 token带用户名的 HS256 JWT30 天过期。密钥取 `JWT_SECRET`。
pub fn issue_token(username: &str) -> String {
    let exp = now_secs() + 30 * 24 * 3600;
    sign(&jwt_secret(), username, exp)
}

/// 校验 token成功返回其中的用户名。过期/签名不符/格式错均返回 `None`。
pub fn verify_token(token: &str) -> Option<String> {
    verify(&jwt_secret(), token)
}

/// 用指定密钥签一个 JWT便于测试不读环境变量fn sign(secret: &[u8], username: &str, exp: usize) -> String {
    let claims = Claims {
        sub: username.to_string(),
        exp,
    };
    encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret),
    )
    .unwrap_or_default()
}

/// 用指定密钥校验 JWT便于测试。默认校验 `exp`。
fn verify(secret: &[u8], token: &str) -> Option<String> {
    decode::<Claims>(
        token,
        &DecodingKey::from_secret(secret),
        &Validation::default(),
    )
    .ok()
    .map(|data| data.claims.sub)
}

/// 从 `JWT_SECRET` 取签名密钥;未设置则用内置开发密钥并告警(生产务必设置)。
fn jwt_secret() -> Vec<u8> {
    match std::env::var("JWT_SECRET") {
        Ok(s) if !s.trim().is_empty() => s.into_bytes(),
        _ => {
            eprintln!("[auth] 警告:未设置 JWT_SECRET使用内置开发密钥生产环境务必设置");
            b"dev-insecure-secret-change-me".to_vec()
        }
    }
}

fn now_secs() -> usize {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0) as usize
}

注:原 issue_token 用的 AtomicU64/COUNTER 若不再被其它代码引用,可一并删除其 use std::sync::atomic::...(编译告警会提示)。SystemTime/UNIX_EPOCH 仍需保留。

  • Step 4: 跑测试确认通过

Run: cd Server && cargo test auth::tests 2>&1 | tail -20 Expected: test result: ok. 4 passed

  • Step 5: 提交
git add Server/src/auth.rs
git commit -m "feat(auth): issue/verify username-bearing HS256 JWT

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: 后端图片存储photos.rs

Files:

  • Create: Server/src/photos.rs
  • Modify: Server/Cargo.tomlServer/src/main.rs
  • Test: Server/src/photos.rs#[cfg(test)] 内联)

Interfaces:

  • Produces:

    • pub struct PhotoStoreClonePhotoStore::from_env()PhotoStore::new(base)
    • 方法:list(&self, user) -> Vec<String>save(&self, user, data_url) -> Result<String, SaveError>read(&self, user, id) -> Option<(&'static str, Vec<u8>)>delete(&self, user, id) -> bool
    • pub enum SaveError { Full, TooLarge, BadType, BadUser, Io }pub const MAX_PHOTOS: usize = 10。 (供 Task 4 的 handler 使用。)
  • Step 1: 加 base64 依赖

编辑 Server/Cargo.toml,在 [dependencies] 下加一行(保持字母序附近即可):

base64 = "0.22"
  • Step 2: 声明模块

编辑 Server/src/main.rs,在 mod monitor; 后加一行:

mod photos;
  • Step 3: 写失败测试 + 模块骨架

新建 Server/src/photos.rs,先只放测试与签名(让它编译失败):

//! 照片墙图片存储:按用户隔离到 PHOTO_DIR/<user>/,每图随机十六进制文件名。

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    // 每个测试一个独立临时目录,结束清理(不引入 tempfile 依赖)。
    struct TmpDir(PathBuf);
    impl TmpDir {
        fn new() -> Self {
            let nanos = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos();
            let p = std::env::temp_dir().join(format!("pw-test-{nanos}"));
            std::fs::create_dir_all(&p).unwrap();
            TmpDir(p)
        }
    }
    impl Drop for TmpDir {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    // 1x1 PNG 的 data URL合法图片    const PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";

    #[test]
    fn sanitize_user_accepts_safe_rejects_traversal() {
        assert_eq!(sanitize_user("cc"), Some("cc".to_string()));
        assert_eq!(sanitize_user("a_b-1"), Some("a_b-1".to_string()));
        assert_eq!(sanitize_user("../etc"), None);
        assert_eq!(sanitize_user("a/b"), None);
        assert_eq!(sanitize_user(""), None);
    }

    #[test]
    fn valid_id_guards_extension_and_path() {
        assert!(valid_id("abc123.jpg"));
        assert!(valid_id("ff.webp"));
        assert!(!valid_id("abc.exe"));
        assert!(!valid_id("../x.jpg"));
        assert!(!valid_id("a/b.png"));
        assert!(!valid_id("nodot"));
        assert!(!valid_id("XYZ.png")); // 非 hex
    }

    #[test]
    fn parse_data_url_extracts_ext_and_rejects_nonimage() {
        let (ext, bytes) = parse_data_url(PNG_DATA_URL).unwrap();
        assert_eq!(ext, "png");
        assert!(!bytes.is_empty());
        assert_eq!(parse_data_url("data:text/plain;base64,aGk="), Err(SaveError::BadType));
        assert_eq!(parse_data_url("not-a-data-url"), Err(SaveError::BadType));
    }

    #[test]
    fn save_list_read_delete_round_trip() {
        let tmp = TmpDir::new();
        let store = PhotoStore::new(&tmp.0);
        assert_eq!(store.list("cc").len(), 0);

        let id = store.save("cc", PNG_DATA_URL).unwrap();
        assert!(valid_id(&id));
        assert_eq!(store.list("cc"), vec![id.clone()]);

        let (mime, bytes) = store.read("cc", &id).unwrap();
        assert_eq!(mime, "image/png");
        assert!(!bytes.is_empty());

        assert!(store.delete("cc", &id));
        assert_eq!(store.list("cc").len(), 0);
    }

    #[test]
    fn save_enforces_max_photos() {
        let tmp = TmpDir::new();
        let store = PhotoStore::new(&tmp.0);
        for _ in 0..MAX_PHOTOS {
            store.save("cc", PNG_DATA_URL).unwrap();
        }
        assert_eq!(store.save("cc", PNG_DATA_URL), Err(SaveError::Full));
    }

    #[test]
    fn read_rejects_bad_id_and_other_users() {
        let tmp = TmpDir::new();
        let store = PhotoStore::new(&tmp.0);
        let id = store.save("cc", PNG_DATA_URL).unwrap();
        assert!(store.read("cc", "../secret.jpg").is_none());
        assert!(store.read("dave", &id).is_none()); // 不是 dave 的目录
        assert!(!store.delete("cc", "../secret.jpg"));
    }
}
  • Step 4: 跑测试确认失败

Run: cd Server && cargo test photos::tests 2>&1 | tail -20 Expected: 编译失败 —— PhotoStore/sanitize_user/valid_id/parse_data_url/SaveError/MAX_PHOTOS 未定义。

  • Step 5: 实现存储逻辑

Server/src/photos.rs 顶部(#[cfg(test)] 之前)插入:

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

use base64::Engine;
use sha2::{Digest, Sha256};

pub const MAX_PHOTOS: usize = 10;
pub const MAX_BYTES: usize = 8 * 1024 * 1024;

/// 保存失败原因(映射到 HTTP 状态码见 routes/web.rs#[derive(Debug, PartialEq, Eq)]
pub enum SaveError {
    Full,
    TooLarge,
    BadType,
    BadUser,
    Io,
}

/// 按用户隔离的图片仓库。内部只是一个根目录路径,`Clone` 廉价。
#[derive(Clone)]
pub struct PhotoStore {
    base: PathBuf,
}

impl PhotoStore {
    /// 根目录取 `PHOTO_DIR`,默认 `photo-wall`(相对进程 cwd    pub fn from_env() -> Self {
        let base = std::env::var("PHOTO_DIR").unwrap_or_else(|_| "photo-wall".to_string());
        Self { base: base.into() }
    }

    pub fn new(base: impl Into<PathBuf>) -> Self {
        Self { base: base.into() }
    }

    fn user_dir(&self, user: &str) -> Option<PathBuf> {
        Some(self.base.join(sanitize_user(user)?))
    }

    pub fn list(&self, user: &str) -> Vec<String> {
        self.user_dir(user).map(|d| list_in(&d)).unwrap_or_default()
    }

    pub fn save(&self, user: &str, data_url: &str) -> Result<String, SaveError> {
        let dir = self.user_dir(user).ok_or(SaveError::BadUser)?;
        let (ext, bytes) = parse_data_url(data_url)?;
        std::fs::create_dir_all(&dir).map_err(|_| SaveError::Io)?;
        if list_in(&dir).len() >= MAX_PHOTOS {
            return Err(SaveError::Full);
        }
        let id = format!("{}.{}", new_stem(), ext);
        std::fs::write(dir.join(&id), &bytes).map_err(|_| SaveError::Io)?;
        Ok(id)
    }

    pub fn read(&self, user: &str, id: &str) -> Option<(&'static str, Vec<u8>)> {
        if !valid_id(id) {
            return None;
        }
        let dir = self.user_dir(user)?;
        let bytes = std::fs::read(dir.join(id)).ok()?;
        let ext = id.rsplit_once('.').map(|(_, e)| e)?;
        Some((mime_for_ext(ext), bytes))
    }

    pub fn delete(&self, user: &str, id: &str) -> bool {
        if !valid_id(id) {
            return false;
        }
        match self.user_dir(user) {
            Some(dir) => std::fs::remove_file(dir.join(id)).is_ok(),
            None => false,
        }
    }
}

/// 用户名 → 安全目录名:仅允许 `[A-Za-z0-9_-]`、长度 164。
pub fn sanitize_user(user: &str) -> Option<String> {
    if user.is_empty() || user.len() > 64 {
        return None;
    }
    if user
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
    {
        Some(user.to_string())
    } else {
        None
    }
}

/// 图片 id 合法性:`<hex>.<ext>`ext 在白名单内;杜绝路径穿越。
pub fn valid_id(id: &str) -> bool {
    let Some((stem, ext)) = id.rsplit_once('.') else {
        return false;
    };
    matches!(ext, "jpg" | "png" | "gif" | "webp")
        && !stem.is_empty()
        && stem.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}

/// 解析 data URL`data:image/png;base64,xxxx`)→ (ext, bytes),校验类型与大小。
pub fn parse_data_url(data_url: &str) -> Result<(&'static str, Vec<u8>), SaveError> {
    let rest = data_url.strip_prefix("data:").ok_or(SaveError::BadType)?;
    let (meta, b64) = rest.split_once(',').ok_or(SaveError::BadType)?;
    let mime = meta.split(';').next().unwrap_or("");
    let ext = ext_for_mime(mime).ok_or(SaveError::BadType)?;
    let bytes = base64::engine::general_purpose::STANDARD
        .decode(b64.trim())
        .map_err(|_| SaveError::BadType)?;
    if bytes.len() > MAX_BYTES {
        return Err(SaveError::TooLarge);
    }
    Ok((ext, bytes))
}

fn ext_for_mime(mime: &str) -> Option<&'static str> {
    match mime {
        "image/jpeg" => Some("jpg"),
        "image/png" => Some("png"),
        "image/gif" => Some("gif"),
        "image/webp" => Some("webp"),
        _ => None,
    }
}

fn mime_for_ext(ext: &str) -> &'static str {
    match ext {
        "jpg" => "image/jpeg",
        "png" => "image/png",
        "gif" => "image/gif",
        "webp" => "image/webp",
        _ => "application/octet-stream",
    }
}

fn list_in(dir: &Path) -> Vec<String> {
    let mut out = Vec::new();
    if let Ok(rd) = std::fs::read_dir(dir) {
        for entry in rd.flatten() {
            if let Some(name) = entry.file_name().to_str() {
                if valid_id(name) {
                    out.push(name.to_string());
                }
            }
        }
    }
    out.sort();
    out
}

/// 生成 16 位 hex 文件名干(纳秒 + 进程内计数器哈希;读取已鉴权,故无需密码学随机)。
fn new_stem() -> String {
    static COUNTER: AtomicU64 = AtomicU64::new(0);
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let digest = Sha256::digest(format!("{nanos}-{n}").as_bytes());
    digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
}
  • Step 6: 跑测试确认通过

Run: cd Server && cargo test photos::tests 2>&1 | tail -20 Expected: test result: ok. 6 passed

  • Step 7: 提交
git add Server/Cargo.toml Server/Cargo.lock Server/src/main.rs Server/src/photos.rs
git commit -m "feat(photos): per-user image store with size/count/path-safety guards

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: AuthUser 提取器routes/extract.rs

Files:

  • Create: Server/src/routes/extract.rs
  • Modify: Server/src/routes/mod.rs

Interfaces:

  • Consumes: crate::auth::verify_token

  • Produces: pub struct AuthUser(pub String),实现 FromRequestParts(鉴权失败 401)。供 Task 4 用作 handler 参数。

  • Step 1: 建提取器

新建 Server/src/routes/extract.rs

//! 鉴权提取器:从 `Authorization: Bearer <jwt>` 解出当前用户名。
//!
//! axum 0.8 的 `FromRequestParts` 用原生 `async fn`,无需 `#[async_trait]`。

use axum::extract::FromRequestParts;
use axum::http::{header, request::Parts, StatusCode};

use crate::auth;

/// 受保护接口的参数:产出已校验的用户名;缺失/非法/过期 token 一律 `401`。
pub struct AuthUser(pub String);

impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = StatusCode;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let header = parts
            .headers
            .get(header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .ok_or(StatusCode::UNAUTHORIZED)?;
        let token = header.strip_prefix("Bearer ").ok_or(StatusCode::UNAUTHORIZED)?;
        auth::verify_token(token)
            .map(AuthUser)
            .ok_or(StatusCode::UNAUTHORIZED)
    }
}
  • Step 2: 声明模块

编辑 Server/src/routes/mod.rs,在 mod client; 后加:

mod extract;
  • Step 3: 编译确认

Run: cd Server && cargo build 2>&1 | tail -20 Expected: 编译通过(可能有 AuthUser/extract 未使用的告警Task 4 会用上)。

  • Step 4: 提交
git add Server/src/routes/extract.rs Server/src/routes/mod.rs
git commit -m "feat(routes): AuthUser extractor validating Bearer JWT

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: 图片接口 + 登录改签 JWTstate.rs, web.rs

Files:

  • Modify: Server/src/state.rsServer/src/routes/web.rs

Interfaces:

  • Consumes: PhotoStoreSaveErrorMAX_PHOTOSTask 2AuthUserTask 3auth::issue_tokenTask 1

  • Produces: HTTP 接口 GET/POST /api/web/photosGET/DELETE /api/web/photos/{id}Task 6 前端消费)。

  • Step 1: AppState 加 PhotoStore

编辑 Server/src/state.rs

use crate::weather::WeatherService; 后加:

use crate::photos::PhotoStore;

在结构体里 pub weather: ... 后加字段:

    /// 照片墙图片仓库(按用户隔离的磁盘目录)。
    pub photos: PhotoStore,

Self { ... } 初始化里 weather: ... 后加:

            photos: PhotoStore::from_env(),
  • Step 2: 登录改签带用户名的 JWT

编辑 Server/src/routes/web.rs,把 login 里:

            token: crate::auth::issue_token(),

改为:

            token: crate::auth::issue_token(&req.username),
  • Step 3: 加图片 handler 与路由

编辑 Server/src/routes/web.rs。顶部 use 区补充:

use axum::extract::{DefaultBodyLimit, Path as AxPath};
use axum::response::{IntoResponse, Response};
use axum::routing::delete;

use crate::photos::{SaveError, MAX_PHOTOS};
use crate::routes::extract::AuthUser;

说明:get/post 已在原有 routing::{get, post} 引入;这里补 delete。 若 routing 那行是 use axum::routing::{get, post};,改成 use axum::routing::{delete, get, post};,并删掉上面单独的 use axum::routing::delete; 以免重复。

CORS 改为放行 DELETE 与 Authorization 头 —— 把:

        .allow_methods([Method::GET, Method::POST])
        .allow_headers([header::CONTENT_TYPE]);

改成:

        .allow_methods([Method::GET, Method::POST, Method::DELETE])
        .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]);

路由表里,在 .route("/api/web/weather", get(weather)) 后加:

        .route("/api/web/photos", get(list_photos).post(upload_photo))
        .route("/api/web/photos/{id}", get(get_photo).delete(delete_photo))
        .layer(DefaultBodyLimit::max(16 * 1024 * 1024))

在文件末尾(client_ip 函数后)追加 handler

/// 当前用户的图片 id 列表 + 数量上限。
#[derive(Serialize)]
struct PhotoList {
    items: Vec<PhotoItem>,
    max: usize,
}
#[derive(Serialize)]
struct PhotoItem {
    id: String,
}

async fn list_photos(AuthUser(user): AuthUser, State(state): State<AppState>) -> Json<PhotoList> {
    let items = state
        .photos
        .list(&user)
        .into_iter()
        .map(|id| PhotoItem { id })
        .collect();
    Json(PhotoList {
        items,
        max: MAX_PHOTOS,
    })
}

/// 上传请求体base64 data URL前端 FileReader 读出的)。
#[derive(Deserialize)]
struct UploadRequest {
    #[serde(rename = "dataUrl")]
    data_url: String,
}
#[derive(Serialize)]
struct UploadResponse {
    id: String,
}

async fn upload_photo(
    AuthUser(user): AuthUser,
    State(state): State<AppState>,
    Json(req): Json<UploadRequest>,
) -> Result<(StatusCode, Json<UploadResponse>), StatusCode> {
    match state.photos.save(&user, &req.data_url) {
        Ok(id) => Ok((StatusCode::CREATED, Json(UploadResponse { id }))),
        Err(SaveError::Full) => Err(StatusCode::CONFLICT),
        Err(SaveError::TooLarge) => Err(StatusCode::PAYLOAD_TOO_LARGE),
        Err(SaveError::BadType) => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE),
        Err(SaveError::BadUser) => Err(StatusCode::BAD_REQUEST),
        Err(SaveError::Io) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

async fn get_photo(
    AuthUser(user): AuthUser,
    State(state): State<AppState>,
    AxPath(id): AxPath<String>,
) -> Response {
    match state.photos.read(&user, &id) {
        Some((mime, bytes)) => ([(header::CONTENT_TYPE, mime)], bytes).into_response(),
        None => StatusCode::NOT_FOUND.into_response(),
    }
}

async fn delete_photo(
    AuthUser(user): AuthUser,
    State(state): State<AppState>,
    AxPath(id): AxPath<String>,
) -> StatusCode {
    if state.photos.delete(&user, &id) {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}
  • Step 4: 编译

Run: cd Server && cargo build 2>&1 | tail -20 Expected: 编译通过。

  • Step 5: 本地起服 + curl 冒烟

本地需要 token用内置兜底用户 cc/192118Lht(无 DB 时)。开两个终端,或后台起服:

cd Server
JWT_SECRET=local-test PHOTO_DIR=/tmp/pw-photos PORT=8080 cargo run >/tmp/pw-server.log 2>&1 &
sleep 2
# 1) 登录拿 token
TOKEN=$(curl -s -X POST localhost:8080/api/web/login -H 'Content-Type: application/json' \
  -d '{"username":"cc","password":"192118Lht"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
echo "token: ${TOKEN:0:24}..."
# 2) 无 token 应 401
curl -s -o /dev/null -w 'no-token -> %{http_code}\n' localhost:8080/api/web/photos
# 3) 带 token 列表应 200 空列表
curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/api/web/photos
# 4) 上传 1x1 png
curl -s -X POST localhost:8080/api/web/photos -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"dataUrl":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}'
echo
# 5) 列表应有 1 张,取其 id 验证可下载
curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/api/web/photos

Expectedno-token -> 401;列表先 {"items":[],"max":10},上传返回 {"id":"....png"},再列表含该 id。 清理:pkill -f 'target/.*RustServer' ; pkill -f 'cargo run'(或记录 PID kill

  • Step 6: 提交
git add Server/src/state.rs Server/src/routes/web.rs
git commit -m "feat(web): per-user photo endpoints (list/upload/get/delete) behind JWT

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 5: 前端图片 API + useAuth 暴露 token

Files:

  • Modify: Client/src/hooks/useAuth.tsClient/src/App.tsx
  • Create: Client/src/api/photos.ts

Interfaces:

  • Produces: Auth.token: string | nullClient/src/api/photos.ts 导出 listPhotosuploadPhotodeletePhotofetchPhotoObjectUrlPhotoItemUploadError。供 Task 6 消费。

  • Step 1: useAuth 暴露 token

编辑 Client/src/hooks/useAuth.ts

interface Authusername: string | null 后加:

  /** 当前会话 tokenJWT未登录为 null。用于受保护接口的 Authorization 头 */
  token: string | null

return 那行改为:

  return { username: session?.username ?? null, token: session?.token ?? null, signIn, signOut }
  • Step 2: 新建图片 API

新建 Client/src/api/photos.ts

// 照片墙图片接口封装。基址与 auth.ts 一致:生产留空走同源相对路径。
const API_BASE =
  (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8080'

export interface PhotoItem {
  id: string
}

/** 上传失败的可区分原因,供 UI 给出对应提示 */
export type UploadError = 'full' | 'too-large' | 'bad-type' | 'failed'

function authHeaders(token: string): Record<string, string> {
  return { Authorization: `Bearer ${token}` }
}

/** 列出当前用户的图片 id 与数量上限 */
export async function listPhotos(token: string): Promise<{ items: PhotoItem[]; max: number }> {
  const res = await fetch(`${API_BASE}/api/web/photos`, { headers: authHeaders(token) })
  if (!res.ok) throw new Error(`list failed: ${res.status}`)
  return (await res.json()) as { items: PhotoItem[]; max: number }
}

/** 上传一张图片base64 data URL返回服务器分配的图片 id */
export async function uploadPhoto(token: string, dataUrl: string): Promise<string> {
  const res = await fetch(`${API_BASE}/api/web/photos`, {
    method: 'POST',
    headers: { ...authHeaders(token), 'Content-Type': 'application/json' },
    body: JSON.stringify({ dataUrl }),
  })
  if (res.ok) return ((await res.json()) as { id: string }).id
  const reason: UploadError =
    res.status === 409
      ? 'full'
      : res.status === 413
        ? 'too-large'
        : res.status === 415
          ? 'bad-type'
          : 'failed'
  throw new Error(reason)
}

/** 删除一张图片 */
export async function deletePhoto(token: string, id: string): Promise<void> {
  const res = await fetch(`${API_BASE}/api/web/photos/${id}`, {
    method: 'DELETE',
    headers: authHeaders(token),
  })
  if (!res.ok && res.status !== 404) throw new Error(`delete failed: ${res.status}`)
}

/** 取图片字节为 objectURL带鉴权故不能直接用作 <img src> 的服务器 URL *  调用方负责在不用时 URL.revokeObjectURL 释放。 */
export async function fetchPhotoObjectUrl(token: string, id: string): Promise<string> {
  const res = await fetch(`${API_BASE}/api/web/photos/${id}`, { headers: authHeaders(token) })
  if (!res.ok) throw new Error(`get failed: ${res.status}`)
  return URL.createObjectURL(await res.blob())
}
  • Step 3: App 传 token 给照片墙

编辑 Client/src/App.tsx,把:

      ? <PhotoWallPage onExit={goStart} />

改为:

      ? <PhotoWallPage onExit={goStart} token={auth.token ?? ''} />
  • Step 4: 类型检查 + lint

Run: cd Client && npm run build 2>&1 | tail -5 && npm run lint 2>&1 | tail -8 Expected: build 成功;新增/改动文件无 lint 报错。 (此时 PhotoWallPage 还没声明 token propTS 会报错 —— 这是预期的,下一任务补上。若想本步即过,可先在 PhotoWallPagePropstoken: string 占位,但 Task 6 会正式用它。为避免红灯阻断,本步把 Task 6 Step 1 一起做了再验证。

  • Step 5: 提交
git add Client/src/hooks/useAuth.ts Client/src/api/photos.ts Client/src/App.tsx
git commit -m "feat(client): photos API client + expose auth token

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: PhotoWallPage 接服务器图片 + 持久化结构变更

Files:

  • Modify: Client/src/components/PhotoWallPage.tsx

Interfaces:

  • Consumes: Client/src/api/photos.tsTask 5
  • Produces: 接 token prop 的照片墙localStorage 结构 { bgIndex, decorations: El[], photoLayout: Record<string, PhotoLayout> }

说明:这是对现有大组件的改造。以下按"加字段 / 改持久化 / 改加载 / 改上传删除"分步,每步给出完整替换代码块与锚点。

  • Step 1: 扩展 props 与 El 类型

interface PhotoWallPageProps 改为:

interface PhotoWallPageProps {
  /** 退出照片墙(清登录态 + 抹掉地址栏 #photo回到导航页 */
  onExit: () => void
  /** 当前会话 token用于图片接口鉴权 */
  token: string
}

函数签名改为:

export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {

interface El 里加一个可选字段photo 专用,关联服务器图片 id

  /** 服务器图片 id仅 type==='photo'),也是 localStorage 排版的 key */
  photoId?: string
  • Step 2: 定义持久化结构与加载函数

把现有的 interface SavedStateloadInitial、以及顶部 STORAGE_KEY 相关部分按下面替换/补充。

新增类型(放在 interface El 之后):

/** 每张照片的本地排版覆盖(按服务器图片 id 存浏览器) */
interface PhotoLayout {
  x: number
  y: number
  rot: number
  scale: number
  z: number
  w: number
  caption: string
}

interface SavedState {
  bgIndex: number
  decorations: El[]
  photoLayout: Record<string, PhotoLayout>
}

loadInitial 整个替换为(只恢复装饰与排版,不含图片字节;兼容旧结构):

function loadInitial() {
  let decorations: El[] = []
  let photoLayout: Record<string, PhotoLayout> = {}
  let bgIndex = 0
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    if (raw) {
      const data = JSON.parse(raw) as Partial<SavedState> & { els?: El[] }
      if (typeof data.bgIndex === 'number') bgIndex = data.bgIndex
      if (Array.isArray(data.decorations)) {
        decorations = data.decorations
      } else if (Array.isArray(data.els)) {
        // 旧结构(照片带 base64 存本地):只保留装饰,丢弃旧照片。
        decorations = data.els.filter((e) => e.type !== 'photo')
      }
      if (data.photoLayout && typeof data.photoLayout === 'object') {
        photoLayout = data.photoLayout
      }
    }
  } catch {
    /* 损坏的存档当作空墙 */
  }
  const nextId = decorations.reduce((m, e) => Math.max(m, e.id), 0) + 1
  const maxZ = Math.max(10, ...decorations.map((e) => e.z || 10))
  return { decorations, photoLayout, bgIndex, nextId, maxZ }
}
  • Step 3: 初始化 state 与排版 ref

把组件内:

  const initial = useMemo(() => loadInitial(), [])

  const [els, setEls] = useState<El[]>(initial.els)

改为:

  const initial = useMemo(() => loadInitial(), [])

  const [els, setEls] = useState<El[]>(initial.decorations)
  // 每张图的本地排版(按服务器图片 id。runtime 维护,保存时连同装饰一起落盘。
  const layoutRef = useRef<Record<string, PhotoLayout>>(initial.photoLayout)
  // 本组件创建的所有 objectURL卸载时统一释放。
  const objUrls = useRef<string[]>([])
  • Step 4: 改自动保存 effect 写新结构

把保存 effect 内 localStorage.setItem(...) 那行替换为(拆分装饰与照片排版):

        const decorations = els.filter((e) => e.type !== 'photo')
        const photoLayout: Record<string, PhotoLayout> = {}
        for (const e of els) {
          if (e.type === 'photo' && e.photoId) {
            photoLayout[e.photoId] = {
              x: e.x, y: e.y, rot: e.rot, scale: e.scale, z: e.z,
              w: e.w ?? 160, caption: e.caption ?? '',
            }
          }
        }
        layoutRef.current = photoLayout
        localStorage.setItem(STORAGE_KEY, JSON.stringify({ bgIndex, decorations, photoLayout }))

(该 effect 的依赖数组 [els, bgIndex, showToast] 不变。)

  • Step 5: 挂载时从服务器加载图片

在组件内(useEffect 群里,紧接自动保存 effect 之后)新增加载 effect

  // 登录后从服务器拉本用户图片:取字节为 objectURL套用本地排版或自动散落。
  useEffect(() => {
    if (!token) return
    let alive = true
    ;(async () => {
      try {
        const { items } = await listPhotos(token)
        for (const item of items) {
          let url: string
          try {
            url = await fetchPhotoObjectUrl(token, item.id)
          } catch {
            continue
          }
          if (!alive) {
            URL.revokeObjectURL(url)
            return
          }
          objUrls.current.push(url)
          const layout = layoutRef.current[item.id]
          // 取自然尺寸算宽高比;无本地排版时随机散落。
          const dims = await new Promise<{ w: number; h: number }>((resolve) => {
            const img = new Image()
            img.onload = () => resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 })
            img.onerror = () => resolve({ w: 1, h: 1 })
            img.src = url
          })
          if (!alive) return
          const ratio = dims.h / dims.w
          const roll = Math.random()
          const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
          const pos = spawnPos(40)
          const base: El = layout
            ? {
                id: idRef.current++, type: 'photo', src: url, photoId: item.id,
                w: layout.w, h: Math.round(layout.w * ratio),
                x: layout.x, y: layout.y, rot: layout.rot, scale: layout.scale,
                z: layout.z, caption: layout.caption,
                fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
              }
            : {
                id: idRef.current++, type: 'photo', src: url, photoId: item.id,
                w: 170, h: Math.round(170 * ratio),
                x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1, z: ++zRef.current,
                caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
              }
          if (layout) zRef.current = Math.max(zRef.current, layout.z)
          setEls((prev) => [...prev, base])
        }
      } catch (e) {
        if (alive) showToast('图片加载失败(请重新登录或检查网络)')
      }
    })()
    return () => {
      alive = false
    }
    // 仅在挂载/换 token 时跑一次
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [token])

  // 卸载时释放所有 objectURL
  useEffect(() => {
    return () => {
      for (const u of objUrls.current) URL.revokeObjectURL(u)
    }
  }, [])

并在文件顶部 import 区加:

import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos'
  • Step 6: 上传改走服务器

把现有 handleFiles 的回调体中"读出后直接 makePhoto"的逻辑改为先上传。具体:将 reader.onload 内:

          const img = new Image()
          img.onload = () => {
            const w = randInt(140, 200)
            const h = Math.round(w * (img.height / img.width))
            makePhoto(src, w, h)
          }
          img.src = src

替换为:

          // 满 10 张直接拦下
          const count = elsRef.current.filter((e) => e.type === 'photo').length
          if (count >= 10) {
            showToast('每位用户最多 10 张照片')
            return
          }
          uploadPhoto(token, src)
            .then((id) => {
              const img = new Image()
              img.onload = () => {
                const w = randInt(140, 200)
                const h = Math.round(w * (img.height / img.width))
                const pos = spawnPos(40)
                const roll = Math.random()
                const fix: El['fix'] = roll < 0.34 ? 'tape' : roll < 0.67 ? 'pin' : 'tape2'
                addEl({
                  type: 'photo', src, photoId: id, w, h,
                  x: pos.x, y: pos.y, rot: rand(-15, 15), scale: 1,
                  caption: '', fix, fixColor: pick(PIN_COLORS), fixRot: rand(-3, 3),
                })
              }
              img.src = src
            })
            .catch((err: Error) => {
              const reason = err.message as UploadError
              showToast(
                reason === 'full' ? '每位用户最多 10 张照片'
                  : reason === 'too-large' ? '图片过大(上限 8MB'
                    : reason === 'bad-type' ? '不支持的图片格式'
                      : '上传失败',
              )
            })

makePhoto 不再被 handleFiles 使用;若无其它引用可删除该函数及其依赖项。addEl 已存在。)

  • Step 7: 删除改走服务器

removeEl 替换为:

  const removeEl = (id: number) => {
    const target = elsRef.current.find((e) => e.id === id)
    if (target?.type === 'photo' && target.photoId) {
      deletePhoto(token, target.photoId).catch(() => {})
      delete layoutRef.current[target.photoId]
      if (target.src?.startsWith('blob:')) {
        URL.revokeObjectURL(target.src)
        objUrls.current = objUrls.current.filter((u) => u !== target.src)
      }
    }
    mutate((p) => p.filter((e) => e.id !== id))
    nodeRefs.current.delete(id)
    setMenu(null)
  }
  • Step 8: 类型检查 + lint

Run: cd Client && npm run build 2>&1 | tail -8 && npm run lint 2>&1 | tail -8 Expected: build 成功;新增/改动文件无 lint 报错(CursorFollower.tsx 既有告警不计)。

  • Step 9: 提交
git add Client/src/components/PhotoWallPage.tsx
git commit -m "feat(photo-wall): load/upload/delete photos via server, split local layout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 7: 右键菜单调整幅度加大 + 角度/比例预设

Files:

  • Modify: Client/src/components/PhotoWallPage.tsx

Interfaces:

  • Consumes: 现有 mutaterotatescaleElresizePhotomenu 状态。

  • Step 1: 默认步进调大

在右键菜单 JSX 里,把旋转项的 ±5 改为 ±15,照片放大/缩小 ±10 改为 ±30,其它元素 scale ±0.12 改为 ±0.25。即:

  • rotate(menu.id, 5)rotate(menu.id, 15),文案 旋转 +15°-5-15

  • resizePhoto(menu.id, 10)resizePhoto(menu.id, 30)-10-30

  • scaleEl(menu.id, 0.12)scaleEl(menu.id, 0.25)-0.12-0.25

  • Step 2: 加"绝对设定"辅助函数

在其它菜单动作(recolorNote 附近)加:

  const setRotation = (id: number, deg: number) =>
    mutate((p) => p.map((e) => (e.id === id ? { ...e, rot: deg } : e)))
  const setScale = (id: number, s: number) =>
    mutate((p) => p.map((e) => (e.id === id ? { ...e, scale: s } : e)))
  • Step 3: 菜单加预设行

在右键菜单 JSX 里,"移到最前/最后"之后、删除之前,加两组预设(沿用 .pw-colorrow 的横排样式做成小药丸按钮):

          <div className="pw-sep" />
          <div className="pw-preset">
            <span className="pw-preset-label">旋转</span>
            {[0, 15, 30, 45, 90].map((d) => (
              <button key={d} className="pw-chip" onClick={() => { setRotation(menu.id, d); setMenu(null) }}>{d}°</button>
            ))}
          </div>
          <div className="pw-preset">
            <span className="pw-preset-label">比例</span>
            {[0.5, 0.75, 1, 1.5, 2].map((s) => (
              <button key={s} className="pw-chip" onClick={() => { setScale(menu.id, s); setMenu(null) }}>{s}×</button>
            ))}
          </div>
  • Step 4: 加预设样式

Client/src/index.css 的照片墙段落里(.pw-colorrow 附近)加:

.pw-preset { display: flex; align-items: center; gap: 5px; padding: 5px 8px; flex-wrap: wrap; }
.pw-preset-label { font-size: 12px; color: #888; width: 28px; }
.pw-chip {
  border: 1px solid #e0e0e0; background: #fff; border-radius: 12px;
  padding: 3px 9px; font-size: 12px; cursor: pointer; color: #444;
}
.pw-chip:hover { background: #f5f5f5; }
  • Step 5: 类型检查 + lint + 手动冒烟

Run: cd Client && npm run build 2>&1 | tail -5 && npm run lint 2>&1 | tail -5 Expected: 通过。 手动:npm run devlocalhost:5173/#photo 登录(需本地后端,见 Task 4 Step 5 起服方式,加 DATABASE_URL 留空即用内置 cc)→ 右键照片,确认旋转/比例预设生效、步进更大。

  • Step 6: 提交
git add Client/src/components/PhotoWallPage.tsx Client/src/index.css
git commit -m "feat(photo-wall): larger adjust steps + rotation/scale presets in context menu

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 8: 部署(后端全量)+ 配置 + 文档

Files:

  • Modify: docs/photo-wall.mddocs/superpowers/plans/2026-06-18-deploy.md
  • Remote: weather.env(加 JWT_SECRET)、重启 RustServer

Interfaces:

  • Consumes: 全部前述任务。

  • Step 1: 服务器加 JWT_SECRET

SECRET=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
ssh root@8.130.143.54 "grep -q '^JWT_SECRET=' /opt/internet-project/weather.env || echo 'JWT_SECRET=$SECRET' >> /opt/internet-project/weather.env"
ssh root@8.130.143.54 "grep -c JWT_SECRET /opt/internet-project/weather.env"

Expected: 末行打印 1

  • Step 2: 构建前端 + 交叉编译后端
cd /home/cc/InternetProject/Client && npm run build 2>&1 | tail -3
cd /home/cc/InternetProject/Server && cargo build --release 2>&1 | tail -3
file target/release/RustServer

Expected: 前端 ✓ built;后端 Finished releaseELF 64-bit ... x86-64

  • Step 3: 上传二进制 + 前端静态
cd /home/cc/InternetProject
scp Server/target/release/RustServer root@8.130.143.54:/opt/internet-project/RustServer.new
ssh root@8.130.143.54 "rm -rf /opt/internet-project/static/assets && mkdir -p /opt/internet-project/static"
scp -r Client/dist/* root@8.130.143.54:/opt/internet-project/static/
  • Step 4: 用 restart 脚本切换二进制并重启
ssh root@8.130.143.54 "cat /opt/internet-project/restart_rust.sh"
# 若脚本期望新二进制名/路径,按其约定放置;否则手动替换 + 重启:
ssh root@8.130.143.54 "cd /opt/internet-project && pkill RustServer; sleep 1; cp RustServer RustServer.bak; mv RustServer.new RustServer; chmod +x RustServer; nohup ./RustServer > server.log 2>&1 & sleep 1; tail -3 server.log"

Expected: 日志出现 backend listening on http://0.0.0.0:8548

  • Step 5: 线上冒烟
TOKEN=$(curl -s -X POST http://8.130.143.54:8548/api/web/login -H 'Content-Type: application/json' \
  -d '{"username":"cc","password":"192118Lht"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -s -o /dev/null -w 'no-token -> %{http_code}\n' http://8.130.143.54:8548/api/web/photos
curl -s -H "Authorization: Bearer $TOKEN" http://8.130.143.54:8548/api/web/photos

Expected: no-token -> 401;带 token 返回 {"items":[...],"max":10}

  • Step 6: 更新文档

编辑 docs/photo-wall.md:把"持久化"一节改为说明"图片存服务器按用户≤10私有JWT 鉴权),排版/装饰存浏览器",并补接口与存储路径(/opt/internet-project/photo-wall/<用户>/)。 编辑 docs/superpowers/plans/2026-06-18-deploy.md:在运维参考里注明"照片墙服务器存图引入了 PHOTO_DIRJWT_SECRET;该功能改了后端,需全量重部署(非 frontend-only上线后用户需重新登录一次"。

  • Step 7: 提交并合回
cd /home/cc/InternetProject
git add docs/photo-wall.md docs/superpowers/plans/2026-06-18-deploy.md
git commit -m "docs: photo wall server-side image storage + deploy notes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
git checkout main && git merge --no-ff feat/photo-wall-server-storage
  • Step 8: 手动端到端验证

浏览器 http://8.130.143.54:8548/#photo → 重新登录 → 上传几张图 → 拖动/旋转/缩放(用新预设)→ 刷新页面确认图片仍在、排版保留 → 换个浏览器登录确认图片在、排版回到自动散落。


Self-Review 记录

  • Spec 覆盖:职责切分(Task 6) · JWT(Task 1) · 接口与存储(Task 2/4) · 私有读取鉴权(Task 3/4) · 前端数据流(Task 5/6) · 右键菜单改进(Task 7) · 部署/CORS/上限/JWT_SECRET(Task 4/8)。均有对应任务。
  • 占位符:无 TBD/TODO每个代码步给出完整代码。
  • 类型一致PhotoStore/SaveError/MAX_PHOTOS(Task 2) 在 Task 4 同名使用;AuthUser(Task 3) 在 Task 4 使用;issue_token(&str)(Task 1) 在 Task 4 调用;前端 photos.ts 导出名(Task 5) 在 Task 6 import 一致;PhotoLayout/SavedState(Task 6) 自洽。
  • axum 0.8:路径用 {id}FromRequestPartsasync_trait,已在 Global Constraints 与 Task 3/4 明确。