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
+43
View File
@@ -14,12 +14,14 @@ interface Skill {
starters: string[]; tasks: SkillTask[]; tags: string[];
prerequisites?: string[];
}
interface AffiliateLink { id: number; title: string; description: string | null; url: string }
export default function SkillDetailPage() {
const params = useParams();
const t = useT();
const [skill, setSkill] = useState<Skill | null>(null);
const [loading, setLoading] = useState(true);
const [links, setLinks] = useState<AffiliateLink[]>([]);
useEffect(() => {
if (!params.id) return;
@@ -29,6 +31,24 @@ export default function SkillDetailPage() {
.finally(() => setLoading(false));
}, [params.id]);
useEffect(() => {
if (!skill?.id) return;
fetch(`${API_BASE}/affiliate/links?skillId=${skill.id}`)
.then(r => r.json())
.then(data => setLinks(data.links || []))
.catch(() => {});
}, [skill?.id]);
async function handlePromote(link: AffiliateLink) {
try {
const res = await fetch(`${API_BASE}/affiliate/click/${link.id}`, { method: 'POST' });
const data = await res.json();
if (data.redirectUrl) window.open(data.redirectUrl, '_blank', 'noopener,noreferrer');
} catch {
window.open(link.url, '_blank', 'noopener,noreferrer');
}
}
if (loading) return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
@@ -108,6 +128,29 @@ export default function SkillDetailPage() {
</div>
</div>
)}
{links.length > 0 && (
<div className="mt-6 bg-card rounded-2xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-3">{t.affiliate.recommendForSkill}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{links.map(link => (
<div key={link.id} className="flex items-center justify-between gap-3 p-3 border border-border rounded-xl">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate">{link.title}</div>
{link.description && (
<div className="text-xs text-muted-foreground line-clamp-1">{link.description}</div>
)}
</div>
<button
onClick={() => handlePromote(link)}
className="shrink-0 px-3 py-1.5 text-xs bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors">
{t.affiliate.promoteLink}
</button>
</div>
))}
</div>
</div>
)}
</div>
);
}