# 智能贺卡生成微信小程序 — 设计、实现与部署

> 合并文档:产品设计框架 + 服务器环境记录(原 cardmak.md 与 cardmake.md 合并,记录日期 2026-06-17)

---

## 一、产品概述

用户登录后上传照片，输入提示词（描述贺卡风格、祝福语、场景等），系统结合照片与提示词通过 AI 生成贺卡图片，用户可预览、保存或分享。

### 核心流程
```
微信登录 → 上传照片 → 输入提示词 → AI 生成贺卡 → 预览/保存/分享
```

---

## 二、技术架构

### 整体架构图
```
┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│   微信小程序端    │ ──→ │   后端服务 API     │ ──→ │   AI 图片生成     │
│  (WXML/WXSS/JS) │     │  (Node/Java/Go)  │     │ (NanoBanana等)  │
└─────────────────┘     └──────────────────┘     └─────────────────┘
        │                       │                        │
        │                       ↓                        │
        │               ┌──────────────┐                 │
        └──────────────→│  对象存储 OSS  │←────────────────┘
                        │  (照片/贺卡)   │
                        └──────────────┘
                               │
                        ┌──────────────┐
                        │  数据库 MySQL  │
                        │ (用户/作品)    │
                        └──────────────┘
```

### 技术选型
| 层级 | 技术 | 说明 |
|------|------|------|
| 前端 | 微信原生小程序 / Taro / uni-app | 原生性能最佳，Taro 可跨端 |
| 后端 | **Node.js (Express/Nest)** ✅已定 | 与微信生态契合；业务核心为异步生成+轮询，Node 事件循环天然擅长外部慢接口 |
| 数据库 | MySQL + Redis | 业务数据 + 缓存/会话 |
| 存储 | 腾讯云 COS / 阿里云 OSS | 照片与生成贺卡存储 |
| AI 生成 | NanoBanana / 文生图 API | 照片+提示词图生图 |
| 鉴权 | 微信 code2session + JWT | 登录态管理 |

---

## 三、功能模块设计

### 1. 用户登录模块
- 调用 `wx.login()` 获取临时 `code`
- 后端用 `code` 换取 `openid` + `session_key`
- 生成 JWT 返回前端，本地存储维持登录态
- 首次登录创建用户记录，可选授权头像/昵称

### 2. 照片上传模块
- `wx.chooseMedia()` 选择/拍摄照片
- 前端压缩（控制 1~2MB）后上传
- 上传至 OSS，返回图片 URL
- 校验：格式（jpg/png）、大小、内容安全（调用微信 `imgSecCheck`）

### 3. 提示词输入模块
- 文本输入框 + 预设模板（生日/节日/婚礼/祝福）
- 风格标签快选（水彩、国潮、卡通、简约）
- 内容安全检测 `msgSecCheck`

### 4. AI 贺卡生成模块
- 后端组装最终 prompt（用户输入 + 风格模板 + 贺卡构图指令）
- 调用图片生成 API（图生图：传入照片 URL + prompt）
- 异步任务：返回 task_id，前端轮询生成进度
- 生成完成后回传贺卡图片 URL

### 5. 预览与分享模块
- 预览生成结果，支持重新生成
- `wx.saveImageToPhotosAlbum()` 保存到相册
- 转发分享 / 生成分享海报（含小程序码）

---

## 四、数据库设计

```sql
-- 用户表
CREATE TABLE users (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  openid      VARCHAR(64) UNIQUE NOT NULL,
  nickname    VARCHAR(64),
  avatar      VARCHAR(255),
  created_at  DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 贺卡作品表
CREATE TABLE cards (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id     BIGINT NOT NULL,
  origin_img  VARCHAR(255),          -- 原始照片
  prompt      TEXT,                  -- 用户提示词
  style       VARCHAR(32),           -- 风格
  result_img  VARCHAR(255),          -- 生成贺卡
  status      TINYINT DEFAULT 0,     -- 0生成中 1成功 2失败
  created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_user (user_id)
);
```

---

## 五、核心接口设计

| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/login` | POST | 微信登录，返回 token |
| `/api/upload` | POST | 上传照片，返回 OSS URL |
| `/api/card/generate` | POST | 提交生成任务，返回 task_id |
| `/api/card/status/:taskId` | GET | 查询生成进度/结果 |
| `/api/card/list` | GET | 获取用户作品列表 |

### 生成接口示例
```
POST /api/card/generate
{
  "origin_img": "https://oss.../photo.jpg",
  "prompt": "生日快乐，温馨水彩风格",
  "style": "watercolor"
}
→ { "task_id": "xxx", "status": "pending" }
```

---

## 六、关键代码片段

### 前端：登录
```javascript
wx.login({
  success: (res) => {
    wx.request({
      url: 'https://api.xxx.com/api/login',
      method: 'POST',
      data: { code: res.code },
      success: (r) => wx.setStorageSync('token', r.data.token)
    })
  }
})
```

### 前端：选择并上传照片
```javascript
wx.chooseMedia({
  count: 1, mediaType: ['image'], sizeType: ['compressed'],
  success: (res) => {
    wx.uploadFile({
      url: 'https://api.xxx.com/api/upload',
      filePath: res.tempFiles[0].tempFilePath,
      name: 'file',
      header: { Authorization: wx.getStorageSync('token') },
      success: (r) => { this.setData({ originImg: JSON.parse(r.data).url }) }
    })
  }
})
```

### 后端：登录换取 openid (Node.js)
```javascript
const axios = require('axios')
app.post('/api/login', async (req, res) => {
  const { code } = req.body
  const { data } = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
    params: { appid: APPID, secret: SECRET, js_code: code, grant_type: 'authorization_code' }
  })
  // data.openid 入库 → 生成 JWT
  const token = jwt.sign({ openid: data.openid }, JWT_SECRET, { expiresIn: '7d' })
  res.json({ token })
})
```

### 后端：调用 AI 生成（异步轮询）
```javascript
app.post('/api/card/generate', auth, async (req, res) => {
  const { origin_img, prompt, style } = req.body
  const finalPrompt = buildPrompt(prompt, style)  // 组合风格模板
  const task = await aiClient.editImage({ image_urls: [origin_img], prompt: finalPrompt })
  await db.cards.insert({ user_id: req.user.id, origin_img, prompt, style, status: 0 })
  res.json({ task_id: task.id, status: 'pending' })
})
```

---

## 七、安全与合规

- **内容安全**：照片 `imgSecCheck`、文本 `msgSecCheck`，过滤违规内容
- **接口鉴权**：所有业务接口校验 JWT，防止越权
- **限流防刷**：单用户生成次数限制（如每日 N 次），Redis 计数
- **隐私合规**：明示用户数据用途，照片仅用于生成，提供删除作品功能
- **HTTPS**：小程序要求所有请求走 https，域名需在后台配置白名单

---

## 八、开发计划（建议）

| 阶段 | 内容 | 周期 |
|------|------|------|
| 一 | 项目搭建、登录、上传 | 1 周 |
| 二 | AI 生成对接、轮询、预览 | 1-2 周 |
| 三 | 作品列表、保存分享、海报 | 1 周 |
| 四 | 内容安全、限流、测试上线 | 1 周 |

---

## 九、扩展方向

- 多照片合成贺卡 / 拼图模板
- 动态贺卡（GIF / 短视频）
- 付费模板与高清导出（小程序虚拟支付）
- 贺卡定时发送 / 生成专属祝福链接
- AI 自动生成祝福文案（接入文本大模型）

---

## 十、微信小程序注册（个人账号，暂不接入支付）

### 1. 个人账号能力边界

| 能力 | 个人账号 | 企业账号 |
|------|---------|---------|
| 注册小程序 | ✅ 免费 | ✅ 需 300 元认证费 |
| 上传 / 发布代码 | ✅ | ✅ |
| 微信登录（openid） | ✅ | ✅ |
| 上传照片 / AI 生成 | ✅ | ✅ |
| **微信支付** | ❌ 不支持 | ✅ |
| 部分高级接口（直播等） | ❌ 受限 | ✅ |

**结论**：贺卡生成小程序若暂不做支付，个人账号完全够用，可正常上线发布。

### 2. 注册步骤

**① 注册账号**
- 打开 https://mp.weixin.qq.com → 右上角「立即注册」
- 选择 **「小程序」**（非订阅号 / 服务号）
- 使用一个**未注册过公众平台**的邮箱（一个邮箱只能注册一个）
- 设置密码 → 邮箱激活

**② 选择主体类型**
- 主体类型选 **「个人」**
- 填写本人身份证姓名 + 身份证号
- 绑定本人微信作管理员，扫码 + 人脸识别验证身份
- 一个身份证最多注册 **5 个**小程序

**③ 完善小程序信息**
- 名称避免含商业 / 行业敏感词（建议「暖心贺卡生成」这类）
- 头像、简介、服务类目（选「文化娱乐 / 图片处理」相关）

**④ 获取关键凭证**
- 「开发 → 开发管理 → 开发设置」中获取：
  - **AppID**（小程序 ID）
  - **AppSecret**（密钥，仅显示一次，务必保存）
- 对应后端登录接口的 `APPID` / `SECRET`

### 3. 个人账号关键限制

1. **服务器域名必须备案 + HTTPS**：所有 `wx.request` / `wx.uploadFile` 域名需在白名单中，且需 ICP 备案、支持 https。
2. **内容安全强制**：用户上传照片 + AI 生成，必须接入 `imgSecCheck`（图片）和 `msgSecCheck`（文本），否则易审核驳回。
3. **个人小程序不能直接转企业**：主体变更需走付费认证；若未来确定做支付，建议提前规划。

### 4. 开发阶段可先用测试号

- 下载「微信开发者工具」
- 用「公众平台测试账号」先跑通登录、上传、生成流程
- 真机预览 / 发布时再切正式 AppID

---

## 十一、域名与 IP 访问说明

### 1. 正式环境不能用 IP

微信对网络接口有硬性要求，「开发设置 → 服务器域名」白名单中：

| 要求 | 说明 |
|------|------|
| 必须是**域名** | ❌ 不接受 IP 地址（如 `https://123.45.67.89`） |
| 必须 **HTTPS** | ❌ 不接受 http |
| 域名必须 **ICP 备案** | 国内服务器强制 |
| 端口限制 | https 默认 443，不能用自定义端口（如 `:8080`） |

→ 真机运行 + 发布上线，IP 直连走不通。

### 2. 开发调试阶段可绕过

微信开发者工具内可临时跳过域名校验：

```
开发者工具 → 右上角「详情」→「本地设置」
→ 勾选「不校验合法域名、web-view、TLS 版本以及 HTTPS 证书」
```

勾选后**在开发者工具内**可用 IP、http、任意端口。但：
1. 仅开发者工具内有效，**真机预览 / 体验版不生效**
2. 发布审核时此开关无效，必须配好正式域名

### 3. 本项目实际情况

现已具备正式域名 **cardmake.cn**(详见「服务器环境」章节),已配置 HTTPS(Let's Encrypt),备案就绪后即可配入小程序白名单。

### 4. 新域名准备要点

**① 购买域名**
- 国内服务商（腾讯云 / 阿里云）注册，便于后续备案与 SSL 一站式办理
- 后缀建议 `.com` / `.cn`，避免冷门后缀
- 个人主体可注册（备案时主体与小程序个人主体保持一致更顺畅）

**② ICP 备案**（国内服务器强制，约 7~20 个工作日）
- 在服务器所在云厂商提交备案（服务器在哪家就在哪家备案）
- 个人备案需：身份证、手机、域名、服务器（云主机 / 轻量 / 备案服务码）
- 备案期间网站不能对外提供服务，提前规划时间

**③ 配置 HTTPS**
- 备案通过后，申请免费 SSL 证书（云厂商免费证书 / Let's Encrypt）
- 部署到 Nginx，443 端口，强制 https
- 验证：`curl -I https://新域名` 返回 200

**④ 配进小程序白名单**
- 「开发设置 → 服务器域名」填入 `https://新域名`
- request / uploadFile / downloadFile 合法域名按需配置

### 5. 推进路线

```
阶段0 域名期：购买新域名 → ICP 备案（7~20 工作日）→ 配置 HTTPS
阶段1 开发期：开发者工具勾「不校验域名」→ 用 IP 本地调试（不等备案）
阶段2 真机测试：新域名 HTTPS 就绪 → 配进服务器域名白名单
阶段3 发布：白名单域名就绪 → 提交审核
```

> 💡 备案周期较长，建议**域名备案与前后端开发并行**：开发期用开发者工具「不校验域名」开关，先用 IP 跑通流程，备案完成后再切正式域名做真机测试。

---

## 十二、部署架构（Apache + Node.js + MySQL + COS）

### 1. 各组件分工

| 组件 | 角色 | 运行位置 | 说明 |
|------|------|---------|------|
| **微信小程序前端** | UI 展示 | 用户手机微信 App | 上传至微信后台审核发布，用户端运行 |
| **Apache** | 反向代理 + HTTPS 终结 | 上海轻量服务器 | 监听 443，处理 SSL，转发 `/api` 给 Node.js |
| **Node.js** | 业务后端 API | 上海轻量服务器（内网端口 3000） | 只对 Apache 开放，不直接对外 |
| **MySQL** | 数据库 | 上海轻量服务器 | 存用户、作品记录（图片 URL，非图片本身） |
| **COS** | 对象存储 | 腾讯云 COS（独立服务） | 存原始照片 + AI 生成贺卡 |

**请求链路**：小程序 → `https://域名/api/xxx`（443）→ Apache 反代 → Node.js（127.0.0.1:3000）→ MySQL / COS

### 2. 关键点说明

- **Apache 只做反向代理**：业务逻辑全在 Node.js，Apache 不处理 PHP/静态业务页面，只负责 SSL 和转发。
- **Node.js 不直接对外**：绑定 `127.0.0.1:3000`，仅 Apache 可访问，避免绕过 HTTPS 和鉴权。
- **图片不经过 MySQL**：照片存 COS，数据库只记录 COS 返回的 URL。
- **多站点共存**：若服务器已跑其它 Apache 站点，用不同域名的 VirtualHost 区分即可，互不影响。

### 3. Apache 反向代理配置

启用必要模块：
```bash
sudo a2enmod proxy proxy_http ssl headers rewrite
sudo systemctl restart apache2
```

VirtualHost 配置（`/etc/apache2/sites-available/cardmaker.conf`）：
```apache
# HTTP 强制跳转 HTTPS
<VirtualHost *:80>
    ServerName 你的域名.com
    Redirect permanent / https://你的域名.com/
</VirtualHost>

# HTTPS 反向代理到 Node.js
<VirtualHost *:443>
    ServerName 你的域名.com

    SSLEngine on
    SSLCertificateFile    /etc/ssl/cardmaker/fullchain.pem
    SSLCertificateKeyFile /etc/ssl/cardmaker/privkey.pem

    # 反代到本地 Node.js
    ProxyPreserveHost On
    ProxyPass        /api  http://127.0.0.1:3000/api
    ProxyPassReverse /api  http://127.0.0.1:3000/api

    # 上传大文件放宽限制（照片 1~2MB）
    LimitRequestBody 10485760

    ErrorLog  ${APACHE_LOG_DIR}/cardmaker_error.log
    CustomLog ${APACHE_LOG_DIR}/cardmaker_access.log combined
</VirtualHost>
```

启用站点：
```bash
sudo a2ensite cardmaker.conf
sudo systemctl reload apache2
```

> ⚠️ 微信要求 443 端口 + 合法 SSL 证书，不能用自定义端口。证书可用腾讯云免费 SSL 或 Let's Encrypt。

### 4. Node.js 部署（PM2 守护）

```bash
# 安装 Node.js（推荐 LTS）
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
sudo apt install -y nodejs

# 安装 PM2 进程守护
sudo npm install -g pm2

# 部署项目
cd /var/www/cardmaker-api
npm install
pm2 start app.js --name cardmaker-api
pm2 save
pm2 startup   # 设置开机自启
```

Node 服务监听内网端口：
```javascript
app.listen(3000, '127.0.0.1', () => {
  console.log('API running on 127.0.0.1:3000')
})
```

### 5. MySQL 配置

```bash
sudo apt install -y mysql-server
sudo mysql_secure_installation   # 设置 root 密码、移除测试库

# 创建业务库与专用账号（不要用 root 连业务）
sudo mysql -e "CREATE DATABASE cardmaker DEFAULT CHARSET utf8mb4;"
sudo mysql -e "CREATE USER 'cardapp'@'localhost' IDENTIFIED BY '强密码';"
sudo mysql -e "GRANT ALL ON cardmaker.* TO 'cardapp'@'localhost';"
sudo mysql -e "FLUSH PRIVILEGES;"
```

> MySQL 只监听 `localhost`，不对公网开放，Node.js 本机连接。

### 6. 腾讯云 COS 配置

**① 开通存储桶**
- 腾讯云控制台 → 对象存储 COS → 创建存储桶
- 地域选 **上海**（与服务器同地域，内网传输免流量费、低延迟）
- 访问权限：**私有读写**（通过签名 URL 访问，不公开）

**② 获取密钥**
- 访问管理 → API 密钥管理 → 获取 `SecretId` / `SecretKey`
- 建议用子账号密钥 + 最小权限策略（只授权该桶）

**③ 后端集成（推荐前端直传）**
```javascript
// 后端生成临时签名，前端直传 COS，不经过 Node 中转
const COS = require('cos-nodejs-sdk-v5')
const cos = new COS({ SecretId: SECRET_ID, SecretKey: SECRET_KEY })

// 接口：返回前端直传所需的临时签名
app.get('/api/cos/sign', auth, (req, res) => {
  // 生成临时密钥 STS 或预签名 URL，返回前端
  // 前端用 wx.uploadFile 直传 COS，回传图片 URL 入库
})
```

> 💡 **前端直传方案**：照片不经过 Node 服务器中转，直接传 COS，省服务器带宽和内存。Node 只负责生成签名和记录 URL。

### 7. 部署顺序清单

```
1. 装环境：Node.js + PM2 + MySQL + Apache 模块
2. 配 MySQL：建库、建账号、导入表结构
3. 开 COS：建桶（上海）、取密钥、配权限
4. 部署 Node：上传代码、npm install、PM2 启动（127.0.0.1:3000）
5. 配 Apache：VirtualHost 反代 + SSL 证书
6. 验证：curl -I https://域名/api/health 返回 200
7. 配微信白名单：服务器域名填 https://域名
```

### 8. 安全要点

- Node.js 绑定 `127.0.0.1`，不暴露公网端口
- MySQL 仅 `localhost`，业务用专用账号非 root
- COS 私有读写，签名 URL 访问，密钥不写进前端代码
- Apache 强制 HTTPS，HTTP 自动跳转
- 轻量服务器防火墙只放行 80/443，关闭 3000/3306 公网入站

---

## 十三、服务器环境(cardmake.cn)

记录日期:2026-06-17 ｜ 系统:Ubuntu 22.04 LTS ｜ 公网 IP:124.223.30.5

### Apache
- 版本:Apache/2.4.52,服务 active 且开机自启
- 网站根目录:/var/www/html
- 主配置:/etc/apache2/apache2.conf
- 虚拟主机:/etc/apache2/sites-available/(000-default.conf / 000-default-le-ssl.conf)

### HTTPS(Let's Encrypt)
- 证书域名:cardmake.cn、www.cardmake.cn
- 到期:2026-09-15,已配置 certbot 自动续期
- 证书路径:/etc/letsencrypt/live/cardmake.cn/
- 已启用 HTTP → HTTPS 301 重定向
- 注意:需在云安全组放行 443 端口

### PHP
- 版本:PHP 8.1.2(CLI + libapache2-mod-php)
- 配置文件(Apache):/etc/php/8.1/apache2/php.ini
- 已装扩展:mysqli、curl、gd、mbstring、xml、zip、bcmath、intl、opcache
- upload_max_filesize = 20M,post_max_size = 20M

### MySQL
- 版本:MySQL 8.0.46,服务 active 且开机自启
- root 默认 auth_socket 认证(本机 sudo mysql 登录)
- 待办:运行 sudo mysql_secure_installation 进行安全加固

> 📌 后端技术栈已定为 **Node.js**(详见第十二章部署方案)。当前服务器虽已装 PHP 8.1,但本项目不使用 PHP 跑业务,Apache 仅作反向代理 + HTTPS 终结,业务全部由 Node.js 处理。mod_php 与反向代理可在同一 Apache 共存,互不影响。

### 待办清单(落地 Node.js 方案)

```
[x] 安装 Node.js — 系统已自带 v22.22.3(当前 LTS,比 20.x 更新),npm 10.9.8
[x] 全局安装 PM2 — 已装 v7.0.1(sudo npm install -g pm2)
[x] 启用 Apache 反代模块 — proxy / proxy_http / headers 新启用,ssl / rewrite 原已启用,已 restart 生效
[x] 反代配置 — 未另建 cardmaker.conf,改在现有 000-default-le-ssl.conf 内加 /api 反代(复用 certbot 证书,避免同域名 vhost 撞车);已备份 .bak.20260617,configtest 通过,reload 生效
[x] 部署 Node 项目 — 代码在 /var/www/cardmaker-api,绑定 127.0.0.1:3000,PM2 守护(name=cardmaker-api),pm2 save + pm2 startup(systemd 开机自启 enabled)已完成
[x] 验证 — https://cardmake.cn/api/health 与本机直连均返回 {"status":"ok"}
[x] 建库建账号 — cardmaker 库(utf8mb4_unicode_ci)+ cardapp@localhost 专用账号(随机强密码、仅授权该库),已导入第四章表结构(users / cards),Node 经 .env 连接验证通过
[x] 配置 .env — DB_PASSWORD、JWT_SECRET、WX_APPID、WX_SECRET、COS(SecretId/SecretKey/Bucket/Region)、AI 接口(URL/Key)均已写入真实值;.env 权限 600
[x] AI 图生图接入 — acedata gpt-image-2:有照片走 images/edits(图生图),无照片走 images/generations(文生图),均验证可用(同步约 60~75s)
[x] generate 异步化 — generate 立即返回 task_id,后台调 AI→转存 COS→写回 cards.result_img/status,前端轮询 /status 验证成功
[x] COS 存储 — 桶 shanghai-1251602985(ap-shanghai),生成图转存 cards/<id>.png、上传照片存 uploads/,均验证返回可访问 URL(HTTP 200)
[x] /api/upload — multer 接收照片(jpg/png,≤10MB)中转上传 COS 返回 URL,验证通过
[x] MySQL 安全加固 — 已执行 mysql_secure_installation:匿名用户已删、test 库已删、3306 仅监听 127.0.0.1;root 改为 caching_sha2_password 密码认证,空密码登录已封堵(注:root 不再免密,登录需 mysql -u root -p)
[x] COS 前端直传签名 — GET /api/cos/sign 返回预签名 PUT URL(600s),前端可直传不经 Node;curl PUT 实测 HTTP 200、图片可访问。/api/upload 中转上传保留作兜底
[x] 内容安全 — msgSecCheck(文本)接入 generate、imgSecCheck(图片)接入 /api/upload;access_token 进程内存缓存(提前5分钟刷新);策略=违规拦截(87014)、接口报错放行(已验证假 openid 报 40003 时放行不阻断)
[x] 限流防刷 — rateLimit 中间件(基于 cards 表,无 Redis):并发锁(同时仅1个 status=0 任务)+ 日限次(DAILY_LIMIT=20),均实测返回 429
[ ] 微信后台服务器域名白名单填入 https://cardmake.cn,真机联调登录/上传/生成全流程(真机用真实 openid,内容安全才实际生效)
```

> 数据库记录(2026-06-17 已完成):MySQL 8.0.46 已建 `cardmaker` 库 + `cardapp`@`localhost` 专用账号(GRANT ALL ON cardmaker.*,非 root),表结构 users / cards 已导入。账号密码与随机 JWT_SECRET 已写入 `/var/www/cardmaker-api/.env`(权限 600,已 gitignore,密码不入文档)。Node 服务用该凭证连接池查询验证成功。MySQL 加固已完成:匿名用户/test 库已删、3306 仅监听 127.0.0.1;root 已设 `caching_sha2_password` 密码认证,空密码 TCP/socket 登录均被拒。**注意:root 改密码认证后 `sudo mysql` 不再免密,登录需 `mysql -u root -p`;root 密码请妥善保管(本文档不记录)。**

> 环境记录(2026-06-17 已完成):Node.js v22.22.3 + npm 10.9.8 + PM2 v7.0.1;Apache 已启用 proxy、proxy_http、headers、ssl、rewrite 模块,configtest 通过,服务 active。反代写在 `000-default-le-ssl.conf` 内(`ProxyPass /api → 127.0.0.1:3000/api`),原文件已备份为 `000-default-le-ssl.conf.bak.20260617`。后端骨架(Express)已部署于 `/var/www/cardmaker-api` 并由 PM2 守护、开机自启,健康检查经 HTTPS 反代验证通过。

### 后端项目结构(/var/www/cardmaker-api)

```
cardmaker-api/
├── package.json          # 依赖:express / mysql2 / jsonwebtoken / axios / cors / dotenv / cos-nodejs-sdk-v5 / multer
├── .env.example          # 配置模板(复制为 .env 填真实值,.env 已 gitignore)
├── .gitignore
└── src/
    ├── app.js            # 入口:绑定 127.0.0.1:3000,挂载路由,/api/health 健康检查
    ├── config.js         # 读取 .env 的集中配置(含 limit.daily、security.secCheckEnabled)
    ├── db.js             # mysql2 连接池(本机、专用账号 cardapp)
    ├── middleware/
    │   ├── auth.js       # JWT 鉴权中间件
    │   └── rateLimit.js  # 生成限流:并发锁 + 日限次(基于 cards 表,无 Redis)
    ├── services/
    │   ├── ai.js         # acedata 图片生成(edits 图生图 / generations 文生图)+ buildPrompt 风格模板
    │   ├── cos.js        # COS 转存 transferToCos / 上传 putBuffer / 预签名直传 getPresignedPutUrl
    │   └── wechat.js     # access_token 内存缓存 + msgSecCheck(文本)+ imgSecCheck(图片)
    └── routes/
        ├── login.js      # POST /api/login(code 换 openid → 入库 → 发 JWT)
        ├── upload.js     # POST /api/upload(multer 接收照片 → imgSecCheck → COS → URL)
        ├── cos.js        # GET /api/cos/sign(预签名 PUT URL,前端直传)
        └── card.js       # /api/card/generate(异步,挂 rateLimit + msgSecCheck)· /status/:taskId · /list

全部接口已就绪并验证:/api/health、/api/login、/api/upload、/api/cos/sign、/api/card/{generate,status,list}
图生图闭环:上传照片拿 URL → 作为 origin_img 传 generate → 后台 AI 生成 → 转存 COS → 轮询 /status 拿永久 URL
```

### AI 与存储记录(2026-06-17 已完成)

- **AI 接口**:acedata gpt-image-2。图生图 `POST /openai/images/edits`(入参 model/prompt/image(URL)/size);无照片降级文生图 `/openai/images/generations`。同步阻塞约 60~75 秒,故 generate 放后台任务执行。Key 存 .env 的 `AI_API_KEY`。
- **COS**:存储桶 `shanghai-1251602985`,地域 `ap-shanghai`,可匿名读。生成图存 `cards/<cardId>.png`,上传照片存 `uploads/<userId>_<ts>.<ext>`。SecretId/SecretKey 存 .env。
- **降级逻辑**:COS 未配置时 transferToCos 原样返回 AI 临时 URL(已配置后自动转永久 URL,代码无需改)。
- **风格模板**:watercolor/guochao/cartoon/simple,在 ai.js 的 buildPrompt 里拼接到 prompt。

### 内容安全与限流记录(2026-06-17 已完成)

- **access_token**:进程内存缓存(`expires_in - 300` 提前刷新),不引入 Redis。单实例 PM2(fork)有效;若改 cluster 多实例需换共享缓存。
- **内容安全**:文本走 `wxa/msg_sec_check`(v2,需 openid),图片走 `wxa/img_sec_check`。策略=**违规(errcode 87014 或 suggest=risky)拦截,接口报错放行**(未发布/频率限等不阻断开发)。总开关 `SEC_CHECK_ENABLED`,开发期可设 false 关闭。⚠️ 假 openid 会报 40003 放行;真机用真实 openid 才实际生效。
- **限流**:`src/middleware/rateLimit.js` 基于 cards 表,无需 Redis。① 并发锁:同一用户同时仅 1 个进行中任务(status=0),否则 429 `task in progress`;② 日限次:当日(`created_at>=CURDATE()`)≥ `DAILY_LIMIT`(默认 20)则 429 `daily limit exceeded`。
- **COS 直传**:`GET /api/cos/sign?ext=png` 返回 `{url(预签名PUT,600s), key, finalUrl}`。前端 PUT 直传到 url,成品地址即 finalUrl。直传图不过 Node,内容安全需前端检测或在 generate 时补检;中转上传 `/api/upload` 仍做 imgSecCheck,二者并存前端择一。

> 注:第十二章 VirtualHost 示例里的证书路径 `/etc/ssl/cardmaker/` 应改为本机实际的 Let's Encrypt 路径 `/etc/letsencrypt/live/cardmake.cn/fullchain.pem` 与 `privkey.pem`。

---

## 十四、前端对接清单(真机联调,2026-06-17)

公网链路已就绪并验证:HTTPS 健康检查 200、证书有效、路由通、鉴权生效、HTTP 301 强制跳 HTTPS。域名白名单已配置,可用正式 AppID 真机联调。

**基础约定**
- 接口前缀:`https://cardmake.cn/api`
- 鉴权:除 `/login` 外,业务接口请求头带 `Authorization: Bearer <token>`(token 由 login 返回,本地 storage 保存)
- 风格值:`watercolor`(水彩)/ `guochao`(国潮)/ `cartoon`(卡通)/ `simple`(简约)

### 1. 登录 `POST /api/login`
```javascript
wx.login({
  success: (res) => {
    wx.request({
      url: 'https://cardmake.cn/api/login',
      method: 'POST',
      data: { code: res.code },
      success: (r) => wx.setStorageSync('token', r.data.token)
    })
  }
})
```
返回:`{ token }`。token 默认 7 天有效。

### 2. 上传照片(方案 A:服务器中转)`POST /api/upload`
```javascript
wx.chooseMedia({
  count: 1, mediaType: ['image'], sizeType: ['compressed'],
  success: (res) => {
    wx.uploadFile({
      url: 'https://cardmake.cn/api/upload',
      filePath: res.tempFiles[0].tempFilePath,
      name: 'file',
      header: { Authorization: 'Bearer ' + wx.getStorageSync('token') },
      success: (r) => { const url = JSON.parse(r.data).url /* COS 永久 URL */ }
    })
  }
})
```
返回:`{ url }`。已做 imgSecCheck;违规返回 400 `image not allowed`。

### 2'. 上传照片(方案 B:COS 直传,省带宽)
```javascript
// ① 取预签名 URL
wx.request({
  url: 'https://cardmake.cn/api/cos/sign?ext=jpg',
  header: { Authorization: 'Bearer ' + wx.getStorageSync('token') },
  success: (r) => {
    const { url, finalUrl } = r.data
    // ② PUT 直传图片体到 url;成功后 finalUrl 即图片地址
    //    (注:直传图不过后端,内容安全需另行处理)
  }
})
```
返回:`{ url, key, finalUrl, expires }`。方案 A / B 择一。

### 3. 提交生成 `POST /api/card/generate`
```javascript
wx.request({
  url: 'https://cardmake.cn/api/card/generate',
  method: 'POST',
  header: { Authorization: 'Bearer ' + wx.getStorageSync('token'), 'content-type': 'application/json' },
  data: { origin_img: '<上一步的 URL,可选>', prompt: '生日快乐 温馨水彩', style: 'watercolor' },
  success: (r) => { const taskId = r.data.task_id /* 开始轮询 */ }
})
```
返回:`{ task_id, status:'pending' }`。
- 不传 `origin_img` → 纯文生图;传了 → 图生图。
- 限流:有进行中任务 → 429 `task in progress`;当日超 20 → 429 `daily limit exceeded`。
- 内容安全:prompt 违规 → 400 `content not allowed`。

### 4. 轮询结果 `GET /api/card/status/:taskId`
```javascript
const timer = setInterval(() => {
  wx.request({
    url: 'https://cardmake.cn/api/card/status/' + taskId,
    header: { Authorization: 'Bearer ' + wx.getStorageSync('token') },
    success: (r) => {
      if (r.data.status === 'success') { clearInterval(timer); /* r.data.result_img */ }
      else if (r.data.status === 'failed') { clearInterval(timer); /* 提示失败 */ }
    }
  })
}, 5000) // 生成约 60~75s,5 秒一轮
```
返回:`{ task_id, status:'pending'|'success'|'failed', result_img }`。

### 5. 作品列表 `GET /api/card/list`
返回:`{ list: [{ id, origin_img, prompt, style, result_img, status, created_at }] }`,按时间倒序。

### 联调注意
- token 过期返回 401,前端需重新 `wx.login` 取新 token。
- 生成耗时长,务必做 loading 态;轮询间隔约 5 秒。
- 真机用真实 openid,msgSecCheck/imgSecCheck 才实际生效(开发者工具的假 openid 会被放行)。
- 接口异常时贴请求+返回,后端可查 PM2 日志(`pm2 logs cardmaker-api`)定位。

---

## 十五、命令行上传/预览(miniprogram-ci,无需开发者工具)

微信开发者工具**无官方 Linux 版**。本项目改用官方 Node 工具 `miniprogram-ci`,在服务器命令行完成**预览**和**上传**,彻底绕开"没有 Linux 版工具"的问题(仅真机扫码仍需手机微信)。

### 1. 工具位置与构成(2026-06-17 已就绪)

```
/var/www/cardmaker-mp-ci/
├── package.json
├── node_modules/         # miniprogram-ci v2.1.31
├── upload.js             # 上传代码(开发版→可设体验版/提审)
├── preview.js            # 生成预览二维码(真机预览)
├── private.key           # 微信上传密钥(权限 600,勿外传)
└── preview-qr.jpg        # preview.js 生成的二维码图
```

### 2. 前置(已完成)

- 微信公众平台 → 开发 → 开发管理 → 开发设置 → 小程序代码上传:
  - 已生成**上传密钥**,下载后重命名 `private.key` 放入 `/var/www/cardmaker-mp-ci/`(chmod 600)
  - **IP 白名单**已填服务器公网 IP `124.223.30.5`
- AppID:`wx67ca541eda24df2c`(脚本内已写死)

### 3. 预览(真机调试,不发布)

```bash
node preview.js
# 生成 preview-qr.jpg → 下载到本地 → 手机微信「扫一扫」
# scp ubuntu@124.223.30.5:/var/www/cardmaker-mp-ci/preview-qr.jpg ~/Desktop/
```
- 二维码**约 25 分钟过期**,过期重跑即可。
- 扫码微信号须为该小程序**开发者/体验成员**(管理员本人可直接扫)。

### 4. 上传(转体验版 / 提交审核)

```bash
cd /var/www/cardmaker-mp-ci && node upload.js 1.0.0 "首次体验版"
# 成功后:微信公众平台 → 管理 → 版本管理 → 开发版本 → 选为体验版 → 扫码真机测试
# 体验无误后在同页「提交审核」,审核通过即可发布上线
```

### 5. 仍需图形工具/真机的环节

- **预览/上传**:命令行即可(本章方案)。
- **真机运行**:手机微信扫码(预览码或体验版码)。
- **提交审核 / 发布**:可在微信公众平台**网页后台**完成,无需开发者工具。
- 结论:全流程可在 "Ubuntu 服务器命令行 + 微信网页后台 + 手机" 下闭环,无需 Windows/Mac。

---

## 十六、联调问题记录

### Q1:保存到相册「下载失败」,但预览大图可下载(2026-06-17)

- **现象**:贺卡生成成功,点图片预览正常并可在预览里下载;点「保存到相册」报"下载失败"。
- **原因**:`wx.previewImage` 不校验域名白名单;`wx.downloadFile` 强制校验 **downloadFile 合法域名**。贺卡图存于 COS 域名 `shanghai-1251602985.cos.ap-shanghai.myqcloud.com`,该域名未加入白名单(此前只配了 `cardmake.cn`,且 downloadFile 类需单独配)。
- **解决**:微信公众平台 → 开发 → 开发管理 → 开发设置 → 服务器域名 →「downloadFile 合法域名」加入:
  ```
  https://shanghai-1251602985.cos.ap-shanghai.myqcloud.com
  ```
  建议同时加入「uploadFile 合法域名」(将来 COS 直传用)。保存后重启小程序生效,无需改代码。
- **白名单归纳**:request → `https://cardmake.cn`;downloadFile/uploadFile → COS 域名 + 按需 `cardmake.cn`。

#### Q1 续:加了白名单仍「下载失败」的进一步排查(2026-06-17)

- 加 downloadFile 白名单后仍失败。原代码 fail 分支只笼统提示"下载失败",吞掉了真实 errMsg,无法定位。
- **已改进**(`pages/index/index.js` 的 saveToAlbum / _save):
  - downloadFile fail 时用 `wx.showModal` 暴露真实 `errMsg`;
  - 校验 downloadFile 返回 `statusCode`(非 200 也提示);
  - saveImageToPhotosAlbum 失败时区分"权限被拒"(引导 `wx.openSetting` 去设置开权限)与其它错误。
- 改后已重新生成预览码上传:`node preview.js` → 二维码同步到 `https://cardmake.cn/share/preview-qr.jpg`。
- **排查对照**(看弹窗真实内容):
  - `url not in domain list` → 白名单未生效/域名不一致(有时需等十余分钟,注意末尾不能多斜杠)
  - `ssl / certificate` → TLS 问题
  - `timeout` → 网络超时
  - `HTTP 403/404` → COS 对象权限或路径问题
  - 含 auth/deny → 相册权限,走 openSetting 开启
- 待用户反馈真实报错后继续定位。

---

## 十七、常用命令速查(命令行开发/运维)

无开发者工具图形界面,全程命令行。以下命令在服务器 `ubuntu` 用户下执行。

### 小程序:改代码后重新预览(最常用)

```bash
cd /var/www/cardmaker-mp-ci
node preview.js                                   # 生成预览二维码 preview-qr.jpg(约25分钟有效)
cp preview-qr.jpg /var/www/html/share/preview-qr.jpg && chmod 664 /var/www/html/share/preview-qr.jpg
# 然后手机微信扫:https://cardmake.cn/share/preview-qr.jpg
```

### 小程序:上传为开发版(转体验版/提审)

```bash
cd /var/www/cardmaker-mp-ci
node upload.js 1.0.0 "版本描述"
# 之后:微信公众平台 → 管理 → 版本管理 → 设为体验版 / 提交审核 / 发布
```

> 工程路径与 AppID 已写死在脚本里;小程序源码在 `/var/www/cardmaker-miniprogram`,密钥 `/var/www/cardmaker-mp-ci/private.key`(600,勿外传)。

### 后端:PM2 进程管理

```bash
pm2 list                          # 查看进程状态
pm2 restart cardmaker-api         # 改后端代码后重启
pm2 logs cardmaker-api            # 实时日志(排查接口报错)
pm2 logs cardmaker-api --lines 50 --nostream   # 看最近 50 行
pm2 save                          # 保存进程列表(开机自启用)
```

### 后端:改 .env 配置后

```bash
cd /var/www/cardmaker-api
# 编辑 .env 后必须重启才生效
pm2 restart cardmaker-api
```

### 后端:常用自检

```bash
curl -s https://cardmake.cn/api/health                 # 健康检查
sudo apache2ctl configtest && sudo systemctl reload apache2   # 改 Apache 配置后
mysql -u root -p -e "USE cardmaker; SELECT COUNT(*) FROM cards;"   # 查作品数(需root密码)
```

### 数据库:用业务账号快速查(无需密码,走 node)

```bash
cd /var/www/cardmaker-api
node -e "const p=require('./src/db');(async()=>{const[r]=await p.query('SELECT id,status,result_img FROM cards ORDER BY id DESC LIMIT 5');console.log(r);process.exit(0)})()"
```

#### Q1 结论:已解决(2026-06-17)

- **真实根因**:相册写入权限(`scope.writePhotosAlbum`)未授权,并非域名白名单。原代码缺授权处理,失败仅笼统提示,误判为"下载失败"。
- **修复**:saveImageToPhotosAlbum 失败时区分权限被拒并引导授权(改进后会正常弹权限框)。用户点确认授权后保存成功。
- **状态**:✅ 主流程真机全程跑通——扫码 → 上传照片 → 输入祝福语 → 选风格 → 生成 → 预览 → 保存到相册。

---

## 十八、功能迭代记录(2026-06-18)

备案/认证审核期间继续迭代前端(开发版可改可传,不影响审核;仅"发布"被备案卡住)。本轮四项 + 分享:

### 1. 结果图/缩略图 4:3 完整显示
- 需求:图片按 4:3 比例自动缩放完整显示,不裁剪。
- 实现:外层 `.ratio-box`(`padding-bottom:75%` 撑 4:3)+ 内层 `<image mode="aspectFit">` 绝对定位填满。空白处填浅粉底。
- 改动:`pages/index/index.wxml`(结果图)、`pages/list/list.wxml`(缩略图,原 aspectFill→aspectFit)及各自 wxss。

### 2. 删除历史作品
- 前端:作品列表每张加「删除」按钮(`catchtap` 防冒泡到预览)+ 二次确认弹窗,删除成功本地移除该项。
- 后端:新增 `DELETE /api/card/:id`(鉴权,仅删本人作品):先删 cards 记录,再异步 `cos.deleteByUrl()` 清理 result_img/origin_img 对应的 COS 对象(清理失败不阻断)。
- 文件:`src/routes/card.js`(DELETE 路由)、`src/services/cos.js`(deleteByUrl)、小程序 `utils/request.js`(remove)、`pages/list/*`。
- 已验证:删成功 ok / 重复删 404 / 不存在或越权 404。

### 3. 生成时间进度条
- 需求:显示 xx% 进度;超 1 分钟(100%)显示"请稍后"。
- 实现:`startProgress()` 按 60 秒线性推进(每 500ms 更新),到 100% 停住;wxml 进度条 `width:{{progress}}%`,文案 `progress>=100 ? '请稍后…' : progress+'%'`。生成结束/失败/离开页 `stopProgress()` 清定时器。
- 注:进度是"时间估算"非真实进度(AI 同步返回无中间进度),60s 是经验值。

### 4. 分享给好友/群
- 结果页「分享给好友」按钮 + 作品列表每张「分享」按钮,均 `open-type="share"`。
- `onShareAppMessage` 用贺卡图作分享封面 `imageUrl`,`path` 指向首页;列表页按钮的 `data-id` 决定分享哪张。
- 右上角原生「···」转发同时生效。未加朋友圈分享(onShareTimeline)——个人小程序支持有限。
- 封面图走微信服务端抓取,不受 downloadFile 白名单限制。

> 改动后已 `node preview.js` 更新预览码(https://cardmake.cn/share/preview-qr.jpg),后端 `pm2 restart cardmaker-api`。待真机验证通过后用 `node upload.js 1.0.1 "..."` 上传开发版。

### 5. UI 修正(2026-06-18 续)
- **上传照片区 4:3**:原固定 320rpx 高 + image 拉伸,改为 `.upload-box` 4:3 容器(padding-bottom:75%)+ `aspectFit`,照片完整不裁不变形。
- **列表去"完成"字**:status=1 时不显示状态文字(`item.status===1 ? '' : statusText`),仅生成中/失败显示。
- **分享/删除改图标**:原"分享/删除"文字按钮 → 圆形图标 ↗(绿,分享)/ ✕(红,删除)。
- **修复扁椭圆 bug**:分享是 `<button open-type=share>`,微信 button 默认块级宽度+min-height+padding 把它撑成扁椭圆并挤掉删除图标。解法:`button.icon-btn` 重置(min-height:0、去 border、box-sizing+flex 居中、::after 去边框)。删除用 `<view>`,二者统一 56rpx 圆形。

### 6. 角标图标改 PNG + 颜色/居中(2026-06-18 续)
- 之前文字符号 ↗/✕ 在真机显示异常(button 默认样式撑变形)。改用纯 Node 生成的 PNG 图标(`/var/www/cardmaker-mp-ci/gen-icons.js` 无依赖生成 48×48 RGBA PNG),放小程序 `images/delete.png`、`images/share.png`。
- 列表角标:删除浮在缩略图**左上角**、分享**右上角**(`.overlay-icon` 绝对定位,button 用 `!important` 锁宽避免撑开)。
- 图标圆底色:淡粉 rgb(255,138,155) + 白色字形,呼应主题色。
- 作品列表提示词/状态文字居中(`.meta` text-align center)。
- 首页「生成贺卡」按钮:放进 `.generate-area`(flex:1)占据风格区到底部之间空间并垂直居中;按钮文字用 flex 居中(替代 line-height,修复偏下)。
- 提示词输入框降到 88rpx、区块间距收紧,首页一屏显示。

### 上传记录
- 2026-06-18:`node upload.js 1.0.1` 上传开发版成功(含本章全部 UI 改动 + 删除/进度条/分享功能)。待微信后台设体验版自测;备案通过后提审发布。

---

## 十九、主体变更为企业后的微信支付接入记录(2026-07-07)

### 1. 背景与结论

- 个人主体小程序变更为企业主体后,若要接入微信支付,不需要重新注册新的小程序账号;现有小程序 AppID 可继续沿用,只是主体变为企业。
- 微信支付商户号(`mch_id`)是收款主体,应由变更后的企业申请或绑定,资金结算、退款、账单、税务与法律责任均归企业。
- 企业主体通常需要企业对公银行账户作为结算账户;不应使用开发者个人银行卡、开发者个人微信或第三方公司账户。
- 当前开发者可继续作为小程序管理员协助后台操作和技术接入,但企业认证、对公账户验证、协议签署、商户平台超级管理员等应由企业方完成或明确授权。

### 2. 推荐分工

| 事项 | 企业方负责 | 开发者负责 |
|------|------------|------------|
| 小程序主体接收/确认 | ✅ | 协助 |
| 申请或提供微信支付商户号 | ✅ | 指导入口 |
| 营业执照、法人/经办人信息、对公账户 | ✅ | 不建议持有原件 |
| 商户平台超级管理员 | ✅ | 不建议长期担任 |
| 商户号绑定小程序 AppID | 企业在商户平台确认 | 小程序后台侧配合确认 |
| JSAPI 支付开通 | ✅ | 确认状态 |
| 支付接口开发、回调、订单状态 | 提供参数 | ✅ |
| 退款策略 | 决定业务规则 | 按需求开发或不开发 |

### 3. 企业申请或绑定微信支付商户号

企业有两种方式:

1. **从小程序后台申请(推荐)**  
   路径:微信公众平台 `mp.weixin.qq.com` → 小程序后台 → 微信支付 → 开通/申请接入。

2. **从微信支付商户平台申请**  
   路径:`pay.weixin.qq.com` → 注册/接入微信支付商户号。申请完成后,再在商户平台关联本小程序 AppID。

企业申请时通常需要准备:

- 营业执照、统一社会信用代码、企业名称;
- 法定代表人或经办人身份信息;
- 企业对公银行账户;
- 经营类目、客服电话、商户简称;
- 特殊行业资质(如食品、教育、医疗、出版、票务等,视业务类目而定)。

申请流程通常包括资料提交、审核、对公账户小额打款验证、签署微信支付协议。审核通过后企业获得商户号 `mch_id`。

### 4. 开发者接入微信支付需要企业提供的信息

用于本项目后端接入微信支付时,企业应提供或配合确认以下信息:

```text
1. 小程序 AppID: wx67ca541eda24df2c(本项目已有,仍需确认商户号已绑定该 AppID)
2. 微信支付商户号 mch_id:
3. 商户主体名称:
4. JSAPI 支付是否已开通: 是/否
5. 商户号是否已绑定小程序 AppID: 是/否
6. API v3 密钥:
7. 商户 API 证书序列号:
8. 商户 API 私钥 apiclient_key.pem:
9. 商户证书 apiclient_cert.pem(如 SDK 需要):
10. 是否需要程序内退款功能: 是/否
11. 商户平台联系人/管理员,用于后续验证或授权:
```

> 注意:营业执照、法人身份证、对公账号、法人手机号、商户平台登录密码等是企业申请商户号使用的信息,开发者做技术接入通常不需要直接持有。

### 5. pay.weixin.qq.com 中的查看/配置位置

| 信息 | 是否能在商户平台查看/配置 | 常见位置 |
|------|----------------------------|----------|
| 商户号 `mch_id` | 可以查看 | 账户中心 → 商户信息 |
| 商户主体名称 | 可以查看 | 账户中心 → 商户信息 |
| API v3 密钥 | 可设置,通常不可再次查看明文 | 账户中心 → API 安全 |
| `apiclient_key.pem` 私钥 | 申请 API 证书时下载,网页不直接显示 | 账户中心 → API 安全 → API 证书 |
| 商户 API 证书序列号 | 可以查看 | 账户中心 → API 安全 → API 证书 |
| JSAPI 支付状态 | 可以查看 | 产品中心 → 我的产品 / JSAPI 支付 |
| 商户号绑定小程序 AppID | 可以查看 | 产品中心 → AppID 账号管理 |
| 退款功能 | 业务决定,非固定必填项 | 交易中心可人工退款;程序退款需开发接口 |

重要说明:

- API v3 密钥设置成功后通常不会再展示明文;遗忘时需重新设置,并同步更新服务器配置。
- 商户 API 私钥 `apiclient_key.pem` 是申请/下载 API 证书时生成的本地文件;若遗失,通常需要重新申请或更换证书。
- `API v3 密钥`、`apiclient_key.pem`、证书文件均为敏感资产,不要提交到 Git,不要写入前端小程序代码,不要放在 Web 可访问目录。

### 6. 退款功能决策

微信支付商户平台本身通常支持人工退款。企业可登录 `pay.weixin.qq.com` → 交易中心/订单管理,找到订单后手动退款。因此第一版小程序不一定要开发程序内退款接口。

退款有两种方案:

| 方案 | 说明 | 适用场景 |
|------|------|----------|
| A. 商户平台人工退款 | 用户联系客服,企业在微信支付商户平台手动退款,本系统手动改订单状态 | 初期上线、订单量少、降低误退款风险 |
| B. 程序内退款 | 本系统开发退款申请、管理员审核、调用微信退款 API、退款回调、状态同步 | 订单量较大、需要自动化售后 |

若选择程序内退款,还需额外设计:

- 退款申请与管理员审核流程;
- 退款权限控制,防止用户或普通账号越权退款;
- 订单状态:已支付、退款申请中、已退款、部分退款、退款失败等;
- 金额校验:退款金额不能大于实付金额,防止重复退款;
- 退款回调地址,如 `https://cardmake.cn/api/wechat/refund/notify`;
- 操作日志:发起人、时间、金额、原因、微信退款单号、处理结果。

当前建议:第一版先实现支付闭环,退款由企业在微信支付商户平台人工处理;等订单量或售后需求明确后,再开发程序内退款功能。

### 7. 本项目微信支付后续技术接入 TODO

```text
[ ] 企业完成小程序主体变更/接收确认
[ ] 企业申请或提供微信支付商户号 mch_id
[ ] 企业确认 JSAPI 支付已开通
[ ] 企业确认商户号已绑定 AppID: wx67ca541eda24df2c
[ ] 企业提供/协助配置 API v3 密钥、商户 API 私钥、证书序列号
[ ] 后端 .env 增加 WECHAT_PAY_* 配置(密钥文件放安全目录,权限 600)
[ ] 后端新增微信支付下单接口(JSAPI)
[ ] 后端新增支付回调 notify 接口并验签/解密回调
[ ] 数据库增加订单/支付流水表(含微信订单号、支付状态、金额、用户 id)
[ ] 小程序端接入 wx.requestPayment
[ ] 真机联调:下单 → 调起支付 → 支付成功回调 → 订单状态更新
[ ] 决定是否开发程序内退款功能(默认第一版不做,商户平台人工退款)
```

---

## 二十、动态图生成方案(2026-07-07)

### 1. 背景与结论

当前项目已接入 aceclaude 的 OpenAI API `gpt-image-2` 模型生成静态贺卡图片。若要在微信小程序中增加“动态贺卡”,第一版不建议直接做复杂视频生成,而建议采用 **静态主图 + 2 帧/多帧 GIF 动图** 的方式落地。

推荐结论:

```text
AI 生成静态贺卡主图
→ 后端基于主图生成 2 帧局部变化图
→ ffmpeg 合成循环 GIF
→ 上传 COS
→ 小程序用 <image> 展示 GIF
```

该方案最适合贺卡类轻动态效果,如蜡烛发光、星光闪烁、爱心发光、金粉闪耀等。复杂人物动作如挥手、手势变化容易导致人物五官、文字、构图漂移,建议后续再做。

### 2. 为什么优先做 GIF

| 方案 | 优点 | 缺点 | 结论 |
|------|------|------|------|
| GIF | 小程序 `<image>` 可直接展示,接入简单,保存链路接近静态图 | 体积可能偏大,颜色压缩 | 第一版推荐 |
| MP4 | 体积小、流畅度好 | 需 `<video>`,保存/分享体验复杂 | 后续可选 |
| WebP 动图 | 体积优于 GIF | 微信端兼容性与相册保存稳定性不如 GIF | 不优先 |
| 视频生成模型 | 效果更强 | 成本高、耗时长、接口复杂 | 后续高级功能 |

第一版动态图目标不是短视频,而是让静态贺卡“轻微动起来”,因此 2-4 帧 GIF 足够。

### 3. 首批推荐动效

优先做“局部装饰变化”,不要一开始做大幅人物动作。

| 动效 | 稳定性 | 说明 |
|------|--------|------|
| 烛光闪烁 | 高 | 原图不变,只增强火焰/暖光光晕 |
| 星光闪烁 | 高 | 叠加不同透明度星点 |
| 爱心发光 | 高 | 爱心或祝福元素亮度变化 |
| 金粉闪耀 | 高 | 局部粒子闪动 |
| 雪花闪动 | 中 | 可做 2 帧轻微位移 |
| 手势挥动 | 低 | AI 二次生成易改脸、改手、改字,暂不优先 |

### 4. 两种实现路线

#### 方案 A:AI 生成两张图再合成 GIF

流程:

```text
frame1:正常贺卡
frame2:基于 frame1 生成轻微变化帧
frame1 + frame2 → output.gif
```

示例 prompt(烛光闪烁第 2 帧):

```text
基于上一张贺卡生成第二帧动画效果,保持人物、文字、构图、背景、颜色风格完全一致。
只让蜡烛火焰更明亮,周围增加柔和暖黄色光晕,表现烛光闪烁。
不要改变人物五官、姿势、衣服、祝福文字和整体布局。
```

优点:理论上可做更多语义变化。  
缺点:二次 AI 生成容易导致人物、文字、背景漂移,成本和耗时也翻倍。

#### 方案 B:AI 只生成静态主图,代码生成动效帧(推荐)

流程:

```text
AI 生成一张静态主图
→ 后端复制主图为 frame1
→ 后端在主图上叠加光晕/星点/粒子生成 frame2
→ ffmpeg 合成 GIF
```

优点:
- 只调用一次 AI,成本低;
- 人物、文字、构图完全稳定;
- 后端可控,生成速度快;
- 特别适合烛光、星光、爱心、金粉等装饰类动效。

当前项目第一版建议采用方案 B。

### 5. 后端接口扩展建议

继续复用现有 `/api/card/generate` 异步任务接口,增加两个参数:

```json
POST /api/card/generate
{
  "origin_img": "https://xxx/photo.jpg",
  "prompt": "生日快乐,温馨水彩风格",
  "style": "watercolor",
  "output_type": "gif",
  "motion_type": "candle_glow"
}
```

字段说明:

| 字段 | 说明 |
|------|------|
| `output_type` | `image` 静态图;`gif` 动态图 |
| `motion_type` | 动效类型,如 `candle_glow`、`star_twinkle`、`heart_glow` |

轮询成功后返回:

```json
{
  "task_id": "123",
  "status": "success",
  "result_img": "https://xxx/cards/123.png",
  "result_gif": "https://xxx/cards/123.gif",
  "result_type": "gif"
}
```

### 6. 数据库字段建议

在现有 `cards` 表上增加动态图相关字段:

```sql
ALTER TABLE cards
ADD COLUMN result_gif VARCHAR(255) NULL,
ADD COLUMN result_type VARCHAR(16) DEFAULT 'image',
ADD COLUMN motion_type VARCHAR(32) NULL;
```

含义:

| 字段 | 说明 |
|------|------|
| `result_img` | 静态主图或 GIF 封面图 |
| `result_gif` | 动态 GIF 地址 |
| `result_type` | `image` / `gif` |
| `motion_type` | 动效模板 |

### 7. 后端生成流程

动态图任务建议流程:

```text
/api/card/generate(output_type=gif)
  ↓
创建 cards 记录,status=0,result_type=gif,motion_type=xxx
  ↓
调用 gpt-image-2 生成静态主图
  ↓
转存主图到 COS: cards/<id>.png
  ↓
下载主图到服务器临时目录
  ↓
按 motion_type 生成 2-4 帧 PNG
  ↓
ffmpeg 合成循环 GIF
  ↓
上传 GIF 到 COS: cards/<id>.gif(Content-Type:image/gif)
  ↓
写回 cards.result_img / result_gif / status=1
```

### 8. ffmpeg 合成 GIF

服务器安装:

```bash
sudo apt install -y ffmpeg
```

示例命令:

```bash
ffmpeg -y \
  -framerate 2 \
  -i frame%d.png \
  -vf "scale=768:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" \
  -loop 0 \
  output.gif
```

说明:
- `-framerate 2`:每秒 2 帧,适合轻微闪烁;
- `-loop 0`:无限循环;
- `palettegen/paletteuse`:优化 GIF 色彩质量;
- 建议输出宽度控制在 768 左右,避免文件过大。

### 9. 小程序前端展示与保存

展示时优先使用 `result_gif`,没有 GIF 时回退静态图:

```xml
<image src="{{resultGif || resultImg}}" mode="aspectFit" />
```

保存时沿用现有下载保存逻辑,只是 URL 换成 GIF 优先:

```javascript
const url = this.data.resultGif || this.data.resultImg
wx.downloadFile({
  url,
  success: (res) => {
    wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath })
  }
})
```

注意:
- COS 域名需继续配置在 `downloadFile 合法域名` 中;
- GIF 上传 COS 时需设置 `Content-Type: image/gif`;
- 小程序分享卡片封面通常仍按静态图展示,用户打开小程序后再播放 GIF。

### 10. 文件大小与体验建议

| 项目 | 建议 |
|------|------|
| 尺寸 | 宽 768 左右,保持 4:3 或当前贺卡比例 |
| 帧数 | 2-4 帧 |
| 帧率 | 1.5-3 fps |
| 循环 | 无限循环 |
| 文件大小 | 尽量控制在 1-3MB |
| 首批模板 | 烛光闪烁、星光闪烁、爱心发光 |

### 11. 实施 TODO

```text
[ ] 后端安装/确认 ffmpeg 可用
[ ] cards 表增加 result_gif / result_type / motion_type 字段
[ ] /api/card/generate 接收 output_type 与 motion_type
[ ] 新增 GIF 生成服务:静态主图 → 动效帧 → output.gif
[ ] COS 上传支持 image/gif Content-Type
[ ] /api/card/status 返回 result_gif / result_type
[ ] 小程序首页增加「静态贺卡 / 动态贺卡」选择
[ ] 小程序增加动效模板选择:candle_glow / star_twinkle / heart_glow
[ ] 结果展示与保存优先使用 result_gif
[ ] 真机测试:生成 → 预览 GIF → 保存到相册 → 分享打开后播放
```
