# 使用 Func 接入支付宝电脑网站支付｜Creght

> 在 Creght Func 中正确实现支付宝电脑网站支付，涵盖 RSA2、AES-CBC 内容加密、订单表、回调验签、金额校验和幂等处理。

[![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)API for AI](/)

[查看 llms.txt](/llms.txt)

概览

- [Creght AI 编程指南](/api.md)

AI 可发现性

- [如何优化 llms.txt](/api/optimize-llms-txt.md)

站点配置

- [实现基于域名的多语言路由](/api/domain-locale-routing.md)

后端

- [使用 Func 构建站点后端能力](/api/func-backend.md)
- [使用 Func 接入支付宝电脑网站支付](/api/func-alipay-payment.md)

本页目录

- [范围与前提](#scope)
- [准备支付宝参数并配置环境变量](#config)
- [1\. 在支付宝开放平台获取参数](#alipay-credentials)
- [2\. 对应到 Creght 环境变量](#env-map)
- [3\. 写入 Creght Backend / Env](#creght-env)
- [4\. AES 的生效范围](#aes-config)
- [订单表](#order-table)
- [Func 实现](#func-code)
- [浏览器发起支付](#browser-call)
- [验收与安全边界](#security)

后端/使用 Func 接入支付宝电脑网站支付

# 使用 Func 接入支付宝电脑网站支付

支付宝电脑网站支付的完整 Func 示例：公钥模式、RSA2、可选 AES 内容加密、可信订单、异步通知验签与幂等更新。

复制 Markdown 链接

使用 Creght Func 接入支付宝电脑网站支付：服务端创建可信订单，使用 RSA2 签名，可选启用 AES 内容加密，并通过验签后的异步通知幂等更新支付状态。

**适用协议**

本文使用 `alipay.trade.page.pay` 公钥模式，不适用于证书模式、App 支付或当面付。

## 范围与前提

金额必须由服务端商品表决定，浏览器只传 `productId`。支付成功状态只来自验签通过的异步通知，不能相信前端回跳参数。

## 准备支付宝参数并配置环境变量

### 1\. 在支付宝开放平台获取参数

1. 登录支付宝开放平台控制台并进入用于收款的应用。在「应用详情」记录 `APPID`。
2. 打开「开发设置 → 开发信息 → 接口加签方式」。使用 [支付宝开发者工具](https://open.alipay.com/tool) 生成 RSA2 应用密钥对；应用私钥由自己保管，只把应用公钥上传到支付宝。配置完成后，在同一位置复制支付宝生成的 **支付宝公钥**。
3. 如需内容加密，打开「接口内容加密方式」，查看或生成 AES 密钥。它是 Base64 字符串，与 RSA 密钥用途不同。

[![支付宝开放平台应用开发设置页面，标出接口加签状态和支付宝正式网关地址](https://fsu.creght.com/site/2066727200882692096/1785226571196__docs_alipay_development_settings_v2.png)](https://fsu.creght.com/site/2066727200882692096/1785226571196__docs_alipay_development_settings_v2.png) 支付宝开放平台「开发设置」：确认接口加签和内容加密配置；正式环境使用页面显示的支付宝网关地址。点击图片可查看大图。

> **不要混淆密钥与地址：** 应用私钥由开发者生成并用于请求签名；应用公钥上传给支付宝；支付宝公钥由支付宝提供并用于验证响应和异步通知。图中的「支付宝网关地址」是 Func 向支付宝发起 OpenAPI 请求的目标地址。支付异步通知地址和支付后返回地址不在本页配置，而是在每次 `alipay.trade.page.pay` 请求中分别通过 `notify_url` 和 `return_url` 传入。

### 2\. 对应到 Creght 环境变量

| Creght Key | 值的来源 | 是否必需 |
| --- | --- | --- |
| `ALIPAY_APP_ID` | 支付宝应用详情中的 APPID | 必需 |
| `ALIPAY_PRIVATE_KEY` | 自己生成并妥善保存的应用私钥；不是应用公钥 | 必需 |
| `ALIPAY_PUBLIC_KEY` | 上传应用公钥后，支付宝提供的支付宝公钥 | 必需 |
| `ALIPAY_AES_KEY` | 接口内容加密方式中的 Base64 AES 密钥 | 启用 AES 时必需 |
| `ALIPAY_SELLER_ID` | 实际收款账号的支付宝用户 ID（PID），应与通知中的 `seller_id` 一致 | 必需 |
| `ALIPAY_NOTIFY_URL` | 稳定且已发布的 HTTPS Func 地址，例如 `https://example.com/func/alipay.notify` | 必需 |
| `ALIPAY_RETURN_URL` | 支付后返回的网站页面 URL；只用于页面体验 | 可选 |
| `ALIPAY_GATEWAY` | 正式环境可省略并使用默认网关；沙箱填写对应沙箱网关 | 沙箱可选 |

### 3\. 写入 Creght Backend / Env

1. 进入目标站点编辑器，打开左侧「后端 → 环境变量」。
2. 点击右上角「New variable」，按上表逐项新增；Key 必须完全一致，Value 粘贴对应的完整值。
3. 密钥只放环境变量，在 Func 中通过 `process.env.ALIPAY_PRIVATE_KEY` 等名称读取。不要写入页面、Func 源码、CMS、日志或浏览器返回值。

[![Creght 编辑器的后端环境变量页面，右上角 New variable 按钮被标出](https://fsu.creght.com/site/2066727200882692096/1785225239620__docs_creght_backend_env.png)](https://fsu.creght.com/site/2066727200882692096/1785225239620__docs_creght_backend_env.png) Creght 编辑器「后端 → 环境变量」：点击 New variable，为每个配置项分别创建一条变量。点击图片可查看大图。

> 环境变量页面只应显示变量名和「Value set」状态。截图、报错、日志和工单中都不要暴露私钥或 AES 密钥；轮换密钥后需同步更新支付宝与 Creght 配置，并重新验证沙箱或正式环境。

### 4\. AES 的生效范围

支付宝使用 `AES/CBC/PKCS5Padding` 和 16 字节全零 IV；Func 中对应原生 Web Crypto 的 `AES-CBC`，运行时会自动进行兼容的 PKCS#7 填充，不要手工补位。在支付宝后台配置 AES 密钥不会强制所有调用加密；本示例只有检测到 `ALIPAY_AES_KEY` 时，才会显式加密 UTF-8 `biz_content`、设置 `encrypt_type=AES`，最后对包含密文的完整参数做 RSA2 签名。详见 [支付宝接口内容加密方式](https://opendocs.alipay.com/common/02mse3)。

> **异步通知不做 AES 解密：** `alipay.trade.page.pay` 的标准异步通知仍是表单参数，直接按支付宝规则对参数执行 RSA2 验签。只有具体产品文档明确标为密文的字段才单独解密，不能解密整个通知正文。

## 订单表

创建 `/platform/table/payment_orders.json`：

```json
{
  "name": "Payment orders",
  "desc": "Alipay order state",
  "json_schema": {
    "type": "object",
    "properties": {
      "userId": { "type": "string" },
      "productId": { "type": "string" },
      "subject": { "type": "string" },
      "amount": { "type": "string" },
      "outTradeNo": { "type": "string" },
      "status": {
        "type": "string",
        "enum": ["pending", "paid", "closed"]
      },
      "alipayTradeNo": { "type": "string" },
      "paidAt": { "type": "string" }
    },
    "required": [
      "userId", "productId", "subject", "amount", "outTradeNo", "status"
    ]
  }
}
```

## Func 实现

创建 `/backend/func/alipay.ts`：

```typescript
import type { TalizenFuncContext } from 'talizen/func-runtime'

type OrderRow = {
  id: string
  userId: string
  productId: string
  subject: string
  amount: string
  outTradeNo: string
  status: 'pending' | 'paid' | 'closed'
  alipayTradeNo?: string
  paidAt?: string
}

const TABLE = 'payment_orders'
const PRODUCTS = {
  starter: { subject: 'Starter plan', amount: '9.90' },
} as const

function env(name: string): string {
  const value = process.env[name]
  if (!value) throw new Error(name + ' is required')
  return value
}

function binaryStringToBytes(value: string): Uint8Array {
  const bytes: number[] = []
  for (let i = 0; i < value.length; i++) bytes.push(value.charCodeAt(i))
  return new Uint8Array(bytes)
}

function pemBytes(value: string): Uint8Array {
  const base64 = value
    .replace(/-----BEGIN [^-]+-----/g, '')
    .replace(/-----END [^-]+-----/g, '')
    .replace(/\s+/g, '')
  if (!base64) throw new Error('RSA key is empty')
  return binaryStringToBytes(atob(base64))
}

function bytesToBase64(value: ArrayBuffer): string {
  let binary = ''
  for (const byte of new Uint8Array(value)) binary += String.fromCharCode(byte)
  return btoa(binary)
}

function base64ToBytes(value: string): Uint8Array {
  return binaryStringToBytes(atob(value))
}

async function encryptAES(content: string): Promise<string> {
  const keyBytes = base64ToBytes(env('ALIPAY_AES_KEY'))
  if (![16, 24, 32].includes(keyBytes.length)) throw new Error('invalid AES key')
  const key = await crypto.subtle.importKey(
    'raw',
    keyBytes,
    { name: 'AES-CBC' },
    false,
    ['encrypt'],
  )
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-CBC', iv: new Uint8Array(16) },
    key,
    new TextEncoder().encode(content),
  )
  return bytesToBase64(encrypted)
}

async function decryptAES(content: string): Promise<string> {
  const keyBytes = base64ToBytes(env('ALIPAY_AES_KEY'))
  if (![16, 24, 32].includes(keyBytes.length)) throw new Error('invalid AES key')
  const key = await crypto.subtle.importKey(
    'raw',
    keyBytes,
    { name: 'AES-CBC' },
    false,
    ['decrypt'],
  )
  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-CBC', iv: new Uint8Array(16) },
    key,
    base64ToBytes(content),
  )
  return new TextDecoder().decode(decrypted)
}

function decodeFormPart(value: string): string {
  return decodeURIComponent(value.replace(/\+/g, ' '))
}

function parseForm(raw: string): Array<[string, string]> {
  if (!raw) return []
  return raw.split('&').map((part) => {
    const separator = part.indexOf('=')
    const key = separator < 0 ? part : part.slice(0, separator)
    const value = separator < 0 ? '' : part.slice(separator + 1)
    return [decodeFormPart(key), decodeFormPart(value)]
  })
}

function formValue(pairs: Array<[string, string]>, name: string): string {
  const pair = pairs.find(([key]) => key === name)
  return pair ? pair[1] : ''
}

function canonicalPairs(
  pairs: Array<[string, string]>,
  ignored: string[] = [],
): string {
  const ignoredSet = new Set(ignored)
  return pairs
    .filter(([key]) => !ignoredSet.has(key))
    .map(([key, value]) => key + '=' + value)
    .sort()
    .join('&')
}

function canonicalParams(params: Record<string, string>): string {
  return Object.keys(params)
    .map((key) => key + '=' + params[key])
    .sort()
    .join('&')
}

function queryString(params: Record<string, string>): string {
  return Object.keys(params)
    .sort()
    .map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(params[key]))
    .join('&')
}

function chinaTimestamp(): string {
  const china = new Date(Date.now() + 8 * 60 * 60 * 1000)
  return china.toISOString().slice(0, 19).replace('T', ' ')
}

function normalizeAmount(value: string): string {
  const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(String(value).trim())
  if (!match) throw new Error('invalid amount')
  const integer = match[1].replace(/^0+(?=\d)/, '')
  return integer + '.' + (match[2] || '').padEnd(2, '0')
}

async function signRSA2(content: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    'pkcs8',
    pemBytes(env('ALIPAY_PRIVATE_KEY')),
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['sign'],
  )
  const signature = await crypto.subtle.sign(
    'RSASSA-PKCS1-v1_5',
    key,
    new TextEncoder().encode(content),
  )
  return bytesToBase64(signature)
}

async function verifyRSA2(content: string, signature: string): Promise<boolean> {
  const key = await crypto.subtle.importKey(
    'spki',
    pemBytes(env('ALIPAY_PUBLIC_KEY')),
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['verify'],
  )
  return crypto.subtle.verify(
    'RSASSA-PKCS1-v1_5',
    key,
    base64ToBytes(signature),
    new TextEncoder().encode(content),
  )
}

export async function create(
  input: { productId?: string },
  ctx: TalizenFuncContext,
) {
  const user = ctx.auth.requireUser()
  const productId = String(input.productId || '') as keyof typeof PRODUCTS
  const product = PRODUCTS[productId]
  if (!product) throw new Error('invalid product')

  const outTradeNo = 'C' + crypto.randomUUID().replace(/-/g, '')
  const params: Record<string, string> = {
    app_id: env('ALIPAY_APP_ID'),
    method: 'alipay.trade.page.pay',
    format: 'JSON',
    charset: 'utf-8',
    sign_type: 'RSA2',
    timestamp: chinaTimestamp(),
    version: '1.0',
    notify_url: env('ALIPAY_NOTIFY_URL'),
  }
  const bizContent = JSON.stringify({
    out_trade_no: outTradeNo,
    product_code: 'FAST_INSTANT_TRADE_PAY',
    subject: product.subject,
    total_amount: product.amount,
  })
  if (process.env.ALIPAY_AES_KEY) {
    params.encrypt_type = 'AES'
    params.biz_content = await encryptAES(bizContent)
  } else {
    params.biz_content = bizContent
  }
  if (process.env.ALIPAY_RETURN_URL) {
    params.return_url = process.env.ALIPAY_RETURN_URL
  }
  params.sign = await signRSA2(canonicalParams(params))

  ctx.db.insert(TABLE, {
    userId: String(user.id),
    productId,
    subject: product.subject,
    amount: product.amount,
    outTradeNo,
    status: 'pending',
  })

  const gateway = process.env.ALIPAY_GATEWAY || 'https://openapi.alipay.com/gateway.do'
  return { outTradeNo, payUrl: gateway + '?' + queryString(params) }
}

export async function notify(_input: unknown, ctx: TalizenFuncContext) {
  const failure = (reason: string, status = 400) => {
    console.warn('alipay notify rejected:', reason)
    return new Response('failure', { status })
  }

  try {
    const pairs = parseForm(await ctx.request.text())
    const signature = formValue(pairs, 'sign')
    if (!signature || formValue(pairs, 'sign_type') !== 'RSA2') {
      return failure('invalid sign fields')
    }

    const content = canonicalPairs(pairs, ['sign', 'sign_type', 'alipay_cert_sn'])
    if (!(await verifyRSA2(content, signature))) {
      return failure('signature verification failed')
    }
    if (formValue(pairs, 'app_id') !== env('ALIPAY_APP_ID')) {
      return failure('app_id mismatch')
    }
    if (formValue(pairs, 'seller_id') !== env('ALIPAY_SELLER_ID')) {
      return failure('seller_id mismatch')
    }

    const outTradeNo = formValue(pairs, 'out_trade_no')
    const tradeNo = formValue(pairs, 'trade_no')
    const tradeStatus = formValue(pairs, 'trade_status')
    const totalAmount = formValue(pairs, 'total_amount')
    if (!outTradeNo || !tradeNo || !totalAmount) {
      return failure('required trade fields are missing')
    }

    const result = ctx.db.query<OrderRow>(TABLE, {
      where: { outTradeNo },
      limit: 1,
    })
    const order = result.list[0]
    if (!order) return failure('local order not found')
    if (normalizeAmount(order.amount) !== normalizeAmount(totalAmount)) {
      return failure('amount mismatch')
    }

    if (order.status === 'paid') {
      if (order.alipayTradeNo && order.alipayTradeNo !== tradeNo) {
        return failure('trade_no mismatch')
      }
      return new Response('success')
    }

    if (tradeStatus === 'TRADE_CLOSED') {
      ctx.db.update(TABLE, order.id, { status: 'closed' })
      return new Response('success')
    }
    if (tradeStatus !== 'TRADE_SUCCESS' && tradeStatus !== 'TRADE_FINISHED') {
      return new Response('success')
    }

    ctx.db.update(TABLE, order.id, {
      status: 'paid',
      alipayTradeNo: tradeNo,
      paidAt: new Date().toISOString(),
    })
    return new Response('success')
  } catch (error) {
    console.error('alipay notify failed:', error)
    return new Response('failure', { status: 500 })
  }
}
```

## 浏览器发起支付

浏览器发起支付：

```typescript
import { invoke } from 'talizen/func'

const { payUrl } = await invoke<{ payUrl: string }>(
  'alipay.create',
  { productId: 'starter' },
)
window.location.assign(payUrl)
```

## 验收与安全边界

- AES 只加密 OpenAPI 的 `biz_content`，不会替代 RSA2。异步通知仍按表单原文执行 RSA2 验签，不要把整个通知正文当作 AES 密文。其他 OpenAPI 返回加密响应时，先按支付宝规定的响应节点原文验签，再把密文传给 `decryptAES()`。
- `return_url` 只用于支付后的页面体验，不能据此标记已支付；支付事实只来自验签通过的异步通知。
- 回调先验 RSA2 签名，再核对 `app_id`、 `seller_id`、本地订单、金额和支付宝交易号；重复通知返回相同的 `success`。
- 示例只进行幂等订单状态更新。若还要发放权益，必须继续以 `outTradeNo`/ `tradeNo` 做幂等保护。
- 沙箱与正式环境使用各自的应用、密钥和网关，上线前必须在支付宝对应环境完成一次真实通知验收。

**Func 基础能力**

文件、调用、数据表、鉴权、资源上传、超时和 SSE 见 [Func 后端完整指南](/api/func-backend.md)。

![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)

此网站使用 [Creght](/) 创建

![微信客服](https://fsu.creght.com/site/2066727200882692096/1785119134612__image.png)

微信客服

## 链接

- [价格](/price.md)
- [解决方案](/solution.md)
- [客户案例](/customers.md)
- [帮助中心](/help.md)
- [联系我们](/contact.md)
- [博客](/blogs.md)
- [退款说明](/tuikuan.md)

## 资源

- [全部资源](/resources.md)
- [模板](/templates.md)
- [组件库](https://creghtlib.site.creght.com)
- [动效库](/effects.md)
- [Figma to Creght](/figma2creght.md)
- [AI 编程指南](/api.md)

## 产品对比

- [对比上线了](/creght-vs-sxl.md)
- [对比凡科建站](/creght-vs-fkw.md)
- [自己写代码 vs Creght](/compare/self-coding.md)
- [外包 vs 自己做](/compare/outsourcing.md)

## 协议

- [用户协议](/legal/terms.md)
- [隐私政策](/legal/privacy.md)
- [可接受使用政策](/legal/acceptable-use.md)

## 社交媒体

- [小红书](https://www.xiaohongshu.com/user/profile/5a38606811be10715f4895b6)
- [哔哩哔哩](https://space.bilibili.com/513308095)

[蜀ICP备2023038192号-2](https://beian.miit.gov.cn)
