59eb34976a
iotlcm-admin-svr:管理后台服务。 - Go module:iotlcm → git.5iot.com/iotlcm-public/iotlcm-admin-svr(729 文件) - 凭据与地址脱敏:JWT signingKey(含代码里的 MustGet 默认值)、 MySQL DSN、Redis 口令、告警邮件发件人邮箱全部改为占位; 私网 IP → 文档地址段;库名 xiujie_admin → iotlcm - 真实手机号/邮箱 → 合法 11 位编造号 / example.com - 移除内部资产:CI 触发器、过时 DDL(sql/db_iotlcm.sql)、 私有依赖 go get 行、明文 registry 登录口令 - 启动 banner 统一 IOTLCM 方块字 + 社区惯例署名 - Dockerfile 改用公共基础镜像 - 拼错的 RADEME.md → README.md,README 重写 - 需要真实数据库的用例改为可用 IOTLCM_TEST_WITH_DB=1 显式启用 - 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes 数据库 schema 不随本仓发布(生产 DDL 已过时),README 已说明。
306 lines
10 KiB
Go
306 lines
10 KiB
Go
package fullsyncretry
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/errors/gcode"
|
|
"github.com/gogf/gf/v2/errors/gerror"
|
|
)
|
|
|
|
const maxResponse = 512 << 10
|
|
|
|
var ErrConfig = errors.New("full-sync worker is not configured")
|
|
|
|
type Client struct {
|
|
BaseURL string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
type Receipt struct {
|
|
ReplayRunID string `json:"replayRunId"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type Status struct {
|
|
ReplayRunID string `json:"replayRunId"`
|
|
SourceRunID string `json:"sourceRunId"`
|
|
Status string `json:"status"`
|
|
Remaining int64 `json:"remaining"`
|
|
Enqueued int64 `json:"enqueued"`
|
|
RunStatus string `json:"runStatus,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type RunMeta struct {
|
|
RunID string `json:"runId"`
|
|
Operation string `json:"operation"`
|
|
QueryMonth string `json:"queryMonth"`
|
|
Status string `json:"status"`
|
|
ProducerDone bool `json:"producerDone"`
|
|
CurrentRound int `json:"currentRound"`
|
|
ActiveCount int64 `json:"activeCount"`
|
|
InflightCount int64 `json:"inflightCount"`
|
|
NextRoundCount int64 `json:"nextRoundCount"`
|
|
SuccessCount int64 `json:"successCount"`
|
|
FailedCount int64 `json:"failedCount"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
ProducerCompletedAt time.Time `json:"producerCompletedAt"`
|
|
CompletedAt time.Time `json:"completedAt"`
|
|
SyncType string `json:"syncType,omitempty"`
|
|
SourceRunID string `json:"sourceRunId,omitempty"`
|
|
ReplayCycle int `json:"replayCycle,omitempty"`
|
|
TriggerSource string `json:"triggerSource,omitempty"`
|
|
ScannedCount int64 `json:"scannedCount,omitempty"`
|
|
EnqueuedCount int64 `json:"enqueuedCount,omitempty"`
|
|
UpstreamAttemptCount int64 `json:"upstreamAttemptCount,omitempty"`
|
|
BatchCount int64 `json:"batchCount,omitempty"`
|
|
MaxRound int `json:"maxRound,omitempty"`
|
|
PendingFailureCount int64 `json:"pendingFailureCount,omitempty"`
|
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
|
ScanCompletedAt *time.Time `json:"scanCompletedAt,omitempty"`
|
|
ArchiveStatus string `json:"archiveStatus,omitempty"`
|
|
ArchivedAt *time.Time `json:"archivedAt,omitempty"`
|
|
}
|
|
|
|
type RoundStats struct {
|
|
RunID string `json:"runId"`
|
|
Round int `json:"round"`
|
|
EnqueuedCount int64 `json:"enqueuedCount"`
|
|
ActiveCount int64 `json:"activeCount"`
|
|
NextRoundCount int64 `json:"nextRoundCount"`
|
|
SuccessCount int64 `json:"successCount"`
|
|
FailedCount int64 `json:"failedCount"`
|
|
UpstreamAttemptCount int64 `json:"upstreamAttemptCount"`
|
|
BatchCount int64 `json:"batchCount"`
|
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
|
}
|
|
|
|
type RunView struct {
|
|
Source string `json:"source"`
|
|
Run RunMeta `json:"run"`
|
|
Rounds []RoundStats `json:"rounds,omitempty"`
|
|
}
|
|
|
|
type RunList struct {
|
|
Items []RunView `json:"items"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
type Failure struct {
|
|
TaskID string `json:"taskId"`
|
|
CardID int64 `json:"cardId"`
|
|
ErrorCategory string `json:"errorCategory"`
|
|
ErrorMessage string `json:"errorMessage"`
|
|
FailedAt time.Time `json:"failedAt"`
|
|
Round int `json:"round"`
|
|
}
|
|
|
|
type FailurePage struct {
|
|
Items []Failure `json:"items"`
|
|
NextCursor string `json:"nextCursor"`
|
|
}
|
|
|
|
type StartReceipt struct {
|
|
RunID string `json:"runId"`
|
|
SyncKind string `json:"syncKind"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
func FromEnvironment() Client {
|
|
return Client{BaseURL: "http://iotlcm-worker-svr:18081"}
|
|
}
|
|
|
|
func ValidateID(id string) bool {
|
|
if len(id) == 0 || len(id) > 128 {
|
|
return false
|
|
}
|
|
for _, c := range id {
|
|
if c < '0' || c > '9' {
|
|
if c < 'a' || c > 'z' {
|
|
if c < 'A' || c > 'Z' {
|
|
if c != '-' && c != '_' && c != ':' && c != '.' {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MaxReplayRounds mirrors the Worker's server-side ceiling so an oversized
|
|
// request is rejected at the edge instead of surfacing as an opaque 400.
|
|
const MaxReplayRounds = 100
|
|
|
|
// Replay requests a re-run of the source run's failures. maxRounds == 0 asks
|
|
// the Worker to apply its configured default; any other value is passed
|
|
// through and must not exceed MaxReplayRounds.
|
|
func (c Client) Replay(ctx context.Context, id, actor string, maxRounds int) (Receipt, error) {
|
|
if !ValidateID(id) || actor == "" {
|
|
return Receipt{}, gerror.NewCode(gcode.CodeInvalidParameter, "Invalid replay request")
|
|
}
|
|
if maxRounds < 0 || maxRounds > MaxReplayRounds {
|
|
return Receipt{}, gerror.NewCode(gcode.CodeInvalidParameter, "Invalid replay maxRounds")
|
|
}
|
|
body, _ := json.Marshal(struct {
|
|
RequestedBy string `json:"requestedBy"`
|
|
MaxRounds int `json:"maxRounds,omitempty"`
|
|
}{actor, maxRounds})
|
|
var receipt Receipt
|
|
if err := c.do(ctx, http.MethodPost, "/internal/v1/full-sync-runs/"+url.PathEscape(id)+"/replay", body, http.StatusAccepted, &receipt); err != nil {
|
|
return Receipt{}, err
|
|
}
|
|
if !ValidateID(receipt.ReplayRunID) {
|
|
return Receipt{}, gerror.NewCode(gcode.CodeInternalError, "Invalid Worker response")
|
|
}
|
|
return receipt, nil
|
|
}
|
|
|
|
// ValidSyncKind matches the Worker's full-sync kinds for both monitoring and starts.
|
|
func ValidSyncKind(kind string) bool {
|
|
switch kind {
|
|
case "traffic_usage", "sim_status", "imei", "traffic_usage_daily", "traffic_usage_monthly":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (c Client) ListRuns(ctx context.Context, kind string, limit int) (RunList, error) {
|
|
if (kind != "" && !ValidSyncKind(kind)) || limit < 0 || limit > 100 {
|
|
return RunList{}, gerror.NewCode(gcode.CodeInvalidParameter, "Invalid full-sync filter")
|
|
}
|
|
query := url.Values{}
|
|
if kind != "" {
|
|
query.Set("syncKind", kind)
|
|
}
|
|
if limit > 0 {
|
|
query.Set("limit", fmt.Sprint(limit))
|
|
}
|
|
path := "/internal/v1/full-sync-runs"
|
|
if len(query) > 0 {
|
|
path += "?" + query.Encode()
|
|
}
|
|
var result RunList
|
|
if err := c.do(ctx, http.MethodGet, path, nil, http.StatusOK, &result); err != nil {
|
|
return RunList{}, err
|
|
}
|
|
if result.Items == nil {
|
|
result.Items = []RunView{}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c Client) Run(ctx context.Context, id string) (RunView, error) {
|
|
if !ValidateID(id) {
|
|
return RunView{}, gerror.NewCode(gcode.CodeInvalidParameter, "Invalid run ID")
|
|
}
|
|
var result RunView
|
|
if err := c.do(ctx, http.MethodGet, "/internal/v1/full-sync-runs/"+url.PathEscape(id), nil, http.StatusOK, &result); err != nil {
|
|
return RunView{}, err
|
|
}
|
|
if result.Run.RunID != id {
|
|
return RunView{}, gerror.NewCode(gcode.CodeInternalError, "Invalid Worker response")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c Client) Failures(ctx context.Context, id string, limit int, cursor string) (FailurePage, error) {
|
|
if !ValidateID(id) || limit < 0 || limit > 500 || len(cursor) > 1024 {
|
|
return FailurePage{}, gerror.NewCode(gcode.CodeInvalidParameter, "Invalid failure query")
|
|
}
|
|
query := url.Values{}
|
|
if limit > 0 {
|
|
query.Set("limit", fmt.Sprint(limit))
|
|
}
|
|
if cursor != "" {
|
|
query.Set("cursor", cursor)
|
|
}
|
|
path := "/internal/v1/full-sync-runs/" + url.PathEscape(id) + "/failures"
|
|
if len(query) > 0 {
|
|
path += "?" + query.Encode()
|
|
}
|
|
var result FailurePage
|
|
if err := c.do(ctx, http.MethodGet, path, nil, http.StatusOK, &result); err != nil {
|
|
return FailurePage{}, err
|
|
}
|
|
if result.Items == nil {
|
|
result.Items = []Failure{}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c Client) Get(ctx context.Context, id string) (Status, error) {
|
|
if !ValidateID(id) {
|
|
return Status{}, gerror.NewCode(gcode.CodeInvalidParameter, "Invalid replay run ID")
|
|
}
|
|
var status Status
|
|
if err := c.do(ctx, http.MethodGet, "/internal/v1/full-sync-replays/"+url.PathEscape(id), nil, http.StatusOK, &status); err != nil {
|
|
return Status{}, err
|
|
}
|
|
if status.ReplayRunID != id {
|
|
return Status{}, gerror.NewCode(gcode.CodeInternalError, "Invalid Worker response")
|
|
}
|
|
return status, nil
|
|
}
|
|
|
|
func (c Client) do(ctx context.Context, method, path string, body []byte, expected int, result any) error {
|
|
base, err := url.Parse(c.BaseURL)
|
|
if err != nil || base == nil || base.Scheme != "http" || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || base.Path != "" {
|
|
return gerror.NewCode(gcode.CodeInternalError, ErrConfig.Error())
|
|
}
|
|
client := c.HTTP
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(base.String(), "/")+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return gerror.NewCode(gcode.CodeInternalError, "Invalid Worker request")
|
|
}
|
|
if body != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return gerror.NewCode(gcode.CodeInternalError, "Worker unavailable")
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != expected {
|
|
return workerError(response.StatusCode)
|
|
}
|
|
decoder := json.NewDecoder(io.LimitReader(response.Body, maxResponse))
|
|
if err := decoder.Decode(result); err != nil {
|
|
return gerror.NewCode(gcode.CodeInternalError, "Invalid Worker response")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func workerError(status int) error {
|
|
switch status {
|
|
case http.StatusBadRequest:
|
|
return gerror.NewCode(gcode.CodeInvalidParameter, "Invalid replay request")
|
|
case http.StatusNotFound:
|
|
return gerror.NewCode(gcode.CodeNotFound, "Run not found")
|
|
case http.StatusGone:
|
|
return gerror.NewCode(gcode.CodeNotFound, "Failure details are no longer available")
|
|
case http.StatusServiceUnavailable:
|
|
return gerror.NewCode(gcode.CodeInternalError, "Worker is busy")
|
|
case http.StatusConflict:
|
|
return gerror.NewCode(gcode.CodeInvalidParameter, "Run is not ready for replay")
|
|
case http.StatusUnprocessableEntity:
|
|
return gerror.NewCode(gcode.CodeInvalidParameter, "Run has no failures")
|
|
default:
|
|
return gerror.NewCode(gcode.CodeInternalError, fmt.Sprintf("Worker failed (HTTP %d)", status))
|
|
}
|
|
}
|