Spaces:
Running
Running
系统升级
Browse files- app.py +19 -7
- router_comments.py +8 -2
- router_items.py +27 -20
- router_messages.py +14 -3
- router_posts.py +8 -2
- router_users_auth.py +4 -1
- verify_code_engine.py +35 -4
app.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
# ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
|
| 2 |
-
from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
from fastapi.responses import Response, JSONResponse, FileResponse
|
| 5 |
from sqlalchemy.orm import Session
|
|
@@ -385,10 +385,11 @@ def sanitize_audio_content(content: bytes, ext: str) -> bytes:
|
|
| 385 |
|
| 386 |
|
| 387 |
@app.post("/api/upload")
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
# 🟢 动态文件大小风控
|
|
|
|
|
|
|
| 392 |
max_size = 10 * 1024 * 1024
|
| 393 |
if file_type == "avatar":
|
| 394 |
max_size = 2 * 1024 * 1024
|
|
@@ -408,9 +409,20 @@ def upload_file(file: UploadFile = File(...), file_type: str = Form(...), curren
|
|
| 408 |
max_size = 100 * 1024 * 1024
|
| 409 |
elif file_type == "post_audio":
|
| 410 |
max_size = 30 * 1024 * 1024 # 音乐提示词音频:30MB
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
if not file.filename:
|
| 416 |
raise HTTPException(status_code=400, detail="文件名不能为空")
|
|
|
|
| 1 |
# ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
|
| 2 |
+
from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
from fastapi.responses import Response, JSONResponse, FileResponse
|
| 5 |
from sqlalchemy.orm import Session
|
|
|
|
| 385 |
|
| 386 |
|
| 387 |
@app.post("/api/upload")
|
| 388 |
+
@limiter.limit("30/minute") # 🔒 安全限流:防止刷存储(单次发布最多 9 图 + 1 JSON,阈值留足余量)
|
| 389 |
+
def upload_file(request: Request, file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)):
|
|
|
|
| 390 |
# 🟢 动态文件大小风控
|
| 391 |
+
# 🔒 安全修复:上限必须先算出来。原实现是先把整个文件 read() 进内存再判大小,
|
| 392 |
+
# 超大文件会直接吃满容器内存(可被用于打挂服务),现改为分块读取、超限立即中止。
|
| 393 |
max_size = 10 * 1024 * 1024
|
| 394 |
if file_type == "avatar":
|
| 395 |
max_size = 2 * 1024 * 1024
|
|
|
|
| 409 |
max_size = 100 * 1024 * 1024
|
| 410 |
elif file_type == "post_audio":
|
| 411 |
max_size = 30 * 1024 * 1024 # 音乐提示词音频:30MB
|
| 412 |
+
|
| 413 |
+
# 分块读取(1MB / 块),累计超限则马上报错,不再继续消耗内存与带宽
|
| 414 |
+
_CHUNK_SIZE = 1024 * 1024
|
| 415 |
+
_chunks = []
|
| 416 |
+
_received = 0
|
| 417 |
+
while True:
|
| 418 |
+
_chunk = file.file.read(_CHUNK_SIZE)
|
| 419 |
+
if not _chunk:
|
| 420 |
+
break
|
| 421 |
+
_received += len(_chunk)
|
| 422 |
+
if _received > max_size:
|
| 423 |
+
raise HTTPException(status_code=400, detail=f"文件过大,{file_type} 类型请限制在 {max_size // (1024*1024)}MB 以内")
|
| 424 |
+
_chunks.append(_chunk)
|
| 425 |
+
content = b"".join(_chunks)
|
| 426 |
|
| 427 |
if not file.filename:
|
| 428 |
raise HTTPException(status_code=400, detail="文件名不能为空")
|
router_comments.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
# router_comments.py
|
| 2 |
-
from fastapi import APIRouter, HTTPException, Depends
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
from html import escape
|
|
@@ -8,6 +8,11 @@ from notifications import add_notification
|
|
| 8 |
from models import InteractionToggle, CommentCreate
|
| 9 |
from 安全认证 import require_auth, is_admin
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
router = APIRouter()
|
| 12 |
|
| 13 |
@router.post("/api/interactions/toggle")
|
|
@@ -57,7 +62,8 @@ async def toggle_interaction(interaction: InteractionToggle, current_user: str =
|
|
| 57 |
return {"status": "success", "new_count": result_container["count"]}
|
| 58 |
|
| 59 |
@router.post("/api/comments")
|
| 60 |
-
|
|
|
|
| 61 |
comment.author = current_user # 强制使用认证用户身份,防止伪造
|
| 62 |
# 评论内容长度校验
|
| 63 |
if not comment.content or len(comment.content) > 5000:
|
|
|
|
| 1 |
# router_comments.py
|
| 2 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
from html import escape
|
|
|
|
| 8 |
from models import InteractionToggle, CommentCreate
|
| 9 |
from 安全认证 import require_auth, is_admin
|
| 10 |
|
| 11 |
+
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
| 12 |
+
from slowapi import Limiter
|
| 13 |
+
from slowapi.util import get_remote_address
|
| 14 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 15 |
+
|
| 16 |
router = APIRouter()
|
| 17 |
|
| 18 |
@router.post("/api/interactions/toggle")
|
|
|
|
| 62 |
return {"status": "success", "new_count": result_container["count"]}
|
| 63 |
|
| 64 |
@router.post("/api/comments")
|
| 65 |
+
@limiter.limit("10/minute") # 🔒 安全限流:防止刷评论洗版(正常用户不会一分钟发 10 条以上)
|
| 66 |
+
async def post_comment(request: Request, comment: CommentCreate, current_user: str = Depends(require_auth)):
|
| 67 |
comment.author = current_user # 强制使用认证用户身份,防止伪造
|
| 68 |
# 评论内容长度校验
|
| 69 |
if not comment.content or len(comment.content) > 5000:
|
router_items.py
CHANGED
|
@@ -557,26 +557,33 @@ async def check_github_updates():
|
|
| 557 |
headers["Authorization"] = f"Bearer {active_token}"
|
| 558 |
|
| 559 |
req = urllib.request.Request(api_url, headers=headers)
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
except urllib.error.HTTPError as e:
|
| 581 |
print(f"[版本检查失败] {item.get('title', item_id)}: HTTP {e.code}")
|
| 582 |
except Exception as e:
|
|
|
|
| 557 |
headers["Authorization"] = f"Bearer {active_token}"
|
| 558 |
|
| 559 |
req = urllib.request.Request(api_url, headers=headers)
|
| 560 |
+
|
| 561 |
+
# ⚡ 性能修复:urlopen 是同步阻塞调用(timeout=10),在 async 处理器里
|
| 562 |
+
# 逐个 item 串行等待会占死唯一的事件循环(N 个仓库最坏 N×10 秒全站无响应);
|
| 563 |
+
# 抽成同步小函数交给线程池执行,异常仍由下方 except 分支按原样捕获。
|
| 564 |
+
def _fetch_commits():
|
| 565 |
+
with urllib.request.urlopen(req, timeout=10) as response:
|
| 566 |
+
return json.loads(response.read().decode("utf-8"))
|
| 567 |
+
|
| 568 |
+
commits = await asyncio.to_thread(_fetch_commits)
|
| 569 |
+
if commits and len(commits) > 0:
|
| 570 |
+
latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识
|
| 571 |
+
|
| 572 |
+
# 如果版本有变化,更新 versions.json
|
| 573 |
+
# 🧹 P2修复(格式统一):统一以新格式 dict 读写,保留 cached_at/zip_size 等缓存元字段;
|
| 574 |
+
# 旧逻辑拿字符串与 dict 比较会把 dict 条目永远判为"有更新"并覆盖成字符串丢失元信息
|
| 575 |
+
entry = versions_db.get(item_id)
|
| 576 |
+
if isinstance(entry, str):
|
| 577 |
+
entry = {"hash": entry}
|
| 578 |
+
elif not isinstance(entry, dict):
|
| 579 |
+
entry = {}
|
| 580 |
+
old_version = entry.get("hash", "")
|
| 581 |
+
if latest_sha and latest_sha != old_version:
|
| 582 |
+
entry["hash"] = latest_sha
|
| 583 |
+
versions_db[item_id] = entry
|
| 584 |
+
updated_count += 1
|
| 585 |
+
print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}")
|
| 586 |
+
|
| 587 |
except urllib.error.HTTPError as e:
|
| 588 |
print(f"[版本检查失败] {item.get('title', item_id)}: HTTP {e.code}")
|
| 589 |
except Exception as e:
|
router_messages.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# router_messages.py
|
| 2 |
-
from fastapi import APIRouter, HTTPException, Depends
|
| 3 |
from pydantic import BaseModel
|
|
|
|
| 4 |
import time
|
| 5 |
import uuid
|
| 6 |
import subprocess
|
|
@@ -10,6 +11,11 @@ from notifications import add_notification
|
|
| 10 |
from models import PrivateMessage
|
| 11 |
from 安全认证 import require_auth, is_admin
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
router = APIRouter()
|
| 14 |
|
| 15 |
# ==========================================
|
|
@@ -107,7 +113,11 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
|
|
| 107 |
clean_env = {k: v for k, v in os.environ.items()
|
| 108 |
if not any(k.startswith(prefix) for prefix in sensitive_prefixes)}
|
| 109 |
# 执行脚本,超时缩短至30秒
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
["python", script_path],
|
| 112 |
capture_output=True,
|
| 113 |
text=True,
|
|
@@ -150,7 +160,8 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
|
|
| 150 |
# 原有功能:私信与聊天
|
| 151 |
# ==========================================
|
| 152 |
@router.post("/api/messages/private")
|
| 153 |
-
|
|
|
|
| 154 |
msg.sender = current_user # 强制发送者身份,防止伪造
|
| 155 |
|
| 156 |
# 验证接收者存在
|
|
|
|
| 1 |
# router_messages.py
|
| 2 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 3 |
from pydantic import BaseModel
|
| 4 |
+
import asyncio
|
| 5 |
import time
|
| 6 |
import uuid
|
| 7 |
import subprocess
|
|
|
|
| 11 |
from models import PrivateMessage
|
| 12 |
from 安全认证 import require_auth, is_admin
|
| 13 |
|
| 14 |
+
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
| 15 |
+
from slowapi import Limiter
|
| 16 |
+
from slowapi.util import get_remote_address
|
| 17 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 18 |
+
|
| 19 |
router = APIRouter()
|
| 20 |
|
| 21 |
# ==========================================
|
|
|
|
| 113 |
clean_env = {k: v for k, v in os.environ.items()
|
| 114 |
if not any(k.startswith(prefix) for prefix in sensitive_prefixes)}
|
| 115 |
# 执行脚本,超时缩短至30秒
|
| 116 |
+
# ⚡ 性能修复:subprocess.run 会同步等待子进程结束(最长 30 秒),
|
| 117 |
+
# 在 async 处理器里直接调用会占死唯一的事件循环,脚本跑完前整个服务无法响应任何请求;
|
| 118 |
+
# 交给线程池执行,TimeoutExpired 等异常仍由下方原有 except 分支按原样处理。
|
| 119 |
+
result = await asyncio.to_thread(
|
| 120 |
+
subprocess.run,
|
| 121 |
["python", script_path],
|
| 122 |
capture_output=True,
|
| 123 |
text=True,
|
|
|
|
| 160 |
# 原有功能:私信与聊天
|
| 161 |
# ==========================================
|
| 162 |
@router.post("/api/messages/private")
|
| 163 |
+
@limiter.limit("30/minute") # 🔒 安全限流:防止私信骚扰/刷屏(聊天节奏快,阈值比发帖宽松)
|
| 164 |
+
async def send_private_message(request: Request, msg: PrivateMessage, current_user: str = Depends(require_auth)):
|
| 165 |
msg.sender = current_user # 强制发送者身份,防止伪造
|
| 166 |
|
| 167 |
# 验证接收者存在
|
router_posts.py
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
# 功能:帖子发布、列表、详情、互动(点赞/收藏/评论/打赏)
|
| 6 |
# ==========================================
|
| 7 |
|
| 8 |
-
from fastapi import APIRouter, HTTPException, Depends
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
from models import PostCreate, PostUpdate, RatingRequest
|
| 11 |
import 数据库连接 as db
|
|
@@ -20,6 +20,11 @@ import hashlib
|
|
| 20 |
import datetime
|
| 21 |
import logging
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
router = APIRouter()
|
| 24 |
|
| 25 |
# ==========================================
|
|
@@ -144,7 +149,8 @@ async def get_post_detail(post_id: str):
|
|
| 144 |
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 145 |
|
| 146 |
@router.post("/api/posts")
|
| 147 |
-
|
|
|
|
| 148 |
"""
|
| 149 |
发布帖子
|
| 150 |
"""
|
|
|
|
| 5 |
# 功能:帖子发布、列表、详情、互动(点赞/收藏/评论/打赏)
|
| 6 |
# ==========================================
|
| 7 |
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
from models import PostCreate, PostUpdate, RatingRequest
|
| 11 |
import 数据库连接 as db
|
|
|
|
| 20 |
import datetime
|
| 21 |
import logging
|
| 22 |
|
| 23 |
+
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
| 24 |
+
from slowapi import Limiter
|
| 25 |
+
from slowapi.util import get_remote_address
|
| 26 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 27 |
+
|
| 28 |
router = APIRouter()
|
| 29 |
|
| 30 |
# ==========================================
|
|
|
|
| 149 |
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 150 |
|
| 151 |
@router.post("/api/posts")
|
| 152 |
+
@limiter.limit("5/minute") # 🔒 安全限流:防止刷帖洗版(一次发布含多图上传,但建帖只调一次)
|
| 153 |
+
async def create_post(request: Request, post: PostCreate, current_user: str = Depends(require_auth)):
|
| 154 |
"""
|
| 155 |
发布帖子
|
| 156 |
"""
|
router_users_auth.py
CHANGED
|
@@ -144,9 +144,12 @@ async def send_code_api(request: Request, req: SendCodeRequest):
|
|
| 144 |
}
|
| 145 |
|
| 146 |
# 同步发送(会阻塞等待结果)
|
|
|
|
|
|
|
|
|
|
| 147 |
if req.contact_type == "email":
|
| 148 |
try:
|
| 149 |
-
|
| 150 |
return {"status": "success", "message": "验证码已成功发送至邮箱"}
|
| 151 |
except Exception as e:
|
| 152 |
# 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
|
|
|
|
| 144 |
}
|
| 145 |
|
| 146 |
# 同步发送(会阻塞等待结果)
|
| 147 |
+
# ⚡ 性能修复:send_email_code 内部走 SMTP 同步连接(最长可阻塞十秒级),
|
| 148 |
+
# 直接在 async 处理器里调用会占死唯一的事件循环,导致整个服务期间无法响应任何请求;
|
| 149 |
+
# 改由 asyncio.to_thread 丢到线程池执行,等待结果的同时不阻塞其他请求。
|
| 150 |
if req.contact_type == "email":
|
| 151 |
try:
|
| 152 |
+
await asyncio.to_thread(send_email_code, req.contact, code, req.action_type)
|
| 153 |
return {"status": "success", "message": "验证码已成功发送至邮箱"}
|
| 154 |
except Exception as e:
|
| 155 |
# 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
|
verify_code_engine.py
CHANGED
|
@@ -34,6 +34,31 @@ _last_cleanup_time = 0
|
|
| 34 |
_CLEANUP_INTERVAL = 300 # 5分钟清理一次
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def check_send_cooldown(contact: str):
|
| 38 |
"""
|
| 39 |
🔒 检查发送频率限制
|
|
@@ -148,7 +173,8 @@ def send_email_code(to_email: str, code: str, action: str):
|
|
| 148 |
)
|
| 149 |
try:
|
| 150 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 151 |
-
|
|
|
|
| 152 |
except Exception as e:
|
| 153 |
print(f"❌ Webhook 触发失败: {e}")
|
| 154 |
|
|
@@ -198,7 +224,7 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 198 |
req.add_header("Authorization", f"Basic {auth}")
|
| 199 |
try:
|
| 200 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 201 |
-
print(f"✅ Twilio 短信已成功下发至 {phone}")
|
| 202 |
except Exception as e:
|
| 203 |
print(f"❌ Twilio 发送失败: {e}")
|
| 204 |
return
|
|
@@ -239,7 +265,7 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 239 |
try:
|
| 240 |
response = client.send_sms(send_req)
|
| 241 |
if response.body.code == "OK":
|
| 242 |
-
print(f"✅ 阿里云短信已成功下发至 {phone}")
|
| 243 |
else:
|
| 244 |
print(f"❌ 阿里云下发失败: {response.body.message}")
|
| 245 |
except Exception as e:
|
|
@@ -250,4 +276,9 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 250 |
# 降级模式:控制台打印模拟
|
| 251 |
# ==========================================
|
| 252 |
# 作用:当没有配置任何短信服务时,仅在控制台打印验证码(仅限开发测试)
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
_CLEANUP_INTERVAL = 300 # 5分钟清理一次
|
| 35 |
|
| 36 |
|
| 37 |
+
# ==========================================
|
| 38 |
+
# 🔒 日志脱敏工具
|
| 39 |
+
# ==========================================
|
| 40 |
+
# 作用:HF Spaces 的运行日志对外可见,验证码与联系方式明文入日志等同于泄露账号
|
| 41 |
+
# 关联:云端/本地判据与 数据库连接.py 保持一致(HF Spaces 会注入 SPACE_ID)
|
| 42 |
+
_IS_CLOUD = bool(os.environ.get("SPACE_ID"))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _mask_contact(contact: str) -> str:
|
| 46 |
+
"""
|
| 47 |
+
🔒 联系方式脱敏(邮箱 / 手机号统一处理),仅用于日志输出
|
| 48 |
+
参数:contact - 原始邮箱或手机号
|
| 49 |
+
返回:脱敏后的字符串,保留足够辨识度以便排障,但不足以定位到具体用户
|
| 50 |
+
"""
|
| 51 |
+
if not contact:
|
| 52 |
+
return ""
|
| 53 |
+
contact = str(contact)
|
| 54 |
+
if "@" in contact:
|
| 55 |
+
name, _, domain = contact.partition("@")
|
| 56 |
+
return f"{name[:1]}***@{domain}" if len(name) > 1 else f"***@{domain}"
|
| 57 |
+
if len(contact) >= 7:
|
| 58 |
+
return contact[:3] + "****" + contact[-2:]
|
| 59 |
+
return "***"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
def check_send_cooldown(contact: str):
|
| 63 |
"""
|
| 64 |
🔒 检查发送频率限制
|
|
|
|
| 173 |
)
|
| 174 |
try:
|
| 175 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 176 |
+
# 🔒 安全修复:不再打印验证码与邮箱明文(日志对外可见),仅留脱敏标识与动作类型
|
| 177 |
+
print(f"✅ 成功触发 Webhook 发送验证码邮件至 {_mask_contact(to_email)}(动作: {action})")
|
| 178 |
except Exception as e:
|
| 179 |
print(f"❌ Webhook 触发失败: {e}")
|
| 180 |
|
|
|
|
| 224 |
req.add_header("Authorization", f"Basic {auth}")
|
| 225 |
try:
|
| 226 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 227 |
+
print(f"✅ Twilio 短信已成功下发至 {_mask_contact(phone)}")
|
| 228 |
except Exception as e:
|
| 229 |
print(f"❌ Twilio 发送失败: {e}")
|
| 230 |
return
|
|
|
|
| 265 |
try:
|
| 266 |
response = client.send_sms(send_req)
|
| 267 |
if response.body.code == "OK":
|
| 268 |
+
print(f"✅ 阿里云短信已成功下发至 {_mask_contact(phone)}")
|
| 269 |
else:
|
| 270 |
print(f"❌ 阿里云下发失败: {response.body.message}")
|
| 271 |
except Exception as e:
|
|
|
|
| 276 |
# 降级模式:控制台打印模拟
|
| 277 |
# ==========================================
|
| 278 |
# 作用:当没有配置任何短信服务时,仅在控制台打印验证码(仅限开发测试)
|
| 279 |
+
# 🔒 P0安全修复:云端日志对外可见,生产环境绝不能打印验证码明文;
|
| 280 |
+
# 本地开发(无 SPACE_ID)保留原行为,不影响调试便利性。
|
| 281 |
+
if _IS_CLOUD:
|
| 282 |
+
print(f"⚠️ 未配置短信密钥,验证码未实际下发(目标: {_mask_contact(phone)})")
|
| 283 |
+
else:
|
| 284 |
+
print(f"⚠️ 未配置短信密钥,模拟下发 -> 手机号: {phone}, 验证码: {code}")
|