﻿# AiPay 接入任务单 — 请按顺序执行以下任务

你是一个**接入工程师 AI Agent**。你的任务是帮我在当前项目中完整接入 AiPay 订阅制收银台。
请严格按照下面的任务清单逐一执行，每完成一项打 ✅ 并汇报结果。

## 🎯 三个最常见的认知误区（先纠正再干活）

> 这三点你**很容易踩坑**，扫一眼就明白：

1. **「AiPay 没有用户登录系统」** — 错！AiPay **内置了完整账号系统**（注册 / 登录 / 找回密码 / session_token）。**别自己写邮箱注册登录**。一行 iframe 即可嵌入：`<iframe src="{{ORIGIN}}/auth/embedded?project={{PROJECT_ID}}">`。详见任务 0 后的「🔐」章节。
2. **「/api/check 只是状态展示」** — 错！**这是你确定用户已付款的唯一途径**。没它你的项目永远不知道谁付了钱。详见「🔁 支付确认闭环」章节。
3. **「AIPAY_API_BASE = 自己的 Supabase 函数 URL」** — 错！它必须填 **AiPay 官方域名**（如 `https://pay.aipay.ai`）。详见下方「📍 三种 URL」。

---

## 📋 任务清单

按顺序执行，每完成一项打 ✅：

- [ ] **任务 0**：项目扫描与框架识别（前端/后端/语言/用户系统）
- [ ] **🔐 决策点**：根据任务 0 结果，**选择登录方案**（AiPay iframe / 项目自带 / 无需登录）
- [ ] **任务 1**：添加会员开通入口（跳转收银台）
- [ ] **任务 2**：后端代理查询权益（`/api/check`）
- [ ] **任务 3**：Webhook 接收端（可选，但推荐）
- [ ] **任务 4**：完整测试（支付 → 回调 → 状态更新）
- [ ] **任务 5**：常见问题排查（FAQ）

---

## 🔑 凭证信息（已内嵌，直接使用）

```
AIPAY_API_KEY={{API_KEY}}
AIPAY_CHECKOUT_BASE={{CHECKOUT_BASE}}
AIPAY_WEBHOOK_SECRET={{WEBHOOK_SECRET}}
AIPAY_PROJECT_ID={{PROJECT_ID}}
AIPAY_ORIGIN={{ORIGIN}}
AiPay API Base URL: {{API_BASE}}
```

商品信息：
- 商品名：{{PRODUCT_NAME}}
- Slug：`{{PRODUCT_SLUG}}`
- 收银台链接：`${{CHECKOUT_BASE}}/{{PRODUCT_SLUG}}?user_id=${userId}&user_label=${userLabel}`

> ⚠️ 安全建议：如果项目有后端，**AIPAY_API_KEY** 应放在**服务端**（后端代理调用 AiPay API），前端通过你自己的接口间接查询。但如果你是个纯前端静态小项目、没有后端，直接在浏览器里调用 AiPay API 也完全可以——Key 暴露的风险对于小项目来说可以忽略不计。

### 📍 三种 URL，不要搞混

| 变量 | 值 | 用途 | 用在哪 |
|------|-----|------|--------|
| **AIPAY_CHECKOUT_BASE** | {{CHECKOUT_BASE}} | 收银台页面地址（用户扫码付钱的地方） | **前端**：**\<a href>** 链接跳转 |
| **AiPay API Base** | {{API_BASE}} | AiPay API 地址（查权益/创建订单） | **后端**：**fetch()** 调用，带 **X-Api-Key** |
| **你的 Webhook URL** | https://你的域名/api/aipay/webhook | AiPay 通知你付款成功的地址 | **你的服务端**：接收 POST 请求 |

> **AIPAY_CHECKOUT_BASE**（前端跳转） ≠ AiPay API Base（后端调用） ≠ Webhook URL（你的服务器），三者完全不同！
>
> **AIPAY_PROJECT_ID** 是项目的唯一标识，调用 `/api/create_order`、iframe 登录 (`/auth/embedded?project=AIPAY_PROJECT_ID`)、配置 Webhook 时都要用到。当前值已内嵌到上方凭证信息块。

> 🚨 **坑3 · API_BASE 到底填什么？** `{{API_BASE}}` 是 **AiPay 官方域名**（如 `https://pay.aipay.ai`），**不是你自己 Supabase 项目的函数 URL**。
> - ✅ 正确：`AIPAY_API_BASE = https://pay.aipay.ai`（AiPay 官方）
> - ❌ 错误：`AIPAY_API_BASE = https://你的项目.supabase.co/functions/v1/make-server-xxxx`（你自己的 Supabase 函数 URL）
> - 如果你把 API_BASE 填成了自己的 Supabase 函数 URL，所有 `/api/check`、`/api/create_order` 调用都会打到错误地址，付款了也查不到权益。

---

## 任务 0：项目扫描与框架识别（自动执行）

**目标**：了解当前项目的技术栈和目录结构。

**请你执行**：
1. 扫描项目根目录的配置文件（package.json / go.mod / requirements.txt / pom.xml / Cargo.toml 等），识别：
   - **前端框架**：Next.js / React+Vite / Vue / Nuxt / Svelte / 纯 HTML
   - **后端框架**：Next.js API Routes / Express / Fastify / FastAPI / Django / Flask / Rails / Go Gin / 无后端(纯前端)
   - **语言**：TypeScript / JavaScript / Python / Go / Rust / Java
   - **包管理器**：pnpm / npm / yarn / pip / cargo
2. 找到以下关键位置并记录：
   - 路由定义在哪里？（pages/ / app/ / routes/ / urls.py）
   - API 层在哪里？（api/ / controllers/ / handlers/）
   - 环境变量文件在哪？（.env / .env.local / config/）
   - 组件/UI 入口在哪里？
3. 判断项目是否已有**用户认证系统**（登录/注册/session）

**输出格式**（完成后汇报）：
```
## 项目扫描结果
| 项目 | 值 |
|------|-----|
| 前端框架 | xxx |
| 后端框架 | xxx |
| 语言 | xxx |
| 用户系统 | 有 / 无 |
| 路由目录 | path/to/routes |
| API 目录 | path/to/api |
| 环境变量文件 | .env.xxx |
```

> 🚨 **关键决策点（任务 0 必读）**：扫描完成后，**如果「用户系统 = 无」**，**不要自己写邮箱注册 / 登录 / 密码哈希 / 找回密码**！
> **AiPay 内置了完整账号系统**（注册 / 登录 / 找回密码 / session_token），一行 iframe 即可嵌入。
> 直接看下一章节 **🔐 项目没有登录系统？一行 iframe 嵌入 AiPay 登录**（就在任务 0 后面）。
> 重复造登录系统 = 浪费 1-2 天 + 多一个攻击面。

---

## 🔁 支付确认闭环（必读！理解这个才能正确接入）

**核心问题**：用户付了钱，你的项目如何知道？

AiPay 有两层存储，你需要搞清它们的关系：

| 存储层 | 谁管理 | 存什么 | 你的项目能直接读吗 |
|--------|--------|--------|-------------------|
| **AiPay 云端权益** | AiPay 服务端 KV | 谁付了款、什么套餐、到期时间 | ✅ 通过 **/api/check** 查询 |
| **你的本地数据库** | 你的项目 | 用户资料、会员标记、业务数据 | ✅ 你自己的 DB |

**支付确认需要两步**：

```
用户扫码付款
  → 手机捕获通知 → 上报 AiPay → 匹配订单
  → AiPay 写入云端权益（存在 AiPay KV 中）
  → 你的项目调用 /api/check 读取云端权益
  → 展示"已开通会员"状态
```

> **⚠️ 关键认知**：**/api/check** **不是可选的状态展示**，它是**你确定用户已付款的唯一途径**！
> 没有它，你的项目永远不知道谁付了钱。

**如果你想更实时地响应支付**，可以额外实现 Webhook 接收端（任务 3），这样 AiPay 会主动通知你，你可以在本地 DB 也写入一份会员标记。

---

## 🔐 项目没有登录系统？一行 iframe 嵌入 AiPay 登录

**如果任务 0 发现你的项目「用户系统 = 无」**，不需要自己写登录注册——AiPay 内置了完整账号系统，一行 iframe 即可嵌入：注册、登录、找回密码全包。

### iframe 嵌入登录代码

```html
<!-- 把这段 iframe 放在你想展示登录框的地方 -->
<iframe
  src="AIPAY_ORIGIN/auth/embedded?project=AIPAY_PROJECT_ID"
  style="width:100%;height:100vh;border:none;"
  id="aipay-auth-frame"
  allow="autoplay"
  title="AiPay 登录">
</iframe>

<script>
// 监听登录成功消息
window.addEventListener('message', (e) => {
  if (e.data.type === 'aipay:auth') {
    // payload: { user_id, email, name, session_token }
    const user = e.data.payload;
    localStorage.setItem('aipay_user', JSON.stringify(user));
    window.location.reload(); // 刷新显示已登录状态
  }
});
</script>
```

### 关键说明
- **iframe src** 格式 = `AIPAY_ORIGIN/auth/embedded?project=AIPAY_PROJECT_ID`（已内嵌到上方凭证信息）
- 登录成功后 iframe 通过 **postMessage** 向父窗口发送消息
- 消息格式：`{ type: "aipay:auth", payload: { user_id, email, name, session_token } }`
- 拿到 **user_id** 后就与收银台、权益查询全部打通

### 登录→付款 联动（完整闭环）
```javascript
const user = JSON.parse(localStorage.getItem('aipay_user'));
const url = AIPAY_CHECKOUT_BASE + '/' + PRODUCT_SLUG
  + '?user_id=' + user.user_id + '&user_label=' + user.email;
window.open(url, '_blank');
```

> 💡 **如果项目已有登录系统**：跳过本节，直接用现有 user_id 跳转收银台。本节的 iframe 登录是给「完全没有登录功能」的项目兜底的，零成本拥有完整账号体系。

---

## 🗺️ 推荐接入策略：两阶段渐进式接入

**不必一次性把所有东西都做了！** 按以下顺序，每一步都可独立验证：

### 阶段一：最小闭环（约 30 分钟，先跑通）

目标：用户能跳转收银台付钱，你的项目能判断谁付了。

```
1. 前端加「升级会员」按钮 → 跳转 AIPAY_CHECKOUT_BASE/{slug}?user_id=xxx&user_label=xxx
2. 后端加 /api/user/membership 代理 → 调 AiPay /api/check 查权益
3. 前端根据 { active: true/false } 切换按钮状态
```

> ✅ **阶段一就能跑通整个流程！** 用户能付钱、页面能显示会员状态。Webhook 可以先不管，用 /api/check 查询模式完全足够。

### 阶段二：Webhook 实时化（约 20 分钟，生产环境再加）

目标：付款后秒级响应，本地 DB 有记录，不再依赖轮询。

```
1. 后端加 POST /api/aipay/webhook 路由（接收 AiPay 主动通知）
2. 在通知处理里更新本地数据库（is_vip = true）
3. curl 回填 Webhook URL 到 AiPay 控制台
4. 前端优先读本地 DB，/api/check 兜底验证
```

> 💡 **本地开发没有公网域名？** 阶段二先跳过！部署到线上后再接 Webhook。阶段一的 /api/check 查询方式在本地开发完全够用。

---

## 任务 1：添加会员开通入口（必须完成 ✅）

**目标**：在项目中添加唯一的"升级 VIP / 开通会员"按钮或链接，点击跳转 AiPay 托管收银台。

### 收银台使用方式

- **步骤 1（前端）**：用户点击按钮 → 跳转 `${CHECKOUT_BASE}/${productSlug}?user_id=${userId}&user_label=${userLabel}`
- **步骤 2（用户）**：选套餐 → 扫码付款
- **步骤 3（后端）**：你的服务端调用 **`${API_BASE}/api/check?user_id=xxx&product=xxx`** 查询 AiPay 云端权益 → 返回 **{ active: true/false, plan_name, days_left }**
- **步骤 4（前端）**：根据返回结果展示「已开通」或「升级会员」按钮

> ⚠️ **步骤 3 是支付确认的关键**：**/api/check** 查询的是 **AiPay 云端权益存储**（不是你的本地数据库）。只有这个接口返回 **active: true**，你才能确定用户已付款。

> 🚨 ⚠️ **【接入后最常见问题】user_id 强烈建议用邮箱**：用户付款后在收银台看到的 USER 标签就是 **user_id** 的值。如果传一串无意义的 UUID 或数字代码（如 **d4e5f6a7-b8c9**），用户会困惑"这是谁？"。**用邮箱作为 user_id 是最佳实践**，同时用 **user_label** 传昵称让展示更友好。如果你的项目目前 user_id 是一串代码，请在跳转收银台时把邮箱作为 user_id 传入。
>
> 🚨 **收银台显示乱码？十有八九是 user_id 传了 UUID。用邮箱代替，问题秒解。**

**参数说明**：
- **productSlug**: 商品 slug（{{SLUG_HINT}}）
- **user_id**: 你系统中当前用户的唯一标识（**强烈建议传邮箱地址**，如 **user@example.com**，收银台会直接展示这个值；没有邮箱再考虑用户名/UUID）
- **user_label**（强烈推荐）：在收银台展示的友好名称（如昵称），用户看到的是 "你好，${user_label}"。不传则 fallback 到 user_id
- **return_url**（可选）：支付成功后的回调地址

> **坑6 · return_url 支付成功后会怎样跳转？**
> - 用户付款成功后，AiPay 收银台会跳转到 `return_url`，并附带 query 参数 `order_id` 和 `status=paid`，例如：
>   `https://你的域名/success?order_id=xxxx&status=paid`
> - 前端从 URL 里取出 `order_id` 后，应调用 `/api/check` 确认权益（不要只凭 URL 参数判断，URL 可被伪造）
> - 如果不传 `return_url`，收银台停留在支付成功页，用户需手动返回你的站点

> 🚨 **坑1 · 收银台 URL 不支持 notify_url 参数！**
> 收银台链接格式只有 `user_id`、`user_label`、`return_url` 三个参数。
> - **不要**在收银台 URL 上拼接 `&notify_url=...`，它不会生效，还会和控制台配置的 Webhook URL 混淆
> - **Webhook 回调地址只能在控制台配置**：通过 `PUT /projects/{id}` 设置 `webhookUrl`（见任务 3）
> - 如果你确实需要每笔订单回调到不同地址，那是 `/api/create_order`（API 模式）的 `notify_url` 字段，与收银台无关

> **重要：**user_id** 和 **user_label** 的最佳实践**
> - **有邮箱系统**（推荐）：**user_id** 传邮箱（如 **user@example.com**），**user_label** 传昵称或也传邮箱
> - **无邮箱**：**user_id** 传用户名或可读 ID（如 **zhangsan**），**user_label** 传昵称
> - **只有 UUID**：如果你项目里 **user_id** 是一串代码（如 **usr_abc123**），**务必用 **user_label** 传个有意义的名字**（邮箱/昵称），否则收银台会显示 **usr_abc123** 这种无意义的字符串
> - 收银台页面右上角会显示 USER 标签，支付成功页也会显示"已为用户 xxx 开通"
> - **核心原则：让用户在收银台一眼认出这是自己的订单**

### 根据你的框架选择对应方案：

#### 方案 A：Next.js App Router（最推荐）

**1️⃣ 后端代理 —— 权益查询接口**

创建文件 **app/api/user/membership/route.ts**：

```typescript
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const userId = searchParams.get("user_id") || "";
  
  // ⚠️ 重要：从 session/token 获取真实 userId
  // const userId = await getCurrentUserId(request);

  if (!userId) {
    return NextResponse.json({ active: false, error: "missing user_id" }, 400);
  }

  const res = await fetch(
    `${API_BASE}/api/check?user_id=${encodeURIComponent(userId)}&product={{PRODUCT_SLUG}}`,
    {
      headers: { "X-Api-Key": process.env.AIPAY_API_KEY! },
      next: { revalidate: 60 },
    }
  );
  const data = await res.json();
  return NextResponse.json(data);
}
```

**2️⃣ 前端组件 —— 会员入口 + 状态展示**

创建文件 **components/MembershipButton.tsx**：

```tsx
"use client";
import { useEffect, useState } from "react";

type MembershipStatus = {
  active: boolean;
  plan_name?: string;
  days_left?: number | null;
  lifetime?: boolean;
};

export function MembershipButton({ userId }: { userId: string }) {
  const [membership, setMembership] = useState<MembershipStatus | null>(null);

  useEffect(() => {
    fetch(`/api/user/membership?user_id=${userId}`)
      .then((r) => r.json())
      .then(setMembership)
      .catch(() => setMembership(null));
  }, [userId]);

  if (membership?.active) {
    return (
      <div className="flex items-center gap-2 text-green-600 font-medium">
        <span className="text-lg">✓</span>
        <span className="font-bold">{membership.plan_name}</span>
        {membership.lifetime ? (
          <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">永久</span>
        ) : membership.days_left !== null && membership.days_left > 0 ? (
          <span className="text-xs text-gray-500">剩余 {membership.days_left} 天</span>
        ) : membership.days_left === 0 ? (
          <span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded">即将到期</span>
        ) : null}
      </div>
    );
  }

  return (
    <a
      href={`${CHECKOUT_BASE}/{{PRODUCT_SLUG}}?user_id=${userId}&user_label=${userLabel}`}
      target="_blank"
      rel="noopener noreferrer"
      className="inline-flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-violet-600 to-indigo-600 text-white rounded-lg font-bold hover:opacity-90 transition shadow-lg hover:shadow-xl"
    >
      <Sparkles className="w-4 h-4" /> 升级会员
    </a>
  );
}
```

**3️⃣ 在页面中使用**

```tsx
import { MembershipButton } from "@/components/MembershipButton";

// 在导航栏、用户中心等位置：
// <MembershipButton userId={currentUserId} />
```

#### 方案 B：React + Express / Vite

**后端代理** (**routes/membership.js** 或 **server.js**)：

```javascript
router.get("/api/user/membership", async (req, res) => {
  // 从 session/JWT 获取 userId
  const userId = req.user?.id || req.query.user_id;
  if (!userId) return res.status(400).json({ active: false });

  try {
    const response = await fetch(
      `${API_BASE}/api/check?user_id=${userId}&product={{PRODUCT_SLUG}}`,
      { headers: { "X-Api-Key": process.env.AIPAY_API_KEY } }
    );
    const data = await response.json();
    res.json(data);
  } catch (err) {
    console.error("AiPay check failed:", err);
    res.status(502).json({ active: false });
  }
});
```

**前端组件**：

```tsx
export function MembershipEntry({ userId }: { userId: string }) {
  const [status, setStatus] = useState<{ active: boolean; plan_name?: string } | null>(null);

  useEffect(() => {
    fetch(`/api/user/membership?user_id=${userId}`)
      .then(r => r.json()).then(setStatus).catch(() => setStatus(null));
  }, [userId]);

  if (status?.active) return <div className="vip-badge">✓ {status.plan_name}</div>;
  
  return (
    <a href={`${CHECKOUT_BASE}/{{PRODUCT_SLUG}}?user_id=${userId}&user_label=${userLabel}`}
       target="_blank" rel="noopener noreferrer" className="btn-upgrade">
      升级 VIP →
    </a>
  );
}
```

#### 方案 C：Python FastAPI / Flask

**FastAPI 后端代理** (**routers/membership.py**)：

```python
from fastapi import APIRouter, Query, HTTPException
import httpx
import os

router = APIRouter(prefix="/api/user", tags=["membership"])

@router.get("/membership")
async def get_membership(user_id: str = Query(...)):
    """代理查询 AiPay 权益状态，内置缓存由 AiPay 服务端处理"""
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            "{{API_BASE}}/api/check",
            params={"user_id": user_id, "product": "{{PRODUCT_SLUG}}"},
            headers={"X-Api-Key": os.environ["AIPAY_API_KEY"]},
            timeout=10,
        )
    return resp.json()
```

**Jinja2 前端模板**：

```jinja2
{# templates/components/membership.html #}
{% if membership and membership.active %}
    <span class="vip-status">
        ✓ {{ membership.plan_name }}
        {% if not membership.lifetime and membership.days_left is not none %}
            <small>剩余 {{ membership.days_left }}天</small>
        {% endif %}
    </span>
{% else %}
    <a href="{{CHECKOUT_BASE}}/{{PRODUCT_SLUG}}?user_id={{ user_id }}&user_label={{ user_label }}"
       target="_blank" class="btn btn-primary">升级会员 →</a>
{% endif %}
```

#### 方案 D：纯前端项目（无后端）

> 💡 纯前端同样可以完整接入 AiPay——直接在你的前端 JS 里调用 AiPay API 即可。API Key 暴露在小项目中不是什么大问题，没人会去翻你的 F12。如果你将来项目做大了，再加一层后端代理就行。

**完整的前端接入方案**（一个组件搞定入口 + 权益查询）：

```tsx
// 纯前端 —— 入口按钮 + 权益状态查询一体化
import { useEffect, useState } from "react";

const API_BASE = "{{API_BASE}}";
const API_KEY = "{{API_KEY}}";
const PRODUCT_SLUG = "{{PRODUCT_SLUG}}";
const CHECKOUT_BASE = "{{CHECKOUT_BASE}}";

type MembershipStatus = {
  active: boolean;
  plan_name?: string;
  days_left?: number | null;
  lifetime?: boolean;
};

export function MembershipButton({ userId, userLabel }: { userId: string; userLabel?: string }) {
  const [status, setStatus] = useState<MembershipStatus | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!userId) return;
    fetch(`${API_BASE}/api/check?user_id=${encodeURIComponent(userId)}&product=${PRODUCT_SLUG}`, {
      headers: { "X-Api-Key": API_KEY },
    })
      .then((r) => r.json())
      .then((data) => setStatus({ active: data.active ?? false, plan_name: data.plan_name, days_left: data.days_left, lifetime: data.lifetime }))
      .catch(() => setStatus(null))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <span>加载中...</span>;

  if (status?.active) {
    return (
      <div className="vip-badge">
        <span className="vip-check">✓</span>
        <span className="vip-plan">{status.plan_name}</span>
        {status.lifetime ? (
          <span className="vip-tag">永久</span>
        ) : status.days_left != null && status.days_left > 0 ? (
          <span className="vip-days">剩余 {status.days_left} 天</span>
        ) : (
          <span className="vip-expiring">即将到期</span>
        )}
      </div>
    );
  }

  return (
    <a
      href={`${CHECKOUT_BASE}/${PRODUCT_SLUG}?user_id=${encodeURIComponent(userId)}&user_label=${encodeURIComponent(userLabel || userId)}`}
      target="_blank"
      rel="noopener noreferrer"
      className="upgrade-btn"
    >
      ⭐ 开通会员
    </a>
  );
}
```

**如果项目连 npm/React 都没有**（纯 HTML + 原生 JS），在页面底部加入以下 JS：

```javascript
const AIPAY_API_BASE = "{{API_BASE}}";
const AIPAY_API_KEY = "{{API_KEY}}";
const AIPAY_PRODUCT = "{{PRODUCT_SLUG}}";
const AIPAY_CHECKOUT = "{{CHECKOUT_BASE}}";

async function checkMembership(userId) {
  const res = await fetch(
    AIPAY_API_BASE + "/api/check?user_id=" + encodeURIComponent(userId) + "&product=" + AIPAY_PRODUCT,
    { headers: { "X-Api-Key": AIPAY_API_KEY } }
  );
  return res.json(); // { active: true/false, plan_name, days_left, ... }
}

function renderMembership(el, userId, userLabel) {
  checkMembership(userId).then(function(status) {
    if (status.active) {
      el.innerHTML = "✅ " + status.plan_name + "（剩余 " + (status.days_left || "永久") + " 天）";
    } else {
      el.innerHTML = '<a href="' + AIPAY_CHECKOUT + '/' + AIPAY_PRODUCT +
        '?user_id=' + userId + '&user_label=' + (userLabel || userId) +
        '" target="_blank" style="color:#fff;background:linear-gradient(135deg,#667eea,#764ba2);padding:10px 24px;border-radius:10px;text-decoration:none;font-weight:600">⭐ 开通会员</a>';
    }
  });
}

// 使用: renderMembership(document.getElementById("vip-area"), "user@example.com", "用户昵称");
```

> 💡 **总结**：方案 D 和方案 A/B/C 没有本质区别——都是调 /api/check 查权益 + 跳收银台链接。唯一的区别是 Key 在哪：后端项目放在 .env（更安全），纯前端直接写 JS 里（更方便）。小项目直接用方案 D，完全够用。

### 任务 1 完成标准 ✅
- [ ] 环境变量 **AIPAY_API_KEY** 和 **AIPAY_CHECKOUT_BASE** 已写入 **.env** 文件
- [ ] 页面上有可见的「升级会员/开通 VIP」入口
- [ ] 点击入口能正确跳转到收银台 URL（含 user_id 参数）
- [ ] 如果实现了权益查询，**GET /api/user/membership?user_id=xxx** 返回正确的 JSON
- [ ] 全局搜索确认 **AIPAY_API_KEY** 没有出现在任何前端文件中

---

## 任务 2：会员状态展示与守卫（推荐完成 ✅）

**目标**：根据会员状态控制 UI 展示。

### 可选模式（选适合你项目的）：

**模式 A：路由守卫 —— 未登录/未开通自动重定向**

Next.js Middleware 示例：

```typescript
// middleware.ts —— 放在项目根目录
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

// 需要会员才能访问的路径前缀
const PROTECTED_PATHS = ["/premium", "/dashboard/pro"];

export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  if (PROTECTED_PATHS.some((p) => pathname.startsWith(p))) {
    const userId = request.cookies.get("user_id")?.value;
    if (!userId) {
      return NextResponse.redirect(new URL("/login", request.url));
    }
    
    // 代理查询权益状态
    const res = await fetch(`${process.env.AIPAY_CHECKOUT_BASE}/../api/user/membership?user_id=${userId}`);
    // 或者直接调 AiPay：
    // const res = await fetch(`${process.env.API_BASE}/api/check?user_id=${userId}&product=pro`, 
    //   { headers: { "X-Api-Key": process.env.AIPAY_API_KEY! }});
    
    const { active } = await res.json();
    if (!active) {
      // 重定向到收银台，支付成功后自动返回
      const returnUrl = encodeURIComponent(request.url);
      return NextResponse.redirect(
        new URL(`${CHECKOUT_BASE}/{{PRODUCT_SLUG}}?user_id=${userId}&user_label=${userLabel}&return_url=${returnUrl}`, request.url)
      );
    }
  }
  return NextResponse.next();
}

export const config = { matcher: ["/premium/:path*", "/dashboard/:path*"] };
```

**模式 B：组件级守卫 —— 包裹会员专属内容**

```tsx
// components/MemberOnly.tsx
export function MemberOnly({ children, userId }: { children: ReactNode; userId: string }) {
  const [allowed, setAllowed] = useState(false);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/user/membership?user_id=${userId}`)
      .then(r => r.json())
      .then(d => setAllowed(d.active ?? false))
      .catch(() => setAllowed(false))
      .finally(() => setLoading(false));
  }, [userId]);
  
  if (loading) return <Skeleton />;
  
  if (!allowed) {
    return (
      <div className="member-gate border rounded-lg p-8 text-center">
        <p className="text-lg mb-2">🔒 这是会员专属内容</p>
        <p className="text-gray-500 mb-4">升级会员即可解锁全部功能</p>
        <a href={`${CHECKOUT_BASE}/{{PRODUCT_SLUG}}?user_id=${userId}&user_label=${userLabel}`}
           className="btn btn-primary">立即开通 →</a>
      </div>
    );
  }
  return <>{children}</>;
}

// 使用：<MemberOnly userId={uid}><PremiumContent /></MemberOnly>
```

### 任务 2 完成标准 ✅
- [ ] 已开通会员的用户能看到 VIP 状态标识（plan_name + 剩余天数）
- [ ] 未开通用户能看到升级入口
- [ ] （如实现了守卫）未开通用户无法访问受保护路径/组件

---

## 任务 3：Webhook 接收端（推荐 ✅ 让支付确认更实时）

> 🚨 **#1 最常见集成Bug：Webhook 收到通知只打日志没写数据库！** 模板里的 TODO 不是装饰——必须把 `is_vip=true`、`expires_at` 写入你自己的 DB。只打日志 = 用户付了钱还是「未开通」！详见 FAQ Q4。
>
> **Webhook 的作用**：用户付款后，AiPay **主动通知**你的服务器"钱到了"。你在通知里更新自己的数据库（如设置 **is_vip=true**），这样下次查询就不需要再调 **/api/check**。
>
> **与 /api/check 的关系**：
> - **只用 /api/check**：每次用户打开页面都去 AiPay 查一次（简单，但有网络延迟）
> - **加上 Webhook**：收到通知就写本地 DB，后续查询直接读本地（更快，更可靠）
> - **两者结合（最稳妥）**：Webhook 实时更新本地 DB + /api/check 作为兜底验证

#### Webhook 数据格式参考

当支付成功时，AiPay 会 POST 到你配置的 Webhook URL：

```json
{
  "event": "order.paid",
  "order_id": "xxxx",
  "amount": 9.9,
  "real_amount": 9.88,
  "channel": "wechat",
  "metadata": { "user_id": "用户ID" },
  "paid_at": 1700000000000,
  "entitlement": {
    "product_slug": "pro",
    "user_id": "用户ID",
    "plan_id": "plan_xxx",
    "plan_name": "月度会员",
    "expires_at": 1735660800000
  }
}
```

#### Webhook 接收端模板（按框架选择）

**Next.js Route Handler**：

```typescript
// app/api/aipay/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
// 假如你有 Prisma: import { prisma } from "@/lib/prisma";

const processedIds = new Set<string>();

export async function POST(request: NextRequest) {
  const idempotencyKey = request.headers.get("X-AiPay-Idempotency-Key");
  if (idempotencyKey && processedIds.has(idempotencyKey)) {
    return NextResponse.json({ ok: true, skipped: true });
  }

  const payload = await request.json();

  if (payload.event === "order.paid") {
    const userId = payload.metadata?.user_id;
    const entitlement = payload.entitlement; // { plan_name, expires_at, ... }

    // ════════════════════════════════════════
    // 🚨 🔑 核心业务逻辑：必须更新本地数据库！⚠️ #1 最常见Bug：只打印日志不写DB → 用户付了钱仍是"未开通"。必须写入 is_vip/expires_at，不能只 console.log！
    // ════════════════════════════════════════
    // 示例（Prisma）：
    // await prisma.user.update({
    //   where: { id: userId },
    //   data: {
    //     isVip: true,
    //     vipPlanName: entitlement?.plan_name,
    //     vipExpiresAt: entitlement?.expires_at ? new Date(entitlement.expires_at) : null,
    //   },
    // });
    //
    // 示例（Supabase）：
    // await supabase.from("users").update({
    //   is_vip: true,
    //   vip_plan: entitlement?.plan_name,
    //   vip_expires_at: entitlement?.expires_at,
    // }).eq("id", userId);

    // ════════════════════════════════════════
    // 可选：发通知、解锁内容等
    // ════════════════════════════════════════
    // await sendWelcomeEmail(userId);
    // await grantDigitalContent(userId);

    console.log(`[✅ AiPay Webhook] user=${userId} plan=${entitlement?.plan_name} order=${payload.order_id}`);

    if (idempotencyKey) processedIds.add(idempotencyKey);
  }

  // 必须返回 200！否则 AiPay 会指数退避重试（1s → 5s → 30s）
  return NextResponse.json({ ok: true });
}

// 支持 GET 用于验证端点可达性
export async function GET() {
  return NextResponse.json({ status: "aipay-webhook-ok" });
}
```

**Express**：

```javascript
const express = require("express");
const router = express.Router();
const processedIds = new Set();

router.post("/api/aipay/webhook", (req, res) => {
  const key = req.headers["x-aipay-idempotency-key"];
  if (key && processedIds.has(key)) return res.json({ ok: true, skipped: true });

  const { event, metadata, order_id, real_amount } = req.body;
  if (event === "order.paid") {
    const userId = metadata?.user_id;
    // 🚨 必须写入数据库！最常见Bug：只 console.log → 用户付钱了仍是未开通
    console.log(`[✅ Paid] user=${userId} amount=${real_amount}`);
    if (key) processedIds.add(key);
  }
  res.json({ ok: true }); // 必须 200！
});

module.exports = router;
```

**Python FastAPI**：

```python
from fastapi import APIRouter, Request, HTTPException
router = APIRouter()
processed_ids = set()

@router.post("/api/aipay/webhook")
async def webhook(request: Request):
    payload = await request.json()
    key = request.headers.get("X-AiPay-Idempotency-Key", "")
    
    if key in processed_ids:
        return {"ok": True, "skipped": True}
    
    if payload.get("event") == "order.paid":
        user_id = payload.get("metadata", {}).get("user_id")
        # 🚨 必须写入数据库！最常见Bug：只 print → 用户付钱了仍是未开通
        print(f"[✅ Paid] user={user_id} amount={payload.get('real_amount')}")
        processed_ids.add(key)
    
    return {"ok": True}  # 必须 200！
```

### 🔧 自动填入 Webhook URL（关键一步！AI 必须执行）

> **Webhook URL 如果填错，付款了也不会触发通知，本地 DB 永远不会更新！**
>
> ⚠️ **注意：这里用的是项目级 API（PUT /projects/{id}），只影响当前项目，不会覆盖全局 Webhook。**
> 如果你有多个项目共用同一个回调地址，才需要在 AiPay 设置页填全局 Webhook。

实现完接收端后，**你必须立即执行以下命令**把 Webhook URL 回填到 AiPay 控制台：

```bash
# ⚠️ AI 注意：把 YOUR_DOMAIN 替换为这个项目的实际域名！
# 如果是本地开发没有公网域名 → 暂时跳过，部署后再填
curl -X PUT "{{API_BASE}}/projects/{{PROJECT_ID}}" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: {{API_KEY}}" \
  -d '{"webhookUrl": "https://YOUR_DOMAIN/api/aipay/webhook"}'
```

**AI 执行前自查**：
1. 这个项目有没有公网域名？没有 → 跳过，提醒用户部署后用 AiPay 设置页手动填入
2. Webhook 的路由路径是什么？确认上面命令里的 **/api/aipay/webhook** 与你创建的路径一致
3. 项目是否已部署到线上？本地 localhost 不可用

执行后返回 **{"project":{...}}（注意是 project 不是 merchant）** 表示成功。

> 🚨 **坑4 · Supabase 函数改名后，必须重新执行上面的 curl 更新 Webhook URL！**
> 如果你的 Edge Function 从 `make-server-xxx` 改名为 `make-server-yyy`，控制台里存的旧 Webhook URL 不会自动更新，回调会全部打到旧函数地址而丢失。
> - **每次函数改名/迁移后，立即重新执行 curl PUT 命令**把新 URL 写回控制台
> - 验证方法：在控制台「项目设置」里确认显示的 Webhook URL 与当前函数名一致

### 任务 3 完成标准 ✅
- [ ] Webhook 端点已创建并能接受 POST 请求
- [ ] 返回 **{"ok": true}**（200 OK）
- [ ] 有幂等处理（Idempotency-Key）
- [ ] **在业务逻辑中更新了本地数据库**（is_vip=true 等）
- [ ] Webhook URL 已通过上述 curl 命令自动填入控制台
- [ ] （可选）浏览器访问 Webhook URL 验证可达性

---

## 任务 4：环境变量配置

**请在项目的环境变量文件中添加**：

```
# AiPay 集成（在 .env.local / .env 中添加）
AIPAY_API_KEY='{{API_KEY}}'
AIPAY_CHECKOUT_BASE='{{CHECKOUT_BASE}}'
AIPAY_WEBHOOK_SECRET='{{WEBHOOK_SECRET}}'  # 如需验签
```

框架对应的文件位置：
- **Next.js** → **.env.local**（不要提交到 Git！确保 **.gitignore** 包含它）
- **Vite+React** → **.env**（前端变量用 VITE_ 前缀；服务端用普通名）
- **Express** → **.env** + **dotenv.config()**
- **Python/FastAPI** → **.env** + **python-dotenv**
- **Go** → **.env** + **godotenv**

---

## 任务 5：最终自检验证（AI 执行完所有任务后自动运行）

**请逐项检查并输出结果**：

### 功能验证清单
- [ ] **入口可点击**：「升级会员」按钮/链接在页面上可见且可点击
- [ ] **URL 正确性**：点击后跳转地址格式为 `${CHECKOUT_BASE}/slug?user_id=xxx&user_label=xxx`，且 user_label 是有意义的值（邮箱/昵称）而非 UUID
- [ ] **Key 安全性**：如果有后端，确认 Key 在 `.env` 中；如果是纯前端小项目，Key 直接写 JS 里也无妨
- [ ] **权益查询**：调用 **/api/check?user_id=test_user**（后端代理或前端直接调）返回含 **active** 字段的 JSON
- [ ] **代码风格一致**：新代码遵循项目现有的命名规范、缩进、目录组织
- [ ] **无多余依赖**：没引入不必要的第三方库（fetch 内置，不需要 axios/node-fetch）
- [ ] **类型安全**（TS 项目）：新文件没有 **any** 类型，接口已正确定义

### 端到端测试步骤（复制这段给用户测试）

```
1. 启动开发服务器（npm run dev / pnpm dev 等）
2. 打开有「升级会员」按钮的页面
3. 点击按钮 → 应该跳转到 AiPay 收银台，能看到套餐列表和二维码
4. 用手机扫码 → 付款
5. 等待 10 秒 → 刷新页面
6. 按钮应该变成「✓ 已开通」，显示套餐名和剩余天数
7. 打开浏览器 DevTools → Network → 找到 /api/user/membership 请求 → 确认返回 {"active": true, ...}
8. （如有 Webhook）确认你的数据库里用户记录已被更新为 is_vip=true
```

---

## 🐛 常见问题排查 FAQ

### Q1：页面显示"升级会员"，但用户明明付了钱？

**90% 的情况是 /api/check 没调或调错了。** 排查步骤：

1. 打开浏览器 DevTools → Network，找 /api/check 请求
2. 确认 user_id 参数正确（和跳转收银台时传的一致）
3. 确认 product 参数正确（和收银台 slug 一致）
4. 确认带了 X-Api-Key 请求头

如果返回 `{ active: false }`：等 10 秒再试（订单匹配有几秒延迟），或去 AiPay「订单」页确认订单状态是否 paid。

### Q2：收银台页面显示乱码/随机字符串？

**user_id 传了 UUID/数字代码。** 用邮箱代替，问题秒解。参看上文 🚨 警告。

### Q3：/api/create_order 返回错误？

常见原因：没有上传收款码 / 金额冲突 / 渠道不匹配。在 AiPay 控制台确认已有对应渠道的收款码。

### Q4：Webhook 收不到通知 / 收到但没开通会员？

⚠️ #1 集成 Bug — 症状B：Webhook handler 只打印日志没写数据库，用户付了钱仍是「未开通」。必须把 is_vip/expires_at 写进本地 DB！

⚠️ 但本地 DB 只是缓存，前端判断权益时必须以 /api/check 为准（管理员在控制台撤销权益后，本地 DB 不会自动同步）

1. Webhook URL 是否已在 AiPay 控制台填写（不是项目 .env！）
2. URL 是否公网可达（localhost 不可用）
3. 端点是否返回 200（非 200 会触发重试）
4. 确认请求头包含 X-AiPay-Idempotency-Key

### Q5：订单一直 pending，手动查也没变化？

- 确认手机端确实收到了支付通知
- 金额可能有微小偏差（±0.50 以内正常），导致匹配不上
- 在 AiPay「订单」页查看详细匹配记录

### Q6：💡 管理员如何撤销用户权益？

AiPay 控制台自带撤销功能——去「会员管理」页点击「撤销」即可：
- 撤销后 /api/check 立即返回 { active: false }
- ⚠️ 你的本地数据库只是缓存！如果你实现了 Webhook → 本地 DB，必须在权益查询时以 /api/check 为准，不能用本地 is_vip 替代 /api/check——否则管理员在 AiPay 撤销权益后，你的本地 DB 还是「已开通」

---

## 🚧 Supabase 部署陷阱（来自真实踩坑记录）

> 以下问题来自商户接入时的真实踩坑，**在开始接入前请先读一遍**，避免重复踩坑。
> 「付款了没开通」这类时灵时不灵的问题，根因往往在这里。

### 坑2 · Supabase Edge Function 路由前缀导致 404

Supabase Edge Function 收到的请求路径会**带函数名前缀**，例如部署名为 `make-server-41dc007f` 的函数，实际收到的 path 是 `/make-server-41dc007f/api/check`，而不是 `/api/check`。

如果你的路由框架（Hono / Express / Oak）没有处理这个前缀，所有路由会返回 404，表现为：
- 前端调 `/api/check` 查不到权益
- Webhook 回调打到函数但订单匹配不上

**解决方案**（二选一）：
1. **在入口处 strip 前缀**：解析 URL 时去掉 `/{函数名}` 这一段，再交给路由器
2. **路由加前缀**：所有路由定义成 `/make-server-41dc007f/api/check` 这种带前缀的路径

> AiPay 的 Edge Function 入口已自动 strip 前缀，但如果你 fork 后自部署或换了函数名，需确认前缀处理是否仍然正确。

### 坑5 · 多函数共存：旧函数仍在 ACTIVE，请求打到废弃函数

在 Supabase 上部署新函数名（如 `make-server-v2`）后，**旧函数（`make-server-v1`）不会自动删除，仍保持 ACTIVE 状态**。如果你的前端 `BASE` 还指向旧函数，请求会全部打到废弃函数上：
- 旧函数读取的是旧 KV 数据 → 权益查不到、订单匹配不上
- 表现为「有时候能开通、有时候不能」的诡异现象

**解决方案**：
1. **部署新函数后，要么删除旧函数**（`supabase functions delete make-server-v1`），**要么确认前端 BASE 已切换到新函数**
2. 在 Supabase 控制台「Functions」页确认只有**一个** ACTIVE 版本
3. 全局搜索代码里的函数 URL，确认没有残留的旧函数名引用

### 坑速查表

| # | 踩坑场景 | 根因 | 参考位置 |
|---|---------|------|---------|
| 1 | 在收银台 URL 拼接 `notify_url` 不生效 | 收银台不支持该参数，Webhook 只能控制台配 | 任务 1 参数说明 |
| 2 | Edge Function 路由全 404 | 请求 path 带函数名前缀未 strip | 本节坑2 |
| 3 | API_BASE 填成自己的 Supabase URL | API_BASE 是 AiPay 官方域名 | 凭证信息「三种 URL」 |
| 4 | 函数改名后 Webhook 回调全丢 | 控制台 Webhook URL 没同步更新 | 任务 3 curl 后提示 |
| 5 | 付款时灵时不灵 | 旧函数仍 ACTIVE，请求打到废弃函数 | 本节坑5 |
| 6 | return_url 跳转后不知带什么参数 | 文档未说明跳转行为 | 任务 1 参数说明 |

---

🎯 给 AI Agent 的最终执行指令

**按顺序执行，每步完成后汇报**：

1. **任务 0** → 扫描项目，汇报技术栈和目录结构
2. **任务 1** → 根据扫描结果选择对应方案的代码模板，写入项目（**必须实现 /api/check 后端代理**）
3. **任务 2** → （如需）添加守卫/Badge 展示
4. **任务 3** → 实现 Webhook 接收端（**推荐**：写入本地数据库，并执行 curl 命令自动回填 Webhook URL）
5. **任务 4** → 写入环境变量文件
6. **任务 5** → 自检，输出变更清单

**核心原则**：
- 📖 **先读后写**：修改任何文件前先读取，理解现有结构
- 🔑 **/api/check 是支付确认的唯一途径**：必须实现后端代理，前端通过它确定用户是否已付款
- 🏪 **Webhook + 本地 DB 双保险**：Webhook 更新本地数据库 + /api/check 兜底验证
- 🎨 **保持风格**：遵循项目既有命名规范、缩进、目录组织
- 💬 **不确定就问**：不知道 userId 怎么获取？先问用户再假设
- 🔒 **按需安全**：后端项目 Key 放 `.env` 通过代理调用；纯前端小项目直接写 JS 里也可以，Key 暴露风险可忽略
- 🔓 **权益必须可撤销**：你的本地数据库只是 AiPay 云端权益的**缓存镜像**。管理员在 AiPay 控制台撤销后 /api/check 立即返回 active:false。绝不能只用本地 DB 的 is_vip 字段替代 /api/check 的真实查询
- ⚠️ **先读「Supabase 部署陷阱」**：接入前务必阅读上文「🚧 Supabase 部署陷阱」章节，确认 API_BASE、函数名、路由前缀、Webhook URL 均正确，避免「付款了没开通」
- ♻️ **最小侵入**：只加必要的新文件/代码，不重构已有功能
