feat(private-room): connect moments feed
Docker Image / Build and Push Docker Image (push) Successful in 9m29s

This commit is contained in:
2026-07-08 16:29:08 +08:00
parent 77b496da2b
commit c46b9b4cdd
41 changed files with 3107 additions and 281 deletions
+973
View File
@@ -0,0 +1,973 @@
# Cozsweet Private Room / Moments Frontend API
Last verified: 2026-07-08
This document is for the frontend integration of the Elio private room / Moments-like feed. The feature is backend-only in phase 1: posts come from existing schedule images, all posts are locked by default, and users spend credits to unlock the real image URLs.
## 1. Scope
The frontend needs to support three backend endpoints:
| Purpose | Method | Path |
| --- | --- | --- |
| Get private-room config | `GET` | `/api/private-room/config` |
| Get locked/unlocked Moments feed | `GET` | `/api/private-room/moments` |
| Unlock one Moment post | `POST` | `/api/private-room/moments/{momentId}/unlock` |
Recommended production base for cozsweet frontend:
```text
https://api.cozsweet.com
```
Project production API base is also:
```text
https://api.banlv-ai.com
```
Use the same API base already used by the cozsweet frontend login/chat client. Do not hardcode both in the same frontend flow.
## 2. Authentication
All three endpoints require the normal user Bearer token.
```http
Authorization: Bearer <TOKEN>
```
If the token is missing or invalid, production returns:
```json
{
"code": 401,
"message": "Not authenticated",
"success": false,
"data": null
}
```
Frontend action:
- If the user is a guest, use the existing guest token flow first.
- If the user is logged in, use the existing login token.
- If this API returns 401, refresh/re-login using the existing auth behavior.
## 3. Field Mapping vs Payment Plans
Do not treat private-room unlock price as a payment amount.
| Area | Price field | Currency field | Meaning |
| --- | --- | --- | --- |
| Payment plans | `amountCents` | `currency`, for example `PHP` or `USD` | Cash price in display cents. `14990` + `PHP` means `PHP 149.90`. |
| Payment plans | `originalAmountCents` | `currency` | Original crossed-out cash price. |
| Payment plans | `dailyPriceCents` | `currency` | VIP daily cash price, if applicable. |
| Private room | `unlockCost` | `currency: "credits"` | Total credits required to unlock this post. |
| Private room | `unlockCostPerImage` | `currency: "credits"` | Credits required per image. Currently `40`. |
| Private room | `requiredCredits` | `currency: "credits"` | Same unlock requirement, useful for lock/paywall UI. |
Unlock state fields:
| Field | Type | Meaning | Frontend usage |
| --- | --- | --- | --- |
| `locked` | boolean | `true` means the post is locked. | Primary field for locked UI. |
| `unlocked` | boolean | `true` means the post has been unlocked for this user. | Primary field for unlocked UI if preferred. |
| `lockDetail.locked` | boolean | Same lock decision in chat-compatible structure. | Useful if sharing lock/paywall components with chat. |
| `images[].locked` | boolean | Whether this image placeholder is locked. | Use for image tile state. |
| `images[].url` | string or null | Real image URL only after unlock. | Render `<img src={url}>` only when not null. |
Recommended frontend condition:
```ts
const isLocked = item.locked || item.lockDetail?.locked;
```
## 4. Data Source and Image Access
Phase 1 data source:
| Source | Meaning |
| --- | --- |
| Table | `elio_schedules` |
| Single image field | `image_url` |
| Multi-image fields | `image_urls`, `image_paths`, metadata inside `content` |
| Returned post id | `momentId`, for example `schedule:91` |
Locked posts deliberately do not expose real image URLs:
```json
{
"images": [
{
"url": null,
"type": "image",
"locked": true,
"index": 0
}
]
}
```
After unlock, the same image item returns a real public Supabase Storage URL:
```json
{
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
]
}
```
Frontend can render it directly:
```tsx
{image.url ? <img src={image.url} alt="" /> : <LockedImageTile />}
```
Production image accessibility was verified on 2026-07-08:
```text
HTTP 200 OK
Content-Type: image/jpeg
Access-Control-Allow-Origin: *
```
So the frontend does not need an extra image proxy for the current Supabase public URLs.
## 5. Common Response Wrapper
All successful API calls use the standard wrapper:
```ts
interface ApiResponse<T> {
code: number;
message: string;
success: boolean;
data: T | null;
}
```
Important: unlock business failures can still return HTTP 200 with `success: true`, but `data.unlocked` is `false`. For unlock UI, check `data.unlocked`, `data.locked`, and `data.reason`, not only HTTP status.
## 6. GET /api/private-room/config
### Purpose
Get the current user's private-room config, default unlock price, current credit balance, and Elio profile info.
### Request URL
```http
GET https://api.cozsweet.com/api/private-room/config
```
### Request Format
No query params and no body.
### Request Example
```bash
curl -X GET "https://api.cozsweet.com/api/private-room/config" \
-H "Authorization: Bearer <TOKEN>"
```
### Success Response
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"currency": "credits",
"unlockCostDefault": 40,
"unlockCostPerImage": 40,
"source": "elio_schedules",
"creditBalance": 100,
"profile": {
"characterId": "elio",
"displayName": "Elio Silvestri",
"authorName": "Elio Silvestri",
"avatarUrl": null,
"coverUrl": null,
"title": "Elio Private room",
"subtitle": "Join me, unlock my private room"
}
}
}
```
### Key Fields
| Field | Type | Meaning |
| --- | --- | --- |
| `currency` | string | Always `"credits"` for this feature. |
| `unlockCostDefault` | number | Default single-image unlock price. Currently `40`. |
| `unlockCostPerImage` | number | Price per image. Currently `40`. |
| `creditBalance` | number | User's current credits. |
| `profile` | object | Elio private-room profile. |
### Frontend Usage
Call it when entering the private room page if the frontend needs profile/header data before loading the feed. Otherwise, the feed endpoint also returns `profile`, `creditBalance`, and pricing fields.
## 7. GET /api/private-room/moments
### Purpose
Get the private-room feed. Locked posts return placeholders and unlock prices; already-unlocked posts return real image URLs.
### Request URL
```http
GET https://api.cozsweet.com/api/private-room/moments?character=elio&limit=20
```
### Query Parameters
| Field | Type | Required | Example | Meaning | Validation |
| --- | --- | --- | --- | --- | --- |
| `character` | string | No | `elio` | Character/private-room id. Phase 1 supports `elio`. | Defaults to `elio`. |
| `limit` | number | No | `20` | Page size. | Min `1`, max `50`, defaults to `20`. |
| `cursor` | string | No | `1783488000` | Pagination cursor from previous `data.nextCursor`. | Omit for first page. |
### Request Example
```bash
curl -X GET "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=20" \
-H "Authorization: Bearer <TOKEN>"
```
### Locked Post Response Example
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"profile": {
"characterId": "elio",
"displayName": "Elio Silvestri",
"authorName": "Elio Silvestri",
"avatarUrl": null,
"coverUrl": null,
"title": "Elio Private room",
"subtitle": "Join me, unlock my private room"
},
"items": [
{
"momentId": "schedule:91",
"source": "elio_schedules",
"sourceId": "91",
"characterId": "elio",
"author": {
"id": "elio",
"name": "Elio Silvestri",
"avatarUrl": null
},
"createdAt": "2026-07-01T08:11:35+00:00",
"publishedAt": "2026-07-01T08:11:35+00:00",
"timeText": "7天前",
"title": "Paris morning",
"content": null,
"text": null,
"textPreview": "Unlock to view private room photos",
"mediaCount": 1,
"images": [
{
"url": null,
"type": "image",
"locked": true,
"index": 0
}
],
"locked": true,
"unlocked": false,
"unlockCost": 40,
"unlockCostPerImage": 40,
"requiredCredits": 40,
"currency": "credits",
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "private_room_moment",
"hint": "40 credits · Unlock photo",
"actionLabel": "Unlock",
"type": "private_room_moment",
"requiredCredits": 40,
"currentCredits": 100,
"shortfallCredits": 0,
"mediaCount": 1,
"unlockCostPerImage": 40,
"detail": {
"type": "private_room_moment",
"requiredCredits": 40,
"currentCredits": 100,
"shortfallCredits": 0,
"mediaCount": 1,
"unlockCostPerImage": 40
}
}
}
],
"nextCursor": null,
"hasMore": false,
"creditBalance": 100,
"unlockCostDefault": 40,
"unlockCostPerImage": 40,
"currency": "credits",
"source": "elio_schedules"
}
}
```
### Already-Unlocked Post Shape
When the user has already unlocked the post, the list endpoint returns real image URLs directly:
```json
{
"momentId": "schedule:91",
"locked": false,
"unlocked": true,
"content": "A quiet morning in Paris.",
"text": "A quiet morning in Paris.",
"mediaCount": 1,
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
],
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false
}
}
```
### Key Item Fields
| Field | Type | Meaning |
| --- | --- | --- |
| `momentId` | string | Post id. Use this for unlock. Example: `schedule:91`. |
| `source` | string | Current source table, `elio_schedules`. |
| `sourceId` | string | Schedule row id. |
| `title` | string | Optional post title. |
| `content` | string or null | Real text content. Null when locked. |
| `text` | string or null | Same as content, for frontend naming compatibility. |
| `textPreview` | string | Preview text shown while locked. |
| `mediaCount` | number | Number of images in this post. |
| `images` | array | Image placeholders when locked; real URLs when unlocked. |
| `locked` | boolean | Whether this post is locked. |
| `unlocked` | boolean | Whether this post is unlocked. |
| `unlockCost` | number | Total credits required for this post. |
| `unlockCostPerImage` | number | Credits per image. |
| `requiredCredits` | number | Required credits for lock UI. |
| `currency` | string | Always `"credits"`. |
| `lockDetail` | object | Chat-compatible lock detail. |
### Pagination
If `data.hasMore` is true, call the same endpoint with:
```text
cursor=<data.nextCursor>
```
## 8. POST /api/private-room/moments/{momentId}/unlock
### Purpose
Unlock one private-room post, deduct credits, persist the unlock state, and return real image URLs.
### Request URL
`momentId` must be URL encoded because it contains `:`.
```http
POST https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock
```
Frontend example:
```ts
const url = `/api/private-room/moments/${encodeURIComponent(momentId)}/unlock`;
```
### Request Format
Content-Type:
```http
Content-Type: application/json
```
Body is optional, but recommended:
```json
{
"expectedCost": 40
}
```
### Body Parameters
| Field | Type | Required | Example | Meaning |
| --- | --- | --- | --- | --- |
| `expectedCost` | number | No | `40` | The price shown to the user. If backend price changed, unlock returns `reason: "cost_changed"` instead of charging. |
### Request Example
```bash
curl -X POST "https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock" \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"expectedCost":40}'
```
### Unlock Success Response
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"momentId": "schedule:91",
"source": "elio_schedules",
"sourceId": "91",
"characterId": "elio",
"content": "A quiet morning in Paris.",
"text": "A quiet morning in Paris.",
"mediaCount": 1,
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
],
"locked": false,
"unlocked": true,
"unlockCost": 40,
"unlockCostPerImage": 40,
"requiredCredits": 40,
"currency": "credits",
"reason": "ok",
"creditsCharged": 40,
"creditBalance": 60,
"previousCreditBalance": 100,
"persisted": true,
"lockDetail": {
"locked": false,
"showContent": true,
"showUpgrade": false,
"reason": null,
"hint": null,
"actionLabel": null,
"type": "private_room_moment",
"requiredCredits": 40,
"currentCredits": 60,
"shortfallCredits": 0,
"mediaCount": 1,
"unlockCostPerImage": 40
}
}
}
```
### Already Unlocked Response
If the same user unlocks the same post again, backend does not charge again:
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"momentId": "schedule:91",
"locked": false,
"unlocked": true,
"reason": "already_unlocked",
"creditsCharged": 0,
"creditBalance": 60,
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
]
}
}
```
### Insufficient Credits Response
This is a business failure, but it still returns HTTP 200 and `success: true`.
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"momentId": "schedule:91",
"locked": true,
"unlocked": false,
"reason": "insufficient_credits",
"requiredCredits": 40,
"currentCredits": 10,
"shortfallCredits": 30,
"creditBalance": 10,
"images": [
{
"url": null,
"type": "image",
"locked": true,
"index": 0
}
],
"lockDetail": {
"locked": true,
"showContent": false,
"showUpgrade": true,
"reason": "insufficient_credits",
"hint": "Not enough credits to unlock this private room post.",
"actionLabel": "Unlock",
"requiredCredits": 40,
"currentCredits": 10,
"shortfallCredits": 30,
"mediaCount": 1,
"unlockCostPerImage": 40
}
}
}
```
Frontend action:
- Keep the post locked.
- Show recharge / insufficient credits UI.
- Do not try to render `images[].url` because it remains null.
### Cost Changed Response
If `expectedCost` does not match the backend price:
```json
{
"code": 200,
"message": "success",
"success": true,
"data": {
"momentId": "schedule:91",
"locked": true,
"unlocked": false,
"reason": "cost_changed",
"requiredCredits": 40,
"lockDetail": {
"locked": true,
"reason": "cost_changed",
"hint": "Unlock price changed. Please refresh and try again."
}
}
}
```
Frontend action:
- Refresh the feed item.
- Ask the user to confirm again with the latest `unlockCost`.
### Other Business Reasons
| `data.reason` | Meaning | Frontend action |
| --- | --- | --- |
| `ok` | Unlock succeeded and credits were charged. | Show real images and refresh credit balance. |
| `already_unlocked` | User had unlocked before. No charge. | Show real images. |
| `insufficient_credits` | Not enough credits. No charge. | Keep locked and open recharge UI. |
| `cost_changed` | Frontend expected cost differs from backend. No charge. | Refresh and ask user to confirm again. |
| `not_found` | Post no longer exists. | Remove item or show unavailable state. |
| `no_media` | Post has no images to unlock. | Hide unlock button. |
| `deduct_failed` | Credit deduction failed. | Keep locked and allow retry. |
| `ok_persist_failed` | Credits charged and images returned, but unlock record fallback may have been used. | Show images; optionally refresh list. |
## 9. TypeScript Types
```ts
export interface PrivateRoomProfile {
characterId: string;
displayName: string;
authorName: string;
avatarUrl: string | null;
coverUrl: string | null;
title: string;
subtitle: string;
}
export interface PrivateRoomImage {
url: string | null;
type: "image";
locked: boolean;
index: number;
}
export interface PrivateRoomLockDetail {
locked: boolean;
showContent: boolean;
showUpgrade: boolean;
reason?: string | null;
hint?: string | null;
actionLabel?: string | null;
type?: "private_room_moment";
requiredCredits?: number;
currentCredits?: number | null;
shortfallCredits?: number;
mediaCount?: number;
unlockCostPerImage?: number;
detail?: {
type: "private_room_moment";
requiredCredits: number;
currentCredits: number | null;
shortfallCredits: number;
mediaCount: number;
unlockCostPerImage: number;
};
}
export interface PrivateRoomMoment {
momentId: string;
source: "elio_schedules";
sourceId: string;
characterId: string;
author: {
id: string;
name: string;
avatarUrl: string | null;
};
createdAt: string | null;
publishedAt: string | null;
timeText: string;
title: string;
content: string | null;
text: string | null;
textPreview: string;
mediaCount: number;
images: PrivateRoomImage[];
locked: boolean;
unlocked: boolean;
unlockCost: number;
unlockCostPerImage: number;
requiredCredits: number;
currency: "credits";
lockDetail: PrivateRoomLockDetail;
}
export interface PrivateRoomMomentsData {
profile: PrivateRoomProfile;
items: PrivateRoomMoment[];
nextCursor: string | null;
hasMore: boolean;
creditBalance: number;
unlockCostDefault: number;
unlockCostPerImage: number;
currency: "credits";
source: "elio_schedules";
}
export interface PrivateRoomUnlockData extends PrivateRoomMoment {
reason:
| "ok"
| "already_unlocked"
| "insufficient_credits"
| "cost_changed"
| "not_found"
| "no_media"
| "deduct_failed"
| "ok_persist_failed";
creditsCharged?: number;
creditBalance?: number;
previousCreditBalance?: number;
currentCredits?: number;
shortfallCredits?: number;
persisted?: boolean;
}
```
## 10. Recommended Frontend Flow
1. User enters private room page.
2. Call `GET /api/private-room/moments?character=elio&limit=20`.
3. Render each item:
- If `item.locked === true`, show locked image tiles and unlock price.
- If `item.locked === false`, render `images[].url`.
4. User taps unlock.
5. Show confirm UI with `item.unlockCost` and `item.currency`.
6. Call:
```ts
await api.post(
`/api/private-room/moments/${encodeURIComponent(item.momentId)}/unlock`,
{ expectedCost: item.unlockCost },
);
```
7. On response:
- If `data.unlocked === true`, replace the current card with response `data`.
- If `data.reason === "insufficient_credits"`, keep locked and open recharge UI.
- If `data.reason === "cost_changed"`, refresh list and ask for confirmation again.
- For other failure reasons, show a retry/unavailable message.
8. Update visible credit balance from `data.creditBalance` if present.
## 11. UI Rules
- Locked state must never render `images[].url` if it is null.
- Do not show broken images for locked posts; use lock tiles based on `mediaCount`.
- Use `unlockCost` for the button price, not `amountCents`.
- Use `currency === "credits"` to display "credits", not PHP/USD.
- For multi-image posts, display total price:
```ts
`${item.unlockCost} credits`
```
Optionally show per-image detail:
```ts
`${item.unlockCostPerImage} credits/photo`
```
- After successful unlock, do not call any separate image API. Use `data.images`.
- To avoid double tapping, disable the unlock button while the POST request is pending.
## 12. Failure Handling
| Case | How to detect | Frontend handling |
| --- | --- | --- |
| Not logged in | HTTP 401 or wrapper `code: 401` | Refresh token, guest token, or login. |
| Validation error | HTTP 422 | Check path/body format, especially encoded `momentId`. |
| Insufficient credits | HTTP 200 with `data.reason === "insufficient_credits"` | Open recharge UI. |
| Price changed | HTTP 200 with `data.reason === "cost_changed"` | Refresh item and reconfirm. |
| Already unlocked | HTTP 200 with `data.reason === "already_unlocked"` | Show returned images, no charge. |
| Deduct failed | HTTP 200 with `data.reason === "deduct_failed"` | Keep locked and allow retry. |
| Network/API error | request failed or 5xx | Keep current state and show retry. |
## 13. Production-Safe Test Method
These tests do not require creating posts.
### 13.1 Auth Required
```bash
curl -i "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=1"
```
Expected:
```text
HTTP/1.1 401 Unauthorized
```
### 13.2 Get Config
```bash
curl -X GET "https://api.cozsweet.com/api/private-room/config" \
-H "Authorization: Bearer <TOKEN>"
```
Expected:
- `data.currency === "credits"`
- `data.unlockCostPerImage === 40`
- `data.creditBalance` is a number
### 13.3 Get Locked Feed
```bash
curl -X GET "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=1" \
-H "Authorization: Bearer <TOKEN>"
```
Expected for a not-yet-unlocked post:
- `data.items[0].locked === true`
- `data.items[0].unlocked === false`
- `data.items[0].unlockCost === data.items[0].mediaCount * 40`
- `data.items[0].images[0].url === null`
### 13.4 Unlock With Insufficient Credits
Use a test/guest account with fewer credits than `unlockCost`.
```bash
curl -X POST "https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock" \
-H "Authorization: Bearer <TOKEN>" \
-H "Content-Type: application/json" \
-d '{"expectedCost":40}'
```
Expected:
- HTTP 200
- `data.unlocked === false`
- `data.locked === true`
- `data.reason === "insufficient_credits"`
- `data.images[0].url === null`
### 13.5 Unlock Success
Use only a test account that intentionally has enough credits. This deducts real credits.
Expected:
- `data.unlocked === true`
- `data.locked === false`
- `data.creditsCharged === data.unlockCost`
- `data.images[0].url` is a non-empty HTTPS URL
- Repeating the same unlock should return `reason: "already_unlocked"` and `creditsCharged: 0`
## 14. Confirmed Production Evidence
Verified on 2026-07-08:
```text
GET https://api.cozsweet.com/health
HTTP 200
```
Private-room endpoints require auth:
```text
GET /api/private-room/moments without Bearer token
HTTP 401 Not authenticated
```
Backend generated locked item shape:
```json
{
"momentId": "schedule:91",
"mediaCount": 1,
"unlockCost": 40,
"unlockCostPerImage": 40,
"currency": "credits",
"locked": true,
"unlocked": false,
"requiredCredits": 40,
"images": [
{
"url": null,
"type": "image",
"locked": true,
"index": 0
}
],
"lockDetail": {
"locked": true,
"requiredCredits": 40
}
}
```
Backend generated unlocked image shape:
```json
{
"momentId": "schedule:91",
"locked": false,
"unlocked": true,
"unlockCost": 40,
"currency": "credits",
"images": [
{
"url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
"type": "image",
"locked": false,
"index": 0
}
],
"lockDetail": {
"locked": false
}
}
```
The sample real image URL returned:
```text
HTTP 200 OK
Content-Type: image/jpeg
Access-Control-Allow-Origin: *
```
## 15. Database / Persistence Notes
Migration file:
```text
database/private-room-migration.sql
```
Full local path:
```text
C:\windows_banlv\ai-boyfriend\ai-boyfriend-unified\database\private-room-migration.sql
```
Purpose:
- Create `private_room_unlocks`
- Persist `user_id + moment_key`
- Prevent charging again for already-unlocked posts
Current production backend also has a fallback to `users.preferences.private_room_unlocked_moment_keys` if the unlock table is missing, so the feature can work before/without the migration. Running the migration is still recommended for cleaner persistence and future analytics.
## 16. Frontend Integration Checklist
- Use Bearer token for all three endpoints.
- Encode `momentId` in unlock path with `encodeURIComponent`.
- Render locked placeholders when `locked === true`.
- Render real images only when `images[].url` is not null.
- Use `unlockCost` and `currency: "credits"` for unlock price.
- Send `{ expectedCost: item.unlockCost }` when unlocking.
- Treat `data.unlocked === false` as an unlock business failure even if HTTP is 200.
- Update displayed credit balance from `data.creditBalance`.
- On insufficient credits, open recharge flow.
- On success, replace the card with returned `data` or reload the feed.
## 17. Rollback / Compatibility Notes
This document describes the currently deployed backend behavior. Frontend can integrate it without changing existing payment plan fields.
Compatibility:
- Existing payment `/api/payment/plans` is unchanged.
- Existing chat lock UI can reuse `lockDetail.locked`, `requiredCredits`, and `shortfallCredits`.
- Locked private-room items do not leak image URLs.
Rollback if frontend needs to disable the feature:
- Hide the private-room entry point.
- Do not call `/api/private-room/*`.
- No existing chat/payment flow needs to be rolled back.
## 18. Needs Confirmation
- `avatarUrl` and `coverUrl` currently return `null`; frontend should use local/default Elio assets until final profile images are provided.
- Phase 1 content source is `elio_schedules`; if product wants separate Moments content, backend can keep the API contract and change only the source implementation later.
+7
View File
@@ -0,0 +1,7 @@
前端核心逻辑是:
列表接口:GET /api/private-room/moments?character=elio&limit=20
解锁接口:POST /api/private-room/moments/${encodeURIComponent(momentId)}/unlock
价格看 unlockCost,单位是 currency: "credits"
是否解锁看 locked/unlocked
锁定时 images[].url = null
解锁成功后直接用返回的 images[].url