完善许可证服务多项目交付地基

This commit is contained in:
WuYongJie 2026-05-25 14:31:58 +08:00
parent 1d03717ff9
commit d9955b6835
72 changed files with 5596 additions and 2200 deletions

View 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

View File

@ -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"

View File

@ -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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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');
}

View File

@ -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');
}

View File

@ -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');
}

View File

@ -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 || '登录失败');
}

View File

@ -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');
}

View 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>
);
}

View 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>
);
}

View File

@ -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');
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View 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 });
}
}

View 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 }
);
}
}

View File

@ -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 });
}
}

View 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 }
);
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@ -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 }
);
}

View File

@ -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 }
);
}

View File

@ -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 }
);
}

View 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();
}

View File

@ -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();
}

View File

@ -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 }
);
}

View 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 }
);
}

View File

@ -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 }
);
}

View File

@ -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 }
);
}

View File

@ -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();
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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();
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 });
}
}

View File

@ -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 }
);
}
}

View 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 }
);
}
}

View File

@ -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 }
);
}

View 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 }
);
}

View File

@ -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 }
);
}

View 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 }
);
}

View File

@ -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 }
);
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@ -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 }
);
}

View File

@ -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 }
);
}

View File

@ -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

View File

@ -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

View 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' },
});
}

View File

@ -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,

View File

@ -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,

View 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';

View File

@ -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();
}

View 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 }),
};
}
}

View File

@ -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"
},

View 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")
}

View File

@ -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())

View File

@ -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,不在终端日志中输出明文。');

View File

@ -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()

View File

@ -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"],