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/setup-go@v5
with:
go-version-file: services/api/go.mod
cache-dependency-path: services/api/go.sum
go-version-file: go.mod
cache-dependency-path: go.sum
- uses: actions/setup-node@v4
with:
node-version: 22
@@ -31,5 +31,5 @@ jobs:
- run: npm ci
- run: make build
- run: make test
- run: go vet ./services/api/...
- run: test -z "$(gofmt -l services/api)"
- run: go vet ./...
- run: test -z "$(gofmt -l cmd internal)"
+3 -3
View File
@@ -9,6 +9,6 @@ bin/
.idea/
*.log
coverage/
!services/api/internal/platform/adminui/dist/
services/api/internal/platform/adminui/dist/*
!services/api/internal/platform/adminui/dist/.keep
!internal/adminui/dist/
internal/adminui/dist/*
!internal/adminui/dist/.keep
+1
View File
@@ -3,6 +3,7 @@
项目背景、已完成功能和验证状态见 `docs/development-state.md`。继续开发前阅读该文件,完成工作后更新事实与待办,不记录密码。
- 后端使用 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 传入业务层。
- PostgreSQL 所有表(包括迁移、任务和关联表)必须以 `veyra_` 开头,表与字段使用 snake_case。
- 主键 `id` 使用字符串 UUID:Go 类型 `string`,数据库 `varchar(36)` 并校验 UUID 格式。外键保持相同类型。
+10 -10
View File
@@ -2,24 +2,24 @@
export GOCACHE := $(CURDIR)/.cache/go-build
api: admin-build
go run ./services/api/cmd/api -env-file .env
go run ./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
go build -trimpath -o bin/veyra-api ./cmd/api
go build -trimpath -o bin/veyra-db ./cmd/db
go build -trimpath -o bin/veyra-user ./cmd/user
db-create:
go run ./services/api/cmd/db -env-file .env -action create
go run ./cmd/db -env-file .env -action create
db-migrate:
go run ./services/api/cmd/db -env-file .env -action migrate
go run ./cmd/db -env-file .env -action migrate
admin-create:
go run ./services/api/cmd/user -env-file .env -role ADMIN
go run ./cmd/user -env-file .env -role ADMIN
user-create:
go run ./services/api/cmd/user -env-file .env -role USER
go run ./cmd/user -env-file .env -role USER
test:
go test -race ./services/api/...
go test -race ./...
check:
go vet ./services/api/...
go vet ./...
npm run build --workspace @veyra/admin
admin-dev:
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 则修改。
```sh
go mod download -C services/api
go mod download
npm ci
make db-create
make db-migrate
@@ -71,18 +71,28 @@ VEYRA_TEST_DATABASE_URL='postgres://user:password@host:5432/test_db?sslmode=disa
## 目录
```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 契约
cmd/
api/ API 入口与依赖装配
db/ 建库与迁移命令
user/ 开发身份初始化命令
internal/
config/ 配置加载与校验
database/migrations/ 数据库连接与版本化迁移
model/ User、Account、Session 等模型
repository/ PostgreSQL 数据访问与事务
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 启动不自动建库或迁移。健康检查不会返回连接串和供应商错误细节。
## 待完成
@@ -13,14 +13,12 @@ import (
"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"
"git.sundynix.cn/Blizzard/Veyra/internal/config"
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
"git.sundynix.cn/Blizzard/Veyra/internal/ratelimit"
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
)
func main() {
@@ -44,8 +42,8 @@ func run(logger *slog.Logger) error {
return err
}
var postgres service.Probe
var authRepo identityservice.Repository
var accountRepo portfolioservice.Repository
var authRepo service.AuthRepository
var accountRepo service.AccountRepository
if c.PGUser != "" {
if err := c.ValidateDatabase(); err != nil {
return err
@@ -56,18 +54,18 @@ func run(logger *slog.Logger) error {
}
defer pool.Close()
postgres = pool.Ping
authRepo = identityrepo.NewPostgres(pool)
accountRepo = portfoliorepo.NewPostgres(pool)
authRepo = repository.NewIdentityRepository(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})
defer redisClient.Close()
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 {
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}
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: 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)
defer stop()
result := make(chan error, 1)
@@ -8,8 +8,8 @@ import (
"os"
"time"
"veyra/services/api/internal/platform/config"
"veyra/services/api/internal/platform/database"
"git.sundynix.cn/Blizzard/Veyra/internal/config"
"git.sundynix.cn/Blizzard/Veyra/internal/database"
)
func main() {
@@ -9,10 +9,10 @@ import (
"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"
"git.sundynix.cn/Blizzard/Veyra/internal/config"
"git.sundynix.cn/Blizzard/Veyra/internal/database"
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
)
func main() {
@@ -60,7 +60,7 @@ func run() error {
return err
}
defer pool.Close()
auth, err := service.NewAuthService(repository.NewPostgres(pool))
auth, err := service.NewAuthService(repository.NewIdentityRepository(pool))
if err != nil {
return err
}
+19
View File
@@ -33,6 +33,19 @@
- OpenAPI、依赖锁文件、Makefile 和带 PostgreSQL 集成测试服务的 CI。
- `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 检查)。
@@ -58,3 +71,9 @@
用户本轮明确要求保存记忆、git add、commit 和 push。目标为当前 master 分支的 origin;操作结果以实际 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
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/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"veyra/services/api/internal/platform/config"
"git.sundynix.cn/Blizzard/Veyra/internal/config"
)
//go:embed migrations/*.sql
@@ -1,4 +1,4 @@
package http
package handler
import (
"context"
@@ -9,20 +9,20 @@ import (
"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"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
)
type LoginLimiter interface {
Login(context.Context, string, string) (bool, error)
}
type IdentityOptions struct {
Auth *identity.AuthService
Accounts *portfolio.AccountService
Auth *service.AuthService
Accounts *service.AccountService
Limiter LoginLimiter
DevelopmentLogin bool
SecureCookie bool
@@ -193,7 +193,7 @@ func requestToken(r *http.Request, audience string) string {
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.
func sameOrigin(r *http.Request) bool {
@@ -1,4 +1,4 @@
package http
package handler
import (
"context"
@@ -9,17 +9,16 @@ import (
"strings"
"testing"
identity "veyra/services/api/internal/identity/service"
operations "veyra/services/api/internal/operations/service"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
)
func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
auth, err := identity.NewAuthService(nil)
auth, err := service.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})
router := NewRouter(service.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
@@ -44,7 +43,7 @@ func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
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{})
router := NewRouter(service.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 {
@@ -1,5 +1,5 @@
// Package http adapts application services to the HTTP protocol.
package http
// Package handler adapts application services to the HTTP protocol.
package handler
import (
"context"
@@ -10,8 +10,9 @@ import (
"github.com/gin-gonic/gin"
"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 {
@@ -1,4 +1,4 @@
package http
package handler
import (
"context"
@@ -10,7 +10,7 @@ import (
"strings"
"testing"
"veyra/services/api/internal/operations/service"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
)
func TestLivenessAndReadinessAreIndependent(t *testing.T) {
@@ -14,14 +14,12 @@ import (
"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"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/database"
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
)
func TestIdentityAccountLifecycle(t *testing.T) {
@@ -58,7 +56,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
auth, err := identity.NewAuthService(identityrepo.NewPostgres(pool))
auth, err := service.NewAuthService(repository.NewIdentityRepository(pool))
if err != nil {
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) {
t.Fatal("revoked session remained valid")
}
accounts := portfolio.NewAccountService(portfoliorepo.NewPostgres(pool))
accounts := service.NewAccountService(repository.NewAccountRepository(pool))
cash := "100000.01"
a, err := accounts.Create(ctx, u1.ID, "simulation", "Simulation", &cash)
if err != nil || a.CashBalance == nil || *a.CashBalance != cash {
@@ -123,7 +121,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
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 {
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)
}
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)
}
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 {
req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body))
if body != "" {
@@ -1,4 +1,4 @@
package domain
package model
import "time"
@@ -1,4 +1,4 @@
package domain
package model
import (
"time"
@@ -1,4 +1,4 @@
package domain
package model
import "time"
@@ -1,11 +1,12 @@
package domain
package model
import (
"fmt"
"regexp"
"strconv"
"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})?$`)
@@ -1,4 +1,4 @@
package domain
package model
import "testing"
@@ -1,4 +1,4 @@
package database
package model
import "time"
@@ -1,4 +1,4 @@
package domain
package model
import "time"
@@ -2,78 +2,79 @@ 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"
"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)
if err != nil {
return domain.AccountSummary{}, apperror.ErrUnavailable
return model.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)
return model.AccountSummary{}, mapError(err)
}
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)
if err != nil {
return domain.AccountSummary{}, mapError(err)
return model.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)
return model.AccountSummary{}, mapError(err)
}
value := domain.FormatMoney(entry.AmountMinor)
value := model.FormatMoney(entry.AmountMinor)
balance = &value
}
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`
func scanSummary(row pgx.Row) (domain.AccountSummary, error) {
var s domain.AccountSummary
func scanSummary(row pgx.Row) (model.AccountSummary, error) {
var s model.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)
value := model.FormatMoney(*minor)
s.CashBalance = &value
}
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))
}
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)
if err != nil {
return nil, apperror.ErrUnavailable
}
defer rows.Close()
list := make([]domain.AccountSummary, 0)
list := make([]model.AccountSummary, 0)
for rows.Next() {
s, err := scanSummary(rows)
if err != nil {
@@ -83,17 +84,3 @@ func (r *Postgres) List(ctx context.Context, userID string) ([]domain.AccountSum
}
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"
"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"
"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)
if err != nil {
return domain.User{}, apperror.ErrUnavailable
return model.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)
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)
if err != nil {
return domain.User{}, mapError(err)
return model.User{}, mapError(err)
}
if err = tx.Commit(ctx); err != nil {
return domain.User{}, apperror.ErrUnavailable
return model.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
func (r *IdentityRepository) FindCredential(ctx context.Context, login string) (model.User, model.Credential, error) {
var u model.User
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)
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)
return mapError(err)
}
func (r *Postgres) FindSessionUser(ctx context.Context, hash, audience string, now time.Time) (domain.User, error) {
var u domain.User
func (r *IdentityRepository) FindSessionUser(ctx context.Context, hash, audience string, now time.Time) (model.User, error) {
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)
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, apperror.ErrUnauthenticated
return model.User{}, apperror.ErrUnauthenticated
}
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)
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)
if err != nil {
return nil, apperror.ErrUnavailable
}
defer rows.Close()
users := make([]domain.User, 0)
users := make([]model.User, 0)
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 {
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())
}
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"
"time"
"unicode/utf8"
"veyra/services/api/internal/platform/apperror"
"github.com/google/uuid"
"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 {
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)
type AuthRepository interface {
CreateUser(context.Context, model.User, model.Credential) (model.User, error)
FindCredential(context.Context, string) (model.User, model.Credential, error)
CreateSession(context.Context, model.Session) error
FindSessionUser(context.Context, string, string, time.Time) (model.User, 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 {
repo Repository
repo AuthRepository
now func() time.Time
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)
if err != nil {
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}$`)
// 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))
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
return model.User{}, apperror.ErrInvalidInput
}
if s.repo == nil {
return domain.User{}, apperror.ErrUnavailable
return model.User{}, apperror.ErrUnavailable
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
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}
credential := domain.Credential{ID: uuid.NewString(), UserID: user.ID, LoginName: login, PasswordHash: string(hash)}
user := model.User{ID: uuid.NewString(), DisplayName: name, Status: "ACTIVE", Role: role}
credential := model.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
User model.User
Token string
ExpiresAt time.Time
}
@@ -97,29 +98,29 @@ func (s *AuthService) Login(ctx context.Context, login, password, audience strin
}
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}
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 {
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) {
func (s *AuthService) Authenticate(ctx context.Context, token, audience string) (model.User, error) {
if s.repo == nil {
return domain.User{}, apperror.ErrUnavailable
return model.User{}, apperror.ErrUnavailable
}
if len(token) != 43 {
return domain.User{}, apperror.ErrUnauthenticated
return model.User{}, apperror.ErrUnauthenticated
}
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())
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" {
return domain.User{}, apperror.ErrUnauthenticated
return model.User{}, apperror.ErrUnauthenticated
}
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())
}
func (s *AuthService) ListUsers(ctx context.Context) ([]domain.User, error) {
func (s *AuthService) ListUsers(ctx context.Context) ([]model.User, error) {
if s.repo == nil {
return nil, apperror.ErrUnavailable
}
@@ -6,39 +6,39 @@ import (
"testing"
"time"
"veyra/services/api/internal/identity/domain"
"veyra/services/api/internal/platform/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
)
type testRepository struct {
user domain.User
credential domain.Credential
session domain.Session
user model.User
credential model.Credential
session model.Session
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.credential = c
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 {
return domain.User{}, domain.Credential{}, apperror.ErrNotFound
return model.User{}, model.Credential{}, apperror.ErrNotFound
}
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.createdSessions++
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
}
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 (r *testRepository) ListUsers(context.Context, int) ([]model.User, error) {
return []model.User{r.user}, nil
}
func TestPasswordAndSessionSecretsAreHashed(t *testing.T) {
@@ -81,7 +81,7 @@ func TestPasswordAndSessionSecretsAreHashed(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)
if err != nil {
t.Fatal(err)
+26 -26
View File
@@ -6,33 +6,9 @@
"": {
"name": "veyra",
"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": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1623,7 +1599,7 @@
}
},
"node_modules/@veyra/admin": {
"resolved": "apps/admin",
"resolved": "web/admin",
"link": true
},
"node_modules/@vitejs/plugin-react": {
@@ -2625,6 +2601,30 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"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",
"private": true,
"workspaces": [
"apps/admin"
"web/admin"
],
"scripts": {
"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"
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 })
// Preserve the tracked .keep so a clean checkout can build the foundation API.
const { readdir } = await import("node:fs/promises")