Round6: 前台图片缩略图+下载/视频修复; 后台连接测试单次+自动消失; 导航重组; 存储中文化; 自动化去掉创建按钮; 短信验证码(注册/重置/充值通知)
This commit is contained in:
1 parent
ba09017884
commit
d179cc2f02
14 files changed
+806
-149
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user