dataDownloadFaultRecordService.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. package services
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/sha256"
  6. "encoding/hex"
  7. "errors"
  8. "fmt"
  9. "mime"
  10. "path/filepath"
  11. "strconv"
  12. "strings"
  13. "time"
  14. "fwh-toolbox-server/cache"
  15. "fwh-toolbox-server/domain"
  16. "fwh-toolbox-server/repositories"
  17. minioRepo "fwh-toolbox-server/repositories/minio"
  18. mongoRepo "fwh-toolbox-server/repositories/mongo"
  19. "go.mongodb.org/mongo-driver/bson/primitive"
  20. "go.mongodb.org/mongo-driver/mongo"
  21. "go.uber.org/zap"
  22. )
  23. type DataDownloadPartialFailure struct {
  24. Succeeded int
  25. Failed int
  26. Cause error
  27. }
  28. func (e *DataDownloadPartialFailure) Error() string {
  29. return fmt.Sprintf("fault-record request partially completed: %d succeeded, %d failed", e.Succeeded, e.Failed)
  30. }
  31. func (e *DataDownloadPartialFailure) Unwrap() error { return e.Cause }
  32. func executeFaultRecordDataDownloadItem(ctx context.Context, item *domain.DataDownloadItem, awaitRequestID string) error {
  33. ready, lease, err := prepareDataDownloadRequest(ctx, item, awaitRequestID)
  34. if err != nil {
  35. return err
  36. }
  37. if ready != nil {
  38. _ = mongoRepo.ReleaseDataDownloadFaultCall(ctx, ready.ID, item.ID)
  39. return linkDataDownloadItem(ctx, item, ready, ready.Empty)
  40. }
  41. defer lease.abort()
  42. otherActive, err := mongoRepo.OtherDataDownloadFaultCallActive(ctx, lease.documentID, item.ID)
  43. if err != nil {
  44. return err
  45. }
  46. if otherActive {
  47. return &DataDownloadFaultCallDeferred{After: time.Minute}
  48. }
  49. deviceType, err := strconv.Atoi(item.DeviceType)
  50. if err != nil {
  51. return fmt.Errorf("invalid fault-record device type %q", item.DeviceType)
  52. }
  53. loc := dataDownloadLocation(item.Timezone)
  54. start, err := time.ParseInLocation(time.DateOnly, item.Date, loc)
  55. if err != nil {
  56. return err
  57. }
  58. end := start.AddDate(0, 0, 1).Add(-time.Second)
  59. sources, sourceErr := resolveFaultRecordSources(ctx, item, lease.documentID, deviceType, start, end)
  60. var callDeferred *DataDownloadFaultCallDeferred
  61. pendingCall := errors.As(sourceErr, &callDeferred)
  62. if errors.Is(sourceErr, context.Canceled) {
  63. _ = ReleaseFaultCallOwnership(context.Background(), item)
  64. return sourceErr
  65. }
  66. if sourceErr != nil && len(sources) == 0 {
  67. if faultCallTerminal(item, sourceErr) {
  68. _ = ReleaseFaultCallOwnership(context.Background(), item)
  69. }
  70. return sourceErr
  71. }
  72. fetchedAt := time.Now().UTC()
  73. zap.L().Debug("[DataDownload] FaultRecord 已验证录波子记录",
  74. zap.String("documentId", lease.documentID),
  75. zap.String("deviceSn", item.DeviceSN),
  76. zap.String("deviceType", item.DeviceType),
  77. zap.String("date", item.Date),
  78. zap.Int("sourceCount", len(sources)),
  79. )
  80. for _, source := range sources {
  81. sourceID := fmt.Sprintf("%d:%d", source.record.Id, source.sub.Id)
  82. if attempt, getErr := mongoRepo.GetDataDownloadFileAttempt(ctx, item.TaskID, item.ID, sourceID); getErr == nil && attempt.Status == domain.DataDownloadItemSucceeded {
  83. if _, entryErr := mongoRepo.GetDataDownloadFaultRecordEntry(ctx, lease.documentID, sourceID); entryErr == nil {
  84. continue
  85. }
  86. }
  87. name, body, downloadErr := repositories.QueryDownloadFaultRecordData(source.record.Id, source.sub.Id)
  88. if downloadErr != nil {
  89. _ = recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, nil, "download", downloadErr)
  90. continue
  91. }
  92. name = filepath.Base(name)
  93. if name == "." || name == "" {
  94. name = fmt.Sprintf("fault-%d-%d.bin", source.record.Id, source.sub.Id)
  95. }
  96. zap.L().Debug("[DataDownload] FaultRecord 文件已下载到内存",
  97. zap.String("documentId", lease.documentID),
  98. zap.String("sourceId", sourceID),
  99. zap.String("fileName", name),
  100. zap.Int("bytes", len(body)),
  101. )
  102. contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(name)))
  103. if contentType == "" {
  104. contentType = "application/octet-stream"
  105. }
  106. sum := sha256.Sum256(body)
  107. objectKey, uploadErr := minioRepo.PutBinaryDocument(ctx, lease.documentID, name, bytes.NewReader(body), int64(len(body)), contentType)
  108. if uploadErr != nil {
  109. _ = recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, nil, "upload", uploadErr)
  110. continue
  111. }
  112. contentLock, lockErr := cache.Lock(ctx, "data-download:content:"+lease.documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
  113. if lockErr != nil {
  114. _ = minioRepo.Delete(context.Background(), objectKey)
  115. _ = recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, nil, "publish", lockErr)
  116. continue
  117. }
  118. if validateErr := lease.validate(ctx); validateErr != nil {
  119. contentLock.SafeRelease(context.Background())
  120. _ = minioRepo.Delete(context.Background(), objectKey)
  121. return validateErr
  122. }
  123. entry := &domain.DataDownloadFaultRecordEntry{
  124. DocumentID: lease.documentID, SourceFileID: sourceID, FaultID: source.record.Id,
  125. SubRecordID: source.sub.Id, FileName: name, ContentType: contentType,
  126. Size: int64(len(body)), Checksum: hex.EncodeToString(sum[:]), ObjectKey: objectKey,
  127. LastSuccessfulAt: fetchedAt, UpdatedAt: fetchedAt,
  128. }
  129. oldObjectKey, publishErr := mongoRepo.UpsertDataDownloadFaultRecordEntry(ctx, entry)
  130. contentLock.SafeRelease(context.Background())
  131. if publishErr != nil {
  132. _ = minioRepo.Delete(context.Background(), objectKey)
  133. _ = recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, nil, "publish", publishErr)
  134. continue
  135. }
  136. if oldObjectKey != "" && oldObjectKey != objectKey {
  137. _ = minioRepo.Delete(context.Background(), oldObjectKey)
  138. }
  139. if attemptErr := recordFaultRecordAttempt(ctx, item, lease.documentID, sourceID, name, &entry.ID, "", nil); attemptErr != nil {
  140. return attemptErr
  141. }
  142. }
  143. if sourceErr != nil && !pendingCall {
  144. if err := recordFaultRecordAttempt(ctx, item, lease.documentID, "call:*", "", nil, "call", sourceErr); err != nil {
  145. return err
  146. }
  147. } else {
  148. _ = mongoRepo.DeleteDataDownloadFileAttempt(ctx, item.TaskID, item.ID, "call:*")
  149. }
  150. attempts, err := mongoRepo.ListDataDownloadFileAttempts(ctx, item.TaskID)
  151. if err != nil {
  152. return err
  153. }
  154. succeeded, failed := 0, 0
  155. for i := range attempts {
  156. if attempts[i].ItemID != item.ID {
  157. continue
  158. }
  159. if attempts[i].Status == domain.DataDownloadItemSucceeded {
  160. succeeded++
  161. } else if attempts[i].Status == domain.DataDownloadFailed {
  162. failed++
  163. }
  164. }
  165. entries, err := mongoRepo.ListDataDownloadFaultRecordEntries(ctx, lease.documentID)
  166. if err != nil {
  167. return err
  168. }
  169. manifest := strings.Builder{}
  170. for i := range entries {
  171. manifest.WriteString(entries[i].SourceFileID)
  172. manifest.WriteByte('|')
  173. manifest.WriteString(entries[i].Checksum)
  174. manifest.WriteByte('\n')
  175. }
  176. manifestSum := sha256.Sum256([]byte(manifest.String()))
  177. contentLock, err := cache.Lock(ctx, "data-download:content:"+lease.documentID, 10*time.Minute, 200*time.Millisecond, 30*time.Second)
  178. if err != nil {
  179. return err
  180. }
  181. defer contentLock.SafeRelease(context.Background())
  182. if err := lease.validate(ctx); err != nil {
  183. return err
  184. }
  185. generation, createdAt := int64(1), fetchedAt
  186. if existing, getErr := mongoRepo.GetDataDownloadDocument(ctx, lease.documentID); getErr == nil {
  187. generation = existing.ContentGeneration + 1
  188. createdAt = existing.CreatedAt
  189. } else if !errors.Is(getErr, mongo.ErrNoDocuments) {
  190. return getErr
  191. }
  192. completeness := domain.DataDownloadCompletenessComplete
  193. if failed > 0 || pendingCall {
  194. completeness = domain.DataDownloadCompletenessPartial
  195. }
  196. state, frozenAt := faultRecordDocumentState(time.Now(), loc, start, failed)
  197. if pendingCall {
  198. state, frozenAt = domain.DataDownloadMutable, nil
  199. }
  200. document := &domain.DataDownloadDocument{
  201. ID: lease.documentID, RequestKey: lease.requestKey, Kind: item.Kind,
  202. SiteSN: item.SiteSN, DeviceSN: item.DeviceSN, Date: item.Date, DeviceType: item.DeviceType,
  203. Timezone: item.Timezone, State: state, Completeness: completeness,
  204. Empty: !pendingCall && failed == 0 && len(entries) == 0, FileCount: int64(len(entries)), FailedFileCount: int64(failed),
  205. ContentGeneration: generation, Checksum: hex.EncodeToString(manifestSum[:]), SourceFetchedAt: fetchedAt,
  206. FrozenAt: frozenAt, CreatedAt: createdAt, UpdatedAt: fetchedAt,
  207. }
  208. if err := mongoRepo.UpsertDataDownloadDocument(ctx, document); err != nil {
  209. return err
  210. }
  211. zap.L().Debug("[DataDownload] FaultRecord 内容已提交",
  212. zap.String("documentId", lease.documentID),
  213. zap.String("state", string(state)),
  214. zap.String("completeness", string(completeness)),
  215. zap.Int("fileCount", len(entries)),
  216. zap.Int("succeeded", succeeded),
  217. zap.Int("failed", failed),
  218. zap.Int64("generation", generation),
  219. )
  220. if err := linkDataDownloadItem(ctx, item, document, document.Empty); err != nil {
  221. return err
  222. }
  223. if pendingCall {
  224. return callDeferred
  225. }
  226. if faultCallTerminal(item, sourceErr) {
  227. _ = ReleaseFaultCallOwnership(context.Background(), item)
  228. }
  229. if failed > 0 {
  230. return &DataDownloadPartialFailure{Succeeded: succeeded, Failed: failed, Cause: sourceErr}
  231. }
  232. if err := lease.complete(ctx); err != nil {
  233. return err
  234. }
  235. return mongoRepo.ReleaseDataDownloadFaultCall(ctx, lease.documentID, item.ID)
  236. }
  237. func faultCallTerminal(item *domain.DataDownloadItem, err error) bool {
  238. return item.CallRecordStatus != nil && *item.CallRecordStatus == 2 ||
  239. errors.Is(err, ErrFaultRecordCallTimeout) || errors.Is(err, ErrFaultRecordCallFailed) || errors.Is(err, errAmbiguousFaultCall)
  240. }
  241. // ReleaseFaultCallOwnership clears an owner only when no remote call was sent,
  242. // the call is terminal, or its two-hour observation window has elapsed. A
  243. // cancelled item can otherwise leave a remote call running; its owner expires
  244. // automatically and must not be removed early by another queue execution.
  245. func ReleaseFaultCallOwnership(ctx context.Context, item *domain.DataDownloadItem) error {
  246. if item == nil || item.Kind != domain.DataDownloadFaultRecord {
  247. return nil
  248. }
  249. if item.CallRequestedAt != nil &&
  250. (item.CallRecordStatus == nil || (*item.CallRecordStatus != 2 && *item.CallRecordStatus != -1)) &&
  251. time.Now().Before(item.CallRequestedAt.Add(faultCallTimeout)) {
  252. return nil
  253. }
  254. requestKey, err := buildDataDownloadRequestKey(item)
  255. if err != nil {
  256. return err
  257. }
  258. return mongoRepo.ReleaseDataDownloadFaultCall(ctx, stableDataDownloadDocumentID(requestKey), item.ID)
  259. }
  260. func faultRecordDocumentState(now time.Time, loc *time.Location, dataDay time.Time, failed int) (domain.DataDownloadState, *time.Time) {
  261. if failed > 0 || now.In(loc).Before(dataDay.AddDate(0, 0, 2)) {
  262. return domain.DataDownloadMutable, nil
  263. }
  264. frozenAt := now.UTC()
  265. return domain.DataDownloadFrozen, &frozenAt
  266. }
  267. func recordFaultRecordAttempt(ctx context.Context, item *domain.DataDownloadItem, documentID, sourceID, fileName string, entryID *primitive.ObjectID, stage string, cause error) error {
  268. status, reason := domain.DataDownloadItemSucceeded, ""
  269. if cause != nil {
  270. status, reason = domain.DataDownloadFailed, cause.Error()
  271. }
  272. return mongoRepo.RecordDataDownloadFileAttempt(ctx, &domain.DataDownloadFileAttempt{
  273. TaskID: item.TaskID, ItemID: item.ID, DocumentID: documentID, SourceFileID: sourceID,
  274. FileName: fileName, Status: status, EntryID: entryID, FailureStage: stage, FailureReason: reason,
  275. })
  276. }