- 新增商业化模块: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
This commit is contained in:
@@ -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
@@ -1,114 +1,105 @@
|
||||
# 祈福小助手后端服务
|
||||
# 知命 / 祈福后端服务
|
||||
|
||||
基于 Go + Gin + Inertia.js 的后端服务。
|
||||
基于 Go、Gin、GORM 的统一后端,提供微信小程序能力、商业版八字报告、邮箱登录、支付、订单权益和管理后台。
|
||||
|
||||
## 功能模块
|
||||
|
||||
- **用户模块** — 微信登录、资料修改、授权管理
|
||||
- **支付模块** — 微信支付集成、订单管理
|
||||
- **许愿树** — 许愿发布、付费许愿、机器人自动许愿
|
||||
- **百科/历史上的今天** — 百科条目后台维护;从中文维基百科同步 366 天「历史上的今天」数据,定时刷新
|
||||
- **管理后台** — 基于 Inertia.js 的前后端分离管理界面
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Go 1.22 + Gin + GORM
|
||||
- **前端**: Inertia.js + React/Vue(待选择)
|
||||
- **数据库**: MySQL
|
||||
- **缓存**: Redis
|
||||
- 微信小程序登录与生辰档案。
|
||||
- Postmark 邮箱验证码登录。
|
||||
- Stripe `4.99 USD` 报告支付。
|
||||
- Binance Pay `5 USDT` 报告支付。
|
||||
- 签名 Webhook、幂等事件、退款权益撤销。
|
||||
- 随机报告恢复码与不可变报告快照。
|
||||
- 用户订单与报告权益查询。
|
||||
- 数据库驱动的 PC 商业版订单列表和收入统计。
|
||||
- PC 商业版后台只查询 `type=bazi_report`,与小程序许愿等旧业务订单逻辑隔离。
|
||||
- 许愿树与百科数据。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
cp .env.example .env.local
|
||||
go mod download
|
||||
|
||||
# 启动开发服务器
|
||||
./scripts/dev.sh
|
||||
go run ./cmd
|
||||
```
|
||||
|
||||
### 生产构建
|
||||
默认地址:`http://localhost:8080`。
|
||||
|
||||
生产部署可使用 systemd 直接运行二进制,商业前端静态文件放在服务目录的 `frontend/`,根路径 `/` 为商业站点,`/admin/orders` 为管理后台,`/api/*` 为后端接口。这样无需 Docker 或 Nginx,适合小内存服务器;Cloudflare 可在边缘终结 HTTPS 并回源到 80 端口。
|
||||
|
||||
## 测试
|
||||
|
||||
项目包含 HTTP API 测试,覆盖未认证拦截、Webhook 拒绝、Stripe 模拟下单、签名回调、权益发放、恢复码和快照读取。
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
./scripts/build.sh
|
||||
|
||||
# 运行
|
||||
./bin/lunar-server
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
测试环境文件为本地 `server/.env.test`,不提交任何真实密钥。
|
||||
|
||||
```
|
||||
server/
|
||||
├── cmd/ # 入口文件
|
||||
│ └── main.go
|
||||
├── internal/ # 内部包
|
||||
│ ├── config/ # 配置
|
||||
│ ├── handler/ # 处理器
|
||||
│ ├── middleware/ # 中间件
|
||||
│ ├── model/ # 数据模型
|
||||
│ └── service/ # 业务逻辑
|
||||
├── web/ # Inertia.js 前端
|
||||
│ ├── src/
|
||||
│ └── public/
|
||||
├── migrations/ # 数据库迁移
|
||||
├── scripts/ # 脚本
|
||||
└── go.mod
|
||||
```
|
||||
## 商业支付 API
|
||||
|
||||
## API 接口
|
||||
### 邮箱登录
|
||||
|
||||
### 用户接口
|
||||
- `POST /api/auth/email/request`
|
||||
- `POST /api/auth/email/verify`
|
||||
|
||||
- `POST /api/user/login` — 用户登录
|
||||
- `POST /api/user/logout` — 用户登出
|
||||
- `GET /api/user/profile` — 获取用户资料
|
||||
- `PUT /api/user/profile` — 更新用户资料
|
||||
- `POST /api/user/auth` — 微信授权
|
||||
### 支付与报告
|
||||
|
||||
### 支付接口
|
||||
- `POST /api/commerce/checkout` — 创建 Stripe 或 Binance Pay 订单。
|
||||
- `POST /api/commerce/webhooks/stripe` — Stripe 签名通知。
|
||||
- `POST /api/commerce/webhooks/binance-pay` — Binance Pay 签名通知。
|
||||
- `GET /api/commerce/orders/:orderNo` — 查询本人订单。
|
||||
- `GET /api/commerce/reports/:reportId/access` — 查询报告权益。
|
||||
- `GET /api/commerce/reports` — 本人已购报告。
|
||||
- `GET /api/commerce/reports/recover/:accessCode` — 使用恢复码读取购买时快照。
|
||||
|
||||
- `POST /api/pay/create` — 创建订单
|
||||
- `POST /api/pay/notify` — 支付回调
|
||||
- `GET /api/pay/status/:orderId` — 查询支付状态
|
||||
除 Webhook 外,商业接口都要求 `Authorization: Bearer <JWT>`。
|
||||
|
||||
### 订单接口
|
||||
## 管理后台
|
||||
|
||||
- `GET /api/order/list` — 订单列表
|
||||
- `GET /api/order/detail/:id` — 订单详情
|
||||
- `POST /api/order/cancel/:id` — 取消订单
|
||||
- `/admin/orders` — 真实订单列表。
|
||||
- `/admin/api/orders` — 按渠道、状态、订单号、邮箱、报告号查询。
|
||||
- `/admin/api/orders/stats` — 订单和收入统计。
|
||||
|
||||
### 许愿接口
|
||||
后台使用 `ADMIN_PASSWORD` 登录并写入 HttpOnly Cookie。
|
||||
|
||||
- `GET /api/wish/tree` — 获取许愿树
|
||||
- `POST /api/wish/create` — 创建许愿
|
||||
- `GET /api/wish/list` — 许愿列表
|
||||
- `DELETE /api/wish/:id` — 删除许愿
|
||||
## 支付安全
|
||||
|
||||
### 百科接口
|
||||
|
||||
- `GET /api/wiki/entries` — 百科条目列表(支持 category/keyword 过滤)
|
||||
- `GET /api/wiki/on-this-day?month=&day=` — 历史上的今天(大事记/出生/逝世/节假日习俗)
|
||||
|
||||
### 管理后台接口(/admin,需管理员认证)
|
||||
|
||||
- `GET/POST /admin/api/wiki/entries` — 百科条目列表/新增
|
||||
- `PUT/DELETE /admin/api/wiki/entries/:id` — 更新/删除条目
|
||||
- `GET /admin/api/wiki/sync-status` — 维基同步状态
|
||||
- `POST /admin/api/wiki/sync` — 手动触发全量同步
|
||||
|
||||
## 环境变量
|
||||
|
||||
参考 `.env.local` 文件配置。维基同步相关:
|
||||
|
||||
- `WIKI_SYNC_INTERVAL_DAYS` — 自动同步间隔(天),默认 7
|
||||
- `WIKI_SYNC_HOUR` — 自动同步执行的小时点(0-23),默认 4
|
||||
|
||||
同步策略:服务启动时若数据缺失自动补齐;之后每天在指定小时点检查,距上次成功超过间隔天数才全量刷新(共 366 页,只抓中文版)。
|
||||
- 金额只从 `report_products` 读取。
|
||||
- Stripe 使用 Checkout Session 和 `Idempotency-Key`。
|
||||
- Stripe Webhook 校验 HMAC 签名与 5 分钟时间窗口。
|
||||
- Binance Pay 下单使用 HMAC-SHA512。
|
||||
- Binance Pay Webhook 使用商户证书 RSA 公钥验签。
|
||||
- Webhook 同时校验服务商订单号、金额与币种。
|
||||
- `payment_events` 唯一索引保证重复回调不会重复发放权益。
|
||||
- 支付、事件、恢复码和权益在数据库事务中完成。
|
||||
- 退款后报告权益自动撤销。
|
||||
|
||||
## 数据库
|
||||
|
||||
执行 `migrations/001_init.sql` 初始化数据库。
|
||||
新安装由 GORM AutoMigrate 创建表。已有 MySQL 数据库先备份,再单独执行:
|
||||
|
||||
```bash
|
||||
mysql < migrations/002_commerce.sql
|
||||
```
|
||||
|
||||
不要执行数据库 fresh 或 refresh。
|
||||
|
||||
## 环境变量
|
||||
|
||||
完整模板见 `.env.example`。生产环境至少需要:
|
||||
|
||||
- `JWT_SECRET`
|
||||
- `ADMIN_PASSWORD`
|
||||
- `PUBLIC_BASE_URL`
|
||||
- `WEB_APP_URL`
|
||||
- `POSTMARK_SERVER_TOKEN`
|
||||
- `POSTMARK_FROM_EMAIL`
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- `BINANCE_PAY_API_KEY`
|
||||
- `BINANCE_PAY_SECRET_KEY`
|
||||
- `BINANCE_PAY_WEBHOOK_PUBLIC_KEY`
|
||||
|
||||
所有支付与邮件密钥必须通过部署环境注入,禁止提交到 Git。
|
||||
|
||||
+28
-3
@@ -68,9 +68,12 @@ func main() {
|
||||
// 中间件
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
// 静态文件与管理后台模板
|
||||
// 商业版前端静态文件、旧管理后台静态资源与模板
|
||||
// 商业站点部署在根路径;后台仍通过 /admin 访问。
|
||||
r.Static("/assets", "./frontend/assets")
|
||||
r.GET("/index.html", func(c *gin.Context) { c.File("./frontend/index.html") })
|
||||
r.GET("/", func(c *gin.Context) { c.File("./frontend/index.html") })
|
||||
r.Static("/static", "./web/static")
|
||||
r.StaticFile("/", "./web/index.html")
|
||||
r.LoadHTMLFiles("./web/index.html")
|
||||
|
||||
// 内嵌图片资源(许愿树背景图等)
|
||||
@@ -84,6 +87,24 @@ func main() {
|
||||
// 版本信息(无需认证)
|
||||
api.GET("/version", handler.GetVersion)
|
||||
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/email/request", middleware.RateLimit(), handler.RequestEmailLoginCode)
|
||||
auth.POST("/email/verify", middleware.RateLimit(), handler.VerifyEmailLoginCode)
|
||||
}
|
||||
|
||||
commerce := api.Group("/commerce")
|
||||
{
|
||||
commerce.POST("/charts", handler.SaveChartRecord)
|
||||
commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout)
|
||||
commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder)
|
||||
commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess)
|
||||
commerce.GET("/reports", middleware.Auth(), handler.ListPurchasedReports)
|
||||
commerce.GET("/reports/recover/:accessCode", middleware.Auth(), handler.GetReportByAccessCode)
|
||||
commerce.POST("/webhooks/stripe", handler.StripeWebhook)
|
||||
commerce.POST("/webhooks/binance-pay", handler.BinancePayWebhook)
|
||||
}
|
||||
|
||||
// 用户相关
|
||||
user := api.Group("/user")
|
||||
{
|
||||
@@ -103,7 +124,7 @@ func main() {
|
||||
{
|
||||
pay.POST("/create", middleware.Auth(), handler.CreateOrder)
|
||||
pay.POST("/notify", handler.PayNotify)
|
||||
pay.GET("/status/:orderId", handler.GetPayStatus)
|
||||
pay.GET("/status/:orderId", middleware.Auth(), handler.GetPayStatus)
|
||||
}
|
||||
|
||||
// 订单相关
|
||||
@@ -148,6 +169,7 @@ func main() {
|
||||
authed.GET("/", handler.AdminDashboard)
|
||||
authed.GET("/users", handler.AdminUsers)
|
||||
authed.GET("/orders", handler.AdminOrders)
|
||||
authed.GET("/charts", handler.AdminCharts)
|
||||
authed.GET("/wishes", handler.AdminWishes)
|
||||
authed.GET("/wiki", handler.AdminWiki)
|
||||
authed.GET("/settings", handler.AdminSettings)
|
||||
@@ -159,6 +181,9 @@ func main() {
|
||||
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
|
||||
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
|
||||
authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger)
|
||||
authed.GET("/api/orders", handler.AdminCommerceOrders)
|
||||
authed.GET("/api/orders/stats", handler.AdminCommerceStats)
|
||||
authed.GET("/api/charts", handler.AdminChartRecords)
|
||||
// 节气详情同步(百度百科,独立于维基)
|
||||
authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus)
|
||||
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
|
||||
|
||||
@@ -6,13 +6,17 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Redis RedisConfig
|
||||
JWT JWTConfig
|
||||
Wechat WechatConfig
|
||||
Admin AdminConfig
|
||||
Wiki WikiConfig
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Redis RedisConfig
|
||||
JWT JWTConfig
|
||||
Wechat WechatConfig
|
||||
Stripe StripeConfig
|
||||
BinancePay BinancePayConfig
|
||||
Postmark PostmarkConfig
|
||||
Commerce CommerceConfig
|
||||
Admin AdminConfig
|
||||
Wiki WikiConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -48,6 +52,30 @@ type WechatConfig struct {
|
||||
MchID string
|
||||
}
|
||||
|
||||
type StripeConfig struct {
|
||||
SecretKey string
|
||||
WebhookSecret string
|
||||
APIBaseURL string
|
||||
}
|
||||
|
||||
type BinancePayConfig struct {
|
||||
APIKey string
|
||||
SecretKey string
|
||||
APIBaseURL string
|
||||
WebhookPublicKey string
|
||||
}
|
||||
|
||||
type PostmarkConfig struct {
|
||||
ServerToken string
|
||||
FromEmail string
|
||||
APIBaseURL string
|
||||
}
|
||||
|
||||
type CommerceConfig struct {
|
||||
PublicBaseURL string
|
||||
WebAppURL string
|
||||
}
|
||||
|
||||
// AdminConfig 管理后台配置
|
||||
type AdminConfig struct {
|
||||
// Password 管理员登录密码;为空时禁用后台登录
|
||||
@@ -92,6 +120,26 @@ func Load() *Config {
|
||||
PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
|
||||
MchID: getEnv("WECHAT_PAY_MCHID", ""),
|
||||
},
|
||||
Stripe: StripeConfig{
|
||||
SecretKey: getEnv("STRIPE_SECRET_KEY", ""),
|
||||
WebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
|
||||
APIBaseURL: getEnv("STRIPE_API_BASE_URL", "https://api.stripe.com"),
|
||||
},
|
||||
BinancePay: BinancePayConfig{
|
||||
APIKey: getEnv("BINANCE_PAY_API_KEY", ""),
|
||||
SecretKey: getEnv("BINANCE_PAY_SECRET_KEY", ""),
|
||||
APIBaseURL: getEnv("BINANCE_PAY_API_BASE_URL", "https://bpay.binanceapi.com"),
|
||||
WebhookPublicKey: getEnv("BINANCE_PAY_WEBHOOK_PUBLIC_KEY", ""),
|
||||
},
|
||||
Postmark: PostmarkConfig{
|
||||
ServerToken: getEnv("POSTMARK_SERVER_TOKEN", ""),
|
||||
FromEmail: getEnv("POSTMARK_FROM_EMAIL", ""),
|
||||
APIBaseURL: getEnv("POSTMARK_API_BASE_URL", "https://api.postmarkapp.com"),
|
||||
},
|
||||
Commerce: CommerceConfig{
|
||||
PublicBaseURL: getEnv("PUBLIC_BASE_URL", "http://localhost:8080"),
|
||||
WebAppURL: getEnv("WEB_APP_URL", "http://localhost:4260"),
|
||||
},
|
||||
Admin: AdminConfig{
|
||||
Password: getEnv("ADMIN_PASSWORD", ""),
|
||||
},
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/gouki/lunar-server/internal/model"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"github.com/gouki/lunar-server/internal/model"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
@@ -56,6 +56,12 @@ func autoMigrate() error {
|
||||
&model.BirthProfile{},
|
||||
&model.Order{},
|
||||
&model.OrderItem{},
|
||||
&model.ReportProduct{},
|
||||
&model.ReportEntitlement{},
|
||||
&model.ReportSnapshot{},
|
||||
&model.PaymentEvent{},
|
||||
&model.EmailLoginCode{},
|
||||
&model.BaziChartRecord{},
|
||||
&model.WishTree{},
|
||||
&model.Wish{},
|
||||
&model.WishProduct{},
|
||||
@@ -78,6 +84,14 @@ func autoMigrate() error {
|
||||
|
||||
// seedDefaultData 初始化默认数据
|
||||
func seedDefaultData() error {
|
||||
reportProduct := model.ReportProduct{
|
||||
Code: "bazi_full_report", Name: "八字完整精批报告", Description: "六大主题与十步大运完整解读",
|
||||
StripeAmount: 499, StripeCurrency: "USD", BinanceAmount: "5.00", BinanceCurrency: "USDT", Status: 1,
|
||||
}
|
||||
if err := DB.Where("code = ?", reportProduct.Code).FirstOrCreate(&reportProduct).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 补充已有许愿树的背景图(新增字段后老数据为空)
|
||||
if err := DB.Model(&model.WishTree{}).
|
||||
Where("image = '' OR image IS NULL").
|
||||
|
||||
@@ -92,9 +92,14 @@ func AdminUsers(c *gin.Context) {
|
||||
adminPage(c, "用户管理", "users")
|
||||
}
|
||||
|
||||
// AdminOrders 订单管理
|
||||
// AdminOrders PC 商业版订单管理
|
||||
func AdminOrders(c *gin.Context) {
|
||||
adminPage(c, "订单管理", "orders")
|
||||
adminPage(c, "PC 报告订单", "orders")
|
||||
}
|
||||
|
||||
// AdminCharts 排盘记录
|
||||
func AdminCharts(c *gin.Context) {
|
||||
adminPage(c, "排盘记录", "charts")
|
||||
}
|
||||
|
||||
// AdminWishes 许愿管理
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
@@ -183,6 +183,7 @@ func PayNotify(c *gin.Context) {
|
||||
// GetPayStatus 获取支付状态(支持订单号或订单 ID)
|
||||
func GetPayStatus(c *gin.Context) {
|
||||
param := c.Param("orderId")
|
||||
userID, _ := c.Get("userID")
|
||||
|
||||
orderService := service.NewOrderService()
|
||||
order, err := orderService.GetOrderByOrderNo(param)
|
||||
@@ -192,7 +193,7 @@ func GetPayStatus(c *gin.Context) {
|
||||
order, err = orderService.GetOrderByID(uint(id))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err != nil || order.UserID != userID.(uint) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"code": 404,
|
||||
"msg": "订单不存在",
|
||||
|
||||
@@ -3,6 +3,8 @@ package middleware
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gouki/lunar-server/internal/config"
|
||||
@@ -146,10 +148,32 @@ func AdminAuth() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
var rateLimitStore = struct {
|
||||
sync.Mutex
|
||||
requests map[string][]time.Time
|
||||
}{requests: make(map[string][]time.Time)}
|
||||
|
||||
// RateLimit 限流中间件
|
||||
func RateLimit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// TODO: 实现限流
|
||||
now := time.Now()
|
||||
key := c.ClientIP() + "|" + c.FullPath()
|
||||
rateLimitStore.Lock()
|
||||
windowStart := now.Add(-time.Minute)
|
||||
recent := rateLimitStore.requests[key][:0]
|
||||
for _, requestTime := range rateLimitStore.requests[key] {
|
||||
if requestTime.After(windowStart) {
|
||||
recent = append(recent, requestTime)
|
||||
}
|
||||
}
|
||||
if len(recent) >= 10 {
|
||||
rateLimitStore.requests[key] = recent
|
||||
rateLimitStore.Unlock()
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 429, "msg": "请求过于频繁"})
|
||||
return
|
||||
}
|
||||
rateLimitStore.requests[key] = append(recent, now)
|
||||
rateLimitStore.Unlock()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -6,19 +6,28 @@ import (
|
||||
|
||||
// Order 订单模型
|
||||
type Order struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号
|
||||
UserID uint `gorm:"index" json:"userId"`
|
||||
Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员
|
||||
ProductID uint `json:"productId"`
|
||||
ProductName string `gorm:"size:100" json:"productName"`
|
||||
Amount int `json:"amount"` // 金额(分)
|
||||
Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款
|
||||
PayTime *time.Time `json:"payTime"`
|
||||
ExpireTime *time.Time `json:"expireTime"`
|
||||
Remark string `gorm:"size:255" json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号
|
||||
UserID uint `gorm:"index" json:"userId"`
|
||||
Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员
|
||||
ProductID uint `json:"productId"`
|
||||
ProductName string `gorm:"size:100" json:"productName"`
|
||||
Amount int `json:"amount"` // 金额(分)
|
||||
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
|
||||
Provider string `gorm:"size:30;index" json:"provider"`
|
||||
ProviderAmount string `gorm:"size:32" json:"providerAmount"`
|
||||
ProviderOrderID string `gorm:"size:128;index" json:"providerOrderId"`
|
||||
ProviderTransactionID string `gorm:"size:128;index" json:"providerTransactionId"`
|
||||
CustomerEmail string `gorm:"size:254;index" json:"customerEmail"`
|
||||
ReportID string `gorm:"size:64;index" json:"reportId"`
|
||||
AccessCode *string `gorm:"size:20;uniqueIndex" json:"accessCode,omitempty"`
|
||||
CheckoutURL string `gorm:"size:1024" json:"checkoutUrl"`
|
||||
Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款
|
||||
PayTime *time.Time `json:"payTime"`
|
||||
ExpireTime *time.Time `json:"expireTime"`
|
||||
Remark string `gorm:"size:255" json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// OrderItem 订单项
|
||||
@@ -27,6 +36,6 @@ type OrderItem struct {
|
||||
OrderID uint `gorm:"index" json:"orderId"`
|
||||
ProductID uint `json:"productId"`
|
||||
Name string `gorm:"size:100" json:"name"`
|
||||
Price int `json:"price"` // 单价(分)
|
||||
Quantity int `json:"quantity"` // 数量
|
||||
Price int `json:"price"` // 单价(分)
|
||||
Quantity int `json:"quantity"` // 数量
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func (s *UserService) GenerateToken(userID uint, secret string) (string, error)
|
||||
func (s *UserService) ParseToken(tokenString, secret string) (uint, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -156,7 +156,7 @@ func (s *UserService) GenerateAdminToken(secret string) (string, error) {
|
||||
func (s *UserService) IsAdminToken(tokenString, secret string) bool {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
<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,自动解析依赖树,国内可访问) -->
|
||||
@@ -30,6 +30,7 @@
|
||||
dashboard: () => import('/static/js/pages/Dashboard.js'),
|
||||
users: () => import('/static/js/pages/Users.js'),
|
||||
orders: () => import('/static/js/pages/Orders.js'),
|
||||
charts: () => import('/static/js/pages/Charts.js'),
|
||||
wishes: () => import('/static/js/pages/Wishes.js'),
|
||||
wiki: () => import('/static/js/pages/Wiki.js'),
|
||||
settings: () => import('/static/js/pages/Settings.js'),
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
};
|
||||
@@ -1,38 +1,10 @@
|
||||
import { adminHeader } from './AdminHeader.js';
|
||||
|
||||
// Dashboard 页面组件
|
||||
export default {
|
||||
template: `
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<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">祈福小助手</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>
|
||||
${adminHeader('dashboard')}
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
<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="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-300 text-xs mt-2">v2026.08.12-login</p>
|
||||
</div>
|
||||
|
||||
@@ -1,74 +1,94 @@
|
||||
// Orders 页面组件
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { adminHeader } from './AdminHeader.js';
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const orders = ref([]);
|
||||
const stats = ref({ totalOrders: 0, paidOrders: 0, pendingOrders: 0, refundedOrders: 0, revenueUsdCents: 0 });
|
||||
const total = ref(0);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const filters = ref({ status: '', provider: '', search: '' });
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
const params = new URLSearchParams({ page: '1', pageSize: '100' });
|
||||
Object.entries(filters.value).forEach(([key, value]) => value && params.set(key, value));
|
||||
try {
|
||||
const [orderResponse, statsResponse] = await Promise.all([
|
||||
fetch('/admin/api/orders?' + params.toString(), { credentials: 'same-origin' }),
|
||||
fetch('/admin/api/orders/stats', { credentials: 'same-origin' }),
|
||||
]);
|
||||
if (!orderResponse.ok || !statsResponse.ok) throw new Error('加载订单失败');
|
||||
const orderPayload = await orderResponse.json();
|
||||
const statsPayload = await statsResponse.json();
|
||||
orders.value = orderPayload.data.list || [];
|
||||
total.value = orderPayload.data.total || 0;
|
||||
stats.value = statsPayload.data;
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '加载订单失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const money = order => order.currency === 'USD'
|
||||
? '$' + (order.amount / 100).toFixed(2)
|
||||
: order.providerAmount + ' ' + order.currency;
|
||||
const dateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||
const statusText = status => ({ pending: '待支付', paid: '已支付', cancelled: '已关闭', refunded: '已退款' }[status] || status);
|
||||
const statusClass = status => ({
|
||||
paid: 'bg-green-100 text-green-800', pending: 'bg-amber-100 text-amber-800',
|
||||
cancelled: 'bg-gray-100 text-gray-700', refunded: 'bg-blue-100 text-blue-800',
|
||||
}[status] || 'bg-gray-100 text-gray-700');
|
||||
const providerText = provider => ({ stripe: 'Stripe', binance_pay: 'Binance Pay' }[provider] || provider || '-');
|
||||
|
||||
onMounted(load);
|
||||
return { orders, stats, total, loading, error, filters, load, money, dateTime, statusText, statusClass, providerText };
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<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">祈福小助手</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-red-500 text-gray-900 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>
|
||||
${adminHeader('orders')}
|
||||
<main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||
<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="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>
|
||||
<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 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="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>
|
||||
</div>
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="p-5 border-b flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
|
||||
<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">
|
||||
<input v-model="filters.search" @keyup.enter="load" class="border rounded px-3 py-2 text-sm" placeholder="订单号 / 邮箱 / 报告号" />
|
||||
<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>
|
||||
<button @click="load" class="bg-red-700 text-white rounded px-4 py-2 text-sm">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<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="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">订单列表</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">订单号</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">商品</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
|
||||
</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 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><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="7" class="p-10 text-center text-sm text-gray-500">正在加载订单...</td></tr>
|
||||
<tr v-else-if="orders.length === 0"><td colspan="7" class="p-10 text-center text-sm text-gray-500">暂无符合条件的订单</td></tr>
|
||||
<tr v-for="order in orders" :key="order.id" class="hover:bg-gray-50">
|
||||
<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>
|
||||
<td class="px-4 py-4 text-sm">{{ order.customerEmail || '-' }}</td>
|
||||
<td class="px-4 py-4 text-sm">{{ providerText(order.provider) }}</td>
|
||||
<td class="px-4 py-4 text-sm font-medium">{{ money(order) }}</td>
|
||||
<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>
|
||||
<td class="px-4 py-4 text-xs text-gray-500 max-w-xs truncate" :title="order.providerTransactionId">{{ order.providerTransactionId || order.providerOrderId || '-' }}</td>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -1,38 +1,10 @@
|
||||
import { adminHeader } from './AdminHeader.js';
|
||||
|
||||
// Settings 页面组件
|
||||
export default {
|
||||
template: `
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<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">祈福小助手</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>
|
||||
${adminHeader('settings')}
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
|
||||
@@ -1,38 +1,10 @@
|
||||
import { adminHeader } from './AdminHeader.js';
|
||||
|
||||
// Users 页面组件
|
||||
export default {
|
||||
template: `
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<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">祈福小助手</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>
|
||||
${adminHeader('users')}
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
|
||||
@@ -1,38 +1,10 @@
|
||||
import { adminHeader } from './AdminHeader.js';
|
||||
|
||||
// Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步
|
||||
export default {
|
||||
template: `
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<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">祈福小助手</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>
|
||||
${adminHeader('wiki')}
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<!-- 标签页切换 -->
|
||||
|
||||
@@ -1,38 +1,10 @@
|
||||
import { adminHeader } from './AdminHeader.js';
|
||||
|
||||
// Wishes 页面组件
|
||||
export default {
|
||||
template: `
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<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">祈福小助手</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>
|
||||
${adminHeader('wishes')}
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div class="px-4 py-6 sm:px-0">
|
||||
|
||||
Reference in New Issue
Block a user