Persist how many return items each sync run flipped to "missing" and how many came back to "ok" (yeeke_sync_run.missing_marked_count / recovered_count, migration 1789801000000), return them from the sync-runs API and add 「标记不可用」「恢复可用」 columns to the sync-runs page. When the 20% safety valve skips marking the count stays 0 and the reason remains in error_message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
213 lines
7.5 KiB
Go
213 lines
7.5 KiB
Go
package yeeke
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go-admin/app/goauto/models"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-admin-team/go-admin-core/sdk/api"
|
|
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Handler exposes the yeeke read-only admin surface: manual sync trigger and
|
|
// sync run history/summary. It never returns credentials, tokens, captcha
|
|
// text or a full raw yeeke response — only the counters already stored on
|
|
// models.YeekeSyncRun (#336 requirement #7).
|
|
type Handler struct {
|
|
// DB lets tests inject a database directly; production requests resolve
|
|
// it from the gin context via pkg.GetOrm, same as sybimport.Handler.
|
|
DB *gorm.DB
|
|
}
|
|
|
|
func (h Handler) db(c *gin.Context) (*gorm.DB, bool) {
|
|
db := h.DB
|
|
var err error
|
|
if db == nil {
|
|
db, err = pkg.GetOrm(c)
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
|
return nil, false
|
|
}
|
|
return db, true
|
|
}
|
|
|
|
// SyncRunDTO is the read-only shape returned to admin/purchaser. It embeds
|
|
// only the summary fields already computed by the sync run itself; it never
|
|
// carries yeeke_session (token/cookies) or a raw page response.
|
|
type SyncRunDTO struct {
|
|
ID uint64 `json:"id"`
|
|
Status string `json:"status"`
|
|
Trigger string `json:"trigger"`
|
|
TotalPages int `json:"totalPages"`
|
|
ReadCount int `json:"readCount"`
|
|
CreatedCount int `json:"createdCount"`
|
|
UpdatedCount int `json:"updatedCount"`
|
|
SkippedCount int `json:"skippedCount"`
|
|
FailedCount int `json:"failedCount"`
|
|
MissingMarkedCount int `json:"missingMarkedCount"`
|
|
RecoveredCount int `json:"recoveredCount"`
|
|
ErrorMessage string `json:"errorMessage"`
|
|
StartedAt string `json:"startedAt"`
|
|
FinishedAt *string `json:"finishedAt"`
|
|
LastSuccessAt *string `json:"lastSuccessAt"`
|
|
}
|
|
|
|
func toDTO(r models.YeekeSyncRun) SyncRunDTO {
|
|
dto := SyncRunDTO{
|
|
ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages,
|
|
ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount,
|
|
SkippedCount: r.SkippedCount, FailedCount: r.FailedCount,
|
|
MissingMarkedCount: r.MissingMarkedCount, RecoveredCount: r.RecoveredCount, ErrorMessage: r.ErrorMessage,
|
|
StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
if r.FinishedAt != nil {
|
|
s := r.FinishedAt.UTC().Format("2006-01-02T15:04:05Z")
|
|
dto.FinishedAt = &s
|
|
}
|
|
if r.LastSuccessAt != nil {
|
|
s := r.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
|
|
dto.LastSuccessAt = &s
|
|
}
|
|
return dto
|
|
}
|
|
|
|
// ListSyncRuns returns the most recent sync runs, newest first, optionally
|
|
// filtered by trigger, status and started-at range (#337 backward-compatible
|
|
// extension: all filters are optional query params, existing callers with no
|
|
// filters keep the #336 behavior). Visible to admin and purchaser alike
|
|
// (#336 requirement #7); it is mounted without Casbin role gating, mirroring
|
|
// sybimport's /sync-runs.
|
|
func (h Handler) ListSyncRuns(c *gin.Context) {
|
|
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
if err != nil || page < 1 {
|
|
page = 1
|
|
}
|
|
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
|
if err != nil || pageSize < 1 || pageSize > 100 {
|
|
pageSize = 20
|
|
}
|
|
db, ok := h.db(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
query := db.Model(&models.YeekeSyncRun{})
|
|
if v := strings.TrimSpace(c.Query("trigger")); v != "" {
|
|
query = query.Where("trigger = ?", v)
|
|
}
|
|
if v := strings.TrimSpace(c.Query("status")); v != "" {
|
|
query = query.Where("status = ?", v)
|
|
}
|
|
if v := strings.TrimSpace(c.Query("startedFrom")); v != "" {
|
|
t, perr := parseFilterTime(v)
|
|
if perr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedFrom 格式无效"})
|
|
return
|
|
}
|
|
query = query.Where("started_at >= ?", t)
|
|
}
|
|
if v := strings.TrimSpace(c.Query("startedTo")); v != "" {
|
|
t, perr := parseFilterTime(v)
|
|
if perr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedTo 格式无效"})
|
|
return
|
|
}
|
|
query = query.Where("started_at <= ?", t)
|
|
}
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
|
return
|
|
}
|
|
var rows []models.YeekeSyncRun
|
|
if err := query.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
|
return
|
|
}
|
|
items := make([]SyncRunDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, toDTO(row))
|
|
}
|
|
var last models.YeekeSyncRun
|
|
lastSuccessAt := ""
|
|
if err := db.Where("status = ?", "succeeded").Order("id desc").First(&last).Error; err == nil && last.LastSuccessAt != nil {
|
|
lastSuccessAt = last.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
|
|
"items": items, "total": total, "page": page, "pageSize": pageSize,
|
|
"lastSuccessAt": lastSuccessAt,
|
|
}})
|
|
}
|
|
|
|
// SyncRunDetail returns one run's summary.
|
|
func (h Handler) SyncRunDetail(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("runId"), 10, 64)
|
|
if err != nil || id == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "runId 无效"})
|
|
return
|
|
}
|
|
db, ok := h.db(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var row models.YeekeSyncRun
|
|
if err := db.First(&row, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "同步记录不存在"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": toDTO(row)}})
|
|
}
|
|
|
|
// TriggerSync starts a manual sync run. Only admin and purchaser may call it,
|
|
// same as SYB's manual Import (sybimport.Handler.Import) — this mirrors that
|
|
// role check exactly.
|
|
func (h Handler) TriggerSync(c *gin.Context) {
|
|
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
|
if role != "admin" && role != "purchaser" {
|
|
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
|
|
return
|
|
}
|
|
db, ok := h.db(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := StartSync(c.Request.Context(), db, nil, "manual", false)
|
|
if err != nil {
|
|
if errors.Is(err, ErrAlreadyRunning) {
|
|
c.JSON(http.StatusConflict, gin.H{"code": "ALREADY_RUNNING", "message": err.Error()})
|
|
return
|
|
}
|
|
// `[必须]` err here is only ever a config/connect-stage message built in
|
|
// start.go/yeekeclient — never a raw yeeke response, never a token or
|
|
// credential. api.GetRequestLogger keeps the same text out of the HTTP
|
|
// body while still recording it server-side for operators.
|
|
api.GetRequestLogger(c).Errorf("yeeke manual sync failed to start: %v", err)
|
|
c.JSON(http.StatusBadGateway, gin.H{"code": "SYNC_START_FAILED", "message": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "skipped": result.Skipped}})
|
|
}
|
|
|
|
// parseFilterTime accepts either a full RFC3339-ish "2006-01-02T15:04:05Z" or
|
|
// a plain "2006-01-02" date, matching the query-string shapes used by both
|
|
// the return-package and sync-run filters.
|
|
func parseFilterTime(v string) (time.Time, error) {
|
|
if t, err := time.Parse("2006-01-02T15:04:05Z", v); err == nil {
|
|
return t, nil
|
|
}
|
|
return time.Parse("2006-01-02", v)
|
|
}
|