chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:03 +08:00
422 changed files with 80993 additions and 0 deletions
@@ -0,0 +1,321 @@
// Package httpapi exposes the internal full-sync control API through contract
// interfaces. Persistence and replay execution belong to the injected services.
package httpapi
import (
"bytes"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract"
fullsyncruntime "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/runtime"
"github.com/gogf/gf/v2/errors/gerror"
)
const maxReplayBodyBytes = 64 << 10
// Dependencies are the only application services used by the HTTP handlers.
type Dependencies struct {
Runs contract.RunReader
List contract.RunLister
Failures contract.FailurePager
Replays contract.ReplayRequester
Actions contract.ActionReader
Simple *fullsyncruntime.SimpleReplay
}
// ErrorResponse is shared by authentication, routing, validation and service
// failures. Messages deliberately exclude underlying storage error details.
type ErrorResponse struct {
Error APIError `json:"error"`
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// NewHandler exposes the control routes on the private Docker network. Admin
// performs the user JWT and permission checks before calling this listener.
func NewHandler(bearerToken string, deps Dependencies) (http.Handler, error) {
if strings.ContainsFunc(bearerToken, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) {
return nil, gerror.New("invalid full-sync HTTP token")
}
if deps.Runs == nil || deps.List == nil || deps.Failures == nil || deps.Replays == nil || deps.Actions == nil {
return nil, gerror.New("full-sync HTTP requires run list, run, failure, replay and action services")
}
mux := http.NewServeMux()
mux.HandleFunc(contract.ListRunsRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
query, err := url.ParseQuery(r.URL.RawQuery)
if err != nil || len(query["limit"]) > 1 || len(query["syncKind"]) > 1 {
writeError(w, contract.ErrInvalidArgument)
return
}
filter := contract.RunListFilter{SyncKind: contract.SyncKind(query.Get("syncKind"))}
if filter.SyncKind != "" && !filter.SyncKind.Valid() {
writeError(w, contract.ErrInvalidArgument)
return
}
if query.Get("limit") != "" {
filter.Limit, err = strconv.Atoi(query.Get("limit"))
if err != nil || filter.Limit < 1 || filter.Limit > 100 {
writeError(w, contract.ErrInvalidArgument)
return
}
}
result, err := deps.List.ListRuns(r.Context(), filter)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}))
mux.HandleFunc(contract.GetRunRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("runId")
if err := contract.ValidateID(id); err != nil {
writeError(w, err)
return
}
view, err := deps.Runs.GetRun(r.Context(), id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, view)
}))
mux.HandleFunc(contract.GetFailuresRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("runId")
if err := contract.ValidateID(id); err != nil {
writeError(w, err)
return
}
page, err := parsePage(r.URL.RawQuery)
if err != nil {
writeError(w, err)
return
}
result, err := deps.Failures.ListFailures(r.Context(), id, page)
if err != nil {
writeError(w, err)
return
}
if result.Items == nil {
result.Items = []contract.Failure{}
}
writeJSON(w, http.StatusOK, result)
}))
mux.HandleFunc(contract.PostReplayRoute, method(http.MethodPost, func(w http.ResponseWriter, r *http.Request) {
if deps.Simple != nil {
id := r.PathValue("runId")
if err := contract.ValidateID(id); err != nil {
writeError(w, err)
return
}
var request struct {
RequestedBy string `json:"requestedBy"`
MaxRounds int `json:"maxRounds"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxReplayBodyBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
writeError(w, contract.ErrInvalidArgument)
return
}
if err := decoder.Decode(new(any)); err != io.EOF {
writeError(w, contract.ErrInvalidArgument)
return
}
accepted, err := deps.Simple.Request(r.Context(), id, request.RequestedBy, request.MaxRounds)
if errors.Is(err, fullsyncruntime.ErrNoFailures) {
writeAPIError(w, http.StatusUnprocessableEntity, "no_failures", "Run has no failures to replay")
return
}
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusAccepted, accepted)
return
}
request, err := parseReplay(w, r)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeAPIError(w, http.StatusRequestEntityTooLarge, "body_too_large", "Replay request body is too large")
} else {
writeError(w, err)
}
return
}
accepted, err := deps.Replays.RequestReplay(r.Context(), request)
if err != nil {
writeError(w, err)
return
}
// Construct a local status URL from the returned identity, never an
// externally supplied redirect. Invalid service output is a server error.
accepted.StatusURL, err = contract.ActionStatusURL(accepted.ActionID)
if err != nil || contract.ValidateID(accepted.TargetRunID) != nil || !accepted.Status.Valid() {
writeAPIError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
return
}
w.Header().Set("Location", accepted.StatusURL)
writeJSON(w, http.StatusAccepted, accepted)
}))
mux.HandleFunc("/internal/v1/full-sync-replays/{replayRunId}", method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
if deps.Simple == nil {
writeError(w, contract.ErrNotFound)
return
}
status, err := deps.Simple.Status(r.Context(), r.PathValue("replayRunId"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, status)
}))
mux.HandleFunc(contract.GetActionRoute, method(http.MethodGet, func(w http.ResponseWriter, r *http.Request) {
if deps.Simple != nil {
writeError(w, contract.ErrNotFound)
return
}
id := r.PathValue("actionId")
if err := contract.ValidateID(id); err != nil {
writeError(w, err)
return
}
action, err := deps.Actions.GetReplayAction(r.Context(), id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, action)
}))
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
writeError(w, contract.ErrNotFound)
})
if bearerToken == "" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
mux.ServeHTTP(w, r)
}), nil
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
values := r.Header.Values("Authorization")
if len(values) == 1 {
parts := strings.Fields(values[0])
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") && subtle.ConstantTimeCompare([]byte(parts[1]), []byte(bearerToken)) == 1 {
mux.ServeHTTP(w, r)
return
}
}
w.Header().Set("WWW-Authenticate", `Bearer realm="full-sync"`)
writeAPIError(w, http.StatusUnauthorized, "unauthorized", "Valid bearer authentication is required")
}), nil
}
func method(expected string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != expected {
w.Header().Set("Allow", expected)
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
return
}
next(w, r)
}
}
func parsePage(rawQuery string) (contract.PageRequest, error) {
query, err := url.ParseQuery(rawQuery)
if err != nil || len(query["cursor"]) > 1 || len(query["limit"]) > 1 {
return contract.PageRequest{}, gerror.Wrap(contract.ErrInvalidArgument, "invalid pagination query")
}
page := contract.PageRequest{Cursor: contract.Cursor(query.Get("cursor"))}
if value := query.Get("limit"); value != "" {
page.Limit, err = strconv.Atoi(value)
if err != nil {
return contract.PageRequest{}, gerror.Wrap(contract.ErrInvalidArgument, "limit must be an integer")
}
}
return page.Normalized()
}
func parseReplay(w http.ResponseWriter, r *http.Request) (contract.ReplayRequest, error) {
var request contract.ReplayRequest
if value := r.Header.Get("Content-Type"); value != "" {
mediaType, _, err := mime.ParseMediaType(value)
if err != nil || mediaType != "application/json" {
return request, gerror.Wrap(contract.ErrInvalidArgument, "replay body must be JSON")
}
}
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxReplayBodyBytes))
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return request, err
}
return request, gerror.Wrap(contract.ErrInvalidArgument, "cannot read replay body")
}
if !utf8.Valid(data) {
return request, gerror.Wrap(contract.ErrInvalidArgument, "replay body must be UTF-8")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
return request, gerror.Wrap(contract.ErrInvalidArgument, "invalid replay JSON")
}
if err := decoder.Decode(new(any)); err != io.EOF {
return request, gerror.Wrap(contract.ErrInvalidArgument, "replay body must contain exactly one JSON object")
}
if len(r.Header.Values(contract.IdempotencyHeader)) != 1 {
return request, gerror.Wrap(contract.ErrInvalidArgument, "exactly one Idempotency-Key is required")
}
request.SourceRunID = r.PathValue("runId")
request.IdempotencyKey = r.Header.Get(contract.IdempotencyHeader)
return request, request.Validate()
}
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, contract.ErrInvalidArgument):
writeAPIError(w, http.StatusBadRequest, "invalid_argument", "Invalid request")
case errors.Is(err, contract.ErrNotFound):
writeAPIError(w, http.StatusNotFound, "not_found", "Resource not found")
case errors.Is(err, contract.ErrFailureDetailsUnavailable):
writeAPIError(w, http.StatusGone, "failure_details_unavailable", "Live failure details are no longer available")
case errors.Is(err, contract.ErrConflict), errors.Is(err, contract.ErrLeaseLost):
writeAPIError(w, http.StatusConflict, "conflict", "Request conflicts with the current resource")
case errors.Is(err, contract.ErrBusy):
writeAPIError(w, http.StatusServiceUnavailable, "busy", "Resource is busy; retry later")
default:
writeAPIError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
}
}
func writeAPIError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, ErrorResponse{Error: APIError{Code: code, Message: message}})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
// Marshal before writing headers so an invalid service payload cannot
// produce a successful status with truncated JSON.
data, err := json.Marshal(value)
if err != nil {
status = http.StatusInternalServerError
data = []byte(`{"error":{"code":"internal_error","message":"Internal server error"}}`)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(append(data, '\n'))
}