配置开发环境

This commit is contained in:
xiao12feng8 2026-01-31 19:15:41 +08:00
commit 19bd5910ad
6176 changed files with 2831121 additions and 0 deletions

4
.env Normal file
View 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
View File

@ -0,0 +1 @@
归档/

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

106
create_tables.py Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
# Package marker for FastAPI app

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

171
lover/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View File

@ -0,0 +1 @@
# Routers package marker

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1164
lover/routers/chat.py Normal file

File diff suppressed because it is too large Load Diff

330
lover/routers/config.py Normal file
View 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

File diff suppressed because it is too large Load Diff

596
lover/routers/dynamic.py Normal file
View 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

File diff suppressed because it is too large Load Diff

833
lover/routers/outfit.py Normal file
View 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

File diff suppressed because it is too large Load Diff

587
lover/routers/voice_call.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,8 @@
/node_modules
/unpackage
/nativeplugins
.DS_Store

View File

@ -0,0 +1,36 @@
{
// launch.json configurations app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/
// launchtypelocalremote, localremote
"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"
}
]
}

View 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": {}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

242
xuniYou/App.vue Normal file
View 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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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('>>>>全局监听');
};

View File

@ -0,0 +1,4 @@
import { emConnectListener } from './emConnectListener';
import { emContactsListener } from './emContactsListener';
import { emMountGlobalListener } from './emMountGlobalListener';
export { emMountGlobalListener, emConnectListener, emContactsListener };

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

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

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

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

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

View 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
View 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 为实例化后的IMEaseSDK.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
View 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;
}

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

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

File diff suppressed because it is too large Load Diff

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