手动同步改为异步任务并前端轮询状态

This commit is contained in:
2026-07-04 12:10:33 +08:00
parent ab14eb9c30
commit 46013a2b2f
4 changed files with 98 additions and 23 deletions
+5 -5
View File
@@ -147,7 +147,7 @@ func (h *StockHandler) SyncStatus(c *gin.Context) {
} }
// TriggerSync 手动触发同步(管理员)。 // TriggerSync 手动触发同步(管理员)。
// 系统管理员点击时同步近半年历史数据,其他管理员只同步当天。 // 系统管理员点击时异步同步近半年历史数据,其他管理员只同步当天。
func (h *StockHandler) TriggerSync(c *gin.Context) { func (h *StockHandler) TriggerSync(c *gin.Context) {
user, ok := middleware.GetCurrentUser(c) user, ok := middleware.GetCurrentUser(c)
if !ok { if !ok {
@@ -155,18 +155,18 @@ func (h *StockHandler) TriggerSync(c *gin.Context) {
return return
} }
var count int var job *models.StockSyncJob
var err error var err error
if user.Role == models.RoleSystemAdmin { if user.Role == models.RoleSystemAdmin {
count, err = h.Svc.SyncHistory(c.Request.Context(), "manual", 6) job, err = h.Svc.StartSyncHistory(c.Request.Context(), "manual", 6)
} else { } else {
count, err = h.Svc.Sync(c.Request.Context(), "manual") job, err = h.Svc.StartSync(c.Request.Context(), "manual")
} }
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"success": true, "records_count": count}) c.JSON(http.StatusOK, gin.H{"success": true, "job": job})
} }
type distributionRow struct { type distributionRow struct {
+51 -9
View File
@@ -52,10 +52,31 @@ func (s *StockSyncService) SyncHistory(ctx context.Context, triggerBy string, mo
}) })
} }
func (s *StockSyncService) runSyncJob(ctx context.Context, triggerBy string, fn func() (int, error)) (int, error) { // StartSync 异步启动一次盘后同步,返回已创建的 job。
s.mu.Lock() func (s *StockSyncService) StartSync(ctx context.Context, triggerBy string) (*models.StockSyncJob, error) {
defer s.mu.Unlock() job, err := s.createJob(ctx, triggerBy)
if err != nil {
return nil, err
}
go s.runSyncJobByID(context.Background(), job.ID, func(ctx context.Context) (int, error) {
return s.doSync(ctx)
})
return job, nil
}
// StartSyncHistory 异步启动近 N 个月历史同步,返回已创建的 job。
func (s *StockSyncService) StartSyncHistory(ctx context.Context, triggerBy string, months int) (*models.StockSyncJob, error) {
job, err := s.createJob(ctx, triggerBy)
if err != nil {
return nil, err
}
go s.runSyncJobByID(context.Background(), job.ID, func(ctx context.Context) (int, error) {
return s.doSyncHistory(ctx, months)
})
return job, nil
}
func (s *StockSyncService) createJob(ctx context.Context, triggerBy string) (*models.StockSyncJob, error) {
job := models.StockSyncJob{ job := models.StockSyncJob{
JobDate: today(), JobDate: today(),
Status: "running", Status: "running",
@@ -63,25 +84,46 @@ func (s *StockSyncService) runSyncJob(ctx context.Context, triggerBy string, fn
TriggerBy: triggerBy, TriggerBy: triggerBy,
} }
if err := s.db.WithContext(ctx).Create(&job).Error; err != nil { if err := s.db.WithContext(ctx).Create(&job).Error; err != nil {
return 0, fmt.Errorf("create sync job: %w", err) return nil, fmt.Errorf("create sync job: %w", err)
} }
return &job, nil
}
func (s *StockSyncService) runSyncJob(ctx context.Context, triggerBy string, fn func() (int, error)) (int, error) {
job, err := s.createJob(ctx, triggerBy)
if err != nil {
return 0, err
}
recordsCount, err := fn() recordsCount, err := fn()
s.finishJob(ctx, job.ID, recordsCount, err)
return recordsCount, err
}
func (s *StockSyncService) runSyncJobByID(ctx context.Context, jobID string, fn func(context.Context) (int, error)) {
s.mu.Lock()
defer s.mu.Unlock()
recordsCount, err := fn(ctx)
s.finishJob(ctx, jobID, recordsCount, err)
}
func (s *StockSyncService) finishJob(ctx context.Context, jobID string, recordsCount int, jobErr error) {
now := time.Now().UTC() now := time.Now().UTC()
updates := map[string]any{ updates := map[string]any{
"records_count": recordsCount, "records_count": recordsCount,
"finished_at": &now, "finished_at": &now,
} }
if err != nil { if jobErr != nil {
updates["status"] = "failed" updates["status"] = "failed"
updates["error_message"] = err.Error() updates["error_message"] = jobErr.Error()
log.Printf("[stock sync] job %s failed: %v", jobID, jobErr)
} else { } else {
updates["status"] = "success" updates["status"] = "success"
log.Printf("[stock sync] job %s success, records=%d", jobID, recordsCount)
}
if err := s.db.WithContext(ctx).Model(&models.StockSyncJob{}).Where("id = ?", jobID).Updates(updates).Error; err != nil {
log.Printf("[stock sync] update job %s failed: %v", jobID, err)
} }
s.db.WithContext(ctx).Model(&job).Updates(updates)
return recordsCount, err
} }
func (s *StockSyncService) doSync(ctx context.Context) (int, error) { func (s *StockSyncService) doSync(ctx context.Context) (int, error) {
+40 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
BarChart3, BarChart3,
TrendingUp, TrendingUp,
@@ -24,6 +24,7 @@ export function StockOverview({ role }: StockOverviewProps) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const pollRef = useRef<number | null>(null)
const canSync = canManageUsers(role) const canSync = canManageUsers(role)
@@ -43,20 +44,52 @@ export function StockOverview({ role }: StockOverviewProps) {
useEffect(() => { useEffect(() => {
fetchAll() fetchAll()
return () => {
if (pollRef.current) {
window.clearInterval(pollRef.current)
}
}
}, []) }, [])
const startPolling = () => {
if (pollRef.current) {
window.clearInterval(pollRef.current)
}
pollRef.current = window.setInterval(async () => {
try {
const j = await api.stockSyncStatus()
setJob(j)
if (!j || j.status !== 'running') {
if (pollRef.current) {
window.clearInterval(pollRef.current)
pollRef.current = null
}
setSyncing(false)
await fetchAll()
if (j?.status === 'failed') {
setError(j.error_message || '同步失败')
}
}
} catch (err: any) {
if (pollRef.current) {
window.clearInterval(pollRef.current)
pollRef.current = null
}
setSyncing(false)
setError(err?.message || '轮询同步状态失败')
}
}, 2000)
}
const handleSync = async () => { const handleSync = async () => {
setSyncing(true) setSyncing(true)
setError('') setError('')
try { try {
const res = await api.triggerStockSync() await api.triggerStockSync()
await fetchAll() startPolling()
// eslint-disable-next-line no-console
console.log('synced', res.records_count, 'records')
} catch (err: any) { } catch (err: any) {
setError(err?.message || '同步失败')
} finally {
setSyncing(false) setSyncing(false)
setError(err?.message || '同步失败')
} }
} }
+2 -2
View File
@@ -58,7 +58,7 @@ export interface SyncJob {
records_count: number records_count: number
started_at: string started_at: string
finished_at?: string finished_at?: string
error_message: string error_message?: string
trigger_by: string trigger_by: string
} }
@@ -147,5 +147,5 @@ export const api = {
stockSyncStatus: () => request<SyncJob | null>('/api/stocks/sync/status'), stockSyncStatus: () => request<SyncJob | null>('/api/stocks/sync/status'),
triggerStockSync: () => triggerStockSync: () =>
request<{ records_count: number }>('/api/admin/stocks/sync', { method: 'POST' }), request<{ job: SyncJob }>('/api/admin/stocks/sync', { method: 'POST' }),
} }