feat: support credits

This commit is contained in:
javayhu 2025-05-22 00:30:10 +08:00
parent 6195df2bc5
commit e1b0e2f44c
5 changed files with 421 additions and 2 deletions

View File

@ -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 };
});

View File

@ -1,8 +1,15 @@
import {
consumeCreditsAction,
getCreditsAction,
} from '@/actions/credits.action';
import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive'; import { ChartAreaInteractive } from '@/components/dashboard/chart-area-interactive';
import { DashboardHeader } from '@/components/dashboard/dashboard-header'; import { DashboardHeader } from '@/components/dashboard/dashboard-header';
import { DataTable } from '@/components/dashboard/data-table'; import { DataTable } from '@/components/dashboard/data-table';
import { SectionCards } from '@/components/dashboard/section-cards'; import { SectionCards } from '@/components/dashboard/section-cards';
import { Button } from '@/components/ui/button';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import React from 'react';
import { useState } from 'react';
import data from './data.json'; import data from './data.json';
@ -14,6 +21,44 @@ import data from './data.json';
*/ */
export default function DashboardPage() { export default function DashboardPage() {
const t = useTranslations(); const t = useTranslations();
const [credits, setCredits] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 = [ const breadcrumbs = [
{ {
@ -24,8 +69,21 @@ export default function DashboardPage() {
return ( return (
<> <>
<DashboardHeader breadcrumbs={breadcrumbs} /> <DashboardHeader
breadcrumbs={breadcrumbs}
actions={
<div className="flex items-center gap-2">
<span>: {credits === null ? '加载中...' : credits}</span>
<Button
onClick={consumeCredits}
disabled={loading || credits === null || credits < 10}
>
10
</Button>
</div>
}
/>
{error && <div className="text-red-500 px-4">{error}</div>}
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2"> <div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">

View File

@ -69,3 +69,28 @@ export const payment = pgTable("payment", {
createdAt: timestamp('created_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_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(),
});

View File

@ -1,2 +1,24 @@
export const PLACEHOLDER_IMAGE = 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=='; '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',
};

251
src/lib/credits.ts Normal file
View File

@ -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<number> {
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));
}
}