Merge remote-tracking branch 'origin/main' into cloudflare

This commit is contained in:
javayhu 2025-08-11 07:36:28 +08:00
commit 97654d97ea
10 changed files with 411 additions and 1771 deletions

View File

@ -168,10 +168,10 @@ TURNSTILE_SECRET_KEY=""
NEXT_PUBLIC_CRISP_WEBSITE_ID=""
# -----------------------------------------------------------------------------
# Inngest
# https://mksaas.com/docs/jobs#setup
# Cron Jobs
# -----------------------------------------------------------------------------
INNGEST_SIGNING_KEY=""
CRON_JOBS_USERNAME=""
CRON_JOBS_PASSWORD=""
# -----------------------------------------------------------------------------
# AI
@ -185,6 +185,13 @@ GOOGLE_GENERATIVE_AI_API_KEY=""
DEEPSEEK_API_KEY=""
OPENROUTER_API_KEY=""
# -----------------------------------------------------------------------------
# Basic Authentication
# Used for protecting sensitive API endpoints like distribute-credits
# -----------------------------------------------------------------------------
BASIC_AUTH_USERNAME="admin"
BASIC_AUTH_PASSWORD=""
# -----------------------------------------------------------------------------
# Web Content Analyzer (Firecrawl)
# https://firecrawl.dev/

View File

@ -101,7 +101,6 @@
"fumadocs-core": "^15.6.7",
"fumadocs-mdx": "^11.7.3",
"fumadocs-ui": "^15.6.7",
"inngest": "^3.40.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.483.0",
"motion": "^12.4.3",

1637
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,58 @@
import { distributeCreditsToAllUsers } from '@/credits/credits';
import { NextResponse } from 'next/server';
// Basic authentication middleware
function validateBasicAuth(request: Request): boolean {
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Basic ')) {
return false;
}
// Extract credentials from Authorization header
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString(
'utf-8'
);
const [username, password] = credentials.split(':');
// Validate against environment variables
const expectedUsername = process.env.CRON_JOBS_USERNAME;
const expectedPassword = process.env.CRON_JOBS_PASSWORD;
if (!expectedUsername || !expectedPassword) {
console.error(
'Basic auth credentials not configured in environment variables'
);
return false;
}
return username === expectedUsername && password === expectedPassword;
}
/**
* distribute credits to all users daily
*/
export async function GET(request: Request) {
// Validate basic authentication
if (!validateBasicAuth(request)) {
console.error('distribute credits unauthorized');
return new NextResponse('Unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Secure Area"',
},
});
}
console.log('distribute credits start');
const { processedCount, errorCount } = await distributeCreditsToAllUsers();
console.log(
`distribute credits end, processed: ${processedCount}, errors: ${errorCount}`
);
return NextResponse.json({
message: `distribute credits success, processed: ${processedCount}, errors: ${errorCount}`,
processedCount,
errorCount,
});
}

View File

@ -1,20 +0,0 @@
import { inngest } from '@/inngest/client';
import { NextResponse } from 'next/server';
// Opt out of caching; every request should send a new event
export const dynamic = 'force-dynamic';
// Create a simple async Next.js API route handler
export async function GET() {
console.log('Send event to Inngest start');
// Send your event payload to Inngest
await inngest.send({
name: 'test/hello.world',
data: {
email: 'testUser@example.com',
},
});
console.log('Send event to Inngest end');
return NextResponse.json({ message: 'Event sent!' });
}

View File

@ -1,19 +0,0 @@
import { serve } from 'inngest/next';
import { inngest } from '../../../inngest/client';
import { distributeCreditsDaily, helloWorld } from '../../../inngest/functions';
/**
* Inngest route
*
* https://www.inngest.com/docs/getting-started/nextjs-quick-start
*
* Next.js Edge Functions hosted on Vercel can also stream responses back to Inngest,
* giving you a much higher request timeout of 15 minutes (up from 10 seconds on the Vercel Hobby plan!).
* To enable this, set your runtime to "edge" (see Quickstart for Using Edge Functions | Vercel Docs)
* and add the streaming: "allow" option to your serve handler:
* https://www.inngest.com/docs/learn/serving-inngest-functions#framework-next-js
*/
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [helloWorld, distributeCreditsDaily],
});

View File

@ -4,7 +4,8 @@ import { getDb } from '@/db';
import { creditTransaction, payment, user, userCredit } from '@/db/schema';
import { findPlanByPriceId } from '@/lib/price-plan';
import { addDays, isAfter } from 'date-fns';
import { and, asc, desc, eq, gt, isNull, not, or } from 'drizzle-orm';
import { and, asc, desc, eq, gt, inArray, isNull, not, or } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
import { CREDIT_TRANSACTION_TYPE } from './types';
/**
@ -447,6 +448,9 @@ export async function addMonthlyFreeCredits(userId: string) {
expireDays,
});
// Update last refresh time for free monthly credits
await updateUserLastRefreshAt(userId, now);
console.log(
`addMonthlyFreeCredits, ${credits} credits for user ${userId}, date: ${now.getFullYear()}-${now.getMonth() + 1}`
);
@ -559,65 +563,367 @@ export async function addLifetimeMonthlyCredits(userId: string) {
* This function is designed to be called by a cron job
*/
export async function distributeCreditsToAllUsers() {
console.log('distributing credits to all users start');
console.log('distribute credits start');
const db = await getDb();
// Get all users with their current active payments/subscriptions
const users = await db
// Get all users with their current active payments/subscriptions in a single query
// This uses a LEFT JOIN to get users and their latest active payment in one query
const latestPaymentQuery = db
.select({
userId: payment.userId,
priceId: payment.priceId,
status: payment.status,
createdAt: payment.createdAt,
rowNumber:
sql<number>`ROW_NUMBER() OVER (PARTITION BY ${payment.userId} ORDER BY ${payment.createdAt} DESC)`.as(
'row_number'
),
})
.from(payment)
.where(or(eq(payment.status, 'active'), eq(payment.status, 'trialing')))
.as('latest_payment');
const usersWithPayments = await db
.select({
userId: user.id,
email: user.email,
name: user.name,
priceId: latestPaymentQuery.priceId,
paymentStatus: latestPaymentQuery.status,
paymentCreatedAt: latestPaymentQuery.createdAt,
})
.from(user)
.where(eq(user.banned, false)); // Only active users
console.log('distributing credits to all users, users count:', users.length);
.leftJoin(
latestPaymentQuery,
and(
eq(user.id, latestPaymentQuery.userId),
eq(latestPaymentQuery.rowNumber, 1)
)
)
.where(or(isNull(user.banned), eq(user.banned, false)));
console.log('distribute credits, users count:', usersWithPayments.length);
let processedCount = 0;
let errorCount = 0;
for (const userRecord of users) {
try {
// Get user's current active subscription/payment
const activePayments = await db
.select()
.from(payment)
.where(
and(
eq(payment.userId, userRecord.userId),
or(eq(payment.status, 'active'), eq(payment.status, 'trialing'))
)
)
.orderBy(desc(payment.createdAt));
// Separate users by their plan type for batch processing
const lifetimeUserIds: string[] = [];
const freeUserIds: string[] = [];
if (activePayments.length > 0) {
// User has active subscription - check what type
const activePayment = activePayments[0];
const pricePlan = findPlanByPriceId(activePayment.priceId);
if (pricePlan?.isLifetime) {
// Lifetime user - add monthly credits
await addLifetimeMonthlyCredits(userRecord.userId);
}
// Note: Subscription renewals are handled by Stripe webhooks, not here
} else {
// User has no active subscription - add free monthly credits if enabled
await addMonthlyFreeCredits(userRecord.userId);
usersWithPayments.forEach((userRecord) => {
if (userRecord.priceId && userRecord.paymentStatus) {
// User has active subscription - check what type
const pricePlan = findPlanByPriceId(userRecord.priceId);
if (pricePlan?.isLifetime) {
lifetimeUserIds.push(userRecord.userId);
}
// Note: Subscription renewals are handled by Stripe webhooks, not here
} else {
// User has no active subscription - add free monthly credits if enabled
freeUserIds.push(userRecord.userId);
}
});
processedCount++;
console.log(
`distribute credits, lifetime users: ${lifetimeUserIds.length}, free users: ${freeUserIds.length}`
);
// Process lifetime users in batches
const batchSize = 100;
for (let i = 0; i < lifetimeUserIds.length; i += batchSize) {
const batch = lifetimeUserIds.slice(i, i + batchSize);
try {
await batchAddLifetimeMonthlyCredits(batch);
processedCount += batch.length;
} catch (error) {
console.error(
`distributing credits to all users error, user: ${userRecord.userId}, error:`,
`batchAddLifetimeMonthlyCredits error for batch ${i / batchSize + 1}:`,
error
);
errorCount++;
errorCount += batch.length;
}
// Log progress for large datasets
if (lifetimeUserIds.length > 1000) {
console.log(
`lifetime credits progress: ${Math.min(i + batchSize, lifetimeUserIds.length)}/${lifetimeUserIds.length}`
);
}
}
// Process free users in batches
for (let i = 0; i < freeUserIds.length; i += batchSize) {
const batch = freeUserIds.slice(i, i + batchSize);
try {
await batchAddMonthlyFreeCredits(batch);
processedCount += batch.length;
} catch (error) {
console.error(
`batchAddMonthlyFreeCredits error for batch ${i / batchSize + 1}:`,
error
);
errorCount += batch.length;
}
// Log progress for large datasets
if (freeUserIds.length > 1000) {
console.log(
`free credits progress: ${Math.min(i + batchSize, freeUserIds.length)}/${freeUserIds.length}`
);
}
}
console.log(
`distributing credits to all users end, processed: ${processedCount}, errors: ${errorCount}`
`distribute credits end, processed: ${processedCount}, errors: ${errorCount}`
);
return { processedCount, errorCount };
}
/**
* Batch add monthly free credits to multiple users
* @param userIds - Array of user IDs
*/
export async function batchAddMonthlyFreeCredits(userIds: string[]) {
if (userIds.length === 0) return;
const freePlan = Object.values(websiteConfig.price.plans).find(
(plan) => plan.isFree && !plan.disabled
);
if (!freePlan) {
console.log('batchAddMonthlyFreeCredits, no free plan found');
return;
}
if (
freePlan.disabled ||
!freePlan.credits?.enable ||
!freePlan.credits?.amount
) {
console.log(
'batchAddMonthlyFreeCredits, plan disabled or credits disabled',
freePlan.id
);
return;
}
const db = await getDb();
const now = new Date();
const credits = freePlan.credits.amount;
const expireDays = freePlan.credits.expireDays;
// Use transaction for data consistency
let processedCount = 0;
await db.transaction(async (tx) => {
// Get all user credit records in one query
const userCredits = await tx
.select({
userId: userCredit.userId,
lastRefreshAt: userCredit.lastRefreshAt,
currentCredits: userCredit.currentCredits,
})
.from(userCredit)
.where(inArray(userCredit.userId, userIds));
// Create a map for quick lookup
const userCreditMap = new Map(
userCredits.map((record) => [record.userId, record])
);
// Filter users who can receive credits
const eligibleUserIds = userIds.filter((userId) => {
const record = userCreditMap.get(userId);
if (!record?.lastRefreshAt) {
return true; // never added credits before
}
const last = new Date(record.lastRefreshAt);
// different month or year means new month
return (
now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear()
);
});
if (eligibleUserIds.length === 0) {
console.log('batchAddMonthlyFreeCredits, no eligible users');
return;
}
processedCount = eligibleUserIds.length;
const expirationDate = expireDays ? addDays(now, expireDays) : undefined;
// Batch insert credit transactions
const transactions = eligibleUserIds.map((userId) => ({
id: randomUUID(),
userId,
type: CREDIT_TRANSACTION_TYPE.MONTHLY_REFRESH,
amount: credits,
remainingAmount: credits,
description: `Free monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`,
expirationDate,
createdAt: now,
updatedAt: now,
}));
await tx.insert(creditTransaction).values(transactions);
// Prepare user credit updates
const existingUserIds = eligibleUserIds.filter((userId) =>
userCreditMap.has(userId)
);
const newUserIds = eligibleUserIds.filter(
(userId) => !userCreditMap.has(userId)
);
// Insert new user credit records
if (newUserIds.length > 0) {
const newRecords = newUserIds.map((userId) => ({
id: randomUUID(),
userId,
currentCredits: credits,
lastRefreshAt: now,
createdAt: now,
updatedAt: now,
}));
await tx.insert(userCredit).values(newRecords);
}
// Update existing user credit records
if (existingUserIds.length > 0) {
await tx
.update(userCredit)
.set({
currentCredits: credits,
lastRefreshAt: now,
updatedAt: now,
})
.where(inArray(userCredit.userId, existingUserIds));
}
});
console.log(
`batchAddMonthlyFreeCredits, ${credits} credits for ${processedCount} users, date: ${now.getFullYear()}-${now.getMonth() + 1}`
);
}
/**
* Batch add lifetime monthly credits to multiple users
* @param userIds - Array of user IDs
*/
export async function batchAddLifetimeMonthlyCredits(userIds: string[]) {
if (userIds.length === 0) return;
const lifetimePlan = Object.values(websiteConfig.price.plans).find(
(plan) => plan.isLifetime && !plan.disabled
);
if (
!lifetimePlan ||
lifetimePlan.disabled ||
!lifetimePlan.credits ||
!lifetimePlan.credits.enable
) {
console.log(
'batchAddLifetimeMonthlyCredits, plan disabled or credits disabled',
lifetimePlan?.id
);
return;
}
const db = await getDb();
const now = new Date();
const credits = lifetimePlan.credits.amount;
const expireDays = lifetimePlan.credits.expireDays;
// Use transaction for data consistency
let processedCount = 0;
await db.transaction(async (tx) => {
// Get all user credit records in one query
const userCredits = await tx
.select({
userId: userCredit.userId,
lastRefreshAt: userCredit.lastRefreshAt,
currentCredits: userCredit.currentCredits,
})
.from(userCredit)
.where(inArray(userCredit.userId, userIds));
// Create a map for quick lookup
const userCreditMap = new Map(
userCredits.map((record) => [record.userId, record])
);
// Filter users who can receive credits
const eligibleUserIds = userIds.filter((userId) => {
const record = userCreditMap.get(userId);
if (!record?.lastRefreshAt) {
return true; // never added credits before
}
const last = new Date(record.lastRefreshAt);
// different month or year means new month
return (
now.getMonth() !== last.getMonth() ||
now.getFullYear() !== last.getFullYear()
);
});
if (eligibleUserIds.length === 0) {
console.log('batchAddLifetimeMonthlyCredits, no eligible users');
return;
}
processedCount = eligibleUserIds.length;
const expirationDate = expireDays ? addDays(now, expireDays) : undefined;
// Batch insert credit transactions
const transactions = eligibleUserIds.map((userId) => ({
id: randomUUID(),
userId,
type: CREDIT_TRANSACTION_TYPE.LIFETIME_MONTHLY,
amount: credits,
remainingAmount: credits,
description: `Lifetime monthly credits: ${credits} for ${now.getFullYear()}-${now.getMonth() + 1}`,
expirationDate,
createdAt: now,
updatedAt: now,
}));
await tx.insert(creditTransaction).values(transactions);
// Prepare user credit updates
const existingUserIds = eligibleUserIds.filter((userId) =>
userCreditMap.has(userId)
);
const newUserIds = eligibleUserIds.filter(
(userId) => !userCreditMap.has(userId)
);
// Insert new user credit records
if (newUserIds.length > 0) {
const newRecords = newUserIds.map((userId) => ({
id: randomUUID(),
userId,
currentCredits: credits,
lastRefreshAt: now,
createdAt: now,
updatedAt: now,
}));
await tx.insert(userCredit).values(newRecords);
}
// Update existing user credit records
if (existingUserIds.length > 0) {
await tx
.update(userCredit)
.set({
currentCredits: credits,
lastRefreshAt: now,
updatedAt: now,
})
.where(inArray(userCredit.userId, existingUserIds));
}
});
console.log(
`batchAddLifetimeMonthlyCredits, ${credits} credits for ${processedCount} users, date: ${now.getFullYear()}-${now.getMonth() + 1}`
);
}

View File

@ -1,8 +0,0 @@
import { Inngest } from 'inngest';
/**
* Create a client to send and receive events
*
* https://www.inngest.com/docs/getting-started/nextjs-quick-start
*/
export const inngest = new Inngest({ id: 'mksaas-template' });

View File

@ -1,46 +0,0 @@
import { distributeCreditsToAllUsers } from '@/credits/credits';
import { inngest } from './client';
/**
* Distribute credits to all users daily
*
* https://www.inngest.com/docs/guides/scheduled-functions
*/
export const distributeCreditsDaily = inngest.createFunction(
{ id: 'distribute-credits-daily' },
{ cron: 'TZ=Asia/Shanghai 0 1 * * *' },
async ({ step }) => {
// You should use step.run for any async or long-running logic.
// This allows Inngest to track, retry, and visualize each step in your workflow.
await step.run('distribute-credits-to-all-users', async () => {
console.log('distributing credits to all users start');
const { processedCount, errorCount } =
await distributeCreditsToAllUsers();
console.log(
`distributing credits to all users end, processed: ${processedCount}, errors: ${errorCount}`
);
return {
message: `credits distributed, processed: ${processedCount}, errors: ${errorCount}`,
processedCount,
errorCount,
};
});
// you can add new steps here, for example, send email to admin
}
);
/**
* Hello World function, for testing inngest
*
* https://www.inngest.com/docs/guides/scheduled-functions
*/
export const helloWorld = inngest.createFunction(
{ id: 'hello-world' },
{ event: 'test/hello.world' },
async ({ event, step }) => {
console.log('Hello World function start');
await step.sleep('wait-a-moment', '1s');
console.log('Hello World function end');
return { message: `Hello ${event.data.email}!` };
}
);

View File

@ -1,7 +1,7 @@
{
"functions": {
"app/api/**/*": {
"maxDuration": 60
"maxDuration": 300
}
}
}