Compare commits

...

15 Commits
main ... dev

Author SHA1 Message Date
pmc
0330124b19 fix: pass through paragraph=true empty-text terminator in subv handler
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 9m27s
火山 SubtitleMode=1 下,paragraph=true 的事件 text 字段是空字符串,
作为段落终止的独立信号;之前 strategy B 的 `if not text.strip(): continue`
直接吞掉了这个信号,导致 buffer 永远 flush 不出来、AI 字幕全部丢失。

只在文本空且非 paragraph 终止时跳过;终止信号本身不进 buffer,但触发
已累积分片的拼接落库。Mode=0 行为不变(其 paragraph=true 事件 text 非空)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 16:03:44 +08:00
pmc
c85f6f2f9e feat: enable Traefik access logs via HelmChartConfig
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 56m21s
Adds k8s/traefik-config.yaml to enable JSON-format access logs on
the K3s built-in Traefik for diagnosing whether subv webhooks from
volcengine are dropped between ingress and Django, or simply not
sent. Drops Authorization/Cookie headers to avoid leaking secrets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:58:31 +08:00
pmc
c70bee7295 feat: update device interaction views
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 11m6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 17:41:44 +08:00
pmc
3b7c5c85f5 feat: update device interaction views, docs, and CLAUDE.md
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 9m14s
- Update device_interaction views
- Update admin README and CLAUDE.md
- Add affinity system design doc
- Add device chat record subtitle storage scheme doc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 17:06:21 +08:00
pmc
e57f681040 feat: update userapp views
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 1h9m26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 18:48:04 +08:00
pmc
90c6feb5a5 feat: update userapp utils
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 11m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 11:05:23 +08:00
pmc
29b4913723 feat: update RTC bot migration and device interaction consumers
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 9m19s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:08:05 +08:00
pmc
8dcef0cff2 Merge branch 'dev' of https://gitea.airlabs.art/zyc/lty into dev
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 31m8s
2026-04-10 11:56:00 +08:00
pmc
59b178f8f4 feat: update AI app views/urls and add RTC bot migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:55:52 +08:00
zyc
cbfa704299 perf: 保留基础镜像缓存 + kubectl 装到 /usr/bin 避开 bind mount 冲突
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 43m21s
2026-04-04 22:13:57 +08:00
zyc
83a67029a8 fix: remove unnecessary CR repo creation step
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 9m20s
Docker push auto-creates repos on Volcano Engine CR, no need for
explicit OpenAPI calls. Align with jimeng-clone approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:12:08 +08:00
zyc
8762da358a feat: auto-create CR repositories before build
Some checks failed
Build and Deploy LTY / build-and-deploy (push) Has been cancelled
Add Volcano Engine OpenAPI call to ensure lty-backend and lty-admin
repos exist in the CR namespace before pushing images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:09:27 +08:00
zyc
32f55b3f39 feat: add Docker cleanup step to CI pipeline
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 10m6s
Automatically prune unused containers, images and build cache after
each CI run to prevent disk space exhaustion on the runner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:46:51 +08:00
zyc
4de4595ec0 feat: update CI/CD pipeline for multi-env deploy and use mirror registry
All checks were successful
Build and Deploy LTY / build-and-deploy (push) Successful in 7m50s
- Add dev branch trigger and environment-based config (prod/dev)
- Switch to Volcano container registry with retry logic
- Use mirror images for Docker base images (daocloud.io)
- Add dynamic domain/DB/Redis config per environment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:33:32 +08:00
pmc
9bf46cb8af fix new . 2026-04-04 15:18:52 +08:00
17 changed files with 1528 additions and 412 deletions

View File

@ -172,7 +172,40 @@
"Bash(C:/python/python.exe -X utf8 -c \"import sys,json; sys.stdout.reconfigure\\(encoding=''''utf-8''''\\); d=json.load\\(sys.stdin\\); [print\\(f''''ID={i[\"\"id\"\"]} status={i.get\\(\"\"status\"\",\"\"?\"\"\\)} name={i[\"\"name\"\"]}''''\\) for i in d.get\\(''''data'''',d\\).get\\(''''results'''',d.get\\(''''data'''',[]\\)\\)]\")",
"Bash(ls c:/Users/admin/Desktop/Lila-Server/qy_lty/settings*)",
"Bash(python manage.py makemigrations card)",
"Bash(python manage.py runserver)"
"Bash(python manage.py runserver)",
"Bash(start cmd:*)",
"Bash(redis-cli ping:*)",
"Bash(where redis-cli:*)",
"Bash(cmd //c \"where redis-cli\")",
"Bash(cmd //c \"where redis-server\")",
"Read(//c//**)",
"Bash(cmd //c \"where /R C:\\\\\\\\Program Files redis-server.exe\")",
"Bash(cmd //c \"where /R C:\\\\\\\\Users\\\\\\\\admin redis-server.exe\")",
"Read(//c/Redis/**)",
"Read(//c/Program Files/Redis/**)",
"Bash(docker info:*)",
"Bash(cmd //c \"sc query Redis\")",
"Bash(conda run:*)",
"Bash(cmd //c \"conda activate fengye && pip show django-redis\")",
"Bash(conda env:*)",
"Bash(cmd //c \"conda env list\")",
"Read(//c/Users/admin/anaconda3/Scripts/**)",
"Read(//c/Users/admin/miniconda3/Scripts/**)",
"Read(//c/ProgramData/anaconda3/Scripts/**)",
"Read(//c/ProgramData/miniconda3/Scripts/**)",
"Bash(cmd //c \"where python\")",
"Bash(cmd //c \"C:\\\\python\\\\python.exe -c \\\\\"import django; print\\(django.get_version\\(\\)\\)\\\\\"\")",
"Bash(cmd //c \"C:\\\\python\\\\python.exe -m pip list\")",
"Bash(cmd //c \"netstat -ano | findstr :8000 | findstr LISTENING\")",
"Bash(cmd //c \"netstat -ano | findstr :3000 | findstr LISTENING\")",
"Bash(cmd //c \"netstat -ano | findstr LISTENING | findstr /R \\\\\"8000 3000\\\\\"\")",
"Bash(cmd //c \"netstat -ano\")",
"Bash(/c/python/python.exe -m daphne -b 0.0.0.0 -p 8000 qy_lty.asgi:application)",
"Bash(kill %1)",
"Bash(python -c ':*)",
"Bash(xargs ls:*)",
"Bash(find /c/Users/admin/Desktop/Lila-Server -name \"*.py\" -exec grep -l \"connect\\\\|disconnect\\\\|presence\\\\|heartbeat\" {} \\\\;)",
"Bash(grep -E \"\\\\.py$|app$\")"
],
"additionalDirectories": [
"C:\\Users\\admin\\.claude"

View File

@ -5,78 +5,155 @@ on:
branches:
- main
- master
- dev
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
run: |
git clone --depth=1 --branch=${{ github.ref_name }} https://gitea.airlabs.art/${{ github.repository }}.git .
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
config-inline: |
[registry."docker.io"]
mirrors = ["https://docker.m.daocloud.io", "https://docker.1panel.live", "https://hub.rat.dev"]
- name: Set environment by branch
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
BUILD_DATE=$(date +%Y%m%d)
- name: Login to Huawei Cloud SWR
uses: docker/login-action@v2
with:
registry: ${{ secrets.SWR_SERVER }}
username: ${{ secrets.SWR_USERNAME }}
password: ${{ secrets.SWR_PASSWORD }}
if [[ "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "master" ]]; then
echo "IMAGE_TAG=prod-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV
echo "CR_SERVER_ACTIVE=gitea-prod-cn-shanghai.cr.volces.com" >> $GITHUB_ENV
echo "CR_USERNAME_ACTIVE=seaislee@76339115" >> $GITHUB_ENV
echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PROD_PASSWORD }}" >> $GITHUB_ENV
echo "CR_ORG=prod" >> $GITHUB_ENV
echo "DEPLOY_ENV=production" >> $GITHUB_ENV
echo "DOMAIN_API=qy-lty.airlabs.art" >> $GITHUB_ENV
echo "DOMAIN_ADMIN=qy-lty-admin.airlabs.art" >> $GITHUB_ENV
echo "DB_HOST=pgm-7xv4811oj11j86htzo.pg.rds.aliyuncs.com" >> $GITHUB_ENV
echo "REDIS_LOCATION=redis://r-7xvat0vez5clwbzk5vpd.redis.rds.aliyuncs.com:6379/0" >> $GITHUB_ENV
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
echo "IMAGE_TAG=dev-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV
echo "CR_SERVER_ACTIVE=${{ secrets.CR_SERVER }}" >> $GITHUB_ENV
echo "CR_USERNAME_ACTIVE=${{ secrets.CR_USERNAME }}" >> $GITHUB_ENV
echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PASSWORD }}" >> $GITHUB_ENV
echo "CR_ORG=dev" >> $GITHUB_ENV
echo "DEPLOY_ENV=development" >> $GITHUB_ENV
echo "DOMAIN_API=qy-lty.test.airlabs.art" >> $GITHUB_ENV
echo "DOMAIN_ADMIN=qy-lty-admin.test.airlabs.art" >> $GITHUB_ENV
echo "DB_HOST=pgm-7xv4811oj11j86htzo.pg.rds.aliyuncs.com" >> $GITHUB_ENV
echo "REDIS_LOCATION=redis://r-7xvat0vez5clwbzk5vpd.redis.rds.aliyuncs.com:6379/0" >> $GITHUB_ENV
fi
- name: Login to Container Registry
run: |
echo "${{ env.CR_PASSWORD_ACTIVE }}" | docker login --username "${{ env.CR_USERNAME_ACTIVE }}" --password-stdin ${{ env.CR_SERVER_ACTIVE }}
- name: Build and Push Backend
id: build-backend
id: build_backend
run: |
set -o pipefail
docker buildx build \
--push \
--provenance=false \
--tag ${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/lty-backend:latest \
./qy_lty 2>&1 | tee /tmp/build.log
for attempt in 1 2 3; do
echo "Build backend attempt $attempt/3..."
DOCKER_BUILDKIT=0 docker build \
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/lty-backend:${{ env.IMAGE_TAG }} \
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/lty-backend:latest \
./qy_lty 2>&1 | tee /tmp/build.log && break
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
done
for attempt in 1 2 3; do
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/lty-backend:${{ env.IMAGE_TAG }} && \
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/lty-backend:latest && break
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
done
- name: Build and Push Admin Frontend
id: build-admin
id: build_admin
run: |
set -o pipefail
docker buildx build \
--push \
--provenance=false \
--tag ${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/lty-admin:latest \
./qy-lty-admin 2>&1 | tee -a /tmp/build.log
for attempt in 1 2 3; do
echo "Build admin attempt $attempt/3..."
DOCKER_BUILDKIT=0 docker build \
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/lty-admin:${{ env.IMAGE_TAG }} \
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/lty-admin:latest \
./qy-lty-admin 2>&1 | tee -a /tmp/build.log && break
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
done
for attempt in 1 2 3; do
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/lty-admin:${{ env.IMAGE_TAG }} && \
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/lty-admin:latest && break
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
done
- name: Setup Kubectl
run: |
curl -LO "https://dl.k8s.io/release/v1.28.2/bin/linux/amd64/kubectl" || \
curl -LO "https://cdn.dl.k8s.io/release/v1.28.2/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
if ! command -v kubectl &>/dev/null; then
for attempt in 1 2 3; do
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && break
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
done
chmod +x kubectl && mv kubectl /usr/bin/kubectl
fi
kubectl version --client
- name: Set kubeconfig
run: |
mkdir -p $HOME/.kube
if [[ "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "master" ]]; then
echo "${{ secrets.KUBE_CONFIG }}" > $HOME/.kube/config
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
echo "${{ secrets.VOLCANO_TEST_KUBE_CONFIG }}" > $HOME/.kube/config
fi
chmod 600 $HOME/.kube/config
- name: Deploy to K3s
uses: Azure/k8s-set-context@v3
with:
method: kubeconfig
kubeconfig: ${{ secrets.KUBE_CONFIG }}
- name: Update K8s Manifests
id: deploy
run: |
echo "Environment: ${{ env.DEPLOY_ENV }}"
CR_IMAGE="${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}"
# Replace image placeholders
sed -i "s|\${CI_REGISTRY_IMAGE}/lty-backend:latest|${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/lty-backend:latest|g" k8s/backend-deployment-prod.yaml
sed -i "s|\${CI_REGISTRY_IMAGE}/lty-admin:latest|${{ secrets.SWR_SERVER }}/${{ secrets.SWR_ORG }}/lty-admin:latest|g" k8s/admin-deployment-prod.yaml
sed -i "s|\${CI_REGISTRY_IMAGE}/lty-backend:latest|${CR_IMAGE}/lty-backend:${{ env.IMAGE_TAG }}|g" k8s/backend-deployment-prod.yaml
sed -i "s|\${CI_REGISTRY_IMAGE}/lty-admin:latest|${CR_IMAGE}/lty-admin:${{ env.IMAGE_TAG }}|g" k8s/admin-deployment-prod.yaml
# Apply manifests and restart
set -o pipefail
{
kubectl apply -f k8s/backend-deployment-prod.yaml
kubectl apply -f k8s/admin-deployment-prod.yaml
kubectl apply -f k8s/ingress.yaml
kubectl rollout restart deployment/lty-backend
kubectl rollout restart deployment/lty-admin
} 2>&1 | tee /tmp/deploy.log
# Replace domain placeholders by environment
sed -i "s|qy-lty.airlabs.art|${{ env.DOMAIN_API }}|g" k8s/ingress.yaml
sed -i "s|qy-lty-admin.airlabs.art|${{ env.DOMAIN_ADMIN }}|g" k8s/ingress.yaml
sed -i "s|https://qy-lty.airlabs.art|https://${{ env.DOMAIN_API }}|g" k8s/admin-deployment-prod.yaml
# Replace DB host by environment
sed -i "s|pgm-7xv4811oj11j86htzo.pg.rds.aliyuncs.com|${{ env.DB_HOST }}|g" k8s/backend-deployment-prod.yaml
# Replace Redis by environment
sed -i "s|redis://r-7xvat0vez5clwbzk5vpd.redis.rds.aliyuncs.com:6379/0|${{ env.REDIS_LOCATION }}|g" k8s/backend-deployment-prod.yaml
# All kubectl operations with retry
for attempt in 1 2 3; do
echo "Deploy attempt $attempt/3..."
{
# Create/update image pull secret
kubectl create secret docker-registry cr-pull-secret \
--docker-server="${{ env.CR_SERVER_ACTIVE }}" \
--docker-username="${{ env.CR_USERNAME_ACTIVE }}" \
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
--dry-run=client -o yaml | kubectl apply -f -
# Apply manifests
kubectl apply -f k8s/backend-deployment-prod.yaml
kubectl apply -f k8s/admin-deployment-prod.yaml
kubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/traefik-config.yaml
# Preserve real client IP
kubectl patch svc traefik -n kube-system -p '{"spec":{"externalTrafficPolicy":"Local"}}' 2>/dev/null || true
kubectl rollout restart deployment/lty-backend
kubectl rollout restart deployment/lty-admin
} 2>&1 | tee /tmp/deploy.log && break
echo "Attempt $attempt failed, retrying in 10s..."
sleep 10
done
# ===== Log Center: failure reporting =====
- name: Report failure to Log Center
if: failure()
run: |
@ -84,35 +161,42 @@ jobs:
DEPLOY_LOG=""
FAILED_STEP="unknown"
if [ -f /tmp/build.log ]; then
BUILD_LOG=$(tail -50 /tmp/build.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
if [[ "${{ steps.build_backend.outcome }}" == "failure" || "${{ steps.build_admin.outcome }}" == "failure" ]]; then
FAILED_STEP="build"
fi
if [ -f /tmp/deploy.log ]; then
DEPLOY_LOG=$(tail -50 /tmp/deploy.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
if [ -n "$DEPLOY_LOG" ]; then
FAILED_STEP="deploy"
if [ -f /tmp/build.log ]; then
BUILD_LOG=$(tail -50 /tmp/build.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
fi
elif [[ "${{ steps.deploy.outcome }}" == "failure" ]]; then
FAILED_STEP="deploy"
if [ -f /tmp/deploy.log ]; then
DEPLOY_LOG=$(tail -50 /tmp/deploy.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
fi
fi
if [ -z "$BUILD_LOG" ] && [ -z "$DEPLOY_LOG" ]; then
BUILD_LOG="No captured output. Check Gitea Actions UI for details."
FAILED_STEP="pre-build"
ERROR_LOG="${BUILD_LOG}${DEPLOY_LOG}"
if [ -z "$ERROR_LOG" ]; then
ERROR_LOG="No captured output. Check Gitea Actions UI for details."
fi
ERROR_LOG="${BUILD_LOG}${DEPLOY_LOG}"
if [[ "$FAILED_STEP" == "deploy" ]]; then
SOURCE="deployment"
ERROR_TYPE="DeployError"
else
SOURCE="cicd"
ERROR_TYPE="DockerBuildError"
fi
curl -s -X POST "https://qiyuan-log-center-api.airlabs.art/api/v1/logs/report" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"lty\",
\"environment\": \"${{ github.ref_name }}\",
\"environment\": \"${{ env.DEPLOY_ENV }}\",
\"level\": \"ERROR\",
\"source\": \"cicd\",
\"source\": \"${SOURCE}\",
\"commit_hash\": \"${{ github.sha }}\",
\"repo_url\": \"https://gitea.airlabs.art/zyc/lty.git\",
\"error\": {
\"type\": \"CICDFailure\",
\"type\": \"${ERROR_TYPE}\",
\"message\": \"[${FAILED_STEP}] Build and Deploy failed on branch ${{ github.ref_name }}\",
\"stack_trace\": [\"${ERROR_LOG}\"]
},
@ -120,9 +204,20 @@ jobs:
\"job_name\": \"build-and-deploy\",
\"step_name\": \"${FAILED_STEP}\",
\"workflow\": \"${{ github.workflow }}\",
\"run_id\": \"${{ github.run_id }}\",
\"run_id\": \"${{ github.run_number }}\",
\"branch\": \"${{ github.ref_name }}\",
\"actor\": \"${{ github.actor }}\",
\"commit\": \"${{ github.sha }}\"
\"commit\": \"${{ github.sha }}\",
\"run_url\": \"https://gitea.airlabs.art/${{ github.repository }}/actions/runs/${{ github.run_number }}\"
}
}" || true
# ===== Cleanup: remove unused Docker resources =====
- name: Docker Cleanup
if: always()
run: |
docker container prune -f
docker image prune -f
docker builder prune -a -f
echo "Disk usage after cleanup:"
df -h / | tail -1

View File

@ -0,0 +1,313 @@
# 好感度系统功能与规则设计
> 文档范围:本文档描述**洛天依管理系统**中「好感度系统」的全部功能模块与规则设计,用于产品对齐、联调对接与后续服务端落地。
>
> 当前状态:管理后台 UI 已完成(见 [qy-lty-admin/app/affinity/page.tsx](../qy-lty-admin/app/affinity/page.tsx)),后端模型骨架已就绪(见 [qy_lty/userapp/models.py](../qy_lty/userapp/models.py) 第 79115 行),但前端尚未实际调用真实接口,设备端/手机端事件尚未接入好感度逻辑。
---
## 一、系统定位
好感度系统用于刻画**用户与洛天依**之间的亲密度关系。其核心价值:
- 让用户的互动行为产生**持续、可感知的数值反馈**
- 通过**等级 + 解锁内容**形成长期陪伴动机
- 对不活跃用户通过**衰减机制**保留流失预警与召回空间
好感度是一个 `[0, max_affinity]` 区间的整数(默认上限 100每个用户独立维护记录在 `ParadiseUser.favorability` 字段。
---
## 二、功能模块总览
管理后台分 4 个标签页,对应 5 块功能:
| 模块 | 页签 | 功能说明 |
|---|---|---|
| 系统概览 | 系统概览 | 关键指标卡片 + 全局基础参数设置 |
| 互动规则 | 互动规则 | 管理各类互动行为的好感度变化规则 |
| 衰减规则 | 互动规则页下半 | 配置不活跃用户的好感度衰减策略 |
| 等级奖励 | 等级奖励 | 管理好感度等级划分与奖励发放 |
| 数据统计 | 数据统计 | 好感度分布、互动分析、趋势监控 |
---
## 三、模块 1 — 系统概览与全局参数
### 3.1 关键指标卡片
| 指标 | 含义 | 数据源 |
|---|---|---|
| 平均好感度 | 所有用户 `favorability` 平均值 | 聚合 `ParadiseUser` |
| 最高好感度 | 系统中达到上限的用户数 | `favorability = max_affinity` 计数 |
| 互动次数/日 | 当天触发的所有规则次数总和 | 聚合 `AffinityLog`(待建) |
| 活跃用户比例 | 近 N 天有互动的用户占比 | 聚合用户活跃状态 |
### 3.2 全局参数AffinitySetting
| 字段 | 默认值 | 说明 |
|---|---|---|
| `initial_affinity` | 10 | 新用户创建时的初始好感度 |
| `max_affinity` | 100 | 好感度上限(所有规则累计不会超过此值) |
| `daily_cap` | 20 | 单用户每日好感度**净增长**上限(跨规则汇总) |
| `decay_rate` | 2 点/天 | 全局默认衰减速率(衰减规则可覆盖) |
| `decay_threshold` | 3 天 | 不互动多少天后开始衰减 |
| `enable_notify` | true | 好感度变化是否推送通知 |
| `enable_rewards` | true | 是否启用等级奖励发放 |
**规则**
- `initial_affinity` 只影响**新用户**,不回溯修改已有用户
- `max_affinity` 调低后,**已超过的用户保留原值**,但不再增加
- `daily_cap` 是**跨所有正向规则**的顶层限制,触达后当日所有增益无效(衰减不受此限)
---
## 四、模块 2 — 互动规则
### 4.1 规则字段定义
| 字段 | 类型 | 示例 | 说明 |
|---|---|---|---|
| `id` | string | `rule-2` | 规则唯一 ID |
| `name` | string | `对话` | 规则展示名 |
| `type` / `rule_key` | enum | `chat` | **代码标识**,服务端事件通过它匹配规则,不可重复 |
| `description` | string | `与洛天依进行对话` | 展示描述 |
| `minChange` | int | 1 | 单次好感度变化最小值 |
| `maxChange` | int | 5 | 单次好感度变化最大值(最终取 `[min, max]` 的随机整数) |
| `singleCap` | int | 5 | 单次变化绝对值上限(保护性钳位,防止数据异常) |
| `dailyCap` | int | 15 | **本规则**每日累计变化上限(绝对值) |
| `isNegative` | bool | false | 是否负向规则。正向用正数,负向用负数 |
| `isEnabled` | bool | true | 是否启用,禁用后事件不触发 |
### 4.2 默认规则集8 条)
| rule_key | 名称 | 范围 | 单次/日 | 正负 | 触发来源 |
|---|---|---|---|---|---|
| `card` | 使用卡片 | +1 ~ +3 | 3 / 10 | 正 | 手机端(使用卡片 API |
| `chat` | 对话 | +1 ~ +5 | 5 / 15 | 正 | 设备端 + 手机端(聊天消息) |
| `feed` | 喂食 | +2 ~ +8 | 8 / 16 | 正 | 手机端(喂食动作) |
| `touch` | 抚摸 | +1 ~ +3 | 3 / 9 | 正 | 手机端 / 设备端(抚摸信号) |
| `dress` | 换装 | +2 ~ +6 | 6 / 12 | 正 | 手机端(换装 API |
| `prop` | 使用道具 | +1 ~ +4 | 4 / 12 | 正 | 手机端(道具使用) |
| `gift` | 送礼物 | +5 ~ +15 | 15 / 20 | 正 | 手机端(赠礼 API |
| `decay` | 无互动衰减 | 1 ~ 3 | 3 / 5 | 负 | 定时任务 |
### 4.3 触发计算流程
```
收到事件 (user_id, rule_key, 来源上下文)
① 取规则 → 若 is_enabled=false丢弃
② 冷却检查Redisaffinity:cd:{user}:{rule_key}
│ 未到冷却 → 丢弃
③ 本规则日上限检查affinity:daily:{user}:{rule_key}:{date}
│ 已满 → 丢弃
④ 全局日上限检查(正向事件才检查)
│ 已满 → 丢弃
⑤ 计算变化值 = random(min, max)
↓ 按 single_cap 钳位
⑥ 原子更新 ParadiseUser.favorability
↓ 钳位到 [0, max_affinity]
⑦ 写 AffinityLog、更新计数器
⑧ 判断是否跨越等级边界
│ 是 → 触发等级变更事件(发奖励 + 推通知)
⑨ 通过 WebSocket 向用户的所有在线端推送 affinity_update
```
### 4.4 规则设计约定
- **单一写入入口**:所有好感度变化必须经由服务端统一入口,客户端不能直接增减。
- **rule_key 即契约**:客户端事件不携带分值,只报「我触发了 `gift` 规则」,具体加多少由服务端按规则算。规则可被管理员随时调整,客户端无需改动。
- **幂等防护**:同一 `rule_key` + 同一设备事件 ID 在冷却窗口内只生效一次,防抖防重复。
- **禁用规则的兜底**:管理员禁用某规则后,客户端若继续上报该事件,服务端静默丢弃(不报错、不扣冷却)。
---
## 五、模块 3 — 衰减规则
衰减是**本质为 `rule_key=decay` 的负向规则**,但由于业务语义特殊,在后台独立配置。
### 5.1 衰减字段
| 字段 | 默认值 | 说明 |
|---|---|---|
| `decay_start_days` | 3 | 不互动多少天后开始衰减 |
| `decay_rate_per_day` | 2 点/天 | 平均每日衰减点数 |
| `min_decay` | 1 | 单日衰减最小值 |
| `max_decay` | 3 | 单日衰减最大值 |
| `decay_cap` | 5 点/天 | 单日衰减上限(保护性) |
| `min_floor` | 0 | **衰减下限**,好感度不会低于此值 |
| `notify_decay` | true | 是否通知用户「好感度下降了」 |
### 5.2 衰减执行
- **频次**:每日 00:30 由定时任务统一跑一次
- **命中对象**`last_active_at < now - decay_start_days` 的用户
- **落库**:衰减也写 `AffinityLog``source='system_decay'`
- **下限保护**:若用户当前好感度 ≤ `min_floor` 则跳过
- **与互动的关系**:用户当天有互动即**重置不活跃计数**,次日不衰减
### 5.3 设计权衡
- 衰减**不占用** `daily_cap` 全局日上限(因为它是扣减,不是增益)
- 衰减日志会产生大量记录,可考虑按天合并写一条,减少 `AffinityLog` 膨胀
- 若「当日互动」和「当日衰减」同时命中,先执行衰减再执行互动(让用户感受到「我回来了 → 好感度止跌回升」)
---
## 六、模块 4 — 等级奖励
### 6.1 等级字段
| 字段 | 示例 | 说明 |
|---|---|---|
| `level` | 3 | 等级序号,唯一 |
| `name` | 熟悉 | 等级名 |
| `minAffinity` / `maxAffinity` | 41 / 60 | 等级好感度区间(闭区间) |
| `unlockContent` | `更多服装、特殊对话` | 文案描述,前端展示 |
| `rewardType` | `unlock` / `item` / `currency` / `mixed` | 奖励类型 |
| `rewardCurrency` | 100 | 虚拟货币数量rewardType 含 currency 时生效) |
| `rewardItems` | `[{item_id, qty}]` | 道具列表rewardType 含 item 时生效) |
| `isEnabled` | true | 是否启用该等级 |
### 6.2 默认等级5 档)
| 等级 | 名称 | 区间 | 解锁内容 |
|---|---|---|---|
| 1 | 初识 | 0 ~ 20 | 基础对话功能 |
| 2 | 相识 | 21 ~ 40 | 基础服装、道具使用 |
| 3 | 熟悉 | 41 ~ 60 | 更多服装、特殊对话 |
| 4 | 亲密 | 61 ~ 80 | 限定服装、特殊互动 |
| 5 | 挚友 | 81 ~ 100 | 专属内容、特殊剧情 |
### 6.3 等级变化规则
- 等级由好感度区间**自动映射**,不是独立字段
- **跨级判定**:每次好感度变动后,取当前值所属区间,与上一次等级比较
- 升级 → 发放目标等级的奖励(**只发最终落点等级**,跳级不补发中间等级)
- 降级(衰减导致) → 不追回奖励,但取消解锁内容访问权限
- **奖励幂等**:同一用户同一等级的「首次达到奖励」只发一次;降级后再升级不重复发放
### 6.4 区间约束
- 区间**不得重叠**,管理端保存时做校验
- 区间**不得有空隙**(如等级 2 的 max=40则等级 3 的 min 必须是 41
- 最低等级 `min=0`,最高等级 `max=max_affinity`
---
## 七、模块 5 — 数据统计
### 7.1 概览指标
- 平均好感度 / 中位数 / 最高好感度
- 活跃用户数(近 7 日有互动)
- 今日互动次数、今日新增好感度总量
### 7.2 分布分析
- 好感度区间分布0-20、21-40…
- 各等级用户数占比
- 各互动规则触发频次 Top N
### 7.3 趋势分析
- 日/周/月的平均好感度变化曲线
- 日互动量趋势
- 衰减命中用户数趋势
### 7.4 用户级查询
- 按用户 ID 查询其好感度当前值、等级、近期变化日志
- 管理员手动调整(加减好感度,走 `source='admin_adjust'` 记 log
---
## 八、多端触发点一览
好感度变化可由以下端点触发,所有端都走**同一个服务端入口**
| 触发来源 | 规则 | 通道 | 位置参考 |
|---|---|---|---|
| 设备端上报「用户发起对话」 | `chat` | WebSocket | [device_interaction/consumers.py](../qy_lty/device_interaction/consumers.py) `chat_message` |
| 设备端对话结束(陪伴时长) | `chat` 或独立 `companion_time` | WebSocket | `conversation_status` 的 begin/end |
| 手机端点击「唱歌/跳舞/抚摸」 | 对应 rule_key | WebSocket | consumers.py `sing` / `dance` / `touch` |
| 手机端赠礼 / 喂食 / 换装 / 用道具 | 对应 rule_key | HTTP | 对应业务 ViewSet 钩子 |
| 管理员手动调整 | 无 rule | HTTP Admin API | 管理后台 |
| 衰减定时任务 | `decay` | 后台任务 | 定时调度 |
**身份识别**
- 设备端MAC 登录获取 token → 服务端通过 `UserDevice` 找到 `user_id`
- 手机端Redis token 认证 → 直接拿到 `user_id`
- 服务端只认 `user_id`,与触发端无关
---
## 九、数据契约(接口层摘要)
### 9.1 管理端
| 接口 | 方法 | 用途 |
|---|---|---|
| `/api/admin/affinity/rules/` | GET/POST/PATCH/DELETE | 互动规则 CRUD |
| `/api/admin/affinity/levels/` | GET/POST/PATCH/DELETE | 等级 CRUD |
| `/api/admin/affinity/settings/` | GET/PUT | 全局参数(单例) |
| `/api/admin/affinity/logs/` | GET | 变化日志查询(可按 user、rule、时间过滤 |
| `/api/admin/affinity/stats/` | GET | 统计聚合 |
| `/api/admin/affinity/adjust/` | POST | 管理员手动调整(必须留审计) |
### 9.2 客户端(手机端 / 设备端共用)
| 接口 | 方法 | 用途 |
|---|---|---|
| `/api/user/me/affinity/` | GET | 当前好感度、等级、下一级进度、近期变化 |
| `/api/user/me/affinity/claim-reward/` | POST | 领取等级奖励 |
### 9.3 WebSocket 实时推送
| 事件 | 方向 | payload |
|---|---|---|
| `affinity_update` | 服务端 → 用户 | `{change, before, after, rule_key, source}` |
| `level_up` | 服务端 → 用户 | `{old_level, new_level, reward}` |
| `level_down` | 服务端 → 用户 | `{old_level, new_level}`(衰减导致降级) |
---
## 十、待开发拼板
| 模块 | 前端 | 后端模型 | 后端接口 | 触发埋点 |
|---|---|---|---|---|
| 互动规则 CRUD | ✅ UI 完成 | ⚠️ 缺字段 | ⚠️ 需扩展 | — |
| 等级 CRUD | ✅ UI 完成 | ⚠️ 缺字段 | ⚠️ 需扩展 | — |
| 全局设置 | ✅ UI 完成 | ❌ 无表 | ❌ 无接口 | — |
| 衰减配置 | ✅ UI 完成 | ❌ 无表 | ❌ 无接口 | ❌ 无定时任务 |
| 变化日志 | ❌ 无 UI | ❌ 无表 | ❌ 无接口 | — |
| 数据统计 | ⚠️ Mock 展示 | — | ❌ 无聚合接口 | — |
| 客户端查询 | — | — | ❌ 无接口 | — |
| WS 实时推送 | — | — | — | ❌ 未接入 |
| 设备/手机事件埋点 | — | — | — | ❌ 未接入 |
图例:✅ 已完成 ⚠️ 部分完成 ❌ 未开始
---
## 十一、术语表
| 术语 | 含义 |
|---|---|
| rule_key | 互动规则的代码级标识(如 `chat`),客户端事件通过它匹配规则 |
| single_cap | 单次变化绝对值上限(保护性钳位) |
| daily_cap | 单规则每日累计变化上限 |
| 全局日上限 | 跨所有正向规则的顶层日增长上限(`AffinitySetting.daily_cap` |
| 冷却 | 同一用户同一规则的最小触发间隔 |
| source | 变化来源:`device_event` / `mobile_event` / `system_decay` / `admin_adjust` |
| 跨级 | 好感度变化使得用户从一个等级区间移动到另一个 |

17
k8s/traefik-config.yaml Normal file
View File

@ -0,0 +1,17 @@
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
logs:
access:
enabled: true
format: json
fields:
headers:
defaultmode: keep
names:
Authorization: drop
Cookie: drop

View File

@ -1,5 +1,5 @@
# 构建阶段
FROM node:22.10.0-alpine AS builder
FROM docker.m.daocloud.io/node:22.10.0-alpine AS builder
# 设置工作目录
WORKDIR /app
@ -21,7 +21,7 @@ COPY . .
RUN yarn build
# 运行阶段
FROM node:22.10.0-alpine AS runner
FROM docker.m.daocloud.io/node:22.10.0-alpine AS runner
# 设置工作目录
WORKDIR /app

View File

@ -1,137 +1,266 @@
# 洛天依应用管理后台
# 洛天依应用管理后台 (qy-lty-admin)
这是一个基于 Next.js 和 React 构建的洛天依应用管理后台系统提供完整的管理功能包括用户管理、AI模型管理、卡牌管理、内容管理等功能
洛天依 APP 配套的 Web 管理后台,基于 **Next.js 15 (App Router) + React 19 + TypeScript** 构建,搭配 Tailwind CSS 与 Radix UI/shadcn 风格组件库为运营与研发团队提供用户、AI 模型、卡牌、内容、权限等一体化的管理能力。后端 API 由 [qy_lty](../qy_lty/) 服务器项目统一提供(位于 `C:\Users\admin\Desktop\Lila-Server\qy_lty`
## 功能特点
---
- **用户管理**:查看用户数据、活跃用户统计
- **AI模型管理**:大模型框架系统、模型微调、语音克隆与合成、知识库管理
- **卡牌管理**:服装卡牌、道具卡牌、家居装饰卡牌、食物卡牌
- **内容管理**:歌曲管理、舞蹈管理、好感度系统、成就系统
- **数据分析**:用户活跃度统计、系统运行概览
- **安全认证**:用户登录/注册系统
## 目录
- [功能概览](#功能概览)
- [技术栈](#技术栈)
- [权限矩阵](#权限矩阵)
- [快速开始](#快速开始)
- [环境变量](#环境变量)
- [项目结构](#项目结构)
- [鉴权与路由保护](#鉴权与路由保护)
- [API 集成](#api-集成)
- [构建与部署](#构建与部署)
- [浏览器支持](#浏览器支持)
- [许可证](#许可证)
---
## 功能概览
根据左侧导航及 [lib/permissions.ts](lib/permissions.ts) 中声明的权限模块,后台分为三大功能分组:
### AI 管理
- **大模型管理** `/ai-model` — 大模型框架接入、微调、语音克隆与合成、知识库管理入口
### 内容管理
- **服装管理** `/outfits`
- **道具管理** `/props`
- **家居装饰管理** `/home-decor`
- **食物管理** `/food`
- **歌曲管理** `/songs`
- **舞蹈管理** `/dances`
- **成就管理** `/achievements`
- **好感度系统** `/affinity`
### 系统管理
- **用户管理** `/users` — 用户列表、检索、创建/编辑/删除、登录历史
- **权限管理** `/permissions` — 角色与模块访问控制
- **系统设置** `/settings`
### 仪表盘
首页 [app/page.tsx](app/page.tsx) 汇总关键指标(总用户、活跃用户、卡牌激活、对话次数)、系统概览图表与最近活动,并提供主要业务模块的快捷入口。
### 账户
- 登录 `/login`、注册 `/register`、忘记密码 `/forgot-password`
---
## 技术栈
- [Next.js](https://nextjs.org/) - React 框架
- [React](https://reactjs.org/) - UI 库
- [Tailwind CSS](https://tailwindcss.com/) - 样式框架
- [Radix UI](https://www.radix-ui.com/) - 无障碍组件库
- [Lucide React](https://lucide.dev/) - 图标库
- [Recharts](https://recharts.org/) - 图表库
| 类别 | 选型 |
|------|------|
| 框架 | [Next.js 15.2.4](https://nextjs.org/)App Router、standalone 输出)|
| 语言 | TypeScript 5 |
| UI 库 | [React 19](https://react.dev/) |
| 样式 | [Tailwind CSS 3.4](https://tailwindcss.com/) + `tailwindcss-animate` |
| 组件 | [Radix UI](https://www.radix-ui.com/) + shadcn 风格封装(见 [components/ui/](components/ui/)|
| 图标 | [Lucide React](https://lucide.dev/) |
| 图表 | [Recharts](https://recharts.org/) |
| 表单 | [React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/) + `@hookform/resolvers` |
| HTTP 客户端 | [Axios](https://axios-http.com/)(含请求/响应拦截器)|
| 通知 | [Sonner](https://sonner.emilkowal.ski/) + Radix Toast |
| 主题 | [next-themes](https://github.com/pacocoursey/next-themes) |
| 其他 | `js-cookie``date-fns``react-day-picker``embla-carousel-react``cmdk``vaul` 等 |
| 包管理 | 兼容 npm / yarn / pnpmDockerfile 使用 yarn + 淘宝镜像源)|
## 安装与运行
---
### 前提条件
## 权限矩阵
- Node.js 22.x 或更高版本
- npm 或 yarn 或 pnpm
基于角色 (RBAC) 控制模块访问,定义位于 [lib/permissions.ts](lib/permissions.ts)。角色信息存储在 `localStorage.user_role`,由侧边栏 [components/sidebar.tsx](components/sidebar.tsx) 在客户端根据角色过滤可见菜单。
### 安装步骤
| 模块 / 角色 | 超级管理员 | 内容管理员 | AI模型管理员 | 卡牌管理员 | 查看者 |
|------------|:----------:|:----------:|:------------:|:----------:|:------:|
| 仪表盘 | ✓ | ✓ | ✓ | ✓ | ✓ |
| 用户管理 | ✓ | | | | |
| 权限管理 | ✓ | | | | |
| AI 模型管理 | ✓ | | ✓ | | |
| 服装 / 道具 / 家居 / 食物 | ✓ | ✓ | | ✓ | |
| 歌曲 / 舞蹈 / 成就 / 好感度 | ✓ | ✓ | | | |
| 系统设置 | ✓ | | | | |
1. 克隆仓库
```bash
git clone <repository-url>
cd admin-dashboard
```
辅助 API`getUserRole()``getAllowedModules()``hasPermission(module)``hasPathPermission(pathname)`
2. 安装依赖
```bash
npm install
# 或
yarn
# 或
pnpm install
```
---
3. 启动开发服务器
```bash
npm run dev
# 或
yarn dev
# 或
pnpm dev
```
## 快速开始
4. 打开浏览器访问 http://localhost:3000
### 环境要求
- Node.js **22.x** 及以上(与 Dockerfile 中的 `node:22.10.0-alpine` 对齐)
- 包管理器npm / yarn / pnpm 任意其一
- 可访问的 Lila-Server API 服务
## 构建与部署
### 本地开发
```bash
# 构建项目
npm run build
# 或
yarn build
# 或
pnpm build
# 1. 安装依赖
pnpm install # 或 yarn / npm install
# 启动生产环境服务器
npm run start
#
yarn start
#
pnpm start
# 2. 配置环境变量
cp .env.example .env.local
# 编辑 .env.local填入 NEXT_PUBLIC_API_BASE_URL
# 3. 启动开发服务器(默认 http://localhost:3000
pnpm dev # 或 yarn dev / npm run dev
```
## 项目结构
### 可用脚本
| 命令 | 说明 |
|------|------|
| `next dev` | 启动开发服务器HMR|
| `next build` | 生产构建(启用 `webpackBuildWorker` 与并行编译)|
| `next start` | 启动生产服务器 |
| `next lint` | ESLint 检查(构建时已通过 `ignoreDuringBuilds` 忽略)|
> 注意:[next.config.mjs](next.config.mjs) 中 `eslint.ignoreDuringBuilds``typescript.ignoreBuildErrors` 均为 `true`,构建不会因 lint / 类型错误而失败,务必在本地执行 `next lint``tsc --noEmit` 保证质量。
---
## 环境变量
采用 Next.js 标准的环境变量机制,加载优先级(从低到高):
```
admin-dashboard/
├── app/ # Next.js 应用程序目录
│ ├── ai-model/ # AI模型管理相关页面
│ ├── achievements/ # 成就系统页面
│ ├── affinity/ # 好感度系统页面
│ ├── dances/ # 舞蹈管理页面
│ ├── food/ # 食物卡牌管理页面
│ ├── home-decor/ # 家居装饰卡牌管理页面
│ ├── login/ # 登录页面
│ ├── outfits/ # 服装卡牌管理页面
│ ├── permissions/ # 权限管理页面
│ ├── props/ # 道具卡牌管理页面
│ ├── register/ # 注册页面
│ ├── settings/ # 系统设置页面
│ ├── songs/ # 歌曲管理页面
│ └── users/ # 用户管理页面
├── components/ # React 组件
├── hooks/ # 自定义 React hooks
├── lib/ # 工具函数和辅助库
├── public/ # 静态资源
└── styles/ # 全局样式
.env → .env.development / .env.production → .env.local
```
## 浏览器支持
暴露给浏览器的变量必须以 `NEXT_PUBLIC_` 前缀声明。
- Chrome (最新版本)
- Firefox (最新版本)
- Safari (最新版本)
- Edge (最新版本)
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `NEXT_PUBLIC_API_BASE_URL` | 后端 API 基础地址,被 [lib/api/client.ts](lib/api/client.ts) 的 axios 实例使用 | `http://localhost:8000/api` |
## 许可证
[MIT](LICENSE)
## Environment Configuration
This project uses environment variables for configuration across different environments. The configuration files are:
- `.env` - Base environment variables (lowest priority)
- `.env.development` - Development environment variables (used with `next dev`)
- `.env.production` - Production environment variables (used with `next start`)
- `.env.local` - Local overrides for any environment (highest priority)
### Setup
1. Copy the example files to create your environment configuration:
初始化示例(存在 `.env.example` / `.env.local.example` 时):
```bash
cp .env.example .env
cp .env.local.example .env.local
```
2. Update the variables in these files as needed for your environment.
---
3. Environment variables that need to be exposed to the browser should be prefixed with `NEXT_PUBLIC_`.
## 项目结构
### Environment Variables
```
qy-lty-admin/
├── app/ # Next.js App Router 页面
│ ├── page.tsx # 仪表盘首页
│ ├── layout.tsx # 根布局
│ ├── globals.css # 全局样式
│ ├── login/ register/ forgot-password/
│ ├── ai-model/ # AI 模型管理
│ ├── outfits/ props/ home-decor/ food/ # 卡牌管理
│ ├── songs/ dances/ achievements/ affinity/ # 内容管理
│ ├── users/ permissions/ settings/ # 系统管理
├── components/
│ ├── ui/ # shadcn 风格的 Radix UI 封装
│ ├── sidebar.tsx # 带权限过滤的侧边栏
│ ├── dashboard-shell.tsx # 后台外壳布局
│ ├── dashboard-header.tsx # 页头
│ ├── overview.tsx # 仪表盘图表
│ ├── recent-activity.tsx # 最近活动列表
│ ├── stat-card.tsx # 指标卡片
│ ├── *-dialog.tsx # 新增 / 删除 / 发布等对话框
│ └── <module>/ # 按业务模块拆分的业务组件
├── hooks/
│ ├── use-mobile.tsx # 响应式断点 hook
│ └── use-toast.ts # Toast hook
├── lib/
│ ├── api/ # 业务 API 模块(见下节)
│ ├── permissions.ts # 角色 / 权限矩阵
│ └── utils.ts # cn 等通用工具
├── middleware.ts # 基于 cookie 的路由保护
├── public/ # 静态资源
├── styles/ # 附加样式
├── Dockerfile # 多阶段构建builder + runner
├── docker-compose.yml # 容器编排(端口 3030→3000
├── next.config.mjs # standalone 输出、实验项
├── tailwind.config.ts
└── tsconfig.json
```
- `NEXT_PUBLIC_API_BASE_URL`: The base URL for API requests
---
## 鉴权与路由保护
项目采用"Cookie + localStorage + 前端权限矩阵"的三层方案:
1. **Cookie (`auth_token`)** — 由 [middleware.ts](middleware.ts) 读取。受保护路径列表见 `protectedPaths`,未携带 token 时重定向至 `/login?callbackUrl=<original>`
2. **localStorage (`auth_token`)** — 由 [lib/api/client.ts](lib/api/client.ts) 的 axios 请求拦截器在每个请求上自动附加 `Authorization: Bearer <token>`;响应拦截器捕获 `401` 时清除 token 并跳转至 `/login`
3. **前端权限矩阵** — [lib/permissions.ts](lib/permissions.ts) + [components/sidebar.tsx](components/sidebar.tsx),根据 `localStorage.user_role` 过滤菜单与页面入口。
> 服务端Next.js Middleware仅校验 token 是否存在;真正的权限校验由后端 API 在业务接口中执行,前端权限矩阵用于提升 UX 而非安全边界。
---
## API 集成
所有业务 API 模块集中在 [lib/api/](lib/api/)
| 文件 | 职责 |
|------|------|
| `client.ts` | axios 实例、拦截器、基础 URL、Mock 辅助工具 |
| `auth.ts` | 登录 / 注册 / 登出 / Token 管理 |
| `users.ts` / `roles.ts` | 用户与角色 |
| `ai-models.ts` | AI 模型相关接口 |
| `outfits.ts` / `props.ts` / `home-decor.ts` / `food.ts` / `card.ts` | 卡牌体系 |
| `songs.ts` / `dances.ts` / `achievements.ts` / `affinity.ts` | 内容与互动 |
| `upload.ts` | 文件上传 |
| `adapters.ts` | 后端 DTO → 前端 ViewModel 转换 |
| `error-handler.ts` | 统一错误处理 |
| `token-debug.ts` | Token 调试工具 |
| `types.ts` / `song.type.ts` | 共享类型定义 |
| `index.ts` | 聚合导出与 Mock 用户 / 角色 API |
> `index.ts` 中包含 Mock 数据与 `simulateDelay`,供联调或离线开发使用;生产环境应确保实际请求打向 `NEXT_PUBLIC_API_BASE_URL`
---
## 构建与部署
### 本地构建
```bash
pnpm build
pnpm start # 启动生产服务器
```
`next.config.mjs` 配置了 `output: 'standalone'`,构建产物位于 `.next/standalone`,适合作为独立部署单元。
### Docker
项目提供多阶段 [Dockerfile](Dockerfile)builder + runner基于 `node:22.10.0-alpine`,使用淘宝 npm 镜像)与 [docker-compose.yml](docker-compose.yml)
```bash
# 构建镜像并启动
docker-compose up -d --build
# 访问 http://<host>:3030
```
Compose 关键配置:
- 容器名:`lty-admin`
- 端口映射:宿主机 `3030` → 容器 `3000`
- 环境文件:`.env.production`
- 挂载 `./public` 以便热更新静态资源
- `restart: always` 故障自动恢复
部署前确认 `.env.production` 已正确配置 `NEXT_PUBLIC_API_BASE_URL`
---
## 浏览器支持
最新稳定版本的 Chrome / Firefox / Safari / Edge。项目使用 React 19 与现代 CSS 特性,不再适配 IE。
---
## 许可证
[MIT](LICENSE)

View File

@ -1,210 +1,247 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
QY LTY Backend is a comprehensive Django-based backend service that provides:
- User management and authentication system
- AI conversation capabilities (single/multi-turn chat with voice support)
- Card management system with batch generation and QR code functionality
- Device interaction via WebSocket real-time communication
- Achievement system
- Subscription management
- Multiple third-party integrations (Aliyun, Volcengine, Tencent, etc.)
## Development Commands
### Environment Setup
```bash
# Install dependencies
pip install -r requirements.txt
# Copy environment configuration
cp .env.bak .env
# Database migrations
python manage.py migrate
# Create superuser
python manage.py createsuperuser
```
### Running the Application
```bash
# Start development server (ASGI with WebSocket support)
./run.sh
# Or directly with daphne
daphne -b 0.0.0.0 -p 8000 qy_lty.asgi:application
# Traditional Django development server (HTTP only)
python manage.py runserver
```
### Database Management
```bash
# Create new migrations
python manage.py makemigrations
python manage.py makemigrations [app_name]
# Apply migrations
python manage.py migrate
# Database shell
python manage.py dbshell
# Django shell
python manage.py shell
```
### Internationalization (i18n)
```bash
# Generate message files
django-admin makemessages -l en
django-admin makemessages -l zh_HAns
# Compile translations
django-admin compilemessages
```
### Docker Deployment
```bash
# Build and start containers
docker-compose up -d --build
# Access application at http://localhost:12012
```
## Architecture Overview
### Core Django Apps Structure
- **userapp/**: Custom user model (ParadiseUser) with authentication, phone verification, and user management
- **aiapp/**: AI conversation system with Kimi integration, voice synthesis/recognition via multiple providers
- **card/**: Card management system with categories, batches, QR codes, and usage tracking
- **device_interaction/**: WebSocket-based real-time device communication with authentication
- **achievement_app/**: User achievement and progress tracking system
- **subscription_app/**: User subscription and billing management
- **ali_vi_app/**: Alibaba Cloud vision intelligence integration
- **workflow_app/**: Multi-tenant workflow management (in development)
### Key Technical Components
#### ASGI/WebSocket Configuration
- Uses Django Channels for WebSocket support
- Custom token-based WebSocket authentication (`device_interaction.auth.TokenAuthMiddleware`)
- Redis-backed channel layers for real-time messaging
- WebSocket routing: `ws://domain/ws/device/` and `ws://domain/ws/device/token/{token}/`
#### Authentication System
- Custom user model: `userapp.ParadiseUser` extending AbstractUser
- Token-based API authentication
- Phone verification with SMS (Aliyun SMS service)
- Social authentication (WeChat) via django-allauth
#### Database Configuration
- Primary: PostgreSQL (configurable via environment)
- Redis for caching and WebSocket channel layers
- Environment-based configuration using python-decouple
#### Audio/Voice Services
- Multi-provider audio support: Aliyun, Tencent, Volcengine (Huoshan)
- Provider configurable via `AUDIO_SERVICE_PROVIDER` setting
- Voice synthesis and recognition capabilities
## API Documentation
### Access Points
- Swagger UI: `http://localhost:8000/swagger/`
- ReDoc: `http://localhost:8000/redoc/`
### Main API Modules
- `/api/user/` - User authentication and management
- `/api/ai/` - AI conversation endpoints
- `/api/device/` - Device interaction and WebSocket messaging
- `/api/card/` - Card system management
- `/api/achievement/` - Achievement system
- `/api/v1/admin/` - Administrative functions
### WebSocket Message Types
```json
{
"type": "message_type",
"message": "content",
"user_id": "user_id"
}
```
Supported types: `chat_message`, `weather`, `sing`, `dance`
## Environment Configuration
### Required Environment Variables (.env)
- `SECRET_KEY` - Django secret key
- `DEBUG` - Development mode flag
- Database: `POSTGRESQL_DATABASE_*` variables
- Redis: `REDIS_LOCATION`, `REDIS_PASSWORD`
- Kimi AI: `KIMI_API_KEY`, `KIMI_BASE_URL`
- Aliyun services: Various `ALIYUN_*` keys
- Audio services: Provider-specific configuration
- Volcengine: `VOLCENGINE_ACCESS_KEY`, `VOLCENGINE_SECRET_KEY`
## Key Dependencies
### Core Framework
- Django 4.2.13 with Django REST Framework
- Django Channels for WebSocket support
- Daphne ASGI server
### Third-party Integrations
- Aliyun: SMS, OSS, NLS (speech), Vision Intelligence
- Volcengine (ByteDance): RTC services
- Tencent: Audio services
- OpenAI-compatible API (Kimi)
### Development Tools
- django-debug-toolbar for development debugging
- django-simpleui for enhanced admin interface
- drf-yasg for API documentation generation
## Code Patterns and Conventions
### Model Structure
- Custom user model with extensive profile fields (gender, MBTI, interests, etc.)
- Card system uses batch-based generation with category classification
- Achievement system with rarity levels and progress tracking
### API Response Format
- Standardized responses via `common.middleware.StandardResponseMiddleware`
- Custom pagination: `common.pagination.CustomPageNumberPagination`
### Logging
- Aliyun Log Service integration for production logging
- Application-specific loggers for aiapp, userapp, common modules
### File Storage
- OSS (Aliyun Object Storage) for audio and media files
- Local storage for development with configurable paths
## Development Notes
### WebSocket Development
- Device connections require token-based authentication
- Channel layer uses Redis for message passing
- Custom consumers in `device_interaction.consumers`
### Audio Service Integration
- Provider-agnostic interface via `aiapp.audio.AudioService`
- Speech-to-text and text-to-speech capabilities
- File storage integration for audio assets
### Card System Features
- Batch generation with configurable size and format
- QR code generation and scanning
- Usage tracking and analytics
- Category-based organization with attributes
### Admin Interface
- Heavily customized SimpleUI theme
- Multi-language support (Chinese/English)
- Custom icons and organization for different modules
# CLAUDE.md
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指导。
## 项目概述
QY LTY Backend 是一个基于 Django 的综合性后端服务,提供以下功能:
- 用户管理与认证系统
- AI 对话能力(支持语音的单轮/多轮聊天)
- 卡片管理系统,支持批量生成与二维码功能
- 通过 WebSocket 实时通信进行设备交互
- 成就系统
- 订阅管理
- 多种第三方集成(阿里云、火山引擎、腾讯等)
### 对接的客户端项目
本服务器作为统一后端,与以下客户端/管理端项目进行通讯和数据交互:
| 项目 | 路径 | 角色 | 通讯方式 |
|------|------|------|---------|
| **LTY_App_Project_URP** | `C:\Unity2022project\LTY_App_Project_URP` | 洛天依 APPUnity URP 客户端) | HTTP REST API + WebSocket (`/ws/device/`) |
| **LTY_Project** | `C:\Unity2022project\LTY_Project` | 洛天依 Unity 项目(设备端/终端) | HTTP REST API + WebSocket + RTC火山引擎 |
| **qy-lty-admin** | `../qy-lty-admin/``C:\Users\admin\Desktop\Lila-Server\qy-lty-admin` | Web 管理后台Next.js 15 + React 19 | HTTP REST API`NEXT_PUBLIC_API_BASE_URL` |
- 两个 Unity 客户端通过 `device_interaction` 模块共享同一套 WebSocket 通道(分组模型 `device_{user_id}`),设备端与 APP 端解析到同一 user_id 才能互通消息
- 管理后台通过 `/api/v1/admin/` 与各业务模块接口进行运营管理数据交互
## 开发命令
### 环境配置
```bash
# 安装依赖
pip install -r requirements.txt
# 复制环境配置文件
cp .env.bak .env
# 数据库迁移
python manage.py migrate
# 创建超级用户
python manage.py createsuperuser
```
### 运行应用
```bash
# 启动开发服务器(支持 WebSocket 的 ASGI
./run.sh
# 或直接使用 daphne
daphne -b 0.0.0.0 -p 8000 qy_lty.asgi:application
# 传统 Django 开发服务器(仅 HTTP
python manage.py runserver
```
### 数据库管理
```bash
# 创建新的迁移文件
python manage.py makemigrations
python manage.py makemigrations [app_name]
# 应用迁移
python manage.py migrate
# 数据库 shell
python manage.py dbshell
# Django shell
python manage.py shell
```
### 国际化 (i18n)
```bash
# 生成消息文件
django-admin makemessages -l en
django-admin makemessages -l zh_HAns
# 编译翻译
django-admin compilemessages
```
### Docker 部署
```bash
# 构建并启动容器
docker-compose up -d --build
# 通过 http://localhost:12012 访问应用
```
## 架构概览
### 核心 Django 应用结构
- **userapp/**:自定义用户模型 (ParadiseUser),包含认证、手机验证和用户管理
- **aiapp/**AI 对话系统,集成 Kimi通过多个服务商提供语音合成/识别
- **card/**:卡片管理系统,包含分类、批次、二维码和使用追踪
- **device_interaction/**:基于 WebSocket 的实时设备通信,带有认证机制
- **achievement_app/**:用户成就与进度追踪系统
- **subscription_app/**:用户订阅与计费管理
- **ali_vi_app/**:阿里云视觉智能集成
- **workflow_app/**:多租户工作流管理(开发中)
### 关键技术组件
#### ASGI/WebSocket 配置
- 使用 Django Channels 提供 WebSocket 支持
- 自定义基于 token 的 WebSocket 认证(`device_interaction.auth.TokenAuthMiddleware`,复用 `userapp.authentication.RedisTokenAuthentication`
- 基于 Redis 的通道层用于实时消息传递
- WebSocket 路由:`ws://domain/ws/device/`Header 鉴权)和 `ws://domain/ws/device/token/{token}/`URL 鉴权)
- **分组模型**`device_{user_id}` —— 设备端与手机端必须解析到 **同一个 user_id** 才能互通消息(详见下文"设备绑定与控制权"
#### 认证系统
- 自定义用户模型:`userapp.ParadiseUser`,继承自 AbstractUser
- 基于 token 的 API 认证token 存储在 Redis 中,普通用户 key 为 `token:{token}`,管理员为 `admin_token:{token}`TTL 30 天(见 `userapp/utils.py:generate_token`
- 通过短信进行手机验证(阿里云 SMS 服务)
- 通过 django-allauth 实现社交认证(微信)
- 设备端通过 `POST /api/user/mac-login/` 用 MAC 地址换取绑定用户的 user-token
#### 数据库配置
- 主库PostgreSQL可通过环境变量配置
- Redis 用于缓存和 WebSocket 通道层
- 使用 python-decouple 进行基于环境的配置
#### 音频/语音服务
- 多服务商音频支持:阿里云、腾讯、火山引擎
- 通过 `AUDIO_SERVICE_PROVIDER` 配置切换服务商
- 支持语音合成与识别能力
## API 文档
### 访问入口
- Swagger UI`http://localhost:8000/swagger/`
- ReDoc`http://localhost:8000/redoc/`
### 主要 API 模块
- `/api/user/` - 用户认证与管理
- `/api/ai/` - AI 对话接口
- `/api/device/` - 设备交互与 WebSocket 消息
- `/api/card/` - 卡片系统管理
- `/api/achievement/` - 成就系统
- `/api/v1/admin/` - 管理功能
### WebSocket 消息类型
```json
{
"type": "message_type",
"message": "content",
"user_id": "user_id"
}
```
支持的类型(见 `device_interaction/consumers.py:DeviceConsumer.receive`
- `chat_message` — 默认文本消息
- `weather` — 天气信息message 为 JSON 字符串)
- `sing` / `dance` / `touch` — 动作指令
- `flow_light` — 流水灯开关
- `device_info` — 设备上报 MAC、电量、固件、WiFi 等,会写库并刷新心跳
- `device_state` — 手机端设备状态
- `conversation_status` / `conversation_subtitle` — 火山引擎对话回调转发
- `factory_reset` — 恢复出厂设置
### RTC火山引擎
- 端点 `/api/device/rtc-token/get_by_mac/?mac_address=...` 不需鉴权,根据 MAC 返回该设备绑定用户对应的 RTC token
- `room_id = f"room_{user_id}"`、token 缓存 key 为 `rtc_room:{user_id}:{task_id}`
- `room_id` 与 WebSocket 分组绑定的是同一个 user_id保持端到端一致
## 环境配置
### 必需的环境变量 (.env)
- `SECRET_KEY` - Django 密钥
- `DEBUG` - 开发模式标志
- 数据库:`POSTGRESQL_DATABASE_*` 系列变量
- Redis`REDIS_LOCATION``REDIS_PASSWORD`
- Kimi AI`KIMI_API_KEY``KIMI_BASE_URL`
- 阿里云服务:各类 `ALIYUN_*` 密钥
- 音频服务:服务商相关配置
- 火山引擎:`VOLCENGINE_ACCESS_KEY``VOLCENGINE_SECRET_KEY`
## 关键依赖
### 核心框架
- Django 4.2.13 配合 Django REST Framework
- Django Channels 提供 WebSocket 支持
- Daphne ASGI 服务器
### 第三方集成
- 阿里云SMS、OSS、NLS语音、视觉智能
- 火山引擎字节跳动RTC 服务
- 腾讯:音频服务
- OpenAI 兼容 APIKimi
### 开发工具
- django-debug-toolbar 用于开发环境调试
- django-simpleui 用于增强后台管理界面
- drf-yasg 用于生成 API 文档
## 代码模式与约定
### 模型结构
- 自定义用户模型包含丰富的资料字段性别、MBTI、兴趣等
- 卡片系统采用基于批次的生成方式,配合分类管理
- 成就系统包含稀有度等级与进度追踪
### API 响应格式
- 通过 `common.middleware.StandardResponseMiddleware` 实现标准化响应
- 自定义分页:`common.pagination.CustomPageNumberPagination`
### 日志
- 集成阿里云日志服务用于生产环境日志记录
- 为 aiapp、userapp、common 模块配置专用日志记录器
### 文件存储
- 使用 OSS阿里云对象存储存储音频和媒体文件
- 开发环境使用本地存储,路径可配置
## 开发说明
### WebSocket 开发
- 设备连接需要基于 token 的认证
- 通道层使用 Redis 进行消息传递
- 自定义消费者位于 `device_interaction.consumers`
- 设备心跳:每次收消息时刷新 `device:last_seen:{mac}`TTL 5 分钟),断连时把 `Device.status` 标记为 `disconnected`
### 设备绑定与控制权(重要)
- `UserDevice` 关联表的 `Meta.ordering = ['-bound_at']`
- **"后绑的挤掉先绑的"语义**`userapp/views.py` 的 MAC 登录显式按 `order_by('-bound_at').first()` 取最新绑定者并签发 user-token`device_interaction/views.py` 中的 `bind_status` / `rtc-token/get_by_mac` 等使用 `.first()` 隐式依赖该 ordering结果一致
- 由于 WebSocket 分组是 `device_{user_id}`**同一台设备同一时刻只有一个用户能真正控制它**——即最近一次绑定的那个用户
- 旧的 `UserDevice` 记录**不会**被自动删除,仅在控制权解析中被忽略;如需"换绑"语义请显式删除旧记录
- `is_primary` 是"用户视角的主设备"(每个用户最多一个),**不是**"设备视角的主控用户"——同一台设备可能出现多条 `is_primary=True` 的记录
- **测试 MAC `AA:BB:CC:DD:EE:FF`**`device_interaction/serializers.py``views.py` 中被硬编码跳过"设备已被其他用户绑定"校验,仅供测试用
### 音频服务集成
- 通过 `aiapp.audio.AudioService` 提供与服务商无关的接口
- 支持语音转文本与文本转语音能力
- 集成文件存储用于音频资源管理
### 卡片系统功能
- 批量生成,支持配置数量与格式
- 二维码生成与扫描
- 使用追踪与数据分析
- 基于分类的组织管理,支持属性配置
### 后台管理界面
- 深度定制 SimpleUI 主题
- 支持多语言(中文/英文)
- 为不同模块配置自定义图标与组织结构

View File

@ -1,5 +1,5 @@
# 使用国内的 Python 3.8 镜像作为基础镜像
FROM python:3.8
FROM docker.m.daocloud.io/python:3.8
# 设置工作目录为 /app
WORKDIR /app

View File

@ -0,0 +1,32 @@
from django.db import migrations
def create_rtc_bot(apps, schema_editor):
Bot = apps.get_model('aiapp', 'Bot')
if not Bot.objects.filter(name='RTC_Voice_Agent').exists():
# 手动指定一个不会冲突的 id
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM aiapp_bot")
next_id = cursor.fetchone()[0]
Bot.objects.create(
id=next_id,
name='RTC_Voice_Agent',
description='RTC实时语音智能体'
)
def remove_rtc_bot(apps, schema_editor):
Bot = apps.get_model('aiapp', 'Bot')
Bot.objects.filter(name='RTC_Voice_Agent').delete()
class Migration(migrations.Migration):
dependencies = [
('aiapp', '0002_initial'),
]
operations = [
migrations.RunPython(create_rtc_bot, remove_rtc_bot),
]

View File

@ -1,6 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ChatBotAPIView, MultiChatAPIView, BotViewSet
from .views import ChatBotAPIView, MultiChatAPIView, BotViewSet, RTCChatHistoryAPIView
router = DefaultRouter()
router.register(r'bots', BotViewSet, basename='bot')
@ -9,4 +9,5 @@ urlpatterns = [
path('', include(router.urls)),
path('chat/<int:bot_id>/', ChatBotAPIView.as_view(), name='chat-bot'),
path('multichat/', MultiChatAPIView.as_view(), name='multi-chat'),
path('rtc-chat-history/', RTCChatHistoryAPIView.as_view(), name='rtc-chat-history'),
]

View File

@ -9,6 +9,7 @@ from rest_framework.permissions import IsAuthenticated
from userapp.authentication import RedisTokenAuthentication
from rest_framework import serializers
from common.swagger_utils import swagger_schema
from common.responses import success_response, created_response, error_response
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
import logging
@ -427,4 +428,89 @@ def create_user_message(user: ParadiseUser, bot: Bot, message_type: str, user_me
logger.info(f"Created video message for user {user.id} and bot {bot.id}")
return user_chat_message
return None
return None
class RTCChatHistoryAPIView(APIView):
"""
RTC 语音智能体聊天记录接口
GET: 获取当前用户的 RTC 聊天历史
POST: 保存一条 RTC 聊天消息
DELETE: 清空当前用户的 RTC 聊天记录
"""
authentication_classes = [RedisTokenAuthentication]
permission_classes = [IsAuthenticated]
RTC_BOT_NAME = 'RTC_Voice_Agent'
def _get_rtc_bot(self):
try:
return Bot.objects.get(name=self.RTC_BOT_NAME)
except Bot.DoesNotExist:
return None
def get(self, request):
bot = self._get_rtc_bot()
if bot is None:
return error_response(message='RTC Bot 未配置', code=500, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
page_size = int(request.query_params.get('page_size', 50))
page_size = min(page_size, 200)
queryset = ChatMessage.objects.filter(
user=request.user,
bot=bot
).order_by('timestamp')
total = queryset.count()
messages = queryset[max(0, total - page_size):]
data = {
'messages': [
{
'id': msg.id,
'message': msg.message,
'sender': msg.sender,
'timestamp': msg.timestamp.isoformat(),
}
for msg in messages
],
'total': total,
'has_more': total > page_size,
}
return success_response(data=data)
def post(self, request):
bot = self._get_rtc_bot()
if bot is None:
return error_response(message='RTC Bot 未配置', code=500, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
message_text = request.data.get('message', '').strip()
sender = request.data.get('sender', '').strip()
if not message_text:
return error_response(message='消息内容不能为空')
if sender not in ('user', 'assistant'):
return error_response(message='sender 必须是 user 或 assistant')
chat_msg = ChatMessage.objects.create(
user=request.user,
bot=bot,
message=message_text,
sender=sender,
message_type=ChatMessage.MESSAGE_TYPE_TEXT,
)
return created_response(data={
'id': chat_msg.id,
'timestamp': chat_msg.timestamp.isoformat(),
})
def delete(self, request):
bot = self._get_rtc_bot()
if bot is None:
return error_response(message='RTC Bot 未配置', code=500, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
count, _ = ChatMessage.objects.filter(user=request.user, bot=bot).delete()
return success_response(data={'deleted': count}, message=f'已删除 {count} 条记录')

View File

@ -354,11 +354,26 @@ class DeviceConsumer(AsyncWebsocketConsumer):
'user_id': self.user_id
}
)
await self.send(text_data=json.dumps({
'status': 'success',
'message': '恢复出厂设置指令已发送'
}))
elif message_type == 'device_state':
# 处理手机设备状态消息
await self.channel_layer.group_send(
self.group_name,
{
'type': 'device_state',
'message': message,
'user_id': self.user_id
}
)
await self.send(text_data=json.dumps({
'status': 'success',
'message': '设备状态消息已发送'
}))
else:
# 处理普通文本消息
await self.channel_layer.group_send(
@ -548,6 +563,23 @@ class DeviceConsumer(AsyncWebsocketConsumer):
except Exception as e:
logger.error(f"Error in factory_reset: {str(e)}")
async def device_state(self, event):
"""
处理手机设备状态消息
将设备状态信息转发给 WebSocket
"""
try:
message = event['message']
user_id = event['user_id']
await self.send(text_data=json.dumps({
'type': 'device_state',
'message': message,
'user_id': user_id
}))
except Exception as e:
logger.error(f"Error in device_state: {str(e)}")
async def device_info(self, event):
"""
处理设备信息上报消息

View File

@ -121,8 +121,8 @@ class DeviceBindSerializer(serializers.Serializer):
except Device.DoesNotExist:
raise serializers.ValidationError("设备不存在")
# 检查设备是否已被其他用户绑定
if UserDevice.objects.filter(device=device).exists():
# 检查设备是否已被其他用户绑定(测试 MAC 跳过此检查)
if value != 'AA:BB:CC:DD:EE:FF' and UserDevice.objects.filter(device=device).exists():
raise serializers.ValidationError("设备已被其他用户绑定")
return value

View File

@ -21,6 +21,8 @@ import datetime
from .volcengine_api import update_voice_chat
from .amap_api import search_nearby
from .models import DeviceType, DeviceBatch, Device, UserDevice
from aiapp.models import ChatMessage, Bot
from userapp.models import ParadiseUser
from .serializers import (
DeviceTypeSerializer, DeviceBatchSerializer, DeviceSerializer,
DeviceCreateSerializer, DeviceBatchCreateSerializer,
@ -45,6 +47,24 @@ from common.swagger_utils import get_standardized_response_schema
import logging
logger = logging.getLogger(__name__)
def _merge_subv_segments(segments):
"""合并字幕分片自动检测累积式每段以前段为前缀vs 增量式(直接拼接)。
segments: 已按 seq 升序排好的 [{seq, text}, ...]
"""
if not segments:
return ''
if len(segments) == 1:
return segments[0]['text']
is_cumulative = all(
segments[i]['text'].startswith(segments[i - 1]['text'])
for i in range(1, len(segments))
)
if is_cumulative:
return segments[-1]['text']
return ''.join(s['text'] for s in segments)
# Create your views here.
# 设备相关视图集
@ -678,8 +698,8 @@ class UserDeviceViewSet(viewsets.ModelViewSet):
message='设备已绑定'
)
# 检查是否已被其他用户绑定
if UserDevice.objects.filter(device=device).exists():
# 检查是否已被其他用户绑定(测试 MAC 跳过此检查)
if mac_address != 'AA:BB:CC:DD:EE:FF' and UserDevice.objects.filter(device=device).exists():
return error_response(message='设备已被其他用户绑定', code=status.HTTP_400_BAD_REQUEST)
# 激活设备
@ -1179,10 +1199,13 @@ class VolcEngineTokenViewSet(viewsets.ViewSet):
'device_code': device.device_code,
'device_type': device.device_type.name
}
# 存储到Redis设置过期时间
cache.set(redis_key, token_data, expire_time)
# 反向索引task_id -> user_id用于字幕回调中 bot01 字幕的 user 归属
cache.set(f"rtc_task_user:{task_id}", user_id, expire_time)
return success_response(
data=token_data,
message='Token生成成功'
@ -1215,6 +1238,12 @@ class VolcEngineTokenViewSet(viewsets.ViewSet):
logger.info("-----------------------------------------")
# 记录回调日志
logger.info('Conversation AI status callback: %s', request.method)
# 永久保留:记录 webhook 上下文headers + query用于字幕归属排查
try:
logger.info('Webhook context: headers=%s, query=%s',
dict(request.headers), dict(request.query_params))
except Exception:
pass
# 处理消息
try:
@ -1291,34 +1320,124 @@ class VolcEngineTokenViewSet(viewsets.ViewSet):
}
subtitle_items.append(subtitle)
logger.info('Subtitle: %s', subtitle)
# === 字幕落库(策略 B按 paragraph 累积拼接 + AI 归属锁)===
try:
# 主路径:尝试从 webhook 上下文提取 task_id火山如带
webhook_task_id = (
request.query_params.get('task_id')
or request.headers.get('X-Volc-Task-Id')
or request.headers.get('X-Task-Id')
or json_data.get('task_id')
)
# 拿 RTC Bot id带缓存避免每条字幕查 DB
rtc_bot_id = cache.get('rtc_voice_agent_bot_id')
if not rtc_bot_id:
rtc_bot_id = Bot.objects.filter(name='RTC_Voice_Agent').values_list('id', flat=True).first()
if rtc_bot_id:
cache.set('rtc_voice_agent_bot_id', rtc_bot_id, 3600)
if not rtc_bot_id:
logger.error('RTC_Voice_Agent Bot 未配置,跳过字幕落库')
else:
MAX_BUFFER_SEGMENTS = 50 # paragraph 始终不来时的兜底强制 flush
for item in subtitle_items:
text = item.get('text') or ''
# 中间识别结果不入库
if not item.get('definite'):
continue
user_id_in_subtitle = item.get('userId') or ''
sequence = item.get('sequence', 0)
is_paragraph_end = bool(item.get('paragraph'))
# Mode=1 下 paragraph=True 是独立的空文本终止信号,必须放过去触发 flush
# 仅当文本空 + 又不是终止信号时才跳过
if not text.strip() and not is_paragraph_end:
continue
# 解析 ParadiseUser 归属
paradise_user_id = None
if user_id_in_subtitle == 'bot01':
# AI 字幕:先看"当前 AI 回合归属锁"
paradise_user_id = cache.get('rtc_current_bot_owner')
if not paradise_user_id:
# 没锁就用主路径/兜底解析,并加锁
if webhook_task_id:
paradise_user_id = cache.get(f"rtc_task_user:{webhook_task_id}")
if not paradise_user_id:
paradise_user_id = cache.get('rtc_last_active_user')
if paradise_user_id:
# 续期归属锁,确保整段 AI 回复内一致(无论中间多长)
cache.set('rtc_current_bot_owner', str(paradise_user_id), 300)
sender = ChatMessage.SENDER_BOT
elif user_id_in_subtitle.isdigit():
paradise_user_id = user_id_in_subtitle
sender = ChatMessage.SENDER_USER
# 用户字幕到达,刷新最近活跃用户(给后续 bot01 兜底)
cache.set('rtc_last_active_user', user_id_in_subtitle, 60)
else:
logger.warning('字幕 userId %r 无法识别,跳过', user_id_in_subtitle)
continue
if not paradise_user_id:
logger.warning('字幕无法归属用户: userId=%s task_id=%s sequence=%s',
user_id_in_subtitle, webhook_task_id, sequence)
continue
# 累积到 buffer仅在文本非空时空文本是 Mode=1 的 paragraph 终止信号,不入 buffer 但要触发 flush
buffer_key = f"rtc_subv_buffer:{sender}:{paradise_user_id}"
buf = cache.get(buffer_key) or []
if text.strip():
buf.append({'seq': sequence, 'text': text})
force_flush = len(buf) > MAX_BUFFER_SEGMENTS
if force_flush:
logger.warning('字幕 buffer 超过 %d 段仍未见 paragraph 边界,强制落库 user=%s sender=%s',
MAX_BUFFER_SEGMENTS, paradise_user_id, sender)
if is_paragraph_end or force_flush:
# 防重:同一 (sender, paradise_user_id, last_sequence) 只落一次
dedup_key = f"rtc_subv_flushed:{sender}:{paradise_user_id}:{sequence}"
if cache.get(dedup_key):
cache.delete(buffer_key)
continue
cache.set(dedup_key, 1, 300)
# 排序拼接(自适应累积/增量)
buf_sorted = sorted(buf, key=lambda x: x.get('seq', 0))
full_text = _merge_subv_segments(buf_sorted).strip()
if full_text:
try:
ChatMessage.objects.create(
user_id=int(paradise_user_id),
bot_id=rtc_bot_id,
message=full_text[:2048],
sender=sender,
message_type=ChatMessage.MESSAGE_TYPE_TEXT,
)
logger.info('字幕落库成功: sender=%s user=%s len=%d segs=%d',
sender, paradise_user_id, len(full_text), len(buf_sorted))
except Exception as e:
logger.error('字幕落库失败: %s, sender=%s, text=%r',
e, sender, full_text[:100])
cache.delete(buffer_key)
else:
# 还在回合中,刷新 buffer TTL
cache.set(buffer_key, buf, 300)
except Exception as e:
logger.error('字幕落库流程异常: %s', e)
# === 字幕落库结束 ===
# 构建响应数据
response_data = {
'type': 'subtitle',
'subtitles': subtitle_items,
'signature': signature
}
# # 可以选择发送WebSocket消息通知前端
# if subtitle_items and len(subtitle_items) > 0 and subtitle_items[0].get('userId'):
# try:
# user_id = subtitle_items[0].get('userId')
# # 构建组名 (假设userId包含真实用户ID)
# group_name = f"device_{user_id}"
# # 发送消息到WebSocket
# channel_layer = get_channel_layer()
# async_to_sync(channel_layer.group_send)(
# group_name,
# {
# 'type': 'conversation_subtitle',
# 'message': response_data
# }
# )
# logger.info('Subtitle message sent via WebSocket to user: %s', user_id)
# except Exception as e:
# logger.error('WebSocket send failed: %s', str(e))
return success_response(data=response_data, message='Subtitle received successfully')
except UnicodeDecodeError as e:
logger.error('Failed to decode subtitle JSON: %s', str(e))
@ -1361,7 +1480,13 @@ class VolcEngineTokenViewSet(viewsets.ViewSet):
# 记录状态信息
logger.info('Conversation status: TaskId=%s, UserID=%s, RoundID=%s, StageCode=%s, Description=%s',
task_id, user_id, round_id, stage_code, stage_description)
# 维护 task->user 反向索引和最近活跃用户(用于 bot01 字幕归属)
if task_id and user_id:
cache.set(f"rtc_task_user:{task_id}", str(user_id), 3600)
if user_id:
cache.set('rtc_last_active_user', str(user_id), 60)
# 根据不同的状态码进行不同的处理
response_data = {
'task_id': task_id,
@ -1379,7 +1504,7 @@ class VolcEngineTokenViewSet(viewsets.ViewSet):
try:
# 构建组名
group_name = f"device_{user_id}"
# 发送消息到WebSocket
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(

View File

@ -0,0 +1,215 @@
# 设备端聊天记录上报功能 — 修改计划(方案 B服务器端字幕落库
## Context
设备端 Unity 项目LTY_Project运行在 RK3588 上)需要把"用户在设备前与 Lila 的语音对话"以文字形式落到服务器,让手机端 App 能在原有 `/api/ai/rtc-chat-history/` 接口里看到。**不需要在设备屏幕显示文字,也不需要打字输入**。
调研后发现:火山引擎对话式 AI 的"服务端字幕回调"链路**已经全部搭好且在跑**
- 设备端 [getJson.cs:70-72](Assets/Scripts/getJson.cs#L70) 已经在 RTC AgentConfig 里配置了 `EnableConversationStateCallback=true``ServerMessageURLForRTS=httpBaseUrl+"api/device/rtc-token/conversation_status/"` —— 火山引擎会把房间内 ASR 字幕(用户和 AI 的)和会话状态推到这个 URL。
- 服务器 [device_interaction/views.py:1199-1421](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1199) 的 `conversation_status` action 已经在接收并解析两种二进制格式:
- `subv`(字幕格式,[views.py:1258-1302](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1258)):包含 `text` / `userId` / `definite` / `paragraph` / `sequence` / `language` / `mode` / `timestamp` —— 其中 `text` 就是用户和 AI 的对话文本。**当前只 log 出来,没有写入数据库**。
- `conv`(对话状态格式,[views.py:1309-1376](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1309)):包含 `TaskId` / `UserID` / `RoundID` / `Stage.Code/Description` —— 已经在通过 WebSocket 转推给设备/手机驱动状态机。
所以方案 B 的本质是:**只在服务器端 `conversation_status` 接口的 `subv` 分支里加几十行写库逻辑**,把字幕文本作为 `ChatMessage` 写到与手机端相同的表里。设备端、手机端、火山引擎一律不动。
## 关键设计决策(已与用户确认)
- **设备端零改动**:不复制 ASR 客户端、不挂 ChatLogManager、不改 LoginRTC、不改 WebSocketConnection。
- **手机端零改动**:手机端 App 现有的 `GET /api/ai/rtc-chat-history/?page_size=50` 在服务器开始落库后会自然返回设备端产生的消息(与手机端 App 自己跟 Lila 聊天的记录混在同一个 user 名下,与之前用户确认的"不加 device_mac 字段"一致)。
- **bot 关联**:设备端字幕也归到 `RTC_Voice_Agent` 这个 Bot 名下(与手机端 App 用同一个 Bot保证 [RTCChatHistoryAPIView](C:/Users/admin/Desktop/Lila-Server/qy_lty/aiapp/views.py#L440) 的 `filter(user=request.user, bot=bot)` 查询直接覆盖。
- **落库时机**:每条字幕到达就处理,仅在判定为"一句话最终结果"时写入 `ChatMessage`
## RTC userId ↔ ParadiseUser 映射(关键事实)
从 [device_interaction/views.py:1142](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1142) 可知,`get_by_mac` 返回的 RTC `user_id` 就是 `str(user_device.user.id)`ParadiseUser 主键的字符串形式)。这传给设备端 [getJson.cs:67](Assets/Scripts/getJson.cs#L67) 的 `targetUserIds.Add(UserId)`,最终火山字幕回调里 `subtitle.userId` 字段:
- `userId == "bot01"` → AI 说的话assistant来自 [getJson.cs:69](Assets/Scripts/getJson.cs#L69) `agentConfig["UserId"] = "bot01"` 的硬编码
- `userId == "<纯数字字符串>"` → 用户说的话,**该数字就是 ParadiseUser.id**
这意味着用户消息可以直接从字幕字段反查 ParadiseUser无需额外映射。
AI 消息(`bot01`)的 user 归属需要别的方法解决 —— 见下面"AI 字幕 user 归属"小节。
## 服务器端 — 核心修改
**唯一改动文件**`C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py`
### 1. 修改 `conversation_status` action 的 `subv` 分支([views.py:1258-1302](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1258)
当前代码:解析出 `subtitle_items` 列表后只构建 response_data 返回。
需要在 `subtitle_items` 构建后,紧接着遍历每条 subtitle 并按下面的规则写入 `ChatMessage`
```python
# 伪代码(详细见执行阶段)
for item in subtitle_items:
text = item['text'].strip()
user_id_in_subtitle = item['userId']
is_definite = item['definite']
is_paragraph_end = item['paragraph']
sequence = item['sequence']
if not text:
continue
# 只在"一句话结束"时写库(见下面"流式字幕去重"小节)
if not (is_definite and is_paragraph_end):
# 中间结果先在 Redis 里累积,不落库
_accumulate_partial_subtitle(task_id_or_round_key, user_id_in_subtitle, text, sequence)
continue
# 反查 ParadiseUser
if user_id_in_subtitle == 'bot01':
sender = ChatMessage.SENDER_BOT # 'assistant'
paradise_user = _resolve_user_for_bot_subtitle(...) # 见 AI 字幕 user 归属
else:
sender = ChatMessage.SENDER_USER # 'user'
try:
paradise_user = ParadiseUser.objects.get(id=int(user_id_in_subtitle))
except (ParadiseUser.DoesNotExist, ValueError):
logger.warning('subtitle userId %s 无法解析为 ParadiseUser跳过', user_id_in_subtitle)
continue
if paradise_user is None:
continue
# 拿 RTC Bot
try:
bot = Bot.objects.get(name='RTC_Voice_Agent')
except Bot.DoesNotExist:
logger.error('RTC_Voice_Agent Bot 未配置,跳过字幕落库')
continue
# 拼接累积的中间结果(如果之前累积过)
final_text = _flush_accumulated_subtitle(task_id_or_round_key, user_id_in_subtitle, text)
ChatMessage.objects.create(
user=paradise_user,
bot=bot,
message=final_text,
sender=sender,
message_type=ChatMessage.MESSAGE_TYPE_TEXT,
)
```
实际选择"直接落库 vs 累积后落库"的策略由执行阶段在第一次跑通后看真实字幕事件流来定 —— 见下面"流式字幕去重"小节的两种策略。
### 2. AI 字幕 user 归属(关键难点)
字幕外层 JSON `{message, binary, signature}` 里**没有 task_id**[views.py:1232-1234](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1232))。`subv` 二进制内部解析出来也只有 `userId/text/sequence/...`**也没有 task_id**[views.py:1281-1291](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1281))。所以"`userId='bot01'` 时归属哪个用户"需要外部上下文。
**首选方案:用最近一次 `conv` 状态回调的 user_id 作为上下文**
`conv` 状态回调在 [views.py:1333-1334](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1333) 已经解析出 `TaskId``UserID`(这里的 UserID 来自火山,等于 ParadiseUser.id 的字符串)。在 `conv` 分支处理时把 `(TaskId, UserID, RoundID)` 写到 Redis short-TTL如 30 秒key 形如 `rtc_active_round:<TaskId>` value=`{user_id, round_id, last_seen_ts}`
但字幕回调没有 task_id没法按 task_id 反查 —— 退而求其次:**维护一个全局"最近活跃用户"的列表**按时间倒序AI 字幕到达时取距其最近的那个用户。这在并发设备数较少时有效;如果同时有多个设备 RTC 房间在跑,需要更精细的关联。
**更稳妥的备选方案:在 `_resolve_user_for_bot_subtitle` 里查 Redis**
由于 `get_by_mac` 已经把 `rtc_room:{user_id}:{task_id}` 写到 Redis[views.py:1169](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1169)),可以在这里**额外加一份反向索引** `rtc_active_user:bot01_last:{round_or_task} → user_id`,由 `conv` 分支负责更新。
**最稳妥的兜底方案:等火山回调 URL 实测**
火山引擎 RTS webhook **可能**在 URL query string 里带 `?task_id=xxx`(很多服务端 webhook 都这么干),或者在 HTTP header 里带(如 `X-Volc-Task-Id`)。设备端配置的 URL 是固定的 `api/device/rtc-token/conversation_status/`(无 query但火山服务器实际调用时是否会附加需要在生产日志`logger.info('JSON data: %s', json_data)` [views.py:1229](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1229) 已经在打)里观察。
执行阶段第一步是**先打开生产日志看一次真实的 subv 字幕回调 raw payload**,确认能不能直接拿到 task_id 或类似关联字段。如果能,整个映射问题秒杀;如果不能,用 Redis 维护"最近活跃 task→user 映射"。
### 3. 流式字幕去重 / "一句话最终"判定
火山引擎对话式 AI 字幕是流式的,同一句话会推多条 `definite=false` 中间结果,最后推 `definite=true` 最终结果;多句话之间用 `paragraph=true` 标记段落结束。
参考火山官方文档([views.py:1206](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1206) 注释里给的 https://www.volcengine.com/docs/6348/1415216的字段定义
- `definite=true`:当前 utterance 已确定
- `paragraph=true`:当前段落(可能含多个 utterance已结束
- `sequence`:序号,单调递增
**两种落库策略,执行阶段二选一**
**策略 A简单**:只在 `definite=true && paragraph=true` 时把 `text` 直接落库。优点:实现简单、不需要状态。缺点:火山的 paragraph 颗粒度可能太大(一段话含好几个完整意群),落库的 message 可能很长;或者反过来颗粒度太小(一个 utterance 就是一段),导致同一轮对话被拆成多条。
**策略 B拼接**:用 Redis 的 List/String 累积同一对话回合(按 `task_id+userId+round_key`)的所有 `definite=true` text遇到 `paragraph=true` 时把累积结果拼接成一条 ChatMessage 落库。需要超时清理(如累积 60 秒还没收到 paragraph_end 就强制落库)。
执行阶段第一步先用策略 A 跑通,观察 Unity Editor + 服务器日志里的真实字幕颗粒度,再决定要不要切策略 B。
### 4. (可选轻量优化)`get_by_mac` 写一份反向索引
修改 [views.py:1167-1184](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1167) 在已有 `cache.set(redis_key, token_data, expire_time)` 后追加:
```python
# 反向索引task_id → user_id用于字幕回调中 bot01 字幕的 user 归属
cache.set(f"rtc_task_user:{task_id}", user_id, expire_time)
```
这样 `conversation_status` 处理 `conv` 回调时能 `cache.get(f"rtc_task_user:{task_id}")` 直接拿到 user_id不依赖时序假设。
只在执行阶段确认"火山字幕回调里有 task_id 关联"时这步才有意义;如果没有 task_id这层映射救不了字幕分支字幕分支根本不知道当前 task_id 是哪个)。
## 验证
按顺序:
### 1. 摸火山真实字幕回调的 schema最关键的第一步
在不改任何代码的情况下,先收集一次真实字幕事件:
- 服务器开 DEBUG 日志,确认 [views.py:1229](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1229) `logger.info('JSON data: %s', json_data)` 和 [views.py:1275](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1275) `logger.info('Parsed subtitle data: %s', subtitle_data)` 都能输出
- 设备端真机或 Editor Play → 进入 Game 场景 → RTC 加房 → 对着麦说一句话
- 看服务器日志里:
- 外层 JSON 除了 `message/binary/signature` 还有没有别的字段(可能藏着 task_id
- HTTP 请求 URL可能带 `?task_id=`)和 headers可能带 `X-Volc-*`)—— 需要在 view 函数最顶端临时加 `logger.info('headers=%s, query=%s', dict(request.headers), request.query_params.dict())`
- subtitle_data 内的所有字段,特别是 `userId='bot01'` 时有没有别的字段能关联到 user
这一步直接决定第 2 节"AI 字幕 user 归属"用哪种方案。
### 2. 改代码 + 跑通用户消息
先实现"用户消息"分支subtitle.userId 是数字 → ParadiseUser—— 这部分不依赖 task_id 映射,最容易跑通。
- 改完后用 `python manage.py runserver` 起本地服务
- 设备端切到本地([RootManager.cs:30](Assets/Scripts/RootManager.cs#L30) `LocalTest=true`
- Editor Play 加 RTC 房 → 说一句话 → 在 Django shell 里 `ChatMessage.objects.filter(sender='user').order_by('-id')[:5]` 看是否有新记录
### 3. 跑通 AI 消息
按第 1 步实测结果选第 2 节的方案,实现 `bot01` → user 归属。
然后让 Lila 回一句话,验证 `ChatMessage.objects.filter(sender='assistant').order_by('-id')[:5]`
### 4. 三端联调
用手机端 App 登录绑定该设备的同一个用户账号进入聊天记录界面确认能看到刚才设备前对话的字幕user 和 assistant 各一条)。
### 5. 真机验证 + 长稳
烧 APK 到 RK3588对话 5-10 分钟,看:
- ChatMessage 表数量是否符合预期(无重复、无丢失)
- 服务器 error log 有没有写库失败(数据库唯一约束、字段长度等)
- ChatMessage.message 字段最大 2048[aiapp/models.py:39](C:/Users/admin/Desktop/Lila-Server/qy_lty/aiapp/models.py#L39)),超长字幕需要 truncate
### 6. 回归
确认设备端 RTC 视频/音频/对话状态机aiState一切正常 —— 因为只改了服务器端,设备端逻辑应该零影响。
## 需修改文件清单
服务器端(**唯一改动**
- `C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py`
- 在 `VolcEngineTokenViewSet.conversation_status` action 的 `subv` 分支([第 1278-1302 行](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1278))追加字幕落库逻辑(约 30-50 行)
- 顶部加 `from aiapp.models import ChatMessage, Bot``from userapp.models import ParadiseUser`
- 临时调试期间加 headers / query 的 logger.info[第 1217 行](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1217) 附近)—— 跑通后可保留或删除
- 可选:在 `get_by_mac` 的 [第 1184 行](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1184) 后追加 `cache.set(f"rtc_task_user:{task_id}", user_id, expire_time)`
- 可选:在 `conv` 分支 [第 1356 行](C:/Users/admin/Desktop/Lila-Server/qy_lty/device_interaction/views.py#L1356) 附近追加"最近活跃 task→user"映射写入
服务器端**不需要 migration**ChatMessage 表结构不变,复用现有字段)。
服务器端**不需要确认 RTC_Voice_Agent Bot 存在** —— 已经被手机端用着,必然存在;执行时跑一次 `Bot.objects.get(name='RTC_Voice_Agent')` 验证一次。
设备端:**无修改**。
手机端:**无修改**。
火山引擎:**无配置变化**(设备端 getJson.cs 已经配置好了)。
## 风险与回滚
- 主要风险:字幕落库逻辑写错 → 数据库写脏数据。缓解:先在本地服务器跑,写完后用 SQL 批量 DELETE 测试期产生的脏数据再上正式环境。
- 回滚:只需 `git revert` views.py 的那个 commit因为没有 schema 改动。

View File

@ -33,7 +33,7 @@ def send_sms(phone_number, code):
def generate_token(user_id, is_admin=False):
token = str(uuid.uuid4())
prefix = "admin_token:" if is_admin else "token:"
cache.set(f"{prefix}{token}", user_id, timeout=86400) # Token 有效期为1
cache.set(f"{prefix}{token}", user_id, timeout=2592000) # Token 有效期为30
return token
def get_user_id_from_token(token):

View File

@ -116,7 +116,8 @@ class MacAddressLoginView(APIView):
device = Device.objects.get(mac_address=mac_address)
# 检查设备是否已绑定给用户
user_device = UserDevice.objects.filter(device=device).first()
# 按绑定时间倒序取最新绑定者作为设备归属人——"后绑的挤掉先绑的"
user_device = UserDevice.objects.filter(device=device).order_by('-bound_at').first()
if not user_device:
logger.warning(f"Device not bound to any user: {mac_address}")
# 返回特定 code=4010让设备端可以识别"未绑定"状态