@@ -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)"
|
||||||
@@ -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`,应区分执行沙箱权限限制与主机网络故障,不因此修改服务地址。
|
||||||
@@ -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
|
||||||
@@ -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 <access_token>`。
|
||||||
|
|
||||||
|
`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)。
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><meta name="theme-color" content="#f8f6f2"/><title>见势 · Veyra 管理端</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
@@ -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<void>;onSessionExpired:()=>void}) {
|
||||||
|
const [page,setPage] = useState("总览")
|
||||||
|
const [health,setHealth] = useState<Health | null>(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<AbortController | null>(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 <div className="min-h-screen lg:grid lg:grid-cols-[238px_1fr]">
|
||||||
|
<aside className="flex flex-col border-r bg-card px-5 py-7 lg:min-h-screen">
|
||||||
|
<a href="#" onClick={()=>setPage("总览")} className="flex items-center gap-3 px-2 text-foreground no-underline"><span className="flex size-10 items-center justify-center rounded-xl bg-foreground font-serif text-xl text-card">V</span><div><strong className="text-base">见势 · Veyra</strong><p className="mt-1 text-[10px] tracking-[.2em] text-muted-foreground">OPERATIONS CONSOLE</p></div></a>
|
||||||
|
<p className="mb-3 mt-10 px-3 text-[10px] tracking-[.18em] text-muted-foreground">工作空间</p>
|
||||||
|
<nav aria-label="管理端工作区" className="grid grid-cols-2 gap-1 sm:grid-cols-5 lg:grid-cols-1">{workspaces.map(({name,icon:Icon})=><button key={name} onClick={()=>setPage(name)} aria-current={page===name ? "page" : undefined} className={`flex items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${page===name ? "bg-accent font-medium text-accent-foreground" : "text-muted-foreground hover:bg-muted"}`}><Icon size={17}/>{name}{page===name && <ChevronRight size={14} className="ml-auto"/>}</button>)}</nav>
|
||||||
|
<div className="mt-auto hidden pt-12 lg:block"><div className="rounded-lg border p-3 text-xs text-muted-foreground"><div className="mb-2 flex items-center gap-2 text-foreground"><CircleDot size={14}/>开发环境</div>第一阶段 · 工程与服务基础</div></div>
|
||||||
|
</aside>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<header className="flex h-20 items-center justify-between border-b px-6 lg:px-10"><div className="flex items-center gap-2 text-sm text-muted-foreground">工作空间 <ChevronRight size={13}/><span className="text-foreground">{page}</span></div><div className="flex items-center gap-3"><span className="hidden text-xs text-muted-foreground sm:block">{user.display_name}</span><Badge variant="outline">LOCAL · 开发环境</Badge><Button variant="ghost" size="sm" onClick={()=>void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "退出"}</Button></div></header>
|
||||||
|
<main className="mx-auto max-w-7xl p-6 lg:p-10">
|
||||||
|
{logoutError && <p role="alert" className="mb-6 text-sm text-destructive">{logoutError}</p>}
|
||||||
|
<div className="mb-8 flex flex-wrap items-end justify-between gap-4"><div><p className="mb-3 text-[10px] tracking-[.2em] text-primary">VEYRA / {overview ? "SYSTEM OVERVIEW" : "WORKSPACE"}</p><h1 className="text-3xl font-semibold tracking-tight">{page === "总览" ? "把每一步,建立在依据之上。" : page}</h1><p className="mt-3 text-sm text-muted-foreground">{overview ? "查看服务连接与建设进度,从可靠的基础开始。" : "该工作区的业务功能正在建设中。"}</p></div>{overview && <Button variant="outline" onClick={()=>void refresh()} disabled={loading}><RefreshCw className={loading ? "animate-spin" : ""}/>{loading ? "检查中" : "刷新状态"}</Button>}</div>
|
||||||
|
{overview ? <>
|
||||||
|
{error && <div role="alert" className="mb-6 rounded-lg border border-destructive/25 bg-destructive/5 p-4 text-sm text-destructive">无法读取当前服务状态。请检查 API 是否启动。{error}</div>}
|
||||||
|
<div className="mb-7 grid gap-4 md:grid-cols-3">{[
|
||||||
|
{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})=><Card key={title}><CardHeader><div className="flex items-center justify-between"><CardDescription>{title}</CardDescription><Icon size={18} className="text-muted-foreground"/></div></CardHeader><CardContent><p className="mb-2 text-2xl font-semibold">{value}</p><p className="text-xs text-muted-foreground">{subtitle}</p></CardContent></Card>)}</div>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>第一条产品闭环</CardTitle><Badge>建设中</Badge></div><CardDescription className="mt-2">先验证完整流程,再扩大策略与数据范围。</CardDescription></CardHeader><CardContent><ol className="space-y-5">{["管理端审核并发布观察候选","用户查看证据,确认买入计划","条件满足后发送提醒","记录实际成交,建立持仓监控","记录退出成交,生成成本后复盘"].map((v,i)=><li key={v} className="flex items-center gap-4"><span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">0{i+1}</span><span className="text-sm">{v}</span><span className="ml-auto text-xs text-muted-foreground">待实现</span></li>)}</ol></CardContent></Card><Card><CardHeader><BarChart3 size={24} className="mb-4 text-primary"/><CardTitle>数据接入后,开始观察。</CardTitle><CardDescription className="mt-2 leading-7">候选数量、策略表现与账户指标将在业务功能与数据接入完成后展示。</CardDescription></CardHeader><CardContent><div className="rounded-lg bg-muted p-4 text-xs leading-6 text-muted-foreground">当前状态来源:Go API 实时探测<br/>最近检查:{health ? new Date(health.checked_at).toLocaleString("zh-CN") : "尚未取得状态"}</div><Button variant="ghost" className="mt-4 w-full justify-between" onClick={()=>setPage("数据健康")}>查看数据健康<ArrowUpRight/></Button></CardContent></Card></div>
|
||||||
|
</> : page==="用户管理" ? <UsersPanel onSessionExpired={onSessionExpired}/> : <Card><CardContent className="flex min-h-80 flex-col items-center justify-center p-10 text-center"><div className="mb-5 rounded-2xl bg-muted p-5"><LockKeyhole size={28} className="text-muted-foreground"/></div><h2 className="text-lg font-semibold">{page}尚未开放</h2><p className="mt-3 max-w-md text-sm leading-7 text-muted-foreground">该模块完成业务接口、权限与验证后开放。</p><Button variant="outline" className="mt-6" onClick={()=>setPage("总览")}>返回总览</Button></CardContent></Card>}
|
||||||
|
<footer className="mt-10 flex items-center justify-between border-t pt-5 text-[11px] text-muted-foreground"><span>见势 · Veyra / 可追溯的投资决策</span><span>工程基础 v0.1</span></footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -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<User | null>(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 <div className="flex min-h-screen items-center justify-center gap-3 text-sm text-muted-foreground"><LoaderCircle className="animate-spin" size={18}/>正在验证管理端会话…</div>
|
||||||
|
if (user) return <App user={user} onLogout={signOut} onSessionExpired={sessionExpired}/>
|
||||||
|
return <div className="grid min-h-screen lg:grid-cols-[1.05fr_1fr]">
|
||||||
|
<section className="relative hidden flex-col justify-between bg-foreground p-14 text-card lg:flex"><div className="flex items-center gap-3"><span className="flex size-10 items-center justify-center rounded-lg border border-card/30 font-serif text-xl">V</span><span className="font-semibold">见势 · Veyra</span></div><div><p className="mb-6 text-xs tracking-[.24em] text-card/45">OPERATIONS CONSOLE</p><h1 className="text-4xl font-semibold leading-relaxed">看清依据,<br/>守住每一步决策。</h1><p className="mt-7 max-w-sm text-sm leading-8 text-card/55">从候选审核到策略监控,让每条证据、每次变化与每项操作都有记录。</p></div><p className="text-xs text-card/35">见势 · Veyra / 管理工作空间</p></section>
|
||||||
|
<main className="flex items-center justify-center p-7"><div className="w-full max-w-sm"><p className="mb-5 text-xs font-semibold tracking-[.15em] text-primary">VEYRA / ADMIN</p><h2 className="text-3xl font-semibold">登录管理端</h2><p className="mb-9 mt-3 text-sm text-muted-foreground">使用已创建的管理员账号继续。</p><form onSubmit={event=>void submit(event)} className="space-y-5"><div><label htmlFor="login-name" className="mb-2 block text-sm">登录名</label><Input id="login-name" value={loginName} onChange={e=>setLoginName(e.target.value)} autoComplete="username" minLength={3} maxLength={64} required disabled={busy}/></div><div><label htmlFor="password" className="mb-2 block text-sm">密码</label><Input id="password" type="password" value={password} onChange={e=>setPassword(e.target.value)} autoComplete="current-password" maxLength={72} required disabled={busy}/></div>{error && <p role="alert" className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">{error}</p>}<Button type="submit" disabled={busy} className="mt-2 w-full">{busy ? <LoaderCircle className="animate-spin"/> : <ArrowRight/>}{busy ? "正在登录" : "进入管理端"}</Button></form><p className="mt-7 border-t pt-5 text-xs leading-6 text-muted-foreground">当前登录用于本地开发验证。正式管理端将在多因素认证完成后开放。</p></div></main>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -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<User[]>([])
|
||||||
|
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 <Card><CardHeader><div className="flex items-center justify-between"><CardTitle>用户列表</CardTitle><Button variant="outline" size="sm" onClick={()=>setRevision(v=>v+1)} disabled={loading}><RefreshCw className={loading ? "animate-spin" : ""}/>刷新</Button></div><CardDescription>按创建时间显示最近 100 位 App 用户。</CardDescription></CardHeader><CardContent>
|
||||||
|
{error ? <p role="alert" className="py-8 text-sm text-destructive">{error}</p> : loading ? <p className="py-8 text-sm text-muted-foreground">正在加载用户…</p> : users.length===0 ? <div className="flex flex-col items-center py-14"><Users size={28} className="mb-4 text-muted-foreground"/><p className="text-sm text-muted-foreground">暂时没有 App 用户</p></div> : <div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-muted-foreground"><th className="py-3 font-normal">用户</th><th className="py-3 font-normal">状态</th><th className="py-3 font-normal">创建时间</th></tr></thead><tbody>{users.map(user=><tr key={user.id} className="border-b last:border-0"><td className="py-4 pr-6"><p className="font-medium">{user.display_name}</p><p className="mt-1 font-mono text-[11px] text-muted-foreground">{user.id}</p></td><td className="py-4 pr-6"><Badge variant={user.status==="ACTIVE" ? "default" : "secondary"}>{user.status==="ACTIVE" ? "正常" : "已停用"}</Badge></td><td className="whitespace-nowrap py-4 text-muted-foreground">{new Date(user.created_at).toLocaleString("zh-CN")}</td></tr>)}</tbody></table></div>}
|
||||||
|
</CardContent></Card>
|
||||||
|
}
|
||||||
@@ -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<typeof badgeVariants>) { return <span data-slot="badge" className={cn(badgeVariants({variant}),className)} {...props}/> }
|
||||||
|
export {Badge}
|
||||||
@@ -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<typeof buttonVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
||||||
|
}
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
function Card({className, ...props}: React.ComponentProps<"div">) { return <div data-slot="card" className={cn("rounded-xl border bg-card text-card-foreground shadow-sm",className)} {...props} /> }
|
||||||
|
function CardHeader({className, ...props}: React.ComponentProps<"div">) { return <div data-slot="card-header" className={cn("flex flex-col gap-1.5 p-6",className)} {...props} /> }
|
||||||
|
function CardTitle({className, ...props}: React.ComponentProps<"h2">) { return <h2 data-slot="card-title" className={cn("text-base font-semibold leading-none",className)} {...props} /> }
|
||||||
|
function CardDescription({className, ...props}: React.ComponentProps<"p">) { return <p data-slot="card-description" className={cn("text-sm text-muted-foreground",className)} {...props} /> }
|
||||||
|
function CardContent({className, ...props}: React.ComponentProps<"div">) { return <div data-slot="card-content" className={cn("p-6 pt-0",className)} {...props} /> }
|
||||||
|
export {Card, CardHeader, CardTitle, CardDescription, CardContent}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {cn} from "@/lib/utils"
|
||||||
|
function Input({className,type,...props}:React.ComponentProps<"input">) {
|
||||||
|
return <input data-slot="input" type={type} className={cn("flex h-10 w-full rounded-md border border-input bg-card px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",className)} {...props}/>
|
||||||
|
}
|
||||||
|
export {Input}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<Health> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type User = {id:string;display_name:string;status:"ACTIVE" | "DISABLED";created_at:string;updated_at:string}
|
||||||
|
type Envelope<T> = {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<T>(path:string,init?:RequestInit):Promise<T> {
|
||||||
|
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<T>).data
|
||||||
|
}
|
||||||
|
export function getMe(signal?:AbortSignal) {return request<User>("/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<User[]>("/api/admin/v1/users",{signal})}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
|
||||||
@@ -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(<React.StrictMode><Console /></React.StrictMode>)
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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" } },
|
||||||
|
})
|
||||||
@@ -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 写权限和网络访问能力的终端完成提交与推送,先确认远程状态;不强制推送、不覆盖远程历史。
|
||||||
+32
@@ -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=
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Generated
+2630
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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" }
|
||||||
@@ -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" }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Build apps/admin to populate this directory before building the release binary.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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" }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(), "<html") {
|
||||||
|
t.Fatalf("embedded SPA %s: status=%d", path, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/api/v1/missing", "/assets/missing.js", "/.keep"} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
|
||||||
|
t.Fatalf("missing route %s must not serve the SPA: %d", path, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user