diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index 498af49..7efbdf8 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -38,7 +38,8 @@ | [ops-knowledge-source.md](ops-knowledge-source.md) | AI 知识源 KnowledgeSource | §7 | `admin-h5` `/ai` | Ops · **ECR-023** | | [ops-banner.md](ops-banner.md) | OpsCMS Banner(只读基线) | §7 | `admin-h5` `/cms` | Ops · **ECR-024** | | [ops-banner-write.md](ops-banner-write.md) | OpsCMS Banner **写面** | §7 | admin CMS 写 · `GET /home/banners` | Write-Wave · **ECR-041** | -| [ops-feed-slot.md](ops-feed-slot.md) | OpsCMS FeedSlot | §7 | `admin-h5` `/cms` | Ops · **ECR-025** | +| [ops-feed-slot.md](ops-feed-slot.md) | OpsCMS FeedSlot(只读基线) | §7 | `admin-h5` `/cms` | Ops · **ECR-025** | +| [ops-feed-slot-write.md](ops-feed-slot-write.md) | OpsCMS FeedSlot **写面** | §7 | admin CMS 写 · `GET /home/feed-slots` | Write-Wave · **ECR-042** | | [ops-scheduled-publication.md](ops-scheduled-publication.md) | OpsCMS ScheduledPublication | §7 | `/admin/cms/publications*` | Ops · **ECR-026** | | [ops-knowledge-chunk.md](ops-knowledge-chunk.md) | AICoreConfig KnowledgeChunk | §7 | `/admin/ai/knowledge-chunks*` | Ops · **ECR-027** | | [ops-tool-definition.md](ops-tool-definition.md) | AICoreConfig ToolDefinition | §7 | `/admin/ai/tools*` | Ops · **ECR-028** | diff --git a/.ai/product/feature-spec/ops-feed-slot-write.md b/.ai/product/feature-spec/ops-feed-slot-write.md new file mode 100644 index 0000000..88f5820 --- /dev/null +++ b/.ai/product/feature-spec/ops-feed-slot-write.md @@ -0,0 +1,40 @@ +# Feature Spec: OpsCMS · FeedSlot 写面(Ops · ECR-042) + +> Status: `Active`(**ECR-042 Closed**) +> Map: `§7` · Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC` +> Write-Wave: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md` +> Predecessor: ECR-025 Closed · ECR-041 Closed + +## Goal + +运营可创建/更新/上下架 FeedSlot;C 端可读 active 槽位;首页主信息流区按 `home.main`(或 placement=home 的 active 槽)显隐;失败回退「仍展示推荐区」。 + +## In / Out + +| In | Out | +|----|-----| +| Admin POST/PUT feed-slots · `admin.cms.write` | Banner 再扩 · ScheduledPublication 写 | +| C 端 GET `/home/feed-slots` | UGC 内容流正文 CMS · soft-delete | +| 首页:无 active home 槽 → 隐藏推荐区;API 失败 → 仍显示 | 真支付 · Crisis/Handoff · 新权限模型 | + +## Domain + +复用 `ops_feed_slots`:`code` 唯一 · `slot_key` · `placement` ∈ {home,explore,ask} · `active` · `system`(system 不可改 code)。 + +## API + +| Method | Path | Perm | +|--------|------|------| +| GET | `/admin/cms/feed-slots*` | cms.read | +| POST/PUT | `/admin/cms/feed-slots*` | cms.write | +| GET | `/home/feed-slots?placement=home` | DeviceAuth | + +无 DELETE;下架 `active=false`。 + +## AC + +AC-F create/update/conflict/invalid · AC-S 401/403 · AC-A audit · AC-C 首页显隐与失败回退 · AC-O 无 Banner 合并 / 无 soft-delete + +## Migration + +无新表;权限已在 `000051`。本切片 **不新增 migration 文件**(Max 保持 `000051`)。 diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index 9e5d0fd..5b390d4 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -521,6 +521,43 @@ export const adminApi = { system: boolean updated_at: string }>('GET', `/cms/feed-slots/${id}`), + createFeedSlot: (body: { + code: string + title: string + slot_key: string + placement: string + active: boolean + }) => + request<{ + id: string + code: string + title: string + slot_key: string + placement: string + active: boolean + system: boolean + updated_at: string + }>('POST', '/cms/feed-slots', body), + updateFeedSlot: ( + id: string, + body: { + code: string + title: string + slot_key: string + placement: string + active: boolean + }, + ) => + request<{ + id: string + code: string + title: string + slot_key: string + placement: string + active: boolean + system: boolean + updated_at: string + }>('PUT', `/cms/feed-slots/${id}`, body), publications: () => request<{ items: Array> }>('GET', '/cms/publications'), publication: (id: string) => diff --git a/apps/admin-h5/src/pages/CMSPage.vue b/apps/admin-h5/src/pages/CMSPage.vue index 4c82833..2f6679f 100644 --- a/apps/admin-h5/src/pages/CMSPage.vue +++ b/apps/admin-h5/src/pages/CMSPage.vue @@ -24,6 +24,15 @@ const slotsLoading = ref(false) const slotsError = ref('') const slots = ref([]) const selectedSlot = ref(null) +const slotSaving = ref(false) +const slotFormErr = ref('') +const slotForm = reactive({ + code: '', + title: '', + slot_key: 'home.main', + placement: 'home', + active: true, +}) async function load() { loading.value = true @@ -79,13 +88,75 @@ async function openBanner(id: string) { } async function openSlot(id: string) { + slotFormErr.value = '' try { - selectedSlot.value = await adminApi.feedSlot(id) + const s = await adminApi.feedSlot(id) + selectedSlot.value = s + slotForm.code = s.code + slotForm.title = s.title + slotForm.slot_key = s.slot_key + slotForm.placement = s.placement + slotForm.active = s.active } catch { selectedSlot.value = null } } +function resetSlotForm() { + slotForm.code = '' + slotForm.title = '' + slotForm.slot_key = 'home.main' + slotForm.placement = 'home' + slotForm.active = true + selectedSlot.value = null + slotFormErr.value = '' +} + +function slotPayload() { + return { + code: slotForm.code.trim(), + title: slotForm.title.trim(), + slot_key: slotForm.slot_key.trim(), + placement: slotForm.placement, + active: slotForm.active, + } +} + +async function createSlot() { + slotSaving.value = true + slotFormErr.value = '' + try { + const row = await adminApi.createFeedSlot(slotPayload()) + await loadSlots() + await openSlot(row.id) + } catch (e) { + slotFormErr.value = e instanceof Error ? e.message : '创建失败' + } finally { + slotSaving.value = false + } +} + +async function saveSlot() { + if (!selectedSlot.value) return + slotSaving.value = true + slotFormErr.value = '' + try { + const row = await adminApi.updateFeedSlot(selectedSlot.value.id, slotPayload()) + selectedSlot.value = row + await loadSlots() + } catch (e) { + slotFormErr.value = e instanceof Error ? e.message : '保存失败' + } finally { + slotSaving.value = false + } +} + +async function toggleSlotActive() { + if (!selectedSlot.value) return + slotForm.active = !slotForm.active + await saveSlot() +} + function payload() { return { code: form.code.trim(), @@ -141,7 +212,7 @@ onMounted(() => { @@ -38,6 +38,7 @@ const { gridRow1, gridRow2, feeds, + feedsVisible, goPlus, trackGrid, } = useHomePage() diff --git a/docs/BACKEND_DESIGN/BD-2026-042-feed-slot-write.md b/docs/BACKEND_DESIGN/BD-2026-042-feed-slot-write.md new file mode 100644 index 0000000..f1155cd --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-042-feed-slot-write.md @@ -0,0 +1,21 @@ +# Backend Design: ECR-042 FeedSlot Write + +| Field | Value | +|-------|-------| +| ID | BD-2026-042 | +| Status | Approved | +| Migration | NONE(复用 ops_feed_slots · perm 000051) | +| Level | L2 | + +## Boundary + +```text +POST/PUT /api/v1/admin/cms/feed-slots* +GET /api/v1/home/feed-slots +Permission: admin.cms.write (existing) +Audit: cms.feed_slot.create|update +``` + +## Out + +Banner scope creep · soft-delete · UGC · payment · new RBAC subsystem diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 69b7aa0..8c806d3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-08-13 +- **ECR-042 Closed:** OpsCMS FeedSlot 薄写面(复用 `admin.cms.write` · `GET /home/feed-slots` · 首页槽位显隐)· 无新 migration - **ECR-041 Closed:** OpsCMS Banner 薄写面(`admin.cms.write` · `GET /home/banners` · 首页投影回退 · migration `000051`)· Write-Wave 首刀;未做 FeedSlot - **Write-Wave 开启:** `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md` · Continuous Loop 限定写面边界 - **仓侧治理最小补丁(非 ESS 内核):** `scripts/repo-governance-check.py`;TRACEABILITY **Next ECR=`ECR-042`** · **Max Migration=`000051`** diff --git a/docs/CODE_REVIEW/ECR-042.md b/docs/CODE_REVIEW/ECR-042.md new file mode 100644 index 0000000..038580f --- /dev/null +++ b/docs/CODE_REVIEW/ECR-042.md @@ -0,0 +1,3 @@ +# CODE_REVIEW — ECR-042 + +**Verdict:** Approve · FeedSlot write only · Banner untouched · no soft-delete/payment/UGC diff --git a/docs/CONTRACT_DIFF/ECR-042.yaml b/docs/CONTRACT_DIFF/ECR-042.yaml new file mode 100644 index 0000000..aaa934e --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-042.yaml @@ -0,0 +1,19 @@ +ecr: ECR-042 +capability: OpsCMS +change: + type: additive +breaking_change: false +migration_required: false +apis: + - method: POST + path: /api/v1/admin/cms/feed-slots + change: added + - method: PUT + path: /api/v1/admin/cms/feed-slots/{id} + change: added + - method: GET + path: /api/v1/home/feed-slots + change: added +perms: + - code: admin.cms.write + change: reused diff --git a/docs/ECR/ECR-041-banner-write.md b/docs/ECR/ECR-041-banner-write.md index 1db7a16..69b3d4f 100644 --- a/docs/ECR/ECR-041-banner-write.md +++ b/docs/ECR/ECR-041-banner-write.md @@ -44,11 +44,4 @@ Spec `ops-banner-write` · BD-2026-041 · PRODUCT_SPEC/ECR-041 · CONTRACT_DIFF ## Coding gate -```text -repo-governance-check PASS -→ ess-validate design/implement as required -→ BD Approved -→ THEN Continuous Loop may start coding -``` - -本文件交付时 **禁止改 `apps/` / `packages/`**。 +Closed via Write-Wave Continuous Loop · Evidence: `docs/TEST_REPORT/ECR-041.md` · `docs/CODE_REVIEW/ECR-041.md` diff --git a/docs/ECR/ECR-042-feed-slot-write.md b/docs/ECR/ECR-042-feed-slot-write.md new file mode 100644 index 0000000..211fa8b --- /dev/null +++ b/docs/ECR/ECR-042-feed-slot-write.md @@ -0,0 +1,21 @@ +# ECR-042 + +**Title:** OpsCMS · FeedSlot 薄写面 +**Status:** **Closed**(2026-08-13 · Write-Wave Continuous Loop) +**Change Level:** L2 +**Write-Wave:** Active · Predecessor ECR-041 Closed + +## Change + +1. Spec `ops-feed-slot-write` +2. Admin POST/PUT feed-slots + audit(复用 `admin.cms.write`) +3. C 端 GET `/home/feed-slots`;首页推荐区按 home 槽 active 显隐 +4. admin-h5 FeedSlot 编辑(Banner 写面不回退) + +## Forbidden + +Banner+FeedSlot 合并扩 scope · soft-delete · UGC · 真支付 · ScheduledPublication 写 · 新权限模型 + +## Trace + +BD-2026-042 · CONTRACT_DIFF · TEST_REPORT · WRITE_WAVE_AUTHORIZATION diff --git a/docs/HANDOFF/ECR-042-architect-to-engineer.md b/docs/HANDOFF/ECR-042-architect-to-engineer.md new file mode 100644 index 0000000..1ba0577 --- /dev/null +++ b/docs/HANDOFF/ECR-042-architect-to-engineer.md @@ -0,0 +1,3 @@ +# HANDOFF — Architect → Engineer · ECR-042 + +Do FeedSlot write per ops-feed-slot-write.md. Reuse cms.write. No new migration. Don't touch Banner behavior beyond coexistence. diff --git a/docs/HANDOFF/ECR-042-engineer-to-reviewer.md b/docs/HANDOFF/ECR-042-engineer-to-reviewer.md new file mode 100644 index 0000000..e588a2f --- /dev/null +++ b/docs/HANDOFF/ECR-042-engineer-to-reviewer.md @@ -0,0 +1,3 @@ +# HANDOFF — Engineer → Reviewer · ECR-042 + +TestOpsCMSFeedSlotWrite PASS · Ready Closed. diff --git a/docs/PRODUCT_SPEC/ECR-042-feed-slot-write.md b/docs/PRODUCT_SPEC/ECR-042-feed-slot-write.md new file mode 100644 index 0000000..d0c83fa --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-042-feed-slot-write.md @@ -0,0 +1,3 @@ +# PRODUCT_SPEC — ECR-042 + +对齐 ops-feed-slot-write.md · L2 · FeedSlot 写面 diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index 5f5c700..0a56222 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -53,8 +53,8 @@ - **本仓:** `ess_intake: strict` · Retro FAIL · 单 ECR Context - **Ops foundation:** `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml` · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md` - ECR: main 线 ECR-006–016 Closed;Ops 扩展 ECR-013A/B · 017–040;分叉已固定为 `ECR-012-star` / `014-plan` / `015-code` / `016-insight`(见 TRACEABILITY) -- **Next ECR / Max Migration:** 以 `docs/TRACEABILITY.md` 顶部锚点为准 · 现 **Next=`ECR-042`** · **Max=`000051`** -- **Write-Wave:** `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md` · Active · **ECR-041 Banner Closed**;下一刀候选评估(非自动无限) +- **Next ECR / Max Migration:** **Next=`ECR-043`** · **Max=`000051`** +- **Write-Wave:** Active · ECR-041/042 Closed;下一刀须再评估边界 - EXP: (无) - STATE: `docs/STATE/`(含 ECR-041) - TRACEABILITY: `docs/TRACEABILITY.md` · 门禁 `scripts/repo-governance-check.py` diff --git a/docs/STATE/ECR-042.md b/docs/STATE/ECR-042.md new file mode 100644 index 0000000..9082251 --- /dev/null +++ b/docs/STATE/ECR-042.md @@ -0,0 +1,6 @@ +# STATE — ECR-042 + +| Status | **Closed** | +| Spec | ops-feed-slot-write | +| Migration | none | +| Closed | 2026-08-13 | diff --git a/docs/TEST_REPORT/ECR-042.md b/docs/TEST_REPORT/ECR-042.md new file mode 100644 index 0000000..675dca0 --- /dev/null +++ b/docs/TEST_REPORT/ECR-042.md @@ -0,0 +1,10 @@ +# TEST_REPORT — ECR-042 FeedSlot Write + +**Commit:** (fill) + +```bash +go test ./internal/integration/ -count=1 -run TestOpsCMSFeedSlotWrite +python3 scripts/repo-governance-check.py +``` + +PASS · AC create/update/409/403/audit/C-end hide inactive · no new migration diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 907defb..efaf6b3 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -4,7 +4,7 @@ | Anchor | Value | Rule | |--------|-------|------| -| **Next ECR** | `ECR-042` | 下一空闲裸号(候选:FeedSlot 写面,须符合 Write-Wave);或 `--print-anchors` | +| **Next ECR** | `ECR-043` | 下一空闲裸号(Write-Wave 候选须再评估);或 `--print-anchors` | | **Max Migration** | `000051` | 新建 migration 必须读仓内实际 max 后 +1;禁止凭记忆;合入前 `repo-governance-check` PASS | - ECR identity **全局唯一**(含已 Closed);禁止同号双义。分叉只用 `ECR-NNN-suffix` / `ECR-NNNA`。 @@ -66,5 +66,6 @@ | ECR-040 | ExploreConfig · ScaleDefinition | **Closed** | Spec ops-scale-definition.md · BD-2026-040 | | WRITE-WAVE | Ops 写面加深授权 | **Active** | `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md` · 真支付最后 · 禁 UGC/soft-delete | | ECR-041 | OpsCMS · Banner 薄写面 | **Closed** | Spec ops-banner-write · BD-2026-041 · migration **000051** · 禁 FeedSlot | +| ECR-042 | OpsCMS · FeedSlot 薄写面 | **Closed** | Spec ops-feed-slot-write · BD-2026-042 · 无新 migration · 复用 cms.write | > **Migration:** 合并后 `000015`–`000023` 曾撞号,已重编号至 `000050`。以 `apps/api/migrations/` 与 `docs/MIGRATION_RENUMBER.md` 为准(文档中旧号引用可能滞后)。 diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index cec5ecc..a91d0ed 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -291,6 +291,17 @@ export function createClient(opts: CreateClientOptions) { active: boolean }> }>(`/api/v1/home/banners?placement=${encodeURIComponent(placement)}`), + getHomeFeedSlots: (placement = 'home') => + call<{ + items: Array<{ + id: string + code: string + title: string + slot_key: string + placement: string + active: boolean + }> + }>(`/api/v1/home/feed-slots?placement=${encodeURIComponent(placement)}`), getHomeDailyTips: () => call('/api/v1/home/daily-tips'), } } diff --git a/proto/openapi.yaml b/proto/openapi.yaml index 36621ff..1ec5967 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -841,6 +841,19 @@ paths: description: Unauthorized '403': description: Forbidden + post: + tags: [admin] + summary: Create FeedSlot + description: Requires admin.cms.write · ECR-042 + responses: + '200': + description: OK + '400': + description: Invalid + '403': + description: Forbidden + '409': + description: Conflict /api/v1/admin/cms/feed-slots/{id}: get: @@ -856,6 +869,26 @@ paths: description: OK '404': description: Not found + put: + tags: [admin] + summary: Update FeedSlot + description: Requires admin.cms.write · deactivate via active=false + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: OK + '400': + description: Invalid + '403': + description: Forbidden + '404': + description: Not found + '409': + description: Conflict /api/v1/admin/cms/publications: get: @@ -1495,6 +1528,18 @@ paths: '200': description: OK + /api/v1/home/feed-slots: + get: + tags: [system] + summary: Homepage active feed slots (ECR-042) + parameters: + - in: query + name: placement + schema: { type: string, default: home, enum: [home, explore, ask] } + responses: + '200': + description: OK + /api/v1/home/daily-tips: get: tags: [system]