diff --git a/docs/sql/10_schema/distribution/ak_distribution_agent_applications_v1.sql b/docs/sql/10_schema/distribution/ak_distribution_agent_applications_v1.sql new file mode 100644 index 00000000..dcf06cba --- /dev/null +++ b/docs/sql/10_schema/distribution/ak_distribution_agent_applications_v1.sql @@ -0,0 +1,34 @@ +-- ===================================================================================== +-- Schema: 分销代理商申请表 +-- 位置:docs/sql/10_schema/distribution/ak_distribution_agent_applications_v1.sql +-- 对象类型:TABLE +-- 版本:v1 +-- 依赖:ak_users, ak_distribution_divisions +-- ===================================================================================== + +CREATE TABLE IF NOT EXISTS public.ak_distribution_agent_applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE, + division_uid UUID NOT NULL REFERENCES public.ak_distribution_divisions(uid), + + agent_name TEXT NOT NULL, + agent_phone TEXT NULL, + + proof_images JSONB NULL, -- 申请凭证图片列表 + + status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected + refusal_reason TEXT NULL, + + approved_at TIMESTAMPTZ NULL, + approved_by UUID NULL REFERENCES public.ak_users(id), + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_dist_agent_applications_uid ON public.ak_distribution_agent_applications(uid); +CREATE INDEX IF NOT EXISTS idx_dist_agent_applications_division_uid ON public.ak_distribution_agent_applications(division_uid); +CREATE INDEX IF NOT EXISTS idx_dist_agent_applications_status ON public.ak_distribution_agent_applications(status); + +COMMENT ON TABLE public.ak_distribution_agent_applications IS '分销代理商申请记录表'; +COMMENT ON COLUMN public.ak_distribution_agent_applications.proof_images IS '申请图片列表(JSON)'; diff --git a/docs/sql/10_schema/distribution/ak_distribution_agents_v1.sql b/docs/sql/10_schema/distribution/ak_distribution_agents_v1.sql index 4757ce83..adf33ab2 100644 --- a/docs/sql/10_schema/distribution/ak_distribution_agents_v1.sql +++ b/docs/sql/10_schema/distribution/ak_distribution_agents_v1.sql @@ -1,41 +1,29 @@ -- ===================================================================================== -- Schema: 分销代理商管理表 -- 位置:docs/sql/10_schema/distribution/ak_distribution_agents_v1.sql --- 说明:管理事业部旗下的代理商,按商家隔离。 +-- 对象类型:TABLE +-- 版本:v1 +-- 依赖:ak_users, ak_distribution_divisions -- ===================================================================================== CREATE TABLE IF NOT EXISTS public.ak_distribution_agents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE, - division_id UUID NOT NULL REFERENCES public.ak_distribution_divisions(id) ON DELETE CASCADE, - uid UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE, - - name TEXT NOT NULL, -- 代理商名称(或备注名) - status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭 - + uid UUID PRIMARY KEY REFERENCES public.ak_users(id) ON DELETE CASCADE, + division_uid UUID NOT NULL REFERENCES public.ak_distribution_divisions(uid), -- 所属事业部 + name TEXT NOT NULL, + commission_ratio NUMERIC(5,2) DEFAULT 0 CHECK (commission_ratio >= 0 AND commission_ratio <= 100), + is_enabled BOOLEAN DEFAULT TRUE, + end_time TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), - - -- 约束:一个用户在一个商家下只能成为一个代理商 - UNIQUE(merchant_id, uid) + created_by UUID REFERENCES public.ak_users(id), + updated_by UUID REFERENCES public.ak_users(id) ); --- 启用 RLS -ALTER TABLE public.ak_distribution_agents ENABLE ROW LEVEL SECURITY; - --- 权限策略:商家仅能管理自己的代理商 -CREATE POLICY "Merchants manage their own agents" -ON public.ak_distribution_agents FOR ALL -TO authenticated -USING (merchant_id = auth.uid()) -WITH CHECK (merchant_id = auth.uid()); - --- 允许查看 -CREATE POLICY "Authenticated users view active agents" -ON public.ak_distribution_agents FOR SELECT -TO authenticated -USING (status = true); - -- 索引 -CREATE INDEX IF NOT EXISTS idx_agents_merchant ON public.ak_distribution_agents(merchant_id); -CREATE INDEX IF NOT EXISTS idx_agents_division ON public.ak_distribution_agents(division_id); +CREATE INDEX IF NOT EXISTS idx_distribution_agents_division_uid ON public.ak_distribution_agents(division_uid); + +-- 注释 +COMMENT ON TABLE public.ak_distribution_agents IS '分销代理商信息表'; +COMMENT ON COLUMN public.ak_distribution_agents.uid IS '用户ID(关联代理商本人)'; +COMMENT ON COLUMN public.ak_distribution_agents.division_uid IS '所属事业部UID'; +COMMENT ON COLUMN public.ak_distribution_agents.commission_ratio IS '代理商固定分佣比例(%)'; diff --git a/docs/sql/10_schema/distribution/ak_distribution_divisions_v1.sql b/docs/sql/10_schema/distribution/ak_distribution_divisions_v1.sql index e0e58126..66122446 100644 --- a/docs/sql/10_schema/distribution/ak_distribution_divisions_v1.sql +++ b/docs/sql/10_schema/distribution/ak_distribution_divisions_v1.sql @@ -1,46 +1,30 @@ -- ===================================================================================== -- Schema: 分销事业部管理表 -- 位置:docs/sql/10_schema/distribution/ak_distribution_divisions_v1.sql --- 说明:管理分销体系中的事业部,支持独立分销比例、邀请码及有效期,按商家隔离。 +-- 对象类型:TABLE +-- 版本:v1 +-- 依赖:ak_users -- ===================================================================================== CREATE TABLE IF NOT EXISTS public.ak_distribution_divisions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - merchant_id UUID NOT NULL REFERENCES public.ak_users(id) ON DELETE CASCADE, - - uid UUID NOT NULL REFERENCES public.ak_users(id), -- 事业部负责人UID - name TEXT NOT NULL, -- 事业部名称 - invite_code TEXT UNIQUE NOT NULL, -- 事业部专属邀请码 - - ratio DECIMAL(5,2) DEFAULT 0, -- 事业部额外分销比例 (%) - agent_count INTEGER DEFAULT 0, -- 下属代理商数量 (由程序或触发器维护) - - end_time TIMESTAMPTZ, -- 协议截止时间 - status BOOLEAN DEFAULT true, -- 状态: true开启, false关闭 - + uid UUID PRIMARY KEY REFERENCES public.ak_users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + invite_code TEXT UNIQUE NOT NULL, + commission_ratio NUMERIC(5,2) DEFAULT 0 CHECK (commission_ratio >= 0 AND commission_ratio <= 100), + is_enabled BOOLEAN DEFAULT TRUE, + end_time TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), - - -- 约束:一个用户在一个商家下只能负责一个事业部 - UNIQUE(merchant_id, uid) + created_by UUID REFERENCES public.ak_users(id), + updated_by UUID REFERENCES public.ak_users(id) ); --- 启用 RLS -ALTER TABLE public.ak_distribution_divisions ENABLE ROW LEVEL SECURITY; - --- 权限策略 -CREATE POLICY "Merchants manage their own divisions" -ON public.ak_distribution_divisions FOR ALL -TO authenticated -USING (merchant_id = auth.uid()) -WITH CHECK (merchant_id = auth.uid()); - --- 允许查看 -CREATE POLICY "Authenticated users view active divisions" -ON public.ak_distribution_divisions FOR SELECT -TO authenticated -USING (status = true); - -- 索引 -CREATE INDEX IF NOT EXISTS idx_divisions_merchant ON public.ak_distribution_divisions(merchant_id); -CREATE INDEX IF NOT EXISTS idx_divisions_uid ON public.ak_distribution_divisions(uid); +CREATE INDEX IF NOT EXISTS idx_distribution_divisions_invite_code ON public.ak_distribution_divisions(invite_code); + +-- 注释 +COMMENT ON TABLE public.ak_distribution_divisions IS '分销事业部信息表'; +COMMENT ON COLUMN public.ak_distribution_divisions.uid IS '用户ID(关联事业部负责人)'; +COMMENT ON COLUMN public.ak_distribution_divisions.invite_code IS '事业部专属邀请码'; +COMMENT ON COLUMN public.ak_distribution_divisions.commission_ratio IS '事业部固定分佣比例(%)'; +COMMENT ON COLUMN public.ak_distribution_divisions.end_time IS '事业部有效截止时间'; diff --git a/docs/sql/20_rls/distribution/ml_distribution_rls_v1.sql b/docs/sql/20_rls/distribution/ml_distribution_rls_v1.sql new file mode 100644 index 00000000..32722f20 --- /dev/null +++ b/docs/sql/20_rls/distribution/ml_distribution_rls_v1.sql @@ -0,0 +1,52 @@ +-- ===================================================================================== +-- RLS: 分销模块安全策略 +-- 位置:docs/sql/20_rls/distribution/ml_distribution_rls_v1.sql +-- 对象类型:RLS 策略 +-- 版本:v1 +-- 说明:管理端全量权限通过 SECURITY DEFINER RPC 执行;用户仅能访问个人关联数据 +-- ===================================================================================== + +-- 启用 RLS +ALTER TABLE public.ak_distribution_config ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ak_distribution_level ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ak_promoter_relations ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ak_commission_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ak_distribution_divisions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ak_distribution_agents ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.ak_distribution_agent_applications ENABLE ROW LEVEL SECURITY; + +-- 1. 分销配置:允许所有登录用户读取(消费者端展示逻辑需要) +DROP POLICY IF EXISTS dist_config_select_policy ON public.ak_distribution_config; +CREATE POLICY dist_config_select_policy ON public.ak_distribution_config +FOR SELECT TO authenticated USING (true); + +-- 2. 分销等级:允许所有登录用户读取可见等级 +DROP POLICY IF EXISTS dist_level_select_policy ON public.ak_distribution_level; +CREATE POLICY dist_level_select_policy ON public.ak_distribution_level +FOR SELECT TO authenticated USING (is_visible = true); + +-- 3. 推广员关系:用户仅能查看与自己相关的记录 +DROP POLICY IF EXISTS promoter_relations_select_policy ON public.ak_promoter_relations; +CREATE POLICY promoter_relations_select_policy ON public.ak_promoter_relations +FOR SELECT TO authenticated USING (uid = auth.uid() OR inviter_uid = auth.uid()); + +-- 4. 佣金日志:用户仅能查看自己的佣金记录 +DROP POLICY IF EXISTS commission_logs_select_policy ON public.ak_commission_logs; +CREATE POLICY commission_logs_select_policy ON public.ak_commission_logs +FOR SELECT TO authenticated USING (uid = auth.uid()); + +-- 5. 事业部与代理商:允许登录用户查看启用的记录 +DROP POLICY IF EXISTS dist_divisions_select_policy ON public.ak_distribution_divisions; +CREATE POLICY dist_divisions_select_policy ON public.ak_distribution_divisions +FOR SELECT TO authenticated USING (is_enabled = true); + +DROP POLICY IF EXISTS dist_agents_select_policy ON public.ak_distribution_agents; +CREATE POLICY dist_agents_select_policy ON public.ak_distribution_agents +FOR SELECT TO authenticated USING (is_enabled = true); + +-- 6. 代理商申请:用户仅能管理自己的申请记录 +DROP POLICY IF EXISTS dist_apply_user_policy ON public.ak_distribution_agent_applications; +CREATE POLICY dist_apply_user_policy ON public.ak_distribution_agent_applications +FOR ALL TO authenticated USING (uid = auth.uid()) WITH CHECK (uid = auth.uid()); + +-- 管理端全量管理将通过 SECURITY DEFINER 的 RPC 接口执行,此处不再额外开放直接表操作 diff --git a/docs/sql/30_rpc/distribution/rpc_admin_delete_agent_v1.sql b/docs/sql/30_rpc/distribution/rpc_admin_delete_agent_v1.sql new file mode 100644 index 00000000..13d5018b --- /dev/null +++ b/docs/sql/30_rpc/distribution/rpc_admin_delete_agent_v1.sql @@ -0,0 +1,31 @@ +-- RPC: rpc_admin_delete_agent +-- 管理端删除代理商 + +CREATE OR REPLACE FUNCTION public.rpc_admin_delete_agent( + p_uid uuid +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_ok boolean; +BEGIN + -- 仅管理员可操作 + 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; + + DELETE FROM public.ak_distribution_agents WHERE uid = p_uid; + + GET DIAGNOSTICS v_ok = ROW_COUNT; + RETURN v_ok; +END; +$$; + +REVOKE ALL ON FUNCTION public.rpc_admin_delete_agent(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_agent(uuid) TO authenticated; diff --git a/docs/sql/30_rpc/distribution/rpc_admin_delete_division_v1.sql b/docs/sql/30_rpc/distribution/rpc_admin_delete_division_v1.sql new file mode 100644 index 00000000..c7781003 --- /dev/null +++ b/docs/sql/30_rpc/distribution/rpc_admin_delete_division_v1.sql @@ -0,0 +1,38 @@ +-- RPC: rpc_admin_delete_division +-- 管理端删除事业部 + +CREATE OR REPLACE FUNCTION public.rpc_admin_delete_division( + p_uid uuid +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_ok boolean; +BEGIN + -- 仅管理员可操作 + 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; + + -- 检查是否有关联代理商 + IF EXISTS ( + SELECT 1 FROM public.ak_distribution_agents WHERE division_uid = p_uid + ) THEN + RAISE EXCEPTION 'cannot delete division with associated agents'; + END IF; + + DELETE FROM public.ak_distribution_divisions WHERE uid = p_uid; + + GET DIAGNOSTICS v_ok = ROW_COUNT; + RETURN v_ok; +END; +$$; + +REVOKE ALL ON FUNCTION public.rpc_admin_delete_division(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_delete_division(uuid) TO authenticated; diff --git a/docs/sql/30_rpc/distribution/rpc_admin_get_agent_apply_list_v1.sql b/docs/sql/30_rpc/distribution/rpc_admin_get_agent_apply_list_v1.sql new file mode 100644 index 00000000..ab2980f7 --- /dev/null +++ b/docs/sql/30_rpc/distribution/rpc_admin_get_agent_apply_list_v1.sql @@ -0,0 +1,68 @@ +-- RPC: rpc_admin_get_agent_apply_list +-- 管理端获取代理商申请列表 +-- 支持按状态过滤:all, pending, approved, rejected + +CREATE OR REPLACE FUNCTION public.rpc_admin_get_agent_apply_list( + p_status text DEFAULT 'all', + p_search text DEFAULT NULL, + p_page integer DEFAULT 1, + p_page_size integer DEFAULT 20 +) +RETURNS TABLE ( + id uuid, + uid uuid, + name text, + phone text, + dept_uid uuid, + dept_name text, + proof_images jsonb, + status text, + refusal_reason text, + time timestamptz, + invite_code text +) +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; +BEGIN + -- 权限检查 + 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; + + RETURN QUERY + SELECT + a.id, + a.uid, + a.agent_name AS name, + a.agent_phone AS phone, + a.division_uid AS dept_uid, + d.name AS dept_name, + a.proof_images, + a.status, + a.refusal_reason, + a.created_at AS time, + d.invite_code + FROM public.ak_distribution_agent_applications a + JOIN public.ak_distribution_divisions d ON d.uid = a.division_uid + WHERE (p_status = 'all' OR a.status = p_status) + AND ( + p_search IS NULL OR p_search = '' + OR a.agent_name ILIKE ('%' || p_search || '%') + OR a.uid::text ILIKE ('%' || p_search || '%') + ) + ORDER BY a.created_at DESC + LIMIT v_page_size OFFSET v_offset; +END; +$$; + +REVOKE ALL ON FUNCTION public.rpc_admin_get_agent_apply_list(text, text, integer, integer) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_get_agent_apply_list(text, text, integer, integer) TO authenticated; diff --git a/docs/sql/30_rpc/distribution/rpc_admin_get_agent_list_v1.sql b/docs/sql/30_rpc/distribution/rpc_admin_get_agent_list_v1.sql new file mode 100644 index 00000000..fc73e53c --- /dev/null +++ b/docs/sql/30_rpc/distribution/rpc_admin_get_agent_list_v1.sql @@ -0,0 +1,62 @@ +-- RPC: rpc_admin_get_agent_list +-- 管理端获取代理商列表 +-- 支持搜索代理商名称或负责人UID,并关联显示所属事业部信息 + +CREATE OR REPLACE FUNCTION public.rpc_admin_get_agent_list( + p_search text DEFAULT NULL, + p_page integer DEFAULT 1, + p_page_size integer DEFAULT 20 +) +RETURNS TABLE ( + uid uuid, + name text, + division_uid uuid, + division_name text, + commission_ratio numeric, + is_enabled boolean, + end_time timestamptz, + created_at timestamptz, + "staffCount" bigint +) +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; +BEGIN + -- 权限检查 + 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; + + RETURN QUERY + SELECT + a.uid, + a.name, + a.division_uid, + d.name AS division_name, + a.commission_ratio, + a.is_enabled, + a.end_time, + a.created_at, + (SELECT COUNT(*) FROM public.ak_promoter_relations r WHERE r.inviter_uid = a.uid)::bigint AS "staffCount" + FROM public.ak_distribution_agents a + JOIN public.ak_distribution_divisions d ON d.uid = a.division_uid + WHERE ( + p_search IS NULL OR p_search = '' + OR a.name ILIKE ('%' || p_search || '%') + OR a.uid::text ILIKE ('%' || p_search || '%') + ) + ORDER BY a.created_at DESC + LIMIT v_page_size OFFSET v_offset; +END; +$$; + +REVOKE ALL ON FUNCTION public.rpc_admin_get_agent_list(text, integer, integer) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_get_agent_list(text, integer, integer) TO authenticated; diff --git a/docs/sql/30_rpc/distribution/rpc_admin_get_division_list_v1.sql b/docs/sql/30_rpc/distribution/rpc_admin_get_division_list_v1.sql new file mode 100644 index 00000000..97383aa7 --- /dev/null +++ b/docs/sql/30_rpc/distribution/rpc_admin_get_division_list_v1.sql @@ -0,0 +1,59 @@ +-- RPC: rpc_admin_get_division_list +-- 管理端获取事业部列表 +-- 支持搜索事业部名称或负责人UID + +CREATE OR REPLACE FUNCTION public.rpc_admin_get_division_list( + p_search text DEFAULT NULL, + p_page integer DEFAULT 1, + p_page_size integer DEFAULT 20 +) +RETURNS TABLE ( + uid uuid, + name text, + invite_code text, + commission_ratio numeric, + is_enabled boolean, + end_time timestamptz, + created_at timestamptz, + "agentCount" bigint +) +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; +BEGIN + -- 仅管理员或分析员可调用 + 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; + + RETURN QUERY + SELECT + d.uid, + d.name, + d.invite_code, + d.commission_ratio, + d.is_enabled, + d.end_time, + d.created_at, + (SELECT COUNT(*) FROM public.ak_distribution_agents a WHERE a.division_uid = d.uid)::bigint AS "agentCount" + FROM public.ak_distribution_divisions d + WHERE ( + p_search IS NULL OR p_search = '' + OR d.name ILIKE ('%' || p_search || '%') + OR d.uid::text ILIKE ('%' || p_search || '%') + ) + ORDER BY d.created_at DESC + LIMIT v_page_size OFFSET v_offset; +END; +$$; + +REVOKE ALL ON FUNCTION public.rpc_admin_get_division_list(text, integer, integer) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_get_division_list(text, integer, integer) TO authenticated; diff --git a/docs/sql/30_rpc/distribution/rpc_admin_process_agent_apply_v1.sql b/docs/sql/30_rpc/distribution/rpc_admin_process_agent_apply_v1.sql new file mode 100644 index 00000000..6c33a171 --- /dev/null +++ b/docs/sql/30_rpc/distribution/rpc_admin_process_agent_apply_v1.sql @@ -0,0 +1,69 @@ +-- RPC: rpc_admin_process_agent_apply +-- 管理端审核代理商申请 +-- 若通过(approved),则同步在 ak_distribution_agents 中创建或更新记录 + +CREATE OR REPLACE FUNCTION public.rpc_admin_process_agent_apply( + p_id uuid, + p_status text, -- approved / rejected + p_refusal_reason text DEFAULT NULL +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_uid uuid; + v_division_uid uuid; + v_agent_name text; +BEGIN + -- 仅管理员可审核 + 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; + + -- 1. 获取并锁定申请记录 + SELECT uid, division_uid, agent_name + INTO v_uid, v_division_uid, v_agent_name + FROM public.ak_distribution_agent_applications + WHERE id = p_id; + + IF v_uid IS NULL THEN + RAISE EXCEPTION 'application record not found'; + END IF; + + -- 2. 更新申请状态 + UPDATE public.ak_distribution_agent_applications + SET + status = p_status, + refusal_reason = CASE WHEN p_status = 'rejected' THEN p_refusal_reason ELSE NULL END, + approved_at = now(), + approved_by = auth.uid(), + updated_at = now() + WHERE id = p_id; + + -- 3. 如果通过,则同步到代理商正式表 + IF p_status = 'approved' THEN + INSERT INTO public.ak_distribution_agents ( + uid, division_uid, name, commission_ratio, is_enabled, updated_at, updated_by + ) + VALUES ( + v_uid, v_division_uid, v_agent_name, 0, true, now(), auth.uid() + ) + ON CONFLICT (uid) DO UPDATE + SET + division_uid = EXCLUDED.division_uid, + name = EXCLUDED.name, + updated_at = now(), + updated_by = auth.uid(); + END IF; + + RETURN true; +END; +$$; + +REVOKE ALL ON FUNCTION public.rpc_admin_process_agent_apply(uuid, text, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_process_agent_apply(uuid, text, text) TO authenticated; diff --git a/docs/sql/30_rpc/distribution/rpc_admin_save_agent_v1.sql b/docs/sql/30_rpc/distribution/rpc_admin_save_agent_v1.sql new file mode 100644 index 00000000..51982c5a --- /dev/null +++ b/docs/sql/30_rpc/distribution/rpc_admin_save_agent_v1.sql @@ -0,0 +1,54 @@ +-- RPC: rpc_admin_save_agent +-- 管理端新增或更新代理商 + +CREATE OR REPLACE FUNCTION public.rpc_admin_save_agent( + p_uid uuid, + p_division_uid uuid, + p_name text, + p_commission_ratio numeric, + p_is_enabled boolean DEFAULT true, + p_end_time timestamptz DEFAULT NULL +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + -- 仅管理员可操作 + 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; + + -- 确保事业部存在 + IF NOT EXISTS ( + SELECT 1 FROM public.ak_distribution_divisions WHERE uid = p_division_uid + ) THEN + RAISE EXCEPTION 'parent division not found'; + END IF; + + INSERT INTO public.ak_distribution_agents ( + uid, division_uid, name, commission_ratio, is_enabled, end_time, updated_at, updated_by + ) + VALUES ( + p_uid, p_division_uid, p_name, p_commission_ratio, p_is_enabled, p_end_time, now(), auth.uid() + ) + ON CONFLICT (uid) DO UPDATE + SET + division_uid = EXCLUDED.division_uid, + name = EXCLUDED.name, + commission_ratio = EXCLUDED.commission_ratio, + is_enabled = EXCLUDED.is_enabled, + end_time = EXCLUDED.end_time, + updated_at = now(), + updated_by = auth.uid(); + + RETURN p_uid; +END; +$$; + +REVOKE ALL ON FUNCTION public.rpc_admin_save_agent(uuid, uuid, text, numeric, boolean, timestamptz) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_save_agent(uuid, uuid, text, numeric, boolean, timestamptz) TO authenticated; diff --git a/docs/sql/30_rpc/distribution/rpc_admin_save_division_v1.sql b/docs/sql/30_rpc/distribution/rpc_admin_save_division_v1.sql new file mode 100644 index 00000000..d874dcbc --- /dev/null +++ b/docs/sql/30_rpc/distribution/rpc_admin_save_division_v1.sql @@ -0,0 +1,47 @@ +-- RPC: rpc_admin_save_division +-- 管理端新增或更新事业部 + +CREATE OR REPLACE FUNCTION public.rpc_admin_save_division( + p_uid uuid, + p_name text, + p_invite_code text, + p_commission_ratio numeric, + p_is_enabled boolean DEFAULT true, + p_end_time timestamptz DEFAULT NULL +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + -- 仅管理员可操作 + 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; + + INSERT INTO public.ak_distribution_divisions ( + uid, name, invite_code, commission_ratio, is_enabled, end_time, updated_at, updated_by + ) + VALUES ( + p_uid, p_name, p_invite_code, p_commission_ratio, p_is_enabled, p_end_time, now(), auth.uid() + ) + ON CONFLICT (uid) DO UPDATE + SET + name = EXCLUDED.name, + invite_code = EXCLUDED.invite_code, + commission_ratio = EXCLUDED.commission_ratio, + is_enabled = EXCLUDED.is_enabled, + end_time = EXCLUDED.end_time, + updated_at = now(), + updated_by = auth.uid(); + + RETURN p_uid; +END; +$$; + +REVOKE ALL ON FUNCTION public.rpc_admin_save_division(uuid, text, text, numeric, boolean, timestamptz) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.rpc_admin_save_division(uuid, text, text, numeric, boolean, timestamptz) TO authenticated; diff --git a/pages/mall/admin/distribution/division/agent.uvue b/pages/mall/admin/distribution/division/agent.uvue index 7ccaa7ac..6e59cb40 100644 --- a/pages/mall/admin/distribution/division/agent.uvue +++ b/pages/mall/admin/distribution/division/agent.uvue @@ -4,32 +4,36 @@ 代理商查询: - + + + - - 加载中... + 数据加载中... 用户UID 头像 名称 - 所属事业部 - 加入时间 + 分佣比例 + 所属事业部 + 员工数量 + 过期时间 状态 操作 + 暂无代理商数据 @@ -37,18 +41,20 @@ {{ item.uid }} - + - {{ item.name || item.nickname }} - {{ item.division_name || '-' }} - {{ formatDateTime(item.created_at) }} + {{ item.name }} + {{ item.commission_ratio }}% + {{ item.division_name || '-' }} + {{ item.staffCount }} + {{ item.end_time ? item.end_time.substring(0, 10) : '-' }} - + - 详情 + 编辑 | - 删除 + 删除 @@ -60,36 +66,100 @@ 第 {{ page }} 页 - 共 {{ total }} 条记录 + 共 {{ agentList.length }} 条记录 + + + + + + + + {{ isEdit ? '编辑代理商' : '添加代理商' }} + × + + + + + 用户 UID + + + + + 所属事业部 + + + {{ divisionOptions[divisionIndex]?.name || '请选择事业部' }} + + + + + + + 代理商名称 + + + + + 分佣比例 (%) + + + + + 过期时间 + + + + + 启用状态 + + + + + + + + - + \ No newline at end of file + +.pagination { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; align-items: center; justify-content: space-between; } +.pager-btns { display: flex; flex-direction: row; align-items: center; gap: 12px; } +.page-num { font-size: 14px; color: #333; } +.page-info { font-size: 14px; color: #999; } + +.loading-mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.7); display: flex; align-items: center; justify-content: center; z-index: 10; } +.loading-text { color: #1890ff; font-size: 14px; } +.empty-row { padding: 40px 0; text-align: center; color: #999; font-size: 14px; } + +/* 弹窗样式 */ +.popup-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 999; } +.popup-card { width: 500px; background-color: #fff; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; } +.popup-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid #f0f0f0; } +.popup-title { font-size: 16px; font-weight: bold; color: #333; } +.popup-close { font-size: 20px; color: #999; cursor: pointer; padding: 4px; } +.popup-body { padding: 24px; display: flex; flex-direction: column; gap: 16px; } +.popup-item { display: flex; flex-direction: column; gap: 8px; } +.popup-row { flex-direction: row; align-items: center; justify-content: space-between; } +.popup-label { font-size: 14px; color: #666; } +.popup-input { border: 1px solid #d9d9d9; border-radius: 4px; height: 36px; padding: 0 12px; font-size: 14px; width: 100%; } +.select-box { border: 1px solid #d9d9d9; border-radius: 4px; height: 36px; padding: 0 12px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; text { font-size: 14px; color: #333; } .arrow { font-size: 10px; color: #bfbfbf; } } +.popup-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; } + diff --git a/pages/mall/admin/distribution/division/apply.uvue b/pages/mall/admin/distribution/division/apply.uvue index f6bd0a2a..aa573b4b 100644 --- a/pages/mall/admin/distribution/division/apply.uvue +++ b/pages/mall/admin/distribution/division/apply.uvue @@ -4,23 +4,24 @@ 搜索: - + + - - {{ tab }} + + {{ tab.label }} - 加载中... + 数据加载中... @@ -41,75 +42,70 @@ {{ item.uid }} {{ item.name }} - {{ item.phone }} - {{ item.division_name || '-' }} + {{ item.phone || '-' }} + {{ item.dept_name }} - + + - {{ formatTime(item.created_at) }} + {{ formatDateTime(item.time) }} - {{ getStatusLabel(item.status) }} + {{ getStatusText(item.status) }} - {{ item.invite_code || '-' }} + {{ item.invite_code }} - + + - \ No newline at end of file diff --git a/pages/mall/admin/distribution/division/list.uvue b/pages/mall/admin/distribution/division/list.uvue index 65917580..1a06e82b 100644 --- a/pages/mall/admin/distribution/division/list.uvue +++ b/pages/mall/admin/distribution/division/list.uvue @@ -1,5 +1,6 @@ - + \ No newline at end of file + +.pagination { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; align-items: center; justify-content: space-between; } +.pager-btns { display: flex; flex-direction: row; align-items: center; gap: 12px; } +.page-num { font-size: 14px; color: #333; } +.page-info { font-size: 14px; color: #999; } + +.loading-mask { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.7); display: flex; align-items: center; justify-content: center; z-index: 10; } +.loading-text { color: #1890ff; font-size: 14px; } +.empty-row { padding: 40px 0; text-align: center; color: #999; font-size: 14px; } + +/* 弹窗样式 */ +.popup-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 999; } +.popup-card { width: 500px; background-color: #fff; border-radius: 8px; display: flex; flex-direction: column; overflow: hidden; } +.popup-header { display: flex; flex-direction: row; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid #f0f0f0; } +.popup-title { font-size: 16px; font-weight: bold; color: #333; } +.popup-close { font-size: 20px; color: #999; cursor: pointer; padding: 4px; } +.popup-body { padding: 24px; display: flex; flex-direction: column; gap: 16px; } +.popup-item { display: flex; flex-direction: column; gap: 8px; } +.popup-row { flex-direction: row; align-items: center; justify-content: space-between; } +.popup-label { font-size: 14px; color: #666; } +.popup-input { border: 1px solid #d9d9d9; border-radius: 4px; height: 36px; padding: 0 12px; font-size: 14px; width: 100%; } +.popup-footer { padding: 16px 24px; border-top: 1px solid #f0f0f0; display: flex; flex-direction: row; justify-content: flex-end; gap: 12px; } + diff --git a/pages/mall/admin/marketing/groupbuy/list.uvue b/pages/mall/admin/marketing/groupbuy/list.uvue index 59cfee04..ab4f739a 100644 --- a/pages/mall/admin/marketing/groupbuy/list.uvue +++ b/pages/mall/admin/marketing/groupbuy/list.uvue @@ -1,27 +1,296 @@ - - diff --git a/pages/mall/admin/marketing/recharge/record.uvue b/pages/mall/admin/marketing/recharge/record.uvue index 63bd8c6b..873b651d 100644 --- a/pages/mall/admin/marketing/recharge/record.uvue +++ b/pages/mall/admin/marketing/recharge/record.uvue @@ -1,27 +1,240 @@ - - diff --git a/services/admin/distributionService.uts b/services/admin/distributionService.uts index 7e4906a3..ab0aa154 100644 --- a/services/admin/distributionService.uts +++ b/services/admin/distributionService.uts @@ -1,11 +1,10 @@ import supa from '@/components/supadb/aksupainstance.uts' /** - * 分销配置模型 (与 ak_distribution_config 表对齐) + * 分销配置模型 */ export type DistributionConfig = { id?: string - // 分销模式 is_enabled: boolean extract_type: string bind_type: string @@ -15,8 +14,6 @@ export type DistributionConfig = { is_area_manager: boolean is_agent_apply: boolean is_commission_window: boolean - - // 返佣设置 is_self_brokerage: boolean is_member_brokerage: boolean brokerage_type: string @@ -26,17 +23,13 @@ export type DistributionConfig = { store_brokerage_ratio: number store_brokerage_two_ratio: number extract_frozen_time: number - - // 提现设置 user_extract_min_price: number extract_bank_list: string extract_type_list: string[] wechat_extract_type: string alipay_extract_type: string user_extract_fee: number - updated_at?: string - updated_by?: string } /** @@ -58,6 +51,52 @@ export type Promoter = { unwithdrawnAmount: number } +/** + * 事业部模型 + */ +export type Division = { + uid: string + name: string + invite_code: string + commission_ratio: number + is_enabled: boolean + end_time: string | null + created_at: string + agentCount: number +} + +/** + * 代理商模型 + */ +export type Agent = { + uid: string + name: string + division_uid: string + division_name: string + commission_ratio: number + is_enabled: boolean + end_time: string | null + created_at: string + staffCount: number +} + +/** + * 代理商申请模型 + */ +export type AgentApply = { + id: string + uid: string + name: string + phone: string + dept_uid: string + dept_name: string + proof_images: string[] | null + status: string + refusal_reason: string | null + time: string + invite_code: string +} + /** * 获取分销全局配置 */ @@ -89,11 +128,7 @@ export async function saveDistributionConfig(config: DistributionConfig): Promis }) .execute() - if (error != null) { - console.error('保存分销配置失败:', error) - return false - } - return true + return error == null } /** @@ -108,8 +143,6 @@ export type DistributionLevel = { task_total: number task_finish: number is_visible: boolean - created_at?: string - updated_at?: string } /** @@ -122,30 +155,18 @@ export async function getDistributionLevelList(): Promise { .order('level', { ascending: true }) .execute() - if (error != null) { - console.error('获取分销等级列表失败:', error) - return [] as DistributionLevel[] - } - return data as DistributionLevel[] + return (data ?? []) as DistributionLevel[] } /** - * 保存/更新分销等级 + * 保存分销等级 */ export async function saveDistributionLevel(level: DistributionLevel): Promise { const { error } = await supa .from('ak_distribution_level') - .upsert({ - ...level, - updated_at: new Date().toISOString() - }) + .upsert(level) .execute() - - if (error != null) { - console.error('保存分销等级失败:', error) - return false - } - return true + return error == null } /** @@ -157,16 +178,11 @@ export async function deleteDistributionLevel(id: string): Promise { .delete() .eq('id', id) .execute() - - if (error != null) { - console.error('删除分销等级失败:', error) - return false - } - return true + return error == null } /** - * 获取推广员列表 + * 推广员列表参数 */ export type PromoterListParams = { search?: string | null @@ -177,185 +193,111 @@ export type PromoterListParams = { } /** - * 获取推广员列表(聚合统计) + * 获取推广员列表 */ export async function getPromoterList(params?: PromoterListParams): Promise { - const payload = { + const { data, error } = await supa.rpc('rpc_admin_get_promoter_list', { p_search: params?.search ?? null, p_page: params?.page ?? 1, p_page_size: params?.pageSize ?? 20, p_start_time: params?.startTime ?? null, p_end_time: params?.endTime ?? null - } as any + } as any) - const { data, error } = await supa - .rpc('rpc_admin_get_promoter_list', payload as any) - - if (error != null) { - console.error('获取推广员列表失败:', error) - return [] as Promoter[] - } return (data ?? []) as Promoter[] } -/** - * 事业部模型 - */ -export type DistributionDivision = { - id?: string - merchant_id?: string - uid: string - name: string - invite_code: string - ratio: number - agent_count: number - end_time: string | null - status: boolean - created_at?: string - updated_at?: string - // 关联字段 - nickname?: string - avatar_url?: string -} - -/** - * 代理商模型 - */ -export type DistributionAgent = { - id?: string - merchant_id?: string - uid: string - division_id: string - name: string - status: boolean - created_at?: string - updated_at?: string - // 关联字段 - nickname?: string - avatar_url?: string - division_name?: string -} - /** * 获取事业部列表 */ -export async function fetchDivisions(query?: { search?: string, page?: number, pageSize?: number }): Promise<{ total: number, items: DistributionDivision[] }> { - let q = supa.from('ak_distribution_divisions').select('*, ak_users!uid(username, avatar_url)', { count: 'exact' }) - - if (query?.search != null && query.search !== '') { - q = q.or(`name.ilike.%${query.search}%,ak_users.username.ilike.%${query.search}%`) - } - - const p = query?.page ?? 1 - const ps = query?.pageSize ?? 20 - const from = (p - 1) * ps - const to = from + ps - 1 - - const { data, error, count } = await q - .order('created_at', { ascending: false }) - .range(from, to) - .execute() - - if (error != null) { - console.error('获取事业部列表失败:', error) - return { total: 0, items: [] as DistributionDivision[] } - } - - const items = (data ?? []).map((item: any): DistributionDivision => { - return { - ...item, - nickname: item.ak_users?.username, - avatar_url: item.ak_users?.avatar_url - } as DistributionDivision - }) - - return { total: count ?? 0, items } +export async function getDivisionList(search: string | null, page: number, pageSize: number): Promise { + const { data, error } = await supa.rpc('rpc_admin_get_division_list', { + p_search: search, + p_page: page, + p_page_size: pageSize + } as any) + return (data ?? []) as Division[] } /** - * 保存事业部(新增/更新) + * 保存事业部 */ -export async function saveDivision(division: DistributionDivision): Promise { - const session = supa.getSession() - const mid = session?.user?.getString('id') - if (mid == null) return false - - const { error } = await supa - .from('ak_distribution_divisions') - .upsert({ - ...division, - merchant_id: mid, - updated_at: new Date().toISOString() - }) - .execute() - +export async function saveDivision(division: any): Promise { + const { error } = await supa.rpc('rpc_admin_save_division', { + p_uid: division.uid, + p_name: division.name, + p_invite_code: division.invite_code, + p_commission_ratio: division.commission_ratio, + p_is_enabled: division.is_enabled, + p_end_time: division.end_time + } as any) return error == null } /** * 删除事业部 */ -export async function deleteDivision(id: string): Promise { - const { error } = await supa.from('ak_distribution_divisions').delete().eq('id', id).execute() - return error == null +export async function deleteDivision(uid: string): Promise { + const { data, error } = await supa.rpc('rpc_admin_delete_division', { p_uid: uid } as any) + return error == null && data === true } /** * 获取代理商列表 */ -export async function fetchAgents(query?: { search?: string, divisionId?: string, page?: number, pageSize?: number }): Promise<{ total: number, items: DistributionAgent[] }> { - let q = supa.from('ak_distribution_agents').select('*, ak_users!uid(username, avatar_url), ak_distribution_divisions!division_id(name)', { count: 'exact' }) - - if (query?.search != null && query.search !== '') { - q = q.or(`name.ilike.%${query.search}%,ak_users.username.ilike.%${query.search}%`) - } - if (query?.divisionId != null) { - q = q.eq('division_id', query.divisionId) - } - - const p = query?.page ?? 1 - const ps = query?.pageSize ?? 20 - const from = (p - 1) * ps - const to = from + ps - 1 - - const { data, error, count } = await q - .order('created_at', { ascending: false }) - .range(from, to) - .execute() - - if (error != null) { - console.error('获取代理商列表失败:', error) - return { total: 0, items: [] as DistributionAgent[] } - } - - const items = (data ?? []).map((item: any): DistributionAgent => { - return { - ...item, - nickname: item.ak_users?.username, - avatar_url: item.ak_users?.avatar_url, - division_name: item.ak_distribution_divisions?.name - } as DistributionAgent - }) - - return { total: count ?? 0, items } +export async function getAgentList(search: string | null, page: number, pageSize: number): Promise { + const { data, error } = await supa.rpc('rpc_admin_get_agent_list', { + p_search: search, + p_page: page, + p_page_size: pageSize + } as any) + return (data ?? []) as Agent[] } /** * 保存代理商 */ -export async function saveAgent(agent: DistributionAgent): Promise { - const session = supa.getSession() - const mid = session?.user?.getString('id') - if (mid == null) return false - - const { error } = await supa - .from('ak_distribution_agents') - .upsert({ - ...agent, - merchant_id: mid, - updated_at: new Date().toISOString() - }) - .execute() - +export async function saveAgent(agent: any): Promise { + const { error } = await supa.rpc('rpc_admin_save_agent', { + p_uid: agent.uid, + p_division_uid: agent.division_uid, + p_name: agent.name, + p_commission_ratio: agent.commission_ratio, + p_is_enabled: agent.is_enabled, + p_end_time: agent.end_time + } as any) return error == null } + +/** + * 删除代理商 + */ +export async function deleteAgent(uid: string): Promise { + const { data, error } = await supa.rpc('rpc_admin_delete_agent', { p_uid: uid } as any) + return error == null && data === true +} + +/** + * 获取代理商申请列表 + */ +export async function getAgentApplyList(status: string, search: string | null, page: number, pageSize: number): Promise { + const { data, error } = await supa.rpc('rpc_admin_get_agent_apply_list', { + p_status: status, + p_search: search, + p_page: page, + p_page_size: pageSize + } as any) + return (data ?? []) as AgentApply[] +} + +/** + * 审核代理商申请 + */ +export async function processAgentApply(id: string, status: string, reason: string | null): Promise { + const { data, error } = await supa.rpc('rpc_admin_process_agent_apply', { + p_id: id, + p_status: status, + p_refusal_reason: reason + } as any) + return error == null && data === true +}