feat(chat): add promotional external entry flow
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UnlockPrivateRequest } from "@/data/dto/chat";
|
||||
|
||||
describe("UnlockPrivateRequest", () => {
|
||||
it("serializes an existing backend message unlock", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({ messageId: "message-1" }).toJson(),
|
||||
).toEqual({ messageId: "message-1" });
|
||||
});
|
||||
|
||||
it("serializes a temporary promotion lock without a fake message id", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
}).toJson(),
|
||||
).toEqual({
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,34 @@ describe("UnlockPrivateResponse", () => {
|
||||
expect(response.lockDetail.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes backend ids, audio aliases, and unlocked images", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
message_id: "backend-message-1",
|
||||
clientLockId: "promotion-1",
|
||||
lockType: "image_paywall",
|
||||
content: null,
|
||||
reply: "Unlocked reply",
|
||||
audio_url: "https://example.com/audio.mp3",
|
||||
image: {
|
||||
type: "promotion",
|
||||
url: "https://example.com/image.jpg",
|
||||
},
|
||||
lockDetail: {
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.messageId).toBe("backend-message-1");
|
||||
expect(response.clientLockId).toBe("promotion-1");
|
||||
expect(response.lockType).toBe("image_paywall");
|
||||
expect(response.content).toBe("Unlocked reply");
|
||||
expect(response.audioUrl).toBe("https://example.com/audio.mp3");
|
||||
expect(response.image.url).toBe("https://example.com/image.jpg");
|
||||
});
|
||||
|
||||
it("parses an insufficient-balance unlock response", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: false,
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from "@/data/schemas/chat/unlock_private_request";
|
||||
|
||||
export class UnlockPrivateRequest {
|
||||
declare readonly messageId: string;
|
||||
declare readonly messageId?: string;
|
||||
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
|
||||
declare readonly clientLockId?: string;
|
||||
|
||||
private constructor(input: UnlockPrivateRequestInput) {
|
||||
const data = UnlockPrivateRequestSchema.parse(input);
|
||||
|
||||
@@ -5,14 +5,19 @@ import {
|
||||
type UnlockPrivateResponseInput,
|
||||
} from "@/data/schemas/chat/unlock_private_response";
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应 DTO。
|
||||
*/
|
||||
export class UnlockPrivateResponse {
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly messageId: string;
|
||||
declare readonly clientLockId: string | null;
|
||||
declare readonly lockType: ChatLockType | null;
|
||||
declare readonly content: string;
|
||||
declare readonly audioUrl: string;
|
||||
declare readonly image: ChatImageData;
|
||||
declare readonly reason: UnlockPrivateReason;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly creditsCharged: number;
|
||||
|
||||
@@ -26,6 +26,7 @@ export class ChatLocalMessageStore {
|
||||
...message.toJson(),
|
||||
content: normalizeUnlockedContent(patch.content, message.content),
|
||||
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
|
||||
image: patch.image ?? message.image,
|
||||
lockDetail: patch.lockDetail ?? {
|
||||
...message.lockDetail,
|
||||
locked: false,
|
||||
@@ -132,6 +133,8 @@ function shouldMarkMessageUnlocked(
|
||||
if (message.lockDetail.locked !== true) return false;
|
||||
return (
|
||||
Boolean(message.image.url) ||
|
||||
message.lockDetail.reason === "image_paywall" ||
|
||||
message.lockDetail.reason === "image" ||
|
||||
message.lockDetail.reason === "private_message" ||
|
||||
message.lockDetail.reason === "voice_message"
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import type { ChatApi } from "@/data/services/api";
|
||||
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
export class ChatRemoteDataSource {
|
||||
@@ -36,10 +37,10 @@ export class ChatRemoteDataSource {
|
||||
}
|
||||
|
||||
async unlockPrivateMessage(
|
||||
messageId: string,
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })),
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from(input)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
CacheRemoteChatMediaInput,
|
||||
ChatMediaLookupInput,
|
||||
IChatRepository,
|
||||
UnlockPrivateMessageInput,
|
||||
UnlockedPrivateMessageLocalPatch,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import type { Result } from "@/utils";
|
||||
@@ -53,9 +54,9 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
async unlockPrivateMessage(
|
||||
messageId: string,
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return this.remote.unlockPrivateMessage(messageId);
|
||||
return this.remote.unlockPrivateMessage(input);
|
||||
}
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
||||
|
||||
export interface ChatMediaLookupInput {
|
||||
@@ -35,9 +36,16 @@ export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
|
||||
export interface UnlockedPrivateMessageLocalPatch {
|
||||
lockDetail?: ChatLockDetailData;
|
||||
audioUrl?: string | null;
|
||||
image?: ChatImageData;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface UnlockPrivateMessageInput {
|
||||
messageId?: string;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
export interface IChatRepository {
|
||||
/** 发送一条消息。 */
|
||||
sendMessage(
|
||||
@@ -49,7 +57,9 @@ export interface IChatRepository {
|
||||
getHistory(limit?: number, offset?: number): Promise<Result<ChatHistoryResponse>>;
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
||||
unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>>;
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
|
||||
|
||||
@@ -3,9 +3,23 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const UnlockPrivateRequestSchema = z.object({
|
||||
messageId: z.string(),
|
||||
});
|
||||
export const ChatLockTypeSchema = z.enum([
|
||||
"voice_message",
|
||||
"image_paywall",
|
||||
"private_message",
|
||||
]);
|
||||
|
||||
export const UnlockPrivateRequestSchema = z
|
||||
.object({
|
||||
messageId: z.string().min(1).optional(),
|
||||
lockType: ChatLockTypeSchema.optional(),
|
||||
clientLockId: z.string().min(1).optional(),
|
||||
})
|
||||
.refine((value) => value.messageId || value.lockType, {
|
||||
message: "messageId or lockType is required",
|
||||
});
|
||||
|
||||
export type ChatLockType = z.output<typeof ChatLockTypeSchema>;
|
||||
|
||||
export type UnlockPrivateRequestInput = z.input<
|
||||
typeof UnlockPrivateRequestSchema
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults";
|
||||
import { ChatLockDetailSchema } from "./chat_payloads";
|
||||
import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
stringOrNull,
|
||||
} from "../nullable-defaults";
|
||||
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
|
||||
import { ChatLockTypeSchema } from "./unlock_private_request";
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应。
|
||||
*/
|
||||
export const UnlockPrivateReasonSchema = stringOrEmpty;
|
||||
|
||||
export const UnlockPrivateResponseSchema = z.object({
|
||||
unlocked: booleanOrFalse,
|
||||
content: stringOrEmpty,
|
||||
audioUrl: stringOrEmpty,
|
||||
reason: UnlockPrivateReasonSchema,
|
||||
creditBalance: numberOrZero,
|
||||
creditsCharged: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
lockDetail: ChatLockDetailSchema,
|
||||
});
|
||||
export const UnlockPrivateResponseSchema = z
|
||||
.object({
|
||||
unlocked: booleanOrFalse,
|
||||
content: stringOrEmpty,
|
||||
reply: stringOrEmpty,
|
||||
messageId: stringOrEmpty,
|
||||
message_id: stringOrEmpty,
|
||||
clientLockId: stringOrNull,
|
||||
lockType: ChatLockTypeSchema.nullable().default(null),
|
||||
audioUrl: stringOrEmpty,
|
||||
audio_url: stringOrEmpty,
|
||||
image: ChatImageSchema,
|
||||
reason: UnlockPrivateReasonSchema,
|
||||
creditBalance: numberOrZero,
|
||||
creditsCharged: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
lockDetail: ChatLockDetailSchema,
|
||||
})
|
||||
.transform(({ reply, message_id, audio_url, ...data }) => ({
|
||||
...data,
|
||||
content: data.content || reply,
|
||||
messageId: data.messageId || message_id,
|
||||
audioUrl: data.audioUrl || audio_url,
|
||||
}));
|
||||
|
||||
export type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>;
|
||||
export type UnlockPrivateResponseInput = z.input<
|
||||
|
||||
@@ -52,6 +52,37 @@ describe("NavigationStorage", () => {
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("stores a one-time promotion and carries it through unlock navigation", async () => {
|
||||
const promotion = await NavigationStorage.savePendingChatPromotion("image");
|
||||
|
||||
expect(promotion).toMatchObject({
|
||||
promotionType: "image",
|
||||
lockType: "image_paywall",
|
||||
});
|
||||
expect(promotion.clientLockId).toMatch(/^promotion_/);
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(),
|
||||
).resolves.toEqual(promotion);
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
displayMessageId: `promotion:${promotion.clientLockId}`,
|
||||
kind: "image",
|
||||
lockType: promotion.lockType,
|
||||
clientLockId: promotion.clientLockId,
|
||||
promotion,
|
||||
returnUrl: "/chat",
|
||||
stage: "auth",
|
||||
});
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
||||
lockType: "image_paywall",
|
||||
clientLockId: promotion.clientLockId,
|
||||
promotion,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves and consumes pending chat image return sessions", async () => {
|
||||
await NavigationStorage.savePendingChatImageReturn({
|
||||
messageId: "msg_1",
|
||||
|
||||
@@ -3,28 +3,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
||||
import { Result, SessionAsyncUtil } from "@/utils";
|
||||
|
||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
||||
export type PendingChatUnlockStage = "auth" | "payment";
|
||||
export type PendingChatPromotionType = "voice" | "image" | "private";
|
||||
|
||||
const PendingChatUnlockSchema = z.object({
|
||||
reason: z.literal("single_message_unlock"),
|
||||
messageId: z.string().min(1),
|
||||
kind: z.enum(["private", "voice", "image"]),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.startsWith("/") && !value.startsWith("//"),
|
||||
"returnUrl must be an internal route",
|
||||
),
|
||||
stage: z.enum(["auth", "payment"]),
|
||||
export const PendingChatPromotionSchema = z.object({
|
||||
promotionType: z.enum(["voice", "image", "private"]),
|
||||
lockType: ChatLockTypeSchema,
|
||||
clientLockId: z.string().min(1),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
const PendingChatUnlockSchema = z
|
||||
.object({
|
||||
reason: z.literal("single_message_unlock"),
|
||||
displayMessageId: z.string().min(1),
|
||||
messageId: z.string().min(1).optional(),
|
||||
kind: z.enum(["private", "voice", "image"]),
|
||||
lockType: ChatLockTypeSchema.optional(),
|
||||
clientLockId: z.string().min(1).optional(),
|
||||
promotion: PendingChatPromotionSchema.optional(),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.startsWith("/") && !value.startsWith("//"),
|
||||
"returnUrl must be an internal route",
|
||||
),
|
||||
stage: z.enum(["auth", "payment"]),
|
||||
createdAt: z.number(),
|
||||
})
|
||||
.refine((value) => value.messageId || value.lockType, {
|
||||
message: "messageId or lockType is required",
|
||||
});
|
||||
|
||||
const PendingChatImageReturnSchema = z.object({
|
||||
reason: z.literal("image_paywall"),
|
||||
messageId: z.string().min(1),
|
||||
@@ -39,6 +56,9 @@ const PendingChatImageReturnSchema = z.object({
|
||||
});
|
||||
|
||||
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
|
||||
export type PendingChatPromotion = z.output<
|
||||
typeof PendingChatPromotionSchema
|
||||
>;
|
||||
export type PendingChatImageReturn = z.output<
|
||||
typeof PendingChatImageReturnSchema
|
||||
>;
|
||||
@@ -54,15 +74,30 @@ export class NavigationStorage {
|
||||
private constructor() {}
|
||||
|
||||
static async savePendingChatUnlock(input: {
|
||||
messageId: string;
|
||||
displayMessageId?: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: PendingChatUnlock["lockType"];
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
}): Promise<void> {
|
||||
const displayMessageId =
|
||||
input.displayMessageId ??
|
||||
input.messageId ??
|
||||
(input.clientLockId ? `promotion:${input.clientLockId}` : null);
|
||||
if (!displayMessageId) {
|
||||
throw new Error("displayMessageId is required");
|
||||
}
|
||||
const payload: PendingChatUnlock = {
|
||||
reason: "single_message_unlock",
|
||||
messageId: input.messageId,
|
||||
displayMessageId,
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
kind: input.kind,
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
...(input.promotion ? { promotion: input.promotion } : {}),
|
||||
returnUrl: input.returnUrl,
|
||||
stage: input.stage,
|
||||
createdAt: Date.now(),
|
||||
@@ -105,6 +140,37 @@ export class NavigationStorage {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
|
||||
static async savePendingChatPromotion(
|
||||
promotionType: PendingChatPromotionType,
|
||||
): Promise<PendingChatPromotion> {
|
||||
const promotion: PendingChatPromotion = {
|
||||
promotionType,
|
||||
lockType: toPromotionLockType(promotionType),
|
||||
clientLockId: `promotion_${createUuidV4()}`,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatPromotion,
|
||||
promotion,
|
||||
PendingChatPromotionSchema,
|
||||
);
|
||||
return promotion;
|
||||
}
|
||||
|
||||
static async consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatPromotion,
|
||||
PendingChatPromotionSchema,
|
||||
);
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatPromotion(result.data);
|
||||
}
|
||||
|
||||
static async clearPendingChatPromotion(): Promise<void> {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
}
|
||||
|
||||
static async savePendingChatImageReturn(input: {
|
||||
messageId: string;
|
||||
returnUrl: string;
|
||||
@@ -147,4 +213,46 @@ export class NavigationStorage {
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static parsePendingChatPromotion(
|
||||
value: PendingChatPromotion | null,
|
||||
): PendingChatPromotion | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toPromotionLockType(
|
||||
promotionType: PendingChatPromotionType,
|
||||
): PendingChatPromotion["lockType"] {
|
||||
if (promotionType === "voice") return "voice_message";
|
||||
if (promotionType === "image") return "image_paywall";
|
||||
return "private_message";
|
||||
}
|
||||
|
||||
function createUuidV4(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (typeof cryptoApi?.randomUUID === "function") {
|
||||
return cryptoApi.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
if (typeof cryptoApi?.getRandomValues === "function") {
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return [
|
||||
hex.slice(0, 4).join(""),
|
||||
hex.slice(4, 6).join(""),
|
||||
hex.slice(6, 8).join(""),
|
||||
hex.slice(8, 10).join(""),
|
||||
hex.slice(10, 16).join(""),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const StorageKeys = {
|
||||
chatHistory: "chat_history",
|
||||
pendingChatImageReturn: "pending_chat_image_return",
|
||||
pendingChatUnlock: "pending_chat_unlock",
|
||||
pendingChatPromotion: "pending_chat_promotion",
|
||||
|
||||
// pwa / app info
|
||||
pwaDialogShown: "pwa_dialog_shown",
|
||||
|
||||
Reference in New Issue
Block a user