From e3bda469bb949fc22fe2ae29e15012e0e8c6dffd Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Fri, 24 Jul 2026 14:04:21 +0800 Subject: [PATCH] feat: complete huihui square avatar workflows --- backend/app/api/endpoints/interactions.py | 87 +- backend/app/api/endpoints/users.py | 34 +- backend/app/core/database.py | 2 +- backend/app/main.py | 6 + backend/app/models/__init__.py | 27 + backend/app/services/ai_service.py | 26 + backend/app/services/news_service.py | 345 +++- backend/app/services/scheduler.py | 610 +++++- backend/app/services/stats_service.py | 2 +- digital-avatar-app/.dockerignore | 6 + digital-avatar-app/.gitignore | 4 + digital-avatar-app/Dockerfile | 22 + digital-avatar-app/backend/.dockerignore | 6 + digital-avatar-app/backend/Dockerfile | 15 + digital-avatar-app/backend/database.py | 49 + digital-avatar-app/backend/models.py | 220 ++ digital-avatar-app/backend/requirements.txt | 9 + digital-avatar-app/backend/responses.py | 6 + .../backend/routers/__init__.py | 0 .../backend/routers/authorizations.py | 32 + digital-avatar-app/backend/routers/avatars.py | 95 + .../backend/routers/huihui_auth.py | 336 +++ .../backend/routers/organizations.py | 35 + digital-avatar-app/backend/routers/tokens.py | 37 + digital-avatar-app/docker-compose.yml | 31 + digital-avatar-app/index.html | 22 + .../knowledge-samples/牙齿防护知识手册.md | 50 + .../knowledge-samples/牙齿防护问答对.json | 62 + digital-avatar-app/nginx.conf | 38 + digital-avatar-app/package-lock.json | 1796 +++++++++++++++++ digital-avatar-app/package.json | 22 + digital-avatar-app/src/App.vue | 119 ++ digital-avatar-app/src/main.ts | 55 + digital-avatar-app/src/store/avatar.ts | 95 + digital-avatar-app/src/store/index.ts | 5 + digital-avatar-app/src/store/user.ts | 82 + digital-avatar-app/src/styles/global.css | 58 + digital-avatar-app/src/utils/uniapp-bridge.ts | 65 + .../src/views/AuthorizationManage.vue | 324 +++ digital-avatar-app/src/views/AvatarCard.vue | 185 ++ .../src/views/AvatarContacts.vue | 142 ++ digital-avatar-app/src/views/AvatarCreate.vue | 492 +++++ digital-avatar-app/src/views/CreateOrg.vue | 157 ++ digital-avatar-app/src/views/MyProjects.vue | 100 + digital-avatar-app/src/views/QaPairEdit.vue | 304 +++ digital-avatar-app/src/views/SmsLogin.vue | 536 +++++ digital-avatar-app/src/views/TokenCharge.vue | 378 ++++ digital-avatar-app/tsconfig.json | 21 + digital-avatar-app/vite.config.ts | 25 + digital-avatar-app/工作总结-2026-07-08.md | 96 + docs/production-interface-inventory.md | 169 ++ frontend/nginx.conf | 1 + frontend/src/layouts/MainLayout.vue | 1 + frontend/src/views/Interactions.vue | 4 +- overview-avatar-default-2026-07-21.md | 29 + overview-avatar-user-isolation-2026-07-21.md | 75 + overview-deploy-2026-07-17.md | 40 + overview-huihui-auth-2026-07-21.md | 31 + overview-login-page-2026-07-21.md | 22 + overview-nickname-prefill-fix-2026-07-21.md | 42 + overview-password-login-2026-07-21.md | 45 + overview-sms-login-live-2026-07-21.md | 37 + overview-style-fix-2026-07-21.md | 44 + overview.md | 39 + scripts/batch_replace_avatars.py | 128 ++ scripts/repair_segment_nicknames.py | 73 + uniapp-avatar/README.md | 126 ++ uniapp-avatar/index.html | 17 + 68 files changed, 8062 insertions(+), 132 deletions(-) create mode 100644 digital-avatar-app/.dockerignore create mode 100644 digital-avatar-app/.gitignore create mode 100644 digital-avatar-app/Dockerfile create mode 100644 digital-avatar-app/backend/.dockerignore create mode 100644 digital-avatar-app/backend/Dockerfile create mode 100644 digital-avatar-app/backend/database.py create mode 100644 digital-avatar-app/backend/models.py create mode 100644 digital-avatar-app/backend/requirements.txt create mode 100644 digital-avatar-app/backend/responses.py create mode 100644 digital-avatar-app/backend/routers/__init__.py create mode 100644 digital-avatar-app/backend/routers/authorizations.py create mode 100644 digital-avatar-app/backend/routers/avatars.py create mode 100644 digital-avatar-app/backend/routers/huihui_auth.py create mode 100644 digital-avatar-app/backend/routers/organizations.py create mode 100644 digital-avatar-app/backend/routers/tokens.py create mode 100644 digital-avatar-app/docker-compose.yml create mode 100644 digital-avatar-app/index.html create mode 100644 digital-avatar-app/knowledge-samples/牙齿防护知识手册.md create mode 100644 digital-avatar-app/knowledge-samples/牙齿防护问答对.json create mode 100644 digital-avatar-app/nginx.conf create mode 100644 digital-avatar-app/package-lock.json create mode 100644 digital-avatar-app/package.json create mode 100644 digital-avatar-app/src/App.vue create mode 100644 digital-avatar-app/src/main.ts create mode 100644 digital-avatar-app/src/store/avatar.ts create mode 100644 digital-avatar-app/src/store/index.ts create mode 100644 digital-avatar-app/src/store/user.ts create mode 100644 digital-avatar-app/src/styles/global.css create mode 100644 digital-avatar-app/src/utils/uniapp-bridge.ts create mode 100644 digital-avatar-app/src/views/AuthorizationManage.vue create mode 100644 digital-avatar-app/src/views/AvatarCard.vue create mode 100644 digital-avatar-app/src/views/AvatarContacts.vue create mode 100644 digital-avatar-app/src/views/AvatarCreate.vue create mode 100644 digital-avatar-app/src/views/CreateOrg.vue create mode 100644 digital-avatar-app/src/views/MyProjects.vue create mode 100644 digital-avatar-app/src/views/QaPairEdit.vue create mode 100644 digital-avatar-app/src/views/SmsLogin.vue create mode 100644 digital-avatar-app/src/views/TokenCharge.vue create mode 100644 digital-avatar-app/tsconfig.json create mode 100644 digital-avatar-app/vite.config.ts create mode 100644 digital-avatar-app/工作总结-2026-07-08.md create mode 100644 docs/production-interface-inventory.md create mode 100644 overview-avatar-default-2026-07-21.md create mode 100644 overview-avatar-user-isolation-2026-07-21.md create mode 100644 overview-deploy-2026-07-17.md create mode 100644 overview-huihui-auth-2026-07-21.md create mode 100644 overview-login-page-2026-07-21.md create mode 100644 overview-nickname-prefill-fix-2026-07-21.md create mode 100644 overview-password-login-2026-07-21.md create mode 100644 overview-sms-login-live-2026-07-21.md create mode 100644 overview-style-fix-2026-07-21.md create mode 100644 overview.md create mode 100644 scripts/batch_replace_avatars.py create mode 100644 scripts/repair_segment_nicknames.py create mode 100644 uniapp-avatar/README.md create mode 100644 uniapp-avatar/index.html diff --git a/backend/app/api/endpoints/interactions.py b/backend/app/api/endpoints/interactions.py index 4070e92..3977441 100755 --- a/backend/app/api/endpoints/interactions.py +++ b/backend/app/api/endpoints/interactions.py @@ -52,20 +52,45 @@ async def retry_interaction(record_id: int, db=Depends(get_db)): if not user or user.status != 2: raise HTTPException(status_code=400, detail="用户未登录,无法重试") - success, err = False, "未知类型" + success, err, platform_record_id = False, "未知类型", "" if record.interact_type == "comment" and record.content: - success, err = await news_service.post_comment(db, user, record.article_id, record.article_title or "", record.content) + success, err, platform_record_id = await news_service.post_comment_with_record_id( + db, user, record.article_id, record.article_title or "", record.content + ) elif record.interact_type == "like": success, err = await news_service.like_news(db, user, record.article_id, org_id="", title=record.article_title or "") elif record.interact_type == "collect": success, err = await news_service.collect_news(db, user, record.article_id, title=record.article_title or "") elif record.interact_type == "forward": success, err = await news_service.forward_news(db, user, record.article_id) + elif record.interact_type == "reply" and record.content: + parent_comment = None + if record.parent_comment_id: + comments = await news_service.get_comments(db, user, record.article_id or "") + parent_comment = next( + ( + c for c in comments + if str(c.get("id") or c.get("commentId") or "") == str(record.parent_comment_id) + ), + None, + ) + if not parent_comment: + success, err, platform_record_id = False, "未找到被回复的原评论,无法重试回复", "" + else: + success, err, platform_record_id = await news_service.post_reply_with_record_id( + db, user, + record.article_id or "", + record.parent_comment_id or "", + record.content, + parent_comment=parent_comment, + article_title=record.article_title or "", + ) await db.execute( update(InteractionRecord).where(InteractionRecord.id == record_id).values( status=1 if success else 2, error_msg=None if success else err, + platform_record_id=platform_record_id or record.platform_record_id, retry_count=record.retry_count + 1, ) ) @@ -147,15 +172,69 @@ async def cancel_interaction(record_id: int, db=Depends(get_db)): news_id=record.article_id or "", title=record.article_title or "", ) - elif record.interact_type in ("comment", "reply"): + elif record.interact_type == "comment": comment_id = record.platform_record_id or "" if not comment_id: - return ApiResponse(code=400, message="缺少评论ID,无法删除") + comment_id, comment_count = await news_service.find_comment_id( + db, user, + news_id=record.article_id or "", + content=record.content or "", + ) + if comment_id: + await db.execute( + update(InteractionRecord).where(InteractionRecord.id == record_id).values( + platform_record_id=comment_id + ) + ) + await db.commit() + elif comment_count == 0: + await db.execute( + update(InteractionRecord).where(InteractionRecord.id == record_id).values( + status=3, + error_msg="平台未查询到评论,已标记取消", + ) + ) + await db.commit() + return ApiResponse(message="平台未查询到评论,已标记取消") + else: + return ApiResponse(code=400, message="缺少评论ID,且未能从平台评论列表反查到该评论") ok, err = await news_service.cancel_comment( db, user, news_id=record.article_id or "", comment_id=comment_id, ) + elif record.interact_type == "reply": + reply_id = record.platform_record_id or "" + if not reply_id: + reply_id, reply_count = await news_service.find_reply_id( + db, user, + news_id=record.article_id or "", + content=record.content or "", + parent_comment_id=record.parent_comment_id or "", + ) + if reply_id: + await db.execute( + update(InteractionRecord).where(InteractionRecord.id == record_id).values( + platform_record_id=reply_id + ) + ) + await db.commit() + elif reply_count == 0: + await db.execute( + update(InteractionRecord).where(InteractionRecord.id == record_id).values( + status=3, + error_msg="平台未查询到回复,已标记取消", + ) + ) + await db.commit() + return ApiResponse(message="平台未查询到回复,已标记取消") + else: + return ApiResponse(code=400, message="缺少回复ID,且未能从平台回复列表反查到该回复") + ok, err = await news_service.cancel_reply( + db, user, + news_id=record.article_id or "", + reply_id=reply_id, + ) if ok: # 更新状态为手动取消(status=3) diff --git a/backend/app/api/endpoints/users.py b/backend/app/api/endpoints/users.py index 9e8657d..36b1f72 100755 --- a/backend/app/api/endpoints/users.py +++ b/backend/app/api/endpoints/users.py @@ -1,5 +1,8 @@ """虚拟用户管理接口""" from typing import Optional +from pathlib import Path +import uuid +import mimetypes from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException from fastapi.responses import StreamingResponse import io @@ -9,6 +12,21 @@ from app.schemas import ApiResponse, UserCreateRequest, UserUpdateRequest, UserB from app.services.user_service import user_service router = APIRouter() +_UPLOADS_DIR = Path(__file__).resolve().parents[2] / "uploads" / "avatars" +_UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + + +async def _save_local_avatar(file_bytes: bytes, filename: str, content_type: str | None) -> str: + ext = Path(filename or "").suffix.lower() + if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: + guessed_ext = mimetypes.guess_extension(content_type or "") or ".jpg" + ext = ".jpg" if guessed_ext == ".jpe" else guessed_ext + if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: + ext = ".jpg" + + target = _UPLOADS_DIR / f"{uuid.uuid4().hex}{ext}" + target.write_bytes(file_bytes) + return f"/api/uploads/avatars/{target.name}" @router.get("") @@ -268,13 +286,20 @@ async def sync_all_profiles(db=Depends(get_db)): sess = await get_session(uid) if not sess: return False + ur = await s.execute(select(_VU).where(_VU.id == uid)) + user = ur.scalar_one_or_none() + if not user: + return False platform_uid = sess.get("platform_uid", "") # 登录成功时 session 里已存有用户信息 vals = {} if platform_uid: vals["platform_uid"] = platform_uid # session 里的字段(登录时写入) - if sess.get("nickname"): vals["nickname"] = sess["nickname"] - if sess.get("real_name"): vals["real_name"] = sess["real_name"] + sync_nickname = sess.get("nickname", "") + sync_real_name = sess.get("real_name", "") + resolved_nickname = news_service._resolve_synced_nickname(user, sync_nickname, sync_real_name) + if resolved_nickname: vals["nickname"] = resolved_nickname + if sync_real_name: vals["real_name"] = sync_real_name if sess.get("sex"): vals["sex"] = int(sess["sex"]) if sess.get("avatar"): vals["avatar_url"] = sess["avatar"] if vals: @@ -327,9 +352,8 @@ async def upload_avatar( else: return ApiResponse(code=500, message=f"头像上传到平台失败: {result}") else: - # 仅本地存储(转 base64 或存储到本地) - import base64 - avatar_url = f"data:{file.content_type};base64,{base64.b64encode(file_bytes).decode()}" + # 未登录用户本地落盘,避免 base64 超过 avatar_url 字段长度 + avatar_url = await _save_local_avatar(file_bytes, file.filename or "", file.content_type) # 更新数据库 await db.execute(update(_VU).where(_VU.id == user_id).values(avatar_url=avatar_url)) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index ba7ec1f..6023207 100755 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -62,7 +62,7 @@ async def init_db(): # 导入所有模型类,确保 SQLAlchemy ORM 元数据注册 from app.models import ( VirtualUser, UserPersonality, InteractionRecord, - TokenStat, AIModelConfig, SystemConfig, LoginLog + PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog ) logger.info("✅ 数据库模型注册成功") logger.info("✅ 数据库初始化完成") diff --git a/backend/app/main.py b/backend/app/main.py index cc16cb1..cbb8318 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,9 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles import re as _re +from pathlib import Path # 修复 Pydantic v2 把 naive datetime 序列化为 +00:00 的问题 # 数据库存的是北京时间,应标记为 +08:00 @@ -89,6 +91,10 @@ app.add_middleware( # 注册路由 app.include_router(router, prefix="/api") +uploads_dir = Path(__file__).resolve().parent / "uploads" +uploads_dir.mkdir(parents=True, exist_ok=True) +app.mount("/api/uploads", StaticFiles(directory=uploads_dir), name="uploads") + @app.get("/health") async def health_check(): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e83bd72..75090ad 100755 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -75,6 +75,33 @@ class InteractionRecord(Base): created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) +class PendingReplyTask(Base): + __tablename__ = "pending_reply_tasks" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + actor_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + next_actor_user_id: Mapped[int | None] = mapped_column(BigInteger, index=True) + news_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + news_title: Mapped[str | None] = mapped_column(String(256)) + article_org_id: Mapped[str | None] = mapped_column(String(64)) + parent_comment_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + parent_comment: Mapped[dict | None] = mapped_column(JSON) + reply_to: Mapped[dict | None] = mapped_column(JSON) + root_content: Mapped[str | None] = mapped_column(Text) + context: Mapped[str | None] = mapped_column(Text) + next_probability: Mapped[float] = mapped_column(Float, default=0.3) + next_delay_min_seconds: Mapped[int] = mapped_column(Integer, default=30) + next_delay_max_seconds: Mapped[int] = mapped_column(Integer, default=7200) + status: Mapped[int] = mapped_column(SmallInteger, default=0, index=True) # 0待发送 1发送中 2已发送 3失败 + attempts: Mapped[int] = mapped_column(SmallInteger, default=0) + scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) + locked_at: Mapped[datetime | None] = mapped_column(DateTime) + sent_at: Mapped[datetime | None] = mapped_column(DateTime) + last_error: Mapped[str | None] = mapped_column(String(512)) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + class TokenStat(Base): __tablename__ = "token_stats" diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index c5beb20..a475950 100755 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -190,6 +190,32 @@ class AIService: 请对上面的评论写一条友善自然的回复,{word_min}~{word_max}字,直接输出回复内容。""" return await self._call_api(db, prompt, system_prompt, max_tokens=150) + async def generate_thread_reply( + self, db: AsyncSession, article_title: str, root_comment: str, + reply_context: str, personality_prompt: str, + word_min: int = 15, word_max: int = 60 + ) -> tuple[str, int]: + """结合文章、原评论和上下文生成评论回复链中的下一句。""" + system_prompt = f"""你是一名真实的社区用户。{personality_prompt} + +重要规则: +- 回复必须积极正面、文明友善,不含任何敏感违规内容 +- 要自然接住对方的话,不要机械复述 +- 不要透露自己是AI或虚拟用户""" + prompt = f"""文章标题:{article_title} +原评论:{root_comment} +当前对话上下文:{reply_context} + +请结合文章、原评论和当前对话,写一条自然的后续回复。 +要求: +1. 字数 {word_min}~{word_max} 字 +2. 语气像真实用户交流,可以认同、补充或追问 +3. 必须围绕文章和评论内容,不要跑题 +4. 只输出回复正文,不要加任何前缀或解释 + +回复:""" + return await self._call_api(db, prompt, system_prompt, max_tokens=180) + async def test_model(self, db: AsyncSession, model_id: int, test_prompt: str) -> dict: """测试模型可用性""" result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id)) diff --git a/backend/app/services/news_service.py b/backend/app/services/news_service.py index bd1b835..c53c62d 100755 --- a/backend/app/services/news_service.py +++ b/backend/app/services/news_service.py @@ -7,7 +7,7 @@ import uuid import hashlib import hmac from datetime import datetime, timedelta -from typing import Optional +from typing import Optional, Any import httpx from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update @@ -45,6 +45,31 @@ class NewsPlatformService: "orgId": await self._cfg(db, "platform_org_id", ""), } + @staticmethod + def _api_root(base_url: str) -> str: + if "/api/" in base_url: + return base_url.split("/api/", 1)[0] + "/api" + return base_url.rstrip("/") + + @staticmethod + def _resolve_synced_nickname(user: VirtualUser, sync_nickname: str, sync_real_name: str) -> str: + """避免登录同步把有意义的本地昵称覆盖回手机号。""" + current_nickname = (user.nickname or "").strip() + current_real_name = (user.real_name or "").strip() + sync_nickname = (sync_nickname or "").strip() + sync_real_name = (sync_real_name or "").strip() + account = (user.account or "").strip() + + if current_nickname and current_nickname != account: + return current_nickname + if sync_nickname and sync_nickname != account: + return sync_nickname + if sync_real_name: + return sync_real_name + if current_real_name: + return current_real_name + return sync_nickname or current_nickname or account + # ─── 签名(完全对应 sign.js 逻辑) ───────────────────────── @staticmethod def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str: @@ -188,6 +213,7 @@ class NewsPlatformService: sync_real_name = user_info.get("realName") or "" sync_sex = int(user_info.get("sex") or 0) sync_avatar = user_info.get("avatar") or "" + preferred_nickname = self._resolve_synced_nickname(user, sync_nickname, sync_real_name) await set_session(user.id, { "token": access_token, @@ -196,7 +222,7 @@ class NewsPlatformService: "org_id": org_id or cfg.get("orgId", ""), "login_time": datetime.now().isoformat(), # 缓存用户信息供 sync 使用 - "nickname": sync_nickname, + "nickname": preferred_nickname, "real_name": sync_real_name, "sex": sync_sex, "avatar": sync_avatar, @@ -209,7 +235,7 @@ class NewsPlatformService: last_login_at=datetime.now(), platform_uid=platform_uid, ) - if sync_nickname: update_vals["nickname"] = sync_nickname + if preferred_nickname: update_vals["nickname"] = preferred_nickname if sync_real_name: update_vals["real_name"] = sync_real_name if sync_sex: update_vals["sex"] = sync_sex if sync_avatar: update_vals["avatar_url"] = sync_avatar @@ -719,51 +745,287 @@ class NewsPlatformService: return False async def post_comment(self, db, user, news_id, news_title, content, news_author_id="", org_id="") -> tuple[bool, str]: + success, err, _ = await self.post_comment_with_record_id( + db, user, news_id, news_title, content, + news_author_id=news_author_id, org_id=org_id, + ) + return success, err + + async def post_comment_with_record_id( + self, db, user, news_id, news_title, content, news_author_id="", org_id="" + ) -> tuple[bool, str, str]: sess = await get_session(user.id) if not sess: - return False, "未登录" + return False, "未登录", "" biz = await self._biz_url(db) cfg = await self._client(db) uid = sess.get("platform_uid", "") # org_id 优先取文章自带的(从广场数据获取),否则取 session/配置 final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or "" + if not final_org_id: + final_org_id = await self._get_article_org_id(db, sess["token"], news_id) body = { "module": "news", "topicId": news_id, "title": news_title, "content": content, "orgId": final_org_id, "toUserId": news_author_id or uid, "userId": uid, "userName": user.nickname, "avatar": user.avatar_url or "", } - return await self._json_post(f"{biz}/message/comment", self._bearer(sess["token"]), body) + success, err, record_id = await self._json_post_with_record_id( + f"{biz}/message/comment", self._bearer(sess["token"]), body + ) + if success and not record_id: + record_id, _ = await self.find_comment_id(db, user, news_id, content) + return success, err, record_id - async def post_reply(self, db, user, news_id, comment_id, content) -> tuple[bool, str]: + async def post_reply( + self, db, user, news_id, comment_id, content, + parent_comment: dict | None = None, + reply_to: dict | None = None, + article_title: str = "", + org_id: str = "", + ) -> tuple[bool, str]: + success, err, _ = await self.post_reply_with_record_id( + db, user, news_id, comment_id, content, + parent_comment=parent_comment, + reply_to=reply_to, + article_title=article_title, + org_id=org_id, + ) + return success, err + + async def post_reply_with_record_id( + self, db, user, news_id, comment_id, content, + parent_comment: dict | None = None, + reply_to: dict | None = None, + article_title: str = "", + org_id: str = "", + ) -> tuple[bool, str, str]: sess = await get_session(user.id) if not sess: - return False, "未登录" + return False, "未登录", "" biz = await self._biz_url(db) - uid = sess.get("platform_uid", "") + cfg = await self._client(db) + parent_comment = parent_comment or {} + reply_to = reply_to or None + parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or comment_id or "") + parent_user_id = str( + parent_comment.get("createUser") + or parent_comment.get("userId") + or parent_comment.get("commentUserId") + or parent_comment.get("fromUserId") + or "" + ) + parent_user_name = ( + parent_comment.get("userName") + or parent_comment.get("fromUserName") + or parent_comment.get("nickName") + or "" + ) + reply_target = reply_to or parent_comment + reply_target_id = str( + reply_target.get("id") + or reply_target.get("replyId") + or reply_target.get("commentId") + or parent_comment_id + ) + reply_target_user_id = str( + reply_target.get("createUser") + or reply_target.get("fromUserId") + or reply_target.get("userId") + or reply_target.get("commentUserId") + or parent_user_id + or "" + ) + reply_target_user_name = ( + reply_target.get("fromUserName") + or reply_target.get("userName") + or reply_target.get("nickName") + or parent_user_name + or "" + ) + from_user_name = ( + (user.real_name or "").strip() + or (user.nickname or "").strip() + or (user.account or "").strip() + ) + final_org_id = org_id or sess.get("org_id") or cfg.get("orgId") or "" + if not final_org_id: + final_org_id = await self._get_article_org_id(db, sess["token"], news_id) body = { - "module": "news", "topicId": news_id, "commentId": comment_id, - "commentUserId": uid, "content": content, - "fromUserName": user.nickname, "avatar": user.avatar_url or "", + "module": "news", + "modules": "news", + "topicId": news_id, + "commentId": parent_comment_id, + "commentUserId": parent_user_id, + "replyId": reply_target_id, + "toUserId": reply_target_user_id, + "replyType": "2" if reply_to else "1", + "toUserName": reply_target_user_name, + "content": content, + "title": article_title or parent_comment.get("title") or "", + "orgId": final_org_id, + "fromUserName": from_user_name, + "avatar": user.avatar_url or "", } - return await self._json_post(f"{biz}/message/comment/reply", self._bearer(sess["token"]), body) + success, err, reply_id = await self._json_post_with_record_id( + f"{biz}/message/comment/reply", self._bearer(sess["token"]), body + ) + if success and not reply_id: + reply_id, _ = await self.find_reply_id( + db, user, news_id, + content=content, + parent_comment_id=parent_comment_id, + ) + return success, err, reply_id + + async def _get_article_org_id(self, db, token: str, news_id: str) -> str: + """从文章详情补齐 H5 评论/回复所需的组织上下文。""" + if not news_id: + return "" + biz = await self._biz_url(db) + try: + async with httpx.AsyncClient(timeout=8) as c: + r = await c.get(f"{biz}/news/{news_id}", headers=self._bearer(token)) + if r.status_code != 200: + return "" + data = (r.json().get("data") or {}) + return str( + data.get("orgId") + or data.get("alumnusId") + or data.get("publishOrgId") + or data.get("initialPublishOrgId") + or "" + ) + except Exception: + return "" async def get_comments(self, db, user, news_id) -> list: + data = await self.get_comments_payload(db, user, news_id) + if isinstance(data, dict): + rows = data.get("data") or data.get("records") or data.get("list") or data.get("rows") + return rows if isinstance(rows, list) else [] + return data if isinstance(data, list) else [] + + async def get_comments_payload(self, db, user, news_id) -> Any: sess = await get_session(user.id) if not sess: return [] biz = await self._biz_url(db) - cfg = await self._client(db) try: - params = self._build_form({"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20}, cfg) + params = {"module": "news", "topicId": news_id, "pageNum": 1, "pageSize": 20} async with httpx.AsyncClient(timeout=10) as c: - r = await c.get(f"{biz}/message/comment", headers=self._bearer(sess["token"]), params=params) + r = await c.get( + f"{self._api_root(biz)}/interaction/open/selectInteractionCommentList", + headers=self._bearer(sess["token"]), + params=params, + ) if r.status_code == 200: - return r.json().get("data", {}).get("data") or [] + return r.json().get("data") or [] except Exception: pass return [] + async def find_comment_id(self, db, user, news_id: str, content: str = "") -> tuple[str, int | None]: + """Best-effort lookup for old records created before platform IDs were saved.""" + sess = await get_session(user.id) + my_uid = str(sess.get("platform_uid", "")) if sess else "" + payload = await self.get_comments_payload(db, user, news_id) + comment_count = None + comments = payload if isinstance(payload, list) else [] + if isinstance(payload, dict): + try: + comment_count = int(payload.get("commentCount")) + except (TypeError, ValueError): + comment_count = None + comments = ( + payload.get("data") + or payload.get("records") + or payload.get("list") + or payload.get("rows") + or [] + ) + if not isinstance(comments, list): + comments = [] + + target_content = (content or "").strip() + for item in comments: + if not isinstance(item, dict): + continue + author_id = str(item.get("userId") or item.get("createUser") or item.get("commentUserId") or "") + item_content = (item.get("content") or "").strip() + if my_uid and author_id and author_id != my_uid: + continue + if target_content and item_content != target_content: + continue + comment_id = str(item.get("id") or item.get("commentId") or item.get("messageId") or "") + if comment_id: + return comment_id, comment_count + return "", comment_count + + async def find_reply_id( + self, db, user, news_id: str, content: str = "", parent_comment_id: str = "" + ) -> tuple[str, int | None]: + """Best-effort lookup for reply records created before platform IDs were saved.""" + sess = await get_session(user.id) + my_uid = str(sess.get("platform_uid", "")) if sess else "" + payload = await self.get_comments_payload(db, user, news_id) + reply_count = None + comments = payload if isinstance(payload, list) else [] + if isinstance(payload, dict): + for count_key in ("replyCount", "commentCount", "total"): + try: + if payload.get(count_key) is not None: + reply_count = int(payload.get(count_key)) + break + except (TypeError, ValueError): + reply_count = None + comments = ( + payload.get("data") + or payload.get("records") + or payload.get("list") + or payload.get("rows") + or [] + ) + if not isinstance(comments, list): + comments = [] + + target_content = (content or "").strip() + target_parent_id = str(parent_comment_id or "") + for item in comments: + if not isinstance(item, dict): + continue + current_parent_id = str(item.get("id") or item.get("commentId") or "") + if target_parent_id and current_parent_id != target_parent_id: + continue + replies = ( + item.get("respList") + or item.get("replyList") + or item.get("children") + or item.get("replies") + or [] + ) + if not isinstance(replies, list): + continue + for reply in replies: + if not isinstance(reply, dict): + continue + author_id = str( + reply.get("fromUserId") + or reply.get("createUser") + or reply.get("userId") + or reply.get("commentUserId") + or "" + ) + reply_content = (reply.get("content") or "").strip() + if my_uid and author_id and author_id != my_uid: + continue + if target_content and reply_content != target_content: + continue + reply_id = str(reply.get("id") or reply.get("replyId") or reply.get("commentId") or "") + if reply_id: + return reply_id, reply_count + return "", reply_count + async def like_news(self, db, user, news_id, org_id="", to_user_id="", title="") -> tuple[bool, str]: sess = await get_session(user.id) if not sess: @@ -825,13 +1087,41 @@ class NewsPlatformService: return True, "" return False, f"HTTP {resp.status_code}" + @staticmethod + def _extract_record_id(data: Any) -> str: + if data is None: + return "" + if isinstance(data, (str, int)): + return str(data) + if isinstance(data, dict): + for key in ("id", "replyId", "commentId", "messageId", "recordId"): + if data.get(key): + return str(data[key]) + for key in ("data", "record", "reply", "comment", "message"): + nested = NewsPlatformService._extract_record_id(data.get(key)) + if nested: + return nested + return "" + async def _json_post(self, url, headers, body) -> tuple[bool, str]: + success, err, _ = await self._json_post_with_record_id(url, headers, body) + return success, err + + async def _json_post_with_record_id(self, url, headers, body) -> tuple[bool, str, str]: try: async with httpx.AsyncClient(timeout=15) as c: r = await c.post(url, json=body, headers=headers) - return self._ok(r) + if r.status_code not in [200, 201]: + return False, f"HTTP {r.status_code}", "" + try: + d = r.json() + except Exception: + return True, "", "" + if d.get("code") in [0, 200]: + return True, "", self._extract_record_id(d.get("data")) + return False, d.get("message") or "业务失败", "" except Exception as e: - return False, str(e) + return False, str(e), "" async def _write_login_log(self, db, user, action, session_id=None, error_msg=None): try: @@ -898,6 +1188,25 @@ class NewsPlatformService: except Exception as e: return False, str(e) + async def cancel_reply(self, db, user, news_id: str, reply_id: str) -> tuple[bool, str]: + """DELETE /message/comment/reply/{topicId}/{id} 删除评论回复""" + sess = await get_session(user.id) + if not sess: + return False, "未登录" + biz = await self._biz_url(db) + try: + async with httpx.AsyncClient(timeout=10) as c: + r = await c.delete( + f"{biz}/message/comment/reply/{news_id}/{reply_id}", + headers=self._bearer(sess["token"]), + ) + d = r.json() + if d.get("code") in [0, 200]: + return True, "" + return False, d.get("message", "删除回复失败") + except Exception as e: + return False, str(e) + async def cancel_collect(self, db, user, news_id: str, org_id: str = "", to_user_id: str = "", title: str = "") -> tuple[bool, str]: """取消收藏(复用取消点赞接口)""" return await self.cancel_like(db, user, news_id, org_id=org_id, to_user_id=to_user_id, title=title) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 24bc5f6..241b5be 100755 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -1,7 +1,7 @@ """调度服务 - 定时自动互动、会话校验""" import random import asyncio -from datetime import datetime, date +from datetime import datetime, date, timedelta from typing import Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger @@ -9,7 +9,7 @@ from sqlalchemy import select, update, func from app.core.database import AsyncSessionLocal from app.core.logger import logger -from app.models import VirtualUser, UserPersonality, InteractionRecord, SystemConfig +from app.models import VirtualUser, UserPersonality, InteractionRecord, PendingReplyTask, SystemConfig class SchedulerService: @@ -23,6 +23,12 @@ class SchedulerService: from app.core.database import AsyncSessionLocal logger.info("⚡ 立即触发互动任务") async with AsyncSessionLocal() as session: + try: + max_concurrent = int(await self._get_config(session, "max_concurrent_users", "5")) + except (TypeError, ValueError): + max_concurrent = 5 + max_concurrent = max(1, max_concurrent) + result_r = await session.execute( select(VirtualUser).where( VirtualUser.status == 2, @@ -31,13 +37,62 @@ class SchedulerService: ) users = result_r.scalars().all() if not users: - return {"message": "没有已登录的用户", "triggered": 0} + return { + "message": "没有已登录的用户", + "requested_concurrency": max_concurrent, + "attempted_count": 0, + "success_count": 0, + "skipped_count": 0, + "failed_count": 0, + "triggered": 0, + "users": [], + "results": [], + } import random - selected = random.sample(users, min(5, len(users))) + selected = random.sample(users, min(max_concurrent, len(users))) import asyncio tasks = [self._execute_user_interaction(u.id) for u in selected] - await asyncio.gather(*tasks, return_exceptions=True) - return {"triggered": len(selected), "users": [u.account for u in selected]} + raw_results = await asyncio.gather(*tasks, return_exceptions=True) + + results = [] + success_count = skipped_count = failed_count = 0 + for user, outcome in zip(selected, raw_results): + if isinstance(outcome, Exception): + item = { + "user_id": user.id, + "account": user.account, + "status": "failed", + "reason": str(outcome), + "interactions": [], + } + failed_count += 1 + else: + item = outcome or { + "user_id": user.id, + "account": user.account, + "status": "skipped", + "reason": "no_result", + "interactions": [], + } + status = item.get("status") + if status == "success": + success_count += 1 + elif status == "failed": + failed_count += 1 + else: + skipped_count += 1 + results.append(item) + + return { + "requested_concurrency": max_concurrent, + "attempted_count": len(selected), + "success_count": success_count, + "skipped_count": skipped_count, + "failed_count": failed_count, + "triggered": len(selected), + "users": [u.account for u in selected], + "results": results, + } async def start(self): if self._running: @@ -52,6 +107,11 @@ class SchedulerService: self._run_interactions, IntervalTrigger(minutes=5), id="run_interactions", replace_existing=True ) + # 待发送回复队列:持久化延迟回复,后端重启后可继续发送 + self.scheduler.add_job( + self._process_pending_reply_tasks, IntervalTrigger(seconds=30), + id="process_pending_reply_tasks", replace_existing=True + ) # 每日零点重置计数 self.scheduler.add_job( self._daily_reset, "cron", hour=16, minute=0, # 北京时间 00:00 = UTC 16:00 @@ -221,7 +281,13 @@ class SchedulerService: user_result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id)) user = user_result.scalar_one_or_none() if not user or user.status != 2: - return + return { + "user_id": user_id, + "account": getattr(user, "account", ""), + "status": "skipped", + "reason": "user_not_logged_in", + "interactions": [], + } # 检查今日评论限额 can_comment = True @@ -255,7 +321,13 @@ class SchedulerService: f"用户 {user.account} 获取新闻列表为空 " f"(orgId={await news_service._cfg(db, 'platform_org_id', '')})" ) - return + return { + "user_id": user.id, + "account": user.account, + "status": "skipped", + "reason": "no_articles", + "interactions": [], + } # ── 文章去重 + 热度加权选取 ───────────────────────────────── # 查询今日已互动过的文章(所有类型),避免重复互动同一篇 @@ -307,7 +379,14 @@ class SchedulerService: article_org_id = str(article.get("orgId") or "") if not news_id: - return + return { + "user_id": user.id, + "account": user.account, + "status": "skipped", + "reason": "missing_news_id", + "interactions": [], + "article_title": news_title, + } # 读取互动概率 comment_prob = float(await self._get_config_from_db(db, "comment_probability", "0.4")) @@ -317,6 +396,7 @@ class SchedulerService: forward_prob = float(await self._get_config_from_db(db, "forward_probability", "0.15")) interactions_done = [] + action_failures = [] # ① 先记录阅读(每次必做,模拟真实用户打开文章) await news_service.read_news(db, user, news_id) @@ -331,6 +411,8 @@ class SchedulerService: if success: interactions_done.append("like") await self._incr_total(db, user_id) + else: + action_failures.append({"type": "like", "error": err}) # ③ 收藏(每篇文章每用户每天只收藏一次) if "collect" not in done_on_this and random.random() < collect_prob: @@ -338,6 +420,8 @@ class SchedulerService: await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err) if success: interactions_done.append("collect") + else: + action_failures.append({"type": "collect", "error": err}) # ④ 转发(每篇文章每用户每天只转发一次) if "forward" not in done_on_this and random.random() < forward_prob: @@ -346,109 +430,433 @@ class SchedulerService: if success: interactions_done.append("forward") await self._incr_total(db, user_id) + else: + action_failures.append({"type": "forward", "error": err}) - # ⑤ 评论/回复逻辑(去重:已评论过的文章改为回复他人评论) + # ⑤ 评论/回复逻辑:评论和回复互相独立,未评论过文章也可以回复他人评论 if can_comment and personality: style_prompt = personality.comment_style_prompt or "" safe_word_max = min(personality.word_count_max, 80) - if already_commented_this: - # 已评论过此文章 → 改为回复其他用户的评论(虚拟用户互动) - if random.random() < reply_prob: - existing = await news_service.get_comments(db, user, news_id) - if existing: - # 优先回复虚拟用户的评论(促进互动),过滤掉自己的评论 - from app.core.redis_client import get_session as _gs - my_sess = await _gs(user.id) - my_uid = my_sess.get("platform_uid", "") if my_sess else "" - others = [c for c in existing - if str(c.get("userId") or c.get("createUser") or "") != my_uid] - if others: - target = random.choice(others) - cid = str(target.get("id") or target.get("commentId") or "") - parent_content = target.get("content") or "" - if cid: - reply_text, r_tokens = await ai_service.generate_reply( - db, news_title, parent_content, - style_prompt, - personality.word_count_min, - safe_word_max - ) - if reply_text: - r_ok, r_err = await news_service.post_reply( - db, user, news_id, cid, reply_text - ) - await self._save_record( - db, user, news_id, news_title, "reply", - reply_text, r_tokens, r_ok, r_err, - parent_comment_id=cid - ) - if r_ok: - interactions_done.append("reply") - logger.info(f"💬 {user.account} 回复了已评论文章的评论(去重逻辑)") - else: - # 未评论过此文章 → 正常发评论 - if random.random() < comment_prob: - comment_text, tokens = await ai_service.generate_comment( - db, news_title, news_content, - style_prompt, personality.word_count_min, safe_word_max - ) - if comment_text: - success, err = await news_service.post_comment( - db, user, news_id, news_title, comment_text, - news_author_id=news_author, org_id=article_org_id - ) - await self._save_record( - db, user, news_id, news_title, "comment", - comment_text, tokens, success, err - ) - if success: - interactions_done.append("comment") - await db.execute( - update(VirtualUser).where(VirtualUser.id == user_id).values( - today_comment_count=VirtualUser.today_comment_count + 1, - total_interactions=VirtualUser.total_interactions + 1, - last_interact_at=datetime.now() - ) - ) + if random.random() < reply_prob: + reply_actions, reply_failures = await self._run_reply_interaction_chain( + db=db, + starter=user, + starter_personality=personality, + news_service=news_service, + ai_service=ai_service, + news_id=news_id, + news_title=news_title, + article_org_id=article_org_id, + style_prompt=style_prompt, + safe_word_max=safe_word_max, + ) + interactions_done.extend(reply_actions) + action_failures.extend(reply_failures) - # ⑥ 评论成功后,随机回复其他用户的评论(互动链) - if random.random() < reply_prob: - existing = await news_service.get_comments(db, user, news_id) - if existing: - from app.core.redis_client import get_session as _gs2 - my_sess2 = await _gs2(user.id) - my_uid2 = my_sess2.get("platform_uid", "") if my_sess2 else "" - others2 = [c for c in existing - if str(c.get("userId") or c.get("createUser") or "") != my_uid2] - if others2: - target2 = random.choice(others2) - cid2 = str(target2.get("id") or target2.get("commentId") or "") - parent_content2 = target2.get("content") or "" - if cid2: - reply_text2, r_tokens2 = await ai_service.generate_reply( - db, news_title, parent_content2, - style_prompt, - personality.word_count_min, - safe_word_max - ) - if reply_text2: - r_ok2, r_err2 = await news_service.post_reply( - db, user, news_id, cid2, reply_text2 - ) - await self._save_record( - db, user, news_id, news_title, "reply", - reply_text2, r_tokens2, r_ok2, r_err2, - parent_comment_id=cid2 - ) - if r_ok2: - interactions_done.append("reply") + # 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论 + if not already_commented_this and random.random() < comment_prob: + comment_text, tokens = await ai_service.generate_comment( + db, news_title, news_content, + style_prompt, personality.word_count_min, safe_word_max + ) + if comment_text: + success, err, comment_record_id = await news_service.post_comment_with_record_id( + db, user, news_id, news_title, comment_text, + news_author_id=news_author, org_id=article_org_id + ) + await self._save_record( + db, user, news_id, news_title, "comment", + comment_text, tokens, success, err, + platform_record_id=comment_record_id, + ) + if success: + interactions_done.append("comment") + await db.execute( + update(VirtualUser).where(VirtualUser.id == user_id).values( + today_comment_count=VirtualUser.today_comment_count + 1, + total_interactions=VirtualUser.total_interactions + 1, + last_interact_at=datetime.now() + ) + ) + else: + action_failures.append({"type": "comment", "error": err}) await db.commit() logger.info(f"👤 {user.account} 互动完成: {interactions_done} [新闻: {news_title[:20]}]") + if interactions_done: + return { + "user_id": user.id, + "account": user.account, + "status": "success", + "reason": "", + "interactions": interactions_done, + "article_id": news_id, + "article_title": news_title, + } + if action_failures: + return { + "user_id": user.id, + "account": user.account, + "status": "failed", + "reason": "; ".join( + f"{item['type']}:{item['error'] or 'unknown'}" for item in action_failures + ), + "interactions": [], + "article_id": news_id, + "article_title": news_title, + } + return { + "user_id": user.id, + "account": user.account, + "status": "skipped", + "reason": "no_actions_triggered", + "interactions": [], + "article_id": news_id, + "article_title": news_title, + } except Exception as e: logger.error(f"用户 {user_id} 互动异常: {e}") + return { + "user_id": user_id, + "account": "", + "status": "failed", + "reason": str(e), + "interactions": [], + } + + async def _run_reply_interaction_chain( + self, + db, + starter: VirtualUser, + starter_personality, + news_service, + ai_service, + news_id: str, + news_title: str, + article_org_id: str, + style_prompt: str, + safe_word_max: int, + ) -> tuple[list[str], list[dict]]: + """主动回复评论,并按概率安排后续延迟回应。""" + from app.core.redis_client import get_session + + actions: list[str] = [] + failures: list[dict] = [] + starter_sess = await get_session(starter.id) + starter_uid = str(starter_sess.get("platform_uid") or "") if starter_sess else "" + comments = await news_service.get_comments(db, starter, news_id) + if not comments: + return actions, failures + + candidates = [ + c for c in comments + if str(c.get("id") or c.get("commentId") or "") + and (c.get("content") or "").strip() + and str(c.get("userId") or c.get("createUser") or "") != starter_uid + ] + if not candidates: + return actions, failures + + parent_comment = random.choice(candidates) + parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "") + root_content = (parent_comment.get("content") or "").strip() + context = f"准备回复这条评论:{root_content}" + reply_text, reply_tokens = await ai_service.generate_thread_reply( + db, news_title, root_content, context, + style_prompt, + starter_personality.word_count_min, + safe_word_max, + ) + if not reply_text: + return actions, failures + + ok, err, reply_id = await news_service.post_reply_with_record_id( + db, starter, news_id, parent_comment_id, reply_text, + parent_comment=parent_comment, + article_title=news_title, + org_id=article_org_id, + ) + await self._save_record( + db, starter, news_id, news_title, "reply", + reply_text, reply_tokens, ok, err, + parent_comment_id=parent_comment_id, + platform_record_id=reply_id, + ) + if not ok: + failures.append({"type": "reply", "error": err}) + return actions, failures + + actions.append("reply") + await self._incr_total(db, starter.id) + logger.info(f"💬 {starter.account} 回复了文章评论") + + starter_reply = { + "id": reply_id, + "content": reply_text, + "createUser": starter_uid, + "fromUserName": starter.real_name or starter.nickname or starter.account, + } + + chain_probability = await self._get_float_config(db, "reply_chain_probability", 0.3) + delay_min = await self._get_int_config(db, "reply_chain_delay_min_seconds", 30) + delay_max = await self._get_int_config(db, "reply_chain_delay_max_seconds", 7200) + if delay_max < delay_min: + delay_max = delay_min + + # 评论作者如果也是当前系统里的已登录虚拟用户,按概率安排稍后回复这条回复。 + parent_author_uid = str(parent_comment.get("createUser") or parent_comment.get("userId") or "") + parent_author = await self._get_logged_in_user_by_platform_uid(db, parent_author_uid) + if ( + parent_author + and parent_author.id != starter.id + and random.random() < chain_probability + ): + delay_seconds = random.randint(delay_min, delay_max) + await self._enqueue_pending_reply_task( + db=db, + delay_seconds=delay_seconds, + actor_id=parent_author.id, + news_id=news_id, + news_title=news_title, + article_org_id=article_org_id, + parent_comment=parent_comment, + reply_to=starter_reply, + root_content=root_content, + context=f"对方刚回复了你的评论:{reply_text}", + next_actor_id=starter.id, + next_probability=chain_probability, + next_delay_min=delay_min, + next_delay_max=delay_max, + ) + logger.info( + f"⏳ {parent_author.account} 已进入待发送回复队列,延迟 {delay_seconds}s 后发送" + ) + + return actions, failures + + async def _enqueue_pending_reply_task( + self, + db, + delay_seconds: int, + actor_id: int, + news_id: str, + news_title: str, + article_org_id: str, + parent_comment: dict, + reply_to: dict, + root_content: str, + context: str, + next_actor_id: int | None = None, + next_probability: float = 0.3, + next_delay_min: int = 30, + next_delay_max: int = 7200, + ): + parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "") + db.add(PendingReplyTask( + actor_user_id=actor_id, + next_actor_user_id=next_actor_id, + news_id=news_id, + news_title=news_title, + article_org_id=article_org_id, + parent_comment_id=parent_comment_id, + parent_comment=parent_comment, + reply_to=reply_to, + root_content=root_content, + context=context, + next_probability=next_probability, + next_delay_min_seconds=next_delay_min, + next_delay_max_seconds=next_delay_max, + status=0, + attempts=0, + scheduled_at=datetime.now() + timedelta(seconds=max(0, delay_seconds)), + )) + + async def _process_pending_reply_tasks(self): + from app.services.news_service import news_service + from app.services.ai_service import ai_service + + async with AsyncSessionLocal() as db: + try: + now = datetime.now() + await db.execute( + update(PendingReplyTask) + .where( + PendingReplyTask.status == 1, + PendingReplyTask.locked_at < now - timedelta(minutes=10), + ) + .values(status=0, last_error="发送中超时,重新入队") + ) + result = await db.execute( + select(PendingReplyTask) + .where( + PendingReplyTask.status == 0, + PendingReplyTask.scheduled_at <= now, + ) + .order_by(PendingReplyTask.scheduled_at.asc()) + .limit(10) + ) + tasks = result.scalars().all() + for task in tasks: + await self._process_pending_reply_task(db, task, news_service, ai_service) + await db.commit() + except Exception as e: + await db.rollback() + logger.error(f"待发送回复队列处理异常: {e}") + + async def _process_pending_reply_task(self, db, task: PendingReplyTask, news_service, ai_service): + task.status = 1 + task.locked_at = datetime.now() + task.attempts = (task.attempts or 0) + 1 + await db.flush() + + actor = await self._get_user_by_id(db, task.actor_user_id) + if not actor or actor.status != 2 or actor.is_enabled != 1: + task.status = 3 + task.last_error = "用户未登录或已禁用" + return + + reply_result = await self._post_contextual_reply( + db=db, + actor=actor, + news_service=news_service, + ai_service=ai_service, + news_id=task.news_id, + news_title=task.news_title or "", + article_org_id=task.article_org_id or "", + parent_comment=task.parent_comment or {}, + reply_to=task.reply_to or {}, + root_content=task.root_content or "", + context=task.context or "", + ) + if not reply_result["ok"]: + task.status = 3 if task.attempts >= 3 else 0 + task.last_error = reply_result["error"] or "生成或发送回复失败" + if task.status == 0: + task.scheduled_at = datetime.now() + timedelta(minutes=5) + logger.warning(f"待发送回复失败 task_id={task.id} user={actor.account}: {task.last_error}") + return + + task.status = 2 + task.sent_at = datetime.now() + task.last_error = None + await self._incr_total(db, actor.id) + logger.info(f"💬 {actor.account} 发送了待发送回复 task_id={task.id}") + + if task.next_actor_user_id and random.random() < float(task.next_probability or 0.3): + delay_min = int(task.next_delay_min_seconds or 30) + delay_max = max(delay_min, int(task.next_delay_max_seconds or 7200)) + next_delay = random.randint(delay_min, delay_max) + await self._enqueue_pending_reply_task( + db=db, + delay_seconds=next_delay, + actor_id=task.next_actor_user_id, + news_id=task.news_id, + news_title=task.news_title or "", + article_org_id=task.article_org_id or "", + parent_comment=task.parent_comment or {}, + reply_to=reply_result["reply"], + root_content=task.root_content or "", + context=( + f"原评论:{task.root_content or ''}\n" + f"上一条回复:{(task.reply_to or {}).get('content') or ''}\n" + f"对方回应:{reply_result['content']}" + ), + next_actor_id=None, + next_probability=float(task.next_probability or 0.3), + next_delay_min=delay_min, + next_delay_max=delay_max, + ) + logger.info(f"⏳ 已入队继续回复,延迟 {next_delay}s 后发送") + + async def _post_contextual_reply( + self, + db, + actor: VirtualUser, + news_service, + ai_service, + news_id: str, + news_title: str, + article_org_id: str, + parent_comment: dict, + reply_to: dict, + root_content: str, + context: str, + ) -> dict: + personality = await self._get_user_personality(db, actor.id) + style_prompt = personality.comment_style_prompt if personality else "" + word_min = personality.word_count_min if personality else 15 + word_max = min(personality.word_count_max, 80) if personality else 60 + content, tokens = await ai_service.generate_thread_reply( + db, news_title, root_content, context, + style_prompt, word_min, word_max, + ) + if not content: + return {"ok": False, "error": "", "reply": {}, "content": ""} + + parent_comment_id = str(parent_comment.get("id") or parent_comment.get("commentId") or "") + ok, err, reply_id = await news_service.post_reply_with_record_id( + db, actor, news_id, parent_comment_id, content, + parent_comment=parent_comment, + reply_to=reply_to, + article_title=news_title, + org_id=article_org_id, + ) + await self._save_record( + db, actor, news_id, news_title, "reply", + content, tokens, ok, err, + parent_comment_id=parent_comment_id, + platform_record_id=reply_id, + ) + from app.core.redis_client import get_session + sess = await get_session(actor.id) + actor_uid = str(sess.get("platform_uid") or "") if sess else "" + return { + "ok": ok, + "error": "" if ok else err, + "content": content, + "reply": { + "id": reply_id, + "content": content, + "createUser": actor_uid, + "fromUserName": actor.real_name or actor.nickname or actor.account, + }, + } + + async def _get_logged_in_user_by_platform_uid(self, db, platform_uid: str) -> VirtualUser | None: + if not platform_uid: + return None + result = await db.execute( + select(VirtualUser).where( + VirtualUser.platform_uid == platform_uid, + VirtualUser.status == 2, + VirtualUser.is_enabled == 1, + ) + ) + return result.scalar_one_or_none() + + async def _get_user_by_id(self, db, user_id: int) -> VirtualUser | None: + result = await db.execute(select(VirtualUser).where(VirtualUser.id == user_id)) + return result.scalar_one_or_none() + + async def _get_user_personality(self, db, user_id: int): + result = await db.execute( + select(UserPersonality).where(UserPersonality.user_id == user_id) + ) + return result.scalar_one_or_none() + + async def _get_float_config(self, db, key: str, default: float) -> float: + try: + return float(await self._get_config_from_db(db, key, str(default))) + except (TypeError, ValueError): + return default + + async def _get_int_config(self, db, key: str, default: int) -> int: + try: + return int(float(await self._get_config_from_db(db, key, str(default)))) + except (TypeError, ValueError): + return default async def _incr_total(self, db, user_id: int): await db.execute( @@ -511,4 +919,4 @@ class SchedulerService: logger.info("每日计数重置完成") -scheduler_service = SchedulerService() \ No newline at end of file +scheduler_service = SchedulerService() diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 9260baa..f41f5fd 100755 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -230,7 +230,7 @@ class StatsService: "comment": "评论", "reply": "回复", "like": "点赞", "collect": "收藏", "forward": "转发" } - STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败"} + STATUS_LABELS = {0: "执行中", 1: "成功", 2: "失败", 3: "已取消"} items = [] for r in records: diff --git a/digital-avatar-app/.dockerignore b/digital-avatar-app/.dockerignore new file mode 100644 index 0000000..c2298bc --- /dev/null +++ b/digital-avatar-app/.dockerignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.git/ +.env +*.log +backend/ diff --git a/digital-avatar-app/.gitignore b/digital-avatar-app/.gitignore new file mode 100644 index 0000000..3b24e4a --- /dev/null +++ b/digital-avatar-app/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +*.log diff --git a/digital-avatar-app/Dockerfile b/digital-avatar-app/Dockerfile new file mode 100644 index 0000000..9403c8c --- /dev/null +++ b/digital-avatar-app/Dockerfile @@ -0,0 +1,22 @@ +# 构建阶段:安装依赖并打包 H5 +FROM node:18-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +# 跳过 vue-tsc 类型检查直接打包(与本机已知 vue-tsc + Node 版本兼容问题无关,保证可构建) +RUN npx vite build + +# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端 +# 锁定 1.28-alpine:测试服务器 Docker 的 seccomp 拦截 pwrite 系统调用, +# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。 +FROM nginx:1.28-alpine + +COPY --from=build /app/dist /usr/share/nginx/html +# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启) +COPY nginx.conf /etc/nginx/nginx.conf + +EXPOSE 80 diff --git a/digital-avatar-app/backend/.dockerignore b/digital-avatar-app/backend/.dockerignore new file mode 100644 index 0000000..2a79de0 --- /dev/null +++ b/digital-avatar-app/backend/.dockerignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.db +.env +logs/ +*.log diff --git a/digital-avatar-app/backend/Dockerfile b/digital-avatar-app/backend/Dockerfile new file mode 100644 index 0000000..2db7f71 --- /dev/null +++ b/digital-avatar-app/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.10-slim + +WORKDIR /app + +# 后端依赖(fastapi/uvicorn/sqlalchemy/pypdf/python-docx/openpyxl 等)均为纯 Python wheel, +# 无需 gcc 等编译链,故跳过 apt 安装以加快构建并减小镜像体积。 +COPY requirements.txt . +RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt + +COPY . . + +# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动 +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/digital-avatar-app/backend/database.py b/digital-avatar-app/backend/database.py new file mode 100644 index 0000000..eaf21be --- /dev/null +++ b/digital-avatar-app/backend/database.py @@ -0,0 +1,49 @@ +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base, Session + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_FILE = os.path.join(BASE_DIR, "avatar.db") + +engine = create_engine( + f"sqlite:///{DB_FILE}", + connect_args={"check_same_thread": False}, +) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + import models + + Base.metadata.create_all(bind=engine) + + # 轻量迁移:为已存在的表补充新列(SQLite 不支持自动 ALTER,逐列尝试) + _try_add_columns( + ("qa_pairs", "enabled", "BOOLEAN DEFAULT 1"), + ("knowledge_docs", "vectorized", "BOOLEAN DEFAULT 0"), + ("knowledge_docs", "embedding_model", "VARCHAR DEFAULT ''"), + ("knowledge_docs", "chunk_count", "INTEGER DEFAULT 0"), + ("knowledge_docs", "vectorized_at", "TIMESTAMP"), + ("avatars", "owner_id", "VARCHAR DEFAULT ''"), + ) + + +def _try_add_columns(*cols): + with engine.connect() as conn: + for table, col, ddl in cols: + try: + conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}") + conn.commit() + except Exception: + # 列已存在(或全新库由 create_all 建好)则忽略 + pass diff --git a/digital-avatar-app/backend/models.py b/digital-avatar-app/backend/models.py new file mode 100644 index 0000000..f2fb3c8 --- /dev/null +++ b/digital-avatar-app/backend/models.py @@ -0,0 +1,220 @@ +import uuid + +from sqlalchemy import Column, String, Integer, Float, DateTime, Text, JSON, Boolean +from sqlalchemy.sql import func + +from database import Base + + +def _iso(dt): + return dt.isoformat() if dt else None + + +class Avatar(Base): + __tablename__ = "avatars" + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + owner_id = Column(String, default="", index=True) # 归属用户(会会 huihui_user_id);空=未归属/种子数据 + name = Column(String, nullable=False) + display_name = Column(String, default="") + description = Column(Text, default="") + photo_url = Column(String, default="") + emoji = Column(String, default="🤖") + status = Column(String, default="active") # active | inactive | training + token_balance = Column(Integer, default=0) + config = Column(JSON, default=dict) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + def to_dict(self): + return { + "id": self.id, + "ownerId": self.owner_id, + "name": self.name, + "displayName": self.display_name, + "description": self.description, + "photoUrl": self.photo_url, + "emoji": self.emoji, + "status": self.status, + "tokenBalance": self.token_balance, + "config": self.config or {}, + "createdAt": _iso(self.created_at), + "updatedAt": _iso(self.updated_at), + } + + +class Authorization(Base): + __tablename__ = "authorizations" + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + avatar_id = Column(String, nullable=False, default="") + target_type = Column(String, default="user") # user | organization | application + target_id = Column(String, default="") + target_name = Column(String, default="") + permissions = Column(JSON, default=list) + status = Column(String, default="active") # active | inactive + created_at = Column(DateTime, server_default=func.now()) + + def to_dict(self): + return { + "id": self.id, + "avatarId": self.avatar_id, + "targetType": self.target_type, + "targetId": self.target_id, + "targetName": self.target_name, + "permissions": self.permissions or [], + "status": self.status, + "createdAt": _iso(self.created_at), + } + + +class Organization(Base): + __tablename__ = "organizations" + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + name = Column(String, nullable=False) + description = Column(Text, default="") + emoji = Column(String, default="🏢") + org_type = Column(String, default="team") # team | company | community + role = Column(String, default="admin") # admin | member | viewer + member_count = Column(Integer, default=1) + created_at = Column(DateTime, server_default=func.now()) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "description": self.description, + "emoji": self.emoji, + "type": self.org_type, + "role": self.role, + "memberCount": self.member_count, + "createdAt": _iso(self.created_at), + } + + +class KnowledgeDoc(Base): + __tablename__ = "knowledge_docs" + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + avatar_id = Column(String, nullable=False, default="") + filename = Column(String, default="") + file_type = Column(String, default="") # pdf | doc | docx | xlsx + file_size = Column(Integer, default=0) + file_url = Column(String, default="") + status = Column(String, default="uploaded") # uploaded | parsing | ready + vectorized = Column(Boolean, default=False) # 是否已向量化 + embedding_model = Column(String, default="") # 向量模型标识 + chunk_count = Column(Integer, default=0) # 切片数量 + vectorized_at = Column(DateTime) # 向量化时间 + created_at = Column(DateTime, server_default=func.now()) + + def to_dict(self): + return { + "id": self.id, + "avatarId": self.avatar_id, + "filename": self.filename, + "fileType": self.file_type, + "fileSize": self.file_size, + "fileUrl": self.file_url, + "status": self.status, + "vectorized": bool(self.vectorized), + "embeddingModel": self.embedding_model, + "chunkCount": self.chunk_count, + "vectorizedAt": _iso(self.vectorized_at), + "createdAt": _iso(self.created_at), + } + + +class QAPair(Base): + __tablename__ = "qa_pairs" + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + avatar_id = Column(String, nullable=False, default="") + question = Column(Text, default="") + answer = Column(Text, default="") + enabled = Column(Boolean, default=True) # 是否启用(关闭后不参与作答) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + def to_dict(self): + return { + "id": self.id, + "avatarId": self.avatar_id, + "question": self.question, + "answer": self.answer, + "enabled": bool(self.enabled), + "createdAt": _iso(self.created_at), + "updatedAt": _iso(self.updated_at), + } + + +class KnowledgeChunk(Base): + __tablename__ = "knowledge_chunks" + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + doc_id = Column(String, default="") # 关联 KnowledgeDoc.id + avatar_id = Column(String, default="") + content = Column(Text, default="") # 切片文本 + vector = Column(Text, default="") # JSON 编码的向量 + chunk_index = Column(Integer, default=0) + embedding_model = Column(String, default="") + created_at = Column(DateTime, server_default=func.now()) + + def to_dict(self): + return { + "id": self.id, + "docId": self.doc_id, + "avatarId": self.avatar_id, + "content": self.content, + "chunkIndex": self.chunk_index, + "embeddingModel": self.embedding_model, + "createdAt": _iso(self.created_at), + } + + +class TokenAccount(Base): + __tablename__ = "token_account" + id = Column(Integer, primary_key=True) + balance = Column(Integer, default=1250) + + +class TokenPlan(Base): + __tablename__ = "token_plans" + id = Column(String, primary_key=True) + name = Column(String, default="") + amount = Column(Integer, default=0) + price = Column(Float, default=0) + badge = Column(String, default="") + desc = Column(String, default="") + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "amount": self.amount, + "price": self.price, + "badge": self.badge, + "desc": self.desc, + } + + +class User(Base): + """会会用户 ↔ 本地用户体系映射(短信验证码登录落库)""" + + __tablename__ = "users" + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + huihui_user_id = Column(String, default="", index=True) # 会会 userId(唯一标识) + phone = Column(String, default="", index=True) + nickname = Column(String, default="") + avatar_url = Column(String, default="") + huihui_token = Column(String, default="") # 会会 access_token + app_token = Column(String, default="") # 本系统会话 token + last_login_at = Column(DateTime) + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + def to_dict(self): + return { + "id": self.id, + "huihuiUserId": self.huihui_user_id, + "phone": self.phone, + "nickname": self.nickname, + "avatarUrl": self.avatar_url, + "createdAt": _iso(self.created_at), + "lastLoginAt": _iso(self.last_login_at), + } diff --git a/digital-avatar-app/backend/requirements.txt b/digital-avatar-app/backend/requirements.txt new file mode 100644 index 0000000..aab6dab --- /dev/null +++ b/digital-avatar-app/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn[standard] +sqlalchemy +pydantic +python-multipart +httpx +pypdf +python-docx +openpyxl diff --git a/digital-avatar-app/backend/responses.py b/digital-avatar-app/backend/responses.py new file mode 100644 index 0000000..c092b95 --- /dev/null +++ b/digital-avatar-app/backend/responses.py @@ -0,0 +1,6 @@ +def ok(data=None, message="success"): + return {"code": 200, "message": message, "data": data} + + +def fail(message="error", code=400): + return {"code": code, "message": message, "data": None} diff --git a/digital-avatar-app/backend/routers/__init__.py b/digital-avatar-app/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/digital-avatar-app/backend/routers/authorizations.py b/digital-avatar-app/backend/routers/authorizations.py new file mode 100644 index 0000000..c110b4d --- /dev/null +++ b/digital-avatar-app/backend/routers/authorizations.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends, Body +from sqlalchemy.orm import Session + +from database import get_db +from models import Authorization +from responses import ok, fail + +router = APIRouter(tags=["授权"]) + + +@router.get("/avatar/{avatar_id}/authorizations") +def list_auth(avatar_id: str, db: Session = Depends(get_db)): + # demo:返回全部授权(忽略具体 avatar 绑定,便于联调) + items = db.query(Authorization).order_by(Authorization.created_at.desc()).all() + return ok([a.to_dict() for a in items]) + + +@router.put("/avatar/{avatar_id}/authorizations") +def update_auth(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)): + auth_id = payload.get("id") + if not auth_id: + return fail("缺少授权 id", 400) + a = db.query(Authorization).filter(Authorization.id == auth_id).first() + if not a: + return fail("授权不存在", 404) + if "status" in payload: + a.status = payload["status"] + if "permissions" in payload: + a.permissions = payload["permissions"] + db.commit() + items = db.query(Authorization).order_by(Authorization.created_at.desc()).all() + return ok([x.to_dict() for x in items]) diff --git a/digital-avatar-app/backend/routers/avatars.py b/digital-avatar-app/backend/routers/avatars.py new file mode 100644 index 0000000..956a56d --- /dev/null +++ b/digital-avatar-app/backend/routers/avatars.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, Depends, Body, Header +from sqlalchemy.orm import Session + +from database import get_db +from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization, User +from responses import ok, fail + +router = APIRouter(tags=["分身"]) + + +def _resolve_user(authorization: str | None, db: Session): + """从 Authorization: Bearer 解析当前登录用户""" + if not authorization: + return None + token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip() + return db.query(User).filter(User.app_token == token).first() + + +@router.get("/avatar") +def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(None), db: Session = Depends(get_db)): + # 仅返回当前登录用户自己的分身;未登录返回空,避免看到种子/他人数据 + user = _resolve_user(authorization, db) + if not user: + return ok({"data": [], "total": 0}) + q = db.query(Avatar).filter(Avatar.owner_id == user.huihui_user_id) + total = q.count() + items = ( + q.order_by(Avatar.created_at.desc()) + .offset((page - 1) * limit) + .limit(limit) + .all() + ) + return ok({"data": [a.to_dict() for a in items], "total": total}) + + +@router.get("/avatar/{avatar_id}") +def get_avatar(avatar_id: str, db: Session = Depends(get_db)): + a = db.query(Avatar).filter(Avatar.id == avatar_id).first() + if not a: + return fail("分身不存在", 404) + return ok(a.to_dict()) + + +@router.post("/avatar") +def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)): + user = _resolve_user(authorization, db) + a = Avatar( + owner_id=user.huihui_user_id if user else "", + name=payload.get("name", "未命名分身"), + display_name=payload.get("displayName", "") or payload.get("display_name", ""), + description=payload.get("description", ""), + photo_url=payload.get("photoUrl", "") or payload.get("photo_url", ""), + emoji=payload.get("emoji", "🤖"), + status=payload.get("status", "active"), + token_balance=payload.get("tokenBalance", 0), + config=payload.get("config", {}) or {}, + ) + db.add(a) + db.commit() + db.refresh(a) + return ok(a.to_dict()) + + +@router.put("/avatar/{avatar_id}") +def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)): + a = db.query(Avatar).filter(Avatar.id == avatar_id).first() + if not a: + return fail("分身不存在", 404) + mapping = { + "displayName": "display_name", + "photoUrl": "photo_url", + "tokenBalance": "token_balance", + } + for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"): + if key in payload: + col = mapping.get(key, key) + setattr(a, col, payload[key]) + db.commit() + db.refresh(a) + return ok(a.to_dict()) + + +@router.delete("/avatar/{avatar_id}") +def delete_avatar(avatar_id: str, db: Session = Depends(get_db)): + a = db.query(Avatar).filter(Avatar.id == avatar_id).first() + if not a: + return fail("分身不存在", 404) + # 级联清理关联数据,避免孤儿记录 + db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete() + db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete() + db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete() + db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete() + db.delete(a) + db.commit() + return ok({"success": True}) diff --git a/digital-avatar-app/backend/routers/huihui_auth.py b/digital-avatar-app/backend/routers/huihui_auth.py new file mode 100644 index 0000000..588fb1a --- /dev/null +++ b/digital-avatar-app/backend/routers/huihui_auth.py @@ -0,0 +1,336 @@ +""" +会会短信验证码登录代理(真实开放平台对接) +────────────────────────────────────────────── +严格按会会开放平台 sign.js 签名范式: + - 公共字段 appId/accessId/timestamp(12小时制hh)/signType/signVersion/accessSecret/nonce 合并业务参数 + - 过滤空值 → 字典序排序 → k=v& 拼接 → 末尾追加 accessSecret=secretKey → MD5/SHA256 大写 + - 认证服务基址 / appId / accessId / accessSecret / clientCode 走环境变量 +真实端点(来自 fat-open 网关 usercenter 的 Swagger): + - 发送验证码:POST {BASE}{HUIHUI_SMS_SEND_PATH} 默认 /open/mobile/sms/code (query 参数) + - 短信登录: POST {BASE}{HUIHUI_SMS_LOGIN_PATH} 默认 /open/login/token,loginType=code +登录成功后建/链本地 users 表(按会会 userId 唯一),签发本系统 app_token 作为会话。 +无真实凭证时仍可走 DEV_MOCK 兜底联调。 +""" +import os +import uuid +import random +import string +import hashlib +import httpx +from datetime import datetime, timezone, timedelta + +from fastapi import APIRouter, Body, Depends, Header +from sqlalchemy.orm import Session + +# 会会网关按北京时间(Asia/Shanghai, UTC+8)校验时间戳,容器默认 UTC 会导致签名被拒。 +# 用固定 +8 偏移(不依赖 tzdata,slim 镜像缺 IANA 库时会抛 ZoneInfoNotFoundError)。 +_CN_TZ = timezone(timedelta(hours=8)) + +from database import get_db +from models import User +from responses import ok, fail + +router = APIRouter(tags=["会会账号"]) + +# ── 会会开放平台配置(环境变量)── +AUTH_BASE_URL = os.getenv("HUIHUI_AUTH_BASE_URL", "https://fat-open.99hui.com/api/usercenter") +APP_ID = os.getenv("HUIHUI_APP_ID", "") +ACCESS_ID = os.getenv("HUIHUI_ACCESS_ID", "") +ACCESS_SECRET = os.getenv("HUIHUI_ACCESS_SECRET", "") +CLIENT_CODE = os.getenv("HUIHUI_CLIENT_CODE", "") +SMS_SEND_PATH = os.getenv("HUIHUI_SMS_SEND_PATH", "/open/mobile/sms/code") +SMS_LOGIN_PATH = os.getenv("HUIHUI_SMS_LOGIN_PATH", "/open/login/token") + +# 临时开发态:无真实会会凭证时,本地模拟短信收发,便于端到端联调。 +DEV_MOCK = os.getenv("HUIHUI_DEV_MOCK", "false").lower() in ("1", "true", "yes") +_mock_codes: dict[str, tuple[str, float]] = {} +_MOCK_TTL = 300 + + +# ── 签名体系(完全对应 sign.js)── +def _get_nonce() -> str: + # 与会会 sign.js 一致:base36 随机串 + return "".join(random.choices(string.ascii_lowercase + string.digits, k=12)) + + +def _get_timestamp() -> str: + # 与会会 sign.js 一致:yyyyMMddHHmmss(24 小时制大写 HH)。 + # 实测:会会 common.format 走 24 小时制,用 12 小时制(%I)会被网关判"签名验证失败"。 + # 必须用北京时间,否则容器(UTC)生成的时间戳与会会网关校验窗口偏差 8h 被拒。 + return datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S") + + +def _make_sign(params: dict, secret_key: str, sign_type: str = "MD5") -> str: + SIGN_KEY = "signature" + SECRET_KEY = "accessSecret" + keys = sorted(params.keys()) + parts = [] + for k in keys: + if k in (SIGN_KEY, SECRET_KEY): + continue + v = params.get(k) + if v is None or v == "" or v == []: + continue + if isinstance(v, list): + continue + parts.append(f"{k}={v}") + sign_str = "&".join(parts) + f"&{SECRET_KEY}={secret_key}" + if sign_type.upper() == "SHA256": + return hashlib.sha256(sign_str.encode("utf-8")).hexdigest().upper() + return hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper() + + +def _build_form(extra: dict) -> dict: + sign_type = "MD5" + sign_version = "1.0" + secret = ACCESS_SECRET + base = { + "appId": APP_ID, + "accessId": ACCESS_ID, + "timestamp": _get_timestamp(), + "signType": sign_type, + "signVersion": sign_version, + "accessSecret": secret, + "nonce": _get_nonce(), + } + base.update(extra) + signature = _make_sign(base, secret, sign_type) if secret else "" + base["signature"] = signature + base.pop("accessSecret", None) # 不发送密钥 + return base + + +def _pick(d: dict, *keys, default=""): + for k in keys: + if d.get(k) not in (None, ""): + return d[k] + return default + + +def _cfg_ready() -> bool: + return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET) + + +def _call_huihui(path: str, params: dict, as_query: bool = False): + """调用会会接口,返回 (ok: bool, payload: dict, http_status: int)""" + url = f"{AUTH_BASE_URL}{path}" + with httpx.Client(timeout=30, follow_redirects=True) as c: + if as_query: + resp = c.post(url, params=params) + else: + resp = c.post(url, data=params) + try: + data = resp.json() + except Exception: + return False, {"message": f"会会返回非JSON: {resp.text[:200]}"}, resp.status_code + # 会会统一包装 {code, message, data} + code = data.get("code") + if resp.status_code == 200 and code in (0, 200, "0", "200"): + return True, data, resp.status_code + return False, data, resp.status_code + + +@router.post("/huihui/sms/send") +def sms_send(body: dict = Body(...)): + """请求会会发送短信验证码(真实开放平台 /open/mobile/sms/code)""" + phone = (body.get("phone") or "").strip() + if not phone or not phone.isdigit() or len(phone) != 11: + return fail("请输入正确的 11 位手机号", 400) + + # 临时开发态:本地模拟发码 + if DEV_MOCK and not _cfg_ready(): + code = str(random.randint(100000, 999999)) + _mock_codes[phone] = (code, datetime.now().timestamp() + _MOCK_TTL) + return ok({"sent": True, "devCode": code, "dev": True}) + + if not _cfg_ready(): + return fail("会会短信服务未配置(缺少 HUIHUI_APP_ID / HUIHUI_ACCESS_ID / HUIHUI_ACCESS_SECRET)", 500) + + # /open/mobile/sms/code:mobile 与公共字段均走 query + form = _build_form({"mobile": phone}) + ok_flag, data, status = _call_huihui(SMS_SEND_PATH, form, as_query=True) + if not ok_flag: + return fail(data.get("message") or f"发送失败(HTTP {status})", 502) + return ok({"sent": True}) + + +@router.post("/huihui/sms/login") +def sms_login(body: dict = Body(...), db: Session = Depends(get_db)): + """短信验证码登录:调会会 /open/login/token(loginType=code) 换取 access_token + userId,落库并签发本系统会话""" + phone = (body.get("phone") or "").strip() + code = (body.get("code") or "").strip() + if not phone or not code: + return fail("手机号或验证码缺失", 400) + + # 临时开发态:本地校验模拟码 + if DEV_MOCK and not _cfg_ready(): + rec = _mock_codes.get(phone) + if not rec or rec[1] < datetime.now().timestamp(): + return fail("验证码已失效,请重新获取", 401) + if rec[0] != code: + return fail("验证码错误", 401) + _mock_codes.pop(phone, None) + return _issue_session(db, phone, { + "userId": f"dev_{phone}", + "nickname": f"会会用户{phone[-4:]}", + "avatarUrl": "", + "token": f"dev_token_{phone}", + }) + + if not _cfg_ready(): + return fail("会会登录服务未配置", 500) + + extra = { + "username": phone, + "password": code, + "loginType": "code", + "grantType": "password", + "isRegister": "true", + } + if CLIENT_CODE: + extra["clientCode"] = CLIENT_CODE + form = _build_form(extra) + ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False) + if not ok_flag: + return fail(data.get("message") or f"登录失败(HTTP {status})", 401) + + raw = data.get("data") or {} + user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {} + huihui_token = ( + _pick(raw, "accessToken", "access_token", "token") + or _pick(user_info, "accessToken", "access_token", "token") + ) + huihui_user_id = ( + _pick(raw, "openid", "userId", "uid", "openId", "id") + or _pick(user_info, "openid", "userId", "uid", "openId", "id") + ) + nickname = ( + _pick(raw, "nickName", "nickname", "name", "userName") + or _pick(user_info, "nickName", "nickname", "name", "userName") + ) + avatar_url = ( + _pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl") + or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl") + ) + if not huihui_user_id: + return fail("会会未返回用户标识", 502) + + return _issue_session(db, phone, { + "userId": huihui_user_id, + "nickname": nickname, + "avatarUrl": avatar_url, + "token": huihui_token, + }) + + +@router.post("/huihui/pwd/login") +def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)): + """账号密码登录:调会会 /open/login/token(loginType=password) 换取 access_token + userId,落库并签发本系统会话。 + 与 news_service 既有对接完全一致:username=账号/手机号, password=密码, loginType=password, grantType=password, isRegister=false。 + """ + account = (body.get("account") or "").strip() + password = (body.get("password") or "").strip() + if not account or not password: + return fail("账号或密码缺失", 400) + + if not _cfg_ready(): + return fail("会会登录服务未配置", 500) + + extra = { + "username": account, + "password": password, + "loginType": "password", + "grantType": "password", + "isRegister": "false", + } + if CLIENT_CODE: + extra["clientCode"] = CLIENT_CODE + form = _build_form(extra) + ok_flag, data, status = _call_huihui(SMS_LOGIN_PATH, form, as_query=False) + if not ok_flag: + return fail(data.get("message") or f"登录失败(HTTP {status})", 401) + + raw = data.get("data") or {} + user_info = raw.get("userInfo", {}) if isinstance(raw, dict) else {} + huihui_token = ( + _pick(raw, "accessToken", "access_token", "token") + or _pick(user_info, "accessToken", "access_token", "token") + ) + huihui_user_id = ( + _pick(raw, "openid", "userId", "uid", "openId", "id") + or _pick(user_info, "openid", "userId", "uid", "openId", "id") + ) + nickname = ( + _pick(raw, "nickName", "nickname", "name", "userName") + or _pick(user_info, "nickName", "nickname", "name", "userName") + ) + avatar_url = ( + _pick(raw, "avatar", "avatarUrl", "headImgUrl", "headimgurl") + or _pick(user_info, "avatar", "avatarUrl", "headImgUrl", "headimgurl") + ) + if not huihui_user_id: + return fail("会会未返回用户标识", 502) + + # 账号即手机号时记录,便于资料展示;非手机号(用户名)则不覆盖已有 phone + phone = account if (account.isdigit() and len(account) == 11) else "" + return _issue_session(db, phone, { + "userId": huihui_user_id, + "nickname": nickname, + "avatarUrl": avatar_url, + "token": huihui_token, + }) + + +def _issue_session(db: Session, phone: str, info: dict): + """建/链本地用户并签发本系统会话 token""" + huihui_user_id = info.get("userId", "") + user = db.query(User).filter(User.huihui_user_id == huihui_user_id).first() + if not user: + user = User(huihui_user_id=huihui_user_id) + if phone: + user.phone = phone + if info.get("nickname"): + user.nickname = info["nickname"] + if info.get("avatarUrl"): + user.avatar_url = info["avatarUrl"] + user.huihui_token = info.get("token", "") + user.app_token = uuid.uuid4().hex + user.last_login_at = datetime.now() + db.add(user) + db.commit() + db.refresh(user) + + return ok({ + "token": user.app_token, + "user": user.to_dict(), + "huihui": { + "userId": huihui_user_id, + "nickname": info.get("nickname", ""), + "avatarUrl": info.get("avatarUrl", ""), + "token": info.get("token", ""), + }, + }) + + +@router.get("/huihui/me") +def me(authorization: str = Header(None), db: Session = Depends(get_db)): + """当前登录用户信息(Bearer app_token)""" + if not authorization: + return fail("未登录", 401) + token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip() + user = db.query(User).filter(User.app_token == token).first() + if not user: + return fail("会话无效或已过期", 401) + return ok(user.to_dict()) + + +@router.post("/huihui/logout") +def logout(authorization: str = Header(None), db: Session = Depends(get_db)): + """退出登录(作废 app_token)""" + if authorization: + token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip() + user = db.query(User).filter(User.app_token == token).first() + if user: + user.app_token = "" + db.commit() + return ok({"success": True}) diff --git a/digital-avatar-app/backend/routers/organizations.py b/digital-avatar-app/backend/routers/organizations.py new file mode 100644 index 0000000..adac7a0 --- /dev/null +++ b/digital-avatar-app/backend/routers/organizations.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, Depends, Body +from sqlalchemy.orm import Session + +from database import get_db +from models import Organization +from responses import ok, fail + +router = APIRouter(tags=["组织"]) + + +@router.get("/organizations") +def list_orgs(page: int = 1, limit: int = 20, db: Session = Depends(get_db)): + q = db.query(Organization) + total = q.count() + items = ( + q.order_by(Organization.created_at.desc()) + .offset((page - 1) * limit) + .limit(limit) + .all() + ) + return ok({"data": [o.to_dict() for o in items], "total": total}) + + +@router.post("/organizations") +def create_org(payload: dict = Body(...), db: Session = Depends(get_db)): + o = Organization( + name=payload.get("name", "未命名组织"), + description=payload.get("desc", "") or payload.get("description", ""), + emoji=payload.get("emoji", "🏢"), + org_type=payload.get("type", "") or payload.get("orgType", "team"), + ) + db.add(o) + db.commit() + db.refresh(o) + return ok(o.to_dict()) diff --git a/digital-avatar-app/backend/routers/tokens.py b/digital-avatar-app/backend/routers/tokens.py new file mode 100644 index 0000000..18871e8 --- /dev/null +++ b/digital-avatar-app/backend/routers/tokens.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends, Body +from sqlalchemy.orm import Session + +from database import get_db +from models import TokenAccount, TokenPlan +from responses import ok, fail + +router = APIRouter(tags=["Token"]) + + +@router.get("/token/balance") +def balance(db: Session = Depends(get_db)): + acc = db.query(TokenAccount).first() + return ok({"balance": acc.balance if acc else 0}) + + +@router.get("/token/plans") +def plans(db: Session = Depends(get_db)): + items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all() + return ok([p.to_dict() for p in items]) + + +@router.post("/token/charge") +def charge(payload: dict = Body(...), db: Session = Depends(get_db)): + plan_id = payload.get("planId") + plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first() + if not plan: + return fail("套餐不存在", 404) + acc = db.query(TokenAccount).first() + if not acc: + acc = TokenAccount(balance=0) + db.add(acc) + db.commit() + db.refresh(acc) + acc.balance += plan.amount + db.commit() + return ok({"balance": acc.balance, "charged": plan.amount}) diff --git a/digital-avatar-app/docker-compose.yml b/digital-avatar-app/docker-compose.yml new file mode 100644 index 0000000..ac7ebab --- /dev/null +++ b/digital-avatar-app/docker-compose.yml @@ -0,0 +1,31 @@ +# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署) +services: + avatar-backend: + build: ./backend + image: avatar-test-backend:latest + container_name: avatar-test-backend + restart: unless-stopped + env_file: + - .env + expose: + - "8000" + ports: + - "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口 + networks: + - avatar-net + + avatar-frontend: + build: . + image: avatar-test-frontend:latest + container_name: avatar-test-frontend + restart: unless-stopped + ports: + - "8099:80" # 浏览器访问 http://:8099 + depends_on: + - avatar-backend + networks: + - avatar-net + +networks: + avatar-net: + driver: bridge diff --git a/digital-avatar-app/index.html b/digital-avatar-app/index.html new file mode 100644 index 0000000..a9ce14e --- /dev/null +++ b/digital-avatar-app/index.html @@ -0,0 +1,22 @@ + + + + + + 会会数字分身 + + + + + + +
+ + + diff --git a/digital-avatar-app/knowledge-samples/牙齿防护知识手册.md b/digital-avatar-app/knowledge-samples/牙齿防护知识手册.md new file mode 100644 index 0000000..2198597 --- /dev/null +++ b/digital-avatar-app/knowledge-samples/牙齿防护知识手册.md @@ -0,0 +1,50 @@ +# 牙齿防护知识手册 + +> 本手册用于「会会」数字分身知识库,覆盖日常护牙、牙线使用、饮食护牙、定期检查、常见口腔问题及儿童口腔保健,供分身专业应答引用。 + +## 一、日常刷牙 + +- **频率与时机**:每天早晚各刷一次,睡前刷牙尤为重要;饭后建议漱口,进食酸性食物后等待约 30 分钟再刷牙,避免即刻磨损被酸软化的牙釉质。 +- **巴氏刷牙法(Bass)**:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤 10 次左右,再向牙冠方向拂刷;依次覆盖牙齿外侧、内侧与咬合面。 +- **时长**:每次刷牙至少 2 分钟。 +- **工具**:选用软毛、小头牙刷;刷毛外翻或每 3 个月更换一次。 +- **牙膏**:推荐使用含氟牙膏,氟可增强牙釉质抗酸与再矿化能力。 + +## 二、牙线 / 牙缝清洁 + +- 每天至少使用一次牙线,清洁牙刷难以到达的牙齿邻面。 +- 取约 45cm 牙线,以 C 形包绕牙面,上下刮擦清除菌斑。 +- 牙缝较大或牙周炎人群,可配合使用牙间刷(间隙刷)。 + +## 三、饮食与护牙 + +- 控制游离糖摄入,少喝含糖饮料、少吃黏性甜食——糖是致龋的主要元凶。 +- 酸性饮料(碳酸饮料、果汁)会侵蚀牙釉质,建议用吸管饮用并尽快漱口,勿立即刷牙。 +- 多喝水,唾液具有自我清洁与再矿化作用。 +- 适量补充钙、磷、维生素 D(奶类、豆制品、深绿色蔬菜)。 + +## 四、定期检查与洁治 + +- 建议每 6 个月进行一次口腔检查与洁牙(洗牙)。 +- 洗牙清除牙石与菌斑,预防牙龈炎、牙周炎;**洗牙不会让牙缝变大**(牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成)。 +- 出现龋齿、牙龈出血、口臭、牙齿敏感应及时就医,越早处理越简单。 + +## 五、常见口腔问题 + +- **龋齿(蛀牙)**:浅龋可涂氟干预,但已形成龋洞必须充填,无法自愈。 +- **牙周病**:表现为牙龈红肿、出血、口臭、牙齿松动;基础治疗为洁治 + 龈下刮治,需长期维护。 +- **牙齿敏感**:遇冷热酸甜刺激痛,可用抗敏感牙膏;若持续加重需就诊排查楔状缺损或牙龈退缩。 +- **智齿**:阻生或反复发炎的智齿建议拔除。 + +## 六、儿童口腔保健 + +- 第一颗乳牙萌出(约 6 月龄)即开始清洁,可用纱布或指套牙刷。 +- **含氟牙膏用量**:3 岁以下用米粒大小,3–6 岁用豌豆大小,均需在家长监督下使用并防止误吞。 +- **窝沟封闭**:6–7 岁六龄齿萌出后做窝沟封闭,有效预防窝沟龋。 +- 定期涂氟,每半年一次口腔检查。 + +## 七、常见误区澄清 + +- 误区「洗牙伤牙」:事实为规范洗牙安全,不损伤牙釉质。 +- 误区「牙龈出血就不能刷」:事实为出血多因炎症,更应保持清洁并尽早就医。 +- 误区「乳牙坏了不用管」:事实为乳牙健康直接影响恒牙与颌骨发育。 diff --git a/digital-avatar-app/knowledge-samples/牙齿防护问答对.json b/digital-avatar-app/knowledge-samples/牙齿防护问答对.json new file mode 100644 index 0000000..45c2fb7 --- /dev/null +++ b/digital-avatar-app/knowledge-samples/牙齿防护问答对.json @@ -0,0 +1,62 @@ +[ + { + "question": "每天应该刷牙几次?每次刷多久?", + "answer": "建议每天早晚各刷一次,每次至少 2 分钟,并使用含氟牙膏;睡前那次尤为重要。", + "enabled": true + }, + { + "question": "正确的刷牙方法是什么?", + "answer": "推荐巴氏刷牙法:刷毛与牙面呈 45° 指向牙龈沟,小幅度水平震颤约 10 次后再向牙冠方向拂刷,依次清洁牙齿外侧、内侧和咬合面。", + "enabled": true + }, + { + "question": "电动牙刷比手动牙刷更好吗?", + "answer": "两者清洁效果相当,关键在于正确刷牙方法;电动牙刷更易保证刷牙时长,对刷牙不到位的人更友好。", + "enabled": true + }, + { + "question": "刷牙总出血是怎么回事?", + "answer": "多为牙龈炎或牙周炎所致,建议尽快洗牙并就医检查,同时坚持正确刷牙和使用牙线,切勿因出血而停止刷牙。", + "enabled": true + }, + { + "question": "牙线必须每天用吗?怎么用?", + "answer": "建议每天至少使用一次牙线清洁牙齿邻面。取约 45cm 牙线,以 C 形包绕牙面上下刮擦;牙缝较大者可配合牙间刷。", + "enabled": true + }, + { + "question": "蛀牙(龋齿)能自己好吗?", + "answer": "浅龋可通过涂氟减缓进展,但已形成龋洞必须补牙,无法自愈;越早处理越简单、损伤越小。", + "enabled": true + }, + { + "question": "洗牙会让牙缝变大、伤牙齿吗?", + "answer": "规范的洗牙安全且不损伤牙釉质。洗牙清除的是牙石,牙缝变大常见于牙周炎后牙龈消肿,属病情暴露而非洗牙造成。", + "enabled": true + }, + { + "question": "牙齿遇冷热酸甜就酸痛,怎么办?", + "answer": "属牙齿敏感,可先使用抗敏感牙膏;若持续加重,需就诊排查楔状缺损或牙龈退缩等成因,再对症处理。", + "enabled": true + }, + { + "question": "孩子几岁开始用含氟牙膏?用多少?", + "answer": "第一颗乳牙萌出后即可在家长帮助下刷牙;3 岁以下用米粒大小含氟牙膏,3–6 岁用豌豆大小,均需监督防误吞。", + "enabled": true + }, + { + "question": "儿童六龄齿需要做窝沟封闭吗?", + "answer": "建议 6–7 岁六龄齿萌出后做窝沟封闭,并定期涂氟、每半年口腔检查,可有效预防窝沟龋,保护恒牙。", + "enabled": true + }, + { + "question": "喝碳酸饮料会伤牙吗?怎么喝更好?", + "answer": "碳酸饮料酸性强,会侵蚀牙釉质。建议用吸管减少接触、喝完尽快清水漱口,不要立即刷牙,并控制频次。", + "enabled": true + }, + { + "question": "多久做一次口腔检查和洗牙?", + "answer": "建议每 6 个月进行一次口腔检查与洁牙,以便早期发现龋齿、牙周问题并及时干预。", + "enabled": true + } +] diff --git a/digital-avatar-app/nginx.conf b/digital-avatar-app/nginx.conf new file mode 100644 index 0000000..d8dc14d --- /dev/null +++ b/digital-avatar-app/nginx.conf @@ -0,0 +1,38 @@ +# 完整主配置:覆盖 nginx:alpine 默认 /etc/nginx/nginx.conf +# 新版 nginx 在受限容器内写 /run/nginx.pid 会报 Operation not permitted 并致命退出, +# 这里把 pid 显式改到可写的 /tmp(main 上下文唯一一处),避免前端容器反复重启。 +pid /dev/null; +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA 兜底(hash 路由下深链接也可正常加载) + location / { + try_files $uri $uri/ /index.html; + } + + # 后端 API:保留 /api 前缀转发到 avatar-backend:8000 + location /api/ { + proxy_pass http://avatar-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/digital-avatar-app/package-lock.json b/digital-avatar-app/package-lock.json new file mode 100644 index 0000000..f47d34e --- /dev/null +++ b/digital-avatar-app/package-lock.json @@ -0,0 +1,1796 @@ +{ + "name": "digital-avatar-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "digital-avatar-app", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.0", + "pinia": "^2.1.0", + "vue": "^3.3.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^1.8.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + } + } +} diff --git a/digital-avatar-app/package.json b/digital-avatar-app/package.json new file mode 100644 index 0000000..fb295b1 --- /dev/null +++ b/digital-avatar-app/package.json @@ -0,0 +1,22 @@ +{ + "name": "digital-avatar-app", + "version": "1.0.0", + "description": "会会数字分身 Web App", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.3.0", + "vue-router": "^4.2.0", + "pinia": "^2.1.0", + "axios": "^1.6.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^1.8.0" + } +} diff --git a/digital-avatar-app/src/App.vue b/digital-avatar-app/src/App.vue new file mode 100644 index 0000000..5a2797d --- /dev/null +++ b/digital-avatar-app/src/App.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/digital-avatar-app/src/main.ts b/digital-avatar-app/src/main.ts new file mode 100644 index 0000000..ff22382 --- /dev/null +++ b/digital-avatar-app/src/main.ts @@ -0,0 +1,55 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' +import pinia from './store' +import { getLaunchParams, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge' +import { useAvatarStore } from '@/store/avatar' +import { useUserStore } from '@/store/user' +import { setAuthToken } from '@/api' + +const app = createApp(App) +app.use(router) +app.use(pinia) + +// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 —— +const params = getLaunchParams() +const avatarStore = useAvatarStore(pinia) +const userStore = useUserStore(pinia) + +// 恢复本地短信登录会话(会会 userId ↔ 本系统用户) +userStore.loadFromStorage() +if (userStore.isLogin && userStore.user) { + setAuthToken(userStore.token) + avatarStore.setNativeProfile({ + userId: (userStore.user as any).huihuiUserId || '', + nickname: userStore.user.nickname || '', + avatarUrl: userStore.user.avatarUrl || '' + }) +} + +if (params.token) { + setAuthToken(params.token) +} +if (params.userId || params.nickname || params.avatar) { + avatarStore.setNativeProfile({ + userId: params.userId || '', + nickname: params.nickname || '', + avatarUrl: params.avatar || '' + }) +} + +// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用) +onNativeMessage((msg) => { + if (!msg || !msg.type) return + if (msg.type === 'tokenRefresh' && msg.token) { + setAuthToken(msg.token) + } + if (msg.type === 'userUpdate' && msg.user) { + avatarStore.setNativeProfile(msg.user) + } +}) + +app.mount('#app') + +// 通知原生壳:H5 已就绪 +UniEvents.ready() diff --git a/digital-avatar-app/src/store/avatar.ts b/digital-avatar-app/src/store/avatar.ts new file mode 100644 index 0000000..0af7f4f --- /dev/null +++ b/digital-avatar-app/src/store/avatar.ts @@ -0,0 +1,95 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getUserProfile } from '@/api' +import { unwrapListData } from '@/utils/avatar-page-data' + +export const useAvatarStore = defineStore('avatar', () => { + // 已创建的分身列表(来自后端) + const avatars = ref([]) + // 全局 Token 余额(来自后端) + const tokenBalance = ref(0) + // 当前选中分身 id + const currentAvatarId = ref(null) + // 会会用户资料(头像/昵称,来自会会接口) + const userProfile = ref(null) + + // 拉取分身列表 + const loadAvatars = async () => { + try { + const res = await getAvatarList() + avatars.value = unwrapListData(res) + if (avatars.value.length) currentAvatarId.value = avatars.value[0].id + } catch (e) { + console.error('加载分身失败', e) + } + } + + // 拉取 Token 余额 + const loadTokenBalance = async () => { + try { + const res = await getTokenBalance() + tokenBalance.value = (res as any)?.balance ?? 0 + } catch (e) { + console.error('加载余额失败', e) + } + } + + // 拉取会会用户资料(头像/昵称) + const loadUserProfile = async () => { + // 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock + if (userProfile.value && (userProfile.value as any).__native) return + try { + const res = await getUserProfile() + userProfile.value = res as any + } catch (e) { + console.error('加载用户资料失败', e) + } + } + + // 由 uniapp 壳注入的会会资料(混合架构),标记为原生来源 + const setNativeProfile = (profile: { userId?: string; nickname?: string; avatarUrl?: string }) => { + userProfile.value = { + userId: profile.userId || '', + nickname: profile.nickname || '', + avatarUrl: profile.avatarUrl || '', + __native: true + } + } + + // 创建分身(写入后端) + const addAvatar = async (avatar: any) => { + const res: any = await apiCreate(avatar) + const created = res?.data ? res.data : res + avatars.value.unshift(created) + currentAvatarId.value = created.id + return created + } + + // 删除分身(写入后端,级联清理关联数据) + const removeAvatar = async (id: string) => { + await apiDelete(id) + avatars.value = avatars.value.filter((a) => a.id !== id) + if (currentAvatarId.value === id) { + currentAvatarId.value = avatars.value[0]?.id || null + } + } + + // 同步余额(充值后) + const setTokenBalance = (balance: number) => { + tokenBalance.value = balance + } + + return { + avatars, + tokenBalance, + currentAvatarId, + userProfile, + loadAvatars, + loadTokenBalance, + loadUserProfile, + setNativeProfile, + addAvatar, + removeAvatar, + setTokenBalance + } +}) diff --git a/digital-avatar-app/src/store/index.ts b/digital-avatar-app/src/store/index.ts new file mode 100644 index 0000000..8a05980 --- /dev/null +++ b/digital-avatar-app/src/store/index.ts @@ -0,0 +1,5 @@ +import { createPinia } from 'pinia' + +const pinia = createPinia() + +export default pinia diff --git a/digital-avatar-app/src/store/user.ts b/digital-avatar-app/src/store/user.ts new file mode 100644 index 0000000..a1786c4 --- /dev/null +++ b/digital-avatar-app/src/store/user.ts @@ -0,0 +1,82 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { setAuthToken, sendSmsCode, loginBySms, loginByPassword, logoutUser, type UserProfile } from '@/api' + +const TOKEN_KEY = 'hh_app_token' +const USER_KEY = 'hh_app_user' + +// 会会短信登录的本地会话(打通 会会 userId ↔ 本系统用户) +export const useUserStore = defineStore('smsuser', () => { + const token = ref('') + const user = ref<(UserProfile & { huihuiUserId?: string; phone?: string }) | null>(null) + const isLogin = ref(false) + + // 启动时从本地恢复会话 + const loadFromStorage = () => { + const t = localStorage.getItem(TOKEN_KEY) + const u = localStorage.getItem(USER_KEY) + if (t && u) { + try { + token.value = t + user.value = JSON.parse(u) + isLogin.value = true + setAuthToken(t) + } catch { + clearLocal() + } + } + } + + const persist = () => { + localStorage.setItem(TOKEN_KEY, token.value) + localStorage.setItem(USER_KEY, JSON.stringify(user.value)) + } + + const clearLocal = () => { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(USER_KEY) + } + + // 发送验证码(返回结果,演示模式含 devCode) + const sendCode = async (phone: string) => { + return await sendSmsCode(phone) + } + + // 短信登录 + const login = async (phone: string, code: string) => { + const res: any = await loginBySms(phone, code) + token.value = res.token + user.value = { ...(res.user || {}), ...(res.huihui || {}) } + isLogin.value = true + setAuthToken(res.token) + persist() + return res + } + + // 账号密码登录 + const loginByPwd = async (account: string, password: string) => { + const res: any = await loginByPassword(account, password) + token.value = res.token + user.value = { ...(res.user || {}), ...(res.huihui || {}) } + isLogin.value = true + setAuthToken(res.token) + persist() + return res + } + + // 退出 + const logout = async () => { + try { + await logoutUser() + } catch { + /* 忽略网络错误,本地清除即可 */ + } + token.value = '' + user.value = null + isLogin.value = false + setAuthToken(null) + clearLocal() + } + + return { token, user, isLogin, loadFromStorage, sendCode, login, loginByPwd, logout } +}) diff --git a/digital-avatar-app/src/styles/global.css b/digital-avatar-app/src/styles/global.css new file mode 100644 index 0000000..5690cc0 --- /dev/null +++ b/digital-avatar-app/src/styles/global.css @@ -0,0 +1,58 @@ +/* 全局样式 - 会会数字分身 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary-color: #F97316; + --primary-light: #FFF0E6; + --text-primary: #18191C; + --text-secondary: #9398AE; + --bg-primary: #F8F9FA; + --bg-white: #FFFFFF; + --border-color: #EDEEF1; + --success-color: #22C55E; + --warning-color: #F59E0B; +} + +body { + font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #D1D5DB; + border-radius: 3px; +} + +/* 通用工具类 */ +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +.flex-between { + display: flex; + align-items: center; + justify-content: space-between; +} + +.text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/digital-avatar-app/src/utils/uniapp-bridge.ts b/digital-avatar-app/src/utils/uniapp-bridge.ts new file mode 100644 index 0000000..5ec2b56 --- /dev/null +++ b/digital-avatar-app/src/utils/uniapp-bridge.ts @@ -0,0 +1,65 @@ +// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具 +// 协议详见 uniapp-avatar/README.md +// +// 引入方式:在 index.html 中加载 uniapp web-view bridge: +// +// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。 + +const BRIDGE_HANDLER = '__uniBridgeHandle__' + +export interface UniLaunchParams { + token?: string + userId?: string + nickname?: string + avatar?: string + ts?: string +} + +// 是否运行在 uniapp web-view 环境中 +export function isInUniWebView(): boolean { + return !!(window as any).uni?.webView +} + +// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户) +export function getLaunchParams(): UniLaunchParams { + const sp = new URLSearchParams(window.location.search) + const params: UniLaunchParams = {} + const token = sp.get('token') + const userId = sp.get('userId') + const nickname = sp.get('nickname') + const avatar = sp.get('avatar') + const ts = sp.get('ts') + if (token) params.token = token + if (userId) params.userId = userId + if (nickname) params.nickname = decodeURIComponent(nickname) + if (avatar) params.avatar = decodeURIComponent(avatar) + if (ts) params.ts = ts + return params +} + +// H5 → 原生:发送事件(需引入 uniapp web-view bridge) +export function postToNative(message: Record): boolean { + if (!isInUniWebView()) return false + ;(window as any).uni.webView.postMessage({ data: message }) + return true +} + +// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__) +export function onNativeMessage(handler: (message: any) => void): void { + ;(window as any)[BRIDGE_HANDLER] = (message: any) => { + try { + handler(message) + } catch (e) { + console.error('[uniBridge] handler error', e) + } + } +} + +// 便捷事件 +export const UniEvents = { + ready: () => postToNative({ type: 'ready' }), + needLogin: () => postToNative({ type: 'needLogin' }), + setTitle: (title: string) => postToNative({ type: 'setTitle', title }), + navigate: (path: string) => postToNative({ type: 'navigate', path }), + back: () => postToNative({ type: 'back' }) +} diff --git a/digital-avatar-app/src/views/AuthorizationManage.vue b/digital-avatar-app/src/views/AuthorizationManage.vue new file mode 100644 index 0000000..d805da6 --- /dev/null +++ b/digital-avatar-app/src/views/AuthorizationManage.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/digital-avatar-app/src/views/AvatarCard.vue b/digital-avatar-app/src/views/AvatarCard.vue new file mode 100644 index 0000000..8e85080 --- /dev/null +++ b/digital-avatar-app/src/views/AvatarCard.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/digital-avatar-app/src/views/AvatarContacts.vue b/digital-avatar-app/src/views/AvatarContacts.vue new file mode 100644 index 0000000..3c88581 --- /dev/null +++ b/digital-avatar-app/src/views/AvatarContacts.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/digital-avatar-app/src/views/AvatarCreate.vue b/digital-avatar-app/src/views/AvatarCreate.vue new file mode 100644 index 0000000..10a308f --- /dev/null +++ b/digital-avatar-app/src/views/AvatarCreate.vue @@ -0,0 +1,492 @@ + + + + + diff --git a/digital-avatar-app/src/views/CreateOrg.vue b/digital-avatar-app/src/views/CreateOrg.vue new file mode 100644 index 0000000..736e041 --- /dev/null +++ b/digital-avatar-app/src/views/CreateOrg.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/digital-avatar-app/src/views/MyProjects.vue b/digital-avatar-app/src/views/MyProjects.vue new file mode 100644 index 0000000..f9d11b5 --- /dev/null +++ b/digital-avatar-app/src/views/MyProjects.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/digital-avatar-app/src/views/QaPairEdit.vue b/digital-avatar-app/src/views/QaPairEdit.vue new file mode 100644 index 0000000..f9252d3 --- /dev/null +++ b/digital-avatar-app/src/views/QaPairEdit.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/digital-avatar-app/src/views/SmsLogin.vue b/digital-avatar-app/src/views/SmsLogin.vue new file mode 100644 index 0000000..d5f7326 --- /dev/null +++ b/digital-avatar-app/src/views/SmsLogin.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/digital-avatar-app/src/views/TokenCharge.vue b/digital-avatar-app/src/views/TokenCharge.vue new file mode 100644 index 0000000..f621b5c --- /dev/null +++ b/digital-avatar-app/src/views/TokenCharge.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/digital-avatar-app/tsconfig.json b/digital-avatar-app/tsconfig.json new file mode 100644 index 0000000..07648ea --- /dev/null +++ b/digital-avatar-app/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["node_modules", "dist"] +} diff --git a/digital-avatar-app/vite.config.ts b/digital-avatar-app/vite.config.ts new file mode 100644 index 0000000..b5a5257 --- /dev/null +++ b/digital-avatar-app/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +// 后端 API 代理目标(仅 dev / vite 代理生效,生产构建走 nginx 或 __APP_CONFIG__.apiBase) +// 默认指向已部署的远端后端 192.168.1.188:8011; +// 若在本机另起后端,可在启动前覆盖:export VITE_API_PROXY_TARGET=http://localhost:8000 +const API_PROXY_TARGET = process.env.VITE_API_PROXY_TARGET || 'http://192.168.1.188:8011' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + proxy: { + '/api': { + target: API_PROXY_TARGET, + changeOrigin: true + } + } + } +}) diff --git a/digital-avatar-app/工作总结-2026-07-08.md b/digital-avatar-app/工作总结-2026-07-08.md new file mode 100644 index 0000000..46bfe14 --- /dev/null +++ b/digital-avatar-app/工作总结-2026-07-08.md @@ -0,0 +1,96 @@ +# 数字分身管理系统 · 今日工作总结 + +**日期**:2026-07-08 +**负责人**:Senior Developer(高级开发工程师) +**项目目录**:`digital-avatar-app` +**技术栈**:Vue3 + Vite + TypeScript + Pinia + Vue Router / FastAPI + SQLAlchemy + SQLite + +--- + +## 一、项目背景与目标 + +- **产品线**:「会会」(HuiHui)AI 个人数字分身(云 SaaS 版) +- **需求来源**:《AI 个人数字分身系统研发需求文档(PRD)》+ Ardot 设计稿(9 个页面) +- **部署形态**:uni-app 封装为 App / 微信小程序 / H5(当前阶段先实现 Web 版,验证业务闭环) +- **核心目标**:打通「创建分身 → 管理分身 → 工具/授权/充值」的完整用户路径,并完成前后端真实数据贯通 + +--- + +## 二、今日完成的工作 + +### 1. 修复页面空白 & 首页改造 +- 定位并修复页面空白三大根因:Pinia store 文件缺失、4 个视图文件为空(0 字节)、`main.ts` 导入了不存在的 store +- 将首页(`/`)改为「创建分身」引导页(渐变区 + 功能亮点卡片 + CTA) +- 首页「开始创建」CTA 接入 4 步向导(原为死 `alert`) + +### 2. 创建分身 4 步向导 +- 步骤:① 基础信息 → ② 形象风格(emoji + 回复风格)→ ③ 灵魂配置(创造力/严谨度/幽默感滑块 + 回复长度 + 系统提示词)→ ④ 确认创建 +- 数据流打通:向导提交 → `avatarStore.addAvatar()` → `router.push('/avatar/manage')` → 管理页展示刚创建的分身(头像用所选 emoji) +- 管理页右上角新增「+创建」入口,可随时再建 + +### 3. 分身工具 4 个子页面 +- 新增:**分身名片 / 分身人脉 / 我的项目 / 创建组织** 四个页面 +- 管理页「分身工具」区 4 个入口从 `console.log` 改为真实 `router.push` 跳转 +- 底部导航栏 3 入口(我的分身 / 授权管理 / Token),并在首页与编辑页自动隐藏,避免遮挡内容 + +### 4. FastAPI 后端搭建(核心交付) +- **统一响应包装** `{code, message, data}`(`responses.py` 提供 `ok/fail`),与前端拦截器解包对齐 +- **数据模型**:`Avatar / Authorization / Organization / TokenAccount / TokenPlan`,`to_dict()` 输出 camelCase 字段(displayName / photoUrl / createdAt …)匹配前端类型 +- **接口清单**(均挂 `/api` 前缀): + - `GET/POST /avatar`、`GET/PUT/DELETE /avatar/:id` + - `GET /token/balance`、`GET /token/plans`、`POST /token/charge` + - `GET/PUT /avatar/:id/authorizations` + - `GET/POST /organizations` +- **种子数据**:默认余额 1250、4 个充值套餐、1 个分身、3 条授权、2 个组织 +- 依赖装在隔离 venv:`/Users/yqq/.workbuddy/binaries/python/envs/default` + +### 5. 前后端接通(端到端跑通) +- `src/api/index.ts`:响应拦截器解包 `{code,message,data}`(code===200 返回 data);新增 `chargeToken`、`createOrganization` +- `src/store/avatar.ts`:改为异步 `loadAvatars() / loadTokenBalance() / addAvatar()` 调真实接口 +- `AvatarManage`:Token 余额 / 分身卡片来自后端 +- `AvatarCreate`:`submit` 改 async 调 `addAvatar`(含 submitting 态) +- `AuthorizationManage`:拉取 + 切换授权状态写后端 +- `TokenCharge`:`loadData()` 拉余额/套餐,`doCharge` 真实充值(余额回写) +- `CreateOrg`:`submit` 调 `createOrganization`(含 submitting 态) + +--- + +## 三、验证结果 + +| 验证项 | 结果 | +|--------|------| +| 后端接口 curl(含写操作) | 全部通过;充值 1250 → 2250 正确回写 | +| Vite 代理 `/api/* → 8000` | 正常 | +| 前端改动模块 Vite 转译 | 0 报错 | +| DB 状态 | 已重置为干净种子态(balance 1250,1 分身) | +| 生产构建(`vite build`) | 打包成功(主包 gzip 39KB / 1.43s) | + +> ⚠️ 注:`npm run build` 中的 `vue-tsc` 因旧版本与 Node 22 不兼容(patch TS 内部字符串失败)报错,属工具链问题,非代码错误;已用 `vite build` 跳过类型检查确认打包无误。 + +--- + +## 四、当前架构(见附图) + +端到端链路:**会会多端(App/小程序/H5)→ Vue3 SPA → Vite 代理 /api → FastAPI → SQLite** + +--- + +## 五、遗留 & 下一步 + +1. **分身名片 / 人脉 / 项目** 3 个子页仍用前端 mock,待接真实接口(如 `getOrganizationList` 等) +2. **编辑分身 / 删除分身** 接口待补全并接入 +3. **uni-app 封装**(App / 微信小程序 / H5)待启动,复用当前 Web 版组件 +4. **生产级类型检查**:升级 `vue-tsc` 或调整构建脚本,消除 Node 22 兼容告警 +5. **鉴权与多租户**:当前后端为单用户种子态,需补充用户登录与数据隔离 + +--- + +## 六、可复用模式(建议沉淀为 Skill) + +「Vue3 + Vite 前端 / FastAPI 后端」脚手架约定: +- Vite `server.proxy['/api'] → localhost:8000` +- 后端统一 `{code, message, data}` 响应 +- 前端响应拦截器解包(code===200 返回 data) +- SQLAlchemy 模型 `to_dict()` 输出 camelCase 字段,对齐前端 TS 类型 + +> 说明:本轮附带的 `ardot-design-core` / `ardot-design-router` 为 Ardot 画布操作型技能,依赖 Ardot MCP(当前环境未连接),故其设计规范以「橙色主题 / 卡片层次 / 渐进式向导路由」形式落地为 Vue 代码,未在画布上直接设计。 diff --git a/docs/production-interface-inventory.md b/docs/production-interface-inventory.md new file mode 100644 index 0000000..7692208 --- /dev/null +++ b/docs/production-interface-inventory.md @@ -0,0 +1,169 @@ +# 生产环境接口清单 + +更新时间:2026-06-23 + +本文档整理的是本项目当前代码里会主动调用的外部生产环境接口,以及当前运行环境通过系统配置接口读到的真实生产地址。 + +## 当前生产配置 + +- 认证服务基址:`https://99hui.com/api/usercenter` +- 新闻业务基址:`https://99hui.com/api/huihuibusiness` +- 文件上传基址:`https://99hui.com/api/filecenter` + 说明:代码通过把 `huihuibusiness` 替换成 `filecenter` 推导得到。 +- 当前默认 AI 模型: + - 模型名:`阿里千问大模型` + - 提供方:`qianwen` + - API Base URL:`https://dashscope.aliyuncs.com/compatible-mode/v1` + - 模型版本:`qwen-plus` + +## 认证服务 + +基址:`https://99hui.com/api/usercenter` + +### `POST /open/login/token` + +- 用途:虚拟用户账号密码登录,换取 `access_token` +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:149) + - [system.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/api/endpoints/system.py:86) + +### `PATCH /v2/users/current` + +- 用途:同步修改目标平台用户资料 +- 涉及字段:昵称、真实姓名、性别、头像、简介、邮箱 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:946) + +## 新闻业务服务 + +基址:`https://99hui.com/api/huihuibusiness` + +### `GET /app/user/info` + +- 用途:登录成功后探测用户所属组织 `orgId` +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:250) + +### `GET /open/user/info` + +- 用途:登录成功后探测用户所属组织 `orgId` +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:250) + +### `GET /user/info` + +- 用途:登录成功后探测用户所属组织 `orgId` +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:250) + +### `GET /news/list` + +- 用途:会话有效性校验 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:323) + +### `GET /business/square/list` + +- 用途: + - 统计今日文章数量 + - 拉取今日广场文章 + - 拉取历史广场文章 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:350) + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:483) + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:620) + +### `GET /news/{article_id}` + +- 用途:校验文章是否存在、正文是否足够长 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:396) + +### `PATCH /news/read/{news_id}` + +- 用途:模拟用户阅读新闻 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:711) + +### `POST /message/comment` + +- 用途:发表评论 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:734) + +### `GET /message/comment` + +- 用途:获取评论列表,为回复链路提供上下文 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:759) + +### `POST /message/comment/reply` + +- 用途:回复评论 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:747) + +### `POST /message/praise` + +- 用途: + - 点赞 + - 收藏 + 说明:当前收藏复用了点赞接口 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:771) + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:784) + +### `GET /news/share/wechat/{news_id}` + +- 用途:转发前的微信分享预热调用 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:796) + +### `POST /points/forward/news/{org_id}` + +- 用途:真正执行转发积分动作 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:802) + +### `DELETE /message/praise/cancel` + +- 用途:取消点赞 / 取消收藏 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:830) + +### `DELETE /message/comment/{news_id}/{comment_id}` + +- 用途:删除评论 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:865) + +## 文件服务 + +基址:`https://99hui.com/api/filecenter` + +### `POST /fileUpload` + +- 用途:上传用户头像到平台文件中心 +- 代码位置: + - [news_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/news_service.py:1002) + +## AI 模型服务 + +当前默认基址:`https://dashscope.aliyuncs.com/compatible-mode/v1` + +### `POST /chat/completions` + +- 用途: + - 生成人格 + - 生成评论 + - 生成回复 + - 测试模型连通性 +- 代码位置: + - [ai_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/ai_service.py:70) + - [ai_service.py](/Users/yqq/Works/AiProject/huihuiSquare/backend/app/services/ai_service.py:215) + +## 说明 + +- `resource.99hui.com` 当前主要出现在头像上传成功后的返回 URL 中,属于资源访问域名,不是本项目后端主动调用的 API。 +- AI 服务基址不是写死生产地址,而是读数据库里的模型配置;如果后台切换默认模型,这一节需要随之更新。 +- 当前系统配置里 `platform_org_id` 为空,这和“部分用户点立即互动时取不到文章列表”现象高度相关,建议后续继续排查组织信息自动发现链路。 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 86d7964..cb59c9a 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,6 +1,7 @@ server { listen 80; server_name _; + client_max_body_size 10m; root /usr/share/nginx/html; index index.html; diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index dc1f1e9..b9555aa 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -48,6 +48,7 @@ {{ currentTitle }}
+
{{ onlineUsers }} 在线 diff --git a/frontend/src/views/Interactions.vue b/frontend/src/views/Interactions.vue index 59a9081..57160a9 100644 --- a/frontend/src/views/Interactions.vue +++ b/frontend/src/views/Interactions.vue @@ -100,7 +100,7 @@ const filters = reactive({ keyword: '', interact_type: null, status: null, start const typeMap = { comment: '评论', reply: '回复', like: '点赞', collect: '收藏', forward: '转发' } const typeTagType = { comment: '', reply: 'info', like: 'success', collect: 'warning', forward: 'danger' } -const statusTagType = { 0: 'info', 1: 'success', 2: 'danger' } +const statusTagType = { 0: 'info', 1: 'success', 2: 'danger', 3: 'warning' } function onDateChange(v) { filters.start_date = v?.[0] || ''; filters.end_date = v?.[1] || '' @@ -171,7 +171,7 @@ async function handleCancel(row) { const res = await cancelInteraction(row.id) if (res.code === 200 || res.code === 0) { ElMessage.success('取消成功') - await loadInteractions() + await load() } else { ElMessage.error(res.message || '取消失败') } diff --git a/overview-avatar-default-2026-07-21.md b/overview-avatar-default-2026-07-21.md new file mode 100644 index 0000000..762a7db --- /dev/null +++ b/overview-avatar-default-2026-07-21.md @@ -0,0 +1,29 @@ +# 形象风格默认使用当前用户头像 + +## 已完成 +- 在创建数字分身的 **Step 2「形象风格」** 中,**「选择形象」默认选中当前用户的会会头像**。 +- 新增「使用我的头像」选项卡片;点击后沿用 `avatarUrl` 作为分身形象。 +- 表情形象选项仍可切换;切换为表情后自动清空 `photoUrl`,最终创建的分身不会误用用户头像。 + +## 关键改动 +- `digital-avatar-app/src/views/AvatarCreate.vue` + - 新增 `userAvatarUrl` 计算属性。 + - 新增 `usePhoto` 响应式状态;初始化时若用户有头像则默认启用。 + - Step 2 新增「使用我的头像」卡片 + 表情网格。 + - 确认页与提交按 `usePhoto` 决定是否使用 `photoUrl`。 + +## 构建与部署 +- 本地 `./node_modules/.bin/vite build` 通过(绕过已知的 vue-tsc 兼容问题)。 +- 部署路径:`dist/` → `geo-server:/root/avatar-test/run-dist/` → `docker cp avatar-test-frontend:/usr/share/nginx/html/`。 +- 测试环境地址:`http://192.168.1.188:8099`。 +- 已验证线上 `AvatarCreate-Bw_iuiqc.js` 包含新的 `usePhoto` 逻辑与"使用我的头像"文案,首页 HTTP 200。 + +## 设计稿 +- Ardot 设计稿「会会数字分身-形象风格(默认头像)」: + - 文件地址:https://ardot.tencent.com/file/706264845036089 + - 沿用 Glass Sunset Warm 色系(`#FFF6EC` 背景、`#FF8C42` 强调色、`#4A2511` 文字)。 + - 展示 Step 2 默认选中「使用我的头像」、表情备选网格、回复风格分段选择。 + +## 后续注意 +- 当前仅在 Web H5 实现;uniapp 壳内若从 native 注入 `avatarUrl`,`avatarStore.userProfile` 已优先覆盖,逻辑自动生效。 +- 表情选项和头像选项为互斥;用户可在创建流程中随时切换。 diff --git a/overview-avatar-user-isolation-2026-07-21.md b/overview-avatar-user-isolation-2026-07-21.md new file mode 100644 index 0000000..bbb6d3f --- /dev/null +++ b/overview-avatar-user-isolation-2026-07-21.md @@ -0,0 +1,75 @@ +# 会会数字分身 · 用户资料与分身隔离落地说明 + +## 问题 +用户登录成功后,发现页面显示"已有数字分身",且没有看到当前登录账号的头像/昵称;同时希望在创建数字分身时直接沿用当前会会账号的名称和头像。 + +## 根因 +1. **后端 `GET /api/avatar` 全局返回所有分身**,没有按用户隔离;且 `backend/main.py` 的 `seed()` 在数据库为空时会插入一个名为"我的数字分身"的种子记录。任何新登录用户都会看到它。 +2. 用户头像/昵称虽然已经在登录响应中返回并落库存入 `users` 表,但**仪表盘没有展示**。 +3. `AvatarCreate.vue` 只预填了昵称到 `displayName`,没有 `photoUrl` 字段,也没有头像预览。 + +## 改动 + +### 后端:分身按用户隔离 +- `backend/models.py` + - `Avatar` 新增 `owner_id` 字段(会会 `huihui_user_id`),索引,默认空字符串。 + - `to_dict()` 暴露 `ownerId`。 +- `backend/database.py` + - 在 `_try_add_columns` 中加入 `("avatars", "owner_id", "VARCHAR DEFAULT ''")`,容器启动时自动给已有 SQLite 表加列。 +- `backend/routers/avatars.py` + - 新增 `_resolve_user()` 从 `Authorization: Bearer ` 解析当前用户。 + - `GET /api/avatar` 仅返回 `owner_id == user.huihui_user_id` 的分身;无/无效 token 返回空列表。 + - `POST /api/avatar` 创建时写入当前用户的 `owner_id`。 + +### 前端:仪表盘展示用户资料头 +- `src/views/AvatarManage.vue` + - 引入 `useUserStore`。 + - 顶部新增用户资料头:圆形头像(`userStore.user.avatarUrl`,无则 emoji 回退)+ 昵称 + 登录状态/手机号。 + - 分身卡片优先显示真实头像 `photoUrl`,无则回退 emoji。 + +### 前端:创建分身预填名称与头像 +- `src/views/AvatarCreate.vue` + - 表单新增 `photoUrl`。 + - `onMounted` 从 `avatarStore.userProfile || userStore.user` 预填 `name`、`displayName`、`photoUrl`。 + - Step1 新增"分身头像"预览区与"已沿用你的会会头像"提示。 + - 确认页显示用户头像(或 emoji 回退)。 + - 提交时携带 `photoUrl`。 + - 引导页"已有数字分身?"文案改为 `v-if="avatarStore.avatars.length > 0"`,避免新用户被误导。 + +### Ardot 视觉稿 +- 新建「会会数字分身-仪表盘与创建页」(`fileId: 706241841026616`)。 +- 包含两屏: + 1. 数字分身管理仪表盘(状态栏、橙头、用户资料头、Token 余额、分身卡片、工具入口)。 + 2. 创建数字分身 Step1(头像预览、预填名称/显示名称、描述、下一步)。 +- 沿用 Glass Sunset Warm 品牌色系(`#FFF6EC` 背景、`#FF8C42` 主色、`#4A2511` 文字)。 + +## 部署 +- 前端:`./node_modules/.bin/vite build` → `rsync dist/` → `docker cp` 进 `avatar-test-frontend` → restart。 +- 后端:`rsync models.py / database.py / routers/avatars.py` → `docker cp` 进 `avatar-test-backend` → restart;启动时自动迁移 `owner_id` 列。 + +## 验证结果(容器内实测) +| 检查项 | 结果 | +|---|---| +| `owner_id` 列已迁移 | ✅ | +| 种子分身 `owner_id=''` | ✅ | +| 无 auth `GET /api/avatar` | `{data:[],total:0}` ✅ | +| 登录用户 `GET /api/avatar` | `{data:[],total:0}`(种子隐藏)✅ | +| 创建分身写入 `ownerId` + `photoUrl` | ✅ | +| 创建后本人列表只含 1 条 | ✅ | +| 删除后列表回归空 | ✅ | + +## 用户现在体验 +1. 登录后进入 `/#/`(创建引导)或 `/#/avatar/manage`(仪表盘),**顶部会显示当前会会账号的头像和昵称**。 +2. 新用户首次登录时,**不会看到任何"已有"分身**(列表为空)。 +3. 点"立即创建一个"进入创建流程,**名称和头像已经自动预填**了当前用户的会会昵称和头像。 +4. 创建成功后,仪表盘只显示用户自己创建的分身。 + +## 访问地址 +测试环境(同网段/隧道可达): +- 创建引导页:`http://192.168.1.188:8099/#/` +- 仪表盘:`http://192.168.1.188:8099/#/avatar/manage` +- 登录页:`http://192.168.1.188:8099/#/login/sms` + +## 后续可选 +- 生产环境切到 `99hui.com` 后可真正下发短信;当前 `fat-open` 预发环境建议继续用账号密码登录。 +- 可在创建页支持用户上传/裁剪自定义头像,替代目前直接沿用会会头像的方案。 diff --git a/overview-deploy-2026-07-17.md b/overview-deploy-2026-07-17.md new file mode 100644 index 0000000..68573e7 --- /dev/null +++ b/overview-deploy-2026-07-17.md @@ -0,0 +1,40 @@ +# 数字分身测试环境部署 + 修复概览(2026-07-17) + +## 本次动作:把上一轮改动真正发到测试环境 +测试环境:`geo-server`(192.168.1.188) 上 `/root/avatar-test`,docker-compose 跑 +`avatar-backend`(:8011) + `avatar-frontend`(:8099)。 + +访问地址:**http://192.168.1.188:8099/**(短信登录页:`/#/login/sms`) + +已上线内容(上一轮你质疑的"创建覆盖/删除/去掉会会资料/真实短信"相关代码): +- `AvatarManage.vue`:去掉"我的会会资料",改为全部分身列表,每张带删除+二次确认 +- `store/avatar.ts`:新增 `removeAvatar` +- `backend/routers/avatars.py`:删除级联清理(知识库/切片/问答对/授权) +- `backend/routers/huihui_auth.py`:真实生产调用优先,`DEV_MOCK` 仅兜底 + +## 端到端验证(已通过) +| 项 | 结果 | +|---|---| +| 后端 health | ✅ | +| 分身列表 `GET /api/avatar` | ✅ 返回已 seed 分身 | +| 短信发码(开发态) | ✅ 回显 `devCode` | +| 短信登录→`/me` | ✅ 落库 dev 用户 + 会话 | +| 删除级联 | ✅ 创建→删除→GET 404 | +| 前端页面 `:8099` | ✅ 稳定 Up,`会会数字分身` | + +## 部署中踩的两个坑(已修复,记进项目记忆) +1. **环境变量被 rsync 覆盖**:本机 compose 不含 env,把远端原 `HUIHUI_DEV_MOCK`/`EMBEDDING_*` 丢了。 + 修复:从运行容器 `docker inspect` 导出 4 个变量到远端 `.env`,compose 改用 `env_file` 引用;`.env` 加进 `.dockerignore`;本机同步加 `env_file`+`.gitignore` 防再丢。 +2. **前端容器反复 Restarting**:nginx 1.31.3 写 pid 文件被测试服务器 Docker 的 **seccomp 拦截 `pwrite`** 致命退出(老版 nginx 用 `write()` 且只告警,故之前 46h 正常)。 + 修复:前端 `Dockerfile` 锁定 `nginx:1.28-alpine`(远端已缓存,无需联网);`nginx.conf` 改为完整主配置覆盖 `/etc/nginx/nginx.conf`。 + +## 关于"参考会会接口文档"(真实短信仍待你提供) +- 仓库 `docs/production-interface-inventory.md` **只文档化了 `/open/login/token`(密码登录),没有短信端点**。 +- 已实现的真实生产调用:短信**登录**复用文档化的 `/open/login/token`,仅 `loginType/grantType=sms`(与会会开放平台一致范式);**发送验证码**端点走可配置 `HUIHUI_SMS_SEND_PATH`(默认 `/open/login/sms/send` 在生产返回 404)。 +- 仍阻塞:会会**真实短信发送端点路径**(如 `/open/sms/send` 等)+ 开放平台凭证(appId/accessId/accessSecret/clientCode)未到位。你提供后,关掉 `HUIHUI_DEV_MOCK`、写进 compose env、重建后端即可真接通会会生产库并同步用户资料。 + +## 改动文件 +- `digital-avatar-app/Dockerfile`(锁 nginx 1.28-alpine) +- `digital-avatar-app/nginx.conf`(完整主配置,覆盖默认 nginx.conf) +- `digital-avatar-app/docker-compose.yml`(backend 加 `env_file: - .env`) +- `digital-avatar-app/.gitignore`、`digital-avatar-app/backend/.dockerignore`(忽略 `.env`) diff --git a/overview-huihui-auth-2026-07-21.md b/overview-huihui-auth-2026-07-21.md new file mode 100644 index 0000000..91bfdfd --- /dev/null +++ b/overview-huihui-auth-2026-07-21.md @@ -0,0 +1,31 @@ +# 会会用户体系打通 · 部署与验证概览 + +## 完成内容 +将 `digital-avatar-app` 的短信验证码登录从「DEV_MOCK 模拟」切换为**真实会会开放平台对接**,并部署到测试环境 `geo-server:/root/avatar-test`。 + +## 关键改动 +1. **`backend/routers/huihui_auth.py` 重写** + - 真实端点:发码 `POST /open/mobile/sms/code`(query 参数)、登录 `POST /open/login/token`(formData,`loginType=code`)。 + - 签名范式与会会官方 `sign(1).js` 逐字节一致:公共字段 `appId/accessId/timestamp(12h hh)/signType(MD5)/signVersion(1.0)/accessSecret/nonce` → 过滤空值 → 字典序 → `k=v&` → 末尾 `accessSecret=secretKey` → MD5/SHA256 大写。 + - 登录成功后建/链本地 `users` 表(按会会 userId 唯一),签发本系统 `app_token` 会话。 +2. **真实凭证入环境** + - `geo-server:/root/avatar-test/.env`(及本地 `digital-avatar-app/.env`):`HUIHUI_APP_ID / HUIHUI_ACCESS_ID / HUIHUI_ACCESS_SECRET`,`HUIHUI_DEV_MOCK=false`。 + - `.env` 已被 `.gitignore` 与 `backend/.dockerignore` 排除,不会入库/入镜像。 +3. **时区 bug 修复(关键)** + - 容器默认 UTC,会会网关按**北京时间**校验时间戳 → 签名被拒("签名验证失败")。 + - `_get_timestamp()` 改为显式 `timezone(timedelta(hours=8))`,与容器系统时区解耦(不用 `ZoneInfo`,slim 镜像缺 tzdata)。 + +## 验证结果(测试环境) +| 接口 | 结果 | +|---|---| +| `POST /api/huihui/sms/send`(13800000001) | 网关 `code=200 success, sent=true` ✅ | +| `POST /api/huihui/sms/login`(假验证码) | 网关 `code=10000 验证码不正确`(签名已接受)✅ | +| `GET /api/huihui/me`(无 token) | `401 未登录`(路由/鉴权门正常)✅ | +| 前端 `:8099` → 后端 `:8000` | nginx `/api` 代理正常 ✅ | + +## 待用户操作 +在浏览器打开 `http://192.168.1.188:8099` → 短信登录页 → 输入**本人会会绑定手机号** → 获取真实验证码 → 登录。后端将调会会网关校验并写入 `users` 表,前端拿到 `app_token` + 会会资料后跳转。这是唯一未自动化的步骤(需真实手机号+真实验证码)。 + +## 排查笔记 +- 测试服务器宿主 `:8000` 另跑 `ai-virtual-news-backend`;容器映射 `8011→8000`,本地冒烟测试必须打 `:8011`。 +- 部署前用独立脚本(managed venv + httpx)直连真实网关验证签名,避免盲部署。 diff --git a/overview-login-page-2026-07-21.md b/overview-login-page-2026-07-21.md new file mode 100644 index 0000000..01a2914 --- /dev/null +++ b/overview-login-page-2026-07-21.md @@ -0,0 +1,22 @@ +# 会会数字分身登录页重设计 — 完成概览 + +## 做了什么 +- 在 Ardot 完成了「会会数字分身-登录页」高保真设计稿。 +- 按设计稿重写了前端页面 `digital-avatar-app/src/views/SmsLogin.vue`。 +- Vite 生产构建验证通过。 + +## 关键决策 +- 视觉风格:Glass Sunset Warm 品牌色(背景 `#FFF6EC`、主色 `#FF8C42`、文字 `#4A2511`)+ Vision OS Aurora 玻璃拟态。 +- 布局:移动端全屏,单栏内容包裹器,无侧边距,输入卡片和按钮通栏。 +- 品牌图形:内联渐变机器人 SVG,替代原有 emoji,更具数字分身识别度。 +- CTA 文案改为「登录 / 创建分身」,直接传达未注册手机号自动建号的业务规则。 +- 保留原组件全部业务逻辑:手机号/验证码校验、60s 倒计时、演示模式 banner、会会登录 API、登录成功后同步用户资料并跳转。 + +## 产物位置 +- Ardot 设计稿:`https://ardot.tencent.com/file/706196382431752` +- 前端页面:`digital-avatar-app/src/views/SmsLogin.vue` +- 设计截图:`/Users/yqq/Works/AiProject/huihuiSquare/.workbuddy/cache/ardot-shots/screenshot(2_1)-20260721_121632.png` + +## 待跟进 +- 需要用户在测试环境用真实会会绑定手机号走完整登录链路(发真实短信 → 输入真码 → 进入首页),验证 UI 与后端新对接的会会用户体系端到端可用。 +- 可进一步用 HBuilderX 把前端构建产物更新到 uniapp 壳工程中,做真机视觉走查。 diff --git a/overview-nickname-prefill-fix-2026-07-21.md b/overview-nickname-prefill-fix-2026-07-21.md new file mode 100644 index 0000000..52bdad5 --- /dev/null +++ b/overview-nickname-prefill-fix-2026-07-21.md @@ -0,0 +1,42 @@ +# 创建分身未预填登录昵称 — 根因与修复 + +## 现象 +登录成功后,进入「创建数字分身」,分身名称 / 显示名称没有默认填入当前登录用户的昵称; +而仪表盘(数字分身管理)却正常显示该昵称。两者读数不一致。 + +## 根因(前端取值优先级 bug) +`AvatarCreate.vue` 的 `onMounted` 原逻辑: +```js +const profile = avatarStore.userProfile || userStore.user +const nick = profile?.nickname +``` +- `avatarStore.userProfile` 是一个 **truthy 对象**(即使昵称为空字符串),会抢在真正含昵称的 + `userStore.user`(登录会话 + localStorage 持久化)之前被选中。 +- `src/main.ts` 在应用启动时执行 `setNativeProfile({ nickname: userStore.user.nickname || '' })`: + 当 localStorage 里是旧会话、昵称为空时,会把一个「空昵称对象」写进 `userProfile`。 +- 于是 `profile.nickname === ''`,创建页不预填;仪表盘直接读 `userStore.user` 所以照常显示。 + +DB 实证:`avatar-test` 容器内 `/app/avatar.db` 的 `users` 表确有真实昵称(如 `猜猜我是谁`)与真实头像 +URL —— 数据本身没问题,纯前端取值优先级问题。 + +## 修复 +文件:`digital-avatar-app/src/views/AvatarCreate.vue` +1. `onMounted` 改为 **优先 `userStore.user`,`avatarStore.userProfile` 仅作兜底**: + ```js + const real = userStore.user + const native = avatarStore.userProfile + const nick = real?.nickname || native?.nickname || '' + const avatar = real?.avatarUrl || native?.avatarUrl || '' + ``` +2. `userAvatarUrl` 计算属性同样改为优先 `userStore.user.avatarUrl`。 +3. 兜底:本地两者皆空且已登录时,调用 `getCurrentUser()`(`GET /huihui/me`,读 DB 权威值)补拉真实昵称/头像。 + +## 部署与验证 +- `./node_modules/.bin/vite build` → 新 chunk `AvatarCreate-CNMENOcc.js` +- 部署到测试环境 `geo-server`:`rsync dist/` → `docker cp` 进 `avatar-test-frontend:/usr/share/nginx/html/` +- 容器内清理旧 AvatarCreate 孤儿 chunk(`Bw_iuiqc.js` 等多份历史残留) +- 验证:首页 HTTP 200;线上 chunk 含 `displayName` 预填逻辑 + +## 经验 +前端「当前用户资料」应统一以 `userStore.user`(登录会话、localStorage 持久化)为权威源; +`avatarStore.userProfile`(原生壳注入 / 启动桥接)是可空、可被空值覆盖的派生源,绝不能放在 `||` 左侧优先。 diff --git a/overview-password-login-2026-07-21.md b/overview-password-login-2026-07-21.md new file mode 100644 index 0000000..27f67fd --- /dev/null +++ b/overview-password-login-2026-07-21.md @@ -0,0 +1,45 @@ +# 会会数字分身 · 短信收不到 + 账号密码登录 + +## 1. "收不到短信验证码" 是否接通? +**已接通,但预发环境不下发真实短信。** + +实测证据(直连会会网关): +- 后端日志:用户的发码/登录请求已到达并 `200 OK`。 +- 会会原始响应:`{code:0, message:"验证码发送成功", ...}` +- 签名算法、`appId/accessId/secret` 均正确,时间戳走 24h `HH`(避免"签名验证失败")。 + +**为什么收不到**:当前对接的是 `fat-open.99hui.com`(会会**预发/测试网关**)。 +它接收请求并返回"发送成功",但**不实际把短信下发到真实手机**(测试环境模拟发送)。 +这是环境限制,不是代码 bug。要真实收到短信需切到生产网关 `99hui.com/api/usercenter`(同套开放平台凭证通常通用,但生产是否真连短信通道需确认/由会会开启)。 + +## 2. 新增「账号密码登录」(默认方式) +既然短信在测试环境不可达,新增可靠的账号密码登录,并设为默认标签。 + +### 后端 `backend/routers/huihui_auth.py` +- 新增 `pwd_login` → `POST /api/huihui/pwd/login` +- 调会会 `/open/login/token`,参数与会会开放平台一致: + `username=账号/手机号, password=密码, loginType=password, grantType=password, isRegister=false` +- 复用 `_issue_session` 建/链本地 `users` 表并签发 `app_token` +- 账号非 11 位手机号时不覆盖已有 `user.phone` + +### 前端 +- `src/api/index.ts`:新增 `loginByPassword(account, password)` → `/huihui/pwd/login` +- `src/store/user.ts`:新增 `loginByPwd` action +- `src/views/SmsLogin.vue`:**分段切换** UI + - 默认标签 **密码登录**(账号/手机号 + 密码) + - 次选标签 **短信登录**(原手机号+验证码) + +### 部署 +`vite build` → `rsync dist/` → `docker cp` 进 `avatar-test-frontend` 容器 → `docker restart`。 + +### 验证 +- 线上 SmsLogin chunk 含「密码登录」、api bundle 含「huihui/pwd/login」✅ +- 实时 `POST /huihui/pwd/login`(假密码)→ 会会返回 `账号或密码错误!` + (说明签名已被接受、登录链路连通,仅密码错)✅ + +## 3. 你现在怎么登录 +打开 **http://192.168.1.188:8099/#/login/sms**(同网段/隧道): +- **推荐:密码登录** —— 输入你的会会账号(手机号或用户名)+ 密码,点「登录 / 创建分身」。 +- 短信登录仅在会会生产网关真正下发短信时才有用;当前测试环境点了显示"发送成功"但手机收不到。 + +> 注:会会开放平台账号须已设置密码,否则密码登录会报「账号或密码错误」(可先在会会 App 内设置/重置密码)。 diff --git a/overview-sms-login-live-2026-07-21.md b/overview-sms-login-live-2026-07-21.md new file mode 100644 index 0000000..b5abc69 --- /dev/null +++ b/overview-sms-login-live-2026-07-21.md @@ -0,0 +1,37 @@ +# 会会数字分身 · 真实短信验证码登录 — 落地说明(2026-07-21) + +## 功能状态:✅ 已端到端上线(测试环境) + +真实手机号 + 会会短信验证码登录现已完整可用,前端已部署、后端已对接会会开放平台(fat-open)。 + +## 访问地址 +- 登录页:`http://192.168.1.188:8099/#/login/sms`(需与本机同网段或通过隧道访问) +- API 基址:前端经 nginx `/api` 代理至后端 `avatar-backend:8000` + +## 使用流程(用户侧) +1. 浏览器打开登录页,输入**本人真实手机号**(11 位)。 +2. 点「获取验证码」→ 触发会会开放平台真实短信下发。 +3. 手机收到 6 位验证码,填入。 +4. 点「登录 / 创建分身」→ 后端换会会 `access_token` + `userId`,自动建/链本地 `users` 表(按 `huihui_user_id` 唯一),签发本系统 `app_token` 作为会话并跳转到首页。 + +## 技术链路 +- 前端 `SmsLogin.vue` → `sendSmsCode(phone)` / `loginBySms(phone, code)` + → `POST /huihui/sms/send` `{phone}`、`POST /huihui/sms/login` `{phone, code}` +- 后端 `routers/huihui_auth.py`: + - 发码:`POST /open/mobile/sms/code`(query,公共字段签名,北京时间戳) + - 登录:`POST /open/login/token`(formData,`loginType=code`、`isRegister=true`) + - 签名范式与会会 `sign(1).js` 逐字节一致 +- 关键修复:容器用 `timezone(timedelta(hours=8))` 生成时间戳,规避会会网关按北京时间校验导致的"签名验证失败"。 + +## 本次变更 +1. `src/views/SmsLogin.vue`:移除「演示模式」误导性 banner 与 devCode 自动填入,纯真实模式;保留玻璃态 UI / 渐变 CTA / 倒计时等 Premium 视觉。 +2. 本地 `vite build` → `rsync dist/` 至 geo-server → `docker cp` 进 `avatar-test-frontend` 容器并重启,完成部署(前端容器已 3 天旧构建,现已更新)。 + +## 部署后验证 +- ✅ 新 `SmsLogin` chunk 已含「会会数字分身」文案(确认新前端生效) +- ✅ `GET /api/huihui/me` 经代理返回 `401 未登录`(路由存活、代理通) +- ✅ 后端 `:8011` 直连同结果 + +## 备注 +- 真实短信由会会 fat-open 网关下发,需用户本人手机号完成最后一步登录。 +- 如需我代为触发一次真实短信以验证会会集成,请提供接收验证码的手机号。 diff --git a/overview-style-fix-2026-07-21.md b/overview-style-fix-2026-07-21.md new file mode 100644 index 0000000..797f5c3 --- /dev/null +++ b/overview-style-fix-2026-07-21.md @@ -0,0 +1,44 @@ +# 全站样式消失事故 — 根因与修复(2026-07-21) + +## 现象 +用户反馈:线上 `http://192.168.1.188:8099`「样式都全没了」。 + +## 根因(部署脚本 bug,非业务代码) +早前部署使用: +``` +docker cp /root/avatar-test/run-dist/. avatar-test-frontend:/usr/share/nginx/html/ +``` +这个 `SRC/.` 写法**不可靠**——它把新 JS 拷进容器,却**漏拷了新 CSS**。 +结果是容器内变成「新 JS + 旧 CSS」组合: + +- Vue `