fix(wechat/token/market): 修复微信对话时间、Token数据重置、技能市场复活、媒体内联

1. 微信Bot对话记录时间按北京时间渲染(ImBotHub/WechatHistory 的 formatTime 显式 +08:00 + Asia/Shanghai);
2. 新增 /app/api/chat/media/relay 媒体中转端点(白名单 agnES 域名, 经香港代理代取),
   改写 _wechat_send_media 用平台中转地址,微信/iLink 可内联播放图片/视频;
3. 删除 014_clean_test_data.sql:该迁移每次重启重跑并 DELETE token_usage_logs/
   messages/conversations/knowledge 等,导致 Token 驾驶舱数据每次重启被清空;
4. 002_init_data.sql 移除 market_apps 自动播种(原 WHERE COUNT=0 在用户删空后重启复活),
   技能市场交由用户后台维护,重启不再复活。
This commit is contained in:
QihuAI Design committed 2026-07-16 09:19:23 +08:00
1 parent da89a27bca
commit 82afe14a23
6 files changed
+85 -108

No files matched your search

+35 -1
View File
@@ -3,7 +3,7 @@
包含: Skill ID统一(MarketApp映射), 真实Skill执行, Agent工作流注入, 包含: Skill ID统一(MarketApp映射), 真实Skill执行, Agent工作流注入,
多轮上下文保持, BM25 KB检索, Function Calling循环增强 多轮上下文保持, BM25 KB检索, Function Calling循环增强
""" """
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -173,6 +173,40 @@ def _proxy_for_url(url: str) -> str | None:
return _outbound_proxy() return _outbound_proxy()
# ============================================================
# 媒体中转(relay)端点:让微信/iLink 能内联渲染 AGNES 生成的图片/视频
# 背景:AGNES 媒体 URL 位于境外(agnes-ai.com / agnes-ai.space),
# iLink 服务器(中国大陆)直连常被墙/超时,导致微信里只看到"链接"无法内联播放。
# 此端点由平台服务器(经香港代理可达 AGNES)代取媒体并以平台域名回传,
# 微信即可内联显示。仅允许白名单域名,杜绝 SSRF。
# ============================================================
_MEDIA_RELAY_ALLOWED = ("agnes-ai.com", "agnes-ai.space", "qihuyun.com")
@router.get("/media/relay")
async def media_relay(url: str = Query(...)):
"""中转 AGNES 媒体(图片/视频)到平台域名,供微信/iLink 内联渲染。
仅允许白名单域名;否则 400。返回的媒体流保持原 content-type。
"""
import urllib.parse as _up
host = (_up.urlparse(url).hostname or "").lower()
if not any(allowed in host for allowed in _MEDIA_RELAY_ALLOWED):
raise HTTPException(status_code=400, detail="unsupported media host")
try:
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client:
r = await client.get(url)
if r.status_code != 200:
raise HTTPException(status_code=r.status_code, detail="upstream error")
content_type = r.headers.get("content-type", "application/octet-stream")
data = await r.aread()
return Response(content=data, media_type=content_type)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"relay failed: {e}")
async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> str: async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> str:
"""使用平台文本模型对用户原始输入做「媒体提示词增强优化」。 """使用平台文本模型对用户原始输入做「媒体提示词增强优化」。
+25 -12
View File
@@ -14,11 +14,13 @@ im_bot_user.py
""" """
import base64 import base64
import json import json
import os
import random import random
import threading import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from urllib.parse import quote as _quote
import requests import requests
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@@ -225,21 +227,31 @@ def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_
[{"type": 1, "text_item": {"text": text}}]) [{"type": 1, "text_item": {"text": text}}])
def _platform_relay_url(raw_url: str) -> str:
"""把 AGNES 等境外媒体 URL 改写为平台中转地址,使微信/iLink 可内联渲染。
iLink 服务器(中国大陆)直连境外 AGNES 常被墙/超时,微信里只能看到"链接"。
改写为平台中转地址(平台服务器经香港代理可达 AGNES),微信即可内联显示图片/视频。
"""
access = os.environ.get("APP_ACCESS_URL", "http://210.16.161.218:12888").rstrip("/")
return f"{access}/app/api/chat/media/relay?url={_quote(raw_url, safe='')}"
def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str, def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str,
media_type: str, url: str): media_type: str, url: str):
"""发送媒体(图片/视频)并附带文本链接兜底,确保用户可在微信内直接查看。 """发送媒体(图片/视频)并在微信内联渲染。
iLink 消息 item_list: 问题:AGNES 媒体 URL 位于境外,iLink(中国大陆服务器)直连常被墙/超时,
- type=2 图片:image_item: {url} 微信里只能看到"链接"而无法内联播放。
- type=5 视频:video_item: {url} 解决:改写为平台中转地址(经香港代理可达 AGNES),微信即可内联显示图片/视频。
同时附带一条文本项(含直链),防止微信/iLink 不渲染外链时用户仍能复制查看。
""" """
relay_url = _platform_relay_url(url)
if media_type == "image": if media_type == "image":
media_item = {"type": 2, "image_item": {"url": url}} media_item = {"type": 2, "image_item": {"url": relay_url}}
caption = "🖼️ 图片已生成(若未直接显示,请点链接查看):" + url caption = "🖼️ 图片已生成(若未直接显示,请点此查看):" + relay_url
else: else:
media_item = {"type": 5, "video_item": {"url": url}} media_item = {"type": 5, "video_item": {"url": relay_url}}
caption = "🎬 视频已生成(若未直接播放,请点链接查看):" + url caption = "🎬 视频已生成(若未直接播放,请点此查看):" + relay_url
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
[media_item, {"type": 1, "text_item": {"text": caption}}]) [media_item, {"type": 1, "text_item": {"text": caption}}])
@@ -420,11 +432,12 @@ def _wechat_receiver_loop(account_id: int):
media_type = reply.get("media_type") or "image" media_type = reply.get("media_type") or "image"
media_url = reply.get("url") or "" media_url = reply.get("url") or ""
ok, ret, _ = _wechat_send_media(baseurl, bot_token, sender, ctx, media_type, media_url) ok, ret, _ = _wechat_send_media(baseurl, bot_token, sender, ctx, media_type, media_url)
# 对话历史里存可渲染的媒体标签(AI 聊天 WeXin Bot 窗与历史页均可显示) # 对话历史里存可渲染的媒体标签(经平台中转,AI 聊天 WeXin Bot 窗与历史页均可内联显示)
relay_url = _platform_relay_url(media_url)
reply_for_log = ( reply_for_log = (
f'<img src="{media_url}" alt="生成图片"/>' f'<img src="{relay_url}" alt="生成图片"/>'
if media_type == "image" if media_type == "image"
else f'<video src="{media_url}" controls></video>' else f'<video src="{relay_url}" controls></video>'
) )
else: else:
reply_text = reply if isinstance(reply, str) else str(reply) reply_text = reply if isinstance(reply, str) else str(reply)
+11 -1
View File
@@ -6,6 +6,16 @@ import { imUserAPI } from '@/api';
const PRIMARY = '#5B5FED'; const PRIMARY = '#5B5FED';
const PRIMARY_ACTIVE = '#4A4ECC'; const PRIMARY_ACTIVE = '#4A4ECC';
// 后端按北京时间存储(无时区标记的 ISO),此处显式按 Asia/Shanghai 渲染,
// 避免浏览器本地时区导致对话时间错乱(与个人微信绑定页一致)。
function formatBjTime(t?: string): string {
if (!t) return '';
const iso = /[Z+]/.test(t) ? t : t + '+08:00';
const d = new Date(iso);
if (isNaN(d.getTime())) return t;
return d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
}
const ImBotHub: React.FC = () => { const ImBotHub: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const highlight = searchParams.get('platform'); const highlight = searchParams.get('platform');
@@ -269,7 +279,7 @@ const HistoryModal: React.FC<{ schema: any; data: any; loading: boolean; onClose
<div key={m.id} style={{ display: 'flex', flexDirection: m.role === 'user' ? 'row-reverse' : 'row' }}> <div key={m.id} style={{ display: 'flex', flexDirection: m.role === 'user' ? 'row-reverse' : 'row' }}>
<div style={{ maxWidth: '80%', padding: '10px 14px', borderRadius: '12px', fontSize: '14px', lineHeight: 1.6, background: m.role === 'user' ? PRIMARY : '#F1F2FB', color: m.role === 'user' ? '#fff' : '#1A1F36' }}> <div style={{ maxWidth: '80%', padding: '10px 14px', borderRadius: '12px', fontSize: '14px', lineHeight: 1.6, background: m.role === 'user' ? PRIMARY : '#F1F2FB', color: m.role === 'user' ? '#fff' : '#1A1F36' }}>
{m.content} {m.content}
<div style={{ fontSize: '11px', opacity: 0.6, marginTop: '4px' }}>{m.created_at ? new Date(m.created_at).toLocaleString('zh-CN') : ''}</div> <div style={{ fontSize: '11px', opacity: 0.6, marginTop: '4px' }}>{formatBjTime(m.created_at)}</div>
</div> </div>
</div> </div>
))} ))}
+9 -1
View File
@@ -32,7 +32,15 @@ const WechatHistory: React.FC = () => {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
const formatTime = (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-'); const formatTime = (t: string) => {
if (!t) return '-';
// 后端按北京时间存储(无时区标记的 ISO),此处显式按 Asia/Shanghai 渲染,
// 避免浏览器本地时区导致对话时间错乱(与微信 Bot 绑定页一致)。
const iso = /[Z+]/.test(t) ? t : t + '+08:00';
const d = new Date(iso);
if (isNaN(d.getTime())) return t;
return d.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
};
return ( return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}> <div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
+5 -13
View File
@@ -67,19 +67,11 @@ INSERT IGNORE INTO membership_plans (id, name, level, duration_type, price, dail
-- ============================================================ -- ============================================================
-- 初始化官方市场应用 -- 初始化官方市场应用
-- ============================================================ -- ============================================================
-- 注意:market_apps 不再在此自动播种。原因:run.sh 每次重启都会重跑全部迁移脚本,
-- 仅当 market_apps 为空(全新库)时才写入官方应用, -- 原 `WHERE (SELECT COUNT(*) FROM market_apps) = 0` 逻辑会在"用户删空技能市场"后
-- 避免用户删除的技能/应用在重启(迁移脚本每次重启重跑)后被复活。 -- 于重启时把官方应用重新插回(即用户反馈的"删除的技能又复活")。
INSERT INTO market_apps (id, name, type, source, publisher_id, publisher_name, description, icon, tutorial, price, is_member_free, join_promotion, status, rating_avg, review_count, install_count) -- 现在完全交由用户在后台「技能市场」维护,重启不再复活。
SELECT * FROM ( -- 如需全新库预置官方应用,请由管理员在后台手动添加。
SELECT 'app-001', '智能客服Agent', 'agent', 'official', 'admin-001', 'AIFlow官方', '基于大模型的智能客服Agent,支持多渠道接入、意图识别、情感分析等功能', '🤖', NULL, 0.00, 1, 0, 'published', 4.50, 128, 3500
UNION ALL SELECT 'app-002', '数据分析工具箱', 'skill', 'official', 'admin-001', 'AIFlow官方', '一站式数据分析工具集,包含数据清洗、可视化、统计分析等功能模块', '📊', NULL, 29.90, 0, 0, 'published', 4.20, 89, 2100
UNION ALL SELECT 'app-003', 'MCP数据库连接器', 'mcp', 'official', 'admin-001', 'AIFlow官方', '标准MCP协议实现的数据库连接器,支持MySQL、PostgreSQL、Redis等主流数据库', '🔌', NULL, 19.90, 0, 0, 'published', 4.00, 56, 1800
UNION ALL SELECT 'app-004', 'SEO优化套件', 'skill', 'official', 'admin-001', 'AIFlow官方', '全面的SEO优化工具包,包含关键词分析、内容优化、排名追踪等功能', '🔍', NULL, 39.90, 0, 0, 'published', 4.30, 45, 1200
UNION ALL SELECT 'app-005', 'PPT生成器', 'skill', 'official', 'admin-001', 'AIFlow官方', 'AI驱动的PPT自动生成工具,输入主题即可生成精美的演示文稿', '📝', NULL, 9.90, 1, 0, 'published', 4.80, 210, 5600
UNION ALL SELECT 'app-legal-agent', '法律文书Agent', 'agent', 'official', 'admin-001', 'AIFlow官方', '智能法律文书生成与审查Agent,支持合同、诉状、法律意见书等多种文书类型的自动生成和合规审查。集成最新法律法规知识库,提供精准的法律文书辅助服务。', '⚖️', NULL, 0.00, 1, 0, 'published', 4.60, 32, 128
) AS seed
WHERE (SELECT COUNT(*) FROM market_apps) = 0;
-- ============================================================ -- ============================================================
-80
View File
@@ -1,80 +0,0 @@
-- ============================================================
-- 014: 清除 Mock 和测试数据,保留 admin 账号及种子数据
-- ============================================================
-- 1. 删除 test/seed 会话和消息
DELETE FROM messages;
DELETE FROM conversations;
-- 2. 删除测试资产
DELETE FROM assets;
-- 3. 删除测试知识库和文档
DELETE FROM knowledge_documents;
DELETE FROM knowledge_graph_edges;
DELETE FROM knowledge_graph_nodes;
DELETE FROM knowledge_graph_build_logs;
DELETE FROM knowledge_bases;
-- 4. 删除 token 使用日志(保留余额)
DELETE FROM token_usage_logs;
-- 5. 删除操作日志和审计日志
DELETE FROM operation_logs;
DELETE FROM audit_logs;
-- 6. 删除通知
DELETE FROM notifications;
-- 7. 删除频道数据
DELETE FROM channel_messages;
DELETE FROM channel_members;
DELETE FROM channels;
-- 8. 删除登录日志
DELETE FROM login_logs;
-- 9. 删除 API 调用统计
DELETE FROM api_call_stats;
DELETE FROM slow_request_logs;
-- 10. 删除进化相关测试数据
DELETE FROM evolution_events;
DELETE FROM evolution_trajectories;
DELETE FROM skill_trajectory_links;
DELETE FROM skill_versions;
DELETE FROM skill_publish_records;
DELETE FROM evolution_reports;
-- 11. 修复 model 引用,然后删除重复的 model_provider(保留第一个)
UPDATE models SET provider_id = '03ce94d2-a889-4d0e-a389-dd5d2176d0a0' WHERE provider_id = '23bf76d4-b3e9-4b47-a374-83f5d46741de';
DELETE FROM model_providers WHERE id = '23bf76d4-b3e9-4b47-a374-83f5d46741de';
-- 12. 删除订单和支付测试数据
DELETE FROM custom_service_orders;
DELETE FROM custom_service_requests;
DELETE FROM orders;
-- 13. 删除应用安装/购买记录(保留市场应用本身)
DELETE FROM app_installations;
DELETE FROM app_purchases;
DELETE FROM app_reviews;
-- 14. 删除优惠券使用记录
DELETE FROM user_coupons;
-- 15. 删除会员订阅测试数据
DELETE FROM membership_subscriptions;
-- 16. 删除团队和共享数据
DELETE FROM team_shared_skills;
DELETE FROM teams;
-- 17. 删除推荐数据
DELETE FROM referrals;
-- 18. 删除开放API客户端
DELETE FROM open_api_clients;
-- 19. 确认 admin 存在且状态正常
UPDATE users SET status = 'active' WHERE username = 'admin';