nonebot-plugin-randpic 2.0.1__tar.gz → 2.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.
Files changed (19) hide show
  1. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/PKG-INFO +25 -9
  2. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/README.md +24 -8
  3. nonebot_plugin_randpic-2.1.0/nonebot_plugin_randpic/ali_oss.py +132 -0
  4. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/library.py +11 -10
  5. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/models.py +10 -7
  6. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/repository.py +31 -94
  7. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/service.py +112 -62
  8. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/utils.py +2 -2
  9. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/web.py +28 -26
  10. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/pyproject.toml +1 -1
  11. nonebot_plugin_randpic-2.0.1/nonebot_plugin_randpic/ali_oss.py +0 -71
  12. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/LICENSE +0 -0
  13. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/__init__.py +0 -0
  14. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/compress.py +0 -0
  15. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/config.py +0 -0
  16. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/drivers/fastapi.py +0 -0
  17. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/handlers.py +0 -0
  18. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/journal.py +0 -0
  19. {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-randpic
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: 一个通过指令随机发送你存储的图片的nonebot2插件
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -66,10 +66,22 @@ _✨ 一个发送指令就能让你的 bot 发出对应指令的图片的插件
66
66
 
67
67
  首次启动会创建数据库,并扫描配置的图片文件夹,根据实际图片建立索引,发送次数从 0 开始。数据库不存在时也可由图片文件重新建立索引。
68
68
 
69
- 之后每次启动都会核对文件变化,并保留已有图片的发送次数、消息引用关系和同步状态。这些运行数据无法仅凭图片内容恢复,因此不要在正常使用期间删除数据库。
69
+ 之后每次启动都会核对文件变化,并保留已有图片的发送次数和消息引用关系。这些运行数据无法仅凭图片内容恢复,因此不要在正常使用期间删除数据库。
70
70
 
71
71
  实现说明见 [架构与数据一致性](./docs/architecture.md)。
72
72
 
73
+ ### 数据库
74
+
75
+ 数据库只保存三类业务数据:
76
+
77
+ | 表 | 用途 |
78
+ | --- | --- |
79
+ | `randpic_images` | 图片身份、指令、文件名、内容哈希、感知哈希、发送次数、文件大小和删除状态 |
80
+ | `randpic_messages` | Bot/群/消息 ID 与图片的对应关系,用于引用删除 |
81
+ | `randpic_sequences` | 每个指令的最大文件编号,防止删除后复用编号 |
82
+
83
+ 网页发布进度仅保存在内存,启动时重新生成;OSS 状态直接从远端获取,不建立数据库镜像或任务表。
84
+
73
85
  ### 本地存储和离线加图
74
86
 
75
87
  `randpic_store_dir_path` 指定的路径就是存储根目录,**不会额外追加一层 `randpic`**。未设置时,由 `nonebot-plugin-localstore` 提供默认目录。
@@ -84,8 +96,8 @@ data/randpic/
84
96
  │ │ └── randpic_capoo_0000000001.png
85
97
  │ └── 马哥/
86
98
  ├── database/
87
- │ ├── data.db # 图片、发送次数、历史消息、待同步任务
88
- │ └── ... # 文件恢复日志、网页上传缓存等内部文件
99
+ │ ├── data.db # 图片、发送次数、历史消息、文件编号
100
+ │ └── ... # 文件恢复日志、可选 OSS 页面缓存等内部文件
89
101
  └── public/ # 自动生成的本地静态网页与图片副本,请勿手动维护
90
102
  ```
91
103
 
@@ -105,7 +117,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
105
117
 
106
118
  使用 FastAPI 驱动时,Bot 启动后即可通过 `http://<host>:<port>/randpic/` 查看图库,无需配置 OSS。默认每页 100 张图片,可通过 `randpic_web_page_size` 调整。图片使用 `loading="lazy"`,按需加载。
107
119
 
108
- 增删图片后本地页面立即重新发布;已打开的网页在可见时每 15 秒检查一次目录版本,发现变化会自动刷新。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
120
+ 增删图片后本地页面立即重新发布,浏览器手动刷新即可查看最新内容。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
109
121
 
110
122
  #### 阿里云 OSS
111
123
 
@@ -114,7 +126,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
114
126
  - `randpic_endpoint`:绑定到 Bucket 的完整网页域名,如 `https://gallery.example.com`。
115
127
  - `randpic_bucket`:Bucket 名称。
116
128
  - `randpic_region`:地域 ID,如 `cn-beijing`,可查阅 [OSS 地域和访问域名](https://help.aliyun.com/zh/oss/regions-and-endpoints)。
117
- - AccessKey:需要上传和删除本插件对象的权限。
129
+ - AccessKey:需要 `oss:ListObjects`、`oss:GetObject`、`oss:PutObject` 和 `oss:DeleteObject` 权限,用于列举、核对、上传和删除图库对象。
118
130
 
119
131
  请配置网页访问权限、域名及静态网站首页 `index.html`,示意如下:
120
132
 
@@ -122,7 +134,11 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
122
134
 
123
135
  启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,普通终端中以单行动态进度显示完成数、百分比和成功/失败数,最多每秒刷新 5 次;其他插件的默认 NoneBot 日志显示在进度行上方。同步结束后清除动态行,只保留一条结果汇总,错误详情仍单独记录。输出重定向、非交互控制台或已移除 NoneBot 默认日志处理器时仅输出汇总。
124
136
 
125
- 失败任务存入数据库,后台约每 30 秒重试,重启后继续处理。OSS 的图片、页面和目录版本依次同步,网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;此前已同步的分类被排除后,其受本插件跟踪的云端对象会清理。未被数据库跟踪的历史对象不保证自动清理。
137
+ OSS 同步使用 Python SDK 分页列举远端对象,与本地发布文件比较大小和校验值,仅上传变化的文件,并清理不在本地发布清单中的云端图库文件。普通对象直接比较列表中的 ETag 与本地 MD5;遇到不一致的同大小对象,再查询 SHA-256 自定义元数据。无需安装 `ossutil`,数据库不保存云端状态或同步任务。
138
+
139
+ 后台只在内容变化或失败待重试时同步,失败后约每 30 秒重新比较;重启后重新检查远端,继续完成差异同步。网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;被排除或移除的分类,其云端图库文件会在比较后清理。
140
+
141
+ Bucket 根目录的 `index.html`、`<指令>/index.html`、分页及 `randpic_<指令>_<编号>.<图片后缀>` 均由插件维护。其他命名的文件不参与自动删除;请不要与其他站点共用上述路径。
126
142
 
127
143
  ## 💿 安装
128
144
 
@@ -222,8 +238,8 @@ randpic_oss_no_upload_list=["capoo"]
222
238
 
223
239
  群管理员或群主引用本插件发送的图库图片,再发送 `删除` 即可。添加成功时 Bot 回复的图片也支持引用删除。
224
240
 
225
- - 删除会移除原图和网页副本,并将数据库中的图片标记为已删除,并在启用 OSS 时删除云端对象、更新页面;影响所有群共用的图库,不会撤回已经发送的 QQ 消息。
226
- - 消息与图片的对应关系会持久化,重启 Bot 后仍可引用删除。升级前发送、尚未建立对应记录的历史消息无法直接删除,请让 Bot 重新发送对应图片后再操作。
241
+ - 删除会移除原图和网页副本,并将数据库中的图片标记为已删除,在启用 OSS 时删除云端对象、更新页面;影响所有群共用的图库,不会撤回已经发送的 QQ 消息。
242
+ - 消息与图片的对应关系会持久化,重启 Bot 后仍可引用删除。没有对应记录的消息无法直接删除,请让 Bot 重新发送对应图片后再操作。
227
243
  - 删除过的图片再次引用会提示已删除;若网页或 OSS 同步失败,再次引用原消息发送 `删除` 可重试,`上传oss` 也会重试未完成的删除同步。
228
244
 
229
245
  ### 效果图
@@ -35,10 +35,22 @@ _✨ 一个发送指令就能让你的 bot 发出对应指令的图片的插件
35
35
 
36
36
  首次启动会创建数据库,并扫描配置的图片文件夹,根据实际图片建立索引,发送次数从 0 开始。数据库不存在时也可由图片文件重新建立索引。
37
37
 
38
- 之后每次启动都会核对文件变化,并保留已有图片的发送次数、消息引用关系和同步状态。这些运行数据无法仅凭图片内容恢复,因此不要在正常使用期间删除数据库。
38
+ 之后每次启动都会核对文件变化,并保留已有图片的发送次数和消息引用关系。这些运行数据无法仅凭图片内容恢复,因此不要在正常使用期间删除数据库。
39
39
 
40
40
  实现说明见 [架构与数据一致性](./docs/architecture.md)。
41
41
 
42
+ ### 数据库
43
+
44
+ 数据库只保存三类业务数据:
45
+
46
+ | 表 | 用途 |
47
+ | --- | --- |
48
+ | `randpic_images` | 图片身份、指令、文件名、内容哈希、感知哈希、发送次数、文件大小和删除状态 |
49
+ | `randpic_messages` | Bot/群/消息 ID 与图片的对应关系,用于引用删除 |
50
+ | `randpic_sequences` | 每个指令的最大文件编号,防止删除后复用编号 |
51
+
52
+ 网页发布进度仅保存在内存,启动时重新生成;OSS 状态直接从远端获取,不建立数据库镜像或任务表。
53
+
42
54
  ### 本地存储和离线加图
43
55
 
44
56
  `randpic_store_dir_path` 指定的路径就是存储根目录,**不会额外追加一层 `randpic`**。未设置时,由 `nonebot-plugin-localstore` 提供默认目录。
@@ -53,8 +65,8 @@ data/randpic/
53
65
  │ │ └── randpic_capoo_0000000001.png
54
66
  │ └── 马哥/
55
67
  ├── database/
56
- │ ├── data.db # 图片、发送次数、历史消息、待同步任务
57
- │ └── ... # 文件恢复日志、网页上传缓存等内部文件
68
+ │ ├── data.db # 图片、发送次数、历史消息、文件编号
69
+ │ └── ... # 文件恢复日志、可选 OSS 页面缓存等内部文件
58
70
  └── public/ # 自动生成的本地静态网页与图片副本,请勿手动维护
59
71
  ```
60
72
 
@@ -74,7 +86,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
74
86
 
75
87
  使用 FastAPI 驱动时,Bot 启动后即可通过 `http://<host>:<port>/randpic/` 查看图库,无需配置 OSS。默认每页 100 张图片,可通过 `randpic_web_page_size` 调整。图片使用 `loading="lazy"`,按需加载。
76
88
 
77
- 增删图片后本地页面立即重新发布;已打开的网页在可见时每 15 秒检查一次目录版本,发现变化会自动刷新。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
89
+ 增删图片后本地页面立即重新发布,浏览器手动刷新即可查看最新内容。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
78
90
 
79
91
  #### 阿里云 OSS
80
92
 
@@ -83,7 +95,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
83
95
  - `randpic_endpoint`:绑定到 Bucket 的完整网页域名,如 `https://gallery.example.com`。
84
96
  - `randpic_bucket`:Bucket 名称。
85
97
  - `randpic_region`:地域 ID,如 `cn-beijing`,可查阅 [OSS 地域和访问域名](https://help.aliyun.com/zh/oss/regions-and-endpoints)。
86
- - AccessKey:需要上传和删除本插件对象的权限。
98
+ - AccessKey:需要 `oss:ListObjects`、`oss:GetObject`、`oss:PutObject` 和 `oss:DeleteObject` 权限,用于列举、核对、上传和删除图库对象。
87
99
 
88
100
  请配置网页访问权限、域名及静态网站首页 `index.html`,示意如下:
89
101
 
@@ -91,7 +103,11 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
91
103
 
92
104
  启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,普通终端中以单行动态进度显示完成数、百分比和成功/失败数,最多每秒刷新 5 次;其他插件的默认 NoneBot 日志显示在进度行上方。同步结束后清除动态行,只保留一条结果汇总,错误详情仍单独记录。输出重定向、非交互控制台或已移除 NoneBot 默认日志处理器时仅输出汇总。
93
105
 
94
- 失败任务存入数据库,后台约每 30 秒重试,重启后继续处理。OSS 的图片、页面和目录版本依次同步,网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;此前已同步的分类被排除后,其受本插件跟踪的云端对象会清理。未被数据库跟踪的历史对象不保证自动清理。
106
+ OSS 同步使用 Python SDK 分页列举远端对象,与本地发布文件比较大小和校验值,仅上传变化的文件,并清理不在本地发布清单中的云端图库文件。普通对象直接比较列表中的 ETag 与本地 MD5;遇到不一致的同大小对象,再查询 SHA-256 自定义元数据。无需安装 `ossutil`,数据库不保存云端状态或同步任务。
107
+
108
+ 后台只在内容变化或失败待重试时同步,失败后约每 30 秒重新比较;重启后重新检查远端,继续完成差异同步。网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;被排除或移除的分类,其云端图库文件会在比较后清理。
109
+
110
+ Bucket 根目录的 `index.html`、`<指令>/index.html`、分页及 `randpic_<指令>_<编号>.<图片后缀>` 均由插件维护。其他命名的文件不参与自动删除;请不要与其他站点共用上述路径。
95
111
 
96
112
  ## 💿 安装
97
113
 
@@ -191,8 +207,8 @@ randpic_oss_no_upload_list=["capoo"]
191
207
 
192
208
  群管理员或群主引用本插件发送的图库图片,再发送 `删除` 即可。添加成功时 Bot 回复的图片也支持引用删除。
193
209
 
194
- - 删除会移除原图和网页副本,并将数据库中的图片标记为已删除,并在启用 OSS 时删除云端对象、更新页面;影响所有群共用的图库,不会撤回已经发送的 QQ 消息。
195
- - 消息与图片的对应关系会持久化,重启 Bot 后仍可引用删除。升级前发送、尚未建立对应记录的历史消息无法直接删除,请让 Bot 重新发送对应图片后再操作。
210
+ - 删除会移除原图和网页副本,并将数据库中的图片标记为已删除,在启用 OSS 时删除云端对象、更新页面;影响所有群共用的图库,不会撤回已经发送的 QQ 消息。
211
+ - 消息与图片的对应关系会持久化,重启 Bot 后仍可引用删除。没有对应记录的消息无法直接删除,请让 Bot 重新发送对应图片后再操作。
196
212
  - 删除过的图片再次引用会提示已删除;若网页或 OSS 同步失败,再次引用原消息发送 `删除` 可重试,`上传oss` 也会重试未完成的删除同步。
197
213
 
198
214
  ### 效果图
@@ -0,0 +1,132 @@
1
+ """复用异步 OSS 客户端;不持有图库或数据库的锁。"""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import mimetypes
6
+ import re
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import Dict, Optional
9
+
10
+ import alibabacloud_oss_v2 as oss
11
+ import alibabacloud_oss_v2.aio as oss_aio
12
+ from nonebot.log import logger
13
+
14
+ from .config import Config
15
+ from .models import PublishedFile, RandpicError, RemoteObject
16
+ from .utils import SUPPORTED_EXTENSIONS, parse_index, run_blocking, valid_command
17
+
18
+
19
+ def is_gallery_key(key: str) -> bool:
20
+ """只处理插件自己的网页和规范命名图片,不清理 Bucket 内任意文件。"""
21
+ if key == 'index.html':
22
+ return True
23
+ parts = PurePosixPath(key).parts
24
+ if len(parts) != 2 or key != '/'.join(parts) or not valid_command(parts[0]):
25
+ return False
26
+ command, filename = parts
27
+ if filename == 'index.html' or re.fullmatch(r'page-[1-9][0-9]*\.html', filename):
28
+ return True
29
+ path = PurePosixPath(filename)
30
+ return path.suffix.lower() in SUPPORTED_EXTENSIONS and parse_index(path.stem, command) is not None
31
+
32
+
33
+ class OSSUploaderV2:
34
+ def __init__(self, config: Config):
35
+ credentials = oss.credentials.StaticCredentialsProvider(
36
+ access_key_id=config.randpic_oss_access_key_id,
37
+ access_key_secret=config.randpic_oss_access_key_secret,
38
+ )
39
+ self.cfg = oss.config.load_default()
40
+ self.cfg.credentials_provider = credentials
41
+ self.cfg.region = config.randpic_region
42
+ self.bucket = config.randpic_bucket
43
+ self.client = None
44
+
45
+ async def open(self) -> None:
46
+ if self.client is None:
47
+ self.client = oss_aio.AsyncClient(self.cfg)
48
+
49
+ async def close(self) -> None:
50
+ if self.client is not None:
51
+ await self.client.close()
52
+ self.client = None
53
+
54
+ async def list_files(self) -> Dict[str, RemoteObject]:
55
+ """完整分页列举成功后才交给同步器,避免部分列表造成错误删除。"""
56
+ await self.open()
57
+ objects: Dict[str, RemoteObject] = {}
58
+ token = None
59
+ seen_tokens = set()
60
+ while True:
61
+ result = await asyncio.wait_for(self.client.list_objects_v2(oss.ListObjectsV2Request(
62
+ bucket=self.bucket, max_keys=1000, continuation_token=token,
63
+ )), timeout=60)
64
+ if not 200 <= result.status_code < 300:
65
+ raise RandpicError(f'列举 OSS 对象失败:HTTP {result.status_code}')
66
+ for item in result.contents or []:
67
+ if item.key is not None and is_gallery_key(item.key):
68
+ objects[item.key] = RemoteObject(int(item.size), str(item.etag or '').strip('"').lower())
69
+ if not result.is_truncated:
70
+ return objects
71
+ next_token = result.next_continuation_token
72
+ if not next_token or next_token in seen_tokens:
73
+ raise RandpicError('OSS 分页标记无效,本轮停止同步。')
74
+ seen_tokens.add(next_token)
75
+ token = next_token
76
+
77
+ async def matches_file(self, key: str, file: PublishedFile, remote: RemoteObject) -> bool:
78
+ if remote.size != file.size:
79
+ return False
80
+ if remote.etag == file.etag:
81
+ return True
82
+ # 分片上传或加密对象的 ETag 不保证为 MD5,按本插件的 SHA-256 元数据核对。
83
+ await self.open()
84
+ try:
85
+ result = await asyncio.wait_for(self.client.head_object(oss.HeadObjectRequest(
86
+ bucket=self.bucket, key=key,
87
+ )), timeout=60)
88
+ except oss.exceptions.ServiceError as exc:
89
+ if exc.code == 'NoSuchKey':
90
+ return False
91
+ raise
92
+ if not 200 <= result.status_code < 300:
93
+ raise RandpicError(f'读取 OSS 对象属性失败:{key} (HTTP {result.status_code})')
94
+ return (result.content_length == file.size
95
+ and (result.metadata or {}).get('randpic-sha256') == file.content_hash)
96
+
97
+ async def upload_file(self, local_file_path: str, oss_key: str, expected_hash: Optional[str] = None) -> bool:
98
+ try:
99
+ await self.open()
100
+ data = await run_blocking(Path(local_file_path).read_bytes)
101
+ content_hash = hashlib.sha256(data).hexdigest()
102
+ if expected_hash is not None and content_hash != expected_hash:
103
+ return False # 任务已过期,后续版本会重新上传,不发送旧内容。
104
+ result = await asyncio.wait_for(self.client.put_object(oss.PutObjectRequest(
105
+ bucket=self.bucket, key=oss_key, body=data,
106
+ metadata={'randpic-sha256': content_hash},
107
+ content_type=mimetypes.guess_type(oss_key)[0] or 'application/octet-stream',
108
+ cache_control='no-cache' if oss_key.endswith('.html') else 'public, max-age=31536000, immutable',
109
+ )), timeout=60)
110
+ if 200 <= result.status_code < 300:
111
+ return True
112
+ logger.error(f'OSS 上传失败:{oss_key} (HTTP {result.status_code})')
113
+ except Exception as exc:
114
+ logger.error(f'OSS 上传失败:{oss_key} ({exc})')
115
+ return False
116
+
117
+ async def delete_file(self, oss_key: str) -> bool:
118
+ try:
119
+ await self.open()
120
+ result = await asyncio.wait_for(self.client.delete_object(
121
+ oss.DeleteObjectRequest(bucket=self.bucket, key=oss_key)
122
+ ), timeout=60)
123
+ if 200 <= result.status_code < 300:
124
+ return True
125
+ logger.error(f'OSS 删除失败:{oss_key} (HTTP {result.status_code})')
126
+ except oss.exceptions.ServiceError as exc:
127
+ if exc.code == 'NoSuchKey':
128
+ return True
129
+ logger.error(f'OSS 删除失败:{oss_key} ({exc})')
130
+ except Exception as exc:
131
+ logger.error(f'OSS 删除失败:{oss_key} ({exc})')
132
+ return False
@@ -38,6 +38,7 @@ class ImageLibrary:
38
38
  self.settings = CommandSettings(self.root)
39
39
  self.journal = FileJournal(self.root, self.repository)
40
40
  self.store_lock = StoreLock(self.root / 'database' / 'randpic.lock')
41
+ self.revision = 0
41
42
 
42
43
  def start(self) -> None:
43
44
  for directory in (self.root, self.image_root, self.root / 'database'):
@@ -61,7 +62,7 @@ class ImageLibrary:
61
62
  def _candidates(self, command: str, existing: List[ImageRecord]) -> List[_Candidate]:
62
63
  directory = safe_path(self.image_root, f'{command}/placeholder').parent
63
64
  directory.mkdir(parents=True, exist_ok=True)
64
- cached = {image.content_hash: image for image in existing if image.validated}
65
+ cached = {image.content_hash: image for image in existing}
65
66
  candidates = []
66
67
  for source in sorted(directory.iterdir()):
67
68
  if source.is_symlink() or not source.is_file() or source.name.startswith('.randpic-'):
@@ -161,15 +162,15 @@ class ImageLibrary:
161
162
  self.journal.write(image, candidate.source, candidate.payload)
162
163
  destination = self.image_root / image.key
163
164
  stat = destination.stat()
164
- image = replace(image, byte_size=stat.st_size, mtime_ns=stat.st_mtime_ns)
165
+ image = replace(image, byte_size=stat.st_size)
165
166
  self.repository.upsert_image(db, image)
166
167
  records.append(image)
167
168
  self.repository.set_sequence(db, command, maximum)
168
169
  old_state = {(i.id, i.filename, i.content_hash) for i in existing}
169
170
  new_state = {(i.id, i.filename, i.content_hash) for i in records}
170
171
  changed = changed or old_state != new_state
171
- if changed:
172
- self.repository.mark_dirty(db)
172
+ if changed:
173
+ self.revision += 1
173
174
  finally:
174
175
  # 事务已经提交时清理备份,事务失败时还原文件。
175
176
  self.journal.recover()
@@ -197,12 +198,12 @@ class ImageLibrary:
197
198
  payload = self.journal.stage(processed.data)
198
199
  self.journal.write(image, None, payload)
199
200
  stat = (directory / image.filename).stat()
200
- image = replace(image, byte_size=stat.st_size, mtime_ns=stat.st_mtime_ns)
201
+ image = replace(image, byte_size=stat.st_size)
201
202
  self.repository.upsert_image(db, image)
202
203
  self.repository.set_sequence(db, command, index)
203
- self.repository.mark_dirty(db)
204
204
  finally:
205
205
  self.journal.recover()
206
+ self.revision += 1
206
207
  saved = self.settings.add(command)
207
208
  return MutationResult(image, settings_saved=saved)
208
209
 
@@ -216,11 +217,11 @@ class ImageLibrary:
216
217
  if path.exists() and (not path.is_file() or digest_file(path) != image.content_hash):
217
218
  raise RandpicError('这条消息对应的文件已被替换,未执行删除。请重新发送当前图片后再操作。')
218
219
  self.journal.delete(image)
219
- db.execute('UPDATE randpic_images SET deleted = 1 WHERE id = ?', (image.id,))
220
- self.repository.mark_dirty(db)
220
+ self.repository.mark_deleted(db, image.id)
221
221
  result = MutationResult(replace(image, deleted=True))
222
222
  finally:
223
223
  self.journal.recover()
224
+ self.revision += 1
224
225
  return result
225
226
 
226
227
  def pick(self, command: str) -> Optional[ImageSnapshot]:
@@ -237,6 +238,6 @@ class ImageLibrary:
237
238
  except FileNotFoundError:
238
239
  # 清理在运行中意外消失的文件,并继续挑选其他图片。
239
240
  with self.repository.connection(write=True) as db:
240
- db.execute('UPDATE randpic_images SET deleted = 1 WHERE id = ?', (image.id,))
241
- self.repository.mark_dirty(db)
241
+ self.repository.mark_deleted(db, image.id)
242
+ self.revision += 1
242
243
  logger.warning(f'图片文件已丢失,已从图库索引移除:{path}')
@@ -25,9 +25,7 @@ class ImageRecord:
25
25
  phash: str
26
26
  send_count: int = 0
27
27
  byte_size: int = 0
28
- mtime_ns: int = 0
29
28
  deleted: bool = False
30
- validated: bool = True
31
29
 
32
30
  @property
33
31
  def key(self) -> str:
@@ -49,12 +47,17 @@ class MutationResult:
49
47
 
50
48
 
51
49
  @dataclass(frozen=True)
52
- class SyncJob:
53
- key: str
54
- action: str
55
- local_path: Optional[str]
50
+ class PublishedFile:
51
+ path: str
56
52
  content_hash: str
57
- revision: int
53
+ etag: str
54
+ size: int
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class RemoteObject:
59
+ size: int
60
+ etag: str
58
61
 
59
62
 
60
63
  @dataclass(frozen=True)
@@ -4,41 +4,40 @@ import sqlite3
4
4
  from contextlib import contextmanager
5
5
  from dataclasses import fields
6
6
  from pathlib import Path
7
- from typing import Iterator, List, Mapping, Optional, Tuple
7
+ from typing import Iterator, List, Optional
8
8
 
9
- from .models import ImageRecord, RandpicError, SyncJob
9
+ from .models import ImageRecord, RandpicError
10
10
 
11
11
 
12
12
  IMAGE_COLUMNS = ', '.join(field.name for field in fields(ImageRecord))
13
13
  SCHEMA = '''
14
- CREATE TABLE IF NOT EXISTS randpic_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
15
14
  CREATE TABLE IF NOT EXISTS randpic_images (
16
15
  id TEXT PRIMARY KEY, command TEXT NOT NULL, filename TEXT NOT NULL,
17
- content_hash TEXT NOT NULL, phash TEXT NOT NULL, send_count INTEGER NOT NULL DEFAULT 0 CHECK(send_count >= 0),
18
- byte_size INTEGER NOT NULL DEFAULT 0, mtime_ns INTEGER NOT NULL DEFAULT 0,
19
- deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN (0, 1)),
20
- validated INTEGER NOT NULL DEFAULT 1 CHECK(validated IN (0, 1))
21
- );
22
- CREATE UNIQUE INDEX IF NOT EXISTS randpic_image_path ON randpic_images(command, filename) WHERE deleted = 0;
23
- CREATE INDEX IF NOT EXISTS randpic_image_command ON randpic_images(command, deleted, id);
16
+ content_hash TEXT NOT NULL, phash TEXT NOT NULL,
17
+ send_count INTEGER NOT NULL DEFAULT 0 CHECK(send_count >= 0),
18
+ byte_size INTEGER NOT NULL DEFAULT 0 CHECK(byte_size >= 0),
19
+ deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN (0, 1))
20
+ ) WITHOUT ROWID;
21
+ CREATE UNIQUE INDEX IF NOT EXISTS randpic_image_path
22
+ ON randpic_images(command, filename) WHERE deleted = 0;
23
+ CREATE INDEX IF NOT EXISTS randpic_image_command
24
+ ON randpic_images(command, id) WHERE deleted = 0;
25
+ CREATE INDEX IF NOT EXISTS randpic_image_hash
26
+ ON randpic_images(command, content_hash) WHERE deleted = 0;
24
27
  CREATE TABLE IF NOT EXISTS randpic_messages (
25
28
  bot_id TEXT NOT NULL, group_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
26
- image_id TEXT NOT NULL REFERENCES randpic_images(id), PRIMARY KEY(bot_id, group_id, message_id)
27
- );
28
- CREATE TABLE IF NOT EXISTS randpic_sequences (command TEXT PRIMARY KEY, last_index INTEGER NOT NULL);
29
- CREATE TABLE IF NOT EXISTS randpic_remote (key TEXT PRIMARY KEY, content_hash TEXT NOT NULL);
30
- CREATE TABLE IF NOT EXISTS randpic_sync_jobs (
31
- key TEXT PRIMARY KEY, action TEXT NOT NULL CHECK(action IN ('upload', 'delete')),
32
- local_path TEXT, content_hash TEXT NOT NULL, revision INTEGER NOT NULL,
33
- attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT
34
- );
29
+ image_id TEXT NOT NULL REFERENCES randpic_images(id),
30
+ PRIMARY KEY(bot_id, group_id, message_id)
31
+ ) WITHOUT ROWID;
32
+ CREATE TABLE IF NOT EXISTS randpic_sequences (
33
+ command TEXT PRIMARY KEY, last_index INTEGER NOT NULL CHECK(last_index >= 0)
34
+ ) WITHOUT ROWID;
35
35
  '''
36
36
 
37
37
 
38
38
  def _image(row: sqlite3.Row) -> ImageRecord:
39
39
  values = dict(row)
40
40
  values['deleted'] = bool(values['deleted'])
41
- values['validated'] = bool(values['validated'])
42
41
  return ImageRecord(**values)
43
42
 
44
43
 
@@ -74,33 +73,16 @@ class Repository:
74
73
  finally:
75
74
  db.close()
76
75
 
77
- @staticmethod
78
- def meta(db: sqlite3.Connection, key: str, default: str = '0') -> str:
79
- row = db.execute('SELECT value FROM randpic_meta WHERE key = ?', (key,)).fetchone()
80
- return row[0] if row else default
81
-
82
- @staticmethod
83
- def set_meta(db: sqlite3.Connection, key: str, value: str) -> None:
84
- db.execute('INSERT OR REPLACE INTO randpic_meta VALUES (?, ?)', (key, value))
85
-
86
- def mark_dirty(self, db: sqlite3.Connection) -> None:
87
- self.set_meta(db, 'catalog_revision', str(int(self.meta(db, 'catalog_revision')) + 1))
88
-
89
76
  def initialize(self) -> None:
90
77
  self.path.parent.mkdir(parents=True, exist_ok=True)
91
78
  with self.connection() as db:
92
- names = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
93
- version = int(self.meta(db, 'schema_version')) if 'randpic_meta' in names else 0
94
- if version > 2:
95
- raise RandpicError('数据库来自更新版本的 Randpic,不能用 2.0.0 覆盖。')
96
79
  db.execute('PRAGMA journal_mode = WAL')
97
80
  db.execute('PRAGMA synchronous = FULL')
98
81
  db.executescript(SCHEMA)
99
- with self.connection(write=True) as db:
100
- if int(self.meta(db, 'schema_version')) == 0:
101
- self.set_meta(db, 'schema_version', '2')
102
- self.set_meta(db, 'published_revision', '-1')
103
- self.mark_dirty(db)
82
+
83
+ @staticmethod
84
+ def mark_deleted(db: sqlite3.Connection, image_id: str) -> None:
85
+ db.execute('UPDATE randpic_images SET deleted = 1 WHERE id = ?', (image_id,))
104
86
 
105
87
  @staticmethod
106
88
  def upsert_image(db: sqlite3.Connection, image: ImageRecord) -> None:
@@ -145,9 +127,14 @@ class Repository:
145
127
 
146
128
  @staticmethod
147
129
  def duplicate(db: sqlite3.Connection, command: str, content_hash: str, phash: str) -> bool:
148
- return db.execute('''SELECT 1 FROM randpic_images WHERE command = ? AND deleted = 0
149
- AND (content_hash = ? OR hamming_distance(phash, ?) <= 5) LIMIT 1''',
150
- (command, content_hash, phash)).fetchone() is not None
130
+ exact = db.execute('''SELECT 1 FROM randpic_images
131
+ WHERE command = ? AND deleted = 0 AND content_hash = ? LIMIT 1''',
132
+ (command, content_hash)).fetchone()
133
+ if exact is not None:
134
+ return True
135
+ return db.execute('''SELECT 1 FROM randpic_images
136
+ WHERE command = ? AND deleted = 0 AND hamming_distance(phash, ?) <= 5 LIMIT 1''',
137
+ (command, phash)).fetchone() is not None
151
138
 
152
139
  @staticmethod
153
140
  def sequence(db: sqlite3.Connection, command: str) -> int:
@@ -179,53 +166,3 @@ class Repository:
179
166
  if row is None:
180
167
  raise RandpicError('找不到这条消息对应的图库图片,请引用本插件发送的图片。')
181
168
  return _image(row)
182
-
183
- def revision(self) -> Tuple[int, int]:
184
- with self.connection() as db:
185
- return int(self.meta(db, 'catalog_revision')), int(self.meta(db, 'published_revision', '-1'))
186
-
187
- def published(self, revision: int) -> None:
188
- with self.connection(write=True) as db:
189
- self.set_meta(db, 'published_revision', str(revision))
190
-
191
- def reconcile_remote(self, manifest: Mapping[str, Tuple[str, str]], *, force: bool = False) -> None:
192
- with self.connection(write=True) as db:
193
- known = {row['key']: row['content_hash'] for row in db.execute('SELECT * FROM randpic_remote')}
194
- jobs = {row['key']: row for row in db.execute('SELECT * FROM randpic_sync_jobs')}
195
- revision = int(self.meta(db, 'sync_revision'))
196
-
197
- def enqueue(key: str, action: str, path: Optional[str], content_hash: str) -> None:
198
- nonlocal revision
199
- old = jobs.get(key)
200
- if old and (old['action'], old['local_path'], old['content_hash']) == (action, path, content_hash) and not force:
201
- return
202
- revision += 1
203
- db.execute('''INSERT OR REPLACE INTO randpic_sync_jobs
204
- (key, action, local_path, content_hash, revision) VALUES (?, ?, ?, ?, ?)''',
205
- (key, action, path, content_hash, revision))
206
-
207
- for key, (path, content_hash) in manifest.items():
208
- if force or known.get(key) != content_hash or key in jobs:
209
- enqueue(key, 'upload', path, content_hash)
210
- for key in (set(known) | set(jobs)) - set(manifest):
211
- enqueue(key, 'delete', None, '')
212
- self.set_meta(db, 'sync_revision', str(revision))
213
-
214
- def sync_jobs(self) -> List[SyncJob]:
215
- with self.connection() as db:
216
- return [SyncJob(row['key'], row['action'], row['local_path'], row['content_hash'], row['revision'])
217
- for row in db.execute('SELECT * FROM randpic_sync_jobs ORDER BY revision')]
218
-
219
- def complete_job(self, job: SyncJob) -> None:
220
- with self.connection(write=True) as db:
221
- if job.action == 'delete':
222
- db.execute('DELETE FROM randpic_remote WHERE key = ?', (job.key,))
223
- else:
224
- db.execute('INSERT OR REPLACE INTO randpic_remote VALUES (?, ?)', (job.key, job.content_hash))
225
- # 老请求完成时,只能确认自己的版本,不能覆盖期间新加入的删除/上传任务。
226
- db.execute('DELETE FROM randpic_sync_jobs WHERE key = ? AND revision = ?', (job.key, job.revision))
227
-
228
- def fail_job(self, job: SyncJob, error: str) -> None:
229
- with self.connection(write=True) as db:
230
- db.execute('''UPDATE randpic_sync_jobs SET attempts = attempts + 1, last_error = ?
231
- WHERE key = ? AND revision = ?''', (error[:500], job.key, job.revision))
@@ -1,9 +1,9 @@
1
- """异步应用服务:业务串行提交,网页及时发布,远端任务独立重试。"""
1
+ """异步应用服务:业务串行提交,网页及时发布,按远端实际状态同步。"""
2
2
 
3
3
  import asyncio
4
4
  from dataclasses import replace
5
5
  from pathlib import Path
6
- from typing import Any, Optional
6
+ from typing import Any, Dict, Optional
7
7
  from urllib.parse import urlsplit
8
8
 
9
9
  import httpx
@@ -12,7 +12,7 @@ from nonebot.log import logger
12
12
  from .ali_oss import OSSUploaderV2
13
13
  from .config import Config
14
14
  from .library import ImageLibrary
15
- from .models import ImageRecord, ImageSnapshot, MutationResult, RandpicError, SyncResult
15
+ from .models import ImageRecord, ImageSnapshot, MutationResult, PublishedFile, RandpicError, SyncResult
16
16
  from .progress import SyncProgress
17
17
  from .utils import run_blocking
18
18
  from .web import GalleryPublisher
@@ -35,6 +35,10 @@ class RandpicService:
35
35
  self._worker: Optional[asyncio.Task] = None
36
36
  self.ready = False
37
37
  self._web_dirty = True
38
+ self._published_revision = -1
39
+ self._published_remote_enabled = False
40
+ self._manifest: Dict[str, PublishedFile] = {}
41
+ self._remote_dirty = True
38
42
  self._closed = False
39
43
 
40
44
  @property
@@ -93,19 +97,23 @@ class RandpicService:
93
97
  finally:
94
98
  await run_blocking(self.library.close)
95
99
 
96
- async def _refresh_locked(self, *, force: bool = False, force_upload: bool = False) -> Optional[str]:
100
+ async def _refresh_locked(self, *, force: bool = False) -> Optional[str]:
97
101
  try:
98
- revision, published = await run_blocking(self.repository.revision)
99
- if not force and not force_upload and not self._web_dirty and revision == published:
102
+ revision = self.library.revision
103
+ remote_enabled = self.uploader is not None
104
+ if (not force and not self._web_dirty and revision == self._published_revision
105
+ and remote_enabled == self._published_remote_enabled):
100
106
  return None
101
107
  images = await run_blocking(self.repository.list_images)
102
108
  manifest = await run_blocking(
103
- self.publisher.publish, images, list(self.commands), set(self.config.randpic_oss_no_upload_list)
109
+ self.publisher.publish, images, list(self.commands),
110
+ set(self.config.randpic_oss_no_upload_list), remote_enabled=remote_enabled,
104
111
  )
105
- if self.uploader is not None:
106
- await run_blocking(self.repository.reconcile_remote, manifest, force=force_upload)
107
- await run_blocking(self.repository.published, revision)
112
+ self._manifest = manifest
113
+ self._published_revision = revision
114
+ self._published_remote_enabled = remote_enabled
108
115
  self._web_dirty = False
116
+ self._remote_dirty = remote_enabled
109
117
  return None
110
118
  except Exception as exc:
111
119
  self._web_dirty = True
@@ -156,7 +164,7 @@ class RandpicService:
156
164
  self._require_ready()
157
165
  snapshot = await run_blocking(self.library.pick, command)
158
166
  error = await self._refresh_locked()
159
- if error:
167
+ if error or self._remote_dirty:
160
168
  self._wake.set()
161
169
  return snapshot
162
170
 
@@ -184,61 +192,100 @@ class RandpicService:
184
192
  return SyncResult()
185
193
  async with self._mutation_lock:
186
194
  self._require_ready()
187
- error = await self._refresh_locked(force_upload=force)
195
+ error = await self._refresh_locked(force=force)
196
+ manifest = dict(self._manifest)
188
197
  if error:
189
- return SyncResult(web_error=error)
190
- if self.uploader is None:
198
+ return SyncResult(pending=1, web_error=error)
199
+ if self.uploader is None or (not force and not self._remote_dirty):
191
200
  return SyncResult()
192
- jobs = await run_blocking(self.repository.sync_jobs)
193
- if not jobs:
194
- return SyncResult()
195
- succeeded = failed = completed = 0
201
+ self._remote_dirty = True
196
202
  started = asyncio.get_running_loop().time()
203
+ try:
204
+ remote = await self.uploader.list_files()
205
+ except Exception as exc:
206
+ logger.error(f'列举 OSS 文件失败,本轮未执行上传或删除,将自动重试:{exc}')
207
+ return SyncResult(failed=1, pending=1)
208
+
209
+ # 普通 PutObject 的 ETag 与 MD5 相同;大多数对象只需分页列表即可确认。
210
+ jobs = []
211
+ comparisons = []
212
+ failed_keys = set()
213
+ for key, file in manifest.items():
214
+ old = remote.get(key)
215
+ if force or old is None or old.size != file.size:
216
+ jobs.append((key, file))
217
+ elif old.etag != file.etag:
218
+ comparisons.append((key, file, old))
219
+ candidates = iter(comparisons)
220
+
221
+ async def compare_worker():
222
+ for key, file, old in candidates:
223
+ try:
224
+ if not await self.uploader.matches_file(key, file, old):
225
+ jobs.append((key, file))
226
+ except Exception as exc:
227
+ failed_keys.add(key)
228
+ logger.error(f'核对 OSS 文件失败,将自动重试:{key} ({exc})')
229
+
230
+ limit = self.config.randpic_upload_concurrency
231
+ await asyncio.gather(*(compare_worker() for _ in range(min(limit, len(comparisons)))))
232
+ jobs.extend((key, None) for key in remote.keys() - manifest.keys())
233
+ succeeded = completed = 0
197
234
 
198
- with SyncProgress(len(jobs)) as progress:
199
- async def phase(items):
200
- queue = asyncio.Queue()
201
- for job in items:
202
- queue.put_nowait(job)
203
-
204
- async def worker():
205
- nonlocal succeeded, failed, completed
206
- while not queue.empty():
207
- job = queue.get_nowait()
208
- try:
209
- if job.action == 'delete':
210
- ok = await self.uploader.delete_file(job.key)
211
- else:
212
- ok = await self.uploader.upload_file(job.local_path, job.key, job.content_hash)
213
- if ok:
214
- await run_blocking(self.repository.complete_job, job)
215
- succeeded += 1
216
- else:
217
- await run_blocking(self.repository.fail_job, job, '远端请求失败或任务已过期')
218
- failed += 1
219
- except Exception as exc:
220
- logger.error(f'同步 OSS 任务失败,已保留以便重试:{job.key} ({exc})')
221
- failed += 1
222
- finally:
223
- queue.task_done()
224
- completed += 1
225
- progress.update(completed, succeeded, failed)
226
-
227
- await asyncio.gather(*(worker() for _ in range(min(self.config.randpic_upload_concurrency, len(items)))))
228
-
229
- # 先同步图片,再同步页面,最后更新用于浏览器自动刷新的目录版本。
230
- await phase([job for job in jobs if not job.key.endswith(('.html', '.json'))])
231
- await phase([job for job in jobs if job.key.endswith('.html')])
232
- await phase([job for job in jobs if job.key.endswith('.json')])
233
- pending = len(await run_blocking(self.repository.sync_jobs))
234
- elapsed = asyncio.get_running_loop().time() - started
235
- summary = (f'OSS 同步完成:成功 {succeeded},失败 {failed},待处理 {pending},'
236
- f'用时 {elapsed:.1f} 秒')
237
- if failed:
238
- logger.warning(summary + ';失败任务将自动重试')
239
- else:
240
- logger.info(summary)
241
- return SyncResult(succeeded=succeeded, failed=failed, pending=pending)
235
+ if jobs:
236
+ with SyncProgress(len(jobs)) as progress:
237
+ async def phase(items):
238
+ remaining = iter(items)
239
+
240
+ async def worker():
241
+ nonlocal succeeded, completed
242
+ for key, file in remaining:
243
+ try:
244
+ async with self._mutation_lock:
245
+ # 跳过被同步期间新操作替代的上传或删除。
246
+ if self._manifest.get(key) != file:
247
+ continue
248
+ if file is None:
249
+ ok = await self.uploader.delete_file(key)
250
+ else:
251
+ ok = await self.uploader.upload_file(file.path, key, file.content_hash)
252
+ if ok:
253
+ succeeded += 1
254
+ else:
255
+ failed_keys.add(key)
256
+ except Exception as exc:
257
+ failed_keys.add(key)
258
+ logger.error(f'同步 OSS 文件失败,将自动重试:{key} ({exc})')
259
+ finally:
260
+ completed += 1
261
+ progress.update(completed, succeeded, len(failed_keys))
262
+
263
+ await asyncio.gather(*(worker() for _ in range(min(limit, len(items)))))
264
+
265
+ # 先处理图片,再更新网页;网络期间不占用图库修改锁。
266
+ await phase([job for job in jobs if not job[0].endswith('.html')])
267
+ await phase([job for job in jobs if job[0].endswith('.html')])
268
+
269
+ async with self._mutation_lock:
270
+ pending_keys = failed_keys | {
271
+ key for key in manifest.keys() | self._manifest.keys()
272
+ if manifest.get(key) != self._manifest.get(key)
273
+ }
274
+ if self._web_dirty or self.library.revision != self._published_revision:
275
+ pending_keys.add('<网页待发布>')
276
+ self._remote_dirty = bool(pending_keys)
277
+ changed = manifest != self._manifest
278
+ if changed:
279
+ self._wake.set()
280
+ if jobs or failed_keys:
281
+ elapsed = asyncio.get_running_loop().time() - started
282
+ summary = (f'OSS 同步完成:成功 {succeeded},失败 {len(failed_keys)},'
283
+ f'待处理 {len(pending_keys)},用时 {elapsed:.1f} 秒')
284
+ if failed_keys:
285
+ logger.warning(summary + ';将重新比较远端内容后重试')
286
+ else:
287
+ logger.info(summary)
288
+ return SyncResult(succeeded=succeeded, failed=len(failed_keys), pending=len(pending_keys))
242
289
 
243
290
  async def _retry_worker(self) -> None:
244
291
  while True:
@@ -249,6 +296,9 @@ class RandpicService:
249
296
  self._wake.clear()
250
297
  if self._closed:
251
298
  return
299
+ if (not self._web_dirty and self.library.revision == self._published_revision
300
+ and (self.uploader is None or not self._remote_dirty)):
301
+ continue
252
302
  try:
253
303
  await self.sync()
254
304
  except Exception as exc:
@@ -49,8 +49,8 @@ def safe_path(root: Path, key: str) -> Path:
49
49
  return path
50
50
 
51
51
 
52
- def digest_file(path: Path) -> str:
53
- digest = hashlib.sha256()
52
+ def digest_file(path: Path, algorithm: str = 'sha256') -> str:
53
+ digest = hashlib.new(algorithm, usedforsecurity=False)
54
54
  with path.open('rb') as file:
55
55
  for block in iter(lambda: file.read(128 * 1024), b''):
56
56
  digest.update(block)
@@ -1,14 +1,13 @@
1
- """静态网页发布:原子替换文件、分页、懒加载,以及增删后的自动刷新。"""
1
+ """静态网页发布:原子替换文件、分页与图片懒加载。"""
2
2
 
3
3
  import hashlib
4
- import json
5
4
  import math
6
5
  from html import escape
7
6
  from pathlib import Path
8
- from typing import Dict, Iterable, List, Mapping, Sequence, Set, Tuple
7
+ from typing import Dict, Iterable, List, Mapping, Sequence, Set
9
8
  from urllib.parse import quote
10
9
 
11
- from .models import ImageRecord, RandpicError
10
+ from .models import ImageRecord, PublishedFile, RandpicError
12
11
  from .utils import atomic_write, digest_file, parse_index, safe_path
13
12
 
14
13
  GALLERY_STYLE = '''
@@ -67,8 +66,7 @@ def _url(image: ImageRecord) -> str:
67
66
  return quote(image.filename, safe='') + '?v=' + image.content_hash[:16]
68
67
 
69
68
 
70
- def _shell(title: str, home: str, version: str, command: str, page: int, content: str) -> str:
71
- state = json.dumps({'version': version, 'command': command, 'page': page}, ensure_ascii=False).replace('<', '\\u003c')
69
+ def _shell(title: str, home: str, content: str) -> str:
72
70
  return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
73
71
  <meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#f5f6f2">
74
72
  <title>{escape(title)} · Randpic</title><style>{GALLERY_STYLE}
@@ -77,22 +75,13 @@ def _shell(title: str, home: str, version: str, command: str, page: int, content
77
75
  </style></head><body><div class="shell">
78
76
  <nav class="topbar"><a class="brand" href="{home}"><span class="brand-mark">✳</span><span>RANDPIC / 图片收藏馆</span></a><span class="top-note">捕捉喜欢的瞬间</span></nav>
79
77
  {content}<footer>Randpic · 收藏每一个喜欢的瞬间</footer></div>
80
- <script>(()=>{{const state={state}; const home={json.dumps(home)};
81
- setInterval(async()=>{{if(document.hidden)return;try{{
82
- const response=await fetch(home+'catalog.json',{{cache:'no-store'}});if(!response.ok)return;
83
- const next=await response.json();if(next.version===state.version)return;
84
- if(state.command && (!(state.command in next.pages)||state.page>next.pages[state.command])){{location.href=state.command in next.pages?'./':home;return;}}
85
- location.reload();}}catch(error){{}}}},15000);}})();</script></body></html>'''
78
+ </body></html>'''
86
79
 
87
80
 
88
81
  def render_catalog(groups: Mapping[str, Sequence[ImageRecord]], page_size: int) -> Dict[str, bytes]:
89
- """相同图库总是产生相同 HTML;发送次数变化不会导致网页重复刷新。"""
90
- identity = [(command, [(image.id, image.filename, image.content_hash) for image in images])
91
- for command, images in sorted(groups.items())]
92
- version = hashlib.sha256(json.dumps(identity, ensure_ascii=False).encode()).hexdigest()
82
+ """生成纯静态 HTML,只让内容实际变化的页面产生同步任务。"""
93
83
  pages = {command: max(1, math.ceil(len(images) / page_size)) for command, images in groups.items()}
94
- catalog = json.dumps({'version': version, 'pages': pages}, ensure_ascii=False, sort_keys=True).encode()
95
- files = {'catalog.json': catalog}
84
+ files: Dict[str, bytes] = {}
96
85
  total = sum(len(images) for images in groups.values())
97
86
  cards = []
98
87
  for command, images in sorted(groups.items()):
@@ -106,7 +95,7 @@ def render_catalog(groups: Mapping[str, Sequence[ImageRecord]], page_size: int)
106
95
  home = f'''<header class="hero"><p class="eyebrow">YOUR IMAGE LIBRARY</p><h1>每一张,都值得<br>被好好收藏。</h1>
107
96
  <p class="hero-copy">在这里慢慢翻看收藏的每一份灵感。</p></header><div class="section-head"><h2>全部分类</h2>
108
97
  <span class="count">{len(groups)} 个分类 · {total} 张图片</span></div><main class="folder-grid">{''.join(cards)}</main>'''
109
- files['index.html'] = _shell('图片收藏馆', './', version, '', 1, home).encode()
98
+ files['index.html'] = _shell('图片收藏馆', './', home).encode()
110
99
  for command, images in groups.items():
111
100
  for page in range(1, pages[command] + 1):
112
101
  cards = []
@@ -128,7 +117,7 @@ def render_catalog(groups: Mapping[str, Sequence[ImageRecord]], page_size: int)
128
117
  <header class="gallery-title"><p class="eyebrow">COLLECTION</p><h1>{escape(command)}</h1><p class="hero-copy">本分类共收藏 {len(images)} 张图片。</p></header>
129
118
  <main class="image-grid">{''.join(cards)}</main><nav class="pagination">{''.join(navigation)}</nav>'''
130
119
  filename = 'index.html' if page == 1 else f'page-{page}.html'
131
- files[f'{command}/{filename}'] = _shell(command, '../', version, command, page, content).encode()
120
+ files[f'{command}/{filename}'] = _shell(command, '../', content).encode()
132
121
  return files
133
122
 
134
123
 
@@ -140,6 +129,7 @@ class GalleryPublisher:
140
129
  self.remote_directory = root / 'database' / 'oss_pages'
141
130
  self.page_size = page_size
142
131
  self._asset_hashes: Dict[str, str] = {}
132
+ self._asset_md5: Dict[str, str] = {}
143
133
 
144
134
  @staticmethod
145
135
  def _write_pages(directory: Path, pages: Mapping[str, bytes]) -> None:
@@ -163,8 +153,8 @@ class GalleryPublisher:
163
153
  except OSError:
164
154
  pass
165
155
 
166
- def publish(self, images: Sequence[ImageRecord], commands: Iterable[str], excluded: Set[str]) -> Dict[str, Tuple[str, str]]:
167
- for directory in (self.local_directory, self.remote_directory):
156
+ def publish(self, images: Sequence[ImageRecord], commands: Iterable[str], excluded: Set[str], *, remote_enabled: bool = False) -> Dict[str, PublishedFile]:
157
+ for directory in ((self.local_directory, self.remote_directory) if remote_enabled else (self.local_directory,)):
168
158
  if directory.is_symlink():
169
159
  raise RandpicError('网页输出目录不能是符号链接。')
170
160
  directory.mkdir(parents=True, exist_ok=True)
@@ -175,6 +165,8 @@ class GalleryPublisher:
175
165
  for command in groups:
176
166
  groups[command].sort(key=lambda image: (parse_index(Path(image.filename).stem, command) or 0, image.filename))
177
167
  assets: Dict[str, str] = {}
168
+ checksums: Dict[str, str] = {}
169
+ files: Dict[str, PublishedFile] = {}
178
170
  for group in groups.values():
179
171
  for image in group:
180
172
  path = safe_path(self.local_directory, image.key)
@@ -186,16 +178,26 @@ class GalleryPublisher:
186
178
  raise RandpicError(f'图片文件在运行时被修改:{image.key}')
187
179
  atomic_write(path, data)
188
180
  assets[image.key] = image.content_hash
181
+ if remote_enabled and image.command not in excluded:
182
+ checksum = self._asset_md5.get(image.key) if known else None
183
+ if checksum is None:
184
+ checksum = digest_file(path, 'md5')
185
+ checksums[image.key] = checksum
186
+ files[image.key] = PublishedFile(str(path), image.content_hash, checksum, image.byte_size)
189
187
  local_pages = render_catalog(groups, self.page_size)
190
188
  self._write_pages(self.local_directory, local_pages)
191
189
  self._prune(self.local_directory, set(local_pages) | set(assets))
190
+ self._asset_hashes, self._asset_md5 = assets, checksums
191
+ if not remote_enabled:
192
+ return {}
192
193
  remote_groups = {command: group for command, group in groups.items() if command not in excluded}
193
194
  remote_pages = render_catalog(remote_groups, self.page_size)
194
195
  self._write_pages(self.remote_directory, remote_pages)
195
196
  self._prune(self.remote_directory, set(remote_pages))
196
- self._asset_hashes = assets
197
- manifest = {key: (str(self.remote_directory / key), hashlib.sha256(data).hexdigest())
198
- for key, data in remote_pages.items()}
199
- manifest.update({image.key: (str(self.local_directory / image.key), image.content_hash)
197
+ manifest = {key: PublishedFile(
198
+ str(self.remote_directory / key), hashlib.sha256(data).hexdigest(),
199
+ hashlib.md5(data, usedforsecurity=False).hexdigest(), len(data),
200
+ ) for key, data in remote_pages.items()}
201
+ manifest.update({image.key: files[image.key]
200
202
  for group in remote_groups.values() for image in group})
201
203
  return manifest
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "nonebot-plugin-randpic"
3
- version = "2.0.1"
3
+ version = "2.1.0"
4
4
  description = "一个通过指令随机发送你存储的图片的nonebot2插件"
5
5
  authors = ["HuParry <hu_parry@qq.com>"]
6
6
  license = "MIT"
@@ -1,71 +0,0 @@
1
- """复用异步 OSS 客户端;不持有图库或数据库的锁。"""
2
-
3
- import asyncio
4
- import hashlib
5
- import mimetypes
6
- from pathlib import Path
7
- from typing import Optional
8
-
9
- import alibabacloud_oss_v2 as oss
10
- import alibabacloud_oss_v2.aio as oss_aio
11
- from nonebot.log import logger
12
-
13
- from .config import Config
14
- from .utils import run_blocking
15
-
16
-
17
- class OSSUploaderV2:
18
- def __init__(self, config: Config):
19
- credentials = oss.credentials.StaticCredentialsProvider(
20
- access_key_id=config.randpic_oss_access_key_id,
21
- access_key_secret=config.randpic_oss_access_key_secret,
22
- )
23
- self.cfg = oss.config.load_default()
24
- self.cfg.credentials_provider = credentials
25
- self.cfg.region = config.randpic_region
26
- self.bucket = config.randpic_bucket
27
- self.client = None
28
-
29
- async def open(self) -> None:
30
- if self.client is None:
31
- self.client = oss_aio.AsyncClient(self.cfg)
32
-
33
- async def close(self) -> None:
34
- if self.client is not None:
35
- await self.client.close()
36
- self.client = None
37
-
38
- async def upload_file(self, local_file_path: str, oss_key: str, expected_hash: Optional[str] = None) -> bool:
39
- try:
40
- await self.open()
41
- data = await run_blocking(Path(local_file_path).read_bytes)
42
- if expected_hash is not None and hashlib.sha256(data).hexdigest() != expected_hash:
43
- return False # 任务已过期,后续版本会重新上传,不发送旧内容。
44
- result = await asyncio.wait_for(self.client.put_object(oss.PutObjectRequest(
45
- bucket=self.bucket, key=oss_key, body=data,
46
- content_type=mimetypes.guess_type(oss_key)[0] or 'application/octet-stream',
47
- cache_control='no-cache' if oss_key.endswith(('.html', '.json')) else 'public, max-age=31536000, immutable',
48
- )), timeout=60)
49
- if 200 <= result.status_code < 300:
50
- return True
51
- logger.error(f'OSS 上传失败:{oss_key} (HTTP {result.status_code})')
52
- except Exception as exc:
53
- logger.error(f'OSS 上传失败:{oss_key} ({exc})')
54
- return False
55
-
56
- async def delete_file(self, oss_key: str) -> bool:
57
- try:
58
- await self.open()
59
- result = await asyncio.wait_for(self.client.delete_object(
60
- oss.DeleteObjectRequest(bucket=self.bucket, key=oss_key)
61
- ), timeout=60)
62
- if 200 <= result.status_code < 300:
63
- return True
64
- logger.error(f'OSS 删除失败:{oss_key} (HTTP {result.status_code})')
65
- except oss.exceptions.ServiceError as exc:
66
- if exc.code == 'NoSuchKey':
67
- return True
68
- logger.error(f'OSS 删除失败:{oss_key} ({exc})')
69
- except Exception as exc:
70
- logger.error(f'OSS 删除失败:{oss_key} ({exc})')
71
- return False