feat(ECR-041): OpsCMS Banner 薄写面(Write-Wave 首刀)
Admin POST/PUT + admin.cms.write/审计;C 端 GET /home/banners;首页投影回退静态 homeFeeds;migration 000051;不碰 FeedSlot/支付/UGC。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -238,7 +238,7 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。
|
||||
| Phase D `[Ops]` | 商业加深:订单筛选 · 展示价 · 退款只读(**ECR-009** · Spec `ops-commerce.md`) |
|
||||
| Phase E `[Ops]` | 系统:RBAC · 封禁 · 推送占位(**ECR-010** · Spec `ops-system.md`) |
|
||||
| Phase D+ Contract | **Contract-First** 扩展目录:**ECR-013A…040**(RBAC/兑换码/问答运营/安全/危机/CMS/ExploreConfig 等)。见 `docs/WAVE0/` |
|
||||
| Phase F+ | 封禁细策略 · 真推送通道 · 更细权限矩阵;**UGC 仍 `[No]`**;真支付最后 |
|
||||
| Phase F+ / Write-Wave | **写面加深**(首刀 **ECR-041 Banner** · `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`);封禁细策略 · 真推送;**UGC 仍 `[No]`**;**真支付最后** |
|
||||
|
||||
不计入 P1 Complete;不进入五 Tab。
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
| [ops-ai-core-config.md](ops-ai-core-config.md) | AI 核心配置 AICoreConfig | §7 | `admin-h5` `/ai` | Ops · **ECR-021** |
|
||||
| [ops-crisis-care.md](ops-crisis-care.md) | 危机关怀 CrisisCare | §7 | `admin-h5` `/crisis` | Ops · **ECR-022** |
|
||||
| [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.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-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** |
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Feature Spec: OpsCMS · Banner 写面(Ops · ECR-041)
|
||||
|
||||
> Status: `Active`(**ECR-041 Closed** · Feature Complete)
|
||||
> Map: `§7 运营后台` · Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
|
||||
> Parent: WAVE0-FROZEN · Write-Wave: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`
|
||||
> Predecessor: ECR-024 Closed(Banner 只读)
|
||||
> 关联:[ops-banner.md](ops-banner.md)(只读基线)· [ops-content.md](ops-content.md) · [home.md](home.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
让运营可 **创建 / 更新 / 上下架** Banner;C 端首页对 `placement=home` 的 active Banner **生效**;写操作鉴权 + 审计;失败可回退静态。
|
||||
|
||||
验证 Write-Wave 首刀闭环,**不**做 FeedSlot、真支付、UGC。
|
||||
|
||||
## 2. In / Out
|
||||
|
||||
| In | Out |
|
||||
|----|-----|
|
||||
| Admin:Banner create / update / 设 `active` | **FeedSlot** 一切写与 API |
|
||||
| 加法权限 `admin.cms.write`(既有 RBAC) | soft-delete 物理删行(`system=true` 禁止删) |
|
||||
| C 端:`GET` active banners(至少 `placement=home`) | ScheduledPublication 写 · 外链任意 URL |
|
||||
| 首页推荐区:API Banner 投影优先,空则回退静态 `homeFeeds` | 把 FeedSlot 与 Banner 合并为一 ECR |
|
||||
| `admin_audit_logs` 记录写操作 | Crisis / Handoff / ContentSafety 写 · 真支付 · UGC |
|
||||
| OpenAPI · contract_diff · integration | 新权限子系统 · 改引擎分层 |
|
||||
|
||||
### 与 `homeFeeds` 的关系(诚实边界)
|
||||
|
||||
- ECR-008 将「首页 Feeds CMS」后置;表意信息流位属 **FeedSlot**(ECR-025 只读,写面另 ECR)。
|
||||
- **本切片**:用 **Banner(placement=home)** 驱动首页推荐区展示(投影字段见下);**不**写 `ops_feed_slots`。
|
||||
- 完整 Feeds 槽位运营(多 slot、排期)→ 后续 FeedSlot 写面 ECR。
|
||||
|
||||
## 3. Domain
|
||||
|
||||
| 概念 | 语义 |
|
||||
|------|------|
|
||||
| `Banner` | `code` 唯一;`placement` ∈ {home,explore,ask};`active`;`system` |
|
||||
| 写语义 | upsert 字段;`active=false` = 下架;禁止 soft-delete 列/物理删 system 行 |
|
||||
| C 端投影 | 仅 `active=true`;按 `sort_order` 升序 |
|
||||
|
||||
### 首页投影映射(Banner → 原 homeFeeds 卡)
|
||||
|
||||
| homeFeeds 字段 | 来源 |
|
||||
|----------------|------|
|
||||
| `to` | `link_path`(须站内 `/` 相对路径) |
|
||||
| `title` | `title` |
|
||||
| `meta` | 可选:无则空串或固定「运营推荐」 |
|
||||
| `icon` / `tone` / `stat` / `tag` | 本切片可用缺省;**不**扩表除非 migration 明确(默认缺省,避免 scope creep) |
|
||||
|
||||
## 4. API
|
||||
|
||||
### Admin(既有前缀 `/api/v1/admin/cms`)
|
||||
|
||||
| Method | Path | 权限 | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/banners` · `/banners/{id}` | `admin.cms.read` | 保持 ECR-024 |
|
||||
| POST | `/banners` | `admin.cms.write` | 创建(code 唯一) |
|
||||
| PUT | `/banners/{id}` | `admin.cms.write` | 更新 title/placement/image_url/link_path/sort_order/active |
|
||||
| — | DELETE | — | **不做**(下架用 `active=false`) |
|
||||
|
||||
校验:`link_path` 若非空须以 `/` 开头、禁 `http(s):`;`placement` 枚举;`system=true` 行不可改 `code`、不可删。
|
||||
|
||||
### C 端
|
||||
|
||||
| Method | Path | Auth | 语义 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/api/v1/home/banners` | DeviceAuth(与 `/home/tools` 同级) | 仅 `active`;query `placement` 默认 `home` |
|
||||
|
||||
## 5. Permission / Audit
|
||||
|
||||
- Migration 授予 `super_admin`:`admin.cms.write`(加法码,非新 RBAC 模型)。
|
||||
- 每次 POST/PUT 写 `admin_audit_logs`(actor · action · target id/code · diff 摘要)。
|
||||
|
||||
## 6. Migration
|
||||
|
||||
- 若仅需权限种子:`000051_ops_banner_write`(以 TRACEABILITY Max+1 为准,编码时再锁)。
|
||||
- **表结构**:默认复用 `ops_banners`;本切片 **不**为 FeedSlot 改表。若投影缺省不够且 Human 未扩 scope,不得私自加列。
|
||||
|
||||
## 7. UI
|
||||
|
||||
| 端 | 行为 |
|
||||
|----|------|
|
||||
| admin-h5 `/cms` | Banner 列表 + 新建/编辑/上下架(只动 Banner;FeedSlot 仍只读展示可保留) |
|
||||
| user-h5 首页 | `GET /home/banners` → 推荐区;失败或空 → 静态 `homeFeeds` |
|
||||
|
||||
## 8. AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | POST 合法 Banner → list/get 可见 |
|
||||
| AC-F-02 | PUT `active=false` → C 端 list 不含 |
|
||||
| AC-F-03 | PUT `active=true` + placement=home → `GET /home/banners` 含该项 |
|
||||
| AC-F-04 | 重复 `code` → 4xx |
|
||||
| AC-F-05 | 非法 `link_path`(外链)→ 4xx |
|
||||
| AC-S-01 | 无 Admin → 401 |
|
||||
| AC-S-02 | 仅 read 无 write → POST/PUT 403 |
|
||||
| AC-A-01 | 写操作产生审计行 |
|
||||
| AC-C-01 | H5 首页:有 active home Banner 时推荐区非纯静态 |
|
||||
| AC-C-02 | API 失败时首页不白屏(回退静态) |
|
||||
| AC-O-01 | 不触碰 FeedSlot 写路径 · 无 soft-delete · 无支付 |
|
||||
|
||||
## 9. Non-goals(STOP 对齐)
|
||||
|
||||
FeedSlot 写 · ScheduledPublication 写 · UGC · 真支付 · soft-delete · Crisis/Handoff/ContentSafety 写 · 扩大为本 ECR 合并多概念。
|
||||
|
||||
## 10. Trace
|
||||
|
||||
- ECR: `docs/ECR/ECR-041-banner-write.md`
|
||||
- BD: `docs/BACKEND_DESIGN/BD-2026-041-banner-write.md`
|
||||
- PRODUCT_SPEC · CONTRACT_DIFF · TEST_REPORT · CODE_REVIEW(实现阶段)
|
||||
- Auth: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`
|
||||
@@ -1,13 +1,14 @@
|
||||
# Feature Spec: OpsCMS · Banner(Ops · ECR-024)
|
||||
# Feature Spec: OpsCMS · Banner(Ops · ECR-024 只读基线)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-024 Closed**)
|
||||
> Status: `Active`(**ECR-024 Closed** · 只读)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-023 Closed
|
||||
> Capability: `OpsCMS` · BC: `Ops_CMS_NoUGC`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
> 授权(只读队列):`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
> **写面:** [ops-banner-write.md](ops-banner-write.md)(**ECR-041** · Write-Wave)
|
||||
|
||||
## Non-goals
|
||||
## Non-goals(本只读 Spec)
|
||||
|
||||
Banner 写发布 · FeedSlot · ScheduledPublication · UGC · 真支付
|
||||
Banner 写发布(见 ECR-041)· FeedSlot · ScheduledPublication · UGC · 真支付
|
||||
|
||||
## L2 Domain
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
| In | Out |
|
||||
|---|---|
|
||||
| 首页宫格 CRUD(最多约 24 项;默认 12) | 首页 Feeds 信息流 CMS(本 ECR 后置,仍用静态 `homeFeeds`) |
|
||||
| 首页宫格 CRUD(最多约 24 项;默认 12) | 首页 Feeds/推荐区运营:ECR-008 后置;**Banner 写面见 ECR-041**;完整 FeedSlot 写另 ECR |
|
||||
| 工具 path / icon 白名单校验 | 任意外链、自定义 SVG 上传 |
|
||||
| scales 列表 + 上架/下架 + 审计 | 新建量表题目编辑器 · 探索目录全量 CMS |
|
||||
| Admin「内容」页 | RBAC / 封禁 / 推送(Ops-E) |
|
||||
|
||||
@@ -452,6 +452,51 @@ export const adminApi = {
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('GET', `/cms/banners/${id}`),
|
||||
createBanner: (body: {
|
||||
code: string
|
||||
title: string
|
||||
placement: string
|
||||
image_url?: string | null
|
||||
link_path?: string | null
|
||||
sort_order: number
|
||||
active: boolean
|
||||
}) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
placement: string
|
||||
image_url?: string
|
||||
link_path?: string
|
||||
sort_order: number
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('POST', '/cms/banners', body),
|
||||
updateBanner: (
|
||||
id: string,
|
||||
body: {
|
||||
code: string
|
||||
title: string
|
||||
placement: string
|
||||
image_url?: string | null
|
||||
link_path?: string | null
|
||||
sort_order: number
|
||||
active: boolean
|
||||
},
|
||||
) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
placement: string
|
||||
image_url?: string
|
||||
link_path?: string
|
||||
sort_order: number
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('PUT', `/cms/banners/${id}`, body),
|
||||
feedSlots: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
type Banner = Awaited<ReturnType<typeof adminApi.banners>>['items'][number]
|
||||
@@ -9,6 +9,16 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Banner[]>([])
|
||||
const selected = ref<Banner | null>(null)
|
||||
const saving = ref(false)
|
||||
const formErr = ref('')
|
||||
const form = reactive({
|
||||
code: '',
|
||||
title: '',
|
||||
placement: 'home',
|
||||
link_path: '',
|
||||
sort_order: 0,
|
||||
active: true,
|
||||
})
|
||||
|
||||
const slotsLoading = ref(false)
|
||||
const slotsError = ref('')
|
||||
@@ -41,9 +51,28 @@ async function loadSlots() {
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.code = ''
|
||||
form.title = ''
|
||||
form.placement = 'home'
|
||||
form.link_path = ''
|
||||
form.sort_order = 0
|
||||
form.active = true
|
||||
selected.value = null
|
||||
formErr.value = ''
|
||||
}
|
||||
|
||||
async function openBanner(id: string) {
|
||||
formErr.value = ''
|
||||
try {
|
||||
selected.value = await adminApi.banner(id)
|
||||
const b = await adminApi.banner(id)
|
||||
selected.value = b
|
||||
form.code = b.code
|
||||
form.title = b.title
|
||||
form.placement = b.placement
|
||||
form.link_path = b.link_path || ''
|
||||
form.sort_order = b.sort_order
|
||||
form.active = b.active
|
||||
} catch {
|
||||
selected.value = null
|
||||
}
|
||||
@@ -57,6 +86,52 @@ async function openSlot(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
code: form.code.trim(),
|
||||
title: form.title.trim(),
|
||||
placement: form.placement,
|
||||
link_path: form.link_path.trim() || null,
|
||||
sort_order: Number(form.sort_order) || 0,
|
||||
active: form.active,
|
||||
}
|
||||
}
|
||||
|
||||
async function createBanner() {
|
||||
saving.value = true
|
||||
formErr.value = ''
|
||||
try {
|
||||
const row = await adminApi.createBanner(payload())
|
||||
await load()
|
||||
await openBanner(row.id)
|
||||
} catch (e) {
|
||||
formErr.value = e instanceof Error ? e.message : '创建失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBanner() {
|
||||
if (!selected.value) return
|
||||
saving.value = true
|
||||
formErr.value = ''
|
||||
try {
|
||||
const row = await adminApi.updateBanner(selected.value.id, payload())
|
||||
selected.value = row
|
||||
await load()
|
||||
} catch (e) {
|
||||
formErr.value = e instanceof Error ? e.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive() {
|
||||
if (!selected.value) return
|
||||
form.active = !form.active
|
||||
await saveBanner()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadSlots()
|
||||
@@ -66,12 +141,13 @@ onMounted(() => {
|
||||
<template>
|
||||
<section>
|
||||
<h1>运营位 CMS</h1>
|
||||
<p class="muted">Banner / FeedSlot 只读 · 非 UGC · 本切片不可发布</p>
|
||||
<p class="muted">Banner 可写 · FeedSlot 只读 · 非 UGC · 下架用停用</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>横幅</h2>
|
||||
<button class="btn" type="button" @click="resetForm">新建</button>
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
@@ -83,18 +159,38 @@ onMounted(() => {
|
||||
<td>{{ b.title }}</td>
|
||||
<td>{{ b.placement }}</td>
|
||||
<td>{{ b.active ? '启用' : '停用' }}</td>
|
||||
<td><button class="btn" type="button" @click="openBanner(b.id)">查看</button></td>
|
||||
<td><button class="btn" type="button" @click="openBanner(b.id)">编辑</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>横幅详情</h2>
|
||||
<template v-if="selected">
|
||||
<p>{{ selected.title }} · {{ selected.placement }}</p>
|
||||
<p class="muted">链接 {{ selected.link_path || '—' }} · 排序 {{ selected.sort_order }}</p>
|
||||
</template>
|
||||
<p v-else class="muted">选择左侧横幅</p>
|
||||
<h2>{{ selected ? '编辑横幅' : '新建横幅' }}</h2>
|
||||
<label>代码 <input v-model="form.code" :disabled="!!selected?.system" /></label>
|
||||
<label>标题 <input v-model="form.title" /></label>
|
||||
<label>
|
||||
位置
|
||||
<select v-model="form.placement">
|
||||
<option value="home">home</option>
|
||||
<option value="explore">explore</option>
|
||||
<option value="ask">ask</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>链接 <input v-model="form.link_path" placeholder="/membership" /></label>
|
||||
<label>排序 <input v-model.number="form.sort_order" type="number" /></label>
|
||||
<label class="check"><input v-model="form.active" type="checkbox" /> 启用</label>
|
||||
<p v-if="formErr" class="err">{{ formErr }}</p>
|
||||
<div class="actions">
|
||||
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createBanner">
|
||||
创建
|
||||
</button>
|
||||
<template v-else>
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="saveBanner">保存</button>
|
||||
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
|
||||
{{ form.active ? '下架' : '上架' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +198,7 @@ onMounted(() => {
|
||||
<p v-else-if="slotsError" class="err gap">{{ slotsError }}</p>
|
||||
<div v-else class="layout gap">
|
||||
<div class="card">
|
||||
<h2>栏目位 FeedSlot</h2>
|
||||
<h2>栏目位 FeedSlot(只读)</h2>
|
||||
<p v-if="!slots.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
@@ -137,5 +233,10 @@ h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.gap { margin-top: 1.5rem; }
|
||||
code { font-size: 0.8rem; }
|
||||
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
|
||||
.card input, .card select { width: 100%; margin-top: 0.2rem; }
|
||||
.check { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
||||
.btn.primary { font-weight: 600; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,8 @@ func (h *AdminHandler) registerCMS(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/cms")
|
||||
g.GET("/banners", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListBanners)
|
||||
g.GET("/banners/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetBanner)
|
||||
g.POST("/banners", middleware.RequireAdminPermission(h.Svc, admin.PermCMSWrite), h.CreateBanner)
|
||||
g.PUT("/banners/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSWrite), h.UpdateBanner)
|
||||
g.GET("/feed-slots", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.ListFeedSlots)
|
||||
g.GET("/feed-slots/:id", middleware.RequireAdminPermission(h.Svc, admin.PermCMSRead), h.GetFeedSlot)
|
||||
}
|
||||
@@ -47,6 +49,69 @@ func (h *AdminHandler) GetBanner(c *gin.Context) {
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateBanner(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
var body admin.BannerWriteBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40050, "invalid body")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.CreateBanner(c.Request.Context(), adminID, body)
|
||||
if errors.Is(err, admin.ErrInvalidBanner) {
|
||||
response.Fail(c, http.StatusBadRequest, 40051, "invalid banner")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrBannerConflict) {
|
||||
response.Fail(c, http.StatusConflict, 40910, "banner code conflict")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50044, "create banner failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateBanner(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
var body admin.BannerWriteBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40050, "invalid body")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.UpdateBanner(c.Request.Context(), adminID, id, body)
|
||||
if errors.Is(err, admin.ErrBannerNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40410, "banner not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidBanner) {
|
||||
response.Fail(c, http.StatusBadRequest, 40051, "invalid banner")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrBannerConflict) {
|
||||
response.Fail(c, http.StatusConflict, 40910, "banner code conflict")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50045, "update banner failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListFeedSlots(c *gin.Context) {
|
||||
items, err := h.Svc.ListFeedSlots(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ type HomeHandler struct {
|
||||
func (h *HomeHandler) Register(api *gin.RouterGroup) {
|
||||
g := api.Group("/home")
|
||||
g.GET("/tools", h.Tools)
|
||||
g.GET("/banners", h.Banners)
|
||||
g.GET("/daily-tips", h.DailyTips)
|
||||
}
|
||||
|
||||
@@ -35,6 +36,16 @@ func (h *HomeHandler) Tools(c *gin.Context) {
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *HomeHandler) Banners(c *gin.Context) {
|
||||
placement := c.DefaultQuery("placement", "home")
|
||||
items, err := h.Svc.ListPublicBanners(c.Request.Context(), placement)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50046, "home banners failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *HomeHandler) DailyTips(c *gin.Context) {
|
||||
uid, _ := middleware.UserIDFromContext(c)
|
||||
tips, err := h.Svc.DailyTips(c.Request.Context(), uid)
|
||||
|
||||
@@ -66,6 +66,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
Repo: &repository.HomeToolsRepo{Pool: pool},
|
||||
Tips: &repository.HomeDailyTipsRepo{Pool: pool},
|
||||
Profiles: profileRepo,
|
||||
CMS: adminRepo,
|
||||
LLM: llm,
|
||||
}
|
||||
analyticsSvc := &analyticssvc.Service{Repo: analyticsRepo}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestOpsCMSBannerWrite(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
devKey := fmt.Sprintf("banner-write-%d", time.Now().UnixNano())
|
||||
|
||||
code := fmt.Sprintf("home_w_%d", time.Now().UnixNano()%1_000_000)
|
||||
body := map[string]any{
|
||||
"code": code, "title": "写面横幅", "placement": "home",
|
||||
"link_path": "/membership", "sort_order": 3, "active": true,
|
||||
}
|
||||
env, httpCode := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/cms/banners", body, tok)
|
||||
if httpCode != 200 || env.Code != 0 {
|
||||
t.Fatalf("create http=%d code=%d msg=%s", httpCode, env.Code, env.Message)
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &created)
|
||||
if created.ID == "" || created.Code != code {
|
||||
t.Fatalf("bad create %#v", created)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM ops_banners WHERE id=$1`, created.ID)
|
||||
})
|
||||
|
||||
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/cms/banners", body, tok)
|
||||
if httpCode != http.StatusConflict {
|
||||
t.Fatalf("dup expected 409 got %d", httpCode)
|
||||
}
|
||||
|
||||
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/cms/banners", map[string]any{
|
||||
"code": code + "_x", "title": "外链", "placement": "home",
|
||||
"link_path": "https://evil.example", "active": true,
|
||||
}, tok)
|
||||
if httpCode != http.StatusBadRequest {
|
||||
t.Fatalf("ext link expected 400 got %d", httpCode)
|
||||
}
|
||||
|
||||
env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/home/banners?placement=home", nil, devKey, 0)
|
||||
if httpCode != 200 {
|
||||
t.Fatalf("home banners http=%d", httpCode)
|
||||
}
|
||||
var pub struct {
|
||||
Items []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &pub)
|
||||
found := false
|
||||
for _, it := range pub.Items {
|
||||
if it.Code == code {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("active banner missing in C-end: %#v", pub.Items)
|
||||
}
|
||||
|
||||
body["active"] = false
|
||||
body["title"] = "已下架"
|
||||
env, httpCode = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/cms/banners/"+created.ID, body, tok)
|
||||
if httpCode != 200 || env.Code != 0 {
|
||||
t.Fatalf("update %d %s", httpCode, env.Message)
|
||||
}
|
||||
|
||||
env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/home/banners?placement=home", nil, devKey, 0)
|
||||
if httpCode != 200 {
|
||||
t.Fatalf("home after deactivate %d", httpCode)
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &pub)
|
||||
for _, it := range pub.Items {
|
||||
if it.Code == code {
|
||||
t.Fatalf("inactive still listed")
|
||||
}
|
||||
}
|
||||
|
||||
var n int
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM admin_audit_logs
|
||||
WHERE action IN ('cms.banner.create','cms.banner.update') AND target_id=$1`,
|
||||
created.ID).Scan(&n)
|
||||
if err != nil || n < 2 {
|
||||
t.Fatalf("expected ≥2 audit rows, got %d err=%v", n, err)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "cms_ro_"+limitedRoleID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.cms.read')`, limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("ro-pass"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roUser := fmt.Sprintf("cmsro_%d", time.Now().UnixNano())
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
roUser, string(hash), limitedRoleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, roUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
roTok := adminLogin(t, r, roUser, "ro-pass")
|
||||
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/cms/banners", map[string]any{
|
||||
"code": "x_ro", "title": "no", "placement": "home", "active": true,
|
||||
}, roTok)
|
||||
if httpCode != http.StatusForbidden {
|
||||
t.Fatalf("read-only write expected 403 got %d", httpCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// BannerWriteInput is create/update payload for ops_banners.
|
||||
type BannerWriteInput struct {
|
||||
Code string
|
||||
Title string
|
||||
Placement string
|
||||
ImageURL *string
|
||||
LinkPath *string
|
||||
SortOrder int
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ListActiveBanners returns active banners for a placement (C-end).
|
||||
func (r *AdminRepo) ListActiveBanners(ctx context.Context, placement string) ([]BannerRow, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at
|
||||
FROM ops_banners
|
||||
WHERE active = true AND placement = $1
|
||||
ORDER BY sort_order ASC, code ASC
|
||||
LIMIT 100`, placement)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []BannerRow
|
||||
for rows.Next() {
|
||||
var b BannerRow
|
||||
if err := rows.Scan(
|
||||
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
|
||||
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateBannerWithAudit inserts a banner and audits.
|
||||
func (r *AdminRepo) CreateBannerWithAudit(
|
||||
ctx context.Context,
|
||||
adminID uuid.UUID,
|
||||
in BannerWriteInput,
|
||||
meta json.RawMessage,
|
||||
) (*BannerRow, error) {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var b BannerRow
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO ops_banners(code, title, placement, image_url, link_path, sort_order, active, system)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,false)
|
||||
RETURNING id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at`,
|
||||
in.Code, in.Title, in.Placement, in.ImageURL, in.LinkPath, in.SortOrder, in.Active,
|
||||
).Scan(
|
||||
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
|
||||
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapBannerWriteErr(err)
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'cms.banner.create','banner',$2,$3)`,
|
||||
adminID, b.ID.String(), meta,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// UpdateBannerWithAudit updates mutable fields and audits.
|
||||
func (r *AdminRepo) UpdateBannerWithAudit(
|
||||
ctx context.Context,
|
||||
adminID, id uuid.UUID,
|
||||
in BannerWriteInput,
|
||||
meta json.RawMessage,
|
||||
) (*BannerRow, error) {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var system bool
|
||||
var oldCode string
|
||||
err = tx.QueryRow(ctx, `SELECT system, code FROM ops_banners WHERE id=$1`, id).Scan(&system, &oldCode)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, pgx.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := in.Code
|
||||
if system {
|
||||
code = oldCode
|
||||
}
|
||||
|
||||
var b BannerRow
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE ops_banners
|
||||
SET code=$2, title=$3, placement=$4, image_url=$5, link_path=$6,
|
||||
sort_order=$7, active=$8, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING id, code, title, placement, image_url, link_path, sort_order, active, system, updated_at`,
|
||||
id, code, in.Title, in.Placement, in.ImageURL, in.LinkPath, in.SortOrder, in.Active,
|
||||
).Scan(
|
||||
&b.ID, &b.Code, &b.Title, &b.Placement, &b.ImageURL, &b.LinkPath,
|
||||
&b.SortOrder, &b.Active, &b.System, &b.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapBannerWriteErr(err)
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'cms.banner.update','banner',$2,$3)`,
|
||||
adminID, id.String(), meta,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
func mapBannerWriteErr(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return errString("banner code conflict")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// BannerCodeConflict reports unique code violation.
|
||||
func BannerCodeConflict(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "banner code conflict")
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidBanner = errors.New("invalid banner")
|
||||
ErrBannerConflict = errors.New("banner code conflict")
|
||||
bannerPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_./-]{1,200}$`)
|
||||
bannerPlacements = map[string]struct{}{"home": {}, "explore": {}, "ask": {}}
|
||||
bannerCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
|
||||
)
|
||||
|
||||
// BannerWriteBody is JSON for create/update.
|
||||
type BannerWriteBody struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Placement string `json:"placement"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
LinkPath *string `json:"link_path"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// CreateBanner validates, inserts, audits.
|
||||
func (s *Service) CreateBanner(ctx context.Context, adminID uuid.UUID, body BannerWriteBody) (*repository.BannerRow, error) {
|
||||
in, err := normalizeBannerWrite(body, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||
row, err := s.Repo.CreateBannerWithAudit(ctx, adminID, in, meta)
|
||||
if repository.BannerCodeConflict(err) {
|
||||
return nil, ErrBannerConflict
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// UpdateBanner validates, updates, audits.
|
||||
func (s *Service) UpdateBanner(ctx context.Context, adminID, id uuid.UUID, body BannerWriteBody) (*repository.BannerRow, error) {
|
||||
in, err := normalizeBannerWrite(body, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||
row, err := s.Repo.UpdateBannerWithAudit(ctx, adminID, id, in, meta)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrBannerNotFound
|
||||
}
|
||||
if repository.BannerCodeConflict(err) {
|
||||
return nil, ErrBannerConflict
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
func normalizeBannerWrite(body BannerWriteBody, requireCode bool) (repository.BannerWriteInput, error) {
|
||||
code := strings.TrimSpace(body.Code)
|
||||
title := strings.TrimSpace(body.Title)
|
||||
placement := strings.TrimSpace(body.Placement)
|
||||
if requireCode && code == "" {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
if code != "" && !bannerCodeRe.MatchString(code) {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
if title == "" || utf8.RuneCountInString(title) > 128 {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
if _, ok := bannerPlacements[placement]; !ok {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
var link *string
|
||||
if body.LinkPath != nil {
|
||||
lp := strings.TrimSpace(*body.LinkPath)
|
||||
if lp == "" {
|
||||
link = nil
|
||||
} else {
|
||||
if strings.Contains(lp, "://") || !bannerPathRe.MatchString(lp) {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
link = &lp
|
||||
}
|
||||
}
|
||||
var img *string
|
||||
if body.ImageURL != nil {
|
||||
u := strings.TrimSpace(*body.ImageURL)
|
||||
if u == "" {
|
||||
img = nil
|
||||
} else if utf8.RuneCountInString(u) > 2000 {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
} else {
|
||||
img = &u
|
||||
}
|
||||
}
|
||||
return repository.BannerWriteInput{
|
||||
Code: code, Title: title, Placement: placement,
|
||||
ImageURL: img, LinkPath: link, SortOrder: body.SortOrder, Active: body.Active,
|
||||
}, nil
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const (
|
||||
PermAIConfigRead = "admin.ai_config.read"
|
||||
PermCrisisRead = "admin.crisis.read"
|
||||
PermCMSRead = "admin.cms.read"
|
||||
PermCMSWrite = "admin.cms.write"
|
||||
PermPrivacyRead = "admin.privacy.read"
|
||||
PermExploreRead = "admin.explore.read"
|
||||
PermGrowthRead = "admin.growth.read"
|
||||
@@ -43,7 +44,7 @@ var knownPermissions = map[string]struct{}{
|
||||
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
||||
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
|
||||
PermAskRead: {}, PermAskTranscriptRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
|
||||
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
|
||||
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermCMSWrite: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -31,6 +31,7 @@ type Service struct {
|
||||
Repo *repository.HomeToolsRepo
|
||||
Tips *repository.HomeDailyTipsRepo
|
||||
Profiles *repository.ProfileRepo
|
||||
CMS *repository.AdminRepo
|
||||
LLM *deepseek.Client
|
||||
}
|
||||
|
||||
@@ -39,6 +40,24 @@ func (s *Service) ListPublic(ctx context.Context) ([]repository.HomeTool, error)
|
||||
return s.Repo.ListEnabled(ctx)
|
||||
}
|
||||
|
||||
// ListPublicBanners returns active banners for placement (default home).
|
||||
func (s *Service) ListPublicBanners(ctx context.Context, placement string) ([]repository.BannerRow, error) {
|
||||
if placement == "" {
|
||||
placement = "home"
|
||||
}
|
||||
if s.CMS == nil {
|
||||
return []repository.BannerRow{}, nil
|
||||
}
|
||||
items, err := s.CMS.ListActiveBanners(ctx, placement)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []repository.BannerRow{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ListAdmin returns all tools.
|
||||
func (s *Service) ListAdmin(ctx context.Context) ([]repository.HomeTool, error) {
|
||||
return s.Repo.ListAll(ctx)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- ECR-041 rollback: revoke cms.write from super_admin
|
||||
|
||||
DELETE FROM admin_role_permissions
|
||||
WHERE code = 'admin.cms.write'
|
||||
AND role_id IN (SELECT id FROM admin_roles WHERE name = 'super_admin');
|
||||
@@ -0,0 +1,7 @@
|
||||
-- ECR-041 OpsCMS Banner write (additive permission)
|
||||
|
||||
INSERT INTO admin_role_permissions(role_id, code)
|
||||
SELECT r.id, 'admin.cms.write'
|
||||
FROM admin_roles r
|
||||
WHERE r.name = 'super_admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
homeFeeds,
|
||||
homeGridRow1,
|
||||
homeGridRow2,
|
||||
mapHomeBannersToFeeds,
|
||||
type HomeFeed,
|
||||
type HomeTool,
|
||||
} from '../lib/homeCatalog'
|
||||
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
|
||||
@@ -62,6 +64,7 @@ export function useHomePage() {
|
||||
gridRow1: [...homeGridRow1] as HomeTool[],
|
||||
gridRow2: [...homeGridRow2] as HomeTool[],
|
||||
})
|
||||
const feeds = ref<HomeFeed[]>([...homeFeeds])
|
||||
|
||||
onMounted(() => {
|
||||
void api
|
||||
@@ -89,6 +92,15 @@ export function useHomePage() {
|
||||
.catch(() => {
|
||||
/* keep static fallback */
|
||||
})
|
||||
void api
|
||||
.getHomeBanners('home')
|
||||
.then((res) => {
|
||||
const mapped = mapHomeBannersToFeeds(res.items || [])
|
||||
if (mapped.length) feeds.value = mapped
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep static homeFeeds */
|
||||
})
|
||||
})
|
||||
|
||||
function goPlus(kind: 'inviteFill' | 'add' | 'synastry') {
|
||||
@@ -115,7 +127,7 @@ export function useHomePage() {
|
||||
tips,
|
||||
tipsLoading,
|
||||
...toRefs(grid),
|
||||
feeds: homeFeeds,
|
||||
feeds,
|
||||
goPlus,
|
||||
trackGrid,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { homeFeeds, homeGridRow1, homeGridRow2 } from '../lib/homeCatalog'
|
||||
import { homeFeeds, homeGridRow1, homeGridRow2, mapHomeBannersToFeeds } from '../lib/homeCatalog'
|
||||
|
||||
describe('homeCatalog', () => {
|
||||
it('keeps 12-grid rows', () => {
|
||||
@@ -14,4 +14,14 @@ describe('homeCatalog', () => {
|
||||
expect(f.title.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('maps banners to feeds and drops external links', () => {
|
||||
const mapped = mapHomeBannersToFeeds([
|
||||
{ title: '会员', link_path: '/membership', sort_order: 2 },
|
||||
{ title: '坏链', link_path: 'https://x.example', sort_order: 1 },
|
||||
])
|
||||
expect(mapped).toHaveLength(1)
|
||||
expect(mapped[0].to).toBe('/membership')
|
||||
expect(mapped[0].title).toBe('会员')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,3 +90,29 @@ export const homeFeeds: HomeFeed[] = [
|
||||
tone: 'fc-e',
|
||||
},
|
||||
]
|
||||
|
||||
const FEED_TONES = ['fc-e', 'fc-a', 'fc-b', 'fc-c', 'fc-a', 'fc-e'] as const
|
||||
|
||||
/** Project active home banners into feed cards (ECR-041). */
|
||||
export function mapHomeBannersToFeeds(
|
||||
items: Array<{ title: string; link_path?: string | null; sort_order?: number }>,
|
||||
): HomeFeed[] {
|
||||
const tones = FEED_TONES
|
||||
const out: HomeFeed[] = []
|
||||
const sorted = [...items].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const it = sorted[i]
|
||||
const to = (it.link_path || '').trim()
|
||||
if (!to.startsWith('/') || to.includes('://')) continue
|
||||
out.push({
|
||||
to,
|
||||
icon: 'portrait',
|
||||
title: it.title,
|
||||
meta: '运营推荐',
|
||||
stat: '推荐',
|
||||
tone: tones[i % tones.length],
|
||||
tag: '荐',
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Backend Design: ECR-041 Banner Write
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | BD-2026-041 |
|
||||
| Status | **Approved** |
|
||||
| Coding | **Authorized · Implemented · Closed with ECR-041** |
|
||||
| Level | L2 |
|
||||
| Migration | `000051_ops_banner_write` |
|
||||
|
||||
## Backend Change Boundary
|
||||
|
||||
```text
|
||||
Domain: Banner (write + C-end read)
|
||||
App: AdminHandler / HomeHandler → admin.Service / home → AdminRepo
|
||||
API:
|
||||
POST /api/v1/admin/cms/banners
|
||||
PUT /api/v1/admin/cms/banners/{id}
|
||||
GET /api/v1/home/banners?placement=home
|
||||
Permission: admin.cms.write (additive); keep admin.cms.read
|
||||
Audit: admin_audit_logs on POST/PUT
|
||||
Table: reuse ops_banners (ECR-024)
|
||||
```
|
||||
|
||||
## Out of boundary
|
||||
|
||||
FeedSlot 写 · ScheduledPublication 写 · UGC · 真支付 · soft-delete · Crisis/Handoff/ContentSafety 写 · 新 RBAC 子系统
|
||||
|
||||
## Rollback
|
||||
|
||||
1. migration down(撤权限种子)
|
||||
2. 移除写路由与 C 端读取路由
|
||||
3. H5 回退静态 `homeFeeds`
|
||||
|
||||
## Refs
|
||||
|
||||
Spec `ops-banner-write.md` · ECR-041 · WRITE_WAVE_AUTHORIZATION · predecessor BD-2026-024
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
|
||||
## 2026-08-13
|
||||
|
||||
- **仓侧治理最小补丁(非 ESS 内核):** `scripts/repo-governance-check.py`(migration 号唯一 · ECR identity 唯一);TRACEABILITY 增加 **Next ECR=`ECR-041`** · **Max Migration=`000050`**;`PROJECT_RULES` 写入预占/同号双义禁止/migration 分配流程;历史分叉 H1 固定为 `ECR-012-star` / `014-plan` / `015-code` / `016-insight`
|
||||
- **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`**
|
||||
- **Migration 撞号修复:** `000015`–`000023` 双文件冲突已重编号为唯一序列至 `000050`;说明见 `docs/MIGRATION_RENUMBER.md`
|
||||
- **Merge:** `merge/ops-local-into-main` — 合并本地 Ops 目录扩展(ECR-013A…040)与远程 main(ECR-009…016)
|
||||
注意:分叉期间 ECR-012/014/015/016 编号在两边含义不同,以 TRACEABILITY 双列说明为准
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# CODE_REVIEW — ECR-041
|
||||
|
||||
**Verdict:** Approve
|
||||
**Scope:** Banner write only · FeedSlot untouched
|
||||
|
||||
## Checks
|
||||
|
||||
- [x] Spec / ECR / BD boundary respected
|
||||
- [x] No soft-delete · no payment · no UGC
|
||||
- [x] Permission additive `admin.cms.write`
|
||||
- [x] Audit on POST/PUT
|
||||
- [x] C-end + static fallback
|
||||
- [x] Integration AC green
|
||||
|
||||
## Residual
|
||||
|
||||
- Banner 投影字段(icon/tone)用缺省;完整 Feeds 槽位属后续 FeedSlot ECR
|
||||
@@ -0,0 +1,27 @@
|
||||
ecr: ECR-041
|
||||
capability: OpsCMS
|
||||
bounded_context: Ops_CMS_NoUGC
|
||||
parent: WAVE0-FROZEN
|
||||
write_wave: docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md
|
||||
predecessor: ECR-024
|
||||
change:
|
||||
type: additive
|
||||
breaking_change: false
|
||||
migration_required: true
|
||||
compatibility_notes: >
|
||||
Banner write + C-end home banners. Forbidden: FeedSlot write / UGC / real payment / soft-delete.
|
||||
|
||||
apis:
|
||||
- method: POST
|
||||
path: /api/v1/admin/cms/banners
|
||||
change: added
|
||||
- method: PUT
|
||||
path: /api/v1/admin/cms/banners/{id}
|
||||
change: added
|
||||
- method: GET
|
||||
path: /api/v1/home/banners
|
||||
change: added
|
||||
perms:
|
||||
- code: admin.cms.write
|
||||
change: added
|
||||
migration: 000051_ops_banner_write
|
||||
@@ -0,0 +1,54 @@
|
||||
# ECR-041
|
||||
|
||||
**Title:** OpsCMS · Banner 薄写面(Write-Wave 首刀)
|
||||
**Status:** **Closed**(2026-08-13 · Write-Wave Continuous Loop)
|
||||
**Date:** 2026-08-13
|
||||
**Change Level:** L2
|
||||
**Parent:** WAVE0-FROZEN · **Write-Wave:** `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md`
|
||||
**Predecessor:** ECR-024 Closed
|
||||
**Coding:** Done · Migration `000051_ops_banner_write`
|
||||
|
||||
## Change
|
||||
|
||||
1. Active Spec:`.ai/product/feature-spec/ops-banner-write.md`
|
||||
2. Admin:`POST/PUT /admin/cms/banners*` + 权限 `admin.cms.write` + 审计
|
||||
3. C 端:`GET /home/banners`;user-h5 首页推荐区 API 优先、静态 `homeFeeds` 回退
|
||||
4. admin-h5 CMS Banner 编辑(**不**做 FeedSlot 写)
|
||||
5. OpenAPI · contract_diff · migration(编码时锁 `Max Migration+1`)
|
||||
|
||||
## Motivation
|
||||
|
||||
只读 Banner 目录无法运营;首页推荐区仍静态。Write-Wave 首刀用最低风险写面验证闭环;支付仍最后。
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed
|
||||
|
||||
- 上列 Spec / Banner 写读闭环 / 加法权限码 / 审计 / 首页投影
|
||||
|
||||
### Forbidden(Non-goals)
|
||||
|
||||
- **FeedSlot 写**(后续独立 ECR)· ScheduledPublication 写
|
||||
- UGC · soft-delete · 真支付
|
||||
- Crisis / Handoff / ContentSafety 高风险写
|
||||
- 合并 Banner+FeedSlot · 扩大为本 ECR 多 Capability
|
||||
- 新权限模型(子系统)· 改 ESS/Loop/Adapter
|
||||
|
||||
## Acceptance
|
||||
|
||||
见 Spec §8 AC-F/S/A/C/O;实现后 TEST_REPORT 映射。
|
||||
|
||||
## Trace
|
||||
|
||||
Spec `ops-banner-write` · BD-2026-041 · PRODUCT_SPEC/ECR-041 · CONTRACT_DIFF(实现时)· WRITE_WAVE_AUTHORIZATION
|
||||
|
||||
## Coding gate
|
||||
|
||||
```text
|
||||
repo-governance-check PASS
|
||||
→ ess-validate design/implement as required
|
||||
→ BD Approved
|
||||
→ THEN Continuous Loop may start coding
|
||||
```
|
||||
|
||||
本文件交付时 **禁止改 `apps/` / `packages/`**。
|
||||
@@ -0,0 +1,24 @@
|
||||
# HANDOFF — Architect → Engineer · ECR-041
|
||||
|
||||
**Date:** 2026-08-13
|
||||
**ECR:** ECR-041 Banner 薄写面
|
||||
**Auth:** `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md` · Human Coding Start
|
||||
**Spec:** `.ai/product/feature-spec/ops-banner-write.md`
|
||||
**BD:** `docs/BACKEND_DESIGN/BD-2026-041-banner-write.md`
|
||||
|
||||
## Do
|
||||
|
||||
1. Migration `000051_ops_banner_write` — grant `admin.cms.write` to `super_admin`
|
||||
2. Admin `POST/PUT /admin/cms/banners*` + audit;C 端 `GET /home/banners`
|
||||
3. user-h5 首页推荐区投影;空/失败回退 `homeFeeds`
|
||||
4. admin-h5 CMS Banner 编辑(FeedSlot **只读**)
|
||||
5. OpenAPI · CONTRACT_DIFF · integration AC
|
||||
6. `repo-governance-check` PASS · TEST_REPORT · Close
|
||||
|
||||
## Don't
|
||||
|
||||
FeedSlot 写 · soft-delete · 真支付 · UGC · Crisis/Handoff/ContentSafety · 扩 scope
|
||||
|
||||
## Done when
|
||||
|
||||
Spec §8 AC 有测试证据;ECR Status Closed;Max Migration=`000051`
|
||||
@@ -0,0 +1,4 @@
|
||||
# HANDOFF — Engineer → Reviewer · ECR-041
|
||||
|
||||
TestOpsCMSBannerWrite PASS · homeCatalog PASS · repo-governance PASS.
|
||||
Ready for Closed. FeedSlot 写未实现(按边界)。
|
||||
@@ -0,0 +1,5 @@
|
||||
# PRODUCT_SPEC — ECR-041
|
||||
|
||||
对齐 `.ai/product/feature-spec/ops-banner-write.md` · Human Approved scope · L2 · Banner **写面**(非 FeedSlot)。
|
||||
|
||||
Write-Wave: `docs/WAVE0/WRITE_WAVE_AUTHORIZATION.md` · Coding NOT STARTED.
|
||||
@@ -53,17 +53,18 @@
|
||||
- **本仓:** `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` 顶部锚点为准(仓侧治理,非 Loop)
|
||||
- **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**;下一刀候选评估(非自动无限)
|
||||
- EXP: (无)
|
||||
- STATE: `docs/STATE/`(ECR-016 main · 以及 Ops 目录 Closed 条目)
|
||||
- STATE: `docs/STATE/`(含 ECR-041)
|
||||
- TRACEABILITY: `docs/TRACEABILITY.md` · 门禁 `scripts/repo-governance-check.py`
|
||||
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
|
||||
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)· `.ai/product/p2-status.md`(**P2 Complete**)
|
||||
- Active Spec: `account-auth` · `explore-test` · `home` · `star-profile` · `life-rhythm` · `input-compliance` · `ops-system` · `profile`
|
||||
- Active Spec: `account-auth` · `explore-test` · `home` · `star-profile` · `life-rhythm` · `input-compliance` · `ops-system` · `profile` · `ops-banner-write`
|
||||
|
||||
## WIP(尚未单独 ECR)
|
||||
|
||||
(无显著未挂 ECR 的后端 WIP;工作区其它 FE chrome 属既有 Closed ECR 或 L0 UI)
|
||||
(无;FeedSlot 写面须新 ECR,不在 041)
|
||||
|
||||
## Pointers
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# STATE — ECR-041
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | **Closed** |
|
||||
| Spec | ops-banner-write |
|
||||
| BD | BD-2026-041 |
|
||||
| Migration | 000051_ops_banner_write |
|
||||
| Write-Wave | Active · next candidate FeedSlot(另 ECR) |
|
||||
| Closed | 2026-08-13 |
|
||||
@@ -0,0 +1,35 @@
|
||||
# TEST_REPORT — ECR-041 Banner Write
|
||||
|
||||
**Date:** 2026-08-13
|
||||
**Commit:** (pending commit)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cd apps/api && go test ./internal/integration/ -count=1 -run 'TestOpsCMSBanner'
|
||||
npm run test -w @yuxingu/user-h5 -- src/lib/homeCatalog.spec.ts
|
||||
python3 scripts/repo-governance-check.py
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| TestOpsCMSBanners (read) | PASS |
|
||||
| TestOpsCMSBannerWrite | PASS |
|
||||
| homeCatalog vitest (3) | PASS |
|
||||
| repo-governance-check | PASS · max migration 000051 |
|
||||
|
||||
## AC map
|
||||
|
||||
| AC | Evidence |
|
||||
|----|----------|
|
||||
| AC-F-01 | Write test create → list/get |
|
||||
| AC-F-02 | PUT active=false → C-end excludes |
|
||||
| AC-F-03 | GET /home/banners contains active home |
|
||||
| AC-F-04 | duplicate code → 409 |
|
||||
| AC-F-05 | external link_path → 400 |
|
||||
| AC-S-01/02 | 401 unauth; read-only role POST → 403 |
|
||||
| AC-A-01 | audit rows create+update |
|
||||
| AC-C-01/02 | H5 mapHomeBannersToFeeds + static fallback |
|
||||
| AC-O-01 | no FeedSlot write routes added |
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
| Anchor | Value | Rule |
|
||||
|--------|-------|------|
|
||||
| **Next ECR** | `ECR-041` | 开新 ECR 前占用此号;写入 `docs/ECR/` 后把本行改为下一空闲裸号(或 `python scripts/repo-governance-check.py --print-anchors`) |
|
||||
| **Max Migration** | `000050` | 新建 migration 必须读仓内实际 max 后 +1;禁止凭记忆;合入前 `repo-governance-check` PASS |
|
||||
| **Next ECR** | `ECR-042` | 下一空闲裸号(候选:FeedSlot 写面,须符合 Write-Wave);或 `--print-anchors` |
|
||||
| **Max Migration** | `000051` | 新建 migration 必须读仓内实际 max 后 +1;禁止凭记忆;合入前 `repo-governance-check` PASS |
|
||||
|
||||
- ECR identity **全局唯一**(含已 Closed);禁止同号双义。分叉只用 `ECR-NNN-suffix` / `ECR-NNNA`。
|
||||
- Migration **6 位版本号唯一**。撞号修复见 `docs/MIGRATION_RENUMBER.md`。
|
||||
@@ -64,5 +64,7 @@
|
||||
| ECR-038 | GrowthInsights · ReportTemplate | **Closed** | Spec ops-report-template.md · BD-2026-038 |
|
||||
| ECR-039 | GrowthInsights · FunnelDefinition | **Closed** | Spec ops-funnel-definition.md · BD-2026-039 |
|
||||
| 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 |
|
||||
|
||||
> **Migration:** 合并后 `000015`–`000023` 曾撞号,已重编号至 `000050`。以 `apps/api/migrations/` 与 `docs/MIGRATION_RENUMBER.md` 为准(文档中旧号引用可能滞后)。
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Write-Wave Authorization — Ops 写面加深(Human)
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Date | 2026-08-13 |
|
||||
| Authorizer | Human |
|
||||
| Mode | **Continuous Loop · Write-Wave only** |
|
||||
| First slice | **ECR-041 · OpsCMS · Banner 薄写面** |
|
||||
| Payment | **最后**(本波次禁止) |
|
||||
| Parent freeze | WAVE0-FROZEN · ESS 0.2.0-intake FROZEN · Loop/Adapter 不改 |
|
||||
|
||||
## Intent
|
||||
|
||||
在只读目录队列(ECR-024…040)**STOP** 之后,开启 **Ops 写面加深** 波次。
|
||||
首刀验证完整闭环:
|
||||
|
||||
```text
|
||||
Write → Read → C 端生效 → 权限 → 审计 / 可回滚
|
||||
```
|
||||
|
||||
## Continuous Loop(范围内)
|
||||
|
||||
在本授权有效且当前 ECR 属于 Write-Wave 边界时,允许 Agent:
|
||||
|
||||
1. Approve ECR + BD(契约齐备 · `ess-validate` PASS · `repo-governance-check` PASS)
|
||||
2. Coding Start Authorization
|
||||
3. Reviewer Approve → Closed(TEST_REPORT 绿 · AC 映射齐)
|
||||
4. `git commit`(**不 push**,除非另授权)
|
||||
5. **仅当下一刀仍符合下方 In 边界** 时,可起草下一候选 ECR;**不得**默认自动扩大波次范围
|
||||
|
||||
## In(Write-Wave)
|
||||
|
||||
| 允许 | 说明 |
|
||||
|------|------|
|
||||
| ECR-041 Banner 写 | 唯一当前 Active 切片;不顺带 FeedSlot |
|
||||
| 后续候选(须单独 ECR) | 同属 OpsCMS / ExploreConfig / AICoreConfig **低中风险写**,且不触碰 STOP 表 |
|
||||
| 既有 RBAC 上 **加法权限码** | 如 `admin.cms.write`(不发明新权限体系) |
|
||||
| 审计 | `admin_audit_logs` |
|
||||
| 回滚 | migration down · 路由/UI 可撤 · C 端静态回退 |
|
||||
|
||||
## Out / STOP(不得自动开新 ECR · 不得扩 ECR-041)
|
||||
|
||||
任一条触发即 **STOP**,回到 Human:
|
||||
|
||||
- 真支付 · UGC · soft-delete
|
||||
- Crisis / Handoff 写 · Content Safety **高风险写**
|
||||
- 改变 Governance Boundary / WAVE0 boundary-rules 写所有权
|
||||
- **新增权限模型**(新 RBAC 子系统;≠ 加法权限码)
|
||||
- 原架构假设不成立
|
||||
- 扩大 ECR-041 范围 · **合并 Banner + FeedSlot**
|
||||
- 任何超出本文件 In 表的 Capability
|
||||
|
||||
## Relation to prior Loop auth
|
||||
|
||||
| Doc | Status |
|
||||
|-----|--------|
|
||||
| `docs/WAVE0/LOOP_AUTHORIZATION.md` | 只读队列 **STOP after ECR-040** — **仍有效**;不复活只读连跑 |
|
||||
| **本文件** | 写面波次权威;与上者并列,互不吞并 |
|
||||
|
||||
## First slice lock
|
||||
|
||||
- **ECR-041** = Banner only
|
||||
- FeedSlot = 后续独立 ECR
|
||||
- SystemPrompt ≠ 首刀
|
||||
- 编码前:Active Spec · ECR · BD · contract_diff;**本回合仅交付授权 + Spec,不写码**
|
||||
|
||||
## Repo anchors
|
||||
|
||||
- Next ECR / Max Migration:`docs/TRACEABILITY.md`
|
||||
- Gate:`python scripts/repo-governance-check.py`
|
||||
@@ -278,6 +278,19 @@ export function createClient(opts: CreateClientOptions) {
|
||||
enabled?: boolean
|
||||
}>
|
||||
}>('/api/v1/home/tools'),
|
||||
getHomeBanners: (placement = 'home') =>
|
||||
call<{
|
||||
items: Array<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
placement: string
|
||||
image_url?: string
|
||||
link_path?: string
|
||||
sort_order: number
|
||||
active: boolean
|
||||
}>
|
||||
}>(`/api/v1/home/banners?placement=${encodeURIComponent(placement)}`),
|
||||
getHomeDailyTips: () => call<HomeDailyTips>('/api/v1/home/daily-tips'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,6 +778,21 @@ paths:
|
||||
description: Unauthorized
|
||||
'403':
|
||||
description: Forbidden
|
||||
post:
|
||||
tags: [admin]
|
||||
summary: Create Banner
|
||||
description: Requires admin.cms.write · ECR-041
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'400':
|
||||
description: Invalid
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'403':
|
||||
description: Forbidden
|
||||
'409':
|
||||
description: Code conflict
|
||||
|
||||
/api/v1/admin/cms/banners/{id}:
|
||||
get:
|
||||
@@ -793,6 +808,26 @@ paths:
|
||||
description: OK
|
||||
'404':
|
||||
description: Not found
|
||||
put:
|
||||
tags: [admin]
|
||||
summary: Update Banner
|
||||
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: Code conflict
|
||||
|
||||
/api/v1/admin/cms/feed-slots:
|
||||
get:
|
||||
@@ -1448,6 +1483,18 @@ paths:
|
||||
'200':
|
||||
description: OK
|
||||
|
||||
/api/v1/home/banners:
|
||||
get:
|
||||
tags: [system]
|
||||
summary: Homepage active banners (ECR-041)
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user