feat(user): sync entitlement snapshot

This commit is contained in:
2026-06-29 10:37:37 +08:00
parent 455204e1e4
commit 58cd2a7545
22 changed files with 479 additions and 54 deletions
+48
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { UserEntitlements } from "@/data/dto/user";
import { User } from "@/data/dto/user/user";
describe("User", () => {
@@ -42,4 +43,51 @@ describe("User", () => {
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
});
});
it("parses the latest backend entitlements payload", () => {
const entitlements = UserEntitlements.fromJson({
userId: "user-1",
isGuest: false,
isVip: true,
vipExpiresAt: "2026-07-26T00:00:00+00:00",
creditBalance: 120,
dolBalance: 120,
policy: {
membershipState: "vip",
nonVipEntitlementsShared: true,
guestSameAsLoggedInNonVip: true,
refreshAfterPayment: "GET /api/user/entitlements",
},
costs: {
normal_message: 2,
private_message: 10,
voice_message: 20,
photo: 40,
voice_call_minute: 50,
private_album_10: 300,
private_album_20: 600,
},
quotas: {
normalChatFreeDaily: 30,
privateUnlockFreeDaily: 1,
voiceMessageFreeDaily: 0,
photoFreeDaily: 0,
},
historyUnlock: {
enabled: true,
order: "oldest_first",
chargeMode: "highest_cost_per_locked_message",
insufficientBalanceBehavior: "no_deduction",
costs: {
private_message: 10,
voice_message: 20,
photo: 40,
},
},
});
expect(entitlements.isVip).toBe(true);
expect(entitlements.creditBalance).toBe(120);
expect(entitlements.historyUnlock.costs.photo).toBe(40);
});
});
+1
View File
@@ -9,5 +9,6 @@ export * from "./personality_traits";
export * from "./recent_memory";
export * from "./update_profile_request";
export * from "./user";
export * from "./user_entitlements";
export * from "./user_stats_response";
export * from "./user_view";
+39
View File
@@ -0,0 +1,39 @@
/**
* 用户权益快照 DTO
*/
import {
UserEntitlementsSchema,
type UserEntitlementsData,
type UserEntitlementsInput,
} from "@/data/schemas/user/user_entitlements";
export class UserEntitlements {
declare readonly userId: string;
declare readonly isGuest: boolean;
declare readonly isVip: boolean;
declare readonly vipExpiresAt: string | null;
declare readonly creditBalance: number;
declare readonly dolBalance: number;
declare readonly policy: UserEntitlementsData["policy"];
declare readonly costs: UserEntitlementsData["costs"];
declare readonly quotas: UserEntitlementsData["quotas"];
declare readonly historyUnlock: UserEntitlementsData["historyUnlock"];
private constructor(input: UserEntitlementsInput) {
const data = UserEntitlementsSchema.parse(input);
Object.assign(this, data);
Object.freeze(this);
}
static from(input: UserEntitlementsInput): UserEntitlements {
return new UserEntitlements(input);
}
static fromJson(json: unknown): UserEntitlements {
return UserEntitlements.from(json as UserEntitlementsInput);
}
toJson(): UserEntitlementsData {
return UserEntitlementsSchema.parse(this);
}
}
+2
View File
@@ -12,6 +12,8 @@ export const UserViewSchema = z.object({
avatarUrl: z.string(),
intimacy: z.number(),
dolBalance: z.number(),
creditBalance: z.number(),
vipExpiresAt: z.string().nullable(),
relationshipStage: z.string(),
currentMood: z.string(),
isGuest: z.boolean(),
@@ -15,6 +15,7 @@ import type {
CreditsHistoryData,
UpdateProfileRequest,
User,
UserEntitlements,
UserStatsResponse,
} from "@/data/dto/user";
@@ -36,4 +37,7 @@ export interface IUserRepository {
limit?: number,
offset?: number,
): Promise<Result<CreditsHistoryData>>;
/** 获取当前用户权益快照。 */
getEntitlements(): Promise<Result<UserEntitlements>>;
}
+6
View File
@@ -14,6 +14,7 @@ import {
CreditsHistoryData,
UpdateProfileRequest,
User,
UserEntitlements,
UserStatsResponse,
} from "@/data/dto/user";
import { Result } from "@/utils";
@@ -49,6 +50,11 @@ export class UserRepository implements IUserRepository {
): Promise<Result<CreditsHistoryData>> {
return Result.wrap(() => this.api.getCreditsHistory(limit, offset));
}
/** 获取当前用户权益快照。 */
async getEntitlements(): Promise<Result<UserEntitlements>> {
return Result.wrap(() => this.api.getEntitlements());
}
}
/** 全局单例。 */
+2
View File
@@ -9,4 +9,6 @@ export * from "./personality_traits";
export * from "./recent_memory";
export * from "./update_profile_request";
export * from "./user";
export * from "./user_entitlement_snapshot";
export * from "./user_entitlements";
export * from "./user_stats_response";
@@ -0,0 +1,21 @@
/**
* 本地用户权益快照
*
* 只持久化 UI 初始化必须的两个字段,完整权益数据仍由
* `GET /api/user/entitlements` 实时获取。
*/
import { z } from "zod";
import { booleanOrFalse, numberOrZero } from "../nullable-defaults";
export const UserEntitlementSnapshotSchema = z.object({
isVip: booleanOrFalse,
creditBalance: numberOrZero,
});
export type UserEntitlementSnapshotInput = z.input<
typeof UserEntitlementSnapshotSchema
>;
export type UserEntitlementSnapshotData = z.output<
typeof UserEntitlementSnapshotSchema
>;
+105
View File
@@ -0,0 +1,105 @@
/**
* 用户权益快照
*/
import { z } from "zod";
import {
booleanOrFalse,
numberOrZero,
stringOrEmpty,
} from "../nullable-defaults";
export const UserEntitlementsPolicySchema = z
.object({
membershipState: stringOrEmpty,
nonVipEntitlementsShared: booleanOrFalse,
guestSameAsLoggedInNonVip: booleanOrFalse,
refreshAfterPayment: stringOrEmpty,
})
.passthrough();
export const UserEntitlementsCostsSchema = z
.object({
normal_message: numberOrZero,
private_message: numberOrZero,
voice_message: numberOrZero,
photo: numberOrZero,
voice_call_minute: numberOrZero,
private_album_10: numberOrZero,
private_album_20: numberOrZero,
})
.passthrough();
export const UserEntitlementsQuotasSchema = z
.object({
normalChatFreeDaily: numberOrZero,
privateUnlockFreeDaily: numberOrZero,
voiceMessageFreeDaily: numberOrZero,
photoFreeDaily: numberOrZero,
})
.passthrough();
export const UserEntitlementsHistoryUnlockSchema = z
.object({
enabled: booleanOrFalse,
order: stringOrEmpty,
chargeMode: stringOrEmpty,
insufficientBalanceBehavior: stringOrEmpty,
costs: z
.object({
private_message: numberOrZero,
voice_message: numberOrZero,
photo: numberOrZero,
})
.passthrough()
.default(() => ({
private_message: 0,
voice_message: 0,
photo: 0,
})),
})
.passthrough();
export const UserEntitlementsSchema = z.object({
userId: stringOrEmpty,
isGuest: booleanOrFalse,
isVip: booleanOrFalse,
vipExpiresAt: z.string().nullable().default(null),
creditBalance: numberOrZero,
dolBalance: numberOrZero,
policy: UserEntitlementsPolicySchema.default(() => ({
membershipState: "",
nonVipEntitlementsShared: false,
guestSameAsLoggedInNonVip: false,
refreshAfterPayment: "",
})),
costs: UserEntitlementsCostsSchema.default(() => ({
normal_message: 0,
private_message: 0,
voice_message: 0,
photo: 0,
voice_call_minute: 0,
private_album_10: 0,
private_album_20: 0,
})),
quotas: UserEntitlementsQuotasSchema.default(() => ({
normalChatFreeDaily: 0,
privateUnlockFreeDaily: 0,
voiceMessageFreeDaily: 0,
photoFreeDaily: 0,
})),
historyUnlock: UserEntitlementsHistoryUnlockSchema.default(() => ({
enabled: false,
order: "",
chargeMode: "",
insufficientBalanceBehavior: "",
costs: {
private_message: 0,
voice_message: 0,
photo: 0,
},
})),
});
export type UserEntitlementsInput = z.input<typeof UserEntitlementsSchema>;
export type UserEntitlementsData = z.output<typeof UserEntitlementsSchema>;
+3
View File
@@ -69,6 +69,9 @@ export class ApiPath {
/** 查询积分操作历史 */
static readonly userCreditsHistory = `${ApiPath._user}/credits/history`;
/** 获取当前用户权益快照 */
static readonly userEntitlements = `${ApiPath._user}/entitlements`;
// ============ 支付相关 ============
/** 创建充值订单 */
static readonly paymentCreateOrder = `${ApiPath._payment}/create-order`;
+9
View File
@@ -12,6 +12,7 @@ import {
CreditsHistoryData,
UpdateProfileRequest,
User,
UserEntitlements,
UserStatsResponse,
} from "@/data/dto/user";
import type { UserData } from "@/data/schemas/user/user";
@@ -69,6 +70,14 @@ export class UserApi {
unwrap(env) as Record<string, unknown>
);
}
/**
* 获取当前用户权益快照
*/
async getEntitlements(): Promise<UserEntitlements> {
const env = await httpClient<ApiEnvelope<unknown>>(ApiPath.userEntitlements);
return UserEntitlements.fromJson(unwrap(env) as Record<string, unknown>);
}
}
/**
+1
View File
@@ -18,6 +18,7 @@ export const StorageKeys = {
user: "user",
userId: "user_id",
userAvatar: "user_avatar",
userEntitlementSnapshot: "user_entitlement_snapshot",
// chat
chatHistory: "chat_history",
+7
View File
@@ -12,6 +12,7 @@
*/
import type { Result } from "@/utils";
import type { UserEntitlementSnapshotData } from "@/data/schemas/user/user_entitlement_snapshot";
import type { UserData } from "@/data/schemas/user/user";
export interface IUserStorage {
@@ -27,5 +28,11 @@ export interface IUserStorage {
setAvatarUrl(url: string): Promise<Result<void>>;
clearAvatarUrl(): Promise<Result<void>>;
getEntitlementSnapshot(): Promise<Result<UserEntitlementSnapshotData | null>>;
setEntitlementSnapshot(
data: UserEntitlementSnapshotData,
): Promise<Result<void>>;
clearEntitlementSnapshot(): Promise<Result<void>>;
clearUserData(): Promise<Result<void>>;
}
+32 -1
View File
@@ -15,6 +15,10 @@
* - 单例挂在 class 静态字段,HMR 复用安全
*/
import {
UserEntitlementSnapshotSchema,
type UserEntitlementSnapshotData,
} from "@/data/schemas/user/user_entitlement_snapshot";
import { UserSchema, type UserData } from "@/data/schemas/user/user";
import { type Result as ResultT, SpAsyncUtil } from "@/utils";
import { StorageKeys } from "../storage_keys";
@@ -75,6 +79,31 @@ export class UserStorage implements IUserStorage {
return SpAsyncUtil.remove(StorageKeys.userAvatar);
}
// ---- entitlement snapshot ----
getEntitlementSnapshot(): Promise<
ResultT<UserEntitlementSnapshotData | null>
> {
return SpAsyncUtil.getJson(
StorageKeys.userEntitlementSnapshot,
UserEntitlementSnapshotSchema,
);
}
setEntitlementSnapshot(
data: UserEntitlementSnapshotData,
): Promise<ResultT<void>> {
return SpAsyncUtil.setJson(
StorageKeys.userEntitlementSnapshot,
data,
UserEntitlementSnapshotSchema,
);
}
clearEntitlementSnapshot(): Promise<ResultT<void>> {
return SpAsyncUtil.remove(StorageKeys.userEntitlementSnapshot);
}
// ---- bulk clear ----
async clearUserData(): Promise<ResultT<void>> {
@@ -82,6 +111,8 @@ export class UserStorage implements IUserStorage {
if (!r1.success) return r1;
const r2 = await this.clearUserId();
if (!r2.success) return r2;
return this.clearAvatarUrl();
const r3 = await this.clearAvatarUrl();
if (!r3.success) return r3;
return this.clearEntitlementSnapshot();
}
}