feat(commerce): 排盘保存需显式同意,默认本地计算不上传
- 前端免费排盘默认不保存,勾选同意后才调用保存接口 - 服务端要求 consentVersion,记录 consentAt,校验并限流公开保存接口 - 付费结算单独披露报告快照与订单信息将由服务端存储 - 管理端展示同意版本与时间,历史无同意证据记录保留并标注 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
ea6828b925
commit
132fef051b
@@ -14,3 +14,4 @@
|
|||||||
- 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。
|
- 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。
|
||||||
- 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。
|
- 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。
|
||||||
- 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。
|
- 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。
|
||||||
|
- 排盘数据会进入服务端时,必须明确说明采集字段、保存目的与范围;免费排盘默认不上传,主动同意后才保存,同意版本需留档,公开写入接口必须限流。付费下单的报告快照应在下单前另行说明。
|
||||||
|
|||||||
+5
-4
@@ -53,8 +53,11 @@ go test ./...
|
|||||||
- `GET /api/commerce/reports/:reportId/access` — 查询报告权益。
|
- `GET /api/commerce/reports/:reportId/access` — 查询报告权益。
|
||||||
- `GET /api/commerce/reports` — 本人已购报告。
|
- `GET /api/commerce/reports` — 本人已购报告。
|
||||||
- `GET /api/commerce/reports/recover/:accessCode` — 使用恢复码读取购买时快照。
|
- `GET /api/commerce/reports/recover/:accessCode` — 使用恢复码读取购买时快照。
|
||||||
|
- `POST /api/commerce/charts` — 用户主动同意后保存免费排盘记录;接口限流并记录同意版本与时间。
|
||||||
|
|
||||||
除 Webhook 外,商业接口都要求 `Authorization: Bearer <JWT>`。
|
除 Webhook 和需同意的公开排盘记录接口外,商业接口都要求 `Authorization: Bearer <JWT>`。
|
||||||
|
|
||||||
|
免费排盘默认不上传。创建付费订单时,报告快照会发送并保存在服务端,用于生成、交付和恢复已购报告。
|
||||||
|
|
||||||
## 管理后台
|
## 管理后台
|
||||||
|
|
||||||
@@ -78,14 +81,12 @@ go test ./...
|
|||||||
|
|
||||||
## 数据库
|
## 数据库
|
||||||
|
|
||||||
新安装由 GORM AutoMigrate 创建表。已有 MySQL 数据库先备份,再单独执行:
|
新安装由 GORM AutoMigrate 创建表。商业排盘记录新增同意版本和时间字段,服务启动时通过 AutoMigrate 增量添加;已有 MySQL 数据库应先备份再升级,不要执行数据库 fresh 或 refresh。商业表结构初始化脚本仍为:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mysql < migrations/002_commerce.sql
|
mysql < migrations/002_commerce.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
不要执行数据库 fresh 或 refresh。
|
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
完整模板见 `.env.example`。生产环境至少需要:
|
完整模板见 `.env.example`。生产环境至少需要:
|
||||||
|
|||||||
+1
-1
@@ -95,7 +95,7 @@ func main() {
|
|||||||
|
|
||||||
commerce := api.Group("/commerce")
|
commerce := api.Group("/commerce")
|
||||||
{
|
{
|
||||||
commerce.POST("/charts", handler.SaveChartRecord)
|
commerce.POST("/charts", middleware.RateLimit(), handler.SaveChartRecord)
|
||||||
commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout)
|
commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout)
|
||||||
commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder)
|
commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder)
|
||||||
commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess)
|
commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gouki/lunar-server/internal/config"
|
"github.com/gouki/lunar-server/internal/config"
|
||||||
@@ -21,21 +23,37 @@ var accessCodePattern = regexp.MustCompile(`^ZM-[A-Z2-7]{4}-[A-Z2-7]{4}-[A-Z2-7]
|
|||||||
|
|
||||||
func SaveChartRecord(c *gin.Context) {
|
func SaveChartRecord(c *gin.Context) {
|
||||||
var request struct {
|
var request struct {
|
||||||
ReportID string `json:"reportId" binding:"required"`
|
ReportID string `json:"reportId" binding:"required"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BirthDate string `json:"birthDate" binding:"required"`
|
BirthDate string `json:"birthDate" binding:"required"`
|
||||||
BirthTime string `json:"birthTime" binding:"required"`
|
BirthTime string `json:"birthTime" binding:"required"`
|
||||||
Gender string `json:"gender" binding:"required"`
|
Gender string `json:"gender" binding:"required"`
|
||||||
City string `json:"city" binding:"required"`
|
City string `json:"city" binding:"required"`
|
||||||
Snapshot json.RawMessage `json:"snapshot" binding:"required"`
|
ConsentVersion string `json:"consentVersion" 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.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 600*1024)
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
snapshot := bytes.TrimSpace(request.Snapshot)
|
||||||
|
var snapshotObject map[string]json.RawMessage
|
||||||
|
birthDate, dateErr := time.Parse("2006-01-02", request.BirthDate)
|
||||||
|
_, timeErr := time.Parse("15:04", request.BirthTime)
|
||||||
|
if !reportIDPattern.MatchString(request.ReportID) || len(snapshot) < 2 || len(snapshot) > 512*1024 ||
|
||||||
|
json.Unmarshal(snapshot, &snapshotObject) != nil || snapshotObject == nil ||
|
||||||
|
dateErr != nil || birthDate.Year() < 1901 || birthDate.Year() > 2100 || timeErr != nil ||
|
||||||
|
(request.Gender != "male" && request.Gender != "female") || request.ConsentVersion != service.ChartRecordConsentVersion ||
|
||||||
|
utf8.RuneCountInString(request.Name) > 64 || utf8.RuneCountInString(request.City) > 64 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误或未同意保存"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
consentedAt := time.Now().UTC()
|
||||||
record := &model.BaziChartRecord{
|
record := &model.BaziChartRecord{
|
||||||
ReportID: request.ReportID, Name: request.Name, BirthDate: request.BirthDate,
|
ReportID: request.ReportID, Name: request.Name, BirthDate: request.BirthDate,
|
||||||
BirthTime: request.BirthTime, Gender: request.Gender, City: request.City, Snapshot: string(request.Snapshot),
|
BirthTime: request.BirthTime, Gender: request.Gender, City: request.City,
|
||||||
|
ConsentVersion: request.ConsentVersion, ConsentAt: &consentedAt, Snapshot: string(snapshot),
|
||||||
}
|
}
|
||||||
if userID, exists := c.Get("userID"); exists {
|
if userID, exists := c.Get("userID"); exists {
|
||||||
if value, ok := userID.(uint); ok {
|
if value, ok := userID.(uint); ok {
|
||||||
|
|||||||
@@ -155,3 +155,88 @@ func TestStripeCheckoutWebhookAndEntitlementHTTPFlow(t *testing.T) {
|
|||||||
t.Fatalf("expected server-generated premium snapshot, got %d: %s", recoverResponse.Code, recoverResponse.Body.String())
|
t.Fatalf("expected server-generated premium snapshot, got %d: %s", recoverResponse.Code, recoverResponse.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveChartRecordRequiresConsent(t *testing.T) {
|
||||||
|
router := setupChartRecordAPI(t)
|
||||||
|
response := postChartRecord(router, "198.51.100.41", false)
|
||||||
|
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"code":400`) {
|
||||||
|
t.Fatalf("expected consent to be required, got %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
var recordCount int64
|
||||||
|
if err := config.GetDB().Model(&model.BaziChartRecord{}).Count(&recordCount).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if recordCount != 0 {
|
||||||
|
t.Fatalf("expected no chart record without consent, got %d", recordCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveChartRecordPersistsConsentViaHTTP(t *testing.T) {
|
||||||
|
router := setupChartRecordAPI(t)
|
||||||
|
response := postChartRecord(router, "198.51.100.42", true)
|
||||||
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) {
|
||||||
|
t.Fatalf("expected chart record to be saved after consent, got %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
var record model.BaziChartRecord
|
||||||
|
if err := config.GetDB().Where("report_id = ?", "ZM-CHART123").First(&record).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record.ConsentVersion != "chart-record-v1" || record.ConsentAt == nil {
|
||||||
|
t.Fatalf("expected persisted consent version and timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveChartRecordRateLimitedViaHTTP(t *testing.T) {
|
||||||
|
router := setupChartRecordAPI(t)
|
||||||
|
for requestNumber := 0; requestNumber < 11; requestNumber++ {
|
||||||
|
response := postChartRecord(router, "198.51.100.43", true)
|
||||||
|
if requestNumber < 10 && response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("request %d expected HTTP 200, got %d: %s", requestNumber+1, response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if requestNumber == 10 && response.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("expected HTTP 429 after rate limit, got %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveChartRecordRejectsOversizedBodyViaHTTP(t *testing.T) {
|
||||||
|
router := setupChartRecordAPI(t)
|
||||||
|
body := fmt.Sprintf(`{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","consentVersion":"chart-record-v1","snapshot":{"data":"%s"}}`, strings.Repeat("x", 600*1024))
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.RemoteAddr = "198.51.100.44:1234"
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected oversized body to be rejected, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupChartRecordAPI(t *testing.T) *gin.Engine {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("SERVER_ENV", "test")
|
||||||
|
t.Setenv("DB_TYPE", "sqlite")
|
||||||
|
t.Setenv("DB_NAME", fmt.Sprintf("file:chart-consent-http-%d?mode=memory&cache=shared", time.Now().UnixNano()))
|
||||||
|
t.Setenv("JWT_SECRET", "chart-consent-test-secret")
|
||||||
|
if err := config.InitDB(config.Load()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router := gin.New()
|
||||||
|
router.POST("/api/commerce/charts", middleware.RateLimit(), SaveChartRecord)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func postChartRecord(router *gin.Engine, clientIP string, consent bool) *httptest.ResponseRecorder {
|
||||||
|
body := `{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","snapshot":{"id":"ZM-CHART123"}`
|
||||||
|
if consent {
|
||||||
|
body += `,"consentVersion":"chart-record-v1"`
|
||||||
|
}
|
||||||
|
body += `}`
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.RemoteAddr = clientIP + ":1234"
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type BaziChartRecord struct {
|
type BaziChartRecord struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
ReportID string `gorm:"index;size:64" json:"reportId"`
|
ReportID string `gorm:"index;size:64" json:"reportId"`
|
||||||
UserID *uint `gorm:"index" json:"userId,omitempty"`
|
UserID *uint `gorm:"index" json:"userId,omitempty"`
|
||||||
Name string `gorm:"size:64" json:"name"`
|
Name string `gorm:"size:64" json:"name"`
|
||||||
BirthDate string `gorm:"size:16" json:"birthDate"`
|
BirthDate string `gorm:"size:16" json:"birthDate"`
|
||||||
BirthTime string `gorm:"size:8" json:"birthTime"`
|
BirthTime string `gorm:"size:8" json:"birthTime"`
|
||||||
Gender string `gorm:"size:16" json:"gender"`
|
Gender string `gorm:"size:16" json:"gender"`
|
||||||
City string `gorm:"size:64" json:"city"`
|
City string `gorm:"size:64" json:"city"`
|
||||||
Snapshot string `gorm:"type:longtext" json:"-"`
|
ConsentVersion string `gorm:"size:32;not null;default:''" json:"consentVersion"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
ConsentAt *time.Time `json:"consentAt,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
Snapshot string `gorm:"type:longtext" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReportProduct struct {
|
type ReportProduct struct {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"github.com/gouki/lunar-server/internal/model"
|
"github.com/gouki/lunar-server/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ChartRecordConsentVersion = "chart-record-v1"
|
||||||
|
|
||||||
func (s *CommerceService) CreateChartRecord(record *model.BaziChartRecord) error {
|
func (s *CommerceService) CreateChartRecord(record *model.BaziChartRecord) error {
|
||||||
if record.ReportID == "" || record.BirthDate == "" || record.BirthTime == "" || record.Snapshot == "" {
|
if record.ReportID == "" || record.BirthDate == "" || record.BirthTime == "" || record.Snapshot == "" || record.ConsentVersion != ChartRecordConsentVersion || record.ConsentAt == nil {
|
||||||
return errors.New("incomplete chart record")
|
return errors.New("incomplete chart record")
|
||||||
}
|
}
|
||||||
return s.db.Create(record).Error
|
return s.db.Create(record).Error
|
||||||
|
|||||||
@@ -36,17 +36,17 @@ export default {
|
|||||||
<main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
<main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="bg-white shadow rounded-lg">
|
<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 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><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 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>
|
||||||
<div v-if="error" class="m-5 p-3 bg-red-50 text-red-700 text-sm">{{ error }}</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">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<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>
|
<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></tr></thead>
|
||||||
<tbody class="divide-y divide-gray-100">
|
<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-if="loading"><td colspan="6" 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-else-if="records.length === 0"><td colspan="6" 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>
|
<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">{{ record.consentVersion || '迁移前记录(无同意凭证)' }}<br />{{ record.consentAt ? new Date(record.consentAt).toLocaleString('zh-CN', { hour12: false }) : '-' }}</td><td class="px-4 py-4 text-xs text-gray-500">{{ new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) }}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
## [未发布]
|
## [未发布]
|
||||||
|
|
||||||
|
### 排盘数据告知与同意
|
||||||
|
|
||||||
|
- 免费排盘默认只在当前页面展示;用户主动同意后才上传出生资料和排盘快照。
|
||||||
|
- 服务端保存同意版本与时间戳,拒绝缺少同意的写入,并对公开保存接口限流。
|
||||||
|
- 修正“本地计算保护隐私”的误导文案,并在付费下单前说明报告快照会保存到服务端。
|
||||||
|
|
||||||
### 排盘记录与免费层
|
### 排盘记录与免费层
|
||||||
|
|
||||||
- 每次排盘保存服务端历史记录,后台新增排盘记录页面。
|
- 每次排盘保存服务端历史记录,后台新增排盘记录页面。
|
||||||
|
|||||||
+20
-14
@@ -8,25 +8,30 @@
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[填写出生日期、时间、性别、城市] --> B[本地计算真太阳时与八字]
|
A[填写出生日期、时间、性别、城市] --> B[在当前设备计算真太阳时与八字]
|
||||||
B --> C[免费查看四柱、五行、大运、流年、刑冲合害、神煞]
|
B --> C[免费查看四柱、五行、大运、流年、刑冲合害、神煞]
|
||||||
C --> D{是否解锁深度解读}
|
C --> D{是否同意保存本次排盘记录}
|
||||||
D -- 否 --> E[继续查看免费排盘]
|
D -- 是 --> E[服务端保存所列资料与排盘快照]
|
||||||
D -- 是 --> F[Postmark 邮箱验证码登录]
|
D -- 否 --> F[只在当前页面查看,不上传免费排盘记录]
|
||||||
F --> G{选择支付渠道}
|
C --> G{是否解锁深度解读}
|
||||||
G --> H[Stripe: 4.99 USD]
|
G -- 否 --> H[继续查看免费排盘]
|
||||||
G --> I[Binance Pay: 5 USDT]
|
G -- 是 --> I[邮箱验证码登录并确认下单数据保存说明]
|
||||||
H --> J[服务商签名 Webhook]
|
I --> J{选择支付渠道}
|
||||||
I --> J
|
J --> K[Stripe: 4.99 USD]
|
||||||
J --> K[事务更新订单和报告权益]
|
J --> L[Binance Pay: 5 USDT]
|
||||||
K --> L[生成随机报告恢复码]
|
K --> M[服务商签名 Webhook]
|
||||||
L --> M[保存不可变报告快照]
|
L --> M
|
||||||
M --> N[用户凭购买邮箱和恢复码跨设备找回]
|
M --> N[事务更新订单和报告权益]
|
||||||
|
N --> O[生成随机报告恢复码]
|
||||||
|
O --> P[保存不可变报告快照]
|
||||||
|
P --> Q[用户凭购买邮箱和恢复码跨设备找回]
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 免费排盘内容
|
## 3. 免费排盘内容
|
||||||
|
|
||||||
每次排盘都会向服务端保存一份免费快照,记录出生参数、报告编号和计算结果版本,避免用户重复排盘后无法追踪历史。相同输入在同一算法版本下应得到相同结果;算法升级后保留旧快照,不覆盖历史记录。
|
八字运算在当前设备完成。用户默认不上传免费排盘资料;只有主动勾选保存选项后,姓名(如填写)、出生日期、出生时间、性别、城市和排盘快照才会发送到服务端,供授权后台人员查看。未勾选仍可免费计算,但服务端不会创建免费排盘记录。服务端验证同意版本并对公开保存接口限流。相同输入在同一算法版本下应得到相同结果;算法升级后保留已保存快照,不覆盖历史记录。
|
||||||
|
|
||||||
|
购买时会另行告知:创建订单会把报告快照和订单所需信息发送到服务端,用于生成、交付和恢复已购报告;该购买记录不依赖免费排盘保存选项。
|
||||||
|
|
||||||
免费层展示基础事实,不把基础数据故意藏到付费层:
|
免费层展示基础事实,不把基础数据故意藏到付费层:
|
||||||
|
|
||||||
@@ -150,6 +155,7 @@ Stripe 校验 `Stripe-Signature` 与 5 分钟时间窗口;Binance Pay 使用
|
|||||||
- `report_snapshots`:购买时不可变报告快照。
|
- `report_snapshots`:购买时不可变报告快照。
|
||||||
- `payment_events`:Webhook 幂等记录。
|
- `payment_events`:Webhook 幂等记录。
|
||||||
- `email_login_codes`:一次性验证码。
|
- `email_login_codes`:一次性验证码。
|
||||||
|
- `bazi_chart_records`:排盘记录增加同意版本与同意时间字段;旧记录不回填为已同意。
|
||||||
- `orders` 新增渠道、币种、服务商交易号、报告号、恢复码等字段。
|
- `orders` 新增渠道、币种、服务商交易号、报告号、恢复码等字段。
|
||||||
|
|
||||||
新部署可使用 GORM AutoMigrate;已有 MySQL 数据库应在备份后执行 `server/migrations/002_commerce.sql`,不要 fresh 或 refresh 数据库。
|
新部署可使用 GORM AutoMigrate;已有 MySQL 数据库应在备份后执行 `server/migrations/002_commerce.sql`,不要 fresh 或 refresh 数据库。
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
- Postmark 邮箱验证码登录。
|
- Postmark 邮箱验证码登录。
|
||||||
- 支付 Webhook 验签与幂等处理。
|
- 支付 Webhook 验签与幂等处理。
|
||||||
- 支付前只下发免费内容和通用锁定占位,个性化付费解读由服务端在支付确认后生成。
|
- 支付前只下发免费内容和通用锁定占位,个性化付费解读由服务端在支付确认后生成。
|
||||||
- 每次排盘保存服务端历史记录,后台可查询排盘次数与出生参数。
|
- 免费排盘默认不上传;用户主动同意后,服务端保存出生资料、排盘快照及同意版本/时间,授权后台可查询记录。
|
||||||
- 已购报告历史列表、随机恢复码与购买时报告快照。
|
- 已购报告历史列表、随机恢复码与购买时报告快照。
|
||||||
- 后台真实订单列表、排盘记录和收入统计。
|
- 后台真实订单列表、排盘记录和收入统计。
|
||||||
|
|
||||||
|
|||||||
+28
-12
@@ -30,6 +30,7 @@ import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core';
|
|||||||
import {
|
import {
|
||||||
authEmailKey,
|
authEmailKey,
|
||||||
authTokenKey,
|
authTokenKey,
|
||||||
|
chartRecordConsentVersion,
|
||||||
checkReportAccess,
|
checkReportAccess,
|
||||||
createCheckout,
|
createCheckout,
|
||||||
isDemoPayment,
|
isDemoPayment,
|
||||||
@@ -108,6 +109,8 @@ function App() {
|
|||||||
const [form, setForm] = useState<BirthForm>(DEFAULT_FORM);
|
const [form, setForm] = useState<BirthForm>(DEFAULT_FORM);
|
||||||
const [report, setReport] = useState<ReportState | null>(null);
|
const [report, setReport] = useState<ReportState | null>(null);
|
||||||
const [isCalculating, setIsCalculating] = useState(false);
|
const [isCalculating, setIsCalculating] = useState(false);
|
||||||
|
const [consentToSaveChart, setConsentToSaveChart] = useState(false);
|
||||||
|
const [chartSaveStatus, setChartSaveStatus] = useState('');
|
||||||
const [isUnlocked, setIsUnlocked] = useState(false);
|
const [isUnlocked, setIsUnlocked] = useState(false);
|
||||||
const [showCheckout, setShowCheckout] = useState(false);
|
const [showCheckout, setShowCheckout] = useState(false);
|
||||||
const [showRecovery, setShowRecovery] = useState(false);
|
const [showRecovery, setShowRecovery] = useState(false);
|
||||||
@@ -137,6 +140,8 @@ function App() {
|
|||||||
|
|
||||||
const calculate = (event: FormEvent<HTMLFormElement>) => {
|
const calculate = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const shouldSaveChart = consentToSaveChart;
|
||||||
|
setConsentToSaveChart(false);
|
||||||
setIsCalculating(true);
|
setIsCalculating(true);
|
||||||
const [year, month, day] = form.date.split('-').map(Number);
|
const [year, month, day] = form.date.split('-').map(Number);
|
||||||
const [hour, minute] = form.time.split(':').map(Number);
|
const [hour, minute] = form.time.split(':').map(Number);
|
||||||
@@ -161,15 +166,20 @@ function App() {
|
|||||||
const id = createReportId(`${form.date}|${form.time}|${form.gender}|${form.city}`);
|
const id = createReportId(`${form.date}|${form.time}|${form.gender}|${form.city}`);
|
||||||
const chartSnapshot: ReportState = { id, result, elements, shensha, sections: [] };
|
const chartSnapshot: ReportState = { id, result, elements, shensha, sections: [] };
|
||||||
setReport(chartSnapshot);
|
setReport(chartSnapshot);
|
||||||
void saveChartRecord({
|
setChartSaveStatus(shouldSaveChart ? '正在保存本次排盘记录…' : '本次未上传或保存排盘资料。');
|
||||||
reportId: id,
|
if (shouldSaveChart) {
|
||||||
name: form.name,
|
void saveChartRecord({
|
||||||
birthDate: form.date,
|
reportId: id,
|
||||||
birthTime: form.time,
|
name: form.name,
|
||||||
gender: form.gender,
|
birthDate: form.date,
|
||||||
city: form.city,
|
birthTime: form.time,
|
||||||
snapshot: chartSnapshot,
|
gender: form.gender,
|
||||||
}).catch(() => undefined);
|
city: form.city,
|
||||||
|
consentVersion: chartRecordConsentVersion,
|
||||||
|
snapshot: chartSnapshot,
|
||||||
|
}).then(() => setChartSaveStatus('本次排盘记录已保存至服务端,授权后台人员可查看。'))
|
||||||
|
.catch(() => setChartSaveStatus('排盘已完成,但服务端保存失败;本次结果仅保留在当前页面。'));
|
||||||
|
}
|
||||||
const localAccess = localStorage.getItem(`lunar-report:${id}`) === 'unlocked';
|
const localAccess = localStorage.getItem(`lunar-report:${id}`) === 'unlocked';
|
||||||
setIsUnlocked(localAccess);
|
setIsUnlocked(localAccess);
|
||||||
const token = localStorage.getItem(authTokenKey);
|
const token = localStorage.getItem(authTokenKey);
|
||||||
@@ -203,7 +213,7 @@ function App() {
|
|||||||
<h1>看懂命盘,<br /><em>更懂自己。</em></h1>
|
<h1>看懂命盘,<br /><em>更懂自己。</em></h1>
|
||||||
<p className="hero-lead">输入出生信息,免费查看四柱与五行。完整报告将性格、事业、财富、关系与大运,整理成一份可以反复阅读的人生说明书。</p>
|
<p className="hero-lead">输入出生信息,免费查看四柱与五行。完整报告将性格、事业、财富、关系与大运,整理成一份可以反复阅读的人生说明书。</p>
|
||||||
<div className="trust-row">
|
<div className="trust-row">
|
||||||
<span><ShieldCheck size={17} /> 本地计算保护隐私</span>
|
<span><ShieldCheck size={17} /> 资料保存由你选择</span>
|
||||||
<span><WalletCards size={17} /> 一次购买永久回看</span>
|
<span><WalletCards size={17} /> 一次购买永久回看</span>
|
||||||
<span><HeartHandshake size={17} /> 无自动续费</span>
|
<span><HeartHandshake size={17} /> 无自动续费</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,10 +255,15 @@ function App() {
|
|||||||
<ChevronDown size={15} />
|
<ChevronDown size={15} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="privacy-consent">
|
||||||
|
<input type="checkbox" checked={consentToSaveChart} onChange={event => setConsentToSaveChart(event.target.checked)} />
|
||||||
|
<span><strong>同意保存本次排盘资料(可选)</strong>姓名(如填写)、出生日期和时间、性别、城市及排盘结果会发送到服务端保存,授权后台人员可查看。未勾选仍可免费排盘,但不会保存本次记录。</span>
|
||||||
|
</label>
|
||||||
<button className="primary-action" type="submit" disabled={isCalculating}>
|
<button className="primary-action" type="submit" disabled={isCalculating}>
|
||||||
{isCalculating ? <><span className="loading-mark" /> 正在推演命盘...</> : <>免费开始排盘 <ArrowRight size={19} /></>}
|
{isCalculating ? <><span className="loading-mark" /> 正在推演命盘...</> : <>免费开始排盘 <ArrowRight size={19} /></>}
|
||||||
</button>
|
</button>
|
||||||
<p className="form-note"><Fingerprint size={14} /> 出生资料仅在当前设备参与计算,不会公开展示</p>
|
<p className="form-note"><Fingerprint size={14} /> 排盘在当前设备计算;勾选保存后才会上传所列资料。</p>
|
||||||
|
{chartSaveStatus && <p className="chart-save-status" aria-live="polite">{chartSaveStatus}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<a className="scroll-cue" href="#how"><span>了解报告</span><ArrowDown size={16} /></a>
|
<a className="scroll-cue" href="#how"><span>了解报告</span><ArrowDown size={16} /></a>
|
||||||
@@ -631,7 +646,8 @@ function Checkout({ reportId, snapshot, onClose, onPaid }: { reportId: string; s
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{error && <p className="payment-error">{error}</p>}
|
{error && <p className="payment-error">{error}</p>}
|
||||||
<p className="checkout-note"><ShieldCheck /> 服务商签名回调确认到账 · 无自动续费</p>
|
<p className="checkout-note"><ShieldCheck />{isDemoPayment ? '演示支付不会向服务端创建真实订单。' : '下单后,报告快照和订单信息会发送到服务端保存,用于生成、交付和恢复已购报告。'}</p>
|
||||||
|
<p className="checkout-note">服务商签名回调确认到账 · 无自动续费</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface ApiPayload<T> {
|
|||||||
|
|
||||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? '';
|
const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||||
export const isDemoPayment = import.meta.env.VITE_PAYMENT_MODE === 'demo';
|
export const isDemoPayment = import.meta.env.VITE_PAYMENT_MODE === 'demo';
|
||||||
|
export const chartRecordConsentVersion = 'chart-record-v1';
|
||||||
export const authTokenKey = 'lunar-commerce-token';
|
export const authTokenKey = 'lunar-commerce-token';
|
||||||
export const authEmailKey = 'lunar-commerce-email';
|
export const authEmailKey = 'lunar-commerce-email';
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export async function saveChartRecord(input: {
|
|||||||
birthTime: string;
|
birthTime: string;
|
||||||
gender: string;
|
gender: string;
|
||||||
city: string;
|
city: string;
|
||||||
|
consentVersion: string;
|
||||||
snapshot: unknown;
|
snapshot: unknown;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
await apiRequest<never>('/api/commerce/charts', {
|
await apiRequest<never>('/api/commerce/charts', {
|
||||||
|
|||||||
@@ -187,6 +187,10 @@ header nav a:hover { color: #fff; }
|
|||||||
.primary-action:hover, .offer-card button:hover, .unlock-panel button:hover, .pay-button:hover { background: var(--vermilion-dark); transform: translateY(-1px); }
|
.primary-action:hover, .offer-card button:hover, .unlock-panel button:hover, .pay-button:hover { background: var(--vermilion-dark); transform: translateY(-1px); }
|
||||||
.primary-action:disabled, .pay-button:disabled { opacity: .7; cursor: wait; }
|
.primary-action:disabled, .pay-button:disabled { opacity: .7; cursor: wait; }
|
||||||
.form-note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 14px 0 0; font-size: 10px; color: #929086; }
|
.form-note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 14px 0 0; font-size: 10px; color: #929086; }
|
||||||
|
.privacy-consent { display: flex; align-items: flex-start; gap: 9px; margin: 16px 0 18px; color: #77786f; font-size: 10px; line-height: 1.7; cursor: pointer; }
|
||||||
|
.privacy-consent input { flex: 0 0 auto; width: 15px; height: 15px; margin: 2px 0 0; accent-color: var(--vermilion); }
|
||||||
|
.privacy-consent strong { display: block; margin-bottom: 2px; color: var(--ink); font-size: 11px; }
|
||||||
|
.chart-save-status { margin: 9px 0 0; color: var(--ink-soft); text-align: center; font-size: 10px; line-height: 1.6; }
|
||||||
.loading-mark { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; }
|
.loading-mark { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||||
.scroll-cue { position: absolute; bottom: 27px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.42); font-size: 9px; letter-spacing: .2em; writing-mode: vertical-rl; }
|
.scroll-cue { position: absolute; bottom: 27px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.42); font-size: 9px; letter-spacing: .2em; writing-mode: vertical-rl; }
|
||||||
.scroll-cue svg { animation: bob 1.8s ease-in-out infinite; }
|
.scroll-cue svg { animation: bob 1.8s ease-in-out infinite; }
|
||||||
|
|||||||
Reference in New Issue
Block a user