fix: close business flow regressions
This commit is contained in:
219
audit/BUSINESS_FLOW_TEST_REPORT.md
Normal file
219
audit/BUSINESS_FLOW_TEST_REPORT.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# 全项目业务流程测试报告
|
||||||
|
|
||||||
|
测试日期:2026-07-31
|
||||||
|
测试分支:`main`
|
||||||
|
测试环境:仓库当前远程开发 PostgreSQL / Redis、Mock 验证码、Mock 支付边界
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
本轮门禁通过:
|
||||||
|
|
||||||
|
- 核心成功路径全部通过。
|
||||||
|
- 本轮发现的 P0:0 个。
|
||||||
|
- 本轮发现并修复的 P1:5 类。
|
||||||
|
- 修复后平台、气站、配送三套后台的 91 个鉴权及资源列表 HTTP 检查全部通过。
|
||||||
|
- 用户注册、服务关系、商城、钱包和提现闭环的 56 个流程断言全部通过。
|
||||||
|
- 三套 Web 已用真实浏览器完成登录和关键页面回归。
|
||||||
|
- 两个 Flutter 客户端的静态分析及现有测试全部通过。
|
||||||
|
|
||||||
|
当前没有阻止提交和推送的未修复 P0/P1。
|
||||||
|
|
||||||
|
## 2. 测试范围与数据隔离
|
||||||
|
|
||||||
|
测试以 `BFT20260731...` 为唯一前缀创建气站、配送点、后台账号、用户、地址、商品、钱包、银行卡、商城订单和提现申请。未使用或修改既有业务主数据。
|
||||||
|
|
||||||
|
测试结束后:
|
||||||
|
|
||||||
|
- 已驳回仍处于待处理状态的并发测试提现,恢复其预扣余额。
|
||||||
|
- 已归档本轮创建的气站、配送点、后台账号、用户、地址、服务关系、商品和分类等可变主数据。
|
||||||
|
- 钱包流水、提现申请、审核记录等不可变资金与审计证据按约定保留,并可通过测试前缀追踪。
|
||||||
|
- Redis 验证码使用短 TTL,未修改既有键;本轮测试键会自动过期。
|
||||||
|
|
||||||
|
## 3. 核心流程结果
|
||||||
|
|
||||||
|
### 3.1 鉴权与数据域
|
||||||
|
|
||||||
|
- 平台 root 正确密码登录成功,错误密码被拒绝。
|
||||||
|
- 未携带令牌访问受保护接口被拒绝。
|
||||||
|
- 气站管理员、配送管理员分别登录成功。
|
||||||
|
- 气站令牌访问平台后台、用户端接口均被拒绝。
|
||||||
|
- 用户完成验证码注册、密码登录、资料及地址查询。
|
||||||
|
- 用户注册时正确建立气站和配送点服务关系。
|
||||||
|
|
||||||
|
### 3.2 商城与幂等
|
||||||
|
|
||||||
|
- 创建并启用商城分类和商品。
|
||||||
|
- 用户按服务端价格创建 80 元订单。
|
||||||
|
- 相同 `request_no` 重复创建只返回同一订单,未重复扣减库存。
|
||||||
|
- 钱包支付成功后订单进入已支付状态。
|
||||||
|
- 已支付订单重复支付被拒绝。
|
||||||
|
|
||||||
|
### 3.3 钱包资金闭环
|
||||||
|
|
||||||
|
以人工充值 100 元且可提现为起点:
|
||||||
|
|
||||||
|
1. 充值后:`balance=10000`、`withdrawal_balance=10000`。
|
||||||
|
2. 重复充值请求保持幂等,未重复入账。
|
||||||
|
3. 商城消费 80 元后:`balance=2000`、`withdrawal_balance=2000`。
|
||||||
|
4. 再申请提现 100 元被拒绝,余额保持不变。
|
||||||
|
5. 申请提现 10 元后立即预扣:两类余额均从 2000 降至 1000。
|
||||||
|
6. 重复提交同一提现申请未重复预扣。
|
||||||
|
7. 平台驳回后两类余额均恢复至 2000。
|
||||||
|
8. 重复驳回未重复返还,余额没有凭空增加。
|
||||||
|
9. 未审核提现直接完成被拒绝。
|
||||||
|
10. 审核通过并完成提现 10 元后:`balance=1000`、`withdrawal_balance=1000`。
|
||||||
|
11. 相同交易号重复完成保持幂等。
|
||||||
|
12. 两个并发 15 元提现请求面对 20 元余额时仅一个成功,最终余额为 5 元,没有透支。
|
||||||
|
|
||||||
|
以上结果确认此前的重复支出、驳回增发和完成不扣总余额问题已闭环。
|
||||||
|
|
||||||
|
### 3.4 全资源 HTTP 扫描
|
||||||
|
|
||||||
|
修复后独立执行 91 个回归检查,结果 `91/91`:
|
||||||
|
|
||||||
|
- 平台总后台:47 个资源列表。
|
||||||
|
- 气站后台:20 个资源列表。
|
||||||
|
- 配送后台:18 个资源列表。
|
||||||
|
- 另含三端登录、平台错误密码和未认证访问检查。
|
||||||
|
|
||||||
|
所有接口均返回预期业务码,没有 404、500、跨端越权或 SQL 错误。
|
||||||
|
|
||||||
|
## 4. 发现并修复的问题
|
||||||
|
|
||||||
|
### P1-1 远程库迁移被历史非空数据阻断
|
||||||
|
|
||||||
|
`gasorder_track_point.received_at` 新增为非空字段时,GORM 直接执行 `ADD NOT NULL`,已有轨迹行导致迁移失败。
|
||||||
|
|
||||||
|
修复:
|
||||||
|
|
||||||
|
- 迁移前先添加可空字段。
|
||||||
|
- 使用既有 `occurred_at`,再退化到 `created_at` / 当前时间回填。
|
||||||
|
- 回填完成后再设置非空。
|
||||||
|
- PostgreSQL 和 MySQL 分别使用对应 SQL。
|
||||||
|
- 已在远程开发库实际迁移成功。
|
||||||
|
|
||||||
|
### P1-2 联表资源查询的 `status` 字段歧义
|
||||||
|
|
||||||
|
气站后台的配送账号、人员资质等联表资源使用裸 `status <> 3`,PostgreSQL 返回 `column reference "status" is ambiguous`。
|
||||||
|
|
||||||
|
修复:
|
||||||
|
|
||||||
|
- `ActiveRecords` 统一使用 GORM 当前表限定 `"<table>"."status"`。
|
||||||
|
- 气站配送账号查询抽取明确的数据域查询函数。
|
||||||
|
- 增加联表 SQL 回归测试。
|
||||||
|
- 修复后气站和配送全部资源列表通过。
|
||||||
|
|
||||||
|
### P1-3 配送钱包充值页面契约存在但 GET 路由缺失
|
||||||
|
|
||||||
|
配送前端声明 `/wallet_recharge` 资源页,后端仅有 POST 充值动作,列表页请求 GET 时直接 404。
|
||||||
|
|
||||||
|
修复:
|
||||||
|
|
||||||
|
- 增加配送点范围内的充值流水列表和详情接口。
|
||||||
|
- 仅返回当前配送点钱包中 `delivery_admin_recharge` 类型流水。
|
||||||
|
- 同步配送前端生成契约。
|
||||||
|
- 增加路由回归断言。
|
||||||
|
|
||||||
|
### P1-4 气站、配送后台登录令牌写入与读取使用不同键
|
||||||
|
|
||||||
|
登录 Store 将令牌写入 `token`,HTTP 客户端却读取 `gas_admin_token` / `delivery_admin_token`。表现为提示“登录成功”后资料请求 401,并退回登录页。
|
||||||
|
|
||||||
|
修复:
|
||||||
|
|
||||||
|
- HTTP 客户端统一调用各端 `getToken()`。
|
||||||
|
- 气站端固定使用 `gas_admin_token`。
|
||||||
|
- 配送端固定使用 `delivery_admin_token`。
|
||||||
|
- 浏览器回归确认两端均能进入运营首页。
|
||||||
|
|
||||||
|
### P1-5 隐藏关联页被前端权限守卫错误送到 404
|
||||||
|
|
||||||
|
`hideInMenu` 的资质、地址、关联明细页复用已授权 `menuCode`,但权限守卫仍强制要求路由名存在于服务端菜单树。
|
||||||
|
|
||||||
|
修复:
|
||||||
|
|
||||||
|
- 三套后台统一允许“服务端菜单路由存在”或“当前路由 `menuCode` 已授权”。
|
||||||
|
- 后端 JWT、角色和数据域校验保持不变。
|
||||||
|
- 浏览器回归确认气站人员资质隐藏页可访问。
|
||||||
|
|
||||||
|
## 5. Web 浏览器验证
|
||||||
|
|
||||||
|
使用本地真实前端和远程开发 API:
|
||||||
|
|
||||||
|
- 平台后台 `5173`:root 登录、运营仪表盘数据、提现列表加载通过。
|
||||||
|
- 气站后台 `5175`:隔离气站管理员登录、首页数据、人员资质隐藏页加载通过。
|
||||||
|
- 配送后台 `5176`:隔离配送管理员登录、首页、钱包充值页加载通过。
|
||||||
|
- 关键页面控制台无 error / warning。
|
||||||
|
|
||||||
|
## 6. Flutter 客户端
|
||||||
|
|
||||||
|
### 用户 App
|
||||||
|
|
||||||
|
- `flutter analyze`:通过,无问题。
|
||||||
|
- `flutter test`:2 项通过。
|
||||||
|
|
||||||
|
### 服务 App
|
||||||
|
|
||||||
|
- `flutter analyze`:通过,无问题。
|
||||||
|
- `flutter test`:3 项通过。
|
||||||
|
|
||||||
|
设备级运行未覆盖,原因:
|
||||||
|
|
||||||
|
- 两个工程当前只包含 Android / iOS 目录,没有 macOS / Web 平台。
|
||||||
|
- 本机没有 Android SDK。
|
||||||
|
- Flutter 报两个工程未配置为可构建的 iOS 应用。
|
||||||
|
- 当前可见设备只有 macOS 和 Chrome,无法承载现有工程。
|
||||||
|
|
||||||
|
因此客户端结论限定为“静态分析和自动化测试通过”,不声明真机/模拟器运行通过。
|
||||||
|
|
||||||
|
## 7. Worker、IoT 与外部边界
|
||||||
|
|
||||||
|
- Worker:`go test ./...`、构建通过;当前为 Mock 边界。
|
||||||
|
- IoT:`go test ./...`、构建通过;未连接真实 MQTT Broker 或设备。
|
||||||
|
- 未连接真实短信、支付渠道和 MQTT。
|
||||||
|
- 验证码与支付只验证当前 Mock 实现的状态机、幂等和资金记账。
|
||||||
|
|
||||||
|
## 8. 实际执行的回归命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/api
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./cmd/main/main.go
|
||||||
|
|
||||||
|
cd backend/worker
|
||||||
|
go test ./...
|
||||||
|
go build ./cmd/main/main.go
|
||||||
|
|
||||||
|
cd backend/iot
|
||||||
|
go test ./...
|
||||||
|
go build ./cmd/main/main.go
|
||||||
|
|
||||||
|
cd frontend/platform_admin
|
||||||
|
pnpm type:check
|
||||||
|
pnpm lint
|
||||||
|
pnpm contract:check
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
cd frontend/gas_admin
|
||||||
|
pnpm type:check
|
||||||
|
pnpm lint
|
||||||
|
pnpm contract:check
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
cd frontend/delivery_admin
|
||||||
|
pnpm contract:sync
|
||||||
|
pnpm type:check
|
||||||
|
pnpm lint
|
||||||
|
pnpm contract:check
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
cd apps/user_app
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
|
||||||
|
cd apps/service_app
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
补充说明:三套前端 lint 命令返回成功,但仍报告工程模板既有的未使用代码、单词组件名和 Node import 风格 warnings;本轮未批量改写这些非业务告警。
|
||||||
@@ -179,11 +179,10 @@ func migrateDatabase() error {
|
|||||||
if config.Spec.Databases == nil {
|
if config.Spec.Databases == nil {
|
||||||
return fmt.Errorf("database configuration is required")
|
return fmt.Errorf("database configuration is required")
|
||||||
}
|
}
|
||||||
var migrationDatabase interface {
|
var migrationDatabase *gorm.DB
|
||||||
Migrator() gorm.Migrator
|
|
||||||
}
|
|
||||||
var err error
|
var err error
|
||||||
switch strings.ToLower(config.Spec.Databases.Driver) {
|
driver := strings.ToLower(config.Spec.Databases.Driver)
|
||||||
|
switch driver {
|
||||||
case "postgres":
|
case "postgres":
|
||||||
migrationDatabase, err = database.NewPostgres(config.Spec.Databases.Source, nil)
|
migrationDatabase, err = database.NewPostgres(config.Spec.Databases.Source, nil)
|
||||||
case "mysql":
|
case "mysql":
|
||||||
@@ -194,6 +193,9 @@ func migrateDatabase() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("connect database before migration: %w", err)
|
return fmt.Errorf("connect database before migration: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := prepareAdditiveMigrations(migrationDatabase, driver); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
const legacyPhoneIndex = "idx_platform_account_phone"
|
const legacyPhoneIndex = "idx_platform_account_phone"
|
||||||
if migrationDatabase.Migrator().HasIndex(&models.PlatformAccount{}, legacyPhoneIndex) {
|
if migrationDatabase.Migrator().HasIndex(&models.PlatformAccount{}, legacyPhoneIndex) {
|
||||||
if err := migrationDatabase.Migrator().DropIndex(&models.PlatformAccount{}, legacyPhoneIndex); err != nil {
|
if err := migrationDatabase.Migrator().DropIndex(&models.PlatformAccount{}, legacyPhoneIndex); err != nil {
|
||||||
@@ -210,3 +212,41 @@ func migrateDatabase() error {
|
|||||||
}
|
}
|
||||||
return initdb.New(databaseService)
|
return initdb.New(databaseService)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prepareAdditiveMigrations 先处理无法由 GORM AutoMigrate 安全完成的新增非空字段。
|
||||||
|
// 旧轨迹没有服务端接收时间时,以定位发生时间(再退化到创建时间)回填,
|
||||||
|
// 避免直接 ADD NOT NULL 因历史行存在而中断整库迁移。
|
||||||
|
func prepareAdditiveMigrations(databaseService *gorm.DB, driver string) error {
|
||||||
|
if !databaseService.Migrator().HasTable(&models.GasorderTrackPoint{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var statements []string
|
||||||
|
hasReceivedAt := databaseService.Migrator().HasColumn(&models.GasorderTrackPoint{}, "received_at")
|
||||||
|
switch driver {
|
||||||
|
case "postgres":
|
||||||
|
if !hasReceivedAt {
|
||||||
|
statements = append(statements, `ALTER TABLE "gasorder_track_point" ADD COLUMN "received_at" timestamptz`)
|
||||||
|
}
|
||||||
|
statements = append(statements,
|
||||||
|
`UPDATE "gasorder_track_point" SET "received_at" = COALESCE("occurred_at", "created_at", CURRENT_TIMESTAMP) WHERE "received_at" IS NULL`,
|
||||||
|
`ALTER TABLE "gasorder_track_point" ALTER COLUMN "received_at" SET NOT NULL`,
|
||||||
|
)
|
||||||
|
case "mysql":
|
||||||
|
if !hasReceivedAt {
|
||||||
|
statements = append(statements, "ALTER TABLE `gasorder_track_point` ADD COLUMN `received_at` datetime(3) NULL")
|
||||||
|
}
|
||||||
|
statements = append(statements,
|
||||||
|
"UPDATE `gasorder_track_point` SET `received_at` = COALESCE(`occurred_at`, `created_at`, CURRENT_TIMESTAMP(3)) WHERE `received_at` IS NULL",
|
||||||
|
"ALTER TABLE `gasorder_track_point` MODIFY COLUMN `received_at` datetime(3) NOT NULL",
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported database driver for additive migrations: %s", driver)
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if err := databaseService.Exec(statement).Error; err != nil {
|
||||||
|
return fmt.Errorf("backfill gasorder_track_point.received_at: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
18
backend/api/cmd/cli/main_test.go
Normal file
18
backend/api/cmd/cli/main_test.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareAdditiveMigrationsSkipsMissingLegacyTable(t *testing.T) {
|
||||||
|
databaseService, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite database: %v", err)
|
||||||
|
}
|
||||||
|
if err := prepareAdditiveMigrations(databaseService, "postgres"); err != nil {
|
||||||
|
t.Fatalf("missing legacy table should not require a backfill: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AccountPasswordMinLength 是账号密码允许的最低字符数。
|
// AccountPasswordMinLength 是账号密码允许的最低字符数。
|
||||||
@@ -79,7 +80,10 @@ func IsGenericRecordStatus(status int) bool {
|
|||||||
|
|
||||||
// ActiveRecords excludes logically archived records from operational queries.
|
// ActiveRecords excludes logically archived records from operational queries.
|
||||||
func ActiveRecords(query *gorm.DB) *gorm.DB {
|
func ActiveRecords(query *gorm.DB) *gorm.DB {
|
||||||
return query.Where("status <> ?", StatusArchived)
|
return query.Where(clause.Neq{
|
||||||
|
Column: clause.Column{Table: clause.CurrentTable, Name: "status"},
|
||||||
|
Value: StatusArchived,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListPage[T any](ctx *gin.Context) {
|
func ListPage[T any](ctx *gin.Context) {
|
||||||
|
|||||||
@@ -31,11 +31,34 @@ func TestOperationalQueriesExcludeArchivedRecords(t *testing.T) {
|
|||||||
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||||
return ActiveRecords(tx.Model(&models.EcProduct{})).Find(&[]models.EcProduct{})
|
return ActiveRecords(tx.Model(&models.EcProduct{})).Find(&[]models.EcProduct{})
|
||||||
})
|
})
|
||||||
if !strings.Contains(statement, `status <> 3`) {
|
if !strings.Contains(statement, `"ec_product"."status" <> 3`) {
|
||||||
t.Fatalf("archive filter missing from operational query: %s", statement)
|
t.Fatalf("archive filter missing from operational query: %s", statement)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOperationalQueriesQualifyStatusAcrossJoins(t *testing.T) {
|
||||||
|
sqlDatabase, _, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sqlDatabase.Close()
|
||||||
|
database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB {
|
||||||
|
return ActiveRecords(tx.Model(&models.StaffCredential{})).
|
||||||
|
Joins("JOIN staff_account ON staff_account.id = staff_credential.staff_account_id").
|
||||||
|
Find(&[]models.StaffCredential{})
|
||||||
|
})
|
||||||
|
if !strings.Contains(statement, `"staff_credential"."status" <> 3`) {
|
||||||
|
t.Fatalf("joined archive filter is not table-qualified: %s", statement)
|
||||||
|
}
|
||||||
|
if strings.Contains(statement, `WHERE status <>`) {
|
||||||
|
t.Fatalf("joined archive filter is ambiguous: %s", statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) {
|
func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) {
|
||||||
got := ResourceResponse(map[string]any{
|
got := ResourceResponse(map[string]any{
|
||||||
"id": uint64(1), "identity": "root",
|
"id": uint64(1), "identity": "root",
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ func ListBank(ctx *gin.Context) { listWalletChild(ctx, &models.WalletBank{},
|
|||||||
func ListPayment(ctx *gin.Context) { listWalletChild(ctx, &models.WalletPayment{}, "wallet_payment") }
|
func ListPayment(ctx *gin.Context) { listWalletChild(ctx, &models.WalletPayment{}, "wallet_payment") }
|
||||||
func ListRecord(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRecord{}, "wallet_record") }
|
func ListRecord(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRecord{}, "wallet_record") }
|
||||||
func ListRefund(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRefund{}, "wallet_refund") }
|
func ListRefund(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRefund{}, "wallet_refund") }
|
||||||
|
func ListRecharge(ctx *gin.Context) {
|
||||||
|
point, _, ok := currentScope(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := common.ActiveRecords(db().Model(&models.WalletRecord{})).
|
||||||
|
Joins("JOIN wallet_basic ON wallet_basic.id = wallet_record.wallet_basic_id").
|
||||||
|
Where("wallet_basic.owner_type = ? AND wallet_basic.owner_id = ? AND wallet_record.trade_type = ?",
|
||||||
|
"delivery", point.ID, "delivery_admin_recharge")
|
||||||
|
listScoped(ctx, &models.WalletRecord{}, query, "wallet_record.created_at desc")
|
||||||
|
}
|
||||||
func ListApplyCash(ctx *gin.Context) {
|
func ListApplyCash(ctx *gin.Context) {
|
||||||
listWalletChild(ctx, &models.WalletApplyCash{}, "wallet_apply_cash")
|
listWalletChild(ctx, &models.WalletApplyCash{}, "wallet_apply_cash")
|
||||||
}
|
}
|
||||||
@@ -79,6 +90,18 @@ func GetBank(ctx *gin.Context) { getWalletChild(ctx, &models.WalletBank{}, "w
|
|||||||
func GetPayment(ctx *gin.Context) { getWalletChild(ctx, &models.WalletPayment{}, "wallet_payment") }
|
func GetPayment(ctx *gin.Context) { getWalletChild(ctx, &models.WalletPayment{}, "wallet_payment") }
|
||||||
func GetRecord(ctx *gin.Context) { getWalletChild(ctx, &models.WalletRecord{}, "wallet_record") }
|
func GetRecord(ctx *gin.Context) { getWalletChild(ctx, &models.WalletRecord{}, "wallet_record") }
|
||||||
func GetRefund(ctx *gin.Context) { getWalletChild(ctx, &models.WalletRefund{}, "wallet_refund") }
|
func GetRefund(ctx *gin.Context) { getWalletChild(ctx, &models.WalletRefund{}, "wallet_refund") }
|
||||||
|
func GetRecharge(ctx *gin.Context) {
|
||||||
|
point, _, ok := currentScope(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var record models.WalletRecord
|
||||||
|
query := common.ActiveRecords(db().Model(&models.WalletRecord{})).
|
||||||
|
Joins("JOIN wallet_basic ON wallet_basic.id = wallet_record.wallet_basic_id").
|
||||||
|
Where("wallet_record.identity = ? AND wallet_basic.owner_type = ? AND wallet_basic.owner_id = ? AND wallet_record.trade_type = ?",
|
||||||
|
ctx.Param("identity"), "delivery", point.ID, "delivery_admin_recharge")
|
||||||
|
respondRecord(ctx, query, &record)
|
||||||
|
}
|
||||||
func GetApplyCash(ctx *gin.Context) {
|
func GetApplyCash(ctx *gin.Context) {
|
||||||
getWalletChild(ctx, &models.WalletApplyCash{}, "wallet_apply_cash")
|
getWalletChild(ctx, &models.WalletApplyCash{}, "wallet_apply_cash")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ import (
|
|||||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func scopedDeliveryAccounts(databaseService *gorm.DB, gasBasicID uint64) *gorm.DB {
|
||||||
|
return databaseService.Model(&models.DeliveryAccount{}).
|
||||||
|
Joins("JOIN delivery_basic ON delivery_basic.id = delivery_account.delivery_basic_id").
|
||||||
|
Where("delivery_account.status <> ? AND delivery_basic.gas_basic_id = ?", common.StatusArchived, gasBasicID)
|
||||||
|
}
|
||||||
|
|
||||||
func ListDeliveryAccount(ctx *gin.Context) {
|
func ListDeliveryAccount(ctx *gin.Context) {
|
||||||
station, ok := currentGas(ctx)
|
station, ok := currentGas(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -18,9 +25,7 @@ func ListDeliveryAccount(ctx *gin.Context) {
|
|||||||
var list []models.DeliveryAccount
|
var list []models.DeliveryAccount
|
||||||
var total int64
|
var total int64
|
||||||
query := common.ApplyKeywordFilter(ctx,
|
query := common.ApplyKeywordFilter(ctx,
|
||||||
common.ActiveRecords(impl.DBService.Model(&models.DeliveryAccount{})).
|
scopedDeliveryAccounts(impl.DBService, station.ID), &models.DeliveryAccount{})
|
||||||
Joins("JOIN delivery_basic ON delivery_basic.id = delivery_account.delivery_basic_id").
|
|
||||||
Where("delivery_basic.gas_basic_id = ?", station.ID), &models.DeliveryAccount{})
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
infra.Response.Error(ctx, err)
|
infra.Response.Error(ctx, err)
|
||||||
return
|
return
|
||||||
@@ -37,9 +42,8 @@ func GetDeliveryAccount(ctx *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
query := common.ActiveRecords(impl.DBService.Model(&models.DeliveryAccount{})).
|
query := scopedDeliveryAccounts(impl.DBService, station.ID).
|
||||||
Joins("JOIN delivery_basic ON delivery_basic.id = delivery_account.delivery_basic_id").
|
Where("delivery_account.identity = ?", ctx.Param("identity"))
|
||||||
Where("delivery_account.identity = ? AND delivery_basic.gas_basic_id = ?", ctx.Param("identity"), station.ID)
|
|
||||||
respondScopedRecord(ctx, query, &models.DeliveryAccount{})
|
respondScopedRecord(ctx, query, &models.DeliveryAccount{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,10 +88,9 @@ func UpdateDeliveryAccount(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var account models.DeliveryAccount
|
var account models.DeliveryAccount
|
||||||
if err := common.ActiveRecords(impl.DBService.Model(&models.DeliveryAccount{})).
|
if err := scopedDeliveryAccounts(impl.DBService, station.ID).
|
||||||
Select("delivery_account.*").
|
Select("delivery_account.*").
|
||||||
Joins("JOIN delivery_basic ON delivery_basic.id = delivery_account.delivery_basic_id").
|
Where("delivery_account.identity = ?", ctx.Param("identity")).
|
||||||
Where("delivery_account.identity = ? AND delivery_basic.gas_basic_id = ?", ctx.Param("identity"), station.ID).
|
|
||||||
First(&account).Error; err != nil {
|
First(&account).Error; err != nil {
|
||||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||||
return
|
return
|
||||||
@@ -108,10 +111,9 @@ func UpdateDeliveryAccountStatus(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var account models.DeliveryAccount
|
var account models.DeliveryAccount
|
||||||
if err := common.ActiveRecords(impl.DBService.Model(&models.DeliveryAccount{})).
|
if err := scopedDeliveryAccounts(impl.DBService, station.ID).
|
||||||
Select("delivery_account.*").
|
Select("delivery_account.*").
|
||||||
Joins("JOIN delivery_basic ON delivery_basic.id = delivery_account.delivery_basic_id").
|
Where("delivery_account.identity = ?", ctx.Param("identity")).
|
||||||
Where("delivery_account.identity = ? AND delivery_basic.gas_basic_id = ?", ctx.Param("identity"), station.ID).
|
|
||||||
First(&account).Error; err != nil {
|
First(&account).Error; err != nil {
|
||||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||||
return
|
return
|
||||||
|
|||||||
31
backend/api/internal/logic/gas/delivery_account_test.go
Normal file
31
backend/api/internal/logic/gas/delivery_account_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package gas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScopedDeliveryAccountsQualifiesJoinedStatusColumn(t *testing.T) {
|
||||||
|
connection, _, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create SQL mock: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = connection.Close() })
|
||||||
|
databaseService, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{DryRun: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open GORM database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := scopedDeliveryAccounts(databaseService, 42).
|
||||||
|
Find(&[]struct{}{}).Statement.SQL.String()
|
||||||
|
if !strings.Contains(statement, "delivery_account.status <>") {
|
||||||
|
t.Fatalf("joined query must qualify delivery account status, got: %s", statement)
|
||||||
|
}
|
||||||
|
if strings.Contains(statement, "WHERE status <>") {
|
||||||
|
t.Fatalf("joined query contains ambiguous status column: %s", statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,8 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) {
|
|||||||
protected.GET("/wallet_basic", deliverylogic.ListWallet)
|
protected.GET("/wallet_basic", deliverylogic.ListWallet)
|
||||||
protected.GET("/wallet_basic/:identity", deliverylogic.GetWallet)
|
protected.GET("/wallet_basic/:identity", deliverylogic.GetWallet)
|
||||||
protected.POST("/wallet_basic/recharge", deliverylogic.Recharge)
|
protected.POST("/wallet_basic/recharge", deliverylogic.Recharge)
|
||||||
|
protected.GET("/wallet_recharge", deliverylogic.ListRecharge)
|
||||||
|
protected.GET("/wallet_recharge/:identity", deliverylogic.GetRecharge)
|
||||||
protected.POST("/wallet_recharge", deliverylogic.Recharge)
|
protected.POST("/wallet_recharge", deliverylogic.Recharge)
|
||||||
protected.GET("/wallet_bank", deliverylogic.ListBank)
|
protected.GET("/wallet_bank", deliverylogic.ListBank)
|
||||||
protected.GET("/wallet_bank/:identity", deliverylogic.GetBank)
|
protected.GET("/wallet_bank/:identity", deliverylogic.GetBank)
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ func TestDeliveryOrderActionBoundary(t *testing.T) {
|
|||||||
"POST /heqi/delivery/v1/gasorder_basic/:identity/assign",
|
"POST /heqi/delivery/v1/gasorder_basic/:identity/assign",
|
||||||
"POST /heqi/delivery/v1/gasorder_basic/:identity/reclaim",
|
"POST /heqi/delivery/v1/gasorder_basic/:identity/reclaim",
|
||||||
"POST /heqi/delivery/v1/gasorder_basic/:identity/adjust-amount",
|
"POST /heqi/delivery/v1/gasorder_basic/:identity/adjust-amount",
|
||||||
|
"GET /heqi/delivery/v1/wallet_recharge",
|
||||||
|
"GET /heqi/delivery/v1/wallet_recharge/:identity",
|
||||||
} {
|
} {
|
||||||
if !routes[required] {
|
if !routes[required] {
|
||||||
t.Fatalf("missing confirmed delivery action %s", required)
|
t.Fatalf("missing confirmed delivery action %s", required)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/** 配送点后台 HTTP 客户端,使用独立 API 前缀和 JWT 存储键。 */
|
/** 配送点后台 HTTP 客户端,使用独立 API 前缀和 JWT 存储键。 */
|
||||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/delivery/v1';
|
import { getToken } from '@/utils/auth';
|
||||||
|
|
||||||
export const tokenStorageKey = 'delivery_admin_token';
|
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/delivery/v1';
|
||||||
|
|
||||||
export type PageResult<T> = { total: number; list: T[] };
|
export type PageResult<T> = { total: number; list: T[] };
|
||||||
|
|
||||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const token = localStorage.getItem(tokenStorageKey);
|
const token = getToken();
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${apiBaseURL}${path}`, {
|
response = await fetch(`${apiBaseURL}${path}`, {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -37,7 +37,10 @@ export default function setupPermissionGuard(router: Router) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (exist && permissionsAllow) {
|
const menuCode = to.meta.menuCode;
|
||||||
|
const grantedByMenuCode =
|
||||||
|
typeof menuCode === 'string' && userStore.menuCodes.includes(menuCode);
|
||||||
|
if ((exist || grantedByMenuCode) && permissionsAllow) {
|
||||||
next();
|
next();
|
||||||
} else next(NOT_FOUND);
|
} else next(NOT_FOUND);
|
||||||
} else if (permissionsAllow) {
|
} else if (permissionsAllow) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const TOKEN_KEY = 'token';
|
const TOKEN_KEY = 'delivery_admin_token';
|
||||||
|
|
||||||
const isLogin = () => {
|
const isLogin = () => {
|
||||||
return !!localStorage.getItem(TOKEN_KEY);
|
return !!localStorage.getItem(TOKEN_KEY);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/** 气站后台 HTTP 客户端,使用独立 API 前缀和 JWT 存储键。 */
|
/** 气站后台 HTTP 客户端,使用独立 API 前缀和 JWT 存储键。 */
|
||||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/gas/v1';
|
import { getToken } from '@/utils/auth';
|
||||||
|
|
||||||
export const tokenStorageKey = 'gas_admin_token';
|
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/gas/v1';
|
||||||
|
|
||||||
export type PageResult<T> = { total: number; list: T[] };
|
export type PageResult<T> = { total: number; list: T[] };
|
||||||
|
|
||||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const token = localStorage.getItem(tokenStorageKey);
|
const token = getToken();
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${apiBaseURL}${path}`, {
|
response = await fetch(`${apiBaseURL}${path}`, {
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export default function setupPermissionGuard(router: Router) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (exist && permissionsAllow) {
|
const menuCode = to.meta.menuCode;
|
||||||
|
const grantedByMenuCode =
|
||||||
|
typeof menuCode === 'string' && userStore.menuCodes.includes(menuCode);
|
||||||
|
if ((exist || grantedByMenuCode) && permissionsAllow) {
|
||||||
next();
|
next();
|
||||||
} else next(NOT_FOUND);
|
} else next(NOT_FOUND);
|
||||||
} else if (permissionsAllow) {
|
} else if (permissionsAllow) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const TOKEN_KEY = 'token';
|
const TOKEN_KEY = 'gas_admin_token';
|
||||||
|
|
||||||
const isLogin = () => {
|
const isLogin = () => {
|
||||||
return !!localStorage.getItem(TOKEN_KEY);
|
return !!localStorage.getItem(TOKEN_KEY);
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export default function setupPermissionGuard(router: Router) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (exist && permissionsAllow) {
|
const menuCode = to.meta.menuCode;
|
||||||
|
const grantedByMenuCode =
|
||||||
|
typeof menuCode === 'string' && userStore.menuCodes.includes(menuCode);
|
||||||
|
if ((exist || grantedByMenuCode) && permissionsAllow) {
|
||||||
next();
|
next();
|
||||||
} else next(NOT_FOUND);
|
} else next(NOT_FOUND);
|
||||||
} else if (permissionsAllow) {
|
} else if (permissionsAllow) {
|
||||||
|
|||||||
Reference in New Issue
Block a user