From e1b0e2f44cdb133347a30f810b78aa8a186b74f9 Mon Sep 17 00:00:00 2001 From: javayhu Date: Thu, 22 May 2025 00:30:10 +0800 Subject: [PATCH] feat: support credits --- src/actions/credits.action.ts | 63 +++++ .../[locale]/(protected)/dashboard/page.tsx | 62 ++++- src/db/schema.ts | 25 ++ src/lib/constants.ts | 22 ++ src/lib/credits.ts | 251 ++++++++++++++++++ 5 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 src/actions/credits.action.ts create mode 100644 src/lib/credits.ts diff --git a/src/actions/credits.action.ts b/src/actions/credits.action.ts new file mode 100644 index 0000000..4511b00 --- /dev/null +++ b/src/actions/credits.action.ts @@ -0,0 +1,63 @@ +import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants'; +import { + addCredits, + addMonthlyFreeCredits, + consumeCredits, + getUserCredits, +} from '@/lib/credits'; +import { getSession } from '@/lib/server'; +import { createSafeActionClient } from 'next-safe-action'; +import { z } from 'zod'; + +const actionClient = createSafeActionClient(); + +// get current user's credits +export const getCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + const credits = await getUserCredits(session.user.id); + return { success: true, credits }; +}); + +// consume credits (simulate button) +const consumeSchema = z.object({ + amount: z.number().min(1), + reason: z.string().optional(), +}); +export const consumeCreditsAction = actionClient + .schema(consumeSchema) + .action(async ({ parsedInput }) => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + try { + await consumeCredits({ + userId: session.user.id, + amount: parsedInput.amount, + reason: parsedInput.reason || 'SIMULATE_USE', + }); + return { success: true }; + } catch (e) { + return { success: false, error: (e as Error).message }; + } + }); + +// add register credits (for testing) +export const addRegisterCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addCredits({ + userId: session.user.id, + amount: 100, + type: CREDIT_TRANSACTION_TYPE.REGISTER, + reason: 'REGISTER', + }); + return { success: true }; +}); + +// add monthly free credits (for testing) +export const addMonthlyCreditsAction = actionClient.action(async () => { + const session = await getSession(); + if (!session) return { success: false, error: 'Unauthorized' }; + await addMonthlyFreeCredits(session.user.id); + return { success: true }; +}); diff --git a/src/app/[locale]/(protected)/dashboard/page.tsx b/src/app/[locale]/(protected)/dashboard/page.tsx index 61d51d0..5cf3ed9 100644 --- a/src/app/[locale]/(protected)/dashboard/page.tsx +++ b/src/app/[locale]/(protected)/dashboard/page.tsx @@ -1,8 +1,15 @@ +import { + consumeCreditsAction, + getCreditsAction, +} from '@/actions/credits.action'; import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive'; import { DashboardHeader } from '@/components/dashboard/dashboard-header'; import { DataTable } from '@/components/dashboard/data-table'; import { SectionCards } from '@/components/dashboard/section-cards'; +import { Button } from '@/components/ui/button'; import { useTranslations } from 'next-intl'; +import React from 'react'; +import { useState } from 'react'; import data from './data.json'; @@ -14,6 +21,44 @@ import data from './data.json'; */ export default function DashboardPage() { const t = useTranslations(); + const [credits, setCredits] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // get credits + async function fetchCredits() { + setLoading(true); + setError(null); + const res = await getCreditsAction(); + if ( + typeof res === 'object' && + res && + 'success' in res && + res.success && + 'credits' in res + ) + setCredits(res.credits as number); + else if (typeof res === 'object' && res && 'error' in res) + setError((res.error as string) || 'get credits failed'); + setLoading(false); + } + + // consume credits + async function consumeCredits() { + setLoading(true); + setError(null); + const res = await consumeCreditsAction({ amount: 10 }); + if (typeof res === 'object' && res && 'success' in res && res.success) + await fetchCredits(); + else if (typeof res === 'object' && res && 'error' in res) + setError((res.error as string) || 'consume credits failed'); + setLoading(false); + } + + // first load credits + React.useEffect(() => { + fetchCredits(); + }, []); const breadcrumbs = [ { @@ -24,8 +69,21 @@ export default function DashboardPage() { return ( <> - - + + 当前积分: {credits === null ? '加载中...' : credits} + + + } + /> + {error &&
{error}
}
diff --git a/src/db/schema.ts b/src/db/schema.ts index e8afeb3..513881a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -69,3 +69,28 @@ export const payment = pgTable("payment", { createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }); + +// Credits table: stores user's current credit balance and last refresh date +export const userCredit = pgTable("user_credit", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), + balance: text("balance").notNull(), // store as string for bigints, or use integer if preferred + lastRefresh: timestamp("last_refresh"), // last time free/monthly credits were refreshed + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +// Credit transaction table: records all credit changes (earn/spend/expire) +export const creditTransaction = pgTable("credit_transaction", { + id: text("id").primaryKey(), + userId: text("user_id").notNull().references(() => user.id, { onDelete: 'cascade' }), + type: text("type").notNull(), // main type, e.g. REGISTER, MONTHLY_REFRESH, PURCHASE, USAGE, EXPIRE + reason: text("reason"), // sub reason, e.g. REGISTER, MONTHLY_REFRESH, FEATURE_USE + amount: text("amount").notNull(), // positive for earn, negative for spend + remainingAmount: text("remaining_amount"), // for FIFO consumption + paymentId: text("payment_id"), // associated payment order, can be null, only has value when purchasing credits + expirationDate: timestamp("expiration_date"), // when these credits expire + expirationDateProcessedAt: timestamp("expiration_date_processed_at"), // when expired credits were processed + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 39459fd..e29cdb1 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,2 +1,24 @@ export const PLACEHOLDER_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAoJJREFUWEfFl4lu4zAMRO3cx/9/au6reMaOdkxTTl0grQFCRoqaT+SQotq2bV9N8rRt28xms87m83l553eZ/9vr9Wpkz+ezkT0ej+6dv1X81AFw7M4FBACPVn2c1Z3zLgDeJwHgeLFYdAARYioAEAKJEG2WAjl3gCwNYymQQ9b7/V4spmIAwO6Wy2VnAMikBWlDURBELf8CuN1uHQSrPwMAHK5WqwFELQ01AIXdAa7XawfAb3p6AOwK5+v1ugAoEq4FRSFLgavfQ49jAGQpAE5wjgGCeRrGdBArwHOPcwFcLpcGU1X0IsBuN5tNgYhaiFFwHTiAwq8I+O5xfj6fOz38K+X/fYAdb7fbAgFAjIJ6Aav3AYlQ6nfnDoDz0+lUxNiLALvf7XaDNGQ6GANQBKR85V27B4D3QQRw7hGIYlQKWGM79hSweyCUe1blXhEAogfABwHAXAcqSYkxCtHLUK3XBajSc4Dj8dilAeiSAgD2+30BAEKV4GKcAuDqB4TdYwBgPQByCgApUBoE4EJUGvxUjF3Q69/zLw3g/HA45ABKgdIQu+JPIyDnisCfAxAFNFM0EFNQ64gfS0EUoQP8ighrZSjn3oziZEQpauyKbfjbZchHUL/3AS/Dd30gAkxuRACgfO+EWQW8qwI1o+wseNuKcQiESjALvwNoMI0TcRzD4lFcPYwIM+JTF5x6HOs8yI7jeB5oKhpMRFH9UwaSCDB2Jmg4rc6E2TT0biIaG0rQhNqyhpHBcayTTSXH6vcDL7/sdqRK8LkwTsU499E8vRcAojHcZ4AxABdilgrp4lsXk8oVqgwh7+6H3phqd8J0Kk4vbx/+sZqCD/vNLya/5dT9fAH8g1WdNGgwbQAAAABJRU5ErkJggg=='; + +// credit package definition (example) +export const CREDIT_PACKAGES = [ + { id: 'package-1', credits: 500, price: 5 }, + { id: 'package-2', credits: 1200, price: 10 }, + { id: 'package-3', credits: 3000, price: 20 }, +]; + +// free monthly credits (10% of the smallest package) +export const FREE_MONTHLY_CREDITS = 50; + +// default credit expiration days +export const CREDIT_EXPIRE_DAYS = 30; + +// credit transaction type +export const CREDIT_TRANSACTION_TYPE = { + MONTHLY_REFRESH: 'MONTHLY_REFRESH', + REGISTER: 'REGISTER', + PURCHASE: 'PURCHASE', + USAGE: 'USAGE', + EXPIRE: 'EXPIRE', +}; diff --git a/src/lib/credits.ts b/src/lib/credits.ts new file mode 100644 index 0000000..903a6fe --- /dev/null +++ b/src/lib/credits.ts @@ -0,0 +1,251 @@ +import db from '@/db'; +import { creditTransaction, payment, userCredit } from '@/db/schema'; +import { addDays, isAfter } from 'date-fns'; +import { and, asc, eq } from 'drizzle-orm'; +import { + CREDIT_EXPIRE_DAYS, + CREDIT_TRANSACTION_TYPE, + FREE_MONTHLY_CREDITS, +} from './constants'; + +// Get user's current credit balance +export async function getUserCredits(userId: string): Promise { + const record = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + return record[0]?.balance ? Number.parseInt(record[0].balance, 10) : 0; +} + +// Write a credit transaction record +async function logCreditTransaction(params: { + userId: string; + type: string; + amount: number; + reason: string; + paymentId?: string; + expirationDate?: Date; +}) { + await db.insert(creditTransaction).values({ + id: crypto.randomUUID(), + userId: params.userId, + type: params.type, + amount: params.amount.toString(), + remainingAmount: params.amount > 0 ? params.amount.toString() : undefined, + reason: params.reason, + paymentId: params.paymentId, + expirationDate: params.expirationDate, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + +// Add credits (registration, monthly, purchase, etc.) +export async function addCredits({ + userId, + amount, + type, + reason, + paymentId, + expireDays = CREDIT_EXPIRE_DAYS, +}: { + userId: string; + amount: number; + type: string; + reason: string; + paymentId?: string; + expireDays?: number; +}) { + // Process expired credits first + await processExpiredCredits(userId); + // Update balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) + amount + ).toString(); + if (current.length > 0) { + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: new Date() }) + .where(eq(userCredit.userId, userId)); + } else { + await db.insert(userCredit).values({ + id: crypto.randomUUID(), + userId, + balance: newBalance, + lastRefresh: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + } + // Write transaction record + await logCreditTransaction({ + userId, + type, + amount, + reason, + paymentId, + expirationDate: addDays(new Date(), expireDays), + }); + // Refresh session if needed + // await refreshUserSession(userId); +} + +// Consume credits (FIFO, by expiration) +export async function consumeCredits({ + userId, + amount, + reason, +}: { + userId: string; + amount: number; + reason: string; +}) { + await processExpiredCredits(userId); + const balance = await getUserCredits(userId); + if (balance < amount) throw new Error('Insufficient credits'); + // FIFO consumption: consume from the earliest unexpired credits first + const txs = await db + .select() + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + ) + ) + .orderBy( + asc(creditTransaction.expirationDate), + asc(creditTransaction.createdAt) + ); + let left = amount; + for (const tx of txs) { + if (left <= 0) break; + const remain = Number.parseInt(tx.remainingAmount || '0', 10); + if (remain <= 0) continue; + const consume = Math.min(remain, left); + await db + .update(creditTransaction) + .set({ + remainingAmount: (remain - consume).toString(), + updatedAt: new Date(), + }) + .where(eq(creditTransaction.id, tx.id)); + left -= consume; + } + // Update balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) - amount + ).toString(); + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: new Date() }) + .where(eq(userCredit.userId, userId)); + // Write usage record + await logCreditTransaction({ + userId, + type: CREDIT_TRANSACTION_TYPE.USAGE, + amount: -amount, + reason, + }); + // Refresh session if needed + // await refreshUserSession(userId); +} + +// Process expired credits +export async function processExpiredCredits(userId: string) { + const now = new Date(); + const txs = await db + .select() + .from(creditTransaction) + .where( + and( + eq(creditTransaction.userId, userId), + eq(creditTransaction.type, CREDIT_TRANSACTION_TYPE.PURCHASE) + ) + ); + let expiredTotal = 0; + for (const tx of txs) { + if ( + tx.expirationDate && + isAfter(now, tx.expirationDate) && + !tx.expirationDateProcessedAt + ) { + const remain = Number.parseInt(tx.remainingAmount || '0', 10); + if (remain > 0) { + expiredTotal += remain; + await db + .update(creditTransaction) + .set({ + remainingAmount: '0', + expirationDateProcessedAt: now, + updatedAt: now, + }) + .where(eq(creditTransaction.id, tx.id)); + } + } + } + if (expiredTotal > 0) { + // Deduct expired credits from balance + const current = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const newBalance = ( + Number.parseInt(current[0]?.balance || '0', 10) - expiredTotal + ).toString(); + await db + .update(userCredit) + .set({ balance: newBalance, updatedAt: now }) + .where(eq(userCredit.userId, userId)); + // Write expire record + await logCreditTransaction({ + userId, + type: CREDIT_TRANSACTION_TYPE.EXPIRE, + amount: -expiredTotal, + reason: 'EXPIRE', + }); + } +} + +// Add free monthly credits (can be called by scheduler) +export async function addMonthlyFreeCredits(userId: string) { + // Check last refresh time + const record = await db + .select() + .from(userCredit) + .where(eq(userCredit.userId, userId)) + .limit(1); + const now = new Date(); + let canAdd = false; + if (!record[0]?.lastRefresh) canAdd = true; + else { + const last = new Date(record[0].lastRefresh); + canAdd = + now.getMonth() !== last.getMonth() || + now.getFullYear() !== last.getFullYear(); + } + if (canAdd) { + await addCredits({ + userId, + amount: FREE_MONTHLY_CREDITS, + type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH, + reason: 'MONTHLY_FREE', + }); + await db + .update(userCredit) + .set({ lastRefresh: now, updatedAt: now }) + .where(eq(userCredit.userId, userId)); + } +}