uc-integration 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uc_integration/__init__.py +64 -0
- uc_integration/backend/__init__.py +42 -0
- uc_integration/backend/controller/SsoAuthController.py +47 -0
- uc_integration/backend/controller/UcDataSyncController.py +62 -0
- uc_integration/backend/controller/__init__.py +49 -0
- uc_integration/backend/deps.py +27 -0
- uc_integration/backend/schema/SsoAuthSchema.py +32 -0
- uc_integration/backend/schema/__init__.py +0 -0
- uc_integration/backend/service/SsoAuthService.py +301 -0
- uc_integration/backend/service/UcDataSyncService.py +260 -0
- uc_integration/backend/service/UcStoreScopeService.py +245 -0
- uc_integration/backend/service/UserCenterClient.py +92 -0
- uc_integration/backend/service/__init__.py +0 -0
- uc_integration-0.1.0.dist-info/METADATA +112 -0
- uc_integration-0.1.0.dist-info/RECORD +17 -0
- uc_integration-0.1.0.dist-info/WHEEL +5 -0
- uc_integration-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# uc_integration — 用户中心 SSO / 主数据同步(Fred 子系统可安装模块)
|
|
2
|
+
"""安装后可直接 import;在 Fred 下通过 modules/ symlink 自动注册蓝图。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import sys
|
|
7
|
+
import types
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
__version__ = '0.1.0'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ensure_fred_style_aliases() -> None:
|
|
14
|
+
"""将 backend/{service,schema,...} 注册为 uc_integration.*,并预加载 .py 文件。
|
|
15
|
+
|
|
16
|
+
对齐 Fred `_register_backend_submodules`:仅建空包会导致
|
|
17
|
+
`from uc_integration.schema.X import Y` 在部分加载顺序下失败。
|
|
18
|
+
"""
|
|
19
|
+
backend = Path(__file__).resolve().parent / 'backend'
|
|
20
|
+
if not backend.is_dir():
|
|
21
|
+
return
|
|
22
|
+
for subdir in backend.iterdir():
|
|
23
|
+
if not subdir.is_dir() or subdir.name.startswith('_') or subdir.name == 'controller':
|
|
24
|
+
continue
|
|
25
|
+
pkg_name = f'uc_integration.{subdir.name}'
|
|
26
|
+
if pkg_name not in sys.modules:
|
|
27
|
+
mod = types.ModuleType(pkg_name)
|
|
28
|
+
mod.__path__ = [str(subdir)]
|
|
29
|
+
mod.__file__ = str(subdir / '__init__.py')
|
|
30
|
+
mod.__package__ = pkg_name
|
|
31
|
+
sys.modules[pkg_name] = mod
|
|
32
|
+
setattr(sys.modules[__name__], subdir.name, mod)
|
|
33
|
+
else:
|
|
34
|
+
mod = sys.modules[pkg_name]
|
|
35
|
+
if not getattr(mod, '__path__', None):
|
|
36
|
+
mod.__path__ = [str(subdir)]
|
|
37
|
+
|
|
38
|
+
init_file = subdir / '__init__.py'
|
|
39
|
+
if init_file.exists() and not getattr(mod, '__file__', None):
|
|
40
|
+
mod.__file__ = str(init_file)
|
|
41
|
+
|
|
42
|
+
for py_file in subdir.glob('*.py'):
|
|
43
|
+
if py_file.name.startswith('__'):
|
|
44
|
+
continue
|
|
45
|
+
file_mod_name = f'{pkg_name}.{py_file.stem}'
|
|
46
|
+
if file_mod_name in sys.modules:
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
spec = importlib.util.spec_from_file_location(file_mod_name, py_file)
|
|
50
|
+
if not spec or not spec.loader:
|
|
51
|
+
continue
|
|
52
|
+
file_mod = importlib.util.module_from_spec(spec)
|
|
53
|
+
sys.modules[file_mod_name] = file_mod
|
|
54
|
+
spec.loader.exec_module(file_mod)
|
|
55
|
+
setattr(mod, py_file.stem, file_mod)
|
|
56
|
+
except Exception:
|
|
57
|
+
sys.modules.pop(file_mod_name, None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_ensure_fred_style_aliases()
|
|
61
|
+
|
|
62
|
+
from .backend import uc_integration, get_admin_role, get_subsystem_code # noqa: E402
|
|
63
|
+
|
|
64
|
+
__all__ = ['uc_integration', 'get_admin_role', 'get_subsystem_code', '__version__']
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# -*- coding: utf-8
|
|
2
|
+
"""蓝图入口:url_prefix 由 UC_SUBSYSTEM_CODE 动态推导。"""
|
|
3
|
+
from flask_smorest import Blueprint
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_subsystem_code() -> str:
|
|
7
|
+
"""子系统编码:优先 Config.UC_SUBSYSTEM_CODE,否则 DEFAULT_MODULE。"""
|
|
8
|
+
try:
|
|
9
|
+
from flask import current_app, has_app_context
|
|
10
|
+
if has_app_context():
|
|
11
|
+
code = (current_app.config.get('UC_SUBSYSTEM_CODE') or '').strip()
|
|
12
|
+
if code:
|
|
13
|
+
return code
|
|
14
|
+
return (current_app.config.get('DEFAULT_MODULE') or 'app').strip()
|
|
15
|
+
except Exception:
|
|
16
|
+
pass
|
|
17
|
+
try:
|
|
18
|
+
from common.config.Config import Config
|
|
19
|
+
code = (getattr(Config, 'UC_SUBSYSTEM_CODE', None) or '').strip()
|
|
20
|
+
if code:
|
|
21
|
+
return code
|
|
22
|
+
return (getattr(Config, 'DEFAULT_MODULE', None) or 'app').strip()
|
|
23
|
+
except Exception:
|
|
24
|
+
return 'app'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_admin_role() -> str:
|
|
28
|
+
"""从 DEFAULT_MODULE 推导管理员角色:{subsystem}_admin。"""
|
|
29
|
+
from flask import current_app
|
|
30
|
+
subsystem = (current_app.config.get('DEFAULT_MODULE') or get_subsystem_code()).strip()
|
|
31
|
+
return f'{subsystem}_admin'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
uc_integration = Blueprint(
|
|
35
|
+
'uc_integration',
|
|
36
|
+
__name__,
|
|
37
|
+
url_prefix=f'/{get_subsystem_code()}/uc_integration',
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# SSO:/{UC_SUBSYSTEM_CODE}/uc_integration/auth/sso/*
|
|
41
|
+
# 同步:/{UC_SUBSYSTEM_CODE}/uc_integration/internal/uc-sync
|
|
42
|
+
# controller 由 Fred 扫描 modules/uc_integration/backend/controller/
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# -*- coding: utf-8
|
|
2
|
+
"""SSO 换票接口:UC code / accessToken → 本地 JWT。"""
|
|
3
|
+
from flask import abort, current_app
|
|
4
|
+
from flask.views import MethodView
|
|
5
|
+
from flask_babelplus import gettext
|
|
6
|
+
|
|
7
|
+
from uc_integration import uc_integration
|
|
8
|
+
from uc_integration.schema.SsoAuthSchema import (
|
|
9
|
+
SsoExchangeBodySchema,
|
|
10
|
+
SsoRefreshBodySchema,
|
|
11
|
+
SsoExchangeResponseSchema,
|
|
12
|
+
)
|
|
13
|
+
from uc_integration.service.SsoAuthService import SsoAuthService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@uc_integration.route('/auth/sso/config')
|
|
17
|
+
class SsoConfigController(MethodView):
|
|
18
|
+
def get(self):
|
|
19
|
+
"""子系统前端读取登录方式(无需登录)。"""
|
|
20
|
+
mode = (current_app.config.get('SSO_LOGIN_MODE') or 'local').strip().lower()
|
|
21
|
+
if mode not in ('uc', 'local'):
|
|
22
|
+
mode = 'local'
|
|
23
|
+
base_url = (current_app.config.get('USER_CENTER_BASE_URL') or '').strip().rstrip('/')
|
|
24
|
+
if not base_url:
|
|
25
|
+
abort(500, description=gettext('用户中心地址未配置(USER_CENTER_BASE_URL)'))
|
|
26
|
+
return {
|
|
27
|
+
'loginMode': mode,
|
|
28
|
+
'userCenterBaseUrl': base_url,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@uc_integration.route('/auth/sso/exchange')
|
|
33
|
+
class SsoExchangeController(MethodView):
|
|
34
|
+
@uc_integration.arguments(SsoExchangeBodySchema)
|
|
35
|
+
@uc_integration.response(200, SsoExchangeResponseSchema)
|
|
36
|
+
def post(self, args):
|
|
37
|
+
"""用 UC 授权码或 accessToken 兑换本子系统 JWT(服务端调 UC)。"""
|
|
38
|
+
return SsoAuthService().sso_exchange(args)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@uc_integration.route('/auth/sso/refresh')
|
|
42
|
+
class SsoRefreshController(MethodView):
|
|
43
|
+
@uc_integration.arguments(SsoRefreshBodySchema)
|
|
44
|
+
@uc_integration.response(200, SsoExchangeResponseSchema)
|
|
45
|
+
def post(self, args):
|
|
46
|
+
"""UC refreshToken 续期并重新签发本地 JWT。"""
|
|
47
|
+
return SsoAuthService().sso_refresh(args)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
"""
|
|
3
|
+
UC 主数据同步接口
|
|
4
|
+
|
|
5
|
+
接收 user-center 推送的用户、组织架构、门店等主数据,保存到本地数据库。
|
|
6
|
+
- POST /{subsystem}/uc_integration/internal/uc-sync 接收推送
|
|
7
|
+
- GET /{subsystem}/uc_integration/internal/uc-sync 健康检查
|
|
8
|
+
|
|
9
|
+
安全策略:
|
|
10
|
+
- 仅允许本机(127.0.0.1 / ::1)访问
|
|
11
|
+
- 校验请求头 X-UC-Internal-Secret 与本地 USER_CENTER_INTERNAL_SECRET 一致
|
|
12
|
+
"""
|
|
13
|
+
from flask import abort, current_app, request
|
|
14
|
+
from flask.views import MethodView
|
|
15
|
+
from flask_babelplus import gettext
|
|
16
|
+
from functools import wraps
|
|
17
|
+
|
|
18
|
+
from uc_integration import uc_integration
|
|
19
|
+
from uc_integration.service.UcDataSyncService import UcDataSyncService
|
|
20
|
+
|
|
21
|
+
_LOCALHOST_ADDRS = frozenset({'127.0.0.1', '::1'})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _verify_local_internal_access() -> None:
|
|
25
|
+
"""校验请求来自本机且携带正确的内部密钥。"""
|
|
26
|
+
remote = (request.remote_addr or '').strip()
|
|
27
|
+
if remote not in _LOCALHOST_ADDRS:
|
|
28
|
+
abort(403, description=gettext('仅允许本机访问'))
|
|
29
|
+
|
|
30
|
+
expected = (current_app.config.get('USER_CENTER_INTERNAL_SECRET') or '').strip()
|
|
31
|
+
if not expected:
|
|
32
|
+
abort(501, description=gettext('内部同步密钥未配置(USER_CENTER_INTERNAL_SECRET)'))
|
|
33
|
+
|
|
34
|
+
provided = (request.headers.get('X-UC-Internal-Secret') or '').strip()
|
|
35
|
+
if provided != expected:
|
|
36
|
+
abort(403, description=gettext('无权访问同步接口'))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def uc_sync_local_only(f):
|
|
40
|
+
"""装饰器:限制仅本机 + 正确密钥可访问。"""
|
|
41
|
+
@wraps(f)
|
|
42
|
+
def wrapper(*args, **kwargs):
|
|
43
|
+
_verify_local_internal_access()
|
|
44
|
+
return f(*args, **kwargs)
|
|
45
|
+
return wrapper
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@uc_integration.route('/internal/uc-sync')
|
|
49
|
+
class UcDataSyncController(MethodView):
|
|
50
|
+
|
|
51
|
+
@uc_sync_local_only
|
|
52
|
+
@uc_integration.response(200)
|
|
53
|
+
def post(self):
|
|
54
|
+
"""接收 UC 推送的用户、组织、门店主数据(仅本机)。"""
|
|
55
|
+
payload = request.get_json(silent=True) or {}
|
|
56
|
+
return UcDataSyncService().sync(payload)
|
|
57
|
+
|
|
58
|
+
@uc_sync_local_only
|
|
59
|
+
@uc_integration.response(200)
|
|
60
|
+
def get(self):
|
|
61
|
+
"""同步接口健康检查(仅本机),返回各镜像表有效记录数。"""
|
|
62
|
+
return UcDataSyncService.health_check()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
"""业务 API 认证装饰器:仅认本地 JWT。"""
|
|
3
|
+
from functools import wraps
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from flask import abort, g, request
|
|
7
|
+
from flask_babelplus import gettext
|
|
8
|
+
from flask_jwt_extended import decode_token
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _extract_bearer_token() -> str:
|
|
12
|
+
auth = request.headers.get('Authorization') or ''
|
|
13
|
+
if auth.lower().startswith('bearer '):
|
|
14
|
+
return auth[7:].strip()
|
|
15
|
+
return ''
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _try_admin_jwt(token: str) -> bool:
|
|
19
|
+
from uc_integration.backend import get_admin_role
|
|
20
|
+
try:
|
|
21
|
+
decoded = decode_token(token)
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
roles = decoded.get('role')
|
|
25
|
+
admin_role = get_admin_role()
|
|
26
|
+
if isinstance(roles, list):
|
|
27
|
+
if 'uc_integration' not in roles and admin_role not in roles:
|
|
28
|
+
return False
|
|
29
|
+
elif roles not in ('uc_integration', admin_role):
|
|
30
|
+
return False
|
|
31
|
+
identity = json.loads(decoded['sub'])
|
|
32
|
+
g.uc_user_id = identity.get('username') or str(identity.get('id'))
|
|
33
|
+
g.uc_identity = identity
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def public_api(f):
|
|
38
|
+
"""业务 API 仅认本地 JWT(uc_integration 或子系统管理员角色)。"""
|
|
39
|
+
|
|
40
|
+
@wraps(f)
|
|
41
|
+
def wrapper(*args, **kwargs):
|
|
42
|
+
token = _extract_bearer_token()
|
|
43
|
+
if not token:
|
|
44
|
+
abort(401, description=gettext('请先登录'))
|
|
45
|
+
if not _try_admin_jwt(token):
|
|
46
|
+
abort(401, description=gettext('请先登录'))
|
|
47
|
+
return f(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
return wrapper
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# -*- coding: utf-8
|
|
2
|
+
"""宿主依赖惰性解析:避免包安装时强绑 model.model。"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_db():
|
|
7
|
+
"""返回宿主 Flask-SQLAlchemy db 实例。"""
|
|
8
|
+
from model.model import db
|
|
9
|
+
return db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_uc_user_model():
|
|
13
|
+
"""返回宿主 UcUser ORM 类。"""
|
|
14
|
+
from model.model import UcUser
|
|
15
|
+
return UcUser
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_uc_models():
|
|
19
|
+
"""返回门店 scope 所需的 UC 镜像表模型。"""
|
|
20
|
+
from model.model import UcDept, UcDeptUserRelation, UcStore, UcUser, db
|
|
21
|
+
return {
|
|
22
|
+
'UcDept': UcDept,
|
|
23
|
+
'UcDeptUserRelation': UcDeptUserRelation,
|
|
24
|
+
'UcStore': UcStore,
|
|
25
|
+
'UcUser': UcUser,
|
|
26
|
+
'db': db,
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# -*- coding: utf-8
|
|
2
|
+
"""SSO 换票接口 Schema 定义。"""
|
|
3
|
+
from marshmallow import Schema, fields
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SsoExchangeBodySchema(Schema):
|
|
7
|
+
code = fields.Str(metadata={'description': 'UC 跨域回跳一次性授权码'})
|
|
8
|
+
accessToken = fields.Str(metadata={'description': 'UC accessToken(同域兜底)'})
|
|
9
|
+
refreshToken = fields.Str(metadata={'description': 'UC refreshToken(可选)'})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SsoRefreshBodySchema(Schema):
|
|
13
|
+
refreshToken = fields.Str(required=True, metadata={'description': 'UC refreshToken'})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SsoExchangeResponseSchema(Schema):
|
|
17
|
+
id = fields.Int()
|
|
18
|
+
username = fields.Str()
|
|
19
|
+
name = fields.Str()
|
|
20
|
+
role = fields.Str(metadata={'description': '业务角色 manager/employee'})
|
|
21
|
+
access_token = fields.Str()
|
|
22
|
+
refresh_token = fields.Str()
|
|
23
|
+
uc_access_token = fields.Str()
|
|
24
|
+
uc_refresh_token = fields.Str()
|
|
25
|
+
store_id = fields.Int(metadata={'description': '当前门店 ID'})
|
|
26
|
+
storeId = fields.Int(metadata={'description': '当前门店 ID(驼峰)'})
|
|
27
|
+
store_ids = fields.List(fields.Int(), metadata={'description': '可访问门店 ID 列表'})
|
|
28
|
+
storeIds = fields.List(fields.Int(), metadata={'description': '可访问门店 ID 列表(驼峰)'})
|
|
29
|
+
store_name = fields.Str(metadata={'description': '门店名称(来自 uc_store)'})
|
|
30
|
+
uc = fields.Dict(
|
|
31
|
+
metadata={'description': 'UC 换票/校验原样返回(完整透传,不做字段裁剪)'},
|
|
32
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# -*- coding: utf-8
|
|
2
|
+
"""SSO 换票:UC code / accessToken → 本地 JWT。
|
|
3
|
+
|
|
4
|
+
- UC 返回原样写入 identity['uc'](不做字段白名单)
|
|
5
|
+
- 可选门店 scope(store_id / store_ids / role),由 UC_INCLUDE_STORE_SCOPE 控制
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from flask import abort, current_app
|
|
13
|
+
from flask_babelplus import gettext
|
|
14
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
15
|
+
from sqlalchemy import text
|
|
16
|
+
|
|
17
|
+
from uc_integration.service.UserCenterClient import UserCenterClient
|
|
18
|
+
from fred_admin.common.Utils import Utils
|
|
19
|
+
from uc_integration.backend.deps import get_db, get_uc_user_model
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SsoAuthService:
|
|
23
|
+
def sso_exchange(self, args: dict) -> dict:
|
|
24
|
+
"""浏览器 callback 换票:优先 code,否则 accessToken。"""
|
|
25
|
+
auth_code = (args.get('code') or '').strip()
|
|
26
|
+
access_token = (args.get('accessToken') or args.get('access_token') or '').strip()
|
|
27
|
+
refresh_token = (args.get('refreshToken') or args.get('refresh_token') or '').strip() or None
|
|
28
|
+
|
|
29
|
+
uc_payload = {}
|
|
30
|
+
if auth_code:
|
|
31
|
+
uc_tokens = UserCenterClient().exchange_sso_code(auth_code)
|
|
32
|
+
access_token = (uc_tokens.get('accessToken') or '').strip()
|
|
33
|
+
refresh_token = (uc_tokens.get('refreshToken') or '').strip() or refresh_token
|
|
34
|
+
uc_payload = self._capture_uc_payload(uc_tokens)
|
|
35
|
+
else:
|
|
36
|
+
if not access_token:
|
|
37
|
+
abort(400, description=gettext('code 或 accessToken 不能为空'))
|
|
38
|
+
validated = UserCenterClient().validate_token(access_token)
|
|
39
|
+
uc_payload = self._capture_uc_payload(validated)
|
|
40
|
+
|
|
41
|
+
if not access_token:
|
|
42
|
+
abort(401, description=gettext('授权码无效或已过期'))
|
|
43
|
+
user_code = uc_payload.get('code')
|
|
44
|
+
if not user_code:
|
|
45
|
+
abort(401, description=gettext('登录凭证无效或已过期'))
|
|
46
|
+
|
|
47
|
+
return self._build_local_session(user_code, uc_payload, access_token, refresh_token)
|
|
48
|
+
|
|
49
|
+
def sso_refresh(self, args: dict) -> dict:
|
|
50
|
+
"""UC refreshToken 续期并重新签发本地 JWT。"""
|
|
51
|
+
refresh_token = (args.get('refreshToken') or args.get('refresh_token') or '').strip()
|
|
52
|
+
if not refresh_token:
|
|
53
|
+
abort(400, description=gettext('refreshToken 不能为空'))
|
|
54
|
+
tokens = UserCenterClient().refresh_token(refresh_token)
|
|
55
|
+
access_token = (tokens.get('accessToken') or '').strip()
|
|
56
|
+
if not access_token:
|
|
57
|
+
abort(401, description=gettext('登录凭证无效或已过期'))
|
|
58
|
+
validated = UserCenterClient().validate_token(access_token)
|
|
59
|
+
uc_payload = self._capture_uc_payload(validated)
|
|
60
|
+
user_code = uc_payload.get('code')
|
|
61
|
+
if not user_code:
|
|
62
|
+
abort(401, description=gettext('登录凭证无效或已过期'))
|
|
63
|
+
new_refresh = (tokens.get('refreshToken') or refresh_token).strip() or None
|
|
64
|
+
return self._build_local_session(user_code, uc_payload, access_token, new_refresh)
|
|
65
|
+
|
|
66
|
+
def _capture_uc_payload(self, uc_data: dict) -> dict:
|
|
67
|
+
"""原样保留 UC 返回;仅额外解析本地登录必需字段。"""
|
|
68
|
+
raw = copy.deepcopy(uc_data) if isinstance(uc_data, dict) else {}
|
|
69
|
+
current_app.logger.info('SSO UC raw response: %s', raw)
|
|
70
|
+
|
|
71
|
+
profile = (
|
|
72
|
+
raw.get('userProfile')
|
|
73
|
+
or raw.get('user_profile')
|
|
74
|
+
or raw.get('profile')
|
|
75
|
+
or {}
|
|
76
|
+
)
|
|
77
|
+
if not isinstance(profile, dict):
|
|
78
|
+
profile = {}
|
|
79
|
+
store = profile.get('store') or {}
|
|
80
|
+
if not isinstance(store, dict):
|
|
81
|
+
store = {}
|
|
82
|
+
stores = profile.get('stores') or []
|
|
83
|
+
if not isinstance(stores, list):
|
|
84
|
+
stores = []
|
|
85
|
+
|
|
86
|
+
is_store_manager = profile.get('is_store_manager')
|
|
87
|
+
if is_store_manager is None:
|
|
88
|
+
is_store_manager = profile.get('isStoreManager')
|
|
89
|
+
|
|
90
|
+
code = (
|
|
91
|
+
profile.get('code')
|
|
92
|
+
or profile.get('username')
|
|
93
|
+
or raw.get('userId')
|
|
94
|
+
or raw.get('user_id')
|
|
95
|
+
or raw.get('userName')
|
|
96
|
+
or raw.get('userCode')
|
|
97
|
+
or ''
|
|
98
|
+
)
|
|
99
|
+
code = code.strip() if isinstance(code, str) else str(code or '').strip()
|
|
100
|
+
|
|
101
|
+
store_id = (
|
|
102
|
+
store.get('id')
|
|
103
|
+
or profile.get('default_store_id')
|
|
104
|
+
or raw.get('store_id')
|
|
105
|
+
or raw.get('storeId')
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
store_id = int(store_id) if store_id else None
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
store_id = None
|
|
111
|
+
|
|
112
|
+
name = (
|
|
113
|
+
profile.get('name')
|
|
114
|
+
or profile.get('real_name')
|
|
115
|
+
or raw.get('name')
|
|
116
|
+
or raw.get('userName')
|
|
117
|
+
or raw.get('nickName')
|
|
118
|
+
or code
|
|
119
|
+
)
|
|
120
|
+
name = name.strip() if isinstance(name, str) else str(name or code).strip()
|
|
121
|
+
|
|
122
|
+
mobile = profile.get('mobile') or raw.get('mobile') or raw.get('phone') or ''
|
|
123
|
+
mobile = mobile.strip() if isinstance(mobile, str) else str(mobile or '').strip()
|
|
124
|
+
|
|
125
|
+
avatar = profile.get('avatar') or raw.get('avatar') or ''
|
|
126
|
+
avatar = avatar.strip() if isinstance(avatar, str) else str(avatar or '').strip()
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
'raw': raw,
|
|
130
|
+
'code': code,
|
|
131
|
+
'name': name,
|
|
132
|
+
'mobile': mobile,
|
|
133
|
+
'avatar': avatar,
|
|
134
|
+
'store_id': store_id,
|
|
135
|
+
'store': store if store else None,
|
|
136
|
+
'stores': stores,
|
|
137
|
+
'is_store_manager': bool(is_store_manager) if is_store_manager is not None else None,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def _include_store_scope(self) -> bool:
|
|
141
|
+
val = current_app.config.get('UC_INCLUDE_STORE_SCOPE', True)
|
|
142
|
+
return bool(val)
|
|
143
|
+
|
|
144
|
+
def _store_fallback_first(self) -> bool:
|
|
145
|
+
val = current_app.config.get('UC_STORE_FALLBACK_FIRST', True)
|
|
146
|
+
return bool(val)
|
|
147
|
+
|
|
148
|
+
def _build_local_session(
|
|
149
|
+
self,
|
|
150
|
+
user_code: str,
|
|
151
|
+
uc_payload: dict | None = None,
|
|
152
|
+
uc_access_token: str | None = None,
|
|
153
|
+
uc_refresh_token: str | None = None,
|
|
154
|
+
) -> dict:
|
|
155
|
+
from uc_integration.backend import get_admin_role
|
|
156
|
+
try:
|
|
157
|
+
info = uc_payload or {}
|
|
158
|
+
uc_raw = info.get('raw') if isinstance(info.get('raw'), dict) else {}
|
|
159
|
+
user = self._resolve_user(user_code, info)
|
|
160
|
+
|
|
161
|
+
store_id = None
|
|
162
|
+
store_ids: list[int] = []
|
|
163
|
+
role = None
|
|
164
|
+
|
|
165
|
+
if self._include_store_scope():
|
|
166
|
+
from uc_integration.service.UcStoreScopeService import UcStoreScopeService
|
|
167
|
+
scope = UcStoreScopeService().resolve_profile_scope(
|
|
168
|
+
store=info.get('store'),
|
|
169
|
+
stores=info.get('stores'),
|
|
170
|
+
is_store_manager=info.get('is_store_manager'),
|
|
171
|
+
fallback_user=user,
|
|
172
|
+
)
|
|
173
|
+
store_id = self._resolve_store_id(scope.get('store_id'))
|
|
174
|
+
for sid in scope.get('store_ids') or []:
|
|
175
|
+
resolved = self._resolve_store_id(sid)
|
|
176
|
+
if resolved is not None and resolved not in store_ids:
|
|
177
|
+
store_ids.append(resolved)
|
|
178
|
+
if store_id is not None and store_id not in store_ids:
|
|
179
|
+
store_ids = [store_id, *store_ids]
|
|
180
|
+
if store_id is None and len(store_ids) == 1:
|
|
181
|
+
store_id = store_ids[0]
|
|
182
|
+
role = scope.get('role') or 'employee'
|
|
183
|
+
else:
|
|
184
|
+
store_id = self._resolve_store_id(info.get('store_id'))
|
|
185
|
+
|
|
186
|
+
store_name = self._resolve_store_name(store_id)
|
|
187
|
+
identity = {
|
|
188
|
+
'id': user.id,
|
|
189
|
+
'username': user.code,
|
|
190
|
+
'name': user.name,
|
|
191
|
+
'uc': uc_raw,
|
|
192
|
+
}
|
|
193
|
+
if role is not None:
|
|
194
|
+
identity['role'] = role
|
|
195
|
+
if store_ids:
|
|
196
|
+
identity['store_ids'] = store_ids
|
|
197
|
+
if store_id is not None:
|
|
198
|
+
identity['store_id'] = store_id
|
|
199
|
+
|
|
200
|
+
admin_role = get_admin_role()
|
|
201
|
+
claims = {
|
|
202
|
+
'role': ['uc_integration', admin_role],
|
|
203
|
+
'store_id': store_id,
|
|
204
|
+
}
|
|
205
|
+
if store_ids:
|
|
206
|
+
claims['store_ids'] = store_ids
|
|
207
|
+
if role is not None:
|
|
208
|
+
claims['uc_role'] = role
|
|
209
|
+
|
|
210
|
+
token = Utils.create_token(
|
|
211
|
+
identity=json.dumps(identity, ensure_ascii=False, default=str),
|
|
212
|
+
claims=claims,
|
|
213
|
+
)
|
|
214
|
+
payload = {
|
|
215
|
+
**identity,
|
|
216
|
+
**token,
|
|
217
|
+
}
|
|
218
|
+
if store_ids:
|
|
219
|
+
payload['storeIds'] = store_ids
|
|
220
|
+
if role is not None:
|
|
221
|
+
payload['role'] = role
|
|
222
|
+
if store_id is not None:
|
|
223
|
+
payload['storeId'] = store_id
|
|
224
|
+
if store_name:
|
|
225
|
+
payload['store_name'] = store_name
|
|
226
|
+
if uc_access_token:
|
|
227
|
+
payload['uc_access_token'] = uc_access_token
|
|
228
|
+
if uc_refresh_token:
|
|
229
|
+
payload['uc_refresh_token'] = uc_refresh_token
|
|
230
|
+
return payload
|
|
231
|
+
except SQLAlchemyError:
|
|
232
|
+
current_app.logger.exception('SSO local session failed for user %s', user_code)
|
|
233
|
+
abort(503, description=gettext('本地数据库未初始化或不可用'))
|
|
234
|
+
|
|
235
|
+
def _provision_uc_user(self, user_code: str, uc_user_info: dict | None = None):
|
|
236
|
+
UcUser = get_uc_user_model()
|
|
237
|
+
db = get_db()
|
|
238
|
+
code = (user_code or '').strip()
|
|
239
|
+
info = uc_user_info or {}
|
|
240
|
+
user = UcUser(
|
|
241
|
+
code=code,
|
|
242
|
+
name=info.get('name') or code,
|
|
243
|
+
mobile=info.get('mobile') or '',
|
|
244
|
+
avatar=info.get('avatar') or '',
|
|
245
|
+
status=1,
|
|
246
|
+
is_deleted=0,
|
|
247
|
+
)
|
|
248
|
+
db.session.add(user)
|
|
249
|
+
db.session.flush()
|
|
250
|
+
current_app.logger.info('SSO provisioned uc_user %s (name=%s) for uc user %s', code, user.name, user_code)
|
|
251
|
+
return user
|
|
252
|
+
|
|
253
|
+
def _resolve_user(self, user_code: str, uc_user_info: dict | None = None):
|
|
254
|
+
UcUser = get_uc_user_model()
|
|
255
|
+
code = (user_code or '').strip()
|
|
256
|
+
if not code:
|
|
257
|
+
abort(400, description=gettext('用户标识不能为空'))
|
|
258
|
+
user = UcUser.query.filter_by(code=code, is_deleted=0, status=1).first()
|
|
259
|
+
if user:
|
|
260
|
+
return user
|
|
261
|
+
return self._provision_uc_user(user_code, uc_user_info)
|
|
262
|
+
|
|
263
|
+
def _resolve_store_name(self, store_id: int | None) -> str | None:
|
|
264
|
+
if not store_id:
|
|
265
|
+
return None
|
|
266
|
+
db = get_db()
|
|
267
|
+
try:
|
|
268
|
+
row = db.session.execute(
|
|
269
|
+
text('SELECT name FROM uc_store WHERE id = :sid AND is_deleted = 0'),
|
|
270
|
+
{'sid': int(store_id)},
|
|
271
|
+
).mappings().first()
|
|
272
|
+
if row:
|
|
273
|
+
return (row['name'] or '').strip()
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def _resolve_store_id(self, uc_store_id: int | None) -> int | None:
|
|
279
|
+
db = get_db()
|
|
280
|
+
if uc_store_id:
|
|
281
|
+
try:
|
|
282
|
+
row = db.session.execute(
|
|
283
|
+
text('SELECT id FROM uc_store WHERE id = :sid AND is_deleted = 0'),
|
|
284
|
+
{'sid': int(uc_store_id)},
|
|
285
|
+
).mappings().first()
|
|
286
|
+
if row:
|
|
287
|
+
return int(row['id'])
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
if not self._store_fallback_first():
|
|
291
|
+
return None
|
|
292
|
+
if self._store_fallback_first():
|
|
293
|
+
try:
|
|
294
|
+
row = db.session.execute(
|
|
295
|
+
text('SELECT id FROM uc_store WHERE is_deleted = 0 ORDER BY id LIMIT 1'),
|
|
296
|
+
).mappings().first()
|
|
297
|
+
if row:
|
|
298
|
+
return int(row['id'])
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
return uc_store_id if self._store_fallback_first() else None
|