feat: Z36 host_meta-only publish + Z37 SyncPage LWW UX

Allow display-name-only publish via host_meta; stop treating company conflicts 403 as a fault and ship rebuilt web dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-07 12:01:18 +08:00
parent 4b6c90904b
commit 4f9087bd38
9 changed files with 597 additions and 387 deletions

View File

@@ -229,6 +229,9 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
bp.Apis.BasePath = "/api/v1/apps/" + slug bp.Apis.BasePath = "/api/v1/apps/" + slug
mergeRes, err = blueprint.MergeInto(bp, incoming) mergeRes, err = blueprint.MergeInto(bp, incoming)
if err != nil { if err != nil {
if isNothingNewToPublish(err) {
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
}
return nil, err return nil, err
} }
publishMode = "pages_added" publishMode = "pages_added"
@@ -249,6 +252,9 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
bp.Apis.BasePath = "/api/v1/apps/" + slug bp.Apis.BasePath = "/api/v1/apps/" + slug
mergeRes, err = blueprint.MergeInto(bp, incoming) mergeRes, err = blueprint.MergeInto(bp, incoming)
if err != nil { if err != nil {
if isNothingNewToPublish(err) {
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
}
return nil, err return nil, err
} }
publishMode = "pages_added" publishMode = "pages_added"
@@ -258,6 +264,13 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
} }
} }
// Z36有蓝图增量时也把 host_meta.module_name 落到蓝图/模块显示名
if req.HostMeta != nil {
if n := strings.TrimSpace(req.HostMeta.ModuleName); n != "" {
bp.Meta.Name = n
}
}
bp.EnsureDefaultImportExport() bp.EnsureDefaultImportExport()
if err := bp.Validate(slug); err != nil { if err := bp.Validate(slug); err != nil {
return nil, err return nil, err
@@ -483,6 +496,112 @@ func (l *PublishLogic) audit(action, detail string) error {
return l.svcCtx.Audit.Log(l.ctx, authx.TenantID(l.ctx), authx.UserID(l.ctx), action, detail) return l.svcCtx.Audit.Log(l.ctx, authx.TenantID(l.ctx), authx.UserID(l.ctx), action, detail)
} }
func isNothingNewToPublish(err error) bool {
return err != nil && strings.Contains(err.Error(), "nothing new to publish")
}
// handleNoBlueprintDelta Z36merge 无增量时,有 module_name 则仅更新 host_meta否则中文业务错误。
func (l *PublishLogic) handleNoBlueprintDelta(
existing *meta.AppRecord,
req *types.PublishReq,
slug string,
tenantID, userID int64,
) (*types.PublishResp, error) {
name := ""
if req.HostMeta != nil {
name = strings.TrimSpace(req.HostMeta.ModuleName)
}
if name == "" {
return nil, fmt.Errorf("没有可发布的蓝图增量(页面/实体/接口未变化)。若仅修改模块显示名,请在 host_meta.module_name 传入新名称后重试 [NO_BLUEPRINT_DELTA]")
}
return l.publishHostMetaOnly(existing, req, slug, tenantID, userID, name)
}
// publishHostMetaOnly 无蓝图增量时只更新模块显示名并重签胶囊,不跑 DDL。
func (l *PublishLogic) publishHostMetaOnly(
existing *meta.AppRecord,
req *types.PublishReq,
slug string,
tenantID, userID int64,
moduleName string,
) (*types.PublishResp, error) {
if existing == nil || existing.Blueprint == nil {
return nil, fmt.Errorf("existing app has no blueprint")
}
bp := existing.Blueprint
bp.Meta.Name = moduleName
bp.Meta.Slug = slug
if bp.Apis.BasePath == "" {
bp.Apis.BasePath = "/api/v1/apps/" + slug
}
rec := existing
rec.Name = moduleName
rec.Blueprint = bp
rec.UpdatedAt = time.Now().UTC()
if rec.Status == "" {
rec.Status = meta.StatusPublished
}
if err := l.svcCtx.Meta.Save(l.ctx, rec); err != nil {
return nil, fmt.Errorf("meta save: %w", err)
}
_ = l.audit("publish.host_meta", audit.DetailJSON(map[string]any{
"slug": slug, "module_name": moduleName, "user_id": userID, "publish_mode": "host_meta_updated",
}))
ownerID := authx.AgentID(l.ctx)
if ownerID <= 0 {
ownerID = userID
}
secret := l.svcCtx.Config.Agent.CapsuleSecret
if secret == "" {
secret = l.svcCtx.JWT.AccessSecret
}
accessPath := ""
accessURL := ""
if secret != "" && ownerID > 0 {
_, filePath, err := agentcap.SealModulePath(secret, tenantID, ownerID, slug)
if err == nil {
accessPath = filePath
base := strings.TrimRight(l.svcCtx.Config.PublicBaseURL, "/")
if req.HostMeta != nil && strings.TrimSpace(req.HostMeta.HostBaseURL) != "" {
accessURL = strings.TrimRight(strings.TrimSpace(req.HostMeta.HostBaseURL), "/")
} else if base != "" {
accessURL = base + "/api/v1/public/" + filePath + "/blueprint"
}
}
}
publishStyle := ""
if req.HostMeta != nil {
publishStyle = strings.TrimSpace(req.HostMeta.PublishStyle)
}
if publishStyle == "" {
publishStyle = "immediate"
}
endpoints := rec.Endpoints
if len(endpoints) == 0 {
endpoints = buildEndpoints(bp)
}
return &types.PublishResp{
AppID: rec.AppID,
Slug: slug,
SchemaName: rec.SchemaName,
DatabaseName: rec.DatabaseName,
Status: string(rec.Status),
Endpoints: endpoints,
MemoryMode: l.svcCtx.MemoryMode,
PublishMode: "host_meta_updated",
ModuleName: moduleName,
PublishStyle: publishStyle,
AccessPath: accessPath,
AccessURL: accessURL,
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
OwnerID: ownerID,
}, nil
}
func buildEndpoints(bp *blueprint.Blueprint) []string { func buildEndpoints(bp *blueprint.Blueprint) []string {
base := strings.TrimRight(bp.Apis.BasePath, "/") base := strings.TrimRight(bp.Apis.BasePath, "/")
if base == "" { if base == "" {

View File

@@ -29,7 +29,7 @@ type PublishResp struct {
Endpoints []string `json:"endpoints"` Endpoints []string `json:"endpoints"`
DDL []string `json:"ddl,omitempty"` DDL []string `json:"ddl,omitempty"`
MemoryMode bool `json:"memory_mode"` MemoryMode bool `json:"memory_mode"`
PublishMode string `json:"publish_mode"` // created | pages_added | replaced PublishMode string `json:"publish_mode"` // created | pages_added | replaced | host_meta_updated
AddedPages []string `json:"added_pages,omitempty"` AddedPages []string `json:"added_pages,omitempty"`
AddedEntities []string `json:"added_entities,omitempty"` AddedEntities []string `json:"added_entities,omitempty"`
AddedResources []string `json:"added_resources,omitempty"` AddedResources []string `json:"added_resources,omitempty"`

379
web/dist/assets/index-C41vYD6l.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
web/dist/index.html vendored
View File

@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-WRB2YG1b.js"></script> <script type="module" crossorigin src="/assets/index-C41vYD6l.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CtqUfnO-.css"> <link rel="stylesheet" crossorigin href="/assets/index-CtqUfnO-.css">
</head> </head>
<body> <body>

View File

@@ -313,7 +313,7 @@ export function PlatformTenantsPage(props: {
next.tenantName = r.name; next.tenantName = r.name;
onSession?.(next); onSession?.(next);
setInfo(`已打开「${r.name}」管理视图(身份仍是平台超管)`); setInfo(`已打开「${r.name}」管理视图(身份仍是平台超管)`);
message.success(`已打开「${r.name}`); message.success(`已打开「${r.name}管理视图(身份仍是平台超管)`);
onEnteredCompany?.(); onEnteredCompany?.();
} catch (e: any) { } catch (e: any) {
message.error(e.message || String(e)); message.error(e.message || String(e));

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Alert,
App as AntApp, App as AntApp,
Button, Button,
Checkbox, Checkbox,
@@ -486,6 +487,13 @@ export function SyncPage(props: {
线 A <Typography.Text code>postgres</Typography.Text> 线 A <Typography.Text code>postgres</Typography.Text>
</Typography.Paragraph> </Typography.Paragraph>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="冲突已自动按 LWW 处理;覆盖审计请到「平台超管 · 租户/公司 · LWW 覆盖日志」查看。公司侧无冲突台。"
/>
<Space style={{ marginBottom: 12 }} wrap> <Space style={{ marginBottom: 12 }} wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
@@ -511,6 +519,11 @@ export function SyncPage(props: {
loading={loading} loading={loading}
dataSource={visibleChannels} dataSource={visibleChannels}
pagination={false} pagination={false}
locale={{
emptyText: filterAgentId
? "该智能体暂无同步通道"
: "该公司暂无同步通道(未绑定智能体或尚未创建通道)",
}}
columns={[ columns={[
{ {
title: "通道 ID", title: "通道 ID",
@@ -908,7 +921,7 @@ export function SyncPage(props: {
/> />
<Typography.Paragraph type="secondary" style={{ marginTop: 16 }}> <Typography.Paragraph type="secondary" style={{ marginTop: 16 }}>
LWW 5 5
= agent push = agent push
线 push Binding 线 push Binding
Binding <Typography.Text code>shared=true</Typography.Text> Binding <Typography.Text code>shared=true</Typography.Text>

View File

@@ -1017,13 +1017,16 @@ export async function stopSyncChannel(session: Session, id: string) {
return data; return data;
} }
/** @deprecated 公司侧已 403请用 listPlatformLwwOverrides */ /** @deprecated 公司侧已 403请用 listPlatformLwwOverrides。预期 403 静默返回空,勿当故障。 */
export async function listSyncConflicts(session: Session, unresolved = true) { export async function listSyncConflicts(session: Session, unresolved = true) {
const q = unresolved ? "?unresolved=1" : "?unresolved=0"; const q = unresolved ? "?unresolved=1" : "?unresolved=0";
const res = await apiFetch(`/api/v1/admin/sync/conflicts${q}`, { const res = await apiFetch(`/api/v1/admin/sync/conflicts${q}`, {
headers: { Authorization: `Bearer ${session.accessToken}` }, headers: { Authorization: `Bearer ${session.accessToken}` },
}); });
const data = await readJson(res); const data = await readJson(res);
if (res.status === 403) {
return { items: [] as SyncConflict[] };
}
throwIfBad(res, data, "list conflicts failed (公司侧已禁用,请用超管 LWW 审计)"); throwIfBad(res, data, "list conflicts failed (公司侧已禁用,请用超管 LWW 审计)");
return data as { items: SyncConflict[] }; return data as { items: SyncConflict[] };
} }

View File

@@ -1,6 +1,6 @@
# 联调后修改意见 · 宇恒松离线(形态 B # 联调后修改意见 · 宇恒松离线(形态 B
> 初稿2026-08-01 · 修订至 **2026-08-07**Z34b 智建已落实restore-by-host > 初稿2026-08-01 · 修订至 **2026-08-07**+Z37 SyncPage LWW 403 体验
> 焦点:**§0.2**(按负责方);**改代码前须先写入本意见**(见 §0.0 > 焦点:**§0.2**(按负责方);**改代码前须先写入本意见**(见 §0.0
> 来源:宇恒 `yuhengyihao_client` ↔ 智建(生产 `aisite.yuxindazhineng.com` > 来源:宇恒 `yuhengyihao_client` ↔ 智建(生产 `aisite.yuxindazhineng.com`
> 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md` > 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md`
@@ -31,6 +31,8 @@
| **Z24 已绑定勿 force 清落点** | **宇恒已改完;智建无需** | 启动自动引导绑定时禁止 `force_rebind` 清 CHANNEL仅用户点「换绑」才清§5.22 | | **Z24 已绑定勿 force 清落点** | **宇恒已改完;智建无需** | 启动自动引导绑定时禁止 `force_rebind` 清 CHANNEL仅用户点「换绑」才清§5.22 |
| **Z34 / Z34b 换机恢复** | **宇恒半程 + 智建已落实** | 有手机静默恢复Z34无手机 `POST …/restore-by-host`Z34b · §5.32 | | **Z34 / Z34b 换机恢复** | **宇恒半程 + 智建已落实** | 有手机静默恢复Z34无手机 `POST …/restore-by-host`Z34b · §5.32 |
| **Z35 库名非法中文** | **智建已落实(须生产 pull** | 绑定/AttachSyncBind/heal 纠正publish 拒非法名§5.33**宇恒勿改** | | **Z35 库名非法中文** | **智建已落实(须生产 pull** | 绑定/AttachSyncBind/heal 纠正publish 拒非法名§5.33**宇恒勿改** |
| **Z36 编辑发布无增量** | **智建已落实(须生产 pull** | 无增量 + `host_meta.module_name``host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`§5.34 |
| **Z37 SyncPage LWW 403 吓人** | **智建已落实(须生产 pull** | SyncPage 无冲突队列;中性提示 + 空通道说明conflicts 403 静默§5.35 |
| **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹 / 数据恢复;不是「再点启动」 | | **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹 / 数据恢复;不是「再点启动」 |
### 0.0 修改流程(冻结) ### 0.0 修改流程(冻结)
@@ -75,6 +77,7 @@
| **Z32** | **绑定表单按方式显隐字段2026-08-06 · 宇恒已改完)**`visible_when` + sendFrom 过滤;绑码/手机号/稍后切换下方表单项。**智建无需改** | | **Z32** | **绑定表单按方式显隐字段2026-08-06 · 宇恒已改完)**`visible_when` + sendFrom 过滤;绑码/手机号/稍后切换下方表单项。**智建无需改** |
| **Z33** | **仅数据同步时勿因缺 app.read 进换绑2026-08-06 · 宇恒已改完)**GET /apps 403 且已 sync_bound → apps=[] 继续模块菜单并提示开通读取模块。**智建无需改**(控制台给智能体开「读取模块」即可列模块) | | **Z33** | **仅数据同步时勿因缺 app.read 进换绑2026-08-06 · 宇恒已改完)**GET /apps 403 且已 sync_bound → apps=[] 继续模块菜单并提示开通读取模块。**智建无需改**(控制台给智能体开「读取模块」即可列模块) |
| **Z34** | **换机按账号恢复2026-08-06 · 宇恒半程已改完)**:有手机号时可静默 ticket/confirm。**完整「仅宇恒 ID」见智建 Z34b已落实** | | **Z34** | **换机按账号恢复2026-08-06 · 宇恒半程已改完)**:有手机号时可静默 ticket/confirm。**完整「仅宇恒 ID」见智建 Z34b已落实** |
| **Z36** | **编辑发布无增量中文取消2026-08-07 · 宇恒已改完;智建 Z36 已落实)**:宇恒侧预处理;智建 `host_meta_updated` / `[NO_BLUEPRINT_DELTA]`§5.34 |
#### A. 宇恒 · 配合注意(非阻塞新开发) #### A. 宇恒 · 配合注意(非阻塞新开发)
@@ -98,6 +101,8 @@
| **数据恢复§5.13** | 成功同步后线上库快照latest+previousSyncPage「数据恢复」`GET …/checkpoint` + `POST …/restore` | | **数据恢复§5.13** | 成功同步后线上库快照latest+previousSyncPage「数据恢复」`GET …/checkpoint` + `POST …/restore` |
| **Z34b** | `POST /api/v1/auth/yuheng/restore-by-host`HMAC 凭票phone 可空)按 `host_key` 恢复已 sync_bound 落点并轮换密钥;见 §5.32 | | **Z34b** | `POST /api/v1/auth/yuheng/restore-by-host`HMAC 凭票phone 可空)按 `host_key` 恢复已 sync_bound 落点并轮换密钥;见 §5.32 |
| **Z35** | 绑定 `database_name=user_{id}`publish 拒绝中文库名并纠正脏数据;见 §5.33 | | **Z35** | 绑定 `database_name=user_{id}`publish 拒绝中文库名并纠正脏数据;见 §5.33 |
| **Z36** | merge 无增量:有 `host_meta.module_name``publish_mode=host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`;见 §5.34 |
| **Z37** | SyncPage 无冲突队列;中性 LWW 说明 +「该公司暂无通道」空态;`listSyncConflicts` 403 静默;见 §5.35 |
> 产品一句:**账号已绑定 ⇒ 落点信息固定;通道没了平台自动补,终端无需手填 channel_id。** > 产品一句:**账号已绑定 ⇒ 落点信息固定;通道没了平台自动补,终端无需手填 channel_id。**
@@ -105,7 +110,7 @@
| 优先级 | 编号 | 项 | 说明 | | 优先级 | 编号 | 项 | 说明 |
|--------|------|----|------| |--------|------|----|------|
| — | — | Z35 已合入见 B | 其余无阻塞开发项;生产 pull 见 B | | — | — | Z36/Z37 已合入见 B | 当前无待开发项;运维见 B |
#### B‴. 智建 · 本次明确不改Z15 #### B‴. 智建 · 本次明确不改Z15
@@ -121,7 +126,7 @@
| 优先级 | 项 | 说明 | | 优先级 | 项 | 说明 |
|--------|----|------| |--------|----|------|
| **P0** | **生产 pull 本批** | Z10d + fingerprint + Z14c + **Z12h** + **数据恢复** + **Z34b** + **Z35**`bash ./restart.sh --pull` | | **P0** | **生产 pull 本批** | Z10d + fingerprint + Z14c + **Z12h** + **数据恢复** + **Z34b** + **Z35** + **Z36** + **Z37**`bash ./restart.sh --pull` |
| **P0** | 成员手机 | 「宇信达」绑 **`13531041944`**;勿超管号 | | **P0** | 成员手机 | 「宇信达」绑 **`13531041944`**;勿超管号 |
| **P0** | 通道表白名单 | **勿**「填入测试默认」;形态 B 可空 | | **P0** | 通道表白名单 | **勿**「填入测试默认」;形态 B 可空 |
| **P1** | 凭票 Secret | `YuhengTicket.Secret``YXD_YUHENG_TICKET_SECRET` | | **P1** | 凭票 Secret | `YuhengTicket.Secret``YXD_YUHENG_TICKET_SECRET` |
@@ -1123,6 +1128,76 @@ POST /api/v1/agent/sync/channels/{id}/pull
2. 智能体 `database_name` 为合法 `a-z0-9_`;控制台展示名仍可为中文。 2. 智能体 `database_name` 为合法 `a-z0-9_`;控制台展示名仍可为中文。
3. 宇恒侧无需发版即可复测通过。 3. 宇恒侧无需发版即可复测通过。
### 5.34 【Z36 · 2026-08-07】编辑发布 `nothing new to publish`
**状态****宇恒已改提示/预处理;智建已落实(须生产 pull**。
#### 现象
编辑已有模块后发布:
`HTTP 400: nothing new to publish: provide newly generated pages ...`
请求多为 `mode=add_pages``host_meta.module_name` 可能已改(如「记录列表/概览」)。
#### 根因
`add_pages` 走蓝图 merge生成草稿的 page id/route 与线上完全一致、且无新字段/资源时merge 增量为空 → 400。仅改显示名host_meta也会踩中。
#### 宇恒改
1. 发布前 `prepare_add_pages_draft`:去掉完全重复页,冲突 id/route 改写。
2. `draft_has_add_pages_delta` 判定无新页/新字段则中文取消发布,不再抛裸英文 400。
3. 若仍收到该错误,中文说明(并指向本意见 Z36
#### 智建Z36 · 已改)
`add_pages`/`auto` 在 merge 无增量时:若 `host_meta.module_name` 非空 → 只更新显示名并重签胶囊,返回 `publish_mode=host_meta_updated`;否则 400 中文 message 含 `[NO_BLUEPRINT_DELTA]`。有蓝图增量时亦把 `module_name` 写入蓝图 Meta.Name。
#### 验收
1. 编辑需求明确「新增某页」且生成含新页 → add_pages 成功。
2. 生成仅复述旧页 / 仅改显示名 → 宇恒中文取消,不出现裸 `nothing new to publish`
3.(智建)仅改模块显示名再发布(带 `host_meta.module_name`)→ 2xx + `host_meta_updated`;无 module_name → 中文 `[NO_BLUEPRINT_DELTA]`
### 5.35 【Z37 · 2026-08-07】超管进公司「数据同步」红条 LWW 403 + 空表
**状态****智建已落实(须生产 pull 前端 dist宇恒勿改**。
#### 现象(生产控制台截图)
1. 平台超管在「租户/公司」点「管理该公司」(如「帅帅」)→ 绿条:`已打开「帅帅」管理视图(身份仍是平台部/超管)`**预期**:身份仍是超管,只是切到该公司租户上下文)。
2. 打开「数据同步」:顶部**红气泡** + 页内**红 Alert** 同文案:`冲突/LWW 覆盖日志仅平台超级管理员可查`(重复、吓人,像故障)。
3. 「数据同步」通道表、`冲突队列(未解决)` 均为「暂无数据」。
#### 根因(产品设计 + 前端体验)
| 点 | 说明 |
|----|------|
| **权限设计(正确)** | 公司侧 `GET /api/v1/admin/sync/conflicts*` **固定 403**,文案即上句(`syncConflictsHandler`。LWW 覆盖审计只在平台超管页:`GET /api/v1/platform/dbsync/lww-overrides``PlatformTenantsPage`「LWW 覆盖日志」)。方案/开通说明已写「公司侧无冲突台」。 |
| **为何会弹 403** | 生产前端仍在公司 SyncPage 拉冲突队列(截图有「冲突队列」区块)。本仓当前 `SyncPage.tsx` **已去掉**冲突列表与 `listSyncConflicts` 调用,仅文案提示「覆盖审计仅超管可查」——**生产 web 未 pull/未发到与本仓一致**,或残留调用把 403 当成 `setError` + `message.error` 双报。 |
| **空通道表** | 列表按**当前公司租户**过滤。进「帅帅」只看帅帅的通道;该公司从未绑智能体/无通道 →「暂无数据」正常。联调账号若在别的公司(如婷婷),须进**对应公司**管理视图,或回平台租户列表再进目标公司。 |
| **绿条** | 管理视图提示与红条无关可保留。文案「平台部」vs 源码「平台超管」亦说明生产前端偏旧。 |
> 结论:**不是宇恒同步坏了,也不是超管没权限查 LWW**;是公司 SyncPage 仍把「公司侧禁用冲突 API」当错误展示且可能看错公司导致通道为空。LWW 应去平台侧审计区查。
#### 智建改(须 · 已落实)
1. **生产 pull/发版前端**SyncPage 与本仓一致——**无**「冲突队列」区块、**不**再调 `listSyncConflicts`(或 403 静默空列表)。
2. 公司 SyncPage 顶栏中性说明(非 error「冲突已自动 LWW覆盖日志请到平台超管 · LWW 覆盖日志」。
3. 空通道:空态「该公司暂无同步通道…」,勿与 LWW 403 混为一谈。
4. 管理视图绿条文案统一为「身份仍是平台超管」。
#### 宇恒
**无需改代码、无需改契约。**
#### 验收
1. 超管 → 管理某公司 → 数据同步:**无**红条/红气泡「冲突/LWW…仅超管可查」无冲突队列表。
2. 该公司确有通道时列表可见;无通道时空态说明清楚,不报权限错误。
3. 平台租户页「LWW 覆盖日志」仍可查审计;公司侧 conflicts 接口保持 403契约不变
## 6. 联系与附件 ## 6. 联系与附件
- **待改清单(优先看)**§0.2**A 宇恒接入 restore-by-host · B 运维 pull****改代码前先写本意见**§0.0);配合见 **§0.3** - **待改清单(优先看)**§0.2**A 宇恒接入 restore-by-host · B 运维 pull****改代码前先写本意见**§0.0);配合见 **§0.3**