From d8eea22b894c6a6edec5c7cbebc8a5c95e3a85e2 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 30 Jun 2026 15:35:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20tenant=20=E5=A4=9A=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E5=9F=9F=E5=90=8D=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tenant_domain 表: domain → resellerid 映射, UNIQUE(domain) - init.py: get_domain_by_host() 根据域名查找分销商 - index.ui: Tabular CRUD 管理界面 - load_path.py: RBAC 权限注册 --- .gitignore | 9 +++ README.md | 41 ++++++++++++- json/tenant_domain.json | 26 +++++++++ models/tenant_domain.json | 92 ++++++++++++++++++++++++++++++ pyproject.toml | 16 ++++++ scripts/load_path.py | 37 ++++++++++++ tenant/__init__.py | 3 + tenant/init.py | 25 ++++++++ wwwroot/index.ui | 117 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 json/tenant_domain.json create mode 100644 models/tenant_domain.json create mode 100644 pyproject.toml create mode 100644 scripts/load_path.py create mode 100644 tenant/__init__.py create mode 100644 tenant/init.py create mode 100644 wwwroot/index.ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5eec357 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.swp +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.DS_Store + +# CRUD auto-generated directories +wwwroot/tenant_domain/ diff --git a/README.md b/README.md index 3aef69c..cb50da7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,41 @@ -# tenant +# Tenant 模块 +多租户域名管理。每个分销商可申请独立域名,客户在域名下注册即自动绑定为该分销商的客户。 + +## 数据模型 + +### tenant_domain(租户域名) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | VARCHAR(32) | 主键 | +| domain | VARCHAR(200) | 域名 UNIQUE | +| resellerid | VARCHAR(32) | 分销商机构ID | +| orgid | VARCHAR(32) | 所属平台机构ID | +| status | VARCHAR(10) | active/inactive | +| created_at | TIMESTAMP | 创建时间 | +| updated_at | TIMESTAMP | 更新时间 | + +## 集成点 + +1. **注册自动绑定**: 用户注册时根据 `request.host` 查询 `tenant_domain` → 找到 `resellerid` → 写入 `discount_customer_bind` +2. **RBAC**: `load_path.py` 注册所有路径 +3. **Sage 集成**: 需要在 `app/sage.py` import + load, `build.sh` install loop, `global_menu.ui` menu entry + +## DB 迁移 + +```sql +CREATE TABLE tenant_domain ( + id VARCHAR(32) NOT NULL, + domain VARCHAR(200) NOT NULL, + resellerid VARCHAR(32) NOT NULL, + orgid VARCHAR(32) NOT NULL, + status VARCHAR(10) NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE INDEX idx_tenant_domain_domain (domain), + INDEX idx_tenant_domain_reseller (resellerid), + INDEX idx_tenant_domain_orgid (orgid) +); +``` diff --git a/json/tenant_domain.json b/json/tenant_domain.json new file mode 100644 index 0000000..6a8929b --- /dev/null +++ b/json/tenant_domain.json @@ -0,0 +1,26 @@ +{ + "tblname": "tenant_domain", + "title": "租户域名管理", + "params": { + "sortby": "created_at desc", + "logined_userorgid": "orgid", + "browserfields": { + "exclouded": ["id"], + "alters": { + "status": { + "uitype": "code", + "data": [ + {"value": "active", "text": "启用"}, + {"value": "inactive", "text": "停用"} + ] + } + } + }, + "editexclouded": ["id", "created_at", "updated_at"], + "editable": { + "new_data_url": "{{entire_url('../api/tenant_domain_create.dspy')}}", + "update_data_url": "{{entire_url('../api/tenant_domain_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/tenant_domain_delete.dspy')}}" + } + } +} diff --git a/models/tenant_domain.json b/models/tenant_domain.json new file mode 100644 index 0000000..bf40cb5 --- /dev/null +++ b/models/tenant_domain.json @@ -0,0 +1,92 @@ +{ + "summary": [ + { + "name": "tenant_domain", + "title": "租户域名", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "domain", + "title": "域名", + "type": "str", + "length": 200, + "nullable": "no" + }, + { + "name": "resellerid", + "title": "分销商机构ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "orgid", + "title": "所属平台机构ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 10, + "nullable": "no", + "default": "active" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "nullable": "no" + } + ], + "indexes": [ + { + "name": "idx_tenant_domain_domain", + "idxtype": "unique", + "idxfields": ["domain"] + }, + { + "name": "idx_tenant_domain_reseller", + "idxtype": "index", + "idxfields": ["resellerid"] + }, + { + "name": "idx_tenant_domain_orgid", + "idxtype": "index", + "idxfields": ["orgid"] + } + ], + "codes": [ + { + "field": "resellerid", + "table": "organization", + "valuefield": "id", + "textfield": "orgname" + }, + { + "field": "status", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='domain_status'" + } + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d8956bc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tenant" +version = "1.0.0" +description = "多租户域名管理模块 — 分销商独立域名,客户域名注册自动归属" +requires-python = ">=3.8" +dependencies = [ + "sqlor", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["tenant*"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..2528bdd --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""RBAC 权限注册 — tenant 模块""" +import os, sys, subprocess + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +MOD = "tenant" + +for candidate in [ + os.path.join(SCRIPT_DIR, "..", ".."), + os.path.expanduser("~/repos/sage"), + os.path.expanduser("~/sage"), +]: + if os.path.isdir(os.path.join(candidate, "wwwroot")): + SAGE_ROOT = candidate + break +else: + print("ERROR: Sage root not found") + sys.exit(1) + +PATHS_LOGINED = [ + f"/{MOD}", + f"/{MOD}/index.ui", + f"/{MOD}/tenant_domain", + f"/{MOD}/tenant_domain/index.ui", + f"/{MOD}/tenant_domain/get_tenant_domain.dspy", + f"/{MOD}/tenant_domain/add_tenant_domain.dspy", + f"/{MOD}/tenant_domain/update_tenant_domain.dspy", + f"/{MOD}/tenant_domain/delete_tenant_domain.dspy", +] + +def run(cmd): + subprocess.run(cmd, shell=True, cwd=SAGE_ROOT) + +for path in PATHS_LOGINED: + run(f'./py3/bin/python set_role_perm.py "logined" "{path}"') + +print(f"tenant: {len(PATHS_LOGINED)} RBAC paths registered") diff --git a/tenant/__init__.py b/tenant/__init__.py new file mode 100644 index 0000000..27dbde4 --- /dev/null +++ b/tenant/__init__.py @@ -0,0 +1,3 @@ +from .init import load_tenant + +__all__ = ['load_tenant'] diff --git a/tenant/init.py b/tenant/init.py new file mode 100644 index 0000000..f651b64 --- /dev/null +++ b/tenant/init.py @@ -0,0 +1,25 @@ +""" +Tenant module — 多租户域名管理 +每个分销商可申请独立域名,客户在其域名注册即绑定为该分销商的客户。 +""" +from ahserver.serverenv import ServerEnv +from sqlor.dbpools import get_sor_context + +MODULE_NAME = "tenant" + + +async def get_domain_by_host(request, host): + """根据请求域名查找对应的分销商""" + env = request._run_ns + domain = host.split(':')[0] + async with get_sor_context(env, 'tenant') as sor: + sql = """SELECT resellerid, orgid FROM tenant_domain + WHERE domain = ${domain}$ AND status = 'active' + LIMIT 1""" + recs = await sor.sqlExe(sql, {'domain': domain}) + return recs[0] if recs else None + + +def load_tenant(): + env = ServerEnv() + env.get_domain_by_host = get_domain_by_host diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..9ec8143 --- /dev/null +++ b/wwwroot/index.ui @@ -0,0 +1,117 @@ +{ + "widgettype": "VBox", + "options": { + "width": "100%", + "height": "100%", + "padding": "16px", + "gap": "12px" + }, + "subwidgets": [ + { + "widgettype": "Text", + "options": { + "text": "租户域名管理", + "fontSize": "22px", + "fontWeight": "700", + "color": "#F1F5F9" + } + }, + { + "widgettype": "Text", + "options": { + "text": "管理各分销商的独立域名,客户通过域名注册后自动归属到对应分销商", + "fontSize": "13px", + "color": "#94A3B8" + } + }, + { + "widgettype": "Tabular", + "id": "domain_table", + "options": { + "width": "100%", + "height": "100%", + "css": "card", + "title": "域名列表", + "data_url": "{{entire_url('/tenant/tenant_domain')}}", + "data_method": "GET", + "page_rows": 20, + "toolbar": { + "tools": [ + {"name": "add", "label": "新增域名"}, + {"name": "edit", "label": "编辑", "selected_row": true}, + {"name": "delete", "label": "删除", "selected_row": true} + ] + }, + "row_options": { + "browserfields": { + "exclouded": ["id"], + "alters": { + "status": { + "uitype": "code", + "data": [ + {"value": "active", "text": "启用"}, + {"value": "inactive", "text": "停用"} + ] + }, + "resellerid": { + "uitype": "code", + "valueField": "resellerid", + "textField": "resellerid_text" + } + }, + "cwidths": { + "domain": 20, + "resellerid": 15, + "status": 6, + "created_at": 12 + } + }, + "fields": [ + {"name": "domain", "type": "str", "length": 200, "cwidth": 20, "uitype": "str", "label": "域名"}, + {"name": "resellerid", "type": "str", "length": 32, "cwidth": 15, "uitype": "code", "label": "分销商"}, + {"name": "status", "type": "str", "length": 10, "cwidth": 6, "uitype": "code", "label": "状态"}, + {"name": "created_at", "type": "timestamp", "cwidth": 12, "uitype": "timestamp", "label": "创建时间"} + ] + } + }, + "binds": [ + { + "wid": "self", + "event": "add", + "actiontype": "urlwidget", + "target": "PopupWindow", + "popup_options": { + "title": "新增域名", + "dismiss_events": ["cancel", "submited"] + }, + "options": { + "url": "{{entire_url('/tenant/tenant_domain')}}" + } + }, + { + "wid": "self", + "event": "edit", + "actiontype": "urlwidget", + "target": "PopupWindow", + "popup_options": { + "title": "编辑域名", + "dismiss_events": ["cancel", "submited"] + }, + "options": { + "url": "{{entire_url('/tenant/tenant_domain')}}" + } + }, + { + "wid": "self", + "event": "delete", + "actiontype": "method", + "target": "self", + "method": "delete_rows", + "options": { + "url": "{{entire_url('/tenant/tenant_domain')}}" + } + } + ] + } + ] +}