feat: 完成模拟交易闭环、App 与 A 股行情接入
Checks / verify (push) Failing after 9m52s

统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
This commit is contained in:
2026-10-08 22:58:52 +08:00
parent bffdb3858b
commit 92329ddce2
145 changed files with 18593 additions and 587 deletions
-19
View File
@@ -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=
+15 -4
View File
@@ -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)"
+6
View File
@@ -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/
+8 -3
View File
@@ -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 或请求正文。
-27
View File
@@ -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
+77 -22
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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=
+6
View File
@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
+37
View File
@@ -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 或到期清理身份与查询缓存。旧会话的迟到响应不会污染新登录用户。切换账户会中断旧查询、清理账户详情缓存;金额使用十进制字符串与整数校验,不做浮点账本计算。
+10
View File
@@ -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}
}
}
+12
View File
@@ -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>
}
+6
View File
@@ -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>}
+11
View File
@@ -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>
}
+16
View File
@@ -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>
}
+17
View File
@@ -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>
}
+6
View File
@@ -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>}
+15
View File
@@ -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>}
+14
View File
@@ -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>
}
+23
View File
@@ -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"&&params.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>
}
+9
View File
@@ -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>
}
+11
View File
@@ -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>
}
+15
View File
@@ -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>
}
+19
View File
@@ -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>
}
+22
View File
@@ -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>
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = function (api) {
api.cache(true)
return { presets: ["babel-preset-expo"] }
}
+2
View File
@@ -0,0 +1,2 @@
const { getDefaultConfig } = require("expo/metro-config")
module.exports = getDefaultConfig(__dirname)
+9010
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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>
}
+13
View File
@@ -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}})
+37
View File
@@ -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)}
}
}
+2
View File
@@ -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}
+2
View File
@@ -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}[]}
+13
View File
@@ -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&&cents<=100000000000n
}
+5
View File
@@ -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:"已失效"}
+11
View File
@@ -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}
}
+11
View File
@@ -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)
+4
View File
@@ -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}
+2
View File
@@ -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})}))
+11
View File
@@ -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)}}
+47
View File
@@ -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}
+42
View File
@@ -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")
})
+11
View File
@@ -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)
})
+13
View File
@@ -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)}
})
+20
View File
@@ -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)
})
+18
View File
@@ -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"
]
}
-85
View File
@@ -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)
}
}
-58
View File
@@ -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
}
-73
View File
@@ -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
}
+32
View File
@@ -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
View File
@@ -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。
+3
View File
@@ -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
+8
View File
@@ -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=
+1 -1
View File
@@ -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 != "." {
+10 -1
View File
@@ -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")
) )
+17 -1
View File
@@ -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")
+5 -1
View File
@@ -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 '';
+75
View File
@@ -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)
})
}
+99
View File
@@ -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)
})
}
}
}
+27 -3
View File
@@ -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:
+3 -4
View File
@@ -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 {
+50
View File
@@ -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)
})
}
}
}
+79
View File
@@ -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})
})
}
}
}
+51
View File
@@ -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)
})
}
+29 -4
View File
@@ -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" {
+4 -5
View File
@@ -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))
+73
View File
@@ -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)
})
}
+61
View File
@@ -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)
})
}
+73
View File
@@ -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")
}
}
+213
View File
@@ -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")
}
}
+30 -5
View File
@@ -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{}
+123
View File
@@ -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")
}
}
+148
View File
@@ -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")
}
}
+144
View File
@@ -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")
}
}
+181
View File
@@ -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")
}
}
}
+51
View File
@@ -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
}
+88
View File
@@ -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")
}
}
+172
View File
@@ -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
}
+74
View File
@@ -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")
}
}
+164
View File
@@ -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
}
+42
View File
@@ -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" }
+183
View File
@@ -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
}
+42
View File
@@ -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"`
}
+9 -1
View File
@@ -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)
}
+11
View File
@@ -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)
}
}
}
+46
View File
@@ -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"`
}
+19
View File
@@ -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