mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-06 01:57:51 +08:00
Compare commits
40 Commits
dev_backup
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a039b957d0 | |||
| bba6de25ac | |||
| 7a34fc0079 | |||
| 6af713b67a | |||
| 6992b58208 | |||
| 1604decd3c | |||
| 98971588ae | |||
| 531844f33c | |||
| 76f00db86d | |||
| 761fad17bc | |||
| 0cab5c1cda | |||
| 9574b02d8a | |||
| c48c2b9143 | |||
| cdad76cd3b | |||
| d3e59bc7f3 | |||
| 61b6357418 | |||
| 943770b2bc | |||
| f4d9b2075c | |||
| e3541f8d43 | |||
| 6ddedd76d7 | |||
| ca36f3813a | |||
| 2cd9137f91 | |||
| 3fe122cb80 | |||
| 97c97b7bae | |||
| 7c01b5c265 | |||
| 8a5f5ee9ea | |||
| f68b674eb2 | |||
| 136d49d2d5 | |||
| 0c325ec1e7 | |||
| 3e84f65cdc | |||
| 9c64d52e1b | |||
| a10a5a176b | |||
| d4a8f59fd8 | |||
| 1b8fadc0c9 | |||
| 210bb3b9ea | |||
| ca796a5fd2 | |||
| b18901a2d5 | |||
| 2335b62384 | |||
| a424185854 | |||
| 8b5fb44ded |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git checkout *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit -m ' *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 前端
|
||||||
|
frontend/dist
|
||||||
|
frontend/node_modules
|
||||||
|
|
||||||
|
# 后端
|
||||||
|
backend/.venv
|
||||||
|
backend/.git
|
||||||
|
backend/__pycache__
|
||||||
|
backend/*.pyc
|
||||||
|
backend/*.pyo
|
||||||
|
backend/*.pyd
|
||||||
|
backend/.pytest_cache
|
||||||
|
backend/.mypy_cache
|
||||||
|
backend/.cache
|
||||||
|
backend/.env
|
||||||
|
backend/*.log
|
||||||
|
backend/dist
|
||||||
|
backend/build
|
||||||
+11
-1
@@ -41,6 +41,8 @@ MANIFEST
|
|||||||
pip-log.txt
|
pip-log.txt
|
||||||
pip-delete-this-directory.txt
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
**/logs/*
|
||||||
|
|
||||||
# Unit test / coverage reports
|
# Unit test / coverage reports
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
@@ -183,4 +185,12 @@ cython_debug/
|
|||||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||||
# refer to https://docs.cursor.com/context/ignore-files
|
# refer to https://docs.cursor.com/context/ignore-files
|
||||||
.cursorignore
|
.cursorignore
|
||||||
.cursorindexingignore
|
.cursorindexingignore
|
||||||
|
|
||||||
|
**/data/*
|
||||||
|
**/docker/*
|
||||||
|
backend/app/static/*
|
||||||
|
|
||||||
|
test*.*
|
||||||
|
|
||||||
|
docs/**
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
InsightRadar(聚势智见)是一个热点资讯聚合平台。核心流程:定时爬取微博、知乎、百度等平台热搜 → 用本地 Embedding 模型(Qwen3-Embedding-4B)做余弦相似度语义聚类 → 合并为 `UnifiedEvent`(大事件)→ 调用 DeepSeek 等大模型生成 AI 摘要与标签 → 按用户订阅关键词定时推送邮件简报。
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
### 后端(Python / FastAPI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv sync # 安装依赖
|
||||||
|
uv run python main.py # 启动开发服务器(默认 :8000)
|
||||||
|
# 或
|
||||||
|
uv run uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(Vue 3 / Vite)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # 开发服务器(Vite,默认 :5173,代理到后端)
|
||||||
|
npm run build # 构建产物到 dist/
|
||||||
|
npm run type-check # TypeScript 类型检查
|
||||||
|
npm run lint # oxlint + eslint 双重 lint(自动修复)
|
||||||
|
npm run format # Prettier 格式化 src/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产部署(将前端打包集成到后端)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run build
|
||||||
|
cp -r dist/* ../backend/app/static/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
### 后端分层
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/
|
||||||
|
├── main.py # FastAPI 入口,APScheduler 调度(抓取/摘要/推送三个定时任务)
|
||||||
|
├── database.py # SQLAlchemy engine(SQLite WAL 模式,支持 SQLALCHEMY_DATABASE_URL 切换)
|
||||||
|
├── initialize.py # 启动时幂等写入默认信息源(今日头条、微博等11个平台)
|
||||||
|
├── models/models.py # 全部 ORM 表定义(单文件)
|
||||||
|
├── api/
|
||||||
|
│ ├── router.py # 统一挂载所有子路由,前缀 /api/v1
|
||||||
|
│ └── endpoints/ # auth / events / preferences / delivery / revisions / sources / stats
|
||||||
|
├── services/
|
||||||
|
│ ├── fetcher_service.py # 爬取热搜 + Embedding 生成 + 语义聚类入库(核心)
|
||||||
|
│ ├── summary_service.py # 调用大模型生成 AI 摘要与标签
|
||||||
|
│ ├── matching_service.py # 精确 + 语义双模式匹配用户兴趣
|
||||||
|
│ └── delivery_service.py # 检查推送时间窗口并发送邮件简报
|
||||||
|
├── core/
|
||||||
|
│ ├── security.py # JWT 签发与校验
|
||||||
|
│ └── verification/ # 验证码逻辑(Redis 或 DB 双模式存储)
|
||||||
|
├── crud/ # 数据库 CRUD 操作
|
||||||
|
├── schemas/ # Pydantic 请求/响应 Schema
|
||||||
|
├── prompts/ # LLM Prompt 模板
|
||||||
|
└── static/ # 前端构建产物(生产环境)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端分层
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── api/ # 封装 fetch 请求(基于 config/apiBase.ts,前缀 /api/v1)
|
||||||
|
├── stores/ # Pinia 状态(auth / theme)
|
||||||
|
├── router/ # Vue Router(requiresAuth / guestOnly meta 守卫)
|
||||||
|
├── views/ # 页面:Dashboard / Search / Topics / Delivery / Revisions / Login / Register
|
||||||
|
├── layouts/ # DashboardLayout(统一侧边栏)
|
||||||
|
└── components/ # 通用组件(UnifiedEventCard 等)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键数据模型
|
||||||
|
|
||||||
|
- `UnifiedEvent`:语义聚类后的"大事件",含 AI 摘要、`center_embedding`(聚类中心向量)、`hot_score`
|
||||||
|
- `TrendingEvent`:各平台原始热搜,通过 `external_id`(MD5 指纹)去重,`unified_event_id` 关联大事件
|
||||||
|
- `ExtractedTopic` / `DiscussionComment`:多态设计,`target_type` 区分挂载在 EVENT / TREND / ARTICLE 下
|
||||||
|
- `DeliveryHistory`:防重推记录,唯一约束 `(user_id, target_type, target_id)`
|
||||||
|
|
||||||
|
### Embedding 模型
|
||||||
|
|
||||||
|
`fetcher_service.py` 在模块级加载 `SentenceTransformer` 全局单例(`embedder_model`)。`matching_service.py` 直接 import 复用该单例,避免重复加载。模型路径由 `EMBEDDING_MODEL_PATH` 配置,需提前将模型文件放入 `backend/data/` 目录。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
`.env` 文件放在项目根目录(或 `backend/data/`,两处均可),关键变量:
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `SQLALCHEMY_DATABASE_URL` | 默认 `sqlite:///./data/demo.db`,可切换 PostgreSQL |
|
||||||
|
| `EMBEDDING_MODEL_PATH` | 本地 Embedding 模型路径 |
|
||||||
|
| `AI_API_KEY` | 大模型 API Key(DeepSeek 等 OpenAI 兼容接口) |
|
||||||
|
| `SIMILARITY_THRESHOLD` | 热搜语义聚类阈值(默认 0.72) |
|
||||||
|
| `AUTH_CODE_STORE` | 验证码存储模式:`db`(无 Redis 时)或 `redis` |
|
||||||
|
| `REDIS_URL` | Redis 连接,为空时验证码自动回退到数据库 |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- **后端工作目录**:必须在 `backend/` 下运行,静态文件路径 `app/static` 是相对路径
|
||||||
|
- **Embedding 模型冷启动慢**:首次加载 Qwen3-Embedding-4B 约需数十秒,是正常现象
|
||||||
|
- **前端 API 路径**:所有请求统一经 `src/config/apiBase.ts` 的 `fetchApi()` 发出,前缀 `/api/v1`,无需手动拼接
|
||||||
|
- **数据库迁移**:当前使用 `Base.metadata.create_all()` 自动建表,不使用 Alembic;修改 Model 字段后需手动处理已有数据库
|
||||||
@@ -1,2 +1,70 @@
|
|||||||
# InsightRadar
|
# 聚势智见 — 基于语义聚类与大模型的热点资讯聚合平台
|
||||||
An AI-powered trend monitoring and news intelligence platform
|
|
||||||
|
一个智能热点监测与个性化分发平台,通过语义聚类与大模型技术,将分散在微博、知乎、抖音、百度等平台的热点资讯自动归并为统一事件,生成AI摘要与标签,并支持个性化订阅与定时推送。
|
||||||
|
|
||||||
|
## 核心特性
|
||||||
|
|
||||||
|
- **跨平台热点聚合**:基于Embedding语义相似度计算,自动识别不同平台的同一事件
|
||||||
|
- **AI智能摘要**:调用大模型生成统一标题、综合摘要与标准化标签
|
||||||
|
- **个性化推荐**:支持关键词订阅、语义匹配与多因子排序
|
||||||
|
- **舆情分析工具**:提供热度趋势追踪、标题修改监控、时间线分析
|
||||||
|
- **定时简报推送**:自定义推送时间与接收邮箱,生成个性化AI简报
|
||||||
|
|
||||||
|
## 快速部署
|
||||||
|
|
||||||
|
### 方式一:Docker部署(推荐)
|
||||||
|
|
||||||
|
**环境要求**
|
||||||
|
- Linux系统(推荐Ubuntu 22.04 LTS / Debian 12)
|
||||||
|
- Docker ≥ 20.10.0,Docker Compose v2
|
||||||
|
- 内存 ≥ 512MB(建议1GB以上)
|
||||||
|
|
||||||
|
**部署步骤**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 构建镜像
|
||||||
|
docker build -t insightradar:latest .
|
||||||
|
|
||||||
|
# 2. 配置目录(参考docker/ereadm.txt)
|
||||||
|
mkdir -p ./data ./logs
|
||||||
|
|
||||||
|
# 3. 启动服务
|
||||||
|
cd docker
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:源码部署
|
||||||
|
|
||||||
|
**环境要求**
|
||||||
|
|
||||||
|
- Python ≥ 3.11,uv包管理器
|
||||||
|
- Node.js ≥ 22
|
||||||
|
- 内存 ≥ 512MB
|
||||||
|
|
||||||
|
**后端部署**
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
# 复制
|
||||||
|
cd backend
|
||||||
|
uv sync
|
||||||
|
uv run
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端部署**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
# 将dist/目录内容复制到 backend/app/static/
|
||||||
|
```
|
||||||
|
|
||||||
|
**配置说明**
|
||||||
|
|
||||||
|
- 复制 .env.example 为 .env 并填写配置
|
||||||
|
- 将Embedding模型(Qwen3-Embedding-4B)放入 backend/data/ 目录
|
||||||
|
|
||||||
|
### 访问应用
|
||||||
|
|
||||||
|
部署完成后,通过 http://<服务器IP>:<配置端口> 访问Web界面。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
+104
-389
@@ -1,9 +1,7 @@
|
|||||||
"""
|
|
||||||
认证模块:用户注册、登录、邮箱验证码(支持 Redis / 数据库双存储与自动降级)
|
|
||||||
"""
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from datetime import timedelta, timezone
|
from datetime import timedelta, timezone
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
@@ -19,7 +17,7 @@ from app.core.security import (
|
|||||||
verify_password,
|
verify_password,
|
||||||
verify_verification_code,
|
verify_verification_code,
|
||||||
)
|
)
|
||||||
from app.models.models import AppUser, EmailVerificationCode, VerificationPurpose, utcnow
|
from app.models.models import AppUser, VerificationPurpose, utcnow
|
||||||
from app.schemas.auth_schema import (
|
from app.schemas.auth_schema import (
|
||||||
AuthTokenResponse,
|
AuthTokenResponse,
|
||||||
LoginCodeSendRequest,
|
LoginCodeSendRequest,
|
||||||
@@ -32,9 +30,11 @@ from app.schemas.auth_schema import (
|
|||||||
)
|
)
|
||||||
from app.utils.email_utils import send_html_email
|
from app.utils.email_utils import send_html_email
|
||||||
from app.utils.redis_client import get_redis_client
|
from app.utils.redis_client import get_redis_client
|
||||||
|
from app.core.verification.email.verificationService import EmailVerificationService, get_verification_service, TooManyCodeRequestsError, CodeExpiredError, CodeInvalidError
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
|
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
|
||||||
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
|
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
|
||||||
@@ -66,7 +66,7 @@ def _normalize_email(email: str) -> str:
|
|||||||
def _build_verification_email(code: str, purpose_text: str, expire_minutes: int) -> str:
|
def _build_verification_email(code: str, purpose_text: str, expire_minutes: int) -> str:
|
||||||
return f"""
|
return f"""
|
||||||
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #222;">
|
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #222;">
|
||||||
<h2 style="margin-bottom: 12px;">InsightRadar 邮箱验证</h2>
|
<h2 style="margin-bottom: 12px;">聚势智见邮箱验证</h2>
|
||||||
<p>您的{purpose_text}验证码是:</p>
|
<p>您的{purpose_text}验证码是:</p>
|
||||||
<p style="font-size: 28px; font-weight: bold; letter-spacing: 4px; color: #0b57d0;">{code}</p>
|
<p style="font-size: 28px; font-weight: bold; letter-spacing: 4px; color: #0b57d0;">{code}</p>
|
||||||
<p>该验证码在 {expire_minutes} 分钟内有效。请勿泄露给他人。</p>
|
<p>该验证码在 {expire_minutes} 分钟内有效。请勿泄露给他人。</p>
|
||||||
@@ -131,19 +131,12 @@ def _cache_code_in_redis(
|
|||||||
"code_hash": code_hash,
|
"code_hash": code_hash,
|
||||||
"created_at": utcnow().isoformat(),
|
"created_at": utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
try:
|
|
||||||
client.set(
|
client.set(
|
||||||
_redis_code_key(email, purpose),
|
_redis_code_key(email, purpose),
|
||||||
json.dumps(payload),
|
json.dumps(payload),
|
||||||
ex=max(1, expire_minutes * 60),
|
ex=max(1, expire_minutes * 60),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
if _is_redis_only():
|
|
||||||
# If redis fails but we're in redis_only, don't crash here.
|
|
||||||
# We already generated the code hash, but we won't cache it in redis.
|
|
||||||
# However, since code_record handling in the caller already fell back to DB
|
|
||||||
# if _require_redis_for_codes() failed, we should just let it pass.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _set_send_cooldown_in_redis(email: str, purpose: VerificationPurpose) -> None:
|
def _set_send_cooldown_in_redis(email: str, purpose: VerificationPurpose) -> None:
|
||||||
@@ -178,166 +171,6 @@ def _clear_code_in_redis(email: str, purpose: VerificationPurpose) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _verify_code_with_redis(
|
|
||||||
email: str,
|
|
||||||
purpose: VerificationPurpose,
|
|
||||||
code: str,
|
|
||||||
*,
|
|
||||||
strict: bool = False,
|
|
||||||
) -> Optional[bool]:
|
|
||||||
"""
|
|
||||||
Redis 验证码校验。
|
|
||||||
返回:
|
|
||||||
- True: 校验成功,且已消费验证码
|
|
||||||
- False: Redis 有验证码但校验失败
|
|
||||||
- None: Redis 不可用或无记录,调用方可按策略回退数据库
|
|
||||||
"""
|
|
||||||
client = _get_redis_for_codes()
|
|
||||||
if client is None:
|
|
||||||
if strict:
|
|
||||||
pass # allow fallback
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw = client.get(_redis_code_key(email, purpose))
|
|
||||||
except Exception as e:
|
|
||||||
if strict:
|
|
||||||
pass # fallthrough to let it try db instead of crashing
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
expected_hash = str(payload.get("code_hash", ""))
|
|
||||||
except Exception:
|
|
||||||
# 不要轻易清除,可能是数据格式异常
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not expected_hash:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not verify_verification_code(code, expected_hash):
|
|
||||||
# 注意:校验失败时不要直接清空 Redis,可能用户只是输错了
|
|
||||||
return False
|
|
||||||
|
|
||||||
_clear_code_in_redis(email, purpose)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_unused_codes(db: Session, email: str, purpose: VerificationPurpose) -> None:
|
|
||||||
"""将同一邮箱、同一用途下未使用的旧验证码全部标记为已使用,避免重复使用"""
|
|
||||||
db.query(EmailVerificationCode).filter(
|
|
||||||
EmailVerificationCode.email == email,
|
|
||||||
EmailVerificationCode.purpose == purpose,
|
|
||||||
EmailVerificationCode.is_used.is_(False),
|
|
||||||
).update({EmailVerificationCode.is_used: True}, synchronize_session=False)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _create_code_record(
|
|
||||||
db: Session,
|
|
||||||
*,
|
|
||||||
email: str,
|
|
||||||
purpose: VerificationPurpose,
|
|
||||||
expire_minutes: int,
|
|
||||||
) -> Tuple[EmailVerificationCode, str]:
|
|
||||||
"""在数据库中创建验证码记录,返回 (记录对象, 明文验证码)"""
|
|
||||||
code = generate_verification_code()
|
|
||||||
now = utcnow()
|
|
||||||
code_record = EmailVerificationCode(
|
|
||||||
email=email,
|
|
||||||
purpose=purpose,
|
|
||||||
code_hash=hash_verification_code(code),
|
|
||||||
expires_at=now + timedelta(minutes=expire_minutes),
|
|
||||||
)
|
|
||||||
db.add(code_record)
|
|
||||||
db.commit()
|
|
||||||
return code_record, code
|
|
||||||
|
|
||||||
|
|
||||||
def _get_latest_valid_code_record(
|
|
||||||
db: Session,
|
|
||||||
*,
|
|
||||||
email: str,
|
|
||||||
purpose: VerificationPurpose,
|
|
||||||
):
|
|
||||||
"""从数据库获取该邮箱该用途下最新且未过期、未使用的验证码记录"""
|
|
||||||
now = utcnow()
|
|
||||||
return (
|
|
||||||
db.query(EmailVerificationCode)
|
|
||||||
.filter(
|
|
||||||
EmailVerificationCode.email == email,
|
|
||||||
EmailVerificationCode.purpose == purpose,
|
|
||||||
EmailVerificationCode.is_used.is_(False),
|
|
||||||
EmailVerificationCode.expires_at >= now,
|
|
||||||
)
|
|
||||||
.order_by(EmailVerificationCode.created_at.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _enforce_code_send_cooldown(db: Session, email: str, purpose: VerificationPurpose) -> None:
|
|
||||||
"""限制同一邮箱同一用途验证码的发送频率。"""
|
|
||||||
if CODE_SEND_COOLDOWN_SECONDS <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
client = _get_redis_for_codes()
|
|
||||||
if client is not None:
|
|
||||||
try:
|
|
||||||
ttl = client.ttl(_redis_cooldown_key(email, purpose))
|
|
||||||
if ttl is not None and ttl > 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
detail=f"Please wait {ttl}s before requesting another verification code",
|
|
||||||
headers={"Retry-After": str(ttl)},
|
|
||||||
)
|
|
||||||
if _is_redis_only():
|
|
||||||
return
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
# redis failed during cooldown check, fallback to DB
|
|
||||||
pass
|
|
||||||
|
|
||||||
if _is_redis_only():
|
|
||||||
# Even if redis_only, we allow it to fallthrough if it's down.
|
|
||||||
# This aligns with our fallback logic.
|
|
||||||
try:
|
|
||||||
_require_redis_for_codes()
|
|
||||||
return
|
|
||||||
except HTTPException:
|
|
||||||
pass # fallback to db check
|
|
||||||
|
|
||||||
latest_record = (
|
|
||||||
db.query(EmailVerificationCode)
|
|
||||||
.filter(
|
|
||||||
EmailVerificationCode.email == email,
|
|
||||||
EmailVerificationCode.purpose == purpose,
|
|
||||||
)
|
|
||||||
.order_by(EmailVerificationCode.created_at.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not latest_record:
|
|
||||||
return
|
|
||||||
|
|
||||||
now = utcnow()
|
|
||||||
record_time = latest_record.created_at
|
|
||||||
if record_time.tzinfo is None:
|
|
||||||
record_time = record_time.replace(tzinfo=timezone.utc)
|
|
||||||
elapsed_seconds = (now - record_time).total_seconds()
|
|
||||||
if elapsed_seconds >= CODE_SEND_COOLDOWN_SECONDS:
|
|
||||||
return
|
|
||||||
|
|
||||||
retry_after_seconds = max(1, math.ceil(CODE_SEND_COOLDOWN_SECONDS - elapsed_seconds))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
||||||
detail=f"Please wait {retry_after_seconds}s before requesting another verification code",
|
|
||||||
headers={"Retry-After": str(retry_after_seconds)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_auth_response(user: AppUser) -> AuthTokenResponse:
|
def _build_auth_response(user: AppUser) -> AuthTokenResponse:
|
||||||
token, expires_in = create_access_token(user_id=user.id, email=user.email)
|
token, expires_in = create_access_token(user_id=user.id, email=user.email)
|
||||||
return AuthTokenResponse(
|
return AuthTokenResponse(
|
||||||
@@ -348,219 +181,115 @@ def _build_auth_response(user: AppUser) -> AuthTokenResponse:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/register/send-code", response_model=MessageResponse)
|
@router.post("/register/send-code", response_model=MessageResponse)
|
||||||
async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Depends(get_db)):
|
async def send_register_code(
|
||||||
"""发送注册验证码:先校验邮箱未注册、冷却期,再生成并发送"""
|
payload: RegisterCodeSendRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: EmailVerificationService = Depends(get_verification_service),
|
||||||
|
):
|
||||||
email = _normalize_email(payload.email)
|
email = _normalize_email(payload.email)
|
||||||
|
|
||||||
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
|
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
_enforce_code_send_cooldown(db, email, VerificationPurpose.REGISTER)
|
detail="Email is already registered",
|
||||||
|
|
||||||
code_record = None
|
|
||||||
if _is_redis_only():
|
|
||||||
try:
|
|
||||||
_require_redis_for_codes()
|
|
||||||
code = generate_verification_code()
|
|
||||||
code_hash = hash_verification_code(code)
|
|
||||||
except HTTPException:
|
|
||||||
# If redis is down, temporarily fallback to DB even in redis_only mode
|
|
||||||
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
|
|
||||||
code_record, code = _create_code_record(
|
|
||||||
db,
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.REGISTER,
|
|
||||||
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
|
|
||||||
)
|
|
||||||
code_hash = code_record.code_hash
|
|
||||||
else:
|
|
||||||
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
|
|
||||||
code_record, code = _create_code_record(
|
|
||||||
db,
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.REGISTER,
|
|
||||||
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
|
|
||||||
)
|
)
|
||||||
code_hash = code_record.code_hash
|
|
||||||
|
|
||||||
_cache_code_in_redis(
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.REGISTER,
|
|
||||||
code_hash=code_hash,
|
|
||||||
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
|
|
||||||
)
|
|
||||||
_set_send_cooldown_in_redis(email, VerificationPurpose.REGISTER)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email_sent = await send_html_email(
|
code = service.send_code(email, VerificationPurpose.REGISTER)
|
||||||
|
|
||||||
|
await send_html_email(
|
||||||
to_email=email,
|
to_email=email,
|
||||||
subject=f"【{code}】InsightRadar 注册验证码",
|
subject=f"【{code}】聚势智见 注册验证码",
|
||||||
html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES),
|
html_content=_build_verification_email(
|
||||||
|
code, "注册", REGISTER_CODE_EXPIRE_MINUTES
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except TooManyCodeRequestsError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_clear_code_in_redis(email, VerificationPurpose.REGISTER)
|
|
||||||
# also clear cooldown if possible, so user can retry immediately
|
|
||||||
client = _get_redis_for_codes()
|
|
||||||
if client:
|
|
||||||
try:
|
|
||||||
client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=500,
|
||||||
detail=f"Failed to send verification code: {e}",
|
detail=f"Failed to send verification code: {e}",
|
||||||
)
|
)
|
||||||
if not email_sent:
|
|
||||||
_clear_code_in_redis(email, VerificationPurpose.REGISTER)
|
|
||||||
client = _get_redis_for_codes()
|
|
||||||
if client:
|
|
||||||
try:
|
|
||||||
client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if code_record is not None:
|
|
||||||
code_record.is_used = True
|
|
||||||
db.add(code_record)
|
|
||||||
db.commit()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to send verification code",
|
|
||||||
)
|
|
||||||
|
|
||||||
return MessageResponse(message="Verification code sent")
|
return MessageResponse(message="Verification code sent")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login/send-code", response_model=MessageResponse)
|
@router.post("/login/send-code", response_model=MessageResponse)
|
||||||
async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)):
|
async def send_login_code(
|
||||||
"""发送登录验证码:仅对已注册用户发送"""
|
payload: LoginCodeSendRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: EmailVerificationService = Depends(get_verification_service),
|
||||||
|
):
|
||||||
email = _normalize_email(payload.email)
|
email = _normalize_email(payload.email)
|
||||||
|
|
||||||
user = db.query(AppUser).filter(AppUser.email == email).first()
|
user = db.query(AppUser).filter(AppUser.email == email).first()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
_enforce_code_send_cooldown(db, email, VerificationPurpose.LOGIN)
|
detail="Email is not registered",
|
||||||
|
|
||||||
code_record = None
|
|
||||||
if _is_redis_only():
|
|
||||||
try:
|
|
||||||
_require_redis_for_codes()
|
|
||||||
code = generate_verification_code()
|
|
||||||
code_hash = hash_verification_code(code)
|
|
||||||
except HTTPException:
|
|
||||||
# If redis is down, temporarily fallback to DB even in redis_only mode
|
|
||||||
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
|
|
||||||
code_record, code = _create_code_record(
|
|
||||||
db,
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.LOGIN,
|
|
||||||
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
|
|
||||||
)
|
|
||||||
code_hash = code_record.code_hash
|
|
||||||
else:
|
|
||||||
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
|
|
||||||
code_record, code = _create_code_record(
|
|
||||||
db,
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.LOGIN,
|
|
||||||
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
|
|
||||||
)
|
)
|
||||||
code_hash = code_record.code_hash
|
|
||||||
|
|
||||||
_cache_code_in_redis(
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.LOGIN,
|
|
||||||
code_hash=code_hash,
|
|
||||||
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
|
|
||||||
)
|
|
||||||
_set_send_cooldown_in_redis(email, VerificationPurpose.LOGIN)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
email_sent = await send_html_email(
|
code = service.send_code(email, VerificationPurpose.LOGIN)
|
||||||
|
|
||||||
|
await send_html_email(
|
||||||
to_email=email,
|
to_email=email,
|
||||||
subject=f"【{code}】InsightRadar 登录验证码",
|
subject=f"【{code}】聚势智见 登录验证码",
|
||||||
html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES),
|
html_content=_build_verification_email(
|
||||||
|
code, "登录", LOGIN_CODE_EXPIRE_MINUTES
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except TooManyCodeRequestsError as e:
|
||||||
|
raise HTTPException(status_code=429, detail=str(e))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_clear_code_in_redis(email, VerificationPurpose.LOGIN)
|
|
||||||
client = _get_redis_for_codes()
|
|
||||||
if client:
|
|
||||||
try:
|
|
||||||
client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=500,
|
||||||
detail=f"Failed to send verification code: {e}",
|
detail=f"Failed to send verification code: {e}",
|
||||||
)
|
)
|
||||||
if not email_sent:
|
|
||||||
_clear_code_in_redis(email, VerificationPurpose.LOGIN)
|
|
||||||
client = _get_redis_for_codes()
|
|
||||||
if client:
|
|
||||||
try:
|
|
||||||
client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if code_record is not None:
|
|
||||||
code_record.is_used = True
|
|
||||||
db.add(code_record)
|
|
||||||
db.commit()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to send verification code",
|
|
||||||
)
|
|
||||||
|
|
||||||
return MessageResponse(message="Verification code sent")
|
return MessageResponse(message="Verification code sent")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/register",
|
"/register",
|
||||||
response_model=AuthTokenResponse,
|
response_model=AuthTokenResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
|
async def register(
|
||||||
"""用户注册:校验验证码(Redis 优先,失败则回退数据库)后创建用户"""
|
payload: RegisterRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: EmailVerificationService = Depends(get_verification_service),
|
||||||
|
):
|
||||||
email = _normalize_email(payload.email)
|
email = _normalize_email(payload.email)
|
||||||
|
|
||||||
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
|
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email is already registered",
|
||||||
|
)
|
||||||
|
|
||||||
redis_result = _verify_code_with_redis(
|
try:
|
||||||
email,
|
service.verify_code(
|
||||||
VerificationPurpose.REGISTER,
|
email=email,
|
||||||
payload.verification_code,
|
purpose=VerificationPurpose.REGISTER,
|
||||||
strict=False, # Never be strict so we can fallback to DB if redis is down
|
code=payload.verification_code,
|
||||||
)
|
)
|
||||||
code_record = None
|
except CodeExpiredError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code expired")
|
||||||
if redis_result is False:
|
except CodeInvalidError:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
|
||||||
|
except TooManyCodeRequestsError:
|
||||||
if redis_result is None:
|
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many attempts")
|
||||||
# 即使在 _is_redis_only() 模式下,也去数据库兜底查找
|
|
||||||
# 这样如果Redis挂了时代码回退到了DB,验证时也能从DB拿出来。
|
|
||||||
code_record = _get_latest_valid_code_record(
|
|
||||||
db,
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.REGISTER,
|
|
||||||
)
|
|
||||||
if not code_record:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
|
|
||||||
|
|
||||||
if not verify_verification_code(payload.verification_code, code_record.code_hash):
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
|
|
||||||
else:
|
|
||||||
# Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。
|
|
||||||
# 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。
|
|
||||||
code_record = _get_latest_valid_code_record(
|
|
||||||
db,
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.REGISTER,
|
|
||||||
)
|
|
||||||
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
nickname = payload.nickname or email.split("@")[0]
|
nickname = payload.nickname or email.split("@")[0]
|
||||||
|
|
||||||
user = AppUser(
|
user = AppUser(
|
||||||
email=email,
|
email=email,
|
||||||
password_hash=hash_password(payload.password),
|
password_hash=hash_password(payload.password),
|
||||||
@@ -569,16 +298,11 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
if code_record is not None:
|
|
||||||
code_record.is_used = True
|
|
||||||
db.add(code_record)
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
return _build_auth_response(user)
|
return _build_auth_response(user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=AuthTokenResponse)
|
@router.post("/login", response_model=AuthTokenResponse)
|
||||||
async def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
async def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||||
"""密码登录"""
|
"""密码登录"""
|
||||||
@@ -595,49 +319,40 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login/code", response_model=AuthTokenResponse)
|
@router.post("/login/code", response_model=AuthTokenResponse)
|
||||||
async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)):
|
async def login_with_code(
|
||||||
"""验证码登录:Redis 校验优先,失败则从数据库兜底"""
|
payload: LoginWithCodeRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: EmailVerificationService = Depends(get_verification_service),
|
||||||
|
):
|
||||||
email = _normalize_email(payload.email)
|
email = _normalize_email(payload.email)
|
||||||
|
|
||||||
user = db.query(AppUser).filter(AppUser.email == email).first()
|
user = db.query(AppUser).filter(AppUser.email == email).first()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
redis_result = _verify_code_with_redis(
|
detail="Invalid email or verification code",
|
||||||
email,
|
|
||||||
VerificationPurpose.LOGIN,
|
|
||||||
payload.verification_code,
|
|
||||||
strict=False, # Never be strict so we can fallback to DB if redis is down
|
|
||||||
)
|
|
||||||
code_record = None
|
|
||||||
|
|
||||||
if redis_result is False:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
|
|
||||||
|
|
||||||
if redis_result is None:
|
|
||||||
code_record = _get_latest_valid_code_record(
|
|
||||||
db,
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.LOGIN,
|
|
||||||
)
|
|
||||||
if not code_record:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
|
|
||||||
|
|
||||||
if not verify_verification_code(payload.verification_code, code_record.code_hash):
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
|
|
||||||
else:
|
|
||||||
# Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。
|
|
||||||
# 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。
|
|
||||||
code_record = _get_latest_valid_code_record(
|
|
||||||
db,
|
|
||||||
email=email,
|
|
||||||
purpose=VerificationPurpose.LOGIN,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if code_record is not None:
|
try:
|
||||||
code_record.is_used = True
|
service.verify_code(
|
||||||
db.add(code_record)
|
email=email,
|
||||||
|
purpose=VerificationPurpose.LOGIN,
|
||||||
db.commit()
|
code=payload.verification_code,
|
||||||
|
)
|
||||||
|
except CodeExpiredError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid email or verification code",
|
||||||
|
)
|
||||||
|
except CodeInvalidError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid email or verification code",
|
||||||
|
)
|
||||||
|
except TooManyCodeRequestsError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many attempts",
|
||||||
|
)
|
||||||
|
|
||||||
return _build_auth_response(user)
|
return _build_auth_response(user)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# 推送设置 API:管理用户的推送时间表和推送渠道
|
|
||||||
# 关键约束:同一用户两条推送时间间隔至少 30 分钟
|
|
||||||
from datetime import time as dt_time
|
from datetime import time as dt_time
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
# app/api/endpoints/events.py
|
|
||||||
"""
|
|
||||||
事件模块:统一事件列表、详情、搜索时间线(支持精确/语义/混合匹配)
|
|
||||||
"""
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -41,10 +37,8 @@ SEARCH_MAX_HOURS = int(os.getenv("SEARCH_MAX_HOURS", "168"))
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# 排名轨迹最多返回的点数,避免时间跨度过大时响应体过重。
|
|
||||||
MAX_RANKING_POINTS = 30
|
MAX_RANKING_POINTS = 30
|
||||||
|
|
||||||
# 统一事件列表接口的短期缓存。
|
|
||||||
_UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {}
|
_UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {}
|
||||||
CACHE_TTL_SECONDS = 60
|
CACHE_TTL_SECONDS = 60
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
"""
|
|
||||||
用户偏好模块:兴趣关键词的增删查、基于关键词的个性化事件推荐
|
|
||||||
"""
|
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
@@ -20,7 +17,6 @@ from app.services.matching_service import recommend_events_for_user
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# --- 轻量级接口缓存配置 ---
|
|
||||||
_RECOMMEND_CACHE: Dict[str, Tuple[float, Any]] = {}
|
_RECOMMEND_CACHE: Dict[str, Tuple[float, Any]] = {}
|
||||||
CACHE_TTL_SECONDS = 60
|
CACHE_TTL_SECONDS = 60
|
||||||
|
|
||||||
@@ -29,7 +25,6 @@ def _invalidate_user_cache(user_id: int):
|
|||||||
keys_to_delete = [k for k in _RECOMMEND_CACHE.keys() if k.startswith(f"{user_id}:")]
|
keys_to_delete = [k for k in _RECOMMEND_CACHE.keys() if k.startswith(f"{user_id}:")]
|
||||||
for k in keys_to_delete:
|
for k in keys_to_delete:
|
||||||
_RECOMMEND_CACHE.pop(k, None)
|
_RECOMMEND_CACHE.pop(k, None)
|
||||||
# ---------------------------
|
|
||||||
|
|
||||||
def _ensure_self_access(path_user_id: int, current_user: AppUser) -> None:
|
def _ensure_self_access(path_user_id: int, current_user: AppUser) -> None:
|
||||||
"""校验路径 user_id 是否为当前登录用户本人。"""
|
"""校验路径 user_id 是否为当前登录用户本人。"""
|
||||||
@@ -93,7 +88,7 @@ def create_user_preference(
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.refresh(db_obj)
|
db.refresh(db_obj)
|
||||||
_invalidate_user_cache(user_id) # 失效推荐缓存
|
_invalidate_user_cache(user_id)
|
||||||
return db_obj
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
@@ -122,7 +117,7 @@ def delete_user_preference(
|
|||||||
|
|
||||||
db.delete(preference)
|
db.delete(preference)
|
||||||
db.commit()
|
db.commit()
|
||||||
_invalidate_user_cache(user_id) # 失效推荐缓存
|
_invalidate_user_cache(user_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -143,7 +138,6 @@ def recommend_events(
|
|||||||
"""基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。"""
|
"""基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。"""
|
||||||
_ensure_self_access(user_id, current_user)
|
_ensure_self_access(user_id, current_user)
|
||||||
|
|
||||||
# 推荐结果缓存,避免频繁调用匹配服务
|
|
||||||
cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}"
|
cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}"
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
@@ -151,7 +145,6 @@ def recommend_events(
|
|||||||
expire_time, cached_data = _RECOMMEND_CACHE[cache_key]
|
expire_time, cached_data = _RECOMMEND_CACHE[cache_key]
|
||||||
if current_time < expire_time:
|
if current_time < expire_time:
|
||||||
return cached_data
|
return cached_data
|
||||||
# -----------------------
|
|
||||||
|
|
||||||
matched = recommend_events_for_user(
|
matched = recommend_events_for_user(
|
||||||
db,
|
db,
|
||||||
@@ -189,10 +182,8 @@ def recommend_events(
|
|||||||
|
|
||||||
# 写入缓存,超过 2000 条时清空防止内存膨胀
|
# 写入缓存,超过 2000 条时清空防止内存膨胀
|
||||||
if len(_RECOMMEND_CACHE) > 2000:
|
if len(_RECOMMEND_CACHE) > 2000:
|
||||||
# 防止内存无限增长
|
|
||||||
_RECOMMEND_CACHE.clear()
|
_RECOMMEND_CACHE.clear()
|
||||||
|
|
||||||
_RECOMMEND_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response)
|
_RECOMMEND_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response)
|
||||||
# ------------------
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# 公关修改追踪 API:查询热搜标题被偷偷修改的历史记录,用于舆情监测
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ def list_headline_revisions(
|
|||||||
"""
|
"""
|
||||||
time_limit = utcnow() - timedelta(hours=hours)
|
time_limit = utcnow() - timedelta(hours=hours)
|
||||||
|
|
||||||
# 关联 TrendingEvent、InfoSource 获取平台名和链接
|
|
||||||
rows = (
|
rows = (
|
||||||
db.query(HeadlineRevision, InfoSource.source_name, TrendingEvent.event_url)
|
db.query(HeadlineRevision, InfoSource.source_name, TrendingEvent.event_url)
|
||||||
.join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id)
|
.join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id)
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
# app/api/endpoints/sources.py
|
|
||||||
"""
|
|
||||||
信息源模块:信息源的增删改查,供爬虫与后台管理使用
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
@@ -43,6 +39,4 @@ async def update_info_source(source_id: int, source_in: InfoSourceUpdate, db: Se
|
|||||||
source = crud_source.get(db=db, source_id=source_id)
|
source = crud_source.get(db=db, source_id=source_id)
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="该信息源不存在")
|
raise HTTPException(status_code=404, detail="该信息源不存在")
|
||||||
|
|
||||||
# 直接把查出来的数据库对象和前端传来的 Pydantic 对象丢给 CRUD 处理
|
|
||||||
return crud_source.update(db=db, db_obj=source, obj_in=source_in)
|
return crud_source.update(db=db, db_obj=source, obj_in=source_in)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# 系统状态监控 API:返回爬虫集群运行概况(信息源数、今日抓取量、最近同步时间等)
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ def get_system_stats(db: Session = Depends(get_db)):
|
|||||||
"""获取爬虫集群的当日运行状态。"""
|
"""获取爬虫集群的当日运行状态。"""
|
||||||
today_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
# 信息源统计:总数与启用数
|
|
||||||
total_sources = db.query(func.count(InfoSource.id)).scalar() or 0
|
total_sources = db.query(func.count(InfoSource.id)).scalar() or 0
|
||||||
active_sources = (
|
active_sources = (
|
||||||
db.query(func.count(InfoSource.id))
|
db.query(func.count(InfoSource.id))
|
||||||
@@ -36,7 +34,6 @@ def get_system_stats(db: Session = Depends(get_db)):
|
|||||||
.scalar() or 0
|
.scalar() or 0
|
||||||
)
|
)
|
||||||
|
|
||||||
# 今日任务统计:抓取条数、成功/失败任务数
|
|
||||||
today_tasks = (
|
today_tasks = (
|
||||||
db.query(DataSyncTask)
|
db.query(DataSyncTask)
|
||||||
.filter(DataSyncTask.created_at >= today_start)
|
.filter(DataSyncTask.created_at >= today_start)
|
||||||
@@ -47,7 +44,6 @@ def get_system_stats(db: Session = Depends(get_db)):
|
|||||||
success_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.SUCCESS)
|
success_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.SUCCESS)
|
||||||
error_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.ERROR)
|
error_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.ERROR)
|
||||||
|
|
||||||
# 最后一次同步时间
|
|
||||||
last_task = (
|
last_task = (
|
||||||
db.query(DataSyncTask)
|
db.query(DataSyncTask)
|
||||||
.filter(DataSyncTask.task_status == TaskStatus.SUCCESS)
|
.filter(DataSyncTask.task_status == TaskStatus.SUCCESS)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# app/api/router.py
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import auth, delivery, events, preferences, revisions, sources, stats
|
from app.api.endpoints import auth, delivery, events, preferences, revisions, sources, stats
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.models.models import VerificationPurpose
|
||||||
|
from app.core.verification.email.verificationRepository import VerificationRepository
|
||||||
|
|
||||||
|
class MemoryRepository(VerificationRepository):
|
||||||
|
def __init__(self):
|
||||||
|
self._store = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _key(self, email: str, purpose: VerificationPurpose) -> str:
|
||||||
|
email = email.lower()
|
||||||
|
return f"verification:code:{purpose.value.lower()}:{email}"
|
||||||
|
|
||||||
|
def set_code(self, email: str, purpose: VerificationPurpose, code_hash: str, ttl: int) -> None:
|
||||||
|
key = self._key(email, purpose)
|
||||||
|
expire_at = time.time() + ttl
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"code_hash": code_hash
|
||||||
|
}
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._store[key] = (json.dumps(payload), expire_at)
|
||||||
|
|
||||||
|
def compare_and_consume(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
purpose: VerificationPurpose,
|
||||||
|
code_hash: str,
|
||||||
|
) -> Optional[bool]:
|
||||||
|
key = self._key(email, purpose)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
data = self._store.get(key)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
value, expire_at = data
|
||||||
|
|
||||||
|
if time.time() > expire_at:
|
||||||
|
del self._store[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(value)
|
||||||
|
stored_hash = payload.get("code_hash")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if stored_hash == code_hash:
|
||||||
|
del self._store[key]
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def incr(self, key: str, ttl: int) -> int:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
value, expire = self._store.get(key, (0, 0))
|
||||||
|
|
||||||
|
if now > expire:
|
||||||
|
value = 0
|
||||||
|
|
||||||
|
value += 1
|
||||||
|
self._store[key] = (value, now + ttl)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_memory_repo():
|
||||||
|
return MemoryRepository()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import redis
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.models.models import VerificationPurpose
|
||||||
|
from app.core.verification.email.verificationRepository import VerificationRepository
|
||||||
|
from app.utils.redis_client import get_redis_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AUTH_CODE_REDIS_PREFIX = os.getenv(
|
||||||
|
"AUTH_CODE_REDIS_PREFIX", "insightradar:auth_code"
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
|
class RedisRepository(VerificationRepository):
|
||||||
|
|
||||||
|
_compare_and_consume_lua = """
|
||||||
|
local val = redis.call("GET", KEYS[1])
|
||||||
|
if not val then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local data = cjson.decode(val)
|
||||||
|
|
||||||
|
if data["code_hash"] == ARGV[1] then
|
||||||
|
redis.call("DEL", KEYS[1])
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: redis.Redis):
|
||||||
|
self.client = client
|
||||||
|
self._compare_script = self.client.register_script(
|
||||||
|
self._compare_and_consume_lua
|
||||||
|
)
|
||||||
|
|
||||||
|
def _key(self, email: str, purpose: VerificationPurpose) -> str:
|
||||||
|
return f"{AUTH_CODE_REDIS_PREFIX}:{purpose.value.lower()}:{email}:code"
|
||||||
|
|
||||||
|
def set_code(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
purpose: VerificationPurpose,
|
||||||
|
code_hash: str,
|
||||||
|
ttl: int,
|
||||||
|
) -> None:
|
||||||
|
key = self._key(email, purpose)
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"code_hash": code_hash,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.client.set(key, payload, ex=ttl)
|
||||||
|
|
||||||
|
def compare_and_consume(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
purpose: VerificationPurpose,
|
||||||
|
code_hash: str,
|
||||||
|
) -> Optional[bool]:
|
||||||
|
key = self._key(email, purpose)
|
||||||
|
|
||||||
|
result = self._compare_script(
|
||||||
|
keys=[key],
|
||||||
|
args=[code_hash],
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if result == 1:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def incr(self, key: str, ttl: int) -> int:
|
||||||
|
value = self.client.incr(key)
|
||||||
|
|
||||||
|
if value == 1:
|
||||||
|
self.client.expire(key, ttl)
|
||||||
|
|
||||||
|
return int(value) # type: ignore
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_redis_repo():
|
||||||
|
client = get_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return None
|
||||||
|
return RedisRepository(client)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.models.models import VerificationPurpose
|
||||||
|
from app.core.verification.email.verificationRepository import VerificationRepository
|
||||||
|
from app.core.verification.email.RespositoryImpl.MemoryRepository import get_memory_repo
|
||||||
|
from app.core.verification.email.RespositoryImpl.RedisRepository import get_redis_repo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HybridRepository(VerificationRepository):
|
||||||
|
|
||||||
|
def __init__(self, redis_repo: Optional[VerificationRepository], memory_repo: VerificationRepository):
|
||||||
|
self.redis = redis_repo
|
||||||
|
self.memory = memory_repo
|
||||||
|
|
||||||
|
def set_code(self, email: str, purpose: VerificationPurpose, code_hash: str, ttl: int) -> None:
|
||||||
|
if self.redis:
|
||||||
|
try:
|
||||||
|
self.redis.set_code(email, purpose, code_hash, ttl)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Redis set_code failed, fallback to memory: %s", e)
|
||||||
|
|
||||||
|
self.memory.set_code(email, purpose, code_hash, ttl)
|
||||||
|
|
||||||
|
def compare_and_consume(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
purpose: VerificationPurpose,
|
||||||
|
code_hash: str,
|
||||||
|
) -> Optional[bool]:
|
||||||
|
if self.redis:
|
||||||
|
try:
|
||||||
|
return self.redis.compare_and_consume(
|
||||||
|
email, purpose, code_hash
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Redis compare_and_consume failed, fallback to memory: %s", e
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.memory.compare_and_consume(email, purpose, code_hash)
|
||||||
|
|
||||||
|
def incr(self, key: str, ttl: int) -> int:
|
||||||
|
if self.redis:
|
||||||
|
try:
|
||||||
|
return self.redis.incr(key, ttl)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Redis incr failed, fallback to memory: %s", e)
|
||||||
|
|
||||||
|
return self.memory.incr(key, ttl)
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_verification_repository():
|
||||||
|
redis_repo = get_redis_repo()
|
||||||
|
memory_repo = get_memory_repo()
|
||||||
|
|
||||||
|
return HybridRepository(redis_repo, memory_repo)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from abc import abstractmethod, ABC
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.models.models import VerificationPurpose
|
||||||
|
|
||||||
|
class VerificationRepository(ABC):
|
||||||
|
"""验证码持久层抽象基类"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_code(self, email: str, purpose: VerificationPurpose, code_hash: str, ttl: int) -> None:
|
||||||
|
"""write the code into the storage
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email (str): email of user
|
||||||
|
purpose (VerificationPurpose): the purpose of the code, such as, "login", "register"
|
||||||
|
code_hash (str): hash of the code
|
||||||
|
ttl (int): duration
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def compare_and_consume(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
purpose: VerificationPurpose,
|
||||||
|
code_hash: str,
|
||||||
|
) -> Optional[bool]:
|
||||||
|
"""consume the code atomically
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email (str): email of user
|
||||||
|
purpose (VerificationPurpose): the purpose of the code, such as, "login", "register"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[str]: if success return the true
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def incr(self, key: str, ttl: int) -> int:
|
||||||
|
"""create a counter in the storage, it will be delete after ttl
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): key of the counter
|
||||||
|
ttl (int): duration
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.models.models import VerificationPurpose
|
||||||
|
from app.core.verification.email.verificationRepository import VerificationRepository
|
||||||
|
from app.core.security import generate_verification_code, hash_verification_code
|
||||||
|
from app.core.verification.email.RespositoryImpl.hybirdRepository import get_verification_repository
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 注册验证码有效期(分钟)
|
||||||
|
REGISTER_CODE_EXPIRE_MINUTES = os.getenv("REGISTER_CODE_EXPIRE_MINUTES", 5)
|
||||||
|
|
||||||
|
# 登录验证码有效期(分钟)
|
||||||
|
LOGIN_CODE_EXPIRE_MINUTES = os.getenv("LOGIN_CODE_EXPIRE_MINUTES",5)
|
||||||
|
|
||||||
|
# 同一邮箱发送验证码的冷却间隔(秒)
|
||||||
|
CODE_SEND_COOLDOWN_SECONDS = os.getenv("CODE_SEND_COOLDOWN_SECONDS",60)
|
||||||
|
|
||||||
|
CODE_VERIFICATE_ATTEMP_SECONDS = os.getenv("CODE_VERIFICATE_ATTEMP_SECONDS", 60)
|
||||||
|
|
||||||
|
CODE_VERIFICATE_ATTEMP_COUNT = os.getenv("CODE_VERIFICATE_ATTEMP_COUNT", 10)
|
||||||
|
|
||||||
|
class CodeExpiredError(Exception):
|
||||||
|
"""code has been expired"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CodeInvalidError(Exception):
|
||||||
|
"""code is not right"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TooManyCodeRequestsError(Exception):
|
||||||
|
"""User request too many times when they verificate the email"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_ttl(purpose: VerificationPurpose)->int:
|
||||||
|
if purpose == VerificationPurpose.LOGIN:
|
||||||
|
return int(LOGIN_CODE_EXPIRE_MINUTES) * 60
|
||||||
|
else:
|
||||||
|
return int(REGISTER_CODE_EXPIRE_MINUTES) * 60
|
||||||
|
|
||||||
|
class EmailVerificationService:
|
||||||
|
|
||||||
|
def __init__(self, repo: VerificationRepository) -> None:
|
||||||
|
self.repo = repo
|
||||||
|
|
||||||
|
def _cooldown_key(self, email: str, purpose: VerificationPurpose) -> str:
|
||||||
|
return f"verification:cooldown:{purpose.value}:{email.lower()}"
|
||||||
|
|
||||||
|
def send_code(self, email: str, purpose: VerificationPurpose) -> str:
|
||||||
|
email = email.lower()
|
||||||
|
|
||||||
|
count = self.repo.incr(self._cooldown_key(email, purpose), int(CODE_SEND_COOLDOWN_SECONDS))
|
||||||
|
|
||||||
|
if count > 1:
|
||||||
|
raise TooManyCodeRequestsError("Please wait before requesting another code")
|
||||||
|
|
||||||
|
code = generate_verification_code()
|
||||||
|
code_hash = hash_verification_code(code)
|
||||||
|
|
||||||
|
self.repo.set_code(email, purpose, code_hash, get_ttl(purpose))
|
||||||
|
|
||||||
|
return code
|
||||||
|
|
||||||
|
def verify_code(self,email: str, code: str, purpose: VerificationPurpose):
|
||||||
|
email = email.lower()
|
||||||
|
key = f"verification:attempts:{purpose.value.lower()}:{email}"
|
||||||
|
code_hash = hash_verification_code(code)
|
||||||
|
|
||||||
|
attempts = self.repo.incr(key, int(CODE_VERIFICATE_ATTEMP_SECONDS))
|
||||||
|
if attempts > int(CODE_VERIFICATE_ATTEMP_COUNT):
|
||||||
|
raise TooManyCodeRequestsError("Too many attempts")
|
||||||
|
|
||||||
|
stored = self.repo.compare_and_consume(email, purpose, code_hash)
|
||||||
|
|
||||||
|
if stored == False:
|
||||||
|
raise CodeInvalidError("Invalid code")
|
||||||
|
|
||||||
|
if not stored:
|
||||||
|
raise CodeExpiredError("Code expired or not found")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_verification_service():
|
||||||
|
repo = get_verification_repository()
|
||||||
|
return EmailVerificationService(repo)
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
# app/crud/crud_source.py
|
from sqlite3 import IntegrityError
|
||||||
"""
|
|
||||||
信息源 CRUD:对 InfoSource 的增删改查,供 API 与爬虫使用
|
|
||||||
"""
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
@@ -22,10 +20,20 @@ def get_multi(db: Session, skip: int = 0, limit: int = 100) -> List[InfoSource]:
|
|||||||
def create(db: Session, obj_in: InfoSourceCreate) -> InfoSource:
|
def create(db: Session, obj_in: InfoSourceCreate) -> InfoSource:
|
||||||
"""创建新的信息源"""
|
"""创建新的信息源"""
|
||||||
db_obj = InfoSource(**obj_in.model_dump())
|
db_obj = InfoSource(**obj_in.model_dump())
|
||||||
db.add(db_obj)
|
exits =db.query(InfoSource).filter(InfoSource.source_name == db_obj.source_name).first()
|
||||||
db.commit()
|
if exits:
|
||||||
db.refresh(db_obj)
|
db.close()
|
||||||
return db_obj
|
return db_obj
|
||||||
|
try:
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
def update(db: Session, db_obj: InfoSource, obj_in: InfoSourceUpdate) -> InfoSource:
|
def update(db: Session, db_obj: InfoSource, obj_in: InfoSourceUpdate) -> InfoSource:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# database.py
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|||||||
+31
-40
@@ -1,46 +1,37 @@
|
|||||||
import requests
|
from app.database import SessionLocal
|
||||||
import json
|
from app.crud.crud_source import create
|
||||||
|
from app.models.models import SourceType
|
||||||
|
from app.schemas.source_schema import InfoSourceCreate
|
||||||
|
|
||||||
# 请将此处的 URL 替换为您实际的 API 基础域名
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
api_url = "http://10.252.130.135:8000/api/v1/sources/"
|
|
||||||
|
|
||||||
# 请求头
|
def init():
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
# "Authorization": "Bearer YOUR_TOKEN" # 如果接口需要鉴权,请取消注释并填入 Token
|
|
||||||
}
|
|
||||||
|
|
||||||
# 解析后的数据源列表
|
sources_data = [
|
||||||
sources_data = [
|
{"name": "今日头条", "url": "toutiao"},
|
||||||
{"name": "今日头条", "url": "toutiao"},
|
{"name": "百度热搜", "url": "baidu"},
|
||||||
{"name": "百度热搜", "url": "baidu"},
|
{"name": "华尔街见闻", "url": "wallstreetcn-hot"},
|
||||||
{"name": "华尔街见闻", "url": "wallstreetcn-hot"},
|
{"name": "澎湃新闻", "url": "thepaper"},
|
||||||
{"name": "澎湃新闻", "url": "thepaper"},
|
{"name": "bilibili 热搜", "url": "bilibili-hot-search"},
|
||||||
{"name": "bilibili 热搜", "url": "bilibili-hot-search"},
|
{"name": "财联社热门", "url": "cls-hot"},
|
||||||
{"name": "财联社热门", "url": "cls-hot"},
|
{"name": "凤凰网", "url": "ifeng"},
|
||||||
{"name": "凤凰网", "url": "ifeng"},
|
{"name": "贴吧", "url": "tieba"},
|
||||||
{"name": "贴吧", "url": "tieba"},
|
{"name": "微博", "url": "weibo"},
|
||||||
{"name": "微博", "url": "weibo"},
|
{"name": "抖音", "url": "douyin"},
|
||||||
{"name": "抖音", "url": "douyin"},
|
{"name": "知乎", "url": "zhihu"}
|
||||||
{"name": "知乎", "url": "zhihu"}
|
]
|
||||||
]
|
|
||||||
|
|
||||||
# 遍历数据并发送 POST 请求
|
for item in sources_data:
|
||||||
for item in sources_data:
|
try:
|
||||||
payload = {
|
with SessionLocal() as db:
|
||||||
"source_name": item["name"],
|
|
||||||
"source_type": "HOT_TREND",
|
|
||||||
"home_url": item["url"],
|
|
||||||
"is_enabled": True
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
create(db, InfoSourceCreate(
|
||||||
response = requests.post(api_url, headers=headers, data=json.dumps(payload))
|
source_name=item["name"],
|
||||||
if response.status_code in (200, 201):
|
source_type=SourceType.HOT_TREND,
|
||||||
print(f"✅ 成功创建: {item['name']}")
|
home_url=item["url"],
|
||||||
else:
|
is_enabled=True
|
||||||
print(f"❌ 创建失败: {item['name']} - 状态码: {response.status_code} - 详情: {response.text}")
|
))
|
||||||
except Exception as e:
|
print(f"创建订阅源{item['name']}")
|
||||||
print(f"⚠️ 请求异常: {item['name']} - 错误: {e}")
|
|
||||||
|
|
||||||
print("执行完毕!")
|
except Exception as e:
|
||||||
|
print(f"⚠️ 请求异常: {item['name']} - 错误: {e}")
|
||||||
|
|||||||
+38
-32
@@ -1,14 +1,17 @@
|
|||||||
# app/main.py
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
|
import httpx
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException, Request, staticfiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# 统一配置日志格式和级别,确保 delivery_service 等的 INFO 日志可见
|
# 统一配置日志格式和级别,确保 delivery_service 等的 INFO 日志可见
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.DEBUG,
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
@@ -21,28 +24,29 @@ from app.services.summary_service import generate_unified_summaries
|
|||||||
from app.services.delivery_service import check_and_deliver
|
from app.services.delivery_service import check_and_deliver
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.models.models import Base
|
from app.models.models import Base
|
||||||
|
from app.initialize import init
|
||||||
|
|
||||||
# 路由总线
|
# 路由总线
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
CRAWL_INTERVAL = int(os.getenv("CRAWL_INTERVAL_MINUTES", 10))
|
CRAWL_INTERVAL = int(os.getenv("CRAWL_INTERVAL_MINUTES", 10))
|
||||||
SUMMARY_INTERVAL = int(os.getenv("SUMMARY_INTERVAL_MINUTES", 30))
|
SUMMARY_INTERVAL = int(os.getenv("SUMMARY_INTERVAL_MINUTES", 30))
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 1. 生命周期管理:App 启动时自动建表 & 启动调度器
|
|
||||||
# ==========================================
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# 1. 数据库建表
|
# 1. 数据库建表
|
||||||
print("正在初始化数据库表...")
|
logging.info("正在初始化数据库表...")
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
print("数据库表初始化完成!")
|
logging.info("数据库表初始化完成!")
|
||||||
|
|
||||||
# 2. 配置并启动定时任务
|
logging.info("初始化订阅源")
|
||||||
|
init()
|
||||||
|
logging.info("订阅源初始化完毕")
|
||||||
|
|
||||||
|
# 爬取订阅源
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
fetch_and_save_trending_data,
|
fetch_and_save_trending_data,
|
||||||
'interval',
|
'interval',
|
||||||
@@ -59,7 +63,7 @@ async def lifespan(app: FastAPI):
|
|||||||
id='ai_summary_job',
|
id='ai_summary_job',
|
||||||
replace_existing=True
|
replace_existing=True
|
||||||
)
|
)
|
||||||
# 推送调度:每分钟检查是否有用户需要接收邮件推送
|
# 推送调度
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
check_and_deliver,
|
check_and_deliver,
|
||||||
'interval',
|
'interval',
|
||||||
@@ -69,28 +73,18 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
|
logging.info(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
|
||||||
print(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
|
logging.info(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
|
||||||
print("邮件推送调度已启动,每分钟检查一次")
|
logging.info("邮件推送调度已启动,每分钟检查一次")
|
||||||
|
|
||||||
# 为了测试方便,启动时立即执行一次
|
yield
|
||||||
# await fetch_and_save_trending_data()
|
|
||||||
|
|
||||||
# await generate_unified_summaries()
|
|
||||||
|
|
||||||
yield # 此时 FastAPI 开始接受请求
|
|
||||||
|
|
||||||
# 优雅关闭
|
|
||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
print("定时任务已安全关闭")
|
logging.info("定时任务已安全关闭")
|
||||||
|
|
||||||
|
|
||||||
# 初始化 FastAPI
|
|
||||||
app = FastAPI(title="AI 新闻聚合引擎 API", lifespan=lifespan)
|
app = FastAPI(title="AI 新闻聚合引擎 API", lifespan=lifespan)
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 2. CORS 中间件:允许前端开发服务器跨域请求
|
|
||||||
# ==========================================
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
# allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
# allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
@@ -100,14 +94,26 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 3. 挂载路由总线
|
|
||||||
# ==========================================
|
|
||||||
# 版本控制
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
# AI辅助生成结束
|
||||||
|
|
||||||
|
@app.get("/api/{full_path:path}")
|
||||||
|
async def api_not_found(full_path: str):
|
||||||
|
return {"detail": "API Not Found"}
|
||||||
|
|
||||||
|
staticPath = staticfiles.StaticFiles(directory="app/static", html=True)
|
||||||
|
|
||||||
|
app.mount("/", staticPath, name="static")
|
||||||
|
|
||||||
|
INDEX_HTML = Path("app/static/index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
@app.exception_handler(404)
|
||||||
|
async def not_found_handler(request: Request, exc: HTTPException):
|
||||||
|
if request.url.path.startswith("/api/"):
|
||||||
|
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
||||||
|
return HTMLResponse(INDEX_HTML)
|
||||||
|
|
||||||
# 健康检查
|
|
||||||
@app.get("/", tags=["健康检查"])
|
@app.get("/", tags=["健康检查"])
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "Welcome to AI News Aggregator API", "status": "ok"}
|
return {"message": "Welcome to AI News Aggregator API", "status": "ok"}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# models.py
|
|
||||||
from datetime import datetime, timezone, time
|
from datetime import datetime, timezone, time
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
import enum
|
import enum
|
||||||
@@ -9,11 +8,6 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 0. 全局基类、枚举定义与动态类型
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
"""
|
"""
|
||||||
SQLAlchemy 2.0 声明式基类
|
SQLAlchemy 2.0 声明式基类
|
||||||
@@ -21,9 +15,6 @@ class Base(DeclarativeBase):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# 让代码在 SQLite 环境下自动降级为 Integer 以保证自增正常工作,
|
|
||||||
# 而在生产环境部署到 PostgreSQL 或 MySQL 时,依然会使用容量更大的 BigInteger。
|
|
||||||
BigIntType = BigInteger().with_variant(Integer, "sqlite")
|
BigIntType = BigInteger().with_variant(Integer, "sqlite")
|
||||||
|
|
||||||
|
|
||||||
@@ -70,10 +61,6 @@ def utcnow():
|
|||||||
"""
|
"""
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 模块一:信息源管理
|
|
||||||
# ==========================================
|
|
||||||
class InfoSource(Base):
|
class InfoSource(Base):
|
||||||
"""
|
"""
|
||||||
抓取源配置表
|
抓取源配置表
|
||||||
@@ -93,11 +80,11 @@ class InfoSource(Base):
|
|||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source_name", name="uix_source_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 模块二:AI 语义聚类中枢 (大事件池)
|
|
||||||
# ==========================================
|
|
||||||
class UnifiedEvent(Base):
|
class UnifiedEvent(Base):
|
||||||
"""
|
"""
|
||||||
AI 统一事件表 (核心大脑)
|
AI 统一事件表 (核心大脑)
|
||||||
@@ -120,10 +107,6 @@ class UnifiedEvent(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 模块三:内容存储库 (热搜 & 新闻子节点)
|
|
||||||
# ==========================================
|
|
||||||
class TrendingEvent(Base):
|
class TrendingEvent(Base):
|
||||||
"""
|
"""
|
||||||
各平台热搜数据明细表
|
各平台热搜数据明细表
|
||||||
@@ -176,8 +159,7 @@ class NewsArticle(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||||
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="所属信息源ID")
|
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="所属信息源ID")
|
||||||
unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"),
|
unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"), comment="深度文章也可归入大事件分析")
|
||||||
comment="深度文章也可归入大事件分析")
|
|
||||||
|
|
||||||
external_id: Mapped[str] = mapped_column(String(32), comment="RSS原文<guid>生成的MD5防重指纹")
|
external_id: Mapped[str] = mapped_column(String(32), comment="RSS原文<guid>生成的MD5防重指纹")
|
||||||
title_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="新闻标题/摘要的语义向量")
|
title_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="新闻标题/摘要的语义向量")
|
||||||
@@ -196,10 +178,6 @@ class NewsArticle(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 模块四:热度与轨迹追踪
|
|
||||||
# ==========================================
|
|
||||||
class HeadlineRevision(Base):
|
class HeadlineRevision(Base):
|
||||||
"""
|
"""
|
||||||
标题修订历史表
|
标题修订历史表
|
||||||
@@ -214,8 +192,7 @@ class HeadlineRevision(Base):
|
|||||||
previous_headline: Mapped[str] = mapped_column(String(255), comment="修改前的旧标题")
|
previous_headline: Mapped[str] = mapped_column(String(255), comment="修改前的旧标题")
|
||||||
revised_headline: Mapped[str] = mapped_column(String(255), comment="修改后的新标题")
|
revised_headline: Mapped[str] = mapped_column(String(255), comment="修改后的新标题")
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="系统发现被修改的时间")
|
||||||
comment="系统发现被修改的时间")
|
|
||||||
|
|
||||||
|
|
||||||
class RankingLog(Base):
|
class RankingLog(Base):
|
||||||
@@ -235,15 +212,10 @@ class RankingLog(Base):
|
|||||||
# 当时它在第几名
|
# 当时它在第几名
|
||||||
ranking_position: Mapped[int] = mapped_column(Integer, comment="当时抓取时的排名名次")
|
ranking_position: Mapped[int] = mapped_column(Integer, comment="当时抓取时的排名名次")
|
||||||
# 爬虫看到它的那一瞬间的时间
|
# 爬虫看到它的那一瞬间的时间
|
||||||
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
|
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="观察到该名次的准确时间")
|
||||||
comment="观察到该名次的准确时间")
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 模块五:多态话题与多态评论
|
|
||||||
# ==========================================
|
|
||||||
class ExtractedTopic(Base):
|
class ExtractedTopic(Base):
|
||||||
"""
|
"""
|
||||||
AI 提取的核心话题标签表
|
AI 提取的核心话题标签表
|
||||||
@@ -290,10 +262,6 @@ class DiscussionComment(Base):
|
|||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 模块六:用户画像与多渠道高可用推送系统
|
|
||||||
# ==========================================
|
|
||||||
class AppUser(Base):
|
class AppUser(Base):
|
||||||
"""
|
"""
|
||||||
系统核心用户表
|
系统核心用户表
|
||||||
@@ -304,39 +272,15 @@ class AppUser(Base):
|
|||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
email: Mapped[str] = mapped_column(String(150), unique=True, index=True, comment="主账号邮箱")
|
email: Mapped[str] = mapped_column(String(150), unique=True, index=True, comment="主账号邮箱")
|
||||||
password_hash: Mapped[Optional[str]] = mapped_column(String(255), comment="密码哈希(第三方登录可为空)")
|
password_hash: Mapped[Optional[str]] = mapped_column(String(255), comment="密码哈希(第三方登录可为空)")
|
||||||
|
|
||||||
nickname: Mapped[Optional[str]] = mapped_column(String(100), comment="用户展示昵称")
|
nickname: Mapped[Optional[str]] = mapped_column(String(100), comment="用户展示昵称")
|
||||||
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), comment="用户头像地址")
|
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), comment="用户头像地址")
|
||||||
gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN,
|
gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN, comment="用户性别(用于AI调整行文语气)")
|
||||||
comment="用户性别(用于AI调整行文语气)")
|
metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON, comment="JSON扩展字段: 存放灵活多变的前端用户偏好设置")
|
||||||
|
|
||||||
# 极其强大:一个万能收纳箱!前端未来想加任何诸如“夜间模式”、“字体变大”的开关,
|
|
||||||
# 全部丢进这个 JSON 字段即可,从此免去手动修改后端表结构的麻烦。
|
|
||||||
metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON,
|
|
||||||
comment="JSON扩展字段: 存放灵活多变的前端用户偏好设置")
|
|
||||||
|
|
||||||
# 时区对于定时推送系统极其重要!保证纽约的用户和北京的用户都能在早晨8点收到新闻。
|
|
||||||
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai", comment="用户所在地时区")
|
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai", comment="用户所在地时区")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
class EmailVerificationCode(Base):
|
|
||||||
__tablename__ = "email_verification_codes"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_email_code_lookup", "email", "purpose", "is_used", "expires_at"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
email: Mapped[str] = mapped_column(String(150), index=True, nullable=False)
|
|
||||||
purpose: Mapped[VerificationPurpose] = mapped_column(Enum(VerificationPurpose), nullable=False)
|
|
||||||
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
||||||
is_used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class UserPushEndpoint(Base):
|
class UserPushEndpoint(Base):
|
||||||
"""
|
"""
|
||||||
多渠道推送端点配置表 (高可用解耦设计)
|
多渠道推送端点配置表 (高可用解耦设计)
|
||||||
@@ -350,14 +294,10 @@ class UserPushEndpoint(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID")
|
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID")
|
||||||
# 填入大写的纯字符串,如 EMAIL, WECHAT_BOT, TELEGRAM
|
|
||||||
channel_type: Mapped[str] = mapped_column(String(50), comment="推送渠道类型标识")
|
channel_type: Mapped[str] = mapped_column(String(50), comment="推送渠道类型标识")
|
||||||
# 具体的发送目标地址
|
|
||||||
channel_account: Mapped[str] = mapped_column(String(255), comment="具体的接收账号(邮箱号/微信号/Webhook)")
|
channel_account: Mapped[str] = mapped_column(String(255), comment="具体的接收账号(邮箱号/微信号/Webhook)")
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="用户是否临时关闭了该渠道")
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="用户是否临时关闭了该渠道")
|
||||||
# 高可用容灾:比如 1 代表必须先发微信,如果报错了,再去找 priority=2 的邮箱补发
|
|
||||||
priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="推送优先级(1最高,用于错误降级重试)")
|
priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="推送优先级(1最高,用于错误降级重试)")
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
@@ -369,7 +309,6 @@ class UserTopicPreference(Base):
|
|||||||
"""
|
"""
|
||||||
__tablename__ = "user_topic_preferences"
|
__tablename__ = "user_topic_preferences"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# 联合防抖限制:防止用户在界面卡顿时连点两次,订阅了两个同样的词
|
|
||||||
UniqueConstraint("user_id", "interested_keyword", name="idx_unique_preference"),
|
UniqueConstraint("user_id", "interested_keyword", name="idx_unique_preference"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -406,7 +345,6 @@ class DeliveryHistory(Base):
|
|||||||
"""
|
"""
|
||||||
__tablename__ = "delivery_history"
|
__tablename__ = "delivery_history"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# 终极去重约束:一个用户,针对同一篇新闻,永远只允许存在一条记录
|
|
||||||
UniqueConstraint("user_id", "target_type", "target_id", name="idx_prevent_duplicate_push"),
|
UniqueConstraint("user_id", "target_type", "target_id", name="idx_prevent_duplicate_push"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -414,16 +352,10 @@ class DeliveryHistory(Base):
|
|||||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="接收推送的用户")
|
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="接收推送的用户")
|
||||||
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="推送出去的具体内容类型")
|
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="推送出去的具体内容类型")
|
||||||
target_id: Mapped[int] = mapped_column(BigIntType, comment="推送内容的主键ID")
|
target_id: Mapped[int] = mapped_column(BigIntType, comment="推送内容的主键ID")
|
||||||
# 记录这次推送是彻底成功了,还是由于渠道网络问题失败了
|
|
||||||
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="最终推送结果状态")
|
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="最终推送结果状态")
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="记录或实际推送的准确时间")
|
||||||
comment="记录或实际推送的准确时间")
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 模块七:系统任务监控
|
|
||||||
# ==========================================
|
|
||||||
class DataSyncTask(Base):
|
class DataSyncTask(Base):
|
||||||
"""
|
"""
|
||||||
数据同步健康度监控表 (运维巡检专用)
|
数据同步健康度监控表 (运维巡检专用)
|
||||||
@@ -436,7 +368,6 @@ class DataSyncTask(Base):
|
|||||||
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="本次运行爬取的哪个源")
|
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="本次运行爬取的哪个源")
|
||||||
items_fetched: Mapped[int] = mapped_column(Integer, default=0, comment="本次爬虫成功插入或更新的新闻条数")
|
items_fetched: Mapped[int] = mapped_column(Integer, default=0, comment="本次爬虫成功插入或更新的新闻条数")
|
||||||
task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="该平台的宏观抓取状态")
|
task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="该平台的宏观抓取状态")
|
||||||
# 如果代码意外崩溃、或是遭遇403/502,把 Python的 traceback 堆栈原封不动存进这里
|
|
||||||
error_trace: Mapped[Optional[str]] = mapped_column(Text, comment="若失败则保存完整报错堆栈")
|
error_trace: Mapped[Optional[str]] = mapped_column(Text, comment="若失败则保存完整报错堆栈")
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="任务执行的发生时间")
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="任务执行的发生时间")
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
# 推送邮件 HTML 模板
|
|
||||||
# 用于生成定时推送给用户的热点摘要邮件
|
|
||||||
|
|
||||||
# 邮件客户端不支持 Font Awesome,改用 Emoji 代替平台图标
|
|
||||||
PLATFORM_EMOJI: dict[str, str] = {
|
PLATFORM_EMOJI: dict[str, str] = {
|
||||||
"微博热搜": "🔴",
|
"微博热搜": "🔴",
|
||||||
"微博": "🔴",
|
"微博": "🔴",
|
||||||
@@ -86,7 +82,7 @@ body{{margin:0;padding:0;background:#0d1117;color:#e6edf3;font-family:-apple-sys
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>InsightRadar · 热点快报</h1>
|
<h1>聚势智见 · 热点快报</h1>
|
||||||
<p>{delivery_time} · 为你精选了 {event_count} 条事件</p>
|
<p>{delivery_time} · 为你精选了 {event_count} 条事件</p>
|
||||||
<span class="mode-badge {mode_badge_class}">{mode_label}</span>
|
<span class="mode-badge {mode_badge_class}">{mode_label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,8 +90,8 @@ body{{margin:0;padding:0;background:#0d1117;color:#e6edf3;font-family:-apple-sys
|
|||||||
{event_cards_html}
|
{event_cards_html}
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>此邮件由 InsightRadar 自动推送。</p>
|
<p>此邮件由 聚势智见自动推送。</p>
|
||||||
<p>如需调整推送设置,请登录 <a href="{app_url}">InsightRadar 控制台</a></p>
|
<p>如需调整推送设置,请登录 <a href="{app_url}">聚势智见 控制台</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# 推送设置相关的请求/响应模型
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 推送时间表 (UserDeliverySchedule)
|
# 推送时间表 (UserDeliverySchedule)
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
# app/schemas/event_schema.py
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class PlatformTrendResponse(BaseModel):
|
class PlatformTrendResponse(BaseModel):
|
||||||
source_id: int
|
source_id: int
|
||||||
platform_name: str
|
platform_name: str
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class UserTopicPreferenceCreate(BaseModel):
|
class UserTopicPreferenceCreate(BaseModel):
|
||||||
"""新增用户兴趣词请求体。"""
|
"""新增用户兴趣词请求体。"""
|
||||||
interested_keyword: str = Field(..., min_length=1, max_length=100, description="用户感兴趣的关键词")
|
interested_keyword: str = Field(..., min_length=1, max_length=100, description="用户感兴趣的关键词")
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# app/schemas/source_schema.py
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -6,6 +5,7 @@ from datetime import datetime
|
|||||||
# 枚举
|
# 枚举
|
||||||
from app.models.models import SourceType
|
from app.models.models import SourceType
|
||||||
|
|
||||||
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# InfoSource (信息源) 相关的 Schemas
|
# InfoSource (信息源) 相关的 Schemas
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
# 定时推送调度服务
|
|
||||||
# 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送,
|
|
||||||
# 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。
|
|
||||||
# 推送优先级:有关键词且匹配 → 个性化简报;无关键词或无匹配 → 默认热点快报
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from logging.handlers import TimedRotatingFileHandler
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
@@ -34,7 +30,7 @@ from app.utils.email_utils import send_html_email
|
|||||||
|
|
||||||
logger = logging.getLogger("delivery_service")
|
logger = logging.getLogger("delivery_service")
|
||||||
|
|
||||||
# delivery_service 日志单独写文件
|
|
||||||
_delivery_log_dir = Path(__file__).resolve().parents[2] / "logs"
|
_delivery_log_dir = Path(__file__).resolve().parents[2] / "logs"
|
||||||
_delivery_log_dir.mkdir(parents=True, exist_ok=True)
|
_delivery_log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
_delivery_log_file = _delivery_log_dir / "delivery_check.log"
|
_delivery_log_file = _delivery_log_dir / "delivery_check.log"
|
||||||
@@ -51,6 +47,8 @@ if not logger.handlers:
|
|||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
logger.propagate = False
|
logger.propagate = False
|
||||||
|
|
||||||
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
|
|
||||||
# 推送时间窗口:实际执行时刻与设定时间的最大容差(分钟)
|
# 推送时间窗口:实际执行时刻与设定时间的最大容差(分钟)
|
||||||
DELIVERY_WINDOW_MINUTES = int(os.getenv("DELIVERY_WINDOW_MINUTES", 2))
|
DELIVERY_WINDOW_MINUTES = int(os.getenv("DELIVERY_WINDOW_MINUTES", 2))
|
||||||
# 同一用户两次推送之间的最小间隔(分钟)
|
# 同一用户两次推送之间的最小间隔(分钟)
|
||||||
@@ -64,13 +62,10 @@ DEFAULT_MODE_HOURS = int(os.getenv("DEFAULT_MODE_HOURS", 24))
|
|||||||
# 用户时区无效时的兜底时区
|
# 用户时区无效时的兜底时区
|
||||||
DEFAULT_FALLBACK_TIMEZONE = os.getenv("DEFAULT_FALLBACK_TIMEZONE", "Asia/Shanghai")
|
DEFAULT_FALLBACK_TIMEZONE = os.getenv("DEFAULT_FALLBACK_TIMEZONE", "Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 默认热点事件容器(无关键词时使用)
|
|
||||||
# ==========================================
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _DefaultEventItem:
|
class _DefaultEventItem:
|
||||||
"""
|
"""
|
||||||
|
默认热点事件容器
|
||||||
无关键词订阅或关键词无匹配时的默认热点包装器,
|
无关键词订阅或关键词无匹配时的默认热点包装器,
|
||||||
接口与 MatchedEventResult 保持一致,方便统一传给模板。
|
接口与 MatchedEventResult 保持一致,方便统一传给模板。
|
||||||
"""
|
"""
|
||||||
@@ -81,10 +76,6 @@ class _DefaultEventItem:
|
|||||||
tags: list[str] = field(default_factory=list)
|
tags: list[str] = field(default_factory=list)
|
||||||
is_default: bool = True
|
is_default: bool = True
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 时区工具
|
|
||||||
# ==========================================
|
|
||||||
def _time_to_minutes(t: dt_time) -> int:
|
def _time_to_minutes(t: dt_time) -> int:
|
||||||
return t.hour * 60 + t.minute
|
return t.hour * 60 + t.minute
|
||||||
|
|
||||||
@@ -125,10 +116,10 @@ def _ensure_aware(dt: datetime) -> datetime:
|
|||||||
return dt.replace(tzinfo=timezone.utc)
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
# AI辅助生成结束
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 数据库查询辅助
|
# 数据库查询辅助
|
||||||
# ==========================================
|
|
||||||
def _should_skip_by_interval(db: Session, user_id: int) -> bool:
|
def _should_skip_by_interval(db: Session, user_id: int) -> bool:
|
||||||
"""检查用户是否仍在冷却期内,避免短时间内重复推送"""
|
"""检查用户是否仍在冷却期内,避免短时间内重复推送"""
|
||||||
row = (
|
row = (
|
||||||
@@ -297,9 +288,9 @@ def _record_delivery(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
|
|
||||||
# 推送准备
|
# 推送准备
|
||||||
# ==========================================
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _PendingPush:
|
class _PendingPush:
|
||||||
"""暂存需要发送邮件的信息,便于在 async 上下文中发送。"""
|
"""暂存需要发送邮件的信息,便于在 async 上下文中发送。"""
|
||||||
@@ -309,6 +300,7 @@ class _PendingPush:
|
|||||||
html_body: str
|
html_body: str
|
||||||
event_ids: list[int]
|
event_ids: list[int]
|
||||||
|
|
||||||
|
# AI生成结束
|
||||||
|
|
||||||
def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedule) -> _PendingPush | None:
|
def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedule) -> _PendingPush | None:
|
||||||
"""
|
"""
|
||||||
@@ -331,7 +323,6 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
|
|||||||
|
|
||||||
pushed_ids = _get_already_pushed_event_ids(db, user_id)
|
pushed_ids = _get_already_pushed_event_ids(db, user_id)
|
||||||
|
|
||||||
# 决策:有关键词且有匹配 → 匹配模式;否则 → 默认热点模式
|
|
||||||
items: list = []
|
items: list = []
|
||||||
is_default = False
|
is_default = False
|
||||||
|
|
||||||
@@ -361,7 +352,6 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
|
|||||||
logger.info(f"用户 {user_id} 默认热点无可推送内容,跳过")
|
logger.info(f"用户 {user_id} 默认热点无可推送内容,跳过")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 批量加载平台数据(来源名、标题、URL、排名)
|
|
||||||
event_ids = [item.event.id for item in items]
|
event_ids = [item.event.id for item in items]
|
||||||
platforms_map = _load_event_platforms(db, event_ids)
|
platforms_map = _load_event_platforms(db, event_ids)
|
||||||
|
|
||||||
@@ -377,15 +367,12 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
|
|||||||
return _PendingPush(
|
return _PendingPush(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
email_targets=[ep.channel_account for ep in email_endpoints],
|
email_targets=[ep.channel_account for ep in email_endpoints],
|
||||||
subject=f"InsightRadar {subject_suffix} · {time_str}",
|
subject=f"聚势智见 {subject_suffix} · {time_str}",
|
||||||
html_body=html_body,
|
html_body=html_body,
|
||||||
event_ids=event_ids,
|
event_ids=event_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 调度主入口
|
|
||||||
# ==========================================
|
|
||||||
async def check_and_deliver() -> None:
|
async def check_and_deliver() -> None:
|
||||||
"""
|
"""
|
||||||
定时推送主入口,由 APScheduler 每分钟调用。
|
定时推送主入口,由 APScheduler 每分钟调用。
|
||||||
@@ -412,7 +399,6 @@ async def check_and_deliver() -> None:
|
|||||||
if not user:
|
if not user:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 将 UTC 转为用户本地时间,判断是否落在推送窗口内
|
|
||||||
user_current = _user_local_time(now, user.timezone)
|
user_current = _user_local_time(now, user.timezone)
|
||||||
if not _is_within_window(schedule.delivery_time, user_current):
|
if not _is_within_window(schedule.delivery_time, user_current):
|
||||||
continue
|
continue
|
||||||
@@ -422,7 +408,6 @@ async def check_and_deliver() -> None:
|
|||||||
if pending is None:
|
if pending is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 异步按优先级尝试各邮件渠道
|
|
||||||
sent = False
|
sent = False
|
||||||
for target_email in pending.email_targets:
|
for target_email in pending.email_targets:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
# app/services/fetcher_service.py
|
|
||||||
"""
|
|
||||||
抓取服务:从外部 API 拉取热搜/RSS 数据,做查重、向量聚类、入库
|
|
||||||
热搜分支:语义聚类到 UnifiedEvent;RSS 分支:写入 NewsArticle
|
|
||||||
"""
|
|
||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -19,6 +14,8 @@ from app.models.models import (
|
|||||||
HeadlineRevision, RankingLog, SourceType, utcnow, UnifiedEvent
|
HeadlineRevision, RankingLog, SourceType, utcnow, UnifiedEvent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
|
|
||||||
# 加载环境变量
|
# 加载环境变量
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
hf_token = os.getenv("HF_TOKEN")
|
hf_token = os.getenv("HF_TOKEN")
|
||||||
@@ -26,11 +23,13 @@ SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", 0.72))
|
|||||||
API_BASE_URL = os.getenv("API_BASE_URL", "https://newsnow.busiyi.world/api/s")
|
API_BASE_URL = os.getenv("API_BASE_URL", "https://newsnow.busiyi.world/api/s")
|
||||||
EMBEDDING_MODEL_PATH = os.getenv("EMBEDDING_MODEL_PATH", "")
|
EMBEDDING_MODEL_PATH = os.getenv("EMBEDDING_MODEL_PATH", "")
|
||||||
|
|
||||||
print("正在加载 BAAI/bge-m3 向量模型...")
|
print("正在加载模型...")
|
||||||
# 全局单例
|
# 全局单例
|
||||||
embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True, device="cuda")
|
embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True)
|
||||||
print("模型加载完成。")
|
print("模型加载完成。")
|
||||||
|
|
||||||
|
# AI生成结束
|
||||||
|
|
||||||
|
|
||||||
def generate_md5(text: str) -> str:
|
def generate_md5(text: str) -> str:
|
||||||
"""生成 32 位 MD5 作为 external_id,用于跨平台去重"""
|
"""生成 32 位 MD5 作为 external_id,用于跨平台去重"""
|
||||||
@@ -88,10 +87,10 @@ class UnifiedEventClusterer:
|
|||||||
new_unified = UnifiedEvent(
|
new_unified = UnifiedEvent(
|
||||||
unified_title=title,
|
unified_title=title,
|
||||||
center_embedding=embedding_json,
|
center_embedding=embedding_json,
|
||||||
hot_score=1 # 初始热度
|
hot_score=1
|
||||||
)
|
)
|
||||||
self.db.add(new_unified)
|
self.db.add(new_unified)
|
||||||
self.db.flush() # 获取自增的主键 ID
|
self.db.flush()
|
||||||
|
|
||||||
# 更新缓存
|
# 更新缓存
|
||||||
self.event_vectors.append(new_vec)
|
self.event_vectors.append(new_vec)
|
||||||
@@ -109,11 +108,8 @@ def process_hot_trend_item(db, source, item, index: int, external_id: str, exist
|
|||||||
|
|
||||||
event_to_log = None
|
event_to_log = None
|
||||||
|
|
||||||
# 查重:已存在则可能只需更新标题/排名;不存在则需聚类并新建
|
|
||||||
if existing_event:
|
if existing_event:
|
||||||
# 场景 A1:老熟人
|
|
||||||
if existing_event.current_headline != title:
|
if existing_event.current_headline != title:
|
||||||
# 标题被暗改,此时需要重新算一次 Embedding
|
|
||||||
new_embedding_json, _ = embeddings_dict[title]
|
new_embedding_json, _ = embeddings_dict[title]
|
||||||
|
|
||||||
revision = HeadlineRevision(
|
revision = HeadlineRevision(
|
||||||
@@ -123,30 +119,25 @@ def process_hot_trend_item(db, source, item, index: int, external_id: str, exist
|
|||||||
)
|
)
|
||||||
db.add(revision)
|
db.add(revision)
|
||||||
existing_event.current_headline = title
|
existing_event.current_headline = title
|
||||||
existing_event.title_embedding = new_embedding_json # 更新为新标题的语义向量
|
existing_event.title_embedding = new_embedding_json
|
||||||
# 注:这里不改变它所属的 unified_event_id,因为大体还是同一件事
|
|
||||||
|
|
||||||
existing_event.current_ranking = index
|
existing_event.current_ranking = index
|
||||||
existing_event.event_url = item_url
|
existing_event.event_url = item_url
|
||||||
event_to_log = existing_event
|
event_to_log = existing_event
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# 场景 A2:这是一条彻底的全新热搜
|
|
||||||
# 1. 计算向量
|
|
||||||
new_embedding_json, new_vec = embeddings_dict[title]
|
|
||||||
|
|
||||||
# 2. 扔进聚类中枢找归宿
|
new_embedding_json, new_vec = embeddings_dict[title]
|
||||||
matched_event_id = clusterer.match_or_create(title, new_embedding_json, new_vec)
|
matched_event_id = clusterer.match_or_create(title, new_embedding_json, new_vec)
|
||||||
|
|
||||||
# 3. 落库
|
|
||||||
new_event = TrendingEvent(
|
new_event = TrendingEvent(
|
||||||
source_id=source.id,
|
source_id=source.id,
|
||||||
external_id=external_id,
|
external_id=external_id,
|
||||||
current_headline=title,
|
current_headline=title,
|
||||||
event_url=item_url,
|
event_url=item_url,
|
||||||
current_ranking=index,
|
current_ranking=index,
|
||||||
title_embedding=new_embedding_json, # 存入向量
|
title_embedding=new_embedding_json,
|
||||||
unified_event_id=matched_event_id # 挂载到大事件下
|
unified_event_id=matched_event_id
|
||||||
)
|
)
|
||||||
db.add(new_event)
|
db.add(new_event)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -192,7 +183,6 @@ def process_source_data(db, source, items: list) -> int:
|
|||||||
saved_count = 0
|
saved_count = 0
|
||||||
platform_id = source.home_url
|
platform_id = source.home_url
|
||||||
|
|
||||||
# 1. 批量计算外部 ID 并聚合要计算的文本
|
|
||||||
valid_items = []
|
valid_items = []
|
||||||
external_ids = []
|
external_ids = []
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -209,7 +199,6 @@ def process_source_data(db, source, items: list) -> int:
|
|||||||
if not valid_items:
|
if not valid_items:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 批量查重:按 external_id 判断是更新还是新增
|
|
||||||
existing_events_dict = {}
|
existing_events_dict = {}
|
||||||
existing_articles_dict = {}
|
existing_articles_dict = {}
|
||||||
|
|
||||||
@@ -226,7 +215,6 @@ def process_source_data(db, source, items: list) -> int:
|
|||||||
).all()
|
).all()
|
||||||
existing_articles_dict = {art.external_id: art for art in existing_articles}
|
existing_articles_dict = {art.external_id: art for art in existing_articles}
|
||||||
|
|
||||||
# 仅对需要算向量的标题做批量 embedding,避免重复计算
|
|
||||||
texts_to_embed = []
|
texts_to_embed = []
|
||||||
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
||||||
for item, external_id in valid_items:
|
for item, external_id in valid_items:
|
||||||
@@ -238,15 +226,12 @@ def process_source_data(db, source, items: list) -> int:
|
|||||||
else:
|
else:
|
||||||
texts_to_embed.append(title)
|
texts_to_embed.append(title)
|
||||||
|
|
||||||
# 4. 批量执行大模型推理
|
|
||||||
embeddings_dict = generate_embeddings_batch(texts_to_embed)
|
embeddings_dict = generate_embeddings_batch(texts_to_embed)
|
||||||
|
|
||||||
# 初始化聚类器(只在热搜模式下需要,且只初始化一次)
|
|
||||||
clusterer = None
|
clusterer = None
|
||||||
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
||||||
clusterer = UnifiedEventClusterer(db)
|
clusterer = UnifiedEventClusterer(db)
|
||||||
|
|
||||||
# 按来源类型分流:热搜/API → TrendingEvent + 聚类;RSS → NewsArticle
|
|
||||||
for index, (item, external_id) in enumerate(valid_items, 1):
|
for index, (item, external_id) in enumerate(valid_items, 1):
|
||||||
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
||||||
existing_event = existing_events_dict.get(external_id)
|
existing_event = existing_events_dict.get(external_id)
|
||||||
@@ -269,14 +254,12 @@ async def fetch_and_save_trending_data():
|
|||||||
"""
|
"""
|
||||||
print(f"[{utcnow()}] 开始执行定时抓取任务...")
|
print(f"[{utcnow()}] 开始执行定时抓取任务...")
|
||||||
|
|
||||||
# 获取启用的信息源 - 这个只读操作用一个短连接
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
sources = db.query(InfoSource).filter(InfoSource.is_enabled == True).all()
|
sources = db.query(InfoSource).filter(InfoSource.is_enabled == True).all()
|
||||||
if not sources:
|
if not sources:
|
||||||
print("没有找到启用的信息源,任务结束。")
|
print("没有找到启用的信息源,任务结束。")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 我们把 source 的信息提前提取出来,避免在异步中长期持有 session
|
|
||||||
source_configs = [
|
source_configs = [
|
||||||
{
|
{
|
||||||
"id": s.id,
|
"id": s.id,
|
||||||
@@ -287,7 +270,6 @@ async def fetch_and_save_trending_data():
|
|||||||
for s in sources
|
for s in sources
|
||||||
]
|
]
|
||||||
|
|
||||||
# 伪装请求头,规避反爬
|
|
||||||
custom_headers = {
|
custom_headers = {
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
||||||
"Accept": "application/json, text/plain, */*",
|
"Accept": "application/json, text/plain, */*",
|
||||||
@@ -304,13 +286,11 @@ async def fetch_and_save_trending_data():
|
|||||||
url = f"{API_BASE_URL}?id={platform_id}&latest"
|
url = f"{API_BASE_URL}?id={platform_id}&latest"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. 网络请求(可能耗时较长,不要包在 db session 里)
|
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data_json = response.json()
|
data_json = response.json()
|
||||||
items = data_json.get("items", [])
|
items = data_json.get("items", [])
|
||||||
|
|
||||||
# 2. 数据库事务操作(尽量短,单独使用 session)
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
# 重新从短 session 中获取 source 实例,以免 detached
|
# 重新从短 session 中获取 source 实例,以免 detached
|
||||||
source = db.query(InfoSource).get(s_config["id"])
|
source = db.query(InfoSource).get(s_config["id"])
|
||||||
@@ -319,10 +299,8 @@ async def fetch_and_save_trending_data():
|
|||||||
|
|
||||||
task_log = DataSyncTask(source_id=source.id, items_fetched=0)
|
task_log = DataSyncTask(source_id=source.id, items_fetched=0)
|
||||||
try:
|
try:
|
||||||
# 调用数据处理层
|
|
||||||
saved_count = process_source_data(db, source, items)
|
saved_count = process_source_data(db, source, items)
|
||||||
|
|
||||||
# 业务事务成功提交
|
|
||||||
task_log.items_fetched = saved_count
|
task_log.items_fetched = saved_count
|
||||||
task_log.task_status = TaskStatus.SUCCESS
|
task_log.task_status = TaskStatus.SUCCESS
|
||||||
db.add(task_log)
|
db.add(task_log)
|
||||||
@@ -330,10 +308,9 @@ async def fetch_and_save_trending_data():
|
|||||||
print(f"[{source.source_name}] ({source.source_type}) 成功抓取并更新了 {saved_count} 条数据")
|
print(f"[{source.source_name}] ({source.source_type}) 成功抓取并更新了 {saved_count} 条数据")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise e # 抛出给外层捕获记录日志
|
raise e
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 异常拦截与错误隔离,另起一个超短事务记录日志
|
|
||||||
with SessionLocal() as log_db:
|
with SessionLocal() as log_db:
|
||||||
try:
|
try:
|
||||||
new_task_log = DataSyncTask(source_id=s_config["id"], items_fetched=0)
|
new_task_log = DataSyncTask(source_id=s_config["id"], items_fetched=0)
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
"""
|
|
||||||
匹配服务:根据用户兴趣关键词(精确 + 语义)推荐事件
|
|
||||||
打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度加成
|
|
||||||
"""
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -13,8 +9,9 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models.models import ExtractedTopic, TargetType, UnifiedEvent, UserTopicPreference, utcnow
|
from app.models.models import ExtractedTopic, TargetType, UnifiedEvent, UserTopicPreference, utcnow
|
||||||
from app.services.fetcher_service import embedder_model
|
from app.services.fetcher_service import embedder_model
|
||||||
|
|
||||||
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
|
|
||||||
# 语义匹配阈值:用户关键词和事件标签向量相似度达到该值才计入语义命中
|
# 语义匹配阈值:用户关键词和事件标签/标题向量相似度达到该值才计入语义命中
|
||||||
DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD = 0.78
|
DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD = 0.78
|
||||||
PREFERENCE_SEMANTIC_THRESHOLD = float(
|
PREFERENCE_SEMANTIC_THRESHOLD = float(
|
||||||
os.getenv("PREFERENCE_SEMANTIC_THRESHOLD", str(DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD))
|
os.getenv("PREFERENCE_SEMANTIC_THRESHOLD", str(DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD))
|
||||||
@@ -35,12 +32,38 @@ class MatchedEventResult:
|
|||||||
semantic_hits: list[dict[str, Any]]
|
semantic_hits: list[dict[str, Any]]
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
|
|
||||||
|
# AI生成结束
|
||||||
|
|
||||||
def _normalize_text(text: str) -> str:
|
def _normalize_text(text: str) -> str:
|
||||||
"""统一小写与首尾空白,便于做稳定匹配。"""
|
"""统一小写与首尾空白,便于做稳定匹配。"""
|
||||||
return text.strip().casefold()
|
return text.strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _find_exact_preference_match(
|
||||||
|
target_text: str,
|
||||||
|
normalized_preferences: list[tuple[str, str]],
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
判断目标文本是否与某个用户兴趣词形成“精确命中”。
|
||||||
|
命中条件:
|
||||||
|
1. 标准化后完全相等
|
||||||
|
2. 二者互为包含关系
|
||||||
|
返回命中的原始兴趣词,未命中则返回 None。
|
||||||
|
"""
|
||||||
|
normalized_target = _normalize_text(target_text)
|
||||||
|
if not normalized_target:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for raw_pref, normalized_pref in normalized_preferences:
|
||||||
|
if not normalized_pref:
|
||||||
|
continue
|
||||||
|
if normalized_target == normalized_pref:
|
||||||
|
return raw_pref
|
||||||
|
if normalized_pref in normalized_target or normalized_target in normalized_pref:
|
||||||
|
return raw_pref
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
_EMBEDDING_CACHE: dict[str, np.ndarray] = {}
|
_EMBEDDING_CACHE: dict[str, np.ndarray] = {}
|
||||||
MAX_CACHE_SIZE = 10000
|
MAX_CACHE_SIZE = 10000
|
||||||
|
|
||||||
@@ -55,7 +78,6 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
|
|||||||
|
|
||||||
uncached_keywords = []
|
uncached_keywords = []
|
||||||
|
|
||||||
# 1. 尝试从缓存获取
|
|
||||||
for keyword in keywords:
|
for keyword in keywords:
|
||||||
if not keyword:
|
if not keyword:
|
||||||
continue
|
continue
|
||||||
@@ -64,9 +86,7 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
|
|||||||
else:
|
else:
|
||||||
uncached_keywords.append(keyword)
|
uncached_keywords.append(keyword)
|
||||||
|
|
||||||
# 2. 对未命中的词进行统一的批量推理
|
|
||||||
if uncached_keywords:
|
if uncached_keywords:
|
||||||
# 去重,避免同一个未缓存的词被计算多次
|
|
||||||
unique_uncached = list(dict.fromkeys(uncached_keywords))
|
unique_uncached = list(dict.fromkeys(uncached_keywords))
|
||||||
|
|
||||||
vectors = embedder_model.encode(unique_uncached, normalize_embeddings=True, show_progress_bar=False)
|
vectors = embedder_model.encode(unique_uncached, normalize_embeddings=True, show_progress_bar=False)
|
||||||
@@ -77,7 +97,6 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
|
|||||||
for k in keys_to_delete:
|
for k in keys_to_delete:
|
||||||
del _EMBEDDING_CACHE[k]
|
del _EMBEDDING_CACHE[k]
|
||||||
|
|
||||||
# 3. 将新计算的向量存入缓存并回填结果
|
|
||||||
for keyword, vec in zip(unique_uncached, vectors):
|
for keyword, vec in zip(unique_uncached, vectors):
|
||||||
vec_array = np.asarray(vec, dtype=np.float32)
|
vec_array = np.asarray(vec, dtype=np.float32)
|
||||||
_EMBEDDING_CACHE[keyword] = vec_array
|
_EMBEDDING_CACHE[keyword] = vec_array
|
||||||
@@ -86,6 +105,26 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _find_best_semantic_match(
|
||||||
|
target_text: str,
|
||||||
|
target_vec_map: dict[str, np.ndarray],
|
||||||
|
pref_vec_map: dict[str, np.ndarray],
|
||||||
|
) -> tuple[str | None, float]:
|
||||||
|
"""返回与目标文本最接近的兴趣词及其余弦相似度。"""
|
||||||
|
target_vec = target_vec_map.get(target_text)
|
||||||
|
if target_vec is None:
|
||||||
|
return None, -1.0
|
||||||
|
|
||||||
|
best_pref = None
|
||||||
|
best_sim = -1.0
|
||||||
|
for pref_keyword, pref_vec in pref_vec_map.items():
|
||||||
|
sim = float(np.dot(target_vec, pref_vec))
|
||||||
|
if sim > best_sim:
|
||||||
|
best_sim = sim
|
||||||
|
best_pref = pref_keyword
|
||||||
|
return best_pref, best_sim
|
||||||
|
|
||||||
|
|
||||||
def _ensure_aware(dt: datetime) -> datetime:
|
def _ensure_aware(dt: datetime) -> datetime:
|
||||||
"""SQLite 读出的 datetime 不带时区信息,统一补上 UTC 后才能和 utcnow() 做减法。"""
|
"""SQLite 读出的 datetime 不带时区信息,统一补上 UTC 后才能和 utcnow() 做减法。"""
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
@@ -116,8 +155,8 @@ def recommend_events_for_user(
|
|||||||
) -> list[MatchedEventResult]:
|
) -> list[MatchedEventResult]:
|
||||||
"""
|
"""
|
||||||
用户兴趣推荐主流程:
|
用户兴趣推荐主流程:
|
||||||
1) 精确匹配:用户词 == EVENT 标签
|
1) 精确匹配:用户词 vs EVENT 标签/标题
|
||||||
2) 语义匹配:用户词向量 vs EVENT 标签向量(超过阈值)
|
2) 语义匹配:用户词向量 vs EVENT 标签/标题向量(超过阈值)
|
||||||
3) 打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度
|
3) 打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度
|
||||||
"""
|
"""
|
||||||
final_limit = max(1, min(limit, PREFERENCE_RECOMMEND_MAX_LIMIT))
|
final_limit = max(1, min(limit, PREFERENCE_RECOMMEND_MAX_LIMIT))
|
||||||
@@ -127,7 +166,6 @@ def recommend_events_for_user(
|
|||||||
else PREFERENCE_SEMANTIC_THRESHOLD
|
else PREFERENCE_SEMANTIC_THRESHOLD
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1. 读取用户兴趣词
|
|
||||||
preferences = (
|
preferences = (
|
||||||
db.query(UserTopicPreference)
|
db.query(UserTopicPreference)
|
||||||
.filter(UserTopicPreference.user_id == user_id)
|
.filter(UserTopicPreference.user_id == user_id)
|
||||||
@@ -140,7 +178,6 @@ def recommend_events_for_user(
|
|||||||
if not preference_keywords:
|
if not preference_keywords:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 2. 读取候选事件(时间 + 热度过滤,避免全表扫描)
|
|
||||||
time_limit = utcnow() - timedelta(hours=hours)
|
time_limit = utcnow() - timedelta(hours=hours)
|
||||||
events = (
|
events = (
|
||||||
db.query(UnifiedEvent)
|
db.query(UnifiedEvent)
|
||||||
@@ -167,72 +204,47 @@ def recommend_events_for_user(
|
|||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
if not topic_rows:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 组织事件标签映射:event_id -> [(tag, relevance_score), ...]
|
|
||||||
event_topics: dict[int, list[tuple[str, float | None]]] = {}
|
event_topics: dict[int, list[tuple[str, float | None]]] = {}
|
||||||
for event_id, topic_keyword, relevance_score in topic_rows:
|
for event_id, topic_keyword, relevance_score in topic_rows:
|
||||||
if not topic_keyword:
|
if not topic_keyword:
|
||||||
continue
|
continue
|
||||||
event_topics.setdefault(event_id, []).append((topic_keyword, relevance_score))
|
event_topics.setdefault(event_id, []).append((topic_keyword, relevance_score))
|
||||||
|
|
||||||
# 如果某事件没有标签,就不参与推荐
|
|
||||||
if not event_topics:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 3. 批量编码用户词与标签词,减少模型调用次数
|
|
||||||
unique_preference_keywords = list(dict.fromkeys(preference_keywords))
|
unique_preference_keywords = list(dict.fromkeys(preference_keywords))
|
||||||
unique_topic_keywords = list(dict.fromkeys([row[1] for row in topic_rows if row[1]]))
|
unique_topic_keywords = list(dict.fromkeys([row[1] for row in topic_rows if row[1]]))
|
||||||
pref_vec_map = _build_keyword_embedding_map(unique_preference_keywords)
|
pref_vec_map = _build_keyword_embedding_map(unique_preference_keywords)
|
||||||
topic_vec_map = _build_keyword_embedding_map(unique_topic_keywords)
|
topic_vec_map = _build_keyword_embedding_map(unique_topic_keywords)
|
||||||
|
|
||||||
# 预先建立“标准化后用户词集合”,用于精确匹配
|
normalized_preference_pairs = [
|
||||||
normalized_pref_set = {_normalize_text(word) for word in unique_preference_keywords}
|
(word, _normalize_text(word))
|
||||||
|
for word in unique_preference_keywords
|
||||||
|
if _normalize_text(word)
|
||||||
|
]
|
||||||
|
unique_event_titles = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
[event.unified_title.strip() for event in events if event.unified_title and event.unified_title.strip()]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
title_vec_map = _build_keyword_embedding_map(unique_event_titles)
|
||||||
|
|
||||||
scored_results: list[MatchedEventResult] = []
|
scored_results: list[MatchedEventResult] = []
|
||||||
for event in events:
|
for event in events:
|
||||||
topic_list = event_topics.get(event.id, [])
|
topic_list = event_topics.get(event.id, [])
|
||||||
if not topic_list:
|
|
||||||
continue
|
|
||||||
|
|
||||||
exact_hits: list[str] = []
|
exact_hits: list[str] = []
|
||||||
semantic_hits: list[dict[str, Any]] = []
|
semantic_hits: list[dict[str, Any]] = []
|
||||||
score = 0.0
|
score = 0.0
|
||||||
|
|
||||||
# 对每个事件标签做精确匹配或语义匹配
|
|
||||||
for topic_keyword, topic_relevance in topic_list:
|
for topic_keyword, topic_relevance in topic_list:
|
||||||
normalized_topic = _normalize_text(topic_keyword)
|
|
||||||
topic_relevance_score = float(topic_relevance) if topic_relevance is not None else 50.0
|
topic_relevance_score = float(topic_relevance) if topic_relevance is not None else 50.0
|
||||||
|
|
||||||
# 1) 精确命中(包括完全相等与包含关系)
|
matched_pref = _find_exact_preference_match(topic_keyword, normalized_preference_pairs)
|
||||||
matched_exact = False
|
if matched_pref is not None:
|
||||||
if normalized_topic in normalized_pref_set:
|
|
||||||
matched_exact = True
|
|
||||||
else:
|
|
||||||
for pref_word in normalized_pref_set:
|
|
||||||
if pref_word and (pref_word in normalized_topic or normalized_topic in pref_word):
|
|
||||||
matched_exact = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if matched_exact:
|
|
||||||
exact_hits.append(topic_keyword)
|
exact_hits.append(topic_keyword)
|
||||||
# 精确命中给较高基础分,标签自身相关度作为增益
|
|
||||||
score += 45.0 + topic_relevance_score * 0.2
|
score += 45.0 + topic_relevance_score * 0.2
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 2) 语义命中(未精确命中时再算)
|
best_pref, best_sim = _find_best_semantic_match(topic_keyword, topic_vec_map, pref_vec_map)
|
||||||
topic_vec = topic_vec_map.get(topic_keyword)
|
|
||||||
if topic_vec is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
best_pref = None
|
|
||||||
best_sim = -1.0
|
|
||||||
for pref_keyword, pref_vec in pref_vec_map.items():
|
|
||||||
sim = float(np.dot(topic_vec, pref_vec))
|
|
||||||
if sim > best_sim:
|
|
||||||
best_sim = sim
|
|
||||||
best_pref = pref_keyword
|
|
||||||
|
|
||||||
if best_pref is not None and best_sim >= similarity_threshold:
|
if best_pref is not None and best_sim >= similarity_threshold:
|
||||||
semantic_hits.append(
|
semantic_hits.append(
|
||||||
@@ -242,18 +254,32 @@ def recommend_events_for_user(
|
|||||||
"similarity": round(best_sim, 4),
|
"similarity": round(best_sim, 4),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# 语义命中分略低于精确命中,并由相似度放大
|
|
||||||
score += best_sim * 35.0 + topic_relevance_score * 0.12
|
score += best_sim * 35.0 + topic_relevance_score * 0.12
|
||||||
|
|
||||||
# 如果精确和语义都没命中,直接跳过
|
event_title = (event.unified_title or "").strip()
|
||||||
|
if event_title:
|
||||||
|
title_exact_pref = _find_exact_preference_match(event_title, normalized_preference_pairs)
|
||||||
|
if title_exact_pref is not None:
|
||||||
|
exact_hits.append(f"标题:{title_exact_pref}")
|
||||||
|
score += 30.0
|
||||||
|
else:
|
||||||
|
best_pref, best_sim = _find_best_semantic_match(event_title, title_vec_map, pref_vec_map)
|
||||||
|
if best_pref is not None and best_sim >= similarity_threshold:
|
||||||
|
semantic_hits.append(
|
||||||
|
{
|
||||||
|
"preference_keyword": best_pref,
|
||||||
|
"topic_keyword": f"标题:{best_pref}",
|
||||||
|
"similarity": round(best_sim, 4),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
score += best_sim * 24.0
|
||||||
|
|
||||||
if not exact_hits and not semantic_hits:
|
if not exact_hits and not semantic_hits:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 融合事件热度和新鲜度,避免只看语义分
|
|
||||||
score += min(event.hot_score, 100) * 0.3
|
score += min(event.hot_score, 100) * 0.3
|
||||||
score += _calc_freshness_bonus(event)
|
score += _calc_freshness_bonus(event)
|
||||||
|
|
||||||
# 返回标签时做去重,保证接口稳定
|
|
||||||
tags = list(dict.fromkeys([item[0] for item in topic_list]))
|
tags = list(dict.fromkeys([item[0] for item in topic_list]))
|
||||||
scored_results.append(
|
scored_results.append(
|
||||||
MatchedEventResult(
|
MatchedEventResult(
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
# app/services/summary_service.py
|
|
||||||
"""
|
|
||||||
摘要服务:调用 LLM 生成统一标题、综合摘要、话题标签
|
|
||||||
定时任务:对热度达标且未摘要的事件批量处理
|
|
||||||
"""
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -26,12 +21,16 @@ from app.prompts.summary_prompts import (
|
|||||||
)
|
)
|
||||||
from app.services.fetcher_service import embedder_model
|
from app.services.fetcher_service import embedder_model
|
||||||
|
|
||||||
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
|
|
||||||
HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3))
|
HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3))
|
||||||
TOPIC_TAG_MIN_HOT_SCORE = int(os.getenv("TOPIC_TAG_MIN_HOT_SCORE", HOT_SCORE_THRESHOLD))
|
TOPIC_TAG_MIN_HOT_SCORE = int(os.getenv("TOPIC_TAG_MIN_HOT_SCORE", HOT_SCORE_THRESHOLD))
|
||||||
TOPIC_SIMILARITY_THRESHOLD = float(os.getenv("TOPIC_SIMILARITY_THRESHOLD", 0.82))
|
TOPIC_SIMILARITY_THRESHOLD = float(os.getenv("TOPIC_SIMILARITY_THRESHOLD", 0.82))
|
||||||
TOPIC_TAG_MAX_COUNT = int(os.getenv("TOPIC_TAG_MAX_COUNT", 8))
|
TOPIC_TAG_MAX_COUNT = int(os.getenv("TOPIC_TAG_MAX_COUNT", 8))
|
||||||
AI_API_KEY = os.getenv("AI_API_KEY", "")
|
AI_API_KEY = os.getenv("AI_API_KEY", "")
|
||||||
|
|
||||||
|
# AI生成结束
|
||||||
|
|
||||||
|
|
||||||
deepseek_client = AsyncOpenAI(
|
deepseek_client = AsyncOpenAI(
|
||||||
api_key=AI_API_KEY,
|
api_key=AI_API_KEY,
|
||||||
@@ -184,7 +183,6 @@ async def generate_unified_summaries():
|
|||||||
"""定时任务:对热度达标且未摘要的事件刷新标题、摘要、标签"""
|
"""定时任务:对热度达标且未摘要的事件刷新标题、摘要、标签"""
|
||||||
print(f"[{utcnow()}] Start unified summary generation task...")
|
print(f"[{utcnow()}] Start unified summary generation task...")
|
||||||
|
|
||||||
# 先提取需要处理的事件 ID,尽早释放 session,不长期占用 db session
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
recent_threshold = utcnow() - timedelta(days=3)
|
recent_threshold = utcnow() - timedelta(days=3)
|
||||||
events = db.query(UnifiedEvent).filter(
|
events = db.query(UnifiedEvent).filter(
|
||||||
@@ -197,11 +195,9 @@ async def generate_unified_summaries():
|
|||||||
print("No events require summary update in this round.")
|
print("No events require summary update in this round.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 复制出需要的信息,脱离 session
|
|
||||||
event_ids = [e.id for e in events]
|
event_ids = [e.id for e in events]
|
||||||
event_hot_scores = {e.id: e.hot_score for e in events}
|
event_hot_scores = {e.id: e.hot_score for e in events}
|
||||||
|
|
||||||
# 外层循环:针对每个 event_id 开启一个极短生命周期的 session 获取依赖数据
|
|
||||||
for event_id in event_ids:
|
for event_id in event_ids:
|
||||||
platform_dict: dict[str, set[str]] = {}
|
platform_dict: dict[str, set[str]] = {}
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# app/utils/email_utils.py
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
import os
|
import os
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
import aiosmtplib
|
import aiosmtplib
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
|
from functools import lru_cache
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
import redis
|
||||||
if TYPE_CHECKING:
|
|
||||||
from redis import Redis
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
try:
|
|
||||||
import redis # type: ignore
|
|
||||||
except ImportError: # pragma: no cover
|
|
||||||
redis = None # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "").strip()
|
REDIS_URL = os.getenv("REDIS_URL", "").strip()
|
||||||
REDIS_CONNECT_TIMEOUT_SECONDS = float(os.getenv("REDIS_CONNECT_TIMEOUT_SECONDS", "2"))
|
REDIS_CONNECT_TIMEOUT_SECONDS = float(os.getenv("REDIS_CONNECT_TIMEOUT_SECONDS", "2"))
|
||||||
REDIS_SOCKET_TIMEOUT_SECONDS = float(os.getenv("REDIS_SOCKET_TIMEOUT_SECONDS", "2"))
|
REDIS_SOCKET_TIMEOUT_SECONDS = float(os.getenv("REDIS_SOCKET_TIMEOUT_SECONDS", "2"))
|
||||||
|
|
||||||
_redis_client: Optional["Redis"] = None
|
_redis_client: Optional["redis.Redis"] = None
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
def get_redis_client() -> Optional["Redis"]:
|
def get_redis_client() -> Optional["redis.Redis"]:
|
||||||
"""Return a singleton Redis client, or None when Redis is unavailable."""
|
"""Return a singleton Redis client, or None when Redis is unavailable."""
|
||||||
global _redis_client, _initialized
|
global _redis_client, _initialized
|
||||||
|
|
||||||
@@ -31,12 +25,7 @@ def get_redis_client() -> Optional["Redis"]:
|
|||||||
_initialized = True
|
_initialized = True
|
||||||
|
|
||||||
if not REDIS_URL:
|
if not REDIS_URL:
|
||||||
logger.info("REDIS_URL 未配置,验证码将回退到数据库存储")
|
logger.info("REDIS_URL 未配置,验证码将回退到内存存储")
|
||||||
_redis_client = None
|
|
||||||
return _redis_client
|
|
||||||
|
|
||||||
if redis is None:
|
|
||||||
logger.warning("未安装 redis 包,验证码将回退到数据库存储")
|
|
||||||
_redis_client = None
|
_redis_client = None
|
||||||
return _redis_client
|
return _redis_client
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
|
import uvicorn
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
PORT = int(os.getenv("PORT", 8000))
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
app="app.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=PORT,
|
||||||
|
workers=1
|
||||||
|
)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
[project]
|
||||||
|
name = "backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"aiosmtplib==5.1.0",
|
||||||
|
"annotated-doc==0.0.4",
|
||||||
|
"annotated-types==0.7.0",
|
||||||
|
"anyio==4.12.1",
|
||||||
|
"apscheduler==3.11.2",
|
||||||
|
"certifi==2026.2.25",
|
||||||
|
"charset-normalizer==3.4.5",
|
||||||
|
"click==8.3.1",
|
||||||
|
"colorama==0.4.6",
|
||||||
|
"distro==1.9.0",
|
||||||
|
"fastapi==0.135.1",
|
||||||
|
"filelock==3.25.0",
|
||||||
|
"fsspec==2026.2.0",
|
||||||
|
"greenlet==3.3.2",
|
||||||
|
"h11==0.16.0",
|
||||||
|
"hf-xet==1.3.2",
|
||||||
|
"httpcore==1.0.9",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"huggingface-hub==1.6.0",
|
||||||
|
"idna==3.11",
|
||||||
|
"jinja2==3.1.6",
|
||||||
|
"jiter==0.13.0",
|
||||||
|
"joblib==1.5.3",
|
||||||
|
"markdown-it-py==4.0.0",
|
||||||
|
"markupsafe==3.0.3",
|
||||||
|
"mdurl==0.1.2",
|
||||||
|
"modelscope>=1.35.0",
|
||||||
|
"mpmath==1.3.0",
|
||||||
|
"networkx==3.6.1",
|
||||||
|
"numpy==2.4.3",
|
||||||
|
"openai==2.26.0",
|
||||||
|
"pillow==12.0.0",
|
||||||
|
"pydantic==2.12.5",
|
||||||
|
"pydantic-core==2.41.5",
|
||||||
|
"pygments==2.19.2",
|
||||||
|
"python-dotenv==1.2.2",
|
||||||
|
"pyyaml==6.0.3",
|
||||||
|
"redis==7.3.0",
|
||||||
|
"regex==2026.2.28",
|
||||||
|
"requests==2.32.5",
|
||||||
|
"rich==14.3.3",
|
||||||
|
"safetensors==0.7.0",
|
||||||
|
"scikit-learn==1.8.0",
|
||||||
|
"scipy==1.17.1",
|
||||||
|
"shellingham==1.5.4",
|
||||||
|
"sniffio==1.3.1",
|
||||||
|
"sqlalchemy==2.0.48",
|
||||||
|
"starlette==0.52.1",
|
||||||
|
"sympy==1.14.0",
|
||||||
|
"threadpoolctl==3.6.0",
|
||||||
|
"tokenizers==0.22.2",
|
||||||
|
"tqdm==4.67.3",
|
||||||
|
"transformers==5.3.0",
|
||||||
|
"typer==0.24.1",
|
||||||
|
"typing-extensions==4.15.0",
|
||||||
|
"typing-inspection==0.4.2",
|
||||||
|
"tzdata==2025.3",
|
||||||
|
"tzlocal==5.3.1",
|
||||||
|
"urllib3==2.6.3",
|
||||||
|
"uvicorn==0.41.0",
|
||||||
|
"torch==2.11.0+cpu",
|
||||||
|
"torchvision==0.26.0+cpu",
|
||||||
|
"torchaudio==2.11.0+cpu",
|
||||||
|
"sentence-transformers>=5.3.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
name = "pytorch-cpu"
|
||||||
|
url = "https://download.pytorch.org/whl/cpu"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
index-strategy = "unsafe-best-match"
|
||||||
Binary file not shown.
@@ -1,12 +0,0 @@
|
|||||||
# run.py
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 启动服务
|
|
||||||
uvicorn.run(
|
|
||||||
app="app.main:app",
|
|
||||||
host="0.0.0.0",
|
|
||||||
port=8000,
|
|
||||||
# reload=True,
|
|
||||||
workers=1
|
|
||||||
)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def print_tree(root, prefix=""):
|
|
||||||
items = sorted(
|
|
||||||
name for name in os.listdir(root)
|
|
||||||
if name != "__pycache__"
|
|
||||||
)
|
|
||||||
total = len(items)
|
|
||||||
|
|
||||||
for i, name in enumerate(items):
|
|
||||||
path = os.path.join(root, name)
|
|
||||||
is_last = (i == total - 1)
|
|
||||||
|
|
||||||
connector = "└── " if is_last else "├── "
|
|
||||||
print(prefix + connector + name)
|
|
||||||
|
|
||||||
if os.path.isdir(path):
|
|
||||||
extension = " " if is_last else "│ "
|
|
||||||
print_tree(path, prefix + extension)
|
|
||||||
|
|
||||||
|
|
||||||
root_dir = r"E:\ScnuProject\InsightRadar\backend\app"
|
|
||||||
print(os.path.basename(root_dir) + "/")
|
|
||||||
print_tree(root_dir)
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# from dotenv import load_dotenv
|
|
||||||
# import os
|
|
||||||
# import time
|
|
||||||
#
|
|
||||||
# print("step 1: loading env")
|
|
||||||
# load_dotenv()
|
|
||||||
#
|
|
||||||
# hf_token = os.getenv("HF_TOKEN")
|
|
||||||
# print("step 2:", "HF_TOKEN loaded" if hf_token else "No token")
|
|
||||||
#
|
|
||||||
# print("step 3: importing sentence-transformers")
|
|
||||||
# from sentence_transformers import SentenceTransformer
|
|
||||||
#
|
|
||||||
# print("step 4: start loading model")
|
|
||||||
# t0 = time.time()
|
|
||||||
# model = SentenceTransformer(r"E:\Models\bge-m3", local_files_only=True, device="cuda")
|
|
||||||
# print(f"step 5: model loaded in {time.time() - t0:.2f}s")
|
|
||||||
#
|
|
||||||
# print("step 6: importing sklearn/numpy")
|
|
||||||
# from sklearn.metrics.pairwise import cosine_similarity
|
|
||||||
# import numpy as np
|
|
||||||
#
|
|
||||||
# titles = [
|
|
||||||
# # A组:同品牌同产品,但含义不同
|
|
||||||
# "苹果发布新款iPhone,影像系统再次升级",
|
|
||||||
# "苹果推出全新iPhone,摄像头性能进一步增强",
|
|
||||||
# "苹果回应新款iPhone发热问题:将通过系统更新修复",
|
|
||||||
# "苹果下调部分旧款iPhone售价,新机型并未参与促销",
|
|
||||||
#
|
|
||||||
# # B组:看起来都像“苹果新闻”,但主题已变
|
|
||||||
# "苹果公司股价上涨,市值再创新高",
|
|
||||||
# "苹果供应链承压,部分零部件厂商下调全年预期",
|
|
||||||
# "苹果被曝缩减Vision产品产量,市场需求不及预期",
|
|
||||||
# "苹果发布新款MacBook,并未更新iPhone产品线",
|
|
||||||
#
|
|
||||||
# # C组:同样是“发布/推出”,但主体不同
|
|
||||||
# "华为发布新款手机,影像能力进一步提升",
|
|
||||||
# "小米推出全新手机,影像系统迎来升级",
|
|
||||||
# "OPPO发布年度旗舰机型,主打夜景拍摄",
|
|
||||||
# ]
|
|
||||||
#
|
|
||||||
# print("step 7: start encoding")
|
|
||||||
# t1 = time.time()
|
|
||||||
# embeddings = model.encode(
|
|
||||||
# titles,
|
|
||||||
# normalize_embeddings=True,
|
|
||||||
# show_progress_bar=True,
|
|
||||||
# batch_size=16
|
|
||||||
# )
|
|
||||||
# print(f"step 8: encode done in {time.time() - t1:.2f}s")
|
|
||||||
#
|
|
||||||
# sim = cosine_similarity(embeddings)
|
|
||||||
# print(np.round(sim, 4))
|
|
||||||
#
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
print(secrets.token_urlsafe(64))
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Health
|
|
||||||
GET http://127.0.0.1:8000/
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# Send register verification code
|
|
||||||
POST http://127.0.0.1:8000/api/v1/auth/register/send-code
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"email": "demo@example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
###
|
|
||||||
# Register by verification code
|
|
||||||
POST http://127.0.0.1:8000/api/v1/auth/register
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"email": "demo@example.com",
|
|
||||||
"password": "DemoPass123",
|
|
||||||
"verification_code": "123456",
|
|
||||||
"nickname": "demo_user"
|
|
||||||
}
|
|
||||||
|
|
||||||
###
|
|
||||||
# Login
|
|
||||||
POST http://127.0.0.1:8000/api/v1/auth/login
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"email": "demo@example.com",
|
|
||||||
"password": "DemoPass123"
|
|
||||||
}
|
|
||||||
Generated
+1720
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
|||||||
|
# ---------- 阶段1:前端编译(Node打包静态产物) ----------
|
||||||
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
# 复制前端依赖,利用Docker缓存优化
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm install --registry=https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# 复制前端代码,编译出静态产物
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---------- 阶段2:后端依赖构建(uv构建虚拟环境) ----------
|
||||||
|
FROM python:3.11-slim AS backend-builder
|
||||||
|
|
||||||
|
WORKDIR /backend
|
||||||
|
|
||||||
|
# 安装uv,同步Python依赖
|
||||||
|
COPY backend/pyproject.toml backend/uv.lock ./
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
pip install --no-cache-dir uv && \
|
||||||
|
uv sync --frozen --no-dev --index https://pypi.tuna.tsinghua.edu.cn/simple/
|
||||||
|
|
||||||
|
# 复制后端代码
|
||||||
|
COPY backend/app ./app
|
||||||
|
COPY backend/main.py ./
|
||||||
|
|
||||||
|
# ---------- 阶段3:最终运行镜像(仅Python+Uvicorn,托管前端静态) ----------
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制构建好的后端虚拟环境
|
||||||
|
COPY --from=backend-builder /backend/.venv /app/.venv
|
||||||
|
COPY --from=backend-builder /backend/app /app/app
|
||||||
|
COPY --from=backend-builder /backend/main.py /app/main.py
|
||||||
|
|
||||||
|
# 复制前端编译好的静态产物,放到后端能访问的目录
|
||||||
|
# 这里我们把静态文件放到 /app/static 目录
|
||||||
|
COPY --from=frontend-builder /frontend/dist /app/app/static
|
||||||
|
|
||||||
|
# 把venv加入PATH
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
# 暴露Uvicorn端口
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 直接启动Uvicorn,由Uvicorn配合后端框架托管静态文件
|
||||||
|
CMD ["python3", "main.py"]
|
||||||
Vendored
+9
@@ -1 +1,10 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BACKEND_ORIGIN: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|||||||
+1
-2
@@ -4,8 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.svg">
|
<link rel="icon" href="/favicon.svg">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>InsightRadar - 全网热点监控中枢</title>
|
<title>聚势智见 - 基于语义聚类与大模型的热点资讯聚合平台</title>
|
||||||
<!-- Font Awesome 图标库 -->
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
-15
@@ -69,7 +69,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1915,7 +1914,6 @@
|
|||||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1965,7 +1963,6 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -2549,7 +2546,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2619,7 +2615,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz",
|
||||||
"integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==",
|
"integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yr/monotone-cubic-spline": "^1.0.3"
|
"@yr/monotone-cubic-spline": "^1.0.3"
|
||||||
}
|
}
|
||||||
@@ -2741,7 +2736,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -3048,7 +3042,6 @@
|
|||||||
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
@@ -3131,7 +3124,6 @@
|
|||||||
"integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==",
|
"integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.4.0",
|
"@eslint-community/eslint-utils": "^4.4.0",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
@@ -3590,7 +3582,6 @@
|
|||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
@@ -4187,7 +4178,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^7.7.7"
|
"@vue/devtools-api": "^7.7.7"
|
||||||
},
|
},
|
||||||
@@ -4594,7 +4584,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4657,7 +4646,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4811,7 +4799,6 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -5043,7 +5030,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5063,7 +5049,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
||||||
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.29",
|
"@vue/compiler-dom": "3.5.29",
|
||||||
"@vue/compiler-sfc": "3.5.29",
|
"@vue/compiler-sfc": "3.5.29",
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ export function fetchDeliveryConfig(userId: number): Promise<DeliveryConfig> {
|
|||||||
return apiGet<DeliveryConfig>(`/users/${userId}/delivery-config`)
|
return apiGet<DeliveryConfig>(`/users/${userId}/delivery-config`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 推送时间表
|
|
||||||
// ==========================================
|
|
||||||
export function createDeliverySchedule(
|
export function createDeliverySchedule(
|
||||||
userId: number,
|
userId: number,
|
||||||
payload: { delivery_time: string; is_active?: boolean },
|
payload: { delivery_time: string; is_active?: boolean },
|
||||||
@@ -34,9 +31,6 @@ export function deleteDeliverySchedule(
|
|||||||
return apiDelete(`/users/${userId}/delivery-schedules/${scheduleId}`)
|
return apiDelete(`/users/${userId}/delivery-schedules/${scheduleId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 推送渠道
|
|
||||||
// ==========================================
|
|
||||||
export function createPushEndpoint(
|
export function createPushEndpoint(
|
||||||
userId: number,
|
userId: number,
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
|
@import url(./font.css);
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
1. 现代 SaaS 风格高级主题变量
|
1. 现代 SaaS 风格高级主题变量
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
/**
|
|
||||||
* 认证 API:登录、注册、发送验证码(不走通用 client,无 Bearer)
|
|
||||||
*/
|
|
||||||
import type {
|
import type {
|
||||||
AuthTokenResponse,
|
AuthTokenResponse,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id: number
|
id: number
|
||||||
email: string
|
email: string
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ function handleToggle(event: MouseEvent) {
|
|||||||
Math.max(y, innerHeight - y)
|
Math.max(y, innerHeight - y)
|
||||||
)
|
)
|
||||||
|
|
||||||
// @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错
|
|
||||||
const transition = document.startViewTransition(() => {
|
const transition = document.startViewTransition(() => {
|
||||||
themeStore.toggleTheme()
|
themeStore.toggleTheme()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ function getRankingChartOptions(history: number[], platformColor: string) {
|
|||||||
height: 56,
|
height: 56,
|
||||||
sparkline: { enabled: true },
|
sparkline: { enabled: true },
|
||||||
animations: { enabled: true, easing: 'easeinout' as const, speed: 400 },
|
animations: { enabled: true, easing: 'easeinout' as const, speed: 400 },
|
||||||
|
events: {
|
||||||
|
mounted: (chartContext: any) => {
|
||||||
|
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
|
||||||
|
},
|
||||||
|
updated: (chartContext: any) => {
|
||||||
|
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
stroke: { curve: 'smooth' as const, width: 2 },
|
stroke: { curve: 'smooth' as const, width: 2 },
|
||||||
fill: {
|
fill: {
|
||||||
|
|||||||
@@ -1,121 +1,12 @@
|
|||||||
/**
|
|
||||||
* API 基础配置:自动探测内网/公网后端,失败时回退公网
|
|
||||||
*/
|
|
||||||
const API_PREFIX = '/api/v1'
|
const API_PREFIX = '/api/v1'
|
||||||
const LAN_BACKEND_ORIGIN = 'http://10.252.130.135:8000'
|
|
||||||
const PUBLIC_BACKEND_ORIGIN = 'http://47.107.130.88:51290'
|
|
||||||
const PROBE_TIMEOUT_MS = 1200
|
|
||||||
|
|
||||||
const LAN_API_BASE_URL = `${LAN_BACKEND_ORIGIN}${API_PREFIX}`
|
export function buildUrl(path: string): string {
|
||||||
const PUBLIC_API_BASE_URL = `${PUBLIC_BACKEND_ORIGIN}${API_PREFIX}`
|
if (!path.startsWith('/')) {
|
||||||
const ENV_API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined
|
path = '/' + path
|
||||||
|
|
||||||
const EXPECTED_OPENAPI_PATHS = ['/api/v1/auth/login', '/api/v1/events/unified']
|
|
||||||
|
|
||||||
let detectedApiBaseUrl: string | null = ENV_API_BASE_URL ?? null
|
|
||||||
let detectPromise: Promise<string> | null = null
|
|
||||||
|
|
||||||
function normalizePath(path: string): string {
|
|
||||||
if (!path) return '/'
|
|
||||||
return path.startsWith('/') ? path : `/${path}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUrl(base: string, path: string): string {
|
|
||||||
return `${base}${normalizePath(path)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPrivateIpv4(hostname: string): boolean {
|
|
||||||
const parts = hostname.split('.').map((part) => Number.parseInt(part, 10))
|
|
||||||
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return `${API_PREFIX}${path}`
|
||||||
const a = parts[0] as number
|
|
||||||
const b = parts[1] as number
|
|
||||||
if (a === 10) return true
|
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true
|
|
||||||
if (a === 192 && b === 168) return true
|
|
||||||
if (a === 127) return true
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLanHostname(hostname: string): boolean {
|
export async function fetchApi(path: string, init?: RequestInit) {
|
||||||
const normalized = hostname.toLowerCase()
|
return fetch(buildUrl(path), init)
|
||||||
if (normalized === 'localhost' || normalized.endsWith('.local')) return true
|
|
||||||
return isPrivateIpv4(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 探测内网后端是否可用(请求 openapi.json)
|
|
||||||
async function probeLanBackend(): Promise<boolean> {
|
|
||||||
if (typeof window === 'undefined') return false
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeout = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${LAN_BACKEND_ORIGIN}/openapi.json`, {
|
|
||||||
method: 'GET',
|
|
||||||
cache: 'no-store',
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
if (!response.ok) return false
|
|
||||||
|
|
||||||
const data = (await response.json()) as { paths?: Record<string, unknown> }
|
|
||||||
const paths = data.paths
|
|
||||||
if (!paths || typeof paths !== 'object') return false
|
|
||||||
|
|
||||||
return EXPECTED_OPENAPI_PATHS.every((path) =>
|
|
||||||
Object.prototype.hasOwnProperty.call(paths, path),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
window.clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据当前 hostname 与探测结果选择内网或公网 API 地址
|
|
||||||
async function detectApiBaseUrl(): Promise<string> {
|
|
||||||
if (ENV_API_BASE_URL) return ENV_API_BASE_URL
|
|
||||||
if (typeof window === 'undefined') return PUBLIC_API_BASE_URL
|
|
||||||
|
|
||||||
if (!isLanHostname(window.location.hostname)) {
|
|
||||||
return PUBLIC_API_BASE_URL
|
|
||||||
}
|
|
||||||
|
|
||||||
const canUseLan = await probeLanBackend()
|
|
||||||
return canUseLan ? LAN_API_BASE_URL : PUBLIC_API_BASE_URL
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyNetworkError(error: unknown): boolean {
|
|
||||||
return error instanceof TypeError || (error instanceof DOMException && error.name === 'AbortError')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getApiBaseUrl(): Promise<string> {
|
|
||||||
if (detectedApiBaseUrl) return detectedApiBaseUrl
|
|
||||||
if (!detectPromise) {
|
|
||||||
detectPromise = detectApiBaseUrl()
|
|
||||||
.then((url) => {
|
|
||||||
detectedApiBaseUrl = url
|
|
||||||
return url
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
detectPromise = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return detectPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchApi(path: string, init?: RequestInit): Promise<Response> {
|
|
||||||
const apiBaseUrl = await getApiBaseUrl()
|
|
||||||
const requestUrl = buildUrl(apiBaseUrl, path)
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await fetch(requestUrl, init)
|
|
||||||
} catch (error) {
|
|
||||||
if (!ENV_API_BASE_URL && apiBaseUrl === LAN_API_BASE_URL && isLikelyNetworkError(error)) {
|
|
||||||
detectedApiBaseUrl = PUBLIC_API_BASE_URL
|
|
||||||
return fetch(buildUrl(PUBLIC_API_BASE_URL, path), init)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<!-- AI辅助生成:deepseek-v3-2,2026年3月20日 -->
|
||||||
<!-- 仪表盘布局:侧边栏导航、主内容区、移动端抽屉 -->
|
<!-- 仪表盘布局:侧边栏导航、主内容区、移动端抽屉 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
@@ -57,7 +58,7 @@ function toggleSidebar() {
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="sidebar-logo">
|
<div class="sidebar-logo">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
<span class="logo-text">InsightRadar<span class="logo-dot">.AI</span></span>
|
<span class="logo-text">聚势智见<span class="logo-dot">.AI</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 导航菜单 -->
|
<!-- 导航菜单 -->
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
/**
|
// AI辅助生成:deepseek-v3-2,2026年3月20日
|
||||||
* 应用入口:初始化 Vue、Pinia、路由、主题
|
|
||||||
*/
|
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
<!-- AI辅助生成:deepseek-v3-2,2026年3月20日 -->
|
||||||
|
|
||||||
<!-- 关于页(占位) -->
|
<!-- 关于页(占位) -->
|
||||||
<template>
|
<template>
|
||||||
<div class="about">
|
<div class="about">
|
||||||
<h1>关于 InsightRadar</h1>
|
<h1>关于 聚势智见</h1>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- 主仪表盘:事件流、为你推荐、公关修改追踪、系统状态 -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed, watch } from 'vue'
|
import { onMounted, ref, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -13,9 +12,6 @@ import type { MatchedEvent, UserTopicPreference } from '@/types/preference'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 聚光灯:从推荐页跳转过来时,按 ID 单独拉取目标事件
|
|
||||||
// ==========================================
|
|
||||||
const spotlightEvent = ref<UnifiedEvent | null>(null)
|
const spotlightEvent = ref<UnifiedEvent | null>(null)
|
||||||
const loadingSpotlight = ref(false)
|
const loadingSpotlight = ref(false)
|
||||||
|
|
||||||
@@ -41,9 +37,7 @@ function dismissSpotlight() {
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const userId = computed(() => authStore.user?.id ?? 0)
|
const userId = computed(() => authStore.user?.id ?? 0)
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 状态
|
|
||||||
// ==========================================
|
|
||||||
const events = ref<UnifiedEvent[]>([])
|
const events = ref<UnifiedEvent[]>([])
|
||||||
const revisions = ref<HeadlineRevision[]>([])
|
const revisions = ref<HeadlineRevision[]>([])
|
||||||
const stats = ref<SystemStats | null>(null)
|
const stats = ref<SystemStats | null>(null)
|
||||||
@@ -93,7 +87,7 @@ const hoursOptions = [
|
|||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ label: '时间排序', value: 'created_at' },
|
{ label: '时间排序', value: 'created_at' },
|
||||||
{ label: '热度排序', value: 'hot_score' },
|
{ label: '热度排序', value: 'hot_score' },
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const recSortOptions = [
|
const recSortOptions = [
|
||||||
@@ -101,9 +95,6 @@ const recSortOptions = [
|
|||||||
{ label: '最新', value: 'created_at' },
|
{ label: '最新', value: 'created_at' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 平台视觉映射
|
|
||||||
// ==========================================
|
|
||||||
const platformIconMap: Record<string, string> = {
|
const platformIconMap: Record<string, string> = {
|
||||||
微博热搜: 'fa-brands fa-weibo',
|
微博热搜: 'fa-brands fa-weibo',
|
||||||
微博: 'fa-brands fa-weibo',
|
微博: 'fa-brands fa-weibo',
|
||||||
@@ -171,9 +162,7 @@ function formatRelativeTime(dateStr: string): string {
|
|||||||
return `${days} 天前`
|
return `${days} 天前`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 排名图表配置
|
|
||||||
// ==========================================
|
|
||||||
function getRankingChartOptions(history: number[], platformColor: string) {
|
function getRankingChartOptions(history: number[], platformColor: string) {
|
||||||
return {
|
return {
|
||||||
series: [{ name: '排名', data: history }],
|
series: [{ name: '排名', data: history }],
|
||||||
@@ -182,6 +171,14 @@ function getRankingChartOptions(history: number[], platformColor: string) {
|
|||||||
height: 56,
|
height: 56,
|
||||||
sparkline: { enabled: true },
|
sparkline: { enabled: true },
|
||||||
animations: { enabled: true, easing: 'easeinout' as const, speed: 400 },
|
animations: { enabled: true, easing: 'easeinout' as const, speed: 400 },
|
||||||
|
events: {
|
||||||
|
mounted: (chartContext: any) => {
|
||||||
|
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
|
||||||
|
},
|
||||||
|
updated: (chartContext: any) => {
|
||||||
|
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
stroke: { curve: 'smooth' as const, width: 2 },
|
stroke: { curve: 'smooth' as const, width: 2 },
|
||||||
fill: {
|
fill: {
|
||||||
@@ -241,9 +238,6 @@ function platformKey(eventId: number, index: number, prefix: string = ''): strin
|
|||||||
return prefix ? `${prefix}-${eventId}-${index}` : `${eventId}-${index}`
|
return prefix ? `${prefix}-${eventId}-${index}` : `${eventId}-${index}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 数据加载
|
|
||||||
// ==========================================
|
|
||||||
async function loadEvents(append = false) {
|
async function loadEvents(append = false) {
|
||||||
if (!append) {
|
if (!append) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -673,9 +667,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ==========================================
|
|
||||||
右侧:小组件面板
|
|
||||||
========================================== -->
|
|
||||||
<div class="widgets-column">
|
<div class="widgets-column">
|
||||||
|
|
||||||
<!-- 为你推荐(基于用户关键词的匹配) -->
|
<!-- 为你推荐(基于用户关键词的匹配) -->
|
||||||
@@ -838,10 +829,10 @@ watch(() => route.query.event, (newId) => {
|
|||||||
<i class="fa-regular fa-clock"></i>
|
<i class="fa-regular fa-clock"></i>
|
||||||
最后同步: {{ lastSyncText }}
|
最后同步: {{ lastSyncText }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="stats.error_tasks_today > 0" class="error-count">
|
<!-- <span v-if="stats.error_tasks_today > 0" class="error-count">
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
{{ stats.error_tasks_today }} 个异常
|
{{ stats.error_tasks_today }} 个异常
|
||||||
</span>
|
</span> -->
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -889,9 +880,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
网格布局
|
|
||||||
========================================== */
|
|
||||||
.content-grid {
|
.content-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -923,9 +911,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
区域标题 + 热度阈值 (高级磨砂透明风)
|
|
||||||
========================================== */
|
|
||||||
.section-header {
|
.section-header {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
@@ -1015,9 +1000,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
事件卡片
|
|
||||||
========================================== */
|
|
||||||
/* 事件卡片,加入毛玻璃与高级阴影 */
|
/* 事件卡片,加入毛玻璃与高级阴影 */
|
||||||
.event-card {
|
.event-card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -1134,9 +1116,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
平台列表 + 悬停排名图
|
|
||||||
========================================== */
|
|
||||||
.platforms-list {
|
.platforms-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1254,9 +1233,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
加载更多
|
|
||||||
========================================== */
|
|
||||||
.load-more-wrapper {
|
.load-more-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1302,9 +1278,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
color: var(--text-placeholder);
|
color: var(--text-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
小组件面板(通用)- 玻璃拟态高级质感
|
|
||||||
========================================== */
|
|
||||||
.widget-panel {
|
.widget-panel {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
backdrop-filter: var(--backdrop-blur);
|
backdrop-filter: var(--backdrop-blur);
|
||||||
@@ -1398,9 +1371,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
为你推荐面板
|
|
||||||
========================================== */
|
|
||||||
.recommend-header {
|
.recommend-header {
|
||||||
background: rgba(139, 92, 246, 0.06);
|
background: rgba(139, 92, 246, 0.06);
|
||||||
border-bottom-color: rgba(139, 92, 246, 0.15);
|
border-bottom-color: rgba(139, 92, 246, 0.15);
|
||||||
@@ -1576,9 +1546,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
公关修改追踪
|
|
||||||
========================================== */
|
|
||||||
.revision-header {
|
.revision-header {
|
||||||
background: rgba(239, 68, 68, 0.06);
|
background: rgba(239, 68, 68, 0.06);
|
||||||
border-bottom-color: rgba(239, 68, 68, 0.15);
|
border-bottom-color: rgba(239, 68, 68, 0.15);
|
||||||
@@ -1679,9 +1646,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
系统状态
|
|
||||||
========================================== */
|
|
||||||
.stats-widget {
|
.stats-widget {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
@@ -1751,9 +1715,6 @@ watch(() => route.query.event, (newId) => {
|
|||||||
color: var(--status-error);
|
color: var(--status-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
聚光灯区块
|
|
||||||
========================================== */
|
|
||||||
.spotlight-wrap {
|
.spotlight-wrap {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- 推送设置页:管理推送时间表与推送渠道(邮箱等) -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
|
||||||
@@ -62,9 +61,6 @@ async function loadConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 推送时间表操作
|
|
||||||
// ==========================================
|
|
||||||
async function handleAddSchedule() {
|
async function handleAddSchedule() {
|
||||||
if (!userId.value || !newTime.value) return
|
if (!userId.value || !newTime.value) return
|
||||||
submittingSchedule.value = true
|
submittingSchedule.value = true
|
||||||
@@ -109,9 +105,6 @@ async function handleDeleteSchedule(schedule: DeliverySchedule) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 推送渠道操作
|
|
||||||
// ==========================================
|
|
||||||
async function handleAddEndpoint() {
|
async function handleAddEndpoint() {
|
||||||
if (!userId.value || !newChannelAccount.value.trim()) return
|
if (!userId.value || !newChannelAccount.value.trim()) return
|
||||||
submittingEndpoint.value = true
|
submittingEndpoint.value = true
|
||||||
@@ -186,9 +179,6 @@ onMounted(loadConfig)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="config-sections">
|
<div v-else class="config-sections">
|
||||||
<!-- ==========================================
|
|
||||||
推送时间管理
|
|
||||||
========================================== -->
|
|
||||||
<section class="config-section">
|
<section class="config-section">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2><i class="fa-regular fa-clock"></i> 推送时间</h2>
|
<h2><i class="fa-regular fa-clock"></i> 推送时间</h2>
|
||||||
@@ -229,9 +219,6 @@ onMounted(loadConfig)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ==========================================
|
|
||||||
推送渠道管理
|
|
||||||
========================================== -->
|
|
||||||
<section class="config-section">
|
<section class="config-section">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2><i class="fa-solid fa-envelope"></i> 接收邮箱</h2>
|
<h2><i class="fa-solid fa-envelope"></i> 接收邮箱</h2>
|
||||||
@@ -374,9 +361,6 @@ onMounted(loadConfig)
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
通用区块样式
|
|
||||||
========================================== */
|
|
||||||
.config-sections {
|
.config-sections {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -418,9 +402,6 @@ onMounted(loadConfig)
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
添加行
|
|
||||||
========================================== */
|
|
||||||
.add-row {
|
.add-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -497,9 +478,6 @@ onMounted(loadConfig)
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
时间表列表
|
|
||||||
========================================== */
|
|
||||||
.schedule-list {
|
.schedule-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -573,9 +551,6 @@ onMounted(loadConfig)
|
|||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
渠道列表
|
|
||||||
========================================== */
|
|
||||||
.endpoint-add {
|
.endpoint-add {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -661,9 +636,6 @@ onMounted(loadConfig)
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
|
||||||
工作原理说明
|
|
||||||
========================================== */
|
|
||||||
.info-section {
|
.info-section {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px dashed var(--border-subtle);
|
border: 1px dashed var(--border-subtle);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- 概览页:展示当前账户、会话状态、认证接入说明 -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -31,7 +30,7 @@ async function handleLogout() {
|
|||||||
<div class="nav-brand">
|
<div class="nav-brand">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
InsightRadar
|
聚势智见
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-actions">
|
<div class="nav-actions">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- 登录页:支持密码登录与邮箱验证码登录 -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
|
import { computed, onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -150,13 +149,13 @@ onUnmounted(() => {
|
|||||||
<div class="brand-content">
|
<div class="brand-content">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
InsightRadar
|
聚势智见
|
||||||
</div>
|
</div>
|
||||||
<h1 class="brand-title">洞察全网热点<br />让信息更聚焦</h1>
|
<h1 class="brand-title">洞察全网热点<br />让信息更聚焦</h1>
|
||||||
<p class="brand-desc">
|
<p class="brand-desc">
|
||||||
聚合多平台趋势,自动完成热点归并与摘要。你可以用密码登录,也可以直接使用邮箱验证码快速登录。
|
聚合多平台趋势,自动完成热点归并与摘要。你可以用密码登录,也可以直接使用邮箱验证码快速登录。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="feature-list">
|
<div class="feature-list">
|
||||||
<div class="feature-item">
|
<div class="feature-item">
|
||||||
<div class="feature-icon">🚀</div>
|
<div class="feature-icon">🚀</div>
|
||||||
@@ -192,7 +191,7 @@ onUnmounted(() => {
|
|||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
<h2>欢迎回来</h2>
|
<h2>欢迎回来</h2>
|
||||||
<p>登录后继续查看 InsightRadar 实时动态</p>
|
<p>登录后继续查看 聚势智见 实时动态</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="login-mode-tabs">
|
<div class="login-mode-tabs">
|
||||||
@@ -287,7 +286,7 @@ onUnmounted(() => {
|
|||||||
<button class="btn-primary" :disabled="authStore.loading" type="submit">
|
<button class="btn-primary" :disabled="authStore.loading" type="submit">
|
||||||
{{ isSubmitting ? '登录中...' : (loginMode === 'password' ? '密码登录' : '邮箱验证码登录') }}
|
{{ isSubmitting ? '登录中...' : (loginMode === 'password' ? '密码登录' : '邮箱验证码登录') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" class="btn-primary guest-btn" @click="router.push('/')" style="margin-top: 12px; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border-subtle);">
|
<button type="button" class="btn-primary guest-btn" @click="router.push('/')" style="margin-top: 12px; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border-subtle);">
|
||||||
以游客身份体验
|
以游客身份体验
|
||||||
</button>
|
</button>
|
||||||
@@ -303,9 +302,6 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ==========================================
|
|
||||||
全新高级分屏布局与背景
|
|
||||||
========================================== */
|
|
||||||
.split-layout {
|
.split-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -330,7 +326,7 @@ onUnmounted(() => {
|
|||||||
left: -50%;
|
left: -50%;
|
||||||
right: -50%;
|
right: -50%;
|
||||||
bottom: -50%;
|
bottom: -50%;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px),
|
linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px);
|
linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px);
|
||||||
background-size: 32px 32px;
|
background-size: 32px 32px;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- 注册页:邮箱验证码 + 密码,带密码强度提示 -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, reactive, ref } from 'vue'
|
import { computed, onUnmounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@@ -131,7 +130,7 @@ onUnmounted(() => {
|
|||||||
<div class="brand-content">
|
<div class="brand-content">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
InsightRadar
|
聚势智见
|
||||||
</div>
|
</div>
|
||||||
<h1 class="brand-title">开启智能<br />分析之旅。</h1>
|
<h1 class="brand-title">开启智能<br />分析之旅。</h1>
|
||||||
<p class="brand-desc">
|
<p class="brand-desc">
|
||||||
@@ -280,9 +279,6 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ==========================================
|
|
||||||
全新高级分屏布局与背景
|
|
||||||
========================================== */
|
|
||||||
.split-layout {
|
.split-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -306,7 +302,7 @@ onUnmounted(() => {
|
|||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -50%; left: -50%; right: -50%; bottom: -50%;
|
top: -50%; left: -50%; right: -50%; bottom: -50%;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px),
|
linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px);
|
linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px);
|
||||||
background-size: 32px 32px;
|
background-size: 32px 32px;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- 公关修改追踪页:展示热搜标题被偷偷修改的历史记录 -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, reactive } from 'vue'
|
import { computed, onMounted, ref, reactive } from 'vue'
|
||||||
|
|
||||||
@@ -88,8 +87,8 @@ const revisionChains = computed<RevisionChain[]>(() => {
|
|||||||
// 组内按时间升序
|
// 组内按时间升序
|
||||||
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
|
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
|
||||||
|
|
||||||
const first = items[0]
|
const first = items[0]!
|
||||||
const last = items[items.length - 1]
|
const last = items[items.length - 1]!
|
||||||
|
|
||||||
// 拼接标题链,避免相邻记录重复
|
// 拼接标题链,避免相邻记录重复
|
||||||
const titles: string[] = [first.previous_headline]
|
const titles: string[] = [first.previous_headline]
|
||||||
@@ -107,13 +106,13 @@ const revisionChains = computed<RevisionChain[]>(() => {
|
|||||||
|
|
||||||
chains.push({
|
chains.push({
|
||||||
event_id,
|
event_id,
|
||||||
source_name: first.source_name,
|
source_name: first.source_name!,
|
||||||
titles,
|
titles,
|
||||||
change_times,
|
change_times,
|
||||||
first_at: first.created_at,
|
first_at: first.created_at!,
|
||||||
last_at: last.created_at,
|
last_at: last.created_at!,
|
||||||
change_count: items.length,
|
change_count: items.length,
|
||||||
url: first.url,
|
url: first.url!,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +241,7 @@ onMounted(loadRevisions)
|
|||||||
</span>
|
</span>
|
||||||
<p class="chain-title-text">{{ title }}</p>
|
<p class="chain-title-text">{{ title }}</p>
|
||||||
<span v-if="idx < chain.change_times.length" class="chain-step-time">
|
<span v-if="idx < chain.change_times.length" class="chain-step-time">
|
||||||
{{ formatTime(chain.change_times[idx]) }}
|
{{ formatTime(chain.change_times[idx] ?? '') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="idx < chain.titles.length - 1" class="chain-arrow">
|
<div v-if="idx < chain.titles.length - 1" class="chain-arrow">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- 事件追踪分析页:关键词搜索、时间热度图表、关联事件列表 -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import VueApexCharts from 'vue3-apexcharts'
|
import VueApexCharts from 'vue3-apexcharts'
|
||||||
@@ -6,6 +5,7 @@ import { searchEventsTimeline } from '@/api/events'
|
|||||||
import type { SearchTimelineResponse } from '@/types/event'
|
import type { SearchTimelineResponse } from '@/types/event'
|
||||||
import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
|
import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
|
||||||
import CustomSelect from '@/components/CustomSelect.vue'
|
import CustomSelect from '@/components/CustomSelect.vue'
|
||||||
|
import type { ApexOptions } from 'apexcharts'
|
||||||
|
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const searchResult = ref<SearchTimelineResponse | null>(null)
|
const searchResult = ref<SearchTimelineResponse | null>(null)
|
||||||
@@ -56,7 +56,7 @@ const filteredEvents = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 热度时间线图表配置。
|
// 热度时间线图表配置。
|
||||||
const chartOptions = ref({
|
const chartOptions = ref<ApexOptions>({
|
||||||
chart: {
|
chart: {
|
||||||
type: 'area',
|
type: 'area',
|
||||||
height: 350,
|
height: 350,
|
||||||
@@ -66,12 +66,18 @@ const chartOptions = ref({
|
|||||||
},
|
},
|
||||||
animations: {
|
animations: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
easing: 'easeinout',
|
// easing: 'easeinout',
|
||||||
speed: 800,
|
speed: 800,
|
||||||
},
|
},
|
||||||
// 点击图表数据点:切换选中时间,再次点击则取消筛选
|
// 点击图表数据点:切换选中时间,再次点击则取消筛选
|
||||||
events: {
|
events: {
|
||||||
markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) {
|
mounted: (chartContext: any) => {
|
||||||
|
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
|
||||||
|
},
|
||||||
|
updated: (chartContext: any) => {
|
||||||
|
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
|
||||||
|
},
|
||||||
|
markerClick: function(event: unknown, chartContext: unknown, { dataPointIndex }: never) {
|
||||||
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
|
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
|
||||||
const clickedTime = searchResult.value.timeline[dataPointIndex].time_label
|
const clickedTime = searchResult.value.timeline[dataPointIndex].time_label
|
||||||
if (selectedTimeLabel.value === clickedTime) {
|
if (selectedTimeLabel.value === clickedTime) {
|
||||||
@@ -228,17 +234,17 @@ async function handleSearch() {
|
|||||||
<div class="tips-box glass-panel">
|
<div class="tips-box glass-panel">
|
||||||
<h2 class="panel-title"><i class="fa-regular fa-lightbulb"></i> 搜索建议</h2>
|
<h2 class="panel-title"><i class="fa-regular fa-lightbulb"></i> 搜索建议</h2>
|
||||||
<div class="tips-content">
|
<div class="tips-content">
|
||||||
<button class="tip-tag" @click="keyword='新能源汽车'; hours=168; handleSearch()">
|
<button class="tip-tag" @click="keyword='火箭发射'; hours=168; handleSearch()">
|
||||||
<i class="fa-solid fa-rocket"></i> 新能源汽车
|
<i class="fa-solid fa-rocket"></i> 火箭发射
|
||||||
</button>
|
</button>
|
||||||
<button class="tip-tag" @click="keyword='苹果公司'; hours=168; handleSearch()">
|
<button class="tip-tag" @click="keyword='苹果公司'; hours=168; handleSearch()">
|
||||||
<i class="fa-brands fa-apple"></i> 苹果产业链
|
<i class="fa-brands fa-apple"></i> 苹果公司
|
||||||
</button>
|
</button>
|
||||||
<button class="tip-tag regex-tag" @click="keyword='AI|LLM'; hours=168; handleSearch()">
|
<button class="tip-tag regex-tag" @click="keyword='AI|LLM'; hours=168; handleSearch()">
|
||||||
<i class="fa-solid fa-code-branch"></i> AI / 大模型
|
<i class="fa-solid fa-code-branch"></i> AI / 大模型
|
||||||
</button>
|
</button>
|
||||||
<button class="tip-tag regex-tag" @click="keyword='美国关税'; hours=168; handleSearch()">
|
<button class="tip-tag regex-tag" @click="keyword='美国'; hours=168; handleSearch()">
|
||||||
<i class="fa-solid fa-flag-usa"></i> 美国关税
|
<i class="fa-solid fa-flag-usa"></i> 美国
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,9 +260,15 @@ async function handleSearch() {
|
|||||||
<div v-else-if="searchResult" class="results-container">
|
<div v-else-if="searchResult" class="results-container">
|
||||||
<section class="chart-section glass-panel">
|
<section class="chart-section glass-panel">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2 class="section-title">
|
<div class="section-title-group">
|
||||||
<i class="fa-solid fa-wave-square"></i> 时间热度脉络
|
<h2 class="section-title">
|
||||||
</h2>
|
<i class="fa-solid fa-wave-square"></i> 时间热度脉络
|
||||||
|
</h2>
|
||||||
|
<span class="chart-tip">
|
||||||
|
<i class="fa-solid fa-hand-pointer"></i>
|
||||||
|
点击时间点查看具体事件列表
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span class="meta-info">共 {{ searchResult.timeline.length }} 个时间节点 · 覆盖 {{ searchResult.events.length }} 个聚合事件</span>
|
<span class="meta-info">共 {{ searchResult.timeline.length }} 个时间节点 · 覆盖 {{ searchResult.events.length }} 个聚合事件</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,7 +380,7 @@ async function handleSearch() {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box {
|
.search-box {
|
||||||
@@ -378,7 +390,7 @@ async function handleSearch() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tips-box {
|
.tips-box {
|
||||||
@@ -546,6 +558,30 @@ async function handleSearch() {
|
|||||||
color: var(--brand-primary);
|
color: var(--brand-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--brand-primary-alpha);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||||
|
color: var(--brand-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tip i {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.time-filter-badge {
|
.time-filter-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -584,7 +620,16 @@ async function handleSearch() {
|
|||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
margin-left: -10px; /* 视觉上抵消 apexcharts 的默认左侧留白。 */
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container :deep(svg),
|
||||||
|
.chart-container :deep(canvas) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container :deep(.apexcharts-marker) {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.events-section {
|
.events-section {
|
||||||
@@ -594,7 +639,6 @@ async function handleSearch() {
|
|||||||
.events-grid {
|
.events-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* 与 DashboardView 保持一致,列表按纵向堆叠展示。 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state {
|
.loading-state {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- 兴趣关键词页:添加/删除关键词,查看命中事件 -->
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
|
||||||
@@ -64,10 +63,10 @@ async function loadMatchedEvents() {
|
|||||||
loadingMatched.value = true
|
loadingMatched.value = true
|
||||||
matchedError.value = ''
|
matchedError.value = ''
|
||||||
try {
|
try {
|
||||||
const result = await fetchRecommendedEvents(userId.value, {
|
const result = await fetchRecommendedEvents(userId.value, {
|
||||||
limit: 30,
|
limit: 30,
|
||||||
hours: hoursRange.value,
|
hours: hoursRange.value,
|
||||||
sort_by: sortBy.value
|
sort_by: sortBy.value
|
||||||
})
|
})
|
||||||
matchedEvents.value = result.data
|
matchedEvents.value = result.data
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -156,7 +155,7 @@ onMounted(async () => {
|
|||||||
v-model="newKeyword"
|
v-model="newKeyword"
|
||||||
type="text"
|
type="text"
|
||||||
class="keyword-input"
|
class="keyword-input"
|
||||||
placeholder="输入关键词,如「直升机」「科比」「佐巴扬」..."
|
placeholder="输入关键词,如「篮球」「科比」「科技」..."
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
@keydown="onInputKeydown"
|
@keydown="onInputKeydown"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
|||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://10.252.130.135:8000',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user