forked from imbytecat/fullstack-starter
feat: 完善许可证激活功能及数据库初始化
- 将 db 中间件重命名为 dbProvider,同时保留 db 作为别名以避免破坏现有路由。 - 记录并解决开发环境中数据库迁移和类型检查工具使用问题。 - 完善许可证激活功能,新增数据表与初始化逻辑,实现UPSERT操作及单例模式,添加RPC接口支持,修复时间戳类型不匹配问题。 - 创建设备信息和待办事项数据表,并为设备指纹添加唯一索引。 - 添加许可证激活表及指纹唯一索引 - 添加设备信息和待办事项表的数据库模式快照,包含字段定义、主键约束及指纹唯一索引。 - 添加初始数据库模式快照,包含设备信息、许可证激活和待办事项三张表的结构定义。 - 添加新的迁移日志文件以记录数据库版本变更和迁移步骤 - 确保设备信息在数据库中正确初始化,使用硬件指纹唯一标识设备并支持并发安全的单例模式初始化。 - 初始化许可证激活记录,确保基于硬件指纹创建或更新激活信息。 - 添加许可证激活管理页面,包含设备指纹展示、许可证输入激活功能及激活状态实时反馈。 - 添加许可证页面路由配置并更新相关路由类型和路径映射。 - 添加设备信息获取与许可证设置的接口契约定义,包含数据校验 schema 和接口输入输出规范。 - 添加设备和许可证合约接口到导出契约对象中 - 添加许可证相关API契约,定义获取激活状态和激活许可证的输入输出结构。 - 初始化设备和许可激活状态,并将数据库中间件重命名为dbProvider,同时导出原始db名称以保持兼容性。 - 添加设备信息获取和许可证设置接口,确保设备初始化并安全处理数据读写。 - 添加设备和许可证路由到API路由器中。 - 添加许可证激活状态查询和激活功能,支持通过数据库记录管理许可证信息并返回激活时间戳。 - 添加设备信息表结构,包含指纹、指纹质量等级、许可证及激活时间字段。 - 添加设备信息和许可证激活相关数据模型导出 - 添加许可证激活记录表,包含指纹、许可证信息及激活时间字段。
This commit is contained in:
3
.sisyphus/notepads/license-activation/decisions.md
Normal file
3
.sisyphus/notepads/license-activation/decisions.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Renamed `db` middleware to `dbProvider` as requested, while keeping `db` as an alias to avoid breaking existing routers.
|
||||||
4
.sisyphus/notepads/license-activation/issues.md
Normal file
4
.sisyphus/notepads/license-activation/issues.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
## Issues encountered
|
||||||
|
|
||||||
|
- `db:migrate` failed due to `device_info` table already existing in the local SQLite database. Resolved by using `db:push` which is suitable for dev environments.
|
||||||
|
- `lsp_diagnostics` was not available, used `bun typecheck` (`tsc --noEmit`) instead.
|
||||||
7
.sisyphus/notepads/license-activation/learnings.md
Normal file
7
.sisyphus/notepads/license-activation/learnings.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## License Activation Implementation
|
||||||
|
|
||||||
|
- Created `license_activation` table for better semantic clarity.
|
||||||
|
- Implemented `ensureLicenseActivationInitialized` with UPSERT logic and singleton pattern.
|
||||||
|
- Added ORPC endpoints `license.getActivation` and `license.activate`.
|
||||||
|
- Used `db:push` to handle migration in dev environment when conflicts occur.
|
||||||
|
- Fixed type mismatch in `device.router.ts` where `Date` was returned instead of `number` for timestamp fields.
|
||||||
18
apps/server/drizzle/0000_messy_goliath.sql
Normal file
18
apps/server/drizzle/0000_messy_goliath.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE `device_info` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`fingerprint` text NOT NULL,
|
||||||
|
`fingerprint_quality` text NOT NULL,
|
||||||
|
`license` text,
|
||||||
|
`license_activated_at` integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `device_info_fingerprint_unique` ON `device_info` (`fingerprint`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `todo` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`completed` integer DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
10
apps/server/drizzle/0001_watery_mongu.sql
Normal file
10
apps/server/drizzle/0001_watery_mongu.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE `license_activation` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`fingerprint` text NOT NULL,
|
||||||
|
`license` text,
|
||||||
|
`license_activated_at` integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `license_activation_fingerprint_unique` ON `license_activation` (`fingerprint`);
|
||||||
129
apps/server/drizzle/meta/0000_snapshot.json
Normal file
129
apps/server/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "09c29147-479a-4490-8223-db64c71c910f",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"device_info": {
|
||||||
|
"name": "device_info",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"name": "fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint_quality": {
|
||||||
|
"name": "fingerprint_quality",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"name": "license",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license_activated_at": {
|
||||||
|
"name": "license_activated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"device_info_fingerprint_unique": {
|
||||||
|
"name": "device_info_fingerprint_unique",
|
||||||
|
"columns": ["fingerprint"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"name": "todo",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"name": "completed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
apps/server/drizzle/meta/0001_snapshot.json
Normal file
187
apps/server/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "14bad572-1fc5-489d-90f2-7560a7cad1f4",
|
||||||
|
"prevId": "09c29147-479a-4490-8223-db64c71c910f",
|
||||||
|
"tables": {
|
||||||
|
"device_info": {
|
||||||
|
"name": "device_info",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"name": "fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint_quality": {
|
||||||
|
"name": "fingerprint_quality",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"name": "license",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license_activated_at": {
|
||||||
|
"name": "license_activated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"device_info_fingerprint_unique": {
|
||||||
|
"name": "device_info_fingerprint_unique",
|
||||||
|
"columns": ["fingerprint"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"license_activation": {
|
||||||
|
"name": "license_activation",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fingerprint": {
|
||||||
|
"name": "fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"name": "license",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"license_activated_at": {
|
||||||
|
"name": "license_activated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"license_activation_fingerprint_unique": {
|
||||||
|
"name": "license_activation_fingerprint_unique",
|
||||||
|
"columns": ["fingerprint"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"name": "todo",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"name": "completed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/server/drizzle/meta/_journal.json
Normal file
20
apps/server/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769409553227,
|
||||||
|
"tag": "0000_messy_goliath",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769409970060,
|
||||||
|
"tag": "0001_watery_mongu",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
61
apps/server/src/lib/device-init.ts
Normal file
61
apps/server/src/lib/device-init.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { getHardwareFingerprint } from '@/lib/fingerprint'
|
||||||
|
import { getDB } from '@/server/db'
|
||||||
|
import { deviceInfoTable } from '@/server/db/schema'
|
||||||
|
|
||||||
|
let initPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保设备信息已在数据库中初始化
|
||||||
|
* 使用单例模式防止并发重复初始化
|
||||||
|
*/
|
||||||
|
export async function ensureDeviceInitialized(): Promise<void> {
|
||||||
|
if (initPromise) {
|
||||||
|
return initPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
initPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const db = getDB()
|
||||||
|
|
||||||
|
// 获取硬件指纹
|
||||||
|
let result: { fingerprint: string; quality: 'strong' | 'medium' | 'weak' }
|
||||||
|
try {
|
||||||
|
result = await getHardwareFingerprint({
|
||||||
|
cacheTtlMs: 10 * 60 * 1000,
|
||||||
|
includePrimaryDisk: true,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get hardware fingerprint:', error)
|
||||||
|
// 回退逻辑
|
||||||
|
result = {
|
||||||
|
fingerprint: `unknown-${Date.now()}`,
|
||||||
|
quality: 'weak' as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 UPSERT 逻辑更新或插入设备信息
|
||||||
|
await db
|
||||||
|
.insert(deviceInfoTable)
|
||||||
|
.values({
|
||||||
|
fingerprint: result.fingerprint,
|
||||||
|
fingerprintQuality: result.quality,
|
||||||
|
license: null,
|
||||||
|
licenseActivatedAt: null,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: deviceInfoTable.fingerprint,
|
||||||
|
set: {
|
||||||
|
fingerprintQuality: result.quality,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize device info:', error)
|
||||||
|
// 重置 promise 以允许重试
|
||||||
|
initPromise = null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return initPromise
|
||||||
|
}
|
||||||
40
apps/server/src/lib/license-init.ts
Normal file
40
apps/server/src/lib/license-init.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getDB } from '@/server/db'
|
||||||
|
import { licenseActivationTable } from '@/server/db/schema'
|
||||||
|
import { getHardwareFingerprint } from './fingerprint'
|
||||||
|
|
||||||
|
let initPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
export async function ensureLicenseActivationInitialized(): Promise<void> {
|
||||||
|
if (initPromise) return initPromise
|
||||||
|
|
||||||
|
initPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const db = getDB()
|
||||||
|
const result = await getHardwareFingerprint({
|
||||||
|
cacheTtlMs: 10 * 60 * 1000,
|
||||||
|
includePrimaryDisk: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(licenseActivationTable)
|
||||||
|
.values({
|
||||||
|
fingerprint: result.fingerprint,
|
||||||
|
license: null,
|
||||||
|
licenseActivatedAt: null,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: licenseActivationTable.fingerprint,
|
||||||
|
set: {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize license activation:', error)
|
||||||
|
// 重置 promise 以便下次重试
|
||||||
|
initPromise = null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return initPromise
|
||||||
|
}
|
||||||
@@ -9,11 +9,17 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as LicenseRouteImport } from './routes/license'
|
||||||
import { Route as FingerprintRouteImport } from './routes/fingerprint'
|
import { Route as FingerprintRouteImport } from './routes/fingerprint'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ApiSplatRouteImport } from './routes/api/$'
|
import { Route as ApiSplatRouteImport } from './routes/api/$'
|
||||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
||||||
|
|
||||||
|
const LicenseRoute = LicenseRouteImport.update({
|
||||||
|
id: '/license',
|
||||||
|
path: '/license',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const FingerprintRoute = FingerprintRouteImport.update({
|
const FingerprintRoute = FingerprintRouteImport.update({
|
||||||
id: '/fingerprint',
|
id: '/fingerprint',
|
||||||
path: '/fingerprint',
|
path: '/fingerprint',
|
||||||
@@ -38,12 +44,14 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/fingerprint': typeof FingerprintRoute
|
'/fingerprint': typeof FingerprintRoute
|
||||||
|
'/license': typeof LicenseRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/fingerprint': typeof FingerprintRoute
|
'/fingerprint': typeof FingerprintRoute
|
||||||
|
'/license': typeof LicenseRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
@@ -51,26 +59,35 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/fingerprint': typeof FingerprintRoute
|
'/fingerprint': typeof FingerprintRoute
|
||||||
|
'/license': typeof LicenseRoute
|
||||||
'/api/$': typeof ApiSplatRoute
|
'/api/$': typeof ApiSplatRoute
|
||||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/fingerprint' | '/api/$' | '/api/rpc/$'
|
fullPaths: '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/fingerprint' | '/api/$' | '/api/rpc/$'
|
to: '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$'
|
||||||
id: '__root__' | '/' | '/fingerprint' | '/api/$' | '/api/rpc/$'
|
id: '__root__' | '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
FingerprintRoute: typeof FingerprintRoute
|
FingerprintRoute: typeof FingerprintRoute
|
||||||
|
LicenseRoute: typeof LicenseRoute
|
||||||
ApiSplatRoute: typeof ApiSplatRoute
|
ApiSplatRoute: typeof ApiSplatRoute
|
||||||
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/license': {
|
||||||
|
id: '/license'
|
||||||
|
path: '/license'
|
||||||
|
fullPath: '/license'
|
||||||
|
preLoaderRoute: typeof LicenseRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/fingerprint': {
|
'/fingerprint': {
|
||||||
id: '/fingerprint'
|
id: '/fingerprint'
|
||||||
path: '/fingerprint'
|
path: '/fingerprint'
|
||||||
@@ -105,6 +122,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
FingerprintRoute: FingerprintRoute,
|
FingerprintRoute: FingerprintRoute,
|
||||||
|
LicenseRoute: LicenseRoute,
|
||||||
ApiSplatRoute: ApiSplatRoute,
|
ApiSplatRoute: ApiSplatRoute,
|
||||||
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
391
apps/server/src/routes/license.tsx
Normal file
391
apps/server/src/routes/license.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
useSuspenseQuery,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { orpc } from '@/client/query-client'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/license')({
|
||||||
|
component: License,
|
||||||
|
})
|
||||||
|
|
||||||
|
function License() {
|
||||||
|
const [licenseInput, setLicenseInput] = useState('')
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// 获取激活状态
|
||||||
|
const { data } = useSuspenseQuery(orpc.license.getActivation.queryOptions())
|
||||||
|
|
||||||
|
// 激活 mutation
|
||||||
|
const activateMutation = useMutation({
|
||||||
|
...orpc.license.activate.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
// 刷新数据
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: orpc.license.getActivation.key(),
|
||||||
|
})
|
||||||
|
// 清空输入
|
||||||
|
setLicenseInput('')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleActivate = () => {
|
||||||
|
if (!licenseInput.trim()) return
|
||||||
|
activateMutation.mutate({ license: licenseInput.trim() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyFingerprint = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(data.fingerprint)
|
||||||
|
setCopySuccess(true)
|
||||||
|
setTimeout(() => setCopySuccess(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy fingerprint:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActivated = !!data.license
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4 font-sans">
|
||||||
|
<div className="w-full max-w-2xl space-y-6">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
||||||
|
License 激活管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-2">管理您的设备授权与激活状态</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备信息卡片 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-blue-500"
|
||||||
|
>
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
|
||||||
|
<rect x="9" y="9" width="6" height="6"></rect>
|
||||||
|
<line x1="9" y1="1" x2="9" y2="4"></line>
|
||||||
|
<line x1="15" y1="1" x2="15" y2="4"></line>
|
||||||
|
<line x1="9" y1="20" x2="9" y2="23"></line>
|
||||||
|
<line x1="15" y1="20" x2="15" y2="23"></line>
|
||||||
|
<line x1="20" y1="9" x2="23" y2="9"></line>
|
||||||
|
<line x1="20" y1="14" x2="23" y2="14"></line>
|
||||||
|
<line x1="1" y1="9" x2="4" y2="9"></line>
|
||||||
|
<line x1="1" y1="14" x2="4" y2="14"></line>
|
||||||
|
</svg>
|
||||||
|
设备信息
|
||||||
|
</h2>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">
|
||||||
|
设备指纹 (Device Fingerprint)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<code className="flex-1 font-mono text-sm text-gray-700 break-all select-all">
|
||||||
|
{data.fingerprint}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyFingerprint}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${
|
||||||
|
copySuccess
|
||||||
|
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||||
|
: 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copySuccess ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
已复制
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="9"
|
||||||
|
y="9"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
复制
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* License 激活卡片 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-purple-500"
|
||||||
|
>
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||||
|
</svg>
|
||||||
|
License 激活
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="license-key"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
License Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="license-key"
|
||||||
|
type="text"
|
||||||
|
value={licenseInput}
|
||||||
|
onChange={(e) => setLicenseInput(e.target.value)}
|
||||||
|
disabled={isActivated || activateMutation.isPending}
|
||||||
|
placeholder={
|
||||||
|
isActivated ? '已激活,无需输入' : '请输入您的 License Key'
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 outline-none transition-all disabled:bg-gray-100 disabled:text-gray-500 placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activateMutation.isError && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm flex items-start gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="mt-0.5 shrink-0"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||||
|
</svg>
|
||||||
|
<span>激活失败: 请检查 License 是否正确或稍后重试</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleActivate}
|
||||||
|
disabled={
|
||||||
|
isActivated ||
|
||||||
|
activateMutation.isPending ||
|
||||||
|
!licenseInput.trim()
|
||||||
|
}
|
||||||
|
className={`w-full px-6 py-2.5 rounded-lg font-medium text-white shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||||
|
isActivated
|
||||||
|
? 'bg-gray-300 cursor-not-allowed'
|
||||||
|
: activateMutation.isPending
|
||||||
|
? 'bg-purple-500 opacity-80 cursor-wait'
|
||||||
|
: 'bg-purple-600 hover:bg-purple-700 hover:shadow-md active:scale-[0.99] focus:ring-purple-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{activateMutation.isPending ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className="animate-spin h-4 w-4 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
正在激活...
|
||||||
|
</span>
|
||||||
|
) : isActivated ? (
|
||||||
|
'已完成激活'
|
||||||
|
) : (
|
||||||
|
'立即激活'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 激活状态卡片 */}
|
||||||
|
<div
|
||||||
|
className={`rounded-xl shadow-sm border overflow-hidden transition-colors ${
|
||||||
|
isActivated
|
||||||
|
? 'bg-green-50/50 border-green-100'
|
||||||
|
: 'bg-white border-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={isActivated ? 'text-green-500' : 'text-gray-400'}
|
||||||
|
>
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||||
|
</svg>
|
||||||
|
激活状态
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isActivated ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-green-700 font-medium text-lg">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-green-500"
|
||||||
|
>
|
||||||
|
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path>
|
||||||
|
<path d="M9 12l2 2 4-4"></path>
|
||||||
|
</svg>
|
||||||
|
<span>License 已激活</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-8 space-y-1">
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
当前 License:{' '}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">{data.license}</span>
|
||||||
|
</p>
|
||||||
|
{data.licenseActivatedAt && (
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
激活时间:{' '}
|
||||||
|
</span>
|
||||||
|
{new Date(data.licenseActivatedAt).toLocaleString(
|
||||||
|
'zh-CN',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-orange-500 shrink-0 mt-0.5"
|
||||||
|
>
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="text-orange-700 font-medium text-lg mb-1">
|
||||||
|
未激活
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
您的设备尚未激活。请在上方输入 License Key
|
||||||
|
进行激活以解锁全部功能。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
apps/server/src/server/api/contracts/device.contract.ts
Normal file
15
apps/server/src/server/api/contracts/device.contract.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const deviceInfoSchema = z.object({
|
||||||
|
fingerprint: z.string(),
|
||||||
|
quality: z.enum(['strong', 'medium', 'weak']),
|
||||||
|
license: z.string().nullable(),
|
||||||
|
licenseActivatedAt: z.number().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getInfo = oc.input(z.void()).output(deviceInfoSchema)
|
||||||
|
|
||||||
|
export const setLicense = oc
|
||||||
|
.input(z.object({ license: z.string().min(1) }))
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import * as device from './device.contract'
|
||||||
import * as fingerprint from './fingerprint.contract'
|
import * as fingerprint from './fingerprint.contract'
|
||||||
|
import * as license from './license.contract'
|
||||||
import * as todo from './todo.contract'
|
import * as todo from './todo.contract'
|
||||||
|
|
||||||
export const contract = {
|
export const contract = {
|
||||||
|
device,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
todo,
|
todo,
|
||||||
|
license,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Contract = typeof contract
|
export type Contract = typeof contract
|
||||||
|
|||||||
14
apps/server/src/server/api/contracts/license.contract.ts
Normal file
14
apps/server/src/server/api/contracts/license.contract.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const getActivation = oc.input(z.void()).output(
|
||||||
|
z.object({
|
||||||
|
fingerprint: z.string(),
|
||||||
|
license: z.string().nullable(),
|
||||||
|
licenseActivatedAt: z.number().nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const activate = oc
|
||||||
|
.input(z.object({ license: z.string().min(1) }))
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { os } from '@orpc/server'
|
import { os } from '@orpc/server'
|
||||||
|
import { ensureDeviceInitialized } from '@/lib/device-init'
|
||||||
|
import { ensureLicenseActivationInitialized } from '@/lib/license-init'
|
||||||
import { getDB } from '@/server/db'
|
import { getDB } from '@/server/db'
|
||||||
|
|
||||||
export const db = os.middleware(async ({ context, next }) => {
|
export const dbProvider = os.middleware(async ({ context, next }) => {
|
||||||
|
await ensureDeviceInitialized()
|
||||||
|
await ensureLicenseActivationInitialized()
|
||||||
return next({
|
return next({
|
||||||
context: {
|
context: {
|
||||||
...context,
|
...context,
|
||||||
@@ -9,3 +13,5 @@ export const db = os.middleware(async ({ context, next }) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const db = dbProvider
|
||||||
|
|||||||
54
apps/server/src/server/api/routers/device.router.ts
Normal file
54
apps/server/src/server/api/routers/device.router.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { ORPCError } from '@orpc/server'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { ensureDeviceInitialized } from '@/lib/device-init'
|
||||||
|
import { deviceInfoTable } from '@/server/db/schema'
|
||||||
|
import { db } from '../middlewares'
|
||||||
|
import { os } from '../server'
|
||||||
|
|
||||||
|
export const getInfo = os.device.getInfo
|
||||||
|
.use(db)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
// 再次确保初始化(竞态条件兜底)
|
||||||
|
await ensureDeviceInitialized()
|
||||||
|
|
||||||
|
const info = await context.db.query.deviceInfoTable.findFirst()
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
// 理论上不应该发生,因为 ensureDeviceInitialized 已经调用
|
||||||
|
throw new ORPCError('NOT_FOUND', {
|
||||||
|
message: 'Device info not found after initialization',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fingerprint: info.fingerprint,
|
||||||
|
quality: info.fingerprintQuality,
|
||||||
|
license: info.license,
|
||||||
|
licenseActivatedAt: info.licenseActivatedAt?.getTime() ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setLicense = os.device.setLicense
|
||||||
|
.use(db)
|
||||||
|
.handler(async ({ context, input }) => {
|
||||||
|
await ensureDeviceInitialized()
|
||||||
|
|
||||||
|
const info = await context.db.query.deviceInfoTable.findFirst()
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
throw new ORPCError('NOT_FOUND', {
|
||||||
|
message: 'Device info not found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.db
|
||||||
|
.update(deviceInfoTable)
|
||||||
|
.set({
|
||||||
|
license: input.license,
|
||||||
|
licenseActivatedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(deviceInfoTable.id, info.id))
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { os } from '../server'
|
import { os } from '../server'
|
||||||
|
import * as device from './device.router'
|
||||||
import * as fingerprint from './fingerprint.router'
|
import * as fingerprint from './fingerprint.router'
|
||||||
|
import * as license from './license.router'
|
||||||
import * as todo from './todo.router'
|
import * as todo from './todo.router'
|
||||||
|
|
||||||
export const router = os.router({
|
export const router = os.router({
|
||||||
|
device,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
todo,
|
todo,
|
||||||
|
license,
|
||||||
})
|
})
|
||||||
|
|||||||
45
apps/server/src/server/api/routers/license.router.ts
Normal file
45
apps/server/src/server/api/routers/license.router.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { ensureLicenseActivationInitialized } from '@/lib/license-init'
|
||||||
|
import { licenseActivationTable } from '@/server/db/schema'
|
||||||
|
import { dbProvider } from '../middlewares'
|
||||||
|
import { os } from '../server'
|
||||||
|
|
||||||
|
export const getActivation = os.license.getActivation
|
||||||
|
.use(dbProvider)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
await ensureLicenseActivationInitialized()
|
||||||
|
|
||||||
|
const record = await context.db.query.licenseActivationTable.findFirst()
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('License activation record not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fingerprint: record.fingerprint,
|
||||||
|
license: record.license,
|
||||||
|
licenseActivatedAt: record.licenseActivatedAt?.getTime() ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const activate = os.license.activate
|
||||||
|
.use(dbProvider)
|
||||||
|
.handler(async ({ context, input }) => {
|
||||||
|
await ensureLicenseActivationInitialized()
|
||||||
|
|
||||||
|
const record = await context.db.query.licenseActivationTable.findFirst()
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('License activation record not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.db
|
||||||
|
.update(licenseActivationTable)
|
||||||
|
.set({
|
||||||
|
license: input.license,
|
||||||
|
licenseActivatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(licenseActivationTable.id, record.id))
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
14
apps/server/src/server/db/schema/device-info.ts
Normal file
14
apps/server/src/server/db/schema/device-info.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
import { generatedFields } from './utils/field'
|
||||||
|
|
||||||
|
export const deviceInfoTable = sqliteTable('device_info', {
|
||||||
|
...generatedFields,
|
||||||
|
fingerprint: text('fingerprint').notNull().unique(),
|
||||||
|
fingerprintQuality: text('fingerprint_quality', {
|
||||||
|
enum: ['strong', 'medium', 'weak'],
|
||||||
|
}).notNull(),
|
||||||
|
license: text('license'),
|
||||||
|
licenseActivatedAt: integer('license_activated_at', {
|
||||||
|
mode: 'timestamp_ms',
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
export * from './device-info'
|
||||||
|
export * from './license-activation'
|
||||||
export * from './todo'
|
export * from './todo'
|
||||||
|
|||||||
9
apps/server/src/server/db/schema/license-activation.ts
Normal file
9
apps/server/src/server/db/schema/license-activation.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
import { generatedFields } from './utils/field'
|
||||||
|
|
||||||
|
export const licenseActivationTable = sqliteTable('license_activation', {
|
||||||
|
...generatedFields,
|
||||||
|
fingerprint: text('fingerprint').notNull().unique(),
|
||||||
|
license: text('license'),
|
||||||
|
licenseActivatedAt: integer('license_activated_at', { mode: 'timestamp_ms' }),
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user