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

1357 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 照片墙服务器存图 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}` 语法(非 `:id``FromRequestParts` 用原生 `async fn`**不要** `#[async_trait]`
- 图片 `id` = 完整文件名,正则 `^[0-9a-f]+\.(jpg|png|gif|webp)$`;非法一律按不存在处理(防目录穿越)。
- 单图解码后上限 `MAX_BYTES = 8 MiB`;每用户上限 `MAX_PHOTOS = 10`
- 允许类型与扩展名映射:`image/jpeg→jpg`、`image/png→png`、`image/gif→gif`、`image/webp→webp`。
- 用户目录名仅允许 `[A-Za-z0-9_-]`、长度 164不合法拒绝。
- 鉴权:受保护接口读 `Authorization: Bearer <jwt>`,校验失败 `401`。`JWT_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`(改) — `AppState``photos: 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: 建并切到分支**
```bash
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) -> String`、`pub fn verify_token(token: &str) -> Option<String>`(供 Task 4 的登录与 Task 3 的提取器使用)。
- [ ] **Step 1: 写失败测试**
`Server/src/auth.rs` 末尾追加:
```rust
#[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` 区加:
```rust
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
```
把现有的 `pub fn issue_token() -> String { ... }` 整个替换为:
```rust
/// 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: 提交**
```bash
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.toml`、`Server/src/main.rs`
- Test: `Server/src/photos.rs``#[cfg(test)]` 内联)
**Interfaces:**
- Produces:
- `pub struct PhotoStore``Clone``PhotoStore::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]` 下加一行(保持字母序附近即可):
```toml
base64 = "0.22"
```
- [ ] **Step 2: 声明模块**
编辑 `Server/src/main.rs`,在 `mod monitor;` 后加一行:
```rust
mod photos;
```
- [ ] **Step 3: 写失败测试 + 模块骨架**
新建 `Server/src/photos.rs`,先只放测试与签名(让它编译失败):
```rust
//! 照片墙图片存储:按用户隔离到 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)]` 之前)插入:
```rust
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: 提交**
```bash
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`
```rust
//! 鉴权提取器:从 `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;` 后加:
```rust
mod extract;
```
- [ ] **Step 3: 编译确认**
Run: `cd Server && cargo build 2>&1 | tail -20`
Expected: 编译通过(可能有 `AuthUser`/`extract` 未使用的告警Task 4 会用上)。
- [ ] **Step 4: 提交**
```bash
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.rs`、`Server/src/routes/web.rs`
**Interfaces:**
- Consumes: `PhotoStore`、`SaveError`、`MAX_PHOTOS`Task 2、`AuthUser`Task 3、`auth::issue_token`Task 1
- Produces: HTTP 接口 `GET/POST /api/web/photos`、`GET/DELETE /api/web/photos/{id}`Task 6 前端消费)。
- [ ] **Step 1: AppState 加 PhotoStore**
编辑 `Server/src/state.rs`
`use crate::weather::WeatherService;` 后加:
```rust
use crate::photos::PhotoStore;
```
在结构体里 `pub weather: ...` 后加字段:
```rust
/// 照片墙图片仓库(按用户隔离的磁盘目录)。
pub photos: PhotoStore,
```
`Self { ... }` 初始化里 `weather: ...` 后加:
```rust
photos: PhotoStore::from_env(),
```
- [ ] **Step 2: 登录改签带用户名的 JWT**
编辑 `Server/src/routes/web.rs`,把 `login` 里:
```rust
token: crate::auth::issue_token(),
```
改为:
```rust
token: crate::auth::issue_token(&req.username),
```
- [ ] **Step 3: 加图片 handler 与路由**
编辑 `Server/src/routes/web.rs`。顶部 `use` 区补充:
```rust
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 头 —— 把:
```rust
.allow_methods([Method::GET, Method::POST])
.allow_headers([header::CONTENT_TYPE]);
```
改成:
```rust
.allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]);
```
路由表里,在 `.route("/api/web/weather", get(weather))` 后加:
```rust
.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
```rust
/// 当前用户的图片 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 时)。开两个终端,或后台起服:
```bash
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
```
Expected`no-token -> 401`;列表先 `{"items":[],"max":10}`,上传返回 `{"id":"....png"}`,再列表含该 id。
清理:`pkill -f 'target/.*RustServer' ; pkill -f 'cargo run'`(或记录 PID kill
- [ ] **Step 6: 提交**
```bash
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.ts`、`Client/src/App.tsx`
- Create: `Client/src/api/photos.ts`
**Interfaces:**
- Produces: `Auth.token: string | null``Client/src/api/photos.ts` 导出 `listPhotos`、`uploadPhoto`、`deletePhoto`、`fetchPhotoObjectUrl`、`PhotoItem`、`UploadError`。供 Task 6 消费。
- [ ] **Step 1: useAuth 暴露 token**
编辑 `Client/src/hooks/useAuth.ts`
`interface Auth``username: string | null` 后加:
```ts
/** 当前会话 tokenJWT未登录为 null。用于受保护接口的 Authorization 头 */
token: string | null
```
`return` 那行改为:
```ts
return { username: session?.username ?? null, token: session?.token ?? null, signIn, signOut }
```
- [ ] **Step 2: 新建图片 API**
新建 `Client/src/api/photos.ts`
```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`,把:
```tsx
? <PhotoWallPage onExit={goStart} />
```
改为:
```tsx
? <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 会报错 —— 这是预期的,下一任务补上。若想本步即过,可先在 `PhotoWallPageProps``token: string` 占位,但 Task 6 会正式用它。**为避免红灯阻断,本步把 Task 6 Step 1 一起做了再验证。**
- [ ] **Step 5: 提交**
```bash
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.ts`Task 5
- Produces: 接 `token` prop 的照片墙localStorage 结构 `{ bgIndex, decorations: El[], photoLayout: Record<string, PhotoLayout> }`
> 说明:这是对现有大组件的改造。以下按"加字段 / 改持久化 / 改加载 / 改上传删除"分步,每步给出完整替换代码块与锚点。
- [ ] **Step 1: 扩展 props 与 El 类型**
`interface PhotoWallPageProps` 改为:
```tsx
interface PhotoWallPageProps {
/** 退出照片墙(清登录态 + 抹掉地址栏 #photo回到导航页 */
onExit: () => void
/** 当前会话 token用于图片接口鉴权 */
token: string
}
```
函数签名改为:
```tsx
export function PhotoWallPage({ onExit, token }: PhotoWallPageProps) {
```
`interface El` 里加一个可选字段photo 专用,关联服务器图片 id
```tsx
/** 服务器图片 id仅 type==='photo'),也是 localStorage 排版的 key */
photoId?: string
```
- [ ] **Step 2: 定义持久化结构与加载函数**
把现有的 `interface SavedState`、`loadInitial`、以及顶部 `STORAGE_KEY` 相关部分按下面替换/补充。
新增类型(放在 `interface El` 之后):
```tsx
/** 每张照片的本地排版覆盖(按服务器图片 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` 整个替换为(只恢复装饰与排版,不含图片字节;兼容旧结构):
```tsx
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**
把组件内:
```tsx
const initial = useMemo(() => loadInitial(), [])
const [els, setEls] = useState<El[]>(initial.els)
```
改为:
```tsx
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(...)` 那行替换为(拆分装饰与照片排版):
```tsx
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
```tsx
// 登录后从服务器拉本用户图片:取字节为 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` 区加:
```tsx
import { listPhotos, uploadPhoto, deletePhoto, fetchPhotoObjectUrl, type UploadError } from '../api/photos'
```
- [ ] **Step 6: 上传改走服务器**
把现有 `handleFiles` 的回调体中"读出后直接 `makePhoto`"的逻辑改为先上传。具体:将 `reader.onload` 内:
```tsx
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
```
替换为:
```tsx
// 满 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` 替换为:
```tsx
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: 提交**
```bash
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: 现有 `mutate`、`rotate`、`scaleEl`、`resizePhoto`、`menu` 状态。
- [ ] **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` 附近)加:
```tsx
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` 的横排样式做成小药丸按钮):
```tsx
<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` 附近)加:
```css
.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 dev` → `localhost:5173/#photo` 登录(需本地后端,见 Task 4 Step 5 起服方式,加 `DATABASE_URL` 留空即用内置 `cc`)→ 右键照片,确认旋转/比例预设生效、步进更大。
- [ ] **Step 6: 提交**
```bash
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.md`、`docs/superpowers/plans/2026-06-18-deploy.md`
- Remote: `weather.env`(加 `JWT_SECRET`)、重启 RustServer
**Interfaces:**
- Consumes: 全部前述任务。
- [ ] **Step 1: 服务器加 JWT_SECRET**
```bash
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: 构建前端 + 交叉编译后端**
```bash
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 release``ELF 64-bit ... x86-64`。
- [ ] **Step 3: 上传二进制 + 前端静态**
```bash
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 脚本切换二进制并重启**
```bash
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: 线上冒烟**
```bash
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_DIR``JWT_SECRET`;该功能改了后端,需全量重部署(非 frontend-only上线后用户需重新登录一次"。
- [ ] **Step 7: 提交并合回**
```bash
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}`、`FromRequestParts` 无 `async_trait`,已在 Global Constraints 与 Task 3/4 明确。