@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 格式。外键保持相同类型。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
module veyra/services/api
|
module git.sundynix.cn/Blizzard/Veyra
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
-32
@@ -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 {
|
||||||
+5
-6
@@ -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 {
|
||||||
+2
-2
@@ -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) {
|
||||||
+10
-12
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package database
|
package model
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package domain
|
package model
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
+22
-35
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+23
-35
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
+13
-13
@@ -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)
|
||||||
Generated
+26
-26
@@ -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
@@ -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")
|
||||||
Reference in New Issue
Block a user