merge: 纳入 @lunar/core 排盘核心与排盘保存显式同意
# Conflicts: # REMINDER.md # web-commercial/CHANGELOG.md # web-commercial/Fetures.md
This commit is contained in:
+5
-4
@@ -53,8 +53,11 @@ go test ./...
|
||||
- `GET /api/commerce/reports/:reportId/access` — 查询报告权益。
|
||||
- `GET /api/commerce/reports` — 本人已购报告。
|
||||
- `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
|
||||
mysql < migrations/002_commerce.sql
|
||||
```
|
||||
|
||||
不要执行数据库 fresh 或 refresh。
|
||||
|
||||
## 环境变量
|
||||
|
||||
完整模板见 `.env.example`。邮箱登录配置 QQ SMTP:
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ func main() {
|
||||
|
||||
commerce := api.Group("/commerce")
|
||||
{
|
||||
commerce.POST("/charts", handler.SaveChartRecord)
|
||||
commerce.POST("/charts", middleware.RateLimit(), 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,17 +36,17 @@ export default {
|
||||
<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><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>
|
||||
<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">
|
||||
<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>
|
||||
<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="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">{{ 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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user