feat: support credits
This commit is contained in:
parent
6195df2bc5
commit
e1b0e2f44c
63
src/actions/credits.action.ts
Normal file
63
src/actions/credits.action.ts
Normal 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 };
|
||||
});
|
@ -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<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 = [
|
||||
{
|
||||
@ -24,8 +69,21 @@ export default function DashboardPage() {
|
||||
|
||||
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="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
|
@ -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(),
|
||||
});
|
||||
|
@ -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',
|
||||
};
|
||||
|
251
src/lib/credits.ts
Normal file
251
src/lib/credits.ts
Normal 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));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user