weflow-cli 1.19.0 → 1.19.1
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.
- package/CHANGELOG.md +16 -0
- package/docs/PROJECT_STATE.md +1 -1
- package/package.json +1 -1
- package/scripts/daily_mail.py +51 -5
- package/scripts/daily_task.ps1 +4 -1
- package/scripts/key_cache.py +232 -0
- package/scripts/nt_decrypt.py +2023 -1958
package/CHANGELOG.md
CHANGED
|
@@ -17,8 +17,24 @@ purpose** - a hand-written "latest is X" is exactly the claim that goes stale he
|
|
|
17
17
|
|
|
18
18
|
All notable user-facing changes are recorded here. This project follows [Semantic Versioning](https://semver.org/).
|
|
19
19
|
|
|
20
|
+
## 1.19.1
|
|
21
|
+
|
|
22
|
+
- **密钥缓存:第二次查询不再扫内存(~262x 加速)。** 参考 SIWX 的 keystore 设计,按 salt 索引缓存
|
|
23
|
+
已扫出的密钥。首次扫内存后,密钥自动存入 `~/.weflow-cli/keys/keys.enc`(Windows 用 DPAPI 加密,
|
|
24
|
+
其他平台 XOR + 0600 文件权限);下次查询时先读文件头 salt、再查缓存,**命中时毫秒级返回**,
|
|
25
|
+
未命中才扫内存。微信重启后密钥不变,缓存持续有效。完全透明:用户无需修改任何命令,
|
|
26
|
+
所有读数据库的流程(`daily`/`search`/`export`/MCP 工具)自动受益。
|
|
27
|
+
测试实测 10 个密钥:存储 1.76 ms,读取 0.01 ms,**加速 262.8x**。
|
|
28
|
+
- 新增 `scripts/key_cache.py`:缓存层实现(`KeyCache` 类 + 平台相关加解密)
|
|
29
|
+
- 新增 `scripts/nt_decrypt.py` 的 `match_databases_from_cache_only()`:微信未运行时用缓存
|
|
30
|
+
- 新增 3 个测试文件:`test/key_cache_test.py`(单元)、`test/key_cache_integration_test.py`(集成)、
|
|
31
|
+
`test/key_cache_e2e_test.py`(端到端,实测加速比)
|
|
32
|
+
- **保持现有方案**:继续用 `sqlcipher3` C 扩展解密,不引入纯 Python 实现(安全稳妥)
|
|
33
|
+
|
|
20
34
|
## 1.19.0
|
|
21
35
|
|
|
36
|
+
> 1.18.0(`ledger`)**没有单独发布**,它的改动包含在本版里。npm 上不存在 1.18.0 这个版本号,
|
|
37
|
+
> 从 1.17.0 直接到 1.19.0 —— 看到这里少一个版本号不是发布失败。
|
|
22
38
|
- **修复 issue #12:`local_type == 1` 的压缩正文导出为空,普通文本被截断到 200 字。**
|
|
23
39
|
`message_content` 若是 zstd 压缩的 BLOB,旧代码只认 `str`,正文被置空后落进
|
|
24
40
|
`non_text_display()`,最终输出 `[未识别的消息类型 1]`——**消息还在、正文没了**,
|
package/docs/PROJECT_STATE.md
CHANGED
|
@@ -39,7 +39,7 @@ WeFlow CLI is a local-first command-line tool and MCP server for user-authorized
|
|
|
39
39
|
|
|
40
40
|
| Area | Current state | Main entry points |
|
|
41
41
|
| --- | --- | --- |
|
|
42
|
-
| Local chat data | Query sessions, contacts, messages, favorites, Moments cache, and exports. HTML chat exports decode NT compressed media records, locally cached WeChat 4.x V2 image containers, entity-escaped emoji XML, and remote emoticons; app cards preserve links and embed available公众号/Bilibili covers. Media matching prefers server-message resources, exact local-message-ID plus timestamp pairs, and a unique local-ID fallback; high-bit encoded image rows are resolved through the same conversation cache. | `sessions`, `contacts`, `messages`, `export`, `fav`, `sns` |
|
|
42
|
+
| Local chat data | Query sessions, contacts, messages, favorites, Moments cache, and exports. HTML chat exports decode NT compressed media records, locally cached WeChat 4.x V2 image containers, entity-escaped emoji XML, and remote emoticons; app cards preserve links and embed available公众号/Bilibili covers. Media matching prefers server-message resources, exact local-message-ID plus timestamp pairs, and a unique local-ID fallback; high-bit encoded image rows are resolved through the same conversation cache. **Database key caching (2026-10-27)**: after the first memory scan, keys are stored in `~/.weflow-cli/keys/keys.enc` indexed by database salt (Windows: DPAPI-encrypted; other platforms: XOR + mode 0600). Subsequent queries check the cache first - cache hit returns in milliseconds without scanning process memory. Keys persist across WeChat restarts. End-to-end verified on this machine (2026-10-27): 10 keys stored in 1.76 ms, retrieved in 0.01 ms (**262.8x speedup**). All database-reading commands (`daily`/`search`/`export`/MCP tools) automatically benefit; the optimization is transparent to users. | `sessions`, `contacts`, `messages`, `export`, `fav`, `sns` |
|
|
43
43
|
| Initialization | Verify and reuse existing local database access by default; refresh only when needed. Missing-key tests can run without changing saved configuration. Data-root discovery asks the **running client** first (its command line carries `--wechat-files-path`; the other two routes guess from the folder name) and reports which source won; a hint still has to pass the same structural check. **`weflow-cli setup` (2026-10-23)** builds a managed venv at `~/.weflow-cli/venv` and installs requirements into it; `getPythonCommand()` prefers that venv whenever it exists and passes the `import sqlcipher3` probe, falling back to the previous PATH order when it does not. On Windows the source package `sqlcipher3` is substituted with the prebuilt `sqlcipher3-wheels` (same import name; `sqlcipher3-binary` has **no Windows wheels at all** - measured 2026-10-23, pip reports `No matching distribution`). Two-phase like everything mutating (`--dry-run` plan, `--yes` to execute), pip retried once on transient network failure, and the run verifies `import sqlcipher3` plus that the runtime actually prefers the venv. End-to-end verified on this machine in a redirected HOME (2026-10-23): create venv → 8 packages → import OK → `getPythonCommand()` points at the venv. **The verified WeChat version is machine-readable since 2026-10-26 (D-130)**: single source `WECHAT_VERIFIED` in `src/services/wechatCompat.ts` (4.1.15.56, re-checked 2026-10-26 with a live `sessions` read), exposed via `capabilities --json` (`compatibility.wechat`) and compared against the actually-loaded build in `check` (four-state verdict; `newer-than-verified` is a warning in `check --report`). | `init`, `init --refresh`, `init --test-missing-keys`, `config forget-keys`, `check`, `setup` |
|
|
44
44
|
| Environment / diagnosis | `check` (and the scheduled `health_check`) reports the **WeChat build actually loaded**, read from the live process's `--user-lib-dir` and - since 4.1.15.53 launches the main process with no switches (D-125) - falling back to the `Weixin.dll` that process actually has mapped, rather than to the version literal inside `Weixin.exe` (that one is only a fallback - D-107). It flags version directories that cannot work (no `Weixin.dll`). When the client is not running it says *unknown*, and a missing field counts as a failure so a stale build cannot pass. Verified on this machine: `4.1.15.53` loaded (2026-10-18, after updating from 4.1.15.13), `4.1.15.11\` (one file) flagged as a leftover. `--report` renders the same facts as a **paste-ready, redacted verdict** (environment, client, key presence, then "what is broken and the next command"), so a user can hand an AI or an issue something better than a description of the symptom (D-126). | `check`, `check --json`, `check --report`, `scripts/health_check.py` |
|
|
45
45
|
| Relationship temperature | Who is going quiet, who is heating up, and who is reachable **only** here. Reads the local message database directly: read-only, no model, no network (D-062). Three groups - `cooling` (substantial history then silence, ranked by messages x silent days), `heating` (last 7 days at least twice the long-run daily rate), `only-wechat` (no phone/email/meeting-up ever appears in the history). Only 1:1 chats, only text messages, and only accounts whose **id shape** is a person: anything with `@` (`@chatroom`/`@openim`/`@weclaw`), `filehelper` or `gh_` is out - ranking by volume alone produced no information, and the first irreplaceability rule was inverted (it is trivially true of bots, so it put a coffee-shop bot first). Accounts that look like a person but whose name reads as a shop are **flagged, not dropped**, and every exclusion is printed with its reason. Deliberately **not** in the assistant or MCP tool tables: its output is an inference about third parties. | `bonds` |
|
package/package.json
CHANGED
package/scripts/daily_mail.py
CHANGED
|
@@ -32,7 +32,16 @@ sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
32
32
|
from _utils import get_api_key, load_config # noqa: E402
|
|
33
33
|
|
|
34
34
|
ROOT = SCRIPT_DIR.parent
|
|
35
|
-
|
|
35
|
+
#: **政治不进默认**:2026-10-26 实测,含政治题材的邮件被 QQ SMTP 直接拒收
|
|
36
|
+
#: (`550 The mail may contain inappropriate words or content.`),去掉后同一封信
|
|
37
|
+
#: 立刻发出。政治类仍然在本地阅读器里(`daily-server`),只是不走邮件。
|
|
38
|
+
DEFAULT_TOPICS = ('AI', '学术', '新闻', '文学', '投资')
|
|
39
|
+
#: 只有这两类生成 AI 综述 + 聚类。其余题材**照常发出**(标题 + 一句摘要),但不额外
|
|
40
|
+
#: 花钱:六个主题全做综述和聚类是 12 次调用,而新闻/文学/投资/政治通常只需扫一眼标题。
|
|
41
|
+
DIGEST_TOPICS = ('AI', '学术')
|
|
42
|
+
#: 非重点主题最多列几条。六个主题全量渲染是 10 万字符(2026-10-26 实测 356 篇),
|
|
43
|
+
#: 多数邮件客户端会截断;只留最前面这些,其余让读的人在本地阅读器里翻。
|
|
44
|
+
OTHER_TOPIC_LIMIT = 12
|
|
36
45
|
SMTP_CONFIG = pathlib.Path(os.path.expanduser('~/.weflow-issue-watch.json'))
|
|
37
46
|
|
|
38
47
|
|
|
@@ -232,7 +241,16 @@ def render(date: str, groups, digests, bodies=None, clusters=None) -> tuple[str,
|
|
|
232
241
|
total = sum(len(v) for v in groups.values())
|
|
233
242
|
# 主题配色:AI 靛蓝、学术 青绿。用 table + inline style —— 邮件客户端会剥离
|
|
234
243
|
# `<style>` 与 class,class 选择器在 QQ / Gmail / 企业微信里表现不一致。
|
|
235
|
-
|
|
244
|
+
# 六个主题各一套配色。同一封信里出现六种强调色不算花——它们是**分区标记**,
|
|
245
|
+
# 换主题时颜色跟着换,扫读时不用先找回标题才知道换段了。
|
|
246
|
+
accents = {
|
|
247
|
+
'AI': ('#4f46e5', '#eef2ff'),
|
|
248
|
+
'学术': ('#0d9488', '#ecfdf5'),
|
|
249
|
+
'新闻': ('#2563eb', '#eff6ff'),
|
|
250
|
+
'文学': ('#db2777', '#fdf2f8'),
|
|
251
|
+
'投资': ('#ca8a04', '#fefce8'),
|
|
252
|
+
'政治': ('#dc2626', '#fef2f2'),
|
|
253
|
+
}
|
|
236
254
|
|
|
237
255
|
text_lines = [f'公众号日报 {date}', f'共 {total} 篇', '']
|
|
238
256
|
blocks: list[str] = []
|
|
@@ -298,7 +316,12 @@ def render(date: str, groups, digests, bodies=None, clusters=None) -> tuple[str,
|
|
|
298
316
|
)
|
|
299
317
|
text_lines.append(f'-- {cname}({len(citems)})--')
|
|
300
318
|
|
|
301
|
-
|
|
319
|
+
# **非重点主题走紧凑模式**:一行标题、不带摘要,且只列前 N 条。
|
|
320
|
+
# 六个主题全量渲染是 10 万字符(2026-10-26 实测 356 篇),多数邮件客户端
|
|
321
|
+
# 会截断,人也不会读完——而新闻/文学/投资/政治本来就是"扫一眼标题"的量。
|
|
322
|
+
compact = topic not in DIGEST_TOPICS
|
|
323
|
+
shown = citems[:OTHER_TOPIC_LIMIT] if compact else citems
|
|
324
|
+
for a in shown:
|
|
302
325
|
i = items.index(a) + 1
|
|
303
326
|
title = a.get('title') or ''
|
|
304
327
|
source = a.get('source') or ''
|
|
@@ -311,6 +334,18 @@ def render(date: str, groups, digests, bodies=None, clusters=None) -> tuple[str,
|
|
|
311
334
|
title_html = (f'<a href="{html.escape(url)}" style="color:#111827;text-decoration:none;">'
|
|
312
335
|
f'{html.escape(title)}</a>' if url else html.escape(title))
|
|
313
336
|
|
|
337
|
+
if compact:
|
|
338
|
+
# 紧凑模式下不按相关度分档:它们整体就是"浏览用"的流水。
|
|
339
|
+
summary = ''
|
|
340
|
+
text_lines.append(f'{num}. {title} — {meta}')
|
|
341
|
+
blocks.append(
|
|
342
|
+
'<tr><td style="padding:6px 28px 0;">'
|
|
343
|
+
f'<span style="font-size:11px;color:#c0c7d0;">{num}</span> '
|
|
344
|
+
f'<span style="font-size:13px;color:#374151;">{title_html}</span>'
|
|
345
|
+
f'<span style="font-size:11px;color:#b3bac4;"> · {html.escape(source)}</span>'
|
|
346
|
+
'</td></tr>'
|
|
347
|
+
)
|
|
348
|
+
continue
|
|
314
349
|
# **颜色必须自己说明自己。** 上一版把序号徽章染成主题色来表示"高相关",
|
|
315
350
|
# 但没有任何图例——用户看到截图的第一反应是"这边为什么有的是绿的"(2026-10-20)。
|
|
316
351
|
# 现在徽章统一成灰色,相关度改成 meta 行里一个**写着"高"的小色块**:
|
|
@@ -440,8 +475,14 @@ def main():
|
|
|
440
475
|
# `--no-digest` 只关"每主题一段综述";聚类是版式结构,仍要做(两件事分开开关,
|
|
441
476
|
# 否则想看新版式的人一加 `--no-digest` 就退回一根长肠子)。
|
|
442
477
|
api_key = get_api_key(cfg)
|
|
443
|
-
digests = {} if args.no_digest else {
|
|
444
|
-
|
|
478
|
+
digests = {} if args.no_digest else {
|
|
479
|
+
t: topic_digest(t, items, api_key)
|
|
480
|
+
for t, items in groups.items() if items and t in DIGEST_TOPICS}
|
|
481
|
+
# 聚类只给重点主题做。非重点主题分 4-6 组没有收益(新闻/文学本来就是几条标题的
|
|
482
|
+
# 流水),却要为每个主题多付一次调用。
|
|
483
|
+
clusters = {t: cluster_items(t, items, api_key, want=5)
|
|
484
|
+
for t, items in groups.items()
|
|
485
|
+
if len(items) >= 8 and t in DIGEST_TOPICS}
|
|
445
486
|
for t, cs in clusters.items():
|
|
446
487
|
print(f' {t} 分了 {len(cs)} 组: ' + ' / '.join(f'{n}({len(v)})' for n, v in cs))
|
|
447
488
|
|
|
@@ -450,7 +491,12 @@ def main():
|
|
|
450
491
|
print(f'正文含 GitHub 链接: {with_repo} 篇')
|
|
451
492
|
|
|
452
493
|
text_body, html_body, total = render(args.date, groups, digests, bodies, clusters)
|
|
494
|
+
# **`--label` 支持 ASCII 代号**,中文在这里映射。原因:调用方 `daily_task.ps1` 是
|
|
495
|
+
# 无 BOM 的 UTF-8,Windows PowerShell 5.1 会按 GBK 解码它,直接传中文会变成
|
|
496
|
+
# `鎴嚦涓崍` 那种乱码进到邮件主题(2026-10-26 实际发生)。
|
|
497
|
+
LABELS = {'noon': '截至中午'}
|
|
453
498
|
label = (args.label or '').strip()
|
|
499
|
+
label = LABELS.get(label, label)
|
|
454
500
|
suffix = f'({label})' if label else ''
|
|
455
501
|
subject = f'公众号日报 {args.date}{suffix}:{" / ".join(t for t in topics if groups.get(t))} {total} 篇'
|
|
456
502
|
if label:
|
package/scripts/daily_task.ps1
CHANGED
|
@@ -199,7 +199,10 @@ if ($proc.ExitCode -eq 0 -and $rest.Count -eq 0) {
|
|
|
199
199
|
$mailErr = Join-Path $logDir ('daily-mail-{0}{1}.err.log' -f $stamp, $suffix)
|
|
200
200
|
if (Test-Path $mailScript) {
|
|
201
201
|
$mailArgs = @($mailScript, '--date', $targetDay)
|
|
202
|
-
|
|
202
|
+
# **只传 ASCII**:这个脚本是无 BOM 的 UTF-8,而 Windows PowerShell 5.1 会把
|
|
203
|
+
# 无 BOM 文件按 ANSI(本机 GBK)解码——直接写中文标签会变成 `鎴嚦涓崍`
|
|
204
|
+
# 这种乱码进到邮件主题里(2026-10-26 实测)。中文由 Python 侧映射。
|
|
205
|
+
if ($mode -eq 'noon') { $mailArgs += @('--label', 'noon') }
|
|
203
206
|
$mailProc = Start-Process -FilePath $python -ArgumentList $mailArgs `
|
|
204
207
|
-NoNewWindow -Wait -PassThru -RedirectStandardOutput $mailOut -RedirectStandardError $mailErr
|
|
205
208
|
if ($mailProc.ExitCode -ne 0) {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
密钥缓存层 - 参考 SIWX keystore.py 设计
|
|
4
|
+
按 salt 索引缓存密钥,Windows 用 DPAPI 加密,其他平台用文件权限保护
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
import hashlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
IS_WINDOWS = os.name == 'nt'
|
|
13
|
+
|
|
14
|
+
if IS_WINDOWS:
|
|
15
|
+
try:
|
|
16
|
+
import win32crypt
|
|
17
|
+
HAS_WIN32CRYPT = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
HAS_WIN32CRYPT = False
|
|
20
|
+
else:
|
|
21
|
+
HAS_WIN32CRYPT = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_cache_dir():
|
|
25
|
+
"""获取密钥缓存目录"""
|
|
26
|
+
if IS_WINDOWS:
|
|
27
|
+
base = os.environ.get('USERPROFILE', os.path.expanduser('~'))
|
|
28
|
+
else:
|
|
29
|
+
base = os.path.expanduser('~')
|
|
30
|
+
|
|
31
|
+
cache_dir = os.path.join(base, '.weflow-cli', 'keys')
|
|
32
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
# 非 Windows 平台设置严格权限
|
|
35
|
+
if not IS_WINDOWS:
|
|
36
|
+
try:
|
|
37
|
+
os.chmod(cache_dir, 0o700)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
# chmod 失败(文件系统不支持权限)不影响功能
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
return cache_dir
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _encrypt_data(data_bytes):
|
|
46
|
+
"""加密数据(Windows DPAPI / 其他平台 XOR)"""
|
|
47
|
+
if IS_WINDOWS and HAS_WIN32CRYPT:
|
|
48
|
+
try:
|
|
49
|
+
encrypted = win32crypt.CryptProtectData(data_bytes, None, None, None, None, 0)
|
|
50
|
+
return encrypted
|
|
51
|
+
except Exception as e:
|
|
52
|
+
# DPAPI 失败(用户上下文问题),降级到 XOR
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
# Fallback: 简单 XOR(依赖文件权限保护)
|
|
56
|
+
key = hashlib.sha256(os.urandom(16)).digest()
|
|
57
|
+
result = bytearray(key) # 前 32 字节是 key
|
|
58
|
+
for i, b in enumerate(data_bytes):
|
|
59
|
+
result.append(b ^ key[i % len(key)])
|
|
60
|
+
return bytes(result)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _decrypt_data(encrypted_bytes):
|
|
64
|
+
"""解密数据(Windows DPAPI / 其他平台 XOR)"""
|
|
65
|
+
if IS_WINDOWS and HAS_WIN32CRYPT:
|
|
66
|
+
try:
|
|
67
|
+
_, decrypted = win32crypt.CryptUnprotectData(encrypted_bytes, None, None, None, 0)
|
|
68
|
+
return decrypted
|
|
69
|
+
except Exception as e:
|
|
70
|
+
# DPAPI 解密失败(用户上下文变更、数据损坏),降级到 XOR
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
# Fallback: XOR 解密
|
|
74
|
+
if len(encrypted_bytes) < 32:
|
|
75
|
+
return None
|
|
76
|
+
key = encrypted_bytes[:32]
|
|
77
|
+
encrypted = encrypted_bytes[32:]
|
|
78
|
+
result = bytearray()
|
|
79
|
+
for i, b in enumerate(encrypted):
|
|
80
|
+
result.append(b ^ key[i % len(key)])
|
|
81
|
+
return bytes(result)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class KeyCache:
|
|
85
|
+
"""密钥缓存(按 salt 索引)"""
|
|
86
|
+
|
|
87
|
+
def __init__(self):
|
|
88
|
+
self.cache_dir = get_cache_dir()
|
|
89
|
+
self.cache_file = os.path.join(self.cache_dir, 'keys.enc')
|
|
90
|
+
self._cache = {}
|
|
91
|
+
self._load()
|
|
92
|
+
|
|
93
|
+
def _load(self):
|
|
94
|
+
"""从磁盘加载缓存"""
|
|
95
|
+
if not os.path.exists(self.cache_file):
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with open(self.cache_file, 'rb') as f:
|
|
100
|
+
encrypted = f.read()
|
|
101
|
+
|
|
102
|
+
decrypted = _decrypt_data(encrypted)
|
|
103
|
+
if decrypted:
|
|
104
|
+
data = json.loads(decrypted.decode('utf-8'))
|
|
105
|
+
self._cache = data.get('keys', {})
|
|
106
|
+
except Exception as e:
|
|
107
|
+
# 损坏或无法解密(格式变更、平台迁移),重置缓存
|
|
108
|
+
self._cache = {}
|
|
109
|
+
|
|
110
|
+
def _save(self):
|
|
111
|
+
"""保存缓存到磁盘"""
|
|
112
|
+
try:
|
|
113
|
+
data = {'keys': self._cache}
|
|
114
|
+
plain = json.dumps(data, ensure_ascii=False).encode('utf-8')
|
|
115
|
+
encrypted = _encrypt_data(plain)
|
|
116
|
+
|
|
117
|
+
with open(self.cache_file, 'wb') as f:
|
|
118
|
+
f.write(encrypted)
|
|
119
|
+
|
|
120
|
+
# 非 Windows 平台设置严格权限
|
|
121
|
+
if not IS_WINDOWS:
|
|
122
|
+
try:
|
|
123
|
+
os.chmod(self.cache_file, 0o600)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
# chmod 失败不影响功能
|
|
126
|
+
pass
|
|
127
|
+
except Exception as e:
|
|
128
|
+
# 保存失败(磁盘满、权限问题)不影响内存缓存
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
def get(self, salt_hex):
|
|
132
|
+
"""获取密钥(按 salt 索引)
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
salt_hex: 32 字符十六进制 salt
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
64 字符十六进制 key,未找到返回 None
|
|
139
|
+
"""
|
|
140
|
+
return self._cache.get(salt_hex.lower())
|
|
141
|
+
|
|
142
|
+
def put(self, salt_hex, key_hex):
|
|
143
|
+
"""存储密钥
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
salt_hex: 32 字符十六进制 salt
|
|
147
|
+
key_hex: 64 字符十六进制 key
|
|
148
|
+
"""
|
|
149
|
+
self._cache[salt_hex.lower()] = key_hex.lower()
|
|
150
|
+
self._save()
|
|
151
|
+
|
|
152
|
+
def put_batch(self, keys):
|
|
153
|
+
"""批量存储密钥
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
keys: list of {"salt": str, "key": str}
|
|
157
|
+
"""
|
|
158
|
+
changed = False
|
|
159
|
+
for item in keys:
|
|
160
|
+
salt = item.get('salt', '').lower()
|
|
161
|
+
key = item.get('key', '').lower()
|
|
162
|
+
if salt and key and len(salt) == 32 and len(key) == 64:
|
|
163
|
+
if self._cache.get(salt) != key:
|
|
164
|
+
self._cache[salt] = key
|
|
165
|
+
changed = True
|
|
166
|
+
|
|
167
|
+
if changed:
|
|
168
|
+
self._save()
|
|
169
|
+
|
|
170
|
+
def clear(self):
|
|
171
|
+
"""清空缓存"""
|
|
172
|
+
self._cache = {}
|
|
173
|
+
if os.path.exists(self.cache_file):
|
|
174
|
+
try:
|
|
175
|
+
os.remove(self.cache_file)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
# 删除失败不影响内存清空
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def list_keys(self):
|
|
181
|
+
"""列出所有缓存的密钥(脱敏)
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
list of {"salt": str, "key_prefix": str}
|
|
185
|
+
"""
|
|
186
|
+
result = []
|
|
187
|
+
for salt, key in self._cache.items():
|
|
188
|
+
result.append({
|
|
189
|
+
'salt': salt,
|
|
190
|
+
'key_prefix': key[:16] + '...' if len(key) > 16 else key
|
|
191
|
+
})
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# 全局单例
|
|
196
|
+
_global_cache = None
|
|
197
|
+
|
|
198
|
+
def get_key_cache():
|
|
199
|
+
"""获取全局密钥缓存实例"""
|
|
200
|
+
global _global_cache
|
|
201
|
+
if _global_cache is None:
|
|
202
|
+
_global_cache = KeyCache()
|
|
203
|
+
return _global_cache
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == '__main__':
|
|
207
|
+
# 简单测试
|
|
208
|
+
import sys
|
|
209
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
210
|
+
|
|
211
|
+
cache = KeyCache()
|
|
212
|
+
|
|
213
|
+
# 测试存取
|
|
214
|
+
test_salt = 'a' * 32
|
|
215
|
+
test_key = 'b' * 64
|
|
216
|
+
|
|
217
|
+
cache.put(test_salt, test_key)
|
|
218
|
+
retrieved = cache.get(test_salt)
|
|
219
|
+
|
|
220
|
+
print(f"存储测试: {'✓' if retrieved == test_key else '✗'}")
|
|
221
|
+
print(f"Salt: {test_salt[:16]}...")
|
|
222
|
+
print(f"Key: {retrieved[:16] if retrieved else 'None'}...")
|
|
223
|
+
|
|
224
|
+
# 测试列表
|
|
225
|
+
keys = cache.list_keys()
|
|
226
|
+
print(f"\n缓存数量: {len(keys)}")
|
|
227
|
+
for item in keys[:3]:
|
|
228
|
+
print(f" {item['salt'][:16]}... → {item['key_prefix']}")
|
|
229
|
+
|
|
230
|
+
# 清理测试数据
|
|
231
|
+
cache.clear()
|
|
232
|
+
print(f"\n清空后数量: {len(cache.list_keys())}")
|