diff --git a/docs/ops/2026-02-16__admin__decoration-module-repaired-full.md b/docs/ops/2026-02-16__admin__decoration-module-repaired-full.md new file mode 100644 index 00000000..870ada5f --- /dev/null +++ b/docs/ops/2026-02-16__admin__decoration-module-repaired-full.md @@ -0,0 +1,51 @@ +# 装修模块 (Decoration) 全量集成与数据库构建报告 + +## 摘要 +本次对 Admin 侧装修模块(Decoration Module)进行了深度的端到端修复。补齐了该模块完全缺失的数据库表结构(用于存储 DIY 布局 JSON)、行级安全策略(RLS)以及 4 个管理端 RPC 接口。同时重构了前端 3 个核心页面,实现了从 Mock 数据到真实数据库持久化的闭环。 + +## 动机 +装修模块允许管理员自定义商城的视觉表现(首页、专题页、个人中心风格)。此前该功能仅有 UI 静态模拟,无法保存任何配置。为了支撑个性化商城运营,必须补齐底层存储架构,并按照项目规范落地管理端接口。 + +## 影响范围 +- **模块**:后台管理系统 - 装修模块 +- **页面**:首页装修、商品分类装修、个人中心装修 +- **接口**:新增 4 个 `rpc_admin_...` 接口 +- **权限**:增加了 DIY 页面表的 RLS 策略,消费者端仅允许读取已启用配置 + +## 变更清单 + +### 数据库 SQL +- **新增 Schema** (docs/sql/10_schema/decoration/): + - `ak_diy_pages_v1.sql` (核心配置表) +- **新增 RLS** (docs/sql/20_rls/decoration/): + - `ml_decoration_rls_v1.sql` +- **新增 RPC** (docs/sql/30_rpc/decoration/): + - `rpc_admin_get_diy_page_list_v1.sql` + - `rpc_admin_save_diy_page_v1.sql` + - `rpc_admin_delete_diy_page_v1.sql` + - `rpc_admin_set_home_page_v1.sql` + +### 前端代码 +- **新增服务层**:`services/admin/decorationService.uts` (封装全量 DIY 接口) +- **重构页面**: + - `pages/mall/admin/decoration/home.uvue` (接入分页列表、删除及设为首页逻辑) + - `pages/mall/admin/decoration/user.uvue` (接入样式持久化存取) + - `pages/mall/admin/decoration/category.uvue` (接入样式持久化存取) + +## 兼容性与风险 +- **配置格式**:`config` 字段采用 JSONB 存储,前端 UTS 解析时需确保类型强制转换正确(使用 `UTSJSONObject`)。 +- **首页约束**:数据库侧实现了原子切换逻辑,确保同类型页面(如 `home`)全局仅有一个生效项。 + +## 回滚方案 +1. **数据库**:依次 DROP `ak_diy_pages` 表及相关 4 个 RPC 函数。 +2. **代码**:使用 `git checkout` 恢复重构的 3 个 `.uvue` 页面,并删除 `decorationService.uts`。 + +## 验证方式 +1. **功能验证**: + - 首页切换:在“首页装修”列表中将另一个模板设为首页,确认移动端展示内容实时变更。 + - 样式保存:在“个人中心装修”切换样式并点击保存,刷新页面后确认选中状态保持一致。 +2. **安全验证**:确认非 admin 角色无法通过接口修改 DIY 配置。 + +## 关联规范 +- 遵循 `AGENT_PROJECT_SPEC.md` 规范。 +- 遵循统一的 RPC 入口鉴权(admin 角色)。 diff --git a/docs/sql/10_schema/decoration/ak_diy_pages_v1.sql b/docs/sql/10_schema/decoration/ak_diy_pages_v1.sql new file mode 100644 index 00000000..e399094b --- /dev/null +++ b/docs/sql/10_schema/decoration/ak_diy_pages_v1.sql @@ -0,0 +1,32 @@ +-- ===================================================================================== +-- Schema: 装修模块 - DIY 页面配置表 +-- 位置:docs/sql/10_schema/decoration/ak_diy_pages_v1.sql +-- 对象类型:TABLE +-- 版本:v1 +-- 说明:存储首页、专题页及个人中心的 DIY 布局 JSON 配置 +-- ===================================================================================== + +CREATE TABLE IF NOT EXISTS public.ak_diy_pages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + type TEXT NOT NULL, -- home: 首页, topic: 专题页, user: 个人中心 + + config JSONB NOT NULL DEFAULT '{}'::jsonb, -- 核心布局配置 (组件列表及参数) + + is_home BOOLEAN NOT NULL DEFAULT FALSE, -- 是否为生效首页 + is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 是否启用 + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES public.ak_users(id), + updated_by UUID REFERENCES public.ak_users(id) +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_diy_pages_type ON public.ak_diy_pages (type); +CREATE INDEX IF NOT EXISTS idx_diy_pages_is_home ON public.ak_diy_pages (is_home) WHERE is_home = TRUE; + +-- 注释 +COMMENT ON TABLE public.ak_diy_pages IS 'DIY 页面装修配置表'; +COMMENT ON COLUMN public.ak_diy_pages.type IS '页面类型: home(首页), topic(专题), user(个人中心)'; +COMMENT ON COLUMN public.ak_diy_pages.config IS 'DIY 布局配置 JSON'; diff --git a/docs/sql/20_rls/decoration/ml_decoration_rls_v1.sql b/docs/sql/20_rls/decoration/ml_decoration_rls_v1.sql new file mode 100644 index 00000000..1c6ac274 --- /dev/null +++ b/docs/sql/20_rls/decoration/ml_decoration_rls_v1.sql @@ -0,0 +1,18 @@ +-- ===================================================================================== +-- RLS: 装修模块 - DIY 页面安全策略 +-- 位置:docs/sql/20_rls/decoration/ml_decoration_rls_v1.sql +-- 对象类型:RLS 策略 +-- 版本:v1 +-- 说明:消费者端公开只读已发布的页面;管理端通过 SECURITY DEFINER RPC 进行管理 +-- ===================================================================================== + +-- 1. 启用 RLS +ALTER TABLE public.ak_diy_pages ENABLE ROW LEVEL SECURITY; + +-- 2. 消费者端策略:允许匿名和登录用户读取已启用的页面 +DROP POLICY IF EXISTS diy_pages_select_active ON public.ak_diy_pages; +CREATE POLICY diy_pages_select_active ON public.ak_diy_pages +FOR SELECT TO anon, authenticated +USING (is_active = true); + +-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行,此处不再额外开放直接表操作 diff --git a/docs/sql/30_rpc/decoration/rpc_admin_delete_diy_page_v1.sql b/docs/sql/30_rpc/decoration/rpc_admin_delete_diy_page_v1.sql new file mode 100644 index 00000000..f40d2d58 --- /dev/null +++ b/docs/sql/30_rpc/decoration/rpc_admin_delete_diy_page_v1.sql @@ -0,0 +1,40 @@ +-- RPC: rpc_admin_delete_diy_page +-- 管理端删除 DIY 页面配置 + +CREATE OR REPLACE FUNCTION public.rpc_admin_delete_diy_page( + p_id uuid +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_ok boolean; +BEGIN + -- 1. 权限检查 (仅管理员) + IF NOT EXISTS ( + SELECT 1 FROM public.ak_users u + WHERE u.id = auth.uid() AND u.role = 'admin' + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + -- 2. 执行删除 (不允许删除当前生效的首页) + IF EXISTS ( + SELECT 1 FROM public.ak_diy_pages + WHERE id = p_id AND is_home = true + ) THEN + RAISE EXCEPTION 'cannot delete the active home page'; + END IF; + + DELETE FROM public.ak_diy_pages WHERE id = p_id; + + GET DIAGNOSTICS v_ok = ROW_COUNT; + RETURN v_ok; +END; +$$; + +-- 授权 +REVOKE ALL ON FUNCTION public.rpc_admin_delete_diy_page(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_diy_page(uuid) TO authenticated; diff --git a/docs/sql/30_rpc/decoration/rpc_admin_get_diy_page_list_v1.sql b/docs/sql/30_rpc/decoration/rpc_admin_get_diy_page_list_v1.sql new file mode 100644 index 00000000..64c2b549 --- /dev/null +++ b/docs/sql/30_rpc/decoration/rpc_admin_get_diy_page_list_v1.sql @@ -0,0 +1,60 @@ +-- RPC: rpc_admin_get_diy_page_list +-- 管理端获取 DIY 页面分页列表 +-- 支持按名称搜索和按类型筛选 + +CREATE OR REPLACE FUNCTION public.rpc_admin_get_diy_page_list( + p_search text DEFAULT NULL, + p_type text DEFAULT NULL, + p_page integer DEFAULT 1, + p_page_size integer DEFAULT 20 +) +RETURNS JSONB +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_page integer := GREATEST(1, COALESCE(p_page, 1)); + v_page_size integer := LEAST(200, GREATEST(1, COALESCE(p_page_size, 20))); + v_offset integer := (v_page - 1) * v_page_size; + v_total bigint; + v_items jsonb; +BEGIN + -- 1. 权限检查 (仅管理员或分析员) + IF NOT EXISTS ( + SELECT 1 FROM public.ak_users u + WHERE u.id = auth.uid() AND u.role IN ('admin', 'analytics') + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + -- 2. 获取总数 + SELECT COUNT(*) INTO v_total + FROM public.ak_diy_pages + WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%') + AND (p_type IS NULL OR type = p_type); + + -- 3. 获取明细 + SELECT jsonb_agg(t) INTO v_items + FROM ( + SELECT + id, name, type, is_home, is_active, + created_at, updated_at + FROM public.ak_diy_pages + WHERE (p_search IS NULL OR p_search = '' OR name ILIKE '%' || p_search || '%') + AND (p_type IS NULL OR type = p_type) + ORDER BY created_at DESC + LIMIT v_page_size OFFSET v_offset + ) t; + + -- 4. 返回 JSON 结果 + RETURN jsonb_build_object( + 'total', v_total, + 'items', COALESCE(v_items, '[]'::jsonb) + ); +END; +$$; + +-- 授权 +REVOKE ALL ON FUNCTION public.rpc_admin_get_diy_page_list(text, text, integer, integer) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_get_diy_page_list(text, text, integer, integer) TO authenticated; diff --git a/docs/sql/30_rpc/decoration/rpc_admin_save_diy_page_v1.sql b/docs/sql/30_rpc/decoration/rpc_admin_save_diy_page_v1.sql new file mode 100644 index 00000000..118cb12a --- /dev/null +++ b/docs/sql/30_rpc/decoration/rpc_admin_save_diy_page_v1.sql @@ -0,0 +1,57 @@ +-- RPC: rpc_admin_save_diy_page +-- 管理端新增或更新 DIY 页面配置 + +CREATE OR REPLACE FUNCTION public.rpc_admin_save_diy_page( + p_id uuid DEFAULT NULL, + p_name text DEFAULT NULL, + p_type text DEFAULT NULL, + p_config jsonb DEFAULT '{}'::jsonb, + p_is_active boolean DEFAULT true +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_id uuid; +BEGIN + -- 1. 权限检查 (仅管理员) + IF NOT EXISTS ( + SELECT 1 FROM public.ak_users u + WHERE u.id = auth.uid() AND u.role = 'admin' + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + -- 2. 新增或更新 + IF p_id IS NULL THEN + INSERT INTO public.ak_diy_pages ( + name, type, config, is_active, updated_by, created_by + ) VALUES ( + p_name, p_type, p_config, p_is_active, auth.uid(), auth.uid() + ) RETURNING id INTO v_id; + ELSE + UPDATE public.ak_diy_pages + SET + name = COALESCE(p_name, name), + type = COALESCE(p_type, type), + config = COALESCE(p_config, config), + is_active = COALESCE(p_is_active, is_active), + updated_at = now(), + updated_by = auth.uid() + WHERE id = p_id + RETURNING id INTO v_id; + + IF v_id IS NULL THEN + RAISE EXCEPTION 'page not found'; + END IF; + END IF; + + RETURN v_id; +END; +$$; + +-- 授权 +REVOKE ALL ON FUNCTION public.rpc_admin_save_diy_page(uuid, text, text, jsonb, boolean) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_save_diy_page(uuid, text, text, jsonb, boolean) TO authenticated; diff --git a/docs/sql/30_rpc/decoration/rpc_admin_set_home_page_v1.sql b/docs/sql/30_rpc/decoration/rpc_admin_set_home_page_v1.sql new file mode 100644 index 00000000..92104a23 --- /dev/null +++ b/docs/sql/30_rpc/decoration/rpc_admin_set_home_page_v1.sql @@ -0,0 +1,40 @@ +-- RPC: rpc_admin_set_home_page +-- 管理端设置生效首页 +-- 逻辑:先取消所有同类型页面的 is_home 状态,再设置目标页面为 is_home + +CREATE OR REPLACE FUNCTION public.rpc_admin_set_home_page( + p_id uuid +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_type text; +BEGIN + -- 1. 权限检查 (仅管理员) + IF NOT EXISTS ( + SELECT 1 FROM public.ak_users u + WHERE u.id = auth.uid() AND u.role = 'admin' + ) THEN + RAISE EXCEPTION 'permission denied'; + END IF; + + -- 2. 获取目标页面类型 + SELECT type INTO v_type FROM public.ak_diy_pages WHERE id = p_id; + IF v_type IS NULL THEN + RAISE EXCEPTION 'page not found'; + END IF; + + -- 3. 原子切换:同一类型的页面只能有一个 is_home + UPDATE public.ak_diy_pages SET is_home = false WHERE type = v_type; + UPDATE public.ak_diy_pages SET is_home = true WHERE id = p_id; + + RETURN true; +END; +$$; + +-- 授权 +REVOKE ALL ON FUNCTION public.rpc_admin_set_home_page(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_set_home_page(uuid) TO authenticated; diff --git a/pages/mall/admin/decoration/category.uvue b/pages/mall/admin/decoration/category.uvue index 89d7a058..d62af6d6 100644 --- a/pages/mall/admin/decoration/category.uvue +++ b/pages/mall/admin/decoration/category.uvue @@ -3,11 +3,11 @@ - 商品分类 + 商品分类装修 - 保存 + {{ isSaving ? '保存中...' : '保存配置' }} 重置 @@ -17,7 +17,10 @@ - + + 加载配置中... + + @@ -47,22 +50,12 @@ - - 肉制品 - - - 🥩 - {{ ['大肉块','五花肉','鸡腿'][i-1] }} - - - 🏠 📂 - 🛒 👤 @@ -84,42 +77,25 @@ 🔍 点击搜索商品信息 - - 水果 - 全部 - 热带水果 - 西瓜葡萄 - - 乳品 - 午间零食 - 新鲜蔬菜 - 美妆护肤 - 宠物用品 - 户外玩具 + 分类{{n}} - Haier/海尔 BCD-216STPT 时尚静音冰箱 三门出门租家用小型电冰箱 + 精选爆款商品标题示例内容展示 - ¥999.00 - 已售 92 + ¥99.00 立即购买 - 🛒7 - - ¥999.00 - 去结算 - 样式2 @@ -140,41 +116,22 @@ 搜索商品 - - 水果 - 时时生鲜 - 休闲零食 - 坚果蜜饯 - - 乳品 - 午间零食 - 新鲜蔬菜 - 特惠专区 - 大闸蟹 - 精选礼盒 + 分类{{n}} - 【橙中爱马仕】果际新骑士晚季甜橙10个单装 + 优质精选商品名称展示示例 ¥25.99 - - 🛒 - - - 🛒7 - ¥999.00 - 去结算 - 样式3 @@ -186,13 +143,50 @@ diff --git a/pages/mall/admin/decoration/home.uvue b/pages/mall/admin/decoration/home.uvue index cb666372..96945002 100644 --- a/pages/mall/admin/decoration/home.uvue +++ b/pages/mall/admin/decoration/home.uvue @@ -1,7 +1,7 @@ - diff --git a/pages/mall/admin/decoration/user.uvue b/pages/mall/admin/decoration/user.uvue index 97eaacd3..ac8ebdf1 100644 --- a/pages/mall/admin/decoration/user.uvue +++ b/pages/mall/admin/decoration/user.uvue @@ -3,18 +3,21 @@ - 个人中心 + 个人中心装修 - 保存 + {{ isSaving ? '保存中...' : '保存配置' }} - + + 配置加载中... + + @@ -27,37 +30,37 @@ - 🔔6 + 🔔 ⚙️ - 0.00 + 88.00 我的余额 - 65749 + 1200 当前积分 - 25 + 5 优惠券 - + 👑 - 会员到期 2022-12-31 + 尊贵会员服务 立即续费 > @@ -66,13 +69,12 @@ - + 👑 会员可享多项权益 - 会员剩余360天 @@ -90,43 +92,22 @@ - 🔔6 - ⚙️ + 🔔 - 0.00 - 我的余额 + 88.00 + 余额 - 65749 - 当前积分 - - - 25 - 优惠券 - - - - - - - - - 👑 - 开通会员VIP - - - 会员可享多项权益 > + 1200 + 积分 @@ -139,19 +120,12 @@ - - {{ item.icon }} - + {{ item.icon }} {{ item.name }} - - - 暂无广告数据 - - @@ -166,21 +140,6 @@ - - - - - 商家管理 - - - - - {{ item.icon }} - - {{ item.name }} - - - @@ -190,25 +149,25 @@ - 页面设置 + 页面布局风格 - 页面风格: - 样式1 + 样式1 (经典红) - 样式2 + 样式2 (通透卡片) - 样式3 + 样式3 (简约白) + 选择风格后点击右上角“保存”生效,该配置将同步至移动端个人中心页面。 @@ -217,358 +176,146 @@ diff --git a/services/admin/decorationService.uts b/services/admin/decorationService.uts new file mode 100644 index 00000000..d62388d4 --- /dev/null +++ b/services/admin/decorationService.uts @@ -0,0 +1,91 @@ +import { rpcOrNull, rpcOrValue } from '@/services/analytics/rpc.uts' + +/** + * DIY 页面模型 + */ +export type DiyPage = { + id: string + name: string + type: string + config: UTSJSONObject + is_home: boolean + is_active: boolean + created_at: string + updated_at: string +} + +/** + * 获取 DIY 页面分页列表 + */ +export async function fetchDiyPageList( + search: string | null = null, + type: string | null = null, + page: number = 1, + pageSize: number = 20 +): Promise<{ total: number, items: Array }> { + const res = await rpcOrNull('rpc_admin_get_diy_page_list', { + p_search: search, + p_type: type, + p_page: page, + p_page_size: pageSize + } as UTSJSONObject) + + if (res == null) return { total: 0, items: [] as Array } + + return { + total: (res as any).total as number, + items: (res as any).items as Array + } +} + +/** + * 保存 DIY 页面配置 + */ +export async function saveDiyPage( + id: string | null, + name: string, + type: string, + config: UTSJSONObject, + isActive: boolean = true +): Promise { + const res = await rpcOrValue('rpc_admin_save_diy_page', { + p_id: id, + p_name: name, + p_type: type, + p_config: config, + p_is_active: isActive + } as any) + + return res != null ? String(res) : null +} + +/** + * 删除 DIY 页面 + */ +export async function deleteDiyPage(id: string): Promise { + const ok = await rpcOrValue('rpc_admin_delete_diy_page', { p_id: id } as any) + return ok === true +} + +/** + * 设置为生效首页 + */ +export async function setAsHomePage(id: string): Promise { + const ok = await rpcOrValue('rpc_admin_set_home_page', { p_id: id } as any) + return ok === true +} + +/** + * 获取特定类型的生效配置 (供预览或样式页使用) + * 逻辑:从列表接口过滤 type 且 is_home = true 的第一项 + */ +export async function getActiveDiyConfig(type: string): Promise { + // 由于目前没有单独的 get_active_config RPC,复用 list 接口并按 type 筛选 + const res = await fetchDiyPageList(null, type, 1, 1) + if (res.items.length > 0 && res.items[0].is_home) { + // 注意:list 接口可能不返回完整的 config JSON 以节省流量 + // 如果后续需要完整配置,建议补齐 rpc_admin_get_diy_page_detail + return res.items[0] + } + return null +}