# eSIMovo 独立商城部署与开售指南

## 交付状态

本版已实现：活泼旅行风 VI、英文响应式商城、Supabase 邮箱验证码登录、客户订单中心、管理员后台、eSIM Access 套餐导入与零售价、Stripe Checkout、签名校验回调、幂等发卡与定时重试、私有安装二维码、用量查询。

**代码可以独立部署，但尚未用商户真实凭据联调，不能把“构建成功”理解为“已经可正式收款”。** 目前没有真实商户订单、扣款或发卡。预览站不开放销售。

独立部署：Cloudflare Workers + D1；账号：Supabase Auth；支付：Stripe；供应商：eSIM Access。可选交付提醒：Resend。账号验证码与订单提醒是两类邮件。

## 一次性准备（之后通过配置和后台运营）

| 服务 | 需要准备 | 填写位置 |
|---|---|---|
| Cloudflare | 自己的账户、Worker、D1 数据库、域名 | `.production/config.json` |
| Supabase | Project URL、anon/publishable key、启用 Email Auth、生产 SMTP | `.production/secrets.json` 与 Supabase 后台 |
| Stripe | 已开通的商户账户、测试/正式 Secret key、对应 Webhook secret | `.production/secrets.json` |
| eSIM Access | 商户 AccessCode、足够的预付余额 | `.production/secrets.json` 与供应商后台 |
| 商户信息 | 经营主体、客服邮箱、条款、隐私和退款文本 | `.production/secrets.json` |
| 系统 | 随机任务密钥、32 字节安装信息加密密钥、管理员 UUID | `.production/secrets.json` |
| Resend（可选） | API key、已验证发信域名/From | `.production/secrets.json` |

**银行收款账户在 Stripe 后台配置，不填到网站代码中。** Stripe 密钥代表已开通的商户账户；仅有银行卡号无法代替 Stripe 商户开通。不要把密钥发送到聊天或提交到 Git。`.production/` 和本地 env 文件已被忽略。请安全备份加密密钥；直接替换会导致已有安装信息无法解密。

## 1. 安装与创建数据库

要求 Node.js 24+、pnpm。以下在解压的项目根目录执行。

```bash
corepack enable
pnpm install --frozen-lockfile
pnpm exec wrangler login
pnpm exec wrangler d1 create esimovo-store
pnpm production:init
```

**新版部署包已包含 `.production/config.json` 和 `.production/secrets.json` 待填写文件。文件夹名开头有英文点 `.`，不是 `production`。某些文件管理器会隐藏此类目录；原始模板也在可见的 `deploy/` 文件夹。若使用旧包，或解压工具未保留点目录，运行 `node scripts/init-production.mjs`（新版）即可生成，已有配置不会覆盖。**

将 D1 返回的 `database_id` 写入 `.production/config.json` 的 `databaseId`。`workerName` 和 `databaseName` 可用 esimovo-store。先将 customDomain 留空，使用 Cloudflare 分配的 workers.dev 地址；域名注册并加入自己的 Cloudflare 账户后，再填写 esimovo.com。

```bash
pnpm production:configure
pnpm build
pnpm production:migrate
pnpm production:deploy
```

首次部署没有密钥，销售保持关闭。Cloudflare 输出实际访问地址后，将其 HTTPS origin（末尾无 `/`）填入 secrets 中的 APP_URL。正式域名切换时同步更新 APP_URL、Stripe 回调地址和 Supabase Site URL。

本包中的 `.openai/hosting.json` 仅保留本地构建所需逻辑 DB 配置，不包含预览站项目身份。独立部署使用 `wrangler.production.json`，不依赖 ChatGPT 登录或 Sites 平台。

## 2. 邮箱验证码账号

1. 在 Supabase 创建项目，启用 Email provider 与用户注册，并保留邮箱验证。
2. 填写 SUPABASE_URL、SUPABASE_ANON_KEY；不要填写 service_role key。
3. 配置自己的 SMTP（如已验证域名的 Resend SMTP）。Supabase 默认测试邮件服务不应作为公众商城邮件方案。
4. 修改 **Magic Link** 邮件模板，使用 `{{ .Token }}` 显示验证码，而不是只放链接。例如：`Your eSIMovo sign-in code is {{ .Token }}`。新账号与已有账号都通过 OTP 验证。
5. 为验证码设置合理有效期及发送限制；本应用额外按邮件/IP限制发送和验证次数。
6. Site URL 指向正式 APP_URL。应用不开放 OAuth 回调，不依赖本地存储保存登录状态；访问与刷新令牌在 HttpOnly Cookie 中。
7. 先部署账号配置，访问 `/account` 用你自己的邮箱登录。打开“Account details”取得 UUID，或从 Supabase Users 中复制 UUID，填入 ADMIN_USER_IDS。多个 UUID 用英文逗号分隔。
8. `/admin` 仅允许这些已验证 UUID，不允许用户通过注册邮件地址自封管理员。

## 3. Stripe 收款与回调

先使用 `sk_test_...`；STRIPE_WEBHOOK_SECRET 使用这个环境的 `whsec_...`。

Stripe Workbench/Webhooks 添加你的**独立公网地址**：

```
https://你的正式域名/api/commerce/webhooks/stripe
```

订阅：
- checkout.session.completed
- checkout.session.async_payment_succeeded
- charge.refunded
- charge.dispute.created

实现只允许卡支付；可用支付方式以 Stripe Checkout 实际显示为准。服务端从已审核目录读取金额，客户端不能改价；签名、付款状态、会话号、订单、用户、币种、金额和 test/live 模式均核对后才确认付款。成功跳转页面本身不作为付款证据。

STRIPE_AUTOMATIC_TAX 可设置 true，但必须先在 Stripe 完成税务配置。开启后税额在 Checkout 计算；站内展示基础 USD 售价。不要把一个开关理解为自动完成所有税务义务。

## 4. 供应商与商品上架

配置 ESIM_ACCESS_CODE。eSIM Access 采购不是由 Stripe 自动划拨款项；需要在供应商账户预存余额。

进入 `/admin`：
1. 输入 JP / TH 等两位国家码；区域使用 !RG，全球使用 !GL。
2. 点击 Sync plans。导入**总量型、DAY 有效期、激活规则明确、USD 计价**套餐；日包暂不支持，避免把每日流量误卖为总流量。
3. 每次同步返回的套餐都变为草稿，需要重新检查后上架；供应商下架的旧套餐在结账实时校验时被拦截，应在后台取消上架。
4. 审核成本、国家列表、网络与激活时间，设置美元零售价并勾选 Published。
5. 建议价暂按成本 × 1.6 生成；这不是利润保证，未扣 Stripe、邮件、税费、退款与营销成本。
6. 供应商价格单位是美元 × 10,000，站内售价单位是美分。不要混用。

结账会再次读取供应商套餐；成本或关键资料发生变化会停止创建支付会话。付款后采购前再检查一次；不一致进入人工处理，不以更高成本盲目采购。

## 5. 填写并上传配置

`.production/secrets.json` 中每项都是字符串。生成两个随机值可使用：

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```

第一个用于 CRON_SECRET，第二个用于 PROFILE_ENCRYPTION_KEY。不要把输出贴到公开位置。

先填写认证所需项和 APP_URL，保持 SALES_ENABLED=false、FULFILLMENT_ENABLED=false，上传以完成管理员首次登录：

```bash
pnpm production:secrets
```

补全 ADMIN_USER_IDS、商户条款、Stripe 和供应商配置后：

```bash
pnpm production:validate
pnpm production:secrets
```

校验脚本仅检查格式，不测试账号有效性。修改源代码后需要 `pnpm build` 和 `pnpm production:deploy`。数据库新增迁移先执行 `pnpm production:migrate`，不要手工改已应用的迁移文件。

## 6. 开售验收

**测试阶段：**
- Stripe 使用测试密钥与测试回调，SALES_ENABLED=true，FULFILLMENT_ENABLED=false。
- 完成验证码登录、导入上架、Stripe 测试付款、回调投递。
- 订单显示 `test_paid / test_only`，不得采购真实 eSIM。
- 账户 A 无法读取账户 B 的订单及二维码；回调重放不会生成新订单。

**真实小额验收：**
- 配置正式 Stripe 密钥和正式回调，供应商账户有余额。
- 在测试完成、条款与客服就绪后设置 FULFILLMENT_ENABLED=true、SALES_ENABLED=true。
- 用支持 eSIM 的设备执行一笔受控真实订单，确认支付、实际采购、二维码、安装、入网、用量和退款流程；这一步有真实费用，本次未执行。
- 确认 Cloudflare 每分钟 Cron 已启用，并查看任务失败告警/日志。
- 确认正式域名的 HTTPS、登录邮件送达、移动浏览器及 Stripe 返回页面。

暂停新销售只改 SALES_ENABLED=false；已经付款的订单仍需处理。紧急暂停真实采购再改 FULFILLMENT_ENABLED=false，并及时联系待交付客户。

## 7. 发卡、退款与售后

- Stripe 回调持久化付款状态；Cron 扫描已付款订单，原子锁防止并发发卡。
- `ovo_...` 订单号是固定 transactionId。网络超时、重试与人工重试都使用同一个值，不创建新的采购号。
- 已取得供应商 orderNo 后只查询安装信息；暂未分配时指数退避，连续失败 12 次转 manual_review。
- 客户无需停留付款成功页；`/account` 会显示持久化状态和私有二维码。二维码在自己的服务端生成，安装信息使用 AES-GCM 加密保存，不发给第三方二维码服务。
- 用量按 esimTranNo 查询，供应商数据可能延迟 2–3 小时。
- Resend 交付提醒仅发送“登录账号查看”的链接，不在邮件中直接暴露安装码。提醒失败在后台标记，需人工联系客户；不影响账号内交付。
- 退款在 Stripe Dashboard 操作；退款事件阻止后续自动采购。退款与供应商取消是两件事，已发卡订单需单独评估状态、取消资格和费用。
- 退款/争议与正在进行的供应商请求可能交错，不能保证撤回已到达供应商的采购。后台核对 supplier_order 并处理；不会自动做供应商取消、充值或新的补发订单。
- 争议状态不会自动恢复为 paid；请人工核对。当前版本不包含联盟分佣、客户自助充值、批量购卡和自动供应商退款。

## 文件与验证

- `app/`：网站与 API；`lib/commerce/`：业务逻辑；`db/schema.ts` 与 `drizzle/`：数据库。
- `deploy/`：独立 Worker 入口和配置样例；`tests/commerce/`：交易边界测试。
- `public/brand/eSIMovo-Design-Kit.zip`：VI 素材。

已通过类型检查、构建及 18 项隔离模拟交易测试，涵盖权限、签名、金额、幂等、超时、退款和加密。模拟测试不替代真实商户联调。没有将商户凭据写入交付包。

接口依据（交付时核对）：
- https://docs.esimaccess.com/
- https://docs.stripe.com/api/checkout/sessions/create
- https://docs.stripe.com/webhooks/signature
- https://supabase.com/docs/guides/auth/auth-email-passwordless

## 多语言与货币显示

网站支持八种语言：English、简体中文、日本語、한국어、Español、Français、Deutsch、Português (Brasil)。可在页面顶部切换，选择保存在当前浏览器并优先于自动识别。首次访问按浏览器首选语言匹配；不支持的语言回退至英文。葡萄牙语采用巴西版本，中文采用简体版本。套餐原始名称、供应商说明和商户政策保留发布原文；常用国家名称随语言显示并可搜索。品牌规范页为中文设计文档。

支持 USD、CNY、HKD、SGD、EUR、GBP、JPY、AUD 参考换算。服务端 `/api/rates` 从 Frankfurter 获取参考汇率，缓存一小时，并显示汇率数据日期。拒绝超过十天或无效的汇率；接口失败时只显示美元，不使用编造汇率。无需额外 API key。

所有采购、后台定价、订单账目和 Stripe 结算保持 USD。非美元金额前带“约 / ≈”，购买弹窗与订单页另列美元原始金额，税费由结账页面显示。银行卡实际换汇汇率和费用可能不同。这是多币种参考展示，并非多币种收款。

词典位于 `lib/i18n/`，语言注册表为 `lib/i18n/languages.ts`；语言和汇率显示组件位于 `components/locale-provider.tsx`。服务端密钥、商品定价和交易金额不接受货币切换参数。
