Round6: 前台图片缩略图+下载/视频修复; 后台连接测试单次+自动消失; 导航重组; 存储中文化; 自动化去掉创建按钮; 短信验证码(注册/重置/充值通知)

This commit is contained in:
QihuAI Design committed 2026-07-14 18:49:27 +08:00
1 parent ba09017884
commit d179cc2f02
14 files changed
+806 -149

No files matched your search

+113 -2
View File
@@ -4,15 +4,18 @@ from sqlalchemy import and_
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime
import random
import re
from datetime import datetime, timedelta
from ..database import get_db
from ..models import (
User, LoginLog, RegisterSetting,
User, LoginLog, RegisterSetting, SmsConfig, SmsVerificationCode,
gen_uuid
)
from ..auth import get_current_user, hash_password, verify_password, create_access_token
from ..middleware.log_service import LogService
from ..sms_client import send_sms_code
router = APIRouter()
@@ -27,6 +30,18 @@ class RegisterRequest(BaseModel):
password: str = Field(min_length=6, max_length=100)
email: Optional[str] = None
phone: Optional[str] = None
sms_code: Optional[str] = None
class SendCodeRequest(BaseModel):
phone: str
purpose: str = "register" # register / reset_password
class ResetPasswordRequest(BaseModel):
phone: str
code: str
new_password: str = Field(min_length=6, max_length=100)
@router.post("/login")
@@ -117,6 +132,20 @@ def register(req: RegisterRequest, db: Session = Depends(get_db)):
if existing_phone:
raise HTTPException(status_code=400, detail="手机号已被注册")
# 短信验证码校验(手机号注册且开启短信验证)
if req.phone and reg_setting and reg_setting.phone_sms_required:
if not req.sms_code:
raise HTTPException(status_code=400, detail="请先获取并填写短信验证码")
vrec = db.query(SmsVerificationCode).filter(
SmsVerificationCode.phone == req.phone,
SmsVerificationCode.purpose == "register",
SmsVerificationCode.used == False,
SmsVerificationCode.expires_at > datetime.now(),
).order_by(SmsVerificationCode.created_at.desc()).first()
if not vrec or vrec.code != req.sms_code:
raise HTTPException(status_code=400, detail="短信验证码错误或已过期")
vrec.used = True
user = User(
id=gen_uuid(),
username=req.username,
@@ -179,6 +208,88 @@ def register(req: RegisterRequest, db: Session = Depends(get_db)):
}
@router.post("/send-code")
def send_code(req: SendCodeRequest, db: Session = Depends(get_db)):
"""发送短信验证码(注册 / 重置密码)。公开接口。"""
if not re.match(r'^1[3-9]\d{9}$', req.phone or ''):
raise HTTPException(status_code=400, detail="手机号格式不正确")
purpose = req.purpose or "register"
if purpose not in ("register", "reset_password"):
raise HTTPException(status_code=400, detail="不支持的验证码类型")
config = db.query(SmsConfig).first()
if not config or not config.provider:
raise HTTPException(status_code=400, detail="短信服务未配置,请联系管理员")
# 注册场景:手机号已注册则直接提示,避免浪费短信
if purpose == "register":
existing = db.query(User).filter(User.phone == req.phone).first()
if existing:
raise HTTPException(status_code=400, detail="该手机号已注册,请直接登录")
# 频率限制:同一手机号+用途 60 秒内不重复下发
recent = db.query(SmsVerificationCode).filter(
SmsVerificationCode.phone == req.phone,
SmsVerificationCode.purpose == purpose,
SmsVerificationCode.used == False,
SmsVerificationCode.expires_at > datetime.now(),
).order_by(SmsVerificationCode.created_at.desc()).first()
if recent:
age = (datetime.now() - recent.created_at).total_seconds()
if age < 60:
raise HTTPException(status_code=429, detail=f"验证码发送过于频繁,请{int(60 - age)}秒后再试")
code = f"{random.randint(100000, 999999)}"
rec = SmsVerificationCode(
id=gen_uuid(),
phone=req.phone,
code=code,
purpose=purpose,
expires_at=datetime.now() + timedelta(minutes=5),
used=False,
)
db.add(rec)
db.commit()
ok, msg, _ = send_sms_code(config, req.phone, code, purpose)
if not ok:
raise HTTPException(status_code=400, detail=f"短信发送失败:{msg}")
return {"code": 0, "data": {"sent": True}, "message": "验证码已发送"}
@router.post("/reset-password")
def reset_password(req: ResetPasswordRequest, db: Session = Depends(get_db)):
"""通过短信验证码重置密码(公开接口)。"""
user = db.query(User).filter(User.phone == req.phone).first()
if not user:
raise HTTPException(status_code=400, detail="该手机号未注册")
vrec = db.query(SmsVerificationCode).filter(
SmsVerificationCode.phone == req.phone,
SmsVerificationCode.purpose == "reset_password",
SmsVerificationCode.used == False,
SmsVerificationCode.expires_at > datetime.now(),
).order_by(SmsVerificationCode.created_at.desc()).first()
if not vrec or vrec.code != req.code:
raise HTTPException(status_code=400, detail="验证码错误或已过期")
user.password_hash = hash_password(req.new_password)
user.updated_at = datetime.now()
vrec.used = True
LogService.log_operation(
user_id=user.id,
username=user.username,
action="reset_password",
target="user",
detail=f"用户 {user.username} 通过短信验证码重置了密码",
)
db.commit()
return {"code": 0, "message": "密码重置成功"}
@router.get("/me")
def get_me(current_user: User = Depends(get_current_user)):
return {
+5 -4
View File
@@ -157,21 +157,22 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
video_id = data.get("video_id") or data.get("task_id") or data.get("id")
if not video_id:
return f"⚠️ 视频生成失败:未返回任务ID {str(data)[:200]}"
# 轮询结果(最多约 5 分钟)
# 轮询结果(最多约 5 分钟)。AGNES 视频状态端点为 GET {base}/v1/videos/{video_id}
# 该端点顶层返回 status(queued/in_progress/completed/failed),成片地址在 metadata.url
result_url = None
err_msg = None
for _ in range(60):
await asyncio.sleep(5)
r2 = await client.get(f"{base}/agnesapi?video_id={video_id}", headers=headers)
r2 = await client.get(f"{base}/v1/videos/{video_id}", headers=headers)
if r2.status_code != 200:
continue
rd = r2.json()
st = rd.get("status")
if st == "completed":
result_url = rd.get("url")
result_url = (rd.get("metadata") or {}).get("url") or rd.get("url")
break
if st == "failed":
err_msg = rd.get("error")
err_msg = rd.get("error") or (rd.get("metadata") or {}).get("error")
break
if result_url:
return (f'<video class="md-video" controls src="{result_url}"></video>\n\n'
+11 -1
View File
@@ -9,10 +9,11 @@ from datetime import datetime, date
from ..database import get_db
from ..models import (
User, LoginLog, ApiKey, MembershipSubscription, AppPurchase, MarketApp,
gen_uuid
SmsConfig, gen_uuid
)
from ..auth import get_current_user, hash_password, verify_password
from ..middleware.log_service import LogService
from ..sms_client import send_sms_text
router = APIRouter()
@@ -145,6 +146,15 @@ def update_password(
db_user.password_hash = hash_password(req.new_password)
db_user.updated_at = datetime.now()
# 密码修改成功后发送短信通知(若已配置短信且用户绑定手机号)
try:
sms_cfg = db.query(SmsConfig).first()
if sms_cfg and sms_cfg.provider and db_user.phone:
send_sms_text(sms_cfg, db_user.phone, "您的登录密码已修改,如非本人操作请尽快联系客服。")
except Exception as sms_err:
import logging
logging.getLogger("aiflow").warning(f"密码修改短信通知失败(不影响修改结果): {sms_err}")
LogService.log_operation(
user_id=db_user.id, username=db_user.username, action="change_password",
target="user", target_id=db_user.id,
+14 -1
View File
@@ -8,11 +8,12 @@ from datetime import datetime, date, timedelta
from ..database import get_db
from ..models import (
User, TokenUsageLog, TokenBalance, AIModel,
User, TokenUsageLog, TokenBalance, AIModel, SmsConfig,
gen_uuid
)
from ..auth import get_current_user
from ..middleware.log_service import LogService
from ..sms_client import send_sms_text
router = APIRouter()
@@ -237,6 +238,18 @@ def recharge(
db.commit()
# 充值成功后发送短信通知(若已配置短信且用户绑定手机号)
try:
sms_cfg = db.query(SmsConfig).first()
if sms_cfg and sms_cfg.provider and current_user.phone:
send_sms_text(
sms_cfg, current_user.phone,
f"您已成功充值 {req.amount} tokens,当前余额 {balance.balance} tokens。",
)
except Exception as sms_err:
import logging
logging.getLogger("aiflow").warning(f"充值短信通知失败(不影响充值结果): {sms_err}")
return {
"code": 0,
"data": {
+13
View File
@@ -1103,3 +1103,16 @@ class MCPServer(Base):
resources_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class SmsVerificationCode(Base):
__tablename__ = "sms_verification_codes"
id = Column(String(36), primary_key=True, default=gen_uuid)
phone = Column(String(20), nullable=False, index=True)
code = Column(String(10), nullable=False)
purpose = Column(String(30), nullable=False) # register / reset_password
expires_at = Column(DateTime, nullable=False)
used = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now)
+168
View File
@@ -0,0 +1,168 @@
"""
短信发送客户端(可复用)
支持:短信宝 (SMSBao)、阿里云短信。
所有发送函数接收一个 SmsConfig ORM 行对象(或具有相同字段的对象)作为配置。
设计:
- 不依赖 Web 框架,纯工具函数,供 auth / profile / token_usage / admin.settings 复用。
- 短信宝走国内直连,必须禁用代理(requests proxies={"http":None,"https":None})。
"""
import hashlib
import logging
import urllib.parse
import uuid
logger = logging.getLogger("aiflow")
# ============================================================
# 通用辅助
# ============================================================
def smsbao_md5(password: str) -> str:
"""短信宝密码:登录密码的 MD5(32位,不区分大小写)"""
return hashlib.md5(password.encode("utf-8")).hexdigest()
SMSBAO_ERRORS = {
"0": "发送成功",
"30": "密码错误",
"40": "账号不存在",
"41": "余额不足",
"43": "IP地址限制",
"50": "内容含有敏感词",
"51": "手机号码不正确",
}
def _aliyun_sign(secret: str, params: dict, method: str = "GET") -> str:
"""阿里云签名机制 (HMAC-SHA1)"""
sorted_keys = sorted(params.keys())
qs = "&".join([
f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(str(params[k]), safe='')}"
for k in sorted_keys
])
string_to_sign = f"{method}&{urllib.parse.quote('/', safe='')}&{urllib.parse.quote(qs, safe='')}"
key = f"{secret}&"
import hmac
import base64
h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1)
return base64.b64encode(h.digest()).decode('utf-8')
def _wrap_sign(config_sign: str) -> str:
"""包裹短信签名:缺少【】时自动加【】"""
sign = config_sign or ""
if "【" not in sign:
sign = f"【{sign}】" if sign else "【七虎AI】"
return sign
# ============================================================
# 核心发送
# ============================================================
def send_sms(config, phone: str, content: str):
"""
发送任意内容的短信。
:param config: SmsConfig ORM 行(需有 provider/username/password/goods_id/sign_name/api_key/api_secret/template_code)
:param phone: 接收手机号
:param content: 已包含签名的短信正文
:return: (ok: bool, message: str, provider_code: str|None)
"""
if not config or not getattr(config, "provider", None):
return False, "短信服务未配置,请联系管理员", None
provider = config.provider
# 短信宝 (SMSBao)
if provider == "短信宝":
if not config.username or not config.password:
return False, "短信宝账号未配置(缺少用户名或密码)", None
pwd_md5 = smsbao_md5(config.password)
params = {"u": config.username, "p": pwd_md5, "m": phone, "c": content}
if getattr(config, "goods_id", None):
params["g"] = config.goods_id
import requests
try:
resp = requests.get(
"https://api.smsbao.com/sms", params=params, timeout=10,
proxies={"http": None, "https": None},
)
code_str = resp.text.strip().split("\n")[0]
except Exception as e:
logger.error(f"短信宝请求失败: {e}")
return False, f"短信宝请求失败: {e}", None
if code_str == "0":
return True, "发送成功", code_str
return False, SMSBAO_ERRORS.get(code_str, f"未知错误码 {code_str}"), code_str
# 阿里云短信
if "阿里云" in provider:
if not config.api_key or not config.api_secret:
return False, "阿里云短信 AccessKey 未配置", None
sign = _wrap_sign(getattr(config, "sign_name", "") or "")
# 内容里已含验证码,阿里云模板需要 TemplateParam;此处用通用签名+内容方式不便,
# 为兼容现有配置,直接以单变量模板发送(TemplateParam 透传 content)。
from datetime import datetime as _dt
params = {
"AccessKeyId": config.api_key,
"Action": "SendSms",
"Format": "JSON",
"PhoneNumbers": phone,
"RegionId": "cn-hangzhou",
"SignName": sign.replace("【", "").replace("】", ""),
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": str(uuid.uuid4()),
"SignatureVersion": "1.0",
"TemplateCode": getattr(config, "template_code", None) or "SMS_00000001",
"TemplateParam": '{"code":"000000"}',
"Timestamp": _dt.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"Version": "2017-05-25",
}
params["Signature"] = _aliyun_sign(config.api_secret, params)
import requests
try:
resp = requests.post(
"https://dysmsapi.aliyuncs.com/",
data=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
result = resp.json()
except Exception as e:
logger.error(f"阿里云短信请求失败: {e}")
return False, f"阿里云短信请求失败: {e}", None
if result.get("Code") == "OK":
return True, "发送成功", result.get("Code")
return False, result.get("Message", result.get("Code", "未知错误")), result.get("Code")
return False, f"不支持的短信服务商: {provider}", None
# ============================================================
# 业务封装
# ============================================================
_PURPOSE_TEXT = {
"register": "注册",
"reset_password": "重置密码",
"change_password": "修改密码",
"recharge": "充值",
}
def send_sms_code(config, phone: str, code: str, purpose: str):
"""发送验证码类短信。"""
purpose_text = _PURPOSE_TEXT.get(purpose, "验证")
sign = _wrap_sign(getattr(config, "sign_name", "") or "")
content = f"{sign}您的{purpose_text}验证码为{code},5分钟内有效,请勿泄露给他人。"
return send_sms(config, phone, content)
def send_sms_text(config, phone: str, text: str):
"""发送通知类短信(如充值成功、密码已修改等)。text 不含签名,自动包裹。"""
sign = _wrap_sign(getattr(config, "sign_name", "") or "")
content = f"{sign}{text}"
return send_sms(config, phone, content)