Author SHA1 Message Date
gouki deb32f8fab feat(server): 商业化支付与订单系统 + Harness 改进
Build and Publish Server / build (push) Successful in 4m30s
- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理
- 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode
- 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/*
- 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件
- 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例
- 新增 AGENTS.md 项目代理指令文件
- 新增 REMINDER.md 商业化功能开发规范
- 修复 JWT 解析:添加 WithValidMethods 限制 HS256
- 修复 GetPayStatus:增加用户权限校验
- 更新 .gitignore 和 README.md
2026-09-19 18:57:23 +00:00
52 changed files with 5419 additions and 339 deletions
+5
View File
@@ -1,5 +1,7 @@
# 环境配置 # 环境配置
.env
.env.local .env.local
.env.test
.env.*.local .env.*.local
# 密钥和证书 # 密钥和证书
@@ -39,6 +41,7 @@ Thumbs.db
logs/ logs/
# 临时文件 # 临时文件
.playwright-mcp/
tmp/ tmp/
temp/ temp/
.ci-tmp/ .ci-tmp/
@@ -90,3 +93,5 @@ storage/uploads/
# 本地开发 # 本地开发
local/ local/
.local/ .local/
vibe_images/
.qoder/
+80
View File
@@ -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` — 开发提醒(商业化功能规范)
+16
View File
@@ -0,0 +1,16 @@
# 开发提醒
## 2026-09-06:商业化功能不能只完成界面
- 任何标记为“支付已接入”的功能,必须同时具备真实服务商下单、Webhook 验签、订单状态机、幂等处理、用户权益、退款处理和后台查询。
- 演示支付必须明确标注,不能把模拟数据描述成真实支付能力。
- 付费数字内容必须支持跨设备恢复,并保存购买时的不可变快照,避免算法升级后用户看到不同结果。
- 完成页面设计后,应继续验证完整的“登录—下单—支付通知—发放权益—恢复报告—退款撤权”闭环。
- 没有商户凭证时只能声明代码与模拟测试完成,不能声明真实生产收款已经开通。
- 管理后台新增 PC 商业模块后,必须统一品牌名称、页面标题和导航,不能让新旧产品名称混在同一套后台界面里。
- 排盘产品必须保存服务端历史快照;不能只依赖浏览器状态,否则重复排盘、算法升级和跨设备恢复都会产生歧义。
- 基础排盘事实(四柱、起运、刑冲合害、神煞位置)应作为免费层展示,付费点应放在个性化解释而不是隐藏原始数据。
- 交运时间不能直接使用 ChildLimit 的出生区间起点;应显示真正的第一步大运交运时间。
- 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。
- 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。
- 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。
+28
View File
@@ -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=
+76 -85
View File
@@ -1,114 +1,105 @@
# 祈福小助手后端服务 # 知命 / 祈福后端服务
基于 Go + Gin + Inertia.js 的后端服务。 基于 Go、Gin、GORM 的统一后端,提供微信小程序能力、商业版八字报告、邮箱登录、支付、订单权益和管理后台。
## 功能模块 ## 功能模块
- **用户模块** — 微信登录、资料修改、授权管理 - 微信小程序登录与生辰档案。
- **支付模块** — 微信支付集成、订单管理 - Postmark 邮箱验证码登录。
- **许愿树** — 许愿发布、付费许愿、机器人自动许愿 - Stripe `4.99 USD` 报告支付。
- **百科/历史上的今天** — 百科条目后台维护;从中文维基百科同步 366 天「历史上的今天」数据,定时刷新 - Binance Pay `5 USDT` 报告支付。
- **管理后台** — 基于 Inertia.js 的前后端分离管理界面 - 签名 Webhook、幂等事件、退款权益撤销。
- 随机报告恢复码与不可变报告快照。
## 技术栈 - 用户订单与报告权益查询。
- 数据库驱动的 PC 商业版订单列表和收入统计。
- **后端**: Go 1.22 + Gin + GORM - PC 商业版后台只查询 `type=bazi_report`,与小程序许愿等旧业务订单逻辑隔离。
- **前端**: Inertia.js + React/Vue(待选择) - 许愿树与百科数据。
- **数据库**: MySQL
- **缓存**: Redis
## 快速开始 ## 快速开始
### 开发环境
```bash ```bash
# 安装依赖 cp .env.example .env.local
go mod download go mod download
go run ./cmd
# 启动开发服务器
./scripts/dev.sh
``` ```
### 生产构建 默认地址:`http://localhost:8080`。
生产部署可使用 systemd 直接运行二进制,商业前端静态文件放在服务目录的 `frontend/`,根路径 `/` 为商业站点,`/admin/orders` 为管理后台,`/api/*` 为后端接口。这样无需 Docker 或 Nginx,适合小内存服务器;Cloudflare 可在边缘终结 HTTPS 并回源到 80 端口。
## 测试
项目包含 HTTP API 测试,覆盖未认证拦截、Webhook 拒绝、Stripe 模拟下单、签名回调、权益发放、恢复码和快照读取。
```bash ```bash
# 构建 go test ./...
./scripts/build.sh
# 运行
./bin/lunar-server
``` ```
## 项目结构 测试环境文件为本地 `server/.env.test`,不提交任何真实密钥。
``` ## 商业支付 API
server/
├── cmd/ # 入口文件
│ └── main.go
├── internal/ # 内部包
│ ├── config/ # 配置
│ ├── handler/ # 处理器
│ ├── middleware/ # 中间件
│ ├── model/ # 数据模型
│ └── service/ # 业务逻辑
├── web/ # Inertia.js 前端
│ ├── src/
│ └── public/
├── migrations/ # 数据库迁移
├── scripts/ # 脚本
└── go.mod
```
## 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` — 创建订单 除 Webhook 外,商业接口都要求 `Authorization: Bearer <JWT>`。
- `POST /api/pay/notify` — 支付回调
- `GET /api/pay/status/:orderId` — 查询支付状态
### 订单接口 ## 管理后台
- `GET /api/order/list` — 订单列表 - `/admin/orders` — 真实订单列表。
- `GET /api/order/detail/:id` — 订单详情 - `/admin/api/orders` — 按渠道、状态、订单号、邮箱、报告号查询。
- `POST /api/order/cancel/:id` — 取消订单 - `/admin/api/orders/stats` — 订单和收入统计。
### 许愿接口 后台使用 `ADMIN_PASSWORD` 登录并写入 HttpOnly Cookie。
- `GET /api/wish/tree` — 获取许愿树 ## 支付安全
- `POST /api/wish/create` — 创建许愿
- `GET /api/wish/list` — 许愿列表
- `DELETE /api/wish/:id` — 删除许愿
### 百科接口 - 金额只从 `report_products` 读取。
- Stripe 使用 Checkout Session 和 `Idempotency-Key`。
- `GET /api/wiki/entries` — 百科条目列表(支持 category/keyword 过滤) - Stripe Webhook 校验 HMAC 签名与 5 分钟时间窗口。
- `GET /api/wiki/on-this-day?month=&day=` — 历史上的今天(大事记/出生/逝世/节假日习俗) - Binance Pay 下单使用 HMAC-SHA512。
- Binance Pay Webhook 使用商户证书 RSA 公钥验签。
### 管理后台接口(/admin,需管理员认证) - Webhook 同时校验服务商订单号、金额与币种。
- `payment_events` 唯一索引保证重复回调不会重复发放权益。
- `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 页,只抓中文版)。
## 数据库 ## 数据库
执行 `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。
+28 -3
View File
@@ -68,9 +68,12 @@ func main() {
// 中间件 // 中间件
r.Use(middleware.CORS()) 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.Static("/static", "./web/static")
r.StaticFile("/", "./web/index.html")
r.LoadHTMLFiles("./web/index.html") r.LoadHTMLFiles("./web/index.html")
// 内嵌图片资源(许愿树背景图等) // 内嵌图片资源(许愿树背景图等)
@@ -84,6 +87,24 @@ func main() {
// 版本信息(无需认证) // 版本信息(无需认证)
api.GET("/version", handler.GetVersion) 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") user := api.Group("/user")
{ {
@@ -103,7 +124,7 @@ func main() {
{ {
pay.POST("/create", middleware.Auth(), handler.CreateOrder) pay.POST("/create", middleware.Auth(), handler.CreateOrder)
pay.POST("/notify", handler.PayNotify) 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("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers) authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders) authed.GET("/orders", handler.AdminOrders)
authed.GET("/charts", handler.AdminCharts)
authed.GET("/wishes", handler.AdminWishes) authed.GET("/wishes", handler.AdminWishes)
authed.GET("/wiki", handler.AdminWiki) authed.GET("/wiki", handler.AdminWiki)
authed.GET("/settings", handler.AdminSettings) authed.GET("/settings", handler.AdminSettings)
@@ -159,6 +181,9 @@ func main() {
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete) authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus) authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger) 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.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus)
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger) authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
+55 -7
View File
@@ -6,13 +6,17 @@ import (
) )
type Config struct { type Config struct {
Server ServerConfig Server ServerConfig
Database DatabaseConfig Database DatabaseConfig
Redis RedisConfig Redis RedisConfig
JWT JWTConfig JWT JWTConfig
Wechat WechatConfig Wechat WechatConfig
Admin AdminConfig Stripe StripeConfig
Wiki WikiConfig BinancePay BinancePayConfig
Postmark PostmarkConfig
Commerce CommerceConfig
Admin AdminConfig
Wiki WikiConfig
} }
type ServerConfig struct { type ServerConfig struct {
@@ -48,6 +52,30 @@ type WechatConfig struct {
MchID string 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 管理后台配置 // AdminConfig 管理后台配置
type AdminConfig struct { type AdminConfig struct {
// Password 管理员登录密码;为空时禁用后台登录 // Password 管理员登录密码;为空时禁用后台登录
@@ -92,6 +120,26 @@ func Load() *Config {
PayKey: getEnv("WECHAT_PAY_APIKEY", ""), PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
MchID: getEnv("WECHAT_PAY_MCHID", ""), 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{ Admin: AdminConfig{
Password: getEnv("ADMIN_PASSWORD", ""), Password: getEnv("ADMIN_PASSWORD", ""),
}, },
+15 -1
View File
@@ -5,9 +5,9 @@ import (
"log" "log"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/gouki/lunar-server/internal/model"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/gouki/lunar-server/internal/model"
) )
var DB *gorm.DB var DB *gorm.DB
@@ -56,6 +56,12 @@ func autoMigrate() error {
&model.BirthProfile{}, &model.BirthProfile{},
&model.Order{}, &model.Order{},
&model.OrderItem{}, &model.OrderItem{},
&model.ReportProduct{},
&model.ReportEntitlement{},
&model.ReportSnapshot{},
&model.PaymentEvent{},
&model.EmailLoginCode{},
&model.BaziChartRecord{},
&model.WishTree{}, &model.WishTree{},
&model.Wish{}, &model.Wish{},
&model.WishProduct{}, &model.WishProduct{},
@@ -78,6 +84,14 @@ func autoMigrate() error {
// seedDefaultData 初始化默认数据 // seedDefaultData 初始化默认数据
func seedDefaultData() error { 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{}). if err := DB.Model(&model.WishTree{}).
Where("image = '' OR image IS NULL"). Where("image = '' OR image IS NULL").
+7 -2
View File
@@ -92,9 +92,14 @@ func AdminUsers(c *gin.Context) {
adminPage(c, "用户管理", "users") adminPage(c, "用户管理", "users")
} }
// AdminOrders 订单管理 // AdminOrders PC 商业版订单管理
func AdminOrders(c *gin.Context) { func AdminOrders(c *gin.Context) {
adminPage(c, "订单管理", "orders") adminPage(c, "PC 报告订单", "orders")
}
// AdminCharts 排盘记录
func AdminCharts(c *gin.Context) {
adminPage(c, "排盘记录", "charts")
} }
// AdminWishes 许愿管理 // AdminWishes 许愿管理
+272
View File
@@ -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})
}
+157
View File
@@ -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())
}
}
+50
View File
@@ -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}})
}
+2 -1
View File
@@ -183,6 +183,7 @@ func PayNotify(c *gin.Context) {
// GetPayStatus 获取支付状态(支持订单号或订单 ID) // GetPayStatus 获取支付状态(支持订单号或订单 ID)
func GetPayStatus(c *gin.Context) { func GetPayStatus(c *gin.Context) {
param := c.Param("orderId") param := c.Param("orderId")
userID, _ := c.Get("userID")
orderService := service.NewOrderService() orderService := service.NewOrderService()
order, err := orderService.GetOrderByOrderNo(param) order, err := orderService.GetOrderByOrderNo(param)
@@ -192,7 +193,7 @@ func GetPayStatus(c *gin.Context) {
order, err = orderService.GetOrderByID(uint(id)) order, err = orderService.GetOrderByID(uint(id))
} }
} }
if err != nil { if err != nil || order.UserID != userID.(uint) {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"code": 404, "code": 404,
"msg": "订单不存在", "msg": "订单不存在",
+25 -1
View File
@@ -3,6 +3,8 @@ package middleware
import ( import (
"net/http" "net/http"
"strings" "strings"
"sync"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config" "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 限流中间件 // RateLimit 限流中间件
func RateLimit() gin.HandlerFunc { func RateLimit() gin.HandlerFunc {
return func(c *gin.Context) { 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() c.Next()
} }
} }
@@ -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")
}
}
+74
View File
@@ -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:"-"`
}
+24 -15
View File
@@ -6,19 +6,28 @@ import (
// Order 订单模型 // Order 订单模型
type Order struct { type Order struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号 OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号
UserID uint `gorm:"index" json:"userId"` UserID uint `gorm:"index" json:"userId"`
Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员 Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员
ProductID uint `json:"productId"` ProductID uint `json:"productId"`
ProductName string `gorm:"size:100" json:"productName"` ProductName string `gorm:"size:100" json:"productName"`
Amount int `json:"amount"` // 金额(分) Amount int `json:"amount"` // 金额(分)
Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款 Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
PayTime *time.Time `json:"payTime"` Provider string `gorm:"size:30;index" json:"provider"`
ExpireTime *time.Time `json:"expireTime"` ProviderAmount string `gorm:"size:32" json:"providerAmount"`
Remark string `gorm:"size:255" json:"remark"` ProviderOrderID string `gorm:"size:128;index" json:"providerOrderId"`
CreatedAt time.Time `json:"createdAt"` ProviderTransactionID string `gorm:"size:128;index" json:"providerTransactionId"`
UpdatedAt time.Time `json:"updatedAt"` 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 订单项 // OrderItem 订单项
@@ -27,6 +36,6 @@ type OrderItem struct {
OrderID uint `gorm:"index" json:"orderId"` OrderID uint `gorm:"index" json:"orderId"`
ProductID uint `json:"productId"` ProductID uint `json:"productId"`
Name string `gorm:"size:100" json:"name"` Name string `gorm:"size:100" json:"name"`
Price int `json:"price"` // 单价(分) Price int `json:"price"` // 单价(分)
Quantity int `json:"quantity"` // 数量 Quantity int `json:"quantity"` // 数量
} }
+30
View File
@@ -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
}
+330
View File
@@ -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
}
+175
View File
@@ -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("<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:auto;padding:32px\"><h2>知命登录验证码</h2><p>你的验证码是:</p><p style=\"font-size:32px;letter-spacing:8px;font-weight:bold\">%s</p><p>10 分钟内有效。若非本人操作,请忽略此邮件。</p></div>", 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
}
+336
View File
@@ -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
}
@@ -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)
}
}
+123
View File
@@ -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
}
+2 -2
View File
@@ -116,7 +116,7 @@ func (s *UserService) GenerateToken(userID uint, secret string) (string, error)
func (s *UserService) ParseToken(tokenString, secret string) (uint, error) { func (s *UserService) ParseToken(tokenString, secret string) (uint, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil return []byte(secret), nil
}) }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil { if err != nil {
return 0, err return 0, err
@@ -156,7 +156,7 @@ func (s *UserService) GenerateAdminToken(secret string) (string, error) {
func (s *UserService) IsAdminToken(tokenString, secret string) bool { func (s *UserService) IsAdminToken(tokenString, secret string) bool {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil return []byte(secret), nil
}) }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil { if err != nil {
return false return false
} }
+94
View File
@@ -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);
+2 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .title }} - 祈福小助手管理后台</title> <title>{{ .title }} - 知命 PC 商业版后台</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Vue + Inertia 通过 import map 从 esm.sh 加载(ESM,自动解析依赖树,国内可访问) --> <!-- Vue + Inertia 通过 import map 从 esm.sh 加载(ESM,自动解析依赖树,国内可访问) -->
@@ -30,6 +30,7 @@
dashboard: () => import('/static/js/pages/Dashboard.js'), dashboard: () => import('/static/js/pages/Dashboard.js'),
users: () => import('/static/js/pages/Users.js'), users: () => import('/static/js/pages/Users.js'),
orders: () => import('/static/js/pages/Orders.js'), orders: () => import('/static/js/pages/Orders.js'),
charts: () => import('/static/js/pages/Charts.js'),
wishes: () => import('/static/js/pages/Wishes.js'), wishes: () => import('/static/js/pages/Wishes.js'),
wiki: () => import('/static/js/pages/Wiki.js'), wiki: () => import('/static/js/pages/Wiki.js'),
settings: () => import('/static/js/pages/Settings.js'), settings: () => import('/static/js/pages/Settings.js'),
+32
View File
@@ -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 `
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">知命 PC 商业版后台</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
${links.map(([key, href, label]) => `
<a href="${href}" class="${key === active ? 'border-red-500 text-gray-900' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
${label}
</a>
`).join('')}
</div>
</div>
</div>
</div>
</nav>
`;
}
+57
View File
@@ -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: `
<div class="min-h-screen bg-gray-100">
${adminHeader('charts')}
<main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div class="bg-white shadow rounded-lg">
<div class="p-5 border-b flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
<div><h2 class="text-lg font-medium">排盘记录</h2><p class="text-xs text-gray-500 mt-1">共 {{ total }} 次排盘,保存免费排盘快照,付费报告另行关联</p></div>
<div class="flex gap-2"><input v-model="search" @keyup.enter="load" class="border rounded px-3 py-2 text-sm" placeholder="报告编号 / 姓名 / 城市" /><button @click="load" class="bg-red-700 text-white rounded px-4 py-2 text-sm">查询</button></div>
</div>
<div v-if="error" class="m-5 p-3 bg-red-50 text-red-700 text-sm">{{ error }}</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs text-gray-500">报告编号</th><th class="px-4 py-3 text-left text-xs text-gray-500">称呼</th><th class="px-4 py-3 text-left text-xs text-gray-500">出生信息</th><th class="px-4 py-3 text-left text-xs text-gray-500">城市</th><th class="px-4 py-3 text-left text-xs text-gray-500">记录时间</th></tr></thead>
<tbody class="divide-y divide-gray-100">
<tr v-if="loading"><td colspan="5" class="p-10 text-center text-sm text-gray-500">正在加载...</td></tr>
<tr v-else-if="records.length === 0"><td colspan="5" class="p-10 text-center text-sm text-gray-500">暂无排盘记录</td></tr>
<tr v-for="record in records" :key="record.id" class="hover:bg-gray-50"><td class="px-4 py-4 text-xs font-medium">{{ record.reportId }}</td><td class="px-4 py-4 text-sm">{{ record.name || '-' }}</td><td class="px-4 py-4 text-sm">{{ record.birthDate }} {{ record.birthTime }} / {{ record.gender }}</td><td class="px-4 py-4 text-sm">{{ record.city }}</td><td class="px-4 py-4 text-xs text-gray-500">{{ new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) }}</td></tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
`,
};
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Dashboard 页面组件 // Dashboard 页面组件
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('dashboard')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0"> <div class="px-4 py-6 sm:px-0">
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
<div class="min-h-screen bg-gray-100 flex items-center justify-center px-4"> <div class="min-h-screen bg-gray-100 flex items-center justify-center px-4">
<div class="max-w-sm w-full bg-white rounded-lg shadow-md p-8"> <div class="max-w-sm w-full bg-white rounded-lg shadow-md p-8">
<div class="text-center mb-6"> <div class="text-center mb-6">
<h1 class="text-2xl font-bold text-red-600">祈福小助手</h1> <h1 class="text-2xl font-bold text-red-600">知命 PC 商业版后台</h1>
<p class="text-gray-500 text-sm mt-1">管理后台登录</p> <p class="text-gray-500 text-sm mt-1">管理后台登录</p>
<p class="text-gray-300 text-xs mt-2">v2026.08.12-login</p> <p class="text-gray-300 text-xs mt-2">v2026.08.12-login</p>
</div> </div>
+85 -65
View File
@@ -1,74 +1,94 @@
// Orders 页面组件 import { onMounted, ref } from 'vue';
import { adminHeader } from './AdminHeader.js';
export default { 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: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('orders')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<div class="flex"> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">报告订单</p><b class="text-2xl">{{ stats.totalOrders }}</b></div>
<div class="flex-shrink-0 flex items-center"> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">已支付</p><b class="text-2xl text-green-700">{{ stats.paidOrders }}</b></div>
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">待支付</p><b class="text-2xl text-amber-700">{{ stats.pendingOrders }}</b></div>
</div> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">已退款</p><b class="text-2xl text-blue-700">{{ stats.refundedOrders }}</b></div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8"> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">Stripe 收入</p><b class="text-2xl">\${{ (stats.revenueUsdCents / 100).toFixed(2) }}</b></div>
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> </div>
仪表盘 <div class="bg-white shadow rounded-lg">
</a> <div class="p-5 border-b flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <div><h2 class="text-lg font-medium">PC 八字报告订单</h2><p class="text-xs text-gray-500 mt-1">仅显示 PC 商业版八字报告订单,共 {{ total }} 笔</p></div>
用户管理 <div class="flex flex-wrap gap-2">
</a> <input v-model="filters.search" @keyup.enter="load" class="border rounded px-3 py-2 text-sm" placeholder="订单号 / 邮箱 / 报告号" />
<a href="/admin/orders" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <select v-model="filters.provider" @change="load" class="border rounded px-3 py-2 text-sm"><option value="">全部渠道</option><option value="stripe">Stripe</option><option value="binance_pay">Binance Pay</option></select>
订单管理 <select v-model="filters.status" @change="load" class="border rounded px-3 py-2 text-sm"><option value="">全部状态</option><option value="pending">待支付</option><option value="paid">已支付</option><option value="cancelled">已关闭</option><option value="refunded">已退款</option></select>
</a> <button @click="load" class="bg-red-700 text-white rounded px-4 py-2 text-sm">查询</button>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div> </div>
</div> </div>
</div> <div v-if="error" class="m-5 p-3 bg-red-50 text-red-700 text-sm">{{ error }}</div>
</nav> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs text-gray-500">订单 / 报告</th><th class="px-4 py-3 text-left text-xs text-gray-500">客户</th><th class="px-4 py-3 text-left text-xs text-gray-500">渠道</th><th class="px-4 py-3 text-left text-xs text-gray-500">金额</th><th class="px-4 py-3 text-left text-xs text-gray-500">状态</th><th class="px-4 py-3 text-left text-xs text-gray-500">渠道交易号</th><th class="px-4 py-3 text-left text-xs text-gray-500">时间</th></tr></thead>
<div class="px-4 py-6 sm:px-0"> <tbody class="divide-y divide-gray-100">
<div class="bg-white shadow rounded-lg"> <tr v-if="loading"><td colspan="7" class="p-10 text-center text-sm text-gray-500">正在加载订单...</td></tr>
<div class="px-4 py-5 sm:p-6"> <tr v-else-if="orders.length === 0"><td colspan="7" class="p-10 text-center text-sm text-gray-500">暂无符合条件的订单</td></tr>
<h2 class="text-lg font-medium text-gray-900 mb-4">订单列表</h2> <tr v-for="order in orders" :key="order.id" class="hover:bg-gray-50">
<div class="overflow-x-auto"> <td class="px-4 py-4 text-xs"><b class="block text-gray-900">{{ order.orderNo }}</b><span class="block text-gray-500">{{ order.reportId || '-' }}</span><code v-if="order.accessCode" class="block mt-1 text-red-700">{{ order.accessCode }}</code></td>
<table class="min-w-full divide-y divide-gray-200"> <td class="px-4 py-4 text-sm">{{ order.customerEmail || '-' }}</td>
<thead class="bg-gray-50"> <td class="px-4 py-4 text-sm">{{ providerText(order.provider) }}</td>
<tr> <td class="px-4 py-4 text-sm font-medium">{{ money(order) }}</td>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">订单号</th> <td class="px-4 py-4"><span :class="statusClass(order.status)" class="px-2 py-1 rounded-full text-xs font-medium">{{ statusText(order.status) }}</span></td>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th> <td class="px-4 py-4 text-xs text-gray-500 max-w-xs truncate" :title="order.providerTransactionId">{{ order.providerTransactionId || order.providerOrderId || '-' }}</td>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">商品</th> <td class="px-4 py-4 text-xs text-gray-500"><span class="block">创建 {{ dateTime(order.createdAt) }}</span><span class="block">支付 {{ dateTime(order.payTime) }}</span></td>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额</th> </tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th> </tbody>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th> </table>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">L20240101001</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">许愿</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">普通许愿条</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">¥1.00</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">已支付</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01 12:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
</div> </div>
` `,
} };
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Settings 页面组件 // Settings 页面组件
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('settings')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0"> <div class="px-4 py-6 sm:px-0">
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Users 页面组件 // Users 页面组件
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('users')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0"> <div class="px-4 py-6 sm:px-0">
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步 // Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('wiki')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<!-- 标签页切换 --> <!-- 标签页切换 -->
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Wishes 页面组件 // Wishes 页面组件
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('wishes')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0"> <div class="px-4 py-6 sm:px-0">
+2
View File
@@ -0,0 +1,2 @@
VITE_PAYMENT_MODE=demo
VITE_API_BASE_URL=http://localhost:8080
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local
.env.*.local
+64
View File
@@ -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` 实现四柱、五行与神煞计算。
- 新增免费命盘、六章付费报告遮罩与响应式视觉系统。
+164
View File
@@ -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 <user-token>
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 地址。
- 补充隐私政策、用户协议、退款规则、公司与客服信息。
- 根据香港当地要求确认虚拟资产收款、税务和消费者保护义务。
+62
View File
@@ -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`。
本项目属于传统文化研究与娱乐内容,不构成医疗、法律、投资或人生决策建议。
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#161713" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23b5372c'/%3E%3Ctext x='32' y='44' text-anchor='middle' font-size='38' fill='white'%3E命%3C/text%3E%3C/svg%3E" />
<meta name="description" content="基于传统干支历法的八字排盘与个人命理解读,免费查看命盘,按需解锁完整报告。" />
<title>知命 · 八字精批</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -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"
}
}
+1172
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false
+708
View File
@@ -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<string, number> = {
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<BirthForm>(DEFAULT_FORM);
const [report, setReport] = useState<ReportState | null>(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<HTMLElement>(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<ReportState>(accessCode, token);
localStorage.setItem(`lunar-report:${reportId}`, 'unlocked');
setReport(recovered.snapshot);
setIsUnlocked(true);
setPaymentNotice(`支付已确认。你的报告恢复码是 ${accessCode},请妥善保存。`);
}).catch(() => setPaymentNotice('暂时无法读取付费报告,请稍后重新打开报告。'));
}, []);
const calculate = (event: FormEvent<HTMLFormElement>) => {
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 = <K extends keyof BirthForm>(key: K, value: BirthForm[K]) => {
setForm(current => ({ ...current, [key]: value }));
};
return (
<div className="site-shell">
<Header showMenu={showMenu} onToggleMenu={() => setShowMenu(value => !value)} onRecovery={() => setShowRecovery(true)} />
<main>
{paymentNotice && <div className="payment-notice"><CheckCircle2 />{paymentNotice}<button onClick={() => setPaymentNotice('')} aria-label="关闭提示"><X /></button></div>}
<section className="hero" id="top">
<div className="hero-orbit orbit-one">甲</div>
<div className="hero-orbit orbit-two">子</div>
<div className="hero-copy reveal-one">
<div className="eyebrow"><span />东方命理 · 私人解读</div>
<h1>看懂命盘,<br /><em>更懂自己。</em></h1>
<p className="hero-lead">输入出生信息,免费查看四柱与五行。完整报告将性格、事业、财富、关系与大运,整理成一份可以反复阅读的人生说明书。</p>
<div className="trust-row">
<span><ShieldCheck size={17} /> 本地计算保护隐私</span>
<span><WalletCards size={17} /> 一次购买永久回看</span>
<span><HeartHandshake size={17} /> 无自动续费</span>
</div>
</div>
<form className="birth-card reveal-two" onSubmit={calculate}>
<div className="card-heading">
<div><span className="step-number">01</span><p>填写你的出生信息</p></div>
<span className="free-chip">基础排盘免费</span>
</div>
<label className="field full-field">
<span>怎么称呼你</span>
<input value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="可不填,仅用于报告称呼" maxLength={12} />
</label>
<div className="field-grid">
<label className="field">
<span>出生日期</span>
<input required type="date" min="1901-01-01" max="2100-12-31" value={form.date} onChange={event => updateForm('date', event.target.value)} />
</label>
<label className="field">
<span>出生时间</span>
<input required type="time" value={form.time} onChange={event => updateForm('time', event.target.value)} />
</label>
</div>
<div className="field-grid">
<fieldset className="field segmented-field">
<legend>性别</legend>
<div className="segment-control">
<button type="button" className={form.gender === 'female' ? 'active' : ''} onClick={() => updateForm('gender', 'female')}>女</button>
<button type="button" className={form.gender === 'male' ? 'active' : ''} onClick={() => updateForm('gender', 'male')}>男</button>
</div>
</fieldset>
<label className="field select-field">
<span>出生城市</span>
<MapPin size={16} />
<select value={form.city} onChange={event => updateForm('city', event.target.value)}>
{CITIES.map(([city]) => <option value={city} key={city}>{city}</option>)}
</select>
<ChevronDown size={15} />
</label>
</div>
<button className="primary-action" type="submit" disabled={isCalculating}>
{isCalculating ? <><span className="loading-mark" /> 正在推演命盘...</> : <>免费开始排盘 <ArrowRight size={19} /></>}
</button>
<p className="form-note"><Fingerprint size={14} /> 出生资料仅在当前设备参与计算,不会公开展示</p>
</form>
<a className="scroll-cue" href="#how"><span>了解报告</span><ArrowDown size={16} /></a>
</section>
<ValueStrip />
{report && (
<Report
refElement={resultRef}
report={report}
name={form.name}
isUnlocked={isUnlocked}
onUnlock={() => setShowCheckout(true)}
/>
)}
<HowItWorks />
<Offer onStart={() => document.querySelector<HTMLButtonElement>('.primary-action')?.scrollIntoView({ behavior: 'smooth', block: 'center' })} />
<Faq />
</main>
<footer>
<div className="seal small-seal">知命</div>
<p>知命 · 东方命理研习所</p>
<span>传统文化研究与娱乐参考,不构成医疗、法律、投资或人生决策建议。</span>
</footer>
{report && showCheckout && (
<Checkout
reportId={report.id}
snapshot={report}
onClose={() => setShowCheckout(false)}
onPaid={(paidReport) => {
if (paidReport) setReport(paidReport);
localStorage.setItem(`lunar-report:${report.id}`, 'unlocked');
setIsUnlocked(true);
setShowCheckout(false);
}}
/>
)}
{showRecovery && (
<RecoveryModal
onClose={() => 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) && <MobilePayBar onClick={() => setShowCheckout(true)} />}
</div>
);
}
function Header({ showMenu, onToggleMenu, onRecovery }: { showMenu: boolean; onToggleMenu: () => void; onRecovery: () => void }) {
return (
<header>
<a className="brand" href="#top"><span className="seal">知命</span><span><b>知命</b><small>东方命理研习所</small></span></a>
<nav className={showMenu ? 'open' : ''}>
<a href="#top">首页</a><a href="#how">解读方式</a><a href="#offer">完整报告</a><a href="#faq">常见问题</a>
</nav>
<button className="recovery-link" onClick={onRecovery}>找回已购报告</button>
<a className="header-cta" href="#top">免费排盘</a>
<button className="menu-button" onClick={onToggleMenu} aria-label="切换导航">{showMenu ? <X /> : <Menu />}</button>
</header>
);
}
function ValueStrip() {
return (
<section className="value-strip" aria-label="服务特点">
<div><b>四柱</b><span>年月日时精准排盘</span></div>
<div><b>五行</b><span>强弱结构清晰呈现</span></div>
<div><b>六章</b><span>完整人生主题解读</span></div>
<div><b>十运</b><span>长期阶段节奏梳理</span></div>
</section>
);
}
function Report({ refElement, report, name, isUnlocked, onUnlock }: { refElement: React.RefObject<HTMLElement | null>; 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 (
<section className="report-section" ref={refElement}>
<div className="section-heading centered">
<span>YOUR CHART</span>
<h2>{name ? `${name}的` : '你的'}先天命盘</h2>
<p>报告编号 {report.id} · {report.result.solarAdjusted ? '已按出生城市经度校正时间' : '按输入的标准时间排盘'}</p>
</div>
<div className="chart-paper">
<div className="paper-topline"><span>乾坤有序 · 四时有信</span><strong>日主 {chart.dayMaster}</strong></div>
<div className="pillars-grid">
{PILLARS.map(([label, key], index) => {
const pillar = chart[key];
return (
<div className={`pillar ${index === 2 ? 'day-pillar' : ''}`} key={key}>
<span>{label}</span>
<small>{pillar.tenStar ?? '日主'}</small>
<b className={`element-${pillar.elementStem}`}>{pillar.heavenStem}</b>
<b className={`element-${pillar.elementBranch}`}>{pillar.earthBranch}</b>
<i>{pillar.nayin}</i>
</div>
);
})}
</div>
<div className="chart-meta">
<span>命宫 <b>{chart.ownSign}</b></span><span>身宫 <b>{chart.bodySign}</b></span><span>胎元 <b>{chart.fetalOrigin}</b></span><span>空亡 <b>{chart.emptyBranches.join('')}</b></span>
</div>
</div>
<div className="preview-grid">
<article className="elements-card">
<div className="mini-heading"><span>02</span><h3>五行能量</h3><i>免费查看</i></div>
<div className="element-list">
{ELEMENTS.map(([label, key]) => {
const value = getElementValue(report.elements, key);
const percent = Math.max(8, Math.round((value / report.elements.total) * 100));
return <div className="element-row" key={key}><span className={`element-dot ${key}`}>{label}</span><div><i style={{ width: `${percent}%` }} /></div><b>{value.toFixed(1)}</b></div>;
})}
</div>
<p>五行计算包含四柱天干、地支与藏干权重,结果用于观察结构倾向。</p>
</article>
<article className="insight-card">
<div className="mini-heading light"><span>03</span><h3>命盘初读</h3><i>已解锁</i></div>
<div className="insight-list">
{freeInsights.map((insight, index) => <div key={insight}><b>0{index + 1}</b><p>{insight}</p></div>)}
</div>
</article>
</div>
<TechnicalChart key={report.id} report={report} />
<div className="premium-report">
<div className="section-heading centered light-heading">
<span>FULL READING</span>
<h2>{premiumLoaded ? '完整命理解读' : '还有更重要的部分'}</h2>
<p>{premiumLoaded ? '六大人生主题已全部解锁,可随时回来阅读。' : '命盘告诉你“是什么”,完整解读帮你理解“如何用”。'}</p>
</div>
<div className="chapter-grid">
{visibleSections.map((section, index) => (
<article className={premiumLoaded ? 'chapter unlocked' : 'chapter locked'} key={section.index}>
<div className="chapter-title"><span>{section.index}</span><div><small>{section.kicker}</small><h3>{section.title}</h3></div>{!premiumLoaded && <LockKeyhole size={18} />}</div>
<p className="chapter-summary">{section.summary}</p>
<ul>{section.details.map(detail => <li key={detail}><Check size={14} />{detail}</li>)}</ul>
{!premiumLoaded && <div className="veil" style={{ opacity: Math.min(0.92, 0.58 + index * 0.055) }} />}
</article>
))}
</div>
{!premiumLoaded && (
<div className="unlock-panel">
<div className="unlock-icon"><Eye size={23} /></div>
<div><span>一次解锁 · 永久回看</span><h3>你的完整命理精批报告</h3><p>性格、事业、财富、感情、身心与十步大运,共 6 大主题。</p></div>
<div className="price"><small>完整报告</small><b><sup>$</sup>4.99</b><del>$9.99</del></div>
<button onClick={onUnlock}>立即解锁完整报告 <ArrowRight size={18} /></button>
<p className="safe-pay"><ShieldCheck size={14} /> 无订阅、无自动续费 · 支付后当前设备永久查看</p>
</div>
)}
</div>
</section>
);
}
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 (
<section className="technical-chart">
<div className="section-heading centered"><span>TRADITIONAL CHART</span><h2>命盘细节与运程</h2><p>排盘数据免费完整展示,付费部分只负责深度解释。</p></div>
<div className="luck-header">
<div><small>起运年龄</small><b>约 {approxStartAge} 岁</b><i>{childLimit.yearCount}年{childLimit.monthCount}月{childLimit.dayCount}天后</i></div>
<div><small>顺逆</small><b>{childLimit.forward ? '顺行' : '逆行'}</b></div>
<div><small>交运时间</small><b>{childLimit.startTime}</b></div>
<div><small>当前日主</small><b>{chart.dayMaster}</b></div>
</div>
<article className="luck-panel">
<div className="technical-title"><div><span>04</span><h3>大运 · 十年一步</h3></div><small>点击大运查看对应流年</small></div>
<div className="decade-track">
{fortunes.map((item, index) => {
const current = currentYear >= item.startYear && currentYear <= item.endYear;
const selected = index === selectedIndex;
return <button type="button" className={`decade-card${selected ? ' selected' : ''}${current ? ' current' : ''}`} key={item.index} onClick={() => setSelectedIndex(index)}><small>{item.startAge}—{item.endAge}岁</small><b>{item.ganzhi}</b><span>{item.analysis.tenStar || '比肩'} · {item.analysis.elementRelation}</span><i>{item.startYear}—{item.endYear}</i>{current && <em>当前</em>}</button>;
})}
</div>
</article>
<article className="luck-panel annual-panel">
<div className="technical-title"><div><span>05</span><h3>流年 · {selectedFortune.ganzhi}({selectedFortune.startYear}—{selectedFortune.endYear})</h3></div><small>小运以出生后逐年干支观察</small></div>
<div className="annual-track">
{selectedFortune.annualFortunes.map(item => <div className={item.year === currentYear ? 'active' : ''} key={item.year}><small>{item.age}岁</small><b>{item.ganzhi}</b><span>{item.year}</span></div>)}
</div>
</article>
<div className="technical-grid">
<article className="relation-panel">
<div className="technical-title"><div><span>06</span><h3>刑冲合害</h3></div><small>四柱内部关系</small></div>
<div className="relation-list">
{relationships.length ? relationships.map(item => <div className={item.tone} key={`${item.label}-${item.detail}`}><b>{item.label}</b><span>{item.detail}</span></div>) : <p className="empty-technical">四柱之间未见主要的合、冲、刑、害关系。</p>}
</div>
</article>
<article className="shensha-panel">
<div className="technical-title"><div><span>07</span><h3>神煞</h3></div><small>共命中 {report.shensha.length} 项</small></div>
<div className="shensha-list">
{report.shensha.map(item => <div className={`star-${item.type}`} key={item.name}><span>{item.type}</span><div><b>{item.name}</b><small>{item.foundIn.join('、')} · {item.description}</small></div></div>)}
</div>
</article>
</div>
<p className="technical-note">神煞流派版本较多,本页面采用常见排盘规则,仅作辅助观察,不单独作为判断依据。</p>
</section>
);
}
function HowItWorks() {
return (
<section className="how-section" id="how">
<div className="section-heading"><span>THE METHOD</span><h2>不是一句判词,<br />而是一套理解自己的方法。</h2></div>
<div className="method-grid">
<article><span>01</span><Clock3 /><h3>校准出生时刻</h3><p>结合出生城市经度进行真太阳时校正,避免地区时差影响时柱。</p></article>
<article><span>02</span><Sparkles /><h3>拆解命盘结构</h3><p>从四柱、十神、藏干、五行与神煞,多层观察先天倾向。</p></article>
<article><span>03</span><Eye /><h3>翻译成行动建议</h3><p>不制造恐惧,不下绝对结论,把传统术语整理成可理解的生活语言。</p></article>
</div>
</section>
);
}
function Offer({ onStart }: { onStart: () => void }) {
return (
<section className="offer-section" id="offer">
<div className="offer-copy"><span className="vertical-mark">知 命 而 行</span><div className="section-heading light-heading"><span>YOUR PRIVATE REPORT</span><h2>人生没有标准答案,<br />但可以多一份清醒。</h2><p>先免费查看自己的命盘。确认内容对你有价值,再决定是否解锁完整报告。</p></div></div>
<div className="offer-card">
<span className="offer-tag">单份完整报告</span><div className="offer-price"><b><sup>$</sup>4.99</b><span><del>$9.99</del><small>或使用 5 USDT · 永久回看</small></span></div>
<ul><li><CheckCircle2 /> 四柱、十神、藏干与五行详盘</li><li><CheckCircle2 /> 性格、事业、财富、关系、身心</li><li><CheckCircle2 /> 十步大运阶段路线</li><li><CheckCircle2 /> 无订阅,不自动续费</li></ul>
<button onClick={onStart}>先免费排盘,再决定 <ArrowRight /></button>
<small>传统文化研究与娱乐参考,请理性看待</small>
</div>
</section>
);
}
function Faq() {
const items = [
['不知道准确出生时间怎么办?', '可以先选择大致时辰查看,但时柱会影响结果。若完全不确定,建议向家人核实出生证明后再购买完整报告。'],
['购买后会自动续费吗?', '不会。完整报告为单次购买,不包含订阅或自动续费。'],
['八字报告能预测具体事件吗?', '不能。报告用于传统文化研究与自我观察,不对具体事件作确定性承诺,也不能代替现实中的专业建议。'],
['我的出生信息会被公开吗?', '不会。当前版本在浏览器本地完成排盘;正式支付接入时也应只保存订单与报告权限所需的最少信息。'],
];
return <section className="faq-section" id="faq"><div className="section-heading"><span>QUESTIONS</span><h2>你可能还想知道</h2></div><div className="faq-list">{items.map(([question, answer]) => <details key={question}><summary>{question}<span>+</span></summary><p>{answer}</p></details>)}</div></section>;
}
function Checkout({ reportId, snapshot, onClose, onPaid }: { reportId: string; snapshot: ReportState; onClose: () => void; onPaid: (paidReport?: ReportState) => void }) {
const [channel, setChannel] = useState<PaymentChannel>('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<ReportState>(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 (
<div className="modal-backdrop" role="presentation" onMouseDown={event => event.currentTarget === event.target && onClose()}>
<div className="checkout-modal" role="dialog" aria-modal="true" aria-labelledby="checkout-title">
<button className="modal-close" onClick={onClose} aria-label="关闭"><X /></button>
<div className="seal checkout-seal">知命</div><span className="modal-kicker">解锁完整报告</span><h2 id="checkout-title">一次支付,永久回看</h2>
{needsLogin ? (
<div className="email-login">
<p>先验证邮箱,用于保存订单并跨设备找回报告。</p>
<input type="email" value={email} onChange={event => setEmail(event.target.value)} placeholder="你的邮箱" autoComplete="email" />
{codeSent && <input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" autoComplete="one-time-code" />}
<button className="pay-button" onClick={codeSent ? login : sendCode} disabled={isPaying || !email || (codeSent && code.length !== 6)}>{isPaying ? '请稍候...' : codeSent ? '验证并继续支付' : '发送邮箱验证码'}</button>
</div>
) : (
<>
<div className="checkout-price"><span>应付金额</span><b>{channel === 'stripe' ? <><sup>$</sup>4.99</> : <>5 <sup>USDT</sup></>}</b></div>
<div className="payment-options">
<button className={channel === 'stripe' ? 'selected stripe' : ''} onClick={() => setChannel('stripe')}><span>银行卡</span>Stripe<CheckCircle2 /></button>
<button className={channel === 'binance_pay' ? 'selected binance' : ''} onClick={() => setChannel('binance_pay')}><span>USDT</span>Binance Pay<CheckCircle2 /></button>
</div>
{isDemoPayment && <p className="demo-notice">当前为产品演示模式,点击后模拟支付成功,不会产生真实扣款。</p>}
<button className="pay-button" onClick={pay} disabled={isPaying}>{isPaying ? '正在创建安全订单...' : isDemoPayment ? '模拟支付并解锁' : channel === 'stripe' ? '前往 Stripe 支付 $4.99' : '前往 Binance Pay 支付 5 USDT'}</button>
</>
)}
{error && <p className="payment-error">{error}</p>}
<p className="checkout-note"><ShieldCheck /> 服务商签名回调确认到账 · 无自动续费</p>
</div>
</div>
);
}
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<Array<{ reportId: string; accessCode: string; productName: string; paidAt: string }>>([]);
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<ReportState>(accessCode, token);
localStorage.setItem(`lunar-report:${recovered.reportId}`, 'unlocked');
onRecovered(recovered.snapshot);
} catch (reason) { setError(reason instanceof Error ? reason.message : '没有找到这份报告'); }
finally { setBusy(false); }
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={event => event.currentTarget === event.target && onClose()}>
<div className="checkout-modal" role="dialog" aria-modal="true" aria-labelledby="recovery-title">
<button className="modal-close" onClick={onClose} aria-label="关闭"><X /></button>
<div className="seal checkout-seal">再阅</div><span className="modal-kicker">已购报告恢复</span><h2 id="recovery-title">找回当时的完整报告</h2>
{!token ? (
<div className="email-login">
<p>先验证购买时使用的邮箱,再输入报告恢复码。</p>
<input type="email" value={email} onChange={event => setEmail(event.target.value)} placeholder="购买邮箱" autoComplete="email" />
{codeSent && <input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" />}
<button className="pay-button" onClick={codeSent ? login : sendCode} disabled={busy || !email || (codeSent && code.length !== 6)}>{busy ? '请稍候...' : codeSent ? '验证邮箱' : '发送邮箱验证码'}</button>
</div>
) : (
<div className="email-login">
{purchasedReports.length > 0 && <div className="purchased-report-list"><p>你的历史报告</p>{purchasedReports.map(item => <button type="button" className="history-report" key={item.accessCode} onClick={() => setAccessCode(item.accessCode)}><b>{item.reportId}</b><span>{new Date(item.paidAt).toLocaleDateString('zh-CN')} · {item.productName}</span></button>)}</div>}
<p>恢复码格式示例:ZM-ABCD-EFGH-JKLM</p>
<input value={accessCode} onChange={event => setAccessCode(event.target.value.toUpperCase().replace(/[^A-Z2-7-]/g, '').slice(0, 20))} placeholder="输入报告恢复码" autoComplete="off" />
<button className="pay-button" onClick={recover} disabled={busy || accessCode.length < 17}>{busy ? '正在读取报告...' : '打开已购报告'}</button>
</div>
)}
{error && <p className="payment-error">{error}</p>}
<p className="checkout-note"><ShieldCheck /> 恢复码与购买邮箱必须同时匹配</p>
</div>
</div>
);
}
function MobilePayBar({ onClick }: { onClick: () => void }) {
return <div className="mobile-pay-bar"><div><span>完整报告</span><b>$4.99</b><del>$9.99</del></div><button onClick={onClick}>立即解锁</button></div>;
}
export default App;
+10
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>,
);
+106
View File
@@ -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<T> {
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<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${apiBaseURL}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
});
const payload = await response.json().catch(() => null) as ApiPayload<T> | null;
if (!response.ok || !payload || payload.code !== 0) throw new Error(payload?.msg ?? '服务暂时不可用');
return payload.data;
}
export async function requestEmailCode(email: string): Promise<void> {
await apiRequest<never>('/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<void> {
await apiRequest<never>('/api/commerce/charts', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function verifyEmailCode(email: string, code: string): Promise<string> {
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<CheckoutResponse> {
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<boolean> {
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<string | null> {
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<Array<{ reportId: string; accessCode: string; productName: string; paidAt: string }>> {
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<T>(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}` },
});
}
+85
View File
@@ -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<string, { gift: string; caution: string; field: string; rhythm: string }> = {
木: { gift: '成长力、规划感与同理心', caution: '容易把长期目标变成眼前压力', field: '教育、内容、设计与长期经营', rhythm: '先建立根基,再持续放大优势' },
火: { gift: '表达力、行动力与感染力', caution: '节奏过快时容易透支专注', field: '传播、品牌、销售与创新项目', rhythm: '抓住窗口,但为决策保留冷静期' },
土: { gift: '稳定性、承诺感与资源整合力', caution: '求稳过度时会错过变化', field: '管理、运营、资产与服务业', rhythm: '稳中求变,用小步试错替代停滞' },
金: { gift: '判断力、边界感与执行标准', caution: '标准过高时容易显得紧绷', field: '技术、金融、法律与精细管理', rhythm: '先定规则,再给关系留出弹性' },
水: { gift: '洞察力、适应力与信息敏感度', caution: '选择过多时容易分散心力', field: '研究、咨询、贸易与流动型工作', rhythm: '聚焦一个主航道,让变化服务目标' },
};
const SUPPORTING_ELEMENT: Record<string, string> = { 木: '水', 火: '木', 土: '火', 金: '土', 水: '金' };
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}。`, `完整报告保留十步大运时间轴,方便长期回看。`],
},
];
}
+506
View File
@@ -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; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+23
View File
@@ -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": []
}
+23
View File
@@ -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',
},
});