tiebameow 0.2.8__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.
@@ -0,0 +1,188 @@
1
+ from typing import Literal, Protocol, get_args, runtime_checkable
2
+
3
+ from pydantic import BaseModel, field_validator
4
+
5
+
6
+ class FragAtModel(BaseModel):
7
+ """@碎片模型。
8
+
9
+ Attributes:
10
+ type: 片段类型,固定为'at'
11
+ text (str): 被@用户的昵称 含@
12
+ user_id (int): 被@用户的user_id
13
+ """
14
+
15
+ type: Literal["at"] = "at"
16
+ text: str = ""
17
+ user_id: int = 0
18
+
19
+
20
+ class FragEmojiModel(BaseModel):
21
+ """表情碎片模型。
22
+
23
+ Attributes:
24
+ type: 片段类型,固定为'emoji'
25
+ id (str): 表情图片id
26
+ desc (str): 表情描述
27
+ """
28
+
29
+ type: Literal["emoji"] = "emoji"
30
+ id: str = ""
31
+ desc: str = ""
32
+
33
+
34
+ class FragImageModel(BaseModel):
35
+ """图像碎片模型。
36
+
37
+ Attributes:
38
+ type: 片段类型,固定为'image'
39
+ src (str): 小图链接 宽720px
40
+ big_src (str): 大图链接 宽960px
41
+ origin_src (str): 原图链接
42
+ origin_size (int): 原图大小(字节)
43
+ show_width (int): 图像在客户端预览显示的宽度(像素)
44
+ show_height (int): 图像在客户端预览显示的高度(像素)
45
+ hash (str): 百度图床hash
46
+ """
47
+
48
+ type: Literal["image"] = "image"
49
+ src: str = ""
50
+ big_src: str = ""
51
+ origin_src: str = ""
52
+ origin_size: int = 0
53
+ show_width: int = 0
54
+ show_height: int = 0
55
+ hash: str = ""
56
+
57
+
58
+ class FragItemModel(BaseModel):
59
+ """item碎片模型。
60
+
61
+ Attributes:
62
+ type: 片段类型,固定为'item'
63
+ text (str): item名称
64
+ """
65
+
66
+ type: Literal["item"] = "item"
67
+ text: str = ""
68
+
69
+
70
+ class FragLinkModel(BaseModel):
71
+ """链接碎片模型。
72
+
73
+ Attributes:
74
+ type: 片段类型,固定为'link'
75
+ text (str): 原链接
76
+ title (str): 链接标题
77
+ raw_url (str): 解析后的原链接
78
+ """
79
+
80
+ type: Literal["link"] = "link"
81
+ text: str = ""
82
+ title: str = ""
83
+ raw_url: str = ""
84
+
85
+ @field_validator("raw_url", mode="before")
86
+ @classmethod
87
+ def _coerce_raw_url(cls, v: str | None) -> str:
88
+ return "" if v is None else str(v)
89
+
90
+
91
+ class FragTextModel(BaseModel):
92
+ """纯文本碎片模型。
93
+
94
+ Attributes:
95
+ type: 片段类型,固定为'text'
96
+ text (str): 文本内容
97
+ """
98
+
99
+ type: Literal["text"] = "text"
100
+ text: str = ""
101
+
102
+
103
+ class FragTiebaPlusModel(BaseModel):
104
+ """贴吧plus广告碎片模型。
105
+
106
+ Attributes:
107
+ type: 片段类型,固定为'tieba_plus'
108
+ text (str): 贴吧plus广告描述
109
+ url (str): 解析后的贴吧plus广告跳转链接
110
+ """
111
+
112
+ type: Literal["tieba_plus"] = "tieba_plus"
113
+ text: str = ""
114
+ url: str = ""
115
+
116
+ @field_validator("url", mode="before")
117
+ @classmethod
118
+ def _coerce_url(cls, v: str | None) -> str:
119
+ return "" if v is None else str(v)
120
+
121
+
122
+ class FragVideoModel(BaseModel):
123
+ """视频碎片模型。
124
+
125
+ Attributes:
126
+ type: 片段类型,固定为'video'
127
+ src (str): 视频链接
128
+ cover_src (str): 封面链接
129
+ duration (int): 视频长度(秒)
130
+ width (int): 视频宽度(像素)
131
+ height (int): 视频高度(像素)
132
+ view_num (int): 浏览次数
133
+ """
134
+
135
+ type: Literal["video"] = "video"
136
+ src: str = ""
137
+ cover_src: str = ""
138
+ duration: int = 0
139
+ width: int = 0
140
+ height: int = 0
141
+ view_num: int = 0
142
+
143
+
144
+ class FragVoiceModel(BaseModel):
145
+ """音频碎片模型。
146
+
147
+ Attributes:
148
+ type: 片段类型,固定为'voice'
149
+ md5 (str): 音频md5
150
+ duration (int): 音频长度(秒)
151
+ """
152
+
153
+ type: Literal["voice"] = "voice"
154
+ md5: str = ""
155
+ duration: int = 0
156
+
157
+
158
+ class FragUnknownModel(BaseModel):
159
+ """未知碎片模型。"""
160
+
161
+ type: Literal["unknown"] = "unknown"
162
+ raw_data: str = ""
163
+
164
+
165
+ Fragment = (
166
+ FragAtModel
167
+ | FragEmojiModel
168
+ | FragImageModel
169
+ | FragItemModel
170
+ | FragLinkModel
171
+ | FragTextModel
172
+ | FragTiebaPlusModel
173
+ | FragVideoModel
174
+ | FragVoiceModel
175
+ | FragUnknownModel
176
+ )
177
+
178
+
179
+ @runtime_checkable
180
+ class TypeFragText(Protocol):
181
+ text: str
182
+
183
+
184
+ FRAG_MAP: dict[str, type[Fragment]] = {
185
+ key: cls
186
+ for cls in get_args(Fragment)
187
+ for key in (cls.__name__.removesuffix("Model"), cls.model_fields["type"].default)
188
+ }
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum, unique
4
+ from typing import Any, Self
5
+
6
+ from pydantic import BaseModel, Field, model_validator
7
+
8
+
9
+ @unique
10
+ class FieldType(StrEnum):
11
+ """
12
+ 支持的字段类型枚举。
13
+
14
+ 定义了规则引擎中可用于条件判断的所有字段,包括主题帖(Thread)、回复(Post)、
15
+ 用户信息以及各类型的通用属性。
16
+ """
17
+
18
+ TITLE = "title" # 仅Thread
19
+ IS_GOOD = "is_good" # 仅Thread
20
+ IS_TOP = "is_top" # 仅Thread
21
+ IS_SHARE = "is_share" # 仅Thread
22
+ IS_HIDE = "is_hide" # 仅Thread
23
+ TEXT = "text"
24
+ FULL_TEXT = "full_text"
25
+ ATS = "ats"
26
+ LEVEL = "author.level"
27
+ USER_ID = "author.user_id"
28
+ PORTRAIT = "author.portrait"
29
+ USER_NAME = "author.user_name"
30
+ NICK_NAME = "author.nick_name"
31
+ AGREE_NUM = "agree_num"
32
+ DISAGREE_NUM = "disagree_num"
33
+ REPLY_NUM = "reply_num" # 仅Thread/Post
34
+ VIEW_NUM = "view_num" # 仅Thread
35
+ SHARE_NUM = "share_num" # 仅Thread
36
+ CREATE_TIME = "create_time"
37
+ LAST_TIME = "last_time" # 仅Thread
38
+ SHARE_FNAME = "share_origin.fname" # 仅Thread
39
+ SHARE_FID = "share_origin.fid" # 仅Thread
40
+ SHARE_TITLE = "share_origin.title" # 仅Thread
41
+ SHARE_TEXT = "share_origin.text" # 仅Thread
42
+
43
+
44
+ @unique
45
+ class OperatorType(StrEnum):
46
+ """
47
+ 支持的操作符类型枚举。
48
+
49
+ 定义了字段值与目标值进行比较的具体逻辑。
50
+ """
51
+
52
+ CONTAINS = "contains"
53
+ NOT_CONTAINS = "not_contains"
54
+ REGEX = "match"
55
+ NOT_REGEX = "not_match"
56
+ EQ = "eq"
57
+ NEQ = "neq"
58
+ GT = "gt"
59
+ LT = "lt"
60
+ GTE = "gte"
61
+ LTE = "lte"
62
+ IN = "in"
63
+ NOT_IN = "not_in"
64
+
65
+
66
+ @unique
67
+ class LogicType(StrEnum):
68
+ """
69
+ 支持的逻辑运算符枚举。
70
+
71
+ 用于连接多个条件节点,构成复杂的逻辑表达式。
72
+ """
73
+
74
+ AND = "AND"
75
+ OR = "OR"
76
+ NOT = "NOT"
77
+
78
+
79
+ @unique
80
+ class ActionType(StrEnum):
81
+ """
82
+ 支持的动作类型枚举。
83
+
84
+ 定义了规则匹配成功后只需执行的具体操作。
85
+ """
86
+
87
+ DELETE = "delete"
88
+ BAN = "ban"
89
+ NOTIFY = "notify"
90
+
91
+
92
+ @unique
93
+ class TargetType(StrEnum):
94
+ """
95
+ 支持的规则目标类型枚举。
96
+
97
+ 定义了规则适用的内容类型范围。
98
+ """
99
+
100
+ ALL = "all"
101
+ THREAD = "thread"
102
+ POST = "post"
103
+ COMMENT = "comment"
104
+
105
+
106
+ class Condition(BaseModel):
107
+ """单个条件单元。
108
+
109
+ 定义了规则中的最小匹配单元,包含字段、操作符和目标值。
110
+
111
+ Attributes:
112
+ field: 匹配字段路径,支持点号分隔的嵌套字段,如 'content', 'author.level'。
113
+ operator: 匹配操作符,支持 'contains', 'regex', 'eq', 'gt', 'lt', 'gte', 'lte', 'in'。
114
+ value: 匹配的目标值,类型取决于操作符。
115
+ """
116
+
117
+ field: FieldType
118
+ operator: OperatorType
119
+ value: Any
120
+
121
+
122
+ class RuleGroup(BaseModel):
123
+ """规则组,支持逻辑组合。
124
+
125
+ 用于组合多个条件或子规则组,支持 AND, OR, NOT 等逻辑运算。
126
+
127
+ Attributes:
128
+ logic: 逻辑关系,如 'AND', 'OR', 'NOT' 等。
129
+ conditions: 子条件列表,可以是 Condition 或嵌套的 RuleGroup。
130
+ """
131
+
132
+ logic: LogicType
133
+ conditions: list[RuleNode]
134
+
135
+
136
+ # 递归类型别名
137
+ type RuleNode = Condition | RuleGroup
138
+
139
+
140
+ class DeleteAction(BaseModel):
141
+ """删除动作配置。"""
142
+
143
+ enabled: bool = False
144
+ params: dict[str, Any] = Field(default_factory=dict)
145
+
146
+
147
+ class BanAction(BaseModel):
148
+ """封禁动作配置。"""
149
+
150
+ enabled: bool = False
151
+ days: int = Field(default=1, ge=1, le=10)
152
+ params: dict[str, Any] = Field(default_factory=dict)
153
+
154
+
155
+ class NotifyAction(BaseModel):
156
+ """通知动作配置。"""
157
+
158
+ enabled: bool = False
159
+ template: str = ""
160
+ params: dict[str, Any] = Field(default_factory=dict)
161
+
162
+
163
+ class Actions(BaseModel):
164
+ """匹配命中后的动作集合。
165
+
166
+ 定义了当规则匹配成功时应执行的操作集合。
167
+ """
168
+
169
+ delete: DeleteAction = Field(default_factory=DeleteAction)
170
+ ban: BanAction = Field(default_factory=BanAction)
171
+ notify: NotifyAction = Field(default_factory=NotifyAction)
172
+
173
+
174
+ class ReviewRule(BaseModel):
175
+ """完整的审查规则实体。
176
+
177
+ 包含规则的元数据、触发条件逻辑树以及命中后的执行动作。
178
+
179
+ Attributes:
180
+ id: 规则唯一标识 ID。
181
+ fid: 贴吧 fid。
182
+ forum_rule_id: 吧内规则标识 ID。
183
+ uploader_id: 规则创建者的用户 ID。
184
+ target_type: 规则适用的目标类型,如 'all', 'thread', 'post', 'comment'。
185
+ name: 规则名称。
186
+ enabled: 是否启用该规则。
187
+ priority: 规则优先级,数字越大越先执行。
188
+ trigger: 规则触发条件的逻辑树根节点。
189
+ actions: 规则命中后执行的动作配置。
190
+ """
191
+
192
+ id: int
193
+ fid: int
194
+ forum_rule_id: int
195
+ uploader_id: int
196
+ target_type: TargetType
197
+ name: str
198
+ enabled: bool
199
+ priority: int
200
+ trigger: RuleNode
201
+ actions: Actions
202
+
203
+ @model_validator(mode="after")
204
+ def validate_trigger_match_target(self) -> Self:
205
+ """验证 trigger 中的字段是否匹配 target_type。"""
206
+
207
+ thread_only_fields = {
208
+ FieldType.TITLE,
209
+ FieldType.IS_GOOD,
210
+ FieldType.IS_TOP,
211
+ FieldType.IS_SHARE,
212
+ FieldType.IS_HIDE,
213
+ FieldType.VIEW_NUM,
214
+ FieldType.SHARE_NUM,
215
+ FieldType.LAST_TIME,
216
+ FieldType.SHARE_FNAME,
217
+ FieldType.SHARE_FID,
218
+ FieldType.SHARE_TITLE,
219
+ FieldType.SHARE_TEXT,
220
+ }
221
+
222
+ thread_post_fields = {FieldType.REPLY_NUM}
223
+
224
+ target = self.target_type
225
+
226
+ forbidden_fields: set[FieldType] = set()
227
+
228
+ if target == TargetType.POST:
229
+ forbidden_fields = thread_only_fields
230
+ elif target == TargetType.COMMENT:
231
+ forbidden_fields = thread_only_fields | thread_post_fields
232
+ elif target == TargetType.ALL:
233
+ forbidden_fields = thread_only_fields | thread_post_fields
234
+
235
+ if not forbidden_fields:
236
+ return self
237
+
238
+ def validate_node(node: RuleNode) -> None:
239
+ if isinstance(node, Condition):
240
+ if node.field in forbidden_fields:
241
+ raise ValueError(f"Field '{node.field}' is not valid for target_type '{target}'")
242
+ elif isinstance(node, RuleGroup):
243
+ for child in node.conditions:
244
+ validate_node(child)
245
+
246
+ validate_node(self.trigger)
247
+ return self
@@ -0,0 +1,15 @@
1
+ from .serializer import (
2
+ deserialize,
3
+ deserialize_comment,
4
+ deserialize_post,
5
+ deserialize_thread,
6
+ serialize,
7
+ )
8
+
9
+ __all__ = [
10
+ "serialize",
11
+ "deserialize",
12
+ "deserialize_thread",
13
+ "deserialize_post",
14
+ "deserialize_comment",
15
+ ]
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Literal, overload
4
+
5
+ from ..models.dto import CommentDTO, PostDTO, ThreadDTO
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Mapping
9
+
10
+ __all__ = [
11
+ "serialize",
12
+ "deserialize",
13
+ "deserialize_thread",
14
+ "deserialize_post",
15
+ "deserialize_comment",
16
+ ]
17
+
18
+
19
+ def serialize(obj: Any) -> Any:
20
+ """将对象序列化为 JSON 可用的字典或列表。"""
21
+ if hasattr(obj, "model_dump"):
22
+ return obj.model_dump(mode="json")
23
+ return obj
24
+
25
+
26
+ def _normalize_contents(data: Any) -> list[Any]:
27
+ if isinstance(data, list):
28
+ return data
29
+ if isinstance(data, dict):
30
+ return data.get("objs", []) or []
31
+ return []
32
+
33
+
34
+ def _normalize_user(data: dict[str, Any]) -> None:
35
+ if "author" not in data and "user" in data:
36
+ data["author"] = data["user"]
37
+
38
+
39
+ def _preprocess_thread(data: Mapping[str, Any]) -> dict[str, Any]:
40
+ d = dict(data)
41
+ if "contents" in d:
42
+ d["contents"] = _normalize_contents(d["contents"])
43
+
44
+ _normalize_user(d)
45
+
46
+ if "share_origin" in d and isinstance(d["share_origin"], dict):
47
+ so = dict(d["share_origin"])
48
+ if "contents" in so:
49
+ so["contents"] = _normalize_contents(so["contents"])
50
+ d["share_origin"] = so
51
+
52
+ return d
53
+
54
+
55
+ def _preprocess_post(data: Mapping[str, Any]) -> dict[str, Any]:
56
+ d = dict(data)
57
+ if "contents" in d:
58
+ d["contents"] = _normalize_contents(d["contents"])
59
+
60
+ _normalize_user(d)
61
+
62
+ if "comments" in d and isinstance(d["comments"], list):
63
+ d["comments"] = [_preprocess_comment(c) if isinstance(c, dict) else c for c in d["comments"]]
64
+
65
+ return d
66
+
67
+
68
+ def _preprocess_comment(data: Mapping[str, Any]) -> dict[str, Any]:
69
+ d = dict(data)
70
+ if "contents" in d:
71
+ d["contents"] = _normalize_contents(d["contents"])
72
+
73
+ _normalize_user(d)
74
+
75
+ return d
76
+
77
+
78
+ def deserialize_thread(data: Mapping[str, Any]) -> ThreadDTO:
79
+ """将 JSON/dict 反序列化为 ThreadDTO 对象。"""
80
+ return ThreadDTO.model_validate(_preprocess_thread(data))
81
+
82
+
83
+ def deserialize_post(data: Mapping[str, Any]) -> PostDTO:
84
+ """将 JSON/dict 反序列化为 PostDTO 对象。"""
85
+ return PostDTO.model_validate(_preprocess_post(data))
86
+
87
+
88
+ def deserialize_comment(data: Mapping[str, Any]) -> CommentDTO:
89
+ """将 JSON/dict 反序列化为 CommentDTO 对象。"""
90
+ return CommentDTO.model_validate(_preprocess_comment(data))
91
+
92
+
93
+ @overload
94
+ def deserialize(item_type: Literal["thread"], data: Mapping[str, Any]) -> ThreadDTO: ...
95
+
96
+
97
+ @overload
98
+ def deserialize(item_type: Literal["post"], data: Mapping[str, Any]) -> PostDTO: ...
99
+
100
+
101
+ @overload
102
+ def deserialize(item_type: Literal["comment"], data: Mapping[str, Any]) -> CommentDTO: ...
103
+
104
+
105
+ def deserialize(
106
+ item_type: Literal["thread", "post", "comment"], data: Mapping[str, Any]
107
+ ) -> ThreadDTO | PostDTO | CommentDTO:
108
+ """根据类型进行通用反序列化。"""
109
+ if item_type == "thread":
110
+ return deserialize_thread(data)
111
+ if item_type == "post":
112
+ return deserialize_post(data)
113
+ if item_type == "comment":
114
+ return deserialize_comment(data)
115
+ raise ValueError(f"Unsupported item_type: {item_type}")
File without changes
@@ -0,0 +1,129 @@
1
+ import logging
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from loguru import logger
7
+
8
+ if TYPE_CHECKING:
9
+ from types import FrameType
10
+
11
+ __all__ = ["logger", "init_logger"]
12
+
13
+
14
+ class InterceptHandler(logging.Handler):
15
+ """
16
+ 拦截标准日志消息并将其路由到 Loguru。
17
+ """
18
+
19
+ def emit(self, record: logging.LogRecord) -> None:
20
+ try:
21
+ level: str | int = logger.level(record.levelname).name
22
+ except ValueError:
23
+ level = record.levelno
24
+
25
+ frame: FrameType | None = logging.currentframe()
26
+ depth = 2
27
+ while frame and frame.f_code.co_filename == logging.__file__:
28
+ frame = frame.f_back
29
+ depth += 1
30
+
31
+ logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
32
+
33
+
34
+ def init_logger(
35
+ *,
36
+ service_name: str = "tiebameow",
37
+ level: str = "INFO",
38
+ console_format: str | None = None,
39
+ enable_filelog: bool = False,
40
+ enable_error_filelog: bool = False,
41
+ log_dir: str | Path = "logs",
42
+ file_format: str | None = None,
43
+ rotation: str = "00:00",
44
+ retention: str = "14 days",
45
+ intercept_standard_logging: bool = True,
46
+ enqueue: bool = True,
47
+ diagnose: bool = True,
48
+ reset: bool = True,
49
+ add_console: bool = True,
50
+ ) -> None:
51
+ """
52
+ 使用标准配置初始化适用于 TiebaMeow 服务的 loguru logger。
53
+
54
+ Args:
55
+ service_name: 服务名称,用于日志文件命名。
56
+
57
+ level: 最低日志级别(例如 "DEBUG", "INFO")。
58
+ console_format: 控制台输出的自定义格式。
59
+ enable_filelog: 是否启用日志文件记录。
60
+ enable_error_filelog: 是否启用错误日志文件记录。
61
+ log_dir: 日志文件存储目录。
62
+ file_format: 文件输出的自定义格式。
63
+ rotation: 日志轮转条件(例如 "1 day", "500 MB", "00:00")。
64
+ retention: 日志保留条件(例如 "10 days")。
65
+ intercept_standard_logging: 是否拦截标准库日志。
66
+ enqueue: 是否使用线程安全的日志记录(异步安全)。
67
+ diagnose: 是否在异常回溯中显示变量值。
68
+ reset: 是否移除所有现有的处理器。如果集成到已有日志记录的应用中(例如 NoneBot),请设置为 False。
69
+ add_console: 是否添加控制台处理器。如果应用已经有一个,请设置为 False。
70
+ """
71
+ if console_format is None:
72
+ console_format = (
73
+ "<green>{time:YYYY-MM-DD HH:mm:ss}</green> "
74
+ "<level>[{level}]</level> "
75
+ "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
76
+ "<level>{message}</level>"
77
+ )
78
+
79
+ if reset:
80
+ logger.remove()
81
+
82
+ if add_console:
83
+ logger.add(
84
+ sys.stderr,
85
+ format=console_format,
86
+ level=level,
87
+ enqueue=enqueue,
88
+ diagnose=diagnose,
89
+ )
90
+
91
+ if enable_filelog or enable_error_filelog:
92
+ if file_format is None:
93
+ file_format = "{time:YYYY-MM-DD HH:mm:ss} [{level}] {name}:{function}:{line} | {message}"
94
+
95
+ log_path = Path(log_dir)
96
+ try:
97
+ log_path.mkdir(parents=True, exist_ok=True)
98
+ except Exception:
99
+ logger.warning("Failed to create log directory: {}, file logging disabled.", log_path)
100
+ return
101
+
102
+ if enable_filelog:
103
+ logger.add(
104
+ log_path / f"{service_name}.log",
105
+ format=file_format,
106
+ level=level,
107
+ rotation=rotation,
108
+ retention=retention,
109
+ encoding="utf-8",
110
+ enqueue=enqueue,
111
+ compression="zip",
112
+ diagnose=diagnose,
113
+ )
114
+
115
+ if enable_error_filelog:
116
+ logger.add(
117
+ log_path / f"{service_name}.error.log",
118
+ format=file_format,
119
+ level="ERROR",
120
+ rotation=rotation,
121
+ retention=retention,
122
+ encoding="utf-8",
123
+ enqueue=enqueue,
124
+ compression="zip",
125
+ diagnose=diagnose,
126
+ )
127
+
128
+ if intercept_standard_logging:
129
+ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
@@ -0,0 +1,15 @@
1
+ from datetime import datetime
2
+ from zoneinfo import ZoneInfo
3
+
4
+ __all__ = ["SHANGHAI_TZ", "now_with_tz"]
5
+
6
+ SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
7
+
8
+
9
+ def now_with_tz() -> datetime:
10
+ """返回带时区的当前时间。
11
+
12
+ Returns:
13
+ datetime: 上海时区的当前时间。
14
+ """
15
+ return datetime.now(SHANGHAI_TZ)