From dd35774400d4e4f0b980f6f11376cbde37b293c5 Mon Sep 17 00:00:00 2001 From: ping <1017253325@qq.com> Date: Thu, 16 Jul 2026 11:17:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B3=A8=E5=86=8C=E7=94=A8=E6=88=B7=E6=94=B9?= =?UTF-8?q?=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- b/customer/registerUser.dspy | 43 ++++- b/docs/user_tenant_refactor.md | 327 +++++++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 b/docs/user_tenant_refactor.md diff --git a/b/customer/registerUser.dspy b/b/customer/registerUser.dspy index 1d754e9..9f78beb 100644 --- a/b/customer/registerUser.dspy +++ b/b/customer/registerUser.dspy @@ -108,6 +108,22 @@ async def sync_cn_ai_user(userid=None, orgid=None, username=None, name=None, ema 'msg': f"sync_cn_ai_user{userid}同步用户失败: {e}" } +async def get_register_tenant_orgid(sor, ns): + domain_name = ns.get('domain_name') + if (not domain_name) and ns.get('url_link'): + domain_name = ns.get('url_link').split("//")[1].split("/")[0] + if domain_name: + domain_name = domain_name.strip().replace('https://', '').replace('http://', '').replace('/', '') + if 'localhost' in domain_name: + domain_name = 'dev.opencomputing.cn' + reseller = await sor.R('reseller', {'domain_name': domain_name, 'del_flg': '0'}) + if len(reseller) >= 1: + return reseller[0]['orgid'] + org = await sor.R('organization', {'org_type': '0', 'del_flg': '0'}) + if len(org) >= 1: + return org[0]['id'] + return None + async def registerUser(ns): """ 用户注册 @@ -116,25 +132,30 @@ async def registerUser(ns): db = DBPools() async with db.sqlorContext('kboss') as sor: if ns: + tenant_orgid = await get_register_tenant_orgid(sor, ns) + if not tenant_orgid: + return {'status': False, 'msg': '未找到当前域名所属租户'} + + # 用户名没有 则用手机号作为用户名 + if not ns.get('username'): + ns['username'] = ns['mobile'] + if ns.get('username'): - userns = {'username': ns['username']} - isuser = await sor.R('users', userns) + user_sql = """select id from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns['username'], tenant_orgid) + isuser = await sor.sqlExe(user_sql, {}) if len(isuser) >= 1: return {'status': False, 'msg': '用户名已注册'} if ns.get('email'): - useremai = {'email': ns['email']} - isuser = await sor.R('users', useremai) + email_sql = """select id from users where email = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns['email'], tenant_orgid) + isuser = await sor.sqlExe(email_sql, {}) if len(isuser) >= 1: return {'status': False, 'msg': '该邮箱已注册'} - usermobile = {'mobile':ns['mobile']} - isuser = await sor.R('users', usermobile) + mobile_sql = """select id from users where mobile = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns['mobile'], tenant_orgid) + isuser = await sor.sqlExe(mobile_sql, {}) if len(isuser) >= 1: return {'status': False, 'msg': '该手机号已注册'} - # 用户名没有 则用手机号作为用户名 - if not ns.get('username'): - ns['username'] = ns['mobile'] # if ns.get('password'): # # 至少8位,包含大小写字母、特殊字符、数字 @@ -188,7 +209,9 @@ async def registerUser(ns): userid = ns['id'] ns['orgid'] = orgid ns['password'] = password_encode(ns['password']) + ns['tenant_orgid'] = tenant_orgid await sor.C('users', ns) + ns.pop('tenant_orgid', None) listrole = ['管理员', '客户'] for i in listrole: role = await sor.R('role', {'role': i, 'org_type': ns['org_type']}) @@ -274,7 +297,9 @@ async def registerUser(ns): ns['orgid'] = org_id # 新增用户信息 + ns['tenant_orgid'] = tenant_orgid await sor.C('users', ns) + ns.pop('tenant_orgid', None) # 新增用户角色信息 listrole = ['管理员', '客户'] diff --git a/b/docs/user_tenant_refactor.md b/b/docs/user_tenant_refactor.md new file mode 100644 index 0000000..4d22fee --- /dev/null +++ b/b/docs/user_tenant_refactor.md @@ -0,0 +1,327 @@ +# 用户按租户隔离改造规划 + +## 背景 + +当前用户注册时,手机号和用户名按 `users` 全表做唯一性判断。两个不同域名下,如果使用同一个手机号注册,会触发“该手机号已注册”。登录时也存在类似问题:手机号、用户名查询没有先限定当前域名所属租户,若数据库中出现跨域名同手机号用户,可能查到错误用户。 + +本次改造目标是支持“不同域名/站点下允许同手机号注册和登录”,同时不影响余额、计费、扣费、结算链路。 + +## 核心原则 + +1. 前端不传 `tenant_orgid`。 +2. 后端根据当前访问域名解析 `tenant_orgid`。 +3. 注册、登录、找回密码、验证码等入口按 `tenant_orgid + 手机号/用户名` 查询用户。 +4. 不改 `users.id`、`users.orgid`、`organization.id`、`customer.customerid`。 +5. 不改账务核心表:`account`、`ledger`、`customer`、`bz_order`、`order_goods` 等。 +6. 当前数据库暂时无法新增 INDEX/UNIQUE,唯一性由代码逻辑保证。 + +## tenant_orgid 来源 + +`tenant_orgid` 表示当前站点所属租户机构 ID,由后端通过域名解析。 + +解析规则: + +1. 从 `domain_name` 获取域名;如果只有 `url_link`,从 `url_link` 中解析域名。 +2. 查询 `reseller`: + +```sql +select orgid +from reseller +where domain_name = '当前域名' + and del_flg = '0' +limit 1; +``` + +1. 如果查到,说明当前是分销商站点: + +```text +tenant_orgid:reseller.orgid +``` + +1. 如果查不到,说明当前是主站,查询主站机构: + +```sql +select id +from organization +where org_type = '0' + and del_flg = '0' +limit 1; +``` + +主站场景: + +```text +tenant_orgid:主站机构id +``` + + + +## 数据库改造 + +只改 `users` 表,新增字段,不新增索引。 + +```sql +ALTER TABLE users +ADD COLUMN tenant_orgid varchar(32) DEFAULT NULL COMMENT '注册/登录所属租户机构id'; +``` + + + +### 历史数据回填 + +客户用户通常满足:`users.orgid` 是客户机构,`organization.parentid` 是该客户所属分销商或主站机构。 + +```sql +UPDATE users u +LEFT JOIN organization o ON u.orgid = o.id +SET u.tenant_orgid = o.parentid +WHERE u.tenant_orgid IS NULL + AND o.parentid IS NOT NULL; + +# 注意管理员角色用户 +``` + +分销商自身用户满足:`users.orgid = reseller.orgid`。 + +```sql +UPDATE users u +INNER JOIN reseller r ON u.orgid = r.orgid +SET u.tenant_orgid = r.orgid +WHERE u.tenant_orgid IS NULL; +``` + +主站/内部用户兜底回填为主站机构。 + +```sql +UPDATE users u +SET u.tenant_orgid = ( + SELECT id FROM organization WHERE org_type = '0' AND del_flg = '0' LIMIT 1 +) +WHERE u.tenant_orgid IS NULL; +``` + +回填后检查: + +```sql +select id, username, mobile, orgid, tenant_orgid +from users +where tenant_orgid is null; +``` + + + +## 注册接口改造 + +文件: + +```text +b/customer/registerUser.dspy +``` + +改造点: + +1. 注册开始先解析 `tenant_orgid`。 +2. 手机号重复校验从全局校验: + +```text +mobile:手机号 +``` + +改成租户内校验: + +```sql +select id +from users +where mobile = '手机号' + and tenant_orgid = '当前tenant_orgid' + and del_flg = '0' +limit 1; +``` + +1. 用户名重复校验也加 `tenant_orgid`。 +2. 邮箱重复校验建议也加 `tenant_orgid`。 +3. 新增 `users` 时写入: + +```text +tenant_orgid:当前tenant_orgid +``` + +1. 如果未传 `username`,仍可用手机号作为用户名,但唯一范围变为当前租户内。 + + + +## 登录接口改造 + +文件: + +```text +b/user/loginUser.dspy +b/user/logintype.dspy +``` + +改造点: + +1. 登录开始先解析 `tenant_orgid`。 +2. 手机号验证码登录按当前租户查询: + +```sql +select * +from users +where mobile = '手机号' + and tenant_orgid = '当前tenant_orgid' + and del_flg = '0' +limit 1; +``` + +1. 用户名密码登录按当前租户查询: + +```sql +select * +from users +where username = '用户名' + and password = '加密后密码' + and tenant_orgid = '当前tenant_orgid' + and del_flg = '0' +limit 1; +``` + +1. 如果使用手机号作为 `username`,也必须加 `tenant_orgid`。 +2. 原有域名归属判断可以保留,但查用户时必须先限定租户,避免查到其他域名同手机号用户。 + + + +## 登录失败锁定改造 + +文件: + +```text +b/user/logintype.dspy +``` + +当前 `login_fail_log` 按 `user_name` 记录失败次数。跨域名同手机号后,A 域名失败可能锁定 B 域名同手机号。 + +暂不改表时,建议使用拼接 key: + +```text +login_fail_key = tenant_orgid + ':' + username_or_mobile +``` + +然后传给: + +```text +handle_login_failed(login_fail_key) +check_login_allowed(login_fail_key) +``` + + + +## 找回密码和验证码改造 + +建议同步改造: + +```text +b/customer/forgotPassword.dspy +b/user/retrievecode.dspy +b/user/mobilecode.dspy +``` + +原因:这些接口也会通过手机号或用户名全局查 `users`。改造后应先解析 `tenant_orgid`,再按当前租户查用户。 + +查询条件示例: + +```sql +select * +from users +where mobile = '手机号' + and tenant_orgid = '当前tenant_orgid' + and del_flg = '0' +limit 1; +``` + + + +## 其他代码排查 + +全局排查这些模式: + +```text +sor.R('users', {'mobile': ...}) +sor.R('users', {'username': ...}) +``` + +处理原则: + +1. 注册、登录、找回密码、验证码:必须加 `tenant_orgid`。 +2. 前台用户入口:优先加 `tenant_orgid`。 +3. 平台后台管理查询:如果需要跨租户管理,可以不加;如果是当前站点后台,则应加。 +4. 账务、订单、结算:通常按 `userid`、`orgid`、`customerid` 查询,不需要因手机号隔离改造。 + + + +## 对余额、计费、扣费、结算的影响 + +按本规划实施,理论上不影响账务链路。 + +原因是账务核心链路依赖以下字段: + +```text +users.id +users.orgid +organization.id +customer.customerid +account.orgid +bz_order.customerid +bz_order.userid +``` + +本次只新增 `users.tenant_orgid`,并改变登录/注册时如何定位用户,不改变已有账务关联 ID。 + +## 并发风险 + +由于当前不能新增数据库唯一索引,租户内手机号唯一性只能由代码保证。 + +风险: + +```text +两个请求同时注册同一 tenant_orgid + mobile,可能同时通过注册前校验。 +``` + +降低风险方案: + +1. 注册前查询一次。 +2. 插入前尽量保持注册逻辑短。 +3. 插入后再查询一次同 `tenant_orgid + mobile + del_flg = 0` 的用户数量。 +4. 如果发现重复,返回异常并人工处理。 + +后续如果数据库允许新增唯一约束,再补数据库唯一约束。 + +## 建议实施顺序 + +1. 给 `users` 增加 `tenant_orgid` 字段。 +2. 回填历史用户 `tenant_orgid`。 +3. 抽出或复制一段 `tenant_orgid` 解析逻辑到注册/登录相关接口。 +4. 改造 `b/customer/registerUser.dspy`。 +5. 改造 `b/user/logintype.dspy`。 +6. 改造 `b/user/loginUser.dspy`。 +7. 改造 `b/customer/forgotPassword.dspy`。 +8. 改造 `b/user/retrievecode.dspy`。 +9. 改造 `b/user/mobilecode.dspy`。 +10. 全局排查剩余按 `mobile`、`username` 查 `users` 的代码。 +11. 测试主站注册登录。 +12. 测试分销商 A 注册登录。 +13. 测试分销商 B 使用同手机号注册登录。 +14. 测试找回密码、验证码登录、用户名密码登录。 +15. 测试余额、下单、扣费、结算查询链路是否仍按原 `orgid/customerid` 工作。 + + + +## 验收标准 + +1. 同一域名下,同手机号不能重复注册。 +2. 不同域名下,同手机号可以分别注册。 +3. 不同域名下,同手机号登录时只登录当前域名对应用户。 +4. 登录失败锁定只影响当前域名对应用户。 +5. 找回密码只影响当前域名对应用户。 +6. 原有用户能正常登录。 +7. 订单、余额、扣费、结算不串账。 +