feat: 基础框架搭建完毕
Checks / verify (push) Failing after 2m37s

This commit is contained in:
2026-10-07 09:27:47 +08:00
parent ba6dfffab7
commit bffdb3858b
64 changed files with 326 additions and 332 deletions
+4 -4
View File
@@ -22,8 +22,8 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: services/api/go.mod go-version-file: go.mod
cache-dependency-path: services/api/go.sum cache-dependency-path: go.sum
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
@@ -31,5 +31,5 @@ jobs:
- run: npm ci - run: npm ci
- run: make build - run: make build
- run: make test - run: make test
- run: go vet ./services/api/... - run: go vet ./...
- run: test -z "$(gofmt -l services/api)" - run: test -z "$(gofmt -l cmd internal)"
+3 -3
View File
@@ -9,6 +9,6 @@ bin/
.idea/ .idea/
*.log *.log
coverage/ coverage/
!services/api/internal/platform/adminui/dist/ !internal/adminui/dist/
services/api/internal/platform/adminui/dist/* internal/adminui/dist/*
!services/api/internal/platform/adminui/dist/.keep !internal/adminui/dist/.keep
+1
View File
@@ -3,6 +3,7 @@
项目背景、已完成功能和验证状态见 `docs/development-state.md`。继续开发前阅读该文件,完成工作后更新事实与待办,不记录密码。 项目背景、已完成功能和验证状态见 `docs/development-state.md`。继续开发前阅读该文件,完成工作后更新事实与待办,不记录密码。
- 后端使用 Go,遵循 gofmt、go vet、惯用错误处理、context 传递、明确依赖注入及 cmd/internal 分层。 - 后端使用 Go,遵循 gofmt、go vet、惯用错误处理、context 传递、明确依赖注入及 cmd/internal 分层。
- 后端采用仓库根目录的单 Go module:cmd 放入口,internal 按 model、service、repository、handler、config、database 等职责组织;管理端位于 web/admin。避免为单体项目重复嵌套 services/api、业务域目录和 platform 层,不引入单模块 go.work。
- HTTP handler 只做协议适配,业务规则归 service/domain,SQL 归 repository。不得将 Gin Context 传入业务层。 - HTTP handler 只做协议适配,业务规则归 service/domain,SQL 归 repository。不得将 Gin Context 传入业务层。
- PostgreSQL 所有表(包括迁移、任务和关联表)必须以 `veyra_` 开头,表与字段使用 snake_case。 - PostgreSQL 所有表(包括迁移、任务和关联表)必须以 `veyra_` 开头,表与字段使用 snake_case。
- 主键 `id` 使用字符串 UUID:Go 类型 `string`,数据库 `varchar(36)` 并校验 UUID 格式。外键保持相同类型。 - 主键 `id` 使用字符串 UUID:Go 类型 `string`,数据库 `varchar(36)` 并校验 UUID 格式。外键保持相同类型。
+10 -10
View File
@@ -2,24 +2,24 @@
export GOCACHE := $(CURDIR)/.cache/go-build export GOCACHE := $(CURDIR)/.cache/go-build
api: admin-build api: admin-build
go run ./services/api/cmd/api -env-file .env go run ./cmd/api -env-file .env
build: admin-build build: admin-build
mkdir -p bin mkdir -p bin
go build -trimpath -o bin/veyra-api ./services/api/cmd/api go build -trimpath -o bin/veyra-api ./cmd/api
go build -trimpath -o bin/veyra-db ./services/api/cmd/db go build -trimpath -o bin/veyra-db ./cmd/db
go build -trimpath -o bin/veyra-user ./services/api/cmd/user go build -trimpath -o bin/veyra-user ./cmd/user
db-create: db-create:
go run ./services/api/cmd/db -env-file .env -action create go run ./cmd/db -env-file .env -action create
db-migrate: db-migrate:
go run ./services/api/cmd/db -env-file .env -action migrate go run ./cmd/db -env-file .env -action migrate
admin-create: admin-create:
go run ./services/api/cmd/user -env-file .env -role ADMIN go run ./cmd/user -env-file .env -role ADMIN
user-create: user-create:
go run ./services/api/cmd/user -env-file .env -role USER go run ./cmd/user -env-file .env -role USER
test: test:
go test -race ./services/api/... go test -race ./...
check: check:
go vet ./services/api/... go vet ./...
npm run build --workspace @veyra/admin npm run build --workspace @veyra/admin
admin-dev: admin-dev:
npm run dev --workspace @veyra/admin npm run dev --workspace @veyra/admin
+21 -11
View File
@@ -25,7 +25,7 @@ cp .env.example .env
将 PostgreSQL 用户名与密码填写到 `.env`。默认数据库名称 `veyra`,默认管理数据库 `postgres`;创建数据库需要连接角色具有 CREATEDB 权限。Redis 无用户名,有密码:在本地 `.env` 配置 `REDIS_PASSWORD`,保持 `REDIS_USERNAME` 为空。`PGSSLMODE=disable` 是本地模板值,如服务器要求 TLS 则修改。 将 PostgreSQL 用户名与密码填写到 `.env`。默认数据库名称 `veyra`,默认管理数据库 `postgres`;创建数据库需要连接角色具有 CREATEDB 权限。Redis 无用户名,有密码:在本地 `.env` 配置 `REDIS_PASSWORD`,保持 `REDIS_USERNAME` 为空。`PGSSLMODE=disable` 是本地模板值,如服务器要求 TLS 则修改。
```sh ```sh
go mod download -C services/api go mod download
npm ci npm ci
make db-create make db-create
make db-migrate make db-migrate
@@ -71,18 +71,28 @@ VEYRA_TEST_DATABASE_URL='postgres://user:password@host:5432/test_db?sslmode=disa
## 目录 ## 目录
```text ```text
apps/admin/ Vite React 管理端 cmd/
services/api/cmd/api/ API 入口 api/ API 入口与依赖装配
services/api/cmd/db/ 建库与迁移入口 db/ 建库与迁移命令
services/api/cmd/user/ 开发身份初始化入口 user/ 开发身份初始化命令
services/api/internal/identity/ 身份领域 internal/
services/api/internal/portfolio/ 账户领域 config/ 配置加载与校验
services/api/internal/operations/ 服务健康业务 database/migrations/ 数据库连接与版本化迁移
services/api/internal/platform/ 配置与数据库设施 model/ User、Account、Session 等模型
services/api/internal/transport/ HTTP 协议适配 repository/ PostgreSQL 数据访问与事务
services/api/api/openapi.yaml 当前 HTTP 契约 service/ 登录、账户和健康检查业务
handler/ HTTP 路由、中间件与协议适配
adminui/ 管理端 embed 资源与静态服务
apperror/ 跨层业务错误
ratelimit/ Redis 登录限流
integration/ 数据库集成测试
web/admin/ Vite React 管理端
api/openapi.yaml HTTP 契约
go.mod / go.sum 根目录唯一 Go module
``` ```
仓库采用根目录 Go 单模块结构,管理端放在 `web/admin`,无需 `go.work`。文件按职责划分,调用方向为 handler → service → repository;接口在 service 中定义,由 repository 实现,避免 service 依赖具体数据库。
采用显式 SQL 和结构体 db tag,避免 ORM 隐式推断表名。迁移打包到 Go 二进制,已执行迁移不可改写,后续修改新增版本。API 启动不自动建库或迁移。健康检查不会返回连接串和供应商错误细节。 采用显式 SQL 和结构体 db tag,避免 ORM 隐式推断表名。迁移打包到 Go 二进制,已执行迁移不可改写,后续修改新增版本。API 启动不自动建库或迁移。健康检查不会返回连接串和供应商错误细节。
## 待完成 ## 待完成
@@ -13,14 +13,12 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
identityrepo "veyra/services/api/internal/identity/repository"
identityservice "veyra/services/api/internal/identity/service" "git.sundynix.cn/Blizzard/Veyra/internal/config"
"veyra/services/api/internal/operations/service" "git.sundynix.cn/Blizzard/Veyra/internal/handler"
"veyra/services/api/internal/platform/config" "git.sundynix.cn/Blizzard/Veyra/internal/ratelimit"
"veyra/services/api/internal/platform/ratelimit" "git.sundynix.cn/Blizzard/Veyra/internal/repository"
portfoliorepo "veyra/services/api/internal/portfolio/repository" "git.sundynix.cn/Blizzard/Veyra/internal/service"
portfolioservice "veyra/services/api/internal/portfolio/service"
transport "veyra/services/api/internal/transport/http"
) )
func main() { func main() {
@@ -44,8 +42,8 @@ func run(logger *slog.Logger) error {
return err return err
} }
var postgres service.Probe var postgres service.Probe
var authRepo identityservice.Repository var authRepo service.AuthRepository
var accountRepo portfolioservice.Repository var accountRepo service.AccountRepository
if c.PGUser != "" { if c.PGUser != "" {
if err := c.ValidateDatabase(); err != nil { if err := c.ValidateDatabase(); err != nil {
return err return err
@@ -56,18 +54,18 @@ func run(logger *slog.Logger) error {
} }
defer pool.Close() defer pool.Close()
postgres = pool.Ping postgres = pool.Ping
authRepo = identityrepo.NewPostgres(pool) authRepo = repository.NewIdentityRepository(pool)
accountRepo = portfoliorepo.NewPostgres(pool) accountRepo = repository.NewAccountRepository(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}) 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() defer redisClient.Close()
health := service.NewHealthService(postgres, func(ctx context.Context) error { return redisClient.Ping(ctx).Err() }) health := service.NewHealthService(postgres, func(ctx context.Context) error { return redisClient.Ping(ctx).Err() })
auth, err := identityservice.NewAuthService(authRepo) auth, err := service.NewAuthService(authRepo)
if err != nil { if err != nil {
return err 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"} identityOptions := handler.IdentityOptions{Auth: auth, Accounts: service.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} server := &http.Server{Addr: c.HTTPAddr, Handler: handler.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) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
result := make(chan error, 1) result := make(chan error, 1)
@@ -8,8 +8,8 @@ import (
"os" "os"
"time" "time"
"veyra/services/api/internal/platform/config" "git.sundynix.cn/Blizzard/Veyra/internal/config"
"veyra/services/api/internal/platform/database" "git.sundynix.cn/Blizzard/Veyra/internal/database"
) )
func main() { func main() {
@@ -9,10 +9,10 @@ import (
"os" "os"
"time" "time"
"veyra/services/api/internal/identity/repository" "git.sundynix.cn/Blizzard/Veyra/internal/config"
"veyra/services/api/internal/identity/service" "git.sundynix.cn/Blizzard/Veyra/internal/database"
"veyra/services/api/internal/platform/config" "git.sundynix.cn/Blizzard/Veyra/internal/repository"
"veyra/services/api/internal/platform/database" "git.sundynix.cn/Blizzard/Veyra/internal/service"
) )
func main() { func main() {
@@ -60,7 +60,7 @@ func run() error {
return err return err
} }
defer pool.Close() defer pool.Close()
auth, err := service.NewAuthService(repository.NewPostgres(pool)) auth, err := service.NewAuthService(repository.NewIdentityRepository(pool))
if err != nil { if err != nil {
return err return err
} }
+19
View File
@@ -33,6 +33,19 @@
- OpenAPI、依赖锁文件、Makefile 和带 PostgreSQL 集成测试服务的 CI。 - OpenAPI、依赖锁文件、Makefile 和带 PostgreSQL 集成测试服务的 CI。
- `make build` 生成内嵌管理端的 veyra-api,以及 veyra-db 和 veyra-user。 - `make build` 生成内嵌管理端的 veyra-api,以及 veyra-db 和 veyra-user。
## 当前工程目录
- 用户指出旧后端目录过深,当前已改为仓库根目录的单 Go module。
- module 为 git.sundynix.cn/Blizzard/Veyra,与仓库地址一致;删除单模块 go.work。
- cmd/api、cmd/db、cmd/user 放入口与装配。
- internal/model 放模型,internal/service 放业务,internal/repository 放 SQL 与事务,internal/handler 放 HTTP 路由和协议适配。
- internal/config、database、adminui、apperror、ratelimit 放当前实际需要的设施;避免多余嵌套和空目录。
- 管理端由 apps/admin 迁至 web/admin;embed 构建产物由 internal/adminui/dist 提供。
- Makefile、CI、npm workspace/锁文件、Go 导入路径与 README 一并更新,原有命令和接口保持一致。
- 原始设计报告中的 monorepo 目录仅为旧规划,当前实现以本文件、AGENTS.md 和 README 的结构为准。
- 迁移文件仅移动路径,已逐字节核对两版 SQL 与原提交一致,不影响已执行迁移的 checksum。
- 新结构已通过前端构建与 embed、Go 编译、race 检查和 go vet;真实数据库集成测试仍因连接限制跳过,不将目录迁移视作数据库验证。
## 验证与环境事实 ## 验证与环境事实
- 已通过前端 TypeScript 检查与生产构建、Go 编译、go vet、单元与无监听 HTTP 测试(含 race 检查)。 - 已通过前端 TypeScript 检查与生产构建、Go 编译、go vet、单元与无监听 HTTP 测试(含 race 检查)。
@@ -58,3 +71,9 @@
用户本轮明确要求保存记忆、git add、commit 和 push。目标为当前 master 分支的 origin;操作结果以实际 Git 输出为准,禁止将失败的提交或推送记作已完成。 用户本轮明确要求保存记忆、git add、commit 和 push。目标为当前 master 分支的 origin;操作结果以实际 Git 输出为准,禁止将失败的提交或推送记作已完成。
本轮操作结果:项目记忆与忽略规则已保存,候选文件检查未发现本地配置的长密码。仓库当前没有提交;git add 因无法创建 .git/index.lock(Operation not permitted)失败,尚未 commit/push。远程分支读取失败,原因是执行环境无法解析远程域名。后续需在有 Git 写权限和网络访问能力的终端完成提交与推送,先确认远程状态;不强制推送、不覆盖远程历史。 本轮操作结果:项目记忆与忽略规则已保存,候选文件检查未发现本地配置的长密码。仓库当前没有提交;git add 因无法创建 .git/index.lock(Operation not permitted)失败,尚未 commit/push。远程分支读取失败,原因是执行环境无法解析远程域名。后续需在有 Git 写权限和网络访问能力的终端完成提交与推送,先确认远程状态;不强制推送、不覆盖远程历史。
后续检查发现本地已有 ba6dfff(init: initial commit)等提交,说明前述无提交状态已变化;远程推送状态未经核验。此次目录调整尚未提交,Git 输出中的旧路径删除与新路径新增是迁移表现,提交时应一并暂存。
## 本轮开发状态
原计划推进模拟候选修订、审核与发布;用户中途指出目录不合理,本轮优先完成工程结构纠正与回归。候选功能尚未接入仓库交付代码,不新增第三版迁移;相关草稿保留在本地 .cache/candidate-draft(不提交),后续必须按新结构重新审查、实现和测试。下一步仍为候选审核闭环,不能将其描述为已完成。
+1 -1
View File
@@ -1,4 +1,4 @@
module veyra/services/api module git.sundynix.cn/Blizzard/Veyra
go 1.26.2 go 1.26.2
View File
-3
View File
@@ -1,3 +0,0 @@
go 1.26.2
use ./services/api
-32
View File
@@ -1,32 +0,0 @@
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=
@@ -10,7 +10,8 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"veyra/services/api/internal/platform/config"
"git.sundynix.cn/Blizzard/Veyra/internal/config"
) )
//go:embed migrations/*.sql //go:embed migrations/*.sql
@@ -1,4 +1,4 @@
package http package handler
import ( import (
"context" "context"
@@ -9,20 +9,20 @@ import (
"net/url" "net/url"
"strings" "strings"
"time" "time"
"veyra/services/api/internal/platform/apperror"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"veyra/services/api/internal/identity/domain"
identity "veyra/services/api/internal/identity/service" "git.sundynix.cn/Blizzard/Veyra/internal/apperror"
portfolio "veyra/services/api/internal/portfolio/service" "git.sundynix.cn/Blizzard/Veyra/internal/model"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
) )
type LoginLimiter interface { type LoginLimiter interface {
Login(context.Context, string, string) (bool, error) Login(context.Context, string, string) (bool, error)
} }
type IdentityOptions struct { type IdentityOptions struct {
Auth *identity.AuthService Auth *service.AuthService
Accounts *portfolio.AccountService Accounts *service.AccountService
Limiter LoginLimiter Limiter LoginLimiter
DevelopmentLogin bool DevelopmentLogin bool
SecureCookie bool SecureCookie bool
@@ -193,7 +193,7 @@ func requestToken(r *http.Request, audience string) string {
return token return token
} }
func currentUser(c *gin.Context) domain.User { return c.MustGet("user").(domain.User) } func currentUser(c *gin.Context) model.User { return c.MustGet("user").(model.User) }
// The embedded console and Vite development proxy both call APIs on their own origin. // The embedded console and Vite development proxy both call APIs on their own origin.
func sameOrigin(r *http.Request) bool { func sameOrigin(r *http.Request) bool {
@@ -1,4 +1,4 @@
package http package handler
import ( import (
"context" "context"
@@ -9,17 +9,16 @@ import (
"strings" "strings"
"testing" "testing"
identity "veyra/services/api/internal/identity/service" "git.sundynix.cn/Blizzard/Veyra/internal/service"
operations "veyra/services/api/internal/operations/service"
) )
func TestAuthenticationAndCSRFBoundaries(t *testing.T) { func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
auth, err := identity.NewAuthService(nil) auth, err := service.NewAuthService(nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
probe := func(context.Context) error { return nil } 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}) router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{Auth: auth, DevelopmentLogin: true})
for _, tc := range []struct { for _, tc := range []struct {
method, path, body, origin string method, path, body, origin string
status int status int
@@ -44,7 +43,7 @@ func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
func TestDevelopmentLoginDisabledByDefault(t *testing.T) { func TestDevelopmentLoginDisabledByDefault(t *testing.T) {
probe := func(context.Context) error { return nil } probe := func(context.Context) error { return nil }
router := NewRouter(operations.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{}) router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{}`))) router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{}`)))
if rec.Code != http.StatusServiceUnavailable { if rec.Code != http.StatusServiceUnavailable {
@@ -1,5 +1,5 @@
// Package http adapts application services to the HTTP protocol. // Package handler adapts application services to the HTTP protocol.
package http package handler
import ( import (
"context" "context"
@@ -10,8 +10,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"veyra/services/api/internal/operations/service"
"veyra/services/api/internal/platform/adminui" "git.sundynix.cn/Blizzard/Veyra/internal/adminui"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
) )
func NewRouter(health *service.HealthService, logger *slog.Logger, identity IdentityOptions) http.Handler { func NewRouter(health *service.HealthService, logger *slog.Logger, identity IdentityOptions) http.Handler {
@@ -1,4 +1,4 @@
package http package handler
import ( import (
"context" "context"
@@ -10,7 +10,7 @@ import (
"strings" "strings"
"testing" "testing"
"veyra/services/api/internal/operations/service" "git.sundynix.cn/Blizzard/Veyra/internal/service"
) )
func TestLivenessAndReadinessAreIndependent(t *testing.T) { func TestLivenessAndReadinessAreIndependent(t *testing.T) {
@@ -14,14 +14,12 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
identityrepo "veyra/services/api/internal/identity/repository"
identity "veyra/services/api/internal/identity/service" "git.sundynix.cn/Blizzard/Veyra/internal/apperror"
operations "veyra/services/api/internal/operations/service" "git.sundynix.cn/Blizzard/Veyra/internal/database"
"veyra/services/api/internal/platform/apperror" "git.sundynix.cn/Blizzard/Veyra/internal/handler"
"veyra/services/api/internal/platform/database" "git.sundynix.cn/Blizzard/Veyra/internal/repository"
portfoliorepo "veyra/services/api/internal/portfolio/repository" "git.sundynix.cn/Blizzard/Veyra/internal/service"
portfolio "veyra/services/api/internal/portfolio/service"
transport "veyra/services/api/internal/transport/http"
) )
func TestIdentityAccountLifecycle(t *testing.T) { func TestIdentityAccountLifecycle(t *testing.T) {
@@ -58,7 +56,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
if err := database.Migrate(ctx, pool); err != nil { if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err) t.Fatal(err)
} }
auth, err := identity.NewAuthService(identityrepo.NewPostgres(pool)) auth, err := service.NewAuthService(repository.NewIdentityRepository(pool))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -93,7 +91,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) { if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("revoked session remained valid") t.Fatal("revoked session remained valid")
} }
accounts := portfolio.NewAccountService(portfoliorepo.NewPostgres(pool)) accounts := service.NewAccountService(repository.NewAccountRepository(pool))
cash := "100000.01" cash := "100000.01"
a, err := accounts.Create(ctx, u1.ID, "simulation", "Simulation", &cash) a, err := accounts.Create(ctx, u1.ID, "simulation", "Simulation", &cash)
if err != nil || a.CashBalance == nil || *a.CashBalance != cash { if err != nil || a.CashBalance == nil || *a.CashBalance != cash {
@@ -123,7 +121,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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 { 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", service.TokenHash(login.Token)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) { if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
@@ -133,7 +131,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
probe := func(context.Context) error { return nil } 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}) router := handler.NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), handler.IdentityOptions{Auth: auth, Accounts: accounts, Limiter: allowLogin{}, DevelopmentLogin: true})
call := func(method, path, body, origin string, cookie *http.Cookie) *httptest.ResponseRecorder { call := func(method, path, body, origin string, cookie *http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body)) req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body))
if body != "" { if body != "" {
@@ -1,4 +1,4 @@
package domain package model
import "time" import "time"
@@ -1,4 +1,4 @@
package domain package model
import ( import (
"time" "time"
@@ -1,4 +1,4 @@
package domain package model
import "time" import "time"
@@ -1,11 +1,12 @@
package domain package model
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"veyra/services/api/internal/platform/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
) )
var moneyPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,9})(\.[0-9]{1,2})?$`) var moneyPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,9})(\.[0-9]{1,2})?$`)
@@ -1,4 +1,4 @@
package domain package model
import "testing" import "testing"
@@ -1,4 +1,4 @@
package database package model
import "time" import "time"
@@ -1,4 +1,4 @@
package domain package model
import "time" import "time"
@@ -2,78 +2,79 @@ package repository
import ( import (
"context" "context"
"errors"
"veyra/services/api/internal/platform/apperror"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"veyra/services/api/internal/portfolio/domain"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
) )
type Postgres struct{ pool *pgxpool.Pool } type AccountRepository struct{ pool *pgxpool.Pool }
func NewPostgres(pool *pgxpool.Pool) *Postgres { return &Postgres{pool: pool} } func NewAccountRepository(pool *pgxpool.Pool) *AccountRepository {
return &AccountRepository{pool: pool}
}
func (r *Postgres) Create(ctx context.Context, a domain.Account, entry *domain.CashEntry) (domain.AccountSummary, error) { func (r *AccountRepository) Create(ctx context.Context, a model.Account, entry *model.CashEntry) (model.AccountSummary, error) {
tx, err := r.pool.Begin(ctx) tx, err := r.pool.Begin(ctx)
if err != nil { if err != nil {
return domain.AccountSummary{}, apperror.ErrUnavailable return model.AccountSummary{}, apperror.ErrUnavailable
} }
defer tx.Rollback(context.Background()) defer tx.Rollback(context.Background())
// Lock the owner so account creation cannot race with disabling the user. // Lock the owner so account creation cannot race with disabling the user.
var status string 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 { 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) return model.AccountSummary{}, mapError(err)
} }
if status != "ACTIVE" { if status != "ACTIVE" {
return domain.AccountSummary{}, apperror.ErrForbidden return model.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) 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 { if err != nil {
return domain.AccountSummary{}, mapError(err) return model.AccountSummary{}, mapError(err)
} }
var balance *string var balance *string
if entry != nil { 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 { 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) return model.AccountSummary{}, mapError(err)
} }
value := domain.FormatMoney(entry.AmountMinor) value := model.FormatMoney(entry.AmountMinor)
balance = &value balance = &value
} }
if err := tx.Commit(ctx); err != nil { if err := tx.Commit(ctx); err != nil {
return domain.AccountSummary{}, apperror.ErrUnavailable return model.AccountSummary{}, apperror.ErrUnavailable
} }
return domain.AccountSummary{Account: a, CashBalance: balance}, nil return model.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` 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) { func scanSummary(row pgx.Row) (model.AccountSummary, error) {
var s domain.AccountSummary var s model.AccountSummary
var minor *int64 var minor *int64
err := row.Scan(&s.ID, &s.UserID, &s.Mode, &s.Name, &s.Currency, &s.LedgerStatus, &s.CreatedAt, &minor) err := row.Scan(&s.ID, &s.UserID, &s.Mode, &s.Name, &s.Currency, &s.LedgerStatus, &s.CreatedAt, &minor)
if err != nil { if err != nil {
return s, mapError(err) return s, mapError(err)
} }
if minor != nil { if minor != nil {
value := domain.FormatMoney(*minor) value := model.FormatMoney(*minor)
s.CashBalance = &value s.CashBalance = &value
} }
return s, nil return s, nil
} }
func (r *Postgres) Get(ctx context.Context, userID, accountID string) (domain.AccountSummary, error) { func (r *AccountRepository) Get(ctx context.Context, userID, accountID string) (model.AccountSummary, error) {
return scanSummary(r.pool.QueryRow(ctx, summarySelect+` WHERE a.user_id=$1 AND a.id=$2`, userID, accountID)) 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) { func (r *AccountRepository) List(ctx context.Context, userID string) ([]model.AccountSummary, error) {
rows, err := r.pool.Query(ctx, summarySelect+` WHERE a.user_id=$1 ORDER BY a.created_at,a.id`, userID) rows, err := r.pool.Query(ctx, summarySelect+` WHERE a.user_id=$1 ORDER BY a.created_at,a.id`, userID)
if err != nil { if err != nil {
return nil, apperror.ErrUnavailable return nil, apperror.ErrUnavailable
} }
defer rows.Close() defer rows.Close()
list := make([]domain.AccountSummary, 0) list := make([]model.AccountSummary, 0)
for rows.Next() { for rows.Next() {
s, err := scanSummary(rows) s, err := scanSummary(rows)
if err != nil { if err != nil {
@@ -83,17 +84,3 @@ func (r *Postgres) List(ctx context.Context, userID string) ([]domain.AccountSum
} }
return list, mapError(rows.Err()) 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
}
+24
View File
@@ -0,0 +1,24 @@
package repository
import (
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
)
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
}
@@ -4,73 +4,75 @@ import (
"context" "context"
"errors" "errors"
"time" "time"
"veyra/services/api/internal/platform/apperror"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"veyra/services/api/internal/identity/domain"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
) )
type Postgres struct{ pool *pgxpool.Pool } type IdentityRepository struct{ pool *pgxpool.Pool }
func NewPostgres(pool *pgxpool.Pool) *Postgres { return &Postgres{pool: pool} } func NewIdentityRepository(pool *pgxpool.Pool) *IdentityRepository {
return &IdentityRepository{pool: pool}
}
func (r *Postgres) CreateUser(ctx context.Context, user domain.User, credential domain.Credential) (domain.User, error) { func (r *IdentityRepository) CreateUser(ctx context.Context, user model.User, credential model.Credential) (model.User, error) {
tx, err := r.pool.Begin(ctx) tx, err := r.pool.Begin(ctx)
if err != nil { if err != nil {
return domain.User{}, apperror.ErrUnavailable return model.User{}, apperror.ErrUnavailable
} }
defer tx.Rollback(context.Background()) 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) 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 { if err != nil {
return domain.User{}, mapError(err) return model.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) _, 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 { if err != nil {
return domain.User{}, mapError(err) return model.User{}, mapError(err)
} }
if err = tx.Commit(ctx); err != nil { if err = tx.Commit(ctx); err != nil {
return domain.User{}, apperror.ErrUnavailable return model.User{}, apperror.ErrUnavailable
} }
return user, nil return user, nil
} }
func (r *Postgres) FindCredential(ctx context.Context, login string) (domain.User, domain.Credential, error) { func (r *IdentityRepository) FindCredential(ctx context.Context, login string) (model.User, model.Credential, error) {
var u domain.User var u model.User
var c domain.Credential var c model.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) 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) return u, c, mapError(err)
} }
func (r *Postgres) CreateSession(ctx context.Context, s domain.Session) error { func (r *IdentityRepository) CreateSession(ctx context.Context, s model.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) _, 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) return mapError(err)
} }
func (r *Postgres) FindSessionUser(ctx context.Context, hash, audience string, now time.Time) (domain.User, error) { func (r *IdentityRepository) FindSessionUser(ctx context.Context, hash, audience string, now time.Time) (model.User, error) {
var u domain.User var u model.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) 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) { if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, apperror.ErrUnauthenticated return model.User{}, apperror.ErrUnauthenticated
} }
return u, mapError(err) return u, mapError(err)
} }
func (r *Postgres) RevokeSession(ctx context.Context, hash, audience string, now time.Time) error { func (r *IdentityRepository) 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) _, 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) return mapError(err)
} }
func (r *Postgres) ListUsers(ctx context.Context, limit int) ([]domain.User, error) { func (r *IdentityRepository) ListUsers(ctx context.Context, limit int) ([]model.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) 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 { if err != nil {
return nil, apperror.ErrUnavailable return nil, apperror.ErrUnavailable
} }
defer rows.Close() defer rows.Close()
users := make([]domain.User, 0) users := make([]model.User, 0)
for rows.Next() { for rows.Next() {
var u domain.User var u model.User
if err := rows.Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil { if err := rows.Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, apperror.ErrUnavailable return nil, apperror.ErrUnavailable
} }
@@ -78,17 +80,3 @@ func (r *Postgres) ListUsers(ctx context.Context, limit int) ([]domain.User, err
} }
return users, mapError(rows.Err()) 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
}
+70
View File
@@ -0,0 +1,70 @@
package service
import (
"context"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
)
type AccountRepository interface {
Create(context.Context, model.Account, *model.CashEntry) (model.AccountSummary, error)
List(context.Context, string) ([]model.AccountSummary, error)
Get(context.Context, string, string) (model.AccountSummary, error)
}
type AccountService struct{ repo AccountRepository }
func NewAccountService(repo AccountRepository) *AccountService { return &AccountService{repo: repo} }
func (s *AccountService) Create(ctx context.Context, userID, mode, name string, initialCash *string) (model.AccountSummary, error) {
if s.repo == nil {
return model.AccountSummary{}, apperror.ErrUnavailable
}
name = strings.TrimSpace(name)
if name == "" || utf8.RuneCountInString(name) > 80 {
return model.AccountSummary{}, apperror.ErrInvalidInput
}
account := model.Account{ID: uuid.NewString(), UserID: userID, Mode: mode, Name: name, Currency: "CNY", LedgerStatus: "INCOMPLETE"}
var entry *model.CashEntry
switch mode {
case "simulation":
if initialCash == nil {
return model.AccountSummary{}, apperror.ErrInvalidInput
}
amount, err := model.ParseMoney(*initialCash)
if err != nil {
return model.AccountSummary{}, err
}
account.LedgerStatus = "COMPLETE"
entry = &model.CashEntry{ID: uuid.NewString(), AccountID: account.ID, EntryType: "INITIAL", AmountMinor: amount}
case "manual_real":
if initialCash != nil {
return model.AccountSummary{}, apperror.ErrInvalidInput
}
default:
return model.AccountSummary{}, apperror.ErrInvalidInput
}
return s.repo.Create(ctx, account, entry)
}
func (s *AccountService) List(ctx context.Context, userID string) ([]model.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) (model.AccountSummary, error) {
if s.repo == nil {
return model.AccountSummary{}, apperror.ErrUnavailable
}
if _, err := uuid.Parse(accountID); err != nil {
return model.AccountSummary{}, apperror.ErrNotFound
}
return s.repo.Get(ctx, userID, accountID)
}
@@ -12,29 +12,30 @@ import (
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"veyra/services/api/internal/platform/apperror"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"veyra/services/api/internal/identity/domain"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
) )
type Repository interface { type AuthRepository interface {
CreateUser(context.Context, domain.User, domain.Credential) (domain.User, error) CreateUser(context.Context, model.User, model.Credential) (model.User, error)
FindCredential(context.Context, string) (domain.User, domain.Credential, error) FindCredential(context.Context, string) (model.User, model.Credential, error)
CreateSession(context.Context, domain.Session) error CreateSession(context.Context, model.Session) error
FindSessionUser(context.Context, string, string, time.Time) (domain.User, error) FindSessionUser(context.Context, string, string, time.Time) (model.User, error)
RevokeSession(context.Context, string, string, time.Time) error RevokeSession(context.Context, string, string, time.Time) error
ListUsers(context.Context, int) ([]domain.User, error) ListUsers(context.Context, int) ([]model.User, error)
} }
type AuthService struct { type AuthService struct {
repo Repository repo AuthRepository
now func() time.Time now func() time.Time
dummyHash []byte dummyHash []byte
} }
func NewAuthService(repo Repository) (*AuthService, error) { func NewAuthService(repo AuthRepository) (*AuthService, error) {
hash, err := bcrypt.GenerateFromPassword([]byte("invalid-user-timing-placeholder"), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte("invalid-user-timing-placeholder"), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, fmt.Errorf("initialize password verifier: %w", err) return nil, fmt.Errorf("initialize password verifier: %w", err)
@@ -45,26 +46,26 @@ func NewAuthService(repo Repository) (*AuthService, error) {
var loginPattern = regexp.MustCompile(`^[a-z0-9_]{3,64}$`) var loginPattern = regexp.MustCompile(`^[a-z0-9_]{3,64}$`)
// ProvisionUser is called by a local CLI. Public registration cannot choose ADMIN. // 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) { func (s *AuthService) ProvisionUser(ctx context.Context, login, password, name, role string) (model.User, error) {
login = strings.ToLower(strings.TrimSpace(login)) login = strings.ToLower(strings.TrimSpace(login))
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if !loginPattern.MatchString(login) || len(password) < 12 || len(password) > 72 || utf8.RuneCountInString(name) > 80 || name == "" || (role != "USER" && role != "ADMIN") { if !loginPattern.MatchString(login) || len(password) < 12 || len(password) > 72 || utf8.RuneCountInString(name) > 80 || name == "" || (role != "USER" && role != "ADMIN") {
return domain.User{}, apperror.ErrInvalidInput return model.User{}, apperror.ErrInvalidInput
} }
if s.repo == nil { if s.repo == nil {
return domain.User{}, apperror.ErrUnavailable return model.User{}, apperror.ErrUnavailable
} }
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return domain.User{}, fmt.Errorf("hash password: %w", err) return model.User{}, fmt.Errorf("hash password: %w", err)
} }
user := domain.User{ID: uuid.NewString(), DisplayName: name, Status: "ACTIVE", Role: role} user := model.User{ID: uuid.NewString(), DisplayName: name, Status: "ACTIVE", Role: role}
credential := domain.Credential{ID: uuid.NewString(), UserID: user.ID, LoginName: login, PasswordHash: string(hash)} credential := model.Credential{ID: uuid.NewString(), UserID: user.ID, LoginName: login, PasswordHash: string(hash)}
return s.repo.CreateUser(ctx, user, credential) return s.repo.CreateUser(ctx, user, credential)
} }
type LoginResult struct { type LoginResult struct {
User domain.User User model.User
Token string Token string
ExpiresAt time.Time ExpiresAt time.Time
} }
@@ -97,29 +98,29 @@ func (s *AuthService) Login(ctx context.Context, login, password, audience strin
} }
token := base64.RawURLEncoding.EncodeToString(raw) token := base64.RawURLEncoding.EncodeToString(raw)
expires := s.now().UTC().Add(8 * time.Hour) expires := s.now().UTC().Add(8 * time.Hour)
session := domain.Session{ID: uuid.NewString(), UserID: user.ID, TokenHash: TokenHash(token), Audience: audience, ExpiresAt: expires} session := model.Session{ID: uuid.NewString(), UserID: user.ID, TokenHash: TokenHash(token), Audience: audience, ExpiresAt: expires}
if err := s.repo.CreateSession(ctx, session); err != nil { if err := s.repo.CreateSession(ctx, session); err != nil {
return LoginResult{}, err return LoginResult{}, err
} }
return LoginResult{User: user, Token: token, ExpiresAt: expires}, nil return LoginResult{User: user, Token: token, ExpiresAt: expires}, nil
} }
func (s *AuthService) Authenticate(ctx context.Context, token, audience string) (domain.User, error) { func (s *AuthService) Authenticate(ctx context.Context, token, audience string) (model.User, error) {
if s.repo == nil { if s.repo == nil {
return domain.User{}, apperror.ErrUnavailable return model.User{}, apperror.ErrUnavailable
} }
if len(token) != 43 { if len(token) != 43 {
return domain.User{}, apperror.ErrUnauthenticated return model.User{}, apperror.ErrUnauthenticated
} }
if audience != "admin" && audience != "app" { if audience != "admin" && audience != "app" {
return domain.User{}, apperror.ErrUnauthenticated return model.User{}, apperror.ErrUnauthenticated
} }
user, err := s.repo.FindSessionUser(ctx, TokenHash(token), audience, s.now().UTC()) user, err := s.repo.FindSessionUser(ctx, TokenHash(token), audience, s.now().UTC())
if err != nil { if err != nil {
return domain.User{}, err return model.User{}, err
} }
if user.Status != "ACTIVE" || audience == "admin" && user.Role != "ADMIN" || audience == "app" && user.Role != "USER" { if user.Status != "ACTIVE" || audience == "admin" && user.Role != "ADMIN" || audience == "app" && user.Role != "USER" {
return domain.User{}, apperror.ErrUnauthenticated return model.User{}, apperror.ErrUnauthenticated
} }
return user, nil return user, nil
} }
@@ -131,7 +132,7 @@ func (s *AuthService) Logout(ctx context.Context, token, audience string) error
return s.repo.RevokeSession(ctx, TokenHash(token), audience, s.now().UTC()) return s.repo.RevokeSession(ctx, TokenHash(token), audience, s.now().UTC())
} }
func (s *AuthService) ListUsers(ctx context.Context) ([]domain.User, error) { func (s *AuthService) ListUsers(ctx context.Context) ([]model.User, error) {
if s.repo == nil { if s.repo == nil {
return nil, apperror.ErrUnavailable return nil, apperror.ErrUnavailable
} }
@@ -6,39 +6,39 @@ import (
"testing" "testing"
"time" "time"
"veyra/services/api/internal/identity/domain" "git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"veyra/services/api/internal/platform/apperror" "git.sundynix.cn/Blizzard/Veyra/internal/model"
) )
type testRepository struct { type testRepository struct {
user domain.User user model.User
credential domain.Credential credential model.Credential
session domain.Session session model.Session
createdSessions int createdSessions int
} }
func (r *testRepository) CreateUser(_ context.Context, u domain.User, c domain.Credential) (domain.User, error) { func (r *testRepository) CreateUser(_ context.Context, u model.User, c model.Credential) (model.User, error) {
r.user = u r.user = u
r.credential = c r.credential = c
return u, nil return u, nil
} }
func (r *testRepository) FindCredential(_ context.Context, login string) (domain.User, domain.Credential, error) { func (r *testRepository) FindCredential(_ context.Context, login string) (model.User, model.Credential, error) {
if login != r.credential.LoginName { if login != r.credential.LoginName {
return domain.User{}, domain.Credential{}, apperror.ErrNotFound return model.User{}, model.Credential{}, apperror.ErrNotFound
} }
return r.user, r.credential, nil return r.user, r.credential, nil
} }
func (r *testRepository) CreateSession(_ context.Context, s domain.Session) error { func (r *testRepository) CreateSession(_ context.Context, s model.Session) error {
r.session = s r.session = s
r.createdSessions++ r.createdSessions++
return nil return nil
} }
func (r *testRepository) FindSessionUser(_ context.Context, _, _ string, _ time.Time) (domain.User, error) { func (r *testRepository) FindSessionUser(_ context.Context, _, _ string, _ time.Time) (model.User, error) {
return r.user, nil return r.user, nil
} }
func (r *testRepository) RevokeSession(_ context.Context, _, _ string, _ time.Time) error { return nil } func (r *testRepository) RevokeSession(_ context.Context, _, _ string, _ time.Time) error { return nil }
func (r *testRepository) ListUsers(context.Context, int) ([]domain.User, error) { func (r *testRepository) ListUsers(context.Context, int) ([]model.User, error) {
return []domain.User{r.user}, nil return []model.User{r.user}, nil
} }
func TestPasswordAndSessionSecretsAreHashed(t *testing.T) { func TestPasswordAndSessionSecretsAreHashed(t *testing.T) {
@@ -81,7 +81,7 @@ func TestPasswordAndSessionSecretsAreHashed(t *testing.T) {
} }
func TestUserCannotAuthenticateAsAdmin(t *testing.T) { func TestUserCannotAuthenticateAsAdmin(t *testing.T) {
repo := &testRepository{user: domain.User{Status: "ACTIVE", Role: "USER"}} repo := &testRepository{user: model.User{Status: "ACTIVE", Role: "USER"}}
auth, err := NewAuthService(repo) auth, err := NewAuthService(repo)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+26 -26
View File
@@ -6,33 +6,9 @@
"": { "": {
"name": "veyra", "name": "veyra",
"workspaces": [ "workspaces": [
"apps/admin" "web/admin"
] ]
}, },
"apps/admin": {
"name": "@veyra/admin",
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-slot": "1.2.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.468.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.2.4",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.7.0",
"tailwindcss": "4.2.4",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
"vite": "6.4.3"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1623,7 +1599,7 @@
} }
}, },
"node_modules/@veyra/admin": { "node_modules/@veyra/admin": {
"resolved": "apps/admin", "resolved": "web/admin",
"link": true "link": true
}, },
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
@@ -2625,6 +2601,30 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"web/admin": {
"name": "@veyra/admin",
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-slot": "1.2.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.468.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.2.4",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.7.0",
"tailwindcss": "4.2.4",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
"vite": "6.4.3"
}
} }
} }
} }
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "veyra", "name": "veyra",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"apps/admin" "web/admin"
], ],
"scripts": { "scripts": {
"dev:admin": "npm run dev --workspace @veyra/admin", "dev:admin": "npm run dev --workspace @veyra/admin",
@@ -1,69 +0,0 @@
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)
}
@@ -1,5 +1,5 @@
import { cp, mkdir, rm } from "node:fs/promises" import { cp, mkdir, rm } from "node:fs/promises"
const target = new URL("../../../services/api/internal/platform/adminui/dist/", import.meta.url) const target = new URL("../../../internal/adminui/dist/", import.meta.url)
await mkdir(target, { recursive: true }) await mkdir(target, { recursive: true })
// Preserve the tracked .keep so a clean checkout can build the foundation API. // Preserve the tracked .keep so a clean checkout can build the foundation API.
const { readdir } = await import("node:fs/promises") const { readdir } = await import("node:fs/promises")