feat: tenant 多租户域名管理模块

- tenant_domain 表: domain → resellerid 映射, UNIQUE(domain)
- init.py: get_domain_by_host() 根据域名查找分销商
- index.ui: Tabular CRUD 管理界面
- load_path.py: RBAC 权限注册
This commit is contained in:
yumoqing 2026-06-30 15:35:08 +08:00
parent d20b97fad1
commit d8eea22b89
9 changed files with 365 additions and 1 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
*.swp
__pycache__/
*.pyc
*.pyo
*.egg-info/
.DS_Store
# CRUD auto-generated directories
wwwroot/tenant_domain/

View File

@ -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)
);
```

26
json/tenant_domain.json Normal file
View File

@ -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')}}"
}
}
}

92
models/tenant_domain.json Normal file
View File

@ -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'"
}
]
}

16
pyproject.toml Normal file
View File

@ -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*"]

37
scripts/load_path.py Normal file
View File

@ -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")

3
tenant/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .init import load_tenant
__all__ = ['load_tenant']

25
tenant/init.py Normal file
View File

@ -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

117
wwwroot/index.ui Normal file
View File

@ -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')}}"
}
}
]
}
]
}