feat: 联盟返佣系统规范化并打通技能广场(无 ICP 证合规变现主路径)

- 新增 AffiliateProgram / AffiliateLink / AffiliateClick 规范化 Prisma 模型
  取代原手写裸表 affiliate_stats / affiliate_clicks
- 新增迁移 prisma/migrations/20260711000000_add_affiliate_models
- 重构 affiliate.service.ts 改用 Prisma ORM,消除 $queryRawUnsafe SQL 注入
- 重构 affiliate.controller.ts 接口:programs / links(?skillId,?toolId) / stats / click
- 前端 affiliate 页接入真实接口,移除硬编码 demo 数据
- 技能详情页新增「学此技能推荐使用的工具」联盟链接区块
- 新增 affiliate.service.spec.ts(4 用例通过)与幂等种子 seed-affiliate.ts
- 更新 docs/progress/current.md,明确无 ICP 经营许可证下以联盟返佣为合规变现主路径
- 含此前工作区未提交改动(工具 slug 路由、支付/订单、SEO 等)

Co-Authored-By: opencode <opencode@anthropic.com>
This commit is contained in:
TradeMate Dev
2026-07-11 14:47:41 +08:00
parent 05bf267d09
commit 31cc1e02ce
46 changed files with 3565 additions and 1201 deletions
+241
View File
@@ -0,0 +1,241 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Wrench, ExternalLink, Star, ArrowLeft } from 'lucide-react';
interface ToolDetail {
id: number;
slug: string;
name: string;
metaTitle: string | null;
metaDesc: string | null;
description: string | null;
content: string | null;
url: string;
icon: string | null;
affiliateLink: string | null;
tags: string | null;
isFeatured: boolean;
viewCount: number;
category: { id: number; name: string } | null;
}
async function getTool(slug: string): Promise<ToolDetail | null> {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1'}/tools/${slug}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const tool = await getTool(params.slug);
if (!tool) return { title: '工具未找到' };
return {
title: tool.metaTitle || `${tool.name} - 功能介绍、使用教程、评测 | 宇之然 AI 工具指南`,
description: tool.metaDesc || tool.description || `${tool.name} AI工具详细介绍、使用教程和评测`,
openGraph: {
title: tool.metaTitle || `${tool.name} - AI工具评测`,
description: tool.metaDesc || tool.description || '',
type: 'article',
},
};
}
export async function generateStaticParams() {
let apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
// Build-time fetch requires absolute URL; if relative, prefix localhost:4000
if (!apiBase.startsWith('http')) {
apiBase = `http://localhost:4000${apiBase.startsWith('/') ? '' : '/'}${apiBase}`;
}
try {
const res = await fetch(`${apiBase}/tools?pageSize=100`);
const data = await res.json();
return (data.items || []).map((tool: any) => ({ slug: tool.slug }));
} catch {
return [];
}
}
export default async function ToolDetailPage({ params }: { params: { slug: string } }) {
const tool = await getTool(params.slug);
if (!tool) notFound();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: tool.name,
description: tool.description,
url: tool.url,
applicationCategory: 'AI工具',
};
// 解析描述中的结构化字段
function parseSection(desc: string | null, key: string): string | null {
if (!desc) return null;
const match = desc.match(new RegExp(`${key}\\s*(.+?)(?:\\n\\n|$)`, 's'));
return match ? match[1].trim() : null;
}
function parseBaseDesc(desc: string | null): string {
if (!desc) return '';
return desc.split('\n\n')[0] || '';
}
const reason = parseSection(tool.description, '推荐理由');
const audience = parseSection(tool.description, '适合人群');
const baseDesc = parseBaseDesc(tool.description);
// 收益标签映射
const roiTags: Record<string, string> = {
'编程': '日省2-4小时编码时间',
'图像': '替代部分设计师,省¥1000-5000/月',
'视频': '短视频效率提升5-10倍',
'对话': '替代部分人工客服,省¥3000-8000/月',
'办公': '文档效率提升3-5倍',
'搜索': '调研效率提升5-10倍',
'语音': '语音处理效率提升10倍',
};
const category = tool.tags ? tool.tags.split(',')[0] : '';
const roiTag = roiTags[category] || '提升工作效率';
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 面包屑导航 */}
<nav className="flex items-center gap-2 text-sm text-muted-foreground mb-6">
<Link href="/" className="hover:text-foreground"></Link>
<span>/</span>
<Link href="/tools" className="hover:text-foreground">AI工具</Link>
<span>/</span>
<span className="text-foreground">{tool.name}</span>
</nav>
{/* 工具头部 */}
<div className="flex items-start gap-4 mb-8">
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-2xl flex items-center justify-center shrink-0">
{tool.icon ? (
<img src={tool.icon} alt={tool.name} className="w-10 h-10" />
) : (
<Wrench className="w-8 h-8 text-brand-600 dark:text-brand-400" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-3xl font-bold text-foreground">{tool.name}</h1>
{tool.isFeatured && <Star className="w-5 h-5 text-amber-500 fill-amber-500" />}
</div>
{tool.category && (
<Badge variant="secondary" className="mt-2">{tool.category.name}</Badge>
)}
{baseDesc && <p className="mt-3 text-lg text-muted-foreground">{baseDesc}</p>}
</div>
</div>
{/* 推荐理由 + 收益标签 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{reason && (
<Card className="p-5 bg-gradient-to-br from-brand-50 to-amber-50/50 dark:from-brand-950/40 dark:to-amber-950/20 border-brand-200 dark:border-brand-800">
<div className="flex items-start gap-3">
<span className="text-2xl">💡</span>
<div>
<h3 className="font-semibold text-brand-700 dark:text-brand-300 text-sm mb-1"></h3>
<p className="text-sm text-foreground">{reason}</p>
</div>
</div>
</Card>
)}
<Card className="p-5 bg-gradient-to-br from-emerald-50 to-teal-50/50 dark:from-emerald-950/40 dark:to-teal-950/20 border-emerald-200 dark:border-emerald-800">
<div className="flex items-start gap-3">
<span className="text-2xl">📈</span>
<div>
<h3 className="font-semibold text-emerald-700 dark:text-emerald-300 text-sm mb-1"></h3>
<p className="text-sm text-foreground">{roiTag}</p>
</div>
</div>
</Card>
</div>
{/* 适用人群 */}
{audience && (
<Card className="p-5 mb-8 bg-gradient-to-br from-purple-50 to-pink-50/50 dark:from-purple-950/40 dark:to-pink-950/20 border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-3">
<span className="text-2xl">👥</span>
<div>
<h3 className="font-semibold text-purple-700 dark:text-purple-300 text-sm mb-1"></h3>
<p className="text-sm text-foreground">{audience}</p>
</div>
</div>
</Card>
)}
{/* 操作按钮 */}
<div className="flex gap-3 mb-10">
<a href={tool.url} target="_blank" rel="noopener noreferrer">
<Button className="gap-2">
<ExternalLink className="w-4 h-4" />
访 {tool.name}
</Button>
</a>
<Link href="/sandbox">
<Button variant="outline" className="gap-2">
</Button>
</Link>
</div>
{/* 详细介绍 */}
{tool.content && (
<Card className="p-6 mb-8">
<div className="prose prose-sm dark:prose-invert max-w-none">
{tool.content.split('\n').map((line, i) => {
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-bold mt-6 mb-3">{line.slice(3)}</h2>;
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-bold mt-4 mb-2">{line.slice(4)}</h3>;
if (line.startsWith('- **')) {
const match = line.match(/- \*\*(.+?)\*\*(:?)\s*(.*)/);
if (match) return <p key={i}><strong>{match[1]}</strong>{match[3] && `: ${match[3]}`}</p>;
}
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>;
if (line.trim() === '') return <div key={i} className="h-2" />;
return <p key={i} className="mb-2">{line}</p>;
})}
</div>
</Card>
)}
{/* 标签 */}
{tool.tags && (
<div className="flex gap-2 flex-wrap mb-8">
{tool.tags.split(',').map(tag => (
<Badge key={tag} variant="secondary">{tag.trim()}</Badge>
))}
</div>
)}
{/* CTA */}
<Card className="p-6 bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-950/30 dark:to-blue-950/20">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h3 className="font-semibold text-lg">使 {tool.name} </h3>
</div>
<a href={tool.url} target="_blank" rel="noopener noreferrer">
<Button>使 </Button>
</a>
</div>
</Card>
{/* 浏览量 */}
<p className="text-xs text-muted-foreground mt-6 text-center">
{tool.viewCount}
</p>
</div>
</>
);
}