refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps

- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
This commit is contained in:
ryan
2026-06-19 14:23:29 +08:00
parent 19d476ed7f
commit 63cd906cfc
1064 changed files with 366 additions and 1397 deletions
+270
View File
@@ -0,0 +1,270 @@
import {type PolicySection} from "./types"
import {CodeBlock} from "@/components/ui/code-block"
import {
DocsTable,
DocsTableBody,
DocsTableCell,
DocsTableHead,
DocsTableHeader,
DocsTableRow,
} from "@/components/ui/docs-table"
export const DOCS_LAST_UPDATED = "2026-06-07"
/**
* ------------------------------------------------------------------
* API 文档
* ------------------------------------------------------------------
*/
export const apiSections: PolicySection[] = [
{
value: "api-specs",
title: "1. 接口规范与鉴权说明",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明</p>
</div>
<h3 id="1-1-response-format" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-3">1.1 统一响应格式</h3>
<p>系统所有 API 接口均遵循标准 JSON 响应结构:</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[120px]">字段</DocsTableHead>
<DocsTableHead className="w-[100px]">类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">error_msg</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>错误信息。请求成功时为空字符串 `&quot;&quot;`,失败时包含错误详情描述。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">data</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>接口返回的具体数据内容。请求失败或无数据返回时为 `null`。</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className="mt-2">成功响应示例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"id": 1,
"username": "ryan",
"nickname": "Ryan"
}
}`}
language="json"
/>
<p className="mt-2">失败响应示例:</p>
<CodeBlock
code={`{
"error_msg": "用户密码错误",
"data": null
}`}
language="json"
/>
<h3 id="1-2-authentication" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-3">1.2 鉴权方式</h3>
<p>除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:</p>
<ul className="list-disc pl-5 space-y-2">
<li><strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session Cookie 会话保持登录。</li>
<li><strong>AccessToken 令牌:</strong>供后台调用或第三方应用集成使用。客户端生成 API 访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。</li>
</ul>
<div className="bg-muted border rounded-xl p-4 mt-2 space-y-2">
<p className="font-bold text-xs">支持携带令牌的请求头格式(二选一):</p>
<ul className="list-disc pl-5 text-xs text-muted-foreground space-y-1">
<li><code className="bg-muted-foreground/10 px-1 rounded text-[11px] font-mono">Authorization: Bearer at_xxx</code></li>
<li><code className="bg-muted-foreground/10 px-1 rounded text-[11px] font-mono">X-Access-Token: at_xxx</code></li>
</ul>
</div>
</div>
),
children: [
{ value: "1-1-response-format", title: "1.1 统一响应格式" },
{ value: "1-2-authentication", title: "1.2 鉴权方式" },
]
},
{
value: "auth-apis",
title: "2. 用户与认证接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<h3 id="2-1-register" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.1 用户注册</h3>
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/register</code></p>
<p><strong>说明:</strong>注册本地账户(在后台注册开关开启状态下)。</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[120px]">参数</DocsTableHead>
<DocsTableHead className="w-[80px]">必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">username</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名,必须唯一且无空格。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">password</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码,长度必须大于等于 8 位。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">nickname</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3 id="2-2-login" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">2.2 密码登录</h3>
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/login</code></p>
<p><strong>说明:</strong>通过常规用户名密码进行登录校验,成功后建立 Session Cookie 会话。</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[120px]">参数</DocsTableHead>
<DocsTableHead className="w-[80px]">必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">username</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">password</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3 id="2-3-logout" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">2.3 退出登录</h3>
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/logout</code></p>
<p><strong>说明:</strong>销毁当前会话 Cookie 并退出登录状态。</p>
<h3 id="2-4-profile" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">2.4 获取个人资料</h3>
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/self</code></p>
<p><strong>说明:</strong>获取当前登录账户的基本数据模型(包含 ID、角色、昵称等)。</p>
</div>
),
children: [
{ value: "2-1-register", title: "2.1 用户注册" },
{ value: "2-2-login", title: "2.2 密码登录" },
{ value: "2-3-logout", title: "2.3 退出登录" },
{ value: "2-4-profile", title: "2.4 获取个人资料" },
]
},
{
value: "token-apis",
title: "3. 个人访问令牌 (AccessToken) 接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4">
<p className="text-muted-foreground m-0">AccessToken 管理相关接口均要求通过 Session 登录后调用,支持普通用户权限。</p>
</div>
<h3 id="3-1-list-token" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">3.1 获取令牌列表</h3>
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens</code></p>
<p><strong>说明:</strong>查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。</p>
<h3 id="3-2-create-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">3.2 新建访问令牌</h3>
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens</code></p>
<p><strong>参数:</strong>JSON Body <code className="bg-muted px-1.5 rounded text-xs font-mono">{`{"name": "token名称", "is_admin": false}`}</code></p>
<p><strong>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文 Token,切勿遗失。</p>
<p className="mt-1 text-xs text-muted-foreground"><code className="bg-muted px-1 rounded">is_admin</code>(可选,默认 <code className="bg-muted px-1 rounded">false</code>):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问 <code className="bg-muted px-1 rounded">/admin/**</code> 端点。</p>
<p className="mt-2">成功返回样例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"token": "at_628d022b7a95e26bcd8b29c9...",
"record": {
"id": 5,
"user_id": 1,
"name": "my-dev-key",
"masked_token": "at_628d...29c9",
"is_admin": false,
"created_at": "2026-06-07T21:30:00+08:00"
}
}
}`}
language="json"
/>
<h3 id="3-3-delete-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">3.3 撤销/删除令牌</h3>
<p><strong>接口:</strong>DELETE <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens/:id</code></p>
<p><strong>说明:</strong>通过 ID 物理删除对应访问令牌,该令牌将立即失效。</p>
<h3 id="3-4-rotate-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">3.4 轮换令牌密钥</h3>
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens/:id/rotate</code></p>
<p><strong>说明:</strong>轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文 Token,令牌名称与 ID 保持一致。</p>
</div>
),
children: [
{ value: "3-1-list-token", title: "3.1 获取令牌列表" },
{ value: "3-2-create-token", title: "3.2 新建访问令牌" },
{ value: "3-3-delete-token", title: "3.3 撤销/删除令牌" },
{ value: "3-4-rotate-token", title: "3.4 轮换令牌密钥" },
]
},
{
value: "config-apis",
title: "4. 公共配置与管理接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<h3 id="4-1-public-config" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">4.1 公共系统配置</h3>
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/config/public</code></p>
<p><strong>说明:</strong>无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。</p>
<p className="mt-2">返回数据结构样例:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"site_name": "OpenFlare",
"registration_enabled": "false",
"password_login_enabled": "true",
"password_register_enabled": "false",
"cap_login_enabled": "true",
"oidc_login_enabled": "true"
}
}`}
language="json"
/>
<h3 id="4-2-admin-configs" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">4.2 系统配置项 CRUD (管理员)</h3>
<p><strong>说明:</strong>用于在后台对 `system_configs` 配置进行动态变更,要求管理员权限会话调用。</p>
<ul className="list-disc pl-5 space-y-2">
<li><strong>获取配置列表:</strong>GET <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs?type=system</code></li>
<li><strong>新建配置项:</strong>POST <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs</code></li>
<li><strong>修改指定配置值:</strong>PUT <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs/:key</code></li>
<li><strong>删除配置项:</strong>DELETE <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs/:key</code></li>
</ul>
</div>
),
children: [
{ value: "4-1-public-config", title: "4.1 公共系统配置" },
{ value: "4-2-admin-configs", title: "4.2 系统配置项 CRUD (管理员)" },
]
}
]
@@ -0,0 +1,138 @@
import {type PolicySection} from "./types"
import {CodeBlock} from "@/components/ui/code-block"
/**
* ------------------------------------------------------------------
* 使用指南 (How To Use)
* ------------------------------------------------------------------
*/
export const howToUseSections: PolicySection[] = [
{
value: "quick-start",
title: "1. 快速开始",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明</p>
</div>
<ul className="list-disc pl-5 space-y-2">
<li><strong>架构底座:</strong>Go (Gin + GORM + Redis + Asynq) 后端 + React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端</li>
<li><strong>认证体系:</strong>支持本地常规账号密码注册登录 + 第三方自定义 OIDC (OAuth2) 认证源绑定</li>
<li><strong>访问令牌:</strong>提供开发者个人 AccessToken (API Key),用于通过 Http Header 鉴权直接调用系统 API</li>
<li><strong>可观测性:</strong>集成 Zap 结构化日志与 OpenTelemetry 全链路 Tracing 追踪</li>
</ul>
</div>
)
},
{
value: "auth-security",
title: "2. 身份认证与安全设置",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>平台采用混合式身份认证,满足不同的部署和业务场景需求:</p>
<h3 id="2-1-login" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.1 常规账号密码认证</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>自主注册与密码登录:</strong>支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">bcrypt</code> 高强度加盐哈希存储。</li>
<li><strong>系统开关控制:</strong>管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。</li>
</ul>
<h3 id="2-2-oidc" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.2 第三方 OIDC 认证源</h3>
<p>用户可以在个人资料页面关联绑定外部授权账户:</p>
<ol className="list-decimal pl-4 md:pl-5 space-y-1">
<li>进入 <strong>设置 / 个人资料</strong> 页面。</li>
<li>在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC 认证源直接触发 OAuth2 绑定流。</li>
<li>绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。</li>
</ol>
</div>
),
children: [
{ value: "2-1-login", title: "2.1 常规账号密码认证" },
{ value: "2-2-oidc", title: "2.2 第三方 OIDC 认证源" },
]
},
{
value: "access-token",
title: "3. 个人访问令牌 (AccessToken) 接口对接",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>为便于开发者或第三方工具直接调用系统 API,平台提供个人访问令牌管理功能。</p>
<h3 id="3-1-generation" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">3.1 令牌生成与存储规范</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值 (形如 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">at_xxx</code>) 仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。</li>
<li><strong>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256 哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。</li>
</ul>
<h3 id="3-2-usage" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">3.2 携带 Header 进行 API 调用</h3>
<p>您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种 Http 请求头携带方式之一:</p>
<div className="space-y-2">
<p className="font-semibold text-xs text-muted-foreground">方式一:Authorization Bearer 头</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
Authorization: Bearer at_628d022b7a95e26b...`}
language="http"
/>
</div>
<div className="space-y-2">
<p className="font-semibold text-xs text-muted-foreground">方式二:X-Access-Token 自定义头</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
X-Access-Token: at_628d022b7a95e26b...`}
language="http"
/>
</div>
</div>
),
children: [
{ value: "3-1-generation", title: "3.1 令牌生成与存储规范" },
{ value: "3-2-usage", title: "3.2 携带 Header 进行 API 调用" },
]
},
{
value: "config-system",
title: "4. 动态系统配置项",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>缓存读取加速:</strong>配置加载基于 GORM 读取数据库,并辅以 Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。</li>
<li><strong>核心系统配置项说明:</strong>
<ul className="list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground">
<li><code className="bg-muted px-1 rounded text-xs font-mono">site_name</code>:平台展示名称</li>
<li><code className="bg-muted px-1 rounded text-xs font-mono">password_login_enabled</code>:密码登录开关</li>
<li><code className="bg-muted px-1 rounded text-xs font-mono">registration_enabled</code>:用户自主注册开关</li>
<li><code className="bg-muted px-1 rounded text-xs font-mono">max_api_keys_per_user</code>:普通用户创建令牌的最大数限制 (默认5)</li>
</ul>
</li>
</ul>
</div>
)
},
{
value: "worker-scheduler",
title: "5. 异步任务与定时调度",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker 服务解耦复杂计算或高延迟IO:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>定时任务派发 (CMD scheduler):</strong>负责按 Cron 表达式配置,定时将待执行任务推送到 Redis 队列中。</li>
<li><strong>多优先级 Worker (CMD worker):</strong>基于 Asynq 驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。</li>
</ul>
</div>
)
},
{
value: "tracing-metrics",
title: "6. 链路追踪与结构化日志",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>OpenTelemetry Tracing:</strong>自动传递 Tracing 上下文,所有经由 Gin 中间件、外部请求或 GORM 数据库的事务操作都将带有全局唯一的 Span,用于排查链路耗时或调用异常。</li>
<li><strong>Zap 结构化日志:</strong>将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki 等日志收集分析工具无缝对接。</li>
</ul>
</div>
)
}
]
@@ -0,0 +1,411 @@
"use client"
import * as React from "react"
import Link from "next/link"
import {ArrowLeft, Calendar, Check, Copy, FileText} from "lucide-react"
import {Button} from "@/components/ui/button"
import {cn} from "@/lib/utils"
import {motion, useScroll, useSpring} from "motion/react"
import type {PolicySection} from "./types"
interface LegalPageLayoutProps {
title: string
lastUpdated: string
sections: PolicySection[]
description?: React.ReactNode
}
export function LegalPageLayout({
title,
lastUpdated,
sections,
description,
}: LegalPageLayoutProps) {
const [activeSection, setActiveSection] = React.useState<string>(sections[0]?.value || "")
const [hasCopied, setHasCopied] = React.useState(false)
const { scrollYProgress } = useScroll()
const contentRef = React.useRef<HTMLDivElement | null>(null)
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
})
const allSectionIds = React.useMemo(() => {
return sections.flatMap(section => [
section.value,
...(section.children?.map(child => child.value) || [])
])
}, [sections])
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveSection(entry.target.id)
}
})
},
{ rootMargin: "-20% 0px -35% 0px" }
)
allSectionIds.forEach((id) => {
const element = document.getElementById(id)
if (element) observer.observe(element)
})
return () => observer.disconnect()
}, [sections, allSectionIds])
const convertInlineNodes = (nodes: ChildNode[]): string => {
return nodes.map((node) => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ""
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return ""
}
const element = node as HTMLElement
const tagName = element.tagName.toLowerCase()
if (tagName === "code") {
return `\`${element.textContent?.trim() || ""}\``
}
if (tagName === "strong" || tagName === "b") {
return `**${convertInlineNodes(Array.from(element.childNodes))}**`
}
if (tagName === "em" || tagName === "i") {
return `*${convertInlineNodes(Array.from(element.childNodes))}*`
}
if (tagName === "a") {
const text = convertInlineNodes(Array.from(element.childNodes)) || element.textContent || ""
const href = element.getAttribute("href") || ""
return href ? `[${text}](${href})` : text
}
if (tagName === "br") {
return "\n"
}
return convertInlineNodes(Array.from(element.childNodes))
}).join("")
}
const convertList = (
list: HTMLOListElement | HTMLUListElement,
indentLevel: number
): string => {
const isOrdered = list.tagName.toLowerCase() === "ol"
const items = Array.from(list.children).filter((child) => child.tagName.toLowerCase() === "li")
return items.map((item, index) => {
const prefix = isOrdered ? `${index + 1}. ` : "- "
const indent = " ".repeat(indentLevel)
const parts: string[] = []
const itemElement = item as HTMLElement
const childNodes = Array.from(itemElement.childNodes)
const nestedLists: HTMLElement[] = []
childNodes.forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE) {
const childElement = child as HTMLElement
if (childElement.tagName.toLowerCase() === "ul" || childElement.tagName.toLowerCase() === "ol") {
nestedLists.push(childElement)
return
}
}
parts.push(convertInlineNodes([child]).trim())
})
const baseLine = `${indent}${prefix}${parts.join("").trim()}`
const nestedMarkdown = nestedLists.map((nested) => convertList(nested as HTMLOListElement | HTMLUListElement, indentLevel + 1))
return [baseLine, ...nestedMarkdown].filter(Boolean).join("\n")
}).join("\n")
}
const convertTable = (table: HTMLTableElement): string => {
const rows = Array.from(table.querySelectorAll("tr")).map((row) =>
Array.from(row.querySelectorAll("th,td")).map((cell) => (cell.textContent || "").trim())
)
if (!rows.length) return ""
const header = rows[0]
const body = rows.slice(1)
const headerLine = `| ${header.join(" | ")} |`
const separatorLine = `| ${header.map(() => "---").join(" | ")} |`
const bodyLines = body.map((row) => `| ${row.join(" | ")} |`)
return [headerLine, separatorLine, ...bodyLines].join("\n")
}
const convertBlockNode = (node: HTMLElement, indentLevel: number): string => {
const tagName = node.tagName.toLowerCase()
if (tagName === "h3") return `### ${node.textContent?.trim() || ""}`
if (tagName === "h4") return `#### ${node.textContent?.trim() || ""}`
if (tagName === "p") return convertInlineNodes(Array.from(node.childNodes)).trim()
if (tagName === "ul" || tagName === "ol") return convertList(node as HTMLOListElement | HTMLUListElement, indentLevel)
if (tagName === "pre") {
const code = node.textContent || ""
return `\`\`\`\n${code.trim()}\n\`\`\``
}
if (tagName === "table") return convertTable(node as HTMLTableElement)
if (tagName === "div") {
const hasTable = node.querySelector("table")
const hasList = node.querySelector("ul,ol")
const hasPre = node.querySelector("pre")
if (hasTable || hasList || hasPre) {
return Array.from(node.children)
.map((child) => convertBlockNode(child as HTMLElement, indentLevel))
.filter(Boolean)
.join("\n\n")
}
const text = convertInlineNodes(Array.from(node.childNodes)).trim()
return text
}
return convertInlineNodes(Array.from(node.childNodes)).trim()
}
const buildMarkdown = (): string => {
const contentRoot = contentRef.current
if (!contentRoot) return ""
const lines: string[] = []
lines.push(`# ${title}`)
lines.push("")
lines.push(`最后更新:${lastUpdated}`)
if (description) {
const descriptionElement = contentRoot.querySelector("[data-docs-description]")
if (descriptionElement) {
const descriptionText = convertInlineNodes(Array.from(descriptionElement.childNodes)).trim()
if (descriptionText) {
lines.push("")
lines.push(descriptionText)
}
}
}
lines.push("")
const sectionNodes = Array.from(contentRoot.querySelectorAll("[data-docs-section]")) as HTMLElement[]
sectionNodes.forEach((section) => {
const heading = section.querySelector("h2")
if (heading?.textContent) {
lines.push(`## ${heading.textContent.trim()}`)
lines.push("")
}
const prose = section.querySelector("[data-docs-content]")
if (prose) {
const blocks = Array.from(prose.children)
.map((child) => convertBlockNode(child as HTMLElement, 0))
.filter(Boolean)
if (blocks.length) {
lines.push(blocks.join("\n\n"))
lines.push("")
}
}
})
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
}
const handleCopyMarkdown = async () => {
const markdown = buildMarkdown()
if (!markdown) return
try {
await navigator.clipboard.writeText(markdown)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
} catch {
const textArea = document.createElement("textarea")
textArea.value = markdown
textArea.style.position = "fixed"
textArea.style.opacity = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand("copy")
document.body.removeChild(textArea)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
}
}
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault()
const element = document.getElementById(id)
if (element) {
const offset = 80
const bodyRect = document.body.getBoundingClientRect().top
const elementRect = element.getBoundingClientRect().top
const elementPosition = elementRect - bodyRect
const offsetPosition = elementPosition - offset
window.scrollTo({
top: offsetPosition,
behavior: "smooth"
})
}
}
return (
<div className="min-h-screen bg-background relative selection:bg-primary/20">
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
style={{ scaleX }}
/>
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-y-1/2" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[120px] translate-y-1/2" />
</div>
<div ref={contentRef} className="container relative z-10 max-w-7xl mx-auto px-4 md:px-6 py-8 lg:py-16">
<header className="mb-8 md:mb-12">
<Link href="/" className="inline-block mb-8">
<Button
variant="ghost"
className="gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2"
>
<ArrowLeft className="w-4 h-4" />
返回首页
</Button>
</Link>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-2xl md:text-3xl lg:text-5xl font-bold tracking-tight text-foreground mb-6">
{title}
</h1>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8">
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
<FileText className="w-3.5 h-3.5" />
{sections.length} 个内容
</span>
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
<Calendar className="w-3.5 h-3.5" />
最后更新:{lastUpdated}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyMarkdown}
className="h-8 gap-2 text-xs"
>
{hasCopied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{hasCopied ? "已复制" : "复制 Markdown"}
</Button>
</div>
{description && (
<div className="w-full p-4 md:p-6 rounded-2xl bg-muted/30 border border-border/50 backdrop-blur-sm overflow-hidden">
<div data-docs-description className="text-sm md:text-base text-muted-foreground leading-relaxed m-0 break-all">
{description}
</div>
</div>
)}
</motion.div>
</header>
<div className="grid lg:grid-cols-12 gap-8 lg:gap-12 relative">
<aside className="hidden lg:block lg:col-span-3">
<div className="sticky top-24">
<div className="space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin">
<div className="text-sm font-semibold text-foreground mb-4 pl-3">目录</div>
<nav className="space-y-1">
{sections.map((section) => (
<div key={section.value} className="space-y-1">
<a
href={`#${ section.value }`}
onClick={(e) => scrollToSection(e, section.value)}
className={cn(
"group flex items-center justify-between py-2 px-3 text-sm rounded-lg transition-all duration-200",
activeSection === section.value
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
<span className="truncate">{section.title}</span>
{activeSection === section.value && (
<motion.div
layoutId="active-dot"
className="w-1.5 h-1.5 rounded-full bg-primary"
/>
)}
</a>
{section.children && (
<div className="pl-4 space-y-1">
{section.children.map((child) => (
<a
key={child.value}
href={`#${ child.value }`}
onClick={(e) => scrollToSection(e, child.value)}
className={cn(
"block py-1.5 px-3 text-xs rounded-lg transition-all duration-200 truncate",
activeSection === child.value
? "text-primary font-medium bg-primary/5"
: "text-muted-foreground/80 hover:text-foreground hover:bg-muted/30"
)}
>
{child.title}
</a>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
</aside>
<main className="lg:col-span-9 lg:col-start-4 space-y-8 md:space-y-16 pb-20">
{sections.map((section, index) => (
<motion.section
key={section.value}
id={section.value}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "100px" }}
transition={{ duration: 0.3 }}
className="scroll-mt-24 group"
data-docs-section
>
<div className="sticky top-0 z-20 flex items-center gap-4 py-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-2 -mx-4 px-4 md:-mx-0 md:px-0">
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary text-sm font-bold opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0">
{index + 1}
</span>
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground group-hover:text-primary transition-colors duration-300">
{section.title.replace(/^\d+\.\s*/, '')}
</h2>
</div>
<div
data-docs-content
className={cn(
"prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none",
"prose-p:text-muted-foreground prose-p:leading-7",
"prose-li:text-muted-foreground",
"prose-strong:text-foreground",
"pl-4 md:pl-12 border-l border-border/30 group-hover:border-primary/20 transition-colors duration-500"
)}>
{section.content}
</div>
</motion.section>
))}
<div className="pt-16 border-t border-border mt-20">
<div className="bg-muted/30 rounded-2xl p-8 text-center">
<p className="text-muted-foreground text-sm mb-4">
对这些内容有疑问?
</p>
<div className="flex justify-center gap-4">
<Link href="https://github.com/Rain-kl/OpenFlare/issues" target="_blank">
<Button variant="outline" className="h-9 text-xs">
联系支持
</Button>
</Link>
</div>
</div>
</div>
</main>
</div>
</div >
</div >
)
}
+100
View File
@@ -0,0 +1,100 @@
import {type PolicySection} from "./types"
/**
* ------------------------------------------------------------------
* 隐私政策 (PRIVACY POLICY)
* ------------------------------------------------------------------
*/
export const privacySections: PolicySection[] = [
{
value: "collection-details",
title: "1. 详细信息收集说明",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:</p>
<div className="space-y-3">
<div>
<span className="font-semibold text-foreground">1.1 身份鉴权信息:</span>
<p className="mt-1 text-muted-foreground">当您通过本地账号注册或绑定第三方 OIDC 认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方 OpenID 标识符。<strong>我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。</strong></p>
</div>
<div>
<span className="font-semibold text-foreground">1.2 服务与接口日志信息:</span>
<p className="mt-1 text-muted-foreground">为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、个人访问令牌 API 调用历史记录、User-Agent(浏览器/设备/请求工具类型)。</p>
</div>
</div>
</div>
),
},
{
value: "storage-security",
title: "2. 数据存储与安全保护",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>存储安全:</strong>用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。</li>
<li><strong>加密技术:</strong>敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API 数据传输链路强制使用 SSL/TLS 进行安全加密。</li>
<li><strong>访问控制:</strong>我们实行严格的最小权限原则(Least Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。</li>
</ul>
</div>
),
},
{
value: "usage-rules",
title: "3. 信息使用规范",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们收集的信息将仅用于以下目的:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1">
<li><strong>身份识别:</strong>用于确认您的注册身份,展示您的个人中心及设置页面数据。</li>
<li><strong>业务功能:</strong>用于识别并处理您的个人访问令牌 API 鉴权指令。</li>
<li><strong>安全风控:</strong>利用 IP 及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。</li>
</ul>
<p><strong>禁止用途:</strong>我们承诺<strong>绝不</strong>将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。</p>
</div>
),
},
{
value: "sharing-disclosure",
title: "4. 信息共享与对外披露",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>4.1 共享原则:</strong>除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
<li>事先获得您的明确授权或同意;</li>
<li>根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。</li>
</ul>
<p><strong>4.2 转让与公开披露:</strong>我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。</p>
</div>
),
},
{
value: "user-rights",
title: "5. 您的权利与数据管理",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:</p>
<div className="space-y-3">
<div>
<span className="font-semibold text-foreground">5.1 查阅与管理权:</span>
<p className="mt-1 text-muted-foreground">您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的 AccessToken (API 密钥)。</p>
</div>
<div>
<span className="font-semibold text-foreground">5.2 删除与注销权:</span>
<p className="mt-1 text-muted-foreground">若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。</p>
</div>
</div>
</div>
),
},
{
value: "policy-update",
title: "6. 政策更新与通知",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
<p>当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。</p>
</div>
),
},
]
+92
View File
@@ -0,0 +1,92 @@
import {type PolicySection} from "./types"
export const TERMS_LAST_UPDATED = "2026-06-07"
/**
* ------------------------------------------------------------------
* 服务条款 (TERMS OF SERVICE)
* ------------------------------------------------------------------
*/
export const termsSections: PolicySection[] = [
{
value: "contract-establishment",
title: "1. 缔约申明与服务综述",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>1.1 缔约主体:</strong>本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。</p>
<p><strong>1.2 审慎阅读:</strong>本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC 接入、API Key 令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。</p>
<p><strong>1.3 协议构成:</strong>本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。</p>
</div>
),
},
{
value: "service-definition",
title: "2. 服务定义与性质界定",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>责任自担:</strong>关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。</li>
</ul>
</div>
),
},
{
value: "account-specifications",
title: "3. 账号注册与使用规范",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>3.1 账号体系:</strong>用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方 OIDC 认证源进行登录关联。</p>
<p><strong>3.2 密码及令牌安全责任:</strong></p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>密码安全:</strong>您应妥善保管您账户的登录密码。</li>
<li><strong>API Token 安全:</strong>个人生成的 AccessToken (API Key) 代表您账户的完整调用权限。<strong>因您保管不善导致 Token 泄漏而造成的一切数据丢失或系统损失,均由您自行承担。</strong></li>
</ul>
</div>
),
},
{
value: "user-conduct",
title: "4. 用户行为准则(负面清单)",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。<strong>严禁利用本平台从事以下活动(“红线条款”):</strong></p>
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3">
<ul className="list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium">
<li><strong>危害国家安全:</strong>反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;</li>
<li><strong>非法信息服务:</strong>黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;</li>
<li><strong>黄赌毒关联:</strong>制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;</li>
<li><strong>侵犯知识产权:</strong>销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;</li>
<li><strong>其他违法信息:</strong>涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。</li>
</ul>
</div>
<p><strong>处理规则:</strong>一旦发现您违反上述规定,平台运维主体有权不经通知<strong>立即永久封禁您的账号、拦截所有 AccessToken 请求,并依法向公安机关、网安部门移交相关线索。</strong></p>
</div>
),
},
{
value: "liability-limitation",
title: "5. 免责声明与不可抗力",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>5.1 基础免责:</strong>本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。</p>
<p><strong>5.2 技术服务中断:</strong>对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
<li>自然灾害(台风、地震、海啸、洪水等);</li>
<li>政府行为、网络安全法律法规调整或行政命令;</li>
<li>电信部门线路技术故障、机房海底光缆损毁;</li>
<li>黑客入侵、勒索病毒感染导致的数据损毁或宕机。</li>
</ul>
</div>
),
},
{
value: "governing-law",
title: "6. 法律适用与争议解决",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>6.1 法律适用:</strong>本协议的订立、执行、解释及争议的解决均适用<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。</p>
<p><strong>6.2 争议解决:</strong>若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。</p>
</div>
),
},
]
+11
View File
@@ -0,0 +1,11 @@
import {type ReactNode} from "react"
export type PolicySection = {
value: string
title: string
content: ReactNode
children?: {
value: string
title: string
}[]
}