feat(private-zone): add paid video moments
Docker Image / Build and Push Docker Image (push) Successful in 2m5s

This commit is contained in:
Codex
2026-07-24 20:17:28 +08:00
parent 30ab2c2c97
commit 0d5b5c17fa
25 changed files with 1578 additions and 43 deletions
@@ -9,4 +9,5 @@ export * from "./ifeedback_repository";
export * from "./imetrics_repository";
export * from "./ipayment_repository";
export * from "./iprivate_zone_repository";
export * from "./iprivate_zone_post_repository";
export * from "./iuser_repository";
@@ -0,0 +1,21 @@
import type {
PrivateZonePostsResponse,
PrivateZonePostUnlockResponse,
} from "@/data/schemas/private-zone";
import type { Result } from "@/utils/result";
export interface GetPrivateZonePostsInput {
characterId: string;
limit?: number;
cursor?: string | null;
}
export interface IPrivateZonePostRepository {
getPosts(
input: GetPrivateZonePostsInput,
): Promise<Result<PrivateZonePostsResponse>>;
unlockPost(
postId: string,
expectedCost: number,
): Promise<Result<PrivateZonePostUnlockResponse>>;
}
@@ -0,0 +1,43 @@
import type {
GetPrivateZonePostsInput,
IPrivateZonePostRepository,
} from "@/data/repositories/interfaces";
import {
UnlockPrivateZonePostRequestSchema,
type PrivateZonePostsResponse,
type PrivateZonePostUnlockResponse,
} from "@/data/schemas/private-zone";
import {
PrivateZonePostApi,
privateZonePostApi,
} from "@/data/services/api/private_zone_post_api";
import { Result } from "@/utils/result";
import { createLazySingleton } from "./lazy_singleton";
export class PrivateZonePostRepository implements IPrivateZonePostRepository {
constructor(private readonly api: PrivateZonePostApi) {}
getPosts(
input: GetPrivateZonePostsInput,
): Promise<Result<PrivateZonePostsResponse>> {
return Result.wrap(() => this.api.getPosts(input));
}
unlockPost(
postId: string,
expectedCost: number,
): Promise<Result<PrivateZonePostUnlockResponse>> {
return Result.wrap(() =>
this.api.unlockPost(
postId,
UnlockPrivateZonePostRequestSchema.parse({ expectedCost }),
),
);
}
}
export const getPrivateZonePostRepository =
createLazySingleton<IPrivateZonePostRepository>(
() => new PrivateZonePostRepository(privateZonePostApi),
);
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
PrivateZonePostUnlockResponseSchema,
PrivateZonePostsResponseSchema,
} from "@/data/schemas/private-zone";
const post = {
postId: "11111111-1111-1111-1111-111111111111",
characterId: "maya-tan",
caption: "Only for you.",
posterUrl: "https://storage.example/poster.jpg?token=short",
mediaType: "video",
videoUrl: null,
videoMimeType: "video/mp4",
videoSizeBytes: 1024,
durationSeconds: 18,
unlockCostCredits: 100,
currency: "credits",
locked: true,
unlocked: false,
availableForNewUnlock: true,
publishedAt: "2026-07-24T10:00:00+00:00",
};
describe("Private Zone video post schema", () => {
it("parses a locked post without inventing a video URL", () => {
const response = PrivateZonePostsResponseSchema.parse({
characterId: "maya-tan",
items: [post],
nextCursor: null,
hasMore: false,
creditBalance: 250,
currency: "credits",
});
expect(response.items[0].videoUrl).toBeNull();
expect(response.items[0].posterUrl).toContain("poster.jpg");
expect(response.items[0].unlockCostCredits).toBe(100);
});
it("parses cost changes and insufficient-credit details", () => {
const changed = PrivateZonePostUnlockResponseSchema.parse({
postId: post.postId,
reason: "cost_changed",
unlocked: false,
creditsCharged: 0,
requiredCredits: 150,
creditBalance: 250,
post: { ...post, unlockCostCredits: 150 },
});
const insufficient = PrivateZonePostUnlockResponseSchema.parse({
postId: post.postId,
reason: "insufficient_credits",
unlocked: false,
creditsCharged: 0,
requiredCredits: 100,
currentCredits: 40,
shortfallCredits: 60,
creditBalance: 40,
post,
});
expect(changed.post?.unlockCostCredits).toBe(150);
expect(insufficient.shortfallCredits).toBe(60);
});
});
+1
View File
@@ -3,6 +3,7 @@
*/
export * from "./private_album";
export * from "./private_zone_post";
export * from "./request/unlock_private_album_request";
export * from "./response/private_album_unlock_response";
export * from "./response/private_albums_response";
@@ -0,0 +1,82 @@
import { z } from "zod";
import {
arrayOrEmpty,
booleanOrFalse,
booleanOrTrue,
numberOrNull,
numberOrZero,
schemaOrNull,
stringOrEmpty,
stringOrNull,
} from "../nullable-defaults";
export const PrivateZoneVideoPostSchema = z
.object({
postId: stringOrEmpty,
characterId: stringOrEmpty,
caption: stringOrEmpty,
posterUrl: stringOrNull,
mediaType: z.literal("video").default("video"),
videoUrl: stringOrNull,
videoMimeType: stringOrEmpty,
videoSizeBytes: numberOrZero,
durationSeconds: numberOrNull,
unlockCostCredits: numberOrZero,
currency: z.literal("credits").default("credits"),
locked: booleanOrFalse,
unlocked: booleanOrFalse,
availableForNewUnlock: booleanOrTrue,
publishedAt: stringOrEmpty,
})
.readonly();
export const PrivateZonePostsResponseSchema = z
.object({
characterId: stringOrEmpty,
items: arrayOrEmpty(PrivateZoneVideoPostSchema),
nextCursor: stringOrNull,
hasMore: booleanOrFalse,
creditBalance: numberOrZero,
currency: z.literal("credits").default("credits"),
})
.readonly();
export const PrivateZonePostUnlockReasonSchema = z.enum([
"ok",
"already_unlocked",
"insufficient_credits",
"cost_changed",
"unavailable",
"deduct_failed",
"not_found",
]);
export const PrivateZonePostUnlockResponseSchema = z
.object({
postId: stringOrEmpty,
reason: PrivateZonePostUnlockReasonSchema.or(stringOrEmpty),
unlocked: booleanOrFalse,
creditsCharged: numberOrZero,
requiredCredits: numberOrZero,
currentCredits: numberOrZero,
shortfallCredits: numberOrZero,
creditBalance: numberOrZero,
post: schemaOrNull(PrivateZoneVideoPostSchema),
})
.readonly();
export const UnlockPrivateZonePostRequestSchema = z
.object({
expectedCost: z.number().int().min(1),
})
.readonly();
export type PrivateZoneVideoPost = z.output<typeof PrivateZoneVideoPostSchema>;
export type PrivateZonePostsResponse = z.output<typeof PrivateZonePostsResponseSchema>;
export type PrivateZonePostUnlockResponse = z.output<
typeof PrivateZonePostUnlockResponseSchema
>;
export type UnlockPrivateZonePostRequest = z.output<
typeof UnlockPrivateZonePostRequestSchema
>;
+2
View File
@@ -26,6 +26,8 @@
"chatUnlockHistory": { "method": "post", "path": "/api/chat/unlock-history" },
"privateZoneAlbums": { "method": "get", "path": "/api/private-zone/albums" },
"privateZoneAlbumUnlock": { "method": "post", "path": "/api/private-zone/albums/{albumId}/unlock" },
"privateZonePosts": { "method": "get", "path": "/api/private-zone/posts" },
"privateZonePostUnlock": { "method": "post", "path": "/api/private-zone/posts/{postId}/unlock" },
"metricsPwaEvent": { "method": "post", "path": "/api/metrics/pwa/event" },
"reportUserInfo": { "method": "post", "path": "/api/data/report-user-info" },
"feedback": { "method": "post", "path": "/api/feedback" },
+11
View File
@@ -105,6 +105,17 @@ export class ApiPath {
);
}
/** 获取 Private Zone 付费视频朋友圈 */
static readonly privateZonePosts = apiContract.privateZonePosts.path;
/** 解锁单条 Private Zone 付费视频朋友圈 */
static privateZonePostUnlock(postId: string): string {
return apiContract.privateZonePostUnlock.path.replace(
"{postId}",
encodeURIComponent(postId),
);
}
// ============ 数据看板相关 ============
/** 上报 PWA 事件 */
static readonly metricsPwaEvent = apiContract.metricsPwaEvent.path;
@@ -0,0 +1,49 @@
import {
PrivateZonePostsResponseSchema,
PrivateZonePostUnlockResponseSchema,
type PrivateZonePostsResponse,
type PrivateZonePostUnlockResponse,
type UnlockPrivateZonePostRequest,
} from "@/data/schemas/private-zone";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export interface GetPrivateZonePostsInput {
characterId: string;
limit?: number;
cursor?: string | null;
}
export class PrivateZonePostApi {
async getPosts(
input: GetPrivateZonePostsInput,
): Promise<PrivateZonePostsResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.privateZonePosts,
{
query: {
characterId: input.characterId,
limit: input.limit ?? 20,
cursor: input.cursor || undefined,
},
},
);
return PrivateZonePostsResponseSchema.parse(unwrap(env));
}
async unlockPost(
postId: string,
body: UnlockPrivateZonePostRequest,
): Promise<PrivateZonePostUnlockResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.privateZonePostUnlock(postId),
{ method: "POST", body },
);
const parsed = PrivateZonePostUnlockResponseSchema.parse(unwrap(env));
return parsed.postId ? parsed : { ...parsed, postId };
}
}
export const privateZonePostApi = new PrivateZonePostApi();