Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5df72956d | ||
|
|
48f3b9bddf | ||
|
|
2122615725 | ||
|
|
cd0c6e3162 | ||
|
|
b48e33fc6b | ||
|
|
d9f12685fa | ||
|
|
b3367eedaa | ||
|
|
e6a4988cf9 | ||
|
|
becf2c7c52 | ||
|
|
d421a9da72 | ||
|
|
f4389612d1 | ||
|
|
ac5a332bce | ||
|
|
9ba89ef2d1 | ||
|
|
b1023eb783 | ||
|
|
2f287ef538 | ||
|
|
f5cbbe9eef | ||
|
|
5fc56143ee | ||
|
|
d521585bf2 | ||
|
|
35e47bf0a1 | ||
|
|
f52e42d9c0 | ||
|
|
1889c8ebba | ||
|
|
910a05107a | ||
|
|
857d6f2562 | ||
|
|
5ce12771b9 | ||
|
|
4c152230aa | ||
|
|
9ce46cd883 | ||
|
|
140ac20281 | ||
|
|
9afc2d5a6c |
@@ -1,12 +1,16 @@
|
|||||||
# 构建阶段:安装依赖并打包 H5
|
# 构建阶段:安装依赖并打包 H5
|
||||||
FROM node:18-alpine AS build
|
FROM node:18-alpine AS build
|
||||||
|
|
||||||
|
ARG APP_GIT_SHA=unknown
|
||||||
|
ARG APP_BUILD_TIME=unknown
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN printf '{"gitSha":"%s","buildTime":"%s"}\n' "$APP_GIT_SHA" "$APP_BUILD_TIME" > public/version.json
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端
|
# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端
|
||||||
@@ -14,6 +18,11 @@ RUN npm run build
|
|||||||
# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。
|
# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。
|
||||||
FROM nginx:1.28-alpine
|
FROM nginx:1.28-alpine
|
||||||
|
|
||||||
|
ARG APP_GIT_SHA=unknown
|
||||||
|
ARG APP_BUILD_TIME=unknown
|
||||||
|
LABEL org.opencontainers.image.revision=${APP_GIT_SHA} \
|
||||||
|
org.opencontainers.image.created=${APP_BUILD_TIME}
|
||||||
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启)
|
# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启)
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||||
|
|
||||||
|
ARG APP_GIT_SHA=unknown
|
||||||
|
ARG APP_BUILD_TIME=unknown
|
||||||
|
ENV APP_GIT_SHA=${APP_GIT_SHA} \
|
||||||
|
APP_BUILD_TIME=${APP_BUILD_TIME}
|
||||||
|
LABEL org.opencontainers.image.revision=${APP_GIT_SHA} \
|
||||||
|
org.opencontainers.image.created=${APP_BUILD_TIME}
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
|
# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
import os
|
import importlib.util
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
from database import init_db, SessionLocal
|
from database import engine, init_db, SessionLocal
|
||||||
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
|
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import routers.avatars
|
import routers.avatars
|
||||||
@@ -54,7 +55,31 @@ app.mount("/api/files", StaticFiles(directory=UPLOAD_DIR), name="knowledge-files
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
return ok({"status": "ok"})
|
checks = _runtime_checks()
|
||||||
|
return ok({
|
||||||
|
"status": "ok" if all(checks.values()) else "degraded",
|
||||||
|
"gitSha": os.getenv("APP_GIT_SHA", "unknown"),
|
||||||
|
"buildTime": os.getenv("APP_BUILD_TIME", "unknown"),
|
||||||
|
"checks": checks,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_checks():
|
||||||
|
return {
|
||||||
|
"database": _database_is_ready(),
|
||||||
|
"uploads": os.path.isdir(UPLOAD_DIR) and os.access(UPLOAD_DIR, os.W_OK),
|
||||||
|
"pdfOcr": importlib.util.find_spec("pymupdf") is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _database_is_ready():
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
connection.exec_driver_sql("SELECT 1")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Database readiness check failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def seed():
|
def seed():
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import hashlib
|
|||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
@@ -753,6 +754,83 @@ async def payment_callback(
|
|||||||
return ok({"received": True, "paid": True})
|
return ok({"received": True, "paid": True})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token/payment/huihui/virtual/notify")
|
||||||
|
async def huihui_virtual_payment_notify(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Server-to-server virtual-payment notice using the Huihui HMAC contract."""
|
||||||
|
secret = os.getenv("HUIHUI_VIRTUAL_CALLBACK_SECRET", "").strip()
|
||||||
|
if len(secret) < 32:
|
||||||
|
raise HTTPException(status_code=503, detail="virtual payment callback is not configured")
|
||||||
|
timestamp = request.headers.get("x-huihui-timestamp", "").strip()
|
||||||
|
signature = request.headers.get("x-huihui-signature", "").strip().lower()
|
||||||
|
if not timestamp.isascii() or not timestamp.isdecimal() or len(timestamp) > 12:
|
||||||
|
raise HTTPException(status_code=401, detail="invalid callback signature")
|
||||||
|
try:
|
||||||
|
issued_at = int(timestamp)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=401, detail="invalid callback signature")
|
||||||
|
if abs(int(time.time()) - issued_at) > 300:
|
||||||
|
raise HTTPException(status_code=401, detail="callback timestamp expired")
|
||||||
|
body = await request.body()
|
||||||
|
if not body or len(body) > 65536:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid callback body")
|
||||||
|
expected = hmac.new(
|
||||||
|
secret.encode("utf-8"), timestamp.encode("ascii") + b"\n" + body, hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
if not hmac.compare_digest(signature, expected):
|
||||||
|
raise HTTPException(status_code=401, detail="invalid callback signature")
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid callback JSON")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid callback JSON")
|
||||||
|
|
||||||
|
order_no = str(payload.get("orderNo") or "").strip()
|
||||||
|
payment_no = str(payload.get("paymentNo") or "").strip()
|
||||||
|
status = str(payload.get("status") or "").upper()
|
||||||
|
if not order_no or not payment_no or status not in {"SUCCESS", "FAILED"}:
|
||||||
|
raise HTTPException(status_code=400, detail="missing callback fields")
|
||||||
|
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||||
|
if not order or order.provider != "wechat_virtual":
|
||||||
|
raise HTTPException(status_code=404, detail="virtual payment order not found")
|
||||||
|
if status == "FAILED":
|
||||||
|
return ok({"received": True, "paid": False})
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == order.user_id).first()
|
||||||
|
plan = db.query(TokenPlan).filter(TokenPlan.id == order.plan_id).first()
|
||||||
|
try:
|
||||||
|
expected_product_id = product_id_for_plan(plan) if plan else ""
|
||||||
|
amount_cents = int(payload.get("amountCents"))
|
||||||
|
callback_env = int(payload.get("env"))
|
||||||
|
except (TypeError, ValueError, WechatVirtualPaymentError):
|
||||||
|
raise HTTPException(status_code=422, detail="invalid virtual payment details")
|
||||||
|
if (
|
||||||
|
isinstance(payload.get("amountCents"), bool)
|
||||||
|
or isinstance(payload.get("env"), bool)
|
||||||
|
or not user
|
||||||
|
or not user.wechat_mp_openid
|
||||||
|
or str(payload.get("openId") or "") != user.wechat_mp_openid
|
||||||
|
or not expected_product_id
|
||||||
|
or str(payload.get("productId") or "") != expected_product_id
|
||||||
|
or amount_cents != order.price_cents
|
||||||
|
or callback_env != wechat_virtual_env()
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="virtual payment details mismatch")
|
||||||
|
existing_payment = db.query(PaymentTransaction).filter(
|
||||||
|
PaymentTransaction.provider == "wechat_virtual",
|
||||||
|
PaymentTransaction.event_type == "payment",
|
||||||
|
PaymentTransaction.transaction_no == payment_no,
|
||||||
|
).first()
|
||||||
|
if existing_payment and existing_payment.order_no != order_no:
|
||||||
|
raise HTTPException(status_code=409, detail="payment number belongs to another order")
|
||||||
|
if order.status in {"paid", "refunded"}:
|
||||||
|
return ok({"received": True, "duplicate": True})
|
||||||
|
|
||||||
|
_settle_paid_order(db, order, provider_status="HUIHUI_SUCCESS", transaction_no=payment_no)
|
||||||
|
db.commit()
|
||||||
|
return ok({"received": True, "paid": True})
|
||||||
|
|
||||||
|
|
||||||
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
|
def _wechat_notify_response(request: Request, *, success: bool, message: str = ""):
|
||||||
code = 0 if success else 1
|
code = 0 if success else 1
|
||||||
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
|
text = "success" if success else (message or "fail")[:200].replace("]]>", "")
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import main
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_reports_release_and_runtime_capabilities(monkeypatch):
|
||||||
|
monkeypatch.setenv("APP_GIT_SHA", "test-sha")
|
||||||
|
monkeypatch.setenv("APP_BUILD_TIME", "2026-09-09T00:00:00Z")
|
||||||
|
monkeypatch.setattr(main, "_runtime_checks", lambda: {
|
||||||
|
"database": True,
|
||||||
|
"uploads": True,
|
||||||
|
"pdfOcr": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
response = main.health()
|
||||||
|
|
||||||
|
assert response["code"] == 200
|
||||||
|
assert response["data"]["status"] == "ok"
|
||||||
|
assert response["data"]["gitSha"] == "test-sha"
|
||||||
|
assert response["data"]["buildTime"] == "2026-09-09T00:00:00Z"
|
||||||
|
assert response["data"]["checks"] == {
|
||||||
|
"database": True,
|
||||||
|
"uploads": True,
|
||||||
|
"pdfOcr": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_degraded_when_a_required_capability_is_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(main, "_runtime_checks", lambda: {
|
||||||
|
"database": True,
|
||||||
|
"uploads": True,
|
||||||
|
"pdfOcr": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert main.health()["data"]["status"] == "degraded"
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -14,6 +16,69 @@ from services.token_billing import DEFAULT_TOKEN_GRANT
|
|||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _huihui_virtual_headers(secret, body, timestamp=None):
|
||||||
|
timestamp = str(timestamp if timestamp is not None else int(time.time()))
|
||||||
|
signature = hmac.new(secret.encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest()
|
||||||
|
return {"X-Huihui-Timestamp": timestamp, "X-Huihui-Signature": signature}
|
||||||
|
|
||||||
|
|
||||||
|
def test_huihui_virtual_callback_requires_signature_and_exact_order_details(authorization_context):
|
||||||
|
seed()
|
||||||
|
context = authorization_context
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.id == context["owner"].id).one()
|
||||||
|
user.wechat_mp_openid = "openid-huihui-callback"
|
||||||
|
user.wechat_mp_session_key = "session-huihui-callback"
|
||||||
|
plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one()
|
||||||
|
plan.virtual_product_id = "points_plan_1"
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
secret = "huihui-virtual-callback-test-secret-123456"
|
||||||
|
env = {
|
||||||
|
"WECHAT_VIRTUAL_ENV": "sandbox",
|
||||||
|
"WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key",
|
||||||
|
"WECHAT_VIRTUAL_OFFER_ID": "offer-1",
|
||||||
|
"HUIHUI_VIRTUAL_CALLBACK_SECRET": secret,
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
created = client.post(
|
||||||
|
"/api/token/charge",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"},
|
||||||
|
).json()["data"]
|
||||||
|
payload = {
|
||||||
|
"orderNo": created["orderNo"],
|
||||||
|
"paymentNo": f"huihui-virtual-payment-{context['suffix']}",
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"amountCents": 1000,
|
||||||
|
"productId": "points_plan_1",
|
||||||
|
"openId": "openid-huihui-callback",
|
||||||
|
"env": 1,
|
||||||
|
}
|
||||||
|
path = "/api/token/payment/huihui/virtual/notify"
|
||||||
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).status_code == 401
|
||||||
|
assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401
|
||||||
|
wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode()
|
||||||
|
assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422
|
||||||
|
first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
|
||||||
|
second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body))
|
||||||
|
|
||||||
|
assert first.json()["data"] == {"received": True, "paid": True}
|
||||||
|
assert second.json()["data"] == {"received": True, "duplicate": True}
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one()
|
||||||
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
|
||||||
|
assert order.status == "paid"
|
||||||
|
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _signature(token, timestamp, nonce):
|
def _signature(token, timestamp, nonce):
|
||||||
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
|
return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest()
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,56 @@
|
|||||||
# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署)
|
# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署)
|
||||||
services:
|
services:
|
||||||
avatar-backend:
|
avatar-backend:
|
||||||
build: ./backend
|
build:
|
||||||
image: avatar-test-backend:latest
|
context: ./backend
|
||||||
|
args:
|
||||||
|
APP_GIT_SHA: ${APP_GIT_SHA:?APP_GIT_SHA must be the full release commit}
|
||||||
|
APP_BUILD_TIME: ${APP_BUILD_TIME:?APP_BUILD_TIME must be set}
|
||||||
|
image: avatar-test-backend:${APP_GIT_SHA}
|
||||||
container_name: avatar-test-backend
|
container_name: avatar-test-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: sqlite:////data/avatar.db
|
DATABASE_URL: sqlite:////data/db/avatar.db
|
||||||
UPLOAD_DIR: /data/uploads
|
UPLOAD_DIR: /data/uploads
|
||||||
CHAT_MODEL_CONFIG_URL: http://host.docker.internal:8000/api/ai-models/runtime/digital-avatar
|
CHAT_MODEL_CONFIG_URL: http://host.docker.internal:8000/api/ai-models/runtime/digital-avatar
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- avatar-data:/data
|
# Mount the directory, not only avatar.db: SQLite WAL/SHM files must survive recreation.
|
||||||
|
- ${AVATAR_DB_DIR:?AVATAR_DB_DIR must contain the persistent avatar.db}:/data/db
|
||||||
|
- ${AVATAR_UPLOAD_DIR:?AVATAR_UPLOAD_DIR must point to persistent uploads}:/data/uploads
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
ports:
|
ports:
|
||||||
- "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口
|
- "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import json,urllib.request; d=json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5))['data']; assert d['status']=='ok' and all(d['checks'].values())"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 8s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
networks:
|
networks:
|
||||||
- avatar-net
|
- avatar-net
|
||||||
|
|
||||||
avatar-frontend:
|
avatar-frontend:
|
||||||
build: .
|
build:
|
||||||
image: avatar-test-frontend:latest
|
context: .
|
||||||
|
args:
|
||||||
|
APP_GIT_SHA: ${APP_GIT_SHA:?APP_GIT_SHA must be the full release commit}
|
||||||
|
APP_BUILD_TIME: ${APP_BUILD_TIME:?APP_BUILD_TIME must be set}
|
||||||
|
image: avatar-test-frontend:${APP_GIT_SHA}
|
||||||
container_name: avatar-test-frontend
|
container_name: avatar-test-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8099:80" # 浏览器访问 http://<host>:8099
|
- "8099:80" # 浏览器访问 http://<host>:8099
|
||||||
depends_on:
|
depends_on:
|
||||||
- avatar-backend
|
avatar-backend:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- avatar-net
|
- avatar-net
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
avatar-net:
|
avatar-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
|
||||||
avatar-data:
|
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID>
|
|||||||
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID>
|
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID>
|
||||||
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
|
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
|
||||||
|
|
||||||
DATABASE_URL=sqlite:////data/avatar.db
|
AVATAR_DB_DIR=/srv/digital-avatar/data/db
|
||||||
|
AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads
|
||||||
|
DATABASE_URL=sqlite:////data/db/avatar.db
|
||||||
UPLOAD_DIR=/data/uploads
|
UPLOAD_DIR=/data/uploads
|
||||||
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
|
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
|
||||||
EMBEDDING_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
EMBEDDING_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
@@ -74,13 +76,17 @@ VISION_OCR_MODEL=qwen-vl-ocr
|
|||||||
VISION_MAX_OUTPUT_TOKENS=2048
|
VISION_MAX_OUTPUT_TOKENS=2048
|
||||||
VISION_TIMEOUT_SECONDS=90
|
VISION_TIMEOUT_SECONDS=90
|
||||||
VISION_TOKEN_RESERVE=12000
|
VISION_TOKEN_RESERVE=12000
|
||||||
|
APP_GIT_SHA=<本次发布的完整提交SHA>
|
||||||
|
APP_BUILD_TIME=<UTC ISO-8601构建时间>
|
||||||
CHAT_IMAGE_MAX_BYTES=8388608
|
CHAT_IMAGE_MAX_BYTES=8388608
|
||||||
CHAT_IMAGE_MAX_PIXELS=16000000
|
CHAT_IMAGE_MAX_PIXELS=16000000
|
||||||
CHAT_ATTACHMENT_RETENTION_HOURS=24
|
CHAT_ATTACHMENT_RETENTION_HOURS=24
|
||||||
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
||||||
```
|
```
|
||||||
|
|
||||||
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
|
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。数据库文件与上传目录必须从宿主机显式挂载,不能存放在容器临时层。
|
||||||
|
|
||||||
|
`AVATAR_DB_DIR` 和 `AVATAR_UPLOAD_DIR` 必须是已备份的宿主机绝对路径,编排缺少任一变量都会直接拒绝构建或启动,防止误挂空卷造成用户、分身或知识库“丢失”的假象。SQLite 必须挂载整个数据库目录,不能只挂载 `avatar.db` 单文件,否则 `avatar.db-wal` 和 `avatar.db-shm` 会留在容器临时层,换容器后可能出现数据状态回退。
|
||||||
|
|
||||||
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
|
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
|
||||||
|
|
||||||
@@ -88,6 +94,8 @@ App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,
|
|||||||
|
|
||||||
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
|
微信小程序使用微信虚拟支付:小程序先通过 `POST /api/token/wechat/session` 交换临时登录码,再由 `POST /api/token/charge`(`payScene=LITE`)返回已签名的 `requestVirtualPayment` 参数。微信回调地址配置为 `https://digital.99hui.com/api/token/payment/wechat/virtual/notify`。回调会复核签名、OpenID、环境、商品 ID 与实付金额,退款回调确认后才扣回积分。AppKey、AppSecret、session_key 均不得下发前端或写日志。
|
||||||
|
|
||||||
|
如由会会服务端转发微信小程序虚拟支付结果,另有待双方确认的固定回调契约,见 [HUIHUI_VIRTUAL_CALLBACK.md](HUIHUI_VIRTUAL_CALLBACK.md)。该接口使用独立的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`;即使接口代码部署到生产,在协议确认和联调验收前也不得将其视作已上线的支付渠道。
|
||||||
|
|
||||||
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
|
管理后台需要配置相同的 `AVATAR_FINANCE_ADMIN_SECRET` 和 `AVATAR_BACKEND_URL=https://digital.99hui.com`。退款只支持整单原路退款;供应商受理后显示“处理中”,收到渠道成功回调(或经渠道后台核对后人工确认)才将订单置为已退款。已消费掉本订单积分时,后台会拒绝主动退款;若渠道外部退款先发生,积分账户允许形成负数以记录欠额并阻止继续消费。
|
||||||
|
|
||||||
## 3. 构建与发布
|
## 3. 构建与发布
|
||||||
@@ -97,7 +105,7 @@ App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,
|
|||||||
```bash
|
```bash
|
||||||
BACKUP_DIR="backups/$(date +%Y%m%d-%H%M%S)"
|
BACKUP_DIR="backups/$(date +%Y%m%d-%H%M%S)"
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
cp /srv/digital-avatar/data/avatar.db "$BACKUP_DIR/"
|
cp /srv/digital-avatar/data/db/avatar.db "$BACKUP_DIR/"
|
||||||
tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
|
tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -107,13 +115,22 @@ tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
|
|||||||
git fetch origin
|
git fetch origin
|
||||||
git checkout <已验收的提交SHA>
|
git checkout <已验收的提交SHA>
|
||||||
cd digital-avatar-app
|
cd digital-avatar-app
|
||||||
docker compose build --pull avatar-backend avatar-frontend
|
export APP_GIT_SHA="$(git rev-parse HEAD)"
|
||||||
docker compose up -d avatar-backend avatar-frontend
|
export APP_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
docker compose build --pull --no-cache avatar-backend avatar-frontend
|
||||||
|
docker compose up -d --force-recreate --wait avatar-backend avatar-frontend
|
||||||
docker compose ps
|
docker compose ps
|
||||||
curl -fsS http://127.0.0.1:8099/api/health
|
python3 scripts/verify-deployment.py \
|
||||||
|
https://digital.99hui.com "$APP_GIT_SHA" \
|
||||||
|
--backend-container avatar-backend \
|
||||||
|
--frontend-container avatar-frontend \
|
||||||
|
--expected-db-source /srv/digital-avatar/data/db \
|
||||||
|
--expected-upload-source /srv/digital-avatar/data/uploads
|
||||||
docker compose exec avatar-backend python -c 'import embeddings; v=embeddings.embed(["部署向量探针"]); print(len(v), len(v[0]))'
|
docker compose exec avatar-backend python -c 'import embeddings; v=embeddings.embed(["部署向量探针"]); print(len(v), len(v[0]))'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Jenkins 必须以 `verify-deployment.py` 返回成功作为发布成功条件,不能只以镜像构建或容器启动成功作为条件。脚本会同时核对公网前后端 Git SHA、数据库可读、上传目录可写、PDF OCR 依赖和宿主机数据挂载;任意一项不一致都会返回非零状态并阻止发布标绿。镜像使用 Git SHA 标签,不再依赖可被旧缓存覆盖的 `latest`。
|
||||||
|
|
||||||
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
|
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
|
||||||
|
|
||||||
## 4. 网关要求
|
## 4. 网关要求
|
||||||
@@ -153,7 +170,7 @@ location /api/ {
|
|||||||
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
|
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
|
||||||
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
|
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
|
||||||
7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。
|
7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。
|
||||||
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
|
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 的 `gitSha` 与发布 SHA 一致,`database`、`uploads`、`pdfOcr` 三项检查均为 `true`。
|
||||||
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
|
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
|
||||||
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
|
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
|
||||||
11. 微信虚拟支付在沙箱环境完成下单、支付回调、查单兜底和退款回调;错误 OpenID、商品、环境或金额均被拒绝。
|
11. 微信虚拟支付在沙箱环境完成下单、支付回调、查单兜底和退款回调;错误 OpenID、商品、环境或金额均被拒绝。
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 会会微信小程序虚拟支付回调(待双方确认)
|
||||||
|
|
||||||
|
本项目为会会服务端预留固定回调地址:
|
||||||
|
|
||||||
|
`POST https://digital.99hui.com/api/token/payment/huihui/virtual/notify`
|
||||||
|
|
||||||
|
回调报文及签名协议尚未与会会确认。以下是本项目实现的拟议对接契约,双方确认并安全交换密钥后才能进行测试支付。接口代码部署与会会联调通过是两个独立状态。它不替代现有微信直连回调 `/api/token/payment/wechat/virtual/notify`。
|
||||||
|
|
||||||
|
请求体为 JSON,成功支付示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orderNo": "AV202610150000000000000001",
|
||||||
|
"paymentNo": "会会侧唯一支付流水号",
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"amountCents": 1000,
|
||||||
|
"productId": "points_plan_1",
|
||||||
|
"openId": "微信小程序用户 OpenID",
|
||||||
|
"env": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`amountCents` 单位为分,`env` 为微信虚拟支付环境(0 正式、1 沙箱)。非成功结果可发送 `status: "FAILED"`,但不会入账。订单号、支付流水号和状态必填;成功结果还必须提供金额、商品 ID、OpenID、环境。仅在订单属于微信虚拟支付,且上述信息与本地订单、套餐、用户一致时入账。重复成功通知不会重复发放积分;同一支付流水号不能用于其他订单。
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
- `X-Huihui-Timestamp`:Unix 秒级时间戳,允许与本服务时间相差不超过 300 秒。
|
||||||
|
- `X-Huihui-Signature`:小写十六进制 HMAC-SHA256,密钥为双方线下安全交换、服务端配置的 `HUIHUI_VIRTUAL_CALLBACK_SECRET`(至少 32 字符),待签名数据为 `时间戳 + 换行符 + 原始请求体字节`。不要重排 JSON 后签名,也不要把密钥发给前端。
|
||||||
|
|
||||||
|
成功接收返回 HTTP 200,`{"code":200,"message":"success","data":{"received":true,"paid":true}}`。重复通知返回 `duplicate: true`。验签、字段、订单或金额不符返回 HTTP 4xx,会会可按其重试策略处理。服务端未配置密钥时返回 HTTP 503。
|
||||||
|
|
||||||
|
上线前需由会会确认:其服务端是否能按此格式签名并发送通知、支付成功状态与支付流水号字段的正式定义、失败通知及重试/超时策略。小程序支付发起和退款仍需独立对接和验收;仅完成此回调不代表虚拟支付全流程已可用。
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail a deployment unless frontend and backend run the expected release."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_json(url):
|
||||||
|
with urllib.request.urlopen(url, timeout=20) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
raise RuntimeError(f"{url} returned HTTP {response.status}")
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("base_url", help="Public site URL, for example https://digital.99hui.com")
|
||||||
|
parser.add_argument("expected_sha", help="Full Git commit SHA being deployed")
|
||||||
|
parser.add_argument("--backend-container", help="Backend container name for image and mount checks")
|
||||||
|
parser.add_argument("--frontend-container", help="Frontend container name for image checks")
|
||||||
|
parser.add_argument("--expected-db-source", help="Required host directory mounted for SQLite and its WAL files")
|
||||||
|
parser.add_argument("--expected-upload-source", help="Required host source mounted as the upload directory")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base_url = args.base_url.rstrip("/")
|
||||||
|
errors = []
|
||||||
|
try:
|
||||||
|
health = fetch_json(f"{base_url}/api/health").get("data") or {}
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"cannot read backend release metadata: {exc}")
|
||||||
|
health = {}
|
||||||
|
try:
|
||||||
|
frontend = fetch_json(f"{base_url}/version.json")
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"cannot read frontend release metadata: {exc}")
|
||||||
|
frontend = {}
|
||||||
|
|
||||||
|
if health.get("status") != "ok":
|
||||||
|
errors.append(f"backend status is {health.get('status')!r}")
|
||||||
|
failed_checks = [name for name, passed in (health.get("checks") or {}).items() if not passed]
|
||||||
|
if failed_checks:
|
||||||
|
errors.append("backend checks failed: " + ", ".join(failed_checks))
|
||||||
|
if health.get("gitSha") != args.expected_sha:
|
||||||
|
errors.append(f"backend SHA is {health.get('gitSha')!r}")
|
||||||
|
if frontend.get("gitSha") != args.expected_sha:
|
||||||
|
errors.append(f"frontend SHA is {frontend.get('gitSha')!r}")
|
||||||
|
|
||||||
|
if args.backend_container:
|
||||||
|
backend = inspect_container(args.backend_container, errors)
|
||||||
|
check_container_revision(backend, args.expected_sha, "backend", errors)
|
||||||
|
check_mount(backend, args.expected_db_source, "database", errors)
|
||||||
|
check_mount(backend, args.expected_upload_source, "uploads", errors)
|
||||||
|
elif args.expected_db_source or args.expected_upload_source:
|
||||||
|
errors.append("--backend-container is required when checking data mounts")
|
||||||
|
|
||||||
|
if args.frontend_container:
|
||||||
|
frontend_container = inspect_container(args.frontend_container, errors)
|
||||||
|
check_container_revision(frontend_container, args.expected_sha, "frontend", errors)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("Deployment verification failed:", file=sys.stderr)
|
||||||
|
for error in errors:
|
||||||
|
print(f"- {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Deployment verified: {args.expected_sha}")
|
||||||
|
print("Backend checks: database, uploads, pdfOcr")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_container(name, errors):
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["docker", "inspect", name], universal_newlines=True
|
||||||
|
)
|
||||||
|
return json.loads(output)[0]
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"cannot inspect container {name!r}: {exc}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def check_container_revision(container, expected_sha, label, errors):
|
||||||
|
actual = ((container.get("Config") or {}).get("Labels") or {}).get(
|
||||||
|
"org.opencontainers.image.revision"
|
||||||
|
)
|
||||||
|
if actual != expected_sha:
|
||||||
|
errors.append(f"{label} container image SHA is {actual!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_mount(container, expected_source, label, errors):
|
||||||
|
if not expected_source:
|
||||||
|
return
|
||||||
|
expected = os.path.realpath(expected_source)
|
||||||
|
sources = {
|
||||||
|
os.path.realpath(mount.get("Source", ""))
|
||||||
|
for mount in container.get("Mounts") or []
|
||||||
|
}
|
||||||
|
if expected not in sources:
|
||||||
|
errors.append(f"{label} mount source {expected!r} is not attached")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
import { getAuthToken } from '@/api'
|
import { getAuthToken } from '@/api'
|
||||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -56,7 +55,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/token/charge',
|
path: '/token/charge',
|
||||||
name: 'TokenCharge',
|
name: 'TokenCharge',
|
||||||
component: () => import('@/views/TokenCharge.vue'),
|
component: () => import('@/views/TokenCharge.vue'),
|
||||||
meta: { title: '积分充值', requiresAuth: true, requiresUniWebView: true }
|
meta: { title: '积分充值', requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/avatar/card',
|
path: '/avatar/card',
|
||||||
@@ -134,10 +133,6 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
document.title = to.meta.title as string || '会会数字分身'
|
document.title = to.meta.title as string || '会会数字分身'
|
||||||
if (to.meta.requiresUniWebView && !isInUniWebView()) {
|
|
||||||
next({ path: '/avatar/manage' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const hasLocalSession = Boolean(localStorage.getItem('hh_app_token'))
|
const hasLocalSession = Boolean(localStorage.getItem('hh_app_token'))
|
||||||
const hasInjectedSession = Boolean(getAuthToken())
|
const hasInjectedSession = Boolean(getAuthToken())
|
||||||
if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) {
|
if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) {
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ const permissionItems: Array<{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'publish',
|
key: 'publish',
|
||||||
title: '发布微博内容',
|
title: '发布微播内容',
|
||||||
description: '允许分身自动发布动态内容',
|
description: '允许分身自动发布动态内容',
|
||||||
tone: 'green',
|
tone: 'green',
|
||||||
},
|
},
|
||||||
@@ -691,7 +691,9 @@ svg {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.permission-row.takeover .permission-copy small {
|
.permission-row.takeover .permission-copy small {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>{{ message.content }}</template>
|
<template v-else>{{ message.content }}</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="message.source || message.references?.length" class="message-source">
|
<div v-if="sourceLabel(message.source) || message.references?.length" class="message-source">
|
||||||
{{ sourceLabel(message.source) }}
|
{{ sourceLabel(message.source) }}
|
||||||
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
|
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +174,8 @@ const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
|
|||||||
qa: '标准问答对',
|
qa: '标准问答对',
|
||||||
knowledge: '参考文件知识库',
|
knowledge: '参考文件知识库',
|
||||||
vision: '图片理解',
|
vision: '图片理解',
|
||||||
qwen: '智能回答',
|
// qwen 来源不再在聊天气泡下显示“智能回答/知能回答”标注。
|
||||||
|
qwen: '',
|
||||||
public: ''
|
public: ''
|
||||||
}
|
}
|
||||||
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
|
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 积分余额条:仅在 uni-app 原生壳内开放充值购买。 -->
|
<!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
|
||||||
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
|
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
|
||||||
<div class="token-card">
|
<div class="token-card">
|
||||||
<div class="token-info">
|
<div class="token-info">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
||||||
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="recharge-btn" @click="goToRecharge">充值购买</button>
|
<button class="recharge-btn" @click="goToRecharge">充值</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -89,15 +89,14 @@ import { useAvatarStore } from '@/store/avatar'
|
|||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { createAvatarShareLink } from '@/api'
|
import { createAvatarShareLink } from '@/api'
|
||||||
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const isEmbedded = isHuihuiEmbeddedMode()
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
// 充值购买只在 uni-app 原生壳内提供,避免普通 H5 进入支付链路。
|
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
|
||||||
const SHOW_POINTS_BALANCE_CARD = isInUniWebView()
|
const SHOW_POINTS_BALANCE_CARD = false
|
||||||
|
|
||||||
// 当前登录会会用户的资料(头像 / 昵称)
|
// 当前登录会会用户的资料(头像 / 昵称)
|
||||||
const me = computed(() => userStore.user)
|
const me = computed(() => userStore.user)
|
||||||
|
|||||||
Reference in New Issue
Block a user