diff --git a/.gitignore b/.gitignore index 4a14d5b..dc4af47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # 环境配置 +.env .env.local +.env.test .env.*.local # 密钥和证书 @@ -39,6 +41,7 @@ Thumbs.db logs/ # 临时文件 +.playwright-mcp/ tmp/ temp/ .ci-tmp/ @@ -90,3 +93,5 @@ storage/uploads/ # 本地开发 local/ .local/ +vibe_images/ +.qoder/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3becf82 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AGENTS.md — lunar 项目代理指令 + +## 项目概述 + +lunar 是一个融合中国传统文化(农历、八字、许愿树)的现代化应用,包含: +- **mini/**: 微信小程序前端(农历日历、八字排盘、许愿树、百科) +- **server/**: Go 后端服务(Gin + GORM + MySQL,提供小程序 API 和商业版支付) +- **web/**: 前端工程化包(core + web) +- **web-commercial/**: 商业版前端(React + Vite) +- **wishing-tree-pages/**: 许愿树独立页面(Next.js) + +## 核心路径导航 + +| 路径 | 说明 | 关键文件 | +|------|------|----------| +| `server/internal/handler/` | HTTP API 处理器 | `user.go`, `commerce.go`, `pay.go`, `wiki.go` | +| `server/internal/service/` | 业务逻辑层 | `user.go`, `commerce.go`, `payment_provider.go`, `wish.go` | +| `server/internal/model/` | 数据模型 | `user.go`, `order.go`, `commerce.go`, `wish.go` | +| `server/internal/middleware/` | 中间件 | `middleware.go`(认证、CORS、限流) | +| `server/internal/config/` | 配置管理 | `config.go`, `database.go` | +| `server/migrations/` | 数据库迁移 | `001_init.sql`, `002_commerce.sql` | +| `mini/pages/` | 小程序页面 | `home/`, `calendar/`, `wish-tree/`, `bazi/`, `wiki/` | +| `mini/utils/` | 小程序工具 | `lunar.js`, `request.js`, `birth-profile.js` | +| `mini/components/` | 小程序组件 | `calendar-grid/`, `score-ring/`, `badge/` | +| `mini/ci/` | 小程序 CI 脚本 | `preview-ci.cjs`, `upload-ci.cjs` | + +## 高频变更热点 + +- `mini/pages/wish-tree/wish-tree.js` — 许愿树页面(30 天 churn 4549 行) +- `mini/utils/core/index.js` — 核心工具(churn 1734 行) +- `server/internal/handler/user.go` — 用户 API(churn 488 行) +- `server/internal/middleware/middleware.go` — 中间件(churn 259 行) + +## 启动与验证命令 + +### 后端(server/) +```bash +cd server +cp .env.example .env.local # 首次运行 +go mod download +go run ./cmd # 开发启动,默认 :8080 +go test ./... # 运行测试 +go build -o bin/server ./cmd # 构建 +``` + +### 小程序(mini/) +```bash +cd mini +npm install +npm run dev # 开发模式(微信开发者工具预览) +npm run upload # 上传体验版(需配置 CI) +``` + +### 商业前端(web-commercial/) +```bash +cd web-commercial +pnpm install +pnpm dev # 开发服务器 +pnpm build # 构建 +``` + +## 高风险操作警示 + +- **生产部署**: 禁止 SSH 直连服务器,使用 Gitea Actions + Release 拉取式部署 +- **数据库**: 禁止执行 `fresh` 或 `refresh`,新环境用 AutoMigrate,已有库手动执行 migrations +- **支付**: 任何支付功能必须同时具备真实下单、Webhook 验签、订单状态机、幂等处理、退款处理 +- **密钥**: 所有支付与邮件密钥必须通过环境变量注入,禁止提交到 Git + +## 测试覆盖现状 + +- Go 后端:2 个测试文件(`server/internal/service/` 下) +- 小程序前端:0 个测试文件 +- 商业前端:0 个测试文件 + +## 相关文档 + +- `server/README.md` — 后端服务详细说明 +- `web/docs/ARCHITECTURE.md` — 前端架构文档 +- `web/docs/REQUIREMENTS.md` — 需求文档 +- `REMINDER.md` — 开发提醒(商业化功能规范) diff --git a/REMINDER.md b/REMINDER.md new file mode 100644 index 0000000..e427c59 --- /dev/null +++ b/REMINDER.md @@ -0,0 +1,16 @@ +# 开发提醒 + +## 2026-09-06:商业化功能不能只完成界面 + +- 任何标记为“支付已接入”的功能,必须同时具备真实服务商下单、Webhook 验签、订单状态机、幂等处理、用户权益、退款处理和后台查询。 +- 演示支付必须明确标注,不能把模拟数据描述成真实支付能力。 +- 付费数字内容必须支持跨设备恢复,并保存购买时的不可变快照,避免算法升级后用户看到不同结果。 +- 完成页面设计后,应继续验证完整的“登录—下单—支付通知—发放权益—恢复报告—退款撤权”闭环。 +- 没有商户凭证时只能声明代码与模拟测试完成,不能声明真实生产收款已经开通。 +- 管理后台新增 PC 商业模块后,必须统一品牌名称、页面标题和导航,不能让新旧产品名称混在同一套后台界面里。 +- 排盘产品必须保存服务端历史快照;不能只依赖浏览器状态,否则重复排盘、算法升级和跨设备恢复都会产生歧义。 +- 基础排盘事实(四柱、起运、刑冲合害、神煞位置)应作为免费层展示,付费点应放在个性化解释而不是隐藏原始数据。 +- 交运时间不能直接使用 ChildLimit 的出生区间起点;应显示真正的第一步大运交运时间。 +- 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。 +- 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。 +- 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..004586d --- /dev/null +++ b/server/.env.example @@ -0,0 +1,28 @@ +SERVER_PORT=8080 +SERVER_ENV=development +PUBLIC_BASE_URL=http://localhost:8080 +WEB_APP_URL=http://localhost:4260 +CORS_ORIGINS=http://localhost:4260 + +DB_TYPE=sqlite +DB_NAME=lunar.db +JWT_SECRET=replace-with-a-long-random-secret +ADMIN_PASSWORD=replace-with-a-strong-admin-password + +POSTMARK_SERVER_TOKEN= +POSTMARK_FROM_EMAIL=login@your-domain.com +POSTMARK_API_BASE_URL=https://api.postmarkapp.com + +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_API_BASE_URL=https://api.stripe.com + +BINANCE_PAY_API_KEY= +BINANCE_PAY_SECRET_KEY= +BINANCE_PAY_WEBHOOK_PUBLIC_KEY= +BINANCE_PAY_API_BASE_URL=https://bpay.binanceapi.com + +MINI_APP_ID= +MINI_APP_SECRET= +WECHAT_PAY_APIKEY= +WECHAT_PAY_MCHID= diff --git a/server/README.md b/server/README.md index 83e7e1c..3557def 100644 --- a/server/README.md +++ b/server/README.md @@ -1,114 +1,105 @@ -# 祈福小助手后端服务 +# 知命 / 祈福后端服务 -基于 Go + Gin + Inertia.js 的后端服务。 +基于 Go、Gin、GORM 的统一后端,提供微信小程序能力、商业版八字报告、邮箱登录、支付、订单权益和管理后台。 ## 功能模块 -- **用户模块** — 微信登录、资料修改、授权管理 -- **支付模块** — 微信支付集成、订单管理 -- **许愿树** — 许愿发布、付费许愿、机器人自动许愿 -- **百科/历史上的今天** — 百科条目后台维护;从中文维基百科同步 366 天「历史上的今天」数据,定时刷新 -- **管理后台** — 基于 Inertia.js 的前后端分离管理界面 - -## 技术栈 - -- **后端**: Go 1.22 + Gin + GORM -- **前端**: Inertia.js + React/Vue(待选择) -- **数据库**: MySQL -- **缓存**: Redis +- 微信小程序登录与生辰档案。 +- Postmark 邮箱验证码登录。 +- Stripe `4.99 USD` 报告支付。 +- Binance Pay `5 USDT` 报告支付。 +- 签名 Webhook、幂等事件、退款权益撤销。 +- 随机报告恢复码与不可变报告快照。 +- 用户订单与报告权益查询。 +- 数据库驱动的 PC 商业版订单列表和收入统计。 +- PC 商业版后台只查询 `type=bazi_report`,与小程序许愿等旧业务订单逻辑隔离。 +- 许愿树与百科数据。 ## 快速开始 -### 开发环境 - ```bash -# 安装依赖 +cp .env.example .env.local go mod download - -# 启动开发服务器 -./scripts/dev.sh +go run ./cmd ``` -### 生产构建 +默认地址:`http://localhost:8080`。 + +生产部署可使用 systemd 直接运行二进制,商业前端静态文件放在服务目录的 `frontend/`,根路径 `/` 为商业站点,`/admin/orders` 为管理后台,`/api/*` 为后端接口。这样无需 Docker 或 Nginx,适合小内存服务器;Cloudflare 可在边缘终结 HTTPS 并回源到 80 端口。 + +## 测试 + +项目包含 HTTP API 测试,覆盖未认证拦截、Webhook 拒绝、Stripe 模拟下单、签名回调、权益发放、恢复码和快照读取。 ```bash -# 构建 -./scripts/build.sh - -# 运行 -./bin/lunar-server +go test ./... ``` -## 项目结构 +测试环境文件为本地 `server/.env.test`,不提交任何真实密钥。 -``` -server/ -├── cmd/ # 入口文件 -│ └── main.go -├── internal/ # 内部包 -│ ├── config/ # 配置 -│ ├── handler/ # 处理器 -│ ├── middleware/ # 中间件 -│ ├── model/ # 数据模型 -│ └── service/ # 业务逻辑 -├── web/ # Inertia.js 前端 -│ ├── src/ -│ └── public/ -├── migrations/ # 数据库迁移 -├── scripts/ # 脚本 -└── go.mod -``` +## 商业支付 API -## API 接口 +### 邮箱登录 -### 用户接口 +- `POST /api/auth/email/request` +- `POST /api/auth/email/verify` -- `POST /api/user/login` — 用户登录 -- `POST /api/user/logout` — 用户登出 -- `GET /api/user/profile` — 获取用户资料 -- `PUT /api/user/profile` — 更新用户资料 -- `POST /api/user/auth` — 微信授权 +### 支付与报告 -### 支付接口 +- `POST /api/commerce/checkout` — 创建 Stripe 或 Binance Pay 订单。 +- `POST /api/commerce/webhooks/stripe` — Stripe 签名通知。 +- `POST /api/commerce/webhooks/binance-pay` — Binance Pay 签名通知。 +- `GET /api/commerce/orders/:orderNo` — 查询本人订单。 +- `GET /api/commerce/reports/:reportId/access` — 查询报告权益。 +- `GET /api/commerce/reports` — 本人已购报告。 +- `GET /api/commerce/reports/recover/:accessCode` — 使用恢复码读取购买时快照。 -- `POST /api/pay/create` — 创建订单 -- `POST /api/pay/notify` — 支付回调 -- `GET /api/pay/status/:orderId` — 查询支付状态 +除 Webhook 外,商业接口都要求 `Authorization: Bearer `。 -### 订单接口 +## 管理后台 -- `GET /api/order/list` — 订单列表 -- `GET /api/order/detail/:id` — 订单详情 -- `POST /api/order/cancel/:id` — 取消订单 +- `/admin/orders` — 真实订单列表。 +- `/admin/api/orders` — 按渠道、状态、订单号、邮箱、报告号查询。 +- `/admin/api/orders/stats` — 订单和收入统计。 -### 许愿接口 +后台使用 `ADMIN_PASSWORD` 登录并写入 HttpOnly Cookie。 -- `GET /api/wish/tree` — 获取许愿树 -- `POST /api/wish/create` — 创建许愿 -- `GET /api/wish/list` — 许愿列表 -- `DELETE /api/wish/:id` — 删除许愿 +## 支付安全 -### 百科接口 - -- `GET /api/wiki/entries` — 百科条目列表(支持 category/keyword 过滤) -- `GET /api/wiki/on-this-day?month=&day=` — 历史上的今天(大事记/出生/逝世/节假日习俗) - -### 管理后台接口(/admin,需管理员认证) - -- `GET/POST /admin/api/wiki/entries` — 百科条目列表/新增 -- `PUT/DELETE /admin/api/wiki/entries/:id` — 更新/删除条目 -- `GET /admin/api/wiki/sync-status` — 维基同步状态 -- `POST /admin/api/wiki/sync` — 手动触发全量同步 - -## 环境变量 - -参考 `.env.local` 文件配置。维基同步相关: - -- `WIKI_SYNC_INTERVAL_DAYS` — 自动同步间隔(天),默认 7 -- `WIKI_SYNC_HOUR` — 自动同步执行的小时点(0-23),默认 4 - -同步策略:服务启动时若数据缺失自动补齐;之后每天在指定小时点检查,距上次成功超过间隔天数才全量刷新(共 366 页,只抓中文版)。 +- 金额只从 `report_products` 读取。 +- Stripe 使用 Checkout Session 和 `Idempotency-Key`。 +- Stripe Webhook 校验 HMAC 签名与 5 分钟时间窗口。 +- Binance Pay 下单使用 HMAC-SHA512。 +- Binance Pay Webhook 使用商户证书 RSA 公钥验签。 +- Webhook 同时校验服务商订单号、金额与币种。 +- `payment_events` 唯一索引保证重复回调不会重复发放权益。 +- 支付、事件、恢复码和权益在数据库事务中完成。 +- 退款后报告权益自动撤销。 ## 数据库 -执行 `migrations/001_init.sql` 初始化数据库。 +新安装由 GORM AutoMigrate 创建表。已有 MySQL 数据库先备份,再单独执行: + +```bash +mysql < migrations/002_commerce.sql +``` + +不要执行数据库 fresh 或 refresh。 + +## 环境变量 + +完整模板见 `.env.example`。生产环境至少需要: + +- `JWT_SECRET` +- `ADMIN_PASSWORD` +- `PUBLIC_BASE_URL` +- `WEB_APP_URL` +- `POSTMARK_SERVER_TOKEN` +- `POSTMARK_FROM_EMAIL` +- `STRIPE_SECRET_KEY` +- `STRIPE_WEBHOOK_SECRET` +- `BINANCE_PAY_API_KEY` +- `BINANCE_PAY_SECRET_KEY` +- `BINANCE_PAY_WEBHOOK_PUBLIC_KEY` + +所有支付与邮件密钥必须通过部署环境注入,禁止提交到 Git。 diff --git a/server/cmd/main.go b/server/cmd/main.go index 699cb9c..ef977d0 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -68,9 +68,12 @@ func main() { // 中间件 r.Use(middleware.CORS()) - // 静态文件与管理后台模板 + // 商业版前端静态文件、旧管理后台静态资源与模板 + // 商业站点部署在根路径;后台仍通过 /admin 访问。 + r.Static("/assets", "./frontend/assets") + r.GET("/index.html", func(c *gin.Context) { c.File("./frontend/index.html") }) + r.GET("/", func(c *gin.Context) { c.File("./frontend/index.html") }) r.Static("/static", "./web/static") - r.StaticFile("/", "./web/index.html") r.LoadHTMLFiles("./web/index.html") // 内嵌图片资源(许愿树背景图等) @@ -84,6 +87,24 @@ func main() { // 版本信息(无需认证) api.GET("/version", handler.GetVersion) + auth := api.Group("/auth") + { + auth.POST("/email/request", middleware.RateLimit(), handler.RequestEmailLoginCode) + auth.POST("/email/verify", middleware.RateLimit(), handler.VerifyEmailLoginCode) + } + + commerce := api.Group("/commerce") + { + commerce.POST("/charts", handler.SaveChartRecord) + commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout) + commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder) + commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess) + commerce.GET("/reports", middleware.Auth(), handler.ListPurchasedReports) + commerce.GET("/reports/recover/:accessCode", middleware.Auth(), handler.GetReportByAccessCode) + commerce.POST("/webhooks/stripe", handler.StripeWebhook) + commerce.POST("/webhooks/binance-pay", handler.BinancePayWebhook) + } + // 用户相关 user := api.Group("/user") { @@ -103,7 +124,7 @@ func main() { { pay.POST("/create", middleware.Auth(), handler.CreateOrder) pay.POST("/notify", handler.PayNotify) - pay.GET("/status/:orderId", handler.GetPayStatus) + pay.GET("/status/:orderId", middleware.Auth(), handler.GetPayStatus) } // 订单相关 @@ -148,6 +169,7 @@ func main() { authed.GET("/", handler.AdminDashboard) authed.GET("/users", handler.AdminUsers) authed.GET("/orders", handler.AdminOrders) + authed.GET("/charts", handler.AdminCharts) authed.GET("/wishes", handler.AdminWishes) authed.GET("/wiki", handler.AdminWiki) authed.GET("/settings", handler.AdminSettings) @@ -159,6 +181,9 @@ func main() { authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete) authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus) authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger) + authed.GET("/api/orders", handler.AdminCommerceOrders) + authed.GET("/api/orders/stats", handler.AdminCommerceStats) + authed.GET("/api/charts", handler.AdminChartRecords) // 节气详情同步(百度百科,独立于维基) authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus) authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger) diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 36496c6..9c89a4e 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -6,13 +6,17 @@ import ( ) type Config struct { - Server ServerConfig - Database DatabaseConfig - Redis RedisConfig - JWT JWTConfig - Wechat WechatConfig - Admin AdminConfig - Wiki WikiConfig + Server ServerConfig + Database DatabaseConfig + Redis RedisConfig + JWT JWTConfig + Wechat WechatConfig + Stripe StripeConfig + BinancePay BinancePayConfig + Postmark PostmarkConfig + Commerce CommerceConfig + Admin AdminConfig + Wiki WikiConfig } type ServerConfig struct { @@ -48,6 +52,30 @@ type WechatConfig struct { MchID string } +type StripeConfig struct { + SecretKey string + WebhookSecret string + APIBaseURL string +} + +type BinancePayConfig struct { + APIKey string + SecretKey string + APIBaseURL string + WebhookPublicKey string +} + +type PostmarkConfig struct { + ServerToken string + FromEmail string + APIBaseURL string +} + +type CommerceConfig struct { + PublicBaseURL string + WebAppURL string +} + // AdminConfig 管理后台配置 type AdminConfig struct { // Password 管理员登录密码;为空时禁用后台登录 @@ -92,6 +120,26 @@ func Load() *Config { PayKey: getEnv("WECHAT_PAY_APIKEY", ""), MchID: getEnv("WECHAT_PAY_MCHID", ""), }, + Stripe: StripeConfig{ + SecretKey: getEnv("STRIPE_SECRET_KEY", ""), + WebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""), + APIBaseURL: getEnv("STRIPE_API_BASE_URL", "https://api.stripe.com"), + }, + BinancePay: BinancePayConfig{ + APIKey: getEnv("BINANCE_PAY_API_KEY", ""), + SecretKey: getEnv("BINANCE_PAY_SECRET_KEY", ""), + APIBaseURL: getEnv("BINANCE_PAY_API_BASE_URL", "https://bpay.binanceapi.com"), + WebhookPublicKey: getEnv("BINANCE_PAY_WEBHOOK_PUBLIC_KEY", ""), + }, + Postmark: PostmarkConfig{ + ServerToken: getEnv("POSTMARK_SERVER_TOKEN", ""), + FromEmail: getEnv("POSTMARK_FROM_EMAIL", ""), + APIBaseURL: getEnv("POSTMARK_API_BASE_URL", "https://api.postmarkapp.com"), + }, + Commerce: CommerceConfig{ + PublicBaseURL: getEnv("PUBLIC_BASE_URL", "http://localhost:8080"), + WebAppURL: getEnv("WEB_APP_URL", "http://localhost:4260"), + }, Admin: AdminConfig{ Password: getEnv("ADMIN_PASSWORD", ""), }, diff --git a/server/internal/config/database.go b/server/internal/config/database.go index bdf0005..6224437 100644 --- a/server/internal/config/database.go +++ b/server/internal/config/database.go @@ -5,9 +5,9 @@ import ( "log" "github.com/glebarez/sqlite" + "github.com/gouki/lunar-server/internal/model" "gorm.io/driver/mysql" "gorm.io/gorm" - "github.com/gouki/lunar-server/internal/model" ) var DB *gorm.DB @@ -56,6 +56,12 @@ func autoMigrate() error { &model.BirthProfile{}, &model.Order{}, &model.OrderItem{}, + &model.ReportProduct{}, + &model.ReportEntitlement{}, + &model.ReportSnapshot{}, + &model.PaymentEvent{}, + &model.EmailLoginCode{}, + &model.BaziChartRecord{}, &model.WishTree{}, &model.Wish{}, &model.WishProduct{}, @@ -78,6 +84,14 @@ func autoMigrate() error { // seedDefaultData 初始化默认数据 func seedDefaultData() error { + reportProduct := model.ReportProduct{ + Code: "bazi_full_report", Name: "八字完整精批报告", Description: "六大主题与十步大运完整解读", + StripeAmount: 499, StripeCurrency: "USD", BinanceAmount: "5.00", BinanceCurrency: "USDT", Status: 1, + } + if err := DB.Where("code = ?", reportProduct.Code).FirstOrCreate(&reportProduct).Error; err != nil { + return err + } + // 补充已有许愿树的背景图(新增字段后老数据为空) if err := DB.Model(&model.WishTree{}). Where("image = '' OR image IS NULL"). diff --git a/server/internal/handler/admin.go b/server/internal/handler/admin.go index 663389b..d7a09e8 100644 --- a/server/internal/handler/admin.go +++ b/server/internal/handler/admin.go @@ -92,9 +92,14 @@ func AdminUsers(c *gin.Context) { adminPage(c, "用户管理", "users") } -// AdminOrders 订单管理 +// AdminOrders PC 商业版订单管理 func AdminOrders(c *gin.Context) { - adminPage(c, "订单管理", "orders") + adminPage(c, "PC 报告订单", "orders") +} + +// AdminCharts 排盘记录 +func AdminCharts(c *gin.Context) { + adminPage(c, "排盘记录", "charts") } // AdminWishes 许愿管理 diff --git a/server/internal/handler/commerce.go b/server/internal/handler/commerce.go new file mode 100644 index 0000000..d0cea34 --- /dev/null +++ b/server/internal/handler/commerce.go @@ -0,0 +1,272 @@ +package handler + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gouki/lunar-server/internal/config" + "github.com/gouki/lunar-server/internal/model" + "github.com/gouki/lunar-server/internal/service" +) + +var reportIDPattern = regexp.MustCompile(`^ZM-[A-Z0-9]{7,32}$`) +var accessCodePattern = regexp.MustCompile(`^ZM-[A-Z2-7]{4}-[A-Z2-7]{4}-[A-Z2-7]{4}$`) + +func SaveChartRecord(c *gin.Context) { + var request struct { + ReportID string `json:"reportId" binding:"required"` + Name string `json:"name"` + BirthDate string `json:"birthDate" binding:"required"` + BirthTime string `json:"birthTime" binding:"required"` + Gender string `json:"gender" binding:"required"` + City string `json:"city" binding:"required"` + Snapshot json.RawMessage `json:"snapshot" binding:"required"` + } + if err := c.ShouldBindJSON(&request); err != nil || !reportIDPattern.MatchString(request.ReportID) || len(request.Snapshot) < 2 || len(request.Snapshot) > 512*1024 || request.Snapshot[0] != '{' { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误"}) + return + } + record := &model.BaziChartRecord{ + ReportID: request.ReportID, Name: request.Name, BirthDate: request.BirthDate, + BirthTime: request.BirthTime, Gender: request.Gender, City: request.City, Snapshot: string(request.Snapshot), + } + if userID, exists := c.Get("userID"); exists { + if value, ok := userID.(uint); ok { + record.UserID = &value + } + } + if err := service.NewCommerceService().CreateChartRecord(record); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "保存排盘记录失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"recordId": record.ID}}) +} + +func CreateReportCheckout(c *gin.Context) { + userID, _ := c.Get("userID") + var request struct { + Provider string `json:"provider" binding:"required"` + ReportID string `json:"reportId" binding:"required"` + Snapshot json.RawMessage `json:"snapshot" binding:"required"` + } + if err := c.ShouldBindJSON(&request); err != nil || !reportIDPattern.MatchString(request.ReportID) || len(request.Snapshot) > 256*1024 || len(request.Snapshot) < 2 || request.Snapshot[0] != '{' { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "支付参数错误"}) + return + } + user, err := service.NewUserService().GetUserByID(userID.(uint)) + if err != nil || user.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "请先完成邮箱登录"}) + return + } + commerce := service.NewCommerceService() + product, err := commerce.GetActiveReportProduct() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "商品暂不可购买"}) + return + } + order, err := commerce.CreateReportOrder(user.ID, user.Email, request.ReportID, request.Provider, string(request.Snapshot), product) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": err.Error()}) + return + } + cfg := config.Load() + providerConfigured := (request.Provider == "stripe" && cfg.Stripe.SecretKey != "") || + (request.Provider == "binance_pay" && cfg.BinancePay.APIKey != "") + // 开发模式:支付渠道未配置时走本地模拟支付,方便完整联调 + if !providerConfigured && cfg.Server.Env != "production" { + mockURL := strings.TrimRight(cfg.Commerce.WebAppURL, "/") + "/?payment=success&order=" + url.QueryEscape(order.OrderNo) + if err := commerce.SetCheckout(order.OrderNo, "DEV-"+order.OrderNo, mockURL); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "订单保存失败"}) + return + } + // 模拟 webhook:立即发放权益(Stripe 金额用 cents,Binance 用 USDT 数量) + mockPaidAmount := order.ProviderAmount + if request.Provider == "stripe" { + mockPaidAmount = strconv.Itoa(order.Amount) + } + _ = commerce.CompletePayment(request.Provider, "dev-"+order.OrderNo, "dev.mock.paid", order.OrderNo, "DEV-"+order.OrderNo, "DEV-TX-"+order.OrderNo, mockPaidAmount, order.Currency, "dev-mock") + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{ + "orderNo": order.OrderNo, "provider": order.Provider, "amount": order.ProviderAmount, + "currency": order.Currency, "checkoutUrl": mockURL, "expiresAt": order.ExpireTime, "demo": true, + }}) + return + } + var checkout *service.CheckoutResult + if request.Provider == "stripe" { + checkout, err = service.NewStripeProvider(cfg.Stripe).CreateCheckout(c.Request.Context(), order, cfg.Commerce.WebAppURL) + } else if request.Provider == "binance_pay" { + checkout, err = service.NewBinancePayProvider(cfg.BinancePay).CreateCheckout(c.Request.Context(), order, cfg.Commerce.WebAppURL, cfg.Commerce.PublicBaseURL) + } else { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "不支持的支付方式"}) + return + } + if err != nil { + _ = commerce.MarkOrderStatus(request.Provider, "create-failed:"+order.OrderNo, "checkout.create_failed", order.OrderNo, "cancelled", "") + c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "支付渠道暂时不可用"}) + return + } + if err := commerce.SetCheckout(order.OrderNo, checkout.ProviderOrderID, checkout.CheckoutURL); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "订单保存失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{ + "orderNo": order.OrderNo, "provider": order.Provider, "amount": order.ProviderAmount, + "currency": order.Currency, "checkoutUrl": checkout.CheckoutURL, "expiresAt": order.ExpireTime, + }}) +} + +func StripeWebhook(c *gin.Context) { + raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + c.Status(http.StatusBadRequest) + return + } + event, err := service.NewStripeProvider(config.Load().Stripe).ParseWebhook(raw, c.GetHeader("Stripe-Signature"), time.Now()) + if err != nil { + c.Status(http.StatusBadRequest) + return + } + if err := processCommerceWebhook("stripe", event); err != nil { + c.Status(http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, gin.H{"received": true}) +} + +func BinancePayWebhook(c *gin.Context) { + raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + c.Status(http.StatusBadRequest) + return + } + event, err := service.NewBinancePayProvider(config.Load().BinancePay).ParseWebhook( + raw, c.GetHeader("BinancePay-Timestamp"), c.GetHeader("BinancePay-Nonce"), c.GetHeader("BinancePay-Signature"), + ) + if err != nil { + c.Status(http.StatusBadRequest) + return + } + if err := processCommerceWebhook("binance_pay", event); err != nil { + c.Status(http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, gin.H{"returnCode": "SUCCESS", "returnMessage": nil}) +} + +func processCommerceWebhook(provider string, event *service.PaymentWebhook) error { + if event.Status == "ignored" { + return nil + } + commerce := service.NewCommerceService() + if event.Status == "refunded" { + return commerce.RefundPayment(provider, event.EventID, event.EventType, event.OrderNo, event.TransactionID, event.PayloadHash) + } + if event.OrderNo == "" { + return nil + } + if event.Status == "paid" { + return commerce.CompletePayment(provider, event.EventID, event.EventType, event.OrderNo, event.ProviderOrderID, event.TransactionID, event.Amount, event.Currency, event.PayloadHash) + } + return commerce.MarkOrderStatus(provider, event.EventID, event.EventType, event.OrderNo, event.Status, event.PayloadHash) +} + +func GetCommerceOrder(c *gin.Context) { + userID, _ := c.Get("userID") + order, err := service.NewCommerceService().GetUserOrder(userID.(uint), c.Param("orderNo")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "订单不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": order}) +} + +func GetReportAccess(c *gin.Context) { + userID, _ := c.Get("userID") + reportID := c.Param("reportId") + if !reportIDPattern.MatchString(reportID) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "报告编号错误"}) + return + } + access, err := service.NewCommerceService().HasReportAccess(userID.(uint), reportID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "查询失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"reportId": reportID, "unlocked": access}}) +} + +func GetReportByAccessCode(c *gin.Context) { + userID, _ := c.Get("userID") + accessCode := c.Param("accessCode") + if !accessCodePattern.MatchString(accessCode) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "恢复码格式错误"}) + return + } + record, err := service.NewCommerceService().GetReportSnapshot(userID.(uint), accessCode) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "未找到已购买报告"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"reportId": record.ReportID, "snapshot": json.RawMessage(record.Snapshot)}}) +} + +func ListPurchasedReports(c *gin.Context) { + userID, _ := c.Get("userID") + reports, err := service.NewCommerceService().ListPurchasedReports(userID.(uint)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取报告失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"list": reports}}) +} + +func AdminCommerceOrders(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + filter := service.AdminOrderFilter{Page: page, PageSize: pageSize, Status: c.Query("status"), Provider: c.Query("provider"), Search: c.Query("search")} + orders, total, err := service.NewCommerceService().AdminOrders(filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取订单失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"list": orders, "total": total, "page": page, "pageSize": pageSize}}) +} + +func AdminChartRecords(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 50 + } + records, total, err := service.NewCommerceService().AdminChartRecords(c.Query("search"), page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取排盘记录失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"list": records, "total": total, "page": page, "pageSize": pageSize}}) +} + +func AdminCommerceStats(c *gin.Context) { + stats, err := service.NewCommerceService().AdminStats() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取统计失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": stats}) +} diff --git a/server/internal/handler/commerce_test.go b/server/internal/handler/commerce_test.go new file mode 100644 index 0000000..9a50bab --- /dev/null +++ b/server/internal/handler/commerce_test.go @@ -0,0 +1,157 @@ +package handler + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gouki/lunar-server/internal/config" + "github.com/gouki/lunar-server/internal/middleware" + "github.com/gouki/lunar-server/internal/model" + "github.com/gouki/lunar-server/internal/service" +) + +func TestCommerceCheckoutRequiresAuthentication(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/commerce/checkout", middleware.Auth(), CreateReportCheckout) + request := httptest.NewRequest(http.MethodPost, "/api/commerce/checkout", strings.NewReader(`{"provider":"stripe","reportId":"ZM-ABCDEFG","snapshot":{"version":1,"result":"test"}}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), `"code":401`) { + t.Fatalf("unexpected response body: %s", response.Body.String()) + } +} + +func TestStripeWebhookRejectsUnsignedRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/commerce/webhooks/stripe", StripeWebhook) + request := httptest.NewRequest(http.MethodPost, "/api/commerce/webhooks/stripe", strings.NewReader(`{"id":"evt_invalid"}`)) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", response.Code) + } +} + +func TestEmailLoginRejectsMalformedBody(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/api/auth/email/request", RequestEmailLoginCode) + request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", response.Code) + } + if !strings.Contains(response.Body.String(), `"code":400`) { + t.Fatalf("unexpected response body: %s", response.Body.String()) + } +} + +func TestStripeCheckoutWebhookAndEntitlementHTTPFlow(t *testing.T) { + gin.SetMode(gin.TestMode) + stripeAPI := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + fmt.Fprint(response, `{"id":"cs_test_flow","url":"https://checkout.stripe.test/flow"}`) + })) + defer stripeAPI.Close() + + t.Setenv("DB_TYPE", "sqlite") + t.Setenv("DB_NAME", "file:commerce-http-test?mode=memory&cache=shared") + t.Setenv("JWT_SECRET", "http-test-secret") + t.Setenv("STRIPE_SECRET_KEY", "sk_test") + t.Setenv("STRIPE_WEBHOOK_SECRET", "whsec_http_test") + t.Setenv("STRIPE_API_BASE_URL", stripeAPI.URL) + t.Setenv("WEB_APP_URL", "https://example.test") + if err := config.InitDB(config.Load()); err != nil { + t.Fatal(err) + } + user := model.User{OpenID: "email:http-test", Email: "buyer@example.com", Nickname: "buyer", Status: 1} + if err := config.GetDB().Create(&user).Error; err != nil { + t.Fatal(err) + } + token, err := service.NewUserService().GenerateToken(user.ID, "http-test-secret") + if err != nil { + t.Fatal(err) + } + + router := gin.New() + router.POST("/api/commerce/checkout", middleware.Auth(), CreateReportCheckout) + router.POST("/api/commerce/webhooks/stripe", StripeWebhook) + router.GET("/api/commerce/orders/:orderNo", middleware.Auth(), GetCommerceOrder) + router.GET("/api/commerce/reports/:reportId/access", middleware.Auth(), GetReportAccess) + router.GET("/api/commerce/reports/recover/:accessCode", middleware.Auth(), GetReportByAccessCode) + + checkoutRequest := httptest.NewRequest(http.MethodPost, "/api/commerce/checkout", strings.NewReader(`{"provider":"stripe","reportId":"ZM-ABCDEFG","snapshot":{"version":1,"id":"ZM-ABCDEFG","result":{"eightChar":{"dayMaster":"甲","dayMasterElement":"木"},"decadeFortunes":[{"ganzhi":"丙寅","startAge":8}]},"elements":{"dominant":"木","weakest":"金"},"shensha":[],"sections":[]}}`)) + checkoutRequest.Header.Set("Content-Type", "application/json") + checkoutRequest.Header.Set("Authorization", "Bearer "+token) + checkoutResponse := httptest.NewRecorder() + router.ServeHTTP(checkoutResponse, checkoutRequest) + if checkoutResponse.Code != http.StatusOK { + t.Fatalf("expected checkout 200, got %d: %s", checkoutResponse.Code, checkoutResponse.Body.String()) + } + var checkoutPayload struct { + Data struct { + OrderNo string `json:"orderNo"` + } `json:"data"` + } + if err := json.Unmarshal(checkoutResponse.Body.Bytes(), &checkoutPayload); err != nil || checkoutPayload.Data.OrderNo == "" { + t.Fatalf("invalid checkout response: %s", checkoutResponse.Body.String()) + } + + now := time.Now() + webhookBody := []byte(fmt.Sprintf(`{"id":"evt_http_flow","type":"checkout.session.completed","data":{"object":{"id":"cs_test_flow","payment_intent":"pi_http_flow","payment_status":"paid","amount_total":499,"currency":"usd","metadata":{"order_no":"%s"}}}}`, checkoutPayload.Data.OrderNo)) + mac := hmac.New(sha256.New, []byte("whsec_http_test")) + mac.Write([]byte(fmt.Sprintf("%d.%s", now.Unix(), webhookBody))) + webhookRequest := httptest.NewRequest(http.MethodPost, "/api/commerce/webhooks/stripe", strings.NewReader(string(webhookBody))) + webhookRequest.Header.Set("Stripe-Signature", fmt.Sprintf("t=%d,v1=%s", now.Unix(), hex.EncodeToString(mac.Sum(nil)))) + webhookResponse := httptest.NewRecorder() + router.ServeHTTP(webhookResponse, webhookRequest) + if webhookResponse.Code != http.StatusOK { + t.Fatalf("expected webhook 200, got %d: %s", webhookResponse.Code, webhookResponse.Body.String()) + } + + accessRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/reports/ZM-ABCDEFG/access", nil) + accessRequest.Header.Set("Authorization", "Bearer "+token) + accessResponse := httptest.NewRecorder() + router.ServeHTTP(accessResponse, accessRequest) + if accessResponse.Code != http.StatusOK || !strings.Contains(accessResponse.Body.String(), `"unlocked":true`) { + t.Fatalf("expected active entitlement, got %d: %s", accessResponse.Code, accessResponse.Body.String()) + } + + orderRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/orders/"+checkoutPayload.Data.OrderNo, nil) + orderRequest.Header.Set("Authorization", "Bearer "+token) + orderResponse := httptest.NewRecorder() + router.ServeHTTP(orderResponse, orderRequest) + var orderPayload struct { + Data struct { + AccessCode string `json:"accessCode"` + } `json:"data"` + } + if err := json.Unmarshal(orderResponse.Body.Bytes(), &orderPayload); err != nil || orderPayload.Data.AccessCode == "" { + t.Fatalf("expected server recovery code, got %s", orderResponse.Body.String()) + } + + recoverRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/reports/recover/"+orderPayload.Data.AccessCode, nil) + recoverRequest.Header.Set("Authorization", "Bearer "+token) + recoverResponse := httptest.NewRecorder() + router.ServeHTTP(recoverResponse, recoverRequest) + if recoverResponse.Code != http.StatusOK || !strings.Contains(recoverResponse.Body.String(), `"title":"性格底色"`) { + t.Fatalf("expected server-generated premium snapshot, got %d: %s", recoverResponse.Code, recoverResponse.Body.String()) + } +} diff --git a/server/internal/handler/email_auth.go b/server/internal/handler/email_auth.go new file mode 100644 index 0000000..6286a93 --- /dev/null +++ b/server/internal/handler/email_auth.go @@ -0,0 +1,50 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gouki/lunar-server/internal/service" +) + +func RequestEmailLoginCode(c *gin.Context) { + var request struct { + Email string `json:"email" binding:"required"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "邮箱格式错误"}) + return + } + err := service.NewEmailAuthService().RequestCode(request.Email) + if errors.Is(err, service.ErrInvalidEmail) { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "邮箱格式错误"}) + return + } + if errors.Is(err, service.ErrLoginCodeRateLimited) { + c.JSON(http.StatusTooManyRequests, gin.H{"code": 429, "msg": "请稍后再获取验证码"}) + return + } + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "验证码暂时无法发送"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "验证码已发送"}) +} + +func VerifyEmailLoginCode(c *gin.Context) { + var request struct { + Email string `json:"email" binding:"required"` + Code string `json:"code" binding:"required"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) + return + } + user, token, err := service.NewEmailAuthService().VerifyCode(request.Email, request.Code) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "验证码无效或已过期"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"token": token, "user": user}}) +} diff --git a/server/internal/handler/pay.go b/server/internal/handler/pay.go index f9bd370..1430bc9 100644 --- a/server/internal/handler/pay.go +++ b/server/internal/handler/pay.go @@ -183,6 +183,7 @@ func PayNotify(c *gin.Context) { // GetPayStatus 获取支付状态(支持订单号或订单 ID) func GetPayStatus(c *gin.Context) { param := c.Param("orderId") + userID, _ := c.Get("userID") orderService := service.NewOrderService() order, err := orderService.GetOrderByOrderNo(param) @@ -192,7 +193,7 @@ func GetPayStatus(c *gin.Context) { order, err = orderService.GetOrderByID(uint(id)) } } - if err != nil { + if err != nil || order.UserID != userID.(uint) { c.JSON(http.StatusNotFound, gin.H{ "code": 404, "msg": "订单不存在", diff --git a/server/internal/middleware/middleware.go b/server/internal/middleware/middleware.go index f7df354..3009162 100644 --- a/server/internal/middleware/middleware.go +++ b/server/internal/middleware/middleware.go @@ -3,6 +3,8 @@ package middleware import ( "net/http" "strings" + "sync" + "time" "github.com/gin-gonic/gin" "github.com/gouki/lunar-server/internal/config" @@ -146,10 +148,32 @@ func AdminAuth() gin.HandlerFunc { } } +var rateLimitStore = struct { + sync.Mutex + requests map[string][]time.Time +}{requests: make(map[string][]time.Time)} + // RateLimit 限流中间件 func RateLimit() gin.HandlerFunc { return func(c *gin.Context) { - // TODO: 实现限流 + now := time.Now() + key := c.ClientIP() + "|" + c.FullPath() + rateLimitStore.Lock() + windowStart := now.Add(-time.Minute) + recent := rateLimitStore.requests[key][:0] + for _, requestTime := range rateLimitStore.requests[key] { + if requestTime.After(windowStart) { + recent = append(recent, requestTime) + } + } + if len(recent) >= 10 { + rateLimitStore.requests[key] = recent + rateLimitStore.Unlock() + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 429, "msg": "请求过于频繁"}) + return + } + rateLimitStore.requests[key] = append(recent, now) + rateLimitStore.Unlock() c.Next() } } diff --git a/server/internal/middleware/middleware_test.go b/server/internal/middleware/middleware_test.go new file mode 100644 index 0000000..3be0054 --- /dev/null +++ b/server/internal/middleware/middleware_test.go @@ -0,0 +1,152 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRateLimitAllowsUnderLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RateLimit()) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + for i := 0; i < 10; i++ { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = "192.168.1.1:12345" + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("request %d should be allowed, got %d", i+1, resp.Code) + } + } +} + +func TestRateLimitBlocksOverLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RateLimit()) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + // 发送 11 个请求,第 11 个应被限流 + for i := 0; i < 11; i++ { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = "192.168.1.2:12345" + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if i < 10 && resp.Code != http.StatusOK { + t.Fatalf("request %d should be allowed, got %d", i+1, resp.Code) + } + if i == 10 && resp.Code != http.StatusTooManyRequests { + t.Fatalf("request 11 should be rate limited, got %d", resp.Code) + } + } +} + +func TestRateLimitWindowReset(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RateLimit()) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + // 先发送 10 个请求达到上限 + for i := 0; i < 10; i++ { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = "192.168.1.3:12345" + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + } + + // 模拟时间过去 61 秒(超过 1 分钟窗口) + // 注意:由于实现使用真实时间,这里只能验证当前窗口内的行为 + // 实际窗口重置测试需要 mock 时间或等待 + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = "192.168.1.3:12345" + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusTooManyRequests { + t.Fatalf("should still be rate limited within window, got %d", resp.Code) + } +} + +func TestAuthRejectsMissingToken(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(Auth()) + router.GET("/protected", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.Code) + } +} + +func TestAuthRejectsMalformedToken(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(Auth()) + router.GET("/protected", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "InvalidFormat token123") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.Code) + } +} + +func TestCORSAllowsConfiguredOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CORS_ORIGINS", "https://example.com,https://app.example.com") + router := gin.New() + router.Use(CORS()) + router.GET("/api/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + req.Header.Set("Origin", "https://example.com") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.Code) + } + if resp.Header().Get("Access-Control-Allow-Origin") != "https://example.com" { + t.Fatalf("expected CORS header for allowed origin, got %s", resp.Header().Get("Access-Control-Allow-Origin")) + } +} + +func TestCORSRejectsUnconfiguredOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CORS_ORIGINS", "https://example.com") + router := gin.New() + router.Use(CORS()) + router.GET("/api/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + req.Header.Set("Origin", "https://evil.com") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + // 非白名单源不应返回 CORS 头 + if resp.Header().Get("Access-Control-Allow-Origin") == "https://evil.com" { + t.Fatal("should not allow unconfigured origin") + } +} diff --git a/server/internal/model/commerce.go b/server/internal/model/commerce.go new file mode 100644 index 0000000..6ca95f7 --- /dev/null +++ b/server/internal/model/commerce.go @@ -0,0 +1,74 @@ +package model + +import "time" + +type BaziChartRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + ReportID string `gorm:"index;size:64" json:"reportId"` + UserID *uint `gorm:"index" json:"userId,omitempty"` + Name string `gorm:"size:64" json:"name"` + BirthDate string `gorm:"size:16" json:"birthDate"` + BirthTime string `gorm:"size:8" json:"birthTime"` + Gender string `gorm:"size:16" json:"gender"` + City string `gorm:"size:64" json:"city"` + Snapshot string `gorm:"type:longtext" json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ReportProduct struct { + ID uint `gorm:"primaryKey" json:"id"` + Code string `gorm:"uniqueIndex;size:40" json:"code"` + Name string `gorm:"size:100" json:"name"` + Description string `gorm:"size:255" json:"description"` + StripeAmount int `json:"stripeAmount"` + StripeCurrency string `gorm:"size:10" json:"stripeCurrency"` + BinanceAmount string `gorm:"size:32" json:"binanceAmount"` + BinanceCurrency string `gorm:"size:10" json:"binanceCurrency"` + Status int `gorm:"default:1" json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ReportEntitlement struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_user_report;index" json:"userId"` + ReportID string `gorm:"uniqueIndex:idx_user_report;size:64;index" json:"reportId"` + OrderID uint `gorm:"uniqueIndex" json:"orderId"` + Status string `gorm:"size:20;default:'active'" json:"status"` + GrantedAt time.Time `json:"grantedAt"` + RevokedAt *time.Time `json:"revokedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ReportSnapshot struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index" json:"userId"` + ReportID string `gorm:"size:64;index" json:"reportId"` + OrderID uint `gorm:"uniqueIndex;index" json:"orderId"` + Snapshot string `gorm:"type:longtext" json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PaymentEvent struct { + ID uint `gorm:"primaryKey" json:"id"` + Provider string `gorm:"uniqueIndex:idx_provider_event;size:30" json:"provider"` + ProviderEventID string `gorm:"uniqueIndex:idx_provider_event;size:128" json:"providerEventId"` + EventType string `gorm:"size:80" json:"eventType"` + OrderNo string `gorm:"size:32;index" json:"orderNo"` + PayloadHash string `gorm:"size:64" json:"payloadHash"` + ProcessedAt time.Time `json:"processedAt"` + CreatedAt time.Time `json:"createdAt"` +} + +type EmailLoginCode struct { + ID uint `gorm:"primaryKey" json:"-"` + Email string `gorm:"size:254;index" json:"-"` + CodeHash string `gorm:"size:64" json:"-"` + Attempts int `gorm:"default:0" json:"-"` + ExpiresAt time.Time `gorm:"index" json:"-"` + UsedAt *time.Time `json:"-"` + CreatedAt time.Time `gorm:"index" json:"-"` +} diff --git a/server/internal/model/order.go b/server/internal/model/order.go index 54fec37..17bc0c7 100644 --- a/server/internal/model/order.go +++ b/server/internal/model/order.go @@ -6,19 +6,28 @@ import ( // Order 订单模型 type Order struct { - ID uint `gorm:"primaryKey" json:"id"` - OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号 - UserID uint `gorm:"index" json:"userId"` - Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员 - ProductID uint `json:"productId"` - ProductName string `gorm:"size:100" json:"productName"` - Amount int `json:"amount"` // 金额(分) - Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款 - PayTime *time.Time `json:"payTime"` - ExpireTime *time.Time `json:"expireTime"` - Remark string `gorm:"size:255" json:"remark"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID uint `gorm:"primaryKey" json:"id"` + OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号 + UserID uint `gorm:"index" json:"userId"` + Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员 + ProductID uint `json:"productId"` + ProductName string `gorm:"size:100" json:"productName"` + Amount int `json:"amount"` // 金额(分) + Currency string `gorm:"size:10;default:'CNY'" json:"currency"` + Provider string `gorm:"size:30;index" json:"provider"` + ProviderAmount string `gorm:"size:32" json:"providerAmount"` + ProviderOrderID string `gorm:"size:128;index" json:"providerOrderId"` + ProviderTransactionID string `gorm:"size:128;index" json:"providerTransactionId"` + CustomerEmail string `gorm:"size:254;index" json:"customerEmail"` + ReportID string `gorm:"size:64;index" json:"reportId"` + AccessCode *string `gorm:"size:20;uniqueIndex" json:"accessCode,omitempty"` + CheckoutURL string `gorm:"size:1024" json:"checkoutUrl"` + Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款 + PayTime *time.Time `json:"payTime"` + ExpireTime *time.Time `json:"expireTime"` + Remark string `gorm:"size:255" json:"remark"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // OrderItem 订单项 @@ -27,6 +36,6 @@ type OrderItem struct { OrderID uint `gorm:"index" json:"orderId"` ProductID uint `json:"productId"` Name string `gorm:"size:100" json:"name"` - Price int `json:"price"` // 单价(分) - Quantity int `json:"quantity"` // 数量 + Price int `json:"price"` // 单价(分) + Quantity int `json:"quantity"` // 数量 } diff --git a/server/internal/service/chart_history.go b/server/internal/service/chart_history.go new file mode 100644 index 0000000..d1ccebe --- /dev/null +++ b/server/internal/service/chart_history.go @@ -0,0 +1,30 @@ +package service + +import ( + "errors" + "strings" + + "github.com/gouki/lunar-server/internal/model" +) + +func (s *CommerceService) CreateChartRecord(record *model.BaziChartRecord) error { + if record.ReportID == "" || record.BirthDate == "" || record.BirthTime == "" || record.Snapshot == "" { + return errors.New("incomplete chart record") + } + return s.db.Create(record).Error +} + +func (s *CommerceService) AdminChartRecords(search string, page, pageSize int) ([]model.BaziChartRecord, int64, error) { + query := s.db.Model(&model.BaziChartRecord{}) + if strings.TrimSpace(search) != "" { + like := "%" + strings.TrimSpace(search) + "%" + query = query.Where("report_id LIKE ? OR name LIKE ? OR city LIKE ?", like, like, like) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var records []model.BaziChartRecord + err := query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error + return records, total, err +} diff --git a/server/internal/service/commerce.go b/server/internal/service/commerce.go new file mode 100644 index 0000000..dca0b01 --- /dev/null +++ b/server/internal/service/commerce.go @@ -0,0 +1,330 @@ +package service + +import ( + "crypto/rand" + "encoding/base32" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strconv" + "strings" + "time" + + "github.com/gouki/lunar-server/internal/config" + "github.com/gouki/lunar-server/internal/model" + "gorm.io/gorm" +) + +type CommerceService struct { + db *gorm.DB +} + +type AdminOrderFilter struct { + Page int + PageSize int + Status string + Provider string + Search string +} + +type OrderStats struct { + TotalOrders int64 `json:"totalOrders"` + PaidOrders int64 `json:"paidOrders"` + Pending int64 `json:"pendingOrders"` + Refunded int64 `json:"refundedOrders"` + RevenueUSD int64 `json:"revenueUsdCents"` +} + +func NewCommerceService() *CommerceService { + return &CommerceService{db: config.GetDB()} +} + +func (s *CommerceService) GetActiveReportProduct() (*model.ReportProduct, error) { + var product model.ReportProduct + if err := s.db.Where("code = ? AND status = 1", "bazi_full_report").First(&product).Error; err != nil { + return nil, err + } + return &product, nil +} + +func (s *CommerceService) CreateReportOrder(userID uint, email, reportID, provider, snapshot string, product *model.ReportProduct) (*model.Order, error) { + if provider != "stripe" && provider != "binance_pay" { + return nil, errors.New("unsupported payment provider") + } + var active model.ReportEntitlement + if err := s.db.Where("user_id = ? AND report_id = ? AND status = ?", userID, reportID, "active").First(&active).Error; err == nil { + return nil, errors.New("report already unlocked") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + orderNo, err := secureOrderNo() + if err != nil { + return nil, err + } + order := &model.Order{ + OrderNo: orderNo, UserID: userID, Type: "bazi_report", ProductID: product.ID, + ProductName: product.Name, Status: "pending", CustomerEmail: strings.ToLower(email), ReportID: reportID, + } + if provider == "stripe" { + order.Amount = product.StripeAmount + order.Currency = product.StripeCurrency + order.ProviderAmount = fmt.Sprintf("%.2f", float64(product.StripeAmount)/100) + } else { + order.Amount = 500 + order.Currency = product.BinanceCurrency + order.ProviderAmount = product.BinanceAmount + } + order.Provider = provider + expires := time.Now().Add(30 * time.Minute) + order.ExpireTime = &expires + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(order).Error; err != nil { + return err + } + record := model.ReportSnapshot{UserID: userID, ReportID: reportID, OrderID: order.ID, Snapshot: snapshot} + if err := tx.Create(&record).Error; err != nil { + return err + } + return tx.Model(&model.BaziChartRecord{}).Where("report_id = ? AND user_id IS NULL", reportID).Update("user_id", userID).Error + }) + if err != nil { + return nil, err + } + return order, nil +} + +func (s *CommerceService) SetCheckout(orderNo, providerOrderID, checkoutURL string) error { + return s.db.Model(&model.Order{}).Where("order_no = ? AND status = ?", orderNo, "pending").Updates(map[string]interface{}{ + "provider_order_id": providerOrderID, + "checkout_url": checkoutURL, + }).Error +} + +func (s *CommerceService) CompletePayment(provider, eventID, eventType, orderNo, providerOrderID, transactionID, paidAmount, currency, payloadHash string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var existing model.PaymentEvent + if err := tx.Where("provider = ? AND provider_event_id = ?", provider, eventID).First(&existing).Error; err == nil { + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + var order model.Order + if err := tx.Where("order_no = ? AND provider = ?", orderNo, provider).First(&order).Error; err != nil { + return err + } + if providerOrderID == "" || providerOrderID != order.ProviderOrderID || !strings.EqualFold(currency, order.Currency) || !amountMatches(order, paidAmount) { + return errors.New("payment amount or provider order mismatch") + } + now := time.Now() + event := model.PaymentEvent{Provider: provider, ProviderEventID: eventID, EventType: eventType, OrderNo: orderNo, PayloadHash: payloadHash, ProcessedAt: now} + if err := tx.Create(&event).Error; err != nil { + return err + } + if order.Status == "paid" { + return nil + } + if order.Status != "pending" { + return fmt.Errorf("order %s cannot transition from %s to paid", orderNo, order.Status) + } + accessCode, err := secureAccessCode() + if err != nil { + return err + } + result := tx.Model(&model.Order{}).Where("id = ? AND status = ?", order.ID, "pending").Updates(map[string]interface{}{ + "status": "paid", "pay_time": now, "provider_transaction_id": transactionID, "access_code": accessCode, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return errors.New("order payment transition conflict") + } + var snapshot model.ReportSnapshot + if err := tx.Where("order_id = ?", order.ID).First(&snapshot).Error; err != nil { + return err + } + premiumSnapshot, err := buildPremiumSnapshot(snapshot.Snapshot) + if err != nil { + return err + } + if err := tx.Model(&snapshot).Update("snapshot", premiumSnapshot).Error; err != nil { + return err + } + entitlement := model.ReportEntitlement{UserID: order.UserID, ReportID: order.ReportID, OrderID: order.ID, Status: "active", GrantedAt: now} + return tx.Where("user_id = ? AND report_id = ?", order.UserID, order.ReportID).FirstOrCreate(&entitlement).Error + }) +} + +func (s *CommerceService) RefundPayment(provider, eventID, eventType, orderNo, transactionID, payloadHash string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&model.PaymentEvent{}).Where("provider = ? AND provider_event_id = ?", provider, eventID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + query := tx.Where("provider = ?", provider) + if orderNo != "" { + query = query.Where("order_no = ?", orderNo) + } else { + query = query.Where("provider_transaction_id = ?", transactionID) + } + var order model.Order + if err := query.First(&order).Error; err != nil { + return err + } + event := model.PaymentEvent{Provider: provider, ProviderEventID: eventID, EventType: eventType, OrderNo: order.OrderNo, PayloadHash: payloadHash, ProcessedAt: time.Now()} + if err := tx.Create(&event).Error; err != nil { + return err + } + if order.Status == "refunded" { + return nil + } + if order.Status != "paid" { + return fmt.Errorf("order %s cannot transition from %s to refunded", order.OrderNo, order.Status) + } + now := time.Now() + if err := tx.Model(&model.Order{}).Where("id = ? AND status = ?", order.ID, "paid").Update("status", "refunded").Error; err != nil { + return err + } + return tx.Model(&model.ReportEntitlement{}).Where("order_id = ? AND status = ?", order.ID, "active").Updates(map[string]interface{}{"status": "revoked", "revoked_at": now}).Error + }) +} + +func (s *CommerceService) MarkOrderStatus(provider, eventID, eventType, orderNo, status, payloadHash string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&model.PaymentEvent{}).Where("provider = ? AND provider_event_id = ?", provider, eventID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + event := model.PaymentEvent{Provider: provider, ProviderEventID: eventID, EventType: eventType, OrderNo: orderNo, PayloadHash: payloadHash, ProcessedAt: time.Now()} + if err := tx.Create(&event).Error; err != nil { + return err + } + return tx.Model(&model.Order{}).Where("order_no = ? AND provider = ? AND status = ?", orderNo, provider, "pending").Update("status", status).Error + }) +} + +func (s *CommerceService) GetUserOrder(userID uint, orderNo string) (*model.Order, error) { + var order model.Order + if err := s.db.Where("user_id = ? AND order_no = ?", userID, orderNo).First(&order).Error; err != nil { + return nil, err + } + return &order, nil +} + +func (s *CommerceService) HasReportAccess(userID uint, reportID string) (bool, error) { + var count int64 + err := s.db.Model(&model.ReportEntitlement{}).Where("user_id = ? AND report_id = ? AND status = ?", userID, reportID, "active").Count(&count).Error + return count > 0, err +} + +func (s *CommerceService) AdminOrders(filter AdminOrderFilter) ([]model.Order, int64, error) { + // PC 商业版后台只显示八字报告订单,隔离小程序许愿等旧业务订单。 + query := s.db.Model(&model.Order{}).Where("type = ?", "bazi_report") + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + if filter.Provider != "" { + query = query.Where("provider = ?", filter.Provider) + } + if filter.Search != "" { + like := "%" + filter.Search + "%" + query = query.Where("order_no LIKE ? OR customer_email LIKE ? OR report_id LIKE ? OR provider_transaction_id LIKE ?", like, like, like, like) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var orders []model.Order + err := query.Order("created_at DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&orders).Error + return orders, total, err +} + +func (s *CommerceService) AdminStats() (OrderStats, error) { + var stats OrderStats + base := s.db.Model(&model.Order{}).Where("type = ?", "bazi_report") + if err := base.Count(&stats.TotalOrders).Error; err != nil { + return stats, err + } + s.db.Model(&model.Order{}).Where("type = ? AND status = ?", "bazi_report", "paid").Count(&stats.PaidOrders) + s.db.Model(&model.Order{}).Where("type = ? AND status = ?", "bazi_report", "pending").Count(&stats.Pending) + s.db.Model(&model.Order{}).Where("type = ? AND status = ?", "bazi_report", "refunded").Count(&stats.Refunded) + s.db.Model(&model.Order{}).Where("type = ? AND status = ? AND currency = ?", "bazi_report", "paid", "USD").Select("COALESCE(SUM(amount), 0)").Scan(&stats.RevenueUSD) + return stats, nil +} + +type PurchasedReport struct { + ReportID string `json:"reportId"` + AccessCode string `json:"accessCode"` + ProductName string `json:"productName"` + PaidAt time.Time `json:"paidAt"` +} + +func (s *CommerceService) GetReportSnapshot(userID uint, accessCode string) (*model.ReportSnapshot, error) { + var order model.Order + if err := s.db.Where("user_id = ? AND access_code = ? AND status = ?", userID, strings.ToUpper(accessCode), "paid").First(&order).Error; err != nil { + return nil, err + } + var entitlement model.ReportEntitlement + if err := s.db.Where("user_id = ? AND order_id = ? AND status = ?", userID, order.ID, "active").First(&entitlement).Error; err != nil { + return nil, err + } + var snapshot model.ReportSnapshot + if err := s.db.Where("user_id = ? AND order_id = ?", userID, order.ID).First(&snapshot).Error; err != nil { + return nil, err + } + return &snapshot, nil +} + +func (s *CommerceService) ListPurchasedReports(userID uint) ([]PurchasedReport, error) { + var orders []model.Order + if err := s.db.Where("user_id = ? AND type = ? AND status = ? AND access_code IS NOT NULL", userID, "bazi_report", "paid").Order("pay_time DESC").Find(&orders).Error; err != nil { + return nil, err + } + reports := make([]PurchasedReport, 0, len(orders)) + for _, order := range orders { + if order.AccessCode == nil || order.PayTime == nil { + continue + } + reports = append(reports, PurchasedReport{ReportID: order.ReportID, AccessCode: *order.AccessCode, ProductName: order.ProductName, PaidAt: *order.PayTime}) + } + return reports, nil +} + +func amountMatches(order model.Order, paidAmount string) bool { + expected := order.ProviderAmount + if order.Provider == "stripe" { + expected = strconv.Itoa(order.Amount) + } + expectedValue, ok := new(big.Rat).SetString(expected) + if !ok { + return false + } + paidValue, ok := new(big.Rat).SetString(paidAmount) + return ok && expectedValue.Cmp(paidValue) == 0 +} + +func secureAccessCode() (string, error) { + bytes := make([]byte, 8) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes) + return "ZM-" + encoded[:4] + "-" + encoded[4:8] + "-" + encoded[8:12], nil +} + +func secureOrderNo() (string, error) { + bytes := make([]byte, 5) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return "R" + time.Now().UTC().Format("20060102150405") + strings.ToUpper(hex.EncodeToString(bytes)), nil +} diff --git a/server/internal/service/email_auth.go b/server/internal/service/email_auth.go new file mode 100644 index 0000000..30d5c1c --- /dev/null +++ b/server/internal/service/email_auth.go @@ -0,0 +1,175 @@ +package service + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/mail" + "strings" + "time" + + "github.com/gouki/lunar-server/internal/config" + "github.com/gouki/lunar-server/internal/model" + "gorm.io/gorm" +) + +var ErrInvalidEmail = errors.New("invalid email") +var ErrLoginCodeRateLimited = errors.New("please wait before requesting another code") +var ErrLoginCodeInvalid = errors.New("invalid or expired login code") + +type EmailAuthService struct { + db *gorm.DB + config *config.Config + client *http.Client +} + +func NewEmailAuthService() *EmailAuthService { + return &EmailAuthService{db: config.GetDB(), config: config.Load(), client: &http.Client{Timeout: 10 * time.Second}} +} + +func NormalizeEmail(value string) (string, error) { + value = strings.ToLower(strings.TrimSpace(value)) + address, err := mail.ParseAddress(value) + if err != nil || address.Address != value || len(value) > 254 { + return "", ErrInvalidEmail + } + return value, nil +} + +func (s *EmailAuthService) RequestCode(email string) error { + email, err := NormalizeEmail(email) + if err != nil { + return err + } + var latest model.EmailLoginCode + if err := s.db.Where("email = ?", email).Order("created_at DESC").First(&latest).Error; err == nil && time.Since(latest.CreatedAt) < time.Minute { + return ErrLoginCodeRateLimited + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + code, err := randomSixDigitCode() + if err != nil { + return err + } + record := model.EmailLoginCode{Email: email, CodeHash: s.hashCode(email, code), ExpiresAt: time.Now().Add(10 * time.Minute)} + if err := s.db.Create(&record).Error; err != nil { + return err + } + if err := s.sendCode(email, code); err != nil { + now := time.Now() + s.db.Model(&record).Update("used_at", &now) + return err + } + return nil +} + +func (s *EmailAuthService) VerifyCode(email, code string) (*model.User, string, error) { + email, err := NormalizeEmail(email) + if err != nil || len(code) != 6 { + return nil, "", ErrLoginCodeInvalid + } + var record model.EmailLoginCode + if err := s.db.Where("email = ? AND used_at IS NULL AND expires_at > ?", email, time.Now()).Order("created_at DESC").First(&record).Error; err != nil { + return nil, "", ErrLoginCodeInvalid + } + if record.Attempts >= 5 { + return nil, "", ErrLoginCodeInvalid + } + if !hmac.Equal([]byte(record.CodeHash), []byte(s.hashCode(email, code))) { + s.db.Model(&record).UpdateColumn("attempts", gorm.Expr("attempts + 1")) + return nil, "", ErrLoginCodeInvalid + } + + var user model.User + err = s.db.Transaction(func(tx *gorm.DB) error { + now := time.Now() + result := tx.Model(&model.EmailLoginCode{}).Where("id = ? AND used_at IS NULL", record.ID).Update("used_at", &now) + if result.Error != nil || result.RowsAffected != 1 { + return ErrLoginCodeInvalid + } + if err := tx.Where("email = ?", email).First(&user).Error; err == nil { + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + identity := sha256.Sum256([]byte(email)) + user = model.User{OpenID: "email:" + hex.EncodeToString(identity[:])[:32], Email: email, Nickname: strings.Split(email, "@")[0], Status: 1} + return tx.Create(&user).Error + }) + if err != nil { + return nil, "", err + } + if user.Status != 1 { + return nil, "", errors.New("account disabled") + } + token, err := NewUserService().GenerateToken(user.ID, s.config.JWT.Secret) + if err != nil { + return nil, "", err + } + return &user, token, nil +} + +func (s *EmailAuthService) hashCode(email, code string) string { + mac := hmac.New(sha256.New, []byte(s.config.JWT.Secret)) + mac.Write([]byte(email + "|" + code)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (s *EmailAuthService) sendCode(email, code string) error { + if s.config.Postmark.ServerToken == "" || s.config.Postmark.FromEmail == "" { + // 开发模式:Postmark 未配置时,验证码打印到服务端控制台,方便本地联调 + if s.config.Server.Env != "production" { + log.Printf("[DEV-EMAIL] 邮箱登录验证码: email=%s code=%s(生产环境将自动通过 Postmark 发送)", email, code) + return nil + } + return errors.New("email service is not configured") + } + payload := map[string]string{ + "From": s.config.Postmark.FromEmail, + "To": email, + "Subject": "知命登录验证码", + "TextBody": fmt.Sprintf("你的登录验证码是 %s,10 分钟内有效。若非本人操作,请忽略此邮件。", code), + "HtmlBody": fmt.Sprintf("

知命登录验证码

你的验证码是:

%s

10 分钟内有效。若非本人操作,请忽略此邮件。

", code), + "MessageStream": "outbound", + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, strings.TrimRight(s.config.Postmark.APIBaseURL, "/")+"/email", strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Postmark-Server-Token", s.config.Postmark.ServerToken) + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("postmark send failed: status=%d body=%s", resp.StatusCode, safeProviderError(responseBody)) + } + return nil +} + +func randomSixDigitCode() (string, error) { + bytes := make([]byte, 4) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + value := (uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3])) % 1000000 + return fmt.Sprintf("%06d", value), nil +} diff --git a/server/internal/service/payment_provider.go b/server/internal/service/payment_provider.go new file mode 100644 index 0000000..77a587c --- /dev/null +++ b/server/internal/service/payment_provider.go @@ -0,0 +1,336 @@ +package service + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gouki/lunar-server/internal/config" + "github.com/gouki/lunar-server/internal/model" +) + +type CheckoutResult struct { + ProviderOrderID string + CheckoutURL string +} + +type PaymentWebhook struct { + EventID string + EventType string + OrderNo string + ProviderOrderID string + TransactionID string + Amount string + Currency string + Status string + PayloadHash string +} + +type StripeProvider struct { + config config.StripeConfig + client *http.Client +} + +type BinancePayProvider struct { + config config.BinancePayConfig + client *http.Client +} + +func NewStripeProvider(cfg config.StripeConfig) *StripeProvider { + return &StripeProvider{config: cfg, client: &http.Client{Timeout: 15 * time.Second}} +} + +func NewBinancePayProvider(cfg config.BinancePayConfig) *BinancePayProvider { + return &BinancePayProvider{config: cfg, client: &http.Client{Timeout: 15 * time.Second}} +} + +func (p *StripeProvider) CreateCheckout(ctx context.Context, order *model.Order, webAppURL string) (*CheckoutResult, error) { + if p.config.SecretKey == "" { + return nil, errors.New("stripe is not configured") + } + values := url.Values{} + values.Set("mode", "payment") + values.Set("success_url", strings.TrimRight(webAppURL, "/")+"/?payment=success&order="+url.QueryEscape(order.OrderNo)) + values.Set("cancel_url", strings.TrimRight(webAppURL, "/")+"/?payment=cancelled&order="+url.QueryEscape(order.OrderNo)) + values.Set("customer_email", order.CustomerEmail) + values.Set("line_items[0][quantity]", "1") + values.Set("line_items[0][price_data][currency]", strings.ToLower(order.Currency)) + values.Set("line_items[0][price_data][unit_amount]", strconv.Itoa(order.Amount)) + values.Set("line_items[0][price_data][product_data][name]", order.ProductName) + values.Set("line_items[0][price_data][product_data][description]", "一次购买,永久解锁完整报告") + values.Set("metadata[order_no]", order.OrderNo) + values.Set("metadata[report_id]", order.ReportID) + values.Set("metadata[user_id]", strconv.FormatUint(uint64(order.UserID), 10)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.config.APIBaseURL, "/")+"/v1/checkout/sessions", strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+p.config.SecretKey) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Idempotency-Key", "checkout-"+order.OrderNo) + resp, err := p.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("stripe create checkout failed: status=%d body=%s", resp.StatusCode, safeProviderError(body)) + } + var payload struct { + ID string `json:"id"` + URL string `json:"url"` + } + if err := json.Unmarshal(body, &payload); err != nil || payload.ID == "" || payload.URL == "" { + return nil, errors.New("stripe returned an invalid checkout response") + } + return &CheckoutResult{ProviderOrderID: payload.ID, CheckoutURL: payload.URL}, nil +} + +func (p *StripeProvider) ParseWebhook(raw []byte, signature string, now time.Time) (*PaymentWebhook, error) { + if p.config.WebhookSecret == "" { + return nil, errors.New("stripe webhook is not configured") + } + parts := strings.Split(signature, ",") + var timestamp int64 + var signatures []string + for _, part := range parts { + pair := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(pair) != 2 { + continue + } + if pair[0] == "t" { + timestamp, _ = strconv.ParseInt(pair[1], 10, 64) + } + if pair[0] == "v1" { + signatures = append(signatures, pair[1]) + } + } + if timestamp == 0 || len(signatures) == 0 || now.Sub(time.Unix(timestamp, 0)) > 5*time.Minute || time.Unix(timestamp, 0).Sub(now) > 5*time.Minute { + return nil, errors.New("invalid stripe webhook timestamp") + } + mac := hmac.New(sha256.New, []byte(p.config.WebhookSecret)) + mac.Write([]byte(strconv.FormatInt(timestamp, 10))) + mac.Write([]byte(".")) + mac.Write(raw) + expected := hex.EncodeToString(mac.Sum(nil)) + valid := false + for _, candidate := range signatures { + if hmac.Equal([]byte(expected), []byte(candidate)) { + valid = true + break + } + } + if !valid { + return nil, errors.New("invalid stripe webhook signature") + } + var event struct { + ID string `json:"id"` + Type string `json:"type"` + Data struct { + Object struct { + ID string `json:"id"` + PaymentIntent string `json:"payment_intent"` + PaymentStatus string `json:"payment_status"` + AmountTotal int64 `json:"amount_total"` + Currency string `json:"currency"` + Metadata map[string]string `json:"metadata"` + } `json:"object"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &event); err != nil || event.ID == "" { + return nil, errors.New("invalid stripe webhook body") + } + status := "ignored" + switch event.Type { + case "checkout.session.completed": + if event.Data.Object.PaymentStatus == "paid" { + status = "paid" + } + case "checkout.session.async_payment_succeeded": + status = "paid" + case "checkout.session.expired", "checkout.session.async_payment_failed": + status = "cancelled" + case "charge.refunded": + status = "refunded" + } + return &PaymentWebhook{ + EventID: event.ID, EventType: event.Type, OrderNo: event.Data.Object.Metadata["order_no"], + ProviderOrderID: event.Data.Object.ID, TransactionID: event.Data.Object.PaymentIntent, + Amount: strconv.FormatInt(event.Data.Object.AmountTotal, 10), Currency: strings.ToUpper(event.Data.Object.Currency), + Status: status, PayloadHash: hashPayload(raw), + }, nil +} + +func (p *BinancePayProvider) CreateCheckout(ctx context.Context, order *model.Order, webAppURL, publicBaseURL string) (*CheckoutResult, error) { + if p.config.APIKey == "" || p.config.SecretKey == "" { + return nil, errors.New("binance pay is not configured") + } + payload := map[string]interface{}{ + "env": map[string]string{"terminalType": "WEB"}, + "merchantTradeNo": order.OrderNo, + "orderAmount": order.ProviderAmount, + "currency": order.Currency, + "goods": map[string]string{"goodsType": "02", "goodsCategory": "D000", "referenceGoodsId": order.ReportID, "goodsName": order.ProductName}, + "returnUrl": strings.TrimRight(webAppURL, "/") + "/?payment=success&order=" + url.QueryEscape(order.OrderNo), + "cancelUrl": strings.TrimRight(webAppURL, "/") + "/?payment=cancelled&order=" + url.QueryEscape(order.OrderNo), + "webhookUrl": strings.TrimRight(publicBaseURL, "/") + "/api/commerce/webhooks/binance-pay", + "orderExpireTime": time.Now().Add(30 * time.Minute).UnixMilli(), + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + nonceBytes := make([]byte, 16) + if _, err := rand.Read(nonceBytes); err != nil { + return nil, err + } + nonce := hex.EncodeToString(nonceBytes) + mac := hmac.New(sha512.New, []byte(p.config.SecretKey)) + mac.Write([]byte(timestamp + "\n" + nonce + "\n" + string(body) + "\n")) + signature := strings.ToUpper(hex.EncodeToString(mac.Sum(nil))) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.config.APIBaseURL, "/")+"/binancepay/openapi/v2/order", strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("BinancePay-Timestamp", timestamp) + req.Header.Set("BinancePay-Nonce", nonce) + req.Header.Set("BinancePay-Certificate-SN", p.config.APIKey) + req.Header.Set("BinancePay-Signature", signature) + resp, err := p.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("binance pay create order failed: status=%d body=%s", resp.StatusCode, safeProviderError(responseBody)) + } + var response struct { + Status string `json:"status"` + Code string `json:"code"` + Data struct { + PrepayID string `json:"prepayId"` + UniversalURL string `json:"universalUrl"` + CheckoutURL string `json:"checkoutUrl"` + } `json:"data"` + } + if err := json.Unmarshal(responseBody, &response); err != nil || response.Status != "SUCCESS" || response.Data.PrepayID == "" { + return nil, fmt.Errorf("binance pay rejected order: code=%s", response.Code) + } + checkoutURL := response.Data.CheckoutURL + if checkoutURL == "" { + checkoutURL = response.Data.UniversalURL + } + if checkoutURL == "" { + return nil, errors.New("binance pay returned no checkout URL") + } + return &CheckoutResult{ProviderOrderID: response.Data.PrepayID, CheckoutURL: checkoutURL}, nil +} + +func (p *BinancePayProvider) ParseWebhook(raw []byte, timestamp, nonce, signature string) (*PaymentWebhook, error) { + if p.config.WebhookPublicKey == "" { + return nil, errors.New("binance pay webhook public key is not configured") + } + block, _ := pem.Decode([]byte(strings.ReplaceAll(p.config.WebhookPublicKey, "\\n", "\n"))) + if block == nil { + return nil, errors.New("invalid binance pay webhook public key") + } + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + if certificate, certErr := x509.ParseCertificate(block.Bytes); certErr == nil { + parsed = certificate.PublicKey + } else if rsaKey, rsaErr := x509.ParsePKCS1PublicKey(block.Bytes); rsaErr == nil { + parsed = rsaKey + } else { + return nil, err + } + } + publicKey, ok := parsed.(*rsa.PublicKey) + if !ok { + return nil, errors.New("binance pay webhook key is not RSA") + } + decodedSignature, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return nil, errors.New("invalid binance pay webhook signature encoding") + } + payload := []byte(timestamp + "\n" + nonce + "\n" + string(raw) + "\n") + digest := sha256.Sum256(payload) + if err := rsa.VerifyPKCS1v15(publicKey, cryptoHashSHA256, digest[:], decodedSignature); err != nil { + return nil, errors.New("invalid binance pay webhook signature") + } + var envelope struct { + BizType string `json:"bizType"` + BizIDStr string `json:"bizIdStr"` + BizStatus string `json:"bizStatus"` + Data string `json:"data"` + } + if err := json.Unmarshal(raw, &envelope); err != nil || envelope.BizIDStr == "" { + return nil, errors.New("invalid binance pay webhook body") + } + var data struct { + MerchantTradeNo string `json:"merchantTradeNo"` + TransactionID string `json:"transactionId"` + TotalFee json.Number `json:"totalFee"` + Currency string `json:"currency"` + } + decoder := json.NewDecoder(strings.NewReader(envelope.Data)) + decoder.UseNumber() + if err := decoder.Decode(&data); err != nil { + return nil, errors.New("invalid binance pay webhook data") + } + status := "ignored" + switch envelope.BizStatus { + case "PAY_SUCCESS": + status = "paid" + case "PAY_CLOSED": + status = "cancelled" + case "REFUND_SUCCESS": + status = "refunded" + } + return &PaymentWebhook{ + EventID: envelope.BizIDStr + ":" + envelope.BizStatus, EventType: envelope.BizStatus, + OrderNo: data.MerchantTradeNo, ProviderOrderID: envelope.BizIDStr, TransactionID: data.TransactionID, + Amount: data.TotalFee.String(), Currency: strings.ToUpper(data.Currency), Status: status, PayloadHash: hashPayload(raw), + }, nil +} + +const cryptoHashSHA256 = 5 + +func hashPayload(raw []byte) string { + digest := sha256.Sum256(raw) + return hex.EncodeToString(digest[:]) +} + +func safeProviderError(body []byte) string { + text := strings.TrimSpace(string(body)) + if len(text) > 500 { + text = text[:500] + } + return text +} diff --git a/server/internal/service/payment_provider_test.go b/server/internal/service/payment_provider_test.go new file mode 100644 index 0000000..ed74894 --- /dev/null +++ b/server/internal/service/payment_provider_test.go @@ -0,0 +1,97 @@ +package service + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/gouki/lunar-server/internal/config" + "github.com/gouki/lunar-server/internal/model" +) + +func TestStripeCreateCheckout(t *testing.T) { + var received url.Values + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer sk_test" { + t.Fatalf("missing Stripe authorization") + } + if request.Header.Get("Idempotency-Key") != "checkout-R123" { + t.Fatalf("missing idempotency key") + } + body, _ := io.ReadAll(request.Body) + received, _ = url.ParseQuery(string(body)) + response.Header().Set("Content-Type", "application/json") + fmt.Fprint(response, `{"id":"cs_test_123","url":"https://checkout.stripe.test/session"}`) + })) + defer server.Close() + + provider := NewStripeProvider(config.StripeConfig{SecretKey: "sk_test", APIBaseURL: server.URL}) + order := &model.Order{OrderNo: "R123", UserID: 9, Amount: 499, Currency: "USD", CustomerEmail: "buyer@example.com", ProductName: "完整报告", ReportID: "ZM-ABCDEFG"} + result, err := provider.CreateCheckout(context.Background(), order, "https://example.test") + if err != nil { + t.Fatal(err) + } + if result.ProviderOrderID != "cs_test_123" || result.CheckoutURL == "" { + t.Fatalf("unexpected checkout result: %#v", result) + } + if received.Get("metadata[order_no]") != order.OrderNo || received.Get("line_items[0][price_data][unit_amount]") != "499" { + t.Fatalf("unexpected Stripe form: %#v", received) + } +} + +func TestStripeWebhookSignature(t *testing.T) { + now := time.Unix(1760000000, 0) + raw := []byte(`{"id":"evt_123","type":"checkout.session.completed","data":{"object":{"id":"cs_test_123","payment_intent":"pi_123","payment_status":"paid","amount_total":499,"currency":"usd","metadata":{"order_no":"R123"}}}}`) + mac := hmac.New(sha256.New, []byte("whsec_test")) + mac.Write([]byte(fmt.Sprintf("%d.%s", now.Unix(), raw))) + signature := fmt.Sprintf("t=%d,v1=%s", now.Unix(), hex.EncodeToString(mac.Sum(nil))) + provider := NewStripeProvider(config.StripeConfig{WebhookSecret: "whsec_test"}) + event, err := provider.ParseWebhook(raw, signature, now) + if err != nil { + t.Fatal(err) + } + if event.Status != "paid" || event.OrderNo != "R123" || event.TransactionID != "pi_123" { + t.Fatalf("unexpected event: %#v", event) + } + if _, err := provider.ParseWebhook(raw, signature+"bad", now); err == nil { + t.Fatal("expected invalid signature to fail") + } +} + +func TestBinancePayCreateCheckoutSignature(t *testing.T) { + secret := "binance-secret" + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + timestamp := request.Header.Get("BinancePay-Timestamp") + nonce := request.Header.Get("BinancePay-Nonce") + mac := hmac.New(sha512.New, []byte(secret)) + mac.Write([]byte(timestamp + "\n" + nonce + "\n" + string(body) + "\n")) + expected := strings.ToUpper(hex.EncodeToString(mac.Sum(nil))) + if !hmac.Equal([]byte(expected), []byte(request.Header.Get("BinancePay-Signature"))) { + t.Fatal("invalid Binance Pay signature") + } + response.Header().Set("Content-Type", "application/json") + fmt.Fprint(response, `{"status":"SUCCESS","code":"000000","data":{"prepayId":"123456","universalUrl":"https://app.binance.test/pay"}}`) + })) + defer server.Close() + + provider := NewBinancePayProvider(config.BinancePayConfig{APIKey: "api-key", SecretKey: secret, APIBaseURL: server.URL}) + order := &model.Order{OrderNo: "R123", ProviderAmount: "5.00", Currency: "USDT", ProductName: "完整报告", ReportID: "ZM-ABCDEFG"} + result, err := provider.CreateCheckout(context.Background(), order, "https://example.test", "https://api.example.test") + if err != nil { + t.Fatal(err) + } + if result.ProviderOrderID != "123456" || result.CheckoutURL == "" { + t.Fatalf("unexpected checkout result: %#v", result) + } +} diff --git a/server/internal/service/premium_report.go b/server/internal/service/premium_report.go new file mode 100644 index 0000000..b5328b3 --- /dev/null +++ b/server/internal/service/premium_report.go @@ -0,0 +1,123 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" +) + +type premiumTrait struct { + Gift string + Caution string + Field string + Rhythm string +} + +type premiumSnapshot struct { + ID string `json:"id"` + Result struct { + EightChar struct { + DayMaster string `json:"dayMaster"` + DayMasterElement string `json:"dayMasterElement"` + } `json:"eightChar"` + DecadeFortunes []struct { + Ganzhi string `json:"ganzhi"` + StartAge int `json:"startAge"` + } `json:"decadeFortunes"` + } `json:"result"` + Elements struct { + Dominant string `json:"dominant"` + Weakest string `json:"weakest"` + } `json:"elements"` + Shensha []struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"shensha"` + Sections []premiumSection `json:"sections"` +} + +type premiumSection struct { + Index string `json:"index"` + Title string `json:"title"` + Kicker string `json:"kicker"` + Summary string `json:"summary"` + Details []string `json:"details"` +} + +var premiumTraits = map[string]premiumTrait{ + "木": {Gift: "成长力、规划感与同理心", Caution: "容易把长期目标变成眼前压力", Field: "教育、内容、设计与长期经营", Rhythm: "先建立根基,再持续放大优势"}, + "火": {Gift: "表达力、行动力与感染力", Caution: "节奏过快时容易透支专注", Field: "传播、品牌、销售与创新项目", Rhythm: "抓住窗口,但为决策保留冷静期"}, + "土": {Gift: "稳定性、承诺感与资源整合力", Caution: "求稳过度时会错过变化", Field: "管理、运营、资产与服务业", Rhythm: "稳中求变,用小步试错替代停滞"}, + "金": {Gift: "判断力、边界感与执行标准", Caution: "标准过高时容易显得紧绷", Field: "技术、金融、法律与精细管理", Rhythm: "先定规则,再给关系留出弹性"}, + "水": {Gift: "洞察力、适应力与信息敏感度", Caution: "选择过多时容易分散心力", Field: "研究、咨询、贸易与流动型工作", Rhythm: "聚焦一个主航道,让变化服务目标"}, +} + +var supportingElements = map[string]string{"木": "水", "火": "木", "土": "火", "金": "土", "水": "金"} + +func buildPremiumSnapshot(raw string) (string, error) { + var snapshot premiumSnapshot + if err := json.Unmarshal([]byte(raw), &snapshot); err != nil { + return "", fmt.Errorf("invalid report snapshot: %w", err) + } + if snapshot.ID == "" || snapshot.Result.EightChar.DayMaster == "" || snapshot.Elements.Dominant == "" { + return "", errors.New("report snapshot is missing chart data") + } + + dominant := premiumTraits[snapshot.Elements.Dominant] + dayMaster := premiumTraits[snapshot.Result.EightChar.DayMasterElement] + if dayMaster.Gift == "" { + dayMaster = dominant + } + helpful := supportingElements[snapshot.Result.EightChar.DayMasterElement] + if helpful == "" { + helpful = snapshot.Elements.Weakest + } + positiveStars := make([]string, 0, 3) + cautionStars := make([]string, 0, 2) + for _, star := range snapshot.Shensha { + if star.Type == "吉" && len(positiveStars) < 3 { + positiveStars = append(positiveStars, star.Name) + } + if star.Type == "凶" && len(cautionStars) < 2 { + cautionStars = append(cautionStars, star.Name) + } + } + starText := "命局本身的稳定结构" + if len(positiveStars) > 0 { + starText = joinChinese(positiveStars) + } + cautionText := "节奏失衡" + if len(cautionStars) > 0 { + cautionText = joinChinese(cautionStars) + } + firstFortune := "人生节奏呈阶段性变化。" + if len(snapshot.Result.DecadeFortunes) > 0 { + fortune := snapshot.Result.DecadeFortunes[0] + firstFortune = fmt.Sprintf("首步大运为%s,约从%d岁进入阶段转换。", fortune.Ganzhi, fortune.StartAge) + } + + snapshot.Sections = []premiumSection{ + {Index: "壹", Title: "性格底色", Kicker: snapshot.Result.EightChar.DayMaster + " · 内在驱动力", Summary: fmt.Sprintf("你的日主为%s,核心优势集中在%s。", snapshot.Result.EightChar.DayMaster, dayMaster.Gift), Details: []string{fmt.Sprintf("五行以%s较突出,做事更重视%s。", snapshot.Elements.Dominant, dominant.Gift), fmt.Sprintf("需要留意:%s。", dayMaster.Caution), fmt.Sprintf("真正适合你的成长方式是:%s。", dayMaster.Rhythm)}}, + {Index: "贰", Title: "事业天赋", Kicker: "能力结构 · 职场定位", Summary: fmt.Sprintf("你的优势更容易在%s中形成复利。", dominant.Field), Details: []string{fmt.Sprintf("适合承担需要%s的角色。", dayMaster.Gift), fmt.Sprintf("命局中的%s,提示你应主动经营专业信用与协作关系。", starText), "比起频繁换方向,更适合建立可重复的方法与个人标签。"}}, + {Index: "叁", Title: "财富节奏", Kicker: "积累方式 · 风险边界", Summary: fmt.Sprintf("财富策略宜围绕“%s”展开。", dominant.Rhythm), Details: []string{fmt.Sprintf("优先把%s转化成可计价的产品或服务。", dayMaster.Gift), fmt.Sprintf("遇到%s对应的冲动时,应延迟重大财务决定。", cautionText), "收入结构宜同时保留稳定现金流与小比例成长投入。"}}, + {Index: "肆", Title: "关系模式", Kicker: "亲密关系 · 沟通课题", Summary: fmt.Sprintf("关系中的吸引力来自%s,长久则依赖清晰表达。", dayMaster.Gift), Details: []string{"你更需要能理解你节奏、也尊重边界的伴侣。", "情绪紧张时,先描述事实与需要,避免用结论代替沟通。", "重要关系中要刻意安排稳定相处,而不是只依赖临时热情。"}}, + {Index: "伍", Title: "身心能量", Kicker: "五行平衡 · 日常建议", Summary: fmt.Sprintf("%s相对偏弱,日常可借%s的意象建立平衡感。", snapshot.Elements.Weakest, helpful), Details: []string{"把作息、运动和独处时间当作长期资产。", "高压期减少多线程任务,让注意力回到一个明确目标。", "所有健康问题应以正规医疗意见为准,本报告不替代诊断。"}}, + {Index: "陆", Title: "大运路线", Kicker: "阶段重点 · 行动窗口", Summary: firstFortune, Details: []string{"大运用于观察阶段主题,不代表确定事件。", fmt.Sprintf("顺势的关键是持续放大%s,并管理%s。", dayMaster.Gift, dayMaster.Caution), "完整报告保留十步大运时间轴,方便长期回看。"}}, + } + encoded, err := json.Marshal(snapshot) + if err != nil { + return "", fmt.Errorf("encode premium snapshot: %w", err) + } + return string(encoded), nil +} + +func joinChinese(values []string) string { + result := "" + for index, value := range values { + if index > 0 { + result += "、" + } + result += value + } + return result +} diff --git a/server/internal/service/user.go b/server/internal/service/user.go index e7a0ad7..24f1bfe 100644 --- a/server/internal/service/user.go +++ b/server/internal/service/user.go @@ -116,7 +116,7 @@ func (s *UserService) GenerateToken(userID uint, secret string) (string, error) func (s *UserService) ParseToken(tokenString, secret string) (uint, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { return []byte(secret), nil - }) + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) if err != nil { return 0, err @@ -156,7 +156,7 @@ func (s *UserService) GenerateAdminToken(secret string) (string, error) { func (s *UserService) IsAdminToken(tokenString, secret string) bool { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { return []byte(secret), nil - }) + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) if err != nil { return false } diff --git a/server/migrations/002_commerce.sql b/server/migrations/002_commerce.sql new file mode 100644 index 0000000..6eca237 --- /dev/null +++ b/server/migrations/002_commerce.sql @@ -0,0 +1,94 @@ +ALTER TABLE orders + ADD COLUMN currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + ADD COLUMN provider VARCHAR(30) NOT NULL DEFAULT '', + ADD COLUMN provider_amount VARCHAR(32) NOT NULL DEFAULT '', + ADD COLUMN provider_order_id VARCHAR(128) NOT NULL DEFAULT '', + ADD COLUMN provider_transaction_id VARCHAR(128) NOT NULL DEFAULT '', + ADD COLUMN customer_email VARCHAR(254) NOT NULL DEFAULT '', + ADD COLUMN report_id VARCHAR(64) NOT NULL DEFAULT '', + ADD COLUMN access_code VARCHAR(20) NULL, + ADD COLUMN checkout_url VARCHAR(1024) NOT NULL DEFAULT ''; + +CREATE INDEX idx_orders_provider ON orders(provider); +CREATE INDEX idx_orders_provider_order_id ON orders(provider_order_id); +CREATE INDEX idx_orders_provider_transaction_id ON orders(provider_transaction_id); +CREATE INDEX idx_orders_customer_email ON orders(customer_email); +CREATE INDEX idx_orders_report_id ON orders(report_id); +CREATE UNIQUE INDEX idx_orders_access_code ON orders(access_code); + +CREATE TABLE report_products ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(40) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + stripe_amount INT NOT NULL, + stripe_currency VARCHAR(10) NOT NULL, + binance_amount VARCHAR(32) NOT NULL, + binance_currency VARCHAR(10) NOT NULL, + status TINYINT NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE report_entitlements ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + report_id VARCHAR(64) NOT NULL, + order_id BIGINT UNSIGNED NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'active', + granted_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY idx_user_report (user_id, report_id), + INDEX idx_report_entitlements_user_id (user_id), + INDEX idx_report_entitlements_report_id (report_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE report_snapshots ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + report_id VARCHAR(64) NOT NULL, + order_id BIGINT UNSIGNED NOT NULL UNIQUE, + snapshot LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_report_snapshots_user_id (user_id), + INDEX idx_report_snapshots_report_id (report_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE payment_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + provider VARCHAR(30) NOT NULL, + provider_event_id VARCHAR(128) NOT NULL, + event_type VARCHAR(80) NOT NULL, + order_no VARCHAR(32) NOT NULL, + payload_hash VARCHAR(64) NOT NULL, + processed_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY idx_provider_event (provider, provider_event_id), + INDEX idx_payment_events_order_no (order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE email_login_codes ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(254) NOT NULL, + code_hash VARCHAR(64) NOT NULL, + attempts INT NOT NULL DEFAULT 0, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_email_login_codes_email (email), + INDEX idx_email_login_codes_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO report_products (code, name, description, stripe_amount, stripe_currency, binance_amount, binance_currency, status) +VALUES ('bazi_full_report', '八字完整精批报告', '六大主题与十步大运完整解读', 499, 'USD', '5.00', 'USDT', 1) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + description = VALUES(description), + stripe_amount = VALUES(stripe_amount), + stripe_currency = VALUES(stripe_currency), + binance_amount = VALUES(binance_amount), + binance_currency = VALUES(binance_currency), + status = VALUES(status); diff --git a/server/web/index.html b/server/web/index.html index 26599b0..7c993f2 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -3,7 +3,7 @@ - {{ .title }} - 祈福小助手管理后台 + {{ .title }} - 知命 PC 商业版后台 @@ -30,6 +30,7 @@ dashboard: () => import('/static/js/pages/Dashboard.js'), users: () => import('/static/js/pages/Users.js'), orders: () => import('/static/js/pages/Orders.js'), + charts: () => import('/static/js/pages/Charts.js'), wishes: () => import('/static/js/pages/Wishes.js'), wiki: () => import('/static/js/pages/Wiki.js'), settings: () => import('/static/js/pages/Settings.js'), diff --git a/server/web/static/js/pages/AdminHeader.js b/server/web/static/js/pages/AdminHeader.js new file mode 100644 index 0000000..9a0398b --- /dev/null +++ b/server/web/static/js/pages/AdminHeader.js @@ -0,0 +1,32 @@ +const links = [ + ['dashboard', '/admin', '仪表盘'], + ['users', '/admin/users', '用户管理'], + ['orders', '/admin/orders', 'PC 报告订单'], + ['charts', '/admin/charts', '排盘记录'], + ['wishes', '/admin/wishes', '许愿管理'], + ['wiki', '/admin/wiki', '百科管理'], + ['settings', '/admin/settings', '系统设置'], +]; + +export function adminHeader(active) { + return ` + + `; +} diff --git a/server/web/static/js/pages/Charts.js b/server/web/static/js/pages/Charts.js new file mode 100644 index 0000000..a050fe9 --- /dev/null +++ b/server/web/static/js/pages/Charts.js @@ -0,0 +1,57 @@ +import { onMounted, ref } from 'vue'; +import { adminHeader } from './AdminHeader.js'; + +export default { + setup() { + const records = ref([]); + const total = ref(0); + const loading = ref(true); + const error = ref(''); + const search = ref(''); + + const load = async () => { + loading.value = true; + error.value = ''; + try { + const params = new URLSearchParams({ page: '1', pageSize: '100' }); + if (search.value) params.set('search', search.value); + const response = await fetch('/admin/api/charts?' + params.toString(), { credentials: 'same-origin' }); + if (!response.ok) throw new Error('加载排盘记录失败'); + const payload = await response.json(); + records.value = payload.data.list || []; + total.value = payload.data.total || 0; + } catch (reason) { + error.value = reason instanceof Error ? reason.message : '加载排盘记录失败'; + } finally { + loading.value = false; + } + }; + + onMounted(load); + return { records, total, loading, error, search, load }; + }, + template: ` +
+ ${adminHeader('charts')} +
+
+
+

排盘记录

共 {{ total }} 次排盘,保存免费排盘快照,付费报告另行关联

+
+
+
{{ error }}
+
+ + + + + + + +
报告编号称呼出生信息城市记录时间
正在加载...
暂无排盘记录
{{ record.reportId }}{{ record.name || '-' }}{{ record.birthDate }} {{ record.birthTime }} / {{ record.gender }}{{ record.city }}{{ new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) }}
+
+
+
+
+ `, +}; diff --git a/server/web/static/js/pages/Dashboard.js b/server/web/static/js/pages/Dashboard.js index d4a9668..9201962 100644 --- a/server/web/static/js/pages/Dashboard.js +++ b/server/web/static/js/pages/Dashboard.js @@ -1,38 +1,10 @@ +import { adminHeader } from './AdminHeader.js'; + // Dashboard 页面组件 export default { template: `
- + ${adminHeader('dashboard')}
diff --git a/server/web/static/js/pages/Login.js b/server/web/static/js/pages/Login.js index 6f0629c..f649226 100644 --- a/server/web/static/js/pages/Login.js +++ b/server/web/static/js/pages/Login.js @@ -4,7 +4,7 @@ export default {
-

祈福小助手

+

知命 PC 商业版后台

管理后台登录

v2026.08.12-login

diff --git a/server/web/static/js/pages/Orders.js b/server/web/static/js/pages/Orders.js index 2c0e170..f2355d3 100644 --- a/server/web/static/js/pages/Orders.js +++ b/server/web/static/js/pages/Orders.js @@ -1,74 +1,94 @@ -// Orders 页面组件 +import { onMounted, ref } from 'vue'; +import { adminHeader } from './AdminHeader.js'; + export default { + setup() { + const orders = ref([]); + const stats = ref({ totalOrders: 0, paidOrders: 0, pendingOrders: 0, refundedOrders: 0, revenueUsdCents: 0 }); + const total = ref(0); + const loading = ref(true); + const error = ref(''); + const filters = ref({ status: '', provider: '', search: '' }); + + const load = async () => { + loading.value = true; + error.value = ''; + const params = new URLSearchParams({ page: '1', pageSize: '100' }); + Object.entries(filters.value).forEach(([key, value]) => value && params.set(key, value)); + try { + const [orderResponse, statsResponse] = await Promise.all([ + fetch('/admin/api/orders?' + params.toString(), { credentials: 'same-origin' }), + fetch('/admin/api/orders/stats', { credentials: 'same-origin' }), + ]); + if (!orderResponse.ok || !statsResponse.ok) throw new Error('加载订单失败'); + const orderPayload = await orderResponse.json(); + const statsPayload = await statsResponse.json(); + orders.value = orderPayload.data.list || []; + total.value = orderPayload.data.total || 0; + stats.value = statsPayload.data; + } catch (reason) { + error.value = reason instanceof Error ? reason.message : '加载订单失败'; + } finally { + loading.value = false; + } + }; + + const money = order => order.currency === 'USD' + ? '$' + (order.amount / 100).toFixed(2) + : order.providerAmount + ' ' + order.currency; + const dateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; + const statusText = status => ({ pending: '待支付', paid: '已支付', cancelled: '已关闭', refunded: '已退款' }[status] || status); + const statusClass = status => ({ + paid: 'bg-green-100 text-green-800', pending: 'bg-amber-100 text-amber-800', + cancelled: 'bg-gray-100 text-gray-700', refunded: 'bg-blue-100 text-blue-800', + }[status] || 'bg-gray-100 text-gray-700'); + const providerText = provider => ({ stripe: 'Stripe', binance_pay: 'Binance Pay' }[provider] || provider || '-'); + + onMounted(load); + return { orders, stats, total, loading, error, filters, load, money, dateTime, statusText, statusClass, providerText }; + }, template: `
- - -
-
-
-
-

订单列表

-
- - - - - - - - - - - - - - - - - - - - - -
订单号类型商品金额状态创建时间
L20240101001许愿普通许愿条¥1.00 - 已支付 - 2024-01-01 12:00
-
-
+
{{ error }}
+
+ + + + + + + + + + + + + + + +
订单 / 报告客户渠道金额状态渠道交易号时间
正在加载订单...
暂无符合条件的订单
{{ order.orderNo }}{{ order.reportId || '-' }}{{ order.accessCode }}{{ order.customerEmail || '-' }}{{ providerText(order.provider) }}{{ money(order) }}{{ statusText(order.status) }}{{ order.providerTransactionId || order.providerOrderId || '-' }}创建 {{ dateTime(order.createdAt) }}支付 {{ dateTime(order.payTime) }}
- ` -} + `, +}; diff --git a/server/web/static/js/pages/Settings.js b/server/web/static/js/pages/Settings.js index 4fe1a91..95642a0 100644 --- a/server/web/static/js/pages/Settings.js +++ b/server/web/static/js/pages/Settings.js @@ -1,38 +1,10 @@ +import { adminHeader } from './AdminHeader.js'; + // Settings 页面组件 export default { template: `
- + ${adminHeader('settings')}
diff --git a/server/web/static/js/pages/Users.js b/server/web/static/js/pages/Users.js index 233f02f..a8056f7 100644 --- a/server/web/static/js/pages/Users.js +++ b/server/web/static/js/pages/Users.js @@ -1,38 +1,10 @@ +import { adminHeader } from './AdminHeader.js'; + // Users 页面组件 export default { template: `
- + ${adminHeader('users')}
diff --git a/server/web/static/js/pages/Wiki.js b/server/web/static/js/pages/Wiki.js index 4226a9d..dad5a90 100644 --- a/server/web/static/js/pages/Wiki.js +++ b/server/web/static/js/pages/Wiki.js @@ -1,38 +1,10 @@ +import { adminHeader } from './AdminHeader.js'; + // Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步 export default { template: `
- + ${adminHeader('wiki')}
diff --git a/server/web/static/js/pages/Wishes.js b/server/web/static/js/pages/Wishes.js index e7b782b..f98188b 100644 --- a/server/web/static/js/pages/Wishes.js +++ b/server/web/static/js/pages/Wishes.js @@ -1,38 +1,10 @@ +import { adminHeader } from './AdminHeader.js'; + // Wishes 页面组件 export default { template: `
- + ${adminHeader('wishes')}
diff --git a/web-commercial/.env.example b/web-commercial/.env.example new file mode 100644 index 0000000..064d027 --- /dev/null +++ b/web-commercial/.env.example @@ -0,0 +1,2 @@ +VITE_PAYMENT_MODE=demo +VITE_API_BASE_URL=http://localhost:8080 diff --git a/web-commercial/.gitignore b/web-commercial/.gitignore new file mode 100644 index 0000000..44d4280 --- /dev/null +++ b/web-commercial/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.tsbuildinfo +.env +.env.local +.env.*.local diff --git a/web-commercial/CHANGELOG.md b/web-commercial/CHANGELOG.md new file mode 100644 index 0000000..d34a99f --- /dev/null +++ b/web-commercial/CHANGELOG.md @@ -0,0 +1,64 @@ +# 变更日志 + +## [未发布] + +### 排盘记录与免费层 + +- 每次排盘保存服务端历史记录,后台新增排盘记录页面。 +- 已购报告恢复弹窗增加历史报告列表,可直接打开历史快照。 +- 四柱、起运、交运时间、刑冲合害和神煞作为基础事实免费展示。 +- 修正交运时间显示:按 `pp.test` 的 sect-2 算法展示起运年限与真实交运时刻。 +- 修正天乙贵人误判:按日干查四支,不再错误使用年干命中月支、时支。 +- 每步大运携带对应十年流年,点击大运会同步切换流年列表。 +- 修复刑冲合害与神煞面板因自动外边距导致的宽度收缩。 +- 商业前端构建改为先构建并直接引用最新 `@lunar/core`,避免发布旧算法。 + +### 付费内容安全 + +- 支付前不再把个性化付费解读生成到浏览器。 +- 服务端在支付确认后生成付费报告快照,恢复接口只对已验证权益开放。 +- 增加服务端报告快照测试,防止前端遮罩被误当作付费保护。 + +### 管理后台 + +- 将所有后台页面的顶部导航抽取为共享 `AdminHeader`,统一菜单、品牌和当前页高亮。 +- 修复订单页缺少“百科管理”和“系统设置”的导航不一致问题。 + +## [0.2.0] - 2026-09-06 + +### 支付与账号 + +- 接入真实 Stripe Checkout Session 创建与签名 Webhook。 +- 接入 Binance Pay USDT 订单创建、HMAC 请求签名与 RSA Webhook 验签。 +- 接入 Postmark 邮箱验证码登录。 +- 服务端商品定价为 `4.99 USD` / `5 USDT`,客户端不能修改金额。 +- 新增支付事件幂等记录、订单状态机和退款后权益撤销。 +- 支付状态查询改为本人认证,修复公开枚举订单的问题。 + +### 报告恢复 + +- 支付成功后生成随机恢复码。 +- 保存购买时不可变报告快照。 +- 支持购买邮箱加恢复码跨设备找回报告。 +- 新增本人已购报告列表、权益查询和报告恢复 API。 + +### 排盘展示 + +- 新增起运年龄、顺逆行和交运日期。 +- 新增十步大运横向时间轴。 +- 新增流年/小运逐年展示。 +- 新增天干五合/相冲与地支六合/三合局成员/六冲/六害/相刑。 +- 新增神煞面板,展示命中位置与含义,包括天厨贵人。 +- 参考 `paipan` 的信息层级,但未复制其桌面表格或未验证的扩展规则。 + +### 管理后台 + +- 订单页由静态示例改为数据库真实订单。 +- 新增订单总数、状态与 Stripe 收入统计。 +- 新增渠道、状态、订单号、邮箱和报告号筛选。 + +## [0.1.0] - 2026-09-05 + +- 新建独立商业版 Web。 +- 复用 `@lunar/core` 实现四柱、五行与神煞计算。 +- 新增免费命盘、六章付费报告遮罩与响应式视觉系统。 diff --git a/web-commercial/Fetures.md b/web-commercial/Fetures.md new file mode 100644 index 0000000..de349dc --- /dev/null +++ b/web-commercial/Fetures.md @@ -0,0 +1,164 @@ +# 商业版 Web 功能说明 + +## 1. 产品目标 + +本项目是八字排盘的独立商业化 Web。免费层完整展示排盘事实,付费层提供六章深度解读。支付、用户、订单、报告快照与恢复码由 Go 服务端管理,不依赖浏览器伪造支付状态。 + +## 2. 完整业务流程 + +```mermaid +flowchart TD + A[填写出生日期、时间、性别、城市] --> B[本地计算真太阳时与八字] + B --> C[免费查看四柱、五行、大运、流年、刑冲合害、神煞] + C --> D{是否解锁深度解读} + D -- 否 --> E[继续查看免费排盘] + D -- 是 --> F[Postmark 邮箱验证码登录] + F --> G{选择支付渠道} + G --> H[Stripe: 4.99 USD] + G --> I[Binance Pay: 5 USDT] + H --> J[服务商签名 Webhook] + I --> J + J --> K[事务更新订单和报告权益] + K --> L[生成随机报告恢复码] + L --> M[保存不可变报告快照] + M --> N[用户凭购买邮箱和恢复码跨设备找回] +``` + +## 3. 免费排盘内容 + +每次排盘都会向服务端保存一份免费快照,记录出生参数、报告编号和计算结果版本,避免用户重复排盘后无法追踪历史。相同输入在同一算法版本下应得到相同结果;算法升级后保留旧快照,不覆盖历史记录。 + +免费层展示基础事实,不把基础数据故意藏到付费层: + +- 出生城市经度与真太阳时校正。 +- 年柱、月柱、日柱、时柱。 +- 十神、藏干、纳音、十二长生、命宫、身宫、胎元、空亡。 +- 五行力量统计。 +- 起运年龄、顺逆行与交运日期。 +- 十步大运时间轴。 +- 十年流年/小运信息。 +- 天干五合与天干相冲。 +- 地支六合、三合局成员、六冲、六害与相刑。 +- 22 个常见神煞,包括天乙贵人、天厨贵人、文昌贵人、禄神、羊刃、桃花、驿马、华盖、红鸾、天喜等;展示命中位置和基础说明。 + +刑冲合害和神煞属于基础排盘事实,免费展示;付费层提供结合命局的个性化解释、优先级和行动建议。 + +神煞流派版本较多,只作为辅助信息,不单独用于判断吉凶。 + +## 4. 付费报告 + +- Stripe 价格:`4.99 USD`。 +- Binance Pay 价格:`5 USDT`。 +- 单次购买,无订阅和自动续费。 +- 六章内容:性格、事业、财富、关系、身心、大运路线。 + +支付成功必须经过服务商签名 Webhook。浏览器跳转页不能直接授予权益。 + +付费内容安全边界:支付前浏览器只接收免费排盘数据和通用锁定占位,不接收个性化六章解读;服务端确认支付后,才根据订单快照生成并保存付费报告。客户端不能通过修改遮罩、localStorage 或请求参数提前获得付费内容。 + +## 5. 报告恢复码 + +支付成功后,服务端使用加密安全随机数生成恢复码,例如: + +```text +ZM-ABCD-EFGH-JKLM +``` + +恢复规则: + +1. 必须先验证购买时的邮箱。 +2. 恢复码必须属于当前邮箱账户。 +3. 订单必须是已支付状态。 +4. 报告权益必须仍然有效。 +5. 返回购买时保存的报告快照,不重新套用可能已经变化的新算法。 +6. 退款后权益会被撤销。 + +## 6. 支付与登录接口 + +### 邮箱登录 + +- `POST /api/auth/email/request`:通过 Postmark 发送 6 位验证码。 +- `POST /api/auth/email/verify`:验证验证码并签发用户 JWT。 +- 验证码 10 分钟有效、最多尝试 5 次、60 秒内不能重复发送。 + +### 创建支付 + +```http +POST /api/commerce/checkout +Authorization: Bearer +Content-Type: application/json + +{ + "provider": "stripe", + "reportId": "ZM-0VB51FN", + "snapshot": { + "id": "ZM-0VB51FN", + "result": {}, + "elements": {}, + "shensha": [], + "sections": [] + } +} +``` + +服务端从商品表读取金额,客户端不能修改定价。 + +### Webhook + +- `POST /api/commerce/webhooks/stripe` +- `POST /api/commerce/webhooks/binance-pay` + +Stripe 校验 `Stripe-Signature` 与 5 分钟时间窗口;Binance Pay 使用商户证书公钥验证 RSA 签名。事件 ID 唯一保存,重复通知不会重复授予权益。 + +### 查询与恢复 + +- `GET /api/commerce/orders/:orderNo`:查询本人订单和恢复码。 +- `GET /api/commerce/reports/:reportId/access`:查询本人报告权益。 +- `GET /api/commerce/reports`:列出本人已购报告。 +- `GET /api/commerce/reports/recover/:accessCode`:通过恢复码读取购买时快照。 + +## 7. 后台订单管理 + +访问 `/admin/orders`,可查看: + +- 报告订单总数、已支付、待支付、已退款。 +- Stripe 美元收入。 +- 用户邮箱、报告编号、恢复码关联订单。 +- Stripe 或 Binance Pay 渠道。 +- 服务商订单号与交易号。 +- 创建时间和支付时间。 +- 按状态、渠道、订单号、邮箱、报告号筛选。 + +## 8. 配置 + +前端复制 `.env.example` 为 `.env.local`。只有明确设置 `VITE_PAYMENT_MODE=demo` 才进入演示支付;未设置时默认使用生产支付,避免线上误开模拟解锁。 + +后端配置见 `server/.env.example`,必须准备: + +- Postmark Server Token 与已验证发件域名。 +- Stripe Secret Key 与 Webhook Secret。 +- Binance Pay 商户 API Key、Secret Key 与 Webhook 公钥。 +- 公开 HTTPS API 域名和 Web 地址。 +- 强随机 JWT 密钥和后台密码。 + +## 9. 数据库 + +新增: + +- `report_products`:服务端价格。 +- `report_entitlements`:付费权益。 +- `report_snapshots`:购买时不可变报告快照。 +- `payment_events`:Webhook 幂等记录。 +- `email_login_codes`:一次性验证码。 +- `orders` 新增渠道、币种、服务商交易号、报告号、恢复码等字段。 + +新部署可使用 GORM AutoMigrate;已有 MySQL 数据库应在备份后执行 `server/migrations/002_commerce.sql`,不要 fresh 或 refresh 数据库。 + +## 10. 上线前仍需完成 + +- 完成香港公司 Stripe 账户审核。 +- 完成 Binance Entity/KYB 与 Binance Pay 商户审核,并确认命理数字内容属于可接受商户类别。 +- 在 Stripe 与 Binance Pay 沙箱/测试环境上传真实密钥并跑通一笔完整支付和退款。 +- 配置正式 HTTPS Webhook 地址。 +- 补充隐私政策、用户协议、退款规则、公司与客服信息。 +- 根据香港当地要求确认虚拟资产收款、税务和消费者保护义务。 diff --git a/web-commercial/README.md b/web-commercial/README.md new file mode 100644 index 0000000..89d21b2 --- /dev/null +++ b/web-commercial/README.md @@ -0,0 +1,62 @@ +# 知命 · 商业版八字 Web + +商业化八字排盘 Web,复用 `@lunar/core` 排盘引擎,并连接 Go 服务端的邮箱登录、Stripe、Binance Pay、订单、报告权益、恢复码与管理后台。 + +## 功能 + +- 四柱、十神、藏干、纳音、五行与真太阳时。 +- 起运年龄、真实交运时间、十步大运、流年/小运时间轴。 +- 免费展示天干五合/相冲,地支六合/三合/六冲/六害/相刑,以及神煞位置。 +- 22 个常见神煞,包括天厨贵人。 +- 六章付费深度报告。 +- Stripe `4.99 USD` 与 Binance Pay `5 USDT`。 +- Postmark 邮箱验证码登录。 +- 支付 Webhook 验签与幂等处理。 +- 支付前只下发免费内容和通用锁定占位,个性化付费解读由服务端在支付确认后生成。 +- 每次排盘保存服务端历史记录,后台可查询排盘次数与出生参数。 +- 已购报告历史列表、随机恢复码与购买时报告快照。 +- 后台真实订单列表、排盘记录和收入统计。 + +## 启动前端 + +```bash +cp .env.example .env.local +npm run dev +``` + +访问 `http://localhost:4260`。 + +只有 `.env.local` 明确设置 `VITE_PAYMENT_MODE=demo` 时才会模拟支付。生产环境不设置该值或设置为 `production`。 + +## 启动后端 + +```bash +cd ../server +cp .env.example .env.local +go run ./cmd +``` + +后端默认地址为 `http://localhost:8080`,订单后台为 `http://localhost:8080/admin/orders`。 + +## 验证 + +```bash +cd ../server && go test ./... +cd ../web-commercial && npm run build +``` + +## 数据库 + +服务启动时 GORM 会自动新增商业化表。已有 MySQL 生产库也可以先备份,再单独执行: + +```bash +mysql < server/migrations/002_commerce.sql +``` + +禁止使用数据库 fresh 或 refresh。 + +## 上线要求 + +真实支付需要 Stripe、Binance Pay 和 Postmark 商户凭证。密钥只能放在部署环境或 `.env.local`,不能提交到 Git。完整接口、恢复码和运营说明见 `Fetures.md`。 + +本项目属于传统文化研究与娱乐内容,不构成医疗、法律、投资或人生决策建议。 diff --git a/web-commercial/index.html b/web-commercial/index.html new file mode 100644 index 0000000..d1859b0 --- /dev/null +++ b/web-commercial/index.html @@ -0,0 +1,15 @@ + + + + + + + + + 知命 · 八字精批 + + +
+ + + diff --git a/web-commercial/package.json b/web-commercial/package.json new file mode 100644 index 0000000..41f7617 --- /dev/null +++ b/web-commercial/package.json @@ -0,0 +1,25 @@ +{ + "name": "lunar-commercial-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "pnpm --dir ../web --filter @lunar/core build && tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0", + "typecheck": "tsc -b --pretty false" + }, + "dependencies": { + "@lunar/core": "file:../web/packages/core", + "lucide-react": "^0.468.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/web-commercial/pnpm-lock.yaml b/web-commercial/pnpm-lock.yaml new file mode 100644 index 0000000..692e7ba --- /dev/null +++ b/web-commercial/pnpm-lock.yaml @@ -0,0 +1,1172 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@lunar/core': + specifier: file:../web/packages/core + version: file:../web/packages/core + lucide-react: + specifier: ^0.468.0 + version: 0.468.0(react@19.2.8) + react: + specifier: ^19.0.0 + version: 19.2.8 + react-dom: + specifier: ^19.0.0 + version: 19.2.8(react@19.2.8) + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^4.4.0 + version: 4.4.0(vite@6.4.3) + typescript: + specifier: ^5.7.0 + version: 5.7.2 + vite: + specifier: ^6.0.0 + version: 6.4.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.0': + resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.0': + resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.0': + resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.0': + resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.0': + resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.0': + resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.0': + resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.0': + resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.0': + resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.0': + resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.0': + resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.0': + resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.0': + resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.0': + resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.0': + resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.0': + resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.0': + resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.0': + resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.0': + resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.0': + resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.0': + resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.0': + resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.0': + resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.0': + resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.0': + resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lunar/core@file:../web/packages/core': + resolution: {directory: ../web/packages/core, type: directory} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitejs/plugin-react@4.4.0': + resolution: {integrity: sha512-x/EztcTKVj+TDeANY1WjNeYsvZjZdfWRMP/KXi5Yn8BoTzpa13ZltaQqKfvWYbX8CE10GOHHdC5v86jY9x8i/g==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + + esbuild@0.25.0: + resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.468.0: + resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tyme4ts@1.5.2: + resolution: {integrity: sha512-vVAjUv1ieXleS7hCOgjAoHaZ0BdNJIza0uDOhXNpndegqWQh9ytKMR0fjkG869yqLtpXAHnzNSouBg28nrIaAg==} + + typescript@5.7.2: + resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.9 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.0': + optional: true + + '@esbuild/android-arm64@0.25.0': + optional: true + + '@esbuild/android-arm@0.25.0': + optional: true + + '@esbuild/android-x64@0.25.0': + optional: true + + '@esbuild/darwin-arm64@0.25.0': + optional: true + + '@esbuild/darwin-x64@0.25.0': + optional: true + + '@esbuild/freebsd-arm64@0.25.0': + optional: true + + '@esbuild/freebsd-x64@0.25.0': + optional: true + + '@esbuild/linux-arm64@0.25.0': + optional: true + + '@esbuild/linux-arm@0.25.0': + optional: true + + '@esbuild/linux-ia32@0.25.0': + optional: true + + '@esbuild/linux-loong64@0.25.0': + optional: true + + '@esbuild/linux-mips64el@0.25.0': + optional: true + + '@esbuild/linux-ppc64@0.25.0': + optional: true + + '@esbuild/linux-riscv64@0.25.0': + optional: true + + '@esbuild/linux-s390x@0.25.0': + optional: true + + '@esbuild/linux-x64@0.25.0': + optional: true + + '@esbuild/netbsd-arm64@0.25.0': + optional: true + + '@esbuild/netbsd-x64@0.25.0': + optional: true + + '@esbuild/openbsd-arm64@0.25.0': + optional: true + + '@esbuild/openbsd-x64@0.25.0': + optional: true + + '@esbuild/sunos-x64@0.25.0': + optional: true + + '@esbuild/win32-arm64@0.25.0': + optional: true + + '@esbuild/win32-ia32@0.25.0': + optional: true + + '@esbuild/win32-x64@0.25.0': + optional: true + + '@jridgewell/gen-mapping@0.3.12': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@lunar/core@file:../web/packages/core': + dependencies: + tyme4ts: 1.5.2 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.4.0(vite@6.4.3)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.11.21: {} + + browserslist@4.28.9: + dependencies: + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) + + caniuse-lite@1.0.30001810: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + electron-to-chromium@1.5.422: {} + + esbuild@0.25.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.0 + '@esbuild/android-arm': 0.25.0 + '@esbuild/android-arm64': 0.25.0 + '@esbuild/android-x64': 0.25.0 + '@esbuild/darwin-arm64': 0.25.0 + '@esbuild/darwin-x64': 0.25.0 + '@esbuild/freebsd-arm64': 0.25.0 + '@esbuild/freebsd-x64': 0.25.0 + '@esbuild/linux-arm': 0.25.0 + '@esbuild/linux-arm64': 0.25.0 + '@esbuild/linux-ia32': 0.25.0 + '@esbuild/linux-loong64': 0.25.0 + '@esbuild/linux-mips64el': 0.25.0 + '@esbuild/linux-ppc64': 0.25.0 + '@esbuild/linux-riscv64': 0.25.0 + '@esbuild/linux-s390x': 0.25.0 + '@esbuild/linux-x64': 0.25.0 + '@esbuild/netbsd-arm64': 0.25.0 + '@esbuild/netbsd-x64': 0.25.0 + '@esbuild/openbsd-arm64': 0.25.0 + '@esbuild/openbsd-x64': 0.25.0 + '@esbuild/sunos-x64': 0.25.0 + '@esbuild/win32-arm64': 0.25.0 + '@esbuild/win32-ia32': 0.25.0 + '@esbuild/win32-x64': 0.25.0 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.468.0(react@19.2.8): + dependencies: + react: 19.2.8 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-releases@2.0.54: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react@19.2.8: {} + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tyme4ts@1.5.2: {} + + typescript@5.7.2: {} + + update-browserslist-db@1.3.2(browserslist@4.28.9): + dependencies: + browserslist: 4.28.9 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@6.4.3: + dependencies: + esbuild: 0.25.0 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.1 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + yallist@3.1.1: {} diff --git a/web-commercial/pnpm-workspace.yaml b/web-commercial/pnpm-workspace.yaml new file mode 100644 index 0000000..00f6fc4 --- /dev/null +++ b/web-commercial/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false diff --git a/web-commercial/src/App.tsx b/web-commercial/src/App.tsx new file mode 100644 index 0000000..df9fed3 --- /dev/null +++ b/web-commercial/src/App.tsx @@ -0,0 +1,708 @@ +import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowDown, + ArrowRight, + Check, + CheckCircle2, + ChevronDown, + Clock3, + Eye, + Fingerprint, + HeartHandshake, + LockKeyhole, + MapPin, + Menu, + ShieldCheck, + Sparkles, + WalletCards, + X, +} from 'lucide-react'; +import { + analyzeElementBalance, + analyzeFortuneGanzhi, + analyzeShensha, + birthInfoToBazi, + checkStemCombine, + checkStemOpposite, + getBranchRelationship, +} from '@lunar/core'; +import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core'; +import { + authEmailKey, + authTokenKey, + checkReportAccess, + createCheckout, + isDemoPayment, + listPurchasedReports, + recoverReport, + requestEmailCode, + saveChartRecord, + verifyEmailCode, + waitForPaidOrder, + type PaymentChannel, +} from './payment'; +import { createReportId, type PremiumSection } from './report'; + +interface BirthForm { + name: string; + date: string; + time: string; + gender: 'male' | 'female'; + city: string; +} + +interface ReportState { + id: string; + result: BaziFullResult; + elements: ElementProfile; + shensha: ShenshaInfo[]; + sections: PremiumSection[]; +} + +const CITIES = [ + ['不校正(标准时间)', 120], + ['北京', 116.4], ['上海', 121.5], ['广州', 113.3], ['深圳', 114.1], ['杭州', 120.2], + ['成都', 104.1], ['重庆', 106.5], ['武汉', 114.3], ['西安', 108.9], ['南京', 118.8], + ['长沙', 113], ['郑州', 113.7], ['天津', 117.2], ['昆明', 102.7], ['乌鲁木齐', 87.6], +] as const; + +const ELEMENTS = [ + ['木', 'wood'], ['火', 'fire'], ['土', 'earth'], ['金', 'metal'], ['水', 'water'], +] as const; + +const PILLARS = [ + ['年柱', 'yearPillar'], ['月柱', 'monthPillar'], ['日柱', 'dayPillar'], ['时柱', 'hourPillar'], +] as const; + +const DEFAULT_FORM: BirthForm = { + name: '', + date: '1990-08-08', + time: '10:30', + gender: 'female', + city: '不校正(标准时间)', +}; + +const LOCKED_SECTIONS: PremiumSection[] = [ + ['壹', '性格底色'], ['贰', '事业天赋'], ['叁', '财富节奏'], + ['肆', '关系模式'], ['伍', '身心能量'], ['陆', '大运路线'], +].map(([index, title]) => ({ + index, + title, + kicker: '付费深度解读', + summary: '完成购买后查看与你的命盘对应的个性化分析。', + details: ['结合你的命盘结构生成', '提供针对性的观察与建议', '支付后永久回看'], +})); + +function getElementValue(elements: ElementProfile, key: string): number { + const map: Record = { + wood: elements.wood, + fire: elements.fire, + earth: elements.earth, + metal: elements.metal, + water: elements.water, + }; + return map[key] ?? 0; +} + +function App() { + const [form, setForm] = useState(DEFAULT_FORM); + const [report, setReport] = useState(null); + const [isCalculating, setIsCalculating] = useState(false); + const [isUnlocked, setIsUnlocked] = useState(false); + const [showCheckout, setShowCheckout] = useState(false); + const [showRecovery, setShowRecovery] = useState(false); + const [showMenu, setShowMenu] = useState(false); + const [paymentNotice, setPaymentNotice] = useState(''); + const resultRef = useRef(null); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const orderNo = params.get('order') ?? sessionStorage.getItem('lunar-pending-order'); + const reportId = sessionStorage.getItem('lunar-pending-report'); + const token = localStorage.getItem(authTokenKey); + if (params.get('payment') !== 'success' || !orderNo || !reportId || !token) return; + setPaymentNotice('正在确认支付结果,请稍候…'); + waitForPaidOrder(orderNo, token).then(async accessCode => { + if (!accessCode) { + setPaymentNotice('支付仍在确认中,你可以稍后重新打开报告。'); + return; + } + const recovered = await recoverReport(accessCode, token); + localStorage.setItem(`lunar-report:${reportId}`, 'unlocked'); + setReport(recovered.snapshot); + setIsUnlocked(true); + setPaymentNotice(`支付已确认。你的报告恢复码是 ${accessCode},请妥善保存。`); + }).catch(() => setPaymentNotice('暂时无法读取付费报告,请稍后重新打开报告。')); + }, []); + + const calculate = (event: FormEvent) => { + event.preventDefault(); + setIsCalculating(true); + const [year, month, day] = form.date.split('-').map(Number); + const [hour, minute] = form.time.split(':').map(Number); + const longitude = CITIES.find(([name]) => name === form.city)?.[1] ?? 120; + const offsetMinutes = Math.round((longitude - 120) * 4); + const adjusted = new Date(Date.UTC(year, month - 1, day, hour, minute) + offsetMinutes * 60_000); + + window.setTimeout(() => { + try { + const result = birthInfoToBazi({ + year: adjusted.getUTCFullYear(), + month: adjusted.getUTCMonth() + 1, + day: adjusted.getUTCDate(), + hour: adjusted.getUTCHours(), + minute: adjusted.getUTCMinutes(), + gender: form.gender, + ziSect: 'lateZiNextDay', + }); + result.solarAdjusted = longitude !== 120; + const elements = analyzeElementBalance(result.eightChar); + const shensha = analyzeShensha(result.eightChar); + const id = createReportId(`${form.date}|${form.time}|${form.gender}|${form.city}`); + const chartSnapshot: ReportState = { id, result, elements, shensha, sections: [] }; + setReport(chartSnapshot); + void saveChartRecord({ + reportId: id, + name: form.name, + birthDate: form.date, + birthTime: form.time, + gender: form.gender, + city: form.city, + snapshot: chartSnapshot, + }).catch(() => undefined); + const localAccess = localStorage.getItem(`lunar-report:${id}`) === 'unlocked'; + setIsUnlocked(localAccess); + const token = localStorage.getItem(authTokenKey); + if (!isDemoPayment && token && !localAccess) { + checkReportAccess(id, token).then(setIsUnlocked).catch(() => undefined); + } + setIsCalculating(false); + window.setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80); + } catch { + setIsCalculating(false); + window.alert('日期或时间无法完成排盘,请检查后重试。'); + } + }, 900); + }; + + const updateForm = (key: K, value: BirthForm[K]) => { + setForm(current => ({ ...current, [key]: value })); + }; + + return ( +
+
setShowMenu(value => !value)} onRecovery={() => setShowRecovery(true)} /> + +
+ {paymentNotice &&
{paymentNotice}
} +
+
甲
+
子
+
+
东方命理 · 私人解读
+

看懂命盘,
更懂自己。

+

输入出生信息,免费查看四柱与五行。完整报告将性格、事业、财富、关系与大运,整理成一份可以反复阅读的人生说明书。

+
+ 本地计算保护隐私 + 一次购买永久回看 + 无自动续费 +
+
+ +
+
+
01

填写你的出生信息

+ 基础排盘免费 +
+ +
+ + +
+
+
+ 性别 +
+ + +
+
+ +
+ +

出生资料仅在当前设备参与计算,不会公开展示

+
+ + 了解报告 +
+ + + + {report && ( + setShowCheckout(true)} + /> + )} + + + document.querySelector('.primary-action')?.scrollIntoView({ behavior: 'smooth', block: 'center' })} /> + +
+ +
+
知命
+

知命 · 东方命理研习所

+ 传统文化研究与娱乐参考,不构成医疗、法律、投资或人生决策建议。 +
+ + {report && showCheckout && ( + setShowCheckout(false)} + onPaid={(paidReport) => { + if (paidReport) setReport(paidReport); + localStorage.setItem(`lunar-report:${report.id}`, 'unlocked'); + setIsUnlocked(true); + setShowCheckout(false); + }} + /> + )} + + {showRecovery && ( + setShowRecovery(false)} + onRecovered={recovered => { + setReport(recovered); + setIsUnlocked(true); + setShowRecovery(false); + window.setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80); + }} + /> + )} + + {report && (!isUnlocked || report.sections.length === 0) && setShowCheckout(true)} />} +
+ ); +} + +function Header({ showMenu, onToggleMenu, onRecovery }: { showMenu: boolean; onToggleMenu: () => void; onRecovery: () => void }) { + return ( +
+ 知命知命东方命理研习所 + + + 免费排盘 + +
+ ); +} + +function ValueStrip() { + return ( +
+
四柱年月日时精准排盘
+
五行强弱结构清晰呈现
+
六章完整人生主题解读
+
十运长期阶段节奏梳理
+
+ ); +} + +function Report({ refElement, report, name, isUnlocked, onUnlock }: { refElement: React.RefObject; report: ReportState; name: string; isUnlocked: boolean; onUnlock: () => void }) { + const chart = report.result.eightChar; + const freeInsights = useMemo(() => [ + `你的日主是${chart.dayMaster},这是理解整张命盘的中心。`, + `命局中${report.elements.dominant}较突出,${report.elements.weakest}相对偏弱。`, + report.shensha[0] ? `盘中可见「${report.shensha[0].name}」,落在${report.shensha[0].foundIn.join('、')}。` : '命盘结构更适合从五行流通与阶段节奏综合观察。', + ], [chart, report]); + const premiumLoaded = isUnlocked && report.sections.length > 0; + const visibleSections = premiumLoaded ? report.sections : LOCKED_SECTIONS; + + return ( +
+
+ YOUR CHART +

{name ? `${name}的` : '你的'}先天命盘

+

报告编号 {report.id} · {report.result.solarAdjusted ? '已按出生城市经度校正时间' : '按输入的标准时间排盘'}

+
+ +
+
乾坤有序 · 四时有信日主 {chart.dayMaster}
+
+ {PILLARS.map(([label, key], index) => { + const pillar = chart[key]; + return ( +
+ {label} + {pillar.tenStar ?? '日主'} + {pillar.heavenStem} + {pillar.earthBranch} + {pillar.nayin} +
+ ); + })} +
+
+ 命宫 {chart.ownSign}身宫 {chart.bodySign}胎元 {chart.fetalOrigin}空亡 {chart.emptyBranches.join('')} +
+
+ +
+
+
02

五行能量

免费查看
+
+ {ELEMENTS.map(([label, key]) => { + const value = getElementValue(report.elements, key); + const percent = Math.max(8, Math.round((value / report.elements.total) * 100)); + return
{label}
{value.toFixed(1)}
; + })} +
+

五行计算包含四柱天干、地支与藏干权重,结果用于观察结构倾向。

+
+ +
+
03

命盘初读

已解锁
+
+ {freeInsights.map((insight, index) =>
0{index + 1}

{insight}

)} +
+
+
+ + + +
+
+ FULL READING +

{premiumLoaded ? '完整命理解读' : '还有更重要的部分'}

+

{premiumLoaded ? '六大人生主题已全部解锁,可随时回来阅读。' : '命盘告诉你“是什么”,完整解读帮你理解“如何用”。'}

+
+ +
+ {visibleSections.map((section, index) => ( +
+
{section.index}
{section.kicker}

{section.title}

{!premiumLoaded && }
+

{section.summary}

+
    {section.details.map(detail =>
  • {detail}
  • )}
+ {!premiumLoaded &&
} +
+ ))} +
+ + {!premiumLoaded && ( +
+
+
一次解锁 · 永久回看

你的完整命理精批报告

性格、事业、财富、感情、身心与十步大运,共 6 大主题。

+
完整报告$4.99$9.99
+ +

无订阅、无自动续费 · 支付后当前设备永久查看

+
+ )} +
+
+ ); +} + +function TechnicalChart({ report }: { report: ReportState }) { + const chart = report.result.eightChar; + const currentYear = new Date().getFullYear(); + const relationships = useMemo(() => { + const items: { label: string; detail: string; tone: string }[] = []; + for (let first = 0; first < PILLARS.length; first += 1) { + for (let second = first + 1; second < PILLARS.length; second += 1) { + const [firstLabel, firstKey] = PILLARS[first]; + const [secondLabel, secondKey] = PILLARS[second]; + const firstPillar = chart[firstKey]; + const secondPillar = chart[secondKey]; + const position = `${firstLabel} · ${secondLabel}`; + if (checkStemCombine(firstPillar.heavenStem, secondPillar.heavenStem)) items.push({ label: '天干五合', detail: `${firstPillar.heavenStem}${secondPillar.heavenStem}合 · ${position}`, tone: 'good' }); + if (checkStemOpposite(firstPillar.heavenStem, secondPillar.heavenStem)) items.push({ label: '天干相冲', detail: `${firstPillar.heavenStem}${secondPillar.heavenStem}冲 · ${position}`, tone: 'alert' }); + const branch = getBranchRelationship(firstPillar.earthBranch, secondPillar.earthBranch); + if (branch.combine) items.push({ label: '地支六合', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}合 · ${position}`, tone: 'good' }); + if (branch.opposite) items.push({ label: '地支六冲', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}冲 · ${position}`, tone: 'alert' }); + if (branch.harm) items.push({ label: '地支六害', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}害 · ${position}`, tone: 'warn' }); + if (branch.punish) items.push({ label: '地支相刑', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}刑 · ${position}`, tone: 'warn' }); + if (branch.threeCombine) items.push({ label: '三合局成员', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch} · ${branch.formation ?? ''}`, tone: 'good' }); + } + } + return items; + }, [chart]); + const fortunes = useMemo(() => report.result.decadeFortunes.map(item => ({ + ...item, + analysis: analyzeFortuneGanzhi(item.ganzhi, chart.dayMasterStem, chart.dayPillar.earthBranch), + })), [report, chart]); + const currentDecadeIndex = fortunes.findIndex(item => currentYear >= item.startYear && currentYear <= item.endYear); + const [selectedIndex, setSelectedIndex] = useState(currentDecadeIndex >= 0 ? currentDecadeIndex : 0); + const selectedFortune = fortunes[selectedIndex] ?? fortunes[0]; + const childLimit = report.result.childLimit; + const approxStartAge = childLimit.yearCount + ((childLimit.monthCount || childLimit.dayCount || childLimit.hourCount || childLimit.minuteCount) ? 1 : 0); + + return ( +
+
TRADITIONAL CHART

命盘细节与运程

排盘数据免费完整展示,付费部分只负责深度解释。

+
+
起运年龄约 {approxStartAge} 岁{childLimit.yearCount}年{childLimit.monthCount}月{childLimit.dayCount}天后
+
顺逆{childLimit.forward ? '顺行' : '逆行'}
+
交运时间{childLimit.startTime}
+
当前日主{chart.dayMaster}
+
+ +
+
04

大运 · 十年一步

点击大运查看对应流年
+
+ {fortunes.map((item, index) => { + const current = currentYear >= item.startYear && currentYear <= item.endYear; + const selected = index === selectedIndex; + return ; + })} +
+
+ +
+
05

流年 · {selectedFortune.ganzhi}({selectedFortune.startYear}—{selectedFortune.endYear})

小运以出生后逐年干支观察
+
+ {selectedFortune.annualFortunes.map(item =>
{item.age}岁{item.ganzhi}{item.year}
)} +
+
+ +
+
+
06

刑冲合害

四柱内部关系
+
+ {relationships.length ? relationships.map(item =>
{item.label}{item.detail}
) :

四柱之间未见主要的合、冲、刑、害关系。

} +
+
+
+
07

神煞

共命中 {report.shensha.length} 项
+
+ {report.shensha.map(item =>
{item.type}
{item.name}{item.foundIn.join('、')} · {item.description}
)} +
+
+ +
+

神煞流派版本较多,本页面采用常见排盘规则,仅作辅助观察,不单独作为判断依据。

+
+ ); +} + +function HowItWorks() { + return ( +
+
THE METHOD

不是一句判词,
而是一套理解自己的方法。

+
+
01

校准出生时刻

结合出生城市经度进行真太阳时校正,避免地区时差影响时柱。

+
02

拆解命盘结构

从四柱、十神、藏干、五行与神煞,多层观察先天倾向。

+
03

翻译成行动建议

不制造恐惧,不下绝对结论,把传统术语整理成可理解的生活语言。

+
+
+ ); +} + +function Offer({ onStart }: { onStart: () => void }) { + return ( +
+
知 命 而 行
YOUR PRIVATE REPORT

人生没有标准答案,
但可以多一份清醒。

先免费查看自己的命盘。确认内容对你有价值,再决定是否解锁完整报告。

+
+ 单份完整报告
$4.99$9.99或使用 5 USDT · 永久回看
+
  • 四柱、十神、藏干与五行详盘
  • 性格、事业、财富、关系、身心
  • 十步大运阶段路线
  • 无订阅,不自动续费
+ + 传统文化研究与娱乐参考,请理性看待 +
+
+ ); +} + +function Faq() { + const items = [ + ['不知道准确出生时间怎么办?', '可以先选择大致时辰查看,但时柱会影响结果。若完全不确定,建议向家人核实出生证明后再购买完整报告。'], + ['购买后会自动续费吗?', '不会。完整报告为单次购买,不包含订阅或自动续费。'], + ['八字报告能预测具体事件吗?', '不能。报告用于传统文化研究与自我观察,不对具体事件作确定性承诺,也不能代替现实中的专业建议。'], + ['我的出生信息会被公开吗?', '不会。当前版本在浏览器本地完成排盘;正式支付接入时也应只保存订单与报告权限所需的最少信息。'], + ]; + return
QUESTIONS

你可能还想知道

{items.map(([question, answer]) =>
{question}+

{answer}

)}
; +} + +function Checkout({ reportId, snapshot, onClose, onPaid }: { reportId: string; snapshot: ReportState; onClose: () => void; onPaid: (paidReport?: ReportState) => void }) { + const [channel, setChannel] = useState('stripe'); + const [token, setToken] = useState(() => localStorage.getItem(authTokenKey) ?? ''); + const [email, setEmail] = useState(() => localStorage.getItem(authEmailKey) ?? ''); + const [code, setCode] = useState(''); + const [codeSent, setCodeSent] = useState(false); + const [isPaying, setIsPaying] = useState(false); + const [error, setError] = useState(''); + + const sendCode = async () => { + setIsPaying(true); + setError(''); + try { + await requestEmailCode(email); + setCodeSent(true); + } catch (reason) { + setError(reason instanceof Error ? reason.message : '验证码发送失败'); + } finally { + setIsPaying(false); + } + }; + + const login = async () => { + setIsPaying(true); + setError(''); + try { + setToken(await verifyEmailCode(email, code)); + } catch (reason) { + setError(reason instanceof Error ? reason.message : '验证码验证失败'); + } finally { + setIsPaying(false); + } + }; + + const pay = async () => { + setIsPaying(true); + setError(''); + try { + const checkout = await createCheckout({ reportId, channel, token, snapshot }); + if (checkout.demo) { + const accessCode = await waitForPaidOrder(checkout.orderNo, token, 1); + if (!accessCode) throw new Error('模拟支付完成,但报告权益尚未生成'); + const recovered = await recoverReport(accessCode, token); + onPaid(recovered.snapshot); + return; + } + if (checkout.checkoutUrl) { + sessionStorage.setItem('lunar-pending-order', checkout.orderNo); + sessionStorage.setItem('lunar-pending-report', reportId); + window.location.assign(checkout.checkoutUrl); + return; + } + setError(`订单 ${checkout.orderNo} 已创建,请完成支付。`); + } catch (reason) { + setError(reason instanceof Error ? reason.message : '暂时无法发起支付'); + } finally { + setIsPaying(false); + } + }; + + const needsLogin = !isDemoPayment && !token; + return ( +
event.currentTarget === event.target && onClose()}> +
+ +
知命
解锁完整报告

一次支付,永久回看

+ {needsLogin ? ( +
+

先验证邮箱,用于保存订单并跨设备找回报告。

+ setEmail(event.target.value)} placeholder="你的邮箱" autoComplete="email" /> + {codeSent && setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" autoComplete="one-time-code" />} + +
+ ) : ( + <> +
应付金额{channel === 'stripe' ? <>$4.99 : <>5 USDT}
+
+ + +
+ {isDemoPayment &&

当前为产品演示模式,点击后模拟支付成功,不会产生真实扣款。

} + + + )} + {error &&

{error}

} +

服务商签名回调确认到账 · 无自动续费

+
+
+ ); +} + +function RecoveryModal({ onClose, onRecovered }: { onClose: () => void; onRecovered: (report: ReportState) => void }) { + const [token, setToken] = useState(() => localStorage.getItem(authTokenKey) ?? ''); + const [email, setEmail] = useState(() => localStorage.getItem(authEmailKey) ?? ''); + const [code, setCode] = useState(''); + const [accessCode, setAccessCode] = useState(''); + const [purchasedReports, setPurchasedReports] = useState>([]); + const [codeSent, setCodeSent] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const sendCode = async () => { + setBusy(true); setError(''); + try { await requestEmailCode(email); setCodeSent(true); } + catch (reason) { setError(reason instanceof Error ? reason.message : '验证码发送失败'); } + finally { setBusy(false); } + }; + useEffect(() => { + if (!token) return; + listPurchasedReports(token).then(setPurchasedReports).catch(() => setPurchasedReports([])); + }, [token]); + + const login = async () => { + setBusy(true); setError(''); + try { setToken(await verifyEmailCode(email, code)); } + catch (reason) { setError(reason instanceof Error ? reason.message : '验证码验证失败'); } + finally { setBusy(false); } + }; + const recover = async () => { + setBusy(true); setError(''); + try { + const recovered = await recoverReport(accessCode, token); + localStorage.setItem(`lunar-report:${recovered.reportId}`, 'unlocked'); + onRecovered(recovered.snapshot); + } catch (reason) { setError(reason instanceof Error ? reason.message : '没有找到这份报告'); } + finally { setBusy(false); } + }; + + return ( +
event.currentTarget === event.target && onClose()}> +
+ +
再阅
已购报告恢复

找回当时的完整报告

+ {!token ? ( +
+

先验证购买时使用的邮箱,再输入报告恢复码。

+ setEmail(event.target.value)} placeholder="购买邮箱" autoComplete="email" /> + {codeSent && setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" />} + +
+ ) : ( +
+ {purchasedReports.length > 0 &&

你的历史报告

{purchasedReports.map(item => )}
} +

恢复码格式示例:ZM-ABCD-EFGH-JKLM

+ setAccessCode(event.target.value.toUpperCase().replace(/[^A-Z2-7-]/g, '').slice(0, 20))} placeholder="输入报告恢复码" autoComplete="off" /> + +
+ )} + {error &&

{error}

} +

恢复码与购买邮箱必须同时匹配

+
+
+ ); +} + +function MobilePayBar({ onClick }: { onClick: () => void }) { + return
完整报告$4.99$9.99
; +} + +export default App; diff --git a/web-commercial/src/main.tsx b/web-commercial/src/main.tsx new file mode 100644 index 0000000..cf91907 --- /dev/null +++ b/web-commercial/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/web-commercial/src/payment.ts b/web-commercial/src/payment.ts new file mode 100644 index 0000000..1532fd3 --- /dev/null +++ b/web-commercial/src/payment.ts @@ -0,0 +1,106 @@ +export type PaymentChannel = 'stripe' | 'binance_pay'; + +interface CheckoutRequest { + reportId: string; + channel: PaymentChannel; + token: string; + snapshot: unknown; +} + +interface CheckoutResponse { + orderNo: string; + checkoutUrl?: string; + demo: boolean; +} + +interface ApiPayload { + code: number; + msg: string; + data: T; +} + +const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? ''; +export const isDemoPayment = import.meta.env.VITE_PAYMENT_MODE === 'demo'; +export const authTokenKey = 'lunar-commerce-token'; +export const authEmailKey = 'lunar-commerce-email'; + +async function apiRequest(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${apiBaseURL}${path}`, { + ...options, + headers: { 'Content-Type': 'application/json', ...options.headers }, + }); + const payload = await response.json().catch(() => null) as ApiPayload | null; + if (!response.ok || !payload || payload.code !== 0) throw new Error(payload?.msg ?? '服务暂时不可用'); + return payload.data; +} + +export async function requestEmailCode(email: string): Promise { + await apiRequest('/api/auth/email/request', { method: 'POST', body: JSON.stringify({ email }) }); +} + +export async function saveChartRecord(input: { + reportId: string; + name: string; + birthDate: string; + birthTime: string; + gender: string; + city: string; + snapshot: unknown; +}): Promise { + await apiRequest('/api/commerce/charts', { + method: 'POST', + body: JSON.stringify(input), + }); +} + +export async function verifyEmailCode(email: string, code: string): Promise { + const result = await apiRequest<{ token: string }>('/api/auth/email/verify', { method: 'POST', body: JSON.stringify({ email, code }) }); + localStorage.setItem(authTokenKey, result.token); + localStorage.setItem(authEmailKey, email.toLowerCase()); + return result.token; +} + +export async function createCheckout(request: CheckoutRequest): Promise { + if (isDemoPayment) { + await new Promise(resolve => window.setTimeout(resolve, 700)); + return { orderNo: `DEMO-${Date.now()}`, demo: true }; + } + const result = await apiRequest<{ orderNo: string; checkoutUrl: string; demo?: boolean }>('/api/commerce/checkout', { + method: 'POST', + headers: { Authorization: `Bearer ${request.token}` }, + body: JSON.stringify({ provider: request.channel, reportId: request.reportId, snapshot: request.snapshot }), + }); + return { orderNo: result.orderNo, checkoutUrl: result.checkoutUrl, demo: result.demo ?? false }; +} + +export async function checkReportAccess(reportId: string, token: string): Promise { + const result = await apiRequest<{ unlocked: boolean }>(`/api/commerce/reports/${encodeURIComponent(reportId)}/access`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return result.unlocked; +} + +export async function waitForPaidOrder(orderNo: string, token: string, attempts = 12): Promise { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const order = await apiRequest<{ status: string; accessCode?: string }>(`/api/commerce/orders/${encodeURIComponent(orderNo)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (order.status === 'paid') return order.accessCode ?? null; + if (order.status === 'cancelled' || order.status === 'refunded') return null; + await new Promise(resolve => window.setTimeout(resolve, 2000)); + } + return null; +} + +export async function listPurchasedReports(token: string): Promise> { + const result = await apiRequest<{ list: Array<{ reportId: string; accessCode: string; productName: string; paidAt: string }> }>('/api/commerce/reports', { + headers: { Authorization: `Bearer ${token}` }, + }); + return result.list; +} + +export async function recoverReport(accessCode: string, token: string): Promise<{ reportId: string; snapshot: T }> { + return apiRequest<{ reportId: string; snapshot: T }>(`/api/commerce/reports/recover/${encodeURIComponent(accessCode.toUpperCase())}`, { + headers: { Authorization: `Bearer ${token}` }, + }); +} diff --git a/web-commercial/src/report.ts b/web-commercial/src/report.ts new file mode 100644 index 0000000..6a7d5d3 --- /dev/null +++ b/web-commercial/src/report.ts @@ -0,0 +1,85 @@ +import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core'; + +export interface PremiumSection { + index: string; + title: string; + kicker: string; + summary: string; + details: string[]; +} + +const ELEMENT_TRAITS: Record = { + 木: { gift: '成长力、规划感与同理心', caution: '容易把长期目标变成眼前压力', field: '教育、内容、设计与长期经营', rhythm: '先建立根基,再持续放大优势' }, + 火: { gift: '表达力、行动力与感染力', caution: '节奏过快时容易透支专注', field: '传播、品牌、销售与创新项目', rhythm: '抓住窗口,但为决策保留冷静期' }, + 土: { gift: '稳定性、承诺感与资源整合力', caution: '求稳过度时会错过变化', field: '管理、运营、资产与服务业', rhythm: '稳中求变,用小步试错替代停滞' }, + 金: { gift: '判断力、边界感与执行标准', caution: '标准过高时容易显得紧绷', field: '技术、金融、法律与精细管理', rhythm: '先定规则,再给关系留出弹性' }, + 水: { gift: '洞察力、适应力与信息敏感度', caution: '选择过多时容易分散心力', field: '研究、咨询、贸易与流动型工作', rhythm: '聚焦一个主航道,让变化服务目标' }, +}; + +const SUPPORTING_ELEMENT: Record = { 木: '水', 火: '木', 土: '火', 金: '土', 水: '金' }; + +export function createReportId(seed: string): string { + let hash = 2166136261; + for (const char of seed) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return `ZM-${Math.abs(hash).toString(36).toUpperCase().padStart(7, '0').slice(0, 7)}`; +} + +export function createPremiumSections(result: BaziFullResult, elements: ElementProfile, shensha: ShenshaInfo[]): PremiumSection[] { + const chart = result.eightChar; + const dominant = ELEMENT_TRAITS[elements.dominant] ?? ELEMENT_TRAITS.土; + const dayMaster = ELEMENT_TRAITS[chart.dayMasterElement] ?? dominant; + const helpful = SUPPORTING_ELEMENT[chart.dayMasterElement] ?? elements.weakest; + const positiveStars = shensha.filter(item => item.type === '吉').slice(0, 3).map(item => item.name); + const cautionStars = shensha.filter(item => item.type === '凶').slice(0, 2).map(item => item.name); + const firstFortune = result.decadeFortunes[0]; + const starText = positiveStars.length ? positiveStars.join('、') : '命局本身的稳定结构'; + const cautionText = cautionStars.length ? cautionStars.join('、') : '节奏失衡'; + + return [ + { + index: '壹', + title: '性格底色', + kicker: `${chart.dayMaster} · 内在驱动力`, + summary: `你的日主为${chart.dayMaster},核心优势集中在${dayMaster.gift}。`, + details: [`五行以${elements.dominant}较突出,做事更重视${dominant.gift}。`, `需要留意:${dayMaster.caution}。`, `真正适合你的成长方式是:${dayMaster.rhythm}。`], + }, + { + index: '贰', + title: '事业天赋', + kicker: '能力结构 · 职场定位', + summary: `你的优势更容易在${dominant.field}中形成复利。`, + details: [`适合承担需要${dayMaster.gift}的角色。`, `命局中的${starText},提示你应主动经营专业信用与协作关系。`, `比起频繁换方向,更适合建立可重复的方法与个人标签。`], + }, + { + index: '叁', + title: '财富节奏', + kicker: '积累方式 · 风险边界', + summary: `财富策略宜围绕“${dominant.rhythm}”展开。`, + details: [`优先把${dayMaster.gift}转化成可计价的产品或服务。`, `遇到${cautionText}对应的冲动时,应延迟重大财务决定。`, `收入结构宜同时保留稳定现金流与小比例成长投入。`], + }, + { + index: '肆', + title: '关系模式', + kicker: '亲密关系 · 沟通课题', + summary: `关系中的吸引力来自${dayMaster.gift},长久则依赖清晰表达。`, + details: [`你更需要能理解你节奏、也尊重边界的伴侣。`, `情绪紧张时,先描述事实与需要,避免用结论代替沟通。`, `重要关系中要刻意安排稳定相处,而不是只依赖临时热情。`], + }, + { + index: '伍', + title: '身心能量', + kicker: '五行平衡 · 日常建议', + summary: `${elements.weakest}相对偏弱,日常可借${helpful}的意象建立平衡感。`, + details: [`把作息、运动和独处时间当作长期资产。`, `高压期减少多线程任务,让注意力回到一个明确目标。`, `所有健康问题应以正规医疗意见为准,本报告不替代诊断。`], + }, + { + index: '陆', + title: '大运路线', + kicker: '阶段重点 · 行动窗口', + summary: firstFortune ? `首步大运为${firstFortune.ganzhi},约从${firstFortune.startAge}岁进入阶段转换。` : '人生节奏呈阶段性变化。', + details: [`大运用于观察阶段主题,不代表确定事件。`, `顺势的关键是持续放大${dayMaster.gift},并管理${dayMaster.caution}。`, `完整报告保留十步大运时间轴,方便长期回看。`], + }, + ]; +} diff --git a/web-commercial/src/styles.css b/web-commercial/src/styles.css new file mode 100644 index 0000000..925a762 --- /dev/null +++ b/web-commercial/src/styles.css @@ -0,0 +1,506 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Ma+Shan+Zheng&family=Noto+Serif+SC:wght@400;500;600;700;900&display=swap'); + +:root { + color: #1a1c18; + background: #f1ecdf; + font-family: 'DM Sans', 'PingFang SC', sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + --ink: #171915; + --ink-soft: #5d6058; + --paper: #f5f0e5; + --paper-deep: #e9e0ce; + --vermilion: #b5372c; + --vermilion-dark: #84251f; + --gold: #b48a47; + --line: rgba(23, 25, 21, 0.14); + --serif: 'Noto Serif SC', 'Songti SC', serif; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--paper); } +button, input, select { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +a { color: inherit; text-decoration: none; } + +.site-shell { min-height: 100vh; overflow: hidden; } +.payment-notice { position: fixed; z-index: 80; left: 50%; top: 18px; transform: translateX(-50%); width: min(560px, calc(100% - 28px)); padding: 13px 16px; display: flex; align-items: center; gap: 9px; background: #f5f0e5; color: var(--ink); border: 1px solid var(--gold); box-shadow: 0 16px 45px rgba(0,0,0,.22); font-size: 12px; } +.payment-notice > svg { color: var(--vermilion); flex: 0 0 auto; } +.payment-notice button { margin-left: auto; padding: 2px; border: 0; background: transparent; color: var(--ink-soft); } +.payment-notice button svg { width: 16px; } + +header { + height: 82px; + padding: 0 clamp(24px, 5vw, 76px); + display: flex; + align-items: center; + justify-content: space-between; + position: absolute; + z-index: 20; + width: 100%; + color: #f7f1e4; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +.brand { display: flex; align-items: center; gap: 12px; } +.brand > span:last-child { display: flex; flex-direction: column; gap: 2px; } +.brand b { font-family: var(--serif); letter-spacing: .22em; font-size: 18px; } +.brand small { color: rgba(255,255,255,.55); font-size: 9px; letter-spacing: .3em; } +.seal { + width: 41px; + height: 41px; + border: 1px solid currentColor; + display: grid; + place-items: center; + color: #f5eee0; + font-family: var(--serif); + font-weight: 700; + font-size: 12px; + line-height: 1.15; + writing-mode: vertical-rl; + letter-spacing: .15em; + position: relative; +} +.seal::after { content: ''; position: absolute; inset: 3px; border: 1px solid currentColor; opacity: .45; } +header nav { display: flex; gap: 38px; margin-left: auto; margin-right: 45px; } +header nav a { font-size: 13px; color: rgba(255,255,255,.72); transition: color .25s ease; } +header nav a:hover { color: #fff; } +.recovery-link { margin-right: 10px; border: 0; background: transparent; color: rgba(255,255,255,.7); font-size: 11px; white-space: nowrap; } +.recovery-link:hover { color: white; } +.header-cta { border: 1px solid rgba(255,255,255,.38); padding: 10px 18px; font-size: 12px; letter-spacing: .12em; transition: .25s ease; } +.header-cta:hover { background: #f6f0e4; color: var(--ink); } +.menu-button { display: none; border: 0; background: transparent; color: inherit; } + +.hero { + min-height: 790px; + background: var(--ink); + color: #f7f1e4; + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(420px, .92fr); + align-items: center; + gap: clamp(48px, 7vw, 120px); + padding: 132px clamp(24px, 8vw, 128px) 90px; + position: relative; + isolation: isolate; +} +.hero::before { + content: ''; + position: absolute; + inset: 0; + z-index: -2; + background-image: linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px); + background-size: 38px 38px; +} +.hero::after { + content: ''; + position: absolute; + width: 58vw; + height: 58vw; + max-width: 850px; + max-height: 850px; + border: 1px solid rgba(180, 138, 71, .13); + border-radius: 50%; + left: -17vw; + top: 80px; + z-index: -1; + box-shadow: 0 0 0 90px rgba(180,138,71,.026), 0 0 0 180px rgba(180,138,71,.018); +} +.hero-orbit { + position: absolute; + z-index: -1; + font-family: var(--serif); + font-size: clamp(110px, 16vw, 260px); + font-weight: 900; + color: rgba(255,255,255,.018); + user-select: none; +} +.orbit-one { left: 5vw; bottom: 5%; } +.orbit-two { left: 35vw; top: 7%; } +.hero-copy { max-width: 650px; } +.eyebrow { display: flex; align-items: center; gap: 13px; color: #c8aa76; font-family: var(--serif); font-size: 12px; letter-spacing: .28em; } +.eyebrow span { width: 30px; height: 1px; background: currentColor; } +.hero h1 { margin: 27px 0 30px; font-family: var(--serif); font-weight: 600; font-size: clamp(54px, 6.6vw, 98px); line-height: 1.13; letter-spacing: -.055em; } +.hero h1 em { color: #c8aa76; font-style: normal; position: relative; } +.hero h1 em::after { content: ''; position: absolute; left: 0; right: 0; bottom: -5px; height: 8px; border-top: 1px solid var(--vermilion); border-radius: 50%; transform: rotate(-2deg); } +.hero-lead { max-width: 590px; font-family: var(--serif); color: rgba(247,241,228,.65); font-size: 16px; line-height: 2; letter-spacing: .04em; } +.trust-row { display: flex; flex-wrap: wrap; gap: 18px 26px; margin-top: 38px; padding-top: 28px; border-top: 1px solid rgba(255,255,255,.1); } +.trust-row span { display: flex; align-items: center; gap: 7px; color: rgba(255,255,255,.58); font-size: 11px; } +.trust-row svg { color: #c8aa76; } + +.birth-card { + background: var(--paper); + color: var(--ink); + padding: 34px; + width: 100%; + max-width: 540px; + justify-self: end; + box-shadow: 22px 26px 0 rgba(0,0,0,.17), 0 35px 80px rgba(0,0,0,.3); + position: relative; +} +.birth-card::before { content: ''; position: absolute; inset: 8px; border: 1px solid rgba(181,55,44,.18); pointer-events: none; } +.card-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 30px; position: relative; } +.card-heading > div { display: flex; align-items: center; gap: 11px; } +.card-heading p { margin: 0; font-family: var(--serif); font-size: 18px; font-weight: 600; } +.step-number { color: var(--vermilion); font-size: 10px; letter-spacing: .12em; } +.free-chip { color: var(--vermilion); border: 1px solid rgba(181,55,44,.3); padding: 5px 8px; font-size: 10px; } +.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.field { display: block; margin: 0 0 14px; position: relative; min-width: 0; } +.field > span, .field legend { display: block; color: #77786f; font-size: 11px; margin: 0 0 7px; letter-spacing: .05em; } +.field input, .field select { + width: 100%; + height: 47px; + border: 1px solid #d7cebd; + border-radius: 0; + color: var(--ink); + background: rgba(255,255,255,.4); + padding: 0 13px; + outline: 0; + transition: border-color .2s ease, background .2s ease; + font-size: 13px; +} +.field input:focus, .field select:focus { border-color: var(--vermilion); background: #fff; } +.field input::placeholder { color: #aaa79d; } +.segmented-field { padding: 0; border: 0; } +.segment-control { height: 47px; display: grid; grid-template-columns: 1fr 1fr; border: 1px solid #d7cebd; padding: 3px; } +.segment-control button { border: 0; background: transparent; color: #74766e; font-family: var(--serif); } +.segment-control button.active { background: var(--ink); color: #f8f2e6; } +.select-field > svg:first-of-type { position: absolute; left: 12px; bottom: 15px; color: var(--vermilion); pointer-events: none; } +.select-field > svg:last-of-type { position: absolute; right: 11px; bottom: 15px; color: #77786f; pointer-events: none; } +.select-field select { appearance: none; padding-left: 35px; } +.primary-action, .offer-card button, .unlock-panel button, .pay-button { + border: 0; + background: var(--vermilion); + color: white; + min-height: 53px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + font-family: var(--serif); + font-weight: 600; + letter-spacing: .08em; + transition: background .2s ease, transform .2s ease; +} +.primary-action:hover, .offer-card button:hover, .unlock-panel button:hover, .pay-button:hover { background: var(--vermilion-dark); transform: translateY(-1px); } +.primary-action:disabled, .pay-button:disabled { opacity: .7; cursor: wait; } +.form-note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 14px 0 0; font-size: 10px; color: #929086; } +.loading-mark { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; } +.scroll-cue { position: absolute; bottom: 27px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.42); font-size: 9px; letter-spacing: .2em; writing-mode: vertical-rl; } +.scroll-cue svg { animation: bob 1.8s ease-in-out infinite; } + +.value-strip { display: grid; grid-template-columns: repeat(4, 1fr); padding: 0 clamp(24px, 8vw, 128px); background: #ded4c1; border-bottom: 1px solid var(--line); } +.value-strip div { min-height: 108px; display: flex; align-items: center; justify-content: center; gap: 13px; border-right: 1px solid var(--line); } +.value-strip div:first-child { border-left: 1px solid var(--line); } +.value-strip b { font-family: var(--serif); font-size: 28px; font-weight: 500; color: var(--vermilion); } +.value-strip span { font-size: 11px; color: #666960; } + +.report-section { padding-top: 112px; scroll-margin-top: 20px; } +.section-heading > span { color: var(--vermilion); font-size: 10px; letter-spacing: .3em; font-weight: 600; } +.section-heading h2 { margin: 13px 0 12px; font-family: var(--serif); font-size: clamp(34px, 4.3vw, 58px); line-height: 1.28; font-weight: 600; letter-spacing: -.035em; } +.section-heading p { margin: 0; color: var(--ink-soft); font-size: 13px; line-height: 1.8; } +.section-heading.centered { text-align: center; } +.chart-paper { + width: min(920px, calc(100% - 40px)); + margin: 52px auto 28px; + background-color: #efe7d7; + background-image: radial-gradient(rgba(79,55,32,.12) .6px, transparent .6px); + background-size: 5px 5px; + border: 1px solid #cfc1aa; + box-shadow: 0 18px 50px rgba(59,43,24,.11); + padding: 32px 45px; + position: relative; +} +.chart-paper::before, .chart-paper::after { content: ''; position: absolute; width: 18px; height: 18px; border-color: var(--vermilion); opacity: .55; } +.chart-paper::before { top: 10px; left: 10px; border-top: 1px solid; border-left: 1px solid; } +.chart-paper::after { right: 10px; bottom: 10px; border-right: 1px solid; border-bottom: 1px solid; } +.paper-topline { display: flex; justify-content: space-between; align-items: center; padding-bottom: 20px; border-bottom: 1px solid rgba(45,37,26,.18); font-family: var(--serif); font-size: 11px; letter-spacing: .12em; color: #797267; } +.paper-topline strong { color: var(--vermilion); font-size: 14px; } +.pillars-grid { display: grid; grid-template-columns: repeat(4, 1fr); max-width: 650px; margin: 28px auto; } +.pillar { text-align: center; border-right: 1px solid rgba(45,37,26,.13); padding: 3px 18px 15px; } +.pillar:last-child { border: 0; } +.pillar > span { display: block; font-family: var(--serif); color: #6c695f; font-size: 12px; letter-spacing: .15em; } +.pillar small { display: block; margin: 10px 0 3px; color: #9a9387; font-size: 10px; } +.pillar b { display: block; font-family: var(--serif); font-size: clamp(38px, 4vw, 52px); font-weight: 600; line-height: 1.15; } +.pillar i { display: block; margin-top: 10px; font-family: var(--serif); font-size: 10px; font-style: normal; color: #82796c; } +.day-pillar { background: rgba(181,55,44,.045); box-shadow: inset 0 -2px var(--vermilion); } +.element-木 { color: #3d6f4c; } +.element-火 { color: #b5372c; } +.element-土 { color: #8b693a; } +.element-金 { color: #77736c; } +.element-水 { color: #365f71; } +.chart-meta { display: grid; grid-template-columns: repeat(4, 1fr); padding-top: 18px; border-top: 1px solid rgba(45,37,26,.18); text-align: center; font-size: 10px; color: #888075; } +.chart-meta b { display: block; margin-top: 5px; font-family: var(--serif); color: #383a35; font-size: 13px; } + +.preview-grid { width: min(920px, calc(100% - 40px)); margin: 0 auto 100px; display: grid; grid-template-columns: .9fr 1.1fr; gap: 18px; } +.elements-card, .insight-card { padding: 28px 30px; border: 1px solid var(--line); } +.elements-card { background: rgba(255,255,255,.35); } +.insight-card { background: var(--vermilion-dark); color: white; } +.mini-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 26px; } +.mini-heading span { color: var(--vermilion); font-size: 9px; } +.mini-heading h3 { margin: 0; font-family: var(--serif); font-weight: 600; font-size: 18px; } +.mini-heading i { margin-left: auto; font-style: normal; color: #7c7d75; font-size: 9px; border: 1px solid var(--line); padding: 4px 7px; } +.mini-heading.light span { color: #e2c790; } +.mini-heading.light i { color: rgba(255,255,255,.65); border-color: rgba(255,255,255,.22); } +.element-list { display: grid; gap: 12px; } +.element-row { display: grid; grid-template-columns: 27px 1fr 28px; gap: 10px; align-items: center; } +.element-dot { width: 25px; height: 25px; display: grid; place-items: center; color: white; font-family: var(--serif); font-size: 11px; border-radius: 50%; } +.element-dot.wood { background: #477854; }.element-dot.fire { background: #b94a3d; }.element-dot.earth { background: #967247; }.element-dot.metal { background: #7d7e7a; }.element-dot.water { background: #426b7d; } +.element-row > div { height: 5px; background: #ddd5c6; } +.element-row > div i { display: block; height: 100%; background: var(--ink); } +.element-row b { font-size: 10px; text-align: right; color: #77786f; } +.elements-card > p { margin: 22px 0 0; padding-top: 15px; border-top: 1px solid var(--line); color: #7c7d75; font-size: 10px; line-height: 1.7; } +.insight-list { display: grid; gap: 16px; } +.insight-list > div { display: grid; grid-template-columns: 28px 1fr; gap: 12px; align-items: start; } +.insight-list b { color: #dec48f; font-size: 9px; padding-top: 5px; } +.insight-list p { margin: 0; font-family: var(--serif); color: rgba(255,255,255,.78); font-size: 12px; line-height: 1.8; } + +.technical-chart { padding: 10px clamp(20px, 8vw, 128px) 110px; background: var(--paper); } +.luck-header { max-width: 1120px; margin: 46px auto 14px; display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); background: rgba(255,255,255,.32); } +.luck-header > div { min-height: 86px; display: flex; flex-direction: column; align-items: center; justify-content: center; border-right: 1px solid var(--line); } +.luck-header > div:last-child { border: 0; } +.luck-header small { color: var(--ink-soft); font-size: 9px; letter-spacing: .08em; } +.luck-header b { margin-top: 6px; font-family: var(--serif); font-size: 15px; } +.luck-header i { margin-top: 4px; color: #929086; font-size: 8px; font-style: normal; } +.luck-panel, .relation-panel, .shensha-panel { max-width: 1120px; margin: 14px auto 0; padding: 27px; border: 1px solid var(--line); background: #ece4d5; } +.technical-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 23px; } +.technical-title > div { display: flex; align-items: center; gap: 10px; } +.technical-title > div > span { color: var(--vermilion); font-size: 9px; } +.technical-title h3 { margin: 0; font-family: var(--serif); font-size: 18px; } +.technical-title > small { color: var(--ink-soft); font-size: 9px; } +.decade-track { display: flex; gap: 9px; overflow-x: auto; padding: 2px 1px 9px; scrollbar-color: rgba(181,55,44,.45) transparent; } +.decade-card { flex: 0 0 132px; min-height: 155px; padding: 17px 13px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px solid var(--line); background: rgba(255,255,255,.45); position: relative; cursor: pointer; font-family: inherit; color: inherit; } +.decade-card > small { color: var(--ink-soft); font-size: 9px; } +.decade-card > b { margin: 9px 0 7px; font-family: var(--serif); color: var(--vermilion-dark); font-size: 25px; letter-spacing: .1em; } +.decade-card > span { font-size: 9px; color: #565950; } +.decade-card > i { margin-top: 8px; color: #929086; font-size: 8px; font-style: normal; } +.decade-card.current em { position: absolute; right: 6px; top: 6px; padding: 2px 5px; background: var(--vermilion); color: white; font-size: 8px; font-style: normal; } +.decade-card.selected { border-color: var(--vermilion); box-shadow: inset 0 -3px var(--vermilion); } +.annual-track { display: grid; grid-template-columns: repeat(10, minmax(70px, 1fr)); overflow-x: auto; } +.annual-track > div { min-width: 70px; padding: 15px 5px; display: flex; flex-direction: column; align-items: center; border-right: 1px solid var(--line); background: rgba(255,255,255,.28); } +.annual-track > div:last-child { border: 0; } +.annual-track small, .annual-track span { color: var(--ink-soft); font-size: 8px; } +.annual-track b { margin: 6px 0; font-family: var(--serif); font-size: 17px; } +.annual-track > div.active { color: white; background: var(--vermilion-dark); } +.annual-track > div.active small, .annual-track > div.active span { color: rgba(255,255,255,.62); } +.technical-grid { max-width: 1120px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; position: relative; } +.technical-grid > .relation-panel, +.technical-grid > .shensha-panel { width: 100%; min-width: 0; margin: 14px 0 0; } +.relation-list { display: grid; gap: 8px; } +.relation-list > div { min-height: 47px; padding: 10px 12px; display: grid; grid-template-columns: 88px 1fr; align-items: center; gap: 10px; background: rgba(255,255,255,.45); border-left: 3px solid #8b693a; } +.relation-list > div.good { border-color: #477854; }.relation-list > div.alert { border-color: #b5372c; }.relation-list > div.warn { border-color: #80658d; } +.relation-list b { font-family: var(--serif); font-size: 11px; } +.relation-list span { color: var(--ink-soft); font-size: 9px; line-height: 1.5; } +.empty-technical { color: var(--ink-soft); font-size: 11px; line-height: 1.8; } +.shensha-list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-height: 330px; overflow-y: auto; padding-right: 3px; } +.shensha-list > div { display: grid; grid-template-columns: 26px 1fr; gap: 9px; align-items: start; padding: 11px; background: rgba(255,255,255,.45); } +.shensha-list > div > span { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid; border-radius: 50%; font-family: var(--serif); font-size: 9px; } +.shensha-list .star-吉 > span { color: #477854; }.shensha-list .star-凶 > span { color: #b5372c; }.shensha-list .star-中性 > span { color: #8b693a; } +.shensha-list b { display: block; font-family: var(--serif); font-size: 11px; } +.shensha-list small { display: block; margin-top: 4px; color: var(--ink-soft); font-size: 8px; line-height: 1.55; } +.technical-note { max-width: 1120px; margin: 12px auto 0; color: #858278; font-size: 9px; text-align: right; } + +.premium-report { background: var(--ink); color: #f7f1e4; padding: 100px clamp(20px, 8vw, 128px) 115px; position: relative; } +.premium-report::before { content: '命'; position: absolute; left: -30px; top: -60px; color: rgba(255,255,255,.025); font-family: var(--serif); font-size: 340px; font-weight: 900; pointer-events: none; } +.light-heading > span { color: #c8aa76; } +.light-heading p { color: rgba(255,255,255,.53); } +.chapter-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; max-width: 1120px; margin: 48px auto 0; } +.chapter { min-height: 270px; position: relative; overflow: hidden; padding: 28px; border: 1px solid rgba(255,255,255,.12); background: #1d1f1a; } +.chapter-title { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; } +.chapter-title > span { width: 35px; height: 35px; border: 1px solid rgba(200,170,118,.5); display: grid; place-items: center; color: #c8aa76; font-family: var(--serif); } +.chapter-title > div { flex: 1; } +.chapter-title small { color: rgba(255,255,255,.38); font-size: 8px; letter-spacing: .12em; } +.chapter-title h3 { margin: 4px 0 0; font-family: var(--serif); font-size: 17px; } +.chapter-title svg { color: rgba(255,255,255,.25); } +.chapter-summary { position: relative; z-index: 1; margin: 23px 0 13px; color: rgba(255,255,255,.72); font-family: var(--serif); font-size: 12px; line-height: 1.8; } +.chapter ul { position: relative; z-index: 1; margin: 0; padding: 0; list-style: none; display: grid; gap: 9px; } +.chapter li { display: flex; gap: 7px; align-items: flex-start; color: rgba(255,255,255,.5); font-size: 10px; line-height: 1.55; } +.chapter li svg { flex: 0 0 auto; color: #c8aa76; margin-top: 1px; } +.chapter.locked .chapter-summary, .chapter.locked ul { filter: blur(5px); user-select: none; } +.chapter .veil { position: absolute; inset: 65px 0 0; background: #1d1f1a; z-index: 2; } +.chapter.unlocked { animation: chapterReveal .5s ease both; } +.unlock-panel { max-width: 800px; margin: 42px auto 0; padding: 30px; display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: center; border: 1px solid rgba(200,170,118,.38); background: #24251f; box-shadow: 0 30px 70px rgba(0,0,0,.28); } +.unlock-icon { width: 47px; height: 47px; display: grid; place-items: center; color: #c8aa76; border: 1px solid rgba(200,170,118,.38); border-radius: 50%; } +.unlock-panel > div:nth-child(2) > span { color: #c8aa76; font-size: 9px; letter-spacing: .18em; } +.unlock-panel h3 { margin: 6px 0 5px; font-family: var(--serif); font-size: 18px; } +.unlock-panel p { margin: 0; color: rgba(255,255,255,.43); font-size: 10px; } +.price { text-align: right; min-width: 100px; } +.price small { display: block; color: rgba(255,255,255,.4); font-size: 9px; } +.price b { display: block; font-size: 29px; color: #ead6ad; } +.price sup { font-size: 13px; } +.price del { color: rgba(255,255,255,.32); font-size: 10px; } +.unlock-panel button { grid-column: 2 / 4; } +.unlock-panel .safe-pay { grid-column: 2 / 4; display: flex; align-items: center; justify-content: center; gap: 5px; } + +.how-section { padding: 120px clamp(24px, 8vw, 128px); background: #e8dfcf; } +.how-section > .section-heading { max-width: 720px; } +.method-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 60px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.method-grid article { min-height: 280px; padding: 38px 34px; border-right: 1px solid var(--line); position: relative; } +.method-grid article:first-child { border-left: 1px solid var(--line); } +.method-grid article > span { position: absolute; right: 22px; top: 20px; font-family: var(--serif); color: rgba(181,55,44,.25); font-size: 38px; } +.method-grid svg { color: var(--vermilion); width: 25px; height: 25px; } +.method-grid h3 { margin: 48px 0 13px; font-family: var(--serif); font-size: 20px; } +.method-grid p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.9; } + +.offer-section { display: grid; grid-template-columns: 1.2fr .8fr; background: var(--vermilion-dark); color: #fff; padding: 100px clamp(24px, 10vw, 150px); gap: clamp(60px, 10vw, 150px); align-items: center; position: relative; } +.offer-copy { display: grid; grid-template-columns: auto 1fr; align-items: start; gap: 30px; } +.vertical-mark { writing-mode: vertical-rl; border: 1px solid rgba(255,255,255,.25); padding: 13px 10px; color: rgba(255,255,255,.62); font-family: var(--serif); letter-spacing: .32em; font-size: 11px; } +.offer-card { background: var(--paper); color: var(--ink); padding: 35px; box-shadow: 18px 18px 0 rgba(36,9,7,.18); } +.offer-tag { color: var(--vermilion); font-size: 10px; letter-spacing: .15em; } +.offer-price { display: flex; align-items: end; gap: 18px; margin: 18px 0 24px; padding-bottom: 22px; border-bottom: 1px solid var(--line); } +.offer-price > b { font-size: 44px; font-weight: 600; } +.offer-price sup { font-size: 18px; } +.offer-price > span { display: flex; flex-direction: column; margin-bottom: 7px; } +.offer-price del { color: #99968e; font-size: 11px; } +.offer-price small { color: #666960; font-size: 10px; } +.offer-card ul { list-style: none; margin: 0 0 28px; padding: 0; display: grid; gap: 14px; } +.offer-card li { display: flex; align-items: center; gap: 9px; font-family: var(--serif); font-size: 12px; } +.offer-card li svg { color: var(--vermilion); width: 17px; } +.offer-card > small { display: block; margin-top: 12px; text-align: center; color: #908d85; font-size: 9px; } + +.faq-section { padding: 115px clamp(24px, 14vw, 220px); background: var(--paper); display: grid; grid-template-columns: .6fr 1.4fr; gap: 90px; } +.faq-list { border-top: 1px solid var(--line); } +details { border-bottom: 1px solid var(--line); } +summary { list-style: none; min-height: 74px; display: flex; align-items: center; justify-content: space-between; cursor: pointer; font-family: var(--serif); font-size: 14px; } +summary::-webkit-details-marker { display: none; } +summary span { color: var(--vermilion); font-family: sans-serif; font-size: 20px; transition: transform .2s ease; } +details[open] summary span { transform: rotate(45deg); } +details p { color: var(--ink-soft); font-size: 12px; line-height: 1.9; margin: -8px 35px 24px 0; } + +footer { background: #10120f; color: rgba(255,255,255,.45); min-height: 180px; padding: 42px clamp(24px, 8vw, 128px); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } +.small-seal { color: var(--vermilion); width: 36px; height: 36px; } +footer p { margin: 14px 0 8px; font-family: var(--serif); color: rgba(255,255,255,.72); font-size: 13px; letter-spacing: .18em; } +footer > span { font-size: 9px; line-height: 1.7; } + +.modal-backdrop { position: fixed; inset: 0; z-index: 100; background: rgba(8,9,7,.82); backdrop-filter: blur(7px); display: grid; place-items: center; padding: 20px; animation: fadeIn .2s ease; } +.checkout-modal { width: min(460px, 100%); background: var(--paper); padding: 38px; position: relative; text-align: center; box-shadow: 0 40px 100px rgba(0,0,0,.42); } +.modal-close { position: absolute; right: 15px; top: 15px; border: 0; background: transparent; color: #77786f; padding: 5px; } +.modal-close svg { width: 20px; } +.checkout-seal { color: var(--vermilion); margin: 0 auto 15px; } +.modal-kicker { color: var(--vermilion); font-size: 9px; letter-spacing: .2em; } +.checkout-modal h2 { margin: 8px 0 23px; font-family: var(--serif); font-size: 25px; } +.checkout-price { display: flex; justify-content: space-between; align-items: center; padding: 17px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.checkout-price span { color: var(--ink-soft); font-size: 11px; } +.checkout-price b { font-size: 30px; } +.checkout-price sup { font-size: 13px; } +.payment-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 22px 0 15px; } +.payment-options button { min-height: 60px; background: transparent; border: 1px solid var(--line); color: var(--ink-soft); display: flex; align-items: center; justify-content: center; gap: 5px; position: relative; } +.payment-options button span { font-weight: 600; color: var(--ink); } +.payment-options button svg { position: absolute; right: 6px; top: 6px; width: 15px; opacity: 0; } +.payment-options button.selected { border-color: var(--ink); background: rgba(255,255,255,.5); } +.payment-options button.selected svg { opacity: 1; } +.payment-options .stripe.selected svg { color: #635bff; } +.payment-options .binance.selected svg { color: #c99400; } +.email-login { display: grid; gap: 11px; text-align: left; } +.email-login p { margin: 0 0 4px; color: var(--ink-soft); font-size: 11px; line-height: 1.7; } +.email-login input { width: 100%; height: 48px; border: 1px solid #d7cebd; background: white; padding: 0 13px; outline: 0; } +.email-login input:focus { border-color: var(--vermilion); } +.purchased-report-list { display: grid; gap: 7px; margin-bottom: 5px; } +.history-report { display: flex; justify-content: space-between; gap: 10px; padding: 10px 11px; border: 1px solid var(--line); background: rgba(255,255,255,.55); text-align: left; cursor: pointer; } +.history-report:hover { border-color: var(--vermilion); } +.history-report b { color: var(--vermilion-dark); font-size: 10px; } +.history-report span { color: var(--ink-soft); font-size: 9px; } +.demo-notice { margin: 0 0 15px; padding: 10px; background: #fff4db; color: #806525; text-align: left; font-size: 10px; line-height: 1.6; } +.payment-error { color: var(--vermilion); font-size: 10px; } +.checkout-note { display: flex; justify-content: center; align-items: center; gap: 5px; color: #8d8b83; font-size: 9px; margin: 13px 0 0; } +.checkout-note svg { width: 13px; } +.mobile-pay-bar { display: none; } + +.reveal-one { animation: revealUp .7s .08s ease both; } +.reveal-two { animation: revealUp .7s .22s ease both; } +@keyframes revealUp { from { opacity: 0; transform: translateY(22px); } to { opacity: 1; transform: none; } } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes bob { 50% { transform: translateY(5px); } } +@keyframes chapterReveal { from { opacity: .5; transform: translateY(8px); } to { opacity: 1; transform: none; } } + +@media (max-width: 980px) { + header nav { gap: 20px; margin-right: 24px; } + .hero { grid-template-columns: 1fr; padding-top: 145px; } + .hero-copy { text-align: center; margin: 0 auto; } + .eyebrow { justify-content: center; } + .trust-row { justify-content: center; } + .birth-card { justify-self: center; } + .scroll-cue { display: none; } + .chapter-grid { grid-template-columns: repeat(2, 1fr); } + .offer-section { grid-template-columns: 1fr 1fr; gap: 50px; } + .faq-section { grid-template-columns: 1fr; gap: 35px; } +} + +@media (max-width: 720px) { + header { height: 66px; padding: 0 18px; } + .brand b { font-size: 15px; } + .brand small { display: none; } + header .seal { width: 35px; height: 35px; } + header nav { display: none; position: absolute; left: 0; right: 0; top: 66px; margin: 0; padding: 18px; background: #20221d; flex-direction: column; gap: 0; border-top: 1px solid rgba(255,255,255,.1); } + header nav.open { display: flex; } + header nav a { padding: 13px 8px; border-bottom: 1px solid rgba(255,255,255,.08); } + .recovery-link { margin-left: auto; margin-right: 8px; font-size: 9px; } + .header-cta { display: none; } + .menu-button { display: grid; place-items: center; } + .hero { min-height: 0; padding: 110px 18px 75px; gap: 42px; } + .hero-copy { text-align: left; } + .eyebrow { justify-content: flex-start; font-size: 10px; } + .hero h1 { font-size: clamp(48px, 15vw, 68px); margin: 22px 0; } + .hero-lead { font-size: 14px; line-height: 1.85; } + .trust-row { justify-content: flex-start; gap: 12px 18px; margin-top: 26px; padding-top: 22px; } + .trust-row span { width: calc(50% - 9px); font-size: 10px; } + .birth-card { padding: 27px 22px; box-shadow: 10px 13px 0 rgba(0,0,0,.2); } + .field-grid { gap: 8px; } + .card-heading p { font-size: 16px; } + .value-strip { grid-template-columns: 1fr 1fr; padding: 0; } + .value-strip div { min-height: 82px; justify-content: flex-start; padding-left: 23px; border-bottom: 1px solid var(--line); } + .value-strip b { font-size: 23px; } + .value-strip span { font-size: 9px; } + .report-section { padding-top: 78px; } + .section-heading h2 { font-size: 34px; } + .chart-paper { width: calc(100% - 24px); padding: 25px 14px; } + .pillars-grid { margin: 23px auto; } + .pillar { padding: 3px 4px 12px; } + .pillar b { font-size: 36px; } + .pillar i { font-size: 8px; } + .chart-meta { gap: 4px; } + .preview-grid { width: calc(100% - 24px); grid-template-columns: 1fr; margin-bottom: 70px; } + .elements-card, .insight-card { padding: 25px 22px; } + .technical-chart { padding: 0 12px 75px; } + .luck-header { grid-template-columns: 1fr 1fr; } + .luck-header > div { min-height: 70px; border-bottom: 1px solid var(--line); } + .luck-header > div:nth-child(2) { border-right: 0; } + .luck-panel, .relation-panel, .shensha-panel { padding: 21px 16px; } + .technical-title { align-items: flex-start; } + .technical-title > small { max-width: 105px; text-align: right; line-height: 1.5; } + .annual-track { grid-template-columns: repeat(10, 70px); } + .technical-grid { grid-template-columns: 1fr; gap: 0; } + .shensha-list { grid-template-columns: 1fr; max-height: 430px; } + .technical-note { text-align: left; line-height: 1.6; } + .premium-report { padding: 76px 12px 100px; } + .chapter-grid { grid-template-columns: 1fr; gap: 9px; } + .chapter { min-height: 245px; } + .unlock-panel { margin: 25px 2px 0; grid-template-columns: auto 1fr; padding: 23px 18px; } + .unlock-icon { width: 42px; height: 42px; } + .price { grid-column: 1 / 3; text-align: center; } + .price small, .price b, .price del { display: inline-block; margin: 0 3px; } + .unlock-panel button, .unlock-panel .safe-pay { grid-column: 1 / 3; } + .how-section { padding: 80px 18px; } + .method-grid { grid-template-columns: 1fr; margin-top: 40px; } + .method-grid article { min-height: 220px; border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); } + .method-grid article:last-child { border-bottom: 0; } + .offer-section { grid-template-columns: 1fr; padding: 74px 18px 88px; gap: 42px; } + .offer-copy { gap: 18px; } + .vertical-mark { display: none; } + .offer-card { padding: 28px 22px; box-shadow: 9px 10px 0 rgba(36,9,7,.18); } + .faq-section { padding: 78px 18px 110px; } + .checkout-modal { padding: 32px 22px; } + .mobile-pay-bar { position: fixed; left: 9px; right: 9px; bottom: 9px; z-index: 50; min-height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 8px 9px 8px 18px; background: #171915; color: white; box-shadow: 0 14px 45px rgba(0,0,0,.35); } + .mobile-pay-bar > div { display: flex; align-items: baseline; gap: 5px; } + .mobile-pay-bar span { display: block; position: absolute; top: 8px; color: rgba(255,255,255,.48); font-size: 8px; } + .mobile-pay-bar b { font-size: 21px; color: #ead6ad; margin-top: 10px; } + .mobile-pay-bar del { font-size: 9px; color: rgba(255,255,255,.35); } + .mobile-pay-bar button { border: 0; background: var(--vermilion); color: white; height: 46px; min-width: 130px; font-family: var(--serif); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} diff --git a/web-commercial/src/vite-env.d.ts b/web-commercial/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web-commercial/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web-commercial/tsconfig.json b/web-commercial/tsconfig.json new file mode 100644 index 0000000..d0740c5 --- /dev/null +++ b/web-commercial/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { "@lunar/core": ["../web/packages/core/dist/index"] } + }, + "include": ["src"], + "references": [] +} diff --git a/web-commercial/vite.config.ts b/web-commercial/vite.config.ts new file mode 100644 index 0000000..c9c6c03 --- /dev/null +++ b/web-commercial/vite.config.ts @@ -0,0 +1,23 @@ +import { fileURLToPath, URL } from 'node:url'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@lunar/core': fileURLToPath(new URL('../web/packages/core/dist/index.js', import.meta.url)), + }, + }, + server: { + port: 4260, + strictPort: true, + }, + preview: { + port: 4260, + strictPort: true, + }, + build: { + target: 'es2022', + }, +});