feat(private-zone): add paid video moments
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user