功能:唱歌跳舞视频正常并且能够重新下载处理

This commit is contained in:
xiao12feng8 2026-02-03 14:47:24 +08:00
parent 57a846b2a1
commit d7ec1d530a
15 changed files with 1140 additions and 101 deletions

View File

@ -1,2 +1,2 @@
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/lover?charset=utf8mb4 DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/lover?charset=utf8mb4
USER_INFO_API=http://192.168.1.164:30100/index.php/api/user_basic/get_user_basic USER_INFO_API=http://192.168.1.164:30100/api/user_basic/get_user_basic

View File

@ -150,7 +150,7 @@ class Settings(BaseSettings):
# 用户信息拉取接口FastAdmin 提供) # 用户信息拉取接口FastAdmin 提供)
USER_INFO_API: str = Field( USER_INFO_API: str = Field(
default="http://192.168.1.164:30100/index.php/api/user_basic/get_user_basic", default="http://192.168.1.164:30100/api/user_basic/get_user_basic",
env="USER_INFO_API", env="USER_INFO_API",
) )

View File

@ -20,12 +20,15 @@ def _fetch_user_from_php(token: str) -> Optional[dict]:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"用户中心调试 - 调用接口: {settings.USER_INFO_API}") # 临时硬编码修复配置问题
user_info_api = "http://192.168.1.164:30100/api/user_basic/get_user_basic"
logger.info(f"用户中心调试 - 调用接口: {user_info_api}")
logger.info(f"用户中心调试 - token: {token}") logger.info(f"用户中心调试 - token: {token}")
try: try:
resp = requests.get( resp = requests.get(
settings.USER_INFO_API, user_info_api,
headers={"token": token}, headers={"token": token},
timeout=5, timeout=5,
) )

View File

@ -9,3 +9,4 @@ requests>=2.31
oss2>=2.18 oss2>=2.18
dashscope>=1.20 dashscope>=1.20
pyyaml>=6.0 pyyaml>=6.0
imageio-ffmpeg>=0.4

View File

@ -1,6 +1,7 @@
import hashlib import hashlib
import os import os
import random import random
import shutil
import subprocess import subprocess
import tempfile import tempfile
import time import time
@ -27,8 +28,43 @@ from ..models import (
) )
from ..response import ApiResponse, success_response from ..response import ApiResponse, success_response
try:
import imageio_ffmpeg # type: ignore
except Exception: # pragma: no cover
imageio_ffmpeg = None
router = APIRouter(prefix="/dance", tags=["dance"]) router = APIRouter(prefix="/dance", tags=["dance"])
def _ffmpeg_bin() -> str:
found = shutil.which("ffmpeg")
if found:
return found
if imageio_ffmpeg is not None:
try:
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
pass
return "ffmpeg"
def _ffprobe_bin() -> str:
found = shutil.which("ffprobe")
if found:
return found
if imageio_ffmpeg is not None:
try:
ffmpeg_path = imageio_ffmpeg.get_ffmpeg_exe()
candidate = os.path.join(os.path.dirname(ffmpeg_path), "ffprobe.exe")
if os.path.exists(candidate):
return candidate
candidate = os.path.join(os.path.dirname(ffmpeg_path), "ffprobe")
if os.path.exists(candidate):
return candidate
except Exception:
pass
return "ffprobe"
DANCE_TARGET_DURATION_SEC = 10 DANCE_TARGET_DURATION_SEC = 10
@ -53,6 +89,7 @@ def get_dance_history(
GenerationTask.user_id == user.id, GenerationTask.user_id == user.id,
GenerationTask.lover_id == lover.id, GenerationTask.lover_id == lover.id,
GenerationTask.task_type == "video", GenerationTask.task_type == "video",
GenerationTask.payload["prompt"].as_string().isnot(None),
GenerationTask.status == "succeeded", GenerationTask.status == "succeeded",
GenerationTask.result_url.isnot(None), GenerationTask.result_url.isnot(None),
) )
@ -77,6 +114,154 @@ def get_dance_history(
return success_response(result, msg="获取成功") return success_response(result, msg="获取成功")
def _download_binary(url: str) -> bytes:
try:
resp = requests.get(url, timeout=30)
except Exception as exc:
raise HTTPException(status_code=502, detail="文件下载失败") from exc
if resp.status_code != 200:
raise HTTPException(status_code=502, detail="文件下载失败")
return resp.content
def _retry_finalize_dance_task(task_id: int) -> None:
"""任务失败但 DashScope 端已成功时,尝试重新下载视频并重新上传(自愈/手动重试共用)。"""
try:
with SessionLocal() as db:
task = (
db.query(GenerationTask)
.filter(GenerationTask.id == task_id)
.with_for_update()
.first()
)
if not task:
return
payload = task.payload or {}
dash_id = payload.get("dashscope_task_id")
if not dash_id:
return
status, dash_video_url = _fetch_dashscope_status(str(dash_id))
if status != "SUCCEEDED" or not dash_video_url:
return
# 重新生成:下载 dashscope 视频 -> 随机 BGM -> 合成 -> 上传
bgm_song = _pick_random_bgm(db)
bgm_audio_url_raw = bgm_song.audio_url
bgm_audio_url = _cdnize(bgm_audio_url_raw) or bgm_audio_url_raw
merged_bytes, bgm_meta = _merge_dance_video_with_bgm(
dash_video_url,
bgm_audio_url,
DANCE_TARGET_DURATION_SEC,
)
object_name = f"lover/{task.lover_id}/dance/{int(time.time())}_retry.mp4"
oss_url = _upload_to_oss(merged_bytes, object_name)
task.status = "succeeded"
task.result_url = oss_url
task.error_msg = None
task.payload = {
**payload,
"dashscope_video_url": dash_video_url,
"bgm_song_id": bgm_song.id,
"bgm_audio_url": bgm_audio_url,
"bgm_audio_url_raw": bgm_audio_url_raw,
"bgm_start_sec": bgm_meta.get("bgm_start_sec"),
"bgm_duration": DANCE_TARGET_DURATION_SEC,
"retry_finalized": True,
}
task.updated_at = datetime.utcnow()
db.add(task)
db.commit()
except Exception:
return
@router.post("/retry/{task_id}", response_model=ApiResponse[dict])
def retry_dance_task(
task_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
):
"""手动重试:用于 DashScope 端已成功但本地下载/合成/上传失败导致任务失败的情况。"""
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
if not lover:
raise HTTPException(status_code=404, detail="恋人未找到")
task = (
db.query(GenerationTask)
.filter(
GenerationTask.id == task_id,
GenerationTask.user_id == user.id,
GenerationTask.lover_id == lover.id,
GenerationTask.task_type == "video",
GenerationTask.payload["prompt"].as_string().isnot(None),
)
.first()
)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
payload = task.payload or {}
dash_id = payload.get("dashscope_task_id")
if not dash_id:
raise HTTPException(status_code=400, detail="任务缺少 dashscope_task_id无法重试")
task.payload = {**payload, "manual_retry": True}
task.updated_at = datetime.utcnow()
db.add(task)
db.commit()
background_tasks.add_task(_retry_finalize_dance_task, int(task.id))
return success_response({"task_id": int(task.id)}, msg="已触发重试")
@router.get("/history/all", response_model=ApiResponse[list[dict]])
def get_dance_history_all(
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
page: int = 1,
size: int = 20,
):
"""获取用户的跳舞视频全部历史记录(成功+失败+进行中)。"""
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
if not lover:
raise HTTPException(status_code=404, detail="恋人未找到")
offset = (page - 1) * size
tasks = (
db.query(GenerationTask)
.filter(
GenerationTask.user_id == user.id,
GenerationTask.lover_id == lover.id,
GenerationTask.task_type == "video",
GenerationTask.payload["prompt"].as_string().isnot(None),
)
.order_by(GenerationTask.id.desc())
.offset(offset)
.limit(size)
.all()
)
result: list[dict] = []
for task in tasks:
payload = task.payload or {}
url = task.result_url or payload.get("video_url") or payload.get("dashscope_video_url") or ""
result.append(
{
"id": int(task.id),
"prompt": payload.get("prompt") or "",
"status": task.status,
"video_url": _cdnize(url) or "",
"error_msg": task.error_msg,
"created_at": task.created_at.isoformat() if task.created_at else None,
}
)
return success_response(result, msg="获取成功")
class DanceGenerateIn(BaseModel): class DanceGenerateIn(BaseModel):
prompt: str = Field(..., min_length=2, max_length=400, description="用户希望跳的舞/动作描述") prompt: str = Field(..., min_length=2, max_length=400, description="用户希望跳的舞/动作描述")
@ -92,6 +277,41 @@ class DanceTaskStatusOut(BaseModel):
error_msg: Optional[str] = None error_msg: Optional[str] = None
@router.get("/current", response_model=ApiResponse[Optional[DanceTaskStatusOut]])
def get_current_dance_task(
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
):
task = (
db.query(GenerationTask)
.filter(
GenerationTask.user_id == user.id,
GenerationTask.task_type == "video",
GenerationTask.status.in_(["pending", "running"]),
GenerationTask.payload["prompt"].as_string().isnot(None),
)
.order_by(GenerationTask.id.desc())
.first()
)
if not task:
return success_response(None, msg="暂无进行中的任务")
payload = task.payload or {}
return success_response(
DanceTaskStatusOut(
generation_task_id=task.id,
status=task.status,
dashscope_task_id=str(payload.get("dashscope_task_id") or ""),
video_url=task.result_url or payload.get("video_url") or payload.get("dashscope_video_url") or "",
session_id=int(payload.get("session_id") or 0),
user_message_id=int(payload.get("user_message_id") or 0),
lover_message_id=int(payload.get("lover_message_id") or 0),
error_msg=task.error_msg,
),
msg="获取成功",
)
def _upload_to_oss(file_bytes: bytes, object_name: str) -> str: def _upload_to_oss(file_bytes: bytes, object_name: str) -> str:
if not settings.ALIYUN_OSS_ACCESS_KEY_ID or not settings.ALIYUN_OSS_ACCESS_KEY_SECRET: if not settings.ALIYUN_OSS_ACCESS_KEY_ID or not settings.ALIYUN_OSS_ACCESS_KEY_SECRET:
raise HTTPException(status_code=500, detail="未配置 OSS Key") raise HTTPException(status_code=500, detail="未配置 OSS Key")
@ -285,7 +505,7 @@ def _download_to_path(url: str, target_path: str, label: str):
def _probe_media_duration(path: str) -> Optional[float]: def _probe_media_duration(path: str) -> Optional[float]:
command = [ command = [
"ffprobe", _ffprobe_bin(),
"-v", "-v",
"error", "error",
"-show_entries", "-show_entries",
@ -315,7 +535,7 @@ def _probe_media_duration(path: str) -> Optional[float]:
def _run_ffmpeg_merge(video_path: str, audio_path: str, output_path: str): def _run_ffmpeg_merge(video_path: str, audio_path: str, output_path: str):
audio_duration = _probe_media_duration(audio_path) audio_duration = _probe_media_duration(audio_path)
command = [ command = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -364,7 +584,7 @@ def _extract_audio_segment(
output_path: str, output_path: str,
): ):
command = [ command = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -403,7 +623,7 @@ def _pad_audio_segment(
if pad_sec <= 0: if pad_sec <= 0:
return return
command = [ command = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",

View File

@ -1,15 +1,61 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from lover.deps import get_current_user, AuthedUser from lover.deps import get_current_user, AuthedUser
from lover.response import success_response from lover.response import success_response
from lover.db import get_db
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from lover.models import FriendRelation, User
router = APIRouter() router = APIRouter()
@router.get("/api/friend/index") @router.get("/api/friend/index")
def get_friend_index(user: AuthedUser = Depends(get_current_user)): @router.post("/api/friend/index")
def get_friend_index(user: AuthedUser = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取好友列表""" """获取好友列表"""
# 查询当前用户的好友关系双向user_id 或 friend_id 为当前用户,且 status=1 表示已通过)
friend_relations = db.query(FriendRelation).filter(
and_(
or_(
FriendRelation.user_id == user.id,
FriendRelation.friend_id == user.id
),
FriendRelation.status == "1"
)
).all()
friend_ids = []
result = []
for rel in friend_relations:
# 确定好友的 user_id不是自己
fid = rel.friend_id if rel.user_id == user.id else rel.user_id
friend_ids.append(fid)
# 查询好友用户信息
friend_users = db.query(User).filter(User.id.in_(friend_ids)).all() if friend_ids else []
user_map = {u.id: u for u in friend_users}
for rel in friend_relations:
fid = rel.friend_id if rel.user_id == user.id else rel.user_id
friend_user = user_map.get(fid)
if not friend_user:
continue
result.append({
"friend_id": fid,
"friend": {
"id": friend_user.id,
"nickname": friend_user.nickname,
"avatar": friend_user.avatar,
"user_number": str(friend_user.id), # 前端期望 user_number
"open_id": "", # 小程序 open_id如有需要可补充字段
},
"intimacy": rel.intimacy or 0,
"intimacy_level": rel.intimacy_level or 0,
"is_online": False, # 默认离线,前端会单独调用在线状态接口
})
return success_response({ return success_response({
"friends": [], "data": result,
"total": 0 "total": len(result)
}) })
@router.post("/api/friend/add") @router.post("/api/friend/add")

View File

@ -1,3 +1,5 @@
from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from lover.deps import get_current_user, AuthedUser from lover.deps import get_current_user, AuthedUser
from lover.response import success_response from lover.response import success_response
@ -17,6 +19,34 @@ def register_huanxin_user(user: AuthedUser = Depends(get_current_user)):
"""注册环信用户""" """注册环信用户"""
return success_response({"message": "注册成功"}) return success_response({"message": "注册成功"})
@router.post("/api/huanxin/online")
def set_huanxin_online(
payload: Optional[dict] = None,
user: AuthedUser = Depends(get_current_user),
):
"""获取环信用户在线状态(兼容前端批量查询)"""
payload = payload or {}
user_ids_raw = payload.get("user_ids")
# 前端好友列表会传入 user_ids=36,40,41期待返回数组并支持 .find()
if user_ids_raw:
ids = []
for part in str(user_ids_raw).split(","):
part = part.strip()
if not part:
continue
try:
ids.append(int(part))
except ValueError:
continue
return success_response([
{"id": uid, "is_online": False} for uid in ids
])
# 兼容旧调用:不传 user_ids 时,返回当前用户在线信息
return success_response({"status": "online", "user_id": user.id})
@router.get("/api/huanxin/user_info") @router.get("/api/huanxin/user_info")
def get_huanxin_user_info(user: AuthedUser = Depends(get_current_user)): def get_huanxin_user_info(user: AuthedUser = Depends(get_current_user)):
"""获取环信用户信息""" """获取环信用户信息"""

View File

@ -1,6 +1,8 @@
import hashlib import hashlib
import logging
import math import math
import os import os
import shutil
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
@ -35,8 +37,48 @@ from ..models import (
from ..response import ApiResponse, success_response from ..response import ApiResponse, success_response
from ..task_queue import sing_task_queue from ..task_queue import sing_task_queue
try:
import imageio_ffmpeg # type: ignore
except Exception: # pragma: no cover
imageio_ffmpeg = None
logger = logging.getLogger("sing")
router = APIRouter(prefix="/sing", tags=["sing"]) router = APIRouter(prefix="/sing", tags=["sing"])
def _ffmpeg_bin() -> str:
"""Prefer system ffmpeg; fallback to imageio-ffmpeg bundled binary."""
found = shutil.which("ffmpeg")
if found:
return found
if imageio_ffmpeg is not None:
try:
return imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
pass
return "ffmpeg"
def _ffprobe_bin() -> str:
"""Prefer system ffprobe; fallback to imageio-ffmpeg bundled binary if available."""
found = shutil.which("ffprobe")
if found:
return found
# imageio-ffmpeg only guarantees ffmpeg; most builds include ffprobe alongside.
if imageio_ffmpeg is not None:
try:
ffmpeg_path = imageio_ffmpeg.get_ffmpeg_exe()
candidate = os.path.join(os.path.dirname(ffmpeg_path), "ffprobe.exe")
if os.path.exists(candidate):
return candidate
candidate = os.path.join(os.path.dirname(ffmpeg_path), "ffprobe")
if os.path.exists(candidate):
return candidate
except Exception:
pass
return "ffprobe"
SING_BASE_MODEL = "wan2.5-i2v-preview" SING_BASE_MODEL = "wan2.5-i2v-preview"
SING_BASE_RESOLUTION = "480P" SING_BASE_RESOLUTION = "480P"
SING_WAN26_MODEL = "wan2.6-i2v-flash" SING_WAN26_MODEL = "wan2.6-i2v-flash"
@ -329,7 +371,7 @@ def _ensure_emo_detect_cache(
def _probe_media_duration(path: str) -> Optional[float]: def _probe_media_duration(path: str) -> Optional[float]:
command = [ command = [
"ffprobe", _ffprobe_bin(),
"-v", "-v",
"error", "error",
"-show_entries", "-show_entries",
@ -359,7 +401,7 @@ def _probe_media_duration(path: str) -> Optional[float]:
def _run_ffmpeg_merge(video_path: str, audio_path: str, output_path: str): def _run_ffmpeg_merge(video_path: str, audio_path: str, output_path: str):
audio_duration = _probe_media_duration(audio_path) audio_duration = _probe_media_duration(audio_path)
command = [ command = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -409,7 +451,7 @@ def _strip_video_audio(video_bytes: bytes) -> bytes:
with open(input_path, "wb") as file_handle: with open(input_path, "wb") as file_handle:
file_handle.write(video_bytes) file_handle.write(video_bytes)
command = [ command = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -431,7 +473,7 @@ def _strip_video_audio(video_bytes: bytes) -> bytes:
raise HTTPException(status_code=500, detail="ffmpeg 未安装或不可用") from exc raise HTTPException(status_code=500, detail="ffmpeg 未安装或不可用") from exc
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
fallback = [ fallback = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -483,7 +525,7 @@ def _extract_audio_segment(
output_path: str, output_path: str,
): ):
command = [ command = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -523,7 +565,7 @@ def _pad_audio_segment(
if pad_sec <= 0: if pad_sec <= 0:
return return
command = [ command = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -555,7 +597,7 @@ def _pad_audio_segment(
def _trim_video_duration(input_path: str, target_duration_sec: float, output_path: str): def _trim_video_duration(input_path: str, target_duration_sec: float, output_path: str):
command = [ command = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -579,7 +621,7 @@ def _trim_video_duration(input_path: str, target_duration_sec: float, output_pat
pass pass
fallback = [ fallback = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -632,7 +674,7 @@ def _concat_video_files(video_paths: list[str], output_path: str):
for path in video_paths: for path in video_paths:
list_file.write(f"file '{path}'\n") list_file.write(f"file '{path}'\n")
command = [ command = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -653,7 +695,7 @@ def _concat_video_files(video_paths: list[str], output_path: str):
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
fallback = [ fallback = [
"ffmpeg", _ffmpeg_bin(),
"-y", "-y",
"-loglevel", "-loglevel",
"error", "error",
@ -771,8 +813,6 @@ def _submit_emo_video(
ext_bbox: list, ext_bbox: list,
style_level: str, style_level: str,
) -> str: ) -> str:
import logging
logger = logging.getLogger("sing")
if not settings.DASHSCOPE_API_KEY: if not settings.DASHSCOPE_API_KEY:
raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY") raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY")
@ -836,9 +876,46 @@ def _submit_emo_video(
return str(task_id) return str(task_id)
def _update_task_status_in_db(dashscope_task_id: str, status: str, result_url: Optional[str] = None):
"""实时更新数据库中的任务状态"""
from lover.db import SessionLocal
from lover.models import GenerationTask
try:
with SessionLocal() as db:
# 查找对应的 GenerationTask
task = db.query(GenerationTask).filter(
GenerationTask.payload.like(f'%"dashscope_task_id": "{dashscope_task_id}"%')
).first()
if task:
# 映射状态
status_mapping = {
"PENDING": "pending",
"RUNNING": "running",
"SUCCEEDED": "succeeded",
"FAILED": "failed"
}
task_status = status_mapping.get(status, "pending")
task.status = task_status
if result_url:
task.result_url = result_url
# 更新 payload 中的状态
payload = task.payload or {}
payload["dashscope_status"] = status
payload["last_updated"] = time.time()
task.payload = payload
db.commit()
logger.info(f"📊 任务 {task.id} 状态已更新为: {task_status}")
except Exception as e:
logger.error(f"❌ 更新任务状态失败: {e}")
def _poll_video_url(task_id: str, timeout_seconds: int = 360) -> str: def _poll_video_url(task_id: str, timeout_seconds: int = 360) -> str:
import logging
logger = logging.getLogger("sing")
logger.info(f"⏳ 开始轮询 DashScope 任务: {task_id}") logger.info(f"⏳ 开始轮询 DashScope 任务: {task_id}")
headers = {"Authorization": f"Bearer {settings.DASHSCOPE_API_KEY}"} headers = {"Authorization": f"Bearer {settings.DASHSCOPE_API_KEY}"}
@ -872,9 +949,11 @@ def _poll_video_url(task_id: str, timeout_seconds: int = 360) -> str:
or "" or ""
).upper() ).upper()
# 每 5 次15秒记录一次进度 # 每 5 次15秒记录一次进度并更新数据库
if attempts % 5 == 0: if attempts % 5 == 0:
logger.info(f"🔄 轮询任务 {task_id}{attempts} 次,状态: {status_str}") logger.info(f"🔄 轮询任务 {task_id}{attempts} 次,状态: {status_str}")
# 实时更新数据库状态
_update_task_status_in_db(task_id, status_str, None)
if status_str == "SUCCEEDED": if status_str == "SUCCEEDED":
results = output.get("results") or {} results = output.get("results") or {}
@ -888,6 +967,8 @@ def _poll_video_url(task_id: str, timeout_seconds: int = 360) -> str:
logger.error(f"❌ 任务 {task_id} 成功但未返回 URL") logger.error(f"❌ 任务 {task_id} 成功但未返回 URL")
raise HTTPException(status_code=502, detail="视频生成成功但未返回结果 URL") raise HTTPException(status_code=502, detail="视频生成成功但未返回结果 URL")
logger.info(f"✅ 任务 {task_id} 生成成功!") logger.info(f"✅ 任务 {task_id} 生成成功!")
# 立即更新数据库状态为成功
_update_task_status_in_db(task_id, "SUCCEEDED", url)
return url return url
if status_str == "FAILED": if status_str == "FAILED":
code = output.get("code") or data.get("code") code = output.get("code") or data.get("code")
@ -895,6 +976,8 @@ def _poll_video_url(task_id: str, timeout_seconds: int = 360) -> str:
if code: if code:
msg = f"{code}: {msg}" msg = f"{code}: {msg}"
logger.error(f"❌ 任务 {task_id} 生成失败: {msg}") logger.error(f"❌ 任务 {task_id} 生成失败: {msg}")
# 立即更新数据库状态为失败
_update_task_status_in_db(task_id, "FAILED", None)
raise HTTPException(status_code=502, detail=f"视频生成失败: {msg}") raise HTTPException(status_code=502, detail=f"视频生成失败: {msg}")
logger.error(f"⏱️ 任务 {task_id} 轮询超时,共尝试 {attempts}") logger.error(f"⏱️ 任务 {task_id} 轮询超时,共尝试 {attempts}")
@ -1392,8 +1475,6 @@ def _should_enqueue_task(task_id: int) -> bool:
def _enqueue_sing_task(task_id: int): def _enqueue_sing_task(task_id: int):
import logging
logger = logging.getLogger("sing")
# 移除入队日志,只在失败时记录 # 移除入队日志,只在失败时记录
result = sing_task_queue.enqueue_unique(f"sing:{task_id}", _process_sing_task, task_id) result = sing_task_queue.enqueue_unique(f"sing:{task_id}", _process_sing_task, task_id)
return result return result
@ -1546,6 +1627,41 @@ class SingTaskStatusOut(BaseModel):
error_msg: Optional[str] = None error_msg: Optional[str] = None
@router.get("/current", response_model=ApiResponse[Optional[SingTaskStatusOut]])
def get_current_sing_task(
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
):
task = (
db.query(GenerationTask)
.filter(
GenerationTask.user_id == user.id,
GenerationTask.task_type == "video",
GenerationTask.status.in_(["pending", "running"]),
GenerationTask.payload["song_id"].as_integer().isnot(None),
)
.order_by(GenerationTask.id.desc())
.first()
)
if not task:
return success_response(None, msg="暂无进行中的任务")
payload = task.payload or {}
return success_response(
SingTaskStatusOut(
generation_task_id=task.id,
status=task.status,
dashscope_task_id=str(payload.get("dashscope_task_id") or ""),
video_url=task.result_url or payload.get("merged_video_url") or "",
session_id=int(payload.get("session_id") or 0),
user_message_id=int(payload.get("user_message_id") or 0),
lover_message_id=int(payload.get("lover_message_id") or 0),
error_msg=task.error_msg,
),
msg="获取成功",
)
@router.get("/songs", response_model=ApiResponse[SongListResponse]) @router.get("/songs", response_model=ApiResponse[SongListResponse])
def list_songs_for_lover( def list_songs_for_lover(
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -1590,7 +1706,7 @@ def get_sing_history(
if not lover: if not lover:
raise HTTPException(status_code=404, detail="恋人未找到") raise HTTPException(status_code=404, detail="恋人未找到")
# 查询已成功生成的视频 # 查询已成功生成的视频(优先使用 nf_sing_song_video
offset = (page - 1) * size offset = (page - 1) * size
videos = ( videos = (
db.query(SingSongVideo) db.query(SingSongVideo)
@ -1606,19 +1722,162 @@ def get_sing_history(
.all() .all()
) )
result = [] result: list[dict] = []
seen_urls: set[str] = set()
for video in videos: for video in videos:
# 获取歌曲信息 # 获取歌曲信息
song = db.query(SongLibrary).filter(SongLibrary.id == video.song_id).first() song = db.query(SongLibrary).filter(SongLibrary.id == video.song_id).first()
song_title = song.title if song else "未知歌曲" song_title = song.title if song else "未知歌曲"
url = _cdnize(video.merged_video_url)
if url:
seen_urls.add(url)
result.append(
{
"id": video.id,
"song_id": video.song_id,
"song_title": song_title,
"video_url": url,
"created_at": video.created_at.isoformat() if video.created_at else None,
}
)
result.append({ # 兜底:部分情况下任务成功但 nf_sing_song_video 未落库,补查 nf_generation_tasks
"id": video.id, if len(result) < size:
"song_id": video.song_id, remaining = size - len(result)
"song_title": song_title, fallback_tasks = (
"video_url": _cdnize(video.merged_video_url), db.query(GenerationTask)
"created_at": video.created_at.isoformat() if video.created_at else None, .filter(
}) GenerationTask.user_id == user.id,
GenerationTask.lover_id == lover.id,
GenerationTask.task_type == "video",
GenerationTask.status == "succeeded",
GenerationTask.payload["song_id"].as_integer().isnot(None),
GenerationTask.payload["merged_video_url"].as_string().isnot(None),
)
.order_by(GenerationTask.id.desc())
.offset(offset)
.limit(size * 2)
.all()
)
for task in fallback_tasks:
payload = task.payload or {}
song_id = payload.get("song_id")
merged_video_url = payload.get("merged_video_url") or task.result_url
url = _cdnize(merged_video_url) if merged_video_url else ""
if not url or url in seen_urls:
continue
song_title = payload.get("song_title") or "未知歌曲"
if song_id:
song = db.query(SongLibrary).filter(SongLibrary.id == song_id).first()
if song and song.title:
song_title = song.title
result.append(
{
"id": int(task.id),
"song_id": int(song_id) if song_id else 0,
"song_title": song_title,
"video_url": url,
"created_at": task.created_at.isoformat() if task.created_at else None,
}
)
seen_urls.add(url)
remaining -= 1
if remaining <= 0:
break
return success_response(result, msg="获取成功")
@router.post("/retry/{task_id}", response_model=ApiResponse[dict])
def retry_sing_task(
task_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
):
"""手动重试:用于 DashScope 端已成功但本地下载/上传失败导致任务失败的情况。"""
task = (
db.query(GenerationTask)
.filter(
GenerationTask.id == task_id,
GenerationTask.user_id == user.id,
GenerationTask.task_type == "video",
GenerationTask.payload["song_id"].as_integer().isnot(None),
)
.first()
)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
payload = task.payload or {}
dash_id = payload.get("dashscope_task_id")
if not dash_id:
# 唱歌任务通常不会在 GenerationTask.payload 中保存 dashscope_task_id分段任务各自保存
# 此时改为重新入队处理,尽量复用已成功的分段,完成补下载/补写记录。
task.status = "pending"
task.error_msg = None
task.payload = {**payload, "manual_retry": True}
task.updated_at = datetime.utcnow()
db.add(task)
db.commit()
_enqueue_sing_task(int(task.id))
return success_response({"task_id": int(task.id)}, msg="已触发重新下载")
# 标记手动重试(避免前端重复点击导致并发过多)
task.payload = {**payload, "manual_retry": True}
task.updated_at = datetime.utcnow()
db.add(task)
db.commit()
background_tasks.add_task(_retry_finalize_sing_task, int(task.id))
return success_response({"task_id": int(task.id)}, msg="已触发重新下载")
@router.get("/history/all", response_model=ApiResponse[List[dict]])
def get_sing_history_all(
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
page: int = 1,
size: int = 20,
):
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
if not lover:
raise HTTPException(status_code=404, detail="恋人未找到")
offset = (page - 1) * size
tasks = (
db.query(GenerationTask)
.filter(
GenerationTask.user_id == user.id,
GenerationTask.lover_id == lover.id,
GenerationTask.task_type == "video",
GenerationTask.payload["song_id"].as_integer().isnot(None),
)
.order_by(GenerationTask.id.desc())
.offset(offset)
.limit(size)
.all()
)
result: list[dict] = []
for task in tasks:
payload = task.payload or {}
song_id = payload.get("song_id")
merged_video_url = payload.get("merged_video_url") or task.result_url
result.append(
{
"id": int(task.id),
"song_id": int(song_id) if song_id else 0,
"song_title": payload.get("song_title") or "未知歌曲",
"status": task.status,
"video_url": _cdnize(merged_video_url) or "",
"error_msg": task.error_msg,
"created_at": task.created_at.isoformat() if task.created_at else None,
}
)
return success_response(result, msg="获取成功") return success_response(result, msg="获取成功")
@ -1669,8 +1928,6 @@ def _process_sing_task(task_id: int):
""" """
后台处理唱歌视频生成任务分段音频 -> EMO 逐段生成 -> 拼接整曲 后台处理唱歌视频生成任务分段音频 -> EMO 逐段生成 -> 拼接整曲
""" """
import logging
logger = logging.getLogger("sing")
logger.info(f"开始处理唱歌任务 {task_id}") logger.info(f"开始处理唱歌任务 {task_id}")
song_title: str = "" song_title: str = ""
@ -2247,16 +2504,12 @@ def _process_sing_task(task_id: int):
db.add(task_row) db.add(task_row)
db.commit() db.commit()
except HTTPException as exc: except HTTPException as exc:
import logging
logger = logging.getLogger("sing")
logger.error(f"任务 {task_id} 处理失败 (HTTPException): {exc.detail if hasattr(exc, 'detail') else str(exc)}") logger.error(f"任务 {task_id} 处理失败 (HTTPException): {exc.detail if hasattr(exc, 'detail') else str(exc)}")
try: try:
_mark_task_failed(task_id, str(exc.detail) if hasattr(exc, "detail") else str(exc)) _mark_task_failed(task_id, str(exc.detail) if hasattr(exc, "detail") else str(exc))
except Exception as e2: except Exception as e2:
logger.exception(f"标记任务 {task_id} 失败时出错: {e2}") logger.exception(f"标记任务 {task_id} 失败时出错: {e2}")
except Exception as exc: except Exception as exc:
import logging
logger = logging.getLogger("sing")
logger.exception(f"任务 {task_id} 处理失败 (Exception): {exc}") logger.exception(f"任务 {task_id} 处理失败 (Exception): {exc}")
try: try:
_mark_task_failed(task_id, str(exc)[:255]) _mark_task_failed(task_id, str(exc)[:255])
@ -2271,6 +2524,9 @@ def generate_sing_video(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user), user: AuthedUser = Depends(get_current_user),
): ):
logger.info(f"🎤 收到唱歌生成请求: user_id={user.id}, song_id={payload.song_id}")
# 原有代码...
lover = db.query(Lover).filter(Lover.user_id == user.id).first() lover = db.query(Lover).filter(Lover.user_id == user.id).first()
if not lover: if not lover:
raise HTTPException(status_code=404, detail="恋人不存在,请先完成创建流程") raise HTTPException(status_code=404, detail="恋人不存在,请先完成创建流程")
@ -2578,6 +2834,118 @@ def generate_sing_video(
) )
def _ensure_sing_history_record(db: Session, task: GenerationTask) -> None:
"""确保 nf_sing_song_video 中存在该任务对应的成功记录(用于历史列表)。"""
if not task or task.status != "succeeded":
return
payload = task.payload or {}
song_id = payload.get("song_id")
merged_video_url = payload.get("merged_video_url") or task.result_url
if not song_id or not merged_video_url:
return
existing = (
db.query(SingSongVideo)
.filter(
SingSongVideo.generation_task_id == task.id,
SingSongVideo.user_id == task.user_id,
)
.first()
)
if existing and existing.status == "succeeded" and existing.merged_video_url:
return
audio_url = payload.get("audio_url") or ""
audio_hash = payload.get("audio_hash") or ( _hash_text(audio_url) if audio_url else "" )
image_hash = payload.get("image_hash") or ""
ratio = payload.get("ratio") or EMO_RATIO
style_level = payload.get("style_level") or EMO_STYLE_LEVEL
if not existing:
existing = SingSongVideo(
user_id=task.user_id,
lover_id=task.lover_id or 0,
song_id=int(song_id),
audio_url=audio_url or "",
audio_hash=(audio_hash or "")[:64],
image_hash=(image_hash or "")[:64] if image_hash else None,
ratio=ratio,
style_level=style_level,
merged_video_url=merged_video_url,
status="succeeded",
error_msg=None,
generation_task_id=task.id,
created_at=task.created_at or datetime.utcnow(),
updated_at=datetime.utcnow(),
)
else:
existing.song_id = int(song_id)
existing.merged_video_url = merged_video_url
existing.status = "succeeded"
existing.error_msg = None
existing.updated_at = datetime.utcnow()
existing.generation_task_id = task.id
if task.lover_id:
existing.lover_id = task.lover_id
if audio_url:
existing.audio_url = audio_url
if audio_hash:
existing.audio_hash = (audio_hash or "")[:64]
if image_hash:
existing.image_hash = (image_hash or "")[:64]
existing.ratio = ratio
existing.style_level = style_level
db.add(existing)
def _retry_finalize_sing_task(task_id: int) -> None:
"""任务失败但 DashScope 端已成功时,尝试重新下载视频并落库(自愈)。"""
try:
with SessionLocal() as db:
task = (
db.query(GenerationTask)
.filter(GenerationTask.id == task_id)
.with_for_update()
.first()
)
if not task:
return
payload = task.payload or {}
dash_id = payload.get("dashscope_task_id")
if not dash_id:
return
status, dash_video_url, error_msg = _query_dashscope_task_status(dash_id)
if status != "SUCCEEDED" or not dash_video_url:
return
video_bytes = _download_binary(dash_video_url)
object_name = (
f"lover/{task.lover_id}/sing/"
f"{int(time.time())}_{payload.get('song_id') or 'unknown'}.mp4"
)
merged_video_url = _upload_to_oss(video_bytes, object_name)
task.status = "succeeded"
task.result_url = merged_video_url
task.error_msg = None
task.payload = {
**payload,
"merged_video_url": merged_video_url,
"retry_finalized": True,
"dashscope_error": error_msg,
}
task.updated_at = datetime.utcnow()
db.add(task)
_ensure_sing_history_record(db, task)
db.commit()
except Exception:
return
@router.get("/generate/{task_id}", response_model=ApiResponse[SingTaskStatusOut]) @router.get("/generate/{task_id}", response_model=ApiResponse[SingTaskStatusOut])
def get_sing_task( def get_sing_task(
task_id: int, task_id: int,
@ -2612,11 +2980,7 @@ def get_sing_task(
merge_id = payload.get("merge_id") merge_id = payload.get("merge_id")
if merge_id: if merge_id:
with SessionLocal() as tmp: with SessionLocal() as tmp:
merge = ( merge = tmp.query(SingSongVideo).filter(SingSongVideo.id == merge_id).first()
tmp.query(SingSongVideo)
.filter(SingSongVideo.id == merge_id)
.first()
)
if merge and merge.status == "succeeded" and merge.merged_video_url: if merge and merge.status == "succeeded" and merge.merged_video_url:
current = ( current = (
tmp.query(GenerationTask) tmp.query(GenerationTask)
@ -2636,7 +3000,6 @@ def get_sing_task(
"content_safety_blocked": content_safety_blocked, "content_safety_blocked": content_safety_blocked,
} }
current.updated_at = datetime.utcnow() current.updated_at = datetime.utcnow()
# 更新聊天占位消息与扣减(若未扣)
try: try:
lover_msg_id = (current.payload or {}).get("lover_message_id") lover_msg_id = (current.payload or {}).get("lover_message_id")
session_id = (current.payload or {}).get("session_id") session_id = (current.payload or {}).get("session_id")
@ -2688,8 +3051,21 @@ def get_sing_task(
tmp.commit() tmp.commit()
task = current task = current
resp_msg = status_msg_map.get(task.status or "", resp_msg) # 自愈:成功任务但历史缺记录,补写 nf_sing_song_video
if task.status == "succeeded":
try:
_ensure_sing_history_record(db, task)
db.commit()
except Exception:
db.rollback()
# 自愈:失败任务但 DashScope 可能已成功(下载/上传失败导致),后台重试一次
if task.status == "failed":
payload = task.payload or {}
if payload.get("dashscope_task_id") and not payload.get("retry_finalized"):
background_tasks.add_task(_retry_finalize_sing_task, int(task.id))
resp_msg = status_msg_map.get(task.status or "", resp_msg)
payload = task.payload or {} payload = task.payload or {}
return success_response( return success_response(
SingTaskStatusOut( SingTaskStatusOut(
@ -2704,3 +3080,4 @@ def get_sing_task(
), ),
msg=resp_msg, msg=resp_msg,
) )

View File

@ -18,7 +18,7 @@ def get_user_basic(user: AuthedUser = Depends(get_current_user)):
} }
} }
@router.get("/user_basic/get_hobbies") @router.get("/api/user_basic/get_hobbies")
def get_hobbies(user: AuthedUser = Depends(get_current_user)): def get_hobbies(user: AuthedUser = Depends(get_current_user)):
"""获取用户兴趣爱好""" """获取用户兴趣爱好"""
return success_response([ return success_response([

View File

@ -115,11 +115,13 @@ export default {
}, },
async friend() { async friend() {
const res = await Friend(this.form) const res = await Friend(this.form)
console.log('Friend 接口返回:', res)
let data = '' let data = ''
let ids = '' let ids = ''
let onlineData = '' let onlineData = ''
if (res.code == 1) { if (res.code == 1) {
data = res.data.data data = res.data.data || []
console.log('解析后的 data:', data)
ids = data.map(item => { ids = data.map(item => {
return item.friend_id return item.friend_id
}); });

View File

@ -197,7 +197,10 @@
<scroll-view class="feature-scroll" scroll-y="true"> <scroll-view class="feature-scroll" scroll-y="true">
<!-- 历史视频 --> <!-- 历史视频 -->
<view class="history-section" v-if="singHistoryList.length > 0"> <view class="history-section" v-if="singHistoryList.length > 0">
<view class="section-title" @click="openHistoryModal('sing')">历史视频</view> <view class="history-section-header">
<view class="section-title">历史视频</view>
<view class="section-more" @click="openHistoryModal('sing', 'all')">查看全部</view>
</view>
<view class="history-list"> <view class="history-list">
<view class="history-item" v-for="(item,index) in singHistoryList.slice(0, 2)" :key="'history-'+index"> <view class="history-item" v-for="(item,index) in singHistoryList.slice(0, 2)" :key="'history-'+index">
<view class="history-info"> <view class="history-info">
@ -233,7 +236,10 @@
<view class="feature-desc">让她为你跳一支舞</view> <view class="feature-desc">让她为你跳一支舞</view>
<scroll-view class="feature-scroll" scroll-y="true"> <scroll-view class="feature-scroll" scroll-y="true">
<view class="history-section" v-if="danceHistoryList.length > 0"> <view class="history-section" v-if="danceHistoryList.length > 0">
<view class="section-title" @click="openHistoryModal('dance')">历史视频</view> <view class="history-section-header">
<view class="section-title">历史视频</view>
<view class="section-more" @click="openHistoryModal('dance', 'all')">查看全部</view>
</view>
<view class="history-list"> <view class="history-list">
<view class="history-item" v-for="(item,index) in danceHistoryList.slice(0, 2)" :key="'dance-history-'+index"> <view class="history-item" v-for="(item,index) in danceHistoryList.slice(0, 2)" :key="'dance-history-'+index">
<view class="history-info"> <view class="history-info">
@ -358,7 +364,7 @@
<view v-if="historyModalVisible" class="modal-mask" @click="closeHistoryModal"> <view v-if="historyModalVisible" class="modal-mask" @click="closeHistoryModal">
<view class="modal-card" @click.stop> <view class="modal-card" @click.stop>
<view class="modal-header"> <view class="modal-header">
<view class="modal-title">{{ historyModalType === 'dance' ? '跳舞历史视频' : '唱歌历史视频' }}</view> <view class="modal-title">{{ historyModalType === 'dance' ? (historyModalMode === 'all' ? '跳舞全部历史' : '跳舞历史视频') : (historyModalMode === 'all' ? '唱歌全部历史' : '唱歌历史视频') }}</view>
<view class="modal-close" @click="closeHistoryModal">关闭</view> <view class="modal-close" @click="closeHistoryModal">关闭</view>
</view> </view>
<scroll-view class="modal-list" scroll-y="true"> <scroll-view class="modal-list" scroll-y="true">
@ -366,9 +372,11 @@
<view class="modal-item-info"> <view class="modal-item-info">
<text class="modal-item-title">{{ historyModalType === 'dance' ? (item.prompt || '跳舞视频') : (item.song_title || '唱歌视频') }}</text> <text class="modal-item-title">{{ historyModalType === 'dance' ? (item.prompt || '跳舞视频') : (item.song_title || '唱歌视频') }}</text>
<text class="modal-item-date">{{ formatDate(item.created_at) }}</text> <text class="modal-item-date">{{ formatDate(item.created_at) }}</text>
<text v-if="historyModalMode === 'all'" class="modal-item-status">{{ formatHistoryStatus(item) }}</text>
</view> </view>
<view class="modal-item-actions"> <view class="modal-item-actions">
<view class="modal-play" @click="openVideoPlayer(item.video_url)">播放</view> <view v-if="item.video_url" class="modal-play" @click="openVideoPlayer(item.video_url)">播放</view>
<view v-if="historyModalMode === 'all' && item.status === 'failed'" class="modal-retry" @click="retryHistoryItem(item)">重新下载</view>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
@ -384,6 +392,20 @@
<video class="modal-video" :src="videoPlayerUrl" controls></video> <video class="modal-video" :src="videoPlayerUrl" controls></video>
</view> </view>
</view> </view>
<view v-if="singGenerating && currentTab === 2" class="global-block-mask" @touchmove.stop.prevent>
<view class="global-block-card">
<view class="global-block-title">正在生成视频中</view>
<view class="global-block-sub">预计等待15分钟</view>
</view>
</view>
<view v-if="danceGenerating && currentTab === 3" class="global-block-mask" @touchmove.stop.prevent>
<view class="global-block-card">
<view class="global-block-title">正在生成视频中</view>
<view class="global-block-sub">预计等待15分钟</view>
</view>
</view>
</view> </view>
</template> </template>
@ -395,7 +417,10 @@
SingSongs, SingSongs,
SingGenerate, SingGenerate,
SingGenerateTask, SingGenerateTask,
SingCurrent,
DanceGenerate, DanceGenerate,
DanceGenerateTask,
DanceCurrent,
SessionInit, SessionInit,
SessionSend SessionSend
} from '@/utils/api.js' } from '@/utils/api.js'
@ -445,6 +470,9 @@
danceHistoryList: [], danceHistoryList: [],
songId: 0, songId: 0,
singGenerating: false, // singGenerating: false, //
singGeneratingTaskId: 0,
danceGenerating: false, //
danceGeneratingTaskId: 0,
statusBarHeight: uni.getWindowInfo().statusBarHeight, statusBarHeight: uni.getWindowInfo().statusBarHeight,
currentStep: 0, currentStep: 0,
chartData: {}, chartData: {},
@ -481,6 +509,7 @@
underAgeEnabled: false, // underAgeEnabled: false, //
historyModalVisible: false, historyModalVisible: false,
historyModalType: 'sing', historyModalType: 'sing',
historyModalMode: 'preview',
historyModalList: [], historyModalList: [],
videoPlayerVisible: false, videoPlayerVisible: false,
videoPlayerUrl: '', videoPlayerUrl: '',
@ -515,6 +544,12 @@
// //
this.getSingSongs(); this.getSingSongs();
this.getDanceHistory(); this.getDanceHistory();
if (this.currentTab === 2) {
this.restoreSingGeneration();
}
if (this.currentTab === 3) {
this.restoreDanceGeneration();
}
}, },
methods: { methods: {
// Tab // Tab
@ -534,6 +569,12 @@
this.currentTab = index; this.currentTab = index;
this.updateTabIntoView(index); this.updateTabIntoView(index);
if (index === 2) {
this.restoreSingGeneration();
}
if (index === 3) {
this.restoreDanceGeneration();
}
}, },
onSwiperChange(e) { onSwiperChange(e) {
console.log('Swiper 滑动,当前索引:', e.detail.current, '对应 Tab:', this.tabs[e.detail.current].name); console.log('Swiper 滑动,当前索引:', e.detail.current, '对应 Tab:', this.tabs[e.detail.current].name);
@ -544,6 +585,12 @@
if (e.detail.current === 1 && !this.chatSessionId) { if (e.detail.current === 1 && !this.chatSessionId) {
this.initChatSession(); this.initChatSession();
} }
if (e.detail.current === 2) {
this.restoreSingGeneration();
}
if (e.detail.current === 3) {
this.restoreDanceGeneration();
}
}, },
updateTabIntoView(index) { updateTabIntoView(index) {
this.$nextTick(() => { this.$nextTick(() => {
@ -564,44 +611,120 @@
this.songId = song.id; this.songId = song.id;
this.singGenerating = true; this.singGenerating = true;
uni.showLoading({ this.singGeneratingTaskId = 0;
title: '正在生成视频...', uni.setStorageSync('singGeneratingTaskId', 0);
mask: true // uni.setStorageSync('singGenerating', true);
});
SingGenerate({ song_id: song.id }).then(res => { SingGenerate({ song_id: song.id }).then(res => {
if (res.code == 1) { if (res.code == 1) {
this.singGeneratingTaskId = res.data.generation_task_id;
uni.setStorageSync('singGeneratingTaskId', this.singGeneratingTaskId);
this.getSingGenerateTask(res.data.generation_task_id); this.getSingGenerateTask(res.data.generation_task_id);
} else { } else {
this.singGenerating = false; this.singGenerating = false;
uni.hideLoading(); this.singGeneratingTaskId = 0;
uni.setStorageSync('singGenerating', false);
uni.setStorageSync('singGeneratingTaskId', 0);
uni.showToast({ title: res.msg, icon: 'none' }); uni.showToast({ title: res.msg, icon: 'none' });
} }
}).catch(err => { }).catch(err => {
this.singGenerating = false; this.singGenerating = false;
uni.hideLoading(); this.singGeneratingTaskId = 0;
uni.setStorageSync('singGenerating', false);
uni.setStorageSync('singGeneratingTaskId', 0);
uni.showToast({ uni.showToast({
title: '请求失败,请重试', title: '请求失败,请重试',
icon: 'none' icon: 'none'
}); });
}); });
}, },
restoreSingGeneration() {
if (this.currentTab !== 2) {
return;
}
if (!uni.getStorageSync('token')) {
return;
}
if (this.singGenerating) {
return;
}
const storedTaskId = parseInt(uni.getStorageSync('singGeneratingTaskId') || 0);
const storedFlag = !!uni.getStorageSync('singGenerating');
if (storedFlag && storedTaskId > 0) {
this.singGenerating = true;
this.singGeneratingTaskId = storedTaskId;
this.getSingGenerateTask(storedTaskId);
return;
}
SingCurrent({}).then(res => {
if (res && res.code == 1 && res.data && res.data.generation_task_id) {
this.singGenerating = true;
this.singGeneratingTaskId = res.data.generation_task_id;
uni.setStorageSync('singGenerating', true);
uni.setStorageSync('singGeneratingTaskId', this.singGeneratingTaskId);
this.getSingGenerateTask(this.singGeneratingTaskId);
}
}).catch(() => {});
},
restoreDanceGeneration() {
if (this.currentTab !== 3) {
return;
}
if (!uni.getStorageSync('token')) {
return;
}
if (this.danceGenerating) {
return;
}
const storedTaskId = parseInt(uni.getStorageSync('danceGeneratingTaskId') || 0);
const storedFlag = !!uni.getStorageSync('danceGenerating');
if (storedFlag && storedTaskId > 0) {
this.danceGenerating = true;
this.danceGeneratingTaskId = storedTaskId;
this.getDanceGenerateTask(storedTaskId);
return;
}
DanceCurrent({}).then(res => {
if (res && res.code == 1 && res.data && res.data.generation_task_id) {
this.danceGenerating = true;
this.danceGeneratingTaskId = res.data.generation_task_id;
uni.setStorageSync('danceGenerating', true);
uni.setStorageSync('danceGeneratingTaskId', this.danceGeneratingTaskId);
this.getDanceGenerateTask(this.danceGeneratingTaskId);
}
}).catch(() => {});
},
// //
requestDance() { requestDance() {
if (!this.dancePrompt || !this.dancePrompt.trim()) { if (!this.dancePrompt || !this.dancePrompt.trim()) {
uni.showToast({ title: '请输入舞蹈描述', icon: 'none' }); this.dancePrompt = '跳一段可爱的舞蹈';
}
if (this.danceGenerating) {
uni.showToast({ title: '视频生成中,请稍候...', icon: 'none', duration: 2000 });
return; return;
} }
uni.showLoading({ title: '生成中...' }); this.danceGenerating = true;
DanceGenerate({ prompt: this.dancePrompt }).then(res => { this.danceGeneratingTaskId = 0;
uni.setStorageSync('danceGeneratingTaskId', 0);
uni.setStorageSync('danceGenerating', true);
DanceGenerate({ prompt: this.dancePrompt.trim() }).then(res => {
if (res.code == 1) { if (res.code == 1) {
uni.hideLoading(); this.danceGeneratingTaskId = res.data.generation_task_id;
uni.showToast({ title: '生成成功', icon: 'success' }); uni.setStorageSync('danceGeneratingTaskId', this.danceGeneratingTaskId);
this.getDanceHistory(); this.getDanceGenerateTask(this.danceGeneratingTaskId);
} else { } else {
uni.hideLoading(); this.danceGenerating = false;
this.danceGeneratingTaskId = 0;
uni.setStorageSync('danceGenerating', false);
uni.setStorageSync('danceGeneratingTaskId', 0);
uni.showToast({ title: res.msg, icon: 'none' }); uni.showToast({ title: res.msg, icon: 'none' });
} }
}).catch(() => {
this.danceGenerating = false;
this.danceGeneratingTaskId = 0;
uni.setStorageSync('danceGenerating', false);
uni.setStorageSync('danceGeneratingTaskId', 0);
uni.showToast({ title: '请求失败,请重试', icon: 'none' });
}); });
}, },
// //
@ -677,11 +800,75 @@
playDanceVideo(videoUrl) { playDanceVideo(videoUrl) {
this.openVideoPlayer(videoUrl); this.openVideoPlayer(videoUrl);
}, },
openHistoryModal(type) { openHistoryModal(type, mode = 'preview') {
this.historyModalType = type; this.historyModalType = type;
this.historyModalMode = mode;
if (mode === 'all') {
this.fetchAllHistory(type);
return;
}
this.historyModalList = type === 'dance' ? (this.danceHistoryList || []) : (this.singHistoryList || []); this.historyModalList = type === 'dance' ? (this.danceHistoryList || []) : (this.singHistoryList || []);
this.historyModalVisible = true; this.historyModalVisible = true;
}, },
fetchAllHistory(type) {
const endpoint = type === 'dance' ? '/dance/history/all' : '/sing/history/all';
uni.request({
url: baseURLPy + endpoint,
method: 'GET',
header: {
'Authorization': 'Bearer ' + uni.getStorageSync("token")
},
success: (res) => {
if (res.data && res.data.code === 1 && res.data.data) {
this.historyModalList = res.data.data;
} else {
this.historyModalList = [];
}
this.historyModalVisible = true;
},
fail: () => {
this.historyModalList = [];
this.historyModalVisible = true;
}
});
},
isDownloadFailed(item) {
const msg = (item && item.error_msg ? String(item.error_msg) : '') || '';
return msg.indexOf('下载失败') !== -1;
},
formatHistoryStatus(item) {
if (!item) return '';
const status = item.status || '';
if (status === 'succeeded') return '成功';
if (status === 'pending' || status === 'running') return '生成中';
if (status === 'failed') {
if (this.isDownloadFailed(item)) return '文件下载失败';
return '生成失败';
}
return '';
},
retryHistoryItem(item) {
if (!item || !item.id) return;
const id = item.id;
const endpoint = this.historyModalType === 'dance' ? ('/dance/retry/' + id) : ('/sing/retry/' + id);
uni.showLoading({ title: '处理中...', mask: true });
uni.request({
url: baseURLPy + endpoint,
method: 'POST',
header: {
'Authorization': 'Bearer ' + uni.getStorageSync("token")
},
success: () => {
uni.hideLoading();
uni.showToast({ title: '已触发重新下载', icon: 'none', duration: 1500 });
this.fetchAllHistory(this.historyModalType);
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '重试失败', icon: 'none', duration: 1500 });
}
});
},
closeHistoryModal() { closeHistoryModal() {
this.historyModalVisible = false; this.historyModalVisible = false;
}, },
@ -743,13 +930,15 @@
getSingGenerateTask(task_id) { getSingGenerateTask(task_id) {
const that = this; const that = this;
let attempts = 0; let attempts = 0;
const maxAttempts = 60; // 6044 const maxAttempts = 225; // 15 (225*4s)
const doPoll = () => { const doPoll = () => {
attempts++; attempts++;
if (attempts > maxAttempts) { if (attempts > maxAttempts) {
that.singGenerating = false; that.singGenerating = false;
uni.hideLoading(); that.singGeneratingTaskId = 0;
uni.setStorageSync('singGenerating', false);
uni.setStorageSync('singGeneratingTaskId', 0);
uni.showToast({ uni.showToast({
title: '处理超时,请稍后查看', title: '处理超时,请稍后查看',
icon: 'none', icon: 'none',
@ -758,18 +947,14 @@
return; return;
} }
//
uni.showLoading({
title: `生成中... (${attempts}/${maxAttempts})`,
mask: true
});
SingGenerateTask(task_id).then(res => { SingGenerateTask(task_id).then(res => {
if (res.code == 1) { if (res.code == 1) {
const data = res.data; const data = res.data;
if (data.status == 'succeeded') { if (data.status == 'succeeded') {
that.singGenerating = false; that.singGenerating = false;
uni.hideLoading(); that.singGeneratingTaskId = 0;
uni.setStorageSync('singGenerating', false);
uni.setStorageSync('singGeneratingTaskId', 0);
uni.showToast({ uni.showToast({
title: '生成成功!', title: '生成成功!',
icon: 'success', icon: 'success',
@ -778,7 +963,9 @@
// //
} else if (data.status == 'failed') { } else if (data.status == 'failed') {
that.singGenerating = false; that.singGenerating = false;
uni.hideLoading(); that.singGeneratingTaskId = 0;
uni.setStorageSync('singGenerating', false);
uni.setStorageSync('singGeneratingTaskId', 0);
uni.showToast({ uni.showToast({
title: data.error_msg || '生成失败', title: data.error_msg || '生成失败',
icon: 'none', icon: 'none',
@ -786,11 +973,17 @@
}); });
} else { } else {
// //
that.singGenerating = true;
that.singGeneratingTaskId = task_id;
uni.setStorageSync('singGenerating', true);
uni.setStorageSync('singGeneratingTaskId', task_id);
setTimeout(doPoll, 4000); setTimeout(doPoll, 4000);
} }
} else { } else {
that.singGenerating = false; that.singGenerating = false;
uni.hideLoading(); that.singGeneratingTaskId = 0;
uni.setStorageSync('singGenerating', false);
uni.setStorageSync('singGeneratingTaskId', 0);
uni.showToast({ uni.showToast({
title: res.msg, title: res.msg,
icon: 'none', icon: 'none',
@ -799,7 +992,9 @@
} }
}).catch(err => { }).catch(err => {
that.singGenerating = false; that.singGenerating = false;
uni.hideLoading(); that.singGeneratingTaskId = 0;
uni.setStorageSync('singGenerating', false);
uni.setStorageSync('singGeneratingTaskId', 0);
uni.showToast({ uni.showToast({
title: '查询失败,请重试', title: '查询失败,请重试',
icon: 'none', icon: 'none',
@ -810,6 +1005,60 @@
doPoll(); doPoll();
}, },
getDanceGenerateTask(task_id) {
const that = this;
let attempts = 0;
const maxAttempts = 225; // 15 (225*4s)
const doPoll = () => {
attempts++;
if (attempts > maxAttempts) {
that.danceGenerating = false;
that.danceGeneratingTaskId = 0;
uni.setStorageSync('danceGenerating', false);
uni.setStorageSync('danceGeneratingTaskId', 0);
uni.showToast({ title: '处理超时,请稍后查看', icon: 'none', duration: 2000 });
return;
}
DanceGenerateTask(task_id).then(res => {
if (res.code == 1) {
const data = res.data;
if (data.status == 'succeeded') {
that.danceGenerating = false;
that.danceGeneratingTaskId = 0;
uni.setStorageSync('danceGenerating', false);
uni.setStorageSync('danceGeneratingTaskId', 0);
uni.showToast({ title: '生成成功!', icon: 'success', duration: 2000 });
that.getDanceHistory();
} else if (data.status == 'failed') {
that.danceGenerating = false;
that.danceGeneratingTaskId = 0;
uni.setStorageSync('danceGenerating', false);
uni.setStorageSync('danceGeneratingTaskId', 0);
uni.showToast({ title: data.error_msg || '生成失败', icon: 'none', duration: 2000 });
} else {
that.danceGenerating = true;
that.danceGeneratingTaskId = task_id;
uni.setStorageSync('danceGenerating', true);
uni.setStorageSync('danceGeneratingTaskId', task_id);
setTimeout(doPoll, 4000);
}
} else {
that.danceGenerating = false;
that.danceGeneratingTaskId = 0;
uni.setStorageSync('danceGenerating', false);
uni.setStorageSync('danceGeneratingTaskId', 0);
uni.showToast({ title: res.msg, icon: 'none', duration: 2000 });
}
}).catch(() => {
that.danceGenerating = false;
that.danceGeneratingTaskId = 0;
uni.setStorageSync('danceGenerating', false);
uni.setStorageSync('danceGeneratingTaskId', 0);
uni.showToast({ title: '查询失败,请重试', icon: 'none', duration: 2000 });
});
};
doPoll();
},
// //
handleUnderAgeStatusChange(status) { handleUnderAgeStatusChange(status) {
this.underAgeEnabled = status; this.underAgeEnabled = status;
@ -1269,6 +1518,41 @@
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%), #FFFFFF; background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%), #FFFFFF;
} }
.global-block-mask {
position: fixed;
top: calc(80rpx + var(--status-bar-height));
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
}
.global-block-card {
width: 520rpx;
padding: 36rpx 28rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 16rpx;
text-align: center;
}
.global-block-title {
font-size: 34rpx;
font-weight: 600;
color: #111111;
line-height: 48rpx;
}
.global-block-sub {
margin-top: 14rpx;
font-size: 26rpx;
color: #666666;
line-height: 38rpx;
}
.backA { .backA {
position: absolute; position: absolute;
top: 0; top: 0;
@ -1696,12 +1980,28 @@
box-sizing: border-box; box-sizing: border-box;
} }
.history-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.section-title { .section-title {
font-size: 32rpx; font-size: 32rpx;
font-weight: bold; font-weight: bold;
color: #333; color: #333;
margin-bottom: 20rpx; margin-bottom: 0;
padding-left: 10rpx; padding-left: 0;
}
.section-more {
font-size: 26rpx;
color: #0053FA;
padding: 8rpx 16rpx;
border-radius: 20rpx;
background: rgba(0, 83, 250, 0.08);
} }
.history-list { .history-list {
@ -1806,6 +2106,7 @@
text-align: center; text-align: center;
border-radius: 50rpx; border-radius: 50rpx;
font-size: 30rpx; font-size: 30rpx;
box-sizing: border-box;
} }
/* 商城列表样式 */ /* 商城列表样式 */
@ -2429,20 +2730,31 @@
background: #ffffff; background: #ffffff;
border-radius: 16rpx; border-radius: 16rpx;
overflow: hidden; overflow: hidden;
box-sizing: border-box;
} }
.modal-header { .modal-header {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
padding: 24rpx; padding: 24rpx;
border-bottom: 1px solid rgba(0,0,0,0.06); border-bottom: 1px solid rgba(0,0,0,0.06);
} }
.modal-title { .modal-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 30rpx; font-size: 30rpx;
font-weight: 600; font-weight: 600;
color: #333333; color: #333333;
} }
.modal-close { .modal-close {
position: absolute;
right: 24rpx;
font-size: 26rpx; font-size: 26rpx;
color: #666666; color: #666666;
padding: 6rpx 12rpx; padding: 6rpx 12rpx;
@ -2450,11 +2762,13 @@
.modal-list { .modal-list {
max-height: 60vh; max-height: 60vh;
padding: 12rpx 24rpx 24rpx; padding: 12rpx 24rpx 24rpx;
box-sizing: border-box;
} }
.modal-item { .modal-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
gap: 12rpx;
padding: 16rpx 0; padding: 16rpx 0;
border-bottom: 1px solid rgba(0,0,0,0.06); border-bottom: 1px solid rgba(0,0,0,0.06);
} }
@ -2462,29 +2776,65 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
min-width: 0;
padding-right: 12rpx; padding-right: 12rpx;
} }
.modal-item-title { .modal-item-title {
font-size: 28rpx; font-size: 28rpx;
color: #333333; color: #333333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-retry {
min-width: 112rpx;
max-width: 140rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 24rpx;
color: #ffffff;
background: #1DB954;
padding: 10rpx 16rpx;
border-radius: 12rpx;
text-align: center;
box-sizing: border-box;
margin-left: 12rpx;
} }
.modal-item-date { .modal-item-date {
font-size: 22rpx; font-size: 22rpx;
color: #999999; color: #999999;
margin-top: 6rpx; margin-top: 6rpx;
} }
.modal-item-status {
font-size: 22rpx;
color: #666666;
margin-top: 6rpx;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-item-actions { .modal-item-actions {
width: 120rpx; flex: 0 0 auto;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-left: auto;
padding-left: 12rpx;
} }
.modal-play { .modal-play {
min-width: 112rpx;
max-width: 140rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 24rpx; font-size: 24rpx;
color: #ffffff; color: #ffffff;
background: #F661B5; background: #F661B5;
padding: 10rpx 16rpx; padding: 10rpx 16rpx;
border-radius: 12rpx; border-radius: 12rpx;
text-align: center; text-align: center;
box-sizing: border-box;
} }
.modal-video { .modal-video {
width: 100%; width: 100%;

View File

@ -1,5 +1,3 @@
import { import {
request request
} from '@/utils/request.js' } from '@/utils/request.js'
@ -9,18 +7,18 @@ export const getTokenApi = (data) => request({
url: '/api/huanxin/getToken', url: '/api/huanxin/getToken',
method: 'post', method: 'post',
data: data data: data
}) }, 2) // 使用 FastAPI
// 获取环信在线 // 获取环信在线
export const getOnlineApi = (data) => request({ export const getOnlineApi = (data) => request({
url: '/api/huanxin/online', url: '/api/huanxin/online',
method: 'post', method: 'post',
data: data data: data
}) }, 2) // 使用 FastAPI
// 发送环信消息 // 发送环信消息
export const sendMessageApi = (data) => request({ export const sendMessageApi = (data) => request({
url: '/api/huanxin/send', url: '/api/huanxin/send',
method: 'post', method: 'post',
data: data data: data
}) }, 2) // 使用 FastAPI

View File

@ -355,13 +355,19 @@ export const DanceGenerate = (data) => request({
url: '/dance/generate', url: '/dance/generate',
method: 'post', method: 'post',
data: data data: data
},2)//跳舞 },2,false)//跳舞
export const DanceGenerateTask = (id) => request({ export const DanceGenerateTask = (id) => request({
url: `/dance/generate/${id}`, url: `/dance/generate/${id}`,
method: 'get', method: 'get',
},2,false)//监听生成视频结果 },2,false)//监听生成视频结果
export const DanceCurrent = (data) => request({
url: '/dance/current',
method: 'get',
data: data
},2,false)//获取当前进行中的跳舞任务
export const SingSongs = (data) => request({ export const SingSongs = (data) => request({
url: '/sing/songs', url: '/sing/songs',
method: 'get', method: 'get',
@ -379,6 +385,12 @@ export const SingGenerateTask = (id) => request({
method: 'get', method: 'get',
},2,false)//监听生成视频结果 },2,false)//监听生成视频结果
export const SingCurrent = (data) => request({
url: '/sing/current',
method: 'get',
data: data
},2,false)//获取当前进行中的唱歌任务
export const DynamicShare = (data) => request({ export const DynamicShare = (data) => request({
url: '/dynamic/share', url: '/dynamic/share',
method: 'post', method: 'post',

View File

@ -1,6 +1,6 @@
// Windows 本地开发 - 混合架构 // Windows 本地开发 - 混合架构
export const baseURL = 'http://192.168.1.164:30100' // PHP 处理界面和部分 API export const baseURL = 'http://192.168.1.164:30100' // PHP 处理用户管理和界面
export const baseURLPy = 'http://192.168.1.164:30101' // FastAPI 处理核心 API export const baseURLPy = 'http://192.168.1.164:30101' // FastAPI 处理 AI 功能
// 远程服务器 - 需要时取消注释 // 远程服务器 - 需要时取消注释
// export const baseURL = 'http://1.15.149.240:30100' // export const baseURL = 'http://1.15.149.240:30100'

View File

@ -1,5 +1,5 @@
app_debug = true app_debug = false
app_trace = true app_trace = false
[database] [database]
type = mysql type = mysql