From 4df88ea502f288a76488f79e64769438276c3e16 Mon Sep 17 00:00:00 2001 From: huangzhenbao <17818024429@163.com> Date: Tue, 10 Mar 2026 17:01:56 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=B3=A8=E5=86=8C=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/supadb/aksupa.uts | 9 +- .../MULTI_TERMINAL_REGISTRATION_GUIDE.md | 85 ++++++++++++++ pages/mall/admin/错误信息.txt | 110 ++---------------- pages/user/login.uvue | 89 ++++++++++++-- pages/user/register.uvue | 75 +++--------- utils/sapi.uts | 14 ++- 6 files changed, 210 insertions(+), 172 deletions(-) create mode 100644 pages/mall/admin/MULTI_TERMINAL_REGISTRATION_GUIDE.md diff --git a/components/supadb/aksupa.uts b/components/supadb/aksupa.uts index 2e52d4c1..d763b1ea 100644 --- a/components/supadb/aksupa.uts +++ b/components/supadb/aksupa.uts @@ -786,7 +786,12 @@ export class AkSupa { }; } - async signUp(email : string, password : string) : Promise { + async signUp(email : string, password : string, options ?: UTSJSONObject) : Promise { + const body = { email, password } as UTSJSONObject; + if (options != null && options.get('data') != null) { + body['data'] = options.get('data'); + } + const res = await AkReq.request({ url: this.baseUrl + '/auth/v1/signup', method: 'POST', @@ -794,7 +799,7 @@ export class AkSupa { apikey: this.apikey, 'Content-Type': 'application/json' } as UTSJSONObject, - data: { email, password } as UTSJSONObject, + data: body, contentType: 'application/json' }, false); return res.data as UTSJSONObject; diff --git a/pages/mall/admin/MULTI_TERMINAL_REGISTRATION_GUIDE.md b/pages/mall/admin/MULTI_TERMINAL_REGISTRATION_GUIDE.md new file mode 100644 index 00000000..8c37d46a --- /dev/null +++ b/pages/mall/admin/MULTI_TERMINAL_REGISTRATION_GUIDE.md @@ -0,0 +1,85 @@ +# 多端用户注册与身份识别实现指南 (Multi-Terminal Registration Guide) + +本档说明了如何在当前的 Supabase 架构下实现“消费者端”、“商家端”及“管理端”的统一注册逻辑,并确保用户身份(Role)在入库时能够自动、准确地被识别。 + +## 1. 核心架构原理 + +系统采用 **“前端声明意图 + 后端自动触发”** 的模式: +1. **前端 App**:在调用接口注册时,通过 `raw_user_meta_data` 声明用户的目标角色(如 `consumer` 或 `merchant`)。 +2. **Supabase Auth**:接收并存储这些元数据。 +3. **数据库触发器 (Trigger)**:在 `auth.users` 产生新记录的一瞬间,由数据库自动读取元数据,并将用户信息连带其正确的角色属性同步到业务表 `ak_users` 中。 + +--- + +## 2. 前端实现步骤 (代码参考) + +在各端 App 的注册逻辑中,需在调用 `signUp` 接口时传递 `options.data`。 + +### 消费者端 (Consumer App) +在 `pages/user/register.uvue` 中: +```typescript +const options = new UTSJSONObject() +const metaData = new UTSJSONObject() +metaData.set('user_role', 'consumer') // 核心:声明为消费者 +options.set('data', metaData) + +const result = await supa.signUp(email, password, options) +``` + +### 商家端 (Merchant App) +在商家端的注册页面中,只需修改 `user_role` 的值: +```typescript +metaData.set('user_role', 'merchant') // 核心:声明为商家 +``` + +--- + +## 3. 数据库实现步骤 (SQL 设置) + +为了让数据库能够“看碟下菜”,必须在 Supabase SQL Editor 中运行以下脚本,安装/更新智能触发器: + +```sql +-- 1. 创建或更新处理函数 +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger AS $$ +BEGIN + -- 向业务表插入数据,并智能识别角色 + INSERT INTO public.ak_users (id, auth_id, email, role, nickname, status) + VALUES ( + NEW.id, + NEW.id, -- 统一使用 Auth ID + NEW.email, + -- 核心逻辑:读取 metadata 中的 user_role,如果没有传则默认为 'consumer' + COALESCE(NEW.raw_user_meta_data->>'user_role', 'consumer'), + -- 默认昵称取邮箱前缀 + split_part(NEW.email, '@', 1), + 1 -- 默认激活状态 + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 2. 绑定触发器 +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); +``` + +--- + +## 4. 三端互不干扰的优势 + +* **全自动入库**:一旦 SQL 触发器设置完成,前端不再需要手动调用 `ensureUserProfile` 或 `insert` 接口,减少了网络请求和前端报错几率。 +* **物理隔离与 RLS 安全**:通过 `ak_users` 表的 RLS 策略(`auth.uid() = id`),确保即使用户通过 API 尝试修改他人数据,也会被数据库直接拦截。 +* **统一维护**:所有端的注册逻辑在数据库层面是统一的,未来若需增加新角色(如 `admin_manager`),只需修改触发器逻辑即可,无需大规模重构代码。 + +--- + +## 5. 开发建议 + +* **强制校验**:在生产环境下,可以在触发器内增加校验逻辑,防止普通用户通过伪造元数据获得 `admin` 角色。 +* **日志排查**:如果新用户注册后 `ak_users` 表没有数据,请检查 Supabase 控制台的 `Database -> Logs`,查看触发器执行是否有报错(通常是唯一索引冲突导致)。 + +--- +*最后更新时间:2026-03-10* diff --git a/pages/mall/admin/错误信息.txt b/pages/mall/admin/错误信息.txt index ec1a46e0..98222592 100644 --- a/pages/mall/admin/错误信息.txt +++ b/pages/mall/admin/错误信息.txt @@ -1,100 +1,10 @@ -index.uvue:991 GET http://localhost:5173/pages/mall/admin/product/product-management/index.uvue?vue&type=style&index=0&scoped=6161a702&lang.scss net::ERR_ABORTED 500 (Internal Server Error) -main.uts:16 [Vue warn]: Unhandled error during execution of async component loader - at -at -at -at -at -at -at -at -warnHandler @ uni-h5.es.js:19975 -callWithErrorHandling @ vue.runtime.esm.js:1381 -warn$1 @ vue.runtime.esm.js:1207 -logError @ vue.runtime.esm.js:1438 -errorHandler @ uni-h5.es.js:19600 -callWithErrorHandling @ vue.runtime.esm.js:1381 -handleError @ vue.runtime.esm.js:1421 -onError @ vue.runtime.esm.js:3724 -(anonymous) @ vue.runtime.esm.js:3767 -Promise.catch -setup @ vue.runtime.esm.js:3766 -callWithErrorHandling @ vue.runtime.esm.js:1381 -setupStatefulComponent @ vue.runtime.esm.js:8985 -setupComponent @ vue.runtime.esm.js:8946 -mountComponent @ vue.runtime.esm.js:7262 -processComponent @ vue.runtime.esm.js:7228 -patch @ vue.runtime.esm.js:6694 -mountChildren @ vue.runtime.esm.js:6942 -processFragment @ vue.runtime.esm.js:7158 -patch @ vue.runtime.esm.js:6668 -mountChildren @ vue.runtime.esm.js:6942 -processFragment @ vue.runtime.esm.js:7158 -patch @ vue.runtime.esm.js:6668 -mountChildren @ vue.runtime.esm.js:6942 -mountElement @ vue.runtime.esm.js:6849 -processElement @ vue.runtime.esm.js:6814 -patch @ vue.runtime.esm.js:6682 -mountChildren @ vue.runtime.esm.js:6942 -mountElement @ vue.runtime.esm.js:6849 -processElement @ vue.runtime.esm.js:6814 -patch @ vue.runtime.esm.js:6682 -mountChildren @ vue.runtime.esm.js:6942 -processFragment @ vue.runtime.esm.js:7158 -patch @ vue.runtime.esm.js:6668 -componentUpdateFn @ vue.runtime.esm.js:7372 -run @ vue.runtime.esm.js:153 -instance.update @ vue.runtime.esm.js:7497 -setupRenderEffect @ vue.runtime.esm.js:7507 -mountComponent @ vue.runtime.esm.js:7274 -processComponent @ vue.runtime.esm.js:7228 -patch @ vue.runtime.esm.js:6694 -mountChildren @ vue.runtime.esm.js:6942 -mountElement @ vue.runtime.esm.js:6849 -processElement @ vue.runtime.esm.js:6814 -patch @ vue.runtime.esm.js:6682 -componentUpdateFn @ vue.runtime.esm.js:7372 -run @ vue.runtime.esm.js:153 -instance.update @ vue.runtime.esm.js:7497 -setupRenderEffect @ vue.runtime.esm.js:7507 -mountComponent @ vue.runtime.esm.js:7274 -processComponent @ vue.runtime.esm.js:7228 -patch @ vue.runtime.esm.js:6694 -componentUpdateFn @ vue.runtime.esm.js:7372 -run @ vue.runtime.esm.js:153 -instance.update @ vue.runtime.esm.js:7497 -setupRenderEffect @ vue.runtime.esm.js:7507 -mountComponent @ vue.runtime.esm.js:7274 -processComponent @ vue.runtime.esm.js:7228 -patch @ vue.runtime.esm.js:6694 -componentUpdateFn @ vue.runtime.esm.js:7453 -run @ vue.runtime.esm.js:153 -instance.update @ vue.runtime.esm.js:7497 -updateComponent @ vue.runtime.esm.js:7305 -processComponent @ vue.runtime.esm.js:7239 -patch @ vue.runtime.esm.js:6694 -componentUpdateFn @ vue.runtime.esm.js:7453 -run @ vue.runtime.esm.js:153 -instance.update @ vue.runtime.esm.js:7497 -callWithErrorHandling @ vue.runtime.esm.js:1381 -flushJobs @ vue.runtime.esm.js:1585 -Promise.then -queueFlush @ vue.runtime.esm.js:1494 -queueJob @ vue.runtime.esm.js:1488 -scheduler @ vue.runtime.esm.js:3179 -resetScheduling @ vue.runtime.esm.js:236 -triggerEffects @ vue.runtime.esm.js:280 -triggerRefValue @ vue.runtime.esm.js:1033 -set value @ vue.runtime.esm.js:1078 -finalizeNavigation @ vue-router.mjs?v=ed041164:2474 -(anonymous) @ vue-router.mjs?v=ed041164:2384 -Promise.then -pushWithRedirect @ vue-router.mjs?v=ed041164:2352 -push @ vue-router.mjs?v=ed041164:2278 -install @ vue-router.mjs?v=ed041164:2631 -use @ vue.runtime.esm.js:5190 -initRouter @ uni-h5.es.js:19886 -install @ uni-h5.es.js:19955 -use @ vue.runtime.esm.js:5190 -(anonymous) @ main.uts:16 -main.uts:16 TypeError: Failed to fetch dynamically imported module: http://localhost:5173/pages/mall/admin/homePage/index.uvue?import \ No newline at end of file +signIn result: +AkSupaSignInResult {access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4Y…HNlfQ.5BvQq26lJUF23AEglMvA7EzJZvYN_hrp1Q2JA6o0s-w', refresh_token: 'ehhxnwgvgeyk', expires_at: 1773136334, user: UTSJSONObject2, token_type: 'bearer', …} +login.uvue:175 🔍 开始校验商家端角色 -> UID: 8bdf11be-2838-4d96-8552-0949cde076d4, Email: test19@163.com +login.uvue:216 ❌ 查询角色过程异常: TypeError: res.getData is not a function + at login.uvue:180:23 + at Generator.next () +login.uvue:435 登录错误: Error: 商家身份校验失败,请联系管理员检查用户数据 + at login.uvue:221:9 + at Generator.next ()。 + 打印这些东西 \ No newline at end of file diff --git a/pages/user/login.uvue b/pages/user/login.uvue index cc8f5f12..e88e2f86 100644 --- a/pages/user/login.uvue +++ b/pages/user/login.uvue @@ -166,6 +166,62 @@ const captcha = ref('') const isLoading = ref(false) +/** + * 【核心函数】:登录成功后,多条件校验是否为商家角色 + * 优先级: session_uid (auth_id) -> id -> normalized email + */ +const checkMerchantAccess = async (uid: string, rawEmail: string) : Promise => { + const email = rawEmail.trim().toLowerCase() + console.log(`🔍 开始校验商家端角色 -> UID: ${uid}, Email: ${email}`) + + try { + // 1. 尝试按 auth_id 查询 + let res = await supa.from('ak_users').select('role').eq('auth_id', uid).execute() + let dataArray = res.data + if (Array.isArray(dataArray) && dataArray.length > 0) { + const role = (dataArray[0] as UTSJSONObject).getString('role') + console.log('✅ 按 auth_id 匹配成功,role:', role) + return role === 'merchant' + } + + // 2. 尝试按 id 查询 (兼容老数据) + res = await supa.from('ak_users').select('role').eq('id', uid).execute() + dataArray = res.data + if (Array.isArray(dataArray) && dataArray.length > 0) { + const role = (dataArray[0] as UTSJSONObject).getString('role') + console.log('✅ 按 id 匹配成功,role:', role) + return role === 'merchant' + } + + // 3. 尝试按 email 兜底查询 + if (email !== '') { + res = await supa.from('ak_users').select('role').eq('email', email).execute() + dataArray = res.data + + if (Array.isArray(dataArray) && dataArray.length > 0) { + // 如果按邮箱查出来多条,可能存在脏数据,只取第一条并记录日志 + if (dataArray.length > 1) { + console.error('⚠️ 警告: 按 email 查到多条 ak_users 记录,取第一条校验。Email:', email) + } + const role = (dataArray[0] as UTSJSONObject).getString('role') + console.log('✅ 按 email 匹配成功,role:', role) + return role === 'merchant' + } + } + + console.error('❌ 未能在 ak_users 中找到该用户的任何记录') + // 查无此人,跑出自定义错误以与普通系统报错区分 + throw new Error('NOT_REGISTERED') + } catch (e) { + console.error('❌ 查询角色过程异常:', e) + if (e instanceof Error && e.message === 'NOT_REGISTERED') { + throw new Error('您还没有注册商家端账户,快去注册一个') + } + // 真实的查询异常/RLS异常抛出,防止误会为"未注册" + throw new Error('商家身份校验失败,请联系管理员检查用户数据') + } +} + const codeDisabled = ref(false) const codeText = ref('获取验证码') let codeTimer: number | null = null @@ -260,8 +316,8 @@ const getCode = async () => { const handleLogin = async () => { if (!validateAccount()) return - // 特殊账号处理:admin/admin 直接跳转 - if (account.value === 'admin' && password.value === 'admin') { + // 特殊账号处理:仅在测试模式下保留直登逻辑,生产环境强制校验角色 + if (IS_TEST_MODE && account.value === 'admin' && password.value === 'admin') { setIsLoggedIn(true) const adminProfile = { id: 'admin', @@ -301,13 +357,12 @@ const handleLogin = async () => { if (loginType.value === 0) { const isEmail = account.value.includes('@') if (isEmail) { - // 邮箱 + 密码登录(Supabase Auth) + // 1) 调用 Supabase Auth 登录 const result = await supa.signIn(account.value.trim(), password.value) console.log('signIn result:', result) // 检查登录是否失败 if (result.user == null) { - // 检查是否是邮箱未确认的错误 const rawData = result.raw as UTSJSONObject const errorMsg = rawData?.getString('msg') ?? '' const errorCode = rawData?.getString('error_code') ?? '' @@ -317,12 +372,25 @@ const handleLogin = async () => { errorMsg.includes('邮箱') && errorMsg.includes('确认')) { throw new Error('邮箱未确认,请先检查邮箱并点击确认链接') } else if (errorMsg.includes('Invalid login credentials') || - errorCode === 'invalid_credentials') { + errorCode === 'invalid_credentials' || + errorMsg.includes('Invalid credentials')) { throw new Error('邮箱或密码错误') } else { throw new Error(errorMsg || '登录失败,请重试') } } + + // 2) 【核心逻辑】:执行商家端角色准入校验 + // 优先使用 session user id 来查询数据库里的真实 user 数据兜底校验 + const sessionUser = result.user + let sessionUid = sessionUser?.getString('id') ?? '' + + const isMerchant = await checkMerchantAccess(sessionUid, account.value) + if (!isMerchant) { + await supa.signOut() + logout() + throw new Error('您还没有注册商家端账户,快去注册一个') + } } else { uni.showToast({ title: '手机号密码登录功能开发中', icon: 'none' }) return @@ -332,27 +400,26 @@ const handleLogin = async () => { return } - // 尝试获取/补全用户资料,但失败时不再阻塞登录 + // 更新 store 中的用户资料 try { const profile = await getCurrentUser() - console.log('current user profile:', profile) + console.log('fetch profile success:', profile) } catch (e) { - console.error('获取用户信息失败(忽略,不阻塞登录):', e) + console.error('获取用户信息失败(忽略):', e) } - // 显式保存用户ID到本地存储,确保页面刷新或重启后 SupabaseService 能恢复身份 + // 显式保存用户ID到本地存储 const currentSession = supa.getSession() if (currentSession.user != null) { const uid = currentSession.user?.getString('id') if (uid != null) { uni.setStorageSync('user_id', uid) - console.log('用户ID已保存到本地存储:', uid) } } uni.showToast({ title: '登录成功', icon: 'success' }) - // 即使在测试模式下,点击登录后也执行跳转,确保进入首页 + // 成功跳转逻辑 setTimeout(() => { const pages = getCurrentPages() as any[] const currentPage = pages.length > 0 ? pages[pages.length - 1] : null diff --git a/pages/user/register.uvue b/pages/user/register.uvue index a56f09ad..262e4204 100644 --- a/pages/user/register.uvue +++ b/pages/user/register.uvue @@ -93,7 +93,6 @@