完善许可证服务多项目交付地基
This commit is contained in:
parent
1d03717ff9
commit
d9955b6835
11
license-server/.dockerignore
Normal file
11
license-server/.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
coverage
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
tsconfig.tsbuildinfo
|
||||
.git
|
||||
.gitignore
|
||||
@ -1,15 +1,27 @@
|
||||
# 数据库连接
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/license_server?schema=public"
|
||||
# PostgreSQL(Docker Compose 部署时也会直接使用这三个变量)
|
||||
POSTGRES_USER="license"
|
||||
POSTGRES_PASSWORD="replace-with-strong-postgres-password"
|
||||
POSTGRES_DB="license_db"
|
||||
|
||||
# RSA 密钥(生产环境请使用 generate-keys 脚本生成)
|
||||
# 数据库连接
|
||||
# 主数据库(管理项目和用户)
|
||||
MAIN_DATABASE_URL="postgresql://license:replace-with-strong-postgres-password@localhost:5432/license_db?schema=main"
|
||||
# 主库加密密钥(用于加密项目数据库密码,至少 32 字节)
|
||||
MAIN_DB_ENCRYPTION_KEY="replace-with-32-byte-or-longer-encryption-key"
|
||||
|
||||
# 项目数据库(默认项目,兼容旧配置)
|
||||
DATABASE_URL="postgresql://license:replace-with-strong-postgres-password@localhost:5432/license_db?schema=public"
|
||||
|
||||
# RSA 密钥(生产环境必须使用 generate-keys 脚本生成,不要使用默认值)
|
||||
RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
|
||||
RSA_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||
|
||||
# AES 加密密钥(32字节 hex)
|
||||
AES_SECRET_KEY="your-32-byte-hex-key-here-12345678901234567890"
|
||||
AES_SECRET_KEY="replace-with-64-char-hex-key"
|
||||
|
||||
# JWT 密钥
|
||||
JWT_SECRET="your-jwt-secret-key"
|
||||
# JWT 密钥(生产环境必须配置,最少 32 字节)
|
||||
# 警告:未配置或长度不足将导致管理员认证功能不可用
|
||||
JWT_SECRET="your-jwt-secret-key-at-least-32-bytes-long"
|
||||
|
||||
# 管理员初始密码
|
||||
ADMIN_INITIAL_PASSWORD="admin123"
|
||||
# 管理员初始密码(只作为初始化入口,不要保留弱默认值)
|
||||
ADMIN_INITIAL_PASSWORD="replace-with-strong-initial-admin-password"
|
||||
|
||||
@ -16,12 +16,10 @@ RUN npm config set registry https://registry.npmmirror.com && \
|
||||
|
||||
# 复制源码
|
||||
COPY . .
|
||||
RUN mkdir -p public
|
||||
|
||||
# 生成 Prisma Client
|
||||
RUN node node_modules/prisma/build/index.js generate
|
||||
|
||||
# 构建(直接用 node 执行 next)
|
||||
RUN node node_modules/next/dist/bin/next build
|
||||
# 构建(会通过 prebuild 自动生成双 Prisma Client)
|
||||
RUN npm run build
|
||||
|
||||
# 生产阶段
|
||||
FROM node:20-slim AS runner
|
||||
|
||||
214
license-server/app/admin/[projectCode]/alerts/page.tsx
Normal file
214
license-server/app/admin/[projectCode]/alerts/page.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Alert {
|
||||
id: string;
|
||||
alertType: string;
|
||||
status: string;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
resolvedAt: string | null;
|
||||
license: {
|
||||
licenseKey: string;
|
||||
customer: { companyName: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProjectAlertsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const projectCode = params.projectCode as string;
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'all' | 'pending' | 'resolved'>('pending');
|
||||
|
||||
const loadAlerts = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/alerts?status=${filter}`);
|
||||
|
||||
if (res.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 403 || res.status === 404) {
|
||||
router.push('/admin/select-project');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setAlerts(data.alerts || []);
|
||||
} catch (error) {
|
||||
console.error('获取告警列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadAlerts();
|
||||
}, [filter, projectCode]);
|
||||
|
||||
const handleResolve = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/alerts/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
await loadAlerts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理告警失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIgnore = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/alerts/${id}/ignore`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
await loadAlerts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('忽略告警失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
multi_device: '多设备激活',
|
||||
fingerprint_mismatch: '指纹不匹配',
|
||||
suspicious_activity: '可疑活动',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/${projectCode}`} className="text-gray-600 hover:text-gray-900">
|
||||
← 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">告警中心</h1>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="rounded-lg bg-white shadow">
|
||||
<div className="border-b p-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilter('pending')}
|
||||
className={`rounded-lg px-4 py-2 ${
|
||||
filter === 'pending'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
待处理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('resolved')}
|
||||
className={`rounded-lg px-4 py-2 ${
|
||||
filter === 'resolved'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
已处理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`rounded-lg px-4 py-2 ${
|
||||
filter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{alerts.map((alert) => (
|
||||
<div key={alert.id} className="p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
alert.status === 'pending'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: alert.status === 'resolved'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{alert.status === 'pending'
|
||||
? '待处理'
|
||||
: alert.status === 'resolved'
|
||||
? '已处理'
|
||||
: '已忽略'}
|
||||
</span>
|
||||
<span className="font-medium">{getAlertTypeLabel(alert.alertType)}</span>
|
||||
</div>
|
||||
<p className="mb-1 text-sm text-gray-600">
|
||||
许可证: <span className="font-mono">{alert.license.licenseKey}</span>
|
||||
</p>
|
||||
{alert.license.customer && (
|
||||
<p className="mb-1 text-sm text-gray-600">
|
||||
客户: {alert.license.customer.companyName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">
|
||||
时间: {new Date(alert.createdAt).toLocaleString()}
|
||||
</p>
|
||||
{alert.details && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-sm text-blue-600">
|
||||
查看详情
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-auto rounded bg-gray-100 p-3 text-xs">
|
||||
{JSON.stringify(alert.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
{alert.status === 'pending' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => void handleResolve(alert.id)}
|
||||
className="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700"
|
||||
>
|
||||
处理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleIgnore(alert.id)}
|
||||
className="rounded bg-gray-200 px-3 py-1 text-sm text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!alerts.length && <div className="p-8 text-center text-gray-500">暂无告警</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
license-server/app/admin/[projectCode]/api-keys/page.tsx
Normal file
289
license-server/app/admin/[projectCode]/api-keys/page.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
|
||||
interface ApiKeyItem {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface AdminInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export default function ProjectApiKeysPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const projectCode = params.projectCode as string;
|
||||
|
||||
const [admin, setAdmin] = useState<AdminInfo | null>(null);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const adminRes = await fetch('/api/admin/me');
|
||||
if (adminRes.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const adminData = await adminRes.json();
|
||||
if (!adminData.isSuperAdmin) {
|
||||
router.push(`/admin/${projectCode}`);
|
||||
return;
|
||||
}
|
||||
setAdmin(adminData);
|
||||
|
||||
const apiKeyRes = await fetch(`/api/admin/${projectCode}/api-keys`);
|
||||
if (apiKeyRes.status === 403 || apiKeyRes.status === 404) {
|
||||
router.push('/admin/select-project');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await apiKeyRes.json();
|
||||
setApiKeys(data.apiKeys || []);
|
||||
} catch (error) {
|
||||
console.error('获取项目 API Key 失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [projectCode]);
|
||||
|
||||
const handleDisable = async (id: string) => {
|
||||
if (!confirm('确定要禁用这个 API Key 吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/${projectCode}/api-keys?id=${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('禁用项目 API Key 失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/${projectCode}`} className="text-gray-600 hover:text-gray-900">
|
||||
← 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">项目 API Key 管理</h1>
|
||||
</div>
|
||||
{admin?.isSuperAdmin ? (
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
创建 API Key
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-4 py-8">
|
||||
<div className="rounded-lg bg-white shadow">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
名称
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
最近使用
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
创建时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{apiKeys.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-6 py-4 font-medium">{item.name}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
item.isActive
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{item.isActive ? '启用中' : '已禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{item.lastUsedAt ? new Date(item.lastUsedAt).toLocaleString() : '未使用'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{item.isActive ? (
|
||||
<button
|
||||
onClick={() => void handleDisable(item.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">不可恢复</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!apiKeys.length ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
|
||||
暂无 API Key
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showModal ? (
|
||||
<ApiKeyModal
|
||||
projectCode={projectCode}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
void loadData();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyModal({
|
||||
projectCode,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
projectCode: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [generatedKey, setGeneratedKey] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/${projectCode}/api-keys`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setGeneratedKey(data.key);
|
||||
} else {
|
||||
setError(data.error || '创建失败');
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (generatedKey) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-lg font-medium">API Key 已创建</h2>
|
||||
<div className="mb-4 rounded-lg bg-gray-100 p-4">
|
||||
<p className="mb-2 text-sm text-gray-500">请立即保存,只会显示一次:</p>
|
||||
<p className="break-all font-mono text-sm font-bold">{generatedKey}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSuccess}
|
||||
className="w-full rounded-lg bg-blue-600 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white shadow-xl">
|
||||
<div className="border-b p-6">
|
||||
<h2 className="text-lg font-medium">创建项目 API Key</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-6">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
placeholder="例如:官网发证"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error ? <div className="rounded-lg bg-red-50 p-3 text-sm text-red-600">{error}</div> : null}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border py-2 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
license-server/app/admin/[projectCode]/customers/page.tsx
Normal file
271
license-server/app/admin/[projectCode]/customers/page.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
companyName: string;
|
||||
contactName: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
createdAt: string;
|
||||
_count: { licenses: number };
|
||||
}
|
||||
|
||||
export default function ProjectCustomersPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const projectCode = params.projectCode as string;
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const loadCustomers = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/customers`);
|
||||
|
||||
if (res.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 403 || res.status === 404) {
|
||||
router.push('/admin/select-project');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setCustomers(data.customers || []);
|
||||
} catch (error) {
|
||||
console.error('获取客户列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadCustomers();
|
||||
}, [projectCode]);
|
||||
|
||||
const filteredCustomers = customers.filter(
|
||||
(customer) =>
|
||||
customer.companyName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
customer.contactName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
customer.contactEmail?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/${projectCode}`} className="text-gray-600 hover:text-gray-900">
|
||||
← 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">客户管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="rounded-lg bg-white shadow">
|
||||
<div className="flex items-center justify-between border-b p-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索客户..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64 rounded-lg border px-4 py-2"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
添加客户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
公司名称
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
联系人
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
邮箱
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
许可证数
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
创建时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{filteredCustomers.map((customer) => (
|
||||
<tr key={customer.id}>
|
||||
<td className="px-6 py-4 font-medium">{customer.companyName}</td>
|
||||
<td className="px-6 py-4 text-gray-600">{customer.contactName || '-'}</td>
|
||||
<td className="px-6 py-4 text-gray-600">{customer.contactEmail || '-'}</td>
|
||||
<td className="px-6 py-4">{customer._count.licenses}</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{new Date(customer.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-400">详情页待补充</td>
|
||||
</tr>
|
||||
))}
|
||||
{!filteredCustomers.length && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
||||
暂无客户数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showModal && (
|
||||
<CustomerModal
|
||||
projectCode={projectCode}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
void loadCustomers();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerModal({
|
||||
projectCode,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
projectCode: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
companyName: '',
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/customers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(data.error || '创建失败');
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white shadow-xl">
|
||||
<div className="border-b p-6">
|
||||
<h2 className="text-lg font-medium">添加客户</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-6">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">公司名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.companyName}
|
||||
onChange={(e) => setForm({ ...form, companyName: e.target.value })}
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">联系人</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.contactName}
|
||||
onChange={(e) => setForm({ ...form, contactName: e.target.value })}
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.contactEmail}
|
||||
onChange={(e) => setForm({ ...form, contactEmail: e.target.value })}
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">电话</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg bg-red-50 p-3 text-sm text-red-600">{error}</div>}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border py-2 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
453
license-server/app/admin/[projectCode]/licenses/page.tsx
Normal file
453
license-server/app/admin/[projectCode]/licenses/page.tsx
Normal file
@ -0,0 +1,453 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface License {
|
||||
id: string;
|
||||
licenseKey: string;
|
||||
licenseType: string;
|
||||
customerName: string | null;
|
||||
status: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
_count: { activations: number };
|
||||
}
|
||||
|
||||
export default function ProjectLicensesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const projectCode = params.projectCode as string;
|
||||
const [licenses, setLicenses] = useState<License[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const loadLicenses = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/licenses`);
|
||||
|
||||
if (res.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 403 || res.status === 404) {
|
||||
router.push('/admin/select-project');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setLicenses(data.licenses || []);
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadLicenses();
|
||||
}, [projectCode]);
|
||||
|
||||
const filteredLicenses = licenses.filter(
|
||||
(license) =>
|
||||
license.licenseKey.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(license.customerName &&
|
||||
license.customerName.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleDisable = async (id: string) => {
|
||||
if (!confirm('确定要禁用此许可证吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/licenses/${id}/disable`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
await loadLicenses();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('禁用失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = async (id: string) => {
|
||||
if (!confirm('确定要启用此许可证吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/licenses/${id}/enable`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
await loadLicenses();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('启用失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async (id: string) => {
|
||||
if (!confirm('确定要重置硬件绑定吗?这将允许在新设备上激活。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/licenses/${id}/reset`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
await loadLicenses();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, licenseKey: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
`确定要删除许可证 ${licenseKey} 吗?\n\n此操作将同时删除所有相关的激活记录、心跳记录和告警记录,且不可恢复!`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/licenses/${id}/delete`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await loadLicenses();
|
||||
} else {
|
||||
alert(data.error || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
alert('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/${projectCode}`} className="text-gray-600 hover:text-gray-900">
|
||||
← 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">许可证管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="rounded-lg bg-white shadow">
|
||||
<div className="flex items-center justify-between border-b p-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索许可证或客户名称..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64 rounded-lg border px-4 py-2"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
生成许可证
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
许可证密钥
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
类型
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
客户名称
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
激活数
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
到期时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{filteredLicenses.map((license) => (
|
||||
<tr key={license.id}>
|
||||
<td className="px-6 py-4 font-mono text-sm">{license.licenseKey}</td>
|
||||
<td className="px-6 py-4">
|
||||
<TypeBadge type={license.licenseType} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge status={license.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{license.customerName || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">{license._count.activations}</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{license.expiresAt
|
||||
? new Date(license.expiresAt).toLocaleDateString()
|
||||
: '永久'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
{license.status === 'active' ? (
|
||||
<button
|
||||
onClick={() => void handleDisable(license.id)}
|
||||
className="text-sm text-orange-600 hover:underline"
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void handleEnable(license.id)}
|
||||
className="text-sm text-green-600 hover:underline"
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void handleReset(license.id)}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
重置绑定
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDelete(license.id, license.licenseKey)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!filteredLicenses.length && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
|
||||
暂无许可证数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showModal && (
|
||||
<LicenseModal
|
||||
projectCode={projectCode}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
void loadLicenses();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
FREE: 'bg-gray-100 text-gray-700',
|
||||
STANDARD: 'bg-blue-100 text-blue-700',
|
||||
PRO: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
FREE: '免费版',
|
||||
STANDARD: '标准版',
|
||||
PRO: '专业版',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`rounded px-2 py-1 text-xs font-medium ${colors[type] || colors.FREE}`}>
|
||||
{labels[type] || type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
disabled: 'bg-red-100 text-red-700',
|
||||
expired: 'bg-yellow-100 text-yellow-700',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
active: '正常',
|
||||
disabled: '已禁用',
|
||||
expired: '已过期',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`rounded px-2 py-1 text-xs font-medium ${colors[status] || colors.active}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseModal({
|
||||
projectCode,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
projectCode: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
customerName: '',
|
||||
licenseType: 'STANDARD',
|
||||
expiresInDays: '365',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [generatedKey, setGeneratedKey] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/licenses`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
customerName: form.customerName || null,
|
||||
licenseType: form.licenseType,
|
||||
expiresInDays: form.expiresInDays ? parseInt(form.expiresInDays, 10) : null,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setGeneratedKey(data.license.licenseKey);
|
||||
} else {
|
||||
setError(data.error || '生成失败');
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (generatedKey) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-lg font-medium">许可证已生成</h2>
|
||||
<div className="mb-4 rounded-lg bg-gray-100 p-4">
|
||||
<p className="mb-2 text-sm text-gray-500">许可证密钥:</p>
|
||||
<p className="break-all text-center font-mono text-lg font-bold">{generatedKey}</p>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-gray-500">
|
||||
请妥善保存此密钥,发送给客户用于激活系统。
|
||||
</p>
|
||||
<button
|
||||
onClick={onSuccess}
|
||||
className="w-full rounded-lg bg-blue-600 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white shadow-xl">
|
||||
<div className="border-b p-6">
|
||||
<h2 className="text-lg font-medium">生成许可证</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-6">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">客户名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.customerName}
|
||||
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
|
||||
placeholder="输入客户公司或个人名称"
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">用于后台管理识别</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">许可证类型 *</label>
|
||||
<select
|
||||
value={form.licenseType}
|
||||
onChange={(e) => setForm({ ...form, licenseType: e.target.value })}
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
required
|
||||
>
|
||||
<option value="STANDARD">标准版</option>
|
||||
<option value="PRO">专业版</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">有效期(天)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.expiresInDays}
|
||||
onChange={(e) => setForm({ ...form, expiresInDays: e.target.value })}
|
||||
placeholder="留空表示永久有效"
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg bg-red-50 p-3 text-sm text-red-600">{error}</div>}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border py-2 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '生成中...' : '生成'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
license-server/app/admin/[projectCode]/page.tsx
Normal file
297
license-server/app/admin/[projectCode]/page.tsx
Normal file
@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface DashboardStats {
|
||||
licenses: { total: number; byType: Record<string, number>; byStatus: Record<string, number> };
|
||||
activations: { total: number; active: number };
|
||||
alerts: { pending: number };
|
||||
recentActivations: Array<{
|
||||
id: string;
|
||||
licenseKey: string;
|
||||
licenseType: string;
|
||||
customerName: string | null;
|
||||
activatedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AdminInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export default function ProjectDashboard() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const projectCode = params.projectCode as string;
|
||||
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [project, setProject] = useState<ProjectInfo | null>(null);
|
||||
const [admin, setAdmin] = useState<AdminInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [projectCode]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// 获取项目信息
|
||||
const projectRes = await fetch(`/api/admin/projects/${projectCode}`);
|
||||
if (projectRes.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
if (projectRes.status === 404) {
|
||||
setError('项目不存在或无权访问');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const projectData = await projectRes.json();
|
||||
setProject(projectData);
|
||||
|
||||
const adminRes = await fetch('/api/admin/me');
|
||||
if (adminRes.ok) {
|
||||
const adminData = await adminRes.json();
|
||||
setAdmin(adminData);
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
const statsRes = await fetch(`/api/admin/${projectCode}/dashboard`);
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json();
|
||||
setStats(statsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取数据失败:', err);
|
||||
setError('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/admin/auth/logout', { method: 'POST' });
|
||||
router.push('/admin/login');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => router.push('/admin/select-project')}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
返回项目选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* 顶部导航 */}
|
||||
<nav className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/admin/select-project')}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
title="切换项目"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-xl font-bold">
|
||||
{project?.name || projectCode}
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">许可证管理</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/${projectCode}/licenses`} className="text-gray-600 hover:text-gray-900">
|
||||
许可证管理
|
||||
</Link>
|
||||
<Link href={`/admin/${projectCode}/alerts`} className="text-gray-600 hover:text-gray-900">
|
||||
告警中心
|
||||
{stats?.alerts.pending ? (
|
||||
<span className="ml-1 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
||||
{stats.alerts.pending}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<Link href={`/admin/${projectCode}/versions`} className="text-gray-600 hover:text-gray-900">
|
||||
版本管理
|
||||
</Link>
|
||||
<Link href={`/admin/${projectCode}/customers`} className="text-gray-600 hover:text-gray-900">
|
||||
客户管理
|
||||
</Link>
|
||||
{admin?.isSuperAdmin ? (
|
||||
<Link href={`/admin/${projectCode}/api-keys`} className="text-gray-600 hover:text-gray-900">
|
||||
API Key
|
||||
</Link>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-6 mb-8">
|
||||
<StatCard title="许可证总数" value={stats?.licenses.total || 0} />
|
||||
<StatCard title="活跃激活" value={stats?.activations.active || 0} />
|
||||
<StatCard
|
||||
title="待处理告警"
|
||||
value={stats?.alerts.pending || 0}
|
||||
highlight={!!stats?.alerts.pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 许可证分布 */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium mb-4">许可证类型分布</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats?.licenses.byType || {}).map(([type, count]) => (
|
||||
<div key={type} className="flex justify-between items-center">
|
||||
<span className="text-gray-600">
|
||||
{type === 'FREE' ? '免费版' : type === 'STANDARD' ? '标准版' : '专业版'}
|
||||
</span>
|
||||
<span className="font-medium">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium mb-4">许可证状态分布</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats?.licenses.byStatus || {}).map(([status, count]) => (
|
||||
<div key={status} className="flex justify-between items-center">
|
||||
<span className="text-gray-600">
|
||||
{status === 'active' ? '正常' : status === 'disabled' ? '已禁用' : '已过期'}
|
||||
</span>
|
||||
<span className="font-medium">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最近激活 */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b">
|
||||
<h3 className="text-lg font-medium">最近激活</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
许可证密钥
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
类型
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
客户名称
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
激活时间
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{stats?.recentActivations.map((activation) => (
|
||||
<tr key={activation.id}>
|
||||
<td className="px-6 py-4 font-mono text-sm">{activation.licenseKey}</td>
|
||||
<td className="px-6 py-4">
|
||||
<TypeBadge type={activation.licenseType} />
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{activation.customerName || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{new Date(activation.activatedAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!stats?.recentActivations?.length && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
|
||||
暂无激活记录
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
highlight = false,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow p-6 ${highlight ? 'ring-2 ring-red-500' : ''}`}>
|
||||
<div className="text-sm text-gray-500 mb-1">{title}</div>
|
||||
<div className={`text-3xl font-bold ${highlight ? 'text-red-600' : 'text-gray-900'}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
FREE: 'bg-gray-100 text-gray-700',
|
||||
STANDARD: 'bg-blue-100 text-blue-700',
|
||||
PRO: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
FREE: '免费版',
|
||||
STANDARD: '标准版',
|
||||
PRO: '专业版',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[type] || colors.FREE}`}>
|
||||
{labels[type] || type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
278
license-server/app/admin/[projectCode]/versions/page.tsx
Normal file
278
license-server/app/admin/[projectCode]/versions/page.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Version {
|
||||
id: string;
|
||||
version: string;
|
||||
releaseNotes: string | null;
|
||||
minRequiredVersion: string | null;
|
||||
isSecurityUpdate: boolean;
|
||||
isCriticalUpdate: boolean;
|
||||
downloadSize: string | null;
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
export default function ProjectVersionsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const projectCode = params.projectCode as string;
|
||||
const [versions, setVersions] = useState<Version[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const loadVersions = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/versions`);
|
||||
|
||||
if (res.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 403 || res.status === 404) {
|
||||
router.push('/admin/select-project');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setVersions(data.versions || []);
|
||||
} catch (error) {
|
||||
console.error('获取版本列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadVersions();
|
||||
}, [projectCode]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/${projectCode}`} className="text-gray-600 hover:text-gray-900">
|
||||
← 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">版本管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="rounded-lg bg-white shadow">
|
||||
<div className="flex items-center justify-between border-b p-6">
|
||||
<h2 className="font-medium">版本列表</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
发布新版本
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
版本号
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
发布日期
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
最低要求版本
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
标签
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
更新说明
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{versions.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-6 py-4 font-mono font-medium">{item.version}</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{new Date(item.releaseDate).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">{item.minRequiredVersion || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
{item.isSecurityUpdate && (
|
||||
<span className="mr-1 rounded bg-red-100 px-2 py-1 text-xs text-red-700">
|
||||
安全更新
|
||||
</span>
|
||||
)}
|
||||
{item.isCriticalUpdate && (
|
||||
<span className="rounded bg-yellow-100 px-2 py-1 text-xs text-yellow-700">
|
||||
重要更新
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-xs truncate px-6 py-4 text-gray-600">
|
||||
{item.releaseNotes || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!versions.length && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
|
||||
暂无版本数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showModal && (
|
||||
<VersionModal
|
||||
projectCode={projectCode}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
void loadVersions();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionModal({
|
||||
projectCode,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
projectCode: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
version: '',
|
||||
releaseNotes: '',
|
||||
minRequiredVersion: '',
|
||||
isSecurityUpdate: false,
|
||||
isCriticalUpdate: false,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/${projectCode}/versions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(data.error || '发布失败');
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white shadow-xl">
|
||||
<div className="border-b p-6">
|
||||
<h2 className="text-lg font-medium">发布新版本</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-6">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">版本号 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.version}
|
||||
onChange={(e) => setForm({ ...form, version: e.target.value })}
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
placeholder="1.0.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">更新说明</label>
|
||||
<textarea
|
||||
value={form.releaseNotes}
|
||||
onChange={(e) => setForm({ ...form, releaseNotes: e.target.value })}
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
最低要求版本
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.minRequiredVersion}
|
||||
onChange={(e) => setForm({ ...form, minRequiredVersion: e.target.value })}
|
||||
className="w-full rounded-lg border px-3 py-2"
|
||||
placeholder="留空表示不强制更新"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isSecurityUpdate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, isSecurityUpdate: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">安全更新</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isCriticalUpdate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, isCriticalUpdate: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">重要更新</span>
|
||||
</label>
|
||||
</div>
|
||||
{error && <div className="rounded-lg bg-red-50 p-3 text-sm text-red-600">{error}</div>}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border py-2 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '发布中...' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,203 +1,5 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Alert {
|
||||
id: string;
|
||||
alertType: string;
|
||||
status: string;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
resolvedAt: string | null;
|
||||
license: {
|
||||
licenseKey: string;
|
||||
customer: { companyName: string } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
const router = useRouter();
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'all' | 'pending' | 'resolved'>('pending');
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlerts();
|
||||
}, [filter]);
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/alerts?status=${filter}`);
|
||||
if (res.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setAlerts(data.alerts || []);
|
||||
} catch (error) {
|
||||
console.error('获取告警列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/alerts/${id}/resolve`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
fetchAlerts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理告警失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIgnore = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/alerts/${id}/ignore`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
fetchAlerts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('忽略告警失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
multi_device: '多设备激活',
|
||||
fingerprint_mismatch: '指纹不匹配',
|
||||
suspicious_activity: '可疑活动',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
|
||||
← 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">告警中心</h1>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilter('pending')}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
filter === 'pending'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
待处理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('resolved')}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
filter === 'resolved'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
已处理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
filter === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{alerts.map((alert) => (
|
||||
<div key={alert.id} className="p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
alert.status === 'pending'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: alert.status === 'resolved'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{alert.status === 'pending'
|
||||
? '待处理'
|
||||
: alert.status === 'resolved'
|
||||
? '已处理'
|
||||
: '已忽略'}
|
||||
</span>
|
||||
<span className="font-medium">{getAlertTypeLabel(alert.alertType)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
许可证: <span className="font-mono">{alert.license.licenseKey}</span>
|
||||
</p>
|
||||
{alert.license.customer && (
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
客户: {alert.license.customer.companyName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">
|
||||
时间: {new Date(alert.createdAt).toLocaleString()}
|
||||
</p>
|
||||
{alert.details && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm text-blue-600 cursor-pointer">
|
||||
查看详情
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(alert.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
{alert.status === 'pending' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleResolve(alert.id)}
|
||||
className="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
||||
>
|
||||
处理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleIgnore(alert.id)}
|
||||
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!alerts.length && (
|
||||
<div className="p-8 text-center text-gray-500">暂无告警</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default function LegacyAlertsPage() {
|
||||
redirect('/admin/select-project');
|
||||
}
|
||||
|
||||
@ -1,276 +1,5 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
companyName: string;
|
||||
contactName: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
createdAt: string;
|
||||
_count: { licenses: number };
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
const router = useRouter();
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers();
|
||||
}, []);
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/customers');
|
||||
if (res.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setCustomers(data.customers || []);
|
||||
} catch (error) {
|
||||
console.error('获取客户列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCustomers = customers.filter(
|
||||
(c) =>
|
||||
c.companyName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.contactName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.contactEmail?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
|
||||
← 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">客户管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b flex justify-between items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索客户..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="px-4 py-2 border rounded-lg w-64"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
添加客户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
公司名称
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
联系人
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
邮箱
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
许可证数
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
创建时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{filteredCustomers.map((customer) => (
|
||||
<tr key={customer.id}>
|
||||
<td className="px-6 py-4 font-medium">{customer.companyName}</td>
|
||||
<td className="px-6 py-4 text-gray-600">{customer.contactName || '-'}</td>
|
||||
<td className="px-6 py-4 text-gray-600">{customer.contactEmail || '-'}</td>
|
||||
<td className="px-6 py-4">{customer._count.licenses}</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{new Date(customer.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Link
|
||||
href={`/admin/customers/${customer.id}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
查看
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!filteredCustomers.length && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
||||
暂无客户数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showModal && (
|
||||
<CustomerModal
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
fetchCustomers();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
companyName: '',
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onSuccess();
|
||||
} else {
|
||||
setError(data.error || '创建失败');
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="text-lg font-medium">添加客户</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
公司名称 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.companyName}
|
||||
onChange={(e) => setForm({ ...form, companyName: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
联系人
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.contactName}
|
||||
onChange={(e) => setForm({ ...form, contactName: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.contactEmail}
|
||||
onChange={(e) => setForm({ ...form, contactEmail: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
电话
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function LegacyCustomersPage() {
|
||||
redirect('/admin/select-project');
|
||||
}
|
||||
|
||||
@ -1,398 +1,5 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface License {
|
||||
id: string;
|
||||
licenseKey: string;
|
||||
licenseType: string;
|
||||
status: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
customer: { companyName: string } | null;
|
||||
_count: { activations: number };
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export default function LicensesPage() {
|
||||
const router = useRouter();
|
||||
const [licenses, setLicenses] = useState<License[]>([]);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [licensesRes, customersRes] = await Promise.all([
|
||||
fetch('/api/admin/licenses'),
|
||||
fetch('/api/admin/customers'),
|
||||
]);
|
||||
|
||||
if (licensesRes.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const licensesData = await licensesRes.json();
|
||||
const customersData = await customersRes.json();
|
||||
|
||||
setLicenses(licensesData.licenses || []);
|
||||
setCustomers(customersData.customers || []);
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLicenses = licenses.filter(
|
||||
(l) =>
|
||||
l.licenseKey.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.customer?.companyName.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleDisable = async (id: string) => {
|
||||
if (!confirm('确定要禁用此许可证吗?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/licenses/${id}/disable`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('禁用失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async (id: string) => {
|
||||
if (!confirm('确定要重置硬件绑定吗?这将允许在新设备上激活。')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/licenses/${id}/reset`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
|
||||
← 返回
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">许可证管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b flex justify-between items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索许可证..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="px-4 py-2 border rounded-lg w-64"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
生成许可证
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
许可证密钥
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
类型
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
客户
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
激活数
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
到期时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{filteredLicenses.map((license) => (
|
||||
<tr key={license.id}>
|
||||
<td className="px-6 py-4 font-mono text-sm">{license.licenseKey}</td>
|
||||
<td className="px-6 py-4">
|
||||
<TypeBadge type={license.licenseType} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge status={license.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{license.customer?.companyName || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">{license._count.activations}</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{license.expiresAt
|
||||
? new Date(license.expiresAt).toLocaleDateString()
|
||||
: '永久'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
{license.status === 'active' && (
|
||||
<button
|
||||
onClick={() => handleDisable(license.id)}
|
||||
className="text-red-600 hover:underline text-sm"
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleReset(license.id)}
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
重置绑定
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!filteredLicenses.length && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
|
||||
暂无许可证数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showModal && (
|
||||
<LicenseModal
|
||||
customers={customers}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
fetchData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
FREE: 'bg-gray-100 text-gray-700',
|
||||
STANDARD: 'bg-blue-100 text-blue-700',
|
||||
PRO: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[type] || colors.FREE}`}>
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
disabled: 'bg-red-100 text-red-700',
|
||||
expired: 'bg-yellow-100 text-yellow-700',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
active: '正常',
|
||||
disabled: '已禁用',
|
||||
expired: '已过期',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[status] || colors.active}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseModal({
|
||||
customers,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
customers: Customer[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
customerId: '',
|
||||
licenseType: 'STANDARD',
|
||||
expiresInDays: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [generatedKey, setGeneratedKey] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/licenses', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
customerId: form.customerId || null,
|
||||
licenseType: form.licenseType,
|
||||
expiresInDays: form.expiresInDays ? parseInt(form.expiresInDays) : null,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setGeneratedKey(data.license.licenseKey);
|
||||
} else {
|
||||
setError(data.error || '生成失败');
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (generatedKey) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h2 className="text-lg font-medium mb-4">许可证已生成</h2>
|
||||
<div className="bg-gray-100 p-4 rounded-lg mb-4">
|
||||
<p className="text-sm text-gray-500 mb-2">许可证密钥:</p>
|
||||
<p className="font-mono text-lg font-bold text-center">{generatedKey}</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">请妥善保存此密钥,它只会显示一次。</p>
|
||||
<button
|
||||
onClick={onSuccess}
|
||||
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="text-lg font-medium">生成许可证</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
关联客户
|
||||
</label>
|
||||
<select
|
||||
value={form.customerId}
|
||||
onChange={(e) => setForm({ ...form, customerId: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="">不关联客户</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.companyName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
许可证类型 *
|
||||
</label>
|
||||
<select
|
||||
value={form.licenseType}
|
||||
onChange={(e) => setForm({ ...form, licenseType: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
required
|
||||
>
|
||||
<option value="FREE">免费版</option>
|
||||
<option value="STANDARD">标准版</option>
|
||||
<option value="PRO">专业版</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
有效期(天)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.expiresInDays}
|
||||
onChange={(e) => setForm({ ...form, expiresInDays: e.target.value })}
|
||||
placeholder="留空表示永久有效"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '生成中...' : '生成'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function LegacyLicensesPage() {
|
||||
redirect('/admin/select-project');
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ export default function AdminLoginPage() {
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
router.push('/admin');
|
||||
router.push('/admin/select-project');
|
||||
} else {
|
||||
setError(data.error || '登录失败');
|
||||
}
|
||||
|
||||
@ -1,217 +1,5 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface DashboardStats {
|
||||
customers: { total: number };
|
||||
licenses: { total: number; byType: Record<string, number>; byStatus: Record<string, number> };
|
||||
activations: { total: number; active: number };
|
||||
alerts: { pending: number };
|
||||
recentActivations: Array<{
|
||||
id: string;
|
||||
licenseKey: string;
|
||||
licenseType: string;
|
||||
activatedAt: string;
|
||||
companyName: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const router = useRouter();
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/dashboard');
|
||||
if (res.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/admin/auth/logout', { method: 'POST' });
|
||||
router.push('/admin/login');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* 顶部导航 */}
|
||||
<nav className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<h1 className="text-xl font-bold">许可证管理后台</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/customers" className="text-gray-600 hover:text-gray-900">
|
||||
客户管理
|
||||
</Link>
|
||||
<Link href="/admin/licenses" className="text-gray-600 hover:text-gray-900">
|
||||
许可证管理
|
||||
</Link>
|
||||
<Link href="/admin/alerts" className="text-gray-600 hover:text-gray-900">
|
||||
告警中心
|
||||
{stats?.alerts.pending ? (
|
||||
<span className="ml-1 px-2 py-0.5 bg-red-500 text-white text-xs rounded-full">
|
||||
{stats.alerts.pending}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-6 mb-8">
|
||||
<StatCard title="客户总数" value={stats?.customers.total || 0} />
|
||||
<StatCard title="许可证总数" value={stats?.licenses.total || 0} />
|
||||
<StatCard title="活跃激活" value={stats?.activations.active || 0} />
|
||||
<StatCard
|
||||
title="待处理告警"
|
||||
value={stats?.alerts.pending || 0}
|
||||
highlight={!!stats?.alerts.pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 许可证分布 */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium mb-4">许可证类型分布</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats?.licenses.byType || {}).map(([type, count]) => (
|
||||
<div key={type} className="flex justify-between items-center">
|
||||
<span className="text-gray-600">{type}</span>
|
||||
<span className="font-medium">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium mb-4">许可证状态分布</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats?.licenses.byStatus || {}).map(([status, count]) => (
|
||||
<div key={status} className="flex justify-between items-center">
|
||||
<span className="text-gray-600">
|
||||
{status === 'active' ? '正常' : status === 'disabled' ? '已禁用' : '已过期'}
|
||||
</span>
|
||||
<span className="font-medium">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最近激活 */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b">
|
||||
<h3 className="text-lg font-medium">最近激活</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
许可证密钥
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
类型
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
客户
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
激活时间
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{stats?.recentActivations.map((activation) => (
|
||||
<tr key={activation.id}>
|
||||
<td className="px-6 py-4 font-mono text-sm">{activation.licenseKey}</td>
|
||||
<td className="px-6 py-4">
|
||||
<TypeBadge type={activation.licenseType} />
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{activation.companyName || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600">
|
||||
{new Date(activation.activatedAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!stats?.recentActivations.length && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
|
||||
暂无激活记录
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
highlight = false,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow p-6 ${highlight ? 'ring-2 ring-red-500' : ''}`}>
|
||||
<div className="text-sm text-gray-500 mb-1">{title}</div>
|
||||
<div className={`text-3xl font-bold ${highlight ? 'text-red-600' : 'text-gray-900'}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
FREE: 'bg-gray-100 text-gray-700',
|
||||
STANDARD: 'bg-blue-100 text-blue-700',
|
||||
PRO: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${colors[type] || colors.FREE}`}>
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
export default function AdminIndexPage() {
|
||||
redirect('/admin/select-project');
|
||||
}
|
||||
|
||||
282
license-server/app/admin/projects/new/page.tsx
Normal file
282
license-server/app/admin/projects/new/page.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function NewProjectPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [form, setForm] = useState({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
dbHost: 'localhost',
|
||||
dbPort: 5432,
|
||||
dbName: '',
|
||||
dbUser: 'postgres',
|
||||
dbPassword: '',
|
||||
dbSchema: 'public',
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[name]: name === 'dbPort' ? parseInt(value) || 5432 : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/projects/test-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
setSuccess('数据库连接成功!');
|
||||
} else {
|
||||
setError(data.error || '连接失败');
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
router.push('/admin/select-project');
|
||||
} else {
|
||||
setError(data.error || '创建失败');
|
||||
}
|
||||
} catch {
|
||||
setError('网络错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* 顶部导航 */}
|
||||
<nav className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/select-project" className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">创建新项目</h1>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-4 py-8">
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6 space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-4">基本信息</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
项目标识 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
value={form.code}
|
||||
onChange={handleChange}
|
||||
placeholder="如:flnxi-sucai"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">唯一标识,用于 URL 和数据库识别</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
项目名称 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="如:FLNXI 素材库"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={handleChange}
|
||||
rows={2}
|
||||
placeholder="项目描述(可选)"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据库配置 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-4">数据库配置</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
数据库主机 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="dbHost"
|
||||
value={form.dbHost}
|
||||
onChange={handleChange}
|
||||
placeholder="localhost"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
端口
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="dbPort"
|
||||
value={form.dbPort}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
数据库名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="dbName"
|
||||
value={form.dbName}
|
||||
onChange={handleChange}
|
||||
placeholder="license_server_project"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Schema
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="dbSchema"
|
||||
value={form.dbSchema}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
用户名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="dbUser"
|
||||
value={form.dbUser}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
密码 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="dbPassword"
|
||||
value={form.dbPassword}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 测试连接按钮 */}
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={testConnection}
|
||||
disabled={testing || !form.dbHost || !form.dbName || !form.dbPassword}
|
||||
className="px-4 py-2 text-sm border border-blue-500 text-blue-500 rounded-lg hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="text-green-600 text-sm bg-green-50 p-3 rounded-lg">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="flex justify-end gap-4 pt-4 border-t">
|
||||
<Link
|
||||
href="/admin/select-project"
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
取消
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '创建中...' : '创建项目'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
license-server/app/admin/select-project/page.tsx
Normal file
196
license-server/app/admin/select-project/page.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface AdminInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export default function SelectProjectPage() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [admin, setAdmin] = useState<AdminInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// 获取管理员信息
|
||||
const adminRes = await fetch('/api/admin/me');
|
||||
if (adminRes.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
const adminData = await adminRes.json();
|
||||
setAdmin(adminData);
|
||||
|
||||
// 获取项目列表
|
||||
const projectsRes = await fetch('/api/admin/projects');
|
||||
if (projectsRes.ok) {
|
||||
const projectsData = await projectsRes.json();
|
||||
setProjects(projectsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取数据失败:', err);
|
||||
setError('加载失败,请刷新重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/admin/auth/logout', { method: 'POST' });
|
||||
router.push('/admin/login');
|
||||
};
|
||||
|
||||
const handleSelectProject = (projectCode: string) => {
|
||||
router.push(`/admin/${projectCode}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* 顶部导航 */}
|
||||
<nav className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<h1 className="text-xl font-bold">许可证管理系统</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-600">
|
||||
{admin?.username}
|
||||
{admin?.isSuperAdmin && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded">
|
||||
超管
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-12">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">选择项目</h2>
|
||||
<p className="text-gray-500">请选择要管理的项目</p>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow">
|
||||
<p className="text-gray-500 mb-4">暂无可访问的项目</p>
|
||||
{admin?.isSuperAdmin && (
|
||||
<Link
|
||||
href="/admin/projects/new"
|
||||
className="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
创建新项目
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => handleSelectProject(project.code)}
|
||||
className="bg-white rounded-lg shadow p-6 cursor-pointer hover:shadow-lg transition-shadow border-2 border-transparent hover:border-blue-500"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{!project.isActive && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
||||
{project.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{project.description || '暂无描述'}
|
||||
</p>
|
||||
<div className="text-xs text-gray-400">
|
||||
项目标识: {project.code}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 超管显示创建新项目卡片 */}
|
||||
{admin?.isSuperAdmin && (
|
||||
<Link
|
||||
href="/admin/projects/new"
|
||||
className="bg-white rounded-lg shadow p-6 cursor-pointer hover:shadow-lg transition-shadow border-2 border-dashed border-gray-300 hover:border-blue-500 flex flex-col items-center justify-center"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-gray-500">创建新项目</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,156 +1,5 @@
|
||||
'use client';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Version {
|
||||
id: string;
|
||||
version: string;
|
||||
releaseNotes: string | null;
|
||||
minRequiredVersion: string | null;
|
||||
isSecurityUpdate: boolean;
|
||||
isCriticalUpdate: boolean;
|
||||
downloadSize: string | null;
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
export default function VersionsPage() {
|
||||
const router = useRouter();
|
||||
const [versions, setVersions] = useState<Version[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersions();
|
||||
}, []);
|
||||
|
||||
const fetchVersions = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/versions');
|
||||
if (res.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setVersions(data.versions || []);
|
||||
} catch (error) {
|
||||
console.error('获取版本列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin" className="text-gray-600 hover:text-gray-900">← 返回</Link>
|
||||
<h1 className="text-xl font-bold">版本管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b flex justify-between items-center">
|
||||
<h2 className="font-medium">版本列表</h2>
|
||||
<button onClick={() => setShowModal(true)} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
发布新版本
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">版本号</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">发布日期</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">最低要求版本</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">标签</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">更新说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{versions.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td className="px-6 py-4 font-mono font-medium">{v.version}</td>
|
||||
<td className="px-6 py-4 text-gray-600">{new Date(v.releaseDate).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4 text-gray-600">{v.minRequiredVersion || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
{v.isSecurityUpdate && <span className="px-2 py-1 bg-red-100 text-red-700 rounded text-xs mr-1">安全更新</span>}
|
||||
{v.isCriticalUpdate && <span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs">重要更新</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-600 max-w-xs truncate">{v.releaseNotes || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!versions.length && (
|
||||
<tr><td colSpan={5} className="px-6 py-8 text-center text-gray-500">暂无版本数据</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showModal && <VersionModal onClose={() => setShowModal(false)} onSuccess={() => { setShowModal(false); fetchVersions(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [form, setForm] = useState({ version: '', releaseNotes: '', minRequiredVersion: '', isSecurityUpdate: false, isCriticalUpdate: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/admin/versions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) onSuccess();
|
||||
else setError(data.error || '发布失败');
|
||||
} catch { setError('网络错误'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||
<div className="p-6 border-b"><h2 className="text-lg font-medium">发布新版本</h2></div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">版本号 *</label>
|
||||
<input type="text" value={form.version} onChange={(e) => setForm({ ...form, version: e.target.value })} className="w-full px-3 py-2 border rounded-lg" placeholder="1.0.0" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">更新说明</label>
|
||||
<textarea value={form.releaseNotes} onChange={(e) => setForm({ ...form, releaseNotes: e.target.value })} className="w-full px-3 py-2 border rounded-lg" rows={3} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">最低要求版本</label>
|
||||
<input type="text" value={form.minRequiredVersion} onChange={(e) => setForm({ ...form, minRequiredVersion: e.target.value })} className="w-full px-3 py-2 border rounded-lg" placeholder="留空表示不强制更新" />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={form.isSecurityUpdate} onChange={(e) => setForm({ ...form, isSecurityUpdate: e.target.checked })} /><span className="text-sm">安全更新</span></label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={form.isCriticalUpdate} onChange={(e) => setForm({ ...form, isCriticalUpdate: e.target.checked })} /><span className="text-sm">重要更新</span></label>
|
||||
</div>
|
||||
{error && <div className="text-red-600 text-sm bg-red-50 p-3 rounded-lg">{error}</div>}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button type="button" onClick={onClose} className="flex-1 py-2 border rounded-lg hover:bg-gray-50">取消</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">{loading ? '发布中...' : '发布'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function LegacyVersionsPage() {
|
||||
redirect('/admin/select-project');
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getWritableProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string; id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode, id } = await params;
|
||||
const projectDbResult = await getWritableProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.alert.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'ignored',
|
||||
resolvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('忽略项目告警失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getWritableProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string; id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode, id } = await params;
|
||||
const projectDbResult = await getWritableProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.alert.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'resolved',
|
||||
resolvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('处理项目告警失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
44
license-server/app/api/admin/[projectCode]/alerts/route.ts
Normal file
44
license-server/app/api/admin/[projectCode]/alerts/route.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { AlertStatus, Prisma } from '.prisma/project-client';
|
||||
import { getAuthorizedProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getAuthorizedProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const status = request.nextUrl.searchParams.get('status') || 'pending';
|
||||
let where: Prisma.AlertWhereInput = {};
|
||||
|
||||
if (status === 'resolved') {
|
||||
where = { status: { in: [AlertStatus.resolved, AlertStatus.ignored] } };
|
||||
} else if (status !== 'all') {
|
||||
where = { status: AlertStatus.pending };
|
||||
}
|
||||
|
||||
const alerts = await projectDbResult.prisma.alert.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
license: {
|
||||
select: {
|
||||
licenseKey: true,
|
||||
customer: { select: { companyName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ alerts });
|
||||
} catch (error) {
|
||||
console.error('获取项目告警失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
122
license-server/app/api/admin/[projectCode]/api-keys/route.ts
Normal file
122
license-server/app/api/admin/[projectCode]/api-keys/route.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createApiKey, disableApiKey, listApiKeys } from '@/lib/api-key';
|
||||
import { getPublicProjectDb, verifySuperAdmin } from '@/lib/project-auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
const authResult = await verifySuperAdmin();
|
||||
if (!authResult.success) {
|
||||
return authResult.response;
|
||||
}
|
||||
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const apiKeys = await listApiKeys(projectDbResult.prisma);
|
||||
return NextResponse.json({ apiKeys });
|
||||
} catch (error) {
|
||||
console.error('获取项目 API Key 列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
const authResult = await verifySuperAdmin();
|
||||
if (!authResult.success) {
|
||||
return authResult.response;
|
||||
}
|
||||
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const { name } = await request.json();
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'API Key 名称不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await createApiKey(name.trim(), projectDbResult.prisma);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result.error || '创建失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
key: result.key,
|
||||
message: '请立即保存此 API Key,它只会显示一次!',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建项目 API Key 失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
const authResult = await verifySuperAdmin();
|
||||
if (!authResult.success) {
|
||||
return authResult.response;
|
||||
}
|
||||
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const id = request.nextUrl.searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'API Key ID 不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const success = await disableApiKey(id, projectDbResult.prisma);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '禁用失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('禁用项目 API Key 失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthorizedProjectDb, getWritableProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getAuthorizedProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const customers = await projectDbResult.prisma.customer.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: { select: { licenses: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ customers });
|
||||
} catch (error) {
|
||||
console.error('获取项目客户列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getWritableProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const { companyName, contactName, contactEmail, contactPhone } = await request.json();
|
||||
|
||||
if (!companyName) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '公司名称不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const customer = await projectDbResult.prisma.customer.create({
|
||||
data: {
|
||||
companyName,
|
||||
contactName: contactName || null,
|
||||
contactEmail: contactEmail || null,
|
||||
contactPhone: contactPhone || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, customer });
|
||||
} catch (error) {
|
||||
console.error('创建项目客户失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
160
license-server/app/api/admin/[projectCode]/dashboard/route.ts
Normal file
160
license-server/app/api/admin/[projectCode]/dashboard/route.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { mainPrisma, getProjectDb } from '@/lib/db';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getAdminSession();
|
||||
const { projectCode } = await params;
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取项目信息并验证权限
|
||||
const project = await mainPrisma.project.findUnique({
|
||||
where: { code: projectCode },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: '项目不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
const admin = await mainPrisma.admin.findUnique({
|
||||
where: { id: session.id },
|
||||
include: { adminProjects: true },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return NextResponse.json(
|
||||
{ error: '管理员不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!admin.isSuperAdmin) {
|
||||
const hasAccess = admin.adminProjects.some(
|
||||
(ap: { projectId: string }) => ap.projectId === project.id
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权访问此项目' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取项目数据库连接
|
||||
const prisma = await getProjectDb(projectCode);
|
||||
|
||||
// 获取统计数据
|
||||
const [
|
||||
totalLicenses,
|
||||
licensesByType,
|
||||
licensesByStatus,
|
||||
totalActivations,
|
||||
activeActivations,
|
||||
pendingAlerts,
|
||||
recentActivations,
|
||||
] = await Promise.all([
|
||||
// 许可证总数
|
||||
prisma.license.count(),
|
||||
// 按类型统计
|
||||
prisma.license.groupBy({
|
||||
by: ['licenseType'],
|
||||
_count: true,
|
||||
}),
|
||||
// 按状态统计
|
||||
prisma.license.groupBy({
|
||||
by: ['status'],
|
||||
_count: true,
|
||||
}),
|
||||
// 激活总数
|
||||
prisma.activation.count(),
|
||||
// 活跃激活(7天内有心跳)
|
||||
prisma.activation.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
lastHeartbeat: {
|
||||
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
}),
|
||||
// 待处理告警
|
||||
prisma.alert.count({
|
||||
where: { status: 'pending' },
|
||||
}),
|
||||
// 最近激活
|
||||
prisma.activation.findMany({
|
||||
take: 10,
|
||||
orderBy: { activatedAt: 'desc' },
|
||||
include: {
|
||||
license: {
|
||||
select: {
|
||||
licenseKey: true,
|
||||
licenseType: true,
|
||||
customerName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// 格式化统计数据
|
||||
const byType: Record<string, number> = {};
|
||||
licensesByType.forEach((item: { licenseType: string; _count: number }) => {
|
||||
byType[item.licenseType] = item._count;
|
||||
});
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
licensesByStatus.forEach((item: { status: string; _count: number }) => {
|
||||
byStatus[item.status] = item._count;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
licenses: {
|
||||
total: totalLicenses,
|
||||
byType,
|
||||
byStatus,
|
||||
},
|
||||
activations: {
|
||||
total: totalActivations,
|
||||
active: activeActivations,
|
||||
},
|
||||
alerts: {
|
||||
pending: pendingAlerts,
|
||||
},
|
||||
recentActivations: recentActivations.map((activation: {
|
||||
id: string;
|
||||
activatedAt: Date;
|
||||
license: {
|
||||
licenseKey: string;
|
||||
licenseType: string;
|
||||
customerName: string | null;
|
||||
};
|
||||
}) => ({
|
||||
id: activation.id,
|
||||
licenseKey: activation.license.licenseKey,
|
||||
licenseType: activation.license.licenseType,
|
||||
customerName: activation.license.customerName,
|
||||
activatedAt: activation.activatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取仪表盘数据失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getWritableProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string; id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode, id } = await params;
|
||||
const projectDbResult = await getWritableProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const license = await projectDbResult.prisma.license.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!license) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.alert.deleteMany({
|
||||
where: { licenseId: id },
|
||||
});
|
||||
await projectDbResult.prisma.heartbeatLog.deleteMany({
|
||||
where: { licenseId: id },
|
||||
});
|
||||
await projectDbResult.prisma.activation.deleteMany({
|
||||
where: { licenseId: id },
|
||||
});
|
||||
await projectDbResult.prisma.license.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, message: '许可证已删除' });
|
||||
} catch (error) {
|
||||
console.error('删除项目许可证失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getWritableProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string; id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode, id } = await params;
|
||||
const projectDbResult = await getWritableProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.license.update({
|
||||
where: { id },
|
||||
data: { status: 'disabled' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('禁用项目许可证失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getWritableProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string; id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode, id } = await params;
|
||||
const projectDbResult = await getWritableProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.license.update({
|
||||
where: { id },
|
||||
data: { status: 'active' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('启用项目许可证失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getWritableProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string; id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode, id } = await params;
|
||||
const projectDbResult = await getWritableProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.activation.updateMany({
|
||||
where: { licenseId: id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('重置项目许可证绑定失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
82
license-server/app/api/admin/[projectCode]/licenses/route.ts
Normal file
82
license-server/app/api/admin/[projectCode]/licenses/route.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { generateActivationCode } from '@/lib/crypto';
|
||||
import { getAuthorizedProjectDb, getWritableProjectDb } from '@/lib/project-auth';
|
||||
import { LICENSE_CONFIGS } from '@/lib/types';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getAuthorizedProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const licenses = await projectDbResult.prisma.license.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
licenseKey: true,
|
||||
licenseType: true,
|
||||
customerName: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
_count: { select: { activations: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ licenses });
|
||||
} catch (error) {
|
||||
console.error('获取项目许可证列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getWritableProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const { customerName, licenseType, expiresInDays } = await request.json();
|
||||
|
||||
if (!licenseType || !['STANDARD', 'PRO'].includes(licenseType)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的许可证类型' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = LICENSE_CONFIGS[licenseType as 'STANDARD' | 'PRO'];
|
||||
const expiresAt = expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const license = await projectDbResult.prisma.license.create({
|
||||
data: {
|
||||
licenseKey: generateActivationCode(licenseType),
|
||||
licenseType,
|
||||
customerName: customerName || null,
|
||||
maxUsers: config.limits.maxUsers,
|
||||
maxMaterials: config.limits.maxMaterials,
|
||||
features: config.features,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, license });
|
||||
} catch (error) {
|
||||
console.error('创建项目许可证失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
69
license-server/app/api/admin/[projectCode]/versions/route.ts
Normal file
69
license-server/app/api/admin/[projectCode]/versions/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthorizedProjectDb, getWritableProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getAuthorizedProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const versions = await projectDbResult.prisma.version.findMany({
|
||||
orderBy: { releaseDate: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ versions });
|
||||
} catch (error) {
|
||||
console.error('获取项目版本列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getWritableProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const { version, releaseNotes, minRequiredVersion, isSecurityUpdate, isCriticalUpdate } =
|
||||
await request.json();
|
||||
|
||||
if (!version) {
|
||||
return NextResponse.json({ success: false, error: '版本号不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await projectDbResult.prisma.version.findUnique({
|
||||
where: { version },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ success: false, error: '版本号已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newVersion = await projectDbResult.prisma.version.create({
|
||||
data: {
|
||||
version,
|
||||
releaseNotes: releaseNotes || null,
|
||||
minRequiredVersion: minRequiredVersion || null,
|
||||
isSecurityUpdate: isSecurityUpdate || false,
|
||||
isCriticalUpdate: isCriticalUpdate || false,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, version: newVersion });
|
||||
} catch (error) {
|
||||
console.error('发布项目版本失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
await prisma.alert.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'ignored',
|
||||
resolvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('忽略告警失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/alerts/{id}/ignore',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,30 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
await prisma.alert.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'resolved',
|
||||
resolvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('处理告警失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/alerts/{id}/resolve',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,41 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { AlertStatus, Prisma } from '@prisma/client';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'pending';
|
||||
|
||||
let where: Prisma.AlertWhereInput = {};
|
||||
if (status === 'resolved') {
|
||||
where = { status: { in: [AlertStatus.resolved, AlertStatus.ignored] } };
|
||||
} else if (status !== 'all') {
|
||||
where = { status: AlertStatus.pending };
|
||||
}
|
||||
|
||||
const alerts = await prisma.alert.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
license: {
|
||||
select: {
|
||||
licenseKey: true,
|
||||
customer: { select: { companyName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ alerts });
|
||||
} catch (error) {
|
||||
console.error('获取告警列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/alerts',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
23
license-server/app/api/admin/api-keys/route.ts
Normal file
23
license-server/app/api/admin/api-keys/route.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
function deprecatedResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/api-keys',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return deprecatedResponse();
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return deprecatedResponse();
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return deprecatedResponse();
|
||||
}
|
||||
@ -1,56 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
function deprecatedResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/customers',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const customers = await prisma.customer.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: { select: { licenses: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ customers });
|
||||
} catch (error) {
|
||||
console.error('获取客户列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
return deprecatedResponse();
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { companyName, contactName, contactEmail, contactPhone } = await request.json();
|
||||
|
||||
if (!companyName) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '公司名称不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.create({
|
||||
data: {
|
||||
companyName,
|
||||
contactName: contactName || null,
|
||||
contactEmail: contactEmail || null,
|
||||
contactPhone: contactPhone || null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, customer });
|
||||
} catch (error) {
|
||||
console.error('创建客户失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
export async function POST() {
|
||||
return deprecatedResponse();
|
||||
}
|
||||
|
||||
@ -1,78 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 客户统计
|
||||
const customerCount = await prisma.customer.count();
|
||||
|
||||
// 许可证统计
|
||||
const licenseCount = await prisma.license.count();
|
||||
const licensesByType = await prisma.license.groupBy({
|
||||
by: ['licenseType'],
|
||||
_count: true,
|
||||
});
|
||||
const licensesByStatus = await prisma.license.groupBy({
|
||||
by: ['status'],
|
||||
_count: true,
|
||||
});
|
||||
|
||||
// 激活统计
|
||||
const activationCount = await prisma.activation.count();
|
||||
const activeActivations = await prisma.activation.count({
|
||||
where: { isActive: true },
|
||||
});
|
||||
|
||||
// 待处理告警
|
||||
const pendingAlerts = await prisma.alert.count({
|
||||
where: { status: 'pending' },
|
||||
});
|
||||
|
||||
// 最近激活
|
||||
const recentActivations = await prisma.activation.findMany({
|
||||
take: 10,
|
||||
orderBy: { activatedAt: 'desc' },
|
||||
include: {
|
||||
license: {
|
||||
include: {
|
||||
customer: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
customers: { total: customerCount },
|
||||
licenses: {
|
||||
total: licenseCount,
|
||||
byType: Object.fromEntries(
|
||||
licensesByType.map((item) => [item.licenseType, item._count])
|
||||
),
|
||||
byStatus: Object.fromEntries(
|
||||
licensesByStatus.map((item) => [item.status, item._count])
|
||||
),
|
||||
},
|
||||
activations: {
|
||||
total: activationCount,
|
||||
active: activeActivations,
|
||||
},
|
||||
alerts: { pending: pendingAlerts },
|
||||
recentActivations: recentActivations.map((a) => ({
|
||||
id: a.id,
|
||||
licenseKey: a.license.licenseKey,
|
||||
licenseType: a.license.licenseType,
|
||||
activatedAt: a.activatedAt.toISOString(),
|
||||
companyName: a.license.customer?.companyName || null,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取仪表盘数据失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/dashboard',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
11
license-server/app/api/admin/licenses/[id]/delete/route.ts
Normal file
11
license-server/app/api/admin/licenses/[id]/delete/route.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function DELETE() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/licenses/{id}/delete',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
@ -1,27 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
await prisma.license.update({
|
||||
where: { id },
|
||||
data: { status: 'disabled' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('禁用许可证失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/licenses/{id}/disable',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,28 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// 将所有激活记录标记为非活跃
|
||||
await prisma.activation.updateMany({
|
||||
where: { licenseId: id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('重置硬件绑定失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/licenses/{id}/reset',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,68 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAdminSession, generateLicenseKey } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { LICENSE_CONFIGS } from '@/lib/types';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
function deprecatedResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/licenses',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const licenses = await prisma.license.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
customer: { select: { companyName: true } },
|
||||
_count: { select: { activations: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ licenses });
|
||||
} catch (error) {
|
||||
console.error('获取许可证列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
return deprecatedResponse();
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { customerId, licenseType, expiresInDays } = await request.json();
|
||||
|
||||
if (!licenseType || !['FREE', 'STANDARD', 'PRO'].includes(licenseType)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的许可证类型' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = LICENSE_CONFIGS[licenseType as keyof typeof LICENSE_CONFIGS];
|
||||
const licenseKey = generateLicenseKey();
|
||||
|
||||
const expiresAt = expiresInDays
|
||||
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const license = await prisma.license.create({
|
||||
data: {
|
||||
licenseKey,
|
||||
licenseType,
|
||||
customerId: customerId || null,
|
||||
maxUsers: config.limits.maxUsers,
|
||||
maxMaterials: config.limits.maxMaterials,
|
||||
features: config.features,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, license });
|
||||
} catch (error) {
|
||||
console.error('生成许可证失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
export async function POST() {
|
||||
return deprecatedResponse();
|
||||
}
|
||||
|
||||
43
license-server/app/api/admin/me/route.ts
Normal file
43
license-server/app/api/admin/me/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
import { mainPrisma } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getAdminSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const admin = await mainPrisma.admin.findUnique({
|
||||
where: { id: session.id },
|
||||
select: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return NextResponse.json(
|
||||
{ error: '管理员不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...session,
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取管理员信息失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
76
license-server/app/api/admin/projects/[projectCode]/route.ts
Normal file
76
license-server/app/api/admin/projects/[projectCode]/route.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { mainPrisma } from '@/lib/db';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getAdminSession();
|
||||
const { projectCode } = await params;
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取项目信息
|
||||
const project = await mainPrisma.project.findUnique({
|
||||
where: { code: projectCode },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: '项目不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
const admin = await mainPrisma.admin.findUnique({
|
||||
where: { id: session.id },
|
||||
include: {
|
||||
adminProjects: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return NextResponse.json(
|
||||
{ error: '管理员不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 超管可以访问所有项目
|
||||
if (!admin.isSuperAdmin) {
|
||||
const hasAccess = admin.adminProjects.some(
|
||||
(ap: { projectId: string }) => ap.projectId === project.id
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权访问此项目' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回项目信息(不包含敏感的数据库连接信息)
|
||||
return NextResponse.json({
|
||||
id: project.id,
|
||||
code: project.code,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
isActive: project.isActive,
|
||||
createdAt: project.createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取项目信息失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
130
license-server/app/api/admin/projects/route.ts
Normal file
130
license-server/app/api/admin/projects/route.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { mainPrisma } from '@/lib/db';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getAdminSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 超管可以查看所有项目
|
||||
// 普通管理员只能查看被分配的项目
|
||||
const admin = await mainPrisma.admin.findUnique({
|
||||
where: { id: session.id },
|
||||
include: {
|
||||
adminProjects: {
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return NextResponse.json(
|
||||
{ error: '管理员不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
let projects;
|
||||
if (admin.isSuperAdmin) {
|
||||
// 超管获取所有项目
|
||||
projects = await mainPrisma.project.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
} else {
|
||||
// 普通管理员获取分配的项目
|
||||
projects = admin.adminProjects.map((ap: { project: unknown }) => ap.project);
|
||||
}
|
||||
|
||||
return NextResponse.json(projects);
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getAdminSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否是超管
|
||||
const admin = await mainPrisma.admin.findUnique({
|
||||
where: { id: session.id },
|
||||
});
|
||||
|
||||
if (!admin || !admin.isSuperAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: '无权限创建项目' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { code, name, description, dbHost, dbPort, dbName, dbUser, dbPassword, dbSchema } = body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!code || !name || !dbHost || !dbName || !dbUser || !dbPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查项目代码是否已存在
|
||||
const existing = await mainPrisma.project.findUnique({
|
||||
where: { code },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: '项目标识已存在' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 加密数据库密码
|
||||
const { encryptPassword } = await import('@/lib/db');
|
||||
const encryptedPassword = encryptPassword(dbPassword);
|
||||
|
||||
// 创建项目
|
||||
const project = await mainPrisma.project.create({
|
||||
data: {
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
dbHost,
|
||||
dbPort: dbPort || 5432,
|
||||
dbName,
|
||||
dbUser,
|
||||
dbPassword: encryptedPassword,
|
||||
dbSchema: dbSchema || 'public',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(project);
|
||||
} catch (error) {
|
||||
console.error('创建项目失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { testProjectConnection } from '@/lib/db';
|
||||
import { verifySuperAdmin } from '@/lib/project-auth';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// 验证超管权限
|
||||
const authResult = await verifySuperAdmin();
|
||||
if (!authResult.success) {
|
||||
return authResult.response;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { dbHost, dbPort, dbName, dbUser, dbPassword, dbSchema } = body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!dbHost || !dbName || !dbUser || !dbPassword) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const result = await testProjectConnection({
|
||||
dbHost,
|
||||
dbPort: dbPort || 5432,
|
||||
dbName,
|
||||
dbUser,
|
||||
dbPassword,
|
||||
dbSchema: dbSchema || 'public',
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('测试连接失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,58 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAdminSession } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
function deprecatedResponse() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/admin/{projectCode}/versions',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const versions = await prisma.version.findMany({
|
||||
orderBy: { releaseDate: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({ versions });
|
||||
} catch (error) {
|
||||
console.error('获取版本列表失败:', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
return deprecatedResponse();
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { version, releaseNotes, minRequiredVersion, isSecurityUpdate, isCriticalUpdate } =
|
||||
await request.json();
|
||||
|
||||
if (!version) {
|
||||
return NextResponse.json({ success: false, error: '版本号不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查版本号是否已存在
|
||||
const existing = await prisma.version.findUnique({ where: { version } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ success: false, error: '版本号已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newVersion = await prisma.version.create({
|
||||
data: {
|
||||
version,
|
||||
releaseNotes: releaseNotes || null,
|
||||
minRequiredVersion: minRequiredVersion || null,
|
||||
isSecurityUpdate: isSecurityUpdate || false,
|
||||
isCriticalUpdate: isCriticalUpdate || false,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, version: newVersion });
|
||||
} catch (error) {
|
||||
console.error('发布版本失败:', error);
|
||||
return NextResponse.json({ success: false, error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
export async function POST() {
|
||||
return deprecatedResponse();
|
||||
}
|
||||
|
||||
132
license-server/app/api/license/[projectCode]/activate/route.ts
Normal file
132
license-server/app/api/license/[projectCode]/activate/route.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { signLicense, isValidLicenseKeyFormat } from '@/lib/crypto';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
const activateSchema = z.object({
|
||||
licenseKey: z.string(),
|
||||
fingerprint: z.object({
|
||||
machineId: z.string(),
|
||||
hostname: z.string(),
|
||||
macAddress: z.string(),
|
||||
cpuId: z.string().optional(),
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = activateSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求参数无效' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { licenseKey, fingerprint, timestamp } = parsed.data;
|
||||
const now = Date.now();
|
||||
|
||||
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求已过期' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidLicenseKeyFormat(licenseKey)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证密钥格式无效' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const license = await projectDbResult.prisma.license.findUnique({
|
||||
where: { licenseKey },
|
||||
});
|
||||
|
||||
if (!license) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证密钥不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (license.status === 'disabled') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已被禁用' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已过期' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const existingActivation = await projectDbResult.prisma.activation.findFirst({
|
||||
where: {
|
||||
licenseId: license.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingActivation) {
|
||||
const existingFingerprint = existingActivation.fingerprint as unknown as HardwareFingerprint;
|
||||
if (existingFingerprint.instanceId !== fingerprint.instanceId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已在其他设备上激活,请联系管理员重置' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.activation.update({
|
||||
where: { id: existingActivation.id },
|
||||
data: {
|
||||
fingerprint: fingerprint as object,
|
||||
lastHeartbeat: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await projectDbResult.prisma.activation.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
instanceId: fingerprint.instanceId,
|
||||
fingerprint: fingerprint as object,
|
||||
lastHeartbeat: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const signedLicense = signLicense(
|
||||
generateLicenseConfig(license.licenseType, license.expiresAt)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
license: signedLicense,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('项目激活失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
167
license-server/app/api/license/[projectCode]/generate/route.ts
Normal file
167
license-server/app/api/license/[projectCode]/generate/route.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { verifyApiKey } from '@/lib/api-key';
|
||||
import { LICENSE_CONFIGS } from '@/lib/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
const generateSchema = z.object({
|
||||
orderNo: z.string().min(1, '订单号不能为空'),
|
||||
plan: z.enum(['STANDARD', 'PRO'], {
|
||||
errorMap: () => ({ message: '许可证类型必须是 STANDARD 或 PRO' }),
|
||||
}),
|
||||
customerEmail: z.string().email('客户邮箱格式无效'),
|
||||
customerName: z.string().optional(),
|
||||
contactInfo: z.string().optional(),
|
||||
validDays: z.number().int().min(1).max(3650).optional(),
|
||||
timestamp: z.number().int(),
|
||||
sign: z.string().min(1, '签名不能为空'),
|
||||
});
|
||||
|
||||
const SIGN_EXPIRE_MS = 5 * 60 * 1000;
|
||||
|
||||
const PLAN_NAMES: Record<string, string> = {
|
||||
STANDARD: '标准版',
|
||||
PRO: '专业版',
|
||||
};
|
||||
|
||||
function generateActivationCode(plan: 'STANDARD' | 'PRO'): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const typePrefix = plan === 'PRO' ? 'PRO' : 'STD';
|
||||
const segments: string[] = [];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let segment = '';
|
||||
for (let j = 0; j < 4; j++) {
|
||||
segment += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
return `FLNXI-${typePrefix}-${segments.join('-')}`;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const apiKey = request.headers.get('X-API-Key');
|
||||
const authResult = await verifyApiKey(apiKey, projectDbResult.prisma);
|
||||
|
||||
if (!authResult.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'API Key 无效或已过期' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = generateSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
const firstError = parsed.error.errors[0]?.message || '参数验证失败';
|
||||
return NextResponse.json(
|
||||
{ success: false, error: firstError },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
orderNo,
|
||||
plan,
|
||||
customerEmail,
|
||||
customerName,
|
||||
contactInfo,
|
||||
validDays,
|
||||
timestamp,
|
||||
sign,
|
||||
} = parsed.data;
|
||||
|
||||
if (Math.abs(Date.now() - timestamp) > SIGN_EXPIRE_MS) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求已过期,请重新发起' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const expectedSign = createHash('md5')
|
||||
.update(`${orderNo}${timestamp}${apiKey}`)
|
||||
.digest('hex');
|
||||
|
||||
if (sign.toLowerCase() !== expectedSign.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '签名验证失败' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const existingOrder = await projectDbResult.prisma.order.findUnique({
|
||||
where: { orderNo },
|
||||
});
|
||||
|
||||
if (existingOrder) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '订单号已存在' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const activationCode = generateActivationCode(plan);
|
||||
const config = LICENSE_CONFIGS[plan];
|
||||
const expiresAt = validDays
|
||||
? new Date(Date.now() + validDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
await projectDbResult.prisma.$transaction(async (tx) => {
|
||||
const license = await tx.license.create({
|
||||
data: {
|
||||
licenseKey: activationCode,
|
||||
licenseType: plan,
|
||||
customerName: customerName || null,
|
||||
contactInfo: contactInfo || null,
|
||||
maxUsers: config.limits.maxUsers,
|
||||
maxMaterials: config.limits.maxMaterials,
|
||||
features: config.features,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.order.create({
|
||||
data: {
|
||||
orderNo,
|
||||
plan,
|
||||
customerEmail,
|
||||
customerName: customerName || null,
|
||||
licenseId: license.id,
|
||||
validDays: validDays ?? undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
activationCode,
|
||||
plan,
|
||||
planName: PLAN_NAMES[plan],
|
||||
expiresAt: expiresAt ? expiresAt.toISOString() : null,
|
||||
validDays: validDays || null,
|
||||
isPermanent: !validDays,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[License] 项目激活码生成失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
192
license-server/app/api/license/[projectCode]/heartbeat/route.ts
Normal file
192
license-server/app/api/license/[projectCode]/heartbeat/route.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { hashFingerprint, signLicense } from '@/lib/crypto';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
function calculateFingerprintMatchScore(
|
||||
stored: HardwareFingerprint,
|
||||
current: HardwareFingerprint
|
||||
): number {
|
||||
let score = 0;
|
||||
let total = 0;
|
||||
|
||||
if (stored.machineId === current.machineId) score += 40;
|
||||
total += 40;
|
||||
|
||||
if (stored.hostname === current.hostname) score += 20;
|
||||
total += 20;
|
||||
|
||||
if (stored.macAddress === current.macAddress) score += 30;
|
||||
total += 30;
|
||||
|
||||
if (stored.instanceId === current.instanceId) score += 10;
|
||||
total += 10;
|
||||
|
||||
return (score / total) * 100;
|
||||
}
|
||||
|
||||
const heartbeatSchema = z.object({
|
||||
instanceId: z.string(),
|
||||
fingerprint: z.object({
|
||||
machineId: z.string(),
|
||||
hostname: z.string(),
|
||||
macAddress: z.string(),
|
||||
cpuId: z.string().optional(),
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
usage: z.object({
|
||||
userCount: z.number(),
|
||||
materialCount: z.number(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
async function detectAnomaly(
|
||||
prisma: Awaited<ReturnType<typeof import('@/lib/db').getProjectDb>>,
|
||||
licenseId: string,
|
||||
fingerprintHash: string
|
||||
) {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const recentHeartbeats = await prisma.heartbeatLog.findMany({
|
||||
where: {
|
||||
licenseId,
|
||||
createdAt: { gte: twentyFourHoursAgo },
|
||||
},
|
||||
select: {
|
||||
fingerprintHash: true,
|
||||
},
|
||||
distinct: ['fingerprintHash'],
|
||||
});
|
||||
|
||||
const uniqueFingerprints = new Set(recentHeartbeats.map((item) => item.fingerprintHash));
|
||||
uniqueFingerprints.add(fingerprintHash);
|
||||
|
||||
if (uniqueFingerprints.size > 1) {
|
||||
const existingAlert = await prisma.alert.findFirst({
|
||||
where: {
|
||||
licenseId,
|
||||
alertType: 'multi_device',
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingAlert) {
|
||||
await prisma.alert.create({
|
||||
data: {
|
||||
licenseId,
|
||||
alertType: 'multi_device',
|
||||
details: {
|
||||
fingerprints: Array.from(uniqueFingerprints),
|
||||
detectedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = heartbeatSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ acknowledged: false }, { status: 400 });
|
||||
}
|
||||
|
||||
const { instanceId, fingerprint, usage, timestamp } = parsed.data;
|
||||
const now = Date.now();
|
||||
|
||||
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||
return NextResponse.json({ acknowledged: false }, { status: 400 });
|
||||
}
|
||||
|
||||
const activation = await projectDbResult.prisma.activation.findFirst({
|
||||
where: {
|
||||
instanceId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
license: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!activation) {
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false, error: '激活记录不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { license } = activation;
|
||||
|
||||
if (license.status === 'disabled') {
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false, error: '许可证已被禁用' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) {
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false, error: '许可证已过期' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const storedFingerprint = activation.fingerprint as unknown as HardwareFingerprint;
|
||||
const heartbeatThreshold = license.licenseType === 'FREE' ? 85 : 60;
|
||||
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||
|
||||
if (matchScore < heartbeatThreshold) {
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false, error: '硬件指纹不匹配' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const fingerprintHash = hashFingerprint(fingerprint);
|
||||
|
||||
await projectDbResult.prisma.heartbeatLog.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
instanceId,
|
||||
fingerprintHash,
|
||||
ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||
},
|
||||
});
|
||||
|
||||
await projectDbResult.prisma.activation.update({
|
||||
where: { id: activation.id },
|
||||
data: {
|
||||
lastHeartbeat: new Date(),
|
||||
usageStats: usage,
|
||||
},
|
||||
});
|
||||
|
||||
await detectAnomaly(projectDbResult.prisma, license.id, fingerprintHash);
|
||||
|
||||
const signedLicense = signLicense(
|
||||
generateLicenseConfig(license.licenseType, license.expiresAt)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
acknowledged: true,
|
||||
license: signedLicense,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('项目心跳处理失败:', error);
|
||||
return NextResponse.json({ acknowledged: false }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { signLicense } from '@/lib/crypto';
|
||||
import { generateLicenseConfig, LICENSE_CONFIGS } from '@/lib/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerFreeSchema = z.object({
|
||||
fingerprint: z.object({
|
||||
machineId: z.string({ required_error: '系统信息不完整:machineId 缺失' }),
|
||||
hostname: z.string({ required_error: '系统信息不完整:hostname 缺失' }),
|
||||
macAddress: z.string({ required_error: '系统信息不完整:macAddress 缺失' }),
|
||||
cpuId: z.string().optional(),
|
||||
instanceId: z.string({ required_error: '系统信息不完整:instanceId 缺失' }),
|
||||
}),
|
||||
customerName: z.string().min(1, '客户名称不能为空'),
|
||||
contactInfo: z.string().min(1, '联系方式不能为空'),
|
||||
timestamp: z.number({ required_error: '请求时间戳缺失' }),
|
||||
});
|
||||
|
||||
function generateFreeActivationCode(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const segments: string[] = [];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let segment = '';
|
||||
for (let j = 0; j < 4; j++) {
|
||||
segment += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
return `FLNXI-FREE-${segments.join('-')}`;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = registerFreeSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
const firstError = parsed.error.errors[0]?.message || '参数验证失败';
|
||||
return NextResponse.json(
|
||||
{ success: false, error: firstError },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { fingerprint, customerName, contactInfo, timestamp } = parsed.data;
|
||||
const now = Date.now();
|
||||
|
||||
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求已过期' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existingActivation = await projectDbResult.prisma.activation.findFirst({
|
||||
where: {
|
||||
instanceId: fingerprint.instanceId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
license: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingActivation) {
|
||||
const signedLicense = signLicense(
|
||||
generateLicenseConfig(
|
||||
existingActivation.license.licenseType,
|
||||
existingActivation.license.expiresAt
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
license: signedLicense,
|
||||
data: {
|
||||
activationCode: existingActivation.license.licenseKey,
|
||||
plan: existingActivation.license.licenseType,
|
||||
planName:
|
||||
existingActivation.license.licenseType === 'FREE'
|
||||
? '免费版'
|
||||
: existingActivation.license.licenseType === 'STANDARD'
|
||||
? '标准版'
|
||||
: '专业版',
|
||||
isExisting: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const activationCode = generateFreeActivationCode();
|
||||
const clientIP =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
await projectDbResult.prisma.$transaction(async (tx) => {
|
||||
const license = await tx.license.create({
|
||||
data: {
|
||||
licenseKey: activationCode,
|
||||
licenseType: 'FREE',
|
||||
customerName,
|
||||
contactInfo,
|
||||
maxUsers: LICENSE_CONFIGS.FREE.limits.maxUsers,
|
||||
maxMaterials: LICENSE_CONFIGS.FREE.limits.maxMaterials,
|
||||
features: LICENSE_CONFIGS.FREE.features,
|
||||
expiresAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.activation.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
instanceId: fingerprint.instanceId,
|
||||
fingerprint: fingerprint as object,
|
||||
lastHeartbeat: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.heartbeatLog.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
instanceId: fingerprint.instanceId,
|
||||
fingerprintHash: fingerprint.machineId,
|
||||
ipAddress: clientIP,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const signedLicense = signLicense(generateLicenseConfig('FREE', null));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
license: signedLicense,
|
||||
data: {
|
||||
activationCode,
|
||||
plan: 'FREE',
|
||||
planName: '免费版',
|
||||
isExisting: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('项目免费版注册失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
151
license-server/app/api/license/[projectCode]/verify/route.ts
Normal file
151
license-server/app/api/license/[projectCode]/verify/route.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { signLicense } from '@/lib/crypto';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
const verifySchema = z.object({
|
||||
instanceId: z.string(),
|
||||
fingerprint: z.object({
|
||||
machineId: z.string(),
|
||||
hostname: z.string(),
|
||||
macAddress: z.string(),
|
||||
cpuId: z.string().optional(),
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
function calculateFingerprintMatchScore(
|
||||
stored: HardwareFingerprint,
|
||||
current: HardwareFingerprint
|
||||
): number {
|
||||
let score = 0;
|
||||
let total = 0;
|
||||
|
||||
if (stored.machineId === current.machineId) score += 40;
|
||||
total += 40;
|
||||
|
||||
if (stored.hostname === current.hostname) score += 20;
|
||||
total += 20;
|
||||
|
||||
if (stored.macAddress === current.macAddress) score += 30;
|
||||
total += 30;
|
||||
|
||||
if (stored.instanceId === current.instanceId) score += 10;
|
||||
total += 10;
|
||||
|
||||
return (score / total) * 100;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = verifySchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '请求参数无效' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { instanceId, fingerprint, timestamp } = parsed.data;
|
||||
const now = Date.now();
|
||||
|
||||
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '请求已过期' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const activation = await projectDbResult.prisma.activation.findFirst({
|
||||
where: {
|
||||
instanceId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
license: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!activation) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '未找到激活记录' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { license } = activation;
|
||||
|
||||
if (license.status === 'disabled') {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '许可证已被禁用' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '许可证已过期' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const storedFingerprint = activation.fingerprint as unknown as HardwareFingerprint;
|
||||
const isFreeLicense = license.licenseType === 'FREE';
|
||||
const strictThreshold = isFreeLicense ? 90 : 70;
|
||||
const normalThreshold = isFreeLicense ? 70 : 50;
|
||||
const isInstanceIdMatch = storedFingerprint.instanceId === fingerprint.instanceId;
|
||||
const isMachineIdMatch = storedFingerprint.machineId === fingerprint.machineId;
|
||||
|
||||
if (isInstanceIdMatch && !isMachineIdMatch) {
|
||||
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||
if (matchScore < strictThreshold) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '硬件指纹不匹配,请重新激活' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
} else if (!isInstanceIdMatch) {
|
||||
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||
if (matchScore < normalThreshold) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '硬件指纹不匹配,请重新激活' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.activation.update({
|
||||
where: { id: activation.id },
|
||||
data: { lastHeartbeat: new Date() },
|
||||
});
|
||||
|
||||
const signedLicense = signLicense(
|
||||
generateLicenseConfig(license.licenseType, license.expiresAt)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
license: signedLicense,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('项目验证失败:', error);
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,135 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { signLicense, hashFingerprint, isValidLicenseKeyFormat } from '@/lib/crypto';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { z } from 'zod';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// 请求验证 schema
|
||||
const activateSchema = z.object({
|
||||
licenseKey: z.string(),
|
||||
fingerprint: z.object({
|
||||
machineId: z.string(),
|
||||
hostname: z.string(),
|
||||
macAddress: z.string(),
|
||||
cpuId: z.string().optional(),
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = activateSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求参数无效' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { licenseKey, fingerprint, timestamp } = parsed.data;
|
||||
|
||||
// 验证时间戳(防止重放攻击,允许 5 分钟误差)
|
||||
const now = Date.now();
|
||||
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求已过期' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证许可证密钥格式
|
||||
if (!isValidLicenseKeyFormat(licenseKey)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证密钥格式无效' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 查找许可证
|
||||
const license = await prisma.license.findUnique({
|
||||
where: { licenseKey },
|
||||
});
|
||||
|
||||
if (!license) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证密钥不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查许可证状态
|
||||
if (license.status === 'disabled') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已被禁用' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已过期' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否已激活
|
||||
const existingActivation = await prisma.activation.findFirst({
|
||||
where: {
|
||||
licenseId: license.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingActivation) {
|
||||
// 检查是否是同一设备重新激活
|
||||
const existingFingerprint = existingActivation.fingerprint as unknown as HardwareFingerprint;
|
||||
if (existingFingerprint.instanceId !== fingerprint.instanceId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已在其他设备上激活,请联系管理员重置' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
// 同一设备,更新激活信息
|
||||
await prisma.activation.update({
|
||||
where: { id: existingActivation.id },
|
||||
data: {
|
||||
fingerprint: fingerprint as object,
|
||||
lastHeartbeat: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 创建新激活记录
|
||||
await prisma.activation.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
instanceId: fingerprint.instanceId,
|
||||
fingerprint: fingerprint as object,
|
||||
lastHeartbeat: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 生成许可证配置
|
||||
const licenseConfig = generateLicenseConfig(
|
||||
license.licenseType,
|
||||
license.expiresAt
|
||||
);
|
||||
|
||||
// 签名许可证
|
||||
const signedLicense = signLicense(licenseConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
license: signedLicense,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('激活失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/license/{projectCode}/activate',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
11
license-server/app/api/license/generate/route.ts
Normal file
11
license-server/app/api/license/generate/route.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/license/{projectCode}/generate',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
@ -1,158 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { signLicense, hashFingerprint } from '@/lib/crypto';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { z } from 'zod';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// 请求验证 schema
|
||||
const heartbeatSchema = z.object({
|
||||
instanceId: z.string(),
|
||||
fingerprint: z.object({
|
||||
machineId: z.string(),
|
||||
hostname: z.string(),
|
||||
macAddress: z.string(),
|
||||
cpuId: z.string().optional(),
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
usage: z.object({
|
||||
userCount: z.number(),
|
||||
materialCount: z.number(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
// 检测异常:24小时内多设备使用
|
||||
async function detectAnomaly(licenseId: string, fingerprintHash: string) {
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// 获取24小时内的不同指纹
|
||||
const recentHeartbeats = await prisma.heartbeatLog.findMany({
|
||||
where: {
|
||||
licenseId,
|
||||
createdAt: { gte: twentyFourHoursAgo },
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/license/{projectCode}/heartbeat',
|
||||
},
|
||||
select: {
|
||||
fingerprintHash: true,
|
||||
},
|
||||
distinct: ['fingerprintHash'],
|
||||
});
|
||||
|
||||
const uniqueFingerprints = new Set(recentHeartbeats.map(h => h.fingerprintHash));
|
||||
uniqueFingerprints.add(fingerprintHash);
|
||||
|
||||
// 如果超过1个不同的指纹,创建告警
|
||||
if (uniqueFingerprints.size > 1) {
|
||||
// 检查是否已有未处理的告警
|
||||
const existingAlert = await prisma.alert.findFirst({
|
||||
where: {
|
||||
licenseId,
|
||||
alertType: 'multi_device',
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingAlert) {
|
||||
await prisma.alert.create({
|
||||
data: {
|
||||
licenseId,
|
||||
alertType: 'multi_device',
|
||||
details: {
|
||||
fingerprints: Array.from(uniqueFingerprints),
|
||||
detectedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = heartbeatSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { instanceId, fingerprint, usage, timestamp } = parsed.data;
|
||||
|
||||
// 验证时间戳
|
||||
const now = Date.now();
|
||||
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 查找激活记录
|
||||
const activation = await prisma.activation.findFirst({
|
||||
where: {
|
||||
instanceId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
license: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!activation) {
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { license } = activation;
|
||||
const fingerprintHash = hashFingerprint(fingerprint);
|
||||
|
||||
// 记录心跳日志
|
||||
await prisma.heartbeatLog.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
instanceId,
|
||||
fingerprintHash,
|
||||
ipAddress: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'),
|
||||
},
|
||||
});
|
||||
|
||||
// 更新激活记录
|
||||
await prisma.activation.update({
|
||||
where: { id: activation.id },
|
||||
data: {
|
||||
lastHeartbeat: new Date(),
|
||||
usageStats: usage,
|
||||
},
|
||||
});
|
||||
|
||||
// 异常检测
|
||||
await detectAnomaly(license.id, fingerprintHash);
|
||||
|
||||
// 检查许可证是否有更新
|
||||
let signedLicense = undefined;
|
||||
|
||||
// 如果许可证状态变化,返回新配置
|
||||
if (license.status === 'active') {
|
||||
const licenseConfig = generateLicenseConfig(
|
||||
license.licenseType,
|
||||
license.expiresAt
|
||||
);
|
||||
signedLicense = signLicense(licenseConfig);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
acknowledged: true,
|
||||
license: signedLicense,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('心跳处理失败:', error);
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
11
license-server/app/api/license/register-free/route.ts
Normal file
11
license-server/app/api/license/register-free/route.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/license/{projectCode}/register-free',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
@ -1,150 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { signLicense, hashFingerprint } from '@/lib/crypto';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { z } from 'zod';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
// 请求验证 schema
|
||||
const verifySchema = z.object({
|
||||
instanceId: z.string(),
|
||||
fingerprint: z.object({
|
||||
machineId: z.string(),
|
||||
hostname: z.string(),
|
||||
macAddress: z.string(),
|
||||
cpuId: z.string().optional(),
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
// 计算指纹匹配分数
|
||||
function calculateFingerprintMatchScore(
|
||||
stored: HardwareFingerprint,
|
||||
current: HardwareFingerprint
|
||||
): number {
|
||||
let score = 0;
|
||||
let total = 0;
|
||||
|
||||
// machineId 权重 40%
|
||||
if (stored.machineId === current.machineId) {
|
||||
score += 40;
|
||||
}
|
||||
total += 40;
|
||||
|
||||
// hostname 权重 20%
|
||||
if (stored.hostname === current.hostname) {
|
||||
score += 20;
|
||||
}
|
||||
total += 20;
|
||||
|
||||
// macAddress 权重 30%
|
||||
if (stored.macAddress === current.macAddress) {
|
||||
score += 30;
|
||||
}
|
||||
total += 30;
|
||||
|
||||
// instanceId 权重 10%
|
||||
if (stored.instanceId === current.instanceId) {
|
||||
score += 10;
|
||||
}
|
||||
total += 10;
|
||||
|
||||
return (score / total) * 100;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = verifySchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '请求参数无效' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { instanceId, fingerprint, timestamp } = parsed.data;
|
||||
|
||||
// 验证时间戳
|
||||
const now = Date.now();
|
||||
if (Math.abs(now - timestamp) > 5 * 60 * 1000) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '请求已过期' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 查找激活记录
|
||||
const activation = await prisma.activation.findFirst({
|
||||
where: {
|
||||
instanceId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
license: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!activation) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '未找到激活记录' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { license } = activation;
|
||||
|
||||
// 检查许可证状态
|
||||
if (license.status === 'disabled') {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '许可证已被禁用' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '许可证已过期' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证硬件指纹匹配度
|
||||
const storedFingerprint = activation.fingerprint as unknown as HardwareFingerprint;
|
||||
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||
|
||||
if (matchScore < 60) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '硬件指纹不匹配,请重新激活' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 更新最后验证时间
|
||||
await prisma.activation.update({
|
||||
where: { id: activation.id },
|
||||
data: { lastHeartbeat: new Date() },
|
||||
});
|
||||
|
||||
// 生成许可证配置
|
||||
const licenseConfig = generateLicenseConfig(
|
||||
license.licenseType,
|
||||
license.expiresAt
|
||||
);
|
||||
|
||||
// 签名许可证
|
||||
const signedLicense = signLicense(licenseConfig);
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
license: signedLicense,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('验证失败:', error);
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
export async function POST() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/license/{projectCode}/verify',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
42
license-server/app/api/version/[projectCode]/agent/route.ts
Normal file
42
license-server/app/api/version/[projectCode]/agent/route.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const latestAgent = await projectDbResult.prisma.agentVersion.findFirst({
|
||||
orderBy: { releaseDate: 'desc' },
|
||||
});
|
||||
|
||||
if (!latestAgent) {
|
||||
return NextResponse.json({
|
||||
latestVersion: '1.0.0',
|
||||
releaseNotes: null,
|
||||
releaseDate: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
latestVersion: latestAgent.version,
|
||||
releaseNotes: latestAgent.releaseNotes,
|
||||
releaseDate: latestAgent.releaseDate.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取项目 Agent 版本失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
license-server/app/api/version/[projectCode]/check/route.ts
Normal file
50
license-server/app/api/version/[projectCode]/check/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import type { VersionCheckResponse } from '@/lib/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
request.nextUrl.searchParams.get('current');
|
||||
|
||||
const latestVersion = await projectDbResult.prisma.version.findFirst({
|
||||
orderBy: { releaseDate: 'desc' },
|
||||
});
|
||||
|
||||
if (!latestVersion) {
|
||||
return NextResponse.json(
|
||||
{ error: '未找到版本信息' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const response: VersionCheckResponse = {
|
||||
latestVersion: latestVersion.version,
|
||||
minRequiredVersion: latestVersion.minRequiredVersion,
|
||||
releaseNotes: latestVersion.releaseNotes,
|
||||
releaseDate: latestVersion.releaseDate.toISOString(),
|
||||
isSecurityUpdate: latestVersion.isSecurityUpdate,
|
||||
isCriticalUpdate: latestVersion.isCriticalUpdate,
|
||||
downloadSize: latestVersion.downloadSize,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('项目版本检查失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,32 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 获取最新的 Agent 版本
|
||||
const latestAgent = await prisma.agentVersion.findFirst({
|
||||
orderBy: { releaseDate: 'desc' },
|
||||
});
|
||||
|
||||
if (!latestAgent) {
|
||||
// 如果没有记录,返回默认版本
|
||||
return NextResponse.json({
|
||||
latestVersion: '1.0.0',
|
||||
releaseNotes: null,
|
||||
releaseDate: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
latestVersion: latestAgent.version,
|
||||
releaseNotes: latestAgent.releaseNotes,
|
||||
releaseDate: latestAgent.releaseDate.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取 Agent 版本失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/version/{projectCode}/agent',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,40 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import type { VersionCheckResponse } from '@/lib/types';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const currentVersion = searchParams.get('current');
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// 获取最新版本
|
||||
const latestVersion = await prisma.version.findFirst({
|
||||
orderBy: { releaseDate: 'desc' },
|
||||
});
|
||||
|
||||
if (!latestVersion) {
|
||||
return NextResponse.json(
|
||||
{ error: '未找到版本信息' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const response: VersionCheckResponse = {
|
||||
latestVersion: latestVersion.version,
|
||||
minRequiredVersion: latestVersion.minRequiredVersion,
|
||||
releaseNotes: latestVersion.releaseNotes,
|
||||
releaseDate: latestVersion.releaseDate.toISOString(),
|
||||
isSecurityUpdate: latestVersion.isSecurityUpdate,
|
||||
isCriticalUpdate: latestVersion.isCriticalUpdate,
|
||||
downloadSize: latestVersion.downloadSize,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('版本检查失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '必须指定项目编码',
|
||||
message: '请改用 /api/version/{projectCode}/check',
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,134 @@
|
||||
# 许可证服务器部署脚本
|
||||
# 部署到 45.249.208.118
|
||||
# 许可证服务器历史内部部署脚本
|
||||
# 状态:默认禁止执行,不作为客户交付入口
|
||||
|
||||
param(
|
||||
[string]$Server = "45.249.208.118",
|
||||
[string]$Server = "",
|
||||
[string]$User = "root",
|
||||
[string]$SshKey = "$env:USERPROFILE\.ssh\youyidian_prod_ed25519",
|
||||
[string]$SshKey = "",
|
||||
[string]$RemotePath = "/opt/license-server",
|
||||
[switch]$SkipBuild = $false
|
||||
[switch]$SkipBuild = $false,
|
||||
[switch]$AcknowledgeLegacyInternalDeploy
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not $AcknowledgeLegacyInternalDeploy) {
|
||||
Write-Host "[ERROR] license-server/deploy.ps1 是历史内部部署脚本,默认禁止执行。" -ForegroundColor Red
|
||||
Write-Host "许可证服务是授权中枢,正式部署前必须先确认目标服务器、密钥、版本镜像和回滚方案。" -ForegroundColor Yellow
|
||||
Write-Host "如确需排查历史链路,请追加 -AcknowledgeLegacyInternalDeploy,并显式传入 -Server 和 -SshKey。" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Server) -or [string]::IsNullOrWhiteSpace($SshKey)) {
|
||||
throw "请显式传入 -Server 和 -SshKey,脚本不再默认指向旧服务器或旧 SSH key。"
|
||||
}
|
||||
|
||||
function New-RandomHex {
|
||||
param([int]$Bytes = 32)
|
||||
$buffer = New-Object byte[] $Bytes
|
||||
[System.Security.Cryptography.RandomNumberGenerator]::Fill($buffer)
|
||||
return ([System.BitConverter]::ToString($buffer)).Replace("-", "").ToLower()
|
||||
}
|
||||
|
||||
function New-RandomBase64Url {
|
||||
param([int]$Bytes = 18)
|
||||
$buffer = New-Object byte[] $Bytes
|
||||
[System.Security.Cryptography.RandomNumberGenerator]::Fill($buffer)
|
||||
$base64 = [Convert]::ToBase64String($buffer)
|
||||
return $base64.TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||
}
|
||||
|
||||
function Convert-ToEscapedEnvMultiline {
|
||||
param([string]$Value)
|
||||
return ($Value.Trim() -replace "`r", "" -replace "`n", "\n")
|
||||
}
|
||||
|
||||
function New-RsaKeyPair {
|
||||
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
|
||||
try {
|
||||
return @{
|
||||
PrivateKey = $rsa.ExportPkcs8PrivateKeyPem()
|
||||
PublicKey = $rsa.ExportSubjectPublicKeyInfoPem()
|
||||
}
|
||||
} finally {
|
||||
$rsa.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-EnvValue {
|
||||
param(
|
||||
[string]$Content,
|
||||
[string]$Key
|
||||
)
|
||||
|
||||
$match = [regex]::Match($Content, "(?m)^" + [regex]::Escape($Key) + "=(.*)$")
|
||||
if (-not $match.Success) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$value = $match.Groups[1].Value.Trim()
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
return $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
return $value
|
||||
}
|
||||
|
||||
function Test-EnvConfig {
|
||||
param([string]$Content)
|
||||
|
||||
$requiredKeys = @(
|
||||
"POSTGRES_USER",
|
||||
"POSTGRES_PASSWORD",
|
||||
"POSTGRES_DB",
|
||||
"MAIN_DATABASE_URL",
|
||||
"MAIN_DB_ENCRYPTION_KEY",
|
||||
"DATABASE_URL",
|
||||
"RSA_PRIVATE_KEY",
|
||||
"RSA_PUBLIC_KEY",
|
||||
"AES_SECRET_KEY",
|
||||
"JWT_SECRET",
|
||||
"ADMIN_INITIAL_PASSWORD"
|
||||
)
|
||||
|
||||
foreach ($key in $requiredKeys) {
|
||||
$value = Get-EnvValue -Content $Content -Key $key
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
throw ".env 缺少必填项: $key"
|
||||
}
|
||||
}
|
||||
|
||||
$blockedPatterns = @(
|
||||
"your-jwt-secret",
|
||||
"your-rsa-private-key",
|
||||
"your-main-db-encryption-key",
|
||||
"your-32-byte-hex-key",
|
||||
"replace-with",
|
||||
"admin123",
|
||||
"license_secret"
|
||||
)
|
||||
|
||||
foreach ($pattern in $blockedPatterns) {
|
||||
if ($Content -match [regex]::Escape($pattern)) {
|
||||
throw ".env 中仍包含不安全占位值: $pattern"
|
||||
}
|
||||
}
|
||||
|
||||
$jwtSecret = Get-EnvValue -Content $Content -Key "JWT_SECRET"
|
||||
if ($jwtSecret.Length -lt 32) {
|
||||
throw "JWT_SECRET 长度不足 32 字节"
|
||||
}
|
||||
|
||||
$mainDbEncryptionKey = Get-EnvValue -Content $Content -Key "MAIN_DB_ENCRYPTION_KEY"
|
||||
if ($mainDbEncryptionKey.Length -lt 32) {
|
||||
throw "MAIN_DB_ENCRYPTION_KEY 长度不足 32 字节"
|
||||
}
|
||||
|
||||
$aesSecretKey = Get-EnvValue -Content $Content -Key "AES_SECRET_KEY"
|
||||
if ($aesSecretKey.Length -lt 64) {
|
||||
throw "AES_SECRET_KEY 需要至少 64 个十六进制字符"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "许可证服务器部署" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
@ -81,18 +199,41 @@ Remove-Item -Recurse -Force $tempDir
|
||||
Write-Host "4. 检查环境变量配置..." -ForegroundColor White
|
||||
$envExists = ssh -i $SshKey "$User@$Server" "test -f $RemotePath/.env && echo 'exists'"
|
||||
if ($envExists -ne "exists") {
|
||||
Write-Host " 创建 .env 文件模板..." -ForegroundColor Yellow
|
||||
Write-Host " 生成安全 .env 配置..." -ForegroundColor Yellow
|
||||
$postgresUser = "license"
|
||||
$postgresPassword = New-RandomBase64Url
|
||||
$postgresDb = "license_db"
|
||||
$jwtSecret = New-RandomHex
|
||||
$mainDbEncryptionKey = New-RandomHex
|
||||
$aesSecretKey = New-RandomHex
|
||||
$adminInitialPassword = New-RandomBase64Url
|
||||
$rsaKeys = New-RsaKeyPair
|
||||
$mainDatabaseUrl = "postgresql://${postgresUser}:${postgresPassword}@postgres:5432/${postgresDb}?schema=main"
|
||||
$projectDatabaseUrl = "postgresql://${postgresUser}:${postgresPassword}@postgres:5432/${postgresDb}?schema=public"
|
||||
|
||||
$envContent = @"
|
||||
# 许可证服务器环境变量
|
||||
JWT_SECRET=your-jwt-secret-change-this
|
||||
RSA_PRIVATE_KEY=your-rsa-private-key
|
||||
POSTGRES_USER=$postgresUser
|
||||
POSTGRES_PASSWORD=$postgresPassword
|
||||
POSTGRES_DB=$postgresDb
|
||||
MAIN_DATABASE_URL=$mainDatabaseUrl
|
||||
MAIN_DB_ENCRYPTION_KEY=$mainDbEncryptionKey
|
||||
DATABASE_URL=$projectDatabaseUrl
|
||||
RSA_PRIVATE_KEY=$(Convert-ToEscapedEnvMultiline $rsaKeys.PrivateKey)
|
||||
RSA_PUBLIC_KEY=$(Convert-ToEscapedEnvMultiline $rsaKeys.PublicKey)
|
||||
AES_SECRET_KEY=$aesSecretKey
|
||||
JWT_SECRET=$jwtSecret
|
||||
ADMIN_INITIAL_PASSWORD=$adminInitialPassword
|
||||
"@
|
||||
ssh -i $SshKey "$User@$Server" "cat > $RemotePath/.env << 'EOF'
|
||||
$envContent
|
||||
EOF"
|
||||
Write-Host " 请编辑 $RemotePath/.env 配置 RSA 私钥" -ForegroundColor Yellow
|
||||
EOF"
|
||||
Write-Host " 已生成安全 .env 文件 ✓" -ForegroundColor Green
|
||||
Write-Host " 初始管理员密码已写入远程 .env 的 ADMIN_INITIAL_PASSWORD,不在部署日志中输出明文" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host " .env 文件已存在 ✓" -ForegroundColor Green
|
||||
$remoteEnv = ssh -i $SshKey "$User@$Server" "cat $RemotePath/.env"
|
||||
Test-EnvConfig -Content $remoteEnv
|
||||
Write-Host " .env 文件已存在且通过安全检查 ✓" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 5. 构建和启动
|
||||
@ -108,7 +249,7 @@ Start-Sleep -Seconds 10
|
||||
|
||||
# 7. 运行数据库迁移
|
||||
Write-Host "7. 运行数据库迁移..." -ForegroundColor White
|
||||
Invoke-RemoteCommand "cd $RemotePath && docker compose exec -T license-server npx prisma db push"
|
||||
Invoke-RemoteCommand "cd $RemotePath && docker compose exec -T license-server npm run db:push"
|
||||
Write-Host " 数据库迁移成功 ✓" -ForegroundColor Green
|
||||
|
||||
# 8. 检查服务状态
|
||||
@ -121,9 +262,9 @@ Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "部署完成!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "许可证服务器地址: http://$Server`:3001" -ForegroundColor Yellow
|
||||
Write-Host "许可证服务器地址: http://$Server`:9527" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "后续步骤:" -ForegroundColor White
|
||||
Write-Host "1. 配置 RSA 私钥: ssh -i $SshKey $User@$Server 'vim $RemotePath/.env'" -ForegroundColor Gray
|
||||
Write-Host "1. 查看安全配置: ssh -i $SshKey $User@$Server 'sed -n \"1,20p\" $RemotePath/.env'" -ForegroundColor Gray
|
||||
Write-Host "2. 重启服务: ssh -i $SshKey $User@$Server 'cd $RemotePath && docker compose restart'" -ForegroundColor Gray
|
||||
Write-Host "3. 初始化管理员: ssh -i $SshKey $User@$Server 'cd $RemotePath && docker compose exec license-server npx ts-node scripts/init-admin.ts'" -ForegroundColor Gray
|
||||
Write-Host "3. 初始化超管: ssh -i $SshKey $User@$Server 'cd $RemotePath && docker compose exec license-server npx ts-node scripts/init-admin.ts admin'" -ForegroundColor Gray
|
||||
|
||||
@ -8,9 +8,14 @@ services:
|
||||
ports:
|
||||
- "9527:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://license:license_secret@postgres:5432/license_db
|
||||
- JWT_SECRET=${JWT_SECRET:-license-server-jwt-secret}
|
||||
- RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY}
|
||||
- MAIN_DATABASE_URL=${MAIN_DATABASE_URL:?MAIN_DATABASE_URL is required}
|
||||
- MAIN_DB_ENCRYPTION_KEY=${MAIN_DB_ENCRYPTION_KEY:?MAIN_DB_ENCRYPTION_KEY is required}
|
||||
- DATABASE_URL=${DATABASE_URL:?DATABASE_URL is required}
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY:?RSA_PRIVATE_KEY is required}
|
||||
- RSA_PUBLIC_KEY=${RSA_PUBLIC_KEY:?RSA_PUBLIC_KEY is required}
|
||||
- AES_SECRET_KEY=${AES_SECRET_KEY:?AES_SECRET_KEY is required}
|
||||
- ADMIN_INITIAL_PASSWORD=${ADMIN_INITIAL_PASSWORD:?ADMIN_INITIAL_PASSWORD is required}
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
postgres:
|
||||
@ -23,13 +28,13 @@ services:
|
||||
container_name: license-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=license
|
||||
- POSTGRES_PASSWORD=license_secret
|
||||
- POSTGRES_DB=license_db
|
||||
- POSTGRES_USER=${POSTGRES_USER:?POSTGRES_USER is required}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
- POSTGRES_DB=${POSTGRES_DB:?POSTGRES_DB is required}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U license -d license_db"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
135
license-server/lib/api-key.ts
Normal file
135
license-server/lib/api-key.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import type { PrismaClient as ProjectPrismaClient } from '.prisma/project-client';
|
||||
|
||||
type ApiKeyPrismaClient = Pick<ProjectPrismaClient, 'apiKey'>;
|
||||
|
||||
/**
|
||||
* 生成新的 API Key
|
||||
* 返回原始 key(只显示一次)和哈希值(存储用)
|
||||
*/
|
||||
export function generateApiKey(): { key: string; hash: string } {
|
||||
// 生成 32 字节随机数,转为 base64,约 43 字符
|
||||
const key = `flnxi_${randomBytes(32).toString('base64url')}`;
|
||||
const hash = hashApiKey(key);
|
||||
return { key, hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 API Key 进行哈希
|
||||
*/
|
||||
export function hashApiKey(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 API Key
|
||||
* 返回 API Key 记录(如果有效)或 null
|
||||
*/
|
||||
export async function verifyApiKey(
|
||||
key: string | null | undefined,
|
||||
prismaClient: ApiKeyPrismaClient
|
||||
): Promise<{
|
||||
valid: boolean;
|
||||
apiKey?: { id: string; name: string };
|
||||
error?: string;
|
||||
}> {
|
||||
if (!key) {
|
||||
return { valid: false, error: 'API Key 未提供' };
|
||||
}
|
||||
|
||||
const hash = hashApiKey(key);
|
||||
|
||||
try {
|
||||
const apiKey = await prismaClient.apiKey.findUnique({
|
||||
where: { keyHash: hash },
|
||||
});
|
||||
|
||||
if (!apiKey) {
|
||||
return { valid: false, error: 'API Key 无效' };
|
||||
}
|
||||
|
||||
if (!apiKey.isActive) {
|
||||
return { valid: false, error: 'API Key 已被禁用' };
|
||||
}
|
||||
|
||||
// 更新最后使用时间(异步,不阻塞)
|
||||
prismaClient.apiKey.update({
|
||||
where: { id: apiKey.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
}).catch((err: Error) => {
|
||||
console.warn('[ApiKey] 更新最后使用时间失败:', err.message);
|
||||
});
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
apiKey: { id: apiKey.id, name: apiKey.name },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ApiKey] 验证失败:', error);
|
||||
return { valid: false, error: '验证服务异常' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 API Key(管理员用)
|
||||
*/
|
||||
export async function createApiKey(
|
||||
name: string,
|
||||
prismaClient: ApiKeyPrismaClient
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
key?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const { key, hash } = generateApiKey();
|
||||
|
||||
await prismaClient.apiKey.create({
|
||||
data: {
|
||||
name,
|
||||
keyHash: hash,
|
||||
},
|
||||
});
|
||||
|
||||
// 返回原始 key,只显示这一次
|
||||
return { success: true, key };
|
||||
} catch (error) {
|
||||
console.error('[ApiKey] 创建失败:', error);
|
||||
return { success: false, error: '创建失败' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用 API Key
|
||||
*/
|
||||
export async function disableApiKey(
|
||||
id: string,
|
||||
prismaClient: ApiKeyPrismaClient
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await prismaClient.apiKey.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[ApiKey] 禁用失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有 API Key(不含原始 key)
|
||||
*/
|
||||
export async function listApiKeys(prismaClient: ApiKeyPrismaClient) {
|
||||
return prismaClient.apiKey.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isActive: true,
|
||||
lastUsedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
@ -1,9 +1,19 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { prisma } from './db';
|
||||
import { mainPrisma } from './db';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'license-server-secret-key';
|
||||
// JWT 密钥 - 必须通过环境变量配置,不再提供默认值
|
||||
const JWT_SECRET = (() => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
console.error('[SECURITY] JWT_SECRET 环境变量未设置,管理员认证功能将不可用');
|
||||
} else if (secret.length < 32) {
|
||||
console.error('[SECURITY] JWT_SECRET 长度不足 32 字节,建议使用更长的密钥');
|
||||
}
|
||||
return secret || '';
|
||||
})();
|
||||
|
||||
const TOKEN_EXPIRY = '24h';
|
||||
|
||||
export interface AdminSession {
|
||||
@ -19,7 +29,7 @@ export async function verifyAdmin(
|
||||
password: string
|
||||
): Promise<{ success: boolean; token?: string; error?: string }> {
|
||||
try {
|
||||
const admin = await prisma.admin.findUnique({
|
||||
const admin = await mainPrisma.admin.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
@ -79,7 +89,7 @@ export async function createAdmin(
|
||||
password: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const existing = await prisma.admin.findUnique({
|
||||
const existing = await mainPrisma.admin.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
@ -89,7 +99,7 @@ export async function createAdmin(
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
await prisma.admin.create({
|
||||
await mainPrisma.admin.create({
|
||||
data: {
|
||||
username,
|
||||
passwordHash,
|
||||
|
||||
@ -38,12 +38,12 @@ export function encryptAES(plaintext: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const key = Buffer.from(AES_SECRET_KEY, 'hex');
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
|
||||
// 格式: iv:authTag:encrypted
|
||||
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
|
||||
}
|
||||
@ -53,27 +53,27 @@ export function encryptAES(plaintext: string): string {
|
||||
*/
|
||||
export function decryptAES(ciphertext: string): string {
|
||||
const [ivBase64, authTagBase64, encrypted] = ciphertext.split(':');
|
||||
|
||||
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const authTag = Buffer.from(authTagBase64, 'base64');
|
||||
const key = Buffer.from(AES_SECRET_KEY, 'hex');
|
||||
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'base64', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成许可证密钥 (XXXX-XXXX-XXXX-XXXX 格式)
|
||||
* 生成许可证密钥 (XXXX-XXXX-XXXX-XXXX 格式) - 旧格式,保留兼容
|
||||
*/
|
||||
export function generateLicenseKey(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 排除容易混淆的字符
|
||||
const segments: string[] = [];
|
||||
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let segment = '';
|
||||
for (let j = 0; j < 4; j++) {
|
||||
@ -81,15 +81,37 @@ export function generateLicenseKey(): string {
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
|
||||
return segments.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成新格式激活码
|
||||
* 格式:FLNXI-{TYPE}-{XXXX}-{XXXX}-{XXXX}-{XXXX}
|
||||
* TYPE: FREE (免费版) / STD (标准版) / PRO (专业版)
|
||||
*/
|
||||
export function generateActivationCode(type: 'FREE' | 'STANDARD' | 'PRO'): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const typeCode = type === 'FREE' ? 'FREE' : type === 'STANDARD' ? 'STD' : 'PRO';
|
||||
|
||||
const segments: string[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let segment = '';
|
||||
for (let j = 0; j < 4; j++) {
|
||||
segment += chars.charAt(crypto.randomInt(chars.length));
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
return `FLNXI-${typeCode}-${segments.join('-')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证许可证密钥格式
|
||||
* 格式:FLNXI-{TYPE}-XXXX-XXXX-XXXX-XXXX(TYPE: FREE/STD/PRO)
|
||||
*/
|
||||
export function isValidLicenseKeyFormat(key: string): boolean {
|
||||
return /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(key);
|
||||
return /^FLNXI-(FREE|STD|PRO)-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -108,7 +130,7 @@ export function signLicense(licenseData: object): { data: object; signature: str
|
||||
const dataWithTimestamp = { ...licenseData, issuedAt };
|
||||
const dataString = JSON.stringify(dataWithTimestamp);
|
||||
const signature = signData(dataString);
|
||||
|
||||
|
||||
return {
|
||||
data: dataWithTimestamp,
|
||||
signature,
|
||||
|
||||
205
license-server/lib/db-manager.ts
Normal file
205
license-server/lib/db-manager.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import { PrismaClient } from '.prisma/main-client';
|
||||
import type { Project } from '.prisma/main-client';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// 主库客户端(单例)
|
||||
let mainClient: PrismaClient | null = null;
|
||||
|
||||
// 项目库客户端缓存(LRU,最多缓存 10 个连接)
|
||||
const projectClients = new Map<string, { client: PrismaClient; lastUsed: number }>();
|
||||
const MAX_CACHED_CLIENTS = 10;
|
||||
const CLIENT_EXPIRY_MS = 30 * 60 * 1000; // 30 分钟未使用则释放
|
||||
|
||||
// 加密密钥(从环境变量获取)
|
||||
const getEncryptionKey = () => {
|
||||
const key = process.env.MAIN_DB_ENCRYPTION_KEY;
|
||||
if (!key || key.length < 32) {
|
||||
throw new Error('MAIN_DB_ENCRYPTION_KEY 环境变量未设置或长度不足 32 字节');
|
||||
}
|
||||
return key.substring(0, 32);
|
||||
};
|
||||
|
||||
/**
|
||||
* 加密数据库密码
|
||||
*/
|
||||
export function encryptPassword(password: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let encrypted = cipher.update(password, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密数据库密码
|
||||
*/
|
||||
export function decryptPassword(encryptedPassword: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const [ivHex, encrypted] = encryptedPassword.split(':');
|
||||
|
||||
if (!ivHex || !encrypted) {
|
||||
throw new Error('密码格式无效');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取主库客户端
|
||||
*/
|
||||
export function getMainClient(): PrismaClient {
|
||||
if (!mainClient) {
|
||||
mainClient = new PrismaClient();
|
||||
}
|
||||
return mainClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建项目数据库连接 URL
|
||||
*/
|
||||
function buildProjectDbUrl(project: Project): string {
|
||||
const password = decryptPassword(project.dbPassword);
|
||||
return `postgresql://${project.dbUser}:${encodeURIComponent(password)}@${project.dbHost}:${project.dbPort}/${project.dbName}?schema=${project.dbSchema}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目库客户端(动态创建,带缓存)
|
||||
*/
|
||||
export async function getProjectClient(projectCode: string): Promise<PrismaClient> {
|
||||
// 清理过期连接
|
||||
cleanupExpiredClients();
|
||||
|
||||
// 检查缓存
|
||||
const cached = projectClients.get(projectCode);
|
||||
if (cached) {
|
||||
cached.lastUsed = Date.now();
|
||||
return cached.client;
|
||||
}
|
||||
|
||||
// 从主库获取项目配置
|
||||
const mainPrisma = getMainClient();
|
||||
const project = await mainPrisma.project.findUnique({
|
||||
where: { code: projectCode },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`项目不存在: ${projectCode}`);
|
||||
}
|
||||
|
||||
if (!project.isActive) {
|
||||
throw new Error(`项目已禁用: ${projectCode}`);
|
||||
}
|
||||
|
||||
// 创建新的项目库客户端
|
||||
const dbUrl = buildProjectDbUrl(project);
|
||||
const client = new PrismaClient({
|
||||
datasourceUrl: dbUrl,
|
||||
});
|
||||
|
||||
// 缓存客户端
|
||||
if (projectClients.size >= MAX_CACHED_CLIENTS) {
|
||||
// 移除最久未使用的客户端
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
projectClients.forEach((value, key) => {
|
||||
if (value.lastUsed < oldestTime) {
|
||||
oldestTime = value.lastUsed;
|
||||
oldestKey = key;
|
||||
}
|
||||
});
|
||||
|
||||
if (oldestKey) {
|
||||
const oldClient = projectClients.get(oldestKey);
|
||||
if (oldClient) {
|
||||
await oldClient.client.$disconnect();
|
||||
}
|
||||
projectClients.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
projectClients.set(projectCode, { client, lastUsed: Date.now() });
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据项目配置直接创建客户端(用于测试连接)
|
||||
*/
|
||||
export async function createProjectClient(project: Project): Promise<PrismaClient> {
|
||||
const dbUrl = buildProjectDbUrl(project);
|
||||
const client = new PrismaClient({
|
||||
datasourceUrl: dbUrl,
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试项目数据库连接
|
||||
*/
|
||||
export async function testProjectConnection(project: Project): Promise<{ success: boolean; error?: string }> {
|
||||
let client: PrismaClient | null = null;
|
||||
|
||||
try {
|
||||
const dbUrl = buildProjectDbUrl(project);
|
||||
client = new PrismaClient({
|
||||
datasourceUrl: dbUrl,
|
||||
});
|
||||
|
||||
await client.$connect();
|
||||
await client.$queryRaw`SELECT 1`;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '连接失败',
|
||||
};
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.$disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的客户端连接
|
||||
*/
|
||||
function cleanupExpiredClients(): void {
|
||||
const now = Date.now();
|
||||
|
||||
projectClients.forEach((value, key) => {
|
||||
if (now - value.lastUsed > CLIENT_EXPIRY_MS) {
|
||||
value.client.$disconnect().catch(console.error);
|
||||
projectClients.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开所有连接(应用关闭时调用)
|
||||
*/
|
||||
export async function disconnectAll(): Promise<void> {
|
||||
if (mainClient) {
|
||||
await mainClient.$disconnect();
|
||||
mainClient = null;
|
||||
}
|
||||
|
||||
for (const value of Array.from(projectClients.values())) {
|
||||
await value.client.$disconnect();
|
||||
}
|
||||
|
||||
projectClients.clear();
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type { Project } from '.prisma/main-client';
|
||||
@ -1,15 +1,213 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
/**
|
||||
* 数据库访问层
|
||||
*
|
||||
* 支持多项目架构:
|
||||
* - 主库:管理项目和用户
|
||||
* - 项目库:每个项目独立的许可证数据库
|
||||
*/
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
import { PrismaClient as MainPrismaClient } from '.prisma/main-client';
|
||||
import { PrismaClient as ProjectPrismaClient } from '.prisma/project-client';
|
||||
|
||||
// 主库客户端(单例)
|
||||
const globalForMainPrisma = globalThis as unknown as {
|
||||
mainPrisma: MainPrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
export const mainPrisma =
|
||||
globalForMainPrisma.mainPrisma ??
|
||||
new MainPrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
if (process.env.NODE_ENV !== 'production') globalForMainPrisma.mainPrisma = mainPrisma;
|
||||
|
||||
export default prisma;
|
||||
// 项目库客户端缓存
|
||||
const projectClients = new Map<string, { client: ProjectPrismaClient; lastUsed: number }>();
|
||||
const MAX_CACHED_CLIENTS = 10;
|
||||
const CLIENT_EXPIRY_MS = 30 * 60 * 1000;
|
||||
|
||||
// 加密密钥
|
||||
const getEncryptionKey = (): string => {
|
||||
const key = process.env.MAIN_DB_ENCRYPTION_KEY;
|
||||
if (!key || key.length < 32) {
|
||||
throw new Error('MAIN_DB_ENCRYPTION_KEY 环境变量未设置或长度不足 32 字节');
|
||||
}
|
||||
return key.substring(0, 32);
|
||||
};
|
||||
|
||||
/**
|
||||
* 加密数据库密码
|
||||
*/
|
||||
export function encryptPassword(password: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const crypto = require('crypto');
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let encrypted = cipher.update(password, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密数据库密码
|
||||
*/
|
||||
export function decryptPassword(encryptedPassword: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const crypto = require('crypto');
|
||||
const [ivHex, encrypted] = encryptedPassword.split(':');
|
||||
|
||||
if (!ivHex || !encrypted) {
|
||||
throw new Error('密码格式无效');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// 项目配置类型
|
||||
type ProjectConfig = {
|
||||
dbHost: string;
|
||||
dbPort: number;
|
||||
dbUser: string;
|
||||
dbPassword: string;
|
||||
dbName: string;
|
||||
dbSchema: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建项目数据库连接 URL(使用加密密码,用于已保存的项目)
|
||||
*/
|
||||
function buildProjectDbUrl(project: ProjectConfig): string {
|
||||
const password = decryptPassword(project.dbPassword);
|
||||
return `postgresql://${project.dbUser}:${encodeURIComponent(password)}@${project.dbHost}:${project.dbPort}/${project.dbName}?schema=${project.dbSchema}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的客户端连接
|
||||
*/
|
||||
function cleanupExpiredClients(): void {
|
||||
const now = Date.now();
|
||||
|
||||
projectClients.forEach((value, key) => {
|
||||
if (now - value.lastUsed > CLIENT_EXPIRY_MS) {
|
||||
value.client.$disconnect().catch(console.error);
|
||||
projectClients.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目库客户端
|
||||
*/
|
||||
export async function getProjectDb(projectCode: string): Promise<ProjectPrismaClient> {
|
||||
cleanupExpiredClients();
|
||||
|
||||
// 检查缓存
|
||||
const cached = projectClients.get(projectCode);
|
||||
if (cached) {
|
||||
cached.lastUsed = Date.now();
|
||||
return cached.client;
|
||||
}
|
||||
|
||||
// 从主库获取项目配置
|
||||
const project = await mainPrisma.project.findUnique({
|
||||
where: { code: projectCode },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error(`项目不存在: ${projectCode}`);
|
||||
}
|
||||
|
||||
if (!project.isActive) {
|
||||
throw new Error(`项目已禁用: ${projectCode}`);
|
||||
}
|
||||
|
||||
// 创建新的项目库客户端
|
||||
const dbUrl = buildProjectDbUrl(project);
|
||||
const client = new ProjectPrismaClient({
|
||||
datasourceUrl: dbUrl,
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
// 缓存管理
|
||||
if (projectClients.size >= MAX_CACHED_CLIENTS) {
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
projectClients.forEach((value, key) => {
|
||||
if (value.lastUsed < oldestTime) {
|
||||
oldestTime = value.lastUsed;
|
||||
oldestKey = key;
|
||||
}
|
||||
});
|
||||
|
||||
if (oldestKey) {
|
||||
const oldClient = projectClients.get(oldestKey);
|
||||
if (oldClient) {
|
||||
await oldClient.client.$disconnect();
|
||||
}
|
||||
projectClients.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
projectClients.set(projectCode, { client, lastUsed: Date.now() });
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试项目数据库连接(使用明文密码,用于创建项目前的测试)
|
||||
*/
|
||||
export async function testProjectConnection(config: {
|
||||
dbHost: string;
|
||||
dbPort: number;
|
||||
dbUser: string;
|
||||
dbPassword: string;
|
||||
dbName: string;
|
||||
dbSchema: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
let client: ProjectPrismaClient | null = null;
|
||||
|
||||
try {
|
||||
// 直接使用明文密码构建连接 URL(仅用于测试)
|
||||
const dbUrl = `postgresql://${config.dbUser}:${encodeURIComponent(config.dbPassword)}@${config.dbHost}:${config.dbPort}/${config.dbName}?schema=${config.dbSchema}`;
|
||||
client = new ProjectPrismaClient({
|
||||
datasourceUrl: dbUrl,
|
||||
});
|
||||
|
||||
await client.$connect();
|
||||
await client.$queryRaw`SELECT 1`;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '连接失败',
|
||||
};
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.$disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开所有连接
|
||||
*/
|
||||
export async function disconnectAll(): Promise<void> {
|
||||
await mainPrisma.$disconnect();
|
||||
|
||||
for (const value of Array.from(projectClients.values())) {
|
||||
await value.client.$disconnect();
|
||||
}
|
||||
|
||||
projectClients.clear();
|
||||
}
|
||||
|
||||
256
license-server/lib/project-auth.ts
Normal file
256
license-server/lib/project-auth.ts
Normal file
@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 项目权限验证工具
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { mainPrisma, getProjectDb } from './db';
|
||||
import { getAdminSession } from './auth';
|
||||
|
||||
export interface ProjectAuthResult {
|
||||
success: true;
|
||||
adminId: string;
|
||||
projectCode: string;
|
||||
projectId: string;
|
||||
isSuperAdmin: boolean;
|
||||
projectRole: 'admin' | 'viewer';
|
||||
}
|
||||
|
||||
export interface ProjectAuthError {
|
||||
success: false;
|
||||
response: NextResponse;
|
||||
}
|
||||
|
||||
export type AuthorizedProjectDbResult =
|
||||
| {
|
||||
success: true;
|
||||
auth: ProjectAuthResult;
|
||||
prisma: Awaited<ReturnType<typeof getProjectDb>>;
|
||||
}
|
||||
| ProjectAuthError;
|
||||
|
||||
export type PublicProjectDbResult =
|
||||
| {
|
||||
success: true;
|
||||
prisma: Awaited<ReturnType<typeof getProjectDb>>;
|
||||
}
|
||||
| ProjectAuthError;
|
||||
|
||||
function normalizeProjectRole(role: string | null | undefined): 'admin' | 'viewer' {
|
||||
return role === 'viewer' ? 'viewer' : 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证管理员对项目的访问权限
|
||||
*/
|
||||
export async function verifyProjectAccess(
|
||||
projectCode: string
|
||||
): Promise<ProjectAuthResult | ProjectAuthError> {
|
||||
// 验证登录状态
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 获取项目信息
|
||||
const project = await mainPrisma.project.findUnique({
|
||||
where: { code: projectCode },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: '项目不存在' },
|
||||
{ status: 404 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!project.isActive) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: '项目已禁用' },
|
||||
{ status: 403 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 获取管理员信息
|
||||
const admin = await mainPrisma.admin.findUnique({
|
||||
where: { id: session.id },
|
||||
include: { adminProjects: true },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: '管理员不存在' },
|
||||
{ status: 404 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
let projectRole: 'admin' | 'viewer' = 'admin';
|
||||
|
||||
if (!admin.isSuperAdmin) {
|
||||
const projectAccess = admin.adminProjects.find(
|
||||
(ap: { projectId: string; role?: string | null }) => ap.projectId === project.id
|
||||
);
|
||||
if (!projectAccess) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: '无权访问此项目' },
|
||||
{ status: 403 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
projectRole = normalizeProjectRole(projectAccess.role);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
adminId: session.id,
|
||||
projectCode,
|
||||
projectId: project.id,
|
||||
isSuperAdmin: admin.isSuperAdmin,
|
||||
projectRole,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证项目写权限。viewer 只能查看,不能创建、修改、禁用或删除项目数据。
|
||||
*/
|
||||
export async function verifyProjectWriteAccess(
|
||||
projectCode: string
|
||||
): Promise<ProjectAuthResult | ProjectAuthError> {
|
||||
const authResult = await verifyProjectAccess(projectCode);
|
||||
|
||||
if (!authResult.success) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
if (!authResult.isSuperAdmin && authResult.projectRole !== 'admin') {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: '需要项目管理员权限' },
|
||||
{ status: 403 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return authResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证超管权限
|
||||
*/
|
||||
export async function verifySuperAdmin(): Promise<{ success: true; adminId: string } | ProjectAuthError> {
|
||||
const session = await getAdminSession();
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: '未登录' },
|
||||
{ status: 401 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const admin = await mainPrisma.admin.findUnique({
|
||||
where: { id: session.id },
|
||||
});
|
||||
|
||||
if (!admin || !admin.isSuperAdmin) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
{ error: '需要超级管理员权限' },
|
||||
{ status: 403 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
adminId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目数据库连接(带权限验证)
|
||||
*/
|
||||
export async function getAuthorizedProjectDb(
|
||||
projectCode: string
|
||||
): Promise<AuthorizedProjectDbResult> {
|
||||
const authResult = await verifyProjectAccess(projectCode);
|
||||
|
||||
if (!authResult.success) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
auth: authResult,
|
||||
prisma: await getProjectDb(projectCode),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWritableProjectDb(
|
||||
projectCode: string
|
||||
): Promise<AuthorizedProjectDbResult> {
|
||||
const authResult = await verifyProjectWriteAccess(projectCode);
|
||||
|
||||
if (!authResult.success) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
auth: authResult,
|
||||
prisma: await getProjectDb(projectCode),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublicProjectDb(
|
||||
projectCode: string
|
||||
): Promise<PublicProjectDbResult> {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
prisma: await getProjectDb(projectCode),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '服务器错误';
|
||||
|
||||
if (message.startsWith('项目不存在')) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json({ error: '项目不存在' }, { status: 404 }),
|
||||
};
|
||||
}
|
||||
|
||||
if (message.startsWith('项目已禁用')) {
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json({ error: '项目已禁用' }, { status: 403 }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json({ error: '服务器错误' }, { status: 500 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,11 +5,16 @@
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"prebuild": "npm run db:generate",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:generate": "npm run db:generate:main && npm run db:generate:project",
|
||||
"db:generate:main": "prisma generate --schema=prisma/schema-main.prisma",
|
||||
"db:generate:project": "prisma generate --schema=prisma/schema.prisma",
|
||||
"db:push": "npm run db:push:main && npm run db:push:project",
|
||||
"db:push:main": "prisma db push --schema=prisma/schema-main.prisma",
|
||||
"db:push:project": "prisma db push --schema=prisma/schema.prisma",
|
||||
"db:migrate": "prisma migrate dev --schema=prisma/schema.prisma",
|
||||
"db:studio": "prisma studio",
|
||||
"generate-keys": "ts-node scripts/generate-keys.ts"
|
||||
},
|
||||
|
||||
68
license-server/prisma/schema-main.prisma
Normal file
68
license-server/prisma/schema-main.prisma
Normal file
@ -0,0 +1,68 @@
|
||||
// 主数据库 Schema - 用于管理项目和用户
|
||||
// 数据库名建议:license-server-main
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl", "debian-openssl-3.0.x"]
|
||||
output = "../node_modules/.prisma/main-client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("MAIN_DATABASE_URL")
|
||||
}
|
||||
|
||||
// 项目表
|
||||
model Project {
|
||||
id String @id @default(uuid())
|
||||
code String @unique @map("code") // 项目唯一标识,如 flnxi-sucai
|
||||
name String @map("name") // 显示名称
|
||||
description String? @map("description")
|
||||
|
||||
// 数据库连接配置
|
||||
dbHost String @map("db_host") // 数据库主机
|
||||
dbPort Int @default(5432) @map("db_port") // 数据库端口
|
||||
dbName String @map("db_name") // 数据库名
|
||||
dbUser String @map("db_user") // 数据库用户
|
||||
dbPassword String @map("db_password") // 数据库密码(AES加密存储)
|
||||
dbSchema String @default("public") @map("db_schema")
|
||||
|
||||
// 项目状态
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联
|
||||
adminProjects AdminProject[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
// 管理员表
|
||||
model Admin {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
isSuperAdmin Boolean @default(false) @map("is_super_admin") // 超管可访问所有项目
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联
|
||||
adminProjects AdminProject[]
|
||||
|
||||
@@map("admins")
|
||||
}
|
||||
|
||||
// 管理员-项目权限关联表
|
||||
model AdminProject {
|
||||
adminId String @map("admin_id")
|
||||
projectId String @map("project_id")
|
||||
role String @default("admin") // admin / viewer
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([adminId, projectId])
|
||||
@@map("admin_projects")
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl", "debian-openssl-3.0.x"]
|
||||
output = "../node_modules/.prisma/project-client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@ -42,6 +43,8 @@ model License {
|
||||
customerId String? @map("customer_id")
|
||||
licenseKey String @unique @map("license_key")
|
||||
licenseType LicenseType @map("license_type")
|
||||
customerName String? @map("customer_name") // 客户名称(免费版注册时填写)
|
||||
contactInfo String? @map("contact_info") // 联系方式(手机/微信/邮箱)
|
||||
status LicenseStatus @default(active)
|
||||
maxUsers Int @default(-1) @map("max_users")
|
||||
maxMaterials Int @default(-1) @map("max_materials")
|
||||
@ -54,6 +57,7 @@ model License {
|
||||
activations Activation[]
|
||||
heartbeatLogs HeartbeatLog[]
|
||||
alerts Alert[]
|
||||
order Order?
|
||||
|
||||
@@map("licenses")
|
||||
}
|
||||
@ -121,6 +125,34 @@ model Admin {
|
||||
@@map("admins")
|
||||
}
|
||||
|
||||
// 订单记录表(官网购买记录)
|
||||
model Order {
|
||||
id String @id @default(uuid())
|
||||
orderNo String @unique @map("order_no")
|
||||
plan LicenseType
|
||||
customerEmail String @map("customer_email")
|
||||
customerName String? @map("customer_name")
|
||||
licenseId String? @unique @map("license_id")
|
||||
validDays Int @default(365) @map("valid_days")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
license License? @relation(fields: [licenseId], references: [id])
|
||||
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
// API 密钥表(官网调用认证)
|
||||
model ApiKey {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
keyHash String @unique @map("key_hash")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastUsedAt DateTime? @map("last_used_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("api_keys")
|
||||
}
|
||||
|
||||
// 版本信息表(素材系统)
|
||||
model Version {
|
||||
id String @id @default(uuid())
|
||||
|
||||
@ -21,6 +21,17 @@ const aesKey = crypto.randomBytes(32).toString('hex');
|
||||
// 生成 JWT 密钥
|
||||
const jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// 主库加密密钥
|
||||
const mainDbEncryptionKey = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Docker / 本地默认数据库参数
|
||||
const postgresUser = 'license';
|
||||
const postgresPassword = crypto.randomBytes(18).toString('base64url');
|
||||
const postgresDb = 'license_db';
|
||||
const mainDatabaseUrl = `postgresql://${postgresUser}:${postgresPassword}@localhost:5432/${postgresDb}?schema=main`;
|
||||
const projectDatabaseUrl = `postgresql://${postgresUser}:${postgresPassword}@localhost:5432/${postgresDb}?schema=public`;
|
||||
const adminInitialPassword = crypto.randomBytes(18).toString('base64url');
|
||||
|
||||
// 创建 keys 目录
|
||||
const keysDir = path.join(__dirname, '..', 'keys');
|
||||
if (!fs.existsSync(keysDir)) {
|
||||
@ -33,7 +44,13 @@ fs.writeFileSync(path.join(keysDir, 'public.key'), publicKey);
|
||||
|
||||
// 生成 .env 内容
|
||||
const envContent = `# 数据库连接
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/license_server?schema=public"
|
||||
POSTGRES_USER="${postgresUser}"
|
||||
POSTGRES_PASSWORD="${postgresPassword}"
|
||||
POSTGRES_DB="${postgresDb}"
|
||||
|
||||
MAIN_DATABASE_URL="${mainDatabaseUrl}"
|
||||
MAIN_DB_ENCRYPTION_KEY="${mainDbEncryptionKey}"
|
||||
DATABASE_URL="${projectDatabaseUrl}"
|
||||
|
||||
# RSA 私钥(用于签名许可证)
|
||||
RSA_PRIVATE_KEY="${privateKey.replace(/\n/g, '\\n')}"
|
||||
@ -48,7 +65,7 @@ AES_SECRET_KEY="${aesKey}"
|
||||
JWT_SECRET="${jwtSecret}"
|
||||
|
||||
# 管理员初始密码
|
||||
ADMIN_INITIAL_PASSWORD="admin123"
|
||||
ADMIN_INITIAL_PASSWORD="${adminInitialPassword}"
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent);
|
||||
@ -65,3 +82,5 @@ console.log('');
|
||||
console.log('📋 客户端公钥(复制到素材系统):');
|
||||
console.log('─'.repeat(50));
|
||||
console.log(publicKey);
|
||||
console.log('');
|
||||
console.log('📋 许可证服务初始管理员密码已写入 license-server/.env 的 ADMIN_INITIAL_PASSWORD,不在终端日志中输出明文。');
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient as MainPrismaClient } from '.prisma/main-client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = new MainPrismaClient();
|
||||
|
||||
async function main() {
|
||||
const username = process.argv[2] || 'admin';
|
||||
const password = process.argv[3] || 'admin123';
|
||||
const password = process.argv[3] || process.env.ADMIN_INITIAL_PASSWORD;
|
||||
|
||||
if (!password) {
|
||||
throw new Error('请通过命令参数或 ADMIN_INITIAL_PASSWORD 提供管理员初始密码');
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await prisma.admin.findUnique({
|
||||
@ -23,13 +27,14 @@ async function main() {
|
||||
data: {
|
||||
username,
|
||||
passwordHash,
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`管理员创建成功!`);
|
||||
console.log(`用户名: ${username}`);
|
||||
console.log(`密码: ${password}`);
|
||||
console.log(`\n请登录后立即修改密码!`);
|
||||
console.log(`密码: 已按输入值写入哈希,不在日志中输出明文`);
|
||||
console.log(`\n请使用提供的初始密码登录后立即修改密码!`);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"target": "ES2020",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
@ -18,7 +19,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
".prisma/main-client": ["./node_modules/.prisma/main-client"],
|
||||
".prisma/project-client": ["./node_modules/.prisma/project-client"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user