feat(models): 组织、用户、角色、客户、会话与消息模型
按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
1 parent
977783c126
commit
9eb8613c05
126 files changed
+17436
No files matched your search
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Whether a signup domain resolves in DNS - for a soft warning, never a block:
|
||||
some people sign up before their site is live.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Dict, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
LOOKUP_TIMEOUT_SECONDS = 2.0
|
||||
# getaddrinfo blocks, and a thread can't be cancelled: a dead nameserver holds
|
||||
# one until the system resolver gives up, long after our timeout. So lookups
|
||||
# get a small pool of their own (never the event loop's shared one) and never
|
||||
# queue: when every slot is busy the answer is "can't tell".
|
||||
MAX_LOOKUPS = 4
|
||||
_executor = ThreadPoolExecutor(max_workers=MAX_LOOKUPS, thread_name_prefix="domain-check")
|
||||
_slots = threading.BoundedSemaphore(MAX_LOOKUPS)
|
||||
# Only these mean "no such name"; anything else (EAI_AGAIN, a failing
|
||||
# resolver) is a temporary failure and says nothing about the domain.
|
||||
_NOT_FOUND = {socket.EAI_NONAME, getattr(socket, "EAI_NODATA", socket.EAI_NONAME)}
|
||||
# The field re-checks on every pause in typing: remember definite answers
|
||||
# for a while so that doesn't cost a lookup each time.
|
||||
CACHE_SECONDS = 600
|
||||
CACHE_LIMIT = 1000
|
||||
_cache: Dict[str, Tuple[float, bool]] = {}
|
||||
|
||||
|
||||
def bare_host(domain: str) -> str:
|
||||
"""'https://Example.com/path' -> 'example.com'."""
|
||||
value = domain.strip()
|
||||
return (urlparse(value if "//" in value else f"//{value}").hostname or "").lower()
|
||||
|
||||
|
||||
def site_host(domain: str) -> str:
|
||||
"""An org's site as hostnames compare: 'https://WWW.Acme.com/' -> 'acme.com'.
|
||||
CORS admits this host and its www., however the domain was typed."""
|
||||
return bare_host(domain).removeprefix("www.")
|
||||
|
||||
|
||||
def _lookup(host: str) -> Optional[bool]:
|
||||
try:
|
||||
socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||
return True
|
||||
except socket.gaierror as e:
|
||||
return False if e.errno in _NOT_FOUND else None
|
||||
|
||||
|
||||
async def _resolves(host: str) -> Optional[bool]:
|
||||
if not _slots.acquire(blocking=False):
|
||||
return None
|
||||
try:
|
||||
future = _executor.submit(_lookup, host)
|
||||
except RuntimeError: # pool shut down
|
||||
_slots.release()
|
||||
return None
|
||||
# Released when the lookup ends - or is cancelled before it ever ran,
|
||||
# which a release inside _lookup would miss.
|
||||
future.add_done_callback(lambda _: _slots.release())
|
||||
return await asyncio.wrap_future(future)
|
||||
|
||||
|
||||
def _combine(answers) -> Optional[bool]:
|
||||
"""Any address is enough; "doesn't exist" only when every name said so."""
|
||||
if any(answer is True for answer in answers):
|
||||
return True
|
||||
return False if answers and all(answer is False for answer in answers) else None
|
||||
|
||||
|
||||
async def domain_resolves(domain: str) -> Optional[bool]:
|
||||
"""True/False when DNS answered, None when it couldn't say in time (or the
|
||||
input isn't a hostname) - callers warn only on a definite False. Asks for
|
||||
the bare domain and www. together (some sites only publish www), within
|
||||
one overall time budget."""
|
||||
host = bare_host(domain)
|
||||
if not host or "." not in host:
|
||||
return None
|
||||
cached = _cache.get(host)
|
||||
if cached and time.monotonic() - cached[0] < CACHE_SECONDS:
|
||||
return cached[1]
|
||||
candidates = {host, f"www.{host.removeprefix('www.')}"}
|
||||
try:
|
||||
result = _combine(await asyncio.wait_for(
|
||||
asyncio.gather(*(_resolves(candidate) for candidate in candidates)), LOOKUP_TIMEOUT_SECONDS
|
||||
))
|
||||
except asyncio.TimeoutError:
|
||||
result = None
|
||||
if result is not None:
|
||||
if len(_cache) >= CACHE_LIMIT:
|
||||
_cache.clear()
|
||||
_cache[host] = (time.monotonic(), result)
|
||||
return result
|
||||
Reference in new issue
Block a user