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:
gouki
2026-09-27 23:21:16 +00:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent ea6828b925
commit 132fef051b
14 changed files with 201 additions and 58 deletions
+27 -9
View File
@@ -1,6 +1,7 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
@@ -9,6 +10,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"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) {
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"`
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"`
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": "排盘记录参数错误"})
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{
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 value, ok := userID.(uint); ok {
+85
View File
@@ -155,3 +155,88 @@ func TestStripeCheckoutWebhookAndEntitlementHTTPFlow(t *testing.T) {
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
}
+13 -11
View File
@@ -3,17 +3,19 @@ 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"`
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"`
ConsentVersion string `gorm:"size:32;not null;default:''" json:"consentVersion"`
ConsentAt *time.Time `json:"consentAt,omitempty"`
Snapshot string `gorm:"type:longtext" json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ReportProduct struct {
+3 -1
View File
@@ -7,8 +7,10 @@ import (
"github.com/gouki/lunar-server/internal/model"
)
const ChartRecordConsentVersion = "chart-record-v1"
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 s.db.Create(record).Error