various fixes

This commit is contained in:
Kevin
2026-03-23 13:21:07 +08:00
parent 9c2e0329ca
commit b9ecfd02a4
18 changed files with 393 additions and 277 deletions

View File

@@ -7,11 +7,11 @@
# - 若需兼容旧 32 位 ARM 设备,可将该属性改为 armeabi-v7a,arm64-v8a体积会增大
# - 若需用 x86 模拟器验证此 APK需改回含 x86 的 ABI 或另建 job。
#
# 环境映射(按触发源自动推断
# main → dev (开发 + 内部测试)
# v*.*.* → prod (正式发布 + GitHub Release 附带 APK)
# 环境映射(与后端 api 一致main → 预发 stagingtag → 生产 production
# push main → stage → node scripts/use-env.js staging → .env.staging
# push v*.*.* → prod → node scripts/use-env.js production → .env.production
#
# 手动触发workflow_dispatch 可选 dev / stage / prod
# 手动触发 workflow_dispatch可选 dev / stage / proddev 用 .env.development便于打内部测试包
#
# Repository secrets与 android-release.yml 共用同一套即可):
# ANDROID_KEYSTORE_BASE64 / ANDROID_STORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD
@@ -72,7 +72,8 @@ jobs:
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "env=${{ github.event.inputs.environment }}"
else
echo "env=dev"
# push 到 main本 workflow 仅监听 main 与 tag
echo "env=stage"
fi >> $GITHUB_OUTPUT
- name: Set up Node.js
@@ -86,8 +87,8 @@ jobs:
working-directory: app-expo
run: npm ci
- name: Quality checks (dev only)
if: steps.env.outputs.env == 'dev'
- name: Quality checks (non-prod)
if: steps.env.outputs.env != 'prod'
working-directory: app-expo
run: |
npm run format:check

View File

@@ -1,15 +1,15 @@
# API Dockermain → DevRepository secrets: DEV_*Tag v*.*.* → Prod 机PROD_*
# API Dockermain → StagingRepository secrets: STAGING_*Tag v*.*.* → Prod 机PROD_*
# 在 Repo → Settings → Secrets and variables → Actions 中配置,无需 GitHub Environments。
# 命名:DEV_SSH_HOST / DEV_SSH_USER / DEV_SSH_PRIVATE_KEY / DEV_SSH_PORT / DEV_DEPLOY_PATH
# 命名:STAGING_SSH_HOST / STAGING_SSH_USER / STAGING_SSH_PRIVATE_KEY / STAGING_SSH_PORT / STAGING_DEPLOY_PATH
# PROD_SSH_HOST / PROD_SSH_USER / PROD_SSH_PRIVATE_KEY / PROD_SSH_PORT / PROD_DEPLOY_PATH
# 阿里云镜像仍为仓库级ALIYUN_CR_USERNAME / ALIYUN_CR_PASSWORD
#
# 从旧版迁移:若仓库里仍是 SSH_HOST、SSH_PRIVATE_KEY、DEPLOY_PATH 等无前缀名称,
# 请把「原机 / 内部测试」对应值复制为 DEV_*,「新生产机」填 PROD_*,并删除旧的无前缀 Secret。
# 请把「预发机」对应值迁移为 STAGING_*,「新生产机」填 PROD_*,并删除旧的无前缀 Secret。
#
# 发布策略:
# - merge / push 到 main构建并部署到 Dev / 内部测试
# - 手动创建并推送 tag vMAJOR.MINOR.PATCH构建并部署到 Production
# - merge / push 到 main构建并部署到 Staging 机;使用仓库中的 api/.env.staging上传后切换为运行时 .env
# - 手动创建并推送 tag vMAJOR.MINOR.PATCH构建并部署到 Production;使用仓库中的 api/.env.production上传后切换为运行时 .env
#
# 注意paths 过滤在 tag push 时按「被指向的 commit」判断若该 commit 未改 api/ 与本 workflow不会触发。
# 此时可用 workflow_dispatch 选择对应 tag/ref 手动部署。
@@ -122,7 +122,7 @@ jobs:
if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "target=prod" >> "$GITHUB_OUTPUT"
else
echo "target=dev" >> "$GITHUB_OUTPUT"
echo "target=staging" >> "$GITHUB_OUTPUT"
fi
- name: Ensure production SSH secret is set
@@ -135,28 +135,28 @@ jobs:
exit 1
fi
- name: Ensure development SSH secret is set
- name: Ensure staging SSH secret is set
if: steps.deploy_target.outputs.target != 'prod'
env:
DEV_SSH_PRIVATE_KEY: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
STAGING_SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
run: |
if [ -z "$DEV_SSH_PRIVATE_KEY" ]; then
echo "::error::DEV_SSH_PRIVATE_KEY 未配置或为空,无法部署开发机。请在 Repository secrets 中设置 DEV_SSH_*。"
if [ -z "$STAGING_SSH_PRIVATE_KEY" ]; then
echo "::error::STAGING_SSH_PRIVATE_KEY 未配置或为空,无法部署 staging。请在 Repository secrets 中设置 STAGING_SSH_*。"
exit 1
fi
# 勿用 `prod && PROD_KEY || DEV_KEY`PROD 为空时会错误回退到 DEV 密钥,导致连生产机报 Permission denied。
# 勿用 `prod && PROD_KEY || STAGING_KEY`PROD 为空时会错误回退到 staging 密钥,导致连生产机报 Permission denied。
- name: Set up SSH (production)
if: steps.deploy_target.outputs.target == 'prod'
uses: webfactory/ssh-agent@v0.9.1
with:
ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
- name: Set up SSH (development)
- name: Set up SSH (staging)
if: steps.deploy_target.outputs.target != 'prod'
uses: webfactory/ssh-agent@v0.9.1
with:
ssh-private-key: ${{ secrets.DEV_SSH_PRIVATE_KEY }}
ssh-private-key: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
- name: Export deploy connection env
run: |
@@ -169,10 +169,10 @@ jobs:
} >> "$GITHUB_ENV"
else
{
echo "SSH_HOST=${{ secrets.DEV_SSH_HOST }}"
echo "SSH_USER=${{ secrets.DEV_SSH_USER }}"
echo "SSH_PORT=${{ secrets.DEV_SSH_PORT || '22' }}"
echo "COMPOSE_DIR=${{ secrets.DEV_DEPLOY_PATH || '/opt/life-echo' }}"
echo "SSH_HOST=${{ secrets.STAGING_SSH_HOST }}"
echo "SSH_USER=${{ secrets.STAGING_SSH_USER }}"
echo "SSH_PORT=${{ secrets.STAGING_SSH_PORT || '22' }}"
echo "COMPOSE_DIR=${{ secrets.STAGING_DEPLOY_PATH || '/opt/life-echo' }}"
} >> "$GITHUB_ENV"
fi
@@ -222,10 +222,32 @@ jobs:
docker network inspect api_life-echo-network >/dev/null 2>&1 || docker network create api_life-echo-network
"
echo "上传候选 compose、Caddyfile 与环境变量..."
if [ "${{ steps.deploy_target.outputs.target }}" = "prod" ]; then
ENV_SRC="api/.env.production"
else
ENV_SRC="api/.env.staging"
fi
if [ ! -f "$ENV_SRC" ]; then
echo "::error::缺少 $ENV_SRC无法部署。"
exit 1
fi
if grep -Eq '=(your_|replace_with_|\\.{3}$)' "$ENV_SRC"; then
echo "::error::$ENV_SRC 仍包含占位符值,请先完善环境文件。"
exit 1
fi
if grep -Eq '^DATABASE_URL=.*@localhost:' "$ENV_SRC" || grep -Eq '^REDIS_URL=redis://localhost' "$ENV_SRC"; then
echo "::error::$ENV_SRC 包含 localhost 数据库或 Redis 地址,容器内将无法连接。"
exit 1
fi
if grep -Eq '^DATABASE_URL=.*@postgresql:' "$ENV_SRC"; then
echo "::error::$ENV_SRC 仍引用过期主机名 postgresql当前 compose 服务名应为 postgres。"
exit 1
fi
echo "上传候选 compose、Caddyfile 与环境文件..."
scp -P "$SSH_PORT" ./api/docker-compose.yml "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/docker-compose.candidate.yml"
scp -P "$SSH_PORT" ./api/Caddyfile "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/Caddyfile.candidate"
scp -P "$SSH_PORT" ./api/.env.production "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/.env.production.candidate"
scp -P "$SSH_PORT" "$ENV_SRC" "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/.env.candidate"
ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" "
set -euo pipefail
@@ -255,9 +277,12 @@ jobs:
if [ -f '.env.production' ]; then
cp '.env.production' '.env.production.predeploy'
fi
if [ -f '.env' ]; then
cp '.env' '.env.predeploy'
fi
mv 'docker-compose.candidate.yml' '$COMPOSE_FILE'
mv 'Caddyfile.candidate' 'Caddyfile'
mv '.env.production.candidate' '.env.production'
mv '.env.candidate' '.env'
docker-compose -f '$COMPOSE_FILE' up -d --remove-orphans
echo '等待服务启动...'
sleep 20

4
.gitignore vendored
View File

@@ -14,6 +14,10 @@ ENV/
.env
.env.*
!.env.example
!api/.env.staging
!api/.env.production
!app-expo/.env.staging
!app-expo/.env.production
# 证书与私钥勿提交README 可提交)
api/certs/*.pem

View File

@@ -31,21 +31,7 @@ life-echo/
│ ├── main.py # FastAPI 应用入口
│ └── README.md # API 服务文档
├── app-android/ # Android 应用
│ └── app/
│ └── src/main/java/com/huaga/life_echo/
│ ├── data/ # 数据层Room、Repository
│ ├── network/ # 网络层WebSocket、REST API
│ ├── ui/ # UI 层Jetpack Compose
│ ├── feature/ # 功能模块(语音录制等)
│ └── navigation/ # 导航管理
│ └── README.md # Android 应用文档
└── docs/ # 项目文档
├── 开发计划.md # 开发计划与任务分解
├── 数据库设计.md # 数据库设计文档
├── AGENT.md # Agent 设计文档
└── v1_features_*.md # 功能需求文档
```
## 🚀 快速开始
@@ -57,11 +43,6 @@ life-echo/
- Docker & Docker Compose用于 PostgreSQL、Redis
- LLM API KeyDeepSeek 或兼容 OpenAI 的服务)
- **Android 开发**
- Android Studio Hedgehog | 2023.1.1+
- JDK 11+
- Android SDKAPI 24+
### 后端服务启动
详细步骤请参考 [api/README.md](api/README.md)
@@ -103,25 +84,6 @@ uv run celery -A tasks.celery_app worker --loglevel=info --pool=solo
常用命令详见 `api/README.md`
### Android 应用启动
详细步骤请参考 [app-android/README.md](app-android/README.md)
```bash
# 1. 使用 Android Studio 打开项目
# File -> Open -> 选择 app-android 目录
# 2. 同步 Gradle 依赖
# Android Studio 会自动同步,或点击 Sync Now
# 3. 配置 API 地址(开发环境)
# 编辑 app/src/main/java/com/huaga/life_echo/config/AppConfig.kt
# 修改 BASE_URL 和 WS_BASE_URL
# 4. 运行应用
# 点击 Run 按钮或使用快捷键 Shift+F10
```
## 🛠️ 技术栈
### 后端技术栈
@@ -138,37 +100,8 @@ uv run celery -A tasks.celery_app worker --loglevel=info --pool=solo
| DeepSeek API | - | LLM大语言模型 |
| ReportLab + WeasyPrint | - | PDF 生成 |
### Android 技术栈
| 技术 | 版本 | 用途 |
|------|------|------|
| Kotlin | 1.9+ | 编程语言 |
| Jetpack Compose | - | 声明式 UI 框架 |
| Ktor | - | HTTP 客户端和 WebSocket |
| Room | - | 本地数据库SQLite |
| Coroutines + Flow | - | 异步编程和响应式数据流 |
| DataStore | - | 键值对存储Token 管理) |
| Coil | - | 图片加载库 |
## 📚 文档导航
### 项目文档
- [开发计划](docs/开发计划.md) - 项目开发计划与任务分解
- [数据库设计](docs/数据库设计.md) - 数据库表结构设计
- [Agent 设计](docs/AGENT.md) - AI Agent 工作流程设计
### API 文档
- [API README](api/README.md) - API 服务完整文档
- [本地开发环境配置](api/docs/本地开发环境配置.md) - 开发环境搭建指南
- [WebSocket 快速测试指南](api/docs/WebSocket快速测试指南.md) - WebSocket 测试文档
- [WebSocket 测试文档](api/docs/WebSocket测试文档.md) - WebSocket 详细测试说明
### Android 文档
- [Android README](app-android/README.md) - Android 应用完整文档
## 🔐 认证系统
系统使用 JWTJSON Web Token进行认证采用访问令牌Access Token+ 刷新令牌Refresh Token机制
@@ -202,26 +135,6 @@ uv run celery -A tasks.celery_app worker --loglevel=info --pool=solo
- 🔄 章节编辑功能
- 🔄 多语言支持
## 📝 开发指南
### 添加新的 API 路由
1.`api/routers/` 目录创建新的路由文件
2. 定义路由函数
3.`api/main.py` 中注册路由
### 添加新的数据库模型
1.`api/database/models.py` 中定义模型类
2. 继承 `Base`
3. 运行数据库迁移
### 添加新的 Android 功能
1.`app-android/app/src/main/java/com/huaga/life_echo/` 下创建功能模块
2. 遵循 MVVM 架构模式
3. 使用 Compose 构建 UI
## 🔒 安全注意事项
1. **环境变量安全**:确保 `.env` 文件不被提交到版本控制
@@ -230,8 +143,6 @@ uv run celery -A tasks.celery_app worker --loglevel=info --pool=solo
4. **API Key 安全**:妥善保管 LLM API Key
5. **密码安全**:密码使用 bcrypt 哈希存储
详细安全建议请参考 [api/README.md#安全注意事项](api/README.md#安全注意事项)
## 🤝 贡献指南
1. Fork 本项目

View File

@@ -1,7 +1,10 @@
# =============================================================================
# Life Echo API environment example
# 复制为 .env 或 .env.production 后按实际环境修改
# 不要把真实密钥提交到仓库
# Life Echo API — 模板(example
#
# 本地:复制为 .env.development勿提交密钥再运行 api/development.sh 会在首次自动生成 .env
# .env.development 复制Settings 只读 .env见 app/core/config.py
# 服务端:仓库维护 .env.staging / .env.productionworkflow 按目标环境上传并复制为运行时 .envcompose 的 env_file 统一指向 .env。
# 不要把真实密钥提交到仓库。
# =============================================================================
# =============================================================================
@@ -19,18 +22,18 @@ DEEPSEEK_MODEL=deepseek-chat
# =============================================================================
# Database
# =============================================================================
# 本地开发示例:
# 本地开发:
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo
# Docker / production 示例:
# Docker / 服务端(主机名一般为 compose 服务名 postgres:
# DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo
# =============================================================================
# Redis
# =============================================================================
# 本地开发示例:
# 本地开发:
# REDIS_URL=redis://localhost:6379/0
# Docker / production 示例:
# Docker / 服务端:
# REDIS_URL=redis://redis:6379/0
REDIS_URL=redis://localhost:6379/0
REDIS_SESSION_TTL=86400
@@ -44,42 +47,52 @@ ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=120
# =============================================================================
# Tencent SMS
# Tencent Cloud — 短信
# =============================================================================
# 短信、一句话 ASR/TTS、COS 为不同产品;同一主账号可共用同一对 SecretId/SecretKey分别填三处
TENCENT_SMS_SECRET_ID=your_tencent_sms_secret_id
TENCENT_SMS_SECRET_KEY=your_tencent_sms_secret_key
# 短信应用 SDK AppID
TENCENT_SMS_SDK_APP_ID=your_sms_sdk_app_id
# 短信签名内容(不包含【】符号)
TENCENT_SMS_SIGN_NAME=your_sms_sign_name
# 短信模板 ID
TENCENT_SMS_TEMPLATE_ID=your_sms_template_id
# 短信模板参数数量1=仅验证码2=验证码+过期时间)
# 若遇 TemplateParamSetNotMatchApprovedTemplate请对照控制台模板配置
TENCENT_SMS_TEMPLATE_PARAM_COUNT=1
# =============================================================================
# ASR Provider
# whisper | tencent
# ASR Providerwhisper | tencent
# =============================================================================
ASR_PROVIDER=whisper
# Whisper ASR
# =============================================================================
# Whisper ASRASR_PROVIDER=whisper 时使用)
# =============================================================================
ASR_MODEL_SIZE=small
ASR_DEVICE=cpu
ASR_COMPUTE_TYPE=int8
# Tencent ASR
# 仅在 ASR_PROVIDER=tencent 时需要
TENCENT_SECRET_ID=your_tencent_asr_secret_id
TENCENT_SECRET_KEY=your_tencent_asr_secret_key
# TENCENT_ASR_APP_ID=
# GPU 环境(示例,按需启用)
# ASR_MODEL_SIZE=medium
# ASR_DEVICE=cuda
# ASR_COMPUTE_TYPE=float16
# =============================================================================
# TTS文字转语音Agent 回复播音)— 与上方 ASR 完全独立
# Tencent Cloud — 一句话 ASR + TTSASR_PROVIDER=tencent 或 TTS_PROVIDER=tencent
# =============================================================================
TENCENT_SECRET_ID=your_tencent_asr_secret_id
TENCENT_SECRET_KEY=your_tencent_asr_secret_key
# =============================================================================
# TTS文字转语音Agent 回复播音)— 与 ASR 独立
# =============================================================================
# ENABLE_TTS仅控制是否合成并下发 TTS_AUDIO不影响用户语音转写ASR
# false / 0 / no 关闭语音合成,不调 TTS 厂商 API
ENABLE_TTS=true
TTS_PROVIDER=tencent
# 仅 TTS_PROVIDER=openai 时需要
# OPENAI_API_KEY=your_openai_api_key
# 仅 TTS_PROVIDER=tencent 时生效;密钥变量名可与 ASR 相同,但开关与流程互不关联
# 音色 ID 见 https://cloud.tencent.com/document/product/1073/92668
TTS_VOICE_TYPE=502001
TTS_CODEC=mp3
@@ -90,10 +103,13 @@ TTS_CODEC=mp3
WECHAT_PAY_APP_ID=your_wechat_pay_app_id
WECHAT_PAY_MCH_ID=your_wechat_mch_id
WECHAT_PAY_API_V3_KEY=your_wechat_api_v3_key
# 商户私钥:推荐使用文件路径,避免 .env 中长 PEM 转义问题
WECHAT_PAY_PRIVATE_KEY_PATH=certs/apiclient_key.pem
# 若不用文件,可配置 WECHAT_PAY_PRIVATE_KEYPEM换行用 \n
# WECHAT_PAY_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
WECHAT_PAY_CERT_SERIAL_NO=your_wechat_cert_serial_no
WECHAT_PAY_NOTIFY_URL=https://your-domain.com/api/payment/notify/wechat
# 可选平台公钥模式
# 平台公钥模式(仅当无法走平台证书自动拉取时使用);勿填商户私钥路径
# WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH=certs/wechat_platform_public_key.pem
# WECHAT_PAY_PLATFORM_PUBLIC_KEY_ID=your_wechat_platform_public_key_id
@@ -111,19 +127,15 @@ ALIPAY_NOTIFY_URL=https://your-domain.com/api/payment/notify/alipay
ENABLE_TEST_SUBSCRIPTION=0
# =============================================================================
# Memoir image generation
# Memoir image generationStory 主图等;轮询 Liblib 任务)
# =============================================================================
MEMOIR_IMAGE_ENABLED=false
MEMOIR_IMAGE_MAX_PER_CHAPTER=2
MEMOIR_IMAGE_POLL_INTERVAL=3
MEMOIR_IMAGE_MAX_ATTEMPTS=20
# 正文越长,可允许更多图片
MEMOIR_IMAGE_CHARS_PER_EXTRA=1500
MEMOIR_IMAGE_MAX_CAP=8
MEMOIR_IMAGE_PROVIDER=liblib
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
# 可选,限制 Liblib 下载域名
# 可选Liblib 返回图片域名不在默认白名单时(逗号分隔)
# MEMOIR_IMAGE_DOWNLOAD_HOSTS=liblib.cloud,liblibai.cloud
# =============================================================================
@@ -135,13 +147,12 @@ LIBLIB_BASE_URL=https://openapi.liblibai.cloud
LIBLIB_TEMPLATE_UUID=your_liblib_template_uuid
# =============================================================================
# Tencent COS for memoir images
# 生产环境请确认这里使用的是生产 bucket而不是开发 bucket
# Tencent Cloud — COS回忆录图片存储
# =============================================================================
TENCENT_COS_SECRET_ID=your_tencent_cos_secret_id
TENCENT_COS_SECRET_KEY=your_tencent_cos_secret_key
TENCENT_COS_REGION=ap-shanghai
TENCENT_COS_BUCKET=your_production_bucket
TENCENT_COS_BASE_URL=https://your_production_bucket.cos.ap-shanghai.myqcloud.com
TENCENT_COS_BUCKET=your_bucket_name
TENCENT_COS_BASE_URL=https://your_bucket_name.cos.ap-shanghai.myqcloud.com
# 可选临时凭证
# TENCENT_COS_TOKEN=

View File

@@ -1,7 +1,9 @@
# DeepSeek API 配置(推荐,优先使用)
DEEPSEEK_API_KEY=sk-09f17fb61c5a4299a3afc2a01de7af75
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
# =============================================================================
# Life Echo API — production生产
#
# 仓库维护本文件production 发布时 workflow 会上传并复制为运行时 .env。
# 若仓库可被非授权人员访问,请不要在此文件中保留真实密钥。
# =============================================================================
# =============================================================================
# Loggingloguru sink 最低级别TRACE / DEBUG / INFO / WARNING / ERROR / CRITICAL
@@ -9,37 +11,43 @@ DEEPSEEK_MODEL=deepseek-chat
LOG_LEVEL=INFO
# =============================================================================
# 数据库配置(必需)
# LLM / DeepSeek
# =============================================================================
# PostgreSQL 数据库连接 URL
# 格式: postgresql://用户名:密码@主机:端口/数据库名
DATABASE_URL=postgresql://postgres:postgres@postgresql:5432/life_echo
DEEPSEEK_API_KEY=sk-09f17fb61c5a4299a3afc2a01de7af75
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
# =============================================================================
# Redis 配置(必需)
# Database
# =============================================================================
# Redis 连接 URL
# 格式: redis://[:密码@]主机:端口[/数据库编号]
# 本地开发:
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo
# Docker / 服务端(主机名一般为 compose 服务名 postgres:
# DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
# =============================================================================
# Redis
# =============================================================================
# 本地开发:
# REDIS_URL=redis://localhost:6379/0
# Docker / 服务端:
# REDIS_URL=redis://redis:6379/0
REDIS_URL=redis://redis:6379/0
# Redis 会话过期时间(可选,默认: 86400 秒,即 24 小时)
REDIS_SESSION_TTL=86400
# =============================================================================
# 认证配置(必需)
# Auth
# =============================================================================
# JWT 签名密钥(必需)
# 建议使用随机生成的强密钥,例如: openssl rand -hex 32
# 生产环境必须更换为强随机字符串
# 建议使用: openssl rand -hex 32
SECRET_KEY=cf47555c7ecbe5ddb7fd2113c59e08a8bcb110810c42f7c644e06a5acc898608
# JWT 算法(可选,默认: HS256
ALGORITHM=HS256
# 访问令牌过期时间(可选,默认: 120 分钟,即 2 小时)
ACCESS_TOKEN_EXPIRE_MINUTES=120
# =============================================================================
# 腾讯云短信服务配置(必需)
# Tencent Cloud — 短信
# =============================================================================
# 腾讯云 SecretIdSecretKey
# 短信、一句话 ASR/TTS、COS 为不同产品;同一主账号可共用同一对 SecretId/SecretKey(分别填三处)。
TENCENT_SMS_SECRET_ID=AKIDa2ILCwUr56uVt31oU0JOHxPfGhvvkLiq
TENCENT_SMS_SECRET_KEY=xiFbjlZ9XheS2NWYLvHRPAh2A5nGYcR2
# 短信应用 SDK AppID
@@ -49,86 +57,100 @@ TENCENT_SMS_SIGN_NAME=上海华嘎科技有限公司
# 短信模板 ID
TENCENT_SMS_TEMPLATE_ID=2592163
# 短信模板参数数量1=仅验证码2=验证码+过期时间)
# 如果遇到 TemplateParamSetNotMatchApprovedTemplate 错误,请检查腾讯云控制台中的模板配置
# 并根据实际模板参数数量设置此值
# 若遇 TemplateParamSetNotMatchApprovedTemplate,请对照控制台模板配置
TENCENT_SMS_TEMPLATE_PARAM_COUNT=1
# =============================================================================
# ASR Provider 选择
# ASR Providerwhisper | tencent
# =============================================================================
# ASR Provider: whisper默认本地 faster-whisper| tencent腾讯云一句话识别
ASR_PROVIDER=tencent
# =============================================================================
# Whisper ASR 配置ASR_PROVIDER=whisper 时使用)
# Whisper ASRASR_PROVIDER=whisper 时使用)
# =============================================================================
# CPU 环境(推荐 small + int8
ASR_MODEL_SIZE=small
ASR_DEVICE=cpu
ASR_COMPUTE_TYPE=int8
# GPU 环境(推荐 medium + float16
# GPU 环境(示例,按需启用
# ASR_MODEL_SIZE=medium
# ASR_DEVICE=cuda
# ASR_COMPUTE_TYPE=float16
# =============================================================================
# 腾讯云 ASR 配置ASR_PROVIDER=tencent 时使用
# Tencent Cloud — 一句话 ASR + TTSASR_PROVIDER=tencent 或 TTS_PROVIDER=tencent
# =============================================================================
# 腾讯云 API 密钥(与短信服务共用,或单独配置语音服务专用密钥)
TENCENT_SECRET_ID=AKIDa2ILCwUr56uVt31oU0JOHxPfGhvvkLiq
TENCENT_SECRET_KEY=xiFbjlZ9XheS2NWYLvHRPAh2A5nGYcR2
# 腾讯云 ASR 应用 ID可选
# TENCENT_ASR_APP_ID=
# =============================================================================
# TTS文字转语音Agent 回复播音)— 与 ASR 独立
# =============================================================================
# ENABLE_TTS仅控制是否合成并下发 TTS_AUDIO不影响用户语音转写ASR
ENABLE_TTS=true
TTS_PROVIDER=tencent
# 仅 TTS_PROVIDER=openai 时需要
# OPENAI_API_KEY=your_openai_api_key
# 音色 ID 见 https://cloud.tencent.com/document/product/1073/92668
TTS_VOICE_TYPE=502001
TTS_CODEC=mp3
# =============================================================================
# WeChat Pay
# =============================================================================
WECHAT_PAY_APP_ID=wx1df508452e06cfb8
WECHAT_PAY_MCH_ID=1662979099
WECHAT_PAY_API_V3_KEY=xjvGSJLGJAJfjgskfjslafjsajsdjals
# 商户私钥:推荐使用文件路径,避免 .env 中长 PEM 转义/换行导致 Invalid private key
# 商户私钥:推荐使用文件路径,避免 .env 中长 PEM 转义问题
WECHAT_PAY_PRIVATE_KEY_PATH=certs/apiclient_key.pem
# 若不用文件,可改为配置 WECHAT_PAY_PRIVATE_KEY完整 PEM换行用 \n
# 若不用文件,可配置 WECHAT_PAY_PRIVATE_KEYPEM换行用 \n
# WECHAT_PAY_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
WECHAT_PAY_CERT_SERIAL_NO=1AA82328AC1456C6F115B014606F22CD621D2032
WECHAT_PAY_NOTIFY_URL=https://lifecho.worldsplats.com/api/payment/notify/wechat
# 平台公钥模式(仅当无法访问 api.mch.weixin.qq.com 时使用)
# 注意:必须填「微信支付平台公钥」的路径/内容,不能填商户私钥 apiclient_key.pem否则会报 MismatchedTags PUBLIC KEY / PRIVATE KEY
# 从商户平台「API安全」→「平台证书」获取微信平台公钥保存为 PEM 后配置下面两项;否则留空走「证书模式」自动拉取。
# 平台公钥模式(仅当无法走平台证书自动拉取时使用);勿填商户私钥路径
# WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH=certs/wechat_platform_public_key.pem
# WECHAT_PAY_PLATFORM_PUBLIC_KEY_ID=PUB_KEY_ID_0116629790992026020700181671002400
# =============================================================================
# Alipay
# =============================================================================
ALIPAY_APP_ID=...
ALIPAY_PRIVATE_KEY=...
ALIPAY_PUBLIC_KEY=...
ALIPAY_NOTIFY_URL=https://lifecho.worldsplats.com/api/payment/notify/alipay
# =============================================================================
# Misc
# =============================================================================
ENABLE_TEST_SUBSCRIPTION=1
# =============================================================================
# 回忆录图片生成配置
# Memoir image generationStory 主图等;轮询 Liblib 任务)
# =============================================================================
# 总开关true 启用,不设置则关闭)
MEMOIR_IMAGE_ENABLED=true
# 每章最多生成图片数
MEMOIR_IMAGE_MAX_PER_CHAPTER=2
# 轮询间隔(秒)和最大尝试次数
MEMOIR_IMAGE_POLL_INTERVAL=3
MEMOIR_IMAGE_MAX_ATTEMPTS=20
MEMOIR_IMAGE_PROVIDER=liblib
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
# 可选Liblib 返回图片域名不在默认白名单时(逗号分隔)
# MEMOIR_IMAGE_DOWNLOAD_HOSTS=
# =============================================================================
# Liblib 图片生成 APIHMAC-SHA1 签名认证)
# Liblib image provider
# =============================================================================
LIBLIB_ACCESS_KEY=zrDp6quCOKlLwcewOEfrog
LIBLIB_SECRET_KEY=iTVHo5Nf3KA-xpC1Mja80bC93u6chJem
LIBLIB_BASE_URL=https://openapi.liblibai.cloud
# 星流 Star-3 Alpha 文生图模板(默认值,可按需切换为其他模型)
LIBLIB_TEMPLATE_UUID=5d7e67009b344550bc1aa6ccbfa1d7f4
# =============================================================================
# 腾讯云 COS回忆录图片存储
# Tencent Cloud — COS回忆录图片存储
# =============================================================================
TENCENT_COS_SECRET_ID=AKIDa2ILCwUr56uVt31oU0JOHxPfGhvvkLiq
TENCENT_COS_SECRET_KEY=xiFbjlZ9XheS2NWYLvHRPAh2A5nGYcR2
TENCENT_COS_REGION=ap-shanghai
# 要把bucket改成生产环境的bucket
TENCENT_COS_BUCKET=life-echo-prod-1319381411
TENCENT_COS_BASE_URL=https://life-echo-prod-1319381411.cos.ap-shanghai.myqcloud.com
TENCENT_COS_BASE_URL=https://life-echo-prod-1319381411.cos.ap-shanghai.myqcloud.com
# 可选临时凭证
# TENCENT_COS_TOKEN=

156
api/.env.staging Normal file
View File

@@ -0,0 +1,156 @@
# =============================================================================
# Life Echo API — staging预发
#
# 仓库维护本文件staging 发布时 workflow 会上传并复制为运行时 .env。
# 不要把生产密钥误填进本文件。
# =============================================================================
# =============================================================================
# Loggingloguru sink 最低级别TRACE / DEBUG / INFO / WARNING / ERROR / CRITICAL
# =============================================================================
LOG_LEVEL=INFO
# =============================================================================
# LLM / DeepSeek
# =============================================================================
DEEPSEEK_API_KEY=your_deepseek_api_key
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
# =============================================================================
# Database
# =============================================================================
# 本地开发:
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo
# Docker / 服务端(主机名一般为 compose 服务名 postgres:
# DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
# =============================================================================
# Redis
# =============================================================================
# 本地开发:
# REDIS_URL=redis://localhost:6379/0
# Docker / 服务端:
# REDIS_URL=redis://redis:6379/0
REDIS_URL=redis://redis:6379/0
REDIS_SESSION_TTL=86400
# =============================================================================
# Auth
# =============================================================================
# 建议使用: openssl rand -hex 32
SECRET_KEY=replace_with_a_strong_random_secret
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=120
# =============================================================================
# Tencent Cloud — 短信
# =============================================================================
# 短信、一句话 ASR/TTS、COS 为不同产品;同一主账号可共用同一对 SecretId/SecretKey分别填三处
TENCENT_SMS_SECRET_ID=your_tencent_sms_secret_id
TENCENT_SMS_SECRET_KEY=your_tencent_sms_secret_key
# 短信应用 SDK AppID
TENCENT_SMS_SDK_APP_ID=your_sms_sdk_app_id
# 短信签名内容(不包含【】符号)
TENCENT_SMS_SIGN_NAME=your_sms_sign_name
# 短信模板 ID
TENCENT_SMS_TEMPLATE_ID=your_sms_template_id
# 短信模板参数数量1=仅验证码2=验证码+过期时间)
# 若遇 TemplateParamSetNotMatchApprovedTemplate请对照控制台模板配置
TENCENT_SMS_TEMPLATE_PARAM_COUNT=1
# =============================================================================
# ASR Providerwhisper | tencent
# =============================================================================
ASR_PROVIDER=whisper
# =============================================================================
# Whisper ASRASR_PROVIDER=whisper 时使用)
# =============================================================================
ASR_MODEL_SIZE=small
ASR_DEVICE=cpu
ASR_COMPUTE_TYPE=int8
# GPU 环境(示例,按需启用)
# ASR_MODEL_SIZE=medium
# ASR_DEVICE=cuda
# ASR_COMPUTE_TYPE=float16
# =============================================================================
# Tencent Cloud — 一句话 ASR + TTSASR_PROVIDER=tencent 或 TTS_PROVIDER=tencent
# =============================================================================
TENCENT_SECRET_ID=your_tencent_asr_secret_id
TENCENT_SECRET_KEY=your_tencent_asr_secret_key
# =============================================================================
# TTS文字转语音Agent 回复播音)— 与 ASR 独立
# =============================================================================
# ENABLE_TTS仅控制是否合成并下发 TTS_AUDIO不影响用户语音转写ASR
ENABLE_TTS=true
TTS_PROVIDER=tencent
# 仅 TTS_PROVIDER=openai 时需要
# OPENAI_API_KEY=your_openai_api_key
# 音色 ID 见 https://cloud.tencent.com/document/product/1073/92668
TTS_VOICE_TYPE=502001
TTS_CODEC=mp3
# =============================================================================
# WeChat Pay
# =============================================================================
WECHAT_PAY_APP_ID=your_wechat_pay_app_id
WECHAT_PAY_MCH_ID=your_wechat_mch_id
WECHAT_PAY_API_V3_KEY=your_wechat_api_v3_key
# 商户私钥:推荐使用文件路径,避免 .env 中长 PEM 转义问题
WECHAT_PAY_PRIVATE_KEY_PATH=certs/apiclient_key.pem
# 若不用文件,可配置 WECHAT_PAY_PRIVATE_KEYPEM换行用 \n
# WECHAT_PAY_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
WECHAT_PAY_CERT_SERIAL_NO=your_wechat_cert_serial_no
WECHAT_PAY_NOTIFY_URL=https://your-domain.com/api/payment/notify/wechat
# 平台公钥模式(仅当无法走平台证书自动拉取时使用);勿填商户私钥路径
# WECHAT_PAY_PLATFORM_PUBLIC_KEY_PATH=certs/wechat_platform_public_key.pem
# WECHAT_PAY_PLATFORM_PUBLIC_KEY_ID=your_wechat_platform_public_key_id
# =============================================================================
# Alipay
# =============================================================================
ALIPAY_APP_ID=your_alipay_app_id
ALIPAY_PRIVATE_KEY=your_alipay_private_key
ALIPAY_PUBLIC_KEY=your_alipay_public_key
ALIPAY_NOTIFY_URL=https://your-domain.com/api/payment/notify/alipay
# =============================================================================
# Misc
# =============================================================================
ENABLE_TEST_SUBSCRIPTION=0
# =============================================================================
# Memoir image generationStory 主图等;轮询 Liblib 任务)
# =============================================================================
MEMOIR_IMAGE_ENABLED=false
MEMOIR_IMAGE_POLL_INTERVAL=3
MEMOIR_IMAGE_MAX_ATTEMPTS=20
MEMOIR_IMAGE_PROVIDER=liblib
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
# 可选Liblib 返回图片域名不在默认白名单时(逗号分隔)
# MEMOIR_IMAGE_DOWNLOAD_HOSTS=liblib.cloud,liblibai.cloud
# =============================================================================
# Liblib image provider
# =============================================================================
LIBLIB_ACCESS_KEY=your_liblib_access_key
LIBLIB_SECRET_KEY=your_liblib_secret_key
LIBLIB_BASE_URL=https://openapi.liblibai.cloud
LIBLIB_TEMPLATE_UUID=your_liblib_template_uuid
# =============================================================================
# Tencent Cloud — COS回忆录图片存储
# =============================================================================
TENCENT_COS_SECRET_ID=your_tencent_cos_secret_id
TENCENT_COS_SECRET_KEY=your_tencent_cos_secret_key
TENCENT_COS_REGION=ap-shanghai
TENCENT_COS_BUCKET=your_bucket_name
TENCENT_COS_BASE_URL=https://your_bucket_name.cos.ap-shanghai.myqcloud.com
# 可选临时凭证
# TENCENT_COS_TOKEN=

View File

@@ -30,9 +30,6 @@ COPY . .
ARG ASR_MODEL_SIZE=small
RUN uv run python -c "from faster_whisper import WhisperModel; WhisperModel('${ASR_MODEL_SIZE}', device='cpu', compute_type='int8', download_root='/app/models/whisper')"
# 复制生产环境配置
COPY .env.production ./.env
# 非 root 用户
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

View File

@@ -1,6 +1,10 @@
"""
统一配置:所有环境变量通过此模块的 Settings 单点读取。
业务代码只允许 import settings禁止散落 os.getenv() / load_dotenv()。
本地开发时由 api/development.sh 在启动前将 .env.development 复制为 .env若尚无 .env
Docker / 服务端由镜像与 compose 注入进程环境;此处仅固定读取工作目录下的 .env 作为默认值来源。
进程环境变量(容器 environment、export覆盖 .env 同名项。
"""
import secrets
@@ -54,10 +58,9 @@ class Settings(BaseSettings):
tencent_sms_template_id: str = ""
tencent_sms_template_param_count: int = 2
# ── Tencent ASR ──────────────────────────────────────────
# ── Tencent ASR / TTS共用 Secret与短信、COS 密钥独立)────────────────
tencent_secret_id: str = ""
tencent_secret_key: str = ""
tencent_asr_app_id: str = ""
# ── TTS (openai | tencent),与 ASR 独立:仅控制回复侧语音合成 ──
enable_tts: bool = True
@@ -94,15 +97,11 @@ class Settings(BaseSettings):
enable_test_subscription: int = 0
enable_test_plan: str = "" # "1" / "true" / "yes" 为 True
enable_docs: bool = True
migration_database_url: str = "" # 脚本迁移用,空则用 database_url
# ── Memoir Image ─────────────────────────────────────────
memoir_image_enabled: bool = False
memoir_image_max_per_chapter: int = 2
memoir_image_poll_interval: int = 3
memoir_image_max_attempts: int = 20
memoir_image_chars_per_extra: int = 1500
memoir_image_max_cap: int = 8
memoir_image_provider: str = "liblib"
memoir_image_style_default: str = "watercolor"
memoir_image_size_default: str = "1280x720"

View File

@@ -5,9 +5,6 @@ if TYPE_CHECKING:
from app.core.config import Settings
DEFAULT_LIBLIB_TEMPLATE_UUID = "5d7e67009b344550bc1aa6ccbfa1d7f4"
DEFAULT_MAX_IMAGES_PER_CHAPTER = 2
DEFAULT_CHARS_PER_EXTRA_IMAGE = 1500
DEFAULT_MAX_IMAGES_CAP = 8
DEFAULT_IMAGE_PROVIDER = "liblib"
DEFAULT_IMAGE_STYLE = "watercolor"
DEFAULT_IMAGE_SIZE = "1280x720"
@@ -18,9 +15,6 @@ DEFAULT_MAX_ATTEMPTS = 60
@dataclass(frozen=True)
class MemoirImageSettings:
enabled: bool = False
max_per_chapter: int = DEFAULT_MAX_IMAGES_PER_CHAPTER
chars_per_extra_image: int = DEFAULT_CHARS_PER_EXTRA_IMAGE
max_images_cap: int = DEFAULT_MAX_IMAGES_CAP
provider: str = DEFAULT_IMAGE_PROVIDER
default_style: str = DEFAULT_IMAGE_STYLE
default_size: str = DEFAULT_IMAGE_SIZE
@@ -33,9 +27,6 @@ class MemoirImageSettings:
s = settings
return cls(
enabled=bool(s.memoir_image_enabled),
max_per_chapter=s.memoir_image_max_per_chapter,
chars_per_extra_image=s.memoir_image_chars_per_extra,
max_images_cap=s.memoir_image_max_cap,
provider=s.memoir_image_provider or DEFAULT_IMAGE_PROVIDER,
default_style=s.memoir_image_style_default or DEFAULT_IMAGE_STYLE,
default_size=s.memoir_image_size_default or DEFAULT_IMAGE_SIZE,
@@ -49,15 +40,3 @@ class MemoirImageSettings:
from app.core.config import settings as _s
return cls.from_settings(_s)
def effective_max_images(self, content_length: int) -> int:
"""根据正文字数动态计算单章允许的最大图片数。"""
base_max = max(self.max_per_chapter, 0)
effective_cap = max(self.max_images_cap, base_max)
safe_length = max(content_length, 0)
extra = (
safe_length // self.chars_per_extra_image
if self.chars_per_extra_image > 0
else 0
)
return min(base_max + extra, effective_cap)

View File

@@ -181,34 +181,6 @@ def _memoir_image_from_asset(
)
def _select_placeholders_for_effective_max(
placeholders: list[dict],
existing_images: list[dict] | None,
effective_max: int,
) -> list[dict]:
existing_placeholders = {
item.get("placeholder")
for item in normalize_image_assets(existing_images)
if item.get("placeholder")
}
existing_count_in_content = sum(
1 for item in placeholders if item.get("placeholder") in existing_placeholders
)
remaining_new_slots = max(0, effective_max - existing_count_in_content)
selected: list[dict] = []
for item in placeholders:
if item.get("placeholder") in existing_placeholders:
selected.append(item)
continue
if remaining_new_slots <= 0:
continue
selected.append(item)
remaining_new_slots -= 1
return [{**item, "index": index} for index, item in enumerate(selected)]
def _coerce_state(model: MemoirState) -> MemoirStateSchema:
"""将数据库模型转换为 Schema"""
return MemoirStateSchema.model_validate(

View File

@@ -180,11 +180,27 @@ ensure_venv() {
fi
}
# 本地约定:仓库维护 .env.development一键启动时复制为 .env供 pydantic Settings(env_file=".env") 读取。
# 若已存在 .env 则不覆盖(便于你本地覆盖);需要与模板同步时可删除 .env 后重新运行本脚本。
ensure_dotenv_from_development() {
print_header "准备本地 .env"
if [[ -f "${ROOT_DIR}/.env" ]]; then
print_ok "已存在 .env未覆盖"
return 0
fi
if [[ -f "${ROOT_DIR}/.env.development" ]]; then
cp "${ROOT_DIR}/.env.development" "${ROOT_DIR}/.env"
print_ok "已从 .env.development 复制为 .env"
return 0
fi
print_warn "未找到 .env.development无法自动生成 .env"
print_warn "请执行: cp api/.env.example api/.env.development 后按说明填写,再运行 ./development.sh"
}
check_env_file() {
print_header "检查环境变量文件"
if [[ ! -f "${ROOT_DIR}/.env" ]]; then
print_warn "未找到 .env应用可能因缺少配置启动失败"
print_warn "请参考 api/README.md 创建 .env"
else
print_ok "检测到 .env"
fi
@@ -242,6 +258,7 @@ main() {
start_infra
wait_postgres_ready || true
ensure_venv
ensure_dotenv_from_development
check_env_file
run_migrations
start_services

View File

@@ -51,6 +51,7 @@ services:
max-file: "3"
# FastAPI 应用
# 运行时统一读取 .env部署时在远端将 .env.staging 或 .env.production 复制为 .env。
api:
build:
context: .
@@ -60,10 +61,8 @@ services:
expose:
- "8000"
env_file:
- .env.production
- .env
environment:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
- REDIS_URL=redis://redis:6379/0
- ASR_MODEL_CACHE_DIR=/app/models/whisper
volumes:
- /root/apiclient_key.pem:/app/certs/apiclient_key.pem:ro
@@ -96,10 +95,7 @@ services:
container_name: life-echo-celery-worker
command: uv run celery -A app.tasks.celery_app worker --loglevel=info --concurrency=4
env_file:
- .env.production
environment:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
- REDIS_URL=redis://redis:6379/0
- .env
restart: always
depends_on:
postgres:
@@ -153,7 +149,7 @@ services:
# container_name: life-echo-celery-beat
# command: celery -A app.tasks.celery_app beat --loglevel=info
# env_file:
# - .env.production
# - .env
# environment:
# - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
# - REDIS_URL=redis://redis:6379/0
@@ -177,7 +173,7 @@ services:
# ports:
# - "5555:5555"
# env_file:
# - .env.production
# - .env
# environment:
# - REDIS_URL=redis://redis:6379/0
# restart: always

View File

@@ -1,2 +0,0 @@
EXPO_PUBLIC_API_URL=http://192.168.10.178:8000
EXPO_PUBLIC_WS_URL=ws://192.168.10.178:8000

View File

@@ -1,2 +1,9 @@
EXPO_PUBLIC_API_URL=http://192.168.10.178:8000
EXPO_PUBLIC_WS_URL=ws://192.168.10.178:8000
# 复制为 .env.development / .env.staging / .env.production 后填写(勿提交含密钥的副本)。
# 本地npm start 会通过 prestart 执行 `use-env development` 生成 .env
# 或手动 `npm run use-env -- staging` / `npm run use-env -- production`。
# CIGitHub Actions 在构建 APK 前会按分支调用 use-envmain → stagingtag → production
#
# 变量在构建时注入;修改后需重新 prebuild/打包客户端。
EXPO_PUBLIC_API_URL=https://your-api.example.com
EXPO_PUBLIC_WS_URL=wss://your-api.example.com

View File

@@ -1,3 +1,13 @@
/**
* 将 app-expo/.env.<name> 复制为 .env供 Metro/Expo 读取 EXPO_PUBLIC_*。
*
* 参数 name → 源文件:
* development → .env.development本地默认npm start / prestart
* staging → .env.staging
* production → .env.production
*
* CI.github/workflows/app-expo-deploy.yml 按分支/tag 调用本脚本,与后端 env 策略对齐。
*/
const fs = require('fs');
const path = require('path');
const env = process.argv[2] || 'development';

View File

@@ -24,7 +24,10 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import { ScreenGutter } from '@/constants/layout';
import { useCreateConversation } from '@/features/conversation/hooks';
import { buildFrameworkChapterPlaceholders } from '@/features/memoir/framework-chapter-keys';
import {
buildFrameworkChapterPlaceholders,
mergeFrameworkChaptersWithFetched,
} from '@/features/memoir/framework-chapter-keys';
import { useChapters, useCheckCoverGeneration } from '@/features/memoir/hooks';
import type { ChapterViewModel } from '@/features/memoir/types';
@@ -263,6 +266,11 @@ export default function MemoirScreen() {
[t],
);
const displayChapters = useMemo(
() => mergeFrameworkChaptersWithFetched(frameworkPlaceholders, chapters),
[frameworkPlaceholders, chapters],
);
useEffect(() => {
if (didRunInitialCoverCheckRef.current) return;
didRunInitialCoverCheckRef.current = true;
@@ -318,19 +326,8 @@ export default function MemoirScreen() {
</>
) : isError ? (
<MemoirLoadError onRetry={() => void refetch()} />
) : chapters.length === 0 ? (
frameworkPlaceholders.map((item) => (
<ChapterCard
key={item.id}
item={item}
variant="drafting"
t={t as (key: string) => string}
onReadPress={() => handleReadChapter(item.id)}
onContinuePress={handleStartChapter}
/>
))
) : (
chapters.map((item) => (
displayChapters.map((item) => (
<ChapterCard
key={item.id}
item={item}

View File

@@ -35,3 +35,17 @@ export function buildFrameworkChapterPlaceholders(
wordCount: 0,
}));
}
/**
* 列表页始终展示 8 个框架槽位:已有章节用接口数据,其余槽位仍用框架占位(与「零章节」时一致)。
*/
export function mergeFrameworkChaptersWithFetched(
placeholders: ChapterViewModel[],
fetched: ChapterViewModel[],
): ChapterViewModel[] {
const byOrder = new Map<number, ChapterViewModel>();
for (const vm of fetched) {
byOrder.set(vm.orderIndex, vm);
}
return placeholders.map((p) => byOrder.get(p.orderIndex) ?? p);
}