diff --git a/links/actions.py b/links/actions.py index 7039eb4..bc3f8d7 100644 --- a/links/actions.py +++ b/links/actions.py @@ -12,11 +12,15 @@ action_config 结构: } webhook URL 从环境变量 DISCORD_WEBHOOK_URL 读取(K8s secret links-discord-webhook)。 + +防重:传入 dedup_key 时,同一 key 在 DEDUP_TTL 秒内只发送一次(发送成功后写入缓存)。 """ +import hashlib import logging import os import requests +from django.core.cache import cache logger = logging.getLogger(__name__) @@ -26,6 +30,15 @@ ACTION_DISCORD_SEND = 'discord_send' # Discord 单条消息上限 2000 字符,留出余量 MAX_MSG_LEN = 1900 +# 防重窗口:同一内容 10 分钟内不重复发送 +DEDUP_TTL = 600 + + +def make_dedup_key(namespace: str, *parts: str) -> str: + """生成防重 key:namespace + 各部分的 md5(避免超长/特殊字符问题)。""" + raw = '|'.join(str(p) for p in parts if p) + return f'dedup:{namespace}:{hashlib.md5(raw.encode("utf-8")).hexdigest()}' + def send_discord_message(message: str) -> None: """通过 Discord webhook 发送消息到配置的频道。""" @@ -49,10 +62,18 @@ def render_template(template: str, query: str) -> str: return template.replace('{query}', query) -def execute_action(action_config: dict, query: str) -> dict: - """执行动作,返回 {success, message, discord_deeplink}。""" +def execute_action(action_config: dict, query: str, dedup_key: str = '') -> dict: + """执行动作,返回 {success, message, discord_deeplink, deduped}。 + + dedup_key 非空时启用防重:同一 key 在 DEDUP_TTL 秒内只发送一次; + 发送成功才占位,失败会释放(允许重试)。 + """ config = action_config or {} action_type = config.get('action_type', '') + deeplink = os.environ.get( + 'DISCORD_DEEPLINK', + 'https://discord.com/channels/1467846046590959798/1467846047089954952', + ) if action_type == ACTION_DISCORD_SEND: if not query.strip(): @@ -60,21 +81,34 @@ def execute_action(action_config: dict, query: str) -> dict: 'success': False, 'message': '链接缺少参数:请在别名后面加上你的问题(go/别名/问题)', 'discord_deeplink': '', + 'deduped': False, + } + # 防重:同 key 在窗口内已发送过 → 不重复发送,直接算成功(跳转 Discord) + if dedup_key and not cache.add(dedup_key, '1', DEDUP_TTL): + return { + 'success': True, + 'message': '已发送过,10 分钟内不重复发送', + 'discord_deeplink': deeplink, + 'deduped': True, } template = config.get('message_template', '@小黑 {query}') message = render_template(template, query) - send_discord_message(message) + try: + send_discord_message(message) + except Exception: + if dedup_key: + cache.delete(dedup_key) # 失败释放防重占位,允许重试 + raise return { 'success': True, 'message': '已投递到 Discord,回复会出现在 #general', - 'discord_deeplink': os.environ.get( - 'DISCORD_DEEPLINK', - 'https://discord.com/channels/1467846046590959798/1467846047089954952', - ), + 'discord_deeplink': deeplink, + 'deduped': False, } return { 'success': False, 'message': f'未知的动作类型: {action_type}', 'discord_deeplink': '', + 'deduped': False, } diff --git a/links/ask_views.py b/links/ask_views.py index 696b28d..dd8ca88 100644 --- a/links/ask_views.py +++ b/links/ask_views.py @@ -15,11 +15,11 @@ import logging import os from django.http import JsonResponse -from django.shortcuts import render +from django.shortcuts import render, redirect from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -from .actions import MAX_MSG_LEN, send_discord_message +from .actions import MAX_MSG_LEN, execute_action, make_dedup_key, send_discord_message logger = logging.getLogger(__name__) @@ -33,11 +33,37 @@ DISCORD_DEEPLINK = os.environ.get( def ask_page(request, question=None): - """渲染提问页。question 可为 URL path 段或 ?q= 参数。""" + """提问入口。 + + - 带问题(/ask/问题 或 ?q=问题):直接发消息到 Discord 并 302 跳转, + 不显示页面;10 分钟内同一问题不重复发送。 + - 不带问题(/ask/):渲染移动端输入页(JS 调 /api/ask/ 发送)。 + """ if question is None: question = request.GET.get('q', '') + question = (question or '').strip() + + if question: + dedup_key = make_dedup_key('ask', question) + try: + result = execute_action( + {'action_type': 'discord_send', 'message_template': f'<@{DISCORD_BOT_ID}> {{query}}'}, + question, + dedup_key=dedup_key, + ) + except Exception as e: + logger.warning('[ASK] failed: %s', e) + result = {'success': False, 'message': str(e), 'discord_deeplink': '', 'deduped': False} + if result.get('success') and result.get('discord_deeplink'): + return redirect(result['discord_deeplink']) + return render(request, 'links/action_result.html', { + 'result': result, + 'query': question, + 'link': None, + }) + return render(request, 'links/ask.html', { - 'initial_question': question, + 'initial_question': '', 'discord_deeplink': DISCORD_DEEPLINK, }) @@ -60,9 +86,16 @@ def ask_api(request): return JsonResponse({'error': '问题太长了(最多 1900 字)'}, status=400) try: - send_discord_message(f'<@{DISCORD_BOT_ID}> {question}') + dedup_key = make_dedup_key('ask', question) + result = execute_action( + {'action_type': 'discord_send', 'message_template': f'<@{DISCORD_BOT_ID}> {{query}}'}, + question, + dedup_key=dedup_key, + ) except Exception as e: logger.warning('[ASK] post failed: %s', e) return JsonResponse({'error': str(e)}, status=502) + if result.get('deduped'): + return JsonResponse({'text': '10 分钟内已发送过相同问题,不重复发送'}) return JsonResponse({'text': '已投递到 Discord,小黑回复会出现在 #general'}) diff --git a/links/views.py b/links/views.py index 97ff49d..ab84216 100644 --- a/links/views.py +++ b/links/views.py @@ -6,7 +6,7 @@ from django.db.models import F, Count, Q, Case, When, Value, IntegerField, Sum from django.db.models.functions import TruncDate from .models import Link, ClickLog, LinkChangeLog, Page, Post, SiteSettings from .forms import LinkForm, PageForm -from .actions import execute_action +from .actions import execute_action, make_dedup_key from django.core.cache import cache import json from django.core.serializers.json import DjangoJSONEncoder @@ -344,14 +344,17 @@ def redirect_to_original(request, alias, param=None): cache.set(click_key, 1, timeout=172800) try: - # 动作链接(ACTION):执行动作(如发 Discord 消息)并显示结果页 + # 动作链接(ACTION):执行动作(如发 Discord 消息),成功直接跳转 Discord if link.link_type == Link.LinkType.ACTION: query = param or request.GET.get('q', '') or '' + dedup_key = make_dedup_key('action', link.alias, query) try: - result = execute_action(link.action_config, query) + result = execute_action(link.action_config, query, dedup_key=dedup_key) except Exception as e: logger.warning('[ACTION] %s failed: %s', link.alias, e) - result = {'success': False, 'message': str(e), 'discord_deeplink': ''} + result = {'success': False, 'message': str(e), 'discord_deeplink': '', 'deduped': False} + if result.get('success') and result.get('discord_deeplink'): + return redirect(result['discord_deeplink']) return render(request, 'links/action_result.html', { 'result': result, 'query': query, diff --git a/scripts/e2e_action_links.py b/scripts/e2e_action_links.py index 6d9a9cc..9c9e9fe 100644 --- a/scripts/e2e_action_links.py +++ b/scripts/e2e_action_links.py @@ -62,14 +62,20 @@ assert 'value="discord_send"' in html or 'discord_send' in html, '编辑页未 assert '@小黑 {query}' in html, '编辑页未预填 message template' print('✅ 3. 编辑页正确预填动作配置') -# 4. 访问动作链接 → 发 Discord + 结果页 -resp = client.get(f'/{ALIAS}/你好墨尔本/') -assert resp.status_code == 200, f'访问链接 {resp.status_code}' -body = resp.content.decode() -assert '已投递到 Discord' in body, '结果页缺少成功提示' -assert '打开 Discord' in body, '结果页缺少 Discord 按钮' -assert '你好墨尔本' in body, '结果页缺少 query 显示' -print('✅ 4. 访问链接返回结果页,消息已发送到 Discord(含 @小黑 你好墨尔本)') +# 4. 访问动作链接 → 发 Discord + 302 跳转(不渲染页面) +with patch('links.actions.send_discord_message') as mock_send: + resp = client.get(f'/{ALIAS}/你好墨尔本/') +assert resp.status_code == 302, f'应 302 跳转: {resp.status_code}' +assert resp['Location'].startswith('https://discord.com/channels/'), resp['Location'] +mock_send.assert_called_once_with('@小黑 你好墨尔本') +print('✅ 4. 访问链接 302 跳转 Discord,消息已发送(不发页面)') + +# 4b. 防重:10 分钟内同一问题刷新不重复发送 +with patch('links.actions.send_discord_message') as mock_send: + resp = client.get(f'/{ALIAS}/你好墨尔本/') # 刷新/重复访问 +assert resp.status_code == 302 +mock_send.assert_not_called(), '防重窗口内不应再次发送' +print('✅ 4b. 10 分钟防重生效:刷新不重复发送') # 5. 无参数访问 → 提示缺参数,不发送空消息 with patch('links.actions.send_discord_message') as mock_send: diff --git a/tests/test_actions.py b/tests/test_actions.py index c7a042a..3361ce4 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -121,22 +121,41 @@ class TestLinkFormActionValidation: # ── redirect_to_original execution ──────────────────────────────────────────── class TestRedirectAction: - def test_action_link_executes_and_renders_result(self, action_link): + def test_action_link_executes_and_redirects(self, action_link): + """成功:发送消息并 302 跳转 Discord,不渲染页面。""" client = Client() with patch('links.actions.send_discord_message') as mock_send: - resp = client.get(f'/testaction/今天有什么新闻/') - assert resp.status_code == 200 + resp = client.get('/testaction/今天有什么新闻/') + assert resp.status_code == 302 + assert resp['Location'].startswith('https://discord.com/channels/') mock_send.assert_called_once_with('@小黑 今天有什么新闻') - assert 'links/action_result.html' in [t.name for t in resp.templates] - assert '今天有什么新闻' in resp.content.decode() def test_action_link_with_query_param(self, action_link): client = Client() with patch('links.actions.send_discord_message') as mock_send: resp = client.get('/testaction/', {'q': '用查询参数提问'}) - assert resp.status_code == 200 + assert resp.status_code == 302 mock_send.assert_called_once_with('@小黑 用查询参数提问') + def test_action_link_dedup_same_query_in_window(self, action_link, monkeypatch): + """10 分钟内同一 query 只发一次,刷新不重复发送。""" + monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') + client = Client() + with patch('links.actions.requests.post') as mock_post: + resp1 = client.get('/testaction/相同问题/') + resp2 = client.get('/testaction/相同问题/') # 刷新/重复访问 + assert resp1.status_code == 302 + assert resp2.status_code == 302 + assert mock_post.call_count == 1, '同一问题 10 分钟内只应发送一次' + + def test_action_link_different_query_not_deduped(self, action_link, monkeypatch): + monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') + client = Client() + with patch('links.actions.requests.post') as mock_post: + client.get('/testaction/问题甲/') + client.get('/testaction/问题乙/') + assert mock_post.call_count == 2, '不同问题应各自发送' + def test_action_link_webhook_failure_shows_error(self, action_link, monkeypatch): import requests as req_lib monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') @@ -145,6 +164,7 @@ class TestRedirectAction: resp = client.get('/testaction/测试/') assert resp.status_code == 200 assert '发送到 Discord 失败' in resp.content.decode() + assert '打开 Discord' not in resp.content.decode() def test_normal_link_unaffected(self, db): Link.objects.create(alias='plainlink', link_type=Link.LinkType.LINK, original_url='https://example.com/path') @@ -152,3 +172,31 @@ class TestRedirectAction: resp = client.get('/plainlink/') assert resp.status_code in (301, 302) assert resp['Location'] == 'https://example.com/path' + + +# ── /ask/ 路由(带问题 = 发送+跳转;不带 = 输入页)───────────────────────── + +class TestAskRoute: + def test_ask_with_question_redirects(self, monkeypatch): + monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') + client = Client() + with patch('links.actions.requests.post') as mock_post: + resp = client.get('/ask/今天有什么新闻/') + assert resp.status_code == 302 + assert resp['Location'].startswith('https://discord.com/channels/') + body = mock_post.call_args.kwargs['json']['content'] + assert '今天有什么新闻' in body + + def test_ask_with_question_dedup(self, monkeypatch): + monkeypatch.setenv('DISCORD_WEBHOOK_URL', 'https://example.com/hook') + client = Client() + with patch('links.actions.requests.post') as mock_post: + client.get('/ask/相同问题/') + client.get('/ask/相同问题/') + assert mock_post.call_count == 1 + + def test_ask_empty_renders_input_page(self): + client = Client() + resp = client.get('/ask/') + assert resp.status_code == 200 + assert '问小黑' in resp.content.decode()