chore: 同步本地到 main 和 Gitea

Made-with: Cursor
This commit is contained in:
卡若
2026-03-16 14:48:26 +08:00
parent a83d652734
commit 7c72871a7a
13 changed files with 519 additions and 262 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -19,9 +19,9 @@ function isSystemIntent(msg: string): boolean {
const m = msg.trim().toLowerCase()
return !m || m === '?' ||
/状态|统计|总量/.test(m) ||
/^rfm|估值|价值$/.test(m) ||
/^高价值|top|排行/.test(m) ||
/帮助|help/.test(m)
/rfm|估值|价值|高价值|top|排行/.test(m) ||
/帮助|help/.test(m) ||
/\d+\s*人|\d+\s*个|前\s*\d+|top\s*\d+/i.test(m)
}
// 将输入拆成多段支持一人或多人手机、QQ、身份证、姓名/关键词)
@@ -59,19 +59,25 @@ function parseSegments(message: string): { type: 'phone' | 'qq' | 'id_card' | 'k
return segments.length ? segments : [{ type: 'keyword', value: raw }]
}
// 解析用户意图(兼容旧单条逻辑
function parseIntent(message: string): { type: string; query?: string; params?: any } {
const msg = message.trim().toLowerCase()
if (isSystemIntent(message)) {
if (/状态|统计|总量/.test(msg)) return { type: "system_status" }
if (/rfm|估值|价值/.test(msg)) return { type: "rfm_analysis" }
if (/高价值|top|排行/.test(msg)) {
const limitMatch = message.match(/(\d+)/)
return { type: "high_value_users", params: { limit: limitMatch ? parseInt(limitMatch[1]) : 10 } }
}
if (/帮助|help|\?/.test(msg)) return { type: "help" }
// 解析用户意图(先匹配「高价值/TOP/排行」再匹配「价值」避免「找出最高价值10人」被误判为 rfm
function parseIntent(message: string): { type: string; query?: string; params?: any; thinking?: string } {
const raw = message.trim()
const msg = raw.toLowerCase()
// 1) 系统状态
if (/状态|统计|总量/.test(msg)) return { type: "system_status", thinking: "意图: 系统状态 → 数据源: KR 统计 → 输出: 连接与库数量" }
// 2) 高价值用户必须优先于「价值」否则「最高价值10人」会被判成 rfm
if (/高价值|最高价值|top|排行|前\s*\d+\s*名?|找出\s*.*价值|\d+\s*人.*价值|价值\s*最高|\d+\s*个.*价值/.test(msg)) {
const limitMatch = raw.match(/(\d+)\s*[人个名]?|top\s*(\d+)|前\s*(\d+)/i) || raw.match(/(\d+)/)
const limit = limitMatch ? parseInt(limitMatch[1] || limitMatch[2] || limitMatch[3] || limitMatch[0]) : 10
const safeLimit = Math.min(Math.max(limit || 10, 1), 50)
return { type: "high_value_users", params: { limit: safeLimit }, thinking: `意图: 高价值用户 TOP${safeLimit} → 数据源: KR.用户估值(user_evaluation_score 降序) → 输出: 前${safeLimit}条完整画像+AI分析` }
}
return { type: "unified_search", query: message }
// 3) RFM/估值分析(仅明确是「分布/分析」时)
if (/rfm|估值分布|价值分布|用户等级分布/.test(msg)) return { type: "rfm_analysis", thinking: "意图: RFM/等级分布 → 数据源: KR.用户估值 按 user_level 聚合 → 输出: 各等级人数" }
// 4) 帮助
if (/帮助|help|\?/.test(msg)) return { type: "help", thinking: "意图: 帮助 → 输出: 使用说明" }
// 5) 手机/QQ/身份证/姓名/关键词 → 统一搜索
return { type: "unified_search", query: message, thinking: "意图: 用户画像查询 → 数据源: KR + KR_腾讯 等 → 输出: 匹配用户完整画像+AI分析" }
}
// 格式化用户数据(简要)
@@ -177,20 +183,60 @@ function generateAIAnalysis(fullProfile: { valuation?: any; qqPhone?: any; ckbAs
return parts.join(' ')
}
// 处理 AI 聊天
async function processChat(message: string): Promise<ChatMessage> {
// 处理 AI 聊天(先拆解意图再执行,避免直接返回「未找到」)
async function processChat(message: string): Promise<ChatMessage & { thinking?: string }> {
const startTime = Date.now()
const intent = parseIntent(message)
const thinking = intent.thinking || `意图: ${intent.type} → 执行查询`
try {
switch (intent.type) {
case "unified_search": {
const segments = parseSegments(message)
const phones = await unifiedResolveToPhones(segments, 10)
// 无结果时先做一次「意图重试」:若像在问高价值/TOP则按高价值用户查询避免直接报未找到
if (phones.length === 0 && /价值|高|top|排行|前\s*\d+|找出|多少\s*人|\d+\s*人/.test(message)) {
const fallback = parseIntent(message)
if (fallback.type === "high_value_users") {
const limit = fallback.params?.limit || 10
const client = await getMongoClient()
const db = client.db("KR")
const users = await db.collection("用户估值")
.find({ user_evaluation_score: { $exists: true } })
.sort({ user_evaluation_score: -1 })
.limit(limit)
.toArray()
const profiles: { fullProfile: any; aiAnalysis: string }[] = []
for (const u of users) {
const phone = u.phone || u.phone_masked
if (phone) {
const fullProfile = await queryFullProfile(phone)
if (fullProfile.valuation || fullProfile.qqPhone) {
profiles.push({ fullProfile, aiAnalysis: generateAIAnalysis(fullProfile) })
}
}
}
if (profiles.length > 0) {
const contentParts = [`🔍 已按「高价值用户」理解,共找到 ${profiles.length} 条(前 ${limit} 名)\n`]
profiles.forEach((p, i) => {
contentParts.push(formatFullPortrait(p.fullProfile, i))
contentParts.push(`\n🤖 AI 分析:${p.aiAnalysis}\n`)
})
contentParts.push(`\n⏱ 查询耗时: ${Date.now() - startTime}ms`)
return {
role: "assistant",
content: contentParts.join('\n'),
data: { profiles, list: profiles },
thinking: `意图重试: 关键字未命中 → 理解为「高价值用户 TOP${limit}」→ 按估值分降序查询 → 输出 ${profiles.length}`
}
}
}
}
if (phones.length === 0) {
return {
role: "assistant",
content: `🤔 未找到与「${message}」相关的用户\n\n💡 支持手机号、QQ、身份证、姓名/城市/省份,可多条用逗号分隔,最多列出前 10 条完整画像`
content: `🤔 未找到与「${message}直接相关的用户\n\n💡 支持手机号、QQ、身份证、姓名/城市/省份,可多条用逗号分隔;或输入「高价值用户 TOP10」查看估值前 10 名`,
thinking
}
}
const profiles: { fullProfile: any; aiAnalysis: string }[] = []
@@ -206,7 +252,8 @@ async function processChat(message: string): Promise<ChatMessage> {
if (profiles.length === 0) {
return {
role: "assistant",
content: `❌ 未解析到有效用户画像\n\n⏱ 耗时: ${Date.now() - startTime}ms`
content: `❌ 未解析到有效用户画像\n\n⏱ 耗时: ${Date.now() - startTime}ms`,
thinking
}
}
const contentParts: string[] = [
@@ -220,7 +267,8 @@ async function processChat(message: string): Promise<ChatMessage> {
return {
role: "assistant",
content: contentParts.join('\n'),
data: { profiles, list: profiles }
data: { profiles, list: profiles },
thinking
}
}
@@ -234,7 +282,8 @@ async function processChat(message: string): Promise<ChatMessage> {
`📄 总记录数: ${(stats.totalDocuments / 1e8).toFixed(2)} 亿条\n` +
`💾 总数据量: ${(stats.totalSize / 1e9).toFixed(2)} GB\n` +
`⏱️ 响应延迟: ${stats.latency}ms`,
data: stats
data: stats,
thinking
}
}
@@ -260,7 +309,8 @@ async function processChat(message: string): Promise<ChatMessage> {
return {
role: "assistant",
content,
data: results
data: results,
thinking: intent.thinking || "意图: RFM 分布 → 按 user_level 聚合"
}
}
@@ -273,18 +323,27 @@ async function processChat(message: string): Promise<ChatMessage> {
.sort({ user_evaluation_score: -1 })
.limit(limit)
.toArray()
let content = `🏆 高价值用户 TOP${limit}\n\n`
users.forEach((u, i) => {
const phone = u.phone?.replace(/(\+?86)?(\d{3})\d{4}(\d{4})/, '$2****$3') || '***'
content += `${i + 1}. ${u.name || '未知'} (${phone}) - 估值: ${u.user_evaluation_score}\n`
const profiles: { fullProfile: any; aiAnalysis: string }[] = []
for (const u of users) {
const phone = u.phone || u.phone_masked
if (phone) {
const fullProfile = await queryFullProfile(phone)
if (fullProfile.valuation || fullProfile.qqPhone) {
profiles.push({ fullProfile, aiAnalysis: generateAIAnalysis(fullProfile) })
}
}
}
const contentParts = [`🏆 高价值用户 TOP${limit}(按估值分降序)\n`]
profiles.forEach((p, i) => {
contentParts.push(formatFullPortrait(p.fullProfile, i))
contentParts.push(`\n🤖 AI 分析:${p.aiAnalysis}\n`)
})
content += `\n⏱ 查询耗时: ${Date.now() - startTime}ms`
contentParts.push(`\n⏱ 查询耗时: ${Date.now() - startTime}ms`)
return {
role: "assistant",
content,
data: users
content: contentParts.join('\n'),
data: { profiles, list: profiles },
thinking
}
}
@@ -295,15 +354,18 @@ async function processChat(message: string): Promise<ChatMessage> {
`📱 手机 / 💬 QQ / 🪪 身份证 / 👤 姓名:统一搜索,最多列出前 10 条完整画像\n\n` +
`• 支持多条同时查:用逗号或换行分隔,如 "13800138000, 28533368 qq, 张三"\n` +
`• 每条画像含手机、QQ、地址、统一标签、流量池、存客宝、AI 分析\n\n` +
`📊 系统状态 / 🏆 高价值 / 📈 RFM输入 "系统状态"、"高价值用户 TOP10"、"RFM分析"\n\n` +
`💡 数据覆盖: 20亿+用户,曼谷库内数据统一展示`
`🏆 高价值用户支持「找出最高价值10人」「高价值 TOP10」「前10名」等先拆解意图再查询\n\n` +
`📊 系统状态 / 📈 RFM输入 "系统状态"、"RFM分析"\n\n` +
`💡 数据覆盖: 20亿+用户,曼谷库内数据统一展示`,
thinking
}
}
default: {
return {
role: "assistant",
content: `🤔 未识别指令\n\n输入 "帮助" 查看支持手机、QQ、身份证、姓名可多条件,前 10 条完整画像`
content: `🤔 未识别指令\n\n输入 "帮助" 查看支持手机、QQ、身份证、姓名可多条件;或「高价值用户 TOP10」查看估值前 10 名`,
thinking
}
}
}

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { getMongoClient } from '@/lib/mongodb'
// API密钥接口
interface APIKey {
@@ -100,40 +101,13 @@ const DEFAULT_FIELD_PERMISSIONS: FieldPermission[] = [
},
]
// 内存存储生产环境应使用MongoDB
let apiKeys: APIKey[] = [
{
id: 'key_1',
name: '存客宝-生产环境',
key: 'sk-archer-ckb-prod-a1b2c3d4e5f6',
secret: 'sec-ckb-x9y8z7w6v5u4',
status: 'active',
createdAt: '2026-01-15',
expiresAt: null,
lastUsed: '2026-01-31 14:32:15',
permissions: JSON.parse(JSON.stringify(DEFAULT_FIELD_PERMISSIONS)),
rateLimit: { requestsPerDay: 5000, requestsPerMonth: 150000 },
billing: { plan: 'pro', usedCredits: 12580, totalCredits: 50000 },
callStats: { today: 342, thisMonth: 8956, total: 45678 }
},
{
id: 'key_2',
name: '点了码-测试环境',
key: 'sk-archer-dlm-test-g7h8i9j0k1l2',
secret: 'sec-dlm-m3n4o5p6q7r8',
status: 'active',
createdAt: '2026-01-20',
expiresAt: '2026-04-20',
lastUsed: '2026-01-30 09:15:42',
permissions: JSON.parse(JSON.stringify(DEFAULT_FIELD_PERMISSIONS)).map((g: FieldPermission) => ({
...g,
fields: g.fields.map(f => ({ ...f, enabled: f.price <= 2 }))
})),
rateLimit: { requestsPerDay: 1000, requestsPerMonth: 30000 },
billing: { plan: 'basic', usedCredits: 2340, totalCredits: 10000 },
callStats: { today: 56, thisMonth: 1234, total: 5678 }
},
]
const COLLECTION = 'api_keys'
const DB_NAME = 'KR'
async function getCollection() {
const client = await getMongoClient()
return client.db(DB_NAME).collection<APIKey & { _id?: any }>(COLLECTION)
}
// 生成随机密钥
function generateKey(prefix: string): string {
@@ -152,12 +126,14 @@ export async function GET(request: NextRequest) {
const action = searchParams.get('action')
const keyId = searchParams.get('id')
const col = await getCollection()
// 验证单个密钥
if (action === 'validate') {
const apiKey = searchParams.get('key')
const apiSecret = searchParams.get('secret')
const key = apiKeys.find(k => k.key === apiKey && k.secret === apiSecret)
const key = await col.findOne({ key: apiKey, secret: apiSecret })
if (!key) {
return NextResponse.json({
@@ -173,7 +149,6 @@ export async function GET(request: NextRequest) {
}, { status: 403 })
}
// 检查是否过期
if (key.expiresAt && new Date(key.expiresAt) < new Date()) {
return NextResponse.json({
success: false,
@@ -196,7 +171,7 @@ export async function GET(request: NextRequest) {
// 获取单个密钥详情
if (keyId) {
const key = apiKeys.find(k => k.id === keyId)
const key = await col.findOne({ id: keyId })
if (!key) {
return NextResponse.json({
success: false,
@@ -210,14 +185,15 @@ export async function GET(request: NextRequest) {
}
// 获取所有密钥列表
const keys = await col.find({}).sort({ createdAt: -1 }).toArray()
return NextResponse.json({
success: true,
data: apiKeys.map(k => ({
data: keys.map(k => ({
...k,
key: k.key.substring(0, 12) + '••••••••••••', // 脱敏显示
key: k.key.substring(0, 12) + '••••••••••••',
secret: '••••••••••••••••'
})),
total: apiKeys.length
total: keys.length
})
} catch (error) {
@@ -266,7 +242,8 @@ export async function POST(request: NextRequest) {
callStats: { today: 0, thisMonth: 0, total: 0 }
}
apiKeys.push(newKey)
const col = await getCollection()
await col.insertOne(newKey)
return NextResponse.json({
success: true,
@@ -289,53 +266,57 @@ export async function PUT(request: NextRequest) {
const body = await request.json()
const { id, action, permissions, status } = body
const keyIndex = apiKeys.findIndex(k => k.id === id)
if (keyIndex === -1) {
const col = await getCollection()
const key = await col.findOne({ id })
if (!key) {
return NextResponse.json({
success: false,
error: '密钥不存在'
}, { status: 404 })
}
// 更新权限
if (action === 'updatePermissions' && permissions) {
apiKeys[keyIndex].permissions = permissions
await col.updateOne({ id }, { $set: { permissions } })
const updated = await col.findOne({ id })
return NextResponse.json({
success: true,
data: apiKeys[keyIndex],
data: updated,
message: '权限更新成功'
})
}
// 切换状态
if (action === 'toggleStatus') {
apiKeys[keyIndex].status = apiKeys[keyIndex].status === 'active' ? 'disabled' : 'active'
const newStatus = key.status === 'active' ? 'disabled' : 'active'
await col.updateOne({ id }, { $set: { status: newStatus } })
const updated = await col.findOne({ id })
return NextResponse.json({
success: true,
data: apiKeys[keyIndex],
message: `密钥已${apiKeys[keyIndex].status === 'active' ? '启用' : '禁用'}`
data: updated,
message: `密钥已${newStatus === 'active' ? '启用' : '禁用'}`
})
}
// 重新生成密钥
if (action === 'regenerate') {
apiKeys[keyIndex].key = generateKey('sk-archer-')
apiKeys[keyIndex].secret = generateKey('sec-')
const newKey = generateKey('sk-archer-')
const newSecret = generateKey('sec-')
await col.updateOne({ id }, { $set: { key: newKey, secret: newSecret } })
const updated = await col.findOne({ id })
return NextResponse.json({
success: true,
data: apiKeys[keyIndex],
data: updated,
message: '密钥已重新生成'
})
}
// 常规更新
if (status) {
apiKeys[keyIndex].status = status
await col.updateOne({ id }, { $set: { status } })
}
const updated = await col.findOne({ id })
return NextResponse.json({
success: true,
data: apiKeys[keyIndex],
data: updated,
message: '更新成功'
})
@@ -361,19 +342,19 @@ export async function DELETE(request: NextRequest) {
}, { status: 400 })
}
const keyIndex = apiKeys.findIndex(k => k.id === id)
if (keyIndex === -1) {
const col = await getCollection()
const result = await col.findOneAndDelete({ id })
if (!result) {
return NextResponse.json({
success: false,
error: '密钥不存在'
}, { status: 404 })
}
const deletedKey = apiKeys.splice(keyIndex, 1)[0]
return NextResponse.json({
success: true,
data: { id: deletedKey.id, name: deletedKey.name },
data: { id: result.id, name: result.name },
message: '密钥已删除'
})

View File

@@ -203,8 +203,10 @@ const MOCK_API_KEYS: APIKey[] = [
]
export default function APIKeysPage() {
const [apiKeys, setApiKeys] = useState<APIKey[]>(MOCK_API_KEYS)
const [apiKeys, setApiKeys] = useState<APIKey[]>([])
const [apiBaseUrl, setApiBaseUrl] = useState('')
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
// 弹窗状态
const [showCreateKeyDialog, setShowCreateKeyDialog] = useState(false)
@@ -229,9 +231,25 @@ export default function APIKeysPage() {
// 显示/隐藏密钥
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set())
// 拉取密钥列表
const fetchKeys = async () => {
try {
const res = await fetch('/api/api-keys')
const json = await res.json()
if (json.success && json.data) {
setApiKeys(json.data)
}
} catch (e) {
console.error('拉取API密钥失败:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
const host = typeof window !== 'undefined' ? window.location.origin : ''
setApiBaseUrl(host)
fetchKeys()
}, [])
const copyToClipboard = (text: string, id: string) => {
@@ -274,40 +292,64 @@ export default function APIKeysPage() {
}
// 创建新密钥
const handleCreateKey = () => {
const plan = BILLING_PLANS.find(p => p.id === newKeyForm.plan)!
const newKey: APIKey = {
id: `key_${Date.now()}`,
name: newKeyForm.name,
key: `sk-archer-${Math.random().toString(36).substring(2, 14)}`,
secret: `sec-${Math.random().toString(36).substring(2, 14)}`,
status: 'active',
createdAt: new Date().toISOString().split('T')[0],
expiresAt: newKeyForm.expiresAt || null,
lastUsed: null,
permissions: JSON.parse(JSON.stringify(FIELD_GROUPS)),
rateLimit: { requestsPerDay: plan.requestsPerDay, requestsPerMonth: plan.requestsPerMonth },
billing: { plan: plan.id as 'free' | 'basic' | 'pro' | 'enterprise', usedCredits: 0, totalCredits: plan.credits },
callStats: { today: 0, thisMonth: 0, total: 0 }
const handleCreateKey = async () => {
if (!newKeyForm.name.trim()) return
setCreating(true)
try {
const res = await fetch('/api/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newKeyForm.name.trim(),
plan: newKeyForm.plan,
expiresAt: newKeyForm.expiresAt || undefined,
}),
})
const json = await res.json()
if (json.success && json.data) {
setApiKeys([json.data, ...apiKeys])
setShowCreateKeyDialog(false)
setNewKeyForm({ name: '', plan: 'basic', expiresAt: '' })
} else {
alert(json.error || '创建失败')
}
} catch (e) {
alert('创建失败')
} finally {
setCreating(false)
}
setApiKeys([...apiKeys, newKey])
setShowCreateKeyDialog(false)
setNewKeyForm({ name: '', plan: 'basic', expiresAt: '' })
}
// 切换密钥状态
const toggleKeyStatus = (keyId: string) => {
setApiKeys(apiKeys.map(k =>
k.id === keyId
? { ...k, status: k.status === 'active' ? 'disabled' : 'active' }
: k
))
const toggleKeyStatus = async (keyId: string) => {
try {
const res = await fetch('/api/api-keys', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: keyId, action: 'toggleStatus' }),
})
const json = await res.json()
if (json.success && json.data) {
setApiKeys(apiKeys.map(k => k.id === keyId ? { ...k, ...json.data } : k))
}
} catch (e) {
console.error('切换状态失败:', e)
}
}
// 删除密钥
const deleteKey = (keyId: string) => {
if (confirm('确定要删除此API密钥吗此操作不可恢复。')) {
setApiKeys(apiKeys.filter(k => k.id !== keyId))
const deleteKey = async (keyId: string) => {
if (!confirm('确定要删除此API密钥吗此操作不可恢复。')) return
try {
const res = await fetch(`/api/api-keys?id=${keyId}`, { method: 'DELETE' })
const json = await res.json()
if (json.success) {
setApiKeys(apiKeys.filter(k => k.id !== keyId))
} else {
alert(json.error || '删除失败')
}
} catch (e) {
alert('删除失败')
}
}
@@ -320,12 +362,22 @@ export default function APIKeysPage() {
}
// 保存权限设置
const savePermissions = () => {
const savePermissions = async () => {
if (!selectedKey) return
setApiKeys(apiKeys.map(k =>
k.id === selectedKey.id ? selectedKey : k
))
setShowPermissionDialog(false)
try {
const res = await fetch('/api/api-keys', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: selectedKey.id, action: 'updatePermissions', permissions: selectedKey.permissions }),
})
const json = await res.json()
if (json.success) {
setApiKeys(apiKeys.map(k => k.id === selectedKey.id ? { ...k, permissions: selectedKey.permissions } : k))
setShowPermissionDialog(false)
}
} catch (e) {
console.error('保存权限失败:', e)
}
}
// 统计
@@ -348,7 +400,7 @@ export default function APIKeysPage() {
<p className="text-sm text-gray-500 mt-1"></p>
</div>
</div>
<Button onClick={() => setShowCreateKeyDialog(true)}>
<Button onClick={() => setShowCreateKeyDialog(true)} disabled={loading}>
<Plus className="h-4 w-4 mr-2" />
</Button>
@@ -434,6 +486,17 @@ export default function APIKeysPage() {
<CardDescription>API密钥</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="py-12 text-center text-gray-500">...</div>
) : apiKeys.length === 0 ? (
<div className="py-12 text-center text-gray-500">
<p>API密钥</p>
<Button variant="outline" className="mt-4" onClick={() => setShowCreateKeyDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
@@ -539,11 +602,12 @@ export default function APIKeysPage() {
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ==================== 弹窗 ==================== */}
@@ -593,7 +657,9 @@ export default function APIKeysPage() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateKeyDialog(false)}></Button>
<Button onClick={handleCreateKey} disabled={!newKeyForm.name}></Button>
<Button onClick={handleCreateKey} disabled={!newKeyForm.name.trim() || creating}>
{creating ? '创建中...' : '创建密钥'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -552,7 +552,9 @@ export default function APIServicePage() {
const [activeTab, setActiveTab] = useState('keys')
const [activeCategory, setActiveCategory] = useState('all')
const [apiBaseUrl, setApiBaseUrl] = useState('')
const [apiKeys, setApiKeys] = useState<APIKey[]>(MOCK_API_KEYS)
const [apiKeys, setApiKeys] = useState<APIKey[]>([])
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const [callLogs] = useState<CallLog[]>(MOCK_CALL_LOGS)
// 弹窗状态
@@ -579,9 +581,22 @@ export default function APIServicePage() {
// 显示/隐藏密钥
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set())
const fetchKeys = async () => {
try {
const res = await fetch('/api/api-keys')
const json = await res.json()
if (json.success && json.data) setApiKeys(json.data)
} catch (e) {
console.error('拉取API密钥失败:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
const host = typeof window !== 'undefined' ? window.location.origin : ''
setApiBaseUrl(host)
fetchKeys()
}, [])
const copyToClipboard = (text: string, id: string) => {
@@ -638,40 +653,64 @@ export default function APIServicePage() {
: API_ENDPOINTS.filter(e => e.category === activeCategory)
// 创建新密钥
const handleCreateKey = () => {
const plan = BILLING_PLANS.find(p => p.id === newKeyForm.plan)!
const newKey: APIKey = {
id: `key_${Date.now()}`,
name: newKeyForm.name,
key: `sk-archer-${Math.random().toString(36).substring(2, 14)}`,
secret: `sec-${Math.random().toString(36).substring(2, 14)}`,
status: 'active',
createdAt: new Date().toISOString().split('T')[0],
expiresAt: newKeyForm.expiresAt || null,
lastUsed: null,
permissions: JSON.parse(JSON.stringify(FIELD_GROUPS)),
rateLimit: plan.rateLimit,
billing: { plan: plan.id, usedCredits: 0, totalCredits: plan.credits },
callStats: { today: 0, thisMonth: 0, total: 0 }
const handleCreateKey = async () => {
if (!newKeyForm.name.trim()) return
setCreating(true)
try {
const res = await fetch('/api/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newKeyForm.name.trim(),
plan: newKeyForm.plan,
expiresAt: newKeyForm.expiresAt || undefined,
}),
})
const json = await res.json()
if (json.success && json.data) {
setApiKeys([json.data, ...apiKeys])
setShowCreateKeyDialog(false)
setNewKeyForm({ name: '', plan: 'basic', expiresAt: '' })
} else {
alert(json.error || '创建失败')
}
} catch {
alert('创建失败')
} finally {
setCreating(false)
}
setApiKeys([...apiKeys, newKey])
setShowCreateKeyDialog(false)
setNewKeyForm({ name: '', plan: 'basic', expiresAt: '' })
}
// 切换密钥状态
const toggleKeyStatus = (keyId: string) => {
setApiKeys(apiKeys.map(k =>
k.id === keyId
? { ...k, status: k.status === 'active' ? 'disabled' : 'active' }
: k
))
const toggleKeyStatus = async (keyId: string) => {
try {
const res = await fetch('/api/api-keys', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: keyId, action: 'toggleStatus' }),
})
const json = await res.json()
if (json.success && json.data) {
setApiKeys(apiKeys.map(k => k.id === keyId ? { ...k, ...json.data } : k))
}
} catch (e) {
console.error('切换状态失败:', e)
}
}
// 删除密钥
const deleteKey = (keyId: string) => {
if (confirm('确定要删除此API密钥吗此操作不可恢复。')) {
setApiKeys(apiKeys.filter(k => k.id !== keyId))
const deleteKey = async (keyId: string) => {
if (!confirm('确定要删除此API密钥吗此操作不可恢复。')) return
try {
const res = await fetch(`/api/api-keys?id=${keyId}`, { method: 'DELETE' })
const json = await res.json()
if (json.success) {
setApiKeys(apiKeys.filter(k => k.id !== keyId))
} else {
alert(json.error || '删除失败')
}
} catch {
alert('删除失败')
}
}
@@ -684,12 +723,22 @@ export default function APIServicePage() {
}
// 保存权限设置
const savePermissions = () => {
const savePermissions = async () => {
if (!selectedKey) return
setApiKeys(apiKeys.map(k =>
k.id === selectedKey.id ? selectedKey : k
))
setShowPermissionDialog(false)
try {
const res = await fetch('/api/api-keys', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: selectedKey.id, action: 'updatePermissions', permissions: selectedKey.permissions }),
})
const json = await res.json()
if (json.success) {
setApiKeys(apiKeys.map(k => k.id === selectedKey.id ? { ...k, permissions: selectedKey.permissions } : k))
setShowPermissionDialog(false)
}
} catch (e) {
console.error('保存权限失败:', e)
}
}
// 统计数据
@@ -846,6 +895,15 @@ export default function APIServicePage() {
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="py-12 text-center text-gray-500">...</div>
) : apiKeys.length === 0 ? (
<div className="py-12 text-center text-gray-500">
API密钥
<Link href="/data-market/api/keys" className="text-purple-600 hover:underline ml-1">API密钥管理</Link>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
@@ -950,6 +1008,7 @@ export default function APIServicePage() {
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
@@ -1330,7 +1389,9 @@ export default function APIServicePage() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateKeyDialog(false)}></Button>
<Button onClick={handleCreateKey} disabled={!newKeyForm.name}></Button>
<Button onClick={handleCreateKey} disabled={!newKeyForm.name.trim() || creating}>
{creating ? '创建中...' : '创建密钥'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -194,7 +194,7 @@ export default function HomePage() {
fullProfile: respData?.fullProfile || first?.fullProfile,
aiAnalysis: respData?.aiAnalysis || first?.aiAnalysis,
profiles: Array.isArray(profiles) ? profiles : undefined,
thinking: generateThinking(query),
thinking: data.response?.thinking || generateThinking(query),
}]
})
} catch (error: any) {

View File

@@ -1,7 +1,7 @@
# 所有网站类服务统一放在 project name: website 下
# 只使用唯一 MongoDBdatacenter_mongodb宿主机 27017不在此编排中新建 MongoDB
# 使用方式:在神射手目录执行 docker compose up -d
# 神射手http://localhost:3117 玩值电竞http://localhost:3001
# 神射手http://localhost:3117 玩值电竞http://localhost:3001 OpenClaw 网关http://localhost:18789
name: website
@@ -13,8 +13,8 @@ services:
environment:
NODE_ENV: production
PORT: "3117"
# 唯一 MongoDBdatacenter_mongodb宿主机 27017
MONGODB_URI: "mongodb://host.docker.internal:27017"
# 唯一 MongoDBdatacenter_mongodb宿主机 27017,若启用认证需带账号
MONGODB_URI: "mongodb://admin:admin123@host.docker.internal:27017/?authSource=admin"
ports:
- "3117:3117"
extra_hosts:
@@ -33,7 +33,7 @@ services:
image: wanzhi-app:latest
container_name: website-wanzhi-web
environment:
MONGODB_URI: mongodb://host.docker.internal:27017
MONGODB_URI: mongodb://admin:admin123@host.docker.internal:27017/?authSource=admin
NODE_ENV: production
ports:
- "3001:3000"
@@ -43,6 +43,68 @@ services:
networks:
- website
# 抖音解析 API供 n8n 工作流调用,一键解析文案(可选下载视频)
douyin-api:
image: python:3.11-slim
container_name: website-douyin-api
working_dir: /app
environment:
DOUYIN_OUTPUT_DIR: /data/douyin_output
volumes:
- ../../../个人/卡若AI/03_卡木/木叶_视频内容/抖音视频解析/脚本:/app
- /Users/karuo/Documents/卡若Ai的文件夹/视频:/data/douyin_output
ports:
- "3099:3099"
command: ["sh", "-c", "pip install -q flask requests && python douyin_api.py"]
restart: unless-stopped
networks:
- website
# n8n 工作流自动化:归入 website 分类,端口 5678界面中文社区汉化镜像
n8n:
image: blowsnow/n8n-chinese:latest
container_name: website-n8n
environment:
TZ: Asia/Shanghai
GENERIC_TIMEZONE: Asia/Shanghai
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true"
N8N_RUNNERS_ENABLED: "true"
N8N_DEFAULT_LOCALE: "zh-CN"
ports:
- "5678:5678"
volumes:
- n8n_data:/home/node/.n8n
restart: unless-stopped
networks:
- website
# OpenClaw 网关:网站类服务,已迁入 website 编排;镜像需在 OpenClaw 项目内先 buildopenclaw:local
# 需访问本机 Ollama 时通过 host.docker.internal:11434
openclaw-gateway:
image: openclaw:local
container_name: website-openclaw-gateway
env_file:
- ../../8、小工具/Docker项目/OpenClaw/openclaw/.env
environment:
HOME: /home/node
TERM: xterm-256color
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- /Users/karuo/.openclaw:/home/node/.openclaw
- /Users/karuo/.openclaw/workspace:/home/node/.openclaw/workspace
ports:
- "18789:18789"
- "18790:18790"
init: true
restart: unless-stopped
command: ["node", "dist/index.js", "gateway", "--bind", "lan", "--port", "18789"]
networks:
- website
networks:
website:
driver: bridge
volumes:
n8n_data:

BIN
开发文档/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -9,6 +9,7 @@
| 日期 | 当日完成 | 进行中 | 备注 |
|:---|:---|:---|:---|
| 2026-03-08 | API 密钥持久化:后端改用 MongoDBKR.api_keys前端 API 服务页、API 密钥页对接 /api/api-keys创建/更新/删除/切换状态全部落库 | — | 解决「API 不创建」问题 |
| 2026-02-26 | API文档系统新增 /api/docs 端点提供 OpenAPI 3.0 和 Markdown 格式文档支持下载和AI直接对接文档页面增加复制链接、下载按钮、AI对接提示卡片 | — | /api/docs?format=openapi |
| 2026-02-26 | 开放API优化侧边栏移除API密钥/文档子项整合到API服务内开放接口增强实时数据流可视化、字段级授权管理、Webhook配置、5列统计卡片、成功率/响应时间、接入方详情增强) | — | 页面 /data-market/open-api |
| 2026-02-26 | 卡若前端标准:神射手+毛狐狸的布局/颜色/毛玻璃/组件/特效提炼为标准文档写入卡若AI「全栈开发」全栈开发 Skill 与前端生成 Skill 全量更新,后续项目按此标准开发 | — | 标准见 卡若AI/全栈开发/前端标准_神射手与毛狐狸.md |

View File

@@ -0,0 +1,111 @@
# 2026-03-08 API 服务功能需求
## 一、原始需求
**功能一API 不创建**
神射手新增 API 后无法直接使用API 没有写入数据库。需要支持生成或手填 API并能与神射手正确对接。
**功能二API 服务与开放接口是否重叠**
分析 API 服务与开放接口两个模块是否重复,若重叠则整合,并确保服务可用、可添加。
---
## 二、现状分析(已核查代码)
### 2.1 API 不创建问题
| 层级 | 现状 | 问题 |
|:---|:---|:---|
| **前端** | `data-market/api/page.tsx``data-market/api/keys/page.tsx` 使用 `MOCK_API_KEYS` | 创建密钥时仅 `setApiKeys([...apiKeys, newKey])`,未调用后端 |
| **后端** | `app/api/api-keys/route.ts` 有完整 GET/POST/PUT/DELETE | 使用内存 `let apiKeys[]`,注释写「生产环境应使用 MongoDB」重启后数据丢失 |
| **结论** | 前端未对接 `/api/api-keys`,后端也未落库 | 新增 API 密钥不会持久化,刷新即丢失 |
### 2.2 API 服务 vs 开放接口对比
| 维度 | API 服务 (`/data-market/api`) | 开放接口 (`/data-market/open-api`) |
|:---|:---|:---|
| **入口** | 数据市场 → API 服务 | 数据市场 → 开放接口 |
| **核心概念** | API 密钥 (Key) | 接入方 (Partner) |
| **接口路径** | `/api/shensheshou/*`用户查询、AI、标签、流量包等 | `/api/open-api/*`(数据流入、查询、标签、批量) |
| **能力** | 密钥管理、计费套餐、字段权限、调用日志 | 接入方管理、字段授权、Webhook、数据流可视化 |
| **数据** | MOCK 数据 | MOCK 数据 |
**重叠部分**
- 密钥/接入方创建、启用禁用、权限配置
- 认证方式均为 Bearer + X-API-Secret
- 字段级权限、调用统计
**差异部分**
- API 服务强调计费信用点、套餐、13 个 shensheshou 端点
- 开放接口:强调接入方、数据流入/流出、Webhook 回调
**结论**:二者在「密钥/接入方 + 权限 + 调用」上高度重叠,可整合为统一模块。
---
## 三、解决方案
### 3.1 修复 API 不创建(优先级高)
1. **后端持久化**
- 新建 MongoDB 集合 `api_keys`(或复用 `KR` 库下 `shensheshou.api_keys`
-`app/api/api-keys/route.ts` 中的内存存储改为 MongoDB CRUD
2. **前端对接**
- `data-market/api/page.tsx``data-market/api/keys/page.tsx` 的创建/更新/删除调用 `/api/api-keys`
- 页面初始化时 GET `/api/api-keys` 拉取列表,替代 MOCK
3. **可选:支持手填 API**
- 在创建密钥时支持「使用已有 key/secret」选项仅录入名称和权限不自动生成
### 3.2 整合 API 服务与开放接口(可选)
**方案 A保留双入口统一后端**
- 导航保留「API 服务」和「开放接口」
- 共用同一套 API 密钥/接入方数据源与 API
- 两页面分别侧重API 服务→文档与计费,开放接口→接入方与数据流
**方案 B合并为单一「开放接口」**
- 导航只保留「开放接口」
- 在开放接口页内增加 Tab接入方、API 文档、计费、调用日志
- 逐步下线「API 服务」独立入口
推荐先做 **3.1**,再按业务需要决定是否做 **3.2**
---
## 四、数据库设计建议
```javascript
// 集合: shensheshou.api_keys (或 KR.api_keys)
{
_id: ObjectId,
id: "key_xxx", // 业务ID
name: "存客宝-生产",
key: "sk-archer-xxx",
secret: "sec-xxx", // 存储时考虑加密
status: "active" | "disabled" | "expired",
plan: "free" | "basic" | "pro" | "enterprise",
permissions: [...],
rateLimit: { requestsPerDay, requestsPerMonth },
billing: { usedCredits, totalCredits },
callStats: { today, thisMonth, total },
createdAt: ISODate,
expiresAt: ISODate | null,
lastUsed: ISODate | null,
partnerId: "ckb_001" // 可选,关联开放接口接入方
}
```
---
## 五、验收标准
- [x] 在「API 服务」或「API 密钥管理」中创建密钥后,刷新页面密钥仍存在(已实现 MongoDB 持久化)
- [x] 新建密钥可正常用于 `/api/shensheshou/user` 等接口认证shensheshou 路由已调用 api-keys 校验)
- [ ] 支持手填已有 key/secret可选
- [ ] 若整合API 服务与开放接口共用同一数据源,无重复录入
---
![](images/2026-03-08-09-03-18.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -1,87 +0,0 @@
# 神射手 - 核心需求提取
> 📅 从项目代码与文档提取 | 2026-01-31
---
## 一、项目目标(金)
| 目标 | 描述 |
|:---|:---|
| 核心能力 | 输入手机号/QQ/UID → 秒级返回用户画像 |
| 数据规模 | 20亿+ 用户记录 |
| 业务价值 | 用户资产数字化、流量池管理、精准营销 |
---
## 二、五大模块需求(木)
| 模块 | 核心功能 | 验收标准 |
|:---|:---|:---|
| 数据概览 | AI对话、系统状态、数据统计 | 输入手机号可查用户 |
| 数据接入 | 数据源、AI引擎、清洗、任务、血缘 | 真实MongoDB连接 |
| 标签画像 | 标签、画像、人群圈选 | 按项目分类流量池 |
| AI Agent | 渠道、打标、清洗、报告 | 飞书/企微对接 |
| 数据市场 | 流量包、API服务 | 导出CSV、发送群 |
---
## 三、用户故事(从代码提取)
### US-001: 手机号查询
```
As a 运营人员
I want 输入11位手机号
So that 秒级获取用户画像姓名、QQ、等级、标签
验收: parseIntent 识别手机号 → queryFullProfile 跨库查询
```
### US-002: QQ号查询
```
As a 运营人员
I want 输入QQ号
So that 获取关联手机号及用户信息
验收: parseIntent 识别QQ → KR_腾讯.QQ+手机 → KR.用户估值
```
### US-003: 人群圈选
```
As a 营销人员
I want 按项目选择流量池
So that 查看真实用户列表
验收: /api/crowd-pools 按存客宝/点了码/微博/QQ分类
```
### US-004: 流量包导出
```
As a 运营人员
I want 点击下载按钮
So that 导出CSV格式用户数据
验收: /api/traffic-packages?action=export → 前端Blob下载
```
---
## 四、MVP边界
```yaml
已实现:
- AI对话手机/QQ/状态/RFM/高价值)
- 5大模块前端页面
- 25个API端点
- MongoDB真实数据对接
- 飞书/企微渠道配置
待完善:
- 飞书机器人环境变量配置
- Redis缓存层
- 更多业务指标
```
---
## 五、关联文档
- [业务需求.md](./业务需求.md)
- [成本.md](./成本.md)
- [../2、架构/核心架构逻辑.md](../2、架构/核心架构逻辑.md)

BIN
开发文档/4、前端/.DS_Store vendored Normal file

Binary file not shown.