统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
@@ -1,19 +0,0 @@
|
|||||||
APP_ENV=local
|
|
||||||
HTTP_ADDR=127.0.0.1:8080
|
|
||||||
PGHOST=192.168.100.127
|
|
||||||
PGPORT=5432
|
|
||||||
PGDATABASE=veyra
|
|
||||||
PGUSER=
|
|
||||||
PGPASSWORD=
|
|
||||||
PGSSLMODE=disable
|
|
||||||
PGADMIN_DATABASE=postgres
|
|
||||||
REDIS_ADDR=192.168.100.127:6379
|
|
||||||
REDIS_USERNAME=
|
|
||||||
# Redis 无用户名;将实际密码填写到本地 .env。
|
|
||||||
REDIS_PASSWORD=
|
|
||||||
REDIS_DB=0
|
|
||||||
# 开发阶段身份:与数据库登录凭据完全独立;密码为 12–72 字节。
|
|
||||||
ADMIN_LOGIN=admin
|
|
||||||
ADMIN_PASSWORD=
|
|
||||||
DEV_USER_LOGIN=demo
|
|
||||||
DEV_USER_PASSWORD=
|
|
||||||
@@ -24,12 +24,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache-dependency-path: go.sum
|
cache-dependency-path: go.sum
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
cache-dependency-path: |
|
||||||
- run: make build
|
web/admin/package-lock.json
|
||||||
- run: make test
|
apps/mobile/package-lock.json
|
||||||
|
- run: npm --prefix web/admin ci
|
||||||
|
- run: npm --prefix apps/mobile ci
|
||||||
|
- run: npm --prefix apps/mobile run typecheck
|
||||||
|
- run: npm --prefix apps/mobile run test
|
||||||
|
- run: npm --prefix apps/mobile run build
|
||||||
|
- run: npm --prefix web/admin run build
|
||||||
|
- run: go build -trimpath -o bin/veyra .
|
||||||
|
- run: go test -race ./...
|
||||||
- run: go vet ./...
|
- run: go vet ./...
|
||||||
- run: test -z "$(gofmt -l cmd internal)"
|
- run: test -z "$(gofmt -l main.go internal)"
|
||||||
|
|||||||
@@ -12,3 +12,9 @@ coverage/
|
|||||||
!internal/adminui/dist/
|
!internal/adminui/dist/
|
||||||
internal/adminui/dist/*
|
internal/adminui/dist/*
|
||||||
!internal/adminui/dist/.keep
|
!internal/adminui/dist/.keep
|
||||||
|
|
||||||
|
# Expo 本地设备状态与生成类型
|
||||||
|
.expo/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
/logs/
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
项目背景、已完成功能和验证状态见 `docs/development-state.md`。继续开发前阅读该文件,完成工作后更新事实与待办,不记录密码。
|
项目背景、已完成功能和验证状态见 `docs/development-state.md`。继续开发前阅读该文件,完成工作后更新事实与待办,不记录密码。
|
||||||
|
|
||||||
- 后端使用 Go,遵循 gofmt、go vet、惯用错误处理、context 传递、明确依赖注入及 cmd/internal 分层。
|
- 后端使用 Go,遵循 gofmt、go vet、惯用错误处理、context 传递、明确依赖注入及入口/internal 分层。
|
||||||
- 后端采用仓库根目录的单 Go module:cmd 放入口,internal 按 model、service、repository、handler、config、database 等职责组织;管理端位于 web/admin。避免为单体项目重复嵌套 services/api、业务域目录和 platform 层,不引入单模块 go.work。
|
- 后端采用仓库根目录的单 Go module:仅保留根目录 main.go 一个入口和一个 main 函数,internal 按 model、service、repository、handler、config、database 等职责组织;管理端位于 web/admin。避免为单体项目重复嵌套 services/api、业务域目录和 platform 层,不引入单模块 go.work。
|
||||||
|
- 不使用 Makefile;后端直接 go run / go build,前端直接使用 npm scripts。数据库和开发身份维护通过同一个可执行文件的 -command 参数切换。
|
||||||
- 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 格式。外键保持相同类型。
|
||||||
@@ -16,5 +17,9 @@
|
|||||||
- 凭据通过环境配置,禁止提交 .env、打印连接串或口令。所有数据库变更使用版本化迁移。
|
- 凭据通过环境配置,禁止提交 .env、打印连接串或口令。所有数据库变更使用版本化迁移。
|
||||||
- 功能与真实数据能力必须如实呈现;不得用演示结果冒充在线统计。
|
- 功能与真实数据能力必须如实呈现;不得用演示结果冒充在线统计。
|
||||||
- 更改后完成对应测试、go vet、类型检查与构建。原始两个设计文件保留。
|
- 更改后完成对应测试、go vet、类型检查与构建。原始两个设计文件保留。
|
||||||
- PostgreSQL 开发地址为 `192.168.100.127:5432`,数据库名 `veyra`;Redis 为 `192.168.100.127:6379`,无用户名、有密码。实际凭据只从本地 `.env` 读取。
|
- PostgreSQL 开发地址为 `192.168.100.127:5432`,数据库名 `veyra`;Redis 为 `192.168.100.127:6379`,无用户名、有密码。实际凭据只从本地 `configs/.env` 读取。
|
||||||
- 本机与数据库处于同一内网。若工具返回 `Operation not permitted`,应区分执行沙箱权限限制与主机网络故障,不因此修改服务地址。
|
- 本机与数据库处于同一内网。若工具返回 `Operation not permitted`,应区分执行沙箱权限限制与主机网络故障,不因此修改服务地址。
|
||||||
|
|
||||||
|
- 后端环境文件与模板位于 configs/;管理端和 App 分别维护自己的 npm 配置、锁文件与依赖,不在根目录建立 npm workspace。
|
||||||
|
|
||||||
|
- 后端日志使用 zap:控制台易读,当前运行目录 logs/ 下自动写文件与轮转;日志不提交,不记录密钥、口令、Cookie 或请求正文。
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
.PHONY: api build db-create db-migrate admin-create user-create test check admin-dev admin-build
|
|
||||||
export GOCACHE := $(CURDIR)/.cache/go-build
|
|
||||||
|
|
||||||
api: admin-build
|
|
||||||
go run ./cmd/api -env-file .env
|
|
||||||
build: admin-build
|
|
||||||
mkdir -p bin
|
|
||||||
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 ./cmd/db -env-file .env -action create
|
|
||||||
db-migrate:
|
|
||||||
go run ./cmd/db -env-file .env -action migrate
|
|
||||||
admin-create:
|
|
||||||
go run ./cmd/user -env-file .env -role ADMIN
|
|
||||||
user-create:
|
|
||||||
go run ./cmd/user -env-file .env -role USER
|
|
||||||
test:
|
|
||||||
go test -race ./...
|
|
||||||
check:
|
|
||||||
go vet ./...
|
|
||||||
npm run build --workspace @veyra/admin
|
|
||||||
admin-dev:
|
|
||||||
npm run dev --workspace @veyra/admin
|
|
||||||
admin-build:
|
|
||||||
npm run build --workspace @veyra/admin
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 见势 · Veyra
|
# 见势 · Veyra
|
||||||
|
|
||||||
AI 量化投资辅助产品。原始交互原型与全栈设计文档保留在根目录;当前交付包括工程基础、开发登录、管理员用户查询与账户初始化。
|
AI 量化投资辅助产品。原始交互原型与全栈设计文档保留在根目录;当前交付包括工程基础、开发登录、管理员用户查询、账户初始化、后台供应商配置与 Expo App 基础流程。
|
||||||
|
|
||||||
## 已实现
|
## 已实现
|
||||||
|
|
||||||
@@ -14,32 +14,71 @@ AI 量化投资辅助产品。原始交互原型与全栈设计文档保留在
|
|||||||
- App 开发登录与 Bearer 会话;管理端和 App 会话互相隔离,数据库只存凭据哈希。
|
- App 开发登录与 Bearer 会话;管理端和 App 会话互相隔离,数据库只存凭据哈希。
|
||||||
- 账户创建、列表与汇总;模拟账户初始现金与流水原子入账,金额使用整数分,API 使用十进制字符串。真实记录账户现金为未知。
|
- 账户创建、列表与汇总;模拟账户初始现金与流水原子入账,金额使用整数分,API 使用十进制字符串。真实记录账户现金为未知。
|
||||||
|
|
||||||
|
- 后台配置主模型、备用模型、审核模型与行情源,初始留空且关闭;密钥加密存储,版本冲突保护、配置历史与审计原子保存。
|
||||||
|
|
||||||
|
- React Native + Expo App:五个主导航、开发登录、安全会话、账户创建与切换、真实现金账本展示;未接入模块明确提示。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
需要 Go 1.26.2 与支持 Vite 的 Node.js,已提交 Go 和 npm 锁文件。根目录执行:
|
需要 Go 1.26.2 与支持 Vite 的 Node.js,已提交 Go 和 npm 锁文件。根目录执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env
|
cp configs/.env.example configs/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
将 PostgreSQL 用户名与密码填写到 `.env`。默认数据库名称 `veyra`,默认管理数据库 `postgres`;创建数据库需要连接角色具有 CREATEDB 权限。Redis 无用户名,有密码:在本地 `.env` 配置 `REDIS_PASSWORD`,保持 `REDIS_USERNAME` 为空。`PGSSLMODE=disable` 是本地模板值,如服务器要求 TLS 则修改。
|
将 PostgreSQL 用户名与密码填写到 `configs/.env`。默认数据库名称 `veyra`,默认管理数据库 `postgres`;创建数据库需要连接角色具有 CREATEDB 权限。Redis 无用户名,有密码:在本地 `configs/.env` 配置 `REDIS_PASSWORD`,保持 `REDIS_USERNAME` 为空。`PGSSLMODE=disable` 是本地模板值,如服务器要求 TLS 则修改。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go mod download
|
go mod download
|
||||||
npm ci
|
npm --prefix web/admin ci
|
||||||
make db-create
|
go run . -command db-create
|
||||||
make db-migrate
|
go run . -command db-migrate
|
||||||
make admin-create
|
go run . -command user-create -role ADMIN
|
||||||
make api
|
npm --prefix web/admin run build
|
||||||
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问 `http://127.0.0.1:8080`,管理端由 Go embed 提供,页面与 API 同源。需要前端热更新时另开终端运行 `make admin-dev`,访问 Vite 输出的本地地址;开发代理将 `/api` 转发到 `127.0.0.1:8080`。API 可在数据库未配置时启动并如实报告不可就绪。
|
启动后访问 `http://127.0.0.1:8080`,管理端由 Go embed 提供,页面与 API 同源。需要前端热更新时另开终端运行 `npm --prefix web/admin run dev`,访问 Vite 输出的本地地址;开发代理将 `/api` 转发到 `127.0.0.1:8080`。API 可在数据库未配置时启动并如实报告不可就绪。
|
||||||
|
|
||||||
管理端初始登录名与密码由 `.env` 的 `ADMIN_LOGIN` / `ADMIN_PASSWORD` 配置,密码要求 12–72 字节。开发环境的本地 `.env` 已单独生成随机密码,模板不包含密码。`make admin-create` 创建身份,不覆盖已有管理员。App 测试身份使用 `DEV_USER_LOGIN` / `DEV_USER_PASSWORD` 和 `make user-create` 初始化。数据库的登录账号不用于产品登录。
|
管理端初始登录名与密码由 `configs/.env` 的 `ADMIN_LOGIN` / `ADMIN_PASSWORD` 配置,管理员密码要求 8–72 字节。凭据只保存在本地 `configs/.env`,模板不包含密码。`go run . -command user-create -role ADMIN` 创建身份,不覆盖已有管理员。App 测试身份使用 `DEV_USER_LOGIN` / `DEV_USER_PASSWORD` 和 `go run . -command user-create -role USER` 初始化。数据库的登录账号不用于产品登录。
|
||||||
|
|
||||||
已有数据库只需 `make db-migrate` 应用新增的 `000002_identity_account.sql`,无需重新建库。
|
已有数据库只需 `go run . -command db-migrate` 应用所有尚未执行的版本化迁移(当前最新为 `000004_llm_configuration.sql`),无需重新建库。
|
||||||
|
|
||||||
`make build` 先构建前端并复制到 embed 目录,再输出 `bin/veyra-api`、`bin/veyra-db` 和 `bin/veyra-user`。运行 `./bin/veyra-api -env-file .env` 无需外置前端文件或 Node.js。修改前端必须重新构建 Go 二进制;直接 `go build` 不会自动编译前端。清空构建产物的 checkout 仍可编译基础 API,管理端返回明确的 503 提示直到完成前端构建。
|
生产编译直接使用 npm 与 Go:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm --prefix web/admin run build
|
||||||
|
go build -trimpath -o bin/veyra .
|
||||||
|
./bin/veyra
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm --prefix web/admin run build` 将前端产物复制到 Go embed 目录,`go build` 输出唯一的 `veyra` 可执行文件。默认读取当前工作目录的 `configs/.env`,也可使用 `-env-file` 指定文件或空值以仅使用进程环境。修改管理端后先重建前端,再重建 Go 二进制;未构建前端时管理端返回明确的 503。
|
||||||
|
|
||||||
|
常规开发启动直接运行 `go run .`,也可以在 IDE 运行根目录 `main.go`;仓库只有一个 main 函数,不依赖 Makefile。服务启动不自动建库、迁移或修改身份。
|
||||||
|
|
||||||
|
开发管理员修改使用本地 `configs/.env` 的 `ADMIN_LOGIN` / `ADMIN_PASSWORD`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . -command admin-set -previous-login <原管理员登录名>
|
||||||
|
```
|
||||||
|
|
||||||
|
已有登录名不变时可省略 `-previous-login`。此命令仅限 local/test,保留原管理员 ID、以 bcrypt 保存新密码并撤销旧会话;登录名冲突时全部回滚。管理员密码为 8–72 字节,App 开发用户仍为 12–72 字节。
|
||||||
|
|
||||||
|
|
||||||
|
### App 开发
|
||||||
|
|
||||||
|
App 位于 `apps/mobile`,使用 Expo Router + React Native + TypeScript,配色为白底、石墨黑与柔紫。初始化 USER 身份后填写 App 的公开 API 地址并启动:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . -command user-create -role USER
|
||||||
|
npm --prefix apps/mobile ci
|
||||||
|
cp apps/mobile/.env.example apps/mobile/.env
|
||||||
|
npm --prefix apps/mobile run start
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `apps/mobile/.env` 设置 `EXPO_PUBLIC_API_URL`。iOS 模拟器可用后端本机地址,Android 模拟器使用 `10.0.2.2`,真机使用开发电脑内网地址。App 登录身份与管理员隔离;凭据不打包到客户端,供应商信息仍由管理端配置。
|
||||||
|
|
||||||
|
`npm --prefix apps/mobile run typecheck && npm --prefix apps/mobile run test` 执行 TypeScript 与客户端测试;`npm --prefix apps/mobile run build` 导出两个平台的 Hermes bundle,不生成 APK/IPA。运行和当前功能边界见 [App 说明](apps/mobile/README.md)。
|
||||||
|
|
||||||
### 账户 API 示例
|
### 账户 API 示例
|
||||||
|
|
||||||
@@ -53,28 +92,34 @@ make api
|
|||||||
|
|
||||||
真实记录账户请求省略 `initial_cash`,返回 `cash_balance: null`、`ledger_status: INCOMPLETE`。同一用户每种模式只可创建一个账户;重复创建返回 409,不重复生成初始资金。`GET /api/v1/accounts` 返回当前用户账户,`GET /api/v1/accounts/{id}/summary` 查询归属当前用户的账户,其他用户的 ID 返回 404。
|
真实记录账户请求省略 `initial_cash`,返回 `cash_balance: null`、`ledger_status: INCOMPLETE`。同一用户每种模式只可创建一个账户;重复创建返回 409,不重复生成初始资金。`GET /api/v1/accounts` 返回当前用户账户,`GET /api/v1/accounts/{id}/summary` 查询归属当前用户的账户,其他用户的 ID 返回 404。
|
||||||
|
|
||||||
|
### 后台供应商配置
|
||||||
|
|
||||||
|
「模型配置」填写主模型、备用模型和审核模型;「系统设置」填写行情源。供应商名称、HTTPS 地址、模型名称和密钥初始均为空,不预选供应商。未配置时保持关闭;模型启用需要名称、地址、模型名称和密钥,行情源启用需要名称和地址。保存或启用不代表连接已验证,当前尚未实现供应商调用与连通性测试。协议、模型目录、响应存储策略和认证开关均为后台可修改的配置,不预填厂家地址或模型。未填写模型时可先保存关闭状态的连接地址和密钥。模型目录需与接口同源,避免把认证信息指向另一个站点。
|
||||||
|
|
||||||
|
`SECRET_ENCRYPTION_KEY` 是部署层加密主密钥,使用 Base64 编码的 32 字节随机值,在本地 `configs/.env` 或部署秘密配置中持久保存。本机已生成此值,仓库模板留空。可用 `openssl rand -base64 32` 为新环境生成;主密钥必须与数据库一起备份,切勿在已有密文后直接替换。未配置主密钥时仍可保存无密钥的关闭配置,提交密钥会被拒绝。
|
||||||
|
|
||||||
|
密钥采用 AES-256-GCM 加密,绑定配置角色;查询只返回 `has_api_key`。密钥留空保留原值,勾选清除后删除当前密钥;修改接口地址时必须重新填写或清除密钥。历史保留加密快照用于追溯,审计日志不含明文密钥。保存使用 `expected_version` 防止覆盖其他管理员的修改;所有接口限制管理员,同源 Origin 保护写操作。
|
||||||
|
|
||||||
### 开发登录边界
|
### 开发登录边界
|
||||||
|
|
||||||
密码登录和命令行身份初始化仅在 `APP_ENV=local/test` 开启;其他环境拒绝开发登录。正式 App 短信登录、访问凭据刷新、管理端 MFA 与细分 RBAC 尚未实现。当前会话为 8 小时、服务端可撤销;Cookie 写操作要求同来源 Origin。开发模式可使用 Vite 的同源代理,部署代理的 HTTPS 信任配置将在正式认证阶段实现。
|
密码登录和命令行身份初始化仅在 `APP_ENV=local/test` 开启;其他环境拒绝开发登录。正式 App 短信登录、访问凭据刷新、管理端 MFA 与细分 RBAC 尚未实现。当前会话为 8 小时、服务端可撤销;Cookie 写操作要求同来源 Origin。开发模式可使用 Vite 的同源代理,部署代理的 HTTPS 信任配置将在正式认证阶段实现。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make test
|
go test -race ./...
|
||||||
make check
|
go vet ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
PostgreSQL 集成测试需显式提供测试数据库,测试在独立临时 schema 内运行并清理:
|
PostgreSQL 集成测试需显式提供测试数据库,测试在独立临时 schema 内运行并清理:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
VEYRA_TEST_DATABASE_URL='postgres://user:password@host:5432/test_db?sslmode=disable' make test
|
VEYRA_TEST_DATABASE_URL='postgres://user:password@host:5432/test_db?sslmode=disable' go test -race ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
```text
|
```text
|
||||||
cmd/
|
main.go 唯一入口,启动服务或执行明确的初始化命令
|
||||||
api/ API 入口与依赖装配
|
configs/ 后端环境配置与模板(私有配置不提交)
|
||||||
db/ 建库与迁移命令
|
|
||||||
user/ 开发身份初始化命令
|
|
||||||
internal/
|
internal/
|
||||||
config/ 配置加载与校验
|
config/ 配置加载与校验
|
||||||
database/migrations/ 数据库连接与版本化迁移
|
database/migrations/ 数据库连接与版本化迁移
|
||||||
@@ -85,20 +130,30 @@ internal/
|
|||||||
adminui/ 管理端 embed 资源与静态服务
|
adminui/ 管理端 embed 资源与静态服务
|
||||||
apperror/ 跨层业务错误
|
apperror/ 跨层业务错误
|
||||||
ratelimit/ Redis 登录限流
|
ratelimit/ Redis 登录限流
|
||||||
|
secret/ 供应商凭据加密
|
||||||
integration/ 数据库集成测试
|
integration/ 数据库集成测试
|
||||||
web/admin/ Vite React 管理端
|
web/admin/ Vite React 管理端
|
||||||
|
apps/mobile/ React Native Expo App
|
||||||
api/openapi.yaml HTTP 契约
|
api/openapi.yaml HTTP 契约
|
||||||
go.mod / go.sum 根目录唯一 Go module
|
go.mod / go.sum 根目录唯一 Go module
|
||||||
```
|
```
|
||||||
|
|
||||||
仓库采用根目录 Go 单模块结构,管理端放在 `web/admin`,无需 `go.work`。文件按职责划分,调用方向为 handler → service → repository;接口在 service 中定义,由 repository 实现,避免 service 依赖具体数据库。
|
仓库采用根目录 Go 单模块结构,管理端放在 `web/admin`,无需 `go.work`。两个前端各自维护 package.json、package-lock.json 和 node_modules,根目录不设置 npm workspace。文件按职责划分,调用方向为 handler → service → repository;接口在 service 中定义,由 repository 实现,避免 service 依赖具体数据库。
|
||||||
|
|
||||||
采用显式 SQL 和结构体 db tag,避免 ORM 隐式推断表名。迁移打包到 Go 二进制,已执行迁移不可改写,后续修改新增版本。API 启动不自动建库或迁移。健康检查不会返回连接串和供应商错误细节。
|
采用显式 SQL 和结构体 db tag,避免 ORM 隐式推断表名。迁移打包到 Go 二进制,已执行迁移不可改写,后续修改新增版本。API 启动不自动建库或迁移。健康检查不会返回连接串和供应商错误细节。
|
||||||
|
|
||||||
## 待完成
|
## 待完成
|
||||||
|
|
||||||
用户已确认执行首次建库迁移。此次新增第二版迁移和身份初始化需在可访问数据库的终端运行;当前执行环境禁止访问目标内网地址和监听本地端口。单元测试及无监听的 HTTP 测试可以本地执行,真实数据库集成测试只有配置测试连接时才会运行。
|
2026-10-08 已验证 PostgreSQL、Redis 内网连接,应用全部四版迁移并初始化开发管理员。真实 PostgreSQL 集成测试已在临时 schema 中执行;测试不会修改开发账户或供应商配置。
|
||||||
|
|
||||||
尚未接入候选审核、交易计划、成交账本、App UI、行情、AI、Worker 或回测。下一步建设候选修订、审核发布与计划闭环,再落实成交账本和风险监控。
|
尚未接入候选审核、交易计划、成交账本、行情、AI、Worker 或回测。App 已有基础界面和登录/账户真实 API,偏好、机会、计划、持仓明细、提醒与复盘仍待相应业务接口。下一步建设候选修订、审核发布与计划闭环,再落实成交账本和风险监控。
|
||||||
|
|
||||||
管理端基础组件按 [shadcn/ui 的 Vite 集成方式](https://ui.shadcn.com/docs/installation/vite)维护在 `src/components/ui`,Tailwind 使用[官方 Vite 插件](https://tailwindcss.com/docs/installation/using-vite)。
|
管理端基础组件按 [shadcn/ui 的 Vite 集成方式](https://ui.shadcn.com/docs/installation/vite)维护在 `src/components/ui`,Tailwind 使用[官方 Vite 插件](https://tailwindcss.com/docs/installation/using-vite)。
|
||||||
|
|
||||||
|
## 日志与 A 股开发数据
|
||||||
|
|
||||||
|
后端使用 zap,默认在运行目录创建 `logs/veyra.log`,控制台易读、文件 JSON,自动轮转压缩。通过 `configs/.env` 的 `LOG_*` 配置目录、级别和保留规则。
|
||||||
|
|
||||||
|
后台「系统设置」配置公开行情适配器与地址,启用后启动同步、每五分钟更新沪深报价;「数据健康」查询股票和同步结果。研究回测可直接按代码、交易所和日期获取不复权日线,无需逐只新建股票或导入 CSV。当前公开源为开发研究用途;供应商时间、历史快照与失败状态如实展示。策略筛选、正式行情授权和实际交易规则仍待接入。
|
||||||
|
|
||||||
|
App 在「机会」→「查看自动同步的 A 股行情」查看数据。当前预览 Metro `exp://192.168.100.2:8081`,API 为 `http://192.168.100.2:8083`,同一内网使用 Expo Go;开发身份读取后端本地 `DEV_USER_*` 配置。尚未完成真机界面验证。
|
||||||
|
|||||||
+2127
-126
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
# 仅公开 API 地址,禁止在 EXPO_PUBLIC_* 中存放数据库/Redis/供应商凭据。
|
||||||
|
# iOS 模拟器可用 http://127.0.0.1:8080,Android 模拟器使用 http://10.0.2.2:8080。
|
||||||
|
# 真机填写开发电脑的内网 IP,后端 HTTP_ADDR 需允许局域网访问。
|
||||||
|
EXPO_PUBLIC_API_URL=
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 见势 · Veyra App
|
||||||
|
|
||||||
|
React Native + Expo + TypeScript,白底、石墨黑与柔紫。源码路由采用 Expo Router,服务端查询使用 TanStack Query,当前账户使用 Zustand。
|
||||||
|
|
||||||
|
当前已接通开发登录、原生安全会话恢复/过期/退出、账户查询、创建和切换。五个主导航与提醒入口已建立;候选、计划、成交、持仓明细、通知、偏好与复盘业务尚未开放,页面明确展示实际状态,不展示原型股票或预设收益。
|
||||||
|
|
||||||
|
## 开发运行
|
||||||
|
|
||||||
|
在仓库根目录执行 `npm --prefix apps/mobile ci`,使用 Node 22.18 或更高版本(测试使用 Node 的 TypeScript 支持)。Go 后端初始化完成后运行 `go run . -command user-create -role USER` 创建 App 的 USER 测试身份,再运行 `go run .`。管理员身份无法登录 App,登录信息从本地环境配置获取,不打包到客户端。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp apps/mobile/.env.example apps/mobile/.env
|
||||||
|
npm --prefix apps/mobile run start
|
||||||
|
```
|
||||||
|
|
||||||
|
填写 `apps/mobile/.env` 中 `EXPO_PUBLIC_API_URL`:
|
||||||
|
|
||||||
|
- iOS 模拟器:`http://127.0.0.1:8080`。
|
||||||
|
- Android 默认模拟器:`http://10.0.2.2:8080`。
|
||||||
|
- 真机:开发电脑的内网 IP,例如 `http://<开发电脑内网IP>:8080`,后端 `HTTP_ADDR` 需绑定可访问的监听地址;与数据库地址无关。
|
||||||
|
|
||||||
|
Expo 的 `i` / `a` 可打开已经安装的 iOS / Android 模拟器,也可用兼容 SDK 的 Expo Go 扫码运行。地址变化后重启 Metro。接口地址留空时会明确提示未配置。正式环境应配置 HTTPS;当前开发密码接口只在 local/test 后端开放。
|
||||||
|
|
||||||
|
`EXPO_PUBLIC_*` 会进入客户端包,只允许公开 API 地址,禁止放入数据库、Redis、模型或行情源密钥。供应商由管理员在后台配置,App 不直接调用供应商。
|
||||||
|
|
||||||
|
## 验证与产物
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm --prefix apps/mobile run typecheck && npm --prefix apps/mobile run test
|
||||||
|
npm --prefix apps/mobile run build
|
||||||
|
```
|
||||||
|
|
||||||
|
类型检查与测试命令 检查 TypeScript 与 API/金额边界测试;构建命令 导出 iOS 和 Android Metro/Hermes bundle 到本目录 `dist`,验证路由和原生依赖可打包。导出包不等于 APK、IPA,也不代表已完成真机或商店验证;后续原生包需通过开发构建或 EAS/本地编译生成。
|
||||||
|
|
||||||
|
依赖按 [Expo SDK 配套版本](https://docs.expo.dev/versions/v55.0.0/)选取,原生模块通过 `expo install` 安装。当前锁定 SDK 55 / React Native 0.83.10 / React 19.2.4。管理端与 App 统一使用 React 19.2.4,分别独立安装依赖。SDK 55 默认建议 React 19.2.0;这里显式固定同 minor 的补丁版本并按 [Expo 版本比较配置](https://docs.expo.dev/versions/latest/config/package-json/#installexclude)配置 expo.install.exclude,仅排除 React 的建议版本比较。React Native 0.83.10 的公开 peer 约束是 ^19.2.0;继续执行类型、客户端、双平台 bundle 与依赖重复检查,真机验证仍待完成。App 的 package.json、锁文件和依赖全部位于本目录,Metro 与 Babel 使用本目录的 Expo Router。
|
||||||
|
|
||||||
|
会话使用 [Expo SecureStore](https://docs.expo.dev/versions/v55.0.0/sdk/securestore/),不会写进 Zustand、URL 或日志。退出撤销服务端会话;网络中断保留原始会话供恢复,401 或到期清理身份与查询缓存。旧会话的迟到响应不会污染新登录用户。切换账户会中断旧查询、清理账户详情缓存;金额使用十进制字符串与整数校验,不做浮点账本计算。
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"expo":{
|
||||||
|
"name":"见势 · Veyra","slug":"veyra","scheme":"veyra","version":"0.1.0","orientation":"portrait","userInterfaceStyle":"light",
|
||||||
|
"platforms":["ios","android"],
|
||||||
|
"ios":{"supportsTablet":true,"bundleIdentifier":"cn.sundynix.veyra"},
|
||||||
|
"android":{"package":"cn.sundynix.veyra"},
|
||||||
|
"plugins":["expo-router","expo-secure-store"],
|
||||||
|
"experiments":{"typedRoutes":true}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import {Tabs} from "expo-router"
|
||||||
|
import Ionicons from "@expo/vector-icons/Ionicons"
|
||||||
|
import {colors} from "../../src/components/ui"
|
||||||
|
export default function TabLayout(){
|
||||||
|
return <Tabs screenOptions={{headerShown:false,tabBarActiveTintColor:colors.accent,tabBarInactiveTintColor:colors.muted,tabBarStyle:{backgroundColor:colors.background,borderTopColor:colors.border},tabBarLabelStyle:{fontSize:11},tabBarItemStyle:{paddingTop:5}}}>
|
||||||
|
<Tabs.Screen name="index" options={{title:"首页",tabBarIcon:({color,size})=><Ionicons name="home-outline" color={color} size={size}/>}}/>
|
||||||
|
<Tabs.Screen name="opportunities" options={{title:"机会",tabBarIcon:({color,size})=><Ionicons name="compass-outline" color={color} size={size}/>}}/>
|
||||||
|
<Tabs.Screen name="plans" options={{title:"计划",tabBarIcon:({color,size})=><Ionicons name="list-outline" color={color} size={size}/>}}/>
|
||||||
|
<Tabs.Screen name="portfolio" options={{title:"持仓",tabBarIcon:({color,size})=><Ionicons name="pie-chart-outline" color={color} size={size}/>}}/>
|
||||||
|
<Tabs.Screen name="profile" options={{title:"我的",tabBarIcon:({color,size})=><Ionicons name="person-outline" color={color} size={size}/>}}/>
|
||||||
|
</Tabs>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {router} from "expo-router"
|
||||||
|
import {Text,View} from "react-native"
|
||||||
|
import {AccountSummary} from "../../src/components/AccountSummary"
|
||||||
|
import {Body,Button,Panel,Screen,Title,styles} from "../../src/components/ui"
|
||||||
|
import {useSession} from "../../src/state/session"
|
||||||
|
export default function Home(){const {session}=useSession();return <Screen><View style={styles.row}><Title>见势 <Text style={{fontSize:18,fontWeight:"400"}}>· Veyra</Text></Title><Button title="提醒" secondary onPress={()=>router.push("/notifications")}/></View><Body>{session?.user.display_name},让判断建立在依据之上。</Body><Panel accent><Text style={styles.badge}>AI 观察</Text><Text style={styles.subtitle}>先理解,再行动。</Text><Body>研究与行情服务尚未接入,当前没有可供展示的在线分析。供应商由管理员在后台配置。</Body><Button title="发现机会" onPress={()=>router.push("/(tabs)/opportunities")}/></Panel><AccountSummary/><View style={styles.divider}/><Text style={styles.subtitle}>每一步,都可追溯</Text><Body>观察候选 → 查看证据 → 制订计划 → 条件提醒 → 记录成交 → 复盘。当前已开放模拟候选、计划预算、站内提醒与成交账本;自动行情和 AI 分析仍待接入。</Body></Screen>}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {Text,View} from "react-native"
|
||||||
|
import {useQuery} from "@tanstack/react-query"
|
||||||
|
import {useRouter} from "expo-router"
|
||||||
|
import {useSession} from "../../src/state/session"
|
||||||
|
import {Body,Button,EmptyState,ErrorMessage,Panel,Screen,Title,styles} from "../../src/components/ui"
|
||||||
|
import type {Candidate} from "../../src/lib/candidates"
|
||||||
|
export default function Opportunities(){
|
||||||
|
const {session,client}=useSession(),router=useRouter()
|
||||||
|
const query=useQuery({queryKey:["candidates",session?.user.id],enabled:!!session,queryFn:({signal})=>client<Candidate[]>("/api/v1/candidates",{signal}),refetchInterval:30000})
|
||||||
|
return <Screen><Title>机会</Title><Body>从证据、反证与失效条件理解一个候选。</Body><Panel accent><Body>模拟研究候选 · 由管理端手工录入并审核发布,当前未接入自动行情筛选。</Body></Panel><Button secondary title="查看自动同步的 A 股行情" onPress={()=>router.push("/market")}/><Button secondary title={query.isFetching?"刷新中…":"刷新候选"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取已发布候选…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>} {query.data?.length===0&&<EmptyState title="当前没有有效候选" detail="审核发布且未过期的模拟候选会显示在这里。"/>}{query.data?.map(c=><Panel key={c.id}><View style={styles.row}><Text style={styles.subtitle}>{c.content.name}</Text><Text style={styles.badge}>模拟候选</Text></View><Body>{c.content.exchange}:{c.content.symbol} · 修订 {c.revision}</Body><Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.thesis}</Body><Body>有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Button title="查看证据与风险" onPress={()=>router.push({pathname:"/candidates/[id]",params:{id:c.id}})}/></Panel>)}</Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {useState} from "react"
|
||||||
|
import {Text} from "react-native"
|
||||||
|
import {useQuery,useQueryClient} from "@tanstack/react-query"
|
||||||
|
import {useRouter} from "expo-router"
|
||||||
|
import {useSession} from "../../src/state/session"
|
||||||
|
import {useAccounts} from "../../src/state/accounts"
|
||||||
|
import {Body,Button,EmptyState,ErrorMessage,Panel,Screen,Title,styles} from "../../src/components/ui"
|
||||||
|
import {planStates,type Plan} from "../../src/lib/plans"
|
||||||
|
export default function Plans(){
|
||||||
|
const {session,client}=useSession(),{selected}=useAccounts(),router=useRouter(),cache=useQueryClient()
|
||||||
|
const [confirming,setConfirming]=useState<Plan|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState("")
|
||||||
|
const query=useQuery({queryKey:["plans",session?.user.id,selected?.id],enabled:!!session,queryFn:({signal})=>client<Plan[]>("/api/v1/plans",{signal}),refetchInterval:15000})
|
||||||
|
const plans=query.data?.filter(p=>p.account_id===selected?.id)
|
||||||
|
async function cancel(){if(!confirming)return;setBusy(true);setError("");try{await client(`/api/v1/plans/${confirming.id}/cancel`,{method:"POST",body:{expected_version:confirming.version}});setConfirming(null);await query.refetch();await cache.invalidateQueries({queryKey:["budget"]})}catch(e){setError(e instanceof Error?e.message:"取消失败")}finally{setBusy(false)}}
|
||||||
|
return <Screen><Title>计划</Title><Body>{selected?`当前账户:${selected.name}`:"先选择账户,再从候选创建计划。"}计划保留预算,成交需另行记录。</Body><Button secondary title={query.isFetching?"刷新中…":"刷新计划"} disabled={query.isFetching||busy} onPress={()=>void query.refetch()}/>{query.error&&<ErrorMessage message={query.error.message}/>}<ErrorMessage message={error}/>{plans?.length===0&&<><EmptyState title="暂无交易计划" detail="从已发布候选查看依据,再预览数量与资金预算。"/><Button title="查看候选" onPress={()=>router.push("/(tabs)/opportunities")}/></>}{plans?.map(p=><Panel key={p.id}><Text style={styles.badge}>{planStates[p.status]??p.status} · 模拟计划</Text><Text style={styles.subtitle}>{p.candidate_content.name} · {p.candidate_content.symbol}</Text><Body>数量 {p.quantity} 股 · 已记录 {p.filled_quantity} 股</Body><Body>保留预算 ¥{p.reserved_amount} · 有效截止 {new Date(p.valid_until).toLocaleString("zh-CN")}</Body>{p.status!=="COMPLETED"&&p.status!=="CANCELLED"&&<Button title="记录此计划的实际模拟买入" onPress={()=>router.push({pathname:"/fills/create",params:{plan_id:p.id,symbol:p.candidate_content.symbol,exchange:p.candidate_content.exchange,name:p.candidate_content.name}})}/>}<Button secondary title="查看原始候选依据" onPress={()=>router.push({pathname:"/candidates/[id]",params:{id:p.candidate_revision_id}})}/>{["ACTIVE","PART_FILLED"].includes(p.status)&&<Button secondary title="取消计划" disabled={busy} onPress={()=>{setConfirming(p);setError("")}}/>}{confirming?.id===p.id&&<><Body>确认取消 {p.candidate_content.name} 的计划?保留额度将释放;已记录成交保留。</Body><Button title="确认取消" busy={busy} onPress={()=>void cancel()}/><Button secondary title="保留计划" disabled={busy} onPress={()=>setConfirming(null)}/></>}</Panel>)}</Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {useState} from "react"
|
||||||
|
import {Text} from "react-native"
|
||||||
|
import {useQuery,useQueryClient} from "@tanstack/react-query"
|
||||||
|
import {useRouter} from "expo-router"
|
||||||
|
import {useSession} from "../../src/state/session"
|
||||||
|
import {useAccounts} from "../../src/state/accounts"
|
||||||
|
import {Body,Button,EmptyState,ErrorMessage,Field,Panel,Screen,Title,styles} from "../../src/components/ui"
|
||||||
|
import type {Fill,Portfolio} from "../../src/lib/fills"
|
||||||
|
export default function PortfolioScreen(){
|
||||||
|
const {session,client}=useSession(),{selected:account}=useAccounts(),router=useRouter(),cache=useQueryClient(),enabled=!!session&&account?.mode==="simulation"
|
||||||
|
const [reversing,setReversing]=useState<Fill|null>(null),[reason,setReason]=useState(""),[busy,setBusy]=useState(false),[error,setError]=useState("")
|
||||||
|
const portfolio=useQuery({queryKey:["portfolio",session?.user.id,account?.id],enabled,queryFn:({signal})=>client<Portfolio>(`/api/v1/accounts/${account!.id}/portfolio`,{signal})})
|
||||||
|
const fills=useQuery({queryKey:["fills",session?.user.id,account?.id],enabled,queryFn:({signal})=>client<Fill[]>(`/api/v1/accounts/${account!.id}/fills`,{signal})})
|
||||||
|
const latest=fills.data?.find(f=>!f.reversed)
|
||||||
|
async function reverse(){if(!reversing)return;setBusy(true);setError("");try{await client(`/api/v1/fills/${reversing.id}/reverse`,{method:"POST",body:{reason}});setReversing(null);await Promise.all(["portfolio","fills","accounts","budget","plans"].map(key=>cache.invalidateQueries({queryKey:[key]})))}catch(e){setError(e instanceof Error?e.message:"冲正失败")}finally{setBusy(false)}}
|
||||||
|
return <Screen><Title>持仓与账本</Title><Body>当前账户:{account?.name??"未选择"}。现金与已实现盈亏来自记录的成交;行情未接入,市值和浮盈亏未知。</Body>{!account&&<Button title="选择账户" onPress={()=>router.push("/settings/accounts")}/>} {account?.mode==="manual_real"&&<EmptyState title="真实记录账本尚未接通" detail="当前成交与持仓闭环仅用于模拟账户,不展示预设资产或盈亏。"/>}{enabled&&<><Button title="记录模拟成交" onPress={()=>router.push("/fills/create")}/><Button secondary title="刷新账本" disabled={portfolio.isFetching||fills.isFetching||busy} onPress={()=>{void portfolio.refetch();void fills.refetch()}}/>{portfolio.error&&<ErrorMessage message={portfolio.error.message}/>} {portfolio.data&&<><Panel accent><Body>账本现金</Body><Text style={styles.money}>¥{portfolio.data.cash_balance}</Text><Body>累计已实现盈亏(含录入费用) ¥{portfolio.data.realized_pnl}</Body></Panel><Panel><Text style={styles.subtitle}>账本复盘 · 累计</Text><Body>未冲正成交 {portfolio.data.review.trade_count} 笔 · 卖出 {portfolio.data.review.sell_count} 笔</Body><Body>买入现金支出 ¥{portfolio.data.review.buy_outflow} · 卖出现金收入 ¥{portfolio.data.review.sell_inflow}</Body><Body>录入费用合计 ¥{portfolio.data.review.total_fees}</Body><Body>盈利卖出 {portfolio.data.review.profitable_sell_count} 笔 · 亏损卖出 {portfolio.data.review.losing_sell_count} 笔</Body><Body>按卖出记录统计,包含已录入费用;不等于独立策略胜率,也不包含未实现盈亏或尚未录入的费用。</Body></Panel>{portfolio.data.positions.length===0&&<EmptyState title="当前没有记录的持仓" detail="记录买入后建立持仓批次,卖出与冲正同步更新。"/>}{portfolio.data.positions.map(p=><Panel key={p.exchange+p.symbol}><Text style={styles.subtitle}>{p.name} · {p.symbol}</Text><Body>持仓 {p.quantity} 股 · 按模拟可卖时间可卖 {p.sellable_quantity} 股</Body><Body>剩余成本 ¥{p.cost_basis} · 市值与浮盈亏未知</Body><Button secondary title="记录卖出成交" disabled={p.sellable_quantity==="0"} onPress={()=>router.push({pathname:"/fills/create",params:{side:"SELL",symbol:p.symbol,exchange:p.exchange,name:p.name}})}/></Panel>)}</>}<Text style={styles.subtitle}>成交记录</Text><Body>最近 100 笔。仅允许冲正账户最新一笔未冲正成交,避免破坏后续成本分摊;冲正不自动恢复原计划。</Body>{fills.error&&<ErrorMessage message={fills.error.message}/>}<ErrorMessage message={error}/>{fills.data?.map(f=><Panel key={f.id}><Text style={styles.badge}>{f.reversed?"已冲正":f.side==="BUY"?"买入":"卖出"}</Text><Text style={styles.subtitle}>{f.name} · {f.symbol}</Text><Body>{f.quantity} 股 · ¥{f.price} / 股 · 费用 ¥{f.fee}</Body><Body>现金变动 ¥{f.cash_delta}{f.side==="SELL"?` · 已实现盈亏 ¥${f.realized_pnl}`:""}</Body><Body>{new Date(f.occurred_at).toLocaleString("zh-CN")}{f.note?` · ${f.note}`:""}</Body>{latest?.id===f.id&&!f.reversed&&<Button secondary title="纠错:冲正此记录" disabled={busy} onPress={()=>{setReversing(f);setReason("");setError("")}}/>}{reversing?.id===f.id&&<><Body>确认冲正此笔 {f.name} {f.quantity} 股成交?现金和持仓将反向恢复,原成交记录保留。</Body><Field label="冲正原因" value={reason} editable={!busy} onChangeText={setReason} multiline/><Button title="确认冲正" disabled={!reason.trim()} busy={busy} onPress={()=>void reverse()}/><Button secondary title="取消" disabled={busy} onPress={()=>setReversing(null)}/></>}</Panel>)}</>}</Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {useState} from "react"
|
||||||
|
import {router} from "expo-router"
|
||||||
|
import {Text,View} from "react-native"
|
||||||
|
import {useSession} from "../../src/state/session"
|
||||||
|
import {Body,Button,ErrorMessage,Panel,Screen,Title,styles} from "../../src/components/ui"
|
||||||
|
export default function Profile(){const {session,logout}=useSession();const [busy,setBusy]=useState(false);const [error,setError]=useState("");async function signOut(){setBusy(true);setError("");try{await logout()}catch(e){setError(e instanceof Error?e.message:"退出失败,请重试。")}finally{setBusy(false)}}return <Screen><Title>我的</Title><Panel><View style={styles.row}><Text style={styles.subtitle}>{session?.user.display_name}</Text><Text style={styles.badge}>开发测试身份</Text></View><Body>会话有效期至 {session?new Date(session.expires_at).toLocaleString("zh-CN"):"—"}</Body></Panel><Button title="账户管理与切换" secondary onPress={()=>router.push("/settings/accounts")}/><Button title="提醒中心" secondary onPress={()=>router.push("/notifications")}/><View style={styles.divider}/><Text style={styles.subtitle}>关于见势</Text><Body>见势 · Veyra 帮助理解依据与记录决策。投资偏好、通知设置和复盘功能将随对应服务接入开放。</Body><ErrorMessage message={error}/><Button title="退出登录" busy={busy} onPress={()=>void signOut()}/></Screen>}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {Stack} from "expo-router"
|
||||||
|
import {StatusBar} from "expo-status-bar"
|
||||||
|
import {SafeAreaProvider} from "react-native-safe-area-context"
|
||||||
|
import {SessionProvider,useSession} from "../src/state/session"
|
||||||
|
import {Body,Button,ErrorMessage,Screen,Title,colors} from "../src/components/ui"
|
||||||
|
function Routes(){
|
||||||
|
const {session,loading,restoreError,retry}=useSession()
|
||||||
|
if(loading)return <Screen><Title>见势 · Veyra</Title><Body>正在恢复会话…</Body></Screen>
|
||||||
|
if(restoreError)return <Screen><Title>连接暂时中断</Title><ErrorMessage message={restoreError}/><Button title="重新连接" onPress={retry}/></Screen>
|
||||||
|
return <Stack screenOptions={{headerStyle:{backgroundColor:colors.background},headerTintColor:colors.text,headerShadowVisible:false,contentStyle:{backgroundColor:colors.background}}}>
|
||||||
|
<Stack.Protected guard={!session}><Stack.Screen name="login" options={{headerShown:false}}/></Stack.Protected>
|
||||||
|
<Stack.Protected guard={!!session}><Stack.Screen name="(tabs)" options={{headerShown:false}}/><Stack.Screen name="settings/accounts" options={{title:"账户管理"}}/><Stack.Screen name="fills/create" options={{title:"成交录入"}}/><Stack.Screen name="plans/create" options={{title:"计划预算"}}/><Stack.Screen name="candidates/[id]" options={{title:"候选证据"}}/><Stack.Screen name="notifications" options={{title:"提醒中心"}}/></Stack.Protected>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
export default function RootLayout(){return <SafeAreaProvider><SessionProvider><StatusBar style="dark"/><Routes/></SessionProvider></SafeAreaProvider>}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {Linking,Text} from "react-native"
|
||||||
|
import {useLocalSearchParams,useRouter} from "expo-router"
|
||||||
|
import {useQuery} from "@tanstack/react-query"
|
||||||
|
import {useState} from "react"
|
||||||
|
import {useSession} from "../../src/state/session"
|
||||||
|
import {Body,Button,ErrorMessage,Panel,Screen,Title,styles} from "../../src/components/ui"
|
||||||
|
import {publicationAvailable,type Candidate} from "../../src/lib/candidates"
|
||||||
|
export default function CandidateDetail(){
|
||||||
|
const {id}=useLocalSearchParams<{id:string}>(),{session,client}=useSession(),[linkError,setLinkError]=useState("")
|
||||||
|
const query=useQuery({queryKey:["candidate",session?.user.id,id],enabled:!!session&&typeof id==="string",queryFn:({signal})=>client<Candidate>(`/api/v1/candidates/${encodeURIComponent(id)}`,{signal}),refetchInterval:15000})
|
||||||
|
const c=query.data,router=useRouter()
|
||||||
|
async function open(url:string){setLinkError("");try{const parsed=new URL(url);if(parsed.protocol!=="https:"||parsed.username||parsed.password)throw new Error("证据链接无效。");await Linking.openURL(url)}catch{setLinkError("无法打开证据来源,请稍后重试。")}}
|
||||||
|
return <Screen><Title>{c?.content.name??"候选详情"}</Title><Button secondary title={query.isFetching?"刷新中…":"刷新状态"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取修订…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>} {c&&<><Text style={styles.badge}>模拟候选 · 修订 {c.revision}</Text>{!publicationAvailable(c)&&<Panel accent><Text style={styles.subtitle}>此候选已失效</Text><Body>{c.status==="WITHDRAWN"?c.withdrawal_reason||"管理员已撤回此修订。":"此修订已过期或已被新发布版本替代。"}历史依据保留供查看。</Body></Panel>}<Button title="预览模拟买入计划" disabled={!publicationAvailable(c)||query.isFetching||!!query.error} onPress={()=>router.push({pathname:"/plans/create",params:{candidate_id:c.id}})}/><Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.exchange}:{c.content.symbol} · 观察 {new Date(c.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Panel><Text style={styles.subtitle}>观察逻辑</Text><Body>{c.content.thesis}</Body></Panel><Panel><Text style={styles.subtitle}>反证与风险</Text><Body>{c.content.counter_evidence}</Body><Text style={styles.subtitle}>失效条件</Text><Body>{c.content.invalidation}</Body></Panel><Text style={styles.subtitle}>证据来源</Text>{c.content.evidence.map((e,i)=><Panel key={i}><Text style={styles.subtitle}>{e.locator}</Text><Body>{e.summary}</Body><Button secondary title="打开原始来源" onPress={()=>void open(e.source_url)}/></Panel>)}<ErrorMessage message={linkError}/><Panel><Text style={styles.subtitle}>参考价格</Text><Body>区间 ¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</Body><Body>风险价格 ¥{c.content.reference_plan.stop_loss} · 目标观察 ¥{c.content.reference_plan.target_price}</Body><Body>参考价格来自手工录入,当前不代表实时行情或已确认交易计划。</Body></Panel></>}</Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {useRef,useState} from "react"
|
||||||
|
import {Text,View} from "react-native"
|
||||||
|
import {useLocalSearchParams,useRouter} from "expo-router"
|
||||||
|
import {useQueryClient} from "@tanstack/react-query"
|
||||||
|
import * as Crypto from "expo-crypto"
|
||||||
|
import {useSession} from "../../src/state/session"
|
||||||
|
import {useAccounts} from "../../src/state/accounts"
|
||||||
|
import {Body,Button,ErrorMessage,Field,Panel,Screen,Title,styles} from "../../src/components/ui"
|
||||||
|
import type {Fill} from "../../src/lib/fills"
|
||||||
|
export default function RecordFill(){
|
||||||
|
const params=useLocalSearchParams<{plan_id?:string;symbol?:string;exchange?:string;name?:string;side?:string}>(),router=useRouter(),cache=useQueryClient(),{client}=useSession(),{selected:account}=useAccounts()
|
||||||
|
const [side,setSide]=useState(params.side==="SELL"?"SELL":"BUY"),[symbol,setSymbol]=useState(params.symbol??""),[exchange,setExchange]=useState(params.exchange??"SSE"),[name,setName]=useState(params.name??"")
|
||||||
|
const [quantity,setQuantity]=useState(""),[price,setPrice]=useState(""),[fee,setFee]=useState(""),[occurred,setOccurred]=useState(()=>new Date().toISOString()),[sellable,setSellable]=useState(""),[note,setNote]=useState("")
|
||||||
|
const [review,setReview]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState("")
|
||||||
|
const pending=useRef<{account_id:string;idempotency_key:string;[key:string]:unknown}|null>(null)
|
||||||
|
async function save(){if(!account)return;setBusy(true);setError("");try{
|
||||||
|
if(!pending.current)pending.current={account_id:account.id,plan_id:side==="BUY"&¶ms.plan_id?params.plan_id:null,idempotency_key:Crypto.randomUUID(),side,symbol,exchange,name,quantity,price,fee,occurred_at:new Date(occurred).toISOString(),sellable_at:side==="BUY"?new Date(sellable).toISOString():null,note}
|
||||||
|
await client<Fill>("/api/v1/fills",{method:"POST",body:pending.current});await Promise.all(["portfolio","fills","accounts","budget","plans"].map(key=>cache.invalidateQueries({queryKey:[key]})));router.replace("/(tabs)/portfolio")
|
||||||
|
}catch(e){setError(e instanceof Error?e.message:"录入失败,请重试。")}finally{setBusy(false)}}
|
||||||
|
async function check(){if(!pending.current)return;setBusy(true);setError("");try{const fills=await client<Fill[]>(`/api/v1/accounts/${pending.current.account_id}/fills`);if(fills.some(f=>f.idempotency_key===pending.current!.idempotency_key)){await cache.invalidateQueries();router.replace("/(tabs)/portfolio")}else{pending.current=null;setReview(false)}}catch(e){setError(e instanceof Error?e.message:"暂时无法核对,请重试。")}finally{setBusy(false)}}
|
||||||
|
const disabled=busy||!!pending.current
|
||||||
|
return <Screen><Title>记录模拟成交</Title><Panel accent><Body>只记录已发生的模拟成交,不提交券商订单。当前使用手工设定的模拟可卖时间,不自动推断交易日或真实 T+1。</Body></Panel><Body>当前账户:{account?.name??"未选择"}。成交录入按发生时间顺序;纠错采用冲正,原记录保留。</Body>{account?.mode!=="simulation"&&<ErrorMessage message="请先选择模拟账户;真实记录账本尚未接通。"/>}<View style={styles.row}><Button secondary={side!=="BUY"} title="买入" disabled={disabled||!!params.plan_id} onPress={()=>{setSide("BUY");setReview(false)}}/><Button secondary={side!=="SELL"} title="卖出" disabled={disabled||!!params.plan_id} onPress={()=>{setSide("SELL");setReview(false)}}/></View><View style={styles.row}><Button secondary={exchange!=="SSE"} title="上交所" disabled={disabled||!!params.plan_id} onPress={()=>{setExchange("SSE");setReview(false)}}/><Button secondary={exchange!=="SZSE"} title="深交所" disabled={disabled||!!params.plan_id} onPress={()=>{setExchange("SZSE");setReview(false)}}/></View>{([["证券代码",symbol,setSymbol],["证券名称",name,setName],["成交数量",quantity,setQuantity],["成交价格(元)",price,setPrice],["实际费用(元,允许 0)",fee,setFee],["成交时间(ISO 格式,含时区)",occurred,setOccurred]] as const).map(([label,value,set])=><Field key={label} label={label} value={value} editable={!disabled} autoCapitalize="none" onChangeText={v=>{set(v);setReview(false)}}/>)}{side==="BUY"&&<Field label="模拟可卖时间(ISO 格式,含时区)" value={sellable} editable={!disabled} autoCapitalize="none" onChangeText={v=>{setSellable(v);setReview(false)}}/>}<Field label="备注 / 偏离计划说明" value={note} editable={!disabled} multiline onChangeText={v=>{setNote(v);setReview(false)}}/><ErrorMessage message={error}/>{!review&&<Button title="核对成交记录" disabled={account?.mode!=="simulation"||!quantity||!price||!fee||!symbol||!name||side==="BUY"&&!sellable} onPress={()=>{setReview(true);setError("")}}/>}{review&&<Panel><Text style={styles.subtitle}>确认入账:{side==="BUY"?"买入":"卖出"} {name}</Text><Body>{exchange}:{symbol} · {quantity} 股 · ¥{price} / 股 · 费用 ¥{fee}</Body><Body>记录后会同步现金与持仓。买入使用现金并建立批次;卖出按 FIFO 分摊成本。已失效计划的实际成交仍可录入,需填写说明。</Body><Button title={pending.current?"重试同一笔记录":"确认成交入账"} busy={busy} onPress={()=>void save()}/>{pending.current?<Button secondary title="刷新账本核对提交结果" disabled={busy} onPress={()=>void check()}/>:<Button secondary title="返回修改" disabled={busy} onPress={()=>setReview(false)}/>}</Panel>}</Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {useState} from "react"
|
||||||
|
import {Text,View} from "react-native"
|
||||||
|
import {Body,Button,ErrorMessage,Field,Panel,Screen,Title,styles} from "../src/components/ui"
|
||||||
|
import {useSession} from "../src/state/session"
|
||||||
|
export default function Login(){
|
||||||
|
const {login}=useSession();const [name,setName]=useState("");const [password,setPassword]=useState("");const [busy,setBusy]=useState(false);const [error,setError]=useState("")
|
||||||
|
async function submit(){if(busy)return;setBusy(true);setError("");try{await login(name,password);setPassword("")}catch(e){setError(e instanceof Error?e.message:"登录失败,请重试。")}finally{setBusy(false)}}
|
||||||
|
return <Screen><View style={{height:42}}/><Text style={styles.badge}>从依据出发</Text><Title>见势 · Veyra</Title><Body>看清机会,理解风险。{"\n"}让每一步投资决策都有迹可循。</Body><Panel accent><Text style={styles.subtitle}>开发环境登录</Text><Body>使用开发测试身份连接服务。正式手机号登录将在身份服务完成后开放。</Body></Panel><Field label="登录名" value={name} onChangeText={setName} autoCapitalize="none" autoCorrect={false} autoComplete="username" maxLength={64} editable={!busy}/><Field label="密码" value={password} onChangeText={setPassword} secureTextEntry autoComplete="current-password" maxLength={72} editable={!busy} returnKeyType="go" onSubmitEditing={()=>void submit()}/><ErrorMessage message={error}/><Button title="登录" busy={busy} disabled={!name.trim()||!password} onPress={()=>void submit()}/><Body>账户、计划与成交记录均由服务端保存。请勿使用数据库账号作为 App 登录身份。</Body></Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {useState} from "react"
|
||||||
|
import {Text,TextInput} from "react-native"
|
||||||
|
import {useQuery} from "@tanstack/react-query"
|
||||||
|
import {Screen,Title,Body,Panel,Button,ErrorMessage,EmptyState} from "../src/components/ui"
|
||||||
|
import {useSession} from "../src/state/session"
|
||||||
|
type Instrument={id:string;symbol:string;exchange:string;name:string;price:string;observed_at:string;received_at:string}
|
||||||
|
export default function Market(){
|
||||||
|
const {session,client}=useSession(),[search,setSearch]=useState(""),[query,setQuery]=useState("")
|
||||||
|
const stocks=useQuery({queryKey:["market",session?.user.id,query],enabled:!!session,queryFn:({signal})=>client<Instrument[]>(`/api/v1/instruments?q=${encodeURIComponent(query)}`,{signal}),refetchInterval:60000})
|
||||||
|
return <Screen><Title>A 股行情</Title><Body>公开行情数据 · 开发研究。报价每五分钟同步,以下时间为供应商行情时间。休市、停牌或源不可用时,快照不代表当前可交易价格。</Body><TextInput accessibilityLabel="搜索股票" placeholder="股票代码或名称" value={search} onChangeText={setSearch} style={{borderWidth:1,borderColor:"#e5e5ec",borderRadius:12,padding:14,backgroundColor:"#fff",color:"#1b1b20"}}/><Button title="搜索" onPress={()=>setQuery(search.trim())}/><Button secondary disabled={stocks.isFetching} title={stocks.isFetching?"读取中…":"刷新行情"} onPress={()=>void stocks.refetch()}/>{stocks.error&&<ErrorMessage message={stocks.error.message}/>} {stocks.data?.length===0&&<EmptyState title="暂无行情" detail="行情源启用后,后台会自动同步;也可以更换搜索条件。"/>}{stocks.data?.map(v=><Panel key={v.id}><Text style={{fontSize:18,fontWeight:"600",color:"#1b1b20"}}>{v.name} · {v.symbol}</Text><Title>{v.price==="0.00"?"无有效报价":v.price+" 元"}</Title><Body>{v.exchange} · 行情时间 {new Date(v.observed_at).toLocaleString("zh-CN")}</Body>{Date.now()-Date.parse(v.observed_at)>5*60000&&<Body>历史快照,当前价格尚未更新。</Body>}</Panel>)}</Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {Text} from "react-native"
|
||||||
|
import {useQuery,useQueryClient} from "@tanstack/react-query"
|
||||||
|
import {useState} from "react"
|
||||||
|
import {useRouter} from "expo-router"
|
||||||
|
import {useSession} from "../src/state/session"
|
||||||
|
import {useSwitchAccount} from "../src/state/accounts"
|
||||||
|
import {Body,Button,EmptyState,ErrorMessage,Panel,Screen,Title,styles} from "../src/components/ui"
|
||||||
|
import type {Plan} from "../src/lib/plans"
|
||||||
|
type Notification={id:string;title:string;body:string;plan_id:string|null;read_at:string|null;created_at:string}
|
||||||
|
export default function Notifications(){
|
||||||
|
const {session,client}=useSession(),router=useRouter(),switchAccount=useSwitchAccount(),cache=useQueryClient(),[error,setError]=useState(""),[busy,setBusy]=useState(false)
|
||||||
|
const query=useQuery({queryKey:["notifications",session?.user.id],enabled:!!session,queryFn:({signal})=>client<Notification[]>("/api/v1/notifications",{signal}),refetchInterval:10000})
|
||||||
|
async function read(n:Notification,open=false){setBusy(true);setError("");try{await client(`/api/v1/notifications/${n.id}/read`,{method:"POST",body:{}});await cache.invalidateQueries({queryKey:["notifications"]});if(open&&n.plan_id){const plans=await client<Plan[]>("/api/v1/plans");const plan=plans.find(p=>p.id===n.plan_id);if(plan){await switchAccount(plan.account_id);router.push("/(tabs)/plans")}else setError("原计划不在当前列表中,请到计划页查看历史状态。")}}catch(e){setError(e instanceof Error?e.message:"操作失败")}finally{setBusy(false)}}
|
||||||
|
return <Screen><Title>提醒中心</Title><Body>站内提醒来自计划条件或候选状态变化。提醒不代表交易;打开后请核对最新状态。远程推送尚未接入。</Body><Button secondary title={query.isFetching?"刷新中…":"刷新提醒"} disabled={query.isFetching||busy} onPress={()=>void query.refetch()}/>{query.error&&<ErrorMessage message={query.error.message}/>}<ErrorMessage message={error}/>{query.data?.length===0&&<EmptyState title="暂无站内提醒" detail="模拟价格进入已确认计划的参考区间,或对应候选被撤回时,会记录提醒。"/>}{query.data?.map(n=><Panel key={n.id} accent={!n.read_at}><Text style={styles.subtitle}>{n.title}</Text><Body>{n.body}</Body><Body>{new Date(n.created_at).toLocaleString("zh-CN")} · {n.read_at?"已读":"未读"}</Body>{n.plan_id&&<Button title="核对当前计划" disabled={busy} onPress={()=>void read(n,true)}/>} {!n.read_at&&<Button secondary title="标记已读" disabled={busy} onPress={()=>void read(n)}/>}</Panel>)}</Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {useState} from "react"
|
||||||
|
import {Text} from "react-native"
|
||||||
|
import {useLocalSearchParams,useRouter} from "expo-router"
|
||||||
|
import {useQuery,useQueryClient} from "@tanstack/react-query"
|
||||||
|
import {useSession} from "../../src/state/session"
|
||||||
|
import {useAccounts} from "../../src/state/accounts"
|
||||||
|
import {Body,Button,ErrorMessage,Field,Panel,Screen,Title,styles} from "../../src/components/ui"
|
||||||
|
import {publicationAvailable,type Candidate} from "../../src/lib/candidates"
|
||||||
|
import type {Budget,Preview} from "../../src/lib/plans"
|
||||||
|
export default function CreatePlan(){
|
||||||
|
const {candidate_id}=useLocalSearchParams<{candidate_id:string}>(),{session,client}=useSession(),{selected:account}=useAccounts(),router=useRouter(),cache=useQueryClient()
|
||||||
|
const [quantity,setQuantity]=useState(""),[fee,setFee]=useState(""),[preview,setPreview]=useState<Preview|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState("")
|
||||||
|
const candidate=useQuery({queryKey:["candidate",session?.user.id,candidate_id],enabled:!!session&&typeof candidate_id==="string",queryFn:({signal})=>client<Candidate>(`/api/v1/candidates/${encodeURIComponent(candidate_id)}`,{signal})})
|
||||||
|
const budget=useQuery({queryKey:["budget",session?.user.id,account?.id],enabled:account?.mode==="simulation",queryFn:({signal})=>client<Budget>(`/api/v1/accounts/${account!.id}/budget`,{signal})})
|
||||||
|
const c=candidate.data,eligible=!!c&&publicationAvailable(c)&&account?.mode==="simulation",currentPreview=preview?.account_id===account?.id?preview:null
|
||||||
|
async function calculate(){if(!c||!account)return;setBusy(true);setError("");setPreview(null);try{setPreview(await client<Preview>("/api/v1/plan-previews",{method:"POST",body:{account_id:account.id,candidate_revision_id:c.id,expected_candidate_version:c.version,quantity,fee_budget:fee}}))}catch(e){setError(e instanceof Error?e.message:"预览失败");void candidate.refetch();void budget.refetch()}finally{setBusy(false)}}
|
||||||
|
async function confirm(){if(!currentPreview)return;setBusy(true);setError("");try{await client(`/api/v1/plan-previews/${currentPreview.id}/confirm`,{method:"POST",body:{}});await cache.invalidateQueries({queryKey:["plans"]});await cache.invalidateQueries({queryKey:["budget"]});router.replace("/(tabs)/plans")}catch(e){setError(e instanceof Error?e.message:"确认失败");setPreview(null);void candidate.refetch();void budget.refetch()}finally{setBusy(false)}}
|
||||||
|
return <Screen><Title>创建模拟买入计划</Title><Body>确认计划仅保留资金预算;提醒与计划不会产生交易或扣款。</Body>{candidate.error&&<ErrorMessage message={candidate.error.message}/>} {c&&<Panel><Text style={styles.subtitle}>{c.content.name} · {c.content.symbol}</Text><Body>候选修订 {c.revision} · 区间 ¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</Body><Body>风险价格 ¥{c.content.reference_plan.stop_loss} · 有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body></Panel>}{!account&&<><Body>请先创建或选择模拟账户。</Body><Button secondary title="账户管理" onPress={()=>router.push("/settings/accounts")}/></>}{account&&<Body>当前账户:{account.name} {account.mode!=="simulation"?"· 真实记录账户暂不支持模拟计划":""}</Body>}{c&&!publicationAvailable(c)&&<ErrorMessage message="此候选已失效,不能创建新计划。"/>}{budget.error&&<ErrorMessage message={budget.error.message}/>} {budget.data&&<Panel><Body>账本现金 ¥{budget.data.cash_balance}</Body><Body>其他计划保留 ¥{budget.data.reserved_amount} · 可用 ¥{budget.data.available_amount}</Body></Panel>}<Field label="计划数量(模拟规则:100 股整数倍)" keyboardType="number-pad" editable={!busy} value={quantity} onChangeText={v=>{setQuantity(v);setPreview(null)}}/><Field label="预计费用预算(元,允许 0)" keyboardType="decimal-pad" editable={!busy} value={fee} onChangeText={v=>{setFee(v);setPreview(null)}}/><ErrorMessage message={error}/><Button title="预览资金预算" disabled={!eligible||!quantity||!fee} busy={busy} onPress={()=>void calculate()}/>{currentPreview&&<Panel accent><Text style={styles.subtitle}>确认前核对</Text><Body>数量 {currentPreview.quantity} 股 · 预计费用 ¥{currentPreview.fee_budget}</Body><Body>按区间上限保留 ¥{currentPreview.reserved_amount},预览截止 {new Date(currentPreview.expires_at).toLocaleTimeString("zh-CN")}</Body><Body>确认时会重新检查候选版本与现金;确认后不代表成交,实际发生交易后另行记录。</Body><Button title="确认计划并保留预算" disabled={!eligible||Date.parse(currentPreview.expires_at)<=Date.now()} busy={busy} onPress={()=>void confirm()}/></Panel>}</Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {useState} from "react"
|
||||||
|
import {useQueryClient} from "@tanstack/react-query"
|
||||||
|
import {Text,View} from "react-native"
|
||||||
|
import {useAccounts,useSwitchAccount} from "../../src/state/accounts"
|
||||||
|
import {useSession} from "../../src/state/session"
|
||||||
|
import {useAccountSelection} from "../../src/state/account"
|
||||||
|
import {formatMoney,validInitialCash} from "../../src/lib/money"
|
||||||
|
import type {Account} from "../../src/lib/types"
|
||||||
|
import {Body,Button,ErrorMessage,Field,Panel,Screen,Title,styles} from "../../src/components/ui"
|
||||||
|
export default function Accounts(){
|
||||||
|
const {client,session}=useSession();const {accounts,selected,isPending,isError,error:queryError,refetch}=useAccounts();const switchAccount=useSwitchAccount();const queryClient=useQueryClient()
|
||||||
|
const [mode,setMode]=useState<Account["mode"]>("simulation");const [name,setName]=useState("");const [cash,setCash]=useState("");const [error,setError]=useState("");const [notice,setNotice]=useState("");const [busy,setBusy]=useState(false)
|
||||||
|
const alreadyExists=accounts.some(account=>account.mode===mode)
|
||||||
|
async function submit(){if(busy||alreadyExists)return;setBusy(true);setError("");setNotice("");try{
|
||||||
|
if(!name.trim())throw new Error("请填写账户名称。")
|
||||||
|
if(mode==="simulation"&&!validInitialCash(cash))throw new Error("初始资金应大于 0,不超过 10 亿元,最多两位小数。")
|
||||||
|
const account=await client<Account>("/api/v1/accounts",{method:"POST",body:{mode,name:name.trim(),...(mode==="simulation"?{initial_cash:cash}:{})}})
|
||||||
|
useAccountSelection.getState().select(account.id);setName("");setCash("");setNotice("账户已创建,初始资金以服务端账本为准。");await queryClient.invalidateQueries({queryKey:["accounts",session?.user.id]})
|
||||||
|
}catch(e){setError(e instanceof Error?e.message:"创建账户失败,请重试。");void refetch()}finally{setBusy(false)}}
|
||||||
|
async function select(id:string){setError("");try{await switchAccount(id);setNotice("已切换账户。")}catch{setError("账户切换失败,请重试。")}}
|
||||||
|
return <Screen><Title>账户管理</Title><Body>模拟账户和真实记录账户独立保存,每种模式各可创建一个。</Body>{isPending?<Body>正在读取账户…</Body>:isError?<Panel><ErrorMessage message={queryError.message}/><Button title="重试" secondary onPress={()=>void refetch()}/></Panel>:accounts.map(account=><Panel key={account.id}><View style={styles.row}><Text style={styles.subtitle}>{account.name}</Text><Text style={styles.badge}>{account.mode==="simulation"?"模拟":"手工真实记录"}</Text></View><Body>账本现金:{formatMoney(account.cash_balance)}{account.cash_balance!==null?" 元":""}</Body><Button title={selected?.id===account.id?"当前账户":"切换到此账户"} secondary disabled={selected?.id===account.id||busy} onPress={()=>void select(account.id)}/></Panel>)}{notice&&<Text accessibilityRole="alert" style={{color:"#6554A5"}}>{notice}</Text>}<ErrorMessage message={error}/><View style={styles.divider}/><Text style={styles.subtitle}>创建账户</Text><View style={styles.row}><Button title="模拟账户" secondary={mode!=="simulation"} disabled={busy} onPress={()=>{setMode("simulation");setError("")}}/><Button title="真实记录" secondary={mode!=="manual_real"} disabled={busy} onPress={()=>{setMode("manual_real");setError("")}}/></View>{alreadyExists?<Body>该模式的账户已存在,可在上方切换使用。</Body>:<><Field label="账户名称" value={name} onChangeText={setName} maxLength={80} editable={!busy}/>{mode==="simulation"?<Field label="初始模拟资金(元)" value={cash} onChangeText={setCash} keyboardType="decimal-pad" maxLength={13} editable={!busy}/>:<Panel accent><Body>真实记录由你手工维护,未经券商核验。创建时不注入模拟资金,现金余额为未知。</Body></Panel>}<Button title={mode==="simulation"?"确认创建模拟账户":"确认创建真实记录账户"} busy={busy} disabled={isPending||isError||!name.trim()||(mode==="simulation"&&!cash)} onPress={()=>void submit()}/></>}</Screen>
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true)
|
||||||
|
return { presets: ["babel-preset-expo"] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
const { getDefaultConfig } = require("expo/metro-config")
|
||||||
|
module.exports = getDefaultConfig(__dirname)
|
||||||
Generated
+9010
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "@veyra/mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build": "expo export --platform all",
|
||||||
|
"test": "node --test tests/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@tanstack/react-query": "^5.90.0",
|
||||||
|
"expo": "~55.0.0",
|
||||||
|
"expo-constants": "~55.0.17",
|
||||||
|
"expo-crypto": "~55.0.19",
|
||||||
|
"expo-linking": "~55.0.17",
|
||||||
|
"expo-router": "~55.0.18",
|
||||||
|
"expo-secure-store": "~55.0.18",
|
||||||
|
"expo-status-bar": "~55.0.6",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-native": "0.83.10",
|
||||||
|
"react-native-gesture-handler": "~2.30.0",
|
||||||
|
"react-native-reanimated": "4.2.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
|
"react-native-screens": "~4.23.0",
|
||||||
|
"react-native-worklets": "0.7.4",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.2.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"expo": {
|
||||||
|
"install": {
|
||||||
|
"exclude": [
|
||||||
|
"react"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"react-server-dom-webpack": "19.2.4",
|
||||||
|
"react-dom": "19.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import {router} from "expo-router"
|
||||||
|
import {Text,View} from "react-native"
|
||||||
|
import {useAccounts} from "../state/accounts"
|
||||||
|
import {formatMoney} from "../lib/money"
|
||||||
|
import {Body,Button,EmptyState,ErrorMessage,Panel,styles} from "./ui"
|
||||||
|
export function AccountSummary(){
|
||||||
|
const {selected,isPending,isError,error,refetch,isRefetching}=useAccounts()
|
||||||
|
if(isPending)return <Body>正在读取账户…</Body>
|
||||||
|
if(isError)return <Panel><ErrorMessage message={error.message}/><Button title="重新读取账户" secondary busy={isRefetching} onPress={()=>void refetch()}/></Panel>
|
||||||
|
if(!selected)return <Panel><EmptyState title="从一个账户开始" detail="创建模拟账户练习决策,或使用真实记录账户记录自行发生的交易。"/><Button title="创建账户" onPress={()=>router.push("/settings/accounts")}/></Panel>
|
||||||
|
return <Panel><View style={styles.row}><Text style={styles.subtitle}>{selected.name}</Text><Text style={styles.badge}>{selected.mode==="simulation"?"模拟账户":"手工真实记录"}</Text></View><Body>{selected.mode==="simulation"?"账本可用现金(元)":"账本估算可用现金(元)"}</Body><Text style={styles.money}>{formatMoney(selected.cash_balance)}</Text><Body>{selected.mode==="manual_real"?"用户记录,未经券商核验。现金账本尚不完整,余额显示未知。":"现金由初始资金与账本流水计算;提醒不会生成成交。"}</Body><View style={styles.row}><Button title="管理账户" secondary onPress={()=>router.push("/settings/accounts")}/><Button title="刷新" secondary busy={isRefetching} onPress={()=>void refetch()}/></View></Panel>
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type {PropsWithChildren} from "react"
|
||||||
|
import {ActivityIndicator,KeyboardAvoidingView,Platform,Pressable,ScrollView,StyleSheet,Text,TextInput,View,type TextInputProps,type ViewStyle} from "react-native"
|
||||||
|
import {SafeAreaView} from "react-native-safe-area-context"
|
||||||
|
export const colors={background:"#FFFFFF",text:"#1B1B20",muted:"#74747F",layer:"#F5F5F7",action:"#202025",accent:"#6554A5",accentSoft:"#F1EDF9",border:"#EAEAF0",danger:"#C54655"}
|
||||||
|
export function Screen({children}:PropsWithChildren){return <SafeAreaView style={styles.safe} edges={["top","left","right"]}><KeyboardAvoidingView style={styles.safe} behavior={Platform.OS==="ios"?"padding":undefined}><ScrollView contentContainerStyle={styles.screen} keyboardShouldPersistTaps="handled">{children}</ScrollView></KeyboardAvoidingView></SafeAreaView>}
|
||||||
|
export function Title({children}:PropsWithChildren){return <Text accessibilityRole="header" style={styles.title}>{children}</Text>}
|
||||||
|
export function Body({children}:PropsWithChildren){return <Text style={styles.body}>{children}</Text>}
|
||||||
|
export function Panel({children,accent=false,style}:PropsWithChildren<{accent?:boolean;style?:ViewStyle}>){return <View style={[styles.panel,accent&&{backgroundColor:colors.accentSoft},style]}>{children}</View>}
|
||||||
|
export function Button({title,onPress,disabled=false,secondary=false,busy=false}:{title:string;onPress:()=>void;disabled?:boolean;secondary?:boolean;busy?:boolean}){return <Pressable accessibilityRole="button" accessibilityState={{disabled:disabled||busy,busy}} disabled={disabled||busy} onPress={onPress} style={({pressed})=>[styles.button,secondary&&styles.secondary,(disabled||busy||pressed)&&{opacity:0.55}]}>{busy?<ActivityIndicator color={secondary?colors.text:"#FFFFFF"}/>:<Text style={[styles.buttonText,secondary&&{color:colors.text}]}>{title}</Text>}</Pressable>}
|
||||||
|
export function Field({label,...props}:TextInputProps&{label:string}){return <View style={styles.field}><Text style={styles.label}>{label}</Text><TextInput accessibilityLabel={label} placeholderTextColor={colors.muted} style={styles.input} {...props}/></View>}
|
||||||
|
export function ErrorMessage({message}:{message:string}){return message?<Text accessibilityRole="alert" style={styles.error}>{message}</Text>:null}
|
||||||
|
export function EmptyState({title,detail}: {title:string;detail:string}){return <View style={styles.empty}><Text style={styles.subtitle}>{title}</Text><Body>{detail}</Body></View>}
|
||||||
|
export const styles=StyleSheet.create({safe:{flex:1,backgroundColor:colors.background},screen:{padding:24,paddingBottom:40,gap:22,flexGrow:1},title:{fontSize:29,fontWeight:"700",color:colors.text,lineHeight:38},subtitle:{fontSize:19,fontWeight:"600",color:colors.text},body:{fontSize:14,lineHeight:24,color:colors.muted},panel:{backgroundColor:colors.layer,padding:22,borderRadius:18,gap:14},button:{backgroundColor:colors.action,minHeight:50,paddingVertical:13,paddingHorizontal:18,borderRadius:12,alignItems:"center",justifyContent:"center"},secondary:{backgroundColor:colors.layer},buttonText:{fontSize:15,fontWeight:"600",color:"#FFFFFF"},field:{gap:10},label:{fontSize:14,color:colors.text},input:{minHeight:52,borderRadius:12,borderWidth:1,borderColor:colors.border,paddingHorizontal:15,paddingVertical:12,fontSize:16,color:colors.text,backgroundColor:"#FFFFFF"},error:{fontSize:14,color:colors.danger,lineHeight:23},empty:{paddingVertical:35,gap:12},row:{flexDirection:"row",alignItems:"center",justifyContent:"space-between",gap:12},money:{fontSize:34,fontWeight:"600",color:colors.text},badge:{fontSize:12,color:colors.accent,backgroundColor:colors.accentSoft,paddingHorizontal:10,paddingVertical:6,borderRadius:8,alignSelf:"flex-start"},divider:{height:1,backgroundColor:colors.border}})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status:number
|
||||||
|
readonly code:string
|
||||||
|
constructor(status:number,code:string,message:string){super(message);this.name="ApiError";this.status=status;this.code=code}
|
||||||
|
}
|
||||||
|
export function normalizeBaseURL(value:string):string {
|
||||||
|
if(!value.trim())throw new ApiError(0,"NOT_CONFIGURED","请先配置 App 的 API 地址。")
|
||||||
|
let url:URL
|
||||||
|
try{url=new URL(value.trim())}catch{throw new ApiError(0,"INVALID_URL","API 地址格式不正确。")}
|
||||||
|
if(!["http:","https:"].includes(url.protocol)||url.username||url.password||url.search||url.hash||url.pathname!=="/")throw new ApiError(0,"INVALID_URL","API 地址应只包含协议、主机和端口。")
|
||||||
|
return url.origin
|
||||||
|
}
|
||||||
|
export type Client = <T>(path:string,options?:{method?:string;body?:unknown;signal?:AbortSignal})=>Promise<T>
|
||||||
|
export function createClient(baseURL:string,token?:string,onExpired?:()=>void,transport:typeof fetch=fetch,isCurrent:()=>boolean=()=>true):Client {
|
||||||
|
return async <T>(path:string,options:{method?:string;body?:unknown;signal?:AbortSignal}={}):Promise<T>=>{
|
||||||
|
const base=normalizeBaseURL(baseURL)
|
||||||
|
if(!path.startsWith("/api/v1/")||path.includes("?")||path.includes("#"))throw new ApiError(0,"INVALID_PATH","请求地址无效。")
|
||||||
|
const controller=new AbortController()
|
||||||
|
const cancel=()=>controller.abort(options.signal?.reason)
|
||||||
|
if(options.signal?.aborted)cancel();else options.signal?.addEventListener("abort",cancel,{once:true})
|
||||||
|
const timer=setTimeout(()=>controller.abort(),10000)
|
||||||
|
try{
|
||||||
|
const response=await transport(base+path,{method:options.method??"GET",signal:controller.signal,credentials:"omit",redirect:"error",headers:{Accept:"application/json",...(token?{Authorization:`Bearer ${token}`} : {}),...(options.body!==undefined?{"Content-Type":"application/json"}:{})},...(options.body!==undefined?{body:JSON.stringify(options.body)}:{})})
|
||||||
|
if(token&&!isCurrent())throw new ApiError(401,"SESSION_CHANGED","会话已变更,请重新进入。")
|
||||||
|
if(response.status===401)onExpired?.()
|
||||||
|
let envelope:unknown
|
||||||
|
try{envelope=await response.json()}catch{throw new ApiError(response.status,"INVALID_RESPONSE","服务响应格式异常,请稍后重试。")}
|
||||||
|
if(!response.ok){const data=envelope as {error?:{code?:string;message?:string}};throw new ApiError(response.status,data?.error?.code??"REQUEST_FAILED",data?.error?.message??"请求失败,请稍后重试。")}
|
||||||
|
if(!envelope||typeof envelope!=="object"||!("data" in envelope))throw new ApiError(response.status,"INVALID_RESPONSE","服务响应格式异常,请稍后重试。")
|
||||||
|
return (envelope as {data:T}).data
|
||||||
|
}catch(error){
|
||||||
|
if(error instanceof ApiError)throw error
|
||||||
|
if(options.signal?.aborted)throw error
|
||||||
|
throw new ApiError(0,"NETWORK_ERROR",controller.signal.aborted?"请求超时,请检查网络后重试。":"无法连接服务,请检查 API 地址与网络。")
|
||||||
|
}finally{clearTimeout(timer);options.signal?.removeEventListener("abort",cancel)}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type Candidate={id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";valid_until:string;observed_at:string;is_active_publication:boolean;withdrawal_reason:string;content:{symbol:string;exchange:string;name:string;title:string;thesis:string;evidence:{source_url:string;locator:string;summary:string}[];counter_evidence:string;invalidation:string;reference_plan:{entry_low:string;entry_high:string;stop_loss:string;target_price:string}}}
|
||||||
|
export function publicationAvailable(candidate:Pick<Candidate,"status"|"is_active_publication"|"valid_until">,now=Date.now()){return candidate.status==="PUBLISHED"&&candidate.is_active_publication&&Date.parse(candidate.valid_until)>now}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type Fill={id:string;account_id:string;plan_id:string|null;idempotency_key:string;side:"BUY"|"SELL";symbol:string;exchange:string;name:string;quantity:string;price:string;fee:string;cash_delta:string;realized_pnl:string;occurred_at:string;sellable_at:string|null;note:string;reversed:boolean}
|
||||||
|
export type Portfolio={account_id:string;cash_balance:string;realized_pnl:string;review:{trade_count:number;sell_count:number;profitable_sell_count:number;losing_sell_count:number;buy_outflow:string;sell_inflow:string;total_fees:string};positions:{symbol:string;exchange:string;name:string;quantity:string;sellable_quantity:string;cost_basis:string;market_value:string|null;unrealized_pnl:string|null}[]}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Money stays a decimal string across the transport and rendering boundary.
|
||||||
|
export function formatMoney(value:string|null):string {
|
||||||
|
if(value===null)return "未知"
|
||||||
|
if(!/^\d+\.\d{2}$/.test(value))return "数据异常"
|
||||||
|
const [whole,fraction]=value.split(".")
|
||||||
|
return `${whole.replace(/\B(?=(\d{3})+(?!\d))/g,",")}.${fraction}`
|
||||||
|
}
|
||||||
|
export function validInitialCash(value:string):boolean {
|
||||||
|
if(!/^\d{1,10}(\.\d{1,2})?$/.test(value))return false
|
||||||
|
const [whole,fraction=""]=value.split(".")
|
||||||
|
const cents=BigInt(whole)*100n+BigInt(fraction.padEnd(2,"0"))
|
||||||
|
return cents>0n&¢s<=100000000000n
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type {Candidate} from "./candidates"
|
||||||
|
export type Preview={id:string;account_id:string;candidate_revision_id:string;quantity:string;reserved_amount:string;fee_budget:string;expires_at:string}
|
||||||
|
export type Plan={id:string;account_id:string;candidate_revision_id:string;quantity:string;filled_quantity:string;reserved_amount:string;status:string;version:number;valid_until:string;candidate_content:Candidate["content"]}
|
||||||
|
export type Budget={cash_balance:string;reserved_amount:string;available_amount:string}
|
||||||
|
export const planStates:Record<string,string>={ACTIVE:"等待条件",PART_FILLED:"部分成交",COMPLETED:"已记录完成",CANCELLED:"已取消",INVALIDATED:"已失效"}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type {Session} from "./types.ts"
|
||||||
|
export function parseSession(value:unknown):Session|null {
|
||||||
|
if(!value||typeof value!=="object")return null
|
||||||
|
const data=value as Partial<Session>
|
||||||
|
if(typeof data.access_token!=="string"||!/^[A-Za-z0-9_-]{43}$/.test(data.access_token)||typeof data.expires_at!=="string"||!Number.isFinite(Date.parse(data.expires_at))||!data.user||typeof data.user.id!=="string"||!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(data.user.id)||typeof data.user.display_name!=="string"||data.user.status!=="ACTIVE")return null
|
||||||
|
return data as Session
|
||||||
|
}
|
||||||
|
export function createStorageQueue(){
|
||||||
|
let queue:Promise<void>=Promise.resolve()
|
||||||
|
return (operation:()=>Promise<void>)=>{const next=queue.catch(()=>{}).then(operation);queue=next;return next}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import * as SecureStore from "expo-secure-store"
|
||||||
|
import {parseSession} from "./session-data"
|
||||||
|
import type {Session} from "./types"
|
||||||
|
const key="veyra.app.session.v1"
|
||||||
|
export async function readSession():Promise<Session|null>{
|
||||||
|
const text=await SecureStore.getItemAsync(key)
|
||||||
|
if(!text)return null
|
||||||
|
try{return parseSession(JSON.parse(text))}catch{return null}
|
||||||
|
}
|
||||||
|
export const saveSession=(session:Session)=>SecureStore.setItemAsync(key,JSON.stringify(session),{keychainAccessible:SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY})
|
||||||
|
export const clearSession=()=>SecureStore.deleteItemAsync(key)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type User = {id:string;display_name:string;status:"ACTIVE"|"DISABLED"}
|
||||||
|
export type Session = {user:User;access_token:string;expires_at:string}
|
||||||
|
export type Account = {id:string;user_id:string;mode:"simulation"|"manual_real";name:string;currency:"CNY";ledger_status:"COMPLETE"|"INCOMPLETE";cash_balance:string|null;created_at:string}
|
||||||
|
export type Health = {status:string;components:{postgresql:{status:string};redis:{status:string}};checked_at:string}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import {create} from "zustand"
|
||||||
|
export const useAccountSelection=create<{accountID:string|null;select:(id:string|null)=>void}>(set=>({accountID:null,select:accountID=>set({accountID})}))
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {useQuery,useQueryClient} from "@tanstack/react-query"
|
||||||
|
import {useSession} from "./session"
|
||||||
|
import {useAccountSelection} from "./account"
|
||||||
|
import type {Account} from "../lib/types"
|
||||||
|
export function useAccounts(){
|
||||||
|
const {session,client}=useSession();const selectedID=useAccountSelection(state=>state.accountID)
|
||||||
|
const query=useQuery({queryKey:["accounts",session?.user.id],enabled:!!session,queryFn:({signal})=>client<Account[]>("/api/v1/accounts",{signal})})
|
||||||
|
const accounts=query.data??[];const selected=accounts.find(account=>account.id===selectedID)??accounts[0]??null
|
||||||
|
return {...query,accounts,selected}
|
||||||
|
}
|
||||||
|
export function useSwitchAccount(){const queryClient=useQueryClient();return async(id:string)=>{await queryClient.cancelQueries();queryClient.removeQueries({predicate:query=>query.queryKey[0]!=="accounts"});useAccountSelection.getState().select(id)}}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {createContext,useCallback,useContext,useEffect,useMemo,useRef,useState,type PropsWithChildren} from "react"
|
||||||
|
import {AppState} from "react-native"
|
||||||
|
import {QueryClient,QueryClientProvider,focusManager} from "@tanstack/react-query"
|
||||||
|
import {ApiError,createClient,type Client} from "../lib/api"
|
||||||
|
import {readSession,saveSession,clearSession} from "../lib/session-storage"
|
||||||
|
import {parseSession,createStorageQueue} from "../lib/session-data"
|
||||||
|
import type {Session,User} from "../lib/types"
|
||||||
|
import {useAccountSelection} from "./account"
|
||||||
|
const baseURL=process.env.EXPO_PUBLIC_API_URL??""
|
||||||
|
const SessionContext=createContext<{session:Session|null;loading:boolean;restoreError:string;client:Client;retry:()=>void;login:(name:string,password:string)=>Promise<void>;logout:()=>Promise<void>}|null>(null)
|
||||||
|
export function SessionProvider({children}:PropsWithChildren){
|
||||||
|
const [queryClient]=useState(()=>new QueryClient({defaultOptions:{queries:{retry:(count,error)=>!(error instanceof ApiError&&[401,403,404,422].includes(error.status))&&count<1,staleTime:15000}}}))
|
||||||
|
const [session,setSession]=useState<Session|null>(null)
|
||||||
|
const [loading,setLoading]=useState(true)
|
||||||
|
const [restoreError,setRestoreError]=useState("")
|
||||||
|
const [attempt,setAttempt]=useState(0)
|
||||||
|
const active=useRef<string|null>(null)
|
||||||
|
const mounted=useRef(true)
|
||||||
|
// SecureStore writes must be ordered so an expired session cannot erase a newer login.
|
||||||
|
const [persist]=useState(()=>createStorageQueue())
|
||||||
|
const reset=useCallback(()=>{active.current=null;setSession(null);void queryClient.cancelQueries();queryClient.clear();useAccountSelection.getState().select(null);void persist(clearSession).catch(()=>setRestoreError("无法清除本机会话,请重试。"))},[persist,queryClient])
|
||||||
|
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false}},[])
|
||||||
|
useEffect(()=>{
|
||||||
|
let cancelled=false;setLoading(true);setRestoreError("")
|
||||||
|
async function restore(){try{
|
||||||
|
const saved=await readSession();if(cancelled)return
|
||||||
|
if(!saved){setSession(null);return}
|
||||||
|
if(Date.parse(saved.expires_at)<=Date.now()){await persist(clearSession);return}
|
||||||
|
const user=await createClient(baseURL,saved.access_token)<User>("/api/v1/me")
|
||||||
|
if(!cancelled){active.current=saved.access_token;setSession({...saved,user})}
|
||||||
|
}catch(error){if(cancelled)return;if(error instanceof ApiError&&error.status===401){try{await persist(clearSession)}catch{setRestoreError("无法清除已过期的本机会话,请重试。")}}else setRestoreError(error instanceof Error?error.message:"会话恢复失败,请重试。")}finally{if(!cancelled)setLoading(false)}}
|
||||||
|
void restore();return()=>{cancelled=true}
|
||||||
|
},[attempt,persist])
|
||||||
|
const client=useMemo(()=>{const token=session?.access_token;return createClient(baseURL,token,()=>{if(token&&active.current===token&&mounted.current)reset()},fetch,()=>active.current===token)},[session?.access_token,reset])
|
||||||
|
useEffect(()=>{const subscription=AppState.addEventListener("change",state=>{focusManager.setFocused(state==="active");if(state==="active"&&session&&Date.parse(session.expires_at)<=Date.now())reset()});return()=>subscription.remove()},[session,reset])
|
||||||
|
useEffect(()=>{if(!session)return;const timer=setTimeout(reset,Math.max(0,Date.parse(session.expires_at)-Date.now()));return()=>clearTimeout(timer)},[session,reset])
|
||||||
|
async function login(name:string,password:string){
|
||||||
|
const response=await createClient(baseURL)<unknown>("/api/v1/auth/login",{method:"POST",body:{login_name:name.trim(),password}})
|
||||||
|
const result=parseSession(response)
|
||||||
|
if(!result)throw new ApiError(502,"INVALID_RESPONSE","服务返回的登录信息无效,请重试。")
|
||||||
|
try{await persist(()=>saveSession(result))}catch{await createClient(baseURL,result.access_token)("/api/v1/auth/logout",{method:"POST"}).catch(()=>{});throw new Error("无法安全保存会话,请检查设备后重试。")}
|
||||||
|
if(mounted.current){active.current=result.access_token;queryClient.clear();useAccountSelection.getState().select(null);setRestoreError("");setSession(result)}
|
||||||
|
}
|
||||||
|
async function logout(){await client("/api/v1/auth/logout",{method:"POST"});active.current=null;setSession(null);await queryClient.cancelQueries();queryClient.clear();useAccountSelection.getState().select(null);try{await persist(clearSession)}catch{setRestoreError("服务端会话已注销,但本机会话清理失败,请重试。")}}
|
||||||
|
return <QueryClientProvider client={queryClient}><SessionContext.Provider value={{session,loading,restoreError,client,retry:()=>setAttempt(v=>v+1),login,logout}}>{children}</SessionContext.Provider></QueryClientProvider>
|
||||||
|
}
|
||||||
|
export function useSession(){const value=useContext(SessionContext);if(!value)throw new Error("SessionProvider is required");return value}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {test} from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {ApiError,createClient,normalizeBaseURL} from "../src/lib/api.ts"
|
||||||
|
test("API URL does not carry credentials or route state",()=>{
|
||||||
|
assert.equal(normalizeBaseURL(" https://api.example.com/ "),"https://api.example.com")
|
||||||
|
for(const value of ["","ftp://api.example.com","https://user:secret@api.example.com","https://api.example.com?token=secret","https://api.example.com/path"]){assert.throws(()=>normalizeBaseURL(value),ApiError)}
|
||||||
|
})
|
||||||
|
test("App uses its bearer token and the server confirmed envelope",async()=>{
|
||||||
|
let observed:RequestInit|undefined
|
||||||
|
const transport:typeof fetch=async(input,options)=>{assert.equal(input,"https://api.example.com/api/v1/accounts");observed=options;return new Response(JSON.stringify({data:[{cash_balance:"100000.01"}],meta:{request_id:"test"}}),{status:200})}
|
||||||
|
const client=createClient("https://api.example.com","app-token",undefined,transport)
|
||||||
|
const result=await client<Array<{cash_balance:string}>>("/api/v1/accounts",{method:"POST",body:{mode:"simulation",initial_cash:"100000.01"}})
|
||||||
|
assert.equal(result[0].cash_balance,"100000.01")
|
||||||
|
assert.equal(new Headers(observed?.headers).get("Authorization"),"Bearer app-token")
|
||||||
|
assert.equal(observed?.credentials,"omit");assert.equal(observed?.redirect,"error")
|
||||||
|
assert.equal(JSON.parse(observed?.body as string).initial_cash,"100000.01")
|
||||||
|
})
|
||||||
|
test("401 expires the session, infrastructure failures do not",async()=>{
|
||||||
|
let expired=0
|
||||||
|
for(const status of [503,401]){
|
||||||
|
const client=createClient("https://api.example.com","token",()=>expired++,async()=>new Response(JSON.stringify({error:{code:"TEST_FAILURE",message:"服务错误"}}),{status}))
|
||||||
|
await assert.rejects(()=>client("/api/v1/me"),(e:unknown)=>e instanceof ApiError&&e.status===status)
|
||||||
|
}
|
||||||
|
assert.equal(expired,1)
|
||||||
|
})
|
||||||
|
test("stale responses cannot expire or populate a newer session",async()=>{
|
||||||
|
let expired=0
|
||||||
|
const client=createClient("https://api.example.com","previous-token",()=>expired++,async()=>new Response(JSON.stringify({data:{id:"old-user"}}),{status:200}),()=>false)
|
||||||
|
await assert.rejects(()=>client("/api/v1/me"),(e:unknown)=>e instanceof ApiError&&e.code==="SESSION_CHANGED")
|
||||||
|
assert.equal(expired,0)
|
||||||
|
})
|
||||||
|
test("broken proxy response and private transport errors are sanitized",async()=>{
|
||||||
|
const client=createClient("https://api.example.com",undefined,undefined,async()=>new Response("<html>proxy</html>",{status:502}))
|
||||||
|
await assert.rejects(()=>client("/api/v1/me"),(e:unknown)=>e instanceof ApiError&&e.code==="INVALID_RESPONSE")
|
||||||
|
const failed=createClient("https://api.example.com",undefined,undefined,async()=>{throw new Error("private request details")})
|
||||||
|
await assert.rejects(()=>failed("/api/v1/me"),(e:unknown)=>e instanceof ApiError&&e.code==="NETWORK_ERROR"&&!e.message.includes("private"))
|
||||||
|
})
|
||||||
|
test("caller cancellation stays distinguishable from a network failure",async()=>{
|
||||||
|
const controller=new AbortController();controller.abort()
|
||||||
|
const transport:typeof fetch=async(_url,options)=>{assert.equal(options?.signal?.aborted,true);throw new DOMException("Aborted","AbortError")}
|
||||||
|
await assert.rejects(()=>createClient("https://api.example.com",undefined,undefined,transport)("/api/v1/accounts",{signal:controller.signal}),(e:unknown)=>e instanceof DOMException&&e.name==="AbortError")
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import test from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {publicationAvailable} from "../src/lib/candidates.ts"
|
||||||
|
test("withdrawn, superseded and expired candidate links remain unavailable",()=>{
|
||||||
|
const now=Date.now(),candidate={status:"PUBLISHED",is_active_publication:true,valid_until:new Date(now+1000).toISOString()}
|
||||||
|
assert.equal(publicationAvailable(candidate,now),true)
|
||||||
|
assert.equal(publicationAvailable({...candidate,status:"WITHDRAWN"},now),false)
|
||||||
|
assert.equal(publicationAvailable({...candidate,is_active_publication:false},now),false)
|
||||||
|
assert.equal(publicationAvailable(candidate,now+1000),false)
|
||||||
|
assert.equal(publicationAvailable({...candidate,valid_until:"invalid"},now),false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {test} from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {formatMoney,validInitialCash} from "../src/lib/money.ts"
|
||||||
|
test("cash formatting preserves cents without floating point",()=>{
|
||||||
|
assert.equal(formatMoney("100000.01"),"100,000.01")
|
||||||
|
assert.equal(formatMoney("9007199254740993.09"),"9,007,199,254,740,993.09")
|
||||||
|
assert.equal(formatMoney(null),"未知")
|
||||||
|
assert.equal(formatMoney("invalid"),"数据异常")
|
||||||
|
})
|
||||||
|
test("initial cash validates decimal limits exactly",()=>{
|
||||||
|
for(const value of ["0.01","100000.01","1000000000.00"])assert.equal(validInitialCash(value),true,value)
|
||||||
|
for(const value of ["0","0.00","-1","1e4","1.001","1000000000.01","",".1"," 10"]){assert.equal(validInitialCash(value),false,value)}
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {test} from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {createStorageQueue,parseSession} from "../src/lib/session-data.ts"
|
||||||
|
const valid={access_token:"a".repeat(43),expires_at:"2026-12-01T10:00:00Z",user:{id:"7f8fd1ca-30e8-4dfb-82a9-8655fbd354ac",display_name:"测试用户",status:"ACTIVE"}}
|
||||||
|
test("session restoration requires a valid app credential and active identity",()=>{
|
||||||
|
assert.deepEqual(parseSession(valid),valid)
|
||||||
|
for(const value of [null,{}, {...valid,access_token:"admin cookie"},{...valid,expires_at:"invalid"},{...valid,user:{...valid.user,status:"DISABLED"}},{...valid,user:{...valid.user,id:"other"}}])assert.equal(parseSession(value),null)
|
||||||
|
})
|
||||||
|
test("ordered secret storage prevents expiration cleanup from erasing a newer login",async()=>{
|
||||||
|
const persist=createStorageQueue();let stored="old-session";const operations:string[]=[]
|
||||||
|
let release:()=>void=()=>{}
|
||||||
|
const barrier=new Promise<void>(resolve=>{release=resolve})
|
||||||
|
const clear=persist(async()=>{await barrier;stored="";operations.push("clear")})
|
||||||
|
const login=persist(async()=>{stored="new-session";operations.push("save")})
|
||||||
|
release();await Promise.all([clear,login]);assert.deepEqual(operations,["clear","save"]);assert.equal(stored,"new-session")
|
||||||
|
})
|
||||||
|
test("a failed storage operation does not prevent later recovery",async()=>{
|
||||||
|
const persist=createStorageQueue();await assert.rejects(persist(async()=>{throw new Error("storage failure")}))
|
||||||
|
let recovered=false;await persist(async()=>{recovered=true});assert.equal(recovered,true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"app/**/*.tsx",
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"tests/**/*.ts",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
|
|
||||||
"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() {
|
|
||||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
|
||||||
if err := run(logger); err != nil {
|
|
||||||
logger.Error("API stopped", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(logger *slog.Logger) error {
|
|
||||||
envFile := flag.String("env-file", "", "optional environment file")
|
|
||||||
flag.Parse()
|
|
||||||
if *envFile != "" {
|
|
||||||
if err := config.LoadEnvFile(*envFile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var postgres service.Probe
|
|
||||||
var authRepo service.AuthRepository
|
|
||||||
var accountRepo service.AccountRepository
|
|
||||||
if c.PGUser != "" {
|
|
||||||
if err := c.ValidateDatabase(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pool, err := pgxpool.New(context.Background(), c.DatabaseURL(c.PGDatabase))
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("invalid PostgreSQL configuration")
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
postgres = pool.Ping
|
|
||||||
authRepo = 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 := service.NewAuthService(authRepo)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
go func() { result <- server.ListenAndServe() }()
|
|
||||||
logger.Info("API starting", "address", c.HTTPAddr, "environment", c.Environment)
|
|
||||||
select {
|
|
||||||
case err := <-result:
|
|
||||||
if errors.Is(err, http.ErrServerClosed) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
case <-ctx.Done():
|
|
||||||
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return server.Shutdown(shutdown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.sundynix.cn/Blizzard/Veyra/internal/config"
|
|
||||||
"git.sundynix.cn/Blizzard/Veyra/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if err := run(); err != nil {
|
|
||||||
slog.Error("database command failed", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run() error {
|
|
||||||
envFile := flag.String("env-file", "", "optional environment file")
|
|
||||||
action := flag.String("action", "migrate", "create or migrate")
|
|
||||||
flag.Parse()
|
|
||||||
if *envFile != "" {
|
|
||||||
if err := config.LoadEnvFile(*envFile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
switch *action {
|
|
||||||
case "create":
|
|
||||||
created, err := database.Create(ctx, c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
slog.Info("database ready", "database", c.PGDatabase, "created", created)
|
|
||||||
case "migrate":
|
|
||||||
pool, err := database.Open(ctx, c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
if err := database.Migrate(ctx, pool); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
slog.Info("migrations applied", "database", c.PGDatabase)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("action must be create or migrate")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// Command user provisions development identities without exposing a registration API.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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() {
|
|
||||||
if err := run(); err != nil {
|
|
||||||
slog.Error("provision identity failed", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func run() error {
|
|
||||||
envFile := flag.String("env-file", "", "environment file")
|
|
||||||
role := flag.String("role", "ADMIN", "ADMIN or USER")
|
|
||||||
flag.Parse()
|
|
||||||
if *envFile != "" {
|
|
||||||
if err := config.LoadEnvFile(*envFile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if c.Environment != "local" && c.Environment != "test" {
|
|
||||||
return fmt.Errorf("development provisioning is disabled outside local/test environments")
|
|
||||||
}
|
|
||||||
var login, password, name string
|
|
||||||
switch *role {
|
|
||||||
case "ADMIN":
|
|
||||||
login = os.Getenv("ADMIN_LOGIN")
|
|
||||||
password = os.Getenv("ADMIN_PASSWORD")
|
|
||||||
name = "管理员"
|
|
||||||
case "USER":
|
|
||||||
login = os.Getenv("DEV_USER_LOGIN")
|
|
||||||
password = os.Getenv("DEV_USER_PASSWORD")
|
|
||||||
name = "开发测试用户"
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("role must be ADMIN or USER")
|
|
||||||
}
|
|
||||||
if login == "" || password == "" {
|
|
||||||
return fmt.Errorf("configure the selected identity login and password in the local environment file")
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
pool, err := database.Open(ctx, c)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
auth, err := service.NewAuthService(repository.NewIdentityRepository(pool))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
user, err := auth.ProvisionUser(ctx, login, password, name, *role)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
slog.Info("identity provisioned", "user_id", user.ID, "role", *role)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
APP_ENV=local
|
||||||
|
HTTP_ADDR=127.0.0.1:8080
|
||||||
|
PGHOST=192.168.100.127
|
||||||
|
PGPORT=5432
|
||||||
|
PGDATABASE=veyra
|
||||||
|
PGUSER=
|
||||||
|
PGPASSWORD=
|
||||||
|
PGSSLMODE=disable
|
||||||
|
PGADMIN_DATABASE=postgres
|
||||||
|
REDIS_ADDR=192.168.100.127:6379
|
||||||
|
REDIS_USERNAME=
|
||||||
|
# Redis 无用户名;将实际密码填写到本地 .env。
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
# 开发阶段身份:与数据库登录凭据完全独立;管理员密码为 8–72 字节,App 开发用户为 12–72 字节。
|
||||||
|
ADMIN_LOGIN=admin
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
DEV_USER_LOGIN=demo
|
||||||
|
DEV_USER_PASSWORD=
|
||||||
|
|
||||||
|
# 后台供应商密钥的加密主密钥:Base64 编码的 32 字节随机值,部署时持久保存。
|
||||||
|
SECRET_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# 日志目录相对于当前工作目录;控制台易读,文件为 JSON,自动轮转压缩。
|
||||||
|
LOG_DIR=logs
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_MAX_SIZE_MB=50
|
||||||
|
LOG_MAX_BACKUPS=10
|
||||||
|
LOG_MAX_AGE_DAYS=14
|
||||||
|
|
||||||
|
# Python 3,用于同进程 Worker 调用隔离的研究引擎。
|
||||||
|
PYTHON_EXECUTABLE=python3
|
||||||
+109
-30
@@ -1,6 +1,6 @@
|
|||||||
# 开发记忆与交接状态
|
# 开发记忆与交接状态
|
||||||
|
|
||||||
更新日期:2026-10-06。
|
更新日期:2026-10-08。
|
||||||
|
|
||||||
## 产品与依据
|
## 产品与依据
|
||||||
|
|
||||||
@@ -12,68 +12,147 @@
|
|||||||
|
|
||||||
## 用户确认的硬性规则
|
## 用户确认的硬性规则
|
||||||
|
|
||||||
- 后端严格遵循 Go 工程规范,采用 cmd/internal 分层、依赖注入、显式 SQL、context、错误处理及 gofmt/go vet。
|
- 后端严格遵循 Go 工程规范,采用根目录唯一入口/internal 分层、依赖注入、显式 SQL、context、错误处理及 gofmt/go vet。
|
||||||
- 所有数据库表以 `veyra_` 开头,字段 snake_case。
|
- 所有数据库表以 `veyra_` 开头,字段 snake_case。
|
||||||
- 主键为字符串 UUID:数据库 varchar(36),Go string;外键同类型。
|
- 主键为字符串 UUID:数据库 varchar(36),Go string;外键同类型。
|
||||||
- 映射去前缀后 PascalCase,例如 `veyra_user` 对应 `User`。
|
- 映射去前缀后 PascalCase,例如 `veyra_user` 对应 `User`。
|
||||||
- 管理端使用 Vite + React + Tailwind CSS + shadcn/ui,并通过 Go embed 嵌入后端。同一二进制提供页面和 API。
|
- 管理端使用 Vite + React + Tailwind CSS + shadcn/ui,并通过 Go embed 嵌入后端。同一二进制提供页面和 API。
|
||||||
- PostgreSQL:192.168.100.127:5432,数据库 veyra;Redis:192.168.100.127:6379,无用户名、有密码。
|
- PostgreSQL:192.168.100.127:5432,数据库 veyra;Redis:192.168.100.127:6379,无用户名、有密码。
|
||||||
- 凭据保存在本地 `.env`,禁止写进 Git 或项目记忆。
|
- 凭据保存在本地 `configs/.env`,禁止写进 Git 或项目记忆。
|
||||||
|
|
||||||
## 已实现
|
## 已实现
|
||||||
|
|
||||||
- Go API:健康检查、结构化日志、请求 ID、超时、优雅停机。
|
- Go API:健康检查、结构化日志、请求 ID、超时、优雅停机。
|
||||||
- 独立建库与迁移命令,迁移使用事务、并发锁、checksum 和字符串 UUID 迁移记录。
|
- 独立建库与迁移命令,迁移使用事务、并发锁、checksum 和字符串 UUID 迁移记录。
|
||||||
- 两版迁移:基础用户/账户;身份凭据/会话/账户状态/初始现金流水。
|
- 十二版迁移已应用开发数据库,覆盖身份、配置、候选、计划、账本、通知、策略、回测与自动行情。
|
||||||
- local/test 开发身份初始化;密码 bcrypt 哈希、随机会话凭据、数据库只保存会话哈希。
|
- local/test 开发身份初始化;密码 bcrypt 哈希、随机会话凭据、数据库只保存会话哈希。
|
||||||
- 管理端 Cookie 和 App Bearer 会话隔离,退出撤销、过期与状态校验、管理员权限校验。
|
- 管理端 Cookie 和 App Bearer 会话隔离,退出撤销、过期与状态校验、管理员权限校验。
|
||||||
- Redis 登录限流;管理端 Cookie 为 HttpOnly、SameSite=Strict,写操作校验同源 Origin。
|
- Redis 登录限流;管理端 Cookie 为 HttpOnly、SameSite=Strict,写操作校验同源 Origin。
|
||||||
- 账户创建、归属查询、账户列表;模拟资金初始化原子入账,整数分计算、金额字符串 API;真实记录账户现金未知。
|
- 账户创建、归属查询、账户列表;模拟资金初始化原子入账,整数分计算、金额字符串 API;真实记录账户现金未知。
|
||||||
- 管理端登录页、服务总览、用户列表和十个工作区入口;未实现业务页面显示建设中。
|
- 管理端登录页、服务总览、用户列表、模型配置、行情源配置、审计日志和十个工作区入口;未实现业务页面显示建设中。
|
||||||
- OpenAPI、依赖锁文件、Makefile 和带 PostgreSQL 集成测试服务的 CI。
|
- 主模型、备用模型、审核模型与行情源由管理员配置,供应商/地址/模型/密钥初始留空并关闭,不在环境模板预选厂家。配置保存和启用均不代表真实连通性或模型能力已验证。
|
||||||
- `make build` 生成内嵌管理端的 veyra-api,以及 veyra-db 和 veyra-user。
|
- 供应商密钥采用 AES-256-GCM 加密,主密钥只在本地环境保存;查询、历史 JSON 与审计不返回密钥。保留/替换/清除密钥与版本保护已实现;更换地址不能静默复用密钥。配置、历史与审计同事务保存。
|
||||||
|
- Expo App 位于 apps/mobile:SDK 55、React Native 0.83.10、React 19.2.4、Expo Router 五导航;白底、石墨黑与柔紫。开发 USER 登录、SecureStore 会话恢复/到期清理/服务端退出撤销、真实账户列表/创建/切换/现金展示已接通。
|
||||||
|
- TanStack Query 查询、Zustand 当前账户选择;账户切换中断旧查询并清理详情缓存,退出清理身份缓存,迟到响应不能影响新会话。金额保持十进制字符串。
|
||||||
|
- App 首页、机会、计划、持仓、我的与提醒入口;未接入业务准确标注尚未开放,不展示原型股票或预设盈亏。
|
||||||
|
- OpenAPI、依赖锁文件和带 PostgreSQL 集成测试服务的 CI;CI 增加 App 类型检查、客户端测试与双平台 bundle 导出。
|
||||||
|
- npm --prefix web/admin run build 构建管理端并复制至 embed;go build -o bin/veyra . 生成唯一可执行文件。
|
||||||
|
|
||||||
## 当前工程目录
|
## 当前工程目录
|
||||||
|
|
||||||
- 用户指出旧后端目录过深,当前已改为仓库根目录的单 Go module。
|
- 用户指出旧后端目录过深,当前已改为仓库根目录的单 Go module。
|
||||||
- module 为 git.sundynix.cn/Blizzard/Veyra,与仓库地址一致;删除单模块 go.work。
|
- module 为 git.sundynix.cn/Blizzard/Veyra,与仓库地址一致;删除单模块 go.work。
|
||||||
- cmd/api、cmd/db、cmd/user 放入口与装配。
|
- 用户进一步要求一个 main 函数:三个 cmd 入口已合并到根目录 main.go,初始化操作通过 -command 参数切换,默认启动 HTTP 服务。
|
||||||
- internal/model 放模型,internal/service 放业务,internal/repository 放 SQL 与事务,internal/handler 放 HTTP 路由和协议适配。
|
- internal/model 放模型,internal/service 放业务,internal/repository 放 SQL 与事务,internal/handler 放 HTTP 路由和协议适配。
|
||||||
- internal/config、database、adminui、apperror、ratelimit 放当前实际需要的设施;避免多余嵌套和空目录。
|
- internal/config、database、adminui、apperror、ratelimit 放当前实际需要的设施;避免多余嵌套和空目录。
|
||||||
- 管理端由 apps/admin 迁至 web/admin;embed 构建产物由 internal/adminui/dist 提供。
|
- 管理端由 apps/admin 迁至 web/admin;embed 构建产物由 internal/adminui/dist 提供。App 保留独立 apps/mobile 目录,与后端单 module 无冲突。
|
||||||
- Makefile、CI、npm workspace/锁文件、Go 导入路径与 README 一并更新,原有命令和接口保持一致。
|
- 管理端和 App 各自维护 npm 配置、锁文件与 node_modules;根目录不再使用 npm workspace 或声明 Expo Router。Metro 与 Babel 使用 App 本地依赖。App 固定 React 19.2.4,满足 React Native ^19.2.0 peer,显式排除 SDK 建议补丁版本比较;其余配套检查保留,实际设备验证仍待完成。
|
||||||
|
- Makefile 已移除,CI、npm scripts 与文档均直接使用 Go/npm 命令;原 HTTP 接口保持一致,CLI 统一到根目录。
|
||||||
- 原始设计报告中的 monorepo 目录仅为旧规划,当前实现以本文件、AGENTS.md 和 README 的结构为准。
|
- 原始设计报告中的 monorepo 目录仅为旧规划,当前实现以本文件、AGENTS.md 和 README 的结构为准。
|
||||||
- 迁移文件仅移动路径,已逐字节核对两版 SQL 与原提交一致,不影响已执行迁移的 checksum。
|
- 迁移文件仅移动路径,已逐字节核对两版 SQL 与原提交一致,不影响已执行迁移的 checksum。
|
||||||
- 新结构已通过前端构建与 embed、Go 编译、race 检查和 go vet;真实数据库集成测试仍因连接限制跳过,不将目录迁移视作数据库验证。
|
- 新结构已通过前端构建与 embed、Go 编译、race 检查和 go vet;本轮已补充真实数据库集成验证。
|
||||||
|
|
||||||
## 验证与环境事实
|
## 验证与环境事实
|
||||||
|
|
||||||
- 已通过前端 TypeScript 检查与生产构建、Go 编译、go vet、单元与无监听 HTTP 测试(含 race 检查)。
|
- 当前执行环境已允许访问内网与监听本机端口。PostgreSQL 和 Redis 均已验证可连接;以前的 Operation not permitted 属于旧沙箱限制,不能继续当作当前阻塞。
|
||||||
- OpenAPI YAML 已解析,内部引用检查通过。
|
- 已在 veyra 应用全部十二版迁移,初始化本地环境指定的开发管理员。用户随后提供 LLM 连接信息,已通过后台 API 把 base_url 与加密密钥保存到主模型配置;模型名称仍为空、未启用,备用/审核/行情源未填入用户配置。此前配置表为空是接入前状态,现已变化。
|
||||||
- 数据库集成测试代码包含迁移幂等、checksum、UUID、身份事务、会话撤销/过期、Cookie/Origin 和账户隔离/现金初始化验证。
|
- `go test -race ./...` 已带真实数据库连接执行,在随机临时 schema 中验证迁移幂等/checksum/UUID、身份事务与会话撤销、账户归属与金额、供应商加密保存/密钥保留清除/并发版本冲突/历史审计原子性、配置 API 认证和 Origin 保护。
|
||||||
- 本轮未实际执行数据库集成测试:执行环境不能连接内网;未设置测试数据库连接时这些测试明确 skip,不能报告为已验证数据库行为。
|
- `go vet ./...`、前端 TypeScript 检查与生产构建、Go 入口构建通过,管理端产物已复制至 embed 目录。
|
||||||
- 用户确认本机和数据库处于同一内网,并已在本机终端执行首次建库/迁移。
|
- 实际 HTTP 服务验证:页面 200、readyz 200、管理员登录/配置读取/审计读取/退出均 200。测试未填入供应商或调用外部 AI/行情接口。
|
||||||
- 命令执行沙箱访问 PostgreSQL/Redis 的 TCP 连接返回 errno=1 Operation not permitted,本地监听同样受限。这不表示用户主机网络有问题。
|
- Chrome 已完成候选录入表单、通知任务与行情搜索的页面验证;App 真机与模拟器界面仍未验证。
|
||||||
- 最新第二版迁移与管理员初始化是否在用户终端执行,尚未获得明确确认;不能假定已执行成功。
|
|
||||||
|
|
||||||
## 启动与后续
|
## 启动与后续
|
||||||
|
|
||||||
- 已初始化数据库使用 `make db-migrate` 应用后续迁移,无需重复建库。
|
- 已初始化数据库使用 `go run . -command db-migrate` 应用后续迁移,无需重复建库。
|
||||||
- 管理端初始化:`.env` 的 ADMIN_LOGIN / ADMIN_PASSWORD,执行 `make admin-create`;App 测试身份使用 DEV_USER_LOGIN / DEV_USER_PASSWORD 和 `make user-create`。
|
- 管理端初始化:`configs/.env` 的 ADMIN_LOGIN / ADMIN_PASSWORD,执行 `go run . -command user-create -role ADMIN`;App 测试身份使用 DEV_USER_LOGIN / DEV_USER_PASSWORD 和 `go run . -command user-create -role USER`。
|
||||||
- 本地 `.env` 已生成独立开发身份随机密码;模板无密码,已有身份不会由初始化命令覆盖。
|
- 本地 `configs/.env` 保存开发身份配置和供应商凭据加密主密钥,管理员配置已按用户指示更新;模板不含秘密值,已有身份不会由初始化命令覆盖。加密主密钥须持久保存并随数据库备份,不能直接更换后继续读取旧密文。
|
||||||
- `make api` 先构建前端,再运行后端;默认 http://127.0.0.1:8080。
|
- 先 npm --prefix web/admin run build 构建前端,再 go run . 启动后端;默认 http://127.0.0.1:8080,自动读取当前目录的 configs/.env。
|
||||||
- 当前开发密码登录仅限 local/test;正式短信登录、刷新流程、MFA 和细分 RBAC 待实现。
|
- 当前开发密码登录仅限 local/test;正式短信登录、刷新流程、MFA 和细分 RBAC 待实现。
|
||||||
- 下一步:候选修订与审核发布、计划预览与确认,再实现成交账本、持仓监控和复盘。
|
- 候选、计划、模拟成交、冲正、持仓和累计账本复盘已接通;后续待自动候选筛选及真实规则与授权集成。
|
||||||
- 行情、AI、Worker、真实回测与 App UI 尚未实现;不得把当前状态描述为完整产品已交付。
|
- 自动公开行情、日线查询、Python 历史回测与 Worker 已实现;AI 调用、正式授权行情及完整产品仍未完成。
|
||||||
|
- App 初始化与启动:go run . -command user-create -role USER → 设置 apps/mobile/.env 的 EXPO_PUBLIC_API_URL → npm --prefix apps/mobile run start;npm --prefix apps/mobile run typecheck && npm --prefix apps/mobile run test 检查类型与客户端测试,npm --prefix apps/mobile run build 导出双平台 Hermes bundle。公开环境配置只放 API 地址,不放秘密。
|
||||||
|
|
||||||
## Git 操作请求
|
## Git 状态
|
||||||
|
|
||||||
用户本轮明确要求保存记忆、git add、commit 和 push。目标为当前 master 分支的 origin;操作结果以实际 Git 输出为准,禁止将失败的提交或推送记作已完成。
|
- 本轮开始时已有用户提交 bffdb38(feat: 基础框架搭建完毕),旧文档中“无提交/无 Git 写权限”是历史状态,现已失效。
|
||||||
|
- 本次提交包含供应商后台配置与后续业务实现;推送以 Git 远端记录和提交完成报告为准。
|
||||||
|
|
||||||
本轮操作结果:项目记忆与忽略规则已保存,候选文件检查未发现本地配置的长密码。仓库当前没有提交;git add 因无法创建 .git/index.lock(Operation not permitted)失败,尚未 commit/push。远程分支读取失败,原因是执行环境无法解析远程域名。后续需在有 Git 写权限和网络访问能力的终端完成提交与推送,先确认远程状态;不强制推送、不覆盖远程历史。
|
## 本轮开发状态与待办
|
||||||
|
|
||||||
后续检查发现本地已有 ba6dfff(init: initial commit)等提交,说明前述无提交状态已变化;远程推送状态未经核验。此次目录调整尚未提交,Git 输出中的旧路径删除与新路径新增是迁移表现,提交时应一并暂存。
|
- 用户明确要求供应商选择留空,后续在后台配置,已完成上述配置管理与审计功能;没有预选模型厂商或行情供应商。
|
||||||
|
- 候选审核草稿已整合到当前 model/service/repository/handler 目录,使用第五版迁移并复用原有审计表。
|
||||||
|
- 下一步:候选修订/审核发布 → 计划预算与确认 → 成交账本/冲正/持仓 → 条件提醒与复盘,同时落实 Expo App。
|
||||||
|
- 供应商适配器、服务端连接测试、真实行情与 AI 调用仍待实现;配置页面没有虚构在线测试结果。正式认证、策略回测、Worker/任务、细分 RBAC 仍待完成。
|
||||||
|
|
||||||
## 本轮开发状态
|
## App 本轮验证与待办
|
||||||
|
|
||||||
原计划推进模拟候选修订、审核与发布;用户中途指出目录不合理,本轮优先完成工程结构纠正与回归。候选功能尚未接入仓库交付代码,不新增第三版迁移;相关草稿保留在本地 .cache/candidate-draft(不提交),后续必须按新结构重新审查、实现和测试。下一步仍为候选审核闭环,不能将其描述为已完成。
|
- App USER 测试身份已用本地环境配置创建,未写入客户端或文档凭据;真实账户未注入测试资金。
|
||||||
|
- npm ci 成功,React 补丁版本已与管理端统一,Expo Doctor 20/20 检查通过;React 建议版本比较按前述规则显式排除,其余配套检查保留。客户端 11 项测试通过,覆盖请求鉴权/取消/网络错误、会话变更隔离、安全存储写入顺序与精确金额边界。
|
||||||
|
- Android/iOS Metro 和 Hermes bundle 导出已成功;App 请求客户端对实际 Go API 的登录、身份、账户查询、退出和退出后 401 验证通过。
|
||||||
|
- 本机 xcrun 无 simctl,且无 adb;尚未进行模拟器/真机交互与安全存储实机验证,没有生成 APK 或 IPA。不能将 bundle 导出记作原生包或设备运行验证。
|
||||||
|
- 仍需接入偏好、候选证据、计划预算、成交/冲正、持仓监控、提醒与复盘页面及 API,正式手机号身份、设备推送与原生发布也未完成。
|
||||||
|
- 本轮改动纳入此次用户授权的提交;原始两个设计文件未修改。
|
||||||
|
|
||||||
|
## LLM 连接配置
|
||||||
|
|
||||||
|
- 用户明确要求只保存连接配置,禁止将供应商地址、模型名或密钥写死到代码中;已保存 base_url 与 api_key,密钥经 AES-256-GCM 加密。
|
||||||
|
- 用户最后确认本轮只使用 base_url 和 api_key;未填写模型名称、未改动备用或审核配置、未执行模型调用或连接测试。
|
||||||
|
- 配置保存后通过后台 GET 回读核对版本与 has_api_key,响应不含明文密钥或密文;临时引导环境变量已从本地环境文件清除。文档不记录密钥或真实连接地址。
|
||||||
|
- 第四版迁移仅新增可配置元数据,无供应商地址或模型种子值。后台支持协议、模型目录及响应存储/认证开关,目录地址须与接口同源;这些设置仍属配置管理,调用适配器未实现。
|
||||||
|
- 原消息中的 Codex 网络、WSL、goals 等运行参数不写入产品 LLM 连接配置。本轮不修改 Codex 自身配置。
|
||||||
|
|
||||||
|
## 单入口与管理员设置
|
||||||
|
|
||||||
|
- 用户要求一个 main 函数、不使用 Makefile,已删除 cmd/api、cmd/db、cmd/user 与 Makefile,保留根目录 main.go;仓库仍是根目录单 Go module,不引入 go.work。
|
||||||
|
- 运行 go run . 启动服务;维护操作使用 -command db-create/db-migrate/user-create/admin-set。前端构建使用 npm --prefix web/admin run build,再 go build -o bin/veyra .;IDE 可直接运行 main.go。
|
||||||
|
- 按用户指示修改现有开发管理员的登录名与密码;凭据只保存在本地环境与数据库 bcrypt 哈希中,文档不记录密码。管理员 ID 保持不变,旧会话撤销。
|
||||||
|
- admin-set 仅允许 local/test 与已有 ACTIVE ADMIN,不允许把普通用户提升成管理员;冲突事务回滚。登录创建会话时重新检查已验证的密码哈希并锁定身份,避免凭据修改后的旧验证结果创建新会话。
|
||||||
|
- 开发管理员密码范围为 8–72 字节,App 用户仍要求 12–72 字节;正式认证边界保持不变。
|
||||||
|
|
||||||
|
- 本轮验证:根目录 go run . 成功启动;管理端页面/readyz/新管理员登录/me/供应商配置回读/退出均通过实际 HTTP 校验。单入口 db-migrate 已验证幂等执行。
|
||||||
|
- 带真实 PostgreSQL 临时 schema 的 go test -race ./...、go vet 与唯一二进制编译通过;新增管理员修改测试覆盖身份 ID 保留、原会话失效、登录名冲突回滚、禁止修改普通用户与禁止生产环境使用,以及旧密码验证结果不能创建会话。
|
||||||
|
|
||||||
|
## 工程目录清理
|
||||||
|
|
||||||
|
- 后端环境文件与模板移至 configs/.env、configs/.env.example,配置值及加密主密钥保持原样;唯一入口默认读取新路径,仍支持 -env-file 与进程环境变量。
|
||||||
|
- 删除根目录 package.json、package-lock.json 和 node_modules。web/admin 与 apps/mobile 分别维护独立配置、锁文件和依赖;兼容版本约束下放到各自 package.json。
|
||||||
|
- CI、README、App 说明、AGENTS.md 与未构建管理端的提示已同步更新;构建直接使用 npm --prefix <前端目录> run build。单 Go module、单 main 函数和无 Makefile 保持不变。
|
||||||
|
- 两个前端均已在移除根目录依赖后通过 npm ci;管理端类型检查/生产构建/embed、App 类型检查/11 项测试/iOS 和 Android Hermes bundle 导出通过。Go vet、唯一二进制编译、真实 PostgreSQL 临时 schema 下的 race 测试通过。
|
||||||
|
- 根目录 go run . 已验证自动加载 configs/.env,嵌入页面、PostgreSQL/Redis 就绪、管理员登录、供应商配置回读与退出均通过;测试服务已停止。原始设计文件未修改,本轮改动纳入此次用户授权的提交。
|
||||||
|
- App 安装时 npm audit 报告 35 项依赖问题(14 moderate、21 high);未在目录清理中强制升级 Expo 依赖,后续需单独处理并复验原生兼容性。
|
||||||
|
|
||||||
|
## 模拟交易闭环(本轮进行中)
|
||||||
|
|
||||||
|
- 已实现第五至第八版迁移,第五至第八版已应用开发数据库;当前总计十二版;原始迁移保留 checksum,不修改已经执行的版本。
|
||||||
|
- 候选审核:手工模拟候选录入、独立修订、提交审核、发布、退回、撤回、时效与版本保护。发布内容保留历史,App 仅能访问曾发布修订;有效列表仅显示当前未过期发布。App 可查看撤回原因,内部审核身份与完整意见列表不返回 App。
|
||||||
|
- 计划:模拟账户买入预算预览、确认重检候选版本与时效、现金争用保护、同一预览幂等确认、取消、失效与预算释放。预览五分钟内有效。当前模拟数量规则为 100 股倍数,不代表完整真实交易所规则。
|
||||||
|
- 成交账本:模拟买入/卖出录入、实际费用、发生时间顺序与幂等保护、部分成交与计划保留额度、FIFO 批次及成本分摊、含费用已实现盈亏、最新未冲正成交的冲正。数据库触发器禁止覆盖或删除已入账成交、现金流水、分摊与冲正事实;冲正保留原记录,不自动重开原计划。
|
||||||
|
- 可卖时间由模拟成交明确输入,不根据自然日冒充交易日历或真实 T+1。真实记录账户的成交账本仍未接通,市场价格、浮盈亏与市值保持未知。
|
||||||
|
- 通知:同一后端进程中的轮询 Worker,Outbox 事务处理、手工模拟价格回放、当前快照/时效/候选/计划重检、每计划一次条件提醒、撤回或过期失效通知、本人站内列表与已读。远程推送未实现;提醒不产生交易或现金变动。
|
||||||
|
- 管理端候选审核、通知任务、数据健康中的模拟价格回放已替代占位;App 候选/证据/预算/计划/成交/持仓/冲正/站内通知及累计账本复盘已接通。
|
||||||
|
- 真实 PostgreSQL 临时 schema 下的 race 测试通过:候选并发审核、历史保护、发布与事件失败回滚、计划现金争用与取消、部分成交、偏离说明、冲正不重开计划、FIFO 舍入、重复请求、冲正失败回滚、不可覆盖成交、模拟可卖时间、过期快照、不重复提醒、提醒不改账本与复盘金额。
|
||||||
|
- 管理端类型检查/生产构建/embed、App 类型检查/12 项客户端测试/iOS 与 Android Hermes 导出、go vet/唯一二进制编译已通过;后续代码变更按需复验。
|
||||||
|
- 本机 8080 由用户 IDE 的旧进程占用,没有关闭用户进程。当前自有验证服务运行 8081;新增管理端与 App HTTP 读接口及登录/退出已通过,不向开发账户插入候选、资金或成交。
|
||||||
|
- 本轮 Chrome 已可用,实际验证新版候选队列、空白录入表单、通知任务与模拟价格入口,未保存任何业务数据。先前无浏览器是历史环境事实;尚未验证 App 真机/模拟器。
|
||||||
|
- 待继续:策略版本管理、Python 历史回测、自动行情与供应商调用、偏好与关注、生产服务授权及正式认证/远程推送。本轮改动纳入此次用户授权的提交。
|
||||||
|
|
||||||
|
|
||||||
|
## 本轮完成:日志、研究与 A 股自动接入
|
||||||
|
|
||||||
|
- 按用户要求引入 zap;控制台使用易读编码,文件为 JSON,默认写入当前工作目录 logs/veyra.log。50 MB 轮转、保留 10 个备份及 14 天、压缩历史;LOG_DIR/LOG_LEVEL/LOG_MAX_SIZE_MB/LOG_MAX_BACKUPS/LOG_MAX_AGE_DAYS 可在配置文件修改。文件初建权限 0600,日志忽略 Git,不记录口令、请求正文、Cookie 或原始查询串。
|
||||||
|
- 策略中心支持新建研究策略、不可覆盖版本、选用研究版本、暂停和审计。研究版本管理尚未控制手工候选或既有计划,不冒充已部署自动交易策略。开发数据库已保存一个暂停的均线研究基线,可在后台修订;该基线没有性能背书。
|
||||||
|
- Python 回测使用固定策略版本、参数、输入 SHA-256 和原始数据快照。异步任务带独立 lease、过期恢复、完成保护和失败状态;使用此前完成日线生成信号、次日开盘模拟成交,整数价格/费用/滑点,保存权益序列和成本后成交结果。未实现复权企业行为、停牌、涨跌停、交易日历成交限制或样本外研究。
|
||||||
|
- 用户明确要求自动 A 股接入,已增加可配置公开行情适配器。东方财富接口首次读取得到数据,后续连接关闭;未把失败当成同步成功。现开发配置采用新浪公开接口,全部地址保存后台配置,免账号;东方财富适配器仍可选。没有静默运行时切源或覆盖失败事实。
|
||||||
|
- 自动同步沪深证券列表和有效报价,启动执行、每五分钟更新;配置变更十秒内检测,保存源配置版本、供应商行情时间、接收时间与同步记录。首次成功写入 5213 条沪深有效报价,不含北交所,不代表全部证券主数据或正式授权行情。API 查询默认最多 100 条,支持名称/代码搜索。
|
||||||
|
- 后台数据健康显示股票与同步结果;系统设置可修改适配器及列表/报价/历史地址、启停。App 机会页有 A 股行情入口,支持搜索与明确的历史快照时间;不把接收时间伪装成行情产生时间。
|
||||||
|
- 回测默认按股票、交易所与日期自动获取日线,CSV 作为复验入口保留。实际从新浪接口读取一个标的的 2025 年一季度 57 根不复权日线,回测任务完成并得到 3 条模拟成交;没有产生真实交易、资金或账户持仓。
|
||||||
|
- 真实 PostgreSQL 临时 schema 的 race 测试、行情精度/供应商时间/非法 OHLC/不可舍入价格测试、Python 无未来数据与费用计算测试通过;go vet、唯一二进制、管理端类型检查/构建/embed、App 类型检查与双平台 Hermes 导出通过。App 客户端 12 项测试通过。
|
||||||
|
- 开发预览后端保留在 0.0.0.0:8083;管理端 http://127.0.0.1:8083。App 本地公开配置为 http://192.168.100.2:8083,Metro 为 exp://192.168.100.2:8081,使用同一内网 Expo Go 查看。固定了 Metro 的网卡选择,避免发布 VPN 虚拟地址。本地常规 HTTP_ADDR 已设 0.0.0.0:8080,需要用户 IDE 重启才读取新代码;未关闭用户 IDE 进程。
|
||||||
|
- 公开源仅为开发研究接入,缺少官方服务保障和商业展示授权确认。行情自动同步还没有驱动自动候选筛选、AI 解释或计划条件触发;现有提醒仍由明确标记的模拟价格回放驱动。
|
||||||
|
- 用户要求“做完这个功能就先停止”,本轮在 A 股自动接入验证后停止开发,预览服务保留。后续待自动筛选/审核、AI 分析、偏好与关注、生产授权、正式认证和远程推送;不自动继续扩展。本轮改动纳入此次用户授权的提交,原始两份设计文件未修改。
|
||||||
|
|
||||||
|
|
||||||
|
## 本轮提交准备
|
||||||
|
|
||||||
|
- 用户已明确要求提交并 push,本次提交包含工程目录整理、后台配置、App 模拟业务闭环、zap 日志、策略与回测及 A 股自动接入。验证状态和未完成能力以上文最新记录为准。
|
||||||
|
- 本地环境、密钥、日志、依赖目录和构建产物不纳入提交;原始设计文件保持不变。目标分支为 master,远端为 origin。
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/redis/go-redis/v9 v9.18.0
|
github.com/redis/go-redis/v9 v9.18.0
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -40,6 +42,7 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
|||||||
@@ -93,8 +93,14 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
@@ -111,6 +117,8 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
|||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func Handler() http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := fs.Stat(root, "index.html"); err != nil {
|
if _, err := fs.Stat(root, "index.html"); err != nil {
|
||||||
http.Error(w, "Management console not built. Run make admin-build, then rebuild the API.", http.StatusServiceUnavailable)
|
http.Error(w, "Management console not built. Run npm --prefix web/admin run build, then rebuild the Go executable.", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if name != "" && name != "." {
|
if name != "" && name != "." {
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
// Package apperror defines safe application errors shared across module boundaries.
|
// Package apperror defines safe application errors shared across module boundaries.
|
||||||
package apperror
|
package apperror
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidInput = errors.New("invalid input")
|
ErrInvalidInput = errors.New("invalid input")
|
||||||
ErrUnauthenticated = errors.New("authentication required")
|
ErrUnauthenticated = errors.New("authentication required")
|
||||||
ErrForbidden = errors.New("permission denied")
|
ErrForbidden = errors.New("permission denied")
|
||||||
ErrConflict = errors.New("already exists")
|
ErrConflict = errors.New("already exists")
|
||||||
|
ErrInsufficientCash = fmt.Errorf("insufficient available cash: %w", ErrConflict)
|
||||||
|
ErrInsufficientQuantity = fmt.Errorf("insufficient sellable quantity: %w", ErrConflict)
|
||||||
|
ErrCandidateUnavailable = fmt.Errorf("candidate unavailable: %w", ErrConflict)
|
||||||
|
ErrPreviewExpired = fmt.Errorf("preview expired: %w", ErrConflict)
|
||||||
|
ErrChronology = fmt.Errorf("fill chronology conflict: %w", ErrConflict)
|
||||||
|
ErrDependentFill = fmt.Errorf("later fill depends on this record: %w", ErrConflict)
|
||||||
ErrNotFound = errors.New("not found")
|
ErrNotFound = errors.New("not found")
|
||||||
ErrUnavailable = errors.New("service unavailable")
|
ErrUnavailable = errors.New("service unavailable")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/logging"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,9 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
PythonExecutable string
|
||||||
|
Logging logging.Options
|
||||||
Environment, HTTPAddr string
|
Environment, HTTPAddr string
|
||||||
PGHost, PGPort, PGDatabase, PGUser, PGPassword, PGSSLMode, PGAdminDatabase string
|
PGHost, PGPort, PGDatabase, PGUser, PGPassword, PGSSLMode, PGAdminDatabase string
|
||||||
RedisAddr, RedisUsername, RedisPassword string
|
RedisAddr, RedisUsername, RedisPassword string
|
||||||
|
SecretEncryptionKey string
|
||||||
RedisDB int
|
RedisDB int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +61,18 @@ func LoadEnvFile(path string) error {
|
|||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
c := Config{Environment: env("APP_ENV", "local"), HTTPAddr: env("HTTP_ADDR", "127.0.0.1:8080"), PGHost: env("PGHOST", "192.168.100.127"), PGPort: env("PGPORT", "5432"), PGDatabase: env("PGDATABASE", "veyra"), PGUser: os.Getenv("PGUSER"), PGPassword: os.Getenv("PGPASSWORD"), PGSSLMode: env("PGSSLMODE", "disable"), PGAdminDatabase: env("PGADMIN_DATABASE", "postgres"), RedisAddr: env("REDIS_ADDR", "192.168.100.127:6379"), RedisUsername: os.Getenv("REDIS_USERNAME"), RedisPassword: os.Getenv("REDIS_PASSWORD")}
|
c := Config{Environment: env("APP_ENV", "local"), HTTPAddr: env("HTTP_ADDR", "127.0.0.1:8080"), PGHost: env("PGHOST", "192.168.100.127"), PGPort: env("PGPORT", "5432"), PGDatabase: env("PGDATABASE", "veyra"), PGUser: os.Getenv("PGUSER"), PGPassword: os.Getenv("PGPASSWORD"), PGSSLMode: env("PGSSLMODE", "disable"), PGAdminDatabase: env("PGADMIN_DATABASE", "postgres"), RedisAddr: env("REDIS_ADDR", "192.168.100.127:6379"), RedisUsername: os.Getenv("REDIS_USERNAME"), RedisPassword: os.Getenv("REDIS_PASSWORD")}
|
||||||
|
c.PythonExecutable = env("PYTHON_EXECUTABLE", "python3")
|
||||||
|
c.Logging = logging.Options{Directory: env("LOG_DIR", "logs"), Level: env("LOG_LEVEL", "info"), MaxSizeMB: 50, MaxBackups: 10, MaxAgeDays: 14}
|
||||||
|
for key, dest := range map[string]*int{"LOG_MAX_SIZE_MB": &c.Logging.MaxSizeMB, "LOG_MAX_BACKUPS": &c.Logging.MaxBackups, "LOG_MAX_AGE_DAYS": &c.Logging.MaxAgeDays} {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return Config{}, fmt.Errorf("%s must be a positive integer", key)
|
||||||
|
}
|
||||||
|
*dest = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.SecretEncryptionKey = os.Getenv("SECRET_ENCRYPTION_KEY")
|
||||||
var err error
|
var err error
|
||||||
c.RedisDB, err = strconv.Atoi(env("REDIS_DB", "0"))
|
c.RedisDB, err = strconv.Atoi(env("REDIS_DB", "0"))
|
||||||
if err != nil || c.RedisDB < 0 {
|
if err != nil || c.RedisDB < 0 {
|
||||||
@@ -82,7 +98,7 @@ func Load() (Config, error) {
|
|||||||
|
|
||||||
func (c Config) ValidateDatabase() error {
|
func (c Config) ValidateDatabase() error {
|
||||||
if c.PGUser == "" {
|
if c.PGUser == "" {
|
||||||
return fmt.Errorf("PGUSER is required; configure the local .env file")
|
return fmt.Errorf("PGUSER is required; configure the local configs/.env file")
|
||||||
}
|
}
|
||||||
if c.PGHost == "" || c.PGDatabase == "" || c.PGAdminDatabase == "" {
|
if c.PGHost == "" || c.PGDatabase == "" || c.PGAdminDatabase == "" {
|
||||||
return fmt.Errorf("database host and names are required")
|
return fmt.Errorf("database host and names are required")
|
||||||
|
|||||||
@@ -48,8 +48,12 @@ func TestMigrationIntegration(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
files, err := migrations.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
var count int
|
var count int
|
||||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_schema_migration").Scan(&count); err != nil || count != 2 {
|
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_schema_migration").Scan(&count); err != nil || count != len(files) {
|
||||||
t.Fatalf("migration ledger count=%d err=%v", count, err)
|
t.Fatalf("migration ledger count=%d err=%v", count, err)
|
||||||
}
|
}
|
||||||
if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES($1,$2)", uuid.NewString(), "Test"); err != nil {
|
if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES($1,$2)", uuid.NewString(), "Test"); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE veyra_provider_config (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
config_key varchar(32) NOT NULL UNIQUE CHECK (config_key IN ('model_primary','model_backup','market_primary')),
|
||||||
|
name varchar(80) NOT NULL,
|
||||||
|
base_url text NOT NULL DEFAULT '',
|
||||||
|
model_name varchar(120) NOT NULL DEFAULT '',
|
||||||
|
enabled boolean NOT NULL DEFAULT false,
|
||||||
|
timeout_seconds integer NOT NULL DEFAULT 15 CHECK (timeout_seconds BETWEEN 1 AND 30),
|
||||||
|
encrypted_api_key bytea,
|
||||||
|
version bigint NOT NULL CHECK (version > 0),
|
||||||
|
last_test_status varchar(24) NOT NULL DEFAULT 'NOT_TESTED' CHECK (last_test_status IN ('NOT_CONFIGURED','NOT_TESTED','READY','FAILED')),
|
||||||
|
last_tested_at timestamptz,
|
||||||
|
last_test_message varchar(200) NOT NULL DEFAULT '',
|
||||||
|
updated_by varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE veyra_provider_config_history (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
config_id varchar(36) NOT NULL REFERENCES veyra_provider_config(id),
|
||||||
|
version bigint NOT NULL CHECK (version > 0),
|
||||||
|
snapshot jsonb NOT NULL,
|
||||||
|
encrypted_api_key bytea,
|
||||||
|
actor_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (config_id, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE veyra_audit_event (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
actor_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||||
|
action varchar(40) NOT NULL,
|
||||||
|
object_type varchar(40) NOT NULL,
|
||||||
|
object_id varchar(36) NOT NULL CHECK (object_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
request_id varchar(36) NOT NULL CHECK (request_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
details jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX veyra_audit_event_created_at_idx ON veyra_audit_event(created_at DESC, id DESC);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE veyra_provider_config
|
||||||
|
DROP CONSTRAINT veyra_provider_config_config_key_check,
|
||||||
|
ADD CONSTRAINT veyra_provider_config_config_key_check
|
||||||
|
CHECK (config_key IN ('model_primary','model_backup','model_review','market_primary')),
|
||||||
|
ADD COLUMN wire_api varchar(32) NOT NULL DEFAULT 'responses'
|
||||||
|
CHECK (wire_api IN ('responses','chat_completions')),
|
||||||
|
ADD COLUMN model_catalog_url text NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN disable_response_storage boolean NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN requires_openai_auth boolean NOT NULL DEFAULT true;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
CREATE TABLE veyra_candidate (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
latest_revision integer NOT NULL CHECK (latest_revision > 0),
|
||||||
|
published_revision_id varchar(36),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE veyra_candidate_revision (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
candidate_id varchar(36) NOT NULL REFERENCES veyra_candidate(id),
|
||||||
|
revision integer NOT NULL CHECK (revision > 0),
|
||||||
|
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||||
|
status varchar(24) NOT NULL CHECK (status IN ('DRAFT', 'PENDING_REVIEW', 'PUBLISHED', 'RETURNED', 'WITHDRAWN')),
|
||||||
|
data_mode varchar(16) NOT NULL CHECK (data_mode = 'simulation'),
|
||||||
|
snapshot jsonb NOT NULL CHECK (jsonb_typeof(snapshot) = 'object'),
|
||||||
|
author_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||||
|
observed_at timestamptz NOT NULL,
|
||||||
|
valid_until timestamptz NOT NULL,
|
||||||
|
published_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (valid_until > observed_at),
|
||||||
|
UNIQUE (candidate_id, revision),
|
||||||
|
UNIQUE (candidate_id, id)
|
||||||
|
);
|
||||||
|
ALTER TABLE veyra_candidate ADD CONSTRAINT veyra_candidate_published_revision_fk
|
||||||
|
FOREIGN KEY (id, published_revision_id) REFERENCES veyra_candidate_revision(candidate_id, id);
|
||||||
|
CREATE INDEX veyra_candidate_revision_queue_idx ON veyra_candidate_revision(status, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE veyra_candidate_review (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
revision_id varchar(36) NOT NULL REFERENCES veyra_candidate_revision(id),
|
||||||
|
reviewer_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||||
|
decision varchar(16) NOT NULL CHECK (decision IN ('SUBMIT', 'PUBLISH', 'RETURN', 'WITHDRAW')),
|
||||||
|
input_version bigint NOT NULL CHECK (input_version > 0),
|
||||||
|
reason text NOT NULL CHECK (length(trim(reason)) BETWEEN 1 AND 1000),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (revision_id, input_version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE veyra_outbox_event (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
aggregate_type varchar(40) NOT NULL,
|
||||||
|
aggregate_id varchar(36) NOT NULL CHECK (aggregate_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
aggregate_version bigint NOT NULL CHECK (aggregate_version > 0),
|
||||||
|
event_type varchar(64) NOT NULL,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
processed_at timestamptz,
|
||||||
|
UNIQUE (aggregate_id, aggregate_version, event_type)
|
||||||
|
);
|
||||||
|
CREATE INDEX veyra_outbox_event_pending_idx ON veyra_outbox_event(created_at, id) WHERE processed_at IS NULL;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE veyra_plan_preview (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
|
||||||
|
candidate_revision_id varchar(36) NOT NULL REFERENCES veyra_candidate_revision(id),
|
||||||
|
candidate_version bigint NOT NULL CHECK (candidate_version > 0),
|
||||||
|
quantity bigint NOT NULL CHECK (quantity > 0 AND quantity <= 1000000 AND quantity % 100 = 0),
|
||||||
|
fee_budget_minor bigint NOT NULL CHECK (fee_budget_minor >= 0),
|
||||||
|
reserved_minor bigint NOT NULL CHECK (reserved_minor > 0 AND reserved_minor <= 100000000000),
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE veyra_trade_plan (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
preview_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_plan_preview(id),
|
||||||
|
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
|
||||||
|
candidate_revision_id varchar(36) NOT NULL REFERENCES veyra_candidate_revision(id),
|
||||||
|
candidate_version bigint NOT NULL CHECK (candidate_version > 0),
|
||||||
|
quantity bigint NOT NULL CHECK (quantity > 0 AND quantity <= 1000000),
|
||||||
|
filled_quantity bigint NOT NULL DEFAULT 0 CHECK (filled_quantity >= 0 AND filled_quantity <= quantity),
|
||||||
|
fee_budget_minor bigint NOT NULL CHECK (fee_budget_minor >= 0),
|
||||||
|
reserved_minor bigint NOT NULL CHECK (reserved_minor >= 0 AND reserved_minor <= 100000000000),
|
||||||
|
status varchar(24) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE','PART_FILLED','COMPLETED','CANCELLED','INVALIDATED')),
|
||||||
|
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||||
|
valid_until timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX veyra_trade_plan_account_idx ON veyra_trade_plan(account_id,status);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_cash_entry_entry_type_check;
|
||||||
|
ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_cash_entry_amount_minor_check;
|
||||||
|
ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_cash_entry_account_id_entry_type_key;
|
||||||
|
ALTER TABLE veyra_cash_entry ADD CONSTRAINT veyra_cash_entry_entry_type_check CHECK(entry_type IN ('INITIAL','BUY','SELL','REVERSAL'));
|
||||||
|
ALTER TABLE veyra_cash_entry ADD CONSTRAINT veyra_cash_entry_amount_minor_check CHECK(amount_minor BETWEEN -100000000000 AND 100000000000);
|
||||||
|
CREATE UNIQUE INDEX veyra_cash_entry_initial_idx ON veyra_cash_entry(account_id) WHERE entry_type='INITIAL';
|
||||||
|
CREATE TABLE veyra_trade_fill (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
|
||||||
|
plan_id varchar(36) REFERENCES veyra_trade_plan(id),
|
||||||
|
idempotency_key varchar(36) NOT NULL CHECK (idempotency_key ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
input_hash varchar(64) NOT NULL,
|
||||||
|
posted_sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
|
||||||
|
side varchar(4) NOT NULL CHECK(side IN ('BUY','SELL')),
|
||||||
|
symbol varchar(6) NOT NULL CHECK(symbol ~ '^[0-9]{6}$'),
|
||||||
|
exchange varchar(4) NOT NULL CHECK(exchange IN ('SSE','SZSE')),
|
||||||
|
name varchar(80) NOT NULL,
|
||||||
|
quantity bigint NOT NULL CHECK(quantity BETWEEN 1 AND 1000000),
|
||||||
|
price_minor bigint NOT NULL CHECK(price_minor>0 AND price_minor<=100000000000),
|
||||||
|
fee_minor bigint NOT NULL CHECK(fee_minor>=0 AND fee_minor<=100000000000),
|
||||||
|
cash_delta_minor bigint NOT NULL CHECK(cash_delta_minor BETWEEN -100000000000 AND 100000000000),
|
||||||
|
realized_minor bigint NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
sellable_at timestamptz,
|
||||||
|
note text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK(side='SELL' OR sellable_at>=occurred_at),
|
||||||
|
UNIQUE(account_id,idempotency_key)
|
||||||
|
);
|
||||||
|
CREATE TABLE veyra_position_lot (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
|
||||||
|
buy_fill_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_trade_fill(id),
|
||||||
|
symbol varchar(6) NOT NULL,
|
||||||
|
exchange varchar(4) NOT NULL,
|
||||||
|
remaining_quantity bigint NOT NULL CHECK(remaining_quantity>=0),
|
||||||
|
remaining_cost_minor bigint NOT NULL CHECK(remaining_cost_minor>=0),
|
||||||
|
sellable_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX veyra_position_lot_account_idx ON veyra_position_lot(account_id,exchange,symbol,sellable_at);
|
||||||
|
CREATE TABLE veyra_fill_allocation (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
sell_fill_id varchar(36) NOT NULL REFERENCES veyra_trade_fill(id),
|
||||||
|
lot_id varchar(36) NOT NULL REFERENCES veyra_position_lot(id),
|
||||||
|
quantity bigint NOT NULL CHECK(quantity>0),
|
||||||
|
cost_minor bigint NOT NULL CHECK(cost_minor>=0),
|
||||||
|
UNIQUE(sell_fill_id,lot_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE veyra_trade_reversal (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
fill_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_trade_fill(id),
|
||||||
|
actor_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||||
|
reason text NOT NULL CHECK(length(trim(reason)) BETWEEN 1 AND 1000),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
ALTER TABLE veyra_cash_entry ADD COLUMN fill_id varchar(36) REFERENCES veyra_trade_fill(id);
|
||||||
|
ALTER TABLE veyra_cash_entry ADD COLUMN reversal_id varchar(36) REFERENCES veyra_trade_reversal(id);
|
||||||
|
CREATE UNIQUE INDEX veyra_cash_entry_fill_idx ON veyra_cash_entry(fill_id) WHERE fill_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX veyra_cash_entry_reversal_idx ON veyra_cash_entry(reversal_id) WHERE reversal_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE FUNCTION veyra_reject_fact_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'posted facts are immutable; use a reversal';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
CREATE TRIGGER veyra_trade_fill_immutable BEFORE UPDATE OR DELETE ON veyra_trade_fill FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||||
|
CREATE TRIGGER veyra_cash_entry_immutable BEFORE UPDATE OR DELETE ON veyra_cash_entry FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||||
|
CREATE TRIGGER veyra_fill_allocation_immutable BEFORE UPDATE OR DELETE ON veyra_fill_allocation FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||||
|
CREATE TRIGGER veyra_trade_reversal_immutable BEFORE UPDATE OR DELETE ON veyra_trade_reversal FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||||
|
CREATE TRIGGER veyra_candidate_review_immutable BEFORE UPDATE OR DELETE ON veyra_candidate_review FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
CREATE TABLE veyra_quote_snapshot (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
symbol varchar(6) NOT NULL CHECK (symbol ~ '^[0-9]{6}$'),
|
||||||
|
exchange varchar(4) NOT NULL CHECK (exchange IN ('SSE','SZSE')),
|
||||||
|
data_mode varchar(16) NOT NULL CHECK (data_mode='simulation'),
|
||||||
|
price_minor bigint NOT NULL CHECK (price_minor>0 AND price_minor<=100000000000),
|
||||||
|
observed_at timestamptz NOT NULL,
|
||||||
|
version bigint NOT NULL CHECK (version>0),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(exchange,symbol,data_mode)
|
||||||
|
);
|
||||||
|
CREATE TABLE veyra_notification (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
user_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||||
|
event_id varchar(36) NOT NULL REFERENCES veyra_outbox_event(id),
|
||||||
|
plan_id varchar(36) REFERENCES veyra_trade_plan(id),
|
||||||
|
title varchar(120) NOT NULL,
|
||||||
|
body text NOT NULL,
|
||||||
|
read_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(user_id,event_id,plan_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX veyra_notification_user_idx ON veyra_notification(user_id,created_at DESC);
|
||||||
|
CREATE TABLE veyra_plan_trigger (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
plan_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_trade_plan(id),
|
||||||
|
quote_snapshot_id varchar(36) NOT NULL REFERENCES veyra_quote_snapshot(id),
|
||||||
|
quote_version bigint NOT NULL CHECK(quote_version>0),
|
||||||
|
triggered_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX veyra_notification_unread_idx ON veyra_notification(user_id) WHERE read_at IS NULL;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE veyra_strategy (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
code varchar(64) NOT NULL UNIQUE CHECK (code ~ '^[a-z][a-z0-9_]{2,63}$'),
|
||||||
|
name varchar(80) NOT NULL,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'PAUSED' CHECK(status IN ('ACTIVE','PAUSED')),
|
||||||
|
latest_revision integer NOT NULL DEFAULT 1 CHECK(latest_revision>0),
|
||||||
|
active_version_id varchar(36),
|
||||||
|
version bigint NOT NULL DEFAULT 1 CHECK(version>0),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE veyra_strategy_version (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
strategy_id varchar(36) NOT NULL REFERENCES veyra_strategy(id),
|
||||||
|
revision integer NOT NULL CHECK(revision>0),
|
||||||
|
algorithm varchar(32) NOT NULL CHECK(algorithm='sma_cross'),
|
||||||
|
parameters jsonb NOT NULL,
|
||||||
|
description text NOT NULL,
|
||||||
|
author_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(strategy_id,revision),
|
||||||
|
UNIQUE(strategy_id,id)
|
||||||
|
);
|
||||||
|
ALTER TABLE veyra_strategy ADD CONSTRAINT veyra_strategy_active_version_fk FOREIGN KEY(id,active_version_id) REFERENCES veyra_strategy_version(strategy_id,id);
|
||||||
|
CREATE TRIGGER veyra_strategy_version_immutable BEFORE UPDATE OR DELETE ON veyra_strategy_version FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE veyra_backtest_run (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK(id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
strategy_version_id varchar(36) NOT NULL REFERENCES veyra_strategy_version(id),
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING','RUNNING','COMPLETED','FAILED')),
|
||||||
|
input jsonb NOT NULL,
|
||||||
|
input_hash varchar(64) NOT NULL,
|
||||||
|
result jsonb,
|
||||||
|
failure text NOT NULL DEFAULT '',
|
||||||
|
lease_id varchar(36) CHECK(lease_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
lease_expires_at timestamptz,
|
||||||
|
author_id varchar(36) NOT NULL REFERENCES veyra_user(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
finished_at timestamptz,
|
||||||
|
CHECK((status='RUNNING') = (lease_id IS NOT NULL AND lease_expires_at IS NOT NULL)),
|
||||||
|
CHECK((status IN ('COMPLETED','FAILED')) = (finished_at IS NOT NULL)),
|
||||||
|
CHECK((status='COMPLETED') = (result IS NOT NULL))
|
||||||
|
);
|
||||||
|
CREATE INDEX veyra_backtest_run_queue_idx ON veyra_backtest_run(status,created_at);
|
||||||
|
CREATE FUNCTION veyra_protect_backtest_run() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='DELETE' OR OLD.status IN ('COMPLETED','FAILED') THEN RAISE EXCEPTION 'immutable research result'; END IF;
|
||||||
|
IF NEW.id<>OLD.id OR NEW.strategy_version_id<>OLD.strategy_version_id OR NEW.input<>OLD.input OR NEW.input_hash<>OLD.input_hash OR NEW.author_id<>OLD.author_id OR NEW.created_at<>OLD.created_at THEN RAISE EXCEPTION 'immutable research input'; END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
CREATE TRIGGER veyra_backtest_run_protected BEFORE UPDATE OR DELETE ON veyra_backtest_run FOR EACH ROW EXECUTE FUNCTION veyra_protect_backtest_run();
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
ALTER TABLE veyra_provider_config ADD COLUMN market_adapter varchar(32) NOT NULL DEFAULT '' CHECK(market_adapter IN ('','eastmoney_public')), ADD COLUMN history_base_url text NOT NULL DEFAULT '';
|
||||||
|
CREATE TABLE veyra_instrument (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK(id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
symbol varchar(6) NOT NULL CHECK(symbol ~ '^[0-9]{6}$'),
|
||||||
|
exchange varchar(8) NOT NULL CHECK(exchange IN ('SSE','SZSE')),
|
||||||
|
name varchar(80) NOT NULL,
|
||||||
|
price_cents bigint NOT NULL CHECK(price_cents>=0),
|
||||||
|
observed_at timestamptz NOT NULL,
|
||||||
|
received_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
source_id varchar(36) NOT NULL REFERENCES veyra_provider_config(id),
|
||||||
|
source_version bigint NOT NULL CHECK(source_version>0),
|
||||||
|
UNIQUE(exchange,symbol)
|
||||||
|
);
|
||||||
|
CREATE TABLE veyra_market_sync (
|
||||||
|
id varchar(36) PRIMARY KEY CHECK(id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
|
||||||
|
source_id varchar(36) NOT NULL REFERENCES veyra_provider_config(id),
|
||||||
|
source_version bigint NOT NULL CHECK(source_version>0),
|
||||||
|
instrument_count integer NOT NULL CHECK(instrument_count>=0),
|
||||||
|
status varchar(16) NOT NULL CHECK(status IN ('COMPLETED','FAILED')),
|
||||||
|
message text NOT NULL DEFAULT '',
|
||||||
|
started_at timestamptz NOT NULL,
|
||||||
|
finished_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX veyra_instrument_symbol_idx ON veyra_instrument(symbol);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE veyra_provider_config DROP CONSTRAINT veyra_provider_config_market_adapter_check, ADD CONSTRAINT veyra_provider_config_market_adapter_check CHECK(market_adapter IN ('','eastmoney_public','sina_public')), ADD COLUMN quote_base_url text NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindBacktests(r *gin.Engine, o IdentityOptions) {
|
||||||
|
g := r.Group("/api/admin/v1/backtests", func(c *gin.Context) { authenticate(c, o, "admin") })
|
||||||
|
g.Use(func(c *gin.Context) {
|
||||||
|
if o.Backtests == nil {
|
||||||
|
fail(c, apperror.ErrUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
g.GET("", func(c *gin.Context) {
|
||||||
|
v, e := o.Backtests.List(c.Request.Context(), currentUser(c))
|
||||||
|
if e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, v)
|
||||||
|
})
|
||||||
|
g.GET("/:id", func(c *gin.Context) {
|
||||||
|
v, e := o.Backtests.Detail(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||||
|
if e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, v)
|
||||||
|
})
|
||||||
|
g.POST("", func(c *gin.Context) {
|
||||||
|
if !sameOrigin(c.Request) {
|
||||||
|
fail(c, apperror.ErrForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input model.BacktestInput
|
||||||
|
if !decodeLimit(c, &input, 256<<10) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := o.Backtests.Create(c.Request.Context(), currentUser(c), input, c.Writer.Header().Get("X-Request-ID"))
|
||||||
|
if e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusAccepted, v)
|
||||||
|
})
|
||||||
|
g.POST("/from-market", func(c *gin.Context) {
|
||||||
|
if !sameOrigin(c.Request) {
|
||||||
|
fail(c, apperror.ErrForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input struct {
|
||||||
|
StrategyVersionID string `json:"strategy_version_id"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Exchange string `json:"exchange"`
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end"`
|
||||||
|
InitialCash string `json:"initial_cash"`
|
||||||
|
}
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := o.Backtests.CreateMarket(c.Request.Context(), currentUser(c), input.StrategyVersionID, input.Symbol, input.Exchange, input.Start, input.End, input.InitialCash, c.Writer.Header().Get("X-Request-ID"))
|
||||||
|
if e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusAccepted, v)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindCandidates(r *gin.Engine, options IdentityOptions) {
|
||||||
|
for _, admin := range []bool{true, false} {
|
||||||
|
path, audience := "/api/v1", "app"
|
||||||
|
if admin {
|
||||||
|
path, audience = "/api/admin/v1", "admin"
|
||||||
|
}
|
||||||
|
group := r.Group(path, func(c *gin.Context) { authenticate(c, options, audience) })
|
||||||
|
group.Use(func(c *gin.Context) {
|
||||||
|
if options.Candidates == nil {
|
||||||
|
fail(c, apperror.ErrUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
group.GET("/candidates", func(c *gin.Context) {
|
||||||
|
limit := 100
|
||||||
|
if raw := c.Query("limit"); raw != "" {
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, apperror.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = value
|
||||||
|
}
|
||||||
|
result, err := options.Candidates.List(c.Request.Context(), currentUser(c), admin, c.Query("status"), limit)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.GET("/candidates/:id", func(c *gin.Context) {
|
||||||
|
result, err := options.Candidates.Detail(c.Request.Context(), currentUser(c), c.Param("id"), admin)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
if !admin {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group.Use(func(c *gin.Context) {
|
||||||
|
if !sameOrigin(c.Request) {
|
||||||
|
fail(c, apperror.ErrForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
group.POST("/candidates", func(c *gin.Context) {
|
||||||
|
var input model.CandidateDraftInput
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Candidates.Create(c.Request.Context(), currentUser(c), "", c.Writer.Header().Get("X-Request-ID"), input, 0)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusCreated, result)
|
||||||
|
})
|
||||||
|
group.POST("/candidates/:id/revisions", func(c *gin.Context) {
|
||||||
|
var input model.CandidateRevisionInput
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Candidates.Create(c.Request.Context(), currentUser(c), c.Param("id"), c.Writer.Header().Get("X-Request-ID"), input.CandidateDraftInput, input.ExpectedVersion)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusCreated, result)
|
||||||
|
})
|
||||||
|
for endpoint, action := range map[string]string{"submit": "SUBMIT", "publish": "PUBLISH", "return": "RETURN", "withdraw": "WITHDRAW"} {
|
||||||
|
group.POST("/candidates/:id/"+endpoint, func(c *gin.Context) {
|
||||||
|
var input model.CandidateActionInput
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Candidates.Transition(c.Request.Context(), currentUser(c), c.Param("id"), action, c.Writer.Header().Get("X-Request-ID"), input)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,15 @@ type LoginLimiter interface {
|
|||||||
Login(context.Context, string, string) (bool, error)
|
Login(context.Context, string, string) (bool, error)
|
||||||
}
|
}
|
||||||
type IdentityOptions struct {
|
type IdentityOptions struct {
|
||||||
|
Market *service.MarketService
|
||||||
|
Backtests *service.BacktestService
|
||||||
Auth *service.AuthService
|
Auth *service.AuthService
|
||||||
|
Providers *service.ProviderService
|
||||||
|
Candidates *service.CandidateService
|
||||||
|
Plans *service.TradePlanService
|
||||||
|
Fills *service.TradeFillService
|
||||||
|
Notifications *service.NotificationService
|
||||||
|
Strategies *service.StrategyService
|
||||||
Accounts *service.AccountService
|
Accounts *service.AccountService
|
||||||
Limiter LoginLimiter
|
Limiter LoginLimiter
|
||||||
DevelopmentLogin bool
|
DevelopmentLogin bool
|
||||||
@@ -209,7 +217,11 @@ func sameOrigin(r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decode(c *gin.Context, value any) bool {
|
func decode(c *gin.Context, value any) bool {
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
|
return decodeLimit(c, value, 16<<10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLimit(c *gin.Context, value any, limit int64) bool {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||||
if !strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "application/json") {
|
if !strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "application/json") {
|
||||||
failure(c, http.StatusUnsupportedMediaType, "INVALID_CONTENT_TYPE", "请使用 application/json。", false)
|
failure(c, http.StatusUnsupportedMediaType, "INVALID_CONTENT_TYPE", "请使用 application/json。", false)
|
||||||
return false
|
return false
|
||||||
@@ -237,13 +249,25 @@ func failure(c *gin.Context, status int, code, message string, retryable bool) {
|
|||||||
func fail(c *gin.Context, err error) {
|
func fail(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, apperror.ErrInvalidInput):
|
case errors.Is(err, apperror.ErrInvalidInput):
|
||||||
failure(c, http.StatusUnprocessableEntity, "INVALID_INPUT", "请检查输入内容;资金最多两位小数,密码为 12–72 字节。", false)
|
failure(c, http.StatusUnprocessableEntity, "INVALID_INPUT", "请检查输入字段、必填项与格式。", false)
|
||||||
case errors.Is(err, apperror.ErrUnauthenticated):
|
case errors.Is(err, apperror.ErrUnauthenticated):
|
||||||
failure(c, http.StatusUnauthorized, "UNAUTHENTICATED", "登录信息无效或会话已过期。", false)
|
failure(c, http.StatusUnauthorized, "UNAUTHENTICATED", "登录信息无效或会话已过期。", false)
|
||||||
case errors.Is(err, apperror.ErrForbidden):
|
case errors.Is(err, apperror.ErrForbidden):
|
||||||
failure(c, http.StatusForbidden, "FORBIDDEN", "无权执行此操作。", false)
|
failure(c, http.StatusForbidden, "FORBIDDEN", "无权执行此操作。", false)
|
||||||
|
case errors.Is(err, apperror.ErrInsufficientCash):
|
||||||
|
failure(c, http.StatusConflict, "INSUFFICIENT_CASH", "可用现金不足,请检查其他计划保留的预算。", false)
|
||||||
|
case errors.Is(err, apperror.ErrInsufficientQuantity):
|
||||||
|
failure(c, http.StatusConflict, "INSUFFICIENT_SELLABLE_QUANTITY", "可卖数量不足;请检查模拟可卖时间与剩余持仓。", false)
|
||||||
|
case errors.Is(err, apperror.ErrCandidateUnavailable):
|
||||||
|
failure(c, http.StatusConflict, "CANDIDATE_UNAVAILABLE", "候选已失效或版本已更新,请重新查看依据。", false)
|
||||||
|
case errors.Is(err, apperror.ErrPreviewExpired):
|
||||||
|
failure(c, http.StatusConflict, "PREVIEW_EXPIRED", "预算预览已过期,请重新预览后确认。", false)
|
||||||
|
case errors.Is(err, apperror.ErrChronology):
|
||||||
|
failure(c, http.StatusConflict, "FILL_CHRONOLOGY", "请按成交发生时间顺序录入;更早记录需先纠正后续成交。", false)
|
||||||
|
case errors.Is(err, apperror.ErrDependentFill):
|
||||||
|
failure(c, http.StatusConflict, "DEPENDENT_FILL", "后续成交依赖此记录,请先冲正最新成交。", false)
|
||||||
case errors.Is(err, apperror.ErrConflict):
|
case errors.Is(err, apperror.ErrConflict):
|
||||||
failure(c, http.StatusConflict, "CONFLICT", "该登录名或账户类型已存在。", false)
|
failure(c, http.StatusConflict, "CONFLICT", "记录已存在或版本已更新,请刷新后重试。", false)
|
||||||
case errors.Is(err, apperror.ErrNotFound):
|
case errors.Is(err, apperror.ErrNotFound):
|
||||||
failure(c, http.StatusNotFound, "NOT_FOUND", "记录不存在。", false)
|
failure(c, http.StatusNotFound, "NOT_FOUND", "记录不存在。", false)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"go.uber.org/zap"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,7 +17,7 @@ func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
probe := func(context.Context) error { return nil }
|
probe := func(context.Context) error { return nil }
|
||||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{Auth: auth, DevelopmentLogin: true})
|
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), 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
|
||||||
@@ -43,7 +42,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(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), 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 {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindMarket(r *gin.Engine, o IdentityOptions) {
|
||||||
|
for _, audience := range []string{"admin", "app"} {
|
||||||
|
prefix := "/api/v1"
|
||||||
|
if audience == "admin" {
|
||||||
|
prefix = "/api/admin/v1"
|
||||||
|
}
|
||||||
|
g := r.Group(prefix, func(c *gin.Context) { authenticate(c, o, audience) })
|
||||||
|
g.Use(func(c *gin.Context) {
|
||||||
|
if o.Market == nil {
|
||||||
|
fail(c, apperror.ErrUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
g.GET("/instruments", func(c *gin.Context) {
|
||||||
|
v, e := o.Market.List(c.Request.Context(), currentUser(c), c.Query("q"))
|
||||||
|
if e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, v)
|
||||||
|
})
|
||||||
|
g.GET("/market/history", func(c *gin.Context) {
|
||||||
|
v, e := o.Market.History(c.Request.Context(), currentUser(c), c.Query("symbol"), c.Query("exchange"), c.Query("start"), c.Query("end"))
|
||||||
|
if e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, v)
|
||||||
|
})
|
||||||
|
if audience == "admin" {
|
||||||
|
g.GET("/market/syncs", func(c *gin.Context) {
|
||||||
|
v, e := o.Market.Tasks(c.Request.Context(), currentUser(c))
|
||||||
|
if e != nil {
|
||||||
|
fail(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindNotifications(r *gin.Engine, options IdentityOptions) {
|
||||||
|
for _, admin := range []bool{true, false} {
|
||||||
|
path, audience := "/api/v1", "app"
|
||||||
|
if admin {
|
||||||
|
path, audience = "/api/admin/v1", "admin"
|
||||||
|
}
|
||||||
|
group := r.Group(path, func(c *gin.Context) { authenticate(c, options, audience) })
|
||||||
|
group.Use(func(c *gin.Context) {
|
||||||
|
if options.Notifications == nil {
|
||||||
|
fail(c, apperror.ErrUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
if admin {
|
||||||
|
group.GET("/notification-tasks", func(c *gin.Context) {
|
||||||
|
result, err := options.Notifications.Tasks(c.Request.Context(), currentUser(c))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.GET("/simulation-quotes", func(c *gin.Context) {
|
||||||
|
result, err := options.Notifications.Quotes(c.Request.Context(), currentUser(c))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.POST("/simulation-quotes", func(c *gin.Context) {
|
||||||
|
if !sameOrigin(c.Request) {
|
||||||
|
fail(c, apperror.ErrForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input service.SimulationQuoteInput
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Notifications.SaveQuote(c.Request.Context(), currentUser(c), input, c.Writer.Header().Get("X-Request-ID"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusCreated, result)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
group.GET("/notifications", func(c *gin.Context) {
|
||||||
|
result, err := options.Notifications.List(c.Request.Context(), currentUser(c))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.POST("/notifications/:id/read", func(c *gin.Context) {
|
||||||
|
var input struct{}
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := options.Notifications.MarkRead(c.Request.Context(), currentUser(c), c.Param("id")); err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, gin.H{"read": true})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindProviders(r *gin.Engine, options IdentityOptions) {
|
||||||
|
admin := r.Group("/api/admin/v1", func(c *gin.Context) { authenticate(c, options, "admin") })
|
||||||
|
admin.Use(func(c *gin.Context) {
|
||||||
|
if options.Providers == nil {
|
||||||
|
fail(c, apperror.ErrUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
admin.GET("/providers", func(c *gin.Context) {
|
||||||
|
list, err := options.Providers.List(c.Request.Context(), currentUser(c))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, list)
|
||||||
|
})
|
||||||
|
admin.PUT("/providers/:key", func(c *gin.Context) {
|
||||||
|
if !sameOrigin(c.Request) {
|
||||||
|
fail(c, apperror.ErrForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input service.ProviderInput
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config, err := options.Providers.Save(c.Request.Context(), currentUser(c), c.Param("key"), c.Writer.Header().Get("X-Request-ID"), input)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, config)
|
||||||
|
})
|
||||||
|
admin.GET("/audit-events", func(c *gin.Context) {
|
||||||
|
list, err := options.Providers.Audit(c.Request.Context(), currentUser(c))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, list)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"go.uber.org/zap"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
"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 *zap.Logger, identity IdentityOptions) http.Handler {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(func(c *gin.Context) {
|
r.Use(func(c *gin.Context) {
|
||||||
@@ -29,10 +29,27 @@ func NewRouter(health *service.HealthService, logger *slog.Logger, identity Iden
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
if recover() != nil {
|
if recover() != nil {
|
||||||
logger.Error("request panic", "request_id", requestID)
|
logger.Error("request panic", zap.String("request_id", requestID))
|
||||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Internal server error", "request_id": requestID}})
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Internal server error", "request_id": requestID}})
|
||||||
}
|
}
|
||||||
logger.Info("http request", "method", c.Request.Method, "route", c.FullPath(), "status", c.Writer.Status(), "duration_ms", time.Since(start).Milliseconds(), "request_id", requestID)
|
route := c.FullPath()
|
||||||
|
if route == "" {
|
||||||
|
route = "/<unmatched>"
|
||||||
|
if c.Request.URL.Path == "/" {
|
||||||
|
route = "/"
|
||||||
|
} else if strings.HasPrefix(c.Request.URL.Path, "/assets/") {
|
||||||
|
route = "/assets/*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields := []zap.Field{zap.String("method", c.Request.Method), zap.String("route", route), zap.Int("status", c.Writer.Status()), zap.Int64("duration_ms", time.Since(start).Milliseconds()), zap.String("request_id", requestID)}
|
||||||
|
switch {
|
||||||
|
case c.Writer.Status() >= 500:
|
||||||
|
logger.Error("HTTP request", fields...)
|
||||||
|
case c.Writer.Status() >= 400:
|
||||||
|
logger.Warn("HTTP request", fields...)
|
||||||
|
default:
|
||||||
|
logger.Info("HTTP request", fields...)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
@@ -48,6 +65,14 @@ func NewRouter(health *service.HealthService, logger *slog.Logger, identity Iden
|
|||||||
r.GET("/readyz", check)
|
r.GET("/readyz", check)
|
||||||
r.GET("/api/v1/system/health", check)
|
r.GET("/api/v1/system/health", check)
|
||||||
bindIdentity(r, identity)
|
bindIdentity(r, identity)
|
||||||
|
bindProviders(r, identity)
|
||||||
|
bindCandidates(r, identity)
|
||||||
|
bindTradePlans(r, identity)
|
||||||
|
bindTradeFills(r, identity)
|
||||||
|
bindNotifications(r, identity)
|
||||||
|
bindStrategies(r, identity)
|
||||||
|
bindBacktests(r, identity)
|
||||||
|
bindMarket(r, identity)
|
||||||
console := adminui.Handler()
|
console := adminui.Handler()
|
||||||
r.NoRoute(func(c *gin.Context) {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && c.Request.URL.Path != "/api" {
|
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && c.Request.URL.Path != "/api" {
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"go.uber.org/zap"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,7 +15,7 @@ import (
|
|||||||
func TestLivenessAndReadinessAreIndependent(t *testing.T) {
|
func TestLivenessAndReadinessAreIndependent(t *testing.T) {
|
||||||
secretError := "password=secret-internal-detail"
|
secretError := "password=secret-internal-detail"
|
||||||
health := service.NewHealthService(nil, func(context.Context) error { return errors.New(secretError) })
|
health := service.NewHealthService(nil, func(context.Context) error { return errors.New(secretError) })
|
||||||
router := NewRouter(health, slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
router := NewRouter(health, zap.NewNop(), IdentityOptions{})
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
path string
|
path string
|
||||||
status int
|
status int
|
||||||
@@ -37,7 +36,7 @@ func TestLivenessAndReadinessAreIndependent(t *testing.T) {
|
|||||||
|
|
||||||
func TestReadyWhenBothDependenciesRespond(t *testing.T) {
|
func TestReadyWhenBothDependenciesRespond(t *testing.T) {
|
||||||
probe := func(context.Context) error { return nil }
|
probe := func(context.Context) error { return nil }
|
||||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{})
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
@@ -47,7 +46,7 @@ func TestReadyWhenBothDependenciesRespond(t *testing.T) {
|
|||||||
|
|
||||||
func TestEmbeddedConsoleAndAPIBoundary(t *testing.T) {
|
func TestEmbeddedConsoleAndAPIBoundary(t *testing.T) {
|
||||||
probe := func(context.Context) error { return nil }
|
probe := func(context.Context) error { return nil }
|
||||||
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
|
router := NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), IdentityOptions{})
|
||||||
for _, path := range []string{"/", "/research/candidates"} {
|
for _, path := range []string{"/", "/research/candidates"} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindStrategies(r *gin.Engine, options IdentityOptions) {
|
||||||
|
group := r.Group("/api/admin/v1", func(c *gin.Context) { authenticate(c, options, "admin") })
|
||||||
|
group.Use(func(c *gin.Context) {
|
||||||
|
if options.Strategies == nil {
|
||||||
|
fail(c, apperror.ErrUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
group.GET("/strategies", func(c *gin.Context) {
|
||||||
|
result, err := options.Strategies.List(c.Request.Context(), currentUser(c))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.GET("/strategies/:id", func(c *gin.Context) {
|
||||||
|
result, err := options.Strategies.Detail(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.Use(func(c *gin.Context) {
|
||||||
|
if !sameOrigin(c.Request) {
|
||||||
|
fail(c, apperror.ErrForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
for _, path := range []string{"/strategies", "/strategies/:id/versions"} {
|
||||||
|
group.POST(path, func(c *gin.Context) {
|
||||||
|
var input service.StrategyInput
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Strategies.Save(c.Request.Context(), currentUser(c), c.Param("id"), input, c.Writer.Header().Get("X-Request-ID"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusCreated, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
group.POST("/strategies/:id/deploy", func(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
VersionID string `json:"version_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExpectedVersion int64 `json:"expected_version"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Strategies.Deploy(c.Request.Context(), currentUser(c), c.Param("id"), input.VersionID, input.Status, input.ExpectedVersion, input.Reason, c.Writer.Header().Get("X-Request-ID"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindTradeFills(r *gin.Engine, options IdentityOptions) {
|
||||||
|
group := r.Group("/api/v1", func(c *gin.Context) { authenticate(c, options, "app") })
|
||||||
|
group.Use(func(c *gin.Context) {
|
||||||
|
if options.Fills == nil {
|
||||||
|
fail(c, apperror.ErrUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
group.POST("/fills", func(c *gin.Context) {
|
||||||
|
var input service.FillInput
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Fills.Record(c.Request.Context(), currentUser(c), input, c.Writer.Header().Get("X-Request-ID"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusCreated, result)
|
||||||
|
})
|
||||||
|
group.POST("/fills/:id/reverse", func(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Fills.Reverse(c.Request.Context(), currentUser(c), c.Param("id"), input.Reason, c.Writer.Header().Get("X-Request-ID"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.GET("/accounts/:id/fills", func(c *gin.Context) {
|
||||||
|
result, err := options.Fills.List(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.GET("/accounts/:id/portfolio", func(c *gin.Context) {
|
||||||
|
result, err := options.Fills.Portfolio(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindTradePlans(r *gin.Engine, options IdentityOptions) {
|
||||||
|
group := r.Group("/api/v1", func(c *gin.Context) { authenticate(c, options, "app") })
|
||||||
|
group.Use(func(c *gin.Context) {
|
||||||
|
if options.Plans == nil {
|
||||||
|
fail(c, apperror.ErrUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
group.POST("/plan-previews", func(c *gin.Context) {
|
||||||
|
var input service.PlanPreviewInput
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Plans.Preview(c.Request.Context(), currentUser(c), input)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusCreated, result)
|
||||||
|
})
|
||||||
|
group.POST("/plan-previews/:id/confirm", func(c *gin.Context) {
|
||||||
|
var input struct{}
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Plans.Confirm(c.Request.Context(), currentUser(c), c.Param("id"), c.Writer.Header().Get("X-Request-ID"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.GET("/plans", func(c *gin.Context) {
|
||||||
|
result, err := options.Plans.List(c.Request.Context(), currentUser(c))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.POST("/plans/:id/cancel", func(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
ExpectedVersion int64 `json:"expected_version"`
|
||||||
|
}
|
||||||
|
if !decode(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := options.Plans.Cancel(c.Request.Context(), currentUser(c), c.Param("id"), input.ExpectedVersion, c.Writer.Header().Get("X-Request-ID"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
group.GET("/accounts/:id/budget", func(c *gin.Context) {
|
||||||
|
result, err := options.Plans.Budget(c.Request.Context(), currentUser(c), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusOK, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testAdminCredentialChange(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService) {
|
||||||
|
t.Helper()
|
||||||
|
repo := repository.NewIdentityRepository(pool)
|
||||||
|
previous, credential, err := repo.FindCredential(ctx, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldSession, err := auth.Login(ctx, "admin", "an-admin-test-password", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
password := "test1234"
|
||||||
|
updater := service.NewAdminCredentialService(repo, true)
|
||||||
|
if _, err := service.NewAdminCredentialService(repo, false).Set(ctx, "admin", "operator", password); !errors.Is(err, apperror.ErrForbidden) {
|
||||||
|
t.Fatal("production credential setup allowed")
|
||||||
|
}
|
||||||
|
if _, err := updater.Set(ctx, "alice", "operator", password); !errors.Is(err, apperror.ErrForbidden) {
|
||||||
|
t.Fatal("ordinary user promoted by credential command")
|
||||||
|
}
|
||||||
|
if _, err := updater.Set(ctx, "admin", "bob", password); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("duplicate login accepted")
|
||||||
|
}
|
||||||
|
if _, err := auth.Authenticate(ctx, oldSession.Token, "admin"); err != nil {
|
||||||
|
t.Fatal("failed update revoked existing session")
|
||||||
|
}
|
||||||
|
userID, err := updater.Set(ctx, "admin", "operator", password)
|
||||||
|
if err != nil || userID != previous.ID {
|
||||||
|
t.Fatal("administrator identity was not preserved")
|
||||||
|
}
|
||||||
|
if _, err := auth.Authenticate(ctx, oldSession.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||||
|
t.Fatal("old session survived password change")
|
||||||
|
}
|
||||||
|
if _, err := auth.Login(ctx, "admin", "an-admin-test-password", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||||
|
t.Fatal("old login survived update")
|
||||||
|
}
|
||||||
|
login, err := auth.Login(ctx, "operator", password, "admin")
|
||||||
|
if err != nil || login.User.ID != previous.ID {
|
||||||
|
t.Fatal("new credentials failed")
|
||||||
|
}
|
||||||
|
_, updated, err := repo.FindCredential(ctx, "operator")
|
||||||
|
if err != nil || updated.PasswordHash == password || updated.PasswordHash == credential.PasswordHash {
|
||||||
|
t.Fatal("new password was not hashed")
|
||||||
|
}
|
||||||
|
stale := model.Session{ID: uuid.NewString(), UserID: previous.ID, TokenHash: service.TokenHash(uuid.NewString()), Audience: "admin", ExpiresAt: time.Now().Add(time.Hour)}
|
||||||
|
if err := repo.CreateSession(ctx, stale, credential.PasswordHash); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||||
|
t.Fatal("stale password verification created a new session")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCandidateLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService, admin, user model.User) {
|
||||||
|
t.Helper()
|
||||||
|
repo := repository.NewCandidateRepository(pool)
|
||||||
|
svc := service.NewCandidateService(repo, true)
|
||||||
|
input := model.CandidateDraftInput{Content: model.CandidateContent{Symbol: "600000", Exchange: "SSE", Name: "测试证券", StrategyCode: "trend_following", Title: "测试候选", Thesis: "可核验测试依据", Evidence: []model.CandidateEvidence{{SourceURL: "https://evidence.example/report", Locator: "测试段落", Summary: "测试摘要"}}, CounterEvidence: "存在风险", Invalidation: "条件失效", ReferencePlan: model.CandidateReferencePlan{EntryLow: "10.00", EntryHigh: "11.00", StopLoss: "9.00", TargetPrice: "13.00"}}, ObservedAt: time.Now().UTC(), ValidUntil: time.Now().UTC().Add(time.Hour)}
|
||||||
|
create := func(source string, expected int64) model.CandidateRevision {
|
||||||
|
r, err := svc.Create(ctx, admin, source, uuid.NewString(), input, expected)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
act := func(r model.CandidateRevision, action string) model.CandidateRevision {
|
||||||
|
next, err := svc.Transition(ctx, admin, r.ID, action, uuid.NewString(), model.CandidateActionInput{ExpectedVersion: r.Version, Reason: "集成测试审核意见"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transition %s: %v", action, err)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
list := func() []model.CandidateRevision {
|
||||||
|
r, err := svc.List(ctx, user, false, "", 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
draft := create("", 0)
|
||||||
|
if len(list()) != 0 {
|
||||||
|
t.Fatal("draft leaked into app feed")
|
||||||
|
}
|
||||||
|
if _, err := svc.Detail(ctx, user, draft.ID, false); !errors.Is(err, apperror.ErrNotFound) {
|
||||||
|
t.Fatal("app read unpublished revision")
|
||||||
|
}
|
||||||
|
if _, err := svc.Create(ctx, user, "", uuid.NewString(), input, 0); !errors.Is(err, apperror.ErrForbidden) {
|
||||||
|
t.Fatal("user created candidate")
|
||||||
|
}
|
||||||
|
if _, err := service.NewCandidateService(repo, false).List(ctx, user, false, "", 100); !errors.Is(err, apperror.ErrUnavailable) {
|
||||||
|
t.Fatal("simulation capability exposed in production")
|
||||||
|
}
|
||||||
|
if _, err := svc.Transition(ctx, admin, draft.ID, "PUBLISH", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: draft.Version, Reason: "invalid"}); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("draft published without submission")
|
||||||
|
}
|
||||||
|
pending := act(draft, "SUBMIT")
|
||||||
|
if _, err := svc.Create(ctx, admin, pending.ID, uuid.NewString(), input, pending.Version); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("pending review overwritten")
|
||||||
|
}
|
||||||
|
returned := act(pending, "RETURN")
|
||||||
|
replacement := create(returned.ID, returned.Version)
|
||||||
|
if replacement.CandidateID != draft.CandidateID || replacement.Revision != 2 {
|
||||||
|
t.Fatal("revision history lost")
|
||||||
|
}
|
||||||
|
pending = act(replacement, "SUBMIT")
|
||||||
|
// Two concurrent reviewers cannot both commit the same input version.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make(chan error, 2)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := svc.Transition(ctx, admin, pending.ID, "PUBLISH", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: pending.Version, Reason: "并发发布测试"})
|
||||||
|
results <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
success, conflict := 0, 0
|
||||||
|
for err := range results {
|
||||||
|
if err == nil {
|
||||||
|
success++
|
||||||
|
} else if errors.Is(err, apperror.ErrConflict) {
|
||||||
|
conflict++
|
||||||
|
} else {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success != 1 || conflict != 1 {
|
||||||
|
t.Fatal("publication CAS failed")
|
||||||
|
}
|
||||||
|
published, err := svc.Detail(ctx, admin, pending.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list()) != 1 || !published.IsActivePublication || len(published.Reviews) != 2 {
|
||||||
|
t.Fatal("publication missing or duplicate reviews")
|
||||||
|
}
|
||||||
|
appDetail, err := svc.Detail(ctx, user, pending.ID, false)
|
||||||
|
if err != nil || len(appDetail.Reviews) != 0 {
|
||||||
|
t.Fatal("internal reviews leaked")
|
||||||
|
}
|
||||||
|
source := published.CandidateRevision
|
||||||
|
input.Content.Thesis = "第二次发布的新内容"
|
||||||
|
next := create(source.ID, source.Version)
|
||||||
|
if len(list()) != 1 || list()[0].ID != source.ID {
|
||||||
|
t.Fatal("new draft hid current publication")
|
||||||
|
}
|
||||||
|
next = act(act(next, "SUBMIT"), "PUBLISH")
|
||||||
|
if len(list()) != 1 || list()[0].ID != next.ID {
|
||||||
|
t.Fatal("superseded publication remained active")
|
||||||
|
}
|
||||||
|
old, err := svc.Detail(ctx, user, source.ID, false)
|
||||||
|
if err != nil || old.IsActivePublication || old.Snapshot.Thesis == input.Content.Thesis {
|
||||||
|
t.Fatal("old content overwritten")
|
||||||
|
}
|
||||||
|
testTradePlans(t, ctx, pool, user, next)
|
||||||
|
// Inject a durable-event write failure; publication, review and audit must roll back.
|
||||||
|
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_outbox_event ADD CONSTRAINT veyra_test_event_failure CHECK(event_type!='candidate.withdrawn') NOT VALID`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Transition(ctx, admin, next.ID, "WITHDRAW", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: next.Version, Reason: "事务回滚测试"}); err == nil {
|
||||||
|
t.Fatal("injected outbox error did not fail")
|
||||||
|
}
|
||||||
|
after, err := svc.Detail(ctx, admin, next.ID, true)
|
||||||
|
if err != nil || after.Version != next.Version || after.Status != "PUBLISHED" || len(after.Reviews) != 2 {
|
||||||
|
t.Fatal("failed event left partial publication")
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_outbox_event DROP CONSTRAINT veyra_test_event_failure`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
withdrawn := act(next, "WITHDRAW")
|
||||||
|
plans, err := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true).List(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range plans {
|
||||||
|
if p.CandidateRevisionID == next.ID && p.Status != "CANCELLED" && (p.Status != "INVALIDATED" || p.ReservedMinor != 0) {
|
||||||
|
t.Fatal("withdrawal did not invalidate plan and release budget")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(list()) != 0 {
|
||||||
|
t.Fatal("withdrawn publication visible in feed")
|
||||||
|
}
|
||||||
|
historical, err := svc.Detail(ctx, user, withdrawn.ID, false)
|
||||||
|
if err != nil || historical.Status != "WITHDRAWN" || historical.IsActivePublication {
|
||||||
|
t.Fatal("withdrawal history unavailable")
|
||||||
|
}
|
||||||
|
var eventCount int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_outbox_event WHERE aggregate_type='candidate_revision'`).Scan(&eventCount); err != nil || eventCount != 4 {
|
||||||
|
t.Fatalf("outbox facts: count=%d err=%v", eventCount, err)
|
||||||
|
}
|
||||||
|
// Expired published candidates leave the live feed without deleting history.
|
||||||
|
expires := act(act(create("", 0), "SUBMIT"), "PUBLISH")
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE veyra_candidate_revision SET observed_at=now()-interval '2 hours',valid_until=now()-interval '1 hour' WHERE id=$1`, expires.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list()) != 0 {
|
||||||
|
t.Fatal("expired publication visible in feed")
|
||||||
|
}
|
||||||
|
// Real HTTP adapters enforce cookie audience, origin and unpublished visibility.
|
||||||
|
probe := func(context.Context) error { return nil }
|
||||||
|
router := handler.NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), handler.IdentityOptions{Auth: auth, Candidates: svc, Limiter: allowLogin{}, DevelopmentLogin: true})
|
||||||
|
adminLogin, err := auth.Login(ctx, "admin", "an-admin-test-password", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
userLogin, err := auth.Login(ctx, "alice", "a-test-only-password", "app")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
call := func(method, path, body, origin, token string, adminAudience bool) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
if adminAudience {
|
||||||
|
req.AddCookie(&http.Cookie{Name: "veyra_admin_session", Value: token})
|
||||||
|
} else {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
if r := call("GET", "/api/v1/candidates/"+draft.ID, "", "", userLogin.Token, false); r.Code != 404 {
|
||||||
|
t.Fatal("HTTP draft disclosure")
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(input)
|
||||||
|
if r := call("POST", "/api/admin/v1/candidates", string(body), "http://attacker.example", adminLogin.Token, true); r.Code != 403 {
|
||||||
|
t.Fatal("cross origin create accepted")
|
||||||
|
}
|
||||||
|
if r := call("GET", "/api/admin/v1/candidates", "", "", userLogin.Token, true); r.Code != 401 {
|
||||||
|
t.Fatal("app token accepted as admin cookie")
|
||||||
|
}
|
||||||
|
r := call("POST", "/api/admin/v1/candidates", string(body), "http://example.com", adminLogin.Token, true)
|
||||||
|
if r.Code != 201 || !bytes.Contains(r.Body.Bytes(), []byte(`"status":"DRAFT"`)) {
|
||||||
|
t.Fatalf("HTTP create: %d %s", r.Code, r.Body.String())
|
||||||
|
}
|
||||||
|
if r := call("GET", "/api/v1/candidates?limit=101", "", "", userLogin.Token, false); r.Code != 422 {
|
||||||
|
t.Fatal("unbounded list accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,7 @@ package integration
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"go.uber.org/zap"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -19,6 +18,7 @@ import (
|
|||||||
"git.sundynix.cn/Blizzard/Veyra/internal/database"
|
"git.sundynix.cn/Blizzard/Veyra/internal/database"
|
||||||
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
|
"git.sundynix.cn/Blizzard/Veyra/internal/handler"
|
||||||
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/secret"
|
||||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
|||||||
if dsn == "" {
|
if dsn == "" {
|
||||||
t.Skip("set VEYRA_TEST_DATABASE_URL for real database integration")
|
t.Skip("set VEYRA_TEST_DATABASE_URL for real database integration")
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
admin, err := pgxpool.New(ctx, dsn)
|
admin, err := pgxpool.New(ctx, dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -127,11 +127,15 @@ 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("expired session remained valid")
|
t.Fatal("expired session remained valid")
|
||||||
}
|
}
|
||||||
if _, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN"); err != nil {
|
adminUser, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN")
|
||||||
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
testProviderLifecycle(t, ctx, pool, adminUser, u1)
|
||||||
|
vault, _ := secret.NewVault("")
|
||||||
|
providers := service.NewProviderService(repository.NewProviderRepository(pool), vault)
|
||||||
probe := func(context.Context) error { return nil }
|
probe := func(context.Context) error { return nil }
|
||||||
router := handler.NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), handler.IdentityOptions{Auth: auth, Accounts: accounts, Limiter: allowLogin{}, DevelopmentLogin: true})
|
router := handler.NewRouter(service.NewHealthService(probe, probe), zap.NewNop(), handler.IdentityOptions{Auth: auth, Providers: providers, 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 != "" {
|
||||||
@@ -162,6 +166,23 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
|||||||
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK {
|
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK {
|
||||||
t.Fatal("cookie authentication failed")
|
t.Fatal("cookie authentication failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if response := call(http.MethodGet, "/api/admin/v1/providers", "", "", nil); response.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatal("unauthenticated configuration read allowed")
|
||||||
|
}
|
||||||
|
if response := call(http.MethodGet, "/api/admin/v1/providers", "", "", cookie); response.Code != http.StatusOK {
|
||||||
|
t.Fatal("admin configuration read failed")
|
||||||
|
}
|
||||||
|
body := `{"name":"","base_url":"","model_name":"","enabled":false,"timeout_seconds":15,"clear_api_key":false,"expected_version":0}`
|
||||||
|
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://attacker.example", cookie); response.Code != http.StatusForbidden {
|
||||||
|
t.Fatal("cross-origin configuration write allowed")
|
||||||
|
}
|
||||||
|
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://example.com", cookie); response.Code != http.StatusOK {
|
||||||
|
t.Fatal("empty market configuration write failed")
|
||||||
|
}
|
||||||
|
if response := call(http.MethodPut, "/api/admin/v1/providers/market_primary", body, "http://example.com", cookie); response.Code != http.StatusConflict {
|
||||||
|
t.Fatal("HTTP stale version accepted")
|
||||||
|
}
|
||||||
if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://attacker.example", cookie); response.Code != http.StatusForbidden {
|
if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://attacker.example", cookie); response.Code != http.StatusForbidden {
|
||||||
t.Fatal("cross-origin logout allowed")
|
t.Fatal("cross-origin logout allowed")
|
||||||
}
|
}
|
||||||
@@ -174,6 +195,10 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
|||||||
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusUnauthorized {
|
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusUnauthorized {
|
||||||
t.Fatal("revoked admin cookie remained valid")
|
t.Fatal("revoked admin cookie remained valid")
|
||||||
}
|
}
|
||||||
|
testCandidateLifecycle(t, ctx, pool, auth, adminUser, u1)
|
||||||
|
testStrategyBacktest(t, ctx, pool, adminUser, u1)
|
||||||
|
testTradeLedger(t, ctx, pool, auth)
|
||||||
|
testAdminCredentialChange(t, ctx, pool, auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
type allowLogin struct{}
|
type allowLogin struct{}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testNotifications(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision, account string) {
|
||||||
|
t.Helper()
|
||||||
|
repo := repository.NewNotificationRepository(pool)
|
||||||
|
svc := service.NewNotificationService(repo, true)
|
||||||
|
var admin model.User
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT id,role,status FROM veyra_user WHERE role='ADMIN' LIMIT 1`).Scan(&admin.ID, &admin.Role, &admin.Status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
process := func() {
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
worked, err := repo.ProcessNext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !worked {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("notification queue did not drain")
|
||||||
|
}
|
||||||
|
var cashBefore, fillBefore int64
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_minor),0)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account).Scan(&cashBefore); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_trade_fill WHERE account_id=$1`, account).Scan(&fillBefore); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
quote := service.SimulationQuoteInput{Symbol: candidate.Snapshot.Symbol, Exchange: candidate.Snapshot.Exchange, Price: "10.50", ObservedAt: time.Now().UTC()}
|
||||||
|
if _, err := svc.SaveQuote(ctx, user, quote, uuid.NewString()); !errors.Is(err, apperror.ErrForbidden) {
|
||||||
|
t.Fatal("user wrote quote")
|
||||||
|
}
|
||||||
|
q, err := svc.SaveQuote(ctx, admin, quote, uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
process()
|
||||||
|
notices, err := svc.List(ctx, user)
|
||||||
|
if err != nil || len(notices) != 1 {
|
||||||
|
t.Fatalf("plan reminder missing: %d %v", len(notices), err)
|
||||||
|
}
|
||||||
|
quote.ObservedAt = time.Now().UTC()
|
||||||
|
if _, err := svc.SaveQuote(ctx, admin, quote, uuid.NewString()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
process()
|
||||||
|
notices, err = svc.List(ctx, user)
|
||||||
|
if err != nil || len(notices) != 1 {
|
||||||
|
t.Fatal("repeated price produced duplicate reminder")
|
||||||
|
}
|
||||||
|
var cashAfter, fillAfter int64
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_minor),0)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account).Scan(&cashAfter); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_trade_fill WHERE account_id=$1`, account).Scan(&fillAfter); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cashBefore != cashAfter || fillBefore != fillAfter {
|
||||||
|
t.Fatal("reminder altered ledger")
|
||||||
|
}
|
||||||
|
stranger := user
|
||||||
|
stranger.ID = uuid.NewString()
|
||||||
|
if err := svc.MarkRead(ctx, stranger, notices[0].ID); !errors.Is(err, apperror.ErrNotFound) {
|
||||||
|
t.Fatal("cross user notification write")
|
||||||
|
}
|
||||||
|
if err := svc.MarkRead(ctx, user, notices[0].ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tasks, err := svc.Tasks(ctx, admin)
|
||||||
|
if err != nil || len(tasks) == 0 {
|
||||||
|
t.Fatal("task audit missing")
|
||||||
|
}
|
||||||
|
for _, task := range tasks {
|
||||||
|
if task.ProcessedAt == nil {
|
||||||
|
t.Fatal("queue event did not commit processed flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A second plan must not trigger on an expired or superseded quote event.
|
||||||
|
plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
|
||||||
|
preview, err := plans.Preview(ctx, user, service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "100", FeeBudget: "0"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := plans.Confirm(ctx, user, preview.ID, uuid.NewString()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
quote.ObservedAt = time.Now().UTC()
|
||||||
|
q, err = svc.SaveQuote(ctx, admin, quote, uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE veyra_quote_snapshot SET observed_at=now()-interval '2 minutes' WHERE id=$1`, q.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
process()
|
||||||
|
notices, err = svc.List(ctx, user)
|
||||||
|
if err != nil || len(notices) != 1 {
|
||||||
|
t.Fatal("stale quote triggered plan")
|
||||||
|
}
|
||||||
|
quote.ObservedAt = time.Now().UTC()
|
||||||
|
if _, err := svc.SaveQuote(ctx, admin, quote, uuid.NewString()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
process()
|
||||||
|
notices, err = svc.List(ctx, user)
|
||||||
|
if err != nil || len(notices) != 2 {
|
||||||
|
t.Fatal("fresh second-plan reminder failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/secret"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testProviderLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool, actor, ordinary model.User) {
|
||||||
|
t.Helper()
|
||||||
|
vault, err := secret.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{9}, 32)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repo := repository.NewProviderRepository(pool)
|
||||||
|
svc := service.NewProviderService(repo, vault)
|
||||||
|
configs, err := svc.List(ctx, actor)
|
||||||
|
if err != nil || len(configs) != 0 {
|
||||||
|
t.Fatalf("configuration must start empty: %v", err)
|
||||||
|
}
|
||||||
|
input := service.ProviderInput{TimeoutSeconds: 15}
|
||||||
|
config, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||||
|
if err != nil || config.Enabled || config.BaseURL != "" || config.LastTestStatus != "NOT_CONFIGURED" || config.WireAPI != "responses" || !config.DisableResponseStorage || !config.RequiresOpenAIAuth {
|
||||||
|
t.Fatalf("blank configuration: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.List(ctx, ordinary); !errors.Is(err, apperror.ErrForbidden) {
|
||||||
|
t.Fatal("ordinary user accessed provider configuration")
|
||||||
|
}
|
||||||
|
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("stale configuration accepted")
|
||||||
|
}
|
||||||
|
token := "test-private-provider-key"
|
||||||
|
input = service.ProviderInput{Name: "configured vendor", BaseURL: "https://provider.example/v1", ModelName: "configured-model", ModelCatalogURL: "https://provider.example/v1/models", TimeoutSeconds: 15, Enabled: true, APIKey: &token, ExpectedVersion: config.Version}
|
||||||
|
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(config)
|
||||||
|
if bytes.Contains(data, []byte(token)) || bytes.Contains(data, []byte("encrypted_api_key")) {
|
||||||
|
t.Fatal("API exposes credential")
|
||||||
|
}
|
||||||
|
loaded, err := repo.Get(ctx, "model_primary")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
value, err := vault.Decrypt(loaded.EncryptedAPIKey, "model_primary")
|
||||||
|
if err != nil || value != token {
|
||||||
|
t.Fatal("credential was not persisted encrypted")
|
||||||
|
}
|
||||||
|
input.ExpectedVersion = config.Version
|
||||||
|
input.ModelCatalogURL = "https://another.example/models"
|
||||||
|
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||||
|
t.Fatal("cross-origin model catalog accepted")
|
||||||
|
}
|
||||||
|
input.ModelCatalogURL = "https://provider.example/v1/models"
|
||||||
|
input.WireAPI = "unknown"
|
||||||
|
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||||
|
t.Fatal("unsupported protocol accepted")
|
||||||
|
}
|
||||||
|
input.WireAPI = "responses"
|
||||||
|
input.ExpectedVersion = config.Version
|
||||||
|
input.APIKey = nil
|
||||||
|
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||||
|
if err != nil || !config.HasAPIKey {
|
||||||
|
t.Fatal("omitted key did not preserve existing credential")
|
||||||
|
}
|
||||||
|
input.ExpectedVersion = config.Version
|
||||||
|
input.BaseURL = "https://different.example/v1"
|
||||||
|
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||||
|
t.Fatal("changed endpoint reused existing secret")
|
||||||
|
}
|
||||||
|
input.BaseURL = "https://provider.example/v1?api_key=secret"
|
||||||
|
if _, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||||
|
t.Fatal("query credentials accepted")
|
||||||
|
}
|
||||||
|
input.BaseURL = config.BaseURL
|
||||||
|
input.Enabled = false
|
||||||
|
input.ClearAPIKey = true
|
||||||
|
disabled := false
|
||||||
|
input.DisableResponseStorage = &disabled
|
||||||
|
input.RequiresOpenAIAuth = &disabled
|
||||||
|
config, err = svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||||
|
if err != nil || config.HasAPIKey || config.DisableResponseStorage || config.RequiresOpenAIAuth {
|
||||||
|
t.Fatal("key clear failed")
|
||||||
|
}
|
||||||
|
input.DisableResponseStorage = nil
|
||||||
|
input.RequiresOpenAIAuth = nil
|
||||||
|
// Two writers of the same version must produce one successor and one conflict.
|
||||||
|
input.ExpectedVersion = config.Version
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make(chan error, 2)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := svc.Save(ctx, actor, "model_primary", uuid.NewString(), input)
|
||||||
|
results <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
succeeded, conflicted := 0, 0
|
||||||
|
for err := range results {
|
||||||
|
if err == nil {
|
||||||
|
succeeded++
|
||||||
|
} else if errors.Is(err, apperror.ErrConflict) {
|
||||||
|
conflicted++
|
||||||
|
} else {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latest, err := repo.Get(ctx, "model_primary")
|
||||||
|
if err != nil || latest.DisableResponseStorage || latest.RequiresOpenAIAuth {
|
||||||
|
t.Fatal("omitted flags did not preserve configuration")
|
||||||
|
}
|
||||||
|
if succeeded != 1 || conflicted != 1 {
|
||||||
|
t.Fatal("concurrent version protection failed")
|
||||||
|
}
|
||||||
|
var snapshots string
|
||||||
|
var historyCount int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*),string_agg(snapshot::text,'') FROM veyra_provider_config_history`).Scan(&historyCount, &snapshots); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if historyCount != 5 || strings.Contains(snapshots, token) {
|
||||||
|
t.Fatal("history count or credential sanitization failed")
|
||||||
|
}
|
||||||
|
events, err := svc.Audit(ctx, actor)
|
||||||
|
if err != nil || len(events) != 5 {
|
||||||
|
t.Fatalf("atomic audit: %v", err)
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(events)
|
||||||
|
if bytes.Contains(encoded, []byte(token)) {
|
||||||
|
t.Fatal("audit exposed credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/research"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testStrategyBacktest(t *testing.T, ctx context.Context, pool *pgxpool.Pool, admin, user model.User) {
|
||||||
|
t.Helper()
|
||||||
|
strategies := service.NewStrategyService(repository.NewStrategyRepository(pool), true)
|
||||||
|
input := service.StrategyInput{Code: "sma_research", Name: "均线研究", Description: "fixture parameters", Parameters: model.StrategyParameters{ShortWindow: 2, LongWindow: 3, FeeBPS: 10, SlippageBPS: 0, LotSize: 100}}
|
||||||
|
if _, e := strategies.Save(ctx, user, "", input, uuid.NewString()); !errors.Is(e, apperror.ErrForbidden) {
|
||||||
|
t.Fatal("ordinary user can edit research strategy")
|
||||||
|
}
|
||||||
|
created, e := strategies.Save(ctx, admin, "", input, uuid.NewString())
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
deployed, e := strategies.Deploy(ctx, admin, created.ID, created.Versions[0].ID, "ACTIVE", created.Version, "研究选用", uuid.NewString())
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
input.ExpectedVersion = deployed.Version
|
||||||
|
input.Parameters.ShortWindow = 3
|
||||||
|
input.Parameters.LongWindow = 4
|
||||||
|
revised, e := strategies.Save(ctx, admin, created.ID, input, uuid.NewString())
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if len(revised.Versions) != 2 || *revised.ActiveVersionID != created.Versions[0].ID || revised.Versions[1].Parameters.ShortWindow != 2 {
|
||||||
|
t.Fatal("revision changed selected or immutable parameters")
|
||||||
|
}
|
||||||
|
if _, e := strategies.Save(ctx, admin, created.ID, input, uuid.NewString()); !errors.Is(e, apperror.ErrConflict) {
|
||||||
|
t.Fatal("stale strategy edit succeeded")
|
||||||
|
}
|
||||||
|
if _, e := pool.Exec(ctx, `UPDATE veyra_strategy_version SET description='overwrite' WHERE id=$1`, created.Versions[0].ID); e == nil {
|
||||||
|
t.Fatal("strategy parameters can be overwritten")
|
||||||
|
}
|
||||||
|
repo := repository.NewBacktestRepository(pool)
|
||||||
|
svc := service.NewBacktestService(repo, research.New("python3"), true)
|
||||||
|
bars := []model.HistoricalBar{}
|
||||||
|
for i, v := range []string{"10.00", "9.00", "8.00", "20.00", "21.00", "7.00", "6.00", "5.00"} {
|
||||||
|
bars = append(bars, model.HistoricalBar{Date: fmt.Sprintf("2025-01-%02d", i+1), Open: v, High: v, Low: v, Close: v})
|
||||||
|
}
|
||||||
|
bi := model.BacktestInput{StrategyVersionID: created.Versions[0].ID, DatasetName: "integration fixture", Symbol: "600000", InitialCash: "10000.00", Bars: bars}
|
||||||
|
bad := bi
|
||||||
|
bad.Bars = append([]model.HistoricalBar(nil), bars...)
|
||||||
|
bad.Bars[1].Date = bad.Bars[0].Date
|
||||||
|
if _, e := svc.Create(ctx, admin, bad, uuid.NewString()); !errors.Is(e, apperror.ErrInvalidInput) {
|
||||||
|
t.Fatal("duplicate day allowed")
|
||||||
|
}
|
||||||
|
job, e := svc.Create(ctx, admin, bi, uuid.NewString())
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
var snapshot model.ResearchInput
|
||||||
|
if json.Unmarshal(job.Input, &snapshot) != nil || snapshot.Parameters.ShortWindow != 2 {
|
||||||
|
t.Fatal("backtest lost immutable version parameters")
|
||||||
|
}
|
||||||
|
claim, e := repo.Claim(ctx)
|
||||||
|
if e != nil || claim.ID != job.ID || claim.LeaseID == nil {
|
||||||
|
t.Fatalf("claim: %v", e)
|
||||||
|
}
|
||||||
|
if _, e := repo.Claim(ctx); !errors.Is(e, apperror.ErrNotFound) {
|
||||||
|
t.Fatal("active lease claimed twice")
|
||||||
|
}
|
||||||
|
if _, e := pool.Exec(ctx, `UPDATE veyra_backtest_run SET lease_expires_at=now()-interval '1 second' WHERE id=$1`, job.ID); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
renewed, e := repo.Claim(ctx)
|
||||||
|
if e != nil || *renewed.LeaseID == *claim.LeaseID {
|
||||||
|
t.Fatal("expired job cannot recover")
|
||||||
|
}
|
||||||
|
result, e := research.New("python3").Execute(ctx, renewed.Input)
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
if e := repo.Finish(ctx, claim, result, ""); !errors.Is(e, apperror.ErrConflict) {
|
||||||
|
t.Fatal("stale worker can overwrite result")
|
||||||
|
}
|
||||||
|
if e := repo.Finish(ctx, renewed, result, ""); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
done, e := svc.Detail(ctx, admin, job.ID)
|
||||||
|
if e != nil || done.Status != "COMPLETED" || done.FinishedAt == nil || len(done.Result) == 0 {
|
||||||
|
t.Fatalf("completed job: %v", e)
|
||||||
|
}
|
||||||
|
if _, e := pool.Exec(ctx, `UPDATE veyra_backtest_run SET result='{}'::jsonb WHERE id=$1`, job.ID); e == nil {
|
||||||
|
t.Fatal("completed result can be overwritten")
|
||||||
|
}
|
||||||
|
if _, e := svc.Detail(ctx, user, job.ID); !errors.Is(e, apperror.ErrForbidden) {
|
||||||
|
t.Fatal("app user can access research inputs")
|
||||||
|
}
|
||||||
|
failed, e := svc.Create(ctx, admin, bi, uuid.NewString())
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
failing := service.NewBacktestService(repo, research.New("/missing/python"), true)
|
||||||
|
if _, e := failing.ProcessNext(ctx); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
failed, e = svc.Detail(ctx, admin, failed.ID)
|
||||||
|
if e != nil || failed.Status != "FAILED" || failed.Result != nil || failed.Failure == "" {
|
||||||
|
t.Fatal("execution failure presented a result")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testTradeLedger(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService) {
|
||||||
|
t.Helper()
|
||||||
|
user, err := auth.ProvisionUser(ctx, "ledger_user", "ledger-test-password", "Ledger", "USER")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cash := "10000.00"
|
||||||
|
account, err := service.NewAccountService(repository.NewAccountRepository(pool)).Create(ctx, user.ID, "simulation", "ledger", &cash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := service.NewTradeFillService(repository.NewTradeFillRepository(pool), true)
|
||||||
|
now := time.Now().UTC().Add(-time.Minute)
|
||||||
|
settlement := now.Add(20 * time.Second)
|
||||||
|
input := service.FillInput{AccountID: account.ID, IdempotencyKey: uuid.NewString(), Side: "BUY", Symbol: "600000", Exchange: "SSE", Name: "测试证券", Quantity: "200", Price: "10.00", Fee: "5.01", OccurredAt: now, SellableAt: &settlement}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make(chan model.TradeFill, 2)
|
||||||
|
failures := make(chan error, 2)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
f, err := svc.Record(ctx, user, input, uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
failures <- err
|
||||||
|
} else {
|
||||||
|
results <- f
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
close(failures)
|
||||||
|
for err := range failures {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
one, two := <-results, <-results
|
||||||
|
if one.ID != two.ID {
|
||||||
|
t.Fatal("idempotent fill duplicated")
|
||||||
|
}
|
||||||
|
input.Price = "11.00"
|
||||||
|
if _, err := svc.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("same idempotency key accepted different input")
|
||||||
|
}
|
||||||
|
input.Price = "10.00"
|
||||||
|
portfolio, err := svc.Portfolio(ctx, user, account.ID)
|
||||||
|
if err != nil || portfolio.CashBalance != "7994.99" || len(portfolio.Positions) != 1 || portfolio.Positions[0].CostBasis != "2005.01" || portfolio.Positions[0].MarketValue != nil {
|
||||||
|
t.Fatalf("buy ledger %+v %v", portfolio, err)
|
||||||
|
}
|
||||||
|
stranger := user
|
||||||
|
stranger.ID = uuid.NewString()
|
||||||
|
if _, err := svc.Portfolio(ctx, stranger, account.ID); !errors.Is(err, apperror.ErrNotFound) {
|
||||||
|
t.Fatal("portfolio owner isolation")
|
||||||
|
}
|
||||||
|
sell := input
|
||||||
|
sell.Side = "SELL"
|
||||||
|
sell.SellableAt = nil
|
||||||
|
sell.IdempotencyKey = uuid.NewString()
|
||||||
|
sell.Quantity = "100"
|
||||||
|
sell.Price = "12.00"
|
||||||
|
sell.Fee = "3.00"
|
||||||
|
sell.OccurredAt = now.Add(10 * time.Second)
|
||||||
|
if _, err := svc.Record(ctx, user, sell, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("unsettled shares sold")
|
||||||
|
}
|
||||||
|
sell.OccurredAt = now.Add(30 * time.Second)
|
||||||
|
sale, err := svc.Record(ctx, user, sell, uuid.NewString())
|
||||||
|
if err != nil || sale.RealizedPnL != "194.50" {
|
||||||
|
t.Fatalf("FIFO sale %+v %v", sale, err)
|
||||||
|
}
|
||||||
|
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||||
|
if err != nil || portfolio.CashBalance != "9191.99" || portfolio.RealizedPnL != "194.50" || portfolio.Review.TradeCount != 2 || portfolio.Review.TotalFees != "8.01" || portfolio.Review.BuyOutflow != "2005.01" || portfolio.Review.SellInflow != "1197.00" || portfolio.Review.ProfitableSellCount != 1 || portfolio.Positions[0].CostBasis != "1002.51" || portfolio.Positions[0].Quantity != "100" {
|
||||||
|
t.Fatal("FIFO rounding or cash mismatch")
|
||||||
|
}
|
||||||
|
if _, err := svc.Reverse(ctx, user, one.ID, "非最新成交", uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("dependent buy reversed")
|
||||||
|
}
|
||||||
|
// Fail the cash reversal write and assert no partial restoration survives.
|
||||||
|
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_cash_entry ADD CONSTRAINT veyra_test_reverse_failure CHECK(entry_type!='REVERSAL') NOT VALID`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Reverse(ctx, user, sale.ID, "回滚检查", uuid.NewString()); err == nil {
|
||||||
|
t.Fatal("injected cash error ignored")
|
||||||
|
}
|
||||||
|
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||||
|
if err != nil || portfolio.Positions[0].Quantity != "100" || portfolio.CashBalance != "9191.99" {
|
||||||
|
t.Fatal("failed reversal changed positions")
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `ALTER TABLE veyra_cash_entry DROP CONSTRAINT veyra_test_reverse_failure`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reversed, err := svc.Reverse(ctx, user, sale.ID, "纠正测试成交", uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
duplicate, err := svc.Reverse(ctx, user, sale.ID, "纠正测试成交", uuid.NewString())
|
||||||
|
if err != nil || duplicate.ID != reversed.ID {
|
||||||
|
t.Fatal("duplicate reversal applied twice")
|
||||||
|
}
|
||||||
|
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||||
|
if err != nil || portfolio.CashBalance != "7994.99" || portfolio.RealizedPnL != "0.00" || portfolio.Positions[0].CostBasis != "2005.01" {
|
||||||
|
t.Fatal("sell reversal restoration mismatch")
|
||||||
|
}
|
||||||
|
if _, err := svc.Reverse(ctx, user, one.ID, "纠正买入", uuid.NewString()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
portfolio, err = svc.Portfolio(ctx, user, account.ID)
|
||||||
|
if err != nil || portfolio.CashBalance != "10000.00" || len(portfolio.Positions) != 0 {
|
||||||
|
t.Fatal("buy reversal did not restore initial ledger")
|
||||||
|
}
|
||||||
|
fills, err := svc.List(ctx, user, account.ID)
|
||||||
|
if err != nil || len(fills) != 2 || !fills[0].Reversed || !fills[1].Reversed {
|
||||||
|
t.Fatal("original fill facts lost")
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE veyra_trade_fill SET quantity=1 WHERE id=$1`, one.ID); err == nil {
|
||||||
|
t.Fatal("posted fill mutation allowed")
|
||||||
|
}
|
||||||
|
input.IdempotencyKey = uuid.NewString()
|
||||||
|
input.Quantity = "1000000"
|
||||||
|
input.Price = "100.00"
|
||||||
|
if _, err := svc.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("insufficient cash accepted")
|
||||||
|
}
|
||||||
|
var entries int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_cash_entry WHERE account_id=$1`, account.ID).Scan(&entries); err != nil || entries != 5 {
|
||||||
|
t.Fatal("failed request left cash or duplicate cash entries")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testTradePlans(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision) {
|
||||||
|
t.Helper()
|
||||||
|
svc := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
|
||||||
|
accounts, err := service.NewAccountService(repository.NewAccountRepository(pool)).List(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var account string
|
||||||
|
for _, a := range accounts {
|
||||||
|
if a.Mode == "simulation" {
|
||||||
|
account = a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input := service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "9000", FeeBudget: "0"}
|
||||||
|
first, err := svc.Preview(ctx, user, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := svc.Preview(ctx, user, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make(chan model.TradePlan, 2)
|
||||||
|
failures := make(chan error, 2)
|
||||||
|
for _, p := range []model.PlanPreview{first, second} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(p model.PlanPreview) {
|
||||||
|
defer wg.Done()
|
||||||
|
plan, err := svc.Confirm(ctx, user, p.ID, uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
failures <- err
|
||||||
|
} else {
|
||||||
|
results <- plan
|
||||||
|
}
|
||||||
|
}(p)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
close(failures)
|
||||||
|
if len(results) != 1 || len(failures) != 1 {
|
||||||
|
t.Fatal("concurrent plans overspent available cash")
|
||||||
|
}
|
||||||
|
for err := range failures {
|
||||||
|
if !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan := <-results
|
||||||
|
same, err := svc.Confirm(ctx, user, plan.PreviewID, uuid.NewString())
|
||||||
|
if err != nil || same.ID != plan.ID {
|
||||||
|
t.Fatal("confirmation retry created duplicate plan")
|
||||||
|
}
|
||||||
|
budget, err := svc.Budget(ctx, user, account)
|
||||||
|
if err != nil || budget.ReservedAmount != "99000.00" || budget.AvailableAmount != "1000.01" {
|
||||||
|
t.Fatalf("reservation budget: %+v %v", budget, err)
|
||||||
|
}
|
||||||
|
stranger := user
|
||||||
|
stranger.ID = uuid.NewString()
|
||||||
|
if _, err := svc.Confirm(ctx, stranger, first.ID, uuid.NewString()); !errors.Is(err, apperror.ErrNotFound) {
|
||||||
|
t.Fatal("cross-user preview confirmation")
|
||||||
|
}
|
||||||
|
if _, err := svc.Cancel(ctx, user, plan.ID, plan.Version+1, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("stale plan cancellation")
|
||||||
|
}
|
||||||
|
if _, err := svc.Cancel(ctx, user, plan.ID, plan.Version, uuid.NewString()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
budget, err = svc.Budget(ctx, user, account)
|
||||||
|
if err != nil || budget.ReservedAmount != "0.00" || budget.CashBalance != "100000.01" {
|
||||||
|
t.Fatal("plan modified cash ledger or failed to release")
|
||||||
|
}
|
||||||
|
input.Quantity = "100"
|
||||||
|
input.FeeBudget = "5.00"
|
||||||
|
expired, err := svc.Preview(ctx, user, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE veyra_plan_preview SET expires_at=$2 WHERE id=$1`, expired.ID, time.Now().Add(-time.Minute)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Confirm(ctx, user, expired.ID, uuid.NewString()); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("expired preview confirmed")
|
||||||
|
}
|
||||||
|
input.ExpectedCandidateVersion++
|
||||||
|
if _, err := svc.Preview(ctx, user, input); !errors.Is(err, apperror.ErrConflict) {
|
||||||
|
t.Fatal("stale candidate preview accepted")
|
||||||
|
}
|
||||||
|
input.ExpectedCandidateVersion--
|
||||||
|
active, err := svc.Preview(ctx, user, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Confirm(ctx, user, active.ID, uuid.NewString()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
testNotifications(t, ctx, pool, user, candidate, account)
|
||||||
|
testLinkedFills(t, ctx, pool, user, candidate, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLinkedFills(t *testing.T, ctx context.Context, pool *pgxpool.Pool, user model.User, candidate model.CandidateRevision, account string) {
|
||||||
|
t.Helper()
|
||||||
|
plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
|
||||||
|
fills := service.NewTradeFillService(repository.NewTradeFillRepository(pool), true)
|
||||||
|
preview, err := plans.Preview(ctx, user, service.PlanPreviewInput{AccountID: account, CandidateRevisionID: candidate.ID, ExpectedCandidateVersion: candidate.Version, Quantity: "200", FeeBudget: "5"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plan, err := plans.Confirm(ctx, user, preview.ID, uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
occurred := time.Now().UTC().Add(-time.Second)
|
||||||
|
settlement := occurred.Add(time.Hour)
|
||||||
|
input := service.FillInput{AccountID: account, PlanID: &plan.ID, IdempotencyKey: uuid.NewString(), Side: "BUY", Symbol: candidate.Snapshot.Symbol, Exchange: candidate.Snapshot.Exchange, Name: candidate.Snapshot.Name, Quantity: "100", Price: "10", Fee: "2", OccurredAt: occurred, SellableAt: &settlement}
|
||||||
|
first, err := fills.Record(ctx, user, input, uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := plans.List(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range list {
|
||||||
|
if p.ID == plan.ID && (p.Status != "PART_FILLED" || p.FilledQuantity != 100 || p.ReservedAmount != "1105.00") {
|
||||||
|
t.Fatal("partial fill reservation mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.IdempotencyKey = uuid.NewString()
|
||||||
|
input.Price = "12"
|
||||||
|
input.Fee = "3"
|
||||||
|
input.OccurredAt = time.Now().UTC()
|
||||||
|
if _, err := fills.Record(ctx, user, input, uuid.NewString()); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||||
|
t.Fatal("unexplained price deviation accepted")
|
||||||
|
}
|
||||||
|
input.Note = "实际模拟成交偏离参考区间"
|
||||||
|
last, err := fills.Record(ctx, user, input, uuid.NewString())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err = plans.List(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range list {
|
||||||
|
if p.ID == plan.ID && (p.Status != "COMPLETED" || p.FilledQuantity != 200 || p.ReservedMinor != 0) {
|
||||||
|
t.Fatal("completed plan kept reservation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fills.Reverse(ctx, user, last.ID, "纠正偏离录入", uuid.NewString()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := fills.Reverse(ctx, user, first.ID, "纠正第一笔录入", uuid.NewString()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err = plans.List(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range list {
|
||||||
|
if p.ID == plan.ID && (p.Status != "INVALIDATED" || p.FilledQuantity != 0 || p.ReservedMinor != 0) {
|
||||||
|
t.Fatal("reversal silently reopened plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Package logging supplies readable console output and rotating structured files.
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Directory, Level string
|
||||||
|
MaxSizeMB, MaxBackups, MaxAgeDays int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(o Options) (*zap.Logger, func(), error) {
|
||||||
|
level, err := zapcore.ParseLevel(o.Level)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid LOG_LEVEL")
|
||||||
|
}
|
||||||
|
if o.Directory == "" || o.MaxSizeMB < 1 || o.MaxBackups < 1 || o.MaxAgeDays < 1 {
|
||||||
|
return nil, nil, fmt.Errorf("invalid logging configuration")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(o.Directory, 0750); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("create log directory: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(o.Directory, "veyra.log")
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("open log file: %w", err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("close initial log file: %w", err)
|
||||||
|
}
|
||||||
|
file := &lumberjack.Logger{Filename: path, MaxSize: o.MaxSizeMB, MaxBackups: o.MaxBackups, MaxAge: o.MaxAgeDays, Compress: true, LocalTime: true}
|
||||||
|
encoder := zap.NewProductionEncoderConfig()
|
||||||
|
encoder.TimeKey = "time"
|
||||||
|
encoder.MessageKey = "message"
|
||||||
|
encoder.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
|
console := encoder
|
||||||
|
console.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||||
|
core := zapcore.NewTee(
|
||||||
|
zapcore.NewCore(zapcore.NewConsoleEncoder(console), zapcore.Lock(zapcore.AddSync(os.Stdout)), level),
|
||||||
|
zapcore.NewCore(zapcore.NewJSONEncoder(encoder), zapcore.AddSync(file), level),
|
||||||
|
)
|
||||||
|
logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.DPanicLevel))
|
||||||
|
return logger, func() { _ = logger.Sync(); _ = file.Close() }, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileOutputAndLevel(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
logger, closeLogs, err := New(Options{dir, "warn", 1, 2, 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger.Info("filtered")
|
||||||
|
logger.Warn("request rejected", zap.String("request_id", "test-request"), zap.Int("status", 409))
|
||||||
|
closeLogs()
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "veyra.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var row struct {
|
||||||
|
Level, Message, RequestID string
|
||||||
|
Status int
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(fields["level"], &row.Level)
|
||||||
|
_ = json.Unmarshal(fields["message"], &row.Message)
|
||||||
|
_ = json.Unmarshal(fields["request_id"], &row.RequestID)
|
||||||
|
_ = json.Unmarshal(fields["status"], &row.Status)
|
||||||
|
if row.Level != "warn" || row.Message != "request rejected" || row.RequestID != "test-request" || row.Status != 409 || strings.Contains(string(data), "filtered") {
|
||||||
|
t.Fatalf("invalid structured log: %s", data)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(dir, "veyra.log"))
|
||||||
|
if err != nil || info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatal("log file must be private")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogRotation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
stdout := os.Stdout
|
||||||
|
discard, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer discard.Close()
|
||||||
|
os.Stdout = discard
|
||||||
|
logger, closeLogs, err := New(Options{dir, "info", 1, 2, 1})
|
||||||
|
os.Stdout = stdout
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer closeLogs()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
logger.Info("rotation", zap.String("payload", strings.Repeat("x", 400000)))
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), "veyra-") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("exceeding size must rotate the log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotOpenLogs(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "file")
|
||||||
|
if err := os.WriteFile(path, nil, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := New(Options{path, "info", 1, 1, 1}); err == nil {
|
||||||
|
t.Fatal("unwritable log directory must fail startup")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// Package market adapts external market responses without floating point prices.
|
||||||
|
package market
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct{ http *http.Client }
|
||||||
|
|
||||||
|
func New() *Client {
|
||||||
|
return &Client{http: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||||
|
}
|
||||||
|
func (c *Client) get(ctx context.Context, base, path string, params url.Values, target any, timeout ...int) error {
|
||||||
|
seconds := 4
|
||||||
|
if len(timeout) > 0 && timeout[0] >= 1 && timeout[0] <= 30 {
|
||||||
|
seconds = timeout[0]
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
u, e := url.Parse(base)
|
||||||
|
if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return errors.New("invalid market endpoint")
|
||||||
|
}
|
||||||
|
u.Path = strings.TrimRight(u.Path, "/") + path
|
||||||
|
u.RawQuery = params.Encode()
|
||||||
|
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if e != nil {
|
||||||
|
return errors.New("invalid market request")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
referer := "https://finance.sina.com.cn/"
|
||||||
|
if strings.HasSuffix(u.Hostname(), "eastmoney.com") {
|
||||||
|
referer = "https://quote.eastmoney.com/"
|
||||||
|
}
|
||||||
|
req.Header.Set("Referer", referer)
|
||||||
|
response, e := c.http.Do(req)
|
||||||
|
if e != nil {
|
||||||
|
return errors.New("market provider unavailable")
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != 200 {
|
||||||
|
return errors.New("market provider rejected request")
|
||||||
|
}
|
||||||
|
data, e := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1))
|
||||||
|
if e != nil || len(data) > 4<<20 {
|
||||||
|
return errors.New("invalid market response size")
|
||||||
|
}
|
||||||
|
if json.Unmarshal(data, target) != nil {
|
||||||
|
return errors.New("invalid market response")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (c *Client) Snapshot(ctx context.Context, config model.ProviderConfig) ([]model.Instrument, error) {
|
||||||
|
if config.MarketAdapter == "sina_public" {
|
||||||
|
return c.sinaSnapshot(ctx, config)
|
||||||
|
}
|
||||||
|
list := make([]model.Instrument, 0)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for page := 1; page <= 120; page++ {
|
||||||
|
var response struct {
|
||||||
|
RC int `json:"rc"`
|
||||||
|
Data *struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Diff map[string]struct {
|
||||||
|
Symbol string `json:"f12"`
|
||||||
|
Market int `json:"f13"`
|
||||||
|
Name string `json:"f14"`
|
||||||
|
Price json.RawMessage `json:"f2"`
|
||||||
|
Observed int64 `json:"f124"`
|
||||||
|
Decimals int `json:"f152"`
|
||||||
|
} `json:"diff"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
p := url.Values{"pn": {strconv.Itoa(page)}, "pz": {"100"}, "fs": {"m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23"}, "fields": {"f12,f13,f14,f2,f124,f152"}, "fid": {"f12"}, "po": {"0"}}
|
||||||
|
if e := c.get(ctx, config.BaseURL, "/api/qt/clist/get", p, &response, config.TimeoutSeconds); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if response.RC != 0 || response.Data == nil || response.Data.Total < 1 || response.Data.Total > 12000 || len(response.Data.Diff) == 0 {
|
||||||
|
return nil, errors.New("invalid market universe")
|
||||||
|
}
|
||||||
|
for _, row := range response.Data.Diff {
|
||||||
|
if len(row.Symbol) != 6 || row.Name == "" || (row.Market != 0 && row.Market != 1) || row.Decimals != 2 {
|
||||||
|
return nil, errors.New("unsupported market precision")
|
||||||
|
}
|
||||||
|
price, e := strconv.ParseInt(string(row.Price), 10, 64)
|
||||||
|
if e != nil || price < 0 || price > 100000000 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
when := time.Unix(row.Observed, 0).UTC()
|
||||||
|
if row.Observed <= 0 || when.After(time.Now().Add(time.Minute)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exchange := "SZSE"
|
||||||
|
if row.Market == 1 {
|
||||||
|
exchange = "SSE"
|
||||||
|
}
|
||||||
|
key := exchange + row.Symbol
|
||||||
|
if seen[key] {
|
||||||
|
return nil, errors.New("duplicate market page")
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
list = append(list, model.Instrument{Symbol: row.Symbol, Exchange: exchange, Name: row.Name, PriceCents: price, Price: model.FormatMoney(price), ObservedAt: when, SourceID: config.ID, SourceVersion: config.Version})
|
||||||
|
}
|
||||||
|
if page*100 >= response.Data.Total {
|
||||||
|
sort.Slice(list, func(i, j int) bool { return list[i].Symbol < list[j].Symbol })
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("market pagination limit exceeded")
|
||||||
|
}
|
||||||
|
func (c *Client) History(ctx context.Context, config model.ProviderConfig, symbol, exchange, start, end string) (model.MarketHistory, error) {
|
||||||
|
if config.MarketAdapter == "sina_public" {
|
||||||
|
return c.sinaHistory(ctx, config, symbol, exchange, start, end)
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
RC int `json:"rc"`
|
||||||
|
Data *struct {
|
||||||
|
Code, Name string
|
||||||
|
Klines []string `json:"klines"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
secid := "0." + symbol
|
||||||
|
if exchange == "SSE" {
|
||||||
|
secid = "1." + symbol
|
||||||
|
}
|
||||||
|
params := url.Values{"secid": {secid}, "klt": {"101"}, "fqt": {"0"}, "beg": {strings.ReplaceAll(start, "-", "")}, "end": {strings.ReplaceAll(end, "-", "")}, "fields1": {"f1,f2,f3,f4,f5,f6"}, "fields2": {"f51,f52,f53,f54,f55,f56,f57"}}
|
||||||
|
result := model.MarketHistory{Symbol: symbol, Exchange: exchange, Source: "eastmoney_public", SourceVersion: config.Version, Adjustment: "unadjusted", ReceivedAt: time.Now().UTC(), Bars: make([]model.HistoricalBar, 0)}
|
||||||
|
if e := c.get(ctx, config.HistoryBaseURL, "/api/qt/stock/kline/get", params, &response, config.TimeoutSeconds); e != nil {
|
||||||
|
return result, e
|
||||||
|
}
|
||||||
|
if response.RC != 0 || response.Data == nil || response.Data.Code != symbol || len(response.Data.Klines) > 1000 {
|
||||||
|
return result, errors.New("invalid market history")
|
||||||
|
}
|
||||||
|
result.Name = response.Data.Name
|
||||||
|
previous := ""
|
||||||
|
for _, line := range response.Data.Klines {
|
||||||
|
parts := strings.Split(line, ",")
|
||||||
|
if len(parts) < 5 {
|
||||||
|
return result, errors.New("invalid historical bar")
|
||||||
|
}
|
||||||
|
date, e := time.Parse("2006-01-02", parts[0])
|
||||||
|
if e != nil || parts[0] <= previous || parts[0] < start || parts[0] > end || date.After(time.Now()) {
|
||||||
|
return result, errors.New("invalid historical date")
|
||||||
|
}
|
||||||
|
previous = parts[0]
|
||||||
|
o, e1 := model.ParseMoney(parts[1])
|
||||||
|
close, e2 := model.ParseMoney(parts[2])
|
||||||
|
high, e3 := model.ParseMoney(parts[3])
|
||||||
|
low, e4 := model.ParseMoney(parts[4])
|
||||||
|
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || low <= 0 || high > 100000000 || low > o || low > close || high < o || high < close {
|
||||||
|
return result, fmt.Errorf("invalid historical OHLC")
|
||||||
|
}
|
||||||
|
result.Bars = append(result.Bars, model.HistoricalBar{Date: parts[0], Open: model.FormatMoney(o), High: model.FormatMoney(high), Low: model.FormatMoney(low), Close: model.FormatMoney(close)})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package market
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarketPrecisionAndTimestamp(t *testing.T) {
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"rc":0,"data":{"total":1,"diff":{"0":{"f12":"600000","f13":1,"f14":"fixture","f2":1001,"f124":1700000000,"f152":2}}}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := &Client{http: server.Client()}
|
||||||
|
rows, e := client.Snapshot(context.Background(), model.ProviderConfig{BaseURL: server.URL, ID: "source", Version: 3})
|
||||||
|
if e != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("snapshot: %v", e)
|
||||||
|
}
|
||||||
|
if rows[0].Price != "10.01" || rows[0].ObservedAt.Unix() != 1700000000 || rows[0].SourceVersion != 3 {
|
||||||
|
t.Fatal("lost integer precision or provider timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHistoryOHLCAndDates(t *testing.T) {
|
||||||
|
response := `{"rc":0,"data":{"code":"600000","name":"fixture","klines":["2025-01-02,10.01,10.11,10.21,9.99,100,1"]}}`
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("fqt") != "0" {
|
||||||
|
t.Error("adjustment changed")
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, response)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := &Client{http: server.Client()}
|
||||||
|
input := model.ProviderConfig{HistoryBaseURL: server.URL, Version: 4}
|
||||||
|
data, e := client.History(context.Background(), input, "600000", "SSE", "2025-01-01", "2025-01-03")
|
||||||
|
if e != nil || len(data.Bars) != 1 || data.Bars[0].Close != "10.11" || data.Adjustment != "unadjusted" {
|
||||||
|
t.Fatalf("history: %v", e)
|
||||||
|
}
|
||||||
|
response = `{"rc":0,"data":{"code":"600000","name":"fixture","klines":["2025-01-02,10.01,10.11,10.00,9.99,100,1"]}}`
|
||||||
|
if _, e := client.History(context.Background(), input, "600000", "SSE", "2025-01-01", "2025-01-03"); e == nil {
|
||||||
|
t.Fatal("invalid high accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSinaQuotesKeepVendorDate(t *testing.T) {
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/quotes_service/api/json_v2.php/Market_Center.getHQNodeData":
|
||||||
|
fmt.Fprint(w, `[{"symbol":"sh600000","code":"600000","name":"fixture"}]`)
|
||||||
|
case "/list=sh600000":
|
||||||
|
fmt.Fprint(w, `var hq_str_sh600000="fixture,10.000,10.000,10.010,10.100,9.900,10.000,10.010,100,1000,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2025-01-02,15:00:00,00";`)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected route %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := &Client{http: server.Client()}
|
||||||
|
rows, e := client.Snapshot(context.Background(), model.ProviderConfig{MarketAdapter: "sina_public", BaseURL: server.URL, QuoteBaseURL: server.URL})
|
||||||
|
if e != nil || len(rows) != 1 || rows[0].Price != "10.01" || rows[0].ObservedAt.Format("2006-01-02T15:04:05Z") != "2025-01-02T07:00:00Z" {
|
||||||
|
t.Fatalf("quote validation: %+v, %v", rows, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestPublicPricesRejectRounding(t *testing.T) {
|
||||||
|
for _, v := range []string{"10.001", "1e2", "NaN"} {
|
||||||
|
if _, e := exactPublicPrice(v); e == nil {
|
||||||
|
t.Fatalf("rounded or invalid price %s", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, e := exactPublicPrice("10.010"); e != nil || v != 1001 {
|
||||||
|
t.Fatal("exact extra zero rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package market
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
_ "time/tzdata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func exactPublicPrice(s string) (int64, error) {
|
||||||
|
if strings.Contains(s, ".") {
|
||||||
|
s = strings.TrimRight(s, "0")
|
||||||
|
s = strings.TrimSuffix(s, ".")
|
||||||
|
}
|
||||||
|
return model.ParseMoney(s)
|
||||||
|
}
|
||||||
|
func (c *Client) sinaSnapshot(ctx context.Context, config model.ProviderConfig) ([]model.Instrument, error) {
|
||||||
|
all := make([]model.Instrument, 0)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
zone, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
for page := 1; page <= 150; page++ {
|
||||||
|
var rows []struct{ Symbol, Code, Name string }
|
||||||
|
p := url.Values{"page": {strconv.Itoa(page)}, "num": {"80"}, "sort": {"symbol"}, "asc": {"1"}, "node": {"hs_a"}, "symbol": {""}}
|
||||||
|
p.Set("symbol", "")
|
||||||
|
if e := c.get(ctx, config.BaseURL, "/quotes_service/api/json_v2.php/Market_Center.getHQNodeData", p, &rows, config.TimeoutSeconds); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
if len(all) == 0 {
|
||||||
|
return nil, errors.New("empty market universe")
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
symbols := make([]string, 0)
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if !(strings.HasPrefix(row.Symbol, "sh") || strings.HasPrefix(row.Symbol, "sz")) || len(row.Code) != 6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[row.Symbol] {
|
||||||
|
return nil, errors.New("duplicate market page")
|
||||||
|
}
|
||||||
|
seen[row.Symbol] = true
|
||||||
|
symbols = append(symbols, row.Symbol)
|
||||||
|
names[row.Symbol] = row.Name
|
||||||
|
}
|
||||||
|
if len(symbols) > 0 {
|
||||||
|
u, e := url.Parse(config.QuoteBaseURL)
|
||||||
|
if e != nil {
|
||||||
|
return nil, errors.New("invalid quote endpoint")
|
||||||
|
}
|
||||||
|
u.Path = strings.TrimRight(u.Path, "/") + "/list=" + strings.Join(symbols, ",")
|
||||||
|
req, e := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
if e != nil {
|
||||||
|
return nil, errors.New("invalid quote request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Referer", "https://finance.sina.com.cn/")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||||
|
seconds := config.TimeoutSeconds
|
||||||
|
if seconds < 1 || seconds > 30 {
|
||||||
|
seconds = 4
|
||||||
|
}
|
||||||
|
quoteCtx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
|
||||||
|
req = req.WithContext(quoteCtx)
|
||||||
|
resp, e := c.http.Do(req)
|
||||||
|
if e != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, errors.New("quote provider unavailable")
|
||||||
|
}
|
||||||
|
data, readError := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
resp.Body.Close()
|
||||||
|
cancel()
|
||||||
|
if readError != nil || resp.StatusCode != 200 {
|
||||||
|
return nil, errors.New("quote provider rejected request")
|
||||||
|
}
|
||||||
|
decoded, e := simplifiedchinese.GBK.NewDecoder().Bytes(data)
|
||||||
|
if e != nil {
|
||||||
|
return nil, errors.New("invalid quote text")
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(decoded), ";") {
|
||||||
|
left, right, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
symbol := strings.TrimPrefix(left, "var hq_str_")
|
||||||
|
name, exists := names[symbol]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(right, "\"\r\n "), ",")
|
||||||
|
if len(parts) < 32 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
price, e := exactPublicPrice(parts[3])
|
||||||
|
when, dateError := time.ParseInLocation("2006-01-02 15:04:05", parts[30]+" "+parts[31], zone)
|
||||||
|
if e != nil || dateError != nil || price <= 0 || price > 100000000 || when.After(time.Now().Add(time.Minute)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exchange := "SZSE"
|
||||||
|
if strings.HasPrefix(symbol, "sh") {
|
||||||
|
exchange = "SSE"
|
||||||
|
}
|
||||||
|
all = append(all, model.Instrument{Symbol: symbol[2:], Exchange: exchange, Name: name, Price: model.FormatMoney(price), PriceCents: price, ObservedAt: when.UTC(), SourceID: config.ID, SourceVersion: config.Version})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rows) < 80 {
|
||||||
|
if len(all) == 0 {
|
||||||
|
return nil, errors.New("no valid market quotes")
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("market universe exceeds limit")
|
||||||
|
}
|
||||||
|
func (c *Client) sinaHistory(ctx context.Context, config model.ProviderConfig, symbol, exchange, start, end string) (model.MarketHistory, error) {
|
||||||
|
ticker := "sz" + symbol
|
||||||
|
if exchange == "SSE" {
|
||||||
|
ticker = "sh" + symbol
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Result struct {
|
||||||
|
Status struct{ Code int }
|
||||||
|
Data []struct{ Day, Open, High, Low, Close string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := url.Values{"symbol": {ticker}, "scale": {"240"}, "ma": {"no"}, "datalen": {"1000"}}
|
||||||
|
result := model.MarketHistory{Symbol: symbol, Exchange: exchange, Source: "sina_public", SourceVersion: config.Version, Adjustment: "unadjusted", ReceivedAt: time.Now().UTC(), Bars: make([]model.HistoricalBar, 0)}
|
||||||
|
if e := c.get(ctx, config.HistoryBaseURL, "/cn/api/openapi.php/CN_MarketDataService.getKLineData", p, &envelope, config.TimeoutSeconds); e != nil {
|
||||||
|
return result, e
|
||||||
|
}
|
||||||
|
if envelope.Result.Status.Code != 0 || len(envelope.Result.Data) == 0 {
|
||||||
|
return result, errors.New("historical provider unavailable")
|
||||||
|
}
|
||||||
|
previous := ""
|
||||||
|
for _, b := range envelope.Result.Data {
|
||||||
|
date := strings.Split(b.Day, " ")[0]
|
||||||
|
if date < start || date > end {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, e := time.Parse("2006-01-02", date)
|
||||||
|
o, e1 := exactPublicPrice(b.Open)
|
||||||
|
h, e2 := exactPublicPrice(b.High)
|
||||||
|
l, e3 := exactPublicPrice(b.Low)
|
||||||
|
v, e4 := exactPublicPrice(b.Close)
|
||||||
|
if e != nil || date <= previous || e1 != nil || e2 != nil || e3 != nil || e4 != nil || l <= 0 || l > o || l > v || h < o || h < v || h > 100000000 {
|
||||||
|
return result, errors.New("invalid provider OHLC")
|
||||||
|
}
|
||||||
|
previous = date
|
||||||
|
result.Bars = append(result.Bars, model.HistoricalBar{Date: date, Open: model.FormatMoney(o), High: model.FormatMoney(h), Low: model.FormatMoney(l), Close: model.FormatMoney(v)})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HistoricalBar struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Open string `json:"open"`
|
||||||
|
High string `json:"high"`
|
||||||
|
Low string `json:"low"`
|
||||||
|
Close string `json:"close"`
|
||||||
|
}
|
||||||
|
type BacktestInput struct {
|
||||||
|
DataOrigin string `json:"data_origin"`
|
||||||
|
SourceVersion int64 `json:"source_version"`
|
||||||
|
DataReceivedAt *time.Time `json:"data_received_at"`
|
||||||
|
StrategyVersionID string `json:"strategy_version_id"`
|
||||||
|
DatasetName string `json:"dataset_name"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
InitialCash string `json:"initial_cash"`
|
||||||
|
Bars []HistoricalBar `json:"bars"`
|
||||||
|
}
|
||||||
|
type ResearchInput struct {
|
||||||
|
BacktestInput
|
||||||
|
Parameters StrategyParameters `json:"parameters"`
|
||||||
|
}
|
||||||
|
type BacktestRun struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
StrategyVersionID string `db:"strategy_version_id" json:"strategy_version_id"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
Input json.RawMessage `db:"input" json:"input,omitempty"`
|
||||||
|
InputHash string `db:"input_hash" json:"input_hash"`
|
||||||
|
Result json.RawMessage `db:"result" json:"result"`
|
||||||
|
Failure string `db:"failure" json:"failure"`
|
||||||
|
LeaseID *string `db:"lease_id" json:"-"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
FinishedAt *time.Time `db:"finished_at" json:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BacktestRun) TableName() string { return "veyra_backtest_run" }
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Candidate struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
LatestRevision int `db:"latest_revision" json:"latest_revision"`
|
||||||
|
PublishedRevisionID *string `db:"published_revision_id" json:"published_revision_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Candidate) TableName() string { return "veyra_candidate" }
|
||||||
|
|
||||||
|
type CandidateEvidence struct {
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
Locator string `json:"locator"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
}
|
||||||
|
type CandidateReferencePlan struct {
|
||||||
|
EntryLow string `json:"entry_low"`
|
||||||
|
EntryHigh string `json:"entry_high"`
|
||||||
|
StopLoss string `json:"stop_loss"`
|
||||||
|
TargetPrice string `json:"target_price"`
|
||||||
|
}
|
||||||
|
type CandidateContent struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Exchange string `json:"exchange"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
StrategyCode string `json:"strategy_code"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Thesis string `json:"thesis"`
|
||||||
|
Evidence []CandidateEvidence `json:"evidence"`
|
||||||
|
CounterEvidence string `json:"counter_evidence"`
|
||||||
|
Invalidation string `json:"invalidation"`
|
||||||
|
ReferencePlan CandidateReferencePlan `json:"reference_plan"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandidateRevision struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
CandidateID string `db:"candidate_id" json:"candidate_id"`
|
||||||
|
Revision int `db:"revision" json:"revision"`
|
||||||
|
Version int64 `db:"version" json:"version"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
DataMode string `db:"data_mode" json:"data_mode"`
|
||||||
|
Snapshot CandidateContent `db:"snapshot" json:"content"`
|
||||||
|
AuthorID string `db:"author_id" json:"author_id"`
|
||||||
|
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
|
||||||
|
ValidUntil time.Time `db:"valid_until" json:"valid_until"`
|
||||||
|
PublishedAt *time.Time `db:"published_at" json:"published_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
IsLatest bool `json:"is_latest"`
|
||||||
|
WithdrawalReason string `json:"withdrawal_reason"`
|
||||||
|
IsActivePublication bool `json:"is_active_publication"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CandidateRevision) TableName() string { return "veyra_candidate_revision" }
|
||||||
|
|
||||||
|
type CandidateReview struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
RevisionID string `db:"revision_id" json:"revision_id"`
|
||||||
|
ReviewerID string `db:"reviewer_id" json:"reviewer_id"`
|
||||||
|
Decision string `db:"decision" json:"decision"`
|
||||||
|
InputVersion int64 `db:"input_version" json:"input_version"`
|
||||||
|
Reason string `db:"reason" json:"reason"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CandidateReview) TableName() string { return "veyra_candidate_review" }
|
||||||
|
|
||||||
|
type CandidateDraftInput struct {
|
||||||
|
Content CandidateContent `json:"content"`
|
||||||
|
ObservedAt time.Time `json:"observed_at"`
|
||||||
|
ValidUntil time.Time `json:"valid_until"`
|
||||||
|
}
|
||||||
|
type CandidateRevisionInput struct {
|
||||||
|
CandidateDraftInput
|
||||||
|
ExpectedVersion int64 `json:"expected_version"`
|
||||||
|
}
|
||||||
|
type CandidateActionInput struct {
|
||||||
|
ExpectedVersion int64 `json:"expected_version"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
type CandidateDetail struct {
|
||||||
|
CandidateRevision
|
||||||
|
Reviews []CandidateReview `json:"reviews"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbolPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||||
|
|
||||||
|
func textValid(s string, max int) bool {
|
||||||
|
return strings.TrimSpace(s) != "" && utf8.RuneCountInString(s) <= max
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateDraft(input CandidateDraftInput, now time.Time) error {
|
||||||
|
c := input.Content
|
||||||
|
if !symbolPattern.MatchString(c.Symbol) || c.Exchange != "SSE" && c.Exchange != "SZSE" || !textValid(c.Name, 80) || !textValid(c.Title, 120) || !textValid(c.Thesis, 2000) || !textValid(c.CounterEvidence, 1000) || !textValid(c.Invalidation, 1000) {
|
||||||
|
return apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
switch c.StrategyCode {
|
||||||
|
case "trend_following", "pullback", "event_driven":
|
||||||
|
default:
|
||||||
|
return apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if len(c.Evidence) < 1 || len(c.Evidence) > 8 {
|
||||||
|
return apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
for _, e := range c.Evidence {
|
||||||
|
u, err := url.Parse(e.SourceURL)
|
||||||
|
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || len(e.SourceURL) > 2048 || !textValid(e.Locator, 200) || !textValid(e.Summary, 1000) {
|
||||||
|
return apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
low, e1 := ParseMoney(c.ReferencePlan.EntryLow)
|
||||||
|
high, e2 := ParseMoney(c.ReferencePlan.EntryHigh)
|
||||||
|
stop, e3 := ParseMoney(c.ReferencePlan.StopLoss)
|
||||||
|
target, e4 := ParseMoney(c.ReferencePlan.TargetPrice)
|
||||||
|
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || stop <= 0 || stop >= low || low > high || target <= high {
|
||||||
|
return apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if input.ObservedAt.IsZero() || input.ObservedAt.After(now.Add(time.Minute)) || input.ObservedAt.Before(now.Add(-24*time.Hour)) || !input.ValidUntil.After(now) || input.ValidUntil.After(now.Add(7*24*time.Hour)) || !input.ValidUntil.After(input.ObservedAt) {
|
||||||
|
return apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(c)
|
||||||
|
if err != nil || len(encoded) > 12<<10 {
|
||||||
|
return apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextStatus is the shared state rule; repositories recheck it under the aggregate lock.
|
||||||
|
func NextStatus(r CandidateRevision, action string, now time.Time) (string, error) {
|
||||||
|
if action == "WITHDRAW" {
|
||||||
|
if r.Status != "PUBLISHED" || !r.IsActivePublication {
|
||||||
|
return "", apperror.ErrConflict
|
||||||
|
}
|
||||||
|
return "WITHDRAWN", nil
|
||||||
|
}
|
||||||
|
if !r.IsLatest {
|
||||||
|
return "", apperror.ErrConflict
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "SUBMIT":
|
||||||
|
if r.Status != "DRAFT" {
|
||||||
|
return "", apperror.ErrConflict
|
||||||
|
}
|
||||||
|
if err := ValidateDraft(CandidateDraftInput{Content: r.Snapshot, ObservedAt: r.ObservedAt, ValidUntil: r.ValidUntil}, now); err != nil {
|
||||||
|
return "", apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return "PENDING_REVIEW", nil
|
||||||
|
case "PUBLISH":
|
||||||
|
if r.Status != "PENDING_REVIEW" {
|
||||||
|
return "", apperror.ErrConflict
|
||||||
|
}
|
||||||
|
if err := ValidateDraft(CandidateDraftInput{Content: r.Snapshot, ObservedAt: r.ObservedAt, ValidUntil: r.ValidUntil}, now); err != nil {
|
||||||
|
return "", apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return "PUBLISHED", nil
|
||||||
|
case "RETURN":
|
||||||
|
if r.Status != "PENDING_REVIEW" {
|
||||||
|
return "", apperror.ErrConflict
|
||||||
|
}
|
||||||
|
return "RETURNED", nil
|
||||||
|
default:
|
||||||
|
return "", apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateReason(reason string) error {
|
||||||
|
if !textValid(reason, 1000) {
|
||||||
|
return apperror.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Instrument struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
Symbol string `db:"symbol" json:"symbol"`
|
||||||
|
Exchange string `db:"exchange" json:"exchange"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Price string `json:"price"`
|
||||||
|
PriceCents int64 `db:"price_cents" json:"-"`
|
||||||
|
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
|
||||||
|
ReceivedAt time.Time `db:"received_at" json:"received_at"`
|
||||||
|
SourceID string `db:"source_id" json:"source_id"`
|
||||||
|
SourceVersion int64 `db:"source_version" json:"source_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Instrument) TableName() string { return "veyra_instrument" }
|
||||||
|
|
||||||
|
type MarketSync struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
SourceID string `db:"source_id" json:"source_id"`
|
||||||
|
SourceVersion int64 `db:"source_version" json:"source_version"`
|
||||||
|
InstrumentCount int `db:"instrument_count" json:"instrument_count"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
Message string `db:"message" json:"message"`
|
||||||
|
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||||
|
FinishedAt time.Time `db:"finished_at" json:"finished_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MarketSync) TableName() string { return "veyra_market_sync" }
|
||||||
|
|
||||||
|
type MarketHistory struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Exchange string `json:"exchange"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceVersion int64 `json:"source_version"`
|
||||||
|
Adjustment string `json:"adjustment"`
|
||||||
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
|
Bars []HistoricalBar `json:"bars"`
|
||||||
|
}
|
||||||
@@ -33,4 +33,12 @@ func ParseMoney(value string) (int64, error) {
|
|||||||
return amount, nil
|
return amount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatMoney(minor int64) string { return fmt.Sprintf("%d.%02d", minor/100, minor%100) }
|
func FormatMoney(minor int64) string {
|
||||||
|
sign := ""
|
||||||
|
absolute := uint64(minor)
|
||||||
|
if minor < 0 {
|
||||||
|
sign = "-"
|
||||||
|
absolute = uint64(-(minor + 1)) + 1
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%d.%02d", sign, absolute/100, absolute%100)
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,3 +19,14 @@ func TestExactMoney(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSignedLedgerMoney(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
minor int64
|
||||||
|
want string
|
||||||
|
}{{-1, "-0.01"}, {-100, "-1.00"}, {-1005, "-10.05"}, {0, "0.00"}, {-9223372036854775808, "-92233720368547758.08"}} {
|
||||||
|
if got := FormatMoney(tc.minor); got != tc.want {
|
||||||
|
t.Fatalf("got %s want %s", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Notification struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
UserID string `db:"user_id" json:"-"`
|
||||||
|
EventID string `db:"event_id" json:"event_id"`
|
||||||
|
PlanID *string `db:"plan_id" json:"plan_id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Body string `db:"body" json:"body"`
|
||||||
|
ReadAt *time.Time `db:"read_at" json:"read_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Notification) TableName() string { return "veyra_notification" }
|
||||||
|
|
||||||
|
type QuoteSnapshot struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
Symbol string `db:"symbol" json:"symbol"`
|
||||||
|
Exchange string `db:"exchange" json:"exchange"`
|
||||||
|
DataMode string `db:"data_mode" json:"data_mode"`
|
||||||
|
PriceMinor int64 `db:"price_minor" json:"-"`
|
||||||
|
Price string `json:"price"`
|
||||||
|
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
|
||||||
|
Version int64 `db:"version" json:"version"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (QuoteSnapshot) TableName() string { return "veyra_quote_snapshot" }
|
||||||
|
|
||||||
|
type PlanTrigger struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
PlanID string `db:"plan_id"`
|
||||||
|
QuoteSnapshotID string `db:"quote_snapshot_id"`
|
||||||
|
QuoteVersion int64 `db:"quote_version"`
|
||||||
|
TriggeredAt time.Time `db:"triggered_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PlanTrigger) TableName() string { return "veyra_plan_trigger" }
|
||||||
|
|
||||||
|
type NotificationTask struct {
|
||||||
|
OutboxEvent
|
||||||
|
RecipientCount int `json:"recipient_count"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OutboxEvent struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
AggregateType string `db:"aggregate_type" json:"aggregate_type"`
|
||||||
|
AggregateID string `db:"aggregate_id" json:"aggregate_id"`
|
||||||
|
AggregateVersion int64 `db:"aggregate_version" json:"aggregate_version"`
|
||||||
|
EventType string `db:"event_type" json:"event_type"`
|
||||||
|
Payload json.RawMessage `db:"payload" json:"payload"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
ProcessedAt *time.Time `db:"processed_at" json:"processed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (OutboxEvent) TableName() string { return "veyra_outbox_event" }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user