# Cozsweet 独立付费图片包前端接口

## 1. 用途

Cozsweet 前端通过本接口展示独立的 8/15 张私密图片包，并使用用户积分解锁。FB 只导入原始图片素材，后端素材池负责跨批次自动凑成 8 张或 15 张图片包。该功能不走现金支付，不使用旧 `/moments` 接口。

## 2. 接口地址

| 功能 | 方法 | 路径 |
| --- | --- | --- |
| 图片包列表 | GET | `/api/private-room/albums` |
| 积分解锁 | POST | `/api/private-room/albums/{albumId}/unlock` |

## 4. 固定价格

| 图片数量 | `unlockCost` | 单张价格 |
| ---: | ---: | ---: |
| 8 | 320 credits | 40 credits |
| 15 | 600 credits | 40 credits |

接口只会返回 `currency: "credits"`。前端不得显示现金价格，也不得调用支付套餐或创建订单接口。

## 5. GET /api/private-room/albums

### 请求参数

| 参数 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| `character` | string | 否 | `elio` | 角色 ID。 |
| `collectionKey` | string | 否 | 全部 | 只查看一个素材分组。 |
| `limit` | integer | 否 | 20 | 1-50。 |
| `cursor` | ISO datetime | 否 | - | 上一页返回的 `nextCursor`。 |

### 请求示例

```bash
curl 'https://api.banlv-ai.com/api/private-room/albums?character=elio&limit=20' \
  -H 'Authorization: Bearer <TOKEN>'
```

### 锁定图片包响应

```json
{
  "code": 200,
  "message": "success",
  "success": true,
  "data": {
    "items": [
      {
        "albumId": "a1b2c3d4-0000-0000-0000-000000000000",
        "momentId": "album:a1b2c3d4-0000-0000-0000-000000000000",
        "characterId": "elio",
        "collectionKey": "manila_202607",
        "title": "Private Manila set",
        "content": null,
        "previewText": "Only for you.",
        "imageCount": 8,
        "mediaCount": 8,
        "images": [
          {
            "url": "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg",
            "type": "image",
            "locked": true,
            "index": 0
          }
        ],
        "locked": true,
        "unlocked": false,
        "unlockCost": 320,
        "requiredCredits": 320,
        "creditCostPerImage": 40,
        "currency": "credits",
        "canUnlockWithCredits": false,
        "publishedAt": "2026-07-13T00:00:00+00:00",
        "lockDetail": {
          "locked": true,
          "showContent": false,
          "showUpgrade": true,
          "reason": "private_album",
          "requiredCredits": 320,
          "currentCredits": 100,
          "shortfallCredits": 220,
          "mediaCount": 8,
          "unlockCostPerImage": 40
        }
      }
    ],
    "nextCursor": null,
    "hasMore": false,
    "creditBalance": 100,
    "currency": "credits",
    "creditCostPerImage": 40,
    "packageOptions": [
      {"imageCount": 8, "creditCost": 320},
      {"imageCount": 15, "creditCost": 600}
    ]
  }
}
```

锁定和解锁状态都会返回完整 `images[].url`。前端必须根据 `locked` / `unlocked` / `lockDetail.locked` 决定是否显示锁层和是否允许查看原图，不能用 URL 是否为空判断解锁状态。

### 已解锁图片包

同一个用户解锁后，再次请求列表会返回：

```json
{
  "albumId": "a1b2c3d4-0000-0000-0000-000000000000",
  "locked": false,
  "unlocked": true,
  "content": "Only for you.",
  "imageCount": 8,
  "images": [
    {
      "url": "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg",
      "type": "image",
      "locked": false,
      "index": 0
    }
  ]
}
```

### 分页

`hasMore=true` 时，把 `nextCursor` 原样传回：

```http
GET /api/private-room/albums?cursor=<nextCursor>&limit=20
```

## 6. POST /api/private-room/albums/{albumId}/unlock

### 请求格式

```http
Content-Type: application/json
Authorization: Bearer <TOKEN>
```

Body：

```json
{
  "expectedCost": 320
}
```

| 字段 | 类型 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | --- |
| `expectedCost` | integer | 否但建议 | 320 | 用户确认时看到的价格；价格变化则拒绝扣分。 |

### 请求示例

```bash
curl -X POST 'https://api.banlv-ai.com/api/private-room/albums/a1b2c3d4-0000-0000-0000-000000000000/unlock' \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"expectedCost":320}'
```

### 解锁成功

```json
{
  "code": 200,
  "message": "success",
  "success": true,
  "data": {
    "albumId": "a1b2c3d4-0000-0000-0000-000000000000",
    "locked": false,
    "unlocked": true,
    "reason": "ok",
    "unlockCost": 320,
    "creditsCharged": 320,
    "previousCreditBalance": 500,
    "creditBalance": 180,
    "images": [
      {
        "url": "https://...",
        "type": "image",
        "locked": false,
        "index": 0
      }
    ]
  }
}
```

前端直接使用响应里的 `images` 替换锁卡，并用 `creditBalance` 刷新余额。

### 积分不足

业务失败仍返回 HTTP 200：

```json
{
  "code": 200,
  "message": "success",
  "success": true,
  "data": {
    "albumId": "a1b2c3d4-0000-0000-0000-000000000000",
    "locked": true,
    "unlocked": false,
    "reason": "insufficient_credits",
    "unlockCost": 320,
    "requiredCredits": 320,
    "creditBalance": 100,
    "shortfallCredits": 220,
    "creditsCharged": 0,
    "images": [
      {"url": "https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg", "type": "image", "locked": true, "index": 0}
    ]
  }
}
```

前端可以打开积分充值页，但后端不会创建图片包现金订单。用户充值积分后，再次点击同一个解锁接口。

### 重复解锁

```json
{
  "unlocked": true,
  "locked": false,
  "reason": "already_unlocked",
  "creditsCharged": 0,
  "creditBalance": 180,
  "images": [{"url": "https://...", "locked": false, "index": 0}]
}
```

## 7. `reason` 处理

| `data.reason` | 含义 | 前端动作 |
| --- | --- | --- |
| `ok` | 已扣积分并解锁 | 显示真实图片并更新余额。 |
| `already_unlocked` | 以前已解锁，本次不扣 | 直接显示返回图片。 |
| `insufficient_credits` | 积分不足，未扣分 | 保持锁定，可引导购买积分。 |
| `cost_changed` | 价格与 `expectedCost` 不一致 | 刷新列表并重新确认。 |
| `unlock_in_progress` | 同一图片包正在并发解锁 | 禁用按钮并稍后刷新。 |
| `deduct_failed` | 扣积分失败 | 保持锁定并允许重试。 |
| `persist_failed_refunded` | 解锁记录失败，积分已尽力退回 | 保持锁定、刷新余额并告警。 |
| `not_found` | 图片包不存在或已停用 | 移除卡片。 |

## 8. TypeScript 建议

```ts
export interface PrivateAlbumImage {
  url: string;
  type: "image";
  locked: boolean;
  index: number;
}

export interface PrivateAlbum {
  albumId: string;
  momentId: string;
  characterId: string;
  collectionKey: string;
  title: string;
  content: string | null;
  previewText: string;
  imageCount: 8 | 15;
  mediaCount: 8 | 15;
  images: PrivateAlbumImage[];
  locked: boolean;
  unlocked: boolean;
  unlockCost: 320 | 600;
  requiredCredits: 320 | 600;
  creditCostPerImage: 40;
  currency: "credits";
  canUnlockWithCredits: boolean;
  publishedAt: string;
}
```

## 9. 前端流程

1. 进入图片包页面，调用 `GET /api/private-room/albums`。
2. `locked=true` 时可使用返回 URL 渲染封面/模糊图，但必须覆盖锁层并禁止打开原图。
3. 用户确认后调用解锁接口并传当前 `expectedCost`。
4. `reason=ok/already_unlocked` 时使用返回的真实图片。
5. `reason=insufficient_credits` 时保持锁定并引导购买积分。
6. 充值完成后重新调用解锁接口，不需要恢复现金订单。
7. 解锁请求进行中禁用按钮，避免重复点击。

## 10. HTTP 错误

| HTTP 状态 | 原因 | 处理 |
| ---: | --- | --- |
| 401 | Token 无效或缺失 | 刷新 Token/重新登录。 |
| 422 | `albumId`、query 或 body 格式错误 | 修正请求。 |
| 500 | 数据库或服务异常 | 保持当前锁定状态并允许重试。 |

## 11. 测试方法

1. 先在 pro 执行 `database/private-albums-migration.sql`。
2. 使用 FB 测试导入生成一个 8 张图片包。
3. 用少于 320 积分的测试账号请求解锁，确认不扣积分、不出现现金订单。
4. 给测试账号补足积分，再请求解锁，确认扣 320 且返回 8 个真实 URL。
5. 重复解锁，确认 `creditsCharged=0`。
6. 刷新列表，确认仍为 `unlocked=true`。
7. 15 张图片包按同样方式验证扣 600。
8. 测试结束后删除测试素材、图片包、图片和解锁记录。
