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
+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
}