Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion App/backend/local-api-contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,35 @@ export const PromotionFlagsSchema = z.object({
});
export type PromotionFlags = z.infer<typeof PromotionFlagsSchema>;

export const LotteryStatusSchema = z.object({
shouldShow: z.boolean(),
startAt: z.number().int().nonnegative(),
endAt: z.number().int().nonnegative(),
serverNow: z.number().int().nonnegative(),
landingUrl: z.string().url()
}).refine((status) => status.endAt > status.startAt, {
message: "Lottery endAt must be later than startAt",
path: ["endAt"]
});
export type LotteryStatus = z.infer<typeof LotteryStatusSchema>;

export const LotteryRewardSchema = z.discriminatedUnion("hasReward", [
z.object({
hasReward: z.literal(false)
}),
z.object({
hasReward: z.literal(true),
drawId: z.string().min(1).optional(),
tokenAmount: z.number().int().positive()
})
]);
export type LotteryReward = z.infer<typeof LotteryRewardSchema>;

export const LotteryRewardAckInputSchema = z.object({
drawId: z.string().min(1).optional()
});
export type LotteryRewardAckInput = z.infer<typeof LotteryRewardAckInputSchema>;

export const AppBootstrapResponseSchema = z.object({
app: AppSettingsDtoSchema,
onboarding: OnboardingStateDtoSchema,
Expand All @@ -611,7 +640,8 @@ export const AppBootstrapResponseSchema = z.object({
legal: LegalAgreementUrlsSchema.optional(),
// Src module.
// Promotions.
promotions: PromotionFlagsSchema.optional()
promotions: PromotionFlagsSchema.optional(),
lotteryStatus: LotteryStatusSchema.optional()
});
export type AppBootstrapResponse = z.infer<typeof AppBootstrapResponseSchema>;

Expand Down
21 changes: 21 additions & 0 deletions App/backend/src/adapters/inbound/local-api/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
AccountProfileViewSchema,
AccountSessionViewSchema,
AvatarOptionSchema,
LotteryRewardAckInputSchema,
LotteryRewardSchema,
OkResponseSchema,
SendCodeInputSchema,
SendCodeResponseSchema,
Expand Down Expand Up @@ -122,6 +124,25 @@ export function registerAccountRoutes(app: FastifyInstance, options: RegisterAcc
})
);

app.get(
"/api/account/lottery/reward",
{ preHandler: options.authenticateRuntimeToken },
withErrorEnvelope(async (_request, reply) => {
const response = LotteryRewardSchema.parse(await options.account.getLotteryReward());
return reply.send(response);
})
);

app.post(
"/api/account/lottery/reward/ack",
{ preHandler: options.authenticateRuntimeToken },
withErrorEnvelope(async (request, reply) => {
const input = LotteryRewardAckInputSchema.parse(request.body);
const response = OkResponseSchema.parse(await options.account.ackLotteryReward(input));
return reply.send(response);
})
);

app.get(
"/api/account/avatars",
{ preHandler: options.authenticateRuntimeToken },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ describe("account local api routes", () => {
]);
});

it("forwards lottery reward lookup and acknowledgement through AccountService", async () => {
const calls: unknown[] = [];
app = createServer({
account: {
async getLotteryReward() {
calls.push("get");
return { hasReward: true as const, drawId: "1", tokenAmount: 500_000 };
},
async ackLotteryReward(input: unknown) {
calls.push({ ack: input });
return { ok: true };
}
}
});

const reward = await app.inject({
method: "GET",
url: "/api/account/lottery/reward",
headers: { "x-memmy-local-token": "test-token" }
});
const ack = await injectJson("POST", "/api/account/lottery/reward/ack", { drawId: "1" });

expect(reward.statusCode).toBe(200);
expect(reward.json()).toEqual({ hasReward: true, drawId: "1", tokenAmount: 500_000 });
expect(ack.json()).toEqual({ ok: true });
expect(calls).toEqual(["get", { ack: { drawId: "1" } }]);
});

it("lists avatars and stores the selected avatar behind the runtime token", async () => {
const calls: string[] = [];
app = createServer({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
LegalAgreementUrlsSchema,
IntegrationToolResultSchema,
InvitationResultSchema,
LotteryRewardSchema,
LotteryStatusSchema,
OkResponseSchema,
PromotionFlagsSchema,
QWEN_ASR_MODEL_ID,
Expand All @@ -20,6 +22,8 @@ import {
type IntegrationConnection,
type IntegrationConnectionsResponse,
type LegalAgreementUrls,
type LotteryReward,
type LotteryStatus,
type IntegrationToolResult,
type OkResponse,
type PromotionFlags,
Expand All @@ -45,10 +49,12 @@ import type {
CloudStartSocialLoginResult,
CloudLogoutInput,
GetAccountInfoInput,
GetLotteryRewardInput,
EnsureInvitationCodeInput,
GetTokenQuotaEligibilityInput,
GetTokenUsageInput,
GrantTokensInput,
AckLotteryRewardInput,
ReleaseCheckResult,
RequestTokenQuotaInput,
TokenQuotaApplyResult,
Expand Down Expand Up @@ -414,6 +420,36 @@ export function createHttpCloudClient(options: CreateHttpCloudClientOptions = {}
} catch {
return undefined;
}
},

async getLotteryStatus(): Promise<LotteryStatus | undefined> {
try {
const data = await requestCloudData<unknown>(fetchImpl, baseUrl, timeoutMs, "/api/memmy/lottery/status", {
method: "GET",
lang: "zh"
});
const parsed = LotteryStatusSchema.safeParse(data);
return parsed.success ? parsed.data : undefined;
} catch {
return undefined;
}
},

async getLotteryReward(input: GetLotteryRewardInput): Promise<LotteryReward> {
const data = await requestCloudData<unknown>(fetchImpl, baseUrl, timeoutMs, "/api/memmy/lottery/reward", {
method: "GET",
lang: "zh",
bearerCredential: input.uuid
});
return LotteryRewardSchema.parse(data);
},

async ackLotteryReward(input: AckLotteryRewardInput): Promise<void> {
await requestBoolean(fetchImpl, baseUrl, timeoutMs, "/api/memmy/lottery/reward/ack", {
body: input.drawId ? { drawId: input.drawId } : {},
lang: "zh",
bearerCredential: input.uuid
});
}
};
}
Expand Down
2 changes: 2 additions & 0 deletions App/backend/src/adapters/outbound/cloud-client/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { createHttpCloudClient, type CreateHttpCloudClientOptions } from "./http-cloud-client.js";
export type {
AckLotteryRewardInput,
CheckReleaseInput,
CloudAsrTranscriptionInput,
CloudAsrTranscriptionResult,
Expand All @@ -18,6 +19,7 @@ export type {
CloudStartSocialLoginResult,
CloudLogoutInput,
GetAccountInfoInput,
GetLotteryRewardInput,
GetTokenQuotaEligibilityInput,
GetTokenUsageInput,
GrantTokensInput,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,112 @@ describe("cloud client", () => {
await expect(client.getPromotions()).resolves.toBeUndefined();
});

it("http client fetches the remote lottery status", async () => {
const requests: Array<{ path: string; method: string | undefined }> = [];
const lotteryStatus = {
shouldShow: true,
startAt: 1790121600000,
endAt: 1790812800000,
serverNow: 1790456789000,
landingUrl: "https://memmy.cn/activity/mid-autumn"
};
server = createServer((request, response) => {
requests.push({
path: request.url ?? "",
method: request.method
});
sendJson(response, { code: 0, message: "ok", data: lotteryStatus });
});
await listen(server);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Mock cloud server did not bind to a port");
}
const client = createHttpCloudClient({ baseUrl: `http://127.0.0.1:${address.port}`, timeoutMs: 1000 });

await expect(client.getLotteryStatus()).resolves.toEqual(lotteryStatus);
expect(requests).toEqual([{
path: "/api/memmy/lottery/status",
method: "GET"
}]);
});

it("http client fails closed when the lottery status is invalid", async () => {
server = createServer((_request, response) => {
sendJson(response, {
code: 0,
message: "ok",
data: {
shouldShow: true,
startAt: 1790121600000,
endAt: 1790812800000
}
});
});
await listen(server);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Mock cloud server did not bind to a port");
}
const client = createHttpCloudClient({ baseUrl: `http://127.0.0.1:${address.port}`, timeoutMs: 1000 });

await expect(client.getLotteryStatus()).resolves.toBeUndefined();
});

it("http client reads and acknowledges a lottery reward with account authorization", async () => {
const requests: Array<{
path: string;
method: string | undefined;
body: unknown;
authorization: string | undefined;
}> = [];
server = createServer(async (request, response) => {
requests.push({
path: request.url ?? "",
method: request.method,
body: await readJson(request),
authorization: request.headers.authorization
});
sendJson(response, request.url === "/api/memmy/lottery/reward"
? {
code: 0,
message: "ok",
data: { hasReward: true, drawId: "1", tokenAmount: 500_000 }
}
: { code: 0, message: "ok", data: true });
});
await listen(server);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Mock cloud server did not bind to a port");
}
const client = createHttpCloudClient({ baseUrl: `http://127.0.0.1:${address.port}`, timeoutMs: 1000 });

await expect(client.getLotteryReward({ uuid: "cloud.login.uuid" })).resolves.toEqual({
hasReward: true,
drawId: "1",
tokenAmount: 500_000
});
await expect(client.ackLotteryReward({
uuid: "cloud.login.uuid",
drawId: "1"
})).resolves.toBeUndefined();
expect(requests).toEqual([
{
path: "/api/memmy/lottery/reward",
method: "GET",
body: {},
authorization: "Bearer cloud.login.uuid"
},
{
path: "/api/memmy/lottery/reward/ack",
method: "POST",
body: { drawId: "1" },
authorization: "Bearer cloud.login.uuid"
}
]);
});

});

function listen(serverToListen: ReturnType<typeof createServer>): Promise<void> {
Expand Down
17 changes: 17 additions & 0 deletions App/backend/src/adapters/outbound/cloud-client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
LegalAgreementUrls,
IntegrationToolResult,
InvitationResult,
LotteryReward,
LotteryStatus,
OkResponse,
PromotionFlags,
SocialLoginProvider,
Expand Down Expand Up @@ -129,6 +131,15 @@ export interface GrantTokensInput {
uuid?: string;
}

export interface GetLotteryRewardInput {
uuid: string;
}

export interface AckLotteryRewardInput {
uuid: string;
drawId?: string;
}

/** Contract for request token quota input. */
export interface RequestTokenQuotaInput {
/** Uuid. */
Expand Down Expand Up @@ -254,4 +265,10 @@ export interface CloudClient {
getLegalUrls(): Promise<LegalAgreementUrls | undefined>;
/** Reads get promotions. */
getPromotions(): Promise<PromotionFlags | undefined>;
/** Reads the remotely controlled lottery campaign status. */
getLotteryStatus(): Promise<LotteryStatus | undefined>;
/** Reads the latest unacknowledged lottery reward for the account. */
getLotteryReward(input: GetLotteryRewardInput): Promise<LotteryReward>;
/** Marks the latest matching lottery reward as shown. */
ackLotteryReward(input: AckLotteryRewardInput): Promise<void>;
}
Loading
Loading