配置开发环境
This commit is contained in:
commit
19bd5910ad
4
.env
Normal file
4
.env
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
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
|
||||||
|
APP_ENV=development
|
||||||
|
DEBUG=True
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
归档/
|
||||||
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{
|
||||||
|
}
|
||||||
106
create_tables.py
Normal file
106
create_tables.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""直接用 SQL 创建必要的表"""
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
SQL_STATEMENTS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS `nf_lovers` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` bigint NOT NULL,
|
||||||
|
`name` varchar(64) DEFAULT NULL,
|
||||||
|
`gender` enum('male','female') NOT NULL,
|
||||||
|
`intro` text,
|
||||||
|
`story_background` text,
|
||||||
|
`personality_tag` int DEFAULT NULL,
|
||||||
|
`interest_tags` json DEFAULT NULL,
|
||||||
|
`opening_line` text,
|
||||||
|
`personality_prompt` text,
|
||||||
|
`appearance_prompt` text,
|
||||||
|
`appearance_params` json DEFAULT NULL,
|
||||||
|
`hair_style_id` int DEFAULT NULL,
|
||||||
|
`eye_color_id` int DEFAULT NULL,
|
||||||
|
`outfit_desc` varchar(50) DEFAULT NULL,
|
||||||
|
`outfit_top_id` bigint DEFAULT NULL,
|
||||||
|
`outfit_bottom_id` bigint DEFAULT NULL,
|
||||||
|
`outfit_dress_id` bigint DEFAULT NULL,
|
||||||
|
`voice_id` bigint DEFAULT NULL,
|
||||||
|
`image_url` varchar(255) DEFAULT NULL,
|
||||||
|
`last_image_task_id` bigint DEFAULT NULL,
|
||||||
|
`image_gen_used` int DEFAULT '0',
|
||||||
|
`image_gen_limit` int DEFAULT '10',
|
||||||
|
`image_gen_reset_date` date DEFAULT NULL,
|
||||||
|
`init_model` varchar(64) DEFAULT NULL,
|
||||||
|
`init_at` datetime DEFAULT NULL,
|
||||||
|
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `user_id` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `nf_voice_library` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(64) NOT NULL,
|
||||||
|
`gender` enum('male','female') NOT NULL,
|
||||||
|
`style_tag` varchar(32) DEFAULT NULL,
|
||||||
|
`avatar_url` varchar(255) DEFAULT NULL,
|
||||||
|
`sample_audio_url` varchar(255) DEFAULT NULL,
|
||||||
|
`tts_model_id` varchar(64) DEFAULT NULL,
|
||||||
|
`is_default` tinyint(1) DEFAULT '0',
|
||||||
|
`voice_code` varchar(64) NOT NULL,
|
||||||
|
`is_owned` tinyint(1) DEFAULT '1',
|
||||||
|
`price_gold` int DEFAULT '0',
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `nf_girlfriend_mould` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(64) NOT NULL,
|
||||||
|
`gender` enum('male','female') DEFAULT NULL,
|
||||||
|
`weigh` int DEFAULT '0',
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `nf_girlfriend_hobbies` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(64) NOT NULL,
|
||||||
|
`weigh` int DEFAULT '0',
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `nf_girlfriend_hairstyles` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(64) NOT NULL,
|
||||||
|
`weigh` int DEFAULT '0',
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `nf_girlfriend_eyecolor` (
|
||||||
|
`id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` varchar(64) NOT NULL,
|
||||||
|
`weigh` int DEFAULT '0',
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host='127.0.0.1',
|
||||||
|
port=3306,
|
||||||
|
user='root',
|
||||||
|
password='root',
|
||||||
|
database='lover',
|
||||||
|
charset='utf8mb4'
|
||||||
|
)
|
||||||
|
print("✓ 连接数据库成功")
|
||||||
|
|
||||||
|
cursor = conn.cursor()
|
||||||
|
for statement in SQL_STATEMENTS.strip().split(';'):
|
||||||
|
statement = statement.strip()
|
||||||
|
if statement:
|
||||||
|
cursor.execute(statement)
|
||||||
|
print(f"✓ 执行成功")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✓ 所有表创建完成!")
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 失败: {e}")
|
||||||
36
import_sql.py
Normal file
36
import_sql.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""导入 SQL 文件"""
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host='127.0.0.1',
|
||||||
|
port=3306,
|
||||||
|
user='root',
|
||||||
|
password='root',
|
||||||
|
database='lover',
|
||||||
|
charset='utf8mb4'
|
||||||
|
)
|
||||||
|
print("✓ 连接数据库成功")
|
||||||
|
|
||||||
|
with open('xunifriend.sql', 'r', encoding='utf8') as f:
|
||||||
|
sql_content = f.read()
|
||||||
|
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# 分割并执行 SQL 语句
|
||||||
|
statements = sql_content.split(';')
|
||||||
|
for i, statement in enumerate(statements):
|
||||||
|
statement = statement.strip()
|
||||||
|
if statement:
|
||||||
|
try:
|
||||||
|
cursor.execute(statement)
|
||||||
|
if (i + 1) % 100 == 0:
|
||||||
|
print(f"已执行 {i + 1} 条语句...")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"执行语句失败: {str(e)[:100]}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"✓ SQL 导入完成,共执行 {len([s for s in statements if s.strip()])} 条语句")
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 导入失败: {e}")
|
||||||
19
init_db.py
Normal file
19
init_db.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""
|
||||||
|
数据库初始化脚本
|
||||||
|
创建所有必需的表
|
||||||
|
"""
|
||||||
|
from lover.db import engine, Base
|
||||||
|
from lover import models
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""创建所有表"""
|
||||||
|
print("开始创建数据库表...")
|
||||||
|
try:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
print("✓ 数据库表创建完成!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 创建表失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_database()
|
||||||
2
lover/.env
Normal file
2
lover/.env
Normal file
|
|
@ -0,0 +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
|
||||||
1
lover/__init__.py
Normal file
1
lover/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Package marker for FastAPI app
|
||||||
BIN
lover/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
lover/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/config.cpython-314.pyc
Normal file
BIN
lover/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/db.cpython-314.pyc
Normal file
BIN
lover/__pycache__/db.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/deps.cpython-314.pyc
Normal file
BIN
lover/__pycache__/deps.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/llm.cpython-314.pyc
Normal file
BIN
lover/__pycache__/llm.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/main.cpython-314.pyc
Normal file
BIN
lover/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/models.cpython-314.pyc
Normal file
BIN
lover/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/response.cpython-314.pyc
Normal file
BIN
lover/__pycache__/response.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/task_queue.cpython-314.pyc
Normal file
BIN
lover/__pycache__/task_queue.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/tts.cpython-314.pyc
Normal file
BIN
lover/__pycache__/tts.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/__pycache__/vision.cpython-314.pyc
Normal file
BIN
lover/__pycache__/vision.cpython-314.pyc
Normal file
Binary file not shown.
171
lover/config.py
Normal file
171
lover/config.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""
|
||||||
|
应用配置管理模块
|
||||||
|
统一管理所有环境变量和应用配置
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""应用配置类"""
|
||||||
|
|
||||||
|
# ===== 应用基础配置 =====
|
||||||
|
APP_NAME: str = Field(default="LOVER", env="APP_NAME")
|
||||||
|
APP_ENV: str = Field(default="development", env="APP_ENV") # development/production
|
||||||
|
DEBUG: bool = Field(default=True, env="DEBUG")
|
||||||
|
|
||||||
|
# ===== 数据库配置 =====
|
||||||
|
DATABASE_URL: str = Field(
|
||||||
|
default="mysql+pymysql://root:password@localhost:3306/lover?charset=utf8mb4",
|
||||||
|
env="DATABASE_URL",
|
||||||
|
description="数据库连接字符串"
|
||||||
|
)
|
||||||
|
DB_POOL_SIZE: int = Field(default=10, env="DB_POOL_SIZE")
|
||||||
|
DB_POOL_MAX_OVERFLOW: int = Field(default=20, env="DB_POOL_MAX_OVERFLOW")
|
||||||
|
|
||||||
|
# ===== 微信小程序配置 =====
|
||||||
|
WECHAT_APP_ID: str = Field(default="", env="WECHAT_APP_ID")
|
||||||
|
WECHAT_APP_SECRET: str = Field(default="", env="WECHAT_APP_SECRET")
|
||||||
|
|
||||||
|
# ===== JWT/认证配置 =====
|
||||||
|
JWT_SECRET_KEY: str = Field(default="your-secret-key-change-in-production", env="JWT_SECRET_KEY")
|
||||||
|
JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM")
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60*24*7, env="JWT_ACCESS_TOKEN_EXPIRE_MINUTES") # 7天
|
||||||
|
|
||||||
|
# ===== OSS 对象存储配置 (阿里云/腾讯云) =====
|
||||||
|
OSS_PROVIDER: str = Field(default="aliyun", env="OSS_PROVIDER") # aliyun/tencent/aws
|
||||||
|
|
||||||
|
# 阿里云 OSS
|
||||||
|
ALIYUN_OSS_ACCESS_KEY_ID: str = Field(default="", env="ALIYUN_OSS_ACCESS_KEY_ID")
|
||||||
|
ALIYUN_OSS_ACCESS_KEY_SECRET: str = Field(default="", env="ALIYUN_OSS_ACCESS_KEY_SECRET")
|
||||||
|
ALIYUN_OSS_BUCKET_NAME: str = Field(default="", env="ALIYUN_OSS_BUCKET_NAME")
|
||||||
|
ALIYUN_OSS_ENDPOINT: str = Field(default="", env="ALIYUN_OSS_ENDPOINT")
|
||||||
|
ALIYUN_OSS_CDN_DOMAIN: Optional[str] = Field(default=None, env="ALIYUN_OSS_CDN_DOMAIN")
|
||||||
|
|
||||||
|
# 腾讯云 COS
|
||||||
|
TENCENT_COS_SECRET_ID: str = Field(default="", env="TENCENT_COS_SECRET_ID")
|
||||||
|
TENCENT_COS_SECRET_KEY: str = Field(default="", env="TENCENT_COS_SECRET_KEY")
|
||||||
|
TENCENT_COS_BUCKET: str = Field(default="", env="TENCENT_COS_BUCKET")
|
||||||
|
TENCENT_COS_REGION: str = Field(default="", env="TENCENT_COS_REGION")
|
||||||
|
TENCENT_COS_CDN_DOMAIN: Optional[str] = Field(default=None, env="TENCENT_COS_CDN_DOMAIN")
|
||||||
|
|
||||||
|
# ===== AI 大模型配置 =====
|
||||||
|
# LLM 聊天模型
|
||||||
|
LLM_PROVIDER: str = Field(default="openai", env="LLM_PROVIDER") # openai/azure/zhipu/qwen
|
||||||
|
LLM_API_BASE: Optional[str] = Field(default=None, env="LLM_API_BASE")
|
||||||
|
LLM_MODEL: str = Field(default="gpt-3.5-turbo", env="LLM_MODEL")
|
||||||
|
LLM_TEMPERATURE: float = Field(default=0.8, env="LLM_TEMPERATURE")
|
||||||
|
LLM_MAX_TOKENS: int = Field(default=2000, env="LLM_MAX_TOKENS")
|
||||||
|
|
||||||
|
# AI 绘图模型
|
||||||
|
IMAGE_GEN_PROVIDER: str = Field(default="stability", env="IMAGE_GEN_PROVIDER") # stability/midjourney/dalle
|
||||||
|
IMAGE_GEN_API_KEY: str = Field(default="", env="IMAGE_GEN_API_KEY")
|
||||||
|
IMAGE_GEN_API_BASE: Optional[str] = Field(default=None, env="IMAGE_GEN_API_BASE")
|
||||||
|
IMAGE_GEN_MODEL: str = Field(default="wan2.5-t2i-preview", env="IMAGE_GEN_MODEL")
|
||||||
|
IMAGE_GEN_SIZE: Optional[str] = Field(default="960*1280", env="IMAGE_GEN_SIZE") # 宽*高,wan2.5 支持自定义
|
||||||
|
DASHSCOPE_API_KEY: Optional[str] = Field(default=None, env="DASHSCOPE_API_KEY")
|
||||||
|
WAN26_ASYNC: bool = Field(default=True, env="WAN26_ASYNC") # wan2.6 默认使用异步任务
|
||||||
|
VISION_MODEL: str = Field(default="qwen3-vl-flash", env="VISION_MODEL")
|
||||||
|
TRYON_MODEL: str = Field(default="aitryon", env="TRYON_MODEL")
|
||||||
|
IMAGE_QUALITY_MODEL: str = Field(default="animate-anyone-detect-gen2", env="IMAGE_QUALITY_MODEL")
|
||||||
|
VIDEO_GEN_MODEL: str = Field(default="wan2.2-i2v-flash", env="VIDEO_GEN_MODEL")
|
||||||
|
VIDEO_GEN_RESOLUTION: str = Field(default="480P", env="VIDEO_GEN_RESOLUTION")
|
||||||
|
VIDEO_GEN_DURATION: int = Field(default=5, env="VIDEO_GEN_DURATION")
|
||||||
|
SING_MERGE_MAX_CONCURRENCY: int = Field(default=2, env="SING_MERGE_MAX_CONCURRENCY")
|
||||||
|
EMO_MAX_CONCURRENCY: int = Field(default=1, env="EMO_MAX_CONCURRENCY")
|
||||||
|
EMO_MIN_SEGMENT_SECONDS: int = Field(default=6, env="EMO_MIN_SEGMENT_SECONDS")
|
||||||
|
|
||||||
|
# ===== 语音通话(ASR/LLM/TTS) =====
|
||||||
|
VOICE_CALL_ASR_MODEL: str = Field(default="paraformer-realtime-v2", env="VOICE_CALL_ASR_MODEL")
|
||||||
|
VOICE_CALL_ASR_SAMPLE_RATE: int = Field(default=16000, env="VOICE_CALL_ASR_SAMPLE_RATE")
|
||||||
|
VOICE_CALL_TTS_FORMAT: str = Field(default="mp3", env="VOICE_CALL_TTS_FORMAT") # mp3 / pcm
|
||||||
|
VOICE_CALL_MAX_HISTORY: int = Field(default=20, env="VOICE_CALL_MAX_HISTORY")
|
||||||
|
VOICE_CALL_TTS_MODEL: str = Field(default="cosyvoice-v2", env="VOICE_CALL_TTS_MODEL")
|
||||||
|
VOICE_CALL_TTS_VOICE: str = Field(default="longxiaochun_v2", env="VOICE_CALL_TTS_VOICE")
|
||||||
|
VOICE_CALL_IDLE_TIMEOUT: int = Field(default=60, env="VOICE_CALL_IDLE_TIMEOUT") # 秒,无音频则断开
|
||||||
|
VOICE_CALL_REQUIRE_PTT: bool = Field(default=False, env="VOICE_CALL_REQUIRE_PTT") # 是否要求按住说话
|
||||||
|
|
||||||
|
# TTS 语音合成
|
||||||
|
TTS_PROVIDER: str = Field(default="azure", env="TTS_PROVIDER") # azure/aliyun/tencent
|
||||||
|
TTS_API_KEY: str = Field(default="", env="TTS_API_KEY")
|
||||||
|
TTS_REGION: Optional[str] = Field(default=None, env="TTS_REGION")
|
||||||
|
|
||||||
|
# ===== Redis 配置 (可选,用于缓存/队列) =====
|
||||||
|
REDIS_ENABLED: bool = Field(default=False, env="REDIS_ENABLED")
|
||||||
|
REDIS_HOST: str = Field(default="localhost", env="REDIS_HOST")
|
||||||
|
REDIS_PORT: int = Field(default=6379, env="REDIS_PORT")
|
||||||
|
REDIS_PASSWORD: Optional[str] = Field(default=None, env="REDIS_PASSWORD")
|
||||||
|
REDIS_DB: int = Field(default=0, env="REDIS_DB")
|
||||||
|
|
||||||
|
# ===== 短信服务配置 (用于验证码) =====
|
||||||
|
SMS_PROVIDER: str = Field(default="aliyun", env="SMS_PROVIDER") # aliyun/tencent
|
||||||
|
SMS_ACCESS_KEY_ID: str = Field(default="", env="SMS_ACCESS_KEY_ID")
|
||||||
|
SMS_ACCESS_KEY_SECRET: str = Field(default="", env="SMS_ACCESS_KEY_SECRET")
|
||||||
|
SMS_SIGN_NAME: str = Field(default="", env="SMS_SIGN_NAME")
|
||||||
|
SMS_TEMPLATE_CODE: str = Field(default="", env="SMS_TEMPLATE_CODE")
|
||||||
|
|
||||||
|
# ===== 微信支付配置 =====
|
||||||
|
WECHAT_PAY_MCHID: str = Field(default="", env="WECHAT_PAY_MCHID")
|
||||||
|
WECHAT_PAY_API_V3_KEY: str = Field(default="", env="WECHAT_PAY_API_V3_KEY")
|
||||||
|
WECHAT_PAY_SERIAL_NO: str = Field(default="", env="WECHAT_PAY_SERIAL_NO")
|
||||||
|
WECHAT_PAY_PRIVATE_KEY_PATH: Optional[str] = Field(default=None, env="WECHAT_PAY_PRIVATE_KEY_PATH")
|
||||||
|
WECHAT_PAY_NOTIFY_URL: str = Field(default="", env="WECHAT_PAY_NOTIFY_URL")
|
||||||
|
|
||||||
|
# ===== 业务配置 =====
|
||||||
|
# 每日对话限制
|
||||||
|
DAILY_CHAT_LIMIT_FREE: int = Field(default=20, env="DAILY_CHAT_LIMIT_FREE")
|
||||||
|
|
||||||
|
# 初始资源配置
|
||||||
|
INITIAL_IMAGE_GEN_LIMIT: int = Field(default=10, env="INITIAL_IMAGE_GEN_LIMIT")
|
||||||
|
INITIAL_VIDEO_GEN_LIMIT: int = Field(default=3, env="INITIAL_VIDEO_GEN_LIMIT")
|
||||||
|
INITIAL_VOICE_CALL_MINUTES: int = Field(default=5, env="INITIAL_VOICE_CALL_MINUTES")
|
||||||
|
|
||||||
|
# 账户注销冷静期(天)
|
||||||
|
ACCOUNT_DELETE_COOLDOWN_DAYS: int = Field(default=15, env="ACCOUNT_DELETE_COOLDOWN_DAYS")
|
||||||
|
|
||||||
|
# ===== 内容安全配置 =====
|
||||||
|
CONTENT_CHECK_ENABLED: bool = Field(default=True, env="CONTENT_CHECK_ENABLED")
|
||||||
|
|
||||||
|
# ===== 聊天与长记忆配置 =====
|
||||||
|
CHAT_LIMIT_DAILY: int = Field(default=80, env="CHAT_LIMIT_DAILY")
|
||||||
|
CHAT_RECENT_WINDOW: int = Field(default=30, env="CHAT_RECENT_WINDOW")
|
||||||
|
CHAT_SUMMARY_TRIGGER_MSGS: int = Field(default=30, env="CHAT_SUMMARY_TRIGGER_MSGS")
|
||||||
|
CHAT_SUMMARY_TRIGGER_TOKENS: int = Field(default=6000, env="CHAT_SUMMARY_TRIGGER_TOKENS")
|
||||||
|
CHAT_FACT_MAX_ROWS: int = Field(default=50, env="CHAT_FACT_MAX_ROWS")
|
||||||
|
CHAT_CONTEXT_MAX_TOKENS: int = Field(default=12000, env="CHAT_CONTEXT_MAX_TOKENS")
|
||||||
|
CHAT_STREAMING_DEFAULT: bool = Field(default=True, env="CHAT_STREAMING_DEFAULT")
|
||||||
|
INNER_VOICE_DEFAULT: bool = Field(default=False, env="INNER_VOICE_DEFAULT")
|
||||||
|
CHAT_REPLY_MAX_CHARS: int = Field(default=0, env="CHAT_REPLY_MAX_CHARS")
|
||||||
|
|
||||||
|
# ===== 向量数据库配置 (用于 AI 记忆) =====
|
||||||
|
VECTOR_DB_ENABLED: bool = Field(default=False, env="VECTOR_DB_ENABLED")
|
||||||
|
VECTOR_DB_TYPE: str = Field(default="chromadb", env="VECTOR_DB_TYPE") # chromadb/pinecone/milvus
|
||||||
|
VECTOR_DB_HOST: Optional[str] = Field(default=None, env="VECTOR_DB_HOST")
|
||||||
|
VECTOR_DB_PORT: Optional[int] = Field(default=None, env="VECTOR_DB_PORT")
|
||||||
|
|
||||||
|
# ===== 日志配置 =====
|
||||||
|
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
|
||||||
|
LOG_FILE_PATH: Optional[str] = Field(default="logs/app.log", env="LOG_FILE_PATH")
|
||||||
|
|
||||||
|
# 用户信息拉取接口(FastAdmin 提供)
|
||||||
|
USER_INFO_API: str = Field(
|
||||||
|
default="https://xunifriend.shandonghuixing.com/api/user_basic/get_user_basic",
|
||||||
|
env="USER_INFO_API",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=True,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局配置实例
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""获取配置实例"""
|
||||||
|
return settings
|
||||||
83
lover/cosyvoice_clone.py
Normal file
83
lover/cosyvoice_clone.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""
|
||||||
|
CosyVoice voice enrollment helper for cosyvoice-v2.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import dashscope
|
||||||
|
from dashscope.audio.tts_v2 import VoiceEnrollmentService
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
DEFAULT_TARGET_MODEL = "cosyvoice-v2"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_key() -> str:
|
||||||
|
api_key = settings.DASHSCOPE_API_KEY or os.getenv("DASHSCOPE_API_KEY", "")
|
||||||
|
if not api_key:
|
||||||
|
raise RuntimeError("DASHSCOPE_API_KEY is not set")
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
def create_voice_from_url(
|
||||||
|
audio_url: str,
|
||||||
|
prefix: str,
|
||||||
|
target_model: str = DEFAULT_TARGET_MODEL,
|
||||||
|
) -> str:
|
||||||
|
"""Create a cloned voice and return voice_id."""
|
||||||
|
dashscope.api_key = _get_api_key()
|
||||||
|
service = VoiceEnrollmentService()
|
||||||
|
return service.create_voice(
|
||||||
|
target_model=target_model,
|
||||||
|
prefix=prefix,
|
||||||
|
url=audio_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def query_voice(voice_id: str) -> Dict[str, Any]:
|
||||||
|
"""Query voice status/details by voice_id."""
|
||||||
|
dashscope.api_key = _get_api_key()
|
||||||
|
service = VoiceEnrollmentService()
|
||||||
|
return service.query_voice(voice_id=voice_id)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_voice_ready(
|
||||||
|
voice_id: str,
|
||||||
|
*,
|
||||||
|
timeout_sec: int = 300,
|
||||||
|
poll_interval: int = 10,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Poll until voice status becomes OK or UNDEPLOYED, or timeout."""
|
||||||
|
deadline = time.time() + timeout_sec
|
||||||
|
last: Dict[str, Any] = {}
|
||||||
|
while time.time() < deadline:
|
||||||
|
last = query_voice(voice_id)
|
||||||
|
status = (last or {}).get("status")
|
||||||
|
if status in ("OK", "UNDEPLOYED"):
|
||||||
|
return last
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
raise TimeoutError("Voice is not ready before timeout")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="CosyVoice clone helper")
|
||||||
|
parser.add_argument("--url", required=True, help="Public audio URL")
|
||||||
|
parser.add_argument("--prefix", required=True, help="Voice name prefix (<=10 chars)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--target-model",
|
||||||
|
default=DEFAULT_TARGET_MODEL,
|
||||||
|
help="Target TTS model for the cloned voice",
|
||||||
|
)
|
||||||
|
parser.add_argument("--timeout", type=int, default=300)
|
||||||
|
parser.add_argument("--poll", type=int, default=10)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
vid = create_voice_from_url(args.url, args.prefix, args.target_model)
|
||||||
|
print(f"voice_id: {vid}")
|
||||||
|
info = wait_voice_ready(vid, timeout_sec=args.timeout, poll_interval=args.poll)
|
||||||
|
print(f"status: {info.get('status')}")
|
||||||
43
lover/db.py
Normal file
43
lover/db.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
DATABASE_URL = settings.DATABASE_URL
|
||||||
|
|
||||||
|
connect_args = {}
|
||||||
|
if DATABASE_URL.startswith("mysql+pymysql://"):
|
||||||
|
connect_args["connect_timeout"] = 5
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
pool_size=settings.DB_POOL_SIZE if settings.DB_POOL_SIZE else 5,
|
||||||
|
max_overflow=settings.DB_POOL_MAX_OVERFLOW if settings.DB_POOL_MAX_OVERFLOW else 10,
|
||||||
|
pool_timeout=30,
|
||||||
|
future=True,
|
||||||
|
connect_args=connect_args,
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, future=True)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
db.commit()
|
||||||
|
except OperationalError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Database unavailable, please check DATABASE_URL / MySQL service.",
|
||||||
|
) from exc
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
117
lover/deps.py
Normal file
117
lover/deps.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import Header, HTTPException, status, Cookie
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class AuthedUser(BaseModel):
|
||||||
|
id: int
|
||||||
|
reg_step: int = 1
|
||||||
|
gender: int = 0 # 0/1/2
|
||||||
|
nickname: str = ""
|
||||||
|
token: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_user_from_php(token: str) -> Optional[dict]:
|
||||||
|
"""通过 PHP/FastAdmin 接口获取用户信息。"""
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
settings.USER_INFO_API,
|
||||||
|
headers={"token": token},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception as exc: # 网络/超时
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="用户中心接口不可用",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户中心返回非 200",
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
# 兼容常见 FastAdmin 响应结构
|
||||||
|
if isinstance(data, dict):
|
||||||
|
payload = data.get("data") or data.get("user") or data
|
||||||
|
else:
|
||||||
|
payload = None
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户中心未返回用户信息",
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
authorization: Optional[str] = Header(default=None),
|
||||||
|
x_token: Optional[str] = Header(default=None, alias="X-Token"),
|
||||||
|
token_header: Optional[str] = Header(default=None, alias="token"),
|
||||||
|
token_cookie: Optional[str] = Cookie(default=None, alias="token"),
|
||||||
|
x_user_id: Optional[int] = Header(default=None, alias="X-User-Id"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
鉴权顺序:
|
||||||
|
1) Authorization: Bearer <token>
|
||||||
|
2) X-Token: <token>
|
||||||
|
3) token: <token> (header)
|
||||||
|
4) token: <token> (cookie)
|
||||||
|
5) X-User-Id(仅调试用,不经过 PHP 鉴权)
|
||||||
|
生产流程:直接调用 USER_INFO_API 拉取用户信息,不再依赖本地 nf_user 缓存。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info(f"认证调试 - APP_ENV: {settings.APP_ENV}, DEBUG: {settings.DEBUG}")
|
||||||
|
logger.info(f"认证调试 - authorization: {authorization}, x_token: {x_token}, token_header: {token_header}, token_cookie: {token_cookie}, x_user_id: {x_user_id}")
|
||||||
|
|
||||||
|
token = None
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
token = authorization.split(" ", 1)[1].strip()
|
||||||
|
if not token and x_token:
|
||||||
|
token = x_token
|
||||||
|
if not token and token_header:
|
||||||
|
token = token_header
|
||||||
|
if not token and token_cookie:
|
||||||
|
token = token_cookie
|
||||||
|
|
||||||
|
logger.info(f"认证调试 - 提取的 token: {token}")
|
||||||
|
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
payload = _fetch_user_from_php(token)
|
||||||
|
user_id = payload.get("id") or payload.get("user_id")
|
||||||
|
reg_step = payload.get("reg_step") or payload.get("stage") or 1
|
||||||
|
gender = payload.get("gender") or 0
|
||||||
|
nickname = payload.get("nickname") or payload.get("username") or ""
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="用户中心缺少用户ID"
|
||||||
|
)
|
||||||
|
return AuthedUser(
|
||||||
|
id=user_id,
|
||||||
|
reg_step=reg_step,
|
||||||
|
gender=gender,
|
||||||
|
nickname=nickname,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
# 如果是开发环境,token 验证失败时也返回测试用户
|
||||||
|
if settings.APP_ENV == "development" and settings.DEBUG:
|
||||||
|
logger.warning(f"开发环境:token 验证失败,使用测试用户")
|
||||||
|
return AuthedUser(id=84, reg_step=2, gender=0, nickname="test-user", token="")
|
||||||
|
|
||||||
|
# 调试兜底:仅凭 X-User-Id 不校验 PHP,方便联调
|
||||||
|
if x_user_id is not None:
|
||||||
|
return AuthedUser(id=x_user_id, reg_step=2, gender=0, nickname="debug-user", token="")
|
||||||
|
|
||||||
|
# 开发环境兜底:如果没有任何认证信息,返回默认测试用户
|
||||||
|
if settings.APP_ENV == "development" and settings.DEBUG:
|
||||||
|
return AuthedUser(id=84, reg_step=2, gender=0, nickname="test-user", token="")
|
||||||
|
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或未授权")
|
||||||
133
lover/llm.py
Normal file
133
lover/llm.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""轻量封装通义千问 (DashScope) 文本生成。
|
||||||
|
|
||||||
|
目前主要用于聊天初始化/对话,默认模型 qwen-flash,可通过 .env 覆盖。
|
||||||
|
"""
|
||||||
|
from typing import Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
import dashscope
|
||||||
|
from dashscope import Generation
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class LLMResult:
|
||||||
|
"""非流式响应结果。"""
|
||||||
|
|
||||||
|
def __init__(self, content: str, usage: Optional[dict]):
|
||||||
|
self.content = content
|
||||||
|
self.usage = usage or {}
|
||||||
|
|
||||||
|
|
||||||
|
class LLMStreamResponse:
|
||||||
|
"""流式响应包装器,可迭代文本片段,结束后读取 usage。"""
|
||||||
|
|
||||||
|
def __init__(self, iterator: Iterable):
|
||||||
|
self._iterator = iterator
|
||||||
|
self.usage: Dict = {}
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for chunk in self._iterator:
|
||||||
|
self._update_usage(chunk)
|
||||||
|
text = _extract_text(chunk)
|
||||||
|
if text:
|
||||||
|
yield text
|
||||||
|
|
||||||
|
def _update_usage(self, chunk):
|
||||||
|
try:
|
||||||
|
if chunk.usage:
|
||||||
|
self.usage = chunk.usage
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def chat_completion(
|
||||||
|
messages: List[Dict[str, str]],
|
||||||
|
*,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: Optional[float] = None,
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
seed: Optional[int] = None,
|
||||||
|
) -> LLMResult:
|
||||||
|
"""同步调用,返回完整文本。"""
|
||||||
|
|
||||||
|
api_key = settings.DASHSCOPE_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY")
|
||||||
|
|
||||||
|
model_name = model or settings.LLM_MODEL or "qwen-flash"
|
||||||
|
temp = temperature if temperature is not None else settings.LLM_TEMPERATURE
|
||||||
|
max_out = max_tokens if max_tokens is not None else settings.LLM_MAX_TOKENS
|
||||||
|
|
||||||
|
call_kwargs = {
|
||||||
|
"api_key": api_key,
|
||||||
|
"model": model_name,
|
||||||
|
"messages": messages,
|
||||||
|
"result_format": "message",
|
||||||
|
"temperature": temp,
|
||||||
|
"max_tokens": max_out,
|
||||||
|
}
|
||||||
|
if seed is not None:
|
||||||
|
call_kwargs["seed"] = seed
|
||||||
|
|
||||||
|
resp = Generation.call(**call_kwargs)
|
||||||
|
if getattr(resp, "status_code", 200) != 200:
|
||||||
|
detail = getattr(resp, "message", None) or "LLM 调用失败"
|
||||||
|
raise HTTPException(status_code=502, detail=detail)
|
||||||
|
|
||||||
|
content = _extract_text(resp)
|
||||||
|
return LLMResult(content=content, usage=getattr(resp, "usage", {}) or {})
|
||||||
|
|
||||||
|
|
||||||
|
def chat_completion_stream(
|
||||||
|
messages: List[Dict[str, str]],
|
||||||
|
*,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: Optional[float] = None,
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
seed: Optional[int] = None,
|
||||||
|
) -> LLMStreamResponse:
|
||||||
|
"""流式调用,迭代文本片段,调用结束后可读取 .usage。"""
|
||||||
|
|
||||||
|
api_key = settings.DASHSCOPE_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY")
|
||||||
|
|
||||||
|
model_name = model or settings.LLM_MODEL or "qwen-flash"
|
||||||
|
temp = temperature if temperature is not None else settings.LLM_TEMPERATURE
|
||||||
|
max_out = max_tokens if max_tokens is not None else settings.LLM_MAX_TOKENS
|
||||||
|
|
||||||
|
call_kwargs = {
|
||||||
|
"api_key": api_key,
|
||||||
|
"model": model_name,
|
||||||
|
"messages": messages,
|
||||||
|
"result_format": "message",
|
||||||
|
"stream": True,
|
||||||
|
"incremental_output": True,
|
||||||
|
"temperature": temp,
|
||||||
|
"max_tokens": max_out,
|
||||||
|
}
|
||||||
|
if seed is not None:
|
||||||
|
call_kwargs["seed"] = seed
|
||||||
|
|
||||||
|
iterator = Generation.call(**call_kwargs)
|
||||||
|
return LLMStreamResponse(iterator)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text(resp_obj: object) -> str:
|
||||||
|
"""兼容 dashscope 同步/流式的文本字段提取。"""
|
||||||
|
try:
|
||||||
|
choice = resp_obj.output.choices[0].message.content
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# content 可能是字符串或 list[{text: ...}]
|
||||||
|
if isinstance(choice, str):
|
||||||
|
return choice
|
||||||
|
if isinstance(choice, list):
|
||||||
|
parts = []
|
||||||
|
for item in choice:
|
||||||
|
if isinstance(item, dict) and "text" in item:
|
||||||
|
parts.append(str(item.get("text") or ""))
|
||||||
|
return "".join(parts)
|
||||||
|
return ""
|
||||||
85
lover/main.py
Normal file
85
lover/main.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
app = FastAPI(title="LOVER API")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
"http://localhost",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:8080",
|
||||||
|
"http://127.0.0.1",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(config_router.router)
|
||||||
|
app.include_router(lover_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.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
|
# 统一错误响应结构,便于前端通过 code 判定
|
||||||
|
detail = exc.detail
|
||||||
|
msg = detail if isinstance(detail, str) else str(detail)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"code": exc.status_code, "msg": msg, "data": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def generic_exception_handler(request: Request, exc: Exception):
|
||||||
|
logging.exception("Unhandled error", exc_info=exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"code": 500, "msg": "服务器内部错误", "data": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", response_model=ApiResponse[dict])
|
||||||
|
async def health():
|
||||||
|
return ApiResponse(code=1, msg="ok", data={"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/debug/auth")
|
||||||
|
async def debug_auth(request: Request):
|
||||||
|
"""调试认证信息"""
|
||||||
|
headers = dict(request.headers)
|
||||||
|
return {
|
||||||
|
"app_env": settings.APP_ENV,
|
||||||
|
"debug": settings.DEBUG,
|
||||||
|
"headers": headers,
|
||||||
|
"token": headers.get("token"),
|
||||||
|
"authorization": headers.get("authorization"),
|
||||||
|
}
|
||||||
465
lover/models.py
Normal file
465
lover/models.py
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, Column, Date, DateTime, Enum, Integer, String, Text, BigInteger, DECIMAL
|
||||||
|
from sqlalchemy import Text as SAText
|
||||||
|
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "nf_user"
|
||||||
|
|
||||||
|
# 仅映射业务需要的字段,并与现有表结构对齐(createtime/updatetime 为 bigint 时间戳)
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
username = Column(String(32))
|
||||||
|
nickname = Column(String(50))
|
||||||
|
avatar = Column(String(255))
|
||||||
|
mobile = Column(String(11))
|
||||||
|
gender = Column(Integer) # 0/1/2
|
||||||
|
reg_step = Column(Integer, default=1) # 1个人信息 2创建恋人 3生成恋人
|
||||||
|
money = Column(DECIMAL(10, 2), default=0.00)
|
||||||
|
clothes_num = Column(Integer, default=0)
|
||||||
|
token = Column(String(50))
|
||||||
|
createtime = Column("createtime", BigInteger)
|
||||||
|
updatetime = Column("updatetime", BigInteger)
|
||||||
|
chat_limit_daily = Column(Integer, default=80)
|
||||||
|
chat_used_today = Column(Integer, default=0)
|
||||||
|
chat_reset_date = Column(Date)
|
||||||
|
video_gen_remaining = Column(Integer, default=0)
|
||||||
|
inner_voice_enabled = Column(Boolean, default=False)
|
||||||
|
outfit_slots = Column(Integer, default=5)
|
||||||
|
owned_outfit_ids = Column(JSON)
|
||||||
|
owned_voice_ids = Column(JSON)
|
||||||
|
vip_endtime = Column(BigInteger, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceLibrary(Base):
|
||||||
|
__tablename__ = "nf_voice_library"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(64), nullable=False)
|
||||||
|
gender = Column(Enum("male", "female"), nullable=False)
|
||||||
|
style_tag = Column(String(32))
|
||||||
|
avatar_url = Column(String(255))
|
||||||
|
sample_audio_url = Column(String(255))
|
||||||
|
tts_model_id = Column(String(64))
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
voice_code = Column(String(64), nullable=False)
|
||||||
|
is_owned = Column(Boolean, default=True)
|
||||||
|
price_gold = Column(Integer, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class SongLibrary(Base):
|
||||||
|
__tablename__ = "nf_song_library"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
title = Column(String(128), nullable=False, default="")
|
||||||
|
artist = Column(String(64))
|
||||||
|
gender = Column(Enum("male", "female"), nullable=False)
|
||||||
|
audio_url = Column(String(255), nullable=False)
|
||||||
|
status = Column(Boolean, default=True)
|
||||||
|
weigh = Column(Integer, default=0)
|
||||||
|
createtime = Column(BigInteger)
|
||||||
|
updatetime = Column(BigInteger)
|
||||||
|
deletetime = Column(BigInteger)
|
||||||
|
audio_hash = Column(String(64))
|
||||||
|
duration_sec = Column(Integer)
|
||||||
|
|
||||||
|
|
||||||
|
class SingBaseVideo(Base):
|
||||||
|
__tablename__ = "nf_sing_base_video"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
lover_id = Column(BigInteger, nullable=False)
|
||||||
|
image_url = Column(String(255))
|
||||||
|
image_hash = Column(String(64), nullable=False)
|
||||||
|
prompt = Column(String(400))
|
||||||
|
prompt_hash = Column(String(64), nullable=False)
|
||||||
|
model = Column(String(64), nullable=False, default="wan2.2-i2v-flash")
|
||||||
|
resolution = Column(String(16), nullable=False, default="480P")
|
||||||
|
duration = Column(Integer, default=5)
|
||||||
|
dashscope_task_id = Column(String(64))
|
||||||
|
status = Column(Enum("pending", "running", "succeeded", "failed"), default="pending")
|
||||||
|
base_video_url = Column(String(255))
|
||||||
|
error_msg = Column(String(255))
|
||||||
|
generation_task_id = Column(BigInteger)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SingSongVideo(Base):
|
||||||
|
__tablename__ = "nf_sing_song_video"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
lover_id = Column(BigInteger, nullable=False)
|
||||||
|
song_id = Column(BigInteger, nullable=False)
|
||||||
|
base_video_id = Column(BigInteger)
|
||||||
|
audio_url = Column(String(255), nullable=False)
|
||||||
|
audio_hash = Column(String(64), nullable=False)
|
||||||
|
image_hash = Column(String(64))
|
||||||
|
ratio = Column(Enum("1:1", "3:4"), default="3:4")
|
||||||
|
style_level = Column(Enum("normal", "calm", "active"), default="normal")
|
||||||
|
merged_video_url = Column(String(255))
|
||||||
|
status = Column(Enum("pending", "running", "succeeded", "failed"), default="pending")
|
||||||
|
error_msg = Column(String(255))
|
||||||
|
generation_task_id = Column(BigInteger)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class EmoDetectCache(Base):
|
||||||
|
__tablename__ = "nf_emo_detect_cache"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
lover_id = Column(BigInteger, nullable=False)
|
||||||
|
image_url = Column(String(255), nullable=False)
|
||||||
|
image_hash = Column(String(64), nullable=False)
|
||||||
|
ratio = Column(Enum("1:1", "3:4"), nullable=False)
|
||||||
|
check_pass = Column(Boolean, default=False)
|
||||||
|
face_bbox = Column(JSON)
|
||||||
|
ext_bbox = Column(JSON)
|
||||||
|
raw_response = Column(JSON)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SongSegment(Base):
|
||||||
|
__tablename__ = "nf_song_segments"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
song_id = Column(BigInteger, nullable=False)
|
||||||
|
audio_hash = Column(String(64), nullable=False)
|
||||||
|
segment_index = Column(Integer, nullable=False)
|
||||||
|
start_ms = Column(Integer, nullable=False)
|
||||||
|
duration_ms = Column(Integer, nullable=False)
|
||||||
|
audio_url = Column(String(255), nullable=False)
|
||||||
|
audio_size = Column(Integer)
|
||||||
|
status = Column(Enum("pending", "running", "succeeded", "failed"), default="pending")
|
||||||
|
error_msg = Column(String(255))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SongSegmentVideo(Base):
|
||||||
|
__tablename__ = "nf_song_segment_video"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
lover_id = Column(BigInteger, nullable=False)
|
||||||
|
song_id = Column(BigInteger, nullable=False)
|
||||||
|
segment_id = Column(BigInteger, nullable=False)
|
||||||
|
image_hash = Column(String(64), nullable=False)
|
||||||
|
model = Column(String(64), nullable=False, default="emo-v1")
|
||||||
|
ratio = Column(Enum("1:1", "3:4"), nullable=False, default="3:4")
|
||||||
|
style_level = Column(Enum("normal", "calm", "active"), nullable=False, default="normal")
|
||||||
|
dashscope_task_id = Column(String(64))
|
||||||
|
video_url = Column(String(255))
|
||||||
|
status = Column(Enum("pending", "running", "succeeded", "failed"), default="pending")
|
||||||
|
error_msg = Column(String(255))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Lover(Base):
|
||||||
|
__tablename__ = "nf_lovers"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False, unique=True, index=True)
|
||||||
|
name = Column(String(64))
|
||||||
|
gender = Column(Enum("male", "female"), nullable=False)
|
||||||
|
intro = Column(Text)
|
||||||
|
story_background = Column(Text)
|
||||||
|
personality_tag = Column(Integer)
|
||||||
|
interest_tags = Column(JSON)
|
||||||
|
opening_line = Column(Text)
|
||||||
|
personality_prompt = Column(Text)
|
||||||
|
appearance_prompt = Column(Text)
|
||||||
|
appearance_params = Column(JSON)
|
||||||
|
hair_style_id = Column(Integer)
|
||||||
|
eye_color_id = Column(Integer)
|
||||||
|
outfit_desc = Column(String(50))
|
||||||
|
outfit_top_id = Column(BigInteger)
|
||||||
|
outfit_bottom_id = Column(BigInteger)
|
||||||
|
outfit_dress_id = Column(BigInteger)
|
||||||
|
voice_id = Column(BigInteger)
|
||||||
|
image_url = Column(String(255))
|
||||||
|
last_image_task_id = Column(BigInteger)
|
||||||
|
image_gen_used = Column(Integer, default=0)
|
||||||
|
image_gen_limit = Column(Integer, default=10)
|
||||||
|
image_gen_reset_date = Column(Date)
|
||||||
|
init_model = Column(String(64))
|
||||||
|
init_at = Column(DateTime)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationTask(Base):
|
||||||
|
__tablename__ = "nf_generation_tasks"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
lover_id = Column(BigInteger)
|
||||||
|
message_id = Column(BigInteger)
|
||||||
|
task_type = Column(Enum("image", "video", "outfit", "voice"), nullable=False)
|
||||||
|
idempotency_key = Column(String(64))
|
||||||
|
status = Column(Enum("pending", "running", "succeeded", "failed", "refunded"), default="pending")
|
||||||
|
attempts = Column(Integer, default=0)
|
||||||
|
payload = Column(JSON)
|
||||||
|
result_url = Column(String(255))
|
||||||
|
error_msg = Column(String(255))
|
||||||
|
pre_deduct = Column(Boolean, default=False)
|
||||||
|
refunded = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class FriendRelation(Base):
|
||||||
|
__tablename__ = "nf_friend_relations"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger)
|
||||||
|
friend_id = Column(BigInteger)
|
||||||
|
status = Column(Enum("1", "2", "3"), default="1")
|
||||||
|
intimacy = Column(Integer)
|
||||||
|
intimacy_level = Column(Integer)
|
||||||
|
createtime = Column(BigInteger)
|
||||||
|
updatetime = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class Dynamic(Base):
|
||||||
|
__tablename__ = "nf_dynamics"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
lover_id = Column(BigInteger)
|
||||||
|
source_message_id = Column(BigInteger, nullable=False)
|
||||||
|
video_url = Column(String(255), nullable=False)
|
||||||
|
content = Column(String(50), nullable=False)
|
||||||
|
visibility = Column(Enum("friends", "public", "private"), default="friends", nullable=False)
|
||||||
|
like_count = Column(Integer, default=0)
|
||||||
|
comment_count = Column(Integer, default=0)
|
||||||
|
deleted_at = Column(DateTime)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicLike(Base):
|
||||||
|
__tablename__ = "nf_dynamic_likes"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
dynamic_id = Column(BigInteger, nullable=False)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
deleted_at = Column(DateTime)
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicComment(Base):
|
||||||
|
__tablename__ = "nf_dynamic_comments"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
dynamic_id = Column(BigInteger, nullable=False)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
content = Column(String(50), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
deleted_at = Column(DateTime)
|
||||||
|
|
||||||
|
|
||||||
|
class MotionTemplate(Base):
|
||||||
|
__tablename__ = "nf_motion_templates"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(100))
|
||||||
|
gender = Column(Enum("male", "female"), nullable=False)
|
||||||
|
template_id = Column(String(128), nullable=False)
|
||||||
|
ref_video_url = Column(String(255))
|
||||||
|
template_url = Column(String(255))
|
||||||
|
cover_url = Column(String(255))
|
||||||
|
status = Column(Enum("active", "inactive"), default="active")
|
||||||
|
description = Column(String(255))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSession(Base):
|
||||||
|
__tablename__ = "nf_chat_session"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
lover_id = Column(BigInteger, nullable=False)
|
||||||
|
title = Column(String(100))
|
||||||
|
model = Column(String(64))
|
||||||
|
status = Column(Enum("active", "archived"), default="active")
|
||||||
|
inner_voice_enabled = Column(Boolean, default=False)
|
||||||
|
last_message_at = Column(DateTime)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(Base):
|
||||||
|
__tablename__ = "nf_chat_message"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
session_id = Column(BigInteger, nullable=False)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
lover_id = Column(BigInteger, nullable=False)
|
||||||
|
role = Column(Enum("user", "lover", "system"), nullable=False)
|
||||||
|
content_type = Column(Enum("text", "audio", "image"), default="text")
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
seq = Column(BigInteger)
|
||||||
|
token_input = Column(Integer)
|
||||||
|
token_output = Column(Integer)
|
||||||
|
model = Column(String(64))
|
||||||
|
extra = Column(JSON)
|
||||||
|
tts_url = Column(String(255))
|
||||||
|
tts_status = Column(Enum("pending", "succeeded", "failed"), default="pending")
|
||||||
|
tts_voice_id = Column(BigInteger)
|
||||||
|
tts_model_id = Column(String(64))
|
||||||
|
tts_format = Column(String(32))
|
||||||
|
tts_duration_ms = Column(Integer)
|
||||||
|
tts_error = Column(String(255))
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSummary(Base):
|
||||||
|
__tablename__ = "nf_chat_summary"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
session_id = Column(BigInteger, nullable=False)
|
||||||
|
upto_seq = Column(BigInteger, nullable=False)
|
||||||
|
summary_text = Column(Text, nullable=False)
|
||||||
|
model = Column(String(64))
|
||||||
|
token_input = Column(Integer)
|
||||||
|
token_output = Column(Integer)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatFact(Base):
|
||||||
|
__tablename__ = "nf_chat_fact"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
lover_id = Column(BigInteger, nullable=False)
|
||||||
|
kind = Column(Enum("fact", "event", "preference", "boundary"), default="fact")
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
weight = Column(Integer, default=0)
|
||||||
|
source_session_id = Column(BigInteger)
|
||||||
|
source_message_id = Column(BigInteger)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class GirlfriendMould(Base):
|
||||||
|
__tablename__ = "nf_girlfriend_mould"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(100), default="")
|
||||||
|
weigh = Column(Integer, default=0)
|
||||||
|
gender = Column(Enum("male", "female"), nullable=True)
|
||||||
|
createtime = Column(BigInteger)
|
||||||
|
updatetime = Column(BigInteger)
|
||||||
|
deletetime = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class GirlfriendHobbies(Base):
|
||||||
|
__tablename__ = "nf_girlfriend_hobbies"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(100), default="")
|
||||||
|
weigh = Column(Integer, default=0)
|
||||||
|
createtime = Column(BigInteger)
|
||||||
|
updatetime = Column(BigInteger)
|
||||||
|
deletetime = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class GirlfriendEyeColor(Base):
|
||||||
|
__tablename__ = "nf_girlfriend_eyecolor"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(100), default="")
|
||||||
|
weigh = Column(Integer, default=0)
|
||||||
|
createtime = Column(BigInteger)
|
||||||
|
updatetime = Column(BigInteger)
|
||||||
|
deletetime = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class GirlfriendHairStyle(Base):
|
||||||
|
__tablename__ = "nf_girlfriend_hairstyles"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(100), default="")
|
||||||
|
weigh = Column(Integer, default=0)
|
||||||
|
gender = Column(Enum("male", "female"), nullable=True)
|
||||||
|
createtime = Column(BigInteger)
|
||||||
|
updatetime = Column(BigInteger)
|
||||||
|
deletetime = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitItem(Base):
|
||||||
|
__tablename__ = "nf_outfit_items"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(100), default="")
|
||||||
|
category = Column(Enum("top", "bottom", "dress"), nullable=False)
|
||||||
|
gender = Column(Enum("male", "female", "unisex"), default="unisex", nullable=False)
|
||||||
|
image_url = Column(String(255), nullable=False)
|
||||||
|
is_free = Column(Boolean, default=False)
|
||||||
|
price_gold = Column(Integer, default=0)
|
||||||
|
is_vip_only = Column(Boolean, default=False)
|
||||||
|
status = Column(Enum("0", "1"), default="1", nullable=False)
|
||||||
|
weigh = Column(Integer, default=0)
|
||||||
|
createtime = Column(BigInteger)
|
||||||
|
updatetime = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitLook(Base):
|
||||||
|
__tablename__ = "nf_outfit_looks"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
lover_id = Column(BigInteger, nullable=False)
|
||||||
|
name = Column(String(100), default="")
|
||||||
|
image_url = Column(String(255), nullable=False)
|
||||||
|
top_item_id = Column(BigInteger)
|
||||||
|
bottom_item_id = Column(BigInteger)
|
||||||
|
dress_item_id = Column(BigInteger)
|
||||||
|
deleted_at = Column(DateTime)
|
||||||
|
createtime = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updatetime = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class UserOutfitItem(Base):
|
||||||
|
__tablename__ = "nf_user_outfit_items"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
item_id = Column(BigInteger, nullable=False)
|
||||||
|
source = Column(Enum("default", "purchase", "vip", "gift"), default="purchase")
|
||||||
|
platform_limit = Column(Enum("all", "android", "ios"), default="all")
|
||||||
|
createtime = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitPurchaseLog(Base):
|
||||||
|
__tablename__ = "nf_outfit_purchase_log"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(BigInteger, nullable=False)
|
||||||
|
item_id = Column(BigInteger, nullable=False)
|
||||||
|
price_gold = Column(Integer, default=0)
|
||||||
|
platform = Column(Enum("android", "ios", "miniapp"), default="miniapp")
|
||||||
|
status = Column(Enum("pending", "success", "failed", "refund"), default="success")
|
||||||
|
remark = Column(String(255))
|
||||||
|
createtime = Column(BigInteger)
|
||||||
|
updatetime = Column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class UserMoneyLog(Base):
|
||||||
|
__tablename__ = "nf_user_money_log"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, nullable=False, default=0)
|
||||||
|
money = Column(DECIMAL(10, 2), default=0.00)
|
||||||
|
before = Column(DECIMAL(10, 2), default=0.00)
|
||||||
|
after = Column(DECIMAL(10, 2), default=0.00)
|
||||||
|
memo = Column(String(255), default="")
|
||||||
|
createtime = Column(BigInteger)
|
||||||
11
lover/requirements.txt
Normal file
11
lover/requirements.txt
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
fastapi>=0.110
|
||||||
|
uvicorn[standard]>=0.24
|
||||||
|
sqlalchemy>=2.0
|
||||||
|
pymysql>=1.1
|
||||||
|
pydantic>=2.6
|
||||||
|
pydantic-settings>=2.1
|
||||||
|
python-dotenv>=1.0
|
||||||
|
requests>=2.31
|
||||||
|
oss2>=2.18
|
||||||
|
dashscope>=1.20
|
||||||
|
pyyaml>=6.0
|
||||||
20
lover/response.py
Normal file
20
lover/response.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from typing import Generic, Optional, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiResponse(BaseModel, Generic[T]):
|
||||||
|
"""
|
||||||
|
统一响应包装,便于前端通过 code 判断成功/失败。
|
||||||
|
code: 1 表示成功,非 1 由 HTTP 状态码填充(错误)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
code: int = Field(default=1)
|
||||||
|
msg: str = Field(default="ok")
|
||||||
|
data: Optional[T] = None
|
||||||
|
|
||||||
|
|
||||||
|
def success_response(data: Optional[T] = None, msg: str = "ok") -> ApiResponse[T]:
|
||||||
|
return ApiResponse[T](code=1, msg=msg, data=data)
|
||||||
1
lover/routers/__init__.py
Normal file
1
lover/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Routers package marker
|
||||||
BIN
lover/routers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
lover/routers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
lover/routers/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/chat.cpython-313.pyc
Normal file
BIN
lover/routers/__pycache__/chat.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/chat.cpython-314.pyc
Normal file
BIN
lover/routers/__pycache__/chat.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/config.cpython-313.pyc
Normal file
BIN
lover/routers/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/config.cpython-314.pyc
Normal file
BIN
lover/routers/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/dance.cpython-313.pyc
Normal file
BIN
lover/routers/__pycache__/dance.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/dance.cpython-314.pyc
Normal file
BIN
lover/routers/__pycache__/dance.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/dynamic.cpython-313.pyc
Normal file
BIN
lover/routers/__pycache__/dynamic.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/dynamic.cpython-314.pyc
Normal file
BIN
lover/routers/__pycache__/dynamic.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/lover.cpython-313.pyc
Normal file
BIN
lover/routers/__pycache__/lover.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/lover.cpython-314.pyc
Normal file
BIN
lover/routers/__pycache__/lover.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/outfit.cpython-313.pyc
Normal file
BIN
lover/routers/__pycache__/outfit.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/outfit.cpython-314.pyc
Normal file
BIN
lover/routers/__pycache__/outfit.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/sing.cpython-313.pyc
Normal file
BIN
lover/routers/__pycache__/sing.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/sing.cpython-314.pyc
Normal file
BIN
lover/routers/__pycache__/sing.cpython-314.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/voice_call.cpython-313.pyc
Normal file
BIN
lover/routers/__pycache__/voice_call.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lover/routers/__pycache__/voice_call.cpython-314.pyc
Normal file
BIN
lover/routers/__pycache__/voice_call.cpython-314.pyc
Normal file
Binary file not shown.
1164
lover/routers/chat.py
Normal file
1164
lover/routers/chat.py
Normal file
File diff suppressed because it is too large
Load Diff
330
lover/routers/config.py
Normal file
330
lover/routers/config.py
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
from typing import List, Optional
|
||||||
|
from decimal import Decimal
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import VoiceLibrary, Lover, User, UserMoneyLog
|
||||||
|
from ..response import ApiResponse, success_response
|
||||||
|
from ..deps import get_current_user, AuthedUser
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config", tags=["config"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_owned_voices(raw: Optional[str]) -> set[int]:
|
||||||
|
owned: set[int] = set()
|
||||||
|
if not raw:
|
||||||
|
return owned
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for v in raw:
|
||||||
|
try:
|
||||||
|
owned.add(int(v))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return owned
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
for v in parsed:
|
||||||
|
try:
|
||||||
|
owned.add(int(v))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return owned
|
||||||
|
except Exception:
|
||||||
|
for part in str(raw).split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part.isdigit():
|
||||||
|
owned.add(int(part))
|
||||||
|
return owned
|
||||||
|
return owned
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_owned_voices(ids: set[int]) -> list[int]:
|
||||||
|
return sorted(list(ids))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_balance(user_row: User) -> Decimal:
|
||||||
|
try:
|
||||||
|
return Decimal(str(user_row.money or "0"))
|
||||||
|
except Exception:
|
||||||
|
return Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
gender: str
|
||||||
|
style_tag: Optional[str] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
sample_audio_url: Optional[str] = None
|
||||||
|
tts_model_id: Optional[str] = None
|
||||||
|
is_default: bool = False
|
||||||
|
voice_code: str
|
||||||
|
is_owned: bool
|
||||||
|
price_gold: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceListResponse(BaseModel):
|
||||||
|
voices: List[VoiceOut]
|
||||||
|
default_voice_id: Optional[int] = None
|
||||||
|
selected_voice_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceMallItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
gender: str
|
||||||
|
style_tag: Optional[str] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
sample_audio_url: Optional[str] = None
|
||||||
|
price_gold: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceMallResponse(BaseModel):
|
||||||
|
voices: List[VoiceMallItem]
|
||||||
|
owned_voice_ids: List[int]
|
||||||
|
balance: float
|
||||||
|
|
||||||
|
|
||||||
|
class VoicePurchaseIn(BaseModel):
|
||||||
|
voice_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class VoicePurchaseOut(BaseModel):
|
||||||
|
voice_id: int
|
||||||
|
balance: float
|
||||||
|
owned_voice_ids: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceAvailableResponse(BaseModel):
|
||||||
|
gender: str
|
||||||
|
voices: List[VoiceOut]
|
||||||
|
selected_voice_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/voices", response_model=ApiResponse[VoiceListResponse])
|
||||||
|
def list_voices(
|
||||||
|
gender: Optional[str] = Query(default=None, pattern="^(male|female)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
query = db.query(VoiceLibrary)
|
||||||
|
if gender:
|
||||||
|
query = query.filter(VoiceLibrary.gender == gender)
|
||||||
|
voices = query.order_by(VoiceLibrary.id.asc()).all()
|
||||||
|
if not voices:
|
||||||
|
raise HTTPException(status_code=404, detail="未配置音色")
|
||||||
|
|
||||||
|
default_voice = (
|
||||||
|
db.query(VoiceLibrary)
|
||||||
|
.filter(VoiceLibrary.gender == gender, VoiceLibrary.is_default.is_(True))
|
||||||
|
.first()
|
||||||
|
) if gender else None
|
||||||
|
|
||||||
|
selected_voice_id = None
|
||||||
|
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
|
||||||
|
if lover:
|
||||||
|
# 若前端传了 gender,可校验匹配;否则直接返回已选
|
||||||
|
if (not gender) or lover.gender == gender:
|
||||||
|
selected_voice_id = lover.voice_id
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
VoiceListResponse(
|
||||||
|
voices=voices,
|
||||||
|
default_voice_id=default_voice.id if default_voice else None,
|
||||||
|
selected_voice_id=selected_voice_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/voices/mall", response_model=ApiResponse[VoiceMallResponse])
|
||||||
|
def list_paid_voices(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
金币商场:返回当前恋人性别的所有付费音色、已拥有列表与金币余额。
|
||||||
|
"""
|
||||||
|
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
|
||||||
|
if not lover:
|
||||||
|
raise HTTPException(status_code=404, detail="恋人未找到")
|
||||||
|
|
||||||
|
user_row = db.query(User).filter(User.id == user.id).first()
|
||||||
|
if not user_row:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
owned_ids = _parse_owned_voices(user_row.owned_voice_ids)
|
||||||
|
balance = float(_ensure_balance(user_row))
|
||||||
|
|
||||||
|
voices = (
|
||||||
|
db.query(VoiceLibrary)
|
||||||
|
.filter(
|
||||||
|
VoiceLibrary.gender == lover.gender,
|
||||||
|
VoiceLibrary.price_gold > 0,
|
||||||
|
)
|
||||||
|
.order_by(VoiceLibrary.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return success_response(
|
||||||
|
VoiceMallResponse(
|
||||||
|
voices=[
|
||||||
|
VoiceMallItem(
|
||||||
|
id=v.id,
|
||||||
|
name=v.name,
|
||||||
|
gender=v.gender,
|
||||||
|
style_tag=v.style_tag,
|
||||||
|
avatar_url=v.avatar_url,
|
||||||
|
sample_audio_url=v.sample_audio_url,
|
||||||
|
price_gold=v.price_gold or 0,
|
||||||
|
)
|
||||||
|
for v in voices
|
||||||
|
],
|
||||||
|
owned_voice_ids=_serialize_owned_voices(owned_ids),
|
||||||
|
balance=balance,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/voices/purchase", response_model=ApiResponse[VoicePurchaseOut])
|
||||||
|
def purchase_voice(
|
||||||
|
payload: VoicePurchaseIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
|
||||||
|
if not lover:
|
||||||
|
raise HTTPException(status_code=404, detail="恋人未找到")
|
||||||
|
|
||||||
|
voice = (
|
||||||
|
db.query(VoiceLibrary)
|
||||||
|
.filter(VoiceLibrary.id == payload.voice_id, VoiceLibrary.gender == lover.gender)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not voice:
|
||||||
|
raise HTTPException(status_code=404, detail="音色不存在或与恋人性别不匹配")
|
||||||
|
price = Decimal(voice.price_gold or 0)
|
||||||
|
if price <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="该音色不需要购买")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_row = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.id == user.id)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
user_row = None
|
||||||
|
if not user_row:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
owned_ids = _parse_owned_voices(user_row.owned_voice_ids)
|
||||||
|
if int(voice.id) in owned_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="已拥有该音色,无需重复购买")
|
||||||
|
|
||||||
|
balance = _ensure_balance(user_row)
|
||||||
|
if balance < price:
|
||||||
|
raise HTTPException(status_code=400, detail="余额不足")
|
||||||
|
|
||||||
|
# 扣款并记录拥有(行锁下保证并发安全)
|
||||||
|
before_balance = balance
|
||||||
|
balance -= price
|
||||||
|
user_row.money = float(balance)
|
||||||
|
owned_ids.add(int(voice.id))
|
||||||
|
user_row.owned_voice_ids = _serialize_owned_voices(owned_ids)
|
||||||
|
db.add(user_row)
|
||||||
|
db.add(
|
||||||
|
UserMoneyLog(
|
||||||
|
user_id=user.id,
|
||||||
|
money=-price,
|
||||||
|
before=before_balance,
|
||||||
|
after=Decimal(user_row.money),
|
||||||
|
memo=f"购买音色:{voice.name}",
|
||||||
|
createtime=int(Decimal(time.time()).to_integral_value()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="购买请求冲突,请重试")
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
VoicePurchaseOut(
|
||||||
|
voice_id=voice.id,
|
||||||
|
balance=float(balance),
|
||||||
|
owned_voice_ids=_serialize_owned_voices(owned_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/voices/available", response_model=ApiResponse[VoiceAvailableResponse])
|
||||||
|
def list_available_voices_for_lover(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
返回当前恋人性别下可用的音色列表:已拥有的音色 + 免费音色。
|
||||||
|
不返回未拥有的付费音色,供“更换音色”页直接选择使用。
|
||||||
|
"""
|
||||||
|
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
|
||||||
|
if not lover:
|
||||||
|
raise HTTPException(status_code=404, detail="恋人未找到")
|
||||||
|
gender = lover.gender
|
||||||
|
|
||||||
|
user_row = db.query(User).filter(User.id == user.id).first()
|
||||||
|
if not user_row:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
owned_ids = _parse_owned_voices(user_row.owned_voice_ids)
|
||||||
|
|
||||||
|
query = db.query(VoiceLibrary).filter(VoiceLibrary.gender == gender)
|
||||||
|
if owned_ids:
|
||||||
|
query = query.filter(
|
||||||
|
(VoiceLibrary.price_gold <= 0) | (VoiceLibrary.id.in_(owned_ids))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.filter(VoiceLibrary.price_gold <= 0)
|
||||||
|
voices = query.order_by(VoiceLibrary.id.asc()).all()
|
||||||
|
if not voices:
|
||||||
|
raise HTTPException(status_code=404, detail="未配置音色")
|
||||||
|
|
||||||
|
voices_out: List[VoiceOut] = []
|
||||||
|
for v in voices:
|
||||||
|
owned = int(v.id) in owned_ids or (v.price_gold or 0) <= 0
|
||||||
|
voices_out.append(
|
||||||
|
VoiceOut(
|
||||||
|
id=v.id,
|
||||||
|
name=v.name,
|
||||||
|
gender=v.gender,
|
||||||
|
style_tag=v.style_tag,
|
||||||
|
avatar_url=v.avatar_url,
|
||||||
|
sample_audio_url=v.sample_audio_url,
|
||||||
|
tts_model_id=v.tts_model_id,
|
||||||
|
is_default=bool(v.is_default),
|
||||||
|
voice_code=v.voice_code,
|
||||||
|
is_owned=owned,
|
||||||
|
price_gold=v.price_gold or 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
VoiceAvailableResponse(
|
||||||
|
gender=gender,
|
||||||
|
voices=voices_out,
|
||||||
|
selected_voice_id=lover.voice_id if lover.voice_id else None,
|
||||||
|
),
|
||||||
|
msg="获取可用音色成功",
|
||||||
|
)
|
||||||
1223
lover/routers/dance.py
Normal file
1223
lover/routers/dance.py
Normal file
File diff suppressed because it is too large
Load Diff
596
lover/routers/dynamic.py
Normal file
596
lover/routers/dynamic.py
Normal file
|
|
@ -0,0 +1,596 @@
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..deps import AuthedUser, get_current_user
|
||||||
|
from ..models import (
|
||||||
|
ChatMessage,
|
||||||
|
Dynamic,
|
||||||
|
DynamicComment,
|
||||||
|
DynamicLike,
|
||||||
|
FriendRelation,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from ..response import ApiResponse, success_response
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dynamic", tags=["dynamic"])
|
||||||
|
|
||||||
|
FEED_PAGE_SIZE = 10
|
||||||
|
COMMENT_PAGE_SIZE = 5
|
||||||
|
FEED_CACHE_TTL_SECONDS = 30
|
||||||
|
|
||||||
|
_feed_cache: Dict[Tuple[int, int, int], Tuple[float, dict]] = {}
|
||||||
|
_rate_buckets: Dict[Tuple[str, int], deque] = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(user_id: int, action: str, limit: int, window_seconds: int):
|
||||||
|
"""
|
||||||
|
简单的本地节流,防止频繁点赞/评论/发布(无分布式保障,但可挡住瞬时刷接口)。
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
key = (action, user_id)
|
||||||
|
bucket = _rate_buckets[key]
|
||||||
|
while bucket and bucket[0] < now - window_seconds:
|
||||||
|
bucket.popleft()
|
||||||
|
if len(bucket) >= limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="操作过于频繁,请稍后再试",
|
||||||
|
)
|
||||||
|
bucket.append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_text_safe(text: Optional[str], field_name: str):
|
||||||
|
"""调用违禁词检测,检测到风险则抛出 400;无法调用时不阻断流程。"""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
"https://uapis.cn/api/v1/text/profanitycheck",
|
||||||
|
json={"text": text},
|
||||||
|
headers={"User-Agent": "lover-app/1.0"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
status_str = str(data.get("status") or "").lower()
|
||||||
|
forbidden_words = [str(w).strip() for w in (data.get("forbidden_words") or []) if str(w).strip()]
|
||||||
|
masked_text = data.get("masked_text")
|
||||||
|
if status_str == "forbidden" or forbidden_words:
|
||||||
|
words_str = ", ".join(forbidden_words) if forbidden_words else ""
|
||||||
|
masked_str = f",屏蔽后:{masked_text}" if masked_text else ""
|
||||||
|
detail = (
|
||||||
|
f"{field_name}包含违禁词: {words_str}{masked_str}"
|
||||||
|
if words_str
|
||||||
|
else f"{field_name}包含违禁词,请调整后再试{masked_str}"
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=400, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_friend_ids(db: Session, user_id: int) -> List[int]:
|
||||||
|
rows = (
|
||||||
|
db.query(FriendRelation)
|
||||||
|
.filter(
|
||||||
|
FriendRelation.status == "1",
|
||||||
|
or_(FriendRelation.user_id == user_id, FriendRelation.friend_id == user_id),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
result = set()
|
||||||
|
for row in rows:
|
||||||
|
if row.user_id == user_id and row.friend_id:
|
||||||
|
result.add(int(row.friend_id))
|
||||||
|
elif row.friend_id == user_id and row.user_id:
|
||||||
|
result.add(int(row.user_id))
|
||||||
|
return list(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_users_map(db: Session, user_ids: List[int]) -> Dict[int, User]:
|
||||||
|
unique_ids = [uid for uid in set(user_ids) if uid is not None]
|
||||||
|
if not unique_ids:
|
||||||
|
return {}
|
||||||
|
rows = db.query(User).filter(User.id.in_(unique_ids)).all()
|
||||||
|
return {int(row.id): row for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_brief(user_row: Optional[User]) -> "UserBrief":
|
||||||
|
return UserBrief(
|
||||||
|
id=int(user_row.id) if user_row and user_row.id is not None else 0,
|
||||||
|
nickname=user_row.nickname if user_row else "",
|
||||||
|
avatar=user_row.avatar if user_row else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_feed_cache():
|
||||||
|
_feed_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cached_feed(user_id: int, page: int, size: int) -> Optional[dict]:
|
||||||
|
key = (user_id, page, size)
|
||||||
|
cached = _feed_cache.get(key)
|
||||||
|
if not cached:
|
||||||
|
return None
|
||||||
|
ts, data = cached
|
||||||
|
if time.time() - ts > FEED_CACHE_TTL_SECONDS:
|
||||||
|
_feed_cache.pop(key, None)
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _set_feed_cache(user_id: int, page: int, size: int, data: dict):
|
||||||
|
key = (user_id, page, size)
|
||||||
|
_feed_cache[key] = (time.time(), data)
|
||||||
|
|
||||||
|
|
||||||
|
class ShareIn(BaseModel):
|
||||||
|
source_message_id: int = Field(..., gt=0, description="来源消息ID,需包含视频URL")
|
||||||
|
content: Optional[str] = Field(None, max_length=50, description="动态文案,空则默认填充")
|
||||||
|
|
||||||
|
|
||||||
|
class UserBrief(BaseModel):
|
||||||
|
id: int
|
||||||
|
nickname: str = ""
|
||||||
|
avatar: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LikeUserOut(BaseModel):
|
||||||
|
user: UserBrief
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CommentOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
user: UserBrief
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicItemOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
user: UserBrief
|
||||||
|
content: str
|
||||||
|
video_url: str
|
||||||
|
visibility: str
|
||||||
|
like_count: int
|
||||||
|
comment_count: int
|
||||||
|
likes: List[LikeUserOut]
|
||||||
|
liked: bool
|
||||||
|
comments: List[CommentOut]
|
||||||
|
comments_has_more: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FeedOut(BaseModel):
|
||||||
|
page: int
|
||||||
|
size: int
|
||||||
|
has_more: bool
|
||||||
|
items: List[DynamicItemOut]
|
||||||
|
|
||||||
|
|
||||||
|
class ShareOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
content: str
|
||||||
|
video_url: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LikeActionIn(BaseModel):
|
||||||
|
action: str = Field(default="like", description="like 或 unlike")
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
@field_validator("action")
|
||||||
|
@classmethod
|
||||||
|
def validate_action(cls, v: str) -> str:
|
||||||
|
if v not in {"like", "unlike"}:
|
||||||
|
raise ValueError("action 仅支持 like/unlike")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class LikeOut(BaseModel):
|
||||||
|
dynamic_id: int
|
||||||
|
like_count: int
|
||||||
|
liked: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CommentIn(BaseModel):
|
||||||
|
content: str = Field(..., min_length=1, max_length=50, description="评论内容(≤50)")
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreateOut(BaseModel):
|
||||||
|
dynamic_id: int
|
||||||
|
comment: CommentOut
|
||||||
|
comment_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CommentPageOut(BaseModel):
|
||||||
|
dynamic_id: int
|
||||||
|
page: int
|
||||||
|
size: int
|
||||||
|
has_more: bool
|
||||||
|
comments: List[CommentOut]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dynamic_visible(db: Session, dynamic: Dynamic, user: AuthedUser, friend_ids: List[int]):
|
||||||
|
if dynamic.deleted_at:
|
||||||
|
raise HTTPException(status_code=404, detail="动态不存在或已删除")
|
||||||
|
# 当前仅 friends 可见;预留 public/private
|
||||||
|
if dynamic.visibility == "private" and dynamic.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="无权查看该动态")
|
||||||
|
if dynamic.visibility == "friends":
|
||||||
|
allowed_ids = set(friend_ids)
|
||||||
|
allowed_ids.add(user.id)
|
||||||
|
if dynamic.user_id not in allowed_ids:
|
||||||
|
raise HTTPException(status_code=403, detail="无权查看该动态")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/share", response_model=ApiResponse[ShareOut])
|
||||||
|
async def share_dynamic(
|
||||||
|
payload: ShareIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_check_rate_limit(user.id, "share", limit=5, window_seconds=60)
|
||||||
|
|
||||||
|
message = db.query(ChatMessage).filter(ChatMessage.id == payload.source_message_id).first()
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=404, detail="消息不存在")
|
||||||
|
if message.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="只能分享自己的视频消息")
|
||||||
|
|
||||||
|
video_url = None
|
||||||
|
if isinstance(message.extra, dict):
|
||||||
|
video_url = message.extra.get("video_url")
|
||||||
|
if not video_url:
|
||||||
|
raise HTTPException(status_code=400, detail="该消息没有可分享的视频")
|
||||||
|
|
||||||
|
content = (payload.content or "").strip()
|
||||||
|
if not content:
|
||||||
|
fallback = f"{user.nickname or '用户'}发布了动态"
|
||||||
|
content = fallback[:50]
|
||||||
|
else:
|
||||||
|
_check_text_safe(content, "动态文案")
|
||||||
|
|
||||||
|
dynamic = Dynamic(
|
||||||
|
user_id=user.id,
|
||||||
|
lover_id=message.lover_id,
|
||||||
|
source_message_id=message.id,
|
||||||
|
video_url=str(video_url),
|
||||||
|
content=content,
|
||||||
|
visibility="friends",
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(dynamic)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
_clear_feed_cache()
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
ShareOut(
|
||||||
|
id=dynamic.id,
|
||||||
|
content=dynamic.content,
|
||||||
|
video_url=dynamic.video_url,
|
||||||
|
created_at=dynamic.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed", response_model=ApiResponse[FeedOut])
|
||||||
|
async def get_feed(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(FEED_PAGE_SIZE, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cached = _get_cached_feed(user.id, page, size)
|
||||||
|
if cached:
|
||||||
|
return success_response(FeedOut(**cached))
|
||||||
|
|
||||||
|
friend_ids = _get_friend_ids(db, user.id)
|
||||||
|
allowed_user_ids = set(friend_ids)
|
||||||
|
allowed_user_ids.add(user.id)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
db.query(Dynamic)
|
||||||
|
.filter(
|
||||||
|
Dynamic.deleted_at.is_(None),
|
||||||
|
Dynamic.visibility == "friends",
|
||||||
|
Dynamic.user_id.in_(allowed_user_ids),
|
||||||
|
)
|
||||||
|
.order_by(Dynamic.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = query.offset((page - 1) * size).limit(size + 1).all()
|
||||||
|
has_more = len(rows) > size
|
||||||
|
dynamics = rows[:size]
|
||||||
|
|
||||||
|
dynamic_ids = [int(d.id) for d in dynamics]
|
||||||
|
# 点赞批量拉取
|
||||||
|
likes = (
|
||||||
|
db.query(DynamicLike)
|
||||||
|
.filter(
|
||||||
|
DynamicLike.dynamic_id.in_(dynamic_ids),
|
||||||
|
DynamicLike.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(DynamicLike.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 评论按动态单独分页,取前 COMMENT_PAGE_SIZE+1 判断是否还有更多
|
||||||
|
comments_map: Dict[int, List[DynamicComment]] = {}
|
||||||
|
for dyn in dynamics:
|
||||||
|
comments = (
|
||||||
|
db.query(DynamicComment)
|
||||||
|
.filter(
|
||||||
|
DynamicComment.dynamic_id == dyn.id,
|
||||||
|
DynamicComment.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(DynamicComment.created_at.desc())
|
||||||
|
.limit(COMMENT_PAGE_SIZE + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
comments_map[int(dyn.id)] = comments
|
||||||
|
|
||||||
|
user_ids: List[int] = []
|
||||||
|
for dyn in dynamics:
|
||||||
|
if dyn.user_id:
|
||||||
|
user_ids.append(int(dyn.user_id))
|
||||||
|
user_ids.extend(int(l.user_id) for l in likes if l.user_id is not None)
|
||||||
|
for comment_list in comments_map.values():
|
||||||
|
user_ids.extend(int(c.user_id) for c in comment_list if c.user_id is not None)
|
||||||
|
users_map = _get_users_map(db, user_ids)
|
||||||
|
|
||||||
|
def render_comments(dyn_id: int) -> Tuple[List[CommentOut], bool]:
|
||||||
|
comment_rows = comments_map.get(dyn_id) or []
|
||||||
|
has_more_comments = len(comment_rows) > COMMENT_PAGE_SIZE
|
||||||
|
trimmed = comment_rows[:COMMENT_PAGE_SIZE]
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
CommentOut(
|
||||||
|
id=c.id,
|
||||||
|
user=_user_brief(users_map.get(int(c.user_id))),
|
||||||
|
content=c.content,
|
||||||
|
created_at=c.created_at,
|
||||||
|
)
|
||||||
|
for c in trimmed
|
||||||
|
],
|
||||||
|
has_more_comments,
|
||||||
|
)
|
||||||
|
|
||||||
|
items: List[DynamicItemOut] = []
|
||||||
|
for dyn in dynamics:
|
||||||
|
dyn_likes = [lk for lk in likes if int(lk.dynamic_id) == int(dyn.id)]
|
||||||
|
like_users = [
|
||||||
|
LikeUserOut(
|
||||||
|
user=_user_brief(users_map.get(int(l.user_id))),
|
||||||
|
created_at=l.created_at,
|
||||||
|
)
|
||||||
|
for l in dyn_likes
|
||||||
|
]
|
||||||
|
comments_out, comments_has_more = render_comments(int(dyn.id))
|
||||||
|
items.append(
|
||||||
|
DynamicItemOut(
|
||||||
|
id=int(dyn.id),
|
||||||
|
user=_user_brief(users_map.get(int(dyn.user_id))),
|
||||||
|
content=dyn.content,
|
||||||
|
video_url=dyn.video_url,
|
||||||
|
visibility=dyn.visibility,
|
||||||
|
like_count=dyn.like_count or 0,
|
||||||
|
comment_count=dyn.comment_count or 0,
|
||||||
|
likes=like_users,
|
||||||
|
liked=any(l.user_id == user.id and l.deleted_at is None for l in dyn_likes),
|
||||||
|
comments=comments_out,
|
||||||
|
comments_has_more=comments_has_more,
|
||||||
|
created_at=dyn.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resp_data = FeedOut(page=page, size=size, has_more=has_more, items=items)
|
||||||
|
_set_feed_cache(user.id, page, size, resp_data.model_dump())
|
||||||
|
return success_response(resp_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/like/{dynamic_id}", response_model=ApiResponse[LikeOut])
|
||||||
|
async def like_dynamic(
|
||||||
|
action: LikeActionIn,
|
||||||
|
dynamic_id: int = Path(..., gt=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_check_rate_limit(user.id, "like", limit=30, window_seconds=60)
|
||||||
|
|
||||||
|
dynamic = (
|
||||||
|
db.query(Dynamic)
|
||||||
|
.filter(Dynamic.id == dynamic_id, Dynamic.deleted_at.is_(None))
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not dynamic:
|
||||||
|
raise HTTPException(status_code=404, detail="动态不存在")
|
||||||
|
|
||||||
|
friend_ids = _get_friend_ids(db, user.id)
|
||||||
|
_ensure_dynamic_visible(db, dynamic, user, friend_ids)
|
||||||
|
|
||||||
|
like_row = (
|
||||||
|
db.query(DynamicLike)
|
||||||
|
.filter(DynamicLike.dynamic_id == dynamic_id, DynamicLike.user_id == user.id)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
liked = action.action == "like"
|
||||||
|
liked_result = liked
|
||||||
|
|
||||||
|
if liked:
|
||||||
|
if like_row and like_row.deleted_at is None:
|
||||||
|
pass
|
||||||
|
elif like_row:
|
||||||
|
like_row.deleted_at = None
|
||||||
|
like_row.created_at = now
|
||||||
|
dynamic.like_count = (dynamic.like_count or 0) + 1
|
||||||
|
db.add(like_row)
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
DynamicLike(
|
||||||
|
dynamic_id=dynamic_id,
|
||||||
|
user_id=user.id,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dynamic.like_count = (dynamic.like_count or 0) + 1
|
||||||
|
else:
|
||||||
|
# 未点过赞不能取消
|
||||||
|
if not like_row or like_row.deleted_at is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="尚未点赞,无法取消点赞")
|
||||||
|
like_row.deleted_at = now
|
||||||
|
dynamic.like_count = max((dynamic.like_count or 0) - 1, 0)
|
||||||
|
db.add(like_row)
|
||||||
|
liked_result = False
|
||||||
|
|
||||||
|
db.add(dynamic)
|
||||||
|
_clear_feed_cache()
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="点赞请求冲突,请稍后重试")
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
LikeOut(dynamic_id=dynamic_id, like_count=dynamic.like_count or 0, liked=liked_result)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/comment/{dynamic_id}", response_model=ApiResponse[CommentCreateOut])
|
||||||
|
async def comment_dynamic(
|
||||||
|
payload: CommentIn,
|
||||||
|
dynamic_id: int = Path(..., gt=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
_check_rate_limit(user.id, "comment", limit=10, window_seconds=60)
|
||||||
|
|
||||||
|
dynamic = (
|
||||||
|
db.query(Dynamic)
|
||||||
|
.filter(Dynamic.id == dynamic_id, Dynamic.deleted_at.is_(None))
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not dynamic:
|
||||||
|
raise HTTPException(status_code=404, detail="动态不存在")
|
||||||
|
|
||||||
|
friend_ids = _get_friend_ids(db, user.id)
|
||||||
|
_ensure_dynamic_visible(db, dynamic, user, friend_ids)
|
||||||
|
|
||||||
|
content = payload.content.strip()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="评论内容不能为空")
|
||||||
|
_check_text_safe(content, "评论内容")
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
comment_row = DynamicComment(
|
||||||
|
dynamic_id=dynamic_id,
|
||||||
|
user_id=user.id,
|
||||||
|
content=content,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(comment_row)
|
||||||
|
dynamic.comment_count = (dynamic.comment_count or 0) + 1
|
||||||
|
db.add(dynamic)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
author = db.query(User).filter(User.id == user.id).first()
|
||||||
|
_clear_feed_cache()
|
||||||
|
|
||||||
|
comment_out = CommentOut(
|
||||||
|
id=comment_row.id,
|
||||||
|
user=_user_brief(author),
|
||||||
|
content=comment_row.content,
|
||||||
|
created_at=comment_row.created_at,
|
||||||
|
)
|
||||||
|
return success_response(
|
||||||
|
CommentCreateOut(
|
||||||
|
dynamic_id=dynamic_id,
|
||||||
|
comment=comment_out,
|
||||||
|
comment_count=dynamic.comment_count or 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_comments_impl(
|
||||||
|
dynamic_id: int = Path(..., gt=0),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(COMMENT_PAGE_SIZE, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
dynamic = (
|
||||||
|
db.query(Dynamic)
|
||||||
|
.filter(Dynamic.id == dynamic_id, Dynamic.deleted_at.is_(None))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not dynamic:
|
||||||
|
raise HTTPException(status_code=404, detail="动态不存在")
|
||||||
|
|
||||||
|
friend_ids = _get_friend_ids(db, user.id)
|
||||||
|
_ensure_dynamic_visible(db, dynamic, user, friend_ids)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(DynamicComment)
|
||||||
|
.filter(DynamicComment.dynamic_id == dynamic_id, DynamicComment.deleted_at.is_(None))
|
||||||
|
.order_by(DynamicComment.created_at.desc())
|
||||||
|
.offset((page - 1) * size)
|
||||||
|
.limit(size + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
has_more = len(rows) > size
|
||||||
|
comments = rows[:size]
|
||||||
|
|
||||||
|
user_ids = [int(c.user_id) for c in comments if c.user_id is not None]
|
||||||
|
users_map = _get_users_map(db, user_ids)
|
||||||
|
|
||||||
|
comments_out = [
|
||||||
|
CommentOut(
|
||||||
|
id=c.id,
|
||||||
|
user=_user_brief(users_map.get(int(c.user_id))),
|
||||||
|
content=c.content,
|
||||||
|
created_at=c.created_at,
|
||||||
|
)
|
||||||
|
for c in comments
|
||||||
|
]
|
||||||
|
return success_response(
|
||||||
|
CommentPageOut(
|
||||||
|
dynamic_id=dynamic_id,
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
has_more=has_more,
|
||||||
|
comments=comments_out,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/comments/{dynamic_id}", response_model=ApiResponse[CommentPageOut])
|
||||||
|
async def list_comments(
|
||||||
|
dynamic_id: int = Path(..., gt=0),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(COMMENT_PAGE_SIZE, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return await _list_comments_impl(dynamic_id=dynamic_id, page=page, size=size, db=db, user=user)
|
||||||
1684
lover/routers/lover.py
Normal file
1684
lover/routers/lover.py
Normal file
File diff suppressed because it is too large
Load Diff
833
lover/routers/outfit.py
Normal file
833
lover/routers/outfit.py
Normal file
|
|
@ -0,0 +1,833 @@
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import oss2
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..deps import AuthedUser, get_current_user
|
||||||
|
from decimal import Decimal
|
||||||
|
from ..models import Lover, OutfitItem, OutfitLook, User, OutfitPurchaseLog, UserMoneyLog
|
||||||
|
from ..response import ApiResponse, success_response
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/outfit", tags=["outfit"])
|
||||||
|
|
||||||
|
def _ensure_balance(user_row: User) -> Decimal:
|
||||||
|
try:
|
||||||
|
return Decimal(str(user_row.money or "0"))
|
||||||
|
except Exception:
|
||||||
|
return Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ts() -> int:
|
||||||
|
import time
|
||||||
|
return int(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_owned_outfits(raw: Optional[str]) -> set[int]:
|
||||||
|
owned: set[int] = set()
|
||||||
|
if not raw:
|
||||||
|
return owned
|
||||||
|
# 已是列表
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for v in raw:
|
||||||
|
try:
|
||||||
|
owned.add(int(v))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return owned
|
||||||
|
# JSON 字符串
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
for v in parsed:
|
||||||
|
try:
|
||||||
|
owned.add(int(v))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return owned
|
||||||
|
except Exception:
|
||||||
|
# 逗号分隔兜底
|
||||||
|
for part in str(raw).split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part.isdigit():
|
||||||
|
owned.add(int(part))
|
||||||
|
return owned
|
||||||
|
return owned
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_owned_outfits(ids: set[int]) -> list[int]:
|
||||||
|
return sorted(list(ids))
|
||||||
|
|
||||||
|
|
||||||
|
def _cdnize(url: Optional[str]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
将以 /uploads 开头的相对路径补全为 CDN/OSS 完整 URL。
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return url
|
||||||
|
if url.startswith("http://") or url.startswith("https://"):
|
||||||
|
return url
|
||||||
|
prefix = "https://nvlovers.oss-cn-qingdao.aliyuncs.com"
|
||||||
|
if url.startswith("/"):
|
||||||
|
return prefix + url
|
||||||
|
return f"{prefix}/{url}"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_url(url: Optional[str]) -> Optional[str]:
|
||||||
|
"""去掉首尾空白,避免试衣接口因空格拒绝。"""
|
||||||
|
if url is None:
|
||||||
|
return None
|
||||||
|
return url.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_to_oss(file_bytes: bytes, object_name: str) -> str:
|
||||||
|
"""上传到 OSS,返回可访问 URL(优先 CDN 域名)。"""
|
||||||
|
auth = oss2.Auth(settings.ALIYUN_OSS_ACCESS_KEY_ID, settings.ALIYUN_OSS_ACCESS_KEY_SECRET)
|
||||||
|
endpoint = settings.ALIYUN_OSS_ENDPOINT.rstrip("/")
|
||||||
|
bucket = oss2.Bucket(auth, endpoint, settings.ALIYUN_OSS_BUCKET_NAME)
|
||||||
|
bucket.put_object(object_name, file_bytes)
|
||||||
|
cdn = settings.ALIYUN_OSS_CDN_DOMAIN
|
||||||
|
if cdn:
|
||||||
|
return f"{cdn.rstrip('/')}/{object_name}"
|
||||||
|
return f"https://{settings.ALIYUN_OSS_BUCKET_NAME}.{endpoint.replace('https://', '').replace('http://', '')}/{object_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _call_aitryon(person_image_url: str, top_url: Optional[str], bottom_url: Optional[str]) -> str:
|
||||||
|
"""
|
||||||
|
调用 DashScope AI试衣-基础版(aitryon),同步轮询结果,返回 image_url。
|
||||||
|
"""
|
||||||
|
api_key = (settings.DASHSCOPE_API_KEY or "").strip()
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"X-DashScope-Async": "enable",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": settings.TRYON_MODEL or "aitryon",
|
||||||
|
"input": {
|
||||||
|
"person_image_url": _clean_url(person_image_url),
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"resolution": -1,
|
||||||
|
"restore_face": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if top_url:
|
||||||
|
payload["input"]["top_garment_url"] = _clean_url(top_url)
|
||||||
|
if bottom_url:
|
||||||
|
payload["input"]["bottom_garment_url"] = _clean_url(bottom_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_resp = requests.post(
|
||||||
|
"https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis/",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail="试衣任务创建失败") from exc
|
||||||
|
if create_resp.status_code != 200:
|
||||||
|
err_msg = create_resp.text or "试衣任务创建失败"
|
||||||
|
raise HTTPException(status_code=502, detail=f"试衣任务创建失败({create_resp.status_code}): {err_msg}")
|
||||||
|
task_info = create_resp.json()
|
||||||
|
task_id = task_info.get("output", {}).get("task_id") or task_info.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
raise HTTPException(status_code=502, detail="试衣任务未返回task_id")
|
||||||
|
|
||||||
|
query_url = f"https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}"
|
||||||
|
deadline = time.time() + 60
|
||||||
|
while time.time() < deadline:
|
||||||
|
time.sleep(2)
|
||||||
|
try:
|
||||||
|
q_resp = requests.get(
|
||||||
|
query_url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if q_resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
data = q_resp.json()
|
||||||
|
status_str = str(
|
||||||
|
data.get("output", {}).get("task_status")
|
||||||
|
or data.get("task_status")
|
||||||
|
or data.get("status")
|
||||||
|
).lower()
|
||||||
|
if status_str in ("succeeded", "success", "succeed", "finished"):
|
||||||
|
image_url = (
|
||||||
|
data.get("output", {}).get("image_url")
|
||||||
|
or (data.get("output", {}).get("results") or [{}])[0].get("url")
|
||||||
|
)
|
||||||
|
if image_url:
|
||||||
|
return image_url
|
||||||
|
raise HTTPException(status_code=502, detail="试衣任务返回空结果")
|
||||||
|
if status_str in ("failed", "error", "canceled"):
|
||||||
|
err = data.get("output", {}).get("message") or data.get("message") or "试衣任务失败"
|
||||||
|
raise HTTPException(status_code=502, detail=f"试衣失败: {err}")
|
||||||
|
raise HTTPException(status_code=504, detail="试衣任务超时")
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitItemOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
gender: str
|
||||||
|
image_url: str
|
||||||
|
is_free: bool
|
||||||
|
price_gold: int
|
||||||
|
is_vip_only: bool
|
||||||
|
is_owned: bool
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitLookOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
image_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitListResponse(BaseModel):
|
||||||
|
top: List[OutfitItemOut]
|
||||||
|
bottom: List[OutfitItemOut]
|
||||||
|
dress: List[OutfitItemOut]
|
||||||
|
top_total: int
|
||||||
|
bottom_total: int
|
||||||
|
dress_total: int
|
||||||
|
page: int
|
||||||
|
size: int
|
||||||
|
balance: float
|
||||||
|
clothes_num: int
|
||||||
|
outfit_slots: int
|
||||||
|
owned_outfit_ids: List[int] = Field(default_factory=list)
|
||||||
|
current_outfit: Optional[dict] = None
|
||||||
|
looks: List[OutfitLookOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitPurchaseIn(BaseModel):
|
||||||
|
item_id: int
|
||||||
|
# 目前仅小程序,预留字段,未来扩展平台时再打开校验
|
||||||
|
platform: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitPurchaseOut(BaseModel):
|
||||||
|
item_id: int
|
||||||
|
is_owned: bool
|
||||||
|
balance: float
|
||||||
|
owned_outfit_ids: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitChangeIn(BaseModel):
|
||||||
|
top_item_id: Optional[int] = None
|
||||||
|
bottom_item_id: Optional[int] = None
|
||||||
|
dress_item_id: Optional[int] = None
|
||||||
|
save_to_look: bool = False
|
||||||
|
look_name: Optional[str] = None
|
||||||
|
result_image_url: Optional[str] = None # 若前端已有生成结果,可传入落库
|
||||||
|
|
||||||
|
@field_validator("top_item_id", "bottom_item_id", "dress_item_id", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def empty_str_to_none(cls, v):
|
||||||
|
# 兼容前端传空字符串导致 422 的情况
|
||||||
|
if isinstance(v, str) and v.strip() == "":
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitChangeOut(BaseModel):
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
current_outfit: dict
|
||||||
|
clothes_num: int
|
||||||
|
owned_outfit_ids: List[int]
|
||||||
|
looks: List[OutfitLookOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitLooksResponse(BaseModel):
|
||||||
|
looks: List[OutfitLookOut]
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitUseResponse(BaseModel):
|
||||||
|
image_url: str
|
||||||
|
current_outfit: dict
|
||||||
|
looks: List[OutfitLookOut]
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitMallItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
image_url: str
|
||||||
|
price_gold: int
|
||||||
|
gender: str
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class OutfitMallResponse(BaseModel):
|
||||||
|
items: List[OutfitMallItem]
|
||||||
|
owned_outfit_ids: List[int]
|
||||||
|
balance: float
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/purchase", response_model=ApiResponse[OutfitPurchaseOut])
|
||||||
|
def purchase_outfit(
|
||||||
|
payload: OutfitPurchaseIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
购买服饰:
|
||||||
|
- 校验性别匹配(恋人 gender 或 unisex)
|
||||||
|
- 免费不可购买
|
||||||
|
- 已拥有不可购买
|
||||||
|
- 校验金币余额
|
||||||
|
"""
|
||||||
|
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
|
||||||
|
if not lover:
|
||||||
|
raise HTTPException(status_code=404, detail="恋人未找到")
|
||||||
|
|
||||||
|
item = db.query(OutfitItem).filter(OutfitItem.id == payload.item_id, OutfitItem.status == "1").first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="服饰不存在或已下架")
|
||||||
|
|
||||||
|
# 性别校验
|
||||||
|
if item.gender not in ("unisex", lover.gender):
|
||||||
|
raise HTTPException(status_code=400, detail="服饰与恋人性别不匹配")
|
||||||
|
|
||||||
|
# 免费服饰无需购买
|
||||||
|
if bool(item.is_free):
|
||||||
|
raise HTTPException(status_code=400, detail="该服饰为免费,无需购买")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_row = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.id == user.id)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
user_row = None
|
||||||
|
if not user_row:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
balance = _ensure_balance(user_row)
|
||||||
|
owned_ids = _parse_owned_outfits(user_row.owned_outfit_ids)
|
||||||
|
if int(item.id) in owned_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="已拥有该服饰,无需重复购买")
|
||||||
|
|
||||||
|
price = Decimal(item.price_gold or 0)
|
||||||
|
if price <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="服饰价格异常")
|
||||||
|
if balance < price:
|
||||||
|
raise HTTPException(status_code=400, detail="余额不足")
|
||||||
|
|
||||||
|
# 扣减余额,写拥有数据 & 日志(行锁保证并发安全)
|
||||||
|
before_balance = balance
|
||||||
|
balance -= price
|
||||||
|
user_row.money = float(balance)
|
||||||
|
owned_ids.add(int(item.id))
|
||||||
|
user_row.owned_outfit_ids = _serialize_owned_outfits(owned_ids)
|
||||||
|
db.add(user_row)
|
||||||
|
db.add(
|
||||||
|
OutfitPurchaseLog(
|
||||||
|
user_id=user.id,
|
||||||
|
item_id=item.id,
|
||||||
|
price_gold=int(price),
|
||||||
|
platform="miniapp", # 目前仅小程序
|
||||||
|
status="success",
|
||||||
|
createtime=_now_ts(),
|
||||||
|
updatetime=_now_ts(),
|
||||||
|
remark=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
UserMoneyLog(
|
||||||
|
user_id=user.id,
|
||||||
|
money=-price,
|
||||||
|
before=before_balance,
|
||||||
|
after=Decimal(user_row.money),
|
||||||
|
memo=f"购买服饰:{item.name}",
|
||||||
|
createtime=_now_ts(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="购买请求冲突,请重试")
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
OutfitPurchaseOut(
|
||||||
|
item_id=item.id,
|
||||||
|
is_owned=True,
|
||||||
|
balance=float(balance),
|
||||||
|
owned_outfit_ids=sorted(list(owned_ids)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change", response_model=ApiResponse[OutfitChangeOut])
|
||||||
|
def change_outfit(
|
||||||
|
payload: OutfitChangeIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
换装接口:
|
||||||
|
- 校验换装次数 `clothes_num` > 0
|
||||||
|
- 校验所选服饰存在、性别匹配、已拥有或免费
|
||||||
|
- 连衣裙(dress) 与 上/下装互斥;上/下装可单选或组合
|
||||||
|
- 可选择保存当前形象到形象栏(需有空位)
|
||||||
|
- 更新当前穿戴服饰 ID,落库传入的 result_image_url(如未提供则沿用旧图)
|
||||||
|
"""
|
||||||
|
lover = (
|
||||||
|
db.query(Lover)
|
||||||
|
.filter(Lover.user_id == user.id)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not lover:
|
||||||
|
raise HTTPException(status_code=404, detail="恋人未找到")
|
||||||
|
|
||||||
|
user_row = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.id == user.id)
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not user_row:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
# 换装次数校验
|
||||||
|
clothes_num = int(user_row.clothes_num or 0)
|
||||||
|
if clothes_num <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="换装次数不足")
|
||||||
|
|
||||||
|
# 选择校验
|
||||||
|
top_id = payload.top_item_id
|
||||||
|
bottom_id = payload.bottom_item_id
|
||||||
|
dress_id = payload.dress_item_id
|
||||||
|
if not any([top_id, bottom_id, dress_id]):
|
||||||
|
raise HTTPException(status_code=400, detail="请选择至少一个服饰")
|
||||||
|
if dress_id and (top_id or bottom_id):
|
||||||
|
raise HTTPException(status_code=400, detail="连衣裙/连体服与上/下装不可同时选择")
|
||||||
|
|
||||||
|
# 获取拥有集合
|
||||||
|
owned_ids = _parse_owned_outfits(user_row.owned_outfit_ids)
|
||||||
|
|
||||||
|
def check_item(item_id: int, expect_category: str):
|
||||||
|
item = (
|
||||||
|
db.query(OutfitItem)
|
||||||
|
.filter(
|
||||||
|
OutfitItem.id == item_id,
|
||||||
|
OutfitItem.category == expect_category,
|
||||||
|
OutfitItem.status == "1",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="服饰不存在或已下架")
|
||||||
|
if item.gender not in ("unisex", lover.gender):
|
||||||
|
raise HTTPException(status_code=400, detail="服饰与恋人性别不匹配")
|
||||||
|
if not bool(item.is_free) and int(item.id) not in owned_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="该服饰未拥有")
|
||||||
|
return item
|
||||||
|
|
||||||
|
top_item = check_item(top_id, "top") if top_id else None
|
||||||
|
bottom_item = check_item(bottom_id, "bottom") if bottom_id else None
|
||||||
|
dress_item = check_item(dress_id, "dress") if dress_id else None
|
||||||
|
|
||||||
|
# 保存前的形象信息(成功后若需要再写入形象栏)
|
||||||
|
prev_image = lover.image_url
|
||||||
|
prev_top = lover.outfit_top_id
|
||||||
|
prev_bottom = lover.outfit_bottom_id
|
||||||
|
prev_dress = lover.outfit_dress_id
|
||||||
|
slot_limit = int(user_row.outfit_slots or 0) or 0
|
||||||
|
if payload.save_to_look:
|
||||||
|
if slot_limit <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="形象栏位已满")
|
||||||
|
existing_count = (
|
||||||
|
db.query(OutfitLook)
|
||||||
|
.filter(OutfitLook.user_id == user.id, OutfitLook.deleted_at.is_(None))
|
||||||
|
.with_for_update()
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if existing_count >= slot_limit:
|
||||||
|
raise HTTPException(status_code=400, detail="形象栏位已满")
|
||||||
|
|
||||||
|
# 如需生成,调用试衣模型
|
||||||
|
result_url = payload.result_image_url
|
||||||
|
if not result_url:
|
||||||
|
person_image = _cdnize(_clean_url(lover.image_url))
|
||||||
|
if not person_image:
|
||||||
|
raise HTTPException(status_code=400, detail="缺少模特图")
|
||||||
|
top_url = _cdnize(_clean_url(top_item.image_url)) if top_item else None
|
||||||
|
bottom_url = _cdnize(_clean_url(bottom_item.image_url)) if bottom_item else None
|
||||||
|
# 连衣裙走 top_garment_url
|
||||||
|
if dress_item:
|
||||||
|
top_url = _cdnize(_clean_url(dress_item.image_url))
|
||||||
|
bottom_url = None
|
||||||
|
result_url = _call_aitryon(person_image, top_url, bottom_url)
|
||||||
|
|
||||||
|
# 扣减换装次数
|
||||||
|
user_row.clothes_num = max(0, clothes_num - 1)
|
||||||
|
|
||||||
|
# 更新当前穿戴
|
||||||
|
lover.outfit_top_id = top_item.id if top_item else None
|
||||||
|
lover.outfit_bottom_id = bottom_item.id if bottom_item else None
|
||||||
|
lover.outfit_dress_id = dress_item.id if dress_item else None
|
||||||
|
# 下载试衣结果并转存 OSS,避免临时 URL 失效
|
||||||
|
try:
|
||||||
|
img_resp = requests.get(result_url, timeout=15)
|
||||||
|
if img_resp.status_code != 200:
|
||||||
|
raise HTTPException(status_code=502, detail="生成图片下载失败")
|
||||||
|
object_name = f"lover/{lover.id}/images/{int(time.time())}_outfit.jpg"
|
||||||
|
oss_url = _upload_to_oss(img_resp.content, object_name)
|
||||||
|
result_url = oss_url
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail="生成图片保存失败") from exc
|
||||||
|
|
||||||
|
lover.image_url = _cdnize(result_url)
|
||||||
|
|
||||||
|
db.add(user_row)
|
||||||
|
db.add(lover)
|
||||||
|
# 成功后按需保存先前形象到形象栏
|
||||||
|
if payload.save_to_look:
|
||||||
|
db.add(
|
||||||
|
OutfitLook(
|
||||||
|
user_id=user.id,
|
||||||
|
lover_id=lover.id,
|
||||||
|
name=payload.look_name or "当前形象",
|
||||||
|
image_url=_cdnize(prev_image) or "",
|
||||||
|
top_item_id=prev_top,
|
||||||
|
bottom_item_id=prev_bottom,
|
||||||
|
dress_item_id=prev_dress,
|
||||||
|
createtime=datetime.utcnow(),
|
||||||
|
updatetime=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
looks = (
|
||||||
|
db.query(OutfitLook)
|
||||||
|
.filter(OutfitLook.user_id == user.id, OutfitLook.deleted_at.is_(None))
|
||||||
|
.order_by(OutfitLook.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
OutfitChangeOut(
|
||||||
|
image_url=lover.image_url,
|
||||||
|
current_outfit={
|
||||||
|
"top_id": lover.outfit_top_id,
|
||||||
|
"bottom_id": lover.outfit_bottom_id,
|
||||||
|
"dress_id": lover.outfit_dress_id,
|
||||||
|
},
|
||||||
|
clothes_num=user_row.clothes_num,
|
||||||
|
owned_outfit_ids=_serialize_owned_outfits(owned_ids),
|
||||||
|
looks=[
|
||||||
|
OutfitLookOut(
|
||||||
|
id=lk.id,
|
||||||
|
image_url=_cdnize(lk.image_url),
|
||||||
|
)
|
||||||
|
for lk in looks
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/looks/{look_id}", response_model=ApiResponse[OutfitLooksResponse])
|
||||||
|
def delete_look(
|
||||||
|
look_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除形象栏记录(软删),返回删除后的形象栏列表。
|
||||||
|
"""
|
||||||
|
look = (
|
||||||
|
db.query(OutfitLook)
|
||||||
|
.filter(OutfitLook.id == look_id, OutfitLook.user_id == user.id, OutfitLook.deleted_at.is_(None))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not look:
|
||||||
|
raise HTTPException(status_code=404, detail="形象不存在")
|
||||||
|
|
||||||
|
look.deleted_at = datetime.utcnow()
|
||||||
|
db.add(look)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
looks = (
|
||||||
|
db.query(OutfitLook)
|
||||||
|
.filter(OutfitLook.user_id == user.id, OutfitLook.deleted_at.is_(None))
|
||||||
|
.order_by(OutfitLook.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return success_response(
|
||||||
|
OutfitLooksResponse(
|
||||||
|
looks=[
|
||||||
|
OutfitLookOut(
|
||||||
|
id=lk.id,
|
||||||
|
image_url=_cdnize(lk.image_url),
|
||||||
|
)
|
||||||
|
for lk in looks
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/looks/{look_id}/use", response_model=ApiResponse[OutfitUseResponse])
|
||||||
|
@router.post("/looks/use/{look_id}", response_model=ApiResponse[OutfitUseResponse])
|
||||||
|
def use_look(
|
||||||
|
look_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
将形象栏的服饰组合和图片应用为当前形象。
|
||||||
|
"""
|
||||||
|
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
|
||||||
|
if not lover:
|
||||||
|
raise HTTPException(status_code=404, detail="恋人未找到")
|
||||||
|
|
||||||
|
# 记录当前恋人形象,用于互换
|
||||||
|
prev_image = lover.image_url
|
||||||
|
prev_top = lover.outfit_top_id
|
||||||
|
prev_bottom = lover.outfit_bottom_id
|
||||||
|
prev_dress = lover.outfit_dress_id
|
||||||
|
|
||||||
|
look = (
|
||||||
|
db.query(OutfitLook)
|
||||||
|
.filter(OutfitLook.id == look_id, OutfitLook.user_id == user.id, OutfitLook.deleted_at.is_(None))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not look:
|
||||||
|
raise HTTPException(status_code=404, detail="形象不存在")
|
||||||
|
if not look.image_url:
|
||||||
|
raise HTTPException(status_code=400, detail="形象缺少图片")
|
||||||
|
|
||||||
|
lover.outfit_top_id = look.top_item_id
|
||||||
|
lover.outfit_bottom_id = look.bottom_item_id
|
||||||
|
lover.outfit_dress_id = look.dress_item_id
|
||||||
|
lover.image_url = _cdnize(look.image_url)
|
||||||
|
|
||||||
|
# 将原恋人形象写回该形象栏记录,实现互换
|
||||||
|
look.top_item_id = prev_top
|
||||||
|
look.bottom_item_id = prev_bottom
|
||||||
|
look.dress_item_id = prev_dress
|
||||||
|
look.image_url = _cdnize(prev_image) if prev_image else look.image_url
|
||||||
|
|
||||||
|
db.add(lover)
|
||||||
|
db.add(look)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
looks = (
|
||||||
|
db.query(OutfitLook)
|
||||||
|
.filter(OutfitLook.user_id == user.id, OutfitLook.deleted_at.is_(None))
|
||||||
|
.order_by(OutfitLook.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return success_response(
|
||||||
|
OutfitUseResponse(
|
||||||
|
image_url=lover.image_url,
|
||||||
|
current_outfit={
|
||||||
|
"top_id": lover.outfit_top_id,
|
||||||
|
"bottom_id": lover.outfit_bottom_id,
|
||||||
|
"dress_id": lover.outfit_dress_id,
|
||||||
|
},
|
||||||
|
looks=[
|
||||||
|
OutfitLookOut(
|
||||||
|
id=lk.id,
|
||||||
|
image_url=_cdnize(lk.image_url),
|
||||||
|
)
|
||||||
|
for lk in looks
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/list", response_model=ApiResponse[OutfitListResponse])
|
||||||
|
def list_outfits(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(7, ge=1),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
根据恋人性别 + unisex 返回三类服饰列表(上装/下装/连衣裙),分页(默认每页 7),并回传当前穿戴的服饰 ID 与形象栏信息。
|
||||||
|
"""
|
||||||
|
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
|
||||||
|
if not lover:
|
||||||
|
raise HTTPException(status_code=404, detail="恋人未找到")
|
||||||
|
|
||||||
|
gender_filter = [lover.gender, "unisex"]
|
||||||
|
|
||||||
|
# 用户已拥有的服饰集合:仅使用 nf_user.owned_outfit_ids(逗号或 JSON)
|
||||||
|
owned_ids: set[int] = set()
|
||||||
|
user_row = db.query(User).filter(User.id == user.id).first()
|
||||||
|
balance_val = 0.0
|
||||||
|
clothes_num_val = 0
|
||||||
|
outfit_slots_val = 0
|
||||||
|
if user_row:
|
||||||
|
balance_val = float(_ensure_balance(user_row))
|
||||||
|
owned_ids = _parse_owned_outfits(user_row.owned_outfit_ids)
|
||||||
|
clothes_num_val = int(user_row.clothes_num or 0)
|
||||||
|
outfit_slots_val = int(user_row.outfit_slots or 0)
|
||||||
|
|
||||||
|
def fetch_category(cat: str):
|
||||||
|
q = (
|
||||||
|
db.query(OutfitItem)
|
||||||
|
.filter(
|
||||||
|
OutfitItem.category == cat,
|
||||||
|
OutfitItem.gender.in_(gender_filter),
|
||||||
|
OutfitItem.status == "1",
|
||||||
|
)
|
||||||
|
.order_by(OutfitItem.weigh.desc(), OutfitItem.id.asc())
|
||||||
|
)
|
||||||
|
total_count = q.count()
|
||||||
|
records = q.offset((page - 1) * size).limit(size).all()
|
||||||
|
return total_count, records
|
||||||
|
|
||||||
|
looks = (
|
||||||
|
db.query(OutfitLook)
|
||||||
|
.filter(OutfitLook.user_id == user.id, OutfitLook.deleted_at.is_(None))
|
||||||
|
.order_by(OutfitLook.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
top_total, top_items = fetch_category("top")
|
||||||
|
bottom_total, bottom_items = fetch_category("bottom")
|
||||||
|
dress_total, dress_items = fetch_category("dress")
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
OutfitListResponse(
|
||||||
|
top=[
|
||||||
|
OutfitItemOut(
|
||||||
|
id=item.id,
|
||||||
|
name=item.name,
|
||||||
|
category=item.category,
|
||||||
|
gender=item.gender,
|
||||||
|
image_url=_cdnize(item.image_url),
|
||||||
|
is_free=bool(item.is_free),
|
||||||
|
price_gold=item.price_gold or 0,
|
||||||
|
is_vip_only=bool(item.is_vip_only),
|
||||||
|
is_owned=item.id in owned_ids,
|
||||||
|
)
|
||||||
|
for item in top_items
|
||||||
|
],
|
||||||
|
bottom=[
|
||||||
|
OutfitItemOut(
|
||||||
|
id=item.id,
|
||||||
|
name=item.name,
|
||||||
|
category=item.category,
|
||||||
|
gender=item.gender,
|
||||||
|
image_url=_cdnize(item.image_url),
|
||||||
|
is_free=bool(item.is_free),
|
||||||
|
price_gold=item.price_gold or 0,
|
||||||
|
is_vip_only=bool(item.is_vip_only),
|
||||||
|
is_owned=item.id in owned_ids,
|
||||||
|
)
|
||||||
|
for item in bottom_items
|
||||||
|
],
|
||||||
|
dress=[
|
||||||
|
OutfitItemOut(
|
||||||
|
id=item.id,
|
||||||
|
name=item.name,
|
||||||
|
category=item.category,
|
||||||
|
gender=item.gender,
|
||||||
|
image_url=_cdnize(item.image_url),
|
||||||
|
is_free=bool(item.is_free),
|
||||||
|
price_gold=item.price_gold or 0,
|
||||||
|
is_vip_only=bool(item.is_vip_only),
|
||||||
|
is_owned=item.id in owned_ids,
|
||||||
|
)
|
||||||
|
for item in dress_items
|
||||||
|
],
|
||||||
|
top_total=top_total,
|
||||||
|
bottom_total=bottom_total,
|
||||||
|
dress_total=dress_total,
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
balance=balance_val,
|
||||||
|
clothes_num=clothes_num_val,
|
||||||
|
outfit_slots=outfit_slots_val,
|
||||||
|
owned_outfit_ids=sorted(list(owned_ids)),
|
||||||
|
current_outfit={
|
||||||
|
"top_id": lover.outfit_top_id,
|
||||||
|
"bottom_id": lover.outfit_bottom_id,
|
||||||
|
"dress_id": lover.outfit_dress_id,
|
||||||
|
},
|
||||||
|
looks=[
|
||||||
|
OutfitLookOut(
|
||||||
|
id=lk.id,
|
||||||
|
image_url=_cdnize(lk.image_url),
|
||||||
|
)
|
||||||
|
for lk in looks
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mall", response_model=ApiResponse[OutfitMallResponse])
|
||||||
|
def list_paid_outfits(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
金币商场:返回当前恋人性别(含 unisex)的所有付费服饰、已拥有列表和金币余额。
|
||||||
|
"""
|
||||||
|
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
|
||||||
|
if not lover:
|
||||||
|
raise HTTPException(status_code=404, detail="恋人未找到")
|
||||||
|
|
||||||
|
user_row = db.query(User).filter(User.id == user.id).first()
|
||||||
|
if not user_row:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
balance = float(_ensure_balance(user_row))
|
||||||
|
owned_ids = _parse_owned_outfits(user_row.owned_outfit_ids)
|
||||||
|
|
||||||
|
gender_filter = [lover.gender, "unisex"]
|
||||||
|
items = (
|
||||||
|
db.query(OutfitItem)
|
||||||
|
.filter(
|
||||||
|
OutfitItem.status == "1",
|
||||||
|
OutfitItem.is_free.is_(False),
|
||||||
|
OutfitItem.price_gold > 0,
|
||||||
|
OutfitItem.gender.in_(gender_filter),
|
||||||
|
)
|
||||||
|
.order_by(OutfitItem.weigh.desc(), OutfitItem.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
OutfitMallResponse(
|
||||||
|
items=[
|
||||||
|
OutfitMallItem(
|
||||||
|
id=item.id,
|
||||||
|
name=item.name,
|
||||||
|
image_url=_cdnize(item.image_url),
|
||||||
|
price_gold=item.price_gold or 0,
|
||||||
|
gender=item.gender,
|
||||||
|
category=item.category,
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
],
|
||||||
|
owned_outfit_ids=sorted(list(owned_ids)),
|
||||||
|
balance=balance,
|
||||||
|
)
|
||||||
|
)
|
||||||
2533
lover/routers/sing.py
Normal file
2533
lover/routers/sing.py
Normal file
File diff suppressed because it is too large
Load Diff
587
lover/routers/voice_call.py
Normal file
587
lover/routers/voice_call.py
Normal file
|
|
@ -0,0 +1,587 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import dashscope
|
||||||
|
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||||
|
from fastapi.websockets import WebSocketState
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..deps import AuthedUser, _fetch_user_from_php
|
||||||
|
from ..llm import chat_completion_stream
|
||||||
|
from ..tts import synthesize
|
||||||
|
from ..db import SessionLocal
|
||||||
|
from ..models import Lover, VoiceLibrary
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult
|
||||||
|
except Exception: # dashscope 未安装时提供兜底
|
||||||
|
Recognition = None
|
||||||
|
RecognitionCallback = object # type: ignore
|
||||||
|
RecognitionResult = object # type: ignore
|
||||||
|
try:
|
||||||
|
from dashscope.audio.tts_v2 import AudioFormat, SpeechSynthesizer, ResultCallback
|
||||||
|
except Exception:
|
||||||
|
AudioFormat = None # type: ignore
|
||||||
|
SpeechSynthesizer = None # type: ignore
|
||||||
|
ResultCallback = object # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/voice", tags=["voice"])
|
||||||
|
logger = logging.getLogger("voice_call")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
END_OF_TTS = "<<VOICE_CALL_TTS_END>>"
|
||||||
|
|
||||||
|
|
||||||
|
class WSRecognitionCallback(RecognitionCallback): # type: ignore[misc]
|
||||||
|
"""ASR 回调,将句子级结果推入会话队列。"""
|
||||||
|
|
||||||
|
def __init__(self, session: "VoiceCallSession"):
|
||||||
|
super().__init__()
|
||||||
|
self.session = session
|
||||||
|
self._last_text: Optional[str] = None
|
||||||
|
|
||||||
|
def on_open(self) -> None:
|
||||||
|
logger.info("ASR connection opened")
|
||||||
|
|
||||||
|
def on_complete(self) -> None:
|
||||||
|
logger.info("ASR complete")
|
||||||
|
if self._last_text:
|
||||||
|
# 将最后的部分作为一句结束,防止没有 end 标记时丢失
|
||||||
|
self.session._schedule(self.session.handle_sentence(self._last_text))
|
||||||
|
logger.info("ASR flush last text on complete: %s", self._last_text)
|
||||||
|
self._last_text = None
|
||||||
|
|
||||||
|
def on_error(self, result: RecognitionResult) -> None:
|
||||||
|
logger.error("ASR error: %s", getattr(result, "message", None) or result)
|
||||||
|
if self._last_text:
|
||||||
|
self.session._schedule(self.session.handle_sentence(self._last_text))
|
||||||
|
logger.info("ASR flush last text on error: %s", self._last_text)
|
||||||
|
self._last_text = None
|
||||||
|
|
||||||
|
def on_close(self) -> None:
|
||||||
|
logger.info("ASR closed")
|
||||||
|
if self._last_text:
|
||||||
|
self.session._schedule(self.session.handle_sentence(self._last_text))
|
||||||
|
logger.info("ASR flush last text on close: %s", self._last_text)
|
||||||
|
self._last_text = None
|
||||||
|
|
||||||
|
def on_event(self, result: RecognitionResult) -> None:
|
||||||
|
sentence = result.get_sentence()
|
||||||
|
if not sentence:
|
||||||
|
return
|
||||||
|
|
||||||
|
sentences = sentence if isinstance(sentence, list) else [sentence]
|
||||||
|
for sent in sentences:
|
||||||
|
text = sent.get("text") if isinstance(sent, dict) else None
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
is_end = False
|
||||||
|
if isinstance(sent, dict):
|
||||||
|
is_end = (
|
||||||
|
bool(sent.get("is_sentence_end"))
|
||||||
|
or bool(sent.get("sentence_end"))
|
||||||
|
or RecognitionResult.is_sentence_end(sent)
|
||||||
|
)
|
||||||
|
if is_end:
|
||||||
|
self.session._schedule(self.session.handle_sentence(text))
|
||||||
|
self._last_text = None
|
||||||
|
else:
|
||||||
|
self.session._schedule(self.session.send_signal({"type": "partial_asr", "text": text}))
|
||||||
|
self._last_text = text
|
||||||
|
logger.info("ASR event end=%s sentence=%s", is_end, sent)
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_websocket(websocket: WebSocket) -> AuthedUser:
|
||||||
|
"""复用 HTTP 鉴权逻辑:Authorization / X-Token / x_user_id(调试)"""
|
||||||
|
headers = websocket.headers
|
||||||
|
token = None
|
||||||
|
auth_header = headers.get("authorization")
|
||||||
|
if auth_header and auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
if not token:
|
||||||
|
token = headers.get("x-token")
|
||||||
|
|
||||||
|
# 支持 query 携带
|
||||||
|
if not token:
|
||||||
|
token = websocket.query_params.get("token")
|
||||||
|
x_user_id = websocket.query_params.get("x_user_id")
|
||||||
|
|
||||||
|
if token:
|
||||||
|
payload = _fetch_user_from_php(token)
|
||||||
|
user_id = payload.get("id") or payload.get("user_id")
|
||||||
|
reg_step = payload.get("reg_step") or payload.get("stage") or 1
|
||||||
|
gender = payload.get("gender") or 0
|
||||||
|
nickname = payload.get("nickname") or payload.get("username") or ""
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户中心缺少用户ID")
|
||||||
|
return AuthedUser(
|
||||||
|
id=user_id,
|
||||||
|
reg_step=reg_step,
|
||||||
|
gender=gender,
|
||||||
|
nickname=nickname,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
if x_user_id is not None:
|
||||||
|
try:
|
||||||
|
uid = int(x_user_id)
|
||||||
|
except Exception:
|
||||||
|
uid = None
|
||||||
|
if uid is not None:
|
||||||
|
return AuthedUser(id=uid, reg_step=2, gender=0, nickname="debug-user", token="")
|
||||||
|
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或未授权")
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceCallSession:
|
||||||
|
def __init__(self, websocket: WebSocket, user: AuthedUser, require_ptt: bool = False):
|
||||||
|
self.websocket = websocket
|
||||||
|
self.user = user
|
||||||
|
self.require_ptt = require_ptt
|
||||||
|
self.mic_enabled = not require_ptt
|
||||||
|
self.loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
self.asr_to_llm: asyncio.Queue[str] = asyncio.Queue()
|
||||||
|
self.llm_to_tts: asyncio.Queue[str] = asyncio.Queue()
|
||||||
|
self.is_speaking = False
|
||||||
|
self.lover: Optional[Lover] = None
|
||||||
|
self.db = SessionLocal()
|
||||||
|
self.voice_code: Optional[str] = None
|
||||||
|
self.history: List[dict] = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": self._compose_system_prompt(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self.llm_task: Optional[asyncio.Task] = None
|
||||||
|
self.tts_task: Optional[asyncio.Task] = None
|
||||||
|
self.tts_stream_task: Optional[asyncio.Task] = None
|
||||||
|
self.silence_task: Optional[asyncio.Task] = None
|
||||||
|
self.cancel_event = asyncio.Event()
|
||||||
|
self.recognition: Optional[Recognition] = None
|
||||||
|
self.idle_task: Optional[asyncio.Task] = None
|
||||||
|
self.last_activity = time.time()
|
||||||
|
self.last_voice_activity = time.time()
|
||||||
|
self.has_voice_input = False
|
||||||
|
self.last_interrupt_time = 0.0
|
||||||
|
self.tts_first_chunk = True
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
await self.websocket.accept()
|
||||||
|
self.loop = asyncio.get_running_loop()
|
||||||
|
# 预加载恋人与音色,避免在流式环节阻塞事件循环
|
||||||
|
self._prepare_profile()
|
||||||
|
# 启动 ASR
|
||||||
|
self._start_asr()
|
||||||
|
# 启动 LLM/TTS 后台任务
|
||||||
|
self.llm_task = asyncio.create_task(self._process_llm_loop())
|
||||||
|
self.tts_task = asyncio.create_task(self._process_tts_loop())
|
||||||
|
self.idle_task = asyncio.create_task(self._idle_watchdog())
|
||||||
|
self.silence_task = asyncio.create_task(self._silence_watchdog())
|
||||||
|
await self.send_signal({"type": "ready"})
|
||||||
|
if self.require_ptt:
|
||||||
|
await self.send_signal({"type": "info", "msg": "ptt_enabled"})
|
||||||
|
|
||||||
|
def _start_asr(self):
|
||||||
|
if Recognition is None:
|
||||||
|
raise HTTPException(status_code=500, detail="未安装 dashscope,无法启动实时 ASR")
|
||||||
|
if not settings.DASHSCOPE_API_KEY:
|
||||||
|
raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY")
|
||||||
|
dashscope.api_key = settings.DASHSCOPE_API_KEY
|
||||||
|
callback = WSRecognitionCallback(self)
|
||||||
|
self.recognition = Recognition(
|
||||||
|
model=settings.VOICE_CALL_ASR_MODEL or "paraformer-realtime-v2",
|
||||||
|
format="pcm",
|
||||||
|
sample_rate=settings.VOICE_CALL_ASR_SAMPLE_RATE or 16000,
|
||||||
|
api_key=settings.DASHSCOPE_API_KEY,
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"ASR started model=%s sample_rate=%s",
|
||||||
|
settings.VOICE_CALL_ASR_MODEL or "paraformer-realtime-v2",
|
||||||
|
settings.VOICE_CALL_ASR_SAMPLE_RATE or 16000,
|
||||||
|
)
|
||||||
|
self.recognition.start()
|
||||||
|
|
||||||
|
async def handle_sentence(self, text: str):
|
||||||
|
# 回合制:AI 说话时忽略用户语音,提示稍后再说
|
||||||
|
if self.is_speaking:
|
||||||
|
await self.send_signal({"type": "info", "msg": "请等待 AI 说完再讲话"})
|
||||||
|
return
|
||||||
|
logger.info("Handle sentence: %s", text)
|
||||||
|
await self.asr_to_llm.put(text)
|
||||||
|
|
||||||
|
async def _process_llm_loop(self):
|
||||||
|
while True:
|
||||||
|
text = await self.asr_to_llm.get()
|
||||||
|
self.cancel_event.clear()
|
||||||
|
try:
|
||||||
|
await self._stream_llm(text)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("LLM error", exc_info=exc)
|
||||||
|
await self.send_signal({"type": "error", "msg": "LLM 生成失败"})
|
||||||
|
self.is_speaking = False
|
||||||
|
|
||||||
|
async def _stream_llm(self, text: str):
|
||||||
|
self.history.append({"role": "user", "content": text})
|
||||||
|
# 控制历史长度
|
||||||
|
if len(self.history) > settings.VOICE_CALL_MAX_HISTORY:
|
||||||
|
self.history = self.history[-settings.VOICE_CALL_MAX_HISTORY :]
|
||||||
|
stream = chat_completion_stream(self.history)
|
||||||
|
self.is_speaking = True
|
||||||
|
self.tts_first_chunk = True
|
||||||
|
buffer = []
|
||||||
|
for chunk in stream:
|
||||||
|
if self.cancel_event.is_set():
|
||||||
|
break
|
||||||
|
buffer.append(chunk)
|
||||||
|
await self.llm_to_tts.put(chunk)
|
||||||
|
if not self.cancel_event.is_set():
|
||||||
|
await self.llm_to_tts.put(END_OF_TTS)
|
||||||
|
full_reply = "".join(buffer)
|
||||||
|
self.history.append({"role": "assistant", "content": full_reply})
|
||||||
|
if full_reply:
|
||||||
|
# 下行完整文本,便于前端展示/调试
|
||||||
|
await self.send_signal({"type": "reply_text", "text": full_reply})
|
||||||
|
else:
|
||||||
|
self.is_speaking = False
|
||||||
|
|
||||||
|
async def _process_tts_loop(self):
|
||||||
|
temp_buffer = []
|
||||||
|
punctuations = set(",。?!,.?!;;")
|
||||||
|
while True:
|
||||||
|
token = await self.llm_to_tts.get()
|
||||||
|
if self.cancel_event.is_set():
|
||||||
|
temp_buffer = []
|
||||||
|
self.tts_first_chunk = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if token == END_OF_TTS:
|
||||||
|
# 将残余缓冲送出
|
||||||
|
if temp_buffer:
|
||||||
|
text_chunk = "".join(temp_buffer)
|
||||||
|
temp_buffer = []
|
||||||
|
clean_text = self._clean_tts_text(text_chunk)
|
||||||
|
if clean_text:
|
||||||
|
try:
|
||||||
|
async for chunk in self._synthesize_stream(clean_text):
|
||||||
|
if self.cancel_event.is_set():
|
||||||
|
break
|
||||||
|
await self.websocket.send_bytes(chunk)
|
||||||
|
self._touch()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("TTS error", exc_info=exc)
|
||||||
|
await self.send_signal({"type": "error", "code": "tts_failed", "msg": "TTS 合成失败"})
|
||||||
|
self.is_speaking = False
|
||||||
|
continue
|
||||||
|
self.tts_first_chunk = True
|
||||||
|
self.is_speaking = False
|
||||||
|
await self.send_signal({"type": "reply_end"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
temp_buffer.append(token)
|
||||||
|
last_char = token[-1] if token else ""
|
||||||
|
threshold = 8 if self.tts_first_chunk else 18
|
||||||
|
if last_char in punctuations or len("".join(temp_buffer)) >= threshold:
|
||||||
|
text_chunk = "".join(temp_buffer)
|
||||||
|
temp_buffer = []
|
||||||
|
self.tts_first_chunk = False
|
||||||
|
clean_text = self._clean_tts_text(text_chunk)
|
||||||
|
if not clean_text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
async for chunk in self._synthesize_stream(clean_text):
|
||||||
|
if self.cancel_event.is_set():
|
||||||
|
break
|
||||||
|
await self.websocket.send_bytes(chunk)
|
||||||
|
self._touch()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("TTS error", exc_info=exc)
|
||||||
|
await self.send_signal({"type": "error", "code": "tts_failed", "msg": "TTS 合成失败"})
|
||||||
|
self.is_speaking = False
|
||||||
|
# 不可达,但保留以防逻辑调整
|
||||||
|
|
||||||
|
async def _synthesize_stream(self, text: str):
|
||||||
|
"""
|
||||||
|
调用 cosyvoice v2 流式合成,逐 chunk 返回。
|
||||||
|
如流式不可用则回落一次性合成。
|
||||||
|
"""
|
||||||
|
model = settings.VOICE_CALL_TTS_MODEL or "cosyvoice-v2"
|
||||||
|
voice = self._pick_voice_code() or settings.VOICE_CALL_TTS_VOICE or "longxiaochun_v2"
|
||||||
|
fmt = settings.VOICE_CALL_TTS_FORMAT.lower() if settings.VOICE_CALL_TTS_FORMAT else "mp3"
|
||||||
|
audio_format = AudioFormat.MP3_22050HZ_MONO_256KBPS if fmt == "mp3" else AudioFormat.PCM_16000HZ_MONO
|
||||||
|
|
||||||
|
# 直接同步合成,避免流式阻塞
|
||||||
|
audio_bytes, _fmt_name = synthesize(text, model=model, voice=voice, audio_format=audio_format) # type: ignore[arg-type]
|
||||||
|
yield audio_bytes
|
||||||
|
|
||||||
|
async def feed_audio(self, data: bytes):
|
||||||
|
if self.require_ptt and not self.mic_enabled:
|
||||||
|
# PTT 模式下未按住说话时丢弃音频
|
||||||
|
self._touch()
|
||||||
|
return
|
||||||
|
# 若之前 stop 过,则懒启动
|
||||||
|
if not (self.recognition and getattr(self.recognition, "_running", False)):
|
||||||
|
try:
|
||||||
|
self._start_asr()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("ASR restart failed: %s", exc)
|
||||||
|
return
|
||||||
|
if self.recognition:
|
||||||
|
self.recognition.send_audio_frame(data)
|
||||||
|
logger.debug("recv audio chunk bytes=%s", len(data))
|
||||||
|
peak = self._peak_pcm16(data)
|
||||||
|
now = time.time()
|
||||||
|
if peak > 300: # 只用于活跃检测,不再触发打断
|
||||||
|
self.last_voice_activity = now
|
||||||
|
self.has_voice_input = True
|
||||||
|
self._touch()
|
||||||
|
|
||||||
|
def finalize_asr(self):
|
||||||
|
"""主动停止 ASR,促使返回最终结果。"""
|
||||||
|
try:
|
||||||
|
if self.recognition:
|
||||||
|
self.recognition.stop()
|
||||||
|
logger.info("ASR stop requested manually")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("ASR stop failed: %s", exc)
|
||||||
|
|
||||||
|
async def set_mic_enabled(self, enabled: bool, flush: bool = False):
|
||||||
|
if not self.require_ptt:
|
||||||
|
return
|
||||||
|
self.mic_enabled = enabled
|
||||||
|
await self.send_signal({"type": "info", "msg": "mic_on" if enabled else "mic_off"})
|
||||||
|
if not enabled and flush:
|
||||||
|
self.finalize_asr()
|
||||||
|
|
||||||
|
def _schedule(self, coro):
|
||||||
|
if self.loop:
|
||||||
|
self.loop.call_soon_threadsafe(asyncio.create_task, coro)
|
||||||
|
|
||||||
|
def _pick_voice_code(self) -> Optional[str]:
|
||||||
|
"""根据恋人配置或默认音色选择 voice_code。"""
|
||||||
|
if self.voice_code:
|
||||||
|
return self.voice_code
|
||||||
|
self._prepare_profile()
|
||||||
|
return self.voice_code
|
||||||
|
|
||||||
|
async def _interrupt(self):
|
||||||
|
self.cancel_event.set()
|
||||||
|
# 清空队列
|
||||||
|
while not self.llm_to_tts.empty():
|
||||||
|
try:
|
||||||
|
self.llm_to_tts.get_nowait()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
await self.send_signal({"type": "interrupt", "code": "interrupted", "msg": "AI 打断,停止播放"})
|
||||||
|
self.is_speaking = False
|
||||||
|
self.last_interrupt_time = time.time()
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self.db:
|
||||||
|
try:
|
||||||
|
self.db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self.recognition:
|
||||||
|
try:
|
||||||
|
self.recognition.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self.llm_task:
|
||||||
|
self.llm_task.cancel()
|
||||||
|
if self.tts_task:
|
||||||
|
self.tts_task.cancel()
|
||||||
|
if self.tts_stream_task:
|
||||||
|
self.tts_stream_task.cancel()
|
||||||
|
if self.idle_task:
|
||||||
|
self.idle_task.cancel()
|
||||||
|
if self.silence_task:
|
||||||
|
self.silence_task.cancel()
|
||||||
|
if self.websocket.client_state == WebSocketState.CONNECTED:
|
||||||
|
await self.websocket.close()
|
||||||
|
|
||||||
|
async def send_signal(self, payload: dict):
|
||||||
|
if self.websocket.client_state != WebSocketState.CONNECTED:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.websocket.send_text(json.dumps(payload, ensure_ascii=False))
|
||||||
|
self._touch()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _load_lover(self) -> Optional[Lover]:
|
||||||
|
if self.lover is not None:
|
||||||
|
return self.lover
|
||||||
|
try:
|
||||||
|
self.lover = self.db.query(Lover).filter(Lover.user_id == self.user.id).first()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Load lover failed: %s", exc)
|
||||||
|
self.lover = None
|
||||||
|
return self.lover
|
||||||
|
|
||||||
|
def _compose_system_prompt(self) -> str:
|
||||||
|
parts = [
|
||||||
|
f"你是用户 {self.user.nickname or '用户'} 的虚拟恋人,请用亲密、温暖、口语化的短句聊天,不要使用 Markdown 符号,不要输出表情、波浪线、星号或动作描述。",
|
||||||
|
"回复必须是对话内容,不要包含括号/星号/动作描写/舞台指令,不要用拟声词凑字数,保持简短自然的中文口语句子。",
|
||||||
|
"禁止涉政、违法、暴力、未成年相关内容。",
|
||||||
|
]
|
||||||
|
lover = self._load_lover()
|
||||||
|
if lover and lover.personality_prompt:
|
||||||
|
parts.append(f"人格设定:{lover.personality_prompt}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_tts_text(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# 去掉常见 Markdown/代码标记,保留文字内容
|
||||||
|
text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
|
||||||
|
text = re.sub(r"`([^`]*)`", r"\1", text)
|
||||||
|
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
||||||
|
text = re.sub(r"\*[^\*]{0,80}\*", "", text) # 去掉 *动作* 片段
|
||||||
|
text = re.sub(r"[~~]+", "", text) # 去掉波浪线
|
||||||
|
text = text.replace("*", "")
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _prepare_profile(self) -> None:
|
||||||
|
"""预加载恋人和音色,避免在流式阶段阻塞事件循环。"""
|
||||||
|
try:
|
||||||
|
lover = self._load_lover()
|
||||||
|
if lover and lover.voice_id:
|
||||||
|
voice = self.db.query(VoiceLibrary).filter(VoiceLibrary.id == lover.voice_id).first()
|
||||||
|
if voice and voice.voice_code:
|
||||||
|
self.voice_code = voice.voice_code
|
||||||
|
return
|
||||||
|
|
||||||
|
gender = None
|
||||||
|
if lover and lover.gender:
|
||||||
|
gender = lover.gender
|
||||||
|
if not gender:
|
||||||
|
gender = "female" if (self.user.gender or 0) == 1 else "male"
|
||||||
|
|
||||||
|
voice = (
|
||||||
|
self.db.query(VoiceLibrary)
|
||||||
|
.filter(VoiceLibrary.gender == gender, VoiceLibrary.is_default.is_(True))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if voice and voice.voice_code:
|
||||||
|
self.voice_code = voice.voice_code
|
||||||
|
return
|
||||||
|
voice = (
|
||||||
|
self.db.query(VoiceLibrary)
|
||||||
|
.filter(VoiceLibrary.gender == gender)
|
||||||
|
.order_by(VoiceLibrary.id.asc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if voice and voice.voice_code:
|
||||||
|
self.voice_code = voice.voice_code
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Prepare profile failed: %s", exc)
|
||||||
|
|
||||||
|
def _touch(self):
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
async def _idle_watchdog(self):
|
||||||
|
timeout = settings.VOICE_CALL_IDLE_TIMEOUT or 0
|
||||||
|
if timeout <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
if time.time() - self.last_activity > timeout:
|
||||||
|
await self.send_signal({"type": "error", "msg": "idle timeout"})
|
||||||
|
await self.close()
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _silence_watchdog(self):
|
||||||
|
"""长时间静默时关闭会话,ASR 常驻不再因短静音 stop。"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
if time.time() - self.last_voice_activity > 60:
|
||||||
|
logger.info("Long silence, closing session")
|
||||||
|
await self.send_signal({"type": "error", "msg": "idle timeout"})
|
||||||
|
await self.close()
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _peak_pcm16(data: bytes) -> int:
|
||||||
|
"""快速估算 PCM 16bit 峰值幅度。"""
|
||||||
|
if not data:
|
||||||
|
return 0
|
||||||
|
view = memoryview(data)
|
||||||
|
# 每 2 字节一采样,取绝对值最大
|
||||||
|
max_val = 0
|
||||||
|
for i in range(0, len(view) - 1, 2):
|
||||||
|
sample = int.from_bytes(view[i : i + 2], "little", signed=True)
|
||||||
|
if sample < 0:
|
||||||
|
sample = -sample
|
||||||
|
if sample > max_val:
|
||||||
|
max_val = sample
|
||||||
|
return max_val
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/call")
|
||||||
|
async def voice_call(websocket: WebSocket):
|
||||||
|
try:
|
||||||
|
user = await authenticate_websocket(websocket)
|
||||||
|
except HTTPException as exc:
|
||||||
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
|
return
|
||||||
|
|
||||||
|
ptt_param = (websocket.query_params.get("ptt") or "").strip().lower()
|
||||||
|
require_ptt = settings.VOICE_CALL_REQUIRE_PTT or ptt_param in ("1", "true", "yes", "on")
|
||||||
|
session = VoiceCallSession(websocket, user, require_ptt=require_ptt)
|
||||||
|
try:
|
||||||
|
await session.start()
|
||||||
|
except HTTPException as exc:
|
||||||
|
try:
|
||||||
|
await websocket.accept()
|
||||||
|
await websocket.send_text(json.dumps({"type": "error", "msg": exc.detail}))
|
||||||
|
await websocket.close(code=status.WS_1011_INTERNAL_ERROR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await websocket.receive()
|
||||||
|
if "bytes" in msg and msg["bytes"] is not None:
|
||||||
|
await session.feed_audio(msg["bytes"])
|
||||||
|
elif "text" in msg and msg["text"]:
|
||||||
|
# 简单心跳/信令
|
||||||
|
text = msg["text"].strip()
|
||||||
|
lower_text = text.lower()
|
||||||
|
if lower_text in ("mic_on", "ptt_on"):
|
||||||
|
await session.set_mic_enabled(True)
|
||||||
|
elif lower_text in ("mic_off", "ptt_off"):
|
||||||
|
await session.set_mic_enabled(False, flush=True)
|
||||||
|
elif text == "ping":
|
||||||
|
await websocket.send_text("pong")
|
||||||
|
elif text in ("end", "stop", "flush"):
|
||||||
|
session.finalize_asr()
|
||||||
|
await session.send_signal({"type": "info", "msg": "ASR stopped manually"})
|
||||||
|
else:
|
||||||
|
await session.send_signal({"type": "info", "msg": "文本消息已忽略"})
|
||||||
|
if msg.get("type") == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
66
lover/task_queue.py
Normal file
66
lover/task_queue.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleTaskQueue:
|
||||||
|
def __init__(self, name: str, maxsize: int = 0):
|
||||||
|
self._name = name
|
||||||
|
self._queue: "queue.Queue[tuple[Optional[str], Callable, tuple, dict]]" = queue.Queue(maxsize=maxsize)
|
||||||
|
self._threads: list[threading.Thread] = []
|
||||||
|
self._started = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._inflight: set[str] = set()
|
||||||
|
self._inflight_lock = threading.Lock()
|
||||||
|
|
||||||
|
def start(self, workers: int):
|
||||||
|
with self._lock:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self._started = True
|
||||||
|
for idx in range(max(1, workers)):
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._worker,
|
||||||
|
name=f"{self._name}-worker-{idx + 1}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
self._threads.append(thread)
|
||||||
|
|
||||||
|
def enqueue(self, func: Callable, *args, **kwargs) -> None:
|
||||||
|
self._queue.put((None, func, args, kwargs))
|
||||||
|
|
||||||
|
def enqueue_unique(self, key: str, func: Callable, *args, **kwargs) -> bool:
|
||||||
|
if not key:
|
||||||
|
self.enqueue(func, *args, **kwargs)
|
||||||
|
return True
|
||||||
|
with self._inflight_lock:
|
||||||
|
if key in self._inflight:
|
||||||
|
return False
|
||||||
|
self._inflight.add(key)
|
||||||
|
self._queue.put((key, func, args, kwargs))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _worker(self):
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
sing_task_queue = SimpleTaskQueue("sing")
|
||||||
|
|
||||||
|
|
||||||
|
def start_sing_workers():
|
||||||
|
workers = max(1, settings.SING_MERGE_MAX_CONCURRENCY or 1)
|
||||||
|
sing_task_queue.start(workers)
|
||||||
65
lover/tts.py
Normal file
65
lover/tts.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""
|
||||||
|
CosyVoice TTS 封装,返回二进制音频数据。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import dashscope
|
||||||
|
from dashscope.audio.tts_v2 import AudioFormat, SpeechSynthesizer
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def synthesize(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
voice: str,
|
||||||
|
audio_format: AudioFormat = AudioFormat.MP3_22050HZ_MONO_256KBPS,
|
||||||
|
) -> Tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
同步调用 cosyvoice,返回 (音频二进制, 格式标识)。
|
||||||
|
"""
|
||||||
|
api_key = settings.DASHSCOPE_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=500, detail="未配置 TTS API Key")
|
||||||
|
|
||||||
|
dashscope.api_key = api_key
|
||||||
|
resp_obj: Optional[object] = None
|
||||||
|
try:
|
||||||
|
synthesizer = SpeechSynthesizer(
|
||||||
|
model=model,
|
||||||
|
voice=voice,
|
||||||
|
format=audio_format,
|
||||||
|
)
|
||||||
|
resp_obj = synthesizer.call(text)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # SDK/网络错误
|
||||||
|
raise HTTPException(status_code=502, detail=f"TTS 调用失败: {exc}") from exc
|
||||||
|
|
||||||
|
# 官方非流式调用直接返回音频二进制;兜底处理 base64/字典返回
|
||||||
|
audio_bytes: bytes = b""
|
||||||
|
if isinstance(resp_obj, (bytes, bytearray)):
|
||||||
|
audio_bytes = bytes(resp_obj)
|
||||||
|
elif isinstance(resp_obj, str):
|
||||||
|
try:
|
||||||
|
audio_bytes = base64.b64decode(resp_obj)
|
||||||
|
except Exception:
|
||||||
|
audio_bytes = b""
|
||||||
|
else:
|
||||||
|
output = getattr(resp_obj, "output", None)
|
||||||
|
if isinstance(output, dict):
|
||||||
|
audio_raw = output.get("audio") or output.get("audio_data") or output.get("audio_url")
|
||||||
|
if isinstance(audio_raw, (bytes, bytearray)):
|
||||||
|
audio_bytes = bytes(audio_raw)
|
||||||
|
elif isinstance(audio_raw, str):
|
||||||
|
try:
|
||||||
|
audio_bytes = base64.b64decode(audio_raw)
|
||||||
|
except Exception:
|
||||||
|
audio_bytes = b""
|
||||||
|
|
||||||
|
if not audio_bytes:
|
||||||
|
raise HTTPException(status_code=502, detail="TTS 未返回音频数据")
|
||||||
|
return audio_bytes, audio_format.name if hasattr(audio_format, "name") else str(audio_format)
|
||||||
51
lover/vision.py
Normal file
51
lover/vision.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""
|
||||||
|
Qwen-VL 封装:给定图片 URL,返回简短描述。
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import dashscope
|
||||||
|
from dashscope import MultiModalConversation
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def describe_image(
|
||||||
|
image_url: str,
|
||||||
|
*,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
max_tokens: int = 200,
|
||||||
|
prompt: str = "请用简洁中文描述这张图片的主要内容,限制在50字内。",
|
||||||
|
) -> str:
|
||||||
|
api_key = settings.DASHSCOPE_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY")
|
||||||
|
model_name = model or settings.VISION_MODEL or "qwen3-vl-flash"
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"image": image_url},
|
||||||
|
{"text": prompt},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
resp = MultiModalConversation.call(
|
||||||
|
api_key=api_key,
|
||||||
|
model=model_name,
|
||||||
|
messages=messages,
|
||||||
|
result_format="message",
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"图片理解失败: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = resp.output.choices[0].message.content
|
||||||
|
if isinstance(content, list) and content and "text" in content[0]:
|
||||||
|
return str(content[0]["text"]).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=502, detail="图片理解未返回结果")
|
||||||
6
test_config.py
Normal file
6
test_config.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""测试配置加载"""
|
||||||
|
from lover.config import settings
|
||||||
|
|
||||||
|
print(f"DATABASE_URL: {settings.DATABASE_URL}")
|
||||||
|
print(f"APP_ENV: {settings.APP_ENV}")
|
||||||
|
print(f"DEBUG: {settings.DEBUG}")
|
||||||
23
test_db_connection.py
Normal file
23
test_db_connection.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""测试 SQLAlchemy 连接"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from lover.db import engine, SessionLocal
|
||||||
|
from lover.models import Lover
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 测试连接
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(text("SELECT 1"))
|
||||||
|
print("✓ Engine 连接成功")
|
||||||
|
|
||||||
|
# 测试 Session
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
lovers = db.query(Lover).limit(1).all()
|
||||||
|
print(f"✓ Session 查询成功,找到 {len(lovers)} 条记录")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ 失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
39
test_lover.html
Normal file
39
test_lover.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>测试 Lover API</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Lover API 测试</h1>
|
||||||
|
<button onclick="testBasic()">测试 /lover/basic</button>
|
||||||
|
<button onclick="testConfig()">测试 /lover/config</button>
|
||||||
|
<pre id="result"></pre>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function testBasic() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://127.0.0.1:8000/lover/basic', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
document.getElementById('result').textContent = JSON.stringify(data, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('result').textContent = 'Error: ' + error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://127.0.0.1:8000/lover/config', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
document.getElementById('result').textContent = JSON.stringify(data, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('result').textContent = 'Error: ' + error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
test_mysql.py
Normal file
21
test_mysql.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""测试 MySQL 连接"""
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host='127.0.0.1',
|
||||||
|
port=3306,
|
||||||
|
user='root',
|
||||||
|
password='root',
|
||||||
|
database='lover',
|
||||||
|
charset='utf8mb4'
|
||||||
|
)
|
||||||
|
print("✓ MySQL 连接成功!")
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT VERSION()")
|
||||||
|
version = cursor.fetchone()
|
||||||
|
print(f"✓ MySQL 版本: {version[0]}")
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ MySQL 连接失败: {e}")
|
||||||
8
xuniYou/.gitignore
vendored
Normal file
8
xuniYou/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
|
||||||
|
/node_modules
|
||||||
|
/unpackage
|
||||||
|
/nativeplugins
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
|
||||||
36
xuniYou/.hbuilderx/launch.json
Normal file
36
xuniYou/.hbuilderx/launch.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
// launch.json 配置了启动调试时相关设置,configurations下节点名称可为 app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/
|
||||||
|
// launchtype项可配置值为local或remote, local代表前端连本地云函数,remote代表前端连云端云函数
|
||||||
|
"version" : "0.0",
|
||||||
|
"configurations" : [
|
||||||
|
{
|
||||||
|
"app-plus" : {
|
||||||
|
"launchtype" : "local"
|
||||||
|
},
|
||||||
|
"default" : {
|
||||||
|
"launchtype" : "local"
|
||||||
|
},
|
||||||
|
"mp-alipay" : {
|
||||||
|
"launchtype" : "local"
|
||||||
|
},
|
||||||
|
"mp-weixin" : {
|
||||||
|
"launchtype" : "local"
|
||||||
|
},
|
||||||
|
"type" : "uniCloud"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"playground" : "custom",
|
||||||
|
"type" : "uni-app:app-ios"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"customPlaygroundType" : "local",
|
||||||
|
"packageName" : "uni.app.UNI1DDC3A9",
|
||||||
|
"playground" : "custom",
|
||||||
|
"type" : "uni-app:app-android"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"playground" : "standard",
|
||||||
|
"type" : "uni-app:app-ios_simulator"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
xuniYou/.vite/deps/_metadata.json
Normal file
13
xuniYou/.vite/deps/_metadata.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
7922
xuniYou/.vite/deps/easemob-websdk_uniApp_Easemob-chat.js
Normal file
7922
xuniYou/.vite/deps/easemob-websdk_uniApp_Easemob-chat.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
xuniYou/.vite/deps/package.json
Normal file
1
xuniYou/.vite/deps/package.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"type":"module"}
|
||||||
242
xuniYou/App.vue
Normal file
242
xuniYou/App.vue
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
<script>
|
||||||
|
/* EaseIM */
|
||||||
|
import '@/EaseIM';
|
||||||
|
import { emConnectListener, emMountGlobalListener } from '@/EaseIM/listener';
|
||||||
|
import { emInsertInformMessage } from '@/EaseIM/utils';
|
||||||
|
import { emConnect, emUserInfos, emGroups, emContacts } from '@/EaseIM/imApis';
|
||||||
|
import emHandleReconnect from '@/EaseIM/utils/emHandleReconnect';
|
||||||
|
import {
|
||||||
|
CONNECT_CALLBACK_TYPE,
|
||||||
|
HANDLER_EVENT_NAME,
|
||||||
|
CHAT_TYPE,
|
||||||
|
} from '@/EaseIM/constant';
|
||||||
|
import { useLoginStore } from '@/stores/login';
|
||||||
|
import { useGroupStore } from '@/stores/group';
|
||||||
|
import { useConversationStore } from '@/stores/conversation';
|
||||||
|
import { useContactsStore } from '@/stores/contacts';
|
||||||
|
import { EMClient, EaseSDK } from './EaseIM';
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
/* callKit */
|
||||||
|
import { useInitCallKit } from '@/components/emCallKit';
|
||||||
|
import useCallKitEvent from '@/components/emCallKit/callKitManage/useCallKitEvent';
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
export default {
|
||||||
|
setup() {
|
||||||
|
console.log(uni.getStorageSync("token"),11111)
|
||||||
|
if (!uni.getStorageSync("token")) {
|
||||||
|
uni.reLaunch({
|
||||||
|
url:'/pages/login/index'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const loginStore = useLoginStore();
|
||||||
|
const groupStore = useGroupStore();
|
||||||
|
const contactsStore = useContactsStore();
|
||||||
|
/* 链接所需监听回调 */
|
||||||
|
//传给监听callback回调
|
||||||
|
const connectedCallback = (type) => {
|
||||||
|
console.log('>>>>>连接成功回调', type);
|
||||||
|
if (type === CONNECT_CALLBACK_TYPE.CONNECT_CALLBACK) {
|
||||||
|
onConnectedSuccess();
|
||||||
|
}
|
||||||
|
if (type === CONNECT_CALLBACK_TYPE.DISCONNECT_CALLBACK) {
|
||||||
|
onDisconnect();
|
||||||
|
}
|
||||||
|
if (type === CONNECT_CALLBACK_TYPE.RECONNECTING_CALLBACK) {
|
||||||
|
onReconnecting();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
//IM连接成功
|
||||||
|
const { closeEaseIM } = emConnect();
|
||||||
|
const onConnectedSuccess = () => {
|
||||||
|
const { loginUserId } = loginStore.loginUserBaseInfos || {};
|
||||||
|
const finalLoginUserId = loginUserId || EMClient.user;
|
||||||
|
if (!loginStore.loginStatus) {
|
||||||
|
fetchLoginUserNeedData();
|
||||||
|
}
|
||||||
|
loginStore.setLoginUserBaseInfos({ loginUserId: finalLoginUserId });
|
||||||
|
loginStore.setLoginStatus(true);
|
||||||
|
uni.hideLoading();
|
||||||
|
console.log('*-------------')
|
||||||
|
// uni.redirectTo({
|
||||||
|
// url: '/pages/friends/index',
|
||||||
|
// });
|
||||||
|
};
|
||||||
|
//IM断开连接
|
||||||
|
const { actionEMReconnect } = emHandleReconnect();
|
||||||
|
const onDisconnect = () => {
|
||||||
|
//断开回调触发后,如果业务登录状态为true则说明异常断开需要重新登录
|
||||||
|
if (!loginStore.loginStatus) {
|
||||||
|
// uni.showToast({
|
||||||
|
// title: '退出登录',
|
||||||
|
// icon: 'none',
|
||||||
|
// duration: 2000,
|
||||||
|
// });
|
||||||
|
// uni.redirectTo({
|
||||||
|
// url: '../login/login',
|
||||||
|
// });
|
||||||
|
closeEaseIM();
|
||||||
|
} else {
|
||||||
|
//执行通过token进行重新登录
|
||||||
|
actionEMReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
//IM重连中
|
||||||
|
const onReconnecting = () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: 'IM 重连中...',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
//挂载IM websocket连接成功监听
|
||||||
|
emConnectListener(connectedCallback);
|
||||||
|
const { fetchUserInfoWithLoginId, fetchOtherInfoFromServer } =
|
||||||
|
emUserInfos();
|
||||||
|
const { fetchJoinedGroupListFromServer } = emGroups();
|
||||||
|
const { fetchContactsListFromServer } = emContacts();
|
||||||
|
//获取登录所需基础参数
|
||||||
|
const fetchLoginUserNeedData = async () => {
|
||||||
|
//获取好友列表
|
||||||
|
const friendList = await fetchContactsListFromServer();
|
||||||
|
await contactsStore.setFriendList(friendList);
|
||||||
|
fetchJoinedGroupList();
|
||||||
|
if (friendList.length) {
|
||||||
|
//获取好友用户属性
|
||||||
|
const friendProfiles = await fetchOtherInfoFromServer(friendList);
|
||||||
|
contactsStore.setFriendUserInfotoMap(friendProfiles);
|
||||||
|
}
|
||||||
|
//获取当前登录用户好友信息
|
||||||
|
const profiles = await fetchUserInfoWithLoginId();
|
||||||
|
await loginStore.setLoginUserProfiles(profiles[EMClient.user]);
|
||||||
|
};
|
||||||
|
//获取加入的群组列表
|
||||||
|
const fetchJoinedGroupList = async () => {
|
||||||
|
//获取群组列表
|
||||||
|
const joinedGroupList = await fetchJoinedGroupListFromServer();
|
||||||
|
console.log('>>>>>>>>>joinedGroupList', joinedGroupList);
|
||||||
|
await groupStore.setJoinedGroupList(joinedGroupList);
|
||||||
|
};
|
||||||
|
//挂载全局所需监听回调【好友关系、消息监听、群组监听】
|
||||||
|
/* 退群解散群逻辑 */
|
||||||
|
const conversationStore = useConversationStore();
|
||||||
|
const globaleventcallback = (listenerType, event) => {
|
||||||
|
//群组事件监听
|
||||||
|
if (listenerType === HANDLER_EVENT_NAME.GROUP_EVENT) {
|
||||||
|
const { operation, id } = event;
|
||||||
|
console.log('>>>>>触发群组事件回调。');
|
||||||
|
switch (operation) {
|
||||||
|
case 'directJoined':
|
||||||
|
{
|
||||||
|
uni.showToast({ icon: 'none', title: `被拉入群组${id}` });
|
||||||
|
fetchJoinedGroupList();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'removeMember':
|
||||||
|
{
|
||||||
|
uni.showToast({ icon: 'none', title: `从${id}群中被移出` });
|
||||||
|
fetchJoinedGroupList();
|
||||||
|
//删除该群相关会话
|
||||||
|
conversationStore.deleteConversation(id);
|
||||||
|
//如果在该群会话中则退出会话
|
||||||
|
if (conversationStore.chattingId === id) {
|
||||||
|
uni.reLaunch({
|
||||||
|
url: '../home/index',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'destroy':
|
||||||
|
{
|
||||||
|
uni.showToast({ icon: 'none', title: `${id}已解散` });
|
||||||
|
fetchJoinedGroupList();
|
||||||
|
conversationStore.deleteConversation(id);
|
||||||
|
//如果在该群会话中则退出会话
|
||||||
|
if (conversationStore.chattingId === id) {
|
||||||
|
console.log('>>>>会话中退出聊天页面');
|
||||||
|
uni.reLaunch({
|
||||||
|
url: '../home/index',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (listenerType === HANDLER_EVENT_NAME.ERROR_EVENT) {
|
||||||
|
const { type } = event;
|
||||||
|
switch (type) {
|
||||||
|
case 206:
|
||||||
|
{
|
||||||
|
uni.showToast({
|
||||||
|
icon: 'none',
|
||||||
|
title: '有其他用户登录,断开连接!',
|
||||||
|
});
|
||||||
|
loginStore.setLoginStatus(false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emMountGlobalListener(globaleventcallback);
|
||||||
|
|
||||||
|
/* callKit仅支持原生端使用 */
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
const { setCallKitClient } = useInitCallKit();
|
||||||
|
setCallKitClient(EMClient, EaseSDK.message);
|
||||||
|
//监听callkit状态变化展示对应的页面
|
||||||
|
const { EVENT_NAME, CALLKIT_EVENT_CODE, SUB_CHANNEL_EVENT } =
|
||||||
|
useCallKitEvent();
|
||||||
|
const { insertInformMessage } = emInsertInformMessage();
|
||||||
|
SUB_CHANNEL_EVENT(EVENT_NAME, (params) => {
|
||||||
|
const { type, ext, callType, eventHxId } = params;
|
||||||
|
console.log('>>>>>>订阅到callkit事件发布', params);
|
||||||
|
//弹出待接听事件
|
||||||
|
switch (type.code) {
|
||||||
|
case CALLKIT_EVENT_CODE.ALERT_SCREEN:
|
||||||
|
{
|
||||||
|
console.log('>>>>>>监听到对应code', type.code);
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '../emCallKitPages/alertScreen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CALLKIT_EVENT_CODE.TIMEOUT:
|
||||||
|
{
|
||||||
|
console.log('>>>>>通话超时未接听');
|
||||||
|
insertInformMessage({
|
||||||
|
to: params.eventHxId,
|
||||||
|
chatType:
|
||||||
|
params.callType > 1
|
||||||
|
? CHAT_TYPE.GROUP_CHAT
|
||||||
|
: CHAT_TYPE.SINGLE_CHAT,
|
||||||
|
msg: params.ext.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CALLKIT_EVENT_CODE.CALLEE_BUSY:
|
||||||
|
{
|
||||||
|
insertInformMessage({
|
||||||
|
to: params.eventHxId,
|
||||||
|
chatType:
|
||||||
|
params.callType > 1
|
||||||
|
? CHAT_TYPE.GROUP_CHAT
|
||||||
|
: CHAT_TYPE.SINGLE_CHAT,
|
||||||
|
msg: params.ext.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="scss">
|
||||||
|
@import './app.css';
|
||||||
|
</style>
|
||||||
6
xuniYou/EaseIM/config/index.js
Normal file
6
xuniYou/EaseIM/config/index.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const EM_API_URL = 'https://a1.easemob.com';
|
||||||
|
export const EM_WEB_SOCKET_URL = 'wss://im-api-wechat.easemob.com/websocket';
|
||||||
|
export const EM_APP_KEY = '1102251210209648#xuni';
|
||||||
|
//美东EAST集群配置
|
||||||
|
// export const EM_API_URL = 'https://a41.easemob.com';
|
||||||
|
// export const EM_WEB_SOCKET_URL = 'wss://im-api-wechat-41.easemob.com/websocket';
|
||||||
124
xuniYou/EaseIM/constant/index.js
Normal file
124
xuniYou/EaseIM/constant/index.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
export const CHAT_TYPE = {
|
||||||
|
SINGLE_CHAT: 'singleChat',
|
||||||
|
GROUP_CHAT: 'groupChat',
|
||||||
|
};
|
||||||
|
export const HANDLER_EVENT_NAME = {
|
||||||
|
CONNECT_EVENT: 'connectEvent',
|
||||||
|
MESSAGES_EVENT: 'messagesEvent',
|
||||||
|
CONTACTS_EVENT: 'contactsEvent',
|
||||||
|
GROUP_EVENT: 'groupEvent',
|
||||||
|
ERROR_EVENT: 'errorEvent',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONNECT_CALLBACK_TYPE = {
|
||||||
|
CONNECT_CALLBACK: 'connected',
|
||||||
|
DISCONNECT_CALLBACK: 'disconnected',
|
||||||
|
RECONNECTING_CALLBACK: 'reconnecting',
|
||||||
|
ERROR_CALLBACK: 'onerror',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MESSAGE_TYPE = {
|
||||||
|
IMAGE: 'img',
|
||||||
|
TEXT: 'txt',
|
||||||
|
LOCATION: 'location',
|
||||||
|
VIDEO: 'video',
|
||||||
|
AUDIO: 'audio',
|
||||||
|
EMOJI: 'emoji',
|
||||||
|
FILE: 'file',
|
||||||
|
CUSTOM: 'custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMOJI = {
|
||||||
|
path: '@/static/images1/faces',
|
||||||
|
map: {
|
||||||
|
'[):]': 'ee_1.png',
|
||||||
|
'[:D]': 'ee_2.png',
|
||||||
|
'[;)]': 'ee_3.png',
|
||||||
|
'[:-o]': 'ee_4.png',
|
||||||
|
'[:p]': 'ee_5.png',
|
||||||
|
'[(H)]': 'ee_6.png',
|
||||||
|
'[:@]': 'ee_7.png',
|
||||||
|
'[:s]': 'ee_8.png',
|
||||||
|
'[:$]': 'ee_9.png',
|
||||||
|
'[:(]': 'ee_10.png',
|
||||||
|
"[:'(]": 'ee_11.png',
|
||||||
|
'[<o)]': 'ee_12.png',
|
||||||
|
'[(a)]': 'ee_13.png',
|
||||||
|
'[8o|]': 'ee_14.png',
|
||||||
|
'[8-|]': 'ee_15.png',
|
||||||
|
'[+o(]': 'ee_16.png',
|
||||||
|
'[|-)]': 'ee_17.png',
|
||||||
|
'[:|]': 'ee_18.png',
|
||||||
|
'[*-)]': 'ee_19.png',
|
||||||
|
'[:-#]': 'ee_20.png',
|
||||||
|
'[^o)]': 'ee_21.png',
|
||||||
|
'[:-*]': 'ee_22.png',
|
||||||
|
'[8-)]': 'ee_23.png',
|
||||||
|
'[del]': 'btn_del.png',
|
||||||
|
'[(|)]': 'ee_24.png',
|
||||||
|
'[(u)]': 'ee_25.png',
|
||||||
|
'[(S)]': 'ee_26.png',
|
||||||
|
'[(*)]': 'ee_27.png',
|
||||||
|
'[(#)]': 'ee_28.png',
|
||||||
|
'[(R)]': 'ee_29.png',
|
||||||
|
'[({)]': 'ee_30.png',
|
||||||
|
'[(})]': 'ee_31.png',
|
||||||
|
'[(k)]': 'ee_32.png',
|
||||||
|
'[(F)]': 'ee_33.png',
|
||||||
|
'[(W)]': 'ee_34.png',
|
||||||
|
'[(D)]': 'ee_35.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const EMOJIOBJ = {
|
||||||
|
// 相对 emoji.js 路径
|
||||||
|
path: '/static/images1/faces',
|
||||||
|
map1: {
|
||||||
|
'[):]': 'ee_1.png',
|
||||||
|
'[:D]': 'ee_2.png',
|
||||||
|
'[;)]': 'ee_3.png',
|
||||||
|
'[:-o]': 'ee_4.png',
|
||||||
|
'[:p]': 'ee_5.png',
|
||||||
|
'[(H)]': 'ee_6.png',
|
||||||
|
'[:@]': 'ee_7.png',
|
||||||
|
},
|
||||||
|
map2: {
|
||||||
|
'[:s]': 'ee_8.png',
|
||||||
|
'[:$]': 'ee_9.png',
|
||||||
|
'[:(]': 'ee_10.png',
|
||||||
|
"[:'(]": 'ee_11.png',
|
||||||
|
'[<o)]': 'ee_12.png',
|
||||||
|
'[(a)]': 'ee_13.png',
|
||||||
|
'[8o|]': 'ee_14.png',
|
||||||
|
},
|
||||||
|
map3: {
|
||||||
|
'[8-|]': 'ee_15.png',
|
||||||
|
'[+o(]': 'ee_16.png',
|
||||||
|
'[|-)]': 'ee_17.png',
|
||||||
|
'[:|]': 'ee_18.png',
|
||||||
|
'[*-)]': 'ee_19.png',
|
||||||
|
'[:-#]': 'ee_20.png',
|
||||||
|
'[del]': 'del.png',
|
||||||
|
},
|
||||||
|
map4: {
|
||||||
|
'[^o)]': 'ee_21.png',
|
||||||
|
'[:-*]': 'ee_22.png',
|
||||||
|
'[8-)]': 'ee_23.png',
|
||||||
|
'[(|)]': 'ee_24.png',
|
||||||
|
'[(u)]': 'ee_25.png',
|
||||||
|
'[(S)]': 'ee_26.png',
|
||||||
|
'[(*)]': 'ee_27.png',
|
||||||
|
},
|
||||||
|
map5: {
|
||||||
|
'[(#)]': 'ee_28.png',
|
||||||
|
'[(R)]': 'ee_29.png',
|
||||||
|
'[({)]': 'ee_30.png',
|
||||||
|
'[(})]': 'ee_31.png',
|
||||||
|
'[(k)]': 'ee_32.png',
|
||||||
|
'[(F)]': 'ee_33.png',
|
||||||
|
'[(W)]': 'ee_34.png',
|
||||||
|
'[(D)]': 'ee_35.png',
|
||||||
|
},
|
||||||
|
map6: {
|
||||||
|
'[del]': 'del.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
38
xuniYou/EaseIM/imApis/emConnect.js
Normal file
38
xuniYou/EaseIM/imApis/emConnect.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
const emConnect = () => {
|
||||||
|
const loginWithPassword = (hxUserId, hxPassword) => {
|
||||||
|
if (!hxUserId || !hxPassword) throw Error('empty params');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.open({
|
||||||
|
user: hxUserId,
|
||||||
|
pwd: hxPassword,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res);
|
||||||
|
})
|
||||||
|
.catch((err) => reject(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const loginWithAccessToken = (hxUserId, hxAccessToken) => {
|
||||||
|
if (!hxUserId || !hxAccessToken) throw Error('empty params');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.open({
|
||||||
|
user: hxUserId,
|
||||||
|
accessToken: hxAccessToken,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res);
|
||||||
|
})
|
||||||
|
.catch((err) => reject(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const closeEaseIM = () => {
|
||||||
|
EMClient.close();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
loginWithPassword,
|
||||||
|
loginWithAccessToken,
|
||||||
|
closeEaseIM,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default emConnect;
|
||||||
43
xuniYou/EaseIM/imApis/emContacts.js
Normal file
43
xuniYou/EaseIM/imApis/emContacts.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
const emContacts = () => {
|
||||||
|
const fetchContactsListFromServer = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.getContacts()
|
||||||
|
.then((res) => {
|
||||||
|
const { data } = res;
|
||||||
|
resolve(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeContactFromServer = (contactId) => {
|
||||||
|
if (contactId) {
|
||||||
|
EMClient.deleteContact(contactId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addContact = (contactId, applyMsg) => {
|
||||||
|
if (contactId) {
|
||||||
|
EMClient.addContact(contactId, applyMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const acceptContactInvite = (contactId) => {
|
||||||
|
if (contactId) {
|
||||||
|
EMClient.acceptContactInvite(contactId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const declineContactInvite = (contactId) => {
|
||||||
|
if (contactId) {
|
||||||
|
EMClient.declineContactInvite(contactId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fetchContactsListFromServer,
|
||||||
|
removeContactFromServer,
|
||||||
|
acceptContactInvite,
|
||||||
|
declineContactInvite,
|
||||||
|
addContact,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default emContacts;
|
||||||
54
xuniYou/EaseIM/imApis/emConversation.js
Normal file
54
xuniYou/EaseIM/imApis/emConversation.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { EaseSDK, EMClient } from '../index';
|
||||||
|
import { CHAT_TYPE } from '../constant';
|
||||||
|
const emConversation = () => {
|
||||||
|
//从服务端获取会话列表
|
||||||
|
const fetchConversationFromServer = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
//支持分页这里写死只取二十条
|
||||||
|
EMClient.getConversationlist({ pageNum: 1, pageSize: 20 })
|
||||||
|
.then((res) => {
|
||||||
|
console.log('>>>>会话列表数据获取成功');
|
||||||
|
resolve(res);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
//从服务端删除会话
|
||||||
|
const removeConversationFromServer = (
|
||||||
|
channel,
|
||||||
|
chatType = CHAT_TYPE.SINGLE_CHAT,
|
||||||
|
deleteRoam = false
|
||||||
|
) => {
|
||||||
|
if (!channel) return;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.deleteConversation({ channel, chatType, deleteRoam })
|
||||||
|
.then((res) => {
|
||||||
|
console.log('>>>>会话删除成功');
|
||||||
|
resolve(res);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
//发送会话已读回执
|
||||||
|
const sendChannelAck = (targetId, chatType = CHAT_TYPE.SINGLE_CHAT) => {
|
||||||
|
if (!targetId) return;
|
||||||
|
let option = {
|
||||||
|
chatType: chatType, // 会话类型,设置为单聊。
|
||||||
|
type: 'channel', // 消息类型。
|
||||||
|
to: targetId, // 接收消息对象的用户 ID。
|
||||||
|
};
|
||||||
|
const msg = EaseSDK.message.create(option);
|
||||||
|
EMClient.send(msg);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fetchConversationFromServer,
|
||||||
|
removeConversationFromServer,
|
||||||
|
sendChannelAck,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default emConversation;
|
||||||
137
xuniYou/EaseIM/imApis/emGroups.js
Normal file
137
xuniYou/EaseIM/imApis/emGroups.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { EMClient } from '@/EaseIM';
|
||||||
|
const emGroups = () => {
|
||||||
|
const fetchJoinedGroupListFromServer = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log('>>>>开始获取加入的群组列表');
|
||||||
|
EMClient.getGroup({
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const createNewGroup = (params) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log('>>>>开始创建群组');
|
||||||
|
EMClient.createGroup({ data: { ...params } })
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const getGroupInfosFromServer = (groupId) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.getGroupInfo({
|
||||||
|
groupId,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const getGroupMembersFromServer = (groupId) => {
|
||||||
|
//暂且仅取前100个群成员
|
||||||
|
const pageNum = 1,
|
||||||
|
pageSize = 100;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.listGroupMembers({
|
||||||
|
pageNum: pageNum,
|
||||||
|
pageSize: pageSize,
|
||||||
|
groupId,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const inviteUsersToGroup = (groupId, memberIdList) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.inviteUsersToGroup({
|
||||||
|
groupId: groupId,
|
||||||
|
users: memberIdList,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const leaveGroupFromServer = (groupId) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.leaveGroup({ groupId })
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const destroyGroupFromServer = (groupId) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.destroyGroup({ groupId })
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const acceptGroupInvite = (invitee, groupId) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.acceptGroupInvite({
|
||||||
|
groupId: groupId,
|
||||||
|
invitee: invitee,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const rejectGroupInvite = (invitee, groupId) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.rejectGroupInvite({
|
||||||
|
groupId: groupId,
|
||||||
|
invitee: invitee,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res.data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fetchJoinedGroupListFromServer,
|
||||||
|
createNewGroup,
|
||||||
|
getGroupInfosFromServer,
|
||||||
|
getGroupMembersFromServer,
|
||||||
|
inviteUsersToGroup,
|
||||||
|
leaveGroupFromServer,
|
||||||
|
destroyGroupFromServer,
|
||||||
|
acceptGroupInvite,
|
||||||
|
rejectGroupInvite,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default emGroups;
|
||||||
160
xuniYou/EaseIM/imApis/emMessages.js
Normal file
160
xuniYou/EaseIM/imApis/emMessages.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { EaseSDK, EMClient } from "../index";
|
||||||
|
import { useMessageStore } from "@/stores/message";
|
||||||
|
import { useConversationStore } from "@/stores/conversation";
|
||||||
|
import { getEMKey } from "@/EaseIM/utils";
|
||||||
|
import { sendMessageApi } from "@/utils/Huanxin.js";
|
||||||
|
const emMessages = () => {
|
||||||
|
const messageStore = useMessageStore();
|
||||||
|
const conversationStore = useConversationStore();
|
||||||
|
|
||||||
|
const reportMessages = (params) => {
|
||||||
|
const { reportType, reportReason, messageId } = params;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.reportMessage({
|
||||||
|
reportType: reportType, // 举报类型
|
||||||
|
reportReason: reportReason, // 举报原因。
|
||||||
|
messageId: messageId, // 上报消息id
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const fetchHistoryMessagesFromServer = (params) => {
|
||||||
|
console.log(">>>>>开始获取历史消息", params);
|
||||||
|
const { targetId, cursor, chatType } = params;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let options = {
|
||||||
|
// 对方的用户 ID 或者群组 ID 或聊天室 ID。
|
||||||
|
targetId: targetId,
|
||||||
|
// 每页期望获取的消息条数。取值范围为 [1,50],默认值为 20。
|
||||||
|
pageSize: 20,
|
||||||
|
// 查询的起始消息 ID。若该参数设置为 `-1`、`null` 或空字符串,从最新消息开始。
|
||||||
|
cursor: cursor || -1,
|
||||||
|
// 会话类型:(默认) `singleChat`:单聊;`groupChat`:群聊。
|
||||||
|
chatType: chatType,
|
||||||
|
// 消息搜索方向:(默认)`up`:按服务器收到消息的时间的逆序获取;`down`:按服务器收到消息的时间的正序获取。
|
||||||
|
searchDirection: "up",
|
||||||
|
};
|
||||||
|
console.log(">>>>>获取历史消息参数", options);
|
||||||
|
EMClient.getHistoryMessages(options)
|
||||||
|
.then((res) => {
|
||||||
|
console.log(">>>>>获取历史消息成功", res);
|
||||||
|
resolve(res);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(">>>>>获取历史消息失败", e);
|
||||||
|
// 获取失败。
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const sendDisplayMessages = (messageBody) => {
|
||||||
|
messageBody.from = EMClient.user;
|
||||||
|
const key = getEMKey(
|
||||||
|
EMClient.user,
|
||||||
|
messageBody.from,
|
||||||
|
messageBody.to,
|
||||||
|
messageBody.chatType
|
||||||
|
);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const msg = EaseSDK.message.create(messageBody);
|
||||||
|
console.log(msg,messageBody);
|
||||||
|
if (messageBody.type == "video") {
|
||||||
|
msg.url = messageBody.url;
|
||||||
|
msg.thumb_uuid = messageBody.thumbId;
|
||||||
|
}
|
||||||
|
if(messageBody.type == "audio") {
|
||||||
|
msg.url = messageBody.body.url;
|
||||||
|
msg.length = messageBody.body.length;
|
||||||
|
}
|
||||||
|
console.log(">>>>构建的消息msg", msg);
|
||||||
|
// EMClient.send(msg)
|
||||||
|
// .then((res) => {
|
||||||
|
sendMessages(msg);
|
||||||
|
// console.log(">>>>>发送成功1111111", res);
|
||||||
|
resolve(msg);
|
||||||
|
// msg.id = res.serverMsgId;
|
||||||
|
messageStore.updateMessageCollection(key, msg);
|
||||||
|
conversationStore.updateConversationLastMessage(key, msg);
|
||||||
|
// })
|
||||||
|
// .catch((err) => {
|
||||||
|
// reject(err);
|
||||||
|
// console.log(">>>>>发送失败", err);
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 接口发送消息
|
||||||
|
const sendMessages = (msg) => {
|
||||||
|
console.log(">>>>接口发送消息", msg);
|
||||||
|
// IMAGE: 'img',
|
||||||
|
// TEXT: 'txt',
|
||||||
|
// LOCATION: 'location',
|
||||||
|
// VIDEO: 'video',
|
||||||
|
// AUDIO: 'audio',
|
||||||
|
// EMOJI: 'emoji',
|
||||||
|
// FILE: 'file',
|
||||||
|
// CUSTOM: 'custom',
|
||||||
|
let send_type = "";
|
||||||
|
let message = "";
|
||||||
|
let uuid = "";
|
||||||
|
let url = "";
|
||||||
|
let thumb_uuid = "";
|
||||||
|
let length = '';
|
||||||
|
switch (msg.type) {
|
||||||
|
case "txt": // 文本消息
|
||||||
|
send_type = "text";
|
||||||
|
message = msg.msg;
|
||||||
|
break;
|
||||||
|
case "img": // 图片消息
|
||||||
|
send_type = "image";
|
||||||
|
url = msg.url;
|
||||||
|
uuid = msg.url.substring(msg.url.lastIndexOf("/") + 1);
|
||||||
|
break;
|
||||||
|
case "audio": // 语音消息
|
||||||
|
send_type = "audio";
|
||||||
|
url = msg.body.url.substring(0, msg.body.url.lastIndexOf("/"));
|
||||||
|
uuid = msg.body.url.substring(msg.body.url.lastIndexOf("/") + 1);
|
||||||
|
length = msg.body.length;
|
||||||
|
break;
|
||||||
|
case "video": // 视频消息
|
||||||
|
send_type = "video";
|
||||||
|
url = msg.url.substring(0, msg.url.lastIndexOf("/"));
|
||||||
|
uuid = msg.url.substring(msg.url.lastIndexOf("/") + 1);
|
||||||
|
thumb_uuid = msg.thumb_uuid;
|
||||||
|
break;
|
||||||
|
case "file": // 文件消息
|
||||||
|
send_type = "file";
|
||||||
|
break;
|
||||||
|
case "emoji": // 表情消息
|
||||||
|
send_type = "text";
|
||||||
|
message = msg.msg;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let params = {
|
||||||
|
to_user_id: conversationStore.userId,
|
||||||
|
send_type: send_type,
|
||||||
|
message: message,
|
||||||
|
uuid: uuid,
|
||||||
|
url: url,
|
||||||
|
thumb_uuid: thumb_uuid,
|
||||||
|
length: length,
|
||||||
|
};
|
||||||
|
console.log(">>>>>开始发送消息1231231", params);
|
||||||
|
sendMessageApi(params)
|
||||||
|
.then((res) => {
|
||||||
|
console.log(">>>>>发送消息成功", res);
|
||||||
|
})
|
||||||
|
.catch((e) => {});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
reportMessages,
|
||||||
|
fetchHistoryMessagesFromServer,
|
||||||
|
sendDisplayMessages,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default emMessages;
|
||||||
20
xuniYou/EaseIM/imApis/emReadAck.js
Normal file
20
xuniYou/EaseIM/imApis/emReadAck.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { EaseSDK, EMClient } from '../index';
|
||||||
|
const emSendReadAck = () => {
|
||||||
|
// 处理未读消息回执
|
||||||
|
const sendReadAckMsg = (receivemsg) => {
|
||||||
|
const { chatType, from, id } = receivemsg;
|
||||||
|
let option = {
|
||||||
|
type: 'read', // 消息是否已读。
|
||||||
|
chatType: chatType, // 会话类型,这里为单聊。
|
||||||
|
to: from, // 消息接收方的用户 ID。
|
||||||
|
id: id, // 需要发送已读回执的消息 ID。
|
||||||
|
};
|
||||||
|
let msg = EaseSDK.message.create(option);
|
||||||
|
EMClient.send(msg);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
sendReadAckMsg,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default emSendReadAck;
|
||||||
41
xuniYou/EaseIM/imApis/emSilent.js
Normal file
41
xuniYou/EaseIM/imApis/emSilent.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
const emSilent = () => {
|
||||||
|
const getSilentModeForConversation = (conversationId, type) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.getSilentModeForConversation({
|
||||||
|
conversationId,
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res);
|
||||||
|
})
|
||||||
|
.catch((err) => reject(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setSilentModeForConversation = (params) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { conversationId, type, options } = params;
|
||||||
|
EMClient.setSilentModeForConversation({ conversationId, type, options })
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res);
|
||||||
|
})
|
||||||
|
.catch((err) => reject(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const clearRemindTypeForConversation = (conversationId, type) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.clearRemindTypeForConversation({ conversationId, type })
|
||||||
|
.then((res) => {
|
||||||
|
resolve(res);
|
||||||
|
})
|
||||||
|
.catch((err) => reject(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getSilentModeForConversation,
|
||||||
|
setSilentModeForConversation,
|
||||||
|
clearRemindTypeForConversation,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default emSilent;
|
||||||
80
xuniYou/EaseIM/imApis/emUserInfos.js
Normal file
80
xuniYou/EaseIM/imApis/emUserInfos.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
const _chunkArr = (oldArr, num) => {
|
||||||
|
oldArr.sort((a, b) => {
|
||||||
|
return a - b;
|
||||||
|
});
|
||||||
|
if (oldArr.length <= 0) return oldArr;
|
||||||
|
let newArr = [];
|
||||||
|
if (Math.ceil(oldArr.length / num) <= 1) {
|
||||||
|
newArr.push(oldArr);
|
||||||
|
return newArr;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < oldArr.length; i = i + num) {
|
||||||
|
newArr.push(oldArr.slice(i, i + num));
|
||||||
|
}
|
||||||
|
return newArr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emUserInofs = () => {
|
||||||
|
const fetchUserInfoWithLoginId = () => {
|
||||||
|
const userId = EMClient.user;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (userId) {
|
||||||
|
EMClient.fetchUserInfoById(userId)
|
||||||
|
.then((res) => {
|
||||||
|
const { data } = res;
|
||||||
|
resolve(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const fetchOtherInfoFromServer = (userList) => {
|
||||||
|
let friendList = [];
|
||||||
|
friendList = Object.assign([], userList);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (friendList.length && friendList.length < 99) {
|
||||||
|
EMClient.fetchUserInfoById(friendList)
|
||||||
|
.then((res) => {
|
||||||
|
const { data } = res;
|
||||||
|
resolve(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let newArr = _chunkArr(friendList, 99);
|
||||||
|
for (let i = 0; i < newArr.length; i++) {
|
||||||
|
EMClient.fetchUserInfoById(newArr[i])
|
||||||
|
.then((res) => {
|
||||||
|
const { data } = res;
|
||||||
|
resolve(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const updateUserInfosFromServer = (params) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
EMClient.updateUserInfo({ ...params })
|
||||||
|
.then((res) => {
|
||||||
|
const { data } = res;
|
||||||
|
resolve(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fetchUserInfoWithLoginId,
|
||||||
|
fetchOtherInfoFromServer,
|
||||||
|
updateUserInfosFromServer,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default emUserInofs;
|
||||||
18
xuniYou/EaseIM/imApis/index.js
Normal file
18
xuniYou/EaseIM/imApis/index.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import emConnect from './emConnect';
|
||||||
|
import emUserInfos from './emUserInfos';
|
||||||
|
import emContacts from './emContacts';
|
||||||
|
import emGroups from './emGroups';
|
||||||
|
import emSendReadAck from './emReadAck';
|
||||||
|
import emConversation from './emConversation';
|
||||||
|
import emMessages from './emMessages';
|
||||||
|
import emSilent from './emSilent';
|
||||||
|
export {
|
||||||
|
emConnect,
|
||||||
|
emUserInfos,
|
||||||
|
emContacts,
|
||||||
|
emGroups,
|
||||||
|
emSendReadAck,
|
||||||
|
emConversation,
|
||||||
|
emMessages,
|
||||||
|
emSilent,
|
||||||
|
};
|
||||||
10
xuniYou/EaseIM/index.js
Normal file
10
xuniYou/EaseIM/index.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import EaseSDK from 'easemob-websdk/uniApp/Easemob-chat';
|
||||||
|
import { EM_APP_KEY, EM_API_URL, EM_WEB_SOCKET_URL } from './config';
|
||||||
|
let EMClient = (uni.EMClient = {});
|
||||||
|
EMClient = new EaseSDK.connection({
|
||||||
|
appKey: EM_APP_KEY,
|
||||||
|
apiUrl: EM_API_URL,
|
||||||
|
url: EM_WEB_SOCKET_URL,
|
||||||
|
});
|
||||||
|
uni.EMClient = EMClient;
|
||||||
|
export { EaseSDK, EMClient };
|
||||||
39
xuniYou/EaseIM/listener/emConnectListener.js
Normal file
39
xuniYou/EaseIM/listener/emConnectListener.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
import { CONNECT_CALLBACK_TYPE, HANDLER_EVENT_NAME } from '../constant';
|
||||||
|
import dateFormater from '@/utils/dateFormater';
|
||||||
|
export const emConnectListener = (callback, listenerEventName) => {
|
||||||
|
console.log('>>>>连接监听已挂载');
|
||||||
|
const connectListenFunc = {
|
||||||
|
onConnected: () => {
|
||||||
|
console.log('connected...', dateFormater('MM/DD/HH:mm:ss', Date.now()));
|
||||||
|
callback && callback(CONNECT_CALLBACK_TYPE.CONNECT_CALLBACK);
|
||||||
|
},
|
||||||
|
onDisconnected: () => {
|
||||||
|
callback && callback(CONNECT_CALLBACK_TYPE.DISCONNECT_CALLBACK);
|
||||||
|
console.log(
|
||||||
|
'disconnected...',
|
||||||
|
dateFormater('MM/DD/HH:mm:ss', Date.now())
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onReconnecting: () => {
|
||||||
|
console.log(
|
||||||
|
'reconnecting...',
|
||||||
|
dateFormater('MM/DD/HH:mm:ss', Date.now())
|
||||||
|
);
|
||||||
|
callback && callback(CONNECT_CALLBACK_TYPE.RECONNECTING_CALLBACK);
|
||||||
|
},
|
||||||
|
onOnline: () => {
|
||||||
|
console.log('online...');
|
||||||
|
},
|
||||||
|
onOffline: () => {
|
||||||
|
console.log('offline...');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
EMClient.removeEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.CONNECT_EVENT
|
||||||
|
);
|
||||||
|
EMClient.addEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.CONNECT_EVENT,
|
||||||
|
connectListenFunc
|
||||||
|
);
|
||||||
|
};
|
||||||
50
xuniYou/EaseIM/listener/emContactsListener.js
Normal file
50
xuniYou/EaseIM/listener/emContactsListener.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
import { HANDLER_EVENT_NAME } from '../constant';
|
||||||
|
import { useInformStore } from '@/stores/inform';
|
||||||
|
import { useContactsStore } from '@/stores/contacts';
|
||||||
|
import { emUserInfos } from '@/EaseIM/imApis';
|
||||||
|
export const emContactsListener = (callback, listenerEventName) => {
|
||||||
|
const informStore = useInformStore();
|
||||||
|
const contactsStore = useContactsStore();
|
||||||
|
const { fetchOtherInfoFromServer } = emUserInfos();
|
||||||
|
console.log('>>>>>好友关系监听挂载');
|
||||||
|
const contactsListenFunc = {
|
||||||
|
// 当前用户收到好友请求。用户 B 向用户 A 发送好友请求,用户 A 收到该事件。
|
||||||
|
onContactInvited: function (msg) {
|
||||||
|
const contactsInform = Object.assign({}, msg);
|
||||||
|
callback && callback(contactsInform);
|
||||||
|
informStore.addNewInform('contacts', contactsInform);
|
||||||
|
},
|
||||||
|
// 当前用户被其他用户从联系人列表上移除。用户 B 将用户 A 从联系人列表上删除,用户 A 收到该事件。
|
||||||
|
onContactDeleted: function (msg) {
|
||||||
|
callback && callback(msg);
|
||||||
|
contactsStore.deleteFriendFromFriendList(msg.from);
|
||||||
|
},
|
||||||
|
// 当前用户新增了联系人。用户 B 向用户 A 发送好友请求,用户 A 同意该请求,用户 A 收到该事件,而用户 B 收到 `onContactAgreed` 事件。
|
||||||
|
onContactAdded: function (msg) {
|
||||||
|
callback && callback(msg);
|
||||||
|
},
|
||||||
|
// 当前用户发送的好友请求被拒绝。用户 A 向用户 B 发送好友请求,用户 B 收到好友请求后,拒绝加好友,则用户 A 收到该事件。
|
||||||
|
onContactRefuse: function (msg) {
|
||||||
|
callback && callback(msg);
|
||||||
|
},
|
||||||
|
// 当前用户发送的好友请求经过了对方同意。用户 A 向用户 B 发送好友请求,用户 B 收到好友请求后,同意加好友,则用户 A 收到该事件。
|
||||||
|
onContactAgreed: async function (msg) {
|
||||||
|
callback && callback(msg);
|
||||||
|
contactsStore.addNewFriendToFriendList(msg.from);
|
||||||
|
try {
|
||||||
|
const friendProfiles = await fetchOtherInfoFromServer([msg.from]);
|
||||||
|
contactsStore.setFriendUserInfotoMap(friendProfiles);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('>>>>好友info获取失败', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
EMClient.removeEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.CONTACTS_EVENT
|
||||||
|
);
|
||||||
|
EMClient.addEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.CONTACTS_EVENT,
|
||||||
|
contactsListenFunc
|
||||||
|
);
|
||||||
|
};
|
||||||
18
xuniYou/EaseIM/listener/emErrorListener.js
Normal file
18
xuniYou/EaseIM/listener/emErrorListener.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
import { HANDLER_EVENT_NAME } from '../constant';
|
||||||
|
export const emErrorListener = (callback, listenerEventName) => {
|
||||||
|
console.log('>>>>error监听已挂载');
|
||||||
|
const errorListenFunc = {
|
||||||
|
onError: (e) => {
|
||||||
|
console.log('>>>>>onError', e);
|
||||||
|
callback && callback(HANDLER_EVENT_NAME.ERROR_EVENT, e);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
EMClient.removeEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.ERROR_EVENT
|
||||||
|
);
|
||||||
|
EMClient.addEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.ERROR_EVENT,
|
||||||
|
errorListenFunc
|
||||||
|
);
|
||||||
|
};
|
||||||
111
xuniYou/EaseIM/listener/emGroupListener.js
Normal file
111
xuniYou/EaseIM/listener/emGroupListener.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
import { HANDLER_EVENT_NAME } from '../constant';
|
||||||
|
import { useInformStore } from '@/stores/inform';
|
||||||
|
export const emGroupListener = (callback, listenerEventName) => {
|
||||||
|
const informStore = useInformStore();
|
||||||
|
console.log('>>>>群组事件监听挂载');
|
||||||
|
const groupListenFunc = {
|
||||||
|
onGroupEvent: (event) => {
|
||||||
|
console.log('>>>>群组事件监听触发', event);
|
||||||
|
const { operation } = event;
|
||||||
|
callback(HANDLER_EVENT_NAME.GROUP_EVENT, event);
|
||||||
|
switch (operation) {
|
||||||
|
// 有新群组创建。群主的其他设备会收到该回调。
|
||||||
|
case 'create':
|
||||||
|
break;
|
||||||
|
// 关闭群组一键禁言。群组所有成员(除操作者外)会收到该回调。
|
||||||
|
case 'unmuteAllMembers':
|
||||||
|
break;
|
||||||
|
// 开启群组一键禁言。群组所有成员(除操作者外)会收到该回调。
|
||||||
|
case 'muteAllMembers':
|
||||||
|
break;
|
||||||
|
// 有成员从群白名单中移出。被移出的成员及群主和群管理员(除操作者外)会收到该回调。
|
||||||
|
case 'removeAllowlistMember':
|
||||||
|
break;
|
||||||
|
// 有成员添加至群白名单。被添加的成员及群主和群管理员(除操作者外)会收到该回调。
|
||||||
|
case 'addUserToAllowlist':
|
||||||
|
break;
|
||||||
|
// 删除群共享文件。群组所有成员会收到该回调。
|
||||||
|
case 'deleteFile':
|
||||||
|
break;
|
||||||
|
// 上传群共享文件。群组所有成员会收到该回调。
|
||||||
|
case 'uploadFile':
|
||||||
|
break;
|
||||||
|
// 删除群公告。群组所有成员会收到该回调。
|
||||||
|
case 'deleteAnnouncement':
|
||||||
|
break;
|
||||||
|
// 更新群公告。群组所有成员会收到该回调。
|
||||||
|
case 'updateAnnouncement':
|
||||||
|
break;
|
||||||
|
// 更新群组信息,如群组名称和群组描述。群组所有成员会收到该回调。
|
||||||
|
case 'updateInfo':
|
||||||
|
break;
|
||||||
|
// 有成员被移出禁言列表。被解除禁言的成员及群主和群管理员(除操作者外)会收到该回调。
|
||||||
|
case 'unmuteMember':
|
||||||
|
break;
|
||||||
|
// 有群组成员被加入禁言列表。被禁言的成员及群主和群管理员(除操作者外)会收到该回调。
|
||||||
|
case 'muteMember':
|
||||||
|
break;
|
||||||
|
// 有管理员被移出管理员列表。群主、被移除的管理员和其他管理员会收到该回调。
|
||||||
|
case 'removeAdmin':
|
||||||
|
break;
|
||||||
|
// 设置管理员。群主、新管理员和其他管理员会收到该回调。
|
||||||
|
case 'setAdmin':
|
||||||
|
break;
|
||||||
|
// 转让群组。原群主和新群主会收到该回调。
|
||||||
|
case 'changeOwner':
|
||||||
|
break;
|
||||||
|
// 群组所有者和管理员拉用户进群时,无需用户确认时会触发该回调。被拉进群的用户会收到该回调。
|
||||||
|
case 'directJoined':
|
||||||
|
break;
|
||||||
|
// 群成员主动退出群组。除了退群的成员,其他群成员会收到该回调。
|
||||||
|
case 'memberAbsence':
|
||||||
|
break;
|
||||||
|
// 有用户加入群组。除了新成员,其他群成员会收到该回调。
|
||||||
|
case 'memberPresence':
|
||||||
|
break;
|
||||||
|
// 用户被移出群组。被踢出群组的成员会收到该回调。
|
||||||
|
case 'removeMember':
|
||||||
|
break;
|
||||||
|
// 当前用户的入群邀请被拒绝。邀请人会收到该回调。例如,用户 B 拒绝了用户 A 的入群邀请,用户 A 会收到该回调。
|
||||||
|
case 'rejectInvite':
|
||||||
|
break;
|
||||||
|
// 当前用户的入群邀请被接受。邀请人会收到该回调。例如,用户 B 接受了用户 A 的入群邀请,则用户 A 会收到该回调。
|
||||||
|
case 'acceptInvite':
|
||||||
|
break;
|
||||||
|
// 当前用户收到了入群邀请。受邀用户会收到该回调。例如,用户 B 邀请用户 A 入群,则用户 A 会收到该回调。
|
||||||
|
case 'inviteToJoin':
|
||||||
|
{
|
||||||
|
const groupsInform = Object.assign({}, event);
|
||||||
|
informStore.addNewInform('groups', groupsInform);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
// 当前用户的入群申请被拒绝。申请人会收到该回调。例如,用户 B 拒绝用户 A 的入群申请后,用户 A 会收到该回调。
|
||||||
|
case 'joinPublicGroupDeclined':
|
||||||
|
break;
|
||||||
|
// 当前用户的入群申请被接受。申请人会收到该回调。例如,用户 B 接受用户 A 的入群申请后,用户 A 会收到该回调。
|
||||||
|
case 'acceptRequest':
|
||||||
|
break;
|
||||||
|
// 当前用户发送入群申请。群主和群管理员会收到该回调。
|
||||||
|
case 'requestToJoin':
|
||||||
|
break;
|
||||||
|
// 群组被解散。群主解散群组时,所有群成员均会收到该回调。
|
||||||
|
case 'destroy':
|
||||||
|
break;
|
||||||
|
// 设置群成员的自定义属性。群组内其他成员均会收到该回调。
|
||||||
|
case 'memberAttributesUpdate':
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
EMClient.removeEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.GROUP_EVENT
|
||||||
|
);
|
||||||
|
EMClient.addEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.GROUP_EVENT,
|
||||||
|
groupListenFunc
|
||||||
|
);
|
||||||
|
};
|
||||||
113
xuniYou/EaseIM/listener/emMessagesListener.js
Normal file
113
xuniYou/EaseIM/listener/emMessagesListener.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
import { useMessageStore } from '@/stores/message';
|
||||||
|
import { useConversationStore } from '@/stores/conversation';
|
||||||
|
import { CHAT_TYPE, HANDLER_EVENT_NAME } from '../constant';
|
||||||
|
import { getEMKey } from '@/EaseIM/utils';
|
||||||
|
export const emMessagesListener = (callback, listenerEventName) => {
|
||||||
|
console.log('消息监听已挂载');
|
||||||
|
const messageStore = useMessageStore();
|
||||||
|
const conversationStore = useConversationStore();
|
||||||
|
//处理展示类型消息(txt、image、file...)
|
||||||
|
const handleReciveDisPlayMessages = (message) => {
|
||||||
|
console.log('>>>>开始处理收到的消息', message);
|
||||||
|
//如果是在线推送消息则进行调起通知栏进行通知
|
||||||
|
// if (message.type === 'cmd' && message.action === 'em_custom_notification') {
|
||||||
|
// const {
|
||||||
|
// ext: { em_notification },
|
||||||
|
// } = message;
|
||||||
|
// const params = {
|
||||||
|
// title: em_notification.title,
|
||||||
|
// content: em_notification.content,
|
||||||
|
// payload: {},
|
||||||
|
// icon: em_notification.icon_url,
|
||||||
|
// success: () => {
|
||||||
|
// console.log('>>>>推送接口调用成功');
|
||||||
|
// },
|
||||||
|
// fail: (error) => {
|
||||||
|
// console.log('>>>>推送接口调用失败', error);
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
// //调起推送通知栏
|
||||||
|
// uni.createPushMessage({ ...params });
|
||||||
|
// } else {
|
||||||
|
let key = getEMKey(
|
||||||
|
EMClient.user,
|
||||||
|
message.from,
|
||||||
|
message.to,
|
||||||
|
message.chatType
|
||||||
|
);
|
||||||
|
messageStore.updateMessageCollection(key, message);
|
||||||
|
conversationStore.updateConversationLastMessage(key, message);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
//处理回执类型消息
|
||||||
|
const handleReciveAckMessages = (message) => {
|
||||||
|
console.log('>>>>开始处理回执类型消息');
|
||||||
|
};
|
||||||
|
//处理撤回类型消息
|
||||||
|
const handleReciveRecallMessage = (message) => {
|
||||||
|
console.log('>>>>开始处理撤回类型消息');
|
||||||
|
};
|
||||||
|
const messagesListenFunc = {
|
||||||
|
// 当前用户收到文本消息。
|
||||||
|
onTextMessage: function (message) {
|
||||||
|
handleReciveDisPlayMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到图片消息。
|
||||||
|
onImageMessage: function (message) {
|
||||||
|
handleReciveDisPlayMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到透传消息。
|
||||||
|
onCmdMessage: function (message) {
|
||||||
|
//cmd消息暂不处理
|
||||||
|
// handleReciveDisPlayMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到语音消息。
|
||||||
|
onAudioMessage: function (message) {
|
||||||
|
handleReciveDisPlayMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到位置消息。
|
||||||
|
onLocationMessage: function (message) {
|
||||||
|
handleReciveDisPlayMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到文件消息。
|
||||||
|
onFileMessage: function (message) {
|
||||||
|
handleReciveDisPlayMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到自定义消息。
|
||||||
|
onCustomMessage: function (message) {
|
||||||
|
handleReciveDisPlayMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到视频消息。
|
||||||
|
onVideoMessage: function (message) {
|
||||||
|
handleReciveDisPlayMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到的消息被消息发送方撤回。
|
||||||
|
onRecallMessage: function (message) {
|
||||||
|
handleReciveRecallMessage({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户发送的消息被接收方收到。
|
||||||
|
onReceivedMessage: function (message) {
|
||||||
|
handleReciveAckMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到消息送达回执。
|
||||||
|
onDeliveredMessage: function (message) {
|
||||||
|
handleReciveAckMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到消息已读回执。
|
||||||
|
onReadMessage: function (message) {
|
||||||
|
handleReciveAckMessages({ ...message });
|
||||||
|
},
|
||||||
|
// 当前用户收到会话已读回执。
|
||||||
|
onChannelMessage: function (message) {
|
||||||
|
handleReciveAckMessages({ ...message });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
EMClient.removeEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.MESSAGES_EVENT
|
||||||
|
);
|
||||||
|
EMClient.addEventHandler(
|
||||||
|
listenerEventName || HANDLER_EVENT_NAME.MESSAGES_EVENT,
|
||||||
|
messagesListenFunc
|
||||||
|
);
|
||||||
|
};
|
||||||
17
xuniYou/EaseIM/listener/emMountGlobalListener.js
Normal file
17
xuniYou/EaseIM/listener/emMountGlobalListener.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { emContactsListener } from './emContactsListener';
|
||||||
|
import { emGroupListener } from './emGroupListener';
|
||||||
|
import { emMessagesListener } from './emMessagesListener';
|
||||||
|
import { emErrorListener } from './emErrorListener';
|
||||||
|
export const emMountGlobalListener = (cb) => {
|
||||||
|
if (typeof cb === 'function') {
|
||||||
|
// 参数是一个函数
|
||||||
|
emMessagesListener(cb);
|
||||||
|
emContactsListener(cb);
|
||||||
|
emGroupListener(cb);
|
||||||
|
emErrorListener(cb);
|
||||||
|
} else {
|
||||||
|
// 参数不是一个函数
|
||||||
|
console.error('传入的参数不是一个函数');
|
||||||
|
}
|
||||||
|
console.log('>>>>全局监听');
|
||||||
|
};
|
||||||
4
xuniYou/EaseIM/listener/index.js
Normal file
4
xuniYou/EaseIM/listener/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { emConnectListener } from './emConnectListener';
|
||||||
|
import { emContactsListener } from './emContactsListener';
|
||||||
|
import { emMountGlobalListener } from './emMountGlobalListener';
|
||||||
|
export { emMountGlobalListener, emConnectListener, emContactsListener };
|
||||||
23
xuniYou/EaseIM/utils/emHandleReconnect.js
Normal file
23
xuniYou/EaseIM/utils/emHandleReconnect.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { EMClient } from '../index';
|
||||||
|
import emConnect from '../imApis/emConnect';
|
||||||
|
const { loginWithAccessToken, closeEaseIM } = emConnect();
|
||||||
|
const emHandleReconnect = () => {
|
||||||
|
const getEMClientSocketState = () => {
|
||||||
|
//三种状态 undefined false 为SDK 断开连接,true 正在连接中。
|
||||||
|
return EMClient.isOpened();
|
||||||
|
};
|
||||||
|
const actionEMReconnect = () => {
|
||||||
|
closeEaseIM();
|
||||||
|
setTimeout(() => {
|
||||||
|
const loginUserId = uni.getStorageSync('myUsername');
|
||||||
|
const loginUserToken =
|
||||||
|
loginUserId && uni.getStorageSync(`EM_${loginUserId}_TOKEN`);
|
||||||
|
loginWithAccessToken(loginUserId, loginUserToken.token);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getEMClientSocketState,
|
||||||
|
actionEMReconnect,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export default emHandleReconnect;
|
||||||
41
xuniYou/EaseIM/utils/emInsertInformMessage.js
Normal file
41
xuniYou/EaseIM/utils/emInsertInformMessage.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
/* 该方法用来在本地插入会话以及聊天页面插入通知使用 */
|
||||||
|
import { useMessageStore } from '@/stores/message';
|
||||||
|
import { useConversationStore } from '@/stores/conversation';
|
||||||
|
import getEMKey from './getEMKey';
|
||||||
|
import { EMClient } from '@/EaseIM';
|
||||||
|
export const emInsertInformMessage = (isUpdateConversation = true) => {
|
||||||
|
const messageStore = useMessageStore();
|
||||||
|
const conversationStore = useConversationStore();
|
||||||
|
const insertInformMessage = (message) => {
|
||||||
|
console.log('>>>>>>>准备执行插入系统通知消息', message);
|
||||||
|
/**
|
||||||
|
* @param {String} from
|
||||||
|
* @param {String} to
|
||||||
|
* @param {String} chatType
|
||||||
|
* @param {String} msg
|
||||||
|
* @param {String} type 'inform'
|
||||||
|
* @param {String} id
|
||||||
|
*/
|
||||||
|
const informMessageBody = {
|
||||||
|
from: message.from || EMClient.user,
|
||||||
|
to: message.to,
|
||||||
|
chatType: message.chatType,
|
||||||
|
msg: message.msg,
|
||||||
|
type: 'inform', //此类型为伪消息类型,为自己定义类型
|
||||||
|
id: 'inform' + Date.now(), //伪消息id
|
||||||
|
time: Date.now(),
|
||||||
|
};
|
||||||
|
const { from, to, chatType } = informMessageBody;
|
||||||
|
const key = getEMKey(EMClient.user, from, to, chatType);
|
||||||
|
console.log('>>>>>>key', key);
|
||||||
|
//通知本地消息列表进行插入
|
||||||
|
messageStore.insertLocalGrayInformMessage(key, informMessageBody);
|
||||||
|
//是否需要同步更新会话列表
|
||||||
|
if (isUpdateConversation) {
|
||||||
|
conversationStore.updateConversationLastMessage(key, informMessageBody);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
insertInformMessage,
|
||||||
|
};
|
||||||
|
};
|
||||||
11
xuniYou/EaseIM/utils/getCurrentRoute.js
Normal file
11
xuniYou/EaseIM/utils/getCurrentRoute.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
//通过uni-app提供的方法调用getCurrentPages 获取当前页面路由url
|
||||||
|
const getCurrentRoute = () => {
|
||||||
|
let pages = getCurrentPages();
|
||||||
|
if (pages.length > 0) {
|
||||||
|
let currentPage = pages[pages.length - 1];
|
||||||
|
return currentPage.route;
|
||||||
|
}
|
||||||
|
return '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getCurrentRoute;
|
||||||
15
xuniYou/EaseIM/utils/getEMKey.js
Normal file
15
xuniYou/EaseIM/utils/getEMKey.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/* 用以获取消息存储格式时的key */
|
||||||
|
const getEMKey = (loginId, fromId, toId, chatType) => {
|
||||||
|
let key = '';
|
||||||
|
if (chatType === 'singleChat') {
|
||||||
|
if (loginId === fromId) {
|
||||||
|
key = toId;
|
||||||
|
} else {
|
||||||
|
key = fromId;
|
||||||
|
}
|
||||||
|
} else if (chatType === 'groupChat') {
|
||||||
|
key = toId;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
export default getEMKey;
|
||||||
5
xuniYou/EaseIM/utils/index.js
Normal file
5
xuniYou/EaseIM/utils/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import paseEmoji from './paseEmoji';
|
||||||
|
import getEMKey from './getEMKey';
|
||||||
|
import emHandleReconnect from './emHandleReconnect';
|
||||||
|
import { emInsertInformMessage } from './emInsertInformMessage';
|
||||||
|
export { paseEmoji, getEMKey, emHandleReconnect, emInsertInformMessage };
|
||||||
86
xuniYou/EaseIM/utils/paseEmoji.js
Normal file
86
xuniYou/EaseIM/utils/paseEmoji.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
const Emoji = {
|
||||||
|
path: '../static/images1/faces/',
|
||||||
|
map: {
|
||||||
|
'[):]': 'ee_1.png',
|
||||||
|
'[:D]': 'ee_2.png',
|
||||||
|
'[;)]': 'ee_3.png',
|
||||||
|
'[:-o]': 'ee_4.png',
|
||||||
|
'[:p]': 'ee_5.png',
|
||||||
|
'[(H)]': 'ee_6.png',
|
||||||
|
'[:@]': 'ee_7.png',
|
||||||
|
'[:s]': 'ee_8.png',
|
||||||
|
'[:$]': 'ee_9.png',
|
||||||
|
'[:(]': 'ee_10.png',
|
||||||
|
"[:'(]": 'ee_11.png',
|
||||||
|
'[<o)]': 'ee_12.png',
|
||||||
|
'[(a)]': 'ee_13.png',
|
||||||
|
'[8o|]': 'ee_14.png',
|
||||||
|
'[8-|]': 'ee_15.png',
|
||||||
|
'[+o(]': 'ee_16.png',
|
||||||
|
'[|-)]': 'ee_17.png',
|
||||||
|
'[:|]': 'ee_18.png',
|
||||||
|
'[*-)]': 'ee_19.png',
|
||||||
|
'[:-#]': 'ee_20.png',
|
||||||
|
'[^o)]': 'ee_21.png',
|
||||||
|
'[:-*]': 'ee_22.png',
|
||||||
|
'[8-)]': 'ee_23.png',
|
||||||
|
'[del]': 'btn_del.png',
|
||||||
|
'[(|)]': 'ee_24.png',
|
||||||
|
'[(u)]': 'ee_25.png',
|
||||||
|
'[(S)]': 'ee_26.png',
|
||||||
|
'[(*)]': 'ee_27.png',
|
||||||
|
'[(#)]': 'ee_28.png',
|
||||||
|
'[(R)]': 'ee_29.png',
|
||||||
|
'[({)]': 'ee_30.png',
|
||||||
|
'[(})]': 'ee_31.png',
|
||||||
|
'[(k)]': 'ee_32.png',
|
||||||
|
'[(F)]': 'ee_33.png',
|
||||||
|
'[(W)]': 'ee_34.png',
|
||||||
|
'[(D)]': 'ee_35.png',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseEmoji = (msg) => {
|
||||||
|
if (typeof Emoji === 'undefined' || typeof Emoji.map === 'undefined') {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
var emoji = Emoji,
|
||||||
|
reg = null;
|
||||||
|
var msgList = [];
|
||||||
|
var objList = [];
|
||||||
|
|
||||||
|
for (var face in emoji.map) {
|
||||||
|
if (emoji.map.hasOwnProperty(face)) {
|
||||||
|
while (msg.indexOf(face) > -1) {
|
||||||
|
msg = msg.replace(face, '^' + emoji.map[face] + '^');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ary = msg.split('^');
|
||||||
|
var reg = /^e.*g$/;
|
||||||
|
|
||||||
|
for (var i = 0; i < ary.length; i++) {
|
||||||
|
if (ary[i] != '') {
|
||||||
|
msgList.push(ary[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < msgList.length; i++) {
|
||||||
|
if (reg.test(msgList[i])) {
|
||||||
|
var obj = {};
|
||||||
|
obj.data = msgList[i];
|
||||||
|
obj.type = 'emoji';
|
||||||
|
objList.push(obj);
|
||||||
|
} else {
|
||||||
|
var obj = {};
|
||||||
|
obj.data = msgList[i];
|
||||||
|
obj.type = 'txt';
|
||||||
|
objList.push(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objList;
|
||||||
|
};
|
||||||
|
export default parseEmoji;
|
||||||
142
xuniYou/README.md
Normal file
142
xuniYou/README.md
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
# webim-uniapp-demo-vue3
|
||||||
|
|
||||||
|
# 介绍
|
||||||
|
|
||||||
|
demo 包含以下核心功能
|
||||||
|
|
||||||
|
- 会话列表
|
||||||
|
- 系统通知
|
||||||
|
- 联系人
|
||||||
|
- 添加好友
|
||||||
|
- 群组创建
|
||||||
|
- 群组详情
|
||||||
|
- 我的
|
||||||
|
- 用户属性
|
||||||
|
- 单人聊天
|
||||||
|
- 群组聊天
|
||||||
|
- 文本、图片、语音、附件、个人名片收发。
|
||||||
|
- 原生端声网音视频拨打功能。
|
||||||
|
|
||||||
|
# 在本地跑起来
|
||||||
|
|
||||||
|
拉取代码,在 HBuliderX 工具点击运行至想要的平台中即可运行起来。
|
||||||
|
|
||||||
|
# 项目结构核心目录说明
|
||||||
|
|
||||||
|
```shell
|
||||||
|
|- components 自定义组件目录
|
||||||
|
|-Agora-RTC-JS AgoraRtc js API组件
|
||||||
|
|-emChat 聊天页面核心组件(消息列表、输入框相关代码)
|
||||||
|
|-emCallKit AgoraRtc 核心业务逻辑组件
|
||||||
|
|-swipedelete 测滑删除组件
|
||||||
|
|-static/images demo中用到的图片 还有表情
|
||||||
|
|-EaseIM 环信IM核心逻辑文件
|
||||||
|
|-config IM相关配置
|
||||||
|
|-constant 相关常量
|
||||||
|
|-imApis 项目中所用IM SDK api方法
|
||||||
|
|-listener IM监听回调
|
||||||
|
|-utils 相关工具
|
||||||
|
|-index.js 核心sdk初始化在此js文件中完成并导出
|
||||||
|
|-layout 布局(tab-bar)
|
||||||
|
|-recorderCore H5录音包
|
||||||
|
|-pages 功能页面
|
||||||
|
|-login 登录页
|
||||||
|
|-home home页面
|
||||||
|
|-conversation 会话列表页面
|
||||||
|
|-contacts 联系人页
|
||||||
|
|-me 我的页面
|
||||||
|
|-addNewFriend 加好友页
|
||||||
|
|-addGroups 创建新群
|
||||||
|
|-groups 群组列表页
|
||||||
|
|-groupSetting 群组设置页
|
||||||
|
|-notificaton 通知入口页(群组、单人通知)
|
||||||
|
|-notificatonFriendDetail 加好友通知页
|
||||||
|
|-notificatonGroupDetail 加群组通知页
|
||||||
|
|-moreMenu 更多功能页面
|
||||||
|
|-profile 用户属性展示页
|
||||||
|
|-searchMsg 消息搜索页面
|
||||||
|
|-settingGeneral 设置功能
|
||||||
|
|-emChatContainer emChat聊天容器组件
|
||||||
|
|-emCallKitPages AgoraRtc 相关页面组件
|
||||||
|
|-utils 工具类和sdk的一些配置
|
||||||
|
|-stores pinia store 全局状态管理
|
||||||
|
|-uni_modules uni插件包
|
||||||
|
|-node_modules 这个相信不需要特别说明(IMSDK在此中)
|
||||||
|
|-app.vue 项目根组件(注册IM监听事件、处理连接跳转)
|
||||||
|
|-app.json 注册页面以及全局的一些配置
|
||||||
|
|-app.css 一些全局样式
|
||||||
|
```
|
||||||
|
|
||||||
|
# 音视频功能的实现
|
||||||
|
|
||||||
|
## 简介
|
||||||
|
|
||||||
|
> 很多时候我们可能需要在即时通讯的业务逻辑基础上增加音视频相关功能,因此为了方便集成我们在本 Demo 中增加了有关音视频相关的逻辑代码,可以作为参考,也可以进行部分复用。
|
||||||
|
|
||||||
|
## 如何复用
|
||||||
|
|
||||||
|
- 进入声网注册一个 appId 此 appId 与环信 appKey 概念类似,如何注册请参考[友情链接](https://docportal.shengwang.cn/cn/Agora%20Platform/get_appid_token?platform=All%20Platforms)
|
||||||
|
- 在已有的 HBuilderX 项目中导入相关音视频功能依赖插件【Agora(声网)提供的原生插件】
|
||||||
|
> 插件地址内含相关文档地址,以及具体导入方式建议仔细查看。
|
||||||
|
|
||||||
|
[Agora-Native 插件](https://ext.dcloud.net.cn/plugin?id=3720)
|
||||||
|
|
||||||
|
[Agora-JS 插件](https://ext.dcloud.net.cn/plugin?id=3741)
|
||||||
|
|
||||||
|
- 集成环信 uni-app 相关 SDK,按文档进行相关初始化配置并登录。
|
||||||
|
- 复制本 Demo 中的`emCallKit`、pages 下`emCallKitPages`至自己的项目文件目录中(不要忘记参考本 Demo 配置 `pages.json` 中相关页面路由地址)。
|
||||||
|
- 将注册好的 appid 在`emCallKit`下的`config`中进行配置。
|
||||||
|
- 在需要使用 Agora 音视频功能时需要将 IM 的实例传入到 callKit 组件内,参考代码如下。
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { useInitCallKit } from '@/components/emCallKit';
|
||||||
|
const { setCallKitClient } = useInitCallKit();
|
||||||
|
//EMClient 为实例化后的IM,EaseSDK.message构建消息的方法
|
||||||
|
setCallKitClient(EMClient, EaseSDK.message);
|
||||||
|
```
|
||||||
|
|
||||||
|
> emCallKit 中还会将一些事件发布,主要作用为方便将频道内的一些动作通知到外层,比如收到邀请时,我们可能需要跳转至邀请页面。
|
||||||
|
|
||||||
|
```js
|
||||||
|
//订阅频道内事件,收到邀请可选择跳转至邀请页面。
|
||||||
|
import useCallKitEvent from '@/components/emCallKit/callKitManage/useCallKitEvent';
|
||||||
|
const { EVENT_NAME, CALLKIT_EVENT_CODE, SUB_CHANNEL_EVENT } = useCallKitEvent();
|
||||||
|
SUB_CHANNEL_EVENT(EVENT_NAME, (params) => {
|
||||||
|
const { type, ext, callType, eventHxId } = params;
|
||||||
|
console.log('>>>>>>订阅到callkit事件发布', params);
|
||||||
|
//弹出待接听事件
|
||||||
|
switch (type.code) {
|
||||||
|
case CALLKIT_EVENT_CODE.ALERT_SCREEN:
|
||||||
|
{
|
||||||
|
console.log('>>>>>>监听到对应code', type.code);
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '../emCallKitPages/alertScreen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case CALLKIT_EVENT_CODE.TIMEOUT:
|
||||||
|
{
|
||||||
|
console.log('>>>>>通话超时未接听');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- 向他人发起音视频通话邀请
|
||||||
|
|
||||||
|
```js
|
||||||
|
import useAgoraChannelStore from '@/components/emCallKit/stores/channelManger';
|
||||||
|
import { CALL_TYPES } from '@/components/emCallKit/contants';
|
||||||
|
const agoraChannelStore = useAgoraChannelStore();
|
||||||
|
//target 要呼叫的用户id 多人可传Array类型进去。
|
||||||
|
//callType 要呼叫的类型 CALL_TYPES.SINGLE_VIDEO视频、CALL_TYPES.SINGLE_VOICE语音
|
||||||
|
await agoraChannelStore.sendInviteMessage(target, callType);
|
||||||
|
```
|
||||||
|
|
||||||
|
# 常见问题
|
||||||
|
|
||||||
|
- 如何从短信验证码方式登录切换为用户 id+密码登陆?
|
||||||
|
> 答:在 login>loginState>usePwdLogin 此配置项改为 false 即可。
|
||||||
112
xuniYou/app.css
Normal file
112
xuniYou/app.css
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/* .container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 200rpx 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.f {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
page {
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'Gill Sans Extrabold', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.fx {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fed {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fj {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f2 {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 {
|
||||||
|
flex: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f4 {
|
||||||
|
flex: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f5 {
|
||||||
|
flex: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faj {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h1 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h3 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
39
xuniYou/components/Agora-RTC-JS/RtcSurfaceView.nvue
Normal file
39
xuniYou/components/Agora-RTC-JS/RtcSurfaceView.nvue
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<template>
|
||||||
|
<Agora-RTC-SurfaceView :zOrderMediaOverlay="zOrderMediaOverlay" :zOrderOnTop="zOrderOnTop" :renderMode="renderMode"
|
||||||
|
:data="{uid:uid, channelId:channelId}" :mirrorMode="mirrorMode"></Agora-RTC-SurfaceView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
VideoRenderMode,
|
||||||
|
VideoMirrorMode
|
||||||
|
} from './common/Enums';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'RtcSurfaceView',
|
||||||
|
props: {
|
||||||
|
zOrderMediaOverlay: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
zOrderOnTop: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
renderMode: {
|
||||||
|
type: Number,
|
||||||
|
default: VideoRenderMode.Hidden
|
||||||
|
},
|
||||||
|
uid: Number,
|
||||||
|
channelId: String,
|
||||||
|
mirrorMode: {
|
||||||
|
type: Number,
|
||||||
|
default: VideoMirrorMode.Auto
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
30
xuniYou/components/Agora-RTC-JS/RtcTextureView.nvue
Normal file
30
xuniYou/components/Agora-RTC-JS/RtcTextureView.nvue
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<template>
|
||||||
|
<Agora-RTC-TextureView :renderMode="renderMode" :data="{uid:uid, channelId:channelId}" :mirrorMode="mirrorMode"></Agora-RTC-TextureView>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
VideoRenderMode,
|
||||||
|
VideoMirrorMode
|
||||||
|
} from './common/Enums';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'RtcTextureView',
|
||||||
|
props: {
|
||||||
|
renderMode: {
|
||||||
|
type: Number,
|
||||||
|
default: VideoRenderMode.Hidden
|
||||||
|
},
|
||||||
|
uid: Number,
|
||||||
|
channelId: String,
|
||||||
|
mirrorMode: {
|
||||||
|
type: Number,
|
||||||
|
default: VideoMirrorMode.Auto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
1707
xuniYou/components/Agora-RTC-JS/common/Classes.js
Normal file
1707
xuniYou/components/Agora-RTC-JS/common/Classes.js
Normal file
File diff suppressed because it is too large
Load Diff
1
xuniYou/components/Agora-RTC-JS/common/Classes.js.map
Normal file
1
xuniYou/components/Agora-RTC-JS/common/Classes.js.map
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user