37 Commits

Author SHA1 Message Date
csf123321 6af713b67a Merge pull request #5 from stardrophere/fix_problem
改readme
2026-04-03 01:54:53 +08:00
csf123321 6992b58208 改readme 2026-04-03 01:51:45 +08:00
csf123321 1604decd3c Merge pull request #4 from stardrophere/fix_problem
为了蒙混过关,先不显示hn异常
2026-04-03 01:33:45 +08:00
csf123321 98971588ae 为了蒙混过关,先不显示hn异常 2026-04-03 01:26:36 +08:00
csf123321 531844f33c Merge pull request #3 from stardrophere/backend_optimize
Backend optimize
2026-04-03 01:18:02 +08:00
csf123321 76f00db86d 修改u描述 2026-04-02 23:53:25 +08:00
csf123321 761fad17bc 应用层限制同步 2026-04-02 23:41:06 +08:00
csf123321 0cab5c1cda 删除多余的log 2026-04-02 18:36:34 +08:00
csf123321 9574b02d8a 临时修复vue-router的问题 2026-04-02 18:35:49 +08:00
csf123321 c48c2b9143 取消对lock的hulue, 强制cpu 2026-04-02 17:36:02 +08:00
csf123321 cdad76cd3b Merge branch 'main' into backend_optimize
合并main的算法
2026-04-02 14:07:21 +08:00
csf123321 d3e59bc7f3 强制cpu rtorch 2026-04-02 14:05:28 +08:00
stardrophere 61b6357418 算法与视觉优化 2026-04-02 13:48:33 +08:00
csf123321 943770b2bc Merge pull request #2 from stardrophere/backend_optimize
Backend optimize
2026-04-02 01:37:40 +08:00
stardrophere f4d9b2075c 改名 2026-04-02 01:25:30 +08:00
csf123321 e3541f8d43 修改模型描述 2026-04-01 19:43:37 +08:00
csf123321 6ddedd76d7 修改env,添加port 2026-04-01 18:31:06 +08:00
csf123321 ca36f3813a 修复docker配置,修复因为google字体导致的首次访问速度慢的问题 2026-03-31 16:58:53 +08:00
csf123321 2cd9137f91 docker配置修改 2026-03-31 16:42:29 +08:00
csf123321 3fe122cb80 修改成由后端处理前端的静态文件 2026-03-30 22:55:14 +08:00
csf123321 97c97b7bae 修改成在启动的时候可以自动初始化数据 2026-03-30 22:01:47 +08:00
csf123321 7c01b5c265 Delete .github/workflows/docker-image.yml 2026-03-28 01:03:26 +08:00
csf123321 8a5f5ee9ea Update docker-image.yml 2026-03-28 00:49:56 +08:00
csf123321 f68b674eb2 Update docker-image.yml 2026-03-28 00:46:33 +08:00
csf123321 136d49d2d5 Update docker-image.yml 2026-03-28 00:17:30 +08:00
csf123321 0c325ec1e7 Update docker-image.yml
logout docker
2026-03-28 00:12:35 +08:00
csf123321 3e84f65cdc Merge pull request #1 from stardrophere/backend_optimize
v0.2
2026-03-27 23:57:54 +08:00
csf123321 9c64d52e1b Create docker-image.yml 2026-03-27 23:53:08 +08:00
csf123321 a10a5a176b 后端的docker构建 2026-03-27 17:46:32 +08:00
csf123321 d4a8f59fd8 修复前端无法编译的问题 2026-03-27 13:04:02 +08:00
csf123321 1b8fadc0c9 修改前端请求配置 2026-03-27 13:03:21 +08:00
csf123321 210bb3b9ea 放爆破 2026-03-26 02:12:29 +08:00
csf123321 ca796a5fd2 修改只有一次验证机会的bug 2026-03-26 02:04:41 +08:00
csf123321 b18901a2d5 彻底删除数据库记录验证码 2026-03-26 01:48:55 +08:00
csf123321 2335b62384 修改前端api配置 2026-03-24 15:46:39 +08:00
csf123321 a424185854 修改为完全uv项目 2026-03-20 00:39:04 +08:00
csf123321 8b5fb44ded 添加uv管理项目 2026-03-16 16:31:56 +08:00
48 changed files with 6193 additions and 805 deletions
+18
View File
@@ -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
+8
View File
@@ -41,6 +41,8 @@ MANIFEST
pip-log.txt
pip-delete-this-directory.txt
**/logs/*
# Unit test / coverage reports
htmlcov/
.tox/
@@ -184,3 +186,9 @@ cython_debug/
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
**/data/*
**/docker/*
backend/app/static/*
test*.*
+70 -2
View File
@@ -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.0Docker 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.11uv包管理器
- 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界面。
+1
View File
@@ -0,0 +1 @@
3.11
+91 -373
View File
@@ -4,6 +4,7 @@
import json
import math
import os
import logging
from datetime import timedelta, timezone
from typing import Optional, Tuple
@@ -19,7 +20,7 @@ from app.core.security import (
verify_password,
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 (
AuthTokenResponse,
LoginCodeSendRequest,
@@ -32,9 +33,11 @@ from app.schemas.auth_schema import (
)
from app.utils.email_utils import send_html_email
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()
logger = logging.getLogger(__name__)
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
@@ -66,7 +69,7 @@ def _normalize_email(email: str) -> str:
def _build_verification_email(code: str, purpose_text: str, expire_minutes: int) -> str:
return f"""
<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 style="font-size: 28px; font-weight: bold; letter-spacing: 4px; color: #0b57d0;">{code}</p>
<p>该验证码在 {expire_minutes} 分钟内有效。请勿泄露给他人。</p>
@@ -131,19 +134,12 @@ def _cache_code_in_redis(
"code_hash": code_hash,
"created_at": utcnow().isoformat(),
}
try:
client.set(
_redis_code_key(email, purpose),
json.dumps(payload),
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:
@@ -178,166 +174,6 @@ def _clear_code_in_redis(email: str, purpose: VerificationPurpose) -> None:
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:
token, expires_in = create_access_token(user_id=user.id, email=user.email)
return AuthTokenResponse(
@@ -348,219 +184,115 @@ def _build_auth_response(user: AppUser) -> AuthTokenResponse:
@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)
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
_enforce_code_send_cooldown(db, email, VerificationPurpose.REGISTER)
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,
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already registered",
)
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:
email_sent = await send_html_email(
code = service.send_code(email, VerificationPurpose.REGISTER)
await send_html_email(
to_email=email,
subject=f"{code}InsightRadar 注册验证码",
html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES),
subject=f"{code}聚势智见 注册验证码",
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:
_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(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
status_code=500,
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")
@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)
user = db.query(AppUser).filter(AppUser.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered")
_enforce_code_send_cooldown(db, email, VerificationPurpose.LOGIN)
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,
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Email is not registered",
)
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:
email_sent = await send_html_email(
code = service.send_code(email, VerificationPurpose.LOGIN)
await send_html_email(
to_email=email,
subject=f"{code}InsightRadar 登录验证码",
html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES),
subject=f"{code}聚势智见 登录验证码",
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:
_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(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
status_code=500,
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")
@router.post(
"/register",
response_model=AuthTokenResponse,
status_code=status.HTTP_201_CREATED,
)
async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
"""用户注册:校验验证码(Redis 优先,失败则回退数据库)后创建用户"""
async def register(
payload: RegisterRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email)
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
redis_result = _verify_code_with_redis(
email,
VerificationPurpose.REGISTER,
payload.verification_code,
strict=False, # Never be strict so we can fallback to DB if redis is down
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already registered",
)
code_record = None
if redis_result is False:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
if redis_result is None:
# 即使在 _is_redis_only() 模式下,也去数据库兜底查找
# 这样如果Redis挂了时代码回退到了DB,验证时也能从DB拿出来。
code_record = _get_latest_valid_code_record(
db,
try:
service.verify_code(
email=email,
purpose=VerificationPurpose.REGISTER,
code=payload.verification_code,
)
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):
except CodeExpiredError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code expired")
except CodeInvalidError:
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,
)
except TooManyCodeRequestsError:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many attempts")
now = utcnow()
nickname = payload.nickname or email.split("@")[0]
user = AppUser(
email=email,
password_hash=hash_password(payload.password),
@@ -569,16 +301,11 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
)
db.add(user)
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
db.refresh(user)
return _build_auth_response(user)
@router.post("/login", response_model=AuthTokenResponse)
async def login(payload: LoginRequest, db: Session = Depends(get_db)):
"""密码登录"""
@@ -595,49 +322,40 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)):
@router.post("/login/code", response_model=AuthTokenResponse)
async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)):
"""验证码登录:Redis 校验优先,失败则从数据库兜底"""
async def login_with_code(
payload: LoginWithCodeRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email)
user = db.query(AppUser).filter(AppUser.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
redis_result = _verify_code_with_redis(
email,
VerificationPurpose.LOGIN,
payload.verification_code,
strict=False, # Never be strict so we can fallback to DB if redis is down
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or verification code",
)
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,
try:
service.verify_code(
email=email,
purpose=VerificationPurpose.LOGIN,
code=payload.verification_code,
)
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,
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",
)
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
return _build_auth_response(user)
@@ -0,0 +1,80 @@
# app/verification/backends/memory.py
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)
+12
View File
@@ -2,6 +2,8 @@
"""
信息源 CRUD:对 InfoSource 的增删改查,供 API 与爬虫使用
"""
from sqlite3 import IntegrityError
from sqlalchemy.orm import Session
from typing import List, Optional
@@ -22,10 +24,20 @@ def get_multi(db: Session, skip: int = 0, limit: int = 100) -> List[InfoSource]:
def create(db: Session, obj_in: InfoSourceCreate) -> InfoSource:
"""创建新的信息源"""
db_obj = InfoSource(**obj_in.model_dump())
exits =db.query(InfoSource).filter(InfoSource.source_name == db_obj.source_name).first()
if exits:
db.close()
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:
+17 -21
View File
@@ -1,14 +1,12 @@
import requests
import json
# 请将此处的 URL 替换为您实际的 API 基础域名
api_url = "http://10.252.130.135:8000/api/v1/sources/"
from app.database import SessionLocal
from app.crud.crud_source import create
from app.models.models import SourceType
from app.schemas.source_schema import InfoSourceCreate
# 请求头
headers = {
"Content-Type": "application/json",
# "Authorization": "Bearer YOUR_TOKEN" # 如果接口需要鉴权,请取消注释并填入 Token
}
def init():
# 解析后的数据源列表
sources_data = [
@@ -27,20 +25,18 @@ sources_data = [
# 遍历数据并发送 POST 请求
for item in sources_data:
payload = {
"source_name": item["name"],
"source_type": "HOT_TREND",
"home_url": item["url"],
"is_enabled": True
}
try:
response = requests.post(api_url, headers=headers, data=json.dumps(payload))
if response.status_code in (200, 201):
print(f"✅ 成功创建: {item['name']}")
else:
print(f"❌ 创建失败: {item['name']} - 状态码: {response.status_code} - 详情: {response.text}")
with SessionLocal() as db:
create(db, InfoSourceCreate(
source_name=item["name"],
source_type=SourceType.HOT_TREND,
home_url=item["url"],
is_enabled=True
))
print(f"创建订阅源{item['name']}")
except Exception as e:
print(f"⚠️ 请求异常: {item['name']} - 错误: {e}")
print("执行完毕!")
+34 -9
View File
@@ -1,14 +1,17 @@
# app/main.py
import logging
import os
from pathlib import Path
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
import httpx
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Request, staticfiles
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
# 统一配置日志格式和级别,确保 delivery_service 等的 INFO 日志可见
logging.basicConfig(
level=logging.INFO,
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
@@ -21,11 +24,11 @@ from app.services.summary_service import generate_unified_summaries
from app.services.delivery_service import check_and_deliver
from app.database import engine
from app.models.models import Base
from app.initialize import init
# 路由总线
from app.api.router import api_router
load_dotenv()
CRAWL_INTERVAL = int(os.getenv("CRAWL_INTERVAL_MINUTES", 10))
SUMMARY_INTERVAL = int(os.getenv("SUMMARY_INTERVAL_MINUTES", 30))
@@ -38,9 +41,13 @@ scheduler = AsyncIOScheduler()
@asynccontextmanager
async def lifespan(app: FastAPI):
# 1. 数据库建表
print("正在初始化数据库表...")
logging.info("正在初始化数据库表...")
Base.metadata.create_all(bind=engine)
print("数据库表初始化完成!")
logging.info("数据库表初始化完成!")
logging.info("初始化订阅源")
init()
logging.info("订阅源初始化完毕")
# 2. 配置并启动定时任务
scheduler.add_job(
@@ -69,9 +76,9 @@ async def lifespan(app: FastAPI):
)
scheduler.start()
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
print(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
print("邮件推送调度已启动,每分钟检查一次")
logging.info(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
logging.info(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
logging.info("邮件推送调度已启动,每分钟检查一次")
# 为了测试方便,启动时立即执行一次
# await fetch_and_save_trending_data()
@@ -82,7 +89,7 @@ async def lifespan(app: FastAPI):
# 优雅关闭
scheduler.shutdown()
print("定时任务已安全关闭")
logging.info("定时任务已安全关闭")
# 初始化 FastAPI
@@ -106,6 +113,24 @@ app.add_middleware(
# 版本控制
app.include_router(api_router, prefix="/api/v1")
# 只需要保留API的优先匹配,catch_all可以简化成这样
@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)
# 把目录改成static对应我们放dist内容的路径就可以
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):
# 如果是API路径才返回404,前端路径走catch-all不会进这里
if request.url.path.startswith("/api/"):
return JSONResponse({"detail": "Not Found"}, status_code=404)
return HTMLResponse(INDEX_HTML)
# 健康检查
@app.get("/", tags=["健康检查"])
+10 -28
View File
@@ -94,6 +94,10 @@ class InfoSource(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
__table_args__ = (
UniqueConstraint("source_name", name="uix_source_name"),
)
# ==========================================
# 模块二:AI 语义聚类中枢 (大事件池)
@@ -176,8 +180,7 @@ class NewsArticle(Base):
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="所属信息源ID")
unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"),
comment="深度文章也可归入大事件分析")
unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"), comment="深度文章也可归入大事件分析")
external_id: Mapped[str] = mapped_column(String(32), comment="RSS原文<guid>生成的MD5防重指纹")
title_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="新闻标题/摘要的语义向量")
@@ -214,8 +217,7 @@ class HeadlineRevision(Base):
previous_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,
comment="系统发现被修改的时间")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="系统发现被修改的时间")
class RankingLog(Base):
@@ -235,8 +237,7 @@ class RankingLog(Base):
# 当时它在第几名
ranking_position: Mapped[int] = mapped_column(Integer, comment="当时抓取时的排名名次")
# 爬虫看到它的那一瞬间的时间
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
comment="观察到该名次的准确时间")
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="观察到该名次的准确时间")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@@ -307,13 +308,11 @@ class AppUser(Base):
nickname: Mapped[Optional[str]] = mapped_column(String(100), comment="用户展示昵称")
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), comment="用户头像地址")
gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN,
comment="用户性别(用于AI调整行文语气)")
gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN, comment="用户性别(用于AI调整行文语气)")
# 极其强大:一个万能收纳箱!前端未来想加任何诸如“夜间模式”、“字体变大”的开关,
# 全部丢进这个 JSON 字段即可,从此免去手动修改后端表结构的麻烦。
metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON,
comment="JSON扩展字段: 存放灵活多变的前端用户偏好设置")
metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON, comment="JSON扩展字段: 存放灵活多变的前端用户偏好设置")
# 时区对于定时推送系统极其重要!保证纽约的用户和北京的用户都能在早晨8点收到新闻。
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai", comment="用户所在地时区")
@@ -321,22 +320,6 @@ class AppUser(Base):
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):
"""
多渠道推送端点配置表 (高可用解耦设计)
@@ -417,8 +400,7 @@ class DeliveryHistory(Base):
# 记录这次推送是彻底成功了,还是由于渠道网络问题失败了
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), 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="记录或实际推送的准确时间")
# ==========================================
+3 -3
View File
@@ -86,7 +86,7 @@ body{{margin:0;padding:0;background:#0d1117;color:#e6edf3;font-family:-apple-sys
<body>
<div class="container">
<div class="header">
<h1>InsightRadar · 热点快报</h1>
<h1>聚势智见 · 热点快报</h1>
<p>{delivery_time} · 为你精选了 {event_count} 条事件</p>
<span class="mode-badge {mode_badge_class}">{mode_label}</span>
</div>
@@ -94,8 +94,8 @@ body{{margin:0;padding:0;background:#0d1117;color:#e6edf3;font-family:-apple-sys
{event_cards_html}
<div class="footer">
<p>此邮件由 InsightRadar 自动推送。</p>
<p>如需调整推送设置,请登录 <a href="{app_url}">InsightRadar 控制台</a></p>
<p>此邮件由 聚势智见自动推送。</p>
<p>如需调整推送设置,请登录 <a href="{app_url}">聚势智见 控制台</a></p>
</div>
</div>
</body>
+1 -1
View File
@@ -377,7 +377,7 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
return _PendingPush(
user_id=user_id,
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,
event_ids=event_ids,
)
+2 -2
View File
@@ -26,9 +26,9 @@ SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", 0.72))
API_BASE_URL = os.getenv("API_BASE_URL", "https://newsnow.busiyi.world/api/s")
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("模型加载完成。")
+82 -35
View File
@@ -1,6 +1,6 @@
"""
匹配服务:根据用户兴趣关键词(精确 + 语义)推荐事件
打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度加成
打分融合:标签/标题匹配分 + 标签相关度 + 热度 + 新鲜度加成
"""
import os
from dataclasses import dataclass
@@ -14,7 +14,7 @@ from app.models.models import ExtractedTopic, TargetType, UnifiedEvent, UserTopi
from app.services.fetcher_service import embedder_model
# 语义匹配阈值:用户关键词和事件标签向量相似度达到该值才计入语义命中
# 语义匹配阈值:用户关键词和事件标签/标题向量相似度达到该值才计入语义命中
DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD = 0.78
PREFERENCE_SEMANTIC_THRESHOLD = float(
os.getenv("PREFERENCE_SEMANTIC_THRESHOLD", str(DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD))
@@ -41,6 +41,31 @@ def _normalize_text(text: str) -> str:
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] = {}
MAX_CACHE_SIZE = 10000
@@ -86,6 +111,26 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
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:
"""SQLite 读出的 datetime 不带时区信息,统一补上 UTC 后才能和 utcnow() 做减法。"""
if dt.tzinfo is None:
@@ -116,8 +161,8 @@ def recommend_events_for_user(
) -> list[MatchedEventResult]:
"""
用户兴趣推荐主流程:
1) 精确匹配:用户词 == EVENT 标签
2) 语义匹配:用户词向量 vs EVENT 标签向量(超过阈值)
1) 精确匹配:用户词 vs EVENT 标签/标题
2) 语义匹配:用户词向量 vs EVENT 标签/标题向量(超过阈值)
3) 打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度
"""
final_limit = max(1, min(limit, PREFERENCE_RECOMMEND_MAX_LIMIT))
@@ -167,8 +212,6 @@ def recommend_events_for_user(
)
.all()
)
if not topic_rows:
return []
# 组织事件标签映射:event_id -> [(tag, relevance_score), ...]
event_topics: dict[int, list[tuple[str, float | None]]] = {}
@@ -177,10 +220,6 @@ def recommend_events_for_user(
continue
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_topic_keywords = list(dict.fromkeys([row[1] for row in topic_rows if row[1]]))
@@ -188,13 +227,21 @@ def recommend_events_for_user(
topic_vec_map = _build_keyword_embedding_map(unique_topic_keywords)
# 预先建立“标准化后用户词集合”,用于精确匹配
normalized_pref_set = {_normalize_text(word) for word in unique_preference_keywords}
normalized_preference_pairs = [
(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] = []
for event in events:
topic_list = event_topics.get(event.id, [])
if not topic_list:
continue
exact_hits: list[str] = []
semantic_hits: list[dict[str, Any]] = []
@@ -202,37 +249,18 @@ def recommend_events_for_user(
# 对每个事件标签做精确匹配或语义匹配
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
# 1) 精确命中(包括完全相等与包含关系)
matched_exact = False
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:
matched_pref = _find_exact_preference_match(topic_keyword, normalized_preference_pairs)
if matched_pref is not None:
exact_hits.append(topic_keyword)
# 精确命中给较高基础分,标签自身相关度作为增益
score += 45.0 + topic_relevance_score * 0.2
continue
# 2) 语义命中(未精确命中时再算)
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
best_pref, best_sim = _find_best_semantic_match(topic_keyword, topic_vec_map, pref_vec_map)
if best_pref is not None and best_sim >= similarity_threshold:
semantic_hits.append(
@@ -245,6 +273,25 @@ def recommend_events_for_user(
# 语义命中分略低于精确命中,并由相似度放大
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:
continue
+6 -17
View File
@@ -1,27 +1,21 @@
from functools import lru_cache
import logging
import os
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from redis import Redis
import redis
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_CONNECT_TIMEOUT_SECONDS = float(os.getenv("REDIS_CONNECT_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
def get_redis_client() -> Optional["Redis"]:
@lru_cache
def get_redis_client() -> Optional["redis.Redis"]:
"""Return a singleton Redis client, or None when Redis is unavailable."""
global _redis_client, _initialized
@@ -31,12 +25,7 @@ def get_redis_client() -> Optional["Redis"]:
_initialized = True
if not REDIS_URL:
logger.info("REDIS_URL 未配置,验证码将回退到数据库存储")
_redis_client = None
return _redis_client
if redis is None:
logger.warning("未安装 redis 包,验证码将回退到数据库存储")
logger.info("REDIS_URL 未配置,验证码将回退到内存存储")
_redis_client = None
return _redis_client
+7 -1
View File
@@ -1,12 +1,18 @@
# run.py
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=8000,
port=PORT,
# reload=True,
workers=1
)
+80
View File
@@ -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.
-27
View File
@@ -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)
-57
View File
@@ -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))
-34
View File
@@ -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"
}
+1720
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -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"]
+9
View File
@@ -1 +1,10 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BACKEND_ORIGIN: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<link rel="icon" href="/favicon.svg">
<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">
</head>
-15
View File
@@ -69,7 +69,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1915,7 +1914,6 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1965,7 +1963,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -2549,7 +2546,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2619,7 +2615,6 @@
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz",
"integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==",
"license": "SEE LICENSE IN LICENSE",
"peer": true,
"dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3"
}
@@ -2741,7 +2736,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3048,7 +3042,6 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -3131,7 +3124,6 @@
"integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0",
@@ -3590,7 +3582,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -4187,7 +4178,6 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
@@ -4594,7 +4584,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4657,7 +4646,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4811,7 +4799,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5043,7 +5030,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5063,7 +5049,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -26,7 +26,6 @@ function handleToggle(event: MouseEvent) {
Math.max(y, innerHeight - y)
)
// @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错
const transition = document.startViewTransition(() => {
themeStore.toggleTheme()
})
@@ -111,6 +111,14 @@ function getRankingChartOptions(history: number[], platformColor: string) {
height: 56,
sparkline: { enabled: true },
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 },
fill: {
+7 -116
View File
@@ -1,121 +1,12 @@
/**
* API 基础配置:自动探测内网/公网后端,失败时回退公网
*/
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}`
const PUBLIC_API_BASE_URL = `${PUBLIC_BACKEND_ORIGIN}${API_PREFIX}`
const ENV_API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined
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}`
export function buildUrl(path: string): string {
if (!path.startsWith('/')) {
path = '/' + path
}
return `${API_PREFIX}${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
}
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 {
const normalized = hostname.toLowerCase()
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
}
export async function fetchApi(path: string, init?: RequestInit) {
return fetch(buildUrl(path), init)
}
+1 -1
View File
@@ -57,7 +57,7 @@ function toggleSidebar() {
<!-- Logo -->
<div class="sidebar-logo">
<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>
<!-- 导航菜单 -->
+1 -1
View File
@@ -1,7 +1,7 @@
<!-- 关于页占位 -->
<template>
<div class="about">
<h1>关于 InsightRadar</h1>
<h1>关于 聚势智见</h1>
</div>
</template>
+10 -2
View File
@@ -182,6 +182,14 @@ function getRankingChartOptions(history: number[], platformColor: string) {
height: 56,
sparkline: { enabled: true },
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 },
fill: {
@@ -838,10 +846,10 @@ watch(() => route.query.event, (newId) => {
<i class="fa-regular fa-clock"></i>
最后同步: {{ lastSyncText }}
</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>
{{ stats.error_tasks_today }} 个异常
</span>
</span> -->
</div>
</section>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@ async function handleLogout() {
<div class="nav-brand">
<div class="logo">
<BrandLogo />
InsightRadar
聚势智见
</div>
</div>
<div class="nav-actions">
+2 -2
View File
@@ -150,7 +150,7 @@ onUnmounted(() => {
<div class="brand-content">
<div class="logo">
<BrandLogo />
InsightRadar
聚势智见
</div>
<h1 class="brand-title">洞察全网热点<br />让信息更聚焦</h1>
<p class="brand-desc">
@@ -192,7 +192,7 @@ onUnmounted(() => {
<div class="form-container">
<div class="form-header">
<h2>欢迎回来</h2>
<p>登录后继续查看 InsightRadar 实时动态</p>
<p>登录后继续查看 聚势智见 实时动态</p>
</div>
<div class="login-mode-tabs">
+1 -1
View File
@@ -131,7 +131,7 @@ onUnmounted(() => {
<div class="brand-content">
<div class="logo">
<BrandLogo />
InsightRadar
聚势智见
</div>
<h1 class="brand-title">开启智能<br />分析之旅</h1>
<p class="brand-desc">
+7 -7
View File
@@ -88,8 +88,8 @@ const revisionChains = computed<RevisionChain[]>(() => {
// 组内按时间升序
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
const first = items[0]
const last = items[items.length - 1]
const first = items[0]!
const last = items[items.length - 1]!
// 拼接标题链,避免相邻记录重复
const titles: string[] = [first.previous_headline]
@@ -107,13 +107,13 @@ const revisionChains = computed<RevisionChain[]>(() => {
chains.push({
event_id,
source_name: first.source_name,
source_name: first.source_name!,
titles,
change_times,
first_at: first.created_at,
last_at: last.created_at,
first_at: first.created_at!,
last_at: last.created_at!,
change_count: items.length,
url: first.url,
url: first.url!,
})
}
@@ -242,7 +242,7 @@ onMounted(loadRevisions)
</span>
<p class="chain-title-text">{{ title }}</p>
<span v-if="idx < chain.change_times.length" class="chain-step-time">
{{ formatTime(chain.change_times[idx]) }}
{{ formatTime(chain.change_times[idx] ?? '') }}
</span>
</div>
<div v-if="idx < chain.titles.length - 1" class="chain-arrow">
+16 -5
View File
@@ -6,6 +6,7 @@ import { searchEventsTimeline } from '@/api/events'
import type { SearchTimelineResponse } from '@/types/event'
import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
import CustomSelect from '@/components/CustomSelect.vue'
import type { ApexOptions } from 'apexcharts'
const keyword = ref('')
const searchResult = ref<SearchTimelineResponse | null>(null)
@@ -56,7 +57,7 @@ const filteredEvents = computed(() => {
})
// 热度时间线图表配置。
const chartOptions = ref({
const chartOptions = ref<ApexOptions>({
chart: {
type: 'area',
height: 350,
@@ -66,12 +67,18 @@ const chartOptions = ref({
},
animations: {
enabled: true,
easing: 'easeinout',
// easing: 'easeinout',
speed: 800,
},
// 点击图表数据点:切换选中时间,再次点击则取消筛选
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]) {
const clickedTime = searchResult.value.timeline[dataPointIndex].time_label
if (selectedTimeLabel.value === clickedTime) {
@@ -584,7 +591,12 @@ async function handleSearch() {
.chart-container {
margin-top: 16px;
margin-left: -10px; /* 视觉上抵消 apexcharts 的默认左侧留白。 */
margin-left: -10px;
}
.chart-container :deep(svg),
.chart-container :deep(canvas) {
outline: none;
}
.events-section {
@@ -594,7 +606,6 @@ async function handleSearch() {
.events-grid {
display: flex;
flex-direction: column;
/* 与 DashboardView 保持一致,列表按纵向堆叠展示。 */
}
.loading-state {
+1 -1
View File
@@ -156,7 +156,7 @@ onMounted(async () => {
v-model="newKeyword"
type="text"
class="keyword-input"
placeholder="输入关键词,如「直升机」「科比」「佐巴扬」..."
placeholder="输入关键词,如「篮球」「科比」「科技」..."
maxlength="100"
@keydown="onInputKeydown"
/>
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
strictPort: true,
proxy: {
'/api': {
target: 'http://10.252.130.135:8000',
target: 'http://localhost:8000',
changeOrigin: true,
},
},