From 8952bf4205ac966f8be318895d89e39208986d8b Mon Sep 17 00:00:00 2001 From: imbytecat Date: Mon, 26 Jan 2026 15:03:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E8=AE=B8=E5=8F=AF?= =?UTF-8?q?=E8=AF=81=E6=BF=80=E6=B4=BB=E5=8A=9F=E8=83=BD=E5=8F=8A=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 db 中间件重命名为 dbProvider,同时保留 db 作为别名以避免破坏现有路由。 - 记录并解决开发环境中数据库迁移和类型检查工具使用问题。 - 完善许可证激活功能,新增数据表与初始化逻辑,实现UPSERT操作及单例模式,添加RPC接口支持,修复时间戳类型不匹配问题。 - 创建设备信息和待办事项数据表,并为设备指纹添加唯一索引。 - 添加许可证激活表及指纹唯一索引 - 添加设备信息和待办事项表的数据库模式快照,包含字段定义、主键约束及指纹唯一索引。 - 添加初始数据库模式快照,包含设备信息、许可证激活和待办事项三张表的结构定义。 - 添加新的迁移日志文件以记录数据库版本变更和迁移步骤 - 确保设备信息在数据库中正确初始化,使用硬件指纹唯一标识设备并支持并发安全的单例模式初始化。 - 初始化许可证激活记录,确保基于硬件指纹创建或更新激活信息。 - 添加许可证激活管理页面,包含设备指纹展示、许可证输入激活功能及激活状态实时反馈。 - 添加许可证页面路由配置并更新相关路由类型和路径映射。 - 添加设备信息获取与许可证设置的接口契约定义,包含数据校验 schema 和接口输入输出规范。 - 添加设备和许可证合约接口到导出契约对象中 - 添加许可证相关API契约,定义获取激活状态和激活许可证的输入输出结构。 - 初始化设备和许可激活状态,并将数据库中间件重命名为dbProvider,同时导出原始db名称以保持兼容性。 - 添加设备信息获取和许可证设置接口,确保设备初始化并安全处理数据读写。 - 添加设备和许可证路由到API路由器中。 - 添加许可证激活状态查询和激活功能,支持通过数据库记录管理许可证信息并返回激活时间戳。 - 添加设备信息表结构,包含指纹、指纹质量等级、许可证及激活时间字段。 - 添加设备信息和许可证激活相关数据模型导出 - 添加许可证激活记录表,包含指纹、许可证信息及激活时间字段。 --- .../notepads/license-activation/decisions.md | 3 + .../notepads/license-activation/issues.md | 4 + .../notepads/license-activation/learnings.md | 7 + apps/server/drizzle/0000_messy_goliath.sql | 18 + apps/server/drizzle/0001_watery_mongu.sql | 10 + apps/server/drizzle/meta/0000_snapshot.json | 129 ++++++ apps/server/drizzle/meta/0001_snapshot.json | 187 +++++++++ apps/server/drizzle/meta/_journal.json | 20 + apps/server/src/lib/device-init.ts | 61 +++ apps/server/src/lib/license-init.ts | 40 ++ apps/server/src/routeTree.gen.ts | 24 +- apps/server/src/routes/license.tsx | 391 ++++++++++++++++++ .../server/api/contracts/device.contract.ts | 15 + apps/server/src/server/api/contracts/index.ts | 4 + .../server/api/contracts/license.contract.ts | 14 + .../server/api/middlewares/db.middleware.ts | 8 +- .../src/server/api/routers/device.router.ts | 54 +++ apps/server/src/server/api/routers/index.ts | 4 + .../src/server/api/routers/license.router.ts | 45 ++ .../src/server/db/schema/device-info.ts | 14 + apps/server/src/server/db/schema/index.ts | 2 + .../server/db/schema/license-activation.ts | 9 + 22 files changed, 1059 insertions(+), 4 deletions(-) create mode 100644 .sisyphus/notepads/license-activation/decisions.md create mode 100644 .sisyphus/notepads/license-activation/issues.md create mode 100644 .sisyphus/notepads/license-activation/learnings.md create mode 100644 apps/server/drizzle/0000_messy_goliath.sql create mode 100644 apps/server/drizzle/0001_watery_mongu.sql create mode 100644 apps/server/drizzle/meta/0000_snapshot.json create mode 100644 apps/server/drizzle/meta/0001_snapshot.json create mode 100644 apps/server/drizzle/meta/_journal.json create mode 100644 apps/server/src/lib/device-init.ts create mode 100644 apps/server/src/lib/license-init.ts create mode 100644 apps/server/src/routes/license.tsx create mode 100644 apps/server/src/server/api/contracts/device.contract.ts create mode 100644 apps/server/src/server/api/contracts/license.contract.ts create mode 100644 apps/server/src/server/api/routers/device.router.ts create mode 100644 apps/server/src/server/api/routers/license.router.ts create mode 100644 apps/server/src/server/db/schema/device-info.ts create mode 100644 apps/server/src/server/db/schema/license-activation.ts diff --git a/.sisyphus/notepads/license-activation/decisions.md b/.sisyphus/notepads/license-activation/decisions.md new file mode 100644 index 0000000..42d8c7e --- /dev/null +++ b/.sisyphus/notepads/license-activation/decisions.md @@ -0,0 +1,3 @@ +## Decisions + +- Renamed `db` middleware to `dbProvider` as requested, while keeping `db` as an alias to avoid breaking existing routers. diff --git a/.sisyphus/notepads/license-activation/issues.md b/.sisyphus/notepads/license-activation/issues.md new file mode 100644 index 0000000..7aac1f7 --- /dev/null +++ b/.sisyphus/notepads/license-activation/issues.md @@ -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. diff --git a/.sisyphus/notepads/license-activation/learnings.md b/.sisyphus/notepads/license-activation/learnings.md new file mode 100644 index 0000000..e0e2076 --- /dev/null +++ b/.sisyphus/notepads/license-activation/learnings.md @@ -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. diff --git a/apps/server/drizzle/0000_messy_goliath.sql b/apps/server/drizzle/0000_messy_goliath.sql new file mode 100644 index 0000000..cbbf294 --- /dev/null +++ b/apps/server/drizzle/0000_messy_goliath.sql @@ -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 +); diff --git a/apps/server/drizzle/0001_watery_mongu.sql b/apps/server/drizzle/0001_watery_mongu.sql new file mode 100644 index 0000000..5f012b9 --- /dev/null +++ b/apps/server/drizzle/0001_watery_mongu.sql @@ -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`); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0000_snapshot.json b/apps/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..ff7bfae --- /dev/null +++ b/apps/server/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} diff --git a/apps/server/drizzle/meta/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..a0eb2a1 --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..172e64c --- /dev/null +++ b/apps/server/drizzle/meta/_journal.json @@ -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 + } + ] +} diff --git a/apps/server/src/lib/device-init.ts b/apps/server/src/lib/device-init.ts new file mode 100644 index 0000000..bfd8e66 --- /dev/null +++ b/apps/server/src/lib/device-init.ts @@ -0,0 +1,61 @@ +import { getHardwareFingerprint } from '@/lib/fingerprint' +import { getDB } from '@/server/db' +import { deviceInfoTable } from '@/server/db/schema' + +let initPromise: Promise | null = null + +/** + * 确保设备信息已在数据库中初始化 + * 使用单例模式防止并发重复初始化 + */ +export async function ensureDeviceInitialized(): Promise { + 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 +} diff --git a/apps/server/src/lib/license-init.ts b/apps/server/src/lib/license-init.ts new file mode 100644 index 0000000..bc56361 --- /dev/null +++ b/apps/server/src/lib/license-init.ts @@ -0,0 +1,40 @@ +import { getDB } from '@/server/db' +import { licenseActivationTable } from '@/server/db/schema' +import { getHardwareFingerprint } from './fingerprint' + +let initPromise: Promise | null = null + +export async function ensureLicenseActivationInitialized(): Promise { + 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 +} diff --git a/apps/server/src/routeTree.gen.ts b/apps/server/src/routeTree.gen.ts index df2c6fd..b00e697 100644 --- a/apps/server/src/routeTree.gen.ts +++ b/apps/server/src/routeTree.gen.ts @@ -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. import { Route as rootRouteImport } from './routes/__root' +import { Route as LicenseRouteImport } from './routes/license' import { Route as FingerprintRouteImport } from './routes/fingerprint' import { Route as IndexRouteImport } from './routes/index' import { Route as ApiSplatRouteImport } from './routes/api/$' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$' +const LicenseRoute = LicenseRouteImport.update({ + id: '/license', + path: '/license', + getParentRoute: () => rootRouteImport, +} as any) const FingerprintRoute = FingerprintRouteImport.update({ id: '/fingerprint', path: '/fingerprint', @@ -38,12 +44,14 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/fingerprint': typeof FingerprintRoute + '/license': typeof LicenseRoute '/api/$': typeof ApiSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/fingerprint': typeof FingerprintRoute + '/license': typeof LicenseRoute '/api/$': typeof ApiSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute } @@ -51,26 +59,35 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/fingerprint': typeof FingerprintRoute + '/license': typeof LicenseRoute '/api/$': typeof ApiSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/fingerprint' | '/api/$' | '/api/rpc/$' + fullPaths: '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/fingerprint' | '/api/$' | '/api/rpc/$' - id: '__root__' | '/' | '/fingerprint' | '/api/$' | '/api/rpc/$' + to: '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$' + id: '__root__' | '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute FingerprintRoute: typeof FingerprintRoute + LicenseRoute: typeof LicenseRoute ApiSplatRoute: typeof ApiSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/license': { + id: '/license' + path: '/license' + fullPath: '/license' + preLoaderRoute: typeof LicenseRouteImport + parentRoute: typeof rootRouteImport + } '/fingerprint': { id: '/fingerprint' path: '/fingerprint' @@ -105,6 +122,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, FingerprintRoute: FingerprintRoute, + LicenseRoute: LicenseRoute, ApiSplatRoute: ApiSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute, } diff --git a/apps/server/src/routes/license.tsx b/apps/server/src/routes/license.tsx new file mode 100644 index 0000000..0da359e --- /dev/null +++ b/apps/server/src/routes/license.tsx @@ -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 ( +
+
+ {/* 页面标题 */} +
+

+ License 激活管理 +

+

管理您的设备授权与激活状态

+
+ + {/* 设备信息卡片 */} +
+
+

+ + 设备信息 +

+
+

+ 设备指纹 (Device Fingerprint) +

+
+ + {data.fingerprint} + + +
+
+
+
+ + {/* License 激活卡片 */} +
+
+

+ + License 激活 +

+ +
+
+ + 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" + /> +
+ + {activateMutation.isError && ( +
+ + 激活失败: 请检查 License 是否正确或稍后重试 +
+ )} + + +
+
+
+ + {/* 激活状态卡片 */} +
+
+

+ + 激活状态 +

+ + {isActivated ? ( +
+
+ + License 已激活 +
+
+

+ + 当前 License:{' '} + + {data.license} +

+ {data.licenseActivatedAt && ( +

+ + 激活时间:{' '} + + {new Date(data.licenseActivatedAt).toLocaleString( + 'zh-CN', + { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }, + )} +

+ )} +
+
+ ) : ( +
+ +
+

+ 未激活 +

+

+ 您的设备尚未激活。请在上方输入 License Key + 进行激活以解锁全部功能。 +

+
+
+ )} +
+
+
+
+ ) +} diff --git a/apps/server/src/server/api/contracts/device.contract.ts b/apps/server/src/server/api/contracts/device.contract.ts new file mode 100644 index 0000000..eb5360c --- /dev/null +++ b/apps/server/src/server/api/contracts/device.contract.ts @@ -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() })) diff --git a/apps/server/src/server/api/contracts/index.ts b/apps/server/src/server/api/contracts/index.ts index eddb4ca..7f5c61d 100644 --- a/apps/server/src/server/api/contracts/index.ts +++ b/apps/server/src/server/api/contracts/index.ts @@ -1,9 +1,13 @@ +import * as device from './device.contract' import * as fingerprint from './fingerprint.contract' +import * as license from './license.contract' import * as todo from './todo.contract' export const contract = { + device, fingerprint, todo, + license, } export type Contract = typeof contract diff --git a/apps/server/src/server/api/contracts/license.contract.ts b/apps/server/src/server/api/contracts/license.contract.ts new file mode 100644 index 0000000..ce86419 --- /dev/null +++ b/apps/server/src/server/api/contracts/license.contract.ts @@ -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() })) diff --git a/apps/server/src/server/api/middlewares/db.middleware.ts b/apps/server/src/server/api/middlewares/db.middleware.ts index 24f6bfc..093b019 100644 --- a/apps/server/src/server/api/middlewares/db.middleware.ts +++ b/apps/server/src/server/api/middlewares/db.middleware.ts @@ -1,7 +1,11 @@ import { os } from '@orpc/server' +import { ensureDeviceInitialized } from '@/lib/device-init' +import { ensureLicenseActivationInitialized } from '@/lib/license-init' 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({ context: { ...context, @@ -9,3 +13,5 @@ export const db = os.middleware(async ({ context, next }) => { }, }) }) + +export const db = dbProvider diff --git a/apps/server/src/server/api/routers/device.router.ts b/apps/server/src/server/api/routers/device.router.ts new file mode 100644 index 0000000..56aee67 --- /dev/null +++ b/apps/server/src/server/api/routers/device.router.ts @@ -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 } + }) diff --git a/apps/server/src/server/api/routers/index.ts b/apps/server/src/server/api/routers/index.ts index 9940541..6e33bd1 100644 --- a/apps/server/src/server/api/routers/index.ts +++ b/apps/server/src/server/api/routers/index.ts @@ -1,8 +1,12 @@ import { os } from '../server' +import * as device from './device.router' import * as fingerprint from './fingerprint.router' +import * as license from './license.router' import * as todo from './todo.router' export const router = os.router({ + device, fingerprint, todo, + license, }) diff --git a/apps/server/src/server/api/routers/license.router.ts b/apps/server/src/server/api/routers/license.router.ts new file mode 100644 index 0000000..43ab10a --- /dev/null +++ b/apps/server/src/server/api/routers/license.router.ts @@ -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 } + }) diff --git a/apps/server/src/server/db/schema/device-info.ts b/apps/server/src/server/db/schema/device-info.ts new file mode 100644 index 0000000..b8872c3 --- /dev/null +++ b/apps/server/src/server/db/schema/device-info.ts @@ -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', + }), +}) diff --git a/apps/server/src/server/db/schema/index.ts b/apps/server/src/server/db/schema/index.ts index a1cad6c..ddebe30 100644 --- a/apps/server/src/server/db/schema/index.ts +++ b/apps/server/src/server/db/schema/index.ts @@ -1 +1,3 @@ +export * from './device-info' +export * from './license-activation' export * from './todo' diff --git a/apps/server/src/server/db/schema/license-activation.ts b/apps/server/src/server/db/schema/license-activation.ts new file mode 100644 index 0000000..e1d6e41 --- /dev/null +++ b/apps/server/src/server/db/schema/license-activation.ts @@ -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' }), +})