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 { 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">
|
||||||
|
@ -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(),
|
||||||
|
});
|
||||||
|
@ -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
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