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:
1 parent
da89a27bca
commit
82afe14a23
6 files changed
+85
-108
No files matched your search
@@ -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:
|
||||||
"""使用平台文本模型对用户原始输入做「媒体提示词增强优化」。
|
"""使用平台文本模型对用户原始输入做「媒体提示词增强优化」。
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@@ -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';
|
|
||||||
Reference in new issue
Block a user