Files
ai_web/server/routers.ts
2026-01-27 14:51:35 +08:00

789 lines
27 KiB
TypeScript

import { COOKIE_NAME } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import * as db from "./db";
import { createBountyEscrowCheckout, createConnectAccount, createAccountLink, getAccountStatus, transferToConnectedAccount } from "./stripe";
export const appRouter = router({
system: systemRouter,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
}),
// ============ Category Routes ============
category: router({
list: publicProcedure.query(async () => {
return db.getAllCategories();
}),
getBySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
return db.getCategoryBySlug(input.slug);
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1),
slug: z.string().min(1),
description: z.string().optional(),
icon: z.string().optional(),
parentId: z.number().optional(),
sortOrder: z.number().optional(),
}))
.mutation(async ({ input, ctx }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有管理员可以创建分类' });
}
await db.createCategory(input);
return { success: true };
}),
}),
// ============ Website Routes ============
website: router({
list: publicProcedure
.input(z.object({ categoryId: z.number().optional() }).optional())
.query(async ({ input }) => {
return db.getWebsites(input?.categoryId);
}),
get: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return db.getWebsiteById(input.id);
}),
search: publicProcedure
.input(z.object({ query: z.string() }))
.query(async ({ input }) => {
return db.searchWebsites(input.query);
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1),
url: z.string().url(),
logo: z.string().optional(),
description: z.string().optional(),
categoryId: z.number(),
rating: z.string().optional(),
isVerified: z.boolean().optional(),
sortOrder: z.number().optional(),
}))
.mutation(async ({ input, ctx }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有管理员可以添加网站' });
}
await db.createWebsite(input);
return { success: true };
}),
}),
// ============ Product Routes ============
product: router({
list: publicProcedure
.input(z.object({
categoryId: z.number().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
sortBy: z.enum(['price_asc', 'price_desc', 'newest', 'oldest']).optional()
}).optional())
.query(async ({ input }) => {
return db.getProducts(
input?.categoryId,
input?.limit || 50,
input?.offset || 0,
input?.sortBy
);
}),
get: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return db.getProductById(input.id);
}),
getWithPrices: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return db.getProductWithPrices(input.id);
}),
search: publicProcedure
.input(z.object({ query: z.string() }))
.query(async ({ input }) => {
return db.searchProducts(input.query);
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1),
description: z.string().optional(),
image: z.string().optional(),
categoryId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有管理员可以添加商品' });
}
await db.createProduct(input);
return { success: true };
}),
addPrice: protectedProcedure
.input(z.object({
productId: z.number(),
websiteId: z.number(),
price: z.string(),
originalPrice: z.string().optional(),
currency: z.string().optional(),
url: z.string().url(),
inStock: z.boolean().optional(),
}))
.mutation(async ({ input, ctx }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有管理员可以添加价格' });
}
await db.createProductPrice(input);
return { success: true };
}),
}),
// ============ Bounty Routes ============
bounty: router({
list: publicProcedure
.input(z.object({
status: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
}).optional())
.query(async ({ input }) => {
return db.getBounties(input?.status, input?.limit, input?.offset);
}),
get: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const bounty = await db.getBountyById(input.id);
if (!bounty) {
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
}
return bounty;
}),
search: publicProcedure
.input(z.object({ query: z.string() }))
.query(async ({ input }) => {
return db.searchBounties(input.query);
}),
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(300),
description: z.string().min(1),
reward: z.string(),
currency: z.string().optional(),
deadline: z.date().optional(),
}))
.mutation(async ({ input, ctx }) => {
const bountyId = await db.createBounty({
...input,
publisherId: ctx.user.id,
});
return { success: true, bountyId };
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
title: z.string().min(1).max(300).optional(),
description: z.string().min(1).optional(),
reward: z.string().optional(),
deadline: z.date().optional(),
status: z.enum(['open', 'in_progress', 'completed', 'cancelled', 'disputed']).optional(),
}))
.mutation(async ({ input, ctx }) => {
const bounty = await db.getBountyById(input.id);
if (!bounty) {
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
}
if (bounty.bounty.publisherId !== ctx.user.id && ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: '无权修改此悬赏' });
}
const { id, ...updateData } = input;
await db.updateBounty(id, updateData);
return { success: true };
}),
cancel: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const bounty = await db.getBountyById(input.id);
if (!bounty) {
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
}
if (bounty.bounty.publisherId !== ctx.user.id && ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: '无权取消此悬赏' });
}
if (bounty.bounty.status !== 'open') {
throw new TRPCError({ code: 'BAD_REQUEST', message: '只能取消开放中的悬赏' });
}
await db.updateBounty(input.id, { status: 'cancelled' });
return { success: true };
}),
complete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const bounty = await db.getBountyById(input.id);
if (!bounty) {
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
}
if (bounty.bounty.publisherId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有发布者可以确认完成' });
}
if (bounty.bounty.status !== 'in_progress') {
throw new TRPCError({ code: 'BAD_REQUEST', message: '只能完成进行中的悬赏' });
}
await db.updateBounty(input.id, {
status: 'completed',
completedAt: new Date()
});
// 创建通知给接单者
if (bounty.bounty.acceptorId) {
await db.createNotification({
userId: bounty.bounty.acceptorId,
type: 'bounty_completed',
title: '悬赏已完成',
content: `您接取的悬赏"${bounty.bounty.title}"已被确认完成`,
relatedId: input.id,
relatedType: 'bounty'
});
}
return { success: true };
}),
myPublished: protectedProcedure.query(async ({ ctx }) => {
return db.getUserBounties(ctx.user.id, 'published');
}),
myAccepted: protectedProcedure.query(async ({ ctx }) => {
return db.getUserBounties(ctx.user.id, 'accepted');
}),
}),
// ============ Bounty Application Routes ============
bountyApplication: router({
list: publicProcedure
.input(z.object({ bountyId: z.number() }))
.query(async ({ input }) => {
return db.getBountyApplications(input.bountyId);
}),
submit: protectedProcedure
.input(z.object({
bountyId: z.number(),
message: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const bounty = await db.getBountyById(input.bountyId);
if (!bounty) {
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
}
if (bounty.bounty.status !== 'open') {
throw new TRPCError({ code: 'BAD_REQUEST', message: '该悬赏不接受申请' });
}
if (bounty.bounty.publisherId === ctx.user.id) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能申请自己发布的悬赏' });
}
const existing = await db.getUserApplication(input.bountyId, ctx.user.id);
if (existing) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '您已经申请过此悬赏' });
}
await db.createBountyApplication({
bountyId: input.bountyId,
applicantId: ctx.user.id,
message: input.message,
});
// 通知发布者
await db.createNotification({
userId: bounty.bounty.publisherId,
type: 'bounty_accepted',
title: '新的悬赏申请',
content: `有人申请了您的悬赏"${bounty.bounty.title}"`,
relatedId: input.bountyId,
relatedType: 'bounty'
});
return { success: true };
}),
accept: protectedProcedure
.input(z.object({
applicationId: z.number(),
bountyId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const bounty = await db.getBountyById(input.bountyId);
if (!bounty) {
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
}
if (bounty.bounty.publisherId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有发布者可以接受申请' });
}
const applications = await db.getBountyApplications(input.bountyId);
const application = applications.find(a => a.application.id === input.applicationId);
if (!application) {
throw new TRPCError({ code: 'NOT_FOUND', message: '申请不存在' });
}
await db.updateBountyApplication(input.applicationId, 'accepted');
await db.updateBounty(input.bountyId, {
status: 'in_progress',
acceptorId: application.application.applicantId,
});
// 拒绝其他申请
for (const app of applications) {
if (app.application.id !== input.applicationId) {
await db.updateBountyApplication(app.application.id, 'rejected');
}
}
// 通知接单者
await db.createNotification({
userId: application.application.applicantId,
type: 'bounty_accepted',
title: '申请已被接受',
content: `您对悬赏"${bounty.bounty.title}"的申请已被接受`,
relatedId: input.bountyId,
relatedType: 'bounty'
});
return { success: true };
}),
myApplication: protectedProcedure
.input(z.object({ bountyId: z.number() }))
.query(async ({ input, ctx }) => {
return db.getUserApplication(input.bountyId, ctx.user.id);
}),
}),
// ============ Comment Routes ============
comment: router({
list: publicProcedure
.input(z.object({ bountyId: z.number() }))
.query(async ({ input }) => {
return db.getBountyComments(input.bountyId);
}),
create: protectedProcedure
.input(z.object({
bountyId: z.number(),
content: z.string().min(1),
parentId: z.number().optional(),
}))
.mutation(async ({ input, ctx }) => {
await db.createBountyComment({
bountyId: input.bountyId,
userId: ctx.user.id,
content: input.content,
parentId: input.parentId,
});
// 通知悬赏发布者(如果评论者不是发布者)
const bounty = await db.getBountyById(input.bountyId);
if (bounty && bounty.bounty.publisherId !== ctx.user.id) {
await db.createNotification({
userId: bounty.bounty.publisherId,
type: 'new_comment',
title: '新评论',
content: `您的悬赏"${bounty.bounty.title}"收到了新评论`,
relatedId: input.bountyId,
relatedType: 'bounty'
});
}
return { success: true };
}),
}),
// ============ Payment Routes ============
payment: router({
createEscrow: protectedProcedure
.input(z.object({ bountyId: z.number() }))
.mutation(async ({ input, ctx }) => {
const bounty = await db.getBountyById(input.bountyId);
if (!bounty) {
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
}
if (bounty.bounty.publisherId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有发布者可以托管赏金' });
}
if (bounty.bounty.isEscrowed) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '赏金已经托管' });
}
const amount = Math.round(parseFloat(bounty.bounty.reward) * 100); // Convert to cents
const origin = ctx.req.headers.origin || 'http://localhost:3000';
const session = await createBountyEscrowCheckout({
bountyId: input.bountyId,
bountyTitle: bounty.bounty.title,
amount,
userId: ctx.user.id,
userEmail: ctx.user.email || '',
userName: ctx.user.name || '',
origin,
});
return { checkoutUrl: session.url };
}),
setupConnectAccount: protectedProcedure.mutation(async ({ ctx }) => {
// Check if user already has a connect account
const user = await db.getUserById(ctx.user.id);
if (user?.stripeAccountId) {
// Return account link for existing account
const origin = ctx.req.headers.origin || 'http://localhost:3000';
const accountLink = await createAccountLink({
accountId: user.stripeAccountId,
origin,
});
return { accountLinkUrl: accountLink.url };
}
// Create new connect account
const account = await createConnectAccount({
userId: ctx.user.id,
email: ctx.user.email || '',
});
// Save account ID to user
await db.updateUserStripeInfo(ctx.user.id, undefined, account.id);
// Create account link
const origin = ctx.req.headers.origin || 'http://localhost:3000';
const accountLink = await createAccountLink({
accountId: account.id,
origin,
});
return { accountLinkUrl: accountLink.url };
}),
getConnectStatus: protectedProcedure.query(async ({ ctx }) => {
const user = await db.getUserById(ctx.user.id);
if (!user?.stripeAccountId) {
return { hasAccount: false, chargesEnabled: false, payoutsEnabled: false };
}
const status = await getAccountStatus(user.stripeAccountId);
return {
hasAccount: true,
...status,
};
}),
releasePayout: protectedProcedure
.input(z.object({ bountyId: z.number() }))
.mutation(async ({ input, ctx }) => {
const bounty = await db.getBountyById(input.bountyId);
if (!bounty) {
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
}
if (bounty.bounty.publisherId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: '只有发布者可以释放赏金' });
}
if (bounty.bounty.status !== 'completed') {
throw new TRPCError({ code: 'BAD_REQUEST', message: '只有已完成的悬赏才能释放赏金' });
}
if (bounty.bounty.isPaid) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '赏金已经释放' });
}
if (!bounty.bounty.acceptorId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '没有接单者' });
}
// Get acceptor's stripe account
const acceptor = await db.getUserById(bounty.bounty.acceptorId);
if (!acceptor?.stripeAccountId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '接单者未设置收款账户' });
}
const amount = Math.round(parseFloat(bounty.bounty.reward) * 100 * 0.9); // 90% to acceptor
const transfer = await transferToConnectedAccount({
amount,
destinationAccountId: acceptor.stripeAccountId,
bountyId: input.bountyId,
});
// Update bounty
await db.updateBounty(input.bountyId, {
isPaid: true,
stripeTransferId: transfer.id,
});
// Notify acceptor
await db.createNotification({
userId: bounty.bounty.acceptorId,
type: 'payment_received',
title: '赏金已到账',
content: `悬赏“${bounty.bounty.title}”的赏金已转入您的账户`,
relatedId: input.bountyId,
relatedType: 'bounty',
});
return { success: true };
}),
}),
// ============ Favorite Routes ============
favorite: router({
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserFavorites(ctx.user.id);
}),
isFavorited: protectedProcedure
.input(z.object({ productId: z.number(), websiteId: z.number() }))
.query(async ({ input, ctx }) => {
return db.isFavorited(ctx.user.id, input.productId, input.websiteId);
}),
add: protectedProcedure
.input(z.object({ productId: z.number(), websiteId: z.number() }))
.mutation(async ({ input, ctx }) => {
const exists = await db.isFavorited(ctx.user.id, input.productId, input.websiteId);
if (exists) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '该商品已收藏' });
}
await db.addFavorite(ctx.user.id, input.productId, input.websiteId);
return { success: true };
}),
remove: protectedProcedure
.input(z.object({ productId: z.number(), websiteId: z.number() }))
.mutation(async ({ input, ctx }) => {
await db.removeFavorite(ctx.user.id, input.productId, input.websiteId);
return { success: true };
}),
// Tag management
tags: router({
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserFavoriteTags(ctx.user.id);
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
color: z.string().optional(),
description: z.string().optional()
}))
.mutation(async ({ input, ctx }) => {
await db.createFavoriteTag(ctx.user.id, input.name, input.color, input.description);
return { success: true };
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).max(100).optional(),
color: z.string().optional(),
description: z.string().optional()
}))
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
await db.updateFavoriteTag(id, ctx.user.id, data);
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
await db.deleteFavoriteTag(input.id, ctx.user.id);
return { success: true };
}),
addToFavorite: protectedProcedure
.input(z.object({ favoriteId: z.number(), tagId: z.number() }))
.mutation(async ({ input }) => {
await db.addTagToFavorite(input.favoriteId, input.tagId);
return { success: true };
}),
removeFromFavorite: protectedProcedure
.input(z.object({ favoriteId: z.number(), tagId: z.number() }))
.mutation(async ({ input }) => {
await db.removeTagFromFavorite(input.favoriteId, input.tagId);
return { success: true };
}),
getByTag: protectedProcedure
.input(z.object({ tagId: z.number() }))
.query(async ({ input, ctx }) => {
return db.getFavoritesByTag(ctx.user.id, input.tagId);
}),
}),
}),
// ============ Price Monitor Routes ============
priceMonitor: router({
create: protectedProcedure
.input(z.object({
favoriteId: z.number(),
currentPrice: z.number().optional(),
targetPrice: z.number().optional()
}))
.mutation(async ({ input, ctx }) => {
await db.createPriceMonitor(ctx.user.id, input.favoriteId, input.currentPrice, input.targetPrice);
return { success: true };
}),
get: protectedProcedure
.input(z.object({ favoriteId: z.number() }))
.query(async ({ input, ctx }) => {
return db.getPriceMonitor(ctx.user.id, input.favoriteId);
}),
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserPriceMonitors(ctx.user.id);
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
currentPrice: z.number().optional(),
targetPrice: z.number().optional(),
isActive: z.boolean().optional()
}))
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
const updateData: any = {};
if (data.currentPrice !== undefined) updateData.currentPrice = data.currentPrice.toString();
if (data.targetPrice !== undefined) updateData.targetPrice = data.targetPrice.toString();
if (data.isActive !== undefined) updateData.isActive = data.isActive;
await db.updatePriceMonitor(id, updateData);
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.deletePriceMonitor(input.id);
return { success: true };
}),
recordPrice: protectedProcedure
.input(z.object({
monitorId: z.number(),
price: z.number(),
previousPrice: z.number().optional()
}))
.mutation(async ({ input }) => {
await db.recordPriceHistory(input.monitorId, input.price, input.previousPrice);
return { success: true };
}),
history: protectedProcedure
.input(z.object({ monitorId: z.number(), limit: z.number().default(30) }))
.query(async ({ input }) => {
return db.getPriceHistory(input.monitorId, input.limit);
}),
batchCreate: protectedProcedure
.input(z.object({
favoriteIds: z.array(z.number()),
targetPrice: z.number()
}))
.mutation(async ({ input, ctx }) => {
const results = [];
for (const favId of input.favoriteIds) {
try {
await db.createPriceMonitor(ctx.user.id, favId, undefined, input.targetPrice);
results.push({ favId, success: true });
} catch (error) {
results.push({ favId, success: false, error: String(error) });
}
}
return { results, totalSuccess: results.filter(r => r.success).length };
}),
batchUpdate: protectedProcedure
.input(z.object({
monitorIds: z.array(z.number()),
targetPrice: z.number()
}))
.mutation(async ({ input }) => {
const results = [];
for (const monitorId of input.monitorIds) {
try {
await db.updatePriceMonitor(monitorId, { targetPrice: input.targetPrice.toString() });
results.push({ monitorId, success: true });
} catch (error) {
results.push({ monitorId, success: false, error: String(error) });
}
}
return { results, totalSuccess: results.filter(r => r.success).length };
}),
batchDelete: protectedProcedure
.input(z.object({ monitorIds: z.array(z.number()) }))
.mutation(async ({ input }) => {
const results = [];
for (const monitorId of input.monitorIds) {
try {
await db.deletePriceMonitor(monitorId);
results.push({ monitorId, success: true });
} catch (error) {
results.push({ monitorId, success: false, error: String(error) });
}
}
return { results, totalSuccess: results.filter(r => r.success).length };
}),
}),
// ============ Notification Routes ============
notification: router({
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserNotifications(ctx.user.id);
}),
unreadCount: protectedProcedure.query(async ({ ctx }) => {
return db.getUnreadNotificationCount(ctx.user.id);
}),
markAsRead: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
await db.markNotificationAsRead(input.id, ctx.user.id);
return { success: true };
}),
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
await db.markAllNotificationsAsRead(ctx.user.id);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;