From 00a3904eba67ee93c466f95b4aee3151d0fe8785 Mon Sep 17 00:00:00 2001 From: TradeMate Dev Date: Sat, 11 Jul 2026 15:02:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=90=BD=E5=9C=B0=E6=89=93=E8=B5=8F(?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E7=A0=81=E6=8D=90=E8=B5=A0)=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=B9=B6=E5=B0=86=E4=BB=98=E8=B4=B9=E9=9D=A2=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=85=8D=E8=B4=B9+=E8=B5=9E=E5=8A=A9(=E6=97=A0ICP?= =?UTF-8?q?=E8=AF=81=E5=90=88=E8=A7=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Donation 模型/迁移/后端 API(GET,POST /donations) - 新增前端 /donate 页:收款码+感谢留言+感谢墙 - 会员页/技能广场/用量包 下架付费,改免费开放+赞助入口 - 沙箱用量耗尽引导至 /donate 赞助 - 导航与页脚新增 赞助/联盟返佣 入口,补充 i18n --- .../migration.sql | 13 + backend/prisma/schema.prisma | 12 + backend/src/app.module.ts | 2 + .../modules/donations/donations.controller.ts | 20 ++ .../src/modules/donations/donations.module.ts | 12 + .../modules/donations/donations.service.ts | 51 ++++ .../donations/dto/create-donation.dto.ts | 22 ++ docs/progress/current.md | 36 ++- frontend/src/app/donate/page.tsx | 225 +++++++++++++++ frontend/src/app/marketplace/page.tsx | 249 +++++----------- frontend/src/app/my/member/page.tsx | 265 +++++------------- frontend/src/app/practices/packages/page.tsx | 174 ++++-------- frontend/src/app/sandbox/page.tsx | 39 ++- frontend/src/components/layout/footer.tsx | 2 + frontend/src/components/layout/header.tsx | 2 + frontend/src/i18n/locales/en.ts | 7 +- frontend/src/i18n/locales/zh.ts | 7 +- 17 files changed, 600 insertions(+), 538 deletions(-) create mode 100644 backend/prisma/migrations/20260711010000_add_donations/migration.sql create mode 100644 backend/src/modules/donations/donations.controller.ts create mode 100644 backend/src/modules/donations/donations.module.ts create mode 100644 backend/src/modules/donations/donations.service.ts create mode 100644 backend/src/modules/donations/dto/create-donation.dto.ts create mode 100644 frontend/src/app/donate/page.tsx diff --git a/backend/prisma/migrations/20260711010000_add_donations/migration.sql b/backend/prisma/migrations/20260711010000_add_donations/migration.sql new file mode 100644 index 0000000..9496077 --- /dev/null +++ b/backend/prisma/migrations/20260711010000_add_donations/migration.sql @@ -0,0 +1,13 @@ +-- 支持模式:用户自愿打赏(个人收款码捐赠)留言记录 +-- 平台不代收任何费用,此处仅用于展示「感谢墙」,不记录支付流水 + +CREATE TABLE IF NOT EXISTS `donations` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(191), + `message` VARCHAR(280), + `channel` VARCHAR(191) NOT NULL DEFAULT 'ALIPAY', + `amount` DOUBLE, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + INDEX `donations_created_at_idx` (`created_at`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3b7a15f..2b1395a 100755 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -718,3 +718,15 @@ model AffiliateClick { @@index([linkId]) @@map("affiliate_clicks") } + +model Donation { + id Int @id @default(autoincrement()) + name String? + message String? @db.VarChar(280) + channel String @default("ALIPAY") + amount Float? + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@map("donations") +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4b86070..a67e8ac 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -25,6 +25,7 @@ import { LearningModule } from './modules/learning/learning.module'; import { SkillsModule } from './modules/skills/skills.module'; import { AiAssistantModule } from './modules/ai-assistant/ai-assistant.module'; import { PracticesModule } from './modules/practices/practices.module'; +import { DonationsModule } from './modules/donations/donations.module'; import { RedisThrottlerStorage } from './common/redis-throttler-storage'; @Module({ @@ -45,6 +46,7 @@ import { RedisThrottlerStorage } from './common/redis-throttler-storage'; EnterpriseModule, NotificationModule, LearningModule, SkillsModule, AiAssistantModule, PracticesModule, + DonationsModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, diff --git a/backend/src/modules/donations/donations.controller.ts b/backend/src/modules/donations/donations.controller.ts new file mode 100644 index 0000000..d7e82ed --- /dev/null +++ b/backend/src/modules/donations/donations.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Post, Body, Query, HttpCode } from '@nestjs/common'; +import { DonationsService } from './donations.service'; +import { CreateDonationDto } from './dto/create-donation.dto'; + +@Controller('donations') +export class DonationsController { + constructor(private readonly donationsService: DonationsService) {} + + @Get() + getList(@Query('limit') limit?: string) { + const parsed = limit ? Math.min(parseInt(limit, 10) || 20, 50) : 20; + return this.donationsService.getList(parsed); + } + + @Post() + @HttpCode(200) + create(@Body() dto: CreateDonationDto) { + return this.donationsService.create(dto); + } +} diff --git a/backend/src/modules/donations/donations.module.ts b/backend/src/modules/donations/donations.module.ts new file mode 100644 index 0000000..00b2b55 --- /dev/null +++ b/backend/src/modules/donations/donations.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DonationsService } from './donations.service'; +import { DonationsController } from './donations.controller'; +import { PrismaModule } from '../../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [DonationsController], + providers: [DonationsService], + exports: [DonationsService], +}) +export class DonationsModule {} diff --git a/backend/src/modules/donations/donations.service.ts b/backend/src/modules/donations/donations.service.ts new file mode 100644 index 0000000..896b397 --- /dev/null +++ b/backend/src/modules/donations/donations.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class DonationsService { + constructor(private prisma: PrismaService) {} + + async getList(limit = 20) { + const donations = await this.prisma.donation.findMany({ + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + name: true, + message: true, + channel: true, + amount: true, + createdAt: true, + }, + }); + return { + donations: donations.map((d) => ({ + ...d, + amount: d.amount != null ? Number(d.amount) : null, + })), + }; + } + + async create(data: { + name?: string; + message?: string; + channel?: 'ALIPAY' | 'WECHAT'; + amount?: number; + }) { + const donation = await this.prisma.donation.create({ + data: { + name: data.name ?? null, + message: data.message ?? null, + channel: data.channel ?? 'ALIPAY', + amount: data.amount != null ? data.amount : null, + }, + }); + return { + success: true, + donation: { + ...donation, + amount: donation.amount != null ? Number(donation.amount) : null, + }, + }; + } +} diff --git a/backend/src/modules/donations/dto/create-donation.dto.ts b/backend/src/modules/donations/dto/create-donation.dto.ts new file mode 100644 index 0000000..a505cc7 --- /dev/null +++ b/backend/src/modules/donations/dto/create-donation.dto.ts @@ -0,0 +1,22 @@ +import { IsOptional, IsString, IsIn, IsNumber, MaxLength, Min } from 'class-validator'; + +export class CreateDonationDto { + @IsOptional() + @IsString() + @MaxLength(40) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(280) + message?: string; + + @IsOptional() + @IsIn(['ALIPAY', 'WECHAT']) + channel?: 'ALIPAY' | 'WECHAT'; + + @IsOptional() + @IsNumber({ allowNaN: false, allowInfinity: false }) + @Min(0) + amount?: number; +} diff --git a/docs/progress/current.md b/docs/progress/current.md index 2a61284..7ecf611 100644 --- a/docs/progress/current.md +++ b/docs/progress/current.md @@ -1,17 +1,17 @@ # 宇之然 AI 平台 - 进度追踪 -**文档版本:** v1.1.0 +**文档版本:** v1.2.0 **最后更新:** 2026-07-11 -**状态:** 🟢 联盟返佣系统核心已完成(管理后台 UI 待补) +**状态:** 🟢 联盟返佣 + 打赏(个人码捐赠)双变现模式已落地;付费订阅/付费 Skill/用量包已下线 **负责人:** Hermes Agent --- ## 项目概览 -- **目标:** 构建联盟返佣系统,并将「技能广场」与推广链接打通,作为当前(无 ICP 经营许可证前)合规的主力变现路径 -- **合规定位:** 会员付费/付费 Skill 属经营性收费,需 ICP 经营许可证。在取证前,以**联盟返佣**(第三方平台结算佣金,非平台经营性收款)为主力变现,配合免费内容 + SEO -- **范围:** 数据库模型(规范化)、后端 API(ORM,去 SQL 注入)、前端展示与点击追踪、单元测试、种子数据 +- **目标:** 在「无 ICP 经营许可证」阶段,将全部经营性收费(会员订阅、付费 Skill、沙箱用量包)下架,改为合规的「打赏(个人码捐赠)+ 联盟返佣」模式 +- **合规定位:** 会员付费/付费 Skill/虚拟商品属经营性收费,需 ICP 经营许可证。在取证前:以**联盟返佣**(第三方平台结算佣金,非平台经营性收款)为主力变现;以**打赏/捐赠**(个人收款码,自愿赠与,平台不提供对价)作为补充;所有内容(技能、练习、工具指南)保持免费开放 +- **范围:** 打赏数据模型与 API、前端 /donate 页、会员/技能广场/用量包三处付费面改造为免费+赞助、导航与页脚入口、单元测试、种子数据 --- @@ -28,23 +28,44 @@ | 测试 | `affiliate.service.spec.ts` | 4 个用例(聚合/点击/异常/过滤),已通过 | | 种子 | `prisma/seed-affiliate.ts` | 幂等 upsert 阿里云云大使计划 + 推广链接;`npx ts-node prisma/seed-affiliate.ts` | +### 打赏(个人码捐赠)模式 — v1.2.0 + +| 模块 | 内容 | 说明 | +|------|------|------| +| 数据模型 | `Donation` | Prisma 模型(`name`/`message`/`channel`/`amount`/`createdAt`),仅用于「感谢墙」展示,不记录支付流水 | +| 迁移 | `prisma/migrations/20260711010000_add_donations` | 建 `donations` 表;部署时 `prisma migrate deploy` | +| 后端服务 | `donations.service.ts` | `getList(limit)` 最近留言、`create(dto)` 保存自愿留言 | +| 后端接口 | `donations.controller.ts` | `GET /donations`、`POST /donations`(公开,无鉴权) | +| 前端页 | `donate/page.tsx` | 个人收款码(支付宝/微信,环境变量 `NEXT_PUBLIC_DONATE_ALIPAY_QR`/`NEXT_PUBLIC_DONATE_WECHAT_QR`)+ 感谢留言表单 + 感谢墙 | +| 会员页 | `my/member/page.tsx` | 移除付费订阅与 PaymentModal,改为免费说明 + 赞助/联盟入口 | +| 技能广场 | `marketplace/page.tsx` | 所有 Skill 免费开放,下架付费购买与 PaymentModal | +| 用量包 | `practices/packages/page.tsx` | 下架付费用量包,仅展示免费配额 + 赞助入口 | +| 沙箱 | `sandbox/page.tsx` | 用量耗尽提示改为引导至 /donate 赞助 | +| 导航/页脚 | `header.tsx` / `footer.tsx` | 新增「赞助」「联盟返佣」入口 | +| i18n | `zh.ts` / `en.ts` | 新增 `donate.*`、导航 `affiliate`/`donate`、说明文案 | + +> **说明:** 后端 `orders` / `payment` 模块代码保留(取证后可复用),但前端已不再调用付费购买流程。 + --- ## 待办(下一阶段) | 任务 | 说明 | |------|------| +| 部署配置 | 在部署环境设置支付宝/微信收款码环境变量(`NEXT_PUBLIC_DONATE_*_QR`) | | 管理后台 UI | AffiliateProgram/Link 的列表与编辑页(T-009~T-013) | | 首页/工具页嵌入 | 在首页与工具详情侧边栏插入「云产品推荐」区块(T-016/T-017) | | 曝光埋点 | 记录推广位曝光(目前仅记录点击) | +| 后端捐赠单测 | 为 `donations.service.spec.ts` 补充用例(mock Prisma) | --- ## 变现模式排序(无 ICP 证阶段) 1. **联盟返佣**(已落地)— 风险最低,第三方结算佣金 -2. 免费内容 + SEO 引流 -3. (取证后)会员订阅 / 付费 Skill / 企业版 +2. **打赏/个人码捐赠**(已落地)— 自愿赠与,平台不提供对价,不构成经营性收费 +3. 免费内容 + SEO 引流 +4. (取证后)会员订阅 / 付费 Skill / 企业版 --- @@ -54,3 +75,4 @@ |------|------|------|----------| | 2026-06-25 | v1.0.0 | Hermes Agent | 初始化进度文档 | | 2026-07-11 | v1.1.0 | Hermes Agent | 规范化联盟数据模型+迁移+ORM服务+前端打通+测试+种子;明确无 ICP 证下的合规变现路径 | +| 2026-07-11 | v1.2.0 | Hermes Agent | 落地打赏(个人码捐赠)模式:Donation 模型+迁移+API+前端 /donate 页;会员/技能广场/用量包三处付费面改造为免费+赞助;导航页脚入口;i18n 中文化 | diff --git a/frontend/src/app/donate/page.tsx b/frontend/src/app/donate/page.tsx new file mode 100644 index 0000000..3efd286 --- /dev/null +++ b/frontend/src/app/donate/page.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Heart, QrCode, MessageCircle, Send } from 'lucide-react'; +import { API_BASE } from '@/lib/config'; +import { apiFetch } from '@/lib/auth'; +import { useT } from '@/i18n'; + +const ALIPAY_QR = process.env.NEXT_PUBLIC_DONATE_ALIPAY_QR || ''; +const WECHAT_QR = process.env.NEXT_PUBLIC_DONATE_WECHAT_QR || ''; + +interface Donation { + id: number; + name: string | null; + message: string | null; + channel: string; + amount: number | null; + createdAt: string; +} + +function channelLabel(t: ReturnType, channel: string) { + if (channel === 'WECHAT') return t.donate.wechat; + return t.donate.alipay; +} + +function QrCard({ + t, + label, + src, +}: { + t: ReturnType; + label: string; + src: string; +}) { + return ( + +
+ +
+

{label}

+ {src ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {label} +
+ ) : ( +

{t.donate.noQr}

+ )} +
+ ); +} + +export default function DonatePage() { + const t = useT(); + const [donations, setDonations] = useState([]); + const [loading, setLoading] = useState(true); + const [name, setName] = useState(''); + const [message, setMessage] = useState(''); + const [channel, setChannel] = useState<'ALIPAY' | 'WECHAT'>('ALIPAY'); + const [submitting, setSubmitting] = useState(false); + const [done, setDone] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + fetch(`${API_BASE}/donations`) + .then((r) => r.json()) + .then((data) => setDonations(data.donations || [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(false); + try { + const res = await apiFetch(`${API_BASE}/donations`, { + method: 'POST', + body: JSON.stringify({ + name: name.trim() || undefined, + message: message.trim() || undefined, + channel, + }), + }); + if (res.ok) { + setDone(true); + setName(''); + setMessage(''); + const data = await res.json(); + if (data.donation) { + setDonations((prev) => [data.donation, ...prev].slice(0, 20)); + } + } else { + setError(true); + } + } catch { + setError(true); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+ +
+

{t.donate.title}

+

{t.donate.desc}

+
+ + +

{t.donate.supportTitle}

+

{t.donate.supportDesc}

+

{t.donate.qrNote}

+
+ +
+ + +
+ + +
+ +

{t.donate.leaveMessage}

+
+ {done ? ( +
+

{t.donate.submitSuccess}

+ +
+ ) : ( +
+ setName(e.target.value)} + placeholder={t.donate.namePlaceholder} + maxLength={40} + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground outline-none focus:border-brand-500" + /> +