wr-common-lib 0.1.0__tar.gz
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.
- wr_common_lib-0.1.0/.gitignore +7 -0
- wr_common_lib-0.1.0/LICENSE +21 -0
- wr_common_lib-0.1.0/PKG-INFO +180 -0
- wr_common_lib-0.1.0/README.md +169 -0
- wr_common_lib-0.1.0/pyproject.toml +26 -0
- wr_common_lib-0.1.0/src/wr_common_lib/__init__.py +1 -0
- wr_common_lib-0.1.0/src/wr_common_lib/email/__init__.py +18 -0
- wr_common_lib-0.1.0/src/wr_common_lib/email/constants.py +63 -0
- wr_common_lib-0.1.0/src/wr_common_lib/email/db_oper.py +218 -0
- wr_common_lib-0.1.0/src/wr_common_lib/email/types.py +35 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wr-common-lib contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wr-common-lib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 气象导航公用库:领域枚举与共享数据访问
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Provides-Extra: db
|
|
9
|
+
Requires-Dist: async-db-tools>=0.1.0; extra == 'db'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# wr-common-lib
|
|
13
|
+
|
|
14
|
+
气象导航各微服务共用的 Python 库:领域枚举、任务类型、以及已定表的共享数据访问(`EmailDbOper` 等)。
|
|
15
|
+
|
|
16
|
+
| 概念 | 名称 |
|
|
17
|
+
|------|------|
|
|
18
|
+
| PyPI / pip 包名 | `wr-common-lib` |
|
|
19
|
+
| import 包名 | `wr_common_lib` |
|
|
20
|
+
| 本仓库目录 | `common-lib/` |
|
|
21
|
+
|
|
22
|
+
要求 **Python ≥ 3.12**。
|
|
23
|
+
|
|
24
|
+
## 目录结构
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
common-lib/
|
|
28
|
+
├── LICENSE
|
|
29
|
+
├── pyproject.toml
|
|
30
|
+
├── README.md
|
|
31
|
+
└── src/wr_common_lib/
|
|
32
|
+
├── __init__.py # __version__
|
|
33
|
+
└── email/ # email 表相关
|
|
34
|
+
├── constants.py # MailFlow, MailStatus(对齐 PG 枚举)
|
|
35
|
+
├── types.py # OutboundTask, InboundTask
|
|
36
|
+
└── db_oper.py # EmailDbOper(需 [db] 可选依赖)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
后续新业务域可在 `wr_common_lib/` 下增加子包(例如 `voyage/`),与 `email/` 平级。
|
|
40
|
+
|
|
41
|
+
## 安装
|
|
42
|
+
|
|
43
|
+
### 仅枚举 / 类型(无数据库依赖)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install wr-common-lib
|
|
47
|
+
# 或 monorepo 本地
|
|
48
|
+
pip install -e /path/to/common-lib
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 含 `EmailDbOper`(依赖 async-db-tools)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install "wr-common-lib[db]"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### monorepo 本地联调(推荐)
|
|
58
|
+
|
|
59
|
+
在 `publish` 仓库内,先装数据库工具包,再装本库:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install -e ../dbtools
|
|
63
|
+
pip install -e ".[db]"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
若业务项目的 `pyproject.toml` 使用 path 依赖,示例:
|
|
67
|
+
|
|
68
|
+
```toml
|
|
69
|
+
dependencies = [
|
|
70
|
+
"wr-common-lib[db] @ file:///${PROJECT_ROOT}/../common-lib",
|
|
71
|
+
"async-db-tools @ file:///${PROJECT_ROOT}/../dbtools",
|
|
72
|
+
]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
(路径按你本机 monorepo 布局调整;也可用 `uv` / `poetry` 的 path / workspace 写法。)
|
|
76
|
+
|
|
77
|
+
## 使用
|
|
78
|
+
|
|
79
|
+
### 邮件枚举
|
|
80
|
+
|
|
81
|
+
与 PostgreSQL `mail_flow`、`mail_status` 枚举值一致,可直接 `.value` 写入 SQL 或 ORM。
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from wr_common_lib.email import MailFlow, MailStatus
|
|
85
|
+
|
|
86
|
+
MailFlow.OUTBOUND # "OUTBOUND"
|
|
87
|
+
MailStatus.QUEUED.value # "QUEUED"
|
|
88
|
+
|
|
89
|
+
if MailStatus.SENT.is_terminal:
|
|
90
|
+
...
|
|
91
|
+
if MailStatus.PENDING.is_outbound_lifecycle:
|
|
92
|
+
...
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 任务类型(TypedDict)
|
|
96
|
+
|
|
97
|
+
出站、入站字段名不同,用类型区分,避免 `dict` 混用 `to` / `mail_to`:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from wr_common_lib.email import OutboundTask, InboundTask
|
|
101
|
+
|
|
102
|
+
task: OutboundTask = {
|
|
103
|
+
"imo": 1234567,
|
|
104
|
+
"voyage_id": "...",
|
|
105
|
+
"mail_from": "noreply@example.com",
|
|
106
|
+
"to": "ship@example.com",
|
|
107
|
+
"content_hash": "abc...",
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 共享表操作 `EmailDbOper`
|
|
112
|
+
|
|
113
|
+
需已安装 `[db]`,并注入各服务自己的 `PostgresPool`:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from async_db_tools import PostgresPool
|
|
117
|
+
from wr_common_lib.email import EmailDbOper, MailStatus
|
|
118
|
+
|
|
119
|
+
db_oper = EmailDbOper(pool)
|
|
120
|
+
|
|
121
|
+
email_id = await db_oper.mark_queued(task)
|
|
122
|
+
await db_oper.mark_sent(email_id)
|
|
123
|
+
await db_oper.mark_failed(email_id)
|
|
124
|
+
|
|
125
|
+
row = await db_oper.find_by_content_hash("...")
|
|
126
|
+
email_id = await db_oper.insert_inbound_received(inbound_task, content_hash)
|
|
127
|
+
await db_oper.mark_parsed(email_id, {"key": "value"})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`EmailDbOper` 在 `wr_common_lib.email` 中为**懒加载**:只 `import MailStatus` 时不会要求安装 `async-db-tools`;只有 `import EmailDbOper` 时才加载 `db_oper` 模块。
|
|
131
|
+
|
|
132
|
+
### 邮件状态(约定)
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
出站 · 发送服务: PENDING → QUEUED → SENT | FAILED
|
|
136
|
+
出站 · Webhook: SENT → DELIVERED | BOUNCED | DEFERRED
|
|
137
|
+
入站 · 收件服务: RECEIVED → PARSED | PARSE_FAILED
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## 依赖说明
|
|
141
|
+
|
|
142
|
+
| 安装方式 | 引入的依赖 |
|
|
143
|
+
|----------|------------|
|
|
144
|
+
| `wr-common-lib` | 无 |
|
|
145
|
+
| `wr-common-lib[db]` | `async-db-tools`(PostgreSQL 连接池) |
|
|
146
|
+
|
|
147
|
+
业务服务仍需自行配置数据库连接;本库只封装对 `email` 表的 SQL。
|
|
148
|
+
|
|
149
|
+
## 开发与发布
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# 可编辑安装(monorepo 内先装 dbtools)
|
|
153
|
+
uv pip install -e ../dbtools
|
|
154
|
+
uv pip install -e ".[db]"
|
|
155
|
+
|
|
156
|
+
# 发版前改版本(可选)
|
|
157
|
+
uv version 0.1.1
|
|
158
|
+
|
|
159
|
+
# 构建并发布到 PyPI(推荐)
|
|
160
|
+
export UV_PUBLISH_TOKEN=pypi-xxxxxxxx # PyPI → Account → API tokens
|
|
161
|
+
uv build
|
|
162
|
+
uv publish
|
|
163
|
+
|
|
164
|
+
# 或一步:构建产物在 dist/ 后上传
|
|
165
|
+
# uv build && uv publish
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`uv publish` 会读取 `dist/` 里的 wheel/sdist 并上传;失败重试同一命令即可(PyPI 会忽略已存在的相同文件)。
|
|
169
|
+
|
|
170
|
+
也可用传统方式:`python -m build` + `twine upload dist/*`。
|
|
171
|
+
|
|
172
|
+
表结构变更时,在 `src/wr_common_lib/email/db_oper.py` 中扩展方法即可;各服务将 `app.email.*` 的引用改为 `wr_common_lib.email` 后统一升级版本。
|
|
173
|
+
|
|
174
|
+
## 版本
|
|
175
|
+
|
|
176
|
+
当前版本见 `wr_common_lib.__version__` 与 `pyproject.toml` 中的 `[project].version`。
|
|
177
|
+
|
|
178
|
+
## 许可证
|
|
179
|
+
|
|
180
|
+
MIT,见 [LICENSE](LICENSE)。
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# wr-common-lib
|
|
2
|
+
|
|
3
|
+
气象导航各微服务共用的 Python 库:领域枚举、任务类型、以及已定表的共享数据访问(`EmailDbOper` 等)。
|
|
4
|
+
|
|
5
|
+
| 概念 | 名称 |
|
|
6
|
+
|------|------|
|
|
7
|
+
| PyPI / pip 包名 | `wr-common-lib` |
|
|
8
|
+
| import 包名 | `wr_common_lib` |
|
|
9
|
+
| 本仓库目录 | `common-lib/` |
|
|
10
|
+
|
|
11
|
+
要求 **Python ≥ 3.12**。
|
|
12
|
+
|
|
13
|
+
## 目录结构
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
common-lib/
|
|
17
|
+
├── LICENSE
|
|
18
|
+
├── pyproject.toml
|
|
19
|
+
├── README.md
|
|
20
|
+
└── src/wr_common_lib/
|
|
21
|
+
├── __init__.py # __version__
|
|
22
|
+
└── email/ # email 表相关
|
|
23
|
+
├── constants.py # MailFlow, MailStatus(对齐 PG 枚举)
|
|
24
|
+
├── types.py # OutboundTask, InboundTask
|
|
25
|
+
└── db_oper.py # EmailDbOper(需 [db] 可选依赖)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
后续新业务域可在 `wr_common_lib/` 下增加子包(例如 `voyage/`),与 `email/` 平级。
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
### 仅枚举 / 类型(无数据库依赖)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install wr-common-lib
|
|
36
|
+
# 或 monorepo 本地
|
|
37
|
+
pip install -e /path/to/common-lib
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 含 `EmailDbOper`(依赖 async-db-tools)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install "wr-common-lib[db]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### monorepo 本地联调(推荐)
|
|
47
|
+
|
|
48
|
+
在 `publish` 仓库内,先装数据库工具包,再装本库:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install -e ../dbtools
|
|
52
|
+
pip install -e ".[db]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
若业务项目的 `pyproject.toml` 使用 path 依赖,示例:
|
|
56
|
+
|
|
57
|
+
```toml
|
|
58
|
+
dependencies = [
|
|
59
|
+
"wr-common-lib[db] @ file:///${PROJECT_ROOT}/../common-lib",
|
|
60
|
+
"async-db-tools @ file:///${PROJECT_ROOT}/../dbtools",
|
|
61
|
+
]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
(路径按你本机 monorepo 布局调整;也可用 `uv` / `poetry` 的 path / workspace 写法。)
|
|
65
|
+
|
|
66
|
+
## 使用
|
|
67
|
+
|
|
68
|
+
### 邮件枚举
|
|
69
|
+
|
|
70
|
+
与 PostgreSQL `mail_flow`、`mail_status` 枚举值一致,可直接 `.value` 写入 SQL 或 ORM。
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from wr_common_lib.email import MailFlow, MailStatus
|
|
74
|
+
|
|
75
|
+
MailFlow.OUTBOUND # "OUTBOUND"
|
|
76
|
+
MailStatus.QUEUED.value # "QUEUED"
|
|
77
|
+
|
|
78
|
+
if MailStatus.SENT.is_terminal:
|
|
79
|
+
...
|
|
80
|
+
if MailStatus.PENDING.is_outbound_lifecycle:
|
|
81
|
+
...
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 任务类型(TypedDict)
|
|
85
|
+
|
|
86
|
+
出站、入站字段名不同,用类型区分,避免 `dict` 混用 `to` / `mail_to`:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from wr_common_lib.email import OutboundTask, InboundTask
|
|
90
|
+
|
|
91
|
+
task: OutboundTask = {
|
|
92
|
+
"imo": 1234567,
|
|
93
|
+
"voyage_id": "...",
|
|
94
|
+
"mail_from": "noreply@example.com",
|
|
95
|
+
"to": "ship@example.com",
|
|
96
|
+
"content_hash": "abc...",
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 共享表操作 `EmailDbOper`
|
|
101
|
+
|
|
102
|
+
需已安装 `[db]`,并注入各服务自己的 `PostgresPool`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from async_db_tools import PostgresPool
|
|
106
|
+
from wr_common_lib.email import EmailDbOper, MailStatus
|
|
107
|
+
|
|
108
|
+
db_oper = EmailDbOper(pool)
|
|
109
|
+
|
|
110
|
+
email_id = await db_oper.mark_queued(task)
|
|
111
|
+
await db_oper.mark_sent(email_id)
|
|
112
|
+
await db_oper.mark_failed(email_id)
|
|
113
|
+
|
|
114
|
+
row = await db_oper.find_by_content_hash("...")
|
|
115
|
+
email_id = await db_oper.insert_inbound_received(inbound_task, content_hash)
|
|
116
|
+
await db_oper.mark_parsed(email_id, {"key": "value"})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`EmailDbOper` 在 `wr_common_lib.email` 中为**懒加载**:只 `import MailStatus` 时不会要求安装 `async-db-tools`;只有 `import EmailDbOper` 时才加载 `db_oper` 模块。
|
|
120
|
+
|
|
121
|
+
### 邮件状态(约定)
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
出站 · 发送服务: PENDING → QUEUED → SENT | FAILED
|
|
125
|
+
出站 · Webhook: SENT → DELIVERED | BOUNCED | DEFERRED
|
|
126
|
+
入站 · 收件服务: RECEIVED → PARSED | PARSE_FAILED
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## 依赖说明
|
|
130
|
+
|
|
131
|
+
| 安装方式 | 引入的依赖 |
|
|
132
|
+
|----------|------------|
|
|
133
|
+
| `wr-common-lib` | 无 |
|
|
134
|
+
| `wr-common-lib[db]` | `async-db-tools`(PostgreSQL 连接池) |
|
|
135
|
+
|
|
136
|
+
业务服务仍需自行配置数据库连接;本库只封装对 `email` 表的 SQL。
|
|
137
|
+
|
|
138
|
+
## 开发与发布
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# 可编辑安装(monorepo 内先装 dbtools)
|
|
142
|
+
uv pip install -e ../dbtools
|
|
143
|
+
uv pip install -e ".[db]"
|
|
144
|
+
|
|
145
|
+
# 发版前改版本(可选)
|
|
146
|
+
uv version 0.1.1
|
|
147
|
+
|
|
148
|
+
# 构建并发布到 PyPI(推荐)
|
|
149
|
+
export UV_PUBLISH_TOKEN=pypi-xxxxxxxx # PyPI → Account → API tokens
|
|
150
|
+
uv build
|
|
151
|
+
uv publish
|
|
152
|
+
|
|
153
|
+
# 或一步:构建产物在 dist/ 后上传
|
|
154
|
+
# uv build && uv publish
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`uv publish` 会读取 `dist/` 里的 wheel/sdist 并上传;失败重试同一命令即可(PyPI 会忽略已存在的相同文件)。
|
|
158
|
+
|
|
159
|
+
也可用传统方式:`python -m build` + `twine upload dist/*`。
|
|
160
|
+
|
|
161
|
+
表结构变更时,在 `src/wr_common_lib/email/db_oper.py` 中扩展方法即可;各服务将 `app.email.*` 的引用改为 `wr_common_lib.email` 后统一升级版本。
|
|
162
|
+
|
|
163
|
+
## 版本
|
|
164
|
+
|
|
165
|
+
当前版本见 `wr_common_lib.__version__` 与 `pyproject.toml` 中的 `[project].version`。
|
|
166
|
+
|
|
167
|
+
## 许可证
|
|
168
|
+
|
|
169
|
+
MIT,见 [LICENSE](LICENSE)。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wr-common-lib"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "气象导航公用库:领域枚举与共享数据访问"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
db = ["async-db-tools>=0.1.0"]
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/wr_common_lib"]
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.sdist]
|
|
21
|
+
include = [
|
|
22
|
+
"/src",
|
|
23
|
+
"/README.md",
|
|
24
|
+
"/LICENSE",
|
|
25
|
+
"/pyproject.toml",
|
|
26
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from wr_common_lib.email.constants import MailFlow, MailStatus
|
|
2
|
+
from wr_common_lib.email.types import InboundTask, OutboundTask
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"EmailDbOper",
|
|
6
|
+
"InboundTask",
|
|
7
|
+
"MailFlow",
|
|
8
|
+
"MailStatus",
|
|
9
|
+
"OutboundTask",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str):
|
|
14
|
+
if name == "EmailDbOper":
|
|
15
|
+
from wr_common_lib.email.db_oper import EmailDbOper
|
|
16
|
+
|
|
17
|
+
return EmailDbOper
|
|
18
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MailFlow(StrEnum):
|
|
5
|
+
"""邮件流向,对应 PostgreSQL mail_flow 枚举。"""
|
|
6
|
+
|
|
7
|
+
INBOUND = "INBOUND" # 入站:船舶发来的午报
|
|
8
|
+
OUTBOUND = "OUTBOUND" # 出站:系统发送的邮件
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MailStatus(StrEnum):
|
|
12
|
+
"""邮件状态,对应 PostgreSQL mail_status 枚举。
|
|
13
|
+
|
|
14
|
+
出站 · 本发送服务: PENDING → QUEUED → SENT | FAILED
|
|
15
|
+
出站 · FastAPI 回调: DELIVERED | BOUNCED | DEFERRED
|
|
16
|
+
入站 · 收件服务: RECEIVED → PARSED | PARSE_FAILED
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# 出站 · 本发送服务
|
|
20
|
+
PENDING = "PENDING" # 待发(API 创建)
|
|
21
|
+
QUEUED = "QUEUED" # 已入队,消费中
|
|
22
|
+
SENT = "SENT" # 已提交邮件服务商
|
|
23
|
+
FAILED = "FAILED" # 本服务发送失败
|
|
24
|
+
|
|
25
|
+
# 出站 · FastAPI 回调(webhook)
|
|
26
|
+
DELIVERED = "DELIVERED" # 已送达
|
|
27
|
+
BOUNCED = "BOUNCED" # 拒收 / 无效邮箱
|
|
28
|
+
DEFERRED = "DEFERRED" # 临时失败,可重试
|
|
29
|
+
|
|
30
|
+
# 入站 · 收件服务(后续实现)
|
|
31
|
+
RECEIVED = "RECEIVED" # 已接收
|
|
32
|
+
PARSED = "PARSED" # 已解析
|
|
33
|
+
PARSE_FAILED = "PARSE_FAILED" # 解析失败
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_terminal(self) -> bool:
|
|
37
|
+
"""是否已进入终态(不再由本状态机主动推进)。"""
|
|
38
|
+
return self in _TERMINAL_STATUSES
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_outbound_lifecycle(self) -> bool:
|
|
42
|
+
"""是否属于出站邮件生命周期内的状态。"""
|
|
43
|
+
return self in _OUTBOUND_STATUSES
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_TERMINAL_STATUSES = frozenset({
|
|
47
|
+
MailStatus.SENT,
|
|
48
|
+
MailStatus.FAILED,
|
|
49
|
+
MailStatus.DELIVERED,
|
|
50
|
+
MailStatus.BOUNCED,
|
|
51
|
+
MailStatus.PARSED,
|
|
52
|
+
MailStatus.PARSE_FAILED,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
_OUTBOUND_STATUSES = frozenset({
|
|
56
|
+
MailStatus.PENDING,
|
|
57
|
+
MailStatus.QUEUED,
|
|
58
|
+
MailStatus.SENT,
|
|
59
|
+
MailStatus.FAILED,
|
|
60
|
+
MailStatus.DELIVERED,
|
|
61
|
+
MailStatus.BOUNCED,
|
|
62
|
+
MailStatus.DEFERRED,
|
|
63
|
+
})
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from async_db_tools import PostgresPool
|
|
6
|
+
|
|
7
|
+
from wr_common_lib.email.constants import MailFlow, MailStatus
|
|
8
|
+
from wr_common_lib.email.types import InboundTask, OutboundTask
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmailDbOper:
|
|
12
|
+
"""email 表共享读写;各服务注入 PostgresPool 后使用。"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, db: PostgresPool):
|
|
15
|
+
self._db = db
|
|
16
|
+
|
|
17
|
+
async def mark_queued(self, task: OutboundTask) -> UUID:
|
|
18
|
+
"""开始发送:已有记录则更新为 QUEUED,否则插入出站记录。"""
|
|
19
|
+
email_id = task.get("id")
|
|
20
|
+
if email_id:
|
|
21
|
+
return await self._update_status(self._as_uuid(email_id), MailStatus.QUEUED)
|
|
22
|
+
|
|
23
|
+
existing_id = await self._find_id_by_content_hash(task.get("content_hash"))
|
|
24
|
+
if existing_id:
|
|
25
|
+
return await self._update_status(existing_id, MailStatus.QUEUED)
|
|
26
|
+
|
|
27
|
+
return await self._insert_outbound(task, MailStatus.QUEUED)
|
|
28
|
+
|
|
29
|
+
async def mark_sent(self, email_id: UUID) -> None:
|
|
30
|
+
await self._update_status(email_id, MailStatus.SENT)
|
|
31
|
+
|
|
32
|
+
async def mark_failed(self, email_id: UUID) -> None:
|
|
33
|
+
await self._update_status(email_id, MailStatus.FAILED)
|
|
34
|
+
|
|
35
|
+
async def find_by_content_hash(self, content_hash: str) -> dict[str, Any] | None:
|
|
36
|
+
row = await self._db.fetchrow(
|
|
37
|
+
"""
|
|
38
|
+
SELECT id, mail_status, mail_flow
|
|
39
|
+
FROM email
|
|
40
|
+
WHERE content_hash = $1
|
|
41
|
+
""",
|
|
42
|
+
content_hash,
|
|
43
|
+
)
|
|
44
|
+
return dict(row) if row else None
|
|
45
|
+
|
|
46
|
+
async def insert_inbound_received(self, task: InboundTask, content_hash: str) -> UUID:
|
|
47
|
+
"""写入入站记录,状态 RECEIVED。"""
|
|
48
|
+
return await self._insert_inbound(task, content_hash, MailStatus.RECEIVED)
|
|
49
|
+
|
|
50
|
+
async def mark_parsed(self, email_id: UUID, parsed_data: dict[str, Any]) -> None:
|
|
51
|
+
await self._db.execute(
|
|
52
|
+
"""
|
|
53
|
+
UPDATE email
|
|
54
|
+
SET mail_status = $2::mail_status,
|
|
55
|
+
parsed_data = $3::jsonb,
|
|
56
|
+
updated_at = now()
|
|
57
|
+
WHERE id = $1
|
|
58
|
+
""",
|
|
59
|
+
email_id,
|
|
60
|
+
MailStatus.PARSED.value,
|
|
61
|
+
json.dumps(parsed_data),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def mark_parse_failed(
|
|
65
|
+
self,
|
|
66
|
+
email_id: UUID,
|
|
67
|
+
parsed_data: dict[str, Any] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
await self._db.execute(
|
|
70
|
+
"""
|
|
71
|
+
UPDATE email
|
|
72
|
+
SET mail_status = $2::mail_status,
|
|
73
|
+
parsed_data = COALESCE($3::jsonb, parsed_data),
|
|
74
|
+
updated_at = now()
|
|
75
|
+
WHERE id = $1
|
|
76
|
+
""",
|
|
77
|
+
email_id,
|
|
78
|
+
MailStatus.PARSE_FAILED.value,
|
|
79
|
+
json.dumps(parsed_data) if parsed_data else None,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def _find_id_by_content_hash(self, content_hash: str | None) -> UUID | None:
|
|
83
|
+
if not content_hash:
|
|
84
|
+
return None
|
|
85
|
+
email_id = await self._db.fetchval(
|
|
86
|
+
"SELECT id FROM email WHERE content_hash = $1",
|
|
87
|
+
content_hash,
|
|
88
|
+
)
|
|
89
|
+
return self._as_uuid(email_id) if email_id else None
|
|
90
|
+
|
|
91
|
+
async def _update_status(self, email_id: UUID, status: MailStatus) -> UUID:
|
|
92
|
+
await self._db.execute(
|
|
93
|
+
"""
|
|
94
|
+
UPDATE email
|
|
95
|
+
SET mail_status = $2::mail_status, updated_at = now()
|
|
96
|
+
WHERE id = $1
|
|
97
|
+
""",
|
|
98
|
+
email_id,
|
|
99
|
+
status.value,
|
|
100
|
+
)
|
|
101
|
+
return email_id
|
|
102
|
+
|
|
103
|
+
async def _insert_outbound(self, task: OutboundTask, status: MailStatus) -> UUID:
|
|
104
|
+
self._require_keys(
|
|
105
|
+
task,
|
|
106
|
+
("imo", "voyage_id", "mail_from", "to", "content_hash"),
|
|
107
|
+
"新建邮件记录缺少必填字段: imo, voyage_id, mail_from, to, content_hash",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
row_id = await self._db.fetchval(
|
|
111
|
+
"""
|
|
112
|
+
INSERT INTO email (
|
|
113
|
+
imo, voyage_id, mail_flow, mail_status,
|
|
114
|
+
mail_from, mail_to, mail_cc, subject, content,
|
|
115
|
+
attachment, content_hash, created_user_id
|
|
116
|
+
)
|
|
117
|
+
VALUES (
|
|
118
|
+
$1, $2::uuid, $3::mail_flow, $4::mail_status,
|
|
119
|
+
$5, $6, $7, $8, $9,
|
|
120
|
+
$10::jsonb, $11, $12::uuid
|
|
121
|
+
)
|
|
122
|
+
ON CONFLICT (content_hash) DO UPDATE SET
|
|
123
|
+
mail_status = EXCLUDED.mail_status,
|
|
124
|
+
mail_to = EXCLUDED.mail_to,
|
|
125
|
+
mail_cc = EXCLUDED.mail_cc,
|
|
126
|
+
subject = EXCLUDED.subject,
|
|
127
|
+
content = EXCLUDED.content,
|
|
128
|
+
attachment = EXCLUDED.attachment,
|
|
129
|
+
updated_at = now()
|
|
130
|
+
RETURNING id
|
|
131
|
+
""",
|
|
132
|
+
int(task["imo"]),
|
|
133
|
+
str(task["voyage_id"]),
|
|
134
|
+
MailFlow.OUTBOUND.value,
|
|
135
|
+
status.value,
|
|
136
|
+
task["mail_from"],
|
|
137
|
+
task["to"],
|
|
138
|
+
task.get("cc") or None,
|
|
139
|
+
task.get("subject"),
|
|
140
|
+
task.get("content"),
|
|
141
|
+
self._to_jsonb(task.get("attachments")),
|
|
142
|
+
task["content_hash"],
|
|
143
|
+
task.get("created_user_id"),
|
|
144
|
+
)
|
|
145
|
+
return self._as_uuid(row_id)
|
|
146
|
+
|
|
147
|
+
async def _insert_inbound(
|
|
148
|
+
self,
|
|
149
|
+
task: InboundTask,
|
|
150
|
+
content_hash: str,
|
|
151
|
+
status: MailStatus,
|
|
152
|
+
) -> UUID:
|
|
153
|
+
self._require_keys(
|
|
154
|
+
task,
|
|
155
|
+
("imo", "voyage_id", "mail_from", "mail_to"),
|
|
156
|
+
"入站邮件缺少必填字段: imo, voyage_id, mail_from, mail_to",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
body = task.get("body_markdown") or task.get("raw_text") or ""
|
|
160
|
+
meta = {
|
|
161
|
+
"brevo": {
|
|
162
|
+
"uuid": task.get("brevo_uuid"),
|
|
163
|
+
"message_id": task.get("message_id"),
|
|
164
|
+
"in_reply_to": task.get("in_reply_to"),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
row_id = await self._db.fetchval(
|
|
169
|
+
"""
|
|
170
|
+
INSERT INTO email (
|
|
171
|
+
imo, voyage_id, mail_flow, mail_status,
|
|
172
|
+
mail_from, mail_to, mail_cc, subject, content,
|
|
173
|
+
attachment, parsed_data, content_hash
|
|
174
|
+
)
|
|
175
|
+
VALUES (
|
|
176
|
+
$1, $2::uuid, $3::mail_flow, $4::mail_status,
|
|
177
|
+
$5, $6, $7, $8, $9,
|
|
178
|
+
$10::jsonb, $11::jsonb, $12
|
|
179
|
+
)
|
|
180
|
+
ON CONFLICT (content_hash) DO UPDATE SET
|
|
181
|
+
mail_status = EXCLUDED.mail_status,
|
|
182
|
+
content = EXCLUDED.content,
|
|
183
|
+
attachment = EXCLUDED.attachment,
|
|
184
|
+
parsed_data = EXCLUDED.parsed_data,
|
|
185
|
+
updated_at = now()
|
|
186
|
+
RETURNING id
|
|
187
|
+
""",
|
|
188
|
+
int(task["imo"]),
|
|
189
|
+
str(task["voyage_id"]),
|
|
190
|
+
MailFlow.INBOUND.value,
|
|
191
|
+
status.value,
|
|
192
|
+
task["mail_from"],
|
|
193
|
+
task["mail_to"],
|
|
194
|
+
task.get("mail_cc") or None,
|
|
195
|
+
task.get("subject"),
|
|
196
|
+
body,
|
|
197
|
+
self._to_jsonb(task.get("attachments")),
|
|
198
|
+
json.dumps(meta),
|
|
199
|
+
content_hash,
|
|
200
|
+
)
|
|
201
|
+
return self._as_uuid(row_id)
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _as_uuid(value: str | UUID) -> UUID:
|
|
205
|
+
return value if isinstance(value, UUID) else UUID(str(value))
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _require_keys(task: dict[str, Any], keys: tuple[str, ...], message: str) -> None:
|
|
209
|
+
if not all(task.get(key) for key in keys):
|
|
210
|
+
raise ValueError(message)
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _to_jsonb(value: Any) -> str | None:
|
|
214
|
+
if not value:
|
|
215
|
+
return None
|
|
216
|
+
if isinstance(value, str):
|
|
217
|
+
return value
|
|
218
|
+
return json.dumps(value)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any, NotRequired, TypedDict
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OutboundTask(TypedDict, total=False):
|
|
6
|
+
"""出站邮件任务字段(发送服务)。"""
|
|
7
|
+
|
|
8
|
+
id: str | UUID
|
|
9
|
+
imo: int | str
|
|
10
|
+
voyage_id: str | UUID
|
|
11
|
+
mail_from: str
|
|
12
|
+
to: str
|
|
13
|
+
content_hash: str
|
|
14
|
+
cc: NotRequired[str | None]
|
|
15
|
+
subject: NotRequired[str | None]
|
|
16
|
+
content: NotRequired[str | None]
|
|
17
|
+
attachments: NotRequired[Any]
|
|
18
|
+
created_user_id: NotRequired[str | UUID | None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InboundTask(TypedDict, total=False):
|
|
22
|
+
"""入站邮件任务字段(收件 / 解析服务)。"""
|
|
23
|
+
|
|
24
|
+
imo: int | str
|
|
25
|
+
voyage_id: str | UUID
|
|
26
|
+
mail_from: str
|
|
27
|
+
mail_to: str
|
|
28
|
+
mail_cc: NotRequired[str | None]
|
|
29
|
+
subject: NotRequired[str | None]
|
|
30
|
+
body_markdown: NotRequired[str | None]
|
|
31
|
+
raw_text: NotRequired[str | None]
|
|
32
|
+
attachments: NotRequired[Any]
|
|
33
|
+
brevo_uuid: NotRequired[str | None]
|
|
34
|
+
message_id: NotRequired[str | None]
|
|
35
|
+
in_reply_to: NotRequired[str | None]
|