Alanxtl commented on code in PR #1046:
URL: https://github.com/apache/dubbo-go-pixiu/pull/1046#discussion_r4043771432


##########
admin/web/src/features/config/ConfigPages.tsx:
##########
@@ -575,70 +577,25 @@ export function ResourcePage({
   const [error, setError] = useState('')
   const [editor, setEditor] = useState<{
     mode: 'create' | 'edit'
-    id: string
-    value: string
-  } | null>(null)
-  const [methods, setMethods] = useState<{ resourceId: string; items: Method[] 
} | null>(null)
-  const [methodEditor, setMethodEditor] = useState<{
-    mode: 'create' | 'edit'
-    id: string
-    value: string
+    name: string
+    binding: RouteBinding | null
+    loading: boolean
+    published: boolean
+    publishStatus: RouteBindingPublishStatus | null
   } | null>(null)
-  const [saving, setSaving] = useState(false)
-  const buildRows = async (next: JsonObject[]) => {
-    const grouped = await Promise.all(
-      next.map(async (resource) => {
-        const id = routeIdentity(resource)
-        const resourceMethods = await methodApi.list(id)
-        const name = String(
-          resource.name ??
-            resource.routeName ??
-            resource.route_name ??
-            resource.description ??
-            `route.${id}`,
-        )
-        return resourceMethods.length
-          ? Promise.all(
-              resourceMethods.map(async (method) => {
-                let target = routeTarget(method)
-                if (target === '未配置后端目标' && method.id != null)
-                  target =
-                    routeTargetFromYaml(await methodApi.detail(id, 
String(method.id))) || target
-                return {
-                  resource,
-                  method,
-                  id,
-                  name,
-                  path: String(method.resourcePath ?? method.resource_path ?? 
resource.path ?? '-'),
-                  verb: String(method.httpVerb ?? method.http_verb ?? 
'GET').toUpperCase(),
-                  target: tx(target),
-                  status: routeStatus(method),
-                }
-              }),
-            )
-          : [
-              {
-                resource,
-                method: null,
-                id,
-                name,
-                path: String(resource.path ?? '-'),
-                verb: '—',
-                target: tx(String(resource.target ?? '未配置后端目标')),
-                status: 'Draft' as RouteStatus,
-              },
-            ]
-      }),
-    )
-    return grouped.flat()
-  }
   const load = useCallback(async () => {
     setLoading(true)
     setError('')
     try {
-      const next = await resourceApi.list()
-      setRows(await buildRows(next))
-      onCountChange?.(next.length)
+      const [draftBindings, publishedBindings] = await Promise.all([
+        routeBindingApi.list('draft'),
+        routeBindingApi.list('published'),
+      ])

Review Comment:
   新页面只读取 route-bindings,没有读取或迁移已有的 resources/<id>/method/<id> 
配置。升级后旧路由可能仍在运行,但 Admin 显示为空,也无法修改 enabled。



##########
admin/logic/route_binding.go:
##########
@@ -0,0 +1,1322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package logic
+
+import (
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "net/url"
+       "reflect"
+       "sort"
+       "strconv"
+       "strings"
+       "time"
+)
+
+import (
+       clientv3 "go.etcd.io/etcd/client/v3"
+)
+
+import (
+       adminconfig "github.com/apache/dubbo-go-pixiu/admin/config"
+       commonyaml "github.com/apache/dubbo-go-pixiu/pkg/common/yaml"
+       legacyconfig "github.com/apache/dubbo-go-pixiu/pkg/config"
+       "github.com/apache/dubbo-go-pixiu/pkg/config/schema"
+)
+
+const (
+       routeBindingPathSegment        = "route-bindings"
+       routeBindingRevisionSegment    = "route-bindings-revision"
+       routeBindingRuntimeResourceDir = "resources"
+       maxRouteBindingIDRetries       = 16
+)
+
+var (
+       ErrRouteBindingNotFound        = errors.New("admin route binding not 
found")
+       ErrRouteBindingAlreadyExists   = errors.New("admin route binding 
already exists")
+       ErrRouteBindingConflict        = errors.New("admin route binding was 
modified concurrently")
+       ErrRouteBindingPublishConflict = errors.New("admin route binding 
publish conflict")
+)
+
+// routeBindingKV is the small part of clientv3.KV needed by the route store.
+// Keeping this boundary narrow makes the planning and transaction logic easy
+// to exercise without coupling it to the gost wrapper.
+type routeBindingKV interface {
+       Get(context.Context, string, ...clientv3.OpOption) 
(*clientv3.GetResponse, error)
+       Txn(context.Context) clientv3.Txn
+}
+
+// RouteBinding is the Admin-facing response shape. ResourceID and MethodID
+// are stable runtime identities used by Pixiu's legacy etcd watcher; they are
+// not part of the user-editable AdminRouteBinding object.
+type RouteBinding struct {
+       Object     schema.AdminObject `json:"object"`
+       ResourceID int                `json:"resourceId"`
+       MethodID   int                `json:"methodId"`
+       Revision   int64              `json:"revision"`
+}
+
+// RouteBindingPublishResult describes one atomic publish transaction.
+type RouteBindingPublishResult struct {
+       Name              string `json:"name"`
+       Revision          int64  `json:"revision"`
+       DraftRevision     int64  `json:"draftRevision"`
+       PublishedRevision int64  `json:"publishedRevision"`
+       PublishedCount    int    `json:"publishedCount"`
+       DeletedCount      int    `json:"deletedCount"`
+}
+
+// RouteBindingPublishStatus exposes one route's draft and published state.
+// Revisions are the etcd key mod revisions for that route, not a global
+// configuration revision.
+type RouteBindingPublishStatus struct {
+       Name              string `json:"name"`
+       DraftRevision     int64  `json:"draftRevision"`
+       PublishedRevision int64  `json:"publishedRevision"`
+       DraftExists       bool   `json:"draftExists"`
+       PublishedExists   bool   `json:"publishedExists"`
+       Dirty             bool   `json:"dirty"`
+}
+
+type RouteBindingDiffChange struct {
+       Path   string `json:"path"`
+       Before any    `json:"before"`
+       After  any    `json:"after"`
+}
+
+// RouteBindingDiff compares one route's draft object with its published
+// object. A nil side represents a route that only exists in the other scope.
+type RouteBindingDiff struct {
+       Name      string                   `json:"name"`
+       Draft     *RouteBinding            `json:"draft,omitempty"`
+       Published *RouteBinding            `json:"published,omitempty"`
+       Changes   []RouteBindingDiffChange `json:"changes"`
+}
+
+type routeBindingRecord struct {
+       Object     schema.AdminObject `json:"object"`
+       ResourceID int                `json:"resourceId"`
+       MethodID   int                `json:"methodId"`
+}
+
+type routeBindingEntry struct {
+       record   routeBindingRecord
+       key      string
+       revision int64
+}
+
+// RouteBindingStore owns the AdminRouteBinding persistence and publish
+// boundary. Published runtime data is deliberately written in the existing
+// Resource/Method layout so the current Pixiu watcher can consume it.
+type RouteBindingStore struct {
+       kv       routeBindingKV
+       root     string
+       registry *schema.Registry
+}
+
+// NewRouteBindingStore creates a store over an etcd v3 client.
+func NewRouteBindingStore(kv routeBindingKV, root string, registry 
*schema.Registry) (*RouteBindingStore, error) {
+       if kv == nil {
+               return nil, errors.New("route binding etcd client is nil")
+       }
+       root = strings.TrimRight(strings.TrimSpace(root), "/")
+       if root == "" {
+               return nil, errors.New("route binding etcd root path is empty")
+       }
+       if registry == nil {
+               var err error
+               registry, err = schema.NewBuiltinRegistry()
+               if err != nil {
+                       return nil, fmt.Errorf("create route binding schema 
registry: %w", err)
+               }
+       }
+       return &RouteBindingStore{kv: kv, root: root, registry: registry}, nil
+}
+
+// NewAdminRouteBindingStore creates the production store from the Admin
+// process' configured etcd client and root path.
+func NewAdminRouteBindingStore() (*RouteBindingStore, error) {
+       if adminconfig.Bootstrap == nil {
+               return nil, errors.New("admin bootstrap is nil")
+       }
+       if adminconfig.Client == nil {
+               return nil, errors.New("admin etcd client is nil")
+       }
+       rawClient := adminconfig.Client.GetRawClient()
+       if rawClient == nil {
+               return nil, errors.New("admin raw etcd client is nil")
+       }
+       return NewRouteBindingStore(rawClient, adminconfig.Bootstrap.GetPath(), 
nil)
+}
+
+func routeBindingContext(ctx context.Context) context.Context {
+       if ctx == nil {
+               return context.Background()
+       }
+       return ctx
+}
+
+// Registry returns the schema registry used by this store.
+func (s *RouteBindingStore) Registry() *schema.Registry {
+       return s.registry
+}
+
+// List returns high-level route bindings from either the draft or published
+// namespace. Empty namespaces are returned as an empty slice.
+func (s *RouteBindingStore) List(ctx context.Context, unpublished bool) 
([]RouteBinding, error) {
+       ctx = routeBindingContext(ctx)
+       entries, err := s.listEntries(ctx, s.bindingPrefix(unpublished))
+       if err != nil {
+               return nil, err
+       }
+       result := make([]RouteBinding, 0, len(entries))
+       for _, entry := range entries {
+               result = append(result, routeBindingView(entry))
+       }
+       return result, nil
+}
+
+// Get returns one high-level route binding from either the draft or published
+// namespace.
+func (s *RouteBindingStore) Get(ctx context.Context, name string, unpublished 
bool) (RouteBinding, error) {
+       ctx = routeBindingContext(ctx)
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       entry, exists, _, err := s.getEntry(ctx, s.bindingPrefix(unpublished), 
name)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       if !exists {
+               return RouteBinding{}, fmt.Errorf("%w: %s", 
ErrRouteBindingNotFound, name)
+       }
+       return routeBindingView(entry), nil
+}
+
+// Normalize validates an object and applies all registered schema defaults.
+// It is useful to the controller's validate and preview endpoints and does
+// not require an etcd write.
+func (s *RouteBindingStore) Normalize(object schema.AdminObject) 
(schema.AdminObject, error) {
+       normalized, err := s.registry.Normalize(object)
+       if err != nil {
+               return normalized, err
+       }
+       name, err := normalizeRouteBindingName(normalized.Metadata.Name)
+       if err != nil {
+               return normalized, err
+       }
+       normalized.Metadata.Name = name
+       return normalized, nil
+}
+
+// Preview validates and compiles an Admin object without writing it.
+func (s *RouteBindingStore) Preview(object schema.AdminObject) 
(schema.AdminObject, []byte, error) {
+       normalized, err := s.Normalize(object)
+       if err != nil {
+               return normalized, nil, err
+       }
+       compiled, err := schema.CompileAdminRouteBinding(s.registry, normalized)
+       if err != nil {
+               return normalized, nil, err
+       }
+       preview, err := compiled.PreviewYAML()
+       if err != nil {
+               return normalized, nil, err
+       }
+       return normalized, preview, nil
+}
+
+// SaveDraft validates and atomically stores one AdminRouteBinding in the
+// draft namespace. POST callers use create=true; PUT callers use create=false.
+// expectedRevision is the draft key's mod revision and may be zero to allow
+// an unconditional write.
+func (s *RouteBindingStore) SaveDraft(ctx context.Context, object 
schema.AdminObject, create bool, expectedRevision int64) (RouteBinding, error) {
+       ctx = routeBindingContext(ctx)
+       if err := validateSaveDraftRequest(create, expectedRevision); err != 
nil {
+               return RouteBinding{}, err
+       }
+       normalized, err := s.Normalize(object)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       name := normalized.Metadata.Name
+       draftPrefix := s.bindingPrefix(true)
+       draftEntry, draftExists, draftRevision, err := s.getEntry(ctx, 
draftPrefix, name)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       if err := validateDraftSaveState(name, create, expectedRevision, 
draftExists, draftRevision); err != nil {
+               return RouteBinding{}, err
+       }
+
+       resourceID, methodID, err := s.resolveRuntimeIdentity(ctx, name, 
draftEntry, draftExists)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+
+       record := routeBindingRecord{
+               Object:     normalized,
+               ResourceID: resourceID,
+               MethodID:   methodID,
+       }
+       value, err := json.Marshal(record)
+       if err != nil {
+               return RouteBinding{}, fmt.Errorf("encode route binding %q: 
%w", name, err)
+       }
+       if err := s.commitDraft(ctx, name, draftPrefix, value, create, 
expectedRevision); err != nil {
+               return RouteBinding{}, err
+       }
+
+       saved, exists, revision, err := s.getEntry(ctx, draftPrefix, name)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       if !exists {
+               return RouteBinding{}, fmt.Errorf("route binding %q disappeared 
after save", name)
+       }
+       saved.revision = revision
+       return routeBindingView(saved), nil
+}
+
+func validateSaveDraftRequest(create bool, expectedRevision int64) error {
+       if expectedRevision < 0 {
+               return errors.New("expected revision must not be negative")
+       }
+       if create && expectedRevision > 0 {
+               return errors.New("create route binding does not accept an 
expected revision")
+       }
+       return nil
+}
+
+func validateDraftSaveState(name string, create bool, expectedRevision int64, 
exists bool, revision int64) error {
+       if create && exists {
+               return fmt.Errorf("%w: %s", ErrRouteBindingAlreadyExists, name)
+       }
+       if expectedRevision > 0 && (!exists || revision != expectedRevision) {
+               return fmt.Errorf("%w: %s", ErrRouteBindingConflict, name)
+       }
+       return nil
+}
+
+func (s *RouteBindingStore) resolveRuntimeIdentity(ctx context.Context, name 
string, draft routeBindingEntry, draftExists bool) (int, int, error) {
+       resourceID, methodID := draft.record.ResourceID, draft.record.MethodID
+       if !draftExists {
+               published, exists, _, err := s.getEntry(ctx, 
s.bindingPrefix(false), name)
+               if err != nil {
+                       return 0, 0, err
+               }
+               if exists {
+                       resourceID, methodID = published.record.ResourceID, 
published.record.MethodID
+               }
+       }
+       if resourceID <= 0 {
+               var err error
+               resourceID, err = s.allocateRuntimeID(ctx)
+               if err != nil {
+                       return 0, 0, err
+               }
+       }
+       if methodID <= 0 {
+               // One AdminRouteBinding currently compiles to exactly one 
Method. Method
+               // IDs are scoped below the resource, so sharing the stable 
route ID is
+               // safe and avoids a second allocation transaction.
+               methodID = resourceID
+       }
+       return resourceID, methodID, nil
+}
+
+func (s *RouteBindingStore) commitDraft(ctx context.Context, name, draftPrefix 
string, value []byte, create bool, expectedRevision int64) error {
+       comparisons := make([]clientv3.Cmp, 0, 1)
+       if create {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.CreateRevision(s.bindingKey(draftPrefix, name)), "=", 
0))
+       } else if expectedRevision > 0 {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.ModRevision(s.bindingKey(draftPrefix, name)), "=", 
expectedRevision))
+       }
+       operations := []clientv3.Op{
+               clientv3.OpPut(s.bindingKey(draftPrefix, name), string(value)),
+               clientv3.OpPut(s.draftRevisionKey(), 
strconv.FormatInt(time.Now().UnixNano(), 10)),
+       }
+       transaction := s.kv.Txn(ctx)
+       if len(comparisons) > 0 {
+               transaction = transaction.If(comparisons...)
+       }
+       response, err := transaction.Then(operations...).Commit()
+       if err != nil {
+               return fmt.Errorf("save route binding %q: %w", name, err)
+       }
+       if response.Succeeded {
+               return nil
+       }
+       if create {
+               return fmt.Errorf("%w: %s", ErrRouteBindingAlreadyExists, name)
+       }
+       return fmt.Errorf("%w: %s", ErrRouteBindingConflict, name)
+}
+
+// DeleteDraft removes a draft. If only a published binding exists, the
+// deletion is still recorded by advancing the draft revision; the next
+// PublishAll transaction will remove that published route atomically.
+func (s *RouteBindingStore) DeleteDraft(ctx context.Context, name string, 
expectedRevision int64) error {
+       ctx = routeBindingContext(ctx)
+       if expectedRevision < 0 {
+               return errors.New("expected revision must not be negative")
+       }
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return err
+       }
+       draftPrefix := s.bindingPrefix(true)
+       _, exists, revision, err := s.getEntry(ctx, draftPrefix, name)
+       if err != nil {
+               return err
+       }
+       _, publishedExists, _, err := s.getEntry(ctx, s.bindingPrefix(false), 
name)
+       if err != nil {
+               return err
+       }
+       if !exists && !publishedExists {
+               return fmt.Errorf("%w: %s", ErrRouteBindingNotFound, name)
+       }
+       if expectedRevision > 0 && (!exists || revision != expectedRevision) {
+               return fmt.Errorf("%w: %s", ErrRouteBindingConflict, name)
+       }
+       draftMarkerRevision, draftMarkerExists, err := s.markerRevision(ctx, 
s.draftRevisionKey())
+       if err != nil {
+               return err
+       }
+
+       comparisons := make([]clientv3.Cmp, 0, 2)
+       if exists {
+               observedRevision := revision
+               if expectedRevision > 0 {
+                       observedRevision = expectedRevision
+               }
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.ModRevision(s.bindingKey(draftPrefix, name)), "=", 
observedRevision))
+       }
+       if draftMarkerExists {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.ModRevision(s.draftRevisionKey()), "=", 
draftMarkerRevision))
+       } else {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.CreateRevision(s.draftRevisionKey()), "=", 0))
+       }
+       operations := make([]clientv3.Op, 0, 2)
+       if exists {
+               operations = append(operations, 
clientv3.OpDelete(s.bindingKey(draftPrefix, name)))
+       }
+       operations = append(operations, clientv3.OpPut(s.draftRevisionKey(), 
strconv.FormatInt(time.Now().UnixNano(), 10)))
+       transaction := s.kv.Txn(ctx)
+       if len(comparisons) > 0 {
+               transaction = transaction.If(comparisons...)
+       }
+       response, err := transaction.Then(operations...).Commit()
+       if err != nil {
+               return fmt.Errorf("delete route binding %q: %w", name, err)
+       }
+       if len(comparisons) > 0 && !response.Succeeded {
+               return fmt.Errorf("%w: %s", ErrRouteBindingConflict, name)
+       }
+       return nil
+}
+
+// Publish publishes exactly one route binding. The generated legacy resource
+// and method, the published high-level binding, and the publish marker are
+// written or deleted in one etcd transaction. Other routes' draft and
+// published values are left untouched.
+func (s *RouteBindingStore) Publish(ctx context.Context, name string, 
expectedDraftRevision int64) (RouteBindingPublishResult, error) {
+       ctx = routeBindingContext(ctx)
+       if expectedDraftRevision < 0 {
+               return RouteBindingPublishResult{}, errors.New("expected draft 
revision must not be negative")
+       }
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       state, err := s.readPublishState(ctx, name)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       if expectedDraftRevision > 0 && (!state.draftExists || 
state.draftRevision != expectedDraftRevision) {
+               return RouteBindingPublishResult{}, fmt.Errorf("%w: %s", 
ErrRouteBindingPublishConflict, name)
+       }
+       publishedEntries, err := s.listEntries(ctx, state.publishedPrefix)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       candidate, err := s.compilePublishCandidate(name, state.draft, 
state.draftExists, publishedEntries)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       comparisons, err := s.buildPublishComparisons(ctx, state)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       operations, publishedCount, deletedCount, err := 
s.buildSinglePublishOperations(state, candidate)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       operations = append(operations,
+               clientv3.OpPut(s.publishedRevisionKey(), 
strconv.FormatInt(time.Now().UnixNano(), 10)))
+
+       response, err := 
s.kv.Txn(ctx).If(comparisons...).Then(operations...).Commit()
+       if err != nil {
+               return RouteBindingPublishResult{}, fmt.Errorf("publish route 
binding %q: %w", name, err)
+       }
+       if !response.Succeeded {
+               return RouteBindingPublishResult{}, fmt.Errorf("%w: %s", 
ErrRouteBindingPublishConflict, name)
+       }
+       publishedRevision, err := s.publishedRouteRevision(ctx, 
state.publishedPrefix, name, candidate)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       return RouteBindingPublishResult{
+               Name:              name,
+               Revision:          response.Header.Revision,
+               DraftRevision:     state.draftRevision,
+               PublishedRevision: publishedRevision,
+               PublishedCount:    publishedCount,
+               DeletedCount:      deletedCount,
+       }, nil
+}
+
+type routeBindingPublishState struct {
+       draftPrefix       string
+       publishedPrefix   string
+       draft             routeBindingEntry
+       draftExists       bool
+       draftRevision     int64
+       published         routeBindingEntry
+       publishedExists   bool
+       publishedRevision int64
+}
+
+func (s *RouteBindingStore) readPublishState(ctx context.Context, name string) 
(routeBindingPublishState, error) {
+       state := routeBindingPublishState{
+               draftPrefix:     s.bindingPrefix(true),
+               publishedPrefix: s.bindingPrefix(false),
+       }
+       var err error
+       state.draft, state.draftExists, state.draftRevision, err = 
s.getEntry(ctx, state.draftPrefix, name)
+       if err != nil {
+               return routeBindingPublishState{}, err
+       }
+       state.published, state.publishedExists, state.publishedRevision, err = 
s.getEntry(ctx, state.publishedPrefix, name)
+       if err != nil {
+               return routeBindingPublishState{}, err
+       }
+       if !state.draftExists && !state.publishedExists {
+               return routeBindingPublishState{}, fmt.Errorf("%w: %s", 
ErrRouteBindingNotFound, name)
+       }
+       return state, nil
+}
+
+func (s *RouteBindingStore) compilePublishCandidate(name string, draft 
routeBindingEntry, draftExists bool, published []routeBindingEntry) 
(*compiledRouteEntry, error) {
+       if !draftExists {
+               return nil, nil
+       }
+       entries := make([]routeBindingEntry, 0, len(published)+1)
+       for _, entry := range published {
+               if entry.record.Object.Metadata.Name != name {
+                       entries = append(entries, entry)
+               }
+       }
+       entries = append(entries, draft)
+       compiled, err := s.compileSnapshot(entries)
+       if err != nil {
+               return nil, err
+       }
+       for index := range compiled {
+               if compiled[index].object.Metadata.Name == name {
+                       return &compiled[index], nil
+               }
+       }
+       return nil, fmt.Errorf("route binding %q was not compiled", name)
+}
+
+func (s *RouteBindingStore) buildPublishComparisons(ctx context.Context, state 
routeBindingPublishState) ([]clientv3.Cmp, error) {
+       comparisons := make([]clientv3.Cmp, 0, 2)
+       if state.draftExists {
+               // Always compare the value observed above, even when the 
caller omitted
+               // expectedDraftRevision. This closes the read/modify/write 
race.
+               comparisons = append(comparisons,
+                       
clientv3.Compare(clientv3.ModRevision(s.bindingKey(state.draftPrefix, 
state.draft.record.Object.Metadata.Name)), "=", state.draftRevision))
+       } else {
+               comparisons = append(comparisons,
+                       
clientv3.Compare(clientv3.ModRevision(s.bindingKey(state.publishedPrefix, 
state.published.record.Object.Metadata.Name)), "=", state.publishedRevision))
+       }
+       publishedMarkerRevision, publishedMarkerExists, err := 
s.markerRevision(ctx, s.publishedRevisionKey())
+       if err != nil {
+               return nil, err
+       }
+       if publishedMarkerExists {
+               comparisons = append(comparisons,
+                       
clientv3.Compare(clientv3.ModRevision(s.publishedRevisionKey()), "=", 
publishedMarkerRevision))
+               return comparisons, nil
+       }
+       comparisons = append(comparisons,
+               
clientv3.Compare(clientv3.CreateRevision(s.publishedRevisionKey()), "=", 0))
+       return comparisons, nil
+}
+
+func (s *RouteBindingStore) buildSinglePublishOperations(state 
routeBindingPublishState, candidate *compiledRouteEntry) ([]clientv3.Op, int, 
int, error) {
+       if candidate == nil {
+               return []clientv3.Op{
+                       
clientv3.OpDelete(s.runtimeResourceKey(state.published.record.ResourceID), 
clientv3.WithPrefix()),
+                       clientv3.OpDelete(s.bindingKey(state.publishedPrefix, 
state.published.record.Object.Metadata.Name)),
+               }, 0, 1, nil
+       }
+       compiled := candidate.legacy
+       operations := make([]clientv3.Op, 0, 4)
+       if state.publishedExists && state.published.record.ResourceID != 
compiled.Resource.ID {
+               operations = append(operations,
+                       
clientv3.OpDelete(s.runtimeResourceKey(state.published.record.ResourceID), 
clientv3.WithPrefix()))
+       } else if state.publishedExists && state.published.record.MethodID != 
compiled.Method.ID {
+               operations = append(operations,
+                       
clientv3.OpDelete(s.runtimeMethodKey(state.published.record.ResourceID, 
state.published.record.MethodID)))
+       }
+       resourceValue, methodValue, err := marshalCompiledRuntimeRoute(compiled)
+       if err != nil {
+               return nil, 0, 0, fmt.Errorf("encode route binding %q for 
runtime: %w", candidate.object.Metadata.Name, err)
+       }
+       recordValue, err := json.Marshal(routeBindingRecord{
+               Object:     candidate.object,
+               ResourceID: compiled.Resource.ID,
+               MethodID:   compiled.Method.ID,
+       })
+       if err != nil {
+               return nil, 0, 0, fmt.Errorf("encode published route binding 
%q: %w", candidate.object.Metadata.Name, err)
+       }
+       operations = append(operations,
+               clientv3.OpPut(s.runtimeResourceKey(compiled.Resource.ID), 
string(resourceValue)),
+               clientv3.OpPut(s.runtimeMethodKey(compiled.Resource.ID, 
compiled.Method.ID), string(methodValue)),
+               clientv3.OpPut(s.bindingKey(state.publishedPrefix, 
candidate.object.Metadata.Name), string(recordValue)))
+       return operations, 1, 0, nil
+}
+
+func (s *RouteBindingStore) publishedRouteRevision(ctx context.Context, 
prefix, name string, candidate *compiledRouteEntry) (int64, error) {
+       if candidate == nil {
+               return 0, nil
+       }
+       _, exists, revision, err := s.getEntry(ctx, prefix, name)
+       if err != nil {
+               return 0, err
+       }
+       if !exists {
+               return 0, fmt.Errorf("route binding %q disappeared after 
publish", name)
+       }
+       return revision, nil
+}
+
+// Status returns draft and published revisions for one route. The revisions
+// are independent of other route bindings in the same config namespace.
+func (s *RouteBindingStore) Status(ctx context.Context, name string) 
(RouteBindingPublishStatus, error) {
+       ctx = routeBindingContext(ctx)
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return RouteBindingPublishStatus{}, err
+       }
+       draft, draftExists, draftRevision, err := s.getEntry(ctx, 
s.bindingPrefix(true), name)
+       if err != nil {
+               return RouteBindingPublishStatus{}, err
+       }
+       published, publishedExists, publishedRevision, err := s.getEntry(ctx, 
s.bindingPrefix(false), name)
+       if err != nil {
+               return RouteBindingPublishStatus{}, err
+       }
+       if !draftExists && !publishedExists {
+               return RouteBindingPublishStatus{}, fmt.Errorf("%w: %s", 
ErrRouteBindingNotFound, name)
+       }
+       dirty := draftExists != publishedExists
+       if draftExists && publishedExists {
+               dirty = !reflect.DeepEqual(draft.record.Object, 
published.record.Object)
+       }
+       return RouteBindingPublishStatus{
+               Name:              name,
+               DraftRevision:     draftRevision,
+               PublishedRevision: publishedRevision,
+               DraftExists:       draftExists,
+               PublishedExists:   publishedExists,
+               Dirty:             dirty,
+       }, nil
+}
+
+// Diff compares one route's draft and published objects. It does not read or
+// mutate any other route's draft state.
+func (s *RouteBindingStore) Diff(ctx context.Context, name string) 
(RouteBindingDiff, error) {
+       ctx = routeBindingContext(ctx)
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return RouteBindingDiff{}, err
+       }
+       draft, draftExists, _, err := s.getEntry(ctx, s.bindingPrefix(true), 
name)
+       if err != nil {
+               return RouteBindingDiff{}, err
+       }
+       published, publishedExists, _, err := s.getEntry(ctx, 
s.bindingPrefix(false), name)
+       if err != nil {
+               return RouteBindingDiff{}, err
+       }
+       if !draftExists && !publishedExists {
+               return RouteBindingDiff{}, fmt.Errorf("%w: %s", 
ErrRouteBindingNotFound, name)
+       }
+
+       var draftView, publishedView *RouteBinding
+       if draftExists {
+               view := routeBindingView(draft)
+               draftView = &view
+       }
+       if publishedExists {
+               view := routeBindingView(published)
+               publishedView = &view
+       }
+       changes, err := diffRouteBindingObjects(
+               func() schema.AdminObject {
+                       if publishedExists {
+                               return published.record.Object
+                       }
+                       return schema.AdminObject{}
+               }(),
+               func() schema.AdminObject {
+                       if draftExists {
+                               return draft.record.Object
+                       }
+                       return schema.AdminObject{}
+               }(),
+               publishedExists,
+               draftExists,
+       )
+       if err != nil {
+               return RouteBindingDiff{}, err
+       }
+       return RouteBindingDiff{
+               Name:      name,
+               Draft:     draftView,
+               Published: publishedView,
+               Changes:   changes,
+       }, nil
+}
+
+func diffRouteBindingObjects(before, after schema.AdminObject, beforeExists, 
afterExists bool) ([]RouteBindingDiffChange, error) {
+       beforeValues := make(map[string]any)
+       afterValues := make(map[string]any)
+       if beforeExists {
+               if err := flattenRouteBindingValue("", before, beforeValues); 
err != nil {
+                       return nil, err
+               }
+       }
+       if afterExists {
+               if err := flattenRouteBindingValue("", after, afterValues); err 
!= nil {
+                       return nil, err
+               }
+       }
+       keys := make(map[string]struct{}, len(beforeValues)+len(afterValues))
+       for key := range beforeValues {
+               keys[key] = struct{}{}
+       }
+       for key := range afterValues {
+               keys[key] = struct{}{}
+       }
+       paths := make([]string, 0, len(keys))
+       for key := range keys {
+               paths = append(paths, key)
+       }
+       sort.Strings(paths)
+       changes := make([]RouteBindingDiffChange, 0, len(paths))
+       for _, path := range paths {
+               beforeValue, beforeOK := beforeValues[path]
+               afterValue, afterOK := afterValues[path]
+               if beforeOK && afterOK && reflect.DeepEqual(beforeValue, 
afterValue) {
+                       continue
+               }
+               changes = append(changes, RouteBindingDiffChange{
+                       Path:   path,
+                       Before: valueOrNil(beforeValue, beforeOK),
+                       After:  valueOrNil(afterValue, afterOK),
+               })
+       }
+       return changes, nil
+}
+
+func flattenRouteBindingValue(path string, value any, result map[string]any) 
error {
+       switch typed := value.(type) {
+       case schema.AdminObject:
+               return flattenRouteBindingValue(path, map[string]any{
+                       "kind":     typed.Kind,
+                       "metadata": typed.Metadata,
+                       "spec":     typed.Spec,
+               }, result)
+       case schema.ObjectMetadata:
+               return flattenRouteBindingValue(path, map[string]any{"name": 
typed.Name}, result)
+       case map[string]any:
+               if len(typed) == 0 {
+                       result[path] = typed
+                       return nil
+               }
+               keys := make([]string, 0, len(typed))
+               for key := range typed {
+                       keys = append(keys, key)
+               }
+               sort.Strings(keys)
+               for _, key := range keys {
+                       childPath := key
+                       if path != "" {
+                               childPath = path + "." + key
+                       }
+                       if err := flattenRouteBindingValue(childPath, 
typed[key], result); err != nil {
+                               return err
+                       }
+               }
+               return nil
+       case []any:
+               if len(typed) == 0 {
+                       result[path] = typed
+                       return nil
+               }
+               for index, item := range typed {
+                       childPath := fmt.Sprintf("%s[%d]", path, index)
+                       if err := flattenRouteBindingValue(childPath, item, 
result); err != nil {
+                               return err
+                       }
+               }
+               return nil
+       default:
+               result[path] = typed
+               return nil
+       }
+}
+
+func valueOrNil(value any, exists bool) any {
+       if !exists {
+               return nil
+       }
+       return value
+}
+
+// PublishAll compiles the complete draft snapshot and publishes it with one
+// etcd transaction. The transaction updates the high-level published records,
+// generated legacy resources/methods, stale-route deletions, and the published
+// revision marker together. A draft marker compare prevents a concurrent save
+// or delete from being silently omitted from the snapshot.
+func (s *RouteBindingStore) PublishAll(ctx context.Context, 
expectedDraftRevision int64) (RouteBindingPublishResult, error) {
+       ctx = routeBindingContext(ctx)
+       if expectedDraftRevision < 0 {
+               return RouteBindingPublishResult{}, errors.New("expected draft 
revision must not be negative")
+       }
+       draftEntries, err := s.listEntries(ctx, s.bindingPrefix(true))
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       publishedEntries, err := s.listEntries(ctx, s.bindingPrefix(false))
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+
+       draftRevision, draftMarkerExists, err := s.markerRevision(ctx, 
s.draftRevisionKey())
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       if expectedDraftRevision > 0 && (!draftMarkerExists || draftRevision != 
expectedDraftRevision) {
+               return RouteBindingPublishResult{}, fmt.Errorf("%w: draft 
revision changed", ErrRouteBindingPublishConflict)
+       }
+
+       compiled, err := s.compileSnapshot(draftEntries)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       operations, publishedCount, deletedCount, err := 
s.buildPublishOperations(compiled, publishedEntries)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+
+       comparisons := make([]clientv3.Cmp, 0, 1)
+       if draftMarkerExists {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.ModRevision(s.draftRevisionKey()), "=", 
draftRevision))
+       } else {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.CreateRevision(s.draftRevisionKey()), "=", 0))
+       }
+       operations = append(operations,
+               clientv3.OpPut(s.publishedRevisionKey(), 
strconv.FormatInt(draftRevision, 10)),
+       )
+       response, err := 
s.kv.Txn(ctx).If(comparisons...).Then(operations...).Commit()
+       if err != nil {
+               return RouteBindingPublishResult{}, fmt.Errorf("publish route 
bindings: %w", err)
+       }
+       if !response.Succeeded {
+               return RouteBindingPublishResult{}, fmt.Errorf("%w: draft 
revision changed", ErrRouteBindingPublishConflict)
+       }
+       return RouteBindingPublishResult{
+               Revision:       response.Header.Revision,
+               DraftRevision:  draftRevision,
+               PublishedCount: publishedCount,
+               DeletedCount:   deletedCount,
+       }, nil
+}
+
+// PublishStatus returns the current draft and published marker revisions.
+func (s *RouteBindingStore) PublishStatus(ctx context.Context) 
(RouteBindingPublishStatus, error) {
+       ctx = routeBindingContext(ctx)
+       draftRevision, _, err := s.markerRevision(ctx, s.draftRevisionKey())
+       if err != nil {
+               return RouteBindingPublishStatus{}, err
+       }
+       publishedRevision, _, err := s.markerRevision(ctx, 
s.publishedRevisionKey())
+       if err != nil {
+               return RouteBindingPublishStatus{}, err
+       }
+       return RouteBindingPublishStatus{
+               DraftRevision:     draftRevision,
+               PublishedRevision: publishedRevision,
+       }, nil
+}
+
+type compiledRouteEntry struct {
+       entry  routeBindingEntry
+       object schema.AdminObject
+       legacy schema.CompiledRoute
+}
+
+func (s *RouteBindingStore) compileSnapshot(entries []routeBindingEntry) 
([]compiledRouteEntry, error) {
+       result := make([]compiledRouteEntry, 0, len(entries))
+       paths := make(map[string]string, len(entries))
+       resourceIDs := make(map[int]string, len(entries))
+       for _, entry := range entries {
+               if entry.record.ResourceID <= 0 || entry.record.MethodID <= 0 {
+                       return nil, fmt.Errorf("route binding %q has invalid 
runtime identity", entry.record.Object.Metadata.Name)

Review Comment:
   compileSnapshot 只检查新 route-bindings 之间的 path + method 冲突,没有检查已有 legacy 
resource。发布相同路径和 HTTP 方法时,Pixiu watcher 的 AddAPI 会拒绝重复路由,但 etcd 
发布事务已经成功,用户会看到“发布成功”而运行时仍使用旧路由。



##########
admin/logic/route_binding.go:
##########
@@ -0,0 +1,1322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package logic
+
+import (
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "net/url"
+       "reflect"
+       "sort"
+       "strconv"
+       "strings"
+       "time"
+)
+
+import (
+       clientv3 "go.etcd.io/etcd/client/v3"
+)
+
+import (
+       adminconfig "github.com/apache/dubbo-go-pixiu/admin/config"
+       commonyaml "github.com/apache/dubbo-go-pixiu/pkg/common/yaml"
+       legacyconfig "github.com/apache/dubbo-go-pixiu/pkg/config"
+       "github.com/apache/dubbo-go-pixiu/pkg/config/schema"
+)
+
+const (
+       routeBindingPathSegment        = "route-bindings"
+       routeBindingRevisionSegment    = "route-bindings-revision"
+       routeBindingRuntimeResourceDir = "resources"
+       maxRouteBindingIDRetries       = 16
+)
+
+var (
+       ErrRouteBindingNotFound        = errors.New("admin route binding not 
found")
+       ErrRouteBindingAlreadyExists   = errors.New("admin route binding 
already exists")
+       ErrRouteBindingConflict        = errors.New("admin route binding was 
modified concurrently")
+       ErrRouteBindingPublishConflict = errors.New("admin route binding 
publish conflict")
+)
+
+// routeBindingKV is the small part of clientv3.KV needed by the route store.
+// Keeping this boundary narrow makes the planning and transaction logic easy
+// to exercise without coupling it to the gost wrapper.
+type routeBindingKV interface {
+       Get(context.Context, string, ...clientv3.OpOption) 
(*clientv3.GetResponse, error)
+       Txn(context.Context) clientv3.Txn
+}
+
+// RouteBinding is the Admin-facing response shape. ResourceID and MethodID
+// are stable runtime identities used by Pixiu's legacy etcd watcher; they are
+// not part of the user-editable AdminRouteBinding object.
+type RouteBinding struct {
+       Object     schema.AdminObject `json:"object"`
+       ResourceID int                `json:"resourceId"`
+       MethodID   int                `json:"methodId"`
+       Revision   int64              `json:"revision"`
+}
+
+// RouteBindingPublishResult describes one atomic publish transaction.
+type RouteBindingPublishResult struct {
+       Name              string `json:"name"`
+       Revision          int64  `json:"revision"`
+       DraftRevision     int64  `json:"draftRevision"`
+       PublishedRevision int64  `json:"publishedRevision"`
+       PublishedCount    int    `json:"publishedCount"`
+       DeletedCount      int    `json:"deletedCount"`
+}
+
+// RouteBindingPublishStatus exposes one route's draft and published state.
+// Revisions are the etcd key mod revisions for that route, not a global
+// configuration revision.
+type RouteBindingPublishStatus struct {
+       Name              string `json:"name"`
+       DraftRevision     int64  `json:"draftRevision"`
+       PublishedRevision int64  `json:"publishedRevision"`
+       DraftExists       bool   `json:"draftExists"`
+       PublishedExists   bool   `json:"publishedExists"`
+       Dirty             bool   `json:"dirty"`
+}
+
+type RouteBindingDiffChange struct {
+       Path   string `json:"path"`
+       Before any    `json:"before"`
+       After  any    `json:"after"`
+}
+
+// RouteBindingDiff compares one route's draft object with its published
+// object. A nil side represents a route that only exists in the other scope.
+type RouteBindingDiff struct {
+       Name      string                   `json:"name"`
+       Draft     *RouteBinding            `json:"draft,omitempty"`
+       Published *RouteBinding            `json:"published,omitempty"`
+       Changes   []RouteBindingDiffChange `json:"changes"`
+}
+
+type routeBindingRecord struct {
+       Object     schema.AdminObject `json:"object"`
+       ResourceID int                `json:"resourceId"`
+       MethodID   int                `json:"methodId"`
+}
+
+type routeBindingEntry struct {
+       record   routeBindingRecord
+       key      string
+       revision int64
+}
+
+// RouteBindingStore owns the AdminRouteBinding persistence and publish
+// boundary. Published runtime data is deliberately written in the existing
+// Resource/Method layout so the current Pixiu watcher can consume it.
+type RouteBindingStore struct {
+       kv       routeBindingKV
+       root     string
+       registry *schema.Registry
+}
+
+// NewRouteBindingStore creates a store over an etcd v3 client.
+func NewRouteBindingStore(kv routeBindingKV, root string, registry 
*schema.Registry) (*RouteBindingStore, error) {
+       if kv == nil {
+               return nil, errors.New("route binding etcd client is nil")
+       }
+       root = strings.TrimRight(strings.TrimSpace(root), "/")
+       if root == "" {
+               return nil, errors.New("route binding etcd root path is empty")
+       }
+       if registry == nil {
+               var err error
+               registry, err = schema.NewBuiltinRegistry()
+               if err != nil {
+                       return nil, fmt.Errorf("create route binding schema 
registry: %w", err)
+               }
+       }
+       return &RouteBindingStore{kv: kv, root: root, registry: registry}, nil
+}
+
+// NewAdminRouteBindingStore creates the production store from the Admin
+// process' configured etcd client and root path.
+func NewAdminRouteBindingStore() (*RouteBindingStore, error) {
+       if adminconfig.Bootstrap == nil {
+               return nil, errors.New("admin bootstrap is nil")
+       }
+       if adminconfig.Client == nil {
+               return nil, errors.New("admin etcd client is nil")
+       }
+       rawClient := adminconfig.Client.GetRawClient()
+       if rawClient == nil {
+               return nil, errors.New("admin raw etcd client is nil")
+       }
+       return NewRouteBindingStore(rawClient, adminconfig.Bootstrap.GetPath(), 
nil)
+}
+
+func routeBindingContext(ctx context.Context) context.Context {
+       if ctx == nil {
+               return context.Background()
+       }
+       return ctx
+}

Review Comment:
   修改接口直接使用请求体中的新名称调用 SaveDraft(..., create=false)。如果把路由 A 改成路由 B,后端会创建 B,但不会删除 
A;之后发布会同时保留两条路由。



##########
admin/logic/route_binding.go:
##########
@@ -0,0 +1,1322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package logic
+
+import (
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "net/url"
+       "reflect"
+       "sort"
+       "strconv"
+       "strings"
+       "time"
+)
+
+import (
+       clientv3 "go.etcd.io/etcd/client/v3"
+)
+
+import (
+       adminconfig "github.com/apache/dubbo-go-pixiu/admin/config"
+       commonyaml "github.com/apache/dubbo-go-pixiu/pkg/common/yaml"
+       legacyconfig "github.com/apache/dubbo-go-pixiu/pkg/config"
+       "github.com/apache/dubbo-go-pixiu/pkg/config/schema"
+)
+
+const (
+       routeBindingPathSegment        = "route-bindings"
+       routeBindingRevisionSegment    = "route-bindings-revision"
+       routeBindingRuntimeResourceDir = "resources"
+       maxRouteBindingIDRetries       = 16
+)
+
+var (
+       ErrRouteBindingNotFound        = errors.New("admin route binding not 
found")
+       ErrRouteBindingAlreadyExists   = errors.New("admin route binding 
already exists")
+       ErrRouteBindingConflict        = errors.New("admin route binding was 
modified concurrently")
+       ErrRouteBindingPublishConflict = errors.New("admin route binding 
publish conflict")
+)
+
+// routeBindingKV is the small part of clientv3.KV needed by the route store.
+// Keeping this boundary narrow makes the planning and transaction logic easy
+// to exercise without coupling it to the gost wrapper.
+type routeBindingKV interface {
+       Get(context.Context, string, ...clientv3.OpOption) 
(*clientv3.GetResponse, error)
+       Txn(context.Context) clientv3.Txn
+}
+
+// RouteBinding is the Admin-facing response shape. ResourceID and MethodID
+// are stable runtime identities used by Pixiu's legacy etcd watcher; they are
+// not part of the user-editable AdminRouteBinding object.
+type RouteBinding struct {
+       Object     schema.AdminObject `json:"object"`
+       ResourceID int                `json:"resourceId"`
+       MethodID   int                `json:"methodId"`
+       Revision   int64              `json:"revision"`
+}
+
+// RouteBindingPublishResult describes one atomic publish transaction.
+type RouteBindingPublishResult struct {
+       Name              string `json:"name"`
+       Revision          int64  `json:"revision"`
+       DraftRevision     int64  `json:"draftRevision"`
+       PublishedRevision int64  `json:"publishedRevision"`
+       PublishedCount    int    `json:"publishedCount"`
+       DeletedCount      int    `json:"deletedCount"`
+}
+
+// RouteBindingPublishStatus exposes one route's draft and published state.
+// Revisions are the etcd key mod revisions for that route, not a global
+// configuration revision.
+type RouteBindingPublishStatus struct {
+       Name              string `json:"name"`
+       DraftRevision     int64  `json:"draftRevision"`
+       PublishedRevision int64  `json:"publishedRevision"`
+       DraftExists       bool   `json:"draftExists"`
+       PublishedExists   bool   `json:"publishedExists"`
+       Dirty             bool   `json:"dirty"`
+}
+
+type RouteBindingDiffChange struct {
+       Path   string `json:"path"`
+       Before any    `json:"before"`
+       After  any    `json:"after"`
+}
+
+// RouteBindingDiff compares one route's draft object with its published
+// object. A nil side represents a route that only exists in the other scope.
+type RouteBindingDiff struct {
+       Name      string                   `json:"name"`
+       Draft     *RouteBinding            `json:"draft,omitempty"`
+       Published *RouteBinding            `json:"published,omitempty"`
+       Changes   []RouteBindingDiffChange `json:"changes"`
+}
+
+type routeBindingRecord struct {
+       Object     schema.AdminObject `json:"object"`
+       ResourceID int                `json:"resourceId"`
+       MethodID   int                `json:"methodId"`
+}
+
+type routeBindingEntry struct {
+       record   routeBindingRecord
+       key      string
+       revision int64
+}
+
+// RouteBindingStore owns the AdminRouteBinding persistence and publish
+// boundary. Published runtime data is deliberately written in the existing
+// Resource/Method layout so the current Pixiu watcher can consume it.
+type RouteBindingStore struct {
+       kv       routeBindingKV
+       root     string
+       registry *schema.Registry
+}
+
+// NewRouteBindingStore creates a store over an etcd v3 client.
+func NewRouteBindingStore(kv routeBindingKV, root string, registry 
*schema.Registry) (*RouteBindingStore, error) {
+       if kv == nil {
+               return nil, errors.New("route binding etcd client is nil")
+       }
+       root = strings.TrimRight(strings.TrimSpace(root), "/")
+       if root == "" {
+               return nil, errors.New("route binding etcd root path is empty")
+       }
+       if registry == nil {
+               var err error
+               registry, err = schema.NewBuiltinRegistry()
+               if err != nil {
+                       return nil, fmt.Errorf("create route binding schema 
registry: %w", err)
+               }
+       }
+       return &RouteBindingStore{kv: kv, root: root, registry: registry}, nil
+}
+
+// NewAdminRouteBindingStore creates the production store from the Admin
+// process' configured etcd client and root path.
+func NewAdminRouteBindingStore() (*RouteBindingStore, error) {
+       if adminconfig.Bootstrap == nil {
+               return nil, errors.New("admin bootstrap is nil")
+       }
+       if adminconfig.Client == nil {
+               return nil, errors.New("admin etcd client is nil")
+       }
+       rawClient := adminconfig.Client.GetRawClient()
+       if rawClient == nil {
+               return nil, errors.New("admin raw etcd client is nil")
+       }
+       return NewRouteBindingStore(rawClient, adminconfig.Bootstrap.GetPath(), 
nil)
+}
+
+func routeBindingContext(ctx context.Context) context.Context {
+       if ctx == nil {
+               return context.Background()
+       }
+       return ctx
+}
+
+// Registry returns the schema registry used by this store.
+func (s *RouteBindingStore) Registry() *schema.Registry {
+       return s.registry
+}
+
+// List returns high-level route bindings from either the draft or published
+// namespace. Empty namespaces are returned as an empty slice.
+func (s *RouteBindingStore) List(ctx context.Context, unpublished bool) 
([]RouteBinding, error) {
+       ctx = routeBindingContext(ctx)
+       entries, err := s.listEntries(ctx, s.bindingPrefix(unpublished))
+       if err != nil {
+               return nil, err
+       }
+       result := make([]RouteBinding, 0, len(entries))
+       for _, entry := range entries {
+               result = append(result, routeBindingView(entry))
+       }
+       return result, nil
+}
+
+// Get returns one high-level route binding from either the draft or published
+// namespace.
+func (s *RouteBindingStore) Get(ctx context.Context, name string, unpublished 
bool) (RouteBinding, error) {
+       ctx = routeBindingContext(ctx)
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       entry, exists, _, err := s.getEntry(ctx, s.bindingPrefix(unpublished), 
name)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       if !exists {
+               return RouteBinding{}, fmt.Errorf("%w: %s", 
ErrRouteBindingNotFound, name)
+       }
+       return routeBindingView(entry), nil
+}
+
+// Normalize validates an object and applies all registered schema defaults.
+// It is useful to the controller's validate and preview endpoints and does
+// not require an etcd write.
+func (s *RouteBindingStore) Normalize(object schema.AdminObject) 
(schema.AdminObject, error) {
+       normalized, err := s.registry.Normalize(object)
+       if err != nil {
+               return normalized, err
+       }
+       name, err := normalizeRouteBindingName(normalized.Metadata.Name)
+       if err != nil {
+               return normalized, err
+       }
+       normalized.Metadata.Name = name
+       return normalized, nil
+}
+
+// Preview validates and compiles an Admin object without writing it.
+func (s *RouteBindingStore) Preview(object schema.AdminObject) 
(schema.AdminObject, []byte, error) {
+       normalized, err := s.Normalize(object)
+       if err != nil {
+               return normalized, nil, err
+       }
+       compiled, err := schema.CompileAdminRouteBinding(s.registry, normalized)
+       if err != nil {
+               return normalized, nil, err
+       }
+       preview, err := compiled.PreviewYAML()
+       if err != nil {
+               return normalized, nil, err
+       }
+       return normalized, preview, nil
+}
+
+// SaveDraft validates and atomically stores one AdminRouteBinding in the
+// draft namespace. POST callers use create=true; PUT callers use create=false.
+// expectedRevision is the draft key's mod revision and may be zero to allow
+// an unconditional write.
+func (s *RouteBindingStore) SaveDraft(ctx context.Context, object 
schema.AdminObject, create bool, expectedRevision int64) (RouteBinding, error) {
+       ctx = routeBindingContext(ctx)
+       if err := validateSaveDraftRequest(create, expectedRevision); err != 
nil {
+               return RouteBinding{}, err
+       }
+       normalized, err := s.Normalize(object)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       name := normalized.Metadata.Name
+       draftPrefix := s.bindingPrefix(true)
+       draftEntry, draftExists, draftRevision, err := s.getEntry(ctx, 
draftPrefix, name)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       if err := validateDraftSaveState(name, create, expectedRevision, 
draftExists, draftRevision); err != nil {
+               return RouteBinding{}, err
+       }
+
+       resourceID, methodID, err := s.resolveRuntimeIdentity(ctx, name, 
draftEntry, draftExists)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+
+       record := routeBindingRecord{
+               Object:     normalized,
+               ResourceID: resourceID,
+               MethodID:   methodID,
+       }
+       value, err := json.Marshal(record)
+       if err != nil {
+               return RouteBinding{}, fmt.Errorf("encode route binding %q: 
%w", name, err)
+       }
+       if err := s.commitDraft(ctx, name, draftPrefix, value, create, 
expectedRevision); err != nil {
+               return RouteBinding{}, err
+       }
+
+       saved, exists, revision, err := s.getEntry(ctx, draftPrefix, name)
+       if err != nil {
+               return RouteBinding{}, err
+       }
+       if !exists {
+               return RouteBinding{}, fmt.Errorf("route binding %q disappeared 
after save", name)
+       }
+       saved.revision = revision
+       return routeBindingView(saved), nil
+}
+
+func validateSaveDraftRequest(create bool, expectedRevision int64) error {
+       if expectedRevision < 0 {
+               return errors.New("expected revision must not be negative")
+       }
+       if create && expectedRevision > 0 {
+               return errors.New("create route binding does not accept an 
expected revision")
+       }
+       return nil
+}
+
+func validateDraftSaveState(name string, create bool, expectedRevision int64, 
exists bool, revision int64) error {
+       if create && exists {
+               return fmt.Errorf("%w: %s", ErrRouteBindingAlreadyExists, name)
+       }
+       if expectedRevision > 0 && (!exists || revision != expectedRevision) {
+               return fmt.Errorf("%w: %s", ErrRouteBindingConflict, name)
+       }
+       return nil
+}
+
+func (s *RouteBindingStore) resolveRuntimeIdentity(ctx context.Context, name 
string, draft routeBindingEntry, draftExists bool) (int, int, error) {
+       resourceID, methodID := draft.record.ResourceID, draft.record.MethodID
+       if !draftExists {
+               published, exists, _, err := s.getEntry(ctx, 
s.bindingPrefix(false), name)
+               if err != nil {
+                       return 0, 0, err
+               }
+               if exists {
+                       resourceID, methodID = published.record.ResourceID, 
published.record.MethodID
+               }
+       }
+       if resourceID <= 0 {
+               var err error
+               resourceID, err = s.allocateRuntimeID(ctx)
+               if err != nil {
+                       return 0, 0, err
+               }
+       }
+       if methodID <= 0 {
+               // One AdminRouteBinding currently compiles to exactly one 
Method. Method
+               // IDs are scoped below the resource, so sharing the stable 
route ID is
+               // safe and avoids a second allocation transaction.
+               methodID = resourceID
+       }
+       return resourceID, methodID, nil
+}
+
+func (s *RouteBindingStore) commitDraft(ctx context.Context, name, draftPrefix 
string, value []byte, create bool, expectedRevision int64) error {
+       comparisons := make([]clientv3.Cmp, 0, 1)
+       if create {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.CreateRevision(s.bindingKey(draftPrefix, name)), "=", 
0))
+       } else if expectedRevision > 0 {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.ModRevision(s.bindingKey(draftPrefix, name)), "=", 
expectedRevision))
+       }
+       operations := []clientv3.Op{
+               clientv3.OpPut(s.bindingKey(draftPrefix, name), string(value)),
+               clientv3.OpPut(s.draftRevisionKey(), 
strconv.FormatInt(time.Now().UnixNano(), 10)),
+       }
+       transaction := s.kv.Txn(ctx)
+       if len(comparisons) > 0 {
+               transaction = transaction.If(comparisons...)
+       }
+       response, err := transaction.Then(operations...).Commit()
+       if err != nil {
+               return fmt.Errorf("save route binding %q: %w", name, err)
+       }
+       if response.Succeeded {
+               return nil
+       }
+       if create {
+               return fmt.Errorf("%w: %s", ErrRouteBindingAlreadyExists, name)
+       }
+       return fmt.Errorf("%w: %s", ErrRouteBindingConflict, name)
+}
+
+// DeleteDraft removes a draft. If only a published binding exists, the
+// deletion is still recorded by advancing the draft revision; the next
+// PublishAll transaction will remove that published route atomically.
+func (s *RouteBindingStore) DeleteDraft(ctx context.Context, name string, 
expectedRevision int64) error {
+       ctx = routeBindingContext(ctx)
+       if expectedRevision < 0 {
+               return errors.New("expected revision must not be negative")
+       }
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return err
+       }
+       draftPrefix := s.bindingPrefix(true)
+       _, exists, revision, err := s.getEntry(ctx, draftPrefix, name)
+       if err != nil {
+               return err
+       }
+       _, publishedExists, _, err := s.getEntry(ctx, s.bindingPrefix(false), 
name)
+       if err != nil {
+               return err
+       }
+       if !exists && !publishedExists {
+               return fmt.Errorf("%w: %s", ErrRouteBindingNotFound, name)
+       }
+       if expectedRevision > 0 && (!exists || revision != expectedRevision) {
+               return fmt.Errorf("%w: %s", ErrRouteBindingConflict, name)
+       }
+       draftMarkerRevision, draftMarkerExists, err := s.markerRevision(ctx, 
s.draftRevisionKey())
+       if err != nil {
+               return err
+       }
+
+       comparisons := make([]clientv3.Cmp, 0, 2)
+       if exists {
+               observedRevision := revision
+               if expectedRevision > 0 {
+                       observedRevision = expectedRevision
+               }
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.ModRevision(s.bindingKey(draftPrefix, name)), "=", 
observedRevision))
+       }
+       if draftMarkerExists {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.ModRevision(s.draftRevisionKey()), "=", 
draftMarkerRevision))
+       } else {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.CreateRevision(s.draftRevisionKey()), "=", 0))
+       }
+       operations := make([]clientv3.Op, 0, 2)
+       if exists {
+               operations = append(operations, 
clientv3.OpDelete(s.bindingKey(draftPrefix, name)))
+       }
+       operations = append(operations, clientv3.OpPut(s.draftRevisionKey(), 
strconv.FormatInt(time.Now().UnixNano(), 10)))
+       transaction := s.kv.Txn(ctx)
+       if len(comparisons) > 0 {
+               transaction = transaction.If(comparisons...)
+       }
+       response, err := transaction.Then(operations...).Commit()
+       if err != nil {
+               return fmt.Errorf("delete route binding %q: %w", name, err)
+       }
+       if len(comparisons) > 0 && !response.Succeeded {
+               return fmt.Errorf("%w: %s", ErrRouteBindingConflict, name)
+       }
+       return nil
+}
+
+// Publish publishes exactly one route binding. The generated legacy resource
+// and method, the published high-level binding, and the publish marker are
+// written or deleted in one etcd transaction. Other routes' draft and
+// published values are left untouched.
+func (s *RouteBindingStore) Publish(ctx context.Context, name string, 
expectedDraftRevision int64) (RouteBindingPublishResult, error) {
+       ctx = routeBindingContext(ctx)
+       if expectedDraftRevision < 0 {
+               return RouteBindingPublishResult{}, errors.New("expected draft 
revision must not be negative")
+       }
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       state, err := s.readPublishState(ctx, name)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       if expectedDraftRevision > 0 && (!state.draftExists || 
state.draftRevision != expectedDraftRevision) {
+               return RouteBindingPublishResult{}, fmt.Errorf("%w: %s", 
ErrRouteBindingPublishConflict, name)
+       }
+       publishedEntries, err := s.listEntries(ctx, state.publishedPrefix)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       candidate, err := s.compilePublishCandidate(name, state.draft, 
state.draftExists, publishedEntries)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       comparisons, err := s.buildPublishComparisons(ctx, state)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       operations, publishedCount, deletedCount, err := 
s.buildSinglePublishOperations(state, candidate)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       operations = append(operations,
+               clientv3.OpPut(s.publishedRevisionKey(), 
strconv.FormatInt(time.Now().UnixNano(), 10)))
+
+       response, err := 
s.kv.Txn(ctx).If(comparisons...).Then(operations...).Commit()
+       if err != nil {
+               return RouteBindingPublishResult{}, fmt.Errorf("publish route 
binding %q: %w", name, err)
+       }
+       if !response.Succeeded {
+               return RouteBindingPublishResult{}, fmt.Errorf("%w: %s", 
ErrRouteBindingPublishConflict, name)
+       }
+       publishedRevision, err := s.publishedRouteRevision(ctx, 
state.publishedPrefix, name, candidate)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       return RouteBindingPublishResult{
+               Name:              name,
+               Revision:          response.Header.Revision,
+               DraftRevision:     state.draftRevision,
+               PublishedRevision: publishedRevision,
+               PublishedCount:    publishedCount,
+               DeletedCount:      deletedCount,
+       }, nil
+}
+
+type routeBindingPublishState struct {
+       draftPrefix       string
+       publishedPrefix   string
+       draft             routeBindingEntry
+       draftExists       bool
+       draftRevision     int64
+       published         routeBindingEntry
+       publishedExists   bool
+       publishedRevision int64
+}
+
+func (s *RouteBindingStore) readPublishState(ctx context.Context, name string) 
(routeBindingPublishState, error) {
+       state := routeBindingPublishState{
+               draftPrefix:     s.bindingPrefix(true),
+               publishedPrefix: s.bindingPrefix(false),
+       }
+       var err error
+       state.draft, state.draftExists, state.draftRevision, err = 
s.getEntry(ctx, state.draftPrefix, name)
+       if err != nil {
+               return routeBindingPublishState{}, err
+       }
+       state.published, state.publishedExists, state.publishedRevision, err = 
s.getEntry(ctx, state.publishedPrefix, name)
+       if err != nil {
+               return routeBindingPublishState{}, err
+       }
+       if !state.draftExists && !state.publishedExists {
+               return routeBindingPublishState{}, fmt.Errorf("%w: %s", 
ErrRouteBindingNotFound, name)
+       }
+       return state, nil
+}
+
+func (s *RouteBindingStore) compilePublishCandidate(name string, draft 
routeBindingEntry, draftExists bool, published []routeBindingEntry) 
(*compiledRouteEntry, error) {
+       if !draftExists {
+               return nil, nil
+       }
+       entries := make([]routeBindingEntry, 0, len(published)+1)
+       for _, entry := range published {
+               if entry.record.Object.Metadata.Name != name {
+                       entries = append(entries, entry)
+               }
+       }
+       entries = append(entries, draft)
+       compiled, err := s.compileSnapshot(entries)
+       if err != nil {
+               return nil, err
+       }
+       for index := range compiled {
+               if compiled[index].object.Metadata.Name == name {
+                       return &compiled[index], nil
+               }
+       }
+       return nil, fmt.Errorf("route binding %q was not compiled", name)
+}
+
+func (s *RouteBindingStore) buildPublishComparisons(ctx context.Context, state 
routeBindingPublishState) ([]clientv3.Cmp, error) {
+       comparisons := make([]clientv3.Cmp, 0, 2)
+       if state.draftExists {
+               // Always compare the value observed above, even when the 
caller omitted
+               // expectedDraftRevision. This closes the read/modify/write 
race.
+               comparisons = append(comparisons,
+                       
clientv3.Compare(clientv3.ModRevision(s.bindingKey(state.draftPrefix, 
state.draft.record.Object.Metadata.Name)), "=", state.draftRevision))
+       } else {
+               comparisons = append(comparisons,
+                       
clientv3.Compare(clientv3.ModRevision(s.bindingKey(state.publishedPrefix, 
state.published.record.Object.Metadata.Name)), "=", state.publishedRevision))
+       }
+       publishedMarkerRevision, publishedMarkerExists, err := 
s.markerRevision(ctx, s.publishedRevisionKey())
+       if err != nil {
+               return nil, err
+       }
+       if publishedMarkerExists {
+               comparisons = append(comparisons,
+                       
clientv3.Compare(clientv3.ModRevision(s.publishedRevisionKey()), "=", 
publishedMarkerRevision))
+               return comparisons, nil
+       }
+       comparisons = append(comparisons,
+               
clientv3.Compare(clientv3.CreateRevision(s.publishedRevisionKey()), "=", 0))
+       return comparisons, nil
+}
+
+func (s *RouteBindingStore) buildSinglePublishOperations(state 
routeBindingPublishState, candidate *compiledRouteEntry) ([]clientv3.Op, int, 
int, error) {
+       if candidate == nil {
+               return []clientv3.Op{
+                       
clientv3.OpDelete(s.runtimeResourceKey(state.published.record.ResourceID), 
clientv3.WithPrefix()),
+                       clientv3.OpDelete(s.bindingKey(state.publishedPrefix, 
state.published.record.Object.Metadata.Name)),
+               }, 0, 1, nil
+       }
+       compiled := candidate.legacy
+       operations := make([]clientv3.Op, 0, 4)
+       if state.publishedExists && state.published.record.ResourceID != 
compiled.Resource.ID {
+               operations = append(operations,
+                       
clientv3.OpDelete(s.runtimeResourceKey(state.published.record.ResourceID), 
clientv3.WithPrefix()))
+       } else if state.publishedExists && state.published.record.MethodID != 
compiled.Method.ID {
+               operations = append(operations,
+                       
clientv3.OpDelete(s.runtimeMethodKey(state.published.record.ResourceID, 
state.published.record.MethodID)))
+       }
+       resourceValue, methodValue, err := marshalCompiledRuntimeRoute(compiled)
+       if err != nil {
+               return nil, 0, 0, fmt.Errorf("encode route binding %q for 
runtime: %w", candidate.object.Metadata.Name, err)
+       }
+       recordValue, err := json.Marshal(routeBindingRecord{
+               Object:     candidate.object,
+               ResourceID: compiled.Resource.ID,
+               MethodID:   compiled.Method.ID,
+       })
+       if err != nil {
+               return nil, 0, 0, fmt.Errorf("encode published route binding 
%q: %w", candidate.object.Metadata.Name, err)
+       }
+       operations = append(operations,
+               clientv3.OpPut(s.runtimeResourceKey(compiled.Resource.ID), 
string(resourceValue)),
+               clientv3.OpPut(s.runtimeMethodKey(compiled.Resource.ID, 
compiled.Method.ID), string(methodValue)),
+               clientv3.OpPut(s.bindingKey(state.publishedPrefix, 
candidate.object.Metadata.Name), string(recordValue)))
+       return operations, 1, 0, nil
+}
+
+func (s *RouteBindingStore) publishedRouteRevision(ctx context.Context, 
prefix, name string, candidate *compiledRouteEntry) (int64, error) {
+       if candidate == nil {
+               return 0, nil
+       }
+       _, exists, revision, err := s.getEntry(ctx, prefix, name)
+       if err != nil {
+               return 0, err
+       }
+       if !exists {
+               return 0, fmt.Errorf("route binding %q disappeared after 
publish", name)
+       }
+       return revision, nil
+}
+
+// Status returns draft and published revisions for one route. The revisions
+// are independent of other route bindings in the same config namespace.
+func (s *RouteBindingStore) Status(ctx context.Context, name string) 
(RouteBindingPublishStatus, error) {
+       ctx = routeBindingContext(ctx)
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return RouteBindingPublishStatus{}, err
+       }
+       draft, draftExists, draftRevision, err := s.getEntry(ctx, 
s.bindingPrefix(true), name)
+       if err != nil {
+               return RouteBindingPublishStatus{}, err
+       }
+       published, publishedExists, publishedRevision, err := s.getEntry(ctx, 
s.bindingPrefix(false), name)
+       if err != nil {
+               return RouteBindingPublishStatus{}, err
+       }
+       if !draftExists && !publishedExists {
+               return RouteBindingPublishStatus{}, fmt.Errorf("%w: %s", 
ErrRouteBindingNotFound, name)
+       }
+       dirty := draftExists != publishedExists
+       if draftExists && publishedExists {
+               dirty = !reflect.DeepEqual(draft.record.Object, 
published.record.Object)
+       }
+       return RouteBindingPublishStatus{
+               Name:              name,
+               DraftRevision:     draftRevision,
+               PublishedRevision: publishedRevision,
+               DraftExists:       draftExists,
+               PublishedExists:   publishedExists,
+               Dirty:             dirty,
+       }, nil
+}
+
+// Diff compares one route's draft and published objects. It does not read or
+// mutate any other route's draft state.
+func (s *RouteBindingStore) Diff(ctx context.Context, name string) 
(RouteBindingDiff, error) {
+       ctx = routeBindingContext(ctx)
+       name, err := normalizeRouteBindingName(name)
+       if err != nil {
+               return RouteBindingDiff{}, err
+       }
+       draft, draftExists, _, err := s.getEntry(ctx, s.bindingPrefix(true), 
name)
+       if err != nil {
+               return RouteBindingDiff{}, err
+       }
+       published, publishedExists, _, err := s.getEntry(ctx, 
s.bindingPrefix(false), name)
+       if err != nil {
+               return RouteBindingDiff{}, err
+       }
+       if !draftExists && !publishedExists {
+               return RouteBindingDiff{}, fmt.Errorf("%w: %s", 
ErrRouteBindingNotFound, name)
+       }
+
+       var draftView, publishedView *RouteBinding
+       if draftExists {
+               view := routeBindingView(draft)
+               draftView = &view
+       }
+       if publishedExists {
+               view := routeBindingView(published)
+               publishedView = &view
+       }
+       changes, err := diffRouteBindingObjects(
+               func() schema.AdminObject {
+                       if publishedExists {
+                               return published.record.Object
+                       }
+                       return schema.AdminObject{}
+               }(),
+               func() schema.AdminObject {
+                       if draftExists {
+                               return draft.record.Object
+                       }
+                       return schema.AdminObject{}
+               }(),
+               publishedExists,
+               draftExists,
+       )
+       if err != nil {
+               return RouteBindingDiff{}, err
+       }
+       return RouteBindingDiff{
+               Name:      name,
+               Draft:     draftView,
+               Published: publishedView,
+               Changes:   changes,
+       }, nil
+}
+
+func diffRouteBindingObjects(before, after schema.AdminObject, beforeExists, 
afterExists bool) ([]RouteBindingDiffChange, error) {
+       beforeValues := make(map[string]any)
+       afterValues := make(map[string]any)
+       if beforeExists {
+               if err := flattenRouteBindingValue("", before, beforeValues); 
err != nil {
+                       return nil, err
+               }
+       }
+       if afterExists {
+               if err := flattenRouteBindingValue("", after, afterValues); err 
!= nil {
+                       return nil, err
+               }
+       }
+       keys := make(map[string]struct{}, len(beforeValues)+len(afterValues))
+       for key := range beforeValues {
+               keys[key] = struct{}{}
+       }
+       for key := range afterValues {
+               keys[key] = struct{}{}
+       }
+       paths := make([]string, 0, len(keys))
+       for key := range keys {
+               paths = append(paths, key)
+       }
+       sort.Strings(paths)
+       changes := make([]RouteBindingDiffChange, 0, len(paths))
+       for _, path := range paths {
+               beforeValue, beforeOK := beforeValues[path]
+               afterValue, afterOK := afterValues[path]
+               if beforeOK && afterOK && reflect.DeepEqual(beforeValue, 
afterValue) {
+                       continue
+               }
+               changes = append(changes, RouteBindingDiffChange{
+                       Path:   path,
+                       Before: valueOrNil(beforeValue, beforeOK),
+                       After:  valueOrNil(afterValue, afterOK),
+               })
+       }
+       return changes, nil
+}
+
+func flattenRouteBindingValue(path string, value any, result map[string]any) 
error {
+       switch typed := value.(type) {
+       case schema.AdminObject:
+               return flattenRouteBindingValue(path, map[string]any{
+                       "kind":     typed.Kind,
+                       "metadata": typed.Metadata,
+                       "spec":     typed.Spec,
+               }, result)
+       case schema.ObjectMetadata:
+               return flattenRouteBindingValue(path, map[string]any{"name": 
typed.Name}, result)
+       case map[string]any:
+               if len(typed) == 0 {
+                       result[path] = typed
+                       return nil
+               }
+               keys := make([]string, 0, len(typed))
+               for key := range typed {
+                       keys = append(keys, key)
+               }
+               sort.Strings(keys)
+               for _, key := range keys {
+                       childPath := key
+                       if path != "" {
+                               childPath = path + "." + key
+                       }
+                       if err := flattenRouteBindingValue(childPath, 
typed[key], result); err != nil {
+                               return err
+                       }
+               }
+               return nil
+       case []any:
+               if len(typed) == 0 {
+                       result[path] = typed
+                       return nil
+               }
+               for index, item := range typed {
+                       childPath := fmt.Sprintf("%s[%d]", path, index)
+                       if err := flattenRouteBindingValue(childPath, item, 
result); err != nil {
+                               return err
+                       }
+               }
+               return nil
+       default:
+               result[path] = typed
+               return nil
+       }
+}
+
+func valueOrNil(value any, exists bool) any {
+       if !exists {
+               return nil
+       }
+       return value
+}
+
+// PublishAll compiles the complete draft snapshot and publishes it with one
+// etcd transaction. The transaction updates the high-level published records,
+// generated legacy resources/methods, stale-route deletions, and the published
+// revision marker together. A draft marker compare prevents a concurrent save
+// or delete from being silently omitted from the snapshot.
+func (s *RouteBindingStore) PublishAll(ctx context.Context, 
expectedDraftRevision int64) (RouteBindingPublishResult, error) {
+       ctx = routeBindingContext(ctx)
+       if expectedDraftRevision < 0 {
+               return RouteBindingPublishResult{}, errors.New("expected draft 
revision must not be negative")
+       }
+       draftEntries, err := s.listEntries(ctx, s.bindingPrefix(true))
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       publishedEntries, err := s.listEntries(ctx, s.bindingPrefix(false))
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+
+       draftRevision, draftMarkerExists, err := s.markerRevision(ctx, 
s.draftRevisionKey())
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       if expectedDraftRevision > 0 && (!draftMarkerExists || draftRevision != 
expectedDraftRevision) {
+               return RouteBindingPublishResult{}, fmt.Errorf("%w: draft 
revision changed", ErrRouteBindingPublishConflict)
+       }
+
+       compiled, err := s.compileSnapshot(draftEntries)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+       operations, publishedCount, deletedCount, err := 
s.buildPublishOperations(compiled, publishedEntries)
+       if err != nil {
+               return RouteBindingPublishResult{}, err
+       }
+
+       comparisons := make([]clientv3.Cmp, 0, 1)
+       if draftMarkerExists {
+               comparisons = append(comparisons, 
clientv3.Compare(clientv3.ModRevision(s.draftRevisionKey()), "=", 
draftRevision))

Review Comment:
   全量发布读取已发布快照后,只 CAS draft revision,没有比较 published 
revision。并发执行单路由发布时,全量发布可能覆盖其他路由的更新,甚至恢复刚删除的路由。



##########
admin/web/src/features/config/RouteBindingEditorSections.tsx:
##########
@@ -0,0 +1,1153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+  AlertCircle,
+  ArrowLeft,
+  CheckCircle2,
+  Eye,
+  FileCode2,
+  GitCompareArrows,
+  Plus,
+  Save,
+  Send,
+  Trash2,
+} from 'lucide-react'
+import { translateText } from '../../i18n'
+import type {
+  AdminRouteBindingObject,
+  RouteBindingDiff,
+  RouteBindingParam,
+  RouteBindingPublishStatus,
+} from '../../types/api'
+
+export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 
'OPTIONS', 'HEAD']
+export const PARAM_TYPES = [
+  'string',
+  'char',
+  'short',
+  'int',
+  'long',
+  'float',
+  'double',
+  'boolean',
+  'byte',
+  'date',
+  'object',
+  'java.lang.String',
+  'java.lang.Character',
+  'java.lang.Short',
+  'java.lang.Integer',
+  'java.lang.Long',
+  'java.lang.Float',
+  'java.lang.Double',
+  'java.lang.Boolean',
+  'java.lang.Byte',
+  'java.lang.Object',
+  'java.util.Date',
+]
+
+export type EditorTab = 'form' | 'preview' | 'diff' | 'yaml'
+export type BusyAction = '' | 'save' | 'publish' | 'validate' | 'preview' | 
'diff'
+export type Notice = { tone: 'success' | 'error'; text: string }
+export type IssueMessage = (path: string) => string
+export type RouteEntry = AdminRouteBindingObject['spec']['entry']
+export type RouteTarget = AdminRouteBindingObject['spec']['target']
+type EditableTargetKey = Exclude<keyof RouteTarget, 'protocol'>
+
+type HeaderProps = {
+  readonly isEnglish: boolean
+  readonly routeLabel: string
+  readonly entry: RouteEntry
+  readonly publishStatus: RouteBindingPublishStatus | null
+  readonly dirty: boolean
+  readonly published: boolean
+  readonly enabled: boolean
+  readonly currentStatus: string
+  readonly lifecycleStatus: string
+  readonly busy: BusyAction
+  readonly yamlError: string
+  readonly onBack: () => void
+  readonly onValidate: () => void
+  readonly onSave: () => void
+  readonly onPublish: () => void
+}
+
+type TabsProps = {
+  readonly isEnglish: boolean
+  readonly activeTab: EditorTab
+  readonly onChange: (tab: EditorTab) => void
+}
+
+type IdentityCardProps = {
+  readonly isEnglish: boolean
+  readonly mode: 'create' | 'edit'
+  readonly entry: RouteEntry
+  readonly name: string
+  readonly issueMessage: IssueMessage
+  readonly onNameChange: (value: string) => void
+  readonly onPathChange: (value: string) => void
+  readonly onMethodChange: (value: string) => void
+}
+
+type TargetCardProps = {
+  readonly isEnglish: boolean
+  readonly target: RouteTarget
+  readonly issueMessage: IssueMessage
+  readonly onChange: (key: EditableTargetKey, value: string) => void
+}
+
+type ParamCardProps = {
+  readonly isEnglish: boolean
+  readonly params: RouteBindingParam[]
+  readonly issueMessage: IssueMessage
+  readonly onAdd: () => void
+  readonly onUpdate: (index: number, patch: Partial<RouteBindingParam>) => void
+  readonly onRemove: (index: number) => void
+}
+
+type PublishCardProps = {
+  readonly isEnglish: boolean
+  readonly enabled: boolean
+  readonly validate: boolean
+  readonly onEnabledChange: (enabled: boolean) => void
+  readonly onValidateChange: (validate: boolean) => void
+}
+
+type FormViewProps = IdentityCardProps & TargetCardProps & ParamCardProps & 
PublishCardProps
+
+type DiffViewProps = {
+  readonly isEnglish: boolean
+  readonly diffData: RouteBindingDiff | null
+  readonly busy: BusyAction
+  readonly onRefresh: () => void
+}
+
+type PreviewViewProps = {
+  readonly isEnglish: boolean
+  readonly activeTab: 'preview' | 'yaml'
+  readonly busy: BusyAction
+  readonly previewYaml: string
+  readonly routeYaml: string
+  readonly yamlError: string
+  readonly onApplyYaml: () => void
+  readonly onChangeYaml: (value: string) => void
+  readonly onPreview: () => void
+}
+
+type ContentProps = {
+  readonly isEnglish: boolean
+  readonly loading: boolean
+  readonly activeTab: EditorTab
+  readonly mode: 'create' | 'edit'
+  readonly object: AdminRouteBindingObject
+  readonly busy: BusyAction
+  readonly diffData: RouteBindingDiff | null
+  readonly previewYaml: string
+  readonly routeYaml: string
+  readonly yamlError: string
+  readonly issueMessage: IssueMessage
+  readonly onNameChange: (value: string) => void
+  readonly onEntryChange: (key: 'path' | 'method', value: string) => void
+  readonly onTargetChange: (key: EditableTargetKey, value: string) => void
+  readonly onParamAdd: () => void
+  readonly onParamUpdate: (index: number, patch: Partial<RouteBindingParam>) 
=> void
+  readonly onParamRemove: (index: number) => void
+  readonly onEnabledChange: (enabled: boolean) => void
+  readonly onValidateChange: (validate: boolean) => void
+  readonly onRefreshDiff: () => void
+  readonly onApplyYaml: () => void
+  readonly onChangeYaml: (value: string) => void
+  readonly onPreview: () => void
+}
+
+function actionLabel(
+  isEnglish: boolean,
+  busy: BusyAction,
+  action: Exclude<BusyAction, ''>,
+  idleLabel: string,
+) {
+  if (busy !== action) return idleLabel
+  const labels: Record<Exclude<BusyAction, ''>, [string, string]> = {
+    save: ['Saving...', '保存中...'],
+    publish: ['Publishing...', '发布中...'],
+    validate: ['Checking...', '校验中...'],
+    preview: ['Generating...', '生成中...'],
+    diff: ['Loading...', '加载中...'],
+  }
+  return isEnglish ? labels[action][0] : labels[action][1]
+}
+
+function routeNameHelp(isEnglish: boolean, mode: 'create' | 'edit') {
+  if (mode === 'edit')
+    return isEnglish ? 'The key stays stable while editing.' : '编辑时保持路由键稳定。'
+  return isEnglish
+    ? 'Used as the AdminRouteBinding metadata.name.'
+    : '对应 AdminRouteBinding 的 metadata.name。'
+}
+
+function previewTitle(isEnglish: boolean, activeTab: 'preview' | 'yaml') {
+  if (activeTab === 'yaml') return 'YAML'
+  return isEnglish ? 'Runtime preview' : '运行时预览'
+}
+
+function previewDescription(isEnglish: boolean, activeTab: 'preview' | 'yaml') 
{
+  if (activeTab === 'yaml') {
+    return isEnglish
+      ? 'Edit the AdminRouteBinding source. Valid changes sync back to the 
form.'
+      : '编辑 AdminRouteBinding 源配置,语法有效的改动会同步回表单。'
+  }
+  return isEnglish
+    ? 'Generated by the current AdminRouteBinding compiler.'
+    : '由当前 AdminRouteBinding 编译器生成。'
+}
+
+function routeStateClass(dirty: boolean, published: boolean) {
+  if (dirty || !published) return 'draft'
+  return 'published'
+}
+
+function yamlSyncLabel(isEnglish: boolean, yamlError: string) {
+  if (yamlError) return isEnglish ? 'Syntax error' : '语法错误'
+  return isEnglish ? 'Synced with form' : '已与表单同步'
+}
+
+function yamlSyncClass(yamlError: string) {
+  return yamlError ? 'error' : 'synced'
+}
+
+export function RouteEditorHeader({
+  isEnglish,
+  routeLabel,
+  entry,
+  publishStatus,
+  dirty,
+  published,
+  enabled,
+  currentStatus,
+  lifecycleStatus,
+  busy,
+  yamlError,
+  onBack,
+  onValidate,
+  onSave,
+  onPublish,
+}: HeaderProps) {
+  return (
+    <>
+      <div className="route-editor-backbar">
+        <button className="route-editor-back" type="button" onClick={onBack}>
+          <ArrowLeft size={15} />
+          {isEnglish ? 'Back to API routes' : '返回 API 路由'}
+        </button>
+        <div
+          className="route-editor-revisions"
+          aria-label={isEnglish ? 'Route revisions' : '路由版本'}
+        >
+          <span className="route-revision draft">
+            {isEnglish ? 'Draft rev' : '草稿 rev'} {publishStatus?.draftRevision 
|| '-'}
+          </span>
+          <span className="route-revision published">
+            {isEnglish ? 'Published rev' : '已发布 rev'} 
{publishStatus?.publishedRevision || '-'}
+          </span>
+        </div>
+      </div>
+
+      <div className="route-editor-titlebar">
+        <div>
+          <p className="eyebrow">API ROUTE</p>
+          <h1>{routeLabel}</h1>
+          <div className="route-editor-summary">
+            <span className={`method 
${entry.method.toLowerCase()}`}>{entry.method || 'GET'}</span>
+            <span className="route-editor-path">
+              {entry.path || (isEnglish ? 'Path required' : '需要填写路径')}
+            </span>
+            <span className={`route-editor-state ${routeStateClass(dirty, 
published)}`}>
+              <i /> {currentStatus}
+            </span>
+            <span className={`route-editor-lifecycle ${enabled ? 'enabled' : 
'disabled'}`}>
+              <i /> {lifecycleStatus}
+            </span>
+          </div>
+        </div>
+        <div className="route-editor-actions">
+          <button
+            className="secondary"
+            type="button"
+            disabled={busy !== '' || Boolean(yamlError)}
+            onClick={onValidate}
+          >
+            <CheckCircle2 size={14} />
+            {actionLabel(
+              isEnglish,
+              busy,
+              'validate',
+              translateText(isEnglish ? 'en-US' : 'zh-CN', '校验'),
+            )}
+          </button>
+          <button
+            className="secondary"
+            type="button"
+            disabled={busy !== '' || Boolean(yamlError)}
+            onClick={onSave}
+          >
+            <Save size={14} />
+            {actionLabel(isEnglish, busy, 'save', isEnglish ? 'Save draft' : 
'保存草稿')}
+          </button>
+          <button
+            className="primary"
+            type="button"
+            disabled={busy !== '' || Boolean(yamlError)}
+            onClick={onPublish}
+          >
+            <Send size={14} />
+            {actionLabel(isEnglish, busy, 'publish', isEnglish ? 'Publish' : 
'发布')}
+          </button>
+        </div>
+      </div>
+    </>
+  )
+}
+
+type ContractProps = {
+  readonly isEnglish: boolean
+}
+
+export function RouteEditorContract({ isEnglish }: ContractProps) {
+  return (
+    <div className="route-editor-contract">
+      <span className="route-contract-mark">{isEnglish ? 'ATOMIC PUBLISH' : 
'原子发布'}</span>
+      <span>
+        {isEnglish
+          ? 'Saving updates this route draft. Publishing replaces only this 
route in one etcd transaction.'
+          : '保存只更新当前路由草稿。发布会通过一次 etcd 事务只替换当前路由。'}
+      </span>
+    </div>
+  )
+}
+
+type NoticeProps = {
+  readonly notice: Notice
+}
+
+export function RouteEditorNotice({ notice }: NoticeProps) {
+  if (notice.tone === 'error') {
+    return (
+      <div className="route-editor-notice error" role="alert">
+        <AlertCircle size={16} />
+        <span>{notice.text}</span>
+      </div>
+    )
+  }
+  return (
+    <output className="route-editor-notice success">
+      <CheckCircle2 size={16} />
+      <span>{notice.text}</span>
+    </output>
+  )
+}
+
+export function RouteEditorTabs({ isEnglish, activeTab, onChange }: TabsProps) 
{
+  return (
+    <div
+      className="route-editor-tabs"
+      role="tablist"
+      aria-label={isEnglish ? 'Route editor views' : '路由编辑视图'}
+    >
+      <button
+        className={activeTab === 'form' ? 'active' : ''}
+        type="button"
+        role="tab"
+        aria-selected={activeTab === 'form'}
+        onClick={() => onChange('form')}
+      >
+        {isEnglish ? 'Form' : '表单'}
+      </button>
+      <button
+        className={activeTab === 'preview' ? 'active' : ''}
+        type="button"
+        role="tab"
+        aria-selected={activeTab === 'preview'}
+        onClick={() => onChange('preview')}
+      >
+        <Eye size={14} /> {isEnglish ? 'Preview' : '预览'}
+      </button>
+      <button
+        className={activeTab === 'diff' ? 'active' : ''}
+        type="button"
+        role="tab"
+        aria-selected={activeTab === 'diff'}
+        onClick={() => onChange('diff')}
+      >
+        <GitCompareArrows size={14} /> Diff
+      </button>
+      <button
+        className="unavailable-tab"
+        type="button"
+        disabled
+        title={
+          isEnglish
+            ? 'History is not exposed by the current Admin API.'
+            : '当前 Admin API 尚未提供历史接口。'
+        }
+      >
+        {isEnglish ? 'History' : '历史'}
+      </button>
+      <button
+        className={activeTab === 'yaml' ? 'active' : ''}
+        type="button"
+        role="tab"
+        aria-selected={activeTab === 'yaml'}
+        onClick={() => onChange('yaml')}
+      >
+        <FileCode2 size={14} /> YAML
+      </button>
+    </div>
+  )
+}
+
+function RouteIdentityCard({
+  isEnglish,
+  mode,
+  entry,
+  name,
+  issueMessage,
+  onNameChange,
+  onPathChange,
+  onMethodChange,
+}: IdentityCardProps) {
+  const nameIssue = issueMessage('metadata.name')
+  const pathIssue = issueMessage('spec.entry.path')
+  const methodIssue = issueMessage('spec.entry.method')
+  return (
+    <section className="panel route-editor-card">
+      <div className="panel-head">
+        <div>
+          <h2>{isEnglish ? 'Identity and entry' : '身份与入口'}</h2>
+          <span>
+            {isEnglish
+              ? 'Define the HTTP route exposed by Pixiu.'
+              : '定义 Pixiu 对外暴露的 HTTP 路由。'}
+          </span>
+        </div>
+      </div>
+      <div className="route-entry-fields">
+        <label className="route-field">
+          <span>{isEnglish ? 'Route name' : '路由名称'}</span>
+          <input
+            value={name}
+            readOnly={mode === 'edit'}
+            aria-invalid={Boolean(nameIssue)}
+            aria-describedby="route-name-help route-name-error"
+            placeholder="create-user"
+            onChange={(event) => onNameChange(event.target.value)}
+          />
+          <small id="route-name-help">{routeNameHelp(isEnglish, mode)}</small>
+          {nameIssue && (
+            <small id="route-name-error" className="route-field-error">
+              {nameIssue}
+            </small>
+          )}
+        </label>
+        <label className="route-field">
+          <span>{isEnglish ? 'Entry protocol' : '入口协议'}</span>
+          <select value={entry.protocol} disabled>
+            <option value="http">HTTP</option>
+          </select>
+          <small>
+            {isEnglish ? 'The current schema accepts HTTP only.' : '当前 schema 
仅支持 HTTP。'}
+          </small>
+        </label>
+        <label className="route-field route-field-wide">
+          <span>{isEnglish ? 'Path' : '路径'}</span>
+          <input
+            value={entry.path}
+            aria-invalid={Boolean(pathIssue)}
+            placeholder="/api/v1/users/:id"
+            onChange={(event) => onPathChange(event.target.value)}
+          />
+          {pathIssue && <small 
className="route-field-error">{pathIssue}</small>}
+        </label>
+        <fieldset className="route-field route-field-wide 
route-method-fieldset">
+          <legend>{isEnglish ? 'HTTP method' : '请求方法'}</legend>
+          <div className="route-method-options">
+            {HTTP_METHODS.map((method) => (
+              <button
+                className={entry.method === method ? 'active' : ''}
+                key={method}
+                type="button"
+                aria-pressed={entry.method === method}
+                onClick={() => onMethodChange(method)}
+              >
+                {method}
+              </button>
+            ))}
+          </div>
+          {methodIssue && <small 
className="route-field-error">{methodIssue}</small>}
+        </fieldset>
+      </div>
+    </section>
+  )
+}
+
+type TargetFieldProps = {
+  readonly label: string
+  readonly value: string
+  readonly placeholder: string
+  readonly issue: string
+  readonly onChange: (value: string) => void
+}
+
+function TargetField({ label, value, placeholder, issue, onChange }: 
TargetFieldProps) {
+  return (
+    <label className="route-field">
+      <span>{label}</span>
+      <input
+        value={value}
+        placeholder={placeholder}
+        onChange={(event) => onChange(event.target.value)}
+      />
+      {issue && <small className="route-field-error">{issue}</small>}
+    </label>
+  )
+}
+
+function RouteTargetCard({ isEnglish, target, issueMessage, onChange }: 
TargetCardProps) {
+  const fields: Array<{
+    key: EditableTargetKey
+    label: string
+    placeholder: string
+    issuePath: string
+  }> = [
+    {
+      key: 'cluster',
+      label: 'Cluster',
+      placeholder: 'user-service',
+      issuePath: 'spec.target.cluster',
+    },
+    {
+      key: 'application',
+      label: 'Application',
+      placeholder: 'UserProvider',
+      issuePath: 'spec.target.application',
+    },
+    {
+      key: 'interface',
+      label: 'Interface',
+      placeholder: 'com.example.user.UserService',
+      issuePath: 'spec.target.interface',
+    },
+    { key: 'method', label: 'Method', placeholder: 'GetUser', issuePath: 
'spec.target.method' },
+    { key: 'group', label: 'Group', placeholder: 'stable', issuePath: '' },
+    { key: 'version', label: 'Version', placeholder: '1.0.0', issuePath: '' },
+  ]
+  return (
+    <section className="panel route-editor-card">
+      <div className="panel-head">
+        <div>
+          <h2>{isEnglish ? 'Backend target' : '后端目标'}</h2>
+          <span>
+            {isEnglish
+              ? 'Map the request to one Dubbo service method.'
+              : '将请求映射到一个 Dubbo 服务方法。'}
+          </span>
+        </div>
+      </div>
+      <div className="route-target-fields">
+        <label className="route-field">
+          <span>{isEnglish ? 'Target protocol' : '目标协议'}</span>
+          <select value={target.protocol} disabled>
+            <option value="dubbo">Dubbo</option>
+          </select>
+          <small>
+            {isEnglish ? 'The current compiler targets Dubbo.' : '当前编译器目标为 
Dubbo。'}
+          </small>
+        </label>
+        {fields.map((field) => (
+          <TargetField
+            key={field.key}
+            label={field.label}
+            value={target[field.key]}
+            placeholder={field.placeholder}
+            issue={field.issuePath ? issueMessage(field.issuePath) : ''}
+            onChange={(value) => onChange(field.key, value)}
+          />
+        ))}
+      </div>
+    </section>
+  )
+}
+
+type ParamRowProps = {
+  readonly isEnglish: boolean
+  readonly param: RouteBindingParam
+  readonly index: number
+  readonly issueMessage: IssueMessage
+  readonly onUpdate: (index: number, patch: Partial<RouteBindingParam>) => void
+  readonly onRemove: (index: number) => void
+}
+
+function RouteParamRow({
+  isEnglish,
+  param,
+  index,
+  issueMessage,
+  onUpdate,
+  onRemove,
+}: ParamRowProps) {
+  const fromIssue = issueMessage(`spec.params[${index}].from`)
+  const toIssue = issueMessage(`spec.params[${index}].to`)
+  const typeIssue = issueMessage(`spec.params[${index}].type`)
+  return (
+    <div className="route-param-row" key={`${index}-${param.from}`}>
+      <label className="route-param-field">
+        <span>{isEnglish ? 'HTTP source' : 'HTTP 来源'}</span>
+        <input
+          value={param.from}
+          aria-invalid={Boolean(fromIssue)}
+          placeholder="queryStrings.page"
+          onChange={(event) => onUpdate(index, { from: event.target.value })}
+        />
+        {fromIssue && <small className="route-field-error">{fromIssue}</small>}
+      </label>
+      <label className="route-param-field">
+        <span>{isEnglish ? 'Argument index' : '参数位置'}</span>
+        <input
+          type="number"
+          min="0"
+          value={param.to}
+          aria-invalid={Boolean(toIssue)}
+          onChange={(event) => onUpdate(index, { to: 
Number(event.target.value) })}
+        />
+        {toIssue && <small className="route-field-error">{toIssue}</small>}
+      </label>
+      <label className="route-param-field">
+        <span>{isEnglish ? 'Dubbo type' : 'Dubbo 类型'}</span>
+        <select
+          value={param.type}
+          onChange={(event) => onUpdate(index, { type: event.target.value })}
+        >
+          {PARAM_TYPES.map((type) => (
+            <option key={type} value={type}>
+              {type}
+            </option>
+          ))}
+        </select>
+        {typeIssue && <small className="route-field-error">{typeIssue}</small>}
+      </label>
+      <button
+        className="route-icon-action"
+        type="button"
+        aria-label={isEnglish ? `Remove parameter ${index + 1}` : `删除第 ${index 
+ 1} 个参数`}
+        onClick={() => onRemove(index)}
+      >
+        <Trash2 size={15} />
+      </button>
+    </div>
+  )
+}
+
+function RouteParamsCard({
+  isEnglish,
+  params,
+  issueMessage,
+  onAdd,
+  onUpdate,
+  onRemove,
+}: ParamCardProps) {
+  return (
+    <section className="panel route-param-panel">
+      <div className="panel-head route-param-head">
+        <div>
+          <h2>{isEnglish ? 'Inbound request' : '入站请求'}</h2>
+          <span>
+            {isEnglish
+              ? 'Map HTTP values to zero-based Dubbo argument indexes.'
+              : '将 HTTP 参数映射到从 0 开始的 Dubbo 参数位置。'}
+          </span>
+        </div>
+        <button className="secondary route-add-param" type="button" 
onClick={onAdd}>
+          <Plus size={14} /> {isEnglish ? 'Add parameter' : '添加参数'}
+        </button>
+      </div>
+      <div className="route-param-table">
+        {params.length === 0 ? (
+          <div className="route-param-empty">
+            <span>{isEnglish ? 'No parameter mappings yet.' : 
'暂未配置参数映射。'}</span>
+            <small>
+              {isEnglish
+                ? 'Add a mapping when the Dubbo method expects HTTP input.'
+                : '当 Dubbo 方法需要接收 HTTP 参数时,再添加映射。'}
+            </small>
+          </div>
+        ) : (
+          <>
+            <div className="route-param-header" aria-hidden="true">
+              <span>{isEnglish ? 'HTTP source' : 'HTTP 来源'}</span>
+              <span>{isEnglish ? 'Argument index' : '参数位置'}</span>
+              <span>{isEnglish ? 'Dubbo type' : 'Dubbo 类型'}</span>
+              <span />
+            </div>
+            {params.map((param, index) => (
+              <RouteParamRow
+                key={`${index}-${param.from}`}
+                isEnglish={isEnglish}
+                param={param}
+                index={index}
+                issueMessage={issueMessage}
+                onUpdate={onUpdate}
+                onRemove={onRemove}
+              />
+            ))}
+          </>
+        )}
+      </div>
+    </section>
+  )
+}
+
+function RoutePublishCard({
+  isEnglish,
+  enabled,
+  validate,
+  onEnabledChange,
+  onValidateChange,
+}: PublishCardProps) {
+  return (
+    <section className="panel route-publish-panel">
+      <div>
+        <h2>{isEnglish ? 'Release and lifecycle' : '发布与生命周期'}</h2>
+        <span>
+          {isEnglish
+            ? 'Both lifecycle changes and validation preferences are published 
with this route.'
+            : '路由启停和校验偏好都会随当前路由一起发布。'}
+        </span>
+      </div>
+      <div className="route-switches">
+        <label className="route-switch">
+          <input
+            type="checkbox"
+            checked={enabled}
+            aria-label={isEnglish ? 'Enable route' : '启用路由'}
+            onChange={(event) => onEnabledChange(event.target.checked)}
+          />
+          <span className="route-switch-track" />
+          <span>{isEnglish ? 'Route enabled' : '启用路由'}</span>
+        </label>
+        <label className="route-switch">
+          <input
+            type="checkbox"
+            checked={validate}
+            aria-label={isEnglish ? 'Validate before publish' : '发布前校验'}
+            onChange={(event) => onValidateChange(event.target.checked)}
+          />

Review Comment:
   另外,前端的“发布前校验”复选框目前只是保存字段,发布流程并没有读取它,属于无效配置:



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to