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)
# 鉴权配置
token_secret = Column(String(64))
token_required = Column(SmallInteger, default=0)
access_token = Column(String(64)) # 访问令牌(长期有效)
allowed_origins = Column(Text) # JSON 数组
# 功能权限

View File

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

View File

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