@@ -78,6 +78,20 @@ func (s *Service) markCompleteOnce(ctx context.Context, s3Key string) (bool, err
7878 return res == "OK" , nil
7979}
8080
81+ // unmarkComplete releases the idempotency marker for s3Key, used when a
82+ // complete attempt set the marker but then failed before the quota deduction
83+ // committed (so a retry can re-run the deduction). Best-effort, and uses a
84+ // fresh context because the request context may already be done by the time
85+ // this runs in a deferred cleanup.
86+ func (s * Service ) unmarkComplete (s3Key string ) {
87+ if s .rdb == nil {
88+ return
89+ }
90+ ctx , cancel := context .WithTimeout (context .Background (), 2 * time .Second )
91+ defer cancel ()
92+ s .rdb .Del (ctx , "upload:complete:" + s3Key )
93+ }
94+
8195// ─── s3_key generation ───────────────────────────────
8296
8397var (
@@ -190,6 +204,17 @@ func (s *Service) verifyAndFinalize(ctx context.Context, userID int, s3Key strin
190204 if ! first {
191205 return actual , nil
192206 }
207+ // The idempotency marker is now held. It must persist ONLY if the quota
208+ // deduction below actually commits — otherwise a retry hits the "!first"
209+ // fast-path above and returns success WITHOUT ever deducting, permanently
210+ // under-counting daily_upload_size. Release the marker on every failure
211+ // path so a retry can re-run the deduction (MOYU-PR7 / M5 follow-up).
212+ deducted := false
213+ defer func () {
214+ if ! deducted {
215+ s .unmarkComplete (s3Key )
216+ }
217+ }()
193218
194219 var user authModel.User
195220 if err := s .db .Select ("daily_upload_size" ).First (& user , userID ).Error ; err != nil {
@@ -205,6 +230,7 @@ func (s *Service) verifyAndFinalize(ctx context.Context, userID int, s3Key strin
205230 UpdateColumn ("daily_upload_size" , gorm .Expr ("daily_upload_size + ?" , actual )).Error ; err != nil {
206231 return 0 , fmt .Errorf ("扣减限额失败: %w" , err )
207232 }
233+ deducted = true
208234 return actual , nil
209235}
210236
@@ -247,6 +273,14 @@ func (s *Service) InitMultipart(ctx context.Context, userID int, privileged bool
247273 if err := s .validatePreUpload (userID , req .FileName , req .FileSize , privileged ); err != nil {
248274 return nil , err
249275 }
276+ // part_count must match the fixed 10 MiB chunking the client uses (FE
277+ // computes ceil(file_size / MULTIPART_PART_SIZE) with the same constant).
278+ // Reject mismatches so a client can't decouple part_count from the real
279+ // size and force the server to presign thousands of bogus part URLs.
280+ wantParts := int ((req .FileSize + constants .MultipartPartSize - 1 ) / constants .MultipartPartSize )
281+ if req .PartCount != wantParts {
282+ return nil , fmt .Errorf ("分片数不正确:应为 %d" , wantParts )
283+ }
250284
251285 key , err := buildPatchResourceKey (req .GalgameID , req .FileName )
252286 if err != nil {
0 commit comments