diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..ef550b3 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,35 @@ +name: Checks +on: [push, pull_request] +jobs: + verify: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: veyra_test + POSTGRES_PASSWORD: test_only + POSTGRES_DB: veyra_test + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U veyra_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + VEYRA_TEST_DATABASE_URL: postgres://veyra_test:test_only@127.0.0.1:5432/veyra_test?sslmode=disable + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: services/api/go.mod + cache-dependency-path: services/api/go.sum + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: make build + - run: make test + - run: go vet ./services/api/... + - run: test -z "$(gofmt -l services/api)" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..64911c2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Veyra 工程约定 + +项目背景、已完成功能和验证状态见 `docs/development-state.md`。继续开发前阅读该文件,完成工作后更新事实与待办,不记录密码。 + +- 后端使用 Go,遵循 gofmt、go vet、惯用错误处理、context 传递、明确依赖注入及 cmd/internal 分层。 +- HTTP handler 只做协议适配,业务规则归 service/domain,SQL 归 repository。不得将 Gin Context 传入业务层。 +- PostgreSQL 所有表(包括迁移、任务和关联表)必须以 `veyra_` 开头,表与字段使用 snake_case。 +- 主键 `id` 使用字符串 UUID:Go 类型 `string`,数据库 `varchar(36)` 并校验 UUID 格式。外键保持相同类型。 +- 映射去掉表名前缀并转 PascalCase:`veyra_user` → `User`,`veyra_trade_plan` → `TradePlan`。使用显式 SQL 与 db tag,不依赖 ORM 隐式命名。 +- 金额、价格、数量规则不得使用 float64;采用明确精度的整数或 Decimal。 +- 已入账成交不可覆盖,采用冲正;触发提醒不可产生持仓或扣款。 +- 管理端必须采用 Vite + React + TypeScript + Tailwind CSS + shadcn/ui,保留暖白、炭黑、焦糖橙视觉。 +- 管理端生产构建必须通过 Go embed 嵌入后端,页面和 API 同源服务,Vite 仅用于本地开发。 +- App 采用 React Native + Expo,白底、石墨黑与柔紫。 +- 凭据通过环境配置,禁止提交 .env、打印连接串或口令。所有数据库变更使用版本化迁移。 +- 功能与真实数据能力必须如实呈现;不得用演示结果冒充在线统计。 +- 更改后完成对应测试、go vet、类型检查与构建。原始两个设计文件保留。 +- PostgreSQL 开发地址为 `192.168.100.127:5432`,数据库名 `veyra`;Redis 为 `192.168.100.127:6379`,无用户名、有密码。实际凭据只从本地 `.env` 读取。 +- 本机与数据库处于同一内网。若工具返回 `Operation not permitted`,应区分执行沙箱权限限制与主机网络故障,不因此修改服务地址。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5b71c39 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: api build db-create db-migrate admin-create user-create test check admin-dev admin-build +export GOCACHE := $(CURDIR)/.cache/go-build + +api: admin-build + go run ./services/api/cmd/api -env-file .env +build: admin-build + mkdir -p bin + go build -trimpath -o bin/veyra-api ./services/api/cmd/api + go build -trimpath -o bin/veyra-db ./services/api/cmd/db + go build -trimpath -o bin/veyra-user ./services/api/cmd/user +db-create: + go run ./services/api/cmd/db -env-file .env -action create +db-migrate: + go run ./services/api/cmd/db -env-file .env -action migrate +admin-create: + go run ./services/api/cmd/user -env-file .env -role ADMIN +user-create: + go run ./services/api/cmd/user -env-file .env -role USER +test: + go test -race ./services/api/... +check: + go vet ./services/api/... + npm run build --workspace @veyra/admin +admin-dev: + npm run dev --workspace @veyra/admin +admin-build: + npm run build --workspace @veyra/admin diff --git a/README.md b/README.md new file mode 100644 index 0000000..b86c00b --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# 见势 · Veyra + +AI 量化投资辅助产品。原始交互原型与全栈设计文档保留在根目录;当前交付包括工程基础、开发登录、管理员用户查询与账户初始化。 + +## 已实现 + +- Go 模块化 API、JSON 日志、请求 ID、健康检查、超时与优雅停机。 +- PostgreSQL 建库命令、事务迁移、并发迁移锁及历史迁移 checksum 校验。 +- `veyra_user` / `User`、`veyra_account` / `Account`;主键为 `varchar(36)` UUID,Go 使用 string。 +- 所有表、字段、索引按项目规范命名,包括 `veyra_schema_migration`。 +- Vite + React + TypeScript + Tailwind CSS 4 + shadcn/ui 管理端,暖白、炭黑、焦糖橙主题,构建产物通过 Go embed 嵌入 API 二进制。 +- 管理端读取 API 实际探测结果,十个工作区入口已建立,尚未实现的业务页面明确显示建设中。 +- 管理端登录、HttpOnly Cookie 会话、退出撤销、用户列表;Redis 按来源 IP 和登录名限流,依赖失败时拒绝登录。 +- App 开发登录与 Bearer 会话;管理端和 App 会话互相隔离,数据库只存凭据哈希。 +- 账户创建、列表与汇总;模拟账户初始现金与流水原子入账,金额使用整数分,API 使用十进制字符串。真实记录账户现金为未知。 + +## 启动 + +需要 Go 1.26.2 与支持 Vite 的 Node.js,已提交 Go 和 npm 锁文件。根目录执行: + +```sh +cp .env.example .env +``` + +将 PostgreSQL 用户名与密码填写到 `.env`。默认数据库名称 `veyra`,默认管理数据库 `postgres`;创建数据库需要连接角色具有 CREATEDB 权限。Redis 无用户名,有密码:在本地 `.env` 配置 `REDIS_PASSWORD`,保持 `REDIS_USERNAME` 为空。`PGSSLMODE=disable` 是本地模板值,如服务器要求 TLS 则修改。 + +```sh +go mod download -C services/api +npm ci +make db-create +make db-migrate +make admin-create +make api +``` + +启动后访问 `http://127.0.0.1:8080`,管理端由 Go embed 提供,页面与 API 同源。需要前端热更新时另开终端运行 `make admin-dev`,访问 Vite 输出的本地地址;开发代理将 `/api` 转发到 `127.0.0.1:8080`。API 可在数据库未配置时启动并如实报告不可就绪。 + +管理端初始登录名与密码由 `.env` 的 `ADMIN_LOGIN` / `ADMIN_PASSWORD` 配置,密码要求 12–72 字节。开发环境的本地 `.env` 已单独生成随机密码,模板不包含密码。`make admin-create` 创建身份,不覆盖已有管理员。App 测试身份使用 `DEV_USER_LOGIN` / `DEV_USER_PASSWORD` 和 `make user-create` 初始化。数据库的登录账号不用于产品登录。 + +已有数据库只需 `make db-migrate` 应用新增的 `000002_identity_account.sql`,无需重新建库。 + +`make build` 先构建前端并复制到 embed 目录,再输出 `bin/veyra-api`、`bin/veyra-db` 和 `bin/veyra-user`。运行 `./bin/veyra-api -env-file .env` 无需外置前端文件或 Node.js。修改前端必须重新构建 Go 二进制;直接 `go build` 不会自动编译前端。清空构建产物的 checkout 仍可编译基础 API,管理端返回明确的 503 提示直到完成前端构建。 + +### 账户 API 示例 + +测试身份初始化后,`POST /api/v1/auth/login` 使用 `login_name` 和 `password` 返回开发访问凭据。后续请求使用 `Authorization: Bearer `。 + +`POST /api/v1/accounts` 的模拟账户请求: + +```json +{"mode":"simulation","name":"模拟账户","initial_cash":"100000.00"} +``` + +真实记录账户请求省略 `initial_cash`,返回 `cash_balance: null`、`ledger_status: INCOMPLETE`。同一用户每种模式只可创建一个账户;重复创建返回 409,不重复生成初始资金。`GET /api/v1/accounts` 返回当前用户账户,`GET /api/v1/accounts/{id}/summary` 查询归属当前用户的账户,其他用户的 ID 返回 404。 + +### 开发登录边界 + +密码登录和命令行身份初始化仅在 `APP_ENV=local/test` 开启;其他环境拒绝开发登录。正式 App 短信登录、访问凭据刷新、管理端 MFA 与细分 RBAC 尚未实现。当前会话为 8 小时、服务端可撤销;Cookie 写操作要求同来源 Origin。开发模式可使用 Vite 的同源代理,部署代理的 HTTPS 信任配置将在正式认证阶段实现。 + +```sh +make test +make check +``` + +PostgreSQL 集成测试需显式提供测试数据库,测试在独立临时 schema 内运行并清理: + +```sh +VEYRA_TEST_DATABASE_URL='postgres://user:password@host:5432/test_db?sslmode=disable' make test +``` + +## 目录 + +```text +apps/admin/ Vite React 管理端 +services/api/cmd/api/ API 入口 +services/api/cmd/db/ 建库与迁移入口 +services/api/cmd/user/ 开发身份初始化入口 +services/api/internal/identity/ 身份领域 +services/api/internal/portfolio/ 账户领域 +services/api/internal/operations/ 服务健康业务 +services/api/internal/platform/ 配置与数据库设施 +services/api/internal/transport/ HTTP 协议适配 +services/api/api/openapi.yaml 当前 HTTP 契约 +``` + +采用显式 SQL 和结构体 db tag,避免 ORM 隐式推断表名。迁移打包到 Go 二进制,已执行迁移不可改写,后续修改新增版本。API 启动不自动建库或迁移。健康检查不会返回连接串和供应商错误细节。 + +## 待完成 + +用户已确认执行首次建库迁移。此次新增第二版迁移和身份初始化需在可访问数据库的终端运行;当前执行环境禁止访问目标内网地址和监听本地端口。单元测试及无监听的 HTTP 测试可以本地执行,真实数据库集成测试只有配置测试连接时才会运行。 + +尚未接入候选审核、交易计划、成交账本、App UI、行情、AI、Worker 或回测。下一步建设候选修订、审核发布与计划闭环,再落实成交账本和风险监控。 + +管理端基础组件按 [shadcn/ui 的 Vite 集成方式](https://ui.shadcn.com/docs/installation/vite)维护在 `src/components/ui`,Tailwind 使用[官方 Vite 插件](https://tailwindcss.com/docs/installation/using-vite)。 diff --git a/apps/admin/components.json b/apps/admin/components.json new file mode 100644 index 0000000..ce887a3 --- /dev/null +++ b/apps/admin/components.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", "rsc": false, "tsx": true, + "tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true }, + "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, + "iconLibrary": "lucide" +} diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..9d13307 --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,2 @@ + +见势 · Veyra 管理端
diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..97af434 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,31 @@ +{ + "name": "@veyra/admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc --noEmit && vite build && node scripts/embed.mjs", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "react": "19.2.4", + "react-dom": "19.2.4", + "lucide-react": "0.468.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "tailwind-merge": "3.5.0", + "@radix-ui/react-slot": "1.2.3" + }, + "devDependencies": { + "vite": "6.4.3", + "@vitejs/plugin-react": "4.7.0", + "typescript": "5.9.3", + "@types/node": "22.19.19", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "tailwindcss": "4.2.4", + "@tailwindcss/vite": "4.2.4", + "tw-animate-css": "1.4.0" + } +} diff --git a/apps/admin/scripts/embed.mjs b/apps/admin/scripts/embed.mjs new file mode 100644 index 0000000..3a2c24d --- /dev/null +++ b/apps/admin/scripts/embed.mjs @@ -0,0 +1,8 @@ +import { cp, mkdir, rm } from "node:fs/promises" +const target = new URL("../../../services/api/internal/platform/adminui/dist/", import.meta.url) +await mkdir(target, { recursive: true }) +// Preserve the tracked .keep so a clean checkout can build the foundation API. +const { readdir } = await import("node:fs/promises") +for (const file of await readdir(target)) if (file !== ".keep") await rm(new URL(file, target), { recursive: true, force: true }) +await cp(new URL("../dist/", import.meta.url), target, { recursive: true }) +console.log("Management console copied into Go embed assets.") diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx new file mode 100644 index 0000000..0a5deae --- /dev/null +++ b/apps/admin/src/App.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from "react" +import { Activity, ArrowUpRight, BarChart3, Bell, BookOpen, ChevronRight, CircleDot, Database, FlaskConical, LayoutDashboard, LockKeyhole, RefreshCw, Server, Settings2, ShieldCheck, Sparkles, Users } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { getHealth, type Health } from "@/lib/api" +import {type User} from "@/lib/auth" +import {UsersPanel} from "@/components/UsersPanel" + +const workspaces = [ + {name:"总览", icon:LayoutDashboard}, {name:"候选审核",icon:BookOpen}, {name:"策略中心",icon:Settings2}, + {name:"回测研究",icon:FlaskConical}, {name:"数据健康",icon:Database}, {name:"模型配置",icon:Sparkles}, + {name:"通知任务",icon:Bell}, {name:"用户管理",icon:Users}, {name:"审计日志",icon:ShieldCheck}, {name:"系统设置",icon:LockKeyhole}, +] +const label = {ready:"连接正常",unavailable:"无法连接",not_configured:"待配置"} + +export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout:()=>Promise;onSessionExpired:()=>void}) { + const [page,setPage] = useState("总览") + const [health,setHealth] = useState(null) + const [error,setError] = useState("") + const [loading,setLoading] = useState(false) + const [logoutError,setLogoutError] = useState("") + const [signingOut,setSigningOut] = useState(false) + async function signOut() {setSigningOut(true);setLogoutError("");try {await onLogout()} catch(e) {setLogoutError(e instanceof Error ? e.message : "退出失败,请重试。")} finally {setSigningOut(false)}} + const controller = useRef(null) + async function refresh() { + controller.current?.abort() + const next = new AbortController() + controller.current = next + setLoading(true) + try { const data = await getHealth(next.signal); if (!next.signal.aborted) {setHealth(data);setError("")} } + catch (e) { if (!next.signal.aborted) {setHealth(null);setError(e instanceof Error ? e.message : "暂时无法连接 API 服务")} } + finally {if (!next.signal.aborted) setLoading(false)} + } + useEffect(()=>{void refresh();return ()=>controller.current?.abort()},[]) + const overview = page === "总览" || page === "数据健康" + return
+ +
+
工作空间 {page}
{user.display_name}LOCAL · 开发环境
+
+ {logoutError &&

{logoutError}

} +

VEYRA / {overview ? "SYSTEM OVERVIEW" : "WORKSPACE"}

{page === "总览" ? "把每一步,建立在依据之上。" : page}

{overview ? "查看服务连接与建设进度,从可靠的基础开始。" : "该工作区的业务功能正在建设中。"}

{overview && }
+ {overview ? <> + {error &&
无法读取当前服务状态。请检查 API 是否启动。{error}
} +
{[ + {title:"API 服务",subtitle:"Go · Gin",icon:Server,value:loading ? "检查中" : health ? "运行中" : "未连接"}, + {title:"PostgreSQL",subtitle:"业务事实与版本化迁移",icon:Database,value:loading ? "检查中" : health ? label[health.components.postgresql.status] : "未知"}, + {title:"Redis",subtitle:"缓存与订阅索引",icon:Activity,value:loading ? "检查中" : health ? label[health.components.redis.status] : "未知"}, + ].map(({title,subtitle,icon:Icon,value})=>
{title}

{value}

{subtitle}

)}
+
第一条产品闭环建设中
先验证完整流程,再扩大策略与数据范围。
    {["管理端审核并发布观察候选","用户查看证据,确认买入计划","条件满足后发送提醒","记录实际成交,建立持仓监控","记录退出成交,生成成本后复盘"].map((v,i)=>
  1. 0{i+1}{v}待实现
  2. )}
数据接入后,开始观察。候选数量、策略表现与账户指标将在业务功能与数据接入完成后展示。
当前状态来源:Go API 实时探测
最近检查:{health ? new Date(health.checked_at).toLocaleString("zh-CN") : "尚未取得状态"}
+ : page==="用户管理" ? :

{page}尚未开放

该模块完成业务接口、权限与验证后开放。

} +
见势 · Veyra / 可追溯的投资决策工程基础 v0.1
+
+
+
+} diff --git a/apps/admin/src/Console.tsx b/apps/admin/src/Console.tsx new file mode 100644 index 0000000..2bf244f --- /dev/null +++ b/apps/admin/src/Console.tsx @@ -0,0 +1,38 @@ +import { useEffect,useState,useCallback,type FormEvent } from "react" +import { ArrowRight,LoaderCircle } from "lucide-react" +import App from "./App" +import {Button} from "@/components/ui/button" +import {Input} from "@/components/ui/input" +import {ApiError,getMe,login,logout,type User} from "@/lib/auth" + +export default function Console() { + const [user,setUser]=useState(null) + const [checking,setChecking]=useState(true) + const [loginName,setLoginName]=useState("") + const [password,setPassword]=useState("") + const [busy,setBusy]=useState(false) + const [error,setError]=useState("") + useEffect(()=>{ + const controller=new AbortController() + getMe(controller.signal).then(setUser).catch(e=>{if (!controller.signal.aborted && !(e instanceof ApiError && e.status===401)) setError(e instanceof Error ? e.message : "无法连接服务。")}).finally(()=>{if (!controller.signal.aborted) setChecking(false)}) + return ()=>controller.abort() + },[]) + async function submit(event:FormEvent) { + event.preventDefault() + setBusy(true);setError("") + try {const result=await login(loginName,password);setUser(result.user);setPassword("")} + catch(e) {setError(e instanceof Error ? e.message : "登录失败,请重试。")} + finally {setBusy(false)} + } + async function signOut() { + try {await logout();setUser(null);setError("")} + catch(e) {if (e instanceof ApiError && e.status===401) {setUser(null);return};throw e} + } + const sessionExpired=useCallback(()=>setUser(null),[]) + if (checking) return
正在验证管理端会话…
+ if (user) return + return
+
V见势 · Veyra

OPERATIONS CONSOLE

看清依据,
守住每一步决策。

从候选审核到策略监控,让每条证据、每次变化与每项操作都有记录。

见势 · Veyra / 管理工作空间

+

VEYRA / ADMIN

登录管理端

使用已创建的管理员账号继续。

void submit(event)} className="space-y-5">
setLoginName(e.target.value)} autoComplete="username" minLength={3} maxLength={64} required disabled={busy}/>
setPassword(e.target.value)} autoComplete="current-password" maxLength={72} required disabled={busy}/>
{error &&

{error}

}

当前登录用于本地开发验证。正式管理端将在多因素认证完成后开放。

+
+} diff --git a/apps/admin/src/components/UsersPanel.tsx b/apps/admin/src/components/UsersPanel.tsx new file mode 100644 index 0000000..ac0593e --- /dev/null +++ b/apps/admin/src/components/UsersPanel.tsx @@ -0,0 +1,26 @@ +import {useEffect,useState} from "react" +import {RefreshCw,Users} from "lucide-react" +import {ApiError,getUsers,type User} from "@/lib/auth" +import {Button} from "@/components/ui/button" +import {Badge} from "@/components/ui/badge" +import {Card,CardHeader,CardTitle,CardDescription,CardContent} from "@/components/ui/card" + +export function UsersPanel({onSessionExpired}:{onSessionExpired:()=>void}) { + const [users,setUsers]=useState([]) + const [error,setError]=useState("") + const [loading,setLoading]=useState(true) + const [revision,setRevision]=useState(0) + useEffect(()=>{ + const controller=new AbortController() + setLoading(true) + getUsers(controller.signal).then(data=>{if (!controller.signal.aborted) {setUsers(data);setError("")}}).catch(e=>{ + if (controller.signal.aborted) return + if (e instanceof ApiError && e.status===401) {onSessionExpired();return} + setError(e instanceof Error ? e.message : "无法读取用户。") + }).finally(()=>{if (!controller.signal.aborted) setLoading(false)}) + return ()=>controller.abort() + },[revision,onSessionExpired]) + return
用户列表
按创建时间显示最近 100 位 App 用户。
+ {error ?

{error}

: loading ?

正在加载用户…

: users.length===0 ?

暂时没有 App 用户

:
{users.map(user=>)}
用户状态创建时间

{user.display_name}

{user.id}

{user.status==="ACTIVE" ? "正常" : "已停用"}{new Date(user.created_at).toLocaleString("zh-CN")}
} +
+} diff --git a/apps/admin/src/components/ui/badge.tsx b/apps/admin/src/components/ui/badge.tsx new file mode 100644 index 0000000..b9df1ef --- /dev/null +++ b/apps/admin/src/components/ui/badge.tsx @@ -0,0 +1,6 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import {cn} from "@/lib/utils" +const badgeVariants = cva("inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium", { variants: {variant: {default: "border-transparent bg-accent text-accent-foreground", secondary:"border-transparent bg-secondary text-secondary-foreground", outline:"text-muted-foreground"}}, defaultVariants:{variant:"default"} }) +function Badge({className,variant,...props}: React.ComponentProps<"span"> & VariantProps) { return } +export {Badge} diff --git a/apps/admin/src/components/ui/button.tsx b/apps/admin/src/components/ui/button.tsx new file mode 100644 index 0000000..29405f4 --- /dev/null +++ b/apps/admin/src/components/ui/button.tsx @@ -0,0 +1,17 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + { variants: { + variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", outline: "border border-input bg-card hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground" }, + size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", icon: "h-10 w-10" }, + }, defaultVariants: { variant: "default", size: "default" } }, +) +function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "button" + return +} +export { Button, buttonVariants } diff --git a/apps/admin/src/components/ui/card.tsx b/apps/admin/src/components/ui/card.tsx new file mode 100644 index 0000000..dffba4a --- /dev/null +++ b/apps/admin/src/components/ui/card.tsx @@ -0,0 +1,8 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +function Card({className, ...props}: React.ComponentProps<"div">) { return
} +function CardHeader({className, ...props}: React.ComponentProps<"div">) { return
} +function CardTitle({className, ...props}: React.ComponentProps<"h2">) { return

} +function CardDescription({className, ...props}: React.ComponentProps<"p">) { return

} +function CardContent({className, ...props}: React.ComponentProps<"div">) { return

} +export {Card, CardHeader, CardTitle, CardDescription, CardContent} diff --git a/apps/admin/src/components/ui/input.tsx b/apps/admin/src/components/ui/input.tsx new file mode 100644 index 0000000..6a2f793 --- /dev/null +++ b/apps/admin/src/components/ui/input.tsx @@ -0,0 +1,6 @@ +import * as React from "react" +import {cn} from "@/lib/utils" +function Input({className,type,...props}:React.ComponentProps<"input">) { + return +} +export {Input} diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css new file mode 100644 index 0000000..4865fd7 --- /dev/null +++ b/apps/admin/src/index.css @@ -0,0 +1,32 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--foreground); + --color-primary: var(--primary); + --color-primary-foreground: #ffffff; + --color-secondary: #eee9e2; + --color-secondary-foreground: var(--foreground); + --color-muted: #f1eee8; + --color-muted-foreground: #79736c; + --color-accent: #f6e9da; + --color-accent-foreground: #995e2e; + --color-border: #e7e2da; + --color-input: #e7e2da; + --color-ring: #ac723f; + --color-destructive: #b34d45; + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; +} +:root { --background: #f8f6f2; --foreground: #302c28; --card: #fffefa; --primary: #a86b35; } +@layer base { + * { @apply border-border; } + body { @apply bg-background text-foreground antialiased; margin: 0; font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; } + button:not(:disabled) { cursor: pointer; } + button:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 4px; } +} diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts new file mode 100644 index 0000000..22b4b47 --- /dev/null +++ b/apps/admin/src/lib/api.ts @@ -0,0 +1,16 @@ +export type ComponentStatus = "ready" | "unavailable" | "not_configured" +export type Health = { + status: "ready" | "not_ready" + checked_at: string + components: { postgresql: { status: ComponentStatus }; redis: { status: ComponentStatus } } +} +export async function getHealth(signal?: AbortSignal): Promise { + const response = await fetch("/api/v1/system/health", { signal, headers: { Accept: "application/json" } }) + if (!response.ok && response.status !== 503) throw new Error(`服务请求失败(${response.status})`) + const data: unknown = await response.json() + if (!data || typeof data !== "object" || !("status" in data) || !("components" in data) || !("checked_at" in data)) throw new Error("服务返回了无效的状态数据") + const health = data as Health + const statuses = ["ready", "unavailable", "not_configured"] + if (!["ready", "not_ready"].includes(health.status) || typeof health.checked_at !== "string" || !statuses.includes(health.components?.postgresql?.status) || !statuses.includes(health.components?.redis?.status)) throw new Error("服务返回了无效的状态数据") + return health +} diff --git a/apps/admin/src/lib/auth.ts b/apps/admin/src/lib/auth.ts new file mode 100644 index 0000000..087398c --- /dev/null +++ b/apps/admin/src/lib/auth.ts @@ -0,0 +1,16 @@ +export type User = {id:string;display_name:string;status:"ACTIVE" | "DISABLED";created_at:string;updated_at:string} +type Envelope = {data:T;meta:{request_id:string;server_time?:string}} +export class ApiError extends Error { + constructor(public status:number,public code:string,message:string) {super(message);this.name="ApiError"} +} +async function request(path:string,init?:RequestInit):Promise { + const response=await fetch(path,{...init,credentials:"same-origin",headers:{Accept:"application/json",...(init?.body ? {"Content-Type":"application/json"} : {}),...init?.headers}}) + const data=await response.json() + if (!response.ok) throw new ApiError(response.status,data.error?.code ?? "REQUEST_FAILED",data.error?.message ?? "请求失败,请稍后重试。") + if (!data || typeof data !== "object" || !("data" in data)) throw new Error("服务返回了无效数据。") + return (data as Envelope).data +} +export function getMe(signal?:AbortSignal) {return request("/api/admin/v1/me",{signal})} +export function login(login_name:string,password:string) {return request<{user:User;expires_at:string}>("/api/admin/v1/auth/login",{method:"POST",body:JSON.stringify({login_name,password})})} +export function logout() {return request<{revoked:boolean}>("/api/admin/v1/auth/logout",{method:"POST"})} +export function getUsers(signal?:AbortSignal) {return request("/api/admin/v1/users",{signal})} diff --git a/apps/admin/src/lib/utils.ts b/apps/admin/src/lib/utils.ts new file mode 100644 index 0000000..e954203 --- /dev/null +++ b/apps/admin/src/lib/utils.ts @@ -0,0 +1,3 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" +export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100644 index 0000000..844f566 --- /dev/null +++ b/apps/admin/src/main.tsx @@ -0,0 +1,6 @@ +import React from "react" +import ReactDOM from "react-dom/client" +import Console from "./Console" +import "./index.css" + +ReactDOM.createRoot(document.getElementById("root")!).render() diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..fcf60f2 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", + "strict": true, "skipLibCheck": true, "noEmit": true, "esModuleInterop": true, + "resolveJsonModule": true, "noUnusedLocals": true, "noUnusedParameters": true, + "baseUrl": ".", "paths": {"@/*": ["./src/*"]}, "types": ["vite/client", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100644 index 0000000..7d63047 --- /dev/null +++ b/apps/admin/vite.config.ts @@ -0,0 +1,10 @@ +import { fileURLToPath, URL } from "node:url" +import { defineConfig } from "vite" +import react from "@vitejs/plugin-react" +import tailwindcss from "@tailwindcss/vite" + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, + server: { proxy: { "/api": "http://127.0.0.1:8080", "/healthz": "http://127.0.0.1:8080" } }, +}) diff --git a/docs/development-state.md b/docs/development-state.md new file mode 100644 index 0000000..70bfb29 --- /dev/null +++ b/docs/development-state.md @@ -0,0 +1,60 @@ +# 开发记忆与交接状态 + +更新日期:2026-10-06。 + +## 产品与依据 + +- 产品名称「见势 · Veyra」,面向从空仓开始的 A 股投资者。 +- 根目录 `hengce_fullstack_technical_design_v1.md` 为实施设计,`hengce_app_admin_prototype.html` 为双端交互与视觉参考,均保留。 +- 目标闭环:偏好 → 观察候选 → 证据 → 买入计划 → 条件提醒 → 用户记录成交 → 持仓监控 → 退出 → 复盘。 +- Go 负责业务、账本与风控;Python 负责量化研究与回测;App 使用 React Native + Expo。 +- 首期用户自行交易,提醒不代表成交,不自动下单。 + +## 用户确认的硬性规则 + +- 后端严格遵循 Go 工程规范,采用 cmd/internal 分层、依赖注入、显式 SQL、context、错误处理及 gofmt/go vet。 +- 所有数据库表以 `veyra_` 开头,字段 snake_case。 +- 主键为字符串 UUID:数据库 varchar(36),Go string;外键同类型。 +- 映射去前缀后 PascalCase,例如 `veyra_user` 对应 `User`。 +- 管理端使用 Vite + React + Tailwind CSS + shadcn/ui,并通过 Go embed 嵌入后端。同一二进制提供页面和 API。 +- PostgreSQL:192.168.100.127:5432,数据库 veyra;Redis:192.168.100.127:6379,无用户名、有密码。 +- 凭据保存在本地 `.env`,禁止写进 Git 或项目记忆。 + +## 已实现 + +- Go API:健康检查、结构化日志、请求 ID、超时、优雅停机。 +- 独立建库与迁移命令,迁移使用事务、并发锁、checksum 和字符串 UUID 迁移记录。 +- 两版迁移:基础用户/账户;身份凭据/会话/账户状态/初始现金流水。 +- local/test 开发身份初始化;密码 bcrypt 哈希、随机会话凭据、数据库只保存会话哈希。 +- 管理端 Cookie 和 App Bearer 会话隔离,退出撤销、过期与状态校验、管理员权限校验。 +- Redis 登录限流;管理端 Cookie 为 HttpOnly、SameSite=Strict,写操作校验同源 Origin。 +- 账户创建、归属查询、账户列表;模拟资金初始化原子入账,整数分计算、金额字符串 API;真实记录账户现金未知。 +- 管理端登录页、服务总览、用户列表和十个工作区入口;未实现业务页面显示建设中。 +- OpenAPI、依赖锁文件、Makefile 和带 PostgreSQL 集成测试服务的 CI。 +- `make build` 生成内嵌管理端的 veyra-api,以及 veyra-db 和 veyra-user。 + +## 验证与环境事实 + +- 已通过前端 TypeScript 检查与生产构建、Go 编译、go vet、单元与无监听 HTTP 测试(含 race 检查)。 +- OpenAPI YAML 已解析,内部引用检查通过。 +- 数据库集成测试代码包含迁移幂等、checksum、UUID、身份事务、会话撤销/过期、Cookie/Origin 和账户隔离/现金初始化验证。 +- 本轮未实际执行数据库集成测试:执行环境不能连接内网;未设置测试数据库连接时这些测试明确 skip,不能报告为已验证数据库行为。 +- 用户确认本机和数据库处于同一内网,并已在本机终端执行首次建库/迁移。 +- 命令执行沙箱访问 PostgreSQL/Redis 的 TCP 连接返回 errno=1 Operation not permitted,本地监听同样受限。这不表示用户主机网络有问题。 +- 最新第二版迁移与管理员初始化是否在用户终端执行,尚未获得明确确认;不能假定已执行成功。 + +## 启动与后续 + +- 已初始化数据库使用 `make db-migrate` 应用后续迁移,无需重复建库。 +- 管理端初始化:`.env` 的 ADMIN_LOGIN / ADMIN_PASSWORD,执行 `make admin-create`;App 测试身份使用 DEV_USER_LOGIN / DEV_USER_PASSWORD 和 `make user-create`。 +- 本地 `.env` 已生成独立开发身份随机密码;模板无密码,已有身份不会由初始化命令覆盖。 +- `make api` 先构建前端,再运行后端;默认 http://127.0.0.1:8080。 +- 当前开发密码登录仅限 local/test;正式短信登录、刷新流程、MFA 和细分 RBAC 待实现。 +- 下一步:候选修订与审核发布、计划预览与确认,再实现成交账本、持仓监控和复盘。 +- 行情、AI、Worker、真实回测与 App UI 尚未实现;不得把当前状态描述为完整产品已交付。 + +## Git 操作请求 + +用户本轮明确要求保存记忆、git add、commit 和 push。目标为当前 master 分支的 origin;操作结果以实际 Git 输出为准,禁止将失败的提交或推送记作已完成。 + +本轮操作结果:项目记忆与忽略规则已保存,候选文件检查未发现本地配置的长密码。仓库当前没有提交;git add 因无法创建 .git/index.lock(Operation not permitted)失败,尚未 commit/push。远程分支读取失败,原因是执行环境无法解析远程域名。后续需在有 Git 写权限和网络访问能力的终端完成提交与推送,先确认远程状态;不强制推送、不覆盖远程历史。 diff --git a/go.work b/go.work new file mode 100644 index 0000000..272037e --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.26.2 + +use ./services/api diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..113a675 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,32 @@ +github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/hengce_app_admin_prototype.html b/hengce_app_admin_prototype.html new file mode 100644 index 0000000..9edb0fc --- /dev/null +++ b/hengce_app_admin_prototype.html @@ -0,0 +1,215 @@ + + +见势 · Veyra | App 与管理端交互原型 + + + +
V
见势 · Veyra产品交互原型 / App V2
+
所有股票、行情、绩效及用户均为虚构演示;操作只改变此文件的本地状态,不连接券商或后台。
+
+
+
+
09:415G
+
演示账户 · 股票与行情均为模拟
+
+ +
+ + +
+
+ + +
+ + +
+ + + + + diff --git a/hengce_fullstack_technical_design_v1.md b/hengce_fullstack_technical_design_v1.md new file mode 100644 index 0000000..c95dbdf --- /dev/null +++ b/hengce_fullstack_technical_design_v1.md @@ -0,0 +1,1321 @@ +# 见势 · Veyra AI 量化分析产品:前后端完整设计报告 + +> 版本:V1.1 · 编写/更新日期:2026-10-04 +> 设计基线:`hengce_app_admin_prototype.html` 双端交互原型;App V2 采用白底、石墨黑与少量柔紫。 +> 本次更新:统一品牌为「见势 · Veyra」,同步 App 视觉 Token、页面层级与组件规范。 +> 面向:产品、设计、App、Web、Go 后端、量化研究、测试与运维团队。 +> 本文是实施设计,尚未连接真实行情、模型或券商。文中容量、时延、参数与排期是规划假设,需通过实际测试确认。 + +## 目录 + +1. 产品目标与范围 +2. 原型到正式产品的映射 +3. 技术栈与选型 +4. 总体架构与职责边界 +5. 核心业务流程与状态机 +6. App 前端设计 +7. 管理端前端设计 +8. Go 后端设计 +9. 量化策略与风险引擎 +10. AI 分析与模型接入 +11. 行情、公告与实时分析链路 +12. 数据库与存储设计 +13. API 与实时事件协议 +14. 异步任务、推送与一致性 +15. 权限、安全与商业上线边界 +16. 回测、模拟交易与复盘 +17. 部署、可观测性与容灾 +18. 测试与验收标准 +19. 开发目录与团队协作 +20. 分阶段实施计划 +21. 容量、成本与扩展路径 +22. 关键决策、待定项与开发起点 +23. 官方参考资料 + +## 1. 产品目标与范围 + +### 1.1 产品定位 + +见势 · Veyra 面向从空仓开始的个人投资者,以 App 为主,通过授权行情、确定性策略、AI 证据解释和持仓风险监控,帮助用户形成可执行、可追踪、可复盘的交易决策。中文名为「见势」,英文名为「Veyra」,完整品牌名统一写作「见势 · Veyra」。 + +产品的完整闭环为:**设置偏好 → 全市场筛选 → 发现候选 → 理解证据 → 生成买入计划 → 条件触发提醒 → 用户确认实际成交 → 持仓监控 → 卖出/减仓/加仓建议 → 成交记录 → 复盘**。 + +“收益最大化”转为工程上可验证的目标:在资金、风险、流动性和交易制度约束下,比较成本后的策略表现与风险调整后收益。产品不承诺盈利,也不能把模型表达的确定性当成真实胜率。 + +### 1.2 本报告采用的实施假设 + +| 项目 | 本版决定 | +| --- | --- | +| 首期市场 | 沪深 A 股普通股票;板块支持采用白名单,主板先上线,创业板、科创板逐步通过规则验收 | +| 用户初始状态 | 无持仓;不会强迫用户先导入股票才提供候选 | +| 投资周期 | 数天至数周为主,后续支持数周至数月 | +| 实时口径 | 盘中基于授权行情更新,分钟级策略评估;前台事件更新,后台系统推送 | +| 初期执行方式 | 用户在券商自行交易,App 记录成交;不自动下单 | +| 首期账户 | 独立模拟账户与手工真实记录账户;真实记录不代表券商核验结果 | +| 推荐发布 | 候选经策略、质量与风险检查,再进入人工审核;个性化计划另受用户适配和服务权限约束 | +| 管理端 | 独立 Web 控制台,覆盖原型的 10 个工作区 | +| 技术偏好 | Go 后端;React Native + Expo App | +| App 配色 | 白底、石墨黑、少量柔紫;不使用绿色,不采用蓝色作为品牌主色 | +| 管理端配色 | 保留暖白、炭黑与焦糖橙的现有后台主题;品牌名同步为见势 · Veyra | + +实际证券交易规则按交易所、证券类别、板块和生效日期配置。原型中的“100 股整数倍”只适用于部分演示场景,不能直接复制到所有正式交易场景。 + +### 1.3 首期范围与后续范围 + +| 优先级 | 功能 | +| --- | --- | +| P0:完整闭环 | 登录、偏好、空仓首页、候选列表与证据、买入计划、条件监控、成交录入、持仓、卖出计划、提醒中心、基础复盘 | +| P0:运营支撑 | 候选审核、策略版本、真实回测任务、数据健康、模型配置、推送任务、用户状态、审计、服务开关 | +| P0:可靠性 | 行情时效检查、规则校验、权限、幂等、账本一致性、失败重试、备份恢复 | +| P1:增强体验 | 分批买卖、加仓/减仓细化、组合集中度、事件更新、多策略对照、详细归因、正式适当性流程 | +| P2:扩展 | 经授权的券商成交同步、更多市场与资产、复杂组合优化、更细行情、机构工作流 | + +授权与资格具备后才能开放相应真实建议服务。研发阶段仍可完成全部流程的模拟验证。 + +### 1.4 首期暂不建设 + +高频交易、杠杆融资、期权、自动券商下单、用户自定义任意脚本执行、收益保证、由大模型独立决定价格与仓位,均不进入首期。 + +## 2. 原型到正式产品的映射 + +### 2.1 保留已确认的交互 + +| 原型页面/交互 | 正式产品实现 | 必须补齐的能力 | +| --- | --- | --- | +| 投资偏好 | 用户偏好、资金参考、关注周期、板块选择 | 与正式风险评估、板块交易权限分开存储 | +| 空仓首页 | 市场摘要、有效候选、等待中的计划 | 无机会、休市、行情过期、服务异常等空状态 | +| 机会与股票详情 | 趋势、回撤、事件候选;逻辑/计划/资料 | 数据来源、证据定位、反证、发布时间、版本、失效时间 | +| 买入计划 | 入场区间、条件、数量上限、止损、目标、期限 | 风险与资金校验、执行可行性、计划预览与确认 | +| 成交录入 | 用户录入买卖价格、数量、成交时间、费用 | 幂等、可卖数量、分批成交、补录、纠错与审计 | +| 持仓监控 | 成本、浮盈亏、可卖数量、风险变化 | 批次账本、真实/模拟隔离、停牌与 T+1 提示 | +| 卖出建议 | 风险止损、目标分批止盈、逻辑失效、时间退出 | 建议数量与可执行数量分离,用户实际成交另记 | +| 提醒中心 | 持久化站内通知与深链 | 推送渠道状态、未读、过期、去重、补拉 | +| 管理端发布/撤回 | 服务端审核流程 | 发布事务、撤回事件、历史版本不可改写 | +| 策略编辑 | 创建新版本并激活 | 参数校验、回测证明、灰度、回滚 | +| 回测演示 | Python 执行真实任务,保存报告 | 时点数据、成本、不可成交情形、样本外验证 | +| 数据故障模拟 | 自动采集健康、故障告警与降级 | 阻断新建议,同时保留历史成交记录能力 | +| 模型连接测试 | 后端受控调用与 Schema 验证 | 密钥管理、费用、版本、超时、权限 | +| 角色切换 | 真实后台身份与权限 | MFA、服务端 RBAC、对象权限、双人复核 | + +### 2.2 原型限制的替换原则 + +原型是单文件、本地共享状态的交互演示。正式产品中,App 与管理端各自独立发布,统一连接 Go API;PostgreSQL 是业务事实来源,Redis 与客户端缓存只负责加速。 + +原型固定资金、日期、用户、股票与预设回测结果全部替换为真实账户配置和授权数据。模拟数据必须有独立环境与标签,不能混入正式候选、报告或运营指标。原型里的“推送成功”拆成网关受理、通道交接、设备观察和用户已读,不能只用一个成功字段表示全部阶段。 + +## 3. 技术栈与选型 + +### 3.1 推荐技术栈 + +以下是针对本项目的工程选型,不是供应商性能排名。 + +| 层级 | 推荐技术 | 选型理由/边界 | +| --- | --- | --- | +| App | React Native + Expo + TypeScript | 符合你的开发习惯,覆盖 iOS 与 Android | +| App 路由 | Expo Router | 用页面路由组织流程和推送深链 [R1] | +| 服务端状态 | TanStack Query | 查询缓存、失效更新、加载与错误状态;不代替账本事实 [R2] | +| 本地交互状态 | Zustand | 筛选、草稿、主题、当前账户;正式写操作必须等待服务端确认 | +| App 表单 | React Hook Form + Zod | 表单反馈与契约校验;服务端再次验证 | +| App UI | 自建主题组件 + react-native-svg | 延续已确认视觉,首期折线/简单 K 线使用 SVG | +| 长列表 | FlashList,性能验证后采用 | 提醒、候选和成交列表;不先引入复杂图形引擎 | +| App 凭据 | expo-secure-store | 存放刷新凭据等敏感小数据,不保存完整持仓数据库 [R3] | +| App 推送 | expo-notifications + 推送适配层 | 使用开发构建测试;国内 Android 另适配厂商/聚合推送 [R4] | +| App 构建 | Expo Development Build;EAS 或受控本地构建 | 原生推送 SDK 通过 config plugin 集成;商店包与测试包隔离 | +| 管理端 | React + TypeScript + Vite | 独立 SPA,后台不需要首期 SSR | +| 管理端 UI | Ant Design,统一覆盖主题 Token | 表格、表单、弹窗;默认蓝色和绿色状态都替换 [R5] | +| 管理端图表 | Apache ECharts | 回测净值、回撤、数据延迟;图表数值来自服务端 | +| Go HTTP | Gin + 标准库 | 清晰路由与中间件,业务不依赖 Gin Context [R6] | +| Go 数据访问 | pgx + sqlc | 显式 SQL、强类型生成、事务边界可审查 [R7] | +| Go 迁移 | goose | 版本化迁移;生产迁移独立任务执行 | +| Go 资金计算 | 定点整数/经审核的 Decimal 库 | 禁止 float64 用于账本金额、价格与费用比较 | +| 量化计算 | Python + NumPy + pandas + PyArrow | 因子、回测与批量研究;算法和数据版本化 | +| Python 内部 API | FastAPI + Pydantic | 接收受控计算任务和返回结构化结果,不直接写用户账本 | +| 业务数据库 | PostgreSQL | 账户、计划、账本、版本、审核、任务;利用行锁保证并发约束 [R8] | +| 缓存 | Redis | 快照、订阅索引、限流、短期事件提示;可重建 | +| 首期异步队列 | PostgreSQL Job 表 + Outbox | 任务状态和业务事务一起持久化;减少组件数量 | +| 文件与研究数据 | S3 兼容对象存储 + Parquet | 原始数据、时点快照、回测报告、证据文件 | +| 模型主路由 | 阿里云百炼 Qwen 固定快照 | 初始 `qwen3.7-plus-2026-05-26`,结构化分析与工具使用 [R9] | +| 模型备用路由 | DeepSeek API | 初始 `deepseek-flash`;别名对应版本可能变化,保存供应商版本信息 [R10] | +| 协议 | REST + OpenAPI;前台 WebSocket | 首期内部 HTTP JSON;高吞吐时再引入 gRPC | +| 可观测性 | OpenTelemetry + Prometheus + Grafana + 日志平台 | 统一事件、任务、模型与 API 链路 | +| 交付 | Docker + CI;本地 Compose | 首期受控容器部署,不先要求 Kubernetes | + +### 3.2 版本与依赖策略 + +采用启动项目时验证过的稳定版本,而不是在文档里组合未经测试的“最新版本”。Go 选择仍获维护的稳定分支并固定补丁版本;Expo SDK、React Native、React 必须使用 Expo 官方支持的配套组合,通过 `expo install` 安装原生依赖。数据库选择云厂商支持的稳定大版本。[R4][R11] + +交付时必须提交 `go.mod/go.sum`、前端锁文件、Python 锁文件、容器镜像摘要、OpenAPI 版本和模型配置版本。升级先跑契约、账本、真机推送和回测再现验证,再灰度发布。生产模型不得使用未记录变更的自动更新配置。 + +### 3.3 为什么保留 Python + +Go 负责用户请求、状态机、账本、风险门禁和任务调度;Python 负责历史因子、策略研究与回测。成熟量化数值生态更适合 Python,而高并发业务与一致性更适合放在 Go。首期不要把同一条仓位规则分别写在两个语言里:共享版本化规则规格和黄金测试样本,Go 风险引擎为在线最终裁决者,Python 回测必须通过相同规则样本。 + +## 4. 总体架构与职责边界 + +### 4.1 总体拓扑 + +```mermaid +flowchart TD + App["React Native / Expo App"] --> Edge["HTTPS 入口与访问控制"] + Admin["React 管理端"] --> Edge + Edge --> API["Go API:模块化单体"] + API --> DB["PostgreSQL:业务事实与 Outbox"] + API --> Cache["Redis:缓存与订阅索引"] + API --> Storage["对象存储:证据与报告"] + DB --> Worker["Go Worker:任务、监控、推送"] + Worker --> Quant["Python:因子与回测"] + Worker --> AI["模型适配层:主模型与备用模型"] + Feed["授权行情、公告、交易日历"] --> Worker + Worker --> Push["推送网关:APNs、Expo、Android 通道"] + Worker --> DB + Worker --> Cache +``` + +API 和 Worker 可以由同一个 Go 仓库、同一个镜像构建,用不同启动命令运行。模块化单体是代码组织方式,不意味着只有一个进程。Python 独立部署以隔离 CPU/内存密集计算。图中的箭头表示依赖与主要数据流,不表示浏览器能直接访问数据库或供应商密钥。 + +### 4.2 Go 模块边界 + +| 模块 | 职责 | 禁止跨越的边界 | +| --- | --- | --- | +| identity | 登录、设备、会话、管理员身份 | 不暴露其他用户账户 | +| profile | 偏好、板块声明、适当性与授权状态 | 个人偏好不冒充正式评估 | +| market | 证券主数据、行情查询、交易日历、健康 | 不将缓存时间当行情产生时间 | +| strategy | 策略版本、因子规格、运行配置 | 已发布版本不原地修改 | +| research | AI 分析、证据、候选、审核 | 模型不能直接发布建议 | +| planning | 买卖计划、条件、修订与生命周期 | 提醒不能变成实际成交 | +| risk | 数量、资金、权限、时效、可执行性 | 不允许客户端参数绕过规则 | +| portfolio | 账户、成交、持仓批次、现金流水、复盘 | 不依赖推送是否送达修改资产 | +| notification | 站内通知、推送、重试与偏好 | 不把网关受理当用户已读 | +| operations | 数据源、服务开关、模型路由、任务 | 配置变更必须授权和审计 | +| audit | 操作记录、证据快照、敏感导出 | 不允许管理员直接删改历史 | + +模块通过 Application Service 调用,不在 Handler 里跨多个表拼接业务事务。统一 Unit of Work;涉及计划、账本、Outbox 的事务由调用方服务明确管理。 + +### 4.3 初期与扩展架构 + +首期:Go API、Go Worker、Python Quant、托管 PostgreSQL、Redis、对象存储和 Web 静态资源即可。队列放 PostgreSQL,查询缓存放 Redis。 + +增长后:仅按实测瓶颈拆出行情接入、实时订阅网关或通知服务。需要大规模事件回放和分区消费时引入 Kafka/Redpanda;历史行情查询与研究并发超出 PostgreSQL/Parquet 能力时评估 ClickHouse。没有数据证明之前,不先铺设完整微服务系统。 + +### 4.4 环境与数据边界 + +`local / test / staging / production` 分离数据库、对象桶、模型密钥、推送项目与包标识。生产内再区分 `simulation` 和 `manual_real` 账户。测试任务不能发送到生产设备,模拟净值不能出现在真实账户业绩汇总中。 + +## 5. 核心业务流程与状态机 + +### 5.1 空仓到持仓的主流程 + +```mermaid +flowchart TD + Scan["授权数据扫描"] --> Check["策略与风险预检查"] + Check --> Review["证据分析与人工审核"] + Review --> Candidate["已发布观察候选"] + Candidate --> Preview["个性化计划预览"] + Preview --> Plan["用户确认创建计划"] + Plan --> Trigger["条件成立:提醒用户"] + Trigger --> Fill["用户录入实际成交"] + Fill --> Position["账本更新与持仓监控"] + Position --> Exit["退出、减仓或加仓计划"] + Exit --> Fill + Position --> Recap["成本后复盘"] + Check --> NoAction["条件不足:继续观察"] + Plan --> Invalid["过期、撤回或条件失效"] +``` + +观察候选回答“值得研究什么”;买入条件回答“何时、以什么上限行动”;成交回答“用户实际做了什么”。三者采用不同对象与 API,避免将一次分析直接解释成买入指令。 + +### 5.2 候选状态机 + +`DRAFT → PENDING_REVIEW → PUBLISHED`;审核退回进入 `RETURNED`,重新提交时生成新修订;已发布候选可进入 `WITHDRAWN` 或 `EXPIRED`。 + +发布要求:关联策略允许运行、数据质量合格、分析通过结构化校验、证据可定位、反证明确、期限合法、服务能力允许发布、审核角色有权限。编辑证据或条件后,旧发布内容不再被直接覆盖,而是新建候选修订重新审核。 + +撤回保留历史版本,关闭新计划创建并冻结未完成买入计划的主动触发;已成交持仓仍继续监控。严重风险撤回可产生高优先级风险通知,不能自动卖掉用户持仓。 + +### 5.3 计划使用两组状态 + +单一“等待/触发/买入”不足以支持部分成交与失效,所以正式数据分离生命周期和触发状态。 + +| 字段 | 取值 | 含义 | +| --- | --- | --- | +| `lifecycle` | `ACTIVE / COMPLETED / CANCELLED / EXPIRED / INVALIDATED` | 计划能否继续使用 | +| `trigger_state` | `WAITING / SATISFIED / NO_LONGER_SATISFIED` | 最新条件是否成立;仅 ACTIVE 时持续评估 | +| `filled_qty` | 非负数量 | 用户记录并关联该计划的已成交数量 | +| `eligibility` | `ELIGIBLE / BLOCKED / UNKNOWN` | 当前是否具备建议所需权限与数据条件 | +| `execution_status` | `EXECUTABLE / WAIT_T1 / SUSPENDED / NO_LIQUIDITY / UNKNOWN` | 建议当前是否有可执行基础,不表示保证成交 | + +页面把 `ACTIVE + WAITING` 显示为“等待条件”,`ACTIVE + SATISFIED` 显示为“条件已满足”,有部分成交时叠加“已成交 X 股”,累计达到目标后 `COMPLETED`。过期、撤回、暂停不会撤销历史成交。 + +条件曾满足后回落,改为 `NO_LONGER_SATISFIED`;下一次满足必须符合重新触发冷却与事件去重规则。即使历史条件成立,用户打开推送时也要重新读取当前计划,不能展示为永久有效。 + +### 5.4 手工成交、纠错与资金 + +模拟账户采用严格余额、数量、时间和交易规则校验;手工真实记录账户允许用户补录真实发生的历史成交,并显示“用户记录、未经券商核验”。历史成交可能偏离计划,因此超计划预算应提示偏离并保留记录,而不是把历史事实强改成建议。 + +模拟账户现金由初始资金和资金流水生成;真实记录账户的“资金参考”仅用于计划风险预算,不自动生成现金余额。只有用户确认完整初始现金与出入金后才展示“账本估算可用现金”;信息不完整时显示未知,阻止依赖现金余量的精确仓位建议。 + +补录历史交易需按时间重放受影响证券账本,检查历史余额与可卖批次。存在缺失时进入待核对状态,不静默制造负持仓。错误成交通过冲正/替代记录纠正,保留原始记录、原因与引用;不得直接修改已入账事实。 + +### 5.5 持仓监控与退出逻辑 + +监控范围包含价格、入场逻辑、公告变化、持仓集中度、资金风险和时间。输出 `HOLD / REDUCE / EXIT / ADD / OBSERVE / NO_ACTION`,同时附条件、证据和失效期限。 + +风险规则触发不依赖大模型完成。当天不可卖批次、停牌、跌停或流动性不足时,可给出风险告知和待执行计划,但建议数量与即时可执行数量必须分开。止损价是风险阈值,跳空、涨跌停、T+1 等情况下实际损失可能超过预算。 + +加仓必须建立新计划并占用风险预算,不能因股价下跌自动补仓。加仓后的整体风险、成本和集中度重新计算;减仓和分批止盈按当前可卖数量核验。 + +## 6. App 前端设计 + +### 6.1 信息架构与路由 + +底部主导航保留原型的:首页、机会、计划、持仓、我的。提醒中心从首页与我的进入;股票详情、计划详情、成交表单、复盘为二级页面。 + +```text +apps/mobile/app/ + (auth)/login.tsx + onboarding/preferences.tsx + (tabs)/index.tsx # 首页 + (tabs)/opportunities.tsx + (tabs)/plans.tsx + (tabs)/portfolio.tsx + (tabs)/profile.tsx + stocks/[instrumentId].tsx + plans/[planId].tsx + fills/new.tsx + positions/[positionId].tsx + notifications/index.tsx + recap/index.tsx + settings/notifications.tsx + settings/accounts.tsx + _layout.tsx +``` + +路由参数只携带对象 ID,不携带可被伪造的价格、持仓或权限结论。对象详情从服务端加载;无权限、已撤回、已过期分别有专门状态。推送深链只能映射到白名单业务路由。 + +### 6.2 页面设计与接口依赖 + +| 页面 | 主要模块 | 主要操作 | 数据接口 | +| --- | --- | --- | --- | +| 偏好 | 资金参考、周期、风险偏好、板块 | 保存、跳过可选项 | `GET/PATCH /me/preferences` | +| 空仓首页 | 市场摘要、机会摘要、等待计划、服务提示 | 发现机会、查看计划、提醒 | `GET /home` | +| 有仓首页 | 账户摘要、风险提醒、当日计划 | 进入持仓、查看建议 | `GET /home` | +| 机会 | 关键词、策略分类、有效期 | 筛选、收藏、查看逻辑 | `GET /candidates` | +| 股票详情 | 行情、逻辑/计划/资料、证据、反证 | 查看证据、预览计划 | `GET /instruments/{id}`、`GET /candidates/{id}` | +| 买入预览 | 触发条件、资金上限、数量、止损、目标 | 调整目标金额、确认计划 | `POST /plan-previews`、`POST /plans` | +| 计划列表/详情 | 状态、版本、触发时间、期限 | 取消、查看变化、录入成交 | `GET /plans`、`GET /plans/{id}` | +| 成交录入 | 账户、方向、价格、数量、时间、费用 | 预检查、确认、偏离提示 | `POST /fills/preview`、`POST /fills` | +| 持仓/详情 | 总量、可卖量、成本、盈亏、风险、计划 | 预览退出、加仓、记录卖出 | `GET /positions`、`GET /positions/{id}` | +| 提醒 | 优先级、原因、状态、未读 | 已读、打开最新对象 | `GET /notifications`、`POST /notifications/{id}/read` | +| 我的/复盘 | 账户模式、偏好、通知、统计、隐私 | 切换账户、查看归因 | `GET /accounts`、`GET /recaps` | + +完整 API 前缀为 `/api/v1`,表中省略前缀。 + +### 6.3 状态管理 + +TanStack Query 管服务端状态,查询 Key 必须包含 `account_id` 和影响结果的筛选参数。Zustand 只保存当前账户、主题、筛选与非敏感表单草稿。切换账户时中断旧请求、清空敏感缓存、重新订阅事件。 + +成交、发布、撤回等操作不做“资产已更新”的乐观展示。用户提交后显示处理中,收到服务端确认再失效刷新账户、持仓、计划和复盘查询。通知标为已读可乐观更新,但失败必须回滚。 + +前台 WebSocket 只发对象变更提示和有限快照;客户端按版本处理,再向 REST 获取完整事实。应用恢复前台后重新拉取服务状态、通知游标与当前账户快照,不能只依赖先前连接。 + +### 6.4 视觉与组件体系 + +App 采用清爽、简洁的视觉方向:白色页面、石墨黑文字与主按钮,柔紫只用于 AI 研究、观察状态和少量强调。减少外框和重复卡片,以字号、留白和轻分隔建立层级。 + +| Token | 浅色值 | 深色值 | 用途 | +| --- | --- | --- | --- | +| `background` | `#FFFFFF` | `#111114` | 页面背景 | +| `surface` | `#FFFFFF` | `#111114` | 表单、弹层与主表面 | +| `layer` | `#F5F5F7` | `#1D1D23` | 浅灰模块与选中导航背景 | +| `textPrimary` | `#1B1B20` | `#F5F5F7` | 主文字与行情图线 | +| `textSecondary` | `#74747F` | `#A6A6B3` | 辅助文字,独立验证对比度 | +| `action` | `#202025` | `#EDEDF2` | 主要按钮 | +| `onAction` | `#FFFFFF` | `#19191E` | 主要按钮文字 | +| `accent` | `#6554A5` | `#BCB0F6` | AI 标签与强调文字 | +| `accentSoft` | `#F1EDF9` | `#2B2442` | AI 观察模块与标签浅底 | +| `border` | `#EAEAF0` | `#2C2C35` | 列表与模块分隔 | +| `priceUp / danger` | `#C54655` | `#F7919D` | 上涨、风险;用图标与标签区分语义 | +| `priceDown` | `#71717C` | `#BABAC5` | 下跌,同时显示负号与向下箭头 | +| `observe / waiting` | `#6554A5` | `#BCB0F6` | 观察/等待标签;风险警告另用明确图标与文案 | + +成功状态使用中性或柔紫文字配勾选图标,不引入默认绿色。禁用、上涨、下跌、已成交均不能只靠颜色识别。App 与管理端保留各自视觉 Token,避免后台焦糖橙自动影响 App。 + +品牌规范:页面标题与首次品牌展示使用「见势 · Veyra」;首页采用「见势」主字标加较小的「· Veyra」,原型主字标约 29px、英文约 18px;图标和头像使用 V 标识。深浅主题共享相同品牌写法,原型顶部和管理端品牌入口也同步更新。 + +页面层级:空仓首页先展示 AI 观察和“发现今日机会”入口,再展示市场快照、观察名单或已有计划;候选采用清晰的列表行与轻分隔。持仓页突出总资产、资金与浮盈亏数字,再展示单股风险与退出入口。详情页保持逻辑/计划/资料三个标签,表单与弹层沿用同一主题。 + +基础组件:`Screen / AppHeader / Card / Button / StatusBadge / MoneyText / QuoteStamp / EvidenceList / RiskNotice / PlanCard / PositionCard / EmptyState / ErrorState / BottomSheet / ConfirmDialog`。交互热区至少 44×44,支持系统字体放大、读屏标签、键盘避让与安全区。 + +### 6.5 图表与性能 + +首期使用服务端聚合好的有限时间窗口,默认不一次加载多年分钟线。分时与净值用 SVG 折线,简单 K 线分批加载;大数据量场景再基于真实设备评测升级渲染方案。图表显示数据时间、复权口径和单位。 + +行情展示按屏幕可见证券订阅,变更合并刷新,避免每条行情导致全屏渲染。退出页面取消无用订阅。模拟与真实账户使用明显文字标签,盈亏注明费用与现金完整性口径。 + +### 6.6 通知、离线与发布 + +离线可以查看带时间戳的历史快照和保存表单草稿;不得离线提交成交并伪装为入账成功,不得使用旧行情生成新买卖建议。重新联网后先同步,再确认提交。 + +远程推送采用 Development Build 和正式包验证,不依赖 Expo Go 完成推送验收。[R4] 海外/具备相应网络通道的设备可采用 Expo Push Service;中国大陆 Android 上线前必须评估无 Google 服务的设备,接入受控厂商/聚合推送 SDK,并测试 token 注册、冷启动、深链和隐私告知。 + +EAS Update 只在匹配 runtime 的范围内发布 JS/资源,原生 SDK 和权限变化需要重新出包;每次应用更新支持灰度与回滚。金融内容、隐私、模型供应商与数据服务变化还需同步更新对应政策与用户告知。 + +## 7. 管理端前端设计 + +### 7.1 管理端布局 + +保留原型的左侧导航、顶部环境提示、主工作区、详情抽屉/确认弹窗。常用表格支持服务端分页、条件筛选、排序和状态回显。桌面优先,移动宽度可查看和处理简单任务;复杂回测配置以桌面为主。 + +### 7.2 十个工作区 + +| 工作区 | 核心功能 | 重要约束 | +| --- | --- | --- | +| 运营总览 | 待审候选、有效发布数、数据质量、任务积压、推送失败 | 明确统计时间、范围和模拟/正式环境 | +| 候选审核 | 证据、反证、策略版本、有效期、发布、退回、撤回 | 有审核意见;版本冲突拒绝;必要时双人复核 | +| 策略管理 | 策略定义、参数版本、灰度激活、暂停、回滚 | 新版不覆盖旧候选;暂停后的计划策略明确 | +| 回测研究 | 创建异步任务、取消、进度、报告与对比 | 不展示预设绩效;数据与成本可复现 | +| 数据状态 | 源延迟、缺口、证券覆盖、校验失败、切源、重算 | 切源后先核验,禁止手动“一键健康”伪造指标 | +| AI 配置 | 模型路由、Prompt 版本、超时、预算、评估、测试 | 密钥只显示引用/掩码;测试执行在后端 | +| 提醒推送 | 站内记录、通道尝试、回执、失败分类、重试 | 过期计划不得重推;全局暂停和个人偏好生效 | +| 用户管理 | 账户状态、权限、用户声明、服务授权、受控查询 | 客服最小访问,不随意查看全部持仓 | +| 审计记录 | 主体、对象、原因、前后版本、请求链路、导出 | 只读;导出审批和可追溯 | +| 系统设置 | 服务开关、限流、通知策略、角色权限 | 高影响配置双人审批与生效版本 | + +### 7.3 交互和错误处理 + +发布、撤回、策略激活、全局服务暂停采用具体对象确认,显示影响数量与策略。审核者提交时携带 `expected_version`,其他人先修改时返回冲突,重新加载后再确认。保存草稿和发布是两个动作。 + +批量操作先预览影响、逐项校验,再返回每项结果;部分失败不能显示整批成功。长任务返回任务 ID,页面轮询或订阅进度,离开页面不取消服务端工作。被撤销权限后立即失效会话,不仅隐藏按钮。 + +### 7.4 Web 状态与安全 + +React Router 管理路由,TanStack Query 管理数据。使用 HttpOnly、Secure、SameSite Cookie 承载管理端会话,写请求增加 CSRF 防护。模型密钥不进入 JS、HTML、浏览器存储和构建环境变量。 + +Ant Design 的 `colorPrimary / colorSuccess / colorInfo / colorError / colorWarning` 统一定制,保证无绿色、无默认蓝色主题;图表也使用同一组业务颜色。[R5] 证据摘要与用户备注按文本渲染;需要富文本时通过安全白名单净化,禁止直接注入供应商 HTML。 + +## 8. Go 后端设计 + +### 8.1 分层与依赖 + +采用 `Transport → Application → Domain → Repository/Adapter`。Transport 负责解析、身份和响应;Application 组织事务;Domain 负责状态、规则与计算;Adapter 对接数据库、行情、模型和推送。 + +Domain 不依赖 HTTP、Gin、模型 SDK 或数据库驱动。业务错误使用稳定代码,HTTP 状态在外层映射。时间、ID 生成与规则读取通过接口注入,保证历史日期与休市测试可重复。 + +### 8.2 关键服务 + +| 服务 | 输入与输出 | 事务/一致性 | +| --- | --- | --- | +| CandidateService | 分析结果、审核意见 → 候选修订与发布 | 状态变更、审计、Outbox 同事务 | +| PlanService | 候选/持仓、账户、用户参数 → 预览和正式计划 | 确认时重检版本、时效、预算并建立保留额度 | +| RiskEngine | 行情、规则、账户、计划 → 阻断项与数量 | 确定性计算,不能被模型覆盖 | +| FillService | 成交事实 → 成交、批次、现金、计划进度 | 账户行锁,完整账本事务,幂等结果 | +| MonitoringService | 行情/公告变化 → 条件变化与风险事件 | 序号检查、条件重验、去重事件 | +| AnalysisService | 证据快照 → 结构化分析 | 异步完成,版本校验后入库 | +| NotificationService | 业务事件 → 站内通知和通道任务 | 先持久化,后发送;回执独立记录 | +| RecapService | 成交、费用、估值 → 可追溯统计 | 快照可重算,明确账本完整性 | + +### 8.3 预览与确认 + +买入预览生成短期 `preview_id`,保存规则版本、证据版本、行情时间、账户版本与计算结果。用户确认创建时,服务端再次读取最新状态;预览过期或风险改变时返回 `PREVIEW_STALE`,要求重新预览。不能接受客户端回传的“已通过风险”字段。 + +在模拟账户或已完整维护资金的记录账户中,正式计划建立预算保留;多个计划共享资金时不能各自假定拥有全部现金。预算保留只是本产品的额度管理,不表示真实券商冻结资金。未知现金账户以用户设定的总计划额度控制,并明确提示非券商余额。 + +### 8.4 幂等与并发 + +所有资金与状态写操作支持 `Idempotency-Key`。作用域为用户/账户+路由,保存请求体哈希与完整结果;相同 Key 不同请求体返回冲突。Key 是随机 ID,不包含个人信息。 + +账本写入时锁账户,按固定顺序锁受影响持仓批次;采用数据库约束和事务防止超卖、重复冲正、重复事件。[R8] 对前端表单编辑使用对象 `version` 乐观锁。服务端不能仅靠 Redis 分布式锁维护资金正确性。 + +### 8.5 开关的精确语义 + +| 开关/事件 | 新建议与计划 | 存量监控 | 成交记录与历史查询 | +| --- | --- | --- | --- | +| 分析服务暂停 | 阻止新分析与新建议生成 | 确定性风险监控继续,标记解释降级 | 继续 | +| 某策略暂停 | 不生成新候选、不触发该策略待买计划 | 已持仓的基础风险规则继续 | 继续 | +| 行情失效 | 阻止依赖该行情的新计划和新触发 | 显示数据未知,不生成新的价格结论 | 继续,记录事实与来源 | +| 候选撤回 | 阻止新计划,相关待买计划失效 | 继续并显示撤回原因 | 继续 | +| 通知暂停 | 站内事件仍保存,远程推送停止 | 分析与监控继续 | 继续 | +| 用户停用 | 按服务政策拒绝主动使用和推送 | 后台维护与依法留存仍执行 | 导出/更正通过受控渠道处理 | + +紧急全局停止建议开关可同时阻断发布、新计划与触发,但必须与“暂停模型调用”“暂停通知”分开,避免误停账本与历史记录。 + +## 9. 量化策略与风险引擎 + +### 9.1 首期三类策略 + +| 策略 | 候选条件示例 | 触发条件示例 | 失效条件示例 | +| --- | --- | --- | --- | +| 趋势与突破 | 趋势、行业相对强度、流动性满足 | 收盘确认或指定分钟线确认突破并满足量能 | 跌破结构、超期、波动/风险超限 | +| 趋势回撤 | 中期趋势仍有效,回撤在预定范围 | 止跌确认、风险收益与成交条件合格 | 趋势破坏、反证出现、回撤超限 | +| 公告事件观察 | 授权公告与可验证事实支持研究 | 事件确认加量价条件;证据不足只观察 | 澄清、更正、事件落空、利好已兑现 | + +这些是研究方向,不代表已验证盈利策略。每个策略必须有版本化公式、参数、数据依赖、适用市场、评估频率、时点规则与退出逻辑。首期优先日线筛选+分钟监控,控制研究与运营复杂度。 + +### 9.2 候选筛选顺序 + +证券白名单 → 交易与风险状态过滤 → 流动性过滤 → 策略因子计算 → 市场/行业状态 → 确定性排序 → AI 证据与反证分析 → 质量检查 → 人工审核 → 发布。 + +暂不采用每天必须推荐若干只的指标。数量不足时展示“当前没有符合条件的候选”。AI 不能把不合格候选通过改写措辞变成合格建议。 + +### 9.3 风险校验顺序 + +校验账户与服务权限、证券交易权限、交易日与证券状态、数据时效、证据与候选有效性、策略版本、价格范围、现金/计划额度、单笔风险、组合集中度、最小数量/递增单位、流动性与可卖数量。每个阻断项输出稳定代码和用户可理解的解释。 + +### 9.4 仓位计算规格 + +令账户风险基数为 `E`,单笔风险率为 `r`,保守入场价格为 `Pe`,止损阈值为 `Ps`,每股预估成本与滑点缓冲为 `c`: + +```text +单笔风险金额 R = E × r +单位计划损失 L = Pe - Ps + c +风险数量上限 Qrisk = floor(R / L),仅当 Pe > Ps 且 L > 0 +资金数量上限 Qcash = floor((可分配资金 - 费用缓冲) / Pe) +最终上限 Q = 按证券规则向下取整(min(Qrisk, Qcash, Qposition, Qliquidity)) +``` + +`Pe` 优先使用入场区间上沿计算,避免低估风险。风险基数、比例和缓冲来自确定性配置;已有活动计划的风险保留要纳入总风险。最终数量小于最低合法数量则不生成可执行买入计划。 + +纯示例:`E=100000`、`r=0.005`、`Pe=25`、`Ps=24`、`c=0.10`,风险数量上限为 454 股;若该证券买入单位为 100 股,则可降为 400 股,再与资金和流动性上限取最小值。该例只说明计算,不是交易建议,也不能保证损失限制在 500 元。 + +### 9.5 风险配置与交易规则 + +风险配置包含单笔风险、总开放风险、单证券/行业集中度、单日提醒频率、最大并行计划、回撤后的建议降级策略。阈值需经研究和产品审批,不把原型的演示数值当上线默认值。 + +交易规则包含最小买入数量、数量递增、零股卖出、价格最小变动单位、T+1/结算属性、涨跌幅与特殊期间、申报范围、交易时段、费用版本。规则按生效时间选择,并加入交易所通知、证券状态和企业行为;不依赖手机日期推断交易日。交易规则更新需要黄金样本测试与人工审核。[R12] + +### 9.6 组合层约束 + +组合风险与单票风险一起检查。无法获知用户在其他券商的持仓时,明确组合统计只覆盖已录入账户;不将局部账本称为全部资产。个股建议需要提示行业重复暴露和计划之间的资金竞争。用户新增外部持仓后重新评估未完成计划。 + +## 10. AI 分析与模型接入 + +### 10.1 模型角色与路由 + +主模型采用已讨论的 Qwen Plus 固定快照,负责中文公告理解、证据归纳、反证查找和结构化说明。备用模型初始采用 DeepSeek Flash,负责主通道故障后的合格分析请求。官方文档确认上述接口具有结构化输出/JSON 与工具调用能力,但这不证明其具有投资预测优势,需自行评估。[R9][R10] + +| 任务 | 执行者 | 调用频率 | +| --- | --- | --- | +| 价格、收益、数量、费用、T+1 | Go 风险/账本与 Python 数值程序 | 按业务与行情频率 | +| 全市场因子扫描 | Python/Go 批量计算 | 日线收盘、盘中规定窗口 | +| 公告事实提取 | AI + 来源校验 | 新文档事件触发 | +| 候选逻辑与反证 | 主模型 | 新候选或证据实质变化 | +| 持仓变化说明 | 主模型/备用模型 | 风险事件、公告或显著变化 | +| 普通价格提醒 | 规则模板 | 条件触发,无需等待 LLM | +| 复盘总结 | 程序统计 + AI 文字说明 | 收盘后/用户请求,缓存 | + +生产初始路由配置写为 `provider + model_id + configuration_version`。Qwen 用固定快照 ID;DeepSeek 别名可能更新,保存请求时模型名与响应可用的版本元数据,发现别名漂移必须重跑回归评估。不能声称别名永远固定在某个模型版本。 + +### 10.2 分析链路 + +1. Worker 收到候选或文档变化事件,按证券+证据版本去重。 +2. 根据分析时点构造数据快照,提供行情摘要、确定性因子、公告证据、策略条件和已知反证。 +3. 执行受控检索;只检索发布/获取时间不晚于分析时点的授权证据。 +4. 模型按版本化 Prompt 与 JSON Schema 返回结构化结论。 +5. 服务端校验 Schema、证据 ID、引用定位、时效、数值引用与建议类别。 +6. 风险与质量检查再次执行,分析入库并保留输入快照引用。 +7. 候选进入人工审核;持仓风险说明按服务能力与审核政策分流。 + +AI 只读工具示例:`get_quote_snapshot`、`get_factors`、`search_evidence`、`get_position_snapshot`、`get_rule_summary`。工具请求必须继承用户与账户权限;不提供成交写入、发布、密钥读取或券商交易工具。 + +### 10.3 输出契约 + +```json +{ + "schema_version": "analysis.v1", + "instrument_id": "inst_demo_001", + "as_of": "2026-09-30T01:41:00Z", + "decision": "OBSERVE", + "summary": "策略条件尚未全部确认,继续观察。", + "evidence_refs": [ + {"evidence_id": "ev_001", "locator": "page:3", "claim": "可核验的事实摘要"} + ], + "counter_evidence_refs": ["ev_002"], + "missing_information": ["下一次规定周期的量价确认"], + "rule_result_refs": ["risk_eval_001"], + "valid_until": "2026-09-30T02:00:00Z" +} +``` + +模型不返回可直接落账的金额、仓位和成交概率。需要展示价格与数量时,API 把程序算出的 `plan_preview` 与 AI 文字组合;引用数值必须精确匹配快照。`valid_until` 的模型提议也受服务端上限和证据期限限制。 + +不展示模型自报“90% 胜率”。如未来需要概率,只能使用明确时间目标、经过样本外检验与校准的量化模型,显示样本数、区间、定义与失效条件。LLM 的“信息完整度”可做质量维度,但不能称为盈利概率。 + +### 10.4 检索与证据 + +首期采用 PostgreSQL 结构化筛选与全文检索,证据以证券、事件类型、发布时间、可用时间和内容哈希建立索引。先限定证券与时点,再检索相关段落;必要时后续引入 pgvector 混合检索,不先建设独立向量数据库。 + +每段证据保存原始来源、文档版本、页码/段落、发布时点、系统首次可用时点、内容哈希与授权状态。公告更正建立新版本并关联旧版本;反证被删除或更正时触发重新分析。历史回测必须使用当时实际可得版本。 + +### 10.5 超时、降级与费用 + +初始规划:单次供应商请求超时 15 秒,最多一次受控备用调用,分析任务端到端预算 35 秒;这些是项目配置,不是供应商 SLA。后台长文提取可使用独立较长任务队列,不阻塞用户操作。 + +主模型超时、限流或服务错误时,备用通道必须通过同样 Schema、证据和质量校验。不能用备用结果绕过审核。连续失败后熔断:价格规则提醒继续,AI 解释显示“暂不可用”;新建依赖 AI 的建议暂停,不复用过期解释。 + +调用预算按任务类型设输入/输出 token 上限、每证券事件频率、每日团队额度与租户额度。相同证据、Prompt、模型和策略版本的公共分析可复用;用户账户个性化结果不能跨用户缓存。价格依赖官方当时计费规则,不在代码中硬编码永久单价。[R9][R10] + +### 10.6 AI 质量与攻击防护 + +建立至少覆盖公告、更正、停牌、数据缺失、跳空、相反证据与提示注入的评估集。离线评估 Schema 通过率、事实引用准确性、过期证据率、数值一致性、缺失信息识别、拒绝越权能力、时延和成本;上线前针对风险事实错误设阻断门槛。 + +公告与新闻视为不可信数据,不当成系统指令。禁止读取文档里的外部命令、执行脚本或自动访问任意 URL。模型工具使用固定 allowlist、最小权限和参数校验;模型生成的 Markdown/链接同样需要安全渲染。 + +## 11. 行情、公告与实时分析链路 + +### 11.1 数据源设计 + +| 数据类型 | 数据内容 | 首期实现 | +| --- | --- | --- | +| 证券主数据 | 代码、交易所、板块、上市状态、名称历史 | 每日同步,盘中接收状态变化 | +| 行情 | 授权快照、分钟 OHLCV、交易状态、序号 | 接入适配层统一格式;日线归档 | +| 公告与财务 | 原文、发布日期、首次可用时间、更正版本 | 受控采集、解析、去重、证据索引 | +| 交易日历 | 交易日、时段、临时调整 | 版本化同步,休市前核验 | +| 规则与费用 | 板块、证券类型、价格/数量、费用 | 人工批准版本,按时点应用 | +| 企业行为 | 分红、拆并股、配股、退市等 | 持仓与行情口径独立处理 | + +行情合作需要明确 App 展示、服务端非展示分析、缓存留存、派生指标与再分发的授权范围。上证信息官方提供行情与相关服务信息,实际可用字段、时延和许可按采购合同核验;本报告不假设购买一种行情即可任意使用全部场景。[R13] + +### 11.2 标准行情契约 + +统一包含 `instrument_id / exchange / vendor / feed_session_id / sequence / event_time / received_at / bid_ask_or_last / volume / trading_status / quality_state / adjustment_mode`。 + +以供应商行情产生时间判断时效,以 `received_at - event_time` 观察接入延迟。供应商序号只在其 session/分区内比较,不跨源强行排序。全系统时间统一 UTC 存储,业务显示 Asia/Shanghai;交易日另外保存 `trade_date`。 + +交易量累计快照差分不能跨 session 或交易日。分钟线区分未完成与完成:使用完整分钟条件的策略必须等 bar close,不能在 bar 中途用最终最高/最低/量造成前视偏差。 + +### 11.3 实时监控 + +```mermaid +flowchart TD + Feed["行情与文档事件"] --> Validate["序号、时效与质量检查"] + Validate --> Store["持久化批次与最新快照"] + Store --> Monitor["证券索引查找活动计划/持仓"] + Monitor --> Rule["条件与风险评估"] + Rule --> Change["事务保存状态与事件"] + Change --> Notice["站内通知与前台更新"] + Change --> AI["必要时异步解释"] + Notice --> Push["检查有效期后发送远程推送"] + Validate --> Bad["异常数据隔离与服务降级"] +``` + +维护 `instrument_id → 活动计划/持仓订阅` 反向索引,只评估受影响对象。公共因子按证券和策略版本计算一次,再应用用户资金与风险约束。全市场扫描可以分批完成,不能对每个用户重新扫全市场。 + +### 11.4 时效门槛与故障 + +按数据源合同和策略周期配置 freshness budget:快照、分钟线、公告、日历各自不同,不设一个统一 5 秒阈值。App 显示行情时间和质量状态,报价异常时禁用依赖该数据的新计划确认。 + +断流处理:监测心跳与时间;暂停相关价格触发;记录缺口;通知运营;切到有授权且口径兼容的备用源;补齐数据并通过校验后恢复。补齐旧行情只能用于历史重算,不能向用户重新发送“刚触发”的陈旧交易提示。 + +恢复时重建 Redis 索引与快照,并从持久化高水位读取。系统无法保证观察到断流期间每个瞬时触发点,因此保留监控缺口,复盘中说明数据覆盖;不可通过插值伪造当时提醒。 + +### 11.5 时延目标 + +首期目标以“系统收到合格数据”作为起点:确定性条件评估至站内事件落库 P95 ≤ 3 秒;前台连接正常时事件可见 P95 ≤ 5 秒;普通 API P95 ≤ 300 毫秒;AI 任务按第 10.5 节预算独立统计。分钟策略本身可能在分钟结束后评估,真实端到端时延还包含源数据延迟和移动网络。 + +系统推送无法承诺秒级必达,后台 App 也不能持续运行实时行情连接。[R14] 此目标只适用于通过容量测试的规定负载,不对所有用户网络和供应商服务做保证。 + +## 12. 数据库与存储设计 + +### 12.1 数据统一规范 + +ID 使用 UUID,外部展示可增加人类可读编号。时间使用 `timestamptz`;交易日使用 `date`;数量使用 `bigint`(首期整股),未来非整股资产扩展独立规格;价格 `numeric(20,6)`,金额 `numeric(24,6)`,金额结算按规则舍入。API 所有金额、价格、数量以十进制字符串传输,避免 JS 精度问题。 + +核心业务字段正规化;策略参数、规则扩展和供应商元数据用 JSONB,但配 Schema 验证与版本。不得把全部业务事实塞到一个无约束 JSON 文档。 + +### 12.2 核心实体与关系 + +```mermaid +erDiagram + USER ||--o{ ACCOUNT : owns + ACCOUNT ||--o{ PLAN : has + CANDIDATE ||--o{ PLAN : informs + STRATEGY_VERSION ||--o{ CANDIDATE : generates + ACCOUNT ||--o{ FILL : records + PLAN o|--o{ FILL : links + FILL ||--o{ POSITION_LOT : creates + POSITION_LOT ||--o{ SELL_ALLOCATION : consumed_by + FILL ||--o{ SELL_ALLOCATION : allocates + ACCOUNT ||--o{ CASH_ENTRY : posts + ANALYSIS ||--o{ CANDIDATE : supports + ANALYSIS ||--o{ ANALYSIS_EVIDENCE : cites + EVIDENCE ||--o{ ANALYSIS_EVIDENCE : referenced_by +``` + +卖出成交通过 `sell_allocations` 分配到买入批次。持仓汇总是可重建投影,不是唯一事实表。历史更正会重建相关投影,不能靠覆盖一个总数量字段解决所有问题。 + +### 12.3 用户、账户与权限表 + +| 表 | 主要字段 | 约束与索引 | +| --- | --- | --- | +| `users` | id、phone_hash、encrypted_phone、status、created_at | 登录标识唯一;敏感原文加密 | +| `user_preferences` | user_id、reference_capital、cycle、risk_preference、boards、version | user_id 唯一;只是用户偏好 | +| `service_entitlements` | user_id、capability、scope、status、valid_from/to、basis_ref | 生效范围与证据;过期自动失效 | +| `suitability_records` | user_id、assessment_version、result、assessed_at、valid_to、consent_ref | 与简单偏好分离 | +| `accounts` | id、user_id、mode、currency、ledger_status、version | 索引 user_id;mode 限定账户类别 | +| `account_settings` | account_id、risk_basis、plan_budget_limit、risk_policy_version | 账户唯一;配置修订可追溯 | +| `user_consents` | user_id、document_type、document_version、accepted_at、channel、revoked_at | 协议/授权版本可追溯;撤回按政策影响能力 | +| `sessions` | id、user_id、token_hash、device_id、expires_at、revoked_at | 刷新凭据只存哈希 | +| `devices` | id、user_id、platform、push_provider、encrypted_token、status | 用户/设备唯一;token 轮换 | +| `admin_roles / admin_role_bindings` | role、permission、admin_user、scope、valid_to | 权限变更审计与会话失效 | + +### 12.4 证券、研究、候选与规则表 + +| 表 | 主要字段 | 约束与索引 | +| --- | --- | --- | +| `instruments` | id、exchange、code、asset_type、board、status | exchange+code+有效期间,处理代码复用 | +| `instrument_events` | instrument_id、event_type、effective_at、source_ref | 包含停复牌与企业行为 | +| `trading_calendars` | exchange、trade_date、sessions、calendar_version | exchange+trade_date+版本 | +| `trading_rule_versions` | id、scope、effective_from/to、schema_version、rules、source | 同范围生效区间不得重叠 | +| `fee_rule_versions` | id、scope、effective_from/to、rates、rounding、source | 按方向、证券类型与时间应用 | +| `strategies` | id、code、name、status | code 唯一 | +| `strategy_versions` | id、strategy_id、version、parameters、code_digest、data_spec、status | strategy_id+version 唯一,不覆盖 | +| `strategy_deployments` | strategy_id、version_id、cohort、status、effective_at | 激活与灰度可回滚 | +| `evidence_documents` | id、instrument_id、source、published_at、available_at、content_hash、storage_uri、supersedes_id | 来源+内容哈希去重;时点索引 | +| `evidence_chunks` | id、document_id、locator、text、search_vector | 文档+定位索引;全文索引 | +| `analysis_runs` | id、as_of、input_hash、model_config_id、prompt_version、schema_version、status、result、cost | 输入/模型/Prompt 版本去重 | +| `analysis_evidence_refs` | analysis_id、chunk_id、claim_type、quoted_locator | 联合唯一;反证和正证区分 | +| `candidate_revisions` | id、candidate_key、revision、instrument_id、strategy_version_id、analysis_id、status、valid_until、version | key+revision 唯一;状态+期限索引 | +| `candidate_reviews` | revision_id、reviewer_id、decision、reason、reviewed_at、input_version | 一次审核事实不可改写 | + +### 12.5 计划、账本与通知表 + +| 表 | 主要字段 | 约束与索引 | +| --- | --- | --- | +| `plan_previews` | id、account_id、candidate_revision_id/position_ref、input_versions、result、expires_at | 短期保留;用户/账户权限 | +| `plans` | id、account_id、instrument_id、side、intent、source_revision_id、lifecycle、trigger_state、target_qty、filled_qty、valid_until、version | 数量非负;account+状态+期限索引 | +| `plan_revisions` | plan_id、revision、conditions、entry_range、stop、targets、risk_snapshot、rule_version_id | 修订不可覆盖,保留用户确认版本 | +| `budget_reservations` | account_id、plan_id、cash_amount、risk_amount、status | 活动计划唯一;成交按量释放保留 | +| `risk_evaluations` | account_id、plan_id、as_of、rule_version、input_refs、blocks、result | 分析与计划引用同一快照 | +| `fills` | id、account_id、instrument_id、plan_id 可空、side、price、qty、fees、trade_at、trade_date、source、reversal_of、status | 冲正目标唯一;qty > 0;account+时间索引 | +| `position_lots` | id、account_id、instrument_id、buy_fill_id、initial_qty、remaining_qty、sellable_from_trade_date、cost_amount | remaining 非负且 ≤ initial;账户/证券索引 | +| `sell_allocations` | sell_fill_id、lot_id、qty、allocated_cost | 成交+批次唯一;总分配量等于卖出量 | +| `corporate_actions` | instrument_id、type、announcement_ref、ex_date、record_date、pay_date、version | 来源与生效时间;按账户持仓资格处理 | +| `lot_adjustments` | lot_id、corporate_action_id/correction_id、qty_delta、cost_delta、effective_at | 企业行为与重放保留调整事实,不改写原始买入成交 | +| `cash_entries` | id、account_id、fill_id、type、amount、effective_at、reversal_of | 资金流水不可删改;与成交事务一致 | +| `position_snapshots` | account_id、instrument_id、qty、sellable_qty、cost、as_of、ledger_version | 账户+证券唯一;可从账本重建 | +| `notifications` | id、user_id、account_id、event_id、kind、priority、object_ref、expires_at、read_at | user+event+kind 去重;用户/时间游标 | +| `notification_deliveries` | notification_id、device_id、provider、status、attempt、ticket_id、receipt_state、last_error | 每设备每尝试独立保存 | +| `notification_preferences` | user_id、kind、push_enabled、quiet_hours、quota_policy | 站内与远程通道分开 | + +真实记录账户中,现金或历史账本不完整时 `ledger_status=INCOMPLETE`。不能输出基于完整资金账本的精确可用现金和总收益率;已有股票数量与成本也标明核对状态。 + +企业行为采用事件入账:现金分红形成资金流水与应收/支付状态,拆并股等形成批次数量和成本调整;复权行情只用于明确口径的研究与图表,不能替代资产调整。系统数据不完整时提示人工核对,保留原始来源和调整版本。 + +### 12.6 任务、审计与行情存储 + +| 表/存储 | 主要字段/内容 | 用途 | +| --- | --- | --- | +| `jobs` | kind、payload_ref、status、priority、attempt、run_after、lease_until、leased_by、result_ref | 持久化任务与抢占租约 | +| `outbox_events` | id、event_type、aggregate_id、aggregate_version、payload、created_at、dispatched_at | 与业务事务同写 | +| `consumer_inbox` | consumer、event_id、processed_at | 消费幂等,联合唯一 | +| `idempotency_records` | principal、scope、key、request_hash、status、response、expires_at | 写接口重放 | +| `audit_events` | actor、role、object、action、reason、before/after_ref、trace_id、occurred_at | 后台变更与敏感访问留痕 | +| `backtest_runs` | strategy_version、dataset_manifest、period、cost_rule、execution_spec、seed、code_digest、status、report_uri | 可再现研究 | +| `quote_ingest_batches` | feed、session、sequence_range、event_range、manifest_uri、quality | 行情接入和缺口追踪 | +| `bars_daily / bars_minute` | instrument、interval、bar_start、OHLCV、source、revision、available_at | 按日期分区;完成状态与时点明确 | +| 对象存储 | 原始行情分片、公告原文、Parquet、回测结果、证据快照 | 校验和、访问权限、生命周期策略 | + +首期只将需要在线查询的时间窗口保存在 PostgreSQL;长期高频行情放 Parquet 与对象存储,按时间和证券分区。归档/缓存删除不能违反行情合同和记录留存要求。 + +### 12.7 账本关键事务示例 + +```text +BEGIN + 校验幂等记录与请求哈希 + SELECT account FOR UPDATE + 按交易时间、证券规则与账本完整性校验成交 + 买入:写 fill、position lot、费用与现金流水 + 卖出:锁可卖 lots,写 fill、sell allocations、现金流水 + 更新 position 投影与 account.version + 关联计划时更新 filled_qty、释放相应保留额度 + 写 audit、outbox、幂等响应 +COMMIT +``` + +若只是录入已经发生的实际成交,当前候选撤回、行情断流或分析服务暂停不应阻止记录事实;历史交易规则仍按成交时点核验。计划偏离记录在独立字段中,不擅自改变成交价格、数量或时间。 + +## 13. API 与实时事件协议 + +### 13.1 通用约定 + +公网 REST 前缀 `/api/v1`,管理端 `/api/admin/v1`,内部量化 `/internal/v1`。Go 服务维护 OpenAPI 源,自动生成 TypeScript 客户端类型。JSON 使用 `snake_case`,所有金额/价格/数量为字符串,时间为带时区 ISO 8601。 + +列表采用 `cursor / limit`;排序稳定使用时间+ID。需要精确跳页的管理报表可使用受限 offset,行情和提醒不用无限 offset。筛选参数设白名单,默认和最大 limit 明确。 + +成功响应统一: + +```json +{ + "data": {"id": "plan_001", "version": 3}, + "meta": { + "request_id": "req_001", + "server_time": "2026-09-30T01:41:02Z", + "as_of": "2026-09-30T01:41:00Z" + } +} +``` + +错误响应统一: + +```json +{ + "error": { + "code": "DATA_STALE", + "message": "行情已超过本策略允许的时效,请等待数据恢复。", + "retryable": true, + "details": {"source": "quote_feed", "as_of": "2026-09-30T01:30:00Z"} + }, + "meta": {"request_id": "req_002"} +} +``` + +401 表示身份失效,403 表示权限不足,404 避免泄露越权对象,409 表示版本/幂等/状态冲突,422 表示业务规则阻断,429 表示限流,503 表示依赖服务暂不可用。前端依据错误代码决定重试或引导,不能解析中文文案判断。 + +### 13.2 App API 清单 + +| 方法与路径 | 用途 | 主要输入/输出 | +| --- | --- | --- | +| `POST /auth/otp/request` | 获取登录验证码 | 限流、防滥用;不暴露账号是否存在 | +| `POST /auth/otp/verify` | 登录 | 会话与短期访问凭据 | +| `POST /auth/refresh` | 轮换刷新凭据 | 旧凭据单次使用、重用检测 | +| `POST /auth/logout` | 撤销会话 | 设备与会话 | +| `GET /me` | 用户与服务权限 | 状态、可用能力,不返回后台权限 | +| `GET/PATCH /me/preferences` | 偏好读写 | `expected_version` | +| `GET /service-status` | 数据与建议能力 | 降级原因、恢复状态 | +| `GET/POST /accounts` | 账户列表/创建 | simulation/manual_real、资金初始化规则 | +| `GET /accounts/{id}/summary` | 账户汇总 | 现金完整性、净值、风险 | +| `GET/PATCH /accounts/{id}/settings` | 账户风险与计划额度 | 版本、变更影响;收紧额度时重新评估活动计划 | +| `POST /accounts/{id}/cash-entries` | 模拟或手工出入金 | 金额、类型、有效时间;幂等 | +| `GET /home?account_id=` | 首页聚合 | 空仓/有仓摘要、提醒、候选 | +| `GET /instruments` | 搜索证券 | 代码、名称、允许市场 | +| `GET /instruments/{id}` | 证券详情 | 主数据、行情和规则摘要 | +| `GET /instruments/{id}/bars` | 图表 | interval、时间范围、复权口径 | +| `GET /candidates` | 有效候选 | strategy、account_id、游标 | +| `GET /candidates/{id}` | 候选修订详情 | 证据、反证、状态、版本 | +| `POST/DELETE /watchlist/{instrument_id}` | 关注/取消关注 | 关注不等于持仓 | +| `GET /watchlist` | 关注列表 | 游标、行情时间、有效候选摘要 | +| `POST /plan-previews` | 买/卖/加/减仓预览 | account、candidate/position、intent、目标金额 | +| `POST /plans` | 确认计划 | preview_id、expected_versions;幂等 | +| `GET /plans`、`GET /plans/{id}` | 计划列表/详情 | 生命周期、触发状态、有效期 | +| `POST /plans/{id}/cancel` | 用户取消计划 | 版本、原因;释放保留 | +| `POST /plans/{id}/revision-previews` | 修改计划预览 | 必须重新风控、确认生成新修订 | +| `POST /plans/{id}/revisions` | 确认计划修订 | 旧版本不得覆盖 | +| `POST /fills/preview` | 成交录入预检查 | 账户、价格、数量、时间、费用 | +| `POST /fills` | 确认成交事实 | 幂等;可不关联计划 | +| `GET /fills` | 成交记录 | 账户、日期、证券 | +| `POST /fills/{id}/corrections` | 冲正并提交替代 | 原因、replacement;账本重放任务 | +| `GET /tasks/{id}` | 用户受控异步任务状态 | 仅本人纠错/导出任务,结果不泄露内部 payload | +| `GET /positions`、`GET /positions/{id}` | 持仓与批次 | 总量/可卖量/成本/核对状态 | +| `GET /notifications` | 提醒补拉 | cursor、未读过滤 | +| `POST /notifications/{id}/read` | 标为已读 | 与通道回执不同 | +| `GET/PATCH /me/notification-preferences` | 通知偏好 | 按类型、时段、通道 | +| `PUT /devices/{id}/push-token` | 注册/轮换设备 token | provider、token、权限状态 | +| `GET /recaps` | 复盘与统计 | 账户、日期范围、口径版本 | +| `POST /exports`、`GET /exports/{id}` | 用户数据导出 | 受控异步下载,审计 | +| `POST /me/deletion-requests` | 账户注销申请 | 会话撤销与留存政策流程 | + +此表路径均位于 `/api/v1`。纠错接口可返回 202 与重放任务 ID;重放期间对应账户为 `RECONCILING`,暂停依赖旧账本的新计划和成交提交,避免并行污染。 + +### 13.3 管理端 API 清单 + +| 方法与路径 | 用途 | 权限要点 | +| --- | --- | --- | +| `GET /overview` | 总览 | 只读运营数据 | +| `GET /candidates`、`GET /candidates/{id}` | 候选队列与详情 | 范围控制 | +| `POST /candidates/{id}/publish` | 审核发布 | reason、expected_version、审核角色 | +| `POST /candidates/{id}/return` | 退回 | 必填原因 | +| `POST /candidates/{id}/withdraw` | 撤回 | 影响预览、版本与原因 | +| `GET /strategies` | 策略列表 | 研究/运营 | +| `POST /strategies/{id}/versions` | 新建参数版本 | Schema、代码摘要与验证 | +| `POST /strategies/{id}/activate` | 激活/灰度 | 回测与审批记录 | +| `POST /strategies/{id}/pause` | 暂停 | 范围、原因、生效事件 | +| `POST/GET /backtests` | 创建/查询研究任务 | 算力配额与数据权限 | +| `GET /backtests/{id}` | 进度/报告 | 清晰标记历史回测 | +| `POST /backtests/{id}/cancel` | 请求取消 | 已完成结果不可改写 | +| `GET /data-sources` | 质量状态 | 实测状态,不允许客户端赋值 | +| `POST /data-sources/{id}/switch-preview` | 切源预览 | 授权、覆盖与口径检查 | +| `POST /data-sources/{id}/switch` | 切源 | 审批、生效配置 | +| `GET /model-configs` | 模型配置与指标 | 不返回密钥原文 | +| `POST /model-configs` | 新建路由版本 | secret_ref、模型与预算 | +| `POST /model-configs/{id}/test` | 后端连通/Schema 测试 | 限额,非前端 API 直连 | +| `POST /model-configs/{id}/activate` | 激活模型配置 | 回归评估与审批 | +| `GET /notification-tasks` | 推送任务与尝试 | 站内/通道状态分开 | +| `POST /notification-tasks/{id}/retry` | 安全重试 | 重检有效期、权限、去重 | +| `POST /notices` | 发布运营通知 | 模板、对象、范围、审核 | +| `GET /users`、`GET /users/{id}` | 用户管理 | 脱敏、最小字段、访问审计 | +| `POST /users/{id}/status` | 启用/停用 | 原因与权限 | +| `GET /audit-events` | 审计查询 | 只读,受控导出 | +| `GET /settings` | 配置 | 权限范围 | +| `POST /settings/change-previews` | 配置影响预览 | 不直接生效 | +| `POST /settings/changes` | 提交/批准配置变更 | 双人审批、版本与生效时间 | +| `GET /roles`、`GET /role-bindings` | 权限配置查询 | 专门身份管理权限 | +| `POST /role-binding-changes` | 申请/批准角色变更 | 双人分离、范围、到期时间与会话失效 | + +路径均位于 `/api/admin/v1`。原型的“模拟触发”只能存在于非生产测试工具,生产管理 API 不提供直接篡改真实价格条件的接口。 + +### 13.4 计划确认请求示例 + +```http +POST /api/v1/plans +Authorization: Bearer +Idempotency-Key: 28b8d02b-e678-4cba-8500-1e33086f728d +Content-Type: application/json +``` + +```json +{ + "account_id": "acc_001", + "preview_id": "preview_001", + "expected_account_version": 12, + "expected_candidate_version": 4, + "accepted_plan_revision": 1 +} +``` + +客户端不传最终风险批准结果。服务端返回正式计划、权威数值、账户新版本和明确状态。 + +### 13.5 WebSocket 协议 + +App 使用短期会话票据完成 WSS 鉴权,票据不放长期可记录的 URL。前端只订阅允许访问的账户和证券;服务端再次检验范围。每连接设置订阅数、速率和缓冲上限,慢客户端允许丢弃行情中间更新,但不能丢失持久化业务事实。 + +```json +{ + "event_id": "evt_001", + "type": "plan.trigger_changed", + "schema_version": 1, + "account_id": "acc_001", + "aggregate_id": "plan_001", + "aggregate_version": 5, + "cursor": "opaque-server-cursor", + "occurred_at": "2026-09-30T01:41:02Z", + "data": { + "lifecycle": "ACTIVE", + "trigger_state": "SATISFIED", + "as_of": "2026-09-30T01:41:00Z" + } +} +``` + +事件类型包括 `candidate.published / candidate.withdrawn / plan.trigger_changed / plan.invalidated / position.changed / notification.created / service.degraded / service.recovered`。模型完成也可发送 `analysis.completed`,但用户只能读取有权限的结果。 + +客户端按 event_id 去重、按对象版本忽略旧更新。短期在线事件采用 Redis Stream/同等短期分发机制可重建;持久化通知通过 REST 游标补拉。重连游标过期时返回 `RESYNC_REQUIRED`,客户端拉最新快照,不能保证无限期回放。 + +### 13.6 Python 内部协议 + +`POST /internal/v1/factor-jobs`、`POST /internal/v1/backtest-jobs` 接收 `job_id、strategy_version_id、dataset_manifest、as_of/period、execution_spec、code_digest`,立即返回受理结果。`GET /internal/v1/jobs/{id}` 返回进度与结果 URI,`POST .../cancel` 请求协作取消。 + +`job_id` 幂等;任务重复投递不重复运行同一逻辑。Go Worker 通过内部身份和网络策略访问,Python 无公网入口,也不允许客户端传入任意 SQL、Shell、Python 代码或对象存储路径。 + +## 14. 异步任务、推送与一致性 + +### 14.1 任务与 Outbox + +首期队列基于 PostgreSQL:业务事务写入状态、Outbox 与必要任务;Dispatcher 将事件转为消费任务/在线更新,成功后记派发进度。Worker 使用 `FOR UPDATE SKIP LOCKED` 短事务领取任务并建立租约,不在调用模型或执行回测的几十秒期间持有数据库行锁。[R8] + +任务状态为 `QUEUED / RUNNING / SUCCEEDED / RETRY_WAIT / FAILED / CANCEL_REQUESTED / CANCELLED`。长任务续租;租约过期可被重新领取;消费者使用 `consumer + event_id` 或业务任务 Key 幂等。目标为至少一次执行与业务效果幂等,不宣传跨数据库、供应商和设备的全局 exactly-once。 + +失败采用指数退避和抖动;初始建议 3 次自动重试,永久性权限错误、Schema 不合格和过期任务直接终止。超过次数进入失败队列,由运营分类处理。模型自动重试另受第 10.5 节供应商调用预算约束,不能每次 Job 重试都无限消耗 token。 + +### 14.2 事件顺序与竞争 + +发布、撤回、触发和成交都保存对象版本。发送买入提醒前再次检查候选状态、策略部署、计划期限、用户权限、行情时效与通知偏好。 + +如果候选先撤回、触发任务后到,旧版本任务必须终止;如果提醒已经发出,再撤回时发送最新状态事件,并在用户打开旧提醒时展示“已失效”。系统无法收回所有手机上已显示的通知,所以通知正文附时间并引导查看当前计划。 + +用户成交与计划到期竞争时,先按成交事实入账,再按关联和时点处理计划进度;计划到期不使历史真实成交无效。两笔并发卖出同一批次时,账户/批次锁和数量约束只允许合法总量入账。 + +### 14.3 提醒持久化与通道状态 + +先保存站内通知,再创建远程推送任务。用户关闭远程通知不会删除站内风险事件。原型里仅成功推送才出现在提醒中心的行为在正式产品中替换为这一规则。 + +| 状态 | 含义 | 可以展示的文案 | +| --- | --- | --- | +| `PENDING` | 等待发送 | 待发送 | +| `SUPPRESSED` | 偏好、静默、额度、暂停或无可用设备 | 远程推送未发送;展示原因 | +| `GATEWAY_ACCEPTED` | 推送网关接受请求 | 网关已受理 | +| `PROVIDER_ACCEPTED` | 下游系统接受/交接成功 | 通道已接收 | +| `DEVICE_OBSERVED` | App 主动上报看到了该事件 | 设备已接收;不表示用户阅读 | +| `READ` | 用户在 App 标为已读或打开内容 | 已读 | +| `FAILED` | 已知发送失败 | 失败与原因 | +| `EXPIRED` | 失去提醒价值 | 已过期,不重推 | + +不同供应商回执能力不同,缺少设备层证据时保留未知。Expo Receipt 表示与底层推送服务交接情况,不能证明设备收到或用户看到。[R14] 数据库分别记录站内已读和每设备尝试,不用一个 status 强行线性覆盖全部维度。 + +### 14.4 去重、频率与优先级 + +去重 Key 为用户+账户+对象+条件版本+触发周期。价格围绕阈值来回波动时采用冷却、持续确认或迟滞机制,参数由策略定义。 + +通知分为风险、计划条件、证据变化和运营四类。运营与普通机会采用日额度和静默时段;高优先级风险采用独立策略,但必须尊重用户系统通知权限,不擅自绕过。日额度按交易日与 Asia/Shanghai 时区计算,数据库原子检查,Redis 只做加速。管理端显示被抑制原因而不是伪报失败。 + +重试时使用剩余有效期设置 TTL,失效后不发送。批量提醒使用摘要与合并规则,避免一次全市场变化产生通知风暴。 + +## 15. 权限、安全与商业上线边界 + +### 15.1 管理权限矩阵 + +| 操作 | 超级管理员 | 审核员 | 量化研究员 | 运营/客服 | 只读审计员 | +| --- | --- | --- | --- | --- | --- | +| 总览与服务状态 | ✓ | ✓ | ✓ | ✓ | ✓ | +| 查看研究证据 | ✓ | ✓ | ✓ | 限范围 | ✓ | +| 审核/发布/退回 | 按审核授权 | ✓ | — | — | — | +| 撤回候选 | 按审核授权 | ✓ | 提交申请 | — | — | +| 策略版本/回测 | ✓ | 只读 | ✓ | — | 只读 | +| 策略激活/全局配置 | 发起/批准分离 | — | 发起申请 | — | — | +| 通知任务处理 | ✓ | 只读 | — | 按范围 | 只读 | +| 用户支持查询 | 按范围 | — | — | 脱敏最小范围 | 按审计授权 | +| 审计查询/导出 | 查询;导出受控 | 自己相关 | 自己相关 | 自己相关 | 查询;导出受控 | +| 密钥轮换/角色授权 | 专门安全权限 | — | — | — | — | + +超级管理员不是自动拥有投资建议审核资格。后台身份权限和业务执业授权是两层独立条件。高影响配置实行发起人与批准人不同;首期无法配备双人时,只允许非生产完整演示,不应把“管理员自审”当成正式双人流程。 + +### 15.2 应用与接口安全 + +App 使用短期访问凭据和轮换刷新凭据;刷新凭据安全存储,服务端保存哈希、撤销和重用检测。管理端使用 MFA 与会话 Cookie,限制登录来源并审计敏感访问。接口按对象核验 user/account 所有权,测试跨账户 ID 篡改与批量导出越权。 + +所有外部连接 TLS,内部网络最小开放;数据库、对象桶、缓存与 Python 服务不暴露公网。模型/行情/推送密钥放 Secret Manager,运行时注入,仅受控后端可读。日志不得记录 OTP、访问凭据、密钥、完整手机号、完整供应商输入或用户资产原文。 + +模型 Base URL、数据回调 URL 和文件下载只允许固定白名单,防止 SSRF。文件解析设大小、格式、页数与执行时长限制;异步容器以低权限运行,禁用任意外部命令和未受控网络访问。 + +### 15.3 用户隐私与数据留存 + +手机号、设备推送 token 和敏感资产信息按需求加密或脱敏;模型公共分析不含用户身份。个性化模型输入原则上只发送必要风险摘要,不能默认把完整交易历史与手机号发给供应商。 + +数据目录必须标注用途、授权、存放地区、访问角色、保留期限与删除方式。供应商调用区域、数据处理条款和跨境安排需在采购时核验,不因“国内品牌”自动判定所有处理都在境内。 + +注销撤销登录与设备 token,停止服务;需依法或依合同保留的业务记录转入受限留存范围,其他数据按政策删除。留存年限由适用业务、法律和合同确定,本文不把一个固定期限套到所有表。 + +### 15.4 真实建议服务的上线门禁 + +证监会官方资料将具体证券分析、价格预测、品种选择及买卖时机建议纳入相关“荐股软件”监管说明,商业提供此类能力不能仅靠“AI 生成”“仅供参考”规避相应要求。[R15] 这不是完整法律意见;正式经营模式和当时适用要求须由专业合规团队核验。 + +工程上实现服务能力门禁: + +| 能力 | 研发/模拟阶段 | 真实商业阶段 | +| --- | --- | --- | +| 模拟候选与模拟计划 | 明确标记,可完整验证 | 与真实服务隔离 | +| 真实行情展示/统计 | 按数据授权及功能边界开放 | 授权范围内开放 | +| 具体股票个性化买卖建议 | 不向无授权正式用户开放 | 根据经营资格、合作模式、服务协议、适当性与审核结果开放 | +| 自动下单 | 首期关闭 | 另立项目,核验券商授权与程序化交易等要求 | + +后台 `service_entitlements` 和全局能力配置共同控制,不仅在 App 隐藏按钮。合作方与系统提供方各自职责需明确,不能认为一次审核或一行免责声明就具备全部上线条件。 + +## 16. 回测、模拟交易与复盘 + +### 16.1 回测任务规范 + +每次回测固定:策略与参数版本、代码镜像摘要、数据清单与哈希、证券池时点、因子可用时间、交易规则版本、费用、滑点、成交模型、基准、时间范围、随机种子和指标口径。 + +分钟条件不能用尚未结束的 bar 最终数据执行;收盘才知道的信号通常从下一个可执行时点开始模拟。信号时点、委托时点、成交时点分开,禁止“用当日收盘得出信号又在同一收盘价必然成交”的隐性前视。 + +### 16.2 历史数据与成交约束 + +必须包含退市证券与历史证券池变化,避免幸存者偏差;财务指标按当时披露和更正时点使用,避免未来数据回填。复权用于研究的方式要与真实成交价格、现金分红、配股等分开,不能同时复权和重复入账红利。 + +成交模型包含 T+1、交易日历、停牌、涨跌停、数量和价格规则、费用、滑点、成交量参与率与部分成交。缺少盘口时采用保守成交假设,显示假设敏感性;不能把触价等同于全部成交。成本参数是版本化输入,不把原型 0.10% 示例当真实统一费率。 + +### 16.3 研究分段与指标 + +训练/参数研究、验证、最终样本外区间按时间切分,进行滚动验证;高度相关样本采用相应隔离方法。记录所有实验,控制反复调参挑最好结果的问题,冻结上线候选后不再用最终测试区间调参。 + +报告至少包含:成本后累计与年化收益、年化波动、最大回撤与持续时间、基准超额、换手、交易笔数、胜率、盈亏比、平均持有期、敞口、无法成交次数、费用贡献、收益分布和不同市场阶段。Sharpe 等指标注明频率与无风险利率假设;样本不足时不夸大稳定性。 + +展示“历史回测”,不得混入“真实成交”。策略发布前需要样本外研究、成本敏感性与前向模拟跟踪,不能因一个漂亮净值图直接上线。 + +### 16.4 模拟交易与真实记录 + +模拟成交只能按预定、可追踪的成交模型产生,不能用户点击就总以有利价格成交。手工模拟成交与系统撮合模拟分别标记来源,避免把两类结果混成策略证明。 + +真实记录展示用户确认的成交、费用和外部出入金;未经券商核验明确标记。没有完整现金流水时只报告股票交易盈亏和持仓估算,不报告完整账户收益率。 + +### 16.5 复盘指标与公式 + +```text +买入批次成本 = 成交价格 × 成交数量 + 按规则归入的买入费用 +卖出已实现盈亏 = 卖出收入 - 卖出费用 - 分配到卖出数量的批次成本 +未实现盈亏 = 当前估值 - 剩余批次成本(明确未来卖出费用是否已计) +完整账户权益 = 已核对现金 + 持仓估值 + 其他已确认资产/应收项 +``` + +部分卖出采用明确的批次分配规则(首期 FIFO);成本和可卖数量以批次账本为基础。资金进出时,账户收益计算使用明确的时间加权口径;如展示资金加权收益另说明算法。不能简单用“当前资产减初始资产”忽略追加资金。 + +AI 复盘解释只能基于程序统计,区分策略行为与用户偏离:未触发买入、超计划仓位、迟延退出、费用影响、信息变化。避免仅凭事后涨跌编造“当时必然应该买卖”的结论。 + +## 17. 部署、可观测性与容灾 + +### 17.1 首期部署 + +| 组件 | 开发环境 | 生产初始方案 | +| --- | --- | --- | +| App | Expo Development Build、真机 | iOS/Android 商店或合规分发,独立包标识 | +| 管理 Web | Vite 本地 | 静态资源托管+HTTPS 入口,后台访问限制 | +| Go API | 本地进程/Compose | 至少 2 个实例,健康检查与滚动发布 | +| Go Worker | Compose,按任务类别进程 | 监控/通知与回测调度分队列,独立并发上限 | +| Python | 固定依赖环境 | 计算容器,与 API 隔离 CPU/内存 | +| PostgreSQL | Compose | 托管 HA、备份、PITR,限制公网 | +| Redis | Compose | 托管或受控主从;故障时事实不丢失 | +| 对象存储 | 本地兼容服务 | 私有桶、加密、版本与生命周期 | +| 密钥 | 本地受控环境文件 | Secret Manager,权限和轮换 | + +部署区域优先接近授权行情和用户,最终由数据授权、服务合规和供应商可用区域共同决定。不得默认用国际推送和模型区承担全部中国大陆数据处理。 + +### 17.2 CI/CD + +提交检查:格式、静态检查、类型检查、契约生成差异、依赖安全、核心测试。构建不可变镜像与 App 工件,产生版本清单。上线采用 staging 验证→生产小范围灰度→指标检查→扩量,保存审批与回滚点。 + +数据库使用扩展→迁移数据→收缩模式;先添加兼容字段,再升级服务,旧字段删除另一个版本执行。备份不是自动回滚方案,回滚服务镜像必须与当前 Schema 兼容。策略/Prompt/模型配置与应用代码可独立发布,但必须有兼容矩阵和审计。 + +### 17.3 监控与告警 + +| 类别 | 指标 | 初期告警方向 | +| --- | --- | --- | +| API | P95/P99、错误率、限流、连接数 | 超出容量验收基线 | +| 行情 | 源延迟、覆盖、缺口、乱序、异常价、完成 bar 延迟 | freshness 超限即降级并通知运营 | +| 任务 | 最老任务年龄、积压、租约超时、失败率 | 监控/通知优先;回测不抢占 | +| AI | 时延、token、失败、Schema/证据校验、备用率 | 质量下降阻断发布,预算超限熔断 | +| 账本 | 数量/现金不变量、投影差异、重放失败 | 严重异常冻结受影响账户写入 | +| 推送 | 网关受理、回执错误、token 失效、过期抑制 | 按供应商分类,不能只统计“成功率” | +| 业务 | 候选有效数、审核时长、计划触发、偏离、撤回 | 同时看质量与风险,不以推送数量作为成绩 | +| 安全 | 异常登录、越权、密钥访问、敏感导出 | 会话撤销与事件处置 | + +每条链路关联 `trace_id / event_id / job_id / aggregate_id / model_config_version`。观测平台脱敏,配置审计与业务资产日志分开权限。 + +### 17.4 容灾与恢复目标 + +规划目标:已提交的同数据库账本事务无部分入账;数据库灾难场景 RPO ≤ 5 分钟、核心只读/账本恢复 RTO ≤ 60 分钟,具体取决于采购的备份和 HA 能力,必须演练证明。Redis 丢失通过数据库重建,不造成账本丢失。 + +每日备份、持续归档和异地保留按服务条件落实;每月至少抽样恢复,每季度完成一次故障演练。恢复顺序:身份与数据库→历史查询与账本核验→数据接入与日历→确定性监控→通知→AI 与新建议。未通过行情质量和时效校验前,不恢复主动买入提示。 + +## 18. 测试与验收标准 + +### 18.1 测试层级 + +Go 单元测试覆盖 Domain 规则;真实 PostgreSQL 集成测试覆盖事务、幂等与竞争;Python 黄金样本和历史 replay 覆盖因子与成交模型;OpenAPI 契约测试覆盖 App/管理端客户端;Web 使用 Playwright,App 使用真机/自动化流程工具完成端到端验收。 + +不靠逐字段镜像实现的测试证明正确性。核心测试以业务不变量、并发场景、历史已知样本和跨端结果为依据。 + +### 18.2 核心验收用例 + +| 编号 | 场景 | 通过标准 | +| --- | --- | --- | +| A01 | 新用户无持仓 | 能发现有效候选;没有机会时显示真实空状态 | +| A02 | 管理端发布候选 | App 可读最新修订;未发布内容不可越权访问 | +| A03 | 候选撤回后旧推送打开 | 显示失效原因,不能继续创建对应买入计划 | +| A04 | 两个计划争用资金 | 总保留额度不超过允许值;并发提交也成立 | +| A05 | 触发后未成交 | 不产生持仓、不扣现金,仅记录提醒 | +| A06 | 重复提交同一成交 | 只入账一次,返回原幂等结果 | +| A07 | 两笔并发卖出 | 不超卖;失败请求不留下半笔流水 | +| A08 | 当天买入和跨交易日可卖 | 按证券与日历规则计算,节假日不提前解锁 | +| A09 | 非统一买入单位证券 | 按对应规则校验,不硬编码全部 100 股 | +| A10 | 部分买入/卖出 | 批次、费用、现金、计划数量与保留额度一致 | +| A11 | 历史补录和冲正 | 按时间重放;缺失历史显式待核对;审计保留 | +| A12 | 无完整现金的真实记录账户 | 不虚构 10 万余额,不显示完整账户收益率 | +| A13 | 行情断流/乱序/旧数据 | 不产生新买入触发;恢复不推陈旧“实时”提醒 | +| A14 | 策略暂停/版本更新 | 待买触发停止;旧历史版本不变;存量风险监控保留 | +| A15 | 主模型超时,备用也失败 | 规则提醒继续,新 AI 解释标记不可用,无伪造结果 | +| A16 | 模型输出虚构证据/错误金额 | 校验失败,不进入正式发布 | +| A17 | 提示注入公告 | 不执行命令,不越权调用工具,不泄露敏感数据 | +| A18 | 通知关闭/通道失败 | 站内通知仍存在;界面如实显示通道状态 | +| A19 | 过期提醒重试 | 拒绝发送,产生抑制/过期记录 | +| A20 | 只读角色/跨用户 ID 篡改 | 所有写入或越权查询在服务端拒绝并记录 | +| A21 | WebSocket 重连/游标过期 | 去重或快照重同步,无重复入账与永久旧状态 | +| A22 | 回测前视/不可成交样本 | 已知未来字段不可用,停牌等样本按预定模型处理 | +| A23 | 模拟与真实切换 | 缓存、提醒、计划、净值互不串账户 | +| A24 | 数据库恢复与投影重建 | 通过账本不变量,恢复步骤和实际 RTO/RPO 可测 | +| A25 | 国内 Android 真机与 iOS 冷启动 | token 注册、推送点击、白名单深链、最新状态拉取成功 | +| A26 | 320/390 宽度、字体放大、深色 | 无遮挡、可操作、不依赖绿色/蓝色状态 | + +### 18.3 账本不变量与发布门槛 + +账本至少保证:剩余批次数量非负;可卖量不大于总量;卖出分配量等于卖出数量;资金流水与成交/费用一致;冲正一一关联;用户不能访问他人账户;投影重建结果与增量结果一致;计划成交进度只来自被确认的关联成交。 + +发布门槛:P0 验收通过、核心风险/账本测试全通过、无未解决严重安全问题、数据授权与服务能力符合上线范围、模型评估合格、真机通知通过、容量与恢复演练完成。真实建议服务另须完成第 15.4 节门禁,不把技术测试通过等同于商业许可完成。 + +## 19. 开发目录与团队协作 + +### 19.1 推荐仓库结构 + +```text +veyra/ + apps/ + mobile/ # Expo App + admin/ # React 管理端 + packages/ + api-client/ # OpenAPI 生成 TS 客户端 + design-tokens/ # 颜色、间距、字体与业务色义 + contracts/ # JSON Schema 与事件规格 + services/ + api/ + cmd/api/ + cmd/worker/ + internal/ + identity/ profile/ market/ strategy/ research/ + planning/ risk/ portfolio/ notification/ operations/ audit/ + platform/ # DB、缓存、日志、时钟、事务 + db/migrations/ + db/queries/ + api/openapi.yaml + quant/ + app/ # 内部受控任务 API + factors/ strategies/ backtest/ + tests/golden/ + pyproject.toml + specs/ + trading-rules/ # 版本化规则与黄金样本 + events/ + risk-policies/ + infra/ + compose/ environments/ dashboards/ runbooks/ + docs/ + architecture/ product/ operations/ +``` + +共享设计 Token 和 API 契约,不强行共享 RN 与 Web UI 组件。Python 与 Go 共享规则规格及测试样本,不复制未经对照的规则实现。 + +### 19.2 责任分工与工作约定 + +| 角色 | 主要交付 | +| --- | --- | +| 产品/设计 | 页面状态、业务文案、用户确认、异常与降级流程 | +| App | 原生路由、查询缓存、账户隔离、真机通知、发布 | +| Web | 十个工作区、审核版本冲突、权限呈现、图表报告 | +| Go | 业务状态、风险、账本、API、事件、供应商适配 | +| 量化 | 因子规格、时点数据、回测、样本外与前向验证 | +| 测试 | 业务不变量、跨端 E2E、故障、真机、容量 | +| 运维/安全 | 密钥、环境隔离、监控、备份、发布与恢复 | +| 合规/业务负责人 | 数据合同、服务资格、适当性、审核职责与留痕范围 | + +每个需求先写触发、前后状态、失败路径、契约与验收用例。API 变更先改 OpenAPI 与事件 Schema,再更新客户端。价格规则、Prompt 和策略配置与代码同样走评审和版本控制。 + +## 20. 分阶段实施计划 + +### 20.1 推荐实施顺序 + +| 阶段 | 规划时长 | 工作内容 | 退出条件 | +| --- | --- | --- | --- | +| 第 0 阶段:规格与采购验证 | 1–2 周 | 确认数据供应商、功能边界、规则规格、契约、视觉组件、国内推送 PoC | 数据字段与授权可用,技术依赖与真机路径验证 | +| 第 1 阶段:工程骨架 | 2 周 | 双端工程、Go 分层、账户、身份、迁移、任务、审计、CI | 双端连通服务,隔离环境可部署 | +| 第 2 阶段:交易闭环 | 3–4 周 | 候选、审核、计划预览、风险、成交账本、持仓、通知 | 使用固定回放数据跑通 A01–A12、A20、A23 | +| 第 3 阶段:数据与 AI | 2–3 周 | 授权源、日历、因子、模型适配、证据、实时监控、故障降级 | 回放与真实小范围数据结果一致,质量门禁有效 | +| 第 4 阶段:研究与硬化 | 2–3 周 | 真实回测、复盘、容量、真机、恢复、灰度、运营流程 | 所有 P0 验收通过,具备受控试运行条件 | +| 第 5 阶段:前向跟踪 | 持续 | 模拟表现、错误分析、规则优化、商业上线审核 | 根据数据质量和业务资格决定开放能力 | + +按 1 名 App、1 名 Web、2 名 Go、1 名量化、1 名测试,产品设计与运维部分参与估算,技术闭环约 10–15 周;可重叠开发,但关键数据采购和规则验收影响总体路径。单人开发不能直接沿用该并行排期,建议先做模拟闭环,再接真实数据。 + +此估算不包含证券建议服务资质、合同采购、应用备案/审核、商店审核的确定办理时间,也不把前向策略验证压缩成工程开发周数。 + +### 20.2 首个可用版本必须完成的纵向切片 + +一个授权或明确模拟证券 → 一个策略版本 → 一份可核验证据 → 管理端审核发布 → 空仓 App 看到候选 → 计划预览与确认 → 数据事件触发 → 站内/远程提醒 → 用户录入成交 → T+1 批次监控 → 卖出计划与成交 → 成本后复盘。 + +先证明一条纵向切片的正确性,再扩大策略数量和全市场扫描,能尽早暴露跨端状态、账本和时效问题。 + +### 20.3 第一批研发任务 + +1. 建立 monorepo、主题 Token、OpenAPI 和账户模式枚举。 +2. 实现登录、对象权限与模拟账户初始化;真实记录账户不默认赋现金。 +3. 定义证券/规则/交易日历版本与 Go/Python 黄金样本。 +4. 实现成交账本、冲正和可重建持仓,先通过并发与 T+1 测试。 +5. 实现候选修订、审核、计划预览/确认、预算保留与 Outbox。 +6. 完成 App 空仓路径和管理端候选工作区。 +7. 接入回放数据监控、站内通知,再测试真机远程推送。 +8. 接入主/备模型与证据校验,最后接真实回测和完整复盘。 + +## 21. 容量、成本与扩展路径 + +### 21.1 首期容量基线 + +以下仅用于压测设计,不能当成已实现吞吐能力:注册用户 5,000、日活 1,000、前台峰值连接 300、活动计划 10,000、持仓条目 5,000、候选发布每日 0–50、公共 AI 分析每日约 500–3,000 次。全市场分钟扫描范围按数据合同和批处理预算确定,活跃证券优先监控。 + +压测同时包含开盘数据波峰、集中通知、多人创建计划、用户补拉、回测资源占用和供应商降级,不只测一个 `/health` 接口。达到基线后记录实测 P95、资源和成本,再决定扩容。 + +### 21.2 数据量估算 + +用 `证券数 × 每日 bar 数 × 交易日数 × 单行存储` 估算分钟线,另加索引、版本和备份。例如假设 5,000 只证券、240 个完整分钟 bar、250 个交易日,则约 3 亿行/年;这是容量算例,不是对实际证券总数和每年交易日的断言。 + +因此全量长期分钟线不能简单长期堆在同一个业务表里。冷热分离、Parquet 分区和研究任务数据切片需从第一版规划;在线数据库只保存规定窗口和服务所需结果。 + +### 21.3 成本模型 + +| 成本项 | 计算方式 | 控制方法 | +| --- | --- | --- | +| 行情与公告授权 | 覆盖范围、时延、终端数、展示/分析与留存许可 | 按真实功能采购,不能以免费抓取代替生产授权 | +| API/Worker | 实例时长、资源、流量 | 分队列、批处理、缓存与弹性扩容 | +| PostgreSQL/Redis | 存储、IO、连接、HA 与备份 | 窗口、分区、连接池、定期归档 | +| 对象存储 | 压缩数据、版本、读取和出网 | Parquet、生命周期与受控下载 | +| AI | 各路由输入/缓存/输出 token × 当时单价 | 公共结果复用、事件触发、短上下文、预算 | +| 推送/短信 | 供应商计费与请求量 | 通道去重、通知合并、验证码防滥用 | +| 监控与安全 | 日志、指标、留存、扫描、密钥服务 | 脱敏和按用途分层留存 | +| 人力与业务 | 开发、研究、运营、审核、采购及资格 | 与技术费用分开估算 | + +模型成本公式:`Σ(input_tokens×input_price + cached_tokens×cache_price + output_tokens×output_price)`,注意单价单位通常为每百万 token,不同区域、时段和供应商可能不同。[R9][R10] 若每日 1,000 次、每次 6,000 输入/1,000 输出,则每日约 600 万输入与 100 万输出 token,再加备用、评估和重试;按采购时单价计算,而不是用调用次数直接估价。 + +在未确定供应商合同、部署区域、行情许可与真实负载前,不给出伪精确月费。第 0 阶段取得报价后形成低/中/高用量预算,并设硬额度与通知。 + +### 21.4 扩展触发条件 + +| 观察到的问题 | 优先措施 | 后续选项 | +| --- | --- | --- | +| 数据库任务领取/事件回放成为瓶颈 | 优化索引、批量派发、任务分区 | 独立事件总线 | +| 长期行情分析 IO 超载 | 扩展 Parquet 分区、缓存与任务切片 | ClickHouse 研究仓库 | +| 前台连接拖累 API | 网关进程独立部署与订阅限额 | 独立实时服务 | +| 回测占满资源 | 计算队列隔离与配额 | 按任务拉起临时计算资源 | +| AI 费用随用户线性增长 | 公共分析复用与事件去重 | 精细分层路由/合格小模型 | +| 权限与运营范围复杂 | 范围化 RBAC、审计与业务授权 | 多租户机构工作区 | + +## 22. 关键决策、待定项与开发起点 + +### 22.1 本版已定决策 + +| 决策 | 结论 | +| --- | --- | +| 端形态 | React Native + Expo App;React Web 管理端 | +| 品牌 | 中文「见势」、英文「Veyra」;完整名称「见势 · Veyra」 | +| App 视觉 | 白底/石墨黑/少量柔紫,无绿色,不用蓝色主品牌 | +| 管理端视觉 | 保留暖白/炭黑/焦糖橙后台主题,品牌入口统一更新 | +| 后端 | Go 模块化单体+独立 Worker,Python 量化服务 | +| 存储与队列 | PostgreSQL 业务事实/Job/Outbox,Redis 缓存,对象存储归档 | +| 核心业务 | 从空仓推荐到计划、成交、持仓、退出、复盘 | +| 模型职责 | Qwen 主路由、DeepSeek 备用;解释与证据分析,不能取代风险和账本 | +| 实时 | 规则先触发,AI 异步解释;前台事件+后台推送 | +| 账户 | 模拟与真实记录隔离;不默认生成真实现金余额 | +| 执行 | 用户自行在券商操作;首期没有自动下单 | +| 发布 | 候选修订+人工审核+服务授权门禁 | + +### 22.2 启动开发时需要落定的外部条件 + +| 待定项 | 默认处理 | 对进度的影响 | +| --- | --- | --- | +| 行情供应商与许可 | 先用固定回放数据开发;真实连接仅在授权后启用 | 实时质量、源字段与采购周期 | +| 首批板块与权限核验方式 | 主板白名单;其他板块默认不提供可执行建议 | 规则与适当性工作量 | +| 商业经营与合作模式 | 研发/模拟完整流程,正式能力按门禁开放 | 真实建议是否可上线 | +| 推送供应商 | Expo/APNs 可用路径+国内 Android PoC | 原生 SDK、真机与隐私要求 | +| 云供应商与区域 | 受控托管服务与中国大陆用户可用性验证 | 数据地区、网络、成本 | +| 团队人数 | 按第 20 节团队假设排期 | 并行能力与实际工期 | + +这些不是阻止编写接口和业务代码的前置问题:可先完成固定数据回放、模拟账本和双端闭环。真实生产数据、实际建议发布和商业收费应在对应条件满足后启用。 + +### 22.3 文档用途与实现边界 + +本报告可用于拆研发任务、确定模块和契约、评审数据库与风险、规划测试和基础设施。它不包含已经运行的后端、真实历史回测结果或已经采购的服务。工程开始后以版本化 OpenAPI、规则规格、迁移文件、ADR 和验收结果持续维护,不让实现细节与此设计长期分离。 + +## 23. 官方参考资料 + +参考日期为 2026-10-04。下列资料用于核验技术能力或业务边界;供应商功能、版本、计费和法律要求可能变化,上线与采购时需再次确认。本文的模块划分、容量、排期和风险阈值是本项目设计判断,不是官方承诺。资料只作短摘要引用,未复刻原文。 + +| 编号 | 官方资料 | 对应设计依据 | +| --- | --- | --- | +| R1 | [Expo Router Introduction](https://docs.expo.dev/router/introduction/) | App 路由与深链 | +| R2 | [TanStack Query Overview](https://tanstack.com/query/latest/docs/framework/react/overview) | 服务端状态与查询缓存 | +| R3 | [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/) | 敏感凭据本地存储 | +| R4 | [Expo Notifications](https://docs.expo.dev/versions/latest/sdk/notifications/) | 推送 API、开发构建与原生配置 | +| R5 | [Ant Design Customize Theme](https://ant.design/docs/react/customize-theme/) | 管理端主题 Token | +| R6 | [Gin Documentation](https://gin-gonic.com/en/docs/) | Go 路由与中间件框架 | +| R7 | [sqlc Documentation](https://docs.sqlc.dev/en/latest/) | SQL 与类型化代码生成 | +| R8 | [PostgreSQL Explicit Locking](https://www.postgresql.org/docs/current/explicit-locking.html) | 行锁、任务领取与并发约束 | +| R9 | [阿里云百炼 qwen3.7-plus](https://help.aliyun.com/zh/model-studio/qwen3-7-plus) | 快照 ID、结构化输出、工具能力与计费核验 | +| R10 | [DeepSeek Models & Pricing](https://api-docs.deepseek.com/quick_start/pricing/) | 模型别名、接口能力与计费变化 | +| R11 | [Go Release History](https://go.dev/doc/devel/release) | 稳定分支与补丁维护核验 | +| R12 | [上交所修订发布《上海证券交易所交易规则》](https://www.sse.com.cn/aboutus/mediacenter/hotandd/c/c_20260424_10816474.shtml) | 交易规则按版本与生效时间维护;不是所有板块的完整规则清单 | +| R13 | [上证所信息网络有限公司:行情服务](https://www.sseinfo.com/services/assortment/market/) | 行情服务与采购核验入口,具体许可取决于合同 | +| R14 | [Expo:Send Notifications / Delivery Guarantees](https://docs.expo.dev/push-notifications/sending-notifications/) | 回执、重试与设备送达限制 | +| R15 | [证监会:规范利用“荐股软件”从事证券投资咨询业务](https://www.csrc.gov.cn/csrc/c100028/c1002385/content.shtml);[江苏监管局:软件工具应说明方法和局限](https://www.csrc.gov.cn/jiangsu/c105409/c7577565/content.shtml) | 商业能力与审核边界;须结合现行适用规定核验 | diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..60ce653 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2630 @@ +{ + "name": "veyra", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "veyra", + "workspaces": [ + "apps/admin" + ] + }, + "apps/admin": { + "name": "@veyra/admin", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-slot": "1.2.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "lucide-react": "0.468.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "tailwind-merge": "3.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "4.2.4", + "@types/node": "22.19.19", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "4.7.0", + "tailwindcss": "4.2.4", + "tw-animate-css": "1.4.0", + "typescript": "5.9.3", + "vite": "6.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@veyra/admin": { + "resolved": "apps/admin", + "link": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8d6f697 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "veyra", + "private": true, + "workspaces": [ + "apps/admin" + ], + "scripts": { + "dev:admin": "npm run dev --workspace @veyra/admin", + "build:admin": "npm run build --workspace @veyra/admin" + }, + "overrides": { + "rollup": "4.62.4" + } +} diff --git a/services/api/api/openapi.yaml b/services/api/api/openapi.yaml new file mode 100644 index 0000000..31ddf0b --- /dev/null +++ b/services/api/api/openapi.yaml @@ -0,0 +1,319 @@ +openapi: 3.0.3 +info: + title: Veyra API + version: 0.2.0 + description: 工程基础、开发身份和账户初始化;密码登录仅限 local/test。 +servers: + - url: http://127.0.0.1:8080 +paths: + /healthz: + get: + operationId: getLiveness + responses: + '200': + description: API 进程存活,与依赖是否就绪无关 + content: + application/json: + schema: + type: object + required: [status] + properties: + status: {type: string, enum: [alive]} + /readyz: + get: + operationId: getReadiness + responses: + '200': {$ref: '#/components/responses/Health'} + '503': {$ref: '#/components/responses/Health'} + /api/v1/system/health: + get: + operationId: getSystemHealth + responses: + '200': {$ref: '#/components/responses/Health'} + '503': {$ref: '#/components/responses/Health'} + /api/admin/v1/auth/login: + post: + operationId: adminLogin + description: 开发环境登录,要求同源 Origin;会话只在 HttpOnly Cookie 中返回。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/LoginRequest'} + responses: + '200': + description: 已创建管理端会话 + headers: + Set-Cookie: {schema: {type: string}, description: veyra_admin_session; HttpOnly; SameSite=Strict; Path=/api/admin} + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {$ref: '#/components/schemas/AdminLogin'} + '401': {$ref: '#/components/responses/Error'} + '403': {$ref: '#/components/responses/Error'} + '422': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} + /api/v1/auth/login: + post: + operationId: developmentAppLogin + description: local/test 开发登录,返回 App 专用会话。正式短信登录与刷新流程待接入。 + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/LoginRequest'} + responses: + '200': + description: 已创建 App 会话 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {$ref: '#/components/schemas/AppLogin'} + '401': {$ref: '#/components/responses/Error'} + '422': {$ref: '#/components/responses/Error'} + '429': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} + /api/admin/v1/me: + get: + operationId: getAdminIdentity + security: [{adminCookie: []}] + responses: + '200': {$ref: '#/components/responses/Identity'} + '401': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} + /api/v1/me: + get: + operationId: getAppIdentity + security: [{appBearer: []}] + responses: + '200': {$ref: '#/components/responses/Identity'} + '401': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} + /api/admin/v1/auth/logout: + post: + operationId: adminLogout + description: 同源 Origin 请求,撤销会话并清除管理端 Cookie。 + security: [{adminCookie: []}] + responses: + '200': {$ref: '#/components/responses/Logout'} + '401': {$ref: '#/components/responses/Error'} + '403': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} + /api/v1/auth/logout: + post: + operationId: appLogout + security: [{appBearer: []}] + responses: + '200': {$ref: '#/components/responses/Logout'} + '401': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} + /api/admin/v1/users: + get: + operationId: listAppUsers + description: 最近创建的 100 位 App 用户,不包含密码、登录名或后台权限。 + security: [{adminCookie: []}] + responses: + '200': + description: App 用户列表 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: array + items: {$ref: '#/components/schemas/User'} + '401': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} + /api/v1/accounts: + get: + operationId: listOwnAccounts + security: [{appBearer: []}] + responses: + '200': + description: 当前用户账户列表 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: array + items: {$ref: '#/components/schemas/AccountSummary'} + '401': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} + post: + operationId: createOwnAccount + description: simulation 要求 initial_cash,manual_real 必须省略;每种模式仅一个账户。 + security: [{appBearer: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CreateAccount'} + responses: + '201': {$ref: '#/components/responses/AccountSummary'} + '401': {$ref: '#/components/responses/Error'} + '409': {$ref: '#/components/responses/Error'} + '422': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} + /api/v1/accounts/{id}/summary: + get: + operationId: getOwnAccountSummary + security: [{appBearer: []}] + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + responses: + '200': {$ref: '#/components/responses/AccountSummary'} + '401': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} + '503': {$ref: '#/components/responses/Error'} +components: + securitySchemes: + adminCookie: {type: apiKey, in: cookie, name: veyra_admin_session} + appBearer: {type: http, scheme: bearer} + responses: + Error: + description: 标准业务错误 + content: + application/json: + schema: + type: object + required: [error, meta] + properties: + error: + type: object + required: [code, message, retryable] + properties: + code: {type: string} + message: {type: string} + retryable: {type: boolean} + meta: {$ref: '#/components/schemas/Meta'} + Identity: + description: 当前身份 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {$ref: '#/components/schemas/User'} + Logout: + description: 会话已撤销 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + required: [revoked] + properties: + revoked: {type: boolean} + AccountSummary: + description: 账户与账本估算现金 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: {$ref: '#/components/schemas/AccountSummary'} + Health: + description: PostgreSQL 和 Redis 都正常时为 200,任一不可用或未配置时为 503 + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + schemas: + Meta: + type: object + required: [request_id] + properties: + request_id: {type: string, format: uuid} + server_time: {type: string, format: date-time} + Envelope: + type: object + required: [data, meta] + properties: + data: {} + meta: {$ref: '#/components/schemas/Meta'} + User: + type: object + required: [id, display_name, status, created_at, updated_at] + properties: + id: {type: string, format: uuid} + display_name: {type: string} + status: {type: string, enum: [ACTIVE, DISABLED]} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + LoginRequest: + type: object + additionalProperties: false + required: [login_name, password] + properties: + login_name: {type: string, minLength: 3, maxLength: 64} + password: {type: string, minLength: 1, maxLength: 72, writeOnly: true} + AdminLogin: + type: object + required: [user, expires_at] + properties: + user: {$ref: '#/components/schemas/User'} + expires_at: {type: string, format: date-time} + AppLogin: + allOf: + - {$ref: '#/components/schemas/AdminLogin'} + - type: object + required: [access_token] + properties: + access_token: {type: string} + CreateAccount: + type: object + additionalProperties: false + required: [mode, name] + properties: + mode: {type: string, enum: [simulation, manual_real]} + name: {type: string, minLength: 1, maxLength: 80} + initial_cash: {type: string, description: CNY 十进制字符串,最多两位小数,范围 0 至 1000000000.00} + AccountSummary: + type: object + required: [id, user_id, mode, name, currency, ledger_status, created_at, cash_balance] + properties: + id: {type: string, format: uuid} + user_id: {type: string, format: uuid} + mode: {type: string, enum: [simulation, manual_real]} + name: {type: string} + currency: {type: string, enum: [CNY]} + ledger_status: {type: string, enum: [COMPLETE, INCOMPLETE]} + created_at: {type: string, format: date-time} + cash_balance: {type: string, nullable: true, description: CNY 十进制字符串,现金历史不完整时为 null} + Health: + type: object + required: [status, checked_at, components] + properties: + status: {type: string, enum: [ready, not_ready]} + checked_at: {type: string, format: date-time} + components: + type: object + required: [postgresql, redis] + properties: + postgresql: {$ref: '#/components/schemas/Component'} + redis: {$ref: '#/components/schemas/Component'} + Component: + type: object + required: [status] + properties: + status: {type: string, enum: [ready, unavailable, not_configured]} diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go new file mode 100644 index 0000000..01b6ade --- /dev/null +++ b/services/api/cmd/api/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "errors" + "flag" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + identityrepo "veyra/services/api/internal/identity/repository" + identityservice "veyra/services/api/internal/identity/service" + "veyra/services/api/internal/operations/service" + "veyra/services/api/internal/platform/config" + "veyra/services/api/internal/platform/ratelimit" + portfoliorepo "veyra/services/api/internal/portfolio/repository" + portfolioservice "veyra/services/api/internal/portfolio/service" + transport "veyra/services/api/internal/transport/http" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + if err := run(logger); err != nil { + logger.Error("API stopped", "error", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + envFile := flag.String("env-file", "", "optional environment file") + flag.Parse() + if *envFile != "" { + if err := config.LoadEnvFile(*envFile); err != nil { + return err + } + } + c, err := config.Load() + if err != nil { + return err + } + var postgres service.Probe + var authRepo identityservice.Repository + var accountRepo portfolioservice.Repository + if c.PGUser != "" { + if err := c.ValidateDatabase(); err != nil { + return err + } + pool, err := pgxpool.New(context.Background(), c.DatabaseURL(c.PGDatabase)) + if err != nil { + return errors.New("invalid PostgreSQL configuration") + } + defer pool.Close() + postgres = pool.Ping + authRepo = identityrepo.NewPostgres(pool) + accountRepo = portfoliorepo.NewPostgres(pool) + } + redisClient := redis.NewClient(&redis.Options{Addr: c.RedisAddr, Username: c.RedisUsername, Password: c.RedisPassword, DB: c.RedisDB, DialTimeout: time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second, MaxRetries: -1, ContextTimeoutEnabled: true}) + defer redisClient.Close() + health := service.NewHealthService(postgres, func(ctx context.Context) error { return redisClient.Ping(ctx).Err() }) + auth, err := identityservice.NewAuthService(authRepo) + if err != nil { + return err + } + identityOptions := transport.IdentityOptions{Auth: auth, Accounts: portfolioservice.NewAccountService(accountRepo), Limiter: ratelimit.NewRedis(redisClient), DevelopmentLogin: c.Environment == "local" || c.Environment == "test", SecureCookie: c.Environment != "local" && c.Environment != "test"} + server := &http.Server{Addr: c.HTTPAddr, Handler: transport.NewRouter(health, logger, identityOptions), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + result := make(chan error, 1) + go func() { result <- server.ListenAndServe() }() + logger.Info("API starting", "address", c.HTTPAddr, "environment", c.Environment) + select { + case err := <-result: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return server.Shutdown(shutdown) + } +} diff --git a/services/api/cmd/db/main.go b/services/api/cmd/db/main.go new file mode 100644 index 0000000..b8144f1 --- /dev/null +++ b/services/api/cmd/db/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "time" + + "veyra/services/api/internal/platform/config" + "veyra/services/api/internal/platform/database" +) + +func main() { + if err := run(); err != nil { + slog.Error("database command failed", "error", err) + os.Exit(1) + } +} + +func run() error { + envFile := flag.String("env-file", "", "optional environment file") + action := flag.String("action", "migrate", "create or migrate") + flag.Parse() + if *envFile != "" { + if err := config.LoadEnvFile(*envFile); err != nil { + return err + } + } + c, err := config.Load() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + switch *action { + case "create": + created, err := database.Create(ctx, c) + if err != nil { + return err + } + slog.Info("database ready", "database", c.PGDatabase, "created", created) + case "migrate": + pool, err := database.Open(ctx, c) + if err != nil { + return err + } + defer pool.Close() + if err := database.Migrate(ctx, pool); err != nil { + return err + } + slog.Info("migrations applied", "database", c.PGDatabase) + default: + return fmt.Errorf("action must be create or migrate") + } + return nil +} diff --git a/services/api/cmd/user/main.go b/services/api/cmd/user/main.go new file mode 100644 index 0000000..fa92716 --- /dev/null +++ b/services/api/cmd/user/main.go @@ -0,0 +1,73 @@ +// Command user provisions development identities without exposing a registration API. +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "time" + + "veyra/services/api/internal/identity/repository" + "veyra/services/api/internal/identity/service" + "veyra/services/api/internal/platform/config" + "veyra/services/api/internal/platform/database" +) + +func main() { + if err := run(); err != nil { + slog.Error("provision identity failed", "error", err) + os.Exit(1) + } +} +func run() error { + envFile := flag.String("env-file", "", "environment file") + role := flag.String("role", "ADMIN", "ADMIN or USER") + flag.Parse() + if *envFile != "" { + if err := config.LoadEnvFile(*envFile); err != nil { + return err + } + } + c, err := config.Load() + if err != nil { + return err + } + if c.Environment != "local" && c.Environment != "test" { + return fmt.Errorf("development provisioning is disabled outside local/test environments") + } + var login, password, name string + switch *role { + case "ADMIN": + login = os.Getenv("ADMIN_LOGIN") + password = os.Getenv("ADMIN_PASSWORD") + name = "管理员" + case "USER": + login = os.Getenv("DEV_USER_LOGIN") + password = os.Getenv("DEV_USER_PASSWORD") + name = "开发测试用户" + default: + return fmt.Errorf("role must be ADMIN or USER") + } + if login == "" || password == "" { + return fmt.Errorf("configure the selected identity login and password in the local environment file") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + pool, err := database.Open(ctx, c) + if err != nil { + return err + } + defer pool.Close() + auth, err := service.NewAuthService(repository.NewPostgres(pool)) + if err != nil { + return err + } + user, err := auth.ProvisionUser(ctx, login, password, name, *role) + if err != nil { + return err + } + slog.Info("identity provisioned", "user_id", user.ID, "role", *role) + return nil +} diff --git a/services/api/go.mod b/services/api/go.mod new file mode 100644 index 0000000..3c261dc --- /dev/null +++ b/services/api/go.mod @@ -0,0 +1,49 @@ +module veyra/services/api + +go 1.26.2 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.11.0 + github.com/redis/go-redis/v9 v9.18.0 + golang.org/x/crypto v0.48.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/services/api/go.sum b/services/api/go.sum new file mode 100644 index 0000000..738b583 --- /dev/null +++ b/services/api/go.sum @@ -0,0 +1,116 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/api/internal/identity/domain/auth.go b/services/api/internal/identity/domain/auth.go new file mode 100644 index 0000000..bbf0615 --- /dev/null +++ b/services/api/internal/identity/domain/auth.go @@ -0,0 +1,27 @@ +package domain + +import ( + "time" +) + +type Credential struct { + ID string `db:"id"` + UserID string `db:"user_id"` + LoginName string `db:"login_name"` + PasswordHash string `db:"password_hash" json:"-"` + CreatedAt time.Time `db:"created_at"` +} + +func (Credential) TableName() string { return "veyra_credential" } + +type Session struct { + ID string `db:"id"` + UserID string `db:"user_id"` + TokenHash string `db:"token_hash" json:"-"` + Audience string `db:"audience"` + ExpiresAt time.Time `db:"expires_at"` + RevokedAt *time.Time `db:"revoked_at"` + CreatedAt time.Time `db:"created_at"` +} + +func (Session) TableName() string { return "veyra_session" } diff --git a/services/api/internal/identity/domain/user.go b/services/api/internal/identity/domain/user.go new file mode 100644 index 0000000..7289636 --- /dev/null +++ b/services/api/internal/identity/domain/user.go @@ -0,0 +1,15 @@ +package domain + +import "time" + +// User maps explicitly to veyra_user. IDs are canonical UUID strings. +type User struct { + ID string `db:"id" json:"id"` + DisplayName string `db:"display_name" json:"display_name"` + Status string `db:"status" json:"status"` + Role string `db:"role" json:"-"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (User) TableName() string { return "veyra_user" } diff --git a/services/api/internal/identity/repository/postgres.go b/services/api/internal/identity/repository/postgres.go new file mode 100644 index 0000000..c9eec54 --- /dev/null +++ b/services/api/internal/identity/repository/postgres.go @@ -0,0 +1,94 @@ +package repository + +import ( + "context" + "errors" + "time" + "veyra/services/api/internal/platform/apperror" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "veyra/services/api/internal/identity/domain" +) + +type Postgres struct{ pool *pgxpool.Pool } + +func NewPostgres(pool *pgxpool.Pool) *Postgres { return &Postgres{pool: pool} } + +func (r *Postgres) CreateUser(ctx context.Context, user domain.User, credential domain.Credential) (domain.User, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return domain.User{}, apperror.ErrUnavailable + } + defer tx.Rollback(context.Background()) + err = tx.QueryRow(ctx, `INSERT INTO veyra_user(id,display_name,status,role) VALUES($1,$2,$3,$4) RETURNING created_at,updated_at`, user.ID, user.DisplayName, user.Status, user.Role).Scan(&user.CreatedAt, &user.UpdatedAt) + if err != nil { + return domain.User{}, mapError(err) + } + _, err = tx.Exec(ctx, `INSERT INTO veyra_credential(id,user_id,login_name,password_hash) VALUES($1,$2,$3,$4)`, credential.ID, credential.UserID, credential.LoginName, credential.PasswordHash) + if err != nil { + return domain.User{}, mapError(err) + } + if err = tx.Commit(ctx); err != nil { + return domain.User{}, apperror.ErrUnavailable + } + return user, nil +} + +func (r *Postgres) FindCredential(ctx context.Context, login string) (domain.User, domain.Credential, error) { + var u domain.User + var c domain.Credential + err := r.pool.QueryRow(ctx, `SELECT u.id,u.display_name,u.status,u.role,u.created_at,u.updated_at,c.id,c.user_id,c.login_name,c.password_hash,c.created_at FROM veyra_user u JOIN veyra_credential c ON c.user_id=u.id WHERE c.login_name=$1`, login).Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt, &c.ID, &c.UserID, &c.LoginName, &c.PasswordHash, &c.CreatedAt) + return u, c, mapError(err) +} + +func (r *Postgres) CreateSession(ctx context.Context, s domain.Session) error { + _, err := r.pool.Exec(ctx, `INSERT INTO veyra_session(id,user_id,token_hash,audience,expires_at) VALUES($1,$2,$3,$4,$5)`, s.ID, s.UserID, s.TokenHash, s.Audience, s.ExpiresAt) + return mapError(err) +} + +func (r *Postgres) FindSessionUser(ctx context.Context, hash, audience string, now time.Time) (domain.User, error) { + var u domain.User + err := r.pool.QueryRow(ctx, `SELECT u.id,u.display_name,u.status,u.role,u.created_at,u.updated_at FROM veyra_session s JOIN veyra_user u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.audience=$2 AND s.expires_at>$3 AND s.revoked_at IS NULL AND u.status='ACTIVE' AND (($2='admin' AND u.role='ADMIN') OR ($2='app' AND u.role='USER'))`, hash, audience, now).Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, apperror.ErrUnauthenticated + } + return u, mapError(err) +} + +func (r *Postgres) RevokeSession(ctx context.Context, hash, audience string, now time.Time) error { + _, err := r.pool.Exec(ctx, `UPDATE veyra_session SET revoked_at=$3 WHERE token_hash=$1 AND audience=$2 AND revoked_at IS NULL`, hash, audience, now) + return mapError(err) +} + +func (r *Postgres) ListUsers(ctx context.Context, limit int) ([]domain.User, error) { + rows, err := r.pool.Query(ctx, `SELECT id,display_name,status,role,created_at,updated_at FROM veyra_user WHERE role='USER' ORDER BY created_at DESC,id DESC LIMIT $1`, limit) + if err != nil { + return nil, apperror.ErrUnavailable + } + defer rows.Close() + users := make([]domain.User, 0) + for rows.Next() { + var u domain.User + if err := rows.Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, apperror.ErrUnavailable + } + users = append(users, u) + } + return users, mapError(rows.Err()) +} + +func mapError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, pgx.ErrNoRows) { + return apperror.ErrNotFound + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return apperror.ErrConflict + } + return apperror.ErrUnavailable +} diff --git a/services/api/internal/identity/service/auth.go b/services/api/internal/identity/service/auth.go new file mode 100644 index 0000000..824c9ee --- /dev/null +++ b/services/api/internal/identity/service/auth.go @@ -0,0 +1,144 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + "time" + "unicode/utf8" + "veyra/services/api/internal/platform/apperror" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "veyra/services/api/internal/identity/domain" +) + +type Repository interface { + CreateUser(context.Context, domain.User, domain.Credential) (domain.User, error) + FindCredential(context.Context, string) (domain.User, domain.Credential, error) + CreateSession(context.Context, domain.Session) error + FindSessionUser(context.Context, string, string, time.Time) (domain.User, error) + RevokeSession(context.Context, string, string, time.Time) error + ListUsers(context.Context, int) ([]domain.User, error) +} + +type AuthService struct { + repo Repository + now func() time.Time + dummyHash []byte +} + +func NewAuthService(repo Repository) (*AuthService, error) { + hash, err := bcrypt.GenerateFromPassword([]byte("invalid-user-timing-placeholder"), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("initialize password verifier: %w", err) + } + return &AuthService{repo: repo, now: time.Now, dummyHash: hash}, nil +} + +var loginPattern = regexp.MustCompile(`^[a-z0-9_]{3,64}$`) + +// ProvisionUser is called by a local CLI. Public registration cannot choose ADMIN. +func (s *AuthService) ProvisionUser(ctx context.Context, login, password, name, role string) (domain.User, error) { + login = strings.ToLower(strings.TrimSpace(login)) + name = strings.TrimSpace(name) + if !loginPattern.MatchString(login) || len(password) < 12 || len(password) > 72 || utf8.RuneCountInString(name) > 80 || name == "" || (role != "USER" && role != "ADMIN") { + return domain.User{}, apperror.ErrInvalidInput + } + if s.repo == nil { + return domain.User{}, apperror.ErrUnavailable + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return domain.User{}, fmt.Errorf("hash password: %w", err) + } + user := domain.User{ID: uuid.NewString(), DisplayName: name, Status: "ACTIVE", Role: role} + credential := domain.Credential{ID: uuid.NewString(), UserID: user.ID, LoginName: login, PasswordHash: string(hash)} + return s.repo.CreateUser(ctx, user, credential) +} + +type LoginResult struct { + User domain.User + Token string + ExpiresAt time.Time +} + +func (s *AuthService) Login(ctx context.Context, login, password, audience string) (LoginResult, error) { + if audience != "admin" && audience != "app" { + return LoginResult{}, apperror.ErrInvalidInput + } + if s.repo == nil { + return LoginResult{}, apperror.ErrUnavailable + } + login = strings.ToLower(strings.TrimSpace(login)) + if !loginPattern.MatchString(login) || len(password) > 72 || password == "" { + return LoginResult{}, apperror.ErrUnauthenticated + } + user, credential, err := s.repo.FindCredential(ctx, login) + if err != nil { + if errors.Is(err, apperror.ErrNotFound) { + _ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password)) + return LoginResult{}, apperror.ErrUnauthenticated + } + return LoginResult{}, err + } + if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(password)) != nil || user.Status != "ACTIVE" || (audience == "admin" && user.Role != "ADMIN") || (audience == "app" && user.Role != "USER") { + return LoginResult{}, apperror.ErrUnauthenticated + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return LoginResult{}, fmt.Errorf("generate session token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + expires := s.now().UTC().Add(8 * time.Hour) + session := domain.Session{ID: uuid.NewString(), UserID: user.ID, TokenHash: TokenHash(token), Audience: audience, ExpiresAt: expires} + if err := s.repo.CreateSession(ctx, session); err != nil { + return LoginResult{}, err + } + return LoginResult{User: user, Token: token, ExpiresAt: expires}, nil +} + +func (s *AuthService) Authenticate(ctx context.Context, token, audience string) (domain.User, error) { + if s.repo == nil { + return domain.User{}, apperror.ErrUnavailable + } + if len(token) != 43 { + return domain.User{}, apperror.ErrUnauthenticated + } + if audience != "admin" && audience != "app" { + return domain.User{}, apperror.ErrUnauthenticated + } + user, err := s.repo.FindSessionUser(ctx, TokenHash(token), audience, s.now().UTC()) + if err != nil { + return domain.User{}, err + } + if user.Status != "ACTIVE" || audience == "admin" && user.Role != "ADMIN" || audience == "app" && user.Role != "USER" { + return domain.User{}, apperror.ErrUnauthenticated + } + return user, nil +} + +func (s *AuthService) Logout(ctx context.Context, token, audience string) error { + if s.repo == nil { + return apperror.ErrUnavailable + } + return s.repo.RevokeSession(ctx, TokenHash(token), audience, s.now().UTC()) +} + +func (s *AuthService) ListUsers(ctx context.Context) ([]domain.User, error) { + if s.repo == nil { + return nil, apperror.ErrUnavailable + } + return s.repo.ListUsers(ctx, 100) +} + +func TokenHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/services/api/internal/identity/service/auth_test.go b/services/api/internal/identity/service/auth_test.go new file mode 100644 index 0000000..2153a4b --- /dev/null +++ b/services/api/internal/identity/service/auth_test.go @@ -0,0 +1,92 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "veyra/services/api/internal/identity/domain" + "veyra/services/api/internal/platform/apperror" +) + +type testRepository struct { + user domain.User + credential domain.Credential + session domain.Session + createdSessions int +} + +func (r *testRepository) CreateUser(_ context.Context, u domain.User, c domain.Credential) (domain.User, error) { + r.user = u + r.credential = c + return u, nil +} +func (r *testRepository) FindCredential(_ context.Context, login string) (domain.User, domain.Credential, error) { + if login != r.credential.LoginName { + return domain.User{}, domain.Credential{}, apperror.ErrNotFound + } + return r.user, r.credential, nil +} +func (r *testRepository) CreateSession(_ context.Context, s domain.Session) error { + r.session = s + r.createdSessions++ + return nil +} +func (r *testRepository) FindSessionUser(_ context.Context, _, _ string, _ time.Time) (domain.User, error) { + return r.user, nil +} +func (r *testRepository) RevokeSession(_ context.Context, _, _ string, _ time.Time) error { return nil } +func (r *testRepository) ListUsers(context.Context, int) ([]domain.User, error) { + return []domain.User{r.user}, nil +} + +func TestPasswordAndSessionSecretsAreHashed(t *testing.T) { + ctx := context.Background() + repo := &testRepository{} + auth, err := NewAuthService(repo) + if err != nil { + t.Fatal(err) + } + password := "a-development-password" + if _, err := auth.ProvisionUser(ctx, " ADMIN ", password, "Admin", "ADMIN"); err != nil { + t.Fatal(err) + } + if repo.credential.PasswordHash == password || repo.credential.LoginName != "admin" { + t.Fatal("credential handling failed") + } + result, err := auth.Login(ctx, "admin", password, "admin") + if err != nil { + t.Fatal(err) + } + if len(result.Token) != 43 || repo.session.TokenHash == result.Token || repo.session.TokenHash != TokenHash(result.Token) { + t.Fatal("raw session secret persisted") + } + if _, err := auth.Login(ctx, "admin", "incorrect-password", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) { + t.Fatal("wrong password accepted") + } + if _, err := auth.Login(ctx, "admin", password, "app"); !errors.Is(err, apperror.ErrUnauthenticated) { + t.Fatal("admin credential used for app login") + } + if _, err := auth.Login(ctx, "missing", password, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) { + t.Fatal("missing identity was disclosed") + } + if repo.createdSessions != 1 { + t.Fatal("failed login created a session") + } + repo.user.Status = "DISABLED" + if _, err := auth.Authenticate(ctx, result.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) { + t.Fatal("disabled user remained authenticated") + } +} + +func TestUserCannotAuthenticateAsAdmin(t *testing.T) { + repo := &testRepository{user: domain.User{Status: "ACTIVE", Role: "USER"}} + auth, err := NewAuthService(repo) + if err != nil { + t.Fatal(err) + } + if _, err := auth.Authenticate(context.Background(), "1234567890123456789012345678901234567890123", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) { + t.Fatal("user role passed admin authorization") + } +} diff --git a/services/api/internal/integration/identity_account_test.go b/services/api/internal/integration/identity_account_test.go new file mode 100644 index 0000000..f8e9eab --- /dev/null +++ b/services/api/internal/integration/identity_account_test.go @@ -0,0 +1,183 @@ +package integration + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + identityrepo "veyra/services/api/internal/identity/repository" + identity "veyra/services/api/internal/identity/service" + operations "veyra/services/api/internal/operations/service" + "veyra/services/api/internal/platform/apperror" + "veyra/services/api/internal/platform/database" + portfoliorepo "veyra/services/api/internal/portfolio/repository" + portfolio "veyra/services/api/internal/portfolio/service" + transport "veyra/services/api/internal/transport/http" +) + +func TestIdentityAccountLifecycle(t *testing.T) { + dsn := os.Getenv("VEYRA_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set VEYRA_TEST_DATABASE_URL for real database integration") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + admin, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + schema := "veyra_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatal(err) + } + defer func() { + if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE"); err != nil { + t.Error(err) + } + }() + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Fatal(err) + } + cfg.ConnConfig.RuntimeParams["search_path"] = schema + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + if err := database.Migrate(ctx, pool); err != nil { + t.Fatal(err) + } + auth, err := identity.NewAuthService(identityrepo.NewPostgres(pool)) + if err != nil { + t.Fatal(err) + } + u1, err := auth.ProvisionUser(ctx, "alice", "a-test-only-password", "Alice", "USER") + if err != nil { + t.Fatal(err) + } + u2, err := auth.ProvisionUser(ctx, "bob", "another-test-password", "Bob", "USER") + if err != nil { + t.Fatal(err) + } + if _, err := auth.ProvisionUser(ctx, "alice", "a-test-only-password", "Duplicate", "USER"); !errors.Is(err, apperror.ErrConflict) { + t.Fatalf("duplicate identity: %v", err) + } + var count int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_user").Scan(&count); err != nil || count != 2 { + t.Fatal("credential conflict left an orphan user") + } + login, err := auth.Login(ctx, "alice", "a-test-only-password", "app") + if err != nil { + t.Fatal(err) + } + if _, err := auth.Authenticate(ctx, login.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) { + t.Fatal("app session escaped audience") + } + if _, err := auth.Authenticate(ctx, login.Token, "app"); err != nil { + t.Fatal(err) + } + if err := auth.Logout(ctx, login.Token, "app"); err != nil { + t.Fatal(err) + } + if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) { + t.Fatal("revoked session remained valid") + } + accounts := portfolio.NewAccountService(portfoliorepo.NewPostgres(pool)) + cash := "100000.01" + a, err := accounts.Create(ctx, u1.ID, "simulation", "Simulation", &cash) + if err != nil || a.CashBalance == nil || *a.CashBalance != cash { + t.Fatalf("simulation balance: %v", err) + } + if _, err := accounts.Get(ctx, u2.ID, a.ID); !errors.Is(err, apperror.ErrNotFound) { + t.Fatal("cross-user account disclosed") + } + if _, err := accounts.Create(ctx, u1.ID, "simulation", "Duplicate", &cash); !errors.Is(err, apperror.ErrConflict) { + t.Fatal("duplicate simulation account accepted") + } + if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_cash_entry").Scan(&count); err != nil || count != 1 { + t.Fatal("duplicate creation left a cash entry") + } + real, err := accounts.Create(ctx, u1.ID, "manual_real", "Manual", nil) + if err != nil || real.CashBalance != nil || real.LedgerStatus != "INCOMPLETE" { + t.Fatalf("manual account fabricated cash: %v", err) + } + if _, err := accounts.Create(ctx, u2.ID, "manual_real", "Manual", &cash); !errors.Is(err, apperror.ErrInvalidInput) { + t.Fatal("manual account accepted simulated funding") + } + list, err := accounts.List(ctx, u2.ID) + if err != nil || len(list) != 0 { + t.Fatal("account list escaped owner scope") + } + login, err = auth.Login(ctx, "alice", "a-test-only-password", "app") + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, "UPDATE veyra_session SET created_at=now()-interval '1 day',expires_at=now()-interval '1 hour' WHERE token_hash=$1", identity.TokenHash(login.Token)); err != nil { + t.Fatal(err) + } + if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) { + t.Fatal("expired session remained valid") + } + if _, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN"); err != nil { + t.Fatal(err) + } + probe := func(context.Context) error { return nil } + router := transport.NewRouter(operations.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), transport.IdentityOptions{Auth: auth, Accounts: accounts, Limiter: allowLogin{}, DevelopmentLogin: true}) + call := func(method, path, body, origin string, cookie *http.Cookie) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if origin != "" { + req.Header.Set("Origin", origin) + } + if cookie != nil { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + response := call(http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"an-admin-test-password"}`, "http://example.com", nil) + if response.Code != http.StatusOK { + t.Fatalf("admin login failed: %d", response.Code) + } + cookies := response.Result().Cookies() + if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode || cookies[0].Path != "/api/admin" { + t.Fatal("admin cookie constraints missing") + } + cookie := cookies[0] + if strings.Contains(response.Body.String(), cookie.Value) { + t.Fatal("admin token leaked into response body") + } + if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK { + t.Fatal("cookie authentication failed") + } + if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://attacker.example", cookie); response.Code != http.StatusForbidden { + t.Fatal("cross-origin logout allowed") + } + if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK { + t.Fatal("CSRF attempt revoked a session") + } + if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://example.com", cookie); response.Code != http.StatusOK { + t.Fatal("logout failed") + } + if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusUnauthorized { + t.Fatal("revoked admin cookie remained valid") + } +} + +type allowLogin struct{} + +func (allowLogin) Login(context.Context, string, string) (bool, error) { return true, nil } diff --git a/services/api/internal/operations/service/health.go b/services/api/internal/operations/service/health.go new file mode 100644 index 0000000..c7a4f61 --- /dev/null +++ b/services/api/internal/operations/service/health.go @@ -0,0 +1,49 @@ +package service + +import ( + "context" + "sync" + "time" +) + +type Probe func(context.Context) error + +type Component struct { + Status string `json:"status"` +} +type Health struct { + Status string `json:"status"` + CheckedAt time.Time `json:"checked_at"` + Components map[string]Component `json:"components"` +} +type HealthService struct{ postgres, redis Probe } + +func NewHealthService(postgres, redis Probe) *HealthService { + return &HealthService{postgres: postgres, redis: redis} +} + +func (s *HealthService) Check(ctx context.Context) Health { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + result := Health{Status: "ready", CheckedAt: time.Now().UTC(), Components: map[string]Component{}} + var mu sync.Mutex + var wg sync.WaitGroup + for name, probe := range map[string]Probe{"postgresql": s.postgres, "redis": s.redis} { + wg.Go(func() { + status := "unavailable" + if probe == nil { + status = "not_configured" + } else if probe(ctx) == nil { + status = "ready" + } + mu.Lock() + defer mu.Unlock() + result.Components[name] = Component{Status: status} + if status != "ready" { + result.Status = "not_ready" + } + }) + } + wg.Wait() + return result +} diff --git a/services/api/internal/platform/adminui/dist/.keep b/services/api/internal/platform/adminui/dist/.keep new file mode 100644 index 0000000..26234f8 --- /dev/null +++ b/services/api/internal/platform/adminui/dist/.keep @@ -0,0 +1 @@ +Build apps/admin to populate this directory before building the release binary. diff --git a/services/api/internal/platform/adminui/handler.go b/services/api/internal/platform/adminui/handler.go new file mode 100644 index 0000000..fd69910 --- /dev/null +++ b/services/api/internal/platform/adminui/handler.go @@ -0,0 +1,55 @@ +// Package adminui serves the compiled management console from the API binary. +package adminui + +import ( + "embed" + "io/fs" + "net/http" + "path" + "strings" +) + +//go:embed all:dist +var assets embed.FS + +func Handler() http.Handler { + root, _ := fs.Sub(assets, "dist") + files := http.FileServer(http.FS(root)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") + if strings.HasPrefix(name, ".") || strings.Contains(name, "/.") { + http.NotFound(w, r) + return + } + if _, err := fs.Stat(root, "index.html"); err != nil { + http.Error(w, "Management console not built. Run make admin-build, then rebuild the API.", http.StatusServiceUnavailable) + return + } + if name != "" && name != "." { + if info, err := fs.Stat(root, name); err == nil && !info.IsDir() { + files.ServeHTTP(w, r) + return + } + } + if path.Ext(name) != "" || strings.HasPrefix(name, "assets/") { + http.NotFound(w, r) + return + } + index, err := fs.ReadFile(root, "index.html") + if err != nil { + http.Error(w, "Management console unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = w.Write(index) + } + }) +} diff --git a/services/api/internal/platform/apperror/errors.go b/services/api/internal/platform/apperror/errors.go new file mode 100644 index 0000000..aeec77f --- /dev/null +++ b/services/api/internal/platform/apperror/errors.go @@ -0,0 +1,13 @@ +// Package apperror defines safe application errors shared across module boundaries. +package apperror + +import "errors" + +var ( + ErrInvalidInput = errors.New("invalid input") + ErrUnauthenticated = errors.New("authentication required") + ErrForbidden = errors.New("permission denied") + ErrConflict = errors.New("already exists") + ErrNotFound = errors.New("not found") + ErrUnavailable = errors.New("service unavailable") +) diff --git a/services/api/internal/platform/config/config.go b/services/api/internal/platform/config/config.go new file mode 100644 index 0000000..dbbaf83 --- /dev/null +++ b/services/api/internal/platform/config/config.go @@ -0,0 +1,105 @@ +// Package config loads and validates application configuration without logging secrets. +package config + +import ( + "bufio" + "fmt" + "net" + "net/url" + "os" + "strconv" + "strings" +) + +type Config struct { + Environment, HTTPAddr string + PGHost, PGPort, PGDatabase, PGUser, PGPassword, PGSSLMode, PGAdminDatabase string + RedisAddr, RedisUsername, RedisPassword string + RedisDB int +} + +// LoadEnvFile reads literal KEY=VALUE entries. Existing environment values win. +// It never evaluates shell expressions or prints values. +func LoadEnvFile(path string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open configuration file: %w", err) + } + defer f.Close() + s := bufio.NewScanner(f) + for line := 1; s.Scan(); line++ { + v := strings.TrimSpace(s.Text()) + if v == "" || strings.HasPrefix(v, "#") { + continue + } + key, value, ok := strings.Cut(v, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return fmt.Errorf("invalid configuration at line %d", line) + } + for i, r := range key { + if !(r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9') { + return fmt.Errorf("invalid key at line %d", line) + } + } + value = strings.TrimSpace(value) + if len(value) >= 2 && (value[0] == '\'' && value[len(value)-1] == '\'' || value[0] == '"' && value[len(value)-1] == '"') { + value = value[1 : len(value)-1] + } + if _, exists := os.LookupEnv(key); !exists { + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("set configuration at line %d: %w", line, err) + } + } + } + return s.Err() +} + +func Load() (Config, error) { + c := Config{Environment: env("APP_ENV", "local"), HTTPAddr: env("HTTP_ADDR", "127.0.0.1:8080"), PGHost: env("PGHOST", "192.168.100.127"), PGPort: env("PGPORT", "5432"), PGDatabase: env("PGDATABASE", "veyra"), PGUser: os.Getenv("PGUSER"), PGPassword: os.Getenv("PGPASSWORD"), PGSSLMode: env("PGSSLMODE", "disable"), PGAdminDatabase: env("PGADMIN_DATABASE", "postgres"), RedisAddr: env("REDIS_ADDR", "192.168.100.127:6379"), RedisUsername: os.Getenv("REDIS_USERNAME"), RedisPassword: os.Getenv("REDIS_PASSWORD")} + var err error + c.RedisDB, err = strconv.Atoi(env("REDIS_DB", "0")) + if err != nil || c.RedisDB < 0 { + return Config{}, fmt.Errorf("REDIS_DB must be a nonnegative integer") + } + port, err := strconv.Atoi(c.PGPort) + if err != nil || port < 1 || port > 65535 { + return Config{}, fmt.Errorf("PGPORT must be a valid port") + } + if _, _, err := net.SplitHostPort(c.HTTPAddr); err != nil { + return Config{}, fmt.Errorf("invalid HTTP_ADDR") + } + if _, _, err := net.SplitHostPort(c.RedisAddr); err != nil { + return Config{}, fmt.Errorf("invalid REDIS_ADDR") + } + switch c.PGSSLMode { + case "disable", "allow", "prefer", "require", "verify-ca", "verify-full": + default: + return Config{}, fmt.Errorf("invalid PGSSLMODE") + } + return c, nil +} + +func (c Config) ValidateDatabase() error { + if c.PGUser == "" { + return fmt.Errorf("PGUSER is required; configure the local .env file") + } + if c.PGHost == "" || c.PGDatabase == "" || c.PGAdminDatabase == "" { + return fmt.Errorf("database host and names are required") + } + return nil +} + +func (c Config) DatabaseURL(database string) string { + u := url.URL{Scheme: "postgres", Host: net.JoinHostPort(c.PGHost, c.PGPort), User: url.UserPassword(c.PGUser, c.PGPassword), Path: "/" + database} + q := url.Values{"sslmode": {c.PGSSLMode}, "connect_timeout": {"5"}} + u.RawQuery = q.Encode() + return u.String() +} + +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/services/api/internal/platform/config/config_test.go b/services/api/internal/platform/config/config_test.go new file mode 100644 index 0000000..e31bfb2 --- /dev/null +++ b/services/api/internal/platform/config/config_test.go @@ -0,0 +1,25 @@ +package config + +import ( + "net/url" + "testing" +) + +func TestDatabaseURLPreservesCredentials(t *testing.T) { + c := Config{PGHost: "192.168.100.127", PGPort: "5432", PGUser: "user@name", PGPassword: "p@ss:/?#&", PGSSLMode: "require"} + u, err := url.Parse(c.DatabaseURL("veyra")) + if err != nil { + t.Fatal(err) + } + password, _ := u.User.Password() + if password != c.PGPassword || u.User.Username() != c.PGUser || u.Path != "/veyra" || u.Query().Get("sslmode") != "require" { + t.Fatal("connection URL changed configuration values") + } +} + +func TestRejectInvalidRedisDB(t *testing.T) { + t.Setenv("REDIS_DB", "-1") + if _, err := Load(); err == nil { + t.Fatal("negative database must be rejected") + } +} diff --git a/services/api/internal/platform/database/database.go b/services/api/internal/platform/database/database.go new file mode 100644 index 0000000..55bf81e --- /dev/null +++ b/services/api/internal/platform/database/database.go @@ -0,0 +1,111 @@ +package database + +import ( + "context" + "crypto/sha256" + "embed" + "fmt" + "sort" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "veyra/services/api/internal/platform/config" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +func Open(ctx context.Context, c config.Config) (*pgxpool.Pool, error) { + if err := c.ValidateDatabase(); err != nil { + return nil, err + } + p, err := pgxpool.New(ctx, c.DatabaseURL(c.PGDatabase)) + if err != nil { + return nil, fmt.Errorf("configure PostgreSQL pool") + } + if err := p.Ping(ctx); err != nil { + p.Close() + return nil, fmt.Errorf("PostgreSQL connection unavailable") + } + return p, nil +} + +// Create creates only the configured database and never drops or resets data. +func Create(ctx context.Context, c config.Config) (bool, error) { + if err := c.ValidateDatabase(); err != nil { + return false, err + } + conn, err := pgx.Connect(ctx, c.DatabaseURL(c.PGAdminDatabase)) + if err != nil { + return false, fmt.Errorf("administrative PostgreSQL connection unavailable") + } + defer conn.Close(context.Background()) + if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock(hashtext($1)::bigint)", "veyra:create:"+c.PGDatabase); err != nil { + return false, fmt.Errorf("lock database creation: %w", err) + } + var exists bool + if err := conn.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", c.PGDatabase).Scan(&exists); err != nil { + return false, fmt.Errorf("inspect database: %w", err) + } + if exists { + return false, nil + } + if _, err := conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{c.PGDatabase}.Sanitize()); err != nil { + return false, fmt.Errorf("create database failed; verify role CREATEDB permission") + } + return true, nil +} + +// Migrate serializes concurrent runners and atomically records each migration. +// Even the migration ledger follows the veyra_ prefix and string UUID ID rules. +func Migrate(ctx context.Context, pool *pgxpool.Pool) error { + conn, err := pool.Acquire(ctx) + if err != nil { + return err + } + defer conn.Release() + tx, err := conn.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(context.Background()) + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(727198321)"); err != nil { + return err + } + if _, err := tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS veyra_schema_migration ( +id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'), +version varchar(120) NOT NULL UNIQUE, checksum varchar(64) NOT NULL, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { + return fmt.Errorf("create migration ledger: %w", err) + } + entries, err := migrations.ReadDir("migrations") + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + sql, err := migrations.ReadFile("migrations/" + entry.Name()) + if err != nil { + return err + } + checksum := fmt.Sprintf("%x", sha256.Sum256(sql)) + var stored string + err = tx.QueryRow(ctx, "SELECT checksum FROM veyra_schema_migration WHERE version=$1", entry.Name()).Scan(&stored) + if err == nil { + if stored != checksum { + return fmt.Errorf("migration %s checksum mismatch", entry.Name()) + } + continue + } + if err != pgx.ErrNoRows { + return err + } + if _, err := tx.Exec(ctx, string(sql)); err != nil { + return fmt.Errorf("apply %s: %w", entry.Name(), err) + } + if _, err := tx.Exec(ctx, "INSERT INTO veyra_schema_migration(id,version,checksum) VALUES($1,$2,$3)", uuid.NewString(), entry.Name(), checksum); err != nil { + return err + } + } + return tx.Commit(ctx) +} diff --git a/services/api/internal/platform/database/database_test.go b/services/api/internal/platform/database/database_test.go new file mode 100644 index 0000000..a0b249d --- /dev/null +++ b/services/api/internal/platform/database/database_test.go @@ -0,0 +1,67 @@ +package database + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Integration testing uses a disposable schema on an explicitly provided test database. +func TestMigrationIntegration(t *testing.T) { + dsn := os.Getenv("VEYRA_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set VEYRA_TEST_DATABASE_URL to run PostgreSQL integration tests") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + admin, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + schema := "veyra_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatal(err) + } + defer func() { + if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE"); err != nil { + t.Error(err) + } + }() + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Fatal(err) + } + cfg.ConnConfig.RuntimeParams["search_path"] = schema + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + for i := 0; i < 2; i++ { + if err := Migrate(ctx, pool); err != nil { + t.Fatal(err) + } + } + var count int + if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_schema_migration").Scan(&count); err != nil || count != 2 { + t.Fatalf("migration ledger count=%d err=%v", count, err) + } + if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES($1,$2)", uuid.NewString(), "Test"); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES('invalid','Test')"); err == nil { + t.Fatal("invalid UUID was accepted") + } + if _, err := pool.Exec(ctx, "UPDATE veyra_schema_migration SET checksum='changed'"); err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, pool); err == nil { + t.Fatal("modified migration checksum was accepted") + } +} diff --git a/services/api/internal/platform/database/migrations/000001_foundation.sql b/services/api/internal/platform/database/migrations/000001_foundation.sql new file mode 100644 index 0000000..7fc9e70 --- /dev/null +++ b/services/api/internal/platform/database/migrations/000001_foundation.sql @@ -0,0 +1,18 @@ +CREATE TABLE veyra_user ( + id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'), + display_name varchar(80) NOT NULL CHECK (length(trim(display_name)) > 0), + status varchar(16) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'DISABLED')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE veyra_account ( + id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'), + user_id varchar(36) NOT NULL REFERENCES veyra_user(id), + mode varchar(16) NOT NULL CHECK (mode IN ('simulation', 'manual_real')), + name varchar(80) NOT NULL CHECK (length(trim(name)) > 0), + currency varchar(3) NOT NULL DEFAULT 'CNY' CHECK (currency = 'CNY'), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, mode) +); +CREATE INDEX veyra_account_user_id_idx ON veyra_account(user_id); diff --git a/services/api/internal/platform/database/migrations/000002_identity_account.sql b/services/api/internal/platform/database/migrations/000002_identity_account.sql new file mode 100644 index 0000000..78301a9 --- /dev/null +++ b/services/api/internal/platform/database/migrations/000002_identity_account.sql @@ -0,0 +1,32 @@ +ALTER TABLE veyra_user ADD COLUMN role varchar(16) NOT NULL DEFAULT 'USER' CHECK (role IN ('USER', 'ADMIN')); + +CREATE TABLE veyra_credential ( + id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'), + user_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_user(id), + login_name varchar(64) NOT NULL UNIQUE CHECK (login_name ~ '^[a-z0-9_]{3,64}$'), + password_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE veyra_session ( + id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'), + user_id varchar(36) NOT NULL REFERENCES veyra_user(id), + token_hash varchar(64) NOT NULL UNIQUE, + audience varchar(8) NOT NULL CHECK (audience IN ('app', 'admin')), + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (expires_at > created_at) +); +CREATE INDEX veyra_session_user_id_idx ON veyra_session(user_id); + +ALTER TABLE veyra_account ADD COLUMN ledger_status varchar(16) NOT NULL DEFAULT 'INCOMPLETE' CHECK (ledger_status IN ('COMPLETE', 'INCOMPLETE')); + +CREATE TABLE veyra_cash_entry ( + id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'), + account_id varchar(36) NOT NULL REFERENCES veyra_account(id), + entry_type varchar(16) NOT NULL CHECK (entry_type IN ('INITIAL')), + amount_minor bigint NOT NULL CHECK (amount_minor >= 0 AND amount_minor <= 100000000000), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (account_id, entry_type) +); diff --git a/services/api/internal/platform/database/schema_migration.go b/services/api/internal/platform/database/schema_migration.go new file mode 100644 index 0000000..b736d1d --- /dev/null +++ b/services/api/internal/platform/database/schema_migration.go @@ -0,0 +1,12 @@ +package database + +import "time" + +type SchemaMigration struct { + ID string `db:"id"` + Version string `db:"version"` + Checksum string `db:"checksum"` + AppliedAt time.Time `db:"applied_at"` +} + +func (SchemaMigration) TableName() string { return "veyra_schema_migration" } diff --git a/services/api/internal/platform/ratelimit/redis.go b/services/api/internal/platform/ratelimit/redis.go new file mode 100644 index 0000000..acbe9ba --- /dev/null +++ b/services/api/internal/platform/ratelimit/redis.go @@ -0,0 +1,34 @@ +package ratelimit + +import ( + "context" + "crypto/sha256" + "fmt" + + "github.com/redis/go-redis/v9" +) + +type Redis struct{ client *redis.Client } + +func NewRedis(client *redis.Client) *Redis { return &Redis{client: client} } + +var loginCounter = redis.NewScript(`local n = redis.call('INCR', KEYS[1]); if n == 1 then redis.call('EXPIRE', KEYS[1], 900) end; return n`) + +// Login permits at most 20 attempts per IP and 10 per login in a 15-minute window. +// Redis errors fail closed. Neither credential nor IP is stored in plaintext keys. +func (r *Redis) Login(ctx context.Context, ip, login string) (bool, error) { + for _, item := range []struct { + kind, value string + limit int64 + }{{"ip", ip, 20}, {"name", login, 10}} { + key := fmt.Sprintf("veyra:login:%s:%x", item.kind, sha256.Sum256([]byte(item.value))) + n, err := loginCounter.Run(ctx, r.client, []string{key}).Int64() + if err != nil { + return false, err + } + if n > item.limit { + return false, nil + } + } + return true, nil +} diff --git a/services/api/internal/portfolio/domain/account.go b/services/api/internal/portfolio/domain/account.go new file mode 100644 index 0000000..ff74346 --- /dev/null +++ b/services/api/internal/portfolio/domain/account.go @@ -0,0 +1,15 @@ +package domain + +import "time" + +type Account struct { + ID string `db:"id" json:"id"` + UserID string `db:"user_id" json:"user_id"` + Mode string `db:"mode" json:"mode"` + Name string `db:"name" json:"name"` + Currency string `db:"currency" json:"currency"` + LedgerStatus string `db:"ledger_status" json:"ledger_status"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (Account) TableName() string { return "veyra_account" } diff --git a/services/api/internal/portfolio/domain/cash_entry.go b/services/api/internal/portfolio/domain/cash_entry.go new file mode 100644 index 0000000..5bcab04 --- /dev/null +++ b/services/api/internal/portfolio/domain/cash_entry.go @@ -0,0 +1,19 @@ +package domain + +import "time" + +// CashEntry amounts are integer CNY cents; no floating-point accounting. +type CashEntry struct { + ID string `db:"id"` + AccountID string `db:"account_id"` + EntryType string `db:"entry_type"` + AmountMinor int64 `db:"amount_minor"` + CreatedAt time.Time `db:"created_at"` +} + +func (CashEntry) TableName() string { return "veyra_cash_entry" } + +type AccountSummary struct { + Account + CashBalance *string `json:"cash_balance"` +} diff --git a/services/api/internal/portfolio/domain/money.go b/services/api/internal/portfolio/domain/money.go new file mode 100644 index 0000000..be77705 --- /dev/null +++ b/services/api/internal/portfolio/domain/money.go @@ -0,0 +1,35 @@ +package domain + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "veyra/services/api/internal/platform/apperror" +) + +var moneyPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,9})(\.[0-9]{1,2})?$`) + +// ParseMoney converts decimal text into exact cents, rejecting rounding and exponents. +func ParseMoney(value string) (int64, error) { + if !moneyPattern.MatchString(value) { + return 0, apperror.ErrInvalidInput + } + whole, fraction, _ := strings.Cut(value, ".") + units, err := strconv.ParseInt(whole, 10, 64) + if err != nil { + return 0, apperror.ErrInvalidInput + } + fraction += strings.Repeat("0", 2-len(fraction)) + cents, err := strconv.ParseInt(fraction, 10, 64) + if err != nil { + return 0, apperror.ErrInvalidInput + } + amount := units*100 + cents + if amount > 100000000000 { + return 0, apperror.ErrInvalidInput + } + return amount, nil +} + +func FormatMoney(minor int64) string { return fmt.Sprintf("%d.%02d", minor/100, minor%100) } diff --git a/services/api/internal/portfolio/domain/money_test.go b/services/api/internal/portfolio/domain/money_test.go new file mode 100644 index 0000000..637cdf2 --- /dev/null +++ b/services/api/internal/portfolio/domain/money_test.go @@ -0,0 +1,21 @@ +package domain + +import "testing" + +func TestExactMoney(t *testing.T) { + for _, tc := range []struct { + input string + minor int64 + formatted string + }{{"0", 0, "0.00"}, {"0.01", 1, "0.01"}, {"100000.10", 10000010, "100000.10"}, {"3.1", 310, "3.10"}, {"1000000000.00", 100000000000, "1000000000.00"}} { + amount, err := ParseMoney(tc.input) + if err != nil || amount != tc.minor || FormatMoney(amount) != tc.formatted { + t.Fatalf("%s: amount=%d err=%v", tc.input, amount, err) + } + } + for _, input := range []string{"-1", "1.001", "1e3", "NaN", " 1", "01", "1.", "1000000000.01", "999999999999999999999", ""} { + if _, err := ParseMoney(input); err == nil { + t.Fatalf("accepted invalid amount %q", input) + } + } +} diff --git a/services/api/internal/portfolio/repository/postgres.go b/services/api/internal/portfolio/repository/postgres.go new file mode 100644 index 0000000..aa9cdbd --- /dev/null +++ b/services/api/internal/portfolio/repository/postgres.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "errors" + "veyra/services/api/internal/platform/apperror" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "veyra/services/api/internal/portfolio/domain" +) + +type Postgres struct{ pool *pgxpool.Pool } + +func NewPostgres(pool *pgxpool.Pool) *Postgres { return &Postgres{pool: pool} } + +func (r *Postgres) Create(ctx context.Context, a domain.Account, entry *domain.CashEntry) (domain.AccountSummary, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return domain.AccountSummary{}, apperror.ErrUnavailable + } + defer tx.Rollback(context.Background()) + // Lock the owner so account creation cannot race with disabling the user. + var status string + if err := tx.QueryRow(ctx, `SELECT status FROM veyra_user WHERE id=$1 AND role='USER' FOR UPDATE`, a.UserID).Scan(&status); err != nil { + return domain.AccountSummary{}, mapError(err) + } + if status != "ACTIVE" { + return domain.AccountSummary{}, apperror.ErrForbidden + } + err = tx.QueryRow(ctx, `INSERT INTO veyra_account(id,user_id,mode,name,currency,ledger_status) VALUES($1,$2,$3,$4,$5,$6) RETURNING created_at`, a.ID, a.UserID, a.Mode, a.Name, a.Currency, a.LedgerStatus).Scan(&a.CreatedAt) + if err != nil { + return domain.AccountSummary{}, mapError(err) + } + var balance *string + if entry != nil { + if _, err := tx.Exec(ctx, `INSERT INTO veyra_cash_entry(id,account_id,entry_type,amount_minor) VALUES($1,$2,$3,$4)`, entry.ID, entry.AccountID, entry.EntryType, entry.AmountMinor); err != nil { + return domain.AccountSummary{}, mapError(err) + } + value := domain.FormatMoney(entry.AmountMinor) + balance = &value + } + if err := tx.Commit(ctx); err != nil { + return domain.AccountSummary{}, apperror.ErrUnavailable + } + return domain.AccountSummary{Account: a, CashBalance: balance}, nil +} + +const summarySelect = `SELECT a.id,a.user_id,a.mode,a.name,a.currency,a.ledger_status,a.created_at,CASE WHEN a.ledger_status='COMPLETE' THEN (SELECT COALESCE(sum(e.amount_minor),0)::bigint FROM veyra_cash_entry e WHERE e.account_id=a.id) ELSE NULL END FROM veyra_account a` + +func scanSummary(row pgx.Row) (domain.AccountSummary, error) { + var s domain.AccountSummary + var minor *int64 + err := row.Scan(&s.ID, &s.UserID, &s.Mode, &s.Name, &s.Currency, &s.LedgerStatus, &s.CreatedAt, &minor) + if err != nil { + return s, mapError(err) + } + if minor != nil { + value := domain.FormatMoney(*minor) + s.CashBalance = &value + } + return s, nil +} + +func (r *Postgres) Get(ctx context.Context, userID, accountID string) (domain.AccountSummary, error) { + return scanSummary(r.pool.QueryRow(ctx, summarySelect+` WHERE a.user_id=$1 AND a.id=$2`, userID, accountID)) +} + +func (r *Postgres) List(ctx context.Context, userID string) ([]domain.AccountSummary, error) { + rows, err := r.pool.Query(ctx, summarySelect+` WHERE a.user_id=$1 ORDER BY a.created_at,a.id`, userID) + if err != nil { + return nil, apperror.ErrUnavailable + } + defer rows.Close() + list := make([]domain.AccountSummary, 0) + for rows.Next() { + s, err := scanSummary(rows) + if err != nil { + return nil, err + } + list = append(list, s) + } + return list, mapError(rows.Err()) +} + +func mapError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, pgx.ErrNoRows) { + return apperror.ErrNotFound + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return apperror.ErrConflict + } + return apperror.ErrUnavailable +} diff --git a/services/api/internal/portfolio/service/account.go b/services/api/internal/portfolio/service/account.go new file mode 100644 index 0000000..d9884a6 --- /dev/null +++ b/services/api/internal/portfolio/service/account.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "strings" + "unicode/utf8" + "veyra/services/api/internal/platform/apperror" + + "github.com/google/uuid" + "veyra/services/api/internal/portfolio/domain" +) + +type Repository interface { + Create(context.Context, domain.Account, *domain.CashEntry) (domain.AccountSummary, error) + List(context.Context, string) ([]domain.AccountSummary, error) + Get(context.Context, string, string) (domain.AccountSummary, error) +} + +type AccountService struct{ repo Repository } + +func NewAccountService(repo Repository) *AccountService { return &AccountService{repo: repo} } + +func (s *AccountService) Create(ctx context.Context, userID, mode, name string, initialCash *string) (domain.AccountSummary, error) { + if s.repo == nil { + return domain.AccountSummary{}, apperror.ErrUnavailable + } + name = strings.TrimSpace(name) + if name == "" || utf8.RuneCountInString(name) > 80 { + return domain.AccountSummary{}, apperror.ErrInvalidInput + } + account := domain.Account{ID: uuid.NewString(), UserID: userID, Mode: mode, Name: name, Currency: "CNY", LedgerStatus: "INCOMPLETE"} + var entry *domain.CashEntry + switch mode { + case "simulation": + if initialCash == nil { + return domain.AccountSummary{}, apperror.ErrInvalidInput + } + amount, err := domain.ParseMoney(*initialCash) + if err != nil { + return domain.AccountSummary{}, err + } + account.LedgerStatus = "COMPLETE" + entry = &domain.CashEntry{ID: uuid.NewString(), AccountID: account.ID, EntryType: "INITIAL", AmountMinor: amount} + case "manual_real": + if initialCash != nil { + return domain.AccountSummary{}, apperror.ErrInvalidInput + } + default: + return domain.AccountSummary{}, apperror.ErrInvalidInput + } + return s.repo.Create(ctx, account, entry) +} + +func (s *AccountService) List(ctx context.Context, userID string) ([]domain.AccountSummary, error) { + if s.repo == nil { + return nil, apperror.ErrUnavailable + } + return s.repo.List(ctx, userID) +} + +func (s *AccountService) Get(ctx context.Context, userID, accountID string) (domain.AccountSummary, error) { + if s.repo == nil { + return domain.AccountSummary{}, apperror.ErrUnavailable + } + if _, err := uuid.Parse(accountID); err != nil { + return domain.AccountSummary{}, apperror.ErrNotFound + } + return s.repo.Get(ctx, userID, accountID) +} diff --git a/services/api/internal/transport/http/identity.go b/services/api/internal/transport/http/identity.go new file mode 100644 index 0000000..3317176 --- /dev/null +++ b/services/api/internal/transport/http/identity.go @@ -0,0 +1,252 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" + "veyra/services/api/internal/platform/apperror" + + "github.com/gin-gonic/gin" + "veyra/services/api/internal/identity/domain" + identity "veyra/services/api/internal/identity/service" + portfolio "veyra/services/api/internal/portfolio/service" +) + +type LoginLimiter interface { + Login(context.Context, string, string) (bool, error) +} +type IdentityOptions struct { + Auth *identity.AuthService + Accounts *portfolio.AccountService + Limiter LoginLimiter + DevelopmentLogin bool + SecureCookie bool +} + +const sessionCookie = "veyra_admin_session" + +func bindIdentity(r *gin.Engine, options IdentityOptions) { + r.SetTrustedProxies(nil) + adminAuth := func(c *gin.Context) { authenticate(c, options, "admin") } + appAuth := func(c *gin.Context) { authenticate(c, options, "app") } + login := func(audience string) gin.HandlerFunc { + return func(c *gin.Context) { + if !options.DevelopmentLogin { + fail(c, apperror.ErrUnavailable) + return + } + if audience == "admin" && !sameOrigin(c.Request) { + fail(c, apperror.ErrForbidden) + return + } + var input struct { + LoginName string `json:"login_name"` + Password string `json:"password"` + } + if !decode(c, &input) { + return + } + if len(input.LoginName) > 64 || len(input.Password) > 72 { + fail(c, apperror.ErrUnauthenticated) + return + } + if options.Auth == nil || options.Limiter == nil { + fail(c, apperror.ErrUnavailable) + return + } + allowed, err := options.Limiter.Login(c.Request.Context(), c.ClientIP(), strings.ToLower(strings.TrimSpace(input.LoginName))) + if err != nil { + fail(c, apperror.ErrUnavailable) + return + } + if !allowed { + c.Header("Retry-After", "900") + failure(c, http.StatusTooManyRequests, "RATE_LIMITED", "登录尝试过于频繁,请稍后再试。", true) + return + } + result, err := options.Auth.Login(c.Request.Context(), input.LoginName, input.Password, audience) + if err != nil { + fail(c, err) + return + } + if audience == "admin" { + http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: result.Token, Path: "/api/admin", HttpOnly: true, Secure: options.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: 8 * 60 * 60, Expires: result.ExpiresAt}) + success(c, http.StatusOK, gin.H{"user": result.User, "expires_at": result.ExpiresAt}) + } else { + success(c, http.StatusOK, gin.H{"user": result.User, "access_token": result.Token, "expires_at": result.ExpiresAt}) + } + } + } + r.POST("/api/admin/v1/auth/login", login("admin")) + r.POST("/api/v1/auth/login", login("app")) + logout := func(audience string) gin.HandlerFunc { + return func(c *gin.Context) { + if audience == "admin" && !sameOrigin(c.Request) { + fail(c, apperror.ErrForbidden) + return + } + token := requestToken(c.Request, audience) + if err := options.Auth.Logout(c.Request.Context(), token, audience); err != nil { + fail(c, err) + return + } + if audience == "admin" { + http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: "", Path: "/api/admin", HttpOnly: true, Secure: options.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1}) + } + success(c, http.StatusOK, gin.H{"revoked": true}) + } + } + r.POST("/api/admin/v1/auth/logout", adminAuth, logout("admin")) + r.POST("/api/v1/auth/logout", appAuth, logout("app")) + r.GET("/api/admin/v1/me", adminAuth, func(c *gin.Context) { success(c, http.StatusOK, currentUser(c)) }) + r.GET("/api/v1/me", appAuth, func(c *gin.Context) { success(c, http.StatusOK, currentUser(c)) }) + r.GET("/api/admin/v1/users", adminAuth, func(c *gin.Context) { + users, err := options.Auth.ListUsers(c.Request.Context()) + if err != nil { + fail(c, err) + return + } + success(c, http.StatusOK, users) + }) + r.GET("/api/v1/accounts", appAuth, func(c *gin.Context) { + if options.Accounts == nil { + fail(c, apperror.ErrUnavailable) + return + } + list, err := options.Accounts.List(c.Request.Context(), currentUser(c).ID) + if err != nil { + fail(c, err) + return + } + success(c, http.StatusOK, list) + }) + r.POST("/api/v1/accounts", appAuth, func(c *gin.Context) { + var input struct { + Mode string `json:"mode"` + Name string `json:"name"` + InitialCash *string `json:"initial_cash"` + } + if !decode(c, &input) { + return + } + if options.Accounts == nil { + fail(c, apperror.ErrUnavailable) + return + } + account, err := options.Accounts.Create(c.Request.Context(), currentUser(c).ID, input.Mode, input.Name, input.InitialCash) + if err != nil { + fail(c, err) + return + } + success(c, http.StatusCreated, account) + }) + r.GET("/api/v1/accounts/:id/summary", appAuth, func(c *gin.Context) { + if options.Accounts == nil { + fail(c, apperror.ErrUnavailable) + return + } + account, err := options.Accounts.Get(c.Request.Context(), currentUser(c).ID, c.Param("id")) + if err != nil { + fail(c, err) + return + } + success(c, http.StatusOK, account) + }) +} + +func authenticate(c *gin.Context, options IdentityOptions, audience string) { + if options.Auth == nil { + fail(c, apperror.ErrUnavailable) + return + } + token := requestToken(c.Request, audience) + if token == "" { + fail(c, apperror.ErrUnauthenticated) + return + } + user, err := options.Auth.Authenticate(c.Request.Context(), token, audience) + if err != nil { + fail(c, err) + return + } + c.Set("user", user) + c.Next() +} + +func requestToken(r *http.Request, audience string) string { + if audience == "admin" { + cookie, err := r.Cookie(sessionCookie) + if err == nil { + return cookie.Value + } + return "" + } + scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ") + if !ok || !strings.EqualFold(scheme, "Bearer") { + return "" + } + return token +} + +func currentUser(c *gin.Context) domain.User { return c.MustGet("user").(domain.User) } + +// The embedded console and Vite development proxy both call APIs on their own origin. +func sameOrigin(r *http.Request) bool { + origin, err := url.Parse(r.Header.Get("Origin")) + if err != nil || origin.Host == "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" { + return false + } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return origin.Scheme == scheme && origin.Host == r.Host +} + +func decode(c *gin.Context, value any) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + if !strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "application/json") { + failure(c, http.StatusUnsupportedMediaType, "INVALID_CONTENT_TYPE", "请使用 application/json。", false) + return false + } + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + fail(c, apperror.ErrInvalidInput) + return false + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + fail(c, apperror.ErrInvalidInput) + return false + } + return true +} + +func success(c *gin.Context, status int, data any) { + c.JSON(status, gin.H{"data": data, "meta": gin.H{"request_id": c.Writer.Header().Get("X-Request-ID"), "server_time": time.Now().UTC()}}) +} +func failure(c *gin.Context, status int, code, message string, retryable bool) { + c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": message, "retryable": retryable}, "meta": gin.H{"request_id": c.Writer.Header().Get("X-Request-ID")}}) +} +func fail(c *gin.Context, err error) { + switch { + case errors.Is(err, apperror.ErrInvalidInput): + failure(c, http.StatusUnprocessableEntity, "INVALID_INPUT", "请检查输入内容;资金最多两位小数,密码为 12–72 字节。", false) + case errors.Is(err, apperror.ErrUnauthenticated): + failure(c, http.StatusUnauthorized, "UNAUTHENTICATED", "登录信息无效或会话已过期。", false) + case errors.Is(err, apperror.ErrForbidden): + failure(c, http.StatusForbidden, "FORBIDDEN", "无权执行此操作。", false) + case errors.Is(err, apperror.ErrConflict): + failure(c, http.StatusConflict, "CONFLICT", "该登录名或账户类型已存在。", false) + case errors.Is(err, apperror.ErrNotFound): + failure(c, http.StatusNotFound, "NOT_FOUND", "记录不存在。", false) + default: + failure(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "服务暂不可用;请检查依赖连接和数据库迁移。", true) + } +} diff --git a/services/api/internal/transport/http/identity_test.go b/services/api/internal/transport/http/identity_test.go new file mode 100644 index 0000000..fcc11ce --- /dev/null +++ b/services/api/internal/transport/http/identity_test.go @@ -0,0 +1,53 @@ +package http + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + identity "veyra/services/api/internal/identity/service" + operations "veyra/services/api/internal/operations/service" +) + +func TestAuthenticationAndCSRFBoundaries(t *testing.T) { + auth, err := identity.NewAuthService(nil) + if err != nil { + t.Fatal(err) + } + probe := func(context.Context) error { return nil } + router := NewRouter(operations.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{Auth: auth, DevelopmentLogin: true}) + for _, tc := range []struct { + method, path, body, origin string + status int + }{ + {http.MethodGet, "/api/admin/v1/users", "", "", 401}, + {http.MethodGet, "/api/v1/accounts", "", "", 401}, + {http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"test-password"}`, "http://evil.example", 403}, + {http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"test-password"}`, "", 403}, + {http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"test-password","role":"ADMIN"}`, "http://example.com", 422}, + {http.MethodPost, "/api/v1/auth/login", `{} {}`, "", 422}, + } { + req := httptest.NewRequest(tc.method, "http://example.com"+tc.path, strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", tc.origin) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != tc.status { + t.Fatalf("%s %s got %d want %d", tc.method, tc.path, rec.Code, tc.status) + } + } +} + +func TestDevelopmentLoginDisabledByDefault(t *testing.T) { + probe := func(context.Context) error { return nil } + router := NewRouter(operations.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{}) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{}`))) + if rec.Code != http.StatusServiceUnavailable { + t.Fatal("development login open outside explicitly enabled environment") + } +} diff --git a/services/api/internal/transport/http/router.go b/services/api/internal/transport/http/router.go new file mode 100644 index 0000000..8f09e9a --- /dev/null +++ b/services/api/internal/transport/http/router.go @@ -0,0 +1,59 @@ +// Package http adapts application services to the HTTP protocol. +package http + +import ( + "context" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "veyra/services/api/internal/operations/service" + "veyra/services/api/internal/platform/adminui" +) + +func NewRouter(health *service.HealthService, logger *slog.Logger, identity IdentityOptions) http.Handler { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + c.Request = c.Request.WithContext(ctx) + requestID := uuid.NewString() + c.Header("X-Request-ID", requestID) + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Cache-Control", "no-store") + start := time.Now() + defer func() { + if recover() != nil { + logger.Error("request panic", "request_id", requestID) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Internal server error", "request_id": requestID}}) + } + logger.Info("http request", "method", c.Request.Method, "route", c.FullPath(), "status", c.Writer.Status(), "duration_ms", time.Since(start).Milliseconds(), "request_id", requestID) + }() + c.Next() + }) + r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "alive"}) }) + check := func(c *gin.Context) { + h := health.Check(c.Request.Context()) + status := http.StatusOK + if h.Status != "ready" { + status = http.StatusServiceUnavailable + } + c.JSON(status, h) + } + r.GET("/readyz", check) + r.GET("/api/v1/system/health", check) + bindIdentity(r, identity) + console := adminui.Handler() + r.NoRoute(func(c *gin.Context) { + if !strings.HasPrefix(c.Request.URL.Path, "/api/") && c.Request.URL.Path != "/api" { + console.ServeHTTP(c.Writer, c.Request) + return + } + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "NOT_FOUND", "message": "Endpoint not found"}}) + }) + return r +} diff --git a/services/api/internal/transport/http/router_test.go b/services/api/internal/transport/http/router_test.go new file mode 100644 index 0000000..7d8d567 --- /dev/null +++ b/services/api/internal/transport/http/router_test.go @@ -0,0 +1,68 @@ +package http + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "veyra/services/api/internal/operations/service" +) + +func TestLivenessAndReadinessAreIndependent(t *testing.T) { + secretError := "password=secret-internal-detail" + health := service.NewHealthService(nil, func(context.Context) error { return errors.New(secretError) }) + router := NewRouter(health, slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{}) + for _, tc := range []struct { + path string + status int + }{{"/healthz", http.StatusOK}, {"/readyz", http.StatusServiceUnavailable}, {"/api/v1/system/health", http.StatusServiceUnavailable}} { + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil)) + if rec.Code != tc.status { + t.Fatalf("%s: got %d", tc.path, rec.Code) + } + if strings.Contains(rec.Body.String(), secretError) { + t.Fatal("health endpoint leaked internal error") + } + if rec.Header().Get("X-Request-ID") == "" { + t.Fatal("missing trace ID") + } + } +} + +func TestReadyWhenBothDependenciesRespond(t *testing.T) { + probe := func(context.Context) error { return nil } + router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{}) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("got %d", rec.Code) + } +} + +func TestEmbeddedConsoleAndAPIBoundary(t *testing.T) { + probe := func(context.Context) error { return nil } + router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{}) + for _, path := range []string{"/", "/research/candidates"} { + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code == http.StatusServiceUnavailable { + t.Skip("build management console before testing embedded assets") + } + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "