> ## Documentation Index
> Fetch the complete documentation index at: https://docs.waffo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 让已登录顾客免邮箱验证管理订单

> 让已在你站点登录的顾客直接进入你店铺的客户门户

## 你将构建什么

在你的站点上放一个**管理订单**按钮。已登录的顾客点击后，你的服务端向 Waffo Pancake 获取门户链接，并把浏览器重定向过去。顾客直接以已登录状态进入你店铺的客户门户，无需输入邮箱或验证码。

```mermaid theme={"system"}
sequenceDiagram
    participant C as Customer Browser
    participant S as Your Server
    participant W as Waffo Pancake API
    participant P as Customer Portal

    C->>S: Click "Manage orders"
    S->>S: Read the customer from your own session
    S->>W: Create portal link (API Key)
    W->>S: portalUrl, expiresAt
    S->>C: 302 redirect to portalUrl
    C->>P: Portal for this customer, this store
```

在门户中，顾客可以：

| 功能区 | 可用操作 |
| - | - |
| 订单与支付 | 查看 |
| 订阅 | 查看、取消、恢复、换方案 |
| 发票 | 查看 |
| 退款工单 | 仅查看 —— 本版本不支持创建或重新提交退款申请 |

<Note>
  通过这种方式进入的顾客无法在门户中申请退款。请通过你自己的客服渠道受理退款申请，再在 Dashboard 或用 [创建退款工单](/zh/api-reference/endpoints/refunds/create-refund-ticket) API 发起退款。
</Note>

***

## 前置条件

* 一个 API Key（Dashboard → API & Development）。开发阶段使用测试 Key
* 顾客应看到的店铺的 Store ID（`STO_xxx`）
* 订单通过[认证式收银台](/zh/integrate/sdks#认证式收银台（推荐）)创建，带有你的 `buyerIdentity`
* 你自己的登录体系：本流程信任你服务端的会话，顾客必须已在你的站点完成认证
* 如果使用 SDK：`@waffo/pancake-ts` 0.26.0 及以上、`@waffo/pancake-nextjs` 0.9.0 及以上，或 [Go SDK](https://pkg.go.dev/github.com/waffo-com/waffo-pancake-sdk-go) v0.17.0 及以上
* 已在[商店设置](/zh/settings/store-settings)中填写店铺的 **Website**。门户会话结束时，门户会提供**返回商户网站**按钮，且只链接到这个地址；未填写网站时不显示该按钮

***

## 第 1 步：确定顾客身份

门户展示的是你指定店铺中、`buyerIdentity` 等于此处传入值的订单。请使用**与创建订单时相同的稳定值**。

* **推荐：** 你系统内部的顾客 ID —— 稳定，且不会再分配给其他人
* 避免使用邮箱：顾客换邮箱后会看不到之前的订单，被回收再分配的邮箱还可能把订单暴露给别人
* 匿名下单或以其他身份下单的订单**不会出现**。Waffo Pancake 不合并身份；如果你更换了标识，之前的订单仍留在旧值下

<Warning>
  `buyerIdentity` 和 `storeId` 必须取自你服务端自己的登录会话 —— 绝不能取自浏览器可控的查询参数、表单字段、请求头或 cookie 值。谁控制了 `buyerIdentity`，谁就拿到那位顾客的门户。Waffo Pancake 信任你的服务端，不会验证顾客的邮箱。
</Warning>

***

## 第 2 步：在服务端生成链接

只在后端调用 API。API Key 绝不能到达浏览器。

* **环境跟随 API Key。** 测试 Key 打开的门户展示测试数据；生产 Key 展示生产数据。没有环境参数
* **每次点击都重新生成链接**，并在跳转前一刻生成。不要缓存、存储或通过邮件发送
* **用 302 重定向。** 在重定向响应上加 `Cache-Control: no-store` 和 `Referrer-Policy: no-referrer`，避免带 token 的链接被缓存或作为 referrer 传出

<CodeGroup>
  ```typescript Next.js (Route Handler) theme={"system"}
  // app/account/portal/route.ts
  import { NextResponse } from "next/server";
  import { WaffoPancake } from "@waffo/pancake-ts";
  import { getSignedInUser } from "@/lib/auth"; // your own session check

  const client = new WaffoPancake({
    merchantId: process.env.WAFFO_MERCHANT_ID!,
    privateKey: process.env.WAFFO_PRIVATE_KEY!,
  });

  export async function GET(request: Request) {
    const user = await getSignedInUser(request);
    if (!user) {
      return NextResponse.redirect(new URL("/login", request.url), 302);
    }

    try {
      // portalUrl already includes #token=...
      const { portalUrl } = await client.auth.createCustomerPortalLink({
        storeId: process.env.WAFFO_STORE_ID!,
        buyerIdentity: user.id, // same value used at checkout
      });

      const response = NextResponse.redirect(portalUrl, 302);
      response.headers.set("Cache-Control", "no-store");
      response.headers.set("Referrer-Policy", "no-referrer");
      return response;
    } catch (err) {
      // Log the error, never the portal URL
      console.error("customer portal link failed", err);
      return new NextResponse("The customer portal is unavailable. Please try again.", { status: 502 });
    }
  }
  ```

  ```typescript Next.js (SDK helper) theme={"system"}
  // app/account/portal/route.ts
  // Signature summary — see the @waffo/pancake-nextjs reference for the exact types
  import { createCustomerPortalRedirect } from "@waffo/pancake-nextjs/server";
  import { getSignedInUser } from "@/lib/auth"; // your own session check

  export const GET = createCustomerPortalRedirect(
    {
      merchantId: process.env.WAFFO_MERCHANT_ID!,
      privateKey: process.env.WAFFO_PRIVATE_KEY!,
    },
    {
      // Resolve the customer from YOUR session, never from the request body or query
      resolveAuthenticatedCustomer: async (request) => {
        const user = await getSignedInUser(request);
        return user ? { storeId: process.env.WAFFO_STORE_ID!, buyerIdentity: user.id } : null;
      },
      // Fixed path you control; without loginUrl, signed-out visitors get a 401
      loginUrl: "/login?returnTo=/account/portal",
    },
  );
  ```

  ```typescript Node.js (Express) theme={"system"}
  import express from "express";
  import { WaffoPancake } from "@waffo/pancake-ts";

  const client = new WaffoPancake({
    merchantId: process.env.WAFFO_MERCHANT_ID!,
    privateKey: process.env.WAFFO_PRIVATE_KEY!,
  });

  const app = express();

  // req.user is set by your own auth middleware; typed here for the example
  type SignedInRequest = express.Request & { user?: { id: string } };

  app.get("/account/portal", async (req: SignedInRequest, res) => {
    const user = req.user;
    if (!user) return res.redirect(302, "/login");

    try {
      const { portalUrl } = await client.auth.createCustomerPortalLink({
        storeId: process.env.WAFFO_STORE_ID!,
        buyerIdentity: user.id,
      });
      res.set("Cache-Control", "no-store");
      res.set("Referrer-Policy", "no-referrer");
      res.redirect(302, portalUrl);
    } catch (err) {
      // Log the error, never the portal URL
      console.error("customer portal link failed", err);
      res.status(502).send("The customer portal is unavailable. Please try again.");
    }
  });
  ```

  ```go Go theme={"system"}
  // Snippet, not a full program: mount portalHandler on your own server.
  // signedInUser is your own session lookup, e.g.
  //   func signedInUser(r *http.Request) (user User, ok bool)
  // Exact types: https://pkg.go.dev/github.com/waffo-com/waffo-pancake-sdk-go
  import (
  	"log"
  	"net/http"
  	"os"

  	pancake "github.com/waffo-com/waffo-pancake-sdk-go"
  )

  func portalHandler(client *pancake.Client) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		user, ok := signedInUser(r) // your own session check
  		if !ok {
  			http.Redirect(w, r, "/login", http.StatusFound)
  			return
  		}

  		link, err := client.Auth.CreateCustomerPortalLink(r.Context(), pancake.CreateCustomerPortalLinkParams{
  			StoreID:       os.Getenv("WAFFO_STORE_ID"),
  			BuyerIdentity: user.ID, // same value used at checkout
  		})
  		if err != nil {
  			log.Printf("customer portal link failed: %v", err) // never log link.PortalURL
  			http.Error(w, "The customer portal is unavailable. Please try again.", http.StatusBadGateway)
  			return
  		}

  		// link.PortalURL already includes #token=...
  		w.Header().Set("Cache-Control", "no-store")
  		w.Header().Set("Referrer-Policy", "no-referrer")
  		http.Redirect(w, r, link.PortalURL, http.StatusFound)
  	}
  }
  ```
</CodeGroup>

SDK 辅助函数 `createCustomerPortalRedirect` 接受以下选项：

| 选项 | 必填 | 行为 |
| - | - | - |
| `resolveAuthenticatedCustomer` | 是 | 从你自己的会话返回 `{ storeId, buyerIdentity }`；无人登录时返回 `null` |
| `loginUrl` | 否 | 未登录访客以 302 跳转到的地址。不设置时，返回 `401` JSON 响应 |
| `onError` | 否 | 处理链接生成失败。默认返回通用的 `502` JSON 响应；可用它记录错误（绝不记录门户链接）或返回你自己的页面 |

辅助函数自己构造的每个响应都带 `Cache-Control: no-store` 和 `Referrer-Policy: no-referrer`。从 `onError` 返回的响应由你自己负责 —— 同样要加上这两个响应头。

<Warning>
  `loginUrl` 应设为你自己掌控的路径。绝不要原样拷贝请求参数（例如 `returnTo` 查询参数）来拼接它 —— 那会让你的门户路由变成开放重定向。
</Warning>

不使用 SDK？以 `purpose: "portal"` 和 `storeId` 调用[签发 Session Token](/zh/api-reference/endpoints/auth/issue-session-token)，然后重定向到 `portalUrl + "#token=" + token`。

***

## 第 3 步：添加按钮

按钮指向你自己的路由，而不是 Waffo Pancake。浏览器永远看不到 API Key，链接也只在顾客点击时才生成。

```tsx theme={"system"}
// A plain <a>, not next/link: prefetching would create portal links nobody clicked
const PORTAL_ROUTE = "/account/portal"; // the route from Step 2

export function ManageOrdersButton() {
  return <a href={PORTAL_ROUTE}>Manage orders</a>;
}
```

***

## 会话行为

| 行为 | 说明 |
| - | - |
| 有效期 | 闲置 15 分钟后失效。门户中的每次操作都会续期 |
| 会话过期 | 门户显示重新进入页面，提示顾客回到你的站点；如果店铺已填写网站，会同时显示**返回商户网站**按钮。再次点击**管理订单**会生成新链接 —— 无需邮箱验证码 |
| 重复使用链接 | 链接**不是一次性的**：会话存活期间可以再次打开。请像对待密码一样对待它 |
| 每个浏览器一个门户 | 一个浏览器同一时间只保留一个门户会话。从你的站点进入会替换该浏览器中之前的门户会话，包括通过邮箱 Magic Link 或为其他店铺打开的会话。仍停留在被替换会话的旧标签页会显示**会话已切换**，并跳转到当前会话 |
| 范围 | 一个店铺、一个环境、一个 `buyerIdentity`。其他店铺和另一环境的数据不可见 |

<Warning>
  链接中的 `#token=` 部分是 bearer 凭证。不要把链接写入日志、埋点、错误追踪或客服工单，也不要通过邮件或聊天分享。
</Warning>

***

## 上线前测试

<Steps>
  <Step title="创建测试订单">
    使用测试 API Key，以某个测试顾客的 `buyerIdentity` 通过认证式收银台下单。
  </Step>

  <Step title="打开门户">
    以该顾客身份登录你的站点，点击**管理订单**。应能看到这笔测试订单。
  </Step>

  <Step title="检查身份边界">
    换一个顾客登录，确认看不到第一位顾客的订单。尝试在请求中加入别人的 ID —— 你的路由必须忽略它。
  </Step>

  <Step title="切换到生产">
    用生产 API Key 部署。此后链接打开的是生产数据。
  </Step>
</Steps>

***

## 故障排查

<AccordionGroup>
  <Accordion title="门户能打开，但没有订单">
    1. 确认订单创建时使用的是同一个 `buyerIdentity` 字符串（精确匹配，区分大小写）
    2. 确认订单属于你传入的 `storeId`
    3. 确认 API Key 的环境：测试 Key 只显示测试订单，生产 Key 只显示生产订单
    4. 匿名收银台的订单不带 `buyerIdentity`，永远不会出现在这里
  </Accordion>

  <Accordion title="门户提示会话已结束">
    会话闲置超过 15 分钟，或在没有新链接的情况下打开了与该浏览器当前会话不同店铺的门户时，门户会显示**请从商户网站重新进入**。让顾客从你站点的**管理订单**重新进入，获取新链接。
  </Accordion>

  <Accordion title="门户提示会话已切换">
    顾客在同一浏览器中再次进入了门户 —— 在另一个标签页从你的站点进入、进入了其他店铺的门户，或使用了邮箱 Magic Link。最新一次进入成为该浏览器的门户会话；旧标签页会显示**会话已切换**，并跳转到当前会话。无需处理。
  </Accordion>

  <Accordion title="Customer Portal API 返回 403 Environment mismatch">
    你用门户 token 调用了 Customer Portal API，但 `X-Environment` 与 API Key 的环境不一致。改传 Key 的环境。
  </Accordion>
</AccordionGroup>

***

## 检查清单

* [ ] 链接在你的服务端生成；API Key 不会到达浏览器
* [ ] `buyerIdentity` 取自你自己的登录会话，并与收银台使用的值一致
* [ ] `storeId` 取自服务端配置，而不是请求
* [ ] 路由以 302 响应，并带 `Cache-Control: no-store` 和 `Referrer-Policy: no-referrer`
* [ ] 门户链接和 token 从不写日志、从不存储
* [ ] 退款申请在门户之外有客服受理渠道

***

## 下一步

<CardGroup cols={2}>
  <Card title="签发 Session Token" icon="key" href="/zh/api-reference/endpoints/auth/issue-session-token">
    `purpose: "portal"` 的请求与响应字段。
  </Card>

  <Card title="客户门户" icon="user" href="/zh/features/customer-portal">
    顾客在门户中看到的内容。
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.