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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user