diff --git a/.deploy-staging/docker-compose-cn.yml b/.deploy-staging/docker-compose-cn.yml new file mode 100644 index 0000000..4653c0a --- /dev/null +++ b/.deploy-staging/docker-compose-cn.yml @@ -0,0 +1,114 @@ +# /opt/photo-live/deploy/docker-compose-cn.yml —— 正式环境 cn 栈(2026-09-24)。 +# 基于 infra/deploy/docker-compose.cn.yml 改造(同 ov 版偏差登记): +# ① 自管 mysql:8.4 + redis:7(1Panel MySQL 是 5.7.44 不满足基线) +# ② 独立桥网络 photo-net(与机上其它项目隔离) +# ③ web 回环端口 18088(避开 1Panel phpMyAdmin 的 8089 段) +# ④ mysql 内存调优:cn 机仅 3.5G(可用 ~1.4G),关 performance_schema、 +# buffer pool 显式 128M(另建 2G swap 兜底,见部署记录) +# cn 侧多两个角色:worker(sharp 图片流水线)+ sync(跨境推送/对账/统计回传)。 +# sync 需出网可达:OV_API_URL(https 443)与 DATABASE_URL_OV(47.254.176.10:13306, +# 安全组白名单 cn 出口 IP)。 + +name: photo-live-cn + +x-logging: &default-logging + driver: json-file + options: { max-size: "10m", max-file: "3" } + +services: + mysql: + image: mysql:8.4 + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_bin --performance-schema=OFF --innodb-buffer-pool-size=128M + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: photo_cn + MYSQL_USER: photo_cn + MYSQL_PASSWORD: ${MYSQL_APP_PASSWORD} + volumes: + - mysql-cn-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 5s + retries: 24 + restart: unless-stopped + logging: *default-logging + networks: [photo-net] + + redis: + image: redis:7-alpine + healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 12 } + restart: unless-stopped + logging: *default-logging + networks: [photo-net] + + api-cn: + image: ${SERVER_IMAGE} + command: ["api-cn"] + init: true + env_file: .env.production-cn + environment: + PORT: "8080" # 容器内固定;对外经 web 反代 + restart: unless-stopped + depends_on: + mysql: { condition: service_healthy } + redis: { condition: service_healthy } + healthcheck: + test: ["CMD", "curl", "-fs", "http://127.0.0.1:8080/v1/health"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + logging: *default-logging + networks: [photo-net] + + worker: + image: ${SERVER_IMAGE} + command: ["worker"] + init: true + env_file: .env.production-cn + # 无 HTTP 端口:健康面由 restart 策略 + 日志监控承担(RUNBOOK.md) + restart: unless-stopped + depends_on: + mysql: { condition: service_healthy } + redis: { condition: service_healthy } + logging: *default-logging + networks: [photo-net] + + sync: + image: ${SERVER_IMAGE} + command: ["sync"] + init: true + env_file: .env.production-cn + # 需出网:OV_API_URL(443) + DATABASE_URL_OV(13306,待安全组放行) + restart: unless-stopped + depends_on: + mysql: { condition: service_healthy } + redis: { condition: service_healthy } + logging: *default-logging + networks: [photo-net] + + web: + image: ${WEB_IMAGE} + init: true + environment: + API_UPSTREAM: http://api-cn:8080 # /v1 反代目标(含 WS /v1/realtime) + ROOT_REDIRECT: /admin/ # cn 根路径直达后台 + ports: + - "127.0.0.1:18088:80" # 1Panel OpenResty 反代到此(站点已建) + restart: unless-stopped + depends_on: + api-cn: { condition: service_healthy } + healthcheck: + test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1/"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s + logging: *default-logging + networks: [photo-net] + +volumes: + mysql-cn-data: + +networks: + photo-net: { driver: bridge } diff --git a/.deploy-staging/docker-compose-ov.yml b/.deploy-staging/docker-compose-ov.yml new file mode 100644 index 0000000..7e4deaa --- /dev/null +++ b/.deploy-staging/docker-compose-ov.yml @@ -0,0 +1,88 @@ +# /opt/photo-live/deploy/docker-compose-ov.yml —— 正式环境 ov 栈(2026-09-24)。 +# 基于 infra/deploy/docker-compose-ov.yml 改三点(计划 §2 T0 偏差登记): +# ① 自管 mysql:8.4 + redis:7(1Panel MySQL 是 5.7.44,不满足 8.4/utf8mb4_0900_bin 基线) +# ② 独立桥网络 photo-net(不挂 1panel-network,与机上其它项目隔离) +# ③ web-ov 回环端口 18089(8089 被 1Panel phpMyAdmin 占用) +# ④ mysql 公网映射 13306:cn 侧 sync 跨境直连读库——安全组仅放行 101.37.116.184! +# 同目录配套:.env(SERVER_IMAGE/WEB_IMAGE/MYSQL_ROOT_PASSWORD/MYSQL_APP_PASSWORD) +# 与 .env.production-ov(应用 env,模板=仓库根 .env.production.example-ov)。 + +name: photo-live-ov + +x-logging: &default-logging + driver: json-file + options: { max-size: "10m", max-file: "3" } + +services: + mysql: + image: mysql:8.4 + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_bin + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: photo_ov + MYSQL_USER: photo_ov + MYSQL_PASSWORD: ${MYSQL_APP_PASSWORD} + ports: + - "0.0.0.0:13306:3306" # 仅安全组白名单 cn IP 可达(阿里云控制台) + volumes: + - mysql-ov-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 5s + retries: 24 + restart: unless-stopped + logging: *default-logging + networks: [photo-net] + + redis: + image: redis:7-alpine + healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 12 } + restart: unless-stopped + logging: *default-logging + networks: [photo-net] + + api-ov: + image: ${SERVER_IMAGE} + command: ["api-ov"] + init: true + env_file: .env.production-ov + environment: + PORT_OV: "8081" # main-ov.ts 读 PORT_OV;容器内固定 + restart: unless-stopped + depends_on: + mysql: { condition: service_healthy } + redis: { condition: service_healthy } + healthcheck: + test: ["CMD", "curl", "-fs", "http://127.0.0.1:8081/v1/health"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + logging: *default-logging + networks: [photo-net] + + web-ov: + image: ${WEB_IMAGE} + init: true + environment: + API_UPSTREAM: http://api-ov:8081 # /v1 反代目标(含 WS /v1/realtime) + ROOT_REDIRECT: /index.html # 观众端安全自指 + ports: + - "127.0.0.1:18089:80" # 1Panel OpenResty 反代到此(站点已建) + restart: unless-stopped + depends_on: + api-ov: { condition: service_healthy } + healthcheck: + test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1/"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s + logging: *default-logging + networks: [photo-net] + +volumes: + mysql-ov-data: + +networks: + photo-net: { driver: bridge } diff --git a/.deploy-staging/env-cn b/.deploy-staging/env-cn new file mode 100644 index 0000000..63f0249 --- /dev/null +++ b/.deploy-staging/env-cn @@ -0,0 +1,4 @@ +SERVER_IMAGE=photo-live-server:30a3520-20260924 +WEB_IMAGE=photo-live-web:30a3520-20260924-cn +MYSQL_ROOT_PASSWORD=2d88f24a008eac264944141c69b61b22dfbd00aaecd133c7 +MYSQL_APP_PASSWORD=20a6f359f685b6a0ae725698d722cfc00357503d7a27ecd7 diff --git a/.deploy-staging/env-ov b/.deploy-staging/env-ov new file mode 100644 index 0000000..81b9818 --- /dev/null +++ b/.deploy-staging/env-ov @@ -0,0 +1,4 @@ +SERVER_IMAGE=photo-live-server:30a3520-20260924 +WEB_IMAGE=photo-live-web:30a3520-20260924-ov +MYSQL_ROOT_PASSWORD=127bd53921e208fbc0f9ff9c0b64a5fa71069ef0b083c617 +MYSQL_APP_PASSWORD=70e4a6edc39800bd160723cb62a9a8e6a91d4c8f4c0ce399 diff --git a/.pc-upload-test.sh b/.pc-upload-test.sh new file mode 100644 index 0000000..8afe405 --- /dev/null +++ b/.pc-upload-test.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# 一次性:PC 适配验证测试相册(真实链路上传 6 张不同宽高比测试图) +set -eu +CN=https://live-album.inside.hnxinyouhe.com +OV=https://live-album.outside.hnxinyouhe.com +IMGDIR="C:/Users/30997/AppData/Local/Temp/pc-test" +PW=$(cat /d/project/photo/.env.prod-secrets/ADMIN_PASSWORD | tr -d '\n\r') + +extract() { printf '%s' "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p" | head -1; } + +BODY=$(curl -sS $CN/v1/auth/password -H 'content-type: application/json' -d "{\"account\":\"admin\",\"password\":\"$PW\"}") +TOKEN=$(extract "$BODY" sessionToken); [ -n "$TOKEN" ] || { echo "login FAIL: $BODY"; exit 1; } +echo "login ok" + +BODY=$(curl -sS $CN/v1/albums -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"translations":[{"locale":"zh-CN","title":"PC适配验证(测试相册可隐藏)","description":"桌面端样式优化验证用,6 张不同宽高比测试图"}],"defaultLocale":"zh-CN","startAt":"2026-09-24T00:00:00.000Z"}') +ALBUM_ID=$(extract "$BODY" id); PUB=$(extract "$BODY" publicToken) +[ -n "$ALBUM_ID" ] || { echo "album FAIL: $BODY"; exit 1; } +echo "album: $ALBUM_ID publicToken: $PUB" + +i=0 +for f in "$IMGDIR"/img*.jpg; do + i=$((i+1)) + BYTES=$(stat -c %s "$f") + BODY=$(curl -sS $CN/v1/uploads -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d "{\"albumId\":\"$ALBUM_ID\",\"clientTaskId\":\"pc-test-$i\",\"filename\":\"pc-$i.jpg\",\"bytes\":$BYTES,\"contentType\":\"image/jpeg\"}") + UP=$(extract "$BODY" uploadId); URL=$(extract "$BODY" url) + [ -n "$UP" ] || { echo "init$i FAIL: $BODY"; exit 1; } + CODE=$(curl -sS -X PUT "$URL" -H 'Content-Type: image/jpeg' --data-binary @"$f" -o /dev/null -w '%{http_code}') + [ "${CODE:0:1}" = 2 ] || { echo "PUT$i FAIL: $CODE"; exit 1; } + CODE=$(curl -sS -X POST $CN/v1/uploads/$UP/complete -H "authorization: Bearer $TOKEN" -o /dev/null -w '%{http_code}') + [ "${CODE:0:1}" = 2 ] || { echo "complete$i FAIL: $CODE"; exit 1; } + echo "img$i uploaded ($BYTES B)" +done + +echo "waiting for ov visibility..." +for t in $(seq 1 30); do + N=$(curl -sS "$OV/v1/public/albums/$PUB/photos" | grep -c photoId || true) + [ "$N" -ge 6 ] && { echo "ov visible: $N photos (${t}x2s)"; break; } + sleep 2 +done +echo "VIEWER_URL=https://live-album.outside.hnxinyouhe.com/a/$PUB" diff --git a/apps/mobile/lib/pages/capture_page.dart b/apps/mobile/lib/pages/capture_page.dart index 68aa292..95eaffc 100644 --- a/apps/mobile/lib/pages/capture_page.dart +++ b/apps/mobile/lib/pages/capture_page.dart @@ -62,14 +62,16 @@ class CameraPluginSource extends CaptureSource { throw CameraException('noCamera', 'no camera available'); } CameraDescription pick() => cameras.firstWhere( - (c) => c.lensDirection == - (preferFront - ? CameraLensDirection.front - : CameraLensDirection.back), - orElse: () => cameras.first, - ); - final controller = CameraController(pick(), ResolutionPreset.max, - enableAudio: false); + (c) => + c.lensDirection == + (preferFront ? CameraLensDirection.front : CameraLensDirection.back), + orElse: () => cameras.first, + ); + final controller = CameraController( + pick(), + ResolutionPreset.max, + enableAudio: false, + ); await controller.initialize(); return CameraPluginSource._(controller); } @@ -84,15 +86,17 @@ class CameraPluginSource extends CaptureSource { @override Widget buildPreview() { - // 纵横比校正(§4 页面 13):预览按传感器尺寸布局,再 cover 铺满—— - // CameraPreview 直填父约束会把画面拉扁/拉高。 + // 纵横比校正(§4 页面 13):传感器 previewSize 报的是**横向**(width > + // height),竖屏 App 直接用它会把画面拉扁——需交换宽高转为纵向比例, + // 再 FittedBox cover 铺满(裁切不变形)。 final size = _controller.value.previewSize; Widget preview = CameraPreview(_controller); if (size != null && size.width > 0 && size.height > 0) { preview = FittedBox( fit: BoxFit.cover, clipBehavior: Clip.hardEdge, - child: SizedBox(width: size.width, height: size.height, child: preview), + // 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。 + child: SizedBox(width: size.height, height: size.width, child: preview), ); } return preview; @@ -102,9 +106,8 @@ class CameraPluginSource extends CaptureSource { bool get supportsFlash => true; @override - Future setFlashEnabled(bool enabled) => _controller.setFlashMode( - enabled ? FlashMode.always : FlashMode.off, - ); + Future setFlashEnabled(bool enabled) => + _controller.setFlashMode(enabled ? FlashMode.always : FlashMode.off); @override Future dispose() => _controller.dispose(); @@ -113,7 +116,8 @@ class CameraPluginSource extends CaptureSource { /// 拍摄源工厂 Provider(测试 override 为替身)。 final captureSourceFactoryProvider = Provider Function({bool preferFront})>( - (ref) => CameraPluginSource.create); + (ref) => CameraPluginSource.create, + ); // ── 连拍页 ──────────────────────────────────────────────────────────────── @@ -207,9 +211,11 @@ class _CapturePageState extends ConsumerState { }); } on Object catch (error) { if (!mounted) return; - setState(() => _phase = _isPermissionDenied(error) - ? _CameraPhase.denied - : _CameraPhase.failed); + setState( + () => _phase = _isPermissionDenied(error) + ? _CameraPhase.denied + : _CameraPhase.failed, + ); } } @@ -247,23 +253,25 @@ class _CapturePageState extends ConsumerState { final source = _source; if (source == null || _phase != _CameraPhase.ready) return; final categoryId = _categoryId; - _shotTail = _shotTail.then((_) async { - final photo = await source.capture(); - if (!mounted) return; - // 最近一张缩略图(拍摄原文件;缺失走占位)。 - setState(() => _lastShotPath = photo.path); - final enqueued = await enqueueForUpload( - ref, - accountId: widget.target.accountId, - albumId: widget.target.albumId, - categoryId: categoryId, - sourcePaths: [photo.path], - ); - // 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。 - if (enqueued > 0 && mounted) _flashEnqueued(); - }).catchError((Object _) { - // 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。 - }); + _shotTail = _shotTail + .then((_) async { + final photo = await source.capture(); + if (!mounted) return; + // 最近一张缩略图(拍摄原文件;缺失走占位)。 + setState(() => _lastShotPath = photo.path); + final enqueued = await enqueueForUpload( + ref, + accountId: widget.target.accountId, + albumId: widget.target.albumId, + categoryId: categoryId, + sourcePaths: [photo.path], + ); + // 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。 + if (enqueued > 0 && mounted) _flashEnqueued(); + }) + .catchError((Object _) { + // 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。 + }); } /// 入队成功反馈:触觉轻点 + 顶部短暂浮层(200ms 后自动消退)。 @@ -488,14 +496,19 @@ class _TopBar extends StatelessWidget { child: Row( children: [ IconButton( - tooltip: MaterialLocalizations.of(context) - .backButtonTooltip, // 语义标签;深色界面固定白色前景。 + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, // 语义标签;深色界面固定白色前景。 color: Colors.white, icon: const Icon(Icons.arrow_back), onPressed: onBack, ), const SizedBox(width: kSpace4), - const Icon(Icons.photo_album_outlined, size: 16, color: Colors.white70), + const Icon( + Icons.photo_album_outlined, + size: 16, + color: Colors.white70, + ), const SizedBox(width: 6), Expanded( child: Text( @@ -609,10 +622,7 @@ class _Badge extends StatelessWidget { color: color, borderRadius: BorderRadius.circular(999), ), - child: Text( - label, - style: TextStyle(color: foreground, fontSize: 12), - ), + child: Text(label, style: TextStyle(color: foreground, fontSize: 12)), ); } } @@ -711,11 +721,8 @@ class _LastShotButton extends StatelessWidget { content = Image.file( File(lastShotPath!), fit: BoxFit.cover, - errorBuilder: (_, _, _) => const Icon( - Icons.image_outlined, - size: 24, - color: Colors.white, - ), + errorBuilder: (_, _, _) => + const Icon(Icons.image_outlined, size: 24, color: Colors.white), ); } return IconButton( diff --git a/docs/designs/ui04-implementation/actual/server/photo-modal.png b/docs/designs/ui04-implementation/actual/server/photo-modal.png index ffbc98e..0a17d45 100644 Binary files a/docs/designs/ui04-implementation/actual/server/photo-modal.png and b/docs/designs/ui04-implementation/actual/server/photo-modal.png differ diff --git a/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm b/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm index d27e882..e81f24d 100644 Binary files a/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm and b/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm differ diff --git a/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm b/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm index ff89133..e104c4f 100644 Binary files a/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm and b/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm differ