55package handlers
66
77import (
8+ "crypto/sha256"
9+ "encoding/hex"
810 "net/http"
911 "strconv"
1012 "strings"
@@ -54,21 +56,45 @@ func CRDTPush(c *gin.Context) {
5456 // M7: 整个循环包裹在单个事务内
5557 txErr := models .DB .Transaction (func (tx * gorm.DB ) error {
5658 for _ , ch := range req .Changes {
59+ // SYNC-M3: changeset 哈希幂等去重——网络重试的重复 changeset
60+ // 不再重复分配序号与落库((user_id, changeset_hash) 唯一索引兜底)
61+ if ch .Changeset != "" {
62+ hash := sha256 .Sum256 ([]byte (ch .Changeset ))
63+ hashHex := hex .EncodeToString (hash [:])
64+ var dupCount int64
65+ if err := tx .Model (& models.CRDTChange {}).
66+ Where ("user_id = ? AND changeset_hash = ?" , userID , hashHex ).
67+ Count (& dupCount ).Error ; err != nil {
68+ return err
69+ }
70+ if dupCount > 0 {
71+ // 已处理过:视为已接受(幂等),不重复落库
72+ accepted = append (accepted , ch .Seq )
73+ continue
74+ }
75+ }
76+
5777 seqNo , err := nextSeqNo (tx )
5878 if err != nil {
5979 return err
6080 }
6181
6282 if err := tx .Create (& models.CRDTChange {
63- ServerSeqNo : seqNo ,
64- DeviceID : req .DeviceID ,
65- UserID : userID ,
66- TableName : ch .TableName ,
67- EntityID : ch .EntityID ,
68- Changeset : ch .Changeset ,
69- Operation : ch .Operation ,
70- CreatedAt : ch .CreatedAt ,
83+ ServerSeqNo : seqNo ,
84+ DeviceID : req .DeviceID ,
85+ UserID : userID ,
86+ TableName : ch .TableName ,
87+ EntityID : ch .EntityID ,
88+ Changeset : ch .Changeset ,
89+ ChangesetHash : hashChangeSet (ch .Changeset ),
90+ Operation : ch .Operation ,
91+ CreatedAt : ch .CreatedAt ,
7192 }).Error ; err != nil {
93+ // 唯一索引兜底:并发重复推送视为已处理
94+ if isUniqueViolation (err ) && ch .Changeset != "" {
95+ accepted = append (accepted , ch .Seq )
96+ continue
97+ }
7298 return err
7399 }
74100 accepted = append (accepted , ch .Seq )
@@ -88,6 +114,15 @@ func CRDTPush(c *gin.Context) {
88114 })
89115}
90116
117+ // SYNC-M3: 计算 changeset 的 SHA-256 hex(空串返回空串,不参与幂等)
118+ func hashChangeSet (changeset string ) string {
119+ if changeset == "" {
120+ return ""
121+ }
122+ hash := sha256 .Sum256 ([]byte (changeset ))
123+ return hex .EncodeToString (hash [:])
124+ }
125+
91126// ─── CRDT Changeset Pull (GET /api/v1/sync/crdt/changes) ──────────────────────
92127
93128// CRDTPull returns all CRDT changesets since `since` (exclusive) that were
@@ -96,6 +131,11 @@ func CRDTPush(c *gin.Context) {
96131func CRDTPull (c * gin.Context ) {
97132 userID := c .GetString ("user_id" )
98133 deviceID := c .Query ("deviceId" )
134+ // SYNC-L2: deviceId 白名单校验——空值拉回本设备变更造成回环
135+ if ! isValidDeviceID (deviceID ) {
136+ c .JSON (http .StatusBadRequest , gin.H {"error" : "invalid deviceId: must match [A-Za-z0-9_-]{1,64}" })
137+ return
138+ }
99139 sinceStr := c .DefaultQuery ("since" , "0" )
100140 since , err := strconv .ParseInt (sinceStr , 10 , 64 )
101141 if err != nil {
0 commit comments