feat: 静态 Token 鉴权改造
All checks were successful
continuous-integration/drone/push Build is passing

- 将 token_secret 改为 access_token(长期有效)
- 移除 token_required 字段,统一使用 token 验证
- 生成链接简化为 ?tid=xxx&token=xxx 格式
- 前端移除签名验证开关,链接永久有效
This commit is contained in:
111
2026-01-23 18:43:04 +08:00
parent 39f33d7ac5
commit f815b29c51
4 changed files with 759 additions and 791 deletions

View File

@@ -19,8 +19,7 @@ class TenantApp(Base):
wechat_secret_encrypted = Column(Text) wechat_secret_encrypted = Column(Text)
# 鉴权配置 # 鉴权配置
token_secret = Column(String(64)) access_token = Column(String(64)) # 访问令牌(长期有效)
token_required = Column(SmallInteger, default=0)
allowed_origins = Column(Text) # JSON 数组 allowed_origins = Column(Text) # JSON 数组
# 功能权限 # 功能权限

View File

@@ -1,281 +1,267 @@
"""应用管理路由""" """应用管理路由"""
import json import json
import hmac from fastapi import APIRouter, Depends, HTTPException, Query
import hashlib from pydantic import BaseModel
import time from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, List from ..database import get_db
from sqlalchemy.orm import Session from ..models.app import App
from ..models.tenant_app import TenantApp
from ..database import get_db from .auth import get_current_user, require_operator
from ..models.app import App from ..models.user import User
from ..models.tenant_app import TenantApp
from .auth import get_current_user, require_operator router = APIRouter(prefix="/apps", tags=["应用管理"])
from ..models.user import User
router = APIRouter(prefix="/apps", tags=["应用管理"]) # ============ Schemas ============
class ToolItem(BaseModel):
# ============ Schemas ============ """工具项"""
code: str
class ToolItem(BaseModel): name: str
"""工具项""" path: str
code: str
name: str
path: str class AppCreate(BaseModel):
"""创建应用"""
app_code: str
class AppCreate(BaseModel): app_name: str
"""创建应用""" base_url: Optional[str] = None
app_code: str description: Optional[str] = None
app_name: str tools: Optional[List[ToolItem]] = None
base_url: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None class AppUpdate(BaseModel):
"""更新应用"""
app_name: Optional[str] = None
class AppUpdate(BaseModel): base_url: Optional[str] = None
"""更新应用""" description: Optional[str] = None
app_name: Optional[str] = None tools: Optional[List[ToolItem]] = None
base_url: Optional[str] = None status: Optional[int] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None
status: Optional[int] = None class GenerateUrlRequest(BaseModel):
"""生成链接请求"""
tenant_id: str
class GenerateUrlRequest(BaseModel): app_code: str
"""生成链接请求""" tool_code: Optional[str] = None # 不传则生成应用首页链接
tenant_id: str
app_code: str
tool_code: Optional[str] = None # 不传则生成应用首页链接 # ============ API Endpoints ============
@router.get("")
# ============ API Endpoints ============ async def list_apps(
page: int = Query(1, ge=1),
@router.get("") size: int = Query(20, ge=1, le=100),
async def list_apps( status: Optional[int] = None,
page: int = Query(1, ge=1), user: User = Depends(get_current_user),
size: int = Query(20, ge=1, le=100), db: Session = Depends(get_db)
status: Optional[int] = None, ):
user: User = Depends(get_current_user), """获取应用列表"""
db: Session = Depends(get_db) query = db.query(App)
):
"""获取应用列表""" if status is not None:
query = db.query(App) query = query.filter(App.status == status)
if status is not None: total = query.count()
query = query.filter(App.status == status) apps = query.order_by(App.id.asc()).offset((page - 1) * size).limit(size).all()
total = query.count() return {
apps = query.order_by(App.id.asc()).offset((page - 1) * size).limit(size).all() "total": total,
"page": page,
return { "size": size,
"total": total, "items": [format_app(app) for app in apps]
"page": page, }
"size": size,
"items": [format_app(app) for app in apps]
} @router.get("/all")
async def list_all_apps(
user: User = Depends(get_current_user),
@router.get("/all") db: Session = Depends(get_db)
async def list_all_apps( ):
user: User = Depends(get_current_user), """获取所有启用的应用(用于下拉选择)"""
db: Session = Depends(get_db) apps = db.query(App).filter(App.status == 1).order_by(App.id.asc()).all()
): return [{"app_code": app.app_code, "app_name": app.app_name} for app in apps]
"""获取所有启用的应用(用于下拉选择)"""
apps = db.query(App).filter(App.status == 1).order_by(App.id.asc()).all()
return [{"app_code": app.app_code, "app_name": app.app_name} for app in apps] @router.get("/{app_id}")
async def get_app(
app_id: int,
@router.get("/{app_id}") user: User = Depends(get_current_user),
async def get_app( db: Session = Depends(get_db)
app_id: int, ):
user: User = Depends(get_current_user), """获取应用详情"""
db: Session = Depends(get_db) app = db.query(App).filter(App.id == app_id).first()
): if not app:
"""获取应用详情""" raise HTTPException(status_code=404, detail="应用不存在")
app = db.query(App).filter(App.id == app_id).first()
if not app: return format_app(app)
raise HTTPException(status_code=404, detail="应用不存在")
return format_app(app) @router.post("")
async def create_app(
data: AppCreate,
@router.post("") user: User = Depends(require_operator),
async def create_app( db: Session = Depends(get_db)
data: AppCreate, ):
user: User = Depends(require_operator), """创建应用"""
db: Session = Depends(get_db) # 检查 app_code 是否重复
): exists = db.query(App).filter(App.app_code == data.app_code).first()
"""创建应用""" if exists:
# 检查 app_code 是否重复 raise HTTPException(status_code=400, detail="应用代码已存在")
exists = db.query(App).filter(App.app_code == data.app_code).first()
if exists: app = App(
raise HTTPException(status_code=400, detail="应用代码已存在") app_code=data.app_code,
app_name=data.app_name,
app = App( base_url=data.base_url,
app_code=data.app_code, description=data.description,
app_name=data.app_name, tools=json.dumps([t.model_dump() for t in data.tools], ensure_ascii=False) if data.tools else None,
base_url=data.base_url, status=1
description=data.description, )
tools=json.dumps([t.model_dump() for t in data.tools], ensure_ascii=False) if data.tools else None, db.add(app)
status=1 db.commit()
) db.refresh(app)
db.add(app)
db.commit() return {"success": True, "id": app.id}
db.refresh(app)
return {"success": True, "id": app.id} @router.put("/{app_id}")
async def update_app(
app_id: int,
@router.put("/{app_id}") data: AppUpdate,
async def update_app( user: User = Depends(require_operator),
app_id: int, db: Session = Depends(get_db)
data: AppUpdate, ):
user: User = Depends(require_operator), """更新应用"""
db: Session = Depends(get_db) app = db.query(App).filter(App.id == app_id).first()
): if not app:
"""更新应用""" raise HTTPException(status_code=404, detail="应用不存在")
app = db.query(App).filter(App.id == app_id).first()
if not app: update_data = data.model_dump(exclude_unset=True)
raise HTTPException(status_code=404, detail="应用不存在")
# 处理 tools JSON
update_data = data.model_dump(exclude_unset=True) if 'tools' in update_data:
if update_data['tools']:
# 处理 tools JSON update_data['tools'] = json.dumps([t.model_dump() if hasattr(t, 'model_dump') else t for t in update_data['tools']], ensure_ascii=False)
if 'tools' in update_data: else:
if update_data['tools']: update_data['tools'] = None
update_data['tools'] = json.dumps([t.model_dump() if hasattr(t, 'model_dump') else t for t in update_data['tools']], ensure_ascii=False)
else: for key, value in update_data.items():
update_data['tools'] = None setattr(app, key, value)
for key, value in update_data.items(): db.commit()
setattr(app, key, value) return {"success": True}
db.commit()
return {"success": True} @router.delete("/{app_id}")
async def delete_app(
app_id: int,
@router.delete("/{app_id}") user: User = Depends(require_operator),
async def delete_app( db: Session = Depends(get_db)
app_id: int, ):
user: User = Depends(require_operator), """删除应用"""
db: Session = Depends(get_db) app = db.query(App).filter(App.id == app_id).first()
): if not app:
"""删除应用""" raise HTTPException(status_code=404, detail="应用不存在")
app = db.query(App).filter(App.id == app_id).first()
if not app: # 检查是否有租户在使用
raise HTTPException(status_code=404, detail="应用不存在") tenant_count = db.query(TenantApp).filter(TenantApp.app_code == app.app_code).count()
if tenant_count > 0:
# 检查是否有租户在使用 raise HTTPException(status_code=400, detail=f"{tenant_count}租户在使用此应用,无法删除")
tenant_count = db.query(TenantApp).filter(TenantApp.app_code == app.app_code).count()
if tenant_count > 0: db.delete(app)
raise HTTPException(status_code=400, detail=f"{tenant_count} 个租户正在使用此应用,无法删除") db.commit()
db.delete(app) return {"success": True}
db.commit()
return {"success": True} @router.post("/generate-url")
async def generate_url(
data: GenerateUrlRequest,
@router.post("/generate-url") user: User = Depends(get_current_user),
async def generate_signed_url( db: Session = Depends(get_db)
data: GenerateUrlRequest, ):
user: User = Depends(get_current_user), """
db: Session = Depends(get_db) 生成访问链接
):
""" 返回完整的可直接使用的 URL使用静态 token长期有效
生成带签名的访问链接 """
# 获取应用信息
返回完整的可直接使用的 URL app = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first()
""" if not app:
# 获取应用信息 raise HTTPException(status_code=404, detail="应用不存在或已禁用")
app = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first()
if not app: if not app.base_url:
raise HTTPException(status_code=404, detail="应用不存在或已禁用") raise HTTPException(status_code=400, detail="应用未配置基础URL")
if not app.base_url: # 获取租户配置
raise HTTPException(status_code=400, detail="应用未配置基础URL") tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == data.tenant_id,
# 获取租户配置 TenantApp.app_code == data.app_code,
tenant_app = db.query(TenantApp).filter( TenantApp.status == 1
TenantApp.tenant_id == data.tenant_id, ).first()
TenantApp.app_code == data.app_code,
TenantApp.status == 1 if not tenant_app:
).first() raise HTTPException(status_code=404, detail="租户未配置此应用")
if not tenant_app: if not tenant_app.access_token:
raise HTTPException(status_code=404, detail="租户未配置此应用") raise HTTPException(status_code=400, detail="租户应用未配置访问令牌")
# 构建基础 URL # 构建基础 URL
base_url = app.base_url.rstrip('/') base_url = app.base_url.rstrip('/')
if data.tool_code: if data.tool_code:
# 查找工具路径 # 查找工具路径
tools = json.loads(app.tools) if app.tools else [] tools = json.loads(app.tools) if app.tools else []
tool = next((t for t in tools if t.get('code') == data.tool_code), None) tool = next((t for t in tools if t.get('code') == data.tool_code), None)
if tool: if tool:
base_url = f"{base_url}{tool.get('path', '')}" base_url = f"{base_url}{tool.get('path', '')}"
else: else:
base_url = f"{base_url}/{data.tool_code}" base_url = f"{base_url}/{data.tool_code}"
# 构建参数 # 构建参数(静态 token长期有效
params = { params = {
"tid": data.tenant_id, "tid": data.tenant_id,
"aid": data.app_code "token": tenant_app.access_token
} }
# 如果需要签名 # 组装 URL
if tenant_app.token_required and tenant_app.token_secret: query_string = "&".join([f"{k}={v}" for k, v in params.items()])
ts = str(int(time.time())) full_url = f"{base_url}?{query_string}"
message = f"{data.tenant_id}{data.app_code}{ts}"
sign = hmac.new( return {
tenant_app.token_secret.encode(), "success": True,
message.encode(), "url": full_url,
hashlib.sha256 "params": params,
).hexdigest() "note": "静态链接,长期有效"
params["ts"] = ts }
params["sign"] = sign
# 组装 URL @router.get("/{app_code}/tools")
query_string = "&".join([f"{k}={v}" for k, v in params.items()]) async def get_app_tools(
full_url = f"{base_url}?{query_string}" app_code: str,
user: User = Depends(get_current_user),
return { db: Session = Depends(get_db)
"success": True, ):
"url": full_url, """获取应用的工具列表(用于配置权限时选择)"""
"params": params, app = db.query(App).filter(App.app_code == app_code).first()
"token_required": bool(tenant_app.token_required), if not app:
"expires_in": 300 if tenant_app.token_required else None, # 签名5分钟有效 raise HTTPException(status_code=404, detail="应用不存在")
"note": "签名链接5分钟内有效过期需重新生成" if tenant_app.token_required else "免签名链接,长期有效"
} tools = json.loads(app.tools) if app.tools else []
return tools
@router.get("/{app_code}/tools")
async def get_app_tools( def format_app(app: App) -> dict:
app_code: str, """格式化应用数据"""
user: User = Depends(get_current_user), return {
db: Session = Depends(get_db) "id": app.id,
): "app_code": app.app_code,
"""获取应用的工具列表(用于配置权限时选择)""" "app_name": app.app_name,
app = db.query(App).filter(App.app_code == app_code).first() "base_url": app.base_url,
if not app: "description": app.description,
raise HTTPException(status_code=404, detail="应用不存在") "tools": json.loads(app.tools) if app.tools else [],
"status": app.status,
tools = json.loads(app.tools) if app.tools else [] "created_at": app.created_at,
return tools "updated_at": app.updated_at
}
def format_app(app: App) -> dict:
"""格式化应用数据"""
return {
"id": app.id,
"app_code": app.app_code,
"app_name": app.app_name,
"base_url": app.base_url,
"description": app.description,
"tools": json.loads(app.tools) if app.tools else [],
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}

View File

@@ -24,8 +24,7 @@ class TenantAppCreate(BaseModel):
wechat_corp_id: Optional[str] = None wechat_corp_id: Optional[str] = None
wechat_agent_id: Optional[str] = None wechat_agent_id: Optional[str] = None
wechat_secret: Optional[str] = None # 明文,存储时加密 wechat_secret: Optional[str] = None # 明文,存储时加密
token_secret: Optional[str] = None # 如果不传则自动生成 access_token: Optional[str] = None # 如果不传则自动生成
token_required: bool = False
allowed_origins: Optional[List[str]] = None allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None allowed_tools: Optional[List[str]] = None
@@ -35,8 +34,7 @@ class TenantAppUpdate(BaseModel):
wechat_corp_id: Optional[str] = None wechat_corp_id: Optional[str] = None
wechat_agent_id: Optional[str] = None wechat_agent_id: Optional[str] = None
wechat_secret: Optional[str] = None wechat_secret: Optional[str] = None
token_secret: Optional[str] = None access_token: Optional[str] = None
token_required: Optional[bool] = None
allowed_origins: Optional[List[str]] = None allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None allowed_tools: Optional[List[str]] = None
status: Optional[int] = None status: Optional[int] = None
@@ -101,8 +99,8 @@ async def create_tenant_app(
if exists: if exists:
raise HTTPException(status_code=400, detail="该租户应用配置已存在") raise HTTPException(status_code=400, detail="该租户应用配置已存在")
# 自动生成 token_secret # 自动生成 access_token
token_secret = data.token_secret or secrets.token_hex(32) access_token = data.access_token or secrets.token_hex(32)
# 加密 wechat_secret # 加密 wechat_secret
wechat_secret_encrypted = None wechat_secret_encrypted = None
@@ -116,8 +114,7 @@ async def create_tenant_app(
wechat_corp_id=data.wechat_corp_id, wechat_corp_id=data.wechat_corp_id,
wechat_agent_id=data.wechat_agent_id, wechat_agent_id=data.wechat_agent_id,
wechat_secret_encrypted=wechat_secret_encrypted, wechat_secret_encrypted=wechat_secret_encrypted,
token_secret=token_secret, access_token=access_token,
token_required=1 if data.token_required else 0,
allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None, allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None,
allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None, allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None,
status=1 status=1
@@ -126,7 +123,7 @@ async def create_tenant_app(
db.commit() db.commit()
db.refresh(app) db.refresh(app)
return {"success": True, "id": app.id, "token_secret": token_secret} return {"success": True, "id": app.id, "access_token": access_token}
@router.put("/{app_id}") @router.put("/{app_id}")
@@ -155,10 +152,6 @@ async def update_tenant_app(
if 'allowed_tools' in update_data: if 'allowed_tools' in update_data:
update_data['allowed_tools'] = json.dumps(update_data['allowed_tools']) if update_data['allowed_tools'] else None update_data['allowed_tools'] = json.dumps(update_data['allowed_tools']) if update_data['allowed_tools'] else None
# 处理 token_required
if 'token_required' in update_data:
update_data['token_required'] = 1 if update_data['token_required'] else 0
for key, value in update_data.items(): for key, value in update_data.items():
setattr(app, key, value) setattr(app, key, value)
@@ -189,16 +182,16 @@ async def regenerate_token(
user: User = Depends(require_operator), user: User = Depends(require_operator),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""重新生成 token_secret""" """重新生成 access_token"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first() app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app: if not app:
raise HTTPException(status_code=404, detail="应用配置不存在") raise HTTPException(status_code=404, detail="应用配置不存在")
new_token = secrets.token_hex(32) new_token = secrets.token_hex(32)
app.token_secret = new_token app.access_token = new_token
db.commit() db.commit()
return {"success": True, "token_secret": new_token} return {"success": True, "access_token": new_token}
@router.get("/{app_id}/wechat-secret") @router.get("/{app_id}/wechat-secret")
@@ -229,8 +222,7 @@ def format_tenant_app(app: TenantApp, mask_secret: bool = True) -> dict:
"wechat_corp_id": app.wechat_corp_id, "wechat_corp_id": app.wechat_corp_id,
"wechat_agent_id": app.wechat_agent_id, "wechat_agent_id": app.wechat_agent_id,
"has_wechat_secret": bool(app.wechat_secret_encrypted), "has_wechat_secret": bool(app.wechat_secret_encrypted),
"token_secret": "******" if mask_secret and app.token_secret else app.token_secret, "access_token": "******" if mask_secret and app.access_token else app.access_token,
"token_required": bool(app.token_required),
"allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [], "allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [],
"allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [], "allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [],
"status": app.status, "status": app.status,

View File

@@ -1,490 +1,481 @@
<script setup> <script setup>
import { ref, reactive, onMounted, computed } from 'vue' import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api' import api from '@/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
const loading = ref(false) const loading = ref(false)
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const query = reactive({ const query = reactive({
page: 1, page: 1,
size: 20, size: 20,
tenant_id: '', tenant_id: '',
app_code: '' app_code: ''
}) })
// 应用列表(从应用管理获取) // 应用列表(从应用管理获取)
const appList = ref([]) const appList = ref([])
const appToolsMap = ref({}) // app_code -> tools[] const appToolsMap = ref({}) // app_code -> tools[]
// 对话框 // 对话框
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogTitle = ref('') const dialogTitle = ref('')
const editingId = ref(null) const editingId = ref(null)
const formRef = ref(null) const formRef = ref(null)
const form = reactive({ const form = reactive({
tenant_id: '', tenant_id: '',
app_code: 'tools', app_code: 'tools',
app_name: '', app_name: '',
wechat_corp_id: '', wechat_corp_id: '',
wechat_agent_id: '', wechat_agent_id: '',
wechat_secret: '', wechat_secret: '',
token_required: false, allowed_tools: []
allowed_tools: [] })
})
// 根据选择的应用获取工具选项
// 根据选择的应用获取工具选项 const toolOptions = computed(() => {
const toolOptions = computed(() => { const tools = appToolsMap.value[form.app_code] || []
const tools = appToolsMap.value[form.app_code] || [] if (tools.length > 0) {
if (tools.length > 0) { return tools.map(t => ({ label: t.name, value: t.code }))
return tools.map(t => ({ label: t.name, value: t.code })) }
} // 默认工具列表(兼容旧数据)
// 默认工具列表(兼容旧数据) return [
return [ { label: '高情商回复', value: 'high-eq' },
{ label: '高情商回复', value: 'high-eq' }, { label: '头脑风暴', value: 'brainstorm' },
{ label: '头脑风暴', value: 'brainstorm' }, { label: '面诊方案', value: 'consultation' },
{ label: '面诊方案', value: 'consultation' }, { label: '客户画像', value: 'customer-profile' },
{ label: '客户画像', value: 'customer-profile' }, { label: '医疗合规', value: 'medical-compliance' }
{ label: '医疗合规', value: 'medical-compliance' } ]
] })
})
const rules = {
const rules = { tenant_id: [{ required: true, message: '请输入租户ID', trigger: 'blur' }],
tenant_id: [{ required: true, message: '请输入租户ID', trigger: 'blur' }], app_code: [{ required: true, message: '请选择应用', trigger: 'change' }]
app_code: [{ required: true, message: '请选择应用', trigger: 'change' }] }
}
// 生成链接对话框
// 生成链接对话框 const urlDialogVisible = ref(false)
const urlDialogVisible = ref(false) const urlLoading = ref(false)
const urlLoading = ref(false) const currentRow = ref(null)
const currentRow = ref(null) const selectedTool = ref('')
const selectedTool = ref('') const generatedUrl = ref('')
const generatedUrl = ref('') const urlInfo = ref({})
const urlInfo = ref({})
async function fetchApps() {
async function fetchApps() { try {
try { const res = await api.get('/api/apps/all')
const res = await api.get('/api/apps/all') appList.value = res.data || []
appList.value = res.data || []
// 获取每个应用的工具列表
// 获取每个应用的工具列表 for (const app of appList.value) {
for (const app of appList.value) { try {
try { const toolsRes = await api.get(`/api/apps/${app.app_code}/tools`)
const toolsRes = await api.get(`/api/apps/${app.app_code}/tools`) appToolsMap.value[app.app_code] = toolsRes.data || []
appToolsMap.value[app.app_code] = toolsRes.data || [] } catch (e) {
} catch (e) { appToolsMap.value[app.app_code] = []
appToolsMap.value[app.app_code] = [] }
} }
} } catch (e) {
} catch (e) { console.error('获取应用列表失败:', e)
console.error('获取应用列表失败:', e) }
} }
}
async function fetchList() {
async function fetchList() { loading.value = true
loading.value = true try {
try { const res = await api.get('/api/tenant-apps', { params: query })
const res = await api.get('/api/tenant-apps', { params: query }) tableData.value = res.data.items || []
tableData.value = res.data.items || [] total.value = res.data.total || 0
total.value = res.data.total || 0 } catch (e) {
} catch (e) { console.error(e)
console.error(e) } finally {
} finally { loading.value = false
loading.value = false }
} }
}
function handleSearch() {
function handleSearch() { query.page = 1
query.page = 1 fetchList()
fetchList() }
}
function handlePageChange(page) {
function handlePageChange(page) { query.page = page
query.page = page fetchList()
fetchList() }
}
function handleCreate() {
function handleCreate() { editingId.value = null
editingId.value = null dialogTitle.value = '新建应用配置'
dialogTitle.value = '新建应用配置' Object.assign(form, {
Object.assign(form, { tenant_id: '',
tenant_id: '', app_code: 'tools',
app_code: 'tools', app_name: '',
app_name: '', wechat_corp_id: '',
wechat_corp_id: '', wechat_agent_id: '',
wechat_agent_id: '', wechat_secret: '',
wechat_secret: '', allowed_tools: []
token_required: false, })
allowed_tools: [] dialogVisible.value = true
}) }
dialogVisible.value = true
} function handleEdit(row) {
editingId.value = row.id
function handleEdit(row) { dialogTitle.value = '编辑应用配置'
editingId.value = row.id Object.assign(form, {
dialogTitle.value = '编辑应用配置' tenant_id: row.tenant_id,
Object.assign(form, { app_code: row.app_code,
tenant_id: row.tenant_id, app_name: row.app_name || '',
app_code: row.app_code, wechat_corp_id: row.wechat_corp_id || '',
app_name: row.app_name || '', wechat_agent_id: row.wechat_agent_id || '',
wechat_corp_id: row.wechat_corp_id || '', wechat_secret: '', // 不回显密钥
wechat_agent_id: row.wechat_agent_id || '', allowed_tools: row.allowed_tools || []
wechat_secret: '', // 不回显密钥 })
token_required: row.token_required, dialogVisible.value = true
allowed_tools: row.allowed_tools || [] }
})
dialogVisible.value = true async function handleSubmit() {
} await formRef.value.validate()
async function handleSubmit() { const data = { ...form }
await formRef.value.validate() // 如果没有输入新密钥,不传这个字段
if (!data.wechat_secret) {
const data = { ...form } delete data.wechat_secret
// 如果没有输入新密钥,不传这个字段 }
if (!data.wechat_secret) {
delete data.wechat_secret try {
} if (editingId.value) {
await api.put(`/api/tenant-apps/${editingId.value}`, data)
try { ElMessage.success('更新成功')
if (editingId.value) { } else {
await api.put(`/api/tenant-apps/${editingId.value}`, data) const res = await api.post('/api/tenant-apps', data)
ElMessage.success('更新成功') ElMessage.success(`创建成功Access Token: ${res.data.access_token}`)
} else { }
const res = await api.post('/api/tenant-apps', data) dialogVisible.value = false
ElMessage.success(`创建成功Token Secret: ${res.data.token_secret}`) fetchList()
} } catch (e) {
dialogVisible.value = false // 错误已在拦截器处理
fetchList() }
} catch (e) { }
// 错误已在拦截器处理
} async function handleDelete(row) {
} await ElMessageBox.confirm(`确定删除此配置吗?`, '提示', {
type: 'warning'
async function handleDelete(row) { })
await ElMessageBox.confirm(`确定删除此配置吗?`, '提示', {
type: 'warning' try {
}) await api.delete(`/api/tenant-apps/${row.id}`)
ElMessage.success('删除成功')
try { fetchList()
await api.delete(`/api/tenant-apps/${row.id}`) } catch (e) {
ElMessage.success('删除成功') // 错误已在拦截器处理
fetchList() }
} catch (e) { }
// 错误已在拦截器处理
} async function handleRegenerateToken(row) {
} await ElMessageBox.confirm('重新生成 Access Token 将使旧的链接失效,确定继续?', '提示', {
type: 'warning'
async function handleRegenerateToken(row) { })
await ElMessageBox.confirm('重新生成 Token Secret 将使旧的签名失效,确定继续?', '提示', {
type: 'warning' try {
}) const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`)
ElMessage.success(`新 Access Token: ${res.data.access_token}`)
try { fetchList()
const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`) } catch (e) {
ElMessage.success(`新 Token Secret: ${res.data.token_secret}`) // 错误已在拦截器处理
fetchList() }
} catch (e) { }
// 错误已在拦截器处理
} async function handleViewSecret(row) {
} try {
const res = await api.get(`/api/tenant-apps/${row.id}/wechat-secret`)
async function handleViewSecret(row) { if (res.data.wechat_secret) {
try { ElMessageBox.alert(res.data.wechat_secret, '微信 Secret', {
const res = await api.get(`/api/tenant-apps/${row.id}/wechat-secret`) confirmButtonText: '关闭'
if (res.data.wechat_secret) { })
ElMessageBox.alert(res.data.wechat_secret, '微信 Secret', { } else {
confirmButtonText: '关闭' ElMessage.info('未配置微信 Secret')
}) }
} else { } catch (e) {
ElMessage.info('未配置微信 Secret') // 错误已在拦截器处理
} }
} catch (e) { }
// 错误已在拦截器处理
} // 生成链接功能
} function handleShowUrl(row) {
currentRow.value = row
// 生成链接功能 selectedTool.value = ''
function handleShowUrl(row) { generatedUrl.value = ''
currentRow.value = row urlInfo.value = {}
selectedTool.value = '' urlDialogVisible.value = true
generatedUrl.value = '' }
urlInfo.value = {}
urlDialogVisible.value = true async function handleGenerateUrl() {
} if (!currentRow.value) return
async function handleGenerateUrl() { urlLoading.value = true
if (!currentRow.value) return try {
const res = await api.post('/api/apps/generate-url', {
urlLoading.value = true tenant_id: currentRow.value.tenant_id,
try { app_code: currentRow.value.app_code,
const res = await api.post('/api/apps/generate-url', { tool_code: selectedTool.value || null
tenant_id: currentRow.value.tenant_id, })
app_code: currentRow.value.app_code,
tool_code: selectedTool.value || null if (res.data.success) {
}) generatedUrl.value = res.data.url
urlInfo.value = res.data
if (res.data.success) { } else {
generatedUrl.value = res.data.url ElMessage.error(res.data.error || '生成失败')
urlInfo.value = res.data }
} else { } catch (e) {
ElMessage.error(res.data.error || '生成失败') console.error(e)
} } finally {
} catch (e) { urlLoading.value = false
console.error(e) }
} finally { }
urlLoading.value = false
} function handleCopyUrl() {
} if (!generatedUrl.value) return
function handleCopyUrl() { navigator.clipboard.writeText(generatedUrl.value).then(() => {
if (!generatedUrl.value) return ElMessage.success('链接已复制到剪贴板')
}).catch(() => {
navigator.clipboard.writeText(generatedUrl.value).then(() => { // 降级方案
ElMessage.success('链接已复制到剪贴板') const input = document.createElement('input')
}).catch(() => { input.value = generatedUrl.value
// 降级方案 document.body.appendChild(input)
const input = document.createElement('input') input.select()
input.value = generatedUrl.value document.execCommand('copy')
document.body.appendChild(input) document.body.removeChild(input)
input.select() ElMessage.success('链接已复制到剪贴板')
document.execCommand('copy') })
document.body.removeChild(input) }
ElMessage.success('链接已复制到剪贴板')
}) // 获取当前行可选的工具
} const currentToolOptions = computed(() => {
if (!currentRow.value) return []
// 获取当前行可选的工具 const appTools = appToolsMap.value[currentRow.value.app_code] || []
const currentToolOptions = computed(() => { const allowedTools = currentRow.value.allowed_tools || []
if (!currentRow.value) return []
const appTools = appToolsMap.value[currentRow.value.app_code] || [] if (appTools.length > 0) {
const allowedTools = currentRow.value.allowed_tools || [] // 过滤出允许的工具
if (allowedTools.length > 0) {
if (appTools.length > 0) { return appTools.filter(t => allowedTools.includes(t.code)).map(t => ({ label: t.name, value: t.code }))
// 过滤出允许的工具 }
if (allowedTools.length > 0) { return appTools.map(t => ({ label: t.name, value: t.code }))
return appTools.filter(t => allowedTools.includes(t.code)).map(t => ({ label: t.name, value: t.code })) }
}
return appTools.map(t => ({ label: t.name, value: t.code })) // 默认工具
} const defaultTools = [
{ label: '高情商回复', value: 'high-eq' },
// 默认工具 { label: '头脑风暴', value: 'brainstorm' },
const defaultTools = [ { label: '面诊方案', value: 'consultation' },
{ label: '高情商回复', value: 'high-eq' }, { label: '客户画像', value: 'customer-profile' },
{ label: '头脑风暴', value: 'brainstorm' }, { label: '医疗合规', value: 'medical-compliance' }
{ label: '面诊方案', value: 'consultation' }, ]
{ label: '客户画像', value: 'customer-profile' }, if (allowedTools.length > 0) {
{ label: '医疗合规', value: 'medical-compliance' } return defaultTools.filter(t => allowedTools.includes(t.value))
] }
if (allowedTools.length > 0) { return defaultTools
return defaultTools.filter(t => allowedTools.includes(t.value)) })
}
return defaultTools onMounted(() => {
}) fetchApps()
fetchList()
onMounted(() => { })
fetchApps() </script>
fetchList()
}) <template>
</script> <div class="page-container">
<div class="page-header">
<template> <div class="title">应用配置</div>
<div class="page-container"> <el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<div class="page-header"> <el-icon><Plus /></el-icon>
<div class="title">应用配置</div> 新建配置
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate"> </el-button>
<el-icon><Plus /></el-icon> </div>
新建配置
</el-button> <!-- 搜索栏 -->
</div> <div class="search-bar">
<el-input
<!-- 搜索栏 --> v-model="query.tenant_id"
<div class="search-bar"> placeholder="租户ID"
<el-input clearable
v-model="query.tenant_id" style="width: 160px"
placeholder="租户ID" @keyup.enter="handleSearch"
clearable />
style="width: 160px" <el-select v-model="query.app_code" placeholder="应用" clearable style="width: 120px">
@keyup.enter="handleSearch" <el-option label="tools" value="tools" />
/> <el-option label="interview" value="interview" />
<el-select v-model="query.app_code" placeholder="应用" clearable style="width: 120px"> </el-select>
<el-option label="tools" value="tools" /> <el-button type="primary" @click="handleSearch">搜索</el-button>
<el-option label="interview" value="interview" /> </div>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button> <!-- 表格 -->
</div> <el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<!-- 表格 --> <el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table v-loading="loading" :data="tableData" style="width: 100%"> <el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="id" label="ID" width="60" /> <el-table-column prop="app_name" label="应用名称" width="150" />
<el-table-column prop="tenant_id" label="租户ID" width="120" /> <el-table-column prop="wechat_corp_id" label="企业ID" width="150" show-overflow-tooltip />
<el-table-column prop="app_code" label="应用" width="100" /> <el-table-column prop="wechat_agent_id" label="应用ID" width="100" />
<el-table-column prop="app_name" label="应用名称" width="150" /> <el-table-column label="微信密钥" width="100">
<el-table-column prop="wechat_corp_id" label="企业ID" width="150" show-overflow-tooltip /> <template #default="{ row }">
<el-table-column prop="wechat_agent_id" label="应用ID" width="100" /> <el-tag v-if="row.has_wechat_secret" type="success" size="small">已配置</el-tag>
<el-table-column label="微信密钥" width="100"> <el-tag v-else type="info" size="small">未配置</el-tag>
<template #default="{ row }"> </template>
<el-tag v-if="row.has_wechat_secret" type="success" size="small">已配置</el-tag> </el-table-column>
<el-tag v-else type="info" size="small">未配置</el-tag> <el-table-column label="Access Token" width="120">
</template> <template #default="{ row }">
</el-table-column> <el-tag v-if="row.access_token" type="success" size="small">已配置</el-tag>
<el-table-column label="Token 验证" width="100"> <el-tag v-else type="danger" size="small">未配置</el-tag>
<template #default="{ row }"> </template>
<el-tag :type="row.token_required ? 'warning' : 'info'" size="small"> </el-table-column>
{{ row.token_required ? '必须' : '可选' }} <el-table-column prop="allowed_tools" label="允许工具" min-width="150">
</el-tag> <template #default="{ row }">
</template> <el-tag v-for="tool in (row.allowed_tools || []).slice(0, 3)" :key="tool" size="small" style="margin-right: 4px">
</el-table-column> {{ tool }}
<el-table-column prop="allowed_tools" label="允许工具" min-width="150"> </el-tag>
<template #default="{ row }"> <span v-if="(row.allowed_tools || []).length > 3">...</span>
<el-tag v-for="tool in (row.allowed_tools || []).slice(0, 3)" :key="tool" size="small" style="margin-right: 4px"> </template>
{{ tool }} </el-table-column>
</el-tag> <el-table-column label="操作" width="300" fixed="right">
<span v-if="(row.allowed_tools || []).length > 3">...</span> <template #default="{ row }">
</template> <el-button type="success" link size="small" @click="handleShowUrl(row)">生成链接</el-button>
</el-table-column> <el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-table-column label="操作" width="300" fixed="right"> <el-button v-if="authStore.isOperator" type="warning" link size="small" @click="handleViewSecret(row)">密钥</el-button>
<template #default="{ row }"> <el-button v-if="authStore.isOperator" type="info" link size="small" @click="handleRegenerateToken(row)">重置</el-button>
<el-button type="success" link size="small" @click="handleShowUrl(row)">生成链接</el-button> <el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button> </template>
<el-button v-if="authStore.isOperator" type="warning" link size="small" @click="handleViewSecret(row)">密钥</el-button> </el-table-column>
<el-button v-if="authStore.isOperator" type="info" link size="small" @click="handleRegenerateToken(row)">重置</el-button> </el-table>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template> <!-- 分页 -->
</el-table-column> <div style="margin-top: 20px; display: flex; justify-content: flex-end">
</el-table> <el-pagination
v-model:current-page="query.page"
<!-- 分页 --> :page-size="query.size"
<div style="margin-top: 20px; display: flex; justify-content: flex-end"> :total="total"
<el-pagination layout="total, prev, pager, next"
v-model:current-page="query.page" @current-change="handlePageChange"
:page-size="query.size" />
:total="total" </div>
layout="total, prev, pager, next"
@current-change="handlePageChange" <!-- 编辑对话框 -->
/> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
</div> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="租户ID" prop="tenant_id">
<!-- 编辑对话框 --> <el-input v-model="form.tenant_id" :disabled="!!editingId" placeholder="如: tenant_001" />
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px"> </el-form-item>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px"> <el-form-item label="应用" prop="app_code">
<el-form-item label="租户ID" prop="tenant_id"> <el-select v-model="form.app_code" :disabled="!!editingId" placeholder="选择应用" style="width: 100%">
<el-input v-model="form.tenant_id" :disabled="!!editingId" placeholder="如: tenant_001" /> <el-option v-for="app in appList" :key="app.app_code" :label="app.app_name" :value="app.app_code" />
</el-form-item> <el-option label="tools (默认)" value="tools" />
<el-form-item label="应用" prop="app_code"> </el-select>
<el-select v-model="form.app_code" :disabled="!!editingId" placeholder="选择应用" style="width: 100%"> </el-form-item>
<el-option v-for="app in appList" :key="app.app_code" :label="app.app_name" :value="app.app_code" /> <el-form-item label="配置名称">
<el-option label="tools (默认)" value="tools" /> <el-input v-model="form.app_name" placeholder="显示名称(可选)" />
</el-select> </el-form-item>
</el-form-item>
<el-form-item label="配置名称"> <el-divider content-position="left">企业微信配置</el-divider>
<el-input v-model="form.app_name" placeholder="显示名称(可选)" />
</el-form-item> <el-form-item label="企业 ID">
<el-input v-model="form.wechat_corp_id" placeholder="ww开头的企业ID" />
<el-divider content-position="left">企业微信配置</el-divider> </el-form-item>
<el-form-item label="应用 ID">
<el-form-item label="企业 ID"> <el-input v-model="form.wechat_agent_id" placeholder="自建应用的 AgentId" />
<el-input v-model="form.wechat_corp_id" placeholder="ww开头的企业ID" /> </el-form-item>
</el-form-item> <el-form-item label="应用 Secret">
<el-form-item label="应用 ID"> <el-input v-model="form.wechat_secret" type="password" show-password :placeholder="editingId ? '留空则不修改' : '应用的 Secret'" />
<el-input v-model="form.wechat_agent_id" placeholder="自建应用的 AgentId" /> </el-form-item>
</el-form-item>
<el-form-item label="应用 Secret"> <el-divider content-position="left">权限配置</el-divider>
<el-input v-model="form.wechat_secret" type="password" show-password :placeholder="editingId ? '留空则不修改' : '应用的 Secret'" />
</el-form-item> <el-form-item label="允许的工具">
<el-checkbox-group v-model="form.allowed_tools">
<el-divider content-position="left">鉴权配置</el-divider> <el-checkbox v-for="opt in toolOptions" :key="opt.value" :label="opt.value">{{ opt.label }}</el-checkbox>
</el-checkbox-group>
<el-form-item label="强制 Token 验证"> </el-form-item>
<el-switch v-model="form.token_required" /> </el-form>
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后 URL 必须携带有效签名</span> <template #footer>
</el-form-item> <el-button @click="dialogVisible = false">取消</el-button>
<el-form-item label="允许的工具"> <el-button type="primary" @click="handleSubmit">确定</el-button>
<el-checkbox-group v-model="form.allowed_tools"> </template>
<el-checkbox v-for="opt in toolOptions" :key="opt.value" :label="opt.value">{{ opt.label }}</el-checkbox> </el-dialog>
</el-checkbox-group>
</el-form-item> <!-- 生成链接对话框 -->
</el-form> <el-dialog v-model="urlDialogVisible" title="生成访问链接" width="650px">
<template #footer> <div v-if="currentRow" class="url-dialog-content">
<el-button @click="dialogVisible = false">取消</el-button> <el-descriptions :column="2" border size="small" style="margin-bottom: 20px">
<el-button type="primary" @click="handleSubmit">确定</el-button> <el-descriptions-item label="租户ID">{{ currentRow.tenant_id }}</el-descriptions-item>
</template> <el-descriptions-item label="应用">{{ currentRow.app_code }}</el-descriptions-item>
</el-dialog> <el-descriptions-item label="Access Token">
<el-tag v-if="currentRow.access_token" type="success" size="small">已配置</el-tag>
<!-- 生成链接对话框 --> <el-tag v-else type="danger" size="small">未配置</el-tag>
<el-dialog v-model="urlDialogVisible" title="生成访问链接" width="650px"> </el-descriptions-item>
<div v-if="currentRow" class="url-dialog-content"> <el-descriptions-item label="允许工具">
<el-descriptions :column="2" border size="small" style="margin-bottom: 20px"> {{ (currentRow.allowed_tools || []).length > 0 ? currentRow.allowed_tools.join(', ') : '全部' }}
<el-descriptions-item label="租户ID">{{ currentRow.tenant_id }}</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="应用">{{ currentRow.app_code }}</el-descriptions-item> </el-descriptions>
<el-descriptions-item label="签名要求">
<el-tag :type="currentRow.token_required ? 'warning' : 'success'" size="small"> <el-form label-width="80px">
{{ currentRow.token_required ? '需要签名' : '免签名' }} <el-form-item label="选择工具">
</el-tag> <el-select v-model="selectedTool" placeholder="选择工具(留空则生成首页链接)" clearable style="width: 100%">
</el-descriptions-item> <el-option v-for="opt in currentToolOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
<el-descriptions-item label="允许工具"> </el-select>
{{ (currentRow.allowed_tools || []).length > 0 ? currentRow.allowed_tools.join(', ') : '全部' }} </el-form-item>
</el-descriptions-item> <el-form-item>
</el-descriptions> <el-button type="primary" :loading="urlLoading" @click="handleGenerateUrl">
生成链接
<el-form label-width="80px"> </el-button>
<el-form-item label="选择工具"> </el-form-item>
<el-select v-model="selectedTool" placeholder="选择工具(留空则生成首页链接)" clearable style="width: 100%"> </el-form>
<el-option v-for="opt in currentToolOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select> <div v-if="generatedUrl" class="url-result">
</el-form-item> <el-divider content-position="left">生成结果</el-divider>
<el-form-item>
<el-button type="primary" :loading="urlLoading" @click="handleGenerateUrl"> <el-alert
生成链接 type="success"
</el-button> :title="urlInfo.note || '静态链接,长期有效'"
</el-form-item> :closable="false"
</el-form> style="margin-bottom: 12px"
/>
<div v-if="generatedUrl" class="url-result">
<el-divider content-position="left">生成结果</el-divider> <div class="url-box">
<el-input
<el-alert v-model="generatedUrl"
:type="urlInfo.token_required ? 'warning' : 'success'" type="textarea"
:title="urlInfo.note" :rows="3"
:closable="false" readonly
style="margin-bottom: 12px" />
/> <el-button type="primary" style="margin-top: 10px" @click="handleCopyUrl">
<el-icon><CopyDocument /></el-icon>
<div class="url-box"> 复制链接
<el-input </el-button>
v-model="generatedUrl" </div>
type="textarea" </div>
:rows="3" </div>
readonly <template #footer>
/> <el-button @click="urlDialogVisible = false">关闭</el-button>
<el-button type="primary" style="margin-top: 10px" @click="handleCopyUrl"> </template>
<el-icon><CopyDocument /></el-icon> </el-dialog>
复制链接 </div>
</el-button> </template>
</div>
</div> <style scoped>
</div> .url-dialog-content {
<template #footer> padding: 0 10px;
<el-button @click="urlDialogVisible = false">关闭</el-button> }
</template>
</el-dialog> .url-result {
</div> margin-top: 10px;
</template> }
<style scoped> .url-box {
.url-dialog-content { background: #f5f7fa;
padding: 0 10px; padding: 15px;
} border-radius: 4px;
}
.url-result { </style>
margin-top: 10px;
}
.url-box {
background: #f5f7fa;
padding: 15px;
border-radius: 4px;
}
</style>